From 6f9fe1cb6c24428b9b8ade570ba3bde5ab177b59 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 10 Oct 2026 18:07:57 +0700 Subject: [PATCH] feat(connections): connect MySQL and MariaDB through a Unix socket --- CHANGELOG.md | 1 + TablePro/Core/Database/DatabaseDriver.swift | 2 +- .../Database/DatabaseManager+Health.swift | 4 +- .../Database/DatabaseManager+Queries.swift | 2 +- .../Database/DatabaseManager+Sessions.swift | 4 +- .../Core/Plugins/PluginDriverAdapter.swift | 2 +- .../Plugins/PluginManager+Registration.swift | 5 + ...uginMetadataRegistry+CuratedDefaults.swift | 6 +- .../Core/Plugins/PluginMetadataRegistry.swift | 5 + .../Query/MetadataConnectionPool.swift | 4 +- .../Connection/ConnectionTunnelKind.swift | 12 +- .../DatabaseConnection+Display.swift | 5 +- .../DatabaseConnection+LocalSocket.swift | 33 +++ .../ConnectionFormCoordinator+Transport.swift | 11 + .../ConnectionFormCoordinator.swift | 2 + .../Panes/GeneralPaneView.swift | 13 +- .../Panes/LocalSocketRows.swift | 60 ++++++ .../Panes/NetworkPaneView.swift | 4 +- .../ConnectionForm/Panes/SSLSections.swift | 4 + .../Support/ConnectionFormEdits.swift | 3 +- .../NetworkPaneViewModel+LocalSocket.swift | 60 ++++++ .../ViewModels/NetworkPaneViewModel.swift | 13 ++ ...tadataRegistryCuratedCapabilityTests.swift | 6 + .../Plugins/SocketPathPlaceholderTests.swift | 14 ++ .../DatabaseConnectionDisplayTests.swift | 30 +++ .../DatabaseConnectionLocalSocketTests.swift | 70 +++++++ .../ViewModels/ConnectionFormEditsTests.swift | 15 ++ .../ConnectionFormTransportTests.swift | 92 ++++++++ .../NetworkPaneLocalSocketTests.swift | 198 ++++++++++++++++++ .../ConnectionFormLocalSocketUITests.swift | 104 +++++++++ docs/connections/connection-form.mdx | 4 + docs/databases/mariadb.mdx | 2 +- docs/databases/mysql.mdx | 37 +++- 33 files changed, 800 insertions(+), 27 deletions(-) create mode 100644 TablePro/Models/Connection/DatabaseConnection+LocalSocket.swift create mode 100644 TablePro/Views/ConnectionForm/Panes/LocalSocketRows.swift create mode 100644 TablePro/Views/ConnectionForm/ViewModels/NetworkPaneViewModel+LocalSocket.swift create mode 100644 TableProTests/Models/DatabaseConnectionLocalSocketTests.swift create mode 100644 TableProTests/Views/ConnectionForm/NetworkPaneLocalSocketTests.swift create mode 100644 TableProUITests/ConnectionFormLocalSocketUITests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index c575254ec8..8a219926d4 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) +- MySQL and MariaDB connections through a local Unix socket. ### Changed diff --git a/TablePro/Core/Database/DatabaseDriver.swift b/TablePro/Core/Database/DatabaseDriver.swift index 6d81871293..032e7082ce 100644 --- a/TablePro/Core/Database/DatabaseDriver.swift +++ b/TablePro/Core/Database/DatabaseDriver.swift @@ -933,7 +933,7 @@ enum DatabaseDriverFactory { throw DatabaseError.connectionFailed(String(localized: "The prepared connection no longer matches this driver.")) } let connectionDeadline = deadline ?? ConnectionDeadline(configuredSeconds: connection.connectTimeoutSeconds) - let endpoint = timeoutEndpoint ?? .database(connection.host.nilIfEmpty ?? connection.name) + let endpoint = timeoutEndpoint ?? .database(connection.timeoutEndpointName) let requiresHostDeadline = ConnectionTimeoutPolicy.requiresHostDeadline(for: connection) if requiresHostDeadline { try connectionDeadline.check(endpoint: endpoint) diff --git a/TablePro/Core/Database/DatabaseManager+Health.swift b/TablePro/Core/Database/DatabaseManager+Health.swift index a4b7337f85..c3be0d168a 100644 --- a/TablePro/Core/Database/DatabaseManager+Health.swift +++ b/TablePro/Core/Database/DatabaseManager+Health.swift @@ -525,9 +525,7 @@ extension DatabaseManager { var passwordOverride = initialPasswordOverride var deadline = initialDeadline let preparedConfiguration = initialPreparedConfiguration - let timeoutEndpoint = ConnectionTimeoutEndpoint.database( - session.connection.host.nilIfEmpty ?? session.connection.name - ) + let timeoutEndpoint = ConnectionTimeoutEndpoint.database(session.connection.timeoutEndpointName) while true { var driver: DatabaseDriver? diff --git a/TablePro/Core/Database/DatabaseManager+Queries.swift b/TablePro/Core/Database/DatabaseManager+Queries.swift index 097d5c7ad4..1863f954cc 100644 --- a/TablePro/Core/Database/DatabaseManager+Queries.swift +++ b/TablePro/Core/Database/DatabaseManager+Queries.swift @@ -39,7 +39,7 @@ extension DatabaseManager { sshPassword: String? = nil, passwordOverride: String? = nil ) async throws -> Bool { - let timeoutEndpoint = ConnectionTimeoutEndpoint.database(connection.host.nilIfEmpty ?? connection.name) + let timeoutEndpoint = ConnectionTimeoutEndpoint.database(connection.timeoutEndpointName) let queryTimeoutSeconds = ConnectionTimeoutPolicy.effectiveQueryTimeoutSeconds( configuredSeconds: connection.queryTimeoutSeconds, globalSeconds: AppSettingsManager.shared.general.queryTimeoutSeconds diff --git a/TablePro/Core/Database/DatabaseManager+Sessions.swift b/TablePro/Core/Database/DatabaseManager+Sessions.swift index 436220bdac..fc843cdaea 100644 --- a/TablePro/Core/Database/DatabaseManager+Sessions.swift +++ b/TablePro/Core/Database/DatabaseManager+Sessions.swift @@ -69,9 +69,7 @@ extension DatabaseManager { ) let deadline = ConnectionDeadline(configuredSeconds: resolvedConnection.connectTimeoutSeconds) - let timeoutEndpoint = ConnectionTimeoutEndpoint.database( - resolvedConnection.host.nilIfEmpty ?? resolvedConnection.name - ) + let timeoutEndpoint = ConnectionTimeoutEndpoint.database(resolvedConnection.timeoutEndpointName) let effectiveConnection: DatabaseConnection do { if !resolvedConnection.enabledTunnelKinds.isEmpty { diff --git a/TablePro/Core/Plugins/PluginDriverAdapter.swift b/TablePro/Core/Plugins/PluginDriverAdapter.swift index c413815975..a4e94b26d5 100644 --- a/TablePro/Core/Plugins/PluginDriverAdapter.swift +++ b/TablePro/Core/Plugins/PluginDriverAdapter.swift @@ -128,7 +128,7 @@ final class PluginDriverAdapter: DatabaseDriver, SchemaSwitchable, DatabaseRepor self.connection = connection self.pluginDriver = pluginDriver self.connectionDeadline = deadline ?? ConnectionDeadline(configuredSeconds: connection.connectTimeoutSeconds) - self.timeoutEndpoint = timeoutEndpoint ?? .database(connection.host.nilIfEmpty ?? connection.name) + self.timeoutEndpoint = timeoutEndpoint ?? .database(connection.timeoutEndpointName) self.requiresHostDeadline = requiresHostDeadline ?? ConnectionTimeoutPolicy.requiresHostDeadline(for: connection) self.effectiveQueryTimeoutSeconds = ConnectionTimeoutPolicy.effectiveQueryTimeoutSeconds( diff --git a/TablePro/Core/Plugins/PluginManager+Registration.swift b/TablePro/Core/Plugins/PluginManager+Registration.swift index a3ad81fc84..9d6077762f 100644 --- a/TablePro/Core/Plugins/PluginManager+Registration.swift +++ b/TablePro/Core/Plugins/PluginManager+Registration.swift @@ -433,6 +433,11 @@ extension PluginManager { .connection.defaultUnixSocketPath } + func defaultLocalSocketPath(for databaseType: DatabaseType) -> String? { + PluginMetadataRegistry.shared.snapshot(for: databaseType)? + .connection.defaultLocalSocketPath + } + func containerEntityNamePlural(for databaseType: DatabaseType) -> String { containerEntityName(for: databaseType) + "s" } diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry+CuratedDefaults.swift b/TablePro/Core/Plugins/PluginMetadataRegistry+CuratedDefaults.swift index 0f594b41bc..dd5a1d26c2 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry+CuratedDefaults.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry+CuratedDefaults.swift @@ -309,7 +309,8 @@ extension PluginMetadataRegistry { additionalConnectionFields: cloudIAMFields + [mysqlIdleReleaseField, mysqlEncodingField], category: .relational, tagline: String(localized: "Most popular open-source SQL database"), - defaultUnixSocketPath: "/var/run/mysqld/mysqld.sock" + defaultUnixSocketPath: "/var/run/mysqld/mysqld.sock", + defaultLocalSocketPath: MySQLLocalSocket.defaultPath ) )), ("MariaDB", PluginMetadataSnapshot( @@ -378,7 +379,8 @@ extension PluginMetadataRegistry { additionalConnectionFields: awsIAMFields + [mysqlIdleReleaseField, mysqlEncodingField], category: .relational, tagline: String(localized: "Open-source fork of MySQL"), - defaultUnixSocketPath: "/var/run/mysqld/mysqld.sock" + defaultUnixSocketPath: "/var/run/mysqld/mysqld.sock", + defaultLocalSocketPath: MySQLLocalSocket.defaultPath ) )), ("PostgreSQL", PluginMetadataSnapshot( diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry.swift b/TablePro/Core/Plugins/PluginMetadataRegistry.swift index e7c3d26368..234850693c 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry.swift @@ -279,6 +279,8 @@ struct PluginMetadataSnapshot: Sendable { /// store may have no container at all. let hidesBuiltInDatabase: Bool let defaultUnixSocketPath: String? + // Non-nil means the form offers Connect Using > Socket. + let defaultLocalSocketPath: String? let defaultHost: String? init( @@ -288,6 +290,7 @@ struct PluginMetadataSnapshot: Sendable { hidesBuiltInPassword: Bool = false, hidesBuiltInDatabase: Bool = false, defaultUnixSocketPath: String? = nil, + defaultLocalSocketPath: String? = nil, defaultHost: String? = nil ) { self.additionalConnectionFields = additionalConnectionFields @@ -296,6 +299,7 @@ struct PluginMetadataSnapshot: Sendable { self.hidesBuiltInPassword = hidesBuiltInPassword self.hidesBuiltInDatabase = hidesBuiltInDatabase self.defaultUnixSocketPath = defaultUnixSocketPath + self.defaultLocalSocketPath = defaultLocalSocketPath self.defaultHost = defaultHost } @@ -794,6 +798,7 @@ final class PluginMetadataRegistry: @unchecked Sendable { hidesBuiltInPassword: existingSnapshot?.connection.hidesBuiltInPassword ?? false, hidesBuiltInDatabase: existingSnapshot?.connection.hidesBuiltInDatabase ?? false, defaultUnixSocketPath: existingSnapshot?.connection.defaultUnixSocketPath, + defaultLocalSocketPath: existingSnapshot?.connection.defaultLocalSocketPath, defaultHost: existingSnapshot?.connection.defaultHost ) ) diff --git a/TablePro/Core/Services/Query/MetadataConnectionPool.swift b/TablePro/Core/Services/Query/MetadataConnectionPool.swift index 2c67925426..c8c7da5d2e 100644 --- a/TablePro/Core/Services/Query/MetadataConnectionPool.swift +++ b/TablePro/Core/Services/Query/MetadataConnectionPool.swift @@ -431,9 +431,7 @@ final class MetadataConnectionPool { purpose: purpose ) let deadline = ConnectionDeadline(configuredSeconds: session.connection.connectTimeoutSeconds) - let timeoutEndpoint = ConnectionTimeoutEndpoint.database( - session.connection.host.nilIfEmpty ?? session.connection.name - ) + let timeoutEndpoint = ConnectionTimeoutEndpoint.database(session.connection.timeoutEndpointName) let operationTimeoutSeconds = session.effectiveQueryTimeoutSeconds > 0 ? Double(session.effectiveQueryTimeoutSeconds) : nil diff --git a/TablePro/Models/Connection/ConnectionTunnelKind.swift b/TablePro/Models/Connection/ConnectionTunnelKind.swift index dc8815c15d..52495d1e10 100644 --- a/TablePro/Models/Connection/ConnectionTunnelKind.swift +++ b/TablePro/Models/Connection/ConnectionTunnelKind.swift @@ -92,10 +92,14 @@ enum ConnectionTunnelKind: String, CaseIterable, Sendable { /// A file-based driver reaches its database through a path, not a host and a port, and it is /// exactly the driver that shows this picker in order to offer Remote Database File. - static func directSummary(isFileBased: Bool) -> String { - isFileBased - ? String(localized: "Opens the database file on this Mac.") - : String(localized: "Connects straight to the host and port on the General tab.") + static func directSummary(isFileBased: Bool, usesLocalSocket: Bool) -> String { + if isFileBased { + return String(localized: "Opens the database file on this Mac.") + } + if usesLocalSocket { + return String(localized: "Connects through the socket file on the General tab.") + } + return String(localized: "Connects straight to the host and port on the General tab.") } } diff --git a/TablePro/Models/Connection/DatabaseConnection+Display.swift b/TablePro/Models/Connection/DatabaseConnection+Display.swift index 354bdb40b3..1646f1e8a5 100644 --- a/TablePro/Models/Connection/DatabaseConnection+Display.swift +++ b/TablePro/Models/Connection/DatabaseConnection+Display.swift @@ -28,6 +28,9 @@ extension DatabaseConnection { if let socketPath = sshForwardUnixSocketPath, resolvedSSHConfig.enabled { return (socketPath as NSString).abbreviatingWithTildeInPath } + if let socketPath = localSocketPath { + return (socketPath as NSString).abbreviatingWithTildeInPath + } if let hostList = hostListDescription { return hostList } @@ -58,7 +61,7 @@ extension DatabaseConnection { } private var databaseDescriptor: String? { - guard !host.isEmpty || hostListDescription != nil else { return nil } + guard !host.isEmpty || hostListDescription != nil || localSocketPath != nil else { return nil } switch type.pathFieldRole { case .database, .serviceName: let trimmed = database.trimmingCharacters(in: .whitespaces) diff --git a/TablePro/Models/Connection/DatabaseConnection+LocalSocket.swift b/TablePro/Models/Connection/DatabaseConnection+LocalSocket.swift new file mode 100644 index 0000000000..64854a940e --- /dev/null +++ b/TablePro/Models/Connection/DatabaseConnection+LocalSocket.swift @@ -0,0 +1,33 @@ +// +// DatabaseConnection+LocalSocket.swift +// TablePro +// + +import Foundation +import TableProPluginKit + +extension DatabaseConnection { + var supportsLocalSocket: Bool { + PluginMetadataRegistry.shared.snapshot(for: type)?.connection.defaultLocalSocketPath != nil + } + + // A tunnel connects to its forwarded port, never this Mac's socket. + var localSocketPath: String? { + get { + guard supportsLocalSocket, activeTunnelKind == nil else { return nil } + return MySQLLocalSocket.path(in: additionalFields) + } + set { + let path = newValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if path.isEmpty { + additionalFields.removeValue(forKey: MySQLLocalSocket.fieldKey) + } else { + additionalFields[MySQLLocalSocket.fieldKey] = path + } + } + } + + var timeoutEndpointName: String { + localSocketPath ?? host.nilIfEmpty ?? name + } +} diff --git a/TablePro/Views/ConnectionForm/ConnectionFormCoordinator+Transport.swift b/TablePro/Views/ConnectionForm/ConnectionFormCoordinator+Transport.swift index 75235172e5..617c21f616 100644 --- a/TablePro/Views/ConnectionForm/ConnectionFormCoordinator+Transport.swift +++ b/TablePro/Views/ConnectionForm/ConnectionFormCoordinator+Transport.swift @@ -52,6 +52,7 @@ extension ConnectionFormCoordinator { /// stored in `ssh.state`, so only one of them can be offered: the getter decides which by /// capability, and offering both would let the picker select `.ssh` and read back `.remoteFile`. var availableTransports: [ConnectionTunnelKind?] { + guard !network.usesLocalSocket else { return [nil] } var transports: [ConnectionTunnelKind?] = [nil] if supportsRemoteDatabaseFile { transports.append(.remoteFile) @@ -73,6 +74,16 @@ extension ConnectionFormCoordinator { return transports } + // No tunnel reaches a socket file on this Mac, so Socket clears the transport in the same edit. + func selectEndpoint(_ endpoint: NetworkPaneViewModel.Endpoint) { + guard endpoint != network.endpoint else { return } + network.endpoint = endpoint + if network.usesLocalSocket { + transport = nil + } + testSucceeded = false + } + var supportsRemoteDatabaseFile: Bool { services.pluginManager.supportsRemoteDatabaseFile(for: network.type) } diff --git a/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift b/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift index fcd4208925..960dcf2624 100644 --- a/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift +++ b/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift @@ -887,6 +887,8 @@ final class ConnectionFormCoordinator: ObservableObject { advanced.resetForType(parsed.type) } + // Left on Socket, normalizeTransport would drop the SSH server the URL names. + network.endpoint = .hostAndPort network.host = parsed.host network.port = String(parsed.resolvedPort) network.database = parsed.database diff --git a/TablePro/Views/ConnectionForm/Panes/GeneralPaneView.swift b/TablePro/Views/ConnectionForm/Panes/GeneralPaneView.swift index ac69421212..0da6206ba8 100644 --- a/TablePro/Views/ConnectionForm/Panes/GeneralPaneView.swift +++ b/TablePro/Views/ConnectionForm/Panes/GeneralPaneView.swift @@ -106,7 +106,14 @@ struct GeneralPaneView: View { } case .network: Section { - hostFieldsView + if coordinator.network.supportsLocalSocket { + LocalSocketEndpointPicker(coordinator: coordinator) + } + if coordinator.network.usesLocalSocket { + LocalSocketPathField(coordinator: coordinator) + } else { + hostFieldsView + } if showsBuiltInDatabaseField { TextField( containerEntityName, @@ -117,7 +124,9 @@ struct GeneralPaneView: View { } header: { Text(String(localized: "Connection")) } footer: { - if usesForwardSocket { + if coordinator.network.usesLocalSocket { + LocalSocketFooter(status: coordinator.network.localSocketFileStatus) + } else if usesForwardSocket { Text(String(localized: """ Host and Port are unused. The SSH tunnel forwards to the socket path set \ on the Network tab. diff --git a/TablePro/Views/ConnectionForm/Panes/LocalSocketRows.swift b/TablePro/Views/ConnectionForm/Panes/LocalSocketRows.swift new file mode 100644 index 0000000000..84b436dbd9 --- /dev/null +++ b/TablePro/Views/ConnectionForm/Panes/LocalSocketRows.swift @@ -0,0 +1,60 @@ +// +// LocalSocketRows.swift +// TablePro +// + +import SwiftUI + +struct LocalSocketEndpointPicker: View { + @ObservedObject var coordinator: ConnectionFormCoordinator + + var body: some View { + Picker(String(localized: "Connect Using"), selection: endpointBinding) { + Text(String(localized: "Host and Port")).tag(NetworkPaneViewModel.Endpoint.hostAndPort) + Text(String(localized: "Socket")).tag(NetworkPaneViewModel.Endpoint.localSocket) + } + .accessibilityIdentifier("connection-form-endpoint") + } + + private var endpointBinding: Binding { + Binding( + get: { coordinator.network.endpoint }, + set: { coordinator.selectEndpoint($0) } + ) + } +} + +struct LocalSocketPathField: View { + @ObservedObject var coordinator: ConnectionFormCoordinator + + var body: some View { + TextField( + String(localized: "Socket"), + text: $coordinator.network.localSocketPath, + prompt: Text(coordinator.network.localSocketPrompt) + ) + .accessibilityIdentifier("connection-form-socket-path") + } +} + +struct LocalSocketFooter: View { + let status: LocalSocketFileStatus? + + var body: some View { + Text(message) + .font(.caption) + .foregroundStyle(.secondary) + .accessibilityIdentifier("connection-form-socket-status") + } + + private var message: String { + switch status { + case .missing: + return String(localized: "Nothing is at this path. Start the server, or check the path.") + case .notSocket: + return String(localized: "This file is not a socket.") + case .socket, .none: + return String(localized: "The server's socket file on this Mac. SELECT @@socket in the mysql client shows it.") + } + } +} diff --git a/TablePro/Views/ConnectionForm/Panes/NetworkPaneView.swift b/TablePro/Views/ConnectionForm/Panes/NetworkPaneView.swift index 631d39bdd0..d805f5884d 100644 --- a/TablePro/Views/ConnectionForm/Panes/NetworkPaneView.swift +++ b/TablePro/Views/ConnectionForm/Panes/NetworkPaneView.swift @@ -22,6 +22,7 @@ struct NetworkPaneView: View { if coordinator.supportsSSL { SSLSections( databaseType: coordinator.network.type, + usesLocalSocket: coordinator.network.usesLocalSocket, sslMode: sslModeBinding, sslCaCertPath: $coordinator.ssl.caCertPath, sslClientCertPath: $coordinator.ssl.clientCertPath, @@ -59,7 +60,8 @@ struct NetworkPaneView: View { private var directSummary: String { ConnectionTunnelKind.directSummary( - isFileBased: coordinator.network.connectionMode == .fileBased + isFileBased: coordinator.network.connectionMode == .fileBased, + usesLocalSocket: coordinator.network.usesLocalSocket ) } diff --git a/TablePro/Views/ConnectionForm/Panes/SSLSections.swift b/TablePro/Views/ConnectionForm/Panes/SSLSections.swift index d6c9adcd31..4f0883b3dc 100644 --- a/TablePro/Views/ConnectionForm/Panes/SSLSections.swift +++ b/TablePro/Views/ConnectionForm/Panes/SSLSections.swift @@ -14,6 +14,7 @@ import UniformTypeIdentifiers /// connections leave alone. struct SSLSections: View { let databaseType: DatabaseType + var usesLocalSocket = false @Binding var sslMode: SSLMode @Binding var sslCaCertPath: String @Binding var sslClientCertPath: String @@ -59,6 +60,9 @@ struct SSLSections: View { if sslMode != .disabled { Text(sslMode.description) } + if usesLocalSocket && sslMode == .preferred { + Text(String(localized: "Preferred leaves a socket connection unencrypted. Required encrypts it.")) + } } .font(.caption) .foregroundStyle(.secondary) diff --git a/TablePro/Views/ConnectionForm/Support/ConnectionFormEdits.swift b/TablePro/Views/ConnectionForm/Support/ConnectionFormEdits.swift index b4aaf093bf..4c7862281d 100644 --- a/TablePro/Views/ConnectionForm/Support/ConnectionFormEdits.swift +++ b/TablePro/Views/ConnectionForm/Support/ConnectionFormEdits.swift @@ -42,7 +42,8 @@ struct ConnectionFormEdits: Equatable { "promptForPassword", DatabaseConnection.connectTimeoutSecondsKey, DatabaseConnection.queryTimeoutSecondsKey, - DatabaseConnection.sshForwardUnixSocketPathKey + DatabaseConnection.sshForwardUnixSocketPathKey, + MySQLLocalSocket.fieldKey ] func applied(to base: DatabaseConnection) -> DatabaseConnection { diff --git a/TablePro/Views/ConnectionForm/ViewModels/NetworkPaneViewModel+LocalSocket.swift b/TablePro/Views/ConnectionForm/ViewModels/NetworkPaneViewModel+LocalSocket.swift new file mode 100644 index 0000000000..6523466dbb --- /dev/null +++ b/TablePro/Views/ConnectionForm/ViewModels/NetworkPaneViewModel+LocalSocket.swift @@ -0,0 +1,60 @@ +// +// NetworkPaneViewModel+LocalSocket.swift +// TablePro +// + +import Foundation + +enum LocalSocketFileStatus: Equatable { + case missing + case notSocket + case socket +} + +extension NetworkPaneViewModel { + var supportsLocalSocket: Bool { + PluginManager.shared.defaultLocalSocketPath(for: type) != nil + } + + var usesLocalSocket: Bool { + supportsLocalSocket && endpoint == .localSocket + } + + var localSocketPrompt: String { + PluginManager.shared.defaultLocalSocketPath(for: type) ?? MySQLLocalSocket.defaultPath + } + + // The driver hands the path to connect(2) unchanged, so `~` is expanded here. + var resolvedLocalSocketPath: String? { + let trimmed = localSocketPath.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + return (trimmed as NSString).expandingTildeInPath + } + + var localSocketIssues: [String] { + guard usesLocalSocket else { return [] } + guard let path = resolvedLocalSocketPath else { + return [String(format: String(localized: "%@ is required"), String(localized: "Socket"))] + } + guard let issue = MySQLLocalSocket.issue(for: path) else { return [] } + return [issue.message] + } + + // Advisory only: the server may start after the connection is saved. + var localSocketFileStatus: LocalSocketFileStatus? { + guard usesLocalSocket, + let path = resolvedLocalSocketPath, + MySQLLocalSocket.issue(for: path) == nil + else { return nil } + return Self.localSocketFileStatus(atPath: path) + } + + // stat, not attributesOfItem, which reports a symlink to the socket as a symlink. + nonisolated static func localSocketFileStatus(atPath path: String) -> LocalSocketFileStatus? { + var info = stat() + guard stat(path, &info) == 0 else { + return errno == ENOENT || errno == ENOTDIR ? .missing : nil + } + return info.st_mode & S_IFMT == S_IFSOCK ? .socket : .notSocket + } +} diff --git a/TablePro/Views/ConnectionForm/ViewModels/NetworkPaneViewModel.swift b/TablePro/Views/ConnectionForm/ViewModels/NetworkPaneViewModel.swift index e0980809f3..73f9911e76 100644 --- a/TablePro/Views/ConnectionForm/ViewModels/NetworkPaneViewModel.swift +++ b/TablePro/Views/ConnectionForm/ViewModels/NetworkPaneViewModel.swift @@ -10,12 +10,19 @@ import TableProPluginKit @MainActor final class NetworkPaneViewModel: ObservableObject { + enum Endpoint: Hashable { + case hostAndPort + case localSocket + } + @Published var name: String = "" @Published var type: DatabaseType = .mysql @Published var host: String = "" @Published var port: String = "" @Published var database: String = "" @Published var sshForwardUnixSocketPath: String = "" + @Published var endpoint: Endpoint = .hostAndPort + @Published var localSocketPath: String = "" @Published var additionalFieldValues: [String: String] = [:] @Published var coordinator: WeakCoordinatorRef? @@ -131,6 +138,7 @@ final class NetworkPaneViewModel: ObservableObject { } issues += connectionFields.filter(isFieldVisible).compactMap { $0.rangeIssue(in: additionalFieldValues[$0.id] ?? "") } issues += endpointHostListIssues + issues += localSocketIssues return issues } @@ -202,6 +210,8 @@ final class NetworkPaneViewModel: ObservableObject { database = connection.database type = connection.type sshForwardUnixSocketPath = connection.sshForwardUnixSocketPath ?? "" + localSocketPath = connection.localSocketPath ?? "" + endpoint = connection.localSocketPath == nil ? .hostAndPort : .localSocket var values: [String: String] = [:] let allFields = PluginManager.shared.additionalConnectionFields(for: connection.type) @@ -244,6 +254,9 @@ final class NetworkPaneViewModel: ObservableObject { if !socketPath.isEmpty { fields[DatabaseConnection.sshForwardUnixSocketPathKey] = socketPath } + if usesLocalSocket, let localSocket = resolvedLocalSocketPath { + fields[MySQLLocalSocket.fieldKey] = localSocket + } } } diff --git a/TableProTests/Core/Plugins/PluginMetadataRegistryCuratedCapabilityTests.swift b/TableProTests/Core/Plugins/PluginMetadataRegistryCuratedCapabilityTests.swift index 8e9adb0d86..0f7648f885 100644 --- a/TableProTests/Core/Plugins/PluginMetadataRegistryCuratedCapabilityTests.swift +++ b/TableProTests/Core/Plugins/PluginMetadataRegistryCuratedCapabilityTests.swift @@ -383,6 +383,12 @@ struct PluginMetadataRegistryCuratedCapabilityTests { #expect(built.capabilities.browsingRequiresSelectedDatabase == true) } + @Test("MySQL keeps its socket on this Mac when its plugin registers") + func mySQLKeepsItsLocalSocket() { + let built = PluginMetadataRegistry.shared.buildMetadataSnapshot(from: MockMySQLPlugin.self) + #expect(built.connection.defaultLocalSocketPath == MySQLLocalSocket.defaultPath) + } + /// A MySQL-protocol session opened with no database has none at all, so it lists nothing until /// one is chosen. Databend's session falls back to its `default` database instead. @Test("Only the MySQL engines with no default database require a selected database to browse") diff --git a/TableProTests/Core/Plugins/SocketPathPlaceholderTests.swift b/TableProTests/Core/Plugins/SocketPathPlaceholderTests.swift index 8d7e7bbfa7..ff8e12bccb 100644 --- a/TableProTests/Core/Plugins/SocketPathPlaceholderTests.swift +++ b/TableProTests/Core/Plugins/SocketPathPlaceholderTests.swift @@ -39,6 +39,20 @@ struct SocketPathPlaceholderTests { #expect(PluginManager.shared.defaultUnixSocketPath(for: .clickhouse) == nil) } + @Test("MySQL and MariaDB offer a socket on this Mac at the mysql client's default path") + func mysqlFamilyHasALocalSocket() { + #expect(PluginManager.shared.defaultLocalSocketPath(for: .mysql) == "/tmp/mysql.sock") + #expect(PluginManager.shared.defaultLocalSocketPath(for: .mariadb) == "/tmp/mysql.sock") + } + + @Test("No other type offers a socket on this Mac") + func otherTypesHaveNoLocalSocket() { + let unknown = DatabaseType(rawValue: "FuturePlugin") + for type in [DatabaseType.tidb, .databend, .oceanbase, .postgresql, .redis, .sqlite, unknown] { + #expect(PluginManager.shared.defaultLocalSocketPath(for: type) == nil, "\(type.rawValue)") + } + } + @Test("Unknown type has no default") func unknownTypeHasNoDefault() { let unknown = DatabaseType(rawValue: "FuturePlugin") diff --git a/TableProTests/Models/DatabaseConnectionDisplayTests.swift b/TableProTests/Models/DatabaseConnectionDisplayTests.swift index bdd7fe26e9..ddedfbdd95 100644 --- a/TableProTests/Models/DatabaseConnectionDisplayTests.swift +++ b/TableProTests/Models/DatabaseConnectionDisplayTests.swift @@ -93,6 +93,36 @@ struct DatabaseConnectionDisplayTests { #expect(connection.connectionSubtitle == "appdb on ~/run/mysql.sock") } + @Test("A socket connection shows its socket in place of the host") + func localSocketReplacesHost() { + let socket = (NSHomeDirectory() as NSString) + .appendingPathComponent("Library/Application Support/Local/run/abc/mysql/mysqld.sock") + var connection = DatabaseConnection( + name: "Site", host: "localhost", port: 3_306, database: "local", type: .mysql + ) + connection.localSocketPath = socket + + #expect(connection.connectionSubtitle == "local on ~/Library/Application Support/Local/run/abc/mysql/mysqld.sock") + + connection.database = "" + connection.host = "" + #expect(connection.connectionSubtitle == "~/Library/Application Support/Local/run/abc/mysql/mysqld.sock") + } + + @Test("A socket left under an SSH tunnel shows the host the tunnel reaches") + func tunnelHidesLocalSocket() { + var sshConfig = SSHConfiguration() + sshConfig.enabled = true + sshConfig.host = "bastion.example.com" + var connection = DatabaseConnection( + name: "Project", host: "localhost", port: 3_306, + database: "myapp", type: .mysql, sshConfig: sshConfig + ) + connection.additionalFields[MySQLLocalSocket.fieldKey] = "/tmp/mysql.sock" + + #expect(connection.connectionSubtitle == "localhost/myapp via bastion.example.com") + } + @Test("File-based connection with no path falls back to the type name") func fileBasedEmptyFallsBackToType() { let connection = DatabaseConnection( diff --git a/TableProTests/Models/DatabaseConnectionLocalSocketTests.swift b/TableProTests/Models/DatabaseConnectionLocalSocketTests.swift new file mode 100644 index 0000000000..a22a5880f1 --- /dev/null +++ b/TableProTests/Models/DatabaseConnectionLocalSocketTests.swift @@ -0,0 +1,70 @@ +// +// DatabaseConnectionLocalSocketTests.swift +// TableProTests +// + +import Foundation +import TableProPluginKit +import Testing + +@testable import TablePro + +struct DatabaseConnectionLocalSocketTests { + private func connection(type: DatabaseType = .mysql, socket: String? = "/tmp/mysql.sock") -> DatabaseConnection { + var connection = DatabaseConnection(name: "Local", host: "localhost", port: 3_306, type: type) + if let socket { + connection.additionalFields[MySQLLocalSocket.fieldKey] = socket + } + return connection + } + + @Test("MySQL and MariaDB read the stored socket") + func mysqlFamilyReadsTheSocket() { + #expect(connection(type: .mysql).localSocketPath == "/tmp/mysql.sock") + #expect(connection(type: .mariadb).localSocketPath == "/tmp/mysql.sock") + } + + @Test("A type the form offers no socket for reports none") + func otherTypesReportNoSocket() { + for type in [DatabaseType.tidb, .oceanbase, .databend, .postgresql] { + #expect(connection(type: type).localSocketPath == nil, "\(type.rawValue)") + } + } + + @Test("Every tunnel wins over a stored socket, as it does when connecting") + func tunnelWinsOverSocket() { + var ssh = connection() + ssh.sshTunnelMode = .inline(SSHConfiguration(enabled: true, host: "bastion.example.com")) + #expect(ssh.localSocketPath == nil) + + var socks = connection() + socks.socksProxyMode = .inline(SOCKSProxyConfiguration(host: "proxy.example.com")) + #expect(socks.localSocketPath == nil) + } + + @Test("The setter trims the path and removes the key when cleared") + func setterWritesAndRemovesTheKey() { + var connection = connection(socket: nil) + connection.localSocketPath = " /opt/homebrew/var/mysql/mysql.sock " + #expect(connection.additionalFields[MySQLLocalSocket.fieldKey] == "/opt/homebrew/var/mysql/mysql.sock") + + connection.localSocketPath = " " + #expect(connection.additionalFields[MySQLLocalSocket.fieldKey] == nil) + + connection.localSocketPath = "/tmp/mysql.sock" + connection.localSocketPath = nil + #expect(connection.additionalFields[MySQLLocalSocket.fieldKey] == nil) + } + + @Test("A connect timeout names the socket, then the host, then the connection") + func timeoutNamesTheEndpoint() { + #expect(connection().timeoutEndpointName == "/tmp/mysql.sock") + + var tcp = connection(socket: nil) + tcp.host = "db.internal" + #expect(tcp.timeoutEndpointName == "db.internal") + + tcp.host = "" + #expect(tcp.timeoutEndpointName == "Local") + } +} diff --git a/TableProTests/ViewModels/ConnectionFormEditsTests.swift b/TableProTests/ViewModels/ConnectionFormEditsTests.swift index db9c7a74cb..a30c81ba9e 100644 --- a/TableProTests/ViewModels/ConnectionFormEditsTests.swift +++ b/TableProTests/ViewModels/ConnectionFormEditsTests.swift @@ -221,5 +221,20 @@ struct ConnectionFormEditsTests { ConnectionFormEdits.appManagedAdditionalFieldIDs .contains(DatabaseConnection.sshForwardUnixSocketPathKey) ) + #expect(ConnectionFormEdits.appManagedAdditionalFieldIDs.contains(MySQLLocalSocket.fieldKey)) + } + + @Test("Switching a socket connection back to Host and Port removes the socket") + func hostAndPortRemovesTheSocket() { + var original = populatedConnection() + original.type = .mysql + original.additionalFields = [MySQLLocalSocket.fieldKey: "/tmp/mysql.sock"] + + let result = edits( + additionalFields: [:], + ownedAdditionalFieldIDs: ConnectionFormEdits.appManagedAdditionalFieldIDs + ).applied(to: original) + + #expect(result.additionalFields[MySQLLocalSocket.fieldKey] == nil) } } diff --git a/TableProTests/ViewModels/ConnectionFormTransportTests.swift b/TableProTests/ViewModels/ConnectionFormTransportTests.swift index 93f4c65d2a..09c184bcb7 100644 --- a/TableProTests/ViewModels/ConnectionFormTransportTests.swift +++ b/TableProTests/ViewModels/ConnectionFormTransportTests.swift @@ -291,6 +291,98 @@ struct ConnectionFormTransportTests { #expect(coordinator.ssh.state.host == "bastion.example.com") } + @Test("choosing Socket clears the transport and leaves only Direct") + func socketClearsTheTransport() { + let coordinator = coordinator() + coordinator.transport = .ssh + coordinator.ssh.state.host = "bastion.example.com" + + coordinator.selectEndpoint(.localSocket) + + #expect(coordinator.transport == nil) + #expect(enabledFlags(coordinator).isEmpty) + #expect(coordinator.availableTransports == [nil]) + #expect(coordinator.visibleTabs.contains(.network), "SSL Mode still applies over a socket") + #expect(coordinator.ssh.state.host == "bastion.example.com", "The server itself is kept") + } + + @Test("going back to Host and Port offers the transports again") + func hostAndPortRestoresTransports() { + let coordinator = coordinator() + let offered = coordinator.availableTransports + + coordinator.selectEndpoint(.localSocket) + coordinator.selectEndpoint(.hostAndPort) + + #expect(coordinator.availableTransports == offered) + #expect(offered.contains(.ssh)) + } + + @Test("choosing an endpoint clears a passing test result") + func endpointInvalidatesTestResult() { + let coordinator = coordinator() + coordinator.testSucceeded = true + coordinator.selectEndpoint(.localSocket) + #expect(!coordinator.testSucceeded) + } + + @Test("a stored socket under an SSH tunnel loads as Host and Port and keeps the tunnel") + func storedSocketUnderSSHKeepsTheTunnel() { + var stored = DatabaseConnection(name: "Mixed", host: "db.internal", port: 3_306, type: .mysql) + stored.additionalFields[MySQLLocalSocket.fieldKey] = "/tmp/mysql.sock" + stored.sshTunnelMode = .inline(SSHConfiguration(enabled: true, host: "bastion.example.com")) + + let coordinator = coordinator() + coordinator.network.load(from: stored) + coordinator.ssh.state.enabled = true + coordinator.normalizeTransport() + + #expect(coordinator.network.endpoint == .hostAndPort) + #expect(coordinator.transport == .ssh) + #expect(coordinator.buildEdits().additionalFields[MySQLLocalSocket.fieldKey] == nil) + } + + @Test("a URL pasted onto a Socket form returns it to Host and Port with the URL's SSH server") + func urlImportLeavesSocketMode() throws { + let candidate = try #require(ClipboardConnectionCandidate( + clipboardText: "mysql+ssh://deploy@bastion.example.com:22/dbuser@db.internal:3306/app" + )) + let coordinator = coordinator() + coordinator.start() + coordinator.selectEndpoint(.localSocket) + + coordinator.applyClipboardCandidate(candidate) + + #expect(coordinator.network.endpoint == .hostAndPort) + #expect(coordinator.transport == .ssh) + #expect(coordinator.ssh.state.host == "bastion.example.com") + } + + @Test("a socket connection saves its path and no transport") + func socketEditsCarryThePath() { + let coordinator = coordinator() + coordinator.network.name = "Local" + coordinator.selectEndpoint(.localSocket) + coordinator.network.localSocketPath = "/tmp/mysql.sock" + + let edits = coordinator.buildEdits() + + #expect(edits.additionalFields[MySQLLocalSocket.fieldKey] == "/tmp/mysql.sock") + #expect(edits.ownedAdditionalFieldIDs.contains(MySQLLocalSocket.fieldKey)) + #expect(edits.applied(to: DatabaseConnection(name: "")).localSocketPath == "/tmp/mysql.sock") + #expect(coordinator.isFormValid) + } + + @Test("an empty socket blocks Save from the General tab") + func emptySocketIsClaimedByGeneral() { + let coordinator = coordinator() + coordinator.network.name = "Local" + coordinator.selectEndpoint(.localSocket) + + #expect(!coordinator.isFormValid) + #expect(coordinator.firstTabWithIssue == .general) + } + @Test("every issue that blocks Save is claimed by a visible tab") func everyBlockingIssueHasATabToFix() { let coordinator = coordinator() diff --git a/TableProTests/Views/ConnectionForm/NetworkPaneLocalSocketTests.swift b/TableProTests/Views/ConnectionForm/NetworkPaneLocalSocketTests.swift new file mode 100644 index 0000000000..f0c6857432 --- /dev/null +++ b/TableProTests/Views/ConnectionForm/NetworkPaneLocalSocketTests.swift @@ -0,0 +1,198 @@ +// +// NetworkPaneLocalSocketTests.swift +// TableProTests +// + +import Darwin +import Foundation +@testable import TablePro +import Testing + +@MainActor +struct NetworkPaneLocalSocketTests { + private func socketConnection(path: String = "/tmp/mysql.sock", type: DatabaseType = .mysql) -> DatabaseConnection { + var connection = DatabaseConnection(name: "Local", host: "localhost", port: 3_306, type: type) + connection.additionalFields[MySQLLocalSocket.fieldKey] = path + return connection + } + + private func socketViewModel(path: String) -> NetworkPaneViewModel { + let viewModel = NetworkPaneViewModel() + viewModel.name = "Local" + viewModel.type = .mysql + viewModel.endpoint = .localSocket + viewModel.localSocketPath = path + return viewModel + } + + @Test("Loading a socket connection selects Socket and fills the path") + func loadsSocketConnection() { + let viewModel = NetworkPaneViewModel() + viewModel.load(from: socketConnection()) + + #expect(viewModel.endpoint == .localSocket) + #expect(viewModel.localSocketPath == "/tmp/mysql.sock") + #expect(viewModel.usesLocalSocket) + } + + @Test("Loading a TCP connection stays on Host and Port") + func loadsTCPConnection() { + let viewModel = NetworkPaneViewModel() + viewModel.load(from: DatabaseConnection(name: "TCP", host: "db.internal", port: 3_306, type: .mysql)) + + #expect(viewModel.endpoint == .hostAndPort) + #expect(viewModel.localSocketPath.isEmpty) + #expect(!viewModel.usesLocalSocket) + } + + @Test("A stored socket under an SSH tunnel loads as Host and Port, since the tunnel is what connects") + func socketUnderTunnelLoadsAsHostAndPort() { + var connection = socketConnection() + connection.sshTunnelMode = .inline(SSHConfiguration(enabled: true, host: "bastion.example.com")) + + let viewModel = NetworkPaneViewModel() + viewModel.load(from: connection) + + #expect(viewModel.endpoint == .hostAndPort) + #expect(viewModel.localSocketPath.isEmpty) + } + + @Test("Writing in Socket mode stores the trimmed path with ~ expanded") + func writesTrimmedExpandedPath() { + var fields: [String: String] = [:] + socketViewModel(path: " /tmp/mysql.sock\n").write(into: &fields) + #expect(fields[MySQLLocalSocket.fieldKey] == "/tmp/mysql.sock") + + var homeFields: [String: String] = [:] + socketViewModel(path: "~/run/mysqld.sock").write(into: &homeFields) + #expect(homeFields[MySQLLocalSocket.fieldKey] == NSHomeDirectory() + "/run/mysqld.sock") + } + + @Test("Host and Port mode writes no socket, even with a path typed earlier") + func hostAndPortWritesNoSocket() { + let viewModel = socketViewModel(path: "/tmp/mysql.sock") + viewModel.endpoint = .hostAndPort + + var fields: [String: String] = [:] + viewModel.write(into: &fields) + + #expect(fields[MySQLLocalSocket.fieldKey] == nil) + #expect(viewModel.localSocketIssues.isEmpty) + } + + @Test("An empty socket path blocks Save and names the field") + func emptyPathIsRequired() { + let viewModel = socketViewModel(path: " ") + let required = String(format: String(localized: "%@ is required"), String(localized: "Socket")) + #expect(viewModel.validationIssues == [required]) + } + + @Test("A relative socket path blocks Save") + func relativePathIsRejected() { + let viewModel = socketViewModel(path: "mysql.sock") + #expect(viewModel.validationIssues == [MySQLLocalSocket.PathIssue.notAbsolute.message]) + } + + @Test("A path past macOS's 103-byte limit blocks Save with its length") + func longPathIsRejected() { + let path = "/" + String(repeating: "a", count: 103) + let viewModel = socketViewModel(path: path) + #expect(viewModel.validationIssues == [MySQLLocalSocket.PathIssue.tooLong(bytes: 104).message]) + + let atLimit = socketViewModel(path: "/" + String(repeating: "a", count: 102)) + #expect(atLimit.validationIssues.isEmpty) + } + + @Test("A path under ~ is checked after expanding it, so it is absolute") + func tildePathIsAbsolute() { + #expect(socketViewModel(path: "~/mysql.sock").validationIssues.isEmpty) + } + + @Test("Changing the type to PostgreSQL drops the socket") + func typeChangeDropsSocket() { + let viewModel = socketViewModel(path: "") + viewModel.type = .postgresql + + var fields: [String: String] = [:] + viewModel.write(into: &fields) + + #expect(!viewModel.supportsLocalSocket) + #expect(!viewModel.usesLocalSocket) + #expect(fields[MySQLLocalSocket.fieldKey] == nil) + #expect(viewModel.localSocketIssues.isEmpty) + } + + @Test("Socket is offered for MySQL and MariaDB only") + func socketIsOfferedPerType() { + let cases: [(type: DatabaseType, offered: Bool)] = [ + (.mysql, true), (.mariadb, true), (.tidb, false), (.oceanbase, false), + (.databend, false), (.postgresql, false), (.sqlite, false) + ] + for entry in cases { + let viewModel = NetworkPaneViewModel() + viewModel.type = entry.type + #expect(viewModel.supportsLocalSocket == entry.offered, "\(entry.type.rawValue)") + } + } + + @Test("The prompt is the socket path a Homebrew or installer MySQL uses") + func promptIsTheDefaultSocket() { + let viewModel = NetworkPaneViewModel() + viewModel.type = .mariadb + #expect(viewModel.localSocketPrompt == "/tmp/mysql.sock") + } + + @Test("A missing path, a plain file and a socket each read as such") + func fileStatusFollowsTheFile() throws { + let directory = URL(fileURLWithPath: "/tmp", isDirectory: true) + .appendingPathComponent("tp-\(UUID().uuidString.prefix(8))", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let missing = directory.appendingPathComponent("missing.sock").path + #expect(NetworkPaneViewModel.localSocketFileStatus(atPath: missing) == .missing) + + let file = directory.appendingPathComponent("plain.sock").path + #expect(FileManager.default.createFile(atPath: file, contents: Data())) + #expect(NetworkPaneViewModel.localSocketFileStatus(atPath: file) == .notSocket) + + let socketPath = directory.appendingPathComponent("m.sock").path + let descriptor = try bindUnixSocket(at: socketPath) + defer { close(descriptor) } + #expect(NetworkPaneViewModel.localSocketFileStatus(atPath: socketPath) == .socket) + + let link = directory.appendingPathComponent("link.sock").path + try FileManager.default.createSymbolicLink(atPath: link, withDestinationPath: socketPath) + #expect(NetworkPaneViewModel.localSocketFileStatus(atPath: link) == .socket) + } + + @Test("The file status stays quiet until the path itself is valid") + func fileStatusWaitsForAValidPath() { + #expect(socketViewModel(path: "").localSocketFileStatus == nil) + #expect(socketViewModel(path: "relative.sock").localSocketFileStatus == nil) + #expect(socketViewModel(path: "/nonexistent-tablepro/m.sock").localSocketFileStatus == .missing) + } + + private func bindUnixSocket(at path: String) throws -> Int32 { + let descriptor = socket(AF_UNIX, SOCK_STREAM, 0) + guard descriptor >= 0 else { throw POSIXError(.EBADF) } + var address = sockaddr_un() + address.sun_family = sa_family_t(AF_UNIX) + let bytes = Array(path.utf8) + withUnsafeMutableBytes(of: &address.sun_path) { buffer in + for (index, byte) in bytes.enumerated() where index < buffer.count - 1 { + buffer[index] = byte + } + } + let result = withUnsafePointer(to: &address) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { + bind(descriptor, $0, socklen_t(MemoryLayout.size)) + } + } + guard result == 0 else { + close(descriptor) + throw POSIXError(.EADDRINUSE) + } + return descriptor + } +} diff --git a/TableProUITests/ConnectionFormLocalSocketUITests.swift b/TableProUITests/ConnectionFormLocalSocketUITests.swift new file mode 100644 index 0000000000..dae624d5e9 --- /dev/null +++ b/TableProUITests/ConnectionFormLocalSocketUITests.swift @@ -0,0 +1,104 @@ +import XCTest + +final class ConnectionFormLocalSocketUITests: UITestCase { + private let endpointPicker = "connection-form-endpoint" + private let socketField = "connection-form-socket-path" + private let hostField = "connection-form-host" + private let portField = "connection-form-port" + private let transportPicker = "connection-form-transport" + + func testSocketReplacesHostAndPortAndLeavesOnlyDirect() throws { + let app = try launchApp() + XCTAssertTrue(app.windows.firstMatch.waitToExist(timeout: 10)) + + let form = try openConnectionForm(for: "MySQL", in: app) + let name = form.textFields["connection-form-name"] + XCTAssertTrue(name.waitToExist(timeout: 10)) + XCTAssertTrue(waitUntilHittable(name, timeout: 10)) + name.click() + app.typeText("Local") + + let picker = form.popUpButtons[endpointPicker] + XCTAssertTrue(picker.waitToExist(timeout: 10), "MySQL should offer Connect Using") + XCTAssertTrue(form.textFields[hostField].exists, "Host and Port is the default") + XCTAssertFalse(form.textFields[socketField].exists) + + select(option: "Socket", in: picker) + XCTAssertTrue(form.textFields[socketField].waitToExist(timeout: 5), "Socket should show its path field") + XCTAssertTrue( + waitForPredicate(timeout: 5) { !form.textFields[self.hostField].exists }, + "Socket replaces Host" + ) + XCTAssertFalse(form.textFields[portField].exists, "Socket replaces Port") + + let validation = form.descendants(matching: .any).matching(identifier: "connection-form-validation") + XCTAssertTrue( + waitForPredicate(timeout: 5) { validation.firstMatch.exists }, + "An empty socket path should dim Save" + ) + let reason = validation.firstMatch.label + " " + String(describing: validation.firstMatch.value ?? "") + XCTAssertTrue(reason.contains("Socket"), "Save should wait for the socket path, not: \(reason)") + + selectConnectionFormTab("network", in: form) + let transport = form.popUpButtons[transportPicker] + XCTAssertTrue(transport.waitToExist(timeout: 10)) + XCTAssertTrue(waitUntilHittable(transport, timeout: 10)) + transport.click() + XCTAssertTrue(transport.menuItems["Direct"].waitToExist(timeout: 5)) + XCTAssertEqual(transport.menuItems.count, 1, "A socket connection offers only Direct") + app.typeKey(.escape, modifierFlags: []) + + selectConnectionFormTab("general", in: form) + let socket = form.textFields[socketField] + XCTAssertTrue(socket.waitToExist(timeout: 10)) + XCTAssertTrue(waitUntilHittable(socket, timeout: 10)) + socket.click() + app.typeText("/tmp/mysql.sock") + XCTAssertTrue( + waitForPredicate(timeout: 5) { !validation.firstMatch.exists }, + "A valid socket path should leave nothing blocking Save" + ) + + select(option: "Host and Port", in: form.popUpButtons[endpointPicker]) + XCTAssertTrue(form.textFields[hostField].waitToExist(timeout: 5), "Host and Port should come back") + XCTAssertFalse(form.textFields[socketField].exists) + } + + private func select(option: String, in picker: XCUIElement) { + XCTAssertTrue(waitUntilHittable(picker, timeout: 10)) + picker.click() + let item = picker.menuItems[option] + XCTAssertTrue(item.waitToExist(timeout: 5), "The picker should offer \(option)") + item.click() + } + + private func openConnectionForm(for type: String, in app: XCUIApplication) throws -> XCUIElement { + let newConnection = app.menuBars.menuItems["New Connection…"] + XCTAssertTrue(newConnection.waitToExist(timeout: 10)) + newConnection.click() + + let sheet = app.sheets.firstMatch + XCTAssertTrue(sheet.waitToExist(timeout: 10), "New Connection… should open the chooser sheet") + + let search = sheet.searchFields.firstMatch + XCTAssertTrue(search.waitToExist(timeout: 10), "The chooser should offer its search field") + XCTAssertTrue(waitUntilHittable(search, timeout: 10)) + search.click() + search.typeText(type) + XCTAssertTrue( + waitForPredicate(timeout: 10) { (search.value as? String) == type }, + "Typing should reach the chooser's search field" + ) + + let row = sheet.outlines.firstMatch.staticTexts + .matching(NSPredicate(format: "value == %@", type)) + .firstMatch + XCTAssertTrue(row.waitToExist(timeout: 10), "The chooser should list \(type)") + XCTAssertTrue(waitUntilHittable(row, timeout: 10)) + row.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).doubleClick() + + let form = app.windows["connection-form"] + XCTAssertTrue(form.waitToExist(timeout: 10), "Choosing \(type) should open the connection form") + return form + } +} diff --git a/docs/connections/connection-form.mdx b/docs/connections/connection-form.mdx index a6c64055fd..a40a97b358 100644 --- a/docs/connections/connection-form.mdx +++ b/docs/connections/connection-form.mdx @@ -25,8 +25,10 @@ The bar along the bottom carries **Test Connection**, **Cancel** and **Save**, p |-------|-------------| | **Name** | Display name in the connection list | | **Type** | The database type. **Change…** retypes the connection and resets the fields the old type owned | +| **Connect Using** | MySQL and MariaDB. **Host and Port**, or **Socket** for a server on this Mac. See [Unix socket](/databases/mysql#unix-socket) | | **Host** | Empty falls back to the driver's own default, usually `localhost` | | **Port** | Pre-filled from the database type | +| **Socket** | Replaces Host and Port when **Connect Using** is **Socket**. The full path of the server's socket file, such as `/tmp/mysql.sock` | | **Database** | Optional on drivers that browse every database from one session. PostgreSQL and Redshift do not connect without one | | **Username** | Not pre-filled. Empty means the driver's own default | | **Password** | Stored in the macOS Keychain | @@ -51,6 +53,8 @@ SQLite, DuckDB, libSQL, and Beancount replace the host fields with a file path. Leave it on **Direct** unless the database is unreachable from this Mac. Switching to another transport keeps what you typed into the previous one, so switching back costs nothing. +A connection set to **Socket** on General offers only **Direct**, since a tunnel cannot reach a socket file on this Mac. + **Socket Path**, under **SSH Tunnel**, forwards to a unix socket instead of Host and Port, which are then unused. See [Forwarding to a unix socket](/connections/ssh-tunneling#forwarding-to-a-unix-socket). Encryption sits below the transport. [SSL/TLS](/connections/ssl) covers the modes and the certificate fields. diff --git a/docs/databases/mariadb.mdx b/docs/databases/mariadb.mdx index 21a4657ebe..b81210bf97 100644 --- a/docs/databases/mariadb.mdx +++ b/docs/databases/mariadb.mdx @@ -3,7 +3,7 @@ title: MariaDB description: Connect to MariaDB 10.x and later through the bundled MySQL driver --- -MySQL and MariaDB load the same bundled driver, so [MySQL](/databases/mysql) carries the setup. Port `3306`, an optional Database field, `Cmd+K` to change database in place, SSL/TLS starting on **Preferred**, [AWS IAM](/connections/aws-iam) and SSH tunnels. MariaDB 10.x and later are supported. The rest of this page is what differs. +MySQL and MariaDB load the same bundled driver, so [MySQL](/databases/mysql) carries the setup. Port `3306`, an optional Database field, `Cmd+K` to change database in place, SSL/TLS starting on **Preferred**, [AWS IAM](/connections/aws-iam), SSH tunnels, and a [Unix socket](/databases/mysql#unix-socket) for a server on this Mac. MariaDB 10.x and later are supported. The rest of this page is what differs. ## Connection URL diff --git a/docs/databases/mysql.mdx b/docs/databases/mysql.mdx index 2392c865f2..cb2fd3d713 100644 --- a/docs/databases/mysql.mdx +++ b/docs/databases/mysql.mdx @@ -12,7 +12,7 @@ MySQL 8 accounts on `caching_sha2_password` connect on the first try, with no au Click **New Connection…** on the welcome window and pick **MySQL** - Fill in host, port, and credentials, click **Test Connection**, then **Save & Connect** + Fill in host, port, and credentials, or pick **Socket** under **Connect Using** for a server on this Mac. Click **Test Connection**, then **Save & Connect** @@ -20,8 +20,10 @@ MySQL 8 accounts on `caching_sha2_password` connect on the first try, with no au | Field | Default | Notes | |-------|---------|-------| -| **Host** | `localhost` | TCP only, never a Unix socket | +| **Connect Using** | **Host and Port** | **Socket** connects through the server's socket file on this Mac. See [Unix socket](#unix-socket) | +| **Host** | `localhost` | Always TCP, `localhost` included | | **Port** | `3306` | | +| **Socket** | empty | With **Connect Using** set to **Socket**: the full path of the socket file | | **Username** | empty | An empty field connects as your macOS login name | | **Password** | empty | Stored in the macOS Keychain | | **Database** | empty | Optional. Leave it empty and open one with `Cmd+K` | @@ -30,6 +32,26 @@ Every connection sets its session character set to `utf8mb4` once it logs in, ov the server runs, so emoji and non-Latin text round-trip untouched. The **Connect timeout** under **Options** bounds the whole attempt; an empty field uses 30 seconds. +## Unix socket + +A server on this Mac listens on a socket file as well as a port, and a server started with `skip-networking` listens only there. Set **Connect Using** to **Socket** and enter the file's path. Host and Port leave the form, and **Network** offers only **Direct**: a tunnel cannot reach a file on this Mac. + +The server reports its path: + +```sql +SELECT @@socket; +``` + +| Where | Socket | +|-------|--------| +| Homebrew, MySQL installer | `/tmp/mysql.sock` | +| MAMP | `/Applications/MAMP/tmp/mysql/mysql.sock` | +| Local | `~/Library/Application Support/Local/run//mysql/mysqld.sock` | + +The note under the field says when nothing is at the path or the file there is not a socket. Save still works, so a connection can be set up before its server starts. A path starting with `~` is expanded, and macOS caps a socket path at 103 bytes. + +**SSL Mode** still applies. **Preferred** connects without TLS over a socket, as the `mysql` client does; **Required** and stricter encrypt the connection. Backup and restore reach the server through the same socket. + ## Connection URL ```text @@ -157,7 +179,6 @@ New connections default to **Preferred**: TLS first, dropping to plain text only ## Limitations -- No Unix socket connections. Give the connection a host and a port, and leave networking on in the server. - `LOAD DATA LOCAL INFILE` is refused by the driver. Load the file with **File > Import > Import Data…** instead. - An index key over an expression, such as `lower(email)`, needs MySQL 8.0.13 or later. MariaDB has none, so index a generated column instead. The **Indexes** tab stops the save on either server before anything runs. @@ -167,6 +188,16 @@ New connections default to **Preferred**: TLS first, dropping to plain text only Nothing answered on that host and port. Check the server is running (`brew services start mysql`), the port is right, and `skip-networking` is off. +### Can't connect to local server through socket '…' (2) + +The number at the end says why the socket did not open: + +| Number | Cause | +|--------|-------| +| `2` | Nothing is at that path. Start the server, or copy the path from `SELECT @@socket;` | +| `61` | The file is there and no server is listening on it, usually left behind by a server that stopped. Start the server | +| `13` | Your macOS account cannot open the file or a folder on its path | + ### Access denied for user '…'@'…' (using password: YES) The password is wrong, or no account covers the host you connect from. Sign in with an account that can read the `mysql` schema and run `SHOW GRANTS FOR 'name'@'host';` with the real user name and host. Without that access, the statement fails with `SELECT command denied`.