crossmate

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

commit d7967b2dabaae6be1fe23c5e7690df5671cbebf6
parent 773ab3b53caa322092b3ecfc4258d285c40d9375
Author: Michael Camilleri <[email protected]>
Date:   Mon, 25 May 2026 16:43:46 +0900

Browse zones and records from Record Editor

Record Editor was fetch-by-exact-name only — you had to already know (scope,
zone, owner, recordName) before it could do anything. On Production builds the
usual fallback (CloudKit Console 'Data' tab) isn't available, so the device is
the only vantage point.

This commit adds three helpers to CloudDiagnostics: listZonesForEdit enumerates the
current scope's zones, queryRecordsForEdit runs a single-page CKQuery
against a zone with NSPredicate(value: true) and sorts the results
client-side by modificationDate descending (CloudKit's server-side sort on
system fields is unreliable), and deleteRecordForEdit removes a record
and runs a follow-up fetchChanges so CKSyncEngine's tracked state
reconciles. The view picks them up in two new sections:

- 'Browse zones' lists everything allRecordZones() returns. Tapping a zone
  populates the existing zoneName/ownerName fields.

- 'Browse records' runs the query for a configurable record type (defaulting to
  Ping) and shows recordName, kind and modification date per row. Tapping a row
  flows into the existing fetch() path; swipe-left is a destructive delete that
  also evicts the record from the editor if it was loaded.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

Diffstat:
MCrossmate/Sync/CloudDiagnostics.swift | 59+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MCrossmate/Views/RecordEditorView.swift | 142+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 201 insertions(+), 0 deletions(-)

