crossmate

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

CloudService.swift (14419B)


      1 import CloudKit
      2 
      3 extension Notification.Name {
      4     static let cloudShareAcceptanceStarted = Notification.Name("cloudShareAcceptanceStarted")
      5     static let cloudShareAcceptanceCompleted = Notification.Name("cloudShareAcceptanceCompleted")
      6 }
      7 
      8 @MainActor
      9 final class CloudService {
     10     private let ckContainer: CKContainer
     11     private let syncEngine: SyncEngine
     12     private let syncMonitor: SyncMonitor
     13     private let store: GameStore
     14     private let shareController: ShareController
     15 
     16     /// Fired after a successful share acceptance once the shared zone has been
     17     /// fetched. Used to enqueue a `.join` ping so other collaborators are
     18     /// notified that someone has joined the puzzle.
     19     var onShareJoined: ((UUID) async -> Void)?
     20 
     21     init(
     22         container: CKContainer,
     23         syncEngine: SyncEngine,
     24         syncMonitor: SyncMonitor,
     25         store: GameStore,
     26         shareController: ShareController
     27     ) {
     28         self.ckContainer = container
     29         self.syncEngine = syncEngine
     30         self.syncMonitor = syncMonitor
     31         self.store = store
     32         self.shareController = shareController
     33     }
     34 
     35     /// The result of accepting a share, so callers can react to a join that
     36     /// succeeded at the CloudKit level but hasn't produced a playable puzzle
     37     /// yet. Navigation is still driven by `.cloudShareAcceptanceCompleted`; this
     38     /// only lets a caller surface a "still syncing" message where appropriate.
     39     enum AcceptOutcome {
     40         /// A playable puzzle was joined; navigation has been posted.
     41         case opened
     42         /// The share was accepted but its puzzle hadn't synced before the wait
     43         /// timed out. The game still surfaces in the Game List once sync
     44         /// settles, so callers may reassure the user rather than report failure.
     45         case pendingSync
     46         /// The user cancelled the join (only the link tap can); no message.
     47         case cancelled
     48     }
     49 
     50     /// Fetches share metadata for a URL and joins via `acceptShare(metadata:)`.
     51     /// Used by the "Invited" section and the universal-link tap, where the
     52     /// share URL arrived in an `.invite` Ping or a tapped link rather than from
     53     /// the OS share-accept handler.
     54     @discardableResult
     55     func acceptShare(url: URL, prefetchedPuzzleSource: String? = nil) async throws -> AcceptOutcome {
     56         let metadata = try await withCheckedThrowingContinuation {
     57             (cont: CheckedContinuation<CKShare.Metadata, Error>) in
     58             var found: CKShare.Metadata?
     59             let op = CKFetchShareMetadataOperation(shareURLs: [url])
     60             op.shouldFetchRootRecord = false
     61             op.perShareMetadataResultBlock = { _, result in
     62                 if case .success(let m) = result { found = m }
     63             }
     64             op.fetchShareMetadataResultBlock = { result in
     65                 switch result {
     66                 case .success:
     67                     if let found {
     68                         cont.resume(returning: found)
     69                     } else {
     70                         cont.resume(throwing: CKError(.unknownItem))
     71                     }
     72                 case .failure(let error):
     73                     cont.resume(throwing: error)
     74                 }
     75             }
     76             ckContainer.add(op)
     77         }
     78         return try await acceptShare(metadata: metadata, prefetchedPuzzleSource: prefetchedPuzzleSource)
     79     }
     80 
     81     @discardableResult
     82     func acceptShare(
     83         metadata: CKShare.Metadata,
     84         prefetchedPuzzleSource: String? = nil
     85     ) async throws -> AcceptOutcome {
     86         NotificationCenter.default.post(name: .cloudShareAcceptanceStarted, object: nil)
     87 
     88         guard metadata.containerIdentifier == ckContainer.containerIdentifier else {
     89             syncMonitor.note(
     90                 "acceptShare: container mismatch — metadata=\(metadata.containerIdentifier) " +
     91                 "expected=\(ckContainer.containerIdentifier ?? "nil")"
     92             )
     93             throw CKError(.permissionFailure)
     94         }
     95         // The share names the game it covers: Crossmate uses zone-wide shares,
     96         // so the metadata's zone ("game-<UUID>") identifies the game outright.
     97         // This replaces a fragile before/after join diff that came up empty
     98         // whenever the game was already present — a re-tapped link, a sibling
     99         // device, or a directly-invited friend added by identity before Accept.
    100         let sharedZoneID = metadata.share.recordID.zoneID
    101         let sharedGameID = RecordSerializer.gameID(
    102             fromGameRecordName: sharedZoneID.zoneName
    103         )
    104         do {
    105             try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
    106                 let op = CKAcceptSharesOperation(shareMetadatas: [metadata])
    107                 op.acceptSharesResultBlock = { result in cont.resume(with: result) }
    108                 ckContainer.add(op)
    109             }
    110             if let sharedGameID {
    111                 // When the invite carried the puzzle source, build the playable
    112                 // game from it now and pull the canonical Game record, Moves and
    113                 // Players in the background — they merge into this same row
    114                 // (matched by record name) as a pure update. The user reaches
    115                 // the grid after just the accept round-trip rather than waiting
    116                 // on the shared-zone fetch.
    117                 var constructed = false
    118                 if let prefetchedPuzzleSource, !prefetchedPuzzleSource.isEmpty {
    119                     do {
    120                         try store.constructJoinedGame(
    121                             gameID: sharedGameID,
    122                             zoneID: sharedZoneID,
    123                             source: prefetchedPuzzleSource
    124                         )
    125                         constructed = true
    126                         syncMonitor.note("Share accepted — built game from invite; fetching remainder in background")
    127                     } catch {
    128                         syncMonitor.note("acceptShare: invite-source construct failed — \(error); fetching inline")
    129                     }
    130                 }
    131                 if constructed {
    132                     Task { @MainActor [weak self] in
    133                         guard let self else { return }
    134                         _ = await self.syncMonitor.run("share-accept background remainder fetch") {
    135                             try await self.syncEngine.fetchAcceptedSharedGameDirect(
    136                                 gameID: sharedGameID,
    137                                 zoneID: sharedZoneID
    138                             )
    139                         }
    140                     }
    141                 } else {
    142                     syncMonitor.note("Share accepted — fetching shared game")
    143                     _ = try await syncEngine.fetchAcceptedSharedGameDirect(
    144                         gameID: sharedGameID,
    145                         zoneID: sharedZoneID
    146                     )
    147                 }
    148             } else {
    149                 syncMonitor.note("Share accepted — discovering shared zone")
    150                 await syncMonitor.run("share-accept shared discovery") {
    151                     _ = try await syncEngine.discoverNewZonesDirect(scope: .shared)
    152                 }
    153             }
    154             // Navigate once the game's puzzle has actually synced and is
    155             // playable. The caller holds the join placeholder up for the whole
    156             // of this call, so waiting here keeps the user on the joining screen
    157             // through a slow sync rather than dropping them back at the Game
    158             // List with an unopened game.
    159             let joinedGameID = await waitForPlayablePuzzle(
    160                 gameID: sharedGameID,
    161                 zoneID: sharedZoneID
    162             )
    163             // The user tapped Cancel on the joining screen — don't pull them
    164             // into the game. The joined zone still surfaces in the Game List on
    165             // its own once sync settles.
    166             guard !Task.isCancelled else { return .cancelled }
    167             if let joinedGameID {
    168                 try await shareController.confirmSeatAfterJoin(gameID: joinedGameID)
    169             }
    170             NotificationCenter.default.post(
    171                 name: .cloudShareAcceptanceCompleted,
    172                 object: nil,
    173                 userInfo: joinedGameID.map { ["gameID": $0] }
    174             )
    175             if let joinedGameID, let onShareJoined {
    176                 await onShareJoined(joinedGameID)
    177             }
    178             return joinedGameID == nil ? .pendingSync : .opened
    179         } catch {
    180             syncMonitor.recordError("acceptShare", error)
    181             throw error
    182         }
    183     }
    184 
    185     /// How long `acceptShare` holds the joining screen waiting for the puzzle to
    186     /// become playable before returning the user to the Game List. Mirrors
    187     /// `RootView`'s invite-ping join wait.
    188     private static let joinSyncTimeout: TimeInterval = 30
    189     private static let joinSyncPollInterval: Duration = .seconds(1)
    190     /// How often `waitForPlayablePuzzle` re-issues the CloudKit fetch while
    191     /// waiting. Between backstops it only observes the store, which the initial
    192     /// accepted-game fetch (and concurrent sync) populate — so a slow asset
    193     /// commit costs cheap store polls, not repeated three-query CloudKit reads.
    194     private static let joinSyncRefetchInterval: TimeInterval = 3
    195 
    196     /// Polls the just-joined game's own zone until its puzzle is playable, so
    197     /// the joining screen holds through a slow sync rather than dropping the
    198     /// user back at the Game List. The accepted-zone fetch downloads the Game
    199     /// record and its `puzzleSource` asset inline, so this returns on the first
    200     /// check in the common case. Returns nil on timeout, or when the join
    201     /// `Task` is cancelled (the user tapped Cancel) — the caller then doesn't
    202     /// navigate.
    203     private func waitForPlayablePuzzle(gameID: UUID?, zoneID: CKRecordZone.ID?) async -> UUID? {
    204         guard let gameID else { return nil }
    205         if store.joinedSharedGameIDs().contains(gameID) { return gameID }
    206         let deadline = Date().addingTimeInterval(Self.joinSyncTimeout)
    207         // The caller already issued one accepted-game fetch, so begin by just
    208         // observing the store and only re-issue the CloudKit fetch on a backstop
    209         // interval. In the common case the asset commits within a poll or two
    210         // and we return without a single redundant three-query read.
    211         var nextRefetch = Date().addingTimeInterval(Self.joinSyncRefetchInterval)
    212         while Date() < deadline {
    213             if store.joinedSharedGameIDs().contains(gameID) { return gameID }
    214 
    215             if Date() >= nextRefetch {
    216                 if let zoneID {
    217                     // The gate only reads `puzzleSource`, so the backstop needs
    218                     // just the Game record — the initial accept fetch already
    219                     // pulled Moves/Players, and the grid re-fetches them on open.
    220                     _ = try? await syncEngine.fetchAcceptedSharedGameDirect(
    221                         gameID: gameID,
    222                         zoneID: zoneID,
    223                         onlyGame: true
    224                     )
    225                 } else {
    226                     _ = try? await syncEngine.fetchGameDirect(scope: .shared, gameID: gameID)
    227                 }
    228                 nextRefetch = Date().addingTimeInterval(Self.joinSyncRefetchInterval)
    229                 if store.joinedSharedGameIDs().contains(gameID) { return gameID }
    230             }
    231 
    232             do {
    233                 try await Task.sleep(for: Self.joinSyncPollInterval)
    234             } catch {
    235                 return nil // cancelled
    236             }
    237         }
    238         syncMonitor.note(
    239             "acceptShare: puzzle not playable within \(Int(Self.joinSyncTimeout))s " +
    240             "for \(gameID.uuidString)"
    241         )
    242         return nil
    243     }
    244 
    245     func resetAllData() async throws {
    246         await syncEngine.resetSyncState()
    247 
    248         async let privateCleanup: Void = deleteAllPrivateZones()
    249         async let sharedCleanup: Void = leaveAllSharedZones()
    250         _ = await (privateCleanup, sharedCleanup)
    251 
    252         try store.resetAllData()
    253         UserDefaults.standard.removeObject(forKey: "gamePlayerColors")
    254         BadgeState.reset()
    255         syncMonitor.note("Database reset — all games and sync state cleared")
    256     }
    257 
    258     /// Local-only counterpart to `resetAllData`, used on an iCloud account
    259     /// switch. Drops this device's cached store, badge ledger, colour map, and
    260     /// sync-engine tokens, then rebuilds the engines so they resync as the new
    261     /// account. Critically it does **not** delete private zones or leave shared
    262     /// zones: the previous account's games stay intact in *its* CloudKit (the
    263     /// user still has them on other devices) — only this device's stale cache
    264     /// of them is discarded. The store is wiped before `resetSyncState` so the
    265     /// latter's unconfirmed-move recovery finds nothing to re-enqueue.
    266     func purgeLocalData() async throws {
    267         try store.resetAllData()
    268         UserDefaults.standard.removeObject(forKey: "gamePlayerColors")
    269         BadgeState.reset()
    270         await syncEngine.resetSyncState()
    271         syncMonitor.note("Local store purged for account switch — previous account untouched in CloudKit")
    272     }
    273 
    274     private func deleteAllPrivateZones() async {
    275         do {
    276             let zones = try await ckContainer.privateCloudDatabase.allRecordZones()
    277             guard !zones.isEmpty else { return }
    278             let ids = zones.map(\.zoneID)
    279             try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
    280                 let op = CKModifyRecordZonesOperation(
    281                     recordZonesToSave: nil,
    282                     recordZoneIDsToDelete: ids
    283                 )
    284                 op.modifyRecordZonesResultBlock = { result in cont.resume(with: result) }
    285                 ckContainer.privateCloudDatabase.add(op)
    286             }
    287         } catch {
    288             syncMonitor.note("reset: private zone cleanup failed — \(error)")
    289         }
    290     }
    291 
    292     private func leaveAllSharedZones() async {
    293         do {
    294             let zones = try await ckContainer.sharedCloudDatabase.allRecordZones()
    295             for zone in zones {
    296                 try await ckContainer.sharedCloudDatabase.deleteRecordZone(withID: zone.zoneID)
    297             }
    298         } catch {
    299             syncMonitor.note("reset: shared zone cleanup failed — \(error)")
    300         }
    301     }
    302 }