From c48a9018946e17bec71561d4e84067d501079996 Mon Sep 17 00:00:00 2001 From: blankll Date: Sat, 20 Jun 2026 01:46:38 +0800 Subject: [PATCH 01/21] feat: enhance Oracle connection form with 3 connection methods and robust JRE download MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Oracle connection method selector (Basic/TNS/Cloud Wallet) with SID/Service Name toggle - Add TNS names directory browser with auto-populated alias dropdown - Add Cloud Wallet (ATP/ADW) support with wallet directory, password, and service level - Add Role selector (NORMAL/SYSDBA/SYSOPER) with pill-toggle UI - Redesign SSL/TLS mode as segmented radio group with hover descriptions - Place Host/Port on same row, dynamic label for Service Name/SID field - Add red asterisk indicators on required fields - Increase dialog max-width from lg to xl Backend: - Add OracleConnectionOptions struct flowing through ServerConfig→ConnectionConfig→ConnectParams - Implement build_oracle_url() with 4 URL formats (SID, Service Name, TNS, Cloud Wallet) - Add tnsnames.ora parser for TNS alias auto-population - Add direct JDBC driver download from Maven Central (no Java needed) - Fix Adoptium JRE download URL (separate os/arch path segments) - Add JRE download validation (size check, gzip magic bytes, atomic swap with rollback) - Fix Java binary executable permission check (is_valid_java now checks +x) - Handle macOS .jdk bundle format (Contents/Home/bin/java) - Skip bridge JAR validation when Java is macOS stub - Add process health check before bridge requests - Skip non-JSON lines in bridge response parsing - Retry empty bridge response after 1s delay Java: - Pass TNS_ADMIN and wallet_password as HikariCP data source properties - Parse oracle_options from ConnectParams JSON UI/UX: - Add sequential step progress with per-item status and retry buttons - Show download progress bar during JRE/bridge/driver downloads - Add retry-on-error for individual setup steps - Show loading indicator during connection test - Add Connection successful/failed summary with error details - Disable Test Connection and Save buttons during testing - Prevent form submission on Browse buttons (type=button) - Bundle carbon icons locally instead of CDN fetch --- .../java/sqlkit/bridge/ConnectionManager.java | 27 +- .../java/sqlkit/bridge/ProtocolHandler.java | 15 +- package-lock.json | 11 + package.json | 1 + src-tauri/Cargo.toml | 3 +- src-tauri/src/commands/jdbc.rs | 20 +- src-tauri/src/database/config.rs | 22 + src-tauri/src/database/jdbc_bridge/adapter.rs | 1 + .../src/database/jdbc_bridge/download.rs | 158 +++- .../src/database/jdbc_bridge/drivers.toml | 1 + .../src/database/jdbc_bridge/fallback.rs | 52 +- src-tauri/src/database/jdbc_bridge/jre.rs | 287 +++++-- .../src/database/jdbc_bridge/launcher.rs | 90 +- src-tauri/src/database/jdbc_bridge/mod.rs | 1 + .../src/database/jdbc_bridge/protocol.rs | 3 + .../src/database/jdbc_bridge/registry.rs | 26 +- .../src/database/jdbc_bridge/tns_parser.rs | 108 +++ src-tauri/src/lib.rs | 2 + src-tauri/src/state.rs | 10 +- .../connections/ServerFormDialog.vue | 777 ++++++++++++++++-- .../connections/ssl/SslModeSelect.vue | 55 +- src/datasources/connectionApi.ts | 11 + src/datasources/jdbcApi.ts | 4 + src/lang/enUS.ts | 52 +- src/lang/zhCN.ts | 54 +- src/store/connectionStore.ts | 52 ++ src/store/index.ts | 3 +- src/types/connection.ts | 2 +- src/views/setting/jre-driver-section.vue | 197 ++--- uno.config.ts | 4 +- 30 files changed, 1682 insertions(+), 367 deletions(-) create mode 100644 src-tauri/src/database/jdbc_bridge/tns_parser.rs diff --git a/jdbc-bridge/src/main/java/sqlkit/bridge/ConnectionManager.java b/jdbc-bridge/src/main/java/sqlkit/bridge/ConnectionManager.java index ac7758b1..7dbbd85e 100644 --- a/jdbc-bridge/src/main/java/sqlkit/bridge/ConnectionManager.java +++ b/jdbc-bridge/src/main/java/sqlkit/bridge/ConnectionManager.java @@ -18,18 +18,21 @@ public class ConnectionManager { /** * Create a new JDBC connection pool. * - * @param connId unique identifier for this connection - * @param url JDBC URL - * @param username database username - * @param password database password - * @param driverClass JDBC driver class name - * @param minPool minimum pool size - * @param maxPool maximum pool size + * @param connId unique identifier for this connection + * @param url JDBC URL + * @param username database username + * @param password database password + * @param driverClass JDBC driver class name + * @param minPool minimum pool size + * @param maxPool maximum pool size + * @param tnsAdminDir Oracle TNS_ADMIN directory (optional, for TNS/wallet connections) + * @param walletPassword Oracle wallet password (optional, for encrypted wallets) */ public void connect(String connId, String url, String username, String password, String driverClass, List driverJars, - int minPool, int maxPool) throws ClassifiedException, Exception { + int minPool, int maxPool, + String tnsAdminDir, String walletPassword) throws ClassifiedException, Exception { if (pools.containsKey(connId)) { throw new Exception("Connection already exists: " + connId); } @@ -57,6 +60,14 @@ public void connect(String connId, String url, String username, config.addDataSourceProperty("prepStmtCacheSize", "250"); config.addDataSourceProperty("prepStmtCacheSqlLimit", "2048"); + // Oracle-specific data source properties + if (tnsAdminDir != null && !tnsAdminDir.isEmpty()) { + config.addDataSourceProperty("oracle.net.tns_admin", tnsAdminDir); + } + if (walletPassword != null && !walletPassword.isEmpty()) { + config.addDataSourceProperty("oracle.net.wallet_password", walletPassword); + } + HikariDataSource ds = new HikariDataSource(config); // Verify connection works diff --git a/jdbc-bridge/src/main/java/sqlkit/bridge/ProtocolHandler.java b/jdbc-bridge/src/main/java/sqlkit/bridge/ProtocolHandler.java index 7c0c1b56..fd83ea77 100644 --- a/jdbc-bridge/src/main/java/sqlkit/bridge/ProtocolHandler.java +++ b/jdbc-bridge/src/main/java/sqlkit/bridge/ProtocolHandler.java @@ -115,7 +115,20 @@ private void handleConnect(JsonNode params, ObjectNode response) throws Exceptio } } - connectionManager.connect(connId, url, username, password, driverClass, driverJars, poolMin, poolMax); + // Extract Oracle-specific connection options + String tnsAdminDir = null; + String walletPassword = null; + if (params.has("oracle_options") && !params.get("oracle_options").isNull()) { + JsonNode oracleOpts = params.get("oracle_options"); + if (oracleOpts.has("tns_admin_dir") && !oracleOpts.get("tns_admin_dir").isNull()) { + tnsAdminDir = oracleOpts.get("tns_admin_dir").asText(); + } + if (oracleOpts.has("wallet_password") && !oracleOpts.get("wallet_password").isNull()) { + walletPassword = oracleOpts.get("wallet_password").asText(); + } + } + + connectionManager.connect(connId, url, username, password, driverClass, driverJars, poolMin, poolMax, tnsAdminDir, walletPassword); response.put("result", connId); } diff --git a/package-lock.json b/package-lock.json index c61771d9..1fbdc78d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -42,6 +42,7 @@ }, "devDependencies": { "@antfu/eslint-config": "^7.0.1", + "@iconify-json/carbon": "^1.2.23", "@iconify/json": "^2.2.482", "@tauri-apps/cli": "^2", "@types/dompurify": "^3.0.5", @@ -1652,6 +1653,16 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@iconify-json/carbon": { + "version": "1.2.23", + "resolved": "https://registry.npmjs.org/@iconify-json/carbon/-/carbon-1.2.23.tgz", + "integrity": "sha512-7apXetbRmEiWDXIQyikFJZyq7pCVBKHYRzmeLdtT7wWoHYdWHwnFcBAzpuLoSh+ZEAfXZapSWEe8iuS6dUqf+Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@iconify/types": "*" + } + }, "node_modules/@iconify/json": { "version": "2.2.482", "resolved": "https://registry.npmjs.org/@iconify/json/-/json-2.2.482.tgz", diff --git a/package.json b/package.json index a46a083c..8acb2963 100644 --- a/package.json +++ b/package.json @@ -102,6 +102,7 @@ }, "devDependencies": { "@antfu/eslint-config": "^7.0.1", + "@iconify-json/carbon": "^1.2.23", "@iconify/json": "^2.2.482", "@tauri-apps/cli": "^2", "@types/dompurify": "^3.0.5", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 210b8af5..e109cc37 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -78,7 +78,8 @@ calamine = "0.22" reqwest = { version = "0.12", default-features = false, features = [ "json", "rustls-tls", - "socks" + "socks", + "stream" ] } http = "1" log = "0.4" diff --git a/src-tauri/src/commands/jdbc.rs b/src-tauri/src/commands/jdbc.rs index c318da71..c60bc639 100644 --- a/src-tauri/src/commands/jdbc.rs +++ b/src-tauri/src/commands/jdbc.rs @@ -1,5 +1,5 @@ use crate::database::config::DatabaseType; -use crate::database::jdbc_bridge::{download, jre, launcher::JdbcBridgeLauncher, protocol::{JdbcMethod, JdbcRequest}, registry::DriverRegistry}; +use crate::database::jdbc_bridge::{download, jre, launcher::JdbcBridgeLauncher, protocol::{JdbcMethod, JdbcRequest}, registry::DriverRegistry, tns_parser}; use serde::Serialize; use std::path::PathBuf; @@ -34,8 +34,7 @@ pub async fn check_jre_status() -> Result { }) } else if let Some(system_java) = jre::JreDetector::detect_system_java() { let version = jre::system_java_version(&system_java) - .map(|v| format!("{}.x", v)) - .or_else(|| Some("system".to_string())); + .map(|v| format!("{}.x", v)); Ok(JreStatus { installed: true, version, @@ -209,6 +208,21 @@ pub struct BridgeStatus { pub path: Option, } +/// List TNS alias names from an Oracle tnsnames.ora file in the given directory. +#[tauri::command] +pub async fn list_tns_aliases(tns_admin_dir: String) -> Result, String> { + Ok(tns_parser::parse_tns_aliases(&tns_admin_dir)) +} + +/// Download a JDBC driver JAR directly from Maven Central via HTTP. +/// Does NOT require Java — purely HTTP download, parallel-safe. +#[tauri::command] +pub async fn download_jdbc_driver_direct(db_type: String) -> Result<(), String> { + download::download_jdbc_driver_direct(&db_type) + .await + .map_err(|e| e.to_string()) +} + #[tauri::command] pub async fn check_bridge_status() -> Result { let jar_path = download::bridge_jar_path(); diff --git a/src-tauri/src/database/config.rs b/src-tauri/src/database/config.rs index fd4f3d50..322d1351 100644 --- a/src-tauri/src/database/config.rs +++ b/src-tauri/src/database/config.rs @@ -233,6 +233,9 @@ pub struct ConnectionConfig { /// Trust server certificate (SQL Server specific). #[serde(default)] pub trust_server_certificate: bool, + /// Oracle-specific connection options. + #[serde(default)] + pub oracle_options: Option, /// Additional connection options. #[serde(default)] pub options: std::collections::HashMap, @@ -244,6 +247,18 @@ pub struct ConnectionConfig { pub transport_layers: Vec, } +/// Oracle-specific connection options for non-basic connection methods. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct OracleConnectionOptions { + pub connection_method: String, + pub sid_or_service: Option, + pub role: Option, + pub tns_admin_dir: Option, + pub tns_alias: Option, + pub wallet_password: Option, + pub service_level: Option, +} + impl ConnectionConfig { /// Create a new connection configuration. pub fn new( @@ -264,6 +279,7 @@ impl ConnectionConfig { ssl_client_cert: None, ssl_client_key: None, trust_server_certificate: false, + oracle_options: None, options: std::collections::HashMap::new(), pool_config: PoolConfig::default(), transport_layers: Vec::new(), @@ -294,6 +310,12 @@ impl ConnectionConfig { self } + /// Set the Oracle-specific options. + pub fn with_oracle_options(mut self, opts: OracleConnectionOptions) -> Self { + self.oracle_options = Some(opts); + self + } + /// Set the transport layer configuration. pub fn with_transport_layers(mut self, layers: Vec) -> Self { self.transport_layers = layers; diff --git a/src-tauri/src/database/jdbc_bridge/adapter.rs b/src-tauri/src/database/jdbc_bridge/adapter.rs index 4adb690f..0dc7dd39 100644 --- a/src-tauri/src/database/jdbc_bridge/adapter.rs +++ b/src-tauri/src/database/jdbc_bridge/adapter.rs @@ -53,6 +53,7 @@ impl JdbcBridgeAdapter { self.config.database.as_deref(), &self.config.username, &self.config.password, + self.config.oracle_options.as_ref(), ) .await?; diff --git a/src-tauri/src/database/jdbc_bridge/download.rs b/src-tauri/src/database/jdbc_bridge/download.rs index e607f9ef..803b5d3e 100644 --- a/src-tauri/src/database/jdbc_bridge/download.rs +++ b/src-tauri/src/database/jdbc_bridge/download.rs @@ -3,11 +3,15 @@ //! Downloads the bridge fat JAR from GitHub Releases, version-pinned //! by the app version. JARs are stored flat in `~/.sqlkit/jdbc-bridge/` //! with versioned filenames (`jdbc-bridge-{version}.jar`). -//! JDBC driver JARs are resolved by the Java bridge process, not by Rust. +//! JDBC driver JARs can be downloaded directly from Maven Central +//! (fallback if the Java bridge resolution is unavailable). use crate::database::error::{DbError, DbResult}; +use crate::APP_HANDLE; +use futures::StreamExt; use std::path::{Path, PathBuf}; use std::process::Command; +use tauri::Emitter; const APP_VERSION: &str = env!("APP_VERSION"); @@ -36,7 +40,8 @@ pub fn is_bridge_installed() -> bool { } /// Download a file from URL to a temporary path, then atomically rename to final. -pub async fn download_to_path(url: &str, dest: &Path) -> DbResult<()> { +/// Emits Tauri progress events if the global APP_HANDLE is set. +pub async fn download_to_path(url: &str, dest: &Path, event_label: &str, expected_size_hint: u64) -> DbResult<()> { let tmp_path = dest.with_extension("tmp"); let response = reqwest::get(url) .await @@ -48,18 +53,43 @@ pub async fn download_to_path(url: &str, dest: &Path) -> DbResult<()> { url ))); } - let bytes = response - .bytes() - .await - .map_err(|e| DbError::Connection(format!("Failed to read download: {}", e)))?; + + let total = expected_size_hint.max(1); + let mut downloaded: u64 = 0; + if let Some(parent) = dest.parent() { tokio::fs::create_dir_all(parent) .await .map_err(|e| DbError::Connection(format!("Failed to create dir: {}", e)))?; } - tokio::fs::write(&tmp_path, &bytes) + + // Stream chunks and write to temp file + let mut file = tokio::fs::File::create(&tmp_path) .await - .map_err(|e| DbError::Connection(format!("Failed to write temp file: {}", e)))?; + .map_err(|e| DbError::Connection(format!("Failed to create temp file: {}", e)))?; + + let mut stream = response.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|e| DbError::Connection(format!("Download stream error: {}", e)))?; + downloaded += chunk.len() as u64; + use tokio::io::AsyncWriteExt; + file.write_all(&chunk) + .await + .map_err(|e| DbError::Connection(format!("Failed to write chunk: {}", e)))?; + + // Emit progress event + if let Some(handle) = crate::APP_HANDLE.get() { + let _ = handle.emit( + "connection-progress", + serde_json::json!({ + "step": event_label, + "downloaded": downloaded, + "total": total, + }), + ); + } + } + tokio::fs::rename(&tmp_path, dest) .await .map_err(|e| DbError::Connection(format!("Failed to finalize download: {}", e)))?; @@ -89,8 +119,22 @@ pub async fn download_bridge_plugin() -> DbResult<()> { ); let mut last_err = None::; - for _attempt in 0..2 { - if let Err(e) = download_to_path(&url, &jar_path).await { + for attempt in 0..2 { + if attempt > 0 { + // Emit retry event + if let Some(handle) = APP_HANDLE.get() { + let _ = handle.emit( + "connection-progress", + serde_json::json!({ + "step": "retry", + "message": format!("Download failed, retrying... ({})", last_err.as_deref().unwrap_or("unknown error")), + "downloaded": 0, + "total": 1, + }), + ); + } + } + if let Err(e) = download_to_path(&url, &jar_path, "bridge_jar", 10_000_000).await { last_err = Some(e.to_string()); continue; } @@ -108,31 +152,40 @@ pub async fn download_bridge_plugin() -> DbResult<()> { } if let Some(java) = super::jre::JreDetector::detect() { - let output = Command::new(&java) + // Try to validate the JAR. If Java isn't actually runnable (e.g. macOS stub), + // skip validation rather than deleting the downloaded JAR. + match Command::new(&java) .args(["-jar", &jar_path.to_string_lossy(), "--version"]) .output() - .map_err(|e| { - DbError::Connection(format!("Failed to run JAR validation: {}", e)) - })?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - let _ = std::fs::remove_file(&jar_path); - last_err = Some(format!( - "Bridge JAR validation failed (exit: {}): {}", - output.status, stderr - )); - continue; - } - - let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); - if stdout.is_empty() || stdout == "unknown" { - let _ = std::fs::remove_file(&jar_path); - last_err = Some(format!( - "Bridge JAR --version returned invalid output: {}", - stdout - )); - continue; + { + Ok(output) => { + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + if stderr.contains("Unable to locate") || stderr.contains("no java") { + // macOS stub or missing JRE — skip validation, JAR is fine + } else { + let _ = std::fs::remove_file(&jar_path); + last_err = Some(format!( + "Bridge JAR validation failed (exit: {}): {}", + output.status, stderr + )); + continue; + } + } else { + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if stdout.is_empty() || stdout == "unknown" { + let _ = std::fs::remove_file(&jar_path); + last_err = Some(format!( + "Bridge JAR --version returned invalid output: {}", + stdout + )); + continue; + } + } + } + Err(_) => { + // Can't run Java at all — skip validation, JAR is fine + } } } @@ -154,6 +207,45 @@ pub async fn download_bridge_plugin() -> DbResult<()> { Ok(()) } +/// Download a JDBC driver JAR directly from Maven Central via HTTP. +/// Uses the driver registry config (version_cap, maven coordinates) to +/// construct the download URL. Stores the JAR in the driver cache directory. +/// Does NOT require Java — purely HTTP. +pub async fn download_jdbc_driver_direct(db_type: &str) -> DbResult<()> { + use super::registry::{resolve_maven_url, DriverRegistry}; + use crate::database::config::DatabaseType; + + let dt = match db_type { + "oracle" => DatabaseType::Oracle, + "db2" => DatabaseType::DB2, + "h2" => DatabaseType::H2, + _ => return Err(DbError::Connection(format!("No direct JDBC driver download for {}", db_type))), + }; + + let registry = DriverRegistry::load(); + let config = registry.get_config(dt).ok_or_else(|| { + DbError::Connection(format!("No driver registry entry for {}", db_type)) + })?; + + let version = config.version_cap.as_deref().unwrap_or("latest"); + let classifier = config.maven_classifier.as_deref(); + let url = resolve_maven_url(&config.maven_group, &config.maven_artifact, version, classifier); + + let dest_dir = super::jre::home_dir() + .join(".sqlkit") + .join("jdbc-bridge") + .join("drivers") + .join(&config.maven_artifact); + let jar_name = format!("{}-{}.jar", config.maven_artifact, version); + let dest = dest_dir.join(&jar_name); + + if dest.exists() { + return Ok(()); // Already cached + } + + download_to_path(&url, &dest, "jdbc_driver", 5_000_000).await +} + /// Clean up old bridge JARs and stale version directories. /// Removes `jdbc-bridge-*.jar` files for versions other than current, /// and any leftover `{version}/` directories from the old folder layout. diff --git a/src-tauri/src/database/jdbc_bridge/drivers.toml b/src-tauri/src/database/jdbc_bridge/drivers.toml index c452a570..96850533 100644 --- a/src-tauri/src/database/jdbc_bridge/drivers.toml +++ b/src-tauri/src/database/jdbc_bridge/drivers.toml @@ -13,6 +13,7 @@ class_name = "oracle.jdbc.OracleDriver" maven_group = "com.oracle.database.jdbc" maven_artifact = "ojdbc11" jdbc_url_template = "jdbc:oracle:thin:@{host}:{port}:{database}" +jdbc_url_template_service = "jdbc:oracle:thin:@//{host}:{port}/{database}" default_port = 1521 min_jre_version = "11" version_cap = "21.15.0.0" diff --git a/src-tauri/src/database/jdbc_bridge/fallback.rs b/src-tauri/src/database/jdbc_bridge/fallback.rs index 428cbc93..3d830979 100644 --- a/src-tauri/src/database/jdbc_bridge/fallback.rs +++ b/src-tauri/src/database/jdbc_bridge/fallback.rs @@ -1,4 +1,4 @@ -use crate::database::config::DatabaseType; +use crate::database::config::{DatabaseType, OracleConnectionOptions}; use crate::database::error::{DbError, DbResult}; use std::sync::Arc; use tokio::sync::Mutex; @@ -19,6 +19,46 @@ pub enum DriverAttempt { Fatal(DbError), } +/// Build an Oracle JDBC URL based on the connection method and options. +/// Supports: basic SID, basic service name, TNS alias, and cloud wallet formats. +fn build_oracle_url( + config: &DatabaseDriverConfig, + host: &str, + port: u16, + database: Option<&str>, + oracle_options: Option<&OracleConnectionOptions>, +) -> String { + let opts = match oracle_options { + Some(o) => o, + // No Oracle options — fall back to default SID format + None => return super::registry::build_jdbc_url(config, host, port, database), + }; + + match opts.connection_method.as_str() { + "basic" => { + let use_service = matches!(opts.sid_or_service.as_deref(), Some("service_name")); + if use_service { + // Service name format: jdbc:oracle:thin:@//host:port/service_name + if let Some(ref service_template) = config.jdbc_url_template_service { + super::registry::build_jdbc_url_from_template(service_template, host, port, database) + } else { + super::registry::build_jdbc_url(config, host, port, database) + } + } else { + // SID format: jdbc:oracle:thin:@host:port:sid (default template) + super::registry::build_jdbc_url(config, host, port, database) + } + } + "tns" | "cloud_wallet" => { + // The TNS alias from tnsnames.ora already includes the service level + // suffix (e.g. dbname_medium). Use it as-is. + let alias = opts.tns_alias.as_deref().unwrap_or(""); + format!("jdbc:oracle:thin:@{}", alias) + } + _ => super::registry::build_jdbc_url(config, host, port, database), + } +} + /// Try connecting using the JDBC bridge. /// /// Starts the bridge, resolves the driver JAR via Java-side `ResolveDriver` RPC, @@ -33,8 +73,9 @@ pub async fn try_driver( username: &str, password: &Option, use_cap: bool, + oracle_options: Option<&OracleConnectionOptions>, ) -> DriverAttempt { - let url = super::registry::build_jdbc_url(config, host, port, database); + let url = build_oracle_url(config, host, port, database, oracle_options); // Start bridge (no driver JARs yet — ResolveDriver will download on the Java side) let bridge_jar = download::bridge_jar_path(); @@ -112,6 +153,7 @@ pub async fn try_driver( driver_jars: vec![jar_path], pool_min: 1, pool_max: 5, + oracle_options: oracle_options.cloned(), }) { Ok(v) => v, Err(e) => { @@ -176,6 +218,7 @@ pub async fn try_driver( /// /// Ensures JRE and bridge JAR are installed, then delegates to `try_driver` /// which resolves the driver dynamically via Java-side `ResolveDriver` RPC. +/// Supports Oracle-specific URL construction via `oracle_options`. pub async fn run_fallback_chain( db_type: DatabaseType, host: &str, @@ -183,6 +226,7 @@ pub async fn run_fallback_chain( database: Option<&str>, username: &str, password: &Option, + oracle_options: Option<&OracleConnectionOptions>, ) -> DbResult<(String, String, Arc>)> { let registry = super::registry::DriverRegistry::load(); let config = registry.get_config(db_type).ok_or_else(|| { @@ -237,14 +281,14 @@ pub async fn run_fallback_chain( // Two-phase driver resolution: // 1. Try LATEST (no version_cap) - match try_driver(config, host, port, database, username, password, false).await { + match try_driver(config, host, port, database, username, password, false, oracle_options).await { DriverAttempt::Connected(conn_id, launcher) => { return Ok(("resolved".to_string(), conn_id, launcher)); } DriverAttempt::VersionMismatch(_) => { // 2. If LATEST fails with version_incompatible and a cap exists, retry with cap if config.version_cap.is_some() { - match try_driver(config, host, port, database, username, password, true).await { + match try_driver(config, host, port, database, username, password, true, oracle_options).await { DriverAttempt::Connected(conn_id, launcher) => { return Ok(("capped".to_string(), conn_id, launcher)); } diff --git a/src-tauri/src/database/jdbc_bridge/jre.rs b/src-tauri/src/database/jdbc_bridge/jre.rs index d02c0e5f..2259aa2b 100644 --- a/src-tauri/src/database/jdbc_bridge/jre.rs +++ b/src-tauri/src/database/jdbc_bridge/jre.rs @@ -6,8 +6,12 @@ //! `release` file, and cleaning it up. use crate::database::error::{DbError, DbResult}; +use crate::APP_HANDLE; +use futures::StreamExt; +use std::path::Path; use std::path::PathBuf; use std::sync::OnceLock; +use tauri::Emitter; use tokio::sync::Mutex; /// Subdirectory under user home for the managed JRE. @@ -69,13 +73,13 @@ pub fn is_managed_jre_installed() -> bool { // ── Adoptium platform ───────────────────────────────────── -/// Determine the Adoptium platform string for the current architecture. -fn adoptium_platform() -> Option<&'static str> { - #[cfg(all(target_os = "macos", target_arch = "aarch64"))] { Some("macos-aarch64") } - #[cfg(all(target_os = "macos", target_arch = "x86_64"))] { Some("macos-x64") } - #[cfg(all(target_os = "linux", target_arch = "x86_64"))] { Some("linux-x64") } - #[cfg(all(target_os = "linux", target_arch = "aarch64"))] { Some("linux-aarch64") } - #[cfg(all(target_os = "windows", target_arch = "x86_64"))] { Some("windows-x64") } +/// Determine the Adoptium OS and arch strings for the current platform. +fn adoptium_os_arch() -> Option<(&'static str, &'static str)> { + #[cfg(all(target_os = "macos", target_arch = "aarch64"))] { Some(("mac", "aarch64")) } + #[cfg(all(target_os = "macos", target_arch = "x86_64"))] { Some(("mac", "x64")) } + #[cfg(all(target_os = "linux", target_arch = "x86_64"))] { Some(("linux", "x64")) } + #[cfg(all(target_os = "linux", target_arch = "aarch64"))] { Some(("linux", "aarch64")) } + #[cfg(all(target_os = "windows", target_arch = "x86_64"))] { Some(("windows", "x64")) } #[cfg(not(any( all(target_os = "macos", target_arch = "aarch64"), all(target_os = "macos", target_arch = "x86_64"), @@ -100,14 +104,45 @@ impl JreDetector { if Self::is_valid_java(&managed) { return Some(managed); } + // If the managed JRE exists but isn't executable, try to fix permissions + if managed.exists() { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if let Ok(meta) = managed.metadata() { + let mode = meta.permissions().mode(); + if mode & 0o111 == 0 { + let perms = std::fs::Permissions::from_mode(mode | 0o100); + let _ = std::fs::set_permissions(&managed, perms); + if Self::is_valid_java(&managed) { + return Some(managed); + } + } + } + } + } // 2. System Java Self::detect_system_java() } - /// Check whether `path` points to an existing file. + /// Check whether `path` points to an existing, executable java binary. pub fn is_valid_java(path: &PathBuf) -> bool { - path.exists() + if !path.exists() { + return false; + } + // On Unix, verify the file has executable permission + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + path.metadata() + .map(|meta| meta.permissions().mode() & 0o111 != 0) + .unwrap_or(false) + } + #[cfg(not(unix))] + { + true + } } /// Probe `JAVA_HOME` then `PATH` for a `java` executable. @@ -198,9 +233,9 @@ pub fn read_jre_version() -> Option { /// Returns `Some(redirect_url)` with the redirect target containing the build /// version, or `None` if not available / on error. pub async fn check_adoptium_update() -> Option { - let platform = adoptium_platform()?; + let (os, arch) = adoptium_os_arch()?; let url = format!( - "https://api.adoptium.net/v3/binary/latest/25/ga/{platform}/jre/hotspot/normal/eclipse" + "https://api.adoptium.net/v3/binary/latest/25/ga/{os}/{arch}/jre/hotspot/normal/eclipse" ); let client = reqwest::Client::builder() @@ -235,109 +270,229 @@ pub fn parse_adoptium_build_version(url: &str) -> Option { // ── download / remove ───────────────────────────────────── +/// Stream a JRE download to disk with progress events. +async fn download_jre_stream( + client: &reqwest::Client, + url: &str, + tmp_path: &Path, + os: &str, + _parent: &Path, +) -> DbResult<()> { + let response = client + .get(url) + .send() + .await + .map_err(|e| DbError::Connection(format!("Failed to download JRE: {}", e)))?; + + if !response.status().is_success() { + return Err(DbError::Connection(format!( + "Failed to download JRE: HTTP {} from {}", + response.status(), + url + ))); + } + + let is_zip = os == "windows"; + let _ext = if is_zip { "zip" } else { "tar.gz" }; + + let mut file = tokio::fs::File::create(tmp_path) + .await + .map_err(|e| DbError::Connection(format!("Failed to create temp file: {}", e)))?; + use tokio::io::AsyncWriteExt; + let mut stream = response.bytes_stream(); + let mut downloaded: u64 = 0; + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|e| DbError::Connection(format!("Download stream error: {}", e)))?; + downloaded += chunk.len() as u64; + file.write_all(&chunk) + .await + .map_err(|e| DbError::Connection(format!("Failed to write chunk: {}", e)))?; + if let Some(handle) = APP_HANDLE.get() { + let _ = handle.emit( + "connection-progress", + serde_json::json!({ + "step": "jre_download", + "downloaded": downloaded, + "total": 60_000_000, + }), + ); + } + } + file.flush().await.ok(); + Ok(()) +} + /// Download and extract the managed JRE for the current platform from Adoptium. /// /// Downloads the latest JRE 25 (Eclipse Temurin) build from the Adoptium API, /// extracts the archive, and renames the extracted directory to `jre`. +/// Uses atomic operations: download to temp → validate → extract to temp dir → replace. pub async fn download_managed_jre() -> DbResult<()> { let _guard = JRE_INSTALL_LOCK .get_or_init(|| Mutex::new(())) .lock() .await; - let platform = adoptium_platform().ok_or_else(|| { + let (os, arch) = adoptium_os_arch().ok_or_else(|| { DbError::Connection("No JRE available for this platform".to_string()) })?; - let parent = jre_base_dir() - .parent() + let base_dir = jre_base_dir(); // ~/.sqlkit/jre + let parent = base_dir.parent() .expect("jre_base_dir has a parent") - .to_path_buf(); + .to_path_buf(); // ~/.sqlkit tokio::fs::create_dir_all(&parent) .await .map_err(|e| DbError::Connection(format!("Failed to create JRE parent dir: {}", e)))?; let url = format!( - "https://api.adoptium.net/v3/binary/latest/25/ga/{platform}/jre/hotspot/normal/eclipse" + "https://api.adoptium.net/v3/binary/latest/25/ga/{os}/{arch}/jre/hotspot/normal/eclipse" ); - let response = reqwest::get(&url) - .await - .map_err(|e| DbError::Connection(format!("Failed to download JRE: {}", e)))?; + let is_zip = os == "windows"; + let ext = if is_zip { "zip" } else { "tar.gz" }; + let tmp_archive = parent.join(format!("jre_download.{}", ext)); + let tmp_extract = parent.join(format!("jre_extract_{}", uuid::Uuid::new_v4())); - if !response.status().is_success() { + // Step 1: Download archive (single attempt — retrying the same URL doesn't help) + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(300)) + .build() + .map_err(|e| DbError::Connection(format!("Failed to build HTTP client: {}", e)))?; + + if let Err(e) = download_jre_stream(&client, &url, &tmp_archive, &os, &parent).await { + let _ = tokio::fs::remove_file(&tmp_archive).await; + return Err(e); + } + + // Step 2: Validate downloaded archive (size and magic bytes) + let meta = std::fs::metadata(&tmp_archive) + .map_err(|e| DbError::Connection(format!("Failed to check JRE archive: {}", e)))?; + if meta.len() < 10_000_000 { + let _ = tokio::fs::remove_file(&tmp_archive).await; return Err(DbError::Connection(format!( - "Failed to download JRE: HTTP {} from {}", - response.status(), - url + "JRE archive too small: {} bytes (expected ≥ 10MB)", meta.len() ))); } + // Validate gzip magic bytes (1f 8b) if not a zip file + if !is_zip { + let magic = std::fs::read(&tmp_archive) + .map_err(|e| DbError::Connection(format!("Failed to read JRE archive: {}", e)))?; + if magic.len() < 2 || magic[0] != 0x1f || magic[1] != 0x8b { + let _ = tokio::fs::remove_file(&tmp_archive).await; + return Err(DbError::Connection( + "Downloaded JRE archive has invalid gzip magic bytes — corrupt download".to_string() + )); + } + } - let is_zip = platform.starts_with("windows"); - - let bytes = response - .bytes() + // Step 3: Extract to temporary directory + tokio::fs::create_dir_all(&tmp_extract) .await - .map_err(|e| DbError::Connection(format!("Failed to read JRE download: {}", e)))?; + .map_err(|e| DbError::Connection(format!("Failed to create extract dir: {}", e)))?; - let ext = if is_zip { "zip" } else { "tar.gz" }; - let tmp_path = parent.join(format!("jre_download.{}", ext)); - tokio::fs::write(&tmp_path, &bytes) - .await - .map_err(|e| DbError::Connection(format!("Failed to write JRE archive: {}", e)))?; - - let jre_path = jre_base_dir(); - if jre_path.exists() { - tokio::fs::remove_dir_all(&jre_path) - .await - .map_err(|e| DbError::Connection(format!("Failed to remove old JRE: {}", e)))?; - } - - let extract_result = tokio::task::spawn_blocking(move || -> Result<(), String> { - let file = - std::fs::File::open(&tmp_path).map_err(|e| format!("Failed to open archive: {}", e))?; + let tmp_archive_clone = tmp_archive.clone(); + let tmp_extract_clone = tmp_extract.clone(); + let extract_result = tokio::task::spawn_blocking(move || -> Result { + let file = std::fs::File::open(&tmp_archive_clone) + .map_err(|e| format!("Failed to open archive: {}", e))?; if is_zip { let mut archive = zip::ZipArchive::new(file) .map_err(|e| format!("Failed to open zip archive: {}", e))?; - archive - .extract(&parent) + archive.extract(&tmp_extract_clone) .map_err(|e| format!("Failed to extract JRE zip: {}", e))?; } else { let decoder = flate2::read::GzDecoder::new(file); let mut archive = tar::Archive::new(decoder); - archive - .unpack(&parent) - .map_err(|e| format!("Failed to extract JRE: {}", e))?; + archive.unpack(&tmp_extract_clone) + .map_err(|e| format!("Failed to extract JRE tar: {}", e))?; + } + + // Find the directory with bin/java — could be directly in extract dir + // or inside a subdirectory (e.g. jdk-25.0.1/) + let java_bin = if cfg!(target_os = "windows") { "java.exe" } else { "java" }; + let jdk_contents_home = |p: &Path| p.join("Contents").join("Home").join("bin").join(java_bin); + + // Case 1: directly in extract dir (no wrapper directory) + if tmp_extract_clone.join("bin").join(java_bin).exists() { + return Ok(tmp_extract_clone.clone()); + } + + // Case 2: macOS .jdk bundle format (Contents/Home/bin/java) + if jdk_contents_home(&tmp_extract_clone).exists() { + // Return the Contents/Home directory as the JRE root + return Ok(tmp_extract_clone.join("Contents").join("Home")); } - // Find the extracted directory that contains bin/java - for entry in std::fs::read_dir(&parent) + // Case 3: inside a subdirectory (e.g. jdk-25.0.1/) + for entry in std::fs::read_dir(&tmp_extract_clone) .map_err(|e| format!("Failed to list extracted files: {}", e))? .filter_map(|e| e.ok()) .filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false)) { - let bin_java = entry.path().join("bin").join(if cfg!(target_os = "windows") { - "java.exe" - } else { - "java" - }); - if bin_java.exists() { - let extracted_path = entry.path(); - let target_path = parent.join("jre"); - std::fs::rename(&extracted_path, &target_path) - .map_err(|e| format!("Failed to rename JRE directory: {}", e))?; - break; + let path = entry.path(); + // Standard: subdir/bin/java + if path.join("bin").join(java_bin).exists() { + return Ok(path); + } + // macOS bundle inside subdir: subdir/Contents/Home/bin/java + if jdk_contents_home(&path).exists() { + return Ok(path.join("Contents").join("Home")); } } - - let _ = std::fs::remove_file(&tmp_path); - Ok(()) + Err(format!( + "Extracted JRE archive does not contain bin/{java_bin} — checked {:?} and its subdirectories", + tmp_extract_clone + )) }) .await .map_err(|e| DbError::Connection(format!("JRE extraction panicked: {}", e)))?; - extract_result.map_err(|e| DbError::Connection(format!("JRE extraction failed: {}", e)))?; + let extracted_dir = extract_result + .map_err(|e| DbError::Connection(format!("JRE extraction failed: {}", e)))?; + + // Step 4: Atomic swap — rename temp to final, with rollback + let _ = tokio::fs::remove_file(&tmp_archive).await; + + // If target exists, move it aside first (backup), then rename temp, then delete backup + if base_dir.exists() { + let backup = parent.join(format!("jre_old_{}", uuid::Uuid::new_v4())); + tokio::fs::rename(&base_dir, &backup) + .await + .map_err(|e| DbError::Connection(format!("Failed to backup old JRE: {}", e)))?; + match tokio::fs::rename(&extracted_dir, &base_dir).await { + Ok(()) => { + let _ = tokio::fs::remove_dir_all(&backup).await; + } + Err(_) => { + // Rollback: restore backup + let _ = tokio::fs::rename(&backup, &base_dir).await; + let _ = tokio::fs::remove_dir_all(&extracted_dir).await; + return Err(DbError::Connection("Failed to install JRE — restored previous version".to_string())); + } + } + } else { + tokio::fs::rename(&extracted_dir, &base_dir) + .await + .map_err(|e| DbError::Connection(format!("Failed to install JRE: {}", e)))?; + } + + // Step 5: Ensure java binary is executable (fix permissions if needed) + #[cfg(unix)] + { + let java_bin = base_dir.join("bin").join("java"); + if java_bin.exists() { + use std::os::unix::fs::PermissionsExt; + if let Ok(meta) = java_bin.metadata() { + let mode = meta.permissions().mode(); + if mode & 0o111 == 0 { + let perms = std::fs::Permissions::from_mode(mode | 0o100); + let _ = std::fs::set_permissions(&java_bin, perms); + } + } + } + } Ok(()) } diff --git a/src-tauri/src/database/jdbc_bridge/launcher.rs b/src-tauri/src/database/jdbc_bridge/launcher.rs index 25ca447c..e5773222 100644 --- a/src-tauri/src/database/jdbc_bridge/launcher.rs +++ b/src-tauri/src/database/jdbc_bridge/launcher.rs @@ -189,6 +189,24 @@ impl JdbcBridgeLauncher { .as_mut() .ok_or_else(|| DbError::Connection("JDBC bridge not started".to_string()))?; + // Check if the process is still alive before trying to communicate + if let Ok(Some(status)) = process.try_wait() { + let stderr = self.stderr_buffer.as_ref() + .map(Self::read_stderr_buffer) + .unwrap_or_default(); + return if stderr.is_empty() { + Err(DbError::Connection(format!( + "JDBC bridge exited before request (code: {}). No stderr output.", + status + ))) + } else { + Err(DbError::Connection(format!( + "JDBC bridge exited before request (code: {}). stderr: {}", + status, stderr + ))) + }; + } + let stdout = process .stdout .as_mut() @@ -231,34 +249,52 @@ impl JdbcBridgeLauncher { let mut reader = BufReader::new(stdout); let mut line = String::new(); - reader.read_line(&mut line).map_err(|e| { - let stderr = self.stderr_buffer.as_ref() - .map(Self::read_stderr_buffer) - .unwrap_or_default(); - if stderr.is_empty() { - DbError::Connection(format!("Failed to read bridge response: {}", e)) - } else { - DbError::Connection(format!( - "Bridge read error: {}. stderr: {}", - e, stderr - )) + let mut read_attempts = 0; + + // Skip any non-JSON lines (e.g. JVM prints version info to stdout) + // Retry once if first read is empty (JVM may be slow to start) + loop { + line.clear(); + reader.read_line(&mut line).map_err(|e| { + let stderr = self.stderr_buffer.as_ref() + .map(Self::read_stderr_buffer) + .unwrap_or_default(); + if stderr.is_empty() { + DbError::Connection(format!("Failed to read bridge response: {}", e)) + } else { + DbError::Connection(format!( + "Bridge read error: {}. stderr: {}", + e, stderr + )) + } + })?; + + let trimmed = line.trim(); + if trimmed.is_empty() { + if read_attempts == 0 { + // JVM may be slow to start — wait and retry once + read_attempts += 1; + std::thread::sleep(std::time::Duration::from_millis(1000)); + continue; + } + let stderr = self.stderr_buffer.as_ref() + .map(Self::read_stderr_buffer) + .unwrap_or_default(); + return if stderr.is_empty() { + Err(DbError::Connection( + "Empty response from JDBC bridge".to_string(), + )) + } else { + Err(DbError::Connection(format!( + "Bridge read error. stderr: {}", + stderr + ))) + }; + } + // Skip lines that don't start with '{' (non-JSON noise from JVM) + if trimmed.starts_with('{') { + break; } - })?; - - if line.trim().is_empty() { - let stderr = self.stderr_buffer.as_ref() - .map(Self::read_stderr_buffer) - .unwrap_or_default(); - return if stderr.is_empty() { - Err(DbError::Connection( - "Empty response from JDBC bridge".to_string(), - )) - } else { - Err(DbError::Connection(format!( - "Bridge read error. stderr: {}", - stderr - ))) - }; } let resp: JdbcResponse = serde_json::from_str(line.trim()) diff --git a/src-tauri/src/database/jdbc_bridge/mod.rs b/src-tauri/src/database/jdbc_bridge/mod.rs index 144fcad2..8ee1521d 100644 --- a/src-tauri/src/database/jdbc_bridge/mod.rs +++ b/src-tauri/src/database/jdbc_bridge/mod.rs @@ -25,6 +25,7 @@ pub mod pool; pub mod progress; pub mod protocol; pub mod registry; +pub mod tns_parser; pub use adapter::JdbcBridgeAdapter; pub use launcher::JdbcBridgeLauncher; diff --git a/src-tauri/src/database/jdbc_bridge/protocol.rs b/src-tauri/src/database/jdbc_bridge/protocol.rs index a7cceb43..5275734b 100644 --- a/src-tauri/src/database/jdbc_bridge/protocol.rs +++ b/src-tauri/src/database/jdbc_bridge/protocol.rs @@ -3,6 +3,7 @@ //! The bridge communicates with a Java subprocess over stdin/stdout //! using newline-delimited JSON (one JSON object per line). +use crate::database::config::OracleConnectionOptions; use serde::{Deserialize, Serialize}; /// Request methods the Rust side can invoke on the Java bridge. @@ -75,6 +76,8 @@ pub struct ConnectParams { pub pool_min: u32, #[serde(default = "default_pool_max")] pub pool_max: u32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub oracle_options: Option, } fn default_pool_min() -> u32 { diff --git a/src-tauri/src/database/jdbc_bridge/registry.rs b/src-tauri/src/database/jdbc_bridge/registry.rs index 9e5d8ca0..1bd34625 100644 --- a/src-tauri/src/database/jdbc_bridge/registry.rs +++ b/src-tauri/src/database/jdbc_bridge/registry.rs @@ -34,6 +34,10 @@ pub struct DatabaseDriverConfig { pub maven_artifact: String, /// JDBC URL template with `{host}`, `{port}`, `{database}` placeholders. pub jdbc_url_template: String, + /// Optional service-name format JDBC URL template (Oracle-specific). + /// Uses `//{host}:{port}/{database}` format instead of `@{host}:{port}:{database}`. + #[serde(default)] + pub jdbc_url_template_service: Option, /// Default port for this database type. #[serde(default)] pub default_port: Option, @@ -115,14 +119,13 @@ pub fn resolve_maven_url(group: &str, artifact: &str, version: &str, classifier: /// When `database` is `None`, the `{database}` placeholder and any associated /// parameter prefix (e.g. `;httpPath=`, `/DATABASE=`) are removed from the /// template to avoid dangling empty parameters. -pub fn build_jdbc_url( - config: &DatabaseDriverConfig, +pub fn build_jdbc_url_from_template( + template: &str, host: &str, port: u16, database: Option<&str>, ) -> String { - let url = config - .jdbc_url_template + let url = template .replace("{host}", host) .replace("{port}", &port.to_string()); match database { @@ -142,6 +145,16 @@ pub fn build_jdbc_url( } } +/// Build a JDBC URL from the config's template. +pub fn build_jdbc_url( + config: &DatabaseDriverConfig, + host: &str, + port: u16, + database: Option<&str>, +) -> String { + build_jdbc_url_from_template(&config.jdbc_url_template, host, port, database) +} + // --------------------------------------------------------------------------- // Private helpers // --------------------------------------------------------------------------- @@ -250,6 +263,7 @@ mod tests { maven_group: "com.h2database".into(), maven_artifact: "h2".into(), jdbc_url_template: "jdbc:h2:tcp://{host}:{port}/{database}".into(), + jdbc_url_template_service: None, default_port: Some(9092), min_jre_version: Some("11".into()), version_error_signatures: vec![], @@ -268,6 +282,7 @@ mod tests { maven_group: "com.oracle.database.jdbc".into(), maven_artifact: "ojdbc11".into(), jdbc_url_template: "jdbc:oracle:thin:@{host}:{port}:{database}".into(), + jdbc_url_template_service: None, default_port: Some(1521), min_jre_version: Some("11".into()), version_error_signatures: vec![], @@ -287,6 +302,7 @@ mod tests { maven_group: "com.h2database".into(), maven_artifact: "h2".into(), jdbc_url_template: "jdbc:h2:tcp://{host}:{port}/{database}".into(), + jdbc_url_template_service: None, default_port: Some(9092), min_jre_version: Some("11".into()), version_error_signatures: vec![], @@ -306,6 +322,7 @@ mod tests { maven_group: "com.dameng".into(), maven_artifact: "DmJdbcDriver".into(), jdbc_url_template: "jdbc:dm://{host}:{port}".into(), + jdbc_url_template_service: None, default_port: Some(5236), min_jre_version: Some("11".into()), version_error_signatures: vec![], @@ -368,6 +385,7 @@ mod tests { maven_group: "test".into(), maven_artifact: "test".into(), jdbc_url_template: template.to_string(), + jdbc_url_template_service: None, default_port: None, min_jre_version: None, version_error_signatures: vec![], diff --git a/src-tauri/src/database/jdbc_bridge/tns_parser.rs b/src-tauri/src/database/jdbc_bridge/tns_parser.rs new file mode 100644 index 00000000..c3cccc6c --- /dev/null +++ b/src-tauri/src/database/jdbc_bridge/tns_parser.rs @@ -0,0 +1,108 @@ +use std::fs; +use std::path::Path; + +/// Parse Oracle tnsnames.ora from a directory and return the list of TNS alias names. +/// +/// The parser looks for lines that start a TNS entry: a word followed by `=`. +/// Lines starting with `#` (comments) or whitespace are skipped. +/// Tries common filename variants: `tnsnames.ora`, `TNSNAMES.ORA`. +pub fn parse_tns_aliases(tns_admin_dir: &str) -> Vec { + let dir = Path::new(tns_admin_dir); + + // Try common filename variants + let filenames = ["tnsnames.ora", "TNSNAMES.ORA", "Tnsnames.ora"]; + let content = filenames.iter() + .find_map(|name| fs::read_to_string(dir.join(name)).ok()); + + let content = match content { + Some(c) => c, + None => return Vec::new(), + }; + + let mut aliases: Vec = Vec::new(); + for line in content.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { + continue; + } + // Match "alias_name =" or "alias_name=" at the start of a line (after trimming) + if let Some(eq_pos) = trimmed.find('=') { + let before_eq = trimmed[..eq_pos].trim(); + if !before_eq.is_empty() + && !before_eq.contains(' ') + && !before_eq.contains('(') + && !before_eq.contains(')') + { + let alias = before_eq.to_string(); + if !aliases.contains(&alias) { + aliases.push(alias); + } + } + } + } + + aliases.sort(); + aliases +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + fn with_temp_tnsnames(id: &str, content: &str) -> String { + let dir = std::env::temp_dir().join(format!("tns_test_{}", id)); + let _ = fs::create_dir_all(&dir); + let file_path = dir.join("tnsnames.ora"); + let mut file = fs::File::create(&file_path).unwrap(); + write!(file, "{}", content).unwrap(); + dir.to_string_lossy().to_string() + } + + #[test] + fn test_parse_tns_aliases_from_file() { + let dir_path = with_temp_tnsnames( + "from_file", + r#"dbname_medium = + (DESCRIPTION = + (ADDRESS = (PROTOCOL = tcps)(HOST = adb.example.com)(PORT = 1522)) + (CONNECT_DATA = + (SERVER = DEDICATED) + (SERVICE_NAME = dbname_medium.adb.example.com) + ) + ) + +dbname_low = + (DESCRIPTION = + (ADDRESS = (PROTOCOL = tcps)(HOST = adb.example.com)(PORT = 1522)) + (CONNECT_DATA = + (SERVICE_NAME = dbname_low.adb.example.com) + ) + ) + +# This is a comment +dbname_high = (DESCRIPTION=(ADDRESS=...))"#, + ); + + let aliases = parse_tns_aliases(&dir_path); + assert_eq!(aliases.len(), 3); + assert!(aliases.contains(&"dbname_high".to_string())); + assert!(aliases.contains(&"dbname_low".to_string())); + assert!(aliases.contains(&"dbname_medium".to_string())); + let _ = fs::remove_dir_all(std::env::temp_dir().join("tns_test_from_file")); + } + + #[test] + fn test_parse_tns_aliases_missing_file() { + let aliases = parse_tns_aliases("/tmp/nonexistent_dir_tns_test_99999"); + assert!(aliases.is_empty()); + } + + #[test] + fn test_parse_tns_aliases_empty_file() { + let dir_path = with_temp_tnsnames("empty_file", ""); + let aliases = parse_tns_aliases(&dir_path); + assert!(aliases.is_empty()); + let _ = fs::remove_dir_all(std::env::temp_dir().join("tns_test_empty_file")); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4e4c8a29..65f31e0b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -200,6 +200,8 @@ pub fn run() { commands::list_drivers, commands::download_driver, commands::remove_driver, + commands::list_tns_aliases, + commands::download_jdbc_driver_direct, // Connection management commands::save_connection, commands::list_connections, diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index d3e74f87..1ce97ce5 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -3,7 +3,7 @@ //! This module provides the application-wide state that is shared across all Tauri commands. //! The state includes connection managers for each database type and application configuration. -use crate::database::config::ConnectionConfig; +use crate::database::config::{ConnectionConfig, OracleConnectionOptions}; use crate::ssh::config::TransportLayerConfig; use crate::ssh::TunnelManager; use serde::{Deserialize, Serialize}; @@ -63,6 +63,9 @@ pub struct ServerConfig { /// Additional metadata. #[serde(skip_serializing_if = "Option::is_none")] pub metadata: Option>, + /// Oracle-specific connection options. + #[serde(skip_serializing_if = "Option::is_none")] + pub oracle_options: Option, /// Transport layer configuration (SSH tunnels). #[serde(skip_serializing_if = "Option::is_none")] pub transport_layers: Option>, @@ -82,6 +85,7 @@ impl ServerConfig { database: None, ssl_mode: None, metadata: None, + oracle_options: None, transport_layers: None, } } @@ -190,6 +194,10 @@ impl ServerConfig { config = config.with_transport_layers(layers.clone()); } + if let Some(ref oracle_opts) = self.oracle_options { + config = config.with_oracle_options(oracle_opts.clone()); + } + Ok(config) } } diff --git a/src/components/connections/ServerFormDialog.vue b/src/components/connections/ServerFormDialog.vue index 71999d01..fc863f80 100644 --- a/src/components/connections/ServerFormDialog.vue +++ b/src/components/connections/ServerFormDialog.vue @@ -1,7 +1,8 @@