NotificationService.swift (14475B)
1 @preconcurrency import UserNotifications 2 3 /// Notification Service Extension. Runs in its own process when an APNs alert 4 /// arrives with `mutable-content: 1`, with ~30s to mutate the content before 5 /// iOS displays it. 6 /// 7 /// Crossmate uses the NSE for one job only: keep the app-icon badge close to 8 /// accurate when push notifications land while the main app is suspended or 9 /// terminated. The push-side badge model is a per-game horizon ledger in App 10 /// Group UserDefaults (`BadgeState`): pushes advance `unreadAt`, while the app 11 /// advances `seenAt` when the user opens the puzzle. Once the main app runs it 12 /// unions this provisional push ledger with Core Data ground truth and 13 /// re-stamps the badge. 14 /// 15 /// Whether a push marks its game unread is decided from the per-recipient 16 /// `PushPayload` the sender encodes (forwarded opaquely by the worker): 17 /// - a `pause` with unseen cells, or a `win` / `resign` — mark `gameID` 18 /// unread. Per-game horizon semantics make repeats idempotent (a pause 19 /// followed by a win for the same game is one badge unit, not two). 20 /// - a `nudge` (a manual "come play" ping), a legacy `play`, or a `pause` 21 /// with zero counts — presence only; the grid has nothing unseen for this 22 /// recipient, so stamp the current count without growing it. 23 /// When the payload is absent (an older sender, or the worker not yet 24 /// forwarding it) we fall back to the coarse top-level `kind`. 25 final class NotificationService: UNNotificationServiceExtension { 26 27 private var contentHandler: (@Sendable (UNNotificationContent) -> Void)? 28 private var bestAttemptContent: UNMutableNotificationContent? 29 30 override func didReceive( 31 _ request: UNNotificationRequest, 32 withContentHandler contentHandler: @escaping @Sendable (UNNotificationContent) -> Void 33 ) { 34 self.contentHandler = contentHandler 35 self.bestAttemptContent = request.content.mutableCopy() as? UNMutableNotificationContent 36 37 guard let bestAttemptContent else { 38 contentHandler(request.content) 39 return 40 } 41 42 let userInfo = request.content.userInfo 43 let kind = userInfo["kind"] as? String 44 let gameID = (userInfo["gameID"] as? String).flatMap(UUID.init(uuidString:)) 45 46 // Resolve the structured payload. Current senders ship it encrypted 47 // (`enc`) under the game's content key, which the app mirrors into the 48 // App Group keyed by gameID; decrypt it here. Fall back to the legacy 49 // cleartext `payload` an older sender may still send. When `enc` is 50 // present but no key has synced yet (a just-joined participant), this is 51 // nil and the generic cleartext body stands. 52 let encrypted = userInfo["enc"] as? String 53 let payload: PushPayload? = { 54 if let encrypted, let gameID, 55 let key = ContentKeyDirectory.key(for: gameID), 56 let opened = PushPayloadCipher.open(encrypted, key: key) { 57 return opened 58 } 59 if let encrypted, 60 kind == "invite", 61 let fromAuthorID = (userInfo["fromAuthorID"] as? String).flatMap({ $0.isEmpty ? nil : $0 }), 62 let key = FriendEncryptionKeyDirectory.key(for: fromAuthorID), 63 let opened = PushPayloadCipher.open(encrypted, key: key) { 64 return opened 65 } 66 return PushPayload.decode(from: userInfo["payload"] as? String) 67 }() 68 69 // The wire body is now a generic placeholder ("New activity in one of 70 // your puzzles") — the real wording never leaves the sender in cleartext. 71 // Recompose it here from the (decrypted) structured fields: 72 // `PushPayload.composedBody` runs the same builders the sender used, 73 // substituting the recipient's private nickname for the sender when one 74 // is set (mirrored authorID → nickname into the App Group, with 75 // `fromAuthorID` identifying the sender), otherwise the sender's own 76 // `playerName` carried in the payload. Recomposing from components means 77 // a friend's later rename can never desync the result. 78 // Record *why* the rewrite did or didn't happen as a diagnostics 79 // receipt — a silent no-op otherwise hides several distinct causes (a 80 // bodyless background event, an `enc` we couldn't decrypt, or an older 81 // sender with no structured fields). With it, the next occurrence names 82 // the cause. 83 let fromAuthorID = (userInfo["fromAuthorID"] as? String) 84 .flatMap { $0.isEmpty ? nil : $0 } 85 let nickname = fromAuthorID.flatMap { NicknameDirectory.entry(for: $0)?.nickname } 86 let fromPrefix = fromAuthorID.map { String($0.prefix(8)) } ?? "nil" 87 let rewriteOutcome: String 88 if bestAttemptContent.body.isEmpty { 89 rewriteOutcome = "skipped=empty-body" 90 } else if let payload, 91 let rebuilt = payload.composedBody(playerName: nickname ?? payload.playerName ?? "") { 92 bestAttemptContent.body = rebuilt 93 rewriteOutcome = "applied via=\(nickname != nil ? "nickname" : "payload-name") from=\(fromPrefix)" 94 } else if payload == nil { 95 rewriteOutcome = encrypted != nil ? "skipped=undecryptable from=\(fromPrefix)" : "skipped=no-payload from=\(fromPrefix)" 96 } else { 97 rewriteOutcome = "skipped=not-composable from=\(fromPrefix)" 98 } 99 VisibleNotificationReceiptLog.record( 100 body: rewriteOutcome, 101 source: "nickname-rewrite" 102 ) 103 104 VisibleNotificationReceiptLog.record( 105 body: bestAttemptContent.body, 106 source: "notification-service-extension" 107 ) 108 // When the sender attached pause diagnostics, record them as a second 109 // receipt so they surface in the app's diagnostics log alongside the 110 // visible body — the only channel we have to inspect a peer's 111 // sender-side counting inputs without reaching that peer's device. 112 if let diagnostics = payload?.diagnostics { 113 VisibleNotificationReceiptLog.record( 114 body: diagnostics.summaryLine, 115 source: "pause-diagnostics" 116 ) 117 } 118 var updatedUserInfo = bestAttemptContent.userInfo 119 updatedUserInfo["crossmateNSELogged"] = true 120 bestAttemptContent.userInfo = updatedUserInfo 121 122 // Whether this push represents grid changes the recipient hasn't seen. 123 // Prefer the structured payload; fall back to the coarse `kind` when it 124 // is absent — an older sender, or the worker not yet forwarding it. 125 let marksUnread: Bool 126 if let payload { 127 marksUnread = payload.marksUnread 128 } else { 129 marksUnread = kind == "pause" || kind == "win" || kind == "resign" 130 } 131 132 if let gameID, marksUnread { 133 // Durable completion delivery can be retried (and APNs can deliver 134 // it) well after the puzzle finished. Preserve the event's real 135 // horizon so a recipient who already saw the completion has a 136 // newer seen watermark and does not get a phantom app badge. 137 BadgeState.markUnread(gameID: gameID, at: payload?.occurredAt ?? Date()) 138 } 139 // While another device of this account is present in the game, deliver 140 // passively: no banner, no sound — the alert drops quietly into 141 // Notification Center, where the present device's read-cursor sync will 142 // sweep it shortly. The full no-show path (`willPresent` → `[]`) only 143 // runs on a foreground app; on an idle sibling the NSE is the only code 144 // that runs, and `.passive` is as quiet as it can make an alert push. 145 let deliveredPassively = gameID.map { BadgeState.isSuppressed(gameID: $0) } ?? false 146 if deliveredPassively { 147 bestAttemptContent.interruptionLevel = .passive 148 } 149 // Fold in pending invites the app published to the App Group: a moves 150 // push must not re-stamp the badge to the moves-only count and drop a 151 // still-pending invite. The two sets are disjoint, so the union is exact. 152 let count = BadgeState.unreadGameIDs() 153 .union(BadgeState.pendingInviteGameIDs()).count 154 bestAttemptContent.badge = NSNumber(value: count) 155 VisibleNotificationReceiptLog.record( 156 body: [ 157 "game=\(gameID.map { String($0.uuidString.prefix(8)) } ?? "nil")", 158 "kind=\(kind ?? "nil")", 159 "payload=\(payload == nil ? "absent" : "present")", 160 "marksUnread=\(marksUnread)", 161 "passive=\(deliveredPassively)", 162 "ledgerUnread=\(BadgeState.unreadGameIDs().count)", 163 "pendingInvites=\(BadgeState.pendingInviteGameIDs().count)", 164 "stampedBadge=\(count)" 165 ].joined(separator: " "), 166 source: "notification-service-extension-badge" 167 ) 168 169 // Coalesce successive session-end summaries for one game into the 170 // single Notification Center tile they share (same apns-collapse-id). 171 // Only a `pause` carrying structured counts can be folded; everything 172 // else (and an older payload-less sender) is delivered as prepared. 173 let pauseCounts: (fills: Int, clears: Int, checks: Int, reveals: Int)? 174 if case let .pause(fills, clears, checks, reveals) = payload?.event { 175 pauseCounts = (fills, clears, checks, reveals) 176 } else { 177 pauseCounts = nil 178 } 179 let center = UNUserNotificationCenter.current() 180 center.getDeliveredNotifications { delivered in 181 Self.finalize( 182 content: bestAttemptContent, 183 gameID: gameID, 184 fromAuthorID: fromAuthorID, 185 puzzleTitle: payload?.puzzleTitle, 186 playerName: payload?.playerName, 187 pauseCounts: pauseCounts, 188 delivered: delivered, 189 center: center, 190 contentHandler: contentHandler 191 ) 192 } 193 } 194 195 /// Applies game-tile coalescing once the already-delivered notifications 196 /// are known, then hands the (possibly rewritten) content back to iOS. 197 /// 198 /// A `pause` is low-stakes presence chatter, so it is always delivered 199 /// `.passive` — it updates the tile and the app badge without a sound or a 200 /// banner-wake. When a tile for this game is already showing, this is a 201 /// follow-up session-end push: its counts are folded into the running 202 /// per-sender tally the tile carries in `userInfo` and the body is 203 /// rewritten to the combined summary. The first push for a game finds no 204 /// tile and merely seeds the tally. A non-pause push (or an older 205 /// payload-less sender) carries no counts and is delivered unchanged; its 206 /// shared collapse id still replaces any tile in place. 207 private static func finalize( 208 content: UNMutableNotificationContent, 209 gameID: UUID?, 210 fromAuthorID: String?, 211 puzzleTitle: String?, 212 playerName: String?, 213 pauseCounts: (fills: Int, clears: Int, checks: Int, reveals: Int)?, 214 delivered: [UNNotification], 215 center: UNUserNotificationCenter, 216 contentHandler: @escaping @Sendable (UNNotificationContent) -> Void 217 ) { 218 guard let gameID, let pauseCounts else { 219 contentHandler(content) 220 return 221 } 222 223 let existing = delivered.filter { 224 ($0.request.content.userInfo["gameID"] as? String) == gameID.uuidString 225 } 226 // Seed from whichever existing tile already carries a tally (the most 227 // recent wins), else start fresh so this push still records itself. 228 var summary = existing 229 .compactMap { 230 CoalescedSummary.decode(from: $0.request.content.userInfo["coalescedSummary"] as? String) 231 } 232 .last ?? CoalescedSummary() 233 // Prefer the receiver's private nickname, fall back to the sender's own 234 // name from the payload, then to a short author id — so a contributor 235 // is always named without parsing the rendered body. 236 let authorID = fromAuthorID.flatMap { $0.isEmpty ? nil : $0 } 237 let name = authorID.flatMap { NicknameDirectory.entry(for: $0)?.nickname } 238 ?? playerName.flatMap { $0.isEmpty ? nil : $0 } 239 ?? authorID.map { String($0.prefix(8)) } 240 ?? "" 241 summary.add( 242 authorID: authorID ?? "?", 243 name: name, 244 fills: pauseCounts.fills, 245 clears: pauseCounts.clears, 246 checks: pauseCounts.checks, 247 reveals: pauseCounts.reveals 248 ) 249 var userInfo = content.userInfo 250 if let encoded = summary.encodedString() { 251 userInfo["coalescedSummary"] = encoded 252 } 253 content.userInfo = userInfo 254 255 // Always deliver a pause quietly — no sound, no banner-wake — leaving 256 // the badge as the only visible signal. Idempotent with the 257 // present-sibling suppression path above. 258 content.interruptionLevel = .passive 259 let coalescing = !existing.isEmpty 260 if coalescing { 261 if let body = PuzzleNotificationText.coalescedBody( 262 puzzleTitle: puzzleTitle ?? "", 263 contributors: summary.contributors 264 ) { 265 content.body = body 266 } 267 // Drop the superseded tiles so only this freshest one remains; the 268 // shared collapse id already replaces a same-id tile, but this also 269 // clears any pre-collapse-id leftovers the system won't merge. 270 let identifiers = existing.map { $0.request.identifier } 271 if !identifiers.isEmpty { 272 center.removeDeliveredNotifications(withIdentifiers: identifiers) 273 } 274 } 275 VisibleNotificationReceiptLog.record( 276 body: "coalesced=\(coalescing) contributors=\(summary.contributors.count) passive=true", 277 source: "notification-service-extension-coalesce" 278 ) 279 contentHandler(content) 280 } 281 282 override func serviceExtensionTimeWillExpire() { 283 if let contentHandler, let bestAttemptContent { 284 contentHandler(bestAttemptContent) 285 } 286 } 287 }