Merge pull request #394 from appergb/fix/keychain-cache

fix(vault): cache CredsRoot in process to stop repeated Keychain prompts
This commit is contained in:
TRIP authored and GitHub committed 2026-05-10 10:12:27 +08:00
commit 0f7961963b
1 file changed
+68 -2
+68 -2
View File
@@ -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> {
@@ -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() —— 它内部对每个
@@ -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<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(),
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),
}
}
@@ -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(())
}