# ========================================================= # MAX Windows Toolkit - 软件安装器 # 中文界面,统一风格 # 支持:静默安装、SHA256 校验、配置文件、DRY-RUN # ========================================================= $Common = "C:\Setup\windows\lib\common.ps1" if (Test-Path $Common) { . $Common } Write-Header "软件安装器" Show-AdminWarn # --------------------------------------------------------- # 基本配置 # --------------------------------------------------------- $AppsDir = "$Global:LocalRoot\apps" $AppBaseUrl = if ($Global:AppBaseUrl) { $Global:AppBaseUrl } else { "$Global:RepoBase/apps" } $AppsCatalogUrl = "$AppBaseUrl/apps.json" $ProfilesBaseUrl = "https://dl.unvmax.com/windows/profiles" $Global:DryRunMode = $false $Global:AppsCatalog = $null # RustDesk 服务器配置(默认配置) $RustDeskConfigString = "Qfi0za0UXeMJXT21WZINVYYFzTohDNiBVe4sENX90QIlWTrdlUtdFRoB1QiVHaiojI5V2aiwiI0ETMxIjOt92YugXYtZnb19yL6AHd0hmI6ISawFmIsISbvNmL4FWb25WdiojI5FGblJnIsISbvNmL4FWb25WdiojI0N3boJye" try { New-Item -ItemType Directory -Path $AppsDir -Force -ErrorAction Stop | Out-Null } catch {} # --------------------------------------------------------- # 软件列表 # --------------------------------------------------------- $SoftwareList = @( @{ Id = 1; Name = "7-Zip"; FileName = "7zip-Setup.exe"; Type = "exe"; Arguments = "/S"; KillProcesses = @("7zFM"); TimeoutSeconds = 180 } @{ Id = 2; Name = "Google Chrome"; FileName = "Chrome-Setup.exe"; Type = "exe"; Arguments = "/silent /install"; KillProcesses = @("chrome", "GoogleUpdate"); TimeoutSeconds = 300 } @{ Id = 3; Name = "WPS Office"; FileName = "WPS-Setup.exe"; Type = "exe"; Arguments = "/S"; KillProcesses = @("wps", "wpp", "et", "ksolaunch", "wpscloudsvr", "wpscenter", "wpsnotify"); TimeoutSeconds = 900 } @{ Id = 4; Name = "搜狗拼音输入法"; FileName = "SogouInput-Setup.exe"; Type = "exe"; Arguments = "/S"; KillProcesses = @("SogouCloud", "SogouImeBroker", "SogouIme", "SogouInput", "SogouExe", "SogouTaskManager"); TimeoutSeconds = 600 } @{ Id = 5; Name = "Listary"; FileName = "Listary-Setup.exe"; Type = "exe"; Arguments = "/VERYSILENT /NORESTART /SUPPRESSMSGBOXES"; KillProcesses = @("Listary"); TimeoutSeconds = 300 } @{ Id = 6; Name = "微信"; FileName = "WeChat-Setup.exe"; Type = "exe"; Arguments = "/S"; KillProcesses = @("WeChat", "WeChatAppEx", "WeChatOCR", "WeChatBrowser"); TimeoutSeconds = 600 } @{ Id = 7; Name = "QQ"; FileName = "QQ-Setup.exe"; Type = "exe"; Arguments = "/S"; KillProcesses = @("QQ", "QQProtect", "TIM", "QQGuild", "QQExternal"); TimeoutSeconds = 600 } @{ Id = 8; Name = "Adobe Acrobat DC"; FileName = "Adobe Acrobat DC.zip"; Type = "zip-setup"; ExtractDir = "AdobeAcrobatDC"; SetupFile = "Setup.exe"; Arguments = "/sAll /rs /rps /msi EULA_ACCEPT=YES"; KillProcesses = @("Acrobat", "AcroRd32", "AdobeARM", "AdobeCollabSync", "AdobeIPCBroker"); TimeoutSeconds = 1800 } @{ Id = 9; Name = "公文助手"; FileName = "GongwenAssistant.exe"; Type = "exe"; Arguments = ""; KillProcesses = @("GongwenAssistant", "Gongwen", "kwgw", "wps", "wpp", "et"); TimeoutSeconds = 900; Dependencies = @( @{ Name = ".NET Framework 4.8"; FileName = "ndp48-x86-x64-allos-enu.exe"; Type = "exe"; Arguments = "/q /norestart"; KillProcesses = @(); TimeoutSeconds = 1800 } @{ Name = "VSTO Runtime"; FileName = "vstor_redist.exe"; Type = "exe"; Arguments = "/quiet /norestart"; KillProcesses = @(); TimeoutSeconds = 900 } )} @{ Id = 10; Name = "Everything"; FileName = "Everything-Setup.exe"; Type = "exe"; Arguments = "/S"; KillProcesses = @("Everything"); TimeoutSeconds = 300 } @{ Id = 11; Name = "PotPlayer"; FileName = "PotPlayer-Setup.exe"; Type = "exe"; Arguments = "/S"; KillProcesses = @("PotPlayerMini64", "PotPlayerMini", "PotPlayer"); TimeoutSeconds = 600 } @{ Id = 12; Name = "RustDesk"; FileName = "rustdesk-Setup.exe"; Type = "exe"; Arguments = "--silent-install"; KillProcesses = @("rustdesk"); TimeoutSeconds = 600; PostInstall = "ImportRustDeskConfig" } @{ Id = 13; Name = "V2RayN 便携版"; FileName = "V2RayN.zip"; Type = "portable-zip"; ExtractDir = "V2RayN"; MainExe = "v2rayN.exe"; CreateDesktopShortcut = $true; KillProcesses = @("v2rayN"); TimeoutSeconds = 600 } ) # --------------------------------------------------------- # 辅助函数 # --------------------------------------------------------- function Get-AppsCatalog { if ($null -ne $Global:AppsCatalog) { return $Global:AppsCatalog } try { Write-Info "正在加载应用目录: $AppsCatalogUrl" $Json = (New-Object System.Net.WebClient).DownloadString($AppsCatalogUrl) $Global:AppsCatalog = $Json | ConvertFrom-Json return $Global:AppsCatalog } catch { Write-Warn "应用目录不可用,将跳过哈希校验" Write-Muted $_.Exception.Message return $null } } function Get-AppCatalogEntry { param([string]$FileName) $Catalog = Get-AppsCatalog if ($null -eq $Catalog -or $null -eq $Catalog.apps) { return $null } foreach ($Item in $Catalog.apps) { if ($Item.file -eq $FileName) { return $Item } } return $null } function Test-AppFileHash { param([string]$Path, [string]$FileName) $Entry = Get-AppCatalogEntry -FileName $FileName if ($null -eq $Entry -or [string]::IsNullOrWhiteSpace($Entry.sha256)) { Write-Warn "目录中无 SHA256 记录: $FileName,跳过哈希校验" return $true } try { $Actual = (Get-FileHash -Algorithm SHA256 -Path $Path).Hash.ToLowerInvariant() $Expected = $Entry.sha256.ToLowerInvariant() if ($Actual -eq $Expected) { Write-OK "SHA256 校验通过: $Actual" return $true } Write-Err "SHA256 校验失败: $FileName" Write-Muted "期望: $Expected" Write-Muted "实际: $Actual" return $false } catch { Write-Err "无法校验 SHA256: $($_.Exception.Message)" return $false } } function Load-InstallProfile { param([string]$ProfileId) $SafeId = $ProfileId.Trim().ToLowerInvariant() if ($SafeId -notmatch '^[a-z0-9-]+$') { Write-Err "无效的配置文件 ID: $ProfileId" return $null } $ProfileUrl = "$ProfilesBaseUrl/$SafeId.json" try { Write-Info "正在加载配置文件: $ProfileUrl" $Json = (New-Object System.Net.WebClient).DownloadString($ProfileUrl) return $Json | ConvertFrom-Json } catch { Write-Err "无法加载配置文件: $SafeId" Write-Muted $_.Exception.Message return $null } } function Show-InstallProfiles { Write-Info "" Write-Info "可用配置文件:" Write-Info " office 办公电脑配置" Write-Info " admin 管理员工作站配置" Write-Info " remote-support RustDesk 远程支持配置" Write-Info " developer 开发人员工作站配置" } function Install-ProfileApps { param([string]$ProfileId) $Profile = Load-InstallProfile -ProfileId $ProfileId if ($null -eq $Profile) { return } Write-Section "配置文件: $($Profile.name) — $($Profile.description)" if ($Profile.apps) { $Ids = @($Profile.apps | ForEach-Object { [int]$_ }) Write-Info "软件列表: $($Ids -join ', ')" Install-SelectedApps -Ids $Ids } else { Write-Warn "配置文件中未定义软件" } if ($Profile.scripts) { Write-Info "" Write-Info "配置推荐的工具脚本:" foreach ($Script in $Profile.scripts) { Write-Muted "- $Script" } Write-Info "需要时请从 MAX Windows Toolkit 主菜单运行" } } function Get-EncodedFileUrl { param([string]$BaseUrl, [string]$FileName) $EncodedFileName = [System.Uri]::EscapeDataString($FileName) return "$BaseUrl/$EncodedFileName" } function Download-AppFile { param([string]$Url, [string]$OutFile) try { $OutDir = Split-Path $OutFile -Parent New-Item -ItemType Directory -Path $OutDir -Force | Out-Null Write-Info "正在下载: $Url" Write-Muted "保存至: $OutFile" Invoke-WebRequest -Uri $Url -OutFile $OutFile -UseBasicParsing -TimeoutSec 120 -ErrorAction Stop if ((Test-Path $OutFile) -and ((Get-Item $OutFile).Length -gt 0)) { $SizeMB = [Math]::Round((Get-Item $OutFile).Length / 1MB, 2) Write-OK "下载完成: $SizeMB MB" return $true } Write-Err "下载的文件为空" return $false } catch { Write-Err "下载失败" Write-Muted $_.Exception.Message return $false } } function Get-SetupFileFromExtractedFolder { param([string]$ExtractPath, [string]$SetupFile) $Candidate = Join-Path $ExtractPath $SetupFile if (Test-Path $Candidate) { return $Candidate } $Found = Get-ChildItem -Path $ExtractPath -Filter $SetupFile -Recurse -File -ErrorAction SilentlyContinue | Select-Object -First 1 if ($Found) { return $Found.FullName } return $null } function Get-ExecutableFromExtractedFolder { param([string]$ExtractPath, [string]$MainExe) if (-not [string]::IsNullOrWhiteSpace($MainExe)) { $Candidate = Join-Path $ExtractPath $MainExe if (Test-Path $Candidate) { return $Candidate } $Found = Get-ChildItem -Path $ExtractPath -Filter $MainExe -Recurse -File -ErrorAction SilentlyContinue | Select-Object -First 1 if ($Found) { return $Found.FullName } } $FirstExe = Get-ChildItem -Path $ExtractPath -Filter "*.exe" -Recurse -File -ErrorAction SilentlyContinue | Select-Object -First 1 if ($FirstExe) { return $FirstExe.FullName } return $null } function Create-DesktopShortcut { param([string]$ShortcutName, [string]$TargetPath, [string]$WorkingDirectory) try { $Desktop = [Environment]::GetFolderPath("Desktop") $ShortcutPath = Join-Path $Desktop "$ShortcutName.lnk" $Shell = New-Object -ComObject WScript.Shell $Shortcut = $Shell.CreateShortcut($ShortcutPath) $Shortcut.TargetPath = $TargetPath $Shortcut.WorkingDirectory = $WorkingDirectory $Shortcut.Save() Write-OK "桌面快捷方式已创建: $ShortcutPath" } catch { Write-Warn "创建桌面快捷方式失败" Write-Muted $_.Exception.Message } } function Stop-AppProcesses { param([string[]]$ProcessNames) if (-not $ProcessNames -or $ProcessNames.Count -eq 0) { return } foreach ($Name in $ProcessNames) { if ([string]::IsNullOrWhiteSpace($Name)) { continue } Get-Process -Name $Name -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue } } function Get-RustDeskExePath { $Candidates = @( "C:\Program Files\RustDesk\rustdesk.exe", "C:\Program Files (x86)\RustDesk\rustdesk.exe", "$env:ProgramFiles\RustDesk\rustdesk.exe" ) if (${env:ProgramFiles(x86)}) { $Candidates += "${env:ProgramFiles(x86)}\RustDesk\rustdesk.exe" } foreach ($Path in $Candidates) { if (-not [string]::IsNullOrWhiteSpace($Path) -and (Test-Path $Path)) { return $Path } } $SearchRoots = @("C:\Program Files", "C:\Program Files (x86)") foreach ($Root in $SearchRoots) { if (Test-Path $Root) { $Found = Get-ChildItem -Path $Root -Filter "rustdesk.exe" -Recurse -File -ErrorAction SilentlyContinue | Select-Object -First 1 if ($Found) { return $Found.FullName } } } return $null } function Import-RustDeskServerConfig { param([string]$ConfigString) if ([string]::IsNullOrWhiteSpace($ConfigString)) { Write-Warn "RustDesk 配置字符串为空,跳过服务器配置导入" return } $ConfigString = $ConfigString.Trim().TrimStart("=") if ([string]::IsNullOrWhiteSpace($ConfigString)) { Write-Err "RustDesk 配置字符串清理后无效,跳过导入" return } Write-Info "正在导入 RustDesk 服务器配置..." Start-Sleep -Seconds 8 $RustDeskExe = Get-RustDeskExePath if ([string]::IsNullOrWhiteSpace($RustDeskExe)) { Write-Err "未找到 rustdesk.exe,跳过服务器配置导入" return } Write-Muted "RustDesk 路径: $RustDeskExe" try { Get-Process -Name "rustdesk" -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue Start-Sleep -Seconds 2 Write-Muted "执行: rustdesk.exe --config <隐藏配置>" $Process = Start-Process -FilePath $RustDeskExe -ArgumentList @("--config", $ConfigString) -PassThru -WindowStyle Hidden $Exited = $Process.WaitForExit(15000) if ($Exited) { Write-OK "RustDesk 服务器配置导入完成,退出代码: $($Process.ExitCode)" } else { Write-Warn "RustDesk 配置导入命令超时,继续执行..." } Start-Sleep -Seconds 2 Get-Process -Name "rustdesk" -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue } catch { Write-Err "导入 RustDesk 服务器配置失败" Write-Muted $_.Exception.Message } } function Show-SoftwareList { Write-Info "" Write-Info "可用软件:" foreach ($App in $SoftwareList) { $Type = if ([string]::IsNullOrWhiteSpace($App.Type)) { "exe" } else { $App.Type } if ($App.ContainsKey("Dependencies") -and $App.Dependencies) { Write-Info "$($App.Id). $($App.Name) [$Type + 依赖]" } else { Write-Info "$($App.Id). $($App.Name) [$Type]" } } } function Install-App { param([hashtable]$App, [switch]$SkipDependencies) $Name = $App.Name $FileName = $App.FileName $Arguments = $App.Arguments $TimeoutSeconds = if ($App.TimeoutSeconds -and $App.TimeoutSeconds -gt 0) { $App.TimeoutSeconds } else { 600 } $KillList = $App.KillProcesses $Type = if ([string]::IsNullOrWhiteSpace($App.Type)) { "exe" } else { $App.Type } # 安装依赖 if (-not $SkipDependencies -and $App.ContainsKey("Dependencies") -and $App.Dependencies) { Write-Section "正在安装 $Name 的依赖组件" foreach ($Dependency in $App.Dependencies) { Install-App -App $Dependency -SkipDependencies } Write-OK "$Name 的依赖组件安装完成" } $Url = Get-EncodedFileUrl -BaseUrl $AppBaseUrl -FileName $FileName $Installer = Join-Path $AppsDir $FileName Write-Section "正在安装: $Name" Write-Muted "文件: $FileName" Write-Muted "类型: $Type" Write-Muted "参数: $Arguments" Write-Muted "超时: $TimeoutSeconds 秒" Write-Muted "URL : $Url" if ($Global:DryRunMode) { Write-Warn "DRY-RUN 模式:跳过下载和安装" return } if (-not (Download-AppFile -Url $Url -OutFile $Installer)) { Write-Err "$Name 安装跳过(下载失败)" return } if (-not (Test-AppFileHash -Path $Installer -FileName $FileName)) { Remove-Item $Installer -Force -ErrorAction SilentlyContinue Write-Err "$Name 安装跳过(SHA256 校验失败)" return } try { Unblock-File -Path $Installer -ErrorAction SilentlyContinue } catch {} try { $RunFile = $Installer $WorkingDirectory = Split-Path $Installer -Parent # ZIP 安装包处理 if ($Type -eq "zip-setup") { $ExtractDirName = if ([string]::IsNullOrWhiteSpace($App.ExtractDir)) { [System.IO.Path]::GetFileNameWithoutExtension($FileName) } else { $App.ExtractDir } $SetupFile = if ([string]::IsNullOrWhiteSpace($App.SetupFile)) { "Setup.exe" } else { $App.SetupFile } $ExtractPath = Join-Path $AppsDir $ExtractDirName if (Test-Path $ExtractPath) { Write-Info "正在删除旧的解压文件夹: $ExtractPath" Remove-Item $ExtractPath -Recurse -Force -ErrorAction SilentlyContinue } New-Item -ItemType Directory -Path $ExtractPath -Force | Out-Null Write-Info "正在解压 ZIP 到: $ExtractPath" Expand-Archive -Path $Installer -DestinationPath $ExtractPath -Force Get-ChildItem -Path $ExtractPath -Recurse -ErrorAction SilentlyContinue | Unblock-File -ErrorAction SilentlyContinue $RunFile = Get-SetupFileFromExtractedFolder -ExtractPath $ExtractPath -SetupFile $SetupFile if ([string]::IsNullOrWhiteSpace($RunFile)) { Write-Err "解压后未找到安装文件: $SetupFile" Write-Err "$Name 安装跳过" return } $WorkingDirectory = Split-Path $RunFile -Parent Write-OK "安装文件已找到: $RunFile" } # 便携版 ZIP 处理 if ($Type -eq "portable-zip") { $ExtractDirName = if ([string]::IsNullOrWhiteSpace($App.ExtractDir)) { [System.IO.Path]::GetFileNameWithoutExtension($FileName) } else { $App.ExtractDir } $ExtractPath = Join-Path $AppsDir $ExtractDirName if (Test-Path $ExtractPath) { Write-Info "正在删除旧的便携版文件夹: $ExtractPath" Remove-Item $ExtractPath -Recurse -Force -ErrorAction SilentlyContinue } New-Item -ItemType Directory -Path $ExtractPath -Force | Out-Null Write-Info "正在解压便携版 ZIP 到: $ExtractPath" Expand-Archive -Path $Installer -DestinationPath $ExtractPath -Force Get-ChildItem -Path $ExtractPath -Recurse -ErrorAction SilentlyContinue | Unblock-File -ErrorAction SilentlyContinue $ExePath = Get-ExecutableFromExtractedFolder -ExtractPath $ExtractPath -MainExe $App.MainExe if ($ExePath) { Write-OK "$Name 解压完成" Write-Muted "主程序: $ExePath" if ($App.CreateDesktopShortcut -eq $true) { Create-DesktopShortcut -ShortcutName $Name -TargetPath $ExePath -WorkingDirectory (Split-Path $ExePath -Parent) } } else { Write-Warn "$Name 已解压,但未找到可执行文件" } return } # 执行安装 Write-Info "正在启动安装程序..." if ($RunFile -like "*.msi") { $MsiArgs = "/i `"$RunFile`" $Arguments" Write-Muted "命令: msiexec.exe $MsiArgs" $Process = Start-Process msiexec.exe -ArgumentList $MsiArgs -WorkingDirectory $WorkingDirectory -PassThru } else { if ([string]::IsNullOrWhiteSpace($Arguments)) { Write-Muted "命令: `"$RunFile`"" $Process = Start-Process -FilePath $RunFile -WorkingDirectory $WorkingDirectory -PassThru } else { Write-Muted "命令: `"$RunFile`" $Arguments" $Process = Start-Process -FilePath $RunFile -ArgumentList $Arguments -WorkingDirectory $WorkingDirectory -PassThru } } $Exited = $Process.WaitForExit($TimeoutSeconds * 1000) if ($Exited) { Write-OK "$Name 安装程序已退出,退出代码: $($Process.ExitCode)" } else { Write-Warn "$Name 安装超时,正在终止安装程序..." Stop-Process -Id $Process.Id -Force -ErrorAction SilentlyContinue } Start-Sleep -Seconds 5 if ($KillList -and $KillList.Count -gt 0) { Write-Info "正在关闭残留进程..." Stop-AppProcesses -ProcessNames $KillList } # 安装后处理 if ($App.ContainsKey("PostInstall") -and $App.PostInstall -eq "ImportRustDeskConfig") { Import-RustDeskServerConfig -ConfigString $RustDeskConfigString } Write-OK "$Name 安装完成" } catch { Write-Err "$Name 安装失败" Write-Muted $_.Exception.Message if ($KillList -and $KillList.Count -gt 0) { Stop-AppProcesses -ProcessNames $KillList } } } function Install-SelectedApps { param([int[]]$Ids) foreach ($Id in $Ids) { $App = $SoftwareList | Where-Object { $_.Id -eq $Id } | Select-Object -First 1 if ($App) { Install-App -App $App } else { Write-Err "无效的 ID: $Id" } } } function Install-AllApps { foreach ($App in $SoftwareList) { Install-App -App $App } } function Open-AppsFolder { if (Test-Path $AppsDir) { Start-Process explorer.exe $AppsDir } } function Test-AppDownloadUrls { Write-Info "" Write-Info "正在测试软件下载链接..." foreach ($App in $SoftwareList) { $Url = Get-EncodedFileUrl -BaseUrl $AppBaseUrl -FileName $App.FileName Write-Info "测试: $($App.Name)" try { $Response = Invoke-WebRequest -Uri $Url -Method Head -UseBasicParsing -ErrorAction Stop Write-OK "HTTP $($Response.StatusCode)" } catch { Write-Err "失败: $($_.Exception.Message)" } if ($App.ContainsKey("Dependencies") -and $App.Dependencies) { foreach ($Dependency in $App.Dependencies) { $DepUrl = Get-EncodedFileUrl -BaseUrl $AppBaseUrl -FileName $Dependency.FileName Write-Info "测试依赖: $($Dependency.Name)" try { $DepResponse = Invoke-WebRequest -Uri $DepUrl -Method Head -UseBasicParsing -ErrorAction Stop Write-OK "HTTP $($DepResponse.StatusCode)" } catch { Write-Err "失败: $($_.Exception.Message)" } } } } } # --------------------------------------------------------- # 主菜单 # --------------------------------------------------------- while ($true) { Clear-Host Write-Header "软件安装器" Write-Info "软件源:" Write-Muted $AppBaseUrl if ($Global:DryRunMode) { Write-Warn "模式: DRY-RUN(不执行下载/安装)" } else { Write-OK "模式: LIVE(正常执行)" } Write-Info "" Write-Info "本地路径:" Write-Muted $AppsDir Show-SoftwareList Write-Info "A. 安装全部" Write-Info "P. 按配置文件安装" Write-Info "D. 切换 DRY-RUN 模式" Write-Info "T. 测试下载链接" Write-Info "O. 打开本地应用文件夹" Write-Info "0. 退出" Write-Info "" $Choice = Read-Host "选择软件(如 1,2,3)或选项(A/P/D/T/O/0)" Write-Line if ($Choice -match '^[0]$') { break } if ($Choice -match '^[Aa]$') { Install-AllApps; Pause-Max; continue } if ($Choice -match '^[Pp]$') { Show-InstallProfiles $ProfileId = Read-Host "输入配置文件 ID" if (-not [string]::IsNullOrWhiteSpace($ProfileId)) { Install-ProfileApps -ProfileId $ProfileId } Pause-Max; continue } if ($Choice -match '^[Dd]$') { $Global:DryRunMode = -not $Global:DryRunMode if ($Global:DryRunMode) { Write-Warn "DRY-RUN 模式已启用" } else { Write-OK "DRY-RUN 模式已禁用" } Pause-Max; continue } if ($Choice -match '^[Tt]$') { Test-AppDownloadUrls; Pause-Max; continue } if ($Choice -match '^[Oo]$') { Open-AppsFolder; Pause-Max; continue } $Ids = $Choice -split "," | ForEach-Object { $_.Trim() } | Where-Object { $_ -match '^\d+$' } | ForEach-Object { [int]$_ } if (-not $Ids -or $Ids.Count -eq 0) { Write-Err "无效选择"; Pause-Max; continue } Install-SelectedApps -Ids $Ids Pause-Max }