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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Edit Comment… for MySQL, MariaDB, ClickHouse, CockroachDB, Redshift, SQL Server, Oracle, DuckDB and other engines with table comments. (#3308)
- Custom icons for connections and groups on Mac and iPhone, chosen from a searchable set of symbols and synced through iCloud. (#3309)
- Edit Group… in the welcome window, with name, icon, color and parent group in one sheet. (#3309)
- `tablepro://settings` and `tablepro://settings/<pane>` links that open Settings on a pane.

### Changed

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,8 @@ internal extension LaunchIntent {
.openDatabaseURL,
.installPlugin,
.reopenClosedTab,
.openSampleDatabase:
.openSampleDatabase,
.openSettings:
return true
}
}
Expand Down
18 changes: 18 additions & 0 deletions TablePro/Core/Services/Infrastructure/DeeplinkParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
//

import Foundation
import os
import TableProImport
import TableProPluginKit

Expand Down Expand Up @@ -42,6 +43,8 @@ internal enum DeeplinkError: Error, LocalizedError, Equatable {
internal enum DeeplinkParser {
internal static let sqlLengthLimit = 51_200

private static let logger = Logger(subsystem: "com.TablePro", category: "DeeplinkParser")

internal static func parse(_ url: URL) -> Result<LaunchIntent, DeeplinkError> {
guard url.scheme == "tablepro" else {
return .failure(.unknownScheme(url.scheme ?? ""))
Expand All @@ -54,11 +57,26 @@ internal enum DeeplinkParser {
return parseImport(url)
case "integrations":
return parseIntegrations(url)
case "settings":
return parseSettings(url)
default:
return .failure(.unknownHost(host))
}
}

/// Never fails: an unknown id or extra segments open the last-used pane, so a link to a pane
/// or section added later still opens Settings on an older app.
private static func parseSettings(_ url: URL) -> Result<LaunchIntent, DeeplinkError> {
guard let identifier = pathSegments(url).first else {
return .success(.openSettings(nil))
}
guard let pane = SettingsPane(urlIdentifier: identifier) else {
logger.notice("Unknown settings pane id \(identifier, privacy: .public), opening the last-used pane")
return .success(.openSettings(nil))
}
return .success(.openSettings(pane))
}

private static func parseConnect(_ url: URL) -> Result<LaunchIntent, DeeplinkError> {
let segments = pathSegments(url)
var cursor = PathCursor(segments: segments)
Expand Down
3 changes: 3 additions & 0 deletions TablePro/Core/Services/Infrastructure/LaunchIntent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ internal enum LaunchIntent: @unchecked Sendable {
case openConnectionShare(URL)
case pairIntegration(PairingRequest)
case startMCPServer
/// nil opens the last-used pane.
case openSettings(SettingsPane?)
case openDatabaseURL(URL)
case installPlugin(URL)
case reopenClosedTab(RecentlyClosedTabEntry)
Expand All @@ -49,6 +51,7 @@ internal enum LaunchIntent: @unchecked Sendable {
.openConnectionShare,
.pairIntegration,
.startMCPServer,
.openSettings,
.installPlugin:
return nil
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ internal final class LaunchIntentRouter {
case .startMCPServer:
await MCPServerManager.shared.lazyStart()

case .openSettings(let pane):
WindowOpener.shared.openSettings(tab: pane)

case .installPlugin(let url):
try await installPlugin(url)

Expand Down Expand Up @@ -143,7 +146,7 @@ internal final class LaunchIntentRouter {
title = String(localized: "Connection Failed")
case .openSQLFile, .openDataFile:
title = String(localized: "Could Not Open File")
case .importConnection, .openConnectionShare, .startMCPServer:
case .importConnection, .openConnectionShare, .startMCPServer, .openSettings:
title = String(localized: "Action Failed")
}
if presentRecoverableError(error, for: intent, title: title) { return }
Expand Down
25 changes: 24 additions & 1 deletion TablePro/Core/Testing/UITestLaunchEnvironment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
//

import Foundation
import os

/// The launch intents a UI test can ask for without driving the menu bar to get them.
///
Expand All @@ -21,6 +22,9 @@ internal enum UITestLaunchEnvironment {
internal static let sampleDatabaseVariable = "TABLEPRO_UI_TEST_OPEN_SAMPLE"
internal static let welcomeSheetVariable = "TABLEPRO_UI_TEST_SHOW_WELCOME_SHEET"
internal static let dataFileVariable = "TABLEPRO_UI_TEST_OPEN_FILE"
internal static let openURLVariable = "TABLEPRO_UI_TEST_OPEN_URL"

private static let logger = Logger(subsystem: "com.TablePro", category: "UITestLaunchEnvironment")

internal static var requestsWelcomeSheet: Bool {
isSet(welcomeSheetVariable)
Expand All @@ -30,17 +34,36 @@ internal enum UITestLaunchEnvironment {
/// counts as one somebody asked for. `runStartupBehaviorIfNeeded(skipping:)` skips a launch
/// that carries intents, which is what stops the startup behaviour from racing the sample
/// window onto the screen 150ms later.
internal static var launchIntents: [LaunchIntent] {
@MainActor internal static var launchIntents: [LaunchIntent] {
var intents: [LaunchIntent] = []
if isSet(sampleDatabaseVariable) {
intents.append(.openSampleDatabase)
}
if let dataFileURL {
intents.append(.openDataFile(dataFileURL))
}
if let openURLIntent {
intents.append(openURLIntent)
}
return intents
}

/// Classified like a URL macOS hands the app, without going through LaunchServices, which
/// would deliver it to whichever TablePro is registered for the scheme.
@MainActor private static var openURLIntent: LaunchIntent? {
guard let raw = value(of: openURLVariable), let url = URL(string: raw) else { return nil }
switch URLClassifier.classify(url) {
case .some(.success(let intent)):
return intent
case .some(.failure(let error)):
logger.error("UI test URL did not parse: \(error.publicLogShape, privacy: .public)")
return nil
case .none:
logger.error("UI test URL is not one the app opens")
return nil
}
}

private static var dataFileURL: URL? {
guard let path = value(of: dataFileVariable), path.hasPrefix("/") else { return nil }
return URL(fileURLWithPath: path)
Expand Down
45 changes: 45 additions & 0 deletions TablePro/Views/Settings/SettingsPane+URLIdentifier.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
//
// SettingsPane+URLIdentifier.swift
// TablePro
//

import Foundation

/// The ids in `tablepro://settings/<id>` links. They are public and documented, so they never
/// follow a pane's title or rawValue.
internal extension SettingsPane {
var urlIdentifier: String {
switch self {
case .general: "general"
case .appearance: "appearance"
case .editor: "editor"
case .data: "data"
case .keyboard: "keyboard"
case .profiles: "profiles"
case .notifications: "notifications"
case .ai: "ai"
case .mcp: "mcp"
case .plugins: "plugins"
case .sync: "sync"
case .account: "license"
}
}

init?(urlIdentifier: String) {
switch urlIdentifier {
case "general": self = .general
case "appearance": self = .appearance
case "editor": self = .editor
case "data": self = .data
case "keyboard": self = .keyboard
case "profiles": self = .profiles
case "notifications": self = .notifications
case "ai": self = .ai
case "mcp": self = .mcp
case "plugins": self = .plugins
case "sync": self = .sync
case "license": self = .account
default: return nil
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ struct AppActivationPolicyTests {
#expect(LaunchIntent.openDataFile(URL(fileURLWithPath: "/tmp/rows.csv")).impliesUserInterface)
#expect(LaunchIntent.openConnectionShare(URL(string: "tablepro://share")!).impliesUserInterface)
#expect(LaunchIntent.installPlugin(URL(string: "tablepro://plugin")!).impliesUserInterface)
#expect(LaunchIntent.openSettings(.mcp).impliesUserInterface)
#expect(LaunchIntent.openSettings(nil).impliesUserInterface)
#expect(
LaunchIntent.openTable(
connectionId: UUID(), database: nil, schema: nil, table: "users", isView: false
Expand Down Expand Up @@ -84,6 +86,15 @@ struct AppActivationPolicyTests {
)
}

@Test("A launch from a settings link is the person's own")
func settingsLinkLaunchIsUser() {
#expect(
AppActivationPolicyDecision.origin(
adopting: [.openSettings(.mcp)], current: .machine, isLaunching: true
) == .user
)
}

@Test("A machine session is in the Dock exactly while it has a window to show")
func machineSessionFollowsItsWindows() {
#expect(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
//
// DeeplinkSettingsParsingTests.swift
// TableProTests
//

import Foundation
@testable import TablePro
import Testing

@MainActor
struct DeeplinkSettingsParsingTests {
private func pane(for link: String) throws -> SettingsPane? {
let url = try #require(URL(string: link))
let outcome = URLClassifier.classify(url)
guard case .some(.success(.openSettings(let pane))) = outcome else {
Issue.record("Expected .openSettings for \(link), got \(String(describing: outcome))")
return nil
}
return pane
}

@Test("A bare link asks for no pane", arguments: ["tablepro://settings", "tablepro://settings/"])
func bareLinkAsksForNoPane(link: String) throws {
#expect(try pane(for: link) == nil)
}

@Test("A known id opens its pane")
func knownIdOpensItsPane() throws {
#expect(try pane(for: "tablepro://settings/mcp") == .mcp)
#expect(try pane(for: "tablepro://settings/plugins") == .plugins)
#expect(try pane(for: "tablepro://settings/license") == .account)
}

@Test(
"An unknown id asks for no pane instead of failing",
arguments: ["tablepro://settings/nope", "tablepro://settings/MCP", "tablepro://settings/account"]
)
func unknownIdAsksForNoPane(link: String) throws {
#expect(try pane(for: link) == nil)
}

@Test("Path segments after the pane are ignored")
func extraSegmentsAreIgnored() throws {
#expect(try pane(for: "tablepro://settings/mcp/authentication") == .mcp)
#expect(try pane(for: "tablepro://settings/ai/providers/openai/") == .ai)
}

@Test("Query items are ignored")
func queryIsIgnored() throws {
#expect(try pane(for: "tablepro://settings/sync?section=status") == .sync)
#expect(try pane(for: "tablepro://settings?pane=mcp") == nil)
}

@Test("A percent-encoded id is decoded before matching")
func percentEncodedIdIsDecoded() throws {
#expect(try pane(for: "tablepro://settings/%6Dcp") == .mcp)
}
}
12 changes: 12 additions & 0 deletions TableProTests/Core/Services/Infrastructure/WindowOpenerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,18 @@ final class WindowOpenerTests: XCTestCase {
XCTAssertEqual(opened, [.ai])
}

func testASettingsLinkRoutesItsPaneToThePresenter() async {
await LaunchIntentRouter.shared.route(.openSettings(.mcp))

XCTAssertEqual(openedSettingsPanes, [.mcp])
}

func testABareSettingsLinkAsksForNoPane() async {
await LaunchIntentRouter.shared.route(.openSettings(nil))

XCTAssertEqual(openedSettingsPanes, [SettingsPane?.none])
}

func testCompareSyncCarriesThePrefilledSourceToThePresenter() {
let connectionId = UUID()

Expand Down
56 changes: 56 additions & 0 deletions TableProTests/Views/Settings/SettingsPaneURLIdentifierTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
//
// SettingsPaneURLIdentifierTests.swift
// TableProTests
//

@testable import TablePro
import Testing

struct SettingsPaneURLIdentifierTests {
// Published in docs/developers/url-scheme.mdx. Changing a value breaks links in other apps.
private static let published: [SettingsPane: String] = [
.general: "general",
.appearance: "appearance",
.editor: "editor",
.data: "data",
.keyboard: "keyboard",
.profiles: "profiles",
.notifications: "notifications",
.ai: "ai",
.mcp: "mcp",
.plugins: "plugins",
.sync: "sync",
.account: "license",
]

@Test("Every pane has the published id")
func everyPaneHasThePublishedId() {
#expect(Self.published.count == SettingsPane.allCases.count)
for pane in SettingsPane.allCases {
#expect(pane.urlIdentifier == Self.published[pane])
}
}

@Test("Every id maps back to its pane", arguments: SettingsPane.allCases)
func roundTrip(pane: SettingsPane) {
#expect(SettingsPane(urlIdentifier: pane.urlIdentifier) == pane)
}

@Test("Ids are unique")
func idsAreUnique() {
let ids = SettingsPane.allCases.map(\.urlIdentifier)
#expect(Set(ids).count == ids.count)
}

@Test("The License pane is license, not its stored rawValue")
func licenseIsIndependentOfRawValue() {
#expect(SettingsPane.account.urlIdentifier == "license")
#expect(SettingsPane(urlIdentifier: "license") == .account)
#expect(SettingsPane(urlIdentifier: "account") == nil)
}

@Test("Matching is exact and lowercase", arguments: ["MCP", "Mcp", " mcp", "mcp ", "Integrations", "integrations", ""])
func matchingIsExact(raw: String) {
#expect(SettingsPane(urlIdentifier: raw) == nil)
}
}
28 changes: 28 additions & 0 deletions TableProUITests/SettingsDeeplinkUITests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import XCTest

final class SettingsDeeplinkUITests: UITestCase {
// Pane titles are localized.
private let englishArguments = ["-AppleLanguages", "(en)"]

func testASettingsLinkOpensItsPane() throws {
let app = try launchOpeningURL("tablepro://settings/license", arguments: englishArguments)

let settingsWindow = app.windows["settings"]
XCTAssertTrue(settingsWindow.waitToExist(timeout: 10))
XCTAssertTrue(
waitForPredicate(timeout: 5) { settingsWindow.title == "License" },
"The link has to open the License pane, the window is titled \(settingsWindow.title)"
)
}

func testAnUnknownPaneOpensTheLastUsedPane() throws {
let app = try launchOpeningURL("tablepro://settings/not-a-pane", arguments: englishArguments)

let settingsWindow = app.windows["settings"]
XCTAssertTrue(settingsWindow.waitToExist(timeout: 10))
XCTAssertTrue(
waitForPredicate(timeout: 5) { settingsWindow.title == "General" },
"A fresh sandbox has no last-used pane, so Settings opens on General, not \(settingsWindow.title)"
)
}
}
Loading
Loading