crossmate

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

commit c2291d7736b64aa21bc86c7994643154b023e364
parent 24dc9973eb1a89dda0b05906f4c6a95e9ceea519
Author: Michael Camilleri <[email protected]>
Date:   Sun, 19 Jul 2026 10:12:48 +0900

Buffer APNs callbacks that arrive before the handlers install

The app delegate starts APNs registration in didFinishLaunching, but its
token, registration-result, and remote-notification callbacks only
called optional closures that AppServices.start assigns — and start runs
from the root view's asynchronous startup task. A callback that beat
that task was silently discarded: a token lost this way left the install
unable to register any game or account push address for the whole
session (PushClient holds the token only in memory), a dropped
registration failure never reached the diagnostics log, and an early
silent push was reported to iOS as new data without doing its work.

This commit gives the delegate the same cold-launch handoff the
notification-navigation and share-acceptance brokers already provide.
The most recent token and registration result are retained and replayed
when their handlers are assigned; pre-start remote notifications are
queued — coalesced by source, so a duplicate wake supersedes its
predecessor rather than queueing repeat fetch work — and drained once
AppServices.start installs the handler, preserving each push's
arrival-time background state. A buffered push completes its system
handler promptly instead of holding the completion open against the
background budget.

A background-only launch, where no scene activates and startup never
runs, still performs no push work; a following change is planned to make
the wake itself able to drive startup.

Co-Authored-By: Claude Fable 5 <[email protected]>

Diffstat:
MCrossmate.xcodeproj/project.pbxproj | 4++++
MCrossmate/CrossmateApp.swift | 134++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
ATests/Unit/AppDelegatePushBufferTests.swift | 103+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 234 insertions(+), 7 deletions(-)

diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj @@ -172,6 +172,7 @@ A98382E7659991FAF0F4ED0A /* AuthorIdentityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 457B06DBFDC358D213A7CE54 /* AuthorIdentityTests.swift */; }; AA38A51862FC0AB8F7D34899 /* NYTToXDConverterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C54223FED97577A593B7964E /* NYTToXDConverterTests.swift */; }; AACC9F70AEEDCB3360FFDEFF /* GridStateMergerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C7F3A9BD7FAF81CB77032A6 /* GridStateMergerTests.swift */; }; + AC70BED45521BB77535C456C /* AppDelegatePushBufferTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48EC33BC7DDAC3B7F29C3F02 /* AppDelegatePushBufferTests.swift */; }; ADBEAD1C0139BCF864CA8A1D /* PuzzleCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89528AF69DE06C61FA8E91A1 /* PuzzleCommands.swift */; }; AE5D8C531F89F05B7201B3AC /* SessionMonitorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F64DAE64C9AA042B330C526F /* SessionMonitorTests.swift */; }; AE92B185FB52032AEB9421A1 /* AppActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAD36DEDB3D997AB47146EA9 /* AppActions.swift */; }; @@ -345,6 +346,7 @@ 465F2BB469EFE84CF3733398 /* Game.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Game.swift; sourceTree = "<group>"; }; 46801B570FC0B2C791ECDED3 /* PlayerSessionNavigationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerSessionNavigationTests.swift; sourceTree = "<group>"; }; 47532AED239AEF476D8E9206 /* NotificationStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationStateTests.swift; sourceTree = "<group>"; }; + 48EC33BC7DDAC3B7F29C3F02 /* AppDelegatePushBufferTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegatePushBufferTests.swift; sourceTree = "<group>"; }; 4A467BC00116EEC8500BE6A1 /* PersistenceRecoveryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceRecoveryTests.swift; sourceTree = "<group>"; }; 4B166E87D13C41C4D3575169 /* AccountPushCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountPushCoordinatorTests.swift; sourceTree = "<group>"; }; 4B33C21324E1474BCC126AA0 /* MovesCodecLegacyDecodeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovesCodecLegacyDecodeTests.swift; sourceTree = "<group>"; }; @@ -602,6 +604,7 @@ 4B166E87D13C41C4D3575169 /* AccountPushCoordinatorTests.swift */, 978A96CC6F550ED7A73F8D96 /* AnnouncementCenterTests.swift */, 99F4D2E1504784E738D2B33A /* AppDefaultsMigratorTests.swift */, + 48EC33BC7DDAC3B7F29C3F02 /* AppDelegatePushBufferTests.swift */, B9EA9CF96312BFF5340CE2A7 /* ArchiveTests.swift */, 3059A4C23D040E0D1F258B92 /* CellAccessibilityDescriberTests.swift */, 29686FFBC5BC8BCCF8CCC49A /* DriveMonitorTests.swift */, @@ -1055,6 +1058,7 @@ 521E877D28502B917C89B66D /* AccountPushCoordinatorTests.swift in Sources */, 6C091D30AAC9F63B7CE6FB58 /* AnnouncementCenterTests.swift in Sources */, 5082CCB75939E1BC6E9DC03A /* AppDefaultsMigratorTests.swift in Sources */, + AC70BED45521BB77535C456C /* AppDelegatePushBufferTests.swift in Sources */, D519F54F8CE0BD53D9C6144C /* AppServicesAnnouncementTests.swift in Sources */, DB098F40C6950E29B4BF10A7 /* ArchiveTests.swift in Sources */, A98382E7659991FAF0F4ED0A /* AuthorIdentityTests.swift in Sources */, diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift @@ -74,6 +74,14 @@ final class AppDelegate: UIResponder, UIApplicationDelegate, @preconcurrency UNU builder.remove(menu: .view) } + /// The handlers below are installed by `AppServices.start`, which runs + /// from the root view's asynchronous startup task — and UIKit can deliver + /// the APNs token or a remote notification before that task gets there. + /// Each callback therefore buffers what arrives early, and each handler + /// replays its buffer on assignment (the same cold-launch handoff the + /// notification-navigation and share-acceptance brokers provide), so a + /// callback that wins the race against SwiftUI startup is deferred rather + /// than lost. var onRemoteNotification: (( String, CKDatabase.Scope?, @@ -83,21 +91,110 @@ final class AppDelegate: UIResponder, UIApplicationDelegate, @preconcurrency UNU String?, Date?, Bool - ) async -> Void)? + ) async -> Void)? { + didSet { drainBufferedRemoteNotifications() } + } /// Reports the outcome of `registerForRemoteNotifications`. Surfaced in /// the diagnostics log so a missing APNs token (e.g. an aps-environment /// mismatch between the entitlements and the TestFlight distribution /// channel) is visible rather than silently degrading sync to the /// CKSyncEngine poll cadence. - var onAPNsRegistrationResult: ((String) -> Void)? + var onAPNsRegistrationResult: ((String) -> Void)? { + didSet { + guard let handler = onAPNsRegistrationResult, + let message = bufferedAPNsRegistrationResult else { return } + bufferedAPNsRegistrationResult = nil + handler(message) + } + } /// Delivers the raw APNs token to `PushClient` so it can register with the /// Crossmate push worker. Fires on every successful APNs registration — /// the worker dedupes unchanged triples server-side. - var onAPNsToken: ((Data) -> Void)? + var onAPNsToken: ((Data) -> Void)? { + didSet { + guard let handler = onAPNsToken, + let token = bufferedAPNsToken else { return } + bufferedAPNsToken = nil + handler(token) + } + } /// Tells the app that visible notification receipts may be waiting in the /// App Group ring buffer written by the Notification Service Extension. + /// Deliberately unbuffered: `AppServices.start` imports the ring buffer + /// unconditionally, so an early call is covered by startup itself. var onVisibleNotificationReceiptsAvailable: (() -> Void)? + /// A remote notification that arrived before `onRemoteNotification` was + /// installed, captured with its already-derived fields — including the + /// arrival-time background flag, so the replay preserves the state the + /// push actually arrived in. + private struct BufferedRemoteNotification { + let summary: String + let scope: CKDatabase.Scope? + let event: PushPayload.Event? + let gameID: UUID? + let kind: String? + let senderDeviceID: String? + let presenceUntil: Date? + let isBackground: Bool + } + + private var bufferedAPNsRegistrationResult: String? + private var bufferedAPNsToken: Data? + private var bufferedRemoteNotifications: [BufferedRemoteNotification] = [] + /// Bounds a pathological pre-start push burst. Oldest entries drop first — + /// pushes are wake signals, and the newest reflects current server state. + private static let bufferedRemoteNotificationCap = 8 + + private func bufferRemoteNotification(_ push: BufferedRemoteNotification) { + // A pre-start push is a wake signal, not a work item: a second push + // from the same source supersedes the queued one (carrying the newer + // presence deadline) instead of queueing duplicate fetch work for the + // drain. + if let index = bufferedRemoteNotifications.firstIndex(where: { + $0.scope == push.scope + && $0.event == push.event + && $0.gameID == push.gameID + && $0.kind == push.kind + && $0.senderDeviceID == push.senderDeviceID + }) { + bufferedRemoteNotifications[index] = push + } else { + bufferedRemoteNotifications.append(push) + if bufferedRemoteNotifications.count > Self.bufferedRemoteNotificationCap { + bufferedRemoteNotifications.removeFirst() + } + } + } + + private func drainBufferedRemoteNotifications() { + guard onRemoteNotification != nil, !bufferedRemoteNotifications.isEmpty else { return } + let drained = bufferedRemoteNotifications + bufferedRemoteNotifications = [] + Task { @MainActor in + for push in drained { + await onRemoteNotification?( + push.summary, + push.scope, + push.event, + push.gameID, + push.kind, + push.senderDeviceID, + push.presenceUntil, + push.isBackground + ) + } + } + } + + private func deliverAPNsRegistrationResult(_ message: String) { + if let onAPNsRegistrationResult { + onAPNsRegistrationResult(message) + } else { + bufferedAPNsRegistrationResult = message + } + } + func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil @@ -113,8 +210,12 @@ final class AppDelegate: UIResponder, UIApplicationDelegate, @preconcurrency UNU ) { let hex = deviceToken.map { String(format: "%02x", $0) }.joined() let prefix = hex.prefix(12) - onAPNsRegistrationResult?("APNs registered token=\(prefix)… (\(deviceToken.count) bytes)") - onAPNsToken?(deviceToken) + deliverAPNsRegistrationResult("APNs registered token=\(prefix)… (\(deviceToken.count) bytes)") + if let onAPNsToken { + onAPNsToken(deviceToken) + } else { + bufferedAPNsToken = deviceToken + } } func application( @@ -122,7 +223,7 @@ final class AppDelegate: UIResponder, UIApplicationDelegate, @preconcurrency UNU didFailToRegisterForRemoteNotificationsWithError error: Error ) { let nsError = error as NSError - onAPNsRegistrationResult?( + deliverAPNsRegistrationResult( "APNs registration FAILED — domain=\(nsError.domain) code=\(nsError.code) " + "\(nsError.localizedDescription)" ) @@ -227,7 +328,26 @@ final class AppDelegate: UIResponder, UIApplicationDelegate, @preconcurrency UNU let senderDeviceID = userInfo["senderDeviceID"] as? String let presenceUntil = Self.date(from: userInfo["presenceUntil"] as? String) let isBackground = application.applicationState != .active - await onRemoteNotification?( + guard let onRemoteNotification else { + // Pre-start arrival: buffer the wake and complete promptly — the + // work runs once startup installs the handler, and holding the + // system completion open for that would risk the background + // budget. In a background-only launch no scene ever activates, so + // the drain may never run this process; that leaves such wakes a + // no-op, exactly as they were before buffering existed. + bufferRemoteNotification(BufferedRemoteNotification( + summary: summary, + scope: scope, + event: payload?.event, + gameID: gameID, + kind: kind, + senderDeviceID: senderDeviceID, + presenceUntil: presenceUntil, + isBackground: isBackground + )) + return .newData + } + await onRemoteNotification( summary, scope, payload?.event, diff --git a/Tests/Unit/AppDelegatePushBufferTests.swift b/Tests/Unit/AppDelegatePushBufferTests.swift @@ -0,0 +1,103 @@ +import Foundation +import Testing +import UIKit + +@testable import Crossmate + +@Suite("App delegate push buffering") +@MainActor +struct AppDelegatePushBufferTests { + @Test("An APNs token that beats handler installation is replayed once") + func tokenReplayedOnce() { + let delegate = AppDelegate() + let token = Data([0x01, 0x02, 0x03]) + delegate.application( + UIApplication.shared, + didRegisterForRemoteNotificationsWithDeviceToken: token + ) + + var received: [Data] = [] + delegate.onAPNsToken = { received.append($0) } + #expect(received == [token]) + + // The buffer is consumed by the replay — reinstalling the handler + // must not deliver the token again. + delegate.onAPNsToken = { received.append($0) } + #expect(received == [token]) + } + + @Test("The APNs token goes straight through once the handler is installed") + func tokenDeliveredDirectly() { + let delegate = AppDelegate() + var received: [Data] = [] + delegate.onAPNsToken = { received.append($0) } + + let token = Data([0x0a, 0x0b]) + delegate.application( + UIApplication.shared, + didRegisterForRemoteNotificationsWithDeviceToken: token + ) + #expect(received == [token]) + } + + @Test("A registration failure that beats handler installation is replayed") + func registrationFailureReplayed() { + let delegate = AppDelegate() + delegate.application( + UIApplication.shared, + didFailToRegisterForRemoteNotificationsWithError: NSError(domain: "test", code: 3) + ) + + var messages: [String] = [] + delegate.onAPNsRegistrationResult = { messages.append($0) } + #expect(messages.count == 1) + #expect(messages.first?.contains("FAILED") == true) + } + + @Test("Pre-start remote notifications are buffered, coalesced by source, and drained in order") + func remoteNotificationsBufferedAndDrained() async { + let delegate = AppDelegate() + let firstGame = UUID() + let secondGame = UUID() + + // Two wakes from the same source coalesce to the newer one (which + // carries the presence deadline); a different game stays a separate + // entry. + _ = await delegate.application(UIApplication.shared, didReceiveRemoteNotification: [ + "kind": "presence", + "gameID": firstGame.uuidString, + "senderDeviceID": "alice-device", + ]) + _ = await delegate.application(UIApplication.shared, didReceiveRemoteNotification: [ + "kind": "presence", + "gameID": firstGame.uuidString, + "senderDeviceID": "alice-device", + "presenceUntil": "2026-07-19T10:00:00Z", + ]) + _ = await delegate.application(UIApplication.shared, didReceiveRemoteNotification: [ + "kind": "presence", + "gameID": secondGame.uuidString, + "senderDeviceID": "bob-device", + ]) + + var drained: [(gameID: UUID?, presenceUntil: Date?)] = [] + delegate.onRemoteNotification = { _, _, _, gameID, _, _, presenceUntil, _ in + drained.append((gameID, presenceUntil)) + } + for _ in 0..<1000 where drained.count < 2 { await Task.yield() } + + #expect(drained.count == 2) + #expect(drained.first?.gameID == firstGame) + #expect(drained.first?.presenceUntil != nil) + #expect(drained.last?.gameID == secondGame) + + // The buffer is empty after the drain — a later push is delivered + // directly, not re-queued. + _ = await delegate.application(UIApplication.shared, didReceiveRemoteNotification: [ + "kind": "presence", + "gameID": firstGame.uuidString, + "senderDeviceID": "alice-device", + ]) + #expect(drained.count == 3) + } +}