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 let fetchGate = FetchGate() private(set) var isRunning = false private init() {} func startIfConfigured() async { guard let config = configService.config, config.isConfigured, tokenManager.isAuthenticated else { return } start(interval: TimeInterval(config.pollIntervalSeconds), fetchImmediately: config.refreshOnStart) } func start(interval: TimeInterval, fetchImmediately: Bool = true) { guard !isRunning else { return } isRunning = true if fetchImmediately { 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 } } 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 ) await MainActor.run { bugStore.update(with: bugs) bugStore.setLoading(false) AppDelegate.shared?.updateBadge(count: bugStore.activeBugs.count) } } catch FeishuError.unauthorized { await MainActor.run { bugStore.setLoading(false) tokenManager.clearTokens() } } catch { 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 } }