From 4b75de412c8b5500004c6df0ee502a57f8da62a0 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 11 Mar 2026 08:25:01 +0700 Subject: [PATCH 1/4] feat: open SQLite files from Finder by double-clicking --- CHANGELOG.md | 1 + TablePro/AppDelegate.swift | 92 ++++++++++++++++++++++++++++++++++++++ TablePro/Info.plist | 43 ++++++++++++++++++ 3 files changed, 136 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 50f146750..45249277c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/TablePro/AppDelegate.swift b/TablePro/AppDelegate.swift index 61259999f..e78b6b27d 100644 --- a/TablePro/AppDelegate.swift +++ b/TablePro/AppDelegate.swift @@ -42,6 +42,9 @@ class AppDelegate: NSObject, NSApplicationDelegate { /// Database URLs queued until the SwiftUI window system is ready private var queuedDatabaseURLs: [URL] = [] + /// SQLite file URLs queued until the SwiftUI window system is ready + private var queuedSQLiteFileURLs: [URL] = [] + /// True while handling a file-open event with an active connection. /// Prevents SwiftUI from showing the welcome window as a side-effect. private var isHandlingFileOpen = false @@ -196,6 +199,24 @@ class AppDelegate: NSObject, NSApplicationDelegate { } } + // Handle SQLite database files (double-click from Finder) + let sqliteExtensions: Set = ["sqlite", "sqlite3", "db3", "s3db", "sl3", "sqlitedb"] + let sqliteFileURLs = urls.filter { sqliteExtensions.contains($0.pathExtension.lowercased()) } + if !sqliteFileURLs.isEmpty { + isHandlingFileOpen = true + fileOpenSuppressionCount += 1 + for window in NSApp.windows where isWelcomeWindow(window) { + window.orderOut(nil) + } + + Task { @MainActor in + for url in sqliteFileURLs { + self.handleSQLiteFile(url) + } + self.scheduleWelcomeWindowSuppression() + } + } + // Handle SQL files (existing logic unchanged) let sqlURLs = urls.filter { $0.pathExtension.lowercased() == "sql" } guard !sqlURLs.isEmpty else { return } @@ -467,6 +488,77 @@ class AppDelegate: NSObject, NSApplicationDelegate { } } + @MainActor + private func handleSQLiteFile(_ url: URL) { + guard WindowOpener.shared.openWindow != nil else { + queuedSQLiteFileURLs.append(url) + scheduleQueuedSQLiteFileProcessing() + return + } + + let filePath = url.path + let connectionName = url.deletingPathExtension().lastPathComponent + + // Deduplicate: if this file is already open in an active session, bring it to front + 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 + ) + + let hadExistingMain = NSApp.windows.contains { isMainWindow($0) && $0.isVisible } + if hadExistingMain { + NSWindow.allowsAutomaticWindowTabbing = false + } + + let payload = EditorTabPayload(connectionId: connection.id) + WindowOpener.shared.openNativeTab(payload) + + Task { @MainActor in + do { + try await DatabaseManager.shared.connectToSession(connection) + for window in NSApp.windows where self.isWelcomeWindow(window) { + window.close() + } + } catch { + Self.logger.error("SQLite file open failed for '\(filePath, privacy: .public)': \(error.localizedDescription)") + await self.handleConnectionFailure(error) + } + } + } + + private func scheduleQueuedSQLiteFileProcessing() { + Task { @MainActor [weak self] in + 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 { + Self.logger.warning("SwiftUI window system not ready after 5s, dropping \(self.queuedSQLiteFileURLs.count) queued SQLite file(s)") + self.queuedSQLiteFileURLs.removeAll() + return + } + let urls = self.queuedSQLiteFileURLs + self.queuedSQLiteFileURLs.removeAll() + for url in urls { + self.handleSQLiteFile(url) + } + } + } + private func scheduleQueuedDatabaseURLProcessing() { Task { @MainActor [weak self] in var ready = false diff --git a/TablePro/Info.plist b/TablePro/Info.plist index cc707540b..a8b693dad 100644 --- a/TablePro/Info.plist +++ b/TablePro/Info.plist @@ -46,6 +46,28 @@ LSTypeIsPackage + + CFBundleTypeExtensions + + sqlite + sqlite3 + db3 + s3db + sl3 + sqlitedb + + CFBundleTypeName + SQLite Database + CFBundleTypeRole + Editor + LSHandlerRank + Alternate + LSItemContentTypes + + com.apple.sqlite3 + com.tablepro.sqlite-db + + UTImportedTypeDeclarations @@ -68,6 +90,27 @@ + + UTTypeIdentifier + com.tablepro.sqlite-db + UTTypeDescription + SQLite Database + UTTypeConformsTo + + public.database + public.data + + UTTypeTagSpecification + + public.filename-extension + + db3 + s3db + sl3 + sqlitedb + + + UTExportedTypeDeclarations From 789b292dbe37b1eaddb87b41ff22d76d97b5006e Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 11 Mar 2026 08:31:45 +0700 Subject: [PATCH 2/4] fix: replace guard-return with if-block so SQL handler doesn't block SQLite opens --- TablePro/AppDelegate.swift | 57 ++++++++++++++++++++------------------ 1 file changed, 30 insertions(+), 27 deletions(-) diff --git a/TablePro/AppDelegate.swift b/TablePro/AppDelegate.swift index e78b6b27d..7707d6146 100644 --- a/TablePro/AppDelegate.swift +++ b/TablePro/AppDelegate.swift @@ -60,6 +60,10 @@ class AppDelegate: NSObject, NSApplicationDelegate { "mssql", "sqlserver", "oracle" ] + private static let sqliteFileExtensions: Set = [ + "sqlite", "sqlite3", "db3", "s3db", "sl3", "sqlitedb" + ] + func applicationDockMenu(_ sender: NSApplication) -> NSMenu? { let menu = NSMenu() @@ -200,8 +204,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { } // Handle SQLite database files (double-click from Finder) - let sqliteExtensions: Set = ["sqlite", "sqlite3", "db3", "s3db", "sl3", "sqlitedb"] - let sqliteFileURLs = urls.filter { sqliteExtensions.contains($0.pathExtension.lowercased()) } + let sqliteFileURLs = urls.filter { Self.sqliteFileExtensions.contains($0.pathExtension.lowercased()) } if !sqliteFileURLs.isEmpty { isHandlingFileOpen = true fileOpenSuppressionCount += 1 @@ -217,34 +220,34 @@ class AppDelegate: NSObject, NSApplicationDelegate { } } - // Handle SQL files (existing logic unchanged) + // Handle SQL files let sqlURLs = urls.filter { $0.pathExtension.lowercased() == "sql" } - guard !sqlURLs.isEmpty else { return } - - if DatabaseManager.shared.currentSession != nil { - // Suppress any welcome window that SwiftUI may create as a - // side-effect of the app being activated by the file-open event. - isHandlingFileOpen = true - fileOpenSuppressionCount += 1 + if !sqlURLs.isEmpty { + if DatabaseManager.shared.currentSession != nil { + // Suppress any welcome window that SwiftUI may create as a + // side-effect of the app being activated by the file-open event. + isHandlingFileOpen = true + fileOpenSuppressionCount += 1 + + // Already connected — bring main window to front and open files + for window in NSApp.windows where isMainWindow(window) { + window.makeKeyAndOrderFront(nil) + } + // Close welcome window if it's already open + for window in NSApp.windows where isWelcomeWindow(window) { + window.close() + } + NotificationCenter.default.post(name: .openSQLFiles, object: sqlURLs) - // Already connected — bring main window to front and open files - for window in NSApp.windows where isMainWindow(window) { - window.makeKeyAndOrderFront(nil) - } - // Close welcome window if it's already open - for window in NSApp.windows where isWelcomeWindow(window) { - window.close() + // SwiftUI may asynchronously create a welcome window after this + // method returns (scene restoration on activation). Schedule + // multiple cleanup passes so we catch windows that appear late. + scheduleWelcomeWindowSuppression() + } else { + // Not connected — queue and show welcome window + queuedFileURLs.append(contentsOf: sqlURLs) + openWelcomeWindow() } - NotificationCenter.default.post(name: .openSQLFiles, object: sqlURLs) - - // SwiftUI may asynchronously create a welcome window after this - // method returns (scene restoration on activation). Schedule - // multiple cleanup passes so we catch windows that appear late. - scheduleWelcomeWindowSuppression() - } else { - // Not connected — queue and show welcome window - queuedFileURLs.append(contentsOf: sqlURLs) - openWelcomeWindow() } } From 5b30cc6385a84bd2fe14d2446379e34995d16587 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 11 Mar 2026 08:41:25 +0700 Subject: [PATCH 3/4] refactor: split AppDelegate into focused extension files - AppDelegate.swift: class body, stored properties, lifecycle (103 lines) - AppDelegate+FileOpen.swift: URL dispatch, deeplinks, plugins (214 lines) - AppDelegate+ConnectionHandler.swift: DB URL, SQLite, unified queue (356 lines) - AppDelegate+WindowConfig.swift: window lifecycle, dock, styling (320 lines) Unified queuedDatabaseURLs + queuedSQLiteFileURLs into single QueuedURLEntry enum with one polling loop. Extracted shared suppressWelcomeWindow() and openNewConnectionWindow() helpers. Replaced guard-return with if-block in SQL file handler. --- TablePro/AppDelegate+ConnectionHandler.swift | 356 ++++++ TablePro/AppDelegate+FileOpen.swift | 214 ++++ TablePro/AppDelegate+WindowConfig.swift | 320 ++++++ TablePro/AppDelegate.swift | 1033 +----------------- 4 files changed, 912 insertions(+), 1011 deletions(-) create mode 100644 TablePro/AppDelegate+ConnectionHandler.swift create mode 100644 TablePro/AppDelegate+FileOpen.swift create mode 100644 TablePro/AppDelegate+WindowConfig.swift diff --git a/TablePro/AppDelegate+ConnectionHandler.swift b/TablePro/AppDelegate+ConnectionHandler.swift new file mode 100644 index 000000000..5fe012343 --- /dev/null +++ b/TablePro/AppDelegate+ConnectionHandler.swift @@ -0,0 +1,356 @@ +// +// 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 + 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() { + Task { @MainActor [weak self] in + 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 + } + 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) + } + } + } + } + + // 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) 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 + ) + } +} diff --git a/TablePro/AppDelegate+FileOpen.swift b/TablePro/AppDelegate+FileOpen.swift new file mode 100644 index 000000000..c89a77574 --- /dev/null +++ b/TablePro/AppDelegate+FileOpen.swift @@ -0,0 +1,214 @@ +// +// AppDelegate+FileOpen.swift +// TablePro +// +// URL and file open handling dispatched from application(_:open:) +// + +import AppKit +import os +import SwiftUI + +private let fileOpenLogger = Logger(subsystem: "com.TablePro", category: "FileOpen") + +extension AppDelegate { + // MARK: - URL Classification + + private static let databaseURLSchemes: Set = [ + "postgresql", "postgres", "mysql", "mariadb", "sqlite", + "mongodb", "mongodb+srv", "redis", "rediss", "redshift", + "mssql", "sqlserver", "oracle" + ] + + static let sqliteFileExtensions: Set = [ + "sqlite", "sqlite3", "db3", "s3db", "sl3", "sqlitedb" + ] + + private func isDatabaseURL(_ url: URL) -> Bool { + guard let scheme = url.scheme?.lowercased() else { return false } + let base = scheme + .replacingOccurrences(of: "+ssh", with: "") + .replacingOccurrences(of: "+srv", with: "") + return Self.databaseURLSchemes.contains(base) || Self.databaseURLSchemes.contains(scheme) + } + + private func isSQLiteFile(_ url: URL) -> Bool { + Self.sqliteFileExtensions.contains(url.pathExtension.lowercased()) + } + + // MARK: - Main Dispatch + + func handleOpenURLs(_ urls: [URL]) { + let deeplinks = urls.filter { $0.scheme == "tablepro" } + if !deeplinks.isEmpty { + Task { @MainActor in + for url in deeplinks { self.handleDeeplink(url) } + } + } + + let plugins = urls.filter { $0.pathExtension == "tableplugin" } + if !plugins.isEmpty { + Task { @MainActor in + for url in plugins { await self.handlePluginInstall(url) } + } + } + + let databaseURLs = urls.filter { isDatabaseURL($0) } + if !databaseURLs.isEmpty { + suppressWelcomeWindow() + Task { @MainActor in + for url in databaseURLs { self.handleDatabaseURL(url) } + self.scheduleWelcomeWindowSuppression() + } + } + + let sqliteFiles = urls.filter { isSQLiteFile($0) } + if !sqliteFiles.isEmpty { + suppressWelcomeWindow() + Task { @MainActor in + for url in sqliteFiles { self.handleSQLiteFile(url) } + self.scheduleWelcomeWindowSuppression() + } + } + + let sqlFiles = urls.filter { $0.pathExtension.lowercased() == "sql" } + if !sqlFiles.isEmpty { + if DatabaseManager.shared.currentSession != nil { + suppressWelcomeWindow() + for window in NSApp.windows where isMainWindow(window) { + window.makeKeyAndOrderFront(nil) + } + for window in NSApp.windows where isWelcomeWindow(window) { + window.close() + } + NotificationCenter.default.post(name: .openSQLFiles, object: sqlFiles) + scheduleWelcomeWindowSuppression() + } else { + queuedFileURLs.append(contentsOf: sqlFiles) + openWelcomeWindow() + } + } + } + + // MARK: - Welcome Window Suppression + + func suppressWelcomeWindow() { + isHandlingFileOpen = true + fileOpenSuppressionCount += 1 + for window in NSApp.windows where isWelcomeWindow(window) { + window.orderOut(nil) + } + } + + // MARK: - Deeplink Handling + + private func handleDeeplink(_ url: URL) { + guard let action = DeeplinkHandler.parse(url) else { return } + + switch action { + case .connect(let name): + connectViaDeeplink(connectionName: name) + + case .openTable(let name, let table, let database): + connectViaDeeplink(connectionName: name) { connectionId in + EditorTabPayload(connectionId: connectionId, tabType: .table, + tableName: table, databaseName: database) + } + + case .openQuery(let name, let sql): + connectViaDeeplink(connectionName: name) { connectionId in + EditorTabPayload(connectionId: connectionId, tabType: .query, + initialQuery: sql) + } + + case .importConnection(let name, let host, let port, let type, let username, let database): + handleImportDeeplink(name: name, host: host, port: port, type: type, + username: username, database: database) + } + } + + private func connectViaDeeplink( + connectionName: String, + makePayload: (@Sendable (UUID) -> EditorTabPayload)? = nil + ) { + guard let connection = DeeplinkHandler.resolveConnection(named: connectionName) else { + fileOpenLogger.error("Deep link: no connection named '\(connectionName, privacy: .public)'") + AlertHelper.showErrorSheet( + title: String(localized: "Connection Not Found"), + message: String(localized: "No saved connection named \"\(connectionName)\"."), + window: NSApp.keyWindow + ) + return + } + + if DatabaseManager.shared.activeSessions[connection.id]?.driver != nil { + if let payload = makePayload?(connection.id) { + WindowOpener.shared.openNativeTab(payload) + } else { + for window in NSApp.windows where isMainWindow(window) { + window.makeKeyAndOrderFront(nil) + return + } + } + return + } + + let hadExistingMain = NSApp.windows.contains { isMainWindow($0) && $0.isVisible } + if hadExistingMain { + NSWindow.allowsAutomaticWindowTabbing = false + } + + let deeplinkPayload = EditorTabPayload(connectionId: connection.id) + WindowOpener.shared.openNativeTab(deeplinkPayload) + + Task { @MainActor in + do { + try await DatabaseManager.shared.connectToSession(connection) + for window in NSApp.windows where self.isWelcomeWindow(window) { + window.close() + } + if let payload = makePayload?(connection.id) { + WindowOpener.shared.openNativeTab(payload) + } + } catch { + fileOpenLogger.error("Deep link connect failed: \(error.localizedDescription)") + await self.handleConnectionFailure(error) + } + } + } + + private func handleImportDeeplink( + name: String, host: String, port: Int, + type: DatabaseType, username: String, database: String + ) { + let connection = DatabaseConnection( + name: name, host: host, port: port, + database: database, username: username, type: type + ) + ConnectionStorage.shared.addConnection(connection) + NotificationCenter.default.post(name: .connectionUpdated, object: nil) + + if let openWindow = WindowOpener.shared.openWindow { + openWindow(id: "connection-form", value: connection.id) + } + } + + // MARK: - Plugin Install + + private func handlePluginInstall(_ url: URL) async { + do { + let entry = try await PluginManager.shared.installPlugin(from: url) + fileOpenLogger.info("Installed plugin '\(entry.name)' from Finder") + + UserDefaults.standard.set(SettingsTab.plugins.rawValue, forKey: "selectedSettingsTab") + NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil) + } catch { + fileOpenLogger.error("Plugin install failed: \(error.localizedDescription)") + AlertHelper.showErrorSheet( + title: String(localized: "Plugin Installation Failed"), + message: error.localizedDescription, + window: NSApp.keyWindow + ) + } + } +} diff --git a/TablePro/AppDelegate+WindowConfig.swift b/TablePro/AppDelegate+WindowConfig.swift new file mode 100644 index 000000000..c23209836 --- /dev/null +++ b/TablePro/AppDelegate+WindowConfig.swift @@ -0,0 +1,320 @@ +// +// AppDelegate+WindowConfig.swift +// TablePro +// +// Window lifecycle, styling, dock menu, and auto-reconnect +// + +import AppKit +import os +import SwiftUI + +private let windowLogger = Logger(subsystem: "com.TablePro", category: "WindowConfig") + +extension AppDelegate { + // MARK: - Dock Menu + + func applicationDockMenu(_ sender: NSApplication) -> NSMenu? { + let menu = NSMenu() + + let welcomeItem = NSMenuItem( + title: String(localized: "Show Welcome Window"), + action: #selector(showWelcomeFromDock), + keyEquivalent: "" + ) + welcomeItem.target = self + menu.addItem(welcomeItem) + + let connections = ConnectionStorage.shared.loadConnections() + if !connections.isEmpty { + let connectionsItem = NSMenuItem(title: String(localized: "Open Connection"), action: nil, keyEquivalent: "") + let submenu = NSMenu() + + for connection in connections { + let item = NSMenuItem( + title: connection.name, + action: #selector(connectFromDock(_:)), + keyEquivalent: "" + ) + item.target = self + item.representedObject = connection.id + if let original = NSImage(named: connection.type.iconName) { + let resized = NSImage(size: NSSize(width: 16, height: 16), flipped: false) { rect in + original.draw(in: rect) + return true + } + item.image = resized + } + submenu.addItem(item) + } + + connectionsItem.submenu = submenu + menu.addItem(connectionsItem) + } + + return menu + } + + @objc func showWelcomeFromDock() { + openWelcomeWindow() + } + + @objc func connectFromDock(_ sender: NSMenuItem) { + guard let connectionId = sender.representedObject as? UUID else { return } + let connections = ConnectionStorage.shared.loadConnections() + guard let connection = connections.first(where: { $0.id == connectionId }) else { return } + + NotificationCenter.default.post(name: .openMainWindow, object: connection.id) + + Task { @MainActor in + do { + try await DatabaseManager.shared.connectToSession(connection) + + for window in NSApp.windows where self.isWelcomeWindow(window) { + window.close() + } + } catch { + windowLogger.error("Dock connection failed for '\(connection.name)': \(error.localizedDescription)") + + for window in NSApp.windows where self.isMainWindow(window) { + window.close() + } + self.openWelcomeWindow() + } + } + } + + // MARK: - Reopen Handling + + func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { + if flag { + return true + } + + openWelcomeWindow() + return false + } + + // MARK: - Window Identification + + func isMainWindow(_ window: NSWindow) -> Bool { + guard let identifier = window.identifier?.rawValue else { return false } + return identifier.contains("main") + } + + func isWelcomeWindow(_ window: NSWindow) -> Bool { + window.identifier?.rawValue == "welcome" || + window.title.lowercased().contains("welcome") + } + + private func isConnectionFormWindow(_ window: NSWindow) -> Bool { + window.identifier?.rawValue.contains("connection-form") == true + } + + // MARK: - Welcome Window + + func openWelcomeWindow() { + for window in NSApp.windows where isWelcomeWindow(window) { + window.makeKeyAndOrderFront(nil) + return + } + + NotificationCenter.default.post(name: .openWelcomeWindow, object: nil) + } + + func configureWelcomeWindow() { + Task { @MainActor [weak self] in + for _ in 0 ..< 5 { + guard let self else { return } + let found = NSApp.windows.contains(where: { self.isWelcomeWindow($0) }) + if found { + for window in NSApp.windows where self.isWelcomeWindow(window) { + self.configureWelcomeWindowStyle(window) + } + return + } + try? await Task.sleep(for: .milliseconds(50)) + } + } + } + + private func configureWelcomeWindowStyle(_ window: NSWindow) { + window.standardWindowButton(.miniaturizeButton)?.isHidden = true + window.standardWindowButton(.zoomButton)?.isHidden = true + window.styleMask.remove(.miniaturizable) + + window.collectionBehavior.remove(.fullScreenPrimary) + window.collectionBehavior.insert(.fullScreenNone) + + if window.styleMask.contains(.resizable) { + window.styleMask.remove(.resizable) + } + + let welcomeSize = NSSize(width: 700, height: 450) + if window.frame.size != welcomeSize { + window.setContentSize(welcomeSize) + window.center() + } + + window.isOpaque = false + window.backgroundColor = .clear + window.titlebarAppearsTransparent = true + } + + private func configureConnectionFormWindowStyle(_ window: NSWindow) { + window.standardWindowButton(.miniaturizeButton)?.isEnabled = false + window.standardWindowButton(.zoomButton)?.isEnabled = false + window.styleMask.remove(.miniaturizable) + + window.collectionBehavior.remove(.fullScreenPrimary) + window.collectionBehavior.insert(.fullScreenNone) + + window.level = .floating + } + + // MARK: - Welcome Window Suppression + + func scheduleWelcomeWindowSuppression() { + Task { @MainActor [weak self] in + try? await Task.sleep(for: .milliseconds(200)) + self?.closeWelcomeWindowIfMainExists() + try? await Task.sleep(for: .milliseconds(500)) + guard let self else { return } + self.closeWelcomeWindowIfMainExists() + self.fileOpenSuppressionCount = max(0, self.fileOpenSuppressionCount - 1) + if self.fileOpenSuppressionCount == 0 { + self.isHandlingFileOpen = false + } + } + } + + private func closeWelcomeWindowIfMainExists() { + let hasMainWindow = NSApp.windows.contains { isMainWindow($0) && $0.isVisible } + guard hasMainWindow else { return } + for window in NSApp.windows where isWelcomeWindow(window) { + window.close() + } + } + + // MARK: - Window Notifications + + @objc func windowDidBecomeKey(_ notification: Notification) { + guard let window = notification.object as? NSWindow else { return } + let windowId = ObjectIdentifier(window) + + if isWelcomeWindow(window) && isHandlingFileOpen { + window.close() + for mainWin in NSApp.windows where isMainWindow(mainWin) { + mainWin.makeKeyAndOrderFront(nil) + } + return + } + + if isWelcomeWindow(window) && !configuredWindows.contains(windowId) { + configureWelcomeWindowStyle(window) + configuredWindows.insert(windowId) + } + + if isConnectionFormWindow(window) && !configuredWindows.contains(windowId) { + configureConnectionFormWindowStyle(window) + configuredWindows.insert(windowId) + } + + if isMainWindow(window) && !configuredWindows.contains(windowId) { + window.tabbingMode = .preferred + let pendingId = MainActor.assumeIsolated { WindowOpener.shared.consumePendingConnectionId() } + let existingIdentifier = NSApp.windows + .first { $0 !== window && isMainWindow($0) && $0.isVisible }? + .tabbingIdentifier + window.tabbingIdentifier = TabbingIdentifierResolver.resolve( + pendingConnectionId: pendingId, + existingIdentifier: existingIdentifier + ) + configuredWindows.insert(windowId) + + if !NSWindow.allowsAutomaticWindowTabbing { + NSWindow.allowsAutomaticWindowTabbing = true + } + } + } + + @objc func windowWillClose(_ notification: Notification) { + guard let window = notification.object as? NSWindow else { return } + + configuredWindows.remove(ObjectIdentifier(window)) + + if isMainWindow(window) { + let remainingMainWindows = NSApp.windows.filter { + $0 !== window && isMainWindow($0) && $0.isVisible + }.count + + if remainingMainWindows == 0 { + NotificationCenter.default.post(name: .mainWindowWillClose, object: nil) + + DispatchQueue.main.async { + self.openWelcomeWindow() + } + } + } + } + + @objc func windowDidChangeOcclusionState(_ notification: Notification) { + guard let window = notification.object as? NSWindow, + isHandlingFileOpen else { return } + + if isWelcomeWindow(window), + window.occlusionState.contains(.visible), + NSApp.windows.contains(where: { isMainWindow($0) && $0.isVisible }) { + DispatchQueue.main.async { [weak self] in + guard let self else { return } + if self.isWelcomeWindow(window), window.isVisible { + window.close() + } + } + } + } + + // MARK: - Auto-Reconnect + + func attemptAutoReconnect(connectionId: UUID) { + let connections = ConnectionStorage.shared.loadConnections() + guard let connection = connections.first(where: { $0.id == connectionId }) else { + AppSettingsStorage.shared.saveLastConnectionId(nil) + closeRestoredMainWindows() + openWelcomeWindow() + return + } + + DispatchQueue.main.async { [weak self] in + guard let self else { return } + + NotificationCenter.default.post(name: .openMainWindow, object: connection.id) + + Task { @MainActor in + do { + try await DatabaseManager.shared.connectToSession(connection) + + for window in NSApp.windows where self.isWelcomeWindow(window) { + window.close() + } + } catch { + windowLogger.error("Auto-reconnect failed for '\(connection.name)': \(error.localizedDescription)") + + for window in NSApp.windows where self.isMainWindow(window) { + window.close() + } + + self.openWelcomeWindow() + } + } + } + } + + func closeRestoredMainWindows() { + DispatchQueue.main.async { + for window in NSApp.windows where window.identifier?.rawValue.contains("main") == true { + window.close() + } + } + } +} diff --git a/TablePro/AppDelegate.swift b/TablePro/AppDelegate.swift index 7707d6146..4bad0b193 100644 --- a/TablePro/AppDelegate.swift +++ b/TablePro/AppDelegate.swift @@ -22,721 +22,52 @@ internal extension URL { } /// AppDelegate handles window lifecycle events using proper AppKit patterns. -/// This is the correct way to configure window appearance on macOS, rather than -/// using SwiftUI view hacks which can be unreliable. -/// -/// **Why this approach is better:** -/// 1. **Proper lifecycle management**: NSApplicationDelegate receives window events at the right time -/// 2. **Stable and reliable**: AppKit APIs are mature and well-documented -/// 3. **Separation of concerns**: Window configuration is separate from SwiftUI views -/// 4. **Future-proof**: Works reliably across macOS Ventura/Sonoma and future versions @MainActor class AppDelegate: NSObject, NSApplicationDelegate { private static let logger = Logger(subsystem: "com.TablePro", category: "AppDelegate") - /// Track windows that have been configured to avoid re-applying styles (which causes flicker) - private var configuredWindows = Set() - /// URLs queued for opening when no database connection is active yet - private var queuedFileURLs: [URL] = [] + /// Track windows that have been configured to avoid re-applying styles + var configuredWindows = Set() - /// Database URLs queued until the SwiftUI window system is ready - private var queuedDatabaseURLs: [URL] = [] + /// SQL files queued until a database connection is active (drained on .databaseDidConnect) + var queuedFileURLs: [URL] = [] - /// SQLite file URLs queued until the SwiftUI window system is ready - private var queuedSQLiteFileURLs: [URL] = [] + /// Database URL and SQLite file entries queued until the SwiftUI window system is ready + var queuedURLEntries: [QueuedURLEntry] = [] - /// True while handling a file-open event with an active connection. - /// Prevents SwiftUI from showing the welcome window as a side-effect. - private var isHandlingFileOpen = false + /// True while handling a file-open event — suppresses welcome window + var isHandlingFileOpen = false - /// Counter tracking outstanding file-open suppressions. - /// Incremented when a file-open starts, decremented by each delayed - /// cleanup pass. While > 0 the welcome window is suppressed. - private var fileOpenSuppressionCount = 0 + /// Counter for outstanding suppressions; welcome window is suppressed while > 0 + var fileOpenSuppressionCount = 0 - private static let databaseURLSchemes: Set = [ - "postgresql", "postgres", "mysql", "mariadb", "sqlite", - "mongodb", "mongodb+srv", "redis", "rediss", "redshift", - "mssql", "sqlserver", "oracle" - ] - - private static let sqliteFileExtensions: Set = [ - "sqlite", "sqlite3", "db3", "s3db", "sl3", "sqlitedb" - ] - - func applicationDockMenu(_ sender: NSApplication) -> NSMenu? { - let menu = NSMenu() - - let welcomeItem = NSMenuItem( - title: String(localized: "Show Welcome Window"), - action: #selector(showWelcomeFromDock), - keyEquivalent: "" - ) - welcomeItem.target = self - menu.addItem(welcomeItem) - - // Add connections submenu - let connections = ConnectionStorage.shared.loadConnections() - if !connections.isEmpty { - let connectionsItem = NSMenuItem(title: String(localized: "Open Connection"), action: nil, keyEquivalent: "") - let submenu = NSMenu() - - for connection in connections { - let item = NSMenuItem( - title: connection.name, - action: #selector(connectFromDock(_:)), - keyEquivalent: "" - ) - item.target = self - item.representedObject = connection.id - if let original = NSImage(named: connection.type.iconName) { - let resized = NSImage(size: NSSize(width: 16, height: 16), flipped: false) { rect in - original.draw(in: rect) - return true - } - item.image = resized - } - submenu.addItem(item) - } - - connectionsItem.submenu = submenu - menu.addItem(connectionsItem) - } - - return menu - } - - @objc - private func showWelcomeFromDock() { - openWelcomeWindow() - } - - @objc - private func connectFromDock(_ sender: NSMenuItem) { - guard let connectionId = sender.representedObject as? UUID else { return } - let connections = ConnectionStorage.shared.loadConnections() - guard let connection = connections.first(where: { $0.id == connectionId }) else { return } - - // Open main window and connect (same flow as auto-reconnect) - NotificationCenter.default.post(name: .openMainWindow, object: connection.id) - - Task { @MainActor in - do { - try await DatabaseManager.shared.connectToSession(connection) - - // Close welcome window on successful connection - for window in NSApp.windows where self.isWelcomeWindow(window) { - window.close() - } - } catch { - Self.logger.error("Dock connection failed for '\(connection.name)': \(error.localizedDescription)") - - // Connection failed - close main window, reopen welcome - for window in NSApp.windows where self.isMainWindow(window) { - window.close() - } - self.openWelcomeWindow() - } - } - } - - func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { - if flag { - // macOS already activated the app and brought windows to the foreground. - // Return true to let it perform default behavior (no-op for visible windows). - // Manually calling makeKeyAndOrderFront here conflicts with the native - // activation animation and causes a visible stutter/delay. - return true - } - - // No visible windows — show welcome window explicitly. - // Never return true here: SwiftUI would create a new WindowGroup("main") - // instance instead of the welcome Window. - openWelcomeWindow() - return false - } + // MARK: - NSApplicationDelegate func application(_ application: NSApplication, open urls: [URL]) { - // Handle deep links - let deeplinkURLs = urls.filter { $0.scheme == "tablepro" } - if !deeplinkURLs.isEmpty { - Task { @MainActor in - for url in deeplinkURLs { - self.handleDeeplink(url) - } - } - } - - // Handle .tableplugin files (double-click from Finder) - let pluginURLs = urls.filter { $0.pathExtension == "tableplugin" } - if !pluginURLs.isEmpty { - Task { @MainActor in - for url in pluginURLs { - await self.handlePluginInstall(url) - } - } - } - - // Handle database connection URLs (e.g. postgresql://user@host/db) - let databaseURLs = urls.filter { url in - guard let scheme = url.scheme?.lowercased() else { return false } - let baseScheme = scheme - .replacingOccurrences(of: "+ssh", with: "") - .replacingOccurrences(of: "+srv", with: "") - return Self.databaseURLSchemes.contains(baseScheme) || - Self.databaseURLSchemes.contains(scheme) - } - if !databaseURLs.isEmpty { - // Suppress welcome window immediately (before async task runs) - // to prevent the flash on cold start - isHandlingFileOpen = true - fileOpenSuppressionCount += 1 - for window in NSApp.windows where isWelcomeWindow(window) { - window.orderOut(nil) - } - - Task { @MainActor in - for url in databaseURLs { - self.handleDatabaseURL(url) - } - self.scheduleWelcomeWindowSuppression() - } - } - - // Handle SQLite database files (double-click from Finder) - let sqliteFileURLs = urls.filter { Self.sqliteFileExtensions.contains($0.pathExtension.lowercased()) } - if !sqliteFileURLs.isEmpty { - isHandlingFileOpen = true - fileOpenSuppressionCount += 1 - for window in NSApp.windows where isWelcomeWindow(window) { - window.orderOut(nil) - } - - Task { @MainActor in - for url in sqliteFileURLs { - self.handleSQLiteFile(url) - } - self.scheduleWelcomeWindowSuppression() - } - } - - // Handle SQL files - let sqlURLs = urls.filter { $0.pathExtension.lowercased() == "sql" } - if !sqlURLs.isEmpty { - if DatabaseManager.shared.currentSession != nil { - // Suppress any welcome window that SwiftUI may create as a - // side-effect of the app being activated by the file-open event. - isHandlingFileOpen = true - fileOpenSuppressionCount += 1 - - // Already connected — bring main window to front and open files - for window in NSApp.windows where isMainWindow(window) { - window.makeKeyAndOrderFront(nil) - } - // Close welcome window if it's already open - for window in NSApp.windows where isWelcomeWindow(window) { - window.close() - } - NotificationCenter.default.post(name: .openSQLFiles, object: sqlURLs) - - // SwiftUI may asynchronously create a welcome window after this - // method returns (scene restoration on activation). Schedule - // multiple cleanup passes so we catch windows that appear late. - scheduleWelcomeWindowSuppression() - } else { - // Not connected — queue and show welcome window - queuedFileURLs.append(contentsOf: sqlURLs) - openWelcomeWindow() - } - } - } - - @MainActor - private func handleDeeplink(_ url: URL) { - guard let action = DeeplinkHandler.parse(url) else { return } - - switch action { - case .connect(let name): - connectViaDeeplink(connectionName: name) - - case .openTable(let name, let table, let database): - connectViaDeeplink(connectionName: name) { connectionId in - EditorTabPayload(connectionId: connectionId, tabType: .table, - tableName: table, databaseName: database) - } - - case .openQuery(let name, let sql): - connectViaDeeplink(connectionName: name) { connectionId in - EditorTabPayload(connectionId: connectionId, tabType: .query, - initialQuery: sql) - } - - case .importConnection(let name, let host, let port, let type, let username, let database): - handleImportDeeplink(name: name, host: host, port: port, type: type, - username: username, database: database) - } - } - - @MainActor - private func connectViaDeeplink( - connectionName: String, - makePayload: (@Sendable (UUID) -> EditorTabPayload)? = nil - ) { - guard let connection = DeeplinkHandler.resolveConnection(named: connectionName) else { - Self.logger.error("Deep link: no connection named '\(connectionName, privacy: .public)'") - AlertHelper.showErrorSheet( - title: String(localized: "Connection Not Found"), - message: String(localized: "No saved connection named \"\(connectionName)\"."), - window: NSApp.keyWindow - ) - return - } - - // Already connected — open tab directly - if DatabaseManager.shared.activeSessions[connection.id]?.driver != nil { - if let payload = makePayload?(connection.id) { - WindowOpener.shared.openNativeTab(payload) - } else { - for window in NSApp.windows where isMainWindow(window) { - window.makeKeyAndOrderFront(nil) - return - } - } - return - } - - // Not connected — open in a separate window if another connection is active - let hadExistingMain = NSApp.windows.contains { isMainWindow($0) && $0.isVisible } - if hadExistingMain { - NSWindow.allowsAutomaticWindowTabbing = false - } - - // Use openNativeTab directly to avoid duplicate window creation - // from multiple OpenWindowHandler instances receiving the notification - let deeplinkPayload = EditorTabPayload(connectionId: connection.id) - WindowOpener.shared.openNativeTab(deeplinkPayload) - - Task { @MainActor in - do { - try await DatabaseManager.shared.connectToSession(connection) - for window in NSApp.windows where self.isWelcomeWindow(window) { - window.close() - } - if let payload = makePayload?(connection.id) { - WindowOpener.shared.openNativeTab(payload) - } - } catch { - Self.logger.error("Deep link connect failed: \(error.localizedDescription)") - await self.handleConnectionFailure(error) - } - } - } - - @MainActor - private func handleImportDeeplink( - name: String, host: String, port: Int, - type: DatabaseType, username: String, database: String - ) { - let connection = DatabaseConnection( - name: name, host: host, port: port, - database: database, username: username, type: type - ) - ConnectionStorage.shared.addConnection(connection) - NotificationCenter.default.post(name: .connectionUpdated, object: nil) - - if let openWindow = WindowOpener.shared.openWindow { - openWindow(id: "connection-form", value: connection.id) - } - } - - @MainActor - private func handleDatabaseURL(_ url: URL) { - guard WindowOpener.shared.openWindow != nil else { - queuedDatabaseURLs.append(url) - scheduleQueuedDatabaseURLProcessing() - return - } - - let result = ConnectionURLParser.parse(url.absoluteString) - guard case .success(let parsed) = result else { - Self.logger.error("Failed to parse database URL: \(url.sanitizedForLogging, privacy: .public)") - return - } - - // Try to find a matching saved connection - 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 { - // Create a transient connection (not saved to storage) - 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) - } - - connection = 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 - ) - } - - // Store password in Keychain if provided - if !parsed.password.isEmpty { - ConnectionStorage.shared.savePassword(parsed.password, for: connection.id) - } - - // If already connected to this connection, just handle post-connect actions - if DatabaseManager.shared.activeSessions[connection.id]?.driver != nil { - handlePostConnectionActions(parsed, connectionId: connection.id) - bringConnectionWindowToFront(connection.id) - return - } - - // For transient connections, also check by parameters (host/port/db/type) - // since each URL open creates a new UUID - if let activeId = findActiveSessionByParams(parsed) { - handlePostConnectionActions(parsed, connectionId: activeId) - bringConnectionWindowToFront(activeId) - return - } - - // Temporarily disable auto-tabbing so macOS doesn't merge this window - // into an existing tab group for a different connection. - // Re-enabled in windowDidBecomeKey after the tabbingIdentifier is set. - let hadExistingMain = NSApp.windows.contains { isMainWindow($0) && $0.isVisible } - if hadExistingMain { - NSWindow.allowsAutomaticWindowTabbing = false - } - - // Use openNativeTab directly instead of posting .openMainWindow notification, - // which fans out to every OpenWindowHandler and creates duplicate windows. - let payload = EditorTabPayload(connectionId: connection.id) - WindowOpener.shared.openNativeTab(payload) - - 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 { - Self.logger.error("Database URL connect failed: \(error.localizedDescription)") - await self.handleConnectionFailure(error) - } - } - } - - @MainActor - private func handlePluginInstall(_ url: URL) async { - do { - let entry = try await PluginManager.shared.installPlugin(from: url) - Self.logger.info("Installed plugin '\(entry.name)' from Finder") - - // Navigate to Settings > Plugins tab. - // showSettingsWindow: is a private AppKit selector — no public API alternative exists. - UserDefaults.standard.set(SettingsTab.plugins.rawValue, forKey: "selectedSettingsTab") - NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil) - } catch { - Self.logger.error("Plugin install failed: \(error.localizedDescription)") - AlertHelper.showErrorSheet( - title: String(localized: "Plugin Installation Failed"), - message: error.localizedDescription, - window: NSApp.keyWindow - ) - } - } - - @MainActor - private func handleSQLiteFile(_ url: URL) { - guard WindowOpener.shared.openWindow != nil else { - queuedSQLiteFileURLs.append(url) - scheduleQueuedSQLiteFileProcessing() - return - } - - let filePath = url.path - let connectionName = url.deletingPathExtension().lastPathComponent - - // Deduplicate: if this file is already open in an active session, bring it to front - 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 - ) - - let hadExistingMain = NSApp.windows.contains { isMainWindow($0) && $0.isVisible } - if hadExistingMain { - NSWindow.allowsAutomaticWindowTabbing = false - } - - let payload = EditorTabPayload(connectionId: connection.id) - WindowOpener.shared.openNativeTab(payload) - - Task { @MainActor in - do { - try await DatabaseManager.shared.connectToSession(connection) - for window in NSApp.windows where self.isWelcomeWindow(window) { - window.close() - } - } catch { - Self.logger.error("SQLite file open failed for '\(filePath, privacy: .public)': \(error.localizedDescription)") - await self.handleConnectionFailure(error) - } - } - } - - private func scheduleQueuedSQLiteFileProcessing() { - Task { @MainActor [weak self] in - 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 { - Self.logger.warning("SwiftUI window system not ready after 5s, dropping \(self.queuedSQLiteFileURLs.count) queued SQLite file(s)") - self.queuedSQLiteFileURLs.removeAll() - return - } - let urls = self.queuedSQLiteFileURLs - self.queuedSQLiteFileURLs.removeAll() - for url in urls { - self.handleSQLiteFile(url) - } - } - } - - private func scheduleQueuedDatabaseURLProcessing() { - Task { @MainActor [weak self] in - 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 { - Self.logger.warning("SwiftUI window system not ready after 5s, dropping \(self.queuedDatabaseURLs.count) queued database URL(s)") - self.queuedDatabaseURLs.removeAll() - return - } - let urls = self.queuedDatabaseURLs - self.queuedDatabaseURLs.removeAll() - for url in urls { - self.handleDatabaseURL(url) - } - } - } - - 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 - } - - private func bringConnectionWindowToFront(_ connectionId: UUID) { - let windows = WindowLifecycleMonitor.shared.windows(for: connectionId) - if let window = windows.first { - window.makeKeyAndOrderFront(nil) - } else { - // Fallback: bring any main window to front - NSApp.windows.first { isMainWindow($0) && $0.isVisible }?.makeKeyAndOrderFront(nil) - } - } - - @MainActor - private 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 - ) - } - - @MainActor - 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] - ) - // Wait for schema switch to propagate through SwiftUI state before opening table - 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 { - // Wait for table data to load before applying filter via notification - 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 - ] - ) - } - } - } - } - - @MainActor - private func waitForConnection(timeout: Duration) async { - await withCheckedContinuation { (continuation: CheckedContinuation) 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() - } - } + handleOpenURLs(urls) } func applicationDidFinishLaunching(_ notification: Notification) { - // Enable native macOS window tabbing (Finder/Safari-style tabs) NSWindow.allowsAutomaticWindowTabbing = true - - // Discover and load plugins (discovery is synchronous, bundle loading is deferred) PluginManager.shared.loadPlugins() - // Start license periodic validation Task { @MainActor in LicenseManager.shared.startPeriodicValidation() } - // Start anonymous usage analytics heartbeat AnalyticsService.shared.startPeriodicHeartbeat() - // Pre-warm query history storage on background thread - // (avoids blocking main thread on first access due to queue.sync in init) Task.detached(priority: .background) { _ = QueryHistoryStorage.shared } - // Configure windows after app launch configureWelcomeWindow() - // Check startup behavior setting let settings = AppSettingsStorage.shared.loadGeneral() - let shouldReopenLast = settings.startupBehavior == .reopenLast - - if shouldReopenLast, let lastConnectionId = AppSettingsStorage.shared.loadLastConnectionId() { - // Try to auto-reconnect to last session + if settings.startupBehavior == .reopenLast, + let lastConnectionId = AppSettingsStorage.shared.loadLastConnectionId() { attemptAutoReconnect(connectionId: lastConnectionId) } else { - // Normal startup: close any restored main windows closeRestoredMainWindows() } @@ -744,349 +75,29 @@ class AppDelegate: NSObject, NSApplicationDelegate { // lives for the entire app lifetime. NotificationCenter uses weak // references for selector-based observers on macOS 10.11+. - // Observe for new windows being created NotificationCenter.default.addObserver( - self, - selector: #selector(windowDidBecomeKey(_:)), - name: NSWindow.didBecomeKeyNotification, - object: nil + self, selector: #selector(windowDidBecomeKey(_:)), + name: NSWindow.didBecomeKeyNotification, object: nil ) - - // Observe for main window being closed NotificationCenter.default.addObserver( - self, - selector: #selector(windowWillClose(_:)), - name: NSWindow.willCloseNotification, - object: nil + self, selector: #selector(windowWillClose(_:)), + name: NSWindow.willCloseNotification, object: nil ) - - // Observe window visibility changes to suppress the welcome - // window even when it becomes visible without becoming key - // (e.g. SwiftUI restores it in the background during file-open). NotificationCenter.default.addObserver( - self, - selector: #selector(windowDidChangeOcclusionState(_:)), - name: NSWindow.didChangeOcclusionStateNotification, - object: nil + self, selector: #selector(windowDidChangeOcclusionState(_:)), + name: NSWindow.didChangeOcclusionStateNotification, object: nil ) - - // Observe database connection to flush queued .sql files NotificationCenter.default.addObserver( - self, - selector: #selector(handleDatabaseDidConnect), - name: .databaseDidConnect, - object: nil + self, selector: #selector(handleDatabaseDidConnect), + name: .databaseDidConnect, object: nil ) } - private func scheduleWelcomeWindowSuppression() { - Task { @MainActor [weak self] in - // Wait for SwiftUI to create the main window after file-open triggers connection - try? await Task.sleep(for: .milliseconds(200)) - self?.closeWelcomeWindowIfMainExists() - // Second check after windows fully settle (animations, state restoration) - try? await Task.sleep(for: .milliseconds(500)) - guard let self else { return } - self.closeWelcomeWindowIfMainExists() - self.fileOpenSuppressionCount = max(0, self.fileOpenSuppressionCount - 1) - if self.fileOpenSuppressionCount == 0 { - self.isHandlingFileOpen = false - } - } - } - - /// Close the welcome window if a connected main window is present. - private func closeWelcomeWindowIfMainExists() { - let hasMainWindow = NSApp.windows.contains { isMainWindow($0) && $0.isVisible } - guard hasMainWindow else { return } - for window in NSApp.windows where isWelcomeWindow(window) { - window.close() - } - } - - @objc - private 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 - // Brief delay to let the main window become key after connection completes - try? await Task.sleep(for: .milliseconds(100)) - if !NSApp.windows.contains(where: { self?.isMainWindow($0) == true && $0.isKeyWindow }) { - Self.logger.warning("postSQLFilesWhenReady: no key main window, posting anyway") - } - NotificationCenter.default.post(name: .openSQLFiles, object: urls) - } - } - - /// Attempt to auto-reconnect to the last used connection - private func attemptAutoReconnect(connectionId: UUID) { - // Load connections and find the one we want - let connections = ConnectionStorage.shared.loadConnections() - guard let connection = connections.first(where: { $0.id == connectionId }) else { - // Connection was deleted, fall back to welcome window - AppSettingsStorage.shared.saveLastConnectionId(nil) - closeRestoredMainWindows() - openWelcomeWindow() - return - } - - // Open main window first, then attempt connection - DispatchQueue.main.async { [weak self] in - guard let self = self else { return } - - // Open main window via notification FIRST (before closing welcome window) - // The OpenWindowHandler in welcome window will process this - NotificationCenter.default.post(name: .openMainWindow, object: connection.id) - - // Connect in background and handle result - Task { @MainActor in - do { - try await DatabaseManager.shared.connectToSession(connection) - - // Connection successful - close welcome window - for window in NSApp.windows where self.isWelcomeWindow(window) { - window.close() - } - } catch { - // Log the error for debugging - Self.logger.error("Auto-reconnect failed for '\(connection.name)': \(error.localizedDescription)") - - // Connection failed - close main window and show welcome - for window in NSApp.windows where self.isMainWindow(window) { - window.close() - } - - self.openWelcomeWindow() - } - } - } - } - - /// Close any macOS-restored main windows - private func closeRestoredMainWindows() { - DispatchQueue.main.async { - for window in NSApp.windows where window.identifier?.rawValue.contains("main") == true { - window.close() - } - } - } - - @objc - private func windowDidChangeOcclusionState(_ notification: Notification) { - guard let window = notification.object as? NSWindow, - isHandlingFileOpen else { return } - - // When the welcome window becomes visible during a file-open - // event, close it so the user sees the main connection window. - if isWelcomeWindow(window), - window.occlusionState.contains(.visible), - NSApp.windows.contains(where: { isMainWindow($0) && $0.isVisible }) { - // Defer to next run-loop cycle so AppKit finishes ordering - DispatchQueue.main.async { [weak self] in - guard let self else { return } - if self.isWelcomeWindow(window), window.isVisible { - window.close() - } - } - } - } - - @objc - private func windowWillClose(_ notification: Notification) { - guard let window = notification.object as? NSWindow else { return } - - // Clean up window tracking - configuredWindows.remove(ObjectIdentifier(window)) - - // Check if main window is being closed - if isMainWindow(window) { - // Count remaining main windows (excluding the one being closed). - // We cannot rely on `window.tabbedWindows?.count` because AppKit - // may have already detached the closing window from its tab group - // by the time `willClose` fires, making the count unreliable. - let remainingMainWindows = NSApp.windows.filter { - $0 !== window && isMainWindow($0) && $0.isVisible - }.count - - if remainingMainWindows == 0 { - // Last main window closing -- return to welcome screen. - // Per-connection disconnect is handled by each MainContentView's - // onDisappear (via WindowLifecycleMonitor check), so we don't disconnectAll here. - NotificationCenter.default.post(name: .mainWindowWillClose, object: nil) - - // Reopen welcome window on next run loop after the close finishes - DispatchQueue.main.async { - self.openWelcomeWindow() - } - } - // If not the last tab, just let the window close naturally — - // macOS handles removing the tab from the tab group. - } - } - func applicationWillTerminate(_ notification: Notification) { SSHTunnelManager.shared.terminateAllProcessesSync() - - // Each MainContentCoordinator observes willTerminateNotification and - // synchronously writes tab state via TabDiskActor.saveSync. No additional - // action needed here — the per-coordinator observers fire before this returns. } nonisolated deinit { NotificationCenter.default.removeObserver(self) } - - private func isMainWindow(_ window: NSWindow) -> Bool { - // Main window has identifier containing "main" (from WindowGroup(id: "main")) - // This excludes temporary windows like context menus, panels, popovers, etc. - guard let identifier = window.identifier?.rawValue else { return false } - return identifier.contains("main") - } - - private func openWelcomeWindow() { - // Check if welcome window already exists and is visible - for window in NSApp.windows where isWelcomeWindow(window) { - window.makeKeyAndOrderFront(nil) - return - } - - // If no welcome window exists, we need to create one via SwiftUI's openWindow - // Post a notification that SwiftUI can handle - NotificationCenter.default.post(name: .openWelcomeWindow, object: nil) - } - - @objc - private func windowDidBecomeKey(_ notification: Notification) { - guard let window = notification.object as? NSWindow else { return } - let windowId = ObjectIdentifier(window) - - // If we're handling a file-open with an active connection, suppress - // any welcome window that SwiftUI creates as part of app activation. - if isWelcomeWindow(window) && isHandlingFileOpen { - window.close() - // Ensure the main window gets focus instead - for mainWin in NSApp.windows where isMainWindow(mainWin) { - mainWin.makeKeyAndOrderFront(nil) - } - return - } - - // Configure welcome window when it becomes key (only once) - if isWelcomeWindow(window) && !configuredWindows.contains(windowId) { - configureWelcomeWindowStyle(window) - configuredWindows.insert(windowId) - } - - // Configure connection form window when it becomes key (only once) - if isConnectionFormWindow(window) && !configuredWindows.contains(windowId) { - configureConnectionFormWindowStyle(window) - configuredWindows.insert(windowId) - } - - // Configure native tabbing for main windows (only once per window). - // Must run synchronously so tabbingIdentifier is set before display. - if isMainWindow(window) && !configuredWindows.contains(windowId) { - window.tabbingMode = .preferred - let pendingId = MainActor.assumeIsolated { WindowOpener.shared.consumePendingConnectionId() } - let existingIdentifier = NSApp.windows - .first { $0 !== window && isMainWindow($0) && $0.isVisible }? - .tabbingIdentifier - window.tabbingIdentifier = TabbingIdentifierResolver.resolve( - pendingConnectionId: pendingId, - existingIdentifier: existingIdentifier - ) - configuredWindows.insert(windowId) - - // Re-enable auto-tabbing if it was temporarily disabled by - // handleDatabaseURL/connectViaDeeplink to prevent cross-connection merging - if !NSWindow.allowsAutomaticWindowTabbing { - NSWindow.allowsAutomaticWindowTabbing = true - } - } - - // Note: Right panel uses overlay style (not .inspector()) — no split view configuration needed - } - - private func configureWelcomeWindow() { - // SwiftUI creates the welcome window asynchronously after app launch. - // Poll up to 5 times (250ms total) waiting for it to appear so we can - // configure AppKit-level style properties (hide miniaturize/zoom buttons, etc.). - Task { @MainActor [weak self] in - for _ in 0 ..< 5 { - guard let self else { return } - let found = NSApp.windows.contains(where: { self.isWelcomeWindow($0) }) - if found { - for window in NSApp.windows where self.isWelcomeWindow(window) { - self.configureWelcomeWindowStyle(window) - } - return - } - try? await Task.sleep(for: .milliseconds(50)) - } - } - } - - private func isWelcomeWindow(_ window: NSWindow) -> Bool { - // Check by window identifier or title - window.identifier?.rawValue == "welcome" || - window.title.lowercased().contains("welcome") - } - - private func configureWelcomeWindowStyle(_ window: NSWindow) { - // Remove miniaturize (yellow) button functionality - window.standardWindowButton(.miniaturizeButton)?.isHidden = true - - // Remove zoom (green) button functionality - window.standardWindowButton(.zoomButton)?.isHidden = true - - // Remove these capabilities from the window's style mask - // This prevents the actions even if buttons were visible - window.styleMask.remove(.miniaturizable) - - // Prevent full screen - window.collectionBehavior.remove(.fullScreenPrimary) - window.collectionBehavior.insert(.fullScreenNone) - - if window.styleMask.contains(.resizable) { - window.styleMask.remove(.resizable) - } - - let welcomeSize = NSSize(width: 700, height: 450) - if window.frame.size != welcomeSize { - window.setContentSize(welcomeSize) - window.center() - } - - // Enable behind-window translucency (frosted glass effect) - window.isOpaque = false - window.backgroundColor = .clear - window.titlebarAppearsTransparent = true - } - - private func isConnectionFormWindow(_ window: NSWindow) -> Bool { - // Check by window identifier - // WindowGroup uses "connection-form-X" format for identifiers - window.identifier?.rawValue.contains("connection-form") == true - } - - private func configureConnectionFormWindowStyle(_ window: NSWindow) { - // Disable miniaturize (yellow) and zoom (green) buttons - window.standardWindowButton(.miniaturizeButton)?.isEnabled = false - window.standardWindowButton(.zoomButton)?.isEnabled = false - - // Remove these capabilities from the window's style mask - window.styleMask.remove(.miniaturizable) - - // Prevent full screen - window.collectionBehavior.remove(.fullScreenPrimary) - window.collectionBehavior.insert(.fullScreenNone) - - // Keep connection form above welcome window - window.level = .floating - } } From 6d7692ce2c6145447908ad8e2631a06c9d437939 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 11 Mar 2026 08:52:30 +0700 Subject: [PATCH 4/4] fix: address review findings for SQLite file open and queue handling --- TablePro/AppDelegate+ConnectionHandler.swift | 10 +++++++++- TablePro/AppDelegate.swift | 3 +++ TablePro/Info.plist | 2 ++ 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/TablePro/AppDelegate+ConnectionHandler.swift b/TablePro/AppDelegate+ConnectionHandler.swift index 5fe012343..6c1caf98e 100644 --- a/TablePro/AppDelegate+ConnectionHandler.swift +++ b/TablePro/AppDelegate+ConnectionHandler.swift @@ -90,7 +90,7 @@ extension AppDelegate { return } - let filePath = url.path + let filePath = url.path(percentEncoded: false) let connectionName = url.deletingPathExtension().lastPathComponent for (sessionId, session) in DatabaseManager.shared.activeSessions { @@ -129,7 +129,12 @@ extension AppDelegate { // 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 } @@ -143,6 +148,8 @@ extension AppDelegate { self.queuedURLEntries.removeAll() return } + + self.suppressWelcomeWindow() let entries = self.queuedURLEntries self.queuedURLEntries.removeAll() for entry in entries { @@ -151,6 +158,7 @@ extension AppDelegate { case .sqliteFile(let url): self.handleSQLiteFile(url) } } + self.scheduleWelcomeWindowSuppression() } } diff --git a/TablePro/AppDelegate.swift b/TablePro/AppDelegate.swift index 4bad0b193..d7ebe49cf 100644 --- a/TablePro/AppDelegate.swift +++ b/TablePro/AppDelegate.swift @@ -41,6 +41,9 @@ class AppDelegate: NSObject, NSApplicationDelegate { /// Counter for outstanding suppressions; welcome window is suppressed while > 0 var fileOpenSuppressionCount = 0 + /// True while a queued URL polling task is active — prevents duplicate pollers + var isProcessingQueuedURLs = false + // MARK: - NSApplicationDelegate func application(_ application: NSApplication, open urls: [URL]) { diff --git a/TablePro/Info.plist b/TablePro/Info.plist index a8b693dad..1cd347bef 100644 --- a/TablePro/Info.plist +++ b/TablePro/Info.plist @@ -104,6 +104,8 @@ public.filename-extension + sqlite + sqlite3 db3 s3db sl3