commit 86db1e47efd45af205e2d3a872b92da3cf8a8120
parent 24c45b27add4882006c862fe81c0661dc55fd25e
Author: Michael Camilleri <[email protected]>
Date: Wed, 10 Jun 2026 17:59:34 +0900
Extract account push credentials and the replay cache from AppServices
This commit extracts two final parts from the AppServices split:
- AccountPushCoordinator: minting, caching, and rotating the account
push secret and address, the Decision publishes that converge them
across the account's devices, and push-worker registration
(reconcilePushRegistration / setDerivedPushAddress). The inbound
setOnAccountPushAddress/Secret handler bodies move in as
adoptInboundPushAddress/Secret so the version-gating logic lives with
the state it guards; AppServices keeps thin callback wiring. This also
stages the open TODO item to move the secret to the Keychain — that
change now lands in one focused type.
- ReplayLoader: loadReplay plus the per-session assembled-timeline
cache, depending only on the store, sync engine, and monitor.
The code is moved verbatim with method names unchanged.
Co-Authored-By: Claude Fable 5 <[email protected]>
Diffstat:
6 files changed, 440 insertions(+), 350 deletions(-)
diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj
@@ -125,6 +125,7 @@
C89A15D812E372FE1C56039B /* PUZToXDConverterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE193CAB325C991952D7CE5 /* PUZToXDConverterTests.swift */; };
C8ACF431021E7BEE61A99153 /* FriendController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E655698481325C92EF5C348B /* FriendController.swift */; };
C944A5BD871C6ECC64DE8A5B /* CalendarDayCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0CAA5E17BD406AFEEF96196 /* CalendarDayCell.swift */; };
+ C9864C9940C9DAAD0A788094 /* ReplayLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FFD574AC2D0A910053E2A73 /* ReplayLoader.swift */; };
CABF8BFAA30B9F26C482FAB9 /* EngagementCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8FDE03B4A77A8095ED2C23AB /* EngagementCoordinator.swift */; };
CC250D6BA9B41CB722D8A62E /* CloudService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56BC76178319D0D669CD50FF /* CloudService.swift */; };
CCF3867C32C3F36E4F69A59E /* DebuggingMonitors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16E1DA8C1B4E73AFB779CC06 /* DebuggingMonitors.swift */; };
@@ -150,6 +151,7 @@
E454051BA4797000C8AD2B48 /* MovesCodecLegacyDecodeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B33C21324E1474BCC126AA0 /* MovesCodecLegacyDecodeTests.swift */; };
E632562D090D8BE907F28C53 /* NotificationStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47532AED239AEF476D8E9206 /* NotificationStateTests.swift */; };
E91FB8101E1927CA567DE825 /* PuzzleSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7AFD37B03A1C2E23E5766E6 /* PuzzleSource.swift */; };
+ EA0AA522F6C383034C4572F4 /* AccountPushCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03D59EA74A4BA084AD97478D /* AccountPushCoordinator.swift */; };
EB6E99226D5EE27668787008 /* BadgeCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D5B1E8E12B86DF6CA478F65 /* BadgeCoordinator.swift */; };
ECC1A5C3623F50B67185CFFB /* RecordSerializerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E4DEAF9F7887CBB46A99E8E /* RecordSerializerTests.swift */; };
ED6C21CD9F5AB286B69A02E4 /* GridStateMerger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14B05C19BD4705876B3DF0EC /* GridStateMerger.swift */; };
@@ -194,6 +196,7 @@
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
+ 03D59EA74A4BA084AD97478D /* AccountPushCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountPushCoordinator.swift; sourceTree = "<group>"; };
07C57DEE9E0EFA684D8BD00B /* NYTLoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTLoginView.swift; sourceTree = "<group>"; };
07E5E4B165E374FEE732068B /* CloudDiagnostics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudDiagnostics.swift; sourceTree = "<group>"; };
08E8592B1CB1336E63498706 /* PeerPresenceGraceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeerPresenceGraceTests.swift; sourceTree = "<group>"; };
@@ -228,6 +231,7 @@
3292748EAE27B608C769D393 /* PlayerRoster.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerRoster.swift; sourceTree = "<group>"; };
33878A29B09A6154C7A63C82 /* KeychainHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainHelper.swift; sourceTree = "<group>"; };
38DDAD9D6470A894C3FD6F90 /* GameListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameListView.swift; sourceTree = "<group>"; };
+ 3FFD574AC2D0A910053E2A73 /* ReplayLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReplayLoader.swift; sourceTree = "<group>"; };
400B3C87248F3FCA3F76400B /* EngagementHostEnvironment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EngagementHostEnvironment.swift; sourceTree = "<group>"; };
434862125EC5C0C0F3717ECA /* DiagnosticsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiagnosticsView.swift; sourceTree = "<group>"; };
43DC132D49361C56DE79C13E /* GameMutator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameMutator.swift; sourceTree = "<group>"; };
@@ -599,6 +603,7 @@
D8F0E3376B2616B4E917129C /* Services */ = {
isa = PBXGroup;
children = (
+ 03D59EA74A4BA084AD97478D /* AccountPushCoordinator.swift */,
1D3ECD0DE71BE567BCEE15F6 /* AnnouncementCenter.swift */,
CBDC81CA6A9C80EB31E7F493 /* AppServices.swift */,
2D5B1E8E12B86DF6CA478F65 /* BadgeCoordinator.swift */,
@@ -619,6 +624,7 @@
71DFD035381B6252DCD873C9 /* PlayerNamePublisher.swift */,
8A9F9E7ED4E1AF02F0C71051 /* PushClient.swift */,
B369788E0FEA0DCE1B125816 /* PUZToXDConverter.swift */,
+ 3FFD574AC2D0A910053E2A73 /* ReplayLoader.swift */,
7A7BD8DFDB41BFBA694A0933 /* SessionCoordinator.swift */,
CA49DA9E0ADEB11A920787FA /* SessionPushPlanner.swift */,
);
@@ -814,6 +820,7 @@
buildActionMask = 2147483647;
files = (
AB05765D2C3F4841026344E5 /* AboutView.swift in Sources */,
+ EA0AA522F6C383034C4572F4 /* AccountPushCoordinator.swift in Sources */,
AD50D3E3401C7BF9ED012768 /* AnnouncementBanner.swift in Sources */,
5EFCD28B3B682DCCF38068D6 /* AnnouncementCenter.swift in Sources */,
78802AFDF6273231781CC0DC /* AppServices.swift in Sources */,
@@ -897,6 +904,7 @@
D5150033DB80810F93BE0B5F /* RecordEditorView.swift in Sources */,
CF0CA17ABE211DAE4DD35AFD /* RecordSerializer.swift in Sources */,
E1FBC33E3348547D4DF946C4 /* ReplayControls.swift in Sources */,
+ C9864C9940C9DAAD0A788094 /* ReplayLoader.swift in Sources */,
15768439C1783A1780FBB824 /* SessionCoordinator.swift in Sources */,
5ECF5B80D08E5E999A540782 /* SessionMonitor.swift in Sources */,
B48DE7079BE2F31D2367C5F7 /* SessionPushPlanner.swift in Sources */,
diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift
@@ -500,14 +500,14 @@ private struct PuzzleDisplayView: View {
// this is what stops rapid nav from re-running the merge
// each time a fresh `ReplayControls` instance asks for it.
return await ReplayAssembler.memoised(
- cached: services.cachedReplayTimeline(gameID: gameID),
+ cached: services.replays.cachedReplayTimeline(gameID: gameID),
onHit: { cached in
services.syncMonitor.note(
"replay[\(short)]: served from timeline memo " +
"(steps=\(cached.count))"
)
},
- store: { services.cacheReplayTimeline($0, gameID: gameID) }
+ store: { services.replays.cacheReplayTimeline($0, gameID: gameID) }
) {
// Local-first: if no *other* device wrote into this
// game, this device's journal is the whole history,
@@ -533,7 +533,7 @@ private struct PuzzleDisplayView: View {
"replay[\(short)]: merged path, " +
"otherDevices=\(otherDevices.count), localEntries=\(entries.count)"
)
- return await services.loadReplay(gameID: gameID)
+ return await services.replays.loadReplay(gameID: gameID)
}
}
)
@@ -836,7 +836,7 @@ private struct PuzzleDisplayView: View {
// Stamp this game's derived push address inside the burst so it ships on
// the same Player-record write as the read-cursor lease; registration of
// this device under it happens just after the burst.
- _ = services.setDerivedPushAddress(gameID: gameID, authorID: authorID)
+ _ = services.accountPush.setDerivedPushAddress(gameID: gameID, authorID: authorID)
await services.publishReadCursor(for: gameID, mode: .activeLease)
await services.playerNamePublisher?.publishName(for: gameID)
await selectionPublisher.begin(
@@ -853,7 +853,7 @@ private struct PuzzleDisplayView: View {
}
// Register this device under the (now-minted) address set so the
// just-opened game can deliver pushes here immediately.
- await services.reconcilePushRegistration()
+ await services.accountPush.reconcilePushRegistration()
await services.engagement.startEngagementIfPossible(gameID: gameID)
let services = self.services
let eventGameID = gameID
diff --git a/Crossmate/Services/AccountPushCoordinator.swift b/Crossmate/Services/AccountPushCoordinator.swift
@@ -0,0 +1,267 @@
+import Foundation
+
+/// Owns the account-scoped push credentials and worker registration that used
+/// to live in `AppServices`: minting, caching, and rotating the account push
+/// secret (the HMAC key per-game addresses derive from) and the account push
+/// address, publishing both as `Decision` records so the account's devices
+/// converge, adopting a sibling's inbound copies under version gating, and
+/// keeping this device registered with the push worker for every shared game.
+@MainActor
+final class AccountPushCoordinator {
+ private static let accountPushAddressDefaultsPrefix = "push.accountAddress."
+ private static let accountPushSecretDefaultsPrefix = "push.accountSecret."
+ /// Generation of the locally-held push secret. Bumped on a deliberate
+ /// rotation and tracked so a stale inbound copy can't supersede the current
+ /// value (see `RecordSerializer.decisionVersion`).
+ private static let accountPushSecretVersionDefaultsPrefix = "push.accountSecretVersion."
+ static let accountJoinedPushKind = "accountJoined"
+ static let accountSeenPushKind = "accountSeen"
+
+ private let identity: AuthorIdentity
+ private let preferences: PlayerPreferences
+ private let store: GameStore
+ private let syncEngine: SyncEngine
+ private let syncMonitor: SyncMonitor
+ private let pushClient: PushClient?
+
+ init(
+ identity: AuthorIdentity,
+ preferences: PlayerPreferences,
+ store: GameStore,
+ syncEngine: SyncEngine,
+ syncMonitor: SyncMonitor,
+ pushClient: PushClient?
+ ) {
+ self.identity = identity
+ self.preferences = preferences
+ self.store = store
+ self.syncEngine = syncEngine
+ self.syncMonitor = syncMonitor
+ self.pushClient = pushClient
+ }
+
+ /// Adopts an account push address learned from a sibling device's
+ /// `Decision` record, then re-reconciles so this device registers under
+ /// the converged address.
+ func adoptInboundPushAddress(_ address: String) async {
+ guard let authorID = identity.currentID, !authorID.isEmpty else { return }
+ cacheAccountPushAddress(address, authorID: authorID)
+ await reconcilePushRegistration()
+ }
+
+ /// Adopts an account push secret learned from a sibling device's
+ /// `Decision` record. Gate adoption on the generation: take a strictly
+ /// newer secret (a rotation), or an equal-generation one that differs
+ /// (the mint-race loser converging on the server's value). Ignore an
+ /// older copy so a late-arriving stale Decision can't undo a rotation.
+ /// Adopting changes every derived address, so reconcile re-derives,
+ /// rewrites the local Player rows, and re-registers the new set with the
+ /// worker.
+ func adoptInboundPushSecret(_ secret: String, version: Int64) async {
+ guard let authorID = identity.currentID, !authorID.isEmpty else { return }
+ let local = accountPushSecretVersion(authorID: authorID)
+ let current = UserDefaults.standard.string(
+ forKey: accountPushSecretDefaultsKey(authorID: authorID)
+ )
+ guard version > local || (version == local && secret != current) else { return }
+ cacheAccountPushSecret(secret, version: version, authorID: authorID)
+ await reconcilePushRegistration()
+ }
+
+ /// Ensures this device is registered with the push worker under the
+ /// account's per-game push address for every shared game, minting and
+ /// publishing any address that doesn't exist yet so peers learn where to
+ /// reach this account. Deduped inside `PushClient`, so it's cheap to call
+ /// repeatedly — on launch (sync ready), when a shared game appears
+ /// (inbound Player records), and on account switch. This is what makes a
+ /// push reach *all* of the account's devices, not just the one a game was
+ /// opened on, and survive an APNs token rotation.
+ 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 secret = ensureAccountPushSecret(authorID: authorID)
+ let result = store.reconcileLocalPushAddresses(authorID: authorID, secret: secret)
+ for gameID in result.republishGameIDs {
+ await syncEngine.enqueuePlayer(
+ gameID: gameID,
+ authorID: authorID,
+ reason: "pushAddress"
+ )
+ }
+ pushClient.setAddresses(result.addresses.union([accountAddress]))
+ }
+
+ /// Stamps `gameID`'s local Player row with the derived push address inside
+ /// the puzzle-open send burst, so it ships on the same Player-record write
+ /// as the read-cursor lease and display name.
+ @discardableResult
+ func setDerivedPushAddress(gameID: UUID, authorID: String) -> String? {
+ let secret = ensureAccountPushSecret(authorID: authorID)
+ return store.setPushAddress(gameID: gameID, authorID: authorID, secret: secret)
+ }
+
+ private func ensureAccountPushAddress(authorID: String) -> String {
+ let key = accountPushAddressDefaultsKey(authorID: authorID)
+ if let existing = UserDefaults.standard.string(forKey: key), !existing.isEmpty {
+ // Already minted and published (or learned from a sibling). The
+ // Decision write is durable across launches via CKSyncEngine's
+ // pending-change queue and convergence rides the LWW conflict
+ // callback, so there's nothing to re-assert here — re-publishing
+ // would stamp a fresh `createdAt` and re-upload the record on every
+ // `accountSeen`/reconcile for a value that never changes.
+ 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
+ }
+
+ /// Mints (if needed) the account-wide push secret — the HMAC key every
+ /// per-game push address is derived from. Converges across the account's own
+ /// devices through a `Decision` exactly like the account address; never sent
+ /// to peers or the worker, so only the account's devices can derive. A fresh
+ /// mint starts at `decisionBaseVersion`; a deliberate replacement goes
+ /// through `rotateAccountPushSecret`, which bumps the generation so it
+ /// supersedes the converged value.
+ private func ensureAccountPushSecret(authorID: String) -> String {
+ let key = accountPushSecretDefaultsKey(authorID: authorID)
+ if let existing = UserDefaults.standard.string(forKey: key), !existing.isEmpty {
+ return existing
+ }
+ let secret = Self.generatePushSecret()
+ let version = RecordSerializer.decisionBaseVersion
+ cacheAccountPushSecret(secret, version: version, authorID: authorID)
+ publishAccountPushSecretDecision(secret, version: version)
+ return secret
+ }
+
+ /// Rotates the account-wide push secret to a fresh value at the next
+ /// generation. The bumped version lets the new secret overwrite the existing
+ /// `Decision` (otherwise an equal-generation write converges back onto the
+ /// server's value); every one of the account's devices adopts it inbound and
+ /// re-derives its per-game addresses. Safe to call when nothing is minted yet
+ /// — it simply mints the first generation.
+ func rotateAccountPushSecret() {
+ guard let authorID = identity.currentID, !authorID.isEmpty else { return }
+ let secret = Self.generatePushSecret()
+ let version = accountPushSecretVersion(authorID: authorID) + 1
+ cacheAccountPushSecret(secret, version: version, authorID: authorID)
+ publishAccountPushSecretDecision(secret, version: version)
+ Task { @MainActor [weak self] in
+ await self?.reconcilePushRegistration()
+ }
+ }
+
+ private static func generatePushSecret() -> String {
+ var bytes = [UInt8](repeating: 0, count: 32)
+ _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
+ return Data(bytes).base64EncodedString()
+ .replacingOccurrences(of: "+", with: "-")
+ .replacingOccurrences(of: "/", with: "_")
+ .replacingOccurrences(of: "=", with: "")
+ }
+
+ private func cacheAccountPushSecret(_ secret: String, version: Int64, authorID: String) {
+ guard !secret.isEmpty else { return }
+ UserDefaults.standard.set(secret, forKey: accountPushSecretDefaultsKey(authorID: authorID))
+ UserDefaults.standard.set(version, forKey: accountPushSecretVersionDefaultsKey(authorID: authorID))
+ }
+
+ private func accountPushSecretDefaultsKey(authorID: String) -> String {
+ Self.accountPushSecretDefaultsPrefix + authorID
+ }
+
+ private func accountPushSecretVersionDefaultsKey(authorID: String) -> String {
+ Self.accountPushSecretVersionDefaultsPrefix + authorID
+ }
+
+ /// Generation of the locally-held push secret. Defaults to
+ /// `decisionBaseVersion` when a secret exists without a stored version (a
+ /// value cached by the pre-rotation build) and to one below that when no
+ /// secret is cached at all, so a first mint or any inbound copy supersedes it.
+ private func accountPushSecretVersion(authorID: String) -> Int64 {
+ let defaults = UserDefaults.standard
+ if let stored = defaults.object(
+ forKey: accountPushSecretVersionDefaultsKey(authorID: authorID)
+ ) as? NSNumber {
+ return stored.int64Value
+ }
+ let secret = defaults.string(forKey: accountPushSecretDefaultsKey(authorID: authorID))
+ return (secret ?? "").isEmpty
+ ? RecordSerializer.decisionBaseVersion - 1
+ : RecordSerializer.decisionBaseVersion
+ }
+
+ /// True once both the account push secret and address are cached for this
+ /// account — i.e. `ensureAccountPushSecret`/`ensureAccountPushAddress` would
+ /// hit their early returns rather than mint. Used at startup to decide
+ /// whether a pre-reconcile fetch is needed to adopt a sibling's value first.
+ func hasCachedAccountPushCredentials(authorID: String) -> Bool {
+ let defaults = UserDefaults.standard
+ let secret = defaults.string(forKey: accountPushSecretDefaultsKey(authorID: authorID))
+ let address = defaults.string(forKey: accountPushAddressDefaultsKey(authorID: authorID))
+ return !(secret ?? "").isEmpty && !(address ?? "").isEmpty
+ }
+
+ private func publishAccountPushSecretDecision(_ secret: String, version: Int64) {
+ Task.detached { [syncEngine] in
+ await syncEngine.enqueueDecision(
+ kind: RecordSerializer.accountDecisionKind,
+ key: RecordSerializer.accountPushSecretDecisionKey,
+ payload: secret,
+ version: version
+ )
+ }
+ }
+
+ 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
+ )
+ }
+ }
+
+ func publishAccountJoinedPush(gameID: UUID) async {
+ await publishAccountEvent(kind: Self.accountJoinedPushKind, gameID: gameID)
+ }
+
+ 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
+ )
+ }
+}
diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift
@@ -13,14 +13,6 @@ final class AppServices {
private static let readLeaseDuration: TimeInterval = 10 * 60
private static let readLeaseRefreshFloor: TimeInterval = 5 * 60
- private static let accountPushAddressDefaultsPrefix = "push.accountAddress."
- private static let accountPushSecretDefaultsPrefix = "push.accountSecret."
- /// Generation of the locally-held push secret. Bumped on a deliberate
- /// rotation and tracked so a stale inbound copy can't supersede the current
- /// value (see `RecordSerializer.decisionVersion`).
- private static let accountPushSecretVersionDefaultsPrefix = "push.accountSecretVersion."
- private static let accountJoinedPushKind = "accountJoined"
- private static let accountSeenPushKind = "accountSeen"
enum FreshenReason {
case appeared
@@ -56,6 +48,13 @@ final class AppServices {
/// Per-game play-session lifecycle: begin/end grace timers, sender-side
/// session pushes, and the catch-up banner. See `SessionCoordinator`.
let sessions: SessionCoordinator
+ /// Account-scoped push credentials (secret/address mint, rotation,
+ /// inbound adoption) + push-worker registration; see
+ /// `AccountPushCoordinator`.
+ let accountPush: AccountPushCoordinator
+ /// Finished-game replay loading and the per-session timeline cache; see
+ /// `ReplayLoader`.
+ let replays: ReplayLoader
let shareController: ShareController
let friendController: FriendController
let gameArchiver: GameArchiver
@@ -93,7 +92,7 @@ final class AppServices {
syncMonitor: syncMonitor,
readLeaseDuration: Self.readLeaseDuration,
publishAccountSeenPush: { [weak self] gameID, readAt in
- await self?.publishAccountSeenPush(gameID: gameID, readAt: readAt)
+ await self?.accountPush.publishAccountSeenPush(gameID: gameID, readAt: readAt)
}
)
/// Friend-zone traffic — outbound invites, inbound ping handling, durable
@@ -289,6 +288,21 @@ final class AppServices {
pushClient: self.pushClient
)
+ self.accountPush = AccountPushCoordinator(
+ identity: identity,
+ preferences: preferences,
+ store: store,
+ syncEngine: syncEngine,
+ syncMonitor: self.syncMonitor,
+ pushClient: self.pushClient
+ )
+
+ self.replays = ReplayLoader(
+ store: store,
+ syncEngine: syncEngine,
+ syncMonitor: self.syncMonitor
+ )
+
self.shareController = ShareController(
container: self.ckContainer,
persistence: persistence,
@@ -448,7 +462,7 @@ final class AppServices {
// A newly-arrived shared game means a new address slot to mint and
// a token to register under it (so this device can receive the
// game's pushes without opening it first).
- await self?.reconcilePushRegistration()
+ await self?.accountPush.reconcilePushRegistration()
}
// An inbound Player record may have updated a peer's cursor track;
@@ -535,30 +549,11 @@ 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 self?.accountPush.adoptInboundPushAddress(address)
}
await syncEngine.setOnAccountPushSecret { [weak self] secret, version in
- guard let self,
- let authorID = self.identity.currentID,
- !authorID.isEmpty else { return }
- // Gate adoption on the generation: take a strictly newer secret (a
- // rotation), or an equal-generation one that differs (the mint-race
- // loser converging on the server's value). Ignore an older copy so a
- // late-arriving stale Decision can't undo a rotation. Adopting
- // changes every derived address, so reconcile re-derives, rewrites
- // the local Player rows, and re-registers the new set with the worker.
- let local = self.accountPushSecretVersion(authorID: authorID)
- let current = UserDefaults.standard.string(
- forKey: self.accountPushSecretDefaultsKey(authorID: authorID)
- )
- guard version > local || (version == local && secret != current) else { return }
- self.cacheAccountPushSecret(secret, version: version, authorID: authorID)
- await self.reconcilePushRegistration()
+ await self?.accountPush.adoptInboundPushSecret(secret, version: version)
}
shareController.onShareSaved = { [weak self] gameID in
@@ -567,7 +562,7 @@ final class AppServices {
// Register this device under the newly-shared game's derived push
// address so peers can reach it.
Task { @MainActor [weak self] in
- await self?.reconcilePushRegistration()
+ await self?.accountPush.reconcilePushRegistration()
}
// Register the app for notifications now that the user has chosen
// to collaborate. Surfaces the app in Settings > Notifications and
@@ -587,7 +582,7 @@ final class AppServices {
self.pushClient?.updateAuthorID(self.identity.currentID)
// Recompute the address set for the new account; addresses that
// belonged to the old account drop out and are unregistered.
- await self.reconcilePushRegistration()
+ await self.accountPush.reconcilePushRegistration()
}
await syncEngine.setOnGameAccessRevoked { [store, sessionMonitor, announcements, gameArchiver] gameID in
@@ -644,7 +639,7 @@ final class AppServices {
// Defer the sync enqueue out of the `onGameJoined` callback; the
// actual CKSyncEngine send drain remains detached in SyncEngine.
Task { @MainActor [weak self] in
- await self?.reconcilePushRegistration()
+ await self?.accountPush.reconcilePushRegistration()
}
}
@@ -670,8 +665,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)
+ await self.accountPush.reconcilePushRegistration()
+ await self.accountPush.publishAccountJoinedPush(gameID: gameID)
}
// PlayerNamePublisher fans out name changes to active shared/joined
@@ -798,203 +793,6 @@ final class AppServices {
}
}
- /// Ensures this device is registered with the push worker under the
- /// account's per-game push address for every shared game, minting and
- /// publishing any address that doesn't exist yet so peers learn where to
- /// reach this account. Deduped inside `PushClient`, so it's cheap to call
- /// repeatedly — on launch (sync ready), when a shared game appears
- /// (inbound Player records), and on account switch. This is what makes a
- /// push reach *all* of the account's devices, not just the one a game was
- /// opened on, and survive an APNs token rotation.
- 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 secret = ensureAccountPushSecret(authorID: authorID)
- let result = store.reconcileLocalPushAddresses(authorID: authorID, secret: secret)
- for gameID in result.republishGameIDs {
- await syncEngine.enqueuePlayer(
- gameID: gameID,
- authorID: authorID,
- reason: "pushAddress"
- )
- }
- pushClient.setAddresses(result.addresses.union([accountAddress]))
- }
-
- /// Stamps `gameID`'s local Player row with the derived push address inside
- /// the puzzle-open send burst, so it ships on the same Player-record write
- /// as the read-cursor lease and display name.
- @discardableResult
- func setDerivedPushAddress(gameID: UUID, authorID: String) -> String? {
- let secret = ensureAccountPushSecret(authorID: authorID)
- return store.setPushAddress(gameID: gameID, authorID: authorID, secret: secret)
- }
-
- private func ensureAccountPushAddress(authorID: String) -> String {
- let key = accountPushAddressDefaultsKey(authorID: authorID)
- if let existing = UserDefaults.standard.string(forKey: key), !existing.isEmpty {
- // Already minted and published (or learned from a sibling). The
- // Decision write is durable across launches via CKSyncEngine's
- // pending-change queue and convergence rides the LWW conflict
- // callback, so there's nothing to re-assert here — re-publishing
- // would stamp a fresh `createdAt` and re-upload the record on every
- // `accountSeen`/reconcile for a value that never changes.
- 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
- }
-
- /// Mints (if needed) the account-wide push secret — the HMAC key every
- /// per-game push address is derived from. Converges across the account's own
- /// devices through a `Decision` exactly like the account address; never sent
- /// to peers or the worker, so only the account's devices can derive. A fresh
- /// mint starts at `decisionBaseVersion`; a deliberate replacement goes
- /// through `rotateAccountPushSecret`, which bumps the generation so it
- /// supersedes the converged value.
- private func ensureAccountPushSecret(authorID: String) -> String {
- let key = accountPushSecretDefaultsKey(authorID: authorID)
- if let existing = UserDefaults.standard.string(forKey: key), !existing.isEmpty {
- return existing
- }
- let secret = Self.generatePushSecret()
- let version = RecordSerializer.decisionBaseVersion
- cacheAccountPushSecret(secret, version: version, authorID: authorID)
- publishAccountPushSecretDecision(secret, version: version)
- return secret
- }
-
- /// Rotates the account-wide push secret to a fresh value at the next
- /// generation. The bumped version lets the new secret overwrite the existing
- /// `Decision` (otherwise an equal-generation write converges back onto the
- /// server's value); every one of the account's devices adopts it inbound and
- /// re-derives its per-game addresses. Safe to call when nothing is minted yet
- /// — it simply mints the first generation.
- func rotateAccountPushSecret() {
- guard let authorID = identity.currentID, !authorID.isEmpty else { return }
- let secret = Self.generatePushSecret()
- let version = accountPushSecretVersion(authorID: authorID) + 1
- cacheAccountPushSecret(secret, version: version, authorID: authorID)
- publishAccountPushSecretDecision(secret, version: version)
- Task { @MainActor [weak self] in
- await self?.reconcilePushRegistration()
- }
- }
-
- private static func generatePushSecret() -> String {
- var bytes = [UInt8](repeating: 0, count: 32)
- _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
- return Data(bytes).base64EncodedString()
- .replacingOccurrences(of: "+", with: "-")
- .replacingOccurrences(of: "/", with: "_")
- .replacingOccurrences(of: "=", with: "")
- }
-
- private func cacheAccountPushSecret(_ secret: String, version: Int64, authorID: String) {
- guard !secret.isEmpty else { return }
- UserDefaults.standard.set(secret, forKey: accountPushSecretDefaultsKey(authorID: authorID))
- UserDefaults.standard.set(version, forKey: accountPushSecretVersionDefaultsKey(authorID: authorID))
- }
-
- private func accountPushSecretDefaultsKey(authorID: String) -> String {
- Self.accountPushSecretDefaultsPrefix + authorID
- }
-
- private func accountPushSecretVersionDefaultsKey(authorID: String) -> String {
- Self.accountPushSecretVersionDefaultsPrefix + authorID
- }
-
- /// Generation of the locally-held push secret. Defaults to
- /// `decisionBaseVersion` when a secret exists without a stored version (a
- /// value cached by the pre-rotation build) and to one below that when no
- /// secret is cached at all, so a first mint or any inbound copy supersedes it.
- private func accountPushSecretVersion(authorID: String) -> Int64 {
- let defaults = UserDefaults.standard
- if let stored = defaults.object(
- forKey: accountPushSecretVersionDefaultsKey(authorID: authorID)
- ) as? NSNumber {
- return stored.int64Value
- }
- let secret = defaults.string(forKey: accountPushSecretDefaultsKey(authorID: authorID))
- return (secret ?? "").isEmpty
- ? RecordSerializer.decisionBaseVersion - 1
- : RecordSerializer.decisionBaseVersion
- }
-
- /// True once both the account push secret and address are cached for this
- /// account — i.e. `ensureAccountPushSecret`/`ensureAccountPushAddress` would
- /// hit their early returns rather than mint. Used at startup to decide
- /// whether a pre-reconcile fetch is needed to adopt a sibling's value first.
- private func hasCachedAccountPushCredentials(authorID: String) -> Bool {
- let defaults = UserDefaults.standard
- let secret = defaults.string(forKey: accountPushSecretDefaultsKey(authorID: authorID))
- let address = defaults.string(forKey: accountPushAddressDefaultsKey(authorID: authorID))
- return !(secret ?? "").isEmpty && !(address ?? "").isEmpty
- }
-
- private func publishAccountPushSecretDecision(_ secret: String, version: Int64) {
- Task.detached { [syncEngine] in
- await syncEngine.enqueueDecision(
- kind: RecordSerializer.accountDecisionKind,
- key: RecordSerializer.accountPushSecretDecisionKey,
- payload: secret,
- version: version
- )
- }
- }
-
- 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
- )
- }
-
/// Pull-to-refresh action for the library. Discovers any zones the
/// device hasn't seen yet on both database scopes, then runs the normal
/// engine fetch so any in-flight changes also catch up. Bypasses
@@ -1515,7 +1313,7 @@ final class AppServices {
readAt: Date?
) async -> Bool {
guard let kind,
- kind == Self.accountJoinedPushKind || kind == Self.accountSeenPushKind
+ kind == AccountPushCoordinator.accountJoinedPushKind || kind == AccountPushCoordinator.accountSeenPushKind
else { return false }
if senderDeviceID == RecordSerializer.localDeviceID {
syncMonitor.note("push(\(kind)): ignored self-send")
@@ -1527,15 +1325,15 @@ final class AppServices {
}
switch kind {
- case Self.accountJoinedPushKind:
+ case AccountPushCoordinator.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 accountPush.reconcilePushRegistration()
await refreshSnapshot()
- case Self.accountSeenPushKind:
+ case AccountPushCoordinator.accountSeenPushKind:
guard let readAt else {
syncMonitor.note("push(accountSeen): ignored (no readAt)")
return true
@@ -1728,12 +1526,12 @@ final class AppServices {
// is unchanged. If the fetch throws, `run` swallows it and reconcile
// still runs (no worse than before).
if let authorID = identity.currentID, !authorID.isEmpty,
- !hasCachedAccountPushCredentials(authorID: authorID) {
+ !accountPush.hasCachedAccountPushCredentials(authorID: authorID) {
await syncMonitor.run("startup account sync") {
try await syncEngine.fetchChanges(source: "startup")
}
}
- await reconcilePushRegistration()
+ await accountPush.reconcilePushRegistration()
return true
}
syncStartTask = task
@@ -1885,7 +1683,7 @@ final class AppServices {
// initial horizon (the open stamps one via
// `dismissDeliveredNotifications`; this covers the refreshes).
BadgeState.adoptReadHorizon(gameID: gameID, horizon: readAt)
- await publishAccountSeenPush(gameID: gameID, readAt: readAt)
+ await accountPush.publishAccountSeenPush(gameID: gameID, readAt: readAt)
}
case .currentTime:
// Leaving / backgrounding: collapse the presence lease to now and,
@@ -1963,110 +1761,6 @@ final class AppServices {
}
}
- /// Assembled replay timelines, keyed by game. A finished game's journals are
- /// frozen (edit-lockout), so its timeline never changes once built — caching
- /// it here lets a `ReplayControls` instance recreated by rapid
- /// finish-banner nav re-entry skip re-running `ReplayAssembler.assemble`.
- /// Only `.ready` results land here; `.waiting`/`.unavailable` stay retryable.
- private var replayTimelineCache: [UUID: ReplayTimeline] = [:]
-
- /// A previously assembled timeline for `gameID`, if one was cached this
- /// session.
- func cachedReplayTimeline(gameID: UUID) -> ReplayTimeline? {
- replayTimelineCache[gameID]
- }
-
- /// Caches a fully assembled timeline so re-entry skips the re-merge. Safe
- /// because the caller only ever passes a finished game's `.ready` result,
- /// whose journals are frozen.
- func cacheReplayTimeline(_ timeline: ReplayTimeline, gameID: UUID) {
- replayTimelineCache[gameID] = timeline
- }
-
- /// Loads a finished game's replay: fetches every device's journal from
- /// CloudKit, overlays this device's live log, and gates on strict
- /// completeness. `.ready` carries a merged timeline; `.waiting(missing:)`
- /// means some contributing device hasn't uploaded its journal yet (the
- /// scrubber stays disabled until it does); `.unavailable` means the game's
- /// zone can't be reached. The fetch is a plain CKQuery, so it's safe to call
- /// from the UI when the finish banner appears.
- func loadReplay(gameID: UUID) async -> JournalReplayResult {
- let short = gameID.uuidString.prefix(8)
- func describe(_ result: JournalReplayResult) -> String {
- switch result {
- case .ready(let timeline): return "ready(steps=\(timeline.count))"
- case .waiting(let missing): return "waiting(missing=\(missing))"
- case .unavailable: return "unavailable"
- }
- }
- // This device's live journal is always overlaid (fresher than any
- // uploaded copy of itself), whether the contributors' journals come
- // from the local cache or a fresh CloudKit fetch.
- let local = store.localReplaySource(gameID: gameID)
- let localKey = local?.key ?? JournalDeviceKey(authorID: "", deviceID: "")
- let localEntries = local?.entries ?? []
-
- // Completed-game journals are frozen (edit-lockout), so once every
- // contributor's journal has been fetched in full we cache the remote
- // ones locally and re-merge from Core Data — no CloudKit round-trip on
- // re-entry. The cache is `nil` until that first complete fetch lands.
- if let cachedRemotes = await store.cachedRemoteJournals(forGameID: gameID) {
- let result = ReplayAssembler.assemble(
- fetch: JournalReplayFetch(
- journals: cachedRemotes,
- expectedDevices: Set(cachedRemotes.map(\.key))
- ),
- localKey: localKey,
- localEntries: localEntries
- )
- syncMonitor.note(
- "replay[\(short)]: served from cache — remoteDevices=\(cachedRemotes.count), " +
- "localEntries=\(localEntries.count) → \(describe(result))"
- )
- return result
- }
-
- // A nil return means zone unknown / access revoked; a throw means the
- // on-demand CKQuery itself failed. Both flatten to `.unavailable`, but
- // log which one (and the error) so the diagnostics stream can tell them
- // apart — the replay fetch is otherwise invisible to the event log.
- let fetch: JournalReplayFetch?
- do {
- fetch = try await syncEngine.fetchReplay(forGameID: gameID)
- } catch {
- let ns = error as NSError
- syncMonitor.note(
- "replay[\(short)]: fetch threw — domain=\(ns.domain) " +
- "code=\(ns.code) \(ns.localizedDescription)"
- )
- return .unavailable
- }
- guard let fetch else {
- syncMonitor.note("replay[\(short)]: fetch unavailable (zone unknown / access revoked)")
- return .unavailable
- }
- let result = ReplayAssembler.assemble(
- fetch: fetch,
- localKey: localKey,
- localEntries: localEntries
- )
- // A complete merge will never change (the game is finished), so cache
- // the *remote* journals for offline re-entry. Our own copy is excluded:
- // the live local journal is overlaid fresh on every load.
- if case .ready = result {
- await store.cacheRemoteJournals(
- fetch.journals.filter { $0.key != localKey },
- forGameID: gameID
- )
- }
- syncMonitor.note(
- "replay[\(short)]: merged — expected=\(fetch.expectedDevices.count), " +
- "journals=\(fetch.journals.count), localEntries=\(localEntries.count) " +
- "→ \(describe(result))"
- )
- return result
- }
-
/// Builds the `GameStore.onGameDeleted` callback. Extracted so tests can
/// drive the exact same closure that production wires up — keeps the
/// cursor-cleanup branch from drifting silently. (Friend colours need no
diff --git a/Crossmate/Services/BadgeCoordinator.swift b/Crossmate/Services/BadgeCoordinator.swift
@@ -14,7 +14,7 @@ final class BadgeCoordinator {
/// forward the suppression horizon is stamped while the puzzle is open.
private let readLeaseDuration: TimeInterval
/// Fans the seen horizon out to sibling devices —
- /// `AppServices.publishAccountSeenPush(gameID:readAt:)`.
+ /// `AccountPushCoordinator.publishAccountSeenPush(gameID:readAt:)`.
private let publishAccountSeenPush: (UUID, Date) async -> Void
init(
diff --git a/Crossmate/Services/ReplayLoader.swift b/Crossmate/Services/ReplayLoader.swift
@@ -0,0 +1,121 @@
+import Foundation
+
+/// Loads finished-game replays and caches their assembled timelines for the
+/// session, extracted from `AppServices`. A finished game's journals are
+/// frozen, so a fully-merged timeline never changes once built.
+@MainActor
+final class ReplayLoader {
+ private let store: GameStore
+ private let syncEngine: SyncEngine
+ private let syncMonitor: SyncMonitor
+
+ init(store: GameStore, syncEngine: SyncEngine, syncMonitor: SyncMonitor) {
+ self.store = store
+ self.syncEngine = syncEngine
+ self.syncMonitor = syncMonitor
+ }
+
+ /// Assembled replay timelines, keyed by game. A finished game's journals are
+ /// frozen (edit-lockout), so its timeline never changes once built — caching
+ /// it here lets a `ReplayControls` instance recreated by rapid
+ /// finish-banner nav re-entry skip re-running `ReplayAssembler.assemble`.
+ /// Only `.ready` results land here; `.waiting`/`.unavailable` stay retryable.
+ private var replayTimelineCache: [UUID: ReplayTimeline] = [:]
+
+ /// A previously assembled timeline for `gameID`, if one was cached this
+ /// session.
+ func cachedReplayTimeline(gameID: UUID) -> ReplayTimeline? {
+ replayTimelineCache[gameID]
+ }
+
+ /// Caches a fully assembled timeline so re-entry skips the re-merge. Safe
+ /// because the caller only ever passes a finished game's `.ready` result,
+ /// whose journals are frozen.
+ func cacheReplayTimeline(_ timeline: ReplayTimeline, gameID: UUID) {
+ replayTimelineCache[gameID] = timeline
+ }
+
+ /// Loads a finished game's replay: fetches every device's journal from
+ /// CloudKit, overlays this device's live log, and gates on strict
+ /// completeness. `.ready` carries a merged timeline; `.waiting(missing:)`
+ /// means some contributing device hasn't uploaded its journal yet (the
+ /// scrubber stays disabled until it does); `.unavailable` means the game's
+ /// zone can't be reached. The fetch is a plain CKQuery, so it's safe to call
+ /// from the UI when the finish banner appears.
+ func loadReplay(gameID: UUID) async -> JournalReplayResult {
+ let short = gameID.uuidString.prefix(8)
+ func describe(_ result: JournalReplayResult) -> String {
+ switch result {
+ case .ready(let timeline): return "ready(steps=\(timeline.count))"
+ case .waiting(let missing): return "waiting(missing=\(missing))"
+ case .unavailable: return "unavailable"
+ }
+ }
+ // This device's live journal is always overlaid (fresher than any
+ // uploaded copy of itself), whether the contributors' journals come
+ // from the local cache or a fresh CloudKit fetch.
+ let local = store.localReplaySource(gameID: gameID)
+ let localKey = local?.key ?? JournalDeviceKey(authorID: "", deviceID: "")
+ let localEntries = local?.entries ?? []
+
+ // Completed-game journals are frozen (edit-lockout), so once every
+ // contributor's journal has been fetched in full we cache the remote
+ // ones locally and re-merge from Core Data — no CloudKit round-trip on
+ // re-entry. The cache is `nil` until that first complete fetch lands.
+ if let cachedRemotes = await store.cachedRemoteJournals(forGameID: gameID) {
+ let result = ReplayAssembler.assemble(
+ fetch: JournalReplayFetch(
+ journals: cachedRemotes,
+ expectedDevices: Set(cachedRemotes.map(\.key))
+ ),
+ localKey: localKey,
+ localEntries: localEntries
+ )
+ syncMonitor.note(
+ "replay[\(short)]: served from cache — remoteDevices=\(cachedRemotes.count), " +
+ "localEntries=\(localEntries.count) → \(describe(result))"
+ )
+ return result
+ }
+
+ // A nil return means zone unknown / access revoked; a throw means the
+ // on-demand CKQuery itself failed. Both flatten to `.unavailable`, but
+ // log which one (and the error) so the diagnostics stream can tell them
+ // apart — the replay fetch is otherwise invisible to the event log.
+ let fetch: JournalReplayFetch?
+ do {
+ fetch = try await syncEngine.fetchReplay(forGameID: gameID)
+ } catch {
+ let ns = error as NSError
+ syncMonitor.note(
+ "replay[\(short)]: fetch threw — domain=\(ns.domain) " +
+ "code=\(ns.code) \(ns.localizedDescription)"
+ )
+ return .unavailable
+ }
+ guard let fetch else {
+ syncMonitor.note("replay[\(short)]: fetch unavailable (zone unknown / access revoked)")
+ return .unavailable
+ }
+ let result = ReplayAssembler.assemble(
+ fetch: fetch,
+ localKey: localKey,
+ localEntries: localEntries
+ )
+ // A complete merge will never change (the game is finished), so cache
+ // the *remote* journals for offline re-entry. Our own copy is excluded:
+ // the live local journal is overlaid fresh on every load.
+ if case .ready = result {
+ await store.cacheRemoteJournals(
+ fetch.journals.filter { $0.key != localKey },
+ forGameID: gameID
+ )
+ }
+ syncMonitor.note(
+ "replay[\(short)]: merged — expected=\(fetch.expectedDevices.count), " +
+ "journals=\(fetch.journals.count), localEntries=\(localEntries.count) " +
+ "→ \(describe(result))"
+ )
+ return result
+ }
+}