diff --git a/apple/Packages/TableProCore/Sources/TableProDatabase/ConnectionError.swift b/apple/Packages/TableProCore/Sources/TableProDatabase/ConnectionError.swift index c07e9a4fc9..081b455dd7 100644 --- a/apple/Packages/TableProCore/Sources/TableProDatabase/ConnectionError.swift +++ b/apple/Packages/TableProCore/Sources/TableProDatabase/ConnectionError.swift @@ -10,15 +10,15 @@ public enum ConnectionError: Error, LocalizedError, Equatable { public var errorDescription: String? { switch self { case .driverNotFound(let type): - return "No driver available for database type: \(type)" + return String(format: String(localized: "TablePro on iPhone and iPad can't open %@ connections."), type) case .notConnected: - return "Not connected to database" + return String(localized: "Not connected to database") case .sshNotSupported: - return "SSH tunneling is not available on this platform" + return String(localized: "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" + return String(localized: "The previous session is still closing") } } } diff --git a/apple/Packages/TableProCore/Sources/TableProDatabase/ConnectionManager.swift b/apple/Packages/TableProCore/Sources/TableProDatabase/ConnectionManager.swift index 4a2dc13a3b..196219fed0 100644 --- a/apple/Packages/TableProCore/Sources/TableProDatabase/ConnectionManager.swift +++ b/apple/Packages/TableProCore/Sources/TableProDatabase/ConnectionManager.swift @@ -31,17 +31,13 @@ public final class ConnectionManager: @unchecked Sendable { self.clock = clock } - /// Opens a session, waiting out a teardown still running for the same connection first. - /// - /// That wait ends on the calling task's cancellation with `CancellationError`, and after - /// `teardownWaitLimit` with `ConnectionError.previousSessionStillClosing`. Neither ending opens a - /// session over resources the old driver still holds, and neither retires the teardown: it keeps - /// running, it keeps counting as a suspension-blocking resource, and the next attempt succeeds as - /// soon as it lands. + // A teardown wait that gives up leaves the teardown running and counting as suspension-blocking. public func connect( _ connection: DatabaseConnection, prompter: (any ConnectionPrompter)? = nil ) async throws -> ConnectionSession { + // Ahead of beginAttempt, so a refusal neither cancels an attempt in flight nor tears down the open session. + if let refusal = driverFactory.refusal(for: connection) { throw refusal } let generation = beginAttempt(for: connection.id) switch await awaitTeardown(of: connection.id, bound: .after(teardownWaitLimit)) { case .cleared: diff --git a/apple/Packages/TableProCore/Sources/TableProDatabase/Protocols/DriverFactory.swift b/apple/Packages/TableProCore/Sources/TableProDatabase/Protocols/DriverFactory.swift index 5a22a719aa..da68ce7933 100644 --- a/apple/Packages/TableProCore/Sources/TableProDatabase/Protocols/DriverFactory.swift +++ b/apple/Packages/TableProCore/Sources/TableProDatabase/Protocols/DriverFactory.swift @@ -1,9 +1,14 @@ import Foundation import TableProModels -/// Creates database drivers for a given connection. -/// macOS: plugin-based implementation. iOS: direct driver creation. public protocol DriverFactory: Sendable { func createDriver(for connection: DatabaseConnection, password: String?) throws -> any DatabaseDriver func supportedTypes() -> [DatabaseType] + func refusal(for connection: DatabaseConnection) -> ConnectionError? +} + +public extension DriverFactory { + func refusal(for connection: DatabaseConnection) -> ConnectionError? { + supportedTypes().contains(connection.type) ? nil : .driverNotFound(connection.type.rawValue) + } } diff --git a/apple/Packages/TableProCore/Sources/TableProModels/SSLConfiguration.swift b/apple/Packages/TableProCore/Sources/TableProModels/SSLConfiguration.swift index c1ad7fd45d..d95c6d531e 100644 --- a/apple/Packages/TableProCore/Sources/TableProModels/SSLConfiguration.swift +++ b/apple/Packages/TableProCore/Sources/TableProModels/SSLConfiguration.swift @@ -1,10 +1,15 @@ import Foundation +import TableProPluginKit public struct SSLConfiguration: Codable, Hashable, Sendable { - public var mode: SSLMode + public var mode: SSLMode { + didSet { if mode != oldValue { isMacPreferred = false } } + } public var caCertificatePath: String? public var clientCertificatePath: String? public var clientKeyPath: String? + // The Mac's Preferred falls back to plaintext. iOS has no such mode and opens it as .require, but writes Preferred back. + public private(set) var isMacPreferred: Bool public enum SSLMode: String, Codable, Sendable { case disable @@ -22,6 +27,15 @@ public struct SSLConfiguration: Codable, Hashable, Sendable { default: self = .disable } } + + fileprivate var macMode: TableProPluginKit.SSLMode { + switch self { + case .disable: .disabled + case .require: .required + case .verifyCa: .verifyCa + case .verifyFull: .verifyIdentity + } + } } private enum CodingKeys: String, CodingKey { @@ -31,20 +45,46 @@ public struct SSLConfiguration: Codable, Hashable, Sendable { public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) mode = (try? container.decode(SSLMode.self, forKey: .mode)) ?? .disable - caCertificatePath = try? container.decode(String.self, forKey: .caCertificatePath) - clientCertificatePath = try? container.decode(String.self, forKey: .clientCertificatePath) - clientKeyPath = try? container.decode(String.self, forKey: .clientKeyPath) + isMacPreferred = (try? container.decode(String.self, forKey: .mode)) == TableProPluginKit.SSLMode.preferred.rawValue + caCertificatePath = Self.path(in: container, forKey: .caCertificatePath) + clientCertificatePath = Self.path(in: container, forKey: .clientCertificatePath) + clientKeyPath = Self.path(in: container, forKey: .clientKeyPath) + } + + // The Mac decodes this as TableProPluginKit.SSLConfiguration and skips a record it cannot read: its mode + // names, and every path key, an unset one as "". + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(macMode.rawValue, forKey: .mode) + try container.encode(caCertificatePath ?? "", forKey: .caCertificatePath) + try container.encode(clientCertificatePath ?? "", forKey: .clientCertificatePath) + try container.encode(clientKeyPath ?? "", forKey: .clientKeyPath) } public init( mode: SSLMode = .disable, caCertificatePath: String? = nil, clientCertificatePath: String? = nil, - clientKeyPath: String? = nil + clientKeyPath: String? = nil, + isMacPreferred: Bool = false ) { self.mode = mode self.caCertificatePath = caCertificatePath self.clientCertificatePath = clientCertificatePath self.clientKeyPath = clientKeyPath + self.isMacPreferred = isMacPreferred && mode == .require + } + + private var macMode: TableProPluginKit.SSLMode { + isMacPreferred ? .preferred : mode.macMode + } + + public static func isMacWireForm(_ data: Data) -> Bool { + (try? JSONDecoder().decode(TableProPluginKit.SSLConfiguration.self, from: data)) != nil + } + + private static func path(in container: KeyedDecodingContainer, forKey key: CodingKeys) -> String? { + guard let value = try? container.decode(String.self, forKey: key), !value.isEmpty else { return nil } + return value } } diff --git a/apple/Packages/TableProCore/Sources/TableProSync/SyncRecordMapper.swift b/apple/Packages/TableProCore/Sources/TableProSync/SyncRecordMapper.swift index cbbba4c4c9..ab6d195eee 100644 --- a/apple/Packages/TableProCore/Sources/TableProSync/SyncRecordMapper.swift +++ b/apple/Packages/TableProCore/Sources/TableProSync/SyncRecordMapper.swift @@ -253,7 +253,8 @@ public enum SyncRecordMapper { } if let sslConfig = connection.sslConfiguration { - if let data = try? encoder.encode(sslConfig) { + if !serverSSLMatches(fields[.sslConfigJson] as? Data, sslConfig), + let data = try? encoder.encode(sslConfig) { fields[.sslConfigJson] = data as CKRecordValue } } else { @@ -272,6 +273,18 @@ public enum SyncRecordMapper { fields[.modifiedAtLocal] = Date() as CKRecordValue } + // A local copy without isMacPreferred may be one an older iOS build stored, so it keeps the server's Preferred. + // A blob an older iOS build wrote ("require", nil paths omitted) is rewritten: the Mac cannot read it. + private static func serverSSLMatches(_ data: Data?, _ local: SSLConfiguration) -> Bool { + guard let data, SSLConfiguration.isMacWireForm(data), + let server = try? decoder.decode(SSLConfiguration.self, from: data) else { return false } + return (server.isMacPreferred || !local.isMacPreferred) + && server.mode == local.mode + && server.caCertificatePath == local.caCertificatePath + && server.clientCertificatePath == local.clientCertificatePath + && server.clientKeyPath == local.clientKeyPath + } + private static func syncedAdditionalFields(for connection: DatabaseConnection) -> [String: String] { var fields = connection.additionalFields fields.removeValue(forKey: DatabaseConnection.connectTimeoutSecondsKey) diff --git a/apple/Packages/TableProCore/Tests/TableProDatabaseTests/ConnectionManagerTestSupport.swift b/apple/Packages/TableProCore/Tests/TableProDatabaseTests/ConnectionManagerTestSupport.swift index 8948e701b3..d0f02249ba 100644 --- a/apple/Packages/TableProCore/Tests/TableProDatabaseTests/ConnectionManagerTestSupport.swift +++ b/apple/Packages/TableProCore/Tests/TableProDatabaseTests/ConnectionManagerTestSupport.swift @@ -61,7 +61,7 @@ final class MockDriverFactory: DriverFactory, @unchecked Sendable { return driver } - func supportedTypes() -> [DatabaseType] { [] } + func supportedTypes() -> [DatabaseType] { drivers.keys.map(DatabaseType.init(rawValue:)) } } final class MockSecureStore: SecureStore, Sendable { diff --git a/apple/Packages/TableProCore/Tests/TableProDatabaseTests/ConnectionManagerTests.swift b/apple/Packages/TableProCore/Tests/TableProDatabaseTests/ConnectionManagerTests.swift index c50ac803ce..f8b5e12054 100644 --- a/apple/Packages/TableProCore/Tests/TableProDatabaseTests/ConnectionManagerTests.swift +++ b/apple/Packages/TableProCore/Tests/TableProDatabaseTests/ConnectionManagerTests.swift @@ -87,6 +87,32 @@ struct ConnectionManagerTests { } } + @Test("A type the factory cannot open is refused before any tunnel opens") + func unsupportedTypeIsRefusedBeforeTheTunnel() async throws { + let factory = MockDriverFactory() + factory.drivers["mock"] = MockDatabaseDriver() + let ssh = MockSSHProvider() + let manager = ConnectionManager( + driverFactory: factory, + secureStore: MockSecureStore(), + sshProvider: ssh + ) + let connection = DatabaseConnection( + name: "Mac only", + type: .mongodb, + sshEnabled: true, + sshConfiguration: SSHConfiguration(host: "jump.example.com") + ) + + await #expect(throws: ConnectionError.driverNotFound("MongoDB")) { + _ = try await manager.connect(connection) + } + + #expect(ssh.tunnelledConnectionIds.isEmpty) + #expect(ssh.openedTunnelIds.isEmpty) + #expect(manager.session(for: connection.id) == nil) + } + @Test("Connect with SSH but no provider throws error") func connectSSHNoProvider() async throws { let factory = MockDriverFactory() diff --git a/apple/Packages/TableProCore/Tests/TableProModelsTests/SSLConfigurationWireTests.swift b/apple/Packages/TableProCore/Tests/TableProModelsTests/SSLConfigurationWireTests.swift new file mode 100644 index 0000000000..a64c137362 --- /dev/null +++ b/apple/Packages/TableProCore/Tests/TableProModelsTests/SSLConfigurationWireTests.swift @@ -0,0 +1,81 @@ +import Foundation +import Testing + +@testable import TableProModels +import TableProPluginKit + +struct SSLConfigurationWireTests { + @Test( + "Each iOS mode with nil paths decodes as the Mac's SSL configuration", + arguments: [ + (TableProModels.SSLConfiguration.SSLMode.disable, TableProPluginKit.SSLMode.disabled), + (TableProModels.SSLConfiguration.SSLMode.require, TableProPluginKit.SSLMode.required), + (TableProModels.SSLConfiguration.SSLMode.verifyCa, TableProPluginKit.SSLMode.verifyCa), + (TableProModels.SSLConfiguration.SSLMode.verifyFull, TableProPluginKit.SSLMode.verifyIdentity) + ] + ) + func decodesAsMacConfiguration( + _ mode: TableProModels.SSLConfiguration.SSLMode, + _ expected: TableProPluginKit.SSLMode + ) throws { + let data = try JSONEncoder().encode(TableProModels.SSLConfiguration(mode: mode)) + let mac = try JSONDecoder().decode(TableProPluginKit.SSLConfiguration.self, from: data) + #expect(mac.mode == expected) + #expect(mac.caCertificatePath.isEmpty) + #expect(mac.clientCertificatePath.isEmpty) + #expect(mac.clientKeyPath.isEmpty) + #expect(TableProModels.SSLConfiguration.isMacWireForm(data)) + } + + @Test("Nil paths round-trip to nil") + func nilPathsRoundTrip() throws { + let original = TableProModels.SSLConfiguration(mode: .verifyCa) + let data = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(TableProModels.SSLConfiguration.self, from: data) + #expect(decoded == original) + #expect(decoded.caCertificatePath == nil) + #expect(decoded.clientCertificatePath == nil) + #expect(decoded.clientKeyPath == nil) + } + + @Test("Set paths reach the Mac and round-trip") + func setPathsRoundTrip() throws { + let original = TableProModels.SSLConfiguration( + mode: .verifyFull, + caCertificatePath: "/certs/ca.pem", + clientCertificatePath: "/certs/client.crt", + clientKeyPath: "/certs/client.key" + ) + let data = try JSONEncoder().encode(original) + let mac = try JSONDecoder().decode(TableProPluginKit.SSLConfiguration.self, from: data) + #expect(mac.caCertificatePath == "/certs/ca.pem") + #expect(mac.clientCertificatePath == "/certs/client.crt") + #expect(mac.clientKeyPath == "/certs/client.key") + let decoded = try JSONDecoder().decode(TableProModels.SSLConfiguration.self, from: data) + #expect(decoded == original) + } + + @Test("A Mac Preferred reads as require, writes back as Preferred, and a new mode replaces it") + func macPreferredRoundTrips() throws { + let blob = #"{"mode":"Preferred","caCertificatePath":"","clientCertificatePath":"","clientKeyPath":""}"# + var config = try JSONDecoder().decode(TableProModels.SSLConfiguration.self, from: Data(blob.utf8)) + #expect(config.mode == .require) + #expect(config.isMacPreferred) + let mac = try JSONDecoder().decode(TableProPluginKit.SSLConfiguration.self, from: JSONEncoder().encode(config)) + #expect(mac.mode == .preferred) + + config.mode = .require + #expect(config.isMacPreferred) + config.mode = .verifyCa + config.mode = .require + #expect(!config.isMacPreferred) + #expect(!TableProModels.SSLConfiguration(mode: .verifyCa, isMacPreferred: true).isMacPreferred) + } + + @Test("A blob an older iOS build wrote is not in the Mac's form") + func olderIOSBlobIsNotMacForm() { + #expect(!TableProModels.SSLConfiguration.isMacWireForm(Data(#"{"mode":"require"}"#.utf8))) + let allKeys = #"{"mode":"Preferred","caCertificatePath":"","clientCertificatePath":"","clientKeyPath":""}"# + #expect(TableProModels.SSLConfiguration.isMacWireForm(Data(allKeys.utf8))) + } +} diff --git a/apple/Packages/TableProCore/Tests/TableProSyncTests/SyncRecordMapperTests.swift b/apple/Packages/TableProCore/Tests/TableProSyncTests/SyncRecordMapperTests.swift index 2fa2afdfb4..807910bc2e 100644 --- a/apple/Packages/TableProCore/Tests/TableProSyncTests/SyncRecordMapperTests.swift +++ b/apple/Packages/TableProCore/Tests/TableProSyncTests/SyncRecordMapperTests.swift @@ -108,3 +108,126 @@ struct SyncRecordMapperTests { #expect(decoded.safeModeLevel == .confirmWrites) } } + +struct SyncRecordMapperSSLTests { + private let zoneID = CKRecordZone.ID(zoneName: "TestZone", ownerName: CKCurrentUserDefaultName) + private let macPreferred = #"{"mode":"Preferred","caCertificatePath":"","clientCertificatePath":"","clientKeyPath":""}"# + + private func makeRecord(sslJSON: String) -> CKRecord { + let connection = DatabaseConnection( + name: "Test", + type: .postgresql, + host: "db.example.com", + port: 5_432, + username: "admin", + database: "app", + sslEnabled: true, + sslConfiguration: SSLConfiguration(mode: .require) + ) + let record = SyncRecordMapper.toRecord(connection, zoneID: zoneID) + record.fields(ConnectionSyncField.self)[.sslConfigJson] = Data(sslJSON.utf8) as CKRecordValue + return record + } + + private func sslData(in record: CKRecord) throws -> Data { + try #require(record.fields(ConnectionSyncField.self)[.sslConfigJson] as? Data) + } + + private func sslJSONObject(in record: CKRecord) throws -> [String: Any] { + try #require(JSONSerialization.jsonObject(with: sslData(in: record)) as? [String: Any]) + } + + @Test("toRecord writes an SSL blob the Mac can decode") + func toRecordWritesMacForm() throws { + let connection = DatabaseConnection( + name: "Test", + type: .mysql, + sslEnabled: true, + sslConfiguration: SSLConfiguration(mode: .verifyFull) + ) + let record = SyncRecordMapper.toRecord(connection, zoneID: zoneID) + let data = try sslData(in: record) + let json = try sslJSONObject(in: record) + #expect(SSLConfiguration.isMacWireForm(data)) + #expect(json["mode"] as? String == "Verify Identity") + } + + @Test("updateRecord keeps a Mac Preferred blob when SSL is unchanged") + func keepsMacPreferredWhenUnchanged() throws { + let record = makeRecord(sslJSON: macPreferred) + var connection = try #require(SyncRecordMapper.toConnection(record)) + #expect(connection.sslConfiguration == SSLConfiguration(mode: .require, isMacPreferred: true)) + connection.name = "Renamed" + + SyncRecordMapper.updateRecord(record, with: connection) + + let data = try sslData(in: record) + let decoded = try #require(SyncRecordMapper.toConnection(record)) + #expect(data == Data(macPreferred.utf8)) + #expect(decoded.name == "Renamed") + } + + @Test("updateRecord keeps a Mac Preferred blob for a copy an older iOS build stored as require") + func keepsMacPreferredForAnOlderLocalCopy() throws { + let record = makeRecord(sslJSON: macPreferred) + var connection = try #require(SyncRecordMapper.toConnection(record)) + connection.sslConfiguration = SSLConfiguration(mode: .require) + connection.name = "Renamed" + + SyncRecordMapper.updateRecord(record, with: connection) + + #expect(try sslData(in: record) == Data(macPreferred.utf8)) + } + + @Test("updateRecord writes Preferred over a server Required") + func writesPreferredOverRequired() throws { + let record = makeRecord(sslJSON: #"{"mode":"Required","caCertificatePath":"","clientCertificatePath":"","clientKeyPath":""}"#) + var connection = try #require(SyncRecordMapper.toConnection(record)) + connection.sslConfiguration = SSLConfiguration(mode: .require, isMacPreferred: true) + + SyncRecordMapper.updateRecord(record, with: connection) + + #expect(try sslJSONObject(in: record)["mode"] as? String == "Preferred") + } + + @Test("A Mac Preferred survives local storage and a rebuilt record") + func macPreferredSurvivesRebuild() throws { + let fetched = try #require(SyncRecordMapper.toConnection(makeRecord(sslJSON: macPreferred))) + let stored = try JSONDecoder().decode(DatabaseConnection.self, from: JSONEncoder().encode(fetched)) + + let rebuilt = SyncRecordMapper.toRecord(stored, zoneID: zoneID) + + #expect(stored.sslConfiguration?.mode == .require) + #expect(try sslJSONObject(in: rebuilt)["mode"] as? String == "Preferred") + } + + @Test("updateRecord writes Verify CA when SSL changed") + func writesVerifyCaWhenChanged() throws { + let record = makeRecord(sslJSON: macPreferred) + var connection = try #require(SyncRecordMapper.toConnection(record)) + connection.sslConfiguration?.mode = .verifyCa + + SyncRecordMapper.updateRecord(record, with: connection) + + let data = try sslData(in: record) + let json = try sslJSONObject(in: record) + #expect(SSLConfiguration.isMacWireForm(data)) + #expect(json["mode"] as? String == "Verify CA") + } + + @Test("updateRecord rewrites a blob an older iOS build wrote, even with SSL unchanged") + func rewritesOlderIOSBlob() throws { + let record = makeRecord(sslJSON: #"{"mode":"require"}"#) + let connection = try #require(SyncRecordMapper.toConnection(record)) + #expect(connection.sslConfiguration == SSLConfiguration(mode: .require)) + + SyncRecordMapper.updateRecord(record, with: connection) + + let data = try sslData(in: record) + let json = try sslJSONObject(in: record) + #expect(json["mode"] as? String == "Required") + #expect(Set(json.keys) == ["mode", "caCertificatePath", "clientCertificatePath", "clientKeyPath"]) + #expect(json["caCertificatePath"] as? String == "") + #expect(SSLConfiguration.isMacWireForm(data)) + } +} diff --git a/apple/TablePro/Resources/Localizable.xcstrings b/apple/TablePro/Resources/Localizable.xcstrings index a31291cea9..3948abdc0c 100644 --- a/apple/TablePro/Resources/Localizable.xcstrings +++ b/apple/TablePro/Resources/Localizable.xcstrings @@ -168612,6 +168612,10 @@ } } }, + "SSH tunneling is not available on this platform" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "SSH User" : { "localizations" : { "fr" : { @@ -176966,6 +176970,10 @@ } } }, + "TablePro on iPhone and iPad can't open %@ connections." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "TablePro only notifies when the result is not already on screen." : { "localizations" : { "fr" : { @@ -185488,6 +185496,10 @@ } } }, + "The previous session is still closing" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "The primary key is part of the table and can't be dropped." : { "extractionState" : "manual", "generatesSymbol" : false, diff --git a/apple/TableProMobile/TableProMobile/Coordinators/ScenePresenter.swift b/apple/TableProMobile/TableProMobile/Coordinators/ScenePresenter.swift index 7579f6fbc7..2cad4279df 100644 --- a/apple/TableProMobile/TableProMobile/Coordinators/ScenePresenter.swift +++ b/apple/TableProMobile/TableProMobile/Coordinators/ScenePresenter.swift @@ -63,6 +63,8 @@ final class ScenePresenter { private(set) var restoreHolds: Set = [] private(set) var editorHolds: Set = [] private(set) var heldImportFile: URL? + var refusedConnectionId: UUID? + private(set) var heldRefusalId: UUID? var holdsConnectionRestore: Bool { !restoreHolds.isEmpty } @@ -141,6 +143,21 @@ final class ScenePresenter { present(.importFile(url)) } + // The alert sits on the list, so it waits for the cover the same way an import does. + func presentRefusal(for connectionId: UUID, coverIsOnScreen: Bool) { + guard coverIsOnScreen else { + refusedConnectionId = connectionId + return + } + heldRefusalId = connectionId + } + + func presentHeldRefusal() { + guard let connectionId = heldRefusalId else { return } + heldRefusalId = nil + refusedConnectionId = connectionId + } + /// A restore hold postpones a connection that is not open yet; it never closes one that is. func presentsConnectionCover(isOnScreen: Bool) -> Bool { isOnScreen || !holdsConnectionRestore diff --git a/apple/TableProMobile/TableProMobile/Helpers/AppError.swift b/apple/TableProMobile/TableProMobile/Helpers/AppError.swift index f1ec448ded..141e227864 100644 --- a/apple/TableProMobile/TableProMobile/Helpers/AppError.swift +++ b/apple/TableProMobile/TableProMobile/Helpers/AppError.swift @@ -48,6 +48,18 @@ nonisolated struct AppError: LocalizedError, Sendable { var errorDescription: String? { message } } +nonisolated extension AppError { + // The device refuses these before any network work, so a second attempt fails the same way. + var canRetry: Bool { + switch underlying as? ConnectionError { + case .driverNotFound?, .localSocketNotSupported?: + false + default: + true + } + } +} + // MARK: - Error Context nonisolated struct ErrorContext: Sendable { @@ -187,9 +199,8 @@ nonisolated enum ErrorClassifier { ) } - /// A typed error is classified by its case. The substring rules below read a message the user - /// writes part of, so a connection called "Prod Tunnel" reported itself as an SSH tunnel failure. - private static func connectionFailure(_ error: ConnectionError) -> AppError { + // By case, never by text: the substring rules read a name the user typed, such as "Prod Tunnel". + static func connectionFailure(_ error: ConnectionError) -> AppError { switch error { case .previousSessionStillClosing: return AppError( @@ -202,9 +213,9 @@ nonisolated enum ErrorClassifier { case .driverNotFound: return AppError( category: .config, - title: String(localized: "Driver Unavailable"), + title: String(localized: "Opens on Mac Only"), message: error.localizedDescription, - recovery: String(localized: "This build has no driver for that database type."), + recovery: String(localized: "Open it in TablePro on your Mac."), underlying: error ) case .notConnected: diff --git a/apple/TableProMobile/TableProMobile/Helpers/ConnectionDetailFormatter.swift b/apple/TableProMobile/TableProMobile/Helpers/ConnectionDetailFormatter.swift index 9b58429615..c9dcd7d909 100644 --- a/apple/TableProMobile/TableProMobile/Helpers/ConnectionDetailFormatter.swift +++ b/apple/TableProMobile/TableProMobile/Helpers/ConnectionDetailFormatter.swift @@ -24,7 +24,7 @@ nonisolated enum ConnectionDetailFormatter { } var endpoint = connection.host let defaultPort = connection.type.defaultPort - if !defaultPort.isEmpty, String(connection.port) != defaultPort { + if connection.port != 0, !defaultPort.isEmpty, String(connection.port) != defaultPort { endpoint += ":\(connection.port)" } if !connection.database.isEmpty { diff --git a/apple/TableProMobile/TableProMobile/Helpers/ConnectionListRowModel.swift b/apple/TableProMobile/TableProMobile/Helpers/ConnectionListRowModel.swift index 6c19e061dd..26515ede43 100644 --- a/apple/TableProMobile/TableProMobile/Helpers/ConnectionListRowModel.swift +++ b/apple/TableProMobile/TableProMobile/Helpers/ConnectionListRowModel.swift @@ -28,6 +28,7 @@ nonisolated struct ConnectionListRowModel: Hashable, Sendable { let groupLabel: ConnectionListGroupLabel? let isFavorite: Bool let showsFavoriteMark: Bool + let isMacOnly: Bool init( connection: DatabaseConnection, @@ -43,6 +44,7 @@ nonisolated struct ConnectionListRowModel: Hashable, Sendable { color = connection.color isFavorite = connection.isFavorite showsFavoriteMark = connection.isFavorite && section == .connections + isMacOnly = MacOnlyConnection.isMacOnly(connection) let tagsById = Dictionary(allTags.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) let resolved = connection.tagIds.compactMap { tagsById[$0] } @@ -59,6 +61,9 @@ nonisolated struct ConnectionListRowModel: Hashable, Sendable { var accessibilityLabel: String { var parts = [title, type.mobileDisplayName, detail] + if isMacOnly { + parts.append(MacOnlyConnection.status) + } if let groupLabel { parts.append(String(format: String(localized: "in %@"), groupLabel.name)) } diff --git a/apple/TableProMobile/TableProMobile/Helpers/DatabaseType+Mobile.swift b/apple/TableProMobile/TableProMobile/Helpers/DatabaseType+Mobile.swift index 2902fc30e9..1105679def 100644 --- a/apple/TableProMobile/TableProMobile/Helpers/DatabaseType+Mobile.swift +++ b/apple/TableProMobile/TableProMobile/Helpers/DatabaseType+Mobile.swift @@ -46,7 +46,7 @@ nonisolated extension DatabaseType { case .redis: "Redis" case .mssql: "SQL Server" case .oracle: "Oracle" - default: rawValue.uppercased() + default: rawValue } } diff --git a/apple/TableProMobile/TableProMobile/Helpers/MacOnlyConnection.swift b/apple/TableProMobile/TableProMobile/Helpers/MacOnlyConnection.swift new file mode 100644 index 0000000000..1349318c1c --- /dev/null +++ b/apple/TableProMobile/TableProMobile/Helpers/MacOnlyConnection.swift @@ -0,0 +1,20 @@ +import Foundation +import TableProModels + +nonisolated enum MacOnlyConnection { + static var status: String { + String(localized: "Opens on Mac only") + } + + static func isMacOnly(_ connection: DatabaseConnection) -> Bool { + IOSDriverFactory.refusal(for: connection) != nil + } + + static func isMacOnly(type: DatabaseType) -> Bool { + !IOSDriverFactory.supportedTypeList.contains(type) + } + + static func importStatus(typeId: String) -> String? { + isMacOnly(type: DatabaseType(rawValue: typeId)) ? status : nil + } +} diff --git a/apple/TableProMobile/TableProMobile/Localizable.xcstrings b/apple/TableProMobile/TableProMobile/Localizable.xcstrings index ca1c8643d8..3feff65ba4 100644 --- a/apple/TableProMobile/TableProMobile/Localizable.xcstrings +++ b/apple/TableProMobile/TableProMobile/Localizable.xcstrings @@ -6962,6 +6962,9 @@ } } } + }, + "Explains why it can't open here." : { + }, "Explore a music store database without connecting to a server." : { "localizations" : { @@ -12248,6 +12251,9 @@ } } } + }, + "Open it in TablePro on your Mac." : { + }, "Open it on the Mac, or change it there to connect by host and port." : { @@ -12363,6 +12369,12 @@ } } } + }, + "Opens on Mac only" : { + + }, + "Opens on Mac Only" : { + }, "Opens table data" : { "localizations" : { @@ -16478,6 +16490,10 @@ } } }, + "SSH tunneling is not available on this platform" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "SSH Unavailable" : { "localizations" : { "ko" : { @@ -17681,6 +17697,10 @@ } } }, + "TablePro on iPhone and iPad can't open %@ connections." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "TablePro will re-download every connection, group, and tag from your iCloud account. Local data on this device is not deleted." : { "extractionState" : "stale", "localizations" : { @@ -19085,6 +19105,10 @@ } } }, + "The previous session is still closing" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "The query did not finish within the configured timeout, so the connection was reset. Run the query again." : { "extractionState" : "manual", "generatesSymbol" : false, diff --git a/apple/TableProMobile/TableProMobile/Platform/IOSDriverFactory.swift b/apple/TableProMobile/TableProMobile/Platform/IOSDriverFactory.swift index bf10523842..b9f6c503ad 100644 --- a/apple/TableProMobile/TableProMobile/Platform/IOSDriverFactory.swift +++ b/apple/TableProMobile/TableProMobile/Platform/IOSDriverFactory.swift @@ -132,7 +132,23 @@ nonisolated final class IOSDriverFactory: DriverFactory { } } + static let supportedTypeList: [DatabaseType] = [ + .sqlite, .duckdb, .mysql, .mariadb, .tidb, .oceanbase, .postgresql, .redshift, .redis, .mssql, .oracle + ] + + static func refusal(for connection: DatabaseConnection) -> ConnectionError? { + guard supportedTypeList.contains(connection.type) else { + return .driverNotFound(connection.type.mobileDisplayName) + } + guard connection.macSocketPath == nil else { return .localSocketNotSupported } + return nil + } + + func refusal(for connection: DatabaseConnection) -> ConnectionError? { + Self.refusal(for: connection) + } + func supportedTypes() -> [DatabaseType] { - [.sqlite, .duckdb, .mysql, .mariadb, .tidb, .oceanbase, .postgresql, .redshift, .redis, .mssql, .oracle] + Self.supportedTypeList } } diff --git a/apple/TableProMobile/TableProMobile/Services/IOSConnectionExportService.swift b/apple/TableProMobile/TableProMobile/Services/IOSConnectionExportService.swift index 2f96e3691e..76ed535404 100644 --- a/apple/TableProMobile/TableProMobile/Services/IOSConnectionExportService.swift +++ b/apple/TableProMobile/TableProMobile/Services/IOSConnectionExportService.swift @@ -139,7 +139,7 @@ enum IOSConnectionExportService { private static func portableSSL(_ connection: DatabaseConnection) -> ExportableSSLConfig? { guard connection.sslEnabled, let ssl = connection.sslConfiguration, ssl.mode != .disable else { return nil } return ExportableSSLConfig( - mode: ssl.mode.portableMode.rawValue, + mode: (ssl.isMacPreferred ? PortableSSLMode.preferred : ssl.mode.portableMode).rawValue, caCertificatePath: PathPortability.contractHome(ssl.caCertificatePath ?? ""), clientCertificatePath: PathPortability.contractHome(ssl.clientCertificatePath ?? ""), clientKeyPath: PathPortability.contractHome(ssl.clientKeyPath ?? "") diff --git a/apple/TableProMobile/TableProMobile/Services/IOSConnectionImportService.swift b/apple/TableProMobile/TableProMobile/Services/IOSConnectionImportService.swift index 23aae18e2d..9bdf289e05 100644 --- a/apple/TableProMobile/TableProMobile/Services/IOSConnectionImportService.swift +++ b/apple/TableProMobile/TableProMobile/Services/IOSConnectionImportService.swift @@ -6,9 +6,10 @@ import TableProModels @MainActor enum IOSConnectionImportService { + // Mac plugin type ids with no DatabaseType constant. static let recognizedTypeIds: Set = Set( (DatabaseType.allKnownTypes + [.cockroachdb, .scylladb, .turso]).map(\.rawValue) - ) + ).union(["SAP HANA", "Elasticsearch", "Typesense", "PGlite"]) static func environment() -> ImportEnvironment { ImportEnvironment( diff --git a/apple/TableProMobile/TableProMobile/Services/IOSImportLibraryStore.swift b/apple/TableProMobile/TableProMobile/Services/IOSImportLibraryStore.swift index 83fb638863..6ee0dac6aa 100644 --- a/apple/TableProMobile/TableProMobile/Services/IOSImportLibraryStore.swift +++ b/apple/TableProMobile/TableProMobile/Services/IOSImportLibraryStore.swift @@ -189,7 +189,8 @@ private extension DatabaseConnection { mode: sslMode, caCertificatePath: Self.expandedPath(ssl.caCertificatePath), clientCertificatePath: Self.expandedPath(ssl.clientCertificatePath), - clientKeyPath: Self.expandedPath(ssl.clientKeyPath) + clientKeyPath: Self.expandedPath(ssl.clientKeyPath), + isMacPreferred: ssl.portableMode == .preferred ) }, groupId: resolved.groupId, diff --git a/apple/TableProMobile/TableProMobile/Views/ConnectedView.swift b/apple/TableProMobile/TableProMobile/Views/ConnectedView.swift index 565fa0870b..7b893ae195 100644 --- a/apple/TableProMobile/TableProMobile/Views/ConnectedView.swift +++ b/apple/TableProMobile/TableProMobile/Views/ConnectedView.swift @@ -99,9 +99,7 @@ struct ConnectedView: View { statusScreen { connectingView } case .failed(let error): statusScreen { - ErrorView(error: error) { - await coordinator.connect() - } + ErrorView(error: error, onRetry: error.canRetry ? { await coordinator.connect() } : nil) } case .tabs: connectedContent(coordinator) diff --git a/apple/TableProMobile/TableProMobile/Views/ConnectionFormView.swift b/apple/TableProMobile/TableProMobile/Views/ConnectionFormView.swift index 1566375b57..d9d4a8b4f1 100644 --- a/apple/TableProMobile/TableProMobile/Views/ConnectionFormView.swift +++ b/apple/TableProMobile/TableProMobile/Views/ConnectionFormView.swift @@ -130,7 +130,9 @@ struct ConnectionFormView: View { sshSection(viewModel: viewModel) } - testSection + if !MacOnlyConnection.isMacOnly(type: viewModel.type) { + testSection + } } .scrollDismissesKeyboard(.interactively) .interactiveDismissDisabled(viewModel.hasChanges) @@ -226,19 +228,23 @@ struct ConnectionFormView: View { TextField("Name", text: $viewModel.name) .textInputAutocapitalization(.never) - Picker("Database Type", selection: $viewModel.type) { - ForEach(DatabaseType.mobileSupportedTypes, id: \.rawValue) { dbType in - Label { - Text(dbType.mobileDisplayName) - } icon: { - Image(dbType.iconName) - .renderingMode(.template) - .resizable() - .scaledToFit() - .frame(width: 20, height: 20) + if DatabaseType.mobileSupportedTypes.contains(viewModel.type) { + Picker("Database Type", selection: $viewModel.type) { + ForEach(DatabaseType.mobileSupportedTypes, id: \.rawValue) { dbType in + Label { + Text(dbType.mobileDisplayName) + } icon: { + Image(dbType.iconName) + .renderingMode(.template) + .resizable() + .scaledToFit() + .frame(width: 20, height: 20) + } + .tag(dbType) } - .tag(dbType) } + } else { + LabeledContent("Database Type", value: viewModel.type.mobileDisplayName) } } } diff --git a/apple/TableProMobile/TableProMobile/Views/ConnectionListRow.swift b/apple/TableProMobile/TableProMobile/Views/ConnectionListRow.swift index 3db1b7f5e3..53dd164a33 100644 --- a/apple/TableProMobile/TableProMobile/Views/ConnectionListRow.swift +++ b/apple/TableProMobile/TableProMobile/Views/ConnectionListRow.swift @@ -30,10 +30,12 @@ struct ConnectionListRow: View { Button(action: onOpen) { HStack(spacing: 8) { content - Image(systemName: "chevron.right") - .font(.footnote.weight(.semibold)) - .foregroundStyle(.tertiary) - .accessibilityHidden(true) + if !model.isMacOnly { + Image(systemName: "chevron.right") + .font(.footnote.weight(.semibold)) + .foregroundStyle(.tertiary) + .accessibilityHidden(true) + } } .contentShape(Rectangle()) } @@ -41,10 +43,14 @@ struct ConnectionListRow: View { .hoverEffect() .accessibilityElement(children: .combine) .accessibilityLabel(model.accessibilityLabel) - .accessibilityHint(Text("Opens this connection")) + .accessibilityHint(accessibilityHint) } } + private var accessibilityHint: Text { + model.isMacOnly ? Text("Explains why it can't open here.") : Text("Opens this connection") + } + private var lineLimit: Int? { dynamicTypeSize.isAccessibilitySize ? nil : 1 } @@ -58,6 +64,7 @@ struct ConnectionListRow: View { private var content: some View { contentLayout { ConnectionTile(type: model.type, iconName: model.iconName, color: model.color) + .opacity(model.isMacOnly ? 0.5 : 1) VStack(alignment: .leading, spacing: 3) { title @@ -65,6 +72,12 @@ struct ConnectionListRow: View { .font(.subheadline) .foregroundStyle(.secondary) .lineLimit(lineLimit) + if model.isMacOnly { + Label(MacOnlyConnection.status, systemImage: "laptopcomputer") + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(lineLimit) + } if model.groupLabel != nil || !model.tags.isEmpty { ConnectionRowLabels(model: model, lineLimit: lineLimit) } @@ -104,6 +117,7 @@ struct ConnectionListRow: View { } else { Text(model.title) .font(.body) + .foregroundStyle(model.isMacOnly ? .secondary : .primary) .lineLimit(lineLimit) } } diff --git a/apple/TableProMobile/TableProMobile/Views/ConnectionListView.swift b/apple/TableProMobile/TableProMobile/Views/ConnectionListView.swift index 07d9eccea6..e75024649e 100644 --- a/apple/TableProMobile/TableProMobile/Views/ConnectionListView.swift +++ b/apple/TableProMobile/TableProMobile/Views/ConnectionListView.swift @@ -44,14 +44,16 @@ struct ConnectionListView: View { selectedConnectionIdString.flatMap { UUID(uuidString: $0) } } - /// The connection the cover is to show. A held restore keeps the stored id and presents nothing, - /// which is how a locked launch reaches Face ID before anything dials, and how a link that has - /// not been delivered yet keeps the stored connection from opening ahead of it. A hold postpones - /// a restore; it never closes a connection that is already open. private var presentedConnection: DatabaseConnection? { guard let id = selectedConnectionUUID, presenter.presentsConnectionCover(isOnScreen: isConnectionCoverOnScreen) else { return nil } - return coordinatorStore.presentedRecord(for: id, in: appState.connections) + return presentableRecord(for: id) + } + + // An open cover whose record turns Mac-only redials and ends on the refusal, so it is not closed here. + private func presentableRecord(for id: UUID) -> DatabaseConnection? { + guard let record = coordinatorStore.presentedRecord(for: id, in: appState.connections) else { return nil } + return isConnectionCoverOnScreen || !MacOnlyConnection.isMacOnly(record) ? record : nil } private var openConnection: Binding { @@ -217,6 +219,11 @@ struct ConnectionListView: View { } message: { Text(actionErrorMessage ?? "") } + .alert(refusal?.title ?? "", isPresented: refusalPresented, presenting: refusal) { _ in + Button(String(localized: "OK")) { presenter.refusedConnectionId = nil } + } message: { error in + Text(verbatim: [error.message, error.recovery].compactMap { $0 }.joined(separator: " ")) + } } // MARK: - Content @@ -500,10 +507,12 @@ struct ConnectionListView: View { @ViewBuilder private func connectionMenu(for connection: DatabaseConnection, section: LibrarySectionKind) -> some View { - Button { - open(connection.id) - } label: { - Label("Open", systemImage: "arrow.right.circle") + if !MacOnlyConnection.isMacOnly(connection) { + Button { + open(connection.id) + } label: { + Label("Open", systemImage: "arrow.right.circle") + } } if !connection.isSample { Button { @@ -780,13 +789,37 @@ struct ConnectionListView: View { : String(format: String(localized: "%d connections imported."), count) } + private var refusal: AppError? { + guard let id = presenter.refusedConnectionId, + let connection = appState.connections.first(where: { $0.id == id }), + let error = IOSDriverFactory.refusal(for: connection) else { return nil } + return ErrorClassifier.connectionFailure(error) + } + + private var refusalPresented: Binding { + Binding( + get: { refusal != nil }, + set: { if !$0 { presenter.refusedConnectionId = nil } } + ) + } + // MARK: - Actions private func open(_ connectionId: UUID) { + guard !isMacOnly(connectionId) else { + selectedConnectionIdString = nil + presenter.presentRefusal(for: connectionId, coverIsOnScreen: isConnectionCoverOnScreen) + return + } ConnectionListTips.connectionOpened() selectedConnectionIdString = connectionId.uuidString } + private func isMacOnly(_ connectionId: UUID) -> Bool { + guard let connection = appState.connections.first(where: { $0.id == connectionId }) else { return false } + return MacOnlyConnection.isMacOnly(connection) + } + private func openSampleDatabase() { do { let sampleId = try appState.openSampleDatabase() @@ -856,7 +889,9 @@ struct ConnectionListView: View { switch intent { case .openConnection(let connectionId, let table): guard appState.connections.contains(where: { $0.id == connectionId }) else { return } - presenter.requestTable(table, in: connectionId) + if !isMacOnly(connectionId) { + presenter.requestTable(table, in: connectionId) + } open(connectionId) case .importConnections(let url): selectedConnectionIdString = nil @@ -867,7 +902,7 @@ struct ConnectionListView: View { private func clearUnknownSelection() { guard appState.loadStatus == .ready, let id = selectedConnectionUUID, - coordinatorStore.presentedRecord(for: id, in: appState.connections) == nil else { return } + presentableRecord(for: id) == nil else { return } selectedConnectionIdString = nil } @@ -876,6 +911,7 @@ struct ConnectionListView: View { presenter.dismissConnectionEditor() coordinatorStore.discardRemovedRecords() presenter.presentHeldImport() + presenter.presentHeldRefusal() } } diff --git a/apple/TableProMobile/TableProMobile/Views/MobileConnectionImportSheet.swift b/apple/TableProMobile/TableProMobile/Views/MobileConnectionImportSheet.swift index 54123148cd..ac42e781a0 100644 --- a/apple/TableProMobile/TableProMobile/Views/MobileConnectionImportSheet.swift +++ b/apple/TableProMobile/TableProMobile/Views/MobileConnectionImportSheet.swift @@ -181,11 +181,15 @@ struct MobileConnectionImportSheet: View { if let duplicate = row.duplicate { return String(format: String(localized: "Matches “%@”"), duplicate.name) } + if let status = MacOnlyConnection.importStatus(typeId: row.settings.type) { + return status + } return row.warnings.first ?? "\(row.settings.host):\(row.settings.port)" } private static func needsAttention(_ row: ConnectionRow) -> Bool { - row.unsupportedTypeId != nil || row.duplicate != nil || !row.warnings.isEmpty + row.unsupportedTypeId != nil || row.duplicate != nil + || MacOnlyConnection.importStatus(typeId: row.settings.type) != nil || !row.warnings.isEmpty } private static func label(for resolution: ConnectionResolution) -> String { diff --git a/apple/TableProMobile/TableProMobileTests/BackgroundReleaseCoordinatorTests.swift b/apple/TableProMobile/TableProMobileTests/BackgroundReleaseCoordinatorTests.swift index 0eaa082e94..ef68fed9b1 100644 --- a/apple/TableProMobile/TableProMobileTests/BackgroundReleaseCoordinatorTests.swift +++ b/apple/TableProMobile/TableProMobileTests/BackgroundReleaseCoordinatorTests.swift @@ -36,7 +36,7 @@ private final class StubDriverFactory: DriverFactory, @unchecked Sendable { return driver } - func supportedTypes() -> [DatabaseType] { [] } + func supportedTypes() -> [DatabaseType] { drivers.keys.map(DatabaseType.init(rawValue:)) } } private func makeConnection(_ type: String) -> DatabaseConnection { diff --git a/apple/TableProMobile/TableProMobileTests/ConnectionErrorClassifierTests.swift b/apple/TableProMobile/TableProMobileTests/ConnectionErrorClassifierTests.swift index 73a8293aa0..3338887182 100644 --- a/apple/TableProMobile/TableProMobileTests/ConnectionErrorClassifierTests.swift +++ b/apple/TableProMobile/TableProMobileTests/ConnectionErrorClassifierTests.swift @@ -43,16 +43,30 @@ struct ConnectionErrorClassifierTests { #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() { + @Test("A type with no driver here points to the Mac and names the type") + func driverNotFoundPointsToTheMac() { let error = ErrorClassifier.classify( - ConnectionError.driverNotFound("cassandra"), + ConnectionError.driverNotFound("Cassandra"), context: ErrorContext(operation: "connect") ) #expect(error.category == .config) - #expect(error.title == String(localized: "Driver Unavailable")) - #expect(error.message.contains("cassandra")) + #expect(error.title == String(localized: "Opens on Mac Only")) + #expect(error.message.contains("Cassandra")) + #expect(error.recovery == String(localized: "Open it in TablePro on your Mac.")) + } + + @Test( + "A refusal from the device offers no Retry", + arguments: [ConnectionError.driverNotFound("MongoDB"), .localSocketNotSupported] + ) + func refusalCannotRetry(_ refusal: ConnectionError) { + #expect(!ErrorClassifier.connectionFailure(refusal).canRetry) + } + + @Test("A session that is gone can be retried") + func notConnectedCanRetry() { + #expect(ErrorClassifier.connectionFailure(.notConnected).canRetry) } @Test("A session that is gone is reported with a way back") diff --git a/apple/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionDetailFormatterTests.swift b/apple/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionDetailFormatterTests.swift index 316fc6e991..b4c562bfc9 100644 --- a/apple/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionDetailFormatterTests.swift +++ b/apple/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionDetailFormatterTests.swift @@ -17,6 +17,12 @@ struct ConnectionDetailFormatterTests { #expect(ConnectionDetailFormatter.detail(for: connection) == "db.acme.io:6543") } + @Test("A port of 0 is left out, since the engine dials no port") + func zeroPort() { + let connection = DatabaseConnection(type: .bigquery, host: "localhost", port: 0, database: "my-project") + #expect(ConnectionDetailFormatter.detail(for: connection) == "localhost/my-project") + } + @Test("A file database shows its file name, and an in-memory one says so") func fileDatabases() { let sqlite = DatabaseConnection(type: .sqlite, database: "/var/mobile/Documents/app.sqlite") diff --git a/apple/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionListRowModelTests.swift b/apple/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionListRowModelTests.swift index c645247f4b..c0e87219ab 100644 --- a/apple/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionListRowModelTests.swift +++ b/apple/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionListRowModelTests.swift @@ -56,4 +56,28 @@ struct ConnectionListRowModelTests { #expect(row.title == "db.acme.io") #expect(!row.accessibilityLabel.contains("·")) } + + @Test("A row that opens only on a Mac is marked, and VoiceOver names the engine and the reason") + func macOnlyRowIsMarked() { + let mongo = DatabaseConnection(name: "Events", type: .mongodb, host: "db.acme.io") + let socket = DatabaseConnection( + name: "Local", + type: .mysql, + host: "localhost", + additionalFields: [MySQLLocalSocket.fieldKey: "/tmp/mysql.sock"] + ) + let postgres = DatabaseConnection(name: "Prod", type: .postgresql, host: "db.acme.io") + + let mongoRow = ConnectionListRowModel(connection: mongo, section: .connections, tags: [], groups: []) + let socketRow = ConnectionListRowModel(connection: socket, section: .favorites, tags: [], groups: []) + let postgresRow = ConnectionListRowModel(connection: postgres, section: .connections, tags: [], groups: []) + + #expect(mongoRow.isMacOnly) + #expect(socketRow.isMacOnly) + #expect(postgresRow.isMacOnly == false) + #expect(mongoRow.accessibilityLabel.contains("MongoDB")) + #expect(mongoRow.accessibilityLabel.contains(MacOnlyConnection.status)) + #expect(socketRow.accessibilityLabel.contains(MacOnlyConnection.status)) + #expect(postgresRow.accessibilityLabel.contains(MacOnlyConnection.status) == false) + } } diff --git a/apple/TableProMobile/TableProMobileTests/ConnectionLibrary/MacOnlyConnectionFormTests.swift b/apple/TableProMobile/TableProMobileTests/ConnectionLibrary/MacOnlyConnectionFormTests.swift new file mode 100644 index 0000000000..1a9f33ab99 --- /dev/null +++ b/apple/TableProMobile/TableProMobileTests/ConnectionLibrary/MacOnlyConnectionFormTests.swift @@ -0,0 +1,37 @@ +import Foundation +@testable import TableProMobile +import TableProModels +import Testing + +@MainActor +struct MacOnlyConnectionFormTests { + private func mongoConnection() -> DatabaseConnection { + DatabaseConnection( + name: "Events", + type: .mongodb, + host: "mongo.example.com", + port: 27_017, + username: "reader", + database: "events", + additionalFields: ["mongoAuthSource": "admin", "mongoReplicaSet": "rs0"] + ) + } + + @Test("Renaming a MongoDB connection keeps its type, port and Mac fields") + func renameKeepsMacOnlyFields() { + let stored = mongoConnection() + let viewModel = ConnectionFormViewModel(editing: stored) + + viewModel.name = "Event Store" + let saved = viewModel.applyingEdits(to: stored) + let built = viewModel.buildConnection() + + #expect(saved.name == "Event Store") + #expect(saved.type == .mongodb) + #expect(saved.port == 27_017) + #expect(saved.additionalFields == stored.additionalFields) + #expect(built.type == .mongodb) + #expect(built.port == 27_017) + #expect(built.additionalFields == stored.additionalFields) + } +} diff --git a/apple/TableProMobile/TableProMobileTests/IOSConnectionImportServiceTests.swift b/apple/TableProMobile/TableProMobileTests/IOSConnectionImportServiceTests.swift index afaf1d9581..01d07edd48 100644 --- a/apple/TableProMobile/TableProMobileTests/IOSConnectionImportServiceTests.swift +++ b/apple/TableProMobile/TableProMobileTests/IOSConnectionImportServiceTests.swift @@ -436,6 +436,14 @@ struct IOSConnectionImportServiceTests { #expect(connection.sslConfiguration == nil) } + @Test("A Mac Preferred mode opens as required here and exports back as Preferred") + func preferredSSLModeSurvivesImport() async throws { + let connection = try await importedConnection(settings(ssl: ExportableSSLConfig(mode: "Preferred"))) + #expect(connection.sslConfiguration?.mode == .require) + #expect(connection.sslConfiguration?.isMacPreferred == true) + #expect(IOSConnectionExportService.portableSettings(for: connection).sslConfig?.mode == "Preferred") + } + // MARK: - Settings @Test( diff --git a/apple/TableProMobile/TableProMobileTests/MacOnlyImportRowTests.swift b/apple/TableProMobile/TableProMobileTests/MacOnlyImportRowTests.swift new file mode 100644 index 0000000000..77e2e196c0 --- /dev/null +++ b/apple/TableProMobile/TableProMobileTests/MacOnlyImportRowTests.swift @@ -0,0 +1,69 @@ +import Foundation +import TableProImport +import TableProModels +import Testing + +@testable import TableProMobile + +@MainActor +struct MacOnlyImportRowTests { + private func analyzed(type: String) throws -> ImportPreview { + let settings = ExportableConnection( + name: "Search", + host: "db.example.com", + port: 9_200, + database: "", + username: "app", + type: type + ) + let bundle = try ConnectionBundle(appVersion: "Tests", connections: [BundleConnection(ref: "c1", settings: settings)]) + return ConnectionImportAnalyzer.analyze( + CollectedImport(bundle: bundle, source: .file(name: "Tests.tablepro")), + library: ImportLibrarySnapshot(), + environment: IOSConnectionImportService.environment() + ) + } + + private func isPreselected(_ row: ConnectionRow, in preview: ImportPreview) -> Bool { + ImportSelection.defaults(for: preview).resolution(for: row.ref) != nil + } + + @Test("A Mac plugin type is recognized, preselected and marked Mac only", arguments: ["Elasticsearch", "SAP HANA", "Typesense", "PGlite"]) + func macPluginTypeIsMarked(_ typeId: String) throws { + let preview = try analyzed(type: typeId) + let row = try #require(preview.connections.first) + + #expect(row.unsupportedTypeId == nil) + #expect(row.settings.type == typeId) + #expect(isPreselected(row, in: preview)) + #expect(MacOnlyConnection.importStatus(typeId: row.settings.type) == MacOnlyConnection.status) + } + + @Test("A MongoDB row stays preselected and reads the Mac only status") + func mongoDBRowIsMarked() throws { + let preview = try analyzed(type: DatabaseType.mongodb.rawValue) + let row = try #require(preview.connections.first) + + #expect(row.unsupportedTypeId == nil) + #expect(isPreselected(row, in: preview)) + #expect(MacOnlyConnection.importStatus(typeId: row.settings.type) == MacOnlyConnection.status) + } + + @Test("A type this device opens reads no Mac only status") + func supportedTypeIsNotMarked() throws { + let preview = try analyzed(type: DatabaseType.postgresql.rawValue) + let row = try #require(preview.connections.first) + + #expect(isPreselected(row, in: preview)) + #expect(MacOnlyConnection.importStatus(typeId: row.settings.type) == nil) + } + + @Test("A type no TablePro knows stays unsupported and unselected") + func unknownTypeStaysUnsupported() throws { + let preview = try analyzed(type: "Vertica") + let row = try #require(preview.connections.first) + + #expect(row.unsupportedTypeId == "Vertica") + #expect(!isPreselected(row, in: preview)) + } +} diff --git a/apple/TableProMobile/TableProMobileTests/Onboarding/ScenePresenterTests.swift b/apple/TableProMobile/TableProMobileTests/Onboarding/ScenePresenterTests.swift index cafdc9bbaf..d78b220b26 100644 --- a/apple/TableProMobile/TableProMobileTests/Onboarding/ScenePresenterTests.swift +++ b/apple/TableProMobile/TableProMobileTests/Onboarding/ScenePresenterTests.swift @@ -161,6 +161,32 @@ struct ScenePresenterTests { #expect(presenter.sheet == nil) } + @Test("A refusal with no cover on screen is presented at once") + func refusalWithoutCoverIsPresentedAtOnce() { + let presenter = ScenePresenter() + + presenter.presentRefusal(for: connectionId, coverIsOnScreen: false) + #expect(presenter.refusedConnectionId == connectionId) + #expect(presenter.heldRefusalId == nil) + } + + @Test("A refusal that arrives over an open connection is presented once, when the cover dismisses") + func refusalOverACoverWaitsForTheDismissal() { + let presenter = ScenePresenter() + + presenter.presentRefusal(for: connectionId, coverIsOnScreen: true) + #expect(presenter.refusedConnectionId == nil) + #expect(presenter.heldRefusalId == connectionId) + + presenter.presentHeldRefusal() + #expect(presenter.refusedConnectionId == connectionId) + #expect(presenter.heldRefusalId == nil) + + presenter.refusedConnectionId = nil + presenter.presentHeldRefusal() + #expect(presenter.refusedConnectionId == nil) + } + @Test("An import waits until the stored library can be written") func importWaitsForLibrary() { let presenter = ScenePresenter() diff --git a/apple/TableProMobile/TableProMobileTests/Platform/IOSDriverFactoryRefusalTests.swift b/apple/TableProMobile/TableProMobileTests/Platform/IOSDriverFactoryRefusalTests.swift new file mode 100644 index 0000000000..3ca262cef3 --- /dev/null +++ b/apple/TableProMobile/TableProMobileTests/Platform/IOSDriverFactoryRefusalTests.swift @@ -0,0 +1,48 @@ +import Foundation +import TableProDatabase +@testable import TableProMobile +import TableProModels +import Testing + +struct IOSDriverFactoryRefusalTests { + private func socketConnection(sshHost: String? = nil) -> DatabaseConnection { + DatabaseConnection( + type: .mysql, + host: "localhost", + database: "shop", + additionalFields: [MySQLLocalSocket.fieldKey: "/tmp/mysql.sock"], + sshEnabled: sshHost != nil, + sshConfiguration: sshHost.map { SSHConfiguration(host: $0, username: "deploy") } + ) + } + + @Test( + "Every type with a driver on iPhone and iPad opens here", + arguments: [DatabaseType.sqlite, .duckdb, .mysql, .mariadb, .tidb, .oceanbase, .postgresql, .redshift, .redis, .mssql, .oracle] + ) + func supportedTypeOpens(type: DatabaseType) { + #expect(IOSDriverFactory.refusal(for: DatabaseConnection(type: type, host: "db.acme.io")) == nil) + } + + @Test("A type with no driver here is refused by its engine name", arguments: ["MongoDB", "ClickHouse", "Elasticsearch"]) + func typeWithoutDriverIsRefused(engine: String) { + let connection = DatabaseConnection(type: DatabaseType(rawValue: engine), host: "db.acme.io") + + #expect(IOSDriverFactory.refusal(for: connection) == .driverNotFound(engine)) + } + + @Test("A connection on a Mac socket is refused, and one whose tunnel replaces the socket opens") + func macSocketIsRefused() { + #expect(IOSDriverFactory.refusal(for: socketConnection()) == .localSocketNotSupported) + #expect(IOSDriverFactory.refusal(for: socketConnection(sshHost: "bastion.acme.io")) == nil) + } + + @Test("The connect path gets the device's answer, socket included, not the protocol default") + func connectPathUsesTheDeviceRule() { + let factory: any DriverFactory = IOSDriverFactory() + + #expect(factory.refusal(for: socketConnection()) == .localSocketNotSupported) + #expect(factory.refusal(for: DatabaseConnection(type: .mongodb)) == .driverNotFound("MongoDB")) + #expect(factory.supportedTypes() == IOSDriverFactory.supportedTypeList) + } +} diff --git a/apple/changelog.d/3680.added.md b/apple/changelog.d/3680.added.md new file mode 100644 index 0000000000..7af2cfa4a6 --- /dev/null +++ b/apple/changelog.d/3680.added.md @@ -0,0 +1 @@ +iPhone and iPad mark synced connections that only a Mac can open, and explain why on tap instead of connecting. diff --git a/apple/changelog.d/3680.fixed.md b/apple/changelog.d/3680.fixed.md new file mode 100644 index 0000000000..8365510afc --- /dev/null +++ b/apple/changelog.d/3680.fixed.md @@ -0,0 +1 @@ +Edits on iPhone and iPad to a connection with SSL settings never reaching the Mac.