diff --git a/openless-all/app/src-tauri/src/persistence.rs b/openless-all/app/src-tauri/src/persistence.rs index d2175dd4..191fd2e6 100644 --- a/openless-all/app/src-tauri/src/persistence.rs +++ b/openless-all/app/src-tauri/src/persistence.rs @@ -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>> = OnceLock::new(); + +fn credentials_cache() -> &'static Mutex> { + CREDENTIALS_CACHE.get_or_init(|| Mutex::new(None)) +} + +fn store_credentials_cache(root: &CredsRoot) { + *credentials_cache().lock() = Some(root.clone()); +} + +#[cfg(test)] +fn reset_credentials_cache_for_tests() { + *credentials_cache().lock() = None; +} + // ───────────────────────── path helpers ───────────────────────── fn data_dir() -> Result { @@ -533,6 +568,9 @@ fn migrate_legacy_sources_for_update() -> Result { } 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() —— 它内部对每个 @@ -542,10 +580,23 @@ 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() } @@ -553,14 +604,26 @@ fn load_credentials() -> CredsRoot { } fn load_credentials_for_update() -> Result { + 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), } } @@ -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(()) }