fix(win): allow installer upgrades while desktop is running

This commit is contained in:
ZP
2026-08-26 00:16:15 +08:00
parent 2172b1b2f2
commit 46decd44b6
11 changed files with 777 additions and 2 deletions
@@ -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-<GUID>`。为与生产安装器的单实例身份一致,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 固定被测二进制身份。
+61
View File
@@ -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
+2 -1
View File
@@ -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,
@@ -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
}
@@ -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
}
}
@@ -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
}
@@ -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)
}
+10 -1
View File
@@ -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<void> {
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<void> {
}
}
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()
})
@@ -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)')
})
})
@@ -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')
})
})
+3
View File
@@ -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')