From 2c63fb21b4d897f2eb2de67ddf0467e83c16f60d Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 20 Jun 2026 19:33:06 +0700 Subject: [PATCH] feat(connections): import and export connections on iOS --- CHANGELOG.md | 5 + Packages/TableProCore/Package.swift | 11 + .../ConnectionExportCrypto.swift | 24 +- .../ConnectionExportEnvelope.swift | 308 ++++++++++++++++++ .../ConnectionImportTypes.swift | 240 ++++++++++++++ .../ConnectionExportCryptoTests.swift | 52 +++ .../ConnectionImportAnalyzerTests.swift | 93 ++++++ .../ConnectionImportDecoderTests.swift | 110 +++++++ TablePro.xcodeproj/project.pbxproj | 8 + .../Export/ConnectionExportService.swift | 217 +----------- .../ForeignApp/BeekeeperStudioImporter.swift | 1 + .../Export/ForeignApp/DBeaverImporter.swift | 1 + .../Export/ForeignApp/DataGripImporter.swift | 1 + .../ForeignApp/ForeignAppImporter.swift | 1 + .../ForeignApp/Navicat/NavicatImporter.swift | 1 + .../Export/ForeignApp/SequelAceImporter.swift | 1 + .../Export/ForeignApp/TablePlusImporter.swift | 1 + .../Services/Export/LinkedFolderWatcher.swift | 3 +- .../Infrastructure/DeeplinkParser.swift | 1 + .../Infrastructure/LaunchIntent.swift | 1 + .../Infrastructure/WelcomeRouter.swift | 1 + .../Core/Storage/LinkedFolderStorage.swift | 1 + TablePro/Core/Sync/SyncRecordMapper.swift | 1 + .../Models/Connection/ConnectionExport.swift | 151 +-------- TablePro/Models/Query/LinkedSQLFolder.swift | 1 + TablePro/ViewModels/WelcomeViewModel.swift | 1 + .../ConnectionExportOptionsSheet.swift | 1 + .../Connection/ConnectionImportSheet.swift | 5 +- .../Connection/DeeplinkImportSheet.swift | 1 + .../ConnectionImportPreviewList.swift | 5 +- .../ImportFromAppPreviewStep.swift | 1 + .../ImportFromApp/ImportFromAppSheet.swift | 1 + .../Views/Connection/WelcomeWindowView.swift | 1 + .../Views/Settings/LinkedFoldersSection.swift | 1 + TablePro/Views/Sidebar/FavoritesTabView.swift | 1 + .../TableProMobile.xcodeproj/project.pbxproj | 8 + TableProMobile/TableProMobile/AppState.swift | 1 + TableProMobile/TableProMobile/Info.plist | 35 ++ .../Services/IOSConnectionExportService.swift | 172 ++++++++++ .../Services/IOSConnectionImportService.swift | 257 +++++++++++++++ .../TableProMobile/TableProMobileApp.swift | 4 + .../Views/ConnectionListView.swift | 73 +++++ .../Views/MobileConnectionExportSheet.swift | 98 ++++++ .../Views/MobileConnectionImportSheet.swift | 241 ++++++++++++++ .../IOSConnectionImportServiceTests.swift | 75 +++++ .../ConnectionImportServiceTests.swift | 13 +- .../Services/ConnectionSharingTests.swift | 1 + .../ForeignApp/DBeaverImporterTests.swift | 1 + .../ForeignApp/DataGripImporterTests.swift | 1 + .../ForeignApp/NavicatImporterTests.swift | 1 + .../ForeignApp/SequelAceImporterTests.swift | 1 + .../ForeignApp/TablePlusImporterTests.swift | 1 + docs/features/connection-sharing.mdx | 8 + 53 files changed, 1869 insertions(+), 375 deletions(-) rename {TablePro/Core/Services/Export => Packages/TableProCore/Sources/TableProImport}/ConnectionExportCrypto.swift (86%) create mode 100644 Packages/TableProCore/Sources/TableProImport/ConnectionExportEnvelope.swift create mode 100644 Packages/TableProCore/Sources/TableProImport/ConnectionImportTypes.swift create mode 100644 Packages/TableProCore/Tests/TableProImportTests/ConnectionExportCryptoTests.swift create mode 100644 Packages/TableProCore/Tests/TableProImportTests/ConnectionImportAnalyzerTests.swift create mode 100644 Packages/TableProCore/Tests/TableProImportTests/ConnectionImportDecoderTests.swift create mode 100644 TableProMobile/TableProMobile/Services/IOSConnectionExportService.swift create mode 100644 TableProMobile/TableProMobile/Services/IOSConnectionImportService.swift create mode 100644 TableProMobile/TableProMobile/Views/MobileConnectionExportSheet.swift create mode 100644 TableProMobile/TableProMobile/Views/MobileConnectionImportSheet.swift create mode 100644 TableProMobile/TableProMobileTests/IOSConnectionImportServiceTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 553ae5ded..426f90fb4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Import connections on iPhone. Open a .tablepro file from Files or AirDrop, or use Import Connections in the list menu. Encrypted files prompt for the passphrase, and you choose how to handle duplicates. +- Export connections on iPhone from the list menu and share the .tablepro file. Passwords are left out by default; include them by setting a passphrase that encrypts the file. + ### Changed - Data grid now serves the row count from its existing cache instead of recomputing it on every layout pass, reducing CPU churn while scrolling large result sets. diff --git a/Packages/TableProCore/Package.swift b/Packages/TableProCore/Package.swift index ebb763700..a186696fc 100644 --- a/Packages/TableProCore/Package.swift +++ b/Packages/TableProCore/Package.swift @@ -12,6 +12,7 @@ let package = Package( .library(name: "TableProCoreTypes", targets: ["TableProCoreTypes"]), .library(name: "TableProPluginKit", targets: ["TableProPluginKit"]), .library(name: "TableProModels", targets: ["TableProModels"]), + .library(name: "TableProImport", targets: ["TableProImport"]), .library(name: "TableProDatabase", targets: ["TableProDatabase"]), .library(name: "TableProQuery", targets: ["TableProQuery"]), .library(name: "TableProSync", targets: ["TableProSync"]), @@ -35,6 +36,11 @@ let package = Package( dependencies: ["TableProPluginKit", "TableProCoreTypes"], path: "Sources/TableProModels" ), + .target( + name: "TableProImport", + dependencies: [], + path: "Sources/TableProImport" + ), .target( name: "TableProDatabase", dependencies: ["TableProModels", "TableProCoreTypes"], @@ -65,6 +71,11 @@ let package = Package( dependencies: ["TableProModels", "TableProPluginKit"], path: "Tests/TableProModelsTests" ), + .testTarget( + name: "TableProImportTests", + dependencies: ["TableProImport"], + path: "Tests/TableProImportTests" + ), .testTarget( name: "TableProDatabaseTests", dependencies: ["TableProDatabase", "TableProModels"], diff --git a/TablePro/Core/Services/Export/ConnectionExportCrypto.swift b/Packages/TableProCore/Sources/TableProImport/ConnectionExportCrypto.swift similarity index 86% rename from TablePro/Core/Services/Export/ConnectionExportCrypto.swift rename to Packages/TableProCore/Sources/TableProImport/ConnectionExportCrypto.swift index 1d5b832c3..a1a6f9c1e 100644 --- a/TablePro/Core/Services/Export/ConnectionExportCrypto.swift +++ b/Packages/TableProCore/Sources/TableProImport/ConnectionExportCrypto.swift @@ -1,20 +1,13 @@ -// -// ConnectionExportCrypto.swift -// TablePro -// -// AES-256-GCM encryption for connection export files with PBKDF2 key derivation. -// - import CommonCrypto import CryptoKit import Foundation -enum ConnectionExportCryptoError: LocalizedError { +public enum ConnectionExportCryptoError: LocalizedError { case invalidPassphrase case corruptData case unsupportedVersion(UInt8) - var errorDescription: String? { + public var errorDescription: String? { switch self { case .invalidPassphrase: return String(localized: "Incorrect passphrase") @@ -26,22 +19,21 @@ enum ConnectionExportCryptoError: LocalizedError { } } -enum ConnectionExportCrypto { - private static let magic = Data("TPRO".utf8) // 4 bytes +public enum ConnectionExportCrypto { + private static let magic = Data("TPRO".utf8) private static let currentVersion: UInt8 = 1 private static let saltLength = 32 private static let nonceLength = 12 private static let pbkdf2Iterations: UInt32 = 600_000 - private static let keyLength = 32 // AES-256 + private static let keyLength = 32 - // Header: magic (4) + version (1) + salt (32) + nonce (12) = 49 bytes private static let headerLength = 4 + 1 + saltLength + nonceLength - static func isEncrypted(_ data: Data) -> Bool { + public static func isEncrypted(_ data: Data) -> Bool { data.count > headerLength && data.prefix(4) == magic } - static func encrypt(data: Data, passphrase: String) throws -> Data { + public static func encrypt(data: Data, passphrase: String) throws -> Data { var salt = Data(count: saltLength) let saltStatus = salt.withUnsafeMutableBytes { buffer -> OSStatus in guard let baseAddress = buffer.baseAddress else { return errSecParam } @@ -65,7 +57,7 @@ enum ConnectionExportCrypto { return result } - static func decrypt(data: Data, passphrase: String) throws -> Data { + public static func decrypt(data: Data, passphrase: String) throws -> Data { guard data.count > headerLength else { throw ConnectionExportCryptoError.corruptData } diff --git a/Packages/TableProCore/Sources/TableProImport/ConnectionExportEnvelope.swift b/Packages/TableProCore/Sources/TableProImport/ConnectionExportEnvelope.swift new file mode 100644 index 000000000..a63acd38e --- /dev/null +++ b/Packages/TableProCore/Sources/TableProImport/ConnectionExportEnvelope.swift @@ -0,0 +1,308 @@ +import Foundation +import UniformTypeIdentifiers + +// MARK: - UTType + +public extension UTType { + static let tableproConnectionShare = UTType(exportedAs: "com.tablepro.connection-share") +} + +// MARK: - Export Error + +public enum ConnectionExportError: LocalizedError { + case encodingFailed + case fileWriteFailed(String) + case fileReadFailed(String) + case invalidFormat + case unsupportedVersion(Int) + case decodingFailed(String) + case requiresPassphrase + case decryptionFailed(String) + + public var errorDescription: String? { + switch self { + case .encodingFailed: + return String(localized: "Failed to encode connection data") + case .fileWriteFailed(let path): + return String(format: String(localized: "Failed to write file: %@"), path) + case .fileReadFailed(let path): + return String(format: String(localized: "Failed to read file: %@"), path) + case .invalidFormat: + return String(localized: "This file is not a valid TablePro export") + case .unsupportedVersion(let version): + return String(format: String(localized: "This file requires a newer version of TablePro (format version %d)"), version) + case .decodingFailed(let detail): + return String(format: String(localized: "Failed to parse connection file: %@"), detail) + case .requiresPassphrase: + return String(localized: "This file is encrypted and requires a passphrase") + case .decryptionFailed(let detail): + return String(format: String(localized: "Decryption failed: %@"), detail) + } + } +} + +// MARK: - Export Envelope + +public struct ConnectionExportEnvelope: Codable { + public let formatVersion: Int + public let exportedAt: Date + public let appVersion: String + public let connections: [ExportableConnection] + public let groups: [ExportableGroup]? + public let tags: [ExportableTag]? + public let credentials: [String: ExportableCredentials]? + + public init( + formatVersion: Int, + exportedAt: Date, + appVersion: String, + connections: [ExportableConnection], + groups: [ExportableGroup]?, + tags: [ExportableTag]?, + credentials: [String: ExportableCredentials]? + ) { + self.formatVersion = formatVersion + self.exportedAt = exportedAt + self.appVersion = appVersion + self.connections = connections + self.groups = groups + self.tags = tags + self.credentials = credentials + } +} + +// MARK: - Exportable Connection + +public struct ExportableConnection: Codable { + public let name: String + public let host: String + public let port: Int + public let database: String + public let username: String + public let type: String + public let sshConfig: ExportableSSHConfig? + public let sslConfig: ExportableSSLConfig? + public let color: String? + public let tagName: String? + public let groupName: String? + public let sshProfileId: String? + public let safeModeLevel: String? + public let aiPolicy: String? + public let additionalFields: [String: String]? + public let redisDatabase: Int? + public let startupCommands: String? + public let localOnly: Bool? + + public init( + name: String, + host: String, + port: Int, + database: String, + username: String, + type: String, + sshConfig: ExportableSSHConfig?, + sslConfig: ExportableSSLConfig?, + color: String?, + tagName: String?, + groupName: String?, + sshProfileId: String?, + safeModeLevel: String?, + aiPolicy: String?, + additionalFields: [String: String]?, + redisDatabase: Int?, + startupCommands: String?, + localOnly: Bool? + ) { + self.name = name + self.host = host + self.port = port + self.database = database + self.username = username + self.type = type + self.sshConfig = sshConfig + self.sslConfig = sslConfig + self.color = color + self.tagName = tagName + self.groupName = groupName + self.sshProfileId = sshProfileId + self.safeModeLevel = safeModeLevel + self.aiPolicy = aiPolicy + self.additionalFields = additionalFields + self.redisDatabase = redisDatabase + self.startupCommands = startupCommands + self.localOnly = localOnly + } + + public func renamed(to newName: String) -> ExportableConnection { + ExportableConnection( + name: newName, host: host, port: port, database: database, + username: username, type: type, sshConfig: sshConfig, + sslConfig: sslConfig, color: color, tagName: tagName, + groupName: groupName, sshProfileId: sshProfileId, + safeModeLevel: safeModeLevel, aiPolicy: aiPolicy, + additionalFields: additionalFields, redisDatabase: redisDatabase, + startupCommands: startupCommands, localOnly: localOnly + ) + } +} + +public extension ExportableConnection { + static let importBlockedAdditionalFieldKeys: Set = ["preConnectScript"] + + func sanitizedForImport() -> ExportableConnection { + guard let additionalFields else { return self } + let allowed = additionalFields.filter { !Self.importBlockedAdditionalFieldKeys.contains($0.key) } + guard allowed.count != additionalFields.count else { return self } + return ExportableConnection( + name: name, host: host, port: port, database: database, + username: username, type: type, sshConfig: sshConfig, + sslConfig: sslConfig, color: color, tagName: tagName, + groupName: groupName, sshProfileId: sshProfileId, + safeModeLevel: safeModeLevel, aiPolicy: aiPolicy, + additionalFields: allowed.isEmpty ? nil : allowed, redisDatabase: redisDatabase, + startupCommands: startupCommands, localOnly: localOnly + ) + } +} + +// MARK: - SSH Config + +public struct ExportableSSHConfig: Codable { + public let enabled: Bool + public let host: String + public let port: Int? + public let username: String + public let authMethod: String + public let privateKeyPath: String + public let agentSocketPath: String + public let jumpHosts: [ExportableJumpHost]? + public let totpMode: String? + public let totpAlgorithm: String? + public let totpDigits: Int? + public let totpPeriod: Int? + + public init( + enabled: Bool, + host: String, + port: Int?, + username: String, + authMethod: String, + privateKeyPath: String, + agentSocketPath: String, + jumpHosts: [ExportableJumpHost]?, + totpMode: String?, + totpAlgorithm: String?, + totpDigits: Int?, + totpPeriod: Int? + ) { + self.enabled = enabled + self.host = host + self.port = port + self.username = username + self.authMethod = authMethod + self.privateKeyPath = privateKeyPath + self.agentSocketPath = agentSocketPath + self.jumpHosts = jumpHosts + self.totpMode = totpMode + self.totpAlgorithm = totpAlgorithm + self.totpDigits = totpDigits + self.totpPeriod = totpPeriod + } +} + +public struct ExportableJumpHost: Codable { + public let host: String + public let port: Int? + public let username: String + public let authMethod: String + public let privateKeyPath: String + + public init(host: String, port: Int?, username: String, authMethod: String, privateKeyPath: String) { + self.host = host + self.port = port + self.username = username + self.authMethod = authMethod + self.privateKeyPath = privateKeyPath + } +} + +// MARK: - SSL Config + +public struct ExportableSSLConfig: Codable { + public let mode: String + public let caCertificatePath: String? + public let clientCertificatePath: String? + public let clientKeyPath: String? + + public init(mode: String, caCertificatePath: String?, clientCertificatePath: String?, clientKeyPath: String?) { + self.mode = mode + self.caCertificatePath = caCertificatePath + self.clientCertificatePath = clientCertificatePath + self.clientKeyPath = clientKeyPath + } +} + +// MARK: - Group & Tag + +public struct ExportableGroup: Codable { + public let name: String + public let color: String? + + public init(name: String, color: String?) { + self.name = name + self.color = color + } +} + +public struct ExportableTag: Codable { + public let name: String + public let color: String? + + public init(name: String, color: String?) { + self.name = name + self.color = color + } +} + +// MARK: - Credentials + +public struct ExportableCredentials: Codable { + public let password: String? + public let sshPassword: String? + public let keyPassphrase: String? + public let sslClientKeyPassphrase: String? + public let totpSecret: String? + public let pluginSecureFields: [String: String]? + + public init( + password: String?, + sshPassword: String?, + keyPassphrase: String?, + sslClientKeyPassphrase: String?, + totpSecret: String?, + pluginSecureFields: [String: String]? + ) { + self.password = password + self.sshPassword = sshPassword + self.keyPassphrase = keyPassphrase + self.sslClientKeyPassphrase = sslClientKeyPassphrase + self.totpSecret = totpSecret + self.pluginSecureFields = pluginSecureFields + } +} + +// MARK: - Path Portability + +public enum PathPortability { + public static func contractHome(_ path: String) -> String { + guard !path.isEmpty else { return path } + let home = NSHomeDirectory() + guard path.hasPrefix(home) else { return path } + return "~" + path.dropFirst(home.count) + } + + public static func expandHome(_ path: String) -> String { + guard path.hasPrefix("~/") else { return path } + return NSHomeDirectory() + String(path.dropFirst(1)) + } +} diff --git a/Packages/TableProCore/Sources/TableProImport/ConnectionImportTypes.swift b/Packages/TableProCore/Sources/TableProImport/ConnectionImportTypes.swift new file mode 100644 index 000000000..d730dac7a --- /dev/null +++ b/Packages/TableProCore/Sources/TableProImport/ConnectionImportTypes.swift @@ -0,0 +1,240 @@ +import Foundation + +// MARK: - Import Preview Types + +public enum ImportItemStatus { + case ready + case duplicate(existingId: UUID, existingName: String) + case warnings([String]) +} + +public struct ImportItem: Identifiable { + public let id = UUID() + public let connection: ExportableConnection + public let status: ImportItemStatus + + public init(connection: ExportableConnection, status: ImportItemStatus) { + self.connection = connection + self.status = status + } +} + +public enum ImportResolution: Hashable { + case importNew + case skip + case replace(existingId: UUID) + case importAsCopy +} + +public struct ConnectionImportPreview { + public let envelope: ConnectionExportEnvelope + public let items: [ImportItem] + + public init(envelope: ConnectionExportEnvelope, items: [ImportItem]) { + self.envelope = envelope + self.items = items + } +} + +public struct ConnectionDuplicateCandidate { + public let id: UUID + public let name: String + public let host: String + public let port: Int + public let database: String + public let username: String + public let redisDatabase: Int? + + public init( + id: UUID, + name: String, + host: String, + port: Int, + database: String, + username: String, + redisDatabase: Int? + ) { + self.id = id + self.name = name + self.host = host + self.port = port + self.database = database + self.username = username + self.redisDatabase = redisDatabase + } +} + +// MARK: - Import Analyzer + +public enum ConnectionImportAnalyzer { + public static func analyze( + _ envelope: ConnectionExportEnvelope, + existingConnections: [ConnectionDuplicateCandidate], + registeredTypeIds: Set, + fileExists: (String) -> Bool + ) -> ConnectionImportPreview { + var duplicateMap: [ConnectionImportDuplicateKey: ConnectionDuplicateCandidate] = [:] + for existing in existingConnections { + let key = duplicateKey(for: existing) + if duplicateMap[key] == nil { + duplicateMap[key] = existing + } + } + + let items: [ImportItem] = envelope.connections.map { exportable in + if let duplicate = duplicateMap[duplicateKey(for: exportable)] { + return ImportItem( + connection: exportable, + status: .duplicate(existingId: duplicate.id, existingName: duplicate.name) + ) + } + + var warnings: [String] = [] + + if let ssh = exportable.sshConfig { + let keyPath = PathPortability.expandHome(ssh.privateKeyPath) + if !keyPath.isEmpty, !fileExists(keyPath) { + warnings.append(String( + format: String(localized: "SSH private key not found: %@"), + ssh.privateKeyPath + )) + } + for jump in ssh.jumpHosts ?? [] { + let jumpKeyPath = PathPortability.expandHome(jump.privateKeyPath) + if !jumpKeyPath.isEmpty, !fileExists(jumpKeyPath) { + warnings.append(String( + format: String(localized: "Jump host key not found: %@"), + jump.privateKeyPath + )) + } + } + } + + if let ssl = exportable.sslConfig { + for (path, format) in [ + (ssl.caCertificatePath, String(localized: "CA certificate not found: %@")), + (ssl.clientCertificatePath, String(localized: "Client certificate not found: %@")), + (ssl.clientKeyPath, String(localized: "Client key not found: %@")) + ] { + if let path, !path.isEmpty { + let expanded = PathPortability.expandHome(path) + if !fileExists(expanded) { + warnings.append(String(format: format, path)) + } + } + } + } + + if !registeredTypeIds.contains(exportable.type) { + warnings.append(String( + format: String(localized: "Database type \"%@\" is not installed"), + exportable.type + )) + } + + if !warnings.isEmpty { + return ImportItem(connection: exportable, status: .warnings(warnings)) + } + + return ImportItem(connection: exportable, status: .ready) + } + + return ConnectionImportPreview(envelope: envelope, items: items) + } + + // MARK: - Duplicate Keys + + private struct ConnectionImportDuplicateKey: Hashable { + let components: [String] + } + + private static func duplicateKey(for connection: ExportableConnection) -> ConnectionImportDuplicateKey { + ConnectionImportDuplicateKey( + components: [ + normalizedLookupKey(connection.host), + String(connection.port), + effectiveDatabaseKey(database: connection.database, redisDatabase: connection.redisDatabase), + normalizedLookupKey(connection.username) + ] + ) + } + + private static func duplicateKey(for candidate: ConnectionDuplicateCandidate) -> ConnectionImportDuplicateKey { + ConnectionImportDuplicateKey( + components: [ + normalizedLookupKey(candidate.host), + String(candidate.port), + effectiveDatabaseKey(database: candidate.database, redisDatabase: candidate.redisDatabase), + normalizedLookupKey(candidate.username) + ] + ) + } + + private static func effectiveDatabaseKey(database: String?, redisDatabase: Int?) -> String { + let normalized = normalizedLookupKey(database) + if !normalized.isEmpty { + return normalized + } + if let redisDatabase { + return String(redisDatabase) + } + return "" + } + + private static func normalizedLookupKey(_ value: String?) -> String { + value?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? "" + } +} + +// MARK: - Import Decoder + +public enum ConnectionImportDecoder { + public static let currentFormatVersion = 1 + + public static func decodeData(_ data: Data) throws -> ConnectionExportEnvelope { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + + let envelope: ConnectionExportEnvelope + do { + envelope = try decoder.decode(ConnectionExportEnvelope.self, from: data) + } catch { + throw ConnectionExportError.decodingFailed(error.localizedDescription) + } + + guard envelope.formatVersion <= currentFormatVersion else { + throw ConnectionExportError.unsupportedVersion(envelope.formatVersion) + } + + return ConnectionExportEnvelope( + formatVersion: envelope.formatVersion, + exportedAt: envelope.exportedAt, + appVersion: envelope.appVersion, + connections: envelope.connections.map { $0.sanitizedForImport() }, + groups: envelope.groups, + tags: envelope.tags, + credentials: envelope.credentials + ) + } + + public static func decodeEncryptedData(_ data: Data, passphrase: String) throws -> ConnectionExportEnvelope { + let decryptedData: Data + do { + decryptedData = try ConnectionExportCrypto.decrypt(data: data, passphrase: passphrase) + } catch { + throw ConnectionExportError.decryptionFailed(error.localizedDescription) + } + return try decodeData(decryptedData) + } + + public static func encode(_ envelope: ConnectionExportEnvelope) throws -> Data { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + encoder.dateEncodingStrategy = .iso8601 + do { + return try encoder.encode(envelope) + } catch { + throw ConnectionExportError.encodingFailed + } + } +} diff --git a/Packages/TableProCore/Tests/TableProImportTests/ConnectionExportCryptoTests.swift b/Packages/TableProCore/Tests/TableProImportTests/ConnectionExportCryptoTests.swift new file mode 100644 index 000000000..9d412cdd0 --- /dev/null +++ b/Packages/TableProCore/Tests/TableProImportTests/ConnectionExportCryptoTests.swift @@ -0,0 +1,52 @@ +import XCTest +@testable import TableProImport + +final class ConnectionExportCryptoTests: XCTestCase { + func testEncryptDecryptRoundTripRecoversOriginal() throws { + let original = Data("the quick brown fox".utf8) + let encrypted = try ConnectionExportCrypto.encrypt(data: original, passphrase: "correct horse battery") + let decrypted = try ConnectionExportCrypto.decrypt(data: encrypted, passphrase: "correct horse battery") + XCTAssertEqual(decrypted, original) + } + + func testEncryptedBlobIsDetectedAndPlainJSONIsNot() throws { + let encrypted = try ConnectionExportCrypto.encrypt(data: Data("x".utf8), passphrase: "pw") + XCTAssertTrue(ConnectionExportCrypto.isEncrypted(encrypted)) + XCTAssertFalse(ConnectionExportCrypto.isEncrypted(Data("{\"a\":1}".utf8))) + } + + func testWrongPassphraseThrowsInvalidPassphrase() throws { + let encrypted = try ConnectionExportCrypto.encrypt(data: Data("secret".utf8), passphrase: "right") + XCTAssertThrowsError(try ConnectionExportCrypto.decrypt(data: encrypted, passphrase: "wrong")) { error in + XCTAssertEqual(error as? ConnectionExportCryptoError, .invalidPassphrase) + } + } + + func testTruncatedHeaderThrowsCorruptData() { + let tooShort = Data([0x54, 0x50, 0x52, 0x4F, 0x01]) + XCTAssertThrowsError(try ConnectionExportCrypto.decrypt(data: tooShort, passphrase: "pw")) { error in + XCTAssertEqual(error as? ConnectionExportCryptoError, .corruptData) + } + } + + func testNonMagicPrefixThrowsCorruptData() throws { + var blob = try ConnectionExportCrypto.encrypt(data: Data("hello world data".utf8), passphrase: "pw") + blob[0] = 0x00 + XCTAssertThrowsError(try ConnectionExportCrypto.decrypt(data: blob, passphrase: "pw")) { error in + XCTAssertEqual(error as? ConnectionExportCryptoError, .corruptData) + } + } +} + +extension ConnectionExportCryptoError: Equatable { + public static func == (lhs: ConnectionExportCryptoError, rhs: ConnectionExportCryptoError) -> Bool { + switch (lhs, rhs) { + case (.invalidPassphrase, .invalidPassphrase), (.corruptData, .corruptData): + return true + case let (.unsupportedVersion(a), .unsupportedVersion(b)): + return a == b + default: + return false + } + } +} diff --git a/Packages/TableProCore/Tests/TableProImportTests/ConnectionImportAnalyzerTests.swift b/Packages/TableProCore/Tests/TableProImportTests/ConnectionImportAnalyzerTests.swift new file mode 100644 index 000000000..fbf14e84f --- /dev/null +++ b/Packages/TableProCore/Tests/TableProImportTests/ConnectionImportAnalyzerTests.swift @@ -0,0 +1,93 @@ +import XCTest +@testable import TableProImport + +final class ConnectionImportAnalyzerTests: XCTestCase { + private let allTypes: Set = ["MySQL", "PostgreSQL", "Redis"] + + func testMatchingHostPortDatabaseUserIsDuplicate() { + let existing = ConnectionDuplicateCandidate( + id: UUID(), name: "Existing", host: "127.0.0.1", port: 3306, + database: "test", username: "root", redisDatabase: nil + ) + let envelope = makeEnvelope(connections: [makeConnection()]) + + let preview = ConnectionImportAnalyzer.analyze( + envelope, existingConnections: [existing], registeredTypeIds: allTypes, fileExists: { _ in true } + ) + + guard case .duplicate(let existingId, let existingName) = preview.items[0].status else { + return XCTFail("expected duplicate") + } + XCTAssertEqual(existingId, existing.id) + XCTAssertEqual(existingName, "Existing") + } + + func testDifferentUsernameIsNotDuplicate() { + let existing = ConnectionDuplicateCandidate( + id: UUID(), name: "Existing", host: "127.0.0.1", port: 3306, + database: "test", username: "someoneelse", redisDatabase: nil + ) + let preview = ConnectionImportAnalyzer.analyze( + makeEnvelope(connections: [makeConnection()]), + existingConnections: [existing], registeredTypeIds: allTypes, fileExists: { _ in true } + ) + guard case .ready = preview.items[0].status else { + return XCTFail("expected ready") + } + } + + func testUnknownTypeProducesWarning() { + let connection = makeConnection(type: "Cassandra") + let preview = ConnectionImportAnalyzer.analyze( + makeEnvelope(connections: [connection]), + existingConnections: [], registeredTypeIds: allTypes, fileExists: { _ in true } + ) + guard case .warnings(let messages) = preview.items[0].status else { + return XCTFail("expected warnings") + } + XCTAssertTrue(messages.contains { $0.contains("Cassandra") }) + } + + func testMissingSSHKeyProducesWarning() { + let connection = ExportableConnection( + name: "x", host: "h", port: 22, database: "d", username: "u", type: "MySQL", + sshConfig: ExportableSSHConfig( + enabled: true, host: "bastion", port: nil, username: "u", + authMethod: "privateKey", privateKeyPath: "~/.ssh/missing_key", + agentSocketPath: "", jumpHosts: nil, + totpMode: nil, totpAlgorithm: nil, totpDigits: nil, totpPeriod: nil + ), + sslConfig: nil, color: nil, tagName: nil, groupName: nil, sshProfileId: nil, + safeModeLevel: nil, aiPolicy: nil, additionalFields: nil, + redisDatabase: nil, startupCommands: nil, localOnly: nil + ) + let preview = ConnectionImportAnalyzer.analyze( + makeEnvelope(connections: [connection]), + existingConnections: [], registeredTypeIds: allTypes, fileExists: { _ in false } + ) + guard case .warnings(let messages) = preview.items[0].status else { + return XCTFail("expected warnings") + } + XCTAssertTrue(messages.contains { $0.contains("SSH private key") }) + } + + func testRedisDatabaseDistinguishesDuplicates() { + let existing = ConnectionDuplicateCandidate( + id: UUID(), name: "Redis 0", host: "127.0.0.1", port: 6379, + database: "", username: "", redisDatabase: 0 + ) + let connection = ExportableConnection( + name: "Redis 1", host: "127.0.0.1", port: 6379, database: "", username: "", type: "Redis", + sshConfig: nil, sslConfig: nil, color: nil, tagName: nil, groupName: nil, sshProfileId: nil, + safeModeLevel: nil, aiPolicy: nil, additionalFields: nil, + redisDatabase: 1, startupCommands: nil, localOnly: nil + ) + let preview = ConnectionImportAnalyzer.analyze( + makeEnvelope(connections: [connection]), + existingConnections: [existing], registeredTypeIds: allTypes, fileExists: { _ in true } + ) + guard case .ready = preview.items[0].status else { + return XCTFail("expected ready: db index 1 differs from 0") + } + } +} diff --git a/Packages/TableProCore/Tests/TableProImportTests/ConnectionImportDecoderTests.swift b/Packages/TableProCore/Tests/TableProImportTests/ConnectionImportDecoderTests.swift new file mode 100644 index 000000000..f53b4e6dc --- /dev/null +++ b/Packages/TableProCore/Tests/TableProImportTests/ConnectionImportDecoderTests.swift @@ -0,0 +1,110 @@ +import XCTest +@testable import TableProImport + +final class ConnectionImportDecoderTests: XCTestCase { + func testEnvelopeRoundTripPreservesConnectionFields() throws { + let connection = ExportableConnection( + name: "Prod DB", + host: "db.example.com", + port: 5432, + database: "app", + username: "admin", + type: "PostgreSQL", + sshConfig: ExportableSSHConfig( + enabled: true, host: "bastion", port: 2222, username: "deploy", + authMethod: "privateKey", privateKeyPath: "~/.ssh/id_ed25519", + agentSocketPath: "", jumpHosts: nil, + totpMode: nil, totpAlgorithm: nil, totpDigits: nil, totpPeriod: nil + ), + sslConfig: ExportableSSLConfig(mode: "require", caCertificatePath: nil, clientCertificatePath: nil, clientKeyPath: nil), + color: "Blue", + tagName: "production", + groupName: "Work", + sshProfileId: nil, + safeModeLevel: nil, + aiPolicy: nil, + additionalFields: ["schema": "public"], + redisDatabase: nil, + startupCommands: nil, + localOnly: nil + ) + let envelope = ConnectionExportEnvelope( + formatVersion: 1, exportedAt: Date(timeIntervalSince1970: 1_700_000_000), appVersion: "1.0", + connections: [connection], groups: nil, tags: nil, credentials: nil + ) + + let data = try ConnectionImportDecoder.encode(envelope) + let decoded = try ConnectionImportDecoder.decodeData(data) + + let result = try XCTUnwrap(decoded.connections.first) + XCTAssertEqual(result.name, "Prod DB") + XCTAssertEqual(result.host, "db.example.com") + XCTAssertEqual(result.port, 5432) + XCTAssertEqual(result.type, "PostgreSQL") + XCTAssertEqual(result.sshConfig?.host, "bastion") + XCTAssertEqual(result.sshConfig?.port, 2222) + XCTAssertEqual(result.sslConfig?.mode, "require") + XCTAssertEqual(result.tagName, "production") + XCTAssertEqual(result.additionalFields?["schema"], "public") + } + + func testDecodeStripsBlockedAdditionalFields() throws { + let connection = makeConnection(additionalFields: ["schema": "public", "preConnectScript": "rm -rf /"]) + let envelope = makeEnvelope(connections: [connection]) + let data = try ConnectionImportDecoder.encode(envelope) + + let decoded = try ConnectionImportDecoder.decodeData(data) + let fields = try XCTUnwrap(decoded.connections.first?.additionalFields) + XCTAssertEqual(fields["schema"], "public") + XCTAssertNil(fields["preConnectScript"]) + } + + func testFutureFormatVersionThrows() throws { + let envelope = ConnectionExportEnvelope( + formatVersion: 999, exportedAt: Date(), appVersion: "1.0", + connections: [], groups: nil, tags: nil, credentials: nil + ) + let data = try ConnectionImportDecoder.encode(envelope) + XCTAssertThrowsError(try ConnectionImportDecoder.decodeData(data)) + } + + func testEncryptedRoundTripThroughDecoder() throws { + let envelope = makeEnvelope(connections: [makeConnection()]) + let json = try ConnectionImportDecoder.encode(envelope) + let encrypted = try ConnectionExportCrypto.encrypt(data: json, passphrase: "hunter2") + + let decoded = try ConnectionImportDecoder.decodeEncryptedData(encrypted, passphrase: "hunter2") + XCTAssertEqual(decoded.connections.count, 1) + } + + func testPathPortabilityRoundTrips() { + let original = NSHomeDirectory() + "/.ssh/id_rsa" + let contracted = PathPortability.contractHome(original) + XCTAssertTrue(contracted.hasPrefix("~/")) + XCTAssertEqual(PathPortability.expandHome(contracted), original) + } +} + +func makeConnection( + name: String = "Local", + host: String = "127.0.0.1", + port: Int = 3306, + database: String = "test", + username: String = "root", + type: String = "MySQL", + additionalFields: [String: String]? = nil +) -> ExportableConnection { + ExportableConnection( + name: name, host: host, port: port, database: database, username: username, type: type, + sshConfig: nil, sslConfig: nil, color: nil, tagName: nil, groupName: nil, + sshProfileId: nil, safeModeLevel: nil, aiPolicy: nil, + additionalFields: additionalFields, redisDatabase: nil, startupCommands: nil, localOnly: nil + ) +} + +func makeEnvelope(connections: [ExportableConnection]) -> ConnectionExportEnvelope { + ConnectionExportEnvelope( + formatVersion: 1, exportedAt: Date(timeIntervalSince1970: 0), appVersion: "1.0", + connections: connections, groups: nil, tags: nil, credentials: nil + ) +} diff --git a/TablePro.xcodeproj/project.pbxproj b/TablePro.xcodeproj/project.pbxproj index 729774a88..447d7fc7e 100644 --- a/TablePro.xcodeproj/project.pbxproj +++ b/TablePro.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 5A1MPORT000000000000A011 /* TableProImport in Frameworks */ = {isa = PBXBuildFile; productRef = 5A1MPORT000000000000A010 /* TableProImport */; }; 16C74CC07CC30A38ADE1663E /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; 267A5C6ECC62401598389396 /* SnowflakeDDLGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53DF2F4A15214F2AA1DE95CF /* SnowflakeDDLGenerator.swift */; }; 33E6FF2997594F41AA80EEC8 /* SnowflakeConnectionRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE8D74A27EE24B89A7DC79F9 /* SnowflakeConnectionRegistry.swift */; }; @@ -819,6 +820,7 @@ buildActionMask = 2147483647; files = ( 5A7E78A02F95F02A00EEF236 /* TableProAnalytics in Frameworks */, + 5A1MPORT000000000000A011 /* TableProImport in Frameworks */, 5ACE00012F4F000000000004 /* CodeEditSourceEditor in Frameworks */, 5ACE00012F4F000000000005 /* CodeEditLanguages in Frameworks */, 5A32BBFB2F9D5EAB00BAEB5F /* X509 in Frameworks */, @@ -1278,6 +1280,7 @@ 5ACE00012F4F000000000009 /* Sparkle */, 5ACE00012F4F000000000010 /* TableProAnalytics */, 5A32BBFA2F9D5EAB00BAEB5F /* X509 */, + 5A1MPORT000000000000A010 /* TableProImport */, ); productName = TablePro; productReference = 5A1091C72EF17EDC0055EA7C /* TablePro.app */; @@ -4821,6 +4824,11 @@ package = 5A0000012F4F000000000102 /* XCLocalSwiftPackageReference "Packages/TableProCore" */; productName = TableProMSSQLCore; }; + 5A1MPORT000000000000A010 /* TableProImport */ = { + isa = XCSwiftPackageProductDependency; + package = 5A0000012F4F000000000102 /* XCLocalSwiftPackageReference "Packages/TableProCore" */; + productName = TableProImport; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 5A1091BF2EF17EDC0055EA7C /* Project object */; diff --git a/TablePro/Core/Services/Export/ConnectionExportService.swift b/TablePro/Core/Services/Export/ConnectionExportService.swift index 401a68430..db2ac41bc 100644 --- a/TablePro/Core/Services/Export/ConnectionExportService.swift +++ b/TablePro/Core/Services/Export/ConnectionExportService.swift @@ -6,68 +6,11 @@ import Combine import Foundation import os +import TableProImport import TableProPluginKit import UniformTypeIdentifiers -// MARK: - Export Error - -enum ConnectionExportError: LocalizedError { - case encodingFailed - case fileWriteFailed(String) - case fileReadFailed(String) - case invalidFormat - case unsupportedVersion(Int) - case decodingFailed(String) - case requiresPassphrase - case decryptionFailed(String) - - var errorDescription: String? { - switch self { - case .encodingFailed: - return String(localized: "Failed to encode connection data") - case .fileWriteFailed(let path): - return String(format: String(localized: "Failed to write file: %@"), path) - case .fileReadFailed(let path): - return String(format: String(localized: "Failed to read file: %@"), path) - case .invalidFormat: - return String(localized: "This file is not a valid TablePro export") - case .unsupportedVersion(let version): - return String(format: String(localized: "This file requires a newer version of TablePro (format version %d)"), version) - case .decodingFailed(let detail): - return String(format: String(localized: "Failed to parse connection file: %@"), detail) - case .requiresPassphrase: - return String(localized: "This file is encrypted and requires a passphrase") - case .decryptionFailed(let detail): - return String(format: String(localized: "Decryption failed: %@"), detail) - } - } -} - -// MARK: - Import Preview Types - -enum ImportItemStatus { - case ready - case duplicate(existing: DatabaseConnection) - case warnings([String]) -} - -struct ImportItem: Identifiable { - let id = UUID() - let connection: ExportableConnection - let status: ImportItemStatus -} - -enum ImportResolution: Hashable { - case importNew - case skip - case replace(existingId: UUID) - case importAsCopy -} - -struct ConnectionImportPreview { - let envelope: ConnectionExportEnvelope - let items: [ImportItem] -} +// MARK: - Prepared Import enum PreparedImportOperation { case add(DatabaseConnection) @@ -356,17 +299,7 @@ enum ConnectionExportService { throw ConnectionExportError.requiresPassphrase } - return try decodeData(data) - } - - nonisolated static func decodeEncryptedData(_ data: Data, passphrase: String) throws -> ConnectionExportEnvelope { - let decryptedData: Data - do { - decryptedData = try ConnectionExportCrypto.decrypt(data: data, passphrase: passphrase) - } catch { - throw ConnectionExportError.decryptionFailed(error.localizedDescription) - } - return try decodeData(decryptedData) + return try ConnectionImportDecoder.decodeData(data) } static func restoreCredentials(from envelope: ConnectionExportEnvelope, connectionIdMap: [Int: UUID]) { @@ -403,33 +336,6 @@ enum ConnectionExportService { logger.info("Restored credentials for \(restoredCount) of \(credentials.count) connections") } - /// Decode an envelope from raw JSON data. Can be called from any thread. - nonisolated static func decodeData(_ data: Data) throws -> ConnectionExportEnvelope { - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 - - let envelope: ConnectionExportEnvelope - do { - envelope = try decoder.decode(ConnectionExportEnvelope.self, from: data) - } catch { - throw ConnectionExportError.decodingFailed(error.localizedDescription) - } - - guard envelope.formatVersion <= currentFormatVersion else { - throw ConnectionExportError.unsupportedVersion(envelope.formatVersion) - } - - return ConnectionExportEnvelope( - formatVersion: envelope.formatVersion, - exportedAt: envelope.exportedAt, - appVersion: envelope.appVersion, - connections: envelope.connections.map { $0.sanitizedForImport() }, - groups: envelope.groups, - tags: envelope.tags, - credentials: envelope.credentials - ) - } - static func analyzeImport(_ envelope: ConnectionExportEnvelope) -> ConnectionImportPreview { analyzeImport( envelope, @@ -445,74 +351,12 @@ enum ConnectionExportService { registeredTypeIds: Set, fileExists: (String) -> Bool ) -> ConnectionImportPreview { - var duplicateMap: [ConnectionImportDuplicateKey: DatabaseConnection] = [:] - for existing in existingConnections { - let key = duplicateKey(for: existing) - if duplicateMap[key] == nil { - duplicateMap[key] = existing - } - } - - let items: [ImportItem] = envelope.connections.map { exportable in - let duplicate = duplicateMap[duplicateKey(for: exportable)] - - if let duplicate { - return ImportItem(connection: exportable, status: .duplicate(existing: duplicate)) - } - - var warnings: [String] = [] - - // SSH key path check - if let ssh = exportable.sshConfig { - let keyPath = PathPortability.expandHome(ssh.privateKeyPath) - if !keyPath.isEmpty, !fileExists(keyPath) { - warnings.append(String( - format: String(localized: "SSH private key not found: %@"), - ssh.privateKeyPath - )) - } - for jump in ssh.jumpHosts ?? [] { - let jumpKeyPath = PathPortability.expandHome(jump.privateKeyPath) - if !jumpKeyPath.isEmpty, !fileExists(jumpKeyPath) { - warnings.append(String( - format: String(localized: "Jump host key not found: %@"), - jump.privateKeyPath - )) - } - } - } - - // SSL cert paths check - if let ssl = exportable.sslConfig { - for (path, format) in [ - (ssl.caCertificatePath, String(localized: "CA certificate not found: %@")), - (ssl.clientCertificatePath, String(localized: "Client certificate not found: %@")), - (ssl.clientKeyPath, String(localized: "Client key not found: %@")) - ] { - if let path, !path.isEmpty { - let expanded = PathPortability.expandHome(path) - if !fileExists(expanded) { - warnings.append(String(format: format, path)) - } - } - } - } - - if !registeredTypeIds.contains(exportable.type) { - warnings.append(String( - format: String(localized: "Database type \"%@\" is not installed"), - exportable.type - )) - } - - if !warnings.isEmpty { - return ImportItem(connection: exportable, status: .warnings(warnings)) - } - - return ImportItem(connection: exportable, status: .ready) - } - - return ConnectionImportPreview(envelope: envelope, items: items) + ConnectionImportAnalyzer.analyze( + envelope, + existingConnections: existingConnections.map(duplicateCandidate(for:)), + registeredTypeIds: registeredTypeIds, + fileExists: fileExists + ) } struct ImportResult { @@ -890,43 +734,18 @@ enum ConnectionExportService { } } - private struct ConnectionImportDuplicateKey: Hashable { - let components: [String] - } - - private static func duplicateKey(for connection: ExportableConnection) -> ConnectionImportDuplicateKey { - ConnectionImportDuplicateKey( - components: [ - normalizedLookupKey(connection.host), - String(connection.port), - effectiveDatabaseKey(database: connection.database, redisDatabase: connection.redisDatabase), - normalizedLookupKey(connection.username) - ] + private static func duplicateCandidate(for connection: DatabaseConnection) -> ConnectionDuplicateCandidate { + ConnectionDuplicateCandidate( + id: connection.id, + name: connection.name, + host: connection.host, + port: connection.port, + database: connection.database, + username: connection.username, + redisDatabase: connection.redisDatabase ) } - private static func duplicateKey(for connection: DatabaseConnection) -> ConnectionImportDuplicateKey { - ConnectionImportDuplicateKey( - components: [ - normalizedLookupKey(connection.host), - String(connection.port), - effectiveDatabaseKey(database: connection.database, redisDatabase: connection.redisDatabase), - normalizedLookupKey(connection.username) - ] - ) - } - - private static func effectiveDatabaseKey(database: String?, redisDatabase: Int?) -> String { - let normalized = normalizedLookupKey(database) - if !normalized.isEmpty { - return normalized - } - if let redisDatabase { - return String(redisDatabase) - } - return "" - } - private static func tagIdsByName() -> [String: UUID] { var idsByName: [String: UUID] = [:] for tag in TagStorage.shared.loadTags() { diff --git a/TablePro/Core/Services/Export/ForeignApp/BeekeeperStudioImporter.swift b/TablePro/Core/Services/Export/ForeignApp/BeekeeperStudioImporter.swift index f8e38f94e..929d2dac0 100644 --- a/TablePro/Core/Services/Export/ForeignApp/BeekeeperStudioImporter.swift +++ b/TablePro/Core/Services/Export/ForeignApp/BeekeeperStudioImporter.swift @@ -17,6 +17,7 @@ import AppKit import Foundation import os import SQLite3 +import TableProImport import TableProPluginKit struct BeekeeperStudioImporter: ForeignAppImporter { diff --git a/TablePro/Core/Services/Export/ForeignApp/DBeaverImporter.swift b/TablePro/Core/Services/Export/ForeignApp/DBeaverImporter.swift index c78377018..7dece73c1 100644 --- a/TablePro/Core/Services/Export/ForeignApp/DBeaverImporter.swift +++ b/TablePro/Core/Services/Export/ForeignApp/DBeaverImporter.swift @@ -7,6 +7,7 @@ import AppKit import CommonCrypto import Foundation import os +import TableProImport import TableProPluginKit struct DBeaverImporter: ForeignAppImporter { diff --git a/TablePro/Core/Services/Export/ForeignApp/DataGripImporter.swift b/TablePro/Core/Services/Export/ForeignApp/DataGripImporter.swift index a8a553bf2..2be1b9a13 100644 --- a/TablePro/Core/Services/Export/ForeignApp/DataGripImporter.swift +++ b/TablePro/Core/Services/Export/ForeignApp/DataGripImporter.swift @@ -6,6 +6,7 @@ import AppKit import Foundation import os +import TableProImport import TableProPluginKit struct DataGripImporter: ForeignAppImporter { diff --git a/TablePro/Core/Services/Export/ForeignApp/ForeignAppImporter.swift b/TablePro/Core/Services/Export/ForeignApp/ForeignAppImporter.swift index 91421ef58..1c552d759 100644 --- a/TablePro/Core/Services/Export/ForeignApp/ForeignAppImporter.swift +++ b/TablePro/Core/Services/Export/ForeignApp/ForeignAppImporter.swift @@ -7,6 +7,7 @@ import AppKit import Foundation import os import Security +import TableProImport import UniformTypeIdentifiers // MARK: - Protocol diff --git a/TablePro/Core/Services/Export/ForeignApp/Navicat/NavicatImporter.swift b/TablePro/Core/Services/Export/ForeignApp/Navicat/NavicatImporter.swift index c9d80fe17..560f53f32 100644 --- a/TablePro/Core/Services/Export/ForeignApp/Navicat/NavicatImporter.swift +++ b/TablePro/Core/Services/Export/ForeignApp/Navicat/NavicatImporter.swift @@ -5,6 +5,7 @@ import Foundation import os +import TableProImport import TableProPluginKit import UniformTypeIdentifiers diff --git a/TablePro/Core/Services/Export/ForeignApp/SequelAceImporter.swift b/TablePro/Core/Services/Export/ForeignApp/SequelAceImporter.swift index 4600fc88a..ef9e0aa41 100644 --- a/TablePro/Core/Services/Export/ForeignApp/SequelAceImporter.swift +++ b/TablePro/Core/Services/Export/ForeignApp/SequelAceImporter.swift @@ -5,6 +5,7 @@ import Foundation import os +import TableProImport struct SequelAceImporter: ForeignAppImporter { private static let logger = Logger(subsystem: "com.TablePro", category: "SequelAceImporter") diff --git a/TablePro/Core/Services/Export/ForeignApp/TablePlusImporter.swift b/TablePro/Core/Services/Export/ForeignApp/TablePlusImporter.swift index 3d803b49f..13d25830e 100644 --- a/TablePro/Core/Services/Export/ForeignApp/TablePlusImporter.swift +++ b/TablePro/Core/Services/Export/ForeignApp/TablePlusImporter.swift @@ -6,6 +6,7 @@ import AppKit import Foundation import os +import TableProImport import TableProPluginKit struct TablePlusImporter: ForeignAppImporter { diff --git a/TablePro/Core/Services/Export/LinkedFolderWatcher.swift b/TablePro/Core/Services/Export/LinkedFolderWatcher.swift index 636cc14de..4e1976174 100644 --- a/TablePro/Core/Services/Export/LinkedFolderWatcher.swift +++ b/TablePro/Core/Services/Export/LinkedFolderWatcher.swift @@ -10,6 +10,7 @@ import Combine import CryptoKit import Foundation import os +import TableProImport struct LinkedConnection: Identifiable { let id: UUID @@ -105,7 +106,7 @@ final class LinkedFolderWatcher { if ConnectionExportCrypto.isEncrypted(data) { continue } - guard let envelope = try? ConnectionExportService.decodeData(data) else { continue } + guard let envelope = try? ConnectionImportDecoder.decodeData(data) else { continue } for exportable in envelope.connections { let stableId = stableId(folderId: folder.id, connection: exportable) diff --git a/TablePro/Core/Services/Infrastructure/DeeplinkParser.swift b/TablePro/Core/Services/Infrastructure/DeeplinkParser.swift index 036bbaed4..2a69a0c2b 100644 --- a/TablePro/Core/Services/Infrastructure/DeeplinkParser.swift +++ b/TablePro/Core/Services/Infrastructure/DeeplinkParser.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProImport import TableProPluginKit internal enum DeeplinkError: Error, LocalizedError, Equatable { diff --git a/TablePro/Core/Services/Infrastructure/LaunchIntent.swift b/TablePro/Core/Services/Infrastructure/LaunchIntent.swift index 8a8d5905d..be5f3aec3 100644 --- a/TablePro/Core/Services/Infrastructure/LaunchIntent.swift +++ b/TablePro/Core/Services/Infrastructure/LaunchIntent.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProImport internal enum LaunchIntent: @unchecked Sendable { case openConnection(UUID) diff --git a/TablePro/Core/Services/Infrastructure/WelcomeRouter.swift b/TablePro/Core/Services/Infrastructure/WelcomeRouter.swift index 7906be595..0c5f8af60 100644 --- a/TablePro/Core/Services/Infrastructure/WelcomeRouter.swift +++ b/TablePro/Core/Services/Infrastructure/WelcomeRouter.swift @@ -7,6 +7,7 @@ import AppKit import Combine import Foundation import Observation +import TableProImport internal struct PendingConnectionError { let connection: DatabaseConnection diff --git a/TablePro/Core/Storage/LinkedFolderStorage.swift b/TablePro/Core/Storage/LinkedFolderStorage.swift index 98fc78d41..49dc1851f 100644 --- a/TablePro/Core/Storage/LinkedFolderStorage.swift +++ b/TablePro/Core/Storage/LinkedFolderStorage.swift @@ -7,6 +7,7 @@ import Foundation import os +import TableProImport struct LinkedFolder: Codable, Identifiable, Hashable { let id: UUID diff --git a/TablePro/Core/Sync/SyncRecordMapper.swift b/TablePro/Core/Sync/SyncRecordMapper.swift index 8fb182262..26723c76e 100644 --- a/TablePro/Core/Sync/SyncRecordMapper.swift +++ b/TablePro/Core/Sync/SyncRecordMapper.swift @@ -8,6 +8,7 @@ import CloudKit import Foundation import os +import TableProImport import TableProPluginKit /// CloudKit record types for sync diff --git a/TablePro/Models/Connection/ConnectionExport.swift b/TablePro/Models/Connection/ConnectionExport.swift index c3c46d550..1bf87d005 100644 --- a/TablePro/Models/Connection/ConnectionExport.swift +++ b/TablePro/Models/Connection/ConnectionExport.swift @@ -4,64 +4,9 @@ // import Foundation -import UniformTypeIdentifiers +import TableProImport -// MARK: - UTType - -extension UTType { - // swiftlint:disable:next force_unwrapping - static let tableproConnectionShare = UTType("com.tablepro.connection-share")! -} - -// MARK: - Export Envelope - -struct ConnectionExportEnvelope: Codable { - let formatVersion: Int - let exportedAt: Date - let appVersion: String - let connections: [ExportableConnection] - let groups: [ExportableGroup]? - let tags: [ExportableTag]? - let credentials: [String: ExportableCredentials]? // keyed by connection index "0", "1", ... -} - -// MARK: - Exportable Connection - -struct ExportableConnection: Codable { - let name: String - let host: String - let port: Int - let database: String - let username: String - let type: String - let sshConfig: ExportableSSHConfig? - let sslConfig: ExportableSSLConfig? - let color: String? - let tagName: String? - let groupName: String? - let sshProfileId: String? - let safeModeLevel: String? - let aiPolicy: String? - let additionalFields: [String: String]? - let redisDatabase: Int? - let startupCommands: String? - let localOnly: Bool? - - func renamed(to newName: String) -> ExportableConnection { - ExportableConnection( - name: newName, host: host, port: port, database: database, - username: username, type: type, sshConfig: sshConfig, - sslConfig: sslConfig, color: color, tagName: tagName, - groupName: groupName, sshProfileId: sshProfileId, - safeModeLevel: safeModeLevel, aiPolicy: aiPolicy, - additionalFields: additionalFields, redisDatabase: redisDatabase, - startupCommands: startupCommands, localOnly: localOnly - ) - } - - /// One-line subtitle for connection rows. File-based databases - /// (SQLite, DuckDB) show the database path; everything else shows - /// `host:port`. +extension ExportableConnection { var displaySubtitle: String { if type == "SQLite" || type == "DuckDB" { return database.isEmpty @@ -71,95 +16,3 @@ struct ExportableConnection: Codable { return "\(host):\(port)" } } - -extension ExportableConnection { - static let importBlockedAdditionalFieldKeys: Set = ["preConnectScript"] - - func sanitizedForImport() -> ExportableConnection { - guard let additionalFields else { return self } - let allowed = additionalFields.filter { !Self.importBlockedAdditionalFieldKeys.contains($0.key) } - guard allowed.count != additionalFields.count else { return self } - return ExportableConnection( - name: name, host: host, port: port, database: database, - username: username, type: type, sshConfig: sshConfig, - sslConfig: sslConfig, color: color, tagName: tagName, - groupName: groupName, sshProfileId: sshProfileId, - safeModeLevel: safeModeLevel, aiPolicy: aiPolicy, - additionalFields: allowed.isEmpty ? nil : allowed, redisDatabase: redisDatabase, - startupCommands: startupCommands, localOnly: localOnly - ) - } -} - -// MARK: - SSH Config - -struct ExportableSSHConfig: Codable { - let enabled: Bool - let host: String - let port: Int? - let username: String - let authMethod: String - let privateKeyPath: String - let agentSocketPath: String - let jumpHosts: [ExportableJumpHost]? - let totpMode: String? - let totpAlgorithm: String? - let totpDigits: Int? - let totpPeriod: Int? -} - -struct ExportableJumpHost: Codable { - let host: String - let port: Int? - let username: String - let authMethod: String - let privateKeyPath: String -} - -// MARK: - SSL Config - -struct ExportableSSLConfig: Codable { - let mode: String - let caCertificatePath: String? - let clientCertificatePath: String? - let clientKeyPath: String? -} - -// MARK: - Group & Tag - -struct ExportableGroup: Codable { - let name: String - let color: String? -} - -struct ExportableTag: Codable { - let name: String - let color: String? -} - -// MARK: - Credentials (encrypted export only) - -struct ExportableCredentials: Codable { - let password: String? - let sshPassword: String? - let keyPassphrase: String? - let sslClientKeyPassphrase: String? - let totpSecret: String? - let pluginSecureFields: [String: String]? -} - -// MARK: - Path Portability - -enum PathPortability { - static func contractHome(_ path: String) -> String { - guard !path.isEmpty else { return path } - let home = NSHomeDirectory() - guard path.hasPrefix(home) else { return path } - return "~" + path.dropFirst(home.count) - } - - static func expandHome(_ path: String) -> String { - guard path.hasPrefix("~/") else { return path } - return NSHomeDirectory() + String(path.dropFirst(1)) - } -} diff --git a/TablePro/Models/Query/LinkedSQLFolder.swift b/TablePro/Models/Query/LinkedSQLFolder.swift index 84ad1e901..767415a20 100644 --- a/TablePro/Models/Query/LinkedSQLFolder.swift +++ b/TablePro/Models/Query/LinkedSQLFolder.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProImport internal struct LinkedSQLFolder: Codable, Identifiable, Hashable { let id: UUID diff --git a/TablePro/ViewModels/WelcomeViewModel.swift b/TablePro/ViewModels/WelcomeViewModel.swift index b95cf9610..3c699cf56 100644 --- a/TablePro/ViewModels/WelcomeViewModel.swift +++ b/TablePro/ViewModels/WelcomeViewModel.swift @@ -7,6 +7,7 @@ import AppKit import Combine import os import SwiftUI +import TableProImport import TableProPluginKit enum WelcomeActiveSheet: Identifiable { diff --git a/TablePro/Views/Connection/ConnectionExportOptionsSheet.swift b/TablePro/Views/Connection/ConnectionExportOptionsSheet.swift index 0cee45b7f..97bd30792 100644 --- a/TablePro/Views/Connection/ConnectionExportOptionsSheet.swift +++ b/TablePro/Views/Connection/ConnectionExportOptionsSheet.swift @@ -6,6 +6,7 @@ // import SwiftUI +import TableProImport import UniformTypeIdentifiers struct ConnectionExportOptionsSheet: View { diff --git a/TablePro/Views/Connection/ConnectionImportSheet.swift b/TablePro/Views/Connection/ConnectionImportSheet.swift index e7d7f190f..ce96acb2e 100644 --- a/TablePro/Views/Connection/ConnectionImportSheet.swift +++ b/TablePro/Views/Connection/ConnectionImportSheet.swift @@ -6,6 +6,7 @@ // import SwiftUI +import TableProImport import UniformTypeIdentifiers struct ConnectionImportSheet: View { @@ -201,7 +202,7 @@ struct ConnectionImportSheet: View { return } - let envelope = try ConnectionExportService.decodeData(data) + let envelope = try ConnectionImportDecoder.decodeData(data) let result = await ConnectionExportService.analyzeImport(envelope) await MainActor.run { preview = result @@ -224,7 +225,7 @@ struct ConnectionImportSheet: View { Task.detached(priority: .userInitiated) { do { - let envelope = try ConnectionExportService.decodeEncryptedData(data, passphrase: currentPassphrase) + let envelope = try ConnectionImportDecoder.decodeEncryptedData(data, passphrase: currentPassphrase) let result = await ConnectionExportService.analyzeImport(envelope) await MainActor.run { passphraseError = nil diff --git a/TablePro/Views/Connection/DeeplinkImportSheet.swift b/TablePro/Views/Connection/DeeplinkImportSheet.swift index 10c52058c..7a50acdf0 100644 --- a/TablePro/Views/Connection/DeeplinkImportSheet.swift +++ b/TablePro/Views/Connection/DeeplinkImportSheet.swift @@ -4,6 +4,7 @@ // import SwiftUI +import TableProImport struct DeeplinkImportSheet: View { let connection: ExportableConnection diff --git a/TablePro/Views/Connection/ImportFromApp/ConnectionImportPreviewList.swift b/TablePro/Views/Connection/ImportFromApp/ConnectionImportPreviewList.swift index 647e72af9..a49e2f57b 100644 --- a/TablePro/Views/Connection/ImportFromApp/ConnectionImportPreviewList.swift +++ b/TablePro/Views/Connection/ImportFromApp/ConnectionImportPreviewList.swift @@ -4,6 +4,7 @@ // import SwiftUI +import TableProImport struct ConnectionImportPreviewList: View { let items: [ImportItem] @@ -73,8 +74,8 @@ struct ConnectionImportPreviewList: View { set: { duplicateResolutions[item.id] = $0 } )) { Text(String(localized: "As Copy")).tag(ImportResolution.importAsCopy) - if case .duplicate(let existing) = item.status { - Text(String(localized: "Replace")).tag(ImportResolution.replace(existingId: existing.id)) + if case .duplicate(let existingId, _) = item.status { + Text(String(localized: "Replace")).tag(ImportResolution.replace(existingId: existingId)) } Text(String(localized: "Skip")).tag(ImportResolution.skip) } diff --git a/TablePro/Views/Connection/ImportFromApp/ImportFromAppPreviewStep.swift b/TablePro/Views/Connection/ImportFromApp/ImportFromAppPreviewStep.swift index d6cac4f59..64b65bfb2 100644 --- a/TablePro/Views/Connection/ImportFromApp/ImportFromAppPreviewStep.swift +++ b/TablePro/Views/Connection/ImportFromApp/ImportFromAppPreviewStep.swift @@ -4,6 +4,7 @@ // import SwiftUI +import TableProImport struct ImportFromAppPreviewStep: View { let preview: ConnectionImportPreview diff --git a/TablePro/Views/Connection/ImportFromApp/ImportFromAppSheet.swift b/TablePro/Views/Connection/ImportFromApp/ImportFromAppSheet.swift index aeb222b26..2642f0ffd 100644 --- a/TablePro/Views/Connection/ImportFromApp/ImportFromAppSheet.swift +++ b/TablePro/Views/Connection/ImportFromApp/ImportFromAppSheet.swift @@ -5,6 +5,7 @@ import AppKit import SwiftUI +import TableProImport struct ImportFromAppSheet: View { var onImported: ((Int) -> Void)? diff --git a/TablePro/Views/Connection/WelcomeWindowView.swift b/TablePro/Views/Connection/WelcomeWindowView.swift index 1ad9c439f..9096faab0 100644 --- a/TablePro/Views/Connection/WelcomeWindowView.swift +++ b/TablePro/Views/Connection/WelcomeWindowView.swift @@ -5,6 +5,7 @@ import os import SwiftUI +import TableProImport import UniformTypeIdentifiers struct WelcomeWindowView: View { diff --git a/TablePro/Views/Settings/LinkedFoldersSection.swift b/TablePro/Views/Settings/LinkedFoldersSection.swift index fefed00fb..182d9bfaa 100644 --- a/TablePro/Views/Settings/LinkedFoldersSection.swift +++ b/TablePro/Views/Settings/LinkedFoldersSection.swift @@ -8,6 +8,7 @@ import AppKit import SwiftUI +import TableProImport struct LinkedFoldersSection: View { @State private var folders: [LinkedFolder] = LinkedFolderStorage.shared.loadFolders() diff --git a/TablePro/Views/Sidebar/FavoritesTabView.swift b/TablePro/Views/Sidebar/FavoritesTabView.swift index 5a8bb8089..5d2901eb3 100644 --- a/TablePro/Views/Sidebar/FavoritesTabView.swift +++ b/TablePro/Views/Sidebar/FavoritesTabView.swift @@ -1,4 +1,5 @@ import SwiftUI +import TableProImport internal struct FavoritesTabView: View { @State private var viewModel: FavoritesSidebarViewModel diff --git a/TableProMobile/TableProMobile.xcodeproj/project.pbxproj b/TableProMobile/TableProMobile.xcodeproj/project.pbxproj index e171d2d77..5139d8490 100644 --- a/TableProMobile/TableProMobile.xcodeproj/project.pbxproj +++ b/TableProMobile/TableProMobile.xcodeproj/project.pbxproj @@ -22,6 +22,7 @@ 5AA313542F7EC188008EBA97 /* LibSSH2.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5AA313532F7EC188008EBA97 /* LibSSH2.xcframework */; }; 5AB9F3E92F7C1D03001F3337 /* TableProDatabase in Frameworks */ = {isa = PBXBuildFile; productRef = 5AB9F3E82F7C1D03001F3337 /* TableProDatabase */; }; 5AB9F3EB2F7C1D03001F3337 /* TableProModels in Frameworks */ = {isa = PBXBuildFile; productRef = 5AB9F3EA2F7C1D03001F3337 /* TableProModels */; }; + 5A1MPORT00000000000000I1 /* TableProImport in Frameworks */ = {isa = PBXBuildFile; productRef = 5A1MPORT00000000000000I0 /* TableProImport */; }; 5AB9F3ED2F7C1D03001F3337 /* TableProPluginKit in Frameworks */ = {isa = PBXBuildFile; productRef = 5AB9F3EC2F7C1D03001F3337 /* TableProPluginKit */; }; 5AB9F3EF2F7C1D03001F3337 /* TableProQuery in Frameworks */ = {isa = PBXBuildFile; productRef = 5AB9F3EE2F7C1D03001F3337 /* TableProQuery */; }; 5AD1F1B62FB4455700296783 /* TableProMSSQLCore in Frameworks */ = {isa = PBXBuildFile; productRef = 5AD1F1B52FB4455700296783 /* TableProMSSQLCore */; }; @@ -617,6 +618,7 @@ 5AD1F1B62FB4455700296783 /* TableProMSSQLCore in Frameworks */, 5A87EEED2F7F893000D028D0 /* TableProSync in Frameworks */, 5AB9F3EB2F7C1D03001F3337 /* TableProModels in Frameworks */, + 5A1MPORT00000000000000I1 /* TableProImport in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1734,6 +1736,7 @@ packageProductDependencies = ( 5AB9F3E82F7C1D03001F3337 /* TableProDatabase */, 5AB9F3EA2F7C1D03001F3337 /* TableProModels */, + 5A1MPORT00000000000000I0 /* TableProImport */, 5AB9F3EC2F7C1D03001F3337 /* TableProPluginKit */, 5AB9F3EE2F7C1D03001F3337 /* TableProQuery */, 5A87EEEC2F7F893000D028D0 /* TableProSync */, @@ -2284,6 +2287,11 @@ package = 5AB9F3E72F7C1D03001F3337 /* XCLocalSwiftPackageReference "../Packages/TableProCore" */; productName = TableProMSSQLCore; }; + 5A1MPORT00000000000000I0 /* TableProImport */ = { + isa = XCSwiftPackageProductDependency; + package = 5AB9F3E72F7C1D03001F3337 /* XCLocalSwiftPackageReference "../Packages/TableProCore" */; + productName = TableProImport; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 5AB9F3D12F7C1C12001F3337 /* Project object */; diff --git a/TableProMobile/TableProMobile/AppState.swift b/TableProMobile/TableProMobile/AppState.swift index e27eea643..fd6f629b7 100644 --- a/TableProMobile/TableProMobile/AppState.swift +++ b/TableProMobile/TableProMobile/AppState.swift @@ -30,6 +30,7 @@ final class AppState { var pendingConnectionId: UUID? var pendingTableName: String? + var pendingImportURL: URL? let connectionManager: ConnectionManager let syncCoordinator = IOSSyncCoordinator() let sshProvider: IOSSSHProvider diff --git a/TableProMobile/TableProMobile/Info.plist b/TableProMobile/TableProMobile/Info.plist index 28e79c573..d12c17fd1 100644 --- a/TableProMobile/TableProMobile/Info.plist +++ b/TableProMobile/TableProMobile/Info.plist @@ -43,5 +43,40 @@ com.TablePro.sync + UTExportedTypeDeclarations + + + UTTypeIdentifier + com.tablepro.connection-share + UTTypeDescription + TablePro Connections + UTTypeConformsTo + + public.data + + UTTypeTagSpecification + + public.filename-extension + + tablepro + + + + + CFBundleDocumentTypes + + + CFBundleTypeName + TablePro Connections + CFBundleTypeRole + Editor + LSHandlerRank + Owner + LSItemContentTypes + + com.tablepro.connection-share + + + diff --git a/TableProMobile/TableProMobile/Services/IOSConnectionExportService.swift b/TableProMobile/TableProMobile/Services/IOSConnectionExportService.swift new file mode 100644 index 000000000..41f4a72e1 --- /dev/null +++ b/TableProMobile/TableProMobile/Services/IOSConnectionExportService.swift @@ -0,0 +1,172 @@ +import Foundation +import os +import TableProDatabase +import TableProImport +import TableProModels + +@MainActor +enum IOSConnectionExportService { + private static let logger = Logger(subsystem: "com.TablePro", category: "IOSConnectionExport") + private static let currentFormatVersion = 1 + + static func exportData( + connections: [DatabaseConnection], + appState: AppState, + includeCredentials: Bool, + passphrase: String? + ) throws -> Data { + let envelope = includeCredentials + ? buildEnvelopeWithCredentials(connections, appState: appState) + : buildEnvelope(connections, appState: appState) + let json = try ConnectionImportDecoder.encode(envelope) + + guard includeCredentials, let passphrase, !passphrase.isEmpty else { + return json + } + return try ConnectionExportCrypto.encrypt(data: json, passphrase: passphrase) + } + + static func suggestedFilename(for connections: [DatabaseConnection]) -> String { + if connections.count == 1, let only = connections.first { + let base = only.name.isEmpty ? only.host : only.name + return "\(sanitizedFilename(base)).tablepro" + } + return "TablePro Connections.tablepro" + } + + // MARK: - Envelope + + static func buildEnvelope(_ connections: [DatabaseConnection], appState: AppState) -> ConnectionExportEnvelope { + var groupNames: Set = [] + var tagNames: Set = [] + + let exportables: [ExportableConnection] = connections.map { connection in + let tagName = appState.tag(for: connection.tagId)?.name + let groupName = appState.group(for: connection.groupId)?.name + if let tagName { tagNames.insert(tagName) } + if let groupName { groupNames.insert(groupName) } + + return ExportableConnection( + name: connection.name, + host: connection.host, + port: connection.port, + database: connection.database, + username: connection.username, + type: connection.type.rawValue, + sshConfig: exportableSSH(connection), + sslConfig: exportableSSL(connection), + color: (connection.colorTag?.isEmpty == false && connection.colorTag != ConnectionColor.none.rawValue) + ? connection.colorTag : nil, + tagName: tagName, + groupName: groupName, + sshProfileId: nil, + safeModeLevel: connection.safeModeLevel == .off ? nil : connection.safeModeLevel.rawValue, + aiPolicy: nil, + additionalFields: connection.additionalFields.isEmpty ? nil : connection.additionalFields, + redisDatabase: nil, + startupCommands: nil, + localOnly: nil + ) + } + + let exportableGroups: [ExportableGroup]? = groupNames.isEmpty ? nil : groupNames.map { name in + let color = appState.groups.first { $0.name == name }?.color + return ExportableGroup(name: name, color: color == .none ? nil : color?.rawValue) + } + let exportableTags: [ExportableTag]? = tagNames.isEmpty ? nil : tagNames.map { name in + let color = appState.tags.first { $0.name == name }?.color + return ExportableTag(name: name, color: color == .none ? nil : color?.rawValue) + } + + let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown" + + return ConnectionExportEnvelope( + formatVersion: currentFormatVersion, + exportedAt: Date(), + appVersion: appVersion, + connections: exportables, + groups: exportableGroups, + tags: exportableTags, + credentials: nil + ) + } + + static func buildEnvelopeWithCredentials( + _ connections: [DatabaseConnection], + appState: AppState + ) -> ConnectionExportEnvelope { + let base = buildEnvelope(connections, appState: appState) + let store = appState.secureStore + + var credentialsMap: [String: ExportableCredentials] = [:] + for (index, connection) in connections.enumerated() { + let suffix = connection.id.uuidString + let password = try? store.retrieve(forKey: "com.TablePro.password.\(suffix)") + let sshPassword = try? store.retrieve(forKey: "com.TablePro.sshpassword.\(suffix)") + let keyPassphrase = try? store.retrieve(forKey: "com.TablePro.keypassphrase.\(suffix)") + + let unwrappedPassword = password ?? nil + let unwrappedSSH = sshPassword ?? nil + let unwrappedKey = keyPassphrase ?? nil + + guard unwrappedPassword != nil || unwrappedSSH != nil || unwrappedKey != nil else { continue } + credentialsMap[String(index)] = ExportableCredentials( + password: unwrappedPassword, + sshPassword: unwrappedSSH, + keyPassphrase: unwrappedKey, + sslClientKeyPassphrase: nil, + totpSecret: nil, + pluginSecureFields: nil + ) + } + + return ConnectionExportEnvelope( + formatVersion: base.formatVersion, + exportedAt: base.exportedAt, + appVersion: base.appVersion, + connections: base.connections, + groups: base.groups, + tags: base.tags, + credentials: credentialsMap.isEmpty ? nil : credentialsMap + ) + } + + // MARK: - Helpers + + private static func exportableSSH(_ connection: DatabaseConnection) -> ExportableSSHConfig? { + guard connection.sshEnabled, let ssh = connection.sshConfiguration else { return nil } + let jumpHosts: [ExportableJumpHost]? = ssh.jumpHosts.isEmpty ? nil : ssh.jumpHosts.map { + ExportableJumpHost(host: $0.host, port: $0.port, username: $0.username, authMethod: "sshAgent", privateKeyPath: "") + } + return ExportableSSHConfig( + enabled: true, + host: ssh.host, + port: ssh.port, + username: ssh.username, + authMethod: ssh.authMethod.rawValue, + privateKeyPath: PathPortability.contractHome(ssh.privateKeyPath ?? ""), + agentSocketPath: "", + jumpHosts: jumpHosts, + totpMode: nil, + totpAlgorithm: nil, + totpDigits: nil, + totpPeriod: nil + ) + } + + private static func exportableSSL(_ connection: DatabaseConnection) -> ExportableSSLConfig? { + guard connection.sslEnabled, let ssl = connection.sslConfiguration, ssl.mode != .disable else { return nil } + return ExportableSSLConfig( + mode: ssl.mode.rawValue, + caCertificatePath: PathPortability.contractHome(ssl.caCertificatePath ?? ""), + clientCertificatePath: PathPortability.contractHome(ssl.clientCertificatePath ?? ""), + clientKeyPath: PathPortability.contractHome(ssl.clientKeyPath ?? "") + ) + } + + private static func sanitizedFilename(_ name: String) -> String { + let invalid = CharacterSet(charactersIn: "/\\:?%*|\"<>") + let cleaned = name.components(separatedBy: invalid).joined(separator: "-") + return cleaned.isEmpty ? "Connection" : cleaned + } +} diff --git a/TableProMobile/TableProMobile/Services/IOSConnectionImportService.swift b/TableProMobile/TableProMobile/Services/IOSConnectionImportService.swift new file mode 100644 index 000000000..3063fa161 --- /dev/null +++ b/TableProMobile/TableProMobile/Services/IOSConnectionImportService.swift @@ -0,0 +1,257 @@ +import Foundation +import os +import TableProDatabase +import TableProImport +import TableProModels + +@MainActor +enum IOSConnectionImportService { + private static let logger = Logger(subsystem: "com.TablePro", category: "IOSConnectionImport") + + static func analyze(_ envelope: ConnectionExportEnvelope, appState: AppState) -> ConnectionImportPreview { + let candidates = appState.connections.map { connection in + ConnectionDuplicateCandidate( + id: connection.id, + name: connection.name.isEmpty ? connection.host : connection.name, + host: connection.host, + port: connection.port, + database: connection.database, + username: connection.username, + redisDatabase: nil + ) + } + return ConnectionImportAnalyzer.analyze( + envelope, + existingConnections: candidates, + registeredTypeIds: Set(DatabaseType.allKnownTypes.map(\.rawValue)), + fileExists: { FileManager.default.fileExists(atPath: $0) } + ) + } + + struct ImportResult { + let importedCount: Int + let connectionIdMap: [Int: UUID] + let newConnectionIdMap: [Int: UUID] + } + + @discardableResult + static func performImport( + _ preview: ConnectionImportPreview, + resolutions: [UUID: ImportResolution], + appState: AppState + ) -> ImportResult { + createMissingGroupsAndTags(from: preview.envelope, appState: appState) + + let tagIdsByName = lookup(appState.tags.map { ($0.name, $0.id) }) + let groupIdsByName = lookup(appState.groups.map { ($0.name, $0.id) }) + + var takenNames = Set(appState.connections.map { normalizedKey($0.name) }) + var sortOrder = (appState.connections.map(\.sortOrder).max() ?? -1) + 1 + + let itemIndex: [UUID: Int] = Dictionary( + uniqueKeysWithValues: preview.items.enumerated().map { ($1.id, $0) } + ) + + var connectionIdMap: [Int: UUID] = [:] + var newConnectionIdMap: [Int: UUID] = [:] + var importedCount = 0 + + for item in preview.items { + guard let index = itemIndex[item.id] else { continue } + switch resolutions[item.id] ?? .skip { + case .skip: + continue + + case .importNew, .importAsCopy: + let resolution = resolutions[item.id] + let name = resolution == .importAsCopy + ? uniqueCopyName(for: item.connection.name, taken: takenNames) + : item.connection.name + takenNames.insert(normalizedKey(name)) + let id = UUID() + let connection = buildConnection( + id: id, from: item.connection, name: name, sortOrder: sortOrder, + tagIdsByName: tagIdsByName, groupIdsByName: groupIdsByName + ) + sortOrder += 1 + appState.addConnection(connection) + connectionIdMap[index] = id + newConnectionIdMap[index] = id + importedCount += 1 + + case .replace(let existingId): + let existingSortOrder = appState.connections.first { $0.id == existingId }?.sortOrder ?? sortOrder + let connection = buildConnection( + id: existingId, from: item.connection, name: item.connection.name, sortOrder: existingSortOrder, + tagIdsByName: tagIdsByName, groupIdsByName: groupIdsByName + ) + appState.updateConnection(connection) + connectionIdMap[index] = existingId + importedCount += 1 + } + } + + logger.info("Imported \(importedCount) connections") + return ImportResult( + importedCount: importedCount, + connectionIdMap: connectionIdMap, + newConnectionIdMap: newConnectionIdMap + ) + } + + static func restoreCredentials( + from envelope: ConnectionExportEnvelope, + connectionIdMap: [Int: UUID], + secureStore: any SecureStore + ) { + guard let credentials = envelope.credentials else { return } + for (indexString, creds) in credentials { + guard let index = Int(indexString), let id = connectionIdMap[index] else { continue } + let suffix = id.uuidString + if let password = creds.password { + try? secureStore.store(password, forKey: "com.TablePro.password.\(suffix)") + } + if let sshPassword = creds.sshPassword { + try? secureStore.store(sshPassword, forKey: "com.TablePro.sshpassword.\(suffix)") + } + if let keyPassphrase = creds.keyPassphrase { + try? secureStore.store(keyPassphrase, forKey: "com.TablePro.keypassphrase.\(suffix)") + } + } + } + + // MARK: - Building + + private static func buildConnection( + id: UUID, + from exportable: ExportableConnection, + name: String, + sortOrder: Int, + tagIdsByName: [String: UUID], + groupIdsByName: [String: UUID] + ) -> DatabaseConnection { + let host = exportable.host.trimmingCharacters(in: .whitespaces).isEmpty ? "localhost" : exportable.host + + var sshEnabled = false + var sshConfiguration: SSHConfiguration? + if let ssh = exportable.sshConfig, ssh.enabled { + sshEnabled = true + sshConfiguration = SSHConfiguration( + host: ssh.host, + port: ssh.port ?? 22, + username: ssh.username, + authMethod: sshAuthMethod(from: ssh.authMethod), + privateKeyPath: PathPortability.expandHome(ssh.privateKeyPath).isEmpty + ? nil : PathPortability.expandHome(ssh.privateKeyPath), + jumpHosts: (ssh.jumpHosts ?? []).map { + SSHJumpHost(host: $0.host, port: $0.port ?? 22, username: $0.username) + } + ) + } + + var sslEnabled = false + var sslConfiguration: SSLConfiguration? + if let ssl = exportable.sslConfig { + let mode = sslMode(from: ssl.mode) + if mode != .disable { + sslEnabled = true + sslConfiguration = SSLConfiguration( + mode: mode, + caCertificatePath: expandedPath(ssl.caCertificatePath), + clientCertificatePath: expandedPath(ssl.clientCertificatePath), + clientKeyPath: expandedPath(ssl.clientKeyPath) + ) + } + } + + let safeMode = exportable.safeModeLevel.flatMap { SafeModeLevel(rawValue: $0) } ?? .off + + return DatabaseConnection( + id: id, + name: name, + type: DatabaseType(rawValue: exportable.type), + host: host, + port: exportable.port, + username: exportable.username, + database: exportable.database, + colorTag: exportable.color, + safeModeLevel: safeMode, + additionalFields: exportable.additionalFields ?? [:], + sshEnabled: sshEnabled, + sshConfiguration: sshConfiguration, + sslEnabled: sslEnabled, + sslConfiguration: sslConfiguration, + groupId: exportable.groupName.flatMap { groupIdsByName[normalizedKey($0)] }, + tagId: exportable.tagName.flatMap { tagIdsByName[normalizedKey($0)] }, + sortOrder: sortOrder + ) + } + + private static func createMissingGroupsAndTags(from envelope: ConnectionExportEnvelope, appState: AppState) { + for exportGroup in envelope.groups ?? [] { + let exists = appState.groups.contains { normalizedKey($0.name) == normalizedKey(exportGroup.name) } + guard !exists, !exportGroup.name.isEmpty else { continue } + let color = exportGroup.color.flatMap { ConnectionColor(rawValue: $0) } ?? .none + appState.addGroup(ConnectionGroup(name: exportGroup.name, color: color)) + } + for exportTag in envelope.tags ?? [] { + let exists = appState.tags.contains { normalizedKey($0.name) == normalizedKey(exportTag.name) } + guard !exists, !exportTag.name.isEmpty else { continue } + if let preset = ConnectionTag.presets.first(where: { normalizedKey($0.name) == normalizedKey(exportTag.name) }) { + appState.addTag(preset) + } else { + let color = exportTag.color.flatMap { ConnectionColor(rawValue: $0) } ?? .gray + appState.addTag(ConnectionTag(name: exportTag.name, color: color)) + } + } + } + + // MARK: - Helpers + + private static func sshAuthMethod(from raw: String) -> SSHConfiguration.SSHAuthMethod { + switch normalizedKey(raw) { + case "privatekey", "publickey", "private key": return .privateKey + case "sshagent", "agent", "ssh agent": return .sshAgent + case "keyboardinteractive", "keyboard interactive": return .keyboardInteractive + default: return .password + } + } + + private static func sslMode(from raw: String) -> SSLConfiguration.SSLMode { + let key = normalizedKey(raw) + if key.contains("disab") { return .disable } + if key.contains("verifyfull") || key.contains("identity") { return .verifyFull } + if key.contains("verifyca") || key == "verify ca" { return .verifyCa } + if key.contains("require") || key.contains("prefer") { return .require } + return .disable + } + + private static func expandedPath(_ path: String?) -> String? { + guard let path, !path.isEmpty else { return nil } + return PathPortability.expandHome(path) + } + + private static func uniqueCopyName(for baseName: String, taken: Set) -> String { + let first = "\(baseName) (Imported)" + if !taken.contains(normalizedKey(first)) { return first } + var suffix = 2 + while true { + let candidate = "\(baseName) (Imported \(suffix))" + if !taken.contains(normalizedKey(candidate)) { return candidate } + suffix += 1 + } + } + + private static func lookup(_ pairs: [(String, UUID)]) -> [String: UUID] { + var result: [String: UUID] = [:] + for (name, id) in pairs where !name.isEmpty { + let key = normalizedKey(name) + if result[key] == nil { result[key] = id } + } + return result + } + + private static func normalizedKey(_ value: String) -> String { + value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + } +} diff --git a/TableProMobile/TableProMobile/TableProMobileApp.swift b/TableProMobile/TableProMobile/TableProMobileApp.swift index 84e39dec9..30e6c62e4 100644 --- a/TableProMobile/TableProMobile/TableProMobileApp.swift +++ b/TableProMobile/TableProMobile/TableProMobileApp.swift @@ -41,6 +41,10 @@ struct TableProMobileApp: App { } .animation(.default, value: lockState.isLocked) .onOpenURL { url in + if url.isFileURL, url.pathExtension.lowercased() == "tablepro" { + appState.pendingImportURL = url + return + } guard url.scheme == "tablepro", url.host(percentEncoded: false) == "connect", let uuidString = url.pathComponents.dropFirst().first, diff --git a/TableProMobile/TableProMobile/Views/ConnectionListView.swift b/TableProMobile/TableProMobile/Views/ConnectionListView.swift index 861ac63e9..b0703ec3e 100644 --- a/TableProMobile/TableProMobile/Views/ConnectionListView.swift +++ b/TableProMobile/TableProMobile/Views/ConnectionListView.swift @@ -1,6 +1,8 @@ import SwiftUI +import TableProImport import TableProModels import TableProSync +import UniformTypeIdentifiers struct ConnectionListView: View { @Environment(AppState.self) private var appState @@ -18,6 +20,10 @@ struct ConnectionListView: View { @State private var connectionToDelete: DatabaseConnection? @State private var showingSettings = false @State private var coordinatorCache: [UUID: ConnectionCoordinator] = [:] + @State private var showingFileImporter = false + @State private var importItem: IdentifiableURL? + @State private var showingExport = false + @State private var importResultCount: Int? private var showDeleteConfirmation: Binding { Binding( @@ -67,6 +73,7 @@ struct ConnectionListView: View { .navigationTitle("Connections") .toolbar { ToolbarItemGroup(placement: .topBarTrailing) { + moreMenu filterMenu if filterTagId == nil && !appState.connections.isEmpty { Button(editMode == .active ? "Done" : "Edit") { @@ -167,6 +174,72 @@ struct ConnectionListView: View { } } } + .fileImporter( + isPresented: $showingFileImporter, + allowedContentTypes: [.tableproConnectionShare], + allowsMultipleSelection: false + ) { result in + if case .success(let urls) = result, let url = urls.first { + importItem = IdentifiableURL(url: url) + } + } + .sheet(item: $importItem) { item in + MobileConnectionImportSheet(fileURL: item.url) { count in + importResultCount = count + } + .environment(appState) + } + .sheet(isPresented: $showingExport) { + MobileConnectionExportSheet(connections: appState.connections) + .environment(appState) + } + .onChange(of: appState.pendingImportURL) { _, url in + guard let url else { return } + importItem = IdentifiableURL(url: url) + appState.pendingImportURL = nil + } + .onAppear { + if let url = appState.pendingImportURL { + importItem = IdentifiableURL(url: url) + appState.pendingImportURL = nil + } + } + .alert(importResultMessage, isPresented: importResultPresented) { + Button(String(localized: "OK")) { importResultCount = nil } + } + } + + private var moreMenu: some View { + Menu { + Button { + showingFileImporter = true + } label: { + Label("Import Connections", systemImage: "square.and.arrow.down") + } + Button { + showingExport = true + } label: { + Label("Export Connections", systemImage: "square.and.arrow.up") + } + .disabled(appState.connections.isEmpty) + } label: { + Image(systemName: "ellipsis.circle") + } + .accessibilityLabel(Text("More")) + } + + private var importResultPresented: Binding { + Binding( + get: { importResultCount != nil }, + set: { if !$0 { importResultCount = nil } } + ) + } + + private var importResultMessage: String { + let count = importResultCount ?? 0 + return count == 1 + ? String(localized: "1 connection imported.") + : String(format: String(localized: "%d connections imported."), count) } @ViewBuilder diff --git a/TableProMobile/TableProMobile/Views/MobileConnectionExportSheet.swift b/TableProMobile/TableProMobile/Views/MobileConnectionExportSheet.swift new file mode 100644 index 000000000..53260b3c8 --- /dev/null +++ b/TableProMobile/TableProMobile/Views/MobileConnectionExportSheet.swift @@ -0,0 +1,98 @@ +import SwiftUI +import TableProModels + +struct IdentifiableURL: Identifiable { + let url: URL + var id: URL { url } +} + +struct MobileConnectionExportSheet: View { + let connections: [DatabaseConnection] + + @Environment(AppState.self) private var appState + @Environment(\.dismiss) private var dismiss + + @State private var includePasswords = false + @State private var passphrase = "" + @State private var confirmPassphrase = "" + @State private var error: String? + @State private var shareItem: IdentifiableURL? + + private var canExport: Bool { + guard includePasswords else { return true } + return !passphrase.isEmpty && passphrase == confirmPassphrase + } + + var body: some View { + NavigationStack { + Form { + Section { + Text(connectionCountLabel) + .foregroundStyle(.secondary) + } + + Section { + Toggle(String(localized: "Include passwords"), isOn: $includePasswords) + } footer: { + Text("Passwords are excluded by default. To include them, set a passphrase. The file is encrypted with it.") + } + + if includePasswords { + Section { + SecureField(String(localized: "Passphrase"), text: $passphrase) + .textContentType(.newPassword) + SecureField(String(localized: "Confirm passphrase"), text: $confirmPassphrase) + .textContentType(.newPassword) + } footer: { + if !confirmPassphrase.isEmpty, passphrase != confirmPassphrase { + Text("Passphrases don't match.").foregroundStyle(.red) + } + } + } + + if let error { + Section { + Text(error).foregroundStyle(.red) + } + } + } + .navigationTitle(Text("Export Connections")) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(String(localized: "Cancel")) { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button(String(localized: "Export")) { export() } + .disabled(!canExport || connections.isEmpty) + } + } + .sheet(item: $shareItem, onDismiss: { dismiss() }) { item in + ActivityViewController(items: [item.url]) + } + } + } + + private var connectionCountLabel: String { + connections.count == 1 + ? String(localized: "1 connection will be exported.") + : String(format: String(localized: "%d connections will be exported."), connections.count) + } + + private func export() { + do { + let data = try IOSConnectionExportService.exportData( + connections: connections, + appState: appState, + includeCredentials: includePasswords, + passphrase: includePasswords ? passphrase : nil + ) + let filename = IOSConnectionExportService.suggestedFilename(for: connections) + let url = FileManager.default.temporaryDirectory.appendingPathComponent(filename) + try data.write(to: url, options: .atomic) + shareItem = IdentifiableURL(url: url) + } catch { + self.error = error.localizedDescription + } + } +} diff --git a/TableProMobile/TableProMobile/Views/MobileConnectionImportSheet.swift b/TableProMobile/TableProMobile/Views/MobileConnectionImportSheet.swift new file mode 100644 index 000000000..21c7e5a9d --- /dev/null +++ b/TableProMobile/TableProMobile/Views/MobileConnectionImportSheet.swift @@ -0,0 +1,241 @@ +import SwiftUI +import TableProImport +import TableProModels + +struct MobileConnectionImportSheet: View { + let fileURL: URL + var onImported: ((Int) -> Void)? + + @Environment(AppState.self) private var appState + @Environment(\.dismiss) private var dismiss + + @State private var phase: Phase = .loading + @State private var preview: ConnectionImportPreview? + @State private var selectedIds: Set = [] + @State private var resolutions: [UUID: ImportResolution] = [:] + @State private var encryptedData: Data? + @State private var passphrase = "" + @State private var passphraseError: String? + @State private var wasEncryptedImport = false + + private enum Phase: Equatable { + case loading + case passphrase + case preview + case failed(String) + } + + var body: some View { + NavigationStack { + content + .navigationTitle(Text("Import Connections")) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(String(localized: "Cancel")) { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + if phase == .preview { + Button(String(localized: "Import")) { performImport() } + .disabled(selectedIds.isEmpty) + } + } + } + } + .task { await loadFile() } + } + + @ViewBuilder + private var content: some View { + switch phase { + case .loading: + ProgressView().controlSize(.large) + case .passphrase: + passphraseView + case .failed(let message): + ContentUnavailableView { + Label(String(localized: "Can't Import"), systemImage: "exclamationmark.triangle") + } description: { + Text(message) + } + case .preview: + previewList + } + } + + private var passphraseView: some View { + Form { + Section { + SecureField(String(localized: "Passphrase"), text: $passphrase) + .textContentType(.password) + .onSubmit { Task { await decrypt() } } + } header: { + Text("This file is encrypted") + } footer: { + if let passphraseError { + Text(passphraseError).foregroundStyle(.red) + } else { + Text("Enter the passphrase to decrypt and import connections.") + } + } + Button(String(localized: "Decrypt")) { Task { await decrypt() } } + .disabled(passphrase.isEmpty) + } + } + + @ViewBuilder + private var previewList: some View { + if let preview { + List { + ForEach(preview.items) { item in + row(for: item) + } + } + } + } + + private func row(for item: ImportItem) -> some View { + let isSelected = selectedIds.contains(item.id) + return HStack(spacing: 12) { + Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") + .foregroundStyle(isSelected ? Color.accentColor : Color.secondary) + .onTapGesture { toggle(item.id) } + + DatabaseIconView(type: DatabaseType(rawValue: item.connection.type), size: 18) + .frame(width: 28, height: 28) + + VStack(alignment: .leading, spacing: 2) { + Text(item.connection.name) + .lineLimit(1) + Text(subtitle(for: item)) + .font(.caption) + .foregroundStyle(statusColor(for: item.status)) + .lineLimit(1) + } + + Spacer() + + if case .duplicate = item.status, isSelected { + Picker("", selection: resolutionBinding(for: item)) { + Text(String(localized: "As Copy")).tag(ImportResolution.importAsCopy) + if case .duplicate(let existingId, _) = item.status { + Text(String(localized: "Replace")).tag(ImportResolution.replace(existingId: existingId)) + } + } + .pickerStyle(.menu) + .labelsHidden() + } + } + .contentShape(Rectangle()) + .onTapGesture { toggle(item.id) } + } + + private func subtitle(for item: ImportItem) -> String { + switch item.status { + case .ready: + return "\(item.connection.host):\(item.connection.port)" + case .duplicate: + return String(localized: "Already exists") + case .warnings(let messages): + return messages.first ?? "\(item.connection.host):\(item.connection.port)" + } + } + + private func statusColor(for status: ImportItemStatus) -> Color { + switch status { + case .ready: return .secondary + case .duplicate: return .orange + case .warnings: return .orange + } + } + + // MARK: - State helpers + + private func toggle(_ id: UUID) { + if selectedIds.contains(id) { + selectedIds.remove(id) + } else { + selectedIds.insert(id) + } + } + + private func resolutionBinding(for item: ImportItem) -> Binding { + Binding( + get: { resolutions[item.id] ?? .importAsCopy }, + set: { resolutions[item.id] = $0 } + ) + } + + // MARK: - Actions + + private func loadFile() async { + let accessing = fileURL.startAccessingSecurityScopedResource() + defer { if accessing { fileURL.stopAccessingSecurityScopedResource() } } + + do { + let data = try Data(contentsOf: fileURL) + if ConnectionExportCrypto.isEncrypted(data) { + encryptedData = data + phase = .passphrase + return + } + let envelope = try ConnectionImportDecoder.decodeData(data) + applyPreview(IOSConnectionImportService.analyze(envelope, appState: appState)) + } catch { + phase = .failed(error.localizedDescription) + } + } + + private func decrypt() async { + guard let data = encryptedData, !passphrase.isEmpty else { return } + do { + let envelope = try ConnectionImportDecoder.decodeEncryptedData(data, passphrase: passphrase) + wasEncryptedImport = true + applyPreview(IOSConnectionImportService.analyze(envelope, appState: appState)) + } catch { + passphraseError = error.localizedDescription + passphrase = "" + } + } + + private func applyPreview(_ result: ConnectionImportPreview) { + preview = result + for item in result.items { + switch item.status { + case .ready, .warnings: + selectedIds.insert(item.id) + case .duplicate: + break + } + } + phase = .preview + } + + private func performImport() { + guard let preview else { return } + var resolved: [UUID: ImportResolution] = [:] + for item in preview.items { + if selectedIds.contains(item.id) { + switch item.status { + case .ready, .warnings: + resolved[item.id] = .importNew + case .duplicate: + resolved[item.id] = resolutions[item.id] ?? .importAsCopy + } + } else { + resolved[item.id] = .skip + } + } + + let result = IOSConnectionImportService.performImport(preview, resolutions: resolved, appState: appState) + if wasEncryptedImport, preview.envelope.credentials != nil { + IOSConnectionImportService.restoreCredentials( + from: preview.envelope, + connectionIdMap: result.connectionIdMap, + secureStore: appState.secureStore + ) + } + onImported?(result.importedCount) + dismiss() + } +} diff --git a/TableProMobile/TableProMobileTests/IOSConnectionImportServiceTests.swift b/TableProMobile/TableProMobileTests/IOSConnectionImportServiceTests.swift new file mode 100644 index 000000000..7f9014e86 --- /dev/null +++ b/TableProMobile/TableProMobileTests/IOSConnectionImportServiceTests.swift @@ -0,0 +1,75 @@ +import Foundation +import TableProDatabase +import TableProImport +import TableProModels +import Testing + +@testable import TableProMobile + +@MainActor +@Suite("iOS Connection Import/Export Service") +struct IOSConnectionImportServiceTests { + @Test("restores credentials to the iOS keychain key format for mapped connections only") + func restoresCredentialsForMappedIndices() throws { + let idA = UUID() + let idB = UUID() + let store = MockSecureStore() + + let envelope = ConnectionExportEnvelope( + formatVersion: 1, exportedAt: Date(), appVersion: "Tests", + connections: [], + groups: nil, tags: nil, + credentials: [ + "0": ExportableCredentials( + password: "pw0", sshPassword: "ssh0", keyPassphrase: "key0", + sslClientKeyPassphrase: nil, totpSecret: nil, pluginSecureFields: nil + ), + "1": ExportableCredentials( + password: "pw1", sshPassword: nil, keyPassphrase: nil, + sslClientKeyPassphrase: nil, totpSecret: nil, pluginSecureFields: nil + ), + "2": ExportableCredentials( + password: "orphan", sshPassword: nil, keyPassphrase: nil, + sslClientKeyPassphrase: nil, totpSecret: nil, pluginSecureFields: nil + ), + ] + ) + + IOSConnectionImportService.restoreCredentials( + from: envelope, + connectionIdMap: [0: idA, 1: idB], + secureStore: store + ) + + #expect(try store.retrieve(forKey: "com.TablePro.password.\(idA.uuidString)") == "pw0") + #expect(try store.retrieve(forKey: "com.TablePro.sshpassword.\(idA.uuidString)") == "ssh0") + #expect(try store.retrieve(forKey: "com.TablePro.keypassphrase.\(idA.uuidString)") == "key0") + #expect(try store.retrieve(forKey: "com.TablePro.password.\(idB.uuidString)") == "pw1") + #expect(try store.retrieve(forKey: "com.TablePro.sshpassword.\(idB.uuidString)") == nil) + } + + @Test("no credentials envelope writes nothing") + func noCredentialsWritesNothing() throws { + let store = MockSecureStore() + let id = UUID() + let envelope = ConnectionExportEnvelope( + formatVersion: 1, exportedAt: Date(), appVersion: "Tests", + connections: [], groups: nil, tags: nil, credentials: nil + ) + IOSConnectionImportService.restoreCredentials(from: envelope, connectionIdMap: [0: id], secureStore: store) + #expect(try store.retrieve(forKey: "com.TablePro.password.\(id.uuidString)") == nil) + } + + @Test("suggested filename uses the connection name for a single export") + func suggestedFilenameSingle() { + let connection = DatabaseConnection(name: "Prod DB", type: .postgresql, host: "db", port: 5_432) + #expect(IOSConnectionExportService.suggestedFilename(for: [connection]) == "Prod DB.tablepro") + } + + @Test("suggested filename uses a generic name for multiple exports") + func suggestedFilenameMultiple() { + let a = DatabaseConnection(name: "A", type: .mysql, host: "a", port: 3_306) + let b = DatabaseConnection(name: "B", type: .mysql, host: "b", port: 3_306) + #expect(IOSConnectionExportService.suggestedFilename(for: [a, b]) == "TablePro Connections.tablepro") + } +} diff --git a/TableProTests/Core/Services/ConnectionImportServiceTests.swift b/TableProTests/Core/Services/ConnectionImportServiceTests.swift index d7117ca92..39a371528 100644 --- a/TableProTests/Core/Services/ConnectionImportServiceTests.swift +++ b/TableProTests/Core/Services/ConnectionImportServiceTests.swift @@ -1,4 +1,5 @@ import Foundation +import TableProImport import Testing @testable import TablePro @@ -44,12 +45,12 @@ struct ConnectionImportServiceTests { fileExists: { _ in true } ) - guard case .duplicate(let matched) = preview.items.first?.status else { + guard case .duplicate(let matchedId, _) = preview.items.first?.status else { Issue.record("Expected duplicate status") return } - #expect(matched.id == existing.id) + #expect(matchedId == existing.id) } @Test("different username on same host is not a duplicate") @@ -186,12 +187,12 @@ struct ConnectionImportServiceTests { fileExists: { _ in true } ) - guard case .duplicate(let matched) = preview.items.first?.status else { + guard case .duplicate(let matchedId, _) = preview.items.first?.status else { Issue.record("Expected duplicate status for matching Redis database indices") return } - #expect(matched.id == existing.id) + #expect(matchedId == existing.id) } @Test("replace updates the existing connection") @@ -484,7 +485,7 @@ struct ConnectionImportServiceTests { ) let data = try ConnectionExportService.encode(makeEnvelope(with: [imported])) - let decoded = try ConnectionExportService.decodeData(data) + let decoded = try ConnectionImportDecoder.decodeData(data) let fields = decoded.connections.first?.additionalFields #expect(fields?["preConnectScript"] == nil) @@ -515,7 +516,7 @@ struct ConnectionImportServiceTests { imported: ExportableConnection, existing: DatabaseConnection ) -> (ConnectionImportPreview, ImportItem) { - let item = ImportItem(connection: imported, status: .duplicate(existing: existing)) + let item = ImportItem(connection: imported, status: .duplicate(existingId: existing.id, existingName: existing.name)) let preview = ConnectionImportPreview( envelope: makeEnvelope(with: [imported]), items: [item] diff --git a/TableProTests/Core/Services/ConnectionSharingTests.swift b/TableProTests/Core/Services/ConnectionSharingTests.swift index 155cf4c1d..c277cf431 100644 --- a/TableProTests/Core/Services/ConnectionSharingTests.swift +++ b/TableProTests/Core/Services/ConnectionSharingTests.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProImport import TableProPluginKit import Testing @testable import TablePro diff --git a/TableProTests/Core/Services/ForeignApp/DBeaverImporterTests.swift b/TableProTests/Core/Services/ForeignApp/DBeaverImporterTests.swift index d3d26dced..bb306254d 100644 --- a/TableProTests/Core/Services/ForeignApp/DBeaverImporterTests.swift +++ b/TableProTests/Core/Services/ForeignApp/DBeaverImporterTests.swift @@ -5,6 +5,7 @@ import CommonCrypto import Foundation +import TableProImport import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Core/Services/ForeignApp/DataGripImporterTests.swift b/TableProTests/Core/Services/ForeignApp/DataGripImporterTests.swift index d8302a997..12d921f03 100644 --- a/TableProTests/Core/Services/ForeignApp/DataGripImporterTests.swift +++ b/TableProTests/Core/Services/ForeignApp/DataGripImporterTests.swift @@ -5,6 +5,7 @@ import Foundation @testable import TablePro +import TableProImport import Testing @Suite("DataGripImporter", .serialized) diff --git a/TableProTests/Core/Services/ForeignApp/NavicatImporterTests.swift b/TableProTests/Core/Services/ForeignApp/NavicatImporterTests.swift index af936daa1..886fdee9e 100644 --- a/TableProTests/Core/Services/ForeignApp/NavicatImporterTests.swift +++ b/TableProTests/Core/Services/ForeignApp/NavicatImporterTests.swift @@ -5,6 +5,7 @@ import Foundation @testable import TablePro +import TableProImport import Testing import UniformTypeIdentifiers diff --git a/TableProTests/Core/Services/ForeignApp/SequelAceImporterTests.swift b/TableProTests/Core/Services/ForeignApp/SequelAceImporterTests.swift index 620137688..9cb7b693f 100644 --- a/TableProTests/Core/Services/ForeignApp/SequelAceImporterTests.swift +++ b/TableProTests/Core/Services/ForeignApp/SequelAceImporterTests.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProImport import TableProPluginKit @testable import TablePro import Testing diff --git a/TableProTests/Core/Services/ForeignApp/TablePlusImporterTests.swift b/TableProTests/Core/Services/ForeignApp/TablePlusImporterTests.swift index 030662aca..1b0ae5a74 100644 --- a/TableProTests/Core/Services/ForeignApp/TablePlusImporterTests.swift +++ b/TableProTests/Core/Services/ForeignApp/TablePlusImporterTests.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProImport import TableProPluginKit @testable import TablePro import Testing diff --git a/docs/features/connection-sharing.mdx b/docs/features/connection-sharing.mdx index c803b9a70..b48479418 100644 --- a/docs/features/connection-sharing.mdx +++ b/docs/features/connection-sharing.mdx @@ -56,6 +56,14 @@ Duplicates are unchecked. Check to import, then pick **As Copy**, **Replace**, o /> +## On iPhone + +TablePro for iPhone reads and writes the same `.tablepro` file, so connections move between your Mac and your phone. + +**Import:** tap the **more** menu (•••) above the connection list and choose **Import Connections**, then pick a `.tablepro` file. You can also open a `.tablepro` file from the Files app or accept one over AirDrop, and it opens straight into TablePro. The same preview shows each connection with its status, and you choose **As Copy**, **Replace**, or **Skip** for duplicates. Encrypted files prompt for the passphrase first. + +**Export:** tap the **more** menu and choose **Export Connections** to share a `.tablepro` file through the system share sheet. Passwords are left out by default. To include them, turn on **Include passwords** and set a passphrase that encrypts the file. + ## Import from Other Apps Bring your connections over from TablePlus, Sequel Ace, DBeaver, DataGrip, or Navicat. Passwords can be imported too. The source app doesn't need to be running.