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 }