diff --git a/adapters/feishu/__tests__/feishu.test.ts b/adapters/feishu/__tests__/feishu.test.ts index cd13e582..14377639 100644 --- a/adapters/feishu/__tests__/feishu.test.ts +++ b/adapters/feishu/__tests__/feishu.test.ts @@ -5,7 +5,7 @@ */ import { describe, it, expect } from 'bun:test' -import * as path from 'node:path' +import { isOutsideWorkDir } from '../path-safety.js' // ---------- helpers extracted from feishu/index.ts for testability ---------- @@ -209,14 +209,6 @@ function summarizeToolCall(toolName: string, input: unknown): ToolCallSummary { } } -function isOutsideWorkDir(filePath: string, workDir: string): boolean { - const abs = path.isAbsolute(filePath) - ? path.normalize(filePath) - : path.resolve(workDir, filePath) - const normWork = path.normalize(workDir).replace(/\/+$/, '') - return abs !== normWork && !abs.startsWith(normWork + path.sep) -} - function truncateTarget(s: string, maxLen = 160): string { if (s.length <= maxLen) return s return s.slice(0, maxLen - 1) + '…' diff --git a/adapters/feishu/index.ts b/adapters/feishu/index.ts index 9fa535e9..bea9e226 100644 --- a/adapters/feishu/index.ts +++ b/adapters/feishu/index.ts @@ -37,6 +37,7 @@ import { AttachmentStore } from '../common/attachment/attachment-store.js' import { checkAttachmentLimit } from '../common/attachment/attachment-limits.js' import { ImageBlockWatcher } from '../common/attachment/image-block-watcher.js' import type { PendingUpload } from '../common/attachment/attachment-types.js' +import { isOutsideWorkDir } from './path-safety.js' // ---------- init ---------- @@ -502,16 +503,6 @@ function summarizeToolCall(toolName: string, input: unknown): ToolCallSummary { } } -/** True if `filePath` resolves to a location outside of `workDir`. - * Relative paths are resolved against workDir first. */ -function isOutsideWorkDir(filePath: string, workDir: string): boolean { - const abs = path.isAbsolute(filePath) - ? path.normalize(filePath) - : path.resolve(workDir, filePath) - const normWork = path.normalize(workDir).replace(/\/+$/, '') - return abs !== normWork && !abs.startsWith(normWork + path.sep) -} - /** Truncate a single-line target preview (e.g. shell command) to maxLen. */ function truncateTarget(s: string, maxLen = 160): string { if (s.length <= maxLen) return s diff --git a/adapters/feishu/path-safety.ts b/adapters/feishu/path-safety.ts new file mode 100644 index 00000000..4b722755 --- /dev/null +++ b/adapters/feishu/path-safety.ts @@ -0,0 +1,23 @@ +import * as path from 'node:path' + +/** True if `filePath` resolves to a location outside of `workDir`. + * Relative paths are resolved against workDir first. */ +export function isOutsideWorkDir(filePath: string, workDir: string): boolean { + const pathApi = usesWindowsPath(filePath) || usesWindowsPath(workDir) ? path.win32 : path.posix + const abs = pathApi.isAbsolute(filePath) + ? pathApi.normalize(filePath) + : pathApi.resolve(workDir, filePath) + const normWork = stripTrailingSeparators(pathApi.normalize(workDir), pathApi) + const relative = pathApi.relative(normWork, abs) + return relative !== '' && (relative.startsWith('..') || pathApi.isAbsolute(relative)) +} + +function usesWindowsPath(value: string): boolean { + return /^[a-zA-Z]:[\\/]/.test(value) || value.startsWith('\\\\') +} + +function stripTrailingSeparators(value: string, pathApi: typeof path.posix | typeof path.win32): string { + const root = pathApi.parse(value).root + if (value === root) return value + return value.replace(/[\\/]+$/, '') +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 060ad61c..ff1102f0 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -224,10 +224,143 @@ const MAIN_WINDOW_LABEL: &str = "main"; const TRAY_SHOW_ID: &str = "tray_show"; const TRAY_QUIT_ID: &str = "tray_quit"; const WINDOW_STATE_FILE: &str = "window-state.json"; +const TERMINAL_CONFIG_FILE: &str = "terminal-config.json"; +const APP_MODE_FILE: &str = "app-mode.json"; const MIN_WINDOW_WIDTH: u32 = 960; const MIN_WINDOW_HEIGHT: u32 = 640; const MIN_VISIBLE_PIXELS: i64 = 64; +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +enum AppMode { + Default, + Portable, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct AppModeConfig { + #[serde(default = "default_app_mode")] + mode: AppMode, + #[serde(default)] + portable_dir: Option, +} + +fn default_app_mode() -> AppMode { + AppMode::Default +} + +impl Default for AppModeConfig { + fn default() -> Self { + Self { + mode: AppMode::Default, + portable_dir: None, + } + } +} + +/// Write the persisted app-mode.json to the given config directory. +fn write_app_mode_config(config_dir: &Path, config: &AppModeConfig) { + let path = config_dir.join(APP_MODE_FILE); + if let Some(parent) = path.parent() { + if let Err(e) = fs::create_dir_all(parent) { + eprintln!("[desktop] failed to create dir for app-mode.json: {e}"); + return; + } + } + let data = match serde_json::to_string_pretty(config) { + Ok(data) => data, + Err(e) => { + eprintln!("[desktop] failed to serialize app-mode.json: {e}"); + return; + } + }; + if let Err(e) = fs::write(&path, data) { + eprintln!("[desktop] failed to write app-mode.json: {e}"); + } +} + +/// Check if a directory contains portable config/data files. +fn dir_has_portable_data(dir: &Path) -> bool { + if !dir.is_dir() { + return false; + } + [WINDOW_STATE_FILE, TERMINAL_CONFIG_FILE] + .iter() + .any(|f| dir.join(f).is_file()) + || dir.join("Cache").is_dir() + || dir.join("EBWebView").is_dir() +} + +/// Resolve the default portable config directory: exe_dir/CLAUDE_CONFIG_DIR. +fn get_default_portable_dir() -> Option { + let exe = std::env::current_exe().ok()?; + let mut dir = exe.parent()?.to_path_buf(); + dir.push("CLAUDE_CONFIG_DIR"); + Some(dir) +} + + +#[derive(Serialize, Deserialize)] +struct TerminalConfig { + #[serde(default)] + bash_path: Option, +} + +impl TerminalConfig { + fn load(app: &AppHandle) -> Self { + let path = match terminal_config_path(app) { + Some(p) => p, + None => return Self::default(), + }; + fs::read_to_string(&path) + .ok() + .and_then(|data| serde_json::from_str(&data).ok()) + .unwrap_or_default() + } + + fn save(&self, app: &AppHandle) -> Result<(), String> { + let Some(path) = terminal_config_path(app) else { + return Err("terminal config path is unavailable".to_string()); + }; + if let Some(parent) = path.parent() { + if let Err(err) = fs::create_dir_all(parent) { + return Err(format!("create terminal config directory: {err}")); + } + } + let data = match serde_json::to_string_pretty(self) { + Ok(data) => data, + Err(err) => { + return Err(format!("serialize terminal config: {err}")); + } + }; + if let Err(err) = fs::write(&path, data) { + return Err(format!("write terminal config: {err}")); + } + Ok(()) + } +} + +fn terminal_config_path(app: &AppHandle) -> Option { + // honour CLAUDE_CONFIG_DIR for portable installs + std::env::var("CLAUDE_CONFIG_DIR").ok().map(|dir| { + PathBuf::from(&dir).join(TERMINAL_CONFIG_FILE) + }).or_else(|| { + match app.path().app_config_dir() { + Ok(dir) => Some(dir.join(TERMINAL_CONFIG_FILE)), + Err(err) => { + eprintln!("[desktop] failed to resolve app config dir: {err}"); + None + } + } + }) +} + +impl Default for TerminalConfig { + fn default() -> Self { + Self { bash_path: None } + } +} + #[derive(Default)] struct ServerState(Mutex); @@ -372,6 +505,75 @@ fn cancel_update_install(app: AppHandle) -> Result<(), String> { Ok(()) } +/// Returns the current app mode and portable directory info. +#[tauri::command] +fn get_app_mode() -> serde_json::Value { + let config_dir = if let Ok(cd) = std::env::var("CLAUDE_CONFIG_DIR") { + Some(PathBuf::from(&cd)) + } else { + get_default_portable_dir() + }; + + serde_json::json!({ + "mode": if std::env::var("CLAUDE_CONFIG_DIR").is_ok() { "portable" } else { "default" }, + "portableDir": config_dir.as_ref().and_then(|p| p.to_str()), + "defaultPortableDir": get_default_portable_dir().as_ref().and_then(|p| p.to_str()), + }) +} + +/// Sets the app mode. Persists to app-mode.json in the current active config dir. +/// Requires restart to take effect. +#[tauri::command] +fn set_app_mode(app: tauri::AppHandle, mode: String, portable_dir: Option) { + use tauri::Manager; // 确保作用域内引入 Manager + + // 确定当前正在使用的配置目录 + let active_config_dir = if let Ok(cd) = std::env::var("CLAUDE_CONFIG_DIR") { + std::path::PathBuf::from(&cd) + } else { + match app.path().app_config_dir() { + Ok(d) => d, + Err(e) => { + eprintln!("[desktop] set_app_mode: failed to resolve config dir: {e}"); + return; + } + } + }; + + let app_mode = if mode == "portable" { + AppMode::Portable + } else { + AppMode::Default + }; + + let config = AppModeConfig { + mode: app_mode, + portable_dir, + }; + + // 写入当前活跃的配置目录 + write_app_mode_config(&active_config_dir, &config); + + // 修复:同时始终将模式状态写入系统默认配置目录, + // 以防止应用层切换模式后,main.rs在下一次启动时读取到旧的系统全局状态 + if let Ok(sys_dir) = app.path().app_config_dir() { + if sys_dir != active_config_dir { + write_app_mode_config(&sys_dir, &config); + } + } +} + +/// Checks if the default portable directory has existing data files. +#[tauri::command] +fn detect_portable_dir() -> serde_json::Value { + let default_portable = get_default_portable_dir(); + let has_data = default_portable.as_ref().map(|d| dir_has_portable_data(d)).unwrap_or(false); + serde_json::json!({ + "defaultPortableDir": default_portable.as_ref().and_then(|p| p.to_str()), + "hasData": has_data, + }) +} + fn set_app_quitting(app: &AppHandle, next: bool) { if let Some(state) = app.try_state::() { if let Ok(mut is_quitting) = state.is_quitting.lock() { @@ -441,13 +643,24 @@ fn is_window_state_visible_on_any_monitor( } fn window_state_path(app: &AppHandle) -> Option { - match app.path().app_config_dir() { - Ok(dir) => Some(dir.join(WINDOW_STATE_FILE)), - Err(err) => { - eprintln!("[desktop] failed to resolve app config dir: {err}"); - None + // honour CLAUDE_CONFIG_DIR so portable installs keep window-state.json + // and terminal-config.json alongside the config dir instead of + // %APPDATA%\com.claude-code-haha.desktop\. + resolve_portable_state_path().or_else(|| { + match app.path().app_config_dir() { + Ok(dir) => Some(dir.join(WINDOW_STATE_FILE)), + Err(err) => { + eprintln!("[desktop] failed to resolve app config dir: {err}"); + None + } } - } + }) +} + +fn resolve_portable_state_path() -> Option { + std::env::var("CLAUDE_CONFIG_DIR").ok().map(|dir| { + PathBuf::from(&dir).join(WINDOW_STATE_FILE) + }) } fn read_stored_window_state(app: &AppHandle) -> Option { @@ -631,7 +844,7 @@ fn terminal_spawn( cwd: Option, ) -> Result { let cwd_path = resolve_terminal_cwd(cwd)?; - let shell = resolved_terminal_shell()?; + let shell = resolved_terminal_shell(&app)?; let pty_system = native_pty_system(); let pair = pty_system .openpty(PtySize { @@ -831,6 +1044,19 @@ fn terminal_kill(state: State<'_, TerminalState>, session_id: u32) -> Result<(), Ok(()) } +#[tauri::command] +fn get_terminal_bash_path(app: AppHandle) -> Option { + let config = TerminalConfig::load(&app); + config.bash_path +} + +#[tauri::command] +fn set_terminal_bash_path(app: AppHandle, path: Option) -> Result<(), String> { + let mut config = TerminalConfig::load(&app); + config.bash_path = normalize_terminal_bash_path(path)?; + config.save(&app) +} + #[tauri::command] async fn macos_notification_permission_state() -> Result { run_notification_bridge(macos_notifications::permission_state).await @@ -1069,8 +1295,9 @@ fn read_desktop_terminal_config() -> Option { settings.desktop_terminal } -fn resolved_terminal_shell() -> Result { - let system_default = default_shell(); +fn resolved_terminal_shell(app: &AppHandle) -> Result { + let terminal_config = TerminalConfig::load(app); + let system_default = default_shell(terminal_config.bash_path.as_deref()); let platform = current_terminal_host_platform(); let configured = read_desktop_terminal_config(); let override_shell = @@ -1124,7 +1351,31 @@ fn resolve_desktop_terminal_shell( } } -fn default_shell() -> String { +fn normalize_terminal_bash_path(path: Option) -> Result, String> { + let Some(path) = path else { + return Ok(None); + }; + let trimmed = path.trim(); + if trimmed.is_empty() { + return Ok(None); + } + let bash_path = PathBuf::from(trimmed); + if !bash_path.is_file() { + return Err(format!("terminal bash path does not exist: {trimmed}")); + } + Ok(Some(trimmed.to_string())) +} + +fn default_shell(_custom_bash: Option<&str>) -> String { + // On Windows, use configured bash path if set and valid + #[cfg(target_os = "windows")] + if let Some(bash_path) = _custom_bash { + let trimmed = bash_path.trim(); + if !trimmed.is_empty() && PathBuf::from(trimmed).is_file() { + return trimmed.to_string(); + } + } + #[cfg(target_os = "windows")] { std::env::var("COMSPEC").unwrap_or_else(|_| "powershell.exe".to_string()) @@ -1258,12 +1509,28 @@ fn start_server_sidecar(app: &AppHandle) -> Result { .shell() .sidecar("claude-sidecar") .map_err(|err| format!("resolve sidecar: {err}"))?; - for (key, value) in terminal_environment(&default_shell()) { + for (key, value) in terminal_environment(&default_shell(None)) { sidecar = sidecar.env(key, value); } - sidecar = sidecar - .env("CLAUDE_H5_AUTO_PUBLIC_URL", "1") - .env("CLAUDE_H5_DIST_DIR", h5_dist_dir); + // Pass through CLAUDE_CONFIG_DIR so the sidecar (Node.js) uses the same + // portable config directory. Also set XDG_CACHE_HOME to redirect the + // env-paths cache from %LOCALAPPDATA%\claude-cli-nodejs\ to alongside + // the portable config dir. + if let Ok(config_dir) = std::env::var("CLAUDE_CONFIG_DIR") { + let cache_dir = PathBuf::from(&config_dir).join("Cache"); + if let Err(e) = fs::create_dir_all(&cache_dir) { + eprintln!("[desktop] failed to create Cache dir: {e}"); + } + sidecar = sidecar + .env("CLAUDE_CONFIG_DIR", &config_dir) + .env("XDG_CACHE_HOME", cache_dir.to_string_lossy().to_string()) + .env("CLAUDE_H5_AUTO_PUBLIC_URL", "1") + .env("CLAUDE_H5_DIST_DIR", h5_dist_dir); + } else { + sidecar = sidecar + .env("CLAUDE_H5_AUTO_PUBLIC_URL", "1") + .env("CLAUDE_H5_DIST_DIR", h5_dist_dir); + } let sidecar = sidecar.args([ "server", "--app-root", @@ -1378,10 +1645,18 @@ fn start_adapters_sidecars(app: &AppHandle) -> Result, String> .shell() .sidecar("claude-sidecar") .map_err(|err| format!("resolve {label} adapter sidecar: {err}"))?; - for (key, value) in terminal_environment(&default_shell()) { + for (key, value) in terminal_environment(&default_shell(None)) { sidecar = sidecar.env(key, value); } - let sidecar = sidecar.env("ADAPTER_SERVER_URL", &server_ws_url).args([ + // Pass through CLAUDE_CONFIG_DIR for portable installs + let mut sidecar_final = sidecar.env("ADAPTER_SERVER_URL", &server_ws_url); + if let Ok(config_dir) = std::env::var("CLAUDE_CONFIG_DIR") { + let cache_dir = PathBuf::from(&config_dir).join("Cache"); + sidecar_final = sidecar_final + .env("CLAUDE_CONFIG_DIR", &config_dir) + .env("XDG_CACHE_HOME", cache_dir.to_string_lossy().to_string()); + } + let sidecar = sidecar_final.args([ "adapters", "--app-root", &app_root_arg, @@ -1507,8 +1782,8 @@ fn kill_windows_sidecars() { mod tests { use super::{ decode_terminal_output, default_utf8_locale, ensure_utf8_locale, - has_meaningful_intersection, is_persistable_window_state, parse_env_block, - resolve_desktop_terminal_shell, run_notification_bridge, select_h5_dist_dir, + has_meaningful_intersection, is_persistable_window_state, normalize_terminal_bash_path, + parse_env_block, resolve_desktop_terminal_shell, run_notification_bridge, select_h5_dist_dir, DesktopTerminalConfig, StoredWindowState, TerminalHostPlatform, SERVER_BIND_HOST, SERVER_CONTROL_HOST, }; @@ -1610,6 +1885,48 @@ mod tests { assert_eq!(env.get("EMPTY").map(String::as_str), Some("")); } + #[test] + fn terminal_bash_path_normalizer_clears_blank_values() { + assert_eq!( + normalize_terminal_bash_path(Some(" ".to_string())).expect("blank path clears"), + None + ); + assert_eq!( + normalize_terminal_bash_path(None).expect("missing path clears"), + None + ); + } + + #[test] + fn terminal_bash_path_normalizer_rejects_missing_files() { + let missing = std::env::temp_dir().join(format!( + "cchh-missing-bash-{}", + std::process::id() + )); + + let error = normalize_terminal_bash_path(Some(missing.to_string_lossy().to_string())) + .expect_err("missing path should be rejected"); + + assert!(error.contains("terminal bash path does not exist")); + } + + #[test] + fn terminal_bash_path_normalizer_accepts_existing_files() { + let path = std::env::temp_dir().join(format!( + "cchh-bash-path-test-{}", + std::process::id() + )); + fs::write(&path, "").expect("write bash path fixture"); + + assert_eq!( + normalize_terminal_bash_path(Some(format!(" {} ", path.display()))) + .expect("existing file is accepted"), + Some(path.to_string_lossy().to_string()) + ); + + fs::remove_file(path).expect("remove bash path fixture"); + } + #[test] fn terminal_environment_forces_utf8_locale_when_shell_uses_c_locale() { let mut env = HashMap::from([ @@ -1758,10 +2075,15 @@ pub fn run() { terminal_write, terminal_resize, terminal_kill, + get_terminal_bash_path, + set_terminal_bash_path, macos_notification_permission_state, macos_request_notification_permission, macos_send_notification, open_windows_notification_settings, + get_app_mode, + set_app_mode, + detect_portable_dir, set_app_zoom ]); diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index 26593521..35aeb8d6 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -1,6 +1,108 @@ // Prevents additional console window on Windows in release #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] +use std::fs; +use std::path::PathBuf; + fn main() { + // Determine if we should start in portable mode and set CLAUDE_CONFIG_DIR + // before any Tauri/WebView2 initialization. + // + // Mode resolution order: + // 1. External CLAUDE_CONFIG_DIR env var (batch script etc.) — always respected + // 2. Persisted app-mode.json saying "portable" + // 3. Auto-detect: default portable dir already has data files + // + // In "default" mode the app does NOT set CLAUDE_CONFIG_DIR itself. + // It relies on the env var (if set externally) or falls back to system dirs. + // All existing std::env::var("CLAUDE_CONFIG_DIR") checks in lib.rs handle this. + + if let Some(portable_dir) = determine_startup_portable_dir() { + std::env::set_var("CLAUDE_CONFIG_DIR", portable_dir.to_string_lossy().to_string()); + } + + // If CLAUDE_CONFIG_DIR is set (either from env or from our startup logic above), + // redirect WebView2 user data folder so EBWebView cache lives alongside it. + if let Ok(config_dir) = std::env::var("CLAUDE_CONFIG_DIR") { + let webview_data = PathBuf::from(&config_dir).join("EBWebView"); + if let Err(e) = fs::create_dir_all(&webview_data) { + eprintln!("[desktop] failed to create EBWebView dir: {e}"); + } + std::env::set_var("WEBVIEW2_USER_DATA_FOLDER", &webview_data); + } + claude_code_desktop_lib::run() } + +/// Determine if we should start in portable mode. +/// Returns the portable config directory path if yes, None for default mode. +fn determine_startup_portable_dir() -> Option { + // 1. 如果外部已经设置了 CLAUDE_CONFIG_DIR 环境变量,我们不应该覆盖它,直接返回 None 让 main 保持原样 + if std::env::var("CLAUDE_CONFIG_DIR").is_ok() { + return None; + } + + let exe = std::env::current_exe().ok()?; + let exe_dir = exe.parent()?; + let mut default_portable = exe_dir.to_path_buf(); + default_portable.push("CLAUDE_CONFIG_DIR"); + + // 辅助函数:读取 app-mode.json 获取模式和自定义便携路径 + fn get_mode_from_config(dir: &std::path::Path) -> Option<(String, Option)> { + let path = dir.join("app-mode.json"); + let data = std::fs::read_to_string(&path).ok()?; + let parsed: serde_json::Value = serde_json::from_str(&data).ok()?; + let mode = parsed.get("mode").and_then(|m| m.as_str()).unwrap_or("default").to_string(); + let portable_dir = parsed.get("portable_dir").and_then(|v| v.as_str()).map(PathBuf::from); + Some((mode, portable_dir)) + } + + // 2. 优先检查便携目录本地的配置文件(保证移动便携版到新电脑依然生效,并能正确处理切回默认模式) + if let Some((mode, portable_dir)) = get_mode_from_config(&default_portable) { + if mode == "portable" { + return Some(portable_dir.unwrap_or(default_portable.clone())); + } else { + return None; // 明确设置了 default,直接使用系统默认 + } + } + + // 3. 检查系统全局配置 + #[cfg(target_os = "windows")] + let system_config: Option = std::env::var("APPDATA").ok().map(PathBuf::from); + #[cfg(target_os = "macos")] + let system_config: Option = std::env::var("HOME").ok().map(|h| PathBuf::from(h).join("Library").join("Application Support")); + #[cfg(target_os = "linux")] + let system_config: Option = std::env::var("XDG_CONFIG_HOME") + .ok().map(PathBuf::from) + .or_else(|| std::env::var("HOME").ok().map(|h| PathBuf::from(h).join(".config"))); + + if let Some(ref sys_cfg) = system_config { + // 修复:必须使用 Tauri 默认的 bundle identifier + let app_subdir = sys_cfg.join("com.claude-code-haha.desktop"); + if let Some((mode, portable_dir)) = get_mode_from_config(&app_subdir) { + if mode == "portable" { + return Some(portable_dir.unwrap_or(default_portable.clone())); + } else { + return None; // 明确设置了 default + } + } + } + + // 4. 自动检测:如果默认便携目录中已经存在数据文件,则自动进入便携模式 + fn dir_has_portable_data(dir: &std::path::Path) -> bool { + if !dir.is_dir() { + return false; + } + ["window-state.json", "terminal-config.json"] + .iter() + .any(|f| dir.join(f).is_file()) + || dir.join("Cache").is_dir() + || dir.join("EBWebView").is_dir() + } + + if dir_has_portable_data(&default_portable) { + return Some(default_portable); + } + + None +} diff --git a/desktop/src/api/terminal.ts b/desktop/src/api/terminal.ts index 0daf87ee..857f96b0 100644 --- a/desktop/src/api/terminal.ts +++ b/desktop/src/api/terminal.ts @@ -55,4 +55,12 @@ export const terminalApi = { const events = await import('@tauri-apps/api/event') return events.listen('terminal-exit', (event) => handler(event.payload)) }, + + getBashPath() { + return invoke('get_terminal_bash_path', undefined) + }, + + setBashPath(path: string | null) { + return invoke('set_terminal_bash_path', { path }) + }, } diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index 3c44a663..76ff2c90 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -206,6 +206,12 @@ export const en = { 'settings.terminal.status.exited': 'Exited', 'settings.terminal.status.error': 'Error', 'settings.terminal.status.unavailable': 'Unavailable', + 'settings.terminal.bashPathLabel': 'Bash Path', + 'settings.terminal.bashPathDescription': 'Windows defaults to CMD as the terminal shell. If tool calls use Unix commands (grep, sed, etc.), configure the path to a Bash executable here (e.g., Git Bash).', + 'settings.terminal.bashPathSave': 'Save', + 'settings.terminal.bashPathReset': 'Reset to default', + 'settings.terminal.bashPathSaved': 'Saved', + 'settings.terminal.bashPathInvalid': 'Path does not exist. Select a valid Bash executable.', 'terminal.newTab': 'New Terminal', 'terminal.openInTab': 'Open in Tab', 'terminal.closePanel': 'Close terminal panel', @@ -763,6 +769,19 @@ export const en = { 'settings.computerUse.flagClipboard': 'Clipboard Access', 'settings.computerUse.flagSystemKeys': 'System Key Combos', + // Settings > General - Mode + 'settings.general.modeTitle': 'Mode', + 'settings.general.modeDescription': 'Choose where app data is stored. Default mode respects the CLAUDE_CONFIG_DIR env var or uses system directories. Portable mode stores data in a configurable directory.', + 'settings.general.modeDefault': 'Default', + 'settings.general.modePortable': 'Portable', + 'settings.general.modeDefaultHint': 'Uses CLAUDE_CONFIG_DIR env var if set, otherwise system directory (%APPDATA% on Windows).', + 'settings.general.modePortableDir': 'Portable config dir', + 'settings.general.modeSwitchTitle': 'Switch Mode?', + 'settings.general.modeSwitchBody': 'Switch to {mode}? This requires a restart to take effect.', + 'settings.general.modeSwitchConfirm': 'Switch and Restart Later', + 'settings.general.modeRestartTitle': 'Restart Required', + 'settings.general.modeRestartHint': 'Restart the app for the mode change to take effect.', + // Settings > General 'settings.general.appearanceTitle': 'Appearance', 'settings.general.appearanceDescription': 'Switch between the warm classic workspace, dark workspace, and a pure white workspace.', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index 5e91d1ef..f2ad0c1b 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -208,6 +208,12 @@ export const zh: Record = { 'settings.terminal.status.exited': '已退出', 'settings.terminal.status.error': '错误', 'settings.terminal.status.unavailable': '不可用', + 'settings.terminal.bashPathLabel': 'Bash 路径', + 'settings.terminal.bashPathDescription': 'Windows 下默认使用 CMD 作为终端。如果工具调用 grep、sed 等 Unix 命令,请在此配置 Bash 可执行文件路径(如 Git Bash)。', + 'settings.terminal.bashPathSave': '保存', + 'settings.terminal.bashPathReset': '恢复默认', + 'settings.terminal.bashPathSaved': '已保存', + 'settings.terminal.bashPathInvalid': '路径不存在,请选择有效的 Bash 可执行文件', 'terminal.newTab': '新建终端', 'terminal.openInTab': '在 Tab 中打开', 'terminal.closePanel': '关闭终端面板', @@ -765,6 +771,20 @@ export const zh: Record = { 'settings.computerUse.flagClipboard': '剪贴板访问', 'settings.computerUse.flagSystemKeys': '系统快捷键', + // Settings > General + // Settings > General - Mode + 'settings.general.modeTitle': '运行模式', + 'settings.general.modeDescription': '选择应用程序数据的存储位置。默认模式下,如果设置了 CLAUDE_CONFIG_DIR 环境变量则使用该路径,否则使用系统目录。便携模式将数据存储在自定义目录中。', + 'settings.general.modeDefault': '默认模式', + 'settings.general.modePortable': '便携模式', + 'settings.general.modeDefaultHint': 'CLAUDE_CONFIG_DIR 环境变量指定的路径,或系统目录(Windows 上为 %APPDATA%)。', + 'settings.general.modePortableDir': '便携配置目录', + 'settings.general.modeSwitchTitle': '切换模式?', + 'settings.general.modeSwitchBody': '切换到{mode}?此更改需要重启后生效。', + 'settings.general.modeSwitchConfirm': '切换,稍后重启', + 'settings.general.modeRestartTitle': '需要重启', + 'settings.general.modeRestartHint': '重启应用使模式切换生效。下次启动时将更新配置目录。', + // Settings > General 'settings.general.appearanceTitle': '配色主题', 'settings.general.appearanceDescription': '在经典暖色、暗色与纯白工作区之间切换。', diff --git a/desktop/src/pages/Settings.tsx b/desktop/src/pages/Settings.tsx index 7eae08cb..1bdbbef6 100644 --- a/desktop/src/pages/Settings.tsx +++ b/desktop/src/pages/Settings.tsx @@ -9,7 +9,7 @@ import { ConfirmDialog } from '../components/shared/ConfirmDialog' import { Input } from '../components/shared/Input' import { Button } from '../components/shared/Button' import { Dropdown } from '../components/shared/Dropdown' -import type { PermissionMode, EffortLevel, ThemeMode, UpdateProxyMode, WebSearchMode } from '../types/settings' +import type { PermissionMode, EffortLevel, ThemeMode, UpdateProxyMode, WebSearchMode, AppMode } from '../types/settings' import type { Locale } from '../i18n' import type { SavedProvider, UpdateProviderInput, ProviderTestResult, ModelMapping, ApiFormat, ProviderAuthStrategy } from '../types/provider' import type { ProviderPreset } from '../types/providerPreset' @@ -1385,6 +1385,10 @@ function GeneralSettings() { setWebSearch, responseLanguage, setResponseLanguage, + appMode, + appModeRequiresRestart, + fetchAppMode, + setAppMode: setAppModeAction, uiZoom, setUiZoom, } = useSettingsStore() @@ -1392,6 +1396,8 @@ function GeneralSettings() { const [webSearchDraft, setWebSearchDraft] = useState(webSearch) const [notificationPermission, setNotificationPermission] = useState('default') const [notificationActionRunning, setNotificationActionRunning] = useState(false) + const [modeSwitchConfirmOpen, setModeSwitchConfirmOpen] = useState(false) + const [pendingMode, setPendingMode] = useState(null) const [uiZoomDraft, setUiZoomDraft] = useState(uiZoom) const [isUiZoomDragging, setIsUiZoomDragging] = useState(false) const isUiZoomDraggingRef = useRef(false) @@ -1419,6 +1425,11 @@ function GeneralSettings() { } }, []) + useEffect(() => { + if (!isTauriRuntime()) return + void fetchAppMode() + }, [fetchAppMode]) + const EFFORT_LABELS: Record = { low: t('settings.general.effort.low'), medium: t('settings.general.effort.medium'), @@ -1639,6 +1650,79 @@ function GeneralSettings() { return (
+ {/* Mode Section */} + {isTauriRuntime() && ( +
+

{t('settings.general.modeTitle')}

+

{t('settings.general.modeDescription')}

+
+ + +
+ {appMode.mode === 'portable' && appMode.portableDir && ( +
+ {t('settings.general.modePortableDir')}: {appMode.portableDir} +
+ )} + {appMode.mode === 'default' && ( +
+ {t('settings.general.modeDefaultHint')} +
+ )} +
+ )} + + {/* Restart Required Banner */} + {appModeRequiresRestart && ( +
+ + warning + +
+
+ {t('settings.general.modeRestartTitle')} +
+
+ {t('settings.general.modeRestartHint')} +
+
+
+ )} + {/* Appearance selector */}

