commit 11e31ab4918b656b4b415d1af43c2a7eb7c6d7f5
parent 726cdcd67c40f69cb96dd2361b1e9d8ca47a6bb4
Author: Michael Camilleri <[email protected]>
Date: Thu, 30 Apr 2026 11:30:14 +0900
Use synced pings for activity notifications
CloudKit rejects query subscriptions in the shared database with 'Subscription
evaluation type not allowed in shared database', so the SessionPing
subscription path could never deliver alerts for joined games. This commit
stops registering those shared-database query subscriptions and instead handles
SessionPing records as normal CKSyncEngine payloads. Fetched SessionPing
records are parsed during record-zone change handling and forwarded to
AppServices, which filters out the local author's own pings, honors
active-puzzle and recent-notification suppression state, and schedules a local
user notification for remote activity. The notification payload now carries the
Crossmate game ID directly so the existing foreground delegate can apply the
same suppression logic to locally scheduled alerts.
Co-Authored-By: Codex GPT 5.5 <[email protected]>
Diffstat:
3 files changed, 103 insertions(+), 35 deletions(-)
diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift
@@ -66,6 +66,9 @@ final class AppDelegate: NSObject, UIApplicationDelegate, @preconcurrency UNUser
}
private static func gameID(from userInfo: [AnyHashable: Any]) -> UUID? {
+ if let id = userInfo["crossmateGameID"] as? String {
+ return UUID(uuidString: id)
+ }
guard let ck = userInfo["ck"] as? [AnyHashable: Any],
let qry = ck["qry"] as? [AnyHashable: Any],
let zoneName = qry["zid"] as? String,
diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift
@@ -1,5 +1,6 @@
import CloudKit
import Foundation
+import UserNotifications
@MainActor
final class AppServices {
@@ -123,6 +124,11 @@ final class AppServices {
store.refreshCurrentGame()
}
+ await syncEngine.setOnSessionPings { [weak self] pings in
+ guard let self else { return }
+ await self.presentSessionPings(pings)
+ }
+
await syncEngine.setOnAccountChange { [weak self] in
guard let self else { return }
await self.identity.refresh(using: self.ckContainer)
@@ -142,20 +148,6 @@ final class AppServices {
// Fetch identity before starting engines so first moves get an authorID.
await identity.refresh(using: ckContainer)
- // SessionPing alert-push subscription registry. Created after the
- // identity refresh so the predicate-author filter is set on the
- // first save attempt.
- let identity = self.identity
- let subscriber = SessionPingSubscriber(
- container: ckContainer,
- identityProvider: { @MainActor [identity] in identity.currentID },
- tracer: { [syncMonitor] message in
- Task { @MainActor in syncMonitor.note(message) }
- }
- )
- await syncEngine.setSessionPingSubscriber(subscriber)
- await bootstrapSessionPingSubscriptions(subscriber)
-
// NameBroadcaster fans out name changes to all shared/joined games.
// PuzzleDisplayView also calls `broadcastName()` when a shared puzzle
// is opened, which covers first-sync-after-share-create / accept.
@@ -224,25 +216,6 @@ final class AppServices {
)
}
- /// Walks the shared database's existing zones and ensures a SessionPing
- /// subscription exists for each. This catches zones that were already on
- /// the account before the feature shipped (or after a reinstall lost the
- /// local UserDefaults registry); the live path in
- /// `SyncEngine.handleFetchedDatabaseChanges` covers zones that arrive
- /// during the session.
- private func bootstrapSessionPingSubscriptions(_ subscriber: SessionPingSubscriber) async {
- let zones: [CKRecordZone]
- do {
- zones = try await ckContainer.sharedCloudDatabase.allRecordZones()
- } catch {
- syncMonitor.note("session-ping: skipping bootstrap — \(error.localizedDescription)")
- return
- }
- for zone in zones where zone.zoneID.zoneName.hasPrefix("game-") {
- await subscriber.ensureSubscribed(zoneID: zone.zoneID)
- }
- }
-
private func handleRemoteNotification(summary: String) async {
syncMonitor.note("remote notification: \(summary)")
await syncMonitor.run("remote-notification fetch") {
@@ -250,6 +223,56 @@ final class AppServices {
}
}
+ private func presentSessionPings(_ pings: [SessionPing]) async {
+ let center = UNUserNotificationCenter.current()
+ let settings = await center.notificationSettings()
+ let canNotify: Bool
+ switch settings.authorizationStatus {
+ case .authorized, .provisional, .ephemeral:
+ canNotify = true
+ default:
+ canNotify = false
+ }
+ guard canNotify else {
+ syncMonitor.note("session-ping: local notification skipped — authorization not granted")
+ return
+ }
+
+ for ping in pings {
+ if ping.authorID == identity.currentID { continue }
+ if NotificationState.shouldSuppress(gameID: ping.gameID) {
+ NotificationState.recordShown(gameID: ping.gameID)
+ syncMonitor.note("session-ping: local notification suppressed for \(ping.gameID.uuidString)")
+ continue
+ }
+
+ let content = UNMutableNotificationContent()
+ content.title = "Crossmate"
+ if !ping.playerName.isEmpty, !ping.puzzleTitle.isEmpty {
+ content.body = "\(ping.playerName) made an edit to \(ping.puzzleTitle)"
+ } else if !ping.playerName.isEmpty {
+ content.body = "\(ping.playerName) made an edit"
+ } else {
+ content.body = "A player made an edit"
+ }
+ content.sound = .default
+ content.userInfo = ["crossmateGameID": ping.gameID.uuidString]
+
+ let request = UNNotificationRequest(
+ identifier: "sessionping-\(ping.gameID.uuidString)-\(UUID().uuidString)",
+ content: content,
+ trigger: nil
+ )
+ do {
+ try await center.add(request)
+ NotificationState.recordShown(gameID: ping.gameID)
+ syncMonitor.note("session-ping: queued local notification for \(ping.gameID.uuidString)")
+ } catch {
+ syncMonitor.note("session-ping: local notification failed — \(error.localizedDescription)")
+ }
+ }
+ }
+
/// 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
diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift
@@ -16,6 +16,13 @@ extension Notification.Name {
static let playerRosterShouldRefresh = Notification.Name("playerRosterShouldRefresh")
}
+struct SessionPing: Sendable {
+ let gameID: UUID
+ let authorID: String
+ let playerName: String
+ let puzzleTitle: String
+}
+
/// Owns the CloudKit sync lifecycle via two `CKSyncEngine` instances — one for
/// the private database (owned games and shares) and one for the shared
/// database (joined games). Zone creation, subscription setup, change-token
@@ -58,6 +65,7 @@ actor SyncEngine {
private var loggedFirstSharedPushPayload = false
private var onRemoteMoves: (@MainActor @Sendable ([Move]) async -> Void)?
+ private var onSessionPings: (@MainActor @Sendable ([SessionPing]) async -> Void)?
private var onAccountChange: (@MainActor @Sendable () async -> Void)?
private var onGameAccessRevoked: (@MainActor @Sendable (UUID) async -> Void)?
private var onSnapshotsSaved: (@MainActor @Sendable ([String]) async -> Void)?
@@ -76,6 +84,10 @@ actor SyncEngine {
onRemoteMoves = cb
}
+ func setOnSessionPings(_ cb: @MainActor @Sendable @escaping ([SessionPing]) async -> Void) {
+ onSessionPings = cb
+ }
+
func setOnAccountChange(_ cb: @MainActor @Sendable @escaping () async -> Void) {
onAccountChange = cb
}
@@ -846,9 +858,10 @@ actor SyncEngine {
}
let ctx = persistence.container.newBackgroundContext()
- let (newMoves, affectedGameIDs): ([Move], Set<UUID>) = ctx.performAndWait {
+ let (newMoves, affectedGameIDs, sessionPings): ([Move], Set<UUID>, [SessionPing]) = ctx.performAndWait {
var moves: [Move] = []
var affected = Set<UUID>()
+ var pings: [SessionPing] = []
for mod in event.modifications {
let record = mod.record
switch record.recordType {
@@ -871,6 +884,10 @@ actor SyncEngine {
self.applyPlayerRecord(record, in: ctx)
affected.insert(gameID)
}
+ case "SessionPing":
+ if let ping = Self.parseSessionPingRecord(record) {
+ pings.append(ping)
+ }
default:
break
}
@@ -889,12 +906,15 @@ actor SyncEngine {
self.replayCellCache(for: gameID, in: ctx)
}
if ctx.hasChanges { try? ctx.save() }
- return (moves, affected)
+ return (moves, affected, pings)
}
if let onRemoteMoves, !newMoves.isEmpty {
await onRemoteMoves(newMoves)
}
+ if let onSessionPings, !sessionPings.isEmpty {
+ await onSessionPings(sessionPings)
+ }
if !affectedGameIDs.isEmpty {
NotificationCenter.default.post(
name: .playerRosterShouldRefresh,
@@ -904,6 +924,28 @@ actor SyncEngine {
}
}
+ private nonisolated static func parseSessionPingRecord(_ record: CKRecord) -> SessionPing? {
+ let name = record.recordID.recordName
+ let gameID: UUID?
+ if name.hasPrefix("sessionping-") {
+ let rest = name.dropFirst("sessionping-".count)
+ gameID = UUID(uuidString: String(rest.prefix(36)))
+ } else if record.recordID.zoneID.zoneName.hasPrefix("game-") {
+ gameID = UUID(uuidString: String(record.recordID.zoneID.zoneName.dropFirst("game-".count)))
+ } else {
+ gameID = nil
+ }
+ guard let gameID,
+ let authorID = record["authorID"] as? String
+ else { return nil }
+ return SessionPing(
+ gameID: gameID,
+ authorID: authorID,
+ playerName: (record["playerName"] as? String) ?? "",
+ puzzleTitle: (record["puzzleTitle"] as? String) ?? ""
+ )
+ }
+
private nonisolated func applyDeletion(
recordID: CKRecord.ID,
recordType: CKRecord.RecordType,