From d02cb9a7fd3b9d86370f520687d1733e0b87c849 Mon Sep 17 00:00:00 2001 From: pobb <79351885+1506086927@users.noreply.github.com> Date: Thu, 14 May 2026 14:59:22 +0800 Subject: [PATCH 01/18] Update terminal.ts --- desktop/src/api/terminal.ts | 8 ++++++++ 1 file changed, 8 insertions(+) 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 }) + }, } From 2a505917e6198c98eaa090a992880693b2ac2e8a Mon Sep 17 00:00:00 2001 From: pobb <79351885+1506086927@users.noreply.github.com> Date: Thu, 14 May 2026 15:01:27 +0800 Subject: [PATCH 02/18] Update lib.rs --- desktop/src-tauri/src/lib.rs | 82 ++++++++++++++++++++++++++++++++++-- 1 file changed, 78 insertions(+), 4 deletions(-) diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index e6b16def2..917ed7452 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -224,10 +224,59 @@ 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 MIN_WINDOW_WIDTH: u32 = 960; const MIN_WINDOW_HEIGHT: u32 = 640; const MIN_VISIBLE_PIXELS: i64 = 64; +#[derive(Serialize, Deserialize)] +struct TerminalConfig { + #[serde(default)] + bash_path: Option, +} + +impl TerminalConfig { + fn load(app: &AppHandle) -> Self { + let path = match app.path().app_config_dir() { + Ok(dir) => dir.join(TERMINAL_CONFIG_FILE), + Err(_) => 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) { + let path = match app.path().app_config_dir() { + Ok(dir) => dir.join(TERMINAL_CONFIG_FILE), + Err(_) => return, + }; + if let Some(parent) = path.parent() { + if let Err(err) = fs::create_dir_all(parent) { + eprintln!("[desktop] failed to create terminal config directory: {err}"); + return; + } + } + let data = match serde_json::to_string_pretty(self) { + Ok(data) => data, + Err(err) => { + eprintln!("[desktop] failed to serialize terminal config: {err}"); + return; + } + }; + if let Err(err) = fs::write(&path, data) { + eprintln!("[desktop] failed to write terminal config: {err}"); + } + } +} + +impl Default for TerminalConfig { + fn default() -> Self { + Self { bash_path: None } + } +} + #[derive(Default)] struct ServerState(Mutex); @@ -612,7 +661,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 +862,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) { + let mut config = TerminalConfig::load(&app); + config.bash_path = path; + config.save(&app); +} + #[tauri::command] async fn macos_notification_permission_state() -> Result { run_notification_bridge(macos_notifications::permission_state).await @@ -1025,7 +1088,16 @@ fn home_dir() -> Option { .map(PathBuf::from) } -fn default_shell() -> 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).exists() { + return trimmed.to_string(); + } + } + #[cfg(target_os = "windows")] { std::env::var("COMSPEC").unwrap_or_else(|_| "powershell.exe".to_string()) @@ -1159,7 +1231,7 @@ 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 @@ -1279,7 +1351,7 @@ 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([ @@ -1614,6 +1686,8 @@ 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, From e4a487dd4b13169af97d96eb90667a3e8329d4a2 Mon Sep 17 00:00:00 2001 From: pobb <79351885+1506086927@users.noreply.github.com> Date: Thu, 14 May 2026 15:01:46 +0800 Subject: [PATCH 03/18] Update TerminalSettings.tsx --- desktop/src/pages/TerminalSettings.tsx | 122 +++++++++++++++++++++++++ 1 file changed, 122 insertions(+) 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')} +

