mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
Merge pull request #447 from 1506086927/main
Feat: Windows custom Bash path & Full portable mode
This commit is contained in:
commit
cf7c4487f0
@ -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
|
||||
]);
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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 })
|
||||
},
|
||||
}
|
||||
|
||||
@ -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.',
|
||||
|
||||
@ -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': '在经典暖色、暗色与纯白工作区之间切换。',
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
26
src/utils/__tests__/cachePaths.test.ts
Normal file
26
src/utils/__tests__/cachePaths.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@ -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)}`,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user