From 5cc7f0eebc2c622a1bcc7fc173ecfef36a7d4033 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Thu, 23 Apr 2026 17:20:42 +0700 Subject: [PATCH 1/7] feat: add per-connection local-only flag to exclude from iCloud sync --- CHANGELOG.md | 1 + .../Export/ConnectionExportService.swift | 4 +- .../Export/ForeignApp/DBeaverImporter.swift | 3 +- .../Export/ForeignApp/SequelAceImporter.swift | 3 +- .../Export/ForeignApp/TablePlusImporter.swift | 3 +- TablePro/Core/Storage/ConnectionStorage.swift | 29 +++++++++++--- TablePro/Core/Sync/SyncCoordinator.swift | 7 +++- .../Models/Connection/ConnectionExport.swift | 1 + .../Connection/DatabaseConnection.swift | 7 +++- .../Connection/ConnectionAdvancedView.swift | 10 +++++ .../ConnectionFormView+Helpers.swift | 2 + .../Views/Connection/ConnectionFormView.swift | 4 ++ .../Connection/WelcomeConnectionRow.swift | 7 ++++ .../Connection/WelcomeContextMenus.swift | 39 +++++++++++++++++++ 14 files changed, 108 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 96b3435d8..0a637b77c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Per-connection "Local only" option to exclude individual connections from iCloud sync - Filter operator picker shows SQL symbols alongside names for quick visual recognition - SQL autocomplete now suggests column names before a FROM clause is written, using all cached schema columns as fallback - Eager column cache warming after schema load for faster autocomplete diff --git a/TablePro/Core/Services/Export/ConnectionExportService.swift b/TablePro/Core/Services/Export/ConnectionExportService.swift index 57e5c9367..7c702faee 100644 --- a/TablePro/Core/Services/Export/ConnectionExportService.swift +++ b/TablePro/Core/Services/Export/ConnectionExportService.swift @@ -193,7 +193,8 @@ enum ConnectionExportService { aiPolicy: aiPolicy, additionalFields: additionalFields, redisDatabase: connection.redisDatabase, - startupCommands: connection.startupCommands + startupCommands: connection.startupCommands, + localOnly: connection.localOnly ? true : nil ) exportableConnections.append(exportable) @@ -679,6 +680,7 @@ enum ConnectionExportService { aiPolicy: exportable.aiPolicy.flatMap { AIConnectionPolicy(rawValue: $0) }, redisDatabase: exportable.redisDatabase, startupCommands: exportable.startupCommands, + localOnly: exportable.localOnly ?? false, additionalFields: exportable.additionalFields ) } diff --git a/TablePro/Core/Services/Export/ForeignApp/DBeaverImporter.swift b/TablePro/Core/Services/Export/ForeignApp/DBeaverImporter.swift index c01b07255..0c55c394d 100644 --- a/TablePro/Core/Services/Export/ForeignApp/DBeaverImporter.swift +++ b/TablePro/Core/Services/Export/ForeignApp/DBeaverImporter.swift @@ -179,7 +179,8 @@ struct DBeaverImporter: ForeignAppImporter { aiPolicy: nil, additionalFields: nil, redisDatabase: nil, - startupCommands: nil + startupCommands: nil, + localOnly: nil ) } diff --git a/TablePro/Core/Services/Export/ForeignApp/SequelAceImporter.swift b/TablePro/Core/Services/Export/ForeignApp/SequelAceImporter.swift index 1c456a9d3..a9b9321f3 100644 --- a/TablePro/Core/Services/Export/ForeignApp/SequelAceImporter.swift +++ b/TablePro/Core/Services/Export/ForeignApp/SequelAceImporter.swift @@ -186,7 +186,8 @@ struct SequelAceImporter: ForeignAppImporter { aiPolicy: nil, additionalFields: nil, redisDatabase: nil, - startupCommands: nil + startupCommands: nil, + localOnly: nil ) } diff --git a/TablePro/Core/Services/Export/ForeignApp/TablePlusImporter.swift b/TablePro/Core/Services/Export/ForeignApp/TablePlusImporter.swift index 1c806bc7b..a5d786a1e 100644 --- a/TablePro/Core/Services/Export/ForeignApp/TablePlusImporter.swift +++ b/TablePro/Core/Services/Export/ForeignApp/TablePlusImporter.swift @@ -168,7 +168,8 @@ struct TablePlusImporter: ForeignAppImporter { aiPolicy: nil, additionalFields: nil, redisDatabase: nil, - startupCommands: nil + startupCommands: nil, + localOnly: nil ) } diff --git a/TablePro/Core/Storage/ConnectionStorage.swift b/TablePro/Core/Storage/ConnectionStorage.swift index b0f0f25ef..c52c09199 100644 --- a/TablePro/Core/Storage/ConnectionStorage.swift +++ b/TablePro/Core/Storage/ConnectionStorage.swift @@ -112,7 +112,9 @@ final class ConnectionStorage { var connections = loadConnections() connections.append(connection) saveConnections(connections) - SyncChangeTracker.shared.markDirty(.connection, id: connection.id.uuidString) + if !connection.localOnly { + SyncChangeTracker.shared.markDirty(.connection, id: connection.id.uuidString) + } if let password = password, !password.isEmpty { savePassword(password, for: connection.id) @@ -125,7 +127,9 @@ final class ConnectionStorage { if let index = connections.firstIndex(where: { $0.id == connection.id }) { connections[index] = connection saveConnections(connections) - SyncChangeTracker.shared.markDirty(.connection, id: connection.id.uuidString) + if !connection.localOnly { + SyncChangeTracker.shared.markDirty(.connection, id: connection.id.uuidString) + } if let password = password { if password.isEmpty { @@ -139,7 +143,9 @@ final class ConnectionStorage { /// Delete a connection func deleteConnection(_ connection: DatabaseConnection) { - SyncChangeTracker.shared.markDeleted(.connection, id: connection.id.uuidString) + if !connection.localOnly { + SyncChangeTracker.shared.markDeleted(.connection, id: connection.id.uuidString) + } var connections = loadConnections() connections.removeAll { $0.id == connection.id } saveConnections(connections) @@ -157,7 +163,7 @@ final class ConnectionStorage { /// Batch-delete multiple connections and clean up their Keychain entries func deleteConnections(_ connectionsToDelete: [DatabaseConnection]) { - for conn in connectionsToDelete { + for conn in connectionsToDelete where !conn.localOnly { SyncChangeTracker.shared.markDeleted(.connection, id: conn.id.uuidString) } let idsToDelete = Set(connectionsToDelete.map(\.id)) @@ -202,6 +208,7 @@ final class ConnectionStorage { redisDatabase: connection.redisDatabase, startupCommands: connection.startupCommands, sortOrder: connection.sortOrder, + localOnly: connection.localOnly, additionalFields: connection.additionalFields.isEmpty ? nil : connection.additionalFields ) @@ -209,7 +216,9 @@ final class ConnectionStorage { var connections = loadConnections() connections.append(duplicate) saveConnections(connections) - SyncChangeTracker.shared.markDirty(.connection, id: duplicate.id.uuidString) + if !duplicate.localOnly { + SyncChangeTracker.shared.markDirty(.connection, id: duplicate.id.uuidString) + } // Copy all passwords from source to duplicate (skip DB password in prompt mode) if !connection.promptForPassword, let password = loadPassword(for: connection.id) { @@ -433,6 +442,9 @@ private struct StoredConnection: Codable { // Sort order for sync let sortOrder: Int + // Local-only (excluded from iCloud sync) + let localOnly: Bool + // TOTP configuration let totpMode: String let totpAlgorithm: String @@ -508,6 +520,9 @@ private struct StoredConnection: Codable { // Sort order self.sortOrder = connection.sortOrder + // Local-only + self.localOnly = connection.localOnly + // SSH tunnel mode (v2 format preserving jump hosts, profiles, etc.) self.sshTunnelModeJson = try? JSONEncoder().encode(connection.sshTunnelMode) @@ -529,6 +544,7 @@ private struct StoredConnection: Codable { case mssqlSchema, oracleServiceName, startupCommands, sortOrder case sshTunnelModeJson case additionalFields + case localOnly } func encode(to encoder: Encoder) throws { @@ -567,6 +583,7 @@ private struct StoredConnection: Codable { try container.encode(sortOrder, forKey: .sortOrder) try container.encodeIfPresent(sshTunnelModeJson, forKey: .sshTunnelModeJson) try container.encodeIfPresent(additionalFields, forKey: .additionalFields) + try container.encode(localOnly, forKey: .localOnly) } // Custom decoder to handle migration from old format @@ -631,6 +648,7 @@ private struct StoredConnection: Codable { sortOrder = try container.decodeIfPresent(Int.self, forKey: .sortOrder) ?? 0 sshTunnelModeJson = try container.decodeIfPresent(Data.self, forKey: .sshTunnelModeJson) additionalFields = try container.decodeIfPresent([String: String].self, forKey: .additionalFields) + localOnly = try container.decodeIfPresent(Bool.self, forKey: .localOnly) ?? false } func toConnection() -> DatabaseConnection { @@ -716,6 +734,7 @@ private struct StoredConnection: Codable { redisDatabase: redisDatabase, startupCommands: startupCommands, sortOrder: sortOrder, + localOnly: localOnly, additionalFields: mergedFields ) } diff --git a/TablePro/Core/Sync/SyncCoordinator.swift b/TablePro/Core/Sync/SyncCoordinator.swift index fe2cecafb..4921ab35b 100644 --- a/TablePro/Core/Sync/SyncCoordinator.swift +++ b/TablePro/Core/Sync/SyncCoordinator.swift @@ -240,7 +240,8 @@ final class SyncCoordinator { if !dirtyConnectionIds.isEmpty { let connections = ConnectionStorage.shared.loadConnections() for id in dirtyConnectionIds { - if let connection = connections.first(where: { $0.id.uuidString == id }) { + if let connection = connections.first(where: { $0.id.uuidString == id }), + !connection.localOnly { recordsToSave.append( SyncRecordMapper.toCKRecord(connection, in: zoneID) ) @@ -454,7 +455,9 @@ final class SyncCoordinator { conflictResolver.addConflict(conflict) return } - connections[index] = remoteConnection + var merged = remoteConnection + merged.localOnly = connections[index].localOnly + connections[index] = merged } else { connections.append(remoteConnection) } diff --git a/TablePro/Models/Connection/ConnectionExport.swift b/TablePro/Models/Connection/ConnectionExport.swift index 2376d66ac..0b112b43c 100644 --- a/TablePro/Models/Connection/ConnectionExport.swift +++ b/TablePro/Models/Connection/ConnectionExport.swift @@ -57,6 +57,7 @@ struct ExportableConnection: Codable { let additionalFields: [String: String]? let redisDatabase: Int? let startupCommands: String? + let localOnly: Bool? } // MARK: - SSH Config diff --git a/TablePro/Models/Connection/DatabaseConnection.swift b/TablePro/Models/Connection/DatabaseConnection.swift index 54843d7e0..4bb549d99 100644 --- a/TablePro/Models/Connection/DatabaseConnection.swift +++ b/TablePro/Models/Connection/DatabaseConnection.swift @@ -217,6 +217,7 @@ struct DatabaseConnection: Identifiable, Hashable { var redisDatabase: Int? var startupCommands: String? var sortOrder: Int + var localOnly: Bool = false var mongoAuthSource: String? { get { additionalFields["mongoAuthSource"]?.nilIfEmpty } @@ -301,6 +302,7 @@ struct DatabaseConnection: Identifiable, Hashable { oracleServiceName: String? = nil, startupCommands: String? = nil, sortOrder: Int = 0, + localOnly: Bool = false, additionalFields: [String: String]? = nil ) { self.id = id @@ -336,6 +338,7 @@ struct DatabaseConnection: Identifiable, Hashable { self.redisDatabase = redisDatabase self.startupCommands = startupCommands self.sortOrder = sortOrder + self.localOnly = localOnly if let additionalFields { self.additionalFields = additionalFields } else { @@ -371,7 +374,7 @@ extension DatabaseConnection: Codable { case id, name, host, port, database, username, type case sshConfig, sslConfig, color, tagId, groupId, sshProfileId case sshTunnelMode, safeModeLevel, aiPolicy, additionalFields - case redisDatabase, startupCommands, sortOrder + case redisDatabase, startupCommands, sortOrder, localOnly } init(from decoder: Decoder) throws { @@ -395,6 +398,7 @@ extension DatabaseConnection: Codable { redisDatabase = try container.decodeIfPresent(Int.self, forKey: .redisDatabase) startupCommands = try container.decodeIfPresent(String.self, forKey: .startupCommands) sortOrder = try container.decodeIfPresent(Int.self, forKey: .sortOrder) ?? 0 + localOnly = try container.decodeIfPresent(Bool.self, forKey: .localOnly) ?? false // Migrate from legacy fields if sshTunnelMode is not present if let tunnelMode = try container.decodeIfPresent(SSHTunnelMode.self, forKey: .sshTunnelMode) { @@ -434,6 +438,7 @@ extension DatabaseConnection: Codable { try container.encodeIfPresent(redisDatabase, forKey: .redisDatabase) try container.encodeIfPresent(startupCommands, forKey: .startupCommands) try container.encode(sortOrder, forKey: .sortOrder) + try container.encode(localOnly, forKey: .localOnly) } } diff --git a/TablePro/Views/Connection/ConnectionAdvancedView.swift b/TablePro/Views/Connection/ConnectionAdvancedView.swift index e5c2c15bd..6e48c6552 100644 --- a/TablePro/Views/Connection/ConnectionAdvancedView.swift +++ b/TablePro/Views/Connection/ConnectionAdvancedView.swift @@ -13,6 +13,7 @@ struct ConnectionAdvancedView: View { @Binding var startupCommands: String @Binding var preConnectScript: String @Binding var aiPolicy: AIConnectionPolicy? + @Binding var localOnly: Bool let databaseType: DatabaseType let additionalConnectionFields: [ConnectionField] @@ -83,6 +84,15 @@ struct ConnectionAdvancedView: View { } } } + + if AppSettingsManager.shared.sync.enabled { + Section(String(localized: "iCloud Sync")) { + Toggle(String(localized: "Local only"), isOn: $localOnly) + Text("This connection won't sync to other devices via iCloud.") + .font(.caption) + .foregroundStyle(.secondary) + } + } } .formStyle(.grouped) .scrollContentBackground(.hidden) diff --git a/TablePro/Views/Connection/ConnectionFormView+Helpers.swift b/TablePro/Views/Connection/ConnectionFormView+Helpers.swift index b5bda75e1..03e9c821e 100644 --- a/TablePro/Views/Connection/ConnectionFormView+Helpers.swift +++ b/TablePro/Views/Connection/ConnectionFormView+Helpers.swift @@ -119,6 +119,7 @@ extension ConnectionFormView { selectedGroupId = existing.groupId safeModeLevel = existing.safeModeLevel aiPolicy = existing.aiPolicy + localOnly = existing.localOnly // Load additional fields from connection additionalFieldValues = existing.additionalFields @@ -219,6 +220,7 @@ extension ConnectionFormView { redisDatabase: additionalFieldValues["redisDatabase"].map { Int($0) ?? 0 }, startupCommands: startupCommands.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : startupCommands, + localOnly: localOnly, additionalFields: finalAdditionalFields.isEmpty ? nil : finalAdditionalFields ) diff --git a/TablePro/Views/Connection/ConnectionFormView.swift b/TablePro/Views/Connection/ConnectionFormView.swift index 5c3e81217..e13e58fc6 100644 --- a/TablePro/Views/Connection/ConnectionFormView.swift +++ b/TablePro/Views/Connection/ConnectionFormView.swift @@ -106,6 +106,9 @@ struct ConnectionFormView: View { // Pre-connect script @State var preConnectScript: String = "" + // Local only (exclude from iCloud sync) + @State var localOnly: Bool = false + @State var isTesting: Bool = false @State var testSucceeded: Bool = false @@ -224,6 +227,7 @@ struct ConnectionFormView: View { startupCommands: $startupCommands, preConnectScript: $preConnectScript, aiPolicy: $aiPolicy, + localOnly: $localOnly, databaseType: type, additionalConnectionFields: additionalConnectionFields ) diff --git a/TablePro/Views/Connection/WelcomeConnectionRow.swift b/TablePro/Views/Connection/WelcomeConnectionRow.swift index f35f147b3..73ad61ef7 100644 --- a/TablePro/Views/Connection/WelcomeConnectionRow.swift +++ b/TablePro/Views/Connection/WelcomeConnectionRow.swift @@ -43,6 +43,13 @@ struct WelcomeConnectionRow: View { RoundedRectangle(cornerRadius: 4).fill( tag.color.color.opacity(0.15))) } + + if connection.localOnly { + Image(systemName: "icloud.slash") + .font(.system(size: 9)) + .foregroundStyle(.secondary) + .help(String(localized: "Local only — not synced to iCloud")) + } } Text(connectionSubtitle) diff --git a/TablePro/Views/Connection/WelcomeContextMenus.swift b/TablePro/Views/Connection/WelcomeContextMenus.swift index d88f3b85a..44eaee9d4 100644 --- a/TablePro/Views/Connection/WelcomeContextMenus.swift +++ b/TablePro/Views/Connection/WelcomeContextMenus.swift @@ -46,6 +46,27 @@ extension WelcomeWindowView { } } + if AppSettingsManager.shared.sync.enabled { + Divider() + + let allLocalOnly = vm.selectedConnections.allSatisfy(\.localOnly) + Button { + for conn in vm.selectedConnections { + var updated = conn + updated.localOnly = !allLocalOnly + ConnectionStorage.shared.updateConnection(updated) + } + NotificationCenter.default.post(name: .connectionUpdated, object: nil) + } label: { + Label( + allLocalOnly + ? String(localized: "Include in iCloud Sync") + : String(localized: "Exclude from iCloud Sync"), + systemImage: allLocalOnly ? "icloud" : "icloud.slash" + ) + } + } + Divider() Button(role: .destructive) { @@ -125,6 +146,24 @@ extension WelcomeWindowView { } } + if AppSettingsManager.shared.sync.enabled { + Divider() + + Button { + var updated = connection + updated.localOnly.toggle() + ConnectionStorage.shared.updateConnection(updated) + NotificationCenter.default.post(name: .connectionUpdated, object: nil) + } label: { + Label( + connection.localOnly + ? String(localized: "Include in iCloud Sync") + : String(localized: "Exclude from iCloud Sync"), + systemImage: connection.localOnly ? "icloud" : "icloud.slash" + ) + } + } + Divider() Button(role: .destructive) { From 9ac2cc0e43068a83a20db5f7de486ae85faab6a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Thu, 23 Apr 2026 17:29:41 +0700 Subject: [PATCH 2/7] fix: guard localOnly in saveConnection and markAllLocalDataDirty, add docs --- TablePro/Core/Sync/SyncCoordinator.swift | 2 +- .../Views/Connection/ConnectionFormView+Helpers.swift | 8 ++++++-- TablePro/Views/Connection/WelcomeConnectionRow.swift | 2 +- docs/features/icloud-sync.mdx | 9 +++++++++ 4 files changed, 17 insertions(+), 4 deletions(-) diff --git a/TablePro/Core/Sync/SyncCoordinator.swift b/TablePro/Core/Sync/SyncCoordinator.swift index 4921ab35b..ea96ef114 100644 --- a/TablePro/Core/Sync/SyncCoordinator.swift +++ b/TablePro/Core/Sync/SyncCoordinator.swift @@ -135,7 +135,7 @@ final class SyncCoordinator { /// Called when sync is first enabled to upload existing connections/groups/tags/settings. private func markAllLocalDataDirty() { let connections = ConnectionStorage.shared.loadConnections() - for connection in connections { + for connection in connections where !connection.localOnly { changeTracker.markDirty(.connection, id: connection.id.uuidString) } diff --git a/TablePro/Views/Connection/ConnectionFormView+Helpers.swift b/TablePro/Views/Connection/ConnectionFormView+Helpers.swift index 03e9c821e..34c0672b5 100644 --- a/TablePro/Views/Connection/ConnectionFormView+Helpers.swift +++ b/TablePro/Views/Connection/ConnectionFormView+Helpers.swift @@ -256,7 +256,9 @@ extension ConnectionFormView { if isNew { savedConnections.append(connectionToSave) storage.saveConnections(savedConnections) - SyncChangeTracker.shared.markDirty(.connection, id: connectionToSave.id.uuidString) + if !connectionToSave.localOnly { + SyncChangeTracker.shared.markDirty(.connection, id: connectionToSave.id.uuidString) + } NSApplication.shared.closeWindows(withId: "connection-form") NotificationCenter.default.post(name: .connectionUpdated, object: nil) connectToDatabase(connectionToSave) @@ -264,7 +266,9 @@ extension ConnectionFormView { if let index = savedConnections.firstIndex(where: { $0.id == connectionToSave.id }) { savedConnections[index] = connectionToSave storage.saveConnections(savedConnections) - SyncChangeTracker.shared.markDirty(.connection, id: connectionToSave.id.uuidString) + if !connectionToSave.localOnly { + SyncChangeTracker.shared.markDirty(.connection, id: connectionToSave.id.uuidString) + } } NSApplication.shared.closeWindows(withId: "connection-form") NotificationCenter.default.post(name: .connectionUpdated, object: nil) diff --git a/TablePro/Views/Connection/WelcomeConnectionRow.swift b/TablePro/Views/Connection/WelcomeConnectionRow.swift index 73ad61ef7..1fb036daa 100644 --- a/TablePro/Views/Connection/WelcomeConnectionRow.swift +++ b/TablePro/Views/Connection/WelcomeConnectionRow.swift @@ -48,7 +48,7 @@ struct WelcomeConnectionRow: View { Image(systemName: "icloud.slash") .font(.system(size: 9)) .foregroundStyle(.secondary) - .help(String(localized: "Local only — not synced to iCloud")) + .help(String(localized: "Local only - not synced to iCloud")) } } diff --git a/docs/features/icloud-sync.mdx b/docs/features/icloud-sync.mdx index d78cca508..301a6c655 100644 --- a/docs/features/icloud-sync.mdx +++ b/docs/features/icloud-sync.mdx @@ -40,6 +40,15 @@ Open **Settings** (`Cmd+,`) > **Account**, toggle iCloud Sync on, choose which c Each data type has its own toggle: Connections, Groups & Tags, SSH Profiles, and App Settings. +## Excluding individual connections + +Some connections (e.g., localhost, dev databases) don't make sense on other devices. Mark them as **Local only** to keep them off iCloud: + +- **Connection form**: open the **Advanced** tab and toggle **Local only** +- **Context menu**: right-click a connection and choose **Exclude from iCloud Sync** + +Local-only connections show an `icloud.slash` icon in the sidebar. The flag is preserved when duplicating or exporting connections. + TablePro auto-syncs on app launch, when you switch back to it, and 2 seconds after you modify synced data. When the same record changes on two Macs, you choose to keep the local or remote version. Conflicts are per-record, not per-category. From 3ba544bddd539f0ae172a07ad939873ff659d541 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 23 Apr 2026 18:08:10 +0700 Subject: [PATCH 3/7] fix: skip re-adding records with local tombstones during iCloud pull When a record is deleted locally, its tombstone is pushed to iCloud. If the pull in the same sync cycle returns stale data for that record (CloudKit eventual consistency), the record was re-added because tombstones were not checked. Now all applyRemote methods (connection, group, tag, SSH profile) skip records that have pending local tombstones. --- TablePro/Core/Sync/SyncCoordinator.swift | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/TablePro/Core/Sync/SyncCoordinator.swift b/TablePro/Core/Sync/SyncCoordinator.swift index ea96ef114..4c68cf8ae 100644 --- a/TablePro/Core/Sync/SyncCoordinator.swift +++ b/TablePro/Core/Sync/SyncCoordinator.swift @@ -433,9 +433,13 @@ final class SyncCoordinator { private func applyRemoteConnection(_ record: CKRecord) { guard let remoteConnection = SyncRecordMapper.toConnection(record) else { return } + let tombstoneIds = Set(metadataStorage.tombstones(for: .connection).map(\.id)) + if tombstoneIds.contains(remoteConnection.id.uuidString) { + return + } + var connections = ConnectionStorage.shared.loadConnections() if let index = connections.firstIndex(where: { $0.id == remoteConnection.id }) { - // Check for conflict: if local is also dirty, queue conflict if changeTracker.dirtyRecords(for: .connection).contains(remoteConnection.id.uuidString) { let localRecord = SyncRecordMapper.toCKRecord( connections[index], @@ -467,6 +471,9 @@ final class SyncCoordinator { private func applyRemoteGroup(_ record: CKRecord) { guard let remoteGroup = SyncRecordMapper.toGroup(record) else { return } + let tombstoneIds = Set(metadataStorage.tombstones(for: .group).map(\.id)) + if tombstoneIds.contains(remoteGroup.id.uuidString) { return } + var groups = GroupStorage.shared.loadGroups() if let index = groups.firstIndex(where: { $0.id == remoteGroup.id }) { groups[index] = remoteGroup @@ -479,6 +486,9 @@ final class SyncCoordinator { private func applyRemoteTag(_ record: CKRecord) { guard let remoteTag = SyncRecordMapper.toTag(record) else { return } + let tombstoneIds = Set(metadataStorage.tombstones(for: .tag).map(\.id)) + if tombstoneIds.contains(remoteTag.id.uuidString) { return } + var tags = TagStorage.shared.loadTags() if let index = tags.firstIndex(where: { $0.id == remoteTag.id }) { tags[index] = remoteTag @@ -491,6 +501,9 @@ final class SyncCoordinator { private func applyRemoteSSHProfile(_ record: CKRecord) { guard let remoteProfile = SyncRecordMapper.toSSHProfile(record) else { return } + let tombstoneIds = Set(metadataStorage.tombstones(for: .sshProfile).map(\.id)) + if tombstoneIds.contains(remoteProfile.id.uuidString) { return } + var profiles = SSHProfileStorage.shared.loadProfiles() if let index = profiles.firstIndex(where: { $0.id == remoteProfile.id }) { profiles[index] = remoteProfile From 3a5ca14820f908f970729f0e8ee86bc68501a52b Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 23 Apr 2026 18:52:43 +0700 Subject: [PATCH 4/7] refactor: clean up sync system - remove hacks, fix delete ordering, batch deletions - ConnectionStorage: markDeleted before saveConnections (matches Group/Tag pattern) - WelcomeViewModel: remove recentlyDeletedIds hack, debug logging; add rebuildTree after delete - SyncCoordinator: remove pushedRecordNames echo hack, batch remote deletions (one load/save per type), auto-prune tombstones older than 30 days, only post connectionUpdated when actual changes applied, applyRemoteConnection returns Bool --- TablePro/Core/Sync/SyncCoordinator.swift | 122 ++++++++++----------- TablePro/ViewModels/WelcomeViewModel.swift | 1 + 2 files changed, 58 insertions(+), 65 deletions(-) diff --git a/TablePro/Core/Sync/SyncCoordinator.swift b/TablePro/Core/Sync/SyncCoordinator.swift index 4c68cf8ae..27be222e1 100644 --- a/TablePro/Core/Sync/SyncCoordinator.swift +++ b/TablePro/Core/Sync/SyncCoordinator.swift @@ -91,6 +91,7 @@ final class SyncCoordinator { lastSyncDate = Date() metadataStorage.lastSyncDate = lastSyncDate syncStatus = .idle + metadataStorage.pruneTombstones(olderThan: 30) Self.logger.info("Sync completed successfully") } catch { @@ -231,8 +232,6 @@ final class SyncCoordinator { var recordsToSave: [CKRecord] = [] var recordIDsToDelete: [CKRecord.ID] = [] let zoneID = await engine.zoneID - let dirtyConnectionCount = changeTracker.dirtyRecords(for: .connection).count - Self.logger.info("performPush: syncConnections=\(settings.syncConnections), dirty connections=\(dirtyConnectionCount)") // Collect dirty connections if settings.syncConnections { @@ -249,8 +248,8 @@ final class SyncCoordinator { } } - // Collect deletion tombstones - for tombstone in metadataStorage.tombstones(for: .connection) { + let connectionTombstones = metadataStorage.tombstones(for: .connection) + for tombstone in connectionTombstones { recordIDsToDelete.append( SyncRecordMapper.recordID(type: .connection, id: tombstone.id, in: zoneID) ) @@ -288,7 +287,6 @@ final class SyncCoordinator { do { try await engine.push(records: recordsToSave, deletions: uniqueDeletions) - // Clear dirty flags only for types that were actually pushed if settings.syncConnections { changeTracker.clearAllDirty(.connection) } @@ -362,12 +360,6 @@ final class SyncCoordinator { } private func applyPullResult(_ result: PullResult) { - Self.logger.info("Pull fetched: \(result.changedRecords.count) changed, \(result.deletedRecordIDs.count) deleted") - - for record in result.changedRecords { - Self.logger.info("Pulled record: \(record.recordType)/\(record.recordID.recordName)") - } - if let newToken = result.newToken { metadataStorage.saveSyncToken(newToken) } @@ -385,23 +377,22 @@ final class SyncCoordinator { private func applyRemoteChanges(_ result: PullResult) { let settings = AppSettingsStorage.shared.loadSync() - // Invalidate caches before applying remote data to ensure fresh reads ConnectionStorage.shared.invalidateCache() - // Suppress change tracking during remote apply to avoid sync loops changeTracker.isSuppressed = true defer { changeTracker.isSuppressed = false } - var connectionsChanged = false + var actualConnectionChanges = false var groupsOrTagsChanged = false for record in result.changedRecords { switch record.recordType { case SyncRecordType.connection.rawValue where settings.syncConnections: - applyRemoteConnection(record) - connectionsChanged = true + if applyRemoteConnection(record) { + actualConnectionChanges = true + } case SyncRecordType.group.rawValue where settings.syncGroupsAndTags: applyRemoteGroup(record) groupsOrTagsChanged = true @@ -417,25 +408,64 @@ final class SyncCoordinator { } } + var connectionIdsToDelete: Set = [] + var groupIdsToDelete: Set = [] + var tagIdsToDelete: Set = [] + var sshProfileIdsToDelete: Set = [] + for recordID in result.deletedRecordIDs { - let recordName = recordID.recordName - if recordName.hasPrefix("Connection_") { connectionsChanged = true } - if recordName.hasPrefix("Group_") || recordName.hasPrefix("Tag_") { groupsOrTagsChanged = true } - applyRemoteDeletion(recordID) + let name = recordID.recordName + if name.hasPrefix("Connection_"), + let uuid = UUID(uuidString: String(name.dropFirst("Connection_".count))) { + connectionIdsToDelete.insert(uuid) + actualConnectionChanges = true + } else if name.hasPrefix("Group_"), + let uuid = UUID(uuidString: String(name.dropFirst("Group_".count))) { + groupIdsToDelete.insert(uuid) + groupsOrTagsChanged = true + } else if name.hasPrefix("Tag_"), + let uuid = UUID(uuidString: String(name.dropFirst("Tag_".count))) { + tagIdsToDelete.insert(uuid) + groupsOrTagsChanged = true + } else if name.hasPrefix("SSHProfile_"), + let uuid = UUID(uuidString: String(name.dropFirst("SSHProfile_".count))) { + sshProfileIdsToDelete.insert(uuid) + } } - // Notify UI so views refresh with pulled data - if connectionsChanged || groupsOrTagsChanged { + if !connectionIdsToDelete.isEmpty { + var connections = ConnectionStorage.shared.loadConnections() + connections.removeAll { connectionIdsToDelete.contains($0.id) } + ConnectionStorage.shared.saveConnections(connections) + } + if !groupIdsToDelete.isEmpty { + var groups = GroupStorage.shared.loadGroups() + groups.removeAll { groupIdsToDelete.contains($0.id) } + GroupStorage.shared.saveGroups(groups) + } + if !tagIdsToDelete.isEmpty { + var tags = TagStorage.shared.loadTags() + tags.removeAll { tagIdsToDelete.contains($0.id) } + TagStorage.shared.saveTags(tags) + } + if !sshProfileIdsToDelete.isEmpty { + var profiles = SSHProfileStorage.shared.loadProfiles() + profiles.removeAll { sshProfileIdsToDelete.contains($0.id) } + SSHProfileStorage.shared.saveProfilesWithoutSync(profiles) + } + + if actualConnectionChanges || groupsOrTagsChanged { NotificationCenter.default.post(name: .connectionUpdated, object: nil) } } - private func applyRemoteConnection(_ record: CKRecord) { - guard let remoteConnection = SyncRecordMapper.toConnection(record) else { return } + @discardableResult + private func applyRemoteConnection(_ record: CKRecord) -> Bool { + guard let remoteConnection = SyncRecordMapper.toConnection(record) else { return false } let tombstoneIds = Set(metadataStorage.tombstones(for: .connection).map(\.id)) if tombstoneIds.contains(remoteConnection.id.uuidString) { - return + return false } var connections = ConnectionStorage.shared.loadConnections() @@ -457,7 +487,7 @@ final class SyncCoordinator { serverModifiedAt: (record["modifiedAtLocal"] as? Date) ?? Date() ) conflictResolver.addConflict(conflict) - return + return false } var merged = remoteConnection merged.localOnly = connections[index].localOnly @@ -466,6 +496,7 @@ final class SyncCoordinator { connections.append(remoteConnection) } ConnectionStorage.shared.saveConnections(connections) + return true } private func applyRemoteGroup(_ record: CKRecord) { @@ -520,45 +551,6 @@ final class SyncCoordinator { applySettingsData(data, for: category) } - private func applyRemoteDeletion(_ recordID: CKRecord.ID) { - let recordName = recordID.recordName - - if recordName.hasPrefix("Connection_") { - let uuidString = String(recordName.dropFirst("Connection_".count)) - if let uuid = UUID(uuidString: uuidString) { - var connections = ConnectionStorage.shared.loadConnections() - connections.removeAll { $0.id == uuid } - ConnectionStorage.shared.saveConnections(connections) - } - } - if recordName.hasPrefix("Group_") { - let uuidString = String(recordName.dropFirst("Group_".count)) - if let uuid = UUID(uuidString: uuidString) { - var groups = GroupStorage.shared.loadGroups() - groups.removeAll { $0.id == uuid } - GroupStorage.shared.saveGroups(groups) - } - } - - if recordName.hasPrefix("Tag_") { - let uuidString = String(recordName.dropFirst("Tag_".count)) - if let uuid = UUID(uuidString: uuidString) { - var tags = TagStorage.shared.loadTags() - tags.removeAll { $0.id == uuid } - TagStorage.shared.saveTags(tags) - } - } - - if recordName.hasPrefix("SSHProfile_") { - let uuidString = String(recordName.dropFirst("SSHProfile_".count)) - if let uuid = UUID(uuidString: uuidString) { - var profiles = SSHProfileStorage.shared.loadProfiles() - profiles.removeAll { $0.id == uuid } - SSHProfileStorage.shared.saveProfilesWithoutSync(profiles) - } - } - } - // MARK: - Observers private func observeAccountChanges() { diff --git a/TablePro/ViewModels/WelcomeViewModel.swift b/TablePro/ViewModels/WelcomeViewModel.swift index 0f9dfa08e..3533d2ddf 100644 --- a/TablePro/ViewModels/WelcomeViewModel.swift +++ b/TablePro/ViewModels/WelcomeViewModel.swift @@ -310,6 +310,7 @@ final class WelcomeViewModel { connections.removeAll { idsToDelete.contains($0.id) } selectedConnectionIds.subtract(idsToDelete) connectionsToDelete = [] + rebuildTree() } // MARK: - Groups From 07f3ba1d0c19123f1649ef236e87fa02fe41a717 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 23 Apr 2026 18:58:52 +0700 Subject: [PATCH 5/7] refactor: load tombstones once per sync cycle instead of per-record --- TablePro/Core/Sync/SyncCoordinator.swift | 46 +++++++++++++----------- 1 file changed, 25 insertions(+), 21 deletions(-) diff --git a/TablePro/Core/Sync/SyncCoordinator.swift b/TablePro/Core/Sync/SyncCoordinator.swift index 27be222e1..73df4e08b 100644 --- a/TablePro/Core/Sync/SyncCoordinator.swift +++ b/TablePro/Core/Sync/SyncCoordinator.swift @@ -387,20 +387,27 @@ final class SyncCoordinator { var actualConnectionChanges = false var groupsOrTagsChanged = false + let connectionTombstoneIds = Set(metadataStorage.tombstones(for: .connection).map(\.id)) + let groupTombstoneIds = Set(metadataStorage.tombstones(for: .group).map(\.id)) + let tagTombstoneIds = Set(metadataStorage.tombstones(for: .tag).map(\.id)) + let sshTombstoneIds = Set(metadataStorage.tombstones(for: .sshProfile).map(\.id)) + for record in result.changedRecords { switch record.recordType { case SyncRecordType.connection.rawValue where settings.syncConnections: - if applyRemoteConnection(record) { + if applyRemoteConnection(record, tombstoneIds: connectionTombstoneIds) { actualConnectionChanges = true } case SyncRecordType.group.rawValue where settings.syncGroupsAndTags: - applyRemoteGroup(record) - groupsOrTagsChanged = true + if applyRemoteGroup(record, tombstoneIds: groupTombstoneIds) { + groupsOrTagsChanged = true + } case SyncRecordType.tag.rawValue where settings.syncGroupsAndTags: - applyRemoteTag(record) - groupsOrTagsChanged = true + if applyRemoteTag(record, tombstoneIds: tagTombstoneIds) { + groupsOrTagsChanged = true + } case SyncRecordType.sshProfile.rawValue where settings.syncSSHProfiles: - applyRemoteSSHProfile(record) + applyRemoteSSHProfile(record, tombstoneIds: sshTombstoneIds) case SyncRecordType.settings.rawValue where settings.syncSettings: applyRemoteSettings(record) default: @@ -460,10 +467,9 @@ final class SyncCoordinator { } @discardableResult - private func applyRemoteConnection(_ record: CKRecord) -> Bool { + private func applyRemoteConnection(_ record: CKRecord, tombstoneIds: Set) -> Bool { guard let remoteConnection = SyncRecordMapper.toConnection(record) else { return false } - let tombstoneIds = Set(metadataStorage.tombstones(for: .connection).map(\.id)) if tombstoneIds.contains(remoteConnection.id.uuidString) { return false } @@ -499,11 +505,10 @@ final class SyncCoordinator { return true } - private func applyRemoteGroup(_ record: CKRecord) { - guard let remoteGroup = SyncRecordMapper.toGroup(record) else { return } - - let tombstoneIds = Set(metadataStorage.tombstones(for: .group).map(\.id)) - if tombstoneIds.contains(remoteGroup.id.uuidString) { return } + @discardableResult + private func applyRemoteGroup(_ record: CKRecord, tombstoneIds: Set) -> Bool { + guard let remoteGroup = SyncRecordMapper.toGroup(record) else { return false } + if tombstoneIds.contains(remoteGroup.id.uuidString) { return false } var groups = GroupStorage.shared.loadGroups() if let index = groups.firstIndex(where: { $0.id == remoteGroup.id }) { @@ -512,13 +517,13 @@ final class SyncCoordinator { groups.append(remoteGroup) } GroupStorage.shared.saveGroups(groups) + return true } - private func applyRemoteTag(_ record: CKRecord) { - guard let remoteTag = SyncRecordMapper.toTag(record) else { return } - - let tombstoneIds = Set(metadataStorage.tombstones(for: .tag).map(\.id)) - if tombstoneIds.contains(remoteTag.id.uuidString) { return } + @discardableResult + private func applyRemoteTag(_ record: CKRecord, tombstoneIds: Set) -> Bool { + guard let remoteTag = SyncRecordMapper.toTag(record) else { return false } + if tombstoneIds.contains(remoteTag.id.uuidString) { return false } var tags = TagStorage.shared.loadTags() if let index = tags.firstIndex(where: { $0.id == remoteTag.id }) { @@ -527,12 +532,11 @@ final class SyncCoordinator { tags.append(remoteTag) } TagStorage.shared.saveTags(tags) + return true } - private func applyRemoteSSHProfile(_ record: CKRecord) { + private func applyRemoteSSHProfile(_ record: CKRecord, tombstoneIds: Set) { guard let remoteProfile = SyncRecordMapper.toSSHProfile(record) else { return } - - let tombstoneIds = Set(metadataStorage.tombstones(for: .sshProfile).map(\.id)) if tombstoneIds.contains(remoteProfile.id.uuidString) { return } var profiles = SSHProfileStorage.shared.loadProfiles() From ea89a92bee2f633f758ae42f82fb5e5be95729d9 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 23 Apr 2026 19:07:38 +0700 Subject: [PATCH 6/7] docs: add etcd database page and navigation entry --- docs/databases/etcd.mdx | 79 +++++++++++++++++++++++++++++++++++++++++ docs/docs.json | 1 + 2 files changed, 80 insertions(+) create mode 100644 docs/databases/etcd.mdx diff --git a/docs/databases/etcd.mdx b/docs/databases/etcd.mdx new file mode 100644 index 000000000..4b0bdbdf9 --- /dev/null +++ b/docs/databases/etcd.mdx @@ -0,0 +1,79 @@ +--- +title: etcd +description: Connect to etcd key-value stores with TablePro +--- + +# etcd Connections + +TablePro supports etcd, a distributed key-value store used for shared configuration and service discovery. TablePro connects via the gRPC/HTTP API. + +## Install Plugin + +The etcd driver is available as a downloadable plugin. When you select etcd in the connection form, TablePro will prompt you to install it automatically. You can also install it manually: + +1. Open **Settings** > **Plugins** > **Browse** +2. Find **etcd Driver** and click **Install** +3. The plugin downloads and loads immediately - no restart needed + +## Quick Setup + + + + Click **New Connection** from the Welcome screen or **File** > **New Connection** + + + Choose **etcd** from the database type selector + + + Fill in the host (default `127.0.0.1`) and port (default `2379`) + + + Click **Test Connection**, then **Create** + + + +## Connection Settings + +### Required Fields + +| Field | Description | +|-------|-------------| +| **Name** | Connection identifier | +| **Host** | etcd server hostname or IP (default `127.0.0.1`) | +| **Port** | etcd client port (default `2379`) | + +### Optional Fields + +| Field | Description | +|-------|-------------| +| **Username** | For authentication (if enabled) | +| **Password** | For authentication (if enabled) | + +## Connection URL + +``` +etcd://127.0.0.1:2379 +etcds://127.0.0.1:2379 +``` + +Use `etcds://` for TLS-encrypted connections. + +## SSH Tunnel + +etcd connections support [SSH tunneling](/databases/ssh-tunneling) for accessing remote clusters through a bastion host. + +## Features + +- Browse keys with prefix-based navigation +- View and edit key values +- Key metadata (version, create/mod revision, lease) +- Put, delete, and watch keys +- Range queries with prefix and limit + +## Troubleshooting + +**Connection refused**: Verify etcd is running and the client port (default 2379) is accessible. Check firewall rules. + +**Authentication failed**: If etcd has authentication enabled, provide the correct username and password. Check that the user has the required roles. + +**TLS errors**: Use `etcds://` scheme and ensure your certificates are valid. For self-signed certificates, you may need to add them to your macOS Keychain. diff --git a/docs/docs.json b/docs/docs.json index ae725359f..8f4f9620b 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -51,6 +51,7 @@ "databases/cloudflare-d1", "databases/libsql", "databases/bigquery", + "databases/etcd", "databases/ssh-tunneling" ] }, From cf563e9b9ac43042395fa95b4ffea2490b2bf10c Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 23 Apr 2026 19:11:41 +0700 Subject: [PATCH 7/7] chore: remove internal dev docs, update localizations --- TablePro/Resources/Localizable.xcstrings | 18 + docs/development/plugin-settings-tracking.md | 127 ------- docs/development/security-audit-2026-04-14.md | 323 ------------------ 3 files changed, 18 insertions(+), 450 deletions(-) delete mode 100644 docs/development/plugin-settings-tracking.md delete mode 100644 docs/development/security-audit-2026-04-14.md diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 59ade9e4d..9753dca2a 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -15778,6 +15778,9 @@ } } } + }, + "Exclude from iCloud Sync" : { + }, "Execute" : { "localizations" : { @@ -20799,6 +20802,9 @@ } } } + }, + "Include in iCloud Sync" : { + }, "Include NULL values" : { "extractionState" : "stale", @@ -23624,6 +23630,12 @@ } } } + }, + "Local only" : { + + }, + "Local only - not synced to iCloud" : { + }, "localhost" : { "localizations" : { @@ -24133,6 +24145,9 @@ } } } + }, + "MCP" : { + }, "MCP Access Request" : { "localizations" : { @@ -40136,6 +40151,9 @@ } } } + }, + "This connection won't sync to other devices via iCloud." : { + }, "This database has no %@ yet." : { "localizations" : { diff --git a/docs/development/plugin-settings-tracking.md b/docs/development/plugin-settings-tracking.md deleted file mode 100644 index 00605e3aa..000000000 --- a/docs/development/plugin-settings-tracking.md +++ /dev/null @@ -1,127 +0,0 @@ -# Plugin Settings — Progress Tracking - -Analysis date: 2026-03-11 (updated). - -## Current State Summary - -The plugin settings system has two dimensions: - -1. **Plugin management** (Settings > Plugins) — enable/disable, install/uninstall. Fully working. -2. **Per-plugin configuration** — plugins conforming to `SettablePlugin` protocol get automatic persistence via `loadSettings()`/`saveSettings()` and expose `settingsView()` via the `SettablePluginDiscoverable` type-erased witness. All 5 export plugins and 1 import plugin use this pattern. Driver plugins have zero configurable settings but can adopt `SettablePlugin` when needed. - -Plugin enable/disable state lives in `UserDefaults["com.TablePro.disabledPlugins"]` (namespaced, with legacy key migration). - ---- - -## Plugin Management UI - -| Feature | Status | File | Notes | -| -------------------------------------------------------- | ------ | --------------------------------------------------- | ----------------------------------------------------------- | -| Installed plugins list with toggle | Done | `Views/Settings/Plugins/InstalledPluginsView.swift` | One row per `PluginEntry`, inline detail expansion | -| Enable/disable toggle (live) | Done | `Core/Plugins/PluginManager.swift:382` | Immediate capability register/unregister, no restart needed | -| Plugin detail (version, bundle ID, source, capabilities) | Done | `Views/Settings/Plugins/InstalledPluginsView.swift` | Shown on row expansion | -| Install from file (.tableplugin, .zip) | Done | `Views/Settings/Plugins/InstalledPluginsView.swift` | NSOpenPanel + drag-and-drop | -| Uninstall user plugins | Done | `Views/Settings/Plugins/InstalledPluginsView.swift` | Destructive button with AlertHelper.confirmDestructive | -| Restart-required banner | Done | `Views/Settings/Plugins/InstalledPluginsView.swift` | Orange dismissible banner after uninstall | -| Browse registry | Done | `Views/Settings/Plugins/BrowsePluginsView.swift` | Remote manifest from GitHub, search + category filter | -| Registry install with progress | Done | `Views/Settings/Plugins/RegistryPluginRow.swift` | Download + SHA-256 verification, multi-phase progress | -| Contextual install prompt (connection flow) | Done | `Views/Connection/PluginInstallModifier.swift` | Alert when opening DB type with missing driver | -| Code signature verification | Done | `Core/Plugins/PluginManager.swift:586` | Team ID `D7HJ5TFYCU` for user-installed plugins | - -## Per-Plugin Configuration - -### Export Plugins - -| Plugin | Options Model | Options View | Persistence | Status | -| ------ | ----------------------------------------------------------------------------------------------------------- | ----------------------- | ------------------------------------ | ------ | -| CSV | `CSVExportOptions` — delimiter, quoting, null handling, formula sanitize, line breaks, decimals, header row | `CSVExportOptionsView` | `PluginSettingsStorage` (persisted) | Done | -| XLSX | `XLSXExportOptions` — header row, null handling | `XLSXExportOptionsView` | `PluginSettingsStorage` (persisted) | Done | -| JSON | `JSONExportOptions` — pretty print, null values, preserve-as-strings | `JSONExportOptionsView` | `PluginSettingsStorage` (persisted) | Done | -| SQL | `SQLExportOptions` — gzip, batch size | `SQLExportOptionsView` | `PluginSettingsStorage` (persisted) | Done | -| MQL | `MQLExportOptions` | `MQLExportOptionsView` | `PluginSettingsStorage` (persisted) | Done | - -All export plugins conform to `SettablePlugin` which provides automatic `loadSettings()`/`saveSettings()` backed by `PluginSettingsStorage(pluginId:)`. Options are stored in `UserDefaults` keyed as `com.TablePro.plugin..settings`, encoded via `JSONEncoder`. - -### Import Plugins - -| Plugin | Options Model | Options View | Persistence | Status | -| ---------- | ---------------- | ---------------------- | ----------- | ------------------------------------------------ | -| SQL Import | `SQLImportOptions` (Codable struct) | `SQLImportOptionsView` | `PluginSettingsStorage` (persisted) | Done | - -### Driver Plugins - -All 9 driver plugins have zero per-plugin settings. They can adopt `SettablePlugin` when settings are needed. - ---- - -## Known Issues & Gaps - -### High Priority - -None — previously tracked high-priority issues have been resolved. - -### Medium Priority - -None — previously tracked medium-priority issues have been resolved. - -### Low Priority - -| Issue | Description | Impact | -| ---------------------------- | -------------------------------------------------------------------------------------------------------------- | --------------------------------------------- | -| _(none)_ | All previously tracked low-priority issues have been resolved | | - -### Resolved (since initial analysis) - -| Issue | Resolution | -| -------------------------------------------- | ------------------------------------------------------------------------------------------------------- | -| Export options not persisted | All 5 export plugins now use `PluginSettingsStorage` with `Codable` options models | -| `disabledPlugins` key not namespaced | Now uses `"com.TablePro.disabledPlugins"` with legacy key migration (`PluginManager.swift:15-16,50-58`) | -| 4 dead capability types | Removed — `PluginCapability` now only has 3 cases: `databaseDriver`, `exportFormat`, `importFormat` | -| `PluginInstallTracker.markInstalling()` unused | Now called in `BrowsePluginsView.swift:185` when download fraction reaches 1.0 | -| SQL import options not persisted | `SQLImportOptions` converted to `Codable` struct with `PluginSettingsStorage` persistence | -| `additionalConnectionFields` hardcoded | Connection form Advanced tab now dynamically renders fields from `DriverPlugin.additionalConnectionFields` with `ConnectionField.FieldType` support (text, secure, dropdown) | -| No driver plugin settings UI | `DriverPlugin.settingsView()` protocol method added with `nil` default; rendered in InstalledPluginsView | -| No settings protocol in SDK | `SettablePlugin` protocol added to `TableProPluginKit` with `loadSettings()`/`saveSettings()` and `SettablePluginDiscoverable` type-erased witness; all 6 plugins migrated | -| Hardcoded registry URL | `RegistryClient` now reads custom URL from UserDefaults with ETag invalidation on URL change | -| `needsRestart` not persisted | Backed by UserDefaults, cleared on next plugin load cycle | - ---- - -## Recommended Next Steps - -Steps 1-3 have been completed. All plugins with settings now use the `SettablePlugin` protocol. - -### Future — Driver plugin settings - -- When driver plugins need per-plugin configuration (timeout, SSL, query behavior), they can adopt `SettablePlugin` using the same pattern as export/import plugins - ---- - -## Key Files - -| Component | Path | -| ------------------------------ | ---------------------------------------------------------------- | -| Settings tab container | `TablePro/Views/Settings/PluginsSettingsView.swift` | -| Installed list + toggle | `TablePro/Views/Settings/Plugins/InstalledPluginsView.swift` | -| Browse registry | `TablePro/Views/Settings/Plugins/BrowsePluginsView.swift` | -| Registry row + install | `TablePro/Views/Settings/Plugins/RegistryPluginRow.swift` | -| Registry detail | `TablePro/Views/Settings/Plugins/RegistryPluginDetailView.swift` | -| PluginManager | `TablePro/Core/Plugins/PluginManager.swift` | -| Registry extension | `TablePro/Core/Plugins/Registry/PluginManager+Registry.swift` | -| Registry client | `TablePro/Core/Plugins/Registry/RegistryClient.swift` | -| Registry models | `TablePro/Core/Plugins/Registry/RegistryModels.swift` | -| Install tracker | `TablePro/Core/Plugins/Registry/PluginInstallTracker.swift` | -| Download count service | `TablePro/Core/Plugins/Registry/DownloadCountService.swift` | -| Plugin models | `TablePro/Core/Plugins/PluginModels.swift` | -| Plugin settings storage | `Plugins/TableProPluginKit/PluginSettingsStorage.swift` | -| SDK — settable protocol | `Plugins/TableProPluginKit/SettablePlugin.swift` | -| Connection install prompt | `TablePro/Views/Connection/PluginInstallModifier.swift` | -| SDK — base protocol | `Plugins/TableProPluginKit/TableProPlugin.swift` | -| SDK — driver protocol | `Plugins/TableProPluginKit/DriverPlugin.swift` | -| SDK — export protocol | `Plugins/TableProPluginKit/ExportFormatPlugin.swift` | -| SDK — import protocol | `Plugins/TableProPluginKit/ImportFormatPlugin.swift` | -| SDK — capabilities | `Plugins/TableProPluginKit/PluginCapability.swift` | -| SDK — connection fields | `Plugins/TableProPluginKit/ConnectionField.swift` | -| CSV export (representative) | `Plugins/CSVExportPlugin/CSVExportPlugin.swift` | -| SQL import plugin | `Plugins/SQLImportPlugin/SQLImportPlugin.swift` | -| Connection form (adv. fields) | `TablePro/Views/Connection/ConnectionFormView.swift` | diff --git a/docs/development/security-audit-2026-04-14.md b/docs/development/security-audit-2026-04-14.md deleted file mode 100644 index e2e7031e1..000000000 --- a/docs/development/security-audit-2026-04-14.md +++ /dev/null @@ -1,323 +0,0 @@ -# TablePro Security & Production Readiness Audit - -**Date:** 2026-04-14 -**Scope:** Full codebase — TablePro/, Plugins/, scripts/, entitlements, dependencies - ---- - -## Critical / High Severity - -### 1. [HIGH] Raw SQL injection via URL scheme without user confirmation - -- **Category:** SQL Injection -- **Files:** `TablePro/Core/Utilities/Connection/ConnectionURLParser.swift:476`, `TablePro/Views/Main/Extensions/MainContentCoordinator+URLFilter.swift:63` -- **Description:** A crafted URL such as `mysql://myconn/mydb/mytable?condition=1=1 OR DROP TABLE...` injects arbitrary SQL into WHERE clauses when clicked. The `condition`/`raw`/`query` URL parameter is passed directly as a raw SQL filter without any user confirmation dialog or sanitization. The `openQuery` deeplink path does show a confirmation dialog, but this filter path does not. -- **Impact:** An attacker who tricks a user into clicking a link can inject arbitrary SQL against a connected database. -- **Remediation:** Add user confirmation dialog for `condition`/`raw`/`query` URL parameters (matching the existing `openQuery` deeplink), or remove raw SQL passthrough from URL parsing entirely. -- [x] **Fixed** — Added `AlertHelper.confirmDestructive` gate in `AppDelegate+ConnectionHandler.swift` before posting `.applyURLFilter` with raw SQL condition. Matches existing `openQuery` deeplink pattern. - -### 2. [HIGH] OpenSSL 3.4.1 is outdated — 3.4.3 patches CVE-2025-9230 and CVE-2025-9231 - -- **Category:** Supply Chain / Dependency -- **Files:** `scripts/build-libpq.sh:39`, `scripts/build-hiredis.sh:38`, `scripts/build-libssh2.sh:38` -- **Description:** All three build scripts use `OPENSSL_VERSION="3.4.1"`. OpenSSL 3.4.3 fixes: - - **CVE-2025-9230**: Out-of-bounds read/write in RFC 3211 KEK Unwrap (CMS decryption), potentially enabling crashes or code execution. - - **CVE-2025-9231**: Timing side-channel in SM2 on 64-bit ARM — could leak private key material. - - **CVE-2025-9232**: OOB read in HTTP client `no_proxy` handling (Moderate). -- **Impact:** TablePro uses TLS in database connections (PostgreSQL, Redis, MariaDB over SSH). Real attack surface. -- **Remediation:** Update `OPENSSL_VERSION` to `3.4.3` in all three build scripts, update `OPENSSL_SHA256`, rebuild all affected libraries, update `Libs/checksums.sha256`, and upload new archives to `libs-v1` GitHub Release. -- [x] **Fixed** — Created shared `scripts/openssl-version.sh` (single source of truth) with OpenSSL 3.4.3 + SHA256. All 4 build scripts now source this file. **Note:** Libs must still be rebuilt, checksums regenerated, and archives uploaded to complete the update. - -### 3. [HIGH] App sandbox disabled + library validation disabled - -- **Category:** Entitlements -- **File:** `TablePro/TablePro.entitlements` -- **Description:** `com.apple.security.app-sandbox = false` and `com.apple.security.cs.disable-library-validation = true`. Necessary for the plugin system (loading third-party `.tableplugin` bundles) and arbitrary database connections, but means no OS-level containment. -- **Impact:** Any code placed in the plugin directory loads without sandbox restrictions. -- **Mitigation in place:** Plugin code signing verification via `SecStaticCodeCheckValidity` with team ID requirement for registry-installed plugins. -- **Remediation:** Accept as necessary trade-off. Ensure deeplink-triggered connections always prompt user confirmation. Document for contributor awareness. Consider adding UI warning for manually installed plugins from unknown sources. -- [x] **Acknowledged / Documented** — Architecturally necessary. Compensating controls in place: Fix #1 closes deeplink attack surface, plugin code signing + SHA-256 verification for registry plugins. - -### 4. [HIGH] MySQL prepared statement: fixed 64KB buffer, no MYSQL_DATA_TRUNCATED check - -- **Category:** Memory Safety / Data Integrity -- **File:** `Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift:628-675` -- **Description:** Each result column in prepared statements is allocated exactly 65,536 bytes. After `mysql_stmt_fetch`, the code never checks for `MYSQL_DATA_TRUNCATED` (return value 101). For TEXT, BLOB, JSON, or LONGTEXT columns exceeding 64KB, the caller silently reads truncated data with no error or warning. The non-prepared-statement path (`executeQuerySync`) does not have this issue. -- **Impact:** Silent data truncation for large column values. Users see incomplete data without any indication. -- **Remediation:** Check `mysql_stmt_fetch` return for `MYSQL_DATA_TRUNCATED`, reallocate buffer if needed, or use `mysql_stmt_fetch_column` to re-fetch oversized columns. -- [x] **Fixed** — Fetch loop now handles `MYSQL_DATA_TRUNCATED` (101): detects truncated columns by comparing `length > buffer_length`, reallocates buffer, and re-fetches via `mysql_stmt_fetch_column`. Error pointer allocated and cleaned up in defer block. - ---- - -## Medium Severity - -### 5. [MEDIUM] ClickHouse/Etcd TLS cert verification bypassed when sslMode="Required" - -- **Category:** Network Security -- **Files:** `Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift:197-199`, `Plugins/EtcdDriverPlugin/EtcdHttpClient.swift:354-357` -- **Description:** The UI option "Required" implies TLS is required, but the code treats it as "require TLS, skip certificate validation." `InsecureTLSDelegate` accepts any server certificate unconditionally. Query results and credentials (HTTP Basic Auth) are exposed to MITM interception. -- **Remediation:** Rename the option to "Required (Skip Verify)" or "Required (self-signed)" to match actual behavior. Consider adding a "Required (Verify)" option that validates certificates. -- [x] **Fixed** — Added `displayLabel` to `SSLMode` that shows "Required (skip verify)". Updated `ConnectionSSLView` picker to use `displayLabel` instead of `rawValue`. Stored values unchanged (no migration needed). - -### 6. [MEDIUM] ClickHouse credentials sent as plaintext HTTP Basic Auth when TLS disabled - -- **Category:** Network Security -- **File:** `Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift:946-948` -- **Description:** When `sslMode` is "Disabled" or absent, `scheme = "http"`. Credentials are base64-encoded HTTP Basic Auth over plaintext. Trivially decoded by network attackers. -- **Remediation:** Show a warning in the connection UI when TLS is disabled and credentials are configured. Document the risk for users connecting over untrusted networks. -- [ ] **Fixed** - -### 7. [MEDIUM] BigQuery column name not escaped and operator string passed verbatim - -- **Category:** SQL Injection -- **File:** `Plugins/BigQueryDriverPlugin/BigQueryQueryBuilder.swift:276, 192, 209, 315` -- **Description:** `buildFilterClause` wraps column names in backticks without applying backtick escaping (unlike `quoteIdentifier`). The `default` branch interpolates `filter.op` directly without validation against an allowlist. `BigQueryFilterSpec` is a `Codable` struct that could contain any string. -- **Remediation:** Apply `quoteIdentifier` (with backtick escaping) to `filter.column`. Validate `filter.op` against a fixed allowlist of known operators. -- [x] **Fixed** — Added `quoteIdentifier()` for backtick escaping on all column names in filter, search, and sort paths. Added `allowedFilterOperators` allowlist; `default` branch returns `nil` for unknown operators. - -### 8. [MEDIUM] Deeplink sql= preview truncated at 300 chars - -- **Category:** Input Validation -- **Files:** `TablePro/Core/Services/Infrastructure/DeeplinkHandler.swift:46-48`, `TablePro/AppDelegate+FileOpen.swift:184` -- **Description:** The `sql` parameter from `tablepro://connect/{name}/query?sql=...` is accepted with only an empty-string check. The confirmation dialog shows only the first 300 characters. A user could approve a malicious query hidden past the preview. No length limit on accepted SQL. -- **Remediation:** Show more of the SQL in the preview or add a clear "N more characters not shown" warning. Add a hard length limit (e.g., 50KB) for SQL via deeplinks. -- [x] **Fixed** — Added 50KB hard limit, "N more characters not shown" warning when truncated. - -### 9. [MEDIUM] BigQuery OAuth refresh token may persist in UserDefaults - -- **Category:** Credential Storage -- **File:** `Plugins/BigQueryDriverPlugin/BigQueryConnection.swift:722` -- **Description:** `bqOAuthRefreshToken` is stored in `additionalFields` after OAuth flow completion but is never declared as a `ConnectionField` with `isSecure: true`. If it ends up in UserDefaults rather than Keychain, it's readable by any process running as the same user. -- **Remediation:** Verify persistence path. If in UserDefaults, declare the field as `isSecure` or store via `KeychainHelper` directly. -- [x] **Fixed** — Added `bqOAuthRefreshToken` as `ConnectionField` with `fieldType: .secure` to ensure Keychain storage. - -### 10. [MEDIUM] Custom plugin registry URL configurable via defaults write - -- **Category:** Supply Chain -- **File:** `TablePro/Core/Plugins/Registry/RegistryClient.swift:32-34` -- **Description:** The registry URL can be overridden via `defaults write` by any local process. If attacker controls the manifest, they control download URLs AND checksums, so SHA-256 verification provides no protection. Code signature verification is the last line of defense. -- **Remediation:** Require explicit UI confirmation when custom registry URL is set. Consider requiring that custom registry manifests be signed. -- [x] **Fixed** — Added warning log when custom registry URL is detected. Added `isUsingCustomRegistry` property for UI awareness. - -### 11. [MEDIUM] Build scripts download source without checksum verification - -- **Category:** Supply Chain -- **Files:** `scripts/build-freetds.sh:21`, `scripts/build-cassandra.sh:37-38,76`, `scripts/build-duckdb.sh:20-22` -- **Description:** FreeTDS, Cassandra (libuv + cpp-driver), and DuckDB source downloads use `curl -sL` with no SHA-256 verification before compilation. Other build scripts (libpq, hiredis, libssh2, libmongoc) correctly pin checksums. -- **Remediation:** Add SHA-256 constants for all three, matching the pattern used in `build-libpq.sh` / `build-hiredis.sh`. -- [x] **Fixed** — Added SHA-256 checksums and `shasum -a 256 -c -` verification to all three build scripts. - -### 12. [MEDIUM] FreeTDS global mutable error state shared across connections - -- **Category:** Thread Safety -- **File:** `Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift:103-116` -- **Description:** `freetdsLastError` is a global variable protected by a lock. Error/message handlers are registered globally for all FreeTDS connections. With multiple MSSQL connections open, error messages from one connection may be attributed to another. Inherent limitation of FreeTDS C API's global callback design. -- **Impact:** Mislabeled error messages (not data corruption). Only occurs with multiple simultaneous MSSQL connections. -- **Remediation:** Document the limitation. If possible, use the `DBPROCESS*` argument in callbacks to route errors to per-connection buffers. -- [x] **Fixed** — Replaced global error string with per-DBPROCESS dictionary (`freetdsConnectionErrors`). Error/message handlers now route to the correct connection's buffer. Cleanup on disconnect via `freetdsUnregister`. - -### 13. [MEDIUM] unsafeBitCast on SecIdentity without runtime type check - -- **Category:** Memory Safety -- **File:** `Plugins/EtcdDriverPlugin/EtcdHttpClient.swift:1068` -- **Description:** `unsafeBitCast(identityRef, to: SecIdentity.self)` where `identityRef` is typed as `Any` from a `CFDictionary`. No runtime type check before the bitcast. If the dictionary contains an unexpected type, this is undefined behavior. -- **Remediation:** Replace with `identityRef as! SecIdentity` (descriptive crash) or check `CFGetTypeID` before casting. -- [x] **Fixed** — Replaced `unsafeBitCast` with `as! SecIdentity` for descriptive crash on type mismatch. - -### 14. [MEDIUM] MainActor.assumeIsolated in notification callbacks - -- **Category:** Thread Safety -- **Files:** `TablePro/Views/Results/DataGridCoordinator.swift:211,225`, `TablePro/Views/Main/MainContentCoordinator.swift:323,353,527` -- **Description:** `MainActor.assumeIsolated` is used inside `NotificationCenter` callbacks posted with `queue: .main`. If any future code path posts the same notification from a background thread without specifying `queue: .main`, this will assert/crash in debug or silently run off-actor in release. -- **Remediation:** Consider using `Task { @MainActor in }` instead, or add defensive documentation preventing background posting. -- [x] **Fixed** — Converted `themeDidChange`, `teardownObserver`, `pluginDriverObserver`, and VimKeyInterceptor popup observer to `Task { @MainActor in }`. Left `willTerminate` observer (must be synchronous) and event monitor (must be synchronous) unchanged. - -### 15. [MEDIUM] Connection list stored in UserDefaults without atomic writes - -- **Category:** Data Integrity -- **File:** `TablePro/Core/Storage/ConnectionStorage.swift:66-76` -- **Description:** `saveConnections` writes to UserDefaults via `defaults.set(data, forKey:)`. If the process is killed mid-write, the backing plist can corrupt. Compare with `TabDiskActor` which uses `data.write(to: fileURL, options: .atomic)`. -- **Remediation:** Migrate connection metadata to file-based JSON storage with `.atomic` writes, or document the accepted risk. -- [x] **Fixed** — Migrated `ConnectionStorage` to file-based storage at `~/Library/Application Support/TablePro/connections.json` with `.atomic` writes. One-time migration from UserDefaults on first launch. - -### 16. [MEDIUM] No applicationShouldTerminate for graceful quit - -- **Category:** App Lifecycle -- **File:** `TablePro/AppDelegate.swift` -- **Description:** The app implements `applicationWillTerminate` but not `applicationShouldTerminate(_:)`. No opportunity to defer quit while in-flight queries complete or unsaved changes are confirmed. Synchronous `TabDiskActor.saveSync` runs but active queries are cut off. -- **Remediation:** Implement `applicationShouldTerminate` with a check for pending unsaved edits before allowing quit. -- [x] **Fixed** — Added `applicationShouldTerminate` with `MainContentCoordinator.hasAnyUnsavedChanges()` check and confirmation alert. - -### 17. [MEDIUM] Main thread blocked during first-connection plugin load race - -- **Category:** Performance -- **File:** `TablePro/Core/Database/DatabaseDriver.swift:360-364` -- **Description:** `DatabaseDriverFactory.createDriver` is `@MainActor`. If `PluginManager.hasFinishedInitialLoad` is false when the user connects immediately after launch, `loadPendingPlugins()` runs synchronously on the main thread (dynamic linking + C bridge init). Multi-second UI freeze on slower machines. -- **Remediation:** Ensure plugin loading completes before enabling the connect button, or move `loadPendingPlugins()` off the main thread. -- [x] **Fixed** — Added async `createDriver(awaitPlugins:)` overload that awaits `PluginManager.waitForInitialLoad()` via continuation instead of blocking. All async callers updated. Sync fallback preserved. - -### 18. [MEDIUM] Stale plugin rejection not surfaced in UI - -- **Category:** Production UX -- **File:** `TablePro/Core/Plugins/PluginManager.swift` -- **Description:** When `currentPluginKitVersion` is bumped and a user has a stale plugin, it's silently blocked. Only an OSLog error is emitted — no UI notification that their plugin was rejected. -- **Remediation:** Show a user-visible notification or alert when a plugin is rejected due to version mismatch. -- [x] **Fixed** — Added `rejectedPlugins` tracking in PluginManager, `.pluginsRejected` notification, and NSAlert in AppDelegate showing rejected plugin names and reasons. - -### 19. [MEDIUM] Test-only init not guarded by #if DEBUG - -- **Category:** Debug Code -- **Files:** `TablePro/Core/Storage/QueryHistoryStorage.swift:55`, `TablePro/Core/Storage/SQLFavoriteStorage.swift:19` -- **Description:** `init(isolatedForTesting:)` is public and unguarded by `#if DEBUG`. Uses `DispatchSemaphore.wait()` on the main thread — potential deadlock if called from main thread in production. -- **Remediation:** Wrap test-only initializers with `#if DEBUG`. -- [x] **Fixed** — Wrapped `init(isolatedForTesting:)` with `#if DEBUG` in both files. - -### 20. [MEDIUM] try? on COUNT(*) pagination queries — silent failure - -- **Category:** Error Handling -- **File:** `TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift:274,378` -- **Description:** If the COUNT(*) query fails (e.g., connection dropped), the error is silently swallowed. The UI shows no row count or retains a stale count, leading to incorrect pagination display. -- **Remediation:** Propagate the error or show a visual indicator that the count is unavailable. -- [x] **Fixed** — Replaced `try?` with `do/catch` + `Self.logger.warning` at both COUNT(*) sites. - -### 21. [MEDIUM] Settings/connection form not VoiceOver-audited - -- **Category:** Accessibility -- **Description:** The sidebar, connection form, settings screens, and right panel tabs have minimal or no VoiceOver-specific customisation. Data grid and filter panel are covered. -- **Remediation:** Conduct a focused VoiceOver audit of connection and settings UI. -- [ ] **Fixed** - -### 22. [MEDIUM] Sparkle appcast served from mutable main branch - -- **Category:** Update Security -- **File:** `TablePro/Info.plist:7-10` -- **Description:** `SUFeedURL` points to `raw.githubusercontent.com/.../main/appcast.xml`. If the repo is compromised, a malicious appcast could be pushed. Mitigated by Ed25519 signature verification on the actual binary. -- **Remediation:** Consider pointing `SUFeedURL` to a versioned GitHub Release asset or CDN URL. Defense-in-depth. -- [ ] **Fixed** - ---- - -## Low Severity - -### 23. [LOW] PostgreSQL PQexec result leaked - -- **File:** `Plugins/PostgreSQLDriverPlugin/LibPQPluginConnection.swift:227-229` -- **Description:** `PQexec` result for `SET client_encoding TO 'UTF8'` is discarded without `PQclear`. Small memory leak per connection. -- **Fix:** `if let r = PQexec(connection, cStr) { PQclear(r) }` -- [x] **Fixed** — Captured `PQexec` result and added `PQclear()` call, matching existing pattern throughout the file. - -### 24. [LOW] License signed payload in UserDefaults - -- **File:** `TablePro/Core/Storage/LicenseStorage.swift:47-55` -- **Description:** Email and expiry stored in UserDefaults plist (readable by same-user processes). License key itself is correctly in Keychain. Signed payload is re-verified on load. -- [x] **Documented** — By design: signed payload is RSA-SHA256 verified on every cold start. License key is in Keychain. Added inline documentation. - -### 25. [LOW] try! for static regex patterns - -- **Files:** `TablePro/Views/Results/JSONHighlightPatterns.swift:9-12`, `TablePro/Views/AIChat/AIChatCodeBlockView.swift:127-259`, `TablePro/Core/Utilities/Connection/EnvVarResolver.swift:16` -- **Description:** `try!` on `NSRegularExpression` init. Patterns are string literals so crash risk is near zero, but a typo during refactoring would crash at launch. -- [x] **Accepted** — `try!` on static string literal regex patterns is the standard Swift idiom. Callers depend on non-optional types. Patterns are tested at launch. Added clarifying comment. - -### 26. [LOW] oracle-nio pinned to pre-release RC - -- **File:** `Package.resolved` -- **Description:** `oracle-nio` 1.0.0-rc.4, SSWG Sandbox maturity. Pre-release APIs may change. No stable 1.0.0 yet. -- [x] **Accepted** — External dependency; no stable release available. Monitor for 1.0.0 and update when released. - -### 27. [LOW] FreeTDS 1.4.22 behind available 1.5.x - -- **File:** `scripts/build-freetds.sh:8` -- **Description:** FreeTDS 1.5 is available. No confirmed high-severity CVEs in 1.4.22 but changelog should be reviewed. -- [x] **Accepted** — No high-severity CVEs in 1.4.22. Version bump to 1.5.x requires testing MSSQL driver compatibility. Track for next lib rebuild cycle. - -### 28. [LOW] Uncached machineId IOKit lookup - -- **File:** `TablePro/Core/Storage/LicenseStorage.swift:84-110` -- **Description:** Computed property calls `IOServiceGetMatchingService` on every access. Low practical impact (called infrequently) but should be cached as `lazy var`. -- [x] **Fixed** — Changed to `lazy var _machineId` computed once on first access. - -### 29. [LOW] -Wl,-w suppresses all linker warnings in Release - -- **File:** `TablePro.xcodeproj/project.pbxproj` -- **Description:** Hides potential issues like duplicate symbols or undefined behavior during linking. -- [x] **Accepted** — Intentional to suppress noise from third-party static libs. Flag suppresses warnings not errors. Removing risks CI breakage without investigation. - -### 30. [LOW] Etcd VerifyCA mode skips hostname verification - -- **File:** `Plugins/EtcdDriverPlugin/EtcdHttpClient.swift:1024-1028` -- **Description:** CA chain validated but hostname not checked. A certificate from the same CA for a different hostname will be accepted. Standard behavior for VerifyCA mode. -- [x] **Accepted** — Standard VerifyCA behavior matching MySQL/PostgreSQL drivers. Users who need hostname verification should select "Verify Identity" mode. - -### 31. [LOW] SSH tunnel close error silently swallowed - -- **File:** `TablePro/Core/Database/DatabaseManager+Health.swift:137` -- **Description:** `try? await SSHTunnelManager.shared.closeTunnel(...)` — if tunnel close fails, OS resources (file descriptors) may leak. -- [x] **Fixed** — Replaced `try?` with `do/catch` + `Self.logger.warning` for visibility. - -### 32. [LOW] DuckDB extension SET errors silently swallowed - -- **File:** `Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift:566-567` -- **Description:** `try?` on `SET autoinstall_known_extensions=1`. If it fails, subsequent queries relying on autoloaded extensions fail with confusing errors. -- [x] **Fixed** — Replaced `try?` with `do/catch` + `Self.logger.warning` for DuckDB extension autoloading failures. - -### 33. [LOW] Settings sync encode operations all use try? - -- **File:** `TablePro/Core/Sync/SyncCoordinator.swift:687-694` -- **Description:** All eight settings categories silently return `nil` on encode failure, causing that category to not sync with no user feedback. -- [x] **Fixed** — Replaced individual `try?` with a single `do/catch` block + `Self.logger.error` logging the category name. - -### 34. [LOW] Tag badge accessibility label not localized - -- **File:** `TablePro/Views/Toolbar/TagBadgeView.swift:34` -- **Description:** `"Tag: \(tag.name)"` not wrapped in `String(localized:)`. VoiceOver announces "Tag:" in English for non-English users. -- [x] **Fixed** — Changed to `String(format: String(localized: "Tag: %@"), tag.name)` for proper localization. - -### 35. [LOW] No memory pressure response - -- **File:** `TablePro/Core/Utilities/MemoryPressureAdvisor.swift` -- **Description:** Tab eviction budget is row-count-based, not reactive to `DISPATCH_SOURCE_TYPE_MEMORYPRESSURE`. Under sustained memory pressure, no automatic eviction until next tab switch. -- [x] **Fixed** — Added `DispatchSource.makeMemoryPressureSource` monitoring. Budget halved under memory pressure. Monitoring started at app launch. - ---- - -## What's Done Well - -- **Live edits use parameterized prepared statements** (`SQLStatementGenerator` + `ParameterizedStatement`) -- **Plugin code signing enforced** via `SecStaticCodeCheckValidity` with team ID for registry plugins -- **Plugin registry: 3-layer defense** — HTTPS download, SHA-256 checksum, code signature verification -- **Connection export crypto** — AES-256-GCM, 12-byte random nonce, PBKDF2-SHA256 at 600K iterations -- **License verification chain** — RSA-SHA256 with machine ID binding, re-verified on every cold start -- **No passwords or secrets logged** anywhere in OSLog calls -- **Keychain usage correct** — `kSecUseDataProtectionKeychain`, proper accessibility levels -- **Sparkle uses HTTPS + Ed25519 signatures** — MITM-resistant -- **Tab persistence uses atomic writes** with 500KB truncation guard -- **Filter values properly escaped** per SQL dialect in `FilterSQLGenerator` -- **SPM dependencies all pinned** to exact versions/SHAs (no branch pins) -- **Hardened runtime enabled** at target level with all exception flags set to NO -- **`#if DEBUG` blocks are correctly stripped** in release builds - ---- - -## Remediation Priority - -### Immediate (High) - -1. **Issue #1**: Add confirmation dialog for URL `condition`/`raw`/`query` parameters -2. **Issue #2**: Update OpenSSL to 3.4.3 in all build scripts, rebuild libs - -### Short-term (Medium) - -3. **Issue #4**: Handle `MYSQL_DATA_TRUNCATED` in MySQL prepared statements -4. **Issue #7**: Escape BigQuery column names; validate operator allowlist -5. **Issue #8**: Improve deeplink SQL preview; add length limit -6. **Issue #11**: Add SHA-256 verification to FreeTDS, Cassandra, DuckDB build scripts -7. **Issue #5**: Rename misleading sslMode="Required" option -8. **Issue #9**: Verify BigQuery OAuth refresh token storage path - -### Medium-term - -9. **Issue #15**: Migrate connection storage to atomic file-based JSON -10. **Issue #16**: Implement `applicationShouldTerminate` with unsaved-edits check -11. **Issue #18**: Surface stale plugin rejections in UI -12. **Issue #19**: Guard test-only initializers with `#if DEBUG` -13. **Issue #17**: Fix plugin load race on first connection