fix(vault): cache CredsRoot in process to stop repeated Keychain prompts
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<Mutex<Option<CredsRoot>>>:
- 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.
This commit is contained in:
1 parent
618fc7f0b1
commit
b0da8e01b9
1 file changed
+55
-7
@@ -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,7 +568,10 @@ fn migrate_legacy_sources_for_update() -> Result<CredsRoot> {
|
||||
}
|
||||
|
||||
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<CredsRoot> {
|
||||
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(())
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user