refactor(persistence): split persistence.rs into domain modules behind a barrel (#701)
Behavior-unchanged decomposition of the 2757-line `persistence.rs` god-file
into a `persistence/` module, one file per concern. Everything is re-exported
via glob (`pub use <submodule>::*`) so the `crate::persistence::*` public
surface is byte-for-byte identical and no call site changes.
Layout:
- mod.rs shared fs/io helpers (data_dir, ensure_dir, atomic_write,
read_or_default) + the two cross-cutting consts + re-exports
- paths.rs models / recordings / Foundry roots + migration helpers
- credentials.rs OS credential vault (CredsRoot model, keyring/android/legacy,
CredentialAccount, CredentialsSnapshot, CredentialsVault)
- history.rs HistoryStore
- preferences.rs read_preferences + PreferencesStore
- style_pack.rs StylePackStore + ZIP import/export + prefs reconciliation
- dictionary.rs DictionaryStore + vocab presets
- correction.rs CorrectionRuleStore
Pure move + re-export: no logic, signatures, public API, or UI changed. Unit
tests moved next to the code they now cover. Shared helpers stay private in
`mod.rs` (parent items are visible to child modules), and glob re-exports keep
cfg-gated items (Android/Windows) resolution-safe across every target —
avoiding the explicit-`pub use` breakage seen in the earlier types.rs split.
Verified on macOS: cargo fmt/check/clippy clean (no new persistence warnings),
446 lib tests green. Android/Windows cfg paths verified by CI.
Part of #697.
Co-authored-by: sim <sim@local>
This commit is contained in:
1 parent
b98e353a2b
commit
a94ea3d05a
9 files changed
+2878
-2757
No files matched your search
File diff suppressed because it is too large.
Load diff
@@ -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<Self> {
|
||||
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<Vec<CorrectionRule>> {
|
||||
let _guard = self.lock.lock();
|
||||
self.read_locked()
|
||||
}
|
||||
|
||||
pub fn add(&self, pattern: String, replacement: String) -> Result<CorrectionRule> {
|
||||
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<Vec<CorrectionRule>> {
|
||||
read_or_default::<Vec<CorrectionRule>>(&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());
|
||||
}
|
||||
}
|
||||
@@ -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": "<id>", "llm": "<id>" },
|
||||
//! "providers": {
|
||||
//! "asr": { "<id>": { "appKey", "accessKey", "resourceId", "apiKey", "baseURL", "model", "vocabularyId" } },
|
||||
//! "llm": { "<id>": { "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<Mutex<()>> = 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<Mutex<Option<CredsRoot>>> = OnceLock::new();
|
||||
|
||||
fn credentials_cache() -> &'static Mutex<Option<CredsRoot>> {
|
||||
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<String, CredsAsrEntry>,
|
||||
#[serde(default)]
|
||||
llm: HashMap<String, CredsLlmEntry>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Default, Clone)]
|
||||
#[allow(non_snake_case)]
|
||||
struct CredsAsrEntry {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
apiKey: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
baseURL: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
model: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
appKey: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
accessKey: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
resourceId: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
vocabularyId: Option<String>,
|
||||
}
|
||||
|
||||
impl CredsAsrEntry {
|
||||
fn is_empty(&self) -> bool {
|
||||
self.apiKey.as_deref().unwrap_or("").is_empty()
|
||||
&& self.baseURL.as_deref().unwrap_or("").is_empty()
|
||||
&& self.model.as_deref().unwrap_or("").is_empty()
|
||||
&& self.appKey.as_deref().unwrap_or("").is_empty()
|
||||
&& self.accessKey.as_deref().unwrap_or("").is_empty()
|
||||
&& self.resourceId.as_deref().unwrap_or("").is_empty()
|
||||
&& 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<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
apiKey: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
baseURL: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
model: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
temperature: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
extraHeaders: Option<HashMap<String, String>>,
|
||||
}
|
||||
|
||||
impl CredsLlmEntry {
|
||||
fn is_empty(&self) -> bool {
|
||||
self.displayName.as_deref().unwrap_or("").is_empty()
|
||||
&& self.apiKey.as_deref().unwrap_or("").is_empty()
|
||||
&& self.baseURL.as_deref().unwrap_or("").is_empty()
|
||||
&& self.model.as_deref().unwrap_or("").is_empty()
|
||||
&& self.temperature.is_none()
|
||||
&& self
|
||||
.extraHeaders
|
||||
.as_ref()
|
||||
.map(|h| h.is_empty())
|
||||
.unwrap_or(true)
|
||||
}
|
||||
}
|
||||
|
||||
fn credentials_path() -> Result<PathBuf> {
|
||||
// macOS / Linux: ~/.openless/credentials.json (与 Swift 同源)
|
||||
// Windows: %APPDATA%\OpenLess\credentials.json (Windows 没有标准 HOME 环境变量)
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let appdata = std::env::var("APPDATA").context("APPDATA not set")?;
|
||||
return Ok(PathBuf::from(appdata)
|
||||
.join("OpenLess")
|
||||
.join(LEGACY_CREDS_FILE));
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
let home = std::env::var("HOME").context("HOME not set")?;
|
||||
Ok(PathBuf::from(home)
|
||||
.join(LEGACY_CREDS_DIR)
|
||||
.join(LEGACY_CREDS_FILE))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "android"))]
|
||||
fn keyring_entry() -> Result<keyring::Entry> {
|
||||
keyring_entry_for(KEYRING_CREDENTIALS_ACCOUNT)
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "android"))]
|
||||
fn keyring_entry_for(account: &str) -> Result<keyring::Entry> {
|
||||
keyring::Entry::new(CredentialsVault::SERVICE_NAME, account)
|
||||
.context("open system credential vault")
|
||||
}
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
fn android_credentials_path() -> Result<PathBuf> {
|
||||
Ok(super::data_dir()?.join(ANDROID_CREDENTIALS_FILE))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
fn load_android_credentials() -> Result<Option<CredsRoot>> {
|
||||
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::<CredsRoot>(&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<CredsRoot> {
|
||||
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::<CredsRoot>(&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<String>,
|
||||
chunks: usize,
|
||||
}
|
||||
|
||||
/// 旧版(generation=Some):`credentials.v1.chunk.<UUID>.{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<String> {
|
||||
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<CredsChunkManifest> {
|
||||
let manifest = serde_json::from_str::<CredsChunkManifest>(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<Option<String>> {
|
||||
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<Option<CredsRoot>> {
|
||||
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::<CredsRoot>(&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<CredsRoot> {
|
||||
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<CredsRoot> {
|
||||
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<CredsRoot> {
|
||||
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<CredsRoot> {
|
||||
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<String> {
|
||||
let asr = root.providers.asr.get(&root.active.asr);
|
||||
let llm = root.providers.llm.get(&root.active.llm);
|
||||
let pick = |s: &Option<String>| s.as_ref().filter(|v| !v.is_empty()).cloned();
|
||||
match account {
|
||||
CredentialAccount::VolcengineAppKey => {
|
||||
asr.and_then(|e| pick(&e.appKey).or_else(|| pick(&e.apiKey)))
|
||||
}
|
||||
CredentialAccount::VolcengineAccessKey => asr.and_then(|e| pick(&e.accessKey)),
|
||||
CredentialAccount::VolcengineResourceId => asr.and_then(|e| pick(&e.resourceId)),
|
||||
CredentialAccount::ArkApiKey => llm.and_then(|e| pick(&e.apiKey)),
|
||||
CredentialAccount::ArkModelId => llm.and_then(|e| pick(&e.model)),
|
||||
CredentialAccount::ArkEndpoint => llm.and_then(|e| pick(&e.baseURL)),
|
||||
CredentialAccount::AsrApiKey => asr.and_then(|e| pick(&e.apiKey)),
|
||||
CredentialAccount::AsrEndpoint => asr.and_then(|e| pick(&e.baseURL)),
|
||||
CredentialAccount::AsrModel => asr.and_then(|e| pick(&e.model)),
|
||||
CredentialAccount::AsrVocabularyId => asr.and_then(|e| pick(&e.vocabularyId)),
|
||||
}
|
||||
}
|
||||
|
||||
fn write_account(root: &mut CredsRoot, account: CredentialAccount, value: Option<String>) {
|
||||
let asr_id = root.active.asr.clone();
|
||||
let llm_id = root.active.llm.clone();
|
||||
let normalized = value.and_then(|v| if v.is_empty() { None } else { Some(v) });
|
||||
match account {
|
||||
CredentialAccount::VolcengineAppKey => {
|
||||
let entry = root.providers.asr.entry(asr_id).or_default();
|
||||
entry.appKey = normalized;
|
||||
}
|
||||
CredentialAccount::VolcengineAccessKey => {
|
||||
let entry = root.providers.asr.entry(asr_id).or_default();
|
||||
entry.accessKey = normalized;
|
||||
}
|
||||
CredentialAccount::VolcengineResourceId => {
|
||||
let entry = root.providers.asr.entry(asr_id).or_default();
|
||||
entry.resourceId = normalized;
|
||||
}
|
||||
CredentialAccount::ArkApiKey => {
|
||||
let entry = root.providers.llm.entry(llm_id).or_default();
|
||||
entry.apiKey = normalized;
|
||||
}
|
||||
CredentialAccount::ArkModelId => {
|
||||
let entry = root.providers.llm.entry(llm_id).or_default();
|
||||
entry.model = normalized;
|
||||
}
|
||||
CredentialAccount::ArkEndpoint => {
|
||||
let entry = root.providers.llm.entry(llm_id).or_default();
|
||||
entry.baseURL = normalized;
|
||||
}
|
||||
CredentialAccount::AsrApiKey => {
|
||||
let entry = root.providers.asr.entry(asr_id).or_default();
|
||||
entry.apiKey = normalized;
|
||||
}
|
||||
CredentialAccount::AsrEndpoint => {
|
||||
let entry = root.providers.asr.entry(asr_id).or_default();
|
||||
entry.baseURL = normalized;
|
||||
}
|
||||
CredentialAccount::AsrModel => {
|
||||
let entry = root.providers.asr.entry(asr_id).or_default();
|
||||
entry.model = normalized;
|
||||
}
|
||||
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<String>,
|
||||
pub volcengine_access_key: Option<String>,
|
||||
pub volcengine_resource_id: Option<String>,
|
||||
pub asr_api_key: Option<String>,
|
||||
pub asr_endpoint: Option<String>,
|
||||
pub asr_model: Option<String>,
|
||||
pub ark_api_key: Option<String>,
|
||||
pub ark_model_id: Option<String>,
|
||||
pub ark_endpoint: Option<String>,
|
||||
}
|
||||
|
||||
/// 凭据存储——系统凭据库;旧 JSON 文件只作为迁移来源。
|
||||
pub struct CredentialsVault;
|
||||
|
||||
impl CredentialsVault {
|
||||
/// 系统凭据库 service name;macOS 下对应 Keychain service。
|
||||
pub const SERVICE_NAME: &'static str = "com.openless.app";
|
||||
|
||||
pub fn get(account: CredentialAccount) -> Result<Option<String>> {
|
||||
let _guard = credentials_lock().lock();
|
||||
Ok(lookup_account(&load_credentials(), account))
|
||||
}
|
||||
|
||||
pub fn set(account: CredentialAccount, value: &str) -> Result<()> {
|
||||
let _guard = credentials_lock().lock();
|
||||
let mut root = load_credentials_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));
|
||||
}
|
||||
}
|
||||
@@ -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<Self> {
|
||||
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<Vec<DictionaryEntry>> {
|
||||
let _guard = self.lock.lock();
|
||||
self.read_locked()
|
||||
}
|
||||
|
||||
pub fn add(&self, phrase: String, note: Option<String>) -> Result<DictionaryEntry> {
|
||||
let _guard = self.lock.lock();
|
||||
let mut entries = self.read_locked()?;
|
||||
let entry = DictionaryEntry {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
phrase,
|
||||
note,
|
||||
enabled: true,
|
||||
hits: 0,
|
||||
created_at: Utc::now().to_rfc3339(),
|
||||
};
|
||||
entries.insert(0, entry.clone());
|
||||
self.write_locked(&entries)?;
|
||||
Ok(entry)
|
||||
}
|
||||
|
||||
pub fn remove(&self, id: &str) -> Result<()> {
|
||||
let _guard = self.lock.lock();
|
||||
let mut entries = self.read_locked()?;
|
||||
let before = entries.len();
|
||||
entries.retain(|e| e.id != id);
|
||||
if entries.len() == before {
|
||||
return Ok(());
|
||||
}
|
||||
self.write_locked(&entries)
|
||||
}
|
||||
|
||||
pub fn set_enabled(&self, id: &str, enabled: bool) -> Result<()> {
|
||||
let _guard = self.lock.lock();
|
||||
let mut entries = self.read_locked()?;
|
||||
let mut found = false;
|
||||
for entry in entries.iter_mut() {
|
||||
if entry.id == id {
|
||||
entry.enabled = enabled;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return Err(anyhow!("dictionary entry {} not found", id));
|
||||
}
|
||||
self.write_locked(&entries)
|
||||
}
|
||||
|
||||
/// 扫描一段最终文本,对每个 enabled 词条按出现次数累加 `hits`。
|
||||
///
|
||||
/// 匹配是大小写不敏感的子串扫描:「Hello hello HELLO」算 3 次。
|
||||
/// 返回本次累加的总命中数,方便调用方记录到 history.dictionary_entry_count。
|
||||
pub fn record_hits(&self, text: &str) -> Result<u64> {
|
||||
if text.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
let _guard = self.lock.lock();
|
||||
let mut entries = self.read_locked()?;
|
||||
if entries.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
let haystack = text.to_lowercase();
|
||||
let mut total: u64 = 0;
|
||||
let mut changed = false;
|
||||
for entry in entries.iter_mut() {
|
||||
if !entry.enabled {
|
||||
continue;
|
||||
}
|
||||
let needle = entry.phrase.trim().to_lowercase();
|
||||
if needle.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let count = count_occurrences(&haystack, &needle);
|
||||
if count > 0 {
|
||||
entry.hits = entry.hits.saturating_add(count);
|
||||
total = total.saturating_add(count);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
self.write_locked(&entries)?;
|
||||
}
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
fn read_locked(&self) -> Result<Vec<DictionaryEntry>> {
|
||||
read_or_default::<Vec<DictionaryEntry>>(&self.path)
|
||||
}
|
||||
|
||||
fn write_locked(&self, entries: &[DictionaryEntry]) -> Result<()> {
|
||||
let json = serde_json::to_vec_pretty(entries).context("encode vocab failed")?;
|
||||
atomic_write(&self.path, &json)
|
||||
}
|
||||
}
|
||||
|
||||
/// 统计 `needle` 在 `haystack` 中的非重叠出现次数。两侧调用前都应已转小写。
|
||||
fn count_occurrences(haystack: &str, needle: &str) -> u64 {
|
||||
if needle.is_empty() || haystack.len() < needle.len() {
|
||||
return 0;
|
||||
}
|
||||
let mut count: u64 = 0;
|
||||
let mut start = 0usize;
|
||||
while let Some(pos) = haystack[start..].find(needle) {
|
||||
count = count.saturating_add(1);
|
||||
start = start + pos + needle.len();
|
||||
if start >= haystack.len() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
count
|
||||
}
|
||||
|
||||
pub fn list_vocab_presets() -> Result<VocabPresetStore> {
|
||||
let dir = data_dir()?;
|
||||
ensure_dir(&dir)?;
|
||||
read_or_default::<VocabPresetStore>(&dir.join(VOCAB_PRESETS_FILE))
|
||||
}
|
||||
|
||||
pub fn save_vocab_presets(store: &VocabPresetStore) -> Result<()> {
|
||||
let dir = data_dir()?;
|
||||
ensure_dir(&dir)?;
|
||||
let path = dir.join(VOCAB_PRESETS_FILE);
|
||||
let json = serde_json::to_vec_pretty(store).context("encode vocab presets failed")?;
|
||||
atomic_write(&path, &json)
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
@@ -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<Self> {
|
||||
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<Vec<DictationSession>> {
|
||||
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<u32>,
|
||||
) -> 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<Vec<DictationSession>> {
|
||||
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<DictationSession> = 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<bool> {
|
||||
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::<DictationSession>::new())
|
||||
}
|
||||
|
||||
fn read_locked(&self) -> Result<Vec<DictationSession>> {
|
||||
read_or_default::<Vec<DictationSession>>(&self.path)
|
||||
}
|
||||
|
||||
fn write_locked(&self, sessions: &[DictationSession]) -> Result<()> {
|
||||
let json = serde_json::to_vec_pretty(sessions).context("encode history failed")?;
|
||||
atomic_write(&self.path, &json)
|
||||
}
|
||||
}
|
||||
@@ -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<PathBuf> {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let home = std::env::var("HOME").context("HOME not set")?;
|
||||
Ok(PathBuf::from(home)
|
||||
.join("Library")
|
||||
.join("Application Support")
|
||||
.join("OpenLess"))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let appdata = std::env::var("APPDATA").context("APPDATA not set")?;
|
||||
Ok(PathBuf::from(appdata).join("OpenLess"))
|
||||
}
|
||||
|
||||
#[cfg(all(unix, not(target_os = "macos"), 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-<uuid>` 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<T: for<'de> Deserialize<'de> + Default>(path: &Path) -> Result<T> {
|
||||
if !path.exists() {
|
||||
return Ok(T::default());
|
||||
}
|
||||
let bytes = fs::read(path).with_context(|| format!("read failed: {}", path.display()))?;
|
||||
if bytes.is_empty() {
|
||||
return Ok(T::default());
|
||||
}
|
||||
serde_json::from_slice::<T>(&bytes)
|
||||
.with_context(|| format!("decode failed: {}", path.display()))
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
/// 默认模型根目录:`<data_dir>/models/`。
|
||||
pub fn default_models_root() -> Result<PathBuf> {
|
||||
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<PathBuf> {
|
||||
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<Option<String>> {
|
||||
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::<serde_json::Value>(&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<PathBuf> {
|
||||
models_root_for_base_dir(configured_models_base_dir()?.as_deref())
|
||||
}
|
||||
|
||||
/// 校验用户选择的父目录,并返回实际模型根目录。
|
||||
pub fn validate_models_base_dir(base_dir: Option<&str>) -> Result<PathBuf> {
|
||||
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 模型根目录:`<models_root>/qwen3-asr/`。
|
||||
/// 子目录 = 模型 id(如 `qwen3-asr-0.6b`),存 qwen-asr `download_model.sh`
|
||||
/// 列出的 5–7 个文件。
|
||||
pub fn local_models_root() -> Result<PathBuf> {
|
||||
let dir = models_root()?.join("qwen3-asr");
|
||||
ensure_dir(&dir)?;
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
/// 录音归档目录:`<data_dir>/recordings/`。
|
||||
/// 仅当用户开 `prefs.record_audio_for_debug` 时才会有内容(每次会话一个 `<session_id>.wav`)。
|
||||
/// 同样受 `history_retention_days` 清理(写入新文件时顺手裁旧的)。
|
||||
pub fn recordings_root() -> Result<PathBuf> {
|
||||
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<u32>) -> 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<PathBuf> {
|
||||
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<PathBuf> {
|
||||
let dir = models_root()?.join("foundry-local");
|
||||
ensure_dir(&dir)?;
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn foundry_native_runtime_root() -> Result<PathBuf> {
|
||||
let dir = foundry_local_root()?.join("runtime");
|
||||
ensure_dir(&dir)?;
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn sherpa_onnx_models_root() -> Result<PathBuf> {
|
||||
let dir = models_root()?.join("sherpa-onnx");
|
||||
ensure_dir(&dir)?;
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn foundry_model_cache_root() -> Result<PathBuf> {
|
||||
let dir = foundry_local_root()?;
|
||||
ensure_dir(&dir)?;
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn foundry_app_data_root() -> Result<PathBuf> {
|
||||
let dir = foundry_local_root()?.join("app-data");
|
||||
ensure_dir(&dir)?;
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn foundry_logs_root() -> Result<PathBuf> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<UserPreferences> {
|
||||
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::<UserPreferences>(&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::<serde_json::Value>(&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<UserPreferences>,
|
||||
}
|
||||
|
||||
impl PreferencesStore {
|
||||
pub fn new() -> Result<Self> {
|
||||
let dir = data_dir()?;
|
||||
ensure_dir(&dir)?;
|
||||
let path = dir.join(PREFERENCES_FILE);
|
||||
let prefs = if path.exists() {
|
||||
read_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);
|
||||
}
|
||||
}
|
||||
@@ -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<String>,
|
||||
version: String,
|
||||
base_mode: PolishMode,
|
||||
tags: Vec<String>,
|
||||
prompt_file: String,
|
||||
examples_file: String,
|
||||
icon_file: Option<String>,
|
||||
recommended_model: Option<String>,
|
||||
compatible_app_version: Option<String>,
|
||||
/// Marketplace 上游关系。旧 ZIP 没有此字段时自动为 None;
|
||||
/// 兼容早期口误/拼写包里可能出现的 `orion*` 字段名。
|
||||
#[serde(
|
||||
default,
|
||||
alias = "orionPackId",
|
||||
alias = "orion_pack_id",
|
||||
alias = "origin_pack_id"
|
||||
)]
|
||||
origin_pack_id: Option<String>,
|
||||
#[serde(
|
||||
default,
|
||||
alias = "orionAuthorLogin",
|
||||
alias = "orion_author_login",
|
||||
alias = "origin_author_login"
|
||||
)]
|
||||
origin_author_login: Option<String>,
|
||||
}
|
||||
|
||||
pub struct StylePackStore {
|
||||
path: PathBuf,
|
||||
asset_root: PathBuf,
|
||||
state: Mutex<Vec<StylePack>>,
|
||||
}
|
||||
|
||||
impl StylePackStore {
|
||||
pub fn new(prefs: &PreferencesStore) -> Result<Self> {
|
||||
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::<Vec<StylePack>>(&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<Vec<StylePack>> {
|
||||
Ok(self.state.lock().clone())
|
||||
}
|
||||
|
||||
pub fn list_with_active(&self, active_style_pack_id: &str) -> Result<Vec<StylePack>> {
|
||||
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<StylePack> {
|
||||
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<StylePack> {
|
||||
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<StylePack> {
|
||||
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<StylePack> {
|
||||
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<String>,
|
||||
origin_author_login: Option<String>,
|
||||
) -> Result<StylePack> {
|
||||
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<StylePack> {
|
||||
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<StylePack> {
|
||||
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<StylePack> {
|
||||
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::<Vec<StylePackExample>>(&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<StylePack>,
|
||||
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<PolishMode> {
|
||||
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<PolishMode> {
|
||||
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<StylePack> {
|
||||
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<StylePackExample>) -> Vec<StylePackExample> {
|
||||
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<String> {
|
||||
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<String>) -> Option<String> {
|
||||
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<String> {
|
||||
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<T: for<'de> Deserialize<'de>>(
|
||||
archive: &mut zip::ZipArchive<fs::File>,
|
||||
entry_name: &str,
|
||||
) -> Result<T> {
|
||||
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<fs::File>,
|
||||
entry_name: &str,
|
||||
) -> Result<String> {
|
||||
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<fs::File>,
|
||||
asset_root: &Path,
|
||||
pack_id: &str,
|
||||
entry_name: &str,
|
||||
) -> Result<Option<String>> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user