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..73df4e08b 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 { @@ -135,7 +136,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) } @@ -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 { @@ -240,7 +239,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) ) @@ -248,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) ) @@ -287,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) } @@ -361,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) } @@ -384,31 +377,37 @@ 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 + 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: - applyRemoteConnection(record) - connectionsChanged = true + 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: @@ -416,25 +415,67 @@ 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) + } + } + + 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) } - // Notify UI so views refresh with pulled data - if connectionsChanged || groupsOrTagsChanged { + 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, tombstoneIds: Set) -> Bool { + guard let remoteConnection = SyncRecordMapper.toConnection(record) else { return false } + + if tombstoneIds.contains(remoteConnection.id.uuidString) { + return false + } 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], @@ -452,17 +493,22 @@ final class SyncCoordinator { serverModifiedAt: (record["modifiedAtLocal"] as? Date) ?? Date() ) conflictResolver.addConflict(conflict) - return + return false } - connections[index] = remoteConnection + var merged = remoteConnection + merged.localOnly = connections[index].localOnly + connections[index] = merged } else { connections.append(remoteConnection) } ConnectionStorage.shared.saveConnections(connections) + return true } - private func applyRemoteGroup(_ record: CKRecord) { - guard let remoteGroup = SyncRecordMapper.toGroup(record) else { 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 }) { @@ -471,10 +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 } + @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 }) { @@ -483,10 +532,12 @@ 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 } + if tombstoneIds.contains(remoteProfile.id.uuidString) { return } var profiles = SSHProfileStorage.shared.loadProfiles() if let index = profiles.firstIndex(where: { $0.id == remoteProfile.id }) { @@ -504,45 +555,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/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/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/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 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..34c0672b5 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 ) @@ -254,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) @@ -262,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/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..1fb036daa 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) { 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/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 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" ] }, 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.