Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 68 additions & 2 deletions openless-all/app/src-tauri/src/persistence.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,41 @@ 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;
}

// ───────────────────────── path helpers ─────────────────────────

fn data_dir() -> Result<PathBuf> {
Expand Down Expand Up @@ -533,6 +568,9 @@ fn migrate_legacy_sources_for_update() -> Result<CredsRoot> {
}

fn load_credentials() -> CredsRoot {
if let Some(cached) = credentials_cache().lock().as_ref().cloned() {
return cached;
}
match load_keyring_credentials() {
Ok(Some(root)) => {
// 不在这里调 remove_legacy_keyring_credentials() —— 它内部对每个
Expand All @@ -542,25 +580,50 @@ fn load_credentials() -> CredsRoot {
// 只会反复弹「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
}
Ok(None) => migrate_legacy_sources(),
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);
}
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)
}
Ok(None) => migrate_legacy_sources_for_update(),
// 错误路径不缓存 —— 同 load_credentials 注释;让下次读重试 keyring。
Err(e) => Err(e),
}
}
Expand Down Expand Up @@ -616,6 +679,9 @@ fn save_credentials(root: &CredsRoot) -> Result<()> {
}

remove_legacy_credentials_file_best_effort();
// 写完成功后立刻刷新 process cache —— 同进程后续读不再回 Keychain。
// 见 CREDENTIALS_CACHE 的 doc。
store_credentials_cache(&cleaned);
Ok(())
}

Expand Down
Loading