bugger/Sources/Services/CalibrationService.swift

125 lines
4.3 KiB
Swift

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