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
Original file line number Diff line number Diff line change
Expand Up @@ -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? {
Expand All @@ -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"
}
Expand Down
4 changes: 4 additions & 0 deletions TablePro/Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -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" : {
Expand Down
8 changes: 8 additions & 0 deletions TableProMobile/TableProMobile/Helpers/AppError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
}
}

Expand Down
23 changes: 23 additions & 0 deletions TableProMobile/TableProMobile/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -11140,6 +11140,9 @@
}
}
}
},
"Open it on the Mac, or change it there to connect by host and port." : {

},
"Open Sample Database" : {
"localizations" : {
Expand Down Expand Up @@ -13914,6 +13917,9 @@
"Snowflake" : {
"extractionState" : "manual",
"generatesSymbol" : false
},
"Socket Unavailable" : {

},
"Sparkles" : {
"extractionState" : "manual",
Expand Down Expand Up @@ -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" : {
Expand Down Expand Up @@ -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" : {
Expand Down
9 changes: 9 additions & 0 deletions TableProMobile/TableProMobile/Platform/IOSDriverFactory.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
63 changes: 63 additions & 0 deletions TableProMobile/TableProMobileTests/MySQLVariantSupportTests.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Foundation
import TableProDatabase
@testable import TableProMobile
import TableProModels
import Testing
Expand Down Expand Up @@ -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) == [
Expand Down
3 changes: 3 additions & 0 deletions TableProMobile/project.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions docs/ios/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading