crossmate

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

PlayerNamePublisher.swift (9717B)


      1 import CloudKit
      2 import CoreData
      3 import Foundation
      4 import Observation
      5 
      6 /// The local user's monotonic display-name generation, UserDefaults-backed and
      7 /// keyed by authorID. Each rename bumps it by one; every copy of the name
      8 /// Decision written in that rename (account zone + friend zones) carries the
      9 /// same generation, and the send path's version-wins conflict rule lets a
     10 /// newer rename overwrite an older copy wherever they collide. Inbound echoes
     11 /// of our own Decision (another device renamed) are adopted via `adopt` so
     12 /// this device's next rename supersedes the account's highest known
     13 /// generation instead of colliding with it.
     14 enum NameVersionStore {
     15     private static func key(_ authorID: String) -> String {
     16         "NameVersionStore.version.\(authorID)"
     17     }
     18 
     19     /// Highest generation this device has published or seen. 0 until the
     20     /// first rename — friendship seeding publishes at this resting value so a
     21     /// seed never outranks (or ties) a real rename.
     22     static func current(authorID: String, defaults: UserDefaults = .standard) -> Int64 {
     23         (defaults.object(forKey: key(authorID)) as? NSNumber)?.int64Value ?? 0
     24     }
     25 
     26     /// The generation for a new rename: one past everything seen. Stores.
     27     static func next(authorID: String, defaults: UserDefaults = .standard) -> Int64 {
     28         let value = current(authorID: authorID, defaults: defaults) + 1
     29         defaults.set(NSNumber(value: value), forKey: key(authorID))
     30         return value
     31     }
     32 
     33     /// Adopts a generation observed on an inbound copy of our own Decision.
     34     static func adopt(
     35         _ version: Int64,
     36         authorID: String,
     37         defaults: UserDefaults = .standard
     38     ) {
     39         guard version > current(authorID: authorID, defaults: defaults) else { return }
     40         defaults.set(NSNumber(value: version), forKey: key(authorID))
     41     }
     42 }
     43 
     44 /// Observes `PlayerPreferences.name` and, when it changes, publishes the new
     45 /// name as a `name` Decision into the account zone (own-device convergence)
     46 /// and every non-blocked friend zone (how friends learn the rename). The
     47 /// fan-out therefore scales with friendships, not games, and reaches friends
     48 /// even after every shared game is completed or deleted.
     49 ///
     50 /// Per-game `Player` records still carry a snapshot of the name, written once
     51 /// at game open (`publishName(for:)`) — that's what collaborators see before
     52 /// a friendship's first name Decision lands, and what not-yet-friended
     53 /// participants fall back to.
     54 ///
     55 /// Debounces at 250 ms: `PlayerPreferences` writes to both `UserDefaults` and
     56 /// `NSUbiquitousKeyValueStore`, which can echo back as a second setter call;
     57 /// a single rename should produce exactly one fan-out.
     58 @MainActor
     59 final class PlayerNamePublisher {
     60     private let preferences: PlayerPreferences
     61     private let persistence: PersistenceController
     62     private let authorIdentity: AuthorIdentity
     63     private let enqueuePlayer: (UUID, String, String) async -> Void
     64     /// (authorID, name, version, zoneID, scope) → `SyncEngine.enqueueNameDecision`.
     65     private let enqueueNameDecision: (String, String, Int64, CKRecordZone.ID, DatabaseScope) async -> Void
     66 
     67     private var debounceTask: Task<Void, Never>?
     68     private var observationTask: Task<Void, Never>?
     69 
     70     /// Testing-only observer fired after each fan-out completes. Receives the
     71     /// name that was just broadcast. Production callers should leave this nil.
     72     var onFanOutForTesting: ((String) -> Void)?
     73 
     74     init(
     75         preferences: PlayerPreferences,
     76         persistence: PersistenceController,
     77         authorIdentity: AuthorIdentity,
     78         enqueuePlayer: @escaping (UUID, String, String) async -> Void,
     79         enqueueNameDecision: @escaping (String, String, Int64, CKRecordZone.ID, DatabaseScope) async -> Void
     80     ) {
     81         self.preferences = preferences
     82         self.persistence = persistence
     83         self.authorIdentity = authorIdentity
     84         self.enqueuePlayer = enqueuePlayer
     85         self.enqueueNameDecision = enqueueNameDecision
     86         startObserving()
     87     }
     88 
     89     private func startObserving() {
     90         observationTask = Task { [weak self] in
     91             guard let self else { return }
     92             while !Task.isCancelled {
     93                 await withCheckedContinuation { (cont: CheckedContinuation<Void, Never>) in
     94                     withObservationTracking {
     95                         _ = self.preferences.name
     96                     } onChange: {
     97                         cont.resume()
     98                     }
     99                 }
    100                 guard !Task.isCancelled else { break }
    101                 self.scheduleDebounce()
    102             }
    103         }
    104     }
    105 
    106     private func scheduleDebounce() {
    107         debounceTask?.cancel()
    108         debounceTask = Task { [weak self] in
    109             do {
    110                 try await Task.sleep(for: .milliseconds(250))
    111             } catch {
    112                 return
    113             }
    114             guard let self, !Task.isCancelled else { return }
    115             await self.fanOut(newName: self.preferences.name)
    116         }
    117     }
    118 
    119     /// Publishes the local user's current name as a Decision to the account
    120     /// zone and every non-blocked friend zone, at a freshly bumped generation.
    121     func broadcastName() async {
    122         await fanOut(newName: preferences.name)
    123     }
    124 
    125     /// Writes the local user's name into one game's `PlayerEntity` row. Used
    126     /// when opening a shared puzzle so the game zone itself carries a name
    127     /// snapshot — collaborators who aren't friends yet (or whose friend zone
    128     /// hasn't delivered a name Decision) read this.
    129     func publishName(for gameID: UUID) async {
    130         guard let authorID = authorIdentity.currentID else { return }
    131 
    132         let ctx = persistence.container.newBackgroundContext()
    133         ctx.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
    134         guard await Self.upsertPlayerRecord(
    135             for: gameID,
    136             in: ctx,
    137             authorID: authorID,
    138             name: preferences.name
    139         ) else { return }
    140 
    141         await enqueuePlayer(gameID, authorID, "name-open")
    142     }
    143 
    144     private func fanOut(newName: String) async {
    145         guard let authorID = authorIdentity.currentID else { return }
    146         let name = newName.trimmingCharacters(in: .whitespacesAndNewlines)
    147         guard !name.isEmpty else { return }
    148 
    149         let version = NameVersionStore.next(authorID: authorID)
    150         await enqueueNameDecision(
    151             authorID, name, version, RecordSerializer.accountZoneID, .private
    152         )
    153         let ctx = persistence.container.newBackgroundContext()
    154         for target in await Self.friendZoneTargets(in: ctx) {
    155             await enqueueNameDecision(
    156                 authorID, name, version, target.zoneID, target.scope
    157             )
    158         }
    159 
    160         onFanOutForTesting?(name)
    161     }
    162 
    163     /// Background-context work — runs on `ctx`'s queue via `await perform`, so
    164     /// it never blocks the main actor even though the caller is `@MainActor`.
    165     private nonisolated static func friendZoneTargets(
    166         in ctx: NSManagedObjectContext
    167     ) async -> [(zoneID: CKRecordZone.ID, scope: DatabaseScope)] {
    168         await ctx.perform {
    169             let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity")
    170             req.predicate = NSPredicate(format: "isBlocked == NO")
    171             let friends = (try? ctx.fetch(req)) ?? []
    172             return friends.compactMap { friend in
    173                 // Our name is told to a friend by writing into their inbox —
    174                 // our outbox (shared DB).
    175                 guard let pairKey = friend.pairKey,
    176                       let authorID = friend.authorID,
    177                       FriendZone.outboxAccepted(pairKey: pairKey)
    178                 else { return nil }
    179                 return (
    180                     FriendZone.outboxZoneID(pairKey: pairKey, friendAuthorID: authorID),
    181                     .shared
    182                 )
    183             }
    184         }
    185     }
    186 
    187     private nonisolated static func upsertPlayerRecord(
    188         for gameID: UUID,
    189         in ctx: NSManagedObjectContext,
    190         authorID: String,
    191         name: String
    192     ) async -> Bool {
    193         await ctx.perform {
    194             let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
    195             req.predicate = NSPredicate(
    196                 format: "id == %@ AND (ckShareRecordName != nil OR databaseScope == 1) AND isAccessRevoked == NO",
    197                 gameID as CVarArg
    198             )
    199             req.fetchLimit = 1
    200             guard let game = try? ctx.fetch(req).first else { return false }
    201             upsertPlayerEntity(
    202                 for: game,
    203                 authorID: authorID,
    204                 name: name,
    205                 now: Date(),
    206                 in: ctx
    207             )
    208             if ctx.hasChanges { try? ctx.save() }
    209             return true
    210         }
    211     }
    212 
    213     private nonisolated static func upsertPlayerEntity(
    214         for game: GameEntity,
    215         authorID: String,
    216         name: String,
    217         now: Date,
    218         in ctx: NSManagedObjectContext
    219     ) {
    220         guard let gameID = game.id else { return }
    221         let recordName = RecordSerializer.recordName(forPlayerInGame: gameID, authorID: authorID)
    222         let lookup = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity")
    223         lookup.predicate = NSPredicate(format: "ckRecordName == %@", recordName)
    224         lookup.fetchLimit = 1
    225 
    226         let entity: PlayerEntity
    227         if let existing = try? ctx.fetch(lookup).first {
    228             entity = existing
    229         } else {
    230             entity = PlayerEntity(context: ctx)
    231             entity.game = game
    232             entity.ckRecordName = recordName
    233             entity.authorID = authorID
    234         }
    235         entity.name = name
    236         entity.updatedAt = now
    237     }
    238 }