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:
1 file changed
+68
-2
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user