diff --git a/openless-all/app/src-tauri/src/persistence.rs b/openless-all/app/src-tauri/src/persistence.rs deleted file mode 100644 index ebea8884..00000000 --- a/openless-all/app/src-tauri/src/persistence.rs +++ /dev/null @@ -1,2757 +0,0 @@ -#![cfg_attr(target_os = "linux", allow(dead_code, unused_variables))] -//! 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::io::{Read, Write}; -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::{ - builtin_style_pack_for_mode, builtin_style_pack_id, builtin_style_packs, - default_active_style_pack_id, CorrectionRule, CustomStylePrompts, DictationSession, - DictionaryEntry, PolishMode, StylePack, StylePackExample, StylePackKind, UserPreferences, - VocabPresetStore, BUILTIN_STYLE_PACK_LIGHT_ID, -}; - -const HISTORY_CAP: usize = 200; -const HISTORY_FILE: &str = "history.json"; -const PREFERENCES_FILE: &str = "preferences.json"; -const STYLE_PACKS_FILE: &str = "style-packs.json"; -const STYLE_PACK_ASSETS_DIR: &str = "style-pack-assets"; -/// 与 Swift `Sources/OpenLessPersistence/DictionaryStore.swift` 同名, -/// 让旧版词汇表在升级后无缝继承。**不要**改成 `vocab.json`,会丢用户数据。 -const VOCAB_FILE: &str = "dictionary.json"; -const CORRECTION_RULES_FILE: &str = "correction-rules.json"; -const CORRECTION_NUM_TOKEN: &str = "{num}"; -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"; -const KEYRING_CREDENTIALS_CHUNK_PREFIX: &str = "credentials.v1.chunk."; -#[cfg(target_os = "android")] -const ANDROID_CREDENTIALS_FILE: &str = "credentials.enc.json"; -// Windows Credential Manager caps one credential blob at 2560 bytes. keyring stores -// passwords as UTF-16 on Windows, so keep each JSON chunk comfortably below that. -const KEYRING_CHUNK_MAX_UTF16_UNITS: usize = 1000; - -static CREDENTIALS_LOCK: OnceLock> = OnceLock::new(); - -fn credentials_lock() -> &'static Mutex<()> { - CREDENTIALS_LOCK.get_or_init(|| Mutex::new(())) -} - -/// Process-wide credentials cache. -/// -/// Without this cache every `CredentialsVault::get_*` / `snapshot` call hits -/// `load_credentials()` → `load_keyring_credentials()` which reads the -/// manifest entry plus every chunk entry from the OS keyring. On macOS each -/// distinct keychain entry has its own ACL — so an ad-hoc-signed binary (or -/// any binary whose ACL grants haven't been set up yet) prompts on every read -/// of every entry. A single dictation cycle reads credentials 5–10 times, -/// times (1 manifest + N chunks) entries → tens of "OpenLess wants to use -/// the keychain" prompts per recording. -/// -/// With this cache the first read populates `Some(CredsRoot)` and every -/// subsequent read in the same process is silent. `save_credentials` keeps -/// the cache in sync after writes so Settings → Recording credential edits -/// take effect immediately. -/// -/// Cross-process changes (e.g. user edits via `security` CLI, or another -/// instance of the app — single-instance is enforced but defense in depth) -/// will be invisible until the next process launch. Acceptable trade-off -/// per the credential vault contract: the keyring is owned by this app. -static CREDENTIALS_CACHE: OnceLock>> = OnceLock::new(); - -fn credentials_cache() -> &'static Mutex> { - CREDENTIALS_CACHE.get_or_init(|| Mutex::new(None)) -} - -fn store_credentials_cache(root: &CredsRoot) { - *credentials_cache().lock() = Some(root.clone()); -} - -#[cfg(test)] -fn reset_credentials_cache_for_tests() { - *credentials_cache().lock() = None; -} - -// ───────────────────────── path helpers ───────────────────────── - -fn data_dir() -> Result { - #[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"), not(target_os = "android")))] - { - 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")) - } - - #[cfg(target_os = "android")] - { - if let Ok(dir) = std::env::var("TAURI_ANDROID_APP_DATA_DIR") { - return Ok(PathBuf::from(dir).join("OpenLess")); - } - Ok(std::env::temp_dir().join("OpenLess")) - } -} - -fn ensure_dir(dir: &Path) -> Result<()> { - fs::create_dir_all(dir).with_context(|| format!("create dir failed: {}", dir.display()))?; - Ok(()) -} - -/// 默认模型根目录:`/models/`。 -pub fn default_models_root() -> Result { - let dir = data_dir()?.join("models"); - ensure_dir(&dir)?; - Ok(dir) -} - -/// 把用户选择的父目录转成实际模型根目录。 -/// -/// UI 让用户选一个普通目录;OpenLess 固定在其下创建 `OpenLess/models/`, -/// 避免把多个引擎的模型文件直接散落在用户选择目录根部。 -pub fn models_root_for_base_dir(base_dir: Option<&str>) -> Result { - let trimmed = base_dir.map(str::trim).filter(|value| !value.is_empty()); - let dir = match trimmed { - Some(base) => PathBuf::from(base).join("OpenLess").join("models"), - None => return default_models_root(), - }; - ensure_dir(&dir)?; - Ok(dir) -} - -fn configured_models_base_dir() -> Result> { - let path = data_dir()?.join(PREFERENCES_FILE); - if !path.exists() { - return Ok(None); - } - let bytes = fs::read(&path).with_context(|| format!("read failed: {}", path.display()))?; - if bytes.is_empty() { - return Ok(None); - } - let value = serde_json::from_slice::(&bytes) - .with_context(|| format!("decode failed: {}", path.display()))?; - Ok(value - .get("localAsrModelsBaseDir") - .and_then(|value| value.as_str()) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string)) -} - -/// 当前配置下的实际模型根目录。 -pub fn models_root() -> Result { - models_root_for_base_dir(configured_models_base_dir()?.as_deref()) -} - -/// 校验用户选择的父目录,并返回实际模型根目录。 -pub fn validate_models_base_dir(base_dir: Option<&str>) -> Result { - let root = models_root_for_base_dir(base_dir)?; - let probe = root.join(format!(".openless-write-test-{}", Uuid::new_v4().simple())); - fs::write(&probe, b"ok").with_context(|| format!("write probe failed: {}", probe.display()))?; - fs::remove_file(&probe).with_context(|| format!("remove probe failed: {}", probe.display()))?; - Ok(root) -} - -/// 把旧模型根目录合并迁移到新模型根目录。目标已有内容优先,不覆盖。 -pub fn migrate_models_root(old_root: &Path, new_root: &Path) -> Result<()> { - ensure_dir(new_root)?; - if same_existing_path(old_root, new_root) || !old_root.exists() { - return Ok(()); - } - for entry in fs::read_dir(old_root).with_context(|| format!("read {}", old_root.display()))? { - let entry = entry?; - merge_move_no_overwrite(&entry.path(), &new_root.join(entry.file_name()))?; - } - remove_dir_if_empty(old_root)?; - Ok(()) -} - -fn same_existing_path(left: &Path, right: &Path) -> bool { - match (left.canonicalize(), right.canonicalize()) { - (Ok(left), Ok(right)) => left == right, - _ => false, - } -} - -fn merge_move_no_overwrite(src: &Path, dest: &Path) -> Result<()> { - if !src.exists() { - return Ok(()); - } - if !dest.exists() { - if let Some(parent) = dest.parent() { - ensure_dir(parent)?; - } - return rename_or_copy_remove(src, dest); - } - if src.is_dir() && dest.is_dir() { - for entry in fs::read_dir(src).with_context(|| format!("read {}", src.display()))? { - let entry = entry?; - merge_move_no_overwrite(&entry.path(), &dest.join(entry.file_name()))?; - } - remove_dir_if_empty(src)?; - } - Ok(()) -} - -fn rename_or_copy_remove(src: &Path, dest: &Path) -> Result<()> { - match fs::rename(src, dest) { - Ok(()) => Ok(()), - Err(_) if src.is_dir() => { - copy_dir_no_overwrite(src, dest)?; - fs::remove_dir_all(src).with_context(|| format!("remove {}", src.display()))?; - Ok(()) - } - Err(_) => { - fs::copy(src, dest) - .with_context(|| format!("copy {} to {}", src.display(), dest.display()))?; - fs::remove_file(src).with_context(|| format!("remove {}", src.display()))?; - Ok(()) - } - } -} - -fn copy_dir_no_overwrite(src: &Path, dest: &Path) -> Result<()> { - ensure_dir(dest)?; - for entry in fs::read_dir(src).with_context(|| format!("read {}", src.display()))? { - let entry = entry?; - let src_path = entry.path(); - let dest_path = dest.join(entry.file_name()); - if dest_path.exists() { - continue; - } - if src_path.is_dir() { - copy_dir_no_overwrite(&src_path, &dest_path)?; - } else { - fs::copy(&src_path, &dest_path).with_context(|| { - format!("copy {} to {}", src_path.display(), dest_path.display()) - })?; - } - } - Ok(()) -} - -fn remove_dir_if_empty(path: &Path) -> Result<()> { - match fs::read_dir(path) { - Ok(entries) => { - if entries.count() == 0 { - fs::remove_dir(path).with_context(|| format!("remove {}", path.display()))?; - } - } - _ => {} - } - Ok(()) -} - -/// 本地 ASR 模型根目录:`/qwen3-asr/`。 -/// 子目录 = 模型 id(如 `qwen3-asr-0.6b`),存 qwen-asr `download_model.sh` -/// 列出的 5–7 个文件。 -pub fn local_models_root() -> Result { - let dir = models_root()?.join("qwen3-asr"); - ensure_dir(&dir)?; - Ok(dir) -} - -/// 录音归档目录:`/recordings/`。 -/// 仅当用户开 `prefs.record_audio_for_debug` 时才会有内容(每次会话一个 `.wav`)。 -/// 同样受 `history_retention_days` 清理(写入新文件时顺手裁旧的)。 -pub fn recordings_root() -> Result { - let dir = data_dir()?.join("recordings"); - ensure_dir(&dir)?; - Ok(dir) -} - -/// 双重 cap 清理 `recordings/*.wav`: -/// - `retention_days > 0` → 把超过 N 天的删掉(沿用 history 的 retention 逻辑)。 -/// - `max_entries == Some(n)` → 按 mtime 倒序保留最新的 n 条(clamp 到 1..=HISTORY_CAP); -/// `None` 时退回 HISTORY_CAP (200) 硬上限,避免无限增长。 -/// 调用方:每次新建一条录音前。失败仅打 warn,避免影响主路径。 -pub fn prune_recordings(retention_days: u32, max_entries: Option) -> Result<()> { - let dir = match data_dir() { - Ok(d) => d.join("recordings"), - Err(_) => return Ok(()), - }; - if !dir.exists() { - return Ok(()); - } - - // 第一步:按天清理。仅扫 .wav,跟第二步保持一致;metadata 读不到的文件按"过期"处理 - // —— fs 损坏 / 未来格式不一致的孤儿文件应当被回收而不是无限累积。 - if retention_days > 0 { - let cutoff = std::time::SystemTime::now() - - std::time::Duration::from_secs(u64::from(retention_days) * 24 * 3600); - for entry in fs::read_dir(&dir).context("read recordings dir")?.flatten() { - let path = entry.path(); - if path.extension().and_then(|ext| ext.to_str()) != Some("wav") { - continue; - } - let modified = entry - .metadata() - .ok() - .and_then(|m| m.modified().ok()) - .unwrap_or(std::time::UNIX_EPOCH); - if modified < cutoff { - if let Err(err) = fs::remove_file(&path) { - log::warn!("[recordings] prune (days) remove failed for {path:?}: {err}"); - } - } - } - } - - // 第二步:按条数清理。剩下的 wav 按 mtime 倒序,超出 cap 的删掉。 - let cap = max_entries - .map(|n| (n as usize).clamp(1, HISTORY_CAP)) - .unwrap_or(HISTORY_CAP); - let mut entries: Vec<(PathBuf, std::time::SystemTime)> = fs::read_dir(&dir) - .context("read recordings dir")? - .flatten() - .filter_map(|e| { - let path = e.path(); - // 只看 .wav,避免误删未来其他类型的归档文件。 - if path.extension().and_then(|ext| ext.to_str()) != Some("wav") { - return None; - } - let modified = e.metadata().ok()?.modified().ok()?; - Some((path, modified)) - }) - .collect(); - if entries.len() <= cap { - return Ok(()); - } - entries.sort_by(|a, b| b.1.cmp(&a.1)); - for (path, _) in entries.into_iter().skip(cap) { - if let Err(err) = fs::remove_file(&path) { - log::warn!( - "[recordings] prune (count) remove failed for {:?}: {err}", - path - ); - } - } - Ok(()) -} - -/// 单个 session 的录音文件路径。不保证文件已存在(DictationSession.has_audio_recording -/// 决定文件是否被写过)。前端用 `read_audio_recording` IPC 读字节流喂 HTMLAudio。 -pub fn recording_path_for_session(session_id: &str) -> Result { - Ok(recordings_root()?.join(format!("{session_id}.wav"))) -} - -/// Foundry Local 下载与缓存根目录。DLL 和模型都不打进安装包,和 Qwen3-ASR -/// 一样放在 OpenLess 的 models 目录下,卸载清理用户数据时可以一起删除。 -#[cfg(target_os = "windows")] -pub fn foundry_local_root() -> Result { - let dir = models_root()?.join("foundry-local"); - ensure_dir(&dir)?; - Ok(dir) -} - -#[cfg(target_os = "windows")] -pub fn foundry_native_runtime_root() -> Result { - let dir = foundry_local_root()?.join("runtime"); - ensure_dir(&dir)?; - Ok(dir) -} - -#[cfg(target_os = "windows")] -pub fn sherpa_onnx_models_root() -> Result { - let dir = models_root()?.join("sherpa-onnx"); - ensure_dir(&dir)?; - Ok(dir) -} - -#[cfg(target_os = "windows")] -pub fn foundry_model_cache_root() -> Result { - let dir = foundry_local_root()?; - ensure_dir(&dir)?; - Ok(dir) -} - -#[cfg(target_os = "windows")] -pub fn foundry_app_data_root() -> Result { - let dir = foundry_local_root()?.join("app-data"); - ensure_dir(&dir)?; - Ok(dir) -} - -#[cfg(target_os = "windows")] -pub fn foundry_logs_root() -> Result { - let dir = foundry_local_root()?.join("logs"); - ensure_dir(&dir)?; - Ok(dir) -} - -/// Atomic write: write to a unique `*.tmp-` first, then rename onto the -/// target path. The unique suffix lets concurrent writers each own their own -/// tmp file, so a parallel rename never finds its source already taken. -fn atomic_write(path: &Path, contents: &[u8]) -> Result<()> { - if let Some(parent) = path.parent() { - ensure_dir(parent)?; - } - let file_name = path - .file_name() - .map(|n| n.to_string_lossy().into_owned()) - .unwrap_or_default(); - let tmp_path = path.with_file_name(format!("{file_name}.tmp-{}", Uuid::new_v4().simple())); - fs::write(&tmp_path, contents) - .with_context(|| format!("write tmp failed: {}", tmp_path.display()))?; - if let Err(err) = fs::rename(&tmp_path, path) { - let _ = fs::remove_file(&tmp_path); - return Err(err).with_context(|| format!("rename failed: {}", path.display())); - } - Ok(()) -} - -fn read_or_default Deserialize<'de> + Default>(path: &Path) -> Result { - 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::(&bytes) - .with_context(|| format!("decode failed: {}", path.display())) -} - -fn read_preferences(path: &Path) -> Result { - if !path.exists() { - return Ok(UserPreferences::default()); - } - let bytes = fs::read(path).with_context(|| format!("read failed: {}", path.display()))?; - if bytes.is_empty() { - return Ok(UserPreferences::default()); - } - let prefs = serde_json::from_slice::(&bytes) - .with_context(|| format!("decode failed: {}", path.display()))?; - - // issue #440:老版本可能已把旧默认 `streamingInsert:false` 写进 preferences.json。 - // 反序列化会在内存里迁到 true,但还必须把迁移标记落盘,否则每次启动都停留在 - // “旧文件”状态,无法表达用户后续手动关闭后的 durable opt-out。 - let streaming_default_migrated = serde_json::from_slice::(&bytes) - .ok() - .and_then(|value| { - value - .get("streamingInsertDefaultMigrated") - .and_then(|flag| flag.as_bool()) - }) - .unwrap_or(false); - if !streaming_default_migrated { - match serde_json::to_vec_pretty(&prefs) - .context("encode prefs failed") - .and_then(|json| atomic_write(path, &json)) - { - Ok(()) => log::info!("[prefs] migrated streamingInsert default marker"), - Err(err) => log::warn!( - "[prefs] failed to persist streamingInsert migration marker for {}: {}", - path.display(), - err - ), - } - } - - Ok(prefs) -} - -// ───────────────────────── credentials vault ───────────────────────── -// -// 正常读写走系统凭据库;旧 plaintext JSON 只作为迁移来源。为保持多 provider -// schema 与 active provider 状态,凭据库里保存一个 v1 JSON payload;payload 会按平台 -// 凭据库限制拆成多个条目,避免 Windows 单条凭据 2560 bytes 限制。 -// -// v1 schema: -// { -// "version": 1, -// "active": { "asr": "", "llm": "" }, -// "providers": { -// "asr": { "": { "appKey", "accessKey", "resourceId", "apiKey", "baseURL", "model", "vocabularyId" } }, -// "llm": { "": { "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 { - #[cfg(target_os = "windows")] - { - return crate::asr::local::foundry::PROVIDER_ID.into(); - } - #[cfg(not(target_os = "windows"))] - { - "volcengine".into() - } -} -fn creds_default_llm() -> String { - "ark".into() -} - -#[derive(Debug, Serialize, Deserialize, Default, Clone)] -struct CredsProviders { - #[serde(default)] - asr: HashMap, - #[serde(default)] - llm: HashMap, -} - -#[derive(Debug, Serialize, Deserialize, Default, Clone)] -#[allow(non_snake_case)] -struct CredsAsrEntry { - #[serde(skip_serializing_if = "Option::is_none")] - apiKey: Option, - #[serde(skip_serializing_if = "Option::is_none")] - baseURL: Option, - #[serde(skip_serializing_if = "Option::is_none")] - model: Option, - #[serde(skip_serializing_if = "Option::is_none")] - appKey: Option, - #[serde(skip_serializing_if = "Option::is_none")] - accessKey: Option, - #[serde(skip_serializing_if = "Option::is_none")] - resourceId: Option, - #[serde(skip_serializing_if = "Option::is_none")] - vocabularyId: Option, -} - -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() - && self.vocabularyId.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, - #[serde(skip_serializing_if = "Option::is_none")] - apiKey: Option, - #[serde(skip_serializing_if = "Option::is_none")] - baseURL: Option, - #[serde(skip_serializing_if = "Option::is_none")] - model: Option, - #[serde(skip_serializing_if = "Option::is_none")] - temperature: Option, - #[serde(skip_serializing_if = "Option::is_none")] - extraHeaders: Option>, -} - -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 { - // 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)) - } -} - -#[cfg(not(target_os = "android"))] -fn keyring_entry() -> Result { - keyring_entry_for(KEYRING_CREDENTIALS_ACCOUNT) -} - -#[cfg(not(target_os = "android"))] -fn keyring_entry_for(account: &str) -> Result { - keyring::Entry::new(CredentialsVault::SERVICE_NAME, account) - .context("open system credential vault") -} - -#[cfg(target_os = "android")] -fn android_credentials_path() -> Result { - Ok(data_dir()?.join(ANDROID_CREDENTIALS_FILE)) -} - -#[cfg(target_os = "android")] -fn load_android_credentials() -> Result> { - let path = android_credentials_path()?; - if !path.exists() { - return Ok(None); - } - let bytes = fs::read(&path).with_context(|| format!("read failed: {}", path.display()))?; - if bytes.is_empty() { - return Ok(None); - } - // Stub: base64 envelope — replace with Keystore-backed AES when JNI lands. - use base64::Engine; - let decoded = base64::engine::general_purpose::STANDARD - .decode(bytes) - .context("decode android credentials envelope")?; - let root = - serde_json::from_slice::(&decoded).context("parse android credentials json")?; - Ok(Some(root)) -} - -#[cfg(target_os = "android")] -fn save_android_credentials(root: &CredsRoot) -> Result<()> { - let cleaned = clean_credentials(root); - let json = serde_json::to_string(&cleaned).context("encode credentials failed")?; - use base64::Engine; - let encoded = base64::engine::general_purpose::STANDARD.encode(json.as_bytes()); - let path = android_credentials_path()?; - ensure_dir(path.parent().unwrap_or_else(|| Path::new(".")))?; - fs::write(&path, encoded).with_context(|| format!("write failed: {}", path.display()))?; - Ok(()) -} - -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 { - 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::(&bytes) { - Ok(root) => Some(root), - Err(e) => { - log::warn!("[vault] parse legacy {} failed: {}", path.display(), e); - None - } - } -} - -fn remove_legacy_credentials_file() -> Result<()> { - let Ok(path) = credentials_path() else { - return Ok(()); - }; - if path.exists() { - fs::remove_file(&path) - .with_context(|| format!("remove legacy credentials file {}", path.display()))?; - } - Ok(()) -} - -fn remove_legacy_credentials_file_best_effort() { - if let Err(e) = remove_legacy_credentials_file() { - log::warn!("[vault] remove legacy credentials file failed: {e}"); - } -} - -#[derive(Debug, Serialize, Deserialize)] -struct CredsChunkManifest { - openless_credentials_storage: String, - version: u32, - /// 旧版本(v1 早期)每次 save 都生成新 UUID 作为 chunk account 命名前缀, - /// 这让 macOS Keychain 的「始终允许」每次保存后失效 → 反复弹 ACL 弹窗。 - /// 现在 save 总用稳定 chunk.{index} 名,此字段仅向后兼容旧 manifest 读取。 - #[serde(default, skip_serializing_if = "Option::is_none")] - generation: Option, - chunks: usize, -} - -/// 旧版(generation=Some):`credentials.v1.chunk..{index}` -/// 新版(generation=None):`credentials.v1.chunk.{index}` —— 稳定名,ACL 长期有效 -fn chunk_account(generation: Option<&str>, index: usize) -> String { - match generation { - Some(gen) => format!("{KEYRING_CREDENTIALS_CHUNK_PREFIX}{gen}.{index}"), - None => format!("{KEYRING_CREDENTIALS_CHUNK_PREFIX}{index}"), - } -} - -fn chunk_json_payload(json: &str) -> Vec { - let mut chunks = Vec::new(); - let mut current = String::new(); - let mut current_units = 0usize; - for ch in json.chars() { - let units = ch.len_utf16(); - if !current.is_empty() && current_units + units > KEYRING_CHUNK_MAX_UTF16_UNITS { - chunks.push(std::mem::take(&mut current)); - current_units = 0; - } - current.push(ch); - current_units += units; - } - if !current.is_empty() || json.is_empty() { - chunks.push(current); - } - chunks -} - -fn read_chunk_manifest(json: &str) -> Option { - let manifest = serde_json::from_str::(json).ok()?; - if manifest.openless_credentials_storage == "chunked" && manifest.version == 1 { - Some(manifest) - } else { - None - } -} - -#[cfg(not(target_os = "android"))] -fn get_keyring_password(account: &str) -> Result> { - match keyring_entry_for(account)?.get_password() { - Ok(value) => Ok(Some(value)), - Err(keyring::Error::NoEntry) => Ok(None), - Err(e) => { - Err(anyhow!(e)).with_context(|| format!("read system credential vault {account}")) - } - } -} - -#[cfg(not(target_os = "android"))] -fn delete_keyring_password(account: &str) { - match keyring_entry_for(account).and_then(|entry| { - entry - .delete_credential() - .with_context(|| format!("delete system credential vault {account}")) - }) { - Ok(()) | Err(_) => {} - } -} - -#[cfg(not(target_os = "android"))] -fn load_keyring_credentials() -> Result> { - let Some(json_or_manifest) = get_keyring_password(KEYRING_CREDENTIALS_ACCOUNT)? else { - return Ok(None); - }; - - let manifest = read_chunk_manifest(&json_or_manifest) - .ok_or_else(|| anyhow!("invalid system credential vault manifest"))?; - let mut json = String::new(); - for index in 0..manifest.chunks { - let account = chunk_account(manifest.generation.as_deref(), index); - let chunk = get_keyring_password(&account)? - .ok_or_else(|| anyhow!("missing system credential vault chunk {index}"))?; - json.push_str(&chunk); - } - - serde_json::from_str::(&json) - .map(Some) - .context("decode system credential vault payload") -} - -#[cfg(not(target_os = "android"))] -fn load_legacy_keyring_credentials() -> CredsRoot { - match load_legacy_keyring_credentials_for_update() { - Ok(root) => root, - Err(e) => { - log::warn!("[vault] read legacy vault credentials failed: {e}"); - CredsRoot::default() - } - } -} - -#[cfg(not(target_os = "android"))] -fn load_legacy_keyring_credentials_for_update() -> Result { - let mut root = CredsRoot::default(); - for account in CredentialAccount::all() { - let legacy_account = account.keyring_account(); - match get_keyring_password(legacy_account) { - Ok(Some(value)) => write_account(&mut root, *account, Some(value)), - Ok(None) => {} - Err(e) => return Err(e.context(format!("read legacy vault {legacy_account}"))), - } - } - Ok(clean_credentials(&root)) -} - -#[cfg(not(target_os = "android"))] -fn remove_legacy_keyring_credentials() { - for account in CredentialAccount::all() { - delete_keyring_password(account.keyring_account()); - } -} - -fn load_legacy_credentials() -> Option { - credentials_path() - .ok() - .and_then(|p| read_legacy_credentials_file(&p)) -} - -fn legacy_vault_has_credentials(root: &CredsRoot) -> bool { - !root.providers.asr.is_empty() || !root.providers.llm.is_empty() -} - -fn load_legacy_sources_without_migration() -> CredsRoot { - if let Some(legacy) = load_legacy_credentials() { - return legacy; - } - - #[cfg(not(target_os = "android"))] - { - let legacy_vault = load_legacy_keyring_credentials(); - if legacy_vault_has_credentials(&legacy_vault) { - return legacy_vault; - } - } - - CredsRoot::default() -} - -fn migrate_legacy_sources() -> CredsRoot { - match migrate_legacy_sources_for_update() { - Ok(root) => root, - Err(e) => { - log::warn!("[vault] legacy credential migration failed: {e}"); - load_legacy_sources_without_migration() - } - } -} - -fn migrate_legacy_sources_for_update() -> Result { - if let Some(legacy) = load_legacy_credentials() { - save_credentials(&legacy)?; - #[cfg(not(target_os = "android"))] - remove_legacy_keyring_credentials(); - return Ok(legacy); - } - - #[cfg(not(target_os = "android"))] - { - let legacy_vault = load_legacy_keyring_credentials_for_update()?; - if legacy_vault_has_credentials(&legacy_vault) { - save_credentials(&legacy_vault)?; - remove_legacy_keyring_credentials(); - return Ok(legacy_vault); - } - } - - Ok(CredsRoot::default()) -} - -fn load_credentials() -> CredsRoot { - if let Some(cached) = credentials_cache().lock().as_ref().cloned() { - return cached; - } - - #[cfg(target_os = "android")] - { - let root = match load_android_credentials() { - Ok(Some(root)) => root, - Ok(None) => CredsRoot::default(), - Err(e) => { - log::warn!("[vault] android credential read failed: {e}"); - CredsRoot::default() - } - }; - store_credentials_cache(&root); - return root; - } - - #[cfg(not(target_os = "android"))] - match load_keyring_credentials() { - Ok(Some(root)) => { - // 不在这里调 remove_legacy_keyring_credentials() —— 它内部对每个 - // 旧 account 各做一次 keyring delete,每次 delete 在 macOS Keychain - // 上仍要触发 ACL 检查。第一次成功 load 时 legacy entries 通常已经 - // 被 migrate_legacy_sources_for_update 清理过了;这里若再无脑跑, - // 只会反复弹「OpenLess 想删除 X」十几次。文件 legacy(plaintext - // JSON)不需要 ACL,可继续 best-effort 删除。 - remove_legacy_credentials_file_best_effort(); - store_credentials_cache(&root); - root - } - Ok(None) => { - // 没有现成 chunked manifest —— 走 migrate(如果有 legacy 则写入并返回写后的 root)。 - // migrate_legacy_sources 内部 save_credentials 已经会刷 cache,这里再补一次 - // 是为了「无 legacy 也无 manifest」走默认 root 的路径也能进 cache。 - let root = migrate_legacy_sources(); - store_credentials_cache(&root); - root - } - Err(e) => { - // **不缓存 keyring 错误路径下的 fallback**。Keychain 可能只是临时不可读 - // (用户尚未在第一次弹窗里点同意 / DataProtection 错误 / login keychain - // 还没 unlock);如果在这里把 legacy fallback 写进 cache,等用户授权后 - // 我们就再也不会重读 keyring,整个进程生命周期里都拿 stale 数据。下次 - // 调用让它再尝试一次 keyring。pr_agent feedback on PR #394。 - log::warn!("[vault] system credential read failed: {e}"); - load_legacy_sources_without_migration() - } - } -} - -fn load_credentials_for_update() -> Result { - if let Some(cached) = credentials_cache().lock().as_ref().cloned() { - return Ok(cached); - } - - #[cfg(target_os = "android")] - { - let root = match load_android_credentials()? { - Some(root) => root, - None => CredsRoot::default(), - }; - store_credentials_cache(&root); - return Ok(root); - } - - #[cfg(not(target_os = "android"))] - match load_keyring_credentials() { - Ok(Some(root)) => { - // 同 load_credentials:不再每次 update 都尝试 delete legacy keyring - // entries,避免反复触发 macOS Keychain ACL 弹窗。 - remove_legacy_credentials_file_best_effort(); - store_credentials_cache(&root); - Ok(root) - } - Ok(None) => { - // migrate_legacy_sources_for_update 内部如果实际 migrate 会调 - // save_credentials,cache 会被刷新;如果只返回 default root(没 legacy), - // 我们这里再显式 cache 一次防御性补一下。 - let root = migrate_legacy_sources_for_update()?; - store_credentials_cache(&root); - Ok(root) - } - // 错误路径不缓存 —— 同 load_credentials 注释;让下次读重试 keyring。 - Err(e) => Err(e), - } -} - -fn save_credentials(root: &CredsRoot) -> Result<()> { - let cleaned = clean_credentials(root); - - #[cfg(target_os = "android")] - { - save_android_credentials(&cleaned)?; - store_credentials_cache(&cleaned); - return Ok(()); - } - - #[cfg(not(target_os = "android"))] - { - let json = serde_json::to_string(&cleaned).context("encode credentials failed")?; - let previous_manifest = get_keyring_password(KEYRING_CREDENTIALS_ACCOUNT) - .ok() - .flatten() - .and_then(|value| read_chunk_manifest(&value)); - let chunks = chunk_json_payload(&json); - - // 先写所有 chunks(稳定名),再写 manifest —— 保证 partial-write 不会让 - // manifest 指向不完整 chunks。stable name 让 macOS Keychain ACL 一次允许后 - // 长期有效,不再因 UUID 轮换反复弹窗(这是 PR #277 早期 UUID-rotation - // 设计的回退)。 - for (index, chunk) in chunks.iter().enumerate() { - let account = chunk_account(None, index); - keyring_entry_for(&account)? - .set_password(chunk) - .with_context(|| format!("write system credential vault chunk {index}"))?; - } - - let manifest = CredsChunkManifest { - openless_credentials_storage: "chunked".to_string(), - version: 1, - generation: None, - chunks: chunks.len(), - }; - let manifest_json = - serde_json::to_string(&manifest).context("encode credential manifest failed")?; - keyring_entry()? - .set_password(&manifest_json) - .context("write system credential vault manifest")?; - - // 清理旧 chunks: - // 1) 旧 manifest 用 UUID generation → 那一代 chunks 全删(迁移到 stable name) - // 2) 旧 manifest 也是 stable name,但 chunks 数量比这次多 → 删多余的 idx - if let Some(previous) = previous_manifest { - match previous.generation.as_deref() { - Some(prev_gen) => { - for index in 0..previous.chunks { - delete_keyring_password(&chunk_account(Some(prev_gen), index)); - } - } - None => { - for index in chunks.len()..previous.chunks { - delete_keyring_password(&chunk_account(None, index)); - } - } - } - } - - remove_legacy_credentials_file_best_effort(); - // 写完成功后立刻刷新 process cache —— 同进程后续读不再回 Keychain。 - // 见 CREDENTIALS_CACHE 的 doc。 - store_credentials_cache(&cleaned); - Ok(()) - } -} - -fn lookup_account(root: &CredsRoot, account: CredentialAccount) -> Option { - let asr = root.providers.asr.get(&root.active.asr); - let llm = root.providers.llm.get(&root.active.llm); - let pick = |s: &Option| 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)), - CredentialAccount::AsrVocabularyId => asr.and_then(|e| pick(&e.vocabularyId)), - } -} - -fn write_account(root: &mut CredsRoot, account: CredentialAccount, value: Option) { - 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; - } - CredentialAccount::AsrVocabularyId => { - let entry = root.providers.asr.entry(asr_id).or_default(); - entry.vocabularyId = normalized; - } - } -} - -// ───────────────────────── HistoryStore ───────────────────────── - -pub struct HistoryStore { - path: PathBuf, - lock: Mutex<()>, -} - -impl HistoryStore { - pub fn new() -> Result { - let dir = data_dir()?; - ensure_dir(&dir)?; - Ok(Self { - path: dir.join(HISTORY_FILE), - lock: Mutex::new(()), - }) - } - - /// 在 data_dir 不可用时构造一个降级实例(指向临时目录)。 - /// 该实例在运行期间读写会安静地失败或返回空,不会 panic, - /// 也不会影响正常启动路径。 - pub(crate) fn new_fallback() -> Self { - Self { - path: std::env::temp_dir().join("openless_history_fallback.json"), - lock: Mutex::new(()), - } - } - - pub fn list(&self) -> Result> { - let _guard = self.lock.lock(); - self.read_locked() - } - - /// `retention_days == 0` 跟旧 append 行为一致(不按时间清理)。 - /// `> 0` 时在写入新条目后顺手把超过 N 天的会话裁掉,写入时就完成清理, - /// 不需要后台轮询。最后再受条数上限约束: - /// - `max_entries == None` → HISTORY_CAP (200) - /// - `max_entries == Some(n)` → clamp 到 5..=HISTORY_CAP,避免用户填 0 / 极大值。 - pub fn append_with_retention( - &self, - session: DictationSession, - retention_days: u32, - max_entries: Option, - ) -> 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 retention_days > 0 { - let cutoff = chrono::Utc::now() - chrono::Duration::days(i64::from(retention_days)); - sessions.retain(|s| { - chrono::DateTime::parse_from_rfc3339(&s.created_at) - .map(|t| t.with_timezone(&chrono::Utc) >= cutoff) - // 解析失败时保守保留——避免错误的时间戳让用户丢历史。 - .unwrap_or(true) - }); - } - let cap = max_entries - .map(|n| (n as usize).clamp(5, HISTORY_CAP)) - .unwrap_or(HISTORY_CAP); - if sessions.len() > cap { - sessions.truncate(cap); - } - self.write_locked(&sessions) - } - - /// 返回最近 N 分钟内的会话(newest-first)。`minutes == 0` → 空 Vec, - /// 调用方据此跳过对话感知 polish 路径。 - pub fn recent_within_minutes(&self, minutes: u32) -> Result> { - if minutes == 0 { - return Ok(Vec::new()); - } - let _guard = self.lock.lock(); - let sessions = self.read_locked()?; - let cutoff = chrono::Utc::now() - chrono::Duration::minutes(i64::from(minutes)); - // sessions 是 newest-first,超出窗口的会话之后的都更老,take_while 即可。 - // unwrap_or(true):时间戳解析失败时保留该条目,与 append_with_retention 的保守策略一致; - // 避免单条坏记录截断整个上下文窗口。 - let filtered: Vec = sessions - .into_iter() - .take_while(|s| { - chrono::DateTime::parse_from_rfc3339(&s.created_at) - .map(|t| t.with_timezone(&chrono::Utc) >= cutoff) - .unwrap_or(true) - }) - .collect(); - Ok(filtered) - } - - 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 update_entry(&self, updated: DictationSession) -> Result { - let _guard = self.lock.lock(); - let mut sessions = self.read_locked()?; - let Some(slot) = sessions.iter_mut().find(|s| s.id == updated.id) else { - return Ok(false); - }; - *slot = updated; - self.write_locked(&sessions)?; - Ok(true) - } - - pub fn clear(&self) -> Result<()> { - let _guard = self.lock.lock(); - self.write_locked(&Vec::::new()) - } - - fn read_locked(&self) -> Result> { - read_or_default::>(&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, -} - -impl PreferencesStore { - pub fn new() -> Result { - let dir = data_dir()?; - ensure_dir(&dir)?; - let path = dir.join(PREFERENCES_FILE); - let prefs = if path.exists() { - read_preferences(&path).unwrap_or_else(|e| { - log::warn!( - "[prefs] load {} failed, using defaults: {}", - path.display(), - e - ); - UserPreferences::default() - }) - } else { - UserPreferences::default() - }; - Ok(Self { - path, - state: Mutex::new(prefs), - }) - } - - /// 降级实例:data_dir 不可用时使用默认配置,写操作会安静地失败。 - pub(crate) fn new_fallback() -> Self { - Self { - path: std::env::temp_dir().join("openless_prefs_fallback.json"), - state: Mutex::new(UserPreferences::default()), - } - } - - 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")?; - let mut guard = self.state.lock(); - atomic_write(&self.path, &json)?; - *guard = prefs; - Ok(()) - } -} - -#[derive(Debug, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -struct StylePackArchiveManifest { - schema_version: u32, - id: String, - name: String, - description: String, - author: Option, - version: String, - base_mode: PolishMode, - tags: Vec, - prompt_file: String, - examples_file: String, - icon_file: Option, - recommended_model: Option, - compatible_app_version: Option, - /// Marketplace 上游关系。旧 ZIP 没有此字段时自动为 None; - /// 兼容早期口误/拼写包里可能出现的 `orion*` 字段名。 - #[serde( - default, - alias = "orionPackId", - alias = "orion_pack_id", - alias = "origin_pack_id" - )] - origin_pack_id: Option, - #[serde( - default, - alias = "orionAuthorLogin", - alias = "orion_author_login", - alias = "origin_author_login" - )] - origin_author_login: Option, -} - -pub struct StylePackStore { - path: PathBuf, - asset_root: PathBuf, - state: Mutex>, -} - -impl StylePackStore { - pub fn new(prefs: &PreferencesStore) -> Result { - let dir = data_dir()?; - ensure_dir(&dir)?; - let path = dir.join(STYLE_PACKS_FILE); - let asset_root = dir.join(STYLE_PACK_ASSETS_DIR); - ensure_dir(&asset_root)?; - - let mut packs = if path.exists() { - read_or_default::>(&path).unwrap_or_else(|error| { - log::warn!( - "[style-packs] load {} failed, using builtin defaults: {}", - path.display(), - error - ); - Vec::new() - }) - } else { - Vec::new() - }; - - let mut prefs_snapshot = prefs.get(); - let mut changed = migrate_style_packs_from_preferences(&mut packs, &prefs_snapshot); - if ensure_at_least_one_style_pack_enabled(&mut packs) { - changed = true; - } - let active_pref_for_log = prefs_snapshot.active_style_pack_id.clone(); - let enabled_modes_for_log = prefs_snapshot.enabled_modes.clone(); - if sync_style_pack_preferences(&mut prefs_snapshot, &packs) { - prefs.set(prefs_snapshot)?; - } - if changed { - write_style_packs_file(&path, &packs)?; - } - log::info!( - "[style-pack] store ready: file={} packs={} changed={} active_pref={} enabled_modes={:?}", - path.display(), - packs.len(), - changed, - active_pref_for_log, - enabled_modes_for_log - ); - - Ok(Self { - path, - asset_root, - state: Mutex::new(packs), - }) - } - - /// 降级实例:data_dir 不可用时使用临时路径和空列表,写操作会安静地失败。 - pub(crate) fn new_fallback() -> Self { - let tmp = std::env::temp_dir(); - Self { - path: tmp.join("openless_style_packs_fallback.json"), - asset_root: tmp.join("openless_style_pack_assets_fallback"), - state: Mutex::new(Vec::new()), - } - } - - pub fn list(&self) -> Result> { - Ok(self.state.lock().clone()) - } - - pub fn list_with_active(&self, active_style_pack_id: &str) -> Result> { - let mut packs = self.list()?; - for pack in &mut packs { - pack.active = pack.id == active_style_pack_id; - } - Ok(packs) - } - - pub fn get(&self, id: &str) -> Result { - self.state - .lock() - .iter() - .find(|pack| pack.id == id) - .cloned() - .ok_or_else(|| anyhow!("style pack {} not found", id)) - } - - pub fn get_or_default_active(&self, active_style_pack_id: &str) -> Result { - let packs = self.state.lock().clone(); - if let Some(pack) = packs - .iter() - .find(|pack| pack.id == active_style_pack_id && pack.enabled) - .cloned() - { - return Ok(pack); - } - if let Some(pack) = packs - .iter() - .find(|pack| pack.id == BUILTIN_STYLE_PACK_LIGHT_ID && pack.enabled) - .cloned() - { - return Ok(pack); - } - packs - .into_iter() - .find(|pack| pack.enabled) - .ok_or_else(|| anyhow!("no enabled style pack available")) - } - - /// 从模板新建一个 imported 风格包("+"按钮路径)。 - /// 跟 ZIP 导入不同:没有 manifest.json、没有 assets,纯空白模板。 - /// 调用方负责 set `prefs.active_style_pack_id` 等高层 wiring(这里只管落盘)。 - pub fn create_from_template(&self, template: StylePack) -> Result { - let mut packs = self.state.lock(); - let base_id = if template.id.trim().is_empty() { - format!("imported-{}", Uuid::new_v4().simple()) - } else { - template.id.clone() - }; - let assigned_id = unique_imported_style_pack_id(&packs, &base_id); - let now = Utc::now().to_rfc3339(); - let mut pack = template; - pack.id = assigned_id; - pack.kind = StylePackKind::Imported; - pack.created_at = Some(now.clone()); - pack.updated_at = Some(now); - pack.active = false; - pack.enabled = true; - packs.push(pack.clone()); - write_style_packs_file(&self.path, &packs)?; - log::info!( - "[style-pack] created from template id={} base_mode={:?} prompt_chars={} examples={}", - pack.id, - pack.base_mode, - pack.prompt.chars().count(), - pack.examples.len() - ); - Ok(pack) - } - - pub fn upsert(&self, incoming: StylePack) -> Result { - let mut packs = self.state.lock(); - let index = packs - .iter() - .position(|pack| pack.id == incoming.id) - .ok_or_else(|| anyhow!("style pack {} not found", incoming.id))?; - let existing = packs[index].clone(); - let updated = merge_style_pack_update(existing, incoming)?; - packs[index] = updated.clone(); - write_style_packs_file(&self.path, &packs)?; - log::info!( - "[style-pack] saved id={} kind={:?} base_mode={:?} prompt_chars={} examples={} tags={} version={}", - updated.id, - updated.kind, - updated.base_mode, - updated.prompt.chars().count(), - updated.examples.len(), - updated.tags.len(), - updated.version - ); - Ok(updated) - } - - /// 设置衍生关系;marketplace_install 安装本地包后绑定 upstream id + author。 - /// 单独走这里是为了不让前端通用 save 路径误清这两字段。 - pub fn set_origin( - &self, - id: &str, - origin_pack_id: Option, - origin_author_login: Option, - ) -> Result { - let mut packs = self.state.lock(); - let index = packs - .iter() - .position(|pack| pack.id == id) - .ok_or_else(|| anyhow!("style pack {} not found", id))?; - packs[index].origin_pack_id = normalize_optional_text(origin_pack_id); - packs[index].origin_author_login = normalize_optional_text(origin_author_login); - packs[index].updated_at = Some(Utc::now().to_rfc3339()); - let updated = packs[index].clone(); - write_style_packs_file(&self.path, &packs)?; - Ok(updated) - } - - pub fn set_enabled(&self, id: &str, enabled: bool) -> Result { - let mut packs = self.state.lock(); - let index = packs - .iter() - .position(|pack| pack.id == id) - .ok_or_else(|| anyhow!("style pack {} not found", id))?; - packs[index].enabled = enabled; - packs[index].updated_at = Some(Utc::now().to_rfc3339()); - if ensure_at_least_one_style_pack_enabled(&mut packs) { - packs[index].updated_at = Some(Utc::now().to_rfc3339()); - } - let updated = packs[index].clone(); - write_style_packs_file(&self.path, &packs)?; - log::info!( - "[style-pack] set_enabled id={} enabled={} base_mode={:?}", - updated.id, - updated.enabled, - updated.base_mode - ); - Ok(updated) - } - - pub fn reset_builtin(&self, id: &str) -> Result { - let mode = builtin_mode_from_style_pack_id(id) - .ok_or_else(|| anyhow!("style pack {} is not a builtin pack", id))?; - let mut packs = self.state.lock(); - let index = packs - .iter() - .position(|pack| pack.id == id) - .ok_or_else(|| anyhow!("style pack {} not found", id))?; - let existing = packs[index].clone(); - let mut reset = builtin_style_pack_for_mode(mode); - reset.enabled = existing.enabled; - reset.created_at = existing - .created_at - .or_else(|| Some(Utc::now().to_rfc3339())); - reset.updated_at = Some(Utc::now().to_rfc3339()); - packs[index] = reset.clone(); - write_style_packs_file(&self.path, &packs)?; - log::info!( - "[style-pack] reset_builtin id={} base_mode={:?} prompt_chars={} examples={}", - reset.id, - reset.base_mode, - reset.prompt.chars().count(), - reset.examples.len() - ); - Ok(reset) - } - - pub fn remove_imported(&self, id: &str) -> Result<()> { - let mut packs = self.state.lock(); - let index = packs - .iter() - .position(|pack| pack.id == id) - .ok_or_else(|| anyhow!("style pack {} not found", id))?; - if packs[index].kind == StylePackKind::Builtin { - return Err(anyhow!("builtin style pack cannot be deleted")); - } - let removed = packs[index].clone(); - remove_style_pack_assets(&self.asset_root, &packs[index]); - packs.remove(index); - if ensure_at_least_one_style_pack_enabled(&mut packs) { - // write updated fallback state as well - } - write_style_packs_file(&self.path, &packs)?; - log::info!( - "[style-pack] removed imported id={} base_mode={:?}", - removed.id, - removed.base_mode - ); - Ok(()) - } - - pub fn import_from_zip(&self, zip_path: &Path) -> Result { - let file = fs::File::open(zip_path) - .with_context(|| format!("open style pack zip failed: {}", zip_path.display()))?; - let mut archive = zip::ZipArchive::new(file).context("open style pack zip archive")?; - let manifest: StylePackArchiveManifest = - read_zip_json_entry(&mut archive, "manifest.json")?; - let prompt = read_zip_string_entry(&mut archive, &manifest.prompt_file)?; - let examples = - read_zip_json_entry::>(&mut archive, &manifest.examples_file)?; - - let mut packs = self.state.lock(); - let now = Utc::now().to_rfc3339(); - let pack_id = unique_imported_style_pack_id(&packs, &manifest.id); - let icon_path = if let Some(icon_file) = manifest.icon_file.as_deref() { - extract_style_pack_icon(&mut archive, &self.asset_root, &pack_id, icon_file)? - } else { - None - }; - let pack = StylePack { - id: pack_id, - name: manifest.name.trim().to_string(), - description: manifest.description.trim().to_string(), - author: manifest - .author - .and_then(|value| normalize_optional_text(Some(value))), - version: normalize_version(&manifest.version), - kind: StylePackKind::Imported, - base_mode: manifest.base_mode, - prompt, - examples, - tags: normalize_tags(&manifest.tags), - icon_path, - created_at: Some(now.clone()), - updated_at: Some(now), - enabled: true, - active: false, - recommended_model: manifest - .recommended_model - .and_then(|value| normalize_optional_text(Some(value))), - compatible_app_version: manifest - .compatible_app_version - .and_then(|value| normalize_optional_text(Some(value))), - origin_pack_id: normalize_optional_text(manifest.origin_pack_id), - origin_author_login: normalize_optional_text(manifest.origin_author_login), - }; - packs.insert(0, pack.clone()); - write_style_packs_file(&self.path, &packs)?; - log::info!( - "[style-pack] imported source={} installed_id={} manifest_id={} base_mode={:?} prompt_chars={} examples={} tags={} icon={}", - zip_path.display(), - pack.id, - manifest.id, - pack.base_mode, - pack.prompt.chars().count(), - pack.examples.len(), - pack.tags.len(), - pack.icon_path.is_some() - ); - Ok(pack) - } - - pub fn export_to_zip(&self, id: &str, target_path: &Path) -> Result<()> { - let pack = self.get(id)?; - if let Some(parent) = target_path.parent() { - ensure_dir(parent)?; - } - let file = fs::File::create(target_path) - .with_context(|| format!("create style pack zip failed: {}", target_path.display()))?; - let mut zip = zip::ZipWriter::new(file); - let options = zip::write::SimpleFileOptions::default() - .compression_method(zip::CompressionMethod::Deflated); - - let icon_file = pack - .icon_path - .as_deref() - .and_then(|path| Path::new(path).file_name()) - .and_then(|file_name| file_name.to_str()) - .map(|name| format!("assets/{name}")); - - let manifest = StylePackArchiveManifest { - schema_version: 1, - id: pack.id.clone(), - name: pack.name.clone(), - description: pack.description.clone(), - author: pack.author.clone(), - version: pack.version.clone(), - base_mode: pack.base_mode, - tags: pack.tags.clone(), - prompt_file: "prompt.md".into(), - examples_file: "examples.json".into(), - icon_file: icon_file.clone(), - recommended_model: pack.recommended_model.clone(), - compatible_app_version: pack.compatible_app_version.clone(), - origin_pack_id: pack.origin_pack_id.clone(), - origin_author_login: pack.origin_author_login.clone(), - }; - - zip.start_file("manifest.json", options) - .context("write style pack manifest entry")?; - zip.write_all( - serde_json::to_string_pretty(&manifest) - .context("encode style pack manifest")? - .as_bytes(), - ) - .context("write style pack manifest body")?; - - zip.start_file("prompt.md", options) - .context("write style pack prompt entry")?; - zip.write_all(pack.prompt.as_bytes()) - .context("write style pack prompt body")?; - - zip.start_file("examples.json", options) - .context("write style pack examples entry")?; - zip.write_all( - serde_json::to_string_pretty(&pack.examples) - .context("encode style pack examples")? - .as_bytes(), - ) - .context("write style pack examples body")?; - - if let (Some(source_icon_path), Some(zip_icon_path)) = (&pack.icon_path, &icon_file) { - let icon_source = Path::new(source_icon_path); - if icon_source.exists() { - zip.start_file(zip_icon_path, options) - .context("write style pack icon entry")?; - let bytes = fs::read(icon_source).with_context(|| { - format!("read style pack icon failed: {}", icon_source.display()) - })?; - zip.write_all(&bytes) - .context("write style pack icon body")?; - } - } - - zip.finish().context("finalize style pack zip")?; - log::info!( - "[style-pack] exported id={} target={} base_mode={:?} prompt_chars={} examples={} icon={}", - pack.id, - target_path.display(), - pack.base_mode, - pack.prompt.chars().count(), - pack.examples.len(), - pack.icon_path.is_some() - ); - Ok(()) - } -} - -fn write_style_packs_file(path: &Path, packs: &[StylePack]) -> Result<()> { - let json = serde_json::to_vec_pretty(packs).context("encode style packs failed")?; - atomic_write(path, &json) -} - -fn migrate_style_packs_from_preferences( - packs: &mut Vec, - prefs: &UserPreferences, -) -> bool { - let mut changed = false; - let legacy_prompts = prefs.style_system_prompts.clone(); - for builtin in builtin_style_packs() { - if let Some(index) = packs.iter().position(|pack| pack.id == builtin.id) { - let pack = &mut packs[index]; - if pack.kind != StylePackKind::Builtin { - pack.kind = StylePackKind::Builtin; - changed = true; - } - if pack.name.trim().is_empty() { - pack.name = builtin.name.clone(); - changed = true; - } - if pack.description.trim().is_empty() { - pack.description = builtin.description.clone(); - changed = true; - } - if pack.prompt.trim().is_empty() { - pack.prompt = builtin.prompt.clone(); - changed = true; - } - if pack.examples.is_empty() { - pack.examples = builtin.examples.clone(); - changed = true; - } - if pack.tags.is_empty() { - pack.tags = builtin.tags.clone(); - changed = true; - } - if pack.version.trim().is_empty() { - pack.version = builtin.version.clone(); - changed = true; - } - if pack.author.is_none() { - pack.author = builtin.author.clone(); - changed = true; - } - if pack.compatible_app_version.is_none() { - pack.compatible_app_version = builtin.compatible_app_version.clone(); - changed = true; - } - if pack.created_at.is_none() { - pack.created_at = Some(Utc::now().to_rfc3339()); - changed = true; - } - if pack.base_mode != builtin.base_mode { - pack.base_mode = builtin.base_mode; - changed = true; - } - } else { - let mut pack = builtin.clone(); - pack.prompt = legacy_prompts.for_mode(pack.base_mode).to_string(); - pack.enabled = prefs.enabled_modes.contains(&pack.base_mode); - pack.created_at = Some(Utc::now().to_rfc3339()); - pack.updated_at = Some(Utc::now().to_rfc3339()); - packs.push(pack); - changed = true; - } - } - packs.sort_by(|left, right| { - style_pack_sort_key(left) - .cmp(&style_pack_sort_key(right)) - .then_with(|| left.name.cmp(&right.name)) - }); - changed -} - -fn style_pack_sort_key(pack: &StylePack) -> (u8, u8) { - let kind_rank = match pack.kind { - StylePackKind::Builtin => 0, - StylePackKind::Imported => 1, - }; - let mode_rank = match pack.base_mode { - PolishMode::Raw => 0, - PolishMode::Light => 1, - PolishMode::Structured => 2, - PolishMode::Formal => 3, - }; - (kind_rank, mode_rank) -} - -fn ensure_at_least_one_style_pack_enabled(packs: &mut [StylePack]) -> bool { - if packs.iter().any(|pack| pack.enabled) { - return false; - } - if let Some(pack) = packs - .iter_mut() - .find(|pack| pack.id == default_active_style_pack_id()) - { - pack.enabled = true; - pack.updated_at = Some(Utc::now().to_rfc3339()); - return true; - } - if let Some(first) = packs.first_mut() { - first.enabled = true; - first.updated_at = Some(Utc::now().to_rfc3339()); - return true; - } - false -} - -pub fn sync_style_pack_preferences(prefs: &mut UserPreferences, packs: &[StylePack]) -> bool { - let previous_active_style_pack_id = prefs.active_style_pack_id.clone(); - let previous_default_mode = prefs.default_mode; - let previous_enabled_modes = prefs.enabled_modes.clone(); - let enabled: Vec<&StylePack> = packs.iter().filter(|pack| pack.enabled).collect(); - let active = packs - .iter() - .find(|pack| pack.id == prefs.active_style_pack_id && pack.enabled) - .or_else(|| { - packs - .iter() - .find(|pack| pack.id == builtin_style_pack_id(prefs.default_mode) && pack.enabled) - }) - .or_else(|| enabled.first().copied()); - - let Some(active_pack) = active else { - return false; - }; - - let mut changed = false; - if prefs.active_style_pack_id != active_pack.id { - prefs.active_style_pack_id = active_pack.id.clone(); - changed = true; - } - if prefs.default_mode != active_pack.base_mode { - prefs.default_mode = active_pack.base_mode; - changed = true; - } - - let next_enabled_modes = enabled_modes_from_style_packs(packs); - if prefs.enabled_modes != next_enabled_modes { - prefs.enabled_modes = next_enabled_modes; - changed = true; - } - - if sync_builtin_style_prompt_preferences(prefs, packs) { - changed = true; - } - - if changed { - log::info!( - "[style-pack] sync_prefs active:{}->{} default_mode:{:?}->{:?} enabled_modes:{:?}->{:?}", - previous_active_style_pack_id, - prefs.active_style_pack_id, - previous_default_mode, - prefs.default_mode, - previous_enabled_modes, - prefs.enabled_modes - ); - } - - changed -} - -fn sync_builtin_style_prompt_preferences(prefs: &mut UserPreferences, packs: &[StylePack]) -> bool { - let mut changed = false; - let mut saw_builtin = false; - for mode in [ - PolishMode::Raw, - PolishMode::Light, - PolishMode::Structured, - PolishMode::Formal, - ] { - let Some(pack) = packs - .iter() - .find(|pack| pack.kind == StylePackKind::Builtin && pack.base_mode == mode) - else { - continue; - }; - saw_builtin = true; - let next_prompt = pack.prompt.clone(); - let current_prompt = prefs.style_system_prompts.for_mode(mode); - if current_prompt == next_prompt { - continue; - } - match mode { - PolishMode::Raw => prefs.style_system_prompts.raw = next_prompt, - PolishMode::Light => prefs.style_system_prompts.light = next_prompt, - PolishMode::Structured => prefs.style_system_prompts.structured = next_prompt, - PolishMode::Formal => prefs.style_system_prompts.formal = next_prompt, - } - changed = true; - } - - if saw_builtin && prefs.custom_style_prompts != CustomStylePrompts::default() { - prefs.custom_style_prompts = CustomStylePrompts::default(); - changed = true; - } - - changed -} - -pub fn enabled_modes_from_style_packs(packs: &[StylePack]) -> Vec { - let mut modes = Vec::new(); - for mode in [ - PolishMode::Raw, - PolishMode::Light, - PolishMode::Structured, - PolishMode::Formal, - ] { - if packs - .iter() - .any(|pack| pack.enabled && pack.base_mode == mode) - { - modes.push(mode); - } - } - modes -} - -fn builtin_mode_from_style_pack_id(id: &str) -> Option { - for mode in [ - PolishMode::Raw, - PolishMode::Light, - PolishMode::Structured, - PolishMode::Formal, - ] { - if builtin_style_pack_id(mode) == id { - return Some(mode); - } - } - None -} - -fn merge_style_pack_update(existing: StylePack, incoming: StylePack) -> Result { - if existing.id != incoming.id { - return Err(anyhow!("style pack id cannot be changed")); - } - let mut updated = existing; - updated.name = normalize_required_text(&incoming.name, "style pack name")?; - updated.description = incoming.description.trim().to_string(); - updated.author = normalize_optional_text(incoming.author); - updated.version = normalize_version(&incoming.version); - updated.prompt = incoming.prompt; - updated.examples = normalize_examples(incoming.examples); - updated.tags = normalize_tags(&incoming.tags); - updated.recommended_model = normalize_optional_text(incoming.recommended_model); - updated.compatible_app_version = normalize_optional_text(incoming.compatible_app_version); - // origin 字段是 marketplace_install 之后的「衍生关系绑定」,**不能**走通用 save 路径覆盖 - // ——否则前端 save 时丢失 originPackId 就会清掉关联。要写 origin 走专用的 set_origin。 - updated.updated_at = Some(Utc::now().to_rfc3339()); - Ok(updated) -} - -fn normalize_examples(examples: Vec) -> Vec { - examples - .into_iter() - .filter_map(|example| { - let input = example.input.trim().to_string(); - let output = example.output.trim().to_string(); - if input.is_empty() && output.is_empty() { - return None; - } - Some(StylePackExample { - title: normalize_optional_text(example.title), - input, - output, - }) - }) - .collect() -} - -fn normalize_tags(tags: &[String]) -> Vec { - let mut normalized = Vec::new(); - for tag in tags { - let trimmed = tag.trim(); - if trimmed.is_empty() || normalized.iter().any(|existing| existing == trimmed) { - continue; - } - normalized.push(trimmed.to_string()); - } - normalized -} - -fn normalize_optional_text(value: Option) -> Option { - value.and_then(|text| { - let trimmed = text.trim(); - if trimmed.is_empty() { - None - } else { - Some(trimmed.to_string()) - } - }) -} - -fn normalize_required_text(value: &str, field: &str) -> Result { - let trimmed = value.trim(); - if trimmed.is_empty() { - return Err(anyhow!("{field} is empty")); - } - Ok(trimmed.to_string()) -} - -fn normalize_version(value: &str) -> String { - let trimmed = value.trim(); - if trimmed.is_empty() { - "1.0.0".into() - } else { - trimmed.to_string() - } -} - -fn unique_imported_style_pack_id(existing: &[StylePack], requested_id: &str) -> String { - let base = sanitize_style_pack_id(requested_id); - if !existing.iter().any(|pack| pack.id == base) { - return base; - } - let mut index = 2usize; - loop { - let candidate = format!("{base}-{index}"); - if !existing.iter().any(|pack| pack.id == candidate) { - return candidate; - } - index = index.saturating_add(1); - } -} - -fn sanitize_style_pack_id(requested_id: &str) -> String { - let mut output = String::new(); - for ch in requested_id.trim().chars() { - if ch.is_ascii_alphanumeric() { - output.push(ch.to_ascii_lowercase()); - } else if matches!(ch, '-' | '_' | '.') { - output.push(ch); - } else if matches!(ch, ' ' | '/' | '\\') { - output.push('-'); - } - } - let compact = output.trim_matches('-').trim_matches('.').trim_matches('_'); - if compact.is_empty() { - format!("imported-{}", Uuid::new_v4().simple()) - } else if compact.starts_with("builtin.") { - format!("imported.{compact}") - } else { - compact.to_string() - } -} - -fn read_zip_json_entry Deserialize<'de>>( - archive: &mut zip::ZipArchive, - entry_name: &str, -) -> Result { - let text = read_zip_string_entry(archive, entry_name)?; - serde_json::from_str(&text) - .with_context(|| format!("decode style pack zip entry failed: {entry_name}")) -} - -fn read_zip_string_entry( - archive: &mut zip::ZipArchive, - entry_name: &str, -) -> Result { - let mut file = archive - .by_name(entry_name) - .with_context(|| format!("missing style pack zip entry: {entry_name}"))?; - let mut buffer = String::new(); - file.read_to_string(&mut buffer) - .with_context(|| format!("read style pack zip entry failed: {entry_name}"))?; - Ok(buffer) -} - -fn extract_style_pack_icon( - archive: &mut zip::ZipArchive, - asset_root: &Path, - pack_id: &str, - entry_name: &str, -) -> Result> { - let mut file = archive - .by_name(entry_name) - .with_context(|| format!("missing style pack icon entry: {entry_name}"))?; - let file_name = Path::new(entry_name) - .file_name() - .and_then(|name| name.to_str()) - .ok_or_else(|| anyhow!("invalid style pack icon file name"))?; - let target_dir = asset_root.join(pack_id); - ensure_dir(&target_dir)?; - let target_path = target_dir.join(file_name); - let mut bytes = Vec::new(); - file.read_to_end(&mut bytes) - .with_context(|| format!("read style pack icon failed: {entry_name}"))?; - fs::write(&target_path, &bytes) - .with_context(|| format!("write style pack icon failed: {}", target_path.display()))?; - Ok(Some(target_path.to_string_lossy().to_string())) -} - -fn remove_style_pack_assets(asset_root: &Path, pack: &StylePack) { - if let Some(icon_path) = pack.icon_path.as_deref() { - let path = Path::new(icon_path); - let _ = fs::remove_file(path); - if let Some(parent) = path.parent() { - let _ = fs::remove_dir(parent); - } - } else { - let dir = asset_root.join(&pack.id); - let _ = fs::remove_dir_all(dir); - } -} - -// ───────────────────────── DictionaryStore ───────────────────────── - -pub struct DictionaryStore { - path: PathBuf, - lock: Mutex<()>, -} - -impl DictionaryStore { - pub fn new() -> Result { - let dir = data_dir()?; - ensure_dir(&dir)?; - Ok(Self { - path: dir.join(VOCAB_FILE), - lock: Mutex::new(()), - }) - } - - /// 降级实例:data_dir 不可用时使用临时路径,读写会安静地失败或返回空。 - pub(crate) fn new_fallback() -> Self { - Self { - path: std::env::temp_dir().join("openless_vocab_fallback.json"), - lock: Mutex::new(()), - } - } - - pub fn list(&self) -> Result> { - let _guard = self.lock.lock(); - self.read_locked() - } - - pub fn add(&self, phrase: String, note: Option) -> Result { - 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 { - 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> { - read_or_default::>(&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 { - let dir = data_dir()?; - ensure_dir(&dir)?; - read_or_default::(&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) -} - -// ───────────────────────── CorrectionRuleStore ───────────────────────── - -pub struct CorrectionRuleStore { - path: PathBuf, - lock: Mutex<()>, -} - -impl CorrectionRuleStore { - pub fn new() -> Result { - let dir = data_dir()?; - ensure_dir(&dir)?; - Ok(Self { - path: dir.join(CORRECTION_RULES_FILE), - lock: Mutex::new(()), - }) - } - - /// 降级实例:data_dir 不可用时使用临时路径,读写会安静地失败或返回空。 - pub(crate) fn new_fallback() -> Self { - Self { - path: std::env::temp_dir().join("openless_correction_rules_fallback.json"), - lock: Mutex::new(()), - } - } - - pub fn list(&self) -> Result> { - let _guard = self.lock.lock(); - self.read_locked() - } - - pub fn add(&self, pattern: String, replacement: String) -> Result { - let pattern = pattern.trim().to_string(); - let replacement = replacement.trim().to_string(); - validate_correction_rule_syntax(&pattern, &replacement)?; - let _guard = self.lock.lock(); - let mut rules = self.read_locked()?; - let rule = CorrectionRule { - id: Uuid::new_v4().to_string(), - pattern, - replacement, - enabled: true, - created_at: Utc::now().to_rfc3339(), - }; - rules.insert(0, rule.clone()); - self.write_locked(&rules)?; - Ok(rule) - } - - pub fn remove(&self, id: &str) -> Result<()> { - let _guard = self.lock.lock(); - let mut rules = self.read_locked()?; - let before = rules.len(); - rules.retain(|r| r.id != id); - if rules.len() == before { - return Ok(()); - } - self.write_locked(&rules) - } - - pub fn set_enabled(&self, id: &str, enabled: bool) -> Result<()> { - let _guard = self.lock.lock(); - let mut rules = self.read_locked()?; - let mut found = false; - for rule in rules.iter_mut() { - if rule.id == id { - rule.enabled = enabled; - found = true; - break; - } - } - if !found { - return Err(anyhow!("correction rule {} not found", id)); - } - self.write_locked(&rules) - } - - fn read_locked(&self) -> Result> { - read_or_default::>(&self.path) - } - - fn write_locked(&self, rules: &[CorrectionRule]) -> Result<()> { - let json = serde_json::to_vec_pretty(rules).context("encode correction rules failed")?; - atomic_write(&self.path, &json) - } -} - -fn validate_correction_rule_syntax(pattern: &str, replacement: &str) -> Result<()> { - if pattern.is_empty() { - return Err(anyhow!("correction rule pattern is empty")); - } - let pattern_token_count = pattern.matches(CORRECTION_NUM_TOKEN).count(); - if pattern_token_count > 1 { - return Err(anyhow!("unsupported correction rule syntax")); - } - if replacement.contains(CORRECTION_NUM_TOKEN) && pattern_token_count == 0 { - return Err(anyhow!("unsupported correction rule syntax")); - } - if pattern_token_count == 1 { - let Some((prefix, suffix)) = pattern.split_once(CORRECTION_NUM_TOKEN) else { - return Err(anyhow!("unsupported correction rule syntax")); - }; - if prefix.is_empty() && suffix.is_empty() { - return Err(anyhow!("unsupported correction rule syntax")); - } - } - Ok(()) -} - -// ───────────────────────── 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, - /// Active ASR provider's optional hotword vocabulary ID. - AsrVocabularyId, -} - -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", - CredentialAccount::AsrVocabularyId => "asr.vocabulary_id", - } - } - - pub fn all() -> &'static [CredentialAccount] { - &[ - CredentialAccount::VolcengineAppKey, - CredentialAccount::VolcengineAccessKey, - CredentialAccount::VolcengineResourceId, - CredentialAccount::ArkApiKey, - CredentialAccount::ArkModelId, - CredentialAccount::ArkEndpoint, - CredentialAccount::AsrApiKey, - CredentialAccount::AsrEndpoint, - CredentialAccount::AsrModel, - CredentialAccount::AsrVocabularyId, - ] - } -} - -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CredentialsSnapshot { - pub volcengine_app_key: Option, - pub volcengine_access_key: Option, - pub volcengine_resource_id: Option, - pub asr_api_key: Option, - pub asr_endpoint: Option, - pub asr_model: Option, - pub ark_api_key: Option, - pub ark_model_id: Option, - pub ark_endpoint: Option, -} - -/// 凭据存储——系统凭据库;旧 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> { - 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_for_update()?; - 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_for_update()?; - 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_for_update()?; - 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_for_update()?; - 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::{ - chunk_json_payload, list_vocab_presets, read_preferences, save_vocab_presets, - sync_style_pack_preferences, validate_correction_rule_syntax, - KEYRING_CHUNK_MAX_UTF16_UNITS, - }; - use crate::types::{builtin_style_packs, CustomStylePrompts, VocabPreset, VocabPresetStore}; - use std::fs; - use std::path::PathBuf; - - #[test] - fn credential_payload_chunks_stay_under_windows_blob_limit() { - let payload = format!( - "{}{}{}", - "a".repeat(KEYRING_CHUNK_MAX_UTF16_UNITS + 25), - "😀".repeat(20), - "b".repeat(KEYRING_CHUNK_MAX_UTF16_UNITS + 25) - ); - let chunks = chunk_json_payload(&payload); - assert!(chunks.len() > 1); - assert_eq!(chunks.concat(), payload); - assert!(chunks - .iter() - .all(|chunk| chunk.encode_utf16().count() <= KEYRING_CHUNK_MAX_UTF16_UNITS)); - } - - #[test] - fn legacy_streaming_insert_false_is_migrated_and_marker_is_persisted() { - let tmp: PathBuf = - std::env::temp_dir().join(format!("openless-prefs-test-{}", uuid::Uuid::new_v4())); - fs::create_dir_all(&tmp).expect("create temp dir"); - let path = tmp.join("preferences.json"); - fs::write( - &path, - r#"{ - "streamingInsert": false, - "streamingInsertSaveClipboard": true - }"#, - ) - .expect("write legacy prefs"); - - let prefs = read_preferences(&path).expect("read prefs"); - assert!(prefs.streaming_insert); - assert!(prefs.streaming_insert_default_migrated); - - let saved: serde_json::Value = - serde_json::from_slice(&fs::read(&path).expect("read saved prefs")) - .expect("decode saved prefs"); - assert_eq!( - saved - .get("streamingInsert") - .and_then(|value| value.as_bool()), - Some(true) - ); - assert_eq!( - saved - .get("streamingInsertDefaultMigrated") - .and_then(|value| value.as_bool()), - Some(true) - ); - - let _ = fs::remove_dir_all(&tmp); - } - - #[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); - } - - #[test] - fn custom_models_root_uses_openless_models_suffix() { - let tmp: PathBuf = - std::env::temp_dir().join(format!("openless-model-root-{}", uuid::Uuid::new_v4())); - let root = super::models_root_for_base_dir(Some(tmp.to_string_lossy().as_ref())) - .expect("build custom models root"); - - assert_eq!(root, tmp.join("OpenLess").join("models")); - assert!(root.is_dir()); - - let _ = fs::remove_dir_all(&tmp); - } - - #[test] - fn migrate_models_root_merges_without_overwriting_target_files() { - let tmp: PathBuf = - std::env::temp_dir().join(format!("openless-model-migrate-{}", uuid::Uuid::new_v4())); - let old_root = tmp.join("old"); - let new_root = tmp.join("new"); - fs::create_dir_all(old_root.join("qwen3-asr")).expect("create old qwen dir"); - fs::create_dir_all(new_root.join("qwen3-asr")).expect("create new qwen dir"); - fs::write(old_root.join("qwen3-asr").join("moved.bin"), b"old").expect("write moved"); - fs::write(old_root.join("qwen3-asr").join("conflict.bin"), b"old") - .expect("write old conflict"); - fs::write(new_root.join("qwen3-asr").join("conflict.bin"), b"new") - .expect("write new conflict"); - - super::migrate_models_root(&old_root, &new_root).expect("migrate models root"); - - assert_eq!( - fs::read(new_root.join("qwen3-asr").join("moved.bin")).expect("read moved"), - b"old" - ); - assert_eq!( - fs::read(new_root.join("qwen3-asr").join("conflict.bin")).expect("read new conflict"), - b"new" - ); - assert_eq!( - fs::read(old_root.join("qwen3-asr").join("conflict.bin")).expect("read old conflict"), - b"old" - ); - - let _ = fs::remove_dir_all(&tmp); - } - - #[test] - fn correction_rule_syntax_rejects_silent_noops() { - assert!(validate_correction_rule_syntax("{num}粒", "{num}例").is_ok()); - assert!(validate_correction_rule_syntax("几粒", "几例").is_ok()); - assert!(validate_correction_rule_syntax("", "几例").is_err()); - assert!(validate_correction_rule_syntax("{num}", "{num}例").is_err()); - assert!(validate_correction_rule_syntax("{num}到{num}粒", "{num}例").is_err()); - assert!(validate_correction_rule_syntax("几粒", "{num}例").is_err()); - } - - #[test] - fn sync_style_pack_preferences_uses_builtin_store_prompts_as_source_of_truth() { - let mut prefs = crate::types::UserPreferences { - style_system_prompts: crate::types::StyleSystemPrompts { - raw: "stale raw".into(), - light: "stale light".into(), - structured: "stale structured".into(), - formal: "stale formal".into(), - }, - custom_style_prompts: CustomStylePrompts { - raw: String::new(), - light: "legacy extra instruction".into(), - structured: String::new(), - formal: String::new(), - }, - ..Default::default() - }; - let mut packs = builtin_style_packs(); - let light = packs - .iter_mut() - .find(|pack| pack.id == "builtin.light") - .expect("builtin light pack"); - light.prompt = "fresh light prompt from store".into(); - - assert!(sync_style_pack_preferences(&mut prefs, &packs)); - assert_eq!(prefs.style_system_prompts.raw, packs[0].prompt); - assert_eq!( - prefs.style_system_prompts.light, - "fresh light prompt from store" - ); - assert_eq!(prefs.style_system_prompts.structured, packs[2].prompt); - assert_eq!(prefs.style_system_prompts.formal, packs[3].prompt); - assert_eq!(prefs.custom_style_prompts, CustomStylePrompts::default()); - } -} diff --git a/openless-all/app/src-tauri/src/persistence/correction.rs b/openless-all/app/src-tauri/src/persistence/correction.rs new file mode 100644 index 00000000..16a9e806 --- /dev/null +++ b/openless-all/app/src-tauri/src/persistence/correction.rs @@ -0,0 +1,136 @@ +#![cfg_attr(target_os = "linux", allow(dead_code, unused_variables))] +//! Correction-rule store: literal/`{num}`-token find-and-replace rules. + +use std::path::PathBuf; + +use anyhow::{anyhow, Context, Result}; +use chrono::Utc; +use parking_lot::Mutex; +use uuid::Uuid; + +use super::{atomic_write, data_dir, ensure_dir, read_or_default}; +use crate::types::CorrectionRule; + +const CORRECTION_RULES_FILE: &str = "correction-rules.json"; +const CORRECTION_NUM_TOKEN: &str = "{num}"; + +pub struct CorrectionRuleStore { + path: PathBuf, + lock: Mutex<()>, +} + +impl CorrectionRuleStore { + pub fn new() -> Result { + let dir = data_dir()?; + ensure_dir(&dir)?; + Ok(Self { + path: dir.join(CORRECTION_RULES_FILE), + lock: Mutex::new(()), + }) + } + + /// 降级实例:data_dir 不可用时使用临时路径,读写会安静地失败或返回空。 + pub(crate) fn new_fallback() -> Self { + Self { + path: std::env::temp_dir().join("openless_correction_rules_fallback.json"), + lock: Mutex::new(()), + } + } + + pub fn list(&self) -> Result> { + let _guard = self.lock.lock(); + self.read_locked() + } + + pub fn add(&self, pattern: String, replacement: String) -> Result { + let pattern = pattern.trim().to_string(); + let replacement = replacement.trim().to_string(); + validate_correction_rule_syntax(&pattern, &replacement)?; + let _guard = self.lock.lock(); + let mut rules = self.read_locked()?; + let rule = CorrectionRule { + id: Uuid::new_v4().to_string(), + pattern, + replacement, + enabled: true, + created_at: Utc::now().to_rfc3339(), + }; + rules.insert(0, rule.clone()); + self.write_locked(&rules)?; + Ok(rule) + } + + pub fn remove(&self, id: &str) -> Result<()> { + let _guard = self.lock.lock(); + let mut rules = self.read_locked()?; + let before = rules.len(); + rules.retain(|r| r.id != id); + if rules.len() == before { + return Ok(()); + } + self.write_locked(&rules) + } + + pub fn set_enabled(&self, id: &str, enabled: bool) -> Result<()> { + let _guard = self.lock.lock(); + let mut rules = self.read_locked()?; + let mut found = false; + for rule in rules.iter_mut() { + if rule.id == id { + rule.enabled = enabled; + found = true; + break; + } + } + if !found { + return Err(anyhow!("correction rule {} not found", id)); + } + self.write_locked(&rules) + } + + fn read_locked(&self) -> Result> { + read_or_default::>(&self.path) + } + + fn write_locked(&self, rules: &[CorrectionRule]) -> Result<()> { + let json = serde_json::to_vec_pretty(rules).context("encode correction rules failed")?; + atomic_write(&self.path, &json) + } +} + +fn validate_correction_rule_syntax(pattern: &str, replacement: &str) -> Result<()> { + if pattern.is_empty() { + return Err(anyhow!("correction rule pattern is empty")); + } + let pattern_token_count = pattern.matches(CORRECTION_NUM_TOKEN).count(); + if pattern_token_count > 1 { + return Err(anyhow!("unsupported correction rule syntax")); + } + if replacement.contains(CORRECTION_NUM_TOKEN) && pattern_token_count == 0 { + return Err(anyhow!("unsupported correction rule syntax")); + } + if pattern_token_count == 1 { + let Some((prefix, suffix)) = pattern.split_once(CORRECTION_NUM_TOKEN) else { + return Err(anyhow!("unsupported correction rule syntax")); + }; + if prefix.is_empty() && suffix.is_empty() { + return Err(anyhow!("unsupported correction rule syntax")); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::validate_correction_rule_syntax; + + #[test] + fn correction_rule_syntax_rejects_silent_noops() { + assert!(validate_correction_rule_syntax("{num}粒", "{num}例").is_ok()); + assert!(validate_correction_rule_syntax("几粒", "几例").is_ok()); + assert!(validate_correction_rule_syntax("", "几例").is_err()); + assert!(validate_correction_rule_syntax("{num}", "{num}例").is_err()); + assert!(validate_correction_rule_syntax("{num}到{num}粒", "{num}例").is_err()); + assert!(validate_correction_rule_syntax("几粒", "{num}例").is_err()); + } +} diff --git a/openless-all/app/src-tauri/src/persistence/credentials.rs b/openless-all/app/src-tauri/src/persistence/credentials.rs new file mode 100644 index 00000000..05718145 --- /dev/null +++ b/openless-all/app/src-tauri/src/persistence/credentials.rs @@ -0,0 +1,880 @@ +#![cfg_attr(target_os = "linux", allow(dead_code, unused_variables))] +//! Credentials vault. +//! +//! 正常读写走系统凭据库;旧 plaintext JSON 只作为迁移来源。为保持多 provider +//! schema 与 active provider 状态,凭据库里保存一个 v1 JSON payload;payload 会按平台 +//! 凭据库限制拆成多个条目,避免 Windows 单条凭据 2560 bytes 限制。 +//! +//! v1 schema: +//! { +//! "version": 1, +//! "active": { "asr": "", "llm": "" }, +//! "providers": { +//! "asr": { "": { "appKey", "accessKey", "resourceId", "apiKey", "baseURL", "model", "vocabularyId" } }, +//! "llm": { "": { "displayName", "apiKey", "baseURL", "model", "temperature", "extraHeaders" } } +//! } +//! } +//! +//! "ark.api_key"/"volcengine.app_key" 等账户名按 Swift 语义路由到 active provider。 + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; + +use anyhow::{Context, Result}; +use parking_lot::Mutex; +use serde::{Deserialize, Serialize}; + +// `anyhow!` is only invoked from the keyring (non-Android) code paths; gating the +// import keeps the Android build free of an unused-import warning. +#[cfg(not(target_os = "android"))] +use anyhow::anyhow; +#[cfg(target_os = "android")] +use std::fs; + +/// 旧版 plaintext JSON 凭据路径。仅作为迁移来源;成功写入系统凭据库后会删除。 +const LEGACY_CREDS_DIR: &str = ".openless"; +const LEGACY_CREDS_FILE: &str = "credentials.json"; + +const KEYRING_CREDENTIALS_ACCOUNT: &str = "credentials.v1"; +const KEYRING_CREDENTIALS_CHUNK_PREFIX: &str = "credentials.v1.chunk."; +#[cfg(target_os = "android")] +const ANDROID_CREDENTIALS_FILE: &str = "credentials.enc.json"; +// Windows Credential Manager caps one credential blob at 2560 bytes. keyring stores +// passwords as UTF-16 on Windows, so keep each JSON chunk comfortably below that. +const KEYRING_CHUNK_MAX_UTF16_UNITS: usize = 1000; + +static CREDENTIALS_LOCK: OnceLock> = OnceLock::new(); + +fn credentials_lock() -> &'static Mutex<()> { + CREDENTIALS_LOCK.get_or_init(|| Mutex::new(())) +} + +/// Process-wide credentials cache. +/// +/// Without this cache every `CredentialsVault::get_*` / `snapshot` call hits +/// `load_credentials()` → `load_keyring_credentials()` which reads the +/// manifest entry plus every chunk entry from the OS keyring. On macOS each +/// distinct keychain entry has its own ACL — so an ad-hoc-signed binary (or +/// any binary whose ACL grants haven't been set up yet) prompts on every read +/// of every entry. A single dictation cycle reads credentials 5–10 times, +/// times (1 manifest + N chunks) entries → tens of "OpenLess wants to use +/// the keychain" prompts per recording. +/// +/// With this cache the first read populates `Some(CredsRoot)` and every +/// subsequent read in the same process is silent. `save_credentials` keeps +/// the cache in sync after writes so Settings → Recording credential edits +/// take effect immediately. +/// +/// Cross-process changes (e.g. user edits via `security` CLI, or another +/// instance of the app — single-instance is enforced but defense in depth) +/// will be invisible until the next process launch. Acceptable trade-off +/// per the credential vault contract: the keyring is owned by this app. +static CREDENTIALS_CACHE: OnceLock>> = OnceLock::new(); + +fn credentials_cache() -> &'static Mutex> { + CREDENTIALS_CACHE.get_or_init(|| Mutex::new(None)) +} + +fn store_credentials_cache(root: &CredsRoot) { + *credentials_cache().lock() = Some(root.clone()); +} + +#[cfg(test)] +fn reset_credentials_cache_for_tests() { + *credentials_cache().lock() = None; +} + +#[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 { + #[cfg(target_os = "windows")] + { + return crate::asr::local::foundry::PROVIDER_ID.into(); + } + #[cfg(not(target_os = "windows"))] + { + "volcengine".into() + } +} +fn creds_default_llm() -> String { + "ark".into() +} + +#[derive(Debug, Serialize, Deserialize, Default, Clone)] +struct CredsProviders { + #[serde(default)] + asr: HashMap, + #[serde(default)] + llm: HashMap, +} + +#[derive(Debug, Serialize, Deserialize, Default, Clone)] +#[allow(non_snake_case)] +struct CredsAsrEntry { + #[serde(skip_serializing_if = "Option::is_none")] + apiKey: Option, + #[serde(skip_serializing_if = "Option::is_none")] + baseURL: Option, + #[serde(skip_serializing_if = "Option::is_none")] + model: Option, + #[serde(skip_serializing_if = "Option::is_none")] + appKey: Option, + #[serde(skip_serializing_if = "Option::is_none")] + accessKey: Option, + #[serde(skip_serializing_if = "Option::is_none")] + resourceId: Option, + #[serde(skip_serializing_if = "Option::is_none")] + vocabularyId: Option, +} + +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() + && self.vocabularyId.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, + #[serde(skip_serializing_if = "Option::is_none")] + apiKey: Option, + #[serde(skip_serializing_if = "Option::is_none")] + baseURL: Option, + #[serde(skip_serializing_if = "Option::is_none")] + model: Option, + #[serde(skip_serializing_if = "Option::is_none")] + temperature: Option, + #[serde(skip_serializing_if = "Option::is_none")] + extraHeaders: Option>, +} + +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 { + // 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)) + } +} + +#[cfg(not(target_os = "android"))] +fn keyring_entry() -> Result { + keyring_entry_for(KEYRING_CREDENTIALS_ACCOUNT) +} + +#[cfg(not(target_os = "android"))] +fn keyring_entry_for(account: &str) -> Result { + keyring::Entry::new(CredentialsVault::SERVICE_NAME, account) + .context("open system credential vault") +} + +#[cfg(target_os = "android")] +fn android_credentials_path() -> Result { + Ok(super::data_dir()?.join(ANDROID_CREDENTIALS_FILE)) +} + +#[cfg(target_os = "android")] +fn load_android_credentials() -> Result> { + let path = android_credentials_path()?; + if !path.exists() { + return Ok(None); + } + let bytes = fs::read(&path).with_context(|| format!("read failed: {}", path.display()))?; + if bytes.is_empty() { + return Ok(None); + } + // Stub: base64 envelope — replace with Keystore-backed AES when JNI lands. + use base64::Engine; + let decoded = base64::engine::general_purpose::STANDARD + .decode(bytes) + .context("decode android credentials envelope")?; + let root = + serde_json::from_slice::(&decoded).context("parse android credentials json")?; + Ok(Some(root)) +} + +#[cfg(target_os = "android")] +fn save_android_credentials(root: &CredsRoot) -> Result<()> { + let cleaned = clean_credentials(root); + let json = serde_json::to_string(&cleaned).context("encode credentials failed")?; + use base64::Engine; + let encoded = base64::engine::general_purpose::STANDARD.encode(json.as_bytes()); + let path = android_credentials_path()?; + super::ensure_dir(path.parent().unwrap_or_else(|| Path::new(".")))?; + fs::write(&path, encoded).with_context(|| format!("write failed: {}", path.display()))?; + Ok(()) +} + +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 { + if !path.exists() { + return None; + } + let bytes = match std::fs::read(path) { + Ok(b) => b, + Err(e) => { + log::warn!("[vault] read legacy {} failed: {}", path.display(), e); + return None; + } + }; + match serde_json::from_slice::(&bytes) { + Ok(root) => Some(root), + Err(e) => { + log::warn!("[vault] parse legacy {} failed: {}", path.display(), e); + None + } + } +} + +fn remove_legacy_credentials_file() -> Result<()> { + let Ok(path) = credentials_path() else { + return Ok(()); + }; + if path.exists() { + std::fs::remove_file(&path) + .with_context(|| format!("remove legacy credentials file {}", path.display()))?; + } + Ok(()) +} + +fn remove_legacy_credentials_file_best_effort() { + if let Err(e) = remove_legacy_credentials_file() { + log::warn!("[vault] remove legacy credentials file failed: {e}"); + } +} + +#[derive(Debug, Serialize, Deserialize)] +struct CredsChunkManifest { + openless_credentials_storage: String, + version: u32, + /// 旧版本(v1 早期)每次 save 都生成新 UUID 作为 chunk account 命名前缀, + /// 这让 macOS Keychain 的「始终允许」每次保存后失效 → 反复弹 ACL 弹窗。 + /// 现在 save 总用稳定 chunk.{index} 名,此字段仅向后兼容旧 manifest 读取。 + #[serde(default, skip_serializing_if = "Option::is_none")] + generation: Option, + chunks: usize, +} + +/// 旧版(generation=Some):`credentials.v1.chunk..{index}` +/// 新版(generation=None):`credentials.v1.chunk.{index}` —— 稳定名,ACL 长期有效 +fn chunk_account(generation: Option<&str>, index: usize) -> String { + match generation { + Some(gen) => format!("{KEYRING_CREDENTIALS_CHUNK_PREFIX}{gen}.{index}"), + None => format!("{KEYRING_CREDENTIALS_CHUNK_PREFIX}{index}"), + } +} + +fn chunk_json_payload(json: &str) -> Vec { + let mut chunks = Vec::new(); + let mut current = String::new(); + let mut current_units = 0usize; + for ch in json.chars() { + let units = ch.len_utf16(); + if !current.is_empty() && current_units + units > KEYRING_CHUNK_MAX_UTF16_UNITS { + chunks.push(std::mem::take(&mut current)); + current_units = 0; + } + current.push(ch); + current_units += units; + } + if !current.is_empty() || json.is_empty() { + chunks.push(current); + } + chunks +} + +fn read_chunk_manifest(json: &str) -> Option { + let manifest = serde_json::from_str::(json).ok()?; + if manifest.openless_credentials_storage == "chunked" && manifest.version == 1 { + Some(manifest) + } else { + None + } +} + +#[cfg(not(target_os = "android"))] +fn get_keyring_password(account: &str) -> Result> { + match keyring_entry_for(account)?.get_password() { + Ok(value) => Ok(Some(value)), + Err(keyring::Error::NoEntry) => Ok(None), + Err(e) => { + Err(anyhow!(e)).with_context(|| format!("read system credential vault {account}")) + } + } +} + +#[cfg(not(target_os = "android"))] +fn delete_keyring_password(account: &str) { + match keyring_entry_for(account).and_then(|entry| { + entry + .delete_credential() + .with_context(|| format!("delete system credential vault {account}")) + }) { + Ok(()) | Err(_) => {} + } +} + +#[cfg(not(target_os = "android"))] +fn load_keyring_credentials() -> Result> { + let Some(json_or_manifest) = get_keyring_password(KEYRING_CREDENTIALS_ACCOUNT)? else { + return Ok(None); + }; + + let manifest = read_chunk_manifest(&json_or_manifest) + .ok_or_else(|| anyhow!("invalid system credential vault manifest"))?; + let mut json = String::new(); + for index in 0..manifest.chunks { + let account = chunk_account(manifest.generation.as_deref(), index); + let chunk = get_keyring_password(&account)? + .ok_or_else(|| anyhow!("missing system credential vault chunk {index}"))?; + json.push_str(&chunk); + } + + serde_json::from_str::(&json) + .map(Some) + .context("decode system credential vault payload") +} + +#[cfg(not(target_os = "android"))] +fn load_legacy_keyring_credentials() -> CredsRoot { + match load_legacy_keyring_credentials_for_update() { + Ok(root) => root, + Err(e) => { + log::warn!("[vault] read legacy vault credentials failed: {e}"); + CredsRoot::default() + } + } +} + +#[cfg(not(target_os = "android"))] +fn load_legacy_keyring_credentials_for_update() -> Result { + let mut root = CredsRoot::default(); + for account in CredentialAccount::all() { + let legacy_account = account.keyring_account(); + match get_keyring_password(legacy_account) { + Ok(Some(value)) => write_account(&mut root, *account, Some(value)), + Ok(None) => {} + Err(e) => return Err(e.context(format!("read legacy vault {legacy_account}"))), + } + } + Ok(clean_credentials(&root)) +} + +#[cfg(not(target_os = "android"))] +fn remove_legacy_keyring_credentials() { + for account in CredentialAccount::all() { + delete_keyring_password(account.keyring_account()); + } +} + +fn load_legacy_credentials() -> Option { + credentials_path() + .ok() + .and_then(|p| read_legacy_credentials_file(&p)) +} + +fn legacy_vault_has_credentials(root: &CredsRoot) -> bool { + !root.providers.asr.is_empty() || !root.providers.llm.is_empty() +} + +fn load_legacy_sources_without_migration() -> CredsRoot { + if let Some(legacy) = load_legacy_credentials() { + return legacy; + } + + #[cfg(not(target_os = "android"))] + { + let legacy_vault = load_legacy_keyring_credentials(); + if legacy_vault_has_credentials(&legacy_vault) { + return legacy_vault; + } + } + + CredsRoot::default() +} + +fn migrate_legacy_sources() -> CredsRoot { + match migrate_legacy_sources_for_update() { + Ok(root) => root, + Err(e) => { + log::warn!("[vault] legacy credential migration failed: {e}"); + load_legacy_sources_without_migration() + } + } +} + +fn migrate_legacy_sources_for_update() -> Result { + if let Some(legacy) = load_legacy_credentials() { + save_credentials(&legacy)?; + #[cfg(not(target_os = "android"))] + remove_legacy_keyring_credentials(); + return Ok(legacy); + } + + #[cfg(not(target_os = "android"))] + { + let legacy_vault = load_legacy_keyring_credentials_for_update()?; + if legacy_vault_has_credentials(&legacy_vault) { + save_credentials(&legacy_vault)?; + remove_legacy_keyring_credentials(); + return Ok(legacy_vault); + } + } + + Ok(CredsRoot::default()) +} + +fn load_credentials() -> CredsRoot { + if let Some(cached) = credentials_cache().lock().as_ref().cloned() { + return cached; + } + + #[cfg(target_os = "android")] + { + let root = match load_android_credentials() { + Ok(Some(root)) => root, + Ok(None) => CredsRoot::default(), + Err(e) => { + log::warn!("[vault] android credential read failed: {e}"); + CredsRoot::default() + } + }; + store_credentials_cache(&root); + return root; + } + + #[cfg(not(target_os = "android"))] + match load_keyring_credentials() { + Ok(Some(root)) => { + // 不在这里调 remove_legacy_keyring_credentials() —— 它内部对每个 + // 旧 account 各做一次 keyring delete,每次 delete 在 macOS Keychain + // 上仍要触发 ACL 检查。第一次成功 load 时 legacy entries 通常已经 + // 被 migrate_legacy_sources_for_update 清理过了;这里若再无脑跑, + // 只会反复弹「OpenLess 想删除 X」十几次。文件 legacy(plaintext + // JSON)不需要 ACL,可继续 best-effort 删除。 + remove_legacy_credentials_file_best_effort(); + store_credentials_cache(&root); + root + } + Ok(None) => { + // 没有现成 chunked manifest —— 走 migrate(如果有 legacy 则写入并返回写后的 root)。 + // migrate_legacy_sources 内部 save_credentials 已经会刷 cache,这里再补一次 + // 是为了「无 legacy 也无 manifest」走默认 root 的路径也能进 cache。 + let root = migrate_legacy_sources(); + store_credentials_cache(&root); + root + } + Err(e) => { + // **不缓存 keyring 错误路径下的 fallback**。Keychain 可能只是临时不可读 + // (用户尚未在第一次弹窗里点同意 / DataProtection 错误 / login keychain + // 还没 unlock);如果在这里把 legacy fallback 写进 cache,等用户授权后 + // 我们就再也不会重读 keyring,整个进程生命周期里都拿 stale 数据。下次 + // 调用让它再尝试一次 keyring。pr_agent feedback on PR #394。 + log::warn!("[vault] system credential read failed: {e}"); + load_legacy_sources_without_migration() + } + } +} + +fn load_credentials_for_update() -> Result { + if let Some(cached) = credentials_cache().lock().as_ref().cloned() { + return Ok(cached); + } + + #[cfg(target_os = "android")] + { + let root = match load_android_credentials()? { + Some(root) => root, + None => CredsRoot::default(), + }; + store_credentials_cache(&root); + return Ok(root); + } + + #[cfg(not(target_os = "android"))] + match load_keyring_credentials() { + Ok(Some(root)) => { + // 同 load_credentials:不再每次 update 都尝试 delete legacy keyring + // entries,避免反复触发 macOS Keychain ACL 弹窗。 + remove_legacy_credentials_file_best_effort(); + store_credentials_cache(&root); + Ok(root) + } + Ok(None) => { + // migrate_legacy_sources_for_update 内部如果实际 migrate 会调 + // save_credentials,cache 会被刷新;如果只返回 default root(没 legacy), + // 我们这里再显式 cache 一次防御性补一下。 + let root = migrate_legacy_sources_for_update()?; + store_credentials_cache(&root); + Ok(root) + } + // 错误路径不缓存 —— 同 load_credentials 注释;让下次读重试 keyring。 + Err(e) => Err(e), + } +} + +fn save_credentials(root: &CredsRoot) -> Result<()> { + let cleaned = clean_credentials(root); + + #[cfg(target_os = "android")] + { + save_android_credentials(&cleaned)?; + store_credentials_cache(&cleaned); + return Ok(()); + } + + #[cfg(not(target_os = "android"))] + { + let json = serde_json::to_string(&cleaned).context("encode credentials failed")?; + let previous_manifest = get_keyring_password(KEYRING_CREDENTIALS_ACCOUNT) + .ok() + .flatten() + .and_then(|value| read_chunk_manifest(&value)); + let chunks = chunk_json_payload(&json); + + // 先写所有 chunks(稳定名),再写 manifest —— 保证 partial-write 不会让 + // manifest 指向不完整 chunks。stable name 让 macOS Keychain ACL 一次允许后 + // 长期有效,不再因 UUID 轮换反复弹窗(这是 PR #277 早期 UUID-rotation + // 设计的回退)。 + for (index, chunk) in chunks.iter().enumerate() { + let account = chunk_account(None, index); + keyring_entry_for(&account)? + .set_password(chunk) + .with_context(|| format!("write system credential vault chunk {index}"))?; + } + + let manifest = CredsChunkManifest { + openless_credentials_storage: "chunked".to_string(), + version: 1, + generation: None, + chunks: chunks.len(), + }; + let manifest_json = + serde_json::to_string(&manifest).context("encode credential manifest failed")?; + keyring_entry()? + .set_password(&manifest_json) + .context("write system credential vault manifest")?; + + // 清理旧 chunks: + // 1) 旧 manifest 用 UUID generation → 那一代 chunks 全删(迁移到 stable name) + // 2) 旧 manifest 也是 stable name,但 chunks 数量比这次多 → 删多余的 idx + if let Some(previous) = previous_manifest { + match previous.generation.as_deref() { + Some(prev_gen) => { + for index in 0..previous.chunks { + delete_keyring_password(&chunk_account(Some(prev_gen), index)); + } + } + None => { + for index in chunks.len()..previous.chunks { + delete_keyring_password(&chunk_account(None, index)); + } + } + } + } + + remove_legacy_credentials_file_best_effort(); + // 写完成功后立刻刷新 process cache —— 同进程后续读不再回 Keychain。 + // 见 CREDENTIALS_CACHE 的 doc。 + store_credentials_cache(&cleaned); + Ok(()) + } +} + +fn lookup_account(root: &CredsRoot, account: CredentialAccount) -> Option { + let asr = root.providers.asr.get(&root.active.asr); + let llm = root.providers.llm.get(&root.active.llm); + let pick = |s: &Option| 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)), + CredentialAccount::AsrVocabularyId => asr.and_then(|e| pick(&e.vocabularyId)), + } +} + +fn write_account(root: &mut CredsRoot, account: CredentialAccount, value: Option) { + 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; + } + CredentialAccount::AsrVocabularyId => { + let entry = root.providers.asr.entry(asr_id).or_default(); + entry.vocabularyId = normalized; + } + } +} + +#[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, + /// Active ASR provider's optional hotword vocabulary ID. + AsrVocabularyId, +} + +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", + CredentialAccount::AsrVocabularyId => "asr.vocabulary_id", + } + } + + pub fn all() -> &'static [CredentialAccount] { + &[ + CredentialAccount::VolcengineAppKey, + CredentialAccount::VolcengineAccessKey, + CredentialAccount::VolcengineResourceId, + CredentialAccount::ArkApiKey, + CredentialAccount::ArkModelId, + CredentialAccount::ArkEndpoint, + CredentialAccount::AsrApiKey, + CredentialAccount::AsrEndpoint, + CredentialAccount::AsrModel, + CredentialAccount::AsrVocabularyId, + ] + } +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CredentialsSnapshot { + pub volcengine_app_key: Option, + pub volcengine_access_key: Option, + pub volcengine_resource_id: Option, + pub asr_api_key: Option, + pub asr_endpoint: Option, + pub asr_model: Option, + pub ark_api_key: Option, + pub ark_model_id: Option, + pub ark_endpoint: Option, +} + +/// 凭据存储——系统凭据库;旧 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> { + 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_for_update()?; + 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_for_update()?; + 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_for_update()?; + 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_for_update()?; + 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::{chunk_json_payload, KEYRING_CHUNK_MAX_UTF16_UNITS}; + + #[test] + fn credential_payload_chunks_stay_under_windows_blob_limit() { + let payload = format!( + "{}{}{}", + "a".repeat(KEYRING_CHUNK_MAX_UTF16_UNITS + 25), + "😀".repeat(20), + "b".repeat(KEYRING_CHUNK_MAX_UTF16_UNITS + 25) + ); + let chunks = chunk_json_payload(&payload); + assert!(chunks.len() > 1); + assert_eq!(chunks.concat(), payload); + assert!(chunks + .iter() + .all(|chunk| chunk.encode_utf16().count() <= KEYRING_CHUNK_MAX_UTF16_UNITS)); + } +} diff --git a/openless-all/app/src-tauri/src/persistence/dictionary.rs b/openless-all/app/src-tauri/src/persistence/dictionary.rs new file mode 100644 index 00000000..2cec8a23 --- /dev/null +++ b/openless-all/app/src-tauri/src/persistence/dictionary.rs @@ -0,0 +1,206 @@ +#![cfg_attr(target_os = "linux", allow(dead_code, unused_variables))] +//! Vocabulary dictionary store (phrase hit-counting) plus the vocab-preset +//! JSON file accessors. + +use std::path::PathBuf; + +use anyhow::{anyhow, Context, Result}; +use chrono::Utc; +use parking_lot::Mutex; +use uuid::Uuid; + +use super::{atomic_write, data_dir, ensure_dir, read_or_default}; +use crate::types::{DictionaryEntry, VocabPresetStore}; + +/// 与 Swift `Sources/OpenLessPersistence/DictionaryStore.swift` 同名, +/// 让旧版词汇表在升级后无缝继承。**不要**改成 `vocab.json`,会丢用户数据。 +const VOCAB_FILE: &str = "dictionary.json"; +const VOCAB_PRESETS_FILE: &str = "vocab-presets.json"; + +pub struct DictionaryStore { + path: PathBuf, + lock: Mutex<()>, +} + +impl DictionaryStore { + pub fn new() -> Result { + let dir = data_dir()?; + ensure_dir(&dir)?; + Ok(Self { + path: dir.join(VOCAB_FILE), + lock: Mutex::new(()), + }) + } + + /// 降级实例:data_dir 不可用时使用临时路径,读写会安静地失败或返回空。 + pub(crate) fn new_fallback() -> Self { + Self { + path: std::env::temp_dir().join("openless_vocab_fallback.json"), + lock: Mutex::new(()), + } + } + + pub fn list(&self) -> Result> { + let _guard = self.lock.lock(); + self.read_locked() + } + + pub fn add(&self, phrase: String, note: Option) -> Result { + 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 { + 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> { + read_or_default::>(&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 { + let dir = data_dir()?; + ensure_dir(&dir)?; + read_or_default::(&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) +} + +#[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); + } +} diff --git a/openless-all/app/src-tauri/src/persistence/history.rs b/openless-all/app/src-tauri/src/persistence/history.rs new file mode 100644 index 00000000..0e50792f --- /dev/null +++ b/openless-all/app/src-tauri/src/persistence/history.rs @@ -0,0 +1,135 @@ +#![cfg_attr(target_os = "linux", allow(dead_code, unused_variables))] +//! Dictation history store: newest-first JSON list with retention + count caps. + +use std::path::PathBuf; + +use anyhow::{Context, Result}; +use parking_lot::Mutex; + +use super::{atomic_write, data_dir, ensure_dir, read_or_default, HISTORY_CAP}; +use crate::types::DictationSession; + +const HISTORY_FILE: &str = "history.json"; + +pub struct HistoryStore { + path: PathBuf, + lock: Mutex<()>, +} + +impl HistoryStore { + pub fn new() -> Result { + let dir = data_dir()?; + ensure_dir(&dir)?; + Ok(Self { + path: dir.join(HISTORY_FILE), + lock: Mutex::new(()), + }) + } + + /// 在 data_dir 不可用时构造一个降级实例(指向临时目录)。 + /// 该实例在运行期间读写会安静地失败或返回空,不会 panic, + /// 也不会影响正常启动路径。 + pub(crate) fn new_fallback() -> Self { + Self { + path: std::env::temp_dir().join("openless_history_fallback.json"), + lock: Mutex::new(()), + } + } + + pub fn list(&self) -> Result> { + let _guard = self.lock.lock(); + self.read_locked() + } + + /// `retention_days == 0` 跟旧 append 行为一致(不按时间清理)。 + /// `> 0` 时在写入新条目后顺手把超过 N 天的会话裁掉,写入时就完成清理, + /// 不需要后台轮询。最后再受条数上限约束: + /// - `max_entries == None` → HISTORY_CAP (200) + /// - `max_entries == Some(n)` → clamp 到 5..=HISTORY_CAP,避免用户填 0 / 极大值。 + pub fn append_with_retention( + &self, + session: DictationSession, + retention_days: u32, + max_entries: Option, + ) -> 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 retention_days > 0 { + let cutoff = chrono::Utc::now() - chrono::Duration::days(i64::from(retention_days)); + sessions.retain(|s| { + chrono::DateTime::parse_from_rfc3339(&s.created_at) + .map(|t| t.with_timezone(&chrono::Utc) >= cutoff) + // 解析失败时保守保留——避免错误的时间戳让用户丢历史。 + .unwrap_or(true) + }); + } + let cap = max_entries + .map(|n| (n as usize).clamp(5, HISTORY_CAP)) + .unwrap_or(HISTORY_CAP); + if sessions.len() > cap { + sessions.truncate(cap); + } + self.write_locked(&sessions) + } + + /// 返回最近 N 分钟内的会话(newest-first)。`minutes == 0` → 空 Vec, + /// 调用方据此跳过对话感知 polish 路径。 + pub fn recent_within_minutes(&self, minutes: u32) -> Result> { + if minutes == 0 { + return Ok(Vec::new()); + } + let _guard = self.lock.lock(); + let sessions = self.read_locked()?; + let cutoff = chrono::Utc::now() - chrono::Duration::minutes(i64::from(minutes)); + // sessions 是 newest-first,超出窗口的会话之后的都更老,take_while 即可。 + // unwrap_or(true):时间戳解析失败时保留该条目,与 append_with_retention 的保守策略一致; + // 避免单条坏记录截断整个上下文窗口。 + let filtered: Vec = sessions + .into_iter() + .take_while(|s| { + chrono::DateTime::parse_from_rfc3339(&s.created_at) + .map(|t| t.with_timezone(&chrono::Utc) >= cutoff) + .unwrap_or(true) + }) + .collect(); + Ok(filtered) + } + + 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 update_entry(&self, updated: DictationSession) -> Result { + let _guard = self.lock.lock(); + let mut sessions = self.read_locked()?; + let Some(slot) = sessions.iter_mut().find(|s| s.id == updated.id) else { + return Ok(false); + }; + *slot = updated; + self.write_locked(&sessions)?; + Ok(true) + } + + pub fn clear(&self) -> Result<()> { + let _guard = self.lock.lock(); + self.write_locked(&Vec::::new()) + } + + fn read_locked(&self) -> Result> { + read_or_default::>(&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) + } +} diff --git a/openless-all/app/src-tauri/src/persistence/mod.rs b/openless-all/app/src-tauri/src/persistence/mod.rs new file mode 100644 index 00000000..0e99f47a --- /dev/null +++ b/openless-all/app/src-tauri/src/persistence/mod.rs @@ -0,0 +1,121 @@ +#![cfg_attr(target_os = "linux", allow(dead_code, unused_variables))] +//! 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. +//! +//! This module is split into focused submodules; everything that was previously +//! reachable as `crate::persistence::*` stays reachable via the glob re-exports +//! below. The shared filesystem helpers and the two cross-cutting constants live +//! here so every submodule can reach them through `super::`. + +use std::fs; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use serde::Deserialize; +use uuid::Uuid; + +mod correction; +mod credentials; +mod dictionary; +mod history; +mod paths; +mod preferences; +mod style_pack; + +pub use correction::*; +pub use credentials::*; +pub use dictionary::*; +pub use history::*; +pub use paths::*; +pub use preferences::*; +pub use style_pack::*; + +const HISTORY_CAP: usize = 200; +const PREFERENCES_FILE: &str = "preferences.json"; + +fn data_dir() -> Result { + #[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"), not(target_os = "android")))] + { + 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")) + } + + #[cfg(target_os = "android")] + { + if let Ok(dir) = std::env::var("TAURI_ANDROID_APP_DATA_DIR") { + return Ok(PathBuf::from(dir).join("OpenLess")); + } + Ok(std::env::temp_dir().join("OpenLess")) + } +} + +fn ensure_dir(dir: &Path) -> Result<()> { + fs::create_dir_all(dir).with_context(|| format!("create dir failed: {}", dir.display()))?; + Ok(()) +} + +/// Atomic write: write to a unique `*.tmp-` first, then rename onto the +/// target path. The unique suffix lets concurrent writers each own their own +/// tmp file, so a parallel rename never finds its source already taken. +fn atomic_write(path: &Path, contents: &[u8]) -> Result<()> { + if let Some(parent) = path.parent() { + ensure_dir(parent)?; + } + let file_name = path + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default(); + let tmp_path = path.with_file_name(format!("{file_name}.tmp-{}", Uuid::new_v4().simple())); + fs::write(&tmp_path, contents) + .with_context(|| format!("write tmp failed: {}", tmp_path.display()))?; + if let Err(err) = fs::rename(&tmp_path, path) { + let _ = fs::remove_file(&tmp_path); + return Err(err).with_context(|| format!("rename failed: {}", path.display())); + } + Ok(()) +} + +fn read_or_default Deserialize<'de> + Default>(path: &Path) -> Result { + 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::(&bytes) + .with_context(|| format!("decode failed: {}", path.display())) +} diff --git a/openless-all/app/src-tauri/src/persistence/paths.rs b/openless-all/app/src-tauri/src/persistence/paths.rs new file mode 100644 index 00000000..fbafdfa8 --- /dev/null +++ b/openless-all/app/src-tauri/src/persistence/paths.rs @@ -0,0 +1,345 @@ +#![cfg_attr(target_os = "linux", allow(dead_code, unused_variables))] +//! Storage path resolution: models root (with user-configurable base dir and +//! migration), recordings archive (with retention pruning), and the Windows +//! Foundry Local cache roots. + +use std::fs; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use uuid::Uuid; + +use super::{data_dir, ensure_dir, HISTORY_CAP, PREFERENCES_FILE}; + +/// 默认模型根目录:`/models/`。 +pub fn default_models_root() -> Result { + let dir = data_dir()?.join("models"); + ensure_dir(&dir)?; + Ok(dir) +} + +/// 把用户选择的父目录转成实际模型根目录。 +/// +/// UI 让用户选一个普通目录;OpenLess 固定在其下创建 `OpenLess/models/`, +/// 避免把多个引擎的模型文件直接散落在用户选择目录根部。 +pub fn models_root_for_base_dir(base_dir: Option<&str>) -> Result { + let trimmed = base_dir.map(str::trim).filter(|value| !value.is_empty()); + let dir = match trimmed { + Some(base) => PathBuf::from(base).join("OpenLess").join("models"), + None => return default_models_root(), + }; + ensure_dir(&dir)?; + Ok(dir) +} + +fn configured_models_base_dir() -> Result> { + let path = data_dir()?.join(PREFERENCES_FILE); + if !path.exists() { + return Ok(None); + } + let bytes = fs::read(&path).with_context(|| format!("read failed: {}", path.display()))?; + if bytes.is_empty() { + return Ok(None); + } + let value = serde_json::from_slice::(&bytes) + .with_context(|| format!("decode failed: {}", path.display()))?; + Ok(value + .get("localAsrModelsBaseDir") + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string)) +} + +/// 当前配置下的实际模型根目录。 +pub fn models_root() -> Result { + models_root_for_base_dir(configured_models_base_dir()?.as_deref()) +} + +/// 校验用户选择的父目录,并返回实际模型根目录。 +pub fn validate_models_base_dir(base_dir: Option<&str>) -> Result { + let root = models_root_for_base_dir(base_dir)?; + let probe = root.join(format!(".openless-write-test-{}", Uuid::new_v4().simple())); + fs::write(&probe, b"ok").with_context(|| format!("write probe failed: {}", probe.display()))?; + fs::remove_file(&probe).with_context(|| format!("remove probe failed: {}", probe.display()))?; + Ok(root) +} + +/// 把旧模型根目录合并迁移到新模型根目录。目标已有内容优先,不覆盖。 +pub fn migrate_models_root(old_root: &Path, new_root: &Path) -> Result<()> { + ensure_dir(new_root)?; + if same_existing_path(old_root, new_root) || !old_root.exists() { + return Ok(()); + } + for entry in fs::read_dir(old_root).with_context(|| format!("read {}", old_root.display()))? { + let entry = entry?; + merge_move_no_overwrite(&entry.path(), &new_root.join(entry.file_name()))?; + } + remove_dir_if_empty(old_root)?; + Ok(()) +} + +fn same_existing_path(left: &Path, right: &Path) -> bool { + match (left.canonicalize(), right.canonicalize()) { + (Ok(left), Ok(right)) => left == right, + _ => false, + } +} + +fn merge_move_no_overwrite(src: &Path, dest: &Path) -> Result<()> { + if !src.exists() { + return Ok(()); + } + if !dest.exists() { + if let Some(parent) = dest.parent() { + ensure_dir(parent)?; + } + return rename_or_copy_remove(src, dest); + } + if src.is_dir() && dest.is_dir() { + for entry in fs::read_dir(src).with_context(|| format!("read {}", src.display()))? { + let entry = entry?; + merge_move_no_overwrite(&entry.path(), &dest.join(entry.file_name()))?; + } + remove_dir_if_empty(src)?; + } + Ok(()) +} + +fn rename_or_copy_remove(src: &Path, dest: &Path) -> Result<()> { + match fs::rename(src, dest) { + Ok(()) => Ok(()), + Err(_) if src.is_dir() => { + copy_dir_no_overwrite(src, dest)?; + fs::remove_dir_all(src).with_context(|| format!("remove {}", src.display()))?; + Ok(()) + } + Err(_) => { + fs::copy(src, dest) + .with_context(|| format!("copy {} to {}", src.display(), dest.display()))?; + fs::remove_file(src).with_context(|| format!("remove {}", src.display()))?; + Ok(()) + } + } +} + +fn copy_dir_no_overwrite(src: &Path, dest: &Path) -> Result<()> { + ensure_dir(dest)?; + for entry in fs::read_dir(src).with_context(|| format!("read {}", src.display()))? { + let entry = entry?; + let src_path = entry.path(); + let dest_path = dest.join(entry.file_name()); + if dest_path.exists() { + continue; + } + if src_path.is_dir() { + copy_dir_no_overwrite(&src_path, &dest_path)?; + } else { + fs::copy(&src_path, &dest_path).with_context(|| { + format!("copy {} to {}", src_path.display(), dest_path.display()) + })?; + } + } + Ok(()) +} + +fn remove_dir_if_empty(path: &Path) -> Result<()> { + match fs::read_dir(path) { + Ok(entries) => { + if entries.count() == 0 { + fs::remove_dir(path).with_context(|| format!("remove {}", path.display()))?; + } + } + _ => {} + } + Ok(()) +} + +/// 本地 ASR 模型根目录:`/qwen3-asr/`。 +/// 子目录 = 模型 id(如 `qwen3-asr-0.6b`),存 qwen-asr `download_model.sh` +/// 列出的 5–7 个文件。 +pub fn local_models_root() -> Result { + let dir = models_root()?.join("qwen3-asr"); + ensure_dir(&dir)?; + Ok(dir) +} + +/// 录音归档目录:`/recordings/`。 +/// 仅当用户开 `prefs.record_audio_for_debug` 时才会有内容(每次会话一个 `.wav`)。 +/// 同样受 `history_retention_days` 清理(写入新文件时顺手裁旧的)。 +pub fn recordings_root() -> Result { + let dir = data_dir()?.join("recordings"); + ensure_dir(&dir)?; + Ok(dir) +} + +/// 双重 cap 清理 `recordings/*.wav`: +/// - `retention_days > 0` → 把超过 N 天的删掉(沿用 history 的 retention 逻辑)。 +/// - `max_entries == Some(n)` → 按 mtime 倒序保留最新的 n 条(clamp 到 1..=HISTORY_CAP); +/// `None` 时退回 HISTORY_CAP (200) 硬上限,避免无限增长。 +/// 调用方:每次新建一条录音前。失败仅打 warn,避免影响主路径。 +pub fn prune_recordings(retention_days: u32, max_entries: Option) -> Result<()> { + let dir = match data_dir() { + Ok(d) => d.join("recordings"), + Err(_) => return Ok(()), + }; + if !dir.exists() { + return Ok(()); + } + + // 第一步:按天清理。仅扫 .wav,跟第二步保持一致;metadata 读不到的文件按"过期"处理 + // —— fs 损坏 / 未来格式不一致的孤儿文件应当被回收而不是无限累积。 + if retention_days > 0 { + let cutoff = std::time::SystemTime::now() + - std::time::Duration::from_secs(u64::from(retention_days) * 24 * 3600); + for entry in fs::read_dir(&dir).context("read recordings dir")?.flatten() { + let path = entry.path(); + if path.extension().and_then(|ext| ext.to_str()) != Some("wav") { + continue; + } + let modified = entry + .metadata() + .ok() + .and_then(|m| m.modified().ok()) + .unwrap_or(std::time::UNIX_EPOCH); + if modified < cutoff { + if let Err(err) = fs::remove_file(&path) { + log::warn!("[recordings] prune (days) remove failed for {path:?}: {err}"); + } + } + } + } + + // 第二步:按条数清理。剩下的 wav 按 mtime 倒序,超出 cap 的删掉。 + let cap = max_entries + .map(|n| (n as usize).clamp(1, HISTORY_CAP)) + .unwrap_or(HISTORY_CAP); + let mut entries: Vec<(PathBuf, std::time::SystemTime)> = fs::read_dir(&dir) + .context("read recordings dir")? + .flatten() + .filter_map(|e| { + let path = e.path(); + // 只看 .wav,避免误删未来其他类型的归档文件。 + if path.extension().and_then(|ext| ext.to_str()) != Some("wav") { + return None; + } + let modified = e.metadata().ok()?.modified().ok()?; + Some((path, modified)) + }) + .collect(); + if entries.len() <= cap { + return Ok(()); + } + entries.sort_by(|a, b| b.1.cmp(&a.1)); + for (path, _) in entries.into_iter().skip(cap) { + if let Err(err) = fs::remove_file(&path) { + log::warn!( + "[recordings] prune (count) remove failed for {:?}: {err}", + path + ); + } + } + Ok(()) +} + +/// 单个 session 的录音文件路径。不保证文件已存在(DictationSession.has_audio_recording +/// 决定文件是否被写过)。前端用 `read_audio_recording` IPC 读字节流喂 HTMLAudio。 +pub fn recording_path_for_session(session_id: &str) -> Result { + Ok(recordings_root()?.join(format!("{session_id}.wav"))) +} + +/// Foundry Local 下载与缓存根目录。DLL 和模型都不打进安装包,和 Qwen3-ASR +/// 一样放在 OpenLess 的 models 目录下,卸载清理用户数据时可以一起删除。 +#[cfg(target_os = "windows")] +pub fn foundry_local_root() -> Result { + let dir = models_root()?.join("foundry-local"); + ensure_dir(&dir)?; + Ok(dir) +} + +#[cfg(target_os = "windows")] +pub fn foundry_native_runtime_root() -> Result { + let dir = foundry_local_root()?.join("runtime"); + ensure_dir(&dir)?; + Ok(dir) +} + +#[cfg(target_os = "windows")] +pub fn sherpa_onnx_models_root() -> Result { + let dir = models_root()?.join("sherpa-onnx"); + ensure_dir(&dir)?; + Ok(dir) +} + +#[cfg(target_os = "windows")] +pub fn foundry_model_cache_root() -> Result { + let dir = foundry_local_root()?; + ensure_dir(&dir)?; + Ok(dir) +} + +#[cfg(target_os = "windows")] +pub fn foundry_app_data_root() -> Result { + let dir = foundry_local_root()?.join("app-data"); + ensure_dir(&dir)?; + Ok(dir) +} + +#[cfg(target_os = "windows")] +pub fn foundry_logs_root() -> Result { + let dir = foundry_local_root()?.join("logs"); + ensure_dir(&dir)?; + Ok(dir) +} + +#[cfg(test)] +mod tests { + use super::{migrate_models_root, models_root_for_base_dir}; + use std::fs; + use std::path::PathBuf; + + #[test] + fn custom_models_root_uses_openless_models_suffix() { + let tmp: PathBuf = + std::env::temp_dir().join(format!("openless-model-root-{}", uuid::Uuid::new_v4())); + let root = models_root_for_base_dir(Some(tmp.to_string_lossy().as_ref())) + .expect("build custom models root"); + + assert_eq!(root, tmp.join("OpenLess").join("models")); + assert!(root.is_dir()); + + let _ = fs::remove_dir_all(&tmp); + } + + #[test] + fn migrate_models_root_merges_without_overwriting_target_files() { + let tmp: PathBuf = + std::env::temp_dir().join(format!("openless-model-migrate-{}", uuid::Uuid::new_v4())); + let old_root = tmp.join("old"); + let new_root = tmp.join("new"); + fs::create_dir_all(old_root.join("qwen3-asr")).expect("create old qwen dir"); + fs::create_dir_all(new_root.join("qwen3-asr")).expect("create new qwen dir"); + fs::write(old_root.join("qwen3-asr").join("moved.bin"), b"old").expect("write moved"); + fs::write(old_root.join("qwen3-asr").join("conflict.bin"), b"old") + .expect("write old conflict"); + fs::write(new_root.join("qwen3-asr").join("conflict.bin"), b"new") + .expect("write new conflict"); + + migrate_models_root(&old_root, &new_root).expect("migrate models root"); + + assert_eq!( + fs::read(new_root.join("qwen3-asr").join("moved.bin")).expect("read moved"), + b"old" + ); + assert_eq!( + fs::read(new_root.join("qwen3-asr").join("conflict.bin")).expect("read new conflict"), + b"new" + ); + assert_eq!( + fs::read(old_root.join("qwen3-asr").join("conflict.bin")).expect("read old conflict"), + b"old" + ); + + let _ = fs::remove_dir_all(&tmp); + } +} diff --git a/openless-all/app/src-tauri/src/persistence/preferences.rs b/openless-all/app/src-tauri/src/persistence/preferences.rs new file mode 100644 index 00000000..1f3fbb4a --- /dev/null +++ b/openless-all/app/src-tauri/src/persistence/preferences.rs @@ -0,0 +1,145 @@ +#![cfg_attr(target_os = "linux", allow(dead_code, unused_variables))] +//! User preferences store: a single JSON document held in memory behind a lock, +//! with a one-time `streamingInsert` default migration on load. + +use std::fs; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use parking_lot::Mutex; + +use super::{atomic_write, data_dir, ensure_dir, PREFERENCES_FILE}; +use crate::types::UserPreferences; + +fn read_preferences(path: &Path) -> Result { + if !path.exists() { + return Ok(UserPreferences::default()); + } + let bytes = fs::read(path).with_context(|| format!("read failed: {}", path.display()))?; + if bytes.is_empty() { + return Ok(UserPreferences::default()); + } + let prefs = serde_json::from_slice::(&bytes) + .with_context(|| format!("decode failed: {}", path.display()))?; + + // issue #440:老版本可能已把旧默认 `streamingInsert:false` 写进 preferences.json。 + // 反序列化会在内存里迁到 true,但还必须把迁移标记落盘,否则每次启动都停留在 + // “旧文件”状态,无法表达用户后续手动关闭后的 durable opt-out。 + let streaming_default_migrated = serde_json::from_slice::(&bytes) + .ok() + .and_then(|value| { + value + .get("streamingInsertDefaultMigrated") + .and_then(|flag| flag.as_bool()) + }) + .unwrap_or(false); + if !streaming_default_migrated { + match serde_json::to_vec_pretty(&prefs) + .context("encode prefs failed") + .and_then(|json| atomic_write(path, &json)) + { + Ok(()) => log::info!("[prefs] migrated streamingInsert default marker"), + Err(err) => log::warn!( + "[prefs] failed to persist streamingInsert migration marker for {}: {}", + path.display(), + err + ), + } + } + + Ok(prefs) +} + +pub struct PreferencesStore { + path: PathBuf, + state: Mutex, +} + +impl PreferencesStore { + pub fn new() -> Result { + let dir = data_dir()?; + ensure_dir(&dir)?; + let path = dir.join(PREFERENCES_FILE); + let prefs = if path.exists() { + read_preferences(&path).unwrap_or_else(|e| { + log::warn!( + "[prefs] load {} failed, using defaults: {}", + path.display(), + e + ); + UserPreferences::default() + }) + } else { + UserPreferences::default() + }; + Ok(Self { + path, + state: Mutex::new(prefs), + }) + } + + /// 降级实例:data_dir 不可用时使用默认配置,写操作会安静地失败。 + pub(crate) fn new_fallback() -> Self { + Self { + path: std::env::temp_dir().join("openless_prefs_fallback.json"), + state: Mutex::new(UserPreferences::default()), + } + } + + 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")?; + let mut guard = self.state.lock(); + atomic_write(&self.path, &json)?; + *guard = prefs; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::read_preferences; + use std::fs; + use std::path::PathBuf; + + #[test] + fn legacy_streaming_insert_false_is_migrated_and_marker_is_persisted() { + let tmp: PathBuf = + std::env::temp_dir().join(format!("openless-prefs-test-{}", uuid::Uuid::new_v4())); + fs::create_dir_all(&tmp).expect("create temp dir"); + let path = tmp.join("preferences.json"); + fs::write( + &path, + r#"{ + "streamingInsert": false, + "streamingInsertSaveClipboard": true + }"#, + ) + .expect("write legacy prefs"); + + let prefs = read_preferences(&path).expect("read prefs"); + assert!(prefs.streaming_insert); + assert!(prefs.streaming_insert_default_migrated); + + let saved: serde_json::Value = + serde_json::from_slice(&fs::read(&path).expect("read saved prefs")) + .expect("decode saved prefs"); + assert_eq!( + saved + .get("streamingInsert") + .and_then(|value| value.as_bool()), + Some(true) + ); + assert_eq!( + saved + .get("streamingInsertDefaultMigrated") + .and_then(|value| value.as_bool()), + Some(true) + ); + + let _ = fs::remove_dir_all(&tmp); + } +} diff --git a/openless-all/app/src-tauri/src/persistence/style_pack.rs b/openless-all/app/src-tauri/src/persistence/style_pack.rs new file mode 100644 index 00000000..62a924f8 --- /dev/null +++ b/openless-all/app/src-tauri/src/persistence/style_pack.rs @@ -0,0 +1,910 @@ +#![cfg_attr(target_os = "linux", allow(dead_code, unused_variables))] +//! Style-pack store: the on-disk pack list + asset directory, ZIP import/export, +//! and the reconciliation logic that keeps `UserPreferences` in sync with the +//! enabled packs. + +use std::fs; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; + +use anyhow::{anyhow, Context, Result}; +use chrono::Utc; +use parking_lot::Mutex; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::{atomic_write, data_dir, ensure_dir, read_or_default, PreferencesStore}; +use crate::types::{ + builtin_style_pack_for_mode, builtin_style_pack_id, builtin_style_packs, + default_active_style_pack_id, CustomStylePrompts, PolishMode, StylePack, StylePackExample, + StylePackKind, UserPreferences, BUILTIN_STYLE_PACK_LIGHT_ID, +}; + +const STYLE_PACKS_FILE: &str = "style-packs.json"; +const STYLE_PACK_ASSETS_DIR: &str = "style-pack-assets"; + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct StylePackArchiveManifest { + schema_version: u32, + id: String, + name: String, + description: String, + author: Option, + version: String, + base_mode: PolishMode, + tags: Vec, + prompt_file: String, + examples_file: String, + icon_file: Option, + recommended_model: Option, + compatible_app_version: Option, + /// Marketplace 上游关系。旧 ZIP 没有此字段时自动为 None; + /// 兼容早期口误/拼写包里可能出现的 `orion*` 字段名。 + #[serde( + default, + alias = "orionPackId", + alias = "orion_pack_id", + alias = "origin_pack_id" + )] + origin_pack_id: Option, + #[serde( + default, + alias = "orionAuthorLogin", + alias = "orion_author_login", + alias = "origin_author_login" + )] + origin_author_login: Option, +} + +pub struct StylePackStore { + path: PathBuf, + asset_root: PathBuf, + state: Mutex>, +} + +impl StylePackStore { + pub fn new(prefs: &PreferencesStore) -> Result { + let dir = data_dir()?; + ensure_dir(&dir)?; + let path = dir.join(STYLE_PACKS_FILE); + let asset_root = dir.join(STYLE_PACK_ASSETS_DIR); + ensure_dir(&asset_root)?; + + let mut packs = if path.exists() { + read_or_default::>(&path).unwrap_or_else(|error| { + log::warn!( + "[style-packs] load {} failed, using builtin defaults: {}", + path.display(), + error + ); + Vec::new() + }) + } else { + Vec::new() + }; + + let mut prefs_snapshot = prefs.get(); + let mut changed = migrate_style_packs_from_preferences(&mut packs, &prefs_snapshot); + if ensure_at_least_one_style_pack_enabled(&mut packs) { + changed = true; + } + let active_pref_for_log = prefs_snapshot.active_style_pack_id.clone(); + let enabled_modes_for_log = prefs_snapshot.enabled_modes.clone(); + if sync_style_pack_preferences(&mut prefs_snapshot, &packs) { + prefs.set(prefs_snapshot)?; + } + if changed { + write_style_packs_file(&path, &packs)?; + } + log::info!( + "[style-pack] store ready: file={} packs={} changed={} active_pref={} enabled_modes={:?}", + path.display(), + packs.len(), + changed, + active_pref_for_log, + enabled_modes_for_log + ); + + Ok(Self { + path, + asset_root, + state: Mutex::new(packs), + }) + } + + /// 降级实例:data_dir 不可用时使用临时路径和空列表,写操作会安静地失败。 + pub(crate) fn new_fallback() -> Self { + let tmp = std::env::temp_dir(); + Self { + path: tmp.join("openless_style_packs_fallback.json"), + asset_root: tmp.join("openless_style_pack_assets_fallback"), + state: Mutex::new(Vec::new()), + } + } + + pub fn list(&self) -> Result> { + Ok(self.state.lock().clone()) + } + + pub fn list_with_active(&self, active_style_pack_id: &str) -> Result> { + let mut packs = self.list()?; + for pack in &mut packs { + pack.active = pack.id == active_style_pack_id; + } + Ok(packs) + } + + pub fn get(&self, id: &str) -> Result { + self.state + .lock() + .iter() + .find(|pack| pack.id == id) + .cloned() + .ok_or_else(|| anyhow!("style pack {} not found", id)) + } + + pub fn get_or_default_active(&self, active_style_pack_id: &str) -> Result { + let packs = self.state.lock().clone(); + if let Some(pack) = packs + .iter() + .find(|pack| pack.id == active_style_pack_id && pack.enabled) + .cloned() + { + return Ok(pack); + } + if let Some(pack) = packs + .iter() + .find(|pack| pack.id == BUILTIN_STYLE_PACK_LIGHT_ID && pack.enabled) + .cloned() + { + return Ok(pack); + } + packs + .into_iter() + .find(|pack| pack.enabled) + .ok_or_else(|| anyhow!("no enabled style pack available")) + } + + /// 从模板新建一个 imported 风格包("+"按钮路径)。 + /// 跟 ZIP 导入不同:没有 manifest.json、没有 assets,纯空白模板。 + /// 调用方负责 set `prefs.active_style_pack_id` 等高层 wiring(这里只管落盘)。 + pub fn create_from_template(&self, template: StylePack) -> Result { + let mut packs = self.state.lock(); + let base_id = if template.id.trim().is_empty() { + format!("imported-{}", Uuid::new_v4().simple()) + } else { + template.id.clone() + }; + let assigned_id = unique_imported_style_pack_id(&packs, &base_id); + let now = Utc::now().to_rfc3339(); + let mut pack = template; + pack.id = assigned_id; + pack.kind = StylePackKind::Imported; + pack.created_at = Some(now.clone()); + pack.updated_at = Some(now); + pack.active = false; + pack.enabled = true; + packs.push(pack.clone()); + write_style_packs_file(&self.path, &packs)?; + log::info!( + "[style-pack] created from template id={} base_mode={:?} prompt_chars={} examples={}", + pack.id, + pack.base_mode, + pack.prompt.chars().count(), + pack.examples.len() + ); + Ok(pack) + } + + pub fn upsert(&self, incoming: StylePack) -> Result { + let mut packs = self.state.lock(); + let index = packs + .iter() + .position(|pack| pack.id == incoming.id) + .ok_or_else(|| anyhow!("style pack {} not found", incoming.id))?; + let existing = packs[index].clone(); + let updated = merge_style_pack_update(existing, incoming)?; + packs[index] = updated.clone(); + write_style_packs_file(&self.path, &packs)?; + log::info!( + "[style-pack] saved id={} kind={:?} base_mode={:?} prompt_chars={} examples={} tags={} version={}", + updated.id, + updated.kind, + updated.base_mode, + updated.prompt.chars().count(), + updated.examples.len(), + updated.tags.len(), + updated.version + ); + Ok(updated) + } + + /// 设置衍生关系;marketplace_install 安装本地包后绑定 upstream id + author。 + /// 单独走这里是为了不让前端通用 save 路径误清这两字段。 + pub fn set_origin( + &self, + id: &str, + origin_pack_id: Option, + origin_author_login: Option, + ) -> Result { + let mut packs = self.state.lock(); + let index = packs + .iter() + .position(|pack| pack.id == id) + .ok_or_else(|| anyhow!("style pack {} not found", id))?; + packs[index].origin_pack_id = normalize_optional_text(origin_pack_id); + packs[index].origin_author_login = normalize_optional_text(origin_author_login); + packs[index].updated_at = Some(Utc::now().to_rfc3339()); + let updated = packs[index].clone(); + write_style_packs_file(&self.path, &packs)?; + Ok(updated) + } + + pub fn set_enabled(&self, id: &str, enabled: bool) -> Result { + let mut packs = self.state.lock(); + let index = packs + .iter() + .position(|pack| pack.id == id) + .ok_or_else(|| anyhow!("style pack {} not found", id))?; + packs[index].enabled = enabled; + packs[index].updated_at = Some(Utc::now().to_rfc3339()); + if ensure_at_least_one_style_pack_enabled(&mut packs) { + packs[index].updated_at = Some(Utc::now().to_rfc3339()); + } + let updated = packs[index].clone(); + write_style_packs_file(&self.path, &packs)?; + log::info!( + "[style-pack] set_enabled id={} enabled={} base_mode={:?}", + updated.id, + updated.enabled, + updated.base_mode + ); + Ok(updated) + } + + pub fn reset_builtin(&self, id: &str) -> Result { + let mode = builtin_mode_from_style_pack_id(id) + .ok_or_else(|| anyhow!("style pack {} is not a builtin pack", id))?; + let mut packs = self.state.lock(); + let index = packs + .iter() + .position(|pack| pack.id == id) + .ok_or_else(|| anyhow!("style pack {} not found", id))?; + let existing = packs[index].clone(); + let mut reset = builtin_style_pack_for_mode(mode); + reset.enabled = existing.enabled; + reset.created_at = existing + .created_at + .or_else(|| Some(Utc::now().to_rfc3339())); + reset.updated_at = Some(Utc::now().to_rfc3339()); + packs[index] = reset.clone(); + write_style_packs_file(&self.path, &packs)?; + log::info!( + "[style-pack] reset_builtin id={} base_mode={:?} prompt_chars={} examples={}", + reset.id, + reset.base_mode, + reset.prompt.chars().count(), + reset.examples.len() + ); + Ok(reset) + } + + pub fn remove_imported(&self, id: &str) -> Result<()> { + let mut packs = self.state.lock(); + let index = packs + .iter() + .position(|pack| pack.id == id) + .ok_or_else(|| anyhow!("style pack {} not found", id))?; + if packs[index].kind == StylePackKind::Builtin { + return Err(anyhow!("builtin style pack cannot be deleted")); + } + let removed = packs[index].clone(); + remove_style_pack_assets(&self.asset_root, &packs[index]); + packs.remove(index); + if ensure_at_least_one_style_pack_enabled(&mut packs) { + // write updated fallback state as well + } + write_style_packs_file(&self.path, &packs)?; + log::info!( + "[style-pack] removed imported id={} base_mode={:?}", + removed.id, + removed.base_mode + ); + Ok(()) + } + + pub fn import_from_zip(&self, zip_path: &Path) -> Result { + let file = fs::File::open(zip_path) + .with_context(|| format!("open style pack zip failed: {}", zip_path.display()))?; + let mut archive = zip::ZipArchive::new(file).context("open style pack zip archive")?; + let manifest: StylePackArchiveManifest = + read_zip_json_entry(&mut archive, "manifest.json")?; + let prompt = read_zip_string_entry(&mut archive, &manifest.prompt_file)?; + let examples = + read_zip_json_entry::>(&mut archive, &manifest.examples_file)?; + + let mut packs = self.state.lock(); + let now = Utc::now().to_rfc3339(); + let pack_id = unique_imported_style_pack_id(&packs, &manifest.id); + let icon_path = if let Some(icon_file) = manifest.icon_file.as_deref() { + extract_style_pack_icon(&mut archive, &self.asset_root, &pack_id, icon_file)? + } else { + None + }; + let pack = StylePack { + id: pack_id, + name: manifest.name.trim().to_string(), + description: manifest.description.trim().to_string(), + author: manifest + .author + .and_then(|value| normalize_optional_text(Some(value))), + version: normalize_version(&manifest.version), + kind: StylePackKind::Imported, + base_mode: manifest.base_mode, + prompt, + examples, + tags: normalize_tags(&manifest.tags), + icon_path, + created_at: Some(now.clone()), + updated_at: Some(now), + enabled: true, + active: false, + recommended_model: manifest + .recommended_model + .and_then(|value| normalize_optional_text(Some(value))), + compatible_app_version: manifest + .compatible_app_version + .and_then(|value| normalize_optional_text(Some(value))), + origin_pack_id: normalize_optional_text(manifest.origin_pack_id), + origin_author_login: normalize_optional_text(manifest.origin_author_login), + }; + packs.insert(0, pack.clone()); + write_style_packs_file(&self.path, &packs)?; + log::info!( + "[style-pack] imported source={} installed_id={} manifest_id={} base_mode={:?} prompt_chars={} examples={} tags={} icon={}", + zip_path.display(), + pack.id, + manifest.id, + pack.base_mode, + pack.prompt.chars().count(), + pack.examples.len(), + pack.tags.len(), + pack.icon_path.is_some() + ); + Ok(pack) + } + + pub fn export_to_zip(&self, id: &str, target_path: &Path) -> Result<()> { + let pack = self.get(id)?; + if let Some(parent) = target_path.parent() { + ensure_dir(parent)?; + } + let file = fs::File::create(target_path) + .with_context(|| format!("create style pack zip failed: {}", target_path.display()))?; + let mut zip = zip::ZipWriter::new(file); + let options = zip::write::SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Deflated); + + let icon_file = pack + .icon_path + .as_deref() + .and_then(|path| Path::new(path).file_name()) + .and_then(|file_name| file_name.to_str()) + .map(|name| format!("assets/{name}")); + + let manifest = StylePackArchiveManifest { + schema_version: 1, + id: pack.id.clone(), + name: pack.name.clone(), + description: pack.description.clone(), + author: pack.author.clone(), + version: pack.version.clone(), + base_mode: pack.base_mode, + tags: pack.tags.clone(), + prompt_file: "prompt.md".into(), + examples_file: "examples.json".into(), + icon_file: icon_file.clone(), + recommended_model: pack.recommended_model.clone(), + compatible_app_version: pack.compatible_app_version.clone(), + origin_pack_id: pack.origin_pack_id.clone(), + origin_author_login: pack.origin_author_login.clone(), + }; + + zip.start_file("manifest.json", options) + .context("write style pack manifest entry")?; + zip.write_all( + serde_json::to_string_pretty(&manifest) + .context("encode style pack manifest")? + .as_bytes(), + ) + .context("write style pack manifest body")?; + + zip.start_file("prompt.md", options) + .context("write style pack prompt entry")?; + zip.write_all(pack.prompt.as_bytes()) + .context("write style pack prompt body")?; + + zip.start_file("examples.json", options) + .context("write style pack examples entry")?; + zip.write_all( + serde_json::to_string_pretty(&pack.examples) + .context("encode style pack examples")? + .as_bytes(), + ) + .context("write style pack examples body")?; + + if let (Some(source_icon_path), Some(zip_icon_path)) = (&pack.icon_path, &icon_file) { + let icon_source = Path::new(source_icon_path); + if icon_source.exists() { + zip.start_file(zip_icon_path, options) + .context("write style pack icon entry")?; + let bytes = fs::read(icon_source).with_context(|| { + format!("read style pack icon failed: {}", icon_source.display()) + })?; + zip.write_all(&bytes) + .context("write style pack icon body")?; + } + } + + zip.finish().context("finalize style pack zip")?; + log::info!( + "[style-pack] exported id={} target={} base_mode={:?} prompt_chars={} examples={} icon={}", + pack.id, + target_path.display(), + pack.base_mode, + pack.prompt.chars().count(), + pack.examples.len(), + pack.icon_path.is_some() + ); + Ok(()) + } +} + +fn write_style_packs_file(path: &Path, packs: &[StylePack]) -> Result<()> { + let json = serde_json::to_vec_pretty(packs).context("encode style packs failed")?; + atomic_write(path, &json) +} + +fn migrate_style_packs_from_preferences( + packs: &mut Vec, + prefs: &UserPreferences, +) -> bool { + let mut changed = false; + let legacy_prompts = prefs.style_system_prompts.clone(); + for builtin in builtin_style_packs() { + if let Some(index) = packs.iter().position(|pack| pack.id == builtin.id) { + let pack = &mut packs[index]; + if pack.kind != StylePackKind::Builtin { + pack.kind = StylePackKind::Builtin; + changed = true; + } + if pack.name.trim().is_empty() { + pack.name = builtin.name.clone(); + changed = true; + } + if pack.description.trim().is_empty() { + pack.description = builtin.description.clone(); + changed = true; + } + if pack.prompt.trim().is_empty() { + pack.prompt = builtin.prompt.clone(); + changed = true; + } + if pack.examples.is_empty() { + pack.examples = builtin.examples.clone(); + changed = true; + } + if pack.tags.is_empty() { + pack.tags = builtin.tags.clone(); + changed = true; + } + if pack.version.trim().is_empty() { + pack.version = builtin.version.clone(); + changed = true; + } + if pack.author.is_none() { + pack.author = builtin.author.clone(); + changed = true; + } + if pack.compatible_app_version.is_none() { + pack.compatible_app_version = builtin.compatible_app_version.clone(); + changed = true; + } + if pack.created_at.is_none() { + pack.created_at = Some(Utc::now().to_rfc3339()); + changed = true; + } + if pack.base_mode != builtin.base_mode { + pack.base_mode = builtin.base_mode; + changed = true; + } + } else { + let mut pack = builtin.clone(); + pack.prompt = legacy_prompts.for_mode(pack.base_mode).to_string(); + pack.enabled = prefs.enabled_modes.contains(&pack.base_mode); + pack.created_at = Some(Utc::now().to_rfc3339()); + pack.updated_at = Some(Utc::now().to_rfc3339()); + packs.push(pack); + changed = true; + } + } + packs.sort_by(|left, right| { + style_pack_sort_key(left) + .cmp(&style_pack_sort_key(right)) + .then_with(|| left.name.cmp(&right.name)) + }); + changed +} + +fn style_pack_sort_key(pack: &StylePack) -> (u8, u8) { + let kind_rank = match pack.kind { + StylePackKind::Builtin => 0, + StylePackKind::Imported => 1, + }; + let mode_rank = match pack.base_mode { + PolishMode::Raw => 0, + PolishMode::Light => 1, + PolishMode::Structured => 2, + PolishMode::Formal => 3, + }; + (kind_rank, mode_rank) +} + +fn ensure_at_least_one_style_pack_enabled(packs: &mut [StylePack]) -> bool { + if packs.iter().any(|pack| pack.enabled) { + return false; + } + if let Some(pack) = packs + .iter_mut() + .find(|pack| pack.id == default_active_style_pack_id()) + { + pack.enabled = true; + pack.updated_at = Some(Utc::now().to_rfc3339()); + return true; + } + if let Some(first) = packs.first_mut() { + first.enabled = true; + first.updated_at = Some(Utc::now().to_rfc3339()); + return true; + } + false +} + +pub fn sync_style_pack_preferences(prefs: &mut UserPreferences, packs: &[StylePack]) -> bool { + let previous_active_style_pack_id = prefs.active_style_pack_id.clone(); + let previous_default_mode = prefs.default_mode; + let previous_enabled_modes = prefs.enabled_modes.clone(); + let enabled: Vec<&StylePack> = packs.iter().filter(|pack| pack.enabled).collect(); + let active = packs + .iter() + .find(|pack| pack.id == prefs.active_style_pack_id && pack.enabled) + .or_else(|| { + packs + .iter() + .find(|pack| pack.id == builtin_style_pack_id(prefs.default_mode) && pack.enabled) + }) + .or_else(|| enabled.first().copied()); + + let Some(active_pack) = active else { + return false; + }; + + let mut changed = false; + if prefs.active_style_pack_id != active_pack.id { + prefs.active_style_pack_id = active_pack.id.clone(); + changed = true; + } + if prefs.default_mode != active_pack.base_mode { + prefs.default_mode = active_pack.base_mode; + changed = true; + } + + let next_enabled_modes = enabled_modes_from_style_packs(packs); + if prefs.enabled_modes != next_enabled_modes { + prefs.enabled_modes = next_enabled_modes; + changed = true; + } + + if sync_builtin_style_prompt_preferences(prefs, packs) { + changed = true; + } + + if changed { + log::info!( + "[style-pack] sync_prefs active:{}->{} default_mode:{:?}->{:?} enabled_modes:{:?}->{:?}", + previous_active_style_pack_id, + prefs.active_style_pack_id, + previous_default_mode, + prefs.default_mode, + previous_enabled_modes, + prefs.enabled_modes + ); + } + + changed +} + +fn sync_builtin_style_prompt_preferences(prefs: &mut UserPreferences, packs: &[StylePack]) -> bool { + let mut changed = false; + let mut saw_builtin = false; + for mode in [ + PolishMode::Raw, + PolishMode::Light, + PolishMode::Structured, + PolishMode::Formal, + ] { + let Some(pack) = packs + .iter() + .find(|pack| pack.kind == StylePackKind::Builtin && pack.base_mode == mode) + else { + continue; + }; + saw_builtin = true; + let next_prompt = pack.prompt.clone(); + let current_prompt = prefs.style_system_prompts.for_mode(mode); + if current_prompt == next_prompt { + continue; + } + match mode { + PolishMode::Raw => prefs.style_system_prompts.raw = next_prompt, + PolishMode::Light => prefs.style_system_prompts.light = next_prompt, + PolishMode::Structured => prefs.style_system_prompts.structured = next_prompt, + PolishMode::Formal => prefs.style_system_prompts.formal = next_prompt, + } + changed = true; + } + + if saw_builtin && prefs.custom_style_prompts != CustomStylePrompts::default() { + prefs.custom_style_prompts = CustomStylePrompts::default(); + changed = true; + } + + changed +} + +pub fn enabled_modes_from_style_packs(packs: &[StylePack]) -> Vec { + let mut modes = Vec::new(); + for mode in [ + PolishMode::Raw, + PolishMode::Light, + PolishMode::Structured, + PolishMode::Formal, + ] { + if packs + .iter() + .any(|pack| pack.enabled && pack.base_mode == mode) + { + modes.push(mode); + } + } + modes +} + +fn builtin_mode_from_style_pack_id(id: &str) -> Option { + for mode in [ + PolishMode::Raw, + PolishMode::Light, + PolishMode::Structured, + PolishMode::Formal, + ] { + if builtin_style_pack_id(mode) == id { + return Some(mode); + } + } + None +} + +fn merge_style_pack_update(existing: StylePack, incoming: StylePack) -> Result { + if existing.id != incoming.id { + return Err(anyhow!("style pack id cannot be changed")); + } + let mut updated = existing; + updated.name = normalize_required_text(&incoming.name, "style pack name")?; + updated.description = incoming.description.trim().to_string(); + updated.author = normalize_optional_text(incoming.author); + updated.version = normalize_version(&incoming.version); + updated.prompt = incoming.prompt; + updated.examples = normalize_examples(incoming.examples); + updated.tags = normalize_tags(&incoming.tags); + updated.recommended_model = normalize_optional_text(incoming.recommended_model); + updated.compatible_app_version = normalize_optional_text(incoming.compatible_app_version); + // origin 字段是 marketplace_install 之后的「衍生关系绑定」,**不能**走通用 save 路径覆盖 + // ——否则前端 save 时丢失 originPackId 就会清掉关联。要写 origin 走专用的 set_origin。 + updated.updated_at = Some(Utc::now().to_rfc3339()); + Ok(updated) +} + +fn normalize_examples(examples: Vec) -> Vec { + examples + .into_iter() + .filter_map(|example| { + let input = example.input.trim().to_string(); + let output = example.output.trim().to_string(); + if input.is_empty() && output.is_empty() { + return None; + } + Some(StylePackExample { + title: normalize_optional_text(example.title), + input, + output, + }) + }) + .collect() +} + +fn normalize_tags(tags: &[String]) -> Vec { + let mut normalized = Vec::new(); + for tag in tags { + let trimmed = tag.trim(); + if trimmed.is_empty() || normalized.iter().any(|existing| existing == trimmed) { + continue; + } + normalized.push(trimmed.to_string()); + } + normalized +} + +fn normalize_optional_text(value: Option) -> Option { + value.and_then(|text| { + let trimmed = text.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + }) +} + +fn normalize_required_text(value: &str, field: &str) -> Result { + let trimmed = value.trim(); + if trimmed.is_empty() { + return Err(anyhow!("{field} is empty")); + } + Ok(trimmed.to_string()) +} + +fn normalize_version(value: &str) -> String { + let trimmed = value.trim(); + if trimmed.is_empty() { + "1.0.0".into() + } else { + trimmed.to_string() + } +} + +fn unique_imported_style_pack_id(existing: &[StylePack], requested_id: &str) -> String { + let base = sanitize_style_pack_id(requested_id); + if !existing.iter().any(|pack| pack.id == base) { + return base; + } + let mut index = 2usize; + loop { + let candidate = format!("{base}-{index}"); + if !existing.iter().any(|pack| pack.id == candidate) { + return candidate; + } + index = index.saturating_add(1); + } +} + +fn sanitize_style_pack_id(requested_id: &str) -> String { + let mut output = String::new(); + for ch in requested_id.trim().chars() { + if ch.is_ascii_alphanumeric() { + output.push(ch.to_ascii_lowercase()); + } else if matches!(ch, '-' | '_' | '.') { + output.push(ch); + } else if matches!(ch, ' ' | '/' | '\\') { + output.push('-'); + } + } + let compact = output.trim_matches('-').trim_matches('.').trim_matches('_'); + if compact.is_empty() { + format!("imported-{}", Uuid::new_v4().simple()) + } else if compact.starts_with("builtin.") { + format!("imported.{compact}") + } else { + compact.to_string() + } +} + +fn read_zip_json_entry Deserialize<'de>>( + archive: &mut zip::ZipArchive, + entry_name: &str, +) -> Result { + let text = read_zip_string_entry(archive, entry_name)?; + serde_json::from_str(&text) + .with_context(|| format!("decode style pack zip entry failed: {entry_name}")) +} + +fn read_zip_string_entry( + archive: &mut zip::ZipArchive, + entry_name: &str, +) -> Result { + let mut file = archive + .by_name(entry_name) + .with_context(|| format!("missing style pack zip entry: {entry_name}"))?; + let mut buffer = String::new(); + file.read_to_string(&mut buffer) + .with_context(|| format!("read style pack zip entry failed: {entry_name}"))?; + Ok(buffer) +} + +fn extract_style_pack_icon( + archive: &mut zip::ZipArchive, + asset_root: &Path, + pack_id: &str, + entry_name: &str, +) -> Result> { + let mut file = archive + .by_name(entry_name) + .with_context(|| format!("missing style pack icon entry: {entry_name}"))?; + let file_name = Path::new(entry_name) + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| anyhow!("invalid style pack icon file name"))?; + let target_dir = asset_root.join(pack_id); + ensure_dir(&target_dir)?; + let target_path = target_dir.join(file_name); + let mut bytes = Vec::new(); + file.read_to_end(&mut bytes) + .with_context(|| format!("read style pack icon failed: {entry_name}"))?; + fs::write(&target_path, &bytes) + .with_context(|| format!("write style pack icon failed: {}", target_path.display()))?; + Ok(Some(target_path.to_string_lossy().to_string())) +} + +fn remove_style_pack_assets(asset_root: &Path, pack: &StylePack) { + if let Some(icon_path) = pack.icon_path.as_deref() { + let path = Path::new(icon_path); + let _ = fs::remove_file(path); + if let Some(parent) = path.parent() { + let _ = fs::remove_dir(parent); + } + } else { + let dir = asset_root.join(&pack.id); + let _ = fs::remove_dir_all(dir); + } +} + +#[cfg(test)] +mod tests { + use super::sync_style_pack_preferences; + use crate::types::{builtin_style_packs, CustomStylePrompts}; + + #[test] + fn sync_style_pack_preferences_uses_builtin_store_prompts_as_source_of_truth() { + let mut prefs = crate::types::UserPreferences { + style_system_prompts: crate::types::StyleSystemPrompts { + raw: "stale raw".into(), + light: "stale light".into(), + structured: "stale structured".into(), + formal: "stale formal".into(), + }, + custom_style_prompts: CustomStylePrompts { + raw: String::new(), + light: "legacy extra instruction".into(), + structured: String::new(), + formal: String::new(), + }, + ..Default::default() + }; + let mut packs = builtin_style_packs(); + let light = packs + .iter_mut() + .find(|pack| pack.id == "builtin.light") + .expect("builtin light pack"); + light.prompt = "fresh light prompt from store".into(); + + assert!(sync_style_pack_preferences(&mut prefs, &packs)); + assert_eq!(prefs.style_system_prompts.raw, packs[0].prompt); + assert_eq!( + prefs.style_system_prompts.light, + "fresh light prompt from store" + ); + assert_eq!(prefs.style_system_prompts.structured, packs[2].prompt); + assert_eq!(prefs.style_system_prompts.formal, packs[3].prompt); + assert_eq!(prefs.custom_style_prompts, CustomStylePrompts::default()); + } +}