ShareController.swift (47346B)
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 static let maximumPeoplePerPuzzle = 3 12 private static var maximumInviteesPerPuzzle: Int { maximumPeoplePerPuzzle - 1 } 13 private static let ticketPayloadField = "payload" 14 private static let countedTicketVersion = 2 15 16 private struct TicketPayload: Codable { 17 var version: Int 18 var remainingSeats: Int 19 var claimedAuthorIDs: [String] 20 } 21 22 /// The seat ticket for public-link sharing: a `ticket`-kind Ping the owner 23 /// mints into the game zone alongside the link. Current tickets carry their 24 /// remaining-seat count in the existing `payload` string and joiners consume 25 /// a seat by saving a decremented record under CloudKit's optimistic lock; 26 /// legacy one-seat tickets had no count and are still consumed by deletion. 27 /// The `ticket` kind is unknown to `PingKind`, so `Ping.parseRecord` drops 28 /// the record everywhere Pings are surfaced. 29 private static let ticketPingKind = "ticket" 30 private static func ticketRecordName(for gameID: UUID) -> String { 31 "ticket-\(gameID.uuidString)" 32 } 33 34 let container: CKContainer 35 private let persistence: PersistenceController 36 private let syncEngine: SyncEngine 37 private let syncMonitor: SyncMonitor? 38 39 /// Fired after `persistShareName` has saved the local entity's 40 /// `ckShareRecordName`, so dependent state (e.g. the open game's mutator 41 /// `isShared` flag) can flip without waiting for the user to re-open. 42 var onShareSaved: (@MainActor (UUID) -> Void)? 43 44 /// Author IDs added as direct game participants during this app session, 45 /// keyed by game. Re-asserted on every invite save so an eventually- 46 /// consistent share fetch — which can omit a participant added moments 47 /// earlier — can't drop a prior invitee on the next save. In-memory by 48 /// design: it guards the back-to-back invite window within one session; 49 /// across a relaunch the server share has had time to converge, and we 50 /// still never *remove* a participant that a fetched share does carry. 51 private var sessionInvitedAuthorIDs: [UUID: Set<String>] = [:] 52 53 enum ShareError: LocalizedError { 54 case gameNotFound 55 case invalidShareRecord 56 case notAnOwner 57 case invalidGameRecord 58 case missingShareURL 59 case collaborationLimitReached(maxPeople: Int) 60 case directInvitesExist 61 62 var errorDescription: String? { 63 switch self { 64 case .gameNotFound: 65 "Puzzle not found." 66 case .invalidShareRecord: 67 "Invalid share record." 68 case .notAnOwner: 69 "Only the owner can share this puzzle." 70 case .invalidGameRecord: 71 "Invalid puzzle record." 72 case .missingShareURL: 73 "CloudKit did not return a share URL." 74 case .collaborationLimitReached(let maxPeople): 75 "This puzzle already has the maximum of \(maxPeople) people." 76 case .directInvitesExist: 77 "This puzzle already has direct invites, so a share link isn't available." 78 } 79 } 80 } 81 82 init( 83 container: CKContainer, 84 persistence: PersistenceController, 85 syncEngine: SyncEngine, 86 syncMonitor: SyncMonitor? = nil 87 ) { 88 self.container = container 89 self.persistence = persistence 90 self.syncEngine = syncEngine 91 self.syncMonitor = syncMonitor 92 } 93 94 /// Returns the `CKShare` and container for `UICloudSharingController`'s 95 /// preparation handler. For a first-time share, the returned share is 96 /// *unsaved* — `UICloudSharingController` saves it when the user submits 97 /// participants. Call `persistShareName(_:for:)` from the controller's 98 /// `didSaveShare` delegate callback to record the saved share's name. 99 /// For an existing share, the saved share is fetched and returned. 100 func prepareShare(for gameID: UUID) async throws -> (CKShare, CKContainer) { 101 let share = try await prepareShareRecord(for: gameID, publicPermission: .none) 102 return (share, container) 103 } 104 105 /// Creates or updates the game's CloudKit share as a public collaboration 106 /// link and returns the generated URL. This avoids the participant 107 /// management UI and lets Crossmate capture the CloudKit save error 108 /// directly when link creation fails. 109 func createShareLink(for gameID: UUID) async throws -> URL { 110 syncMonitor?.recordStart("create share link") 111 do { 112 // Fetch the share as-is, without flipping its public permission 113 // yet, so an existing direct-invite share stays recognisable: it 114 // carries non-owner invitees while its public permission is still 115 // `.none`. A new share is created with `.readWrite` regardless. 116 let share = try await prepareShareRecord( 117 for: gameID, 118 publicPermission: .readWrite, 119 reconfigureExistingPublicPermission: false 120 ) 121 guard Self.inviteeCount(in: share) < Self.maximumInviteesPerPuzzle else { 122 throw ShareError.collaborationLimitReached(maxPeople: Self.maximumPeoplePerPuzzle) 123 } 124 // Reject converting a direct-invite share into a public link. 125 // Under capacity, non-owner invitees on a `.none` share can only be 126 // friends added directly — a public link keeps `.readWrite` until it 127 // fills (handled by the capacity guard above). Turning it into a link 128 // would create the mixed public/direct-participant state CloudKit 129 // forbids, so the two routes stay mutually exclusive. 130 if share.publicPermission == .none, Self.inviteeCount(in: share) > 0 { 131 throw ShareError.directInvitesExist 132 } 133 share.publicPermission = .readWrite 134 let savedShare: CKShare 135 do { 136 savedShare = try await saveShareForLink(share, for: gameID) 137 } catch let error as CKError where error.code == .serverRecordChanged { 138 savedShare = try await recoverShareLinkAfterSaveConflict(error, for: gameID) 139 } 140 try await setTicketSeats( 141 max(0, Self.maximumInviteesPerPuzzle - Self.inviteeCount(in: savedShare)), 142 claimedAuthorIDs: Self.inviteeAuthorIDs(in: savedShare), 143 for: gameID, 144 in: savedShare.recordID.zoneID 145 ) 146 let url = try shareURL(from: savedShare) 147 syncMonitor?.note("share link created for \(gameID.uuidString): \(url.absoluteString)") 148 syncMonitor?.recordSuccess("create share link") 149 return url 150 } catch { 151 syncMonitor?.recordError("create share link", error) 152 throw error 153 } 154 } 155 156 /// Ensures the game's `CKShare` exists and adds `userRecordName` as a 157 /// `.readWrite` participant. Returns the share URL so the caller can hand 158 /// it to the friend via an `.invite` Ping. Idempotent: re-inviting an 159 /// already-added participant is a no-op re-save. 160 func addFriendParticipant( 161 toGameID gameID: UUID, 162 userRecordName: String 163 ) async throws -> URL { 164 syncMonitor?.recordStart("invite friend to game") 165 do { 166 let share = try await prepareShareRecord( 167 for: gameID, 168 publicPermission: .none, 169 reconfigureExistingPublicPermission: false 170 ) 171 // Re-assert every invitee added this session, not just the new 172 // one. CloudKit reads are not read-after-write consistent, so the 173 // share fetched above can omit a participant added moments earlier 174 // (a second invite right after the first); saving that copy back 175 // would silently revoke them. Restoring the full intended set 176 // before the save guarantees it can never shrink the invitee list 177 // below what we put there. 178 var intended = sessionInvitedAuthorIDs[gameID] ?? [] 179 intended.insert(userRecordName) 180 try enforceInviteCapacity(on: share, addingAll: intended) 181 for authorID in intended { 182 try await addParticipantIfNeeded(authorID, to: share) 183 } 184 revokePublicAccessIfFull(of: share) 185 let saved: CKShare 186 do { 187 saved = try await saveShareForLink(share, for: gameID) 188 } catch let error as CKError where error.code == .serverRecordChanged { 189 saved = try await recoverFriendShareAfterConflict( 190 error, 191 gameID: gameID, 192 userRecordNames: intended 193 ) 194 } 195 sessionInvitedAuthorIDs[gameID] = intended 196 let url = try shareURL(from: saved) 197 syncMonitor?.recordSuccess("invite friend to game") 198 return url 199 } catch { 200 syncMonitor?.recordError("invite friend to game", error) 201 throw error 202 } 203 } 204 205 /// Removes `userRecordName` from the game's `CKShare`, freeing the seat they 206 /// held so the owner can invite someone else. Called on the owner's device 207 /// when an invitee declines (an inbound `.decline` Ping): only the owner can 208 /// manage participants, so the decline rounds back here to do it. No-ops for 209 /// a game we don't own, an unshared game, or a participant who isn't on the 210 /// share. Idempotent. 211 func removeFriendParticipant( 212 fromGameID gameID: UUID, 213 userRecordName: String 214 ) async throws { 215 syncMonitor?.recordStart("free declined seat") 216 do { 217 let ctx = persistence.viewContext 218 let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") 219 request.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 220 request.fetchLimit = 1 221 guard let entity = try ctx.fetch(request).first, entity.databaseScope == 0 else { 222 // Not the owner (or the game is gone) — nothing to manage. 223 syncMonitor?.recordSuccess("free declined seat") 224 return 225 } 226 // Drop the session re-assert first so a concurrent invite save can't 227 // resurrect the declined participant via the intended-set restore. 228 sessionInvitedAuthorIDs[gameID]?.remove(userRecordName) 229 230 let zoneName = entity.ckZoneName ?? "game-\(gameID.uuidString)" 231 guard let share = try await fetchZoneWideShareIfPresent(zoneName: zoneName), 232 removeParticipant(userRecordName, from: share) 233 else { 234 // No share, or they aren't on it (already removed / never added). 235 syncMonitor?.recordSuccess("free declined seat") 236 return 237 } 238 do { 239 _ = try await saveShareForLink(share, for: gameID) 240 } catch let error as CKError where error.code == .serverRecordChanged { 241 guard let serverShare = (error as NSError) 242 .userInfo[CKRecordChangedErrorServerRecordKey] as? CKShare else { 243 throw error 244 } 245 if removeParticipant(userRecordName, from: serverShare) { 246 _ = try await saveShareForLink(serverShare, for: gameID) 247 } 248 } 249 syncMonitor?.recordSuccess("free declined seat") 250 } catch { 251 syncMonitor?.recordError("free declined seat", error) 252 throw error 253 } 254 } 255 256 private func addParticipantIfNeeded( 257 _ userRecordName: String, 258 to share: CKShare 259 ) async throws { 260 let already = share.participants.contains { 261 $0.userIdentity.userRecordID?.recordName == userRecordName 262 } 263 guard !already else { return } 264 let participant = try await fetchParticipant(forUserRecordName: userRecordName) 265 participant.permission = .readWrite 266 share.addParticipant(participant) 267 } 268 269 /// Removes the non-owner participant matching `userRecordName` from `share`, 270 /// returning whether one was found. Idempotent: a participant already gone 271 /// returns `false` so the caller can skip a redundant save. 272 private func removeParticipant( 273 _ userRecordName: String, 274 from share: CKShare 275 ) -> Bool { 276 guard let participant = share.participants.first(where: { 277 $0.role != .owner 278 && $0.userIdentity.userRecordID?.recordName == userRecordName 279 }) else { return false } 280 share.removeParticipant(participant) 281 return true 282 } 283 284 /// Caps the share at `maximumInviteesPerPuzzle` distinct invitees, counting 285 /// the union of those already on the share and everyone we intend to 286 /// (re-)add this save. Author IDs already present don't double-count, so 287 /// re-asserting a prior invitee never trips the limit. 288 private func enforceInviteCapacity(on share: CKShare, addingAll authorIDs: Set<String>) throws { 289 var invitees = Set(Self.inviteeAuthorIDs(in: share)) 290 invitees.formUnion(authorIDs) 291 guard invitees.count <= Self.maximumInviteesPerPuzzle else { 292 throw ShareError.collaborationLimitReached(maxPeople: Self.maximumPeoplePerPuzzle) 293 } 294 } 295 296 /// A full puzzle offers no public link: once an invite commits the last 297 /// seat, the same save revokes any outstanding link so it stops admitting 298 /// joiners at the CloudKit level. 299 private func revokePublicAccessIfFull(of share: CKShare) { 300 guard Self.inviteeCount(in: share) >= Self.maximumInviteesPerPuzzle else { return } 301 share.publicPermission = .none 302 } 303 304 private static func inviteeCount(in share: CKShare) -> Int { 305 inviteeParticipants(in: share).count 306 } 307 308 private static func inviteeAuthorIDs(in share: CKShare) -> [String] { 309 inviteeParticipants(in: share).compactMap { 310 $0.userIdentity.userRecordID?.recordName 311 } 312 } 313 314 private static func inviteeParticipants(in share: CKShare) -> [CKShare.Participant] { 315 share.participants.filter { participant in 316 participant.role != .owner 317 && participant.acceptanceStatus != .removed 318 } 319 } 320 321 private func fetchParticipant( 322 forUserRecordName recordName: String 323 ) async throws -> CKShare.Participant { 324 let lookup = CKUserIdentity.LookupInfo( 325 userRecordID: CKRecord.ID(recordName: recordName) 326 ) 327 return try await withCheckedThrowingContinuation { cont in 328 var found: CKShare.Participant? 329 let op = CKFetchShareParticipantsOperation(userIdentityLookupInfos: [lookup]) 330 op.perShareParticipantResultBlock = { _, result in 331 if case .success(let participant) = result { found = participant } 332 } 333 op.fetchShareParticipantsResultBlock = { result in 334 switch result { 335 case .success: 336 if let found { 337 cont.resume(returning: found) 338 } else { 339 cont.resume(throwing: ShareError.invalidShareRecord) 340 } 341 case .failure(let error): 342 cont.resume(throwing: error) 343 } 344 } 345 self.container.add(op) 346 } 347 } 348 349 private func recoverFriendShareAfterConflict( 350 _ error: CKError, 351 gameID: UUID, 352 userRecordNames: Set<String> 353 ) async throws -> CKShare { 354 let ctx = persistence.viewContext 355 let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") 356 request.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 357 request.fetchLimit = 1 358 guard let entity = try ctx.fetch(request).first else { 359 throw ShareError.gameNotFound 360 } 361 let share: CKShare 362 if let serverShare = (error as NSError).userInfo[CKRecordChangedErrorServerRecordKey] as? CKShare { 363 share = serverShare 364 } else { 365 share = try await fetchExistingShare( 366 recordName: Self.zoneWideShareRecordName, 367 zoneName: entity.ckZoneName ?? "game-\(gameID.uuidString)" 368 ) 369 } 370 // Keep the share's metadata current while applying the current link policy. 371 configureShare(share, title: entity.title, publicPermission: nil) 372 try enforceInviteCapacity(on: share, addingAll: userRecordNames) 373 for authorID in userRecordNames { 374 try await addParticipantIfNeeded(authorID, to: share) 375 } 376 revokePublicAccessIfFull(of: share) 377 return try await saveShareForLink(share, for: gameID) 378 } 379 380 /// Returns the saved public share URL for a game, if Crossmate already 381 /// knows about its `CKShare`. Stale local share references are cleared so 382 /// the caller can safely offer to create a fresh link. 383 func existingShareLink(for gameID: UUID) async throws -> URL? { 384 let ctx = persistence.viewContext 385 let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") 386 request.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 387 request.fetchLimit = 1 388 guard let entity = try ctx.fetch(request).first else { 389 throw ShareError.gameNotFound 390 } 391 guard entity.databaseScope == 0 else { 392 throw ShareError.notAnOwner 393 } 394 guard let existingName = entity.ckShareRecordName else { 395 let zoneName = entity.ckZoneName ?? "game-\(gameID.uuidString)" 396 do { 397 let share = try await fetchExistingShare( 398 recordName: Self.zoneWideShareRecordName, 399 zoneName: zoneName 400 ) 401 entity.ckShareRecordName = share.recordID.recordName 402 try ctx.save() 403 return try await publicLinkURL(from: share, for: gameID) 404 } catch let error as CKError where isMissingShare(error) { 405 return nil 406 } 407 } 408 409 do { 410 let share = try await fetchExistingShare( 411 recordName: existingName, 412 zoneName: entity.ckZoneName ?? "game-\(gameID.uuidString)" 413 ) 414 return try await publicLinkURL(from: share, for: gameID) 415 } catch let error as CKError where error.code == .unknownItem { 416 entity.ckShareRecordName = nil 417 try ctx.save() 418 return nil 419 } 420 } 421 422 /// Resolves a fetched share to its live public link. A full puzzle has no 423 /// link to offer — any lingering public permission is revoked so the old 424 /// URL stops admitting joiners — and a share without public access 425 /// reports `nil` so the caller can offer to create a fresh link. 426 private func publicLinkURL(from share: CKShare, for gameID: UUID) async throws -> URL? { 427 guard Self.inviteeCount(in: share) < Self.maximumInviteesPerPuzzle else { 428 try await disablePublicLinkIfNeeded(share, for: gameID) 429 return nil 430 } 431 guard share.publicPermission != .none else { return nil } 432 return share.url 433 } 434 435 /// Whether the puzzle's invitee seat is already taken, per the share's 436 /// actual participant list (a pending invite counts — the seat is 437 /// committed once offered). Seeds the share sheet so a full game opens 438 /// with invites already disabled instead of surfacing the limit as a 439 /// tap-time error. Best-effort: an unshared game or a transient fetch 440 /// failure reports `false`, and the capacity check in 441 /// `addFriendParticipant` remains the authoritative gate. 442 func isAtInviteCapacity(for gameID: UUID) async -> Bool { 443 let ctx = persistence.viewContext 444 let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") 445 request.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 446 request.fetchLimit = 1 447 guard let entity = try? ctx.fetch(request).first, 448 entity.databaseScope == 0 else { return false } 449 let zoneName = entity.ckZoneName ?? "game-\(gameID.uuidString)" 450 guard let share = (try? await fetchZoneWideShareIfPresent(zoneName: zoneName)) ?? nil 451 else { return false } 452 return Self.inviteeCount(in: share) >= Self.maximumInviteesPerPuzzle 453 } 454 455 /// The author IDs already holding an invitee seat on the game's share. 456 /// Seeds the invite UI so a re-opened share sheet shows everyone you've 457 /// already added with a checkmark instead of an un-invited glyph, which 458 /// otherwise tempts a redundant second invite. Unions the share's current 459 /// invitee participants with the author IDs added this session, since an 460 /// eventually-consistent share fetch can omit a participant added moments 461 /// earlier. Best-effort: an unshared game or a transient fetch failure 462 /// falls back to the session set alone. 463 func invitedAuthorIDs(for gameID: UUID) async -> Set<String> { 464 var invited = invitedAuthorIDsKnownThisSession(for: gameID) 465 let ctx = persistence.viewContext 466 let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") 467 request.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 468 request.fetchLimit = 1 469 guard let entity = try? ctx.fetch(request).first, 470 entity.databaseScope == 0 else { return invited } 471 let zoneName = entity.ckZoneName ?? "game-\(gameID.uuidString)" 472 if let share = (try? await fetchZoneWideShareIfPresent(zoneName: zoneName)) ?? nil { 473 invited.formUnion(Self.inviteeAuthorIDs(in: share)) 474 } 475 return invited 476 } 477 478 /// The author IDs added as invitees during this app session, readable 479 /// synchronously so a re-presented share screen can render their checkmark 480 /// on the first frame — no await, no animated transition — before the 481 /// async invitedAuthorIDs(for:) backfills anyone invited on another device 482 /// or in a prior session. 483 func invitedAuthorIDsKnownThisSession(for gameID: UUID) -> Set<String> { 484 sessionInvitedAuthorIDs[gameID] ?? [] 485 } 486 487 /// The game's grid silhouette for share-link previews, read from the 488 /// cached block layout so it costs nothing at link-creation time. Returns 489 /// `nil` when the cache hasn't been populated, in which case the link simply 490 /// carries no shape segment. 491 func gridSilhouette(for gameID: UUID) -> GridSilhouette.Grid? { 492 let ctx = persistence.viewContext 493 let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") 494 request.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 495 request.fetchLimit = 1 496 guard let entity = try? ctx.fetch(request).first else { return nil } 497 let width = Int(entity.gridWidth) 498 let height = Int(entity.gridHeight) 499 guard width > 0, height > 0, 500 let mask = entity.blockMask, mask.count == width * height else { 501 return nil 502 } 503 return GridSilhouette.Grid(width: width, height: height, blocks: mask.map { $0 != 0 }) 504 } 505 506 private func prepareShareRecord( 507 for gameID: UUID, 508 publicPermission: CKShare.ParticipantPermission, 509 reconfigureExistingPublicPermission: Bool = true 510 ) async throws -> CKShare { 511 // For an *existing* share the friend-invite path passes `false`: the 512 // share keeps whatever public permission it already had (a brand-new 513 // share is still created with the requested `publicPermission`). 514 let existingPermission: CKShare.ParticipantPermission? = 515 reconfigureExistingPublicPermission ? publicPermission : nil 516 let ctx = persistence.viewContext 517 let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") 518 request.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 519 request.fetchLimit = 1 520 guard let entity = try ctx.fetch(request).first else { 521 throw ShareError.gameNotFound 522 } 523 guard entity.databaseScope == 0 else { 524 throw ShareError.notAnOwner 525 } 526 527 if let existingName = entity.ckShareRecordName { 528 do { 529 let existing = try await fetchExistingShare( 530 recordName: existingName, 531 zoneName: entity.ckZoneName ?? "game-\(gameID.uuidString)" 532 ) 533 return configureShare(existing, title: entity.title, publicPermission: existingPermission) 534 } catch let error as CKError where error.code == .unknownItem { 535 entity.ckShareRecordName = nil 536 try ctx.save() 537 } 538 } 539 540 let zoneName = entity.ckZoneName ?? "game-\(gameID.uuidString)" 541 let zoneID = CKRecordZone.ID(zoneName: zoneName, ownerName: CKCurrentUserDefaultName) 542 543 // Create the zone directly rather than going through CKSyncEngine.sendChanges(), 544 // which can block on post-reset state (stale tokens, in-flight operations). 545 // Zone creation is idempotent so this is safe even if the engine already created it. 546 try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in 547 let op = CKModifyRecordZonesOperation( 548 recordZonesToSave: [CKRecordZone(zoneID: zoneID)], 549 recordZoneIDsToDelete: nil 550 ) 551 op.qualityOfService = .userInitiated 552 op.modifyRecordZonesResultBlock = { result in cont.resume(with: result) } 553 self.container.privateCloudDatabase.add(op) 554 } 555 556 if let existing = try await fetchZoneWideShareIfPresent(zoneName: zoneName) { 557 entity.ckShareRecordName = existing.recordID.recordName 558 try ctx.save() 559 return configureShare(existing, title: entity.title, publicPermission: existingPermission) 560 } 561 562 try await ensureGameRecordExists(for: entity, in: zoneID) 563 564 let share = CKShare(recordZoneID: zoneID) 565 return configureShare(share, title: entity.title, publicPermission: publicPermission) 566 } 567 568 /// Records the share's CloudKit record name on the local entity so future 569 /// invocations of `prepareShare` fetch the existing share. Also enqueues a 570 /// Game record push so other owner-devices receive the share marker via 571 /// `RecordSerializer.applyGameRecord` and flip their `isShared` flag. 572 /// Idempotent. 573 func persistShareName(_ recordName: String, for gameID: UUID) async throws { 574 let ctx = persistence.viewContext 575 let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") 576 request.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 577 request.fetchLimit = 1 578 guard let entity = try ctx.fetch(request).first else { return } 579 guard entity.ckShareRecordName != recordName else { return } 580 entity.ckShareRecordName = recordName 581 entity.hasPendingSave = true 582 try ctx.save() 583 if let ckRecordName = entity.ckRecordName { 584 await syncEngine.enqueueGame(ckRecordName: ckRecordName) 585 } 586 onShareSaved?(gameID) 587 } 588 589 /// Removes the current user's participation from a shared game and deletes 590 /// the local entity. No-ops if the game is not a shared (participant) game. 591 func leaveShare(gameID: UUID) async throws { 592 let ctx = persistence.viewContext 593 let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") 594 request.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 595 request.fetchLimit = 1 596 guard let entity = try ctx.fetch(request).first, 597 entity.databaseScope == 1 else { return } 598 599 let zoneName = entity.ckZoneName ?? "game-\(gameID.uuidString)" 600 let ownerName = entity.ckZoneOwnerName ?? CKCurrentUserDefaultName 601 let zoneID = CKRecordZone.ID(zoneName: zoneName, ownerName: ownerName) 602 let shareID = CKRecord.ID(recordName: Self.zoneWideShareRecordName, zoneID: zoneID) 603 604 // A participant leaves a zone-wide share by deleting the CKShare record 605 // from the shared database; deleting the zone itself is rejected with 606 // "Zone delete not allowed". 607 do { 608 try await container.sharedCloudDatabase.deleteRecord(withID: shareID) 609 } catch let error as CKError where error.code == .unknownItem || error.code == .zoneNotFound { 610 // Already gone — proceed to clean up local state. 611 } 612 613 // Delete the invite Ping that brought us in, if it's still around. 614 // It's durable and its usual cleanup (`consumeStaleInvites`) keys off 615 // the local GameEntity we're about to remove, so leaving it behind 616 // lets the invite resurrect on the next cold start and on sibling 617 // devices. Done before the local delete so a query failure can't strand 618 // a half-left game. 619 await syncEngine.deleteInvitePingsAfterLeave(forGameID: gameID) 620 621 // Record the leave as a durable per-user fact so the user's other 622 // devices hard-delete this game too. Without it, a sibling sees only 623 // the shared-zone deletion — indistinguishable from the owner 624 // revoking access — and would mislabel the row "no longer have 625 // access" instead of removing it. Best-effort but self-healing: the 626 // record is re-consulted on every sync, not consumed once. 627 await syncEngine.enqueueDecision(kind: "left", key: gameID.uuidString) 628 629 ctx.delete(entity) 630 try ctx.save() 631 } 632 633 /// Best-effort cleanup for terminal games. Deleting a game deletes its 634 /// CloudKit zone and therefore the ticket; completion keeps the zone around 635 /// for replay/archive, so close the public-link seat explicitly. 636 func closeTicketForCompletedGame(gameID: UUID) async { 637 let ctx = persistence.viewContext 638 let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") 639 request.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 640 request.fetchLimit = 1 641 guard let entity = try? ctx.fetch(request).first, 642 entity.completedAt != nil else { return } 643 644 let zoneName = entity.ckZoneName ?? "game-\(gameID.uuidString)" 645 let ownerName = entity.databaseScope == 0 646 ? CKCurrentUserDefaultName 647 : (entity.ckZoneOwnerName ?? CKCurrentUserDefaultName) 648 let zoneID = CKRecordZone.ID(zoneName: zoneName, ownerName: ownerName) 649 let database = entity.databaseScope == 1 650 ? container.sharedCloudDatabase 651 : container.privateCloudDatabase 652 let ticketID = CKRecord.ID(recordName: Self.ticketRecordName(for: gameID), zoneID: zoneID) 653 654 do { 655 try await database.deleteRecord(withID: ticketID) 656 syncMonitor?.note("ticket closed for completed game \(gameID.uuidString)") 657 } catch let error as CKError where error.code == .unknownItem || error.code == .zoneNotFound { 658 // Already gone, or the zone was deleted; either way the link seat is closed. 659 } catch { 660 syncMonitor?.note( 661 "ticket close skipped for \(gameID.uuidString): \(error.localizedDescription)" 662 ) 663 } 664 } 665 666 /// Joiner-side seat check, run right after a share acceptance has synced 667 /// the new zone. Cooperative by design: CloudKit cannot enforce a 668 /// participant cap, so an over-cap joiner leaves voluntarily and a client 669 /// that skips the check keeps access until the owner intervenes. 670 /// 671 /// Directly invited friends always keep their seat — the owner added them 672 /// by identity, so the participant list itself is the gate. Link joiners 673 /// are admitted while the share is under the cap; simultaneous joiners 674 /// that cannot see each other in the participant list yet are settled by 675 /// consuming one slot from the zone's ticket Ping. A missing or exhausted 676 /// ticket without a prior Player-record footprint means the seat went to 677 /// someone else (or the link predates tickets and is considered dead), so 678 /// the joiner leaves. 679 /// 680 /// Only `ShareError.collaborationLimitReached` escapes. A transient 681 /// CloudKit failure is traced and the join stands — a failed check must 682 /// not turn a successful join into a reported failure; the cap then rests 683 /// on the other cooperating clients. 684 func confirmSeatAfterJoin(gameID: UUID) async throws { 685 let ctx = persistence.viewContext 686 let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") 687 request.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 688 request.fetchLimit = 1 689 guard let entity = try? ctx.fetch(request).first, 690 entity.databaseScope == 1 else { return } 691 692 let zoneName = entity.ckZoneName ?? "game-\(gameID.uuidString)" 693 let ownerName = entity.ckZoneOwnerName ?? CKCurrentUserDefaultName 694 let zoneID = CKRecordZone.ID(zoneName: zoneName, ownerName: ownerName) 695 696 let seatLost: Bool 697 do { 698 seatLost = try await hasLostSeat(gameID: gameID, zoneID: zoneID) 699 } catch { 700 syncMonitor?.note( 701 "join seat check skipped for \(gameID.uuidString): \(error.localizedDescription)" 702 ) 703 return 704 } 705 guard seatLost else { return } 706 707 // Best-effort: if leaving fails the local row lingers, but the limit 708 // error is still the truthful outcome to surface for this join. 709 try? await leaveShare(gameID: gameID) 710 throw ShareError.collaborationLimitReached(maxPeople: Self.maximumPeoplePerPuzzle) 711 } 712 713 private func hasLostSeat(gameID: UUID, zoneID: CKRecordZone.ID) async throws -> Bool { 714 let database = container.sharedCloudDatabase 715 let shareID = CKRecord.ID(recordName: Self.zoneWideShareRecordName, zoneID: zoneID) 716 guard let share = try await database.record(for: shareID) as? CKShare else { 717 return false 718 } 719 if share.currentUserParticipant?.role == .privateUser { return false } 720 if Self.inviteeCount(in: share) > Self.maximumInviteesPerPuzzle { return true } 721 if try await hasNoPlayerFootprint(gameID: gameID, zoneID: zoneID, in: database) == false { 722 return false 723 } 724 725 // Under the cap. A simultaneous link joiner may not be visible in the 726 // participant list yet; consuming a ticket seat settles it atomically. 727 let ticketID = CKRecord.ID( 728 recordName: Self.ticketRecordName(for: gameID), 729 zoneID: zoneID 730 ) 731 return try await consumeTicketSeat(ticketID: ticketID, in: database) == false 732 } 733 734 private func hasNoPlayerFootprint( 735 gameID: UUID, 736 zoneID: CKRecordZone.ID, 737 in database: CKDatabase 738 ) async throws -> Bool { 739 let myRecordName = try await container.userRecordID().recordName 740 let playerID = CKRecord.ID( 741 recordName: RecordSerializer.recordName( 742 forPlayerInGame: gameID, 743 authorID: myRecordName 744 ), 745 zoneID: zoneID 746 ) 747 do { 748 _ = try await database.record(for: playerID) 749 return false 750 } catch let error as CKError where error.code == .unknownItem { 751 return true 752 } 753 } 754 755 // MARK: - Helpers 756 757 private func fetchExistingShare( 758 recordName: String, 759 zoneName: String 760 ) async throws -> CKShare { 761 let zoneID = CKRecordZone.ID(zoneName: zoneName, ownerName: CKCurrentUserDefaultName) 762 let recordID = CKRecord.ID(recordName: recordName, zoneID: zoneID) 763 let record = try await container.privateCloudDatabase.record(for: recordID) 764 guard let share = record as? CKShare else { 765 throw ShareError.invalidShareRecord 766 } 767 return share 768 } 769 770 private func fetchZoneWideShareIfPresent(zoneName: String) async throws -> CKShare? { 771 do { 772 return try await fetchExistingShare( 773 recordName: Self.zoneWideShareRecordName, 774 zoneName: zoneName 775 ) 776 } catch let error as CKError where isMissingShare(error) { 777 return nil 778 } 779 } 780 781 private func isMissingShare(_ error: CKError) -> Bool { 782 error.code == .unknownItem || error.code == .zoneNotFound 783 } 784 785 @discardableResult 786 private func configureShare( 787 _ share: CKShare, 788 title: String?, 789 publicPermission: CKShare.ParticipantPermission? 790 ) -> CKShare { 791 // `nil` leaves the existing public permission untouched — the 792 // friend-invite path uses it so re-saving a share doesn't disturb a 793 // link the owner created separately while the seat is still open. 794 if let publicPermission { 795 share.publicPermission = publicPermission 796 } 797 share[CKShare.SystemFieldKey.title] = title as CKRecordValue? 798 return share 799 } 800 801 /// Saves the zone's counted seat ticket. Recreating a public link resets the 802 /// count to the share's currently available invitee seats, which reopens a 803 /// freed seat after a participant is removed while keeping a full game closed. 804 private func setTicketSeats( 805 _ seats: Int, 806 claimedAuthorIDs: [String], 807 for gameID: UUID, 808 in zoneID: CKRecordZone.ID 809 ) async throws { 810 let ticketID = CKRecord.ID( 811 recordName: Self.ticketRecordName(for: gameID), 812 zoneID: zoneID 813 ) 814 let ticket: CKRecord 815 do { 816 ticket = try await container.privateCloudDatabase.record(for: ticketID) 817 } catch let error as CKError where error.code == .unknownItem { 818 ticket = CKRecord(recordType: "Ping", recordID: ticketID) 819 } 820 ticket["kind"] = Self.ticketPingKind as CKRecordValue 821 ticket["authorID"] = try await container.userRecordID().recordName as CKRecordValue 822 try Self.setTicketPayload( 823 TicketPayload( 824 version: Self.countedTicketVersion, 825 remainingSeats: seats, 826 claimedAuthorIDs: Self.normalizedClaimedAuthorIDs(claimedAuthorIDs) 827 ), 828 on: ticket 829 ) 830 do { 831 _ = try await container.privateCloudDatabase.save(ticket) 832 } catch let error as CKError where error.code == .serverRecordChanged { 833 guard let serverTicket = (error as NSError).userInfo[CKRecordChangedErrorServerRecordKey] as? CKRecord else { 834 throw error 835 } 836 serverTicket["kind"] = Self.ticketPingKind as CKRecordValue 837 serverTicket["authorID"] = try await container.userRecordID().recordName as CKRecordValue 838 try Self.setTicketPayload( 839 TicketPayload( 840 version: Self.countedTicketVersion, 841 remainingSeats: seats, 842 claimedAuthorIDs: Self.normalizedClaimedAuthorIDs(claimedAuthorIDs) 843 ), 844 on: serverTicket 845 ) 846 _ = try await container.privateCloudDatabase.save(serverTicket) 847 } 848 } 849 850 /// Returns true when this joiner successfully consumed a public-link seat. 851 /// Legacy tickets have no seat count and are consumed by deleting the record. 852 private func consumeTicketSeat(ticketID: CKRecord.ID, in database: CKDatabase) async throws -> Bool { 853 let myRecordName = try await container.userRecordID().recordName 854 var attempts = 0 855 var ticket: CKRecord? 856 while attempts < 4 { 857 attempts += 1 858 let record: CKRecord 859 if let ticket { 860 record = ticket 861 } else { 862 do { 863 record = try await database.record(for: ticketID) 864 } catch let error as CKError where error.code == .unknownItem { 865 return false 866 } 867 } 868 869 guard var payload = Self.ticketPayload(in: record) else { 870 do { 871 try await database.deleteRecord(withID: ticketID) 872 return true 873 } catch let error as CKError where error.code == .unknownItem { 874 return false 875 } 876 } 877 878 var claimedAuthorIDs = Set(payload.claimedAuthorIDs) 879 if claimedAuthorIDs.contains(myRecordName) { 880 return true 881 } 882 883 guard payload.remainingSeats > 0 else { return false } 884 claimedAuthorIDs.insert(myRecordName) 885 payload.version = Self.countedTicketVersion 886 payload.remainingSeats -= 1 887 payload.claimedAuthorIDs = Self.normalizedClaimedAuthorIDs(Array(claimedAuthorIDs)) 888 try Self.setTicketPayload(payload, on: record) 889 do { 890 _ = try await database.save(record) 891 return true 892 } catch let error as CKError where error.code == .serverRecordChanged { 893 ticket = (error as NSError).userInfo[CKRecordChangedErrorServerRecordKey] as? CKRecord 894 } 895 } 896 return false 897 } 898 899 private static func setTicketPayload(_ payload: TicketPayload, on ticket: CKRecord) throws { 900 let data = try JSONEncoder().encode(payload) 901 ticket[ticketPayloadField] = String(decoding: data, as: UTF8.self) as CKRecordValue 902 } 903 904 private static func ticketPayload(in ticket: CKRecord) -> TicketPayload? { 905 if let value = ticket[ticketPayloadField] as? String, 906 let data = value.data(using: .utf8), 907 let payload = try? JSONDecoder().decode(TicketPayload.self, from: data) { 908 return payload 909 } 910 return nil 911 } 912 913 private static func normalizedClaimedAuthorIDs(_ authorIDs: [String]) -> [String] { 914 authorIDs.filter { !$0.isEmpty }.sorted() 915 } 916 917 private func disablePublicLinkIfNeeded(_ share: CKShare, for gameID: UUID) async throws { 918 guard share.publicPermission != .none else { return } 919 share.publicPermission = .none 920 _ = try await saveShareForLink(share, for: gameID) 921 } 922 923 private func saveShareForLink(_ share: CKShare, for gameID: UUID) async throws -> CKShare { 924 let savedRecord = try await container.privateCloudDatabase.save(share) 925 guard let savedShare = savedRecord as? CKShare else { 926 throw ShareError.invalidShareRecord 927 } 928 try await persistShareName(savedShare.recordID.recordName, for: gameID) 929 return savedShare 930 } 931 932 private func shareURL(from share: CKShare) throws -> URL { 933 guard let url = share.url else { 934 throw ShareError.missingShareURL 935 } 936 return url 937 } 938 939 private func recoverShareLinkAfterSaveConflict( 940 _ error: CKError, 941 for gameID: UUID 942 ) async throws -> CKShare { 943 let ctx = persistence.viewContext 944 let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") 945 request.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 946 request.fetchLimit = 1 947 guard let entity = try ctx.fetch(request).first else { 948 throw ShareError.gameNotFound 949 } 950 951 let share: CKShare 952 if let serverShare = (error as NSError).userInfo[CKRecordChangedErrorServerRecordKey] as? CKShare { 953 share = serverShare 954 } else { 955 share = try await fetchExistingShare( 956 recordName: Self.zoneWideShareRecordName, 957 zoneName: entity.ckZoneName ?? "game-\(gameID.uuidString)" 958 ) 959 } 960 961 // The conflicting save may have been a sibling device committing the 962 // seat; re-check capacity against the server share before re-opening 963 // public access. 964 guard Self.inviteeCount(in: share) < Self.maximumInviteesPerPuzzle else { 965 throw ShareError.collaborationLimitReached(maxPeople: Self.maximumPeoplePerPuzzle) 966 } 967 configureShare(share, title: entity.title, publicPermission: .readWrite) 968 return try await saveShareForLink(share, for: gameID) 969 } 970 971 /// CloudKit requires the initial records covered by a new share to already 972 /// exist on the server or be saved with the share. `CKShareTransferRepresentation` 973 /// only returns the share, so save the root game record before handing the 974 /// zone-wide share to the system UI. 975 private func ensureGameRecordExists( 976 for entity: GameEntity, 977 in zoneID: CKRecordZone.ID 978 ) async throws { 979 guard let recordName = entity.ckRecordName else { 980 throw ShareError.invalidGameRecord 981 } 982 let recordID = CKRecord.ID(recordName: recordName, zoneID: zoneID) 983 let record: CKRecord 984 let includePuzzleSource: Bool 985 986 do { 987 record = try await container.privateCloudDatabase.record(for: recordID) 988 includePuzzleSource = record["puzzleSource"] == nil 989 } catch let error as CKError where error.code == .unknownItem { 990 guard let newRecord = RecordSerializer.gameRecord( 991 from: entity, 992 recordID: recordID, 993 includePuzzleSource: true 994 ) else { 995 throw ShareError.invalidGameRecord 996 } 997 record = newRecord 998 includePuzzleSource = true 999 } 1000 1001 RecordSerializer.populateGameRecord( 1002 record, 1003 from: entity, 1004 includePuzzleSource: includePuzzleSource 1005 ) 1006 let saved: CKRecord 1007 do { 1008 saved = try await container.privateCloudDatabase.save(record) 1009 } catch let error as CKError where error.code == .serverRecordChanged { 1010 let serverRecord: CKRecord 1011 if let conflictRecord = (error as NSError).userInfo[CKRecordChangedErrorServerRecordKey] as? CKRecord { 1012 serverRecord = conflictRecord 1013 } else { 1014 serverRecord = try await container.privateCloudDatabase.record(for: recordID) 1015 } 1016 RecordSerializer.populateGameRecord( 1017 serverRecord, 1018 from: entity, 1019 includePuzzleSource: serverRecord["puzzleSource"] == nil 1020 ) 1021 saved = try await container.privateCloudDatabase.save(serverRecord) 1022 } 1023 entity.ckSystemFields = RecordSerializer.encodeSystemFields(of: saved) 1024 entity.lastSyncedAt = Date() 1025 if entity.ckZoneName == nil { 1026 entity.ckZoneName = zoneID.zoneName 1027 } 1028 try persistence.viewContext.save() 1029 } 1030 }