crossmate

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

commit 7e8e9313fa55f2fc6d1e4d662c387af78e9fcb1f
parent 23499bca33023cbee34d7e0dae0ae335fe6a8026
Author: Michael Camilleri <[email protected]>
Date:   Wed,  1 Jul 2026 19:49:05 +0900

Remove the raw CloudKit record editor

RecordEditorView could browse, edit, and delete any record in the
.private or .shared scope, gated only by a user-flippable debugMode
AppStorage flag (four taps on the About footer) rather than `#if DEBUG`.
Because it targeted sharedCloudDatabase, it turned every inbound
trust-boundary gap into a one-tap, no-code exploit against other users'
zones.

This commit removes it outright rather than gating it. It deletes
RecordEditorView, its SettingsView navigation link, and the five
now-orphaned ...ForEdit helpers in CloudDiagnostics that existed only to
back it (fetch/save/list-zones/query/delete). No read-only support tool
is retained.

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

Diffstat:
MCrossmate.xcodeproj/project.pbxproj | 4----
MCrossmate/Sync/CloudDiagnostics.swift | 87-------------------------------------------------------------------------------
DCrossmate/Views/Settings/RecordEditorView.swift | 390-------------------------------------------------------------------------------
MCrossmate/Views/Settings/SettingsView.swift | 3---
4 files changed, 0 insertions(+), 484 deletions(-)

diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj @@ -57,7 +57,6 @@ 328309D8CC72CCB5623FB2A1 /* EngagementCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67CFF96D54D2DE9C44EB120A /* EngagementCoordinatorTests.swift */; }; 350722635E9A17324148CACC /* PuzzleCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DC7784917397BCD6B8D679D /* PuzzleCatalog.swift */; }; 351CB23C537BAB61863D95F6 /* PuzzleNotificationText.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE7CEB8980A9664BAAA5D196 /* PuzzleNotificationText.swift */; }; - 35777D908A7D062730A18EF9 /* RecordEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3EF436B410916399336AC106 /* RecordEditorView.swift */; }; 35D97436772257DAD3936ECB /* TipStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D4A76B233E16B7C5A248EB7 /* TipStore.swift */; }; 36E2AAF1EE1314E13477EE85 /* NicknameDirectory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3111803C8FFFB0C839217482 /* NicknameDirectory.swift */; }; 38C913D00ED762BD9E355A2D /* KeychainHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33878A29B09A6154C7A63C82 /* KeychainHelper.swift */; }; @@ -299,7 +298,6 @@ 33DE10D2A5AFBAC73469BD33 /* PushPayloadCipher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushPayloadCipher.swift; sourceTree = "<group>"; }; 3413F8755236FC0578AF8109 /* NYTBrowseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTBrowseView.swift; sourceTree = "<group>"; }; 362E3A93102B6C9AECD4133A /* PuzzleSessionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PuzzleSessionTests.swift; sourceTree = "<group>"; }; - 3EF436B410916399336AC106 /* RecordEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordEditorView.swift; sourceTree = "<group>"; }; 3FDE73AD7C543B29C8E493F8 /* KeyboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardView.swift; sourceTree = "<group>"; }; 3FFD574AC2D0A910053E2A73 /* ReplayLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReplayLoader.swift; sourceTree = "<group>"; }; 400B3C87248F3FCA3F76400B /* EngagementHostEnvironment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EngagementHostEnvironment.swift; sourceTree = "<group>"; }; @@ -537,7 +535,6 @@ children = ( 74C8886A66F0877858A67D62 /* AboutView.swift */, BCACEED6A9235EC6221F4F66 /* DiagnosticsView.swift */, - 3EF436B410916399336AC106 /* RecordEditorView.swift */, 7C6AB016CA4E2FC69A0E6A4F /* SettingsView.swift */, A9DC6394CB0F5B85C083FAC7 /* TipsArchive.swift */, ); @@ -1165,7 +1162,6 @@ 9AD8936D94FD676B23DFBB77 /* RecentChanges.swift in Sources */, D94FF5DFB9412D2DC24F6574 /* RecordApplier.swift in Sources */, D13ECFAE05DB508577D2FF66 /* RecordBuilder.swift in Sources */, - 35777D908A7D062730A18EF9 /* RecordEditorView.swift in Sources */, CF0CA17ABE211DAE4DD35AFD /* RecordSerializer.swift in Sources */, E1FBC33E3348547D4DF946C4 /* ReplayControls.swift in Sources */, C9864C9940C9DAAD0A788094 /* ReplayLoader.swift in Sources */, diff --git a/Crossmate/Sync/CloudDiagnostics.swift b/Crossmate/Sync/CloudDiagnostics.swift @@ -116,93 +116,6 @@ extension SyncEngine { } } - /// 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 - } - - /// 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/Settings/RecordEditorView.swift b/Crossmate/Views/Settings/RecordEditorView.swift @@ -1,390 +0,0 @@ -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 - - @State private var browseRecordType: String = "Ping" - @State private var browsedZones: [CKRecordZone.ID] = [] - @State private var browsedRecords: [CKRecord] = [] - - 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) - } - - 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() - 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 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? - 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/Settings/SettingsView.swift b/Crossmate/Views/Settings/SettingsView.swift @@ -91,9 +91,6 @@ struct SettingsView: View { NavigationLink("Diagnostics Log") { DiagnosticsView() } - NavigationLink("Record Editor") { - RecordEditorView() - } Button("Reset Database", role: .destructive) { showResetConfirmation = true