bugger/Sources/Services/PollerService.swift

179 lines
5.4 KiB
Swift

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 ABA 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
}
}