import Foundation #if os(macOS) import os.lock public enum ClaudeOAuthKeychainAccessGate { private struct State { var loaded = false var deniedUntil: Date? } private static let lock = OSAllocatedUnfairLock(initialState: State()) private static let defaultsKey = "claudeOAuthKeychainDeniedUntil" private static let cooldownInterval: TimeInterval = 60 * 60 * 6 @TaskLocal private static var taskOverrideShouldAllowPromptForTesting: Bool? #if DEBUG public final class DeniedUntilStore: @unchecked Sendable { public var deniedUntil: Date? public init() {} } @TaskLocal private static var taskDeniedUntilStoreOverrideForTesting: DeniedUntilStore? #endif public static func shouldAllowPrompt(now: Date = Date()) -> Bool { guard !KeychainAccessGate.isDisabled else { return false } if let override = self.taskOverrideShouldAllowPromptForTesting { return override } #if DEBUG if let store = self.taskDeniedUntilStoreOverrideForTesting { if let deniedUntil = store.deniedUntil, deniedUntil > now { return false } store.deniedUntil = nil return true } #endif return self.lock.withLock { state in self.loadIfNeeded(&state) if let deniedUntil = state.deniedUntil { if deniedUntil > now { return false } state.deniedUntil = nil self.persist(state) } return true } } public static func recordDenied(now: Date = Date()) { let deniedUntil = now.addingTimeInterval(self.cooldownInterval) #if DEBUG if let store = self.taskDeniedUntilStoreOverrideForTesting { store.deniedUntil = deniedUntil return } #endif self.lock.withLock { state in self.loadIfNeeded(&state) state.deniedUntil = deniedUntil self.persist(state) } } /// Clears the cooldown so the next attempt can proceed. Intended for user-initiated repairs. /// - Returns: true if a cooldown was present and cleared. public static func clearDenied(now: Date = Date()) -> Bool { #if DEBUG if let store = self.taskDeniedUntilStoreOverrideForTesting { guard let deniedUntil = store.deniedUntil, deniedUntil > now else { store.deniedUntil = nil return false } store.deniedUntil = nil return true } #endif return self.lock.withLock { state in self.loadIfNeeded(&state) guard let deniedUntil = state.deniedUntil, deniedUntil > now else { state.deniedUntil = nil self.persist(state) return false } state.deniedUntil = nil self.persist(state) return true } } #if DEBUG static func withShouldAllowPromptOverrideForTesting( _ value: Bool?, operation: () throws -> T) rethrows -> T { try self.$taskOverrideShouldAllowPromptForTesting.withValue(value) { try operation() } } static func withShouldAllowPromptOverrideForTesting( _ value: Bool?, operation: () async throws -> T) async rethrows -> T { try await self.$taskOverrideShouldAllowPromptForTesting.withValue(value) { try await operation() } } public static func withDeniedUntilStoreOverrideForTesting( _ store: DeniedUntilStore?, operation: () throws -> T) rethrows -> T { try self.$taskDeniedUntilStoreOverrideForTesting.withValue(store) { try operation() } } public static func withDeniedUntilStoreOverrideForTesting( _ store: DeniedUntilStore?, operation: () async throws -> T) async rethrows -> T { try await self.$taskDeniedUntilStoreOverrideForTesting.withValue(store) { try await operation() } } public static var currentDeniedUntilStoreOverrideForTesting: DeniedUntilStore? { self.taskDeniedUntilStoreOverrideForTesting } public static func resetForTesting() { self.lock.withLock { state in // Keep deterministic during tests: avoid re-loading UserDefaults written by unrelated code paths. state.loaded = true state.deniedUntil = nil UserDefaults.standard.removeObject(forKey: self.defaultsKey) } } public static func resetInMemoryForTesting() { self.lock.withLock { state in state.loaded = false state.deniedUntil = nil } } #endif private static func loadIfNeeded(_ state: inout State) { guard !state.loaded else { return } state.loaded = true if let raw = UserDefaults.standard.object(forKey: self.defaultsKey) as? Double { state.deniedUntil = Date(timeIntervalSince1970: raw) } } private static func persist(_ state: State) { if let deniedUntil = state.deniedUntil { UserDefaults.standard.set(deniedUntil.timeIntervalSince1970, forKey: self.defaultsKey) } else { UserDefaults.standard.removeObject(forKey: self.defaultsKey) } } } #else public enum ClaudeOAuthKeychainAccessGate { public static func shouldAllowPrompt(now _: Date = Date()) -> Bool { true } public static func recordDenied(now _: Date = Date()) {} public static func clearDenied(now _: Date = Date()) -> Bool { false } #if DEBUG public static func resetForTesting() {} public static func resetInMemoryForTesting() {} #endif } #endif