179 lines
5.4 KiB
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 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
|
|
}
|
|
}
|