crossmate

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

PlayerNamePublisher.swift (4954B)


      1 import CoreData
      2 import Foundation
      3 import Observation
      4 
      5 /// Observes `PlayerPreferences.name` and writes a per-(game, author)
      6 /// `PlayerEntity` for every shared or joined game when the name changes,
      7 /// so remote participants see the updated display name within one sync cycle.
      8 ///
      9 /// Debounces at 250 ms: `PlayerPreferences` writes to both `UserDefaults` and
     10 /// `NSUbiquitousKeyValueStore`, which can echo back as a second setter call;
     11 /// a single rename should produce exactly one fan-out.
     12 @MainActor
     13 final class PlayerNamePublisher {
     14     private let preferences: PlayerPreferences
     15     private let persistence: PersistenceController
     16     private let authorIdentity: AuthorIdentity
     17     private let enqueuePlayerRecord: (UUID, String) async -> Void
     18 
     19     private var debounceTask: Task<Void, Never>?
     20     private var observationTask: Task<Void, Never>?
     21 
     22     /// Testing-only observer fired after each fan-out completes. Receives the
     23     /// name that was just broadcast. Production callers should leave this nil.
     24     var onFanOutForTesting: ((String) -> Void)?
     25 
     26     init(
     27         preferences: PlayerPreferences,
     28         persistence: PersistenceController,
     29         authorIdentity: AuthorIdentity,
     30         enqueuePlayerRecord: @escaping (UUID, String) async -> Void
     31     ) {
     32         self.preferences = preferences
     33         self.persistence = persistence
     34         self.authorIdentity = authorIdentity
     35         self.enqueuePlayerRecord = enqueuePlayerRecord
     36         startObserving()
     37     }
     38 
     39     private func startObserving() {
     40         observationTask = Task { [weak self] in
     41             guard let self else { return }
     42             while !Task.isCancelled {
     43                 await withCheckedContinuation { (cont: CheckedContinuation<Void, Never>) in
     44                     withObservationTracking {
     45                         _ = self.preferences.name
     46                     } onChange: {
     47                         cont.resume()
     48                     }
     49                 }
     50                 guard !Task.isCancelled else { break }
     51                 self.scheduleDebounce()
     52             }
     53         }
     54     }
     55 
     56     private func scheduleDebounce() {
     57         debounceTask?.cancel()
     58         debounceTask = Task { [weak self] in
     59             do {
     60                 try await Task.sleep(for: .milliseconds(250))
     61             } catch {
     62                 return
     63             }
     64             guard let self, !Task.isCancelled else { return }
     65             await self.fanOut(newName: self.preferences.name)
     66         }
     67     }
     68 
     69     /// Writes the local user's name into the `PlayerEntity` row for every
     70     /// shared or joined game and asks the sync engine to push each one. Called
     71     /// directly on game-share creation/accept so the partner sees a name on
     72     /// the very first sync.
     73     func broadcastName() async {
     74         await fanOut(newName: preferences.name)
     75     }
     76 
     77     private func fanOut(newName: String) async {
     78         guard let authorID = authorIdentity.currentID else { return }
     79 
     80         let ctx = persistence.container.newBackgroundContext()
     81         ctx.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
     82         let touchedGameIDs = Self.upsertPlayerRecords(
     83             in: ctx,
     84             authorID: authorID,
     85             name: newName
     86         )
     87 
     88         for gameID in touchedGameIDs {
     89             await enqueuePlayerRecord(gameID, authorID)
     90         }
     91 
     92         onFanOutForTesting?(newName)
     93     }
     94 
     95     /// Background-context work — main-actor isolation does not apply here.
     96     private nonisolated static func upsertPlayerRecords(
     97         in ctx: NSManagedObjectContext,
     98         authorID: String,
     99         name: String
    100     ) -> [UUID] {
    101         ctx.performAndWait {
    102             let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
    103             req.predicate = NSPredicate(
    104                 format: "ckShareRecordName != nil OR databaseScope == 1"
    105             )
    106             let games = (try? ctx.fetch(req)) ?? []
    107             var ids: [UUID] = []
    108             let now = Date()
    109             for game in games {
    110                 guard let gameID = game.id else { continue }
    111                 let recordName = RecordSerializer.recordName(forPlayerInGame: gameID, authorID: authorID)
    112                 let lookup = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity")
    113                 lookup.predicate = NSPredicate(format: "ckRecordName == %@", recordName)
    114                 lookup.fetchLimit = 1
    115 
    116                 let entity: PlayerEntity
    117                 if let existing = try? ctx.fetch(lookup).first {
    118                     entity = existing
    119                 } else {
    120                     entity = PlayerEntity(context: ctx)
    121                     entity.game = game
    122                     entity.ckRecordName = recordName
    123                     entity.authorID = authorID
    124                 }
    125                 entity.name = name
    126                 entity.updatedAt = now
    127                 ids.append(gameID)
    128             }
    129             if ctx.hasChanges { try? ctx.save() }
    130             return ids
    131         }
    132     }
    133 }