diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 9f00b3bc..0c83880d 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -286,11 +286,22 @@ fn dir_has_portable_data(dir: &Path) -> bool { if !dir.is_dir() { return false; } - [WINDOW_STATE_FILE, TERMINAL_CONFIG_FILE] + [ + "settings.json", + ".claude.json", + ".mcp.json", + WINDOW_STATE_FILE, + TERMINAL_CONFIG_FILE, + ] .iter() .any(|f| dir.join(f).is_file()) || dir.join("Cache").is_dir() || dir.join("EBWebView").is_dir() + || dir.join("projects").is_dir() + || dir.join("skills").is_dir() + || dir.join("plugins").is_dir() + || dir.join("cowork_plugins").is_dir() + || dir.join("cc-haha").is_dir() } /// Resolve the default portable config directory: exe_dir/CLAUDE_CONFIG_DIR. @@ -1321,9 +1332,13 @@ fn resolve_terminal_cwd(cwd: Option) -> Result { } }) { Some(path) => path, - None => home_dir().unwrap_or( - std::env::current_dir().map_err(|err| format!("resolve current directory: {err}"))?, - ), + None => std::env::var_os("CLAUDE_CONFIG_DIR") + .map(PathBuf::from) + .or_else(home_dir) + .unwrap_or( + std::env::current_dir() + .map_err(|err| format!("resolve current directory: {err}"))?, + ), }; if path.is_dir() { @@ -1838,8 +1853,9 @@ fn kill_windows_sidecars() { mod tests { use super::{ decode_terminal_output, default_utf8_locale, ensure_utf8_locale, - has_meaningful_intersection, is_persistable_window_state, normalize_terminal_bash_path, - parse_env_block, resolve_desktop_terminal_shell, run_notification_bridge, + dir_has_portable_data, has_meaningful_intersection, is_persistable_window_state, + normalize_terminal_bash_path, parse_env_block, resolve_desktop_terminal_shell, + resolve_terminal_cwd, run_notification_bridge, select_h5_dist_dir, DesktopTerminalConfig, StoredWindowState, TerminalHostPlatform, SERVER_BIND_HOST, SERVER_CONTROL_HOST, }; @@ -2017,6 +2033,42 @@ mod tests { assert_eq!(env.get("LC_ALL").map(String::as_str), Some("C.UTF-8")); } + #[test] + fn terminal_cwd_defaults_to_portable_config_dir_when_present() { + let original = std::env::var_os("CLAUDE_CONFIG_DIR"); + let dir = std::env::temp_dir().join(format!( + "cchh-terminal-portable-cwd-{}", + std::process::id() + )); + fs::create_dir_all(&dir).expect("create portable config dir"); + std::env::set_var("CLAUDE_CONFIG_DIR", &dir); + + let cwd = resolve_terminal_cwd(None).expect("portable cwd should resolve"); + + assert_eq!(cwd, dir); + + if let Some(value) = original { + std::env::set_var("CLAUDE_CONFIG_DIR", value); + } else { + std::env::remove_var("CLAUDE_CONFIG_DIR"); + } + fs::remove_dir_all(cwd).expect("remove portable config dir"); + } + + #[test] + fn portable_data_detection_includes_cli_state_dirs() { + let root = std::env::temp_dir().join(format!( + "cchh-portable-data-detect-{}", + std::process::id() + )); + let skills = root.join("skills"); + fs::create_dir_all(&skills).expect("create skills dir"); + + assert!(dir_has_portable_data(&root)); + + fs::remove_dir_all(root).expect("remove portable data fixture"); + } + #[test] fn desktop_terminal_shell_resolution_keeps_system_default_without_preference() { assert_eq!( diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index 3c15fea5..e8ec2568 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -71,6 +71,9 @@ fn determine_startup_portable_dir() -> Option { // 2. 优先检查便携目录本地的配置文件(保证移动便携版到新电脑依然生效,并能正确处理切回默认模式) if let Some((mode, portable_dir)) = get_mode_from_config(&default_portable) { if mode == "portable" { + if dir_has_portable_data(&default_portable) { + return Some(default_portable.clone()); + } return Some(portable_dir.unwrap_or(default_portable.clone())); } else { return None; // 明确设置了 default,直接使用系统默认 @@ -111,11 +114,22 @@ fn determine_startup_portable_dir() -> Option { if !dir.is_dir() { return false; } - ["window-state.json", "terminal-config.json"] + [ + "settings.json", + ".claude.json", + ".mcp.json", + "window-state.json", + "terminal-config.json", + ] .iter() .any(|f| dir.join(f).is_file()) || dir.join("Cache").is_dir() || dir.join("EBWebView").is_dir() + || dir.join("projects").is_dir() + || dir.join("skills").is_dir() + || dir.join("plugins").is_dir() + || dir.join("cowork_plugins").is_dir() + || dir.join("cc-haha").is_dir() } if dir_has_portable_data(&default_portable) { diff --git a/src/server/__tests__/desktop-cli-launcher.test.ts b/src/server/__tests__/desktop-cli-launcher.test.ts index 6d53fde7..bc5720dc 100644 --- a/src/server/__tests__/desktop-cli-launcher.test.ts +++ b/src/server/__tests__/desktop-cli-launcher.test.ts @@ -3,7 +3,11 @@ import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { tmpdir } from 'node:os' -import { ensureDesktopCliLauncherInstalled } from '../services/desktopCliLauncherService.js' +import { + buildWindowsLauncherWrapper, + ensureDesktopCliLauncherInstalled, + getDesktopCliCommandName, +} from '../services/desktopCliLauncherService.js' const isWindows = process.platform === 'win32' const unixOnly = isWindows ? it.skip : it @@ -13,6 +17,7 @@ const ORIGINAL_USERPROFILE = process.env.USERPROFILE const ORIGINAL_SHELL = process.env.SHELL const ORIGINAL_PATH = process.env.PATH const ORIGINAL_CLAUDE_CLI_PATH = process.env.CLAUDE_CLI_PATH +const ORIGINAL_CLAUDE_CONFIG_DIR = process.env.CLAUDE_CONFIG_DIR describe('ensureDesktopCliLauncherInstalled', () => { let tempHome = '' @@ -25,6 +30,7 @@ describe('ensureDesktopCliLauncherInstalled', () => { process.env.USERPROFILE = tempHome process.env.SHELL = '/bin/zsh' process.env.PATH = '' + delete process.env.CLAUDE_CONFIG_DIR }) afterEach(async () => { @@ -58,6 +64,12 @@ describe('ensureDesktopCliLauncherInstalled', () => { process.env.CLAUDE_CLI_PATH = ORIGINAL_CLAUDE_CLI_PATH } + if (ORIGINAL_CLAUDE_CONFIG_DIR === undefined) { + delete process.env.CLAUDE_CONFIG_DIR + } else { + process.env.CLAUDE_CONFIG_DIR = ORIGINAL_CLAUDE_CONFIG_DIR + } + await rm(tempHome, { recursive: true, force: true }) await rm(tempSourceDir, { recursive: true, force: true }) }) @@ -89,6 +101,32 @@ describe('ensureDesktopCliLauncherInstalled', () => { ) }) + unixOnly('pins portable config dir in the installed launcher wrapper', async () => { + const sourcePath = join(tempSourceDir, 'claude-sidecar') + const portableDir = join(tempHome, 'portable-config') + await writeFile(sourcePath, '#!/bin/sh\necho desktop-sidecar\n', 'utf8') + await chmod(sourcePath, 0o755) + process.env.CLAUDE_CLI_PATH = sourcePath + process.env.CLAUDE_CONFIG_DIR = portableDir + + await ensureDesktopCliLauncherInstalled() + + const launcher = await readFile(join(tempHome, '.local', 'bin', 'claude-haha'), 'utf8') + expect(launcher).toContain(`export CLAUDE_CONFIG_DIR='${portableDir}'`) + }) + + it('uses a Windows cmd launcher so portable env can be injected', () => { + expect(getDesktopCliCommandName('win32')).toBe('claude-haha.cmd') + + process.env.CLAUDE_CONFIG_DIR = 'C:\\Portable\\ClaudeConfig' + const wrapper = buildWindowsLauncherWrapper('C:\\Apps\\cc-haha\\claude-sidecar.exe') + + expect(wrapper).toContain('set "CLAUDE_CONFIG_DIR=C:\\Portable\\ClaudeConfig"') + expect(wrapper).toContain( + '"%SIDECAR%" cli --app-root "%APP_ROOT%" %*', + ) + }) + it('reports unsupported status when the current launcher is not a bundled sidecar', async () => { const sourcePath = join(tempSourceDir, 'claude') await writeFile(sourcePath, '#!/bin/sh\necho plain-cli\n', 'utf8') diff --git a/src/server/services/desktopCliLauncherService.ts b/src/server/services/desktopCliLauncherService.ts index e520d6d5..8e402b0d 100644 --- a/src/server/services/desktopCliLauncherService.ts +++ b/src/server/services/desktopCliLauncherService.ts @@ -1,8 +1,5 @@ -import { createHash } from 'node:crypto' -import { createReadStream } from 'node:fs' import { chmod, - copyFile, mkdir, readFile, rename, @@ -19,6 +16,7 @@ import { getShellConfigPaths } from '../../utils/shellConfig.js' import { getUserBinDir } from '../../utils/xdg.js' const DESKTOP_CLI_NAME = 'claude-haha' +const DESKTOP_CLI_WINDOWS_LEGACY_EXE = `${DESKTOP_CLI_NAME}.exe` const PATH_BLOCK_START = '# >>> Claude Code Haha PATH >>>' const PATH_BLOCK_END = '# <<< Claude Code Haha PATH <<<' const WINDOWS_PATH_TARGET = 'Windows User PATH' @@ -43,7 +41,7 @@ let inFlightEnsure: Promise | null = null export function getDesktopCliCommandName( platform: NodeJS.Platform = process.platform, ) { - return platform === 'win32' ? `${DESKTOP_CLI_NAME}.exe` : DESKTOP_CLI_NAME + return platform === 'win32' ? `${DESKTOP_CLI_NAME}.cmd` : DESKTOP_CLI_NAME } export function resolveHomeDir(env: NodeJS.ProcessEnv = process.env) { @@ -244,27 +242,8 @@ async function syncLauncher(sourcePath: string, targetPath: string) { return } - if (await filesMatch(sourcePath, targetPath)) { - return - } - - const tempPath = `${targetPath}.tmp.${process.pid}.${Date.now()}` - await copyFile(sourcePath, tempPath) - - if (process.platform !== 'win32') { - await chmod(tempPath, 0o755) - } - - try { - if (process.platform === 'win32') { - await replaceWindowsBinary(tempPath, targetPath) - } else { - await rename(tempPath, targetPath) - await chmod(targetPath, 0o755) - } - } finally { - await unlink(tempPath).catch(() => undefined) - } + await syncWindowsLauncherWrapper(sourcePath, targetPath) + await removeLegacyWindowsBinaryLauncher(targetPath) } async function syncUnixLauncherWrapper(sourcePath: string, targetPath: string) { @@ -290,12 +269,17 @@ async function syncUnixLauncherWrapper(sourcePath: string, targetPath: string) { function buildUnixLauncherWrapper(sourcePath: string) { const quotedSource = shellSingleQuote(sourcePath) const quotedAppRoot = shellSingleQuote(dirname(sourcePath)) + const portableConfigDir = process.env.CLAUDE_CONFIG_DIR + const configExport = portableConfigDir + ? `export CLAUDE_CONFIG_DIR=${shellSingleQuote(portableConfigDir)}\n` + : '' return `#!/usr/bin/env bash set -euo pipefail SIDECAR=${quotedSource} APP_ROOT=${quotedAppRoot} +${configExport} if [[ ! -x "$SIDECAR" ]]; then echo "claude-haha launcher could not find bundled sidecar: $SIDECAR" >&2 @@ -314,11 +298,52 @@ exec "$SIDECAR" cli --app-root "$APP_ROOT" "$@" ` } +async function syncWindowsLauncherWrapper(sourcePath: string, targetPath: string) { + const wrapper = buildWindowsLauncherWrapper(sourcePath) + + const existing = await readFile(targetPath, 'utf8').catch(() => null) + if (existing === wrapper) { + return + } + + const tempPath = `${targetPath}.tmp.${process.pid}.${Date.now()}` + await writeFile(tempPath, wrapper, { encoding: 'utf8' }) + + try { + await replaceFile(tempPath, targetPath) + } finally { + await unlink(tempPath).catch(() => undefined) + } +} + +export function buildWindowsLauncherWrapper(sourcePath: string) { + const appRoot = dirname(sourcePath) + const portableConfigDir = process.env.CLAUDE_CONFIG_DIR + const configLine = portableConfigDir + ? `set "CLAUDE_CONFIG_DIR=${portableConfigDir}"\r\n` + : '' + + return [ + '@echo off', + 'setlocal', + `set "SIDECAR=${sourcePath}"`, + `set "APP_ROOT=${appRoot}"`, + configLine.trimEnd(), + 'if not exist "%SIDECAR%" (', + ' echo claude-haha launcher could not find bundled sidecar: %SIDECAR% 1>&2', + ' exit /b 127', + ')', + '"%SIDECAR%" cli --app-root "%APP_ROOT%" %*', + 'exit /b %ERRORLEVEL%', + '', + ].filter((line) => line.length > 0).join('\r\n') +} + function shellSingleQuote(value: string) { return `'${value.replace(/'/g, `'\\''`)}'` } -async function replaceWindowsBinary(tempPath: string, targetPath: string) { +async function replaceFile(tempPath: string, targetPath: string) { try { await unlink(targetPath) } catch { @@ -343,39 +368,34 @@ async function replaceWindowsBinary(tempPath: string, targetPath: string) { await unlink(backupPath).catch(() => undefined) } -async function filesMatch(sourcePath: string, targetPath: string) { +async function removeLegacyWindowsBinaryLauncher(targetPath: string) { + const legacyPath = join(dirname(targetPath), DESKTOP_CLI_WINDOWS_LEGACY_EXE) + if (legacyPath === targetPath) return + try { - const [sourceStats, targetStats] = await Promise.all([ - stat(sourcePath), - stat(targetPath), - ]) - - if (!sourceStats.isFile() || !targetStats.isFile()) { - return false - } - - if (sourceStats.size !== targetStats.size) { - return false - } - - const [sourceHash, targetHash] = await Promise.all([ - hashFile(sourcePath), - hashFile(targetPath), - ]) - return sourceHash === targetHash + const legacyStats = await stat(legacyPath) + if (!legacyStats.isFile()) return } catch { - return false + return } -} -async function hashFile(filePath: string) { - return await new Promise((resolvePromise, reject) => { - const hash = createHash('sha256') - const stream = createReadStream(filePath) - stream.on('data', (chunk) => hash.update(chunk)) - stream.on('error', reject) - stream.on('end', () => resolvePromise(hash.digest('hex'))) - }) + try { + await unlink(legacyPath) + return + } catch { + // Retry below by renaming the stale executable out of PATHEXT lookup. + } + + const backupPath = `${legacyPath}.old.${Date.now()}` + try { + await rename(legacyPath, backupPath) + } catch (error) { + throw new Error( + `failed to remove legacy Windows launcher ${legacyPath}: ${ + error instanceof Error ? error.message : String(error) + }`, + ) + } } async function isUsableLauncher(filePath: string) { diff --git a/src/utils/plugins/installedPluginsManager.test.ts b/src/utils/plugins/installedPluginsManager.test.ts index 01735f88..6da40a33 100644 --- a/src/utils/plugins/installedPluginsManager.test.ts +++ b/src/utils/plugins/installedPluginsManager.test.ts @@ -2,7 +2,11 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test' import * as fs from 'node:fs/promises' import * as os from 'node:os' import * as path from 'node:path' -import { deletePluginCache } from './installedPluginsManager.js' +import { + clearInstalledPluginsCache, + deletePluginCache, + loadInstalledPluginsV2, +} from './installedPluginsManager.js' describe('deletePluginCache', () => { let tempDir: string @@ -29,6 +33,7 @@ describe('deletePluginCache', () => { process.env.CLAUDE_CODE_PLUGIN_CACHE_DIR = originalPluginCacheDir } await fs.rm(tempDir, { recursive: true, force: true }) + clearInstalledPluginsCache() }) test('refuses to delete paths outside the managed plugin cache', async () => { @@ -63,4 +68,62 @@ describe('deletePluginCache', () => { await expect(fs.stat(versionDir)).rejects.toThrow() await expect(fs.stat(path.join(tempDir, '.claude'))).resolves.toBeDefined() }) + + test('rebases installed plugin paths when a portable config directory moves', async () => { + const oldConfigDir = path.join(tempDir, 'old-config') + const newConfigDir = path.join(tempDir, 'new-config') + const pluginId = 'portable-proof-plugin@portable-proof-market' + const oldInstallPath = path.join( + oldConfigDir, + 'plugins', + 'cache', + 'portable-proof-market', + 'portable-proof-plugin', + '1.0.0', + ) + const newInstallPath = path.join( + newConfigDir, + 'plugins', + 'cache', + 'portable-proof-market', + 'portable-proof-plugin', + '1.0.0', + ) + const installedPluginsPath = path.join( + newConfigDir, + 'plugins', + 'installed_plugins.json', + ) + + await fs.mkdir(newInstallPath, { recursive: true }) + await fs.writeFile(path.join(newInstallPath, 'sentinel.txt'), 'ok', 'utf-8') + await fs.mkdir(path.dirname(installedPluginsPath), { recursive: true }) + await fs.writeFile( + installedPluginsPath, + JSON.stringify({ + version: 2, + plugins: { + [pluginId]: [ + { + scope: 'user', + installPath: oldInstallPath, + version: '1.0.0', + installedAt: '2026-05-24T00:00:00.000Z', + lastUpdated: '2026-05-24T00:00:00.000Z', + }, + ], + }, + }, null, 2), + 'utf-8', + ) + + process.env.CLAUDE_CONFIG_DIR = newConfigDir + clearInstalledPluginsCache() + + const loaded = loadInstalledPluginsV2() + + expect(loaded.plugins[pluginId]?.[0]?.installPath).toBe(newInstallPath) + const healed = JSON.parse(await fs.readFile(installedPluginsPath, 'utf-8')) + expect(healed.plugins[pluginId][0].installPath).toBe(newInstallPath) + }) }) diff --git a/src/utils/plugins/installedPluginsManager.ts b/src/utils/plugins/installedPluginsManager.ts index dbaff90e..bc8674e8 100644 --- a/src/utils/plugins/installedPluginsManager.ts +++ b/src/utils/plugins/installedPluginsManager.ts @@ -304,6 +304,35 @@ function migrateV1ToV2(v1Data: InstalledPluginsFileV1): InstalledPluginsFileV2 { return { version: 2, plugins: v2Plugins } } +function normalizePortableInstallPaths( + data: InstalledPluginsFileV2, +): { data: InstalledPluginsFileV2; changed: boolean } { + const fs = getFsImplementation() + let changed = false + const plugins: InstalledPluginsMapV2 = {} + + for (const [pluginId, entries] of Object.entries(data.plugins)) { + plugins[pluginId] = entries.map(entry => { + const expectedPath = getVersionedCachePath(pluginId, entry.version) + if (resolve(entry.installPath) === resolve(expectedPath)) { + return entry + } + + if (!fs.existsSync(expectedPath)) { + return entry + } + + changed = true + return { + ...entry, + installPath: expectedPath, + } + }) + } + + return changed ? { data: { ...data, plugins }, changed } : { data, changed } +} + /** * Load installed plugins in V2 format. * @@ -327,16 +356,23 @@ export function loadInstalledPluginsV2(): InstalledPluginsFileV2 { if (rawData.version === 2) { // V2 format - validate and return const validated = InstalledPluginsFileSchemaV2().parse(rawData.data) - installedPluginsCacheV2 = validated + const normalized = normalizePortableInstallPaths(validated) + if (normalized.changed) { + saveInstalledPluginsV2(normalized.data) + logForDebugging( + `Rebased installed plugin cache paths under ${getPluginsDirectory()}`, + ) + } + installedPluginsCacheV2 = normalized.data logForDebugging( - `Loaded ${Object.keys(validated.plugins).length} installed plugins from ${filePath}`, + `Loaded ${Object.keys(normalized.data.plugins).length} installed plugins from ${filePath}`, ) - return validated + return normalized.data } // V1 format - convert to V2 const v1Validated = InstalledPluginsFileSchemaV1().parse(rawData.data) - const v2Data = migrateV1ToV2(v1Validated) + const v2Data = normalizePortableInstallPaths(migrateV1ToV2(v1Validated)).data installedPluginsCacheV2 = v2Data logForDebugging( `Loaded and converted ${Object.keys(v1Validated.plugins).length} plugins from V1 format`, @@ -506,11 +542,13 @@ export function loadInstalledPluginsFromDisk(): InstalledPluginsFileV2 { if (rawData) { if (rawData.version === 2) { - return InstalledPluginsFileSchemaV2().parse(rawData.data) + return normalizePortableInstallPaths( + InstalledPluginsFileSchemaV2().parse(rawData.data), + ).data } // V1 format - convert to V2 const v1Data = InstalledPluginsFileSchemaV1().parse(rawData.data) - return migrateV1ToV2(v1Data) + return normalizePortableInstallPaths(migrateV1ToV2(v1Data)).data } return { version: 2, plugins: {} }