From 36b3ca6260fa796e1bb495023b50f182282826a9 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 7 Sep 2026 16:26:31 +0700 Subject: [PATCH] fix(ios): keep a cancelled connect from wedging the connection screen for good Claude-Session: https://claude.ai/code/session_01XLAgkECUGM1CXAUgKbUegP --- CHANGELOG.md | 3 + .../TableProDatabase/ConnectionManager.swift | 60 ++++- .../Protocols/SSHProvider.swift | 7 +- .../ConnectionManagerTestSupport.swift | 161 ++++++++++++ .../ConnectionManagerTests.swift | 239 ++++++++---------- .../Coordinators/ConnectionCoordinator.swift | 92 +++++-- .../ConnectionCoordinatorStore.swift | 65 +++++ .../TableProMobile/Drivers/MySQLDriver.swift | 27 +- .../Drivers/PostgreSQLDriver.swift | 64 ++++- .../Intents/IntentDatabaseSession.swift | 1 - .../TableProMobile/SSH/IOSSSHProvider.swift | 75 +++--- .../TableProMobile/SSH/SSHTunnel.swift | 2 + .../TableProMobile/TableProMobileApp.swift | 15 +- .../ViewModels/ConnectionFormViewModel.swift | 2 - .../TableProMobile/Views/ConnectedView.swift | 34 +-- .../Views/ConnectionInfoView.swift | 2 + .../Views/ConnectionListView.swift | 10 +- .../TableProMobile/Views/SceneRootView.swift | 29 +++ .../ConnectionCoordinatorStoreTests.swift | 80 ++++++ 19 files changed, 702 insertions(+), 266 deletions(-) create mode 100644 Packages/TableProCore/Tests/TableProDatabaseTests/ConnectionManagerTestSupport.swift create mode 100644 TableProMobile/TableProMobile/Coordinators/ConnectionCoordinatorStore.swift create mode 100644 TableProMobile/TableProMobile/Views/SceneRootView.swift create mode 100644 TableProMobile/TableProMobileTests/ConnectionCoordinatorStoreTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index d4837e1557..7206434a22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Connection screen stuck on Connecting for good after a cancelled connect on iPhone and iPad. +- Edited connection host, port or credentials ignored until relaunch on iPhone and iPad. +- SSH tunnel handshake with no timeout on iPhone and iPad, against a server that accepts TCP and then stalls. - Export and Transfer To preselecting a same-named table from another schema, or nothing at all. - Delete queuing a table drop from the menu bar with none of the confirmation the sidebar asks for. - Truncate Table offered from the menu bar for a view, which the server then refuses. diff --git a/Packages/TableProCore/Sources/TableProDatabase/ConnectionManager.swift b/Packages/TableProCore/Sources/TableProDatabase/ConnectionManager.swift index 8a3b10d0b3..c9a03700cc 100644 --- a/Packages/TableProCore/Sources/TableProDatabase/ConnectionManager.swift +++ b/Packages/TableProCore/Sources/TableProDatabase/ConnectionManager.swift @@ -10,6 +10,7 @@ public final class ConnectionManager: @unchecked Sendable { private var sessions: [UUID: ConnectionSession] = [:] private var teardowns: [UUID: Task] = [:] private var blockingTeardowns: Set = [] + private var attemptGenerations: [UUID: Int] = [:] public init( driverFactory: DriverFactory, @@ -22,20 +23,25 @@ public final class ConnectionManager: @unchecked Sendable { } public func connect(_ connection: DatabaseConnection) async throws -> ConnectionSession { - await disconnect(connection.id) + let generation = beginAttempt(for: connection.id) + await awaitTeardown(of: connection.id) + guard isCurrentAttempt(generation, for: connection.id) else { throw CancellationError() } let password = try secureStore.retrieve(forKey: Self.passwordKey(for: connection.id)) var effectiveHost = connection.host var effectivePort = connection.port + var tunnelId: UUID? if connection.sshEnabled, let ssh = connection.sshConfiguration { guard let provider = sshProvider else { throw ConnectionError.sshNotSupported } let tunnel = try await provider.createTunnel( config: ssh, + connectionId: connection.id, remoteHost: connection.host, remotePort: connection.port ) + tunnelId = tunnel.id effectiveHost = tunnel.localHost effectivePort = tunnel.localPort } @@ -54,16 +60,45 @@ public final class ConnectionManager: @unchecked Sendable { activeDatabase: connection.database, status: .connected ) - storeSession(session, for: connection.id) + guard adoptSession(session, for: connection.id, generation: generation) else { + try? await driver.disconnect() + if let tunnelId, let provider = sshProvider { + try? await provider.closeTunnel(id: tunnelId) + } + throw CancellationError() + } return session } catch { - if connection.sshEnabled, let provider = sshProvider { - try? await provider.closeTunnel(for: connection.id) + if let tunnelId, let provider = sshProvider { + try? await provider.closeTunnel(id: tunnelId) } throw error } } + /// `Task.cancel()` cannot retire an attempt: the drivers block in C calls that never observe it. + public func invalidateAttempt(for connectionId: UUID) { + lock.lock() + defer { lock.unlock() } + attemptGenerations[connectionId, default: 0] += 1 + } + + private func beginAttempt(for connectionId: UUID) -> Int { + lock.lock() + defer { lock.unlock() } + let next = (attemptGenerations[connectionId] ?? 0) + 1 + attemptGenerations[connectionId] = next + return next + } + + private func adoptSession(_ session: ConnectionSession, for id: UUID, generation: Int) -> Bool { + lock.lock() + defer { lock.unlock() } + guard attemptGenerations[id] == generation else { return false } + sessions[id] = session + return true + } + public func storePassword(_ password: String, for connectionId: UUID) throws { try secureStore.store(password, forKey: Self.passwordKey(for: connectionId)) } @@ -77,11 +112,22 @@ public final class ConnectionManager: @unchecked Sendable { } public func disconnect(_ connectionId: UUID) async { + invalidateAttempt(for: connectionId) + await awaitTeardown(of: connectionId) + } + + private func awaitTeardown(of connectionId: UUID) async { guard let teardown = claimTeardown(for: connectionId) else { return } await teardown.value finishTeardown(teardown, for: connectionId) } + private func isCurrentAttempt(_ generation: Int, for connectionId: UUID) -> Bool { + lock.lock() + defer { lock.unlock() } + return attemptGenerations[connectionId] == generation + } + public var hasSuspensionBlockingResources: Bool { !suspensionBlockingIds().isEmpty } @@ -152,10 +198,4 @@ public final class ConnectionManager: @unchecked Sendable { defer { lock.unlock() } return sessions[connectionId] } - - private func storeSession(_ session: ConnectionSession, for id: UUID) { - lock.lock() - sessions[id] = session - lock.unlock() - } } diff --git a/Packages/TableProCore/Sources/TableProDatabase/Protocols/SSHProvider.swift b/Packages/TableProCore/Sources/TableProDatabase/Protocols/SSHProvider.swift index 066c94d107..349d3b359b 100644 --- a/Packages/TableProCore/Sources/TableProDatabase/Protocols/SSHProvider.swift +++ b/Packages/TableProCore/Sources/TableProDatabase/Protocols/SSHProvider.swift @@ -4,18 +4,23 @@ import TableProModels public protocol SSHProvider: Sendable { func createTunnel( config: SSHConfiguration, + connectionId: UUID, remoteHost: String, remotePort: Int ) async throws -> SSHTunnel func closeTunnel(for connectionId: UUID) async throws + + func closeTunnel(id: UUID) async throws } public struct SSHTunnel: Sendable { + public let id: UUID public let localHost: String public let localPort: Int - public init(localHost: String, localPort: Int) { + public init(id: UUID = UUID(), localHost: String, localPort: Int) { + self.id = id self.localHost = localHost self.localPort = localPort } diff --git a/Packages/TableProCore/Tests/TableProDatabaseTests/ConnectionManagerTestSupport.swift b/Packages/TableProCore/Tests/TableProDatabaseTests/ConnectionManagerTestSupport.swift new file mode 100644 index 0000000000..f26c50a4a1 --- /dev/null +++ b/Packages/TableProCore/Tests/TableProDatabaseTests/ConnectionManagerTestSupport.swift @@ -0,0 +1,161 @@ +import Foundation +@testable import TableProDatabase +@testable import TableProModels +import Testing + +// MARK: - Mock Types + +final class MockDatabaseDriver: DatabaseDriver, @unchecked Sendable { + var isConnected = false + var shouldFailConnect = false + var holdsSuspensionBlockingResource = false + var onConnect: (@Sendable () -> Void)? + var beforeConnect: (@Sendable () async -> Void)? + var beforeDisconnect: (@Sendable () async -> Void)? + private(set) var disconnectCount = 0 + + func connect() async throws { + await beforeConnect?() + if shouldFailConnect { throw NSError(domain: "test", code: 1) } + onConnect?() + isConnected = true + } + + func disconnect() async throws { + await beforeDisconnect?() + isConnected = false + disconnectCount += 1 + } + func ping() async throws -> Bool { isConnected } + + func execute(query: String) async throws -> QueryResult { + QueryResult(columns: [], rows: [], rowsAffected: 0, executionTime: 0, isTruncated: false, statusMessage: nil) + } + + func cancelCurrentQuery() async throws {} + func fetchTables(schema: String?) async throws -> [TableInfo] { [] } + func fetchColumns(table: String, schema: String?) async throws -> [ColumnInfo] { [] } + func fetchIndexes(table: String, schema: String?) async throws -> [IndexInfo] { [] } + func fetchForeignKeys(table: String, schema: String?) async throws -> [ForeignKeyInfo] { [] } + func fetchDatabases() async throws -> [String] { [] } + func switchDatabase(to name: String) async throws {} + var supportsSchemas: Bool { false } + func switchSchema(to name: String) async throws {} + func fetchSchemas() async throws -> [String] { [] } + var currentSchema: String? { nil } + var supportsTransactions: Bool { false } + func beginTransaction() async throws {} + func commitTransaction() async throws {} + func rollbackTransaction() async throws {} + var serverVersion: String? { nil } +} + +final class MockDriverFactory: DriverFactory, @unchecked Sendable { + var drivers: [String: any DatabaseDriver] = [:] + + func createDriver(for connection: DatabaseConnection, password: String?) throws -> any DatabaseDriver { + guard let driver = drivers[connection.type.rawValue] else { + throw ConnectionError.driverNotFound(connection.type.rawValue) + } + return driver + } + + func supportedTypes() -> [DatabaseType] { [] } +} + +final class MockSecureStore: SecureStore, Sendable { + private let passwords: [String: String] + + init(passwords: [String: String] = [:]) { + self.passwords = passwords + } + + func store(_ value: String, forKey key: String) throws {} + + func retrieve(forKey key: String) throws -> String? { + passwords[key] + } + + func delete(forKey key: String) throws {} +} + +// MARK: - Synchronisation Helpers + +actor Gate { + private var isOpen = false + private var hasEntered = false + private var blocked: [CheckedContinuation] = [] + private var observers: [CheckedContinuation] = [] + + func enter() async { + hasEntered = true + for observer in observers { observer.resume() } + observers.removeAll() + guard !isOpen else { return } + await withCheckedContinuation { blocked.append($0) } + } + + func open() { + isOpen = true + for waiter in blocked { waiter.resume() } + blocked.removeAll() + } + + func waitUntilEntered() async { + guard !hasEntered else { return } + await withCheckedContinuation { observers.append($0) } + } +} + +actor Barrier { + private static let pollInterval: UInt64 = 20_000_000 + private static let maxPolls = 250 + + private let expected: Int + private var arrived = 0 + private(set) var overlapped = 0 + + init(expected: Int) { + self.expected = expected + } + + func arriveAndWait() async { + arrived += 1 + var polls = 0 + while arrived < expected, polls < Self.maxPolls { + try? await Task.sleep(nanoseconds: Self.pollInterval) + polls += 1 + } + guard arrived >= expected else { return } + overlapped += 1 + } +} + +// MARK: - Mock SSH Provider + +final class MockSSHProvider: SSHProvider, @unchecked Sendable { + var closedTunnels: Set = [] + var closedTunnelIds: Set = [] + var openedTunnelIds: [UUID] = [] + var tunnelledConnectionIds: [UUID] = [] + + func createTunnel( + config: SSHConfiguration, + connectionId: UUID, + remoteHost: String, + remotePort: Int + ) async throws -> SSHTunnel { + tunnelledConnectionIds.append(connectionId) + let id = UUID() + openedTunnelIds.append(id) + return SSHTunnel(id: id, localHost: "127.0.0.1", localPort: 33_306) + } + + func closeTunnel(for connectionId: UUID) async throws { + closedTunnels.insert(connectionId) + } + + func closeTunnel(id: UUID) async throws { + closedTunnelIds.insert(id) + } +} diff --git a/Packages/TableProCore/Tests/TableProDatabaseTests/ConnectionManagerTests.swift b/Packages/TableProCore/Tests/TableProDatabaseTests/ConnectionManagerTests.swift index cad2c888e2..d56bb703f6 100644 --- a/Packages/TableProCore/Tests/TableProDatabaseTests/ConnectionManagerTests.swift +++ b/Packages/TableProCore/Tests/TableProDatabaseTests/ConnectionManagerTests.swift @@ -1,133 +1,8 @@ -import Testing import Foundation @testable import TableProDatabase @testable import TableProModels +import Testing -// MARK: - Mock Types - -private final class MockDatabaseDriver: DatabaseDriver, @unchecked Sendable { - var isConnected = false - var shouldFailConnect = false - var holdsSuspensionBlockingResource = false - var onConnect: (@Sendable () -> Void)? - var beforeDisconnect: (@Sendable () async -> Void)? - private(set) var disconnectCount = 0 - - func connect() async throws { - if shouldFailConnect { throw NSError(domain: "test", code: 1) } - onConnect?() - isConnected = true - } - - func disconnect() async throws { - await beforeDisconnect?() - isConnected = false - disconnectCount += 1 - } - func ping() async throws -> Bool { isConnected } - - func execute(query: String) async throws -> QueryResult { - QueryResult(columns: [], rows: [], rowsAffected: 0, executionTime: 0, isTruncated: false, statusMessage: nil) - } - - func cancelCurrentQuery() async throws {} - func fetchTables(schema: String?) async throws -> [TableInfo] { [] } - func fetchColumns(table: String, schema: String?) async throws -> [ColumnInfo] { [] } - func fetchIndexes(table: String, schema: String?) async throws -> [IndexInfo] { [] } - func fetchForeignKeys(table: String, schema: String?) async throws -> [ForeignKeyInfo] { [] } - func fetchDatabases() async throws -> [String] { [] } - func switchDatabase(to name: String) async throws {} - var supportsSchemas: Bool { false } - func switchSchema(to name: String) async throws {} - func fetchSchemas() async throws -> [String] { [] } - var currentSchema: String? { nil } - var supportsTransactions: Bool { false } - func beginTransaction() async throws {} - func commitTransaction() async throws {} - func rollbackTransaction() async throws {} - var serverVersion: String? { nil } -} - -private final class MockDriverFactory: DriverFactory, @unchecked Sendable { - var drivers: [String: any DatabaseDriver] = [:] - - func createDriver(for connection: DatabaseConnection, password: String?) throws -> any DatabaseDriver { - guard let driver = drivers[connection.type.rawValue] else { - throw ConnectionError.driverNotFound(connection.type.rawValue) - } - return driver - } - - func supportedTypes() -> [DatabaseType] { [] } -} - -private final class MockSecureStore: SecureStore, Sendable { - private let passwords: [String: String] - - init(passwords: [String: String] = [:]) { - self.passwords = passwords - } - - func store(_ value: String, forKey key: String) throws {} - - func retrieve(forKey key: String) throws -> String? { - passwords[key] - } - - func delete(forKey key: String) throws {} -} - -// MARK: - Synchronisation Helpers - -private actor Gate { - private var isOpen = false - private var hasEntered = false - private var blocked: [CheckedContinuation] = [] - private var observers: [CheckedContinuation] = [] - - func enter() async { - hasEntered = true - for observer in observers { observer.resume() } - observers.removeAll() - guard !isOpen else { return } - await withCheckedContinuation { blocked.append($0) } - } - - func open() { - isOpen = true - for waiter in blocked { waiter.resume() } - blocked.removeAll() - } - - func waitUntilEntered() async { - guard !hasEntered else { return } - await withCheckedContinuation { observers.append($0) } - } -} - -private actor Barrier { - private static let pollInterval: UInt64 = 20_000_000 - private static let maxPolls = 250 - - private let expected: Int - private var arrived = 0 - private(set) var overlapped = 0 - - init(expected: Int) { - self.expected = expected - } - - func arriveAndWait() async { - arrived += 1 - var polls = 0 - while arrived < expected, polls < Self.maxPolls { - try? await Task.sleep(nanoseconds: Self.pollInterval) - polls += 1 - } - guard arrived >= expected else { return } - overlapped += 1 - } -} @Suite("ConnectionManager Tests") struct ConnectionManagerTests { @@ -142,7 +17,7 @@ struct ConnectionManagerTests { name: "Test", type: DatabaseType(rawValue: "mock"), host: "localhost", - port: 5432 + port: 5_432 ) let session = try await manager.connect(connection) @@ -253,7 +128,43 @@ struct ConnectionManagerTests { _ = try await manager.connect(connection) } - #expect(sshProvider.closedTunnels.contains(connection.id)) + #expect(sshProvider.closedTunnelIds.count == 1) + #expect(sshProvider.closedTunnels.isEmpty) + } + + @Test("A losing attempt closes its own tunnel, never the tunnel the winner installed") + func losingAttemptClosesOnlyItsOwnTunnel() async throws { + let factory = MockDriverFactory() + let ssh = MockSSHProvider() + let manager = ConnectionManager( + driverFactory: factory, + secureStore: MockSecureStore(), + sshProvider: ssh + ) + let connection = DatabaseConnection( + name: "Tunnelled", + type: DatabaseType(rawValue: "mock"), + sshEnabled: true, + sshConfiguration: SSHConfiguration(host: "jump.example.com") + ) + let gate = Gate() + + let slow = MockDatabaseDriver() + slow.beforeConnect = { await gate.enter() } + factory.drivers["mock"] = slow + + let losing = Task { _ = try await manager.connect(connection) } + await gate.waitUntilEntered() + + factory.drivers["mock"] = MockDatabaseDriver() + _ = try await manager.connect(connection) + let winningTunnel = ssh.openedTunnelIds[1] + + await gate.open() + await #expect(throws: CancellationError.self) { try await losing.value } + + #expect(ssh.closedTunnelIds == [ssh.openedTunnelIds[0]]) + #expect(!ssh.closedTunnelIds.contains(winningTunnel)) } @Test("Only sessions holding a suspension blocking resource are released") @@ -366,22 +277,70 @@ struct ConnectionManagerTests { #expect(first.disconnectCount == 1) #expect(second.isConnected) } -} -// MARK: - Mock SSH Provider + @Test("An attempt invalidated while it is connecting discards its own driver") + func invalidatedAttemptDiscardsItsDriver() async throws { + let factory = MockDriverFactory() + let manager = ConnectionManager(driverFactory: factory, secureStore: MockSecureStore()) + let connection = DatabaseConnection(name: "Test", type: DatabaseType(rawValue: "mock")) + let gate = Gate() -private final class MockSSHProvider: SSHProvider, @unchecked Sendable { - var closedTunnels: Set = [] + let driver = MockDatabaseDriver() + driver.beforeConnect = { await gate.enter() } + factory.drivers["mock"] = driver - func createTunnel( - config: SSHConfiguration, - remoteHost: String, - remotePort: Int - ) async throws -> SSHTunnel { - SSHTunnel(localHost: "127.0.0.1", localPort: 33306) + let attempt = Task { try await manager.connect(connection) } + await gate.waitUntilEntered() + + manager.invalidateAttempt(for: connection.id) + await gate.open() + + await #expect(throws: CancellationError.self) { try await attempt.value } + #expect(manager.session(for: connection.id) == nil) + #expect(driver.disconnectCount == 1) + } + + @Test("A late attempt cannot overwrite the session a newer attempt established") + func lateAttemptCannotClobberNewerSession() async throws { + let factory = MockDriverFactory() + let manager = ConnectionManager(driverFactory: factory, secureStore: MockSecureStore()) + let connection = DatabaseConnection(name: "Test", type: DatabaseType(rawValue: "mock")) + let gate = Gate() + + let slow = MockDatabaseDriver() + slow.beforeConnect = { await gate.enter() } + factory.drivers["mock"] = slow + + let first = Task { try await manager.connect(connection) } + await gate.waitUntilEntered() + + let fast = MockDatabaseDriver() + factory.drivers["mock"] = fast + _ = try await manager.connect(connection) + + await gate.open() + await #expect(throws: CancellationError.self) { try await first.value } + + #expect(manager.session(for: connection.id)?.driver === fast) + #expect(slow.disconnectCount == 1) } - func closeTunnel(for connectionId: UUID) async throws { - closedTunnels.insert(connectionId) + @Test("A tunnel is opened for the connection being dialed, not for whoever asked last") + func tunnelCarriesItsOwnConnectionId() async throws { + let factory = MockDriverFactory() + let ssh = MockSSHProvider() + let manager = ConnectionManager( + driverFactory: factory, + secureStore: MockSecureStore(), + sshProvider: ssh + ) + var connection = DatabaseConnection(name: "Tunnelled", type: DatabaseType(rawValue: "mock")) + connection.sshEnabled = true + connection.sshConfiguration = SSHConfiguration(host: "jump.example.com", port: 22, username: "probe") + factory.drivers["mock"] = MockDatabaseDriver() + + _ = try await manager.connect(connection) + + #expect(ssh.tunnelledConnectionIds == [connection.id]) } } diff --git a/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinator.swift b/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinator.swift index 7025c69907..c4aa8cb2b6 100644 --- a/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinator.swift +++ b/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinator.swift @@ -35,6 +35,8 @@ final class ConnectionCoordinator { private let historyStorage = QueryHistoryStorage() private let appState: AppState + + var connectionManager: ConnectionManager { appState.connectionManager } private static let logger = Logger(subsystem: "com.TablePro", category: "ConnectionCoordinator") enum ConnectionPhase: Sendable { @@ -78,61 +80,105 @@ final class ConnectionCoordinator { // MARK: - Connection Lifecycle - private var isConnecting = false + /// The attempt allowed to write `session` and `phase`. Cancelling mints a new one. + private var attemptToken = UUID() + private var connectTask: Task? + + var isConnecting: Bool { connectTask != nil } + /// Returning early without touching `phase` is what left the connecting screen up for good. func connect() async { - guard !isConnecting, session == nil else { - if session != nil { phase = .connected } + if let inFlight = connectTask { + await inFlight.value return } - isConnecting = true - defer { isConnecting = false } + let token = UUID() + attemptToken = token phase = .connecting + let task = Task { [weak self] in + guard let self else { return } + await self.runAttempt(token: token) + } + connectTask = task + await task.value + if connectTask == task { connectTask = nil } + } + + /// Never waits on the driver: `Task.cancel()` is cooperative and these drivers ignore it. + func cancelConnect() { + guard connectTask != nil else { return } + attemptToken = UUID() + connectTask?.cancel() + connectTask = nil + appState.connectionManager.invalidateAttempt(for: connection.id) + session = nil + phase = .error(Self.cancelledError) + } + + private static var cancelledError: AppError { + AppError( + category: .network, + title: String(localized: "Connection Cancelled"), + message: String(localized: "The connection attempt was cancelled."), + recovery: String(localized: "Tap Retry to try again."), + underlying: nil + ) + } + + private func runAttempt(token: UUID) async { if let existing = appState.connectionManager.session(for: connection.id) { - self.session = existing do { - self.tables = try await existing.driver.fetchTables(schema: nil) + let existingTables = try await existing.driver.fetchTables(schema: nil) + guard attemptToken == token else { return } + session = existing + tables = existingTables await loadDatabases() await loadSchemas() + guard attemptToken == token else { return } phase = .connected + return } catch { - self.session = nil + guard attemptToken == token else { return } + session = nil await appState.connectionManager.disconnect(connection.id) - await connectFresh() } - return } - await connectFresh() + guard attemptToken == token else { return } + await connectFresh(token: token) } /// `allowSignIn` is false on the retry that follows a sign-in, so a connection that keeps /// failing cannot put the prompt up again and again. - private func connectFresh(allowSignIn: Bool = true) async { - await appState.sshProvider.setPendingConnectionId(connection.id) - + private func connectFresh(token: UUID, allowSignIn: Bool = true) async { IOSAnalyticsProvider.shared.markConnectionAttempted() do { let newSession = try await appState.connectionManager.connect(connection) - self.session = newSession - self.tables = try await newSession.driver.fetchTables(schema: nil) + let newTables = try await newSession.driver.fetchTables(schema: nil) + guard attemptToken == token else { return } + session = newSession + tables = newTables await loadDatabases() await loadSchemas() + guard attemptToken == token else { return } phase = .connected IOSAnalyticsProvider.shared.markConnectionSucceeded() navigateToPendingTable() } catch { + guard attemptToken == token else { return } // A sign-in that expired is recoverable, so offer it once and retry rather than // leaving the user on an error screen whose only button repeats the same failure. if allowSignIn, EntraSignIn.needsSignIn(error), await EntraSignIn.offer(fields: connection.additionalFields) { - await connectFresh(allowSignIn: false) + guard attemptToken == token else { return } + await connectFresh(token: token, allowSignIn: false) return } + guard attemptToken == token else { return } let context = ErrorContext( operation: "connect", databaseType: connection.type, @@ -144,7 +190,7 @@ final class ConnectionCoordinator { } func reconnectIfNeeded() async { - guard let session, !isSwitching, !isReconnecting else { return } + guard let session, !isSwitching, !isReconnecting, connectTask == nil else { return } do { _ = try await session.driver.ping() return @@ -152,13 +198,15 @@ final class ConnectionCoordinator { // Ping failed; fall through to actual reconnect path below. } + let token = attemptToken isReconnecting = true defer { isReconnecting = false } do { - await appState.sshProvider.setPendingConnectionId(connection.id) let newSession = try await appState.connectionManager.connect(connection) + guard attemptToken == token else { return } self.session = newSession } catch { + guard attemptToken == token else { return } let context = ErrorContext( operation: "reconnect", databaseType: connection.type, @@ -204,10 +252,10 @@ final class ConnectionCoordinator { var newConnection = connection newConnection.database = database - await appState.sshProvider.setPendingConnectionId(connection.id) - + let token = attemptToken do { let newSession = try await appState.connectionManager.connect(newConnection) + guard attemptToken == token else { return } self.session = newSession self.tables = try await newSession.driver.fetchTables(schema: nil) activeDatabase = database @@ -215,9 +263,9 @@ final class ConnectionCoordinator { await loadSchemas() } catch { Self.logger.error("Failed to switch to database \(database, privacy: .public): \(error.localizedDescription, privacy: .public)") - await appState.sshProvider.setPendingConnectionId(connection.id) do { let fallbackSession = try await appState.connectionManager.connect(connection) + guard attemptToken == token else { return } self.session = fallbackSession self.tables = try await fallbackSession.driver.fetchTables(schema: nil) failureAlertMessage = String(localized: "Failed to switch database") diff --git a/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinatorStore.swift b/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinatorStore.swift new file mode 100644 index 0000000000..aa4b160245 --- /dev/null +++ b/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinatorStore.swift @@ -0,0 +1,65 @@ +import Foundation +import Observation +import TableProDatabase +import TableProModels + +/// Owns the live coordinator per connection, so a presented screen never writes back into the +/// state of the screen presenting it. +@MainActor +@Observable +final class ConnectionCoordinatorStore { + /// Bumped whenever an entry is retired, so a screen already showing a coordinator rebuilds it + /// instead of going on talking to a driver that has been disconnected underneath it. + private(set) var revision = 0 + + private var coordinators: [UUID: ConnectionCoordinator] = [:] + private let connectionManager: ConnectionManager + + init(connectionManager: ConnectionManager) { + self.connectionManager = connectionManager + } + + func coordinator(for connection: DatabaseConnection, appState: AppState) -> ConnectionCoordinator { + if let existing = coordinators[connection.id] { return existing } + let created = ConnectionCoordinator(connection: connection, appState: appState) + created.restorePersistedState() + coordinators[connection.id] = created + return created + } + + func invalidate(_ id: UUID, droppingSession: Bool = true) { + let removed = coordinators.removeValue(forKey: id) + removed?.cancelConnect() + revision += 1 + guard droppingSession else { return } + let manager = connectionManager + Task { await manager.disconnect(id) } + } + + /// Only a change to how the app dials drops the live session. Sorting, grouping, tagging and + /// renaming rewrite every connection, and a dragged row must not close a working one. + func reconcile(from old: [DatabaseConnection], to new: [DatabaseConnection]) { + let updated = Dictionary(uniqueKeysWithValues: new.map { ($0.id, $0) }) + for previous in old { + let current = updated[previous.id] + guard current != previous else { continue } + let redials = current.map { !$0.dialsTheSameWay(as: previous) } ?? true + invalidate(previous.id, droppingSession: redials) + } + } +} + +nonisolated extension DatabaseConnection { + func dialsTheSameWay(as other: DatabaseConnection) -> Bool { + type == other.type + && host == other.host + && port == other.port + && username == other.username + && database == other.database + && sshEnabled == other.sshEnabled + && sshConfiguration == other.sshConfiguration + && sslEnabled == other.sslEnabled + && sslConfiguration == other.sslConfiguration + && additionalFields == other.additionalFields + } +} diff --git a/TableProMobile/TableProMobile/Drivers/MySQLDriver.swift b/TableProMobile/TableProMobile/Drivers/MySQLDriver.swift index d22c7260cb..8c935d13ad 100644 --- a/TableProMobile/TableProMobile/Drivers/MySQLDriver.swift +++ b/TableProMobile/TableProMobile/Drivers/MySQLDriver.swift @@ -2,6 +2,7 @@ import CMariaDB import Foundation import TableProDatabase import TableProModels +import TableProMSSQLCore nonisolated final class MySQLDriver: DatabaseDriver, @unchecked Sendable { private let actor = MySQLActor() @@ -263,7 +264,9 @@ nonisolated final class MySQLDriver: DatabaseDriver, @unchecked Sendable { private actor MySQLActor { private var mysql: UnsafeMutablePointer? - func connect(host: String, port: Int, user: String, password: String, database: String, ssl: DriverSSLConfiguration) throws { + private static let connectDeadline: DispatchTimeInterval = .seconds(15) + + func connect(host: String, port: Int, user: String, password: String, database: String, ssl: DriverSSLConfiguration) async throws { // Close existing connection if reconnecting if let mysql { mysql_close(mysql); self.mysql = nil } @@ -306,9 +309,25 @@ private actor MySQLActor { "Port \(port) is out of range. Use a value between 1 and 65535." ) } - guard mysql_real_connect( - handle, host, user, password, database, portU32, nil, 0 - ) != nil else { + // A late call closes the handle it was still using, rather than the caller closing it. + nonisolated(unsafe) let unsafeHandle = handle + let connected = try await runCancellableBlocking( + on: DispatchQueue(label: "com.TablePro.mysql.connect.\(UUID().uuidString)"), + deadline: Self.connectDeadline, + timeoutError: { + MySQLError.connectionFailed( + String(localized: "Timed out connecting to the MySQL server.") + ) + }, + work: { + mysql_real_connect( + unsafeHandle, host, user, password, database, portU32, nil, 0 + ) != nil + }, + discardLateResult: { _ in mysql_close(unsafeHandle) } + ) + + guard connected else { let msg = String(cString: mysql_error(handle)) mysql_close(handle) throw MySQLError.connectionFailed(msg) diff --git a/TableProMobile/TableProMobile/Drivers/PostgreSQLDriver.swift b/TableProMobile/TableProMobile/Drivers/PostgreSQLDriver.swift index 6f4224e1ee..41f1c9bd67 100644 --- a/TableProMobile/TableProMobile/Drivers/PostgreSQLDriver.swift +++ b/TableProMobile/TableProMobile/Drivers/PostgreSQLDriver.swift @@ -362,7 +362,11 @@ nonisolated final class PostgreSQLDriver: DatabaseDriver, @unchecked Sendable { private actor PostgreSQLActor { private var conn: OpaquePointer? - func connect(host: String, port: Int, user: String, password: String, database: String, ssl: DriverSSLConfiguration = .disabled) throws { + private static let connectTimeout: TimeInterval = 15 + private static let pollSliceMilliseconds: Int32 = 100 + + /// `PQconnectdb` blocks with no way to abort, so a cancelled connect can never stop dialing. + func connect(host: String, port: Int, user: String, password: String, database: String, ssl: DriverSSLConfiguration = .disabled) async throws { guard (1...65_535).contains(port) else { throw PostgreSQLError.connectionFailed( "Port \(port) is out of range. Use a value between 1 and 65535." @@ -380,15 +384,63 @@ private actor PostgreSQLActor { ssl: ssl ) - let connection = PQconnectdb(connStr) + guard let connection = PQconnectStart(connStr) else { + throw PostgreSQLError.connectionFailed(String(localized: "Could not start a connection.")) + } + + var adopted = false + defer { if !adopted { PQfinish(connection) } } - guard PQstatus(connection) == CONNECTION_OK else { - let msg = connection.flatMap { String(cString: PQerrorMessage($0)) } ?? "Unknown error" - PQfinish(connection) - throw PostgreSQLError.connectionFailed(msg) + guard PQstatus(connection) != CONNECTION_BAD else { + throw PostgreSQLError.connectionFailed(Self.message(from: connection)) } + try await pollUntilConnected(connection) + self.conn = connection + adopted = true + } + + private func pollUntilConnected(_ connection: OpaquePointer) async throws { + let deadline = Date().addingTimeInterval(Self.connectTimeout) + var status = PGRES_POLLING_WRITING + + while true { + try Task.checkCancellation() + + switch status { + case PGRES_POLLING_OK: + return + case PGRES_POLLING_FAILED: + throw PostgreSQLError.connectionFailed(Self.message(from: connection)) + case PGRES_POLLING_READING, PGRES_POLLING_WRITING: + let socket = PQsocket(connection) + guard socket >= 0 else { + throw PostgreSQLError.connectionFailed(Self.message(from: connection)) + } + guard Date() < deadline else { + throw PostgreSQLError.connectionFailed(String(localized: "Connection timed out.")) + } + + let events = status == PGRES_POLLING_READING ? Int16(POLLIN) : Int16(POLLOUT) + var descriptor = pollfd(fd: socket, events: events, revents: 0) + let ready = poll(&descriptor, 1, Self.pollSliceMilliseconds) + guard ready >= 0 else { + throw PostgreSQLError.connectionFailed(String(localized: "Connection failed while waiting on the socket.")) + } + guard ready > 0 else { continue } + + status = PQconnectPoll(connection) + default: + status = PQconnectPoll(connection) + } + } + } + + private static func message(from connection: OpaquePointer?) -> String { + guard let connection else { return String(localized: "Unknown error") } + let text = String(cString: PQerrorMessage(connection)) + return text.isEmpty ? String(localized: "Unknown error") : text } func close() { diff --git a/TableProMobile/TableProMobile/Intents/IntentDatabaseSession.swift b/TableProMobile/TableProMobile/Intents/IntentDatabaseSession.swift index da3267697a..a5fb2fdb70 100644 --- a/TableProMobile/TableProMobile/Intents/IntentDatabaseSession.swift +++ b/TableProMobile/TableProMobile/Intents/IntentDatabaseSession.swift @@ -28,7 +28,6 @@ struct IntentDatabaseSession { sshProvider: sshProvider ) if connection.sshEnabled { - await sshProvider.setPendingConnectionId(connection.id) } do { let session = try await manager.connect(connection) diff --git a/TableProMobile/TableProMobile/SSH/IOSSSHProvider.swift b/TableProMobile/TableProMobile/SSH/IOSSSHProvider.swift index 3d2f6b2ca3..dd80b5ccec 100644 --- a/TableProMobile/TableProMobile/SSH/IOSSSHProvider.swift +++ b/TableProMobile/TableProMobile/SSH/IOSSSHProvider.swift @@ -10,39 +10,22 @@ final class IOSSSHProvider: SSHProvider, @unchecked Sendable { self.secureStore = secureStore } - /// Set pending connectionId atomically via the TunnelStore actor. - /// Must be called before createTunnel to enable connectionId-based Keychain lookup. - func setPendingConnectionId(_ id: UUID) async { - await tunnelStore.setPending(id) - } - func createTunnel( config: SSHConfiguration, + connectionId: UUID, remoteHost: String, remotePort: Int ) async throws -> TableProDatabase.SSHTunnel { - let connId = await tunnelStore.consumePending() - - // Resolve SSH credentials using macOS-compatible Keychain keys - let sshPassword: String? - let keyPassphrase: String? - var resolvedConfig = config - if let connId { - sshPassword = try? secureStore.retrieve( - forKey: "com.TablePro.sshpassword.\(connId.uuidString)") - keyPassphrase = try? secureStore.retrieve( - forKey: "com.TablePro.keypassphrase.\(connId.uuidString)") + let sshPassword = try? secureStore.retrieve( + forKey: "com.TablePro.sshpassword.\(connectionId.uuidString)") + let keyPassphrase = try? secureStore.retrieve( + forKey: "com.TablePro.keypassphrase.\(connectionId.uuidString)") - // Restore key content from Keychain if not in config - if resolvedConfig.privateKeyData == nil || resolvedConfig.privateKeyData?.isEmpty == true { - resolvedConfig.privateKeyData = try? secureStore.retrieve( - forKey: "com.TablePro.sshkeydata.\(connId.uuidString)") - } - } else { - sshPassword = nil - keyPassphrase = nil + if resolvedConfig.privateKeyData == nil || resolvedConfig.privateKeyData?.isEmpty == true { + resolvedConfig.privateKeyData = try? secureStore.retrieve( + forKey: "com.TablePro.sshkeydata.\(connectionId.uuidString)") } let tunnel = try await SSHTunnelFactory.create( @@ -53,38 +36,46 @@ final class IOSSSHProvider: SSHProvider, @unchecked Sendable { keyPassphrase: keyPassphrase ) - let effectiveId = connId ?? UUID() - await tunnelStore.add(tunnel, connectionId: effectiveId) + let tunnelId = UUID() + await tunnelStore.add(tunnel, id: tunnelId, connectionId: connectionId) let port = await tunnel.port - return TableProDatabase.SSHTunnel(localHost: "127.0.0.1", localPort: port) + return TableProDatabase.SSHTunnel(id: tunnelId, localHost: "127.0.0.1", localPort: port) } func closeTunnel(for connectionId: UUID) async throws { - guard let tunnel = await tunnelStore.remove(connectionId: connectionId) else { return } + for tunnel in await tunnelStore.removeAll(connectionId: connectionId) { + await tunnel.close() + } + } + + func closeTunnel(id: UUID) async throws { + guard let tunnel = await tunnelStore.remove(id: id) else { return } await tunnel.close() } } +/// Keyed by tunnel rather than by connection, because a cancelled attempt and the retry that +/// replaced it both own a tunnel for the same connection, and the loser must close only its own. private actor TunnelStore { - var tunnels: [UUID: SSHTunnel] = [:] - private var pendingConnectionId: UUID? - - func setPending(_ id: UUID) { - pendingConnectionId = id + private struct Entry { + let connectionId: UUID + let tunnel: SSHTunnel } - func consumePending() -> UUID? { - let id = pendingConnectionId - pendingConnectionId = nil - return id + private var entries: [UUID: Entry] = [:] + + func add(_ tunnel: SSHTunnel, id: UUID, connectionId: UUID) { + entries[id] = Entry(connectionId: connectionId, tunnel: tunnel) } - func add(_ tunnel: SSHTunnel, connectionId: UUID) { - tunnels[connectionId] = tunnel + func remove(id: UUID) -> SSHTunnel? { + entries.removeValue(forKey: id)?.tunnel } - func remove(connectionId: UUID) -> SSHTunnel? { - tunnels.removeValue(forKey: connectionId) + func removeAll(connectionId: UUID) -> [SSHTunnel] { + let matching = entries.filter { $0.value.connectionId == connectionId } + for key in matching.keys { entries.removeValue(forKey: key) } + return matching.values.map(\.tunnel) } } diff --git a/TableProMobile/TableProMobile/SSH/SSHTunnel.swift b/TableProMobile/TableProMobile/SSH/SSHTunnel.swift index 67aec5deee..c6a85a71a4 100644 --- a/TableProMobile/TableProMobile/SSH/SSHTunnel.swift +++ b/TableProMobile/TableProMobile/SSH/SSHTunnel.swift @@ -32,6 +32,7 @@ actor SSHTunnel { private static let bufferSize = 32_768 private static let connectionTimeout: Int32 = 10 + private static let blockingCallTimeoutMilliseconds: Int = 15_000 nonisolated let sessionLock = NSLock() private var isAlive: Bool { @@ -126,6 +127,7 @@ actor SSHTunnel { } libssh2_session_set_blocking(sess, 1) + libssh2_session_set_timeout(sess, Self.blockingCallTimeoutMilliseconds) let rc = libssh2_session_handshake(sess, socketFD) if rc != 0 { diff --git a/TableProMobile/TableProMobile/TableProMobileApp.swift b/TableProMobile/TableProMobile/TableProMobileApp.swift index 87d29d5cf9..ca85ff66ca 100644 --- a/TableProMobile/TableProMobile/TableProMobileApp.swift +++ b/TableProMobile/TableProMobile/TableProMobileApp.swift @@ -21,17 +21,10 @@ struct TableProMobileApp: App { var body: some Scene { WindowGroup { ZStack { - Group { - if appState.hasCompletedOnboarding { - ConnectionListView() - .environment(appState) - } else { - OnboardingView() - .environment(appState) - } - } - .blur(radius: lockState.isLocked ? 20 : 0) - .allowsHitTesting(!lockState.isLocked) + SceneRootView(connectionManager: appState.connectionManager) + .environment(appState) + .blur(radius: lockState.isLocked ? 20 : 0) + .allowsHitTesting(!lockState.isLocked) if lockState.isLocked { LockScreenView() diff --git a/TableProMobile/TableProMobile/ViewModels/ConnectionFormViewModel.swift b/TableProMobile/TableProMobile/ViewModels/ConnectionFormViewModel.swift index 462b04bd01..089aaa42b6 100644 --- a/TableProMobile/TableProMobile/ViewModels/ConnectionFormViewModel.swift +++ b/TableProMobile/TableProMobile/ViewModels/ConnectionFormViewModel.swift @@ -317,8 +317,6 @@ final class ConnectionFormViewModel { try? secureStore.delete(forKey: "com.TablePro.sshkeydata.\(tempId.uuidString)") } - await appState.sshProvider.setPendingConnectionId(tempId) - do { _ = try await appState.connectionManager.connect(testConn) await appState.connectionManager.disconnect(tempId) diff --git a/TableProMobile/TableProMobile/Views/ConnectedView.swift b/TableProMobile/TableProMobile/Views/ConnectedView.swift index 46c318c5d1..215fdcc099 100644 --- a/TableProMobile/TableProMobile/Views/ConnectedView.swift +++ b/TableProMobile/TableProMobile/Views/ConnectedView.swift @@ -4,11 +4,10 @@ import TableProModels struct ConnectedView: View { @Environment(AppState.self) private var appState + @Environment(ConnectionCoordinatorStore.self) private var coordinatorStore @Environment(\.scenePhase) private var scenePhase @Environment(\.dismiss) private var dismiss let connection: DatabaseConnection - let cachedCoordinator: ConnectionCoordinator? - let onCoordinatorCreated: (ConnectionCoordinator) -> Void @State private var coordinator: ConnectionCoordinator? @State private var hapticSuccess = false @@ -48,25 +47,17 @@ struct ConnectedView: View { } message: { Text("This connection no longer exists. It may have been removed from another device.") } - .task { - if let cached = cachedCoordinator { - coordinator = cached - if case .connected = cached.phase { return } - await cached.connect() - } else { - let c = ConnectionCoordinator(connection: connection, appState: appState) - coordinator = c - onCoordinatorCreated(c) - c.restorePersistedState() - await c.connect() - } - if let c = coordinator, !Task.isCancelled { - if case .connected = c.phase { - c.loadHistory() - hapticSuccess.toggle() - } else if case .error = c.phase { - hapticError.toggle() - } + .task(id: coordinatorStore.revision) { + let resolved = coordinatorStore.coordinator(for: connection, appState: appState) + coordinator = resolved + if case .connected = resolved.phase { return } + await resolved.connect() + guard !Task.isCancelled else { return } + if case .connected = resolved.phase { + resolved.loadHistory() + hapticSuccess.toggle() + } else if case .error = resolved.phase { + hapticError.toggle() } } .onChange(of: scenePhase) { _, phase in @@ -110,6 +101,7 @@ struct ConnectedView: View { connection.name.isEmpty ? connection.host : connection.name)) } Button(String(localized: "Cancel"), role: .cancel) { + coordinator?.cancelConnect() dismiss() } .buttonStyle(.bordered) diff --git a/TableProMobile/TableProMobile/Views/ConnectionInfoView.swift b/TableProMobile/TableProMobile/Views/ConnectionInfoView.swift index f9dab0dfbe..22fb0c7dbb 100644 --- a/TableProMobile/TableProMobile/Views/ConnectionInfoView.swift +++ b/TableProMobile/TableProMobile/Views/ConnectionInfoView.swift @@ -5,6 +5,7 @@ import TableProModels struct ConnectionInfoView: View { @Environment(ConnectionCoordinator.self) private var coordinator @Environment(AppState.self) private var appState + @Environment(ConnectionCoordinatorStore.self) private var coordinatorStore private var connection: DatabaseConnection { coordinator.connection } @@ -52,6 +53,7 @@ struct ConnectionInfoView: View { )) { ConnectionFormView(editing: connection) { updated in appState.updateConnection(updated) + coordinatorStore.invalidate(updated.id) coordinator.showingEditSheet = false } } diff --git a/TableProMobile/TableProMobile/Views/ConnectionListView.swift b/TableProMobile/TableProMobile/Views/ConnectionListView.swift index 15ab659976..5ed1169a4d 100644 --- a/TableProMobile/TableProMobile/Views/ConnectionListView.swift +++ b/TableProMobile/TableProMobile/Views/ConnectionListView.swift @@ -6,6 +6,7 @@ import UniformTypeIdentifiers struct ConnectionListView: View { @Environment(AppState.self) private var appState + @Environment(ConnectionCoordinatorStore.self) private var coordinatorStore @State private var showingAddConnection = false @State private var editingConnection: DatabaseConnection? @SceneStorage("lastConnectionId") private var selectedConnectionIdString: String? @@ -17,7 +18,6 @@ struct ConnectionListView: View { @State private var editMode: EditMode = .inactive @State private var connectionToDelete: DatabaseConnection? @State private var showingSettings = false - @State private var coordinatorCache: [UUID: ConnectionCoordinator] = [:] @State private var showingFileImporter = false @State private var importItem: IdentifiableURL? @State private var showingExport = false @@ -130,10 +130,8 @@ struct ConnectionListView: View { } } .fullScreenCover(item: openConnection) { connection in - ConnectedView(connection: connection, cachedCoordinator: coordinatorCache[connection.id]) { coordinator in - coordinatorCache[connection.id] = coordinator - } - .id(connection.id) + ConnectedView(connection: connection) + .id(connection.id) } .sheet(isPresented: $showingAddConnection) { ConnectionFormView { connection in @@ -144,6 +142,7 @@ struct ConnectionListView: View { .sheet(item: $editingConnection) { connection in ConnectionFormView(editing: connection) { updated in appState.updateConnection(updated) + coordinatorStore.invalidate(updated.id) editingConnection = nil } } @@ -301,7 +300,6 @@ struct ConnectionListView: View { if selectedConnectionUUID == connection.id { selectedConnectionIdString = nil } - coordinatorCache.removeValue(forKey: connection.id) appState.removeConnection(connection) } } diff --git a/TableProMobile/TableProMobile/Views/SceneRootView.swift b/TableProMobile/TableProMobile/Views/SceneRootView.swift new file mode 100644 index 0000000000..8f36b577d4 --- /dev/null +++ b/TableProMobile/TableProMobile/Views/SceneRootView.swift @@ -0,0 +1,29 @@ +import SwiftUI +import TableProDatabase + +/// One per scene, because a coordinator carries the screen's own tab, navigation path and connect +/// attempt. Two iPad windows on the same connection are two screens, not one. +struct SceneRootView: View { + @Environment(AppState.self) private var appState + @State private var coordinatorStore: ConnectionCoordinatorStore + + init(connectionManager: ConnectionManager) { + _coordinatorStore = State( + initialValue: ConnectionCoordinatorStore(connectionManager: connectionManager) + ) + } + + var body: some View { + Group { + if appState.hasCompletedOnboarding { + ConnectionListView() + } else { + OnboardingView() + } + } + .environment(coordinatorStore) + .onChange(of: appState.connections) { previous, current in + coordinatorStore.reconcile(from: previous, to: current) + } + } +} diff --git a/TableProMobile/TableProMobileTests/ConnectionCoordinatorStoreTests.swift b/TableProMobile/TableProMobileTests/ConnectionCoordinatorStoreTests.swift new file mode 100644 index 0000000000..bef713037e --- /dev/null +++ b/TableProMobile/TableProMobileTests/ConnectionCoordinatorStoreTests.swift @@ -0,0 +1,80 @@ +import Foundation +@testable import TableProMobile +@testable import TableProModels +import Testing + +@Suite("Connection redial detection") +struct ConnectionRedialTests { + private func connection() -> DatabaseConnection { + DatabaseConnection( + name: "Prod", + type: .postgresql, + host: "db.example.com", + port: 5_432, + username: "app", + database: "app" + ) + } + + @Test("Reordering, renaming, grouping and tagging do not count as a redial") + func presentationChangesKeepTheSession() { + let original = connection() + + var renamed = original + renamed.name = "Production" + #expect(renamed.dialsTheSameWay(as: original)) + + var reordered = original + reordered.sortOrder = 7 + #expect(reordered.dialsTheSameWay(as: original)) + + var grouped = original + grouped.groupId = UUID() + #expect(grouped.dialsTheSameWay(as: original)) + + var tagged = original + tagged.tagIds = [UUID()] + #expect(tagged.dialsTheSameWay(as: original)) + + var coloured = original + coloured.colorTag = "red" + #expect(coloured.dialsTheSameWay(as: original)) + } + + @Test("Anything that changes where or how the app dials counts as a redial") + func dialingChangesDropTheSession() { + let original = connection() + + var rehosted = original + rehosted.host = "replica.example.com" + #expect(!rehosted.dialsTheSameWay(as: original)) + + var reported = original + reported.port = 5_433 + #expect(!reported.dialsTheSameWay(as: original)) + + var reuser = original + reuser.username = "readonly" + #expect(!reuser.dialsTheSameWay(as: original)) + + var redatabase = original + redatabase.database = "analytics" + #expect(!redatabase.dialsTheSameWay(as: original)) + + var retyped = original + retyped.type = .mysql + #expect(!retyped.dialsTheSameWay(as: original)) + + var tunnelled = original + tunnelled.sshEnabled = true + #expect(!tunnelled.dialsTheSameWay(as: original)) + + var secured = original + secured.sslEnabled = true + #expect(!secured.dialsTheSameWay(as: original)) + + var refielded = original + refielded.additionalFields = ["schema": "reporting"] + #expect(!refielded.dialsTheSameWay(as: original)) + } +}