Skip to content

Commit cd53240

Browse files
authored
Merge pull request #263 from datlechin/feat/open-sqlite-files
feat: open SQLite files from Finder by double-clicking
2 parents 3883a47 + 6d7692c commit cd53240

6 files changed

Lines changed: 968 additions & 915 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2828

2929
### Added
3030

31+
- Open SQLite database files directly from Finder by double-clicking `.sqlite`, `.sqlite3`, `.db3`, `.s3db`, `.sl3`, and `.sqlitedb` files (#262)
3132
- Export plugin options (CSV, XLSX, JSON, SQL, MQL) now persist across app restarts
3233
- Plugins can declare settings views rendered in Settings > Plugins
3334
- True prepared statements for MSSQL (`sp_executesql`) and ClickHouse (HTTP query parameters), eliminating string interpolation for parameterized queries
Lines changed: 364 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,364 @@
1+
//
2+
// AppDelegate+ConnectionHandler.swift
3+
// TablePro
4+
//
5+
// Database URL and SQLite file open handlers with cold-start queuing
6+
//
7+
8+
import AppKit
9+
import os
10+
11+
private let connectionLogger = Logger(subsystem: "com.TablePro", category: "ConnectionHandler")
12+
13+
/// Typed queue entry for URLs waiting on the SwiftUI window system.
14+
/// Replaces the separate `queuedDatabaseURLs` and `queuedSQLiteFileURLs` arrays.
15+
enum QueuedURLEntry {
16+
case databaseURL(URL)
17+
case sqliteFile(URL)
18+
}
19+
20+
extension AppDelegate {
21+
// MARK: - Database URL Handler
22+
23+
func handleDatabaseURL(_ url: URL) {
24+
guard WindowOpener.shared.openWindow != nil else {
25+
queuedURLEntries.append(.databaseURL(url))
26+
scheduleQueuedURLProcessing()
27+
return
28+
}
29+
30+
let result = ConnectionURLParser.parse(url.absoluteString)
31+
guard case .success(let parsed) = result else {
32+
connectionLogger.error("Failed to parse database URL: \(url.sanitizedForLogging, privacy: .public)")
33+
return
34+
}
35+
36+
let connections = ConnectionStorage.shared.loadConnections()
37+
let matchedConnection = connections.first { conn in
38+
conn.type == parsed.type
39+
&& conn.host == parsed.host
40+
&& (parsed.port == nil || conn.port == parsed.port)
41+
&& conn.database == parsed.database
42+
&& (parsed.username.isEmpty || conn.username == parsed.username)
43+
}
44+
45+
let connection: DatabaseConnection
46+
if let matched = matchedConnection {
47+
connection = matched
48+
} else {
49+
connection = buildTransientConnection(from: parsed)
50+
}
51+
52+
if !parsed.password.isEmpty {
53+
ConnectionStorage.shared.savePassword(parsed.password, for: connection.id)
54+
}
55+
56+
if DatabaseManager.shared.activeSessions[connection.id]?.driver != nil {
57+
handlePostConnectionActions(parsed, connectionId: connection.id)
58+
bringConnectionWindowToFront(connection.id)
59+
return
60+
}
61+
62+
if let activeId = findActiveSessionByParams(parsed) {
63+
handlePostConnectionActions(parsed, connectionId: activeId)
64+
bringConnectionWindowToFront(activeId)
65+
return
66+
}
67+
68+
openNewConnectionWindow(for: connection)
69+
70+
Task { @MainActor in
71+
do {
72+
try await DatabaseManager.shared.connectToSession(connection)
73+
for window in NSApp.windows where self.isWelcomeWindow(window) {
74+
window.close()
75+
}
76+
self.handlePostConnectionActions(parsed, connectionId: connection.id)
77+
} catch {
78+
connectionLogger.error("Database URL connect failed: \(error.localizedDescription)")
79+
await self.handleConnectionFailure(error)
80+
}
81+
}
82+
}
83+
84+
// MARK: - SQLite File Handler
85+
86+
func handleSQLiteFile(_ url: URL) {
87+
guard WindowOpener.shared.openWindow != nil else {
88+
queuedURLEntries.append(.sqliteFile(url))
89+
scheduleQueuedURLProcessing()
90+
return
91+
}
92+
93+
let filePath = url.path(percentEncoded: false)
94+
let connectionName = url.deletingPathExtension().lastPathComponent
95+
96+
for (sessionId, session) in DatabaseManager.shared.activeSessions {
97+
if session.connection.type == .sqlite
98+
&& session.connection.database == filePath
99+
&& session.driver != nil {
100+
bringConnectionWindowToFront(sessionId)
101+
return
102+
}
103+
}
104+
105+
let connection = DatabaseConnection(
106+
name: connectionName,
107+
host: "",
108+
port: 0,
109+
database: filePath,
110+
username: "",
111+
type: .sqlite
112+
)
113+
114+
openNewConnectionWindow(for: connection)
115+
116+
Task { @MainActor in
117+
do {
118+
try await DatabaseManager.shared.connectToSession(connection)
119+
for window in NSApp.windows where self.isWelcomeWindow(window) {
120+
window.close()
121+
}
122+
} catch {
123+
connectionLogger.error("SQLite file open failed for '\(filePath, privacy: .public)': \(error.localizedDescription)")
124+
await self.handleConnectionFailure(error)
125+
}
126+
}
127+
}
128+
129+
// MARK: - Unified Queue
130+
131+
func scheduleQueuedURLProcessing() {
132+
guard !isProcessingQueuedURLs else { return }
133+
isProcessingQueuedURLs = true
134+
135+
Task { @MainActor [weak self] in
136+
defer { self?.isProcessingQueuedURLs = false }
137+
138+
var ready = false
139+
for _ in 0..<25 {
140+
if WindowOpener.shared.openWindow != nil { ready = true; break }
141+
try? await Task.sleep(for: .milliseconds(200))
142+
}
143+
guard let self else { return }
144+
if !ready {
145+
connectionLogger.warning(
146+
"SwiftUI window system not ready after 5s, dropping \(self.queuedURLEntries.count) queued URL(s)"
147+
)
148+
self.queuedURLEntries.removeAll()
149+
return
150+
}
151+
152+
self.suppressWelcomeWindow()
153+
let entries = self.queuedURLEntries
154+
self.queuedURLEntries.removeAll()
155+
for entry in entries {
156+
switch entry {
157+
case .databaseURL(let url): self.handleDatabaseURL(url)
158+
case .sqliteFile(let url): self.handleSQLiteFile(url)
159+
}
160+
}
161+
self.scheduleWelcomeWindowSuppression()
162+
}
163+
}
164+
165+
// MARK: - SQL File Queue (drained by .databaseDidConnect)
166+
167+
@objc func handleDatabaseDidConnect() {
168+
guard !queuedFileURLs.isEmpty else { return }
169+
let urls = queuedFileURLs
170+
queuedFileURLs.removeAll()
171+
postSQLFilesWhenReady(urls: urls)
172+
}
173+
174+
private func postSQLFilesWhenReady(urls: [URL]) {
175+
Task { @MainActor [weak self] in
176+
try? await Task.sleep(for: .milliseconds(100))
177+
if !NSApp.windows.contains(where: { self?.isMainWindow($0) == true && $0.isKeyWindow }) {
178+
connectionLogger.warning("postSQLFilesWhenReady: no key main window, posting anyway")
179+
}
180+
NotificationCenter.default.post(name: .openSQLFiles, object: urls)
181+
}
182+
}
183+
184+
// MARK: - Connection Window Helper
185+
186+
private func openNewConnectionWindow(for connection: DatabaseConnection) {
187+
let hadExistingMain = NSApp.windows.contains { isMainWindow($0) && $0.isVisible }
188+
if hadExistingMain {
189+
NSWindow.allowsAutomaticWindowTabbing = false
190+
}
191+
let payload = EditorTabPayload(connectionId: connection.id)
192+
WindowOpener.shared.openNativeTab(payload)
193+
}
194+
195+
// MARK: - Post-Connect Actions
196+
197+
private func handlePostConnectionActions(_ parsed: ParsedConnectionURL, connectionId: UUID) {
198+
Task { @MainActor in
199+
await waitForConnection(timeout: .seconds(5))
200+
201+
if let schema = parsed.schema {
202+
NotificationCenter.default.post(
203+
name: .switchSchemaFromURL,
204+
object: nil,
205+
userInfo: ["connectionId": connectionId, "schema": schema]
206+
)
207+
try? await Task.sleep(for: .milliseconds(500))
208+
}
209+
210+
if let tableName = parsed.tableName {
211+
let payload = EditorTabPayload(
212+
connectionId: connectionId,
213+
tabType: .table,
214+
tableName: tableName,
215+
isView: parsed.isView
216+
)
217+
WindowOpener.shared.openNativeTab(payload)
218+
219+
if parsed.filterColumn != nil || parsed.filterCondition != nil {
220+
try? await Task.sleep(for: .milliseconds(300))
221+
NotificationCenter.default.post(
222+
name: .applyURLFilter,
223+
object: nil,
224+
userInfo: [
225+
"connectionId": connectionId,
226+
"column": parsed.filterColumn as Any,
227+
"operation": parsed.filterOperation as Any,
228+
"value": parsed.filterValue as Any,
229+
"condition": parsed.filterCondition as Any
230+
]
231+
)
232+
}
233+
}
234+
}
235+
}
236+
237+
private func waitForConnection(timeout: Duration) async {
238+
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
239+
var didResume = false
240+
var observer: NSObjectProtocol?
241+
242+
func resumeOnce() {
243+
guard !didResume else { return }
244+
didResume = true
245+
if let obs = observer {
246+
NotificationCenter.default.removeObserver(obs)
247+
}
248+
continuation.resume()
249+
}
250+
251+
let timeoutTask = Task { @MainActor in
252+
try? await Task.sleep(for: timeout)
253+
resumeOnce()
254+
}
255+
observer = NotificationCenter.default.addObserver(
256+
forName: .databaseDidConnect,
257+
object: nil,
258+
queue: .main
259+
) { _ in
260+
timeoutTask.cancel()
261+
resumeOnce()
262+
}
263+
}
264+
}
265+
266+
// MARK: - Session Lookup
267+
268+
private func findActiveSessionByParams(_ parsed: ParsedConnectionURL) -> UUID? {
269+
for (id, session) in DatabaseManager.shared.activeSessions {
270+
guard session.driver != nil else { continue }
271+
let conn = session.connection
272+
if conn.type == parsed.type
273+
&& conn.host == parsed.host
274+
&& conn.database == parsed.database
275+
&& (parsed.port == nil || conn.port == parsed.port || conn.port == parsed.type.defaultPort)
276+
&& (parsed.username.isEmpty || conn.username == parsed.username)
277+
&& (parsed.redisDatabase == nil || conn.redisDatabase == parsed.redisDatabase) {
278+
return id
279+
}
280+
}
281+
return nil
282+
}
283+
284+
func bringConnectionWindowToFront(_ connectionId: UUID) {
285+
let windows = WindowLifecycleMonitor.shared.windows(for: connectionId)
286+
if let window = windows.first {
287+
window.makeKeyAndOrderFront(nil)
288+
} else {
289+
NSApp.windows.first { isMainWindow($0) && $0.isVisible }?.makeKeyAndOrderFront(nil)
290+
}
291+
}
292+
293+
// MARK: - Connection Failure
294+
295+
func handleConnectionFailure(_ error: Error) async {
296+
for window in NSApp.windows where isMainWindow(window) {
297+
let hasActiveSession = DatabaseManager.shared.activeSessions.values.contains {
298+
window.subtitle == $0.connection.name
299+
|| window.subtitle == "\($0.connection.name) — Preview"
300+
}
301+
if !hasActiveSession {
302+
window.close()
303+
}
304+
}
305+
if !NSApp.windows.contains(where: { isMainWindow($0) && $0.isVisible }) {
306+
openWelcomeWindow()
307+
}
308+
try? await Task.sleep(for: .milliseconds(200))
309+
AlertHelper.showErrorSheet(
310+
title: String(localized: "Connection Failed"),
311+
message: error.localizedDescription,
312+
window: NSApp.keyWindow
313+
)
314+
}
315+
316+
// MARK: - Transient Connection Builder
317+
318+
private func buildTransientConnection(from parsed: ParsedConnectionURL) -> DatabaseConnection {
319+
var sshConfig = SSHConfiguration()
320+
if let sshHost = parsed.sshHost {
321+
sshConfig.enabled = true
322+
sshConfig.host = sshHost
323+
sshConfig.port = parsed.sshPort ?? 22
324+
sshConfig.username = parsed.sshUsername ?? ""
325+
if parsed.usePrivateKey == true {
326+
sshConfig.authMethod = .privateKey
327+
}
328+
if parsed.useSSHAgent == true {
329+
sshConfig.authMethod = .sshAgent
330+
sshConfig.agentSocketPath = parsed.agentSocket ?? ""
331+
}
332+
}
333+
334+
var sslConfig = SSLConfiguration()
335+
if let sslMode = parsed.sslMode {
336+
sslConfig.mode = sslMode
337+
}
338+
339+
var color: ConnectionColor = .none
340+
if let hex = parsed.statusColor {
341+
color = ConnectionURLParser.connectionColor(fromHex: hex)
342+
}
343+
344+
var tagId: UUID?
345+
if let envName = parsed.envTag {
346+
tagId = ConnectionURLParser.tagId(fromEnvName: envName)
347+
}
348+
349+
return DatabaseConnection(
350+
name: parsed.connectionName ?? parsed.suggestedName,
351+
host: parsed.host,
352+
port: parsed.port ?? parsed.type.defaultPort,
353+
database: parsed.database,
354+
username: parsed.username,
355+
type: parsed.type,
356+
sshConfig: sshConfig,
357+
sslConfig: sslConfig,
358+
color: color,
359+
tagId: tagId,
360+
redisDatabase: parsed.redisDatabase,
361+
oracleServiceName: parsed.oracleServiceName
362+
)
363+
}
364+
}

0 commit comments

Comments
 (0)