From ecb66bfdd5709c943a93579ed4d9e6686b6fc704 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Tue, 14 Jul 2026 03:15:54 +0800 Subject: [PATCH] fix(desktop): unblock Windows installer upgrades #1000 --- .github/workflows/build-desktop-dev.yml | 6 ++ .github/workflows/release-desktop.yml | 6 ++ desktop/build/installer.nsh | 29 +++++-- desktop/build/recover-legacy-install-data.ps1 | 36 +++++---- desktop/scripts/windows-installer-smoke.ps1 | 79 +++++++++++++++++++ scripts/pr/release-workflow.test.ts | 19 ++++- 6 files changed, 149 insertions(+), 26 deletions(-) create mode 100644 desktop/scripts/windows-installer-smoke.ps1 diff --git a/.github/workflows/build-desktop-dev.yml b/.github/workflows/build-desktop-dev.yml index 68811f63..3c98f999 100644 --- a/.github/workflows/build-desktop-dev.yml +++ b/.github/workflows/build-desktop-dev.yml @@ -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 diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index 9c126263..45267838 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -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 diff --git a/desktop/build/installer.nsh b/desktop/build/installer.nsh index f4f23523..14fa3a67 100644 --- a/desktop/build/installer.nsh +++ b/desktop/build/installer.nsh @@ -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} diff --git a/desktop/build/recover-legacy-install-data.ps1 b/desktop/build/recover-legacy-install-data.ps1 index a443a37a..31b37f2a 100644 --- a/desktop/build/recover-legacy-install-data.ps1 +++ b/desktop/build/recover-legacy-install-data.ps1 @@ -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 } diff --git a/desktop/scripts/windows-installer-smoke.ps1 b/desktop/scripts/windows-installer-smoke.ps1 new file mode 100644 index 00000000..8fa50794 --- /dev/null +++ b/desktop/scripts/windows-installer-smoke.ps1 @@ -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 +} diff --git a/scripts/pr/release-workflow.test.ts b/scripts/pr/release-workflow.test.ts index 319ef95f..5a11cb03 100644 --- a/scripts/pr/release-workflow.test.ts +++ b/scripts/pr/release-workflow.test.ts @@ -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 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') }) })