Skip to content

Cache keychain passwords to prevent duplicate prompts#5982

Merged
kodjima33 merged 1 commit into
mainfrom
worktree-fix-onboarding-restart-recovery
Mar 24, 2026
Merged

Cache keychain passwords to prevent duplicate prompts#5982
kodjima33 merged 1 commit into
mainfrom
worktree-fix-onboarding-restart-recovery

Conversation

@kodjima33
Copy link
Copy Markdown
Collaborator

Summary

Add shared BrowserKeychainCache between GmailReaderService and CalendarReaderService. Keychain is only accessed once per browser per session — the second service reuses the cached password. No more double keychain prompts after restart.

🤖 Generated with Claude Code

Add BrowserKeychainCache shared between Gmail and Calendar services.
Keychain is only accessed once per browser per session — second service
reuses the cached password. No more double prompts after restart.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@kodjima33 kodjima33 merged commit 4a238b1 into main Mar 24, 2026
@kodjima33 kodjima33 deleted the worktree-fix-onboarding-restart-recovery branch March 24, 2026 04:36
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Mar 24, 2026

Greptile Summary

This PR introduces a shared BrowserKeychainCache singleton (in GmailReaderService.swift) used by both GmailReaderService and CalendarReaderService to avoid triggering duplicate macOS keychain permission prompts when both services need the same browser Safe Storage password on startup.

Key observations:

  • Dead hasAttempted guard (both files): After if let cached = BrowserKeychainCache.shared.get(service) handles all cache-hit cases (including the "" sentinel for failures), hasAttempted can never return true — the key is absent when get yields nil. Both files contain this unreachable branch.
  • Missing private init(): BrowserKeychainCache has no private initializer, so external code could accidentally instantiate a separate cache, bypassing the singleton.
  • errSecItemNotFound permanently cached as a failure: markFailed is called regardless of why the keychain lookup failed, including when the item simply doesn't exist yet. Credentials added to the keychain later in the session (e.g., after first browser launch) will never be detected without a full app restart.

Confidence Score: 3/5

  • Safe to merge with low risk of regression, but the dead-code logic issue and permanent failure caching should be addressed before merging.
  • The core goal (share a cache between two services) is achieved correctly and cannot make the user experience worse than today. However, the hasAttempted check being dead code indicates a misunderstanding of the sentinel design that could cause future confusion or bugs if the code is refactored. The permanent caching of errSecItemNotFound could silently break credential discovery after a browser is opened mid-session. These are concrete correctness issues rather than stylistic nits.
  • desktop/Desktop/Sources/GmailReaderService.swift — contains the BrowserKeychainCache definition and all three issues flagged above

Important Files Changed

Filename Overview
desktop/Desktop/Sources/GmailReaderService.swift Introduces BrowserKeychainCache singleton; contains a dead hasAttempted guard, missing private init(), and permanent caching of errSecItemNotFound failures
desktop/Desktop/Sources/CalendarReaderService.swift Adopts BrowserKeychainCache correctly; same dead hasAttempted guard duplicated from GmailReaderService

Sequence Diagram

sequenceDiagram
    participant GRS as GmailReaderService
    participant CRS as CalendarReaderService
    participant Cache as BrowserKeychainCache
    participant KC as macOS Keychain

    Note over GRS,KC: App startup — Gmail loads first
    GRS->>Cache: get("Chrome Safe Storage")
    Cache-->>GRS: nil (cold cache)
    GRS->>KC: SecItemCopyMatching(...)
    KC-->>GRS: password (user prompted once)
    GRS->>Cache: set("Chrome Safe Storage", password)

    Note over GRS,KC: Calendar loads shortly after
    CRS->>Cache: get("Chrome Safe Storage")
    Cache-->>CRS: password (cache hit — no prompt)
    CRS-->>CRS: return cached password

    Note over GRS,KC: Failure path
    GRS->>Cache: get("Arc Safe Storage")
    Cache-->>GRS: nil (cold cache)
    GRS->>KC: SecItemCopyMatching(...)
    KC-->>GRS: errSecItemNotFound
    GRS->>Cache: markFailed("Arc Safe Storage")
    CRS->>Cache: get("Arc Safe Storage")
    Cache-->>CRS: "" (returns nil — no second prompt)
Loading

Reviews (1): Last reviewed commit: "fix(desktop): cache keychain passwords t..." | Re-trigger Greptile

