diff --git a/desktop/build/recover-legacy-install-data.ps1 b/desktop/build/recover-legacy-install-data.ps1 index 7d155909..b8ff45f0 100644 --- a/desktop/build/recover-legacy-install-data.ps1 +++ b/desktop/build/recover-legacy-install-data.ps1 @@ -396,15 +396,28 @@ function Get-UnsafeLegacySource { } if ($activeOutsideInstall -and $ActiveConfigManaged -eq '1') { + # The app-managed active directory lives outside every install directory, + # so removing the old version cannot touch it. A persisted mode that is + # missing, stale, or unresolvable here (the mode was switched without a + # restart, or the installer's APPDATA differs from the app's known-folder + # view) must not block the upgrade: fall back to treating the directory as + # externally managed. Install-contained legacy data is still guarded by + # the external-active check below. $systemMode = Read-AppMode -ConfigDir $UserDataDir - if ($null -eq $systemMode -or - $systemMode.Mode -ne 'portable' -or - [string]::IsNullOrWhiteSpace([string]$systemMode.PortableDir)) { - throw 'App-managed CLAUDE_CONFIG_DIR has no matching persisted custom mode. Restart the old app before upgrading so its active and saved data directories agree.' - } - $persistedActive = Resolve-LegacyConfiguredPath -Value $systemMode.PortableDir -Source 'system app-mode.json' - if (-not (Test-SamePath -Left $active -Right $persistedActive)) { - throw "App-managed CLAUDE_CONFIG_DIR does not match persisted custom mode. Active: $active; persisted: $persistedActive" + if ($null -ne $systemMode) { + if ($systemMode.Mode -ne 'portable' -or + [string]::IsNullOrWhiteSpace([string]$systemMode.PortableDir)) { + $systemMode = $null + } else { + try { + $persistedActive = Resolve-LegacyConfiguredPath -Value $systemMode.PortableDir -Source 'system app-mode.json' + if (-not (Test-SamePath -Left $active -Right $persistedActive)) { + $systemMode = $null + } + } catch { + $systemMode = $null + } + } } } elseif ($activeOutsideInstall) { $systemMode = $null @@ -1008,6 +1021,49 @@ function Run-SelfTest { } Assert-SelfTest -Condition $managedExternalInvalidFailed -Message 'invalid metadata bypassed app-managed external mode validation' + $staleModeInstall = Join-Path $testRoot 'stale mode install' + $staleModeActive = Join-Path $testRoot 'stale mode active data' + $staleModeUserData = Join-Path $testRoot 'stale mode app data' + New-Item -ItemType Directory -Path $staleModeInstall -Force | Out-Null + New-Item -ItemType Directory -Path $staleModeActive -Force | Out-Null + Write-TestMode -Dir $staleModeUserData -Value @{ mode = 'default'; portable_dir = $null } + $staleModeResult = Invoke-LegacyRecovery ` + -InstallDirs @($staleModeInstall) -UserDataDir $staleModeUserData ` + -RecoveryRoot (Join-Path $testRoot 'stale mode recovery') -ProcessName $ProcessName ` + -ActiveConfigDir $staleModeActive -ActiveConfigManaged '1' -SkipProcessCheck + Assert-SelfTest -Condition ($null -eq $staleModeResult) -Message 'stale persisted mode blocked an upgrade whose active data directory is outside every install directory' + + $mismatchInstall = Join-Path $testRoot 'mismatch mode install' + $mismatchActive = Join-Path $testRoot 'mismatch active data' + $mismatchPersisted = Join-Path $testRoot 'mismatch persisted data' + $mismatchUserData = Join-Path $testRoot 'mismatch app data' + New-Item -ItemType Directory -Path $mismatchInstall -Force | Out-Null + New-Item -ItemType Directory -Path $mismatchActive -Force | Out-Null + New-Item -ItemType Directory -Path $mismatchPersisted -Force | Out-Null + Write-TestMode -Dir $mismatchUserData -Value @{ mode = 'portable'; portable_dir = $mismatchPersisted } + $mismatchResult = Invoke-LegacyRecovery ` + -InstallDirs @($mismatchInstall) -UserDataDir $mismatchUserData ` + -RecoveryRoot (Join-Path $testRoot 'mismatch recovery') -ProcessName $ProcessName ` + -ActiveConfigDir $mismatchActive -ActiveConfigManaged '1' -SkipProcessCheck + Assert-SelfTest -Condition ($null -eq $mismatchResult) -Message 'mismatched persisted custom mode blocked an upgrade whose active data directory is outside every install directory' + + $missingModeInstall = Join-Path $testRoot 'missing mode install' + $missingModeLegacy = Join-Path $missingModeInstall 'CLAUDE_CONFIG_DIR' + $missingModeActive = Join-Path $testRoot 'missing mode active data' + New-Item -ItemType Directory -Path $missingModeLegacy -Force | Out-Null + New-Item -ItemType Directory -Path $missingModeActive -Force | Out-Null + Set-Content -LiteralPath (Join-Path $missingModeLegacy 'settings.json') -Value 'missing-mode-data' -NoNewline + $missingModeFailed = $false + try { + Invoke-LegacyRecovery ` + -InstallDirs @($missingModeInstall) -UserDataDir (Join-Path $testRoot 'missing mode app data') ` + -RecoveryRoot (Join-Path $testRoot 'missing mode recovery') -ProcessName $ProcessName ` + -ActiveConfigDir $missingModeActive -ActiveConfigManaged '1' -SkipProcessCheck | Out-Null + } catch { + $missingModeFailed = $_.Exception.Message.Contains('install-contained legacy data still exists') + } + Assert-SelfTest -Condition $missingModeFailed -Message 'missing persisted mode skipped the install-contained legacy data guard' + $externalInstall = Join-Path $testRoot 'external install' $externalLegacy = Join-Path $externalInstall 'CLAUDE_CONFIG_DIR' $externalUserData = Join-Path $testRoot 'external app data' diff --git a/desktop/electron/services/appMode.test.ts b/desktop/electron/services/appMode.test.ts index 01bab042..35629581 100644 --- a/desktop/electron/services/appMode.test.ts +++ b/desktop/electron/services/appMode.test.ts @@ -4,6 +4,7 @@ import path from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { applyStartupPortableMode, + clearAppManagedPortableEnv, determineStartupPortableDir, getAppMode, setAppMode, @@ -120,6 +121,22 @@ describe('Electron app mode service', () => { })).toThrow('outside the application install directory') }) + it('clears only an app-managed portable selection from a handed-off environment', () => { + const customDir = path.join(tempDir(), 'custom-data') + const managedEnv: NodeJS.ProcessEnv = { + CLAUDE_CONFIG_DIR: customDir, + CC_HAHA_APP_PORTABLE_DIR: '1', + WEBVIEW2_USER_DATA_FOLDER: path.join(customDir, 'EBWebView'), + APPDATA: 'C:\\Users\\someone\\AppData\\Roaming', + } + clearAppManagedPortableEnv(managedEnv) + expect(managedEnv).toEqual({ APPDATA: 'C:\\Users\\someone\\AppData\\Roaming' }) + + const externalEnv: NodeJS.ProcessEnv = { CLAUDE_CONFIG_DIR: customDir } + clearAppManagedPortableEnv(externalEnv) + expect(externalEnv).toEqual({ CLAUDE_CONFIG_DIR: customDir }) + }) + it('drops inherited app-managed env so switching back to ~/.claude survives relaunch', () => { const fakeApp = app() writeMode(fakeApp, { mode: 'default', portable_dir: null }) diff --git a/desktop/electron/services/appMode.ts b/desktop/electron/services/appMode.ts index e06856b5..5f532058 100644 --- a/desktop/electron/services/appMode.ts +++ b/desktop/electron/services/appMode.ts @@ -93,6 +93,18 @@ function externallyControlled(env: NodeJS.ProcessEnv): boolean { return Boolean(env.CLAUDE_CONFIG_DIR && env.CC_HAHA_APP_PORTABLE_DIR !== '1') } +// The app-managed portable selection is process-local derived state; the +// persisted app-mode.json stays the source of truth. Strip it before this +// environment reaches another process (app.relaunch(), the NSIS installer +// spawned by quitAndInstall()), otherwise the child would trust a snapshot +// that may no longer match the persisted mode (#1160). +export function clearAppManagedPortableEnv(env: NodeJS.ProcessEnv = process.env): void { + if (env.CC_HAHA_APP_PORTABLE_DIR !== '1') return + delete env.CLAUDE_CONFIG_DIR + delete env.CC_HAHA_APP_PORTABLE_DIR + delete env.WEBVIEW2_USER_DATA_FOLDER +} + export function determineStartupPortableDir( app: AppModeAppLike, env: NodeJS.ProcessEnv = process.env, @@ -115,11 +127,7 @@ export function applyStartupPortableMode( ): string | null { // app.relaunch() inherits process.env. Discard the previous app-managed // selection so the persisted two-mode record remains authoritative. - if (env.CC_HAHA_APP_PORTABLE_DIR === '1') { - delete env.CLAUDE_CONFIG_DIR - delete env.CC_HAHA_APP_PORTABLE_DIR - delete env.WEBVIEW2_USER_DATA_FOLDER - } + clearAppManagedPortableEnv(env) if (env.CLAUDE_CONFIG_DIR) { env.CLAUDE_CONFIG_DIR = normalizedCustomDir(app, env.CLAUDE_CONFIG_DIR) return null diff --git a/desktop/electron/services/updater.test.ts b/desktop/electron/services/updater.test.ts index a9eb986f..d3acf71b 100644 --- a/desktop/electron/services/updater.test.ts +++ b/desktop/electron/services/updater.test.ts @@ -182,4 +182,37 @@ describe('Electron updater service', () => { expect(updater.quitAndInstall).toHaveBeenCalledWith(false, true) }) + + it('hands the spawned installer an environment without the app-managed portable selection', async () => { + const service = new ElectronUpdaterService(updater) + updater.checkForUpdates.mockResolvedValue({ updateInfo: { version: '1.2.4' } }) + updater.downloadUpdate.mockResolvedValue(undefined) + await service.checkForUpdates() + await service.downloadUpdate(() => {}) + + const env: NodeJS.ProcessEnv = { + CLAUDE_CONFIG_DIR: 'E:\\cc-haha-data', + CC_HAHA_APP_PORTABLE_DIR: '1', + WEBVIEW2_USER_DATA_FOLDER: 'E:\\cc-haha-data\\EBWebView', + APPDATA: 'C:\\Users\\someone\\AppData\\Roaming', + } + service.quitAndInstallDownloadedUpdate(env) + + expect(updater.quitAndInstall).toHaveBeenCalledWith(false, true) + expect(env).toEqual({ APPDATA: 'C:\\Users\\someone\\AppData\\Roaming' }) + }) + + it('leaves an externally supplied CLAUDE_CONFIG_DIR untouched when installing', async () => { + const service = new ElectronUpdaterService(updater) + updater.checkForUpdates.mockResolvedValue({ updateInfo: { version: '1.2.4' } }) + updater.downloadUpdate.mockResolvedValue(undefined) + await service.checkForUpdates() + await service.downloadUpdate(() => {}) + + const env: NodeJS.ProcessEnv = { CLAUDE_CONFIG_DIR: 'E:\\external-data' } + service.quitAndInstallDownloadedUpdate(env) + + expect(updater.quitAndInstall).toHaveBeenCalledWith(false, true) + expect(env).toEqual({ CLAUDE_CONFIG_DIR: 'E:\\external-data' }) + }) }) diff --git a/desktop/electron/services/updater.ts b/desktop/electron/services/updater.ts index 24294565..d430d85a 100644 --- a/desktop/electron/services/updater.ts +++ b/desktop/electron/services/updater.ts @@ -1,5 +1,6 @@ import type { DesktopUpdateDownloadEvent } from '../../src/lib/desktopHost/types' import { existsSync } from 'node:fs' +import { clearAppManagedPortableEnv } from './appMode' export type ElectronUpdateInfo = { version: string @@ -193,8 +194,13 @@ export class ElectronUpdaterService { return !!this.pendingUpdate && this.downloaded } - quitAndInstallDownloadedUpdate() { + quitAndInstallDownloadedUpdate(env: NodeJS.ProcessEnv = process.env) { this.stageDownloadedUpdate() + // The NSIS installer spawned here inherits this process's environment. + // Hand it the same clean environment a manually launched setup gets, so + // its legacy-data checks read the persisted app-mode.json instead of a + // process-local snapshot that may disagree with it (#1160). + clearAppManagedPortableEnv(env) this.updater.quitAndInstall(false, true) } }