From 46decd44b6c0397db36db533eb0e7aef8ba1a221 Mon Sep 17 00:00:00 2001 From: ZP <3026418933@qq.com> Date: Wed, 26 Aug 2026 00:16:15 +0800 Subject: [PATCH] fix(win): allow installer upgrades while desktop is running --- .../windows-installer-upgrade-2026-08-25.md | 162 ++++++++++ dsh-plugin-desktop/build/installer.nsh | 61 ++++ dsh-plugin-desktop/package.json | 3 +- .../scripts/probe-windows-installer-quit.ps1 | 110 +++++++ ...roduce-windows-installer-running-check.ps1 | 48 +++ .../smoke-windows-installer-upgrade.ps1 | 290 ++++++++++++++++++ .../src/desktop-installer-quit.ts | 10 + dsh-plugin-desktop/src/main.ts | 11 +- .../tests/desktop-installer-quit.spec.ts | 45 +++ .../tests/installer-nsh.spec.ts | 36 +++ dsh-plugin-desktop/tests/package.spec.ts | 3 + 11 files changed, 777 insertions(+), 2 deletions(-) create mode 100644 docs/evidence/windows-installer-upgrade-2026-08-25.md create mode 100644 dsh-plugin-desktop/build/installer.nsh create mode 100644 dsh-plugin-desktop/scripts/probe-windows-installer-quit.ps1 create mode 100644 dsh-plugin-desktop/scripts/reproduce-windows-installer-running-check.ps1 create mode 100644 dsh-plugin-desktop/scripts/smoke-windows-installer-upgrade.ps1 create mode 100644 dsh-plugin-desktop/src/desktop-installer-quit.ts create mode 100644 dsh-plugin-desktop/tests/desktop-installer-quit.spec.ts create mode 100644 dsh-plugin-desktop/tests/installer-nsh.spec.ts diff --git a/docs/evidence/windows-installer-upgrade-2026-08-25.md b/docs/evidence/windows-installer-upgrade-2026-08-25.md new file mode 100644 index 0000000000..bc6f898d53 --- /dev/null +++ b/docs/evidence/windows-installer-upgrade-2026-08-25.md @@ -0,0 +1,162 @@ +# Windows 安装器升级与覆盖安装修复证据 + +## 结论 + +- 结果:通过。 +- 范围:仅修改 DSH Desktop 的 Electron/NSIS 安装与退出协调;未修改 `deepseek-harness` 子模块或 DSH 底层。 +- 真实安装链路:`2.0.2 -> 2.0.3` 运行中升级通过,`2.0.3 -> 2.0.3` 运行中覆盖安装通过。 +- 清理:测试安装目录、卸载注册表项、快捷方式、测试进程、临时目录和活动运行标记均无残留。 + +## 关联问题 + +- Issue: [#469 Windows 更新安装器误判应用仍在运行](https://github.com/anywhere-labs/dsh-desktop/issues/469) +- 已有基础修复:[PR #471](https://github.com/anywhere-labs/dsh-desktop/pull/471),提交 `edc9574f447c90866e85cfd1d40f718be7737432`,将“安装目录下任意进程”改为精确匹配 `DSH Desktop.exe`。 +- 本次补充:安装器先通过 `--dsh-installer-quit` 请求运行中的桌面端有序退出,等待清理完成后再覆盖文件;旧版不支持该参数时,继续使用精确进程名的停止/强制停止回退。 + +## 测试基线 + +| 项目 | 值 | +| --- | --- | +| 日期 | 2026-08-25(Asia/Shanghai) | +| 系统 | Microsoft Windows 11 专业版,10.0.26200,64-bit | +| 分支 | `fix/windows-installer-upgrade-evidence` | +| 基线提交 | `2172b1b2f2b0de4c2b3a1d8b55f11f8083a9305e` | +| Node.js | `v24.15.0` | +| Yarn | `4.18.0`(通过 Corepack) | +| DSH 子模块 | `b150a551b8d465e31e418e1b2eaf5e79bbb7d28e`,未修改 | + +## 安装器身份 + +| 安装器 | PE 版本 | 大小(字节) | SHA-256 | +| --- | --- | ---: | --- | +| `DSH-Desktop-2.0.2-x64-Setup.exe` | `2.0.2` | 132417238 | `B31F63F8CF70D3FC07ED2AE36E5DE7B1939E604BDB3BE097DE3383A82A06A787` | +| `DSH-Desktop-2.0.3-x64-Setup.exe` | `2.0.3` | 132758556 | `AE2FD0820803A69B7C953B9831FC029EE06499E07078DC8B45497C17A858743A` | + +候选产物:`dsh-plugin-desktop/dist/DSH-Desktop-2.0.3-x64-Setup.exe`。 + +## 问题复现 + +执行: + +```powershell +& .\dsh-plugin-desktop\scripts\reproduce-windows-installer-running-check.ps1 +``` + +脚本在临时“安装目录”中启动真实的 `DSH Helper.exe` 进程,并比较旧目录前缀判断与修复后的精确名称判断: + +```json +{ + "oldInstallDirectoryPredicateMatchedHelper": true, + "fixedExecutableNamePredicateMatchedHelper": false, + "reproduced": true +} +``` + +这证明旧逻辑会把安装目录内的无关辅助进程误判为桌面主程序,而精确匹配不会。 + +## 有序退出运行时探针 + +执行: + +```powershell +& .\dsh-plugin-desktop\scripts\probe-windows-installer-quit.ps1 ` + -CandidateApp .\dsh-plugin-desktop\dist\win-unpacked\DSH Desktop.exe +``` + +结果: + +```json +{ + "candidateVersion": "2.0.3.0", + "primaryProcessId": 50372, + "activeRunMarkerObserved": true, + "quitRequestExitCode": 0, + "primaryProcessStopped": true, + "activeRunMarkerCleared": true, + "testRootRemoved": true, + "error": null, + "success": true +} +``` + +探针的主实例和退出请求实例使用相同的临时 Electron `userData`,验证单实例事件能够触发 Cordis/桌面运行时的有序退出,并在退出前清除 `active-run.json`。 + +## 真实升级与覆盖安装 + +执行: + +```powershell +& .\dsh-plugin-desktop\scripts\smoke-windows-installer-upgrade.ps1 ` + -BaseInstaller E:\qwq\DSHPLU\DSH-desktop\DSH-Desktop-2.0.2-x64-Setup.exe ` + -CandidateInstaller .\dsh-plugin-desktop\dist\DSH-Desktop-2.0.3-x64-Setup.exe +``` + +安装位置和 `DSH_HOME` 使用 `%TEMP%\dsh-installer-upgrade-`。为与生产安装器的单实例身份一致,Electron 使用当前 Windows 账户的默认 `userData`;脚本在开始前要求不存在活动运行标记,并在最终清理中再次验证标记不存在。 + +| 阶段 | 进程状态 | 退出码 | 耗时 | 文件版本 | +| --- | --- | ---: | ---: | --- | +| 安装基线 | 安装后启动 PID `20288` | 0 | 363170 ms | `2.0.2.0` | +| 运行中升级 | 旧版进程已停止 | 0 | 464535 ms | `2.0.3.0` | +| 启动候选 | 启动 PID `42716`,观察到活动标记 | - | - | `2.0.3.0` | +| 运行中同版本覆盖 | 候选进程已停止,活动标记已清除 | 0 | 435635 ms | `2.0.3.0` | +| 静默卸载 | 完成 | 0 | - | - | + +最终 JSON 关键字段: + +```json +{ + "baseInstallExitCode": 0, + "baseInstalledVersion": "2.0.2.0", + "baseProcessStarted": true, + "upgradeExitCode": 0, + "upgradedVersion": "2.0.3.0", + "baseProcessStopped": true, + "candidateProcessStarted": true, + "activeRunMarkerObserved": true, + "overwriteExitCode": 0, + "overwriteVersion": "2.0.3.0", + "candidateProcessStopped": true, + "activeRunMarkerCleared": true, + "uninstallExitCode": 0, + "installRootRemoved": true, + "uninstallEntryRemoved": true, + "shortcutsRemoved": true, + "testProcessesRemaining": 0, + "activeRunMarkerAbsentAfterCleanup": true, + "error": null, + "success": true +} +``` + +`2.0.2` 不识别新退出参数,因此首次升级按设计走精确主进程名回退;`2.0.3` 同版本覆盖观察到标记从存在变为清除,证明新版本走了有序退出路径。 + +## 自动化回归 + +执行: + +```powershell +corepack yarn check:win-package +``` + +结果: + +- 构建通过。 +- TypeScript 类型检查通过。 +- Windows 打包门禁:13 个测试文件、180 个测试全部通过。 +- 运行时闭包单测:4/4 通过。 +- 运行时闭包:201 个一方节点形成闭合、可达的运行图。 +- `git diff --check` 通过。 + +## 最终残留复核 + +```json +{ + "InstallEntries": 0, + "Processes": 0, + "TestTempRoots": 0, + "ActiveRunMarkerPresent": false, + "Shortcuts": 0 +} +``` + +候选安装器未做代码签名验证;本报告用 PE 版本、文件大小和 SHA-256 固定被测二进制身份。 diff --git a/dsh-plugin-desktop/build/installer.nsh b/dsh-plugin-desktop/build/installer.nsh new file mode 100644 index 0000000000..362556ca81 --- /dev/null +++ b/dsh-plugin-desktop/build/installer.nsh @@ -0,0 +1,61 @@ +; Check the exact app process before launching the quit handoff. This preserves +; the #469 fix: unrelated helpers under $INSTDIR must never block an upgrade. +Var pid + +!macro customCheckAppRunning + !insertmacro IS_POWERSHELL_AVAILABLE + !insertmacro FIND_PROCESS "${APP_EXECUTABLE_FILENAME}" $R0 + ${if} $R0 != 0 + Goto dsh_installer_app_stopped + ${endIf} + + IfFileExists "$INSTDIR\${APP_EXECUTABLE_FILENAME}" 0 dsh_installer_scoped_fallback + ; Newer versions receive this through Electron's single-instance channel. + ; 2.0.2 ignores it, so the scoped builder fallback remains necessary for + ; the first upgrade to a version that supports orderly shutdown. + ExecWait '"$INSTDIR\${APP_EXECUTABLE_FILENAME}" --dsh-installer-quit' + StrCpy $R1 0 + + dsh_installer_wait_for_exit: + !insertmacro FIND_PROCESS "${APP_EXECUTABLE_FILENAME}" $R0 + ${if} $R0 != 0 + Goto dsh_installer_app_stopped + ${endIf} + IntOp $R1 $R1 + 1 + ${if} $R1 < 12 + Sleep 500 + Goto dsh_installer_wait_for_exit + ${endIf} + + dsh_installer_scoped_fallback: + ; The patched builder macros match DSH Desktop.exe, not every executable + ; below $INSTDIR. They handle pre-handoff releases and stubborn processes. + MessageBox MB_OKCANCEL|MB_ICONEXCLAMATION "$(appRunning)" /SD IDOK IDOK dsh_installer_stop_app + Quit + + dsh_installer_stop_app: + DetailPrint "$(appClosing)" + ; KILL_PROCESS's tasklist fallback excludes $pid. The installer never has + ; the application executable name, so zero is a safe sentinel here. + StrCpy $pid 0 + !insertmacro KILL_PROCESS "${APP_EXECUTABLE_FILENAME}" 0 + Sleep 500 + StrCpy $R1 0 + + dsh_installer_wait_for_fallback: + !insertmacro FIND_PROCESS "${APP_EXECUTABLE_FILENAME}" $R0 + ${if} $R0 != 0 + Goto dsh_installer_app_stopped + ${endIf} + IntOp $R1 $R1 + 1 + ${if} $R1 > 1 + MessageBox MB_RETRYCANCEL|MB_ICONEXCLAMATION "$(appCannotBeClosed)" /SD IDCANCEL IDRETRY dsh_installer_wait_for_fallback + Quit + ${endIf} + Sleep 1000 + !insertmacro KILL_PROCESS "${APP_EXECUTABLE_FILENAME}" 1 + Sleep 500 + Goto dsh_installer_wait_for_fallback + + dsh_installer_app_stopped: +!macroend diff --git a/dsh-plugin-desktop/package.json b/dsh-plugin-desktop/package.json index f418bad811..e00321eddb 100644 --- a/dsh-plugin-desktop/package.json +++ b/dsh-plugin-desktop/package.json @@ -125,7 +125,7 @@ "verify:operations": "node --test scripts/operation-reliability.spec.mjs && node scripts/verify-operation-reliability.mjs", "verify:notices": "node scripts/verify-licenses.mjs --notices THIRD_PARTY_NOTICES.md", "check": "yarn run build && yarn run typecheck && yarn run test && yarn run verify:closure && yarn run verify:cli && yarn run verify:loader && yarn run verify:profile && yarn run verify:licenses && yarn run verify:operations", - "check:win-package": "yarn workspace dsh-community-market build && yarn run build && yarn run typecheck && vitest run tests/package.spec.ts tests/package-win.spec.ts tests/update-checker.spec.ts tests/update-download.spec.ts tests/verify-win-installer.spec.ts tests/verify-win-portable.spec.ts tests/verify-packaged-runtime.spec.ts tests/windows-agent-presets.spec.ts tests/windows-pwsh-sandbox.spec.ts tests/windows-volume-diagnostics.spec.ts tests/window-options.spec.ts && yarn run verify:closure", + "check:win-package": "yarn workspace dsh-community-market build && yarn run build && yarn run typecheck && vitest run tests/package.spec.ts tests/package-win.spec.ts tests/desktop-installer-quit.spec.ts tests/installer-nsh.spec.ts tests/update-checker.spec.ts tests/update-download.spec.ts tests/verify-win-installer.spec.ts tests/verify-win-portable.spec.ts tests/verify-packaged-runtime.spec.ts tests/windows-agent-presets.spec.ts tests/windows-pwsh-sandbox.spec.ts tests/windows-volume-diagnostics.spec.ts tests/window-options.spec.ts && yarn run verify:closure", "check:mac-package": "yarn workspace dsh-community-market build && yarn run build && yarn run typecheck && vitest run tests/package.spec.ts tests/package-mac.spec.ts tests/verify-mac-smoke.spec.ts tests/verify-packaged-runtime.spec.ts tests/mac-universal.spec.ts && yarn run verify:closure", "start": "node lib/bin.js", "dev": "yarn run build && node lib/bin.js", @@ -351,6 +351,7 @@ "icon": "build/app-icon.png" }, "nsis": { + "include": "installer.nsh", "license": "THIRD_PARTY_NOTICES.md", "oneClick": false, "perMachine": false, diff --git a/dsh-plugin-desktop/scripts/probe-windows-installer-quit.ps1 b/dsh-plugin-desktop/scripts/probe-windows-installer-quit.ps1 new file mode 100644 index 0000000000..ee329b8aa0 --- /dev/null +++ b/dsh-plugin-desktop/scripts/probe-windows-installer-quit.ps1 @@ -0,0 +1,110 @@ +param( + [Parameter(Mandatory = $true)] + [string]$CandidateApp +) + +$ErrorActionPreference = 'Stop' + +if ($env:OS -ne 'Windows_NT') { + throw 'This probe requires Windows.' +} + +$taskAppPath = (Resolve-Path -LiteralPath $CandidateApp).Path +$taskTempRoot = [System.IO.Path]::GetFullPath([System.IO.Path]::GetTempPath()) +$taskRoot = Join-Path $taskTempRoot ("dsh-installer-quit-probe-" + [guid]::NewGuid().ToString('N')) +$taskUserData = Join-Path $taskRoot 'user-data' +$taskDshHome = Join-Path $taskRoot 'dsh-home' +$taskActiveRunMarker = Join-Path $taskUserData 'crash-evidence\active-run.json' +$taskPrimary = $null +$taskResult = [ordered]@{ + scenario = 'isolated packaged app receives installer quit handoff' + testRoot = $taskRoot + candidateApp = $taskAppPath + candidateVersion = (Get-Item -LiteralPath $taskAppPath).VersionInfo.ProductVersion + primaryProcessId = $null + activeRunMarkerObserved = $false + quitRequestExitCode = $null + primaryProcessStopped = $false + activeRunMarkerCleared = $false + testRootRemoved = $false + error = $null + success = $false +} + +function Wait-TaskCondition([scriptblock]$taskCondition, [string]$taskDescription, [int]$taskTimeoutSeconds = 30) { + $taskDeadline = [DateTime]::UtcNow.AddSeconds($taskTimeoutSeconds) + do { + if (& $taskCondition) { return } + Start-Sleep -Milliseconds 250 + } while ([DateTime]::UtcNow -lt $taskDeadline) + throw "Timed out waiting for $taskDescription." +} + +try { + New-Item -ItemType Directory -Path $taskRoot | Out-Null + $taskStartInfo = [System.Diagnostics.ProcessStartInfo]::new() + $taskStartInfo.FileName = $taskAppPath + $taskStartInfo.Arguments = "--user-data-dir=`"$taskUserData`"" + $taskStartInfo.WorkingDirectory = Split-Path -Parent $taskAppPath + $taskStartInfo.UseShellExecute = $false + $taskStartInfo.CreateNoWindow = $true + $taskStartInfo.EnvironmentVariables['DSH_HOME'] = $taskDshHome + $taskPrimary = [System.Diagnostics.Process]::Start($taskStartInfo) + $taskResult.primaryProcessId = $taskPrimary.Id + + Wait-TaskCondition { Test-Path -LiteralPath $taskActiveRunMarker } 'active run marker creation' + $taskResult.activeRunMarkerObserved = $true + + $taskQuitStartInfo = [System.Diagnostics.ProcessStartInfo]::new() + $taskQuitStartInfo.FileName = $taskAppPath + $taskQuitStartInfo.Arguments = "--user-data-dir=`"$taskUserData`" --dsh-installer-quit" + $taskQuitStartInfo.WorkingDirectory = Split-Path -Parent $taskAppPath + $taskQuitStartInfo.UseShellExecute = $false + $taskQuitStartInfo.CreateNoWindow = $true + $taskQuitStartInfo.EnvironmentVariables['DSH_HOME'] = $taskDshHome + $taskQuitRequest = [System.Diagnostics.Process]::Start($taskQuitStartInfo) + $taskQuitRequest.WaitForExit() + $taskResult.quitRequestExitCode = $taskQuitRequest.ExitCode + + Wait-TaskCondition { + $taskPrimary.Refresh() + $taskPrimary.HasExited + } 'primary app exit' 15 + $taskResult.primaryProcessStopped = $true + + Wait-TaskCondition { -not (Test-Path -LiteralPath $taskActiveRunMarker) } 'active run marker cleanup' 15 + $taskResult.activeRunMarkerCleared = $true +} catch { + $taskResult.error = $_.Exception.Message +} finally { + if ($null -ne $taskPrimary) { + $taskPrimary.Refresh() + if (-not $taskPrimary.HasExited) { + Stop-Process -Id $taskPrimary.Id -Force -ErrorAction SilentlyContinue + } + } + + $taskResolvedRoot = [System.IO.Path]::GetFullPath($taskRoot) + if (-not $taskResolvedRoot.StartsWith($taskTempRoot, [System.StringComparison]::OrdinalIgnoreCase) -or + -not ([System.IO.Path]::GetFileName($taskResolvedRoot)).StartsWith('dsh-installer-quit-probe-', [System.StringComparison]::Ordinal)) { + throw "Refusing to remove unexpected probe path: $taskResolvedRoot" + } + if (Test-Path -LiteralPath $taskResolvedRoot) { + Remove-Item -LiteralPath $taskResolvedRoot -Recurse -Force + } + $taskResult.testRootRemoved = -not (Test-Path -LiteralPath $taskResolvedRoot) +} + +$taskResult.success = ( + $null -eq $taskResult.error -and + $taskResult.activeRunMarkerObserved -and + $taskResult.quitRequestExitCode -eq 0 -and + $taskResult.primaryProcessStopped -and + $taskResult.activeRunMarkerCleared -and + $taskResult.testRootRemoved +) + +$taskResult | ConvertTo-Json +if (-not $taskResult.success) { + exit 1 +} diff --git a/dsh-plugin-desktop/scripts/reproduce-windows-installer-running-check.ps1 b/dsh-plugin-desktop/scripts/reproduce-windows-installer-running-check.ps1 new file mode 100644 index 0000000000..ac10a12968 --- /dev/null +++ b/dsh-plugin-desktop/scripts/reproduce-windows-installer-running-check.ps1 @@ -0,0 +1,48 @@ +param() + +$ErrorActionPreference = 'Stop' + +if ($env:OS -ne 'Windows_NT') { + throw 'This reproduction requires Windows.' +} + +$taskRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("dsh-issue-469-" + [guid]::NewGuid().ToString('N')) +$taskHelperPath = Join-Path $taskRoot 'DSH Helper.exe' +$taskHelper = $null + +try { + New-Item -ItemType Directory -Path $taskRoot | Out-Null + Copy-Item -LiteralPath (Join-Path $env:WINDIR 'System32\ping.exe') -Destination $taskHelperPath + $taskHelper = Start-Process -FilePath $taskHelperPath -ArgumentList '-t', '127.0.0.1' -PassThru -WindowStyle Hidden + Start-Sleep -Milliseconds 500 + + $taskProcesses = @(Get-CimInstance -ClassName Win32_Process) + $taskOldMatches = @($taskProcesses | Where-Object { + $_.Path -and $_.Path.StartsWith($taskRoot, [System.StringComparison]::CurrentCultureIgnoreCase) + }) + $taskFixedMatches = @($taskProcesses | Where-Object { + $_.Path -and [System.IO.Path]::GetFileName($_.Path) -ieq 'DSH Desktop.exe' + }) + + [ordered]@{ + scenario = 'unrelated helper executable under the DSH install directory' + helperPid = $taskHelper.Id + helperPath = $taskHelperPath + oldInstallDirectoryPredicateMatchedHelper = $taskOldMatches.ProcessId -contains $taskHelper.Id + fixedExecutableNamePredicateMatchedHelper = $taskFixedMatches.ProcessId -contains $taskHelper.Id + reproduced = ($taskOldMatches.ProcessId -contains $taskHelper.Id) -and -not ($taskFixedMatches.ProcessId -contains $taskHelper.Id) + } | ConvertTo-Json +} finally { + if ($null -ne $taskHelper -and -not $taskHelper.HasExited) { + Stop-Process -Id $taskHelper.Id -Force + $taskHelper.WaitForExit() + } + if (Test-Path -LiteralPath $taskRoot) { + $taskResolvedRoot = [System.IO.Path]::GetFullPath($taskRoot) + $taskResolvedTemp = [System.IO.Path]::GetFullPath([System.IO.Path]::GetTempPath()) + if (-not $taskResolvedRoot.StartsWith($taskResolvedTemp, [System.StringComparison]::OrdinalIgnoreCase)) { + throw "Refusing to remove unexpected path: $taskResolvedRoot" + } + Remove-Item -LiteralPath $taskResolvedRoot -Recurse -Force + } +} diff --git a/dsh-plugin-desktop/scripts/smoke-windows-installer-upgrade.ps1 b/dsh-plugin-desktop/scripts/smoke-windows-installer-upgrade.ps1 new file mode 100644 index 0000000000..fb985fbed5 --- /dev/null +++ b/dsh-plugin-desktop/scripts/smoke-windows-installer-upgrade.ps1 @@ -0,0 +1,290 @@ +param( + [Parameter(Mandatory = $true)] + [string]$BaseInstaller, + + [Parameter(Mandatory = $true)] + [string]$CandidateInstaller +) + +$ErrorActionPreference = 'Stop' + +if ($env:OS -ne 'Windows_NT') { + throw 'This smoke test requires Windows.' +} + +$taskBaseInstaller = (Resolve-Path -LiteralPath $BaseInstaller).Path +$taskCandidateInstaller = (Resolve-Path -LiteralPath $CandidateInstaller).Path +$taskBaseExpectedVersion = (Get-Item -LiteralPath $taskBaseInstaller).VersionInfo.ProductVersion +$taskCandidateExpectedVersion = (Get-Item -LiteralPath $taskCandidateInstaller).VersionInfo.ProductVersion +$taskExistingProcesses = @(Get-CimInstance Win32_Process | Where-Object { + $_.Name -ieq 'DSH Desktop.exe' +}) +$taskUninstallRoots = @( + 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*' + 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*' + 'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*' +) +$taskExistingInstalls = @(Get-ItemProperty $taskUninstallRoots -ErrorAction SilentlyContinue | Where-Object { + $_.DisplayName -match '^DSH Desktop' +}) + +if ($taskExistingProcesses.Count -gt 0 -or $taskExistingInstalls.Count -gt 0) { + throw 'Refusing to run while an existing DSH Desktop process or installation is present.' +} + +$taskTempRoot = [System.IO.Path]::GetFullPath([System.IO.Path]::GetTempPath()) +$taskRoot = Join-Path $taskTempRoot ("dsh-installer-upgrade-" + [guid]::NewGuid().ToString('N')) +$taskInstallRoot = Join-Path $taskRoot 'app' +$taskUserData = Join-Path $env:APPDATA 'DSH Desktop' +$taskDshHome = Join-Path $taskRoot 'dsh-home' +$taskActiveRunMarker = Join-Path $taskUserData 'crash-evidence\active-run.json' +$taskAppPath = Join-Path $taskInstallRoot 'DSH Desktop.exe' +$taskUninstallerPath = Join-Path $taskInstallRoot 'Uninstall DSH Desktop.exe' +if (Test-Path -LiteralPath $taskActiveRunMarker) { + throw 'Refusing to overwrite an existing DSH Desktop active run marker.' +} +$taskResult = [ordered]@{ + scenario = 'temporary install and DSH_HOME: 2.0.2 to 2.0.3 upgrade and fixed-version overwrite' + testRoot = $taskRoot + baseInstaller = $taskBaseInstaller + baseInstallerSha256 = (Get-FileHash -LiteralPath $taskBaseInstaller -Algorithm SHA256).Hash + baseExpectedVersion = $taskBaseExpectedVersion + candidateInstaller = $taskCandidateInstaller + candidateInstallerSha256 = (Get-FileHash -LiteralPath $taskCandidateInstaller -Algorithm SHA256).Hash + candidateExpectedVersion = $taskCandidateExpectedVersion + desktopUserData = $taskUserData + baseInstallExitCode = $null + baseInstallElapsedMs = $null + baseInstalledVersion = $null + baseProcessId = $null + baseProcessStarted = $false + upgradeExitCode = $null + upgradeElapsedMs = $null + upgradedVersion = $null + baseProcessStopped = $false + candidateProcessId = $null + candidateProcessStarted = $false + activeRunMarkerObserved = $false + overwriteExitCode = $null + overwriteElapsedMs = $null + overwriteVersion = $null + activeRunMarkerCleared = $false + candidateProcessStopped = $false + uninstallExitCode = $null + installRootRemoved = $false + uninstallEntryRemoved = $false + shortcutsRemoved = $false + testProcessesRemaining = $null + activeRunMarkerAbsentAfterCleanup = $false + error = $null + success = $false +} + +function Start-TaskInstaller([string]$taskInstallerPath) { + $taskStopwatch = [System.Diagnostics.Stopwatch]::StartNew() + $taskProcess = Start-Process -FilePath $taskInstallerPath -ArgumentList @( + '/S' + '/currentuser' + "/D=$taskInstallRoot" + ) -PassThru -Wait -WindowStyle Hidden + $taskStopwatch.Stop() + return [ordered]@{ + exitCode = $taskProcess.ExitCode + elapsedMs = $taskStopwatch.ElapsedMilliseconds + } +} + +function Start-TaskDesktop { + $taskStartInfo = [System.Diagnostics.ProcessStartInfo]::new() + $taskStartInfo.FileName = $taskAppPath + $taskStartInfo.WorkingDirectory = $taskInstallRoot + $taskStartInfo.UseShellExecute = $false + $taskStartInfo.EnvironmentVariables['DSH_HOME'] = $taskDshHome + $taskStartInfo.EnvironmentVariables['ELECTRON_ENABLE_LOGGING'] = '1' + return [System.Diagnostics.Process]::Start($taskStartInfo) +} + +function Wait-TaskProcess([bool]$taskShouldExist, [int]$taskTimeoutSeconds = 30) { + $taskDeadline = [DateTime]::UtcNow.AddSeconds($taskTimeoutSeconds) + do { + $taskMatches = @(Get-CimInstance Win32_Process | Where-Object { + $_.ExecutablePath -and $_.ExecutablePath.Equals($taskAppPath, [System.StringComparison]::OrdinalIgnoreCase) + }) + if (($taskMatches.Count -gt 0) -eq $taskShouldExist) { + return $taskMatches + } + Start-Sleep -Milliseconds 250 + } while ([DateTime]::UtcNow -lt $taskDeadline) + throw "Timed out waiting for DSH Desktop process state: shouldExist=$taskShouldExist" +} + +function Wait-TaskActiveRunMarker([bool]$taskShouldExist, [int]$taskTimeoutSeconds = 30) { + $taskDeadline = [DateTime]::UtcNow.AddSeconds($taskTimeoutSeconds) + do { + $taskMarkerExists = Test-Path -LiteralPath $taskActiveRunMarker + if ($taskMarkerExists -eq $taskShouldExist) { + return $taskActiveRunMarker + } + Start-Sleep -Milliseconds 250 + } while ([DateTime]::UtcNow -lt $taskDeadline) + throw "Timed out waiting for active-run marker state: shouldExist=$taskShouldExist" +} + +function ConvertTo-TaskVersion([string]$taskVersion) { + $taskParsedVersion = [version]$taskVersion + return [version]::new( + $taskParsedVersion.Major, + $taskParsedVersion.Minor, + [Math]::Max(0, $taskParsedVersion.Build), + [Math]::Max(0, $taskParsedVersion.Revision) + ) +} + +function Test-TaskVersionEquals([string]$taskActualVersion, [string]$taskExpectedVersion) { + return (ConvertTo-TaskVersion $taskActualVersion) -eq (ConvertTo-TaskVersion $taskExpectedVersion) +} + +try { + New-Item -ItemType Directory -Path $taskRoot | Out-Null + + $taskBaseInstall = Start-TaskInstaller $taskBaseInstaller + $taskResult.baseInstallExitCode = $taskBaseInstall.exitCode + $taskResult.baseInstallElapsedMs = $taskBaseInstall.elapsedMs + if ($taskBaseInstall.exitCode -ne 0 -or -not (Test-Path -LiteralPath $taskAppPath)) { + throw "Base installer failed with exit code $($taskBaseInstall.exitCode)." + } + $taskResult.baseInstalledVersion = (Get-Item -LiteralPath $taskAppPath).VersionInfo.ProductVersion + if (-not (Test-TaskVersionEquals $taskResult.baseInstalledVersion $taskBaseExpectedVersion)) { + throw "Expected base version $taskBaseExpectedVersion but installed $($taskResult.baseInstalledVersion)." + } + + $taskBaseProcess = Start-TaskDesktop + Wait-TaskProcess $true | Out-Null + $taskResult.baseProcessId = $taskBaseProcess.Id + $taskResult.baseProcessStarted = -not $taskBaseProcess.HasExited + + $taskUpgrade = Start-TaskInstaller $taskCandidateInstaller + $taskResult.upgradeExitCode = $taskUpgrade.exitCode + $taskResult.upgradeElapsedMs = $taskUpgrade.elapsedMs + if ($taskUpgrade.exitCode -ne 0) { + throw "Candidate upgrade failed with exit code $($taskUpgrade.exitCode)." + } + Wait-TaskProcess $false | Out-Null + $taskResult.baseProcessStopped = $true + $taskResult.upgradedVersion = (Get-Item -LiteralPath $taskAppPath).VersionInfo.ProductVersion + if (-not (Test-TaskVersionEquals $taskResult.upgradedVersion $taskCandidateExpectedVersion)) { + throw "Expected upgraded version $taskCandidateExpectedVersion but installed $($taskResult.upgradedVersion)." + } + + $taskCandidateProcess = Start-TaskDesktop + Wait-TaskProcess $true | Out-Null + $taskResult.candidateProcessId = $taskCandidateProcess.Id + $taskResult.candidateProcessStarted = -not $taskCandidateProcess.HasExited + Wait-TaskActiveRunMarker $true | Out-Null + $taskResult.activeRunMarkerObserved = $true + + $taskOverwrite = Start-TaskInstaller $taskCandidateInstaller + $taskResult.overwriteExitCode = $taskOverwrite.exitCode + $taskResult.overwriteElapsedMs = $taskOverwrite.elapsedMs + if ($taskOverwrite.exitCode -ne 0) { + throw "Candidate overwrite failed with exit code $($taskOverwrite.exitCode)." + } + Wait-TaskProcess $false | Out-Null + $taskResult.candidateProcessStopped = $true + Wait-TaskActiveRunMarker $false | Out-Null + $taskResult.activeRunMarkerCleared = $true + $taskResult.overwriteVersion = (Get-Item -LiteralPath $taskAppPath).VersionInfo.ProductVersion + if (-not (Test-TaskVersionEquals $taskResult.overwriteVersion $taskCandidateExpectedVersion)) { + throw "Expected overwrite version $taskCandidateExpectedVersion but installed $($taskResult.overwriteVersion)." + } +} catch { + $taskResult.error = $_.Exception.Message +} finally { + $taskTestProcesses = @(Get-CimInstance Win32_Process | Where-Object { + $_.ExecutablePath -and $_.ExecutablePath.StartsWith( + $taskInstallRoot, + [System.StringComparison]::OrdinalIgnoreCase + ) + }) + foreach ($taskProcess in $taskTestProcesses) { + Stop-Process -Id $taskProcess.ProcessId -Force -ErrorAction SilentlyContinue + } + + if (Test-Path -LiteralPath $taskUninstallerPath) { + $taskUninstaller = Start-Process -FilePath $taskUninstallerPath -ArgumentList '/S' -PassThru -Wait -WindowStyle Hidden + $taskResult.uninstallExitCode = $taskUninstaller.ExitCode + } + + Start-Sleep -Milliseconds 500 + $taskResolvedRoot = [System.IO.Path]::GetFullPath($taskRoot) + if (-not $taskResolvedRoot.StartsWith($taskTempRoot, [System.StringComparison]::OrdinalIgnoreCase) -or + -not ([System.IO.Path]::GetFileName($taskResolvedRoot)).StartsWith('dsh-installer-upgrade-', [System.StringComparison]::Ordinal)) { + throw "Refusing to remove unexpected test path: $taskResolvedRoot" + } + if ($taskResult.uninstallExitCode -eq 0 -and (Test-Path -LiteralPath $taskResolvedRoot)) { + Remove-Item -LiteralPath $taskResolvedRoot -Recurse -Force + } + $taskResult.installRootRemoved = -not (Test-Path -LiteralPath $taskResolvedRoot) + + $taskRemainingProcesses = @(Get-CimInstance Win32_Process | Where-Object { + $_.ExecutablePath -and $_.ExecutablePath.StartsWith( + $taskInstallRoot, + [System.StringComparison]::OrdinalIgnoreCase + ) + }) + $taskResult.testProcessesRemaining = $taskRemainingProcesses.Count + + $taskRemainingInstalls = @(Get-ItemProperty $taskUninstallRoots -ErrorAction SilentlyContinue | Where-Object { + $_.DisplayName -match '^DSH Desktop' + }) + $taskResult.uninstallEntryRemoved = $taskRemainingInstalls.Count -eq 0 + + $taskShortcutRoots = @( + [Environment]::GetFolderPath('Desktop') + [Environment]::GetFolderPath('StartMenu') + [Environment]::GetFolderPath('CommonDesktopDirectory') + [Environment]::GetFolderPath('CommonStartMenu') + ) | Where-Object { $_ -and (Test-Path -LiteralPath $_) } + $taskRemainingShortcuts = @($taskShortcutRoots | ForEach-Object { + Get-ChildItem -LiteralPath $_ -Filter '*DSH Desktop*' -Recurse -ErrorAction SilentlyContinue + }) + $taskResult.shortcutsRemoved = $taskRemainingShortcuts.Count -eq 0 + + if (Test-Path -LiteralPath $taskActiveRunMarker) { + try { + $taskMarkerRecord = Get-Content -LiteralPath $taskActiveRunMarker -Raw | ConvertFrom-Json + if ($taskMarkerRecord.pid -eq $taskResult.baseProcessId -or + $taskMarkerRecord.pid -eq $taskResult.candidateProcessId) { + Remove-Item -LiteralPath $taskActiveRunMarker -Force + } + } catch {} + } + $taskResult.activeRunMarkerAbsentAfterCleanup = -not (Test-Path -LiteralPath $taskActiveRunMarker) +} + +$taskResult.success = ( + $null -eq $taskResult.error -and + $taskResult.baseInstallExitCode -eq 0 -and + (Test-TaskVersionEquals $taskResult.baseInstalledVersion $taskBaseExpectedVersion) -and + $taskResult.baseProcessStarted -and + $taskResult.upgradeExitCode -eq 0 -and + (Test-TaskVersionEquals $taskResult.upgradedVersion $taskCandidateExpectedVersion) -and + $taskResult.baseProcessStopped -and + $taskResult.candidateProcessStarted -and + $taskResult.activeRunMarkerObserved -and + $taskResult.overwriteExitCode -eq 0 -and + (Test-TaskVersionEquals $taskResult.overwriteVersion $taskCandidateExpectedVersion) -and + $taskResult.candidateProcessStopped -and + $taskResult.activeRunMarkerCleared -and + $taskResult.uninstallExitCode -eq 0 -and + $taskResult.installRootRemoved -and + $taskResult.uninstallEntryRemoved -and + $taskResult.shortcutsRemoved -and + $taskResult.testProcessesRemaining -eq 0 -and + $taskResult.activeRunMarkerAbsentAfterCleanup +) + +$taskResult | ConvertTo-Json +if (-not $taskResult.success) { + exit 1 +} diff --git a/dsh-plugin-desktop/src/desktop-installer-quit.ts b/dsh-plugin-desktop/src/desktop-installer-quit.ts new file mode 100644 index 0000000000..27d25e889d --- /dev/null +++ b/dsh-plugin-desktop/src/desktop-installer-quit.ts @@ -0,0 +1,10 @@ +/** Windows installer flag that requests an orderly Desktop shutdown. */ +export const DESKTOP_INSTALLER_QUIT_FLAG = '--dsh-installer-quit' + +/** Return whether one process invocation belongs to the installer quit handoff. */ +export function isDesktopInstallerQuitRequest( + argv: readonly string[], + platform: NodeJS.Platform, +): boolean { + return platform === 'win32' && argv.includes(DESKTOP_INSTALLER_QUIT_FLAG) +} diff --git a/dsh-plugin-desktop/src/main.ts b/dsh-plugin-desktop/src/main.ts index 356f63811c..3da566b42b 100644 --- a/dsh-plugin-desktop/src/main.ts +++ b/dsh-plugin-desktop/src/main.ts @@ -15,6 +15,7 @@ import { import { provideCmdline } from '@deepseek-ai/dsh-cmdline' import { resolveDshHome } from '@deepseek-ai/dsh-home-paths' import { DSH_LAUNCH_ENVIRONMENT_KEY } from '@deepseek-ai/dsh-launch-environment' +import { isDesktopInstallerQuitRequest } from './desktop-installer-quit.ts' import { installDesktopDshRuntime, installDesktopPnpmRuntime, @@ -184,6 +185,10 @@ async function start(): Promise { app.quit() return } + if (isDesktopInstallerQuitRequest(process.argv, process.platform)) { + app.quit() + return + } let shutdown: DesktopShutdown | undefined let removeShutdownRequests: (() => void) | undefined @@ -347,7 +352,11 @@ async function start(): Promise { } } - app.on('second-instance', () => { + app.on('second-instance', (_event, argv) => { + if (isDesktopInstallerQuitRequest(argv, process.platform)) { + requestQuit(0) + return + } if (startupRecoveryWindow !== undefined) startupRecoveryWindow.show() else runtime.show() }) diff --git a/dsh-plugin-desktop/tests/desktop-installer-quit.spec.ts b/dsh-plugin-desktop/tests/desktop-installer-quit.spec.ts new file mode 100644 index 0000000000..42645ac2c2 --- /dev/null +++ b/dsh-plugin-desktop/tests/desktop-installer-quit.spec.ts @@ -0,0 +1,45 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { + DESKTOP_INSTALLER_QUIT_FLAG, + isDesktopInstallerQuitRequest, +} from '../src/desktop-installer-quit.ts' + +describe('Desktop installer quit request', () => { + it('accepts only the dedicated flag on Windows', () => { + expect(DESKTOP_INSTALLER_QUIT_FLAG).toBe('--dsh-installer-quit') + expect(isDesktopInstallerQuitRequest( + ['DSH Desktop.exe', DESKTOP_INSTALLER_QUIT_FLAG], + 'win32', + )).toBe(true) + expect(isDesktopInstallerQuitRequest(['DSH Desktop.exe', '--quit'], 'win32')).toBe(false) + expect(isDesktopInstallerQuitRequest( + ['DSH Desktop', DESKTOP_INSTALLER_QUIT_FLAG], + 'darwin', + )).toBe(false) + }) + + it('handles first- and second-instance requests without showing a window', () => { + const main = readFileSync(join(process.cwd(), 'src', 'main.ts'), 'utf8') + const lock = main.indexOf('if (!app.requestSingleInstanceLock())') + const earlyQuit = main.indexOf( + 'if (isDesktopInstallerQuitRequest(process.argv, process.platform))', + ) + const startup = main.indexOf('let shutdown: DesktopShutdown') + const secondInstance = main.indexOf("app.on('second-instance', (_event, argv) => {") + const secondQuit = main.indexOf( + 'if (isDesktopInstallerQuitRequest(argv, process.platform))', + secondInstance, + ) + const show = main.indexOf('startupRecoveryWindow.show()', secondInstance) + + expect(lock).toBeGreaterThanOrEqual(0) + expect(earlyQuit).toBeGreaterThan(lock) + expect(earlyQuit).toBeLessThan(startup) + expect(secondInstance).toBeGreaterThan(startup) + expect(secondQuit).toBeGreaterThan(secondInstance) + expect(secondQuit).toBeLessThan(show) + expect(main.slice(secondQuit, show)).toContain('requestQuit(0)') + }) +}) diff --git a/dsh-plugin-desktop/tests/installer-nsh.spec.ts b/dsh-plugin-desktop/tests/installer-nsh.spec.ts new file mode 100644 index 0000000000..9bdf8e2a9d --- /dev/null +++ b/dsh-plugin-desktop/tests/installer-nsh.spec.ts @@ -0,0 +1,36 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { DESKTOP_INSTALLER_QUIT_FLAG } from '../src/desktop-installer-quit.ts' + +describe('Windows NSIS running-app handoff', () => { + it('checks for the exact app before requesting orderly shutdown', () => { + const script = readFileSync(join(process.cwd(), 'build', 'installer.nsh'), 'utf8') + const firstDetection = script.indexOf('!insertmacro FIND_PROCESS') + const request = script.indexOf(DESKTOP_INSTALLER_QUIT_FLAG) + const wait = script.indexOf('dsh_installer_wait_for_exit:') + const fallback = script.indexOf('dsh_installer_scoped_fallback:') + + expect(script).toContain('!macro customCheckAppRunning') + expect(script).toContain('Var pid') + expect(script).toContain('ExecWait') + expect(script).toContain('$INSTDIR\\${APP_EXECUTABLE_FILENAME}') + expect(script).toContain('!insertmacro IS_POWERSHELL_AVAILABLE') + expect(firstDetection).toBeGreaterThanOrEqual(0) + expect(request).toBeGreaterThan(firstDetection) + expect(wait).toBeGreaterThan(request) + expect(fallback).toBeGreaterThan(wait) + }) + + it('waits for graceful disposal before using the scoped builder fallback', () => { + const script = readFileSync(join(process.cwd(), 'build', 'installer.nsh'), 'utf8') + + expect(script).toContain('$R1 < 12') + expect(script).toContain('Sleep 500') + expect(script).toContain('!insertmacro KILL_PROCESS "${APP_EXECUTABLE_FILENAME}" 0') + expect(script).toContain('!insertmacro KILL_PROCESS "${APP_EXECUTABLE_FILENAME}" 1') + expect(script).not.toContain('taskkill') + expect(script).not.toContain('nsProcess::KillProcess') + expect(script).not.toContain('getProcessInfo.nsh') + }) +}) diff --git a/dsh-plugin-desktop/tests/package.spec.ts b/dsh-plugin-desktop/tests/package.spec.ts index 6359a79ff5..8467f2b368 100644 --- a/dsh-plugin-desktop/tests/package.spec.ts +++ b/dsh-plugin-desktop/tests/package.spec.ts @@ -603,6 +603,7 @@ describe('published package surface', () => { }]) expect(manifest.build?.win?.artifactName).toBe('DSH-Desktop-${version}-${arch}-Portable.${ext}') expect(manifest.build?.nsis).toEqual({ + include: 'installer.nsh', license: 'THIRD_PARTY_NOTICES.md', oneClick: false, perMachine: false, @@ -632,6 +633,8 @@ describe('published package surface', () => { expect(manifest.scripts?.['check:win-package']).toContain('yarn run build') expect(manifest.scripts?.['check:win-package']).toContain('yarn run typecheck') expect(manifest.scripts?.['check:win-package']).toContain('tests/package-win.spec.ts') + expect(manifest.scripts?.['check:win-package']).toContain('tests/desktop-installer-quit.spec.ts') + expect(manifest.scripts?.['check:win-package']).toContain('tests/installer-nsh.spec.ts') expect(manifest.scripts?.['check:win-package']).toContain('tests/verify-win-portable.spec.ts') expect(manifest.scripts?.['check:win-package']).toContain('tests/update-checker.spec.ts') expect(manifest.scripts?.['check:win-package']).toContain('tests/update-download.spec.ts')