diff --git a/.github/workflows/build-desktop-dev.yml b/.github/workflows/build-desktop-dev.yml index 2db4ac86..68811f63 100644 --- a/.github/workflows/build-desktop-dev.yml +++ b/.github/workflows/build-desktop-dev.yml @@ -95,6 +95,11 @@ jobs: working-directory: adapters run: bun install + - name: Verify Windows legacy data recovery + if: matrix.smoke_platform == 'windows' + working-directory: desktop + run: bun run test:windows-storage-recovery + - name: Build sidecars working-directory: desktop env: diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index 6fbf2135..9c126263 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -168,6 +168,11 @@ jobs: working-directory: adapters run: bun install + - name: Verify Windows legacy data recovery + if: matrix.smoke_platform == 'windows' + working-directory: desktop + run: bun run test:windows-storage-recovery + - name: Build sidecars working-directory: desktop env: diff --git a/desktop/build/installer.nsh b/desktop/build/installer.nsh new file mode 100644 index 00000000..f4f23523 --- /dev/null +++ b/desktop/build/installer.nsh @@ -0,0 +1,200 @@ +!include "LogicLib.nsh" +!include "getProcessInfo.nsh" +!define /ifndef INSTALL_REGISTRY_KEY "Software\${APP_GUID}" +!define /ifndef UNINSTALL_REGISTRY_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\${UNINSTALL_APP_KEY}" +Var pid + +!ifndef BUILD_UNINSTALLER +Var ccHahaRecoveryDone + +Function CcHahaUninstallerParent + Exch $R0 + Push $R1 + Push $R2 + Push $R3 + + StrCpy $R2 0 + + cc_haha_uninstall_parent_find_first_quote: + StrCpy $R1 $R0 1 $R2 + StrCmp $R1 "" cc_haha_uninstall_parent_invalid + StrCmp $R1 '"' cc_haha_uninstall_parent_after_first_quote + IntOp $R2 $R2 + 1 + Goto cc_haha_uninstall_parent_find_first_quote + + cc_haha_uninstall_parent_after_first_quote: + IntOp $R2 $R2 + 1 + StrCpy $R0 $R0 "" $R2 + StrCpy $R2 0 + + cc_haha_uninstall_parent_find_second_quote: + StrCpy $R1 $R0 1 $R2 + StrCmp $R1 "" cc_haha_uninstall_parent_invalid + StrCmp $R1 '"' cc_haha_uninstall_parent_have_file + IntOp $R2 $R2 + 1 + Goto cc_haha_uninstall_parent_find_second_quote + + cc_haha_uninstall_parent_have_file: + StrCpy $R0 $R0 $R2 + StrLen $R2 $R0 + + cc_haha_uninstall_parent_find_slash: + IntOp $R2 $R2 - 1 + IntCmp $R2 0 cc_haha_uninstall_parent_invalid 0 0 + StrCpy $R1 $R0 1 $R2 + StrCmp $R1 "\" cc_haha_uninstall_parent_done + Goto cc_haha_uninstall_parent_find_slash + + cc_haha_uninstall_parent_invalid: + StrCpy $R0 "" + Goto cc_haha_uninstall_parent_done + + cc_haha_uninstall_parent_done: + StrCpy $R0 $R0 $R2 + Pop $R3 + Pop $R2 + Pop $R1 + Exch $R0 +FunctionEnd + +Function CcHahaFinalInstallDir + Exch $R0 + Push $R1 + Push $R2 + Push $R3 + Push $R4 + Push $R5 + + StrCpy $R1 "${APP_FILENAME}" + StrLen $R2 $R1 + StrLen $R3 $R0 + StrCpy $R4 0 + + cc_haha_final_install_find_name: + IntCmp $R4 $R3 cc_haha_final_install_append 0 cc_haha_final_install_append + StrCpy $R5 $R0 $R2 $R4 + StrCmp $R5 $R1 cc_haha_final_install_done + IntOp $R4 $R4 + 1 + Goto cc_haha_final_install_find_name + + cc_haha_final_install_append: + StrCpy $R0 "$R0\${APP_FILENAME}" + + cc_haha_final_install_done: + Pop $R5 + Pop $R4 + Pop $R3 + Pop $R2 + Pop $R1 + Exch $R0 +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 + ${If} $R0 == "" + !ifdef UNINSTALL_REGISTRY_KEY_2 + ReadRegStr $R0 HKCU "${UNINSTALL_REGISTRY_KEY_2}" UninstallString + !endif + ${EndIf} + ${If} $4 == "" + ${AndIf} $R0 != "" + Push $R0 + Call CcHahaUninstallerParent + Pop $4 + ${EndIf} + ReadRegStr $R1 HKLM "${UNINSTALL_REGISTRY_KEY}" UninstallString + ${If} $R1 == "" + !ifdef UNINSTALL_REGISTRY_KEY_2 + ReadRegStr $R1 HKLM "${UNINSTALL_REGISTRY_KEY_2}" UninstallString + !endif + ${EndIf} + ${If} $5 == "" + ${AndIf} $R1 != "" + Push $R1 + Call CcHahaUninstallerParent + Pop $5 + ${EndIf} + ReadEnvStr $2 APPDATA + ReadEnvStr $3 USERPROFILE + ReadEnvStr $6 CLAUDE_CONFIG_DIR + ReadEnvStr $7 CC_HAHA_APP_PORTABLE_DIR + ${If} $2 == "" + StrCpy $0 "21" + StrCpy $1 "missing current-user APPDATA" + Return + ${EndIf} + ${If} $3 == "" + StrCpy $0 "21" + StrCpy $1 "missing current-user USERPROFILE" + 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 + Pop $1 +FunctionEnd + +!macro CcHahaRunLegacyRecovery + ${If} $ccHahaRecoveryDone != "1" + StrCpy $8 "trusted-user" + ${If} ${UAC_IsAdmin} + ${AndIfNot} ${UAC_IsInnerInstance} + StrCpy $8 "untrusted-elevated" + ${EndIf} + + ${If} ${UAC_IsInnerInstance} + StrCpy $8 "trusted-uac-outer" + !insertmacro UAC_AsUser_Call Function CcHahaRecoverLegacy ${UAC_SYNCREGISTERS}|${UAC_SYNCOUTDIR}|${UAC_SYNCINSTDIR} + ${Else} + Call CcHahaRecoverLegacy + ${EndIf} + + ${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无法安全恢复旧安装目录中的数据。请关闭旧程序,并以普通方式(不要使用“以管理员身份运行”)重新运行安装程序。旧版本和原数据尚未删除。" + SetErrorLevel 20 + Quit + ${EndIf} + StrCpy $ccHahaRecoveryDone "1" + DetailPrint "Legacy Claude Code Haha data safety check completed" + ${EndIf} +!macroend +!endif + +!macro customCheckAppRunning + !insertmacro IS_POWERSHELL_AVAILABLE + !insertmacro _CHECK_APP_RUNNING + !ifndef BUILD_UNINSTALLER + !insertmacro CcHahaRunLegacyRecovery + !endif +!macroend + +!ifndef BUILD_UNINSTALLER +!macro customPageAfterChangeDir + Function CcHahaRecoveryBeforeInstall + ${If} ${UAC_IsInnerInstance} + !insertmacro CcHahaRunLegacyRecovery + ${EndIf} + Abort + FunctionEnd + Page custom CcHahaRecoveryBeforeInstall +!macroend + +!macro customInit + StrCpy $ccHahaRecoveryDone "0" + ${If} ${UAC_IsInnerInstance} + ${AndIf} ${Silent} + !insertmacro CcHahaRunLegacyRecovery + ${EndIf} +!macroend +!endif diff --git a/desktop/build/recover-legacy-install-data.ps1 b/desktop/build/recover-legacy-install-data.ps1 new file mode 100644 index 00000000..a443a37a --- /dev/null +++ b/desktop/build/recover-legacy-install-data.ps1 @@ -0,0 +1,972 @@ +[CmdletBinding()] +param( + [string]$PerUserInstallDir = '', + [string]$PerMachineInstallDir = '', + [string]$CandidateInstallDir = '', + [string]$UserDataDir = '', + [string]$RecoveryRoot = '', + [string]$ProcessName = 'Claude Code Haha.exe', + [string]$ActiveConfigDir = $env:CLAUDE_CONFIG_DIR, + [string]$ActiveConfigManaged = $env:CC_HAHA_APP_PORTABLE_DIR, + [ValidateSet('trusted-user', 'trusted-uac-outer', 'untrusted-elevated')] + [string]$InstallerIdentitySafety = 'trusted-user', + [switch]$SkipProcessCheck, + [switch]$SelfTest +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +if (-not ('CcHahaRecoveryNativePath' -as [type])) { + Add-Type @' +using System; +using System.ComponentModel; +using System.IO; +using System.Runtime.InteropServices; +using System.Text; +using Microsoft.Win32.SafeHandles; + +public static class CcHahaRecoveryNativePath +{ + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern SafeFileHandle CreateFile( + string fileName, + uint desiredAccess, + uint shareMode, + IntPtr securityAttributes, + uint creationDisposition, + uint flagsAndAttributes, + IntPtr templateFile); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern uint GetFinalPathNameByHandle( + SafeFileHandle file, + StringBuilder path, + uint pathLength, + uint flags); + + public static string Resolve(string path) + { + const uint shareReadWriteDelete = 0x00000007; + const uint openExisting = 3; + const uint backupSemantics = 0x02000000; + using (SafeFileHandle handle = CreateFile( + path, + 0, + shareReadWriteDelete, + IntPtr.Zero, + openExisting, + backupSemantics, + IntPtr.Zero)) + { + if (handle.IsInvalid) { + throw new Win32Exception( + Marshal.GetLastWin32Error(), + "Cannot resolve the final path for " + path); + } + StringBuilder result = new StringBuilder(32768); + uint length = GetFinalPathNameByHandle(handle, result, (uint)result.Capacity, 0); + if (length == 0) { + throw new Win32Exception( + Marshal.GetLastWin32Error(), + "Cannot resolve the final path for " + path); + } + if (length >= (uint)result.Capacity) { + throw new InvalidOperationException("Resolved path is too long: " + path); + } + string value = result.ToString(); + if (value.StartsWith(@"\\?\UNC\", StringComparison.OrdinalIgnoreCase)) { + return @"\\" + value.Substring(8); + } + return value.StartsWith(@"\\?\", StringComparison.OrdinalIgnoreCase) + ? value.Substring(4) + : value; + } + } +} +'@ +} + +function Resolve-CanonicalPath { + param([Parameter(Mandatory = $true)][string]$Path) + + $fullPath = [IO.Path]::GetFullPath($Path) + $existingPath = $fullPath + $missingSegments = New-Object 'System.Collections.Generic.List[string]' + while (-not (Test-Path -LiteralPath $existingPath)) { + $parent = [IO.Path]::GetDirectoryName($existingPath) + if ([string]::IsNullOrEmpty($parent) -or $parent -eq $existingPath) { + throw "Cannot resolve an existing ancestor for $Path" + } + $missingSegments.Insert(0, [IO.Path]::GetFileName($existingPath)) + $existingPath = $parent + } + + $resolved = [CcHahaRecoveryNativePath]::Resolve($existingPath) + foreach ($segment in $missingSegments) { + $resolved = Join-Path $resolved $segment + } + return [IO.Path]::GetFullPath($resolved) +} + +function Test-PathAtOrBelow { + param( + [Parameter(Mandatory = $true)][string]$Parent, + [Parameter(Mandatory = $true)][string]$Candidate + ) + + $resolvedParent = (Resolve-CanonicalPath $Parent).TrimEnd('\', '/') + $resolvedCandidate = (Resolve-CanonicalPath $Candidate).TrimEnd('\', '/') + if ($resolvedCandidate.Equals($resolvedParent, [StringComparison]::OrdinalIgnoreCase)) { + return $true + } + return $resolvedCandidate.StartsWith( + $resolvedParent + [IO.Path]::DirectorySeparatorChar, + [StringComparison]::OrdinalIgnoreCase) +} + +function Test-LexicalPathAtOrBelow { + param( + [Parameter(Mandatory = $true)][string]$Parent, + [Parameter(Mandatory = $true)][string]$Candidate + ) + + $fullParent = [IO.Path]::GetFullPath($Parent).TrimEnd('\', '/') + $fullCandidate = [IO.Path]::GetFullPath($Candidate).TrimEnd('\', '/') + if ($fullCandidate.Equals($fullParent, [StringComparison]::OrdinalIgnoreCase)) { + return $true + } + return $fullCandidate.StartsWith( + $fullParent + [IO.Path]::DirectorySeparatorChar, + [StringComparison]::OrdinalIgnoreCase) +} + +function Test-PathMayBeDeleted { + param( + [Parameter(Mandatory = $true)][string]$InstallDir, + [Parameter(Mandatory = $true)][string]$Candidate + ) + + return (Test-LexicalPathAtOrBelow -Parent $InstallDir -Candidate $Candidate) -or + (Test-PathAtOrBelow -Parent $InstallDir -Candidate $Candidate) +} + +function Test-SamePath { + param( + [Parameter(Mandatory = $true)][string]$Left, + [Parameter(Mandatory = $true)][string]$Right + ) + return (Resolve-CanonicalPath $Left).TrimEnd('\', '/').Equals( + (Resolve-CanonicalPath $Right).TrimEnd('\', '/'), + [StringComparison]::OrdinalIgnoreCase) +} + +function Read-AppMode { + param([Parameter(Mandatory = $true)][string]$ConfigDir) + + $modeFile = Join-Path $ConfigDir 'app-mode.json' + if (-not (Test-Path -LiteralPath $modeFile -PathType Leaf)) { + return $null + } + try { + $parsed = Get-Content -LiteralPath $modeFile -Raw | ConvertFrom-Json + } catch { + throw "Existing app mode metadata cannot be read safely ($modeFile): $($_.Exception.Message)" + } + if ($null -eq $parsed -or $parsed -isnot [pscustomobject]) { + throw "Existing app mode metadata is not a JSON object: $modeFile" + } + + $modeProperty = $parsed.PSObject.Properties['mode'] + if ($null -ne $modeProperty -and $modeProperty.Value -isnot [string]) { + throw "Existing app mode metadata has an invalid mode value: $modeFile" + } + $mode = if ($null -ne $modeProperty) { + ([string]$modeProperty.Value).ToLowerInvariant() + } else { + 'default' + } + if ($mode -notin @('default', 'portable')) { + throw "Existing app mode metadata has an unsupported mode '$mode': $modeFile" + } + + $portableProperty = $parsed.PSObject.Properties['portable_dir'] + if ($null -ne $portableProperty -and + $null -ne $portableProperty.Value -and + $portableProperty.Value -isnot [string]) { + throw "Existing app mode metadata has an invalid portable_dir value: $modeFile" + } + $portableDir = if ($null -ne $portableProperty -and $portableProperty.Value -is [string]) { + ([string]$portableProperty.Value).Trim() + } else { + $null + } + return [pscustomobject]@{ + Mode = $mode + PortableDir = $portableDir + } +} + +function Test-LegacyPortableData { + param([Parameter(Mandatory = $true)][string]$Dir) + + if (-not (Test-Path -LiteralPath $Dir -PathType Container)) { + return $false + } + foreach ($file in @('settings.json', '.claude.json', '.mcp.json', 'window-state.json', 'terminal-config.json')) { + if (Test-Path -LiteralPath (Join-Path $Dir $file) -PathType Leaf) { + return $true + } + } + foreach ($childDir in @('Cache', 'EBWebView', 'projects', 'skills', 'plugins', 'cowork_plugins', 'cc-haha')) { + if (Test-Path -LiteralPath (Join-Path $Dir $childDir) -PathType Container) { + return $true + } + } + return $false +} + +function Resolve-LegacyConfiguredPath { + param( + [Parameter(Mandatory = $true)][string]$Value, + [Parameter(Mandatory = $true)][string]$Source + ) + + if (-not [IO.Path]::IsPathRooted($Value)) { + throw "Legacy custom data path is relative and cannot be recovered safely ($Source): $Value" + } + return [IO.Path]::GetFullPath($Value) +} + +function Get-LegacyActiveSource { + param( + [Parameter(Mandatory = $true)][string]$InstallDir, + $SystemMode + ) + + $legacyDir = Join-Path $InstallDir 'CLAUDE_CONFIG_DIR' + $legacyMode = Read-AppMode -ConfigDir $legacyDir + if ($null -ne $legacyMode) { + if ($legacyMode.Mode -ne 'portable') { + return $null + } + if (Test-LegacyPortableData -Dir $legacyDir) { + return $legacyDir + } + if ([string]::IsNullOrWhiteSpace([string]$legacyMode.PortableDir)) { + return $legacyDir + } + return Resolve-LegacyConfiguredPath -Value $legacyMode.PortableDir -Source (Join-Path $legacyDir 'app-mode.json') + } + + if ($null -ne $SystemMode) { + if ($SystemMode.Mode -ne 'portable') { + return $null + } + if ([string]::IsNullOrWhiteSpace([string]$SystemMode.PortableDir)) { + return $legacyDir + } + return Resolve-LegacyConfiguredPath -Value $SystemMode.PortableDir -Source 'system app-mode.json' + } + + if (Test-LegacyPortableData -Dir $legacyDir) { + return $legacyDir + } + return $null +} + +function Get-ExistingInstallDirs { + param([string[]]$InstallDirs) + + $seen = New-Object 'System.Collections.Generic.HashSet[string]' ([StringComparer]::OrdinalIgnoreCase) + $result = New-Object 'System.Collections.Generic.List[string]' + foreach ($installDir in $InstallDirs) { + if ([string]::IsNullOrWhiteSpace($installDir) -or -not (Test-Path -LiteralPath $installDir -PathType Container)) { + continue + } + $canonical = Resolve-CanonicalPath $installDir + if ($seen.Add($canonical)) { + $result.Add($canonical) + } + } + return $result.ToArray() +} + +function Get-PotentialInstallDirs { + param([string[]]$InstallDirs) + + $seen = New-Object 'System.Collections.Generic.HashSet[string]' ([StringComparer]::OrdinalIgnoreCase) + $result = New-Object 'System.Collections.Generic.List[string]' + foreach ($installDir in $InstallDirs) { + if ([string]::IsNullOrWhiteSpace($installDir)) { + continue + } + if (-not [IO.Path]::IsPathRooted($installDir)) { + throw "Application install directory is relative and cannot be checked safely: $installDir" + } + $fullPath = [IO.Path]::GetFullPath($installDir) + if ($seen.Add($fullPath)) { + $result.Add($fullPath) + } + } + return $result.ToArray() +} + +function Get-UnsafeLegacySource { + param( + [Parameter(Mandatory = $true)][string[]]$InstallDirs, + [Parameter(Mandatory = $true)][string]$UserDataDir, + [AllowEmptyString()][string]$ActiveConfigDir, + [AllowEmptyString()][string]$ActiveConfigManaged + ) + + $sources = New-Object 'System.Collections.Generic.Dictionary[string,string]' ([StringComparer]::OrdinalIgnoreCase) + $activeInsideInstall = $false + $activeOutsideInstall = $false + $active = $null + if (-not [string]::IsNullOrWhiteSpace($ActiveConfigDir)) { + if (-not [IO.Path]::IsPathRooted($ActiveConfigDir)) { + throw "Active CLAUDE_CONFIG_DIR is relative and cannot be recovered safely: $ActiveConfigDir" + } + $active = [IO.Path]::GetFullPath($ActiveConfigDir) + foreach ($installDir in $InstallDirs) { + if (Test-PathMayBeDeleted -InstallDir $installDir -Candidate $active) { + $activeInsideInstall = $true + if ($ActiveConfigManaged -ne '1') { + throw "Active CLAUDE_CONFIG_DIR is managed outside Claude Code Haha and points inside an application install directory. Move or remove that environment variable before upgrading: $active" + } + if (Test-SamePath -Left $installDir -Right $active) { + throw "The active data directory is the application install root itself: $active" + } + if (Test-Path -LiteralPath $active -PathType Container) { + $canonicalActive = Resolve-CanonicalPath $active + if (-not $sources.ContainsKey($canonicalActive)) { + $sources.Add($canonicalActive, $active) + } + } + break + } + } + if (-not $activeInsideInstall) { + $activeOutsideInstall = $true + } + } + + if ($activeOutsideInstall -and $ActiveConfigManaged -eq '1') { + $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" + } + } elseif ($activeOutsideInstall) { + $systemMode = $null + } else { + $systemMode = Read-AppMode -ConfigDir $UserDataDir + } + foreach ($installDir in $InstallDirs) { + $source = Get-LegacyActiveSource -InstallDir $installDir -SystemMode $systemMode + if ([string]::IsNullOrWhiteSpace([string]$source) -or + -not (Test-Path -LiteralPath $source -PathType Container)) { + continue + } + foreach ($possiblyDeletedRoot in $InstallDirs) { + if (Test-PathMayBeDeleted -InstallDir $possiblyDeletedRoot -Candidate $source) { + if (Test-SamePath -Left $possiblyDeletedRoot -Right $source) { + throw "The active data directory is the application install root itself: $source" + } + $canonicalSource = Resolve-CanonicalPath $source + if (-not $sources.ContainsKey($canonicalSource)) { + $sources.Add($canonicalSource, $source) + } + break + } + } + } + + if ($activeOutsideInstall) { + if ($sources.Count -gt 0) { + throw "External CLAUDE_CONFIG_DIR is active while install-contained legacy data still exists. Refusing to remove data that may belong to another Windows user: $($sources.Values -join ', ')" + } + return $null + } + if ($sources.Count -gt 1) { + throw "Multiple distinct legacy data sources may be removed; refusing to guess which one is active: $($sources.Values -join ', ')" + } + if ($sources.Count -eq 1) { + return @($sources.Values)[0] + } + return $null +} + +function Assert-NoUndiscoveredLegacySources { + param( + [Parameter(Mandatory = $true)][string[]]$InstallDirs, + [AllowNull()][AllowEmptyString()][string]$ActiveSource + ) + + foreach ($installDir in $InstallDirs) { + $pending = New-Object 'System.Collections.Generic.Stack[string]' + $pending.Push($installDir) + while ($pending.Count -gt 0) { + $currentDir = $pending.Pop() + foreach ($entry in [IO.Directory]::EnumerateFileSystemEntries($currentDir)) { + $attributes = [IO.File]::GetAttributes($entry) + if (($attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "Application install tree contains a reparse point that prevents a complete legacy data scan: $entry" + } + if (($attributes -band [IO.FileAttributes]::Directory) -ne 0) { + $pending.Push($entry) + continue + } + if (-not ([IO.Path]::GetFileName($entry)).Equals('app-mode.json', [StringComparison]::OrdinalIgnoreCase)) { + continue + } + + $modeDir = [IO.Path]::GetDirectoryName($entry) + $mode = Read-AppMode -ConfigDir $modeDir + if ($null -eq $mode -or $mode.Mode -ne 'portable') { + continue + } + $candidate = if (Test-LegacyPortableData -Dir $modeDir) { + $modeDir + } elseif ([string]::IsNullOrWhiteSpace([string]$mode.PortableDir)) { + $modeDir + } else { + Resolve-LegacyConfiguredPath -Value $mode.PortableDir -Source $entry + } + if (-not (Test-Path -LiteralPath $candidate -PathType Container)) { + continue + } + + $willBeRemoved = $false + foreach ($possiblyDeletedRoot in $InstallDirs) { + if (Test-PathMayBeDeleted -InstallDir $possiblyDeletedRoot -Candidate $candidate) { + $willBeRemoved = $true + break + } + } + if (-not $willBeRemoved) { + continue + } + if (-not [string]::IsNullOrWhiteSpace($ActiveSource) -and + (Test-SamePath -Left $ActiveSource -Right $candidate)) { + continue + } + throw "Install-contained custom data was configured by another Windows user or an undiscovered legacy mode. Refusing to remove it: $candidate (metadata: $entry)" + } + } + } +} + +function Assert-NoRunningApplication { + param( + [Parameter(Mandatory = $true)][string[]]$InstallDirs, + [Parameter(Mandatory = $true)][string]$ProcessName + ) + + $deadline = [DateTime]::UtcNow.AddSeconds(5) + do { + try { + $processes = @(Get-CimInstance Win32_Process -ErrorAction Stop) + } catch { + throw "Cannot verify whether the old application or one of its sidecars is still running: $($_.Exception.Message)" + } + + $matching = New-Object 'System.Collections.Generic.List[string]' + $hasUnknownAppPath = $false + foreach ($process in $processes) { + $executablePath = [string]$process.ExecutablePath + if ([string]::IsNullOrWhiteSpace($executablePath)) { + if (([string]$process.Name).Equals($ProcessName, [StringComparison]::OrdinalIgnoreCase)) { + $hasUnknownAppPath = $true + } + continue + } + foreach ($installDir in $InstallDirs) { + if (Test-PathMayBeDeleted -InstallDir $installDir -Candidate $executablePath) { + $matching.Add($executablePath) + break + } + } + } + if ($matching.Count -eq 0 -and -not $hasUnknownAppPath) { + return + } + Start-Sleep -Milliseconds 500 + } while ([DateTime]::UtcNow -lt $deadline) + + if ($hasUnknownAppPath) { + throw "Cannot verify the executable path of a running $ProcessName process. Close the app and run the installer again." + } + throw "An application process is still running from an installation that may contain legacy data: $($matching -join ', '). Close the app and run the installer again." +} + +function Get-FileSha256 { + param([Parameter(Mandatory = $true)][string]$Path) + + $stream = [IO.File]::Open($Path, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::Read) + try { + $sha = [Security.Cryptography.SHA256]::Create() + try { + return ([BitConverter]::ToString($sha.ComputeHash($stream))).Replace('-', '') + } finally { + $sha.Dispose() + } + } finally { + $stream.Dispose() + } +} + +function Get-TreeManifest { + param([Parameter(Mandatory = $true)][string]$Root) + + $rootPath = [IO.Path]::GetFullPath($Root).TrimEnd('\', '/') + $rootAttributes = [IO.File]::GetAttributes($rootPath) + if (($rootAttributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "Legacy data root is a reparse point and cannot be copied without ambiguity: $rootPath" + } + + $entries = New-Object 'System.Collections.Generic.List[string]' + $pending = New-Object 'System.Collections.Generic.Stack[string]' + $pending.Push($rootPath) + while ($pending.Count -gt 0) { + $currentDir = $pending.Pop() + foreach ($entry in [IO.Directory]::EnumerateFileSystemEntries($currentDir)) { + $attributes = [IO.File]::GetAttributes($entry) + if (($attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "Legacy data contains a reparse point and recovery must stop: $entry" + } + $relative = [IO.Path]::GetFullPath($entry).Substring($rootPath.Length).TrimStart('\', '/') + if (($attributes -band [IO.FileAttributes]::Directory) -ne 0) { + $entries.Add("D|$relative") + $pending.Push($entry) + } else { + $file = Get-Item -LiteralPath $entry -Force + $entries.Add("F|$relative|$($file.Length)|$(Get-FileSha256 -Path $entry)") + } + } + } + return @($entries.ToArray() | Sort-Object) +} + +function Assert-TreeManifestsEqual { + param( + [Parameter(Mandatory = $true)][AllowEmptyCollection()][string[]]$Expected, + [Parameter(Mandatory = $true)][AllowEmptyCollection()][string[]]$Actual, + [Parameter(Mandatory = $true)][string]$Message + ) + + if ($Expected.Count -ne $Actual.Count -or + $null -ne (Compare-Object -ReferenceObject @($Expected) -DifferenceObject @($Actual) -CaseSensitive)) { + throw $Message + } +} + +function Copy-VerifiedTree { + param( + [Parameter(Mandatory = $true)][string]$Source, + [Parameter(Mandatory = $true)][string]$RecoveryRoot + ) + + New-Item -ItemType Directory -Path $RecoveryRoot -Force | Out-Null + $id = [Guid]::NewGuid().ToString('N') + $timestamp = [DateTime]::UtcNow.ToString('yyyyMMdd-HHmmss') + $staging = Join-Path $RecoveryRoot ".$id.partial" + $destination = Join-Path $RecoveryRoot "Recovered-$timestamp-$id" + New-Item -ItemType Directory -Path $staging | Out-Null + + try { + $before = @(Get-TreeManifest -Root $Source) + & "$env:SystemRoot\System32\robocopy.exe" ` + $Source $staging /E /COPY:DAT /DCOPY:DAT /R:2 /W:1 /XJ /NFL /NDL /NJH /NJS /NP | Out-Null + $robocopyExitCode = $LASTEXITCODE + if ($robocopyExitCode -ge 8) { + throw "robocopy failed with exit code $robocopyExitCode" + } + + $after = @(Get-TreeManifest -Root $Source) + $copied = @(Get-TreeManifest -Root $staging) + Assert-TreeManifestsEqual -Expected $before -Actual $after -Message 'Legacy data changed while it was being copied; close every app using it and retry.' + Assert-TreeManifestsEqual -Expected $after -Actual $copied -Message 'The recovered copy does not exactly match the legacy data source.' + + [IO.Directory]::Move($staging, $destination) + return $destination + } finally { + if (Test-Path -LiteralPath $staging) { + Remove-Item -LiteralPath $staging -Recurse -Force + } + } +} + +function Write-AppModeAtomically { + param( + [Parameter(Mandatory = $true)][string]$UserDataDir, + [Parameter(Mandatory = $true)][string]$CustomDir + ) + + New-Item -ItemType Directory -Path $UserDataDir -Force | Out-Null + $target = Join-Path $UserDataDir 'app-mode.json' + $id = [Guid]::NewGuid().ToString('N') + $temporary = Join-Path $UserDataDir ".app-mode.$id.tmp" + $backup = Join-Path $UserDataDir "app-mode.pre-recovery.$id.bak" + $json = [ordered]@{ + mode = 'portable' + portable_dir = $CustomDir + } | ConvertTo-Json + + try { + [IO.File]::WriteAllText($temporary, $json, (New-Object Text.UTF8Encoding($false))) + if (Test-Path -LiteralPath $target -PathType Leaf) { + [IO.File]::Replace($temporary, $target, $backup, $true) + } else { + [IO.File]::Move($temporary, $target) + } + } finally { + if (Test-Path -LiteralPath $temporary) { + Remove-Item -LiteralPath $temporary -Force + } + } +} + +function Invoke-LegacyRecovery { + param( + [Parameter(Mandatory = $true)][string[]]$InstallDirs, + [Parameter(Mandatory = $true)][string]$UserDataDir, + [Parameter(Mandatory = $true)][string]$RecoveryRoot, + [Parameter(Mandatory = $true)][string]$ProcessName, + [AllowEmptyString()][string]$ActiveConfigDir, + [AllowEmptyString()][string]$ActiveConfigManaged = '', + [string]$InstallerIdentitySafety = 'trusted-user', + [string[]]$RegisteredInstallDirs = @(), + [switch]$SkipProcessCheck + ) + + $potentialInstallDirs = @(Get-PotentialInstallDirs -InstallDirs $InstallDirs) + $existingInstallDirs = @(Get-ExistingInstallDirs -InstallDirs $potentialInstallDirs) + 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 ` + -UserDataDir $UserDataDir ` + -ActiveConfigDir $ActiveConfigDir ` + -ActiveConfigManaged $ActiveConfigManaged + Assert-NoUndiscoveredLegacySources -InstallDirs $existingInstallDirs -ActiveSource $source + if ([string]::IsNullOrWhiteSpace([string]$source)) { + return $null + } + 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.' + } + + foreach ($installDir in $potentialInstallDirs) { + if (Test-PathMayBeDeleted -InstallDir $installDir -Candidate $UserDataDir) { + throw "The mode metadata directory is inside an application install directory: $UserDataDir" + } + if (Test-PathMayBeDeleted -InstallDir $installDir -Candidate $RecoveryRoot) { + throw "The recovery destination is inside an application install directory: $RecoveryRoot" + } + } + + $destination = Copy-VerifiedTree -Source $source -RecoveryRoot $RecoveryRoot + $finalSource = @(Get-TreeManifest -Root $source) + $finalDestination = @(Get-TreeManifest -Root $destination) + Assert-TreeManifestsEqual ` + -Expected $finalSource ` + -Actual $finalDestination ` + -Message 'Legacy data changed after the verified recovery copy was finalized; the installer stopped before removing the old version.' + if (-not $SkipProcessCheck) { + Assert-NoRunningApplication -InstallDirs $existingInstallDirs -ProcessName $ProcessName + } + Write-AppModeAtomically -UserDataDir $UserDataDir -CustomDir $destination + return $destination +} + +function Assert-SelfTest { + param( + [Parameter(Mandatory = $true)][bool]$Condition, + [Parameter(Mandatory = $true)][string]$Message + ) + if (-not $Condition) { + throw "Self-test failed: $Message" + } +} + +function Write-TestMode { + param( + [Parameter(Mandatory = $true)][string]$Dir, + [Parameter(Mandatory = $true)]$Value + ) + New-Item -ItemType Directory -Path $Dir -Force | Out-Null + $Value | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $Dir 'app-mode.json') -Encoding UTF8 +} + +function Run-SelfTest { + $testRoot = Join-Path ([IO.Path]::GetTempPath()) "cc-haha-storage-recovery-$([Guid]::NewGuid().ToString('N'))" + New-Item -ItemType Directory -Path $testRoot | Out-Null + try { + $install = Join-Path $testRoot 'old install' + $legacy = Join-Path $install 'CLAUDE_CONFIG_DIR' + $userData = Join-Path $testRoot 'app data' + $recovery = Join-Path $testRoot 'recovery' + New-Item -ItemType Directory -Path $legacy -Force | Out-Null + Set-Content -LiteralPath (Join-Path $legacy 'settings.json') -Value 'legacy-v1' -NoNewline + Write-TestMode -Dir $legacy -Value @{ mode = 'portable'; portable_dir = $null } + Write-TestMode -Dir $userData -Value @{ mode = 'default'; portable_dir = $null } + + $first = Invoke-LegacyRecovery ` + -InstallDirs @($install) -UserDataDir $userData -RecoveryRoot $recovery ` + -ProcessName $ProcessName -ActiveConfigDir '' -SkipProcessCheck + Assert-SelfTest -Condition (Test-Path -LiteralPath (Join-Path $first 'settings.json') -PathType Leaf) -Message 'legacy default was not recovered' + Assert-SelfTest -Condition ((Get-Content -LiteralPath (Join-Path $first 'settings.json') -Raw) -eq 'legacy-v1') -Message 'recovered content differs' + Assert-SelfTest -Condition (Test-Path -LiteralPath (Join-Path $legacy 'settings.json') -PathType Leaf) -Message 'source was modified' + $firstMode = Get-Content -LiteralPath (Join-Path $userData 'app-mode.json') -Raw | ConvertFrom-Json + Assert-SelfTest -Condition ($firstMode.mode -eq 'portable' -and $firstMode.portable_dir -eq $first) -Message 'recovery was not persisted as explicit custom mode' + + Set-Content -LiteralPath (Join-Path $legacy 'settings.json') -Value 'legacy-v2' -NoNewline + $second = Invoke-LegacyRecovery ` + -InstallDirs @($install) -UserDataDir $userData -RecoveryRoot $recovery ` + -ProcessName $ProcessName -ActiveConfigDir '' -SkipProcessCheck + Assert-SelfTest -Condition ($second -ne $first) -Message 'retry reused an existing destination' + Assert-SelfTest -Condition ((Get-Content -LiteralPath (Join-Path $second 'settings.json') -Raw) -eq 'legacy-v2') -Message 'retry did not capture the latest source' + Assert-SelfTest -Condition ((Get-Content -LiteralPath (Join-Path $first 'settings.json') -Raw) -eq 'legacy-v1') -Message 'retry overwrote the first recovery' + + $pointerInstall = Join-Path $testRoot 'pointer install' + $pointerLegacy = Join-Path $pointerInstall 'CLAUDE_CONFIG_DIR' + $pointerData = Join-Path $pointerInstall 'custom data' + $pointerUserData = Join-Path $testRoot 'pointer app data' + New-Item -ItemType Directory -Path $pointerData -Force | Out-Null + Set-Content -LiteralPath (Join-Path $pointerData '.claude.json') -Value 'pointer-data' -NoNewline + Write-TestMode -Dir $pointerLegacy -Value @{ mode = 'portable'; portable_dir = $pointerData } + $pointerRecovered = Invoke-LegacyRecovery ` + -InstallDirs @($pointerInstall) -UserDataDir $pointerUserData -RecoveryRoot (Join-Path $testRoot 'pointer recovery') ` + -ProcessName $ProcessName -ActiveConfigDir '' -SkipProcessCheck + Assert-SelfTest -Condition ((Get-Content -LiteralPath (Join-Path $pointerRecovered '.claude.json') -Raw) -eq 'pointer-data') -Message 'legacy custom pointer was not recovered' + + $managedInstall = Join-Path $testRoot 'managed env install' + $managedLegacy = Join-Path $managedInstall 'CLAUDE_CONFIG_DIR' + New-Item -ItemType Directory -Path $managedLegacy -Force | Out-Null + Set-Content -LiteralPath (Join-Path $managedLegacy 'settings.json') -Value 'managed-env' -NoNewline + $unmanagedFailed = $false + try { + Invoke-LegacyRecovery ` + -InstallDirs @($managedInstall) -UserDataDir (Join-Path $testRoot 'unmanaged app data') ` + -RecoveryRoot (Join-Path $testRoot 'unmanaged recovery') -ProcessName $ProcessName ` + -ActiveConfigDir $managedLegacy -ActiveConfigManaged '' -SkipProcessCheck | Out-Null + } catch { + $unmanagedFailed = $_.Exception.Message.Contains('managed outside Claude Code Haha') + } + Assert-SelfTest -Condition $unmanagedFailed -Message 'unsafe external CLAUDE_CONFIG_DIR did not fail closed' + + $managedRecovered = Invoke-LegacyRecovery ` + -InstallDirs @($managedInstall) -UserDataDir (Join-Path $testRoot 'managed app data') ` + -RecoveryRoot (Join-Path $testRoot 'managed recovery') -ProcessName $ProcessName ` + -ActiveConfigDir $managedLegacy -ActiveConfigManaged '1' -SkipProcessCheck + Assert-SelfTest -Condition ((Get-Content -LiteralPath (Join-Path $managedRecovered 'settings.json') -Raw) -eq 'managed-env') -Message 'app-managed active environment was not recovered' + + $elevatedFailed = $false + try { + Invoke-LegacyRecovery ` + -InstallDirs @($managedInstall) -UserDataDir (Join-Path $testRoot 'elevated app data') ` + -RecoveryRoot (Join-Path $testRoot 'elevated recovery') -ProcessName $ProcessName ` + -ActiveConfigDir $managedLegacy -ActiveConfigManaged '1' ` + -InstallerIdentitySafety 'untrusted-elevated' -SkipProcessCheck | Out-Null + } catch { + $elevatedFailed = $_.Exception.Message.Contains('original user process') + } + Assert-SelfTest -Condition $elevatedFailed -Message 'untrusted elevated recovery did not fail closed' + + $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 + $wrongIdentityFailed = $false + try { + Invoke-LegacyRecovery ` + -InstallDirs @($wrongIdentityInstall) -RegisteredInstallDirs @($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') + } + Assert-SelfTest -Condition $wrongIdentityFailed -Message 'untrusted elevated installer treated invisible user metadata as safe' + + $futureInstall = Join-Path $testRoot 'future install' + $futureRecovery = Join-Path $futureInstall 'Recovered' + $futureFailed = $false + try { + Invoke-LegacyRecovery ` + -InstallDirs @($managedInstall, $futureInstall) ` + -UserDataDir (Join-Path $testRoot 'future app data') -RecoveryRoot $futureRecovery ` + -ProcessName $ProcessName -ActiveConfigDir $managedLegacy -ActiveConfigManaged '1' ` + -SkipProcessCheck | Out-Null + } catch { + $futureFailed = $_.Exception.Message.Contains('recovery destination is inside an application install directory') + } + Assert-SelfTest -Condition $futureFailed -Message 'missing future install root did not protect its recovery subtree' + + $sharedInstall = Join-Path $testRoot 'shared install' + $sharedLegacy = Join-Path $sharedInstall 'CLAUDE_CONFIG_DIR' + $activeExternal = Join-Path $testRoot 'active external' + New-Item -ItemType Directory -Path $sharedLegacy -Force | Out-Null + New-Item -ItemType Directory -Path $activeExternal -Force | Out-Null + Set-Content -LiteralPath (Join-Path $sharedLegacy 'settings.json') -Value 'shared-user-data' -NoNewline + $sharedFailed = $false + try { + Invoke-LegacyRecovery ` + -InstallDirs @($sharedInstall) -UserDataDir (Join-Path $testRoot 'shared app data') ` + -RecoveryRoot (Join-Path $testRoot 'shared recovery') -ProcessName $ProcessName ` + -ActiveConfigDir $activeExternal -ActiveConfigManaged '' -SkipProcessCheck | Out-Null + } catch { + $sharedFailed = $_.Exception.Message.Contains('another Windows user') + } + Assert-SelfTest -Condition $sharedFailed -Message 'external active config hid shared install-contained data' + + $otherUserInstall = Join-Path $testRoot 'other user shared install' + $otherUserData = Join-Path $otherUserInstall 'B-data' + $currentUserMode = Join-Path $testRoot 'current user shared mode' + New-Item -ItemType Directory -Path $otherUserData -Force | Out-Null + Set-Content -LiteralPath (Join-Path $otherUserData 'settings.json') -Value 'other-windows-user' -NoNewline + Write-TestMode -Dir $otherUserData -Value @{ mode = 'portable'; portable_dir = $otherUserData } + Write-TestMode -Dir $currentUserMode -Value @{ mode = 'default'; portable_dir = $null } + $otherUserFailed = $false + try { + Invoke-LegacyRecovery ` + -InstallDirs @($otherUserInstall) -UserDataDir $currentUserMode ` + -RecoveryRoot (Join-Path $testRoot 'other user recovery') -ProcessName $ProcessName ` + -ActiveConfigDir '' -SkipProcessCheck | Out-Null + } catch { + $otherUserFailed = $_.Exception.Message.Contains('another Windows user') + } + Assert-SelfTest -Condition $otherUserFailed -Message 'another Windows user custom directory was not protected' + + $managedExternalInstall = Join-Path $testRoot 'managed external install' + $managedExternalData = Join-Path $testRoot 'managed external data' + $managedExternalUserData = Join-Path $testRoot 'managed external app data' + New-Item -ItemType Directory -Path $managedExternalInstall -Force | Out-Null + New-Item -ItemType Directory -Path $managedExternalData -Force | Out-Null + Write-TestMode -Dir $managedExternalUserData -Value @{ mode = 'portable'; portable_dir = $managedExternalData } + $managedExternalResult = Invoke-LegacyRecovery ` + -InstallDirs @($managedExternalInstall) -UserDataDir $managedExternalUserData ` + -RecoveryRoot (Join-Path $testRoot 'managed external recovery') -ProcessName $ProcessName ` + -ActiveConfigDir $managedExternalData -ActiveConfigManaged '1' -SkipProcessCheck + Assert-SelfTest -Condition ($null -eq $managedExternalResult) -Message 'matching app-managed external custom mode was not accepted' + + Set-Content -LiteralPath (Join-Path $managedExternalUserData 'app-mode.json') -Value '{broken-json' -NoNewline + $managedExternalInvalidFailed = $false + try { + Invoke-LegacyRecovery ` + -InstallDirs @($managedExternalInstall) -UserDataDir $managedExternalUserData ` + -RecoveryRoot (Join-Path $testRoot 'managed external invalid recovery') -ProcessName $ProcessName ` + -ActiveConfigDir $managedExternalData -ActiveConfigManaged '1' -SkipProcessCheck | Out-Null + } catch { + $managedExternalInvalidFailed = $_.Exception.Message.Contains('cannot be read safely') + } + Assert-SelfTest -Condition $managedExternalInvalidFailed -Message 'invalid metadata bypassed app-managed external mode validation' + + $externalInstall = Join-Path $testRoot 'external install' + $externalLegacy = Join-Path $externalInstall 'CLAUDE_CONFIG_DIR' + $externalUserData = Join-Path $testRoot 'external app data' + $externalDir = Join-Path $testRoot 'external custom' + New-Item -ItemType Directory -Path $externalLegacy -Force | Out-Null + Set-Content -LiteralPath (Join-Path $externalLegacy 'settings.json') -Value 'inactive-legacy' -NoNewline + New-Item -ItemType Directory -Path $externalDir -Force | Out-Null + Write-TestMode -Dir $externalUserData -Value @{ mode = 'portable'; portable_dir = $externalDir } + $externalResult = Invoke-LegacyRecovery ` + -InstallDirs @($externalInstall) -UserDataDir $externalUserData -RecoveryRoot (Join-Path $testRoot 'external recovery') ` + -ProcessName $ProcessName -ActiveConfigDir '' -SkipProcessCheck + Assert-SelfTest -Condition ($null -eq $externalResult) -Message 'external custom data was unnecessarily migrated' + + $secondInstall = Join-Path $testRoot 'second install' + $secondLegacy = Join-Path $secondInstall 'CLAUDE_CONFIG_DIR' + New-Item -ItemType Directory -Path $secondLegacy -Force | Out-Null + Set-Content -LiteralPath (Join-Path $secondLegacy 'settings.json') -Value 'second-source' -NoNewline + Write-TestMode -Dir $secondLegacy -Value @{ mode = 'portable'; portable_dir = $null } + $multipleFailed = $false + try { + Invoke-LegacyRecovery ` + -InstallDirs @($install, $secondInstall) -UserDataDir (Join-Path $testRoot 'multiple app data') ` + -RecoveryRoot (Join-Path $testRoot 'multiple recovery') -ProcessName $ProcessName ` + -ActiveConfigDir $legacy -ActiveConfigManaged '1' -SkipProcessCheck | Out-Null + } catch { + $multipleFailed = $_.Exception.Message.Contains('Multiple distinct legacy data sources') + } + Assert-SelfTest -Condition $multipleFailed -Message 'active environment hid an ambiguous dual-install source' + + $invalidInstall = Join-Path $testRoot 'invalid mode install' + $invalidUserData = Join-Path $testRoot 'invalid mode app data' + New-Item -ItemType Directory -Path $invalidInstall -Force | Out-Null + New-Item -ItemType Directory -Path $invalidUserData -Force | Out-Null + Set-Content -LiteralPath (Join-Path $invalidUserData 'app-mode.json') -Value '{broken-json' -NoNewline + $invalidFailed = $false + try { + Invoke-LegacyRecovery ` + -InstallDirs @($invalidInstall) -UserDataDir $invalidUserData ` + -RecoveryRoot (Join-Path $testRoot 'invalid recovery') -ProcessName $ProcessName ` + -ActiveConfigDir '' -SkipProcessCheck | Out-Null + } catch { + $invalidFailed = $_.Exception.Message.Contains('cannot be read safely') + } + Assert-SelfTest -Condition $invalidFailed -Message 'invalid existing mode metadata was treated as absent' + + $junctionInstall = Join-Path $testRoot 'junction install' + $junctionTarget = Join-Path $testRoot 'junction external target' + $junctionLegacy = Join-Path $junctionInstall 'CLAUDE_CONFIG_DIR' + New-Item -ItemType Directory -Path $junctionInstall -Force | Out-Null + New-Item -ItemType Directory -Path $junctionTarget -Force | Out-Null + Set-Content -LiteralPath (Join-Path $junctionTarget 'settings.json') -Value 'junction-data' -NoNewline + New-Item -ItemType Junction -Path $junctionLegacy -Target $junctionTarget | Out-Null + $junctionFailed = $false + try { + Invoke-LegacyRecovery ` + -InstallDirs @($junctionInstall) -UserDataDir (Join-Path $testRoot 'junction app data') ` + -RecoveryRoot (Join-Path $testRoot 'junction recovery') -ProcessName $ProcessName ` + -ActiveConfigDir '' -SkipProcessCheck | Out-Null + } catch { + $junctionFailed = $_.Exception.Message.Contains('reparse point') + } + Assert-SelfTest -Condition $junctionFailed -Message 'install-contained junction source was silently skipped' + + [Console]::Out.WriteLine('Legacy install data recovery self-test passed.') + } finally { + Remove-Item -LiteralPath $testRoot -Recurse -Force -ErrorAction SilentlyContinue + } +} + +try { + if ($SelfTest) { + Run-SelfTest + exit 0 + } + + $result = Invoke-LegacyRecovery ` + -InstallDirs @($PerUserInstallDir, $PerMachineInstallDir, $CandidateInstallDir) ` + -UserDataDir $UserDataDir ` + -RecoveryRoot $RecoveryRoot ` + -ProcessName $ProcessName ` + -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) + exit 20 +} diff --git a/desktop/electron/ipc/capabilities.ts b/desktop/electron/ipc/capabilities.ts index bc5c4fae..b1bf8842 100644 --- a/desktop/electron/ipc/capabilities.ts +++ b/desktop/electron/ipc/capabilities.ts @@ -112,7 +112,6 @@ export const ELECTRON_IPC_VALIDATORS = { [ELECTRON_IPC_CHANNELS.previewMessage]: () => true, [ELECTRON_IPC_CHANNELS.appModeGet]: noPayload, [ELECTRON_IPC_CHANNELS.appModeSet]: optionalRecord, - [ELECTRON_IPC_CHANNELS.appModeDetectPortableDir]: noPayload, [ELECTRON_IPC_CHANNELS.appModePrepareRestart]: noPayload, [ELECTRON_IPC_CHANNELS.appModeRestart]: noPayload, [ELECTRON_IPC_CHANNELS.adaptersRestartSidecar]: noPayload, diff --git a/desktop/electron/ipc/channels.ts b/desktop/electron/ipc/channels.ts index 4fb5a31a..1ea23b8d 100644 --- a/desktop/electron/ipc/channels.ts +++ b/desktop/electron/ipc/channels.ts @@ -41,7 +41,6 @@ export const ELECTRON_IPC_CHANNELS = { previewMessage: 'desktop:preview:message', appModeGet: 'desktop:app-mode:get', appModeSet: 'desktop:app-mode:set', - appModeDetectPortableDir: 'desktop:app-mode:detect-portable-dir', appModePrepareRestart: 'desktop:app-mode:prepare-restart', appModeRestart: 'desktop:app-mode:restart', adaptersRestartSidecar: 'desktop:adapters:restart-sidecar', diff --git a/desktop/electron/main.ts b/desktop/electron/main.ts index 362b98e9..faf62e66 100644 --- a/desktop/electron/main.ts +++ b/desktop/electron/main.ts @@ -20,10 +20,8 @@ import { ElectronTerminalService, type TerminalSpawnInput } from './services/ter import { ElectronPreviewService, type PreviewBounds } from './services/preview' import { applyStartupPortableMode, - detectPortableDir, getAppMode, setAppMode, - type PortableDetection, } from './services/appMode' import { installMacOsChromiumKeychainPromptGuard } from './services/keychain' import { applyWindowsAppUserModelId } from './services/appIdentity' @@ -331,8 +329,7 @@ function registerIpcHandlers() { registerHandler(ELECTRON_IPC_CHANNELS.previewMessage, (event, payload) => getPreviewService().message(payload, event.sender)) registerHandler(ELECTRON_IPC_CHANNELS.appModeGet, () => getAppMode(app)) registerHandler(ELECTRON_IPC_CHANNELS.appModeSet, (_event, payload) => setAppMode(app, payload as Parameters[1])) - registerHandler(ELECTRON_IPC_CHANNELS.appModeDetectPortableDir, () => detectPortableDir(app) as PortableDetection) - registerHandler(ELECTRON_IPC_CHANNELS.appModePrepareRestart, () => getServerRuntime().stopAll()) + registerHandler(ELECTRON_IPC_CHANNELS.appModePrepareRestart, () => getServerRuntime().stopAll(true)) registerHandler(ELECTRON_IPC_CHANNELS.appModeRestart, () => { isQuitting = true app.relaunch() diff --git a/desktop/electron/services/appMode.test.ts b/desktop/electron/services/appMode.test.ts index 9658da9f..01bab042 100644 --- a/desktop/electron/services/appMode.test.ts +++ b/desktop/electron/services/appMode.test.ts @@ -1,15 +1,13 @@ import fs from 'node:fs' import os from 'node:os' import path from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { applyStartupPortableMode, - defaultPortableDir, - detectPortableDir, determineStartupPortableDir, - dirHasPortableData, getAppMode, setAppMode, + systemClaudeConfigDir, type AppModeAppLike, } from './appMode' @@ -22,121 +20,255 @@ function tempDir() { } function app(root = tempDir()): AppModeAppLike & { root: string } { - const exe = path.join(root, 'Claude Code Haha.app', 'Contents', 'MacOS', 'Claude Code Haha') + const exe = path.join(root, 'install', 'Claude Code Haha') + const home = path.join(root, 'home') const userData = path.join(root, 'user-data') fs.mkdirSync(path.dirname(exe), { recursive: true }) fs.writeFileSync(exe, '') return { root, getPath(name) { - return name === 'exe' ? exe : userData + if (name === 'exe') return exe + if (name === 'home') return home + return userData }, } } +function writeMode(fakeApp: AppModeAppLike, value: unknown) { + const userData = fakeApp.getPath('userData') + fs.mkdirSync(userData, { recursive: true }) + fs.writeFileSync(path.join(userData, 'app-mode.json'), JSON.stringify(value)) +} + afterEach(() => { + vi.restoreAllMocks() for (const dir of tempDirs.splice(0)) { fs.rmSync(dir, { recursive: true, force: true }) } }) describe('Electron app mode service', () => { - it('detects portable data using the same sentinel files and directories as Tauri', () => { - const root = tempDir() - expect(dirHasPortableData(root)).toBe(false) - fs.writeFileSync(path.join(root, 'settings.json'), '{}') - expect(dirHasPortableData(root)).toBe(true) - fs.rmSync(path.join(root, 'settings.json')) - fs.mkdirSync(path.join(root, 'projects')) - expect(dirHasPortableData(root)).toBe(true) - }) - - it('resolves startup portable mode from default portable data or app-mode config', () => { + it('always uses ~/.claude in system mode and ignores app-adjacent legacy data at runtime', () => { const fakeApp = app() - const defaultDir = defaultPortableDir(fakeApp) - fs.mkdirSync(defaultDir, { recursive: true }) - fs.writeFileSync(path.join(defaultDir, 'settings.json'), '{}') + const legacyDir = path.join(path.dirname(fakeApp.getPath('exe')), 'CLAUDE_CONFIG_DIR') + fs.mkdirSync(legacyDir, { recursive: true }) + fs.writeFileSync(path.join(legacyDir, 'settings.json'), '{"legacy":true}') - expect(determineStartupPortableDir(fakeApp, {})).toBe(defaultDir) - expect(determineStartupPortableDir(fakeApp, { CLAUDE_CONFIG_DIR: '/external' })).toBeNull() - - fs.writeFileSync(path.join(defaultDir, 'app-mode.json'), JSON.stringify({ mode: 'default' })) + expect(systemClaudeConfigDir(fakeApp)).toBe(path.join(fakeApp.root, 'home', '.claude')) expect(determineStartupPortableDir(fakeApp, {})).toBeNull() - }) - - it('sets portable environment variables before sidecars start', () => { - const fakeApp = app() - const env: NodeJS.ProcessEnv = {} - const defaultDir = defaultPortableDir(fakeApp) - fs.mkdirSync(defaultDir, { recursive: true }) - fs.writeFileSync(path.join(defaultDir, 'settings.json'), '{}') - - expect(applyStartupPortableMode(fakeApp, env)).toBe(defaultDir) - expect(env.CLAUDE_CONFIG_DIR).toBe(defaultDir) - expect(env.CC_HAHA_APP_PORTABLE_DIR).toBe('1') - expect(env.WEBVIEW2_USER_DATA_FOLDER).toBe(path.join(defaultDir, 'EBWebView')) - }) - - it('returns the active app mode shape expected by settingsStore', () => { - const fakeApp = app() - + expect(applyStartupPortableMode(fakeApp, {})).toBeNull() expect(getAppMode(fakeApp, {})).toEqual({ mode: 'default', - portableDir: defaultPortableDir(fakeApp), - defaultPortableDir: defaultPortableDir(fakeApp), - activeConfigDir: fakeApp.getPath('userData'), + portableDir: null, + activeConfigDir: path.join(fakeApp.root, 'home', '.claude'), configDirSource: 'system', }) - expect(getAppMode(fakeApp, { CLAUDE_CONFIG_DIR: '/portable', CC_HAHA_APP_PORTABLE_DIR: '1' })).toMatchObject({ - mode: 'portable', - portableDir: '/portable', - activeConfigDir: '/portable', - configDirSource: 'portable', - }) - expect(getAppMode(fakeApp, { CLAUDE_CONFIG_DIR: '/external' })).toMatchObject({ - configDirSource: 'environment', - }) }) - it('writes app-mode.json to active, target portable, and system config dirs', () => { + it('activates only an explicit absolute custom directory persisted in userData', () => { const fakeApp = app() - const active = tempDir() - const selected = path.join(tempDir(), 'portable') + const customDir = path.join(fakeApp.root, 'custom-data') + writeMode(fakeApp, { mode: 'portable', portable_dir: customDir }) + const env: NodeJS.ProcessEnv = {} - setAppMode(fakeApp, { mode: 'portable', portableDir: selected }, { CLAUDE_CONFIG_DIR: active }) + expect(determineStartupPortableDir(fakeApp, env)).toBe(customDir) + expect(applyStartupPortableMode(fakeApp, env)).toBe(customDir) + expect(env).toMatchObject({ + CLAUDE_CONFIG_DIR: customDir, + CC_HAHA_APP_PORTABLE_DIR: '1', + WEBVIEW2_USER_DATA_FOLDER: path.join(customDir, 'EBWebView'), + }) + expect(getAppMode(fakeApp, env)).toEqual({ + mode: 'portable', + portableDir: customDir, + activeConfigDir: customDir, + configDirSource: 'portable', + }) + }) - const expected = { mode: 'portable', portable_dir: selected } - expect(JSON.parse(fs.readFileSync(path.join(active, 'app-mode.json'), 'utf8'))).toEqual(expected) - expect(JSON.parse(fs.readFileSync(path.join(selected, 'app-mode.json'), 'utf8'))).toEqual(expected) - expect(JSON.parse(fs.readFileSync(path.join(fakeApp.getPath('userData'), 'app-mode.json'), 'utf8'))).toEqual(expected) + it('treats an externally supplied CLAUDE_CONFIG_DIR as a read-only override', () => { + const fakeApp = app() + const externalDir = path.join(fakeApp.root, 'external-data') + const env: NodeJS.ProcessEnv = { CLAUDE_CONFIG_DIR: externalDir } - setAppMode(fakeApp, { mode: 'default', portableDir: null }, { CLAUDE_CONFIG_DIR: active }) - expect(JSON.parse(fs.readFileSync(path.join(active, 'app-mode.json'), 'utf8'))).toEqual({ + expect(determineStartupPortableDir(fakeApp, env)).toBeNull() + expect(applyStartupPortableMode(fakeApp, env)).toBeNull() + expect(env).toEqual({ CLAUDE_CONFIG_DIR: externalDir }) + expect(getAppMode(fakeApp, env)).toEqual({ + mode: 'portable', + portableDir: externalDir, + activeConfigDir: externalDir, + configDirSource: 'environment', + }) + expect(() => setAppMode(fakeApp, { mode: 'default', portableDir: null }, env)) + .toThrow('CLAUDE_CONFIG_DIR is controlled by the launch environment') + }) + + it('rejects relative or install-contained external custom directories', () => { + const fakeApp = app() + const installData = path.join(path.dirname(fakeApp.getPath('exe')), 'external-data') + + expect(() => applyStartupPortableMode(fakeApp, { + CLAUDE_CONFIG_DIR: 'relative-data', + })).toThrow('absolute path') + expect(() => getAppMode(fakeApp, { + CLAUDE_CONFIG_DIR: 'relative-data', + })).toThrow('absolute path') + expect(() => applyStartupPortableMode(fakeApp, { + CLAUDE_CONFIG_DIR: installData, + })).toThrow('outside the application install directory') + }) + + it('drops inherited app-managed env so switching back to ~/.claude survives relaunch', () => { + const fakeApp = app() + writeMode(fakeApp, { mode: 'default', portable_dir: null }) + const oldCustomDir = path.join(fakeApp.root, 'old-custom') + const env: NodeJS.ProcessEnv = { + CLAUDE_CONFIG_DIR: oldCustomDir, + CC_HAHA_APP_PORTABLE_DIR: '1', + WEBVIEW2_USER_DATA_FOLDER: path.join(oldCustomDir, 'EBWebView'), + } + + expect(applyStartupPortableMode(fakeApp, env)).toBeNull() + expect(env.CLAUDE_CONFIG_DIR).toBeUndefined() + expect(env.CC_HAHA_APP_PORTABLE_DIR).toBeUndefined() + expect(env.WEBVIEW2_USER_DATA_FOLDER).toBeUndefined() + expect(getAppMode(fakeApp, env)).toMatchObject({ + mode: 'default', + activeConfigDir: systemClaudeConfigDir(fakeApp), + }) + }) + + it('replaces an inherited app-managed env with the newly persisted custom directory', () => { + const fakeApp = app() + const newCustomDir = path.join(fakeApp.root, 'new-custom') + writeMode(fakeApp, { mode: 'portable', portable_dir: newCustomDir }) + const env: NodeJS.ProcessEnv = { + CLAUDE_CONFIG_DIR: path.join(fakeApp.root, 'old-custom'), + CC_HAHA_APP_PORTABLE_DIR: '1', + WEBVIEW2_USER_DATA_FOLDER: path.join(fakeApp.root, 'old-custom', 'EBWebView'), + } + + expect(applyStartupPortableMode(fakeApp, env)).toBe(newCustomDir) + expect(env.CLAUDE_CONFIG_DIR).toBe(newCustomDir) + expect(env.WEBVIEW2_USER_DATA_FOLDER).toBe(path.join(newCustomDir, 'EBWebView')) + }) + + it.each([ + { mode: 'portable', portable_dir: null }, + { mode: 'portable', portable_dir: '' }, + { mode: 'portable', portable_dir: 'relative-data' }, + { mode: 'unknown', portable_dir: '/tmp/custom' }, + ])('falls back to system mode for invalid custom metadata: %o', value => { + const fakeApp = app() + writeMode(fakeApp, value) + + expect(determineStartupPortableDir(fakeApp, {})).toBeNull() + expect(getAppMode(fakeApp, {})).toMatchObject({ + mode: 'default', + portableDir: null, + activeConfigDir: systemClaudeConfigDir(fakeApp), + configDirSource: 'system', + }) + }) + + it('persists one atomic system-owned mode record for a custom directory', () => { + const fakeApp = app() + const customDir = path.join(fakeApp.root, 'custom-data') + const previousActive = path.join(fakeApp.root, 'previous-custom') + + setAppMode(fakeApp, { mode: 'portable', portableDir: customDir }, { + CLAUDE_CONFIG_DIR: previousActive, + CC_HAHA_APP_PORTABLE_DIR: '1', + }) + + expect(JSON.parse(fs.readFileSync(path.join(fakeApp.getPath('userData'), 'app-mode.json'), 'utf8'))).toEqual({ + mode: 'portable', + portable_dir: customDir, + }) + expect(fs.existsSync(path.join(customDir, 'app-mode.json'))).toBe(false) + expect(fs.existsSync(path.join(previousActive, 'app-mode.json'))).toBe(false) + expect(fs.readdirSync(fakeApp.getPath('userData'))).toEqual(['app-mode.json']) + }) + + it('switches back to system mode without touching the custom directory', () => { + const fakeApp = app() + const customDir = path.join(fakeApp.root, 'custom-data') + fs.mkdirSync(customDir, { recursive: true }) + fs.writeFileSync(path.join(customDir, 'settings.json'), '{"keep":true}') + writeMode(fakeApp, { mode: 'portable', portable_dir: customDir }) + + setAppMode(fakeApp, { mode: 'default', portableDir: null }, { + CLAUDE_CONFIG_DIR: customDir, + CC_HAHA_APP_PORTABLE_DIR: '1', + }) + + expect(JSON.parse(fs.readFileSync(path.join(fakeApp.getPath('userData'), 'app-mode.json'), 'utf8'))).toEqual({ mode: 'default', portable_dir: null, }) + expect(fs.readFileSync(path.join(customDir, 'settings.json'), 'utf8')).toBe('{"keep":true}') }) - it('does not point existing config at a portable dir that cannot persist app-mode.json', () => { + it.each([ + { label: 'missing', value: null }, + { label: 'empty', value: ' ' }, + { label: 'relative', value: 'relative-data' }, + ])('rejects a $label custom directory', ({ value }) => { const fakeApp = app() - const active = tempDir() - const selected = path.join(tempDir(), 'portable') - fs.mkdirSync(path.join(selected, 'app-mode.json'), { recursive: true }) - expect(() => setAppMode(fakeApp, { mode: 'portable', portableDir: selected }, { CLAUDE_CONFIG_DIR: active })) - .toThrow() - expect(fs.existsSync(path.join(active, 'app-mode.json'))).toBe(false) + expect(() => setAppMode(fakeApp, { mode: 'portable', portableDir: value }, {})).toThrow() expect(fs.existsSync(path.join(fakeApp.getPath('userData'), 'app-mode.json'))).toBe(false) }) - it('reports whether the default portable dir already has data', () => { + it('rejects custom directories inside the application install tree, including symlink aliases', () => { const fakeApp = app() - expect(detectPortableDir(fakeApp)).toEqual({ - defaultPortableDir: defaultPortableDir(fakeApp), - hasData: false, + const installDir = path.dirname(fakeApp.getPath('exe')) + const aliasedInstallDir = path.join(fakeApp.root, 'install-alias') + fs.symlinkSync(installDir, aliasedInstallDir, 'dir') + + expect(() => setAppMode(fakeApp, { + mode: 'portable', + portableDir: path.join(installDir, 'data'), + }, {})).toThrow('outside the application install directory') + expect(() => setAppMode(fakeApp, { + mode: 'portable', + portableDir: path.join(aliasedInstallDir, 'data'), + }, {})).toThrow('outside the application install directory') + }) + + it('does not partially mutate process.env when custom startup preparation fails', () => { + const fakeApp = app() + const customDir = path.join(fakeApp.root, 'custom-data') + writeMode(fakeApp, { mode: 'portable', portable_dir: customDir }) + const env: NodeJS.ProcessEnv = {} + vi.spyOn(fs, 'mkdirSync').mockImplementation(() => { + throw new Error('mkdir failed') }) - fs.mkdirSync(defaultPortableDir(fakeApp), { recursive: true }) - fs.writeFileSync(path.join(defaultPortableDir(fakeApp), '.mcp.json'), '{}') - expect(detectPortableDir(fakeApp).hasData).toBe(true) + + expect(() => applyStartupPortableMode(fakeApp, env)).toThrow('mkdir failed') + expect(env.CLAUDE_CONFIG_DIR).toBeUndefined() + expect(env.CC_HAHA_APP_PORTABLE_DIR).toBeUndefined() + expect(env.WEBVIEW2_USER_DATA_FOLDER).toBeUndefined() + }) + + it('keeps the previous mode record if the atomic replacement fails', () => { + const fakeApp = app() + const modeFile = path.join(fakeApp.getPath('userData'), 'app-mode.json') + writeMode(fakeApp, { mode: 'default', portable_dir: null }) + vi.spyOn(fs, 'renameSync').mockImplementation(() => { + throw new Error('rename failed') + }) + + expect(() => setAppMode(fakeApp, { + mode: 'portable', + portableDir: path.join(fakeApp.root, 'custom-data'), + }, {})).toThrow('rename failed') + expect(JSON.parse(fs.readFileSync(modeFile, 'utf8'))).toEqual({ + mode: 'default', + portable_dir: null, + }) + expect(fs.readdirSync(fakeApp.getPath('userData'))).toEqual(['app-mode.json']) }) }) diff --git a/desktop/electron/services/appMode.ts b/desktop/electron/services/appMode.ts index e02b97d8..e06856b5 100644 --- a/desktop/electron/services/appMode.ts +++ b/desktop/electron/services/appMode.ts @@ -1,3 +1,4 @@ +import { randomUUID } from 'node:crypto' import fs from 'node:fs' import path from 'node:path' import process from 'node:process' @@ -6,7 +7,7 @@ import type { AppModeConfig, AppModeSetInput } from '../../src/lib/desktopHost/t const APP_MODE_FILE = 'app-mode.json' export type AppModeAppLike = { - getPath(name: 'exe' | 'userData'): string + getPath(name: 'exe' | 'home' | 'userData'): string } type PersistedAppModeConfig = { @@ -14,50 +15,32 @@ type PersistedAppModeConfig = { portable_dir?: string | null } -export type PortableDetection = { - defaultPortableDir: string | null - hasData: boolean +export function systemClaudeConfigDir(app: AppModeAppLike): string { + return path.join(app.getPath('home'), '.claude') } -export function defaultPortableDir(app: AppModeAppLike): string { - return path.join(path.dirname(app.getPath('exe')), 'CLAUDE_CONFIG_DIR') -} - -export function dirHasPortableData(dir: string): boolean { - if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) return false - return [ - 'settings.json', - '.claude.json', - '.mcp.json', - 'window-state.json', - 'terminal-config.json', - ].some(file => fs.existsSync(path.join(dir, file)) && fs.statSync(path.join(dir, file)).isFile()) - || [ - 'Cache', - 'EBWebView', - 'projects', - 'skills', - 'plugins', - 'cowork_plugins', - 'cc-haha', - ].some(file => fs.existsSync(path.join(dir, file)) && fs.statSync(path.join(dir, file)).isDirectory()) -} - -export function readAppModeConfig(configDir: string): PersistedAppModeConfig | null { +function readAppModeConfig(configDir: string): PersistedAppModeConfig | null { try { const parsed = JSON.parse(fs.readFileSync(path.join(configDir, APP_MODE_FILE), 'utf8')) as PersistedAppModeConfig return { mode: typeof parsed.mode === 'string' ? parsed.mode.toLowerCase() : 'default', - portable_dir: typeof parsed.portable_dir === 'string' ? parsed.portable_dir : null, + portable_dir: typeof parsed.portable_dir === 'string' ? parsed.portable_dir.trim() : null, } } catch { return null } } -export function writeAppModeConfig(configDir: string, config: PersistedAppModeConfig): void { +function writeAppModeConfig(configDir: string, config: PersistedAppModeConfig): void { fs.mkdirSync(configDir, { recursive: true }) - fs.writeFileSync(path.join(configDir, APP_MODE_FILE), JSON.stringify(config, null, 2)) + const target = path.join(configDir, APP_MODE_FILE) + const temporary = path.join(configDir, `.${APP_MODE_FILE}.${randomUUID()}.tmp`) + try { + fs.writeFileSync(temporary, JSON.stringify(config, null, 2)) + fs.renameSync(temporary, target) + } finally { + fs.rmSync(temporary, { force: true }) + } } function assertWritableDataDir(configDir: string): void { @@ -74,58 +57,109 @@ function assertWritableDataDir(configDir: string): void { } } +function resolveWithExistingAncestor(inputPath: string): string { + let existingPath = path.resolve(inputPath) + const missingSegments: string[] = [] + while (!fs.existsSync(existingPath)) { + const parent = path.dirname(existingPath) + if (parent === existingPath) return path.resolve(inputPath) + missingSegments.unshift(path.basename(existingPath)) + existingPath = parent + } + return path.join(fs.realpathSync.native(existingPath), ...missingSegments) +} + +function isPathAtOrBelow(parentDir: string, candidateDir: string): boolean { + const relative = path.relative( + resolveWithExistingAncestor(parentDir), + resolveWithExistingAncestor(candidateDir), + ) + return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative)) +} + +function normalizedCustomDir(app: AppModeAppLike, value: string | null | undefined): string { + const selectedDir = value?.trim() + if (!selectedDir) throw new Error('Choose an absolute custom data directory') + if (!path.isAbsolute(selectedDir)) throw new Error('Custom data storage must use an absolute path') + + const normalized = path.resolve(selectedDir) + if (isPathAtOrBelow(path.dirname(app.getPath('exe')), normalized)) { + throw new Error('Custom data storage must stay outside the application install directory') + } + return normalized +} + +function externallyControlled(env: NodeJS.ProcessEnv): boolean { + return Boolean(env.CLAUDE_CONFIG_DIR && env.CC_HAHA_APP_PORTABLE_DIR !== '1') +} + export function determineStartupPortableDir( app: AppModeAppLike, env: NodeJS.ProcessEnv = process.env, ): string | null { if (env.CLAUDE_CONFIG_DIR) return null - const defaultDir = defaultPortableDir(app) - const defaultMode = readAppModeConfig(defaultDir) - if (defaultMode) { - if (defaultMode.mode === 'portable') { - return dirHasPortableData(defaultDir) ? defaultDir : defaultMode.portable_dir ?? defaultDir - } + const config = readAppModeConfig(app.getPath('userData')) + if (config?.mode !== 'portable' || !config.portable_dir || !path.isAbsolute(config.portable_dir)) return null + + try { + return normalizedCustomDir(app, config.portable_dir) + } catch { return null } - - const systemMode = readAppModeConfig(app.getPath('userData')) - if (systemMode) { - if (systemMode.mode === 'portable') return systemMode.portable_dir ?? defaultDir - return null - } - - return dirHasPortableData(defaultDir) ? defaultDir : null } export function applyStartupPortableMode( app: AppModeAppLike, env: NodeJS.ProcessEnv = process.env, ): string | null { - const portableDir = determineStartupPortableDir(app, env) - if (!portableDir) return null - env.CLAUDE_CONFIG_DIR = portableDir + // 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 + } + if (env.CLAUDE_CONFIG_DIR) { + env.CLAUDE_CONFIG_DIR = normalizedCustomDir(app, env.CLAUDE_CONFIG_DIR) + return null + } + const customDir = determineStartupPortableDir(app, env) + if (!customDir) return null + + const webViewDataDir = path.join(customDir, 'EBWebView') + fs.mkdirSync(webViewDataDir, { recursive: true }) + env.CLAUDE_CONFIG_DIR = customDir env.CC_HAHA_APP_PORTABLE_DIR = '1' - env.WEBVIEW2_USER_DATA_FOLDER = path.join(portableDir, 'EBWebView') - fs.mkdirSync(env.WEBVIEW2_USER_DATA_FOLDER, { recursive: true }) - return portableDir + env.WEBVIEW2_USER_DATA_FOLDER = webViewDataDir + return customDir } export function getAppMode( app: AppModeAppLike, env: NodeJS.ProcessEnv = process.env, ): AppModeConfig { - const envConfigDir = env.CLAUDE_CONFIG_DIR || null - const activeConfigDir = envConfigDir || app.getPath('userData') - const portableDir = envConfigDir || defaultPortableDir(app) + const envConfigDir = env.CLAUDE_CONFIG_DIR + ? normalizedCustomDir(app, env.CLAUDE_CONFIG_DIR) + : null + const persistedCustomDir = envConfigDir ? null : determineStartupPortableDir(app, env) + const customDir = envConfigDir || persistedCustomDir + if (customDir) { + return { + mode: 'portable', + portableDir: customDir, + activeConfigDir: customDir, + configDirSource: envConfigDir && env.CC_HAHA_APP_PORTABLE_DIR !== '1' + ? 'environment' + : 'portable', + } + } + return { - mode: envConfigDir ? 'portable' : 'default', - portableDir, - defaultPortableDir: defaultPortableDir(app), - activeConfigDir, - configDirSource: envConfigDir - ? env.CC_HAHA_APP_PORTABLE_DIR ? 'portable' : 'environment' - : 'system', + mode: 'default', + portableDir: null, + activeConfigDir: systemClaudeConfigDir(app), + configDirSource: 'system', } } @@ -134,39 +168,23 @@ export function setAppMode( input: AppModeSetInput, env: NodeJS.ProcessEnv = process.env, ): void { - const activeConfigDir = env.CLAUDE_CONFIG_DIR || app.getPath('userData') - let config: PersistedAppModeConfig = { mode: 'default', portable_dir: null } - let targetPortableDir: string | null = null - - if (input.mode === 'portable') { - const selectedDir = input.portableDir?.trim() || defaultPortableDir(app) - if (fs.existsSync(selectedDir) && !fs.statSync(selectedDir).isDirectory()) { - throw new Error(`portable config path is not a directory: ${selectedDir}`) - } - assertWritableDataDir(selectedDir) - targetPortableDir = selectedDir - config = { - mode: 'portable', - portable_dir: selectedDir === defaultPortableDir(app) ? null : selectedDir, - } + if (externallyControlled(env)) { + throw new Error('CLAUDE_CONFIG_DIR is controlled by the launch environment') } - const systemConfigDir = app.getPath('userData') - const configDirs = [ - targetPortableDir, - activeConfigDir, - systemConfigDir, - ].filter((dir): dir is string => Boolean(dir)) - - for (const configDir of [...new Set(configDirs)]) { - writeAppModeConfig(configDir, config) - } -} - -export function detectPortableDir(app: AppModeAppLike): PortableDetection { - const portableDir = defaultPortableDir(app) - return { - defaultPortableDir: portableDir, - hasData: dirHasPortableData(portableDir), + if (input.mode === 'default') { + writeAppModeConfig(app.getPath('userData'), { mode: 'default', portable_dir: null }) + return } + if (input.mode !== 'portable') throw new Error(`Unsupported app mode: ${String(input.mode)}`) + + const selectedDir = normalizedCustomDir(app, input.portableDir) + if (fs.existsSync(selectedDir) && !fs.statSync(selectedDir).isDirectory()) { + throw new Error(`Custom data storage path is not a directory: ${selectedDir}`) + } + assertWritableDataDir(selectedDir) + writeAppModeConfig(app.getPath('userData'), { + mode: 'portable', + portable_dir: selectedDir, + }) } diff --git a/desktop/electron/services/appModeLifecycle.test.ts b/desktop/electron/services/appModeLifecycle.test.ts new file mode 100644 index 00000000..660c8b20 --- /dev/null +++ b/desktop/electron/services/appModeLifecycle.test.ts @@ -0,0 +1,17 @@ +import { readFileSync } from 'node:fs' +import path from 'node:path' +import { describe, expect, it } from 'vitest' + +describe('app mode restart lifecycle', () => { + it('synchronously stops Windows sidecars before relaunching with another data directory', () => { + const desktopDir = path.basename(process.cwd()) === 'desktop' + ? process.cwd() + : path.join(process.cwd(), 'desktop') + const source = readFileSync(path.join(desktopDir, 'electron', 'main.ts'), 'utf8') + const handler = source.match( + /registerHandler\(ELECTRON_IPC_CHANNELS\.appModePrepareRestart,[\s\S]*?\n\s*registerHandler/, + )?.[0] + + expect(handler).toContain('getServerRuntime().stopAll(true)') + }) +}) diff --git a/desktop/electron/services/terminal.test.ts b/desktop/electron/services/terminal.test.ts index ce385944..1350a096 100644 --- a/desktop/electron/services/terminal.test.ts +++ b/desktop/electron/services/terminal.test.ts @@ -71,11 +71,37 @@ afterEach(() => { }) describe('Electron terminal service', () => { - it('uses the portable terminal config path before app userData', () => { - const app = { getPath: vi.fn(() => '/app/user-data') } + it('uses the custom terminal config path before the standard ~/.claude path', () => { + const app = { getPath: vi.fn(() => '/Users/test') } expect(terminalConfigPath(app, { CLAUDE_CONFIG_DIR: '/portable' })).toBe('/portable/terminal-config.json') - expect(terminalConfigPath(app, {})).toBe('/app/user-data/terminal-config.json') + expect(terminalConfigPath(app, {})).toBe('/Users/test/.claude/terminal-config.json') + }) + + it('reads an old userData terminal config but writes future changes to ~/.claude', () => { + const root = tempDir() + const home = path.join(root, 'home') + const userData = path.join(root, 'user-data') + const legacyBash = path.join(root, 'legacy-bash.exe') + const newBash = path.join(root, 'new-bash.exe') + fs.mkdirSync(userData, { recursive: true }) + fs.writeFileSync(legacyBash, '') + fs.writeFileSync(newBash, '') + fs.writeFileSync(path.join(userData, 'terminal-config.json'), JSON.stringify({ bash_path: legacyBash })) + const service = new ElectronTerminalService({ + app: { getPath: name => name === 'home' ? home : userData }, + env: {}, + isFile: filePath => filePath === legacyBash || filePath === newBash, + }) + + expect(service.getBashPath()).toBe(legacyBash) + service.setBashPath(newBash) + expect(JSON.parse(fs.readFileSync(path.join(home, '.claude', 'terminal-config.json'), 'utf8'))).toEqual({ + bash_path: newBash, + }) + expect(JSON.parse(fs.readFileSync(path.join(userData, 'terminal-config.json'), 'utf8'))).toEqual({ + bash_path: legacyBash, + }) }) it('persists the legacy bash path config and validates saved paths', () => { @@ -121,12 +147,16 @@ describe('Electron terminal service', () => { JSON.stringify({ desktopTerminal: { startupShell: 'cmd' } }), ) + const ignoredHome = tempDir() const service = new ElectronTerminalService({ - env: { HOME: dir, COMSPEC: 'powershell.exe' }, + env: { HOME: ignoredHome, USERPROFILE: dir, COMSPEC: 'powershell.exe' }, platform: 'win32', }) - expect(desktopTerminalSettingsPath({ HOME: dir })).toBe(path.join(dir, '.claude', 'settings.json')) + expect(desktopTerminalSettingsPath({ HOME: ignoredHome, USERPROFILE: dir }, 'win32')) + .toBe(path.join(dir, '.claude', 'settings.json')) + expect(desktopTerminalSettingsPath({ HOME: dir }, 'darwin')) + .toBe(path.join(dir, '.claude', 'settings.json')) expect(service.resolveShell()).toBe('cmd.exe') }) diff --git a/desktop/electron/services/terminal.ts b/desktop/electron/services/terminal.ts index 699fcdae..d4cc3522 100644 --- a/desktop/electron/services/terminal.ts +++ b/desktop/electron/services/terminal.ts @@ -60,7 +60,7 @@ export type TerminalPtyFactory = { } export type TerminalAppLike = { - getPath(name: 'userData'): string + getPath(name: 'home' | 'userData'): string } export type TerminalWebContentsLike = { @@ -104,18 +104,26 @@ export function terminalConfigPath(app: TerminalAppLike | undefined, env: NodeJS return path.join(portableDir, TERMINAL_CONFIG_FILE) } if (!app) return null - return path.join(app.getPath('userData'), TERMINAL_CONFIG_FILE) + return path.join(app.getPath('home'), '.claude', TERMINAL_CONFIG_FILE) } -export function claudeConfigDir(env: NodeJS.ProcessEnv = process.env): string | null { +export function claudeConfigDir( + env: NodeJS.ProcessEnv = process.env, + platform: NodeJS.Platform = process.platform, +): string | null { const portableDir = env.CLAUDE_CONFIG_DIR?.trim() if (portableDir) return portableDir - const home = env.HOME || env.USERPROFILE || os.homedir() + const home = platform === 'win32' + ? env.USERPROFILE || os.homedir() + : env.HOME || os.homedir() return home ? path.join(home, '.claude') : null } -export function desktopTerminalSettingsPath(env: NodeJS.ProcessEnv = process.env): string | null { - const dir = claudeConfigDir(env) +export function desktopTerminalSettingsPath( + env: NodeJS.ProcessEnv = process.env, + platform: NodeJS.Platform = process.platform, +): string | null { + const dir = claudeConfigDir(env, platform) return dir ? path.join(dir, 'settings.json') : null } @@ -228,8 +236,11 @@ export function terminalEnvironment( return ensureUtf8Locale(merged, platform) } -export function readDesktopTerminalConfig(env: NodeJS.ProcessEnv = process.env): DesktopTerminalConfig | null { - const settingsPath = desktopTerminalSettingsPath(env) +export function readDesktopTerminalConfig( + env: NodeJS.ProcessEnv = process.env, + platform: NodeJS.Platform = process.platform, +): DesktopTerminalConfig | null { + const settingsPath = desktopTerminalSettingsPath(env, platform) if (!settingsPath) return null try { const parsed = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as DesktopTerminalSettingsFile @@ -242,11 +253,18 @@ export function readDesktopTerminalConfig(env: NodeJS.ProcessEnv = process.env): function loadTerminalConfig(app: TerminalAppLike | undefined, env: NodeJS.ProcessEnv): TerminalConfig { const configPath = terminalConfigPath(app, env) if (!configPath) return {} - try { - return JSON.parse(fs.readFileSync(configPath, 'utf8')) as TerminalConfig - } catch { - return {} + const candidates = [configPath] + if (app && !env.CLAUDE_CONFIG_DIR) { + candidates.push(path.join(app.getPath('userData'), TERMINAL_CONFIG_FILE)) } + for (const candidate of candidates) { + try { + return JSON.parse(fs.readFileSync(candidate, 'utf8')) as TerminalConfig + } catch { + // Try the old Electron userData location before using defaults. + } + } + return {} } function saveTerminalConfig(app: TerminalAppLike | undefined, env: NodeJS.ProcessEnv, config: TerminalConfig) { @@ -520,7 +538,7 @@ export class ElectronTerminalService { terminalConfig.bash_path ?? null, this.fileExists, ) - return resolveDesktopTerminalShell(this.platform, readDesktopTerminalConfig(this.env)) ?? systemDefault + return resolveDesktopTerminalShell(this.platform, readDesktopTerminalConfig(this.env, this.platform)) ?? systemDefault } async spawn(input: TerminalSpawnInput, webContents: TerminalWebContentsLike): Promise { diff --git a/desktop/electron/services/windows.test.ts b/desktop/electron/services/windows.test.ts index 09a9ae5b..3eeb4078 100644 --- a/desktop/electron/services/windows.test.ts +++ b/desktop/electron/services/windows.test.ts @@ -1,4 +1,4 @@ -import { mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import path from 'node:path' import { tmpdir } from 'node:os' import { describe, expect, it, vi } from 'vitest' @@ -21,11 +21,21 @@ import { writeWindowState, } from './windows' -const fakeApp = (userData: string) => ({ - getPath: vi.fn(() => userData), +const fakeApp = (home: string, userData = path.join(home, 'user-data')) => ({ + getPath: vi.fn((name: string) => name === 'home' ? home : userData), }) describe('Electron window service', () => { + it('stores system-mode window state under ~/.claude', () => { + const tmp = mkdtempSync(path.join(tmpdir(), 'electron-window-state-system-')) + try { + const app = fakeApp(tmp) + expect(windowStatePath(app as never, {})).toBe(path.join(tmp, '.claude', 'window-state.json')) + } finally { + rmSync(tmp, { recursive: true, force: true }) + } + }) + it('persists window state in CLAUDE_CONFIG_DIR when portable config is active', () => { const tmp = mkdtempSync(path.join(tmpdir(), 'electron-window-state-')) try { @@ -97,6 +107,26 @@ describe('Electron window service', () => { } }) + it('reads the old Electron userData window state as a forward-migration fallback', () => { + const tmp = mkdtempSync(path.join(tmpdir(), 'electron-window-state-legacy-')) + try { + const userData = path.join(tmp, 'user-data') + const app = fakeApp(tmp, userData) + const state = { x: 50, y: 60, width: 1280, height: 820, maximized: true } + mkdirSync(userData, { recursive: true }) + writeFileSync(path.join(userData, 'window-state.json'), JSON.stringify(state)) + + expect(readWindowState( + app as never, + [{ bounds: { x: 0, y: 0, width: 1440, height: 900 }, workArea: { x: 0, y: 0, width: 1440, height: 860 } }], + {}, + 'win32', + )).toEqual(state) + } finally { + rmSync(tmp, { recursive: true, force: true }) + } + }) + it('clamps restored macOS windows below the menu bar work area', () => { const tmp = mkdtempSync(path.join(tmpdir(), 'electron-window-state-clamp-')) try { diff --git a/desktop/electron/services/windows.ts b/desktop/electron/services/windows.ts index aec23c46..8e6d9d75 100644 --- a/desktop/electron/services/windows.ts +++ b/desktop/electron/services/windows.ts @@ -28,7 +28,7 @@ export type WindowChromeOptions = Pick< > export function windowStatePath(app: App, env: NodeJS.ProcessEnv = process.env): string { - return path.join(env.CLAUDE_CONFIG_DIR || app.getPath('userData'), WINDOW_STATE_FILE) + return path.join(env.CLAUDE_CONFIG_DIR || path.join(app.getPath('home'), '.claude'), WINDOW_STATE_FILE) } export function isPersistableWindowState(state: StoredWindowState): boolean { @@ -93,7 +93,11 @@ export function readWindowState( env: NodeJS.ProcessEnv = process.env, platform = process.platform, ): StoredWindowState | null { - const statePath = windowStatePath(app, env) + let statePath = windowStatePath(app, env) + if (!existsSync(statePath) && !env.CLAUDE_CONFIG_DIR) { + const legacyStatePath = path.join(app.getPath('userData'), WINDOW_STATE_FILE) + if (existsSync(legacyStatePath)) statePath = legacyStatePath + } if (!existsSync(statePath)) return null try { diff --git a/desktop/package.json b/desktop/package.json index 78097d73..6b6e57a5 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -56,7 +56,8 @@ }, "nsis": { "oneClick": false, - "allowToChangeInstallationDirectory": true + "allowToChangeInstallationDirectory": true, + "include": "build/installer.nsh" }, "linux": { "target": [ @@ -85,6 +86,7 @@ "clean:electron-output": "bun run ./scripts/clean-electron-output.ts", "build:macos-arm64": "bash ./scripts/build-macos-arm64.sh", "build:windows-x64": "powershell -ExecutionPolicy Bypass -File ./scripts/build-windows-x64.ps1", + "test:windows-storage-recovery": "powershell -NoLogo -NoProfile -ExecutionPolicy Bypass -File ./build/recover-legacy-install-data.ps1 -SelfTest", "build:linux-x64": "bash ./scripts/build-linux.sh", "build:linux-arm64": "LINUX_ARCH=arm64 bash ./scripts/build-linux.sh", "electron:dev": "bun run build:electron && bun run ./scripts/electron-dev.ts", diff --git a/desktop/src/__tests__/generalSettings.test.tsx b/desktop/src/__tests__/generalSettings.test.tsx index 6dc2346a..1a6ee8b1 100644 --- a/desktop/src/__tests__/generalSettings.test.tsx +++ b/desktop/src/__tests__/generalSettings.test.tsx @@ -294,8 +294,7 @@ describe('Settings > General tab', () => { appMode: { mode: 'default', portableDir: null, - defaultPortableDir: '/Applications/Claude Code Haha/CLAUDE_CONFIG_DIR', - activeConfigDir: null, + activeConfigDir: '/Users/test/.claude', configDirSource: 'system', }, appModeRequiresRestart: false, @@ -304,9 +303,8 @@ describe('Settings > General tab', () => { useSettingsStore.setState({ appMode: { mode, - portableDir: mode === 'portable' ? portableDir ?? '/Applications/Claude Code Haha/CLAUDE_CONFIG_DIR' : null, - defaultPortableDir: '/Applications/Claude Code Haha/CLAUDE_CONFIG_DIR', - activeConfigDir: mode === 'portable' ? portableDir ?? '/Applications/Claude Code Haha/CLAUDE_CONFIG_DIR' : null, + portableDir: mode === 'portable' ? portableDir ?? null : null, + activeConfigDir: mode === 'portable' ? portableDir ?? null : '/Users/test/.claude', configDirSource: mode === 'portable' ? 'portable' : 'system', }, appModeRequiresRestart: true, @@ -514,17 +512,17 @@ describe('Settings > General tab', () => { const storageHeading = screen.getByRole('heading', { name: 'Data Storage Location' }) expect((webSearchHeading.compareDocumentPosition(storageHeading) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0).toBe(true) - expect(screen.getByText(/Switching directories does not migrate existing data/)).toBeInTheDocument() + expect(screen.getByText(/Windows, upgrades recover verified legacy app-adjacent data/)).toBeInTheDocument() }) - it('lets desktop users choose a portable data directory and relaunch immediately', async () => { + it('lets desktop users choose a custom data directory and relaunch immediately', async () => { render() fireEvent.click(screen.getByText('General')) fireEvent.click(screen.getByRole('button', { name: 'Choose Folder' })) await waitFor(() => { - expect(screen.getByLabelText('Portable data directory')).toHaveValue('/Users/test/cc-haha-data') + expect(screen.getByLabelText('Custom data directory')).toHaveValue('/Users/test/cc-haha-data') }) fireEvent.click(screen.getByRole('button', { name: 'Use This Folder and Restart' })) @@ -538,12 +536,11 @@ describe('Settings > General tab', () => { }) }) - it('switches back to the system directory without deleting portable data', async () => { + it('switches back to ~/.claude without deleting custom data', async () => { useSettingsStore.setState({ appMode: { mode: 'portable', portableDir: '/Users/test/cc-haha-data', - defaultPortableDir: '/Applications/Claude Code Haha/CLAUDE_CONFIG_DIR', activeConfigDir: '/Users/test/cc-haha-data', configDirSource: 'portable', }, @@ -554,7 +551,7 @@ describe('Settings > General tab', () => { fireEvent.click(screen.getByText('General')) fireEvent.click(screen.getByRole('button', { name: /Use system directory/ })) - expect(screen.getByText(/Data in the portable directory is not deleted/)).toBeInTheDocument() + expect(screen.getByText(/Data in the custom directory is not deleted/)).toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: 'Save and Restart' })) await waitFor(() => { @@ -564,19 +561,20 @@ describe('Settings > General tab', () => { }) }) - it('validates portable directory input and lets users reset to the app-side folder', async () => { + it('requires an explicit custom directory and exposes no third default-custom choice', async () => { render() fireEvent.click(screen.getByText('General')) - const input = screen.getByLabelText('Portable data directory') + const input = screen.getByLabelText('Custom data directory') fireEvent.change(input, { target: { value: '' } }) fireEvent.click(screen.getByRole('button', { name: 'Use This Folder and Restart' })) - expect(screen.getByText('Choose or enter a portable data directory first.')).toBeInTheDocument() + expect(screen.getByText('Choose or enter a custom data directory first.')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: /default.*data folder/i })).not.toBeInTheDocument() - fireEvent.click(screen.getByRole('button', { name: 'Use the default portable folder beside the app' })) - expect(input).toHaveValue('/Applications/Claude Code Haha/CLAUDE_CONFIG_DIR') - expect(screen.queryByText('Choose or enter a portable data directory first.')).not.toBeInTheDocument() + fireEvent.change(input, { target: { value: '/Users/test/custom-data' } }) + expect(input).toHaveValue('/Users/test/custom-data') + expect(screen.queryByText('Choose or enter a custom data directory first.')).not.toBeInTheDocument() }) it('shows folder picker failures as an inline storage error', async () => { @@ -595,7 +593,6 @@ describe('Settings > General tab', () => { appMode: { mode: 'portable', portableDir: '/env/claude-data', - defaultPortableDir: '/Applications/Claude Code Haha/CLAUDE_CONFIG_DIR', activeConfigDir: '/env/claude-data', configDirSource: 'environment', }, @@ -609,7 +606,7 @@ describe('Settings > General tab', () => { fireEvent.click(screen.getByRole('button', { name: /Use system directory/ })) expect(screen.getByText(/Remove it from the launch environment before switching back/)).toBeInTheDocument() - fireEvent.change(screen.getByLabelText('Portable data directory'), { target: { value: '/other/data' } }) + fireEvent.change(screen.getByLabelText('Custom data directory'), { target: { value: '/other/data' } }) fireEvent.click(screen.getByRole('button', { name: 'Use This Folder and Restart' })) expect(screen.queryByText('Switch data storage location?')).not.toBeInTheDocument() expect(screen.getByText(/Remove it from the launch environment before switching back/)).toBeInTheDocument() @@ -619,6 +616,7 @@ describe('Settings > General tab', () => { render() fireEvent.click(screen.getByText('General')) + fireEvent.change(screen.getByLabelText('Custom data directory'), { target: { value: '/Users/test/custom-data' } }) fireEvent.click(screen.getByRole('button', { name: 'Use This Folder and Restart' })) expect(screen.getByText('Switch data storage location?')).toBeInTheDocument() @@ -636,6 +634,7 @@ describe('Settings > General tab', () => { render() fireEvent.click(screen.getByText('General')) + fireEvent.change(screen.getByLabelText('Custom data directory'), { target: { value: '/Users/test/custom-data' } }) fireEvent.click(screen.getByRole('button', { name: 'Use This Folder and Restart' })) fireEvent.click(screen.getByRole('button', { name: 'Save and Restart' })) diff --git a/desktop/src/i18n/index.test.tsx b/desktop/src/i18n/index.test.tsx index fc675998..3a08e0fb 100644 --- a/desktop/src/i18n/index.test.tsx +++ b/desktop/src/i18n/index.test.tsx @@ -39,4 +39,14 @@ describe('useTranslation', () => { expect(translate('jp', 'session.timeMinutes', { n: 5 })).toBe('5 分前') expect(translate('kr', 'session.timeMinutes', { n: 5 })).toBe('5분 전') }) + + it('describes exactly the standard ~/.claude mode and an external custom mode', () => { + expect(translate('en', 'settings.general.storageSystemDescription')).toContain('~/.claude') + expect(translate('zh', 'settings.general.storageSystemDescription')).toContain('~/.claude') + expect(translate('zh-TW', 'settings.general.storageSystemDescription')).toContain('~/.claude') + expect(translate('jp', 'settings.general.storageSystemDescription')).toContain('~/.claude') + expect(translate('kr', 'settings.general.storageSystemDescription')).toContain('~/.claude') + expect(translate('en', 'settings.general.storagePortableTitle')).toContain('custom') + expect(translate('zh', 'settings.general.storagePortableTitle')).toContain('自定义') + }) }) diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index 9a55bb92..5620ec33 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -938,25 +938,24 @@ export const en = { 'settings.general.storageTitle': 'Data Storage Location', 'settings.general.storageDescription': 'Advanced, low-frequency setting. After switching, sessions, Skills, MCP, plugins, provider settings, tasks, and caches are read from the new directory.', 'settings.general.storageSystemTitle': 'Use system directory', - 'settings.general.storageSystemDescription': 'Return to the default data source. If CLAUDE_CONFIG_DIR is set in the launch environment, that environment variable still takes priority.', - 'settings.general.storagePortableTitle': 'Use portable directory', - 'settings.general.storagePortableDescription': 'Store desktop data in a folder you choose. Use this for external drives or app bundles you want to move together.', - 'settings.general.storagePortableDirLabel': 'Portable data directory', + 'settings.general.storageSystemDescription': "Use Claude Code's standard user directory: ~/.claude (on Windows, %USERPROFILE%\\.claude).", + 'settings.general.storagePortableTitle': 'Use custom directory', + 'settings.general.storagePortableDescription': 'Store desktop data in one explicit folder you choose. It must be an absolute path outside the app installation directory.', + 'settings.general.storagePortableDirLabel': 'Custom data directory', 'settings.general.storagePortableDirPlaceholder': 'Choose a folder for cc-haha data', 'settings.general.storageChooseDir': 'Choose Folder', - 'settings.general.storageChooseDirTitle': 'Choose portable data directory', - 'settings.general.storageUseDefaultPortableDir': 'Use the default portable folder beside the app', + 'settings.general.storageChooseDirTitle': 'Choose custom data directory', 'settings.general.storageApplyPortable': 'Use This Folder and Restart', 'settings.general.storageActiveDir': 'Current active data directory', 'settings.general.storageEnvironmentHint': 'The current directory is controlled by the CLAUDE_CONFIG_DIR environment variable. In-app switching cannot override it; remove CLAUDE_CONFIG_DIR from the launch environment to return to the system directory.', 'settings.general.storageEnvironmentSwitchBlocked': 'CLAUDE_CONFIG_DIR currently controls the data directory. Remove it from the launch environment before switching back to the system directory.', 'settings.general.storageRestartHint': 'The storage change has been saved. Restart the app for the new data directory to take effect.', - 'settings.general.storageMoveHint': 'Switching directories does not migrate existing data. Copy projects, skills, plugins, cc-haha, and related folders from the old directory if you want old sessions to keep appearing. For a portable bundle, keep this folder beside the app and zip them together.', - 'settings.general.storageNoDirError': 'Choose or enter a portable data directory first.', + 'settings.general.storageMoveHint': 'Switching modes does not merge directories. On Windows, upgrades recover verified legacy app-adjacent data before uninstalling; if recovery cannot be verified, the upgrade stops.', + 'settings.general.storageNoDirError': 'Choose or enter a custom data directory first.', 'settings.general.storagePickerError': 'Could not open the folder picker. Paste the folder path manually.', 'settings.general.storageRestartError': 'The change was saved, but automatic restart failed. Restart the app manually.', 'settings.general.storageSwitchPortableBody': 'After switching, the desktop app will read and write sessions, settings, Skills, MCP, plugins, tasks, and caches in this directory.', - 'settings.general.storageSwitchDefaultBody': 'After switching back, the desktop app will use the system data source again. Data in the portable directory is not deleted or moved back automatically.', + 'settings.general.storageSwitchDefaultBody': 'After switching back, the desktop app will use ~/.claude again. Data in the custom directory is not deleted or moved back automatically.', 'settings.general.storageSwitchRestartBody': 'The app will stop the local server and adapter processes, then relaunch. The new directory takes effect after restart.', // Settings > General diff --git a/desktop/src/i18n/locales/jp.ts b/desktop/src/i18n/locales/jp.ts index bdf9e639..c1238e81 100644 --- a/desktop/src/i18n/locales/jp.ts +++ b/desktop/src/i18n/locales/jp.ts @@ -940,25 +940,24 @@ export const jp: Record = { 'settings.general.storageTitle': 'データ保存場所', 'settings.general.storageDescription': '高度で使用頻度の低い設定です。切り替え後は、セッション、スキル、MCP、プラグイン、プロバイダー設定、タスク、キャッシュが新しいディレクトリから読み込まれます。', 'settings.general.storageSystemTitle': 'システムディレクトリを使用', - 'settings.general.storageSystemDescription': 'デフォルトのデータソースに戻します。起動環境で CLAUDE_CONFIG_DIR が設定されている場合は、その環境変数が引き続き優先されます。', - 'settings.general.storagePortableTitle': 'ポータブルディレクトリを使用', - 'settings.general.storagePortableDescription': '選択したフォルダにデスクトップのデータを保存します。外付けドライブや、一緒に移動したいアプリバンドルに使用してください。', - 'settings.general.storagePortableDirLabel': 'ポータブルデータディレクトリ', + 'settings.general.storageSystemDescription': 'Claude Code の標準ユーザーディレクトリ ~/.claude(Windows では %USERPROFILE%\\.claude)を使用します。', + 'settings.general.storagePortableTitle': 'カスタムディレクトリを使用', + 'settings.general.storagePortableDescription': '明示的に選択したフォルダへデスクトップデータを保存します。絶対パスで、アプリのインストール先の外にある必要があります。', + 'settings.general.storagePortableDirLabel': 'カスタムデータディレクトリ', 'settings.general.storagePortableDirPlaceholder': 'cc-haha データ用のフォルダを選択', 'settings.general.storageChooseDir': 'フォルダを選択', - 'settings.general.storageChooseDirTitle': 'ポータブルデータディレクトリを選択', - 'settings.general.storageUseDefaultPortableDir': 'アプリの隣にあるデフォルトのポータブルフォルダを使用', + 'settings.general.storageChooseDirTitle': 'カスタムデータディレクトリを選択', 'settings.general.storageApplyPortable': 'このフォルダを使用して再起動', 'settings.general.storageActiveDir': '現在アクティブなデータディレクトリ', 'settings.general.storageEnvironmentHint': '現在のディレクトリは CLAUDE_CONFIG_DIR 環境変数によって制御されています。アプリ内での切り替えではこれを上書きできません。システムディレクトリに戻すには、起動環境から CLAUDE_CONFIG_DIR を削除してください。', 'settings.general.storageEnvironmentSwitchBlocked': '現在 CLAUDE_CONFIG_DIR がデータディレクトリを制御しています。システムディレクトリに戻す前に、起動環境からこれを削除してください。', 'settings.general.storageRestartHint': '保存場所の変更を保存しました。新しいデータディレクトリを有効にするには、アプリを再起動してください。', - 'settings.general.storageMoveHint': 'ディレクトリを切り替えても既存のデータは移行されません。以前のセッションを引き続き表示したい場合は、古いディレクトリから projects、skills、plugins、cc-haha および関連フォルダをコピーしてください。ポータブルバンドルにする場合は、このフォルダをアプリの隣に置いて一緒に zip 化してください。', - 'settings.general.storageNoDirError': 'まずポータブルデータディレクトリを選択または入力してください。', + 'settings.general.storageMoveHint': 'モード切り替えでディレクトリ同士は統合されません。Windows のアップグレードでは旧データを検証してから退避し、完全性を確認できない場合はアップグレードを停止します。', + 'settings.general.storageNoDirError': 'まずカスタムデータディレクトリを選択または入力してください。', 'settings.general.storagePickerError': 'フォルダピッカーを開けませんでした。フォルダのパスを手動で貼り付けてください。', 'settings.general.storageRestartError': '変更は保存されましたが、自動再起動に失敗しました。アプリを手動で再起動してください。', 'settings.general.storageSwitchPortableBody': '切り替え後、デスクトップアプリはこのディレクトリでセッション、設定、スキル、MCP、プラグイン、タスク、キャッシュを読み書きします。', - 'settings.general.storageSwitchDefaultBody': '元に戻すと、デスクトップアプリは再びシステムのデータソースを使用します。ポータブルディレクトリ内のデータは自動的に削除されたり戻されたりすることはありません。', + 'settings.general.storageSwitchDefaultBody': '元に戻すと、デスクトップアプリは再び ~/.claude を使用します。カスタムディレクトリ内のデータは削除も自動移動もされません。', 'settings.general.storageSwitchRestartBody': 'アプリはローカルサーバーとアダプタープロセスを停止してから再起動します。新しいディレクトリは再起動後に有効になります。', // Settings > General diff --git a/desktop/src/i18n/locales/kr.ts b/desktop/src/i18n/locales/kr.ts index 76addeb2..cd339093 100644 --- a/desktop/src/i18n/locales/kr.ts +++ b/desktop/src/i18n/locales/kr.ts @@ -940,25 +940,24 @@ export const kr: Record = { 'settings.general.storageTitle': '데이터 저장 위치', 'settings.general.storageDescription': '고급, 사용 빈도가 낮은 설정입니다. 전환 후에는 세션, 스킬, MCP, 플러그인, 공급자 설정, 작업, 캐시를 새 디렉터리에서 읽습니다.', 'settings.general.storageSystemTitle': '시스템 디렉터리 사용', - 'settings.general.storageSystemDescription': '기본 데이터 소스로 돌아갑니다. 시작 환경에 CLAUDE_CONFIG_DIR이 설정되어 있으면 해당 환경 변수가 여전히 우선합니다.', - 'settings.general.storagePortableTitle': '포터블 디렉터리 사용', - 'settings.general.storagePortableDescription': '선택한 폴더에 데스크톱 데이터를 저장합니다. 외장 드라이브나 함께 옮기려는 앱 번들에 사용하세요.', - 'settings.general.storagePortableDirLabel': '포터블 데이터 디렉터리', + 'settings.general.storageSystemDescription': 'Claude Code의 표준 사용자 디렉터리인 ~/.claude(Windows에서는 %USERPROFILE%\\.claude)를 사용합니다.', + 'settings.general.storagePortableTitle': '사용자 지정 디렉터리 사용', + 'settings.general.storagePortableDescription': '명시적으로 선택한 폴더에 데스크톱 데이터를 저장합니다. 절대 경로여야 하며 앱 설치 디렉터리 밖에 있어야 합니다.', + 'settings.general.storagePortableDirLabel': '사용자 지정 데이터 디렉터리', 'settings.general.storagePortableDirPlaceholder': 'cc-haha 데이터용 폴더 선택', 'settings.general.storageChooseDir': '폴더 선택', - 'settings.general.storageChooseDirTitle': '포터블 데이터 디렉터리 선택', - 'settings.general.storageUseDefaultPortableDir': '앱 옆의 기본 포터블 폴더 사용', + 'settings.general.storageChooseDirTitle': '사용자 지정 데이터 디렉터리 선택', 'settings.general.storageApplyPortable': '이 폴더를 사용하고 다시 시작', 'settings.general.storageActiveDir': '현재 활성 데이터 디렉터리', 'settings.general.storageEnvironmentHint': '현재 디렉터리는 CLAUDE_CONFIG_DIR 환경 변수로 제어됩니다. 앱 내 전환으로는 이를 재정의할 수 없습니다. 시스템 디렉터리로 돌아가려면 시작 환경에서 CLAUDE_CONFIG_DIR을 제거하세요.', 'settings.general.storageEnvironmentSwitchBlocked': '현재 CLAUDE_CONFIG_DIR이 데이터 디렉터리를 제어합니다. 시스템 디렉터리로 돌아가기 전에 시작 환경에서 이를 제거하세요.', 'settings.general.storageRestartHint': '저장 위치 변경이 저장되었습니다. 새 데이터 디렉터리를 적용하려면 앱을 다시 시작하세요.', - 'settings.general.storageMoveHint': '디렉터리를 전환해도 기존 데이터는 마이그레이션되지 않습니다. 이전 세션을 계속 표시하려면 이전 디렉터리에서 projects, skills, plugins, cc-haha 및 관련 폴더를 복사하세요. 포터블 번들의 경우 이 폴더를 앱 옆에 두고 함께 압축하세요.', - 'settings.general.storageNoDirError': '먼저 포터블 데이터 디렉터리를 선택하거나 입력하세요.', + 'settings.general.storageMoveHint': '모드를 전환해도 두 디렉터리는 병합되지 않습니다. Windows 업그레이드는 기존 데이터를 검증해 복구한 뒤 제거하며, 완전성을 확인할 수 없으면 업그레이드를 중단합니다.', + 'settings.general.storageNoDirError': '먼저 사용자 지정 데이터 디렉터리를 선택하거나 입력하세요.', 'settings.general.storagePickerError': '폴더 선택기를 열 수 없습니다. 폴더 경로를 수동으로 붙여넣으세요.', 'settings.general.storageRestartError': '변경 사항은 저장되었지만 자동 다시 시작에 실패했습니다. 앱을 수동으로 다시 시작하세요.', 'settings.general.storageSwitchPortableBody': '전환 후 데스크톱 앱은 이 디렉터리에서 세션, 설정, 스킬, MCP, 플러그인, 작업, 캐시를 읽고 씁니다.', - 'settings.general.storageSwitchDefaultBody': '다시 전환하면 데스크톱 앱은 다시 시스템 데이터 소스를 사용합니다. 포터블 디렉터리의 데이터는 자동으로 삭제되거나 되돌려지지 않습니다.', + 'settings.general.storageSwitchDefaultBody': '다시 전환하면 데스크톱 앱은 ~/.claude를 사용합니다. 사용자 지정 디렉터리의 데이터는 삭제되거나 자동으로 이동되지 않습니다.', 'settings.general.storageSwitchRestartBody': '앱이 로컬 서버와 어댑터 프로세스를 중지한 후 다시 시작합니다. 새 디렉터리는 다시 시작 후에 적용됩니다.', // Settings > General diff --git a/desktop/src/i18n/locales/zh-TW.ts b/desktop/src/i18n/locales/zh-TW.ts index dcb7b57b..36e8b64a 100644 --- a/desktop/src/i18n/locales/zh-TW.ts +++ b/desktop/src/i18n/locales/zh-TW.ts @@ -940,25 +940,24 @@ export const zh: Record = { 'settings.general.storageTitle': '資料儲存位置', 'settings.general.storageDescription': '低頻高階設定。切換後,會話記錄、Skills、MCP、外掛、Provider 配置、任務和快取都會從新的目錄讀取。', 'settings.general.storageSystemTitle': '使用系統目錄', - 'settings.general.storageSystemDescription': '回到預設資料來源。若啟動環境設定了 CLAUDE_CONFIG_DIR,則仍會優先使用該環境變數指定的目錄。', - 'settings.general.storagePortableTitle': '使用便攜目錄', - 'settings.general.storagePortableDescription': '把桌面端資料寫入你選擇的資料夾,適合放在行動硬碟或和應用一起打包遷移。', - 'settings.general.storagePortableDirLabel': '便攜資料目錄', + 'settings.general.storageSystemDescription': '使用 Claude Code 的標準使用者目錄:~/.claude(Windows 上是 %USERPROFILE%\\.claude)。', + 'settings.general.storagePortableTitle': '使用自訂目錄', + 'settings.general.storagePortableDescription': '把桌面端資料寫入你明確選擇的資料夾。必須使用絕對路徑,而且不能放在應用安裝目錄內。', + 'settings.general.storagePortableDirLabel': '自訂資料目錄', 'settings.general.storagePortableDirPlaceholder': '選擇一個用於儲存 cc-haha 資料的資料夾', 'settings.general.storageChooseDir': '選擇目錄', - 'settings.general.storageChooseDirTitle': '選擇便攜資料目錄', - 'settings.general.storageUseDefaultPortableDir': '使用應用旁邊的預設便攜目錄', + 'settings.general.storageChooseDirTitle': '選擇自訂資料目錄', 'settings.general.storageApplyPortable': '使用這個目錄並重啟', 'settings.general.storageActiveDir': '當前實際讀取目錄', 'settings.general.storageEnvironmentHint': '當前目錄由 CLAUDE_CONFIG_DIR 環境變數控制。應用內切換不會覆蓋這個環境變數;如需回到系統目錄,請先移除啟動環境裡的 CLAUDE_CONFIG_DIR。', 'settings.general.storageEnvironmentSwitchBlocked': '當前由 CLAUDE_CONFIG_DIR 環境變數控制。請先移除啟動環境裡的 CLAUDE_CONFIG_DIR,再切回系統目錄。', 'settings.general.storageRestartHint': '已儲存切換請求。請重啟應用,讓新的資料目錄生效。', - 'settings.general.storageMoveHint': '切換目錄不會自動搬遷舊資料。如果希望舊會話繼續出現,請把原目錄下的 projects、skills、plugins、cc-haha 等資料複製到新目錄。要做便攜包,建議把目錄放在應用旁邊並一起壓縮。', - 'settings.general.storageNoDirError': '請先選擇或填寫一個便攜資料目錄。', + 'settings.general.storageMoveHint': '切換模式不會合併兩個目錄。Windows 升級會先驗證並救援舊應用目錄旁的資料;無法確認完整恢復時,安裝程式會停止升級。', + 'settings.general.storageNoDirError': '請先選擇或填寫一個自訂資料目錄。', 'settings.general.storagePickerError': '無法開啟目錄選擇器,請手動貼上目錄路徑。', 'settings.general.storageRestartError': '切換已儲存,但自動重啟失敗,請手動重啟應用。', 'settings.general.storageSwitchPortableBody': '切換後,桌面端會從下面這個目錄讀取和寫入會話、配置、Skills、MCP、外掛、任務和快取。', - 'settings.general.storageSwitchDefaultBody': '切回系統目錄後,桌面端會回到系統預設資料來源。當前便攜目錄中的資料不會被刪除,也不會自動遷回。', + 'settings.general.storageSwitchDefaultBody': '切回系統目錄後,桌面端會重新使用 ~/.claude。當前自訂目錄中的資料不會被刪除,也不會自動遷回。', 'settings.general.storageSwitchRestartBody': '應用將先關閉本地服務和介面卡程序,然後自動重啟。重啟後新目錄才會生效。', // Settings > General diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index e79d7e9b..6e58686c 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -940,25 +940,24 @@ export const zh: Record = { 'settings.general.storageTitle': '数据存储位置', 'settings.general.storageDescription': '低频高级设置。切换后,会话记录、Skills、MCP、插件、Provider 配置、任务和缓存都会从新的目录读取。', 'settings.general.storageSystemTitle': '使用系统目录', - 'settings.general.storageSystemDescription': '回到默认数据源。若启动环境设置了 CLAUDE_CONFIG_DIR,则仍会优先使用该环境变量指定的目录。', - 'settings.general.storagePortableTitle': '使用便携目录', - 'settings.general.storagePortableDescription': '把桌面端数据写入你选择的文件夹,适合放在移动硬盘或和应用一起打包迁移。', - 'settings.general.storagePortableDirLabel': '便携数据目录', + 'settings.general.storageSystemDescription': '使用 Claude Code 的标准用户目录:~/.claude(Windows 上是 %USERPROFILE%\\.claude)。', + 'settings.general.storagePortableTitle': '使用自定义目录', + 'settings.general.storagePortableDescription': '把桌面端数据写入你明确选择的文件夹。必须使用绝对路径,而且不能放在应用安装目录内。', + 'settings.general.storagePortableDirLabel': '自定义数据目录', 'settings.general.storagePortableDirPlaceholder': '选择一个用于保存 cc-haha 数据的文件夹', 'settings.general.storageChooseDir': '选择目录', - 'settings.general.storageChooseDirTitle': '选择便携数据目录', - 'settings.general.storageUseDefaultPortableDir': '使用应用旁边的默认便携目录', + 'settings.general.storageChooseDirTitle': '选择自定义数据目录', 'settings.general.storageApplyPortable': '使用这个目录并重启', 'settings.general.storageActiveDir': '当前实际读取目录', 'settings.general.storageEnvironmentHint': '当前目录由 CLAUDE_CONFIG_DIR 环境变量控制。应用内切换不会覆盖这个环境变量;如需回到系统目录,请先移除启动环境里的 CLAUDE_CONFIG_DIR。', 'settings.general.storageEnvironmentSwitchBlocked': '当前由 CLAUDE_CONFIG_DIR 环境变量控制。请先移除启动环境里的 CLAUDE_CONFIG_DIR,再切回系统目录。', 'settings.general.storageRestartHint': '已保存切换请求。请重启应用,让新的数据目录生效。', - 'settings.general.storageMoveHint': '切换目录不会自动搬迁旧数据。如果希望旧会话继续出现,请把原目录下的 projects、skills、plugins、cc-haha 等数据复制到新目录。要做便携包,建议把目录放在应用旁边并一起压缩。', - 'settings.general.storageNoDirError': '请先选择或填写一个便携数据目录。', + 'settings.general.storageMoveHint': '切换模式不会合并两个目录。Windows 升级会先校验并救援旧应用目录旁的数据;无法确认完整恢复时,安装器会停止升级。', + 'settings.general.storageNoDirError': '请先选择或填写一个自定义数据目录。', 'settings.general.storagePickerError': '无法打开目录选择器,请手动粘贴目录路径。', 'settings.general.storageRestartError': '切换已保存,但自动重启失败,请手动重启应用。', 'settings.general.storageSwitchPortableBody': '切换后,桌面端会从下面这个目录读取和写入会话、配置、Skills、MCP、插件、任务和缓存。', - 'settings.general.storageSwitchDefaultBody': '切回系统目录后,桌面端会回到系统默认数据源。当前便携目录中的数据不会被删除,也不会自动迁回。', + 'settings.general.storageSwitchDefaultBody': '切回系统目录后,桌面端会重新使用 ~/.claude。当前自定义目录中的数据不会被删除,也不会自动迁回。', 'settings.general.storageSwitchRestartBody': '应用将先关闭本地服务和适配器进程,然后自动重启。重启后新目录才会生效。', // Settings > General diff --git a/desktop/src/lib/desktopHost/browserHost.ts b/desktop/src/lib/desktopHost/browserHost.ts index 2f8c9903..f0722c95 100644 --- a/desktop/src/lib/desktopHost/browserHost.ts +++ b/desktop/src/lib/desktopHost/browserHost.ts @@ -31,7 +31,6 @@ function noopUnlisten(): void { const defaultAppMode: AppModeConfig = { mode: 'default', portableDir: null, - defaultPortableDir: null, } const defaultPermissionState: NotificationPermissionState = 'default' @@ -240,9 +239,6 @@ export const browserHost: DesktopHost = { async set() { unsupported('Desktop app mode') }, - async detectPortableDir() { - return null - }, async prepareRestart() { unsupported('Desktop app restart') }, diff --git a/desktop/src/lib/desktopHost/electronHost.ts b/desktop/src/lib/desktopHost/electronHost.ts index 25779fbb..07184b4e 100644 --- a/desktop/src/lib/desktopHost/electronHost.ts +++ b/desktop/src/lib/desktopHost/electronHost.ts @@ -153,7 +153,6 @@ export function createElectronHost(bridge: ElectronHostBridge): DesktopHost { appMode: { get: () => invoke(ELECTRON_IPC_CHANNELS.appModeGet), set: config => invoke(ELECTRON_IPC_CHANNELS.appModeSet, config), - detectPortableDir: () => invoke(ELECTRON_IPC_CHANNELS.appModeDetectPortableDir), prepareRestart: () => invoke(ELECTRON_IPC_CHANNELS.appModePrepareRestart), restart: () => invoke(ELECTRON_IPC_CHANNELS.appModeRestart), }, diff --git a/desktop/src/lib/desktopHost/types.ts b/desktop/src/lib/desktopHost/types.ts index 02492687..b8ac7d6e 100644 --- a/desktop/src/lib/desktopHost/types.ts +++ b/desktop/src/lib/desktopHost/types.ts @@ -136,11 +136,6 @@ export type AppModeSetInput = { portableDir: string | null } -export type PortableDirDetection = { - defaultPortableDir: string | null - hasData: boolean -} - export type DesktopHost = { kind: DesktopHostKind isDesktop: boolean @@ -222,7 +217,6 @@ export type DesktopHost = { appMode: { get(): Promise set(config: AppModeSetInput): Promise - detectPortableDir(): Promise prepareRestart(): Promise restart(): Promise } diff --git a/desktop/src/pages/Settings.tsx b/desktop/src/pages/Settings.tsx index b3ed6d79..5fd53fb0 100644 --- a/desktop/src/pages/Settings.tsx +++ b/desktop/src/pages/Settings.tsx @@ -2131,8 +2131,8 @@ export function GeneralSettings() { }, [fetchAppMode]) useEffect(() => { - setPortableDirDraft(appMode.portableDir ?? appMode.defaultPortableDir ?? '') - }, [appMode.defaultPortableDir, appMode.portableDir]) + setPortableDirDraft(appMode.portableDir ?? '') + }, [appMode.portableDir]) const LANGUAGES: Array<{ value: Locale; label: string }> = [ { value: 'en', label: 'English' }, @@ -3197,17 +3197,7 @@ export function GeneralSettings() { -
- +