Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- An already open `.sql` file, linked favorite, query link or database file raising the window without showing its connection and tab.
- Picking a database in the query editor rerunning the statement at the caret, writes included, without asking.
- "Connection Failed: Cancelled by user." after declining a connect prompt from Switch Connection.
- A database link with a filter replacing the filters of a tab already showing that table.
- A cleared group, tag, AI rule, startup command or SSH profile, or a group moved to the top level, returning after iCloud sync. (#3309)
- Exported connections taking the color of another group with the same name. (#3309)
- Copy TablePro Link dropping the connect and query timeouts, and new tags after the first. (#3309)
Expand All @@ -100,6 +101,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- Duplicating a connection set to Blocked for external access making the copy readable over MCP. (#3309)
- DuckDB names holding a quote followed by a combining mark, and ClickHouse names holding a backslash, ending their quoting early in generated SQL. (#3308)
- A database link's SQL filter shown cut to 300 characters, and connecting to a trusted database before asking about it.
- A database link through an SSH tunnel to `127.0.0.1` reusing a local database's Always Allow, whatever SSH server it named.

## [0.79.0] - 2026-10-09

Expand Down
18 changes: 14 additions & 4 deletions TablePro/Core/Services/Infrastructure/ExternalConnectionGate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,25 @@ internal struct ExternalConnectionGate {
self.prompt = prompt ?? ExternalConnectionAlertPrompt()
}

internal func authorize(_ connection: DatabaseConnection, scopeName: String?) async -> Bool {
/// The trust key names neither a filter nor an SSH server, so trusting a link that carries one
/// would cover whatever SQL or server a later link picks. A tunnelled loopback host is not on
/// this Mac either.
internal func authorize(
_ connection: DatabaseConnection,
scopeName: String?,
filter: ConnectionURLFilter? = nil
) async -> Bool {
let key = ExternalConnectionTrustKey(connection: connection, scopeName: scopeName)
if key.isLoopbackHost, trustStore.isTrusted(key) { return true }
let trustable = key.isLoopbackHost && !connection.sshConfig.enabled && filter == nil
if trustable, trustStore.isTrusted(key) { return true }

switch await prompt.prompt(for: connection, offerAlwaysAllow: key.isLoopbackHost) {
switch await prompt.prompt(for: connection, filter: filter, offerAlwaysAllow: trustable) {
case .connect:
return true
case .alwaysAllow:
trustStore.trust(key)
if trustable {
trustStore.trust(key)
}
return true
case .cancel:
return false
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,22 @@ internal enum ExternalConnectionDecision: Sendable {

@MainActor
internal protocol ExternalConnectionPrompting {
func prompt(for connection: DatabaseConnection, offerAlwaysAllow: Bool) async -> ExternalConnectionDecision
func prompt(
for connection: DatabaseConnection,
filter: ConnectionURLFilter?,
offerAlwaysAllow: Bool
) async -> ExternalConnectionDecision
}

@MainActor
internal struct ExternalConnectionAlertPrompt: ExternalConnectionPrompting {
internal func prompt(
for connection: DatabaseConnection,
filter: ConnectionURLFilter?,
offerAlwaysAllow: Bool
) async -> ExternalConnectionDecision {
let response = await present(Self.makeAlert(for: connection, offerAlwaysAllow: offerAlwaysAllow))
let alert = Self.makeAlert(for: connection, filter: filter, offerAlwaysAllow: offerAlwaysAllow)
let response = await present(alert)
switch response {
case .alertFirstButtonReturn:
return .connect
Expand All @@ -34,20 +40,45 @@ internal struct ExternalConnectionAlertPrompt: ExternalConnectionPrompting {
}
}

internal static func makeAlert(for connection: DatabaseConnection, offerAlwaysAllow: Bool) -> NSAlert {
internal static func makeAlert(
for connection: DatabaseConnection,
filter: ConnectionURLFilter? = nil,
offerAlwaysAllow: Bool
) -> NSAlert {
let alert = NSAlert()
alert.messageText = String(localized: "Open External Database Connection?")
alert.informativeText = String(
format: String(localized: """
An external link wants to connect to a %@ database:
let details = details(for: connection).joined(separator: "\n")
if let filter {
alert.informativeText = String(
format: String(localized: """
An external link wants to connect to a %@ database and filter a table:

%@
%@

Connect only if you trust the source of this link.
"""),
connection.type.rawValue,
details(for: connection).joined(separator: "\n")
)
Connect only if you trust the source of this link and the filter below.
"""),
connection.type.rawValue,
details
)
/// `informativeText` neither scrolls nor reveals invisible characters. An overlay scroller
/// stays hidden until scrolled, so line breaks could push SQL out of the box unnoticed.
let box = AlertHelper.scrollingTextAccessory(RevealedText(filter.displayText).plainText)
box.scrollerStyle = .legacy
box.autohidesScrollers = true
alert.accessoryView = box
} else {
alert.informativeText = String(
format: String(localized: """
An external link wants to connect to a %@ database:

%@

Connect only if you trust the source of this link.
"""),
connection.type.rawValue,
details
)
}
alert.alertStyle = .warning
/// Connecting is the risky half of this decision, so it gives up Return. Escape stays on
/// Cancel, which is the only binding that dismisses the alert from the keyboard.
Expand Down
61 changes: 22 additions & 39 deletions TablePro/Core/Services/Infrastructure/TabRouter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ internal enum TabRouterError: Error, LocalizedError {
case fileNoLongerExists(URL)
case userCancelled
case unsupportedIntent(String)
case filterTooLong(Int, limit: Int)
case connectFailedInWindow(connectionId: UUID, underlying: Error)

internal var windowConnectionId: UUID? {
Expand All @@ -38,6 +39,8 @@ internal enum TabRouterError: Error, LocalizedError {
return String(localized: "Cancelled by user.")
case .unsupportedIntent(let detail):
return String(format: String(localized: "Unsupported intent: %@"), detail)
case .filterTooLong(let length, let limit):
return String(format: String(localized: "SQL is too long: %d characters (limit %d)"), length, limit)
case .connectFailedInWindow(_, let underlying):
return underlying.localizedDescription
}
Expand Down Expand Up @@ -228,6 +231,7 @@ internal final class TabRouter {
connectionId: UUID, transientConnection: DatabaseConnection? = nil,
database: String?, schema: String?, table: String, isView: Bool,
objectType: TableInfo.TableType? = nil,
initialFilterState: TabFilterState? = nil,
passwordOverride: String? = nil, sshPasswordOverride: String? = nil
) async throws {
let connection: DatabaseConnection
Expand All @@ -238,7 +242,10 @@ internal final class TabRouter {
} else {
throw TabRouterError.connectionNotFound(connectionId)
}
if focusExistingTableTab(connectionId: connectionId, database: database, schema: schema, table: table) {
/// A filtered open gets a new tab with the filter in its payload, so the filter is in place
/// before the first load and a tab already showing the table keeps its own filters.
if initialFilterState == nil,
focusExistingTableTab(connectionId: connectionId, database: database, schema: schema, table: table) {
AppActivationPolicyController.shared.activate(ignoringOtherApps: true)
WindowOpener.shared.closeWelcome()
return
Expand All @@ -253,7 +260,9 @@ internal final class TabRouter {
databaseName: database,
schemaName: schema,
isView: isView,
objectType: objectType
objectType: objectType,
forcesNewTab: initialFilterState != nil,
initialFilterState: initialFilterState
)
DatabaseManager.shared.registerPendingSession(connection)
WindowManager.shared.openTab(payload: payload)
Expand Down Expand Up @@ -349,6 +358,13 @@ internal final class TabRouter {
guard case .success(let parsed) = ConnectionURLParser.parse(url.absoluteString) else {
throw TabRouterError.malformedDatabaseURL(url)
}
let filter = parsed.filter
if let filter {
let length = (filter.displayText as NSString).length
guard length <= DeeplinkParser.sqlLengthLimit else {
throw TabRouterError.filterTooLong(length, limit: DeeplinkParser.sqlLengthLimit)
}
}

let connections = ConnectionStorage.shared.loadConnections()
let matched = connections.first { conn in
Expand All @@ -369,7 +385,9 @@ internal final class TabRouter {
isTransient = true
}

guard await externalConnectionGate.authorize(connection, scopeName: parsed.connectionName) else {
guard await externalConnectionGate.authorize(
connection, scopeName: parsed.connectionName, filter: filter
) else {
throw TabRouterError.userCancelled
}

Expand All @@ -385,13 +403,11 @@ internal final class TabRouter {
schema: parsed.schema,
table: table,
isView: parsed.isView,
initialFilterState: filter?.filterState,
passwordOverride: passwordOverride,
sshPasswordOverride: sshPasswordOverride
)
}
if parsed.filterColumn != nil || parsed.filterCondition != nil {
try await applyFilterFromParsedURL(parsed: parsed, connectionId: connection.id)
}
return
}

Expand Down Expand Up @@ -562,37 +578,4 @@ internal final class TabRouter {
throw TabRouterError.userCancelled
}
}

private func applyFilterFromParsedURL(parsed: ParsedConnectionURL, connectionId: UUID) async throws {
let description: String
if let condition = parsed.filterCondition, !condition.isEmpty {
description = (condition as NSString).length > 300
? String(condition.prefix(300)) + "…" : condition
} else {
description = [parsed.filterColumn, parsed.filterOperation, parsed.filterValue]
.compactMap { $0 }.joined(separator: " ")
}
if !description.isEmpty {
let confirmed = await AlertHelper.confirmDestructive(
title: String(localized: "Apply Filter from Link"),
message: String(
format: String(localized: "An external link wants to apply a filter:\n\n%@"),
description
),
confirmButton: String(localized: "Apply Filter"),
cancelButton: String(localized: "Cancel"),
window: NSApp.keyWindow
)
guard confirmed else { throw TabRouterError.userCancelled }
}

guard let coordinator = MainContentCoordinator.allActiveCoordinators()
.first(where: { $0.connectionId == connectionId }) else { return }
coordinator.applyURLFilter(
condition: parsed.filterCondition,
column: parsed.filterColumn,
operation: parsed.filterOperation,
value: parsed.filterValue
)
}
}
94 changes: 94 additions & 0 deletions TablePro/Core/Utilities/Connection/ConnectionURLFilter.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
//
// ConnectionURLFilter.swift
// TablePro
//

import Foundation

internal enum ConnectionURLFilter: Equatable, Sendable {
case condition(String)
case column(name: String, operation: String?, value: String?)

internal var displayText: String {
switch self {
case .condition(let sql):
return sql
case .column(let name, let operation, let value):
return [name, operation, value].compactMap { $0 }.joined(separator: " ")
}
}

internal var filterState: TabFilterState {
TabFilterState(filters: [tableFilter], commit: .all, isVisible: true, filterLogicMode: .and)
}

private var tableFilter: TableFilter {
switch self {
case .condition(let sql):
return TableFilter(columnName: TableFilter.rawSQLColumn, rawSQL: sql)
case .column(let name, let operation, let value):
return TableFilter(
columnName: name,
filterOperator: Self.filterOperator(forURLOperation: operation ?? "Equal"),
value: value ?? ""
)
}
}

private static func filterOperator(forURLOperation operation: String) -> FilterOperator {
switch operation.lowercased() {
case "equal", "equals", "=":
return .equal
case "not equal", "notequal", "!=":
return .notEqual
case "contains", "like":
return .contains
case "not contains", "notcontains", "not like":
return .notContains
case "starts with", "startswith":
return .startsWith
case "ends with", "endswith":
return .endsWith
case "greater than", "greaterthan", ">":
return .greaterThan
case "greater or equal", "greaterorequal", ">=":
return .greaterOrEqual
case "less than", "lessthan", "<":
return .lessThan
case "less or equal", "lessorequal", "<=":
return .lessOrEqual
case "is null", "isnull":
return .isNull
case "is not null", "isnotnull":
return .isNotNull
case "is empty", "isempty":
return .isEmpty
case "is not empty", "isnotempty":
return .isNotEmpty
case "in":
return .inList
case "not in", "notin":
return .notInList
case "between":
return .between
case "regex":
return .regex
default:
return .contains
}
}
}

internal extension ParsedConnectionURL {
/// The filter is applied to the table the link opens, so a link without a table applies none.
var filter: ConnectionURLFilter? {
guard tableName != nil else { return nil }
if let filterCondition, !filterCondition.isEmpty {
return .condition(filterCondition)
}
if let filterColumn, !filterColumn.isEmpty {
return .column(name: filterColumn, operation: filterOperation, value: filterValue)
}
return nil
}
}
27 changes: 27 additions & 0 deletions TablePro/Core/Utilities/UI/AlertHelper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,33 @@ final class AlertHelper {
return cancel
}

/// A text view laid inside a scroll view by hand has to be told it may grow and that its text
/// container tracks its width. Left at its default zero-sized container it lays out no text at
/// all, so the accessory reads as an empty box.
static func scrollingTextAccessory(_ text: String) -> NSScrollView {
let scroll = NSScrollView(frame: NSRect(x: 0, y: 0, width: 380, height: 140))
scroll.hasVerticalScroller = true
scroll.borderType = .bezelBorder

let textView = NSTextView(frame: NSRect(origin: .zero, size: scroll.contentSize))
textView.isEditable = false
textView.drawsBackground = false
textView.font = .monospacedSystemFont(ofSize: NSFont.smallSystemFontSize, weight: .regular)
textView.autoresizingMask = [.width]
textView.isVerticallyResizable = true
textView.isHorizontallyResizable = false
textView.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)
textView.textContainer?.widthTracksTextView = true
textView.textContainer?.containerSize = NSSize(
width: scroll.contentSize.width,
height: CGFloat.greatestFiniteMagnitude
)
textView.string = text

scroll.documentView = textView
return scroll
}

/// The window a sheet belongs on. A sheet the user is meant to read against their work must
/// land on a document window, so a floating panel is never a candidate: the Quick Switcher
/// closes the moment it loses focus, taking the sheet with it. An explicit window is honoured
Expand Down
10 changes: 10 additions & 0 deletions TablePro/Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -23572,6 +23572,16 @@
}
}
},
"An external link wants to connect to a %@ database and filter a table:\n\n%@\n\nConnect only if you trust the source of this link and the filter below." : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "An external link wants to connect to a %1$@ database and filter a table:\n\n%2$@\n\nConnect only if you trust the source of this link and the filter below."
}
}
}
},
"An external link wants to connect to a %@ database:\n\n%@\n\nConnect only if you trust the source of this link." : {
"localizations" : {
"en" : {
Expand Down
Loading
Loading