diff --git a/CHANGELOG.md b/CHANGELOG.md index c70881ec..ea9d012c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- 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 +- `.tablepro` file type registration (double-click to import, drag-and-drop) + ## [0.24.2] - 2026-03-26 ### Fixed diff --git a/TablePro/AppDelegate+FileOpen.swift b/TablePro/AppDelegate+FileOpen.swift index 2532d781..a36aed20 100644 --- a/TablePro/AppDelegate+FileOpen.swift +++ b/TablePro/AppDelegate+FileOpen.swift @@ -78,6 +78,12 @@ extension AppDelegate { } } + // Connection share files + let connectionShareFiles = urls.filter { $0.pathExtension.lowercased() == "tablepro" } + for url in connectionShareFiles { + handleConnectionShareFile(url) + } + let sqlFiles = urls.filter { $0.pathExtension.lowercased() == "sql" } if !sqlFiles.isEmpty { if DatabaseManager.shared.currentSession != nil { @@ -200,6 +206,16 @@ extension AppDelegate { } } + // MARK: - Connection Share Import + + private func handleConnectionShareFile(_ url: URL) { + openWelcomeWindow() + // Delay to ensure WelcomeWindowView's .onReceive is registered after window renders + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + NotificationCenter.default.post(name: .connectionShareFileOpened, object: url) + } + } + // MARK: - Plugin Install private func handlePluginInstall(_ url: URL) async { diff --git a/TablePro/Core/Services/Export/ConnectionExportService.swift b/TablePro/Core/Services/Export/ConnectionExportService.swift new file mode 100644 index 00000000..4e9eafb8 --- /dev/null +++ b/TablePro/Core/Services/Export/ConnectionExportService.swift @@ -0,0 +1,534 @@ +// +// ConnectionExportService.swift +// TablePro +// + +import Foundation +import os +import UniformTypeIdentifiers + +// MARK: - Export Error + +enum ConnectionExportError: LocalizedError { + case encodingFailed + case fileWriteFailed(String) + case fileReadFailed(String) + case invalidFormat + case unsupportedVersion(Int) + case decodingFailed(String) + + var errorDescription: String? { + switch self { + case .encodingFailed: + return String(localized: "Failed to encode connection data") + case .fileWriteFailed(let path): + return String(localized: "Failed to write file: \(path)") + case .fileReadFailed(let path): + return 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(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)") + } + } +} + +// 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: - Connection Export Service + +@MainActor +enum ConnectionExportService { + private static let logger = Logger(subsystem: "com.TablePro", category: "ConnectionExportService") + private static let currentFormatVersion = 1 + + // MARK: - Export + + static func buildEnvelope(for connections: [DatabaseConnection]) -> ConnectionExportEnvelope { + var groupNames: Set = [] + var tagNames: Set = [] + var exportableConnections: [ExportableConnection] = [] + + for connection in connections { + // Resolve SSH config: prefer SSH profile if linked, otherwise use inline config + let sshConfig: SSHConfiguration + if let profileId = connection.sshProfileId, + let profile = SSHProfileStorage.shared.profile(for: profileId) { + sshConfig = profile.toSSHConfiguration() + } else { + sshConfig = connection.sshConfig + } + + // Resolve tag name + let tagName: String? + if let tagId = connection.tagId { + tagName = TagStorage.shared.tag(for: tagId)?.name + } else { + tagName = nil + } + + // Resolve group name + let groupName: String? + if let groupId = connection.groupId { + groupName = GroupStorage.shared.group(for: groupId)?.name + } else { + groupName = nil + } + + // Build exportable SSH config (nil if not enabled) + let exportableSSH: ExportableSSHConfig? + if sshConfig.enabled { + let jumpHosts: [ExportableJumpHost]? = sshConfig.jumpHosts.isEmpty ? nil : sshConfig.jumpHosts.map { + ExportableJumpHost( + host: $0.host, + port: $0.port, + username: $0.username, + authMethod: $0.authMethod.rawValue, + privateKeyPath: PathPortability.contractHome($0.privateKeyPath) + ) + } + exportableSSH = ExportableSSHConfig( + enabled: true, + host: sshConfig.host, + port: sshConfig.port, + username: sshConfig.username, + authMethod: sshConfig.authMethod.rawValue, + privateKeyPath: PathPortability.contractHome(sshConfig.privateKeyPath), + useSSHConfig: sshConfig.useSSHConfig, + agentSocketPath: PathPortability.contractHome(sshConfig.agentSocketPath), + jumpHosts: jumpHosts, + totpMode: sshConfig.totpMode == .none ? nil : sshConfig.totpMode.rawValue, + totpAlgorithm: sshConfig.totpAlgorithm == .sha1 ? nil : sshConfig.totpAlgorithm.rawValue, + totpDigits: sshConfig.totpDigits == 6 ? nil : sshConfig.totpDigits, + totpPeriod: sshConfig.totpPeriod == 30 ? nil : sshConfig.totpPeriod + ) + } else { + exportableSSH = nil + } + + // Build exportable SSL config (nil if disabled) + let exportableSSL: ExportableSSLConfig? + if connection.sslConfig.mode != .disabled { + exportableSSL = ExportableSSLConfig( + mode: connection.sslConfig.mode.rawValue, + caCertificatePath: PathPortability.contractHome(connection.sslConfig.caCertificatePath), + clientCertificatePath: PathPortability.contractHome(connection.sslConfig.clientCertificatePath), + clientKeyPath: PathPortability.contractHome(connection.sslConfig.clientKeyPath) + ) + } else { + exportableSSL = nil + } + + // Color + let color: String? = connection.color == .none ? nil : connection.color.rawValue + + // Safe mode level + let safeModeLevel: String? = connection.safeModeLevel == .silent ? nil : connection.safeModeLevel.rawValue + + // AI policy + let aiPolicy: String? = connection.aiPolicy?.rawValue + + // Filter secure fields from additionalFields + // If plugin metadata is unavailable, omit all fields to avoid leaking secrets + let additionalFields: [String: String]? + if let snapshot = PluginMetadataRegistry.shared.snapshot(forTypeId: connection.type.pluginTypeId) { + var filteredFields = connection.additionalFields + let secureFieldIds = snapshot.connection.additionalConnectionFields + .filter(\.isSecure) + .map(\.id) + for fieldId in secureFieldIds { + filteredFields.removeValue(forKey: fieldId) + } + additionalFields = filteredFields.isEmpty ? nil : filteredFields + } else { + additionalFields = nil + } + + let exportable = ExportableConnection( + name: connection.name, + host: connection.host, + port: connection.port, + database: connection.database, + username: connection.username, + type: connection.type.rawValue, + sshConfig: exportableSSH, + sslConfig: exportableSSL, + color: color, + tagName: tagName, + groupName: groupName, + safeModeLevel: safeModeLevel, + aiPolicy: aiPolicy, + additionalFields: additionalFields, + redisDatabase: connection.redisDatabase, + startupCommands: connection.startupCommands + ) + + exportableConnections.append(exportable) + + // Collect unique group/tag names + if let name = tagName { tagNames.insert(name) } + if let name = groupName { groupNames.insert(name) } + } + + // Build group and tag arrays with their colors + let allGroups = GroupStorage.shared.loadGroups() + let exportableGroups: [ExportableGroup]? = groupNames.isEmpty ? nil : groupNames.map { name in + let existing = allGroups.first { $0.name == name } + return ExportableGroup(name: name, color: existing?.color == .none ? nil : existing?.color.rawValue) + } + + let allTags = TagStorage.shared.loadTags() + let exportableTags: [ExportableTag]? = tagNames.isEmpty ? nil : tagNames.map { name in + let existing = allTags.first { $0.name == name } + return ExportableTag(name: name, color: existing?.color == .none ? nil : existing?.color.rawValue) + } + + let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown" + + return ConnectionExportEnvelope( + formatVersion: currentFormatVersion, + exportedAt: Date(), + appVersion: appVersion, + connections: exportableConnections, + groups: exportableGroups, + tags: exportableTags + ) + } + + static func encode(_ envelope: ConnectionExportEnvelope) throws -> Data { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + encoder.dateEncodingStrategy = .iso8601 + + do { + return try encoder.encode(envelope) + } catch { + logger.error("Encoding failed: \(error)") + throw ConnectionExportError.encodingFailed + } + } + + static func exportConnections(_ connections: [DatabaseConnection], to url: URL) throws { + let envelope = buildEnvelope(for: connections) + let data = try encode(envelope) + + do { + try data.write(to: url, options: .atomic) + logger.info("Exported \(connections.count) connections to \(url.path)") + } catch { + throw ConnectionExportError.fileWriteFailed(url.path) + } + } + + // MARK: - Import + + static func decodeFile(at url: URL) throws -> ConnectionExportEnvelope { + let data: Data + do { + data = try Data(contentsOf: url) + } catch { + throw ConnectionExportError.fileReadFailed(url.path) + } + return try decodeData(data) + } + + /// 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 envelope + } + + static func analyzeImport(_ envelope: ConnectionExportEnvelope) -> ConnectionImportPreview { + let existingConnections = ConnectionStorage.shared.loadConnections() + let registeredTypeIds = Set(PluginMetadataRegistry.shared.allRegisteredTypeIds()) + + let items: [ImportItem] = envelope.connections.map { exportable in + // Check for duplicate by matching key fields + let duplicate = existingConnections.first { existing in + existing.name.lowercased() == exportable.name.lowercased() + && existing.host.lowercased() == exportable.host.lowercased() + && existing.port == exportable.port + && existing.type.rawValue.lowercased() == exportable.type.lowercased() + } + + if let duplicate { + return ImportItem(connection: exportable, status: .duplicate(existing: duplicate)) + } + + // Check for warnings + var warnings: [String] = [] + + // SSH key path check + if let ssh = exportable.sshConfig { + let keyPath = PathPortability.expandHome(ssh.privateKeyPath) + if !keyPath.isEmpty, !FileManager.default.fileExists(atPath: keyPath) { + warnings.append("SSH private key not found: \(ssh.privateKeyPath)") + } + // Jump host key paths + for jump in ssh.jumpHosts ?? [] { + let jumpKeyPath = PathPortability.expandHome(jump.privateKeyPath) + if !jumpKeyPath.isEmpty, !FileManager.default.fileExists(atPath: jumpKeyPath) { + warnings.append("Jump host key not found: \(jump.privateKeyPath)") + } + } + } + + // SSL cert paths check + if let ssl = exportable.sslConfig { + for (path, label) in [ + (ssl.caCertificatePath, "CA certificate"), + (ssl.clientCertificatePath, "Client certificate"), + (ssl.clientKeyPath, "Client key") + ] { + if let path, !path.isEmpty { + let expanded = PathPortability.expandHome(path) + if !FileManager.default.fileExists(atPath: expanded) { + warnings.append("\(label) not found: \(path)") + } + } + } + } + + // Database type check + if !registeredTypeIds.contains(exportable.type) { + warnings.append("Database type \"\(exportable.type)\" is not installed") + } + + if !warnings.isEmpty { + return ImportItem(connection: exportable, status: .warnings(warnings)) + } + + return ImportItem(connection: exportable, status: .ready) + } + + return ConnectionImportPreview(envelope: envelope, items: items) + } + + @discardableResult + static func performImport( + _ preview: ConnectionImportPreview, + resolutions: [UUID: ImportResolution] + ) -> Int { + // Create missing groups + let existingGroups = GroupStorage.shared.loadGroups() + if let envelopeGroups = preview.envelope.groups { + for exportGroup in envelopeGroups { + let alreadyExists = existingGroups.contains { + $0.name.lowercased() == exportGroup.name.lowercased() + } + if !alreadyExists { + let color = exportGroup.color.flatMap { ConnectionColor(rawValue: $0) } ?? .none + let group = ConnectionGroup(name: exportGroup.name, color: color) + GroupStorage.shared.addGroup(group) + } + } + } + + // Create missing tags + let existingTags = TagStorage.shared.loadTags() + if let envelopeTags = preview.envelope.tags { + for exportTag in envelopeTags { + let alreadyExists = existingTags.contains { + $0.name.lowercased() == exportTag.name.lowercased() + } + if !alreadyExists { + // Match preset tags by name + let preset = ConnectionTag.presets.first { + $0.name.lowercased() == exportTag.name.lowercased() + } + if let preset { + TagStorage.shared.addTag(preset) + } else { + let color = exportTag.color.flatMap { ConnectionColor(rawValue: $0) } ?? .gray + let tag = ConnectionTag(name: exportTag.name, color: color) + TagStorage.shared.addTag(tag) + } + } + } + } + + var importedCount = 0 + + for item in preview.items { + let resolution = resolutions[item.id] ?? .skip + switch resolution { + case .skip: + continue + + case .importNew, .importAsCopy: + let connectionId = UUID() + var name = item.connection.name + if case .importAsCopy = resolution { + name += " (Imported)" + } + let connection = buildDatabaseConnection( + id: connectionId, + from: item.connection, + name: name + ) + ConnectionStorage.shared.addConnection(connection, password: nil) + importedCount += 1 + + case .replace(let existingId): + let connection = buildDatabaseConnection( + id: existingId, + from: item.connection, + name: item.connection.name + ) + ConnectionStorage.shared.updateConnection(connection, password: nil) + importedCount += 1 + } + } + + if importedCount > 0 { + NotificationCenter.default.post(name: .connectionUpdated, object: nil) + logger.info("Imported \(importedCount) connections") + } + + return importedCount + } + + // MARK: - Deeplink Builder + + static func buildImportDeeplink(for connection: DatabaseConnection) -> String { + var components = URLComponents() + components.scheme = "tablepro" + components.host = "import" + + var queryItems: [URLQueryItem] = [ + URLQueryItem(name: "name", value: connection.name), + URLQueryItem(name: "host", value: connection.host), + URLQueryItem(name: "port", value: String(connection.port)), + URLQueryItem(name: "type", value: connection.type.rawValue) + ] + + if !connection.username.isEmpty { + queryItems.append(URLQueryItem(name: "username", value: connection.username)) + } + if !connection.database.isEmpty { + queryItems.append(URLQueryItem(name: "database", value: connection.database)) + } + + components.queryItems = queryItems + + return components.url?.absoluteString ?? "" + } + + // MARK: - Private Helpers + + private static func buildDatabaseConnection( + id: UUID, + from exportable: ExportableConnection, + name: String + ) -> DatabaseConnection { + // Build SSH configuration + let sshConfig: SSHConfiguration + if let ssh = exportable.sshConfig { + var config = SSHConfiguration() + config.enabled = ssh.enabled + config.host = ssh.host + config.port = ssh.port + config.username = ssh.username + config.authMethod = SSHAuthMethod(rawValue: ssh.authMethod) ?? .password + config.privateKeyPath = PathPortability.expandHome(ssh.privateKeyPath) + config.useSSHConfig = ssh.useSSHConfig + config.agentSocketPath = PathPortability.expandHome(ssh.agentSocketPath) + config.jumpHosts = (ssh.jumpHosts ?? []).map { jump in + SSHJumpHost( + host: jump.host, + port: jump.port, + username: jump.username, + authMethod: SSHJumpAuthMethod(rawValue: jump.authMethod) ?? .sshAgent, + privateKeyPath: PathPortability.expandHome(jump.privateKeyPath) + ) + } + config.totpMode = ssh.totpMode.flatMap { TOTPMode(rawValue: $0) } ?? .none + config.totpAlgorithm = ssh.totpAlgorithm.flatMap { TOTPAlgorithm(rawValue: $0) } ?? .sha1 + config.totpDigits = ssh.totpDigits ?? 6 + config.totpPeriod = ssh.totpPeriod ?? 30 + sshConfig = config + } else { + sshConfig = SSHConfiguration() + } + + // Build SSL configuration + let sslConfig: SSLConfiguration + if let ssl = exportable.sslConfig { + sslConfig = SSLConfiguration( + mode: SSLMode(rawValue: ssl.mode) ?? .disabled, + caCertificatePath: PathPortability.expandHome(ssl.caCertificatePath ?? ""), + clientCertificatePath: PathPortability.expandHome(ssl.clientCertificatePath ?? ""), + clientKeyPath: PathPortability.expandHome(ssl.clientKeyPath ?? "") + ) + } else { + sslConfig = SSLConfiguration() + } + + // Resolve tag and group by name + let tagId = exportable.tagName.flatMap { name in + TagStorage.shared.loadTags().first { $0.name.lowercased() == name.lowercased() }?.id + } + let groupId = exportable.groupName.flatMap { name in + GroupStorage.shared.loadGroups().first { $0.name.lowercased() == name.lowercased() }?.id + } + + return DatabaseConnection( + id: id, + name: name, + host: exportable.host, + port: exportable.port, + database: exportable.database, + username: exportable.username, + type: DatabaseType(rawValue: exportable.type), + sshConfig: sshConfig, + sslConfig: sslConfig, + color: exportable.color.flatMap { ConnectionColor(rawValue: $0) } ?? .none, + tagId: tagId, + groupId: groupId, + safeModeLevel: exportable.safeModeLevel.flatMap { SafeModeLevel(rawValue: $0) } ?? .silent, + aiPolicy: exportable.aiPolicy.flatMap { AIConnectionPolicy(rawValue: $0) }, + redisDatabase: exportable.redisDatabase, + startupCommands: exportable.startupCommands, + additionalFields: exportable.additionalFields + ) + } +} diff --git a/TablePro/Core/Services/Infrastructure/AppNotifications.swift b/TablePro/Core/Services/Infrastructure/AppNotifications.swift index b72cdbd1..9e905d28 100644 --- a/TablePro/Core/Services/Infrastructure/AppNotifications.swift +++ b/TablePro/Core/Services/Infrastructure/AppNotifications.swift @@ -19,6 +19,9 @@ extension Notification.Name { static let connectionUpdated = Notification.Name("connectionUpdated") static let connectionStatusDidChange = Notification.Name("connectionStatusDidChange") static let databaseDidConnect = Notification.Name("databaseDidConnect") + static let connectionShareFileOpened = Notification.Name("connectionShareFileOpened") + static let exportConnections = Notification.Name("exportConnections") + static let importConnections = Notification.Name("importConnections") // MARK: - License diff --git a/TablePro/Info.plist b/TablePro/Info.plist index a5b50e24..d9f3e244 100644 --- a/TablePro/Info.plist +++ b/TablePro/Info.plist @@ -68,6 +68,22 @@ com.tablepro.sqlite-db + + CFBundleTypeExtensions + + tablepro + + CFBundleTypeName + TablePro Connection + CFBundleTypeRole + Viewer + LSHandlerRank + Owner + LSItemContentTypes + + com.tablepro.connection-share + + CFBundleTypeName DuckDB Database @@ -151,6 +167,24 @@ UTExportedTypeDeclarations + + UTTypeIdentifier + com.tablepro.connection-share + UTTypeDescription + TablePro Connection + UTTypeConformsTo + + public.json + public.data + + UTTypeTagSpecification + + public.filename-extension + + tablepro + + + UTTypeIdentifier com.tablepro.plugin diff --git a/TablePro/Models/Connection/ConnectionExport.swift b/TablePro/Models/Connection/ConnectionExport.swift new file mode 100644 index 00000000..6b6cb8cc --- /dev/null +++ b/TablePro/Models/Connection/ConnectionExport.swift @@ -0,0 +1,116 @@ +// +// ConnectionExport.swift +// TablePro +// + +import Foundation +import UniformTypeIdentifiers + +// MARK: - Identifiable URL (for sheet binding) + +struct IdentifiableURL: Identifiable { + let id = UUID() + let url: URL +} + +// 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]? +} + +// 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 safeModeLevel: String? + let aiPolicy: String? + let additionalFields: [String: String]? + let redisDatabase: Int? + let startupCommands: String? +} + +// 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 useSSHConfig: Bool + 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: - 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/TableProApp.swift b/TablePro/TableProApp.swift index 547140dc..c59a57c2 100644 --- a/TablePro/TableProApp.swift +++ b/TablePro/TableProApp.swift @@ -239,6 +239,16 @@ struct AppMenuCommands: Commands { Divider() + Button(String(localized: "Export Connections...")) { + NotificationCenter.default.post(name: .exportConnections, object: nil) + } + + Button(String(localized: "Import Connections...")) { + NotificationCenter.default.post(name: .importConnections, object: nil) + } + + Divider() + Button("Export...") { actions?.exportTables() } diff --git a/TablePro/Views/Connection/ConnectionImportSheet.swift b/TablePro/Views/Connection/ConnectionImportSheet.swift new file mode 100644 index 00000000..e8655217 --- /dev/null +++ b/TablePro/Views/Connection/ConnectionImportSheet.swift @@ -0,0 +1,280 @@ +// +// ConnectionImportSheet.swift +// TablePro +// +// Sheet for previewing and importing connections from a .tablepro file. +// + +import SwiftUI +import UniformTypeIdentifiers + +struct ConnectionImportSheet: View { + let fileURL: URL + var onImported: ((Int) -> Void)? + @Environment(\.dismiss) private var dismiss + @State private var preview: ConnectionImportPreview? + @State private var error: String? + @State private var isLoading = true + @State private var selectedIds: Set = [] + @State private var duplicateResolutions: [UUID: ImportResolution] = [:] + + var body: some View { + VStack(spacing: 0) { + if isLoading { + loadingView + } else if let error { + errorView(error) + } else if let preview { + header(preview) + Divider() + previewList(preview) + Divider() + footer(preview) + } + } + .frame(width: 500, height: 400) + .onAppear { loadFile() } + } + + // MARK: - Loading + + private var loadingView: some View { + VStack { + Spacer() + ProgressView() + .controlSize(.large) + Spacer() + } + .frame(height: 200) + } + + // MARK: - Error + + private func errorView(_ message: String) -> some View { + VStack(spacing: 12) { + Spacer() + Image(systemName: "exclamationmark.triangle") + .font(.system(size: 32)) + .foregroundStyle(.secondary) + Text(message) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + Spacer() + HStack { + Spacer() + Button(String(localized: "OK")) { dismiss() } + .buttonStyle(.borderedProminent) + .keyboardShortcut(.defaultAction) + } + .padding(12) + } + .padding(.horizontal) + } + + // MARK: - Header + + private func header(_ preview: ConnectionImportPreview) -> some View { + HStack { + Text(String(localized: "Import Connections")) + .font(.system(size: 13, weight: .semibold)) + Text("(\(fileURL.lastPathComponent))") + .font(.system(size: 13)) + .foregroundStyle(.secondary) + Spacer() + Toggle(String(localized: "Select All"), isOn: Binding( + get: { selectedIds.count == preview.items.count && !preview.items.isEmpty }, + set: { newValue in + if newValue { + selectedIds = Set(preview.items.map(\.id)) + } else { + selectedIds.removeAll() + } + } + )) + .toggleStyle(.checkbox) + .controlSize(.small) + } + .padding(.vertical, 10) + .padding(.horizontal, 16) + } + + // MARK: - Preview List + + private func previewList(_ preview: ConnectionImportPreview) -> some View { + List { + ForEach(preview.items) { item in + importItemRow(item) + } + } + .listStyle(.inset) + } + + @ViewBuilder + private func importItemRow(_ item: ImportItem) -> some View { + let isSelected = selectedIds.contains(item.id) + HStack(spacing: 8) { + Toggle("", isOn: Binding( + get: { isSelected }, + set: { newValue in + if newValue { + selectedIds.insert(item.id) + } else { + selectedIds.remove(item.id) + } + } + )) + .toggleStyle(.checkbox) + .labelsHidden() + + DatabaseType(rawValue: item.connection.type).iconImage + .frame(width: 18, height: 18) + + VStack(alignment: .leading, spacing: 1) { + HStack(spacing: 4) { + Text(item.connection.name) + .font(.system(size: 13)) + .lineLimit(1) + if case .duplicate = item.status { + Text(String(localized: "duplicate")) + .font(.system(size: 10)) + .foregroundStyle(.secondary) + .padding(.horizontal, 4) + .padding(.vertical, 1) + .background( + RoundedRectangle(cornerRadius: 3) + .fill(Color(nsColor: .quaternaryLabelColor)) + ) + } + } + HStack(spacing: 0) { + Text("\(item.connection.host):\(String(item.connection.port))") + warningText(for: item.status) + } + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .lineLimit(1) + } + + Spacer() + + if case .duplicate = item.status, isSelected { + Picker("", selection: Binding( + get: { duplicateResolutions[item.id] ?? .importAsCopy }, + 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)) + } + Text(String(localized: "Skip")).tag(ImportResolution.skip) + } + .pickerStyle(.menu) + .controlSize(.small) + .frame(width: 110) + .labelsHidden() + } else { + statusIcon(for: item.status) + } + } + .padding(.vertical, 2) + } + + @ViewBuilder + private func statusIcon(for status: ImportItemStatus) -> some View { + switch status { + case .ready: + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 12)) + .foregroundStyle(.green) + case .warnings: + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 12)) + .foregroundStyle(.yellow) + case .duplicate: + EmptyView() + } + } + + @ViewBuilder + private func warningText(for status: ImportItemStatus) -> some View { + if case .warnings(let messages) = status, let first = messages.first { + Text(" — \(first)") + .foregroundStyle(.orange) + } + } + + // MARK: - Footer + + private func footer(_ preview: ConnectionImportPreview) -> some View { + HStack { + Text("\(selectedIds.count) of \(preview.items.count) selected") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + + Spacer() + + Button(String(localized: "Cancel")) { + dismiss() + } + .keyboardShortcut(.cancelAction) + + Button(String(localized: "Import")) { + performImport(preview) + } + .buttonStyle(.borderedProminent) + .keyboardShortcut(.defaultAction) + .disabled(selectedIds.isEmpty) + } + .padding(12) + } + + // MARK: - Actions + + private func loadFile() { + let url = fileURL + Task.detached(priority: .userInitiated) { + do { + let data = try Data(contentsOf: url) + let envelope = try ConnectionExportService.decodeData(data) + let result = 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 + } + } + isLoading = false + } + } catch { + await MainActor.run { + self.error = error.localizedDescription + isLoading = false + } + } + } + } + + private func performImport(_ preview: ConnectionImportPreview) { + var resolutions: [UUID: ImportResolution] = [:] + for item in preview.items { + if selectedIds.contains(item.id) { + switch item.status { + case .ready, .warnings: + resolutions[item.id] = .importNew + case .duplicate: + resolutions[item.id] = duplicateResolutions[item.id] ?? .importAsCopy + } + } else { + resolutions[item.id] = .skip + } + } + + let count = ConnectionExportService.performImport(preview, resolutions: resolutions) + dismiss() + onImported?(count) + } +} diff --git a/TablePro/Views/Connection/WelcomeWindowView.swift b/TablePro/Views/Connection/WelcomeWindowView.swift index 95f4ebd3..e2c4cafb 100644 --- a/TablePro/Views/Connection/WelcomeWindowView.swift +++ b/TablePro/Views/Connection/WelcomeWindowView.swift @@ -9,6 +9,7 @@ import AppKit import os import SwiftUI +import UniformTypeIdentifiers // MARK: - WelcomeWindowView @@ -43,6 +44,7 @@ struct WelcomeWindowView: View { @State private var pendingMoveToNewGroup: [DatabaseConnection] = [] @State private var showActivationSheet = false @State private var pluginInstallConnection: DatabaseConnection? + @State private var importFileURL: IdentifiableURL? @Environment(\.openWindow) private var openWindow @@ -159,6 +161,25 @@ struct WelcomeWindowView: View { .pluginInstallPrompt(connection: $pluginInstallConnection) { connection in connectAfterInstall(connection) } + .sheet(item: $importFileURL) { item in + ConnectionImportSheet(fileURL: item.url) { count in + // Delay to let the sheet fully dismiss before showing alert + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + showImportResultAlert(count: count) + } + } + } + .onReceive(NotificationCenter.default.publisher(for: .connectionShareFileOpened)) { notification in + guard let url = notification.object as? URL else { return } + importFileURL = IdentifiableURL(url: url) + } + .onReceive(NotificationCenter.default.publisher(for: .exportConnections)) { _ in + guard !connections.isEmpty else { return } + exportConnections(connections) + } + .onReceive(NotificationCenter.default.publisher(for: .importConnections)) { _ in + importConnectionsFromFile() + } } private var welcomeContent: some View { @@ -380,6 +401,14 @@ struct WelcomeWindowView: View { Button(action: { openWindow(id: "connection-form") }) { Label("New Connection...", systemImage: "plus") } + + Divider() + + Button { + importConnectionsFromFile() + } label: { + Label(String(localized: "Import Connections..."), systemImage: "square.and.arrow.down") + } } // MARK: - Connection List @@ -479,6 +508,29 @@ 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 { @@ -614,6 +666,17 @@ struct WelcomeWindowView: View { Divider() + Button { + exportConnections(Array(selectedConnections)) + } label: { + Label( + String(localized: "Export \(selectedConnectionIds.count) Connections..."), + systemImage: "square.and.arrow.up" + ) + } + + Divider() + moveToGroupMenu(for: selectedConnections) let validGroupIds = Set(groups.map(\.id)) @@ -652,6 +715,8 @@ struct WelcomeWindowView: View { Label(String(localized: "Duplicate"), systemImage: "doc.on.doc") } + Divider() + Button { let pw = ConnectionStorage.shared.loadPassword(for: connection.id) let sshPw: String? @@ -674,6 +739,19 @@ struct WelcomeWindowView: View { Label(String(localized: "Copy as URL"), systemImage: "link") } + Button { + let link = ConnectionExportService.buildImportDeeplink(for: connection) + ClipboardService.shared.writeText(link) + } label: { + Label(String(localized: "Copy as Import Link"), systemImage: "link.badge.plus") + } + + Button { + exportConnections([connection]) + } label: { + Label(String(localized: "Export..."), systemImage: "square.and.arrow.up") + } + Divider() moveToGroupMenu(for: [connection]) @@ -748,6 +826,61 @@ struct WelcomeWindowView: View { storage.saveConnections(connections) } + // 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 + ) + } + } + + private func importConnectionsFromFile() { + let panel = NSOpenPanel() + panel.allowedContentTypes = [.tableproConnectionShare] + panel.allowsMultipleSelection = false + panel.canChooseDirectories = false + guard panel.runModal() == .OK, let url = panel.url else { return } + importFileURL = IdentifiableURL(url: url) + } + + private func showImportResultAlert(count: Int) { + let alert = NSAlert() + if count > 0 { + alert.alertStyle = .informational + alert.messageText = String(localized: "Import Complete") + alert.informativeText = count == 1 + ? String(localized: "1 connection was imported.") + : String(localized: "\(count) connections were imported.") + alert.icon = NSImage(systemSymbolName: "checkmark.circle.fill", accessibilityDescription: nil)? + .withSymbolConfiguration(.init(paletteColors: [.white, .systemGreen])) + } else { + alert.alertStyle = .informational + alert.messageText = String(localized: "No Connections Imported") + alert.informativeText = String(localized: "All selected connections were skipped.") + } + alert.addButton(withTitle: String(localized: "OK")) + if let window = NSApp.keyWindow { + alert.beginSheetModal(for: window) + } else { + alert.runModal() + } + } + // MARK: - Actions private func loadConnections() { diff --git a/docs/docs.json b/docs/docs.json index e8a39313..72d78915 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -62,6 +62,7 @@ "features/keyboard-shortcuts", "features/deep-links", "features/safe-mode", + "features/connection-sharing", "features/icloud-sync", "features/ssh-profiles" ] diff --git a/docs/features/connection-sharing.mdx b/docs/features/connection-sharing.mdx new file mode 100644 index 00000000..72621f65 --- /dev/null +++ b/docs/features/connection-sharing.mdx @@ -0,0 +1,77 @@ +--- +title: Connection Sharing +description: Export and import database connections as .tablepro files +--- + +# Connection Sharing + +Share connections with your team using `.tablepro` files. Passwords are not included. + +## Export + +- 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. + +## Import + +- Right-click empty area > **Import Connections...** +- **File** menu > **Import Connections...** +- Double-click a `.tablepro` file +- Drag a `.tablepro` file onto TablePro + +A preview shows each connection before importing: + +- **Green checkmark** -- ready +- **Yellow triangle** -- SSH key or cert path not found (imports anyway, fix later) +- **"duplicate"** -- already exists + +Duplicates are unchecked. Check to import, then choose **As Copy**, **Replace**, or **Skip**. + +## Share via Link + +Right-click a connection > **Copy as Import Link**. Paste the URL in Slack, a wiki, or a README. + +``` +tablepro://import?name=Staging&host=db.example.com&port=5432&type=PostgreSQL&username=admin +``` + +## File Format + +JSON. Only `name`, `host`, and `type` are required. + +```json +{ + "formatVersion": 1, + "connections": [ + { + "name": "Production", + "host": "db.example.com", + "port": 3306, + "type": "MySQL", + "username": "deploy", + "tagName": "production" + } + ], + "groups": [{ "name": "Backend", "color": "Blue" }], + "tags": [{ "name": "production", "color": "Red" }] +} +``` + +Groups and tags match by name. Missing ones are created. Paths use `~/` for portability. + +## Passwords + +Not exported. Enter yours after importing. File paths use `~/` instead of full paths. + +## Sharing vs iCloud Sync + +| | Sharing | iCloud Sync | +|---|---|---| +| **For** | Team | Your Macs | +| **How** | Files, links | CloudKit | +| **Passwords** | Enter your own | Optional sync | +| **Requires** | Nothing | Pro + iCloud |