commit a912de458b584f23297fa919b410c41a7e34505b
parent 9cbc8c816576adf63eabaca7c09ddd32733f6b2b
Author: Michael Camilleri <[email protected]>
Date: Wed, 3 Jun 2026 15:36:02 +0900
Nudge absent devices to upload replay journals
Replay assembly is strict about completeness: every device that wrote
Moves for a finished game must upload its immutable journal before
replays can be watched shown. The completion path already uploads the
finishing device's journal and an absent peer eventually uploads when it
learns about the completed Game record, but that leaves the finisher
waiting until the other device happens to sync. That wait is especially
visible when the peer device is suspended.
This commit adds a quiet APNs event, .replay, as a best-effort wakeup
for that gap. A completion still sends the existing visible .win /
.resign push to the other author, then sends a background .replay push
addressed to every author in the game. The receiver treats .replay as
maintenance: fetch changes, refresh local state and run the existing
pending-journal reconciliation so any local journal is flushed and
enqueued through the normal CKSyncEngine path. The event deliberately
does not mark the game unread or affect the badge.
In addition, the push worker also accepts a background flag and maps it
to a proper background APN (content-available, push type background,
priority 5), leaving existing alert pushes unchanged.
Diffstat:
6 files changed, 94 insertions(+), 22 deletions(-)
diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift
@@ -64,7 +64,7 @@ struct CrossmateApp: App {
// MARK: - App Delegate
final class AppDelegate: NSObject, UIApplicationDelegate, @preconcurrency UNUserNotificationCenterDelegate, @unchecked Sendable {
- var onRemoteNotification: ((String, CKDatabase.Scope?, Bool) async -> Void)?
+ var onRemoteNotification: ((String, CKDatabase.Scope?, PushPayload.Event?, UUID?, 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
@@ -196,8 +196,10 @@ final class AppDelegate: NSObject, UIApplicationDelegate, @preconcurrency UNUser
) async -> UIBackgroundFetchResult {
let summary = AppServices.describePush(userInfo: userInfo)
let scope = AppServices.databaseScope(fromPush: userInfo)
+ let payload = PushPayload.decode(from: userInfo["payload"] as? String)
+ let gameID = Self.gameID(from: userInfo)
let isBackground = application.applicationState != .active
- await onRemoteNotification?(summary, scope, isBackground)
+ await onRemoteNotification?(summary, scope, payload?.event, gameID, isBackground)
return .newData
}
diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift
@@ -371,10 +371,12 @@ final class AppServices {
await refreshAppBadge()
importVisibleNotificationReceipts()
- appDelegate.onRemoteNotification = { summary, scope, isBackground in
+ appDelegate.onRemoteNotification = { summary, scope, event, gameID, isBackground in
await self.handleRemoteNotification(
summary: summary,
scope: scope,
+ event: event,
+ gameID: gameID,
isBackground: isBackground
)
}
@@ -689,6 +691,7 @@ final class AppServices {
/// stream (peers' grids fill in once the Moves push lands).
func sendCompletionPings(gameID: UUID, resigned: Bool) async {
await publishCompletionPush(gameID: gameID, resigned: resigned)
+ await publishReplayPush(gameID: gameID)
}
/// Ensures this device is registered with the push worker under the
@@ -764,7 +767,7 @@ final class AppServices {
syncMonitor.note("push(play): skipped (no pushClient)")
return
}
- let plan = pushPlan(forGameID: gameID, excluding: localAuthorID)
+ let plan = pushPlan(for: gameID, excluding: localAuthorID)
guard !plan.recipients.isEmpty else {
syncMonitor.note("push(play): skipped (no recipients)")
return
@@ -908,7 +911,7 @@ final class AppServices {
syncMonitor.note("push(pause): skipped (no pushClient)")
return
}
- let plan = pushPlan(forGameID: gameID, excluding: localAuthorID)
+ let plan = pushPlan(for: gameID, excluding: localAuthorID)
guard !plan.recipients.isEmpty else {
syncMonitor.note("push(pause): skipped (no recipients)")
return
@@ -983,7 +986,7 @@ final class AppServices {
syncMonitor.note("push(\(kindLabel)): skipped (no authorID)")
return
}
- let plan = pushPlan(forGameID: gameID, excluding: localAuthorID)
+ let plan = pushPlan(for: gameID, excluding: localAuthorID)
guard !plan.recipients.isEmpty else {
syncMonitor.note("push(\(kindLabel)): skipped (no recipients)")
return
@@ -1018,6 +1021,35 @@ final class AppServices {
)
}
+ private func publishReplayPush(gameID: UUID) async {
+ guard let pushClient else {
+ syncMonitor.note("push(replay): skipped (no pushClient)")
+ return
+ }
+ let plan = pushPlan(for: gameID)
+ guard !plan.recipients.isEmpty else {
+ syncMonitor.note("push(replay): skipped (no recipients)")
+ return
+ }
+ let addressees = plan.recipients.compactMap { recipient in
+ recipient.pushAddress.map {
+ PushClient.Addressee(address: $0, payload: PushPayload(event: .replay))
+ }
+ }
+ guard !addressees.isEmpty else {
+ syncMonitor.note("push(replay): skipped (no addressable recipients)")
+ return
+ }
+ await pushClient.publish(
+ kind: "replay",
+ gameID: gameID,
+ addressees: addressees,
+ title: "",
+ body: "",
+ background: true
+ )
+ }
+
/// Everything a sender-side push helper needs to know about a game in
/// one Core Data round-trip: the roster authors to notify (each with the
/// last-known `Player.readAt` so the pause path can compute a
@@ -1048,8 +1080,8 @@ final class AppServices {
}
private func pushPlan(
- forGameID gameID: UUID,
- excluding localAuthorID: String
+ for gameID: UUID,
+ excluding authorID: String? = nil
) -> PushPlan {
let ctx = persistence.container.newBackgroundContext()
return ctx.performAndWait {
@@ -1062,10 +1094,10 @@ final class AppServices {
pReq.predicate = NSPredicate(format: "game == %@", game)
for p in (try? ctx.fetch(pReq)) ?? [] {
guard let a = p.authorID,
- a != localAuthorID,
a != CKCurrentUserDefaultName,
!a.isEmpty
else { continue }
+ if let authorID, a == authorID { continue }
byAuthor[a] = (p.readAt, p.pushAddress)
}
let recipients = byAuthor.map {
@@ -1760,6 +1792,8 @@ final class AppServices {
private func handleRemoteNotification(
summary: String,
scope: CKDatabase.Scope?,
+ event: PushPayload.Event?,
+ gameID: UUID?,
isBackground: Bool
) async {
guard preferences.isICloudSyncEnabled else {
@@ -1770,6 +1804,17 @@ final class AppServices {
lastRemoteNotificationAt = Date()
syncMonitor.note("remote notification: \(summary)")
+ if event == .replay {
+ let label = gameID.map { String($0.uuidString.prefix(8)) } ?? "unknown"
+ syncMonitor.note("push(replay): syncing game \(label)")
+ await syncMonitor.run("replay push fetch") {
+ try await syncEngine.fetchChanges(source: "replay push")
+ }
+ await refreshSnapshot()
+ await reconcilePendingJournalUploads()
+ return
+ }
+
guard let scope, scope != .public else {
await syncMonitor.run("remote-notification fetch") {
try await syncEngine.fetchChanges(source: "push")
@@ -2533,7 +2578,9 @@ final class AppServices {
/// 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 = (userInfo["kind"] as? String) ?? "<nil>"
+ let gameID = (userInfo["gameID"] as? String) ?? "<nil>"
+ return "custom kind=\(kind) gameID=\(gameID)"
}
let kind: String
let scope: CKDatabase.Scope?
diff --git a/Crossmate/Services/PushClient.swift b/Crossmate/Services/PushClient.swift
@@ -143,7 +143,8 @@ final class PushClient {
gameID: UUID,
addressees: [Addressee],
title: String,
- body: String
+ body: String,
+ background: Bool = false
) async {
guard !addressees.isEmpty else { return }
log("push(\(kind)): publishing to \(addressees.count) addressee(s)")
@@ -157,9 +158,11 @@ final class PushClient {
"kind": kind,
"gameID": gameID.uuidString,
"fromAuthorID": authorID ?? "",
+ "senderDeviceID": deviceID,
"title": title,
"alertBody": body,
- "addressees": addresseePayloads
+ "addressees": addresseePayloads,
+ "background": background
]
var request = URLRequest(url: baseURL.appendingPathComponent("publish"))
request.httpMethod = "POST"
diff --git a/Shared/PushPayload.swift b/Shared/PushPayload.swift
@@ -115,6 +115,7 @@ struct PushPayload: Codable, Sendable, Equatable {
case pause(fills: Int, clears: Int, checks: Int, reveals: Int)
case win
case resign
+ case replay
/// An event introduced by a newer build. Treated as carrying no
/// unseen content for badge purposes.
case unknown
@@ -129,7 +130,7 @@ struct PushPayload: Codable, Sendable, Equatable {
case .pause(let fills, let clears, let checks, let reveals):
return fills + clears + checks + reveals > 0
case .win, .resign: return true
- case .play, .unknown: return false
+ case .play, .replay, .unknown: return false
}
}
}
@@ -162,7 +163,7 @@ extension PushPayload.Event: Codable {
}
private enum Discriminator: String {
- case play, pause, win, resign
+ case play, pause, win, resign, replay
}
init(from decoder: Decoder) throws {
@@ -181,6 +182,8 @@ extension PushPayload.Event: Codable {
self = .win
case .resign:
self = .resign
+ case .replay:
+ self = .replay
case nil:
// A discriminator this build doesn't know — a newer sender.
self = .unknown
@@ -202,6 +205,8 @@ extension PushPayload.Event: Codable {
try container.encode(Discriminator.win.rawValue, forKey: .type)
case .resign:
try container.encode(Discriminator.resign.rawValue, forKey: .type)
+ case .replay:
+ try container.encode(Discriminator.replay.rawValue, forKey: .type)
case .unknown:
// Not produced as an outgoing event by this build; encode a stable
// marker so an `.unknown` round-trips back to `.unknown`.
diff --git a/Tests/Unit/PushPayloadTests.swift b/Tests/Unit/PushPayloadTests.swift
@@ -18,7 +18,8 @@ struct PushPayloadTests {
.pause(fills: 0, clears: 0, checks: 0, reveals: 0),
.pause(fills: 0, clears: 0, checks: 0, reveals: 2),
.win,
- .resign
+ .resign,
+ .replay
]
for event in cases {
let decoded = try roundTrip(PushPayload(event: event))
@@ -126,6 +127,7 @@ struct PushPayloadTests {
#expect(!PushPayload(event: .pause(fills: 0, clears: 0, checks: 0, reveals: 0)).marksUnread)
#expect(!PushPayload(event: .play).marksUnread)
+ #expect(!PushPayload(event: .replay).marksUnread)
#expect(!PushPayload(event: .unknown).marksUnread)
}
}
diff --git a/Worker/push-worker.js b/Worker/push-worker.js
@@ -78,12 +78,21 @@ export class PushRegistry {
async handlePublish(request) {
const body = await readJSON(request);
if (!body) return badRequest("Body must be JSON");
- const { kind, addressees, gameID, fromAuthorID, title, alertBody } = body;
+ const {
+ kind,
+ addressees,
+ gameID,
+ fromAuthorID,
+ senderDeviceID,
+ title,
+ alertBody,
+ background
+ } = body;
if (!kind || !Array.isArray(addressees) || addressees.length === 0) {
return badRequest("kind and non-empty addressees required");
}
- const targets = await this.resolveTargets(addressees);
+ const targets = await this.resolveTargets(addressees, senderDeviceID);
if (targets.length === 0) {
return Response.json({ delivered: 0, removed: 0, failed: 0 });
}
@@ -98,7 +107,8 @@ export class PushRegistry {
fromAuthorID,
title,
body: target.body || alertBody,
- payload: target.payload
+ payload: target.payload,
+ background: background === true
});
if (result === "ok") delivered += 1;
else if (result === "drop") {
@@ -111,7 +121,7 @@ export class PushRegistry {
return Response.json({ delivered, removed, failed });
}
- async resolveTargets(addressees) {
+ async resolveTargets(addressees, senderDeviceID) {
const targets = [];
for (const addressee of addressees) {
if (!addressee || !addressee.address) continue;
@@ -125,6 +135,7 @@ export class PushRegistry {
const map = await this.state.storage.list({ prefix });
for (const [key, value] of map) {
const deviceID = key.slice(prefix.length);
+ if (senderDeviceID && deviceID === senderDeviceID) continue;
targets.push({
address: addressee.address,
deviceID,
@@ -147,7 +158,9 @@ export class PushRegistry {
if (message.title) alert.title = message.title;
if (message.body) alert.body = message.body;
const apnsPayload = {
- aps: { alert, sound: "default", "mutable-content": 1 },
+ aps: message.background
+ ? { "content-available": 1 }
+ : { alert, sound: "default", "mutable-content": 1 },
kind: message.kind
};
if (message.gameID) apnsPayload.gameID = message.gameID;
@@ -161,8 +174,8 @@ export class PushRegistry {
headers: {
authorization: `bearer ${jwt}`,
"apns-topic": topic,
- "apns-push-type": "alert",
- "apns-priority": "10",
+ "apns-push-type": message.background ? "background" : "alert",
+ "apns-priority": message.background ? "5" : "10",
"apns-expiration": "0",
"content-type": "application/json"
},