ShareController.swift (14604B)
1 import CloudKit 2 import CoreData 3 import Foundation 4 5 /// Manages the lifecycle of `CKShare` objects for per-game zones. Responsible 6 /// for creating zone-scoped shares and saving them to CloudKit, refreshing 7 /// existing shares on re-present, and letting participants leave a shared game. 8 @MainActor 9 final class ShareController { 10 private static let zoneWideShareRecordName = "cloudkit.zoneshare" 11 12 let container: CKContainer 13 private let persistence: PersistenceController 14 private let syncEngine: SyncEngine 15 private let syncMonitor: SyncMonitor? 16 17 /// Fired after `persistShareName` has saved the local entity's 18 /// `ckShareRecordName`, so dependent state (e.g. the open game's mutator 19 /// `isShared` flag) can flip without waiting for the user to re-open. 20 var onShareSaved: (@MainActor (UUID) -> Void)? 21 22 enum ShareError: Error { 23 case gameNotFound 24 case invalidShareRecord 25 case notAnOwner 26 case invalidGameRecord 27 case missingShareURL 28 } 29 30 init( 31 container: CKContainer, 32 persistence: PersistenceController, 33 syncEngine: SyncEngine, 34 syncMonitor: SyncMonitor? = nil 35 ) { 36 self.container = container 37 self.persistence = persistence 38 self.syncEngine = syncEngine 39 self.syncMonitor = syncMonitor 40 } 41 42 /// Returns the `CKShare` and container for `UICloudSharingController`'s 43 /// preparation handler. For a first-time share, the returned share is 44 /// *unsaved* — `UICloudSharingController` saves it when the user submits 45 /// participants. Call `persistShareName(_:for:)` from the controller's 46 /// `didSaveShare` delegate callback to record the saved share's name. 47 /// For an existing share, the saved share is fetched and returned. 48 func prepareShare(for gameID: UUID) async throws -> (CKShare, CKContainer) { 49 let share = try await prepareShareRecord(for: gameID, publicPermission: .none) 50 return (share, container) 51 } 52 53 /// Creates or updates the game's CloudKit share as a public collaboration 54 /// link and returns the generated URL. This avoids the participant 55 /// management UI and lets Crossmate capture the CloudKit save error 56 /// directly when link creation fails. 57 func createShareLink(for gameID: UUID) async throws -> URL { 58 syncMonitor?.recordStart("create share link") 59 do { 60 let share = try await prepareShareRecord(for: gameID, publicPermission: .readWrite) 61 let savedShare: CKShare 62 do { 63 savedShare = try await saveShareForLink(share, for: gameID) 64 } catch let error as CKError where error.code == .serverRecordChanged { 65 savedShare = try await recoverShareLinkAfterSaveConflict(error, for: gameID) 66 } 67 let url = try shareURL(from: savedShare) 68 syncMonitor?.note("share link created for \(gameID.uuidString): \(url.absoluteString)") 69 syncMonitor?.recordSuccess("create share link") 70 return url 71 } catch { 72 syncMonitor?.recordError("create share link", error) 73 throw error 74 } 75 } 76 77 /// Returns the saved public share URL for a game, if Crossmate already 78 /// knows about its `CKShare`. Stale local share references are cleared so 79 /// the caller can safely offer to create a fresh link. 80 func existingShareLink(for gameID: UUID) async throws -> URL? { 81 let ctx = persistence.viewContext 82 let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") 83 request.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 84 request.fetchLimit = 1 85 guard let entity = try ctx.fetch(request).first else { 86 throw ShareError.gameNotFound 87 } 88 guard entity.databaseScope == 0 else { 89 throw ShareError.notAnOwner 90 } 91 guard let existingName = entity.ckShareRecordName else { 92 let zoneName = entity.ckZoneName ?? "game-\(gameID.uuidString)" 93 do { 94 let share = try await fetchExistingShare( 95 recordName: Self.zoneWideShareRecordName, 96 zoneName: zoneName 97 ) 98 entity.ckShareRecordName = share.recordID.recordName 99 try ctx.save() 100 return share.url 101 } catch let error as CKError where isMissingShare(error) { 102 return nil 103 } 104 } 105 106 do { 107 let share = try await fetchExistingShare( 108 recordName: existingName, 109 zoneName: entity.ckZoneName ?? "game-\(gameID.uuidString)" 110 ) 111 return share.url 112 } catch let error as CKError where error.code == .unknownItem { 113 entity.ckShareRecordName = nil 114 try ctx.save() 115 return nil 116 } 117 } 118 119 private func prepareShareRecord( 120 for gameID: UUID, 121 publicPermission: CKShare.ParticipantPermission 122 ) async throws -> CKShare { 123 let ctx = persistence.viewContext 124 let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") 125 request.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 126 request.fetchLimit = 1 127 guard let entity = try ctx.fetch(request).first else { 128 throw ShareError.gameNotFound 129 } 130 guard entity.databaseScope == 0 else { 131 throw ShareError.notAnOwner 132 } 133 134 if let existingName = entity.ckShareRecordName { 135 do { 136 let existing = try await fetchExistingShare( 137 recordName: existingName, 138 zoneName: entity.ckZoneName ?? "game-\(gameID.uuidString)" 139 ) 140 return configureShare(existing, title: entity.title, publicPermission: publicPermission) 141 } catch let error as CKError where error.code == .unknownItem { 142 entity.ckShareRecordName = nil 143 try ctx.save() 144 } 145 } 146 147 let zoneName = entity.ckZoneName ?? "game-\(gameID.uuidString)" 148 let zoneID = CKRecordZone.ID(zoneName: zoneName, ownerName: CKCurrentUserDefaultName) 149 150 // Create the zone directly rather than going through CKSyncEngine.sendChanges(), 151 // which can block on post-reset state (stale tokens, in-flight operations). 152 // Zone creation is idempotent so this is safe even if the engine already created it. 153 try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in 154 let op = CKModifyRecordZonesOperation( 155 recordZonesToSave: [CKRecordZone(zoneID: zoneID)], 156 recordZoneIDsToDelete: nil 157 ) 158 op.qualityOfService = .userInitiated 159 op.modifyRecordZonesResultBlock = { result in cont.resume(with: result) } 160 self.container.privateCloudDatabase.add(op) 161 } 162 163 if let existing = try await fetchZoneWideShareIfPresent(zoneName: zoneName) { 164 entity.ckShareRecordName = existing.recordID.recordName 165 try ctx.save() 166 return configureShare(existing, title: entity.title, publicPermission: publicPermission) 167 } 168 169 try await ensureGameRecordExists(for: entity, in: zoneID) 170 171 let share = CKShare(recordZoneID: zoneID) 172 return configureShare(share, title: entity.title, publicPermission: publicPermission) 173 } 174 175 /// Records the share's CloudKit record name on the local entity so future 176 /// invocations of `prepareShare` fetch the existing share. Also enqueues a 177 /// Game record push so other owner-devices receive the share marker via 178 /// `RecordSerializer.applyGameRecord` and flip their `isShared` flag. 179 /// Idempotent. 180 func persistShareName(_ recordName: String, for gameID: UUID) async throws { 181 let ctx = persistence.viewContext 182 let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") 183 request.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 184 request.fetchLimit = 1 185 guard let entity = try ctx.fetch(request).first else { return } 186 guard entity.ckShareRecordName != recordName else { return } 187 entity.ckShareRecordName = recordName 188 try ctx.save() 189 if let ckRecordName = entity.ckRecordName { 190 await syncEngine.enqueueGame(ckRecordName: ckRecordName) 191 } 192 onShareSaved?(gameID) 193 } 194 195 /// Removes the current user's participation from a shared game and deletes 196 /// the local entity. No-ops if the game is not a shared (participant) game. 197 func leaveShare(gameID: UUID) async throws { 198 let ctx = persistence.viewContext 199 let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") 200 request.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 201 request.fetchLimit = 1 202 guard let entity = try ctx.fetch(request).first, 203 entity.databaseScope == 1 else { return } 204 205 let zoneName = entity.ckZoneName ?? "game-\(gameID.uuidString)" 206 let ownerName = entity.ckZoneOwnerName ?? CKCurrentUserDefaultName 207 let zoneID = CKRecordZone.ID(zoneName: zoneName, ownerName: ownerName) 208 let shareID = CKRecord.ID(recordName: Self.zoneWideShareRecordName, zoneID: zoneID) 209 210 // A participant leaves a zone-wide share by deleting the CKShare record 211 // from the shared database; deleting the zone itself is rejected with 212 // "Zone delete not allowed". 213 do { 214 try await container.sharedCloudDatabase.deleteRecord(withID: shareID) 215 } catch let error as CKError where error.code == .unknownItem || error.code == .zoneNotFound { 216 // Already gone — proceed to clean up local state. 217 } 218 219 ctx.delete(entity) 220 try ctx.save() 221 } 222 223 // MARK: - Helpers 224 225 private func fetchExistingShare( 226 recordName: String, 227 zoneName: String 228 ) async throws -> CKShare { 229 let zoneID = CKRecordZone.ID(zoneName: zoneName, ownerName: CKCurrentUserDefaultName) 230 let recordID = CKRecord.ID(recordName: recordName, zoneID: zoneID) 231 let record = try await container.privateCloudDatabase.record(for: recordID) 232 guard let share = record as? CKShare else { 233 throw ShareError.invalidShareRecord 234 } 235 return share 236 } 237 238 private func fetchZoneWideShareIfPresent(zoneName: String) async throws -> CKShare? { 239 do { 240 return try await fetchExistingShare( 241 recordName: Self.zoneWideShareRecordName, 242 zoneName: zoneName 243 ) 244 } catch let error as CKError where isMissingShare(error) { 245 return nil 246 } 247 } 248 249 private func isMissingShare(_ error: CKError) -> Bool { 250 error.code == .unknownItem || error.code == .zoneNotFound 251 } 252 253 @discardableResult 254 private func configureShare( 255 _ share: CKShare, 256 title: String?, 257 publicPermission: CKShare.ParticipantPermission 258 ) -> CKShare { 259 share.publicPermission = publicPermission 260 share[CKShare.SystemFieldKey.title] = title as CKRecordValue? 261 return share 262 } 263 264 private func saveShareForLink(_ share: CKShare, for gameID: UUID) async throws -> CKShare { 265 let savedRecord = try await container.privateCloudDatabase.save(share) 266 guard let savedShare = savedRecord as? CKShare else { 267 throw ShareError.invalidShareRecord 268 } 269 try await persistShareName(savedShare.recordID.recordName, for: gameID) 270 return savedShare 271 } 272 273 private func shareURL(from share: CKShare) throws -> URL { 274 guard let url = share.url else { 275 throw ShareError.missingShareURL 276 } 277 return url 278 } 279 280 private func recoverShareLinkAfterSaveConflict( 281 _ error: CKError, 282 for gameID: UUID 283 ) async throws -> CKShare { 284 let ctx = persistence.viewContext 285 let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") 286 request.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 287 request.fetchLimit = 1 288 guard let entity = try ctx.fetch(request).first else { 289 throw ShareError.gameNotFound 290 } 291 292 let share: CKShare 293 if let serverShare = (error as NSError).userInfo[CKRecordChangedErrorServerRecordKey] as? CKShare { 294 share = serverShare 295 } else { 296 share = try await fetchExistingShare( 297 recordName: Self.zoneWideShareRecordName, 298 zoneName: entity.ckZoneName ?? "game-\(gameID.uuidString)" 299 ) 300 } 301 302 configureShare(share, title: entity.title, publicPermission: .readWrite) 303 return try await saveShareForLink(share, for: gameID) 304 } 305 306 /// CloudKit requires the initial records covered by a new share to already 307 /// exist on the server or be saved with the share. `CKShareTransferRepresentation` 308 /// only returns the share, so save the root game record before handing the 309 /// zone-wide share to the system UI. 310 private func ensureGameRecordExists( 311 for entity: GameEntity, 312 in zoneID: CKRecordZone.ID 313 ) async throws { 314 guard let recordName = entity.ckRecordName else { 315 throw ShareError.invalidGameRecord 316 } 317 let recordID = CKRecord.ID(recordName: recordName, zoneID: zoneID) 318 let record: CKRecord 319 let includePuzzleSource: Bool 320 321 do { 322 record = try await container.privateCloudDatabase.record(for: recordID) 323 includePuzzleSource = record["puzzleSource"] == nil 324 } catch let error as CKError where error.code == .unknownItem { 325 guard let newRecord = RecordSerializer.gameRecord( 326 from: entity, 327 recordID: recordID, 328 includePuzzleSource: true 329 ) else { 330 throw ShareError.invalidGameRecord 331 } 332 record = newRecord 333 includePuzzleSource = true 334 } 335 336 RecordSerializer.populateGameRecord( 337 record, 338 from: entity, 339 includePuzzleSource: includePuzzleSource 340 ) 341 let saved = try await container.privateCloudDatabase.save(record) 342 entity.ckSystemFields = RecordSerializer.encodeSystemFields(of: saved) 343 entity.lastSyncedAt = Date() 344 if entity.ckZoneName == nil { 345 entity.ckZoneName = zoneID.zoneName 346 } 347 try persistence.viewContext.save() 348 } 349 }