Skip to content

Commit db171c3

Browse files
authored
feat: add linked folders and environment variable references (Pro) (#476)
* feat: add environment variable resolution for connection fields * feat: add linked folders and UI integration for team connection sharing * fix: address code review — stable IDs, async I/O, Pro gates, double-click * docs: add screenshot placeholder comments to connection sharing page * docs: clean up connection sharing page wording and styling
1 parent 72c5c09 commit db171c3

12 files changed

Lines changed: 677 additions & 40 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12+
- Linked Folders: watch a shared directory for `.tablepro` files, auto-sync connections to sidebar (Pro)
13+
- Environment variable references: use `$VAR` and `${VAR}` in `.tablepro` files, resolved at connection time (Pro)
1214
- Encrypted connection export with credentials: Pro users can include passwords in exports, protected by AES-256-GCM encryption with a passphrase
1315
- Connection sharing: export/import connections as `.tablepro` files (#466)
1416
- Import preview with duplicate detection, warning badges, and per-item resolution
1517
- "Copy as Import Link" context menu action for sharing via `tablepro://` URLs
1618
- `.tablepro` file type registration (double-click to import, drag-and-drop)
19+
- Environment variable references (`$VAR` / `${VAR}`) in connection fields (host, database, username, SSH, SSL paths, startup commands, additional fields) — Pro feature
1720

1821
## [0.24.2] - 2026-03-26
1922

TablePro/AppDelegate.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
8989
AnalyticsService.shared.startPeriodicHeartbeat()
9090

9191
SyncCoordinator.shared.start()
92+
LinkedFolderWatcher.shared.start()
9293

9394
Task.detached(priority: .background) {
9495
_ = QueryHistoryStorage.shared
@@ -131,6 +132,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
131132
}
132133

133134
func applicationWillTerminate(_ notification: Notification) {
135+
LinkedFolderWatcher.shared.stop()
134136
UserDefaults.standard.synchronize()
135137
SSHTunnelManager.shared.terminateAllProcessesSync()
136138
}

TablePro/Core/Database/DatabaseManager.swift

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,14 @@ final class DatabaseManager {
9292
return
9393
}
9494

95+
// Resolve environment variable references in connection fields (Pro feature)
96+
let resolvedConnection: DatabaseConnection
97+
if LicenseManager.shared.isFeatureAvailable(.envVarReferences) {
98+
resolvedConnection = EnvVarResolver.resolveConnection(connection)
99+
} else {
100+
resolvedConnection = connection
101+
}
102+
95103
// Create new session (or reuse a prepared one)
96104
if activeSessions[connection.id] == nil {
97105
var session = ConnectionSession(connection: connection)
@@ -103,7 +111,7 @@ final class DatabaseManager {
103111
// Create SSH tunnel if needed and build effective connection
104112
let effectiveConnection: DatabaseConnection
105113
do {
106-
effectiveConnection = try await buildEffectiveConnection(for: connection)
114+
effectiveConnection = try await buildEffectiveConnection(for: resolvedConnection)
107115
} catch {
108116
// Remove failed session
109117
removeSessionEntry(for: connection.id)
@@ -112,7 +120,7 @@ final class DatabaseManager {
112120
}
113121

114122
// Run pre-connect hook if configured (only on explicit connect, not auto-reconnect)
115-
if let script = connection.preConnectScript,
123+
if let script = resolvedConnection.preConnectScript,
116124
!script.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
117125
{
118126
do {
@@ -155,7 +163,7 @@ final class DatabaseManager {
155163

156164
// Run startup commands before schema init
157165
await executeStartupCommands(
158-
connection.startupCommands, on: driver, connectionName: connection.name
166+
resolvedConnection.startupCommands, on: driver, connectionName: connection.name
159167
)
160168

161169
// Initialize schema for drivers that support schema switching
@@ -172,7 +180,7 @@ final class DatabaseManager {
172180
switch action {
173181
case .selectDatabaseFromLastSession:
174182
// Restore saved database (e.g. MSSQL) only when no explicit database is configured
175-
if connection.database.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
183+
if resolvedConnection.database.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
176184
let adapter = driver as? PluginDriverAdapter,
177185
let savedDb = AppSettingsStorage.shared.loadLastDatabase(for: connection.id) {
178186
try? await adapter.switchDatabase(to: savedDb)
@@ -183,11 +191,11 @@ final class DatabaseManager {
183191
// Check additionalFields first, then legacy dedicated properties, then
184192
// fall back to parsing the main database field.
185193
let initialDb: Int
186-
if let fieldValue = connection.additionalFields[fieldId], let parsed = Int(fieldValue) {
194+
if let fieldValue = resolvedConnection.additionalFields[fieldId], let parsed = Int(fieldValue) {
187195
initialDb = parsed
188-
} else if fieldId == "redisDatabase", let legacy = connection.redisDatabase {
196+
} else if fieldId == "redisDatabase", let legacy = resolvedConnection.redisDatabase {
189197
initialDb = legacy
190-
} else if let fallback = Int(connection.database) {
198+
} else if let fallback = Int(resolvedConnection.database) {
191199
initialDb = fallback
192200
} else {
193201
initialDb = 0
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
//
2+
// LinkedFolderWatcher.swift
3+
// TablePro
4+
//
5+
// Watches linked folders for .tablepro connection files.
6+
// Rescans on filesystem changes with 1s debounce.
7+
//
8+
9+
import CryptoKit
10+
import Foundation
11+
import os
12+
13+
struct LinkedConnection: Identifiable {
14+
let id: UUID
15+
let connection: ExportableConnection
16+
let folderId: UUID
17+
let sourceFileURL: URL
18+
}
19+
20+
@MainActor
21+
@Observable
22+
final class LinkedFolderWatcher {
23+
static let shared = LinkedFolderWatcher()
24+
private static let logger = Logger(subsystem: "com.TablePro", category: "LinkedFolderWatcher")
25+
26+
private(set) var linkedConnections: [LinkedConnection] = []
27+
private var watchSources: [UUID: DispatchSourceFileSystemObject] = [:]
28+
private var debounceTask: Task<Void, Never>?
29+
30+
private init() {}
31+
32+
func start() {
33+
guard LicenseManager.shared.isFeatureAvailable(.linkedFolders) else { return }
34+
let folders = LinkedFolderStorage.shared.loadFolders()
35+
scheduleScan(folders)
36+
setupWatchers(for: folders)
37+
}
38+
39+
func stop() {
40+
cancelAllWatchers()
41+
debounceTask?.cancel()
42+
debounceTask = nil
43+
}
44+
45+
func reload() {
46+
stop()
47+
start()
48+
}
49+
50+
// MARK: - Scanning (off main thread)
51+
52+
private func scheduleScan(_ folders: [LinkedFolder]) {
53+
debounceTask?.cancel()
54+
debounceTask = Task { @MainActor [weak self] in
55+
let results = await Self.scanFoldersAsync(folders)
56+
self?.linkedConnections = results
57+
NotificationCenter.default.post(name: .linkedFoldersDidUpdate, object: nil)
58+
}
59+
}
60+
61+
private func scheduleDebouncedRescan() {
62+
debounceTask?.cancel()
63+
debounceTask = Task { @MainActor [weak self] in
64+
try? await Task.sleep(for: .seconds(1))
65+
guard !Task.isCancelled else { return }
66+
let folders = LinkedFolderStorage.shared.loadFolders()
67+
let results = await Self.scanFoldersAsync(folders)
68+
self?.linkedConnections = results
69+
NotificationCenter.default.post(name: .linkedFoldersDidUpdate, object: nil)
70+
}
71+
}
72+
73+
/// Scans folders on a background thread to avoid blocking the main actor.
74+
private nonisolated static func scanFoldersAsync(_ folders: [LinkedFolder]) async -> [LinkedConnection] {
75+
await Task.detached(priority: .utility) {
76+
scanFolders(folders)
77+
}.value
78+
}
79+
80+
/// Pure scanning logic. Runs on any thread.
81+
private nonisolated static func scanFolders(_ folders: [LinkedFolder]) -> [LinkedConnection] {
82+
var results: [LinkedConnection] = []
83+
let fm = FileManager.default
84+
85+
for folder in folders where folder.isEnabled {
86+
let expandedPath = folder.expandedPath
87+
guard fm.fileExists(atPath: expandedPath) else {
88+
logger.warning("Linked folder not found: \(expandedPath, privacy: .public)")
89+
continue
90+
}
91+
92+
guard let contents = try? fm.contentsOfDirectory(atPath: expandedPath) else {
93+
logger.warning("Cannot read linked folder: \(expandedPath, privacy: .public)")
94+
continue
95+
}
96+
97+
for filename in contents where filename.hasSuffix(".tablepro") {
98+
let fileURL = URL(fileURLWithPath: expandedPath).appendingPathComponent(filename)
99+
guard let data = try? Data(contentsOf: fileURL) else { continue }
100+
101+
if ConnectionExportCrypto.isEncrypted(data) { continue }
102+
103+
guard let envelope = try? ConnectionExportService.decodeData(data) else { continue }
104+
105+
for exportable in envelope.connections {
106+
let stableId = stableId(folderId: folder.id, connection: exportable)
107+
results.append(LinkedConnection(
108+
id: stableId,
109+
connection: exportable,
110+
folderId: folder.id,
111+
sourceFileURL: fileURL
112+
))
113+
}
114+
}
115+
}
116+
117+
return results
118+
}
119+
120+
// MARK: - Watchers
121+
122+
private func setupWatchers(for folders: [LinkedFolder]) {
123+
cancelAllWatchers()
124+
125+
for folder in folders where folder.isEnabled {
126+
let expandedPath = folder.expandedPath
127+
let fd = open(expandedPath, O_EVTONLY)
128+
guard fd >= 0 else {
129+
Self.logger.warning("Cannot open linked folder for watching: \(expandedPath, privacy: .public)")
130+
continue
131+
}
132+
133+
let source = DispatchSource.makeFileSystemObjectSource(
134+
fileDescriptor: fd,
135+
eventMask: [.write, .delete, .rename],
136+
queue: .global(qos: .utility)
137+
)
138+
139+
source.setEventHandler { [weak self] in
140+
Task { @MainActor [weak self] in
141+
self?.scheduleDebouncedRescan()
142+
}
143+
}
144+
145+
source.setCancelHandler {
146+
close(fd)
147+
}
148+
149+
watchSources[folder.id] = source
150+
source.resume()
151+
}
152+
}
153+
154+
private func cancelAllWatchers() {
155+
for (_, source) in watchSources {
156+
source.cancel()
157+
}
158+
watchSources.removeAll()
159+
}
160+
161+
// MARK: - Stable IDs (SHA-256 based, deterministic across launches)
162+
163+
private nonisolated static func stableId(folderId: UUID, connection: ExportableConnection) -> UUID {
164+
let key = "\(folderId.uuidString)|\(connection.name)|\(connection.host)|\(connection.port)|\(connection.type)"
165+
let digest = SHA256.hash(data: Data(key.utf8))
166+
var bytes = Array(digest.prefix(16))
167+
// Set UUID version 5 and variant bits
168+
bytes[6] = (bytes[6] & 0x0F) | 0x50
169+
bytes[8] = (bytes[8] & 0x3F) | 0x80
170+
return UUID(uuid: (
171+
bytes[0], bytes[1], bytes[2], bytes[3],
172+
bytes[4], bytes[5], bytes[6], bytes[7],
173+
bytes[8], bytes[9], bytes[10], bytes[11],
174+
bytes[12], bytes[13], bytes[14], bytes[15]
175+
))
176+
}
177+
}

TablePro/Core/Services/Infrastructure/AppNotifications.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ extension Notification.Name {
2222
static let connectionShareFileOpened = Notification.Name("connectionShareFileOpened")
2323
static let exportConnections = Notification.Name("exportConnections")
2424
static let importConnections = Notification.Name("importConnections")
25+
static let linkedFoldersDidUpdate = Notification.Name("linkedFoldersDidUpdate")
2526

2627
// MARK: - License
2728

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
//
2+
// LinkedFolderStorage.swift
3+
// TablePro
4+
//
5+
// UserDefaults persistence for linked folder paths.
6+
//
7+
8+
import Foundation
9+
import os
10+
11+
struct LinkedFolder: Codable, Identifiable, Hashable {
12+
let id: UUID
13+
var path: String
14+
var isEnabled: Bool
15+
16+
var name: String { (path as NSString).lastPathComponent }
17+
var expandedPath: String { PathPortability.expandHome(path) }
18+
19+
init(id: UUID = UUID(), path: String, isEnabled: Bool = true) {
20+
self.id = id
21+
self.path = path
22+
self.isEnabled = isEnabled
23+
}
24+
}
25+
26+
final class LinkedFolderStorage {
27+
static let shared = LinkedFolderStorage()
28+
private static let logger = Logger(subsystem: "com.TablePro", category: "LinkedFolderStorage")
29+
private let key = "com.TablePro.linkedFolders"
30+
31+
private init() {}
32+
33+
func loadFolders() -> [LinkedFolder] {
34+
guard let data = UserDefaults.standard.data(forKey: key) else { return [] }
35+
do {
36+
return try JSONDecoder().decode([LinkedFolder].self, from: data)
37+
} catch {
38+
Self.logger.error("Failed to decode linked folders: \(error.localizedDescription, privacy: .public)")
39+
return []
40+
}
41+
}
42+
43+
func saveFolders(_ folders: [LinkedFolder]) {
44+
do {
45+
let data = try JSONEncoder().encode(folders)
46+
UserDefaults.standard.set(data, forKey: key)
47+
} catch {
48+
Self.logger.error("Failed to encode linked folders: \(error.localizedDescription, privacy: .public)")
49+
}
50+
}
51+
52+
func addFolder(_ folder: LinkedFolder) {
53+
var folders = loadFolders()
54+
folders.append(folder)
55+
saveFolders(folders)
56+
}
57+
58+
func removeFolder(_ folder: LinkedFolder) {
59+
var folders = loadFolders()
60+
folders.removeAll { $0.id == folder.id }
61+
saveFolders(folders)
62+
}
63+
}

0 commit comments

Comments
 (0)