commit e3b9b5effc7c5ab89b32ada395f0fbb2e23855ff
parent 16bbcad048c21630c238862693219dc5d73f2103
Author: Michael Camilleri <[email protected]>
Date: Thu, 7 May 2026 04:31:14 +0900
Propagate ckShareRecordName to other owner-devices
Prior to this commit, ckShareRecordName was a local-only field set on the
GameEntity of the device that ran createShareLink and never serialized into the
Game CKRecord. As a result, an owner playing the same shared game on a second
device (iPad created the share, iPhone has the game synced) saw isShared
evaluate to false in GameStore since ckShareRecordName was nil locally and
databaseScope was 0 (the zone still lives in the owner's private database).
This suppressed the share badge, the PlayerRoster passed to GridView, and
therefore author tinting and the players panel.
This commit adds shareRecordName to the Game CKRecord so the marker round
trips between owner devices: RecordSerializer.populateGameRecord now writes
entity.ckShareRecordName, applyGameRecord reads it back conservatively
(missing-field reads do not clobber a locally-set value), and
ShareController.persistShareName is async so it can call
syncEngine.enqueueGame after the local save and force a Game record push.
The Game record type needs a shareRecordName String field added manually in
the iCloud Dashboard before the field can be written.
Backfilling games whose share was created before this change required CloudKit
Console edits to the owner's private database, which are not available from
non-developer Apple IDs. To work around this, a generic record editor has been
added under Settings → Debugging → CloudKit Record. It fetches by (scope, zone,
owner, record name), displays every field with its type, lets the user edit
String fields in place or add a new String field, and saves through CKDatabase.
After the save, the engine re-runs fetchChanges so any locally-tracked entity
picks up the new server change tag rather than going stale. Two new methods on
SyncEngine, fetchRecordForEdit and saveRecordForEdit, keep the CKContainer
reference inside the actor.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
Diffstat:
6 files changed, 302 insertions(+), 3 deletions(-)
diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj
@@ -75,6 +75,7 @@
CF0CA17ABE211DAE4DD35AFD /* RecordSerializer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0A7348E1283E7CD2486E2A /* RecordSerializer.swift */; };
CFCA3C2C3CF6D88AE844D7AD /* CellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8E50E7BA98C88B4CAB39DC1 /* CellView.swift */; };
D219A9ACC7C1FB305DA6A4CE /* NYTLoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07C57DEE9E0EFA684D8BD00B /* NYTLoginView.swift */; };
+ D5150033DB80810F93BE0B5F /* RecordEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E30C592ECAF9B51BC7F1D297 /* RecordEditorView.swift */; };
D58980B92C99122C368D4216 /* GameStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93EE5BA78566EDED68D846AB /* GameStore.swift */; };
D74E9307FC03801137BE2083 /* PresencePublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 19D51F4A1CAEB849A09EC2C1 /* PresencePublisher.swift */; };
DB74ED1E2DFEBEC951E10C8E /* GamePlayerColorStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 666155B0C17A8CED11C45A80 /* GamePlayerColorStore.swift */; };
@@ -179,6 +180,7 @@
D9BB7D9759D27F7BA6734FDE /* GridThumbnailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GridThumbnailView.swift; sourceTree = "<group>"; };
DB55FC337CF72C650373210A /* PlayerColor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerColor.swift; sourceTree = "<group>"; };
DB851649DE78AAAC5A928C52 /* Square.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Square.swift; sourceTree = "<group>"; };
+ E30C592ECAF9B51BC7F1D297 /* RecordEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordEditorView.swift; sourceTree = "<group>"; };
E7AFD37B03A1C2E23E5766E6 /* PuzzleSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PuzzleSource.swift; sourceTree = "<group>"; };
E9BD3F7EAFD344D8E10E8C3B /* ClueList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClueList.swift; sourceTree = "<group>"; };
EAC61E2582D94B1E6EC67136 /* XDFileType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XDFileType.swift; sourceTree = "<group>"; };
@@ -324,6 +326,7 @@
1F2BE43E18B1CC6AAD27DC6D /* NYTBrowseView.swift */,
07C57DEE9E0EFA684D8BD00B /* NYTLoginView.swift */,
AFBE9E1A5C72FF3918F54CFA /* PuzzleView.swift */,
+ E30C592ECAF9B51BC7F1D297 /* RecordEditorView.swift */,
74FEFF257CDDD3EF0E77CBF7 /* SettingsView.swift */,
B23A692318044351247606DF /* SuccessPanel.swift */,
);
@@ -559,6 +562,7 @@
40256E08EE741F4C414B842B /* PuzzleNotificationText.swift in Sources */,
E91FB8101E1927CA567DE825 /* PuzzleSource.swift in Sources */,
2F43F24C98D7FF00CA486753 /* PuzzleView.swift in Sources */,
+ D5150033DB80810F93BE0B5F /* RecordEditorView.swift in Sources */,
CF0CA17ABE211DAE4DD35AFD /* RecordSerializer.swift in Sources */,
54464FDFB8C71B0D3B4B61A2 /* SettingsView.swift in Sources */,
BCB9A4D5E06EE5006186465D /* ShareController.swift in Sources */,
diff --git a/Crossmate/Sync/RecordSerializer.swift b/Crossmate/Sync/RecordSerializer.swift
@@ -148,6 +148,9 @@ enum RecordSerializer {
) {
record["title"] = entity.title as CKRecordValue?
record["completedAt"] = entity.completedAt as CKRecordValue?
+ // Owner-side share marker. Propagated so other owner-devices can flip
+ // their `isShared` flag without reading the zone's CKShare directly.
+ record["shareRecordName"] = entity.ckShareRecordName as CKRecordValue?
guard includePuzzleSource, let source = entity.puzzleSource else { return }
let url = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString)
@@ -371,6 +374,13 @@ enum RecordSerializer {
entity.title = record["title"] as? String ?? entity.title
entity.completedAt = record["completedAt"] as? Date
entity.databaseScope = databaseScope
+ // Owner-side share marker — set on the device that created the share
+ // and round-tripped via the Game record so other owner-devices learn
+ // the game is shared. On participant devices `databaseScope == 1`
+ // already implies shared, but keeping the field in sync is harmless.
+ if let shareRecordName = record["shareRecordName"] as? String {
+ entity.ckShareRecordName = shareRecordName
+ }
// Persist the zone identity so outbound moves use the right zone ID.
entity.ckZoneName = record.recordID.zoneID.zoneName
diff --git a/Crossmate/Sync/ShareController.swift b/Crossmate/Sync/ShareController.swift
@@ -173,8 +173,11 @@ final class ShareController {
}
/// Records the share's CloudKit record name on the local entity so future
- /// invocations of `prepareShare` fetch the existing share. Idempotent.
- func persistShareName(_ recordName: String, for gameID: UUID) throws {
+ /// invocations of `prepareShare` fetch the existing share. Also enqueues a
+ /// Game record push so other owner-devices receive the share marker via
+ /// `RecordSerializer.applyGameRecord` and flip their `isShared` flag.
+ /// Idempotent.
+ func persistShareName(_ recordName: String, for gameID: UUID) async throws {
let ctx = persistence.viewContext
let request = NSFetchRequest<GameEntity>(entityName: "GameEntity")
request.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
@@ -183,6 +186,9 @@ final class ShareController {
guard entity.ckShareRecordName != recordName else { return }
entity.ckShareRecordName = recordName
try ctx.save()
+ if let ckRecordName = entity.ckRecordName {
+ await syncEngine.enqueueGame(ckRecordName: ckRecordName)
+ }
onShareSaved?(gameID)
}
@@ -260,7 +266,7 @@ final class ShareController {
guard let savedShare = savedRecord as? CKShare else {
throw ShareError.invalidShareRecord
}
- try persistShareName(savedShare.recordID.recordName, for: gameID)
+ try await persistShareName(savedShare.recordID.recordName, for: gameID)
return savedShare
}
diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift
@@ -435,6 +435,34 @@ actor SyncEngine {
return results
}
+ /// Fetches a single record by ID for the in-app record editor. Bypasses
+ /// CKSyncEngine's tracked changes — caller is responsible for triggering a
+ /// reconciling fetch if the record corresponds to a tracked local entity.
+ func fetchRecordForEdit(
+ scope: CKDatabase.Scope,
+ recordID: CKRecord.ID
+ ) async throws -> CKRecord {
+ let database = scope == .shared
+ ? container.sharedCloudDatabase
+ : container.privateCloudDatabase
+ return try await database.record(for: recordID)
+ }
+
+ /// Saves a record edited in the in-app record editor and runs a follow-up
+ /// `fetchChanges` so any locally-tracked entity picks up the new server
+ /// change tag via CKSyncEngine rather than going stale.
+ func saveRecordForEdit(
+ scope: CKDatabase.Scope,
+ record: CKRecord
+ ) async throws -> CKRecord {
+ let database = scope == .shared
+ ? container.sharedCloudDatabase
+ : container.privateCloudDatabase
+ let saved = try await database.save(record)
+ try? await fetchChanges(source: "record-editor")
+ return saved
+ }
+
/// Clears the saved state for both engines so the next `start()` creates
/// fresh instances. Pending records already in CloudKit are unaffected.
func resetSyncState() async {
diff --git a/Crossmate/Views/RecordEditorView.swift b/Crossmate/Views/RecordEditorView.swift
@@ -0,0 +1,248 @@
+import CloudKit
+import SwiftUI
+
+/// In-app substitute for CloudKit Console for accounts that lack developer
+/// console access. Fetches a record by `(scope, zone, owner, recordName)`,
+/// displays its fields, allows editing String fields and adding new String
+/// fields, and saves back via the CKDatabase API. Non-String fields are shown
+/// read-only — extend the type dispatch below if a future migration needs to
+/// edit numbers, dates, etc.
+struct RecordEditorView: View {
+ @Environment(\.syncEngine) private var syncEngine
+
+ @State private var scope: CKDatabase.Scope = .private
+ @State private var zoneName: String = ""
+ @State private var ownerName: String = ""
+ @State private var recordName: String = ""
+
+ @State private var record: CKRecord?
+ @State private var stringEdits: [String: String] = [:]
+ @State private var newFieldName: String = ""
+ @State private var newFieldValue: String = ""
+
+ @State private var status: String = ""
+ @State private var isWorking: Bool = false
+
+ var body: some View {
+ Form {
+ Section("Lookup") {
+ Picker("Scope", selection: $scope) {
+ Text("Private").tag(CKDatabase.Scope.private)
+ Text("Shared").tag(CKDatabase.Scope.shared)
+ }
+ .pickerStyle(.segmented)
+ plainField("Zone name", text: $zoneName)
+ plainField("Owner (blank = self)", text: $ownerName)
+ plainField("Record name", text: $recordName)
+ Button {
+ Task { await fetch() }
+ } label: {
+ HStack {
+ Text("Fetch")
+ if isWorking {
+ Spacer()
+ ProgressView()
+ }
+ }
+ }
+ .disabled(isWorking || zoneName.isEmpty || recordName.isEmpty)
+ }
+
+ if let record {
+ Section("Fields") {
+ let keys = record.allKeys().sorted()
+ if keys.isEmpty {
+ Text("(no fields)").foregroundStyle(.secondary)
+ } else {
+ ForEach(keys, id: \.self) { key in
+ FieldRow(
+ key: key,
+ value: record[key],
+ stringBinding: stringBinding(for: key, in: record)
+ )
+ }
+ }
+ }
+
+ Section("Add string field") {
+ plainField("Name", text: $newFieldName)
+ plainField("Value", text: $newFieldValue)
+ }
+
+ Section("Metadata") {
+ metaRow("Type", record.recordType)
+ metaRow("Zone", record.recordID.zoneID.zoneName)
+ metaRow("Owner", record.recordID.zoneID.ownerName)
+ if let mod = record.modificationDate {
+ metaRow("Modified", ISO8601DateFormatter().string(from: mod))
+ }
+ if let tag = record.recordChangeTag {
+ metaRow("ChangeTag", tag)
+ }
+ }
+
+ Section {
+ Button("Save changes") {
+ Task { await save() }
+ }
+ .disabled(isWorking || !hasPendingEdits)
+ }
+ }
+
+ if !status.isEmpty {
+ Section("Status") {
+ Text(status)
+ .font(.body.monospaced())
+ .textSelection(.enabled)
+ }
+ }
+ }
+ .navigationTitle("Record Editor")
+ .navigationBarTitleDisplayMode(.inline)
+ }
+
+ // MARK: - Subviews
+
+ @ViewBuilder
+ private func plainField(_ placeholder: String, text: Binding<String>) -> some View {
+ TextField(placeholder, text: text)
+ .textInputAutocapitalization(.never)
+ .autocorrectionDisabled()
+ .font(.body.monospaced())
+ }
+
+ @ViewBuilder
+ private func metaRow(_ title: String, _ value: String) -> some View {
+ VStack(alignment: .leading, spacing: 2) {
+ Text(title).font(.caption).foregroundStyle(.secondary)
+ Text(value).font(.caption.monospaced()).textSelection(.enabled)
+ }
+ }
+
+ // MARK: - Bindings / state
+
+ private var hasPendingEdits: Bool {
+ !stringEdits.isEmpty || !newFieldName.isEmpty
+ }
+
+ private func stringBinding(for key: String, in record: CKRecord) -> Binding<String>? {
+ guard record[key] is String else { return nil }
+ return Binding(
+ get: { stringEdits[key] ?? (record[key] as? String) ?? "" },
+ set: { stringEdits[key] = $0 }
+ )
+ }
+
+ // MARK: - Actions
+
+ private func fetch() async {
+ guard let syncEngine else { return }
+ isWorking = true
+ defer { isWorking = false }
+ status = "Fetching…"
+ do {
+ let owner = ownerName.isEmpty ? CKCurrentUserDefaultName : ownerName
+ let zoneID = CKRecordZone.ID(zoneName: zoneName, ownerName: owner)
+ let recordID = CKRecord.ID(recordName: recordName, zoneID: zoneID)
+ let fetched = try await syncEngine.fetchRecordForEdit(
+ scope: scope,
+ recordID: recordID
+ )
+ record = fetched
+ stringEdits = [:]
+ newFieldName = ""
+ newFieldValue = ""
+ status = "Fetched."
+ } catch {
+ record = nil
+ status = describe(error)
+ }
+ }
+
+ private func save() async {
+ guard let syncEngine, let record else { return }
+ isWorking = true
+ defer { isWorking = false }
+ status = "Saving…"
+
+ for (key, value) in stringEdits {
+ record[key] = value.isEmpty ? nil : (value as CKRecordValue)
+ }
+ let trimmedNewName = newFieldName.trimmingCharacters(in: .whitespaces)
+ if !trimmedNewName.isEmpty {
+ record[trimmedNewName] = newFieldValue.isEmpty
+ ? nil
+ : (newFieldValue as CKRecordValue)
+ }
+
+ do {
+ let saved = try await syncEngine.saveRecordForEdit(scope: scope, record: record)
+ self.record = saved
+ stringEdits = [:]
+ newFieldName = ""
+ newFieldValue = ""
+ status = "Saved."
+ } catch {
+ status = describe(error)
+ }
+ }
+
+ private func describe(_ error: Error) -> String {
+ let nsError = error as NSError
+ return "Error: domain=\(nsError.domain) code=\(nsError.code) — \(nsError.localizedDescription)"
+ }
+}
+
+private struct FieldRow: View {
+ let key: String
+ let value: CKRecordValue?
+ let stringBinding: Binding<String>?
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 4) {
+ HStack {
+ Text(key).font(.caption.bold())
+ Spacer()
+ Text(typeName).font(.caption2).foregroundStyle(.secondary)
+ }
+ if let stringBinding {
+ TextField("", text: stringBinding, axis: .vertical)
+ .font(.body.monospaced())
+ .textInputAutocapitalization(.never)
+ .autocorrectionDisabled()
+ } else {
+ Text(displayValue)
+ .font(.body.monospaced())
+ .textSelection(.enabled)
+ .foregroundStyle(.secondary)
+ }
+ }
+ }
+
+ private var typeName: String {
+ guard let value else { return "nil" }
+ if value is String { return "String" }
+ if value is Date { return "Date" }
+ if value is Data { return "Data" }
+ if value is CKAsset { return "Asset" }
+ if value is CKRecord.Reference { return "Reference" }
+ if value is [Any] { return "Array" }
+ if value is NSNumber { return "Number" }
+ return String(describing: type(of: value))
+ }
+
+ private var displayValue: String {
+ guard let value else { return "(nil)" }
+ if let s = value as? String { return s }
+ if let d = value as? Date { return ISO8601DateFormatter().string(from: d) }
+ if let data = value as? Data { return "\(data.count) bytes" }
+ if let asset = value as? CKAsset {
+ return "asset(\(asset.fileURL?.lastPathComponent ?? "?"))"
+ }
+ if let ref = value as? CKRecord.Reference {
+ return "ref(\(ref.recordID.recordName))"
+ }
+ if let arr = value as? [Any] { return "[\(arr.count) items]" }
+ return "\(value)"
+ }
+}
diff --git a/Crossmate/Views/SettingsView.swift b/Crossmate/Views/SettingsView.swift
@@ -30,6 +30,9 @@ struct SettingsView: View {
NavigationLink("Share Diagnostics") {
ShareDiagnosticsView()
}
+ NavigationLink("CloudKit Record") {
+ RecordEditorView()
+ }
Button("Reset Database", role: .destructive) {
showResetConfirmation = true