fix(desktop): preserve Windows data directories #959 #973 #1000

This commit is contained in:
程序员阿江(Relakkes) 2026-07-11 21:09:06 +08:00
parent ad2a504bba
commit 347dbc3368
30 changed files with 1802 additions and 381 deletions

View File

@ -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:

View File

@ -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:

200
desktop/build/installer.nsh Normal file
View File

@ -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

View File

@ -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
}

View File

@ -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,

View File

@ -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',

View File

@ -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<typeof setAppMode>[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()

View File

@ -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'])
})
})

View File

@ -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,
})
}

View File

@ -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)')
})
})

View File

@ -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')
})

View File

@ -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<TerminalSpawnResult> {

View File

@ -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 {

View File

@ -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 {

View File

@ -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",

View File

@ -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(<Settings />)
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(<Settings />)
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(<Settings />)
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(<Settings />)
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' }))

View File

@ -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('自定义')
})
})

View File

@ -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

View File

@ -940,25 +940,24 @@ export const jp: Record<TranslationKey, string> = {
'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 の標準ユーザーディレクトリ ~/.claudeWindows では %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

View File

@ -940,25 +940,24 @@ export const kr: Record<TranslationKey, string> = {
'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

View File

@ -940,25 +940,24 @@ export const zh: Record<TranslationKey, string> = {
'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 的標準使用者目錄:~/.claudeWindows 上是 %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

View File

@ -940,25 +940,24 @@ export const zh: Record<TranslationKey, string> = {
'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 的标准用户目录:~/.claudeWindows 上是 %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

View File

@ -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')
},

View File

@ -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),
},

View File

@ -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<AppModeConfig>
set(config: AppModeSetInput): Promise<void>
detectPortableDir(): Promise<PortableDirDetection | null>
prepareRestart(): Promise<void>
restart(): Promise<void>
}

View File

