crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

PushPayload.swift (16810B)


      1 import Foundation
      2 
      3 /// Structured, app-defined semantics for a push, carried as an opaque
      4 /// base64-encoded JSON blob in the APNs `payload` userInfo field. Shared
      5 /// between the sender (the app) and the notification service extension; the
      6 /// push worker forwards it without inspecting it. Keeping the meaning here —
      7 /// not in the worker — is what lets notification behaviour change without a
      8 /// worker deploy.
      9 ///
     10 /// Decoding is deliberately tolerant. A newer build may send an `Event` this
     11 /// build doesn't recognise; it decodes to `.unknown` rather than throwing, so
     12 /// a mixed-version rollout never drops a notification. A missing or
     13 /// unparseable field (an older sender, or the worker not yet forwarding it)
     14 /// is handled by the caller falling back to the coarse top-level `kind`.
     15 struct PushPayload: Codable, Sendable, Equatable {
     16     /// Bumped only on a breaking shape change, so a future reader can gate
     17     /// behaviour. The current schema is version 1.
     18     static let currentVersion = 1
     19 
     20     var version: Int
     21     var event: Event
     22     /// The puzzle title the sender baked into the alert body. Carried as a
     23     /// structured field so the notification service extension can recompose
     24     /// the body from components — substituting the recipient's private
     25     /// nickname for the sender's name — instead of editing the sender's text.
     26     /// `nil` from older senders (and on bodyless pushes like replay), in which
     27     /// case the NSE leaves the original body untouched.
     28     var puzzleTitle: String?
     29     /// The sender's own chosen name, carried structurally so the notification
     30     /// service extension can name this sender in a *coalesced* multi-sender
     31     /// summary (see `CoalescedSummary`). The single-push nickname rewrite uses
     32     /// the receiver's private nickname instead and never reads this; it exists
     33     /// only as the fall-back display name when the receiver has set no nickname
     34     /// for the sender. `nil` from older senders, which the NSE handles by
     35     /// falling back to a short author id.
     36     var playerName: String?
     37     /// Optional, opaque-to-the-worker diagnostic context attached by the
     38     /// sender. Carries the inputs that produced a pause body's counts so a
     39     /// recipient can record them (via the NSE) and reconstruct *why* the
     40     /// numbers came out as they did, without having to reach the sender.
     41     /// All fields are optional and the whole block is omitted on non-pause
     42     /// pushes, so it never affects badge/visible behaviour.
     43     var diagnostics: Diagnostics?
     44 
     45     init(
     46         version: Int = PushPayload.currentVersion,
     47         event: Event,
     48         puzzleTitle: String? = nil,
     49         playerName: String? = nil,
     50         diagnostics: Diagnostics? = nil
     51     ) {
     52         self.version = version
     53         self.event = event
     54         self.puzzleTitle = puzzleTitle
     55         self.playerName = playerName
     56         self.diagnostics = diagnostics
     57     }
     58 
     59     /// A flat bag of sender-side measurements taken at the moment a pause
     60     /// push was built. Every field is optional: each layer (store, services,
     61     /// per-recipient planner) fills the part it knows, and a reader tolerates
     62     /// any subset. Kept small enough to ride inside the APNs payload budget.
     63     struct Diagnostics: Codable, Sendable, Equatable {
     64         /// The sender's wall clock when the pause was computed — surfaces
     65         /// clock skew against the recipient's own clock.
     66         var senderNow: Date? = nil
     67         /// When the sender believes the current solving session began. Now that
     68         /// the begin push (which used to stamp this) is gone, no sender
     69         /// populates it — it is always `nil` in practice and kept only so the
     70         /// receipt log keeps a stable slot should a session-start signal return.
     71         var sessionStart: Date? = nil
     72         /// The recipient's `Player.readAt` *as the sender saw it* — the exact
     73         /// cutoff the per-recipient diff used. A stale value here widens the
     74         /// counting window.
     75         var recipientReadAt: Date? = nil
     76         /// The grid geometry the sender currently holds for the puzzle.
     77         var gridWidth: Int? = nil
     78         var gridHeight: Int? = nil
     79         /// The sender's converter version stamp for the puzzle — a mismatch
     80         /// hints the two ends merged against different geometry.
     81         var cmVersion: Int? = nil
     82         /// Distinct positions in the sender's merged author Moves (the set the
     83         /// count path iterates).
     84         var mergedCells: Int? = nil
     85         /// Of `mergedCells`, how many fall inside the current grid bounds.
     86         var inBounds: Int? = nil
     87         /// Of `mergedCells`, how many land on a playable (non-block) square.
     88         var playable: Int? = nil
     89         /// Coordinate range observed across the merged cells — exposes
     90         /// out-of-grid or transposed coordinates.
     91         var minRow: Int? = nil
     92         var maxRow: Int? = nil
     93         var minCol: Int? = nil
     94         var maxCol: Int? = nil
     95         /// Distinct devices that contributed Moves for this author/game.
     96         var deviceCount: Int? = nil
     97         /// Oldest/newest `updatedAt` across the merged cells — the true edit
     98         /// window, independent of the session-start announcement.
     99         var earliestEdit: Date? = nil
    100         var latestEdit: Date? = nil
    101 
    102         /// Compact single-line rendering for the receipt log, mirroring the
    103         /// `key=value` style of the existing diagnostics events.
    104         var summaryLine: String {
    105             func iso(_ date: Date?) -> String {
    106                 guard let date else { return "—" }
    107                 let f = ISO8601DateFormatter()
    108                 f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
    109                 f.timeZone = TimeZone(secondsFromGMT: 0)
    110                 return f.string(from: date)
    111             }
    112             func int(_ value: Int?) -> String { value.map(String.init) ?? "—" }
    113             return "now=\(iso(senderNow))"
    114                 + " sessionStart=\(iso(sessionStart))"
    115                 + " recipientReadAt=\(iso(recipientReadAt))"
    116                 + " grid=\(int(gridWidth))x\(int(gridHeight))"
    117                 + " cm=\(int(cmVersion))"
    118                 + " merged=\(int(mergedCells))"
    119                 + " inBounds=\(int(inBounds))"
    120                 + " playable=\(int(playable))"
    121                 + " rows=[\(int(minRow))..\(int(maxRow))]"
    122                 + " cols=[\(int(minCol))..\(int(maxCol))]"
    123                 + " devices=\(int(deviceCount))"
    124                 + " edits=[\(iso(earliestEdit))..\(iso(latestEdit))]"
    125         }
    126     }
    127 
    128     enum Event: Sendable, Equatable {
    129         /// A session-end summary, broken down by what the peer did since the
    130         /// recipient last looked: net letter `fills` / `clears`, and the count
    131         /// of `checks` / `reveals` *gestures* run. Letter counts are
    132         /// net-per-cell (a typed-then-deleted cell nets to nothing) and never
    133         /// include reveal fills — those are owned by `reveals`. A check changes
    134         /// only marks, so it never touches the letter counts.
    135         case pause(fills: Int, clears: Int, checks: Int, reveals: Int)
    136         case win
    137         case resign
    138         case replay
    139         /// A manual "nudge" one player sends from the in-game players menu to
    140         /// rouse the others into the puzzle. Presence only — it carries no
    141         /// grid change — so it never marks the game unread.
    142         case nudge
    143         /// A player has accepted an invitation and joined the shared game,
    144         /// announced to everyone already in the room. Presence only — joining
    145         /// changes no grid cells — so it never marks the game unread.
    146         case join
    147         /// An event introduced by a newer build. Treated as carrying no
    148         /// unseen content for badge purposes.
    149         case unknown
    150 
    151         /// True when the event represents grid changes the recipient hasn't
    152         /// seen — the sole input to whether a delivered push marks its game
    153         /// unread (and so bumps the app-icon badge). Any of the four pause
    154         /// tallies counts: a reveal or even a check alters the shared grid the
    155         /// recipient will see on opening.
    156         var marksUnread: Bool {
    157             switch self {
    158             case .pause(let fills, let clears, let checks, let reveals):
    159                 return fills + clears + checks + reveals > 0
    160             case .win, .resign: return true
    161             case .replay, .nudge, .join, .unknown: return false
    162             }
    163         }
    164     }
    165 
    166     /// Whether a push carrying this payload should mark its game unread.
    167     var marksUnread: Bool { event.marksUnread }
    168 }
    169 
    170 extension PushPayload {
    171     /// The alert body for this event as `playerName` would read it, rebuilt
    172     /// from the structured fields. The sender composes its own body through the
    173     /// same `PuzzleNotificationText` builders, so passing the local user's name
    174     /// reproduces the shipped text; the notification service extension passes
    175     /// the recipient's private nickname to swap the name without touching the
    176     /// sender's string. Returns `nil` when the body can't be faithfully rebuilt
    177     /// — a bodyless event (replay/unknown) or an older sender that omitted
    178     /// `puzzleTitle` — leaving the original body in place.
    179     func composedBody(playerName: String) -> String? {
    180         guard let puzzleTitle else { return nil }
    181         switch event {
    182         case .pause(let fills, let clears, let checks, let reveals):
    183             return PuzzleNotificationText.pauseBody(
    184                 playerName: playerName,
    185                 puzzleTitle: puzzleTitle,
    186                 fills: fills,
    187                 clears: clears,
    188                 checks: checks,
    189                 reveals: reveals
    190             )
    191         case .win:
    192             return PuzzleNotificationText.completionBody(
    193                 playerName: playerName,
    194                 puzzleTitle: puzzleTitle,
    195                 resigned: false
    196             )
    197         case .resign:
    198             return PuzzleNotificationText.completionBody(
    199                 playerName: playerName,
    200                 puzzleTitle: puzzleTitle,
    201                 resigned: true
    202             )
    203         case .nudge:
    204             return PuzzleNotificationText.nudgeBody(
    205                 playerName: playerName,
    206                 puzzleTitle: puzzleTitle
    207             )
    208         case .join:
    209             return PuzzleNotificationText.joinBody(
    210                 playerName: playerName,
    211                 puzzleTitle: puzzleTitle
    212             )
    213         case .replay, .unknown:
    214             return nil
    215         }
    216     }
    217 
    218     /// Base64-encoded JSON for the per-addressee `payload` field on the wire.
    219     func encodedString() -> String? {
    220         guard let data = try? JSONEncoder().encode(self) else { return nil }
    221         return data.base64EncodedString()
    222     }
    223 
    224     /// Decodes the APNs `payload` userInfo field. Returns `nil` when the field
    225     /// is absent or unparseable, leaving the caller to fall back to `kind`.
    226     static func decode(from string: String?) -> PushPayload? {
    227         guard let string,
    228               let data = Data(base64Encoded: string),
    229               let payload = try? JSONDecoder().decode(PushPayload.self, from: data)
    230         else { return nil }
    231         return payload
    232     }
    233 }
    234 
    235 /// Running, per-sender tally the Notification Service Extension carries in a
    236 /// coalesced game tile's `userInfo`. When several session-end (`pause`) pushes
    237 /// for one game arrive in a row, they collapse to a single Notification Center
    238 /// tile (same `apns-collapse-id`); each replacement would otherwise overwrite
    239 /// the previous body. Stashing this accumulator in the delivered tile's
    240 /// `userInfo` — as base64 JSON, exactly like `PushPayload` — lets the next
    241 /// push read back what the tile already showed and *add* to it, since the
    242 /// extension's separate per-push process invocations share no other state.
    243 struct CoalescedSummary: Codable, Sendable, Equatable {
    244     struct Contributor: Codable, Sendable, Equatable {
    245         var authorID: String
    246         var name: String
    247         var fills: Int
    248         var clears: Int
    249         var checks: Int
    250         var reveals: Int
    251     }
    252 
    253     /// First-seen order, so the rendered summary lists players in the order
    254     /// their first update arrived rather than reshuffling on every push.
    255     var contributors: [Contributor]
    256 
    257     init(contributors: [Contributor] = []) {
    258         self.contributors = contributors
    259     }
    260 
    261     /// Folds one pause contribution into the tally: a sender already present
    262     /// has the new counts summed onto theirs; a new sender is appended. A
    263     /// later non-empty `name` refreshes the stored one (a rename, or a
    264     /// nickname the receiver only just learned), while an empty name never
    265     /// overwrites a real one.
    266     mutating func add(
    267         authorID: String,
    268         name: String,
    269         fills: Int,
    270         clears: Int,
    271         checks: Int,
    272         reveals: Int
    273     ) {
    274         if let index = contributors.firstIndex(where: { $0.authorID == authorID }) {
    275             contributors[index].fills += fills
    276             contributors[index].clears += clears
    277             contributors[index].checks += checks
    278             contributors[index].reveals += reveals
    279             if !name.isEmpty { contributors[index].name = name }
    280         } else {
    281             contributors.append(Contributor(
    282                 authorID: authorID,
    283                 name: name,
    284                 fills: fills,
    285                 clears: clears,
    286                 checks: checks,
    287                 reveals: reveals
    288             ))
    289         }
    290     }
    291 
    292     /// Base64-encoded JSON for the tile's `coalescedSummary` userInfo field.
    293     func encodedString() -> String? {
    294         guard let data = try? JSONEncoder().encode(self) else { return nil }
    295         return data.base64EncodedString()
    296     }
    297 
    298     /// Decodes the tile's `coalescedSummary` userInfo field. Returns `nil`
    299     /// when absent or unparseable, leaving the caller to seed a fresh tally.
    300     static func decode(from string: String?) -> CoalescedSummary? {
    301         guard let string,
    302               let data = Data(base64Encoded: string),
    303               let summary = try? JSONDecoder().decode(CoalescedSummary.self, from: data)
    304         else { return nil }
    305         return summary
    306     }
    307 }
    308 
    309 extension PushPayload.Event: Codable {
    310     private enum CodingKeys: String, CodingKey {
    311         case type, fills, clears, checks, reveals
    312     }
    313 
    314     private enum Discriminator: String {
    315         case pause, win, resign, replay, nudge, join
    316     }
    317 
    318     init(from decoder: Decoder) throws {
    319         let container = try decoder.container(keyedBy: CodingKeys.self)
    320         let raw = try container.decode(String.self, forKey: .type)
    321         switch Discriminator(rawValue: raw) {
    322         case .pause:
    323             let fills = try container.decodeIfPresent(Int.self, forKey: .fills) ?? 0
    324             let clears = try container.decodeIfPresent(Int.self, forKey: .clears) ?? 0
    325             let checks = try container.decodeIfPresent(Int.self, forKey: .checks) ?? 0
    326             let reveals = try container.decodeIfPresent(Int.self, forKey: .reveals) ?? 0
    327             self = .pause(fills: fills, clears: clears, checks: checks, reveals: reveals)
    328         case .win:
    329             self = .win
    330         case .resign:
    331             self = .resign
    332         case .replay:
    333             self = .replay
    334         case .nudge:
    335             self = .nudge
    336         case .join:
    337             self = .join
    338         case nil:
    339             // A discriminator this build doesn't know — a newer sender.
    340             self = .unknown
    341         }
    342     }
    343 
    344     func encode(to encoder: Encoder) throws {
    345         var container = encoder.container(keyedBy: CodingKeys.self)
    346         switch self {
    347         case .pause(let fills, let clears, let checks, let reveals):
    348             try container.encode(Discriminator.pause.rawValue, forKey: .type)
    349             try container.encode(fills, forKey: .fills)
    350             try container.encode(clears, forKey: .clears)
    351             try container.encode(checks, forKey: .checks)
    352             try container.encode(reveals, forKey: .reveals)
    353         case .win:
    354             try container.encode(Discriminator.win.rawValue, forKey: .type)
    355         case .resign:
    356             try container.encode(Discriminator.resign.rawValue, forKey: .type)
    357         case .replay:
    358             try container.encode(Discriminator.replay.rawValue, forKey: .type)
    359         case .nudge:
    360             try container.encode(Discriminator.nudge.rawValue, forKey: .type)
    361         case .join:
    362             try container.encode(Discriminator.join.rawValue, forKey: .type)
    363         case .unknown:
    364             // Not produced as an outgoing event by this build; encode a stable
    365             // marker so an `.unknown` round-trips back to `.unknown`.
    366             try container.encode("unknown", forKey: .type)
    367         }
    368     }
    369 }