Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions TablePro/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
AnalyticsService.shared.startPeriodicHeartbeat()

SyncCoordinator.shared.start()
LinkedFolderWatcher.shared.start()

Task.detached(priority: .background) {
_ = QueryHistoryStorage.shared
Expand Down Expand Up @@ -131,6 +132,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
}

func applicationWillTerminate(_ notification: Notification) {
LinkedFolderWatcher.shared.stop()
UserDefaults.standard.synchronize()
SSHTunnelManager.shared.terminateAllProcessesSync()
}
Expand Down
22 changes: 15 additions & 7 deletions TablePro/Core/Database/DatabaseManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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
Expand Down
177 changes: 177 additions & 0 deletions TablePro/Core/Services/Export/LinkedFolderWatcher.swift
Original file line number Diff line number Diff line change
@@ -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<Void, Never>?

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]
))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
63 changes: 63 additions & 0 deletions TablePro/Core/Storage/LinkedFolderStorage.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading
Loading