From b0da8e01b945dc479f1c8ae3f333640bd856a94d Mon Sep 17 00:00:00 2001 From: baiqing Date: Sun, 10 May 2026 09:23:29 +0800 Subject: [PATCH 1/3] fix(vault): cache CredsRoot in process to stop repeated Keychain prompts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without an in-process cache, every CredentialsVault::get_* / get_active_asr / snapshot call drove 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 yet been "Always Allow"-ed) prompts the user on every read of every entry. A single dictation cycle reads credentials 5–10 times, multiplied by (1 manifest + N chunks), produces tens of "OpenLess wants to use the keychain" prompts per recording. Add a process-wide CREDENTIALS_CACHE: OnceLock>>: - load_credentials / load_credentials_for_update consult the cache before falling through to Keychain. First read populates the cache; every subsequent read in the same process is silent. - save_credentials populates the cache with the cleaned root after successful Keychain write, keeping Settings → Recording credential edits visible immediately to dictation. - migrate_legacy_sources{,_for_update} call save_credentials internally so legacy-migration paths inherit the cache update for free. Trade-off: cross-process changes (e.g. user runs `security` CLI manually, or a second instance of the app — single-instance is enforced but defense in depth) are invisible until next launch. Acceptable per the credential vault contract: the keyring is owned by this app. Note: this does NOT fix the underlying problem that ad-hoc-signed builds change cdhash on every rebuild and lose "Always Allow" grants. For that the build must use a stable Apple Developer ID signature (APPLE_CERTIFICATE / APPLE_CERTIFICATE_PASSWORD / APPLE_TEAM_ID env vars). The cache only ensures that within a single process lifetime, the user is prompted at most once per Keychain entry. 185/185 lib tests pass. cargo check clean. --- openless-all/app/src-tauri/src/persistence.rs | 62 ++++++++++++++++--- 1 file changed, 55 insertions(+), 7 deletions(-) diff --git a/openless-all/app/src-tauri/src/persistence.rs b/openless-all/app/src-tauri/src/persistence.rs index d2175dd4..4a251c26 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,7 +568,10 @@ fn migrate_legacy_sources_for_update() -> Result { } fn load_credentials() -> CredsRoot { - match load_keyring_credentials() { + if let Some(cached) = credentials_cache().lock().as_ref().cloned() { + return cached; + } + let root = match load_keyring_credentials() { Ok(Some(root)) => { // 不在这里调 remove_legacy_keyring_credentials() —— 它内部对每个 // 旧 account 各做一次 keyring delete,每次 delete 在 macOS Keychain @@ -549,20 +587,27 @@ fn load_credentials() -> CredsRoot { log::warn!("[vault] system credential read failed: {e}"); load_legacy_sources_without_migration() } - } + }; + store_credentials_cache(&root); + root } fn load_credentials_for_update() -> Result { - match load_keyring_credentials() { + if let Some(cached) = credentials_cache().lock().as_ref().cloned() { + return Ok(cached); + } + let root = match load_keyring_credentials() { Ok(Some(root)) => { // 同 load_credentials:不再每次 update 都尝试 delete legacy keyring // entries,避免反复触发 macOS Keychain ACL 弹窗。 remove_legacy_credentials_file_best_effort(); - Ok(root) + root } - Ok(None) => migrate_legacy_sources_for_update(), - Err(e) => Err(e), - } + Ok(None) => migrate_legacy_sources_for_update()?, + Err(e) => return Err(e), + }; + store_credentials_cache(&root); + Ok(root) } fn save_credentials(root: &CredsRoot) -> Result<()> { @@ -616,6 +661,9 @@ fn save_credentials(root: &CredsRoot) -> Result<()> { } remove_legacy_credentials_file_best_effort(); + // 写完成功后立刻刷新 process cache —— 同进程后续读不再回 Keychain。 + // 见 CREDENTIALS_CACHE 的 doc。 + store_credentials_cache(&cleaned); Ok(()) } From 2dc7ac59cfe1cfdc60518bc2231686635f0ee1b4 Mon Sep 17 00:00:00 2001 From: baiqing Date: Sun, 10 May 2026 09:41:07 +0800 Subject: [PATCH 2/3] ci: re-trigger pr_agent after PR description cleanup From 8a9fb4f4f888ff7f73911cbeab1d403266c93298 Mon Sep 17 00:00:00 2001 From: baiqing Date: Sun, 10 May 2026 10:01:00 +0800 Subject: [PATCH 3/3] fix(vault): don't cache fallback creds when keyring read fails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pr_agent on PR #394 flagged a "Stale cache on read error" issue: both load_credentials / load_credentials_for_update were unconditionally writing the result into CREDENTIALS_CACHE, including the legacy-file fallback returned when load_keyring_credentials() hit an Err (e.g. user hasn't yet clicked "Always Allow" on the first keychain dialog, login keychain locked, DataProtection error). Effect: after a transient keyring failure the process pinned the fallback (or default) creds in cache for its entire lifetime — even after the user granted access, subsequent reads would never retry the keyring and would keep returning stale defaults / legacy content until the app is restarted. Fix: cache only on the Ok(Some) and Ok(None)→migrate paths. The Err path now returns the legacy fallback without populating the cache, so the next call retries the keyring and picks up the user's just-granted access. 185/185 lib tests pass; cargo check clean. --- openless-all/app/src-tauri/src/persistence.rs | 42 +++++++++++++------ 1 file changed, 30 insertions(+), 12 deletions(-) diff --git a/openless-all/app/src-tauri/src/persistence.rs b/openless-all/app/src-tauri/src/persistence.rs index 4a251c26..191fd2e6 100644 --- a/openless-all/app/src-tauri/src/persistence.rs +++ b/openless-all/app/src-tauri/src/persistence.rs @@ -571,7 +571,7 @@ fn load_credentials() -> CredsRoot { if let Some(cached) = credentials_cache().lock().as_ref().cloned() { return cached; } - let root = match load_keyring_credentials() { + match load_keyring_credentials() { Ok(Some(root)) => { // 不在这里调 remove_legacy_keyring_credentials() —— 它内部对每个 // 旧 account 各做一次 keyring delete,每次 delete 在 macOS Keychain @@ -580,34 +580,52 @@ 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() } - }; - store_credentials_cache(&root); - root + } } fn load_credentials_for_update() -> Result { if let Some(cached) = credentials_cache().lock().as_ref().cloned() { return Ok(cached); } - let root = match load_keyring_credentials() { + match load_keyring_credentials() { Ok(Some(root)) => { // 同 load_credentials:不再每次 update 都尝试 delete legacy keyring // entries,避免反复触发 macOS Keychain ACL 弹窗。 remove_legacy_credentials_file_best_effort(); - root + store_credentials_cache(&root); + Ok(root) } - Ok(None) => migrate_legacy_sources_for_update()?, - Err(e) => return Err(e), - }; - 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<()> {