+ )} +
+ ) +} From ad367c4b882dc5e47817fcf9059096c8473808fd Mon Sep 17 00:00:00 2001 From: pobb <79351885+1506086927@users.noreply.github.com> Date: Thu, 14 May 2026 15:02:08 +0800 Subject: [PATCH 04/18] Update en.ts --- desktop/src/i18n/locales/en.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index e1a33b429..4120d2194 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -149,6 +149,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', From 743769f70f0282fbafd88943fc65ffe66333c70b Mon Sep 17 00:00:00 2001 From: pobb <79351885+1506086927@users.noreply.github.com> Date: Thu, 14 May 2026 15:02:23 +0800 Subject: [PATCH 05/18] Update zh.ts --- desktop/src/i18n/locales/zh.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index 7fc9a2442..5bd7313a5 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -151,6 +151,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': '关闭终端面板', From 40a76948acc2f7961d842b28443bde2ba039ba2f Mon Sep 17 00:00:00 2001 From: pobb <79351885+1506086927@users.noreply.github.com> Date: Thu, 14 May 2026 16:35:56 +0800 Subject: [PATCH 06/18] Update cachePaths.ts --- src/utils/cachePaths.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/utils/cachePaths.ts b/src/utils/cachePaths.ts index f66ed8d47..e221a9e71 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). +const claudConfigDir = (process.env as Record).CLAUDE_CONFIG_DIR +const paths: { cache: string } = claudConfigDir + ? { cache: join(claudConfigDir, 'Cache') } + : envPaths('claude-cli') // Local sanitizePath using djb2Hash — NOT the shared version from // sessionStoragePortable.ts which uses Bun.hash (wyhash) when available. From 9d51a16a9a16a0caa25cc598ce27e24104d52d32 Mon Sep 17 00:00:00 2001 From: pobb <79351885+1506086927@users.noreply.github.com> Date: Thu, 14 May 2026 16:36:52 +0800 Subject: [PATCH 07/18] Update main.rs --- desktop/src-tauri/src/main.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index 26593521c..f249ebec7 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -2,5 +2,16 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] fn main() { + // If CLAUDE_CONFIG_DIR is set (e.g. via portable launcher), also redirect + // WebView2 user data folder so EBWebView cache lives alongside it instead of + // under %LOCALAPPDATA%\com.claude-code-haha.desktop\. + if let Ok(config_dir) = std::env::var("CLAUDE_CONFIG_DIR") { + let webview_data = std::path::PathBuf::from(&config_dir).join("EBWebView"); + if let Err(e) = std::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() } From a6998725631bce24e7873462f9e8ff1480d2a304 Mon Sep 17 00:00:00 2001 From: pobb <79351885+1506086927@users.noreply.github.com> Date: Thu, 14 May 2026 16:37:31 +0800 Subject: [PATCH 08/18] Update lib.rs --- desktop/src-tauri/src/lib.rs | 81 ++++++++++++++++++++++++++++-------- 1 file changed, 64 insertions(+), 17 deletions(-) diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 917ed7452..5488b44c9 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -237,9 +237,9 @@ struct TerminalConfig { impl TerminalConfig { fn load(app: &AppHandle) -> Self { - let path = match app.path().app_config_dir() { - Ok(dir) => dir.join(TERMINAL_CONFIG_FILE), - Err(_) => return Self::default(), + let path = match terminal_config_path(app) { + Some(p) => p, + None => return Self::default(), }; fs::read_to_string(&path) .ok() @@ -248,10 +248,7 @@ impl TerminalConfig { } fn save(&self, app: &AppHandle) { - let path = match app.path().app_config_dir() { - Ok(dir) => dir.join(TERMINAL_CONFIG_FILE), - Err(_) => return, - }; + let Some(path) = terminal_config_path(app) else { return }; if let Some(parent) = path.parent() { if let Err(err) = fs::create_dir_all(parent) { eprintln!("[desktop] failed to create terminal config directory: {err}"); @@ -271,6 +268,21 @@ impl TerminalConfig { } } +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 } @@ -471,13 +483,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 { @@ -1234,9 +1257,25 @@ fn start_server_sidecar(app: &AppHandle) -> Result { 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", @@ -1354,7 +1393,15 @@ fn start_adapters_sidecars(app: &AppHandle) -> Result, String> 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, From 4aade1643fb3e0a09b7e8f221d07f9d3592776ad Mon Sep 17 00:00:00 2001 From: pobb <79351885+1506086927@users.noreply.github.com> Date: Fri, 15 May 2026 19:05:15 +0800 Subject: [PATCH 09/18] Update zh.ts --- desktop/src/i18n/locales/zh.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index 5bd7313a5..e4a7b18a5 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -683,6 +683,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': '在经典暖色、暗色与纯白工作区之间切换。', From 2623cc120a2bb5e7b1e2f0329e396e3650ec87d1 Mon Sep 17 00:00:00 2001 From: pobb <79351885+1506086927@users.noreply.github.com> Date: Fri, 15 May 2026 19:05:30 +0800 Subject: [PATCH 10/18] Update en.ts --- desktop/src/i18n/locales/en.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index 4120d2194..78678beba 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -681,6 +681,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.', From 93e55f8f7ec983794baa7013baeb6c9c2405e3a9 Mon Sep 17 00:00:00 2001 From: pobb <79351885+1506086927@users.noreply.github.com> Date: Fri, 15 May 2026 19:05:55 +0800 Subject: [PATCH 11/18] Update Settings.tsx --- desktop/src/pages/Settings.tsx | 102 ++++++++++++++++++++++++++++++++- 1 file changed, 101 insertions(+), 1 deletion(-) diff --git a/desktop/src/pages/Settings.tsx b/desktop/src/pages/Settings.tsx index 4ea0329d6..404f7a821 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' @@ -1382,11 +1382,17 @@ function GeneralSettings() { setWebSearch, responseLanguage, setResponseLanguage, + appMode, + appModeRequiresRestart, + fetchAppMode, + setAppMode: setAppModeAction, } = useSettingsStore() const t = useTranslation() 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 webSearchDirty = JSON.stringify(webSearchDraft) !== JSON.stringify(webSearch) useEffect(() => { @@ -1403,6 +1409,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'), @@ -1510,6 +1521,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')}

