AuthorIdentity.swift (3478B)
1 import CloudKit 2 import Foundation 3 4 /// Cache for the current iCloud user's record name. Persisted to 5 /// UserDefaults so `currentID` is non-nil on every launch after the first 6 /// successful CloudKit fetch — without this, a cold launch from a shared-game 7 /// notification can race the async `userRecordID()` call and treat the local 8 /// user's authorID as if it belonged to a remote peer. Refreshed on 9 /// account-change events from `CKSyncEngine`. 10 /// 11 /// When no iCloud account has resolved yet, `currentID` falls back to a stable 12 /// device-local id (`local-<UUID>`) so local writes can still persist — the 13 /// moves/cell layers refuse to flush without an author. The first time a real 14 /// iCloud id resolves, `onIdentityResolved` fires so persisted rows authored 15 /// under the fallback can be remapped to the real id before they ever sync. 16 @MainActor 17 final class AuthorIdentity { 18 /// Holds a resolved iCloud record name only — never the fallback, so a 19 /// launch with this key empty means "no account has ever resolved" and the 20 /// fallback applies again. 21 private static let storageKey = "AuthorIdentity.currentID" 22 /// The stable device-local stand-in, generated once and reused so offline 23 /// play across launches keeps a single consistent author. 24 private static let fallbackKey = "AuthorIdentity.fallbackID" 25 26 private(set) var currentID: String? 27 /// True while `currentID` is the device-local stand-in rather than a 28 /// resolved iCloud id. 29 private(set) var isFallback: Bool 30 31 /// Fires once when a real iCloud id resolves while the fallback was in use, 32 /// with `(fallbackID, realID)`, so a caller can remap any rows authored 33 /// under the fallback before the sync engine pushes them. 34 var onIdentityResolved: (@MainActor (String, String) -> Void)? 35 36 private let storage: UserDefaults 37 38 init(storage: UserDefaults = .standard) { 39 self.storage = storage 40 if let resolved = storage.string(forKey: Self.storageKey) { 41 currentID = resolved 42 isFallback = false 43 } else { 44 currentID = Self.fallbackID(storage: storage) 45 isFallback = true 46 } 47 } 48 49 /// Injects a known ID without hitting CloudKit or persistent storage — 50 /// use in unit tests only. 51 init(testing fixedID: String) { 52 self.storage = UserDefaults.standard 53 currentID = fixedID 54 isFallback = false 55 } 56 57 func refresh(using container: CKContainer) async { 58 guard let recordID = try? await container.userRecordID() else { return } 59 let realID = recordID.recordName 60 let previousID = currentID 61 let wasFallback = isFallback 62 63 currentID = realID 64 isFallback = false 65 storage.set(realID, forKey: Self.storageKey) 66 67 // First real id after offline play: realign the fallback-authored rows 68 // before anything syncs. An account *switch* (real → different real) is 69 // a different user, so it deliberately doesn't remap. 70 if wasFallback, let previousID, previousID != realID { 71 onIdentityResolved?(previousID, realID) 72 } 73 } 74 75 private static func fallbackID(storage: UserDefaults) -> String { 76 if let existing = storage.string(forKey: Self.fallbackKey) { 77 return existing 78 } 79 let generated = "local-\(UUID().uuidString)" 80 storage.set(generated, forKey: Self.fallbackKey) 81 return generated 82 } 83 }