CrossmateApp.swift (18681B)
1 import CloudKit 2 import SwiftUI 3 import UserNotifications 4 5 @main 6 struct CrossmateApp: App { 7 @UIApplicationDelegateAdaptor private var appDelegate: AppDelegate 8 9 @State private var services: AppServices 10 11 init() { 12 self._services = State(initialValue: AppServices()) 13 } 14 15 var body: some Scene { 16 WindowGroup { 17 RootView( 18 services: services, 19 appDelegate: appDelegate 20 ) 21 .environment(\.managedObjectContext, services.persistence.viewContext) 22 .environment(services.driveMonitor) 23 .environment(services.inputMonitor) 24 .environment(services.syncMonitor) 25 .environment(\.syncEngine, services.syncEngine) 26 .environment(services.nytAuth) 27 .environment(\.nytPuzzleFetcher, services.nytFetcher) 28 .environment(\.resetDatabase, { await services.cloudService.resetAllData() }) 29 } 30 } 31 } 32 33 // MARK: - App Delegate 34 35 final class AppDelegate: NSObject, UIApplicationDelegate, @preconcurrency UNUserNotificationCenterDelegate, @unchecked Sendable { 36 var onRemoteNotification: ((String, CKDatabase.Scope?) async -> Void)? 37 /// Reports the outcome of `registerForRemoteNotifications`. Surfaced in 38 /// the diagnostics log so a missing APNs token (e.g. an aps-environment 39 /// mismatch between the entitlements and the TestFlight distribution 40 /// channel) is visible rather than silently degrading sync to the 41 /// CKSyncEngine poll cadence. 42 var onAPNsRegistrationResult: ((String) -> Void)? 43 44 func application( 45 _ application: UIApplication, 46 didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil 47 ) -> Bool { 48 application.registerForRemoteNotifications() 49 UNUserNotificationCenter.current().delegate = self 50 return true 51 } 52 53 func application( 54 _ application: UIApplication, 55 didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data 56 ) { 57 let hex = deviceToken.map { String(format: "%02x", $0) }.joined() 58 let prefix = hex.prefix(12) 59 onAPNsRegistrationResult?("APNs registered token=\(prefix)… (\(deviceToken.count) bytes)") 60 } 61 62 func application( 63 _ application: UIApplication, 64 didFailToRegisterForRemoteNotificationsWithError error: Error 65 ) { 66 let nsError = error as NSError 67 onAPNsRegistrationResult?( 68 "APNs registration FAILED — domain=\(nsError.domain) code=\(nsError.code) " + 69 "\(nsError.localizedDescription)" 70 ) 71 } 72 73 /// Foreground notification arrival. If the user is currently viewing the 74 /// puzzle the ping refers to, hide it entirely (`[]`); otherwise show it 75 /// as a banner with sound. In both cases the dedup map is updated so a 76 /// rapid follow-up doesn't refire. 77 func userNotificationCenter( 78 _ center: UNUserNotificationCenter, 79 willPresent notification: UNNotification, 80 withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void 81 ) { 82 let userInfo = notification.request.content.userInfo 83 guard let gameID = Self.gameID(from: userInfo) else { 84 completionHandler([.banner, .sound]) 85 return 86 } 87 let isSession = (userInfo["crossmatePingKind"] as? String) == "session" 88 if isSession { 89 NotificationState.recordShown(gameID: gameID) 90 } 91 if NotificationState.isActive(gameID: gameID) { 92 completionHandler([]) 93 } else { 94 completionHandler([.banner, .sound]) 95 } 96 } 97 98 func userNotificationCenter( 99 _ center: UNUserNotificationCenter, 100 didReceive response: UNNotificationResponse, 101 withCompletionHandler completionHandler: @escaping () -> Void 102 ) { 103 let userInfo = response.notification.request.content.userInfo 104 guard let gameID = Self.gameID(from: userInfo) else { 105 completionHandler() 106 return 107 } 108 109 Task { @MainActor in 110 NotificationNavigationBroker.shared.openGame(gameID) 111 completionHandler() 112 } 113 } 114 115 private static func gameID(from userInfo: [AnyHashable: Any]) -> UUID? { 116 if let id = userInfo["crossmateGameID"] as? String { 117 return UUID(uuidString: id) 118 } 119 guard let ck = userInfo["ck"] as? [AnyHashable: Any], 120 let qry = ck["qry"] as? [AnyHashable: Any], 121 let zoneName = qry["zid"] as? String, 122 zoneName.hasPrefix("game-") 123 else { return nil } 124 return UUID(uuidString: String(zoneName.dropFirst("game-".count))) 125 } 126 127 /// Asks the user for notification permission only if they haven't yet 128 /// answered the prompt. Idempotent — once the user has decided either 129 /// way, this is a no-op. 130 static func requestNotificationAuthorizationIfNeeded() async { 131 let center = UNUserNotificationCenter.current() 132 let settings = await center.notificationSettings() 133 guard settings.authorizationStatus == .notDetermined else { return } 134 _ = try? await center.requestAuthorization(options: [.alert, .sound]) 135 } 136 137 func application( 138 _ application: UIApplication, 139 didReceiveRemoteNotification userInfo: [AnyHashable: Any] 140 ) async -> UIBackgroundFetchResult { 141 let summary = AppServices.describePush(userInfo: userInfo) 142 let scope = AppServices.databaseScope(fromPush: userInfo) 143 await onRemoteNotification?(summary, scope) 144 return .newData 145 } 146 147 func application( 148 _ application: UIApplication, 149 configurationForConnecting connectingSceneSession: UISceneSession, 150 options: UIScene.ConnectionOptions 151 ) -> UISceneConfiguration { 152 let configuration = UISceneConfiguration( 153 name: nil, 154 sessionRole: connectingSceneSession.role 155 ) 156 configuration.delegateClass = SceneDelegate.self 157 return configuration 158 } 159 } 160 161 @MainActor 162 final class NotificationNavigationBroker { 163 static let shared = NotificationNavigationBroker() 164 165 var onOpenGame: ((UUID) -> Void)? { 166 didSet { flushPendingGameIDs() } 167 } 168 169 private var pendingGameIDs: [UUID] = [] 170 171 private init() {} 172 173 func openGame(_ gameID: UUID) { 174 guard let onOpenGame else { 175 pendingGameIDs.append(gameID) 176 return 177 } 178 onOpenGame(gameID) 179 } 180 181 private func flushPendingGameIDs() { 182 guard let onOpenGame, !pendingGameIDs.isEmpty else { return } 183 let gameIDs = pendingGameIDs 184 pendingGameIDs.removeAll() 185 for gameID in gameIDs { 186 onOpenGame(gameID) 187 } 188 } 189 } 190 191 @MainActor 192 final class CloudShareAcceptanceBroker { 193 static let shared = CloudShareAcceptanceBroker() 194 195 var onAcceptShare: ((CKShare.Metadata) async -> Void)? { 196 didSet { flushPendingAcceptedShares() } 197 } 198 199 private var pendingAcceptedShares: [CKShare.Metadata] = [] 200 201 private init() {} 202 203 func acceptCloudKitShare(_ metadata: CKShare.Metadata) { 204 guard let onAcceptShare else { 205 pendingAcceptedShares.append(metadata) 206 return 207 } 208 Task { await onAcceptShare(metadata) } 209 } 210 211 private func flushPendingAcceptedShares() { 212 guard let onAcceptShare, !pendingAcceptedShares.isEmpty else { return } 213 let metadatas = pendingAcceptedShares 214 pendingAcceptedShares.removeAll() 215 for metadata in metadatas { 216 Task { await onAcceptShare(metadata) } 217 } 218 } 219 } 220 221 final class SceneDelegate: NSObject, UIWindowSceneDelegate { 222 func windowScene( 223 _ windowScene: UIWindowScene, 224 userDidAcceptCloudKitShareWith metadata: CKShare.Metadata 225 ) { 226 CloudShareAcceptanceBroker.shared.acceptCloudKitShare(metadata) 227 } 228 } 229 230 // MARK: - Root View 231 232 struct RootView: View { 233 let services: AppServices 234 let appDelegate: AppDelegate 235 236 @Environment(\.scenePhase) private var scenePhase 237 @State private var navigationPath = NavigationPath() 238 239 var body: some View { 240 NavigationStack(path: $navigationPath) { 241 GameListView( 242 store: services.store, 243 shareController: services.shareController, 244 onRefresh: { await services.refreshLibrary() }, 245 navigationPath: $navigationPath 246 ) 247 .navigationDestination(for: UUID.self) { gameID in 248 PuzzleDisplayView( 249 gameID: gameID, 250 store: services.store, 251 shareController: services.shareController, 252 services: services 253 ) 254 } 255 } 256 .environment(services.preferences) 257 .task { 258 NotificationState.setActivePuzzleID(nil) 259 NotificationNavigationBroker.shared.onOpenGame = { gameID in 260 UIApplication.shared.dismissPresentedViewControllers() 261 navigationPath = NavigationPath() 262 navigationPath.append(gameID) 263 } 264 await services.start(appDelegate: appDelegate) 265 } 266 .onOpenURL { url in 267 if let id = services.importService.importGame(from: url) { 268 navigationPath.append(id) 269 } 270 } 271 .onReceive(NotificationCenter.default.publisher(for: .cloudShareAcceptanceStarted)) { _ in 272 UIApplication.shared.dismissPresentedViewControllers() 273 } 274 .onReceive(NotificationCenter.default.publisher(for: .cloudShareAcceptanceCompleted)) { notification in 275 guard let gameID = notification.userInfo?["gameID"] as? UUID else { return } 276 navigationPath.append(gameID) 277 } 278 .onChange(of: scenePhase) { _, newPhase in 279 switch newPhase { 280 case .active: 281 Task { await services.syncOnForeground() } 282 case .background, .inactive: 283 NotificationState.setActivePuzzleID(nil) 284 Task { await services.syncOnBackground() } 285 @unknown default: 286 break 287 } 288 } 289 } 290 } 291 292 private extension UIApplication { 293 func dismissPresentedViewControllers() { 294 for scene in connectedScenes { 295 guard let windowScene = scene as? UIWindowScene else { continue } 296 for window in windowScene.windows where window.isKeyWindow { 297 window.rootViewController?.dismiss(animated: true) 298 } 299 } 300 } 301 } 302 303 // MARK: - Game Destination 304 305 /// Loads a game when navigated to. 306 private struct PuzzleDisplayView: View { 307 private static let sharedPuzzlePollingInterval: Duration = .seconds(5) 308 private var sharedID: UUID? { 309 session?.mutator.isShared == true ? gameID : nil 310 } 311 312 let gameID: UUID 313 let store: GameStore 314 let shareController: ShareController 315 let services: AppServices 316 317 @Environment(PlayerPreferences.self) private var preferences 318 @Environment(\.scenePhase) private var scenePhase 319 @State private var session: PlayerSession? 320 @State private var roster: PlayerRoster? 321 @State private var loadError: String? 322 @State private var openPuzzleFollowUpTask: Task<Void, Never>? 323 324 var body: some View { 325 Group { 326 if let session, let roster { 327 PuzzleView( 328 session: session, 329 shareController: shareController, 330 roster: roster, 331 onComplete: { 332 store.markCompleted(id: gameID) 333 Task { 334 await services.cleanupPingsAfterCompletion(gameID: gameID) 335 } 336 }, 337 onResign: { try store.resignGame(id: gameID) }, 338 onDelete: { try store.deleteGame(id: gameID) } 339 ) 340 } else if let loadError { 341 ContentUnavailableView( 342 "Couldn't load puzzle", 343 systemImage: "exclamationmark.triangle", 344 description: Text(loadError) 345 ) 346 } else { 347 ProgressView("Loading puzzle...") 348 .frame(maxWidth: .infinity, maxHeight: .infinity) 349 } 350 } 351 .navigationTitle("") 352 .navigationBarTitleDisplayMode(.inline) 353 .task(id: sharedID) { 354 guard sharedID != nil else { return } 355 await pollOpenSharedPuzzle() 356 } 357 .task(id: gameID) { 358 openPuzzleFollowUpTask?.cancel() 359 openPuzzleFollowUpTask = nil 360 session = nil 361 roster = nil 362 loadError = nil 363 updateActiveNotificationPuzzleID(for: scenePhase) 364 do { 365 let (game, mutator) = try store.loadGame(id: gameID) 366 let newSession = PlayerSession(game: game, mutator: mutator) 367 let newRoster = services.makePlayerRoster(for: gameID, preferences: preferences) 368 roster = newRoster 369 session = newSession 370 openPuzzleFollowUpTask = Task { @MainActor in 371 await finishOpeningPuzzle( 372 session: newSession, 373 roster: newRoster, 374 isShared: mutator.isShared 375 ) 376 } 377 } catch { 378 loadError = String(describing: error) 379 } 380 } 381 .onChange(of: session?.mutator.isShared) { oldValue, newValue in 382 // Fire only on a definite `false → true` transition — that's the 383 // mid-session share-create case. Initial loads of an already-shared 384 // game go `nil → true` and are handled inline in `task(id: gameID)`. 385 guard oldValue == false, newValue == true, 386 let session, 387 preferences.isICloudSyncEnabled 388 else { return } 389 Task { await activateSharing(for: session) } 390 } 391 .onChange(of: scenePhase) { _, newPhase in 392 updateActiveNotificationPuzzleID(for: newPhase) 393 } 394 .onDisappear { 395 openPuzzleFollowUpTask?.cancel() 396 openPuzzleFollowUpTask = nil 397 NotificationState.clearActivePuzzleID(if: gameID) 398 let selectionPublisher = services.playerSelectionPublisher 399 let movesUpdater = services.movesUpdater 400 let exitedID = gameID 401 Task { 402 await movesUpdater.flush() 403 await selectionPublisher.clear() 404 await movesUpdater.noteSessionEnded(gameID: exitedID) 405 } 406 } 407 } 408 409 private func finishOpeningPuzzle( 410 session loadedSession: PlayerSession, 411 roster loadedRoster: PlayerRoster, 412 isShared: Bool 413 ) async { 414 await loadedRoster.refresh() 415 guard !Task.isCancelled, session === loadedSession else { return } 416 417 if isShared && preferences.isICloudSyncEnabled { 418 services.syncMonitor.note( 419 "PuzzleDisplay[\(gameID.uuidString.prefix(8))]: loaded shared roster" 420 ) 421 await activateSharing(for: loadedSession, refreshRoster: false) 422 } else { 423 services.syncMonitor.note( 424 "PuzzleDisplay[\(gameID.uuidString.prefix(8))]: loaded local roster" 425 ) 426 await services.playerSelectionPublisher.clear() 427 } 428 } 429 430 private func updateActiveNotificationPuzzleID(for phase: ScenePhase) { 431 if phase == .active { 432 NotificationState.setActivePuzzleID(gameID) 433 } else { 434 NotificationState.clearActivePuzzleID(if: gameID) 435 } 436 } 437 438 /// Initialises shared-game state (roster, selection publishing, name broadcast) for 439 /// the open session. Called when the puzzle first appears as shared, and 440 /// again if a previously-solo game becomes shared mid-session. 441 private func activateSharing(for session: PlayerSession, refreshRoster: Bool = true) async { 442 Task { await AppDelegate.requestNotificationAuthorizationIfNeeded() } 443 let activeRoster: PlayerRoster 444 if let roster { 445 activeRoster = roster 446 } else { 447 let newRoster = services.makePlayerRoster(for: gameID, preferences: preferences) 448 roster = newRoster 449 activeRoster = newRoster 450 } 451 if refreshRoster { 452 await activeRoster.refresh() 453 } 454 // Fan out the local user's name to every shared/joined game's zone 455 // before any selection publish — otherwise the partner sees "Player" 456 // until we happen to rename ourselves and trigger PlayerNamePublisher's 457 // observer. Idempotent if the name has already been broadcast. 458 await services.playerNamePublisher?.broadcastName() 459 guard let authorID = services.identity.currentID else { return } 460 let selectionPublisher = services.playerSelectionPublisher 461 await selectionPublisher.begin( 462 gameID: gameID, 463 authorID: authorID, 464 currentName: preferences.name 465 ) 466 session.onSelectionChanged = { selection in 467 Task { await selectionPublisher.publish(selection) } 468 } 469 let syncEngine = services.syncEngine 470 let identity = services.identity 471 let preferences = self.preferences 472 let eventGameID = gameID 473 session.onPlayerEvent = { kind, scope in 474 guard preferences.isICloudSyncEnabled, 475 let authorID = identity.currentID 476 else { return } 477 let playerName = preferences.name 478 Task { 479 await syncEngine.enqueuePing( 480 kind: kind, 481 scope: scope, 482 gameID: eventGameID, 483 authorID: authorID, 484 playerName: playerName 485 ) 486 } 487 } 488 let initial = PlayerSelection( 489 row: session.selectedRow, 490 col: session.selectedCol, 491 direction: session.direction 492 ) 493 await selectionPublisher.publish(initial) 494 } 495 496 private func pollOpenSharedPuzzle() async { 497 await services.syncOpenSharedPuzzle() 498 while !Task.isCancelled { 499 do { 500 try await Task.sleep(for: Self.sharedPuzzlePollingInterval) 501 } catch { 502 break 503 } 504 guard !Task.isCancelled else { break } 505 await services.syncOpenSharedPuzzle() 506 } 507 } 508 }