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 }