diff --git a/CHANGELOG.md b/CHANGELOG.md index ea9d012c..cc9c5fb6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Encrypted connection export with credentials: Pro users can include passwords in exports, protected by AES-256-GCM encryption with a passphrase - Connection sharing: export/import connections as `.tablepro` files (#466) - Import preview with duplicate detection, warning badges, and per-item resolution - "Copy as Import Link" context menu action for sharing via `tablepro://` URLs diff --git a/TablePro/Core/Services/Export/ConnectionExportCrypto.swift b/TablePro/Core/Services/Export/ConnectionExportCrypto.swift new file mode 100644 index 00000000..21108914 --- /dev/null +++ b/TablePro/Core/Services/Export/ConnectionExportCrypto.swift @@ -0,0 +1,131 @@ +// +// ConnectionExportCrypto.swift +// TablePro +// +// AES-256-GCM encryption for connection export files with PBKDF2 key derivation. +// + +import CommonCrypto +import CryptoKit +import Foundation + +enum ConnectionExportCryptoError: LocalizedError { + case invalidPassphrase + case corruptData + case unsupportedVersion(UInt8) + + var errorDescription: String? { + switch self { + case .invalidPassphrase: + return String(localized: "Incorrect passphrase") + case .corruptData: + return String(localized: "The encrypted file is corrupt or incomplete") + case .unsupportedVersion(let v): + return String(localized: "Unsupported encryption version \(v)") + } + } +} + +enum ConnectionExportCrypto { + private static let magic = Data("TPRO".utf8) // 4 bytes + 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 + + // 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 { + data.count > headerLength && data.prefix(4) == magic + } + + 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 } + return SecRandomCopyBytes(kSecRandomDefault, saltLength, baseAddress) + } + guard saltStatus == errSecSuccess else { + throw ConnectionExportCryptoError.corruptData + } + + let key = try deriveKey(passphrase: passphrase, salt: salt) + let nonce = AES.GCM.Nonce() + let sealed = try AES.GCM.seal(data, using: key, nonce: nonce) + + var result = Data() + result.append(magic) + result.append(currentVersion) + result.append(salt) + result.append(contentsOf: nonce) + result.append(sealed.ciphertext) + result.append(sealed.tag) + return result + } + + static func decrypt(data: Data, passphrase: String) throws -> Data { + guard data.count > headerLength else { + throw ConnectionExportCryptoError.corruptData + } + guard data.prefix(4) == magic else { + throw ConnectionExportCryptoError.corruptData + } + + let version = data[4] + guard version <= currentVersion else { + throw ConnectionExportCryptoError.unsupportedVersion(version) + } + + let salt = data[5 ..< 37] + let nonceData = data[37 ..< 49] + let ciphertextAndTag = data[49...] + + guard ciphertextAndTag.count > 16 else { + throw ConnectionExportCryptoError.corruptData + } + + let ciphertext = ciphertextAndTag.dropLast(16) + let tag = ciphertextAndTag.suffix(16) + + let key = try deriveKey(passphrase: passphrase, salt: Data(salt)) + let nonce = try AES.GCM.Nonce(data: nonceData) + let sealedBox = try AES.GCM.SealedBox(nonce: nonce, ciphertext: ciphertext, tag: tag) + + do { + return try AES.GCM.open(sealedBox, using: key) + } catch { + throw ConnectionExportCryptoError.invalidPassphrase + } + } + + private static func deriveKey(passphrase: String, salt: Data) throws -> SymmetricKey { + let passphraseData = Data(passphrase.utf8) + var derivedKey = Data(count: keyLength) + + let status = derivedKey.withUnsafeMutableBytes { derivedKeyBytes in + passphraseData.withUnsafeBytes { passphraseBytes in + salt.withUnsafeBytes { saltBytes in + CCKeyDerivationPBKDF( + CCPBKDFAlgorithm(kCCPBKDF2), + passphraseBytes.baseAddress?.assumingMemoryBound(to: Int8.self), + passphraseData.count, + saltBytes.baseAddress?.assumingMemoryBound(to: UInt8.self), + salt.count, + CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA256), + pbkdf2Iterations, + derivedKeyBytes.baseAddress?.assumingMemoryBound(to: UInt8.self), + keyLength + ) + } + } + } + + guard status == kCCSuccess else { + throw ConnectionExportCryptoError.corruptData + } + + return SymmetricKey(data: derivedKey) + } +} diff --git a/TablePro/Core/Services/Export/ConnectionExportService.swift b/TablePro/Core/Services/Export/ConnectionExportService.swift index 4e9eafb8..10f9f14e 100644 --- a/TablePro/Core/Services/Export/ConnectionExportService.swift +++ b/TablePro/Core/Services/Export/ConnectionExportService.swift @@ -16,6 +16,8 @@ enum ConnectionExportError: LocalizedError { case invalidFormat case unsupportedVersion(Int) case decodingFailed(String) + case requiresPassphrase + case decryptionFailed(String) var errorDescription: String? { switch self { @@ -31,6 +33,10 @@ enum ConnectionExportError: LocalizedError { return String(localized: "This file requires a newer version of TablePro (format version \(version))") case .decodingFailed(let detail): return 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(localized: "Decryption failed: \(detail)") } } } @@ -217,7 +223,8 @@ enum ConnectionExportService { appVersion: appVersion, connections: exportableConnections, groups: exportableGroups, - tags: exportableTags + tags: exportableTags, + credentials: nil ) } @@ -246,6 +253,82 @@ enum ConnectionExportService { } } + // MARK: - Encrypted Export + + static func buildEnvelopeWithCredentials(for connections: [DatabaseConnection]) -> ConnectionExportEnvelope { + let baseEnvelope = buildEnvelope(for: connections) + + var credentialsMap: [String: ExportableCredentials] = [:] + for (index, connection) in connections.enumerated() { + let password = ConnectionStorage.shared.loadPassword(for: connection.id) + let sshPassword = ConnectionStorage.shared.loadSSHPassword(for: connection.id) + let keyPassphrase = ConnectionStorage.shared.loadKeyPassphrase(for: connection.id) + let totpSecret = ConnectionStorage.shared.loadTOTPSecret(for: connection.id) + + // Collect plugin-specific secure fields + var pluginSecureFields: [String: String]? + if let snapshot = PluginMetadataRegistry.shared.snapshot(forTypeId: connection.type.pluginTypeId) { + let secureFieldIds = snapshot.connection.additionalConnectionFields + .filter(\.isSecure) + .map(\.id) + if !secureFieldIds.isEmpty { + var fields: [String: String] = [:] + for fieldId in secureFieldIds { + if let value = ConnectionStorage.shared.loadPluginSecureField( + fieldId: fieldId, + for: connection.id + ) { + fields[fieldId] = value + } + } + if !fields.isEmpty { + pluginSecureFields = fields + } + } + } + + let hasAnyCredential = password != nil || sshPassword != nil + || keyPassphrase != nil || totpSecret != nil || pluginSecureFields != nil + + if hasAnyCredential { + credentialsMap[String(index)] = ExportableCredentials( + password: password, + sshPassword: sshPassword, + keyPassphrase: keyPassphrase, + totpSecret: totpSecret, + pluginSecureFields: pluginSecureFields + ) + } + } + + return ConnectionExportEnvelope( + formatVersion: baseEnvelope.formatVersion, + exportedAt: baseEnvelope.exportedAt, + appVersion: baseEnvelope.appVersion, + connections: baseEnvelope.connections, + groups: baseEnvelope.groups, + tags: baseEnvelope.tags, + credentials: credentialsMap.isEmpty ? nil : credentialsMap + ) + } + + static func exportConnectionsEncrypted( + _ connections: [DatabaseConnection], + to url: URL, + passphrase: String + ) throws { + let envelope = buildEnvelopeWithCredentials(for: connections) + let jsonData = try encode(envelope) + let encryptedData = try ConnectionExportCrypto.encrypt(data: jsonData, passphrase: passphrase) + + do { + try encryptedData.write(to: url, options: .atomic) + logger.info("Exported \(connections.count) encrypted connections to \(url.path)") + } catch { + throw ConnectionExportError.fileWriteFailed(url.path) + } + } + // MARK: - Import static func decodeFile(at url: URL) throws -> ConnectionExportEnvelope { @@ -255,9 +338,53 @@ enum ConnectionExportService { } catch { throw ConnectionExportError.fileReadFailed(url.path) } + + if ConnectionExportCrypto.isEncrypted(data) { + 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) + } + + static func restoreCredentials(from envelope: ConnectionExportEnvelope, connectionIdMap: [Int: UUID]) { + guard let credentials = envelope.credentials else { return } + + for (indexString, creds) in credentials { + guard let index = Int(indexString), + let connectionId = connectionIdMap[index] else { continue } + + if let password = creds.password { + ConnectionStorage.shared.savePassword(password, for: connectionId) + } + if let sshPassword = creds.sshPassword { + ConnectionStorage.shared.saveSSHPassword(sshPassword, for: connectionId) + } + if let keyPassphrase = creds.keyPassphrase { + ConnectionStorage.shared.saveKeyPassphrase(keyPassphrase, for: connectionId) + } + if let totpSecret = creds.totpSecret { + ConnectionStorage.shared.saveTOTPSecret(totpSecret, for: connectionId) + } + if let secureFields = creds.pluginSecureFields { + for (fieldId, value) in secureFields { + ConnectionStorage.shared.savePluginSecureField(value, fieldId: fieldId, for: connectionId) + } + } + } + + logger.info("Restored credentials for \(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() @@ -343,11 +470,16 @@ enum ConnectionExportService { return ConnectionImportPreview(envelope: envelope, items: items) } + struct ImportResult { + let importedCount: Int + let connectionIdMap: [Int: UUID] // envelope index -> new connection UUID + } + @discardableResult static func performImport( _ preview: ConnectionImportPreview, resolutions: [UUID: ImportResolution] - ) -> Int { + ) -> ImportResult { // Create missing groups let existingGroups = GroupStorage.shared.loadGroups() if let envelopeGroups = preview.envelope.groups { @@ -387,9 +519,17 @@ enum ConnectionExportService { } var importedCount = 0 + var connectionIdMap: [Int: UUID] = [:] + + // Build a lookup from item.id to envelope index + let itemIndexMap: [UUID: Int] = Dictionary( + uniqueKeysWithValues: preview.items.enumerated().map { ($1.id, $0) } + ) for item in preview.items { let resolution = resolutions[item.id] ?? .skip + guard let envelopeIndex = itemIndexMap[item.id] else { continue } + switch resolution { case .skip: continue @@ -406,6 +546,7 @@ enum ConnectionExportService { name: name ) ConnectionStorage.shared.addConnection(connection, password: nil) + connectionIdMap[envelopeIndex] = connectionId importedCount += 1 case .replace(let existingId): @@ -415,6 +556,7 @@ enum ConnectionExportService { name: item.connection.name ) ConnectionStorage.shared.updateConnection(connection, password: nil) + connectionIdMap[envelopeIndex] = existingId importedCount += 1 } } @@ -424,7 +566,7 @@ enum ConnectionExportService { logger.info("Imported \(importedCount) connections") } - return importedCount + return ImportResult(importedCount: importedCount, connectionIdMap: connectionIdMap) } // MARK: - Deeplink Builder diff --git a/TablePro/Models/Connection/ConnectionExport.swift b/TablePro/Models/Connection/ConnectionExport.swift index 6b6cb8cc..920d1307 100644 --- a/TablePro/Models/Connection/ConnectionExport.swift +++ b/TablePro/Models/Connection/ConnectionExport.swift @@ -6,13 +6,18 @@ import Foundation import UniformTypeIdentifiers -// MARK: - Identifiable URL (for sheet binding) +// MARK: - Sheet Binding Wrappers struct IdentifiableURL: Identifiable { let id = UUID() let url: URL } +struct IdentifiableConnections: Identifiable { + let id = UUID() + let connections: [DatabaseConnection] +} + // MARK: - UTType extension UTType { @@ -29,6 +34,7 @@ struct ConnectionExportEnvelope: Codable { let connections: [ExportableConnection] let groups: [ExportableGroup]? let tags: [ExportableTag]? + let credentials: [String: ExportableCredentials]? // keyed by connection index "0", "1", ... } // MARK: - Exportable Connection @@ -99,6 +105,16 @@ struct ExportableTag: Codable { let color: String? } +// MARK: - Credentials (encrypted export only) + +struct ExportableCredentials: Codable { + let password: String? + let sshPassword: String? + let keyPassphrase: String? + let totpSecret: String? + let pluginSecureFields: [String: String]? +} + // MARK: - Path Portability enum PathPortability { diff --git a/TablePro/Models/Settings/ProFeature.swift b/TablePro/Models/Settings/ProFeature.swift index ec63c39a..c986770b 100644 --- a/TablePro/Models/Settings/ProFeature.swift +++ b/TablePro/Models/Settings/ProFeature.swift @@ -12,6 +12,7 @@ internal enum ProFeature: String, CaseIterable { case iCloudSync case safeMode case xlsxExport + case encryptedExport var displayName: String { switch self { @@ -21,6 +22,8 @@ internal enum ProFeature: String, CaseIterable { return String(localized: "Safe Mode") case .xlsxExport: return String(localized: "XLSX Export") + case .encryptedExport: + return String(localized: "Encrypted Export") } } @@ -32,6 +35,8 @@ internal enum ProFeature: String, CaseIterable { return "lock.shield" case .xlsxExport: return "tablecells" + case .encryptedExport: + return "lock.doc" } } @@ -43,6 +48,8 @@ internal enum ProFeature: String, CaseIterable { return String(localized: "Require confirmation or Touch ID before executing queries.") case .xlsxExport: return String(localized: "Export query results and tables to Excel format.") + case .encryptedExport: + return String(localized: "Export connections with encrypted credentials.") } } } diff --git a/TablePro/Views/Connection/ConnectionExportOptionsSheet.swift b/TablePro/Views/Connection/ConnectionExportOptionsSheet.swift new file mode 100644 index 00000000..2d3d8227 --- /dev/null +++ b/TablePro/Views/Connection/ConnectionExportOptionsSheet.swift @@ -0,0 +1,127 @@ +// +// ConnectionExportOptionsSheet.swift +// TablePro +// +// Sheet for choosing export options before saving a .tablepro file. +// + +import SwiftUI +import UniformTypeIdentifiers + +struct ConnectionExportOptionsSheet: View { + let connections: [DatabaseConnection] + + @Environment(\.dismiss) private var dismiss + @State private var includeCredentials = false + @State private var passphrase = "" + @State private var confirmPassphrase = "" + + private var isProAvailable: Bool { + LicenseManager.shared.isFeatureAvailable(.encryptedExport) + } + + private var canExport: Bool { + if includeCredentials { + return (passphrase as NSString).length >= 8 && passphrase == confirmPassphrase + } + return true + } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text(String(localized: "Export Options")) + .font(.system(size: 13, weight: .semibold)) + .frame(maxWidth: .infinity, alignment: .leading) + + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 4) { + Toggle("Include Credentials", isOn: $includeCredentials) + .toggleStyle(.checkbox) + .disabled(!isProAvailable) + + if !isProAvailable { + Text("Pro") + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(.white) + .padding(.horizontal, 5) + .padding(.vertical, 1) + .background( + RoundedRectangle(cornerRadius: 3) + .fill(Color.accentColor) + ) + } + } + + if includeCredentials { + Text("Passwords will be encrypted with the passphrase you provide.") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + + SecureField("Passphrase (8+ characters)", text: $passphrase) + .textFieldStyle(.roundedBorder) + + SecureField("Confirm passphrase", text: $confirmPassphrase) + .textFieldStyle(.roundedBorder) + + if !passphrase.isEmpty && !confirmPassphrase.isEmpty && passphrase != confirmPassphrase { + Text("Passphrases do not match") + .font(.system(size: 11)) + .foregroundStyle(.red) + } + } + } + + HStack { + Spacer() + Button("Cancel") { dismiss() } + .keyboardShortcut(.cancelAction) + Button("Export...") { performExport() } + .buttonStyle(.borderedProminent) + .keyboardShortcut(.defaultAction) + .disabled(!canExport) + } + } + .padding(20) + .frame(width: 380) + } + + private func performExport() { + let shouldEncrypt = includeCredentials && isProAvailable + let capturedPassphrase = passphrase + let capturedConnections = connections + + // Zero passphrase state before dismissing + passphrase = "" + confirmPassphrase = "" + dismiss() + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + let panel = NSSavePanel() + panel.allowedContentTypes = [.tableproConnectionShare] + let defaultName = capturedConnections.count == 1 + ? "\(capturedConnections[0].name).tablepro" + : "Connections.tablepro" + panel.nameFieldStringValue = defaultName + panel.canCreateDirectories = true + guard panel.runModal() == .OK, let url = panel.url else { return } + + do { + if shouldEncrypt { + try ConnectionExportService.exportConnectionsEncrypted( + capturedConnections, + to: url, + passphrase: capturedPassphrase + ) + } else { + try ConnectionExportService.exportConnections(capturedConnections, to: url) + } + } catch { + AlertHelper.showErrorSheet( + title: String(localized: "Export Failed"), + message: error.localizedDescription, + window: NSApp.keyWindow + ) + } + } + } +} diff --git a/TablePro/Views/Connection/ConnectionImportSheet.swift b/TablePro/Views/Connection/ConnectionImportSheet.swift index e8655217..67b744b1 100644 --- a/TablePro/Views/Connection/ConnectionImportSheet.swift +++ b/TablePro/Views/Connection/ConnectionImportSheet.swift @@ -17,6 +17,11 @@ struct ConnectionImportSheet: View { @State private var isLoading = true @State private var selectedIds: Set = [] @State private var duplicateResolutions: [UUID: ImportResolution] = [:] + @State private var encryptedData: Data? + @State private var passphrase = "" + @State private var passphraseError: String? + @State private var isDecrypting = false + @State private var wasEncryptedImport = false var body: some View { VStack(spacing: 0) { @@ -24,6 +29,8 @@ struct ConnectionImportSheet: View { loadingView } else if let error { errorView(error) + } else if encryptedData != nil { + passphraseView } else if let preview { header(preview) Divider() @@ -203,6 +210,51 @@ struct ConnectionImportSheet: View { } } + // MARK: - Passphrase + + private var passphraseView: some View { + VStack(spacing: 16) { + Spacer() + + Image(systemName: "lock.fill") + .font(.system(size: 32)) + .foregroundStyle(.secondary) + + Text("This file is encrypted") + .font(.system(size: 13, weight: .semibold)) + + Text("Enter the passphrase to decrypt and import connections.") + .font(.system(size: 12)) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + + SecureField(String(localized: "Passphrase"), text: $passphrase) + .textFieldStyle(.roundedBorder) + .frame(width: 260) + .onSubmit { decryptFile() } + + if let passphraseError { + Text(passphraseError) + .font(.system(size: 11)) + .foregroundStyle(.red) + } + + Spacer() + + HStack { + Spacer() + Button(String(localized: "Cancel")) { dismiss() } + .keyboardShortcut(.cancelAction) + Button(String(localized: "Decrypt")) { decryptFile() } + .buttonStyle(.borderedProminent) + .keyboardShortcut(.defaultAction) + .disabled(passphrase.isEmpty || isDecrypting) + } + .padding(12) + } + .padding(.horizontal) + } + // MARK: - Footer private func footer(_ preview: ConnectionImportPreview) -> some View { @@ -235,18 +287,20 @@ struct ConnectionImportSheet: View { Task.detached(priority: .userInitiated) { do { let data = try Data(contentsOf: url) + + if ConnectionExportCrypto.isEncrypted(data) { + await MainActor.run { + encryptedData = data + isLoading = false + } + return + } + let envelope = try ConnectionExportService.decodeData(data) - let result = ConnectionExportService.analyzeImport(envelope) + let result = await ConnectionExportService.analyzeImport(envelope) await MainActor.run { preview = result - for item in result.items { - switch item.status { - case .ready, .warnings: - selectedIds.insert(item.id) - case .duplicate: - break - } - } + selectReadyItems(result) isLoading = false } } catch { @@ -258,6 +312,44 @@ struct ConnectionImportSheet: View { } } + private func decryptFile() { + guard let data = encryptedData, !isDecrypting else { return } + let currentPassphrase = passphrase + isDecrypting = true + + Task.detached(priority: .userInitiated) { + do { + let envelope = try ConnectionExportService.decodeEncryptedData(data, passphrase: currentPassphrase) + let result = await ConnectionExportService.analyzeImport(envelope) + await MainActor.run { + passphraseError = nil + encryptedData = nil + wasEncryptedImport = true + preview = result + selectReadyItems(result) + isDecrypting = false + } + } catch { + await MainActor.run { + passphraseError = error.localizedDescription + passphrase = "" + isDecrypting = false + } + } + } + } + + private func selectReadyItems(_ result: ConnectionImportPreview) { + for item in result.items { + switch item.status { + case .ready, .warnings: + selectedIds.insert(item.id) + case .duplicate: + break + } + } + } + private func performImport(_ preview: ConnectionImportPreview) { var resolutions: [UUID: ImportResolution] = [:] for item in preview.items { @@ -273,8 +365,17 @@ struct ConnectionImportSheet: View { } } - let count = ConnectionExportService.performImport(preview, resolutions: resolutions) + let result = ConnectionExportService.performImport(preview, resolutions: resolutions) + + // Only restore credentials from verified encrypted imports (not plaintext files) + if wasEncryptedImport, preview.envelope.credentials != nil { + ConnectionExportService.restoreCredentials( + from: preview.envelope, + connectionIdMap: result.connectionIdMap + ) + } + dismiss() - onImported?(count) + onImported?(result.importedCount) } } diff --git a/TablePro/Views/Connection/WelcomeWindowView.swift b/TablePro/Views/Connection/WelcomeWindowView.swift index e2c4cafb..f0fc4147 100644 --- a/TablePro/Views/Connection/WelcomeWindowView.swift +++ b/TablePro/Views/Connection/WelcomeWindowView.swift @@ -45,6 +45,7 @@ struct WelcomeWindowView: View { @State private var showActivationSheet = false @State private var pluginInstallConnection: DatabaseConnection? @State private var importFileURL: IdentifiableURL? + @State private var pendingExportConnections: IdentifiableConnections? @Environment(\.openWindow) private var openWindow @@ -169,6 +170,9 @@ struct WelcomeWindowView: View { } } } + .sheet(item: $pendingExportConnections) { item in + ConnectionExportOptionsSheet(connections: item.connections) + } .onReceive(NotificationCenter.default.publisher(for: .connectionShareFileOpened)) { notification in guard let url = notification.object as? URL else { return } importFileURL = IdentifiableURL(url: url) @@ -508,29 +512,6 @@ struct WelcomeWindowView: View { .listRowInsets(ThemeEngine.shared.activeTheme.spacing.listRowInsets.swiftUI) .listRowSeparator(.hidden) .contextMenu { contextMenuContent(for: connection) } - .onDrag { - let provider = NSItemProvider() - provider.registerFileRepresentation( - forTypeIdentifier: UTType.tableproConnectionShare.identifier, - visibility: .all - ) { completion in - do { - let envelope = ConnectionExportService.buildEnvelope(for: [connection]) - let data = try ConnectionExportService.encode(envelope) - let safeName = connection.name - .replacingOccurrences(of: "/", with: "-") - .replacingOccurrences(of: ":", with: "-") - let tempURL = FileManager.default.temporaryDirectory - .appendingPathComponent("\(safeName)-\(connection.id.uuidString).tablepro") - try data.write(to: tempURL, options: .atomic) - completion(tempURL, true, nil) - } catch { - completion(nil, false, error) - } - return nil - } - return provider - } } private func groupHeader(for group: ConnectionGroup) -> some View { @@ -829,24 +810,7 @@ struct WelcomeWindowView: View { // MARK: - Connection Sharing private func exportConnections(_ connectionsToExport: [DatabaseConnection]) { - let panel = NSSavePanel() - panel.allowedContentTypes = [.tableproConnectionShare] - let defaultName = connectionsToExport.count == 1 - ? "\(connectionsToExport[0].name).tablepro" - : "Connections.tablepro" - panel.nameFieldStringValue = defaultName - panel.canCreateDirectories = true - guard panel.runModal() == .OK, let url = panel.url else { return } - - do { - try ConnectionExportService.exportConnections(connectionsToExport, to: url) - } catch { - AlertHelper.showErrorSheet( - title: String(localized: "Export Failed"), - message: error.localizedDescription, - window: NSApp.keyWindow - ) - } + pendingExportConnections = IdentifiableConnections(connections: connectionsToExport) } private func importConnectionsFromFile() { diff --git a/docs/features/connection-sharing.mdx b/docs/features/connection-sharing.mdx index 72621f65..275a74a2 100644 --- a/docs/features/connection-sharing.mdx +++ b/docs/features/connection-sharing.mdx @@ -11,7 +11,6 @@ Share connections with your team using `.tablepro` files. Passwords are not incl - Right-click a connection > **Export Connection...** - Select multiple, then right-click > **Export N Connections...** -- Drag a connection from the sidebar to Finder or Desktop - **File** menu > **Export Connections...** (exports all) The file includes host, port, username, type, SSH/SSL config, color, tag, group, and safe mode. Passwords are excluded. @@ -65,7 +64,20 @@ Groups and tags match by name. Missing ones are created. Paths use `~/` for port ## Passwords -Not exported. Enter yours after importing. File paths use `~/` instead of full paths. +By default, passwords are not exported. Enter yours after importing. File paths use `~/` instead of full paths. + +### Encrypted Export (Pro) + +Pro users can include credentials in the export file: + +1. Right-click > **Export...** (or use the File menu) +2. Check **Include Credentials** +3. Enter a passphrase (8+ characters) and confirm it +4. Click **Export...** + +The file is encrypted with AES-256-GCM. When importing an encrypted file, you'll be prompted for the passphrase. + +Included credentials: database password, SSH password, key passphrase, TOTP secret, and plugin-specific secure fields. ## Sharing vs iCloud Sync @@ -73,5 +85,5 @@ Not exported. Enter yours after importing. File paths use `~/` instead of full p |---|---|---| | **For** | Team | Your Macs | | **How** | Files, links | CloudKit | -| **Passwords** | Enter your own | Optional sync | -| **Requires** | Nothing | Pro + iCloud | +| **Passwords** | Optional (encrypted, Pro) | Optional sync | +| **Requires** | Nothing (Pro for credentials) | Pro + iCloud |