fix(desktop): unblock Windows installer upgrades #1000

This commit is contained in:
程序员阿江(Relakkes) 2026-07-14 03:15:54 +08:00
parent 4c3b08c0a4
commit ecb66bfdd5
6 changed files with 149 additions and 26 deletions

View File

@ -119,6 +119,12 @@ jobs:
CSC_IDENTITY_AUTO_DISCOVERY: 'false'
run: node ./node_modules/electron-builder/out/cli/cli.js ${{ matrix.builder_args }} --publish never
- name: Verify Windows installer execution
if: matrix.smoke_platform == 'windows' && matrix.arch == 'x64'
timeout-minutes: 10
working-directory: desktop
run: powershell -NoLogo -NoProfile -ExecutionPolicy Bypass -File ./scripts/windows-installer-smoke.ps1 -ArtifactsDir ./build-artifacts/electron -Arch x64
- name: Verify packaged app structure
run: bun run test:package-smoke --platform ${{ matrix.smoke_platform }} --arch ${{ matrix.arch }} --package-kind release --artifacts-dir desktop/build-artifacts/electron

View File

@ -381,6 +381,12 @@ jobs:
CSC_IDENTITY_AUTO_DISCOVERY: 'false'
run: node ./node_modules/electron-builder/out/cli/cli.js ${{ matrix.builder_args }} --publish never
- name: Verify Windows installer execution
if: matrix.smoke_platform == 'windows' && matrix.arch == 'x64'
timeout-minutes: 10
working-directory: desktop
run: powershell -NoLogo -NoProfile -ExecutionPolicy Bypass -File ./scripts/windows-installer-smoke.ps1 -ArtifactsDir ./build-artifacts/electron -Arch x64
- name: Verify packaged app structure
run: bun run test:package-smoke --platform ${{ matrix.smoke_platform }} --arch ${{ matrix.arch }} --package-kind release --artifacts-dir desktop/build-artifacts/electron

View File

@ -90,9 +90,6 @@ Function CcHahaFinalInstallDir
FunctionEnd
Function CcHahaRecoverLegacy
InitPluginsDir
File /oname=$PLUGINSDIR\recover-legacy-install-data.ps1 "${BUILD_RESOURCES_DIR}\recover-legacy-install-data.ps1"
ReadRegStr $4 HKCU "${INSTALL_REGISTRY_KEY}" InstallLocation
ReadRegStr $5 HKLM "${INSTALL_REGISTRY_KEY}" InstallLocation
ReadRegStr $R0 HKCU "${UNINSTALL_REGISTRY_KEY}" UninstallString
@ -119,6 +116,22 @@ Function CcHahaRecoverLegacy
Call CcHahaUninstallerParent
Pop $5
${EndIf}
Push "$INSTDIR"
Call CcHahaFinalInstallDir
Pop $9
${If} $4 == ""
${AndIf} $5 == ""
StrCpy $0 "0"
StrCpy $1 "No registered installation needs legacy data recovery"
DetailPrint "$1"
Return
${EndIf}
InitPluginsDir
File /oname=$PLUGINSDIR\recover-legacy-install-data.ps1 "${BUILD_RESOURCES_DIR}\recover-legacy-install-data.ps1"
ReadEnvStr $2 APPDATA
ReadEnvStr $3 USERPROFILE
ReadEnvStr $6 CLAUDE_CONFIG_DIR
@ -134,10 +147,6 @@ Function CcHahaRecoverLegacy
Return
${EndIf}
Push "$INSTDIR"
Call CcHahaFinalInstallDir
Pop $9
DetailPrint "Checking registered installations for legacy Claude Code Haha data..."
nsExec::ExecToStack '"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "$PLUGINSDIR\recover-legacy-install-data.ps1" -PerUserInstallDir "$4" -PerMachineInstallDir "$5" -CandidateInstallDir "$9" -UserDataDir "$2\Claude Code Haha" -RecoveryRoot "$3\Claude Code Haha Data\Recovered" -ProcessName "${PRODUCT_FILENAME}.exe" -ActiveConfigDir "$6" -ActiveConfigManaged "$7" -InstallerIdentitySafety "$8"'
Pop $0
@ -161,7 +170,11 @@ FunctionEnd
${If} $0 != "0"
DetailPrint "Legacy data recovery stopped the installer (helper exit code: $0; output: $1)"
MessageBox MB_ICONSTOP|MB_OK "Claude Code Haha cannot safely recover data stored inside the old application directory. Close the running app and run this installer normally, not as Administrator. The old version and its data have not been removed.$\r$\n$\r$\n无法安全恢复旧安装目录中的数据。请关闭旧程序并以普通方式不要使用“以管理员身份运行”重新运行安装程序。旧版本和原数据尚未删除。"
${If} $1 == ""
StrCpy $1 "Recovery helper failed without diagnostic output (exit code $0)"
${EndIf}
StrCpy $R2 "$1" 360
MessageBox MB_ICONSTOP|MB_OK "Claude Code Haha stopped setup before removing the old version. Reason: $R2$\r$\n$\r$\nClose the app and retry. If the reason mentions an elevated installer, launch setup normally instead of using Run as administrator.$\r$\n$\r$\nClaude Code Haha 已在删除旧版本前停止安装。原因:$R2$\r$\n$\r$\n请关闭旧程序后重试如果原因提到安装器权限过高请直接双击运行不要使用“以管理员身份运行”。旧版本和原数据尚未删除。"
SetErrorLevel 20
Quit
${EndIf}

