diff --git a/CHANGELOG.md b/CHANGELOG.md index 3217b1a91..915921ae9 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 - Drag-selecting many columns in a wide result set scrolls smoothly instead of lagging; the selection overlay and row highlight now look up column positions from a cache that refreshes when columns are added, removed, or reordered. 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 672b064ea..c9a02019b 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) @@ -362,17 +305,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]) { @@ -409,33 +342,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, @@ -451,74 +357,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 { @@ -896,43 +740,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/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/TablePro/ViewModels/WelcomeViewModel.swift b/TablePro/ViewModels/WelcomeViewModel.swift index f8b6810d7..a358b2681 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/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/TablePro/Views/Connection/ConnectionExportOptionsSheet.swift b/TablePro/Views/Connection/ConnectionExportOptionsSheet.swift index c90fc1a97..afba59e60 100644 --- a/TablePro/Views/Connection/ConnectionExportOptionsSheet.swift +++ b/TablePro/Views/Connection/ConnectionExportOptionsSheet.swift @@ -4,6 +4,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/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 diff --git a/TableProMobile/TableProMobile/Services/IOSConnectionExportService.swift b/TableProMobile/TableProMobile/Services/IOSConnectionExportService.swift new file mode 100644 index 000000000..4208e3200 --- /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 = 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)") + + guard password != nil || sshPassword != nil || keyPassphrase != nil else { continue } + credentialsMap[String(index)] = ExportableCredentials( + password: password, + sshPassword: sshPassword, + keyPassphrase: keyPassphrase, + 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 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 { + 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..3e13b5f74 --- /dev/null +++ b/TableProMobile/TableProMobile/Views/MobileConnectionExportSheet.swift @@ -0,0 +1,105 @@ +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? + @State private var exportedURL: URL? + + 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: { + if let exportedURL { + try? FileManager.default.removeItem(at: exportedURL) + } + 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) + 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 new file mode 100644 index 000000000..3a82b31e8 --- /dev/null +++ b/TableProMobile/TableProMobile/Views/MobileConnectionImportSheet.swift @@ -0,0 +1,244 @@ +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) { + 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() + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + + if case .duplicate(let existingId, _) = item.status, isSelected { + Picker("", selection: resolutionBinding(for: item)) { + Text(String(localized: "As Copy")).tag(ImportResolution.importAsCopy) + Text(String(localized: "Replace")).tag(ImportResolution.replace(existingId: existingId)) + } + .pickerStyle(.menu) + .labelsHidden() + } + } + } + + 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/Export/ConnectionExportDataTests.swift b/TableProTests/Core/Services/Export/ConnectionExportDataTests.swift index ee6a12091..71522b80d 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, @@ -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,14 +45,13 @@ 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") } } } @Suite("Connection Export Passphrase State") struct ConnectionExportPassphraseStateTests { - @Test("empty passphrase is not exportable") func testEmpty() { let state = ConnectionExportPassphraseState.evaluate(passphrase: "", confirmation: "") 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.