crossmate

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

CloudService.swift (20855B)


      1 import CloudKit
      2 
      3 enum CloudFailureCopy {
      4     static let quotaJoinTitle = "Puzzle Unavailable"
      5     static let quotaJoinBody =
      6         "The puzzle could not be joined properly. This could be because your friend's iCloud storage is full."
      7 
      8     /// Banner copy for a failed join, shared by the three surfaces that can
      9     /// raise one (share link, invite row, OS-delivered acceptance) so they can't
     10     /// drift apart. An `AcceptedShareError` means the share itself went
     11     /// through and only its puzzle is unusable — a different thing to tell the
     12     /// user than failing to accept at all — and a quota failure names the
     13     /// likely cause instead of echoing CloudKit's wording.
     14     static func joinFailure(for error: Error) -> (title: String, body: String) {
     15         guard let accepted = error as? AcceptedShareError else {
     16             return ("Accepting Failed", error.localizedDescription)
     17         }
     18         guard accepted.isQuotaExceeded else {
     19             return ("Puzzle Unavailable", error.localizedDescription)
     20         }
     21         return (quotaJoinTitle, quotaJoinBody)
     22     }
     23 }
     24 
     25 extension Notification.Name {
     26     static let cloudShareAcceptanceStarted = Notification.Name("cloudShareAcceptanceStarted")
     27     static let cloudShareAcceptanceCompleted = Notification.Name("cloudShareAcceptanceCompleted")
     28 }
     29 
     30 /// Errors from `CloudService` that need distinct user-facing handling.
     31 enum CloudServiceError: Error, LocalizedError, Equatable {
     32     /// A tapped share belongs to a different CloudKit container generation than
     33     /// this build uses — the sharer and joiner are on incompatible app versions
     34     /// (e.g. a not-yet-updated peer taps a link to a v4 game, or vice versa).
     35     case containerVersionMismatch
     36 
     37     var errorDescription: String? {
     38         switch self {
     39         case .containerVersionMismatch:
     40             return "This game is from a different version of Crossmate. "
     41                 + "Make sure you and the other player are both on the latest version."
     42         }
     43     }
     44 }
     45 
     46 /// The share itself was accepted, but Crossmate could not materialize its
     47 /// playable Game record. Keeping this distinct from an acceptance failure lets
     48 /// callers tell the truth: retrying does not need to accept the share again.
     49 struct AcceptedShareError: Error, LocalizedError {
     50     enum Kind: Equatable {
     51         case removed
     52         case temporarilyUnavailable
     53         case unavailable
     54     }
     55 
     56     let kind: Kind
     57     let underlyingError: Error
     58 
     59     var errorDescription: String? {
     60         if isQuotaExceeded { return CloudFailureCopy.quotaJoinBody }
     61         return underlyingError.localizedDescription
     62     }
     63 
     64     var isQuotaExceeded: Bool {
     65         CloudService.cloudErrorCode(underlyingError) == .quotaExceeded
     66     }
     67 }
     68 
     69 @MainActor
     70 final class CloudService {
     71     private let ckContainer: CKContainer
     72     private let syncEngine: SyncEngine
     73     private let syncMonitor: SyncMonitor
     74     private let store: GameStore
     75     private let shareController: ShareController
     76 
     77     /// Fired after a successful share acceptance once the shared zone has been
     78     /// fetched. Used to enqueue a `.join` ping so other collaborators are
     79     /// notified that someone has joined the puzzle.
     80     var onShareJoined: ((UUID) async -> Void)?
     81 
     82     init(
     83         container: CKContainer,
     84         syncEngine: SyncEngine,
     85         syncMonitor: SyncMonitor,
     86         store: GameStore,
     87         shareController: ShareController
     88     ) {
     89         self.ckContainer = container
     90         self.syncEngine = syncEngine
     91         self.syncMonitor = syncMonitor
     92         self.store = store
     93         self.shareController = shareController
     94     }
     95 
     96     /// The result of accepting a share, so callers can react to a join that
     97     /// succeeded at the CloudKit level but hasn't produced a playable puzzle
     98     /// yet. Navigation is still driven by `.cloudShareAcceptanceCompleted`; this
     99     /// only lets a caller surface a "still syncing" message where appropriate.
    100     enum AcceptOutcome {
    101         /// A playable puzzle was joined; navigation has been posted.
    102         case opened
    103         /// The share was accepted but its puzzle hadn't synced before the wait
    104         /// timed out. The game still surfaces in the Game List once sync
    105         /// settles, so callers may reassure the user rather than report failure.
    106         case pendingSync
    107         /// The user cancelled the join (only the link tap can); no message.
    108         case cancelled
    109     }
    110 
    111     /// Fetches share metadata for a URL and joins via `acceptShare(metadata:)`.
    112     /// Used by the "Invited" section and the universal-link tap, where the
    113     /// share URL arrived in an `.invite` Ping or a tapped link rather than from
    114     /// the OS share-accept handler.
    115     @discardableResult
    116     func acceptShare(
    117         url: URL,
    118         prefetchedPuzzleSource: String? = nil,
    119         prefetchedNotification: String? = nil
    120     ) async throws -> AcceptOutcome {
    121         let metadata = try await withCheckedThrowingContinuation {
    122             (cont: CheckedContinuation<CKShare.Metadata, Error>) in
    123             var found: CKShare.Metadata?
    124             let op = CKFetchShareMetadataOperation(shareURLs: [url])
    125             op.shouldFetchRootRecord = false
    126             op.perShareMetadataResultBlock = { _, result in
    127                 if case .success(let m) = result { found = m }
    128             }
    129             op.fetchShareMetadataResultBlock = { result in
    130                 switch result {
    131                 case .success:
    132                     if let found {
    133                         cont.resume(returning: found)
    134                     } else {
    135                         cont.resume(throwing: CKError(.unknownItem))
    136                     }
    137                 case .failure(let error):
    138                     cont.resume(throwing: error)
    139                 }
    140             }
    141             ckContainer.add(op)
    142         }
    143         return try await acceptShare(
    144             metadata: metadata,
    145             prefetchedPuzzleSource: prefetchedPuzzleSource,
    146             prefetchedNotification: prefetchedNotification
    147         )
    148     }
    149 
    150     @discardableResult
    151     func acceptShare(
    152         metadata: CKShare.Metadata,
    153         prefetchedPuzzleSource: String? = nil,
    154         prefetchedNotification: String? = nil
    155     ) async throws -> AcceptOutcome {
    156         NotificationCenter.default.post(name: .cloudShareAcceptanceStarted, object: nil)
    157 
    158         guard metadata.containerIdentifier == ckContainer.containerIdentifier else {
    159             syncMonitor.note(
    160                 "acceptShare: container mismatch — metadata=\(metadata.containerIdentifier) " +
    161                 "expected=\(ckContainer.containerIdentifier ?? "nil")"
    162             )
    163             throw CloudServiceError.containerVersionMismatch
    164         }
    165         // The share names the game it covers: Crossmate uses zone-wide shares,
    166         // so the metadata's zone ("game-<UUID>") identifies the game outright.
    167         // This replaces a fragile before/after join diff that came up empty
    168         // whenever the game was already present — a re-tapped link, a sibling
    169         // device, or a directly-invited friend added by identity before Accept.
    170         let sharedZoneID = metadata.share.recordID.zoneID
    171         let sharedGameID = RecordSerializer.gameID(
    172             fromGameRecordName: sharedZoneID.zoneName
    173         )
    174         var shareWasAccepted = false
    175         do {
    176             try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
    177                 let op = CKAcceptSharesOperation(shareMetadatas: [metadata])
    178                 op.acceptSharesResultBlock = { result in cont.resume(with: result) }
    179                 ckContainer.add(op)
    180             }
    181             shareWasAccepted = true
    182             if let sharedGameID {
    183                 // When the invite carried the puzzle source, build the playable
    184                 // game from it now and pull the canonical Game record, Moves and
    185                 // Players in the background — they merge into this same row
    186                 // (matched by record name) as a pure update. The user reaches
    187                 // the grid after just the accept round-trip rather than waiting
    188                 // on the shared-zone fetch.
    189                 var constructed = false
    190                 if let prefetchedPuzzleSource, !prefetchedPuzzleSource.isEmpty {
    191                     do {
    192                         try store.constructJoinedGame(
    193                             gameID: sharedGameID,
    194                             zoneID: sharedZoneID,
    195                             source: prefetchedPuzzleSource,
    196                             notification: prefetchedNotification
    197                         )
    198                         constructed = true
    199                         syncMonitor.note("Share accepted — built game from invite; fetching remainder in background")
    200                     } catch {
    201                         syncMonitor.note("acceptShare: invite-source construct failed — \(error); fetching inline")
    202                     }
    203                 }
    204                 if constructed {
    205                     Task { @MainActor [weak self] in
    206                         guard let self else { return }
    207                         _ = await self.syncMonitor.run("share-accept background remainder fetch") {
    208                             try await self.syncEngine.fetchAcceptedSharedGameDirect(
    209                                 gameID: sharedGameID,
    210                                 zoneID: sharedZoneID
    211                             )
    212                         }
    213                     }
    214                 } else {
    215                     syncMonitor.note("Share accepted — fetching shared game")
    216                     do {
    217                         _ = try await syncEngine.fetchAcceptedSharedGameDirect(
    218                             gameID: sharedGameID,
    219                             zoneID: sharedZoneID
    220                         )
    221                     } catch {
    222                         guard Self.isRetryableJoinError(error) else { throw error }
    223                         syncMonitor.note(
    224                             "acceptShare: initial shared-game fetch will retry — " +
    225                             Self.describeCloudError(error)
    226                         )
    227                     }
    228                 }
    229             } else {
    230                 syncMonitor.note("Share accepted — discovering shared zone")
    231                 await syncMonitor.run("share-accept shared discovery") {
    232                     _ = try await syncEngine.discoverNewZonesDirect(scope: .shared)
    233                 }
    234             }
    235             // Navigate once the game's puzzle has actually synced and is
    236             // playable. The caller holds the join placeholder up for the whole
    237             // of this call, so waiting here keeps the user on the joining screen
    238             // through a slow sync rather than dropping them back at the Game
    239             // List with an unopened game.
    240             let joinedGameID = try await waitForPlayablePuzzle(
    241                 gameID: sharedGameID,
    242                 zoneID: sharedZoneID
    243             )
    244             // The user tapped Cancel on the joining screen — don't pull them
    245             // into the game. The joined zone still surfaces in the Game List on
    246             // its own once sync settles.
    247             guard !Task.isCancelled else { return .cancelled }
    248             if let joinedGameID {
    249                 try await shareController.confirmSeatAfterJoin(gameID: joinedGameID)
    250             }
    251             NotificationCenter.default.post(
    252                 name: .cloudShareAcceptanceCompleted,
    253                 object: nil,
    254                 userInfo: joinedGameID.map { ["gameID": $0] }
    255             )
    256             if let joinedGameID, let onShareJoined {
    257                 await onShareJoined(joinedGameID)
    258             }
    259             return joinedGameID == nil ? .pendingSync : .opened
    260         } catch {
    261             syncMonitor.recordError("acceptShare", error)
    262             if shareWasAccepted, !(error is AcceptedShareError) {
    263                 throw AcceptedShareError(
    264                     kind: Self.joinFailureKind(for: error),
    265                     underlyingError: error
    266                 )
    267             }
    268             throw error
    269         }
    270     }
    271 
    272     /// How long `acceptShare` holds the joining screen waiting for the puzzle to
    273     /// become playable before returning the user to the Game List. Mirrors
    274     /// `RootView`'s invite-ping join wait.
    275     private static let joinSyncTimeout: TimeInterval = 30
    276     private static let joinSyncPollInterval: Duration = .seconds(1)
    277     /// How often `waitForPlayablePuzzle` re-issues the CloudKit fetch while
    278     /// waiting. Between backstops it only observes the store, which the initial
    279     /// accepted-game fetch (and concurrent sync) populate — so a slow asset
    280     /// commit costs cheap store polls, not repeated three-query CloudKit reads.
    281     private static let joinSyncRefetchInterval: TimeInterval = 3
    282 
    283     /// Polls the just-joined game's own zone until its puzzle is playable, so
    284     /// the joining screen holds through a slow sync rather than dropping the
    285     /// user back at the Game List. The accepted-zone fetch downloads the Game
    286     /// record and its `puzzleSource` asset inline, so this returns on the first
    287     /// check in the common case. Returns nil on timeout, or when the join
    288     /// `Task` is cancelled (the user tapped Cancel) — the caller then doesn't
    289     /// navigate.
    290     private func waitForPlayablePuzzle(
    291         gameID: UUID?,
    292         zoneID: CKRecordZone.ID?
    293     ) async throws -> UUID? {
    294         guard let gameID else { return nil }
    295         if store.joinedSharedGameIDs().contains(gameID) { return gameID }
    296         let deadline = Date().addingTimeInterval(Self.joinSyncTimeout)
    297         // The caller already issued one accepted-game fetch, so begin by just
    298         // observing the store and only re-issue the CloudKit fetch on a backstop
    299         // interval. In the common case the asset commits within a poll or two
    300         // and we return without a single redundant three-query read.
    301         var nextRefetch = Date().addingTimeInterval(Self.joinSyncRefetchInterval)
    302         var lastRetryableError: Error?
    303         while Date() < deadline {
    304             if store.joinedSharedGameIDs().contains(gameID) { return gameID }
    305 
    306             if Date() >= nextRefetch {
    307                 if let zoneID {
    308                     // The gate only reads `puzzleSource`, so the backstop needs
    309                     // just the Game record — the initial accept fetch already
    310                     // pulled Moves/Players, and the grid re-fetches them on open.
    311                     do {
    312                         _ = try await syncEngine.fetchAcceptedSharedGameDirect(
    313                             gameID: gameID,
    314                             zoneID: zoneID,
    315                             onlyGame: true
    316                         )
    317                         lastRetryableError = nil
    318                     } catch {
    319                         guard Self.isRetryableJoinError(error) else { throw error }
    320                         lastRetryableError = error
    321                     }
    322                 } else {
    323                     do {
    324                         _ = try await syncEngine.fetchGameDirect(
    325                             scope: .shared,
    326                             gameID: gameID
    327                         )
    328                         lastRetryableError = nil
    329                     } catch {
    330                         guard Self.isRetryableJoinError(error) else { throw error }
    331                         lastRetryableError = error
    332                     }
    333                 }
    334                 let retryAfter = lastRetryableError.flatMap(Self.retryAfter)
    335                 nextRefetch = Date().addingTimeInterval(
    336                     max(Self.joinSyncRefetchInterval, retryAfter ?? 0)
    337                 )
    338                 if store.joinedSharedGameIDs().contains(gameID) { return gameID }
    339             }
    340 
    341             do {
    342                 try await Task.sleep(for: Self.joinSyncPollInterval)
    343             } catch {
    344                 return nil // cancelled
    345             }
    346         }
    347         syncMonitor.note(
    348             "acceptShare: puzzle not playable within \(Int(Self.joinSyncTimeout))s " +
    349             "for \(gameID.uuidString)"
    350         )
    351         if let lastRetryableError { throw lastRetryableError }
    352         return nil
    353     }
    354 
    355     nonisolated static func joinFailureKind(for error: Error) -> AcceptedShareError.Kind {
    356         let code = cloudErrorCode(error)
    357         if code == .unknownItem || code == .zoneNotFound || code == .userDeletedZone {
    358             return .removed
    359         }
    360         return isRetryableJoinError(error) ? .temporarilyUnavailable : .unavailable
    361     }
    362 
    363     nonisolated static func isRetryableJoinError(_ error: Error) -> Bool {
    364         switch cloudErrorCode(error) {
    365         case .internalError,
    366              .networkUnavailable,
    367              .networkFailure,
    368              .serviceUnavailable,
    369              .requestRateLimited,
    370              .zoneBusy,
    371              .operationCancelled,
    372              .serverResponseLost,
    373              .accountTemporarilyUnavailable:
    374             return true
    375         default:
    376             return false
    377         }
    378     }
    379 
    380     nonisolated static func retryAfter(_ error: Error) -> TimeInterval? {
    381         let value = (unwrappedCloudError(error) as NSError)
    382             .userInfo[CKErrorRetryAfterKey]
    383         if let number = value as? NSNumber { return max(0, number.doubleValue) }
    384         if let interval = value as? TimeInterval { return max(0, interval) }
    385         return nil
    386     }
    387 
    388     nonisolated static func cloudErrorCode(_ error: Error) -> CKError.Code? {
    389         let nsError = unwrappedCloudError(error) as NSError
    390         guard nsError.domain == CKErrorDomain else { return nil }
    391         return CKError.Code(rawValue: nsError.code)
    392     }
    393 
    394     nonisolated private static func unwrappedCloudError(_ error: Error) -> Error {
    395         if let accepted = error as? AcceptedShareError {
    396             return unwrappedCloudError(accepted.underlyingError)
    397         }
    398         return error
    399     }
    400 
    401     nonisolated private static func describeCloudError(_ error: Error) -> String {
    402         let nsError = unwrappedCloudError(error) as NSError
    403         let retry = retryAfter(error).map { " retryAfter=\($0)s" } ?? ""
    404         return "domain=\(nsError.domain) code=\(nsError.code)\(retry) " +
    405             nsError.localizedDescription
    406     }
    407 
    408     func resetAllData() async throws {
    409         await syncEngine.resetSyncState()
    410 
    411         async let privateCleanup: Void = deleteAllPrivateZones()
    412         async let sharedCleanup: Void = leaveAllSharedZones()
    413         _ = await (privateCleanup, sharedCleanup)
    414 
    415         try store.resetAllData()
    416         UserDefaults.standard.removeObject(forKey: "gamePlayerColors")
    417         BadgeState.reset()
    418         syncMonitor.note("Database reset — all games and sync state cleared")
    419     }
    420 
    421     /// Local-only counterpart to `resetAllData`, used on an iCloud account
    422     /// switch. Drops this device's cached store, badge ledger, colour map, and
    423     /// sync-engine tokens, then rebuilds the engines so they resync as the new
    424     /// account. Critically it does **not** delete private zones or leave shared
    425     /// zones: the previous account's games stay intact in *its* CloudKit (the
    426     /// user still has them on other devices) — only this device's stale cache
    427     /// of them is discarded. The store is wiped before `resetSyncState` so the
    428     /// latter's unconfirmed-move recovery finds nothing to re-enqueue.
    429     func purgeLocalData() async throws {
    430         try store.resetAllData()
    431         UserDefaults.standard.removeObject(forKey: "gamePlayerColors")
    432         BadgeState.reset()
    433         await syncEngine.resetSyncState()
    434         syncMonitor.note("Local store purged for account switch — previous account untouched in CloudKit")
    435     }
    436 
    437     private func deleteAllPrivateZones() async {
    438         do {
    439             let zones = try await ckContainer.privateCloudDatabase.allRecordZones()
    440             guard !zones.isEmpty else { return }
    441             let ids = zones.map(\.zoneID)
    442             try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
    443                 let op = CKModifyRecordZonesOperation(
    444                     recordZonesToSave: nil,
    445                     recordZoneIDsToDelete: ids
    446                 )
    447                 op.modifyRecordZonesResultBlock = { result in cont.resume(with: result) }
    448                 ckContainer.privateCloudDatabase.add(op)
    449             }
    450         } catch {
    451             syncMonitor.note("reset: private zone cleanup failed — \(error)")
    452         }
    453     }
    454 
    455     private func leaveAllSharedZones() async {
    456         do {
    457             let zones = try await ckContainer.sharedCloudDatabase.allRecordZones()
    458             for zone in zones {
    459                 try await ckContainer.sharedCloudDatabase.deleteRecordZone(withID: zone.zoneID)
    460             }
    461         } catch {
    462             syncMonitor.note("reset: shared zone cleanup failed — \(error)")
    463         }
    464     }
    465 }