commit 18b932134f080a7dfe639bfb4fafa556d5b76eb5
parent 025000f5dbc368ab5a0a52801fd8460a26b24cda
Author: Michael Camilleri <[email protected]>
Date: Sun, 28 Jun 2026 12:03:02 +0900
Persist offline play under a device-local author id
This commit fixes the total loss of a game's progress for a player who
is not signed in to iCloud. The moves and cell-cache layers refuse to
flush without an author id, and that id only resolved from CloudKit once
an account was present — so an account-less device kept nothing across a
restart: entries, reveal marks, and author attribution were all lost,
and a completed game was sealed back to a bare solution on reopen.
AuthorIdentity now falls back to a stable, device-local `local-<UUID>`
author whenever no iCloud id has resolved, so those local writes
persist. The fallback is generated once and reused across launches, so a
game keeps a single consistent author while offline.
When a real iCloud id later resolves while the fallback was in use,
AuthorIdentity fires onIdentityResolved and GameStore.remapAuthorID
rewrites every row authored under the fallback to the real id —
completedBy, letterAuthorID, the journal author fields, and the
author-embedding CloudKit record names on MovesEntity and PlayerEntity,
whose cached system fields are dropped so they push fresh. The remap
runs during the identity refresh, before the sync engine starts, so the
first push carries the real id; it is safe because fallback-authored
rows were never synced. An account switch — one real id replaced by a
different one — deliberately does not remap, since that is a different
user.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Diffstat:
4 files changed, 142 insertions(+), 16 deletions(-)
diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift
@@ -1134,6 +1134,65 @@ final class GameStore {
currentEntity = nil
}
+ /// Rewrites every local row authored under a device-local fallback identity
+ /// to the resolved iCloud author id. Offline play stamps a `local-<UUID>`
+ /// author so writes can persist without an account (the moves/cell layers
+ /// refuse to flush without one); when the user signs in, this realigns that
+ /// data — including the author-embedding CloudKit record names — before the
+ /// sync engine ever pushes it. Safe precisely because fallback-authored rows
+ /// were never synced: no account existed, so no server record predates them.
+ func remapAuthorID(from oldID: String, to newID: String) {
+ guard oldID != newID, !oldID.isEmpty, !newID.isEmpty else { return }
+
+ func fetch<T: NSManagedObject>(_ entityName: String, where predicate: NSPredicate) -> [T] {
+ let request = NSFetchRequest<T>(entityName: entityName)
+ request.predicate = predicate
+ return (try? context.fetch(request)) ?? []
+ }
+
+ let matchesOld = NSPredicate(format: "authorID == %@", oldID)
+
+ // Plain author fields — no author in their record name.
+ for game: GameEntity in fetch("GameEntity", where: NSPredicate(format: "completedBy == %@", oldID)) {
+ game.completedBy = newID
+ }
+ for cell: CellEntity in fetch("CellEntity", where: NSPredicate(format: "letterAuthorID == %@", oldID)) {
+ cell.letterAuthorID = newID
+ }
+ for entry: JournalEntity in fetch("JournalEntity", where: NSPredicate(
+ format: "actingAuthorID == %@ OR cellAuthorID == %@ OR beforeCellAuthorID == %@ OR sourceAuthorID == %@",
+ oldID, oldID, oldID, oldID
+ )) {
+ if entry.actingAuthorID == oldID { entry.actingAuthorID = newID }
+ if entry.cellAuthorID == oldID { entry.cellAuthorID = newID }
+ if entry.beforeCellAuthorID == oldID { entry.beforeCellAuthorID = newID }
+ if entry.sourceAuthorID == oldID { entry.sourceAuthorID = newID }
+ }
+
+ // Author-embedded record names — rewrite the name and drop any cached
+ // system fields so the row pushes fresh under the real id.
+ for moves: MovesEntity in fetch("MovesEntity", where: matchesOld) {
+ moves.authorID = newID
+ if let gameID = moves.game?.id, let deviceID = moves.deviceID {
+ moves.ckRecordName = RecordSerializer.recordName(
+ forMovesInGame: gameID, authorID: newID, deviceID: deviceID
+ )
+ moves.ckSystemFields = nil
+ }
+ }
+ for player: PlayerEntity in fetch("PlayerEntity", where: matchesOld) {
+ player.authorID = newID
+ if let gameID = player.game?.id {
+ player.ckRecordName = RecordSerializer.recordName(
+ forPlayerInGame: gameID, authorID: newID
+ )
+ player.ckSystemFields = nil
+ }
+ }
+
+ saveContext("remapAuthorID")
+ }
+
/// Marks a game as completed after a normal win. Returns whether the
/// entity changed; no-ops if already marked.
/// Triggers a buffer flush so the completion snapshot is created promptly
diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift
@@ -502,6 +502,12 @@ final class AppServices {
eventLog: eventLog
)
self.store = store
+ // When an iCloud id first resolves after offline play, realign every
+ // row authored under the device-local fallback to the real id — before
+ // `ensureICloudSyncStarted` lets the sync engine push them.
+ identity.onIdentityResolved = { [weak store] fallbackID, realID in
+ store?.remapAuthorID(from: fallbackID, to: realID)
+ }
// Publishes resolve (and mint) the game's shared push credential from
// the store so the worker can verify participation.
self.pushClient?.gameCredentialResolver = { [weak store] gameID in
diff --git a/Crossmate/Sync/AuthorIdentity.swift b/Crossmate/Sync/AuthorIdentity.swift
@@ -7,16 +7,43 @@ import Foundation
/// notification can race the async `userRecordID()` call and treat the local
/// user's authorID as if it belonged to a remote peer. Refreshed on
/// account-change events from `CKSyncEngine`.
+///
+/// When no iCloud account has resolved yet, `currentID` falls back to a stable
+/// device-local id (`local-<UUID>`) so local writes can still persist — the
+/// moves/cell layers refuse to flush without an author. The first time a real
+/// iCloud id resolves, `onIdentityResolved` fires so persisted rows authored
+/// under the fallback can be remapped to the real id before they ever sync.
@MainActor
final class AuthorIdentity {
+ /// Holds a resolved iCloud record name only — never the fallback, so a
+ /// launch with this key empty means "no account has ever resolved" and the
+ /// fallback applies again.
private static let storageKey = "AuthorIdentity.currentID"
+ /// The stable device-local stand-in, generated once and reused so offline
+ /// play across launches keeps a single consistent author.
+ private static let fallbackKey = "AuthorIdentity.fallbackID"
private(set) var currentID: String?
+ /// True while `currentID` is the device-local stand-in rather than a
+ /// resolved iCloud id.
+ private(set) var isFallback: Bool
+
+ /// Fires once when a real iCloud id resolves while the fallback was in use,
+ /// with `(fallbackID, realID)`, so a caller can remap any rows authored
+ /// under the fallback before the sync engine pushes them.
+ var onIdentityResolved: (@MainActor (String, String) -> Void)?
+
private let storage: UserDefaults
init(storage: UserDefaults = .standard) {
self.storage = storage
- currentID = storage.string(forKey: Self.storageKey)
+ if let resolved = storage.string(forKey: Self.storageKey) {
+ currentID = resolved
+ isFallback = false
+ } else {
+ currentID = Self.fallbackID(storage: storage)
+ isFallback = true
+ }
}
/// Injects a known ID without hitting CloudKit or persistent storage —
@@ -24,12 +51,33 @@ final class AuthorIdentity {
init(testing fixedID: String) {
self.storage = UserDefaults.standard
currentID = fixedID
+ isFallback = false
}
func refresh(using container: CKContainer) async {
guard let recordID = try? await container.userRecordID() else { return }
- let newID = recordID.recordName
- currentID = newID
- storage.set(newID, forKey: Self.storageKey)
+ let realID = recordID.recordName
+ let previousID = currentID
+ let wasFallback = isFallback
+
+ currentID = realID
+ isFallback = false
+ storage.set(realID, forKey: Self.storageKey)
+
+ // First real id after offline play: realign the fallback-authored rows
+ // before anything syncs. An account *switch* (real → different real) is
+ // a different user, so it deliberately doesn't remap.
+ if wasFallback, let previousID, previousID != realID {
+ onIdentityResolved?(previousID, realID)
+ }
+ }
+
+ private static func fallbackID(storage: UserDefaults) -> String {
+ if let existing = storage.string(forKey: Self.fallbackKey) {
+ return existing
+ }
+ let generated = "local-\(UUID().uuidString)"
+ storage.set(generated, forKey: Self.fallbackKey)
+ return generated
}
}
diff --git a/Tests/Unit/Sync/AuthorIdentityTests.swift b/Tests/Unit/Sync/AuthorIdentityTests.swift
@@ -12,36 +12,49 @@ struct AuthorIdentityTests {
UserDefaults(suiteName: "test-authoridentity-\(UUID().uuidString)")!
}
- @Test("Initial state returns nil before any refresh")
- func initialStateIsNil() {
+ @Test("Before any account resolves, falls back to a device-local id")
+ func initialStateUsesFallback() {
let identity = AuthorIdentity(storage: makeIsolatedStorage())
- #expect(identity.currentID == nil)
+ #expect(identity.isFallback)
+ #expect(identity.currentID?.hasPrefix("local-") == true)
+ }
+
+ @Test("Fallback id is stable across instances sharing storage")
+ func fallbackPersistsAcrossInstances() {
+ let storage = makeIsolatedStorage()
+ let first = AuthorIdentity(storage: storage).currentID
+ let second = AuthorIdentity(storage: storage).currentID
+ #expect(first?.hasPrefix("local-") == true)
+ #expect(first == second)
}
@Test("Testing initializer seeds currentID")
func testingInitializerSeedsValue() {
let identity = AuthorIdentity(testing: "_abc")
#expect(identity.currentID == "_abc")
+ #expect(!identity.isFallback)
}
- @Test("refresh with unavailable account leaves currentID nil")
- func refreshFailurePreservesNil() async {
+ @Test("refresh with unavailable account leaves the fallback in place")
+ func refreshFailurePreservesFallback() async {
let identity = AuthorIdentity(storage: makeIsolatedStorage())
- // Using the default CKContainer without a signed-in account will fail.
- // The contract is: on failure, currentID stays nil (or unchanged).
+ let fallback = identity.currentID
+ // The default CKContainer without a signed-in account fails the fetch.
+ // The contract is: on failure, currentID is unchanged — still the
+ // fallback — so offline writes keep a consistent author.
let containerWithNoAccount = CKContainer(identifier: "iCloud.net.inqk.crossmate.v3")
await identity.refresh(using: containerWithNoAccount)
- // On a simulator/CI with no account, this should stay nil.
- // We can only assert it doesn't crash; value depends on sim state.
- _ = identity.currentID
+ #expect(identity.currentID == fallback)
+ #expect(identity.isFallback)
}
- @Test("Persists currentID across instances sharing storage")
- func currentIDPersistsAcrossInstances() {
+ @Test("A resolved iCloud id takes precedence over the fallback")
+ func resolvedIDPersistsAcrossInstances() {
let storage = makeIsolatedStorage()
storage.set("_persisted", forKey: "AuthorIdentity.currentID")
let identity = AuthorIdentity(storage: storage)
#expect(identity.currentID == "_persisted")
+ #expect(!identity.isFallback)
}
}