crossmate

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

commit 9ab07c1abed00fc9f31880eb7cf8683bdcc5f964
parent f2bb8d4bb07f3c7d279b83a22969bafb182ef6a4
Author: Michael Camilleri <[email protected]>
Date:   Wed, 29 Apr 2026 02:37:31 +0900

Add more logging for shared games

Diffstat:
MCrossmate/CrossmateApp.swift | 5+++--
MCrossmate/Services/AppServices.swift | 51++++++++++++++++++++++++++++++++++++++++++++-------
MCrossmate/Sync/SyncEngine.swift | 35++++++++++++++++++++++++++++++-----
3 files changed, 77 insertions(+), 14 deletions(-)

diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift @@ -31,7 +31,7 @@ struct CrossmateApp: App { // MARK: - App Delegate final class AppDelegate: NSObject, UIApplicationDelegate, @unchecked Sendable { - var onRemoteNotification: (() async -> Void)? + var onRemoteNotification: ((String) async -> Void)? func application( _ application: UIApplication, @@ -45,7 +45,8 @@ final class AppDelegate: NSObject, UIApplicationDelegate, @unchecked Sendable { _ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any] ) async -> UIBackgroundFetchResult { - await onRemoteNotification?() + let summary = AppServices.describePush(userInfo: userInfo) + await onRemoteNotification?(summary) return .newData } diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -88,8 +88,8 @@ final class AppServices { nytAuth.loadStoredSession() driveMonitor.start() - appDelegate.onRemoteNotification = { - await self.handleRemoteNotification() + appDelegate.onRemoteNotification = { summary in + await self.handleRemoteNotification(summary: summary) } CloudShareAcceptanceBroker.shared.onAcceptShare = { metadata in await self.enqueueShareAcceptance(metadata) @@ -139,7 +139,7 @@ final class AppServices { await processPendingShareAcceptances() await syncMonitor.run("initial fetch") { - try await syncEngine.fetchChanges() + try await syncEngine.fetchChanges(source: "initial") } await syncMonitor.run("initial push") { try await syncEngine.pushChanges() @@ -158,7 +158,7 @@ final class AppServices { func syncOnForeground() async { await moveBuffer.flush() await syncMonitor.run("foreground fetch") { - try await syncEngine.fetchChanges() + try await syncEngine.fetchChanges(source: "foreground") } await syncMonitor.run("foreground push") { try await syncEngine.pushChanges() @@ -173,7 +173,7 @@ final class AppServices { func syncOpenSharedPuzzle() async { await moveBuffer.flush() await syncMonitor.run("open-puzzle fetch") { - try await syncEngine.fetchChanges() + try await syncEngine.fetchChanges(source: "open-puzzle poll") } await refreshSnapshot() } @@ -189,12 +189,49 @@ final class AppServices { ) } - private func handleRemoteNotification() async { + private func handleRemoteNotification(summary: String) async { + syncMonitor.note("remote notification: \(summary)") await syncMonitor.run("remote-notification fetch") { - try await syncEngine.fetchChanges() + try await syncEngine.fetchChanges(source: "push") } } + /// Parses the silent-push payload into a short, human-readable summary + /// (database scope, notification type, subscription ID, pruned flag). + /// Used by the diagnostics log to confirm whether shared-DB pushes are + /// actually being delivered to the device. + static func describePush(userInfo: [AnyHashable: Any]) -> String { + guard let note = CKNotification(fromRemoteNotificationDictionary: userInfo) else { + return "unparseable userInfo=\(userInfo)" + } + let kind: String + let scope: CKDatabase.Scope? + switch note { + case let n as CKDatabaseNotification: + kind = "database" + scope = n.databaseScope + case let n as CKRecordZoneNotification: + kind = "recordZone" + scope = n.databaseScope + case let n as CKQueryNotification: + kind = "query(\(n.queryNotificationReason.rawValue))" + scope = n.databaseScope + default: + kind = "type(\(note.notificationType.rawValue))" + scope = nil + } + let scopeLabel: String + switch scope { + case .private: scopeLabel = "private" + case .shared: scopeLabel = "shared" + case .public: scopeLabel = "public" + case .none: scopeLabel = "n/a" + case .some(let other): scopeLabel = "scope(\(other.rawValue))" + } + let sub = note.subscriptionID ?? "<nil>" + return "scope=\(scopeLabel) kind=\(kind) sub=\(sub) pruned=\(note.isPruned)" + } + private func processPendingShareAcceptances() async { guard isReadyForShareAcceptance, !isProcessingShareAcceptanceQueue else { return } isProcessingShareAcceptanceQueue = true diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift @@ -35,6 +35,15 @@ actor SyncEngine { private var privateEngine: CKSyncEngine? private var sharedEngine: CKSyncEngine? + /// Label for the in-flight fetch — surfaced in traces so the diagnostics + /// log can distinguish push-driven fetches from polls / foreground / etc. + /// `nil` means CKSyncEngine drove the fetch itself (its internal scheduler). + private var currentFetchSource: String? + /// One-shot flag — set the first time we observe shared-DB content + /// arriving via a push-triggered fetch. Confirms the silent-push path is + /// actually wired up end-to-end. + private var loggedFirstSharedPushPayload = false + private var onRemoteMoves: (@MainActor @Sendable ([Move]) async -> Void)? private var onAccountChange: (@MainActor @Sendable () async -> Void)? private var onGameAccessRevoked: (@MainActor @Sendable (UUID) async -> Void)? @@ -179,7 +188,9 @@ actor SyncEngine { // MARK: - Explicit sync triggers (called by AppServices / diagnostics view) - func fetchChanges() async throws { + func fetchChanges(source: String = "manual") async throws { + currentFetchSource = source + defer { currentFetchSource = nil } async let p: Void = privateEngine?.fetchChanges() ?? () async let s: Void = sharedEngine?.fetchChanges() ?? () _ = try await (p, s) @@ -649,8 +660,9 @@ actor SyncEngine { _ event: CKSyncEngine.Event.FetchedDatabaseChanges, isPrivate: Bool ) async { + let src = currentFetchSource ?? "framework" await trace( - "\(isPrivate ? "private" : "shared") db changes: " + + "\(isPrivate ? "private" : "shared") db changes [src=\(src)]: " + "\(event.modifications.count) zone mods, \(event.deletions.count) zone deletions" ) @@ -708,10 +720,16 @@ actor SyncEngine { isPrivate: Bool ) async { let scope: Int16 = isPrivate ? 0 : 1 + let src = currentFetchSource ?? "framework" await trace( - "\(isPrivate ? "private" : "shared") fetch: " + + "\(isPrivate ? "private" : "shared") fetch [src=\(src)]: " + "\(event.modifications.count) modifications, \(event.deletions.count) deletions" ) + if !isPrivate, !loggedFirstSharedPushPayload, src == "push", + event.modifications.count + event.deletions.count > 0 { + loggedFirstSharedPushPayload = true + await trace("✅ first shared-DB push payload received — silent-push path is live") + } let ctx = persistence.container.newBackgroundContext() let (newMoves, affectedGameIDs): ([Move], Set<UUID>) = ctx.performAndWait { @@ -805,8 +823,15 @@ actor SyncEngine { } private func handleSentRecordZoneChanges( - _ event: CKSyncEngine.Event.SentRecordZoneChanges + _ event: CKSyncEngine.Event.SentRecordZoneChanges, + isPrivate: Bool ) async { + await trace( + "\(isPrivate ? "private" : "shared") sent: " + + "\(event.savedRecords.count) saved, " + + "\(event.failedRecordSaves.count) failed, " + + "\(event.deletedRecordIDs.count) deleted" + ) let ctx = persistence.container.newBackgroundContext() let (savedSnapshotNames, failureMessages): ([String], [String]) = ctx.performAndWait { var snapshotNames: [String] = [] @@ -907,7 +932,7 @@ extension SyncEngine: CKSyncEngineDelegate { break case .sentRecordZoneChanges(let e): - await handleSentRecordZoneChanges(e) + await handleSentRecordZoneChanges(e, isPrivate: isPrivate) case .willFetchChanges, .didFetchChanges, .willFetchRecordZoneChanges, .didFetchRecordZoneChanges,