From 50446c8de5e86ec66752d2f576a8a977d93ca42b Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Fri, 11 Sep 2026 16:32:53 +0200 Subject: [PATCH] Add the WatchConnectivity payload format with schema refusal Co-Authored-By: Claude Opus 5 --- .../Transport/WatchTransportPayload.swift | 101 ++++++++++++++++++ .../WatchTransportTests.swift | 81 ++++++++++++++ 2 files changed, 182 insertions(+) create mode 100644 Sources/CalendarrCore/Transport/WatchTransportPayload.swift create mode 100644 Tests/CalendarrCoreTests/WatchTransportTests.swift diff --git a/Sources/CalendarrCore/Transport/WatchTransportPayload.swift b/Sources/CalendarrCore/Transport/WatchTransportPayload.swift new file mode 100644 index 0000000..a1be9a5 --- /dev/null +++ b/Sources/CalendarrCore/Transport/WatchTransportPayload.swift @@ -0,0 +1,101 @@ +import Foundation + +/// Dictionary keys used on the WatchConnectivity wire. Stable within a major +/// version, like the snapshot filenames. +public enum WatchTransportKey { + public static let schema = "schema" + public static let snapshot = "snapshot" + public static let calendars = "calendars" + public static let session = "session" +} + +public enum WatchTransportError: Error, Equatable, LocalizedError { + case missingSnapshot + case unsupportedSchema(found: Int, maxSupported: Int) + case malformed(String) + + public var errorDescription: String? { + switch self { + case .missingSnapshot: + return "Watch payload carried no snapshot." + case .unsupportedSchema(let found, let max): + return "Watch payload schema \(found) is newer than this build understands (\(max))." + case .malformed(let why): + return "Watch payload could not be decoded: \(why)" + } + } +} + +/// What the phone sends to the watch: a trimmed snapshot, the calendar list for +/// a filter UI, and the non-secret session facts so the watch can tell "signed +/// out" from "never received anything". +/// +/// The auth token is deliberately absent. The watch never talks to the server, +/// so there is nothing for it to authenticate with and nothing to leak. +public struct WatchTransportPayload: Sendable { + /// Bump only for a breaking change to the dictionary shape. + public static let currentSchema = 1 + /// Payloads claiming a higher schema are refused rather than mis-decoded. + public static let maxSupportedSchema = 1 + + public let snapshot: CalendarrSnapshot + public let calendars: [SnapshotCalendar] + public let session: SharedSession? + + public init(snapshot: CalendarrSnapshot, + calendars: [SnapshotCalendar], + session: SharedSession?) { + self.snapshot = snapshot + self.calendars = calendars + self.session = session + } + + private static func encoder() -> JSONEncoder { + let e = JSONEncoder(); e.dateEncodingStrategy = .iso8601; return e + } + + private static func decoder() -> JSONDecoder { + let d = JSONDecoder(); d.dateDecodingStrategy = .iso8601; return d + } + + /// Encode for `WCSession`. Values are `Data` and `Int` only, because + /// WatchConnectivity accepts property-list types and rejects everything + /// else at runtime rather than at compile time. + public func encoded() throws -> [String: Any] { + let e = Self.encoder() + var dict: [String: Any] = [ + WatchTransportKey.schema: Self.currentSchema, + WatchTransportKey.snapshot: try e.encode(snapshot), + WatchTransportKey.calendars: try e.encode(calendars), + ] + if let session { dict[WatchTransportKey.session] = try e.encode(session) } + return dict + } + + public static func decode(_ dict: [String: Any]) throws -> WatchTransportPayload { + let found = dict[WatchTransportKey.schema] as? Int ?? 1 + guard found <= maxSupportedSchema else { + throw WatchTransportError.unsupportedSchema(found: found, maxSupported: maxSupportedSchema) + } + guard let snapshotData = dict[WatchTransportKey.snapshot] as? Data else { + throw WatchTransportError.missingSnapshot + } + + let d = decoder() + let snapshot: CalendarrSnapshot + do { snapshot = try d.decode(CalendarrSnapshot.self, from: snapshotData) } + catch { throw WatchTransportError.malformed("snapshot: \(error)") } + + var calendars: [SnapshotCalendar] = [] + if let data = dict[WatchTransportKey.calendars] as? Data { + calendars = (try? d.decode([SnapshotCalendar].self, from: data)) ?? [] + } + + var session: SharedSession? + if let data = dict[WatchTransportKey.session] as? Data { + session = try? d.decode(SharedSession.self, from: data) + } + + return WatchTransportPayload(snapshot: snapshot, calendars: calendars, session: session) + } +} diff --git a/Tests/CalendarrCoreTests/WatchTransportTests.swift b/Tests/CalendarrCoreTests/WatchTransportTests.swift new file mode 100644 index 0000000..af0c86f --- /dev/null +++ b/Tests/CalendarrCoreTests/WatchTransportTests.swift @@ -0,0 +1,81 @@ +import Foundation +import Testing +@testable import CalendarrCore + +/// The phone and the watch ship on independent schedules, so the transport gets +/// the same treatment as the file format: refuse a newer schema rather than +/// mis-decode it. +struct WatchTransportTests { + + private static let now = Date(timeIntervalSince1970: 1_789_000_000) + + private static func snapshot() -> CalendarrSnapshot { + CalendarrSnapshot(writtenAt: now, + coverageStart: now.addingTimeInterval(-86400), + coverageEnd: now.addingTimeInterval(14 * 86400), + isLoggedIn: true, writerVersion: "1.2", + events: [SnapshotEvent(id: "a", title: "Zahnarzt", + start: now.addingTimeInterval(3600), + end: now.addingTimeInterval(7200), + isAllDay: false, colorHex: "#4285f4", + location: "Bern", calendarKey: "local:3")], + theme: SnapshotTheme(today: "#1", text: "#2", background: "#3", + line: "#4", primary: "#5", accent: "#6"), + language: "de") + } + + @Test("A payload survives a round trip") + func roundTrips() throws { + let payload = WatchTransportPayload( + snapshot: Self.snapshot(), + calendars: [SnapshotCalendar(id: "local:3", name: "Privat", colorHex: "#4285f4")], + session: SharedSession(baseURL: "https://cal.example.com", username: "guido", + isLoggedIn: true, writtenAt: Self.now)) + + let decoded = try WatchTransportPayload.decode(try payload.encoded()) + + #expect(decoded.snapshot.events.map(\.id) == ["a"]) + #expect(decoded.snapshot.language == "de") + #expect(decoded.calendars.map(\.name) == ["Privat"]) + #expect(decoded.session?.username == "guido") + } + + @Test("A payload with no session record still decodes") + func sessionIsOptional() throws { + let payload = WatchTransportPayload(snapshot: Self.snapshot(), calendars: [], session: nil) + let decoded = try WatchTransportPayload.decode(try payload.encoded()) + + #expect(decoded.session == nil) + #expect(decoded.calendars.isEmpty) + } + + @Test("The encoded form only contains property-list types") + func encodesToPlistTypes() throws { + let dict = try WatchTransportPayload(snapshot: Self.snapshot(), calendars: [], session: nil).encoded() + + #expect(dict[WatchTransportKey.schema] is Int) + #expect(dict[WatchTransportKey.snapshot] is Data) + #expect(PropertyListSerialization.propertyList(dict, isValidFor: .binary)) + } + + @Test("A newer schema is refused rather than guessed at") + func refusesNewerSchema() throws { + var dict = try WatchTransportPayload(snapshot: Self.snapshot(), calendars: [], session: nil).encoded() + dict[WatchTransportKey.schema] = WatchTransportPayload.maxSupportedSchema + 1 + + #expect(throws: WatchTransportError.unsupportedSchema( + found: WatchTransportPayload.maxSupportedSchema + 1, + maxSupported: WatchTransportPayload.maxSupportedSchema)) { + try WatchTransportPayload.decode(dict) + } + } + + @Test("A payload without a snapshot is rejected") + func rejectsMissingSnapshot() { + let dict: [String: Any] = [WatchTransportKey.schema: WatchTransportPayload.currentSchema] + + #expect(throws: WatchTransportError.missingSnapshot) { + try WatchTransportPayload.decode(dict) + } + } +}