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:
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