diff --git a/Cargo.lock b/Cargo.lock index 51d705d..8ad367c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3172,9 +3172,6 @@ dependencies = [ "objc2-app-kit", "objc2-core-graphics", "objc2-foundation", - "security-framework", - "serde", - "serde_json", "ttree-core", ] @@ -3187,7 +3184,6 @@ dependencies = [ "serde_json", "tempfile", "ttree-core", - "ttree-platform-macos", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index aa4bf66..1bfac65 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,7 +30,6 @@ dirs = "6" auto-launch = "0.5" global-hotkey = "0.8" core-foundation = "0.10" -security-framework = "3" objc2 = "0.6" objc2-foundation = { version = "0.3", features = [ "NSArray", diff --git a/crates/ttree-app/src/state.rs b/crates/ttree-app/src/state.rs index 76824da..9210c0b 100644 --- a/crates/ttree-app/src/state.rs +++ b/crates/ttree-app/src/state.rs @@ -790,7 +790,7 @@ impl AppController { let ai_key_field = make_secure_field( mtm, top_rect(h, 78.0, 79.0, 148.0, 22.0), - if ai_cfg.has_api_key { "已存钥匙串 (留空不改)" } else { "本地服务/GGUF 免填" }, + if ai_cfg.has_api_key { "已保存在配置中 (留空不改)" } else { "本地服务/GGUF 免填" }, ); let ai_key_status_label = make_label( mtm, @@ -946,7 +946,7 @@ impl AppController { let chat_key_field = make_secure_field( mtm, top_rect(h, 78.0, 293.0, 148.0, 22.0), - if chat_cfg.has_api_key { "已存钥匙串 (留空不改)" } else { "本地服务/GGUF 免填" }, + if chat_cfg.has_api_key { "已保存在配置中 (留空不改)" } else { "本地服务/GGUF 免填" }, ); let chat_key_status_label = make_label( mtm, diff --git a/crates/ttree-platform-macos/Cargo.toml b/crates/ttree-platform-macos/Cargo.toml index f871ec9..d354c31 100644 --- a/crates/ttree-platform-macos/Cargo.toml +++ b/crates/ttree-platform-macos/Cargo.toml @@ -10,12 +10,9 @@ ttree-core = { path = "../ttree-core" } anyhow.workspace = true auto-launch.workspace = true global-hotkey.workspace = true -serde.workspace = true -serde_json.workspace = true [target.'cfg(target_os = "macos")'.dependencies] core-foundation.workspace = true -security-framework.workspace = true objc2.workspace = true objc2-app-kit.workspace = true objc2-core-graphics.workspace = true diff --git a/crates/ttree-platform-macos/src/keychain.rs b/crates/ttree-platform-macos/src/keychain.rs deleted file mode 100644 index d4b2389..0000000 --- a/crates/ttree-platform-macos/src/keychain.rs +++ /dev/null @@ -1,127 +0,0 @@ -//! macOS Keychain 封装 —— 兼容 `com.samwstu.ttree` 既有凭据格式。 - -#[cfg(target_os = "macos")] -use security_framework::passwords; -use serde::{Deserialize, Serialize}; - -pub const SERVICE: &str = "com.samwstu.ttree"; -pub const ACCOUNT_OPENAI_KEY: &str = "openai.api_key"; -pub const ACCOUNT_CHAT_AI_KEY: &str = "chat_ai.api_key"; - -const ERR_SEC_ITEM_NOT_FOUND: i32 = -25300; -const BOUND_SECRET_PREFIX: &str = "ttree-bound-secret-v1:"; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum SecretRecord { - Legacy(String), - Bound { endpoint: String, value: String }, -} - -impl SecretRecord { - pub fn value(&self) -> &str { - match self { - Self::Legacy(v) => v, - Self::Bound { value, .. } => value, - } - } -} - -#[derive(Serialize, Deserialize)] -struct BoundSecretPayload { - endpoint: String, - value: String, -} - -pub fn encode_bound_secret(endpoint: &str, value: &str) -> Result { - if endpoint.is_empty() || value.is_empty() { - return Err("接口地址和密钥均不能为空".to_string()); - } - let payload = serde_json::to_string(&BoundSecretPayload { - endpoint: endpoint.to_string(), - value: value.to_string(), - }) - .map_err(|e| format!("序列化钥匙串记录失败: {e}"))?; - Ok(format!("{BOUND_SECRET_PREFIX}{payload}")) -} - -pub fn decode_secret_record(raw: &str) -> Result { - let Some(payload) = raw.strip_prefix(BOUND_SECRET_PREFIX) else { - return Ok(SecretRecord::Legacy(raw.to_string())); - }; - let record: BoundSecretPayload = - serde_json::from_str(payload).map_err(|e| format!("钥匙串记录损坏: {e}"))?; - if record.endpoint.is_empty() || record.value.is_empty() { - return Err("钥匙串记录损坏: 接口地址或密钥为空".to_string()); - } - Ok(SecretRecord::Bound { - endpoint: record.endpoint, - value: record.value, - }) -} - -pub fn get_secret_record(account: &str) -> Result, String> { - get_secret(account)? - .map(|raw| decode_secret_record(&raw)) - .transpose() -} - -pub fn set_bound_secret(account: &str, endpoint: &str, value: &str) -> Result<(), String> { - let encoded = encode_bound_secret(endpoint, value)?; - set_secret(account, &encoded) -} - -#[cfg(target_os = "macos")] -pub fn get_secret(account: &str) -> Result, String> { - match passwords::get_generic_password(SERVICE, account) { - Ok(bytes) => Ok(Some( - String::from_utf8(bytes).map_err(|e| format!("密钥内容非 UTF-8: {e}"))?, - )), - Err(err) => { - if err.code() == ERR_SEC_ITEM_NOT_FOUND { - Ok(None) - } else { - Err(format!("读取钥匙串失败 (code {}): {err}", err.code())) - } - } - } -} - -#[cfg(target_os = "macos")] -pub fn set_secret(account: &str, value: &str) -> Result<(), String> { - passwords::set_generic_password(SERVICE, account, value.as_bytes()) - .map_err(|e| format!("写入钥匙串失败: {e}")) -} - -#[cfg(target_os = "macos")] -pub fn delete_secret(account: &str) -> Result<(), String> { - match passwords::delete_generic_password(SERVICE, account) { - Ok(()) => Ok(()), - Err(err) if err.code() == ERR_SEC_ITEM_NOT_FOUND => Ok(()), - Err(err) => Err(format!("删除钥匙串失败 (code {}): {err}", err.code())), - } -} - -#[cfg(not(target_os = "macos"))] -pub fn get_secret(_account: &str) -> Result, String> { - Ok(None) -} -#[cfg(not(target_os = "macos"))] -pub fn set_secret(_account: &str, _value: &str) -> Result<(), String> { - Ok(()) -} -#[cfg(not(target_os = "macos"))] -pub fn delete_secret(_account: &str) -> Result<(), String> { - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn round_trips_bound_secret() { - let encoded = encode_bound_secret("https://api.openai.com/v1", "sk-test").unwrap(); - let decoded = decode_secret_record(&encoded).unwrap(); - assert_eq!(decoded.value(), "sk-test"); - } -} diff --git a/crates/ttree-platform-macos/src/lib.rs b/crates/ttree-platform-macos/src/lib.rs index f393bbb..20449c7 100644 --- a/crates/ttree-platform-macos/src/lib.rs +++ b/crates/ttree-platform-macos/src/lib.rs @@ -1,10 +1,8 @@ //! `ttree-platform-macos` —— macOS 原生系统能力封装: -//! - Keychain 安全凭据存取(兼容既有 `com.samwstu.ttree` 钥匙串记录) //! - Accessibility + 剪贴板回退的划词捕获 //! - 全局热键解析、冲突校验与内存级 O(1) 分发 //! - LaunchAgent 开机自启管理 pub mod autostart; pub mod hotkey; -pub mod keychain; pub mod selection; diff --git a/crates/ttree-storage/Cargo.toml b/crates/ttree-storage/Cargo.toml index ffb3687..51f74b0 100644 --- a/crates/ttree-storage/Cargo.toml +++ b/crates/ttree-storage/Cargo.toml @@ -7,7 +7,6 @@ license.workspace = true [dependencies] ttree-core = { path = "../ttree-core" } -ttree-platform-macos = { path = "../ttree-platform-macos" } dirs.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/ttree-storage/src/lib.rs b/crates/ttree-storage/src/lib.rs index b515295..99fab49 100644 --- a/crates/ttree-storage/src/lib.rs +++ b/crates/ttree-storage/src/lib.rs @@ -1,4 +1,4 @@ -//! `ttree-storage` —— 内存级配置缓存(兼容既有 `settings.json` 与 Keychain)与 JSONL 对话历史持久化。 +//! `ttree-storage` —— 内存级配置缓存(直接持久化至本地 `settings.json`,无 Keychain 弹窗)与 JSONL 对话历史持久化。 pub mod history; pub mod settings; diff --git a/crates/ttree-storage/src/settings.rs b/crates/ttree-storage/src/settings.rs index 3dbcc7e..4240b60 100644 --- a/crates/ttree-storage/src/settings.rs +++ b/crates/ttree-storage/src/settings.rs @@ -1,9 +1,9 @@ -//! 内存缓存 + 磁盘持久化的配置管理。 +//! 内存缓存 + 本地 JSON (`settings.json`) 持久化的配置管理。 //! -//! 关键性能优化: -//! - 启动时一次性加载 `settings.json` 与 Keychain 密钥到内存 `RwLock` -//! - 翻译/对话/快捷键分发热路径直接从内存读取(0 磁盘 I/O,0 Keychain IPC) -//! - 100% 兼容旧版 Tauri `tauri-plugin-store` 生成的扁平点号键名(如 `"openai.base_url"`) +//! 关键设计: +//! - 启动时一次性加载 `settings.json` 到内存 `RwLock` +//! - 所有配置(含可选的 `api_key`)直接保存在应用配置目录 `settings.json`(权限 `0600` 仅当前用户可读写),彻底杜绝 macOS Keychain 授权密码弹窗 +//! - 翻译/对话/快捷键分发热路径直接从内存读取(0 磁盘 I/O) use std::fs; use std::path::{Path, PathBuf}; @@ -17,7 +17,6 @@ use ttree_core::{ normalize_base_url, AiBackendKind, AiTranslationConfig, AppearanceConfig, ChatAiConfig, ShortcutConfig, WINDOW_MIN_HEIGHT, WINDOW_MIN_WIDTH, }; -use ttree_platform_macos::keychain::{self, ACCOUNT_CHAT_AI_KEY, ACCOUNT_OPENAI_KEY}; pub const APP_BUNDLE_ID: &str = "com.samwstu.ttree"; pub const STORE_FILE: &str = "settings.json"; @@ -71,18 +70,30 @@ impl SettingsStore { } } - /// 供翻译请求直接读取(含内存缓存的真实 API Key,零 I/O)。 + /// 供翻译请求直接读取(纯内存读取,零 I/O)。 pub fn ai_translation_for_request(&self) -> AiTranslationConfig { - self.cache.read().expect("settings lock poisoned").ai_translate.clone() + self.cache + .read() + .expect("settings lock poisoned") + .ai_translate + .clone() } - /// 供对话请求直接读取(含内存缓存的真实 API Key,零 I/O)。 + /// 供对话请求直接读取(纯内存读取,零 I/O)。 pub fn chat_ai_for_request(&self) -> ChatAiConfig { - self.cache.read().expect("settings lock poisoned").chat_ai.clone() + self.cache + .read() + .expect("settings lock poisoned") + .chat_ai + .clone() } pub fn shortcuts(&self) -> ShortcutConfig { - self.cache.read().expect("settings lock poisoned").shortcuts.clone() + self.cache + .read() + .expect("settings lock poisoned") + .shortcuts + .clone() } pub fn appearance(&self) -> AppearanceConfig { @@ -99,27 +110,23 @@ impl SettingsStore { validate_field_len("Tokenizer 路径", &input.gguf_tokenizer_path, MAX_PATH_BYTES)?; validate_field_len("翻译提示词", &input.translate_prompt, MAX_PROMPT_BYTES)?; - let base_url = if input.backend == AiBackendKind::CandleGguf && input.base_url.trim().is_empty() { - String::new() - } else { - normalize_base_url(&input.base_url)? - }; + let base_url = + if input.backend == AiBackendKind::CandleGguf && input.base_url.trim().is_empty() { + String::new() + } else { + normalize_base_url(&input.base_url)? + }; let mut guard = self.cache.write().expect("settings lock poisoned"); - let resolved_key = update_secret_if_needed( - ACCOUNT_OPENAI_KEY, - &base_url, - &guard.ai_translate.api_key, - &input.api_key, - input.clear_api_key, - )?; + let resolved_key = + resolve_api_key(&guard.ai_translate.api_key, &input.api_key, input.clear_api_key)?; let updated = AiTranslationConfig { enabled: input.enabled, backend: input.backend, base_url: base_url.clone(), has_api_key: !resolved_key.is_empty(), - api_key: resolved_key, + api_key: resolved_key.clone(), clear_api_key: false, model: input.model.trim().to_string(), gguf_model_path: input.gguf_model_path.trim().to_string(), @@ -127,13 +134,25 @@ impl SettingsStore { translate_prompt: input.translate_prompt.clone(), }; - guard.raw_map.insert("openai.enabled".into(), Value::Bool(updated.enabled)); + guard + .raw_map + .insert("openai.enabled".into(), Value::Bool(updated.enabled)); guard.raw_map.insert( "openai.backend".into(), - serde_json::to_value(updated.backend).unwrap_or(Value::String("open_ai_compatible".into())), + serde_json::to_value(updated.backend) + .unwrap_or(Value::String("open_ai_compatible".into())), ); - guard.raw_map.insert("openai.base_url".into(), Value::String(updated.base_url.clone())); - guard.raw_map.insert("openai.model".into(), Value::String(updated.model.clone())); + guard.raw_map.insert( + "openai.base_url".into(), + Value::String(updated.base_url.clone()), + ); + guard.raw_map.insert( + "openai.api_key".into(), + Value::String(resolved_key), + ); + guard + .raw_map + .insert("openai.model".into(), Value::String(updated.model.clone())); guard.raw_map.insert( "openai.gguf_model_path".into(), Value::String(updated.gguf_model_path.clone()), @@ -146,7 +165,6 @@ impl SettingsStore { "openai.translate_prompt".into(), Value::String(updated.translate_prompt.clone()), ); - guard.raw_map.remove("openai.api_key"); write_json_map(&self.path, &guard.raw_map)?; guard.ai_translate = updated; @@ -159,26 +177,22 @@ impl SettingsStore { validate_field_len("Tokenizer 路径", &input.gguf_tokenizer_path, MAX_PATH_BYTES)?; validate_field_len("AI 对话提示词", &input.chat_prompt, MAX_PROMPT_BYTES)?; - let base_url = if input.backend == AiBackendKind::CandleGguf && input.base_url.trim().is_empty() { - String::new() - } else { - normalize_base_url(&input.base_url)? - }; + let base_url = + if input.backend == AiBackendKind::CandleGguf && input.base_url.trim().is_empty() { + String::new() + } else { + normalize_base_url(&input.base_url)? + }; let mut guard = self.cache.write().expect("settings lock poisoned"); - let resolved_key = update_secret_if_needed( - ACCOUNT_CHAT_AI_KEY, - &base_url, - &guard.chat_ai.api_key, - &input.api_key, - input.clear_api_key, - )?; + let resolved_key = + resolve_api_key(&guard.chat_ai.api_key, &input.api_key, input.clear_api_key)?; let updated = ChatAiConfig { backend: input.backend, base_url: base_url.clone(), has_api_key: !resolved_key.is_empty(), - api_key: resolved_key, + api_key: resolved_key.clone(), clear_api_key: false, model: input.model.trim().to_string(), gguf_model_path: input.gguf_model_path.trim().to_string(), @@ -188,10 +202,21 @@ impl SettingsStore { guard.raw_map.insert( "chat_ai.backend".into(), - serde_json::to_value(updated.backend).unwrap_or(Value::String("open_ai_compatible".into())), + serde_json::to_value(updated.backend) + .unwrap_or(Value::String("open_ai_compatible".into())), + ); + guard.raw_map.insert( + "chat_ai.base_url".into(), + Value::String(updated.base_url.clone()), + ); + guard.raw_map.insert( + "chat_ai.api_key".into(), + Value::String(resolved_key), + ); + guard.raw_map.insert( + "chat_ai.model".into(), + Value::String(updated.model.clone()), ); - guard.raw_map.insert("chat_ai.base_url".into(), Value::String(updated.base_url.clone())); - guard.raw_map.insert("chat_ai.model".into(), Value::String(updated.model.clone())); guard.raw_map.insert( "chat_ai.gguf_model_path".into(), Value::String(updated.gguf_model_path.clone()), @@ -204,7 +229,6 @@ impl SettingsStore { "chat_ai.chat_prompt".into(), Value::String(updated.chat_prompt.clone()), ); - guard.raw_map.remove("chat_ai.api_key"); write_json_map(&self.path, &guard.raw_map)?; guard.chat_ai = updated; @@ -213,9 +237,16 @@ impl SettingsStore { pub fn save_shortcuts(&self, cfg: &ShortcutConfig) -> Result<(), String> { let mut guard = self.cache.write().expect("settings lock poisoned"); - guard.raw_map.insert("shortcut.toggle".into(), Value::String(cfg.toggle.clone())); - guard.raw_map.insert("shortcut.ocr".into(), Value::String(cfg.ocr.clone())); - guard.raw_map.insert("shortcut.ai_dialog".into(), Value::String(cfg.ai_dialog.clone())); + guard + .raw_map + .insert("shortcut.toggle".into(), Value::String(cfg.toggle.clone())); + guard + .raw_map + .insert("shortcut.ocr".into(), Value::String(cfg.ocr.clone())); + guard.raw_map.insert( + "shortcut.ai_dialog".into(), + Value::String(cfg.ai_dialog.clone()), + ); guard.raw_map.insert( "shortcut.selection_translate".into(), Value::String(cfg.selection_translate.clone()), @@ -234,9 +265,10 @@ impl SettingsStore { panel_opacity: AppearanceConfig::clamp_opacity(cfg.panel_opacity), }; let mut guard = self.cache.write().expect("settings lock poisoned"); - guard - .raw_map - .insert("appearance.panel_opacity".into(), Value::from(clamped.panel_opacity)); + guard.raw_map.insert( + "appearance.panel_opacity".into(), + Value::from(clamped.panel_opacity), + ); write_json_map(&self.path, &guard.raw_map)?; guard.appearance = clamped; Ok(()) @@ -257,26 +289,13 @@ impl SettingsStore { } } -fn update_secret_if_needed( - account: &str, - endpoint: &str, - existing: &str, - new_key: &str, - clear: bool, -) -> Result { +fn resolve_api_key(existing: &str, new_key: &str, clear: bool) -> Result { let trimmed = new_key.trim(); validate_field_len("API Key", trimmed, MAX_API_KEY_BYTES)?; if clear { - let _ = keychain::delete_secret(account); return Ok(String::new()); } if !trimmed.is_empty() { - let bind_url = if endpoint.is_empty() { - "local://gguf" - } else { - endpoint - }; - let _ = keychain::set_bound_secret(account, bind_url, trimmed); return Ok(trimmed.to_string()); } Ok(existing.to_string()) @@ -294,7 +313,14 @@ fn write_json_map(path: &Path, map: &Map) -> Result<(), String> { fs::create_dir_all(parent).map_err(|e| format!("创建配置目录失败: {e}"))?; } let bytes = serde_json::to_vec_pretty(map).map_err(|e| format!("序列化配置失败: {e}"))?; - fs::write(path, bytes).map_err(|e| format!("写入配置文件失败: {e}")) + fs::write(path, bytes).map_err(|e| format!("写入配置文件失败: {e}"))?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = fs::set_permissions(path, fs::Permissions::from_mode(0o600)); + } + Ok(()) } fn load_ai_translation(map: &Map) -> AiTranslationConfig { @@ -313,6 +339,10 @@ fn load_ai_translation(map: &Map) -> AiTranslationConfig { cfg.base_url = norm; } } + if let Some(key) = map.get("openai.api_key").and_then(Value::as_str) { + cfg.api_key = key.trim().to_string(); + cfg.has_api_key = !cfg.api_key.is_empty(); + } if let Some(m) = map.get("openai.model").and_then(Value::as_str) { if !m.trim().is_empty() { cfg.model = m.trim().to_string(); @@ -329,10 +359,6 @@ fn load_ai_translation(map: &Map) -> AiTranslationConfig { cfg.translate_prompt = prompt.to_string(); } } - if let Ok(Some(record)) = keychain::get_secret_record(ACCOUNT_OPENAI_KEY) { - cfg.api_key = record.value().to_string(); - cfg.has_api_key = !cfg.api_key.is_empty(); - } cfg } @@ -349,6 +375,10 @@ fn load_chat_ai(map: &Map) -> ChatAiConfig { cfg.base_url = norm; } } + if let Some(key) = map.get("chat_ai.api_key").and_then(Value::as_str) { + cfg.api_key = key.trim().to_string(); + cfg.has_api_key = !cfg.api_key.is_empty(); + } if let Some(m) = map.get("chat_ai.model").and_then(Value::as_str) { if !m.trim().is_empty() { cfg.model = m.trim().to_string(); @@ -363,10 +393,6 @@ fn load_chat_ai(map: &Map) -> ChatAiConfig { if let Some(prompt) = map.get("chat_ai.chat_prompt").and_then(Value::as_str) { cfg.chat_prompt = prompt.to_string(); } - if let Ok(Some(record)) = keychain::get_secret_record(ACCOUNT_CHAT_AI_KEY) { - cfg.api_key = record.value().to_string(); - cfg.has_api_key = !cfg.api_key.is_empty(); - } cfg } @@ -423,20 +449,23 @@ mod tests { use super::*; #[test] - fn persists_and_reloads_settings_via_memory_cache() { + fn persists_and_reloads_settings_and_api_key_in_json() { let tmp = tempfile::NamedTempFile::new().unwrap(); let store = SettingsStore::open_at(tmp.path().to_path_buf()); let mut cfg = store.ai_translation_for_request(); - cfg.backend = AiBackendKind::Ollama; - cfg.base_url = "http://127.0.0.1:11434".to_string(); - cfg.model = "qwen2.5:3b".to_string(); + cfg.backend = AiBackendKind::OpenAiCompatible; + cfg.base_url = "https://api.openai.com/v1".to_string(); + cfg.api_key = "sk-test-json-123".to_string(); + cfg.model = "gpt-4o-mini".to_string(); store.save_ai_translation(&cfg).unwrap(); let reloaded = SettingsStore::open_at(tmp.path().to_path_buf()); let loaded_cfg = reloaded.ai_translation_for_request(); - assert_eq!(loaded_cfg.backend, AiBackendKind::Ollama); - assert_eq!(loaded_cfg.base_url, "http://127.0.0.1:11434"); - assert_eq!(loaded_cfg.model, "qwen2.5:3b"); + assert_eq!(loaded_cfg.backend, AiBackendKind::OpenAiCompatible); + assert_eq!(loaded_cfg.base_url, "https://api.openai.com/v1"); + assert_eq!(loaded_cfg.api_key, "sk-test-json-123"); + assert!(loaded_cfg.has_api_key); + assert_eq!(loaded_cfg.model, "gpt-4o-mini"); } }