{t('settings.general.appearanceTitle')}

{t('settings.general.appearanceDescription')}

@@ -1906,6 +1990,22 @@ function GeneralSettings() {
+ {/* Confirm dialog for mode switch */} + { setModeSwitchConfirmOpen(false); setPendingMode(null); }} + onConfirm={() => { + if (pendingMode) { void setAppModeAction(pendingMode); } + setModeSwitchConfirmOpen(false); + setPendingMode(null); + }} + title={t('settings.general.modeSwitchTitle')} + body={t('settings.general.modeSwitchBody', { + mode: pendingMode === 'portable' ? t('settings.general.modePortable') : t('settings.general.modeDefault'), + })} + confirmLabel={t('settings.general.modeSwitchConfirm')} + cancelLabel={t('common.cancel')} + /> ) } diff --git a/desktop/src/pages/TerminalSettings.test.tsx b/desktop/src/pages/TerminalSettings.test.tsx index b674cbc7..1942d2fc 100644 --- a/desktop/src/pages/TerminalSettings.test.tsx +++ b/desktop/src/pages/TerminalSettings.test.tsx @@ -1,4 +1,4 @@ -import { act, render, screen, waitFor } from '@testing-library/react' +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import '@testing-library/jest-dom' import { beforeEach, describe, expect, it, vi } from 'vitest' import { useSettingsStore } from '../stores/settingsStore' @@ -28,6 +28,8 @@ const terminalMocks = vi.hoisted(() => { kill: vi.fn(), onOutput: vi.fn(), onExit: vi.fn(), + getBashPath: vi.fn(), + setBashPath: vi.fn(), } }) @@ -48,6 +50,8 @@ vi.mock('../api/terminal', () => ({ kill: terminalMocks.kill, onOutput: terminalMocks.onOutput, onExit: terminalMocks.onExit, + getBashPath: terminalMocks.getBashPath, + setBashPath: terminalMocks.setBashPath, }, })) @@ -55,6 +59,7 @@ import { TerminalSettings } from './TerminalSettings' describe('TerminalSettings', () => { beforeEach(() => { + vi.restoreAllMocks() useSettingsStore.setState({ locale: 'en' }) useSettingsStore.setState({ desktopTerminal: { @@ -70,6 +75,8 @@ describe('TerminalSettings', () => { terminalMocks.kill.mockReset() terminalMocks.onOutput.mockReset() terminalMocks.onExit.mockReset() + terminalMocks.getBashPath.mockReset() + terminalMocks.setBashPath.mockReset() terminalMocks.terminalInstance.loadAddon.mockClear() terminalMocks.terminalInstance.open.mockClear() terminalMocks.terminalInstance.dispose.mockClear() @@ -80,6 +87,8 @@ describe('TerminalSettings', () => { terminalMocks.fitInstance.fit.mockClear() terminalMocks.onOutput.mockResolvedValue(vi.fn()) terminalMocks.onExit.mockResolvedValue(vi.fn()) + terminalMocks.getBashPath.mockResolvedValue(null) + terminalMocks.setBashPath.mockResolvedValue(undefined) terminalMocks.write.mockResolvedValue(undefined) terminalMocks.resize.mockResolvedValue(undefined) terminalMocks.kill.mockResolvedValue(undefined) @@ -92,6 +101,7 @@ describe('TerminalSettings', () => { observe = vi.fn() disconnect = vi.fn() }) + vi.spyOn(navigator, 'platform', 'get').mockReturnValue('MacIntel') }) it('shows a desktop-runtime empty state outside Tauri', () => { @@ -162,4 +172,35 @@ describe('TerminalSettings', () => { expect(screen.getAllByText('Startup shell')).toHaveLength(2) expect(screen.getByText('Use for new terminal sessions and after restart.')).toBeInTheDocument() }) + + it('saves a custom Windows bash path from the terminal settings panel', async () => { + vi.spyOn(navigator, 'platform', 'get').mockReturnValue('Win32') + terminalMocks.available = true + terminalMocks.getBashPath.mockResolvedValue('C:\\Program Files\\Git\\bin\\bash.exe') + + render() + + const input = await screen.findByDisplayValue('C:\\Program Files\\Git\\bin\\bash.exe') + fireEvent.change(input, { target: { value: ' C:\\Tools\\Git\\bin\\bash.exe ' } }) + fireEvent.click(screen.getByRole('button', { name: 'Save' })) + + await waitFor(() => { + expect(terminalMocks.setBashPath).toHaveBeenCalledWith('C:\\Tools\\Git\\bin\\bash.exe') + }) + expect(await screen.findByRole('button', { name: 'Saved' })).toBeInTheDocument() + }) + + it('shows an invalid path message when native bash path validation fails', async () => { + vi.spyOn(navigator, 'platform', 'get').mockReturnValue('Win32') + terminalMocks.available = true + terminalMocks.setBashPath.mockRejectedValue(new Error('terminal bash path does not exist')) + + render() + + const input = await screen.findByPlaceholderText('Bash Path') + fireEvent.change(input, { target: { value: 'C:\\missing\\bash.exe' } }) + fireEvent.click(screen.getByRole('button', { name: 'Save' })) + + expect(await screen.findByText('Path does not exist. Select a valid Bash executable.')).toBeInTheDocument() + }) }) diff --git a/desktop/src/pages/TerminalSettings.tsx b/desktop/src/pages/TerminalSettings.tsx index 82309c44..6fab568f 100644 --- a/desktop/src/pages/TerminalSettings.tsx +++ b/desktop/src/pages/TerminalSettings.tsx @@ -374,77 +374,80 @@ export function TerminalSettings({ )} {showPreferences && isWindows && ( -
-
-
-

- {t('settings.terminal.preferencesTitle')} -

-

- {t('settings.terminal.preferencesBody')} -

-
+ <> +
+
+
+

+ {t('settings.terminal.preferencesTitle')} +

+

+ {t('settings.terminal.preferencesBody')} +

+
-
- - {t('settings.terminal.startupShell')} - - - items={shellItems} - value={startupShell} - onChange={(value) => { - setStartupShell(value) - setPreferencesError(null) - setPreferencesSaved(false) - }} - width="100%" - trigger={ - - } - /> -
- - {startupShell === 'custom' && ( - { - setCustomShellPath(event.target.value) - setPreferencesError(null) - setPreferencesSaved(false) - }} - error={preferencesError ?? undefined} - /> - )} - - {preferencesError && startupShell !== 'custom' && ( -

{preferencesError}

- )} - -
- - {preferencesSaved && ( - - {t('settings.terminal.saveShellSuccess')} +
+ + {t('settings.terminal.startupShell')} + + items={shellItems} + value={startupShell} + onChange={(value) => { + setStartupShell(value) + setPreferencesError(null) + setPreferencesSaved(false) + }} + width="100%" + trigger={ + + } + /> +
+ + {startupShell === 'custom' && ( + { + setCustomShellPath(event.target.value) + setPreferencesError(null) + setPreferencesSaved(false) + }} + error={preferencesError ?? undefined} + /> )} + + {preferencesError && startupShell !== 'custom' && ( +

{preferencesError}

+ )} + +
+ + {preferencesSaved && ( + + {t('settings.terminal.saveShellSuccess')} + + )} +
-
+ + )} {status === 'unavailable' ? ( @@ -499,3 +502,120 @@ function StatusPill({ status, label }: { status: TerminalStatus; label: string } ) } + +function BashPathSettings({ isTauri }: { isTauri: boolean }) { + const t = useTranslation() + const [bashPath, setBashPath] = useState(null) + const [saving, setSaving] = useState(false) + const [saved, setSaved] = useState(false) + const [invalid, setInvalid] = useState(false) + + useEffect(() => { + if (!isTauri) return + void terminalApi.getBashPath().then((path) => setBashPath(path)).catch(() => {}) + }, [isTauri]) + + const handleSave = async () => { + const trimmed = bashPath?.trim() || null + setSaving(true) + setInvalid(false) + setSaved(false) + try { + await terminalApi.setBashPath(trimmed) + setBashPath(trimmed) + setSaved(true) + setTimeout(() => setSaved(false), 2000) + } catch { + setInvalid(true) + } finally { + setSaving(false) + } + } + + const handleReset = async () => { + setSaving(true) + setSaved(false) + setInvalid(false) + try { + await terminalApi.setBashPath(null) + setBashPath(null) + setSaved(true) + setTimeout(() => setSaved(false), 2000) + } catch { + // ignore + } finally { + setSaving(false) + } + } + + const handleBrowse = async () => { + if (!isTauri) return + try { + const { open } = await import('@tauri-apps/plugin-dialog') + const selected = await open({ + title: t('settings.terminal.bashPathLabel'), + multiple: false, + filters: [{ + name: 'Bash Executable', + extensions: ['exe', '', 'bat', 'cmd', 'ps1'], + }], + }) + if (selected && typeof selected === 'string') { + setBashPath(selected) + setInvalid(false) + } + } catch { + // user cancelled + } + } + + if (!isTauri) return null + + return ( +
+ +

+ {t('settings.terminal.bashPathDescription')} +

+
+ { setBashPath(e.target.value); setInvalid(false); setSaved(false) }} + placeholder={t('settings.terminal.bashPathLabel')} + className="flex-1 rounded-[var(--radius-sm)] border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-1.5 text-sm font-mono text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-tertiary)] focus:border-[var(--color-border-focus)]" + /> + + + +
+ {invalid && ( +

+ {t('settings.terminal.bashPathInvalid')} +

+ )} +
+ ) +} diff --git a/desktop/src/stores/settingsStore.test.ts b/desktop/src/stores/settingsStore.test.ts index aae26472..5fc9ccbf 100644 --- a/desktop/src/stores/settingsStore.test.ts +++ b/desktop/src/stores/settingsStore.test.ts @@ -166,6 +166,66 @@ describe('settingsStore update proxy persistence', () => { }) }) +describe('settingsStore app mode', () => { + beforeEach(() => { + vi.resetModules() + vi.clearAllMocks() + delete (window as unknown as { __TAURI_INTERNALS__?: object }).__TAURI_INTERNALS__ + }) + + it('hydrates app mode from the native desktop command', async () => { + const invoke = vi.fn().mockResolvedValue({ + mode: 'portable', + portableDir: 'C:\\cc-haha\\CLAUDE_CONFIG_DIR', + defaultPortableDir: 'C:\\cc-haha\\CLAUDE_CONFIG_DIR', + }) + vi.doMock('@tauri-apps/api/core', () => ({ invoke })) + const tauriWindow = window as unknown as { __TAURI_INTERNALS__?: object } + tauriWindow.__TAURI_INTERNALS__ = {} + + const { useSettingsStore } = await import('./settingsStore') + + await useSettingsStore.getState().fetchAppMode() + + expect(invoke).toHaveBeenCalledWith('get_app_mode') + expect(useSettingsStore.getState().appMode).toEqual({ + mode: 'portable', + portableDir: 'C:\\cc-haha\\CLAUDE_CONFIG_DIR', + defaultPortableDir: 'C:\\cc-haha\\CLAUDE_CONFIG_DIR', + }) + }) + + it('persists app mode through the native desktop command and marks restart required', async () => { + const invoke = vi.fn().mockResolvedValue(undefined) + vi.doMock('@tauri-apps/api/core', () => ({ invoke })) + const tauriWindow = window as unknown as { __TAURI_INTERNALS__?: object } + tauriWindow.__TAURI_INTERNALS__ = {} + + 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(invoke).toHaveBeenCalledWith('set_app_mode', { + 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', + }) + expect(useSettingsStore.getState().appModeRequiresRestart).toBe(true) + }) +}) + describe('settingsStore desktop notification persistence', () => { beforeEach(() => { vi.resetModules() diff --git a/desktop/src/stores/settingsStore.ts b/desktop/src/stores/settingsStore.ts index f2a24a2a..8ada2b8a 100644 --- a/desktop/src/stores/settingsStore.ts +++ b/desktop/src/stores/settingsStore.ts @@ -5,6 +5,8 @@ import { modelsApi } from '../api/models' import { h5AccessApi } from '../api/h5Access' import { isThemeMode, + type AppMode, + type AppModeConfig, type DesktopTerminalSettings, type DesktopTerminalStartupShell, type H5AccessSettings, @@ -16,6 +18,7 @@ import { type UpdateProxySettings, type WebSearchSettings, } from '../types/settings' +import { isTauriRuntime } from '../lib/desktopRuntime' import type { Locale } from '../i18n' import { APP_ZOOM_CONTROL_STEP, @@ -64,6 +67,9 @@ type SettingsStore = { isLoading: boolean error: string | null + appMode: AppModeConfig + appModeRequiresRestart: boolean + fetchAll: () => Promise fetchH5Access: () => Promise setPermissionMode: (mode: PermissionMode) => Promise @@ -85,6 +91,8 @@ type SettingsStore = { publicBaseUrl?: string | null }) => Promise setResponseLanguage: (language: string) => Promise + fetchAppMode: () => Promise + setAppMode: (mode: AppMode, portableDir?: string | null) => Promise setUiZoom: (zoom: number) => void } @@ -126,6 +134,8 @@ export const useSettingsStore = create((set, get) => ({ isLoading: false, error: null, + appMode: { mode: 'default', portableDir: null, defaultPortableDir: null }, + appModeRequiresRestart: false, setUiZoom: (zoom: number) => { const level = normalizeAppZoomLevel(zoom) set({ uiZoom: level }) @@ -364,6 +374,37 @@ export const useSettingsStore = create((set, get) => ({ set({ responseLanguage: prev }) } }, + + fetchAppMode: async () => { + if (!isTauriRuntime()) return + try { + const { invoke } = await import('@tauri-apps/api/core') + const result: AppModeConfig = await invoke('get_app_mode') + set({ appMode: result }) + } catch { /* silently ignore - not in Tauri or command unavailable */ } + }, + + setAppMode: async (mode, portableDir) => { + if (!isTauriRuntime()) return + const prev = get().appMode + const newMode: AppModeConfig = { + ...prev, + mode, + portableDir: mode === 'portable' + ? portableDir ?? prev.defaultPortableDir ?? prev.portableDir + : null, + } + set({ appMode: newMode, appModeRequiresRestart: true }) + try { + const { invoke } = await import('@tauri-apps/api/core') + await invoke('set_app_mode', { + mode, + portableDir: newMode.portableDir || null, + }) + } catch { + set({ appMode: prev, appModeRequiresRestart: false }) + } + }, })) function normalizeWebSearchSettings(settings: WebSearchSettings | undefined): WebSearchSettings { diff --git a/desktop/src/theme/globals.test.ts b/desktop/src/theme/globals.test.ts index 6584d978..218ab394 100644 --- a/desktop/src/theme/globals.test.ts +++ b/desktop/src/theme/globals.test.ts @@ -2,19 +2,21 @@ import { describe, expect, it } from 'vitest' import css from './globals.css?raw' +const normalizedCss = css.replace(/\r\n/g, '\n') + function getThemeBlock(selector: ':root,\n[data-theme="light"]' | '[data-theme="white"]' | '[data-theme="dark"]') { - const start = css.indexOf(`${selector} {`) + const start = normalizedCss.indexOf(`${selector} {`) expect(start).toBeGreaterThanOrEqual(0) - const bodyStart = css.indexOf('{', start) + const bodyStart = normalizedCss.indexOf('{', start) let depth = 0 - for (let index = bodyStart; index < css.length; index += 1) { - const char = css[index] + for (let index = bodyStart; index < normalizedCss.length; index += 1) { + const char = normalizedCss[index] if (char === '{') depth += 1 if (char === '}') { depth -= 1 if (depth === 0) { - return css.slice(bodyStart + 1, index) + return normalizedCss.slice(bodyStart + 1, index) } } } @@ -23,11 +25,11 @@ function getThemeBlock(selector: ':root,\n[data-theme="light"]' | '[data-theme=" } function getCssBetween(startMarker: string, endMarker: string) { - const start = css.indexOf(startMarker) + const start = normalizedCss.indexOf(startMarker) expect(start).toBeGreaterThanOrEqual(0) - const end = css.indexOf(endMarker, start) + const end = normalizedCss.indexOf(endMarker, start) expect(end).toBeGreaterThan(start) - return css.slice(start, end) + return normalizedCss.slice(start, end) } describe('desktop theme tokens', () => { diff --git a/desktop/src/types/settings.ts b/desktop/src/types/settings.ts index 24c5e213..721af985 100644 --- a/desktop/src/types/settings.ts +++ b/desktop/src/types/settings.ts @@ -66,3 +66,11 @@ export type UserSettings = { desktopTerminal?: Partial [key: string]: unknown } + +export type AppMode = 'default' | 'portable' + +export type AppModeConfig = { + mode: AppMode + portableDir: string | null + defaultPortableDir: string | null +} diff --git a/scripts/git-hooks/install.test.ts b/scripts/git-hooks/install.test.ts index 8941d315..e15c193d 100644 --- a/scripts/git-hooks/install.test.ts +++ b/scripts/git-hooks/install.test.ts @@ -35,6 +35,7 @@ describe('installPrePushHook', () => { expect(result.hookPath).toBe(hookPath) expect(result.liveConfigured).toBe(false) expect(readFileSync(hookPath, 'utf8')).toContain('echo quality') + if (process.platform === 'win32') return expect(statSync(hookPath).mode & 0o111).toBeGreaterThan(0) } finally { rmSync(tempDir, { recursive: true, force: true }) diff --git a/src/server/__tests__/adapters.test.ts b/src/server/__tests__/adapters.test.ts index fd3c7d11..7cdf3ea6 100644 --- a/src/server/__tests__/adapters.test.ts +++ b/src/server/__tests__/adapters.test.ts @@ -67,6 +67,10 @@ describe('Adapters API', () => { const configPath = path.join(tmpDir, 'adapters.json') const stat = await fs.stat(configPath) + if (process.platform === 'win32') { + expect(stat.isFile()).toBe(true) + return + } expect(stat.mode & 0o777).toBe(0o600) }) diff --git a/src/server/__tests__/conversations.test.ts b/src/server/__tests__/conversations.test.ts index db685408..11251e8d 100644 --- a/src/server/__tests__/conversations.test.ts +++ b/src/server/__tests__/conversations.test.ts @@ -763,9 +763,9 @@ describe('WebSocket Chat Integration', () => { }) afterAll(async () => { - server?.stop() + server?.stop(true) if (tmpDir) { - await fs.rm(tmpDir, { recursive: true, force: true }) + await rmWithRetry(tmpDir) } if (originalCliPath) { process.env.CLAUDE_CLI_PATH = originalCliPath @@ -906,6 +906,7 @@ describe('WebSocket Chat Integration', () => { workDir: worktreePath!, repository: launchInfo!.repository, }) + await sessionService.deletePlaceholderSessionFiles(sessionId, worktreePath!) const messages = await runTurn(sessionId, 'Continue in the existing worktree') const statusVerbs = messages diff --git a/src/server/__tests__/sessions.test.ts b/src/server/__tests__/sessions.test.ts index c269aef3..e7bf617c 100644 --- a/src/server/__tests__/sessions.test.ts +++ b/src/server/__tests__/sessions.test.ts @@ -1240,6 +1240,21 @@ describe('SessionService', () => { expect(context.branches.some((branch) => branch.name.startsWith('worktree-desktop-'))).toBe(false) }) + it('should keep stale worktree records when their paths cannot be resolved', async () => { + const workDir = await createCleanGitRepo(tmpDir) + const staleWorktreeName = `stale-worktree-${Date.now()}` + const staleWorktree = path.join(tmpDir, staleWorktreeName) + git(workDir, 'worktree', 'add', '-b', 'stale-worktree', staleWorktree, 'feature/rail') + await fs.rm(staleWorktree, { recursive: true, force: true }) + + const context = await getRepositoryContext(workDir) + const expectedPath = path.join(await fs.realpath(tmpDir), staleWorktreeName).normalize('NFC') + expect(context.state).toBe('ok') + expect(context.worktrees.some((worktree) => ( + worktree.path === expectedPath && worktree.branch === 'stale-worktree' && !worktree.current + ))).toBe(true) + }) + it('should let git carry compatible dirty changes during direct branch launch', async () => { const workDir = await createCleanGitRepo(tmpDir) await fs.writeFile(path.join(workDir, 'README.md'), 'main\nlocal-pricing-edit\n') @@ -1529,6 +1544,26 @@ describe('SessionService', () => { expect(launchInfo!.transcriptMessageCount).toBe(2) expect(launchInfo!.customTitle).toBe('Saved chat') }) + + it('should recover Windows drive paths from sanitized project dirs for old transcripts without metadata', async () => { + const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-ffffffffffff' + const userUuid = crypto.randomUUID() + const userEntry = makeUserEntry('Resume this Windows session', userUuid) + delete userEntry.cwd + await writeSessionFile('g--AI-NTos-NT-deepseek-nano-core', sessionId, [ + makeSnapshotEntry(), + userEntry, + makeAssistantEntry('Welcome back', userUuid), + ]) + + const expectedWorkDir = 'g:\\AI\\NTos\\NT\\deepseek\\nano\\core' + expect(await service.getSessionWorkDir(sessionId)).toBe(expectedWorkDir) + + const launchInfo = await service.getSessionLaunchInfo(sessionId) + expect(launchInfo).not.toBeNull() + expect(launchInfo!.workDir).toBe(expectedWorkDir) + expect(launchInfo!.transcriptMessageCount).toBe(2) + }) }) // ============================================================================ @@ -1547,11 +1582,8 @@ describe('Sessions API', () => { const { handleSessionsApi } = await import('../api/sessions.js') const { handleConversationsApi } = await import('../api/conversations.js') - const port = 30000 + Math.floor(Math.random() * 10000) - baseUrl = `http://127.0.0.1:${port}` - server = Bun.serve({ - port, + port: 0, hostname: '127.0.0.1', async fetch(req) { @@ -1569,6 +1601,7 @@ describe('Sessions API', () => { return new Response('Not Found', { status: 404 }) }, }) + baseUrl = `http://127.0.0.1:${server.port}` }) afterEach(async () => { diff --git a/src/server/__tests__/teams.test.ts b/src/server/__tests__/teams.test.ts index d51ccfda..ca24335a 100644 --- a/src/server/__tests__/teams.test.ts +++ b/src/server/__tests__/teams.test.ts @@ -461,11 +461,8 @@ describe('Teams API', () => { const { handleTeamsApi } = await import('../api/teams.js') - const port = 40000 + Math.floor(Math.random() * 10000) - baseUrl = `http://127.0.0.1:${port}` - server = Bun.serve({ - port, + port: 0, hostname: '127.0.0.1', async fetch(req) { @@ -479,6 +476,7 @@ describe('Teams API', () => { return new Response('Not Found', { status: 404 }) }, }) + baseUrl = `http://127.0.0.1:${server.port}` }) afterEach(async () => { diff --git a/src/server/api/sessions.ts b/src/server/api/sessions.ts index 4472f55b..d431d1c0 100644 --- a/src/server/api/sessions.ts +++ b/src/server/api/sessions.ts @@ -778,9 +778,10 @@ const RECENT_PROJECTS_CACHE_TTL = 30_000 const DESKTOP_WORKTREE_MARKER = '/.claude/worktrees/' function projectNameForRecentPath(realPath: string, fallback: string): string { - const displayRoot = realPath.includes(DESKTOP_WORKTREE_MARKER) - ? realPath.slice(0, realPath.indexOf(DESKTOP_WORKTREE_MARKER)) - : realPath + const normalizedRealPath = realPath.replace(/\\/g, '/') + const displayRoot = normalizedRealPath.includes(DESKTOP_WORKTREE_MARKER) + ? normalizedRealPath.slice(0, normalizedRealPath.indexOf(DESKTOP_WORKTREE_MARKER)) + : normalizedRealPath return displayRoot.split('/').filter(Boolean).pop() || fallback } diff --git a/src/server/services/repositoryLaunchService.ts b/src/server/services/repositoryLaunchService.ts index 4d059da9..7b7825bf 100644 --- a/src/server/services/repositoryLaunchService.ts +++ b/src/server/services/repositoryLaunchService.ts @@ -189,6 +189,19 @@ async function resolveDirectory(workDir: string): Promise { return realPath } +async function canonicalizeKnownPath(candidate: string): Promise { + try { + return (await fs.realpath(candidate)).normalize('NFC') + } catch { + return path.resolve(candidate).normalize('NFC') + } +} + +function isSameOrInsidePath(root: string, candidate: string): boolean { + const relative = path.relative(root, candidate) + return relative === '' || (!!relative && !relative.startsWith('..') && !path.isAbsolute(relative)) +} + function normalizeRemoteBranch(ref: string): { name: string; remoteRef: string } | null { if (!ref || ref.endsWith('/HEAD')) return null const slash = ref.indexOf('/') @@ -353,11 +366,17 @@ export async function getRepositoryContext(workDir: string): Promise ({ + ...worktree, + path: await canonicalizeKnownPath(worktree.path), + })), + ) const worktrees = worktreeRecords.map((worktree) => ({ path: worktree.path, branch: worktree.branch, - current: absWorkDir === worktree.path || absWorkDir.startsWith(`${worktree.path}${path.sep}`), + current: isSameOrInsidePath(worktree.path, absWorkDir), })) return { diff --git a/src/server/services/sessionService.ts b/src/server/services/sessionService.ts index 0e651d75..b4db4fcd 100644 --- a/src/server/services/sessionService.ts +++ b/src/server/services/sessionService.ts @@ -950,10 +950,16 @@ export class SessionService { * Reverses sanitizePath(): `-Users-nanmi-workspace` → `/Users/nanmi/workspace`. */ desanitizePath(sanitized: string): string { - // The sanitized form replaces all '/' (and '\') with '-'. + // The sanitized form replaces all non-alphanumeric characters with '-'. + // This fallback is necessarily lossy, but old Windows transcripts without + // session-meta still need the drive separator restored well enough to resume. + const windowsDrivePath = sanitized.match(/^([a-zA-Z])--(.+)$/) + if (windowsDrivePath) { + return `${windowsDrivePath[1]}:${path.win32.sep}${windowsDrivePath[2].replace(/-/g, path.win32.sep)}` + } + // On POSIX the original path starts with '/', so the sanitized form starts with '-'. - // We restore by replacing every '-' with '/' (the platform separator). - // On Windows the leading character would be a drive letter, but we handle POSIX here. + // UNC-style Windows paths also recover to a leading double separator on Windows. return sanitized.replace(/-/g, path.sep) } diff --git a/src/utils/__tests__/cachePaths.test.ts b/src/utils/__tests__/cachePaths.test.ts new file mode 100644 index 00000000..2bd7af79 --- /dev/null +++ b/src/utils/__tests__/cachePaths.test.ts @@ -0,0 +1,26 @@ +import { afterEach, describe, expect, test } from 'bun:test' +import { join } from 'path' +import { tmpdir } from 'os' +import { CACHE_PATHS } from '../cachePaths.js' + +const originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR + +afterEach(() => { + if (originalClaudeConfigDir === undefined) { + delete process.env.CLAUDE_CONFIG_DIR + } else { + process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir + } +}) + +describe('CACHE_PATHS portable mode', () => { + test('places logs under CLAUDE_CONFIG_DIR when portable mode is active', () => { + const configDir = join(tmpdir(), 'cc-haha-portable-cache') + process.env.CLAUDE_CONFIG_DIR = configDir + + expect(CACHE_PATHS.baseLogs().startsWith(join(configDir, 'Cache'))).toBe(true) + expect(CACHE_PATHS.errors().startsWith(join(configDir, 'Cache'))).toBe(true) + expect(CACHE_PATHS.messages().startsWith(join(configDir, 'Cache'))).toBe(true) + expect(CACHE_PATHS.mcpLogs('test:server').startsWith(join(configDir, 'Cache'))).toBe(true) + }) +}) diff --git a/src/utils/cachePaths.ts b/src/utils/cachePaths.ts index f66ed8d4..8162545f 100644 --- a/src/utils/cachePaths.ts +++ b/src/utils/cachePaths.ts @@ -3,7 +3,13 @@ import { join } from 'path' import { getFsImplementation } from './fsOperations.js' import { djb2Hash } from './hash.js' -const paths = envPaths('claude-cli') +// When CLAUDE_CONFIG_DIR is set (portable mode), place cache under it +// so the install is fully self-contained. Otherwise fall back to the +// system default (%LOCALAPPDATA%\claude-cli-nodejs\Cache on Windows). +function getCacheRoot() { + const claudeConfigDir = (process.env as Record).CLAUDE_CONFIG_DIR + return claudeConfigDir ? join(claudeConfigDir, 'Cache') : envPaths('claude-cli').cache +} // Local sanitizePath using djb2Hash — NOT the shared version from // sessionStoragePortable.ts which uses Bun.hash (wyhash) when available. @@ -23,14 +29,14 @@ function getProjectDir(cwd: string): string { } export const CACHE_PATHS = { - baseLogs: () => join(paths.cache, getProjectDir(getFsImplementation().cwd())), + baseLogs: () => join(getCacheRoot(), getProjectDir(getFsImplementation().cwd())), errors: () => - join(paths.cache, getProjectDir(getFsImplementation().cwd()), 'errors'), + join(getCacheRoot(), getProjectDir(getFsImplementation().cwd()), 'errors'), messages: () => - join(paths.cache, getProjectDir(getFsImplementation().cwd()), 'messages'), + join(getCacheRoot(), getProjectDir(getFsImplementation().cwd()), 'messages'), mcpLogs: (serverName: string) => join( - paths.cache, + getCacheRoot(), getProjectDir(getFsImplementation().cwd()), // Sanitize server name for Windows compatibility (colons are reserved for drive letters) `mcp-logs-${sanitizePath(serverName)}`,