crossmate

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

AppServices.swift (23249B)


      1 import CloudKit
      2 import Foundation
      3 import UserNotifications
      4 
      5 @MainActor
      6 final class AppServices {
      7     let persistence: PersistenceController
      8     let store: GameStore
      9     let syncEngine: SyncEngine
     10     let syncMonitor: SyncMonitor
     11     let nytAuth: NYTAuthService
     12     let driveMonitor: DriveMonitor
     13     let nytFetcher: NYTPuzzleFetcher
     14     let inputMonitor: InputMonitor
     15     let movesUpdater: MovesUpdater
     16     let playerSelectionPublisher: PlayerSelectionPublisher
     17     let identity: AuthorIdentity
     18     let shareController: ShareController
     19     let colorStore: GamePlayerColorStore
     20     let cloudService: CloudService
     21     let importService: ImportService
     22 
     23     let preferences: PlayerPreferences
     24 
     25     private let ckContainer = CKContainer(identifier: "iCloud.net.inqk.crossmate.v3")
     26     private var started = false
     27     private var syncStarted = false
     28     private(set) var playerNamePublisher: PlayerNamePublisher?
     29     private var isReadyForShareAcceptance = false
     30     private var isProcessingShareAcceptanceQueue = false
     31     private var pendingShareMetadatas: [CKShare.Metadata] = []
     32 
     33     init() {
     34         let preferences = PlayerPreferences()
     35         self.preferences = preferences
     36         let persistence = PersistenceController()
     37         self.persistence = persistence
     38         let syncEngine = SyncEngine(container: self.ckContainer, persistence: persistence)
     39         self.syncEngine = syncEngine
     40         self.syncMonitor = SyncMonitor()
     41         self.nytAuth = NYTAuthService()
     42         self.driveMonitor = DriveMonitor()
     43         self.nytFetcher = NYTPuzzleFetcher { NYTAuthService.currentCookie() }
     44         self.inputMonitor = InputMonitor()
     45         let identity = AuthorIdentity()
     46         self.identity = identity
     47 
     48         let movesUpdater = MovesUpdater(
     49             debounceInterval: .milliseconds(500),
     50             persistence: persistence,
     51             writerAuthorIDProvider: { await MainActor.run { identity.currentID } },
     52             sink: { gameIDs in
     53                 let isEnabled = await MainActor.run { preferences.isICloudSyncEnabled }
     54                 guard isEnabled else { return }
     55                 await syncEngine.enqueueMoves(gameIDs: gameIDs)
     56             },
     57             sessionPingSink: { [preferences] gameID, authorID in
     58                 guard await MainActor.run(body: { preferences.isICloudSyncEnabled }) else { return }
     59                 let name = await MainActor.run { preferences.name }
     60                 await syncEngine.enqueuePing(
     61                     kind: .session,
     62                     scope: nil,
     63                     gameID: gameID,
     64                     authorID: authorID,
     65                     playerName: name
     66                 )
     67             }
     68         )
     69         self.movesUpdater = movesUpdater
     70 
     71         let colorStore = GamePlayerColorStore()
     72         self.colorStore = colorStore
     73         let onGameDeletedHandler = Self.makeOnGameDeleted(
     74             syncEngine: syncEngine,
     75             colorStore: colorStore
     76         )
     77 
     78         let store = GameStore(
     79             persistence: persistence,
     80             movesUpdater: movesUpdater,
     81             authorIDProvider: { identity.currentID },
     82             onGameCreated: { [preferences, syncEngine] ckRecordName in
     83                 Task {
     84                     guard await MainActor.run(body: { preferences.isICloudSyncEnabled }) else { return }
     85                     await syncEngine.enqueueGame(ckRecordName: ckRecordName)
     86                 }
     87             },
     88             onGameUpdated: { [preferences, syncEngine] ckRecordName in
     89                 Task {
     90                     guard await MainActor.run(body: { preferences.isICloudSyncEnabled }) else { return }
     91                     await syncEngine.enqueueGame(ckRecordName: ckRecordName)
     92                 }
     93             },
     94             onGameDeleted: { [preferences] deletion in
     95                 guard preferences.isICloudSyncEnabled else { return }
     96                 onGameDeletedHandler(deletion)
     97             }
     98         )
     99         self.store = store
    100 
    101         self.shareController = ShareController(
    102             container: self.ckContainer,
    103             persistence: persistence,
    104             syncEngine: syncEngine,
    105             syncMonitor: self.syncMonitor
    106         )
    107         self.shareController.onShareSaved = { [weak store] gameID in
    108             store?.markShared(gameID: gameID)
    109         }
    110         self.playerSelectionPublisher = PlayerSelectionPublisher(
    111             persistence: persistence,
    112             sink: { gameID, authorID in
    113                 let isEnabled = await MainActor.run { preferences.isICloudSyncEnabled }
    114                 guard isEnabled else { return }
    115                 await syncEngine.enqueuePlayerRecord(gameID: gameID, authorID: authorID)
    116             }
    117         )
    118         self.cloudService = CloudService(
    119             container: self.ckContainer,
    120             syncEngine: syncEngine,
    121             syncMonitor: self.syncMonitor,
    122             store: store
    123         )
    124         self.importService = ImportService(store: store, driveMonitor: self.driveMonitor)
    125     }
    126 
    127     func start(appDelegate: AppDelegate) async {
    128         guard !started else { return }
    129         started = true
    130 
    131         nytAuth.loadStoredSession()
    132         driveMonitor.start()
    133 
    134         appDelegate.onRemoteNotification = { summary, scope in
    135             await self.handleRemoteNotification(summary: summary, scope: scope)
    136         }
    137         appDelegate.onAPNsRegistrationResult = { [syncMonitor] message in
    138             syncMonitor.note(message)
    139         }
    140         CloudShareAcceptanceBroker.shared.onAcceptShare = { metadata in
    141             await self.enqueueShareAcceptance(metadata)
    142         }
    143 
    144         await syncEngine.setTracer { [syncMonitor] message in
    145             syncMonitor.note(message)
    146         }
    147 
    148         await syncEngine.setLocalAuthorIDProvider { [identity] in
    149             identity.currentID
    150         }
    151 
    152         await syncEngine.setOnRemoteMovesUpdated { [store, identity] gameIDs in
    153             store.noteIncomingMovesUpdate(
    154                 gameIDs: gameIDs,
    155                 currentAuthorID: identity.currentID
    156             )
    157             if let currentID = store.currentEntity?.id,
    158                gameIDs.contains(currentID) {
    159                 store.refreshCurrentGame()
    160             }
    161         }
    162 
    163         await syncEngine.setOnPings { [weak self] pings in
    164             guard let self else { return }
    165             await self.presentPings(pings)
    166         }
    167 
    168         await syncEngine.setOnAccountChange { [weak self] in
    169             guard let self else { return }
    170             await self.identity.refresh(using: self.ckContainer)
    171         }
    172 
    173         await syncEngine.setOnGameAccessRevoked { [store] gameID in
    174             store.markAccessRevoked(gameID: gameID)
    175         }
    176 
    177         await syncEngine.setOnGameRemoved { [store] gameID in
    178             store.handleRemoteRemoval(gameID: gameID)
    179         }
    180 
    181         cloudService.onShareJoined = { [weak self] gameID in
    182             guard let self else { return }
    183             guard self.preferences.isICloudSyncEnabled,
    184                   let authorID = self.identity.currentID
    185             else { return }
    186             await self.syncEngine.enqueuePing(
    187                 kind: .join,
    188                 scope: nil,
    189                 gameID: gameID,
    190                 authorID: authorID,
    191                 playerName: self.preferences.name
    192             )
    193         }
    194 
    195         // PlayerNamePublisher fans out name changes to all shared/joined games.
    196         // PuzzleDisplayView also calls `broadcastName()` when a shared puzzle
    197         // is opened, which covers first-sync-after-share-create / accept.
    198         playerNamePublisher = PlayerNamePublisher(
    199             preferences: preferences,
    200             persistence: persistence,
    201             authorIdentity: identity,
    202             enqueuePlayerRecord: { [preferences, syncEngine] gameID, authorID in
    203                 let isEnabled = await MainActor.run { preferences.isICloudSyncEnabled }
    204                 guard isEnabled else { return }
    205                 await syncEngine.enqueuePlayerRecord(gameID: gameID, authorID: authorID)
    206             }
    207         )
    208 
    209         guard await ensureICloudSyncStarted() else {
    210             syncMonitor.note("iCloud sync disabled — initial fetch/push skipped")
    211             return
    212         }
    213 
    214         await syncMonitor.run("initial fetch") {
    215             try await syncEngine.fetchChanges(source: "initial")
    216         }
    217         await syncMonitor.run("initial push") {
    218             try await syncEngine.pushChanges()
    219         }
    220         await refreshSnapshot()
    221     }
    222 
    223     func enqueueShareAcceptance(_ metadata: CKShare.Metadata) async {
    224         guard preferences.isICloudSyncEnabled else {
    225             syncMonitor.note("share acceptance ignored while iCloud sync is disabled")
    226             return
    227         }
    228         pendingShareMetadatas.append(metadata)
    229         syncMonitor.note(
    230             "share acceptance queued: container=\(metadata.containerIdentifier)"
    231         )
    232         await processPendingShareAcceptances()
    233     }
    234 
    235     func syncOnForeground() async {
    236         await movesUpdater.flush()
    237         guard await ensureICloudSyncStarted() else { return }
    238         let recoveredMoveCount = await syncEngine.enqueueUnconfirmedMoves()
    239         if recoveredMoveCount > 0 {
    240             syncMonitor.note("recovered \(recoveredMoveCount) unconfirmed move(s) for CloudKit enqueue")
    241         }
    242         await syncMonitor.run("foreground fetch") {
    243             try await syncEngine.fetchChanges(source: "foreground")
    244         }
    245         await syncMonitor.run("foreground push") {
    246             try await syncEngine.pushChanges()
    247         }
    248         await refreshSnapshot()
    249     }
    250 
    251     func syncOnBackground() async {
    252         await movesUpdater.flush()
    253     }
    254 
    255     func cleanupPingsAfterCompletion(gameID: UUID) async {
    256         guard await ensureICloudSyncStarted() else { return }
    257         await syncMonitor.run("completed ping cleanup") {
    258             try await syncEngine.deleteNonWinPings(forCompletedGame: gameID)
    259         }
    260     }
    261 
    262     /// Pull-to-refresh action for the library. Discovers any zones the
    263     /// device hasn't seen yet on both database scopes, then runs the normal
    264     /// engine fetch so any in-flight changes also catch up. Bypasses
    265     /// CKSyncEngine's database-scope change delivery, which can lag behind
    266     /// reality when the engine has been idle.
    267     func refreshLibrary() async {
    268         guard await ensureICloudSyncStarted() else { return }
    269         // Private and shared hit different CloudKit databases, so their
    270         // direct-fetch phases run as an independent pair. Within each
    271         // scope, discovery still completes before known-zone updates so
    272         // any zone discovery just added is included in the same refresh.
    273         async let privatePhase: Void = refreshLibraryScope(.private, label: "private")
    274         async let sharedPhase:  Void = refreshLibraryScope(.shared,  label: "shared")
    275         _ = await (privatePhase, sharedPhase)
    276         await syncMonitor.run("library refresh: engine fetch") {
    277             try await syncEngine.fetchChanges(source: "library refresh")
    278         }
    279         await refreshSnapshot()
    280     }
    281 
    282     private func refreshLibraryScope(_ scope: CKDatabase.Scope, label: String) async {
    283         await syncMonitor.run("library refresh: \(label) discovery") {
    284             _ = try await self.syncEngine.discoverNewZonesDirect(scope: scope)
    285         }
    286         await syncMonitor.run("library refresh: \(label) known-zone updates") {
    287             _ = try await self.syncEngine.fetchKnownZoneUpdatesDirect(scope: scope)
    288         }
    289     }
    290 
    291     func syncOpenSharedPuzzle() async {
    292         await movesUpdater.flush()
    293         guard await ensureICloudSyncStarted() else { return }
    294         await syncMonitor.run("open-puzzle fetch") {
    295             try await syncEngine.fetchChanges(source: "open-puzzle poll")
    296         }
    297         await refreshSnapshot()
    298     }
    299 
    300     func makePlayerRoster(for gameID: UUID, preferences: PlayerPreferences) -> PlayerRoster {
    301         PlayerRoster(
    302             gameID: gameID,
    303             colorStore: colorStore,
    304             authorIdentity: identity,
    305             preferences: preferences,
    306             persistence: persistence,
    307             container: ckContainer,
    308             tracer: { [syncMonitor] message in syncMonitor.note(message) }
    309         )
    310     }
    311 
    312     private func handleRemoteNotification(summary: String, scope: CKDatabase.Scope?) async {
    313         guard preferences.isICloudSyncEnabled else {
    314             syncMonitor.note("remote notification ignored while iCloud sync is disabled")
    315             return
    316         }
    317         guard await ensureICloudSyncStarted() else { return }
    318         syncMonitor.note("remote notification: \(summary)")
    319 
    320         guard let scope, scope != .public else {
    321             await syncMonitor.run("remote-notification fetch") {
    322                 try await syncEngine.fetchChanges(source: "push")
    323             }
    324             await refreshSnapshot()
    325             return
    326         }
    327 
    328         if let activeGameID = activeGameID(in: scope) {
    329             // Hot path: collaborator activity on the open puzzle. The active
    330             // game's zone is already known, so we skip the zone-discovery
    331             // round-trip; new shared games arriving during this window are
    332             // picked up by the next push or by foregrounding. The direct
    333             // fetch and the ping fast-path read different record types and
    334             // are independent, so run them concurrently.
    335             async let activeFetch: Void = syncMonitor.run("remote-notification direct fetch") {
    336                 let handled = try await self.syncEngine.fetchPushChangesDirect(
    337                     scope: scope,
    338                     gameID: activeGameID
    339                 )
    340                 if !handled {
    341                     try await self.syncEngine.fetchChanges(source: "push")
    342                 }
    343             }
    344             async let pingFastPath: Void = syncMonitor.run("remote-notification ping fast-path") {
    345                 _ = try await self.syncEngine.fetchPushPingsDirect(scope: scope)
    346             }
    347             _ = await (activeFetch, pingFastPath)
    348         } else {
    349             // Cold path: no puzzle open. Discover any zones this device
    350             // hasn't seen yet (e.g. a freshly-accepted share or a game
    351             // started on another device of the same iCloud user) so the
    352             // ping fast-path can resolve pings whose zones are brand new.
    353             await syncMonitor.run("remote-notification zone discovery") {
    354                 _ = try await self.syncEngine.discoverNewZonesDirect(scope: scope)
    355             }
    356             await syncMonitor.run("remote-notification ping fast-path") {
    357                 _ = try await self.syncEngine.fetchPushPingsDirect(scope: scope)
    358             }
    359             await syncMonitor.run("remote-notification fetch") {
    360                 try await self.syncEngine.fetchChanges(source: "push")
    361             }
    362         }
    363 
    364         await refreshSnapshot()
    365     }
    366 
    367     private func activeGameID(in scope: CKDatabase.Scope) -> UUID? {
    368         guard let entity = store.currentEntity,
    369               let gameID = entity.id
    370         else { return nil }
    371         switch scope {
    372         case .private:
    373             return entity.databaseScope == 0 ? gameID : nil
    374         case .shared:
    375             return entity.databaseScope == 1 ? gameID : nil
    376         case .public:
    377             return nil
    378         @unknown default:
    379             return nil
    380         }
    381     }
    382 
    383     private func ensureICloudSyncStarted() async -> Bool {
    384         guard preferences.isICloudSyncEnabled else { return false }
    385         guard !syncStarted else { return true }
    386 
    387         await identity.refresh(using: ckContainer)
    388         await syncEngine.start()
    389         syncStarted = true
    390 
    391         let recoveredMoveCount = await syncEngine.enqueueUnconfirmedMoves()
    392         if recoveredMoveCount > 0 {
    393             syncMonitor.note("recovered \(recoveredMoveCount) unconfirmed move(s) for CloudKit enqueue")
    394         }
    395         isReadyForShareAcceptance = true
    396         await processPendingShareAcceptances()
    397         return true
    398     }
    399 
    400     private func presentPings(_ pings: [Ping]) async {
    401         let center = UNUserNotificationCenter.current()
    402         let settings = await center.notificationSettings()
    403         let canNotify: Bool
    404         switch settings.authorizationStatus {
    405         case .authorized, .provisional, .ephemeral:
    406             canNotify = true
    407         default:
    408             canNotify = false
    409         }
    410         guard canNotify else {
    411             syncMonitor.note("session-ping: local notification skipped — authorization not granted")
    412             return
    413         }
    414 
    415         for ping in pings {
    416             if ping.authorID == identity.currentID { continue }
    417             // The push fast path and the CKSyncEngine catch-up both surface
    418             // the same Ping records, so dedup by record name. We do this
    419             // check first and short-circuit; every other path below ends by
    420             // recording the name via the defer.
    421             if NotificationState.wasShown(pingRecordName: ping.recordName) {
    422                 syncMonitor.note("ping(\(ping.kind.rawValue)): already-shown record \(ping.recordName)")
    423                 continue
    424             }
    425             defer { NotificationState.recordShown(pingRecordName: ping.recordName) }
    426 
    427             if NotificationState.isActive(gameID: ping.gameID) {
    428                 if ping.kind == .session {
    429                     NotificationState.recordShown(gameID: ping.gameID)
    430                 }
    431                 syncMonitor.note("ping(\(ping.kind.rawValue)): suppressed — puzzle is active for \(ping.gameID.uuidString)")
    432                 continue
    433             }
    434             if ping.kind == .session,
    435                NotificationState.wasRecentlyShown(gameID: ping.gameID) {
    436                 NotificationState.recordShown(gameID: ping.gameID)
    437                 syncMonitor.note("ping(session): dedup-suppressed for \(ping.gameID.uuidString)")
    438                 continue
    439             }
    440 
    441             let content = UNMutableNotificationContent()
    442             content.title = "Crossmate"
    443             content.body = Self.bodyText(for: ping)
    444             content.sound = .default
    445             content.userInfo = [
    446                 "crossmateGameID": ping.gameID.uuidString,
    447                 "crossmatePingKind": ping.kind.rawValue
    448             ]
    449 
    450             let request = UNNotificationRequest(
    451                 identifier: "ping-\(ping.gameID.uuidString)-\(UUID().uuidString)",
    452                 content: content,
    453                 trigger: nil
    454             )
    455             do {
    456                 try await center.add(request)
    457                 if ping.kind == .session {
    458                     NotificationState.recordShown(gameID: ping.gameID)
    459                 }
    460                 syncMonitor.note("ping(\(ping.kind.rawValue)): queued local notification for \(ping.gameID.uuidString)")
    461             } catch {
    462                 syncMonitor.note("ping(\(ping.kind.rawValue)): local notification failed — \(error.localizedDescription)")
    463             }
    464         }
    465     }
    466 
    467     nonisolated static func bodyText(for ping: Ping) -> String {
    468         let player = ping.playerName.isEmpty ? "A player" : ping.playerName
    469         let puzzleSuffix = ping.puzzleTitle.isEmpty ? "the puzzle" : "the puzzle '\(ping.puzzleTitle)'"
    470         switch ping.kind {
    471         case .session:
    472             return "\(player) is solving \(puzzleSuffix)"
    473         case .join:
    474             return "\(player) joined \(puzzleSuffix)"
    475         case .win:
    476             return "\(player) solved \(puzzleSuffix)"
    477         case .check:
    478             switch ping.scope {
    479             case .square: return "\(player) checked a square in \(puzzleSuffix)"
    480             case .word: return "\(player) checked a word in \(puzzleSuffix)"
    481             case .puzzle: return "\(player) checked all of \(puzzleSuffix)"
    482             case .none: return "\(player) checked \(puzzleSuffix)"
    483             }
    484         case .reveal:
    485             switch ping.scope {
    486             case .square: return "\(player) revealed a square in \(puzzleSuffix)"
    487             case .word: return "\(player) revealed a word in \(puzzleSuffix)"
    488             case .puzzle, .none: return "\(player) revealed \(puzzleSuffix)"
    489             }
    490         }
    491     }
    492 
    493     /// Parses the silent-push payload into a short, human-readable summary
    494     /// (database scope, notification type, subscription ID, pruned flag).
    495     /// Used by the diagnostics log to confirm whether shared-DB pushes are
    496     /// actually being delivered to the device.
    497     static func describePush(userInfo: [AnyHashable: Any]) -> String {
    498         guard let note = CKNotification(fromRemoteNotificationDictionary: userInfo) else {
    499             return "unparseable userInfo=\(userInfo)"
    500         }
    501         let kind: String
    502         let scope: CKDatabase.Scope?
    503         switch note {
    504         case let n as CKDatabaseNotification:
    505             kind = "database"
    506             scope = n.databaseScope
    507         case let n as CKRecordZoneNotification:
    508             kind = "recordZone"
    509             scope = n.databaseScope
    510         case let n as CKQueryNotification:
    511             kind = "query(\(n.queryNotificationReason.rawValue))"
    512             scope = n.databaseScope
    513         default:
    514             kind = "type(\(note.notificationType.rawValue))"
    515             scope = nil
    516         }
    517         let scopeLabel: String
    518         switch scope {
    519         case .private: scopeLabel = "private"
    520         case .shared: scopeLabel = "shared"
    521         case .public: scopeLabel = "public"
    522         case .none: scopeLabel = "n/a"
    523         case .some(let other): scopeLabel = "scope(\(other.rawValue))"
    524         }
    525         let sub = note.subscriptionID ?? "<nil>"
    526         return "scope=\(scopeLabel) kind=\(kind) sub=\(sub) pruned=\(note.isPruned)"
    527     }
    528 
    529     static func databaseScope(fromPush userInfo: [AnyHashable: Any]) -> CKDatabase.Scope? {
    530         guard let note = CKNotification(fromRemoteNotificationDictionary: userInfo) else {
    531             return nil
    532         }
    533         switch note {
    534         case let n as CKDatabaseNotification:
    535             return n.databaseScope
    536         case let n as CKRecordZoneNotification:
    537             return n.databaseScope
    538         case let n as CKQueryNotification:
    539             return n.databaseScope
    540         default:
    541             return nil
    542         }
    543     }
    544 
    545     private func processPendingShareAcceptances() async {
    546         guard isReadyForShareAcceptance, !isProcessingShareAcceptanceQueue else { return }
    547         isProcessingShareAcceptanceQueue = true
    548         defer { isProcessingShareAcceptanceQueue = false }
    549 
    550         while !pendingShareMetadatas.isEmpty {
    551             let metadata = pendingShareMetadatas.removeFirst()
    552             await cloudService.acceptShare(metadata: metadata)
    553         }
    554     }
    555 
    556     private func refreshSnapshot() async {
    557         let snapshot = await syncEngine.diagnosticSnapshot()
    558         syncMonitor.updateSnapshot(snapshot)
    559     }
    560 
    561     /// Builds the `GameStore.onGameDeleted` callback. Extracted so tests can
    562     /// drive the exact same closure that production wires up — keeps the
    563     /// colour-cleanup branch from drifting silently.
    564     static func makeOnGameDeleted(
    565         syncEngine: SyncEngine,
    566         colorStore: GamePlayerColorStore
    567     ) -> (GameCloudDeletion) -> Void {
    568         { deletion in
    569             colorStore.clearColors(forGame: deletion.gameID)
    570             Task { await syncEngine.enqueueDeleteGame(deletion) }
    571         }
    572     }
    573 
    574 }