From 1ace00768b2f8dc379188a2f6289505b20641dc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Thu, 26 Mar 2026 17:07:59 +0700 Subject: [PATCH 1/3] fix: deep link cold launch missing toolbar and duplicate windows (#465) --- TablePro/AppDelegate+ConnectionHandler.swift | 57 +++++++++++++++++--- TablePro/AppDelegate.swift | 4 ++ TablePro/ContentView.swift | 7 +-- 3 files changed, 57 insertions(+), 11 deletions(-) diff --git a/TablePro/AppDelegate+ConnectionHandler.swift b/TablePro/AppDelegate+ConnectionHandler.swift index 408050d5..f0beef07 100644 --- a/TablePro/AppDelegate+ConnectionHandler.swift +++ b/TablePro/AppDelegate+ConnectionHandler.swift @@ -67,12 +67,23 @@ extension AppDelegate { return } - openNewConnectionWindow(for: connection) + // Skip if already connecting this connection from a URL (prevents duplicates) + guard !connectingURLConnectionIds.contains(connection.id), + !isConnectingByParams(parsed) else { return } + connectingURLConnectionIds.insert(connection.id) Task { @MainActor in - defer { self.endFileOpenSuppression() } + defer { + self.connectingURLConnectionIds.remove(connection.id) + self.endFileOpenSuppression() + } do { + // Connect before opening the window so the session is already + // in activeSessions when ContentView.init runs. This avoids a + // SwiftUI bug where toolbar items are dropped when the detail + // view transitions from "Connecting..." to MainContentView. try await DatabaseManager.shared.connectToSession(connection) + self.openNewConnectionWindow(for: connection) for window in NSApp.windows where self.isWelcomeWindow(window) { window.close() } @@ -114,12 +125,17 @@ extension AppDelegate { type: .sqlite ) - openNewConnectionWindow(for: connection) + guard !connectingURLConnectionIds.contains(connection.id) else { return } + connectingURLConnectionIds.insert(connection.id) Task { @MainActor in - defer { self.endFileOpenSuppression() } + defer { + self.connectingURLConnectionIds.remove(connection.id) + self.endFileOpenSuppression() + } do { try await DatabaseManager.shared.connectToSession(connection) + self.openNewConnectionWindow(for: connection) for window in NSApp.windows where self.isWelcomeWindow(window) { window.close() } @@ -160,12 +176,17 @@ extension AppDelegate { type: .duckdb ) - openNewConnectionWindow(for: connection) + guard !connectingURLConnectionIds.contains(connection.id) else { return } + connectingURLConnectionIds.insert(connection.id) Task { @MainActor in - defer { self.endFileOpenSuppression() } + defer { + self.connectingURLConnectionIds.remove(connection.id) + self.endFileOpenSuppression() + } do { try await DatabaseManager.shared.connectToSession(connection) + self.openNewConnectionWindow(for: connection) for window in NSApp.windows where self.isWelcomeWindow(window) { window.close() } @@ -206,12 +227,17 @@ extension AppDelegate { type: dbType ) - openNewConnectionWindow(for: connection) + guard !connectingURLConnectionIds.contains(connection.id) else { return } + connectingURLConnectionIds.insert(connection.id) Task { @MainActor in - defer { self.endFileOpenSuppression() } + defer { + self.connectingURLConnectionIds.remove(connection.id) + self.endFileOpenSuppression() + } do { try await DatabaseManager.shared.connectToSession(connection) + self.openNewConnectionWindow(for: connection) for window in NSApp.windows where self.isWelcomeWindow(window) { window.close() } @@ -379,6 +405,21 @@ extension AppDelegate { return nil } + /// Checks if a connection matching the parsed params is already in-flight. + private func isConnectingByParams(_ parsed: ParsedConnectionURL) -> Bool { + 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.username.isEmpty || conn.username == parsed.username) { + return connectingURLConnectionIds.contains(id) + } + } + return false + } + func bringConnectionWindowToFront(_ connectionId: UUID) { let windows = WindowLifecycleMonitor.shared.windows(for: connectionId) if let window = windows.first { diff --git a/TablePro/AppDelegate.swift b/TablePro/AppDelegate.swift index efb654f2..fa997bcc 100644 --- a/TablePro/AppDelegate.swift +++ b/TablePro/AppDelegate.swift @@ -44,6 +44,10 @@ class AppDelegate: NSObject, NSApplicationDelegate { /// True while a queued URL polling task is active — prevents duplicate pollers var isProcessingQueuedURLs = false + /// ConnectionIds currently being connected from URL/file handlers. + /// Prevents duplicate connections when the same URL is opened twice rapidly. + var connectingURLConnectionIds = Set() + // MARK: - NSApplicationDelegate func application(_ application: NSApplication, open urls: [URL]) { diff --git a/TablePro/ContentView.swift b/TablePro/ContentView.swift index 14b5df98..79736d8c 100644 --- a/TablePro/ContentView.swift +++ b/TablePro/ContentView.swift @@ -256,9 +256,6 @@ struct ContentView: View { .navigationSubtitle(currentSession?.connection.name ?? "") } - // Removed: newConnectionSheet and editConnectionSheet helpers - // Connection forms are now handled by the separate connection-form window - // MARK: - Session State Bindings /// Generic helper to create bindings that update session state @@ -375,6 +372,10 @@ struct ContentView: View { return } currentSession = newSession + // Update window title on first session connect (fixes cold-launch stale title) + if payload?.tableName == nil, windowTitle == "SQL Query" || windowTitle.hasSuffix(" Query") { + windowTitle = newSession.connection.name + } if rightPanelState == nil { rightPanelState = RightPanelState() } From 18e48f266d0a7df3b2663e86cf21a16a18fb288e Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 26 Mar 2026 19:41:18 +0700 Subject: [PATCH 2/3] fix: deep link duplicate windows, orphan restore loop, and suppression leak - Connect-before-window for deep links to prevent SwiftUI toolbar drop bug - Deduplicate connections by param key (catches transient UUIDs and cross-path races) - Use file path dedup for SQLite/DuckDB/generic file handlers - Balance endFileOpenSuppression at batch level in handleOpenURLs - Hide SwiftUI state-restored orphan windows via orderOut in windowDidBecomeKey - Set pendingConnectionId at all openWindow(id:"main") call sites - Set isRestorable=false on main windows to reduce restoration attempts - Update window title on first session connect for cold-launch deep links --- TablePro/AppDelegate+ConnectionHandler.swift | 77 +++++++++---------- TablePro/AppDelegate+FileOpen.swift | 6 +- TablePro/AppDelegate+WindowConfig.swift | 17 +++- TablePro/AppDelegate.swift | 13 +++- TablePro/ContentView.swift | 15 ++-- TablePro/TableProApp.swift | 1 - .../Views/Connection/ConnectionFormView.swift | 2 + .../Views/Connection/WelcomeWindowView.swift | 1 + .../Toolbar/ConnectionSwitcherPopover.swift | 1 + 9 files changed, 80 insertions(+), 53 deletions(-) diff --git a/TablePro/AppDelegate+ConnectionHandler.swift b/TablePro/AppDelegate+ConnectionHandler.swift index f0beef07..8550c9a7 100644 --- a/TablePro/AppDelegate+ConnectionHandler.swift +++ b/TablePro/AppDelegate+ConnectionHandler.swift @@ -55,33 +55,41 @@ extension AppDelegate { ConnectionStorage.shared.savePassword(parsed.password, for: connection.id) } - if DatabaseManager.shared.activeSessions[connection.id]?.driver != nil { - handlePostConnectionActions(parsed, connectionId: connection.id) + // Check if already connected or connecting (by ID or by params). + // This catches duplicates from URL handler, auto-reconnect, or any other source. + if DatabaseManager.shared.activeSessions[connection.id] != nil { + 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) + if let existingId = findSessionByParams(parsed) { + if DatabaseManager.shared.activeSessions[existingId]?.driver != nil { + handlePostConnectionActions(parsed, connectionId: existingId) + } + bringConnectionWindowToFront(existingId) return } - // Skip if already connecting this connection from a URL (prevents duplicates) + // Skip if already connecting this connection from a URL (prevents duplicates). + // Use param key to catch transient connections with different UUIDs + // even before connectToSession creates the session. + let paramKey = Self.paramKey(for: parsed) guard !connectingURLConnectionIds.contains(connection.id), - !isConnectingByParams(parsed) else { return } + !connectingURLParamKeys.contains(paramKey) else { + return + } connectingURLConnectionIds.insert(connection.id) + connectingURLParamKeys.insert(paramKey) Task { @MainActor in defer { self.connectingURLConnectionIds.remove(connection.id) - self.endFileOpenSuppression() + self.connectingURLParamKeys.remove(paramKey) } do { - // Connect before opening the window so the session is already - // in activeSessions when ContentView.init runs. This avoids a - // SwiftUI bug where toolbar items are dropped when the detail - // view transitions from "Connecting..." to MainContentView. try await DatabaseManager.shared.connectToSession(connection) self.openNewConnectionWindow(for: connection) for window in NSApp.windows where self.isWelcomeWindow(window) { @@ -125,13 +133,12 @@ extension AppDelegate { type: .sqlite ) - guard !connectingURLConnectionIds.contains(connection.id) else { return } - connectingURLConnectionIds.insert(connection.id) + guard !connectingFilePaths.contains(filePath) else { return } + connectingFilePaths.insert(filePath) Task { @MainActor in defer { - self.connectingURLConnectionIds.remove(connection.id) - self.endFileOpenSuppression() + self.connectingFilePaths.remove(filePath) } do { try await DatabaseManager.shared.connectToSession(connection) @@ -176,13 +183,12 @@ extension AppDelegate { type: .duckdb ) - guard !connectingURLConnectionIds.contains(connection.id) else { return } - connectingURLConnectionIds.insert(connection.id) + guard !connectingFilePaths.contains(filePath) else { return } + connectingFilePaths.insert(filePath) Task { @MainActor in defer { - self.connectingURLConnectionIds.remove(connection.id) - self.endFileOpenSuppression() + self.connectingFilePaths.remove(filePath) } do { try await DatabaseManager.shared.connectToSession(connection) @@ -227,13 +233,12 @@ extension AppDelegate { type: dbType ) - guard !connectingURLConnectionIds.contains(connection.id) else { return } - connectingURLConnectionIds.insert(connection.id) + guard !connectingFilePaths.contains(filePath) else { return } + connectingFilePaths.insert(filePath) Task { @MainActor in defer { - self.connectingURLConnectionIds.remove(connection.id) - self.endFileOpenSuppression() + self.connectingFilePaths.remove(filePath) } do { try await DatabaseManager.shared.connectToSession(connection) @@ -251,7 +256,9 @@ extension AppDelegate { // MARK: - Unified Queue func scheduleQueuedURLProcessing() { - guard !isProcessingQueuedURLs else { return } + guard !isProcessingQueuedURLs else { + return + } isProcessingQueuedURLs = true Task { @MainActor [weak self] in @@ -389,9 +396,9 @@ extension AppDelegate { // MARK: - Session Lookup - private func findActiveSessionByParams(_ parsed: ParsedConnectionURL) -> UUID? { + /// Finds any session (connected or still connecting) matching the parsed URL params. + private func findSessionByParams(_ 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 @@ -405,19 +412,9 @@ extension AppDelegate { return nil } - /// Checks if a connection matching the parsed params is already in-flight. - private func isConnectingByParams(_ parsed: ParsedConnectionURL) -> Bool { - 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.username.isEmpty || conn.username == parsed.username) { - return connectingURLConnectionIds.contains(id) - } - } - return false + /// Normalized key for deduplicating connection attempts by URL params. + static func paramKey(for parsed: ParsedConnectionURL) -> String { + "\(parsed.type.rawValue):\(parsed.username)@\(parsed.host):\(parsed.port ?? 0)/\(parsed.database)" } func bringConnectionWindowToFront(_ connectionId: UUID) { diff --git a/TablePro/AppDelegate+FileOpen.swift b/TablePro/AppDelegate+FileOpen.swift index ce60f22b..2532d781 100644 --- a/TablePro/AppDelegate+FileOpen.swift +++ b/TablePro/AppDelegate+FileOpen.swift @@ -53,7 +53,9 @@ extension AppDelegate { suppressWelcomeWindow() Task { @MainActor in for url in databaseURLs { self.handleDatabaseURL(url) } - // Flag management is handled by endFileOpenSuppression() in each handler + // endFileOpenSuppression is called here to match suppressWelcomeWindow above. + // Individual handlers no longer manage this flag. + self.endFileOpenSuppression() } } @@ -72,7 +74,7 @@ extension AppDelegate { self.handleGenericDatabaseFile(url, type: dbType) } } - // Flag management is handled by endFileOpenSuppression() in each handler + self.endFileOpenSuppression() } } diff --git a/TablePro/AppDelegate+WindowConfig.swift b/TablePro/AppDelegate+WindowConfig.swift index 8884e675..dfe35b33 100644 --- a/TablePro/AppDelegate+WindowConfig.swift +++ b/TablePro/AppDelegate+WindowConfig.swift @@ -67,6 +67,7 @@ extension AppDelegate { let connections = ConnectionStorage.shared.loadConnections() guard let connection = connections.first(where: { $0.id == connectionId }) else { return } + WindowOpener.shared.pendingConnectionId = connection.id NotificationCenter.default.post(name: .openMainWindow, object: connection.id) Task { @MainActor in @@ -244,7 +245,18 @@ extension AppDelegate { if isMainWindow(window) && !configuredWindows.contains(windowId) { window.tabbingMode = .preferred + window.isRestorable = false let pendingId = MainActor.assumeIsolated { WindowOpener.shared.consumePendingConnectionId() } + + // If no code opened this window (pendingId is nil), this is a + // SwiftUI WindowGroup state restoration — not a window we created. + // Hide it (orderOut, not close) to break the close→restore loop. + if pendingId == nil && !isAutoReconnecting { + configuredWindows.insert(windowId) + window.orderOut(nil) + return + } + let existingIdentifier = NSApp.windows .first { $0 !== window && isMainWindow($0) && $0.isVisible }? .tabbingIdentifier @@ -311,12 +323,15 @@ extension AppDelegate { return } + isAutoReconnecting = true + DispatchQueue.main.async { [weak self] in guard let self else { return } - + WindowOpener.shared.pendingConnectionId = connection.id NotificationCenter.default.post(name: .openMainWindow, object: connection.id) Task { @MainActor in + defer { self.isAutoReconnecting = false } do { try await DatabaseManager.shared.connectToSession(connection) diff --git a/TablePro/AppDelegate.swift b/TablePro/AppDelegate.swift index fa997bcc..42a1be68 100644 --- a/TablePro/AppDelegate.swift +++ b/TablePro/AppDelegate.swift @@ -44,10 +44,21 @@ class AppDelegate: NSObject, NSApplicationDelegate { /// True while a queued URL polling task is active — prevents duplicate pollers var isProcessingQueuedURLs = false - /// ConnectionIds currently being connected from URL/file handlers. + /// True while auto-reconnect is in progress at startup + var isAutoReconnecting = false + + /// ConnectionIds currently being connected from URL handlers. /// Prevents duplicate connections when the same URL is opened twice rapidly. var connectingURLConnectionIds = Set() + /// Normalized param keys for URLs currently being connected. + /// Catches duplicates even before connectToSession creates the session. + var connectingURLParamKeys = Set() + + /// File paths currently being connected from file-open handlers. + /// Prevents duplicate connections when the same file is opened twice rapidly. + var connectingFilePaths = Set() + // MARK: - NSApplicationDelegate func application(_ application: NSApplication, open urls: [URL]) { diff --git a/TablePro/ContentView.swift b/TablePro/ContentView.swift index 79736d8c..a346e380 100644 --- a/TablePro/ContentView.swift +++ b/TablePro/ContentView.swift @@ -335,7 +335,9 @@ struct ContentView: View { // MARK: - Connection Status private func handleConnectionStatusChange() { - guard closingSessionId == nil else { return } + guard closingSessionId == nil else { + return + } let sessions = DatabaseManager.shared.activeSessions let connectionId = payload?.connectionId ?? currentSession?.id ?? DatabaseManager.shared.currentSessionId guard let sid = connectionId else { @@ -357,13 +359,10 @@ struct ContentView: View { AppState.shared.currentDatabaseType = nil AppState.shared.supportsDatabaseSwitching = true - let tabbingId = "com.TablePro.main.\(sid.uuidString)" - DispatchQueue.main.async { - for window in NSApp.windows where window.tabbingIdentifier == tabbingId { - window.isReleasedWhenClosed = true - window.close() - } - } + // Window cleanup is handled by windowWillClose (opens welcome) + // and windowDidBecomeKey (hides restored orphan windows). + // Do NOT close windows here — it triggers SwiftUI state + // restoration which creates an infinite close→restore loop. } return } diff --git a/TablePro/TableProApp.swift b/TablePro/TableProApp.swift index 49b6a2ee..547140dc 100644 --- a/TablePro/TableProApp.swift +++ b/TablePro/TableProApp.swift @@ -544,7 +544,6 @@ private struct OpenWindowHandler: View { if let payload = notification.object as? EditorTabPayload { openWindow(id: "main", value: payload) } else if let connectionId = notification.object as? UUID { - // Legacy: connection ID only — open default query tab openWindow(id: "main", value: EditorTabPayload(connectionId: connectionId)) } } diff --git a/TablePro/Views/Connection/ConnectionFormView.swift b/TablePro/Views/Connection/ConnectionFormView.swift index a6e8afe9..929317a2 100644 --- a/TablePro/Views/Connection/ConnectionFormView.swift +++ b/TablePro/Views/Connection/ConnectionFormView.swift @@ -1303,6 +1303,7 @@ struct ConnectionFormView: View { // swiftlint:disable:this type_body_length } private func connectToDatabase(_ connection: DatabaseConnection) { + WindowOpener.shared.pendingConnectionId = connection.id openWindow(id: "main", value: EditorTabPayload(connectionId: connection.id)) NSApplication.shared.closeWindows(withId: "welcome") @@ -1335,6 +1336,7 @@ struct ConnectionFormView: View { // swiftlint:disable:this type_body_length } private func connectAfterInstall(_ connection: DatabaseConnection) { + WindowOpener.shared.pendingConnectionId = connection.id openWindow(id: "main", value: EditorTabPayload(connectionId: connection.id)) NSApplication.shared.closeWindows(withId: "welcome") diff --git a/TablePro/Views/Connection/WelcomeWindowView.swift b/TablePro/Views/Connection/WelcomeWindowView.swift index a3a34c56..95f4ebd3 100644 --- a/TablePro/Views/Connection/WelcomeWindowView.swift +++ b/TablePro/Views/Connection/WelcomeWindowView.swift @@ -795,6 +795,7 @@ struct WelcomeWindowView: View { } private func connectAfterInstall(_ connection: DatabaseConnection) { + WindowOpener.shared.pendingConnectionId = connection.id openWindow(id: "main", value: EditorTabPayload(connectionId: connection.id)) NSApplication.shared.closeWindows(withId: "welcome") diff --git a/TablePro/Views/Toolbar/ConnectionSwitcherPopover.swift b/TablePro/Views/Toolbar/ConnectionSwitcherPopover.swift index da37d061..c0ab8a61 100644 --- a/TablePro/Views/Toolbar/ConnectionSwitcherPopover.swift +++ b/TablePro/Views/Toolbar/ConnectionSwitcherPopover.swift @@ -346,6 +346,7 @@ struct ConnectionSwitcherPopover: View { let currentWindow = NSApp.keyWindow let previousMode = currentWindow?.tabbingMode ?? .preferred currentWindow?.tabbingMode = .disallowed + WindowOpener.shared.pendingConnectionId = payload.connectionId openWindow(id: "main", value: payload) // Restore after the next run loop to let window creation complete DispatchQueue.main.async { From 97980faf246b5086102de29ade550d38bf5ca059 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 26 Mar 2026 19:45:37 +0700 Subject: [PATCH 3/3] fix: balance endFileOpenSuppression in queued URL path and include redisDatabase in paramKey --- TablePro/AppDelegate+ConnectionHandler.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/TablePro/AppDelegate+ConnectionHandler.swift b/TablePro/AppDelegate+ConnectionHandler.swift index 8550c9a7..253fcba5 100644 --- a/TablePro/AppDelegate+ConnectionHandler.swift +++ b/TablePro/AppDelegate+ConnectionHandler.swift @@ -289,7 +289,7 @@ extension AppDelegate { case .genericDatabaseFile(let url, let dbType): self.handleGenericDatabaseFile(url, type: dbType) } } - // Flag management is handled by endFileOpenSuppression() in each handler + self.endFileOpenSuppression() } } @@ -414,7 +414,8 @@ extension AppDelegate { /// Normalized key for deduplicating connection attempts by URL params. static func paramKey(for parsed: ParsedConnectionURL) -> String { - "\(parsed.type.rawValue):\(parsed.username)@\(parsed.host):\(parsed.port ?? 0)/\(parsed.database)" + let rdb = parsed.redisDatabase.map { "/redis:\($0)" } ?? "" + return "\(parsed.type.rawValue):\(parsed.username)@\(parsed.host):\(parsed.port ?? 0)/\(parsed.database)\(rdb)" } func bringConnectionWindowToFront(_ connectionId: UUID) {