diff --git a/CHANGELOG.md b/CHANGELOG.md index 553ae5ded..c3b21a75e 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 ### Changed +- The connection Export Options dialog keeps a steady size when you turn on Include Credentials, and saves through the standard macOS save dialog. - Data grid now serves the row count from its existing cache instead of recomputing it on every layout pass, reducing CPU churn while scrolling large result sets. - Typing in the sidebar table search stays responsive on databases with thousands of tables; filtering runs after a short pause instead of on every keystroke. diff --git a/TablePro/Core/Services/Export/ConnectionExportService.swift b/TablePro/Core/Services/Export/ConnectionExportService.swift index 401a68430..672b064ea 100644 --- a/TablePro/Core/Services/Export/ConnectionExportService.swift +++ b/TablePro/Core/Services/Export/ConnectionExportService.swift @@ -251,9 +251,12 @@ enum ConnectionExportService { } } + static func exportData(_ connections: [DatabaseConnection]) throws -> Data { + try encode(buildEnvelope(for: connections)) + } + static func exportConnections(_ connections: [DatabaseConnection], to url: URL) throws { - let envelope = buildEnvelope(for: connections) - let data = try encode(envelope) + let data = try exportData(connections) do { try data.write(to: url, options: .atomic) @@ -325,14 +328,17 @@ enum ConnectionExportService { ) } + static func exportEncryptedData(_ connections: [DatabaseConnection], passphrase: String) throws -> Data { + let jsonData = try encode(buildEnvelopeWithCredentials(for: connections)) + return try ConnectionExportCrypto.encrypt(data: jsonData, passphrase: passphrase) + } + 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) + let encryptedData = try exportEncryptedData(connections, passphrase: passphrase) do { try encryptedData.write(to: url, options: .atomic) diff --git a/TablePro/Views/Connection/ConnectionExportDocument.swift b/TablePro/Views/Connection/ConnectionExportDocument.swift new file mode 100644 index 000000000..fda0539c7 --- /dev/null +++ b/TablePro/Views/Connection/ConnectionExportDocument.swift @@ -0,0 +1,29 @@ +// +// ConnectionExportDocument.swift +// TablePro +// + +import SwiftUI +import UniformTypeIdentifiers + +struct ConnectionExportDocument: FileDocument { + static let readableContentTypes: [UTType] = [.tableproConnectionShare] + static let writableContentTypes: [UTType] = [.tableproConnectionShare] + + let data: Data + + init(data: Data) { + self.data = data + } + + init(configuration: ReadConfiguration) throws { + guard let contents = configuration.file.regularFileContents else { + throw CocoaError(.fileReadCorruptFile) + } + data = contents + } + + func fileWrapper(configuration _: WriteConfiguration) throws -> FileWrapper { + FileWrapper(regularFileWithContents: data) + } +} diff --git a/TablePro/Views/Connection/ConnectionExportOptionsSheet.swift b/TablePro/Views/Connection/ConnectionExportOptionsSheet.swift index 0cee45b7f..c90fc1a97 100644 --- a/TablePro/Views/Connection/ConnectionExportOptionsSheet.swift +++ b/TablePro/Views/Connection/ConnectionExportOptionsSheet.swift @@ -2,8 +2,6 @@ // ConnectionExportOptionsSheet.swift // TablePro // -// Sheet for choosing export options before saving a .tablepro file. -// import SwiftUI import UniformTypeIdentifiers @@ -15,120 +13,171 @@ struct ConnectionExportOptionsSheet: View { @State private var includeCredentials = false @State private var passphrase = "" @State private var confirmPassphrase = "" + @State private var exportDocument: ConnectionExportDocument? + @State private var isExporting = false + @State private var exportError: String? private var isProAvailable: Bool { LicenseManager.shared.isFeatureAvailable(.encryptedExport) } + private var passphraseState: ConnectionExportPassphraseState { + ConnectionExportPassphraseState.evaluate(passphrase: passphrase, confirmation: confirmPassphrase) + } + private var canExport: Bool { - if includeCredentials { - return (passphrase as NSString).length >= 8 && passphrase == confirmPassphrase - } - return true + guard includeCredentials else { return true } + return passphraseState.allowsExport + } + + private var defaultFilename: String { + connections.count == 1 ? connections[0].name : String(localized: "Connections") } var body: some View { VStack(spacing: 0) { - Text(String(localized: "Export Options")) - .font(.body.weight(.semibold)) - .padding(.vertical, 12) + header Divider() - Form { - Section { - HStack(spacing: 6) { - Toggle("Include Credentials", isOn: $includeCredentials) - .toggleStyle(.checkbox) - .disabled(!isProAvailable) - if !isProAvailable { - ProBadge() - } - } - } footer: { - if includeCredentials { - Text("Passwords will be encrypted with the passphrase you provide.") - } - } + options + .padding(20) - if includeCredentials { - Section { - LabeledContent(String(localized: "Passphrase")) { - SecureField(String(localized: "8+ characters"), text: $passphrase) - } - LabeledContent(String(localized: "Confirm")) { - SecureField(String(localized: "Re-enter passphrase"), text: $confirmPassphrase) - } - } + Spacer(minLength: 0) + + Divider() + + footer + .padding(16) + } + .frame(width: 440, height: 300) + .fileExporter( + isPresented: $isExporting, + document: exportDocument, + contentType: .tableproConnectionShare, + defaultFilename: defaultFilename + ) { result in + if case .failure(let error) = result, (error as NSError).code != NSUserCancelledError { + exportError = error.localizedDescription + return + } + dismiss() + } + .alert( + String(localized: "Export Failed"), + isPresented: Binding(get: { exportError != nil }, set: { if !$0 { exportError = nil } }) + ) { + Button(String(localized: "OK"), role: .cancel) { exportError = nil } + } message: { + if let exportError { + Text(exportError) + } + } + } + + private var header: some View { + VStack(spacing: 2) { + Text("Export Options") + .font(.headline) + Text(exportSummary) + .font(.subheadline) + .foregroundStyle(.secondary) + } + .padding(.vertical, 14) + } + + private var exportSummary: String { + connections.count == 1 + ? connections[0].name + : String(format: String(localized: "%d connections"), connections.count) + } - if !passphrase.isEmpty && !confirmPassphrase.isEmpty && passphrase != confirmPassphrase { - Section { - Label(String(localized: "Passphrases do not match"), systemImage: "exclamationmark.triangle.fill") - .foregroundStyle(.orange) - } + private var options: some View { + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 6) { + Toggle("Include Credentials", isOn: $includeCredentials) + .toggleStyle(.checkbox) + .disabled(!isProAvailable) + if !isProAvailable { + ProBadge() } } + Text("Off by default. Turn it on to encrypt saved passwords with a passphrase.") + .font(.caption) + .foregroundStyle(.secondary) } - .formStyle(.grouped) - .scrollContentBackground(.hidden) - Divider() - - HStack { - Button("Cancel") { dismiss() } - .keyboardShortcut(.cancelAction) - Spacer() - Button("Export...") { performExport() } - .buttonStyle(.borderedProminent) - .keyboardShortcut(.defaultAction) - .disabled(!canExport) + if includeCredentials { + passphraseFields } - .padding(12) } - .frame(width: 420) + .frame(maxWidth: .infinity, alignment: .leading) } - private func performExport() { - let shouldEncrypt = includeCredentials && isProAvailable - let capturedPassphrase = passphrase - let capturedConnections = connections - - // Zero passphrase state before dismissing - passphrase = "" - confirmPassphrase = "" - dismiss() - - Task { @MainActor in - try? await Task.sleep(for: .milliseconds(200)) - let panel = NSSavePanel() - panel.allowedContentTypes = [.tableproConnectionShare] - let defaultName = capturedConnections.count == 1 - ? "\(capturedConnections[0].name).tablepro" - : "Connections.tablepro" - panel.nameFieldStringValue = defaultName - panel.canCreateDirectories = true - guard let window = NSApp.keyWindow else { return } - panel.beginSheetModal(for: window) { response in - guard response == .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: window - ) + private var passphraseFields: some View { + VStack(alignment: .leading, spacing: 8) { + Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) { + GridRow { + Text("Passphrase") + .gridColumnAlignment(.trailing) + SecureField(String(localized: "8+ characters"), text: $passphrase) + .textFieldStyle(.roundedBorder) + } + GridRow { + Text("Confirm") + .gridColumnAlignment(.trailing) + SecureField(String(localized: "Re-enter passphrase"), text: $confirmPassphrase) + .textFieldStyle(.roundedBorder) } } + + validationMessage + .frame(height: 16, alignment: .leading) + } + } + + @ViewBuilder + private var validationMessage: some View { + switch passphraseState { + case .tooShort: + warningLabel(String(localized: "Use at least 8 characters")) + case .mismatch: + warningLabel(String(localized: "Passphrases do not match")) + case .empty, .incomplete, .ok: + EmptyView() + } + } + + private func warningLabel(_ text: String) -> some View { + Label(text, systemImage: "exclamationmark.triangle.fill") + .font(.caption) + .foregroundStyle(.orange) + } + + private var footer: some View { + HStack { + Button("Cancel") { dismiss() } + .keyboardShortcut(.cancelAction) + Spacer() + Button("Export...") { performExport() } + .buttonStyle(.borderedProminent) + .keyboardShortcut(.defaultAction) + .disabled(!canExport) + } + } + + private func performExport() { + do { + let data = includeCredentials && isProAvailable + ? try ConnectionExportService.exportEncryptedData(connections, passphrase: passphrase) + : try ConnectionExportService.exportData(connections) + passphrase = "" + confirmPassphrase = "" + exportDocument = ConnectionExportDocument(data: data) + isExporting = true + } catch { + exportError = error.localizedDescription } } } diff --git a/TablePro/Views/Connection/ConnectionExportPassphraseState.swift b/TablePro/Views/Connection/ConnectionExportPassphraseState.swift new file mode 100644 index 000000000..db53a14b5 --- /dev/null +++ b/TablePro/Views/Connection/ConnectionExportPassphraseState.swift @@ -0,0 +1,28 @@ +// +// ConnectionExportPassphraseState.swift +// TablePro +// + +import Foundation + +enum ConnectionExportPassphraseState: Equatable { + case empty + case tooShort + case incomplete + case mismatch + case ok + + static let minimumLength = 8 + + static func evaluate(passphrase: String, confirmation: String) -> ConnectionExportPassphraseState { + if passphrase.isEmpty { return .empty } + if (passphrase as NSString).length < minimumLength { return .tooShort } + if confirmation.isEmpty { return .incomplete } + if passphrase != confirmation { return .mismatch } + return .ok + } + + var allowsExport: Bool { + self == .ok + } +} diff --git a/TableProTests/Core/Services/Export/ConnectionExportDataTests.swift b/TableProTests/Core/Services/Export/ConnectionExportDataTests.swift new file mode 100644 index 000000000..ee6a12091 --- /dev/null +++ b/TableProTests/Core/Services/Export/ConnectionExportDataTests.swift @@ -0,0 +1,90 @@ +// +// ConnectionExportDataTests.swift +// TableProTests +// + +import Foundation +import Testing + +@testable import TablePro + +@Suite("Connection Export Data") +@MainActor +struct ConnectionExportDataTests { + + private func makeConnection(name: String = "Dev") -> DatabaseConnection { + DatabaseConnection( + name: name, host: "db.example.com", port: 5_432, + database: "app", username: "admin", type: .postgresql + ) + } + + @Test("exportData round-trips connections through decodeData") + func testPlaintextRoundTrip() throws { + let connections = [makeConnection(name: "Primary"), makeConnection(name: "Replica")] + let data = try ConnectionExportService.exportData(connections) + + let envelope = try ConnectionExportService.decodeData(data) + #expect(envelope.connections.count == 2) + #expect(envelope.connections.map(\.name) == ["Primary", "Replica"]) + #expect(envelope.credentials == nil) + } + + @Test("exportEncryptedData decrypts with the right passphrase") + func testEncryptedRoundTrip() throws { + let connections = [makeConnection(name: "Secret")] + let data = try ConnectionExportService.exportEncryptedData(connections, passphrase: "correct horse") + + #expect(ConnectionExportCrypto.isEncrypted(data)) + let envelope = try ConnectionExportService.decodeEncryptedData(data, passphrase: "correct horse") + #expect(envelope.connections.map(\.name) == ["Secret"]) + } + + @Test("exportEncryptedData fails to decrypt with the wrong passphrase") + func testEncryptedWrongPassphrase() throws { + let data = try ConnectionExportService.exportEncryptedData([makeConnection()], passphrase: "right-one") + + #expect(throws: (any Error).self) { + try ConnectionExportService.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: "") + #expect(state == .empty) + #expect(!state.allowsExport) + } + + @Test("passphrase under the minimum length is too short") + func testTooShort() { + let state = ConnectionExportPassphraseState.evaluate(passphrase: "1234567", confirmation: "1234567") + #expect(state == .tooShort) + #expect(!state.allowsExport) + } + + @Test("valid passphrase with empty confirmation is incomplete") + func testIncomplete() { + let state = ConnectionExportPassphraseState.evaluate(passphrase: "longenough", confirmation: "") + #expect(state == .incomplete) + #expect(!state.allowsExport) + } + + @Test("non-matching confirmation is a mismatch") + func testMismatch() { + let state = ConnectionExportPassphraseState.evaluate(passphrase: "longenough", confirmation: "different1") + #expect(state == .mismatch) + #expect(!state.allowsExport) + } + + @Test("matching passphrase at the minimum length is exportable") + func testOk() { + let state = ConnectionExportPassphraseState.evaluate(passphrase: "12345678", confirmation: "12345678") + #expect(state == .ok) + #expect(state.allowsExport) + } +}