diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c962ee3a3..b98185b18c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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/` links that open Settings on a pane. ### Changed diff --git a/TablePro/Core/Services/Infrastructure/AppActivationPolicy.swift b/TablePro/Core/Services/Infrastructure/AppActivationPolicy.swift index dbdb5686d3..77257be5e5 100644 --- a/TablePro/Core/Services/Infrastructure/AppActivationPolicy.swift +++ b/TablePro/Core/Services/Infrastructure/AppActivationPolicy.swift @@ -85,7 +85,8 @@ internal extension LaunchIntent { .openDatabaseURL, .installPlugin, .reopenClosedTab, - .openSampleDatabase: + .openSampleDatabase, + .openSettings: return true } } diff --git a/TablePro/Core/Services/Infrastructure/DeeplinkParser.swift b/TablePro/Core/Services/Infrastructure/DeeplinkParser.swift index 8d52f157b0..9f54cd7ea3 100644 --- a/TablePro/Core/Services/Infrastructure/DeeplinkParser.swift +++ b/TablePro/Core/Services/Infrastructure/DeeplinkParser.swift @@ -4,6 +4,7 @@ // import Foundation +import os import TableProImport import TableProPluginKit @@ -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 { guard url.scheme == "tablepro" else { return .failure(.unknownScheme(url.scheme ?? "")) @@ -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 { + 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 { let segments = pathSegments(url) var cursor = PathCursor(segments: segments) diff --git a/TablePro/Core/Services/Infrastructure/LaunchIntent.swift b/TablePro/Core/Services/Infrastructure/LaunchIntent.swift index ae30e67d54..0f531924f2 100644 --- a/TablePro/Core/Services/Infrastructure/LaunchIntent.swift +++ b/TablePro/Core/Services/Infrastructure/LaunchIntent.swift @@ -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) @@ -49,6 +51,7 @@ internal enum LaunchIntent: @unchecked Sendable { .openConnectionShare, .pairIntegration, .startMCPServer, + .openSettings, .installPlugin: return nil } diff --git a/TablePro/Core/Services/Infrastructure/LaunchIntentRouter.swift b/TablePro/Core/Services/Infrastructure/LaunchIntentRouter.swift index 0d578c974d..63aa255739 100644 --- a/TablePro/Core/Services/Infrastructure/LaunchIntentRouter.swift +++ b/TablePro/Core/Services/Infrastructure/LaunchIntentRouter.swift @@ -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) @@ -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 } diff --git a/TablePro/Core/Testing/UITestLaunchEnvironment.swift b/TablePro/Core/Testing/UITestLaunchEnvironment.swift index ee950d2573..fbbe84bbc9 100644 --- a/TablePro/Core/Testing/UITestLaunchEnvironment.swift +++ b/TablePro/Core/Testing/UITestLaunchEnvironment.swift @@ -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. /// @@ -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) @@ -30,7 +34,7 @@ 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) @@ -38,9 +42,28 @@ internal enum UITestLaunchEnvironment { 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) diff --git a/TablePro/Views/Settings/SettingsPane+URLIdentifier.swift b/TablePro/Views/Settings/SettingsPane+URLIdentifier.swift new file mode 100644 index 0000000000..caf253d0b0 --- /dev/null +++ b/TablePro/Views/Settings/SettingsPane+URLIdentifier.swift @@ -0,0 +1,45 @@ +// +// SettingsPane+URLIdentifier.swift +// TablePro +// + +import Foundation + +/// The ids in `tablepro://settings/` 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 + } + } +} diff --git a/TableProTests/Core/Services/Infrastructure/AppActivationPolicyTests.swift b/TableProTests/Core/Services/Infrastructure/AppActivationPolicyTests.swift index 1a0112d6ac..6f00727893 100644 --- a/TableProTests/Core/Services/Infrastructure/AppActivationPolicyTests.swift +++ b/TableProTests/Core/Services/Infrastructure/AppActivationPolicyTests.swift @@ -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 @@ -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( diff --git a/TableProTests/Core/Services/Infrastructure/DeeplinkSettingsParsingTests.swift b/TableProTests/Core/Services/Infrastructure/DeeplinkSettingsParsingTests.swift new file mode 100644 index 0000000000..7dd44cb6d1 --- /dev/null +++ b/TableProTests/Core/Services/Infrastructure/DeeplinkSettingsParsingTests.swift @@ -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) + } +} diff --git a/TableProTests/Core/Services/Infrastructure/WindowOpenerTests.swift b/TableProTests/Core/Services/Infrastructure/WindowOpenerTests.swift index af5d39f9df..2132427c87 100644 --- a/TableProTests/Core/Services/Infrastructure/WindowOpenerTests.swift +++ b/TableProTests/Core/Services/Infrastructure/WindowOpenerTests.swift @@ -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() diff --git a/TableProTests/Views/Settings/SettingsPaneURLIdentifierTests.swift b/TableProTests/Views/Settings/SettingsPaneURLIdentifierTests.swift new file mode 100644 index 0000000000..2a2d8f541e --- /dev/null +++ b/TableProTests/Views/Settings/SettingsPaneURLIdentifierTests.swift @@ -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) + } +} diff --git a/TableProUITests/SettingsDeeplinkUITests.swift b/TableProUITests/SettingsDeeplinkUITests.swift new file mode 100644 index 0000000000..a9d2471eb8 --- /dev/null +++ b/TableProUITests/SettingsDeeplinkUITests.swift @@ -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)" + ) + } +} diff --git a/TableProUITests/Support/UITestCase.swift b/TableProUITests/Support/UITestCase.swift index 5bc4baaef6..2630c9a1b0 100644 --- a/TableProUITests/Support/UITestCase.swift +++ b/TableProUITests/Support/UITestCase.swift @@ -143,6 +143,11 @@ internal class UITestCase: XCTestCase { return try launchApp(environment: ["TABLEPRO_UI_TEST_OPEN_FILE": fileURL.path]) } + /// `TABLEPRO_UI_TEST_OPEN_URL` reaches the app's own URL handling, never another installed copy. + internal func launchOpeningURL(_ url: String, arguments: [String] = []) throws -> XCUIApplication { + try launchApp(environment: ["TABLEPRO_UI_TEST_OPEN_URL": url], arguments: arguments) + } + /// Returning as soon as the launch was requested is what used to leave fourteen suites poking /// at a window that had no connection yet, and every one of those misses cost an XCUITest /// retry. The object browser having rows is the cheapest proof the connection is live. diff --git a/docs/developers/url-scheme.mdx b/docs/developers/url-scheme.mdx index eb1c31fe42..353f6a72f6 100644 --- a/docs/developers/url-scheme.mdx +++ b/docs/developers/url-scheme.mdx @@ -18,6 +18,7 @@ Raycast extension with `open()` from `@raycast/api`. | [`import?…`](#import-a-connection) | A review sheet that saves a new connection | | [`integrations/pair?…`](#start-pairing) | The pairing approval sheet | | [`integrations/start-mcp`](#lazy-start-the-mcp-server) | Nothing. Starts the MCP server and returns | +| [`settings/`](#open-a-settings-pane) | Settings on that pane, or on the last-used pane | ## Connection IDs are UUIDs @@ -156,11 +157,45 @@ and `type` are required; the rest of the connection form maps to query parameter open "tablepro://import?name=Staging&host=db.example.com&port=5432&type=postgresql&username=admin&database=mydb" ``` +## Open a settings pane + +```text +tablepro://settings +tablepro://settings/ +``` + +The bare link opens Settings on the last-used pane, the same as `Cmd+,`. With a pane id it opens that +pane. The link only navigates and never changes a setting. + +| Pane | id | +|---|---| +| General | `general` | +| Appearance | `appearance` | +| Editor | `editor` | +| Data | `data` | +| Keyboard | `keyboard` | +| Profiles | `profiles` | +| Notifications | `notifications` | +| AI | `ai` | +| MCP | `mcp` | +| Plugins | `plugins` | +| Sync | `sync` | +| License | `license` | + +Ids are lowercase and matched exactly, and they stay the same when a pane is renamed. An unknown id +opens the last-used pane with no error, so a link to a pane added in a later version still opens +Settings on an older one. Path segments and query items after the id are ignored. + +```bash +open "tablepro://settings/mcp" +``` + ## On iPhone and iPad The iOS app answers two of these paths: `connect/` opens the connection, and `connect//table/` opens it on that table. Any other path under a connection, such as -`database/…` or `query`, opens the connection alone. `import` and `integrations` links do nothing. +`database/…` or `query`, opens the connection alone. `import`, `integrations` and `settings` links do +nothing. ## Errors diff --git a/docs/developers/versioning.mdx b/docs/developers/versioning.mdx index 0009cc8a4f..d7d8897ee0 100644 --- a/docs/developers/versioning.mdx +++ b/docs/developers/versioning.mdx @@ -38,6 +38,8 @@ Within a major TablePro version every covered surface is additive only. Per surf ### Links and database URLs - A `tablepro://` path or parameter is never removed and never changes meaning. +- A [settings pane id](/developers/url-scheme#open-a-settings-pane) is never removed and keeps + opening the same pane, whatever the pane is called. - On iPhone and iPad, `connect/` and `connect//table/` keep working. Any other path under a connection opens the connection alone. - A scheme on [Connection URLs](/connections/urls) keeps opening the same database type, and a