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