diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e9c66ff5f..a158def98d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Setting in Settings > General to hide the version and Check for Updates on the Welcome window. - Install Parquet… in the Export dialog's format menu when the Parquet plugin is not installed. - Cloud Firestore and Firebase Realtime Database as a registry plugin over their REST APIs. (#2504) +- Warning banner and Test Connection notice for a server warning at login, such as an Oracle password about to expire. (#3401) ### Changed @@ -80,6 +81,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Reopen Last Session forgetting its connections after an MCP client launched TablePro in the background. - GitHub Copilot starting at launch when not in use, and running on after AI was turned off or Copilot was removed. - Settings > AI reading sign-in tokens and running command line tools for providers that were never added. +- Oracle connection timing out after 30 seconds when the password is about to expire. (#3401) +- Oracle session left open on the server when a connect was canceled during login. - Only the first statement running on iPhone and iPad when the query holds several. (#3399) - Query on iPhone and iPad appearing to do nothing after CREATE, DROP or a write that changed no rows, including every Oracle write. (#3399) - SQLite on iPhone and iPad reporting a CHECK or UNIQUE violation as a saved row in Add Row, the row editor and Shortcuts. (#3399) diff --git a/Packages/TableProOracle/Package.resolved b/Packages/TableProOracle/Package.resolved index c4454e3a01..e08958da1a 100644 --- a/Packages/TableProOracle/Package.resolved +++ b/Packages/TableProOracle/Package.resolved @@ -15,7 +15,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/TableProApp/oracle-nio", "state" : { - "revision" : "6350a34f61aebb358358392d85c29e990dcd8c0e" + "revision" : "045a44674e9f9da3904e0f2b87995490a7cc88e4" } }, { diff --git a/Packages/TableProOracle/Package.swift b/Packages/TableProOracle/Package.swift index a03b228c7d..36e073ceb7 100644 --- a/Packages/TableProOracle/Package.swift +++ b/Packages/TableProOracle/Package.swift @@ -14,7 +14,7 @@ let package = Package( dependencies: [ .package( url: "https://github.com/TableProApp/oracle-nio", - revision: "6350a34f61aebb358358392d85c29e990dcd8c0e" + revision: "045a44674e9f9da3904e0f2b87995490a7cc88e4" ), .package(url: "https://github.com/apple/swift-nio.git", from: "2.81.0"), .package(url: "https://github.com/apple/swift-nio-ssl.git", from: "2.29.0"), diff --git a/Packages/TableProOracle/Sources/TableProOracleCore/OracleAsyncTimeout.swift b/Packages/TableProOracle/Sources/TableProOracleCore/OracleAsyncTimeout.swift index 819f834c18..aa650d5f18 100644 --- a/Packages/TableProOracle/Sources/TableProOracleCore/OracleAsyncTimeout.swift +++ b/Packages/TableProOracle/Sources/TableProOracleCore/OracleAsyncTimeout.swift @@ -32,9 +32,15 @@ public func withOracleTimeout( /// The task group waits for the operation child even after the deadline fires, /// so `onTimeout` must force the operation to complete (close a connection, /// fail a promise) when the wrapped call does not respond to task cancellation. +/// +/// An operation that ignores cancellation can still succeed after the deadline +/// fired or the caller was cancelled. Nobody receives that value, so it goes to +/// `onAbandoned`, which must release whatever it holds, such as an open session. +/// It is synchronous so that releasing never holds up the caller's error. public func withOracleTimeout( seconds: Double, onTimeout: @escaping @Sendable () -> Void, + onAbandoned: @escaping @Sendable (T) -> Void = { _ in }, operation: @escaping @Sendable () async throws -> T ) async throws -> T { try await withThrowingTaskGroup(of: T.self) { group in @@ -44,10 +50,20 @@ public func withOracleTimeout( onTimeout() throw OracleTimeoutError(seconds: seconds) } - defer { group.cancelAll() } - guard let result = try await group.next() else { - throw OracleTimeoutError(seconds: seconds) + do { + guard let result = try await group.next() else { + throw OracleTimeoutError(seconds: seconds) + } + group.cancelAll() + return result + } catch { + group.cancelAll() + while !group.isEmpty { + if let late = try? await group.next() { + onAbandoned(late) + } + } + throw error } - return result } } diff --git a/Packages/TableProOracle/Sources/TableProOracleCore/OracleCoreConnection.swift b/Packages/TableProOracle/Sources/TableProOracleCore/OracleCoreConnection.swift index a28dad43c4..f2aa64f37a 100644 --- a/Packages/TableProOracle/Sources/TableProOracleCore/OracleCoreConnection.swift +++ b/Packages/TableProOracle/Sources/TableProOracleCore/OracleCoreConnection.swift @@ -107,6 +107,7 @@ public final class OracleCoreConnection: @unchecked Sendable { var capturesServerOutput = false var close = OracleCloseRecord() var serverRelease: OracleServerRelease? + var loginWarning: String? /// Read before the first statement of each session, and again before the statement after one that can /// change them, so no result ever waits on them mid-stream. var sessionZones: OracleSessionTimeZones? @@ -124,6 +125,12 @@ public final class OracleCoreConnection: @unchecked Sendable { state.withLock { $0.serverRelease } } + /// The warning the server attached to the last login that succeeded, such as ORA-28002 for a password in + /// its grace period, in the server's own words. The session is usable; the warning is for the user to read. + public var loginWarning: String? { + state.withLock { $0.loginWarning } + } + public init(options: OracleConnectionOptions) { self.options = options } @@ -161,29 +168,29 @@ public final class OracleCoreConnection: @unchecked Sendable { return counter } - let attempt = OSAllocatedUnfairLock(initialState: false) - do { + // The driver's connect does not observe task cancellation, so a login the caller + // cancelled, or the backstop gave up on, still completes. Nothing downstream will + // ever see that handle, so it closes its own session rather than leaving one open + // on the server per abandoned attempt. Closing logs off and waits for the server's + // answer, so it runs on its own, like every other close here. let connection = try await withOracleTimeout( seconds: options.loginTimeoutSeconds + Self.loginTimeoutGraceSeconds, - onTimeout: { attempt.withLock { $0 = true } } + onTimeout: {}, + onAbandoned: { connection in + Task { + try? await connection.close() + osLogger.notice( + "Closed the Oracle connection: \(OracleDisconnectReason.abandonedLoginAttempt.logDescription, privacy: .public)" + ) + } + } ) { - let connection = try await OracleNIO.OracleConnection.connect( + try await OracleNIO.OracleConnection.connect( configuration: connectConfig, id: connectionId, logger: connectLogger ) - // A connect that lands after the backstop fired has already been given - // up on, and nothing downstream will ever see this handle. It closes its - // own socket rather than leaking one per abandoned attempt. - guard !attempt.withLock({ $0 }) else { - try? await connection.close() - osLogger.notice( - "Closed the Oracle connection: \(OracleDisconnectReason.abandonedLoginAttempt.logDescription, privacy: .public)" - ) - throw OracleCoreError.loginTimedOut - } - return connection } let release = OracleServerRelease( @@ -201,6 +208,7 @@ public final class OracleCoreConnection: @unchecked Sendable { current.isConnected = true current.hasEverConnected = true current.serverRelease = release + current.loginWarning = connection.loginWarning?.message current.sessionZones = nil current.close.clearOnConnect() return true @@ -216,6 +224,8 @@ public final class OracleCoreConnection: @unchecked Sendable { } catch is OracleTimeoutError { osLogger.error("Oracle login handshake timed out after \(self.options.loginTimeoutSeconds, privacy: .public)s") throw OracleCoreError.loginTimedOut + } catch let cancellation as CancellationError { + throw cancellation } catch let sqlError as OracleSQLError { throw connectError(from: sqlError) } catch let nioSslError as NIOSSLError { diff --git a/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleAsyncTimeoutTests.swift b/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleAsyncTimeoutTests.swift index 5242a7c6b2..1c15616b6d 100644 --- a/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleAsyncTimeoutTests.swift +++ b/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleAsyncTimeoutTests.swift @@ -67,6 +67,64 @@ final class OracleAsyncTimeoutTests: XCTestCase { "onTimeout must release an operation that ignores cancellation, or the task group never unwinds" ) } + + /// A login does not observe task cancellation, so it can finish after its caller gave up. That + /// session reaches nobody, and only `onAbandoned` can close it. + func testValueArrivingAfterCancellationGoesToOnAbandoned() async { + let abandoned = Counter() + let task = Task { + try await withOracleTimeout( + seconds: 5, + onTimeout: {}, + onAbandoned: { _ in abandoned.increment() }, + operation: { + await finishIgnoringCancellation(after: .milliseconds(200)) + return "late" + } + ) + } + try? await Task.sleep(nanoseconds: 20_000_000) + task.cancel() + + switch await task.result { + case .success: + XCTFail("a cancelled call must not return the late value") + case .failure(let error): + XCTAssertTrue(error is CancellationError, "expected CancellationError, got \(error)") + } + XCTAssertEqual(abandoned.value, 1) + } + + func testValueArrivingAfterTheDeadlineGoesToOnAbandoned() async { + let abandoned = Counter() + do { + _ = try await withOracleTimeout( + seconds: 0.05, + onTimeout: {}, + onAbandoned: { _ in abandoned.increment() }, + operation: { + await finishIgnoringCancellation(after: .milliseconds(300)) + return "late" + } + ) + XCTFail("expected the deadline to fire") + } catch { + XCTAssertTrue(error is OracleTimeoutError, "expected OracleTimeoutError, got \(error)") + } + XCTAssertEqual(abandoned.value, 1) + } + + func testValueReturnedInTimeIsNotAbandoned() async throws { + let abandoned = Counter() + let result = try await withOracleTimeout( + seconds: 5, + onTimeout: {}, + onAbandoned: { _ in abandoned.increment() }, + operation: { "done" } + ) + XCTAssertEqual(result, "done") + XCTAssertEqual(abandoned.value, 0) + } } private final class Counter: @unchecked Sendable { @@ -85,3 +143,11 @@ private final class Counter: @unchecked Sendable { lock.unlock() } } + +private func finishIgnoringCancellation(after delay: DispatchTimeInterval) async { + await withCheckedContinuation { continuation in + DispatchQueue.global().asyncAfter(deadline: .now() + delay) { + continuation.resume() + } + } +} diff --git a/Plugins/BeancountDriverPlugin/Info.plist b/Plugins/BeancountDriverPlugin/Info.plist index 76b23790a2..efb90dccc5 100644 --- a/Plugins/BeancountDriverPlugin/Info.plist +++ b/Plugins/BeancountDriverPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 37 + 38 TableProProvidesDatabaseTypeIds Beancount diff --git a/Plugins/BigQueryDriverPlugin/Info.plist b/Plugins/BigQueryDriverPlugin/Info.plist index 6489eb200a..27d57a5bfc 100644 --- a/Plugins/BigQueryDriverPlugin/Info.plist +++ b/Plugins/BigQueryDriverPlugin/Info.plist @@ -5,6 +5,6 @@ TableProMinAppVersion 0.42.0 TableProPluginKitVersion - 37 + 38 diff --git a/Plugins/CSVExportPlugin/Info.plist b/Plugins/CSVExportPlugin/Info.plist index 6b42bf2f21..bf4797210e 100644 --- a/Plugins/CSVExportPlugin/Info.plist +++ b/Plugins/CSVExportPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 37 + 38 TableProProvidesExportFormatIds csv diff --git a/Plugins/CSVImportPlugin/Info.plist b/Plugins/CSVImportPlugin/Info.plist index b7b78e1f50..d847241181 100644 --- a/Plugins/CSVImportPlugin/Info.plist +++ b/Plugins/CSVImportPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 37 + 38 TableProProvidesImportFormatIds csv diff --git a/Plugins/CassandraDriverPlugin/Info.plist b/Plugins/CassandraDriverPlugin/Info.plist index 0abb7c292f..ba8b91119d 100644 --- a/Plugins/CassandraDriverPlugin/Info.plist +++ b/Plugins/CassandraDriverPlugin/Info.plist @@ -21,6 +21,6 @@ NSPrincipalClass $(PRODUCT_MODULE_NAME).CassandraPlugin TableProPluginKitVersion - 37 + 38 diff --git a/Plugins/ClickHouseDriverPlugin/Info.plist b/Plugins/ClickHouseDriverPlugin/Info.plist index 744b932348..e1817ef08e 100644 --- a/Plugins/ClickHouseDriverPlugin/Info.plist +++ b/Plugins/ClickHouseDriverPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 37 + 38 TableProProvidesDatabaseTypeIds ClickHouse diff --git a/Plugins/CloudflareD1DriverPlugin/Info.plist b/Plugins/CloudflareD1DriverPlugin/Info.plist index 6489eb200a..27d57a5bfc 100644 --- a/Plugins/CloudflareD1DriverPlugin/Info.plist +++ b/Plugins/CloudflareD1DriverPlugin/Info.plist @@ -5,6 +5,6 @@ TableProMinAppVersion 0.42.0 TableProPluginKitVersion - 37 + 38 diff --git a/Plugins/CloudflareR2SQLDriverPlugin/Info.plist b/Plugins/CloudflareR2SQLDriverPlugin/Info.plist index 2868f243f9..972b37dc53 100644 --- a/Plugins/CloudflareR2SQLDriverPlugin/Info.plist +++ b/Plugins/CloudflareR2SQLDriverPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 37 + 38 TableProProvidesDatabaseTypeIds Cloudflare R2 SQL diff --git a/Plugins/DamengDriverPlugin/Info.plist b/Plugins/DamengDriverPlugin/Info.plist index 35f8ea9c10..f4205b427c 100644 --- a/Plugins/DamengDriverPlugin/Info.plist +++ b/Plugins/DamengDriverPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 37 + 38 TableProProvidesDatabaseTypeIds Dameng diff --git a/Plugins/DuckDBDriverPlugin/Info.plist b/Plugins/DuckDBDriverPlugin/Info.plist index 7b6091ee2e..1a631ea62b 100644 --- a/Plugins/DuckDBDriverPlugin/Info.plist +++ b/Plugins/DuckDBDriverPlugin/Info.plist @@ -3,6 +3,6 @@ TableProPluginKitVersion - 37 + 38 diff --git a/Plugins/DynamoDBDriverPlugin/Info.plist b/Plugins/DynamoDBDriverPlugin/Info.plist index 6489eb200a..27d57a5bfc 100644 --- a/Plugins/DynamoDBDriverPlugin/Info.plist +++ b/Plugins/DynamoDBDriverPlugin/Info.plist @@ -5,6 +5,6 @@ TableProMinAppVersion 0.42.0 TableProPluginKitVersion - 37 + 38 diff --git a/Plugins/ElasticsearchDriverPlugin/Info.plist b/Plugins/ElasticsearchDriverPlugin/Info.plist index d1ac523f07..3200049047 100644 --- a/Plugins/ElasticsearchDriverPlugin/Info.plist +++ b/Plugins/ElasticsearchDriverPlugin/Info.plist @@ -5,6 +5,6 @@ TableProMinAppVersion 0.53.0 TableProPluginKitVersion - 37 + 38 diff --git a/Plugins/EtcdDriverPlugin/Info.plist b/Plugins/EtcdDriverPlugin/Info.plist index 6489eb200a..27d57a5bfc 100644 --- a/Plugins/EtcdDriverPlugin/Info.plist +++ b/Plugins/EtcdDriverPlugin/Info.plist @@ -5,6 +5,6 @@ TableProMinAppVersion 0.42.0 TableProPluginKitVersion - 37 + 38 diff --git a/Plugins/FirebaseDriverPlugin/Info.plist b/Plugins/FirebaseDriverPlugin/Info.plist index 5134db0ac1..93bd68ed49 100644 --- a/Plugins/FirebaseDriverPlugin/Info.plist +++ b/Plugins/FirebaseDriverPlugin/Info.plist @@ -5,7 +5,7 @@ TableProMinAppVersion 0.80.0 TableProPluginKitVersion - 37 + 38 TableProProvidesDatabaseTypeIds Firestore diff --git a/Plugins/HanaDriverPlugin/Info.plist b/Plugins/HanaDriverPlugin/Info.plist index 64de43c86e..c7dd896d4f 100644 --- a/Plugins/HanaDriverPlugin/Info.plist +++ b/Plugins/HanaDriverPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 37 + 38 TableProProvidesDatabaseTypeIds SAP HANA diff --git a/Plugins/JSONExportPlugin/Info.plist b/Plugins/JSONExportPlugin/Info.plist index edbaf6eea8..079645d4e0 100644 --- a/Plugins/JSONExportPlugin/Info.plist +++ b/Plugins/JSONExportPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 37 + 38 TableProProvidesExportFormatIds json diff --git a/Plugins/JSONImportPlugin/Info.plist b/Plugins/JSONImportPlugin/Info.plist index ca84bc4780..61a909101a 100644 --- a/Plugins/JSONImportPlugin/Info.plist +++ b/Plugins/JSONImportPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 37 + 38 TableProProvidesImportFormatIds json diff --git a/Plugins/KafkaDriverPlugin/Info.plist b/Plugins/KafkaDriverPlugin/Info.plist index 00974532f9..71efb2bb14 100644 --- a/Plugins/KafkaDriverPlugin/Info.plist +++ b/Plugins/KafkaDriverPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 37 + 38 TableProProvidesDatabaseTypeIds Kafka diff --git a/Plugins/LibSQLDriverPlugin/Info.plist b/Plugins/LibSQLDriverPlugin/Info.plist index 6489eb200a..27d57a5bfc 100644 --- a/Plugins/LibSQLDriverPlugin/Info.plist +++ b/Plugins/LibSQLDriverPlugin/Info.plist @@ -5,6 +5,6 @@ TableProMinAppVersion 0.42.0 TableProPluginKitVersion - 37 + 38 diff --git a/Plugins/MQLExportPlugin/Info.plist b/Plugins/MQLExportPlugin/Info.plist index bc13fa1bff..cf55b2d22d 100644 --- a/Plugins/MQLExportPlugin/Info.plist +++ b/Plugins/MQLExportPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 37 + 38 TableProProvidesExportFormatIds mql diff --git a/Plugins/MSSQLDriverPlugin/Info.plist b/Plugins/MSSQLDriverPlugin/Info.plist index 7b6091ee2e..1a631ea62b 100644 --- a/Plugins/MSSQLDriverPlugin/Info.plist +++ b/Plugins/MSSQLDriverPlugin/Info.plist @@ -3,6 +3,6 @@ TableProPluginKitVersion - 37 + 38 diff --git a/Plugins/MarkdownExportPlugin/Info.plist b/Plugins/MarkdownExportPlugin/Info.plist index 3c2388c6b6..3a58b1bef3 100644 --- a/Plugins/MarkdownExportPlugin/Info.plist +++ b/Plugins/MarkdownExportPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 37 + 38 TableProProvidesExportFormatIds md diff --git a/Plugins/MongoDBDriverPlugin/Info.plist b/Plugins/MongoDBDriverPlugin/Info.plist index 7b6091ee2e..1a631ea62b 100644 --- a/Plugins/MongoDBDriverPlugin/Info.plist +++ b/Plugins/MongoDBDriverPlugin/Info.plist @@ -3,6 +3,6 @@ TableProPluginKitVersion - 37 + 38 diff --git a/Plugins/MySQLDriverPlugin/Info.plist b/Plugins/MySQLDriverPlugin/Info.plist index 3113fa9627..d173aa6f79 100644 --- a/Plugins/MySQLDriverPlugin/Info.plist +++ b/Plugins/MySQLDriverPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 37 + 38 TableProProvidesDatabaseTypeIds MySQL diff --git a/Plugins/OracleDriverPlugin/Info.plist b/Plugins/OracleDriverPlugin/Info.plist index 7b6091ee2e..1a631ea62b 100644 --- a/Plugins/OracleDriverPlugin/Info.plist +++ b/Plugins/OracleDriverPlugin/Info.plist @@ -3,6 +3,6 @@ TableProPluginKitVersion - 37 + 38 diff --git a/Plugins/OracleDriverPlugin/OraclePlugin.swift b/Plugins/OracleDriverPlugin/OraclePlugin.swift index 968c34d4a2..fcfed99b2d 100644 --- a/Plugins/OracleDriverPlugin/OraclePlugin.swift +++ b/Plugins/OracleDriverPlugin/OraclePlugin.swift @@ -180,6 +180,7 @@ final class OraclePluginDriver: PluginDatabaseDriver, @unchecked Sendable { var currentSchema: String? { _currentSchema } var serverVersion: String? { _serverVersion } + var connectionWarning: String? { core?.loginWarning } var supportsSchemas: Bool { true } var supportsTransactions: Bool { true } diff --git a/Plugins/ParquetExportPlugin/Info.plist b/Plugins/ParquetExportPlugin/Info.plist index ffaf2a8a27..5bcb4498d9 100644 --- a/Plugins/ParquetExportPlugin/Info.plist +++ b/Plugins/ParquetExportPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 37 + 38 TableProProvidesExportFormatIds parquet diff --git a/Plugins/PostgreSQLDriverPlugin/Info.plist b/Plugins/PostgreSQLDriverPlugin/Info.plist index 4d2524e5f8..5f4d29d5bd 100644 --- a/Plugins/PostgreSQLDriverPlugin/Info.plist +++ b/Plugins/PostgreSQLDriverPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 37 + 38 TableProProvidesDatabaseTypeIds PostgreSQL diff --git a/Plugins/RedisDriverPlugin/Info.plist b/Plugins/RedisDriverPlugin/Info.plist index d06dbb0ba0..90c2405de5 100644 --- a/Plugins/RedisDriverPlugin/Info.plist +++ b/Plugins/RedisDriverPlugin/Info.plist @@ -21,7 +21,7 @@ NSPrincipalClass $(PRODUCT_MODULE_NAME).RedisPlugin TableProPluginKitVersion - 37 + 38 TableProProvidesDatabaseTypeIds Redis diff --git a/Plugins/SQLExportPlugin/Info.plist b/Plugins/SQLExportPlugin/Info.plist index 70b318490f..cb5e6e80c9 100644 --- a/Plugins/SQLExportPlugin/Info.plist +++ b/Plugins/SQLExportPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 37 + 38 TableProProvidesExportFormatIds sql diff --git a/Plugins/SQLImportPlugin/Info.plist b/Plugins/SQLImportPlugin/Info.plist index da574c2152..e07a342bce 100644 --- a/Plugins/SQLImportPlugin/Info.plist +++ b/Plugins/SQLImportPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 37 + 38 TableProProvidesImportFormatIds sql diff --git a/Plugins/SQLiteDriverPlugin/Info.plist b/Plugins/SQLiteDriverPlugin/Info.plist index 14f18403f0..3193203ba4 100644 --- a/Plugins/SQLiteDriverPlugin/Info.plist +++ b/Plugins/SQLiteDriverPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 37 + 38 TableProProvidesDatabaseTypeIds SQLite diff --git a/Plugins/SnowflakeDriverPlugin/Info.plist b/Plugins/SnowflakeDriverPlugin/Info.plist index 6522e354ee..f3d805857c 100644 --- a/Plugins/SnowflakeDriverPlugin/Info.plist +++ b/Plugins/SnowflakeDriverPlugin/Info.plist @@ -5,6 +5,6 @@ TableProMinAppVersion 0.48.0 TableProPluginKitVersion - 37 + 38 diff --git a/Plugins/SpannerDriverPlugin/Info.plist b/Plugins/SpannerDriverPlugin/Info.plist index 07d99b7f74..935ed5e828 100644 --- a/Plugins/SpannerDriverPlugin/Info.plist +++ b/Plugins/SpannerDriverPlugin/Info.plist @@ -5,7 +5,7 @@ TableProMinAppVersion 0.42.0 TableProPluginKitVersion - 37 + 38 TableProProvidesDatabaseTypeIds Spanner diff --git a/Plugins/SurrealDBDriverPlugin/Info.plist b/Plugins/SurrealDBDriverPlugin/Info.plist index fd64ae5058..83deb254f9 100644 --- a/Plugins/SurrealDBDriverPlugin/Info.plist +++ b/Plugins/SurrealDBDriverPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 37 + 38 TableProProvidesDatabaseTypeIds SurrealDB diff --git a/Plugins/TableProPluginKit/PluginDatabaseDriver.swift b/Plugins/TableProPluginKit/PluginDatabaseDriver.swift index 9791248ab1..ed70f1c3aa 100644 --- a/Plugins/TableProPluginKit/PluginDatabaseDriver.swift +++ b/Plugins/TableProPluginKit/PluginDatabaseDriver.swift @@ -250,6 +250,9 @@ public protocol PluginDatabaseDriver: AnyObject, Sendable { /// it and say so. Throwing is reserved for a release that was attempted and failed. func releaseIdleResource() async throws -> PluginResourceRelease var serverVersion: String? { get } + /// The warning the server sent with a login that succeeded, in the server's own words, such as an Oracle + /// password in its grace period, or `nil` when there was none. The app reads it once `connect()` returns. + var connectionWarning: String? { get } var hasLostConnection: Bool { get } var parameterStyle: ParameterStyle { get } func resolveQueryCompletionProfile( @@ -851,6 +854,8 @@ public extension PluginDatabaseDriver { var serverVersion: String? { nil } + var connectionWarning: String? { nil } + var hasLostConnection: Bool { false } var parameterStyle: ParameterStyle { .questionMark } diff --git a/Plugins/TeradataDriverPlugin/Info.plist b/Plugins/TeradataDriverPlugin/Info.plist index 7b6091ee2e..1a631ea62b 100644 --- a/Plugins/TeradataDriverPlugin/Info.plist +++ b/Plugins/TeradataDriverPlugin/Info.plist @@ -3,6 +3,6 @@ TableProPluginKitVersion - 37 + 38 diff --git a/Plugins/TrinoDriverPlugin/Info.plist b/Plugins/TrinoDriverPlugin/Info.plist index 7b6091ee2e..1a631ea62b 100644 --- a/Plugins/TrinoDriverPlugin/Info.plist +++ b/Plugins/TrinoDriverPlugin/Info.plist @@ -3,6 +3,6 @@ TableProPluginKitVersion - 37 + 38 diff --git a/Plugins/TypesenseDriverPlugin/Info.plist b/Plugins/TypesenseDriverPlugin/Info.plist index 1d42cc2248..f61dcd3213 100644 --- a/Plugins/TypesenseDriverPlugin/Info.plist +++ b/Plugins/TypesenseDriverPlugin/Info.plist @@ -5,7 +5,7 @@ TableProMinAppVersion 0.73.0 TableProPluginKitVersion - 37 + 38 TableProProvidesDatabaseTypeIds Typesense diff --git a/Plugins/WeaviateDriverPlugin/Info.plist b/Plugins/WeaviateDriverPlugin/Info.plist index a5afe05583..9e38b50117 100644 --- a/Plugins/WeaviateDriverPlugin/Info.plist +++ b/Plugins/WeaviateDriverPlugin/Info.plist @@ -5,7 +5,7 @@ TableProMinAppVersion 0.73.0 TableProPluginKitVersion - 37 + 38 TableProProvidesDatabaseTypeIds Weaviate diff --git a/Plugins/XLSXExportPlugin/Info.plist b/Plugins/XLSXExportPlugin/Info.plist index b1a0d4f234..56627bb15f 100644 --- a/Plugins/XLSXExportPlugin/Info.plist +++ b/Plugins/XLSXExportPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 37 + 38 TableProProvidesExportFormatIds xlsx diff --git a/Plugins/XLSXImportPlugin/Info.plist b/Plugins/XLSXImportPlugin/Info.plist index 4252cc7064..e64e3ac6dc 100644 --- a/Plugins/XLSXImportPlugin/Info.plist +++ b/Plugins/XLSXImportPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 37 + 38 TableProProvidesImportFormatIds xlsx diff --git a/TablePro.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/TablePro.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 58c73e5ade..b02df052e7 100644 --- a/TablePro.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/TablePro.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -14,7 +14,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/TableProApp/oracle-nio", "state" : { - "revision" : "6350a34f61aebb358358392d85c29e990dcd8c0e" + "revision" : "045a44674e9f9da3904e0f2b87995490a7cc88e4" } }, { diff --git a/TablePro/Core/Database/DatabaseDriver.swift b/TablePro/Core/Database/DatabaseDriver.swift index c57eb076fb..e37f3e8926 100644 --- a/TablePro/Core/Database/DatabaseDriver.swift +++ b/TablePro/Core/Database/DatabaseDriver.swift @@ -25,6 +25,10 @@ protocol DatabaseDriver: AnyObject, Sendable { /// Optional - not all drivers may implement this var serverVersion: String? { get } + /// The warning the server sent with the last login that succeeded. See + /// `PluginDatabaseDriver.connectionWarning`. + var connectionWarning: String? { get } + /// The lexical facts the server decided for this session and the driver has read. See /// `PluginDatabaseDriver.sessionLexicalState`. var sessionLexicalState: PluginSessionLexicalState? { get } @@ -449,6 +453,8 @@ extension DatabaseDriver { /// Override in drivers that support version querying var serverVersion: String? { nil } + var connectionWarning: String? { nil } + var sessionLexicalState: PluginSessionLexicalState? { nil } func connectReporting(stage report: @escaping ConnectionStageReporter) async throws { diff --git a/TablePro/Core/Database/DatabaseManager+Queries.swift b/TablePro/Core/Database/DatabaseManager+Queries.swift index 1863f954cc..5e185703bd 100644 --- a/TablePro/Core/Database/DatabaseManager+Queries.swift +++ b/TablePro/Core/Database/DatabaseManager+Queries.swift @@ -38,7 +38,7 @@ extension DatabaseManager { _ connection: DatabaseConnection, sshPassword: String? = nil, passwordOverride: String? = nil - ) async throws -> Bool { + ) async throws -> ConnectionTestResult { let timeoutEndpoint = ConnectionTimeoutEndpoint.database(connection.timeoutEndpointName) let queryTimeoutSeconds = ConnectionTimeoutPolicy.effectiveQueryTimeoutSeconds( configuredSeconds: connection.queryTimeoutSeconds, @@ -47,11 +47,12 @@ extension DatabaseManager { // A remote file answers the only question this button asks without moving the file. if connection.activeTunnelKind == .remoteFile { - return try await testRemoteDatabaseFile( + let reached = try await testRemoteDatabaseFile( connection, sshPassword: sshPassword, deadline: ConnectionDeadline(configuredSeconds: connection.connectTimeoutSeconds) ) + return ConnectionTestResult(succeeded: reached) } let preparedConfiguration = try await DatabaseDriverFactory.prepareConfiguration(for: connection) @@ -66,7 +67,7 @@ extension DatabaseManager { let tunnelManager = activeTunnelManager(for: connection) - let result: Bool + let result: ConnectionTestResult do { let driver = try await DatabaseDriverFactory.createDriver( for: testConnection, @@ -77,7 +78,8 @@ extension DatabaseManager { effectiveQueryTimeoutSeconds: queryTimeoutSeconds, preparedConfiguration: preparedConfiguration ) - result = try await driver.testConnection() + let succeeded = try await driver.testConnection() + result = ConnectionTestResult(succeeded: succeeded, warning: driver.connectionWarning) } catch { let reportedError = await Self.preferredTunnelFailure(replacing: error) { await self.attributedTunnelFailure(for: connection) diff --git a/TablePro/Core/Database/DatabaseManager+Sessions.swift b/TablePro/Core/Database/DatabaseManager+Sessions.swift index 4541bf1b1d..5c8a0458b1 100644 --- a/TablePro/Core/Database/DatabaseManager+Sessions.swift +++ b/TablePro/Core/Database/DatabaseManager+Sessions.swift @@ -156,6 +156,7 @@ extension DatabaseManager { session.driver = driver session.status = driver.status session.effectiveConnection = effectiveConnection + session.connectionWarning = driver.connectionWarning /// A connect is the answer to whatever went wrong before it, including a reconnect /// that gave up on this same entry, so the mark and the reason it carried go here. session.liveness = .live @@ -799,6 +800,11 @@ extension DatabaseManager { setSession(session, for: sessionId) } + /// Hides the login warning in every window on this session, until the next connect brings it back. + func dismissConnectionWarning(for connectionId: UUID) { + updateSession(connectionId) { $0.connectionWarning = nil } + } + func observeConnectionUpdates() { connectionUpdatedCancellable = AppEvents.shared.connectionUpdated .receive(on: RunLoop.main) diff --git a/TablePro/Core/Plugins/PluginDriverAdapter.swift b/TablePro/Core/Plugins/PluginDriverAdapter.swift index d8d6bfec3e..d2fd0a44d2 100644 --- a/TablePro/Core/Plugins/PluginDriverAdapter.swift +++ b/TablePro/Core/Plugins/PluginDriverAdapter.swift @@ -24,6 +24,9 @@ final class PluginDriverAdapter: DatabaseDriver, SchemaSwitchable, DatabaseRepor var connectAttempt: ConnectAttempt? var connectionMayBeOpen = false var disconnectInProgress = false + /// Read from the driver when its connect is adopted, so it outlives the `disconnect()` Test Connection + /// makes before anyone asks for it. + var connectionWarning: String? } let connection: DatabaseConnection @@ -44,6 +47,7 @@ final class PluginDriverAdapter: DatabaseDriver, SchemaSwitchable, DatabaseRepor } var serverVersion: String? { pluginDriver.serverVersion } + var connectionWarning: String? { state.withLock { $0.connectionWarning } } var sessionLexicalState: PluginSessionLexicalState? { pluginDriver.sessionLexicalState } var parameterStyle: ParameterStyle { pluginDriver.parameterStyle } @@ -160,6 +164,7 @@ final class PluginDriverAdapter: DatabaseDriver, SchemaSwitchable, DatabaseRepor state.nextConnectGeneration &+= 1 let generation = state.nextConnectGeneration state.status = .connecting + state.connectionWarning = nil state.connectAttempt = ConnectAttempt(generation: generation, gate: gate) return generation } @@ -304,6 +309,7 @@ final class PluginDriverAdapter: DatabaseDriver, SchemaSwitchable, DatabaseRepor adopted: Bool, error: Error? ) { + let warning = succeeded ? pluginDriver.connectionWarning : nil let shouldDisconnect = state.withLock { state in guard let attempt = state.connectAttempt, attempt.generation == generation else { return false @@ -313,6 +319,7 @@ final class PluginDriverAdapter: DatabaseDriver, SchemaSwitchable, DatabaseRepor if keepsConnection { state.connectionMayBeOpen = true state.status = .connected + state.connectionWarning = warning return false } if let error, adopted { diff --git a/TablePro/Core/Plugins/PluginManager.swift b/TablePro/Core/Plugins/PluginManager.swift index 43a1acf140..26030fc0a4 100644 --- a/TablePro/Core/Plugins/PluginManager.swift +++ b/TablePro/Core/Plugins/PluginManager.swift @@ -130,7 +130,12 @@ final class PluginManager: ObservableObject { /// and `DriverConnectionConfig.refreshPassword`, which mints a token password again for a /// sign-in the driver makes on its own. The old initializer keeps its symbol, so the minimum /// stays and no bulk re-release is needed. - nonisolated static let currentPluginKitVersion = 37 + /// + /// 38 adds `PluginDatabaseDriver.connectionWarning`, the warning a server sent with a login that + /// succeeded, such as an Oracle password in its grace period. The default is `nil`, so an + /// already-built plugin keeps loading and reports none; the minimum stays and no bulk re-release + /// is needed. + nonisolated static let currentPluginKitVersion = 38 /// Still 19, so every plugin already published for the previous release keeps loading. nonisolated static let minimumCompatiblePluginKitVersion = 19 diff --git a/TablePro/Models/Connection/ConnectionSession.swift b/TablePro/Models/Connection/ConnectionSession.swift index 765f8944fb..881eada3a0 100644 --- a/TablePro/Models/Connection/ConnectionSession.swift +++ b/TablePro/Models/Connection/ConnectionSession.swift @@ -21,6 +21,10 @@ struct ConnectionSession: Identifiable { /// this struct's own default value. var liveness: ConnectionLiveness = .live var lastError: String? + /// The warning the server sent with the login that opened this session, such as an Oracle password in its + /// grace period. The user dismissing it clears it; the next connect brings it back, because the server sends + /// it on every login. A reconnect the app makes on its own leaves it as it is. + var connectionWarning: String? /// Live write-protection level. Seeded from the saved default; the toolbar /// updates this, so reading `connection.safeModeLevel` is wrong afterwards. @@ -130,6 +134,7 @@ struct ConnectionSession: Identifiable { && tableOperationOptions == other.tableOperationOptions && browseSchema == other.browseSchema && browseDatabase == other.browseDatabase + && connectionWarning == other.connectionWarning } /// Puts back the Truncate and Drop a save took off the queue and did not complete. diff --git a/TablePro/Models/Connection/ConnectionTestResult.swift b/TablePro/Models/Connection/ConnectionTestResult.swift new file mode 100644 index 0000000000..5bd320a319 --- /dev/null +++ b/TablePro/Models/Connection/ConnectionTestResult.swift @@ -0,0 +1,18 @@ +// +// ConnectionTestResult.swift +// TablePro +// + +import Foundation + +/// What Test Connection learned from a server that answered. A test that could not connect throws instead. +internal struct ConnectionTestResult: Equatable, Sendable { + let succeeded: Bool + /// The warning the server sent with the login, such as an Oracle password in its grace period. + let warning: String? + + init(succeeded: Bool, warning: String? = nil) { + self.succeeded = succeeded + self.warning = succeeded ? warning : nil + } +} diff --git a/TablePro/Models/Connection/ConnectionToolbarState.swift b/TablePro/Models/Connection/ConnectionToolbarState.swift index 41cb3de55d..800f79d617 100644 --- a/TablePro/Models/Connection/ConnectionToolbarState.swift +++ b/TablePro/Models/Connection/ConnectionToolbarState.swift @@ -69,6 +69,9 @@ final class ConnectionToolbarState: ObservableObject { /// Current connection state @Published var connectionState: ToolbarConnectionState = .disconnected + /// The session's login warning, mirrored here so the window shows and hides it as the session changes. + @Published var connectionWarning: String? + // MARK: - Query Execution /// How long the last completed query took, and which tab ran it. @@ -197,6 +200,11 @@ final class ConnectionToolbarState: ObservableObject { if safeModeLevel != resolvedSafeMode { safeModeLevel = resolvedSafeMode } + + let resolvedWarning = DatabaseManager.shared.session(for: connection.id)?.connectionWarning + if connectionWarning != resolvedWarning { + connectionWarning = resolvedWarning + } } /// Update connection state from ConnectionStatus. @@ -217,6 +225,7 @@ final class ConnectionToolbarState: ObservableObject { currentDatabase = "" currentSchema = nil connectionState = .disconnected + connectionWarning = nil queryTimings = [:] safeModeLevel = .silent isTableTab = false diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index ca76b13d90..3ecef3f62d 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -46088,6 +46088,12 @@ } } } + }, + "Connected with a warning" : { + + }, + "Connected with a warning. %@" : { + }, "connecting" : { "localizations" : { @@ -50629,6 +50635,9 @@ } } } + }, + "Copy Warning" : { + }, "Copy with Headers" : { "localizations" : { diff --git a/TablePro/Resources/ThirdPartyLicenses/licenses.yml b/TablePro/Resources/ThirdPartyLicenses/licenses.yml index 2f71f0526d..7bf38e082d 100644 --- a/TablePro/Resources/ThirdPartyLicenses/licenses.yml +++ b/TablePro/Resources/ThirdPartyLicenses/licenses.yml @@ -355,12 +355,12 @@ brew has installed (UNVERIFIED at build time; the shipped dylibs are the pinned 3.4.3). - id: oracle-nio name: OracleNIO - version: rev:6350a34f61aebb358358392d85c29e990dcd8c0e + version: rev:045a44674e9f9da3904e0f2b87995490a7cc88e4 spdx: Apache-2.0 copyrights: - Copyright (c) 2023 Timo Zacherl homepageURL: https://github.com/TableProApp/oracle-nio - licenseTextURL: https://github.com/TableProApp/oracle-nio/blob/6350a34f61aebb358358392d85c29e990dcd8c0e/LICENSE + licenseTextURL: https://github.com/TableProApp/oracle-nio/blob/045a44674e9f9da3904e0f2b87995490a7cc88e4/LICENSE textFile: texts/oracle-nio.txt source: spm:oracle-nio patched: false diff --git a/TablePro/Views/Connection/ConnectionWarningBanner.swift b/TablePro/Views/Connection/ConnectionWarningBanner.swift new file mode 100644 index 0000000000..4f688408f3 --- /dev/null +++ b/TablePro/Views/Connection/ConnectionWarningBanner.swift @@ -0,0 +1,74 @@ +// +// ConnectionWarningBanner.swift +// TablePro +// + +import SwiftUI + +/// The warning a server sent with a login that succeeded, shown in place rather than in an alert: the +/// connection works and nothing here acts on the warning, and the HIG keeps alerts for what needs action. +internal struct ConnectionWarningBanner: View { + let message: String + let onDismiss: () -> Void + + var body: some View { + HStack(alignment: .top, spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.yellow) + .accessibilityHidden(true) + + VStack(alignment: .leading, spacing: 2) { + Text(String(localized: "Connected with a warning")) + .font(.callout.weight(.semibold)) + Text(message) + .font(.callout) + .foregroundStyle(.secondary) + .lineLimit(3) + .truncationMode(.tail) + .fixedSize(horizontal: false, vertical: true) + .textSelection(.enabled) + .help(message) + } + + Spacer(minLength: 8) + + Button { + ClipboardService.shared.writeText(message) + } label: { + Image(systemName: "doc.on.doc") + .imageScale(.small) + } + .buttonStyle(.borderless) + .help(String(localized: "Copy Warning")) + .accessibilityLabel(String(localized: "Copy Warning")) + + Button { + onDismiss() + } label: { + Image(systemName: "xmark") + .imageScale(.small) + } + .buttonStyle(.borderless) + .help(String(localized: "Dismiss")) + .accessibilityLabel(String(localized: "Dismiss")) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + // A shape style background reaches into the safe area by default, which tints the toolbar above. + .background(.yellow.opacity(0.12), ignoresSafeAreaEdges: []) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("connection-warning-banner") + } +} + +#Preview { + VStack(spacing: 0) { + ConnectionWarningBanner( + message: "ORA-28002: the password will expire within 7 days", + onDismiss: {} + ) + Divider() + Spacer() + } + .frame(width: 640, height: 200) +} diff --git a/TablePro/Views/ConnectionForm/Components/ConnectionFormActionBar.swift b/TablePro/Views/ConnectionForm/Components/ConnectionFormActionBar.swift index 938d2d8ebe..35b20512dd 100644 --- a/TablePro/Views/ConnectionForm/Components/ConnectionFormActionBar.swift +++ b/TablePro/Views/ConnectionForm/Components/ConnectionFormActionBar.swift @@ -25,7 +25,11 @@ struct ConnectionFormActionBar: View { return HStack(spacing: 12) { deleteButton - validationMessage(issues) + if issues.isEmpty { + testWarningMessage + } else { + validationMessage(issues) + } Spacer(minLength: 12) TestConnectionStatusButton(coordinator: coordinator) Button(String(localized: "Cancel")) { @@ -104,6 +108,23 @@ struct ConnectionFormActionBar: View { } } + /// The warning the last test's login brought back, in the slot a validation issue would take. Test is off + /// while there is an issue, so the two never compete when the result arrives. + @ViewBuilder + private var testWarningMessage: some View { + if let warning = coordinator.testResult?.warning { + Label(warning, systemImage: "exclamationmark.triangle.fill") + .font(.callout) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.tail) + .textSelection(.enabled) + .help(warning) + .accessibilityElement(children: .combine) + .accessibilityIdentifier("connection-form-test-warning") + } + } + private func messageText(_ issue: String) -> String { guard let tab = coordinator.firstTabWithIssue, tab != coordinator.selectedTab else { return issue diff --git a/TablePro/Views/ConnectionForm/Components/TestConnectionStatusButton.swift b/TablePro/Views/ConnectionForm/Components/TestConnectionStatusButton.swift index 44ad7e1100..c2794b5d85 100644 --- a/TablePro/Views/ConnectionForm/Components/TestConnectionStatusButton.swift +++ b/TablePro/Views/ConnectionForm/Components/TestConnectionStatusButton.swift @@ -30,6 +30,9 @@ struct TestConnectionStatusButton: View { if coordinator.isTesting { return String(localized: "Testing connection") } + if coordinator.testResult?.warning != nil { + return String(localized: "Connected with a warning") + } if coordinator.testSucceeded { return String(localized: "Connection succeeded") } @@ -41,6 +44,9 @@ struct TestConnectionStatusButton: View { if coordinator.isTesting { ProgressView() .controlSize(.small) + } else if coordinator.testResult?.warning != nil { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.yellow) } else if coordinator.testSucceeded { Image(systemName: "checkmark.circle.fill") .foregroundStyle(.green) diff --git a/TablePro/Views/ConnectionForm/ConnectionFormCoordinator+Transport.swift b/TablePro/Views/ConnectionForm/ConnectionFormCoordinator+Transport.swift index 617c21f616..3dab2e5ab1 100644 --- a/TablePro/Views/ConnectionForm/ConnectionFormCoordinator+Transport.swift +++ b/TablePro/Views/ConnectionForm/ConnectionFormCoordinator+Transport.swift @@ -42,7 +42,7 @@ extension ConnectionFormCoordinator { cloudSQLProxy.state.enabled = newValue == .cloudSQLProxy socksProxy.state.enabled = newValue == .socksProxy tunnelCommand.state.enabled = newValue == .tunnelCommand - testSucceeded = false + testResult = nil } } @@ -81,7 +81,7 @@ extension ConnectionFormCoordinator { if network.usesLocalSocket { transport = nil } - testSucceeded = false + testResult = nil } var supportsRemoteDatabaseFile: Bool { diff --git a/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift b/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift index 79c19bf4c7..c17f8cb808 100644 --- a/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift +++ b/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift @@ -43,7 +43,10 @@ final class ConnectionFormCoordinator: ObservableObject { @Published var hasLoadedData: Bool = false @Published var isTesting: Bool = false - @Published var testSucceeded: Bool = false + /// The last Test Connection that reached the server, with the warning the login brought back. A new test, a + /// type change and a transport change clear it. + @Published var testResult: ConnectionTestResult? + var testSucceeded: Bool { testResult?.succeeded == true } @Published var testTask: Task? @Published var isInstallingPlugin: Bool = false @@ -251,7 +254,7 @@ final class ConnectionFormCoordinator: ObservableObject { } func didChangeType(_ newType: DatabaseType) { - testSucceeded = false + testResult = nil if hasLoadedData { applyTypeDefaults(newType, includeNetwork: true) auth.resetForType(newType) @@ -595,7 +598,7 @@ final class ConnectionFormCoordinator: ObservableObject { func test() { guard testTask == nil else { return } isTesting = true - testSucceeded = false + testResult = nil let window = NSApp.keyWindow var testConn = buildEdits().applied(to: DatabaseConnection(id: UUID(), name: "")) @@ -667,7 +670,7 @@ final class ConnectionFormCoordinator: ObservableObject { return } - let success = try await services.databaseManager.testConnection( + let result = try await services.databaseManager.testConnection( testConn, sshPassword: sshPasswordForTest, passwordOverride: testPwOverride @@ -676,8 +679,13 @@ final class ConnectionFormCoordinator: ObservableObject { self?.cleanupTestSecrets(for: testConn.id) self?.isTesting = false self?.testTask = nil - if success { - self?.testSucceeded = true + if result.succeeded { + self?.testResult = result + if let warning = result.warning { + AccessibilityAnnouncement.post( + String(format: String(localized: "Connected with a warning. %@"), warning) + ) + } } else { AlertHelper.showErrorSheet( title: String(localized: "Connection Test Failed"), @@ -695,7 +703,7 @@ final class ConnectionFormCoordinator: ObservableObject { await MainActor.run { self?.cleanupTestSecrets(for: testConn.id) self?.isTesting = false - self?.testSucceeded = false + self?.testResult = nil self?.testTask = nil if case PluginError.pluginNotInstalled = error { self?.pluginInstallConnection = testConn diff --git a/TablePro/Views/Main/Extensions/MainContentView+Helpers.swift b/TablePro/Views/Main/Extensions/MainContentView+Helpers.swift index 456c09196a..6debd687cd 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+Helpers.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+Helpers.swift @@ -47,6 +47,16 @@ extension MainContentView { toolbarState.syncFromSession(for: connection) } + /// The banner appears without the user doing anything and macOS has no live region for it, so VoiceOver + /// hears it only through an announcement. Every window on the session shows the banner, and announcements + /// are app-wide, so only the key window speaks. + func announceConnectionWarning(_ warning: String?) { + guard let warning, coordinator.isKeyWindow else { return } + AccessibilityAnnouncement.post( + String(format: String(localized: "Connected with a warning. %@"), warning) + ) + } + private func consumePendingLoad(trigger: TableLoadTrigger) { if let tabId = tabManager.selectedTab?.id { coordinator.resolveTableTabSchemaIfNeeded(tabId: tabId) diff --git a/TablePro/Views/Main/MainContentView.swift b/TablePro/Views/Main/MainContentView.swift index d07005c633..6877c78838 100644 --- a/TablePro/Views/Main/MainContentView.swift +++ b/TablePro/Views/Main/MainContentView.swift @@ -448,8 +448,23 @@ struct MainContentView: View { // MARK: - Main Content - @ViewBuilder private var mainContentView: some View { + VStack(spacing: 0) { + if let warning = toolbarState.connectionWarning { + ConnectionWarningBanner(message: warning) { + DatabaseManager.shared.dismissConnectionWarning(for: connection.id) + } + Divider() + } + editorContentView + } + .onChange(of: toolbarState.connectionWarning) { warning in + announceConnectionWarning(warning) + } + } + + @ViewBuilder + private var editorContentView: some View { MainEditorContentView( tabManager: tabManager, coordinator: coordinator, diff --git a/TableProMobile/TableProMobile.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/TableProMobile/TableProMobile.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 15f3559f8b..638336afe0 100644 --- a/TableProMobile/TableProMobile.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/TableProMobile/TableProMobile.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -15,7 +15,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/TableProApp/oracle-nio", "state" : { - "revision" : "6350a34f61aebb358358392d85c29e990dcd8c0e" + "revision" : "045a44674e9f9da3904e0f2b87995490a7cc88e4" } }, { diff --git a/TableProMobile/TableProMobile/Acknowledgements/Acknowledgements.json b/TableProMobile/TableProMobile/Acknowledgements/Acknowledgements.json index b1b5ff489b..8b6465cad7 100644 --- a/TableProMobile/TableProMobile/Acknowledgements/Acknowledgements.json +++ b/TableProMobile/TableProMobile/Acknowledgements/Acknowledgements.json @@ -116,7 +116,7 @@ { "id": "oracle-nio", "name": "OracleNIO", - "version": "rev:6350a34f61aebb358358392d85c29e990dcd8c0e", + "version": "rev:045a44674e9f9da3904e0f2b87995490a7cc88e4", "spdx": "Apache-2.0", "copyrights": [ "Copyright (c) 2023 Timo Zacherl" diff --git a/TableProTests/Core/Database/ConnectionWarningTests.swift b/TableProTests/Core/Database/ConnectionWarningTests.swift new file mode 100644 index 0000000000..adfb14ceec --- /dev/null +++ b/TableProTests/Core/Database/ConnectionWarningTests.swift @@ -0,0 +1,121 @@ +// +// ConnectionWarningTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +/// A login can succeed with a warning attached, such as ORA-28002 for an Oracle password in its grace +/// period. The session keeps it for the window to show, and Test Connection hands it back. +@Suite(.serialized) +@MainActor +struct ConnectionWarningTests { + private static let warning = "ORA-28002: the password will expire within 7 days" + + @Test("A connect that succeeds with a warning keeps it on the session until the user dismisses it") + func connectKeepsTheWarningOnTheSession() async throws { + FakeMSSQLPluginRegistration.registerIfNeeded() + let connection = TestFixtures.makeConnection(name: "Grace period", type: .mssql) + FakeMSSQLPlugin.warnOnConnect(for: connection.id, message: Self.warning) + defer { FakeMSSQLPlugin.clearConnectWarning(for: connection.id) } + + do { + try await DatabaseManager.shared.ensureConnected(connection) + #expect(DatabaseManager.shared.session(for: connection.id)?.connectionWarning == Self.warning) + + let toolbarState = ConnectionToolbarState() + toolbarState.syncFromSession(for: connection) + #expect(toolbarState.connectionWarning == Self.warning) + + DatabaseManager.shared.dismissConnectionWarning(for: connection.id) + #expect(DatabaseManager.shared.session(for: connection.id)?.connectionWarning == nil) + toolbarState.syncFromSession(for: connection) + #expect(toolbarState.connectionWarning == nil) + } catch { + await cleanUp(connection.id) + throw error + } + await cleanUp(connection.id) + } + + @Test("A connect without a warning leaves the session without one") + func connectWithoutAWarningHasNone() async throws { + FakeMSSQLPluginRegistration.registerIfNeeded() + let connection = TestFixtures.makeConnection(name: "No warning", type: .mssql) + + do { + try await DatabaseManager.shared.ensureConnected(connection) + let session = try #require(DatabaseManager.shared.session(for: connection.id)) + #expect(session.connectionWarning == nil) + } catch { + await cleanUp(connection.id) + throw error + } + await cleanUp(connection.id) + } + + @Test("A connect canceled while logging in never puts its warning on the session") + func canceledConnectLeavesNoWarning() async throws { + FakeMSSQLPluginRegistration.registerIfNeeded() + let connection = TestFixtures.makeConnection(name: "Canceled grace period", type: .mssql) + FakeMSSQLPlugin.warnOnConnect(for: connection.id, message: Self.warning) + let connectHold = FakeMSSQLPlugin.holdConnect(for: connection.id) + defer { + FakeMSSQLPlugin.clearConnectWarning(for: connection.id) + FakeMSSQLPlugin.clearConnectHold(for: connection.id) + } + + let connectTask = Task { @MainActor in + try await DatabaseManager.shared.ensureConnected(connection) + } + let reachedDriver = await BoundedCall.result(within: .seconds(2)) { + await connectHold.waitUntilReached() + return true + } + #expect(reachedDriver == true) + + await DatabaseManager.shared.cancelEnsureConnected(connection.id) + await connectHold.release() + _ = await connectTask.result + + #expect(DatabaseManager.shared.session(for: connection.id)?.connectionWarning == nil) + await cleanUp(connection.id) + } + + @Test("Test Connection reports the warning the login brought back") + func testConnectionReturnsTheWarning() async throws { + FakeMSSQLPluginRegistration.registerIfNeeded() + let connection = TestFixtures.makeConnection(name: "Grace period test", type: .mssql) + FakeMSSQLPlugin.warnOnConnect(for: connection.id, message: Self.warning) + defer { FakeMSSQLPlugin.clearConnectWarning(for: connection.id) } + + let result = try await DatabaseManager.shared.testConnection(connection) + + #expect(result == ConnectionTestResult(succeeded: true, warning: Self.warning)) + } + + @Test("A dismissed warning is a change windows hear about") + func warningIsPartOfTheSessionContent() { + let connection = TestFixtures.makeConnection() + var warned = ConnectionSession(connection: connection) + warned.connectionWarning = Self.warning + let plain = ConnectionSession(connection: connection) + + #expect(!warned.isContentViewEquivalent(to: plain)) + } + + @Test("A failed test carries no warning") + func failedTestCarriesNoWarning() { + #expect(ConnectionTestResult(succeeded: false, warning: Self.warning).warning == nil) + #expect(ConnectionTestResult(succeeded: true, warning: Self.warning).warning == Self.warning) + } + + private func cleanUp(_ connectionId: UUID) async { + await DatabaseManager.shared.stopHealthMonitor(for: connectionId) + DatabaseManager.shared.driver(for: connectionId)?.disconnect() + DatabaseManager.shared.removeSession(for: connectionId) + } +} diff --git a/TableProTests/Core/Plugins/PluginKitABIResilienceTests.swift b/TableProTests/Core/Plugins/PluginKitABIResilienceTests.swift index a788ed548d..852c877eee 100644 --- a/TableProTests/Core/Plugins/PluginKitABIResilienceTests.swift +++ b/TableProTests/Core/Plugins/PluginKitABIResilienceTests.swift @@ -16,6 +16,34 @@ import Foundation import TableProPluginKit import Testing +/// Implements only what `PluginDatabaseDriver` requires, for the defaults `FakeMSSQLPluginDriver` overrides. +private final class BareRequiredSurfaceDriver: PluginDatabaseDriver, @unchecked Sendable { + var supportsSchemas: Bool { false } + var supportsTransactions: Bool { false } + var currentSchema: String? { nil } + + func connect() async throws {} + func disconnect() {} + + func execute(query: String) async throws -> PluginQueryResult { + PluginQueryResult(columns: [], columnTypeNames: [], rows: [], rowsAffected: 0, executionTime: 0) + } + + func fetchTables(schema: String?) async throws -> [PluginTableInfo] { [] } + func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { [] } + func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { [] } + func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { [] } + func fetchTableDDL(table: String, schema: String?) async throws -> String { "" } + func fetchViewDefinition(view: String, schema: String?) async throws -> String { "" } + func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { + PluginTableMetadata(tableName: table) + } + func fetchDatabases() async throws -> [String] { [] } + func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { + PluginDatabaseMetadata(name: database) + } +} + struct PluginKitABIResilienceTests { private func makeMinimalDriver() -> any PluginDatabaseDriver { FakeMSSQLPluginDriver() @@ -51,6 +79,13 @@ struct PluginKitABIResilienceTests { ) } + @Test("A driver built before connect warnings existed reports none") + func connectionWarningDefaultsToNone() { + let driver: any PluginDatabaseDriver = BareRequiredSurfaceDriver() + #expect(driver.connectionWarning == nil) + #expect(driver.hasLostConnection == false) + } + @Test("A driver that answers only the older name list has it lifted into table-like descriptors") func legacyMaintenanceNamesAreLifted() { let lifted = PluginMaintenanceOperation.lifting(["VACUUM", "ANALYZE"]) diff --git a/TableProTests/Helpers/FakeMSSQLPlugin.swift b/TableProTests/Helpers/FakeMSSQLPlugin.swift index 17b2407c40..0996cd7a1c 100644 --- a/TableProTests/Helpers/FakeMSSQLPlugin.swift +++ b/TableProTests/Helpers/FakeMSSQLPlugin.swift @@ -87,6 +87,7 @@ final class FakeMSSQLPlugin: NSObject, TableProPlugin, DriverPlugin { private static let connectFailures = OSAllocatedUnfairLock<[String: ConnectFailure]>(initialState: [:]) private static let connectHolds = OSAllocatedUnfairLock<[String: FakeMSSQLConnectHold]>(initialState: [:]) + private static let connectWarnings = OSAllocatedUnfairLock<[String: String]>(initialState: [:]) private static let createdDriverConfigurations = OSAllocatedUnfairLock<[String: [CreatedDriverConfiguration]]>( initialState: [:] ) @@ -99,6 +100,15 @@ final class FakeMSSQLPlugin: NSObject, TableProPlugin, DriverPlugin { _ = connectFailures.withLock { $0.removeValue(forKey: connectionId.uuidString) } } + /// The next login on this connection succeeds with this warning, as an Oracle password in its grace period does. + static func warnOnConnect(for connectionId: UUID, message: String) { + connectWarnings.withLock { $0[connectionId.uuidString] = message } + } + + static func clearConnectWarning(for connectionId: UUID) { + _ = connectWarnings.withLock { $0.removeValue(forKey: connectionId.uuidString) } + } + static func holdConnect(for connectionId: UUID) -> FakeMSSQLConnectHold { let hold = FakeMSSQLConnectHold() connectHolds.withLock { $0[connectionId.uuidString] = hold } @@ -133,8 +143,9 @@ final class FakeMSSQLPlugin: NSObject, TableProPlugin, DriverPlugin { Self.createdDriverConfigurations.withLock { $0[connectionId]?.append(createdConfiguration) } + let connectWarning = Self.connectWarnings.withLock { $0[connectionId] } guard let failure = Self.connectFailures.withLock({ $0[connectionId] }) else { - return FakeMSSQLPluginDriver(connectHold: connectHold) + return FakeMSSQLPluginDriver(connectHold: connectHold, connectWarning: connectWarning) } return FakeMSSQLPluginDriver( connectFailure: failure.error, @@ -160,16 +171,22 @@ final class FakeMSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { private let connectFailure: (any Error & Sendable)? private let connectDelay: Duration private let connectHold: FakeMSSQLConnectHold? + private let connectWarning: String? private let queryTimeoutValues = OSAllocatedUnfairLock<[Int]>(initialState: []) + private let reportedWarning = OSAllocatedUnfairLock(initialState: nil) + + var connectionWarning: String? { reportedWarning.withLock { $0 } } init( connectFailure: (any Error & Sendable)? = nil, connectDelay: Duration = .zero, - connectHold: FakeMSSQLConnectHold? = nil + connectHold: FakeMSSQLConnectHold? = nil, + connectWarning: String? = nil ) { self.connectFailure = connectFailure self.connectDelay = connectDelay self.connectHold = connectHold + self.connectWarning = connectWarning } func connect() async throws { @@ -180,6 +197,7 @@ final class FakeMSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { try? await Task.sleep(for: connectDelay) } if let connectFailure { throw connectFailure } + reportedWarning.withLock { $0 = connectWarning } } func disconnect() { disconnectCallCount += 1 } diff --git a/TableProTests/ViewModels/ConnectionFormTransportTests.swift b/TableProTests/ViewModels/ConnectionFormTransportTests.swift index e6fb959dd6..64d0a766dc 100644 --- a/TableProTests/ViewModels/ConnectionFormTransportTests.swift +++ b/TableProTests/ViewModels/ConnectionFormTransportTests.swift @@ -144,7 +144,7 @@ struct ConnectionFormTransportTests { @Test("selecting a transport clears a passing test result") func selectionInvalidatesTestResult() { let coordinator = coordinator() - coordinator.testSucceeded = true + coordinator.testResult = ConnectionTestResult(succeeded: true) coordinator.transport = .ssh #expect(!coordinator.testSucceeded) } @@ -321,7 +321,7 @@ struct ConnectionFormTransportTests { @Test("choosing an endpoint clears a passing test result") func endpointInvalidatesTestResult() { let coordinator = coordinator() - coordinator.testSucceeded = true + coordinator.testResult = ConnectionTestResult(succeeded: true) coordinator.selectEndpoint(.localSocket) #expect(!coordinator.testSucceeded) } diff --git a/scripts/probes/check-oracle-login-warning.sh b/scripts/probes/check-oracle-login-warning.sh new file mode 100755 index 0000000000..0b09881bd3 --- /dev/null +++ b/scripts/probes/check-oracle-login-warning.sh @@ -0,0 +1,147 @@ +#!/usr/bin/env bash +# +# Check that a login whose password is in its grace period connects and reports the server's warning. +# +# Oracle answers such a login with ORA-28002 (ORA-28098 from 23ai) sent as a warning message, twice, ahead of the +# authentication reply (measured on 23.26). oracle-nio once read that message with the wrong field widths and waited +# for bytes that never came, so the login hung until its deadline. This creates a user whose password expires a second +# after it is set, waits for it to enter its grace period, logs in through the same OracleCoreConnection the app uses, +# and checks that the login succeeds within its deadline, that the warning names the expiry, that the session answers +# a query, and that an ordinary login reports no warning. +# +# Usage: +# scripts/probes/check-oracle-login-warning.sh [host] [port] [service] [admin user] [admin password] +# +# Defaults suit a throwaway Oracle Free container, whose SYSTEM account can create users and profiles: +# docker run -d --name oracle-probe -p 1521:1521 -e ORACLE_PASSWORD=probe_pw gvenzl/oracle-free:23-slim-faststart +# +# Every object it creates is named TP_GRACE_PROBE and dropped again. Exits non-zero on a disagreement, 3 when a +# prerequisite is missing. + +set -uo pipefail + +HOST="${1:-127.0.0.1}" +PORT="${2:-1521}" +SERVICE="${3:-FREEPDB1}" +ADMIN_USER="${4:-system}" +ADMIN_PASSWORD="${5:-probe_pw}" +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" + +if [ -z "${DEVELOPER_DIR:-}" ]; then + for candidate in /Applications/Xcode-beta.app /Applications/Xcode.app; do + if [ -d "$candidate/Contents/Developer" ]; then + export DEVELOPER_DIR="$candidate/Contents/Developer" + break + fi + done +fi +command -v swift > /dev/null || { + echo "swift not found" >&2 + exit 3 +} + +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT +mkdir -p "$WORK/Sources/Probe" +# Resolves to the dependency versions the app ships, not the newest ones. +cp "$ROOT/Packages/TableProOracle/Package.resolved" "$WORK/Package.resolved" + +cat > "$WORK/Package.swift" < "$WORK/Sources/Probe/main.swift" <<'EOF' +import Foundation +import TableProOracleCore + +let arguments = CommandLine.arguments +let host = arguments[1] +let port = Int(arguments[2]) ?? 1_521 +let service = arguments[3] +let probeUser = "TP_GRACE_PROBE" +let probePassword = "Grace_Probe_1" + +func options(user: String, password: String) -> OracleConnectionOptions { + OracleConnectionOptions( + host: host, port: port, user: user, password: password, + identifierMode: .service, serviceName: service, loginTimeoutSeconds: 15 + ) +} + +let admin = OracleCoreConnection(options: options(user: arguments[4], password: arguments[5])) +do { + try await admin.connect() +} catch { + print("cannot connect as \(arguments[4]): \(error)") + exit(3) +} + +func run(_ sql: String) async { + _ = try? await admin.executeQuery(sql) +} + +await run("DROP USER \(probeUser) CASCADE") +await run("DROP PROFILE \(probeUser)") +do { + _ = try await admin.executeQuery( + "CREATE PROFILE \(probeUser) LIMIT PASSWORD_LIFE_TIME 1/24/60/60 PASSWORD_GRACE_TIME 7" + ) + _ = try await admin.executeQuery("CREATE USER \(probeUser) IDENTIFIED BY \"\(probePassword)\" PROFILE \(probeUser)") + _ = try await admin.executeQuery("GRANT CREATE SESSION TO \(probeUser)") +} catch { + print("cannot create the probe user and profile: \(error)") + exit(3) +} +try? await Task.sleep(for: .seconds(3)) + +var disagreements = 0 + +@MainActor +func check(_ holds: Bool, _ claim: String) { + print("\(holds ? "ok " : "FAIL") \(claim)") + if !holds { disagreements += 1 } +} + +let graced = OracleCoreConnection(options: options(user: probeUser, password: probePassword)) +let started = ContinuousClock.now +do { + try await graced.connect() + let elapsed = ContinuousClock.now - started + check(elapsed < .seconds(10), "a login in its grace period connects (\(elapsed.formatted(.units(allowed: [.seconds, .milliseconds]))))") + let warning = graced.loginWarning ?? "" + check(warning.hasPrefix("ORA-28002") || warning.hasPrefix("ORA-28098"), "the login reports \(String(reflecting: graced.loginWarning))") + let answer = try? await graced.executeQuery("SELECT 'alive' FROM DUAL") + check(answer?.rows.first?.first?.stringValue == "alive", "the session answers a query") + graced.disconnect() +} catch { + check(false, "a login in its grace period connects: \(error)") +} + +check(admin.loginWarning == nil, "an ordinary login reports no warning") + +await run("DROP USER \(probeUser) CASCADE") +await run("DROP PROFILE \(probeUser)") +admin.disconnect() +print(disagreements == 0 ? "\nall checks hold" : "\n\(disagreements) disagreement(s)") +exit(disagreements == 0 ? 0 : 1) +EOF + +echo "Building the probe against Packages/TableProOracle (the first build takes a minute)" +(cd "$WORK" && swift build -c debug > "$WORK/build.log" 2>&1) || { + grep -E "error:" "$WORK/build.log" | head -20 >&2 + exit 3 +} +"$WORK/.build/debug/Probe" "$HOST" "$PORT" "$SERVICE" "$ADMIN_USER" "$ADMIN_PASSWORD" 2> /dev/null