Files
openless/openless-all/app/src-tauri/src/persistence.rs
T
H-Chris233 13ccf85a48 Store credentials in the system vault
Provider credentials should follow the platform credential-store threat model instead of continuing normal plaintext JSON writes. The vault now stores the existing multi-provider credentials payload in the OS credential store, migrates the legacy JSON file only when the vault has no entry, removes the legacy file after a successful write, and keeps raw credential IPC restricted to the main settings window.

Constraint: User explicitly chose Keychain/Credential Manager/Linux keyring for issue #230.

Constraint: Preserve the existing provider schema and active-provider routing with minimal frontend churn.

Rejected: Keep plaintext JSON as the documented product choice | user rejected this direction.

Rejected: Split every credential field into separate vault entries | larger migration surface and risks losing active provider state.

Confidence: medium

Scope-risk: moderate

Directive: Security-model and storage-strategy choices must be confirmed with the user before implementation.

Tested: cargo check --manifest-path src-tauri/Cargo.toml; npm run -s build; git diff --check; grep for stale plaintext/Keychain fallback wording.

Not-tested: Manual migration on macOS Keychain, Windows Credential Manager, or Linux desktop keyring.

Related: https://github.com/appergb/openless/issues/230
2026-05-06 14:39:40 +08:00

818 lines
28 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Local persistence: history JSON, user preferences JSON, vocab JSON, and
//! platform-backed credentials vault.
//!
//! Storage roots:
//! - macOS: `~/Library/Application Support/OpenLess`
//! - Windows: `%APPDATA%\OpenLess`
//! - Linux: `$XDG_DATA_HOME/OpenLess` or `~/.local/share/OpenLess`
//!
//! Credential storage policy: provider credentials are stored in the OS
//! credential vault (macOS Keychain, Windows Credential Manager, Linux keyring).
//! A legacy plaintext JSON file is read once as a migration source and removed
//! after a successful vault write; new writes never persist plaintext secrets.
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use anyhow::{anyhow, Context, Result};
use chrono::Utc;
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::types::{DictationSession, DictionaryEntry, UserPreferences, VocabPresetStore};
const HISTORY_CAP: usize = 200;
const HISTORY_FILE: &str = "history.json";
const PREFERENCES_FILE: &str = "preferences.json";
/// 与 Swift `Sources/OpenLessPersistence/DictionaryStore.swift` 同名,
/// 让旧版词汇表在升级后无缝继承。**不要**改成 `vocab.json`,会丢用户数据。
const VOCAB_FILE: &str = "dictionary.json";
const VOCAB_PRESETS_FILE: &str = "vocab-presets.json";
/// 旧版 plaintext JSON 凭据路径。仅作为迁移来源;成功写入系统凭据库后会删除。
const LEGACY_CREDS_DIR: &str = ".openless";
const LEGACY_CREDS_FILE: &str = "credentials.json";
const KEYRING_CREDENTIALS_ACCOUNT: &str = "credentials.v1";
static CREDENTIALS_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
fn credentials_lock() -> &'static Mutex<()> {
CREDENTIALS_LOCK.get_or_init(|| Mutex::new(()))
}
// ───────────────────────── path helpers ─────────────────────────
fn data_dir() -> Result<PathBuf> {
#[cfg(target_os = "macos")]
{
let home = std::env::var("HOME").context("HOME not set")?;
Ok(PathBuf::from(home)
.join("Library")
.join("Application Support")
.join("OpenLess"))
}
#[cfg(target_os = "windows")]
{
let appdata = std::env::var("APPDATA").context("APPDATA not set")?;
Ok(PathBuf::from(appdata).join("OpenLess"))
}
#[cfg(all(unix, not(target_os = "macos")))]
{
if let Ok(xdg) = std::env::var("XDG_DATA_HOME") {
if !xdg.is_empty() {
return Ok(PathBuf::from(xdg).join("OpenLess"));
}
}
let home = std::env::var("HOME").context("HOME not set")?;
Ok(PathBuf::from(home)
.join(".local")
.join("share")
.join("OpenLess"))
}
}
fn ensure_dir(dir: &Path) -> Result<()> {
fs::create_dir_all(dir).with_context(|| format!("create dir failed: {}", dir.display()))?;
Ok(())
}
/// 本地 ASR 模型根目录:`<data_dir>/models/qwen3-asr/`。
/// 子目录 = 模型 id(如 `qwen3-asr-0.6b`),存 antirez `download_model.sh`
/// 列出的 5–7 个文件。
pub fn local_models_root() -> Result<PathBuf> {
let dir = data_dir()?.join("models").join("qwen3-asr");
ensure_dir(&dir)?;
Ok(dir)
}
/// Atomic write: write to `*.tmp` first, then rename onto the target path.
fn atomic_write(path: &Path, contents: &[u8]) -> Result<()> {
if let Some(parent) = path.parent() {
ensure_dir(parent)?;
}
let tmp_path = path.with_extension("tmp");
fs::write(&tmp_path, contents)
.with_context(|| format!("write tmp failed: {}", tmp_path.display()))?;
fs::rename(&tmp_path, path).with_context(|| format!("rename failed: {}", path.display()))?;
Ok(())
}
fn read_or_default<T: for<'de> Deserialize<'de> + Default>(path: &Path) -> Result<T> {
if !path.exists() {
return Ok(T::default());
}
let bytes = fs::read(path).with_context(|| format!("read failed: {}", path.display()))?;
if bytes.is_empty() {
return Ok(T::default());
}
serde_json::from_slice::<T>(&bytes)
.with_context(|| format!("decode failed: {}", path.display()))
}
// ───────────────────────── credentials vault ─────────────────────────
//
// 正常读写走系统凭据库;旧 plaintext JSON 只作为迁移来源。为保持多 provider
// schema 与 active provider 状态,凭据库里保存一个 v1 JSON payload,而不是逐字段散落。
//
// v1 schema:
// {
// "version": 1,
// "active": { "asr": "<id>", "llm": "<id>" },
// "providers": {
// "asr": { "<id>": { "appKey", "accessKey", "resourceId", "apiKey", "baseURL", "model" } },
// "llm": { "<id>": { "displayName", "apiKey", "baseURL", "model", "temperature", "extraHeaders" } }
// }
// }
//
// "ark.api_key"/"volcengine.app_key" 等账户名按 Swift 语义路由到 active provider。
use std::collections::HashMap;
#[derive(Debug, Serialize, Deserialize, Default, Clone)]
#[allow(non_snake_case)]
struct CredsRoot {
#[serde(default = "credsroot_default_version")]
version: u32,
#[serde(default)]
active: CredsActive,
#[serde(default)]
providers: CredsProviders,
}
fn credsroot_default_version() -> u32 {
1
}
#[derive(Debug, Serialize, Deserialize, Clone)]
struct CredsActive {
#[serde(default = "creds_default_asr")]
asr: String,
#[serde(default = "creds_default_llm")]
llm: String,
}
impl Default for CredsActive {
fn default() -> Self {
Self {
asr: creds_default_asr(),
llm: creds_default_llm(),
}
}
}
fn creds_default_asr() -> String {
"volcengine".into()
}
fn creds_default_llm() -> String {
"ark".into()
}
#[derive(Debug, Serialize, Deserialize, Default, Clone)]
struct CredsProviders {
#[serde(default)]
asr: HashMap<String, CredsAsrEntry>,
#[serde(default)]
llm: HashMap<String, CredsLlmEntry>,
}
#[derive(Debug, Serialize, Deserialize, Default, Clone)]
#[allow(non_snake_case)]
struct CredsAsrEntry {
#[serde(skip_serializing_if = "Option::is_none")]
apiKey: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
baseURL: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
model: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
appKey: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
accessKey: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
resourceId: Option<String>,
}
impl CredsAsrEntry {
fn is_empty(&self) -> bool {
self.apiKey.as_deref().unwrap_or("").is_empty()
&& self.baseURL.as_deref().unwrap_or("").is_empty()
&& self.model.as_deref().unwrap_or("").is_empty()
&& self.appKey.as_deref().unwrap_or("").is_empty()
&& self.accessKey.as_deref().unwrap_or("").is_empty()
&& self.resourceId.as_deref().unwrap_or("").is_empty()
}
}
#[derive(Debug, Serialize, Deserialize, Default, Clone)]
#[allow(non_snake_case)]
struct CredsLlmEntry {
#[serde(skip_serializing_if = "Option::is_none")]
displayName: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
apiKey: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
baseURL: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
model: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
temperature: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
extraHeaders: Option<HashMap<String, String>>,
}
impl CredsLlmEntry {
fn is_empty(&self) -> bool {
self.displayName.as_deref().unwrap_or("").is_empty()
&& self.apiKey.as_deref().unwrap_or("").is_empty()
&& self.baseURL.as_deref().unwrap_or("").is_empty()
&& self.model.as_deref().unwrap_or("").is_empty()
&& self.temperature.is_none()
&& self
.extraHeaders
.as_ref()
.map(|h| h.is_empty())
.unwrap_or(true)
}
}
fn credentials_path() -> Result<PathBuf> {
// macOS / Linux: ~/.openless/credentials.json (与 Swift 同源)
// Windows: %APPDATA%\OpenLess\credentials.json (Windows 没有标准 HOME 环境变量)
#[cfg(target_os = "windows")]
{
let appdata = std::env::var("APPDATA").context("APPDATA not set")?;
return Ok(PathBuf::from(appdata)
.join("OpenLess")
.join(LEGACY_CREDS_FILE));
}
#[cfg(not(target_os = "windows"))]
{
let home = std::env::var("HOME").context("HOME not set")?;
Ok(PathBuf::from(home)
.join(LEGACY_CREDS_DIR)
.join(LEGACY_CREDS_FILE))
}
}
fn keyring_entry() -> Result<keyring::Entry> {
keyring::Entry::new(CredentialsVault::SERVICE_NAME, KEYRING_CREDENTIALS_ACCOUNT)
.context("open system credential vault")
}
fn clean_credentials(root: &CredsRoot) -> CredsRoot {
let mut cleaned = root.clone();
cleaned.providers.asr.retain(|_, v| !v.is_empty());
cleaned.providers.llm.retain(|_, v| !v.is_empty());
cleaned
}
fn read_legacy_credentials_file(path: &Path) -> Option<CredsRoot> {
if !path.exists() {
return None;
}
let bytes = match fs::read(path) {
Ok(b) => b,
Err(e) => {
log::warn!("[vault] read legacy {} failed: {}", path.display(), e);
return None;
}
};
match serde_json::from_slice::<CredsRoot>(&bytes) {
Ok(root) => Some(root),
Err(e) => {
log::warn!("[vault] parse legacy {} failed: {}", path.display(), e);
None
}
}
}
fn remove_legacy_credentials_file() {
let Ok(path) = credentials_path() else { return };
if path.exists() {
if let Err(e) = fs::remove_file(&path) {
log::warn!("[vault] remove legacy {} failed: {}", path.display(), e);
}
}
}
fn load_keyring_credentials() -> Result<Option<CredsRoot>> {
let entry = keyring_entry()?;
let json = match entry.get_password() {
Ok(json) => json,
Err(keyring::Error::NoEntry) => return Ok(None),
Err(e) => return Err(anyhow!(e)).context("read system credential vault"),
};
serde_json::from_str::<CredsRoot>(&json)
.map(Some)
.context("decode system credential vault payload")
}
fn load_credentials() -> CredsRoot {
match load_keyring_credentials() {
Ok(Some(root)) => return root,
Ok(None) => {}
Err(e) => {
log::warn!("[vault] system credential read failed: {e}");
return CredsRoot::default();
}
}
let Some(legacy) = credentials_path().ok().and_then(|p| read_legacy_credentials_file(&p)) else {
return CredsRoot::default();
};
if let Err(e) = save_credentials(&legacy) {
log::warn!("[vault] legacy credential migration failed: {e}");
return CredsRoot::default();
}
legacy
}
fn save_credentials(root: &CredsRoot) -> Result<()> {
let cleaned = clean_credentials(root);
let json = serde_json::to_string(&cleaned).context("encode credentials failed")?;
keyring_entry()?
.set_password(&json)
.context("write system credential vault")?;
remove_legacy_credentials_file();
Ok(())
}
fn lookup_account(root: &CredsRoot, account: CredentialAccount) -> Option<String> {
let asr = root.providers.asr.get(&root.active.asr);
let llm = root.providers.llm.get(&root.active.llm);
let pick = |s: &Option<String>| s.as_ref().filter(|v| !v.is_empty()).cloned();
match account {
CredentialAccount::VolcengineAppKey => {
asr.and_then(|e| pick(&e.appKey).or_else(|| pick(&e.apiKey)))
}
CredentialAccount::VolcengineAccessKey => asr.and_then(|e| pick(&e.accessKey)),
CredentialAccount::VolcengineResourceId => asr.and_then(|e| pick(&e.resourceId)),
CredentialAccount::ArkApiKey => llm.and_then(|e| pick(&e.apiKey)),
CredentialAccount::ArkModelId => llm.and_then(|e| pick(&e.model)),
CredentialAccount::ArkEndpoint => llm.and_then(|e| pick(&e.baseURL)),
CredentialAccount::AsrApiKey => asr.and_then(|e| pick(&e.apiKey)),
CredentialAccount::AsrEndpoint => asr.and_then(|e| pick(&e.baseURL)),
CredentialAccount::AsrModel => asr.and_then(|e| pick(&e.model)),
}
}
fn write_account(root: &mut CredsRoot, account: CredentialAccount, value: Option<String>) {
let asr_id = root.active.asr.clone();
let llm_id = root.active.llm.clone();
let normalized = value.and_then(|v| if v.is_empty() { None } else { Some(v) });
match account {
CredentialAccount::VolcengineAppKey => {
let entry = root.providers.asr.entry(asr_id).or_default();
entry.appKey = normalized;
}
CredentialAccount::VolcengineAccessKey => {
let entry = root.providers.asr.entry(asr_id).or_default();
entry.accessKey = normalized;
}
CredentialAccount::VolcengineResourceId => {
let entry = root.providers.asr.entry(asr_id).or_default();
entry.resourceId = normalized;
}
CredentialAccount::ArkApiKey => {
let entry = root.providers.llm.entry(llm_id).or_default();
entry.apiKey = normalized;
}
CredentialAccount::ArkModelId => {
let entry = root.providers.llm.entry(llm_id).or_default();
entry.model = normalized;
}
CredentialAccount::ArkEndpoint => {
let entry = root.providers.llm.entry(llm_id).or_default();
entry.baseURL = normalized;
}
CredentialAccount::AsrApiKey => {
let entry = root.providers.asr.entry(asr_id).or_default();
entry.apiKey = normalized;
}
CredentialAccount::AsrEndpoint => {
let entry = root.providers.asr.entry(asr_id).or_default();
entry.baseURL = normalized;
}
CredentialAccount::AsrModel => {
let entry = root.providers.asr.entry(asr_id).or_default();
entry.model = normalized;
}
}
}
// ───────────────────────── HistoryStore ─────────────────────────
pub struct HistoryStore {
path: PathBuf,
lock: Mutex<()>,
}
impl HistoryStore {
pub fn new() -> Result<Self> {
let dir = data_dir()?;
ensure_dir(&dir)?;
Ok(Self {
path: dir.join(HISTORY_FILE),
lock: Mutex::new(()),
})
}
pub fn list(&self) -> Result<Vec<DictationSession>> {
let _guard = self.lock.lock();
self.read_locked()
}
pub fn append(&self, session: DictationSession) -> Result<()> {
let _guard = self.lock.lock();
let mut sessions = self.read_locked()?;
// Prepend so the newest session is at index 0, matching the Swift impl.
sessions.insert(0, session);
if sessions.len() > HISTORY_CAP {
sessions.truncate(HISTORY_CAP);
}
self.write_locked(&sessions)
}
pub fn delete(&self, id: &str) -> Result<()> {
let _guard = self.lock.lock();
let mut sessions = self.read_locked()?;
let original_len = sessions.len();
sessions.retain(|s| s.id != id);
if sessions.len() == original_len {
return Ok(());
}
self.write_locked(&sessions)
}
pub fn clear(&self) -> Result<()> {
let _guard = self.lock.lock();
self.write_locked(&Vec::<DictationSession>::new())
}
fn read_locked(&self) -> Result<Vec<DictationSession>> {
read_or_default::<Vec<DictationSession>>(&self.path)
}
fn write_locked(&self, sessions: &[DictationSession]) -> Result<()> {
let json = serde_json::to_vec_pretty(sessions).context("encode history failed")?;
atomic_write(&self.path, &json)
}
}
// ───────────────────────── PreferencesStore ─────────────────────────
pub struct PreferencesStore {
path: PathBuf,
state: Mutex<UserPreferences>,
}
impl PreferencesStore {
pub fn new() -> Result<Self> {
let dir = data_dir()?;
ensure_dir(&dir)?;
let path = dir.join(PREFERENCES_FILE);
let prefs = if path.exists() {
read_or_default::<UserPreferences>(&path).unwrap_or_default()
} else {
UserPreferences::default()
};
Ok(Self {
path,
state: Mutex::new(prefs),
})
}
pub fn get(&self) -> UserPreferences {
self.state.lock().clone()
}
pub fn set(&self, prefs: UserPreferences) -> Result<()> {
let json = serde_json::to_vec_pretty(&prefs).context("encode prefs failed")?;
atomic_write(&self.path, &json)?;
let mut guard = self.state.lock();
*guard = prefs;
Ok(())
}
}
// ───────────────────────── DictionaryStore ─────────────────────────
pub struct DictionaryStore {
path: PathBuf,
lock: Mutex<()>,
}
impl DictionaryStore {
pub fn new() -> Result<Self> {
let dir = data_dir()?;
ensure_dir(&dir)?;
Ok(Self {
path: dir.join(VOCAB_FILE),
lock: Mutex::new(()),
})
}
pub fn list(&self) -> Result<Vec<DictionaryEntry>> {
let _guard = self.lock.lock();
self.read_locked()
}
pub fn add(&self, phrase: String, note: Option<String>) -> Result<DictionaryEntry> {
let _guard = self.lock.lock();
let mut entries = self.read_locked()?;
let entry = DictionaryEntry {
id: Uuid::new_v4().to_string(),
phrase,
note,
enabled: true,
hits: 0,
created_at: Utc::now().to_rfc3339(),
};
entries.insert(0, entry.clone());
self.write_locked(&entries)?;
Ok(entry)
}
pub fn remove(&self, id: &str) -> Result<()> {
let _guard = self.lock.lock();
let mut entries = self.read_locked()?;
let before = entries.len();
entries.retain(|e| e.id != id);
if entries.len() == before {
return Ok(());
}
self.write_locked(&entries)
}
pub fn set_enabled(&self, id: &str, enabled: bool) -> Result<()> {
let _guard = self.lock.lock();
let mut entries = self.read_locked()?;
let mut found = false;
for entry in entries.iter_mut() {
if entry.id == id {
entry.enabled = enabled;
found = true;
break;
}
}
if !found {
return Err(anyhow!("dictionary entry {} not found", id));
}
self.write_locked(&entries)
}
/// 扫描一段最终文本,对每个 enabled 词条按出现次数累加 `hits`。
///
/// 匹配是大小写不敏感的子串扫描:「Hello hello HELLO」算 3 次。
/// 返回本次累加的总命中数,方便调用方记录到 history.dictionary_entry_count。
pub fn record_hits(&self, text: &str) -> Result<u64> {
if text.is_empty() {
return Ok(0);
}
let _guard = self.lock.lock();
let mut entries = self.read_locked()?;
if entries.is_empty() {
return Ok(0);
}
let haystack = text.to_lowercase();
let mut total: u64 = 0;
let mut changed = false;
for entry in entries.iter_mut() {
if !entry.enabled {
continue;
}
let needle = entry.phrase.trim().to_lowercase();
if needle.is_empty() {
continue;
}
let count = count_occurrences(&haystack, &needle);
if count > 0 {
entry.hits = entry.hits.saturating_add(count);
total = total.saturating_add(count);
changed = true;
}
}
if changed {
self.write_locked(&entries)?;
}
Ok(total)
}
fn read_locked(&self) -> Result<Vec<DictionaryEntry>> {
read_or_default::<Vec<DictionaryEntry>>(&self.path)
}
fn write_locked(&self, entries: &[DictionaryEntry]) -> Result<()> {
let json = serde_json::to_vec_pretty(entries).context("encode vocab failed")?;
atomic_write(&self.path, &json)
}
}
/// 统计 `needle` 在 `haystack` 中的非重叠出现次数。两侧调用前都应已转小写。
fn count_occurrences(haystack: &str, needle: &str) -> u64 {
if needle.is_empty() || haystack.len() < needle.len() {
return 0;
}
let mut count: u64 = 0;
let mut start = 0usize;
while let Some(pos) = haystack[start..].find(needle) {
count = count.saturating_add(1);
start = start + pos + needle.len();
if start >= haystack.len() {
break;
}
}
count
}
pub fn list_vocab_presets() -> Result<VocabPresetStore> {
let dir = data_dir()?;
ensure_dir(&dir)?;
read_or_default::<VocabPresetStore>(&dir.join(VOCAB_PRESETS_FILE))
}
pub fn save_vocab_presets(store: &VocabPresetStore) -> Result<()> {
let dir = data_dir()?;
ensure_dir(&dir)?;
let path = dir.join(VOCAB_PRESETS_FILE);
let json = serde_json::to_vec_pretty(store).context("encode vocab presets failed")?;
atomic_write(&path, &json)
}
// ───────────────────────── CredentialsVault ─────────────────────────
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum CredentialAccount {
VolcengineAppKey,
VolcengineAccessKey,
VolcengineResourceId,
ArkApiKey,
ArkModelId,
ArkEndpoint,
/// Active ASR provider's API key (used by Whisper-compatible providers).
AsrApiKey,
/// Active ASR provider's base URL.
AsrEndpoint,
/// Active ASR provider's model name.
AsrModel,
}
impl CredentialAccount {
/// Account names match the Swift `CredentialAccount` constants exactly so
/// existing Keychain entries written by the macOS Swift app remain
/// readable after upgrade.
pub fn keyring_account(&self) -> &'static str {
match self {
CredentialAccount::VolcengineAppKey => "volcengine.app_key",
CredentialAccount::VolcengineAccessKey => "volcengine.access_key",
CredentialAccount::VolcengineResourceId => "volcengine.resource_id",
CredentialAccount::ArkApiKey => "ark.api_key",
CredentialAccount::ArkModelId => "ark.model_id",
CredentialAccount::ArkEndpoint => "ark.endpoint",
CredentialAccount::AsrApiKey => "asr.api_key",
CredentialAccount::AsrEndpoint => "asr.endpoint",
CredentialAccount::AsrModel => "asr.model",
}
}
pub fn all() -> &'static [CredentialAccount] {
&[
CredentialAccount::VolcengineAppKey,
CredentialAccount::VolcengineAccessKey,
CredentialAccount::VolcengineResourceId,
CredentialAccount::ArkApiKey,
CredentialAccount::ArkModelId,
CredentialAccount::ArkEndpoint,
CredentialAccount::AsrApiKey,
CredentialAccount::AsrEndpoint,
CredentialAccount::AsrModel,
]
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CredentialsSnapshot {
pub volcengine_app_key: Option<String>,
pub volcengine_access_key: Option<String>,
pub volcengine_resource_id: Option<String>,
pub asr_api_key: Option<String>,
pub asr_endpoint: Option<String>,
pub asr_model: Option<String>,
pub ark_api_key: Option<String>,
pub ark_model_id: Option<String>,
pub ark_endpoint: Option<String>,
}
/// 凭据存储——系统凭据库;旧 JSON 文件只作为迁移来源。
pub struct CredentialsVault;
impl CredentialsVault {
/// 系统凭据库 service name;macOS 下对应 Keychain service。
pub const SERVICE_NAME: &'static str = "com.openless.app";
pub fn get(account: CredentialAccount) -> Result<Option<String>> {
let _guard = credentials_lock().lock();
Ok(lookup_account(&load_credentials(), account))
}
pub fn set(account: CredentialAccount, value: &str) -> Result<()> {
let _guard = credentials_lock().lock();
let mut root = load_credentials();
let v = if value.is_empty() {
None
} else {
Some(value.to_string())
};
write_account(&mut root, account, v);
save_credentials(&root)
}
pub fn remove(account: CredentialAccount) -> Result<()> {
let _guard = credentials_lock().lock();
let mut root = load_credentials();
write_account(&mut root, account, None);
save_credentials(&root)
}
pub fn get_active_asr() -> String {
let _guard = credentials_lock().lock();
load_credentials().active.asr
}
pub fn set_active_asr_provider(id: &str) -> Result<()> {
let _guard = credentials_lock().lock();
let mut root = load_credentials();
root.active.asr = id.to_string();
save_credentials(&root)
}
pub fn set_active_llm_provider(id: &str) -> Result<()> {
let _guard = credentials_lock().lock();
let mut root = load_credentials();
root.active.llm = id.to_string();
save_credentials(&root)
}
pub fn get_active_llm() -> String {
let _guard = credentials_lock().lock();
load_credentials().active.llm
}
pub fn snapshot() -> CredentialsSnapshot {
let _guard = credentials_lock().lock();
let root = load_credentials();
CredentialsSnapshot {
volcengine_app_key: lookup_account(&root, CredentialAccount::VolcengineAppKey),
volcengine_access_key: lookup_account(&root, CredentialAccount::VolcengineAccessKey),
volcengine_resource_id: lookup_account(&root, CredentialAccount::VolcengineResourceId),
asr_api_key: lookup_account(&root, CredentialAccount::AsrApiKey),
asr_endpoint: lookup_account(&root, CredentialAccount::AsrEndpoint),
asr_model: lookup_account(&root, CredentialAccount::AsrModel),
ark_api_key: lookup_account(&root, CredentialAccount::ArkApiKey),
ark_model_id: lookup_account(&root, CredentialAccount::ArkModelId),
ark_endpoint: lookup_account(&root, CredentialAccount::ArkEndpoint),
}
}
}
#[cfg(test)]
mod tests {
use super::{list_vocab_presets, save_vocab_presets};
use crate::types::{VocabPreset, VocabPresetStore};
use std::fs;
use std::path::PathBuf;
#[test]
fn vocab_presets_roundtrip_json_file() {
let tmp: PathBuf = std::env::temp_dir().join(format!("openless-test-{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&tmp).expect("create temp dir");
// Linux path helper uses XDG_DATA_HOME first.
unsafe {
std::env::set_var("XDG_DATA_HOME", &tmp);
}
let store = VocabPresetStore {
custom: vec![VocabPreset {
id: "test".into(),
name: "测试".into(),
phrases: vec!["PR".into(), "CI".into()],
}],
overrides: vec![],
disabled_builtin_preset_ids: vec!["chef".into()],
};
save_vocab_presets(&store).expect("save presets");
let loaded = list_vocab_presets().expect("list presets");
assert_eq!(loaded.custom.len(), 1);
assert_eq!(loaded.custom[0].id, "test");
assert_eq!(loaded.custom[0].phrases, vec!["PR".to_string(), "CI".to_string()]);
assert_eq!(loaded.disabled_builtin_preset_ids, vec!["chef".to_string()]);
let _ = fs::remove_dir_all(&tmp);
}
}