NameBroadcaster.swift (4950B)
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 NameBroadcaster { 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 }