@ -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() {
</Button>
</div>
<div className="mt-3 flex flex-wrap items-center justify-between gap-2">
<button
type="button"
className="text-xs font-medium text-[var(--color-brand)] hover:underline"
onClick={() => {
setPortableDirDraft(appMode.defaultPortableDir ?? '')
setModeError(null)
}}
>
{t('settings.general.storageUseDefaultPortableDir')}
</button>
<div className="mt-3 flex justify-end">
<Button
type="button"
size="sm"

View File

@ -445,74 +445,26 @@ describe('settingsStore app mode', () => {
it('hydrates app mode from the Electron desktop host', async () => {
const getAppMode = vi.fn().mockResolvedValue({
mode: 'portable',
portableDir: 'C:\\cc-haha\\CLAUDE_CONFIG_DIR',
defaultPortableDir: 'C:\\cc-haha\\CLAUDE_CONFIG_DIR',
})
installElectronAppModeHost({ get: getAppMode })
const { useSettingsStore } = await import('./settingsStore')
await useSettingsStore.getState().fetchAppMode()
expect(getAppMode).toHaveBeenCalledTimes(1)
expect(useSettingsStore.getState().appMode).toEqual({
mode: 'portable',
portableDir: 'C:\\cc-haha\\CLAUDE_CONFIG_DIR',
defaultPortableDir: 'C:\\cc-haha\\CLAUDE_CONFIG_DIR',
})
})
it('hydrates app mode from an injected desktop host', async () => {
const getAppMode = vi.fn().mockResolvedValue({
mode: 'portable',
portableDir: 'D:\\cc-haha\\data',
defaultPortableDir: 'D:\\cc-haha\\data',
})
installElectronAppModeHost({ get: getAppMode })
const { useSettingsStore } = await import('./settingsStore')
await useSettingsStore.getState().fetchAppMode()
expect(getAppMode).toHaveBeenCalledTimes(1)
expect(useSettingsStore.getState().appMode).toEqual({
mode: 'portable',
portableDir: 'D:\\cc-haha\\data',
defaultPortableDir: 'D:\\cc-haha\\data',
})
})
it('persists app mode through the Electron desktop host and marks restart required', async () => {
const setAppMode = vi.fn().mockResolvedValue(undefined)
installElectronAppModeHost({ set: setAppMode })
const { useSettingsStore } = await import('./settingsStore')
useSettingsStore.setState({
appMode: {
mode: 'default',
portableDir: null,
defaultPortableDir: 'C:\\cc-haha\\CLAUDE_CONFIG_DIR',
},
appModeRequiresRestart: false,
})
await useSettingsStore.getState().setAppMode('portable')
expect(setAppMode).toHaveBeenCalledWith({
mode: 'portable',
portableDir: 'C:\\cc-haha\\CLAUDE_CONFIG_DIR',
})
expect(useSettingsStore.getState().appMode).toEqual({
mode: 'portable',
portableDir: 'C:\\cc-haha\\CLAUDE_CONFIG_DIR',
defaultPortableDir: 'C:\\cc-haha\\CLAUDE_CONFIG_DIR',
activeConfigDir: 'C:\\cc-haha\\CLAUDE_CONFIG_DIR',
portableDir: 'D:\\cc-haha-data',
activeConfigDir: 'D:\\cc-haha-data',
configDirSource: 'portable',
})
installElectronAppModeHost({ get: getAppMode })
const { useSettingsStore } = await import('./settingsStore')
await useSettingsStore.getState().fetchAppMode()
expect(getAppMode).toHaveBeenCalledTimes(1)
expect(useSettingsStore.getState().appMode).toEqual({
mode: 'portable',
portableDir: 'D:\\cc-haha-data',
activeConfigDir: 'D:\\cc-haha-data',
configDirSource: 'portable',
})
expect(useSettingsStore.getState().appModeRequiresRestart).toBe(true)
})
it('persists app mode through an injected desktop host', async () => {
it('requires an explicit custom directory instead of inventing a default portable path', async () => {
const setAppMode = vi.fn().mockResolvedValue(undefined)
installElectronAppModeHost({ set: setAppMode })
@ -521,21 +473,18 @@ describe('settingsStore app mode', () => {
appMode: {
mode: 'default',
portableDir: null,
defaultPortableDir: 'D:\\cc-haha\\data',
activeConfigDir: 'C:\\Users\\test\\.claude',
configDirSource: 'system',
},
appModeRequiresRestart: false,
})
await useSettingsStore.getState().setAppMode('portable')
expect(setAppMode).toHaveBeenCalledWith({
mode: 'portable',
portableDir: 'D:\\cc-haha\\data',
})
expect(useSettingsStore.getState().appModeRequiresRestart).toBe(true)
await expect(useSettingsStore.getState().setAppMode('portable')).rejects.toThrow('Choose an absolute custom data directory')
expect(setAppMode).not.toHaveBeenCalled()
expect(useSettingsStore.getState().appModeRequiresRestart).toBe(false)
})
it('persists a user-selected portable directory', async () => {
it('persists a user-selected custom directory', async () => {
const setAppMode = vi.fn().mockResolvedValue(undefined)
installElectronAppModeHost({ set: setAppMode })
@ -544,7 +493,8 @@ describe('settingsStore app mode', () => {
appMode: {
mode: 'default',
portableDir: null,
defaultPortableDir: 'C:\\cc-haha\\CLAUDE_CONFIG_DIR',
activeConfigDir: 'C:\\Users\\test\\.claude',
configDirSource: 'system',
},
appModeRequiresRestart: false,
})
@ -558,9 +508,10 @@ describe('settingsStore app mode', () => {
expect(useSettingsStore.getState().appMode).toMatchObject({
mode: 'portable',
portableDir: 'D:\\portable-data',
activeConfigDir: 'D:\\portable-data',
configDirSource: 'portable',
activeConfigDir: 'C:\\Users\\test\\.claude',
configDirSource: 'system',
})
expect(useSettingsStore.getState().appModeRequiresRestart).toBe(true)
})
it('rolls back and surfaces app mode persistence failures', async () => {
@ -572,8 +523,7 @@ describe('settingsStore app mode', () => {
const prevAppMode = {
mode: 'default' as const,
portableDir: null,
defaultPortableDir: 'C:\\cc-haha\\CLAUDE_CONFIG_DIR',
activeConfigDir: null,
activeConfigDir: 'C:\\Users\\test\\.claude',
configDirSource: 'system' as const,
}
useSettingsStore.setState({
@ -596,7 +546,6 @@ describe('settingsStore app mode', () => {
appMode: {
mode: 'portable',
portableDir: 'D:\\portable-data',
defaultPortableDir: 'C:\\cc-haha\\CLAUDE_CONFIG_DIR',
activeConfigDir: 'D:\\portable-data',
configDirSource: 'portable',
},
@ -612,9 +561,8 @@ describe('settingsStore app mode', () => {
expect(useSettingsStore.getState().appMode).toEqual({
mode: 'default',
portableDir: null,
defaultPortableDir: 'C:\\cc-haha\\CLAUDE_CONFIG_DIR',
activeConfigDir: null,
configDirSource: 'system',
activeConfigDir: 'D:\\portable-data',
configDirSource: 'portable',
})
expect(useSettingsStore.getState().appModeRequiresRestart).toBe(true)
})

View File

@ -208,7 +208,6 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
appMode: {
mode: 'default',
portableDir: null,
defaultPortableDir: null,
activeConfigDir: null,
configDirSource: 'system',
},
@ -583,16 +582,14 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
const host = getDesktopHost()
if (!host.isDesktop) return
const prev = get().appMode
const selectedCustomDir = mode === 'portable' ? portableDir?.trim() || null : null
if (mode === 'portable' && !selectedCustomDir) {
throw new Error('Choose an absolute custom data directory')
}
const newMode: AppModeConfig = {
...prev,
mode,
portableDir: mode === 'portable'
? portableDir ?? prev.defaultPortableDir ?? prev.portableDir
: null,
activeConfigDir: mode === 'portable'
? portableDir ?? prev.defaultPortableDir ?? prev.portableDir
: null,
configDirSource: mode === 'portable' ? 'portable' : 'system',
portableDir: selectedCustomDir,
}
set({ appMode: newMode, appModeRequiresRestart: true })
try {

View File

@ -136,7 +136,6 @@ export type AppMode = 'default' | 'portable'
export type AppModeConfig = {
mode: AppMode
portableDir: string | null
defaultPortableDir: string | null
activeConfigDir?: string | null
configDirSource?: 'system' | 'environment' | 'portable'
}

View File

@ -476,4 +476,70 @@ describe('release desktop workflow', () => {
expect(desktopPackage.build.nsis?.oneClick).toBe(false)
expect(desktopPackage.build.nsis?.allowToChangeInstallationDirectory).toBe(true)
})
test('Windows NSIS installer fails closed while recovering legacy install-directory data', () => {
const desktopPackage = JSON.parse(readFileSync('desktop/package.json', 'utf8')) as {
scripts?: Record<string, string>
build: {
nsis?: {
include?: string
}
}
}
expect(desktopPackage.build.nsis?.include).toBe('build/installer.nsh')
expect(desktopPackage.scripts?.['test:windows-storage-recovery']).toContain('-SelfTest')
const installerHook = readFileSync('desktop/build/installer.nsh', 'utf8')
const recoveryHelper = readFileSync('desktop/build/recover-legacy-install-data.ps1', 'utf8')
expect(installerHook).toContain('!macro customInit')
expect(installerHook).toContain('!macro customCheckAppRunning')
expect(installerHook).toContain('!macro customPageAfterChangeDir')
expect(installerHook).toContain('UAC_AsUser_Call Function CcHahaRecoverLegacy')
expect(installerHook).toContain('${UAC_IsInnerInstance}')
expect(installerHook).toContain('recover-legacy-install-data.ps1')
expect(installerHook).toContain('ReadRegStr $4 HKCU "${INSTALL_REGISTRY_KEY}" InstallLocation')
expect(installerHook).toContain('ReadRegStr $5 HKLM "${INSTALL_REGISTRY_KEY}" InstallLocation')
expect(installerHook).toContain('Function CcHahaUninstallerParent')
expect(installerHook).toContain('Function CcHahaFinalInstallDir')
expect(installerHook).toContain('HKCU "${UNINSTALL_REGISTRY_KEY}" UninstallString')
expect(installerHook).toContain('HKLM "${UNINSTALL_REGISTRY_KEY}" UninstallString')
expect(installerHook).toContain('UNINSTALL_REGISTRY_KEY_2')
expect(installerHook).toContain('ReadEnvStr $2 APPDATA')
expect(installerHook).toContain('ReadEnvStr $3 USERPROFILE')
expect(installerHook).toContain('ReadEnvStr $6 CLAUDE_CONFIG_DIR')
expect(installerHook).toContain('ReadEnvStr $7 CC_HAHA_APP_PORTABLE_DIR')
expect(installerHook).toContain('SetErrorLevel 20')
expect(installerHook).toContain('Quit')
expect(recoveryHelper).toContain('function Get-LegacyActiveSource')
expect(recoveryHelper).toContain('function Get-PotentialInstallDirs')
expect(recoveryHelper).toContain('function Assert-NoUndiscoveredLegacySources')
expect(recoveryHelper).toContain('function Assert-NoRunningApplication')
expect(recoveryHelper).toContain('function Get-TreeManifest')
expect(recoveryHelper).toContain('function Assert-TreeManifestsEqual')
expect(recoveryHelper).toContain('function Write-AppModeAtomically')
expect(recoveryHelper).toContain('[IO.File]::Replace')
expect(recoveryHelper).toContain('GetFinalPathNameByHandle')
expect(recoveryHelper).toContain('robocopy.exe')
expect(recoveryHelper).not.toMatch(/\/XC|\/XN|\/XO/)
expect(recoveryHelper).toContain('Multiple distinct legacy data sources')
expect(recoveryHelper).toContain('Active CLAUDE_CONFIG_DIR is managed outside Claude Code Haha')
expect(recoveryHelper).toContain('Test-LexicalPathAtOrBelow')
expect(recoveryHelper).toContain('untrusted-elevated')
expect(recoveryHelper).toContain('External CLAUDE_CONFIG_DIR is active while install-contained legacy data still exists')
expect(recoveryHelper).toMatch(/Assert-NoRunningApplication[\s\S]*Get-UnsafeLegacySource/)
expect(recoveryHelper).toMatch(/Assert-TreeManifestsEqual[\s\S]*Assert-NoRunningApplication[\s\S]*Write-AppModeAtomically/)
expect(recoveryHelper).toContain('reparse point')
expect(recoveryHelper).toContain('Run-SelfTest')
})
test('Windows build and release jobs execute the real legacy recovery self-test', () => {
const devWorkflow = readFileSync('.github/workflows/build-desktop-dev.yml', 'utf8')
const releaseWorkflow = readFileSync('.github/workflows/release-desktop.yml', 'utf8')
for (const workflow of [devWorkflow, releaseWorkflow]) {
expect(workflow).toContain("if: matrix.smoke_platform == 'windows'")
expect(workflow).toContain('bun run test:windows-storage-recovery')
}
})
})