import AppKit import CoreGraphics import Foundation @Observable final class TokenManager { static let shared = TokenManager() private let keychain = KeychainHelper.shared let authService = FeishuAuthService() private let accessTokenKey = "feishu.access_token" private let refreshTokenKey = "feishu.refresh_token" private let tokenExpiryKey = "feishu.token_expiry" private let openIdKey = "feishu.open_id" enum State: Equatable { case unauthenticated case authenticating case authenticated case refreshing case error(String) } private(set) var state: State = .unauthenticated private init() { if keychain.read(accessTokenKey) != nil { state = .authenticated } } var isAuthenticated: Bool { if case .authenticated = state { return true } if keychain.read(accessTokenKey) != nil { return true } return false } enum KeychainGate { case ready case locked case unavailable } /// The login-keychain item is tied to the code signature that wrote it. /// An ad-hoc rebuild cannot read it until the user clicks Allow. The main /// thread has to stay free so that dialog can appear. A locked screen /// cannot present it at all. @MainActor func approveKeychainIfNeeded() async -> KeychainGate { if isAuthenticated { return .ready } guard keychain.needsApproval(accessTokenKey) else { return .unavailable } if Self.screenIsLocked() { return .locked } BuggerLog.info("Keychain approval required for the Feishu token") let previous = NSApp.activationPolicy() NSApp.setActivationPolicy(.regular) NSApp.activate() try? await Task.sleep(nanoseconds: 300_000_000) let key = accessTokenKey let helper = keychain let token = await Task.detached { helper.readPrompting(key) }.value if previous == .accessory { NSApp.setActivationPolicy(.accessory) } if token != nil { state = .authenticated BuggerLog.info("Keychain approval granted") return .ready } BuggerLog.error("Keychain approval was not granted") return .unavailable } private static func screenIsLocked() -> Bool { guard let raw = CGSessionCopyCurrentDictionary() else { return false } let value = (raw as NSDictionary)["CGSSessionScreenIsLocked"] if let locked = value as? Bool { return locked } if let number = value as? NSNumber { return number.intValue != 0 } return false } var authorizeURL: URL? { guard authService.hasCredentials else { return nil } return authService.authorizeURL } /// The user's Feishu open_id, cached after the first OAuth callback or /// user-info fetch. Used for server-side filtering of Person-type fields /// on the Feishu search endpoint. Nil if the user hasn't authenticated. var cachedOpenId: String? { keychain.read(openIdKey) } func storeOpenId(_ openId: String) { guard !openId.isEmpty else { return } keychain.write(openId, forKey: openIdKey) } func getAccessToken() async throws -> String { if let token = keychain.read(accessTokenKey), let expiry = UserDefaults.standard.object(forKey: tokenExpiryKey) as? Date, expiry > Date().addingTimeInterval(60) { state = .authenticated return token } guard let refreshToken = keychain.read(refreshTokenKey), !refreshToken.isEmpty else { state = .unauthenticated throw TokenError.noRefreshToken } state = .refreshing do { let response = try await authService.refreshAccessToken(refreshToken) storeTokens( access: response.accessToken, refresh: response.refreshToken ?? refreshToken, expiresIn: response.expiresIn ) state = .authenticated return response.accessToken } catch { state = .error(error.localizedDescription) throw error } } func storeTokens(access: String, refresh: String, expiresIn: Int) { keychain.write(access, forKey: accessTokenKey) if !refresh.isEmpty { keychain.write(refresh, forKey: refreshTokenKey) } else { keychain.delete(refreshTokenKey) } let expiry = Date().addingTimeInterval(TimeInterval(expiresIn)) UserDefaults.standard.set(expiry, forKey: tokenExpiryKey) state = .authenticated } func clearTokens() { keychain.delete(accessTokenKey) keychain.delete(refreshTokenKey) keychain.delete(openIdKey) UserDefaults.standard.removeObject(forKey: tokenExpiryKey) state = .unauthenticated } func setError(_ message: String) { state = .error(message) } func handleCallback(code: String) async throws { guard !code.isEmpty else { BuggerLog.error("handleCallback: empty code") throw TokenError.invalidCallback } BuggerLog.info("handleCallback: exchanging code...") state = .authenticating let response = try await authService.exchangeCode(code) BuggerLog.info("handleCallback: got tokens, storing...") storeTokens( access: response.accessToken, refresh: response.refreshToken ?? "", expiresIn: response.expiresIn ) // Fetch and cache the user's open_id for server-side record filtering. if let userInfo = try? await authService.fetchUserInfo(accessToken: response.accessToken), let openId = userInfo.openId { storeOpenId(openId) } state = .authenticated BuggerLog.info("handleCallback: done, authenticated ✓") } func resolveAssigneeName(config: AppConfig, accessToken: String) async throws -> String { if !config.assigneeName.isEmpty { return config.assigneeName } let userInfo = try await authService.fetchUserInfo(accessToken: accessToken) if let openId = userInfo.openId { storeOpenId(openId) } return userInfo.name ?? userInfo.enName ?? "Unknown" } } enum TokenError: Error, LocalizedError { case noRefreshToken case invalidCallback case refreshFailed var errorDescription: String? { switch self { case .noRefreshToken: return "No Feishu session found. Please connect Feishu." case .invalidCallback: return "Invalid OAuth callback URL." case .refreshFailed: return "Failed to refresh Feishu token." } } }