mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-15 12:53:31 +08:00
fix(desktop): unblock Windows installer upgrades #1000
This commit is contained in:
parent
4c3b08c0a4
commit
ecb66bfdd5
6
.github/workflows/build-desktop-dev.yml
vendored
6
.github/workflows/build-desktop-dev.yml
vendored
@ -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
|
||||
|
||||
|
||||
6
.github/workflows/release-desktop.yml
vendored
6
.github/workflows/release-desktop.yml
vendored
@ -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
|
||||
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
79
desktop/scripts/windows-installer-smoke.ps1
Normal file
79
desktop/scripts/windows-installer-smoke.ps1
Normal 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
|
||||
}
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user