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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
164 changes: 164 additions & 0 deletions TablePro/Core/Services/Infrastructure/CommandLineToolInstaller.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
//
// 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 conflict(String)
case cancelled
case writeFailed(String)

internal var errorDescription: String? {
switch self {
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
}
}
}

@MainActor
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
}

@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
private let privilegedShell: PrivilegedShellRunning

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 {
(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 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 {
"sudo sh -c \(OSAScriptPrivilegedShell.quote(installCommand))"
}

internal var manualUninstallCommand: String {
"sudo sh -c \(OSAScriptPrivilegedShell.quote(uninstallCommand))"
}

internal func install() throws {
guard status != .conflict else { throw CommandLineToolError.conflict(toolPath) }

if canWriteDirectly, writeShimDirectly() {
Self.logger.info("Installed command line tool at \(self.toolPath, privacy: .public)")
return
}

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 with administrator rights")
}

internal func uninstall() throws {
switch status {
case .notInstalled:
return
case .conflict:
throw CommandLineToolError.conflict(toolPath)
case .installed:
if (try? fileManager.removeItem(atPath: toolPath)) != nil {
Self.logger.info("Removed command line tool at \(self.toolPath, privacy: .public)")
return
}
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)
}
}
}
35 changes: 35 additions & 0 deletions TablePro/Core/Services/Infrastructure/ExternalConnectionGate.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
}
70 changes: 70 additions & 0 deletions TablePro/Core/Services/Infrastructure/PrivilegedShell.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading
Loading