@@ -1776,6 +1860,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')} + />
) } From 415f00573e7a0e69a5dbef45f448d5fafe549114 Mon Sep 17 00:00:00 2001 From: pobb <79351885+1506086927@users.noreply.github.com> Date: Fri, 15 May 2026 19:06:18 +0800 Subject: [PATCH 12/18] Update settingsStore.ts --- desktop/src/stores/settingsStore.ts | 42 ++++++++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/desktop/src/stores/settingsStore.ts b/desktop/src/stores/settingsStore.ts index afdd4a9db..0a82e4452 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 { useUIStore } from './uiStore' @@ -36,6 +37,9 @@ type SettingsStore = { isLoading: boolean error: string | null + appMode: AppModeConfig + appModeRequiresRestart: boolean + fetchAll: () => Promise fetchH5Access: () => Promise setPermissionMode: (mode: PermissionMode) => Promise @@ -55,6 +59,8 @@ type SettingsStore = { publicBaseUrl?: string | null }) => Promise setResponseLanguage: (language: string) => Promise + fetchAppMode: () => Promise + setAppMode: (mode: AppMode, portableDir?: string | null) => Promise } const DEFAULT_H5_ACCESS_SETTINGS: H5AccessSettings = { @@ -82,6 +88,9 @@ export const useSettingsStore = create((set, get) => ({ isLoading: false, error: null, + appMode: { mode: 'default', portableDir: null, defaultPortableDir: null }, + appModeRequiresRestart: false, + fetchAll: async () => { set({ isLoading: true, error: null }) try { @@ -288,6 +297,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 { From f3b96553db85972686f46c5bfe5b43973ea13852 Mon Sep 17 00:00:00 2001 From: pobb <79351885+1506086927@users.noreply.github.com> Date: Fri, 15 May 2026 19:07:02 +0800 Subject: [PATCH 13/18] Update settings.ts --- desktop/src/types/settings.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/desktop/src/types/settings.ts b/desktop/src/types/settings.ts index 4a5b247a5..0aaf62f51 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 +} From 547927da4ad91d6b55b082505f2ab6f266703bce Mon Sep 17 00:00:00 2001 From: pobb <79351885+1506086927@users.noreply.github.com> Date: Fri, 15 May 2026 19:07:28 +0800 Subject: [PATCH 14/18] Update lib.rs --- desktop/src-tauri/src/lib.rs | 156 ++++++++++++++++++++++++++++++++++- 1 file changed, 155 insertions(+), 1 deletion(-) diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 5488b44c9..29fcf777c 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -225,10 +225,92 @@ 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, + } + } +} + +/// Read the persisted app-mode.json from the given config directory. +fn read_app_mode_config(config_dir: &Path) -> Option { + let path = config_dir.join(APP_MODE_FILE); + fs::read_to_string(&path).ok().and_then(|data| { + serde_json::from_str(&data).ok().or_else(|| { + eprintln!("[desktop] failed to parse app-mode.json: {}", data); + 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)] @@ -414,6 +496,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() { @@ -1738,7 +1889,10 @@ pub fn run() { macos_notification_permission_state, macos_request_notification_permission, macos_send_notification, - open_windows_notification_settings + open_windows_notification_settings, + get_app_mode, + set_app_mode, + detect_portable_dir, ]); // macOS: native menu bar (traffic-light overlay style) From 78192901cc8e2a2b461319a27b61fc253b4c0a18 Mon Sep 17 00:00:00 2001 From: pobb <79351885+1506086927@users.noreply.github.com> Date: Fri, 15 May 2026 19:07:47 +0800 Subject: [PATCH 15/18] Update main.rs --- desktop/src-tauri/src/main.rs | 101 ++++++++++++++++++++++++++++++++-- 1 file changed, 96 insertions(+), 5 deletions(-) diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index f249ebec7..35aeb8d67 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -1,13 +1,31 @@ // 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() { - // If CLAUDE_CONFIG_DIR is set (e.g. via portable launcher), also redirect - // WebView2 user data folder so EBWebView cache lives alongside it instead of - // under %LOCALAPPDATA%\com.claude-code-haha.desktop\. + // 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 = std::path::PathBuf::from(&config_dir).join("EBWebView"); - if let Err(e) = std::fs::create_dir_all(&webview_data) { + 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); @@ -15,3 +33,76 @@ fn main() { 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 +} From 541b7ee0a73e3a17de9ec91431448a889ebe85b7 Mon Sep 17 00:00:00 2001 From: pobb <79351885+1506086927@users.noreply.github.com> Date: Sat, 16 May 2026 19:12:05 +0800 Subject: [PATCH 16/18] Update skills.ts --- src/server/api/skills.ts | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/src/server/api/skills.ts b/src/server/api/skills.ts index e76c2dc2c..73dfbcbab 100644 --- a/src/server/api/skills.ts +++ b/src/server/api/skills.ts @@ -224,11 +224,25 @@ async function collectSkillsFromRoots( } for (const entry of entries) { - if (!entry.isDirectory() || entry.name.startsWith('.') || seenNames.has(entry.name)) { - continue + if ( + (!entry.isDirectory() && !entry.isSymbolicLink()) || + entry.name.startsWith('.') || + seenNames.has(entry.name) + ) { + continue } - const meta = await loadSkillMeta(path.join(root, entry.name), entry.name, source) + const skillDir = path.join(root, entry.name) + const skillFile = path.join(skillDir, 'SKILL.md') + + try { + const stat = await fs.stat(skillFile) + if (!stat.isFile()) continue + } catch { + continue + } + + const meta = await loadSkillMeta(skillDir, entry.name, source) if (!meta) continue seenNames.add(entry.name) From 0eed355879df42bd1e2319e3bb4a04ad9ffca34e Mon Sep 17 00:00:00 2001 From: pobb <79351885+1506086927@users.noreply.github.com> Date: Sat, 16 May 2026 19:25:02 +0800 Subject: [PATCH 17/18] Update skills.ts --- src/server/api/skills.ts | 20 +++----------------- 1 file changed, 3 insertions(+), 17 deletions(-) diff --git a/src/server/api/skills.ts b/src/server/api/skills.ts index 73dfbcbab..e76c2dc2c 100644 --- a/src/server/api/skills.ts +++ b/src/server/api/skills.ts @@ -224,25 +224,11 @@ async function collectSkillsFromRoots( } for (const entry of entries) { - if ( - (!entry.isDirectory() && !entry.isSymbolicLink()) || - entry.name.startsWith('.') || - seenNames.has(entry.name) - ) { - continue + if (!entry.isDirectory() || entry.name.startsWith('.') || seenNames.has(entry.name)) { + continue } - const skillDir = path.join(root, entry.name) - const skillFile = path.join(skillDir, 'SKILL.md') - - try { - const stat = await fs.stat(skillFile) - if (!stat.isFile()) continue - } catch { - continue - } - - const meta = await loadSkillMeta(skillDir, entry.name, source) + const meta = await loadSkillMeta(path.join(root, entry.name), entry.name, source) if (!meta) continue seenNames.add(entry.name) From e8c045876e245184ad64f074b4b2021955d109f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Sun, 17 May 2026 00:50:59 +0800 Subject: [PATCH 18/18] Stabilize portable mode PR before merge The contributor PR adds useful Windows terminal and portable mode support, but it also reintroduced an older General settings zoom block and left the new native settings paths without enough regression coverage. This commit keeps the feature direction intact while removing the duplicate UI, making invalid bash paths fail at save time, and covering the portable cache and app-mode paths with focused tests. Constraint: This commit lands directly on the contributor PR branch to avoid a long review-comment loop. Rejected: Ask the contributor to rework the PR from scratch | the remaining issues are narrow and maintainable by us. Confidence: high Scope-risk: moderate Directive: Keep future portable-mode changes covered at the native boundary and the desktop store boundary. Tested: cd desktop && bun run test src/pages/TerminalSettings.test.tsx src/__tests__/generalSettings.test.tsx src/stores/settingsStore.test.ts Tested: bun test src/utils/__tests__/cachePaths.test.ts Tested: cd desktop/src-tauri && cargo test Tested: cd desktop && bun run lint Tested: cd desktop/src-tauri && cargo check Tested: bun run check:server Tested: bun run check:desktop Not-tested: Manual Windows packaged-app portable-mode smoke; to be covered before a future release. --- desktop/src-tauri/src/lib.rs | 101 ++++++++++++++------ desktop/src/pages/Settings.tsx | 27 ------ desktop/src/pages/TerminalSettings.test.tsx | 43 ++++++++- desktop/src/stores/settingsStore.test.ts | 60 ++++++++++++ src/utils/__tests__/cachePaths.test.ts | 26 +++++ src/utils/cachePaths.ts | 16 ++-- 6 files changed, 210 insertions(+), 63 deletions(-) create mode 100644 src/utils/__tests__/cachePaths.test.ts diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 3849931f0..f7e94cbc6 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -258,17 +258,6 @@ impl Default for AppModeConfig { } } -/// Read the persisted app-mode.json from the given config directory. -fn read_app_mode_config(config_dir: &Path) -> Option { - let path = config_dir.join(APP_MODE_FILE); - fs::read_to_string(&path).ok().and_then(|data| { - serde_json::from_str(&data).ok().or_else(|| { - eprintln!("[desktop] failed to parse app-mode.json: {}", data); - 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); @@ -329,24 +318,25 @@ impl TerminalConfig { .unwrap_or_default() } - fn save(&self, app: &AppHandle) { - let Some(path) = terminal_config_path(app) else { return }; + 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) { - eprintln!("[desktop] failed to create terminal config directory: {err}"); - return; + return Err(format!("create terminal config directory: {err}")); } } let data = match serde_json::to_string_pretty(self) { Ok(data) => data, Err(err) => { - eprintln!("[desktop] failed to serialize terminal config: {err}"); - return; + return Err(format!("serialize terminal config: {err}")); } }; if let Err(err) = fs::write(&path, data) { - eprintln!("[desktop] failed to write terminal config: {err}"); + return Err(format!("write terminal config: {err}")); } + Ok(()) } } @@ -1043,10 +1033,10 @@ fn get_terminal_bash_path(app: AppHandle) -> Option { } #[tauri::command] -fn set_terminal_bash_path(app: AppHandle, path: Option) { +fn set_terminal_bash_path(app: AppHandle, path: Option) -> Result<(), String> { let mut config = TerminalConfig::load(&app); - config.bash_path = path; - config.save(&app); + config.bash_path = normalize_terminal_bash_path(path)?; + config.save(&app) } #[tauri::command] @@ -1270,12 +1260,27 @@ fn home_dir() -> Option { .map(PathBuf::from) } -fn default_shell(custom_bash: Option<&str>) -> 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 { + if let Some(bash_path) = _custom_bash { let trimmed = bash_path.trim(); - if !trimmed.is_empty() && PathBuf::from(trimmed).exists() { + if !trimmed.is_empty() && PathBuf::from(trimmed).is_file() { return trimmed.to_string(); } } @@ -1686,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}; @@ -1788,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([ diff --git a/desktop/src/pages/Settings.tsx b/desktop/src/pages/Settings.tsx index b096e0655..a66bc8bf4 100644 --- a/desktop/src/pages/Settings.tsx +++ b/desktop/src/pages/Settings.tsx @@ -1990,33 +1990,6 @@ function GeneralSettings() { - {/* UI Zoom */} -
-

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

-

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

-
-
- - {Math.round(uiZoom * 100)}% - -
-
- {Math.round(UI_ZOOM_MIN * 100)}% - setUiZoom(parseFloat(e.target.value))} - onMouseUp={(e) => e.currentTarget.blur()} - className="flex-1" - /> - {Math.round(UI_ZOOM_MAX * 100)}% -
-
-
- {/* Confirm dialog for mode switch */} { 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/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/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 e221a9e71..8162545f7 100644 --- a/src/utils/cachePaths.ts +++ b/src/utils/cachePaths.ts @@ -6,10 +6,10 @@ import { djb2Hash } from './hash.js' // 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). -const claudConfigDir = (process.env as Record).CLAUDE_CONFIG_DIR -const paths: { cache: string } = claudConfigDir - ? { cache: join(claudConfigDir, 'Cache') } - : envPaths('claude-cli') +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. @@ -29,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)}`,