crossmate

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

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 }