crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

commit fd9f55974ee025eae21cb41bc143afd3f29a54b3
parent c502f3e58561fa059e1930d2628a03a46c9f7657
Author: Michael Camilleri <[email protected]>
Date:   Fri,  1 May 2026 20:47:37 +0900

Recover existing CloudKit zone shares

Creating a share link assumes that a missing local share record name means no
CloudKit share existed. However, if the zone-wide share already exists on the
server, CloudKit rejects the save because the `cloudkit.zoneshare` record is
being inserted again.

This commit makes share-link creation idempotent by probing for the well-known
zone share record before creating a new share, and by recovering from a
server-record conflict during the share save.

Co-Authored-By: Codex GPT 5.5 <[email protected]>

Diffstat:
MCrossmate/Sync/ShareController.swift | 109++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
1 file changed, 95 insertions(+), 14 deletions(-)

diff --git a/Crossmate/Sync/ShareController.swift b/Crossmate/Sync/ShareController.swift @@ -7,6 +7,8 @@ import Foundation /// existing shares on re-present, and letting participants leave a shared game. @MainActor final class ShareController { + private static let zoneWideShareRecordName = "cloudkit.zoneshare" + let container: CKContainer private let persistence: PersistenceController private let syncEngine: SyncEngine @@ -51,14 +53,13 @@ final class ShareController { syncMonitor?.recordStart("create share link") do { let share = try await prepareShareRecord(for: gameID, publicPermission: .readWrite) - let savedRecord = try await container.privateCloudDatabase.save(share) - guard let savedShare = savedRecord as? CKShare else { - throw ShareError.invalidShareRecord - } - try persistShareName(savedShare.recordID.recordName, for: gameID) - guard let url = savedShare.url else { - throw ShareError.missingShareURL + let savedShare: CKShare + do { + savedShare = try await saveShareForLink(share, for: gameID) + } catch let error as CKError where error.code == .serverRecordChanged { + savedShare = try await recoverShareLinkAfterSaveConflict(error, for: gameID) } + let url = try shareURL(from: savedShare) syncMonitor?.note("share link created for \(gameID.uuidString): \(url.absoluteString)") syncMonitor?.recordSuccess("create share link") return url @@ -83,7 +84,18 @@ final class ShareController { throw ShareError.notAnOwner } guard let existingName = entity.ckShareRecordName else { - return nil + let zoneName = entity.ckZoneName ?? "game-\(gameID.uuidString)" + do { + let share = try await fetchExistingShare( + recordName: Self.zoneWideShareRecordName, + zoneName: zoneName + ) + entity.ckShareRecordName = share.recordID.recordName + try ctx.save() + return share.url + } catch let error as CKError where isMissingShare(error) { + return nil + } } do { @@ -120,9 +132,7 @@ final class ShareController { recordName: existingName, zoneName: entity.ckZoneName ?? "game-\(gameID.uuidString)" ) - existing.publicPermission = publicPermission - existing[CKShare.SystemFieldKey.title] = entity.title as CKRecordValue? - return existing + return configureShare(existing, title: entity.title, publicPermission: publicPermission) } catch let error as CKError where error.code == .unknownItem { entity.ckShareRecordName = nil try ctx.save() @@ -145,12 +155,16 @@ final class ShareController { self.container.privateCloudDatabase.add(op) } + if let existing = try await fetchZoneWideShareIfPresent(zoneName: zoneName) { + entity.ckShareRecordName = existing.recordID.recordName + try ctx.save() + return configureShare(existing, title: entity.title, publicPermission: publicPermission) + } + try await ensureGameRecordExists(for: entity, in: zoneID) let share = CKShare(recordZoneID: zoneID) - share.publicPermission = publicPermission - share[CKShare.SystemFieldKey.title] = entity.title as CKRecordValue? - return share + return configureShare(share, title: entity.title, publicPermission: publicPermission) } /// Records the share's CloudKit record name on the local entity so future @@ -202,6 +216,73 @@ final class ShareController { return share } + private func fetchZoneWideShareIfPresent(zoneName: String) async throws -> CKShare? { + do { + return try await fetchExistingShare( + recordName: Self.zoneWideShareRecordName, + zoneName: zoneName + ) + } catch let error as CKError where isMissingShare(error) { + return nil + } + } + + private func isMissingShare(_ error: CKError) -> Bool { + error.code == .unknownItem || error.code == .zoneNotFound + } + + private func configureShare( + _ share: CKShare, + title: String?, + publicPermission: CKShare.ParticipantPermission + ) -> CKShare { + share.publicPermission = publicPermission + share[CKShare.SystemFieldKey.title] = title as CKRecordValue? + return share + } + + private func saveShareForLink(_ share: CKShare, for gameID: UUID) async throws -> CKShare { + let savedRecord = try await container.privateCloudDatabase.save(share) + guard let savedShare = savedRecord as? CKShare else { + throw ShareError.invalidShareRecord + } + try persistShareName(savedShare.recordID.recordName, for: gameID) + return savedShare + } + + private func shareURL(from share: CKShare) throws -> URL { + guard let url = share.url else { + throw ShareError.missingShareURL + } + return url + } + + private func recoverShareLinkAfterSaveConflict( + _ error: CKError, + for gameID: UUID + ) async throws -> CKShare { + let ctx = persistence.viewContext + let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") + request.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) + request.fetchLimit = 1 + guard let entity = try ctx.fetch(request).first else { + throw ShareError.gameNotFound + } + + let share: CKShare + if let serverShare = (error as NSError).userInfo[CKRecordChangedErrorServerRecordKey] as? CKShare { + share = serverShare + } else { + share = try await fetchExistingShare( + recordName: Self.zoneWideShareRecordName, + zoneName: entity.ckZoneName ?? "game-\(gameID.uuidString)" + ) + } + + configureShare(share, title: entity.title, publicPermission: .readWrite) + return try await saveShareForLink(share, for: gameID) + } + /// CloudKit requires the initial records covered by a new share to already /// exist on the server or be saved with the share. `CKShareTransferRepresentation` /// only returns the share, so save the root game record before handing the