diff --git a/Packages/TableProCore/Sources/TableProDatabase/ConnectionError.swift b/Packages/TableProCore/Sources/TableProDatabase/ConnectionError.swift index da7068865e..c07e9a4fc9 100644 --- a/Packages/TableProCore/Sources/TableProDatabase/ConnectionError.swift +++ b/Packages/TableProCore/Sources/TableProDatabase/ConnectionError.swift @@ -4,6 +4,7 @@ public enum ConnectionError: Error, LocalizedError, Equatable { case driverNotFound(String) case notConnected case sshNotSupported + case localSocketNotSupported case previousSessionStillClosing public var errorDescription: String? { @@ -14,6 +15,8 @@ public enum ConnectionError: Error, LocalizedError, Equatable { return "Not connected to database" case .sshNotSupported: return "SSH tunneling is not available on this platform" + case .localSocketNotSupported: + return String(localized: "This connection uses a socket on a Mac and cannot be opened on iPhone or iPad.") case .previousSessionStillClosing: return "The previous session is still closing" } diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 15652cf6bf..1d09e76d98 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -190478,6 +190478,10 @@ } } }, + "This connection uses a socket on a Mac and cannot be opened on iPhone or iPad." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "This connection was deleted on another device or window. Your changes were not saved." : { "localizations" : { "fr" : { diff --git a/TableProMobile/TableProMobile/Helpers/AppError.swift b/TableProMobile/TableProMobile/Helpers/AppError.swift index 85b51bcf64..1036e6424e 100644 --- a/TableProMobile/TableProMobile/Helpers/AppError.swift +++ b/TableProMobile/TableProMobile/Helpers/AppError.swift @@ -222,6 +222,14 @@ nonisolated enum ErrorClassifier { recovery: String(localized: "Turn off the SSH tunnel for this connection."), underlying: error ) + case .localSocketNotSupported: + return AppError( + category: .config, + title: String(localized: "Socket Unavailable"), + message: error.localizedDescription, + recovery: String(localized: "Open it on the Mac, or change it there to connect by host and port."), + underlying: error + ) } } diff --git a/TableProMobile/TableProMobile/Localizable.xcstrings b/TableProMobile/TableProMobile/Localizable.xcstrings index 3168548037..e3fcc38ce7 100644 --- a/TableProMobile/TableProMobile/Localizable.xcstrings +++ b/TableProMobile/TableProMobile/Localizable.xcstrings @@ -11140,6 +11140,9 @@ } } } + }, + "Open it on the Mac, or change it there to connect by host and port." : { + }, "Open Sample Database" : { "localizations" : { @@ -13914,6 +13917,9 @@ "Snowflake" : { "extractionState" : "manual", "generatesSymbol" : false + }, + "Socket Unavailable" : { + }, "Sparkles" : { "extractionState" : "manual", @@ -17974,6 +17980,19 @@ } } } + }, + "The socket path is %d bytes. macOS allows %d at most." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "The socket path is %1$d bytes. macOS allows %2$d at most." + } + } + } + }, + "The socket path must start with /." : { + }, "The server's host key has changed." : { "localizations" : { @@ -18425,6 +18444,10 @@ } } }, + "This connection uses a socket on a Mac and cannot be opened on iPhone or iPad." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "This connection's CA certificate is not readable on this device (%@). Certificate files do not sync between devices, so add the certificate here or lower the SSL mode to Required." : { "extractionState" : "stale", "localizations" : { diff --git a/TableProMobile/TableProMobile/Platform/IOSDriverFactory.swift b/TableProMobile/TableProMobile/Platform/IOSDriverFactory.swift index 0b3f5a77f6..f79acc84d8 100644 --- a/TableProMobile/TableProMobile/Platform/IOSDriverFactory.swift +++ b/TableProMobile/TableProMobile/Platform/IOSDriverFactory.swift @@ -77,6 +77,12 @@ nonisolated final class IOSDriverFactory: DriverFactory { return resolved } + /// A tunnel replaces the socket on the Mac too, so only a direct connection would dial it. + private static func dialsMacSocket(_ connection: DatabaseConnection) -> Bool { + guard MySQLLocalSocket.path(in: connection.additionalFields) != nil else { return false } + return !(connection.sshEnabled && connection.sshConfiguration != nil) + } + func createDriver(for connection: DatabaseConnection, password: String?) throws -> any DatabaseDriver { switch connection.type { case .sqlite where connection.isSample: @@ -86,6 +92,9 @@ nonisolated final class IOSDriverFactory: DriverFactory { case .duckdb: return DuckDBDriver(source: try duckDBSource(for: connection)) case .mysql, .mariadb, .tidb, .oceanbase: + guard !Self.dialsMacSocket(connection) else { + throw ConnectionError.localSocketNotSupported + } return MySQLDriver( host: connection.host, port: connection.port, diff --git a/TableProMobile/TableProMobileTests/ConnectionErrorClassifierTests.swift b/TableProMobile/TableProMobileTests/ConnectionErrorClassifierTests.swift index 9e6cc907fa..73a8293aa0 100644 --- a/TableProMobile/TableProMobileTests/ConnectionErrorClassifierTests.swift +++ b/TableProMobile/TableProMobileTests/ConnectionErrorClassifierTests.swift @@ -30,6 +30,19 @@ struct ConnectionErrorClassifierTests { #expect(error.recovery == String(localized: "Turn off the SSH tunnel for this connection.")) } + @Test("A Mac socket connection is a configuration problem that points back to the Mac") + func localSocketIsConfiguration() { + let error = ErrorClassifier.classify( + ConnectionError.localSocketNotSupported, + context: ErrorContext(operation: "connect", host: "localhost") + ) + + #expect(error.category == .config) + #expect(error.title == String(localized: "Socket Unavailable")) + #expect(error.message == ConnectionError.localSocketNotSupported.localizedDescription) + #expect(error.recovery == String(localized: "Open it on the Mac, or change it there to connect by host and port.")) + } + @Test("A missing driver is a configuration problem and names the type") func driverNotFoundIsConfiguration() { let error = ErrorClassifier.classify( diff --git a/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionFormEditsTests.swift b/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionFormEditsTests.swift index e6dca5f154..3d7d31e9b0 100644 --- a/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionFormEditsTests.swift +++ b/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionFormEditsTests.swift @@ -89,6 +89,24 @@ struct ConnectionFormEditsTests { #expect(saved.tagIds == [picked, macSecond]) } + @Test("Editing a connection that uses a Mac socket keeps the socket path") + func editKeepsMacSocketPath() { + let snapshot = DatabaseConnection( + name: "Local", + type: .mysql, + host: "localhost", + additionalFields: [MySQLLocalSocket.fieldKey: "/tmp/mysql.sock"] + ) + let viewModel = ConnectionFormViewModel(editing: snapshot) + + viewModel.name = "Local MySQL" + viewModel.host = "127.0.0.1" + viewModel.port = "3307" + + #expect(viewModel.applyingEdits(to: snapshot).additionalFields[MySQLLocalSocket.fieldKey] == "/tmp/mysql.sock") + #expect(viewModel.buildConnection().additionalFields[MySQLLocalSocket.fieldKey] == "/tmp/mysql.sock") + } + @Test("An SSH port edit keeps the jump hosts and the Mac's own tunnel settings") func sshEditKeepsMacFields() { var ssh = SSHConfiguration(host: "bastion.example.com", port: 22, username: "deploy") diff --git a/TableProMobile/TableProMobileTests/MySQLVariantSupportTests.swift b/TableProMobile/TableProMobileTests/MySQLVariantSupportTests.swift index 80e6746826..1ab4c08878 100644 --- a/TableProMobile/TableProMobileTests/MySQLVariantSupportTests.swift +++ b/TableProMobile/TableProMobileTests/MySQLVariantSupportTests.swift @@ -1,4 +1,5 @@ import Foundation +import TableProDatabase @testable import TableProMobile import TableProModels import Testing @@ -50,6 +51,68 @@ struct MySQLVariantSupportTests { #expect(mysql.databaseType == .oceanbase) } + @Test( + "A connection that dials a socket on the Mac is refused before connecting", + arguments: [DatabaseType.mysql, .mariadb, .tidb, .oceanbase] + ) + func macSocketIsRefused(type: DatabaseType) { + let connection = DatabaseConnection( + name: "Local", + type: type, + host: "localhost", + additionalFields: [MySQLLocalSocket.fieldKey: "/tmp/mysql.sock"] + ) + + #expect(throws: ConnectionError.localSocketNotSupported) { + try IOSDriverFactory().createDriver(for: connection, password: nil) + } + } + + @Test("A blank socket path connects by host and port, as the Mac driver does") + func blankSocketPathConnectsByHost() throws { + let connection = DatabaseConnection( + name: "Local", + type: .mysql, + host: "db.example.com", + additionalFields: [MySQLLocalSocket.fieldKey: " "] + ) + + let driver = try IOSDriverFactory().createDriver(for: connection, password: nil) + + #expect(driver is MySQLDriver) + } + + @Test("An SSH tunnel takes the place of the socket, as it does on the Mac") + func sshTunnelReplacesTheSocket() throws { + let connection = DatabaseConnection( + name: "Remote", + type: .mariadb, + host: "127.0.0.1", + port: 62_000, + additionalFields: [MySQLLocalSocket.fieldKey: "/tmp/mysql.sock"], + sshEnabled: true, + sshConfiguration: SSHConfiguration(host: "bastion.example.com", username: "deploy") + ) + + let driver = try IOSDriverFactory().createDriver(for: connection, password: nil) + + #expect(driver is MySQLDriver) + } + + @Test("SSH switched on without a tunnel configured leaves the socket to be refused") + func sshWithoutTunnelStillRefused() { + let connection = DatabaseConnection( + name: "Local", + type: .mysql, + additionalFields: [MySQLLocalSocket.fieldKey: "/tmp/mysql.sock"], + sshEnabled: true + ) + + #expect(throws: ConnectionError.localSocketNotSupported) { + try IOSDriverFactory().createDriver(for: connection, password: nil) + } + } + @Test("An OceanBase session lifts the server's own statement limit; other MySQL engines set nothing") func oceanBaseSessionSetup() { #expect(MySQLDriver.sessionSetupStatements(for: .oceanbase) == [ diff --git a/TableProMobile/project.yml b/TableProMobile/project.yml index c19fae6d37..c1d5055bd1 100644 --- a/TableProMobile/project.yml +++ b/TableProMobile/project.yml @@ -74,6 +74,9 @@ targets: - ../Plugins/MySQLDriverPlugin/MySQLCreateTableScanner.swift - ../Plugins/MySQLDriverPlugin/MySQLForeignKeyClause.swift - ../Plugins/MySQLDriverPlugin/MySQLServerFlavor.swift + # The socket key a Mac connection stores, so iOS refuses exactly what the Mac driver dials + # through a socket. + - ../Plugins/MySQLDriverPlugin/MySQLLocalSocket.swift - ../Plugins/Shared/PluginQueryTimeout.swift # The column default reading both apps show, plus the literal spellings it writes and the # version floor that says which catalog quotes its defaults. The DDL writers stay on macOS. diff --git a/docs/ios/index.mdx b/docs/ios/index.mdx index cfac4484a0..6a24ee1d83 100644 --- a/docs/ios/index.mdx +++ b/docs/ios/index.mdx @@ -132,6 +132,7 @@ A running query appears in a Live Activity on the lock screen and Dynamic Island - **Schema changes.** Truncating and dropping a table is the whole list; creating or altering tables, columns, indexes, triggers, and views is Mac only. - **Changing Redis data.** Keys open read only; change them in **Query**. - **Jump hosts.** A connection whose SSH tunnel goes through a bastion opens on the Mac only. Its hops sync here and back intact. +- **Socket connections.** A MySQL or MariaDB connection that uses a socket on the Mac opens there only. Its socket path syncs here and back intact. - **AI.** No chat, no inline suggestions, no MCP server. ## Security