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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- Open SQLite database files directly from Finder by double-clicking `.sqlite`, `.sqlite3`, `.db3`, `.s3db`, `.sl3`, and `.sqlitedb` files (#262)
- Export plugin options (CSV, XLSX, JSON, SQL, MQL) now persist across app restarts
- Plugins can declare settings views rendered in Settings > Plugins
- True prepared statements for MSSQL (`sp_executesql`) and ClickHouse (HTTP query parameters), eliminating string interpolation for parameterized queries
Expand Down
364 changes: 364 additions & 0 deletions TablePro/AppDelegate+ConnectionHandler.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,364 @@
//
// AppDelegate+ConnectionHandler.swift
// TablePro
//
// Database URL and SQLite file open handlers with cold-start queuing
//

import AppKit
import os

private let connectionLogger = Logger(subsystem: "com.TablePro", category: "ConnectionHandler")

/// Typed queue entry for URLs waiting on the SwiftUI window system.
/// Replaces the separate `queuedDatabaseURLs` and `queuedSQLiteFileURLs` arrays.
enum QueuedURLEntry {
case databaseURL(URL)
case sqliteFile(URL)
}

extension AppDelegate {
// MARK: - Database URL Handler

func handleDatabaseURL(_ url: URL) {
guard WindowOpener.shared.openWindow != nil else {
queuedURLEntries.append(.databaseURL(url))
scheduleQueuedURLProcessing()
return
}

let result = ConnectionURLParser.parse(url.absoluteString)
guard case .success(let parsed) = result else {
connectionLogger.error("Failed to parse database URL: \(url.sanitizedForLogging, privacy: .public)")
return
}

let connections = ConnectionStorage.shared.loadConnections()
let matchedConnection = connections.first { conn in
conn.type == parsed.type
&& conn.host == parsed.host
&& (parsed.port == nil || conn.port == parsed.port)
&& conn.database == parsed.database
&& (parsed.username.isEmpty || conn.username == parsed.username)
}

let connection: DatabaseConnection
if let matched = matchedConnection {
connection = matched
} else {
connection = buildTransientConnection(from: parsed)
}

if !parsed.password.isEmpty {
ConnectionStorage.shared.savePassword(parsed.password, for: connection.id)
}

if DatabaseManager.shared.activeSessions[connection.id]?.driver != nil {
handlePostConnectionActions(parsed, connectionId: connection.id)
bringConnectionWindowToFront(connection.id)
return
}

if let activeId = findActiveSessionByParams(parsed) {
handlePostConnectionActions(parsed, connectionId: activeId)
bringConnectionWindowToFront(activeId)
return
}

openNewConnectionWindow(for: connection)

Task { @MainActor in
do {
try await DatabaseManager.shared.connectToSession(connection)
for window in NSApp.windows where self.isWelcomeWindow(window) {
window.close()
}
self.handlePostConnectionActions(parsed, connectionId: connection.id)
} catch {
connectionLogger.error("Database URL connect failed: \(error.localizedDescription)")
await self.handleConnectionFailure(error)
}
}
}

// MARK: - SQLite File Handler

func handleSQLiteFile(_ url: URL) {
guard WindowOpener.shared.openWindow != nil else {
queuedURLEntries.append(.sqliteFile(url))
scheduleQueuedURLProcessing()
return
}

let filePath = url.path(percentEncoded: false)
let connectionName = url.deletingPathExtension().lastPathComponent

for (sessionId, session) in DatabaseManager.shared.activeSessions {
if session.connection.type == .sqlite
&& session.connection.database == filePath
&& session.driver != nil {
bringConnectionWindowToFront(sessionId)
return
}
}

let connection = DatabaseConnection(
name: connectionName,
host: "",
port: 0,
database: filePath,
username: "",
type: .sqlite
)

openNewConnectionWindow(for: connection)

Task { @MainActor in
do {
try await DatabaseManager.shared.connectToSession(connection)
for window in NSApp.windows where self.isWelcomeWindow(window) {
window.close()
}
} catch {
connectionLogger.error("SQLite file open failed for '\(filePath, privacy: .public)': \(error.localizedDescription)")
await self.handleConnectionFailure(error)
}
}
}

// MARK: - Unified Queue

func scheduleQueuedURLProcessing() {
guard !isProcessingQueuedURLs else { return }
isProcessingQueuedURLs = true

Task { @MainActor [weak self] in
defer { self?.isProcessingQueuedURLs = false }

var ready = false
for _ in 0..<25 {
if WindowOpener.shared.openWindow != nil { ready = true; break }
try? await Task.sleep(for: .milliseconds(200))
}
guard let self else { return }
if !ready {
connectionLogger.warning(
"SwiftUI window system not ready after 5s, dropping \(self.queuedURLEntries.count) queued URL(s)"
)
self.queuedURLEntries.removeAll()
return
}

self.suppressWelcomeWindow()
let entries = self.queuedURLEntries
self.queuedURLEntries.removeAll()
for entry in entries {
switch entry {
case .databaseURL(let url): self.handleDatabaseURL(url)
case .sqliteFile(let url): self.handleSQLiteFile(url)
}
}
self.scheduleWelcomeWindowSuppression()
}
}

// MARK: - SQL File Queue (drained by .databaseDidConnect)

@objc func handleDatabaseDidConnect() {
guard !queuedFileURLs.isEmpty else { return }
let urls = queuedFileURLs
queuedFileURLs.removeAll()
postSQLFilesWhenReady(urls: urls)
}

private func postSQLFilesWhenReady(urls: [URL]) {
Task { @MainActor [weak self] in
try? await Task.sleep(for: .milliseconds(100))
if !NSApp.windows.contains(where: { self?.isMainWindow($0) == true && $0.isKeyWindow }) {
connectionLogger.warning("postSQLFilesWhenReady: no key main window, posting anyway")
}
NotificationCenter.default.post(name: .openSQLFiles, object: urls)
}
}

// MARK: - Connection Window Helper

private func openNewConnectionWindow(for connection: DatabaseConnection) {
let hadExistingMain = NSApp.windows.contains { isMainWindow($0) && $0.isVisible }
if hadExistingMain {
NSWindow.allowsAutomaticWindowTabbing = false
}
let payload = EditorTabPayload(connectionId: connection.id)
WindowOpener.shared.openNativeTab(payload)
}

// MARK: - Post-Connect Actions

private func handlePostConnectionActions(_ parsed: ParsedConnectionURL, connectionId: UUID) {
Task { @MainActor in
await waitForConnection(timeout: .seconds(5))

if let schema = parsed.schema {
NotificationCenter.default.post(
name: .switchSchemaFromURL,
object: nil,
userInfo: ["connectionId": connectionId, "schema": schema]
)
try? await Task.sleep(for: .milliseconds(500))
}

if let tableName = parsed.tableName {
let payload = EditorTabPayload(
connectionId: connectionId,
tabType: .table,
tableName: tableName,
isView: parsed.isView
)
WindowOpener.shared.openNativeTab(payload)

if parsed.filterColumn != nil || parsed.filterCondition != nil {
try? await Task.sleep(for: .milliseconds(300))
NotificationCenter.default.post(
name: .applyURLFilter,
object: nil,
userInfo: [
"connectionId": connectionId,
"column": parsed.filterColumn as Any,
"operation": parsed.filterOperation as Any,
"value": parsed.filterValue as Any,
"condition": parsed.filterCondition as Any
]
)
}
}
}
}

private func waitForConnection(timeout: Duration) async {
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
var didResume = false
var observer: NSObjectProtocol?

func resumeOnce() {
guard !didResume else { return }
didResume = true
if let obs = observer {
NotificationCenter.default.removeObserver(obs)
}
continuation.resume()
}

let timeoutTask = Task { @MainActor in
try? await Task.sleep(for: timeout)
resumeOnce()
}
observer = NotificationCenter.default.addObserver(
forName: .databaseDidConnect,
object: nil,
queue: .main
) { _ in
timeoutTask.cancel()
resumeOnce()
}
}
}

// MARK: - Session Lookup

private func findActiveSessionByParams(_ parsed: ParsedConnectionURL) -> UUID? {
for (id, session) in DatabaseManager.shared.activeSessions {
guard session.driver != nil else { continue }
let conn = session.connection
if conn.type == parsed.type
&& conn.host == parsed.host
&& conn.database == parsed.database
&& (parsed.port == nil || conn.port == parsed.port || conn.port == parsed.type.defaultPort)
&& (parsed.username.isEmpty || conn.username == parsed.username)
&& (parsed.redisDatabase == nil || conn.redisDatabase == parsed.redisDatabase) {
return id
}
}
return nil
}

func bringConnectionWindowToFront(_ connectionId: UUID) {
let windows = WindowLifecycleMonitor.shared.windows(for: connectionId)
if let window = windows.first {
window.makeKeyAndOrderFront(nil)
} else {
NSApp.windows.first { isMainWindow($0) && $0.isVisible }?.makeKeyAndOrderFront(nil)
}
}

// MARK: - Connection Failure

func handleConnectionFailure(_ error: Error) async {
for window in NSApp.windows where isMainWindow(window) {
let hasActiveSession = DatabaseManager.shared.activeSessions.values.contains {
window.subtitle == $0.connection.name
|| window.subtitle == "\($0.connection.name) — Preview"
}
if !hasActiveSession {
window.close()
}
}
if !NSApp.windows.contains(where: { isMainWindow($0) && $0.isVisible }) {
openWelcomeWindow()
}
try? await Task.sleep(for: .milliseconds(200))
AlertHelper.showErrorSheet(
title: String(localized: "Connection Failed"),
message: error.localizedDescription,
window: NSApp.keyWindow
)
}

// MARK: - Transient Connection Builder

private func buildTransientConnection(from parsed: ParsedConnectionURL) -> DatabaseConnection {
var sshConfig = SSHConfiguration()
if let sshHost = parsed.sshHost {
sshConfig.enabled = true
sshConfig.host = sshHost
sshConfig.port = parsed.sshPort ?? 22
sshConfig.username = parsed.sshUsername ?? ""
if parsed.usePrivateKey == true {
sshConfig.authMethod = .privateKey
}
if parsed.useSSHAgent == true {
sshConfig.authMethod = .sshAgent
sshConfig.agentSocketPath = parsed.agentSocket ?? ""
}
}

var sslConfig = SSLConfiguration()
if let sslMode = parsed.sslMode {
sslConfig.mode = sslMode
}

var color: ConnectionColor = .none
if let hex = parsed.statusColor {
color = ConnectionURLParser.connectionColor(fromHex: hex)
}

var tagId: UUID?
if let envName = parsed.envTag {
tagId = ConnectionURLParser.tagId(fromEnvName: envName)
}

return DatabaseConnection(
name: parsed.connectionName ?? parsed.suggestedName,
host: parsed.host,
port: parsed.port ?? parsed.type.defaultPort,
database: parsed.database,
username: parsed.username,
type: parsed.type,
sshConfig: sshConfig,
sslConfig: sslConfig,
color: color,
tagId: tagId,
redisDatabase: parsed.redisDatabase,
oracleServiceName: parsed.oracleServiceName
)
}
}
Loading