crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

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:
MCrossmate.xcodeproj/project.pbxproj | 8++++++++
MCrossmate/CrossmateApp.swift | 10+++++-----
ACrossmate/Services/AccountPushCoordinator.swift | 267+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MCrossmate/Services/AppServices.swift | 382++++++++-----------------------------------------------------------------------
MCrossmate/Services/BadgeCoordinator.swift | 2+-
ACrossmate/Services/ReplayLoader.swift | 121+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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 + } +}