Comment on lines +564 to +569
if let cached = BrowserKeychainCache.shared.get(service) {
return cached.isEmpty ? nil : cached
}
if BrowserKeychainCache.shared.hasAttempted(service) {
return nil
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 hasAttempted check is unreachable dead code

The hasAttempted guard on lines 567-569 can never fire. Here's why:

  • get(_:) returns cache[service], which is nil only when the key is absent from the dictionary.
  • markFailed(_:) stores "" in the dictionary, so a failed lookup is represented as Optional("")not nil.
  • Therefore if let cached = BrowserKeychainCache.shared.get(service) already handles the "previously failed" case: cached binds "", the empty check returns nil, and we exit early on line 565.
  • Code past that if let is only reached when get returns nil, which means the key is not in the cache at all. At that point hasAttempted is guaranteed to return false.

The result is that the block if BrowserKeychainCache.shared.hasAttempted(service) { return nil } is dead code and creates a false impression that two separate paths exist for "never tried" vs "tried and failed".

Fix: remove the redundant check, or change get to return nil for failures and use a separate Bool-typed flag so the distinction is explicit.

Suggested change
if let cached = BrowserKeychainCache.shared.get(service) {
return cached.isEmpty ? nil : cached
}
if BrowserKeychainCache.shared.hasAttempted(service) {
return nil
}
// Check shared cache first to avoid duplicate keychain prompts
if let cached = BrowserKeychainCache.shared.get(service) {
return cached.isEmpty ? nil : cached
}

Comment on lines +69 to +98
final class BrowserKeychainCache: @unchecked Sendable {
static let shared = BrowserKeychainCache()
private var cache: [String: String] = [:]
private let lock = NSLock()

func get(_ service: String) -> String? {
lock.lock()
defer { lock.unlock() }
return cache[service]
}

func set(_ service: String, password: String) {
lock.lock()
defer { lock.unlock() }
cache[service] = password
}

/// Returns true if we already tried and failed for this service
func hasAttempted(_ service: String) -> Bool {
lock.lock()
defer { lock.unlock() }
return cache.keys.contains(service)
}

func markFailed(_ service: String) {
lock.lock()
defer { lock.unlock() }
cache[service] = ""
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Missing private init() on singleton

BrowserKeychainCache is designed as a singleton (static let shared), but without a private init(), any external code can create additional instances via BrowserKeychainCache(), bypassing the shared cache entirely and defeating the duplicate-prompt prevention.

Suggested change
final class BrowserKeychainCache: @unchecked Sendable {
static let shared = BrowserKeychainCache()
private var cache: [String: String] = [:]
private let lock = NSLock()
func get(_ service: String) -> String? {
lock.lock()
defer { lock.unlock() }
return cache[service]
}
func set(_ service: String, password: String) {
lock.lock()
defer { lock.unlock() }
cache[service] = password
}
/// Returns true if we already tried and failed for this service
func hasAttempted(_ service: String) -> Bool {
lock.lock()
defer { lock.unlock() }
return cache.keys.contains(service)
}
func markFailed(_ service: String) {
lock.lock()
defer { lock.unlock() }
cache[service] = ""
}
}
final class BrowserKeychainCache: @unchecked Sendable {
static let shared = BrowserKeychainCache()
private init() {}
private var cache: [String: String] = [:]
private let lock = NSLock()

Comment on lines +577 to +582
if let cached = BrowserKeychainCache.shared.get(service) {
return cached.isEmpty ? nil : cached
}
if BrowserKeychainCache.shared.hasAttempted(service) {
return nil
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Same dead hasAttempted check as in GmailReaderService

The same unreachable logic described in GmailReaderService.swift is duplicated here. Since get already handles the "" sentinel, hasAttempted can never return true at this point, and the block is dead code.

Suggested change
if let cached = BrowserKeychainCache.shared.get(service) {
return cached.isEmpty ? nil : cached
}
if BrowserKeychainCache.shared.hasAttempted(service) {
return nil
}
// Check shared cache first to avoid duplicate keychain prompts
if let cached = BrowserKeychainCache.shared.get(service) {
return cached.isEmpty ? nil : cached
}

Comment on lines +93 to +97
func markFailed(_ service: String) {
lock.lock()
defer { lock.unlock() }
cache[service] = ""
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Permanent failure caching with no invalidation path

markFailed is called for every failure branch, including errSecItemNotFound (item simply doesn't exist in the keychain yet). Once cached as a failure, there is no way to invalidate the entry for the rest of the app's lifetime.

This means: if a user logs into a browser after the app has already attempted (and failed) a keychain lookup, the credential will never be picked up without a full app restart. While the PR description intentionally scopes this to "one session," it may be surprising in practice — e.g., during a first-run onboarding flow where the browser is opened after the desktop app.

Consider calling markFailed only for hard errors (not errSecItemNotFound), so a "not found yet" case can be retried without restarting the app.

Glucksberg pushed a commit to Glucksberg/omi-local that referenced this pull request Apr 28, 2026
…5982)

## Summary
Add shared `BrowserKeychainCache` between GmailReaderService and
CalendarReaderService. Keychain is only accessed once per browser per
session — the second service reuses the cached password. No more double
keychain prompts after restart.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant