commit 7f5113748ab91f3cb612eb77a82513f7213d5e74
parent 51ae00c0cc3cb2bf39e1d64c32ea7109675c7af6
Author: Michael Camilleri <[email protected]>
Date: Thu, 4 Jun 2026 14:25:27 +0900
Add account-level silent push routing
This commit creates an account-scoped push address using the existing
Decision record type (the record will be called
decision-account-pushAddress) and register each device under that
address alongside its per-game push addresses. The address lives in the
Decision payload, so no CloudKit schema changes are required.
The reason to add this is so an account address can be used for silent
sibling-device notifications when a device joins a shared game or
advances its read horizon. Receiving devices ignore self-sends, run the
existing shared-game refresh path for joins and apply sibling read
horizons immediately for seen updates.
To support this, the Cloudflare Worker script is updated to forward
senderDeviceID and readAt so that the app can identify account-control
pushes and apply them without waiting for CloudKit.
Co-Authored-By: Codex GPT 5.5 <[email protected]>
Diffstat:
8 files changed, 281 insertions(+), 14 deletions(-)
diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift
@@ -64,7 +64,16 @@ struct CrossmateApp: App {
// MARK: - App Delegate
final class AppDelegate: NSObject, UIApplicationDelegate, @preconcurrency UNUserNotificationCenterDelegate, @unchecked Sendable {
- var onRemoteNotification: ((String, CKDatabase.Scope?, PushPayload.Event?, UUID?, Bool) async -> Void)?
+ var onRemoteNotification: ((
+ String,
+ CKDatabase.Scope?,
+ PushPayload.Event?,
+ UUID?,
+ String?,
+ String?,
+ Date?,
+ Bool
+ ) async -> Void)?
/// 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
@@ -198,11 +207,32 @@ final class AppDelegate: NSObject, UIApplicationDelegate, @preconcurrency UNUser
let scope = AppServices.databaseScope(fromPush: userInfo)
let payload = PushPayload.decode(from: userInfo["payload"] as? String)
let gameID = Self.gameID(from: userInfo)
+ let kind = userInfo["kind"] as? String
+ let senderDeviceID = userInfo["senderDeviceID"] as? String
+ let readAt = Self.date(from: userInfo["readAt"] as? String)
let isBackground = application.applicationState != .active
- await onRemoteNotification?(summary, scope, payload?.event, gameID, isBackground)
+ await onRemoteNotification?(
+ summary,
+ scope,
+ payload?.event,
+ gameID,
+ kind,
+ senderDeviceID,
+ readAt,
+ isBackground
+ )
return .newData
}
+ private static func date(from raw: String?) -> Date? {
+ guard let raw else { return nil }
+ let formatter = ISO8601DateFormatter()
+ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
+ if let date = formatter.date(from: raw) { return date }
+ formatter.formatOptions = [.withInternetDateTime]
+ return formatter.date(from: raw)
+ }
+
func application(
_ application: UIApplication,
configurationForConnecting connectingSceneSession: UISceneSession,
diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift
@@ -37,6 +37,9 @@ final class AppServices {
/// no work and writes nothing; the coordinator's connecting-state guard
/// caps any actual re-hail rate.
private static let engagementReconnectInterval: Duration = .seconds(30)
+ private static let accountPushAddressDefaultsPrefix = "push.accountAddress."
+ private static let accountJoinedPushKind = "accountJoined"
+ private static let accountSeenPushKind = "accountSeen"
enum FreshenReason {
case appeared
@@ -373,12 +376,16 @@ final class AppServices {
await refreshAppBadge()
importVisibleNotificationReceipts()
- appDelegate.onRemoteNotification = { summary, scope, event, gameID, isBackground in
+ appDelegate.onRemoteNotification = {
+ summary, scope, event, gameID, kind, senderDeviceID, readAt, isBackground in
await self.handleRemoteNotification(
summary: summary,
scope: scope,
event: event,
gameID: gameID,
+ kind: kind,
+ senderDeviceID: senderDeviceID,
+ readAt: readAt,
isBackground: isBackground
)
}
@@ -486,6 +493,14 @@ final class AppServices {
}
}
+ await syncEngine.setOnAccountPushAddress { [weak self] address in
+ guard let self,
+ let authorID = self.identity.currentID,
+ !authorID.isEmpty else { return }
+ self.cacheAccountPushAddress(address, authorID: authorID)
+ await self.reconcilePushRegistration()
+ }
+
await syncEngine.setOnPings { [weak self] pings in
guard let self else { return }
await self.presentPings(pings)
@@ -564,6 +579,8 @@ final class AppServices {
// app is in Settings > Notifications before any inbound moves.
await AppDelegate.requestNotificationAuthorizationIfNeeded()
self.syncMonitor.note("share joined: join ping skipped")
+ await self.reconcilePushRegistration()
+ await self.publishAccountJoinedPush(gameID: gameID)
}
// PlayerNamePublisher fans out name changes to active shared/joined
@@ -707,6 +724,7 @@ final class AppServices {
func reconcilePushRegistration() async {
guard let pushClient, preferences.isICloudSyncEnabled else { return }
guard let authorID = identity.currentID, !authorID.isEmpty else { return }
+ let accountAddress = ensureAccountPushAddress(authorID: authorID)
let result = store.reconcileLocalPushAddresses(authorID: authorID)
for gameID in result.mintedGameIDs {
await syncEngine.enqueuePlayer(
@@ -715,7 +733,68 @@ final class AppServices {
reason: "pushAddress"
)
}
- pushClient.setAddresses(result.addresses)
+ pushClient.setAddresses(result.addresses.union([accountAddress]))
+ }
+
+ private func ensureAccountPushAddress(authorID: String) -> String {
+ let key = accountPushAddressDefaultsKey(authorID: authorID)
+ if let existing = UserDefaults.standard.string(forKey: key), !existing.isEmpty {
+ publishAccountPushAddressDecision(existing)
+ return existing
+ }
+ let address = "acct-\(UUID().uuidString)"
+ UserDefaults.standard.set(address, forKey: key)
+ publishAccountPushAddressDecision(address)
+ return address
+ }
+
+ private func cacheAccountPushAddress(_ address: String, authorID: String) {
+ guard !address.isEmpty else { return }
+ UserDefaults.standard.set(address, forKey: accountPushAddressDefaultsKey(authorID: authorID))
+ }
+
+ private func accountPushAddressDefaultsKey(authorID: String) -> String {
+ Self.accountPushAddressDefaultsPrefix + authorID
+ }
+
+ private func publishAccountPushAddressDecision(_ address: String) {
+ // This can be reached from callbacks that SyncEngine invokes while a
+ // CKSyncEngine delegate method is still unwinding. Match the existing
+ // friend-accept pattern: do not use plain `Task {}`, which can inherit
+ // the current actor and re-enter before CloudKit's delegate guard clears.
+ Task.detached { [syncEngine] in
+ await syncEngine.enqueueDecision(
+ kind: RecordSerializer.accountDecisionKind,
+ key: RecordSerializer.accountPushAddressDecisionKey,
+ payload: address
+ )
+ }
+ }
+
+ private func publishAccountJoinedPush(gameID: UUID) async {
+ await publishAccountEvent(kind: Self.accountJoinedPushKind, gameID: gameID)
+ }
+
+ private func publishAccountSeenPush(gameID: UUID, readAt: Date) async {
+ await publishAccountEvent(kind: Self.accountSeenPushKind, gameID: gameID, readAt: readAt)
+ }
+
+ private func publishAccountEvent(kind: String, gameID: UUID, readAt: Date? = nil) async {
+ guard let pushClient else {
+ syncMonitor.note("push(\(kind)): skipped (no pushClient)")
+ return
+ }
+ guard let authorID = identity.currentID, !authorID.isEmpty else {
+ syncMonitor.note("push(\(kind)): skipped (no authorID)")
+ return
+ }
+ let address = ensureAccountPushAddress(authorID: authorID)
+ await pushClient.publishAccountEvent(
+ kind: kind,
+ gameID: gameID,
+ address: address,
+ readAt: readAt
+ )
}
/// Defer the session-begin push by `seconds`, replacing any pending timer
@@ -1796,6 +1875,9 @@ final class AppServices {
scope: CKDatabase.Scope?,
event: PushPayload.Event?,
gameID: UUID?,
+ kind: String?,
+ senderDeviceID: String?,
+ readAt: Date?,
isBackground: Bool
) async {
guard preferences.isICloudSyncEnabled else {
@@ -1806,6 +1888,15 @@ final class AppServices {
lastRemoteNotificationAt = Date()
syncMonitor.note("remote notification: \(summary)")
+ if await handleAccountControlPush(
+ kind: kind,
+ gameID: gameID,
+ senderDeviceID: senderDeviceID,
+ readAt: readAt
+ ) {
+ return
+ }
+
if event == .replay {
let label = gameID.map { String($0.uuidString.prefix(8)) } ?? "unknown"
syncMonitor.note("push(replay): syncing game \(label)")
@@ -1884,6 +1975,48 @@ final class AppServices {
await refreshSnapshot()
}
+ private func handleAccountControlPush(
+ kind: String?,
+ gameID: UUID?,
+ senderDeviceID: String?,
+ readAt: Date?
+ ) async -> Bool {
+ guard let kind,
+ kind == Self.accountJoinedPushKind || kind == Self.accountSeenPushKind
+ else { return false }
+ if senderDeviceID == RecordSerializer.localDeviceID {
+ syncMonitor.note("push(\(kind)): ignored self-send")
+ return true
+ }
+ guard let gameID else {
+ syncMonitor.note("push(\(kind)): ignored (no gameID)")
+ return true
+ }
+
+ switch kind {
+ case Self.accountJoinedPushKind:
+ syncMonitor.note("push(accountJoined): sibling joined \(gameID.uuidString.prefix(8))")
+ await syncMonitor.run("account-joined shared discovery") {
+ try await syncEngine.fetchChanges(source: "account joined")
+ }
+ await freshenGameList(scope: .shared, reason: .remote)
+ await reconcilePushRegistration()
+ await refreshSnapshot()
+ case Self.accountSeenPushKind:
+ guard let readAt else {
+ syncMonitor.note("push(accountSeen): ignored (no readAt)")
+ return true
+ }
+ syncMonitor.note("push(accountSeen): sibling saw \(gameID.uuidString.prefix(8))")
+ store.noteIncomingReadCursor(gameID: gameID, readAt: readAt)
+ sessionMonitor.refreshMovesSnapshots(for: gameID)
+ await dismissDeliveredNotifications(for: gameID)
+ default:
+ break
+ }
+ return true
+ }
+
private func activePuzzleGridTarget() -> (UUID, CKDatabase.Scope)? {
guard let entity = store.currentEntity,
let gameID = entity.id
@@ -2710,11 +2843,15 @@ final class AppServices {
let didUpdate: Bool
switch mode {
case .activeLease:
+ let readAt = now.addingTimeInterval(Self.readLeaseDuration)
didUpdate = store.setReadCursor(
gameID: gameID,
- readAt: now.addingTimeInterval(Self.readLeaseDuration),
+ readAt: readAt,
minimumExistingReadAt: now.addingTimeInterval(Self.readLeaseRefreshFloor)
)
+ if didUpdate {
+ await publishAccountSeenPush(gameID: gameID, readAt: readAt)
+ }
case .currentTime:
didUpdate = store.setReadCursor(gameID: gameID, readAt: now)
}
diff --git a/Crossmate/Services/PushClient.swift b/Crossmate/Services/PushClient.swift
@@ -144,7 +144,8 @@ final class PushClient {
addressees: [Addressee],
title: String,
body: String,
- background: Bool = false
+ background: Bool = false,
+ extra: [String: Any] = [:]
) async {
guard !addressees.isEmpty else { return }
log("push(\(kind)): publishing to \(addressees.count) addressee(s)")
@@ -154,7 +155,7 @@ final class PushClient {
if let payload = addressee.payload?.encodedString() { entry["payload"] = payload }
return entry
}
- let payload: [String: Any] = [
+ var payload: [String: Any] = [
"kind": kind,
"gameID": gameID.uuidString,
"fromAuthorID": authorID ?? "",
@@ -164,6 +165,9 @@ final class PushClient {
"addressees": addresseePayloads,
"background": background
]
+ for (key, value) in extra {
+ payload[key] = value
+ }
var request = URLRequest(url: baseURL.appendingPathComponent("publish"))
request.httpMethod = "POST"
applyAuth(&request)
@@ -179,6 +183,34 @@ final class PushClient {
}
}
+ func publishAccountEvent(
+ kind: String,
+ gameID: UUID,
+ address: String,
+ readAt: Date? = nil
+ ) async {
+ var extra: [String: Any] = [:]
+ if let readAt {
+ extra["readAt"] = Self.iso8601.string(from: readAt)
+ }
+ await publish(
+ kind: kind,
+ gameID: gameID,
+ addressees: [Addressee(address: address)],
+ title: "",
+ body: "",
+ background: true,
+ extra: extra
+ )
+ }
+
+ private static let iso8601: ISO8601DateFormatter = {
+ let formatter = ISO8601DateFormatter()
+ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
+ formatter.timeZone = TimeZone(secondsFromGMT: 0)
+ return formatter
+ }()
+
private func register(token: String, addresses: [String]) async throws {
var request = URLRequest(url: baseURL.appendingPathComponent("register"))
request.httpMethod = "POST"
diff --git a/Crossmate/Sync/RecordApplier.swift b/Crossmate/Sync/RecordApplier.swift
@@ -18,6 +18,8 @@ struct BatchEffects {
/// Games for which an inbound `Journal` record landed — wakes a waiting
/// finish-banner replay to re-check completeness.
var journalsSynced = Set<UUID>()
+ /// Account-level push address decisions seen in the private account zone.
+ var accountPushAddresses: [String] = []
}
extension SyncEngine {
diff --git a/Crossmate/Sync/RecordBuilder.swift b/Crossmate/Sync/RecordBuilder.swift
@@ -9,7 +9,8 @@ extension SyncEngine {
/// since the framework calls back synchronously off-actor.
nonisolated func buildRecord(
for recordID: CKRecord.ID,
- pings: [String: PingPayload]
+ pings: [String: PingPayload],
+ decisions: [String: String]
) -> CKRecord? {
let name = recordID.recordName
let zoneID = recordID.zoneID
@@ -32,7 +33,12 @@ extension SyncEngine {
guard let (kind, key) = RecordSerializer.parseDecisionRecordName(name) else {
return nil
}
- return RecordSerializer.decisionRecord(kind: kind, key: key, zone: zoneID)
+ return RecordSerializer.decisionRecord(
+ kind: kind,
+ key: key,
+ payload: decisions[name],
+ zone: zoneID
+ )
}
let ctx = persistence.container.newBackgroundContext()
return ctx.performAndWait {
diff --git a/Crossmate/Sync/RecordSerializer.swift b/Crossmate/Sync/RecordSerializer.swift
@@ -98,6 +98,23 @@ enum RecordSerializer {
return (kind, key)
}
+ static let accountDecisionKind = "account"
+ static let accountPushAddressDecisionKey = "pushAddress"
+
+ static var accountPushAddressDecisionName: String {
+ decisionRecordName(kind: accountDecisionKind, key: accountPushAddressDecisionKey)
+ }
+
+ static func parseAccountPushAddressDecision(_ record: CKRecord) -> String? {
+ guard record.recordType == "Decision",
+ record.recordID.recordName == accountPushAddressDecisionName,
+ (record["kind"] as? String) == accountDecisionKind,
+ let address = record["payload"] as? String,
+ !address.isEmpty
+ else { return nil }
+ return address
+ }
+
// MARK: - Zone
/// Zone ID for a per-game zone. `ownerName` defaults to the current user
diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift
@@ -56,6 +56,7 @@ actor SyncEngine {
/// backing — they're write-once-and-forget — so we stash the minimal data
/// here keyed by record name and look it up in `buildRecord`.
private var pendingPings: [String: PingPayload] = [:]
+ private var pendingDecisionPayloads: [String: String] = [:]
struct PingPayload {
let gameID: UUID
@@ -111,6 +112,7 @@ actor SyncEngine {
/// the account's read horizon; active sessions may move it into the near
/// future and later close it with a lower current-time value.
var onIncomingReadCursor: (@MainActor @Sendable ([(UUID, Date)]) async -> Void)?
+ var onAccountPushAddress: (@MainActor @Sendable (String) async -> Void)?
private var localAuthorIDProvider: (@MainActor @Sendable () -> String?)?
private var tracer: (@MainActor @Sendable (String) -> Void)?
/// Fires when a delegate event reports a successful round-trip with the
@@ -194,6 +196,10 @@ actor SyncEngine {
onIncomingReadCursor = cb
}
+ func setOnAccountPushAddress(_ cb: @MainActor @Sendable @escaping (String) async -> Void) {
+ onAccountPushAddress = cb
+ }
+
func setLocalAuthorIDProvider(_ cb: @MainActor @Sendable @escaping () -> String?) {
localAuthorIDProvider = cb
}
@@ -797,13 +803,16 @@ actor SyncEngine {
/// survives an app kill (CKSyncEngine persists the pending change and
/// `buildRecord` rebuilds it deterministically). Idempotent — a re-send of
/// an existing decision is a benign conflict the send path drops.
- func enqueueDecision(kind: String, key: String) {
+ func enqueueDecision(kind: String, key: String, payload: String? = nil) {
guard let engine = privateEngine else { return }
let zoneID = RecordSerializer.accountZoneID
// CKSyncEngine dedupes redundant saveZone requests, so it's safe to
// repeat — block may be the first thing ever written to this zone.
engine.state.add(pendingDatabaseChanges: [.saveZone(CKRecordZone(zoneID: zoneID))])
let name = RecordSerializer.decisionRecordName(kind: kind, key: key)
+ if let payload {
+ pendingDecisionPayloads[name] = payload
+ }
let recordID = CKRecord.ID(recordName: name, zoneID: zoneID)
engine.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)])
sendChangesDetached(on: engine)
@@ -1021,6 +1030,7 @@ actor SyncEngine {
delegate: self
))
pendingPings = [:]
+ pendingDecisionPayloads = [:]
pingPushCheckpoints = [:]
seenPingRecords = [:]
liveQueryCheckpoints = [:]
@@ -1242,6 +1252,9 @@ actor SyncEngine {
effects.pings.append(ping)
}
case "Decision":
+ if let address = RecordSerializer.parseAccountPushAddressDecision(record) {
+ effects.accountPushAddresses.append(address)
+ }
let wrote = RecordSerializer.applyDecisionRecord(
record,
to: ctx,
@@ -1324,6 +1337,11 @@ actor SyncEngine {
if let onPings, !effects.pings.isEmpty {
await onPings(effects.pings)
}
+ if let onAccountPushAddress {
+ for address in effects.accountPushAddresses {
+ await onAccountPushAddress(address)
+ }
+ }
for id in effects.removed {
if let cb = onGameRemoved { await cb(id) }
}
@@ -1421,16 +1439,19 @@ actor SyncEngine {
let name = record.recordID.recordName
if name.hasPrefix("ping-") {
pendingPings.removeValue(forKey: name)
+ } else if name.hasPrefix("decision-") {
+ pendingDecisionPayloads.removeValue(forKey: name)
}
}
let ctx = persistence.container.newBackgroundContext()
ctx.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
- let (failureMessages, orphanedZones, resolvedDecisions, settledJournals):
- ([String], Set<CKRecordZone.ID>, Set<CKRecord.ID>, Set<CKRecord.ID>) = ctx.performAndWait {
+ let (failureMessages, orphanedZones, resolvedDecisions, settledJournals, resolvedAccountAddresses):
+ ([String], Set<CKRecordZone.ID>, Set<CKRecord.ID>, Set<CKRecord.ID>, [String]) = ctx.performAndWait {
var messages: [String] = []
var orphaned = Set<CKRecordZone.ID>()
var settledDecisions = Set<CKRecord.ID>()
var settledJournals = Set<CKRecord.ID>()
+ var accountAddresses: [String] = []
for record in event.savedRecords {
self.writeBackSystemFields(record: record, in: ctx)
let savedName = record.recordID.recordName
@@ -1466,6 +1487,10 @@ actor SyncEngine {
// so this is success — drop the pending change so the
// immutable record doesn't retry-loop forever.
settledDecisions.insert(failure.record.recordID)
+ if let serverRecord = err.userInfo[CKRecordChangedErrorServerRecordKey] as? CKRecord,
+ let address = RecordSerializer.parseAccountPushAddressDecision(serverRecord) {
+ accountAddresses.append(address)
+ }
settled = true
messages.append("send: decision \(name) already present — settled")
} else if name.hasPrefix("journal-"),
@@ -1509,7 +1534,7 @@ actor SyncEngine {
)
}
}
- return (messages, orphaned, settledDecisions, settledJournals)
+ return (messages, orphaned, settledDecisions, settledJournals, accountAddresses)
}
if !orphanedZones.isEmpty {
await applyZoneOrphaning(orphanedZones, isPrivate: isPrivate)
@@ -1518,6 +1543,14 @@ actor SyncEngine {
privateEngine.state.remove(
pendingRecordZoneChanges: resolvedDecisions.map { .saveRecord($0) }
)
+ for recordID in resolvedDecisions {
+ pendingDecisionPayloads.removeValue(forKey: recordID.recordName)
+ }
+ }
+ if let onAccountPushAddress {
+ for address in resolvedAccountAddresses {
+ await onAccountPushAddress(address)
+ }
}
if !settledJournals.isEmpty {
// Drop from whichever engine owns the zone (private for solo games,
@@ -1767,9 +1800,14 @@ extension SyncEngine: CKSyncEngineDelegate {
let pending = engine.state.pendingRecordZoneChanges
guard !pending.isEmpty else { return nil }
let pingSnapshot = pendingPings
+ let decisionSnapshot = pendingDecisionPayloads
return await CKSyncEngine.RecordZoneChangeBatch(pendingChanges: pending) { [weak self] recordID in
guard let self else { return nil }
- if let record = self.buildRecord(for: recordID, pings: pingSnapshot) {
+ if let record = self.buildRecord(
+ for: recordID,
+ pings: pingSnapshot,
+ decisions: decisionSnapshot
+ ) {
return record
}
engine.state.remove(pendingRecordZoneChanges: [.saveRecord(recordID)])
diff --git a/Worker/push-worker.js b/Worker/push-worker.js
@@ -84,6 +84,7 @@ export class PushRegistry {
gameID,
fromAuthorID,
senderDeviceID,
+ readAt,
title,
alertBody,
background
@@ -105,6 +106,8 @@ export class PushRegistry {
kind,
gameID,
fromAuthorID,
+ senderDeviceID,
+ readAt,
title,
body: target.body || alertBody,
payload: target.payload,
@@ -165,6 +168,8 @@ export class PushRegistry {
};
if (message.gameID) apnsPayload.gameID = message.gameID;
if (message.fromAuthorID) apnsPayload.fromAuthorID = message.fromAuthorID;
+ if (message.senderDeviceID) apnsPayload.senderDeviceID = message.senderDeviceID;
+ if (message.readAt) apnsPayload.readAt = message.readAt;
// Forward the opaque app payload verbatim when present. Absent for older
// app builds, which the extension handles by falling back to `kind`.
if (message.payload) apnsPayload.payload = message.payload;