From ca7f771a4d3f950b17fb421ea75995a00fe97730 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 13 Jul 2026 08:02:00 +0700 Subject: [PATCH 1/3] feat(connections): open a database from the terminal and DDEV (#1486) --- CHANGELOG.md | 1 + CLAUDE.md | 1 + .../CommandLineToolInstaller.swift | 112 +++++++++++ .../ExternalConnectionGate.swift | 35 ++++ .../ExternalConnectionPrompting.swift | 82 ++++++++ .../Services/Infrastructure/TabRouter.swift | 48 +---- .../ExternalConnectionTrustStore.swift | 74 ++++++++ .../Connection/ConnectionURLParser.swift | 2 +- .../ExternalConnectionTrustKey.swift | 65 +++++++ .../Views/Settings/GeneralSettingsView.swift | 4 + .../Sections/CommandLineToolSection.swift | 79 ++++++++ .../TrustedExternalConnectionsSection.swift | 48 +++++ .../CommandLineToolInstallerTests.swift | 114 +++++++++++ .../ExternalConnectionGateTests.swift | 123 ++++++++++++ .../ExternalConnectionTrustStoreTests.swift | 179 ++++++++++++++++++ .../Utilities/ConnectionURLParserTests.swift | 19 ++ docs/customization/settings.mdx | 16 ++ docs/databases/connection-urls.mdx | 4 + docs/docs.json | 1 + docs/external-api/terminal.mdx | 63 ++++++ 20 files changed, 1027 insertions(+), 43 deletions(-) create mode 100644 TablePro/Core/Services/Infrastructure/CommandLineToolInstaller.swift create mode 100644 TablePro/Core/Services/Infrastructure/ExternalConnectionGate.swift create mode 100644 TablePro/Core/Services/Infrastructure/ExternalConnectionPrompting.swift create mode 100644 TablePro/Core/Storage/ExternalConnectionTrustStore.swift create mode 100644 TablePro/Core/Utilities/Connection/ExternalConnectionTrustKey.swift create mode 100644 TablePro/Views/Settings/Sections/CommandLineToolSection.swift create mode 100644 TablePro/Views/Settings/Sections/TrustedExternalConnectionsSection.swift create mode 100644 TableProTests/Core/Services/Infrastructure/CommandLineToolInstallerTests.swift create mode 100644 TableProTests/Core/Services/Infrastructure/ExternalConnectionGateTests.swift create mode 100644 TableProTests/Core/Storage/ExternalConnectionTrustStoreTests.swift create mode 100644 docs/external-api/terminal.mdx diff --git a/CHANGELOG.md b/CHANGELOG.md index db5c4de85..b14228ab4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - The Favorites sidebar **+** menu now includes **New Query**, which opens an empty SQL query tab. - Manage database users, roles, and privileges on MySQL and PostgreSQL connections. Open **View > Users & Roles** to see the accounts on the server, pick an object from the server down through databases, schemas, tables, and columns, and grant or revoke privileges on it. The privilege list shows where access actually comes from, including privileges inherited from a role or from a parent object. Changes are staged, undoable with ⌘Z, and shown as the exact SQL before they run. (#1413) - Bring back a tab you closed by mistake with **File > Reopen Closed Tab** (`Cmd+Shift+T`), or pick an older one from **File > Recently Closed**. The last 20 closed query and table tabs are kept for 30 days, with their SQL, cursor position, and database context. (#1854) +- Open a database from the terminal. Install a `tablepro` command from **Settings > General**, and run `ddev tablepro` in a DDEV project to open its database. A link to a database on your own machine can now be trusted with **Always Allow**, so it stops asking every time; review trusted links in **Settings > General**. (#1486) ### Changed diff --git a/CLAUDE.md b/CLAUDE.md index 4ca28f14a..1c168b89d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -186,6 +186,7 @@ Missing a case produces a wrong "{Language} Query" title on the first frame. | Favorite tables | UserDefaults | `FavoriteTablesStorage` (per connection + database + schema; iCloud-synced) | | Tree database filter | UserDefaults | `DatabaseTreeFilterStorage` (per connection; selected database set, empty = show all; device-local). Live value held in `SharedSidebarState`. | | Recent tables | UserDefaults | `RecentTablesStore` (per connection, keyed by database, last 10 each; device-local). Live value held in `SharedSidebarState`, recorded at the `QueryTabManager` open chokepoint. | +| Trusted external links | UserDefaults | `ExternalConnectionTrustStore` (keyed by database type + host + database + username + URL `name`, never the port; loopback hosts only, enforced on read and write). Consulted by `ExternalConnectionGate` before the external-URL confirmation alert. | ### Logging & Debugging diff --git a/TablePro/Core/Services/Infrastructure/CommandLineToolInstaller.swift b/TablePro/Core/Services/Infrastructure/CommandLineToolInstaller.swift new file mode 100644 index 000000000..449e1b809 --- /dev/null +++ b/TablePro/Core/Services/Infrastructure/CommandLineToolInstaller.swift @@ -0,0 +1,112 @@ +// +// CommandLineToolInstaller.swift +// TablePro +// + +import Foundation +import os + +internal enum CommandLineToolStatus: Equatable, Sendable { + case notInstalled + case installed + case conflict +} + +internal enum CommandLineToolError: Error, LocalizedError, Equatable { + case directoryNotWritable(String) + case conflict(String) + case writeFailed(String) + + internal var errorDescription: String? { + switch self { + case .directoryNotWritable(let path): + return String(format: String(localized: "%@ is not writable."), path) + case .conflict(let path): + return String(format: String(localized: "A different file already exists at %@."), path) + case .writeFailed(let reason): + return reason + } + } +} + +@MainActor +internal protocol CommandLineToolInstalling { + var toolPath: String { get } + var status: CommandLineToolStatus { get } + var manualInstallCommand: String { get } + func install() throws + func uninstall() throws +} + +@MainActor +internal final class CommandLineToolInstaller: CommandLineToolInstalling { + internal static let shared = CommandLineToolInstaller() + + private static let logger = Logger(subsystem: "com.TablePro", category: "CommandLineToolInstaller") + private static let toolName = "tablepro" + private static let marker = "# TablePro command line tool" + private static let scriptContents = """ + #!/bin/sh + \(marker) + exec open -b com.TablePro "$@" + + """ + + private let directory: String + private let fileManager: FileManager + + internal init(directory: String = "/usr/local/bin", fileManager: FileManager = .default) { + self.directory = directory + self.fileManager = fileManager + } + + internal var toolPath: String { + (directory as NSString).appendingPathComponent(Self.toolName) + } + + internal var status: CommandLineToolStatus { + guard fileManager.fileExists(atPath: toolPath) else { return .notInstalled } + guard let contents = try? String(contentsOfFile: toolPath, encoding: .utf8), + contents.contains(Self.marker) + else { return .conflict } + return .installed + } + + internal var manualInstallCommand: String { + let escaped = Self.scriptContents.replacingOccurrences(of: "\n", with: "\\n") + return "sudo mkdir -p \(directory) && printf '\(escaped)' | sudo tee \(toolPath) > /dev/null" + + " && sudo chmod 755 \(toolPath)" + } + + internal func install() throws { + guard status != .conflict else { throw CommandLineToolError.conflict(toolPath) } + guard fileManager.fileExists(atPath: directory), fileManager.isWritableFile(atPath: directory) else { + throw CommandLineToolError.directoryNotWritable(directory) + } + + do { + try Self.scriptContents.write(toFile: toolPath, atomically: true, encoding: .utf8) + try fileManager.setAttributes([.posixPermissions: 0o755], ofItemAtPath: toolPath) + } catch { + Self.logger.error("Failed to install command line tool: \(error.localizedDescription, privacy: .public)") + throw CommandLineToolError.writeFailed(error.localizedDescription) + } + Self.logger.info("Installed command line tool at \(self.toolPath, privacy: .public)") + } + + internal func uninstall() throws { + switch status { + case .notInstalled: + return + case .conflict: + throw CommandLineToolError.conflict(toolPath) + case .installed: + do { + try fileManager.removeItem(atPath: toolPath) + } catch { + throw CommandLineToolError.writeFailed(error.localizedDescription) + } + Self.logger.info("Removed command line tool at \(self.toolPath, privacy: .public)") + } + } +} diff --git a/TablePro/Core/Services/Infrastructure/ExternalConnectionGate.swift b/TablePro/Core/Services/Infrastructure/ExternalConnectionGate.swift new file mode 100644 index 000000000..ef634d2c9 --- /dev/null +++ b/TablePro/Core/Services/Infrastructure/ExternalConnectionGate.swift @@ -0,0 +1,35 @@ +// +// ExternalConnectionGate.swift +// TablePro +// + +import Foundation + +@MainActor +internal struct ExternalConnectionGate { + private let trustStore: ExternalConnectionTrustChecking + private let prompt: ExternalConnectionPrompting + + internal init( + trustStore: ExternalConnectionTrustChecking? = nil, + prompt: ExternalConnectionPrompting? = nil + ) { + self.trustStore = trustStore ?? ExternalConnectionTrustStore.shared + self.prompt = prompt ?? ExternalConnectionAlertPrompt() + } + + internal func authorize(_ connection: DatabaseConnection, scopeName: String?) async -> Bool { + let key = ExternalConnectionTrustKey(connection: connection, scopeName: scopeName) + if key.isLoopbackHost, trustStore.isTrusted(key) { return true } + + switch await prompt.prompt(for: connection, offerAlwaysAllow: key.isLoopbackHost) { + case .connect: + return true + case .alwaysAllow: + trustStore.trust(key) + return true + case .cancel: + return false + } + } +} diff --git a/TablePro/Core/Services/Infrastructure/ExternalConnectionPrompting.swift b/TablePro/Core/Services/Infrastructure/ExternalConnectionPrompting.swift new file mode 100644 index 000000000..e1d625b2e --- /dev/null +++ b/TablePro/Core/Services/Infrastructure/ExternalConnectionPrompting.swift @@ -0,0 +1,82 @@ +// +// ExternalConnectionPrompting.swift +// TablePro +// + +import AppKit +import Foundation + +internal enum ExternalConnectionDecision: Sendable { + case connect + case alwaysAllow + case cancel +} + +@MainActor +internal protocol ExternalConnectionPrompting { + func prompt(for connection: DatabaseConnection, offerAlwaysAllow: Bool) async -> ExternalConnectionDecision +} + +@MainActor +internal struct ExternalConnectionAlertPrompt: ExternalConnectionPrompting { + internal func prompt( + for connection: DatabaseConnection, + offerAlwaysAllow: Bool + ) async -> ExternalConnectionDecision { + 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: + + %@ + + Connect only if you trust the source of this link. + """), + connection.type.rawValue, + details(for: connection).joined(separator: "\n") + ) + alert.alertStyle = .warning + alert.addButton(withTitle: String(localized: "Connect")) + alert.addButton(withTitle: String(localized: "Cancel")) + if offerAlwaysAllow { + alert.addButton(withTitle: String(localized: "Always Allow")) + } + alert.buttons[0].keyEquivalent = "" + alert.buttons[1].keyEquivalent = "\u{1b}" + + let response = await present(alert) + switch response { + case .alertFirstButtonReturn: + return .connect + case .alertThirdButtonReturn where offerAlwaysAllow: + return .alwaysAllow + default: + return .cancel + } + } + + private func details(for connection: DatabaseConnection) -> [String] { + var details: [String] = [ + String(format: String(localized: "Host: %@"), "\(connection.host):\(connection.port)") + ] + if !connection.username.isEmpty { + details.append(String(format: String(localized: "User: %@"), connection.username)) + } + if !connection.database.isEmpty { + details.append(String(format: String(localized: "Database: %@"), connection.database)) + } + return details + } + + private func present(_ alert: NSAlert) async -> NSApplication.ModalResponse { + guard let window = AlertHelper.resolveWindow(NSApp.keyWindow) else { + return alert.runModal() + } + return await withCheckedContinuation { continuation in + alert.beginSheetModal(for: window) { response in + continuation.resume(returning: response) + } + } + } +} diff --git a/TablePro/Core/Services/Infrastructure/TabRouter.swift b/TablePro/Core/Services/Infrastructure/TabRouter.swift index b9fba41e6..55c459094 100644 --- a/TablePro/Core/Services/Infrastructure/TabRouter.swift +++ b/TablePro/Core/Services/Infrastructure/TabRouter.swift @@ -37,7 +37,11 @@ internal final class TabRouter { private static let logger = Logger(subsystem: "com.TablePro", category: "TabRouter") - private init() {} + private let externalConnectionGate: ExternalConnectionGate + + private init(externalConnectionGate: ExternalConnectionGate? = nil) { + self.externalConnectionGate = externalConnectionGate ?? ExternalConnectionGate() + } internal func route(_ intent: LaunchIntent) async throws { switch intent { @@ -269,7 +273,7 @@ internal final class TabRouter { isTransient = true } - guard await confirmExternalDatabaseConnection(connection) else { + guard await externalConnectionGate.authorize(connection, scopeName: parsed.connectionName) else { throw TabRouterError.userCancelled } @@ -309,46 +313,6 @@ internal final class TabRouter { } } - private func confirmExternalDatabaseConnection(_ connection: DatabaseConnection) async -> Bool { - var details: [String] = [ - String(format: String(localized: "Host: %@"), "\(connection.host):\(connection.port)") - ] - if !connection.username.isEmpty { - details.append(String(format: String(localized: "User: %@"), connection.username)) - } - if !connection.database.isEmpty { - details.append(String(format: String(localized: "Database: %@"), connection.database)) - } - - 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: - - %@ - - Connect only if you trust the source of this link. - """), - connection.type.rawValue, - details.joined(separator: "\n") - ) - alert.alertStyle = .warning - alert.addButton(withTitle: String(localized: "Connect")) - alert.addButton(withTitle: String(localized: "Cancel")) - alert.buttons[1].keyEquivalent = "\r" - alert.buttons[0].keyEquivalent = "" - - if let window = AlertHelper.resolveWindow(NSApp.keyWindow) { - return await withCheckedContinuation { continuation in - alert.beginSheetModal(for: window) { response in - continuation.resume(returning: response == .alertFirstButtonReturn) - } - } - } - return alert.runModal() == .alertFirstButtonReturn - } - // MARK: - Database File private func openDatabaseFile(_ url: URL, type: DatabaseType) async throws { diff --git a/TablePro/Core/Storage/ExternalConnectionTrustStore.swift b/TablePro/Core/Storage/ExternalConnectionTrustStore.swift new file mode 100644 index 000000000..6a18ee382 --- /dev/null +++ b/TablePro/Core/Storage/ExternalConnectionTrustStore.swift @@ -0,0 +1,74 @@ +// +// ExternalConnectionTrustStore.swift +// TablePro +// + +import Foundation +import os + +internal struct TrustedExternalConnection: Codable, Hashable, Identifiable, Sendable { + internal let key: ExternalConnectionTrustKey + internal let trustedAt: Date + + internal var id: ExternalConnectionTrustKey { key } +} + +@MainActor +internal protocol ExternalConnectionTrustChecking { + func isTrusted(_ key: ExternalConnectionTrustKey) -> Bool + func trust(_ key: ExternalConnectionTrustKey) +} + +@MainActor +internal final class ExternalConnectionTrustStore: ExternalConnectionTrustChecking { + internal static let shared = ExternalConnectionTrustStore() + + private static let logger = Logger(subsystem: "com.TablePro", category: "ExternalConnectionTrustStore") + private static let storageKey = "com.TablePro.externalConnectionTrust.entries" + + private let defaults: UserDefaults + + internal init(defaults: UserDefaults = .standard) { + self.defaults = defaults + } + + internal func isTrusted(_ key: ExternalConnectionTrustKey) -> Bool { + guard key.isLoopbackHost else { return false } + return entries().contains { $0.key == key } + } + + internal func trust(_ key: ExternalConnectionTrustKey) { + guard key.isLoopbackHost else { + Self.logger.error("Refused to trust non-loopback external connection") + return + } + var updated = entries().filter { $0.key != key } + updated.append(TrustedExternalConnection(key: key, trustedAt: Date())) + save(updated) + Self.logger.info("Trusted external connection \(key.displayDescription, privacy: .public)") + } + + internal func revoke(_ key: ExternalConnectionTrustKey) { + save(entries().filter { $0.key != key }) + } + + internal func revokeAll() { + defaults.removeObject(forKey: Self.storageKey) + } + + internal func entries() -> [TrustedExternalConnection] { + guard let data = defaults.data(forKey: Self.storageKey), + let decoded = try? JSONDecoder().decode([TrustedExternalConnection].self, from: data) + else { return [] } + return decoded.filter { $0.key.isLoopbackHost } + } + + private func save(_ entries: [TrustedExternalConnection]) { + guard !entries.isEmpty else { + defaults.removeObject(forKey: Self.storageKey) + return + } + guard let data = try? JSONEncoder().encode(entries) else { return } + defaults.set(data, forKey: Self.storageKey) + } +} diff --git a/TablePro/Core/Utilities/Connection/ConnectionURLParser.swift b/TablePro/Core/Utilities/Connection/ConnectionURLParser.swift index 0772d38ca..51badce98 100644 --- a/TablePro/Core/Utilities/Connection/ConnectionURLParser.swift +++ b/TablePro/Core/Utilities/Connection/ConnectionURLParser.swift @@ -595,7 +595,7 @@ struct ConnectionURLParser { ext.authSource = value case "statuscolor": ext.statusColor = value - case "env": + case "env", "enviroment", "environment": ext.envTag = value.removingPercentEncoding ?? value case "schema": ext.schema = value diff --git a/TablePro/Core/Utilities/Connection/ExternalConnectionTrustKey.swift b/TablePro/Core/Utilities/Connection/ExternalConnectionTrustKey.swift new file mode 100644 index 000000000..4f6540200 --- /dev/null +++ b/TablePro/Core/Utilities/Connection/ExternalConnectionTrustKey.swift @@ -0,0 +1,65 @@ +// +// ExternalConnectionTrustKey.swift +// TablePro +// + +import Foundation + +internal struct ExternalConnectionTrustKey: Hashable, Codable, Sendable { + internal let databaseType: String + internal let host: String + internal let database: String + internal let username: String + internal let scopeName: String + + private static let loopbackHosts: Set = ["localhost", "127.0.0.1", "::1", "[::1]"] + + internal init(databaseType: String, host: String, database: String, username: String, scopeName: String) { + self.databaseType = databaseType.lowercased() + self.host = host.trimmingCharacters(in: .whitespaces).lowercased() + self.database = database + self.username = username + self.scopeName = scopeName.trimmingCharacters(in: .whitespaces) + } + + internal init(connection: DatabaseConnection, scopeName: String?) { + self.init( + databaseType: connection.type.rawValue, + host: connection.host, + database: connection.database, + username: connection.username, + scopeName: scopeName ?? "" + ) + } + + internal var isLoopbackHost: Bool { + var normalized = host + while normalized.hasSuffix(".") { normalized.removeLast() } + if Self.loopbackHosts.contains(normalized) { return true } + return Self.isLoopbackIPv4(normalized) + } + + private static func isLoopbackIPv4(_ host: String) -> Bool { + let octets = host.split(separator: ".", omittingEmptySubsequences: false) + guard octets.count == 4 else { return false } + for octet in octets { + guard !octet.isEmpty, + octet.allSatisfy({ $0.isASCII && $0.isNumber }), + let value = Int(octet), value <= 255 + else { return false } + } + return Int(octets[0]) == 127 + } + + internal var displayDescription: String { + var target = host + if !username.isEmpty { + target = "\(username)@\(host)" + } + if !database.isEmpty { + target += "/\(database)" + } + guard !scopeName.isEmpty else { return "\(databaseType) \(target)" } + return "\(databaseType) \(target) (\(scopeName))" + } +} diff --git a/TablePro/Views/Settings/GeneralSettingsView.swift b/TablePro/Views/Settings/GeneralSettingsView.swift index 7e81ed8d1..356a7cd84 100644 --- a/TablePro/Views/Settings/GeneralSettingsView.swift +++ b/TablePro/Views/Settings/GeneralSettingsView.swift @@ -78,6 +78,10 @@ struct GeneralSettingsView: View { HistorySection(settings: $historySettings) + CommandLineToolSection() + + TrustedExternalConnectionsSection() + Section("Software Update") { Toggle("Automatically check for updates", isOn: $settings.automaticallyCheckForUpdates) .onChange(of: settings.automaticallyCheckForUpdates) { _, newValue in diff --git a/TablePro/Views/Settings/Sections/CommandLineToolSection.swift b/TablePro/Views/Settings/Sections/CommandLineToolSection.swift new file mode 100644 index 000000000..1d147be60 --- /dev/null +++ b/TablePro/Views/Settings/Sections/CommandLineToolSection.swift @@ -0,0 +1,79 @@ +// +// CommandLineToolSection.swift +// TablePro +// + +import SwiftUI + +struct CommandLineToolSection: View { + private let installer: CommandLineToolInstalling + + @State private var status: CommandLineToolStatus = .notInstalled + @State private var manualCommand: String? + + init(installer: CommandLineToolInstalling = CommandLineToolInstaller.shared) { + self.installer = installer + } + + var body: some View { + Section { + LabeledContent(installer.toolPath) { + switch status { + case .notInstalled: + Button("Install") { install() } + case .installed: + Button("Uninstall") { uninstall() } + case .conflict: + Text("A different file already exists here.") + .foregroundStyle(.secondary) + } + } + + if let manualCommand { + VStack(alignment: .leading, spacing: 6) { + Text("This folder is not writable. Run this command in Terminal, then reopen Settings.") + .font(.caption) + .foregroundStyle(.secondary) + + HStack(alignment: .top, spacing: 8) { + Text(manualCommand) + .font(.system(.caption, design: .monospaced)) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + + Button("Copy") { + ClipboardService.shared.writeText(manualCommand) + } + } + } + } + } header: { + Text("Command Line") + } footer: { + Text("Adds a tablepro command that opens database URLs from the terminal, such as a DDEV project database.") + } + .onAppear(perform: refresh) + } + + private func refresh() { + status = installer.status + } + + private func install() { + do { + try installer.install() + manualCommand = nil + } catch CommandLineToolError.conflict { + manualCommand = nil + } catch { + manualCommand = installer.manualInstallCommand + } + refresh() + } + + private func uninstall() { + try? installer.uninstall() + manualCommand = nil + refresh() + } +} diff --git a/TablePro/Views/Settings/Sections/TrustedExternalConnectionsSection.swift b/TablePro/Views/Settings/Sections/TrustedExternalConnectionsSection.swift new file mode 100644 index 000000000..6a7d0de63 --- /dev/null +++ b/TablePro/Views/Settings/Sections/TrustedExternalConnectionsSection.swift @@ -0,0 +1,48 @@ +// +// TrustedExternalConnectionsSection.swift +// TablePro +// + +import SwiftUI + +struct TrustedExternalConnectionsSection: View { + private let store: ExternalConnectionTrustStore + + @State private var entries: [TrustedExternalConnection] = [] + + init(store: ExternalConnectionTrustStore = .shared) { + self.store = store + } + + var body: some View { + Section { + if entries.isEmpty { + Text("No links are trusted yet.") + .foregroundStyle(.secondary) + } else { + ForEach(entries) { entry in + LabeledContent(entry.key.displayDescription) { + Button("Forget") { + store.revoke(entry.key) + refresh() + } + } + } + + Button(String(localized: "Forget All"), role: .destructive) { + store.revokeAll() + refresh() + } + } + } header: { + Text("Trusted Links") + } footer: { + Text("Links you chose to always allow. TablePro connects without asking. Only connections on this machine can be trusted.") + } + .onAppear(perform: refresh) + } + + private func refresh() { + entries = store.entries().sorted { $0.trustedAt > $1.trustedAt } + } +} diff --git a/TableProTests/Core/Services/Infrastructure/CommandLineToolInstallerTests.swift b/TableProTests/Core/Services/Infrastructure/CommandLineToolInstallerTests.swift new file mode 100644 index 000000000..16e875ce2 --- /dev/null +++ b/TableProTests/Core/Services/Infrastructure/CommandLineToolInstallerTests.swift @@ -0,0 +1,114 @@ +import Foundation +@testable import TablePro +import Testing + +@MainActor +@Suite("CommandLineToolInstaller") +struct CommandLineToolInstallerTests { + private func makeDirectory() throws -> String { + let path = NSTemporaryDirectory() + .appending("CommandLineToolInstallerTests.\(UUID().uuidString)") + try FileManager.default.createDirectory(atPath: path, withIntermediateDirectories: true) + return path + } + + @Test("Reports not installed on a clean directory") + func notInstalledByDefault() throws { + let installer = CommandLineToolInstaller(directory: try makeDirectory()) + #expect(installer.status == .notInstalled) + } + + @Test("Install writes an executable shim that opens TablePro by bundle id") + func installWritesExecutableShim() throws { + let directory = try makeDirectory() + let installer = CommandLineToolInstaller(directory: directory) + + try installer.install() + + #expect(installer.status == .installed) + let contents = try String(contentsOfFile: installer.toolPath, encoding: .utf8) + #expect(contents.contains("open -b com.TablePro")) + + let attributes = try FileManager.default.attributesOfItem(atPath: installer.toolPath) + let permissions = try #require(attributes[.posixPermissions] as? NSNumber) + #expect(permissions.int16Value == 0o755) + } + + @Test("Uninstall removes the shim") + func uninstallRemovesShim() throws { + let installer = CommandLineToolInstaller(directory: try makeDirectory()) + try installer.install() + + try installer.uninstall() + + #expect(installer.status == .notInstalled) + #expect(FileManager.default.fileExists(atPath: installer.toolPath) == false) + } + + @Test("Uninstalling when nothing is installed is a no-op") + func uninstallWithoutInstallIsNoOp() throws { + let installer = CommandLineToolInstaller(directory: try makeDirectory()) + try installer.uninstall() + #expect(installer.status == .notInstalled) + } + + @Test("A foreign file at the same path is reported as a conflict and never overwritten") + func foreignFileConflicts() throws { + let directory = try makeDirectory() + let installer = CommandLineToolInstaller(directory: directory) + try "#!/bin/sh\necho not ours\n".write(toFile: installer.toolPath, atomically: true, encoding: .utf8) + + #expect(installer.status == .conflict) + #expect(throws: CommandLineToolError.conflict(installer.toolPath)) { + try installer.install() + } + + let contents = try String(contentsOfFile: installer.toolPath, encoding: .utf8) + #expect(contents.contains("not ours")) + } + + @Test("A foreign file is never removed by uninstall") + func foreignFileSurvivesUninstall() throws { + let installer = CommandLineToolInstaller(directory: try makeDirectory()) + try "echo not ours\n".write(toFile: installer.toolPath, atomically: true, encoding: .utf8) + + #expect(throws: CommandLineToolError.conflict(installer.toolPath)) { + try installer.uninstall() + } + #expect(FileManager.default.fileExists(atPath: installer.toolPath)) + } + + @Test("A read-only directory reports as not writable, which is what /usr/local/bin does without sudo") + func readOnlyDirectoryIsNotWritable() throws { + let directory = try makeDirectory() + try FileManager.default.setAttributes([.posixPermissions: 0o555], ofItemAtPath: directory) + defer { try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: directory) } + + let installer = CommandLineToolInstaller(directory: directory) + + #expect(throws: CommandLineToolError.directoryNotWritable(directory)) { + try installer.install() + } + #expect(installer.status == .notInstalled) + } + + @Test("A missing directory reports as not writable instead of crashing") + func missingDirectoryIsNotWritable() throws { + let missing = NSTemporaryDirectory().appending("missing.\(UUID().uuidString)") + let installer = CommandLineToolInstaller(directory: missing) + + #expect(throws: CommandLineToolError.directoryNotWritable(missing)) { + try installer.install() + } + } + + @Test("The manual command installs the same shim the app would write") + func manualCommandMatchesShim() throws { + let installer = CommandLineToolInstaller(directory: try makeDirectory()) + let command = installer.manualInstallCommand + + #expect(command.contains("sudo")) + #expect(command.contains(installer.toolPath)) + #expect(command.contains("open -b com.TablePro")) + } +} diff --git a/TableProTests/Core/Services/Infrastructure/ExternalConnectionGateTests.swift b/TableProTests/Core/Services/Infrastructure/ExternalConnectionGateTests.swift new file mode 100644 index 000000000..b9f1d22c0 --- /dev/null +++ b/TableProTests/Core/Services/Infrastructure/ExternalConnectionGateTests.swift @@ -0,0 +1,123 @@ +import Foundation +@testable import TablePro +import Testing + +@MainActor +private final class SpyPrompt: ExternalConnectionPrompting { + private let decision: ExternalConnectionDecision + + private(set) var callCount = 0 + private(set) var offeredAlwaysAllow: Bool? + + init(decision: ExternalConnectionDecision) { + self.decision = decision + } + + func prompt(for connection: DatabaseConnection, offerAlwaysAllow: Bool) async -> ExternalConnectionDecision { + callCount += 1 + offeredAlwaysAllow = offerAlwaysAllow + return decision + } +} + +@MainActor +@Suite("ExternalConnectionGate") +struct ExternalConnectionGateTests { + private func makeStore() throws -> ExternalConnectionTrustStore { + let suite = "ExternalConnectionGateTests.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + return ExternalConnectionTrustStore(defaults: defaults) + } + + private func ddevConnection(port: Int = 32_770) -> DatabaseConnection { + DatabaseConnection( + name: "ddev-shop", host: "127.0.0.1", port: port, + database: "db", username: "db", type: .mysql + ) + } + + private func remoteConnection() -> DatabaseConnection { + DatabaseConnection( + name: "Prod", host: "db.example.com", port: 3_306, + database: "shop", username: "root", type: .mysql + ) + } + + @Test("Connect authorizes once without persisting trust") + func connectDoesNotPersist() async throws { + let store = try makeStore() + let prompt = SpyPrompt(decision: .connect) + let gate = ExternalConnectionGate(trustStore: store, prompt: prompt) + + let authorized = await gate.authorize(ddevConnection(), scopeName: "ddev-shop") + + #expect(authorized) + #expect(store.entries().isEmpty) + #expect(prompt.callCount == 1) + #expect(prompt.offeredAlwaysAllow == true) + } + + @Test("Cancel refuses and persists nothing") + func cancelRefuses() async throws { + let store = try makeStore() + let prompt = SpyPrompt(decision: .cancel) + let gate = ExternalConnectionGate(trustStore: store, prompt: prompt) + + let authorized = await gate.authorize(ddevConnection(), scopeName: "ddev-shop") + + #expect(authorized == false) + #expect(store.entries().isEmpty) + } + + @Test("Always Allow persists trust and the next open never prompts") + func alwaysAllowSkipsSecondPrompt() async throws { + let store = try makeStore() + let prompt = SpyPrompt(decision: .alwaysAllow) + let gate = ExternalConnectionGate(trustStore: store, prompt: prompt) + + #expect(await gate.authorize(ddevConnection(), scopeName: "ddev-shop")) + #expect(prompt.callCount == 1) + + let restarted = ddevConnection(port: 49_153) + #expect(await gate.authorize(restarted, scopeName: "ddev-shop")) + #expect(prompt.callCount == 1) + } + + @Test("A remote host is never offered Always Allow") + func remoteHostGetsNoAlwaysAllow() async throws { + let store = try makeStore() + let prompt = SpyPrompt(decision: .connect) + let gate = ExternalConnectionGate(trustStore: store, prompt: prompt) + + _ = await gate.authorize(remoteConnection(), scopeName: "anything") + + #expect(prompt.offeredAlwaysAllow == false) + } + + @Test("A remote host always prompts, even if the prompt answers Always Allow") + func remoteHostAlwaysPrompts() async throws { + let store = try makeStore() + let prompt = SpyPrompt(decision: .alwaysAllow) + let gate = ExternalConnectionGate(trustStore: store, prompt: prompt) + + #expect(await gate.authorize(remoteConnection(), scopeName: "anything")) + #expect(store.entries().isEmpty) + + #expect(await gate.authorize(remoteConnection(), scopeName: "anything")) + #expect(prompt.callCount == 2) + } + + @Test("Trusting one DDEV project does not silence another") + func trustDoesNotLeakAcrossProjects() async throws { + let store = try makeStore() + let prompt = SpyPrompt(decision: .alwaysAllow) + let gate = ExternalConnectionGate(trustStore: store, prompt: prompt) + + _ = await gate.authorize(ddevConnection(), scopeName: "ddev-shop") + #expect(prompt.callCount == 1) + + _ = await gate.authorize(ddevConnection(port: 49_154), scopeName: "ddev-blog") + #expect(prompt.callCount == 2) + } +} diff --git a/TableProTests/Core/Storage/ExternalConnectionTrustStoreTests.swift b/TableProTests/Core/Storage/ExternalConnectionTrustStoreTests.swift new file mode 100644 index 000000000..e9f4457ce --- /dev/null +++ b/TableProTests/Core/Storage/ExternalConnectionTrustStoreTests.swift @@ -0,0 +1,179 @@ +import Foundation +@testable import TablePro +import Testing + +@MainActor +@Suite("ExternalConnectionTrustStore") +struct ExternalConnectionTrustStoreTests { + private func makeStore() throws -> ExternalConnectionTrustStore { + let suite = "ExternalConnectionTrustStoreTests.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + return ExternalConnectionTrustStore(defaults: defaults) + } + + private func loopbackKey(scopeName: String = "ddev-shop") -> ExternalConnectionTrustKey { + ExternalConnectionTrustKey( + databaseType: "MySQL", + host: "127.0.0.1", + database: "db", + username: "db", + scopeName: scopeName + ) + } + + @Test("Nothing is trusted by default") + func defaultsEmpty() throws { + let store = try makeStore() + #expect(store.entries().isEmpty) + #expect(store.isTrusted(loopbackKey()) == false) + } + + @Test("A loopback key round-trips") + func loopbackRoundTrip() throws { + let store = try makeStore() + store.trust(loopbackKey()) + #expect(store.isTrusted(loopbackKey())) + #expect(store.entries().count == 1) + } + + @Test("A different port still matches, because DDEV reassigns the host port on every start") + func portIsNotPartOfIdentity() throws { + let store = try makeStore() + let connection = DatabaseConnection( + name: "ddev-shop", host: "127.0.0.1", port: 32_770, + database: "db", username: "db", type: .mysql + ) + let restarted = DatabaseConnection( + name: "ddev-shop", host: "127.0.0.1", port: 49_153, + database: "db", username: "db", type: .mysql + ) + + store.trust(ExternalConnectionTrustKey(connection: connection, scopeName: "ddev-shop")) + + #expect(store.isTrusted(ExternalConnectionTrustKey(connection: restarted, scopeName: "ddev-shop"))) + } + + @Test("Two DDEV projects sharing default credentials do not share trust") + func scopeNameSeparatesProjects() throws { + let store = try makeStore() + store.trust(loopbackKey(scopeName: "ddev-shop")) + + #expect(store.isTrusted(loopbackKey(scopeName: "ddev-blog")) == false) + } + + @Test("A remote host can never be trusted") + func remoteHostIsRefused() throws { + let store = try makeStore() + let remote = ExternalConnectionTrustKey( + databaseType: "MySQL", + host: "db.evil.example.com", + database: "db", + username: "db", + scopeName: "ddev-shop" + ) + + store.trust(remote) + + #expect(store.entries().isEmpty) + #expect(store.isTrusted(remote) == false) + } + + @Test("A poisoned defaults blob cannot make a remote host trusted") + func poisonedDefaultsAreIgnored() throws { + let suite = "ExternalConnectionTrustStoreTests.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + + let remote = ExternalConnectionTrustKey( + databaseType: "MySQL", + host: "db.evil.example.com", + database: "db", + username: "db", + scopeName: "" + ) + let poisoned = [TrustedExternalConnection(key: remote, trustedAt: Date())] + defaults.set(try JSONEncoder().encode(poisoned), forKey: "com.TablePro.externalConnectionTrust.entries") + + let store = ExternalConnectionTrustStore(defaults: defaults) + + #expect(store.isTrusted(remote) == false) + #expect(store.entries().isEmpty) + } + + @Test("Revoke removes a single entry") + func revokeOne() throws { + let store = try makeStore() + store.trust(loopbackKey(scopeName: "ddev-shop")) + store.trust(loopbackKey(scopeName: "ddev-blog")) + + store.revoke(loopbackKey(scopeName: "ddev-shop")) + + #expect(store.isTrusted(loopbackKey(scopeName: "ddev-shop")) == false) + #expect(store.isTrusted(loopbackKey(scopeName: "ddev-blog"))) + } + + @Test("Revoke all clears the store") + func revokeAll() throws { + let store = try makeStore() + store.trust(loopbackKey(scopeName: "ddev-shop")) + store.trust(loopbackKey(scopeName: "ddev-blog")) + + store.revokeAll() + + #expect(store.entries().isEmpty) + } + + @Test("Trusting the same key twice keeps one entry") + func trustIsIdempotent() throws { + let store = try makeStore() + store.trust(loopbackKey()) + store.trust(loopbackKey()) + + #expect(store.entries().count == 1) + } + + @Test("localhost and ::1 count as loopback") + func loopbackHostVariants() { + let hosts = ["localhost", "127.0.0.1", "127.0.0.53", "::1", "[::1]", "LOCALHOST", "localhost.", "127.0.0.1."] + for host in hosts { + let key = ExternalConnectionTrustKey( + databaseType: "MySQL", host: host, database: "db", username: "db", scopeName: "" + ) + #expect(key.isLoopbackHost, "\(host) should be loopback") + } + } + + @Test("A domain that merely starts with 127. is not loopback") + func lookalikeHostsAreNotLoopback() { + let hosts = [ + "127.evil.com", + "127.0.0.1.evil.com", + "localhost.evil.com", + "10.0.0.5", + "0x7f.0.0.1", + "db.example.com", + "127.0.0.256", + "1270.0.0.1" + ] + for host in hosts { + let key = ExternalConnectionTrustKey( + databaseType: "MySQL", host: host, database: "db", username: "db", scopeName: "" + ) + #expect(key.isLoopbackHost == false, "\(host) must not be loopback") + } + } + + @Test("A host that only looks like loopback cannot be trusted") + func lookalikeHostCannotBeTrusted() throws { + let store = try makeStore() + let lookalike = ExternalConnectionTrustKey( + databaseType: "MySQL", host: "127.evil.com", database: "db", username: "db", scopeName: "ddev-shop" + ) + + store.trust(lookalike) + + #expect(store.entries().isEmpty) + #expect(store.isTrusted(lookalike) == false) + } +} diff --git a/TableProTests/Core/Utilities/ConnectionURLParserTests.swift b/TableProTests/Core/Utilities/ConnectionURLParserTests.swift index bf0015f08..bf38ef48c 100644 --- a/TableProTests/Core/Utilities/ConnectionURLParserTests.swift +++ b/TableProTests/Core/Utilities/ConnectionURLParserTests.swift @@ -754,6 +754,25 @@ struct ConnectionURLParserTests { #expect(parsed.envTag == "production") } + @Test("Parse the Enviroment parameter that TablePlus and DDEV emit") + func testEnviromentParameterAlias() { + let result = ConnectionURLParser.parse("mysql://db:db@127.0.0.1:32770/db?Enviroment=local&Name=ddev-shop") + guard case .success(let parsed) = result else { + Issue.record("Expected success"); return + } + #expect(parsed.envTag == "local") + #expect(parsed.connectionName == "ddev-shop") + } + + @Test("Parse the correctly spelled environment parameter") + func testEnvironmentParameterAlias() { + let result = ConnectionURLParser.parse("postgresql://user@host/db?environment=staging") + guard case .success(let parsed) = result else { + Issue.record("Expected success"); return + } + #expect(parsed.envTag == "staging") + } + @Test("Parse schema parameter") func testSchemaParameter() { let result = ConnectionURLParser.parse("postgresql://user@host/db?schema=public") diff --git a/docs/customization/settings.mdx b/docs/customization/settings.mdx index 34d4577c1..299b9fc06 100644 --- a/docs/customization/settings.mdx +++ b/docs/customization/settings.mdx @@ -40,6 +40,22 @@ Maximum seconds a query runs before cancellation. Default 60, range 0-600 (0 mea Enforced at the database level where supported: `statement_timeout` (PostgreSQL), `max_execution_time` (MySQL, ClickHouse), `max_statement_time` (MariaDB), `sqlite3_busy_timeout` (SQLite). HTTP-based drivers (ClickHouse, BigQuery, CloudflareD1, LibSQL, Etcd, DynamoDB) also bound the HTTP request timeout to the configured value plus a 30-second grace, so the server-side error fires first. Setting "No limit" raises the HTTP transport ceiling to 1 hour. Oracle enforces the timeout client-side: a query that exceeds it is stopped by closing the connection, and the next query reconnects and restores the current schema automatically. Applies on new connections; change requires reconnect. +### Command Line + +| Setting | Default | Description | +|---------|---------|-------------| +| **Install** | Not installed | Writes a `tablepro` command to `/usr/local/bin` that opens database URLs in TablePro | + +If `/usr/local/bin` is not writable, a `sudo` command is shown for you to run. See [Terminal and DDEV](/external-api/terminal). + +### Trusted Links + +| Setting | Default | Description | +|---------|---------|-------------| +| **Forget** | Empty | Removes a link you chose to always allow | + +Only databases on your own machine can be trusted. Remote links ask for confirmation every time. + ### Software Update | Setting | Default | Description | diff --git a/docs/databases/connection-urls.mdx b/docs/databases/connection-urls.mdx index c6ea5b919..6d153f3dd 100644 --- a/docs/databases/connection-urls.mdx +++ b/docs/databases/connection-urls.mdx @@ -254,3 +254,7 @@ open "postgresql+ssh://ubuntu@bastion/user:pass@10.0.0.5/mydb?usePrivateKey=true ``` Uses a saved connection if it matches, otherwise creates a temporary session. To save permanently, click **New Connection** on the welcome screen and pick **Import from URL...** in the chooser footer. + + +To force TablePro when another client also handles the scheme, use `open -b com.TablePro "mysql://..."`. See [Terminal and DDEV](/external-api/terminal) for the `tablepro` command and a `ddev tablepro` host command. + diff --git a/docs/docs.json b/docs/docs.json index ce1dab436..37e3c423e 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -172,6 +172,7 @@ "pages": [ "external-api/index", "external-api/url-scheme", + "external-api/terminal", "external-api/ios-shortcuts", "external-api/mcp-tools", "external-api/mcp-resources", diff --git a/docs/external-api/terminal.mdx b/docs/external-api/terminal.mdx new file mode 100644 index 000000000..824d9a35e --- /dev/null +++ b/docs/external-api/terminal.mdx @@ -0,0 +1,63 @@ +--- +title: Terminal and DDEV +description: Open a database in TablePro from the shell, and add a ddev tablepro command to your DDEV projects +--- + +# Terminal and DDEV + +TablePro opens any database URL passed to it by the operating system. That makes it scriptable from the shell, and it is how the DDEV integration below works. + +## Open a database from the shell + +Pass a [connection URL](/databases/connection-urls) to `open`: + +```bash +open "mysql://root@127.0.0.1:3306/shop" +open "postgresql://user:pass@127.0.0.1:5432/app?table=orders" +``` + +TablePro uses a saved connection if one matches, otherwise it opens a temporary session that is never written to disk. + +### Force TablePro over another client + +`open "mysql://..."` goes to whichever app owns the `mysql://` scheme. If TablePlus or another client is installed, it may win. Target TablePro by its bundle identifier: + +```bash +open -b com.TablePro "mysql://root@127.0.0.1:3306/shop" +``` + +### Install the tablepro command + +Open **Settings > General > Command Line** and click **Install**. This writes a small `tablepro` script to `/usr/local/bin` that always opens TablePro: + +```bash +tablepro "mysql://root@127.0.0.1:3306/shop" +tablepro +``` + +If `/usr/local/bin` is not writable, Settings shows a `sudo` command you can copy and run instead. TablePro never asks for your admin password. + +## DDEV + +[DDEV](https://ddev.com) ships a `tablepro` command. From any project: + +```bash +cd my-project +ddev tablepro +``` + +DDEV adds the command automatically when TablePro is installed in `/Applications`. If `ddev tablepro` is not found, update DDEV. + +This is the right way to open a DDEV database, because DDEV gives the project a new host port on every `ddev start`. A saved connection goes stale; the command reads the current port each time. It works with both MySQL/MariaDB and PostgreSQL projects. DDEV's database, user, and password are all `db`. + +## Trusting a link + +The first time a link connects, TablePro asks you to confirm it. This stops a web page from silently opening a connection. + +For a database on your own machine (`localhost`, `127.0.0.1`, or `::1`), the alert also offers **Always Allow**. Choose it and TablePro stops asking for that database, so `ddev tablepro` connects straight away. Trust is keyed on the database type, host, database name, user, and the `name` parameter, but not the port, because DDEV changes the port on every start. + + +Only databases on your own machine can be trusted. A link to a remote host always asks, every time. + + +Review and remove trusted links in **Settings > General > Trusted Links**. A link that carries SQL (`?query=`), a filter (`?condition=`), or a pre-connect script still asks for confirmation, even when the connection is trusted. From 59ad55e2e87df8d88f86fd02827691818cb1e66e Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 13 Jul 2026 08:20:51 +0700 Subject: [PATCH 2/3] refactor(settings): surface command line tool errors and reuse the copyable code block --- .../CommandLineToolInstaller.swift | 9 ++- .../Sections/CommandLineToolSection.swift | 69 ++++++++++++------- .../CommandLineToolInstallerTests.swift | 23 +++++++ docs/external-api/terminal.mdx | 2 +- 4 files changed, 75 insertions(+), 28 deletions(-) diff --git a/TablePro/Core/Services/Infrastructure/CommandLineToolInstaller.swift b/TablePro/Core/Services/Infrastructure/CommandLineToolInstaller.swift index 449e1b809..f2a88a9be 100644 --- a/TablePro/Core/Services/Infrastructure/CommandLineToolInstaller.swift +++ b/TablePro/Core/Services/Infrastructure/CommandLineToolInstaller.swift @@ -34,6 +34,7 @@ internal protocol CommandLineToolInstalling { var toolPath: String { get } var status: CommandLineToolStatus { get } var manualInstallCommand: String { get } + var manualUninstallCommand: String { get } func install() throws func uninstall() throws } @@ -74,8 +75,12 @@ internal final class CommandLineToolInstaller: CommandLineToolInstalling { internal var manualInstallCommand: String { let escaped = Self.scriptContents.replacingOccurrences(of: "\n", with: "\\n") - return "sudo mkdir -p \(directory) && printf '\(escaped)' | sudo tee \(toolPath) > /dev/null" - + " && sudo chmod 755 \(toolPath)" + return "sudo mkdir -p \"\(directory)\" && printf '\(escaped)' | sudo tee \"\(toolPath)\" > /dev/null" + + " && sudo chmod 755 \"\(toolPath)\"" + } + + internal var manualUninstallCommand: String { + "sudo rm \"\(toolPath)\"" } internal func install() throws { diff --git a/TablePro/Views/Settings/Sections/CommandLineToolSection.swift b/TablePro/Views/Settings/Sections/CommandLineToolSection.swift index 1d147be60..62f1dbaa6 100644 --- a/TablePro/Views/Settings/Sections/CommandLineToolSection.swift +++ b/TablePro/Views/Settings/Sections/CommandLineToolSection.swift @@ -3,6 +3,7 @@ // TablePro // +import AppKit import SwiftUI struct CommandLineToolSection: View { @@ -10,6 +11,7 @@ struct CommandLineToolSection: View { @State private var status: CommandLineToolStatus = .notInstalled @State private var manualCommand: String? + @State private var errorMessage: String? init(installer: CommandLineToolInstalling = CommandLineToolInstaller.shared) { self.installer = installer @@ -17,63 +19,80 @@ struct CommandLineToolSection: View { var body: some View { Section { - LabeledContent(installer.toolPath) { + LabeledContent { switch status { case .notInstalled: Button("Install") { install() } case .installed: Button("Uninstall") { uninstall() } case .conflict: - Text("A different file already exists here.") - .foregroundStyle(.secondary) + Button("Install") { install() } + .disabled(true) } + } label: { + Text("tablepro command") + Text(installer.toolPath) + .font(.system(.caption, design: .monospaced)) } if let manualCommand { VStack(alignment: .leading, spacing: 6) { - Text("This folder is not writable. Run this command in Terminal, then reopen Settings.") + Text("This folder needs administrator access. Run this in Terminal:") .font(.caption) .foregroundStyle(.secondary) - HStack(alignment: .top, spacing: 8) { - Text(manualCommand) - .font(.system(.caption, design: .monospaced)) - .textSelection(.enabled) - .frame(maxWidth: .infinity, alignment: .leading) - - Button("Copy") { - ClipboardService.shared.writeText(manualCommand) - } - } + CopyableCodeBlock(text: manualCommand) } } + + if let errorMessage { + Text(errorMessage) + .font(.caption) + .foregroundStyle(.red) + } } header: { Text("Command Line") } footer: { - Text("Adds a tablepro command that opens database URLs from the terminal, such as a DDEV project database.") + Text("Opens database URLs from the terminal, such as a DDEV project database.") + } + .onAppear(perform: syncStatus) + .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in + syncStatus() } - .onAppear(perform: refresh) } - private func refresh() { - status = installer.status + private func syncStatus() { + let updated = installer.status + guard updated != status else { return } + status = updated + manualCommand = nil + errorMessage = nil } private func install() { do { try installer.install() manualCommand = nil - } catch CommandLineToolError.conflict { - manualCommand = nil - } catch { + errorMessage = nil + } catch CommandLineToolError.directoryNotWritable { manualCommand = installer.manualInstallCommand + errorMessage = nil + } catch { + manualCommand = nil + errorMessage = error.localizedDescription } - refresh() + status = installer.status } private func uninstall() { - try? installer.uninstall() - manualCommand = nil - refresh() + do { + try installer.uninstall() + manualCommand = nil + errorMessage = nil + } catch { + manualCommand = installer.manualUninstallCommand + errorMessage = error.localizedDescription + } + status = installer.status } } diff --git a/TableProTests/Core/Services/Infrastructure/CommandLineToolInstallerTests.swift b/TableProTests/Core/Services/Infrastructure/CommandLineToolInstallerTests.swift index 16e875ce2..aae725983 100644 --- a/TableProTests/Core/Services/Infrastructure/CommandLineToolInstallerTests.swift +++ b/TableProTests/Core/Services/Infrastructure/CommandLineToolInstallerTests.swift @@ -111,4 +111,27 @@ struct CommandLineToolInstallerTests { #expect(command.contains(installer.toolPath)) #expect(command.contains("open -b com.TablePro")) } + + @Test("Both manual commands quote the path they touch") + func manualCommandsQuoteThePath() throws { + let installer = CommandLineToolInstaller(directory: try makeDirectory()) + + #expect(installer.manualInstallCommand.contains("\"\(installer.toolPath)\"")) + #expect(installer.manualUninstallCommand == "sudo rm \"\(installer.toolPath)\"") + } + + @Test("Uninstalling a shim the app cannot remove fails loudly instead of silently") + func uninstallFromReadOnlyDirectoryThrows() throws { + let directory = try makeDirectory() + let installer = CommandLineToolInstaller(directory: directory) + try installer.install() + + try FileManager.default.setAttributes([.posixPermissions: 0o555], ofItemAtPath: directory) + defer { try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: directory) } + + #expect(throws: (any Error).self) { + try installer.uninstall() + } + #expect(FileManager.default.fileExists(atPath: installer.toolPath)) + } } diff --git a/docs/external-api/terminal.mdx b/docs/external-api/terminal.mdx index 824d9a35e..1853178e7 100644 --- a/docs/external-api/terminal.mdx +++ b/docs/external-api/terminal.mdx @@ -35,7 +35,7 @@ tablepro "mysql://root@127.0.0.1:3306/shop" tablepro ``` -If `/usr/local/bin` is not writable, Settings shows a `sudo` command you can copy and run instead. TablePro never asks for your admin password. +If `/usr/local/bin` is not writable, Settings shows a `sudo` command you can copy and run instead. TablePro never asks for your admin password. Run the command and switch back to TablePro, and Settings picks up the change on its own. ## DDEV From 797e09da684ac8c4082d1158dfa366ce0fca425a Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 13 Jul 2026 08:27:01 +0700 Subject: [PATCH 3/3] feat(settings): install the tablepro command with an administrator prompt --- .../CommandLineToolInstaller.swift | 91 ++++++++--- .../Infrastructure/PrivilegedShell.swift | 70 +++++++++ .../Sections/CommandLineToolSection.swift | 14 +- .../CommandLineToolInstallerTests.swift | 141 ++++++++++++------ docs/external-api/terminal.mdx | 4 +- 5 files changed, 249 insertions(+), 71 deletions(-) create mode 100644 TablePro/Core/Services/Infrastructure/PrivilegedShell.swift diff --git a/TablePro/Core/Services/Infrastructure/CommandLineToolInstaller.swift b/TablePro/Core/Services/Infrastructure/CommandLineToolInstaller.swift index f2a88a9be..a47c37269 100644 --- a/TablePro/Core/Services/Infrastructure/CommandLineToolInstaller.swift +++ b/TablePro/Core/Services/Infrastructure/CommandLineToolInstaller.swift @@ -13,16 +13,16 @@ internal enum CommandLineToolStatus: Equatable, Sendable { } internal enum CommandLineToolError: Error, LocalizedError, Equatable { - case directoryNotWritable(String) case conflict(String) + case cancelled case writeFailed(String) internal var errorDescription: String? { switch self { - case .directoryNotWritable(let path): - return String(format: String(localized: "%@ is not writable."), path) case .conflict(let path): return String(format: String(localized: "A different file already exists at %@."), path) + case .cancelled: + return String(localized: "Cancelled by user.") case .writeFailed(let reason): return reason } @@ -55,10 +55,16 @@ internal final class CommandLineToolInstaller: CommandLineToolInstalling { private let directory: String private let fileManager: FileManager + private let privilegedShell: PrivilegedShellRunning - internal init(directory: String = "/usr/local/bin", fileManager: FileManager = .default) { + internal init( + directory: String = "/usr/local/bin", + fileManager: FileManager = .default, + privilegedShell: PrivilegedShellRunning? = nil + ) { self.directory = directory self.fileManager = fileManager + self.privilegedShell = privilegedShell ?? OSAScriptPrivilegedShell() } internal var toolPath: String { @@ -73,30 +79,42 @@ internal final class CommandLineToolInstaller: CommandLineToolInstalling { return .installed } + internal var installCommand: String { + let script = Self.scriptContents.replacingOccurrences(of: "\n", with: "\\n") + let quotedDirectory = OSAScriptPrivilegedShell.quote(directory) + let quotedPath = OSAScriptPrivilegedShell.quote(toolPath) + return "mkdir -p \(quotedDirectory)" + + " && printf \(OSAScriptPrivilegedShell.quote(script)) > \(quotedPath)" + + " && chmod 755 \(quotedPath)" + } + + internal var uninstallCommand: String { + "rm -f \(OSAScriptPrivilegedShell.quote(toolPath))" + } + internal var manualInstallCommand: String { - let escaped = Self.scriptContents.replacingOccurrences(of: "\n", with: "\\n") - return "sudo mkdir -p \"\(directory)\" && printf '\(escaped)' | sudo tee \"\(toolPath)\" > /dev/null" - + " && sudo chmod 755 \"\(toolPath)\"" + "sudo sh -c \(OSAScriptPrivilegedShell.quote(installCommand))" } internal var manualUninstallCommand: String { - "sudo rm \"\(toolPath)\"" + "sudo sh -c \(OSAScriptPrivilegedShell.quote(uninstallCommand))" } internal func install() throws { guard status != .conflict else { throw CommandLineToolError.conflict(toolPath) } - guard fileManager.fileExists(atPath: directory), fileManager.isWritableFile(atPath: directory) else { - throw CommandLineToolError.directoryNotWritable(directory) + + if canWriteDirectly, writeShimDirectly() { + Self.logger.info("Installed command line tool at \(self.toolPath, privacy: .public)") + return } - do { - try Self.scriptContents.write(toFile: toolPath, atomically: true, encoding: .utf8) - try fileManager.setAttributes([.posixPermissions: 0o755], ofItemAtPath: toolPath) - } catch { - Self.logger.error("Failed to install command line tool: \(error.localizedDescription, privacy: .public)") - throw CommandLineToolError.writeFailed(error.localizedDescription) + try runPrivileged(installCommand) + guard status == .installed else { + throw CommandLineToolError.writeFailed( + String(format: String(localized: "Could not write %@."), toolPath) + ) } - Self.logger.info("Installed command line tool at \(self.toolPath, privacy: .public)") + Self.logger.info("Installed command line tool with administrator rights") } internal func uninstall() throws { @@ -106,12 +124,41 @@ internal final class CommandLineToolInstaller: CommandLineToolInstalling { case .conflict: throw CommandLineToolError.conflict(toolPath) case .installed: - do { - try fileManager.removeItem(atPath: toolPath) - } catch { - throw CommandLineToolError.writeFailed(error.localizedDescription) + if (try? fileManager.removeItem(atPath: toolPath)) != nil { + Self.logger.info("Removed command line tool at \(self.toolPath, privacy: .public)") + return } - Self.logger.info("Removed command line tool at \(self.toolPath, privacy: .public)") + try runPrivileged(uninstallCommand) + guard status == .notInstalled else { + throw CommandLineToolError.writeFailed( + String(format: String(localized: "Could not remove %@."), toolPath) + ) + } + } + } + + private var canWriteDirectly: Bool { + fileManager.fileExists(atPath: directory) && fileManager.isWritableFile(atPath: directory) + } + + private func writeShimDirectly() -> Bool { + do { + try Self.scriptContents.write(toFile: toolPath, atomically: true, encoding: .utf8) + try fileManager.setAttributes([.posixPermissions: 0o755], ofItemAtPath: toolPath) + return true + } catch { + Self.logger.debug("Direct write failed, escalating: \(error.localizedDescription, privacy: .public)") + return false + } + } + + private func runPrivileged(_ command: String) throws { + do { + try privilegedShell.run(command) + } catch PrivilegedShellError.cancelled { + throw CommandLineToolError.cancelled + } catch { + throw CommandLineToolError.writeFailed(error.localizedDescription) } } } diff --git a/TablePro/Core/Services/Infrastructure/PrivilegedShell.swift b/TablePro/Core/Services/Infrastructure/PrivilegedShell.swift new file mode 100644 index 000000000..123b464f8 --- /dev/null +++ b/TablePro/Core/Services/Infrastructure/PrivilegedShell.swift @@ -0,0 +1,70 @@ +// +// PrivilegedShell.swift +// TablePro +// + +import Foundation +import os + +internal enum PrivilegedShellError: Error, LocalizedError, Equatable { + case cancelled + case failed(String) + + internal var errorDescription: String? { + switch self { + case .cancelled: + return String(localized: "Cancelled by user.") + case .failed(let reason): + return reason + } + } +} + +@MainActor +internal protocol PrivilegedShellRunning { + func run(_ command: String) throws +} + +@MainActor +internal struct OSAScriptPrivilegedShell: PrivilegedShellRunning { + private static let logger = Logger(subsystem: "com.TablePro", category: "PrivilegedShell") + + internal static func quote(_ value: String) -> String { + "'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'" + } + + internal static func appleScript(for command: String) -> String { + let escaped = command + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + return "do shell script \"\(escaped)\" with administrator privileges" + } + + internal func run(_ command: String) throws { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/osascript") + process.arguments = ["-e", Self.appleScript(for: command)] + + let errorPipe = Pipe() + process.standardError = errorPipe + process.standardOutput = Pipe() + + do { + try process.run() + } catch { + throw PrivilegedShellError.failed(error.localizedDescription) + } + + let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + guard process.terminationStatus != 0 else { return } + + let message = (String(data: errorData, encoding: .utf8) ?? "") + .trimmingCharacters(in: .whitespacesAndNewlines) + if message.contains("-128") || message.localizedCaseInsensitiveContains("cancel") { + throw PrivilegedShellError.cancelled + } + Self.logger.error("Privileged command failed: \(message, privacy: .public)") + throw PrivilegedShellError.failed(message) + } +} diff --git a/TablePro/Views/Settings/Sections/CommandLineToolSection.swift b/TablePro/Views/Settings/Sections/CommandLineToolSection.swift index 62f1dbaa6..abfc0cc8f 100644 --- a/TablePro/Views/Settings/Sections/CommandLineToolSection.swift +++ b/TablePro/Views/Settings/Sections/CommandLineToolSection.swift @@ -37,7 +37,7 @@ struct CommandLineToolSection: View { if let manualCommand { VStack(alignment: .leading, spacing: 6) { - Text("This folder needs administrator access. Run this in Terminal:") + Text("TablePro could not do this for you. Run this in Terminal instead:") .font(.caption) .foregroundStyle(.secondary) @@ -74,11 +74,14 @@ struct CommandLineToolSection: View { try installer.install() manualCommand = nil errorMessage = nil - } catch CommandLineToolError.directoryNotWritable { - manualCommand = installer.manualInstallCommand + } catch CommandLineToolError.cancelled { + manualCommand = nil errorMessage = nil - } catch { + } catch CommandLineToolError.conflict(let path) { manualCommand = nil + errorMessage = CommandLineToolError.conflict(path).localizedDescription + } catch { + manualCommand = installer.manualInstallCommand errorMessage = error.localizedDescription } status = installer.status @@ -89,6 +92,9 @@ struct CommandLineToolSection: View { try installer.uninstall() manualCommand = nil errorMessage = nil + } catch CommandLineToolError.cancelled { + manualCommand = nil + errorMessage = nil } catch { manualCommand = installer.manualUninstallCommand errorMessage = error.localizedDescription diff --git a/TableProTests/Core/Services/Infrastructure/CommandLineToolInstallerTests.swift b/TableProTests/Core/Services/Infrastructure/CommandLineToolInstallerTests.swift index aae725983..c18711349 100644 --- a/TableProTests/Core/Services/Infrastructure/CommandLineToolInstallerTests.swift +++ b/TableProTests/Core/Services/Infrastructure/CommandLineToolInstallerTests.swift @@ -2,12 +2,38 @@ import Foundation @testable import TablePro import Testing +@MainActor +private final class ExecutingShell: PrivilegedShellRunning { + private(set) var commands: [String] = [] + + func run(_ command: String) throws { + commands.append(command) + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/sh") + process.arguments = ["-c", command] + try process.run() + process.waitUntilExit() + guard process.terminationStatus == 0 else { + throw PrivilegedShellError.failed("exit \(process.terminationStatus)") + } + } +} + +@MainActor +private final class CancellingShell: PrivilegedShellRunning { + private(set) var callCount = 0 + + func run(_ command: String) throws { + callCount += 1 + throw PrivilegedShellError.cancelled + } +} + @MainActor @Suite("CommandLineToolInstaller") struct CommandLineToolInstallerTests { - private func makeDirectory() throws -> String { - let path = NSTemporaryDirectory() - .appending("CommandLineToolInstallerTests.\(UUID().uuidString)") + private func makeDirectory(named name: String = UUID().uuidString) throws -> String { + let path = NSTemporaryDirectory().appending("CommandLineToolInstallerTests.\(name)") try FileManager.default.createDirectory(atPath: path, withIntermediateDirectories: true) return path } @@ -27,13 +53,24 @@ struct CommandLineToolInstallerTests { #expect(installer.status == .installed) let contents = try String(contentsOfFile: installer.toolPath, encoding: .utf8) - #expect(contents.contains("open -b com.TablePro")) + #expect(contents.contains("exec open -b com.TablePro")) let attributes = try FileManager.default.attributesOfItem(atPath: installer.toolPath) let permissions = try #require(attributes[.posixPermissions] as? NSNumber) #expect(permissions.int16Value == 0o755) } + @Test("A writable directory never asks for an administrator password") + func writableDirectoryDoesNotEscalate() throws { + let shell = CancellingShell() + let installer = CommandLineToolInstaller(directory: try makeDirectory(), privilegedShell: shell) + + try installer.install() + + #expect(installer.status == .installed) + #expect(shell.callCount == 0) + } + @Test("Uninstall removes the shim") func uninstallRemovesShim() throws { let installer = CommandLineToolInstaller(directory: try makeDirectory()) @@ -54,8 +91,7 @@ struct CommandLineToolInstallerTests { @Test("A foreign file at the same path is reported as a conflict and never overwritten") func foreignFileConflicts() throws { - let directory = try makeDirectory() - let installer = CommandLineToolInstaller(directory: directory) + let installer = CommandLineToolInstaller(directory: try makeDirectory()) try "#!/bin/sh\necho not ours\n".write(toFile: installer.toolPath, atomically: true, encoding: .utf8) #expect(installer.status == .conflict) @@ -78,60 +114,77 @@ struct CommandLineToolInstallerTests { #expect(FileManager.default.fileExists(atPath: installer.toolPath)) } - @Test("A read-only directory reports as not writable, which is what /usr/local/bin does without sudo") - func readOnlyDirectoryIsNotWritable() throws { - let directory = try makeDirectory() - try FileManager.default.setAttributes([.posixPermissions: 0o555], ofItemAtPath: directory) - defer { try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: directory) } + @Test("A missing directory escalates to an administrator prompt") + func missingDirectoryEscalates() throws { + let missing = NSTemporaryDirectory().appending("missing.\(UUID().uuidString)/bin") + let shell = ExecutingShell() + let installer = CommandLineToolInstaller(directory: missing, privilegedShell: shell) - let installer = CommandLineToolInstaller(directory: directory) + try installer.install() - #expect(throws: CommandLineToolError.directoryNotWritable(directory)) { - try installer.install() - } - #expect(installer.status == .notInstalled) + #expect(shell.commands.count == 1) + #expect(installer.status == .installed) } - @Test("A missing directory reports as not writable instead of crashing") - func missingDirectoryIsNotWritable() throws { - let missing = NSTemporaryDirectory().appending("missing.\(UUID().uuidString)") - let installer = CommandLineToolInstaller(directory: missing) + @Test("Cancelling the password prompt cancels the install and writes nothing") + func cancellingThePromptCancelsInstall() throws { + let missing = NSTemporaryDirectory().appending("missing.\(UUID().uuidString)/bin") + let shell = CancellingShell() + let installer = CommandLineToolInstaller(directory: missing, privilegedShell: shell) - #expect(throws: CommandLineToolError.directoryNotWritable(missing)) { + #expect(throws: CommandLineToolError.cancelled) { try installer.install() } + #expect(shell.callCount == 1) + #expect(installer.status == .notInstalled) } - @Test("The manual command installs the same shim the app would write") - func manualCommandMatchesShim() throws { - let installer = CommandLineToolInstaller(directory: try makeDirectory()) - let command = installer.manualInstallCommand + @Test("The privileged install command builds a working shim, even in a path with spaces and quotes") + func privilegedCommandSurvivesHostileDirectoryNames() throws { + let directory = try makeDirectory(named: "we ird's dir") + let installer = CommandLineToolInstaller(directory: directory) + let shell = ExecutingShell() - #expect(command.contains("sudo")) - #expect(command.contains(installer.toolPath)) - #expect(command.contains("open -b com.TablePro")) + try shell.run(installer.installCommand) + + #expect(installer.status == .installed) + let contents = try String(contentsOfFile: installer.toolPath, encoding: .utf8) + #expect(contents.hasPrefix("#!/bin/sh\n")) + #expect(contents.contains("exec open -b com.TablePro \"$@\"")) + + try shell.run(installer.uninstallCommand) + #expect(installer.status == .notInstalled) } - @Test("Both manual commands quote the path they touch") - func manualCommandsQuoteThePath() throws { - let installer = CommandLineToolInstaller(directory: try makeDirectory()) + @Test("A directory name cannot inject a second command into the privileged shell") + func directoryNameCannotInjectCommands() throws { + let canary = NSTemporaryDirectory().appending("canary.\(UUID().uuidString)") + let hostile = try makeDirectory(named: "x'; touch \(canary); echo '") + let installer = CommandLineToolInstaller(directory: hostile) + let shell = ExecutingShell() - #expect(installer.manualInstallCommand.contains("\"\(installer.toolPath)\"")) - #expect(installer.manualUninstallCommand == "sudo rm \"\(installer.toolPath)\"") + try shell.run(installer.installCommand) + + #expect(FileManager.default.fileExists(atPath: canary) == false) + #expect(installer.status == .installed) } - @Test("Uninstalling a shim the app cannot remove fails loudly instead of silently") - func uninstallFromReadOnlyDirectoryThrows() throws { - let directory = try makeDirectory() - let installer = CommandLineToolInstaller(directory: directory) - try installer.install() + @Test("The AppleScript wrapper escapes quotes and backslashes") + func appleScriptEscaping() { + let script = OSAScriptPrivilegedShell.appleScript(for: #"printf 'a"b\c' > 'x'"#) - try FileManager.default.setAttributes([.posixPermissions: 0o555], ofItemAtPath: directory) - defer { try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: directory) } + #expect(script.hasPrefix("do shell script \"")) + #expect(script.hasSuffix("\" with administrator privileges")) + #expect(script.contains(#"a\"b\\c"#)) + } - #expect(throws: (any Error).self) { - try installer.uninstall() - } - #expect(FileManager.default.fileExists(atPath: installer.toolPath)) + @Test("Both manual commands run the same thing the app would run") + func manualCommandsMirrorTheAppCommands() throws { + let installer = CommandLineToolInstaller(directory: try makeDirectory()) + + #expect(installer.manualInstallCommand.hasPrefix("sudo sh -c ")) + #expect(installer.manualInstallCommand.contains("open -b com.TablePro")) + #expect(installer.manualUninstallCommand.hasPrefix("sudo sh -c ")) + #expect(installer.manualUninstallCommand.contains(installer.toolPath)) } } diff --git a/docs/external-api/terminal.mdx b/docs/external-api/terminal.mdx index 1853178e7..f10828bfc 100644 --- a/docs/external-api/terminal.mdx +++ b/docs/external-api/terminal.mdx @@ -35,7 +35,9 @@ tablepro "mysql://root@127.0.0.1:3306/shop" tablepro ``` -If `/usr/local/bin` is not writable, Settings shows a `sudo` command you can copy and run instead. TablePro never asks for your admin password. Run the command and switch back to TablePro, and Settings picks up the change on its own. +`/usr/local/bin` is owned by root on a stock macOS install, and may not exist at all. When TablePro cannot write there itself, macOS asks for your administrator password, and TablePro then creates the folder and the command. Cancel the password prompt and nothing is written. + +If that fails too, Settings falls back to showing the exact `sudo` command to run in Terminal. Run it and switch back to TablePro, and Settings picks up the change on its own. ## DDEV