commit a256e447d2e519bce849f36752e5183fd202c0be
parent da4140cf18b644af269f1a71dc379317e929cc04
Author: Michael Camilleri <[email protected]>
Date: Tue, 28 Apr 2026 09:11:20 +0900
Fix share link creation
This commit saves the owning Game record to the per-game CloudKit zone before
returning a zone-wide CKShare to ShareLink. CloudKit requires the initial
shared records to already exist on the server, and returning an unsaved share
before the Game record had uploaded could make link creation fail with
'Couldn't Add People'.
It also clears a stale local share record name if a previous share attempt
persisted the name but CloudKit never saved the share, allowing the next share
attempt to recreate it cleanly.
Co-Authored-By: Codex GPT 5.5 <[email protected]>
Diffstat:
3 files changed, 111 insertions(+), 31 deletions(-)
diff --git a/Crossmate/Sync/RecordSerializer.swift b/Crossmate/Sync/RecordSerializer.swift
@@ -119,6 +119,38 @@ enum RecordSerializer {
return record
}
+ static func gameRecord(
+ from entity: GameEntity,
+ recordID: CKRecord.ID,
+ includePuzzleSource: Bool
+ ) -> CKRecord? {
+ guard entity.ckRecordName != nil else { return nil }
+ let record: CKRecord
+ if let fields = entity.ckSystemFields,
+ let restored = decodeRecord(from: fields) {
+ record = restored
+ } else {
+ record = CKRecord(recordType: "Game", recordID: recordID)
+ }
+ populateGameRecord(record, from: entity, includePuzzleSource: includePuzzleSource)
+ return record
+ }
+
+ static func populateGameRecord(
+ _ record: CKRecord,
+ from entity: GameEntity,
+ includePuzzleSource: Bool
+ ) {
+ record["title"] = entity.title as CKRecordValue?
+ record["completedAt"] = entity.completedAt as CKRecordValue?
+ guard includePuzzleSource, let source = entity.puzzleSource else { return }
+ let url = FileManager.default.temporaryDirectory
+ .appendingPathComponent(UUID().uuidString)
+ .appendingPathExtension("xd")
+ try? source.write(to: url, atomically: true, encoding: .utf8)
+ record["puzzleSource"] = CKAsset(fileURL: url)
+ }
+
static func playerRecord(
gameID: UUID,
authorID: String,
diff --git a/Crossmate/Sync/ShareController.swift b/Crossmate/Sync/ShareController.swift
@@ -15,6 +15,7 @@ final class ShareController {
case gameNotFound
case invalidShareRecord
case notAnOwner
+ case invalidGameRecord
}
init(container: CKContainer, persistence: PersistenceController, syncEngine: SyncEngine) {
@@ -42,10 +43,15 @@ final class ShareController {
}
if let existingName = entity.ckShareRecordName {
- return try await fetchExistingShare(
- recordName: existingName,
- zoneName: entity.ckZoneName ?? "game-\(gameID.uuidString)"
- )
+ do {
+ return try await fetchExistingShare(
+ recordName: existingName,
+ zoneName: entity.ckZoneName ?? "game-\(gameID.uuidString)"
+ )
+ } catch let error as CKError where error.code == .unknownItem {
+ entity.ckShareRecordName = nil
+ try ctx.save()
+ }
}
let zoneName = entity.ckZoneName ?? "game-\(gameID.uuidString)"
@@ -64,6 +70,8 @@ final class ShareController {
self.container.privateCloudDatabase.add(op)
}
+ try await ensureGameRecordExists(for: entity, in: zoneID)
+
let share = CKShare(recordZoneID: zoneID)
share.publicPermission = .none
return (share, container)
@@ -117,4 +125,66 @@ final class ShareController {
}
return (share, container)
}
+
+ /// 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
+ /// zone-wide share to the system UI.
+ private func ensureGameRecordExists(
+ for entity: GameEntity,
+ in zoneID: CKRecordZone.ID
+ ) async throws {
+ guard let recordName = entity.ckRecordName else {
+ throw ShareError.invalidGameRecord
+ }
+ let recordID = CKRecord.ID(recordName: recordName, zoneID: zoneID)
+ let record: CKRecord
+ let includePuzzleSource: Bool
+
+ do {
+ record = try await container.privateCloudDatabase.record(for: recordID)
+ includePuzzleSource = record["puzzleSource"] == nil
+ } catch let error as CKError where error.code == .unknownItem {
+ guard let newRecord = RecordSerializer.gameRecord(
+ from: entity,
+ recordID: recordID,
+ includePuzzleSource: true
+ ) else {
+ throw ShareError.invalidGameRecord
+ }
+ record = newRecord
+ includePuzzleSource = true
+ }
+
+ RecordSerializer.populateGameRecord(
+ record,
+ from: entity,
+ includePuzzleSource: includePuzzleSource
+ )
+ let saved = try await saveRecord(record)
+ entity.ckSystemFields = RecordSerializer.encodeSystemFields(of: saved)
+ entity.lastSyncedAt = Date()
+ if entity.ckZoneName == nil {
+ entity.ckZoneName = zoneID.zoneName
+ }
+ try persistence.viewContext.save()
+ }
+
+ private func saveRecord(_ record: CKRecord) async throws -> CKRecord {
+ try await withCheckedThrowingContinuation { (cont: CheckedContinuation<CKRecord, Error>) in
+ let op = CKModifyRecordsOperation(recordsToSave: [record], recordIDsToDelete: nil)
+ op.savePolicy = .changedKeys
+ op.isAtomic = true
+ op.qualityOfService = .userInitiated
+ op.modifyRecordsResultBlock = { result in
+ switch result {
+ case .success:
+ cont.resume(returning: record)
+ case .failure(let error):
+ cont.resume(throwing: error)
+ }
+ }
+ self.container.privateCloudDatabase.add(op)
+ }
+ }
}
diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift
@@ -352,7 +352,11 @@ actor SyncEngine {
req.predicate = NSPredicate(format: "ckRecordName == %@", name)
req.fetchLimit = 1
guard let entity = try? ctx.fetch(req).first else { return nil }
- return Self.gameRecord(from: entity, recordID: recordID)
+ return RecordSerializer.gameRecord(
+ from: entity,
+ recordID: recordID,
+ includePuzzleSource: entity.ckSystemFields == nil
+ )
} else if name.hasPrefix("move-") {
let req = NSFetchRequest<MoveEntity>(entityName: "MoveEntity")
req.predicate = NSPredicate(format: "ckRecordName == %@", name)
@@ -419,32 +423,6 @@ actor SyncEngine {
}
}
- private static nonisolated func gameRecord(
- from entity: GameEntity,
- recordID: CKRecord.ID
- ) -> CKRecord? {
- guard let ckName = entity.ckRecordName else { return nil }
- let record: CKRecord
- if let fields = entity.ckSystemFields,
- let restored = RecordSerializer.decodeRecord(from: fields) {
- record = restored
- } else {
- record = CKRecord(recordType: "Game", recordID: recordID)
- }
- record["title"] = entity.title as CKRecordValue?
- record["completedAt"] = entity.completedAt as CKRecordValue?
- if let source = entity.puzzleSource, entity.ckSystemFields == nil {
- let url = FileManager.default.temporaryDirectory
- .appendingPathComponent(ckName)
- .appendingPathExtension("xd")
- if !FileManager.default.fileExists(atPath: url.path) {
- try? source.write(to: url, atomically: true, encoding: .utf8)
- }
- record["puzzleSource"] = CKAsset(fileURL: url)
- }
- return record
- }
-
// MARK: - Incoming record application
private nonisolated func applyMoveRecord(