Send the trimmed snapshot to the watch over WatchConnectivity
App Groups are per-device, so the watch cannot read the file the phone writes and has to be handed the data. Three delivery paths: application context for the latest state, a complication push only when the next appointment actually changed (the budget is small), and a reply to the watch asking directly. Sign-out pushes an empty signed-out snapshot. Clearing our own container is not enough — the watch holds its own copy and would keep rendering the previous user's calendar. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
ad7a405f32
commit
b28c5a0502
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user