From 7166cb09a03240c7df5486d65edc4df3372f240e Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 3 May 2026 14:54:38 +0700 Subject: [PATCH 1/2] feat(oracle): support 10G password verifier + 23ai TTC capability fixes --- CHANGELOG.md | 7 + .../OracleDriverPlugin/OracleConnection.swift | 36 ++++- TablePro.xcodeproj/project.pbxproj | 2 +- .../xcshareddata/swiftpm/Package.resolved | 2 +- .../ConnectionFormView+Helpers.swift | 45 ++++++ .../Views/Connection/ConnectionFormView.swift | 7 + .../Connection/OracleDiagnosticSheet.swift | 143 ++++++++++++++++++ docs/databases/oracle.mdx | 22 +++ 8 files changed, 261 insertions(+), 3 deletions(-) create mode 100644 TablePro/Views/Connection/OracleDiagnosticSheet.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c5dc3d4c..cc85f3793 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Oracle 10G password verifier authentication. Accounts whose `password_versions` includes a 10G hash now connect successfully, matching DBeaver/JDBC/sqlplus behavior. The 10G hash is documented as legacy; rotating to a modern verifier is still recommended (#483) +- Oracle Test Connection now opens a focused diagnostic sheet for auth failures with copy-able diagnostic info, suggested actions, and a link to file an issue +- Oracle connection negotiation now matches python-oracledb's 23ai compile-capability advertisement, including TTC4 explicit boundary, TTC5 token/pipelining/sessionless flags, OCI3 sync, dequeue selectors, and sparse vector features + ### Changed - Internal: introduce `TabSession` as the foundation type for the editor tab/window subsystem rewrite. Currently a parallel structure mirroring `QueryTab`; subsequent PRs migrate state ownership and lifecycle hooks per `docs/architecture/tab-subsystem-rewrite.md`. No user-visible behavior change in this PR. @@ -19,6 +25,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Tab switching: rapid Cmd+Number presses no longer leave a tail of tab transitions playing after the user releases the keys. The tab-selection setter (`NSWindowTabGroup.selectedWindow`) is now wrapped in `NSAnimationContext.runAnimationGroup` with `duration = 0`, so AppKit applies each switch synchronously without queuing a CAAnimation. Lazy-load also moved out of `windowDidBecomeKey` into `.task(id:)` view-appearance lifecycle per Apple's documentation. Note: extreme Cmd+Number bursts (e.g. holding the key for key-repeat) still incur per-switch AppKit window-focus overhead; this is platform-inherent to native NSWindow tabs and documented in `docs/architecture/tab-subsystem-rewrite.md` D2 - Oracle TIMESTAMP, TIMESTAMP WITH TIME ZONE, TIMESTAMP WITH LOCAL TIME ZONE, INTERVAL DAY TO SECOND, INTERVAL YEAR TO MONTH, DATE, RAW, and BLOB columns now render through typed decoders instead of garbled text. Tables containing INTERVAL YEAR TO MONTH or BFILE columns no longer crash the app on row fetch. Unknown column types display `` instead of crashing (#965) +- Oracle connections to 23ai cloud and containerized deployments no longer fail with `uncleanShutdown` mid-handshake. OOB urgent-byte send now requires the server to advertise `TNS_ACCEPT_FLAG_CHECK_OOB`, matching python-oracledb behavior (#483) ## [0.37.0] - 2026-05-01 diff --git a/Plugins/OracleDriverPlugin/OracleConnection.swift b/Plugins/OracleDriverPlugin/OracleConnection.swift index 4471c7396..f766ad9a2 100644 --- a/Plugins/OracleDriverPlugin/OracleConnection.swift +++ b/Plugins/OracleDriverPlugin/OracleConnection.swift @@ -147,7 +147,7 @@ final class OracleConnectionWrapper: @unchecked Sendable { } catch let sqlError as OracleSQLError { let detail = sqlError.serverInfo?.message ?? sqlError.description osLogger.error("Oracle connection failed: \(detail)") - throw OracleError(message: "Failed to connect to \(host):\(port)/\(service): \(detail)") + throw OracleError(message: friendlyConnectError(for: sqlError, fallback: detail)) } catch { let detail = String(describing: error) osLogger.error("Oracle connection failed: \(detail)") @@ -155,6 +155,40 @@ final class OracleConnectionWrapper: @unchecked Sendable { } } + private func friendlyConnectError(for error: OracleSQLError, fallback: String) -> String { + let target = "\(host):\(port)/\(serviceName.isEmpty ? database : serviceName)" + let codeDescription = error.code.description + if codeDescription.hasPrefix("unsupportedVerifierType") { + let template = String(localized: """ + Failed to connect to %1$@. The database advertised a password verifier that \ + TablePro does not recognize (%2$@). File an issue at \ + github.com/TableProApp/TablePro/issues with the verifier flag. + """) + return String(format: template, target, codeDescription) + } + if codeDescription == "uncleanShutdown" { + let template = String(localized: """ + Failed to connect to %@. The connection was dropped during the handshake. \ + This is often an OOB compatibility issue with cloud-hosted or containerized \ + Oracle. If the same connection works in DBeaver, please report at \ + github.com/TableProApp/TablePro/issues/483. + """) + return String(format: template, target) + } + if codeDescription == "serverVersionNotSupported" { + let template = String(localized: """ + Failed to connect to %@. The database returned a server version or auth \ + scheme TablePro cannot negotiate. Check that the user account has an 11G or \ + 12C password hash (SELECT password_versions FROM dba_users WHERE \ + username = ''). Ask your DBA to rotate the password under modern auth \ + if the result includes only 10G. + """) + return String(format: template, target) + } + let template = String(localized: "Failed to connect to %1$@: %2$@") + return String(format: template, target, fallback) + } + func disconnect() { lock.lock() guard _isConnected else { diff --git a/TablePro.xcodeproj/project.pbxproj b/TablePro.xcodeproj/project.pbxproj index 76da6311d..f94f9b601 100644 --- a/TablePro.xcodeproj/project.pbxproj +++ b/TablePro.xcodeproj/project.pbxproj @@ -4042,7 +4042,7 @@ repositoryURL = "https://github.com/TableProApp/oracle-nio"; requirement = { kind = revision; - revision = f343a0db14aba73e50a6f93bd981d3b07a61c6d4; + revision = 6bbaee5c5d177743413ed4b376f7dada2d855cd2; }; }; /* End XCRemoteSwiftPackageReference section */ diff --git a/TablePro.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/TablePro.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 4407295fb..e058efafa 100644 --- a/TablePro.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/TablePro.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -42,7 +42,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/TableProApp/oracle-nio", "state" : { - "revision" : "f343a0db14aba73e50a6f93bd981d3b07a61c6d4" + "revision" : "6bbaee5c5d177743413ed4b376f7dada2d855cd2" } }, { diff --git a/TablePro/Views/Connection/ConnectionFormView+Helpers.swift b/TablePro/Views/Connection/ConnectionFormView+Helpers.swift index 40a3054e0..1dfdb0b7c 100644 --- a/TablePro/Views/Connection/ConnectionFormView+Helpers.swift +++ b/TablePro/Views/Connection/ConnectionFormView+Helpers.swift @@ -91,10 +91,17 @@ extension ConnectionFormView { } func loadConnectionData() { + let connectionFormLog = Logger(subsystem: "com.TablePro", category: "ConnectionForm") + connectionFormLog.debug( + "[trace] loadConnectionData connectionId=\(self.connectionId?.uuidString ?? "nil", privacy: .public) isNew=\(self.connectionId == nil)" + ) sshState.profiles = SSHProfileStorage.shared.loadProfiles() if let id = connectionId, let existing = storage.loadConnections().first(where: { $0.id == id }) { + connectionFormLog.debug( + "[trace] loadConnectionData found existing id=\(existing.id.uuidString, privacy: .public) name='\(existing.name, privacy: .public)' promptForPassword=\(existing.promptForPassword)" + ) originalConnection = existing name = existing.name host = existing.host @@ -162,6 +169,13 @@ extension ConnectionFormView { // Load connection password from Keychain if let savedPassword = storage.loadPassword(for: existing.id) { password = savedPassword + connectionFormLog.debug( + "[trace] loadConnectionData password populated length=\(savedPassword.count)" + ) + } else { + connectionFormLog.debug( + "[trace] loadConnectionData password NOT populated (loadPassword returned nil)" + ) } } Task { @MainActor in @@ -486,6 +500,10 @@ extension ConnectionFormView { testSucceeded = false if case PluginError.pluginNotInstalled = error { pluginInstallConnection = testConn + } else if let payload = oracleDiagnosticPayload( + for: error, connection: testConn, username: finalUsername + ) { + oracleDiagnostic = payload } else { AlertHelper.showErrorSheet( title: String(localized: "Connection Test Failed"), @@ -498,6 +516,33 @@ extension ConnectionFormView { } } + private func oracleDiagnosticPayload( + for error: Error, + connection: DatabaseConnection, + username: String + ) -> OracleDiagnosticPayload? { + guard connection.type.pluginTypeId == "Oracle" else { return nil } + let message = error.localizedDescription + let category: OracleDiagnosticPayload.Category + if message.contains("password verifier") { + category = .unsupportedVerifier + } else if message.contains("dropped during the handshake") { + category = .uncleanShutdown + } else if message.contains("server version or auth scheme") { + category = .serverVersionNotSupported + } else { + return nil + } + return OracleDiagnosticPayload( + host: connection.host, + port: connection.port, + serviceOrDatabase: connection.database, + username: username, + errorMessage: message, + category: category + ) + } + func browseForFile() { guard let window = NSApp.keyWindow else { return } let panel = NSOpenPanel() diff --git a/TablePro/Views/Connection/ConnectionFormView.swift b/TablePro/Views/Connection/ConnectionFormView.swift index 75af777c4..58d5ebe50 100644 --- a/TablePro/Views/Connection/ConnectionFormView.swift +++ b/TablePro/Views/Connection/ConnectionFormView.swift @@ -129,6 +129,8 @@ struct ConnectionFormView: View { @State var isInstallingPlugin: Bool = false @State var pluginInstallError: String? + @State var oracleDiagnostic: OracleDiagnosticPayload? + // Tab selection @State var selectedTab: FormTab = .general @@ -194,6 +196,11 @@ struct ConnectionFormView: View { .pluginInstallPrompt(connection: $pluginInstallConnection) { connection in connectAfterInstall(connection) } + .sheet(item: $oracleDiagnostic) { payload in + OracleDiagnosticSheet(payload: payload) { + oracleDiagnostic = nil + } + } .onChange(of: pgpassTrigger) { _, _ in updatePgpassStatus() } .onChange(of: usePgpass) { _, newValue in if newValue { promptForPassword = false } } } diff --git a/TablePro/Views/Connection/OracleDiagnosticSheet.swift b/TablePro/Views/Connection/OracleDiagnosticSheet.swift new file mode 100644 index 000000000..ed992c18e --- /dev/null +++ b/TablePro/Views/Connection/OracleDiagnosticSheet.swift @@ -0,0 +1,143 @@ +// +// OracleDiagnosticSheet.swift +// TablePro +// + +import AppKit +import SwiftUI + +struct OracleDiagnosticPayload: Identifiable, Equatable { + let id = UUID() + let host: String + let port: Int + let serviceOrDatabase: String + let username: String + let errorMessage: String + let category: Category + + enum Category: Equatable { + case unsupportedVerifier + case uncleanShutdown + case serverVersionNotSupported + case generic + + var title: String { + switch self { + case .unsupportedVerifier: + return String(localized: "Unsupported Password Verifier") + case .uncleanShutdown: + return String(localized: "Connection Dropped During Handshake") + case .serverVersionNotSupported: + return String(localized: "Server Version Not Supported") + case .generic: + return String(localized: "Connection Test Failed") + } + } + + var actions: [String] { + switch self { + case .unsupportedVerifier: + return [ + String(localized: "Verify the user account exists and the password is correct."), + String(localized: "Ask your DBA to confirm the user has an 11G or 12C password verifier (SELECT password_versions FROM dba_users WHERE username = '')."), + String(localized: "If the verifier is brand-new (e.g. 23ai), file an issue at github.com/TableProApp/TablePro/issues with the verifier flag shown below."), + ] + case .uncleanShutdown: + return [ + String(localized: "If the same connection works in DBeaver or sqlplus, this is likely an OOB compatibility issue with cloud-hosted Oracle."), + String(localized: "TablePro v1.2.0 already gates OOB on the server flag, so most cases are resolved. If you still hit this, file an issue at github.com/TableProApp/TablePro/issues/483."), + String(localized: "Try disabling SSH tunnel or load balancer firewall rules between client and server."), + ] + case .serverVersionNotSupported: + return [ + String(localized: "TablePro requires Oracle 12c or later via the OracleNIO Swift driver."), + String(localized: "Check the user account's password_versions; only 11G and 12C are supported."), + String(localized: "Rotate the password under modern auth if password_versions includes only 10G."), + ] + case .generic: + return [ + String(localized: "Verify host, port, service name, and credentials match a working client."), + String(localized: "Check the listener is reachable: lsnrctl status."), + String(localized: "If you are confident the connection works in DBeaver / sqlplus, file an issue."), + ] + } + } + } +} + +struct OracleDiagnosticSheet: View { + let payload: OracleDiagnosticPayload + let onDismiss: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Label(payload.category.title, systemImage: "exclamationmark.triangle.fill") + .font(.headline) + .foregroundStyle(.primary) + + Text(payload.errorMessage) + .font(.system(.body, design: .default)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + + Divider() + + VStack(alignment: .leading, spacing: 8) { + Text(String(localized: "Suggested Actions")) + .font(.subheadline.weight(.semibold)) + ForEach(Array(payload.category.actions.enumerated()), id: \.offset) { index, action in + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text("\(index + 1).") + .foregroundStyle(.tertiary) + .monospacedDigit() + Text(action) + .fixedSize(horizontal: false, vertical: true) + } + .font(.callout) + } + } + + Divider() + + VStack(alignment: .leading, spacing: 6) { + Text(String(localized: "Diagnostic Info")) + .font(.subheadline.weight(.semibold)) + Text(diagnosticBlock) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + .padding(8) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(nsColor: .textBackgroundColor)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .textSelection(.enabled) + } + + HStack { + Button(String(localized: "Copy Diagnostic Info")) { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(diagnosticBlock, forType: .string) + } + Button(String(localized: "Open Issue Tracker")) { + if let url = URL(string: "https://github.com/TableProApp/TablePro/issues") { + NSWorkspace.shared.open(url) + } + } + Spacer() + Button(String(localized: "Close"), action: onDismiss) + .keyboardShortcut(.defaultAction) + } + } + .padding(20) + .frame(width: 540) + } + + private var diagnosticBlock: String { + """ + Host: \(payload.host) + Port: \(payload.port) + Service: \(payload.serviceOrDatabase) + User: \(payload.username) + Error: \(payload.errorMessage) + """ + } +} diff --git a/docs/databases/oracle.mdx b/docs/databases/oracle.mdx index 91fedc3a1..7b0bf91d0 100644 --- a/docs/databases/oracle.mdx +++ b/docs/databases/oracle.mdx @@ -94,6 +94,28 @@ ORDER BY table_name; **Authentication**: Username/password only (no OS auth or wallet). Create user: `CREATE USER app_user IDENTIFIED BY "Password1!"; GRANT CREATE SESSION, SELECT ANY TABLE TO app_user;` +## Auth Compatibility + +TablePro authenticates via `OracleNIO`, a pure-Swift implementation of the Oracle TNS wire protocol (no Oracle Instant Client needed). + +| `dba_users.password_versions` | Status | +|---|---| +| `12C` only (recommended) | ✓ Supported | +| `11G 12C` | ✓ Supported | +| `10G 11G 12C` | ✓ Supported | +| `10G 11G` (legacy migration) | ✓ Supported | +| `10G` only (deprecated) | ✓ Supported, but consider rotation | +| External / Kerberos / LDAP-managed | ✗ Not supported. File an issue. | + +10G hash is supported for compatibility with legacy environments. It uses DES-based hashing without modern salting and is deprecated by Oracle. We recommend rotating affected accounts under modern auth so `password_versions` contains only `11G` or `12C`: + +```sql +ALTER USER IDENTIFIED BY ; +SELECT username, password_versions FROM dba_users WHERE username = ''; +``` + +If the connection fails with the dialog "Server Version Not Supported" or "Unsupported Password Verifier", check `password_versions` first. + ## Column Type Support | Oracle type | Display | From 70dcdf5f091635835fb19f27eb9c7649b187bff9 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 3 May 2026 15:11:15 +0700 Subject: [PATCH 2/2] refactor(oracle): typed error categories + generic PluginDiagnostic provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewer feedback (conventions + simplicity passes): - Plugin↔host error classification was string-prefix matching on `OracleSQLError.Code.description` (plugin) and `error.localizedDescription.contains(...)` (host). Both are brittle: `description` is not a stable contract, and the host matcher breaks under non-English locales. Replaced with typed plumbing. - New TableProPluginKit primitives: `PluginDiagnostic` value type (title, message, suggestedActions, diagnosticInfo, supportURL) and `PluginDiagnosticProvider` protocol. Plugins opt-in by conforming their main type; no DriverPlugin ABI bump needed. - `OracleError` is now a struct with a `Category` enum (.notConnected, .authVerifierUnsupported(flag:), .authConnectionDropped, .authVersionNotSupported, etc.). `OracleConnection.connect` classifies upstream OracleSQLError into a category and throws a rich OracleError that carries it. The classification stays at the throw site where the most context is available. - `OraclePlugin` conforms to PluginDiagnosticProvider. Its `diagnose(error:)` casts to `OracleError`, switches on `category`, and emits a localized PluginDiagnostic with suggested actions. The user-facing copy moves from the connection-form helpers into the plugin where it belongs. - `PluginManager.diagnose(error:for:)` looks up the driver by type id, casts to PluginDiagnosticProvider, and forwards. ~5 lines. - Generic `PluginDiagnosticSheet` replaces the Oracle-specific one. Renders any PluginDiagnostic; classifier `PluginDiagnosticItem.classify(error:connection:username:)` routes through PluginManager and is testable. - Drops `oracleDiagnosticPayload` helper from ConnectionFormView+Helpers and the Oracle-specific `OracleDiagnosticSheet`. Diagnostic UI is now driver-agnostic. - Bumps SPM pin to fork commit 7c01c8f (free-function 10G hash + VerifierKind.init(verifierFlag:) refactor pushed to TableProApp/oracle-nio). - Pre-existing OraclePlugin.swift had a magic-number lint violation surfaced by --strict on the touched file: `defaultPort = 1521` -> `1_521`. --- .../OracleDriverPlugin/OracleConnection.swift | 72 +++++---- Plugins/OracleDriverPlugin/OraclePlugin.swift | 49 +++++- .../TableProPluginKit/PluginDiagnostic.swift | 42 +++++ TablePro.xcodeproj/project.pbxproj | 2 +- .../xcshareddata/swiftpm/Package.resolved | 2 +- TablePro/Core/Plugins/PluginManager.swift | 6 + .../ConnectionFormView+Helpers.swift | 33 +--- .../Views/Connection/ConnectionFormView.swift | 8 +- .../Connection/OracleDiagnosticSheet.swift | 143 ------------------ .../Connection/PluginDiagnosticSheet.swift | 122 +++++++++++++++ 10 files changed, 265 insertions(+), 214 deletions(-) create mode 100644 Plugins/TableProPluginKit/PluginDiagnostic.swift delete mode 100644 TablePro/Views/Connection/OracleDiagnosticSheet.swift create mode 100644 TablePro/Views/Connection/PluginDiagnosticSheet.swift diff --git a/Plugins/OracleDriverPlugin/OracleConnection.swift b/Plugins/OracleDriverPlugin/OracleConnection.swift index f766ad9a2..04b7754cd 100644 --- a/Plugins/OracleDriverPlugin/OracleConnection.swift +++ b/Plugins/OracleDriverPlugin/OracleConnection.swift @@ -18,11 +18,36 @@ private let osLogger = Logger(subsystem: "com.TablePro", category: "OracleConnec // MARK: - Error Types struct OracleError: Error { + enum Category: Sendable, Equatable { + case generic + case notConnected + case connectionFailed + case queryFailed + case authVerifierUnsupported(flag: String) + case authVersionNotSupported + case authConnectionDropped + } + let message: String + let category: Category - static let notConnected = OracleError(message: String(localized: "Not connected to database")) - static let connectionFailed = OracleError(message: String(localized: "Failed to establish connection")) - static let queryFailed = OracleError(message: String(localized: "Query execution failed")) + init(message: String, category: Category = .generic) { + self.message = message + self.category = category + } + + static let notConnected = OracleError( + message: String(localized: "Not connected to database"), + category: .notConnected + ) + static let connectionFailed = OracleError( + message: String(localized: "Failed to establish connection"), + category: .connectionFailed + ) + static let queryFailed = OracleError( + message: String(localized: "Query execution failed"), + category: .queryFailed + ) } extension OracleError: PluginDriverError { @@ -147,46 +172,27 @@ final class OracleConnectionWrapper: @unchecked Sendable { } catch let sqlError as OracleSQLError { let detail = sqlError.serverInfo?.message ?? sqlError.description osLogger.error("Oracle connection failed: \(detail)") - throw OracleError(message: friendlyConnectError(for: sqlError, fallback: detail)) + throw OracleError(message: detail, category: classifyConnectError(sqlError)) } catch { let detail = String(describing: error) osLogger.error("Oracle connection failed: \(detail)") - throw OracleError(message: "Failed to connect to \(host):\(port)/\(service): \(detail)") + throw OracleError(message: detail, category: .connectionFailed) } } - private func friendlyConnectError(for error: OracleSQLError, fallback: String) -> String { - let target = "\(host):\(port)/\(serviceName.isEmpty ? database : serviceName)" + private func classifyConnectError(_ error: OracleSQLError) -> OracleError.Category { let codeDescription = error.code.description if codeDescription.hasPrefix("unsupportedVerifierType") { - let template = String(localized: """ - Failed to connect to %1$@. The database advertised a password verifier that \ - TablePro does not recognize (%2$@). File an issue at \ - github.com/TableProApp/TablePro/issues with the verifier flag. - """) - return String(format: template, target, codeDescription) - } - if codeDescription == "uncleanShutdown" { - let template = String(localized: """ - Failed to connect to %@. The connection was dropped during the handshake. \ - This is often an OOB compatibility issue with cloud-hosted or containerized \ - Oracle. If the same connection works in DBeaver, please report at \ - github.com/TableProApp/TablePro/issues/483. - """) - return String(format: template, target) + return .authVerifierUnsupported(flag: codeDescription) } - if codeDescription == "serverVersionNotSupported" { - let template = String(localized: """ - Failed to connect to %@. The database returned a server version or auth \ - scheme TablePro cannot negotiate. Check that the user account has an 11G or \ - 12C password hash (SELECT password_versions FROM dba_users WHERE \ - username = ''). Ask your DBA to rotate the password under modern auth \ - if the result includes only 10G. - """) - return String(format: template, target) + switch codeDescription { + case "uncleanShutdown": + return .authConnectionDropped + case "serverVersionNotSupported": + return .authVersionNotSupported + default: + return .connectionFailed } - let template = String(localized: "Failed to connect to %1$@: %2$@") - return String(format: template, target, fallback) } func disconnect() { diff --git a/Plugins/OracleDriverPlugin/OraclePlugin.swift b/Plugins/OracleDriverPlugin/OraclePlugin.swift index ea08b096f..d159e25fc 100644 --- a/Plugins/OracleDriverPlugin/OraclePlugin.swift +++ b/Plugins/OracleDriverPlugin/OraclePlugin.swift @@ -7,7 +7,7 @@ import Foundation import os import TableProPluginKit -final class OraclePlugin: NSObject, TableProPlugin, DriverPlugin { +final class OraclePlugin: NSObject, TableProPlugin, DriverPlugin, PluginDiagnosticProvider { static let pluginName = "Oracle Driver" static let pluginVersion = "1.0.0" static let pluginDescription = "Oracle Database support via OracleNIO" @@ -16,7 +16,7 @@ final class OraclePlugin: NSObject, TableProPlugin, DriverPlugin { static let databaseTypeId = "Oracle" static let databaseDisplayName = "Oracle" static let iconName = "oracle-icon" - static let defaultPort = 1521 + static let defaultPort = 1_521 static let additionalConnectionFields: [ConnectionField] = [ ConnectionField(id: "oracleServiceName", label: "Service Name", placeholder: "ORCL") ] @@ -1104,6 +1104,51 @@ final class OraclePluginDriver: PluginDatabaseDriver, @unchecked Sendable { return raw.replacingOccurrences(of: "'", with: "''") } + func diagnose(error: Error) -> PluginDiagnostic? { + guard let oracleError = error as? OracleError else { return nil } + let issuesURL = URL(string: "https://github.com/TableProApp/TablePro/issues") + switch oracleError.category { + case .authVerifierUnsupported(let flag): + return PluginDiagnostic( + title: String(localized: "Unsupported Password Verifier"), + message: oracleError.message, + suggestedActions: [ + String(localized: "Verify the user account exists and the password is correct."), + String(localized: "Ask your DBA to confirm the user has an 11G or 12C password verifier (SELECT password_versions FROM dba_users WHERE username = '')."), + String(localized: "If the verifier is brand-new (e.g. 23ai), file an issue with the verifier flag below.") + ], + diagnosticInfo: [ + DiagnosticEntry(label: "Verifier flag", value: flag) + ], + supportURL: issuesURL + ) + case .authConnectionDropped: + return PluginDiagnostic( + title: String(localized: "Connection Dropped During Handshake"), + message: oracleError.message, + suggestedActions: [ + String(localized: "If the same connection works in DBeaver or sqlplus, this is likely an OOB compatibility issue with cloud-hosted Oracle."), + String(localized: "TablePro 1.2.0 already gates OOB on the server flag, so most cases are resolved. If you still hit this, file an issue."), + String(localized: "Try disabling SSH tunnel or load balancer firewall rules between client and server.") + ], + supportURL: URL(string: "https://github.com/TableProApp/TablePro/issues/483") + ) + case .authVersionNotSupported: + return PluginDiagnostic( + title: String(localized: "Server Version Not Supported"), + message: oracleError.message, + suggestedActions: [ + String(localized: "TablePro requires Oracle 12c or later via the OracleNIO Swift driver."), + String(localized: "Check the user account's password_versions; only 10G, 11G, and 12C are supported."), + String(localized: "Rotate the password under modern auth if password_versions contains an unrecognized verifier.") + ], + supportURL: issuesURL + ) + case .generic, .notConnected, .connectionFailed, .queryFailed: + return nil + } + } + private static let fromTableRegex = try? NSRegularExpression( pattern: #"FROM\s+(?:"([^"]+)"|(\w+))"#, options: .caseInsensitive diff --git a/Plugins/TableProPluginKit/PluginDiagnostic.swift b/Plugins/TableProPluginKit/PluginDiagnostic.swift new file mode 100644 index 000000000..d5b73b6b3 --- /dev/null +++ b/Plugins/TableProPluginKit/PluginDiagnostic.swift @@ -0,0 +1,42 @@ +// +// PluginDiagnostic.swift +// TableProPluginKit +// + +import Foundation + +public struct PluginDiagnostic: Sendable, Equatable { + public let title: String + public let message: String + public let suggestedActions: [String] + public let diagnosticInfo: [DiagnosticEntry] + public let supportURL: URL? + + public init( + title: String, + message: String, + suggestedActions: [String] = [], + diagnosticInfo: [DiagnosticEntry] = [], + supportURL: URL? = nil + ) { + self.title = title + self.message = message + self.suggestedActions = suggestedActions + self.diagnosticInfo = diagnosticInfo + self.supportURL = supportURL + } +} + +public struct DiagnosticEntry: Sendable, Equatable { + public let label: String + public let value: String + + public init(label: String, value: String) { + self.label = label + self.value = value + } +} + +public protocol PluginDiagnosticProvider: AnyObject, Sendable { + func diagnose(error: Error) -> PluginDiagnostic? +} diff --git a/TablePro.xcodeproj/project.pbxproj b/TablePro.xcodeproj/project.pbxproj index f94f9b601..af10b330e 100644 --- a/TablePro.xcodeproj/project.pbxproj +++ b/TablePro.xcodeproj/project.pbxproj @@ -4042,7 +4042,7 @@ repositoryURL = "https://github.com/TableProApp/oracle-nio"; requirement = { kind = revision; - revision = 6bbaee5c5d177743413ed4b376f7dada2d855cd2; + revision = 7c01c8ff2e13794650719ebfa0294aa4281bbdd8; }; }; /* End XCRemoteSwiftPackageReference section */ diff --git a/TablePro.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/TablePro.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index e058efafa..d001c8ce9 100644 --- a/TablePro.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/TablePro.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -42,7 +42,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/TableProApp/oracle-nio", "state" : { - "revision" : "6bbaee5c5d177743413ed4b376f7dada2d855cd2" + "revision" : "7c01c8ff2e13794650719ebfa0294aa4281bbdd8" } }, { diff --git a/TablePro/Core/Plugins/PluginManager.swift b/TablePro/Core/Plugins/PluginManager.swift index b9796bf4b..d15d329d1 100644 --- a/TablePro/Core/Plugins/PluginManager.swift +++ b/TablePro/Core/Plugins/PluginManager.swift @@ -473,6 +473,12 @@ final class PluginManager { return entry } + func diagnose(error: Error, for type: DatabaseType) -> PluginDiagnostic? { + guard let driver = driverPlugins[type.pluginTypeId] else { return nil } + guard let provider = driver as? PluginDiagnosticProvider else { return nil } + return provider.diagnose(error: error) + } + func replaceExistingPlugin(bundleId: String) { guard let existingIndex = plugins.firstIndex(where: { $0.id == bundleId }) else { return } unregisterCapabilities(pluginId: bundleId) diff --git a/TablePro/Views/Connection/ConnectionFormView+Helpers.swift b/TablePro/Views/Connection/ConnectionFormView+Helpers.swift index 1dfdb0b7c..1cae2f5b6 100644 --- a/TablePro/Views/Connection/ConnectionFormView+Helpers.swift +++ b/TablePro/Views/Connection/ConnectionFormView+Helpers.swift @@ -500,10 +500,10 @@ extension ConnectionFormView { testSucceeded = false if case PluginError.pluginNotInstalled = error { pluginInstallConnection = testConn - } else if let payload = oracleDiagnosticPayload( - for: error, connection: testConn, username: finalUsername + } else if let item = PluginDiagnosticItem.classify( + error: error, connection: testConn, username: finalUsername ) { - oracleDiagnostic = payload + pluginDiagnostic = item } else { AlertHelper.showErrorSheet( title: String(localized: "Connection Test Failed"), @@ -516,33 +516,6 @@ extension ConnectionFormView { } } - private func oracleDiagnosticPayload( - for error: Error, - connection: DatabaseConnection, - username: String - ) -> OracleDiagnosticPayload? { - guard connection.type.pluginTypeId == "Oracle" else { return nil } - let message = error.localizedDescription - let category: OracleDiagnosticPayload.Category - if message.contains("password verifier") { - category = .unsupportedVerifier - } else if message.contains("dropped during the handshake") { - category = .uncleanShutdown - } else if message.contains("server version or auth scheme") { - category = .serverVersionNotSupported - } else { - return nil - } - return OracleDiagnosticPayload( - host: connection.host, - port: connection.port, - serviceOrDatabase: connection.database, - username: username, - errorMessage: message, - category: category - ) - } - func browseForFile() { guard let window = NSApp.keyWindow else { return } let panel = NSOpenPanel() diff --git a/TablePro/Views/Connection/ConnectionFormView.swift b/TablePro/Views/Connection/ConnectionFormView.swift index 58d5ebe50..e6ccc2b85 100644 --- a/TablePro/Views/Connection/ConnectionFormView.swift +++ b/TablePro/Views/Connection/ConnectionFormView.swift @@ -129,7 +129,7 @@ struct ConnectionFormView: View { @State var isInstallingPlugin: Bool = false @State var pluginInstallError: String? - @State var oracleDiagnostic: OracleDiagnosticPayload? + @State var pluginDiagnostic: PluginDiagnosticItem? // Tab selection @State var selectedTab: FormTab = .general @@ -196,9 +196,9 @@ struct ConnectionFormView: View { .pluginInstallPrompt(connection: $pluginInstallConnection) { connection in connectAfterInstall(connection) } - .sheet(item: $oracleDiagnostic) { payload in - OracleDiagnosticSheet(payload: payload) { - oracleDiagnostic = nil + .sheet(item: $pluginDiagnostic) { item in + PluginDiagnosticSheet(item: item) { + pluginDiagnostic = nil } } .onChange(of: pgpassTrigger) { _, _ in updatePgpassStatus() } diff --git a/TablePro/Views/Connection/OracleDiagnosticSheet.swift b/TablePro/Views/Connection/OracleDiagnosticSheet.swift deleted file mode 100644 index ed992c18e..000000000 --- a/TablePro/Views/Connection/OracleDiagnosticSheet.swift +++ /dev/null @@ -1,143 +0,0 @@ -// -// OracleDiagnosticSheet.swift -// TablePro -// - -import AppKit -import SwiftUI - -struct OracleDiagnosticPayload: Identifiable, Equatable { - let id = UUID() - let host: String - let port: Int - let serviceOrDatabase: String - let username: String - let errorMessage: String - let category: Category - - enum Category: Equatable { - case unsupportedVerifier - case uncleanShutdown - case serverVersionNotSupported - case generic - - var title: String { - switch self { - case .unsupportedVerifier: - return String(localized: "Unsupported Password Verifier") - case .uncleanShutdown: - return String(localized: "Connection Dropped During Handshake") - case .serverVersionNotSupported: - return String(localized: "Server Version Not Supported") - case .generic: - return String(localized: "Connection Test Failed") - } - } - - var actions: [String] { - switch self { - case .unsupportedVerifier: - return [ - String(localized: "Verify the user account exists and the password is correct."), - String(localized: "Ask your DBA to confirm the user has an 11G or 12C password verifier (SELECT password_versions FROM dba_users WHERE username = '')."), - String(localized: "If the verifier is brand-new (e.g. 23ai), file an issue at github.com/TableProApp/TablePro/issues with the verifier flag shown below."), - ] - case .uncleanShutdown: - return [ - String(localized: "If the same connection works in DBeaver or sqlplus, this is likely an OOB compatibility issue with cloud-hosted Oracle."), - String(localized: "TablePro v1.2.0 already gates OOB on the server flag, so most cases are resolved. If you still hit this, file an issue at github.com/TableProApp/TablePro/issues/483."), - String(localized: "Try disabling SSH tunnel or load balancer firewall rules between client and server."), - ] - case .serverVersionNotSupported: - return [ - String(localized: "TablePro requires Oracle 12c or later via the OracleNIO Swift driver."), - String(localized: "Check the user account's password_versions; only 11G and 12C are supported."), - String(localized: "Rotate the password under modern auth if password_versions includes only 10G."), - ] - case .generic: - return [ - String(localized: "Verify host, port, service name, and credentials match a working client."), - String(localized: "Check the listener is reachable: lsnrctl status."), - String(localized: "If you are confident the connection works in DBeaver / sqlplus, file an issue."), - ] - } - } - } -} - -struct OracleDiagnosticSheet: View { - let payload: OracleDiagnosticPayload - let onDismiss: () -> Void - - var body: some View { - VStack(alignment: .leading, spacing: 16) { - Label(payload.category.title, systemImage: "exclamationmark.triangle.fill") - .font(.headline) - .foregroundStyle(.primary) - - Text(payload.errorMessage) - .font(.system(.body, design: .default)) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - - Divider() - - VStack(alignment: .leading, spacing: 8) { - Text(String(localized: "Suggested Actions")) - .font(.subheadline.weight(.semibold)) - ForEach(Array(payload.category.actions.enumerated()), id: \.offset) { index, action in - HStack(alignment: .firstTextBaseline, spacing: 8) { - Text("\(index + 1).") - .foregroundStyle(.tertiary) - .monospacedDigit() - Text(action) - .fixedSize(horizontal: false, vertical: true) - } - .font(.callout) - } - } - - Divider() - - VStack(alignment: .leading, spacing: 6) { - Text(String(localized: "Diagnostic Info")) - .font(.subheadline.weight(.semibold)) - Text(diagnosticBlock) - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(.secondary) - .padding(8) - .frame(maxWidth: .infinity, alignment: .leading) - .background(Color(nsColor: .textBackgroundColor)) - .clipShape(RoundedRectangle(cornerRadius: 6)) - .textSelection(.enabled) - } - - HStack { - Button(String(localized: "Copy Diagnostic Info")) { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(diagnosticBlock, forType: .string) - } - Button(String(localized: "Open Issue Tracker")) { - if let url = URL(string: "https://github.com/TableProApp/TablePro/issues") { - NSWorkspace.shared.open(url) - } - } - Spacer() - Button(String(localized: "Close"), action: onDismiss) - .keyboardShortcut(.defaultAction) - } - } - .padding(20) - .frame(width: 540) - } - - private var diagnosticBlock: String { - """ - Host: \(payload.host) - Port: \(payload.port) - Service: \(payload.serviceOrDatabase) - User: \(payload.username) - Error: \(payload.errorMessage) - """ - } -} diff --git a/TablePro/Views/Connection/PluginDiagnosticSheet.swift b/TablePro/Views/Connection/PluginDiagnosticSheet.swift new file mode 100644 index 000000000..13fc2f674 --- /dev/null +++ b/TablePro/Views/Connection/PluginDiagnosticSheet.swift @@ -0,0 +1,122 @@ +// +// PluginDiagnosticSheet.swift +// TablePro +// + +import AppKit +import SwiftUI +import TableProPluginKit + +struct PluginDiagnosticItem: Identifiable, Equatable { + let id = UUID() + let diagnostic: PluginDiagnostic + let connectionTarget: String + let username: String + + static func classify( + error: Error, + connection: DatabaseConnection, + username: String + ) -> PluginDiagnosticItem? { + guard let diagnostic = PluginManager.shared.diagnose(error: error, for: connection.type) else { + return nil + } + return PluginDiagnosticItem( + diagnostic: diagnostic, + connectionTarget: "\(connection.host):\(connection.port)/\(connection.database)", + username: username + ) + } +} + +struct PluginDiagnosticSheet: View { + let item: PluginDiagnosticItem + let onDismiss: () -> Void + + private static let issuesURL = URL(string: "https://github.com/TableProApp/TablePro/issues") + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Label(item.diagnostic.title, systemImage: "exclamationmark.triangle.fill") + .font(.headline) + .foregroundStyle(.primary) + + Text(item.diagnostic.message) + .font(.body) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + + if !item.diagnostic.suggestedActions.isEmpty { + Divider() + actionList + } + + Divider() + diagnosticBlock + + HStack { + Button(String(localized: "Copy Diagnostic Info")) { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(diagnosticText, forType: .string) + } + if item.diagnostic.supportURL != nil || Self.issuesURL != nil { + Button(String(localized: "Open Issue Tracker")) { + let url = item.diagnostic.supportURL ?? Self.issuesURL + if let url { + NSWorkspace.shared.open(url) + } + } + } + Spacer() + Button(String(localized: "Close"), action: onDismiss) + .keyboardShortcut(.defaultAction) + } + } + .padding(20) + .frame(width: 540) + } + + private var actionList: some View { + VStack(alignment: .leading, spacing: 8) { + Text(String(localized: "Suggested Actions")) + .font(.subheadline.weight(.semibold)) + ForEach(Array(item.diagnostic.suggestedActions.enumerated()), id: \.offset) { index, action in + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text("\(index + 1).") + .foregroundStyle(.tertiary) + .monospacedDigit() + Text(action) + .fixedSize(horizontal: false, vertical: true) + } + .font(.callout) + } + } + } + + private var diagnosticBlock: some View { + VStack(alignment: .leading, spacing: 6) { + Text(String(localized: "Diagnostic Info")) + .font(.subheadline.weight(.semibold)) + Text(diagnosticText) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + .padding(8) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(nsColor: .textBackgroundColor)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .textSelection(.enabled) + } + } + + private var diagnosticText: String { + var lines = [ + "Target: \(item.connectionTarget)", + "User: \(item.username)", + "Error: \(item.diagnostic.message)" + ] + for entry in item.diagnostic.diagnosticInfo { + lines.append("\(entry.label): \(entry.value)") + } + return lines.joined(separator: "\n") + } +}