import Foundation /// Periodically verifies that Bugger's local bug list matches the record IDs /// the notification server (bugger-feishu) believes are assigned to the /// current user. If there's a mismatch — meaning a push was missed — this /// service triggers `PollerService.fetchNow()` to recalibrate. /// /// Disabled by default. Enabled via the "Enable calibration check" toggle /// in Advanced settings. Only active when `feishuAppBaseURL` is configured, /// since the calibration endpoint lives on the same server as the SSE stream. final class CalibrationService { static let shared = CalibrationService() private var timer: Timer? private let interval: TimeInterval = 300 private var isRunning = false private let session = URLSession.shared private init() {} // MARK: - Public func start() { guard !isRunning else { return } guard let config = AppStateService.shared.config, config.calibrationEnabled, !config.feishuAppBaseURL.isEmpty else { return } isRunning = true timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in Task { await self?.calibrate() } } timer?.tolerance = 30 DispatchQueue.main.asyncAfter(deadline: .now() + 60) { [weak self] in Task { await self?.calibrate() } } BuggerLog.info("CalibrationService: started (interval=\(Int(interval))s)") } func stop() { timer?.invalidate() timer = nil isRunning = false } func restart() { stop() start() } // MARK: - Private private func calibrate() async { guard let config = AppStateService.shared.config, !config.feishuAppBaseURL.isEmpty, !config.assigneeName.isEmpty, !config.fieldMappings.assigneeField.isEmpty else { return } guard let url = buildURL(config: config) else { return } do { let (data, response) = try await session.data(from: url) guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { BuggerLog.error("CalibrationService: non-200 response (\((response as? HTTPURLResponse)?.statusCode ?? -1))") return } let result = try JSONDecoder().decode(OwnedRecordsResponse.self, from: data) guard result.cacheStatus != "cold" else { BuggerLog.info("CalibrationService: server cache cold, skipping") return } let serverIDs = Set(result.recordIds) let localIDs = Set(BugStore.shared.bugs.map(\.id)) guard !localIDs.isEmpty else { BuggerLog.info("CalibrationService: no local bugs yet, skipping") return } if serverIDs != localIDs { let added = serverIDs.subtracting(localIDs) let removed = localIDs.subtracting(serverIDs) BuggerLog.info("CalibrationService: mismatch (server=\(serverIDs.count), local=\(localIDs.count), +\(added.count) -\(removed.count)), triggering fetchNow") await PollerService.shared.fetchNow() } else { BuggerLog.debug("CalibrationService: in sync (\(serverIDs.count) records)") } } catch { BuggerLog.error("CalibrationService: error — \(error.localizedDescription)") } } private func buildURL(config: AppConfig) -> URL? { let base = config.feishuAppBaseURL.trimmingCharacters(in: CharacterSet(charactersIn: "/")) guard var comps = URLComponents(string: "\(base)/api/v1/bitable/owned_records") else { return nil } comps.queryItems = [ URLQueryItem(name: "file_token", value: config.appToken), URLQueryItem(name: "assignee_field", value: config.fieldMappings.assigneeField), URLQueryItem(name: "assignee_name", value: config.assigneeName), ] return comps.url } } // MARK: - Response model struct OwnedRecordsResponse: Decodable { let recordIds: [String] let cacheStatus: String let count: Int enum CodingKeys: String, CodingKey { case recordIds = "record_ids" case cacheStatus = "cache_status" case count } }