commit d64acf0bd3beeba45cd25f02d5ac1ba338e2aaab
parent 290ad1a3b776b7c8255abe6e09e9a07584adf0b0
Author: Michael Camilleri <[email protected]>
Date: Tue, 12 May 2026 16:19:28 +0900
Surface collaborator pings on background push wakes
CKSyncEngine.fetchChanges() can return successfully from a silent-push wake
without delivering record-zone events until the app is next foregrounded (the
result can then be that notifications are not shown to the user in a timely
manner). This commit makes the silent-push handler bypass that quirk by
querying Ping records directly across all known zones for the notified database
scope, then feeding them through the existing onPings callback so local
notifications are surfaced immediately.
The per-zone since cursor uses a forward-moving scope checkpoint when available
and falls back to the GameEntity's createdAt timestamp, so the first run after
install or reset captures the triggering ping without replaying historical ones
from before the device joined the game. To keep the eventual CKSyncEngine
catch-up from re-surfacing the same pings, presentation now dedupes by Ping
record name through a new ring buffer in NotificationState, and resetSyncState
clears both the scope and live-query checkpoints.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
Diffstat:
4 files changed, 167 insertions(+), 0 deletions(-)
diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift
@@ -287,6 +287,17 @@ final class AppServices {
}
guard await ensureICloudSyncStarted() else { return }
syncMonitor.note("remote notification: \(summary)")
+
+ // Fast path: surface Ping-driven notifications immediately by
+ // querying Ping records directly, bypassing CKSyncEngine. Works
+ // whether or not a game is open, and works during the background-
+ // wake window where CKSyncEngine.fetchChanges() can silently no-op.
+ if let scope, scope != .public {
+ await syncMonitor.run("remote-notification ping fast-path") {
+ _ = try await syncEngine.fetchPushPingsDirect(scope: scope)
+ }
+ }
+
guard let scope, scope != .public else {
await syncMonitor.run("remote-notification fetch") {
try await syncEngine.fetchChanges(source: "push")
@@ -363,6 +374,16 @@ final class AppServices {
for ping in pings {
if ping.authorID == identity.currentID { continue }
+ // The push fast path and the CKSyncEngine catch-up both surface
+ // the same Ping records, so dedup by record name. We do this
+ // check first and short-circuit; every other path below ends by
+ // recording the name via the defer.
+ if NotificationState.wasShown(pingRecordName: ping.recordName) {
+ syncMonitor.note("ping(\(ping.kind.rawValue)): already-shown record \(ping.recordName)")
+ continue
+ }
+ defer { NotificationState.recordShown(pingRecordName: ping.recordName) }
+
if NotificationState.isActive(gameID: ping.gameID) {
if ping.kind == .session {
NotificationState.recordShown(gameID: ping.gameID)
diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift
@@ -35,6 +35,7 @@ enum PingScope: String, Sendable {
}
struct Ping: Sendable {
+ let recordName: String
let gameID: UUID
let authorID: String
let playerName: String
@@ -96,6 +97,13 @@ actor SyncEngine {
private var liveQueryCheckpoints: [String: Date] = [:]
private let liveQueryCheckpointOverlap: TimeInterval = 5
+ /// Per-scope checkpoint for the background ping fast path. Independent of
+ /// CKSyncEngine's change tokens and of `liveQueryCheckpoints` (which are
+ /// per-zone and Moves/Player oriented). Keyed by databaseScope value
+ /// (0 = private, 1 = shared).
+ private var pingPushCheckpoints: [Int16: Date] = [:]
+ private let pingPushCheckpointOverlap: TimeInterval = 30
+
func setTracer(_ t: @MainActor @Sendable @escaping (String) -> Void) {
tracer = t
}
@@ -532,6 +540,76 @@ actor SyncEngine {
return true
}
+ /// Background-wake fast path for surfacing collaborator notifications.
+ /// Queries every known zone in the given scope for Ping records modified
+ /// since the per-scope checkpoint and feeds them to `onPings`. Bypasses
+ /// `CKSyncEngine.fetchChanges()` because that path can return without
+ /// delivering events from a silent-push wake (same Apple quirk that
+ /// motivated `fetchPushChangesDirect`). Moves / Player / Game records are
+ /// deliberately left for the engine-driven or foreground fetch.
+ @discardableResult
+ func fetchPushPingsDirect(scope: CKDatabase.Scope) async throws -> Int {
+ let database: CKDatabase
+ let scopeValue: Int16
+ let label: String
+ switch scope {
+ case .private:
+ database = container.privateCloudDatabase
+ scopeValue = 0
+ label = "private"
+ case .shared:
+ database = container.sharedCloudDatabase
+ scopeValue = 1
+ label = "shared"
+ case .public:
+ return 0
+ @unknown default:
+ return 0
+ }
+
+ let ctx = persistence.container.newBackgroundContext()
+ let zones = knownZones(forScope: scopeValue, in: ctx)
+ guard !zones.isEmpty else {
+ await trace("\(label) ping fast-path: no known zones")
+ return 0
+ }
+
+ let scopeCheckpoint = pingPushCheckpoints[scopeValue]?
+ .addingTimeInterval(-pingPushCheckpointOverlap)
+
+ var collected: [CKRecord] = []
+ for (zoneID, createdAt) in zones {
+ // Scope checkpoint (if present) wins — it's forward-moving across
+ // all zones. On first run for a given scope we fall back to the
+ // game's createdAt floor so the ping that triggered this wake is
+ // still in range, but pings older than the device's first
+ // knowledge of the game are not.
+ let since = scopeCheckpoint
+ ?? createdAt.addingTimeInterval(-pingPushCheckpointOverlap)
+ let recs = try await queryLiveRecords(
+ type: "Ping",
+ database: database,
+ zoneID: zoneID,
+ since: since,
+ desiredKeys: ["authorID", "playerName", "puzzleTitle", "kind", "scope"]
+ )
+ collected.append(contentsOf: recs)
+ }
+
+ let pings = collected.compactMap(Self.parsePingRecord)
+ if let latest = collected.compactMap(\.modificationDate).max() {
+ pingPushCheckpoints[scopeValue] = latest
+ }
+ await trace(
+ "\(label) ping fast-path: zones=\(zones.count), pings=\(pings.count)"
+ )
+
+ if !pings.isEmpty, let onPings {
+ await onPings(pings)
+ }
+ return pings.count
+ }
+
private func queryLiveRecords(
type: CKRecord.RecordType,
database: CKDatabase,
@@ -846,6 +924,8 @@ actor SyncEngine {
delegate: self
))
pendingPings = [:]
+ pingPushCheckpoints = [:]
+ liveQueryCheckpoints = [:]
loggedFirstSharedPushPayload = false
_ = enqueueUnconfirmedMoves()
}
@@ -885,6 +965,35 @@ actor SyncEngine {
}
}
+ /// Enumerates every known game zone for the given database scope, paired
+ /// with the `createdAt` of the corresponding GameEntity. The createdAt
+ /// timestamp is used as the per-zone floor for the ping fast path: pings
+ /// older than the moment this device first knew about the game can't be
+ /// of interest (for shared games, they pre-date our join; for owned
+ /// games, they pre-date the game's existence).
+ private nonisolated func knownZones(
+ forScope scope: Int16,
+ in ctx: NSManagedObjectContext
+ ) -> [(zoneID: CKRecordZone.ID, createdAt: Date)] {
+ ctx.performAndWait {
+ let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
+ req.predicate = NSPredicate(format: "databaseScope == %d", scope)
+ guard let entities = try? ctx.fetch(req) else { return [] }
+ var seen = Set<String>()
+ var result: [(CKRecordZone.ID, Date)] = []
+ for entity in entities {
+ guard let gameID = entity.id else { continue }
+ let zoneName = entity.ckZoneName ?? "game-\(gameID.uuidString)"
+ let ownerName = entity.ckZoneOwnerName ?? CKCurrentUserDefaultName
+ let key = "\(ownerName)|\(zoneName)"
+ guard seen.insert(key).inserted else { continue }
+ let createdAt = entity.createdAt ?? Date(timeIntervalSince1970: 0)
+ result.append((CKRecordZone.ID(zoneName: zoneName, ownerName: ownerName), createdAt))
+ }
+ return result
+ }
+ }
+
/// Extracts the game UUID from any of our record name formats:
/// `game-<UUID>`, `moves-<UUID>-…`, `player-<UUID>-…`, `ping-<UUID>-…`.
private nonisolated func gameID(fromRecordName name: String) -> UUID? {
@@ -1350,6 +1459,7 @@ actor SyncEngine {
else { return nil }
let scope: PingScope? = (record["scope"] as? String).flatMap(PingScope.init(rawValue:))
return Ping(
+ recordName: name,
gameID: gameID,
authorID: authorID,
playerName: (record["playerName"] as? String) ?? "",
diff --git a/Shared/NotificationState.swift b/Shared/NotificationState.swift
@@ -20,6 +20,13 @@ enum NotificationState {
private static let activeKey = "notif.activePuzzleID"
private static let shownKey = "notif.shownByGame"
+ private static let shownPingNamesKey = "notif.shownPingNames"
+
+ /// Maximum number of recently-presented ping record names retained for
+ /// dedup. FIFO; older entries are evicted as new ones come in. 200 covers
+ /// the worst-case overlap between the push fast path and the eventual
+ /// CKSyncEngine catch-up many times over.
+ static let shownPingNamesCap = 200
private static var defaults: UserDefaults? {
UserDefaults(suiteName: appGroup)
@@ -74,4 +81,31 @@ enum NotificationState {
private static func shownMap() -> [String: TimeInterval] {
defaults?.dictionary(forKey: shownKey) as? [String: TimeInterval] ?? [:]
}
+
+ /// True if a notification for this specific Ping record name has already
+ /// been presented. Used to keep the push fast path and the eventual
+ /// CKSyncEngine catch-up from double-notifying for the same ping.
+ static func wasShown(pingRecordName name: String) -> Bool {
+ shownPingNames().contains(name)
+ }
+
+ /// Records that a notification for this Ping record name was presented.
+ /// Maintains FIFO order; evicts the oldest entries once `shownPingNamesCap`
+ /// is exceeded.
+ static func recordShown(pingRecordName name: String) {
+ guard let defaults else { return }
+ var names = shownPingNames()
+ if let existing = names.firstIndex(of: name) {
+ names.remove(at: existing)
+ }
+ names.append(name)
+ if names.count > shownPingNamesCap {
+ names.removeFirst(names.count - shownPingNamesCap)
+ }
+ defaults.set(names, forKey: shownPingNamesKey)
+ }
+
+ private static func shownPingNames() -> [String] {
+ defaults?.stringArray(forKey: shownPingNamesKey) ?? []
+ }
}
diff --git a/Tests/Unit/PuzzleNotificationTextTests.swift b/Tests/Unit/PuzzleNotificationTextTests.swift
@@ -18,6 +18,7 @@ struct PuzzleNotificationTextTests {
@Test("Notification body quotes puzzle title")
func bodyQuotesPuzzleTitle() {
let ping = Ping(
+ recordName: "ping-test-1",
gameID: UUID(),
authorID: "alice",
playerName: "Alice",
@@ -32,6 +33,7 @@ struct PuzzleNotificationTextTests {
@Test("Puzzle check says all of the puzzle")
func puzzleCheckSaysAllOfPuzzle() {
let ping = Ping(
+ recordName: "ping-test-2",
gameID: UUID(),
authorID: "alice",
playerName: "Alice",