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
+6
View File
@@ -119,6 +119,12 @@ jobs:
CSC_IDENTITY_AUTO_DISCOVERY: 'false' CSC_IDENTITY_AUTO_DISCOVERY: 'false'
run: node ./node_modules/electron-builder/out/cli/cli.js ${{ matrix.builder_args }} --publish never 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 - 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 run: bun run test:package-smoke --platform ${{ matrix.smoke_platform }} --arch ${{ matrix.arch }} --package-kind release --artifacts-dir desktop/build-artifacts/electron
+6
View File
@@ -381,6 +381,12 @@ jobs:
CSC_IDENTITY_AUTO_DISCOVERY: 'false' CSC_IDENTITY_AUTO_DISCOVERY: 'false'
run: node ./node_modules/electron-builder/out/cli/cli.js ${{ matrix.builder_args }} --publish never 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 - 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 run: bun run test:package-smoke --platform ${{ matrix.smoke_platform }} --arch ${{ matrix.arch }} --package-kind release --artifacts-dir desktop/build-artifacts/electron
+21 -8
View File
@@ -90,9 +90,6 @@ Function CcHahaFinalInstallDir
FunctionEnd FunctionEnd
Function CcHahaRecoverLegacy 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 $4 HKCU "${INSTALL_REGISTRY_KEY}" InstallLocation
ReadRegStr $5 HKLM "${INSTALL_REGISTRY_KEY}" InstallLocation ReadRegStr $5 HKLM "${INSTALL_REGISTRY_KEY}" InstallLocation
ReadRegStr $R0 HKCU "${UNINSTALL_REGISTRY_KEY}" UninstallString ReadRegStr $R0 HKCU "${UNINSTALL_REGISTRY_KEY}" UninstallString
@@ -119,6 +116,22 @@ Function CcHahaRecoverLegacy
Call CcHahaUninstallerParent Call CcHahaUninstallerParent
Pop $5 Pop $5
${EndIf} ${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 $2 APPDATA
ReadEnvStr $3 USERPROFILE ReadEnvStr $3 USERPROFILE
ReadEnvStr $6 CLAUDE_CONFIG_DIR ReadEnvStr $6 CLAUDE_CONFIG_DIR
@@ -134,10 +147,6 @@ Function CcHahaRecoverLegacy
Return Return
${EndIf} ${EndIf}
Push "$INSTDIR"
Call CcHahaFinalInstallDir
Pop $9
DetailPrint "Checking registered installations for legacy Claude Code Haha data..." 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"' 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 Pop $0
@@ -161,7 +170,11 @@ FunctionEnd
${If} $0 != "0" ${If} $0 != "0"
DetailPrint "Legacy data recovery stopped the installer (helper exit code: $0; output: $1)" 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 SetErrorLevel 20
Quit Quit
${EndIf} ${EndIf}
+21 -15
View File
@@ -469,7 +469,7 @@ function Assert-NoRunningApplication {
[Parameter(Mandatory = $true)][string]$ProcessName [Parameter(Mandatory = $true)][string]$ProcessName
) )
$deadline = [DateTime]::UtcNow.AddSeconds(5) $deadline = [DateTime]::UtcNow.AddSeconds(30)
do { do {
try { try {
$processes = @(Get-CimInstance Win32_Process -ErrorAction Stop) $processes = @(Get-CimInstance Win32_Process -ErrorAction Stop)
@@ -642,7 +642,6 @@ function Invoke-LegacyRecovery {
[AllowEmptyString()][string]$ActiveConfigDir, [AllowEmptyString()][string]$ActiveConfigDir,
[AllowEmptyString()][string]$ActiveConfigManaged = '', [AllowEmptyString()][string]$ActiveConfigManaged = '',
[string]$InstallerIdentitySafety = 'trusted-user', [string]$InstallerIdentitySafety = 'trusted-user',
[string[]]$RegisteredInstallDirs = @(),
[switch]$SkipProcessCheck [switch]$SkipProcessCheck
) )
@@ -651,13 +650,6 @@ function Invoke-LegacyRecovery {
if ($existingInstallDirs.Count -eq 0) { if ($existingInstallDirs.Count -eq 0) {
return $null 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 ` $source = Get-UnsafeLegacySource `
-InstallDirs $existingInstallDirs ` -InstallDirs $existingInstallDirs `
@@ -671,6 +663,9 @@ function Invoke-LegacyRecovery {
if ($InstallerIdentitySafety -eq 'untrusted-elevated') { 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.' 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) { foreach ($installDir in $potentialInstallDirs) {
if (Test-PathMayBeDeleted -InstallDir $installDir -Candidate $UserDataDir) { 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' 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' $wrongIdentityInstall = Join-Path $testRoot 'registered shared install'
New-Item -ItemType Directory -Path (Join-Path $wrongIdentityInstall 'custom data') -Force | Out-Null $wrongIdentityData = Join-Path $wrongIdentityInstall 'custom data'
Set-Content -LiteralPath (Join-Path $wrongIdentityInstall 'custom data\settings.json') -Value 'other-user-data' -NoNewline 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 $wrongIdentityFailed = $false
try { try {
Invoke-LegacyRecovery ` Invoke-LegacyRecovery `
-InstallDirs @($wrongIdentityInstall) -RegisteredInstallDirs @($wrongIdentityInstall) ` -InstallDirs @($wrongIdentityInstall) `
-UserDataDir (Join-Path $testRoot 'wrong identity app data') ` -UserDataDir (Join-Path $testRoot 'wrong identity app data') `
-RecoveryRoot (Join-Path $testRoot 'wrong identity recovery') -ProcessName $ProcessName ` -RecoveryRoot (Join-Path $testRoot 'wrong identity recovery') -ProcessName $ProcessName `
-ActiveConfigDir '' -InstallerIdentitySafety 'untrusted-elevated' -SkipProcessCheck | Out-Null -ActiveConfigDir '' -InstallerIdentitySafety 'untrusted-elevated' -SkipProcessCheck | Out-Null
} catch { } 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' Assert-SelfTest -Condition $wrongIdentityFailed -Message 'untrusted elevated installer treated invisible user metadata as safe'
@@ -960,13 +966,13 @@ try {
-ActiveConfigDir $ActiveConfigDir ` -ActiveConfigDir $ActiveConfigDir `
-ActiveConfigManaged $ActiveConfigManaged ` -ActiveConfigManaged $ActiveConfigManaged `
-InstallerIdentitySafety $InstallerIdentitySafety ` -InstallerIdentitySafety $InstallerIdentitySafety `
-RegisteredInstallDirs @($PerUserInstallDir, $PerMachineInstallDir) `
-SkipProcessCheck:$SkipProcessCheck -SkipProcessCheck:$SkipProcessCheck
if (-not [string]::IsNullOrWhiteSpace([string]$result)) { if (-not [string]::IsNullOrWhiteSpace([string]$result)) {
[Console]::Out.WriteLine("Recovered legacy data to $result") [Console]::Out.WriteLine("Recovered legacy data to $result")
} }
exit 0 exit 0
} catch { } catch {
[Console]::Error.WriteLine($_.Exception.Message) $message = ([string]$_.Exception.Message) -replace '[\r\n]+', ' '
[Console]::Out.WriteLine("Legacy recovery error: $message")
exit 20 exit 20
} }
@@ -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
}
+16 -3
View File
@@ -477,7 +477,7 @@ describe('release desktop workflow', () => {
expect(desktopPackage.build.nsis?.allowToChangeInstallationDirectory).toBe(true) 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 { const desktopPackage = JSON.parse(readFileSync('desktop/package.json', 'utf8')) as {
scripts?: Record<string, string> scripts?: Record<string, string>
build: { build: {
@@ -509,6 +509,7 @@ describe('release desktop workflow', () => {
expect(installerHook).toContain('ReadEnvStr $3 USERPROFILE') expect(installerHook).toContain('ReadEnvStr $3 USERPROFILE')
expect(installerHook).toContain('ReadEnvStr $6 CLAUDE_CONFIG_DIR') expect(installerHook).toContain('ReadEnvStr $6 CLAUDE_CONFIG_DIR')
expect(installerHook).toContain('ReadEnvStr $7 CC_HAHA_APP_PORTABLE_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('SetErrorLevel 20')
expect(installerHook).toContain('Quit') expect(installerHook).toContain('Quit')
expect(recoveryHelper).toContain('function Get-LegacyActiveSource') expect(recoveryHelper).toContain('function Get-LegacyActiveSource')
@@ -527,19 +528,31 @@ describe('release desktop workflow', () => {
expect(recoveryHelper).toContain('Test-LexicalPathAtOrBelow') expect(recoveryHelper).toContain('Test-LexicalPathAtOrBelow')
expect(recoveryHelper).toContain('untrusted-elevated') expect(recoveryHelper).toContain('untrusted-elevated')
expect(recoveryHelper).toContain('External CLAUDE_CONFIG_DIR is active while install-contained legacy data still exists') 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).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('reparse point')
expect(recoveryHelper).toContain('Run-SelfTest') 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 devWorkflow = readFileSync('.github/workflows/build-desktop-dev.yml', 'utf8')
const releaseWorkflow = readFileSync('.github/workflows/release-desktop.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]) { for (const workflow of [devWorkflow, releaseWorkflow]) {
expect(workflow).toContain("if: matrix.smoke_platform == 'windows'") expect(workflow).toContain("if: matrix.smoke_platform == 'windows'")
expect(workflow).toContain('bun run test:windows-storage-recovery') 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')
}) })
}) })