Merge pull request #447 from 1506086927/main

Feat: Windows custom Bash path & Full portable mode
This commit is contained in:
程序员阿江-Relakkes 2026-05-17 00:56:16 +08:00 committed by GitHub
commit cf7c4487f0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 898 additions and 25 deletions

View File

@ -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<String>,
}
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<PathBuf> {
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<String>,
}
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<PathBuf> {
// 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<ServerStatus>);
@ -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<String>) {
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::<AppExitState>() {
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<PathBuf> {
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<PathBuf> {
std::env::var("CLAUDE_CONFIG_DIR").ok().map(|dir| {
PathBuf::from(&dir).join(WINDOW_STATE_FILE)
})
}
fn read_stored_window_state(app: &AppHandle) -> Option<StoredWindowState> {
@ -612,7 +825,8 @@ fn terminal_spawn(
cwd: Option<String>,
) -> Result<TerminalSpawnResult, String> {
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<String> {
let config = TerminalConfig::load(&app);
config.bash_path
}
#[tauri::command]
fn set_terminal_bash_path(app: AppHandle, path: Option<String>) -> 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<String, String> {
run_notification_bridge(macos_notifications::permission_state).await
@ -1033,7 +1260,31 @@ fn home_dir() -> Option<PathBuf> {
.map(PathBuf::from)
}
fn default_shell() -> String {
fn normalize_terminal_bash_path(path: Option<String>) -> Result<Option<String>, 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<ServerRuntime, String> {
.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<Vec<CommandChild>, 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
]);

View File

@ -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<PathBuf> {
// 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<PathBuf>)> {
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<PathBuf> = std::env::var("APPDATA").ok().map(PathBuf::from);
#[cfg(target_os = "macos")]
let system_config: Option<PathBuf> = std::env::var("HOME").ok().map(|h| PathBuf::from(h).join("Library").join("Application Support"));
#[cfg(target_os = "linux")]
let system_config: Option<PathBuf> = 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
}

View File

@ -55,4 +55,12 @@ export const terminalApi = {
const events = await import('@tauri-apps/api/event')
return events.listen<TerminalExitPayload>('terminal-exit', (event) => handler(event.payload))
},
getBashPath() {
return invoke<string | null>('get_terminal_bash_path', undefined)
},
setBashPath(path: string | null) {
return invoke<void>('set_terminal_bash_path', { path })
},
}

View File

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

View File

@ -189,6 +189,12 @@ export const zh: Record<TranslationKey, string> = {
'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<TranslationKey, string> = {
'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': '在经典暖色、暗色与纯白工作区之间切换。',

View File

@ -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<DesktopNotificationPermission>('default')
const [notificationActionRunning, setNotificationActionRunning] = useState(false)
const [modeSwitchConfirmOpen, setModeSwitchConfirmOpen] = useState(false)
const [pendingMode, setPendingMode] = useState<AppMode | null>(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<EffortLevel, string> = {
low: t('settings.general.effort.low'),
medium: t('settings.general.effort.medium'),
@ -1639,6 +1650,79 @@ function GeneralSettings() {
return (
<div className="max-w-xl">
{/* Mode Section */}
{isTauriRuntime() && (
<div className="mb-8">
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">{t('settings.general.modeTitle')}</h2>
<p className="text-sm text-[var(--color-text-tertiary)] mb-3">{t('settings.general.modeDescription')}</p>
<div className="flex gap-2">
<button
onClick={() => {
if (appMode.mode === 'default') return
setPendingMode('default')
setModeSwitchConfirmOpen(true)
}}
aria-pressed={appMode.mode === 'default'}
className={`flex-1 py-3 text-sm font-semibold rounded-lg border transition-all ${
appMode.mode === 'default'
? 'bg-[image:var(--gradient-btn-primary)] text-[var(--color-btn-primary-fg)] border-transparent shadow-[var(--shadow-button-primary)]'
: 'border-[var(--color-border)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]'
}`}
>
<div className="flex flex-col items-center gap-1">
<span className="material-symbols-outlined text-[22px]">settings_applications</span>
<span>{t('settings.general.modeDefault')}</span>
</div>
</button>
<button
onClick={() => {
if (appMode.mode === 'portable') return
setPendingMode('portable')
setModeSwitchConfirmOpen(true)
}}
aria-pressed={appMode.mode === 'portable'}
className={`flex-1 py-3 text-sm font-semibold rounded-lg border transition-all ${
appMode.mode === 'portable'
? 'bg-[image:var(--gradient-btn-primary)] text-[var(--color-btn-primary-fg)] border-transparent shadow-[var(--shadow-button-primary)]'
: 'border-[var(--color-border)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]'
}`}
>
<div className="flex flex-col items-center gap-1">
<span className="material-symbols-outlined text-[22px]">drive_file_move</span>
<span>{t('settings.general.modePortable')}</span>
</div>
</button>
</div>
{appMode.mode === 'portable' && appMode.portableDir && (
<div className="mt-2 text-xs text-[var(--color-text-tertiary)] font-mono break-all">
{t('settings.general.modePortableDir')}: {appMode.portableDir}
</div>
)}
{appMode.mode === 'default' && (
<div className="mt-2 text-xs text-[var(--color-text-tertiary)]">
{t('settings.general.modeDefaultHint')}
</div>
)}
</div>
)}
{/* Restart Required Banner */}
{appModeRequiresRestart && (
<div className="mb-6 rounded-xl border border-[var(--color-warning)] bg-[var(--color-warning)]/10 px-4 py-3 flex items-center gap-3">
<span className="material-symbols-outlined text-[20px] text-[var(--color-warning)]" style={{ fontVariationSettings: "'FILL' 1" }}>
warning
</span>
<div>
<div className="text-sm font-medium text-[var(--color-text-primary)]">
{t('settings.general.modeRestartTitle')}
</div>
<div className="text-xs text-[var(--color-text-tertiary)] mt-0.5">
{t('settings.general.modeRestartHint')}
</div>
</div>
</div>
)}
{/* Appearance selector */}
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">{t('settings.general.appearanceTitle')}</h2>
<p className="text-sm text-[var(--color-text-tertiary)] mb-3">{t('settings.general.appearanceDescription')}</p>
@ -1906,6 +1990,22 @@ function GeneralSettings() {
</div>
</div>
</div>
{/* Confirm dialog for mode switch */}
<ConfirmDialog
open={modeSwitchConfirmOpen}
onClose={() => { 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')}
/>
</div>
)
}

View File

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

View File

@ -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<HTMLDivElement | null>(null)
const terminalRef = useRef<XTermTerminal | null>(null)
const fitRef = useRef<XTermFitAddon | null>(null)
@ -289,6 +290,10 @@ export function TerminalSettings({
</div>
)}
{isWindows && (
<BashPathSettings isTauri={terminalApi.isAvailable()} />
)}
{status === 'unavailable' ? (
<div className="flex flex-1 items-center justify-center rounded-[var(--radius-lg)] border border-dashed border-[var(--color-border)] bg-[var(--color-surface-container-low)] p-8 text-center">
<div>
@ -341,3 +346,120 @@ function StatusPill({ status, label }: { status: TerminalStatus; label: string }
</span>
)
}
function BashPathSettings({ isTauri }: { isTauri: boolean }) {
const t = useTranslation()
const [bashPath, setBashPath] = useState<string | null>(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 (
<div className="mb-3 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-4 py-3">
<label className="mb-1.5 block text-sm font-medium text-[var(--color-text-primary)]">
{t('settings.terminal.bashPathLabel')}
</label>
<p className="mb-2 text-xs text-[var(--color-text-tertiary)]">
{t('settings.terminal.bashPathDescription')}
</p>
<div className="flex gap-2">
<input
type="text"
value={bashPath || ''}
onChange={(e) => { 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)]"
/>
<button
type="button"
onClick={handleBrowse}
className="inline-flex h-8 items-center gap-1 rounded-[var(--radius-sm)] border border-[var(--color-border)] px-3 text-xs font-medium text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)]"
>
<span className="material-symbols-outlined text-[16px]">folder_open</span>
</button>
<button
type="button"
onClick={handleSave}
disabled={saving}
className="inline-flex h-8 items-center gap-1 rounded-[var(--radius-sm)] bg-[var(--color-text-primary)] px-3 text-xs font-medium text-[var(--color-surface)] transition-colors hover:opacity-90 disabled:opacity-50"
>
{saved ? t('settings.terminal.bashPathSaved') : t('settings.terminal.bashPathSave')}
</button>
<button
type="button"
onClick={handleReset}
disabled={saving || bashPath === null}
className="inline-flex h-8 items-center gap-1 rounded-[var(--radius-sm)] border border-[var(--color-border)] px-3 text-xs font-medium text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)] disabled:opacity-50"
>
{t('settings.terminal.bashPathReset')}
</button>
</div>
{invalid && (
<p className="mt-1.5 text-xs text-[var(--color-error)]">
{t('settings.terminal.bashPathInvalid')}
</p>
)}
</div>
)
}

View File

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

View File

@ -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<void>
fetchH5Access: () => Promise<void>
setPermissionMode: (mode: PermissionMode) => Promise<void>
@ -69,6 +73,8 @@ type SettingsStore = {
publicBaseUrl?: string | null
}) => Promise<void>
setResponseLanguage: (language: string) => Promise<void>
fetchAppMode: () => Promise<void>
setAppMode: (mode: AppMode, portableDir?: string | null) => Promise<void>
setUiZoom: (zoom: number) => void
}
@ -98,6 +104,8 @@ export const useSettingsStore = create<SettingsStore>((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<SettingsStore>((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 {

View File

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

View File

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

View File

@ -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<string, string | undefined>).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)}`,