crossmate

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

EngagementLifecycle.swift (23569B)


      1 import Foundation
      2 
      3 /// Drives the live engagement (websocket) lifecycle for open puzzles,
      4 /// extracted from `AppServices`: room reconcile/mint, the scheduled-teardown,
      5 /// reconnect-backstop, and lease-expiry timers, and inbound channel
      6 /// event/message handling. `AppServices` composes one instance and remains
      7 /// the owner of the host/status/store objects it shares with other surfaces
      8 /// (`PlayerRoster`, the selection publisher, the grid-freshen debounce).
      9 @MainActor
     10 final class EngagementLifecycle {
     11     static let engagementTeardownDelaySeconds = 120
     12     static let engagementTeardownDelay: Duration = .seconds(engagementTeardownDelaySeconds)
     13     /// How often a foregrounded shared puzzle re-runs the engagement reconnect
     14     /// check. Engagement auto-connect is otherwise edge-triggered (channel
     15     /// close, foreground, a peer's cursor move), so a drop whose re-connect
     16     /// edge never lands — a failed connect, a `presenceUntil`-only lease refresh, an
     17     /// edge lost to suspension — stays disconnected until the user nudges it
     18     /// manually. This timer is the backstop. It re-runs `peerPresenceMayHave\
     19     /// Changed`, which is a no-op unless the coordinator is idle *and* a peer
     20     /// holds a live lease, so a steady-state live (or peerless) session does
     21     /// no work and writes nothing; the coordinator's connecting-state guard
     22     /// caps any actual re-hail rate.
     23     static let engagementReconnectInterval: Duration = .seconds(30)
     24 
     25     private let preferences: PlayerPreferences
     26     private let persistence: PersistenceController
     27     private let store: GameStore
     28     private let identity: AuthorIdentity
     29     private let syncMonitor: SyncMonitor
     30     private let engagementHost: EngagementHost
     31     private let engagementStatus: EngagementStatus
     32     private let engagementStore: EngagementStore
     33     /// Whether the app is foreground-active — `AppServices.isAppForeground`,
     34     /// the single source of truth a background CKSyncEngine wake can't spoof.
     35     private let isAppForeground: () -> Bool
     36     /// Renews this device's read lease for the game —
     37     /// `AppServices.publishReadCursor(for:mode:.activeLease)`.
     38     private let renewReadLease: (UUID) async -> Void
     39     private let ensureICloudSyncStarted: () async -> Bool
     40 
     41     private lazy var engagementCoordinator = EngagementCoordinator(
     42         host: engagementHost,
     43         localAuthorID: { [weak self] in
     44             await MainActor.run { self?.identity.currentID }
     45         },
     46         log: { [weak self] message in
     47             await MainActor.run { self?.syncMonitor.note(message) }
     48         }
     49     )
     50 
     51     private var latestLocalSelections: [UUID: PlayerSelection] = [:]
     52     private var scheduledEngagementEndTasks: [UUID: Task<Void, Never>] = [:]
     53     private var engagementReconnectTasks: [UUID: Task<Void, Never>] = [:]
     54     private var engagementLeaseExpiryTasks: [UUID: Task<Void, Never>] = [:]
     55 
     56     init(
     57         preferences: PlayerPreferences,
     58         persistence: PersistenceController,
     59         store: GameStore,
     60         identity: AuthorIdentity,
     61         syncMonitor: SyncMonitor,
     62         engagementHost: EngagementHost,
     63         engagementStatus: EngagementStatus,
     64         engagementStore: EngagementStore,
     65         isAppForeground: @escaping () -> Bool,
     66         renewReadLease: @escaping (UUID) async -> Void,
     67         ensureICloudSyncStarted: @escaping () async -> Bool
     68     ) {
     69         self.preferences = preferences
     70         self.persistence = persistence
     71         self.store = store
     72         self.identity = identity
     73         self.syncMonitor = syncMonitor
     74         self.engagementHost = engagementHost
     75         self.engagementStatus = engagementStatus
     76         self.engagementStore = engagementStore
     77         self.isAppForeground = isAppForeground
     78         self.renewReadLease = renewReadLease
     79         self.ensureICloudSyncStarted = ensureICloudSyncStarted
     80     }
     81 
     82     /// Drives one game's live connection toward the room its Game record
     83     /// advertises. Reads the shared `engagement` creds and whether any peer
     84     /// holds a live read lease; if a peer is present (or `force`) and no creds
     85     /// exist yet, mints a room and writes it to the Game record (any present
     86     /// participant may — record-level LWW converges concurrent mints). Then
     87     /// hands the desired creds to the coordinator, which connects, migrates, or
     88     /// tears down to match. `force` is the manual "start a room now" path,
     89     /// which mints and connects without waiting to see a present peer.
     90     func reconcileEngagement(gameID: UUID) async {
     91         guard preferences.isICloudSyncEnabled else { return }
     92         // A completed game is not a live session. Never connect (this is also
     93         // the reopen-from-Completed path, which re-runs through
     94         // `startEngagementIfPossible`) and tear down anything still up.
     95         guard !store.isCompleted(gameID: gameID) else {
     96             cancelEngagementLeaseExpiry(gameID: gameID)
     97             await engagementCoordinator.reconcile(gameID: gameID, creds: nil, hasPeer: false)
     98             return
     99         }
    100         // A live socket is a foreground-only affair — the same rule
    101         // `publishReadCursor` enforces for the read lease. Inbound presence /
    102         // engagement changes also arrive on background CKSyncEngine wakes
    103         // (`onRemotePlayerPresenceChanged` / `onRemoteEngagementChanged`), and a
    104         // background reconcile used to re-dial the socket on every wake. That is
    105         // futile: the `.default` URLSession WebSocket can't survive suspension,
    106         // so it just storms connect → abort → reconnect until the next push.
    107         // When not foreground, leave the current connection (and the
    108         // scheduled-end grace that rides out a transient `.inactive`) untouched
    109         // and refuse to escalate; the foreground `.active` path re-reconciles
    110         // via `startEngagementIfPossible`. Teardown still flows from socket
    111         // abort, `scheduleEngagementEnd`, and the completed-game guard above.
    112         guard isAppForeground() else {
    113             syncMonitor.note("engagement: reconcile skipped for \(gameID.uuidString): backgrounded")
    114             return
    115         }
    116         let soonestLease = await AppServices.soonestPeerLease(
    117             persistence: persistence,
    118             gameID: gameID,
    119             localAuthorID: identity.currentID
    120         )
    121         let hasPeer = soonestLease != nil
    122         var creds = EngagementRoomCredentials.decode(store.engagement(for: gameID))
    123         if creds == nil, hasPeer {
    124             if let minted = try? EngagementRoomCredentials.fresh(),
    125                let encoded = try? minted.encoded(),
    126                store.setEngagement(encoded, for: gameID) {
    127                 creds = minted
    128                 syncMonitor.note(
    129                     "engagement: minted room \(minted.roomID.uuidString) for \(gameID.uuidString)"
    130                 )
    131             }
    132         }
    133         let outcome = await engagementCoordinator.reconcile(gameID: gameID, creds: creds, hasPeer: hasPeer)
    134         if outcome == .roomRejected, let rejected = creds {
    135             // The worker holds a different secret for this room ID (e.g. the
    136             // room was squatted after idle expiry). Reconnecting with these
    137             // creds can never succeed, so drop them from the Game record; a
    138             // later reconcile mints a fresh room and LWW converges the peers
    139             // onto it.
    140             if store.setEngagement(nil, for: gameID) {
    141                 syncMonitor.note(
    142                     "engagement: cleared rejected room \(rejected.roomID.uuidString) for \(gameID.uuidString)"
    143                 )
    144             }
    145         }
    146         // When the soonest present peer drops out of presence, re-reconcile:
    147         // tear down (dropping the bolt) if no renewal arrived, or reschedule
    148         // onto the new horizon if one did. That instant is the lease plus the
    149         // presence grace, not the bare lease — a peer stays present through the
    150         // grace, so tearing down at the raw lease would race it. The 30s
    151         // reconnect tick remains the coarse backstop. A no-peer reconcile
    152         // has no lease to watch, so this just clears any prior wake.
    153         scheduleEngagementLeaseExpiry(
    154             gameID: gameID,
    155             at: soonestLease?.addingTimeInterval(PeerPresence.presenceGrace)
    156         )
    157     }
    158 
    159     func startEngagementIfPossible(gameID: UUID) async {
    160         cancelScheduledEngagementEnd(gameID: gameID)
    161         guard preferences.isICloudSyncEnabled else { return }
    162         guard await ensureICloudSyncStarted() else { return }
    163         await reconcileEngagement(gameID: gameID)
    164         startEngagementReconnectRetry(gameID: gameID)
    165     }
    166 
    167     func endEngagement(gameID: UUID) async {
    168         cancelScheduledEngagementEnd(gameID: gameID)
    169         cancelEngagementReconnectRetry(gameID: gameID)
    170         cancelEngagementLeaseExpiry(gameID: gameID)
    171         syncMonitor.note("engagement: ending for \(gameID.uuidString)")
    172         engagementStatus.setLive(false, gameID: gameID)
    173         latestLocalSelections[gameID] = nil
    174         engagementStore.clear(gameID: gameID)
    175         await engagementCoordinator.teardown(gameID: gameID)
    176     }
    177 
    178     func scheduleEngagementEnd(gameID: UUID) {
    179         cancelScheduledEngagementEnd(gameID: gameID)
    180         // Leaving the puzzle stops the reconnect backstop regardless of
    181         // whether a channel ever went live — otherwise a puzzle that was
    182         // opened but never connected would tick forever. A quick return
    183         // re-arms it via `startEngagementIfPossible`.
    184         cancelEngagementReconnectRetry(gameID: gameID)
    185         cancelEngagementLeaseExpiry(gameID: gameID)
    186         guard engagementStatus.isLive(gameID: gameID) else { return }
    187         syncMonitor.note(
    188             "engagement: scheduled ending for \(gameID.uuidString) " +
    189             "in \(Self.engagementTeardownDelaySeconds)s"
    190         )
    191         scheduledEngagementEndTasks[gameID] = Task { [weak self] in
    192             do {
    193                 try await Task.sleep(for: Self.engagementTeardownDelay)
    194             } catch {
    195                 return
    196             }
    197             await self?.finishScheduledEngagementEnd(gameID: gameID)
    198         }
    199     }
    200 
    201     func cancelScheduledEngagementEnd(gameID: UUID) {
    202         guard let task = scheduledEngagementEndTasks.removeValue(forKey: gameID) else { return }
    203         task.cancel()
    204         syncMonitor.note("engagement: cancelled scheduled ending for \(gameID.uuidString)")
    205     }
    206 
    207     private func finishScheduledEngagementEnd(gameID: UUID) async {
    208         guard scheduledEngagementEndTasks.removeValue(forKey: gameID) != nil else { return }
    209         syncMonitor.note("engagement: scheduled ending fired for \(gameID.uuidString)")
    210         await endEngagement(gameID: gameID)
    211     }
    212 
    213     /// Arms the periodic engagement-presence tick for `gameID`, replacing any
    214     /// prior timer for the same game (so a re-arm on foreground doesn't stack).
    215     /// Each tick does two things:
    216     ///
    217     /// 1. Renews our own read lease (`publishReadCursor(.activeLease)`, which
    218     ///    is floor-gated so it writes at most ~once per 5 min). This is what
    219     ///    makes `presenceUntil` a true "foregrounded on this puzzle" heartbeat: a
    220     ///    peer's own typing never advances their own lease, so without this an
    221     ///    active solo driver would lapse mid-session and look absent.
    222     /// 2. Re-runs the coordinator's presence check, which both reconnects a
    223     ///    dropped channel and tears down a live channel whose peer's lease has
    224     ///    expired — neither of which is safe until (1) keeps present peers
    225     ///    from falsely lapsing.
    226     ///
    227     /// Both are no-ops in steady state (lease has >5 min left; coordinator is
    228     /// live with a present peer). Cancelled on leave via `scheduleEngagement
    229     /// End`/`endEngagement`; goes dormant under suspension (the sleep can't
    230     /// advance) and is re-armed by the foreground `startEngagementIfPossible`.
    231     private func startEngagementReconnectRetry(gameID: UUID) {
    232         engagementReconnectTasks[gameID]?.cancel()
    233         engagementReconnectTasks[gameID] = Task { [weak self] in
    234             while !Task.isCancelled {
    235                 try? await Task.sleep(for: Self.engagementReconnectInterval)
    236                 guard !Task.isCancelled, let self else { return }
    237                 await self.renewReadLease(gameID)
    238                 await self.reconcileEngagement(gameID: gameID)
    239             }
    240         }
    241     }
    242 
    243     func cancelEngagementReconnectRetry(gameID: UUID) {
    244         guard let task = engagementReconnectTasks.removeValue(forKey: gameID) else { return }
    245         task.cancel()
    246         syncMonitor.note("engagement: reconnect backstop cancelled for \(gameID.uuidString)")
    247     }
    248 
    249     /// Arms a one-shot reconcile at `expiry` — the soonest moment a present
    250     /// peer's lease lapses — replacing any prior wake for this game. When it
    251     /// fires, `reconcileEngagement` tears the channel down if no renewal landed
    252     /// (so the bolt drops at expiry, not up to a tick later) or reschedules
    253     /// onto the renewed horizon. `nil` (no-peer) just clears the wake.
    254     private func scheduleEngagementLeaseExpiry(gameID: UUID, at expiry: Date?) {
    255         engagementLeaseExpiryTasks[gameID]?.cancel()
    256         engagementLeaseExpiryTasks[gameID] = nil
    257         guard let expiry else { return }
    258         let interval = max(0, expiry.timeIntervalSinceNow)
    259         engagementLeaseExpiryTasks[gameID] = Task { [weak self] in
    260             try? await Task.sleep(for: .seconds(interval))
    261             guard !Task.isCancelled, let self else { return }
    262             await self.reconcileEngagement(gameID: gameID)
    263         }
    264     }
    265 
    266     private func cancelEngagementLeaseExpiry(gameID: UUID) {
    267         guard let task = engagementLeaseExpiryTasks.removeValue(forKey: gameID) else { return }
    268         task.cancel()
    269     }
    270 
    271     func noteLocalSelection(_ selection: PlayerSelection, gameID: UUID) async {
    272         latestLocalSelections[gameID] = selection
    273         guard engagementStatus.isLive(gameID: gameID),
    274               let localAuthorID = identity.currentID,
    275               !localAuthorID.isEmpty
    276         else { return }
    277         let update = EngagementSelectionUpdate(
    278             gameID: gameID,
    279             authorID: localAuthorID,
    280             deviceID: RecordSerializer.localDeviceID,
    281             selection: selection
    282         )
    283         let auth = EngagementMessageAuthenticator.authTags(
    284             for: EngagementMessage(selection: update),
    285             senderAuthorID: localAuthorID,
    286             recipientAuthorIDs: store.participantAuthorIDs(gameID: gameID)
    287         )
    288         guard !auth.isEmpty else { return }
    289         await engagementCoordinator.sendSelection(update, senderAuthorID: localAuthorID, auth: auth)
    290     }
    291 
    292     /// Forwards a locally-applied cell edit over the live channel. No-op while
    293     /// no channel is live for the game — the durable Moves sync covers it.
    294     func sendLocalCellEdit(_ edit: RealtimeCellEdit) {
    295         guard engagementStatus.isLive(gameID: edit.gameID) else { return }
    296         guard let localAuthorID = identity.currentID, !localAuthorID.isEmpty else { return }
    297         let auth = EngagementMessageAuthenticator.authTags(
    298             for: EngagementMessage(cellEdit: edit),
    299             senderAuthorID: localAuthorID,
    300             recipientAuthorIDs: store.participantAuthorIDs(gameID: edit.gameID)
    301         )
    302         guard !auth.isEmpty else { return }
    303         Task { await engagementCoordinator.sendCellEdit(edit, senderAuthorID: localAuthorID, auth: auth) }
    304     }
    305 
    306     /// Batch companion to `sendLocalCellEdit`. A gesture larger than
    307     /// `EngagementMessage.maxBatchEdits` (a whole-grid check on a very large
    308     /// puzzle) is split into cap-sized frames, each MAC'd independently, so it
    309     /// stays inside the receive bounds every peer now enforces.
    310     func sendLocalCellEdits(_ edits: [RealtimeCellEdit]) {
    311         guard let gameID = edits.first?.gameID else { return }
    312         guard engagementStatus.isLive(gameID: gameID) else { return }
    313         guard let localAuthorID = identity.currentID, !localAuthorID.isEmpty else { return }
    314         let recipientAuthorIDs = store.participantAuthorIDs(gameID: gameID)
    315         for chunk in EngagementMessage.batchChunks(edits) {
    316             let auth = EngagementMessageAuthenticator.authTags(
    317                 for: EngagementMessage(cellEdits: chunk),
    318                 senderAuthorID: localAuthorID,
    319                 recipientAuthorIDs: recipientAuthorIDs
    320             )
    321             guard !auth.isEmpty else { return }
    322             Task { await engagementCoordinator.sendCellEdits(chunk, senderAuthorID: localAuthorID, auth: auth) }
    323         }
    324     }
    325 
    326     func handleEngagementEvent(_ event: EngagementHost.Event) {
    327         switch event {
    328         case .channelOpen(let engagementID):
    329             syncMonitor.note("engagement: channel open \(engagementID.uuidString)")
    330             Task { [weak self] in
    331                 guard let self,
    332                       let gameID = await self.engagementCoordinator.channelOpened(engagementID: engagementID)
    333                 else { return }
    334                 self.engagementStatus.setLive(true, gameID: gameID)
    335                 if let selection = self.latestLocalSelections[gameID] {
    336                     await self.noteLocalSelection(selection, gameID: gameID)
    337                 }
    338             }
    339         case .channelMessage(let engagementID, let message):
    340             if let envelope = EngagementMessage.decode(message) {
    341                 // The channel, not the payload, decides which game a frame may
    342                 // touch; the coordinator owns that binding, so resolving it is
    343                 // an actor hop. Frames on a channel it no longer tracks (stale
    344                 // socket after a room migration or close) are dropped — the
    345                 // Moves/CloudKit path converges them. The hop can reorder
    346                 // frames, which per-cell/per-author last-writer-wins absorbs.
    347                 Task { [weak self] in
    348                     guard let self else { return }
    349                     guard let gameID = await self.engagementCoordinator.gameID(for: engagementID) else {
    350                         self.syncMonitor.note(
    351                             "engagement: dropped \(envelope.kind.rawValue) on unbound channel " +
    352                             engagementID.uuidString
    353                         )
    354                         return
    355                     }
    356                     self.handleEngagementMessage(envelope, engagementID: engagementID, gameID: gameID)
    357                 }
    358             } else {
    359                 syncMonitor.note("engagement: channel message \(engagementID.uuidString) bytes=\(message.count)")
    360             }
    361         case .channelClose(let engagementID):
    362             syncMonitor.note("engagement: channel close \(engagementID.uuidString)")
    363             Task { [weak self] in
    364                 guard let self,
    365                       let gameID = await self.engagementCoordinator.channelClosed(engagementID: engagementID)
    366                 else { return }
    367                 self.engagementStatus.setLive(false, gameID: gameID)
    368                 self.engagementStore.clear(gameID: gameID)
    369                 await self.startEngagementIfPossible(gameID: gameID)
    370             }
    371         case .diagnostic(let engagementID, let message):
    372             syncMonitor.note(
    373                 "engagement: diagnostic \(engagementID?.uuidString ?? "unknown"): \(message)"
    374             )
    375         case .error(let engagementID, let message):
    376             syncMonitor.note("engagement: error \(engagementID?.uuidString ?? "unknown"): \(message)")
    377         }
    378     }
    379 
    380     /// Gate for a mutating inbound frame: the relay is untrusted, so a frame is
    381     /// applied only if its per-recipient MAC verifies against the sender's
    382     /// friend key, every payload authorID is the verified sender, every payload
    383     /// gameID is the channel's game (`gameID`), and the sender is a current
    384     /// participant of that game. A drop is safe — the durable Moves/CloudKit
    385     /// path still converges the change.
    386     private func verifyInbound(
    387         _ envelope: EngagementMessage,
    388         engagementID: UUID,
    389         gameID: UUID,
    390         kind: String
    391     ) -> Bool {
    392         guard let localAuthorID = identity.currentID,
    393               EngagementMessageAuthenticator.verify(
    394                 envelope,
    395                 localAuthorID: localAuthorID,
    396                 channelGameID: gameID,
    397                 participantAuthorIDs: store.participantAuthorIDs(gameID: gameID)
    398               )
    399         else {
    400             syncMonitor.note(
    401                 "engagement: dropped unauthenticated \(kind) \(engagementID.uuidString) " +
    402                 "sender=\(envelope.senderAuthorID.prefix(8))"
    403             )
    404             return false
    405         }
    406         return true
    407     }
    408 
    409     private func handleEngagementMessage(_ envelope: EngagementMessage, engagementID: UUID, gameID: UUID) {
    410         let latencyMs = max(0, Int(Date().timeIntervalSince(envelope.sentAt) * 1000))
    411         switch envelope.kind {
    412         case .debugText:
    413             syncMonitor.note(
    414                 "engagement: received \(envelope.kind.rawValue) \(engagementID.uuidString) " +
    415                 "latency=\(latencyMs)ms: \(envelope.text)"
    416             )
    417         case .cellEdit:
    418             guard let edit = envelope.cellEdit else {
    419                 syncMonitor.note("engagement: ignored malformed cellEdit \(engagementID.uuidString)")
    420                 return
    421             }
    422             guard verifyInbound(envelope, engagementID: engagementID, gameID: gameID, kind: "cellEdit") else { return }
    423             let applied = store.applyRealtimeCellEdit(edit)
    424             if applied {
    425                 syncMonitor.note(
    426                     "engagement: applied cellEdit \(engagementID.uuidString) " +
    427                     "r=\(edit.row) c=\(edit.col) device=\(edit.deviceID.prefix(8)) " +
    428                     "latency=\(latencyMs)ms"
    429                 )
    430             } else {
    431                 syncMonitor.note(
    432                     "engagement: rejected cellEdit \(engagementID.uuidString) " +
    433                     "r=\(edit.row) c=\(edit.col) device=\(edit.deviceID.prefix(8)) " +
    434                     "latency=\(latencyMs)ms"
    435                 )
    436             }
    437         case .cellEditBatch:
    438             guard let edits = envelope.cellEdits, !edits.isEmpty else {
    439                 syncMonitor.note("engagement: ignored malformed cellEditBatch \(engagementID.uuidString)")
    440                 return
    441             }
    442             guard verifyInbound(envelope, engagementID: engagementID, gameID: gameID, kind: "cellEditBatch") else { return }
    443             let applied = store.applyRealtimeCellEdits(edits)
    444             syncMonitor.note(
    445                 "engagement: applied cellEditBatch \(engagementID.uuidString) " +
    446                 "applied=\(applied)/\(edits.count) device=\(edits[0].deviceID.prefix(8)) " +
    447                 "latency=\(latencyMs)ms"
    448             )
    449         case .selection:
    450             guard let selection = envelope.selection else {
    451                 syncMonitor.note("engagement: ignored malformed selection \(engagementID.uuidString)")
    452                 return
    453             }
    454             guard verifyInbound(envelope, engagementID: engagementID, gameID: gameID, kind: "selection") else { return }
    455             engagementStore.set(selection)
    456             syncMonitor.note(
    457                 "engagement: received selection \(engagementID.uuidString) " +
    458                 "r=\(selection.row) c=\(selection.col) device=\(selection.deviceID.prefix(8)) " +
    459                 "latency=\(latencyMs)ms"
    460             )
    461         }
    462     }
    463 }