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 }