refactor(storage): remove macOS Keychain runtime calls and persist config directly in 0600 settings.json

This commit is contained in:
ZASENJC committed 2026-10-14 19:17:38 +08:00
1 parent 0f04fefbcc
commit 2c47dfc79e
9 files changed
+114 -223

No files matched your search

Generated
-4
View File
@@ -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]]
-1
View File
@@ -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",
+2 -2
View File
@@ -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,
-3
View File
@@ -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
-127
View File
@@ -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<String, String> {
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<SecretRecord, String> {
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<Option<SecretRecord>, 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<Option<String>, 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<Option<String>, 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");
}
}
-2
View File
@@ -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;
-1
View File
@@ -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
+1 -1
View File
@@ -1,4 +1,4 @@
//! `ttree-storage` —— 内存级配置缓存(兼容既有 `settings.json` 与 Keychain)与 JSONL 对话历史持久化。
//! `ttree-storage` —— 内存级配置缓存(直接持久化至本地 `settings.json`,无 Keychain 弹窗)与 JSONL 对话历史持久化。
pub mod history;
pub mod settings;
+111 -82
View File
@@ -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<String, String> {
fn resolve_api_key(existing: &str, new_key: &str, clear: bool) -> Result<String, String> {
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<String, Value>) -> 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<String, Value>) -> AiTranslationConfig {
@@ -313,6 +339,10 @@ fn load_ai_translation(map: &Map<String, Value>) -> 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<String, Value>) -> 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<String, Value>) -> 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<String, Value>) -> 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");
}
}