import AppKit import Foundation @Observable final class PollerService { static let shared = PollerService() private let bugStore = BugStore.shared private let feishuService = FeishuService() private let tokenManager = TokenManager.shared private let configService = AppStateService.shared private var timer: Timer? private var lastFetchTime: Date? private var unlockObserver: NSObjectProtocol? private let fetchGate = FetchGate() private(set) var isRunning = false private init() {} func startIfConfigured() async { switch await tokenManager.approveKeychainIfNeeded() { case .ready: break case .locked: waitForUnlock() return case .unavailable: BuggerLog.error("PollerService: not authenticated, incident reports will not run") return } guard let config = configService.config, config.isConfigured else { BuggerLog.error("PollerService: not configured, incident reports will not run") return } guard tokenManager.isAuthenticated else { BuggerLog.error("PollerService: not authenticated, incident reports will not run") return } BuggerLog.info("PollerService: starting") start(interval: TimeInterval(config.pollIntervalSeconds), fetchImmediately: config.refreshOnStart) } func start(interval: TimeInterval, fetchImmediately: Bool = true) { guard !isRunning else { return } isRunning = true // Incident reports need a fetch at launch. Otherwise the first fetch // can be hours later (daily schedule, refresh-on-launch off) and is // the change that delivered the new bug. let investigate = configService.config?.incidentReportsEnabled ?? true if fetchImmediately || investigate { Task { await performFetch() } } if interval < 0 { // Daily schedule mode: check every 60s, fire at configured times timer = Timer.scheduledTimer(withTimeInterval: 60, repeats: true) { [weak self] _ in self?.checkDailySchedule() } timer?.tolerance = 5 } else { timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in Task { await self?.performFetch() } } timer?.tolerance = interval * 0.1 } } /// Keychain Allow dialogs cannot appear while the screen is locked. /// Retry the launch fetch once the user unlocks. private func waitForUnlock() { guard unlockObserver == nil else { return } BuggerLog.info("Screen is locked, so the Feishu token cannot be read. Incident reports start after unlock.") unlockObserver = DistributedNotificationCenter.default().addObserver( forName: Notification.Name("com.apple.screenIsUnlocked"), object: nil, queue: .main ) { [weak self] _ in guard let self else { return } if let token = self.unlockObserver { DistributedNotificationCenter.default().removeObserver(token) self.unlockObserver = nil } BuggerLog.info("Screen unlocked, reading the Feishu token") Task { await self.startIfConfigured() } } } func stop() { timer?.invalidate() timer = nil isRunning = false } func restart(interval: TimeInterval) { stop() start(interval: interval) } func fetchNow() async { await performFetch() } // MARK: - Daily schedule private func checkDailySchedule() { guard let config = configService.config else { return } let formatter = DateFormatter() formatter.dateFormat = "HH:mm" let now = formatter.string(from: Date()) guard config.dailyRefreshTimes.contains(now) else { return } // Avoid firing twice in the same minute if let last = lastFetchTime, Date().timeIntervalSince(last) < 120 { return } Task { await performFetch() } } // MARK: - Fetch /// Serializes fetches and coalesces concurrent triggers. /// /// Previously a trigger arriving mid-fetch was silently dropped /// (`guard !isFetching else { return }`). Rapid successive changes — /// e.g. an A→B→A reassignment producing two SSE wake-ups — then /// collapsed into a single fetch, and if that fetch sampled the final /// state the transition was never observed and no notification fired. /// Now a trigger arriving mid-fetch queues exactly one follow-up round, /// so the final state is always observed. The actor also makes the /// check-then-set race-safe (triggers arrive from the timer, the SSE /// handler and the UI on different tasks). private actor FetchGate { private var fetching = false private var pending = false /// Returns true if the caller may start fetching; otherwise queues /// a follow-up round and returns false. func tryBegin() -> Bool { if fetching { pending = true return false } fetching = true return true } /// Ends one round. Returns true when another round was requested /// while fetching; the gate stays held between rounds so no /// parallel fetch can slip in. func endRound() -> Bool { if pending { pending = false return true } fetching = false return false } } private func performFetch() async { guard await fetchGate.tryBegin() else { return } repeat { await fetchOnce() } while await fetchGate.endRound() } private func fetchOnce() async { lastFetchTime = Date() await MainActor.run { bugStore.setLoading(true) bugStore.setError(nil) } do { let config = try getConfig() let token = try await tokenManager.getAccessToken() let assignee = try await tokenManager.resolveAssigneeName(config: config, accessToken: token) let openId = tokenManager.cachedOpenId let bugs = try await feishuService.fetchBugs( config: config, assigneeName: assignee, accessToken: token, userOpenId: openId ) BuggerLog.info("PollerService: fetched \(bugs.count) bugs") await MainActor.run { bugStore.update(with: bugs) bugStore.setLoading(false) AppDelegate.shared?.updateBadge(count: bugStore.activeBugs.count) IncidentReportService.shared.noteFetch(bugs) } } catch FeishuError.unauthorized { BuggerLog.error("PollerService: unauthorized") await MainActor.run { bugStore.setLoading(false) tokenManager.clearTokens() } } catch { BuggerLog.error("PollerService: fetch failed \(error.localizedDescription)") await MainActor.run { bugStore.setLoading(false) bugStore.setError(error) } } } private func getConfig() throws -> AppConfig { guard let config = configService.config, config.isConfigured else { throw FeishuError.notConfigured } return config } }