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 }