From 2c63fb21b4d897f2eb2de67ddf0467e83c16f60d Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 20 Jun 2026 19:33:06 +0700 Subject: [PATCH 1/5] 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. From b5fdfee1bfb734f3604227ed6eeb5657d248ea98 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 21 Jun 2026 17:55:52 +0700 Subject: [PATCH 2/5] chore(connections): extract localizable strings for iOS import/export --- TablePro/Resources/Localizable.xcstrings | 42 +- .../TableProMobile/Localizable.xcstrings | 622 ++++++++++-------- 2 files changed, 382 insertions(+), 282 deletions(-) diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 0b3d18573..8ddbf50dc 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -2432,6 +2432,9 @@ } } } + }, + "%d connections" : { + }, "%d connections found" : { "localizations" : { @@ -21935,6 +21938,9 @@ } } } + }, + "Create Trigger" : { + }, "Create your own slash commands. Use {{query}}, {{schema}}, {{database}}, or {{body}} in the template to insert chat context at runtime." : { "localizations" : { @@ -27807,6 +27813,12 @@ } } } + }, + "Drop Trigger" : { + + }, + "Drop trigger “%@”?" : { + }, "Drop View" : { "localizations" : { @@ -27866,6 +27878,7 @@ } }, "DuckLake Data Path" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -28832,6 +28845,9 @@ } } } + }, + "Edit Trigger" : { + }, "Edit Values..." : { "localizations" : { @@ -45207,6 +45223,7 @@ } }, "Loading schemas…" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -49423,6 +49440,9 @@ } } } + }, + "New Trigger" : { + }, "New View" : { "localizations" : { @@ -51727,6 +51747,7 @@ } }, "No schemas" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -53303,6 +53324,9 @@ } } } + }, + "Off by default. Turn it on to encrypt saved passwords with a passphrase." : { + }, "Offset" : { "extractionState" : "stale", @@ -56313,6 +56337,7 @@ } }, "Passwords will be encrypted with the passphrase you provide." : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -62746,8 +62771,12 @@ } } } + }, + "Remote (Quack, experimental)" : { + }, "Remote (Quack)" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -67429,6 +67458,7 @@ } }, "Schema: %@" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -79025,6 +79055,9 @@ } } } + }, + "This database cannot drop triggers" : { + }, "This database has no %@ yet." : { "extractionState" : "stale", @@ -81965,6 +81998,9 @@ } } } + }, + "Trigger operation failed" : { + }, "Triggers" : { "localizations" : { @@ -84245,6 +84281,9 @@ } } } + }, + "Use at least 8 characters" : { + }, "Use clipboard URL" : { "localizations" : { @@ -84303,6 +84342,7 @@ } }, "Use DuckLake" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -87167,4 +87207,4 @@ } }, "version" : "1.1" -} +} \ No newline at end of file diff --git a/TableProMobile/TableProMobile/Localizable.xcstrings b/TableProMobile/TableProMobile/Localizable.xcstrings index 2c931cfdb..01e345dd1 100644 --- a/TableProMobile/TableProMobile/Localizable.xcstrings +++ b/TableProMobile/TableProMobile/Localizable.xcstrings @@ -9,13 +9,13 @@ "value" : "" } }, - "zh-Hant" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "" } }, - "zh-Hans" : { + "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "" @@ -59,13 +59,13 @@ "value" : "%@ -> %@" } }, - "zh-Hant" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "%1$@ -> %2$@" } }, - "zh-Hans" : { + "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "%1$@ -> %2$@" @@ -144,13 +144,13 @@ "value" : "%@, %@" } }, - "zh-Hant" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "%1$@, %2$@" } }, - "zh-Hans" : { + "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "%1$@, %2$@" @@ -172,13 +172,13 @@ "value" : "%@, %@, %@" } }, - "zh-Hant" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "%1$@, %2$@, %3$@" } }, - "zh-Hans" : { + "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "%1$@, %2$@, %3$@" @@ -200,16 +200,16 @@ "value" : "%@, %@, %@, thẻ %@" } }, - "zh-Hant" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "%1$@, %2$@, %3$@, 標籤 %4$@" + "value" : "%1$@, %2$@, %3$@,标签 %4$@" } }, - "zh-Hans" : { + "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "%1$@, %2$@, %3$@,标签 %4$@" + "value" : "%1$@, %2$@, %3$@, 標籤 %4$@" } } } @@ -228,16 +228,16 @@ "value" : "%@, %@, %lld hàng" } }, - "zh-Hant" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "%1$@, %2$@, %3$lld 列" + "value" : "%1$@, %2$@, %3$lld 行" } }, - "zh-Hans" : { + "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "%1$@, %2$@, %3$lld 行" + "value" : "%1$@, %2$@, %3$lld 列" } } } @@ -269,6 +269,12 @@ } } } + }, + "%d connections imported." : { + + }, + "%d connections will be exported." : { + }, "%d row(s) affected" : { "localizations" : { @@ -278,16 +284,16 @@ "value" : "%d hàng bị ảnh hưởng" } }, - "zh-Hant" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "已影響 %d 列" + "value" : "已影响 %d 行" } }, - "zh-Hans" : { + "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "已影响 %d 行" + "value" : "已影響 %d 列" } } } @@ -385,6 +391,12 @@ } } } + }, + "1 connection imported." : { + + }, + "1 connection will be exported." : { + }, "A fast, lightweight database client for your iPhone and iPad." : { "localizations" : { @@ -438,16 +450,16 @@ "value" : "DB đang dùng" } }, - "zh-Hant" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "使用中的資料庫" + "value" : "当前数据库" } }, - "zh-Hans" : { + "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "当前数据库" + "value" : "使用中的資料庫" } } } @@ -526,16 +538,16 @@ "value" : "Sau 1 giờ" } }, - "zh-Hant" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "1 小時後" + "value" : "1 小时后" } }, - "zh-Hans" : { + "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "1 小时后" + "value" : "1 小時後" } } } @@ -548,16 +560,16 @@ "value" : "Sau 1 phút" } }, - "zh-Hant" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "1 分鐘後" + "value" : "1 分钟后" } }, - "zh-Hans" : { + "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "1 分钟后" + "value" : "1 分鐘後" } } } @@ -570,16 +582,16 @@ "value" : "Sau 5 phút" } }, - "zh-Hant" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "5 分鐘後" + "value" : "5 分钟后" } }, - "zh-Hans" : { + "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "5 分钟后" + "value" : "5 分鐘後" } } } @@ -592,16 +604,16 @@ "value" : "Sau 15 phút" } }, - "zh-Hant" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "15 分鐘後" + "value" : "15 分钟后" } }, - "zh-Hans" : { + "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "15 分钟后" + "value" : "15 分鐘後" } } } @@ -671,6 +683,9 @@ } } } + }, + "Already exists" : { + }, "An unknown error occurred." : { "extractionState" : "stale", @@ -782,6 +797,9 @@ } } } + }, + "As Copy" : { + }, "Ascending" : { "localizations" : { @@ -813,16 +831,16 @@ "value" : "Xác thực" } }, - "zh-Hant" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "驗證" + "value" : "认证" } }, - "zh-Hans" : { + "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "认证" + "value" : "驗證" } } } @@ -857,16 +875,16 @@ "value" : "Xác thực để truy cập các kết nối cơ sở dữ liệu." } }, - "zh-Hant" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "進行驗證以存取你的資料庫連線。" + "value" : "进行认证以访问你的数据库连接。" } }, - "zh-Hans" : { + "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "进行认证以访问你的数据库连接。" + "value" : "進行驗證以存取你的資料庫連線。" } } } @@ -923,16 +941,16 @@ "value" : "Tự khóa" } }, - "zh-Hant" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "自動鎖定" + "value" : "自动锁定" } }, - "zh-Hans" : { + "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "自动锁定" + "value" : "自動鎖定" } } } @@ -958,6 +976,9 @@ } } } + }, + "Can't Import" : { + }, "Cancel" : { "localizations" : { @@ -1399,6 +1420,9 @@ } } } + }, + "Confirm passphrase" : { + }, "Connected" : { "localizations" : { @@ -1408,16 +1432,16 @@ "value" : "Đã kết nối" } }, - "zh-Hant" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "已連線" + "value" : "已连接" } }, - "zh-Hans" : { + "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "已连接" + "value" : "已連線" } } } @@ -1452,16 +1476,16 @@ "value" : "Đang kết nối…" } }, - "zh-Hant" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "正在連線…" + "value" : "正在连接…" } }, - "zh-Hans" : { + "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "正在连接…" + "value" : "正在連線…" } } } @@ -1496,16 +1520,16 @@ "value" : "Kết nối đã bị xóa" } }, - "zh-Hant" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "連線已刪除" + "value" : "连接已删除" } }, - "zh-Hans" : { + "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "连接已删除" + "value" : "連線已刪除" } } } @@ -1870,16 +1894,16 @@ "value" : "Tạo nhóm" } }, - "zh-Hant" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "建立群組" + "value" : "创建分组" } }, - "zh-Hans" : { + "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "创建分组" + "value" : "建立群組" } } } @@ -1914,16 +1938,16 @@ "value" : "Tạo thẻ" } }, - "zh-Hant" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "建立標籤" + "value" : "创建标签" } }, - "zh-Hans" : { + "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "创建标签" + "value" : "建立標籤" } } } @@ -2046,19 +2070,22 @@ "value" : "Cơ sở dữ liệu" } }, - "zh-Hant" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "資料庫" + "value" : "数据库" } }, - "zh-Hans" : { + "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "数据库" + "value" : "資料庫" } } } + }, + "Decrypt" : { + }, "Default" : { "localizations" : { @@ -2090,16 +2117,16 @@ "value" : "DB mặc định" } }, - "zh-Hant" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "預設資料庫" + "value" : "默认数据库" } }, - "zh-Hans" : { + "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "默认数据库" + "value" : "預設資料庫" } } } @@ -2112,16 +2139,16 @@ "value" : "Chế độ an toàn mặc định" } }, - "zh-Hant" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "預設安全模式" + "value" : "默认安全模式" } }, - "zh-Hans" : { + "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "默认安全模式" + "value" : "預設安全模式" } } } @@ -2156,16 +2183,16 @@ "value" : "Mặc định áp dụng khi thêm kết nối mới và khi mở bảng lần đầu tiên." } }, - "zh-Hant" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "在新增連線及首次開啟資料表時套用的預設值。" + "value" : "添加新连接以及首次打开表时应用的默认设置。" } }, - "zh-Hans" : { + "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "添加新连接以及首次打开表时应用的默认设置。" + "value" : "在新增連線及首次開啟資料表時套用的預設值。" } } } @@ -2222,16 +2249,16 @@ "value" : "Xóa nhóm" } }, - "zh-Hant" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "刪除群組" + "value" : "删除分组" } }, - "zh-Hans" : { + "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "删除分组" + "value" : "刪除群組" } } } @@ -2266,16 +2293,16 @@ "value" : "Xóa hàng" } }, - "zh-Hant" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "刪除列" + "value" : "删除行" } }, - "zh-Hans" : { + "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "删除行" + "value" : "刪除列" } } } @@ -2310,16 +2337,16 @@ "value" : "Xóa thẻ" } }, - "zh-Hant" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "刪除標籤" + "value" : "删除标签" } }, - "zh-Hans" : { + "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "删除标签" + "value" : "刪除標籤" } } } @@ -2376,16 +2403,16 @@ "value" : "Mất kết nối" } }, - "zh-Hant" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "已中斷連線" + "value" : "已断开连接" } }, - "zh-Hans" : { + "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "已断开连接" + "value" : "已中斷連線" } } } @@ -2574,16 +2601,16 @@ "value" : "Bật" } }, - "zh-Hant" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "已啟用" + "value" : "已启用" } }, - "zh-Hans" : { + "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "已启用" + "value" : "已啟用" } } } @@ -2663,16 +2690,16 @@ "value" : "Nhập số trang (1-%lld)" } }, - "zh-Hant" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "輸入頁碼(1-%lld)" + "value" : "输入页码 (1-%lld)" } }, - "zh-Hans" : { + "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "输入页码 (1-%lld)" + "value" : "輸入頁碼(1-%lld)" } } } @@ -2699,6 +2726,9 @@ } } } + }, + "Enter the passphrase to decrypt and import connections." : { + }, "Error" : { "localizations" : { @@ -2831,6 +2861,9 @@ } } } + }, + "Export Connections" : { + }, "Failed to load more rows" : { "extractionState" : "stale", @@ -2952,16 +2985,16 @@ "value" : "Tệp" } }, - "zh-Hant" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "檔案" + "value" : "文件" } }, - "zh-Hans" : { + "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "文件" + "value" : "檔案" } } } @@ -3040,16 +3073,16 @@ "value" : "Tập trung tìm kiếm" } }, - "zh-Hant" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "聚焦搜尋" + "value" : "聚焦搜索" } }, - "zh-Hans" : { + "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "聚焦搜索" + "value" : "聚焦搜尋" } } } @@ -3284,16 +3317,16 @@ "value" : "Ẩn truy vấn trong Live Activity" } }, - "zh-Hant" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "在即時動態中隱藏查詢" + "value" : "在实时活动中隐藏查询" } }, - "zh-Hans" : { + "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "在实时活动中隐藏查询" + "value" : "在即時動態中隱藏查詢" } } } @@ -3350,13 +3383,13 @@ "value" : "Đồng bộ iCloud" } }, - "zh-Hant" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "iCloud 同步" } }, - "zh-Hans" : { + "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "iCloud 同步" @@ -3394,19 +3427,25 @@ "value" : "Ngay lập tức" } }, - "zh-Hant" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "立即" } }, - "zh-Hans" : { + "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "立即" } } } + }, + "Import" : { + + }, + "Import Connections" : { + }, "Import connections from your Mac" : { "localizations" : { @@ -3451,6 +3490,9 @@ } } } + }, + "Include passwords" : { + }, "Indexes" : { "extractionState" : "stale", @@ -3483,16 +3525,16 @@ "value" : "Thông tin" } }, - "zh-Hant" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "資訊" + "value" : "信息" } }, - "zh-Hans" : { + "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "信息" + "value" : "資訊" } } } @@ -3637,16 +3679,16 @@ "value" : "Tải thất bại" } }, - "zh-Hant" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "載入失敗" + "value" : "加载失败" } }, - "zh-Hans" : { + "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "加载失败" + "value" : "載入失敗" } } } @@ -3659,16 +3701,16 @@ "value" : "Tải đầy đủ" } }, - "zh-Hant" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "載入完整值" + "value" : "加载完整值" } }, - "zh-Hans" : { + "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "加载完整值" + "value" : "載入完整值" } } } @@ -3772,16 +3814,16 @@ "value" : "Cần truy cập Mạng nội bộ. Mở Cài đặt > Quyền riêng tư & Bảo mật > Mạng nội bộ và bật TablePro." } }, - "zh-Hant" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "需要區域網路存取權限。請開啟「設定」>「隱私權與安全性」>「區域網路」並開啟 TablePro。" + "value" : "需要本地网络访问权限。请打开 设置 > 隐私与安全性 > 本地网络,并开启 TablePro。" } }, - "zh-Hans" : { + "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "需要本地网络访问权限。请打开 设置 > 隐私与安全性 > 本地网络,并开启 TablePro。" + "value" : "需要區域網路存取權限。請開啟「設定」>「隱私權與安全性」>「區域網路」並開啟 TablePro。" } } } @@ -3794,16 +3836,16 @@ "value" : "Truy cập Mạng nội bộ có thể bị chặn. Mở Cài đặt > Quyền riêng tư & Bảo mật > Mạng nội bộ và bật TablePro." } }, - "zh-Hant" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "區域網路存取可能被封鎖。請開啟「設定」>「隱私權與安全性」>「區域網路」並開啟 TablePro。" + "value" : "本地网络访问可能被阻止。请打开 设置 > 隐私与安全性 > 本地网络,并开启 TablePro。" } }, - "zh-Hans" : { + "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "本地网络访问可能被阻止。请打开 设置 > 隐私与安全性 > 本地网络,并开启 TablePro。" + "value" : "區域網路存取可能被封鎖。請開啟「設定」>「隱私權與安全性」>「區域網路」並開啟 TablePro。" } } } @@ -3816,16 +3858,16 @@ "value" : "Yêu cầu truy cập mạng nội bộ" } }, - "zh-Hant" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "需要區域網路存取權限" + "value" : "需要本地网络访问权限" } }, - "zh-Hans" : { + "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "需要本地网络访问权限" + "value" : "需要區域網路存取權限" } } } @@ -3838,17 +3880,17 @@ "value" : "Khóa TablePro khi mở lại sau khoảng thời gian không hoạt động đã chọn. Khởi động lạnh luôn yêu cầu xác thực." } }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "閒置超過所選時間後重新開啟時鎖定 TablePro。冷啟動一律需要驗證。" - } - }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "在所选闲置时间后重新打开时锁定 TablePro。冷启动始终需要认证。" } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "閒置超過所選時間後重新開啟時鎖定 TablePro。冷啟動一律需要驗證。" + } } } }, @@ -3939,6 +3981,9 @@ } } } + }, + "More" : { + }, "Name" : { "localizations" : { @@ -4014,16 +4059,16 @@ "value" : "Kết nối mới" } }, - "zh-Hant" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "新增連線" + "value" : "新建连接" } }, - "zh-Hans" : { + "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "新建连接" + "value" : "新增連線" } } } @@ -4520,16 +4565,16 @@ "value" : "Tắt" } }, - "zh-Hant" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "關閉" + "value" : "关闭" } }, - "zh-Hans" : { + "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "关闭" + "value" : "關閉" } } } @@ -4652,16 +4697,16 @@ "value" : "Mở Cài đặt > Quyền riêng tư & Bảo mật > Mạng nội bộ, bật TablePro, sau đó thử lại." } }, - "zh-Hant" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "請開啟「設定」>「隱私權與安全性」>「區域網路」並開啟 TablePro,然後再試一次。" + "value" : "请打开 设置 > 隐私与安全性 > 本地网络,开启 TablePro,然后重试。" } }, - "zh-Hans" : { + "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "请打开 设置 > 隐私与安全性 > 本地网络,开启 TablePro,然后重试。" + "value" : "請開啟「設定」>「隱私權與安全性」>「區域網路」並開啟 TablePro,然後再試一次。" } } } @@ -4696,16 +4741,16 @@ "value" : "Mở dữ liệu bảng" } }, - "zh-Hant" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "開啟資料表資料" + "value" : "打开表数据" } }, - "zh-Hans" : { + "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "打开表数据" + "value" : "開啟資料表資料" } } } @@ -4718,16 +4763,16 @@ "value" : "Mở kết nối này" } }, - "zh-Hant" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "開啟此連線" + "value" : "打开此连接" } }, - "zh-Hans" : { + "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "打开此连接" + "value" : "開啟此連線" } } } @@ -4841,6 +4886,9 @@ } } } + }, + "Passphrase" : { + }, "Passphrase (optional)" : { "localizations" : { @@ -4863,6 +4911,9 @@ } } } + }, + "Passphrases don't match." : { + }, "Password" : { "localizations" : { @@ -4885,6 +4936,9 @@ } } } + }, + "Passwords are excluded by default. To include them, set a passphrase. The file is encrypted with it." : { + }, "Paste private key (PEM format)" : { "localizations" : { @@ -4916,16 +4970,16 @@ "value" : "Đường dẫn" } }, - "zh-Hant" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "路徑" + "value" : "路径" } }, - "zh-Hans" : { + "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "路径" + "value" : "路徑" } } } @@ -5261,6 +5315,9 @@ } } } + }, + "Replace" : { + }, "Require Face ID" : { "localizations" : { @@ -5270,13 +5327,13 @@ "value" : "Yêu cầu Face ID" } }, - "zh-Hant" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "需要 Face ID" } }, - "zh-Hans" : { + "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "需要 Face ID" @@ -5292,13 +5349,13 @@ "value" : "Yêu cầu Optic ID" } }, - "zh-Hant" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "需要 Optic ID" } }, - "zh-Hans" : { + "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "需要 Optic ID" @@ -5314,13 +5371,13 @@ "value" : "Yêu cầu Touch ID" } }, - "zh-Hant" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "需要 Touch ID" } }, - "zh-Hans" : { + "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "需要 Touch ID" @@ -5403,16 +5460,16 @@ "value" : "Đã cắt bớt kết quả do áp lực bộ nhớ." } }, - "zh-Hant" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "因記憶體不足,結果已縮減。" + "value" : "由于内存压力,结果已被裁剪。" } }, - "zh-Hans" : { + "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "由于内存压力,结果已被裁剪。" + "value" : "因記憶體不足,結果已縮減。" } } } @@ -5513,16 +5570,16 @@ "value" : "Chạy" } }, - "zh-Hant" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "執行" + "value" : "运行" } }, - "zh-Hans" : { + "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "运行" + "value" : "執行" } } } @@ -5558,16 +5615,16 @@ "value" : "Đang chạy truy vấn" } }, - "zh-Hant" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "正在執行查詢" + "value" : "正在运行查询" } }, - "zh-Hans" : { + "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "正在运行查询" + "value" : "正在執行查詢" } } } @@ -5646,16 +5703,16 @@ "value" : "Schema" } }, - "zh-Hant" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "綱要" + "value" : "模式" } }, - "zh-Hans" : { + "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "模式" + "value" : "綱要" } } } @@ -5668,16 +5725,16 @@ "value" : "Schemas" } }, - "zh-Hant" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "綱要" + "value" : "模式" } }, - "zh-Hans" : { + "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "模式" + "value" : "綱要" } } } @@ -5778,16 +5835,16 @@ "value" : "Bảo mật" } }, - "zh-Hant" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "安全性" + "value" : "安全" } }, - "zh-Hans" : { + "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "安全" + "value" : "安全性" } } } @@ -6329,16 +6386,16 @@ "value" : "Thống kê" } }, - "zh-Hant" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "統計" + "value" : "统计" } }, - "zh-Hans" : { + "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "统计" + "value" : "統計" } } } @@ -6351,16 +6408,16 @@ "value" : "Trạng thái" } }, - "zh-Hant" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "狀態" + "value" : "状态" } }, - "zh-Hans" : { + "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "状态" + "value" : "狀態" } } } @@ -6373,13 +6430,13 @@ "value" : "Dừng" } }, - "zh-Hant" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "停止" } }, - "zh-Hans" : { + "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "停止" @@ -6462,13 +6519,13 @@ "value" : "Đồng bộ" } }, - "zh-Hant" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "同步" } }, - "zh-Hans" : { + "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "同步" @@ -6528,16 +6585,16 @@ "value" : "Đồng bộ với iCloud" } }, - "zh-Hant" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "與 iCloud 同步" + "value" : "与 iCloud 同步" } }, - "zh-Hans" : { + "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "与 iCloud 同步" + "value" : "與 iCloud 同步" } } } @@ -6617,16 +6674,16 @@ "value" : "Bảng" } }, - "zh-Hant" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "資料表" + "value" : "表" } }, - "zh-Hans" : { + "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "表" + "value" : "資料表" } } } @@ -6661,16 +6718,16 @@ "value" : "TablePro đã khóa" } }, - "zh-Hant" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "TablePro 已鎖定" + "value" : "TablePro 已锁定" } }, - "zh-Hans" : { + "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "TablePro 已锁定" + "value" : "TablePro 已鎖定" } } } @@ -6859,16 +6916,16 @@ "value" : "Truy vấn không trả về hàng nào." } }, - "zh-Hant" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "查詢未傳回任何列。" + "value" : "查询未返回任何行。" } }, - "zh-Hans" : { + "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "查询未返回任何行。" + "value" : "查詢未傳回任何列。" } } } @@ -7013,16 +7070,16 @@ "value" : "Kết nối này không còn tồn tại. Có thể đã bị xóa từ thiết bị khác." } }, - "zh-Hant" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "此連線已不存在,可能已從其他裝置移除。" + "value" : "此连接已不存在。它可能已在其他设备上被移除。" } }, - "zh-Hans" : { + "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "此连接已不存在。它可能已在其他设备上被移除。" + "value" : "此連線已不存在,可能已從其他裝置移除。" } } } @@ -7048,6 +7105,9 @@ } } } + }, + "This file is encrypted" : { + }, "This query will modify data. Are you sure you want to continue?" : { "localizations" : { @@ -7255,16 +7315,16 @@ "value" : "Thử lại" } }, - "zh-Hant" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "再試一次" + "value" : "重试" } }, - "zh-Hans" : { + "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "重试" + "value" : "再試一次" } } } @@ -7277,16 +7337,16 @@ "value" : "Thử lại hoặc kiểm tra kết nối." } }, - "zh-Hant" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "請再試一次或檢查你的連線。" + "value" : "请重试或检查你的连接。" } }, - "zh-Hans" : { + "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "请重试或检查你的连接。" + "value" : "請再試一次或檢查你的連線。" } } } @@ -7299,16 +7359,16 @@ "value" : "Loại" } }, - "zh-Hant" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "型別" + "value" : "类型" } }, - "zh-Hans" : { + "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "类型" + "value" : "型別" } } } @@ -7366,16 +7426,16 @@ "value" : "Mở khóa" } }, - "zh-Hant" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "解鎖" + "value" : "解锁" } }, - "zh-Hans" : { + "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "解锁" + "value" : "解鎖" } } } @@ -7388,16 +7448,16 @@ "value" : "Mở khóa TablePro để truy cập các kết nối cơ sở dữ liệu." } }, - "zh-Hant" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "解鎖 TablePro 以存取你的資料庫連線。" + "value" : "解锁 TablePro 以访问你的数据库连接。" } }, - "zh-Hans" : { + "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "解锁 TablePro 以访问你的数据库连接。" + "value" : "解鎖 TablePro 以存取你的資料庫連線。" } } } @@ -7410,16 +7470,16 @@ "value" : "Dùng mật mã" } }, - "zh-Hant" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "使用密碼" + "value" : "使用密码" } }, - "zh-Hans" : { + "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "使用密码" + "value" : "使用密碼" } } } @@ -7498,16 +7558,16 @@ "value" : "View" } }, - "zh-Hant" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "檢視表" + "value" : "视图" } }, - "zh-Hans" : { + "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "视图" + "value" : "檢視表" } } } @@ -7565,16 +7625,16 @@ "value" : "Khi tắt, các kết nối, nhóm và thẻ chỉ ở trên thiết bị này. Dữ liệu iCloud hiện có sẽ không bị xóa." } }, - "zh-Hant" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "關閉時,連線、群組與標籤只會保留在此裝置上。現有的 iCloud 資料不會被刪除。" + "value" : "关闭后,连接、分组和标签将仅保留在此设备上。现有的 iCloud 数据不会被删除。" } }, - "zh-Hans" : { + "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "关闭后,连接、分组和标签将仅保留在此设备上。现有的 iCloud 数据不会被删除。" + "value" : "關閉時,連線、群組與標籤只會保留在此裝置上。現有的 iCloud 資料不會被刪除。" } } } @@ -7588,16 +7648,16 @@ "value" : "Khi bật, màn hình khóa và Dynamic Island hiển thị \"Đang chạy truy vấn\" thay cho nội dung SQL." } }, - "zh-Hant" : { + "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "開啟時,鎖定畫面與動態島會顯示「正在執行查詢」,而非 SQL 預覽。" + "value" : "开启后,锁定屏幕和灵动岛将显示“正在运行查询”而非 SQL 预览。" } }, - "zh-Hans" : { + "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "开启后,锁定屏幕和灵动岛将显示“正在运行查询”而非 SQL 预览。" + "value" : "開啟時,鎖定畫面與動態島會顯示「正在執行查詢」,而非 SQL 預覽。" } } } @@ -7648,4 +7708,4 @@ } }, "version" : "1.0" -} +} \ No newline at end of file From 532fd66e45c789aa98416fdc3446680416338801 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 21 Jun 2026 17:55:53 +0700 Subject: [PATCH 3/5] refactor(connections): single tap target in import list, clean up export temp file --- .../Services/IOSConnectionExportService.swift | 22 ++++----- .../Views/MobileConnectionExportSheet.swift | 9 +++- .../Views/MobileConnectionImportSheet.swift | 47 ++++++++++--------- 3 files changed, 44 insertions(+), 34 deletions(-) diff --git a/TableProMobile/TableProMobile/Services/IOSConnectionExportService.swift b/TableProMobile/TableProMobile/Services/IOSConnectionExportService.swift index 41f4a72e1..4208e3200 100644 --- a/TableProMobile/TableProMobile/Services/IOSConnectionExportService.swift +++ b/TableProMobile/TableProMobile/Services/IOSConnectionExportService.swift @@ -101,19 +101,15 @@ enum IOSConnectionExportService { 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 password = secret(from: store, key: "com.TablePro.password.\(suffix)") + let sshPassword = secret(from: store, key: "com.TablePro.sshpassword.\(suffix)") + let keyPassphrase = secret(from: store, key: "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 } + guard password != nil || sshPassword != nil || keyPassphrase != nil else { continue } credentialsMap[String(index)] = ExportableCredentials( - password: unwrappedPassword, - sshPassword: unwrappedSSH, - keyPassphrase: unwrappedKey, + password: password, + sshPassword: sshPassword, + keyPassphrase: keyPassphrase, sslClientKeyPassphrase: nil, totpSecret: nil, pluginSecureFields: nil @@ -133,6 +129,10 @@ enum IOSConnectionExportService { // MARK: - Helpers + private static func secret(from store: any SecureStore, key: String) -> String? { + (try? store.retrieve(forKey: key)) ?? nil + } + 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 { diff --git a/TableProMobile/TableProMobile/Views/MobileConnectionExportSheet.swift b/TableProMobile/TableProMobile/Views/MobileConnectionExportSheet.swift index 53260b3c8..3e13b5f74 100644 --- a/TableProMobile/TableProMobile/Views/MobileConnectionExportSheet.swift +++ b/TableProMobile/TableProMobile/Views/MobileConnectionExportSheet.swift @@ -17,6 +17,7 @@ struct MobileConnectionExportSheet: View { @State private var confirmPassphrase = "" @State private var error: String? @State private var shareItem: IdentifiableURL? + @State private var exportedURL: URL? private var canExport: Bool { guard includePasswords else { return true } @@ -67,7 +68,12 @@ struct MobileConnectionExportSheet: View { .disabled(!canExport || connections.isEmpty) } } - .sheet(item: $shareItem, onDismiss: { dismiss() }) { item in + .sheet(item: $shareItem, onDismiss: { + if let exportedURL { + try? FileManager.default.removeItem(at: exportedURL) + } + dismiss() + }) { item in ActivityViewController(items: [item.url]) } } @@ -90,6 +96,7 @@ struct MobileConnectionExportSheet: View { let filename = IOSConnectionExportService.suggestedFilename(for: connections) let url = FileManager.default.temporaryDirectory.appendingPathComponent(filename) try data.write(to: url, options: .atomic) + exportedURL = url shareItem = IdentifiableURL(url: url) } catch { self.error = error.localizedDescription diff --git a/TableProMobile/TableProMobile/Views/MobileConnectionImportSheet.swift b/TableProMobile/TableProMobile/Views/MobileConnectionImportSheet.swift index 21c7e5a9d..3a82b31e8 100644 --- a/TableProMobile/TableProMobile/Views/MobileConnectionImportSheet.swift +++ b/TableProMobile/TableProMobile/Views/MobileConnectionImportSheet.swift @@ -97,37 +97,40 @@ struct MobileConnectionImportSheet: View { 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) - } + Button { + toggle(item.id) + } label: { + HStack(spacing: 12) { + Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") + .foregroundStyle(isSelected ? Color.accentColor : Color.secondary) + + 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() + Spacer() + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) - if case .duplicate = item.status, isSelected { + if case .duplicate(let existingId, _) = 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)) - } + 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 { From 65d4dcb967c438fa5f979ed7812da769ec31838d Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 21 Jun 2026 18:13:44 +0700 Subject: [PATCH 4/5] fix(connections): import TableProImport in fileExporter document and export tests after merge --- TablePro/Views/Connection/ConnectionExportDocument.swift | 1 + .../Core/Services/Export/ConnectionExportDataTests.swift | 3 +-- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/TablePro/Views/Connection/ConnectionExportDocument.swift b/TablePro/Views/Connection/ConnectionExportDocument.swift index fda0539c7..ff93875d6 100644 --- a/TablePro/Views/Connection/ConnectionExportDocument.swift +++ b/TablePro/Views/Connection/ConnectionExportDocument.swift @@ -4,6 +4,7 @@ // import SwiftUI +import TableProImport import UniformTypeIdentifiers struct ConnectionExportDocument: FileDocument { diff --git a/TableProTests/Core/Services/Export/ConnectionExportDataTests.swift b/TableProTests/Core/Services/Export/ConnectionExportDataTests.swift index ee6a12091..0eb2453da 100644 --- a/TableProTests/Core/Services/Export/ConnectionExportDataTests.swift +++ b/TableProTests/Core/Services/Export/ConnectionExportDataTests.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProImport import Testing @testable import TablePro @@ -11,7 +12,6 @@ import Testing @Suite("Connection Export Data") @MainActor struct ConnectionExportDataTests { - private func makeConnection(name: String = "Dev") -> DatabaseConnection { DatabaseConnection( name: name, host: "db.example.com", port: 5_432, @@ -52,7 +52,6 @@ struct ConnectionExportDataTests { @Suite("Connection Export Passphrase State") struct ConnectionExportPassphraseStateTests { - @Test("empty passphrase is not exportable") func testEmpty() { let state = ConnectionExportPassphraseState.evaluate(passphrase: "", confirmation: "") From 526b1cd25257a81438f3011f41f2173b3b768d38 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 21 Jun 2026 18:24:15 +0700 Subject: [PATCH 5/5] fix(connections): route merged export tests through ConnectionImportDecoder --- .../Core/Services/Export/ConnectionExportDataTests.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/TableProTests/Core/Services/Export/ConnectionExportDataTests.swift b/TableProTests/Core/Services/Export/ConnectionExportDataTests.swift index 0eb2453da..71522b80d 100644 --- a/TableProTests/Core/Services/Export/ConnectionExportDataTests.swift +++ b/TableProTests/Core/Services/Export/ConnectionExportDataTests.swift @@ -24,7 +24,7 @@ struct ConnectionExportDataTests { let connections = [makeConnection(name: "Primary"), makeConnection(name: "Replica")] let data = try ConnectionExportService.exportData(connections) - let envelope = try ConnectionExportService.decodeData(data) + let envelope = try ConnectionImportDecoder.decodeData(data) #expect(envelope.connections.count == 2) #expect(envelope.connections.map(\.name) == ["Primary", "Replica"]) #expect(envelope.credentials == nil) @@ -36,7 +36,7 @@ struct ConnectionExportDataTests { let data = try ConnectionExportService.exportEncryptedData(connections, passphrase: "correct horse") #expect(ConnectionExportCrypto.isEncrypted(data)) - let envelope = try ConnectionExportService.decodeEncryptedData(data, passphrase: "correct horse") + let envelope = try ConnectionImportDecoder.decodeEncryptedData(data, passphrase: "correct horse") #expect(envelope.connections.map(\.name) == ["Secret"]) } @@ -45,7 +45,7 @@ struct ConnectionExportDataTests { let data = try ConnectionExportService.exportEncryptedData([makeConnection()], passphrase: "right-one") #expect(throws: (any Error).self) { - try ConnectionExportService.decodeEncryptedData(data, passphrase: "wrong-one") + try ConnectionImportDecoder.decodeEncryptedData(data, passphrase: "wrong-one") } } }