diff --git a/Calendarr iOS/CalendarrApp.swift b/Calendarr iOS/CalendarrApp.swift index c6bbe26..8f41dc2 100644 --- a/Calendarr iOS/CalendarrApp.swift +++ b/Calendarr iOS/CalendarrApp.swift @@ -8,6 +8,7 @@ struct CalendarrApp: App { // Registration has to happen before the app finishes launching, which // is why it is here and not in a .task modifier. BackgroundRefresh.register() + WatchSyncService.shared.activate() } var body: some Scene { @@ -114,6 +115,9 @@ class AppState { // say "sign in to Calendarr" rather than "open Calendarr once". WidgetStore.clear() publishSession() + // The watch keeps its own copy of the snapshot, so clearing ours is not + // enough — it has to be told, or it keeps rendering this user's events. + WatchSyncService.shared.pushSignedOut() } func resetServer() { diff --git a/Calendarr iOS/Models/CalendarStore.swift b/Calendarr iOS/Models/CalendarStore.swift index 05e68e4..266c20c 100644 --- a/Calendarr iOS/Models/CalendarStore.swift +++ b/Calendarr iOS/Models/CalendarStore.swift @@ -599,7 +599,8 @@ class CalendarStore { colorHex: ev.calendarColor) } } - WidgetStore.writeCalendars(Array(calendarMap.values).sorted { $0.name < $1.name }) + let calendars = Array(calendarMap.values).sorted { $0.name < $1.name } + WidgetStore.writeCalendars(calendars) let visible = allCachedEvents .filter { ev in @@ -620,10 +621,14 @@ class CalendarStore { location: ev.location, calendarKey: Self.calendarKey(source: ev.source, calendarId: ev.calendarId)) } - WidgetStore.write(WidgetStore.makeSnapshot(events: Array(visible), - coverageStart: from, - coverageEnd: to)) + let snapshot = WidgetStore.makeSnapshot(events: Array(visible), + coverageStart: from, + coverageEnd: to) + WidgetStore.write(snapshot) WidgetTimelineNotifier.reload() + // The watch has its own container and cannot read the file we just + // wrote, so it is handed the data explicitly. + WatchSyncService.shared.push(snapshot: snapshot, calendars: calendars) } // MARK: – Writable calendars diff --git a/Calendarr iOS/Services/WatchSyncService.swift b/Calendarr iOS/Services/WatchSyncService.swift new file mode 100644 index 0000000..c5bb05f --- /dev/null +++ b/Calendarr iOS/Services/WatchSyncService.swift @@ -0,0 +1,156 @@ +import Foundation +import WatchConnectivity +import CalendarrCore + +/// Feeds the watch. App Groups are per-device, so the watch cannot read the +/// file this app just wrote — it has to be handed the data. +/// +/// Three delivery paths, because each one alone leaves a gap: +/// +/// 1. `updateApplicationContext` carries only the latest state and has no queue +/// to overflow. It is delivered when the watch app next launches or wakes. +/// 2. `transferCurrentComplicationUserInfo` is the only path that wakes the +/// watch app while it is not running. Its daily budget is small, so it is +/// spent only when the *next* appointment changed. +/// 3. A reply to `sendMessage` covers the watch app asking while it is open. +@MainActor +final class WatchSyncService: NSObject { + static let shared = WatchSyncService() + + private let sessions = SharedSessionStore() + private static let lastIdentityKey = "watchLastPushedNextEvent" + private static let pushTimesKey = "watchComplicationPushTimes" + /// The system budget is roughly 50 a day; this keeps a burst of edits from + /// spending it in an hour. + private static let maxPushesPerHour = 4 + + private var session: WCSession? { + WCSession.isSupported() ? WCSession.default : nil + } + + /// Call once at launch. + func activate() { + guard let session else { return } + session.delegate = self + session.activate() + } + + /// Publish the current calendar to the watch. + func push(snapshot: CalendarrSnapshot, calendars: [SnapshotCalendar]) { + guard let session, session.activationState == .activated else { return } + + let trimmed = snapshot.trimmedForWatch() + let payload = WatchTransportPayload(snapshot: trimmed, + calendars: calendars, + session: sessions.read()) + guard let dict = try? payload.encoded() else { return } + + try? session.updateApplicationContext(dict) + + guard session.isComplicationEnabled else { return } + let current = WatchPushPolicy.nextEventIdentity(in: trimmed, at: Date()) + guard WatchPushPolicy.shouldPushComplicationUpdate(previous: loadLastIdentity(), + current: current), + consumePushBudget() + else { return } + + session.transferCurrentComplicationUserInfo(dict) + storeLastIdentity(current) + } + + /// Tell the watch the user signed out, so it stops showing their calendar. + /// The watch keeps its own copy of the snapshot, so clearing ours is not + /// enough. + func pushSignedOut() { + guard let session, session.activationState == .activated else { return } + + let now = Date() + let empty = CalendarrSnapshot( + writtenAt: now, coverageStart: now, coverageEnd: now, + isLoggedIn: false, writerVersion: "signout", events: [], + theme: SnapshotTheme(today: "#4285f4", text: "#FFFFFF", background: "#000000", + line: "#3A3A52", primary: "#4285f4", accent: "#ea4335"), + language: "system") + let payload = WatchTransportPayload(snapshot: empty, calendars: [], session: sessions.read()) + guard let dict = try? payload.encoded() else { return } + + try? session.updateApplicationContext(dict) + if session.isComplicationEnabled, consumePushBudget() { + session.transferCurrentComplicationUserInfo(dict) + } + storeLastIdentity(nil) + } + + /// The payload for a watch that asked directly. Built from the file we + /// already wrote, so an on-demand pull cannot disagree with a push. + fileprivate func currentPayloadDictionary() -> [String: Any]? { + let store = SnapshotStore() + guard case .ok(let snapshot) = store.read() else { return nil } + let payload = WatchTransportPayload(snapshot: snapshot.trimmedForWatch(), + calendars: store.readCalendars(), + session: sessions.read()) + return try? payload.encoded() + } + + // MARK: – Push budget + + private func consumePushBudget() -> Bool { + let cutoff = Date().addingTimeInterval(-3600) + var times = (UserDefaults.standard.array(forKey: Self.pushTimesKey) as? [Double] ?? []) + .map { Date(timeIntervalSince1970: $0) } + .filter { $0 > cutoff } + + guard times.count < Self.maxPushesPerHour else { + UserDefaults.standard.set(times.map(\.timeIntervalSince1970), forKey: Self.pushTimesKey) + return false + } + times.append(Date()) + UserDefaults.standard.set(times.map(\.timeIntervalSince1970), forKey: Self.pushTimesKey) + return true + } + + // MARK: – Last pushed identity + + private func loadLastIdentity() -> NextEventIdentity? { + guard let data = UserDefaults.standard.data(forKey: Self.lastIdentityKey) else { return nil } + let decoder = JSONDecoder(); decoder.dateDecodingStrategy = .iso8601 + return try? decoder.decode(NextEventIdentity.self, from: data) + } + + private func storeLastIdentity(_ identity: NextEventIdentity?) { + guard let identity else { + UserDefaults.standard.removeObject(forKey: Self.lastIdentityKey) + return + } + let encoder = JSONEncoder(); encoder.dateEncodingStrategy = .iso8601 + UserDefaults.standard.set(try? encoder.encode(identity), forKey: Self.lastIdentityKey) + } +} + +extension WatchSyncService: WCSessionDelegate { + nonisolated func session(_ session: WCSession, + activationDidCompleteWith state: WCSessionActivationState, + error: Error?) { + if let error { + print("[WatchSync] activation failed: \(error.localizedDescription)") + } + } + + /// The watch asking for the current state, which is what covers "it is open + /// in front of me and looks stale". + nonisolated func session(_ session: WCSession, + didReceiveMessage message: [String: Any], + replyHandler: @escaping ([String: Any]) -> Void) { + Task { @MainActor in + replyHandler(self.currentPayloadDictionary() ?? [:]) + } + } + + // Required on iOS: the session is torn down and re-activated when the user + // switches to a different paired watch. + nonisolated func sessionDidBecomeInactive(_ session: WCSession) {} + + nonisolated func sessionDidDeactivate(_ session: WCSession) { + WCSession.default.activate() + } +}