diff --git a/Crossmate/Sync/CloudDiagnostics.swift b/Crossmate/Sync/CloudDiagnostics.swift @@ -144,6 +144,65 @@ extension SyncEngine { return saved } + /// Lists every zone in the given scope's database for the record editor. + /// Used to populate the zone picker without forcing the user to remember + /// raw zone names — important on Production builds where there is no + /// Console access. + func listZonesForEdit(scope: CKDatabase.Scope) async throws -> [CKRecordZone.ID] { + let database = scope == .shared + ? container.sharedCloudDatabase + : container.privateCloudDatabase + let zones = try await database.allRecordZones() + return zones + .map(\.zoneID) + .sorted { lhs, rhs in + if lhs.zoneName == rhs.zoneName { return lhs.ownerName < rhs.ownerName } + return lhs.zoneName < rhs.zoneName + } + } + + /// Enumerates records of a given type in a single zone, capped at + /// `limit`. Client-side sort by `modificationDate` descending — newest + /// first matches how the dedupe/overlap machinery thinks about + /// time-ordered records. A single page is enough for ad-hoc debugging; + /// raise `limit` if a future caller needs deeper history. + func queryRecordsForEdit( + scope: CKDatabase.Scope, + zoneID: CKRecordZone.ID, + recordType: CKRecord.RecordType, + limit: Int = 100 + ) async throws -> [CKRecord] { + let database = scope == .shared + ? container.sharedCloudDatabase + : container.privateCloudDatabase + let query = CKQuery(recordType: recordType, predicate: NSPredicate(value: true)) + let result = try await database.records( + matching: query, + inZoneWith: zoneID, + resultsLimit: limit + ) + let records = result.matchResults.compactMap { _, recordResult in + try? recordResult.get() + } + return records.sorted { + ($0.modificationDate ?? .distantPast) > ($1.modificationDate ?? .distantPast) + } + } + + /// Deletes a record by ID via the in-app record editor. Matches the + /// editor's bypass-CKSyncEngine pattern; callers that maintain local + /// state for the record are responsible for reconciling separately. + func deleteRecordForEdit( + scope: CKDatabase.Scope, + recordID: CKRecord.ID + ) async throws { + let database = scope == .shared + ? container.sharedCloudDatabase + : container.privateCloudDatabase + _ = try await database.deleteRecord(withID: recordID) + try? await fetchChanges(source: "record-editor-delete") + } + nonisolated func describe(_ error: Error) -> String { let nsError = error as NSError return "ERROR domain=\(nsError.domain) code=\(nsError.code) \(nsError.localizedDescription)" diff --git a/Crossmate/Views/RecordEditorView.swift b/Crossmate/Views/RecordEditorView.swift @@ -23,6 +23,10 @@ struct RecordEditorView: View { @State private var status: String = "" @State private var isWorking: Bool = false + @State private var browseRecordType: String = "Ping" + @State private var browsedZones: [CKRecordZone.ID] = [] + @State private var browsedRecords: [CKRecord] = [] + var body: some View { Form { Section("Lookup") { @@ -48,6 +52,63 @@ struct RecordEditorView: View { .disabled(isWorking || zoneName.isEmpty || recordName.isEmpty) } + Section("Browse zones") { + Button { + Task { await listZones() } + } label: { + Text("List zones in \(scope == .shared ? "shared" : "private") database") + } + .disabled(isWorking) + + if !browsedZones.isEmpty { + ForEach(browsedZones, id: \.self) { zoneID in + Button { + zoneName = zoneID.zoneName + ownerName = zoneID.ownerName == CKCurrentUserDefaultName + ? "" + : zoneID.ownerName + browsedRecords = [] + } label: { + VStack(alignment: .leading, spacing: 2) { + Text(zoneID.zoneName) + .font(.caption.monospaced()) + .foregroundStyle(.primary) + Text("owner: \(zoneID.ownerName)") + .font(.caption2.monospaced()) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } + } + + Section("Browse records") { + plainField("Record type (e.g. Ping)", text: $browseRecordType) + Button { + Task { await listRecords() } + } label: { + Text("List records in zone") + } + .disabled(isWorking || zoneName.isEmpty || browseRecordType.isEmpty) + + if !browsedRecords.isEmpty { + ForEach(browsedRecords, id: \.recordID) { record in + BrowsedRecordRow(record: record) { + recordName = record.recordID.recordName + Task { await fetch() } + } + .swipeActions(edge: .trailing) { + Button(role: .destructive) { + Task { await deleteBrowsedRecord(record) } + } label: { + Label("Delete", systemImage: "trash") + } + } + } + } + } + if let record { Section("Fields") { let keys = record.allKeys().sorted() @@ -187,12 +248,93 @@ struct RecordEditorView: View { } } + private func listZones() async { + guard let syncEngine else { return } + isWorking = true + defer { isWorking = false } + status = "Listing zones…" + do { + browsedZones = try await syncEngine.listZonesForEdit(scope: scope) + status = "Found \(browsedZones.count) zone(s)." + } catch { + browsedZones = [] + status = describe(error) + } + } + + private func listRecords() async { + guard let syncEngine else { return } + isWorking = true + defer { isWorking = false } + status = "Listing records…" + let owner = ownerName.isEmpty ? CKCurrentUserDefaultName : ownerName + let zoneID = CKRecordZone.ID(zoneName: zoneName, ownerName: owner) + do { + browsedRecords = try await syncEngine.queryRecordsForEdit( + scope: scope, + zoneID: zoneID, + recordType: browseRecordType + ) + status = "Found \(browsedRecords.count) \(browseRecordType) record(s)." + } catch { + browsedRecords = [] + status = describe(error) + } + } + + private func deleteBrowsedRecord(_ record: CKRecord) async { + guard let syncEngine else { return } + isWorking = true + defer { isWorking = false } + status = "Deleting \(record.recordID.recordName)…" + do { + try await syncEngine.deleteRecordForEdit(scope: scope, recordID: record.recordID) + browsedRecords.removeAll { $0.recordID == record.recordID } + if self.record?.recordID == record.recordID { + self.record = nil + stringEdits = [:] + } + status = "Deleted \(record.recordID.recordName)." + } 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 BrowsedRecordRow: View { + let record: CKRecord + let onTap: () -> Void + + var body: some View { + Button(action: onTap) { + VStack(alignment: .leading, spacing: 2) { + Text(record.recordID.recordName) + .font(.caption.monospaced()) + .foregroundStyle(.primary) + .lineLimit(2) + HStack(spacing: 8) { + if let kind = record["kind"] as? String { + Text("kind=\(kind)") + .font(.caption2.monospaced()) + .foregroundStyle(.secondary) + } + if let mod = record.modificationDate { + Text(ISO8601DateFormatter().string(from: mod)) + .font(.caption2.monospaced()) + .foregroundStyle(.secondary) + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } +} + private struct FieldRow: View { let key: String let value: CKRecordValue?