diff --git a/CHANGELOG.md b/CHANGELOG.md index cc9c5fb6..ff55543c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,11 +9,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Linked Folders: watch a shared directory for `.tablepro` files, auto-sync connections to sidebar (Pro) +- Environment variable references: use `$VAR` and `${VAR}` in `.tablepro` files, resolved at connection time (Pro) - 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 - `.tablepro` file type registration (double-click to import, drag-and-drop) +- Environment variable references (`$VAR` / `${VAR}`) in connection fields (host, database, username, SSH, SSL paths, startup commands, additional fields) — Pro feature ## [0.24.2] - 2026-03-26 diff --git a/TablePro/AppDelegate.swift b/TablePro/AppDelegate.swift index 42a1be68..2ee34e2a 100644 --- a/TablePro/AppDelegate.swift +++ b/TablePro/AppDelegate.swift @@ -89,6 +89,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { AnalyticsService.shared.startPeriodicHeartbeat() SyncCoordinator.shared.start() + LinkedFolderWatcher.shared.start() Task.detached(priority: .background) { _ = QueryHistoryStorage.shared @@ -131,6 +132,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { } func applicationWillTerminate(_ notification: Notification) { + LinkedFolderWatcher.shared.stop() UserDefaults.standard.synchronize() SSHTunnelManager.shared.terminateAllProcessesSync() } diff --git a/TablePro/Core/Database/DatabaseManager.swift b/TablePro/Core/Database/DatabaseManager.swift index 22fe0be6..5aa8b7e3 100644 --- a/TablePro/Core/Database/DatabaseManager.swift +++ b/TablePro/Core/Database/DatabaseManager.swift @@ -92,6 +92,14 @@ final class DatabaseManager { return } + // Resolve environment variable references in connection fields (Pro feature) + let resolvedConnection: DatabaseConnection + if LicenseManager.shared.isFeatureAvailable(.envVarReferences) { + resolvedConnection = EnvVarResolver.resolveConnection(connection) + } else { + resolvedConnection = connection + } + // Create new session (or reuse a prepared one) if activeSessions[connection.id] == nil { var session = ConnectionSession(connection: connection) @@ -103,7 +111,7 @@ final class DatabaseManager { // Create SSH tunnel if needed and build effective connection let effectiveConnection: DatabaseConnection do { - effectiveConnection = try await buildEffectiveConnection(for: connection) + effectiveConnection = try await buildEffectiveConnection(for: resolvedConnection) } catch { // Remove failed session removeSessionEntry(for: connection.id) @@ -112,7 +120,7 @@ final class DatabaseManager { } // Run pre-connect hook if configured (only on explicit connect, not auto-reconnect) - if let script = connection.preConnectScript, + if let script = resolvedConnection.preConnectScript, !script.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { do { @@ -155,7 +163,7 @@ final class DatabaseManager { // Run startup commands before schema init await executeStartupCommands( - connection.startupCommands, on: driver, connectionName: connection.name + resolvedConnection.startupCommands, on: driver, connectionName: connection.name ) // Initialize schema for drivers that support schema switching @@ -172,7 +180,7 @@ final class DatabaseManager { switch action { case .selectDatabaseFromLastSession: // Restore saved database (e.g. MSSQL) only when no explicit database is configured - if connection.database.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + if resolvedConnection.database.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, let adapter = driver as? PluginDriverAdapter, let savedDb = AppSettingsStorage.shared.loadLastDatabase(for: connection.id) { try? await adapter.switchDatabase(to: savedDb) @@ -183,11 +191,11 @@ final class DatabaseManager { // Check additionalFields first, then legacy dedicated properties, then // fall back to parsing the main database field. let initialDb: Int - if let fieldValue = connection.additionalFields[fieldId], let parsed = Int(fieldValue) { + if let fieldValue = resolvedConnection.additionalFields[fieldId], let parsed = Int(fieldValue) { initialDb = parsed - } else if fieldId == "redisDatabase", let legacy = connection.redisDatabase { + } else if fieldId == "redisDatabase", let legacy = resolvedConnection.redisDatabase { initialDb = legacy - } else if let fallback = Int(connection.database) { + } else if let fallback = Int(resolvedConnection.database) { initialDb = fallback } else { initialDb = 0 diff --git a/TablePro/Core/Services/Export/LinkedFolderWatcher.swift b/TablePro/Core/Services/Export/LinkedFolderWatcher.swift new file mode 100644 index 00000000..7ff00da9 --- /dev/null +++ b/TablePro/Core/Services/Export/LinkedFolderWatcher.swift @@ -0,0 +1,177 @@ +// +// LinkedFolderWatcher.swift +// TablePro +// +// Watches linked folders for .tablepro connection files. +// Rescans on filesystem changes with 1s debounce. +// + +import CryptoKit +import Foundation +import os + +struct LinkedConnection: Identifiable { + let id: UUID + let connection: ExportableConnection + let folderId: UUID + let sourceFileURL: URL +} + +@MainActor +@Observable +final class LinkedFolderWatcher { + static let shared = LinkedFolderWatcher() + private static let logger = Logger(subsystem: "com.TablePro", category: "LinkedFolderWatcher") + + private(set) var linkedConnections: [LinkedConnection] = [] + private var watchSources: [UUID: DispatchSourceFileSystemObject] = [:] + private var debounceTask: Task? + + private init() {} + + func start() { + guard LicenseManager.shared.isFeatureAvailable(.linkedFolders) else { return } + let folders = LinkedFolderStorage.shared.loadFolders() + scheduleScan(folders) + setupWatchers(for: folders) + } + + func stop() { + cancelAllWatchers() + debounceTask?.cancel() + debounceTask = nil + } + + func reload() { + stop() + start() + } + + // MARK: - Scanning (off main thread) + + private func scheduleScan(_ folders: [LinkedFolder]) { + debounceTask?.cancel() + debounceTask = Task { @MainActor [weak self] in + let results = await Self.scanFoldersAsync(folders) + self?.linkedConnections = results + NotificationCenter.default.post(name: .linkedFoldersDidUpdate, object: nil) + } + } + + private func scheduleDebouncedRescan() { + debounceTask?.cancel() + debounceTask = Task { @MainActor [weak self] in + try? await Task.sleep(for: .seconds(1)) + guard !Task.isCancelled else { return } + let folders = LinkedFolderStorage.shared.loadFolders() + let results = await Self.scanFoldersAsync(folders) + self?.linkedConnections = results + NotificationCenter.default.post(name: .linkedFoldersDidUpdate, object: nil) + } + } + + /// Scans folders on a background thread to avoid blocking the main actor. + private nonisolated static func scanFoldersAsync(_ folders: [LinkedFolder]) async -> [LinkedConnection] { + await Task.detached(priority: .utility) { + scanFolders(folders) + }.value + } + + /// Pure scanning logic. Runs on any thread. + private nonisolated static func scanFolders(_ folders: [LinkedFolder]) -> [LinkedConnection] { + var results: [LinkedConnection] = [] + let fm = FileManager.default + + for folder in folders where folder.isEnabled { + let expandedPath = folder.expandedPath + guard fm.fileExists(atPath: expandedPath) else { + logger.warning("Linked folder not found: \(expandedPath, privacy: .public)") + continue + } + + guard let contents = try? fm.contentsOfDirectory(atPath: expandedPath) else { + logger.warning("Cannot read linked folder: \(expandedPath, privacy: .public)") + continue + } + + for filename in contents where filename.hasSuffix(".tablepro") { + let fileURL = URL(fileURLWithPath: expandedPath).appendingPathComponent(filename) + guard let data = try? Data(contentsOf: fileURL) else { continue } + + if ConnectionExportCrypto.isEncrypted(data) { continue } + + guard let envelope = try? ConnectionExportService.decodeData(data) else { continue } + + for exportable in envelope.connections { + let stableId = stableId(folderId: folder.id, connection: exportable) + results.append(LinkedConnection( + id: stableId, + connection: exportable, + folderId: folder.id, + sourceFileURL: fileURL + )) + } + } + } + + return results + } + + // MARK: - Watchers + + private func setupWatchers(for folders: [LinkedFolder]) { + cancelAllWatchers() + + for folder in folders where folder.isEnabled { + let expandedPath = folder.expandedPath + let fd = open(expandedPath, O_EVTONLY) + guard fd >= 0 else { + Self.logger.warning("Cannot open linked folder for watching: \(expandedPath, privacy: .public)") + continue + } + + let source = DispatchSource.makeFileSystemObjectSource( + fileDescriptor: fd, + eventMask: [.write, .delete, .rename], + queue: .global(qos: .utility) + ) + + source.setEventHandler { [weak self] in + Task { @MainActor [weak self] in + self?.scheduleDebouncedRescan() + } + } + + source.setCancelHandler { + close(fd) + } + + watchSources[folder.id] = source + source.resume() + } + } + + private func cancelAllWatchers() { + for (_, source) in watchSources { + source.cancel() + } + watchSources.removeAll() + } + + // MARK: - Stable IDs (SHA-256 based, deterministic across launches) + + private nonisolated static func stableId(folderId: UUID, connection: ExportableConnection) -> UUID { + let key = "\(folderId.uuidString)|\(connection.name)|\(connection.host)|\(connection.port)|\(connection.type)" + let digest = SHA256.hash(data: Data(key.utf8)) + var bytes = Array(digest.prefix(16)) + // Set UUID version 5 and variant bits + bytes[6] = (bytes[6] & 0x0F) | 0x50 + bytes[8] = (bytes[8] & 0x3F) | 0x80 + return UUID(uuid: ( + bytes[0], bytes[1], bytes[2], bytes[3], + bytes[4], bytes[5], bytes[6], bytes[7], + bytes[8], bytes[9], bytes[10], bytes[11], + bytes[12], bytes[13], bytes[14], bytes[15] + )) + } +} diff --git a/TablePro/Core/Services/Infrastructure/AppNotifications.swift b/TablePro/Core/Services/Infrastructure/AppNotifications.swift index 9e905d28..dd6bf273 100644 --- a/TablePro/Core/Services/Infrastructure/AppNotifications.swift +++ b/TablePro/Core/Services/Infrastructure/AppNotifications.swift @@ -22,6 +22,7 @@ extension Notification.Name { static let connectionShareFileOpened = Notification.Name("connectionShareFileOpened") static let exportConnections = Notification.Name("exportConnections") static let importConnections = Notification.Name("importConnections") + static let linkedFoldersDidUpdate = Notification.Name("linkedFoldersDidUpdate") // MARK: - License diff --git a/TablePro/Core/Storage/LinkedFolderStorage.swift b/TablePro/Core/Storage/LinkedFolderStorage.swift new file mode 100644 index 00000000..98fc78d4 --- /dev/null +++ b/TablePro/Core/Storage/LinkedFolderStorage.swift @@ -0,0 +1,63 @@ +// +// LinkedFolderStorage.swift +// TablePro +// +// UserDefaults persistence for linked folder paths. +// + +import Foundation +import os + +struct LinkedFolder: Codable, Identifiable, Hashable { + let id: UUID + var path: String + var isEnabled: Bool + + var name: String { (path as NSString).lastPathComponent } + var expandedPath: String { PathPortability.expandHome(path) } + + init(id: UUID = UUID(), path: String, isEnabled: Bool = true) { + self.id = id + self.path = path + self.isEnabled = isEnabled + } +} + +final class LinkedFolderStorage { + static let shared = LinkedFolderStorage() + private static let logger = Logger(subsystem: "com.TablePro", category: "LinkedFolderStorage") + private let key = "com.TablePro.linkedFolders" + + private init() {} + + func loadFolders() -> [LinkedFolder] { + guard let data = UserDefaults.standard.data(forKey: key) else { return [] } + do { + return try JSONDecoder().decode([LinkedFolder].self, from: data) + } catch { + Self.logger.error("Failed to decode linked folders: \(error.localizedDescription, privacy: .public)") + return [] + } + } + + func saveFolders(_ folders: [LinkedFolder]) { + do { + let data = try JSONEncoder().encode(folders) + UserDefaults.standard.set(data, forKey: key) + } catch { + Self.logger.error("Failed to encode linked folders: \(error.localizedDescription, privacy: .public)") + } + } + + func addFolder(_ folder: LinkedFolder) { + var folders = loadFolders() + folders.append(folder) + saveFolders(folders) + } + + func removeFolder(_ folder: LinkedFolder) { + var folders = loadFolders() + folders.removeAll { $0.id == folder.id } + saveFolders(folders) + } +} diff --git a/TablePro/Core/Utilities/Connection/EnvVarResolver.swift b/TablePro/Core/Utilities/Connection/EnvVarResolver.swift new file mode 100644 index 00000000..3378ed19 --- /dev/null +++ b/TablePro/Core/Utilities/Connection/EnvVarResolver.swift @@ -0,0 +1,94 @@ +// +// EnvVarResolver.swift +// TablePro +// +// Resolves $VAR and ${VAR} patterns from process environment variables. +// + +import Foundation +import os + +internal enum EnvVarResolver { + private static let logger = Logger(subsystem: "com.TablePro", category: "EnvVarResolver") + + // Matches ${VAR_NAME} or $VAR_NAME + // swiftlint:disable:next force_try + private static let pattern = try! NSRegularExpression( + pattern: #"\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)"# + ) + + /// Resolve environment variable references in a string. + /// Unresolved variables are left as-is and logged as warnings. + static func resolve(_ value: String) -> String { + let nsValue = value as NSString + let length = nsValue.length + guard length > 0 else { return value } + + let fullRange = NSRange(location: 0, length: length) + let matches = pattern.matches(in: value, range: fullRange) + guard !matches.isEmpty else { return value } + + // Process in reverse order so replacement ranges stay valid + let result = NSMutableString(string: nsValue) + for match in matches.reversed() { + // Group 1: ${VAR}, Group 2: $VAR + let varName: String + if match.range(at: 1).location != NSNotFound { + varName = nsValue.substring(with: match.range(at: 1)) + } else { + varName = nsValue.substring(with: match.range(at: 2)) + } + + if let envValue = ProcessInfo.processInfo.environment[varName] { + result.replaceCharacters(in: match.range, with: envValue) + } else { + logger.warning("Unresolved environment variable: \(varName)") + } + } + + return result as String + } + + /// Check whether a string contains any `$VAR` or `${VAR}` references. + static func containsVarReferences(_ value: String) -> Bool { + let length = (value as NSString).length + guard length > 0 else { return false } + let fullRange = NSRange(location: 0, length: length) + return pattern.firstMatch(in: value, range: fullRange) != nil + } + + /// Resolve environment variables in all applicable connection fields. + /// Returns a new connection; the original is never mutated. + static func resolveConnection(_ connection: DatabaseConnection) -> DatabaseConnection { + var resolved = connection + + resolved.host = resolve(connection.host) + resolved.database = resolve(connection.database) + resolved.username = resolve(connection.username) + + // SSH fields + resolved.sshConfig.host = resolve(connection.sshConfig.host) + resolved.sshConfig.username = resolve(connection.sshConfig.username) + resolved.sshConfig.privateKeyPath = resolve(connection.sshConfig.privateKeyPath) + resolved.sshConfig.agentSocketPath = resolve(connection.sshConfig.agentSocketPath) + + // SSL certificate paths + resolved.sslConfig.caCertificatePath = resolve(connection.sslConfig.caCertificatePath) + resolved.sslConfig.clientCertificatePath = resolve(connection.sslConfig.clientCertificatePath) + resolved.sslConfig.clientKeyPath = resolve(connection.sslConfig.clientKeyPath) + + // Startup commands + if let commands = connection.startupCommands { + resolved.startupCommands = resolve(commands) + } + + // Additional fields values + var resolvedFields: [String: String] = [:] + for (key, value) in connection.additionalFields { + resolvedFields[key] = resolve(value) + } + resolved.additionalFields = resolvedFields + + return resolved + } +} diff --git a/TablePro/Models/Settings/ProFeature.swift b/TablePro/Models/Settings/ProFeature.swift index c986770b..5f87c988 100644 --- a/TablePro/Models/Settings/ProFeature.swift +++ b/TablePro/Models/Settings/ProFeature.swift @@ -13,6 +13,8 @@ internal enum ProFeature: String, CaseIterable { case safeMode case xlsxExport case encryptedExport + case envVarReferences + case linkedFolders var displayName: String { switch self { @@ -24,6 +26,10 @@ internal enum ProFeature: String, CaseIterable { return String(localized: "XLSX Export") case .encryptedExport: return String(localized: "Encrypted Export") + case .envVarReferences: + return String(localized: "Environment Variables") + case .linkedFolders: + return String(localized: "Linked Folders") } } @@ -37,6 +43,10 @@ internal enum ProFeature: String, CaseIterable { return "tablecells" case .encryptedExport: return "lock.doc" + case .envVarReferences: + return "dollarsign.square" + case .linkedFolders: + return "folder.badge.gearshape" } } @@ -50,6 +60,10 @@ internal enum ProFeature: String, CaseIterable { return String(localized: "Export query results and tables to Excel format.") case .encryptedExport: return String(localized: "Export connections with encrypted credentials.") + case .envVarReferences: + return String(localized: "Use environment variables in connection fields.") + case .linkedFolders: + return String(localized: "Watch shared folders for connection files.") } } } diff --git a/TablePro/Views/Connection/WelcomeWindowView.swift b/TablePro/Views/Connection/WelcomeWindowView.swift index f0fc4147..2b01e3af 100644 --- a/TablePro/Views/Connection/WelcomeWindowView.swift +++ b/TablePro/Views/Connection/WelcomeWindowView.swift @@ -46,6 +46,7 @@ struct WelcomeWindowView: View { @State private var pluginInstallConnection: DatabaseConnection? @State private var importFileURL: IdentifiableURL? @State private var pendingExportConnections: IdentifiableConnections? + @State private var linkedConnections: [LinkedConnection] = [] @Environment(\.openWindow) private var openWindow @@ -184,6 +185,12 @@ struct WelcomeWindowView: View { .onReceive(NotificationCenter.default.publisher(for: .importConnections)) { _ in importConnectionsFromFile() } + .onReceive(NotificationCenter.default.publisher(for: .linkedFoldersDidUpdate)) { _ in + linkedConnections = LinkedFolderWatcher.shared.linkedConnections + } + .task { + linkedConnections = LinkedFolderWatcher.shared.linkedConnections + } } private var welcomeContent: some View { @@ -452,6 +459,22 @@ struct WelcomeWindowView: View { guard searchText.isEmpty else { return } moveGroups(from: from, to: to) } + + if !linkedConnections.isEmpty, LicenseManager.shared.isFeatureAvailable(.linkedFolders) { + Section { + ForEach(linkedConnections) { linked in + linkedConnectionRow(for: linked) + } + } header: { + HStack(spacing: 4) { + Image(systemName: "folder.fill") + .font(.caption2) + Text(String(localized: "Linked")) + .font(.caption) + } + .foregroundStyle(.secondary) + } + } } .listStyle(.inset) .scrollContentBackground(.hidden) @@ -514,6 +537,54 @@ struct WelcomeWindowView: View { .contextMenu { contextMenuContent(for: connection) } } + private func linkedConnectionRow(for linked: LinkedConnection) -> some View { + HStack(spacing: 12) { + ZStack(alignment: .bottomTrailing) { + DatabaseType(rawValue: linked.connection.type).iconImage + .frame(width: 28, height: 28) + Image(systemName: "folder.fill") + .font(.system(size: 8)) + .foregroundStyle(.secondary) + .offset(x: 2, y: 2) + } + VStack(alignment: .leading, spacing: 2) { + Text(linked.connection.name) + .lineLimit(1) + Text("\(linked.connection.host):\(String(linked.connection.port))") + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + .padding(.vertical, ThemeEngine.shared.activeTheme.spacing.xxs) + .listRowInsets(ThemeEngine.shared.activeTheme.spacing.listRowInsets.swiftUI) + .contentShape(Rectangle()) + .simultaneousGesture(TapGesture(count: 2).onEnded { + connectToLinkedConnection(linked) + }) + .listRowSeparator(.hidden) + .contextMenu { + Button { + connectToLinkedConnection(linked) + } label: { + Label(String(localized: "Connect"), systemImage: "play.fill") + } + } + } + + private func connectToLinkedConnection(_ linked: LinkedConnection) { + let connection = DatabaseConnection( + id: linked.id, + name: linked.connection.name, + host: linked.connection.host, + port: linked.connection.port, + database: linked.connection.database, + username: linked.connection.username, + type: DatabaseType(rawValue: linked.connection.type) + ) + connectToDatabase(connection) + } + private func groupHeader(for group: ConnectionGroup) -> some View { Button(action: { withAnimation(.easeInOut(duration: 0.2)) { diff --git a/TablePro/Views/Settings/LinkedFoldersSection.swift b/TablePro/Views/Settings/LinkedFoldersSection.swift new file mode 100644 index 00000000..18a010b8 --- /dev/null +++ b/TablePro/Views/Settings/LinkedFoldersSection.swift @@ -0,0 +1,135 @@ +// +// LinkedFoldersSection.swift +// TablePro +// +// Settings section for managing linked folders. +// Linked folders are watched for .tablepro connection files. +// + +import AppKit +import SwiftUI + +struct LinkedFoldersSection: View { + @State private var folders: [LinkedFolder] = LinkedFolderStorage.shared.loadFolders() + + private var isLicensed: Bool { + LicenseManager.shared.isFeatureAvailable(.linkedFolders) + } + + var body: some View { + Section { + if folders.isEmpty { + Text("No linked folders. Add a folder to watch for shared connection files.") + .font(.caption) + .foregroundStyle(.secondary) + } else { + ForEach(folders) { folder in + folderRow(folder) + } + } + + Button { + addFolder() + } label: { + Label("Add Folder...", systemImage: "plus") + } + .disabled(!isLicensed) + } header: { + HStack(spacing: 6) { + Text("Linked Folders") + if !isLicensed { + ProBadge() + } + } + } footer: { + Text("Watched folders are scanned for .tablepro files. Connections appear read-only in the sidebar.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + + // MARK: - Folder Row + + private func folderRow(_ folder: LinkedFolder) -> some View { + HStack(spacing: 8) { + Toggle(isOn: Binding( + get: { folder.isEnabled }, + set: { newValue in + guard let index = folders.firstIndex(where: { $0.id == folder.id }) else { return } + folders[index].isEnabled = newValue + LinkedFolderStorage.shared.saveFolders(folders) + LinkedFolderWatcher.shared.reload() + } + )) { + EmptyView() + } + .toggleStyle(.switch) + .controlSize(.mini) + .labelsHidden() + + VStack(alignment: .leading, spacing: 1) { + Text(folder.name) + .font(.body) + .lineLimit(1) + + Text(folder.path) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + + Spacer() + + Button(role: .destructive) { + removeFolder(folder) + } label: { + Image(systemName: "trash") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .help(String(localized: "Remove Folder")) + } + } + + // MARK: - Actions + + private func addFolder() { + let panel = NSOpenPanel() + panel.canChooseFiles = false + panel.canChooseDirectories = true + panel.allowsMultipleSelection = false + panel.message = String(localized: "Choose a folder to watch for .tablepro connection files") + + panel.begin { response in + guard response == .OK, let url = panel.url else { return } + let path = PathPortability.contractHome(url.path) + + guard !folders.contains(where: { $0.path == path }) else { return } + + let folder = LinkedFolder(path: path) + LinkedFolderStorage.shared.addFolder(folder) + folders = LinkedFolderStorage.shared.loadFolders() + LinkedFolderWatcher.shared.reload() + } + } + + private func removeFolder(_ folder: LinkedFolder) { + LinkedFolderStorage.shared.removeFolder(folder) + folders = LinkedFolderStorage.shared.loadFolders() + LinkedFolderWatcher.shared.reload() + } +} + +// MARK: - Pro Badge + +private struct ProBadge: View { + var body: some View { + Text("PRO") + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(.white) + .padding(.horizontal, 4) + .padding(.vertical, 1) + .background(.orange, in: RoundedRectangle(cornerRadius: 3)) + } +} diff --git a/TablePro/Views/Settings/SyncSettingsView.swift b/TablePro/Views/Settings/SyncSettingsView.swift index 008cbfab..6c27497f 100644 --- a/TablePro/Views/Settings/SyncSettingsView.swift +++ b/TablePro/Views/Settings/SyncSettingsView.swift @@ -37,6 +37,8 @@ struct SyncSettingsView: View { syncCategoriesSection } + + LinkedFoldersSection() } .formStyle(.grouped) .scrollContentBackground(.hidden) diff --git a/docs/features/connection-sharing.mdx b/docs/features/connection-sharing.mdx index 275a74a2..6e002df3 100644 --- a/docs/features/connection-sharing.mdx +++ b/docs/features/connection-sharing.mdx @@ -5,42 +5,126 @@ description: Export and import database connections as .tablepro files # Connection Sharing -Share connections with your team using `.tablepro` files. Passwords are not included. +Export connections to a `.tablepro` file and share with your team. Passwords stay in your Keychain. ## Export -- Right-click a connection > **Export Connection...** -- Select multiple, then right-click > **Export N Connections...** -- **File** menu > **Export Connections...** (exports all) +Right-click a connection > **Export Connection...**. Select multiple first to export together. Or use **File** > **Export Connections...** for all. -The file includes host, port, username, type, SSH/SSL config, color, tag, group, and safe mode. Passwords are excluded. +Exported: host, port, username, type, SSH/SSL config, color, tag, group, safe mode. Not exported: passwords, key passphrases, TOTP secrets. + + + Export connections + Export connections + ## Import +- **File** > **Import Connections...** - Right-click empty area > **Import Connections...** -- **File** menu > **Import Connections...** - Double-click a `.tablepro` file -- Drag a `.tablepro` file onto TablePro +- Drag 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**. +| Badge | Meaning | +|-------|---------| +| Green checkmark | Ready | +| Yellow triangle | SSH key or cert not found | +| "duplicate" tag | Already exists | + +Duplicates are unchecked. Check to import, then pick **As Copy**, **Replace**, or **Skip**. + + + Import preview + Import preview + ## Share via Link -Right-click a connection > **Copy as Import Link**. Paste the URL in Slack, a wiki, or a README. +Right-click > **Copy as Import Link**. Paste in Slack, a wiki, or a README. ``` tablepro://import?name=Staging&host=db.example.com&port=5432&type=PostgreSQL&username=admin ``` +## Encrypted Export Pro + +Include passwords in the export, protected by a passphrase (AES-256-GCM). + +1. Right-click > **Export...** +2. Check **Include Credentials** +3. Enter passphrase (8+ characters), confirm +4. **Export...** + +When importing, TablePro prompts for the passphrase. + + + Encrypted export + Encrypted export + + +## Linked Folders Pro + +Watch a shared directory for `.tablepro` files. Connections appear read-only in the sidebar. Each person enters their own password. + +**Settings** (`Cmd+,`) > **Sync** > **Linked Folders** > **Add Folder...** + +Works with Git repos, Dropbox, network drives. + + + Linked Folders + Linked Folders + + +## Environment Variables Pro + +Use `$VAR` and `${VAR}` in `.tablepro` files. Resolved at connection time. + +```json +{ + "host": "${DB_HOST}", + "username": "$DB_USER" +} +``` + +Works with `.env` files, 1Password CLI (`op run`), direnv. + ## File Format -JSON. Only `name`, `host`, and `type` are required. +JSON. Required fields: `name`, `host`, `type`. ```json { @@ -62,28 +146,11 @@ JSON. Only `name`, `host`, and `type` are required. Groups and tags match by name. Missing ones are created. Paths use `~/` for portability. -## Passwords - -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 | | Sharing | iCloud Sync | |---|---|---| | **For** | Team | Your Macs | | **How** | Files, links | CloudKit | -| **Passwords** | Optional (encrypted, Pro) | Optional sync | -| **Requires** | Nothing (Pro for credentials) | Pro + iCloud | +| **Passwords** | Per-user (encrypted with Pro) | Optional sync | +| **Requires** | Nothing (Pro for extras) | Pro + iCloud |