refactor(storage): remove macOS Keychain runtime calls and persist config directly in 0600 settings.json
This commit is contained in:
1 parent
0f04fefbcc
commit
2c47dfc79e
9 files changed
+114
-223
No files matched your search
Generated
-4
@@ -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]]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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,4 +1,4 @@
|
||||
//! `ttree-storage` —— 内存级配置缓存(兼容既有 `settings.json` 与 Keychain)与 JSONL 对话历史持久化。
|
||||
//! `ttree-storage` —— 内存级配置缓存(直接持久化至本地 `settings.json`,无 Keychain 弹窗)与 JSONL 对话历史持久化。
|
||||
|
||||
pub mod history;
|
||||
pub mod settings;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user