From 70a31e5d09e96f1ee567453b9a1bec9c0230b79f Mon Sep 17 00:00:00 2001 From: Nik Shevchenko Date: Tue, 24 Mar 2026 00:36:26 -0400 Subject: [PATCH] fix(desktop): cache keychain passwords to prevent duplicate prompts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../Sources/CalendarReaderService.swift | 12 +++++ .../Desktop/Sources/GmailReaderService.swift | 47 +++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/desktop/Desktop/Sources/CalendarReaderService.swift b/desktop/Desktop/Sources/CalendarReaderService.swift index 47e342f1cd4..548b7334c09 100644 --- a/desktop/Desktop/Sources/CalendarReaderService.swift +++ b/desktop/Desktop/Sources/CalendarReaderService.swift @@ -573,6 +573,14 @@ sys.exit(0) // MARK: - Keychain private func getKeychainPassword(service: String) -> String? { + // Check shared cache first to avoid duplicate keychain prompts + if let cached = BrowserKeychainCache.shared.get(service) { + return cached.isEmpty ? nil : cached + } + if BrowserKeychainCache.shared.hasAttempted(service) { + return nil + } + let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, @@ -584,11 +592,15 @@ sys.exit(0) guard status == errSecSuccess, let data = result as? Data, let password = String(data: data, encoding: .utf8) else { + BrowserKeychainCache.shared.markFailed(service) if status != errSecItemNotFound { log("CalendarReaderService: Keychain lookup for '\(service)' failed with status \(status)") } return nil } + if !password.isEmpty { + BrowserKeychainCache.shared.set(service, password: password) + } return password.isEmpty ? nil : password } } diff --git a/desktop/Desktop/Sources/GmailReaderService.swift b/desktop/Desktop/Sources/GmailReaderService.swift index 9c53c2b2263..e4a0c136788 100644 --- a/desktop/Desktop/Sources/GmailReaderService.swift +++ b/desktop/Desktop/Sources/GmailReaderService.swift @@ -62,6 +62,41 @@ private struct BrowserConfig { } } +// MARK: - Shared Keychain Cache + +/// Shared cache for browser keychain passwords so we only prompt once per session. +/// Used by both GmailReaderService and CalendarReaderService. +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] = "" + } +} + // MARK: - GmailReaderService actor GmailReaderService { @@ -525,6 +560,14 @@ actor GmailReaderService { // MARK: - Keychain private func getKeychainPassword(service: String) -> String? { + // Check shared cache first to avoid duplicate keychain prompts + if let cached = BrowserKeychainCache.shared.get(service) { + return cached.isEmpty ? nil : cached + } + if BrowserKeychainCache.shared.hasAttempted(service) { + return nil + } + let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, @@ -536,11 +579,15 @@ actor GmailReaderService { guard status == errSecSuccess, let data = result as? Data, let password = String(data: data, encoding: .utf8) else { + BrowserKeychainCache.shared.markFailed(service) if status != errSecItemNotFound { log("GmailReaderService: Keychain lookup for '\(service)' failed with status \(status)") } return nil } + if !password.isEmpty { + BrowserKeychainCache.shared.set(service, password: password) + } return password.isEmpty ? nil : password }