diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 6d52f5c25..f7e94cbc6 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); @@ -353,6 +486,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() { @@ -422,13 +624,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 { @@ -612,7 +825,8 @@ fn terminal_spawn( cwd: Option, ) -> Result { let cwd_path = resolve_terminal_cwd(cwd)?; - let shell = default_shell(); + let terminal_config = TerminalConfig::load(&app); + let shell = default_shell(terminal_config.bash_path.as_deref()); let pty_system = native_pty_system(); let pair = pty_system .openpty(PtySize { @@ -812,6 +1026,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 @@ -1033,7 +1260,31 @@ fn home_dir() -> Option { .map(PathBuf::from) } -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()) @@ -1167,12 +1418,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", @@ -1287,10 +1554,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, @@ -1416,9 +1691,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, parse_env_block, - run_notification_bridge, select_h5_dist_dir, StoredWindowState, SERVER_BIND_HOST, - SERVER_CONTROL_HOST, + has_meaningful_intersection, is_persistable_window_state, normalize_terminal_bash_path, + parse_env_block, run_notification_bridge, select_h5_dist_dir, StoredWindowState, + SERVER_BIND_HOST, SERVER_CONTROL_HOST, }; use std::{collections::HashMap, fs}; @@ -1518,6 +1793,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([ @@ -1622,10 +1939,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 26593521c..35aeb8d67 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 0daf87ee1..857f96b0f 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 653397a02..2efad19d4 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -187,6 +187,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', @@ -744,6 +750,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 8c275bee0..b5ef91af3 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -189,6 +189,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': '关闭终端面板', @@ -746,6 +752,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 1b94b5c8a..a66bc8bf4 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, WebSearchMode } from '../types/settings' +import type { PermissionMode, EffortLevel, ThemeMode, 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 a2a68c319..37823817d 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' }) terminalMocks.available = false terminalMocks.spawn.mockReset() @@ -63,6 +68,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() @@ -73,6 +80,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) @@ -85,6 +94,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', () => { @@ -142,4 +152,35 @@ describe('TerminalSettings', () => { expect(terminalMocks.terminalInstance.write).toHaveBeenCalledWith('hello\r\n') expect(terminalMocks.terminalInstance.write).not.toHaveBeenCalledWith('ignored\r\n') }) + + 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 ff6230746..2a8f47d89 100644 --- a/desktop/src/pages/TerminalSettings.tsx +++ b/desktop/src/pages/TerminalSettings.tsx @@ -37,6 +37,7 @@ export function TerminalSettings({ docked = false, }: TerminalSettingsProps = {}) { const t = useTranslation() + const isWindows = typeof navigator !== 'undefined' && navigator.platform.toLowerCase().includes('win') const hostRef = useRef(null) const terminalRef = useRef(null) const fitRef = useRef(null) @@ -289,6 +290,10 @@ export function TerminalSettings({ )} + {isWindows && ( + + )} + {status === 'unavailable' ? (
@@ -341,3 +346,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 8c45f1725..c5aa71410 100644 --- a/desktop/src/stores/settingsStore.test.ts +++ b/desktop/src/stores/settingsStore.test.ts @@ -63,6 +63,66 @@ describe('settingsStore UI zoom', () => { }) }) +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 51e628ab6..a662ab8b7 100644 --- a/desktop/src/stores/settingsStore.ts +++ b/desktop/src/stores/settingsStore.ts @@ -3,7 +3,8 @@ import { ApiError } from '../api/client' import { settingsApi } from '../api/settings' import { modelsApi } from '../api/models' import { h5AccessApi } from '../api/h5Access' -import { isThemeMode, type H5AccessSettings, type PermissionMode, type EffortLevel, type ModelInfo, type ThemeMode, type WebSearchSettings } from '../types/settings' +import { isThemeMode, type H5AccessSettings, type PermissionMode, type EffortLevel, type ModelInfo, type ThemeMode, type WebSearchSettings, type AppMode, type AppModeConfig } from '../types/settings' +import { isTauriRuntime } from '../lib/desktopRuntime' import type { Locale } from '../i18n' import { APP_ZOOM_CONTROL_STEP, @@ -50,6 +51,9 @@ type SettingsStore = { isLoading: boolean error: string | null + appMode: AppModeConfig + appModeRequiresRestart: boolean + fetchAll: () => Promise fetchH5Access: () => Promise setPermissionMode: (mode: PermissionMode) => Promise @@ -69,6 +73,8 @@ type SettingsStore = { publicBaseUrl?: string | null }) => Promise setResponseLanguage: (language: string) => Promise + fetchAppMode: () => Promise + setAppMode: (mode: AppMode, portableDir?: string | null) => Promise setUiZoom: (zoom: number) => void } @@ -98,6 +104,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 }) @@ -310,6 +318,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/types/settings.ts b/desktop/src/types/settings.ts index d5ea3aa81..b7c66b011 100644 --- a/desktop/src/types/settings.ts +++ b/desktop/src/types/settings.ts @@ -45,3 +45,11 @@ export type UserSettings = { language?: string [key: string]: unknown } + +export type AppMode = 'default' | 'portable' + +export type AppModeConfig = { + mode: AppMode + portableDir: string | null + defaultPortableDir: string | null +} diff --git a/src/utils/__tests__/cachePaths.test.ts b/src/utils/__tests__/cachePaths.test.ts new file mode 100644 index 000000000..2bd7af790 --- /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 f66ed8d47..8162545f7 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)}`,