crossmate

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

commit 8f0b4b96048707533c6bd3cfb343dd0f4a73754e
parent ecdf12a8168935f6f10e0fd4f1d682fec6ca0a84
Author: Michael Camilleri <[email protected]>
Date:   Tue,  2 Jun 2026 07:36:32 +0900

Add logging to push notifications

Diffstat:
MCrossmate/Persistence/GameStore.swift | 76++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MCrossmate/Services/AppServices.swift | 34+++++++++++++++++++++++++++++++++-
MCrossmate/Services/SessionPushPlanner.swift | 32++++++++++++++++++++++++--------
MNotificationService/NotificationService.swift | 10++++++++++
MShared/PushPayload.swift | 81++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
MTests/Unit/PushPayloadTests.swift | 58++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MTests/Unit/SessionPushPlannerTests.swift | 46++++++++++++++++++++++++++++++++++++++++++++++
7 files changed, 327 insertions(+), 10 deletions(-)

diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift @@ -1191,6 +1191,74 @@ final class GameStore { return GridStateMerger.mergeWithProvenance(values).values.map(\.cell) } + /// Sender-side measurements describing *why* the pause-push counts for + /// `(gameID, authorID)` came out as they did. Mirrors the set the count + /// path (`mergedAuthorCells`) iterates, then breaks it down against the + /// current grid: how many merged positions fall inside the bounds and on + /// playable squares, the coordinate range, the contributing device count, + /// and the edit window. Diagnostic-only; never affects badge or body. The + /// time-of-day and per-recipient fields are filled by the caller — only + /// the store-derived measurements are populated here. + func movesDiagnostics(for gameID: UUID, by authorID: String) -> PushPayload.Diagnostics? { + let gReq = NSFetchRequest<GameEntity>(entityName: "GameEntity") + gReq.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) + gReq.fetchLimit = 1 + guard let game = try? context.fetch(gReq).first else { return nil } + + let mReq = NSFetchRequest<MovesEntity>(entityName: "MovesEntity") + mReq.predicate = NSPredicate( + format: "game.id == %@ AND authorID == %@", + gameID as CVarArg, + authorID + ) + let entities = (try? context.fetch(mReq)) ?? [] + let values: [MovesValue] = entities.compactMap { Self.movesValue(from: $0) } + let merged = GridStateMerger.mergeWithProvenance(values) + + let width = Int(game.gridWidth) + let height = Int(game.gridHeight) + let puzzle = game.puzzleSource + .flatMap { try? XD.parse($0) } + .map(Puzzle.init(xd:)) + + var inBounds = 0 + var playable = 0 + var minRow = Int.max, maxRow = Int.min, minCol = Int.max, maxCol = Int.min + var earliest: Date? + var latest: Date? + for (position, provenance) in merged { + minRow = min(minRow, position.row); maxRow = max(maxRow, position.row) + minCol = min(minCol, position.col); maxCol = max(maxCol, position.col) + let updatedAt = provenance.cell.updatedAt + if earliest == nil || updatedAt < earliest! { earliest = updatedAt } + if latest == nil || updatedAt > latest! { latest = updatedAt } + let within = position.row >= 0 && position.row < height + && position.col >= 0 && position.col < width + guard within else { continue } + inBounds += 1 + if let puzzle, !puzzle.cells[position.row][position.col].isBlock { + playable += 1 + } + } + let deviceCount = Set(entities.compactMap { $0.deviceID }).count + + return PushPayload.Diagnostics( + gridWidth: width, + gridHeight: height, + cmVersion: Int(game.puzzleCmVersion), + mergedCells: merged.count, + inBounds: inBounds, + playable: playable, + minRow: merged.isEmpty ? nil : minRow, + maxRow: merged.isEmpty ? nil : maxRow, + minCol: merged.isEmpty ? nil : minCol, + maxCol: merged.isEmpty ? nil : maxCol, + deviceCount: deviceCount, + earliestEdit: earliest, + latestEdit: latest + ) + } + /// Every `(author, device)` that has written a `MovesEntity` for `gameID` — /// i.e. every device whose grid letters are present locally. Peers' and this /// account's *other* devices' Moves sync in as their own per-device rows, so @@ -1244,6 +1312,14 @@ final class GameStore { return fetchPlayerEntity(gameID: gameID, authorID: authorID)?.name ?? "" } + /// The read cursor persisted for `(gameID, authorID)`, or `nil` when no row + /// exists or none has been stamped. Used by the local pause-diagnostics + /// mirror to compare this device's actual cursor against the value a peer's + /// pushed diagnostics claim it saw. + func readAt(for gameID: UUID, by authorID: String) -> Date? { + return fetchPlayerEntity(gameID: gameID, authorID: authorID)?.readAt + } + private func fetchPlayerEntity(gameID: UUID, authorID: String) -> PlayerEntity? { let request = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity") request.predicate = NSPredicate( diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -924,6 +924,14 @@ final class AppServices { return } let mergedCells = store.mergedAuthorCells(for: gameID, by: localAuthorID) + // Sender-side diagnostics: store-derived measurements plus this + // device's clock and the session-start it announced. Rides the + // per-recipient payload (the planner stamps each recipient's readAt) + // so the receiver can log why the counts came out as they did. + var diagnostics = store.movesDiagnostics(for: gameID, by: localAuthorID) + ?? PushPayload.Diagnostics() + diagnostics.senderNow = Date() + diagnostics.sessionStart = sessionAnnouncements.beginTime(gameID) // Caught-up recipients are *not* dropped: a session end is a presence // signal worth delivering even with nothing unseen (see // `SessionPushPlanner.sessionEndAddressees`). @@ -931,7 +939,8 @@ final class AppServices { recipients: plan.recipients, mergedCells: mergedCells, playerName: preferences.name, - puzzleTitle: plan.title + puzzleTitle: plan.title, + diagnostics: diagnostics ) guard !addressees.isEmpty else { syncMonitor.note("push(pause): skipped (no addressable recipients)") @@ -2456,6 +2465,7 @@ final class AppServices { /// local notification that would otherwise have fired in a few /// minutes' time. No-op if nothing was accumulated. func handlePuzzleOpened(gameID: UUID) { + logLocalPauseDiagnostics(for: gameID) let summaries = sessionMonitor.consumeMovesSnapshots(for: gameID) guard !summaries.isEmpty else { return } let body = Self.formatSummaryBanner(summaries) @@ -2468,6 +2478,28 @@ final class AppServices { )) } + /// Logs this device's own view of each peer's Moves for `gameID`, using the + /// same `movesDiagnostics` computation the sender embeds in a pause push. + /// Pairs with the `pause-diagnostics` receipt the NSE records: a suspicious + /// pushed count can be diffed field-for-field against local ground truth. + /// Phantom cells that actually synced surface here too; ones that stayed + /// local to the sender (un-uploaded churn) won't — which is itself the + /// answer. `recipientReadAt` carries this device's *actual* cursor, to + /// compare against the value the peer's pushed diagnostics claimed it saw. + private func logLocalPauseDiagnostics(for gameID: UUID) { + let localAuthorID = identity.currentID + let selfReadAt = localAuthorID.flatMap { store.readAt(for: gameID, by: $0) } + for peerAuthorID in store.peerAuthorIDs(for: gameID, excluding: localAuthorID) { + guard var diagnostics = store.movesDiagnostics(for: gameID, by: peerAuthorID) + else { continue } + diagnostics.senderNow = Date() + diagnostics.recipientReadAt = selfReadAt + syncMonitor.note( + "local pause diag peer=\(peerAuthorID.prefix(8)): \(diagnostics.summaryLine)" + ) + } + } + nonisolated static func formatSummaryBanner(_ summaries: [SessionMonitor.SessionSummary]) -> String { guard !summaries.isEmpty else { return "" } let phrases: [String] = summaries.map { summary in diff --git a/Crossmate/Services/SessionPushPlanner.swift b/Crossmate/Services/SessionPushPlanner.swift @@ -14,22 +14,29 @@ import Foundation /// pause is actually published — not off a pending timer, so it stays accurate /// even when a stop carries no unseen content. struct SessionAnnouncementLog { - private var announced: Set<UUID> = [] + private var announced: [UUID: Date] = [:] /// True when a begin push should fire: this session isn't already announced. func shouldAnnounceBegin(_ gameID: UUID) -> Bool { - !announced.contains(gameID) + announced[gameID] == nil } - /// Record that a "play" push has been sent for `gameID`. - mutating func noteBeginAnnounced(_ gameID: UUID) { - announced.insert(gameID) + /// Record that a "play" push has been sent for `gameID`, stamping when the + /// session was announced so a later pause can report its start. + mutating func noteBeginAnnounced(_ gameID: UUID, at time: Date = Date()) { + announced[gameID] = time } /// Record that a "pause" push has been sent for `gameID`, re-arming the /// next begin push. mutating func noteEndAnnounced(_ gameID: UUID) { - announced.remove(gameID) + announced.removeValue(forKey: gameID) + } + + /// When the still-open session for `gameID` was announced, or `nil` when no + /// begin push is outstanding (e.g. solo play that never announced one). + func beginTime(_ gameID: UUID) -> Date? { + announced[gameID] } } @@ -44,7 +51,8 @@ enum SessionPushPlanner { recipients: [AppServices.PushRecipient], mergedCells: [TimestampedCell], playerName: String, - puzzleTitle: String + puzzleTitle: String, + diagnostics: PushPayload.Diagnostics? = nil ) -> [PushClient.Addressee] { recipients.compactMap { recipient -> PushClient.Addressee? in guard let address = recipient.pushAddress else { return nil } @@ -60,10 +68,18 @@ enum SessionPushPlanner { added: added, cleared: cleared ) + // Stamp the exact cutoff this recipient's diff used. `nil` readAt + // (no cursor yet) is preserved as nil — itself diagnostic, since it + // means the count covered the author's entire history. + var perRecipient = diagnostics + perRecipient?.recipientReadAt = recipient.readAt return PushClient.Addressee( address: address, body: body, - payload: PushPayload(event: .pause(added: added, cleared: cleared)) + payload: PushPayload( + event: .pause(added: added, cleared: cleared), + diagnostics: perRecipient + ) ) } } diff --git a/NotificationService/NotificationService.swift b/NotificationService/NotificationService.swift @@ -48,6 +48,16 @@ final class NotificationService: UNNotificationServiceExtension { body: bestAttemptContent.body, source: "notification-service-extension" ) + // When the sender attached pause diagnostics, record them as a second + // receipt so they surface in the app's diagnostics log alongside the + // visible body — the only channel we have to inspect a peer's + // sender-side counting inputs without reaching that peer's device. + if let diagnostics = payload?.diagnostics { + VisibleNotificationReceiptLog.record( + body: diagnostics.summaryLine, + source: "pause-diagnostics" + ) + } var updatedUserInfo = bestAttemptContent.userInfo updatedUserInfo["crossmateNSELogged"] = true bestAttemptContent.userInfo = updatedUserInfo diff --git a/Shared/PushPayload.swift b/Shared/PushPayload.swift @@ -19,10 +19,89 @@ struct PushPayload: Codable, Sendable, Equatable { var version: Int var event: Event + /// Optional, opaque-to-the-worker diagnostic context attached by the + /// sender. Carries the inputs that produced a pause body's counts so a + /// recipient can record them (via the NSE) and reconstruct *why* the + /// numbers came out as they did, without having to reach the sender. + /// All fields are optional and the whole block is omitted on non-pause + /// pushes, so it never affects badge/visible behaviour. + var diagnostics: Diagnostics? - init(version: Int = PushPayload.currentVersion, event: Event) { + init( + version: Int = PushPayload.currentVersion, + event: Event, + diagnostics: Diagnostics? = nil + ) { self.version = version self.event = event + self.diagnostics = diagnostics + } + + /// A flat bag of sender-side measurements taken at the moment a pause + /// push was built. Every field is optional: each layer (store, services, + /// per-recipient planner) fills the part it knows, and a reader tolerates + /// any subset. Kept small enough to ride inside the APNs payload budget. + struct Diagnostics: Codable, Sendable, Equatable { + /// The sender's wall clock when the pause was computed — surfaces + /// clock skew against the recipient's own clock. + var senderNow: Date? = nil + /// When the sender believes this play session began (the begin-push + /// timestamp). `nil` if the session was never announced (e.g. solo). + var sessionStart: Date? = nil + /// The recipient's `Player.readAt` *as the sender saw it* — the exact + /// cutoff the per-recipient diff used. A stale value here widens the + /// counting window. + var recipientReadAt: Date? = nil + /// The grid geometry the sender currently holds for the puzzle. + var gridWidth: Int? = nil + var gridHeight: Int? = nil + /// The sender's converter version stamp for the puzzle — a mismatch + /// hints the two ends merged against different geometry. + var cmVersion: Int? = nil + /// Distinct positions in the sender's merged author Moves (the set the + /// count path iterates). + var mergedCells: Int? = nil + /// Of `mergedCells`, how many fall inside the current grid bounds. + var inBounds: Int? = nil + /// Of `mergedCells`, how many land on a playable (non-block) square. + var playable: Int? = nil + /// Coordinate range observed across the merged cells — exposes + /// out-of-grid or transposed coordinates. + var minRow: Int? = nil + var maxRow: Int? = nil + var minCol: Int? = nil + var maxCol: Int? = nil + /// Distinct devices that contributed Moves for this author/game. + var deviceCount: Int? = nil + /// Oldest/newest `updatedAt` across the merged cells — the true edit + /// window, independent of the session-start announcement. + var earliestEdit: Date? = nil + var latestEdit: Date? = nil + + /// Compact single-line rendering for the receipt log, mirroring the + /// `key=value` style of the existing diagnostics events. + var summaryLine: String { + func iso(_ date: Date?) -> String { + guard let date else { return "—" } + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + f.timeZone = TimeZone(secondsFromGMT: 0) + return f.string(from: date) + } + func int(_ value: Int?) -> String { value.map(String.init) ?? "—" } + return "now=\(iso(senderNow))" + + " sessionStart=\(iso(sessionStart))" + + " recipientReadAt=\(iso(recipientReadAt))" + + " grid=\(int(gridWidth))x\(int(gridHeight))" + + " cm=\(int(cmVersion))" + + " merged=\(int(mergedCells))" + + " inBounds=\(int(inBounds))" + + " playable=\(int(playable))" + + " rows=[\(int(minRow))..\(int(maxRow))]" + + " cols=[\(int(minCol))..\(int(maxCol))]" + + " devices=\(int(deviceCount))" + + " edits=[\(iso(earliestEdit))..\(iso(latestEdit))]" + } } enum Event: Sendable, Equatable { diff --git a/Tests/Unit/PushPayloadTests.swift b/Tests/Unit/PushPayloadTests.swift @@ -44,6 +44,64 @@ struct PushPayloadTests { #expect(PushPayload.decode(from: Data("plain text".utf8).base64EncodedString()) == nil) } + @Test("Diagnostics round-trip through the wire encoding") + func diagnosticsRoundTrip() throws { + let diagnostics = PushPayload.Diagnostics( + senderNow: Date(timeIntervalSince1970: 2_000), + sessionStart: Date(timeIntervalSince1970: 1_500), + recipientReadAt: Date(timeIntervalSince1970: 1_000), + gridWidth: 15, + gridHeight: 15, + cmVersion: 3, + mergedCells: 200, + inBounds: 78, + playable: 78, + minRow: 0, + maxRow: 14, + minCol: 0, + maxCol: 14, + deviceCount: 2, + earliestEdit: Date(timeIntervalSince1970: 1_200), + latestEdit: Date(timeIntervalSince1970: 1_900) + ) + let payload = PushPayload( + event: .pause(added: 125, cleared: 75), + diagnostics: diagnostics + ) + + let decoded = try roundTrip(payload) + + #expect(decoded == payload) + #expect(decoded.diagnostics?.mergedCells == 200) + #expect(decoded.diagnostics?.recipientReadAt == Date(timeIntervalSince1970: 1_000)) + } + + @Test("A pause without diagnostics decodes with nil diagnostics") + func diagnosticsAbsenceTolerated() throws { + let json = #"{"version":1,"event":{"type":"pause","added":1,"cleared":0}}"# + let encoded = Data(json.utf8).base64EncodedString() + + let decoded = try #require(PushPayload.decode(from: encoded)) + + #expect(decoded.diagnostics == nil) + #expect(decoded.event == .pause(added: 1, cleared: 0)) + } + + @Test("Diagnostics summary renders values and dashes for absent fields") + func diagnosticsSummaryLine() { + let diagnostics = PushPayload.Diagnostics( + gridWidth: 15, + gridHeight: 15, + mergedCells: 200 + ) + + let line = diagnostics.summaryLine + + #expect(line.contains("grid=15x15")) + #expect(line.contains("merged=200")) + #expect(line.contains("recipientReadAt=—")) + } + @Test("Only unseen content marks a game unread") func marksUnreadMatrix() { #expect(PushPayload(event: .pause(added: 1, cleared: 0)).marksUnread) diff --git a/Tests/Unit/SessionPushPlannerTests.swift b/Tests/Unit/SessionPushPlannerTests.swift @@ -28,6 +28,19 @@ struct SessionAnnouncementLogTests { #expect(!log.shouldAnnounceBegin(a)) #expect(log.shouldAnnounceBegin(b)) } + + @Test("Begin time is recorded for the open session and cleared on stop") + func beginTimeTracked() { + var log = SessionAnnouncementLog() + let game = UUID() + let start = Date(timeIntervalSince1970: 5_000) + + #expect(log.beginTime(game) == nil) + log.noteBeginAnnounced(game, at: start) + #expect(log.beginTime(game) == start) + log.noteEndAnnounced(game) + #expect(log.beginTime(game) == nil) + } } @Suite("Session push planner") @@ -84,6 +97,39 @@ struct SessionPushPlannerTests { #expect(addressees[0].payload?.marksUnread == true) } + @Test("Diagnostics ride each recipient's payload, stamped with that recipient's readAt") + func diagnosticsStampedPerRecipient() { + let edit = Date(timeIntervalSince1970: 1_000) + let cells = [cell("X", at: edit)] + let aReadAt = edit.addingTimeInterval(-60) + let recipientA = recipient("addr-a", readAt: aReadAt) + let recipientB = recipient("addr-b", readAt: nil) + let base = PushPayload.Diagnostics( + gridWidth: 15, + gridHeight: 15, + mergedCells: 200 + ) + + let addressees = SessionPushPlanner.sessionEndAddressees( + recipients: [recipientA, recipientB], + mergedCells: cells, + playerName: "Bunny", + puzzleTitle: "Tuesday", + diagnostics: base + ) + + let byAddress = Dictionary(uniqueKeysWithValues: addressees.map { ($0.address, $0) }) + let diagnosticsA = byAddress["addr-a"]?.payload?.diagnostics + #expect(diagnosticsA?.recipientReadAt == aReadAt) + #expect(diagnosticsA?.gridWidth == 15) + #expect(diagnosticsA?.mergedCells == 200) + // A recipient with no cursor keeps recipientReadAt nil — itself + // diagnostic, since the count then covered the whole history. + let diagnosticsB = byAddress["addr-b"]?.payload?.diagnostics + #expect(diagnosticsB?.recipientReadAt == nil) + #expect(diagnosticsB?.gridWidth == 15) + } + @Test("Recipients without a push capability are dropped") func unaddressableDropped() { let edit = Date(timeIntervalSince1970: 1_000)