crossmate

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

PresencePublisher.swift (6371B)


      1 import CoreData
      2 import Foundation
      3 
      4 /// Debounced writer for the local player's cursor selection. Updates the
      5 /// `PlayerEntity` row for `(gameID, authorID)` with the new `selRow`/`selCol`/
      6 /// `selDir` and asks the sync engine to push the Player record. Cursor edits
      7 /// don't go through `MovesUpdater` because they aren't cell edits — they live
      8 /// on `PlayerEntity` with last-writer-wins semantics.
      9 actor PresencePublisher {
     10     private let debounceInterval: Duration
     11     private let persistence: PersistenceController
     12     private let sink: @Sendable (UUID, String) async -> Void
     13 
     14     private var pending: PlayerSelection?
     15     private var lastPublished: PlayerSelection?
     16     private var debounceTask: Task<Void, Never>?
     17     private var gameID: UUID?
     18     private var authorID: String?
     19     /// The local user's display name at session start. Used as a fallback
     20     /// when `write` has to insert a fresh `PlayerEntity` row before
     21     /// `NameBroadcaster` has fanned out — without it the row would have no
     22     /// name and the SyncEngine would refuse to build a CKRecord, dropping
     23     /// the cursor on the floor.
     24     private var fallbackName: String = ""
     25 
     26     init(
     27         debounceInterval: Duration = .milliseconds(300),
     28         persistence: PersistenceController,
     29         sink: @escaping @Sendable (UUID, String) async -> Void
     30     ) {
     31         self.debounceInterval = debounceInterval
     32         self.persistence = persistence
     33         self.sink = sink
     34     }
     35 
     36     /// Starts publishing for a new puzzle session. Resets dedupe state so the
     37     /// first selection from the new session always flushes. `currentName` is
     38     /// the local user's display name at the time the puzzle was opened — used
     39     /// only when no PlayerEntity row exists yet for this (game, author).
     40     func begin(gameID: UUID, authorID: String, currentName: String) {
     41         self.gameID = gameID
     42         self.authorID = authorID
     43         fallbackName = currentName
     44         pending = nil
     45         lastPublished = nil
     46         debounceTask?.cancel()
     47         debounceTask = nil
     48     }
     49 
     50     /// Registers a new selection. Coalesces with any prior pending value and
     51     /// schedules a trailing-edge flush. Repeated identical selections are
     52     /// dropped.
     53     func publish(_ selection: PlayerSelection) {
     54         guard gameID != nil, authorID != nil else { return }
     55         if pending == selection || (pending == nil && lastPublished == selection) {
     56             return
     57         }
     58         pending = selection
     59         scheduleDebounce()
     60     }
     61 
     62     /// Records a "no selection" — used on puzzle teardown so the peer's
     63     /// outline disappears promptly instead of waiting for staleness.
     64     func clear() {
     65         guard gameID != nil, authorID != nil else { return }
     66         pending = nil
     67         debounceTask?.cancel()
     68         debounceTask = nil
     69         Task { await self.flushClear() }
     70     }
     71 
     72     /// Flushes any pending selection immediately and cancels the debounce.
     73     func flush() async {
     74         debounceTask?.cancel()
     75         debounceTask = nil
     76         await performFlush()
     77     }
     78 
     79     private func scheduleDebounce() {
     80         debounceTask?.cancel()
     81         let interval = debounceInterval
     82         debounceTask = Task { [weak self] in
     83             try? await Task.sleep(for: interval)
     84             if Task.isCancelled { return }
     85             await self?.debouncedFlush()
     86         }
     87     }
     88 
     89     private func debouncedFlush() async {
     90         debounceTask = nil
     91         await performFlush()
     92     }
     93 
     94     private func performFlush() async {
     95         guard let gameID, let authorID, let selection = pending else { return }
     96         if selection == lastPublished { return }
     97         pending = nil
     98         lastPublished = selection
     99         await write(gameID: gameID, authorID: authorID, selection: selection)
    100         await sink(gameID, authorID)
    101     }
    102 
    103     private func flushClear() async {
    104         guard let gameID, let authorID else { return }
    105         if lastPublished == nil { return }
    106         lastPublished = nil
    107         await write(gameID: gameID, authorID: authorID, selection: nil)
    108         await sink(gameID, authorID)
    109     }
    110 
    111     private func write(
    112         gameID: UUID,
    113         authorID: String,
    114         selection: PlayerSelection?
    115     ) async {
    116         let context = persistence.container.newBackgroundContext()
    117         context.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
    118         let now = Date()
    119         let fallbackName = self.fallbackName
    120         context.performAndWait {
    121             let recordName = RecordSerializer.recordName(forPlayerInGame: gameID, authorID: authorID)
    122             let req = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity")
    123             req.predicate = NSPredicate(format: "ckRecordName == %@", recordName)
    124             req.fetchLimit = 1
    125             let entity: PlayerEntity
    126             if let existing = try? context.fetch(req).first {
    127                 entity = existing
    128                 // Don't overwrite a name that NameBroadcaster has set; only
    129                 // backfill if it's missing or empty so the outgoing record is
    130                 // never `name=""`.
    131                 if (entity.name ?? "").isEmpty, !fallbackName.isEmpty {
    132                     entity.name = fallbackName
    133                 }
    134             } else {
    135                 let gameReq = NSFetchRequest<GameEntity>(entityName: "GameEntity")
    136                 gameReq.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
    137                 gameReq.fetchLimit = 1
    138                 guard let game = try? context.fetch(gameReq).first else { return }
    139                 entity = PlayerEntity(context: context)
    140                 entity.game = game
    141                 entity.ckRecordName = recordName
    142                 entity.authorID = authorID
    143                 if !fallbackName.isEmpty {
    144                     entity.name = fallbackName
    145                 }
    146             }
    147             entity.updatedAt = now
    148             if let selection {
    149                 entity.selRow = NSNumber(value: Int64(selection.row))
    150                 entity.selCol = NSNumber(value: Int64(selection.col))
    151                 entity.selDir = NSNumber(value: Int64(selection.direction.rawValue))
    152             } else {
    153                 entity.selRow = nil
    154                 entity.selCol = nil
    155                 entity.selDir = nil
    156             }
    157             if context.hasChanges {
    158                 try? context.save()
    159             }
    160         }
    161     }
    162 }