View File

@ -469,7 +469,7 @@ function Assert-NoRunningApplication {
[Parameter(Mandatory = $true)][string]$ProcessName
)
$deadline = [DateTime]::UtcNow.AddSeconds(5)
$deadline = [DateTime]::UtcNow.AddSeconds(30)
do {
try {
$processes = @(Get-CimInstance Win32_Process -ErrorAction Stop)
@ -642,7 +642,6 @@ function Invoke-LegacyRecovery {
[AllowEmptyString()][string]$ActiveConfigDir,
[AllowEmptyString()][string]$ActiveConfigManaged = '',
[string]$InstallerIdentitySafety = 'trusted-user',
[string[]]$RegisteredInstallDirs = @(),
[switch]$SkipProcessCheck
)
@ -651,13 +650,6 @@ function Invoke-LegacyRecovery {
if ($existingInstallDirs.Count -eq 0) {
return $null
}
if ($InstallerIdentitySafety -eq 'untrusted-elevated' -and
@(Get-ExistingInstallDirs -InstallDirs $RegisteredInstallDirs).Count -gt 0) {
throw 'An existing registered installation cannot be upgraded from an elevated installer without the original user process. Run the installer normally (not as Administrator).'
}
if (-not $SkipProcessCheck) {
Assert-NoRunningApplication -InstallDirs $existingInstallDirs -ProcessName $ProcessName
}
$source = Get-UnsafeLegacySource `
-InstallDirs $existingInstallDirs `
@ -671,6 +663,9 @@ function Invoke-LegacyRecovery {
if ($InstallerIdentitySafety -eq 'untrusted-elevated') {
throw 'Legacy data recovery was requested from an elevated installer without the original user process. Run the installer normally (not as Administrator) so recovery is written to the correct Windows user profile.'
}
if (-not $SkipProcessCheck) {
Assert-NoRunningApplication -InstallDirs $existingInstallDirs -ProcessName $ProcessName
}
foreach ($installDir in $potentialInstallDirs) {
if (Test-PathMayBeDeleted -InstallDir $installDir -Candidate $UserDataDir) {
@ -789,18 +784,29 @@ function Run-SelfTest {
}
Assert-SelfTest -Condition $elevatedFailed -Message 'untrusted elevated recovery did not fail closed'
$elevatedDefaultInstall = Join-Path $testRoot 'elevated default install'
New-Item -ItemType Directory -Path $elevatedDefaultInstall -Force | Out-Null
$elevatedDefaultResult = Invoke-LegacyRecovery `
-InstallDirs @($elevatedDefaultInstall) `
-UserDataDir (Join-Path $testRoot 'elevated default app data') `
-RecoveryRoot (Join-Path $testRoot 'elevated default recovery') -ProcessName $ProcessName `
-ActiveConfigDir '' -InstallerIdentitySafety 'untrusted-elevated' -SkipProcessCheck
Assert-SelfTest -Condition ($null -eq $elevatedDefaultResult) -Message 'elevated default-mode reinstall was blocked without legacy data'
$wrongIdentityInstall = Join-Path $testRoot 'registered shared install'
New-Item -ItemType Directory -Path (Join-Path $wrongIdentityInstall 'custom data') -Force | Out-Null
Set-Content -LiteralPath (Join-Path $wrongIdentityInstall 'custom data\settings.json') -Value 'other-user-data' -NoNewline
$wrongIdentityData = Join-Path $wrongIdentityInstall 'custom data'
New-Item -ItemType Directory -Path $wrongIdentityData -Force | Out-Null
Set-Content -LiteralPath (Join-Path $wrongIdentityData 'settings.json') -Value 'other-user-data' -NoNewline
Write-TestMode -Dir $wrongIdentityData -Value @{ mode = 'portable'; portable_dir = $wrongIdentityData }
$wrongIdentityFailed = $false
try {
Invoke-LegacyRecovery `
-InstallDirs @($wrongIdentityInstall) -RegisteredInstallDirs @($wrongIdentityInstall) `
-InstallDirs @($wrongIdentityInstall) `
-UserDataDir (Join-Path $testRoot 'wrong identity app data') `
-RecoveryRoot (Join-Path $testRoot 'wrong identity recovery') -ProcessName $ProcessName `
-ActiveConfigDir '' -InstallerIdentitySafety 'untrusted-elevated' -SkipProcessCheck | Out-Null
} catch {
$wrongIdentityFailed = $_.Exception.Message.Contains('existing registered installation')
$wrongIdentityFailed = $_.Exception.Message.Contains('another Windows user')
}
Assert-SelfTest -Condition $wrongIdentityFailed -Message 'untrusted elevated installer treated invisible user metadata as safe'
@ -960,13 +966,13 @@ try {
-ActiveConfigDir $ActiveConfigDir `
-ActiveConfigManaged $ActiveConfigManaged `
-InstallerIdentitySafety $InstallerIdentitySafety `
-RegisteredInstallDirs @($PerUserInstallDir, $PerMachineInstallDir) `
-SkipProcessCheck:$SkipProcessCheck
if (-not [string]::IsNullOrWhiteSpace([string]$result)) {
[Console]::Out.WriteLine("Recovered legacy data to $result")
}
exit 0
} catch {
[Console]::Error.WriteLine($_.Exception.Message)
$message = ([string]$_.Exception.Message) -replace '[\r\n]+', ' '
[Console]::Out.WriteLine("Legacy recovery error: $message")
exit 20
}

View File

@ -0,0 +1,79 @@
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)][string]$ArtifactsDir,
[ValidateSet('x64')][string]$Arch = 'x64'
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
if ($env:CI -ne 'true') {
throw 'This installer smoke mutates Windows installer registry state and may run only on an ephemeral CI runner.'
}
$resolvedArtifactsDir = (Resolve-Path -LiteralPath $ArtifactsDir).Path
$installers = @(Get-ChildItem -LiteralPath $resolvedArtifactsDir -File |
Where-Object { $_.Name -like "Claude-Code-Haha-*-win-$Arch.exe" })
if ($installers.Count -ne 1) {
throw "Expected exactly one Windows $Arch installer in $resolvedArtifactsDir, found $($installers.Count)."
}
$installer = $installers[0].FullName
$testRoot = Join-Path ([IO.Path]::GetTempPath()) "cc-haha-installer-smoke-$([Guid]::NewGuid().ToString('N'))"
$installDir = Join-Path $testRoot 'Claude Code Haha'
$appData = Join-Path $testRoot 'AppData\Roaming'
$localAppData = Join-Path $testRoot 'AppData\Local'
$userProfile = Join-Path $testRoot 'UserProfile'
$appExe = Join-Path $installDir 'Claude Code Haha.exe'
$uninstaller = Join-Path $installDir 'Uninstall Claude Code Haha.exe'
$savedEnvironment = @{}
foreach ($name in @('APPDATA', 'LOCALAPPDATA', 'USERPROFILE', 'CLAUDE_CONFIG_DIR', 'CC_HAHA_APP_PORTABLE_DIR')) {
$savedEnvironment[$name] = [Environment]::GetEnvironmentVariable($name, 'Process')
}
function Invoke-CheckedInstaller {
param(
[Parameter(Mandatory = $true)][string]$Stage,
[Parameter(Mandatory = $true)][string[]]$Arguments
)
$process = Start-Process -FilePath $installer -ArgumentList $Arguments -Wait -PassThru
if ($process.ExitCode -ne 0) {
throw "$Stage failed with installer exit code $($process.ExitCode)."
}
}
try {
New-Item -ItemType Directory -Path $appData, $localAppData, $userProfile -Force | Out-Null
$env:APPDATA = $appData
$env:LOCALAPPDATA = $localAppData
$env:USERPROFILE = $userProfile
Remove-Item Env:CLAUDE_CONFIG_DIR -ErrorAction SilentlyContinue
Remove-Item Env:CC_HAHA_APP_PORTABLE_DIR -ErrorAction SilentlyContinue
Invoke-CheckedInstaller -Stage 'Fresh install' -Arguments @('/S', '/currentuser', "/D=$installDir")
if (-not (Test-Path -LiteralPath $appExe -PathType Leaf)) {
throw "Fresh install did not create the application executable: $appExe"
}
Invoke-CheckedInstaller -Stage 'Default-mode reinstall' -Arguments @('--updated', '/S', '/currentuser', "/D=$installDir")
if (-not (Test-Path -LiteralPath $appExe -PathType Leaf)) {
throw "Reinstall removed the application executable: $appExe"
}
[Console]::Out.WriteLine('Windows installer fresh-install and default-mode reinstall smoke passed.')
} finally {
if (Test-Path -LiteralPath $uninstaller -PathType Leaf) {
Start-Process -FilePath $uninstaller -ArgumentList @('/S', '/KEEP_APP_DATA', '/currentuser') -Wait | Out-Null
}
foreach ($name in $savedEnvironment.Keys) {
$value = $savedEnvironment[$name]
if ($null -eq $value) {
[Environment]::SetEnvironmentVariable($name, $null, 'Process')
} else {
[Environment]::SetEnvironmentVariable($name, [string]$value, 'Process')
}
}
Remove-Item -LiteralPath $testRoot -Recurse -Force -ErrorAction SilentlyContinue
}

View File

@ -477,7 +477,7 @@ describe('release desktop workflow', () => {
expect(desktopPackage.build.nsis?.allowToChangeInstallationDirectory).toBe(true)
})
test('Windows NSIS installer fails closed while recovering legacy install-directory data', () => {
test('Windows NSIS installer recovers only registered legacy install-directory data', () => {
const desktopPackage = JSON.parse(readFileSync('desktop/package.json', 'utf8')) as {
scripts?: Record<string, string>
build: {
@ -509,6 +509,7 @@ describe('release desktop workflow', () => {
expect(installerHook).toContain('ReadEnvStr $3 USERPROFILE')
expect(installerHook).toContain('ReadEnvStr $6 CLAUDE_CONFIG_DIR')
expect(installerHook).toContain('ReadEnvStr $7 CC_HAHA_APP_PORTABLE_DIR')
expect(installerHook).toContain('No registered installation needs legacy data recovery')
expect(installerHook).toContain('SetErrorLevel 20')
expect(installerHook).toContain('Quit')
expect(recoveryHelper).toContain('function Get-LegacyActiveSource')
@ -527,19 +528,31 @@ describe('release desktop workflow', () => {
expect(recoveryHelper).toContain('Test-LexicalPathAtOrBelow')
expect(recoveryHelper).toContain('untrusted-elevated')
expect(recoveryHelper).toContain('External CLAUDE_CONFIG_DIR is active while install-contained legacy data still exists')
expect(recoveryHelper).toMatch(/Assert-NoRunningApplication[\s\S]*Get-UnsafeLegacySource/)
expect(recoveryHelper).toMatch(/\$source = Get-UnsafeLegacySource[\s\S]*Assert-NoRunningApplication/)
expect(recoveryHelper).not.toMatch(/InstallerIdentitySafety -eq 'untrusted-elevated' -and\s+@\(Get-ExistingInstallDirs/)
expect(recoveryHelper).toMatch(/Assert-TreeManifestsEqual[\s\S]*Assert-NoRunningApplication[\s\S]*Write-AppModeAtomically/)
expect(recoveryHelper).toContain("AddSeconds(30)")
expect(recoveryHelper).toContain('[Console]::Out.WriteLine("Legacy recovery error:')
expect(recoveryHelper).toContain('reparse point')
expect(recoveryHelper).toContain('Run-SelfTest')
})
test('Windows build and release jobs execute the real legacy recovery self-test', () => {
test('Windows build and release jobs execute helper and compiled-installer smoke tests', () => {
const devWorkflow = readFileSync('.github/workflows/build-desktop-dev.yml', 'utf8')
const releaseWorkflow = readFileSync('.github/workflows/release-desktop.yml', 'utf8')
const installerSmoke = readFileSync('desktop/scripts/windows-installer-smoke.ps1', 'utf8')
for (const workflow of [devWorkflow, releaseWorkflow]) {
expect(workflow).toContain("if: matrix.smoke_platform == 'windows'")
expect(workflow).toContain('bun run test:windows-storage-recovery')
expect(workflow).toContain("matrix.arch == 'x64'")
expect(workflow).toContain('windows-installer-smoke.ps1')
}
expect(installerSmoke).toContain('Invoke-CheckedInstaller')
expect(installerSmoke).toContain("@('/S', '/currentuser'")
expect(installerSmoke).toContain("@('--updated', '/S', '/currentuser'")
expect(installerSmoke).toContain('Fresh install did not create the application executable')
expect(installerSmoke).toContain('Reinstall removed the application executable')
})
})