commit 6ecdf25204cf657953f53fd4bd87a7c039ad4057
parent 3af6dc76e6e28c64f20ca62236af95f467e80878
Author: Michael Camilleri <[email protected]>
Date: Fri, 5 Jun 2026 22:39:50 +0900
Add logging for badges on launch
Diffstat:
1 file changed, 65 insertions(+), 0 deletions(-)
diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift
@@ -355,6 +355,7 @@ final class AppServices {
}
await refreshAppBadge()
importVisibleNotificationReceipts()
+ await logNotificationStartupSnapshot()
appDelegate.onRemoteNotification = {
summary, scope, event, gameID, kind, senderDeviceID, readAt, isBackground in
@@ -3043,6 +3044,70 @@ final class AppServices {
}
}
+ /// One startup-only snapshot for badge debugging. Kept out of
+ /// `refreshAppBadge` so normal notification and sync churn does not flood
+ /// diagnostics.
+ private func logNotificationStartupSnapshot() async {
+ let delivered = await UNUserNotificationCenter.current().deliveredNotifications()
+ let ledgerUnread = BadgeState.unreadGameIDs()
+ let coreDataUnread = store.unreadOtherMovesGameTimes()
+ let merged = ledgerUnread.union(coreDataUnread.keys)
+ syncMonitor.note(
+ "notif startup: delivered=\(delivered.count) "
+ + "badgeLedger=\(ledgerUnread.count) [\(shortIDs(ledgerUnread))] "
+ + "coreDataUnread=\(coreDataUnread.count) [\(shortIDs(coreDataUnread.keys))] "
+ + "merged=\(merged.count) [\(shortIDs(merged))]"
+ )
+ for notification in delivered.sorted(by: { $0.date < $1.date }) {
+ syncMonitor.note("notif delivered: \(notificationSummary(notification))")
+ }
+ }
+
+ private func notificationSummary(_ notification: UNNotification) -> String {
+ let request = notification.request
+ let content = request.content
+ let userInfo = content.userInfo
+ let gameID = notificationGameID(from: userInfo)
+ let kind = userInfo["kind"] as? String
+ let pingKind = userInfo["pingKind"] as? String
+ let nseLogged = (userInfo["crossmateNSELogged"] as? Bool) == true
+ return "id=\(request.identifier)"
+ + " date=\(notification.date.ISO8601Format())"
+ + " game=\(gameID.map { shortID($0) } ?? "nil")"
+ + " kind=\(kind ?? "nil")"
+ + " pingKind=\(pingKind ?? "nil")"
+ + " badge=\(content.badge?.stringValue ?? "nil")"
+ + " nseLogged=\(nseLogged)"
+ + " title=\"\(logEscaped(content.title))\""
+ + " body=\"\(logEscaped(content.body))\""
+ }
+
+ private func notificationGameID(from userInfo: [AnyHashable: Any]) -> UUID? {
+ if let raw = userInfo["gameID"] as? String,
+ let id = UUID(uuidString: raw) {
+ return id
+ }
+ guard let ck = userInfo["ck"] as? [AnyHashable: Any],
+ let qry = ck["qry"] as? [AnyHashable: Any],
+ let zoneName = qry["zid"] as? String,
+ zoneName.hasPrefix("game-")
+ else { return nil }
+ return UUID(uuidString: String(zoneName.dropFirst("game-".count)))
+ }
+
+ private func shortIDs<S: Sequence>(_ ids: S) -> String where S.Element == UUID {
+ ids.map(shortID).sorted().joined(separator: ",")
+ }
+
+ private func shortID(_ id: UUID) -> String {
+ String(id.uuidString.prefix(8))
+ }
+
+ private func logEscaped(_ text: String) -> String {
+ text.replacingOccurrences(of: "\n", with: " ")
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ }
+
/// Assembled replay timelines, keyed by game. A finished game's journals are
/// frozen (edit-lockout), so its timeline never changes once built — caching
/// it here lets a `ReplayController` recreated by rapid finish-banner nav