crossmate

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

PushPayload.swift (17988B)


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