diff --git a/package.json b/package.json index 7577bcd..8fdf7fb 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "@tauri-apps/api": "^2.8.0", "@tauri-apps/plugin-dialog": "^2.4.0", "@tauri-apps/plugin-process": "^2.0.0", + "@tauri-apps/plugin-store": "^2.0.0", "@tauri-apps/plugin-updater": "^2.0.0", "codemirror": "^6.0.2", "i18next": "^25.5.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 590f7e1..55138f8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,6 +38,9 @@ importers: '@tauri-apps/plugin-process': specifier: ^2.0.0 version: 2.3.0 + '@tauri-apps/plugin-store': + specifier: ^2.0.0 + version: 2.4.0 '@tauri-apps/plugin-updater': specifier: ^2.0.0 version: 2.9.0 @@ -689,6 +692,9 @@ packages: '@tauri-apps/plugin-process@2.3.0': resolution: {integrity: sha512-0DNj6u+9csODiV4seSxxRbnLpeGYdojlcctCuLOCgpH9X3+ckVZIEj6H7tRQ7zqWr7kSTEWnrxtAdBb0FbtrmQ==} + '@tauri-apps/plugin-store@2.4.0': + resolution: {integrity: sha512-PjBnlnH6jyI71MGhrPaxUUCsOzc7WO1mbc4gRhME0m2oxLgCqbksw6JyeKQimuzv4ysdpNO3YbmaY2haf82a3A==} + '@tauri-apps/plugin-updater@2.9.0': resolution: {integrity: sha512-j++sgY8XpeDvzImTrzWA08OqqGqgkNyxczLD7FjNJJx/uXxMZFz5nDcfkyoI/rCjYuj2101Tci/r/HFmOmoxCg==} @@ -1561,6 +1567,10 @@ snapshots: dependencies: '@tauri-apps/api': 2.8.0 + '@tauri-apps/plugin-store@2.4.0': + dependencies: + '@tauri-apps/api': 2.8.0 + '@tauri-apps/plugin-updater@2.9.0': dependencies: '@tauri-apps/api': 2.8.0 @@ -1894,4 +1904,4 @@ snapshots: yallist@3.1.1: {} - yallist@5.0.0: {} + yallist@5.0.0: {} \ No newline at end of file diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 27e0a9b..b1f90cf 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -583,6 +583,7 @@ dependencies = [ "tauri-plugin-opener", "tauri-plugin-process", "tauri-plugin-single-instance", + "tauri-plugin-store", "tauri-plugin-updater", "tokio", "toml 0.8.2", @@ -4542,6 +4543,22 @@ dependencies = [ "zbus", ] +[[package]] +name = "tauri-plugin-store" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d85dd80d60a76ee2c2fdce09e9ef30877b239c2a6bb76e6d7d03708aa5f13a19" +dependencies = [ + "dunce", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.17", + "tokio", + "tracing", +] + [[package]] name = "tauri-plugin-updater" version = "2.9.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 0b2a9ae..47333a9 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -28,6 +28,7 @@ tauri-plugin-opener = "2" tauri-plugin-process = "2" tauri-plugin-updater = "2" tauri-plugin-dialog = "2" +tauri-plugin-store = "2" dirs = "5.0" toml = "0.8" reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] } diff --git a/src-tauri/src/app_store.rs b/src-tauri/src/app_store.rs new file mode 100644 index 0000000..e08d3ba --- /dev/null +++ b/src-tauri/src/app_store.rs @@ -0,0 +1,137 @@ +use serde_json::Value; +use std::path::PathBuf; +use std::sync::{OnceLock, RwLock}; +use tauri_plugin_store::StoreExt; + +/// Store 中的键名 +const STORE_KEY_APP_CONFIG_DIR: &str = "app_config_dir_override"; + +/// 全局缓存的 AppHandle (在应用启动时设置) +static APP_HANDLE: OnceLock>> = OnceLock::new(); + +/// 设置全局 AppHandle +pub fn set_app_handle(handle: tauri::AppHandle) { + let store = APP_HANDLE.get_or_init(|| RwLock::new(None)); + if let Ok(mut guard) = store.write() { + *guard = Some(handle); + } +} + +/// 获取全局 AppHandle +fn get_app_handle() -> Option { + let store = APP_HANDLE.get()?; + let guard = store.read().ok()?; + guard.as_ref().cloned() +} + +/// 从 Tauri Store 读取 app_config_dir 覆盖配置 (无需 AppHandle 版本) +pub fn get_app_config_dir_override() -> Option { + let app = get_app_handle()?; + get_app_config_dir_from_store(&app) +} + +/// 从 Tauri Store 读取 app_config_dir 覆盖配置(公开函数) +pub fn get_app_config_dir_from_store(app: &tauri::AppHandle) -> Option { + let store = app.store_builder("app_paths.json").build(); + + if let Err(e) = &store { + log::warn!("无法创建 Store: {}", e); + return None; + } + + let store = store.unwrap(); + + match store.get(STORE_KEY_APP_CONFIG_DIR) { + Some(Value::String(path_str)) => { + let path_str = path_str.trim(); + if path_str.is_empty() { + return None; + } + + let path = resolve_path(path_str); + + // 验证路径是否存在 + if !path.exists() { + log::warn!( + "Store 中配置的 app_config_dir 不存在: {:?}\n\ + 将使用默认路径。", + path + ); + return None; + } + + log::info!("使用 Store 中的 app_config_dir: {:?}", path); + Some(path) + } + Some(_) => { + log::warn!("Store 中的 {} 类型不正确,应为字符串", STORE_KEY_APP_CONFIG_DIR); + None + } + None => None, + } +} + +/// 写入 app_config_dir 到 Tauri Store +pub fn set_app_config_dir_to_store( + app: &tauri::AppHandle, + path: Option<&str>, +) -> Result<(), String> { + let store = app + .store_builder("app_paths.json") + .build() + .map_err(|e| format!("创建 Store 失败: {}", e))?; + + match path { + Some(p) => { + let trimmed = p.trim(); + if !trimmed.is_empty() { + store.set(STORE_KEY_APP_CONFIG_DIR, Value::String(trimmed.to_string())); + log::info!("已将 app_config_dir 写入 Store: {}", trimmed); + } else { + // 空字符串 = 删除配置 + store.delete(STORE_KEY_APP_CONFIG_DIR); + log::info!("已从 Store 中删除 app_config_dir 配置"); + } + } + None => { + // None = 删除配置 + store.delete(STORE_KEY_APP_CONFIG_DIR); + log::info!("已从 Store 中删除 app_config_dir 配置"); + } + } + + store.save().map_err(|e| format!("保存 Store 失败: {}", e))?; + + Ok(()) +} + +/// 解析路径,支持 ~ 开头的相对路径 +fn resolve_path(raw: &str) -> PathBuf { + if raw == "~" { + if let Some(home) = dirs::home_dir() { + return home; + } + } else if let Some(stripped) = raw.strip_prefix("~/") { + if let Some(home) = dirs::home_dir() { + return home.join(stripped); + } + } else if let Some(stripped) = raw.strip_prefix("~\\") { + if let Some(home) = dirs::home_dir() { + return home.join(stripped); + } + } + + PathBuf::from(raw) +} + +/// 从旧的 settings.json 迁移 app_config_dir 到 Store +pub fn migrate_app_config_dir_from_settings(app: &tauri::AppHandle) -> Result<(), String> { + // app_config_dir 已从 settings.json 移除,此函数保留但不再执行迁移 + // 如果用户在旧版本设置过 app_config_dir,需要在 Store 中手动配置 + log::info!("app_config_dir 迁移功能已移除,请在设置中重新配置"); + + // 确保 Store 初始化正常 + let _ = get_app_config_dir_from_store(app); + + Ok(()) +} \ No newline at end of file diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 5a3ec2a..9f1cbd6 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1222,6 +1222,13 @@ pub async fn save_settings(settings: crate::settings::AppSettings) -> Result Result { + // 使用 tauri-plugin-process 重启应用 + app.restart(); +} + /// 检查更新 #[tauri::command] pub async fn check_for_updates(handle: tauri::AppHandle) -> Result { @@ -1469,3 +1476,20 @@ pub async fn update_endpoint_last_used( state.save()?; Ok(()) } + +/// 获取 app_config_dir 覆盖配置 (从 Store) +#[tauri::command] +pub async fn get_app_config_dir_override(app: tauri::AppHandle) -> Result, String> { + Ok(crate::app_store::get_app_config_dir_from_store(&app) + .map(|p| p.to_string_lossy().to_string())) +} + +/// 设置 app_config_dir 覆盖配置 (到 Store) +#[tauri::command] +pub async fn set_app_config_dir_override( + app: tauri::AppHandle, + path: Option, +) -> Result { + crate::app_store::set_app_config_dir_to_store(&app, path.as_deref())?; + Ok(true) +} \ No newline at end of file diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index e30854d..6523835 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -33,6 +33,10 @@ pub fn get_claude_settings_path() -> PathBuf { /// 获取应用配置目录路径 (~/.cc-switch) pub fn get_app_config_dir() -> PathBuf { + if let Some(custom) = crate::app_store::get_app_config_dir_override() { + return custom; + } + dirs::home_dir() .expect("无法获取用户主目录") .join(".cc-switch") @@ -245,4 +249,4 @@ pub fn get_claude_config_status() -> ConfigStatus { } } -//(移除未使用的备份/导入函数,避免 dead_code 告警) +//(移除未使用的备份/导入函数,避免 dead_code 告警) \ No newline at end of file diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c55952c..f08b5b7 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,4 +1,5 @@ mod app_config; +mod app_store; mod claude_mcp; mod claude_plugin; mod codex_config; @@ -305,7 +306,10 @@ pub fn run() { .plugin(tauri_plugin_process::init()) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_opener::init()) + .plugin(tauri_plugin_store::Builder::new().build()) .setup(|app| { + // 设置全局 AppHandle 以供 Store 使用 + app_store::set_app_handle(app.handle().clone()); // 注册 Updater 插件(桌面端) #[cfg(desktop)] { @@ -359,6 +363,11 @@ pub fn run() { // 初始化应用状态(仅创建一次,并在本函数末尾注入 manage) let app_state = AppState::new(); + // 迁移旧的 app_config_dir 配置到 Store + if let Err(e) = app_store::migrate_app_config_dir_from_settings(&app.handle()) { + log::warn!("迁移 app_config_dir 失败: {}", e); + } + // 首次启动迁移:扫描副本文件,合并到 config.json,并归档副本;旧 config.json 先归档 { let mut config_guard = app_state.config.lock().unwrap(); @@ -418,6 +427,7 @@ pub fn run() { commands::read_live_provider_settings, commands::get_settings, commands::save_settings, + commands::restart_app, commands::check_for_updates, commands::is_portable_mode, commands::get_claude_plugin_status, @@ -447,6 +457,9 @@ pub fn run() { commands::add_custom_endpoint, commands::remove_custom_endpoint, commands::update_endpoint_last_used, + // app_config_dir override via Store + commands::get_app_config_dir_override, + commands::set_app_config_dir_override, // theirs: config import/export and dialogs import_export::export_config_to_file, import_export::import_config_from_file, @@ -480,4 +493,4 @@ pub fn run() { let _ = (app_handle, event); } }); -} +} \ No newline at end of file diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 4d9491b..079a98c 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -64,7 +64,12 @@ impl Default for AppSettings { impl AppSettings { fn settings_path() -> PathBuf { - crate::config::get_app_config_dir().join("settings.json") + // settings.json 必须使用固定路径,不能被 app_config_dir 覆盖 + // 否则会造成循环依赖:读取 settings 需要知道路径,但路径在 settings 中 + dirs::home_dir() + .expect("无法获取用户主目录") + .join(".cc-switch") + .join("settings.json") } fn normalize_paths(&mut self) { @@ -178,4 +183,4 @@ pub fn get_codex_override_dir() -> Option { .codex_config_dir .as_ref() .map(|p| resolve_override_path(p)) -} +} \ No newline at end of file diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index 1c83380..2a1f1df 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -61,6 +61,10 @@ export default function SettingsModal({ codexConfigDir: undefined, language: persistedLanguage, }); + // appConfigDir 现在从 Store 独立管理 + const [appConfigDir, setAppConfigDir] = useState( + undefined, + ); const [initialLanguage, setInitialLanguage] = useState<"zh" | "en">( persistedLanguage, ); @@ -69,9 +73,14 @@ export default function SettingsModal({ const [isCheckingUpdate, setIsCheckingUpdate] = useState(false); const [isDownloading, setIsDownloading] = useState(false); const [showUpToDate, setShowUpToDate] = useState(false); + const [resolvedAppConfigDir, setResolvedAppConfigDir] = useState(""); const [resolvedClaudeDir, setResolvedClaudeDir] = useState(""); const [resolvedCodexDir, setResolvedCodexDir] = useState(""); const [isPortable, setIsPortable] = useState(false); + const [initialAppConfigDir, setInitialAppConfigDir] = useState< + string | undefined + >(undefined); + const [showRestartDialog, setShowRestartDialog] = useState(false); const { hasUpdate, updateInfo, updateHandle, checkUpdate, resetDismiss } = useUpdate(); @@ -86,6 +95,7 @@ export default function SettingsModal({ useEffect(() => { loadSettings(); + loadAppConfigDirFromStore(); // 从 Store 加载 appConfigDir loadConfigPath(); loadVersion(); loadResolvedDirs(); @@ -103,6 +113,24 @@ export default function SettingsModal({ } }; + // 从 Tauri Store 加载 appConfigDir + const loadAppConfigDirFromStore = async () => { + try { + const storeValue = await (window as any).api.getAppConfigDirOverride(); + if (storeValue) { + setAppConfigDir(storeValue); + setInitialAppConfigDir(storeValue); + setResolvedAppConfigDir(storeValue); + } else { + // 使用默认值 + const defaultDir = await computeDefaultAppConfigDir(); + setResolvedAppConfigDir(defaultDir); + } + } catch (error) { + console.error("从 Store 加载 appConfigDir 失败:", error); + } + }; + const loadSettings = async () => { try { const loadedSettings = await window.api.getSettings(); @@ -195,7 +223,17 @@ export default function SettingsModal({ : undefined, language: selectedLanguage, }; + + // 保存 settings.json (不包含 appConfigDir) await window.api.saveSettings(payload); + + // 单独保存 appConfigDir 到 Store + const normalizedAppConfigDir = + appConfigDir && appConfigDir.trim() !== "" + ? appConfigDir.trim() + : null; + await (window as any).api.setAppConfigDirOverride(normalizedAppConfigDir); + // 立即生效:根据开关无条件写入/移除 ~/.claude/config.json try { if (payload.enableClaudePluginIntegration) { @@ -206,7 +244,14 @@ export default function SettingsModal({ } catch (e) { console.warn("[Settings] Apply Claude plugin config on save failed", e); } + + // 检测 appConfigDir 是否真正发生变化 + const appConfigDirChanged = + (normalizedAppConfigDir || undefined) !== + (initialAppConfigDir || undefined); + setSettings(payload); + setInitialAppConfigDir(normalizedAppConfigDir ?? undefined); try { window.localStorage.setItem("language", selectedLanguage); } catch (error) { @@ -216,12 +261,47 @@ export default function SettingsModal({ if (i18n.language !== selectedLanguage) { void i18n.changeLanguage(selectedLanguage); } - onClose(); + + // 如果修改了 appConfigDir,需要提示用户重启应用程序 + if (appConfigDirChanged) { + setShowRestartDialog(true); + } else { + onClose(); + } } catch (error) { console.error(t("console.saveSettingsFailed"), error); } }; + const handleRestartNow = async () => { + // 开发模式下不真正重启,只提示 + if (import.meta.env.DEV) { + onNotify?.( + t("settings.devModeRestartHint"), + "success", + 5000, + ); + setShowRestartDialog(false); + onClose(); + return; + } + + // 生产模式下真正重启应用 + try { + await window.api.restartApp(); + } catch (e) { + console.warn("[Settings] Restart app failed", e); + // 如果重启失败,仍然关闭设置窗口 + setShowRestartDialog(false); + onClose(); + } + }; + + const handleRestartLater = () => { + setShowRestartDialog(false); + onClose(); + }; + const handleLanguageChange = (lang: "zh" | "en") => { setSettings((prev) => ({ ...prev, language: lang })); if (i18n.language !== lang) { @@ -298,6 +378,28 @@ export default function SettingsModal({ } }; + const handleBrowseAppConfigDir = async () => { + try { + const currentResolved = appConfigDir ?? resolvedAppConfigDir; + const selected = await window.api.selectConfigDirectory(currentResolved); + + if (!selected) { + return; + } + + const sanitized = selected.trim(); + + if (sanitized === "") { + return; + } + + setAppConfigDir(sanitized); + setResolvedAppConfigDir(sanitized); + } catch (error) { + console.error(t("console.selectConfigDirFailed"), error); + } + }; + const handleBrowseConfigDir = async (app: AppType) => { try { const currentResolved = @@ -340,6 +442,24 @@ export default function SettingsModal({ } }; + const computeDefaultAppConfigDir = async () => { + try { + const home = await homeDir(); + return await join(home, ".cc-switch"); + } catch (error) { + console.error(t("console.getDefaultConfigDirFailed"), error); + return ""; + } + }; + + const handleResetAppConfigDir = async () => { + setAppConfigDir(undefined); + const defaultDir = await computeDefaultAppConfigDir(); + if (defaultDir) { + setResolvedAppConfigDir(defaultDir); + } + }; + const handleResetConfigDir = async (app: AppType) => { setSettings((prev) => ({ ...prev, @@ -605,6 +725,40 @@ export default function SettingsModal({ {t("settings.configDirectoryDescription")}

+
+ +

+ {t("settings.appConfigDirDescription")} +

+
+ setAppConfigDir(e.target.value)} + placeholder={t("settings.browsePlaceholderApp")} + className="flex-1 px-3 py-2 text-xs font-mono bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/40" + /> + + +
+
+