crossmate

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

commit 60a0c7da7488a57bf3e5becce99a642ca14859a1
parent c1c3a272b359d1308166b416930ed1c2cc445e82
Author: Michael Camilleri <[email protected]>
Date:   Wed, 15 Apr 2026 07:58:18 +0900

Ensure Core Data changes are sent to iCloud

Previous to this commit, state changes were persisted to iCloud through
ad-hoc calls. This commit changes this by adding an observer that
ensures writes to Core Data are copied to an outbox where they can be
pushed to iCloud.

This commit also adds a gate to the sync engine that ensures an iCloud
account is configured before attempting to push to it.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

Diffstat:
MCrossmate.xcodeproj/project.pbxproj | 4++++
MCrossmate/CrossmateApp.swift | 7+++++++
MCrossmate/Persistence/GameStore.swift | 5+++++
ACrossmate/Persistence/OutboxRecorder.swift | 91+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MCrossmate/Sync/RecordSerializer.swift | 35+++++++++++++++++++++++++++++++++++
MCrossmate/Sync/SyncEngine.swift | 98+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------
6 files changed, 221 insertions(+), 19 deletions(-)

diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj @@ -14,6 +14,7 @@ 350722635E9A17324148CACC /* PuzzleCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DC7784917397BCD6B8D679D /* PuzzleCatalog.swift */; }; 38C913D00ED762BD9E355A2D /* KeychainHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33878A29B09A6154C7A63C82 /* KeychainHelper.swift */; }; 3A5483EF2893AE325DF27EE8 /* GameMutator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43DC132D49361C56DE79C13E /* GameMutator.swift */; }; + F1A11234567890ABCDEF0001 /* OutboxRecorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1B11234567890ABCDEF0001 /* OutboxRecorder.swift */; }; 47584CBEF819C2F507D06DFF /* PlayerColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB55FC337CF72C650373210A /* PlayerColor.swift */; }; 4819D7FBB407C9D76510EA2A /* TestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = F97B399E89BBB37730F2F1E9 /* TestHelpers.swift */; }; 503229FF89FF7C29CEF4C16D /* Puzzle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C8064F04FC6177D987ACA2 /* Puzzle.swift */; }; @@ -79,6 +80,7 @@ 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>"; }; 43DC132D49361C56DE79C13E /* GameMutator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameMutator.swift; sourceTree = "<group>"; }; + F1B11234567890ABCDEF0001 /* OutboxRecorder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OutboxRecorder.swift; sourceTree = "<group>"; }; 465F2BB469EFE84CF3733398 /* Game.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Game.swift; sourceTree = "<group>"; }; 4DC7784917397BCD6B8D679D /* PuzzleCatalog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PuzzleCatalog.swift; sourceTree = "<group>"; }; 50992CDA4082429EBB17F65C /* garden.xd */ = {isa = PBXFileReference; path = garden.xd; sourceTree = "<group>"; }; @@ -188,6 +190,7 @@ children = ( 43DC132D49361C56DE79C13E /* GameMutator.swift */, 93EE5BA78566EDED68D846AB /* GameStore.swift */, + F1B11234567890ABCDEF0001 /* OutboxRecorder.swift */, E524780E360E008FACE4F213 /* PendingChange+Helpers.swift */, ACC295195602B3DDF7BB3895 /* PersistenceController.swift */, ); @@ -391,6 +394,7 @@ 818B1F2693962832BE14578E /* GameListView.swift in Sources */, 3A5483EF2893AE325DF27EE8 /* GameMutator.swift in Sources */, D58980B92C99122C368D4216 /* GameStore.swift in Sources */, + F1A11234567890ABCDEF0001 /* OutboxRecorder.swift in Sources */, C7370BCAD585EEFD366204E3 /* GridThumbnailView.swift in Sources */, 765B50552B13175F91A25EA1 /* GridView.swift in Sources */, 8478F0BC0CA624C78DC0A3B5 /* ImportedBrowseView.swift in Sources */, diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift @@ -11,16 +11,19 @@ struct CrossmateApp: App { @State private var nytAuth = NYTAuthService() @State private var ubiquityMonitor = UbiquityMonitor() private let persistence: PersistenceController + private let outboxRecorder: OutboxRecorder private let nytFetcher = NYTPuzzleFetcher { NYTAuthService.currentCookie() } init() { let persistence = PersistenceController() + let outboxRecorder = OutboxRecorder(persistence: persistence) let store = GameStore(persistence: persistence) let engine = SyncEngine( container: CKContainer(identifier: "iCloud.net.inqk.crossmate.v2"), persistence: persistence ) self.persistence = persistence + self.outboxRecorder = outboxRecorder self._store = State(initialValue: store) self._syncEngine = State(initialValue: engine) } @@ -130,6 +133,10 @@ struct RootView: View { syncMonitor.note(message) } + // Start observing iCloud account changes so sign-in / sign-out + // events trigger a re-sync without an app relaunch. + await syncEngine.startAccountObserver() + // Bootstrap and initial sync await Self.run("bootstrap", monitor: syncMonitor) { try await syncEngine.bootstrap() diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift @@ -324,6 +324,11 @@ final class GameStore { ) guard let broken = try? context.fetch(request), !broken.isEmpty else { return } + // Repair re-materializes state that already lives on CloudKit, so we + // don't want `OutboxRecorder` to treat it as a local mutation. + context.userInfo[OutboxRecorder.skipKey] = true + defer { context.userInfo.removeObject(forKey: OutboxRecorder.skipKey) } + for entity in broken { if entity.id == nil, let recordName = entity.ckRecordName, diff --git a/Crossmate/Persistence/OutboxRecorder.swift b/Crossmate/Persistence/OutboxRecorder.swift @@ -0,0 +1,91 @@ +import CoreData +import Foundation + +/// Observes Core Data saves on the app's store and enqueues +/// `PendingChangeEntity` rows for every synced-entity mutation as part of the +/// same transaction. The aim is a closed loop: any write that reaches Core +/// Data also produces a sync intent, so new write paths cannot silently skip +/// the push. +/// +/// Writes that already represent remote state (the sync engine applying +/// incoming records, or `GameStore` repairing entities materialized from +/// CloudKit) must set `context.userInfo[OutboxRecorder.skipKey] = true` so +/// those changes don't get echoed back to the server. +final class OutboxRecorder: @unchecked Sendable { + static let skipKey = "OutboxRecorder.skip" + + private let coordinator: NSPersistentStoreCoordinator + + init(persistence: PersistenceController) { + self.coordinator = persistence.container.persistentStoreCoordinator + NotificationCenter.default.addObserver( + self, + selector: #selector(contextWillSave(_:)), + name: .NSManagedObjectContextWillSave, + object: nil + ) + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + @objc private func contextWillSave(_ note: Notification) { + guard let context = note.object as? NSManagedObjectContext, + context.persistentStoreCoordinator === coordinator, + context.userInfo[OutboxRecorder.skipKey] as? Bool != true + else { return } + + for object in context.insertedObjects { + recordUpsert(object, in: context) + } + for object in context.updatedObjects { + recordUpsert(object, in: context) + } + for object in context.deletedObjects { + recordDelete(object, in: context) + } + } + + private func recordUpsert( + _ object: NSManagedObject, + in context: NSManagedObjectContext + ) { + switch object { + case let game as GameEntity: + RecordSerializer.enqueueGamePending(for: game, in: context) + case let cell as CellEntity: + // Untouched cells (never given an `updatedAt` stamp) are handled + // by the receiving device materializing them from the puzzle + // source, so we don't push them. + guard cell.updatedAt != nil else { return } + RecordSerializer.enqueueCellPending(for: cell, in: context) + default: + return + } + } + + private func recordDelete( + _ object: NSManagedObject, + in context: NSManagedObjectContext + ) { + switch object { + case let game as GameEntity: + guard let recordName = game.ckRecordName else { return } + RecordSerializer.enqueueDeletePending( + recordName: recordName, + recordType: .deletedGame, + in: context + ) + case let cell as CellEntity: + guard let recordName = cell.ckRecordName else { return } + RecordSerializer.enqueueDeletePending( + recordName: recordName, + recordType: .deletedCell, + in: context + ) + default: + return + } + } +} diff --git a/Crossmate/Sync/RecordSerializer.swift b/Crossmate/Sync/RecordSerializer.swift @@ -181,6 +181,41 @@ enum RecordSerializer { ) } + /// Enqueues a Cell pending change built directly from a `CellEntity`. + /// Used by `OutboxRecorder` so Core Data state is the single source of + /// truth for the outgoing payload. + static func enqueueCellPending( + for cell: CellEntity, + in context: NSManagedObjectContext + ) { + guard let cellRecordName = cell.ckRecordName, + let gameEntity = cell.game, + let gameID = gameEntity.id + else { return } + + let payload = PendingChangePayload( + recordType: .cell, + recordName: cellRecordName, + letter: cell.letter, + markKind: cell.markKind, + checkedWrong: cell.checkedWrong, + updatedAt: cell.updatedAt, + letterAuthorID: cell.letterAuthorID, + parentGameRecordName: recordName(forGameID: gameID) + ) + + guard let jsonData = try? JSONEncoder().encode(payload), + let jsonString = String(data: jsonData, encoding: .utf8) + else { return } + + PendingChangeEntity.upsert( + recordName: cellRecordName, + recordType: "Cell", + payload: jsonString, + in: context + ) + } + static func enqueueDeletePending( recordName: String, recordType: PendingChangePayload.RecordType, diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift @@ -30,6 +30,10 @@ actor SyncEngine { private var zoneID: CKRecordZone.ID private var ownerResolved: Bool = false + /// Long-lived task that observes `.CKAccountChanged` and re-runs sync + /// whenever the user signs in or out. Installed by `startAccountObserver`. + private var accountObserverTask: Task<Void, Never>? + /// Called on the MainActor with decoded cell changes after remote records /// have been applied to Core Data. Wired up in CrossmateApp to route /// through GameStore → GameMutator (the single inbox). @@ -60,6 +64,73 @@ actor SyncEngine { self.zoneID = RecordSerializer.zoneID() } + // MARK: - Account observer + + /// Starts listening for `.CKAccountChanged` so sign-in / sign-out events + /// trigger a fresh bootstrap + fetch + push without needing an app + /// relaunch. Idempotent. + func startAccountObserver() { + guard accountObserverTask == nil else { return } + accountObserverTask = Task { [weak self] in + let notifications = NotificationCenter.default.notifications(named: .CKAccountChanged) + for await _ in notifications { + guard let self else { return } + await self.handleAccountChange() + } + } + } + + private func handleAccountChange() async { + await trace("account changed: re-resolving owner and syncing") + // Force owner re-resolution in case the user switched iCloud accounts. + ownerResolved = false + zoneID = RecordSerializer.zoneID() + + do { + try await bootstrap() + try await fetchChanges() + try await pushChanges() + } catch { + await trace("account-change sync failed: \(describe(error))") + } + } + + // MARK: - Account status + + /// Returns `true` only when the user has an iCloud account signed in and + /// available. `bootstrap`, `pushChanges`, and `fetchChanges` gate on this + /// so the simulator (and signed-out users) stop generating "Not + /// Authenticated" noise. + private func accountAvailable() async -> Bool { + do { + let status = try await container.accountStatus() + switch status { + case .available: + return true + case .noAccount, .restricted, .couldNotDetermine, .temporarilyUnavailable: + await trace("account status: \(describe(status)), skipping sync") + return false + @unknown default: + await trace("account status: unknown raw=\(status.rawValue), skipping sync") + return false + } + } catch { + await trace("account status: \(describe(error))") + return false + } + } + + private nonisolated func describe(_ status: CKAccountStatus) -> String { + switch status { + case .available: return "available" + case .noAccount: return "noAccount" + case .restricted: return "restricted" + case .couldNotDetermine: return "couldNotDetermine" + case .temporarilyUnavailable: return "temporarilyUnavailable" + @unknown default: return "unknown" + } + } + // MARK: - Owner resolution /// Replaces the placeholder owner in `zoneID` with the real user record @@ -88,9 +159,11 @@ actor SyncEngine { /// safe to call on every launch. Skips the network call if the zone has /// already been created (tracked via `SyncStateEntity`). func bootstrap() async throws { + guard await accountAvailable() else { return } try await resolveOwnerIfNeeded() let context = persistence.container.newBackgroundContext() + context.userInfo[OutboxRecorder.skipKey] = true let alreadyCreated: Bool = context.performAndWait { SyncStateEntity.current(in: context).zoneCreated } @@ -172,33 +245,16 @@ actor SyncEngine { /// Loops until the outbox is empty, processing up to 400 records per batch /// (the CloudKit per-operation limit). func pushChanges() async throws { + guard await accountAvailable() else { return } try await resolveOwnerIfNeeded() let context = persistence.container.newBackgroundContext() + context.userInfo[OutboxRecorder.skipKey] = true var serverWinsCellChanges: [RemoteCellChange] = [] var iteration = 0 await trace("push: enter") - // Recovery: scan for any GameEntity that has a ckRecordName but no - // ckSystemFields (i.e., never successfully pushed) and enqueue a - // pending change for it. This catches games created before the sync - // engine could push them, so any cells referencing them have a - // parent on the server when their turn comes. - let recovered: Int = context.performAndWait { - let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") - request.predicate = NSPredicate(format: "ckRecordName != nil AND ckSystemFields == nil") - guard let entities = try? context.fetch(request), !entities.isEmpty else { return 0 } - for entity in entities { - RecordSerializer.enqueueGamePending(for: entity, in: context) - } - try? context.save() - return entities.count - } - if recovered > 0 { - await trace("push: recovered \(recovered) un-pushed Game(s) into outbox") - } - while true { iteration += 1 await trace("push[\(iteration)]: draining outbox") @@ -390,9 +446,11 @@ actor SyncEngine { /// Applies incoming records to Core Data, persists change tokens, then /// hops to MainActor to refresh the in-memory Game. func fetchChanges() async throws { + guard await accountAvailable() else { return } try await resolveOwnerIfNeeded() let context = persistence.container.newBackgroundContext() + context.userInfo[OutboxRecorder.skipKey] = true // Step 1: Fetch database-level changes to discover changed zones let databaseToken: CKServerChangeToken? = context.performAndWait { @@ -897,6 +955,7 @@ actor SyncEngine { /// outbox or any game data. func resetSyncState() async { let context = persistence.container.newBackgroundContext() + context.userInfo[OutboxRecorder.skipKey] = true context.performAndWait { let state = SyncStateEntity.current(in: context) state.zoneCreated = false @@ -916,6 +975,7 @@ actor SyncEngine { } let context = persistence.container.newBackgroundContext() + context.userInfo[OutboxRecorder.skipKey] = true return context.performAndWait { let state = SyncStateEntity.current(in: context) let request = NSFetchRequest<PendingChangeEntity>(entityName: "PendingChangeEntity")