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 }