crossmate

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

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 }