diff --git a/AGENTS.md b/AGENTS.md index f302fd3..db26434 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -161,30 +161,10 @@ bun run --filter gridline-desktop build # Desktop production build bun run dev:www # Website dev server (Astro) bun run build:www # Website static build (www/dist/) bun run test # Desktop frontend tests (vitest) -bun run icons # Regenerate desktop/src-tauri/icons/* from icon.svg -bun run icons:verify # Check the macOS icon isn't rendered on a legacy plate cargo build # Rust backend only (from desktop/src-tauri/) cargo test # Rust tests (from desktop/src-tauri/) ``` -### App icon - -`desktop/src-tauri/icons/icon.svg` is the source of truth; every PNG/ICNS/ICO beside it is generated output. - -Regenerate **only** through the script — never with a bare `tauri icon`: - -```bash -bun run icons # tauri icon -> temp dir, copy back the desktop set, then fix the ICNS -bun run icons:verify # asserts AppKit renders the icon cleanly at 16px..512px -``` - -Two reasons the wrapper is mandatory: - -1. `tauri icon` takes one output directory for all platforms, so running it in place would drop generated `android/` and `ios/` trees into the desktop-only icons directory. The script generates into a temp dir and copies back just the desktop files. -2. `tauri icon` emits legacy `is32`/`s8mk`/`il32`/`l8mk` (+ `ic12`) representations. **macOS 26+ then draws a grey "legacy plate" behind the icon for every render size below ~72px** — a white border around the app icon in the Dock and in any launcher that resolves icons via `NSWorkspace.icon(forFile:)` (Spotlight, Vicinae, Raycast). `scripts/fix-icns.mjs` strips those chunks, which makes the icon render cleanly at every size. - -`scripts/verify-app-icon.mjs` is the regression check for that: it builds a throwaway `.app` around the committed `icon.icns`, asks AppKit for the icon (the same call launchers make) and fails if any size comes back plated. Run it after touching icons, and after bumping Tauri (a newer `tauri icon` may change what it emits). - ### Branch & PR workflow **GitHub is the only remote that matters.** The canonical repo is `github` → [AdrianBonpin/gridline](https://github.com/AdrianBonpin/gridline) (default branch `prod`, issues, PRs, Actions, releases). The `origin` remote (`git.ranio.xyz/adrianbonpin/gridline`) is a **just-in-case mirror** — do not push branches, open PRs, or publish releases/tags there. If you must mirror something manually, push to `github` first. @@ -404,9 +384,9 @@ Planned work is prioritized in the [Project Roadmap](./ROADMAP.md) (source of tr | Restore UI | ✅ | In-page view: file browse, format, clean toggle, destructive confirmation checkbox, progress bar. **Clean toggle is disabled for plain format** (psql can't DROP-before-CREATE) with a hint to use Custom Archive | | DB-to-DB sync | ✅ | In-page view: source/target connection pickers, schema dropdown, pipe-based pg_dump → pg_restore. **pg_restore side passes `--clean --if-exists`**, so sync works into a non-empty target (UI already requires destructive-overwrite confirmation). Core logic in headless-testable `run_db_sync` | | Unified Tools view | ✅ | Backup / Restore / DB Sync merged into a single **Tools** nav item; operation-switcher dropdown in the view toolbar, existing forms rendered below | -| Bundled PostgreSQL client tools | ✅ | Static pg_dump/pg_restore/psql shipped as Tauri resources; system-first, bundled-fallback resolution via resource_dir (v0.7.5). `resources/mysql_tools` (mariadb-dump / mariadb) is **still bundled and still required** — for MySQL **backup** and **DB Sync** only; MySQL **restore** is native since v0.8.2 so no MySQL client is needed for it | +| Bundled PostgreSQL client tools | ✅ | Static pg_dump/pg_restore/psql shipped as Tauri resources; system-first, bundled-fallback resolution (v0.7.5). **Both engines now resolve through one headless `db/tool_resolver.rs`** (v0.8.3): trusted absolute dirs (Homebrew, MacPorts, Postgres.app, `/usr/local`) → inherited `PATH` → bundled, with the bundled tools read from the nested `resources/pg_tools` / `resources/mysql_tools` path Tauri actually produces (the pre-0.8.3 join dropped the `resources/` component, so bundled fallback never worked). Candidates must be regular files that run `--version` (1 s per probe, 3 s aggregate). `resources/mysql_tools` (mariadb-dump / mariadb) is required for MySQL **backup** and **DB Sync** only; MySQL restore is native since v0.8.2 | | SQLite .dump (backup/restore) | ✅ | Backup a SQLite database to a portable SQL dump and restore it back, matching the pg_dump UX (v0.7.8) | -| Backup / Restore / Sync for MySQL & SQLite | ✅ | MySQL **backup** via bundled/system `mariadb-dump` (`mysqldump`-compatible, system-first); DB-to-DB sync extended beyond PostgreSQL (v0.7.8). **MySQL restore runs in-process over the sqlx driver** (v0.8.2) — `run_mysql_restore_on` splits the script (`DELIMITER`-aware) and executes it on one connection, **no `mariadb` client and no subprocess**, so a MySQL 8 user authenticating with `caching_sha2_password` (the case the bundled client cannot reach) restores. Stop-at-first-error, cancellable, 100 MB / 1M-statement caps; MySQL **backup and DB Sync still shell out to the bundled MariaDB client** | +| Backup / Restore / Sync for MySQL & SQLite | ✅ | MySQL **backup** via the resolved dump client — system `mariadb-dump`, then system `mysqldump` (this is what Homebrew `mysql-client` installs), then the bundled `mariadb-dump`; DB-to-DB sync extended beyond PostgreSQL (v0.7.8). A **non-blocking** client/server version warning is emitted at job start when the resolved dump client's major version is below the server's (MariaDB's legacy `5.5.5--MariaDB` prefix is unwrapped). **MySQL restore runs in-process over the sqlx driver** (v0.8.2) — `run_mysql_restore_on` splits the script (`DELIMITER`-aware) and executes it on one connection, **no `mariadb` client and no subprocess**. MySQL backup and DB Sync still shell out to the resolved dump client | | MySQL `clean` restore semantics | ✅ | `Clean` drops **only the objects the dump recreates** (`CREATE TABLE`/`VIEW`/`PROCEDURE`/`FUNCTION`/`TRIGGER`/`EVENT`), matching `pg_restore --clean` — an unrelated table in the target survives (`db/mysql_clean.rs`, v0.8.2). Deliberately not `DROP DATABASE`. The drops run with `FOREIGN_KEY_CHECKS = 0` (restored right after) so ordering cannot fail the restore. **MySQL DDL auto-commits and cannot be rolled back**, so a failure stops at the failing statement (reported with its position) and the Restore form warns that a failed restore "may leave this database partially applied" | | `.sql` file association (open with Gridline) | ✅ | `bundle.fileAssociations` in `tauri.conf.json` registers `.sql` as **SQL Script** (`role: Editor`, **`rank: Alternate`** — Gridline never hijacks the default handler, it only appears under *Open With*, v0.8.2). The OS path is validated (absolute, `.sql` extension, real file, ≤5 MB — `MAX_SQL_FILE_BYTES`, symlinks not followed) and queued in Rust, then drained by the **argument-free** `take_pending_sql_files` command, so the webview can never name a file to read. The text lands in a query tab named after the file and is **never executed** — Run still goes through the destructive-query guard. Re-opening the same file focuses the existing tab without discarding edits. With no connection open the file is stashed and Home shows a banner prompting for one (macOS `RunEvent::Opened`; Windows/Linux argv + single-instance forward) | | Schema diff / compare | ✅ | Tools → Schema Diff; same-engine snapshot diff; safe items stage into the changes queue (v0.8.0) | diff --git a/ROADMAP.md b/ROADMAP.md index d88cc62..2f730f0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -134,7 +134,7 @@ Still deferred from 0.7.0, slated for this bucket: ## ✅ Shipped (0.7.8) - **SQLite `.dump` support** — backup a SQLite database to a portable SQL dump and restore it back, matching the pg_dump UX -- **Backup / Restore / Sync for MySQL & SQLite** — the pg-only tooling extended: MySQL backup/restore via `mysqldump` (system-first) and DB-to-DB sync beyond PostgreSQL +- **Backup / Restore / Sync for MySQL & SQLite** — the pg-only tooling extended: MySQL backup via the resolved `mariadb-dump`/`mysqldump` client (system-first, bundled fallback) with a native in-process restore, plus DB-to-DB sync beyond PostgreSQL - **Excel (.xlsx) export** — alongside CSV/JSON/SQL/Markdown in the grid export toolbar - **Cancel long-running queries** — per-connection cancel button (`pg_cancel_backend` and equivalents) instead of waiting or killing the app - **Settings export / import** — share theme, accent, editor options, page sizes, and defaults across machines (JSON file) diff --git a/desktop/src-tauri/icons/128x128.png b/desktop/src-tauri/icons/128x128.png index 0d20461..f518e2b 100644 Binary files a/desktop/src-tauri/icons/128x128.png and b/desktop/src-tauri/icons/128x128.png differ diff --git a/desktop/src-tauri/icons/128x128@2x.png b/desktop/src-tauri/icons/128x128@2x.png index 969794f..56b88c4 100644 Binary files a/desktop/src-tauri/icons/128x128@2x.png and b/desktop/src-tauri/icons/128x128@2x.png differ diff --git a/desktop/src-tauri/icons/32x32.png b/desktop/src-tauri/icons/32x32.png index 1e2baf5..ac23d27 100644 Binary files a/desktop/src-tauri/icons/32x32.png and b/desktop/src-tauri/icons/32x32.png differ diff --git a/desktop/src-tauri/icons/64x64.png b/desktop/src-tauri/icons/64x64.png index c7f74e0..ffe865c 100644 Binary files a/desktop/src-tauri/icons/64x64.png and b/desktop/src-tauri/icons/64x64.png differ diff --git a/desktop/src-tauri/icons/Square107x107Logo.png b/desktop/src-tauri/icons/Square107x107Logo.png index 19f6cd6..3e7ad58 100644 Binary files a/desktop/src-tauri/icons/Square107x107Logo.png and b/desktop/src-tauri/icons/Square107x107Logo.png differ diff --git a/desktop/src-tauri/icons/Square142x142Logo.png b/desktop/src-tauri/icons/Square142x142Logo.png index 0611c12..9ba93c5 100644 Binary files a/desktop/src-tauri/icons/Square142x142Logo.png and b/desktop/src-tauri/icons/Square142x142Logo.png differ diff --git a/desktop/src-tauri/icons/Square150x150Logo.png b/desktop/src-tauri/icons/Square150x150Logo.png index 6b6fd33..2e5db62 100644 Binary files a/desktop/src-tauri/icons/Square150x150Logo.png and b/desktop/src-tauri/icons/Square150x150Logo.png differ diff --git a/desktop/src-tauri/icons/Square284x284Logo.png b/desktop/src-tauri/icons/Square284x284Logo.png index 9d4fd22..9e6aa18 100644 Binary files a/desktop/src-tauri/icons/Square284x284Logo.png and b/desktop/src-tauri/icons/Square284x284Logo.png differ diff --git a/desktop/src-tauri/icons/Square30x30Logo.png b/desktop/src-tauri/icons/Square30x30Logo.png index b6a0ee9..320969f 100644 Binary files a/desktop/src-tauri/icons/Square30x30Logo.png and b/desktop/src-tauri/icons/Square30x30Logo.png differ diff --git a/desktop/src-tauri/icons/Square310x310Logo.png b/desktop/src-tauri/icons/Square310x310Logo.png index dd46acc..83e38f8 100644 Binary files a/desktop/src-tauri/icons/Square310x310Logo.png and b/desktop/src-tauri/icons/Square310x310Logo.png differ diff --git a/desktop/src-tauri/icons/Square44x44Logo.png b/desktop/src-tauri/icons/Square44x44Logo.png index b51fca8..2fe1cfa 100644 Binary files a/desktop/src-tauri/icons/Square44x44Logo.png and b/desktop/src-tauri/icons/Square44x44Logo.png differ diff --git a/desktop/src-tauri/icons/Square71x71Logo.png b/desktop/src-tauri/icons/Square71x71Logo.png index 11db2a4..5a3bee0 100644 Binary files a/desktop/src-tauri/icons/Square71x71Logo.png and b/desktop/src-tauri/icons/Square71x71Logo.png differ diff --git a/desktop/src-tauri/icons/Square89x89Logo.png b/desktop/src-tauri/icons/Square89x89Logo.png index fdae39b..ad436c2 100644 Binary files a/desktop/src-tauri/icons/Square89x89Logo.png and b/desktop/src-tauri/icons/Square89x89Logo.png differ diff --git a/desktop/src-tauri/icons/StoreLogo.png b/desktop/src-tauri/icons/StoreLogo.png index c2447bf..2994200 100644 Binary files a/desktop/src-tauri/icons/StoreLogo.png and b/desktop/src-tauri/icons/StoreLogo.png differ diff --git a/desktop/src-tauri/icons/icon.icns b/desktop/src-tauri/icons/icon.icns index 0be39a5..1b61d30 100644 Binary files a/desktop/src-tauri/icons/icon.icns and b/desktop/src-tauri/icons/icon.icns differ diff --git a/desktop/src-tauri/icons/icon.ico b/desktop/src-tauri/icons/icon.ico index 9911f38..de821ac 100644 Binary files a/desktop/src-tauri/icons/icon.ico and b/desktop/src-tauri/icons/icon.ico differ diff --git a/desktop/src-tauri/icons/icon.png b/desktop/src-tauri/icons/icon.png index c2d622b..3729f95 100644 Binary files a/desktop/src-tauri/icons/icon.png and b/desktop/src-tauri/icons/icon.png differ diff --git a/desktop/src-tauri/src/commands/backup.rs b/desktop/src-tauri/src/commands/backup.rs index b28c383..43ba1dc 100644 --- a/desktop/src-tauri/src/commands/backup.rs +++ b/desktop/src-tauri/src/commands/backup.rs @@ -3,6 +3,7 @@ use std::io::Write; use std::process::{Command, Stdio}; use tauri::{AppHandle, Emitter, Manager, State}; +use crate::db::tool_resolver::{self, ToolRequest, ToolResolution}; use crate::models::backup::*; // --------------------------------------------------------------------------- @@ -40,74 +41,106 @@ impl PgConnParams { // Helpers // --------------------------------------------------------------------------- -fn get_version(tool: &str) -> Option { - Command::new(tool) - .arg("--version") - .output() - .ok() - .and_then(|o| String::from_utf8(o.stdout).ok()) - .map(|s| s.trim().to_string()) -} - fn sanitize_error(s: &str) -> String { crate::commands::test_connection::sanitize_error(s) } -fn bundled_bin_name(tool: &str) -> String { - if cfg!(windows) { format!("{tool}.exe") } else { tool.to_string() } -} +/// Tauri-facing one-shot resolution with the process-lifetime cache. +/// `force` bypasses the cache for an explicit user re-check. +fn resolve_request(app: &AppHandle, request: &ToolRequest, force: bool) -> ToolResolution { + let key = request.cache_key(); + if !force { + if let Some(hit) = tool_resolver::cache_get(&key) { + return hit; + } + } -/// Pure resolution decision (unit-testable): system > bundled > bare name. -fn pick_tool(system_ok: bool, bundled: Option<&str>, tool: &str) -> (String, Option) { - if system_ok { return (tool.to_string(), Some("system".to_string())); } - if let Some(b) = bundled { return (b.to_string(), Some("bundled".to_string())); } - (tool.to_string(), None) + let resource_dir = app.path().resource_dir().ok(); + let bundled = resource_dir + .as_ref() + .map(|rd| tool_resolver::bundled_tool_path(rd, request.subdir, request.bundled_name())); + let trusted = tool_resolver::trusted_dirs(); + let start = std::time::Instant::now(); + let probe = |p: &std::path::Path| { + tool_resolver::probe_within(start, tool_resolver::AGGREGATE_DEADLINE, p) + }; + let resolved = tool_resolver::resolve_with( + request, + &trusted, + &tool_resolver::path_lookup, + bundled.as_deref(), + probe, + ); + let version = resolved + .as_ref() + .and_then(|r| tool_resolver::version_of(&r.program)); + let bundled_available = bundled + .as_deref() + .map(tool_resolver::bundled_present) + .unwrap_or(false); + + let out = ToolResolution { + resolved, + bundled_path: bundled, + bundled_available, + version, + }; + tool_resolver::cache_put(&key, out.clone()); + out } /// System-first, bundled-fallback resolver. Returns (command_to_invoke, source). pub fn resolve_tool(app: &AppHandle, tool: &str) -> (String, Option) { - let system_ok = Command::new(tool).arg("--version").output().is_ok(); - let bundled = app.path().resource_dir().ok() - .map(|rd| rd.join("pg_tools").join(bundled_bin_name(tool))) - .filter(|p| p.exists()) - .map(|p| p.to_string_lossy().to_string()); - pick_tool(system_ok, bundled.as_deref(), tool) + match tool_resolver::pg_request(tool) { + Some(request) => { + tool_resolver::adapt_resolution(&resolve_request(app, &request, false), tool) + } + None => (tool.to_string(), None), + } } pub fn resolve_tool_paths(app: &AppHandle) -> PgToolPaths { - let (d, _) = resolve_tool(app, "pg_dump"); - let (r, _) = resolve_tool(app, "pg_restore"); - let (p, _) = resolve_tool(app, "psql"); - PgToolPaths { pg_dump: d, pg_restore: r, psql: p } + PgToolPaths { + pg_dump: resolve_tool(app, "pg_dump").0, + pg_restore: resolve_tool(app, "pg_restore").0, + psql: resolve_tool(app, "psql").0, + } } // --------------------------------------------------------------------------- // detect_pg_tools // --------------------------------------------------------------------------- -/// Shapes the tool status from resolved tool paths + sources. Headless so the -/// Tauri command stays thin and the status logic stays unit-testable. -fn build_pg_tool_status( - dump: &str, - restore: &str, - dump_src: Option, - restore_src: Option, -) -> PgToolStatus { +/// Shapes both PG statuses from resolved tools. Headless so the Tauri command +/// stays thin and the shaping logic stays unit-testable. +fn build_pg_tool_status(dump: &ToolResolution, restore: &ToolResolution) -> PgToolStatus { PgToolStatus { - pg_dump_found: Command::new(dump).arg("--version").output().is_ok(), - pg_restore_found: Command::new(restore).arg("--version").output().is_ok(), - pg_dump_version: get_version(dump), - pg_restore_version: get_version(restore), - pg_dump_source: dump_src, - pg_restore_source: restore_src, + pg_dump_found: dump.resolved.is_some(), + pg_restore_found: restore.resolved.is_some(), + pg_dump_version: dump.version.clone(), + pg_restore_version: restore.version.clone(), + pg_dump_source: dump.resolved.as_ref().map(|r| r.source.as_str().to_string()), + pg_restore_source: restore.resolved.as_ref().map(|r| r.source.as_str().to_string()), + pg_dump_resolved_name: dump.resolved.as_ref().map(|r| r.name.clone()), + pg_restore_resolved_name: restore.resolved.as_ref().map(|r| r.name.clone()), + pg_dump_bundled_available: dump.bundled_available, + pg_restore_bundled_available: restore.bundled_available, } } +/// Detection runs off the UI thread and can be forced past the cache. #[tauri::command] -pub fn detect_pg_tools(app_handle: AppHandle) -> PgToolStatus { - let (dump, dump_src) = resolve_tool(&app_handle, "pg_dump"); - let (restore, restore_src) = resolve_tool(&app_handle, "pg_restore"); - build_pg_tool_status(&dump, &restore, dump_src, restore_src) +pub async fn detect_pg_tools(force: Option, app_handle: AppHandle) -> PgToolStatus { + let force = force.unwrap_or(false); + tauri::async_runtime::spawn_blocking(move || { + let dump = resolve_request(&app_handle, &tool_resolver::pg_dump_request(), force); + let restore = resolve_request(&app_handle, &tool_resolver::pg_restore_request(), force); + build_pg_tool_status(&dump, &restore) + }) + .await + .unwrap_or_else(|_| { + build_pg_tool_status(&ToolResolution::none(), &ToolResolution::none()) + }) } // --------------------------------------------------------------------------- @@ -501,50 +534,109 @@ fn base_mysql_args(conn: &MySqlConnParams) -> Vec { ] } +/// Whether a resolved client is MariaDB's. MariaDB's clients print a +/// `...-MariaDB...` banner from `--version`; MySQL's print the Oracle banner. +/// This matters because MariaDB's clients — including the `mysqldump`/`mysql` +/// compatibility names some MariaDB packages ship — reject several MySQL-only +/// options with "unknown option", aborting the dump before it connects. +/// Probed once per program and cached for the process. +fn client_is_mariadb(program: &str) -> bool { + static CACHE: std::sync::OnceLock< + std::sync::Mutex>, + > = std::sync::OnceLock::new(); + let cache = CACHE.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new())); + + if let Ok(map) = cache.lock() { + if let Some(hit) = map.get(program) { + return *hit; + } + } + + let is_mariadb = Command::new(program) + .arg("--version") + .output() + .map(|o| { + let mut text = String::from_utf8_lossy(&o.stdout).to_string(); + text.push_str(&String::from_utf8_lossy(&o.stderr)); + text.contains("MariaDB") + }) + .unwrap_or(false); + + if let Ok(mut map) = cache.lock() { + map.insert(program.to_string(), is_mariadb); + } + is_mariadb +} + +/// TLS flags for a dump client. MySQL's clients take `--ssl-mode=`; +/// MariaDB's have no `--ssl-mode` and use the boolean `--ssl`, which is the +/// equivalent of the only mode this code path uses (`REQUIRED`). +pub fn tls_args_for_client(client_is_mariadb: bool, tls_mode: Option<&str>) -> Vec { + match (client_is_mariadb, tls_mode) { + (true, Some("REQUIRED")) => vec!["--ssl".into()], + (true, _) => Vec::new(), + (false, Some(m)) => vec![format!("--ssl-mode={m}")], + (false, None) => Vec::new(), + } +} + /// `mariadb-dump`/`mysqldump` args. Passwords go via MYSQL_PWD env (set by the /// command), NEVER --password (process-list visibility). -pub fn build_mysql_dump_args(conn: &MySqlConnParams, options: &MySqlBackupOptions) -> Vec { +/// +/// `client_is_mariadb` gates the MySQL-only options: MariaDB's dump rejects +/// `--skip-column-statistics`. `--databases` is always emitted as a boolean +/// flag followed by the database name — `--databases=` is rejected by +/// clients as an invalid value. +pub fn build_mysql_dump_args( + conn: &MySqlConnParams, + options: &MySqlBackupOptions, + client_is_mariadb: bool, +) -> Vec { let mut a = base_mysql_args(conn); if options.single_transaction { a.push("--single-transaction".into()); } if options.no_data { a.push("--no-data".into()); } if options.routines { a.push("--routines".into()); } if options.triggers { a.push("--triggers".into()); } if options.events { a.push("--events".into()); } - a.push(format!("--databases={}", options.database)); + a.push("--databases".into()); + a.push(options.database.clone()); a.push(format!("--result-file={}", options.file_path)); - a.push("--skip-column-statistics".into()); + if !client_is_mariadb { + a.push("--skip-column-statistics".into()); + } a } -/// System-first mariadb-dump/mariadb (bundled fallback in resources/mysql_tools). +/// System-first mariadb-dump/mysqldump resolution (bundled fallback). pub fn resolve_mysql_tool(app: &AppHandle, tool: &str) -> (String, Option) { - let system_ok = Command::new(tool).arg("--version").output().is_ok(); - let bundled = app.path().resource_dir().ok() - .map(|rd| rd.join("mysql_tools").join(bundled_bin_name(tool))) - .filter(|p| p.exists()) - .map(|p| p.to_string_lossy().to_string()); - pick_tool(system_ok, bundled.as_deref(), tool) + match tool_resolver::mysql_request(tool) { + Some(request) => { + tool_resolver::adapt_resolution(&resolve_request(app, &request, false), tool) + } + None => (tool.to_string(), None), + } } pub fn resolve_mysql_tool_paths(app: &AppHandle) -> MySqlToolPaths { - let (d, _) = resolve_mysql_tool(app, "mariadb-dump"); - let (m, _) = resolve_mysql_tool(app, "mariadb"); - MySqlToolPaths { mysqldump: d, mysql: m } + MySqlToolPaths { + mysqldump: resolve_mysql_tool(app, "mariadb-dump").0, + mysql: resolve_mysql_tool(app, "mariadb").0, + } } /// Headless core: spawn dump with MYSQL_PWD env. `tls_mode` (e.g. `REQUIRED`) -/// is appended as `--ssl-mode=` when routing through an SSH tunnel. (Live -/// behavior = #[ignore] integration test.) +/// is translated per client — `--ssl-mode=` on MySQL clients, `--ssl` on +/// MariaDB's (see `tls_args_for_client`) when routing through an SSH tunnel. +/// (Live behavior = #[ignore] integration test.) pub fn run_mysql_dump( conn: &MySqlConnParams, options: &MySqlBackupOptions, tools: &MySqlToolPaths, tls_mode: Option<&str>, ) -> Result<(), String> { - let mut args = build_mysql_dump_args(conn, options); - if let Some(m) = tls_mode { - args.push(format!("--ssl-mode={m}")); - } + let is_mariadb = client_is_mariadb(&tools.mysqldump); + let mut args = build_mysql_dump_args(conn, options, is_mariadb); + args.extend(tls_args_for_client(is_mariadb, tls_mode)); let out = Command::new(&tools.mysqldump).env("MYSQL_PWD", &conn.password).args(&args).output() .map_err(|e| e.to_string())?; if out.status.success() { Ok(()) } else { Err(sanitize_error(&String::from_utf8_lossy(&out.stderr))) } @@ -556,13 +648,13 @@ pub fn run_mysql_sync( tools: &MySqlToolPaths, tls_mode: Option<&str>, ) -> Result<(), String> { + let is_mariadb = client_is_mariadb(&tools.mysqldump); let mut dump_args = base_mysql_args(source); dump_args.push("--single-transaction".into()); dump_args.push("--add-drop-table".into()); - dump_args.push(format!("--databases={}", source.database)); - if let Some(m) = tls_mode { - dump_args.push(format!("--ssl-mode={m}")); - } + dump_args.push("--databases".into()); + dump_args.push(source.database.clone()); + dump_args.extend(tls_args_for_client(is_mariadb, tls_mode)); let mut dump = Command::new(&tools.mysqldump).env("MYSQL_PWD", &source.password).args(&dump_args).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().map_err(|e| format!("dump: {e}"))?; let stdout = dump.stdout.take().unwrap(); let mut restore_args = base_mysql_args(target); @@ -729,6 +821,7 @@ fn emit_result(app_handle: &AppHandle, job_id: &str, result: Result<(), String>) progress: Some(1.0), output_line: None, error: None, + warning: None, }, Err(e) => BackupProgressEvent { job_id: job_id.to_string(), @@ -736,6 +829,7 @@ fn emit_result(app_handle: &AppHandle, job_id: &str, result: Result<(), String>) progress: None, output_line: None, error: Some(e), + warning: None, }, }; let _ = app_handle.emit("backup-progress", event); @@ -924,20 +1018,40 @@ pub async fn db_sync( // MySQL dump / restore / sync commands (v0.7.8) // --------------------------------------------------------------------------- -#[tauri::command] -pub fn detect_mysql_tools(app_handle: AppHandle) -> MySqlToolStatus { - let (d, ds) = resolve_mysql_tool(&app_handle, "mariadb-dump"); - let (m, ms) = resolve_mysql_tool(&app_handle, "mariadb"); +fn build_mysql_tool_status(dump: &ToolResolution, client: &ToolResolution) -> MySqlToolStatus { MySqlToolStatus { - mysqldump_found: Command::new(&d).arg("--version").output().is_ok(), - mysql_found: Command::new(&m).arg("--version").output().is_ok(), - mysqldump_version: get_version(&d), - mysql_version: get_version(&m), - mysqldump_source: ds, - mysql_source: ms, + mysqldump_found: dump.resolved.is_some(), + mysql_found: client.resolved.is_some(), + mysqldump_version: dump.version.clone(), + mysql_version: client.version.clone(), + mysqldump_source: dump.resolved.as_ref().map(|r| r.source.as_str().to_string()), + mysql_source: client.resolved.as_ref().map(|r| r.source.as_str().to_string()), + mysqldump_resolved_name: dump.resolved.as_ref().map(|r| r.name.clone()), + mysql_resolved_name: client.resolved.as_ref().map(|r| r.name.clone()), + mysqldump_is_mariadb: dump + .resolved + .as_ref() + .map(|r| r.name.starts_with("mariadb")) + .unwrap_or(false), + mysqldump_bundled_available: dump.bundled_available, + mysql_bundled_available: client.bundled_available, } } +#[tauri::command] +pub async fn detect_mysql_tools(force: Option, app_handle: AppHandle) -> MySqlToolStatus { + let force = force.unwrap_or(false); + tauri::async_runtime::spawn_blocking(move || { + let dump = resolve_request(&app_handle, &tool_resolver::mysql_dump_request(), force); + let client = resolve_request(&app_handle, &tool_resolver::mysql_client_request(), force); + build_mysql_tool_status(&dump, &client) + }) + .await + .unwrap_or_else(|_| { + build_mysql_tool_status(&ToolResolution::none(), &ToolResolution::none()) + }) +} + /// Resolve (host, port, via_tunnel) for a MySQL connection, routing through the /// SSH tunnel endpoint when present. fn mysql_endpoint( @@ -952,6 +1066,68 @@ fn mysql_endpoint( (conn.host.clone(), conn.port.unwrap_or(3306), false) } +/// Advisory server version for the compatibility check. Fail-open by design: +/// any error (TLS, SSH tunnel, auth) returns `None`, and the dump proceeds. +async fn mysql_server_version( + host: &str, + port: i64, + via_tunnel: bool, + conn: &crate::models::Connection, + password: &str, +) -> Option { + let database = conn.database.clone().unwrap_or_default(); + let original_host = conn.host.clone(); + let ssl_mode = conn.ssl_mode.clone(); + let ssl_ca = conn.ssl_ca_path.clone(); + let username = conn.username.clone().unwrap_or_else(|| "root".into()); + let mut c = crate::commands::db_viewer::open_mysql_connection( + host, + port as u16, + &username, + password, + &database, + &original_host, + ssl_mode.as_deref(), + ssl_ca.as_deref(), + via_tunnel, + ) + .await + .ok()?; + sqlx::query_scalar::<_, String>("SELECT VERSION()") + .fetch_one(&mut c) + .await + .ok() +} + +/// Emit the advisory warning when the resolved dump client is clearly older +/// than the server. Never returns an error — the job always proceeds. +async fn maybe_emit_version_warning( + app_handle: &AppHandle, + job_id: &str, + resolution: &ToolResolution, + server_version: Option, +) { + let (Some(client_version), Some(tool)) = (&resolution.version, &resolution.resolved) else { + return; + }; + let Some(server_version) = server_version else { return }; + let Some(warning) = tool_resolver::warning_for(client_version, &server_version, &tool.name) + else { + return; + }; + let _ = app_handle.emit( + "backup-progress", + BackupProgressEvent { + job_id: job_id.to_string(), + status: "running".into(), + progress: Some(0.0), + output_line: None, + error: None, + warning: Some(warning), + }, + ); +} + #[tauri::command] pub async fn mysql_dump( connection_id: String, @@ -976,6 +1152,15 @@ pub async fn mysql_dump( .unwrap_or_default(); let (host, port, via_tunnel) = mysql_endpoint(&state, &connection_id, &conn); + // Advisory client/server compatibility check — never blocks the job, and + // only pays for a connection when the resolved client reported a version. + let dump_resolution = resolve_request(&app_handle, &tool_resolver::mysql_dump_request(), false); + let server_version = if dump_resolution.version.is_some() { + mysql_server_version(&host, port, via_tunnel, &conn, &password).await + } else { + None + }; + maybe_emit_version_warning(&app_handle, &job_id, &dump_resolution, server_version).await; let params = MySqlConnParams::new( host, port, @@ -1085,6 +1270,7 @@ pub async fn mysql_restore( progress: Some(done as f64 / total.max(1) as f64), output_line: Some(format!("Statement {done} of {total}")), error: None, + warning: None, }, ); }; @@ -1198,6 +1384,23 @@ pub async fn mysql_sync( let (src_host, src_port, src_via_tunnel) = mysql_endpoint(&state, &source_conn.id, &source_conn); let (tgt_host, tgt_port, tgt_via_tunnel) = mysql_endpoint(&state, &target_conn.id, &target_conn); + // Advisory client/server compatibility check — never blocks the job, and + // only pays for a connection when the resolved client reported a version. + let dump_resolution = resolve_request(&app_handle, &tool_resolver::mysql_dump_request(), false); + let server_version = if dump_resolution.version.is_some() { + mysql_server_version( + &src_host, + src_port, + src_via_tunnel, + &source_conn, + &src_password, + ) + .await + } else { + None + }; + maybe_emit_version_warning(&app_handle, &job_id, &dump_resolution, server_version).await; + let source = MySqlConnParams::new( src_host, src_port, diff --git a/desktop/src-tauri/src/commands/backup.test.rs b/desktop/src-tauri/src/commands/backup.test.rs index 99472f1..eaec1f3 100644 --- a/desktop/src-tauri/src/commands/backup.test.rs +++ b/desktop/src-tauri/src/commands/backup.test.rs @@ -132,15 +132,44 @@ fn build_pg_restore_args_with_schema() { // ------------------------------------------------------------------ #[test] -fn detect_pg_tools_does_not_panic() { - // detect_pg_tools needs a Tauri AppHandle; the headless core keeps the - // status-shaping logic testable without one. - let status = build_pg_tool_status("pg_dump", "pg_restore", None, None); - // May or may not find tools, but the call itself must not panic - let _ = status.pg_dump_found; - let _ = status.pg_restore_found; - let _ = status.pg_dump_version; - let _ = status.pg_restore_version; +fn build_pg_tool_status_reflects_resolutions() { + let dump = ToolResolution { + resolved: Some(crate::db::tool_resolver::Resolution { + program: "/opt/homebrew/bin/mariadb-dump".into(), + source: crate::db::tool_resolver::ToolSource::TrustedDir, + name: "pg_dump".into(), + }), + bundled_path: Some(std::path::PathBuf::from("/app/resources/pg_tools/pg_dump")), + bundled_available: true, + version: Some("pg_dump (PostgreSQL) 16.4".into()), + }; + let status = build_pg_tool_status(&dump, &ToolResolution::none()); + assert!(status.pg_dump_found); + assert!(!status.pg_restore_found); + assert_eq!(status.pg_dump_source.as_deref(), Some("system")); + assert_eq!(status.pg_dump_resolved_name.as_deref(), Some("pg_dump")); + assert!(status.pg_dump_bundled_available); + assert!(!status.pg_restore_bundled_available); +} + +#[test] +fn build_mysql_tool_status_reports_the_resolved_dump_name() { + let dump = ToolResolution { + resolved: Some(crate::db::tool_resolver::Resolution { + program: "/opt/homebrew/bin/mariadb-dump".into(), + source: crate::db::tool_resolver::ToolSource::TrustedDir, + name: "mariadb-dump".into(), + }), + bundled_path: Some(std::path::PathBuf::from("/app/resources/mysql_tools/mariadb-dump")), + bundled_available: true, + version: Some("mariadb-dump 11.4.5-MariaDB".into()), + }; + let status = build_mysql_tool_status(&dump, &ToolResolution::none()); + assert!(status.mysqldump_found); + assert!(!status.mysql_found); + assert_eq!(status.mysqldump_resolved_name.as_deref(), Some("mariadb-dump")); + assert!(status.mysqldump_is_mariadb); + assert!(!status.mysql_bundled_available); } #[test] @@ -152,6 +181,10 @@ fn pg_tool_status_serialization() { pg_restore_version: None, pg_dump_source: None, pg_restore_source: None, + pg_dump_resolved_name: Some("pg_dump".into()), + pg_restore_resolved_name: None, + pg_dump_bundled_available: true, + pg_restore_bundled_available: false, }; let json = serde_json::to_string(&status).unwrap(); assert!(json.contains("pg_dump_found")); @@ -171,6 +204,7 @@ fn backup_progress_event_completed() { progress: Some(1.0), output_line: None, error: None, + warning: None, }; let json = serde_json::to_string(&evt).unwrap(); assert!(json.contains("\"completed\"")); @@ -185,12 +219,41 @@ fn backup_progress_event_failed() { progress: None, output_line: None, error: Some("connection refused".into()), + warning: None, }; let json = serde_json::to_string(&evt).unwrap(); assert!(json.contains("\"failed\"")); assert!(json.contains("\"connection refused\"")); } +#[test] +fn backup_progress_event_serializes_a_non_fatal_warning() { + let evt = BackupProgressEvent { + job_id: "job-warn".into(), + status: "running".into(), + progress: Some(0.0), + output_line: None, + error: None, + warning: Some("Client/server version mismatch: …".into()), + }; + let json = serde_json::to_string(&evt).unwrap(); + assert!(json.contains("\"warning\":\"Client/server version mismatch"), "{json}"); +} + +#[test] +fn backup_progress_event_serializes_a_null_warning_when_absent() { + let evt = BackupProgressEvent { + job_id: "job-quiet".into(), + status: "completed".into(), + progress: Some(1.0), + output_line: None, + error: None, + warning: None, + }; + let json = serde_json::to_string(&evt).unwrap(); + assert!(json.contains("\"warning\":null"), "{json}"); +} + // ------------------------------------------------------------------ // Integration tests (headless, against live DBs) // @@ -406,19 +469,6 @@ fn integration_plain_dump_restore() { // Tool resolution (Task 2.1: system-first, bundled-fallback) // ------------------------------------------------------------------ -#[test] -fn pick_tool_prefers_system_then_bundled_then_bare() { - assert_eq!(pick_tool(true, None, "pg_dump"), ("pg_dump".to_string(), Some("system".into()))); - assert_eq!(pick_tool(false, Some("/r/pg_dump"), "pg_dump"), ("/r/pg_dump".to_string(), Some("bundled".into()))); - assert_eq!(pick_tool(false, None, "pg_dump"), ("pg_dump".to_string(), None)); -} - -#[test] -fn bundled_bin_name_appends_exe_on_windows() { - let name = bundled_bin_name("pg_dump"); - if cfg!(windows) { assert_eq!(name, "pg_dump.exe"); } else { assert_eq!(name, "pg_dump"); } -} - // ------------------------------------------------------------------ // SQLite .dump / restore / sync core (Task 2.2) // ------------------------------------------------------------------ @@ -507,11 +557,20 @@ fn mysql_dump_args_single_transaction_no_data_routines() { database: "shop".into(), file_path: "/tmp/d.sql".into(), single_transaction: true, no_data: true, routines: true, triggers: false, events: false, }; - let args = build_mysql_dump_args(&MySqlConnParams::new("h".into(), 3306, "u".into(), "shop".into(), "p".into()), &opts); + let args = build_mysql_dump_args( + &MySqlConnParams::new("h".into(), 3306, "u".into(), "shop".into(), "p".into()), + &opts, + false, + ); assert!(args.iter().any(|a| a == "--single-transaction")); assert!(args.iter().any(|a| a == "--no-data")); assert!(args.iter().any(|a| a == "--routines")); - assert!(args.iter().any(|a| a == "--databases=shop")); + // `--databases` is a BOOLEAN flag: the database name must be a separate + // positional argument. `--databases=shop` is rejected by clients as + // "invalid value 'shop'". + let i = args.iter().position(|a| a == "--databases").expect("--databases flag"); + assert_eq!(args[i + 1], "shop"); + assert!(!args.iter().any(|a| a.starts_with("--databases=")), "{args:?}"); assert!(args.iter().any(|a| a == "--result-file=/tmp/d.sql")); // no --password on the command line (uses MYSQL_PWD env) assert!(args.iter().all(|a| !a.starts_with("--password"))); @@ -520,10 +579,93 @@ fn mysql_dump_args_single_transaction_no_data_routines() { #[test] fn mysql_env_uses_mysql_pwd_not_password_arg() { let opts = MySqlBackupOptions { database: "db".into(), file_path: "/tmp/x.sql".into(), single_transaction: false, no_data: false, routines: false, triggers: false, events: false }; - let args = build_mysql_dump_args(&MySqlConnParams::new("h".into(), 3306, "u".into(), "db".into(), "p".into()), &opts); + let args = build_mysql_dump_args( + &MySqlConnParams::new("h".into(), 3306, "u".into(), "db".into(), "p".into()), + &opts, + false, + ); assert!(args.iter().all(|a| !a.starts_with("--password"))); } +#[test] +fn mysql_dump_args_omit_column_statistics_for_mariadb_clients() { + // The bundled client is MariaDB 11.4.5 and rejects the MySQL-8-only option + // with `unknown option '--skip-column-statistics'`, aborting the dump. + let opts = MySqlBackupOptions { + database: "shop".into(), file_path: "/tmp/d.sql".into(), + single_transaction: true, no_data: false, routines: true, triggers: true, events: false, + }; + let conn = MySqlConnParams::new("h".into(), 3306, "u".into(), "shop".into(), "p".into()); + let mariadb = build_mysql_dump_args(&conn, &opts, true); + assert!(!mariadb.iter().any(|a| a == "--skip-column-statistics"), "{mariadb:?}"); + let mysql = build_mysql_dump_args(&conn, &opts, false); + assert!(mysql.iter().any(|a| a == "--skip-column-statistics"), "{mysql:?}"); +} + +#[test] +fn tls_args_map_ssl_mode_only_for_mysql_clients() { + // MySQL clients take `--ssl-mode=`; MariaDB clients have no such + // option and use the boolean `--ssl`. + assert_eq!(tls_args_for_client(false, Some("REQUIRED")), vec!["--ssl-mode=REQUIRED".to_string()]); + assert_eq!(tls_args_for_client(true, Some("REQUIRED")), vec!["--ssl".to_string()]); + assert_eq!(tls_args_for_client(false, None), Vec::::new()); + assert_eq!(tls_args_for_client(true, None), Vec::::new()); + assert_eq!(tls_args_for_client(true, Some("DISABLED")), Vec::::new()); +} + +#[test] +fn client_is_mariadb_reads_the_version_banner() { + // A program that cannot be executed must not panic — it reports false + // (keep the MySQL-only flags), and the dump fails at spawn anyway. + assert!(!client_is_mariadb("/nonexistent/definitely-not-a-tool")); +} + +/// Regression guard for the bundled fallback path: the args we generate must be +/// *accepted* by the real bundled MariaDB client. It spawns the staged binary +/// against a closed port and requires the failure to be a CONNECTION error — +/// never an option-parsing error. No live server needed. +/// +/// The binaries are gitignored (the release workflow downloads them), so when +/// they are not staged this test logs loudly and returns instead of failing. +#[test] +fn bundled_mariadb_dump_accepts_our_generated_args() { + let bundled = std::path::PathBuf::from(format!( + "{}/resources/mysql_tools", + env!("CARGO_MANIFEST_DIR") + )) + .join(crate::db::tool_resolver::bundled_bin_name("mariadb-dump")); + if !bundled.is_file() { + eprintln!( + "SKIPPED bundled_mariadb_dump_accepts_our_generated_args: {bundled:?} is not staged \ + (run the release workflow's tool-staging step to enable this guard)" + ); + return; + } + + let conn = MySqlConnParams::new("127.0.0.1".into(), 1, "root".into(), "mysql".into(), "unused".into()); + let opts = MySqlBackupOptions { + database: "mysql".into(), + file_path: std::env::temp_dir().join("gridline_args_guard.sql").to_string_lossy().to_string(), + single_transaction: true, no_data: false, routines: true, triggers: true, events: false, + }; + assert!(client_is_mariadb(bundled.to_str().unwrap()), "the bundled dump tool must be MariaDB's"); + + let mut args = build_mysql_dump_args(&conn, &opts, true); + args.extend(tls_args_for_client(true, Some("REQUIRED"))); + + let out = std::process::Command::new(&bundled).args(&args).output().unwrap(); + let err = String::from_utf8_lossy(&out.stderr).to_lowercase(); + // "unknown option" covers `--skip-column-statistics`; "unknown variable" + // covers `--ssl-mode` (MariaDB's own wording for the same class of mistake). + assert!(!err.contains("unknown option"), "bundled client rejected an option: {err}"); + assert!(!err.contains("unknown variable"), "bundled client rejected an option: {err}"); + assert!(!err.contains("invalid value"), "bundled client rejected an option value: {err}"); + assert!( + err.contains("2002") || err.contains("can't connect") || err.contains("connection refused"), + "expected a connection failure (port 1 is closed), got: {err}" + ); +} + // ── native MySQL restore: error reporting contract ────────────────── #[test] @@ -635,3 +777,91 @@ fn restore_size_cap_rejects_a_file_over_the_limit_and_states_both_sizes() { assert!(err.contains("100 MB"), "error must state the limit in MB; got {err:?}"); assert!(err.contains("MB"), "error must state the actual size in MB; got {err:?}"); } + +// ── live MySQL dump: system tool vs. bundled tool ─────────────────── +// +// Issue #44: the app reported "mysqldump not found" even with MySQL +// client tools installed. These two #[ignore]d tests exercise the real +// `run_mysql_dump` against a live server — once via a tool the shared +// resolver found as a *system* client, once via the *bundled* MariaDB +// client — so both provenance paths are covered end to end. +// +// Run with: +// +// GRIDLINE_TEST_MYSQL_HOST=... GRIDLINE_TEST_MYSQL_PORT=... \ +// GRIDLINE_TEST_MYSQL_USER=... GRIDLINE_TEST_MYSQL_DB=... \ +// GRIDLINE_TEST_MYSQL_PASSWORD=... \ +// cargo test --lib live_mysql_dump -- --ignored --nocapture + +fn mysql_conn_from_env(prefix: &str) -> MySqlConnParams { + MySqlConnParams::new( + env(&format!("{prefix}_HOST")), + env(&format!("{prefix}_PORT")).parse().unwrap(), + env(&format!("{prefix}_USER")), + env(&format!("{prefix}_DB")), + env(&format!("{prefix}_PASSWORD")), + ) +} + +fn live_mysql_dump_opts(conn: &MySqlConnParams, file_name: &str) -> MySqlBackupOptions { + MySqlBackupOptions { + database: conn.database.clone(), + file_path: std::env::temp_dir().join(file_name).to_string_lossy().to_string(), + single_transaction: true, + no_data: false, + routines: true, + triggers: true, + events: false, + } +} + +/// Requires GRIDLINE_TEST_MYSQL_{HOST,PORT,USER,DB,PASSWORD} and a system +/// mariadb-dump/mysqldump on this machine. +#[test] +#[ignore] +fn live_mysql_dump_with_a_system_tool() { + let conn = mysql_conn_from_env("GRIDLINE_TEST_MYSQL"); + let opts = live_mysql_dump_opts(&conn, "gridline_live_mysql_system.sql"); + let req = crate::db::tool_resolver::mysql_dump_request(); + let trusted = crate::db::tool_resolver::trusted_dirs(); + let resolved = crate::db::tool_resolver::resolve_with( + &req, + &trusted, + &crate::db::tool_resolver::path_lookup, + None, + crate::db::tool_resolver::probe, + ) + .expect("no system mariadb-dump/mysqldump resolved"); + eprintln!("resolved: {} ({})", resolved.program, resolved.source.as_str()); + let tools = MySqlToolPaths { + mysqldump: resolved.program.clone(), + mysql: resolved.program.clone(), + }; + run_mysql_dump(&conn, &opts, &tools, None).expect("dump failed"); + let len = std::fs::metadata(&opts.file_path).unwrap().len(); + assert!(len > 0, "dump file is empty"); + let _ = std::fs::remove_file(&opts.file_path); +} + +/// Requires GRIDLINE_TEST_MYSQL_* and a staged `resources/mysql_tools` tree. +#[test] +#[ignore] +fn live_mysql_dump_with_the_bundled_tool() { + let conn = mysql_conn_from_env("GRIDLINE_TEST_MYSQL"); + let opts = live_mysql_dump_opts(&conn, "gridline_live_mysql_bundled.sql"); + let bundled = std::path::PathBuf::from(format!( + "{}/resources/mysql_tools", + env!("CARGO_MANIFEST_DIR") + )) + .join(crate::db::tool_resolver::bundled_bin_name("mariadb-dump")); + assert!(bundled.is_file(), "bundled mariadb-dump not staged at {bundled:?}"); + assert!(crate::db::tool_resolver::probe(&bundled), "bundled mariadb-dump does not run"); + let tools = MySqlToolPaths { + mysqldump: bundled.to_string_lossy().to_string(), + mysql: bundled.to_string_lossy().to_string(), + }; + run_mysql_dump(&conn, &opts, &tools, None).expect("bundled dump failed"); + let len = std::fs::metadata(&opts.file_path).unwrap().len(); + assert!(len > 0, "bundled dump file is empty"); + let _ = std::fs::remove_file(&opts.file_path); +} diff --git a/desktop/src-tauri/src/db/mod.rs b/desktop/src-tauri/src/db/mod.rs index 488af83..96fb1f6 100644 --- a/desktop/src-tauri/src/db/mod.rs +++ b/desktop/src-tauri/src/db/mod.rs @@ -6,6 +6,7 @@ pub mod object_crud; pub mod object_ddl; pub mod pool; pub mod tls; +pub mod tool_resolver; pub mod schema_diff; #[allow(unused_imports)] diff --git a/desktop/src-tauri/src/db/tool_resolver.rs b/desktop/src-tauri/src/db/tool_resolver.rs new file mode 100644 index 0000000..6f02d18 --- /dev/null +++ b/desktop/src-tauri/src/db/tool_resolver.rs @@ -0,0 +1,415 @@ +//! Unified, headless external-client-tool resolver shared by the PostgreSQL +//! and MySQL backup/restore/sync paths. +//! +//! Fixes issue #44: bundled tools live under `resource_dir/resources//` +//! (Tauri preserves the `resources/` component from `tauri.conf.json`), not +//! directly under `resource_dir/`. System lookup is ordered — trusted absolute +//! directories first, then the inherited PATH, then bundled — and probes both +//! `mariadb-dump`/`mysqldump` style names. + +use std::collections::HashMap; +use std::ffi::OsStr; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::sync::{Mutex, OnceLock}; +use std::time::{Duration, Instant}; + +/// The nested directory component Tauri injects when copying +/// `bundle.resources`. Pinned by `bundled_prefix_matches_tauri_config_resources`. +pub const BUNDLED_RESOURCE_PREFIX: &str = "resources"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ToolSource { + /// Found in a fixed, trusted absolute directory. + TrustedDir, + /// Found on the inherited `PATH`. + Path, + /// Found in the app's bundled resource directory. + Bundled, +} + +impl ToolSource { + /// The value surfaced to the UI (matches the pre-existing + /// `"system" | "bundled"` strings). + pub fn as_str(self) -> &'static str { + match self { + ToolSource::TrustedDir | ToolSource::Path => "system", + ToolSource::Bundled => "bundled", + } + } +} + +/// A successfully resolved tool: the runnable program plus its provenance. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Resolution { + pub program: String, + pub source: ToolSource, + /// The binary name that resolved (`mariadb-dump`, `mysqldump`, …). + pub name: String, +} + +/// One engine + tool role, e.g. the MySQL dump tool. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ToolRequest { + /// Resource subdirectory name, e.g. `mysql_tools` / `pg_tools`. + pub subdir: &'static str, + /// Candidate binary names, in preference order. + pub names: Vec<&'static str>, +} + +impl ToolRequest { + pub fn new(subdir: &'static str, names: &[&'static str]) -> Self { + Self { subdir, names: names.to_vec() } + } + + /// The bundled binary that ships for this role. + pub fn bundled_name(&self) -> &'static str { + self.names[0] + } + + /// Stable cache key for this role. + pub fn cache_key(&self) -> String { + format!("{}::{}", self.subdir, self.names.join(",")) + } +} + +/// Platform executable suffix. +pub fn exe_suffix() -> &'static str { + if cfg!(windows) { ".exe" } else { "" } +} + +/// `pg_dump` -> `pg_dump` (POSIX) / `pg_dump.exe` (Windows). +pub fn bundled_bin_name(bin: &str) -> String { + format!("{bin}{}", exe_suffix()) +} + +/// Fixed, trusted absolute directories probed before the inherited `PATH`. +/// Windows deliberately returns an empty list (spec §9 Q5): PATH + bundled only. +pub fn trusted_dirs() -> Vec { + let raw: &[&str] = if cfg!(target_os = "macos") { + &[ + "/opt/homebrew/bin", + "/opt/homebrew/opt/mysql-client/bin", + "/opt/homebrew/opt/mariadb/bin", + "/opt/homebrew/opt/libpq/bin", + "/usr/local/bin", + "/usr/local/opt/mysql-client/bin", + "/usr/local/opt/mariadb/bin", + "/usr/local/opt/libpq/bin", + "/opt/local/bin", + "/Applications/Postgres.app/Contents/Versions/latest/bin", + ] + } else if cfg!(target_os = "linux") { + &["/usr/bin", "/usr/local/bin", "/usr/local/mysql/bin", "/opt/mysql/bin"] + } else { + &[] + }; + raw.iter().map(PathBuf::from).collect() +} + +pub fn pg_dump_request() -> ToolRequest { ToolRequest::new("pg_tools", &["pg_dump"]) } +pub fn pg_restore_request() -> ToolRequest { ToolRequest::new("pg_tools", &["pg_restore"]) } +pub fn psql_request() -> ToolRequest { ToolRequest::new("pg_tools", &["psql"]) } + +/// Maps a PostgreSQL tool name to its request; `None` for unknown names. +pub fn pg_request(tool: &str) -> Option { + match tool { + "pg_dump" => Some(pg_dump_request()), + "pg_restore" => Some(pg_restore_request()), + "psql" => Some(psql_request()), + _ => None, + } +} + +/// MySQL dump role: system `mariadb-dump` -> system `mysqldump` -> bundled. +pub fn mysql_dump_request() -> ToolRequest { + ToolRequest::new("mysql_tools", &["mariadb-dump", "mysqldump"]) +} + +/// MySQL client role (used by sync as the restore target). +pub fn mysql_client_request() -> ToolRequest { + ToolRequest::new("mysql_tools", &["mariadb", "mysql"]) +} + +/// Maps a MySQL tool name to its request; `None` for unknown names. +pub fn mysql_request(tool: &str) -> Option { + match tool { + "mariadb-dump" | "mysqldump" => Some(mysql_dump_request()), + "mariadb" | "mysql" => Some(mysql_client_request()), + _ => None, + } +} + +/// The bundled binary path for a role. Note the nested +/// `BUNDLED_RESOURCE_PREFIX` component — this is the issue #44 fix. +pub fn bundled_tool_path(resource_dir: &Path, subdir: &str, bin: &str) -> PathBuf { + resource_dir + .join(BUNDLED_RESOURCE_PREFIX) + .join(subdir) + .join(bundled_bin_name(bin)) +} + +/// Locate `name` on a `PATH`-shaped value without invoking a shell. +pub fn path_lookup_in(name: &str, path_var: &OsStr) -> Option { + let file = bundled_bin_name(name); + for dir in std::env::split_paths(path_var) { + if dir.as_os_str().is_empty() { + continue; + } + let candidate = dir.join(&file); + if candidate.is_file() { + return Some(candidate); + } + } + None +} + +/// Locate `name` on the inherited `PATH`. +pub fn path_lookup(name: &str) -> Option { + let path_var = std::env::var_os("PATH")?; + path_lookup_in(name, &path_var) +} + +/// Per-probe timeout. `--version` returns in milliseconds; this only guards a +/// pathological binary. +pub const PROBE_TIMEOUT: Duration = Duration::from_secs(1); +/// Aggregate deadline for one detection pass across all candidates. +pub const AGGREGATE_DEADLINE: Duration = Duration::from_secs(3); + +/// Run ` --version` with a bounded wait. The child's stdio is +/// discarded — output is read separately by `version_of`. +pub fn run_version(program: &Path, timeout: Duration) -> bool { + let mut cmd = Command::new(program); + cmd.arg("--version") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + let Ok(mut child) = cmd.spawn() else { + return false; + }; + let deadline = Instant::now() + timeout; + loop { + match child.try_wait() { + Ok(Some(status)) => return status.success(), + Ok(None) => { + if Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + return false; + } + std::thread::sleep(Duration::from_millis(5)); + } + Err(_) => { + let _ = child.kill(); + return false; + } + } + } +} + +/// A tool candidate counts only when it is a regular file AND runs. +pub fn probe(program: &Path) -> bool { + program.is_file() && run_version(program, PROBE_TIMEOUT) +} + +/// A probe that stops running anything once the aggregate deadline is crossed. +pub fn probe_within(start: Instant, deadline: Duration, program: &Path) -> bool { + if start.elapsed() >= deadline { + return false; + } + probe(program) +} + +/// Ordered, injectable resolution. +/// +/// 1. every trusted absolute directory, for each candidate name in order; +/// 2. the inherited `PATH`, for each candidate name in order; +/// 3. the bundled binary. +/// +/// `probe_fn` decides whether a candidate is runnable, so tests never touch +/// the real filesystem. The aggregate deadline is enforced by the caller's +/// `probe_fn` (see `probe_within`). +pub fn resolve_with( + request: &ToolRequest, + trusted: &[PathBuf], + path_lookup: &dyn Fn(&str) -> Option, + bundled: Option<&Path>, + mut probe_fn: F, +) -> Option +where + F: FnMut(&Path) -> bool, +{ + for dir in trusted { + for name in &request.names { + let candidate = dir.join(bundled_bin_name(name)); + if probe_fn(&candidate) { + return Some(Resolution { + program: candidate.to_string_lossy().to_string(), + source: ToolSource::TrustedDir, + name: (*name).to_string(), + }); + } + } + } + + for name in &request.names { + if let Some(found) = path_lookup(name) { + if probe_fn(&found) { + return Some(Resolution { + program: found.to_string_lossy().to_string(), + source: ToolSource::Path, + name: (*name).to_string(), + }); + } + } + } + + if let Some(bundled) = bundled { + if probe_fn(bundled) { + let name = bundled + .file_stem() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_else(|| request.bundled_name().to_string()); + return Some(Resolution { + program: bundled.to_string_lossy().to_string(), + source: ToolSource::Bundled, + name, + }); + } + } + + None +} + +/// Read the first non-empty line of ` --version` (stdout, else stderr). +pub fn version_of(program: &str) -> Option { + let out = Command::new(program).arg("--version").output().ok()?; + let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string(); + if !stdout.is_empty() { + return Some(stdout); + } + let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string(); + if stderr.is_empty() { None } else { Some(stderr) } +} + +/// Lenient major-version extraction. Scans for numeric groups, unwraps the +/// legacy MariaDB `5.5.5--MariaDB` compatibility prefix, and returns +/// `None` when there is no number at all. +pub fn parse_major(version: &str) -> Option { + let mut nums: Vec = Vec::new(); + let mut cur = String::new(); + for ch in version.chars() { + if ch.is_ascii_digit() { + cur.push(ch); + continue; + } + if !cur.is_empty() { + nums.push(cur.parse::().ok()?); + cur.clear(); + // `6` is the only magic number here: it caps how many groups an + // adversarial/advertising version string can make us collect + // (we only ever read the first four). + if nums.len() == 6 { + break; + } + } + } + if !cur.is_empty() { + if let Ok(n) = cur.parse::() { + nums.push(n); + } + } + if nums.is_empty() { + return None; + } + if nums.len() >= 4 && nums[0] == 5 && nums[1] == 5 && nums[2] == 5 { + return Some(nums[3]); + } + Some(nums[0]) +} + +/// Client/server version relationship. Advisory only — never an error. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Compat { + Ok, + /// The client's major version is below the server's. + ClientOlder, + /// Either version could not be parsed. + Unknown, +} + +pub fn compat(client: Option, server: Option) -> Compat { + match (client, server) { + (Some(c), Some(s)) if s > c => Compat::ClientOlder, + (Some(_), Some(_)) => Compat::Ok, + _ => Compat::Unknown, + } +} + +/// Non-blocking warning text, or `None` when there is nothing to say. +pub fn warning_for(client_version: &str, server_version: &str, client_name: &str) -> Option { + match compat(parse_major(client_version), parse_major(server_version)) { + Compat::ClientOlder => Some(format!( + "Client/server version mismatch: the resolved dump tool `{client_name}` reports {client_version}, \ +but the server is {server_version}. The backup may fail or omit data — installing a matching MySQL client is recommended." + )), + Compat::Ok | Compat::Unknown => None, + } +} + +/// Everything one detection pass knows about a tool role. +#[derive(Debug, Clone, PartialEq)] +pub struct ToolResolution { + pub resolved: Option, + pub bundled_path: Option, + /// Whether the bundled file exists — regardless of whether it runs. Lets + /// the UI say "ships with the app but could not run" instead of "absent". + pub bundled_available: bool, + pub version: Option, +} + +impl ToolResolution { + pub fn none() -> Self { + Self { resolved: None, bundled_path: None, bundled_available: false, version: None } + } +} + +/// Whether a bundled tool file exists at `path`. +pub fn bundled_present(path: &Path) -> bool { + path.is_file() +} + +/// Shape the existing resolver call sites expect: `(command, source)`. +pub fn adapt_resolution(res: &ToolResolution, fallback_name: &str) -> (String, Option) { + match &res.resolved { + Some(r) => (r.program.clone(), Some(r.source.as_str().to_string())), + None => (fallback_name.to_string(), None), + } +} + +// Cache is keyed by `ToolRequest::cache_key()` and holds no secrets. +static CACHE: OnceLock>> = OnceLock::new(); + +fn cache() -> &'static Mutex> { + CACHE.get_or_init(|| Mutex::new(HashMap::new())) +} + +pub fn cache_get(key: &str) -> Option { + cache().lock().ok()?.get(key).cloned() +} + +pub fn cache_put(key: &str, value: ToolResolution) { + if let Ok(mut map) = cache().lock() { + map.insert(key.to_string(), value); + } +} + +pub fn cache_clear() { + if let Ok(mut map) = cache().lock() { + map.clear(); + } +} + +#[cfg(test)] +#[path = "tool_resolver.test.rs"] +mod tests; diff --git a/desktop/src-tauri/src/db/tool_resolver.test.rs b/desktop/src-tauri/src/db/tool_resolver.test.rs new file mode 100644 index 0000000..0865768 --- /dev/null +++ b/desktop/src-tauri/src/db/tool_resolver.test.rs @@ -0,0 +1,403 @@ +use super::*; + +#[test] +fn bundled_bin_name_appends_exe_only_on_windows() { + let expected = if cfg!(windows) { "pg_dump.exe" } else { "pg_dump" }; + assert_eq!(bundled_bin_name("pg_dump"), expected); +} + +#[test] +fn pg_request_maps_known_tool_names() { + assert_eq!(pg_request("pg_dump").unwrap(), ToolRequest::new("pg_tools", &["pg_dump"])); + assert_eq!(pg_request("pg_restore").unwrap(), ToolRequest::new("pg_tools", &["pg_restore"])); + assert_eq!(pg_request("psql").unwrap(), ToolRequest::new("pg_tools", &["psql"])); + assert!(pg_request("not_a_tool").is_none()); +} + +#[test] +fn mysql_dump_request_prefers_mariadb_dump_then_mysqldump() { + let req = mysql_dump_request(); + assert_eq!(req.subdir, "mysql_tools"); + assert_eq!(req.names, vec!["mariadb-dump", "mysqldump"]); + assert_eq!(req.bundled_name(), "mariadb-dump"); +} + +#[test] +fn mysql_client_request_prefers_mariadb_then_mysql() { + let req = mysql_client_request(); + assert_eq!(req.names, vec!["mariadb", "mysql"]); + assert_eq!(req.bundled_name(), "mariadb"); +} + +#[test] +fn cache_key_is_stable_and_name_ordered() { + assert_eq!(mysql_dump_request().cache_key(), "mysql_tools::mariadb-dump,mysqldump"); + assert_eq!(pg_dump_request().cache_key(), "pg_tools::pg_dump"); +} + +#[test] +fn tool_source_maps_to_ui_string() { + assert_eq!(ToolSource::TrustedDir.as_str(), "system"); + assert_eq!(ToolSource::Path.as_str(), "system"); + assert_eq!(ToolSource::Bundled.as_str(), "bundled"); +} + +#[cfg(target_os = "macos")] +#[test] +fn trusted_dirs_include_homebrew_and_postgres_app_on_macos() { + let dirs = trusted_dirs(); + assert!(dirs.contains(&PathBuf::from("/opt/homebrew/bin")), "{dirs:?}"); + assert!(dirs.contains(&PathBuf::from("/opt/homebrew/opt/mysql-client/bin")), "{dirs:?}"); + assert!(dirs.contains(&PathBuf::from("/usr/local/opt/libpq/bin")), "{dirs:?}"); + assert!(dirs.iter().any(|d| d.ends_with("Postgres.app/Contents/Versions/latest/bin")), "{dirs:?}"); +} + +#[cfg(target_os = "linux")] +#[test] +fn trusted_dirs_include_usr_bin_on_linux() { + assert!(trusted_dirs().contains(&PathBuf::from("/usr/bin"))); +} + +#[cfg(windows)] +#[test] +fn trusted_dirs_are_empty_on_windows() { + assert!(trusted_dirs().is_empty()); +} + +#[test] +fn bundled_tool_path_includes_the_nested_resources_prefix() { + // THE BUG FROM ISSUE #44: the shipped app stores the tool at + // /resources/mysql_tools/mariadb-dump, never at + // /mysql_tools/mariadb-dump. + let p = bundled_tool_path(Path::new("/app/Contents/Resources"), "mysql_tools", "mariadb-dump"); + assert_eq!( + p, + PathBuf::from("/app/Contents/Resources/resources/mysql_tools").join(bundled_bin_name("mariadb-dump")) + ); + assert!(p.to_string_lossy().ends_with("resources/mysql_tools/mariadb-dump")); +} + +#[test] +fn bundled_tool_path_works_for_postgres_tools_too() { + let p = bundled_tool_path(Path::new("/app/Contents/Resources"), "pg_tools", "pg_dump"); + assert!(p.to_string_lossy().ends_with("resources/pg_tools/pg_dump")); +} + +#[test] +fn bundled_prefix_matches_tauri_config_resources() { + let conf = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/tauri.conf.json")) + .expect("tauri.conf.json must be readable"); + let json: serde_json::Value = serde_json::from_str(&conf).unwrap(); + let resources = json["bundle"]["resources"] + .as_array() + .expect("bundle.resources must be an array"); + assert!(!resources.is_empty(), "bundle.resources must not be empty"); + for entry in resources { + let glob = entry.as_str().unwrap(); + let top = Path::new(glob).components().next().unwrap(); + assert_eq!( + top.as_os_str().to_str().unwrap(), + BUNDLED_RESOURCE_PREFIX, + "resource glob `{glob}` must start with `{BUNDLED_RESOURCE_PREFIX}/`" + ); + } +} + +#[test] +fn path_lookup_finds_an_executable_by_name() { + let dir = unique_dir(); + let exe = dir.join(bundled_bin_name("mysqldump")); + std::fs::write(&exe, b"").unwrap(); + let path_var = std::env::join_paths([&dir]).unwrap(); + assert_eq!(path_lookup_in("mysqldump", &path_var), Some(exe)); +} + +#[test] +fn path_lookup_skips_empty_entries_and_missing_names() { + let dir = unique_dir(); + let path_var = std::env::join_paths([PathBuf::from(""), dir.clone()]).unwrap(); + assert_eq!(path_lookup_in("definitely-absent-tool", &path_var), None); +} + +#[test] +fn probe_rejects_a_missing_file_and_a_directory() { + let dir = unique_dir(); + assert!(!probe(&dir.join("absent-tool"))); + assert!(!probe(&dir), "a directory must never be reported as a runnable tool"); +} + +#[cfg(unix)] +#[test] +fn run_version_is_true_for_zero_exit_and_false_for_nonzero() { + let ok = unique_dir().join("ok_tool"); + write_exe(&ok, "#!/bin/sh\nexit 0\n"); + assert!(run_version(&ok, Duration::from_secs(1))); + + let bad = unique_dir().join("bad_tool"); + write_exe(&bad, "#!/bin/sh\nexit 3\n"); + assert!(!run_version(&bad, Duration::from_secs(1))); +} + +#[cfg(unix)] +#[test] +fn run_version_kills_a_hung_process_at_the_timeout() { + let slow = unique_dir().join("slow_tool"); + write_exe(&slow, "#!/bin/sh\nsleep 5\n"); + assert!(!run_version(&slow, Duration::from_millis(50))); +} + +#[cfg(unix)] +#[test] +fn probe_accepts_a_runnable_script() { + let tool = unique_dir().join(bundled_bin_name("mariadb-dump")); + write_exe(&tool, "#!/bin/sh\necho 'mariadb-dump 11.4.5-MariaDB'\nexit 0\n"); + assert!(probe(&tool)); +} + +#[cfg(unix)] +#[test] +fn version_of_reads_the_version_from_stdout() { + let tool = unique_dir().join("versioned_tool"); + write_exe(&tool, "#!/bin/sh\necho 'mariadb-dump 11.4.5-MariaDB'\n"); + let v = version_of(tool.to_str().unwrap()).unwrap(); + assert!(v.contains("11.4.5-MariaDB"), "got {v}"); +} + +#[cfg(unix)] +#[test] +fn probe_within_returns_false_once_the_aggregate_deadline_passed() { + let tool = unique_dir().join("ok_tool"); + write_exe(&tool, "#!/bin/sh\nexit 0\n"); + let past = Instant::now() - Duration::from_secs(10); + assert!(!probe_within(past, Duration::from_secs(3), &tool)); + assert!(probe_within(Instant::now(), Duration::from_secs(3), &tool)); +} + +#[test] +fn resolve_prefers_a_trusted_dir_and_the_first_candidate_name() { + let req = mysql_dump_request(); + let trusted = vec![PathBuf::from("/trusted")]; + let path_lookup = |_n: &str| -> Option { None }; + let r = resolve_with(&req, &trusted, &path_lookup, None, |_p: &Path| true).unwrap(); + assert_eq!(r.source, ToolSource::TrustedDir); + assert_eq!(r.name, "mariadb-dump"); + assert_eq!(r.program, format!("/trusted/{}", bundled_bin_name("mariadb-dump"))); +} + +#[test] +fn resolve_falls_through_to_the_inherited_path() { + let req = mysql_dump_request(); + let trusted = vec![PathBuf::from("/trusted")]; + let path_lookup = |name: &str| -> Option { + if name == "mysqldump" { Some(PathBuf::from("/usr/bin/mysqldump")) } else { None } + }; + let probe = |p: &Path| p == Path::new("/usr/bin/mysqldump"); + let r = resolve_with(&req, &trusted, &path_lookup, None, probe).unwrap(); + assert_eq!(r.source, ToolSource::Path); + assert_eq!(r.name, "mysqldump"); + assert_eq!(r.program, "/usr/bin/mysqldump"); +} + +#[test] +fn resolve_prefers_any_system_tool_over_the_bundled_one() { + let req = mysql_dump_request(); + let trusted = vec![PathBuf::from("/trusted")]; + let path_lookup = |_n: &str| -> Option { None }; + let bundled = Path::new("/rd/resources/mysql_tools/mariadb-dump"); + let probe = |p: &Path| p != bundled; // only the trusted candidate runs + let r = resolve_with(&req, &trusted, &path_lookup, Some(bundled), probe).unwrap(); + assert_eq!(r.source, ToolSource::TrustedDir); +} + +#[test] +fn resolve_uses_the_bundled_binary_when_no_system_tool_runs() { + let req = mysql_dump_request(); + let bundled = Path::new("/rd/resources/mysql_tools/mariadb-dump"); + let path_lookup = |_n: &str| -> Option { None }; + let probe = |p: &Path| p == bundled; + let r = resolve_with(&req, &[], &path_lookup, Some(bundled), probe).unwrap(); + assert_eq!(r.source, ToolSource::Bundled); + assert_eq!(r.name, "mariadb-dump"); + assert_eq!(r.program, "/rd/resources/mysql_tools/mariadb-dump"); +} + +#[test] +fn resolve_skips_a_candidate_that_fails_the_probe() { + let req = mysql_dump_request(); + let trusted = vec![PathBuf::from("/trusted")]; + let target = PathBuf::from("/trusted").join(bundled_bin_name("mysqldump")); + let probe = move |p: &Path| p == target.as_path(); + let path_lookup = |_n: &str| -> Option { None }; + let r = resolve_with(&req, &trusted, &path_lookup, None, probe).unwrap(); + assert_eq!(r.name, "mysqldump"); + assert_eq!(r.source, ToolSource::TrustedDir); +} + +#[test] +fn resolve_returns_none_when_nothing_runs() { + let req = mysql_dump_request(); + let path_lookup = |_n: &str| -> Option { None }; + let r = resolve_with(&req, &[PathBuf::from("/trusted")], &path_lookup, Some(Path::new("/rd/x")), |_p: &Path| false); + assert!(r.is_none()); +} + +#[cfg(unix)] +fn write_exe(path: &Path, body: &str) { + use std::os::unix::fs::PermissionsExt; + std::fs::write(path, body).unwrap(); + let mut perms = std::fs::metadata(path).unwrap().permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(path, perms).unwrap(); +} + +fn unique_dir() -> PathBuf { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let dir = std::env::temp_dir() + .join(format!("gridline_tool_resolver_{}_{}", std::process::id(), nanos)); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +#[test] +fn parse_major_handles_common_version_strings() { + assert_eq!(parse_major("8.0.36"), Some(8)); + assert_eq!(parse_major("8.4.0"), Some(8)); + assert_eq!(parse_major("11.4.5-MariaDB"), Some(11)); + assert_eq!(parse_major("5.7.44-log"), Some(5)); + assert_eq!(parse_major("26.7.0"), Some(26)); + assert_eq!(parse_major("mariadb-dump from 11.4.5-MariaDB, client 10.19"), Some(11)); +} + +#[test] +fn parse_major_unwraps_the_legacy_mariadb_compat_prefix() { + // MariaDB historically reported 5.5.5--MariaDB for compat + // with MySQL-era clients; the real major is the 4th group. + assert_eq!(parse_major("5.5.5-10.4.11-MariaDB"), Some(10)); + assert_eq!(parse_major("5.5.5-10.11.6-MariaDB-log"), Some(10)); + // A genuine 5.5.5 has only three groups and must stay 5. + assert_eq!(parse_major("5.5.5"), Some(5)); +} + +#[test] +fn parse_major_returns_none_for_garbage() { + assert_eq!(parse_major("no digits here"), None); + assert_eq!(parse_major(""), None); +} + +#[test] +fn compat_flags_only_a_clearly_older_client() { + assert_eq!(compat(Some(8), Some(26)), Compat::ClientOlder); + assert_eq!(compat(Some(11), Some(8)), Compat::Ok); + assert_eq!(compat(Some(8), Some(8)), Compat::Ok); + assert_eq!(compat(None, Some(8)), Compat::Unknown); + assert_eq!(compat(Some(8), None), Compat::Unknown); +} + +#[test] +fn warning_for_is_silent_unless_the_client_is_older() { + assert!(warning_for("mariadb-dump 11.4.5-MariaDB", "8.0.36", "mariadb-dump").is_none()); + assert!(warning_for("unknown", "8.0.36", "mariadb-dump").is_none()); + let w = warning_for("mariadb-dump 11.4.5-MariaDB", "26.7.0", "mariadb-dump").unwrap(); + assert!(w.contains("11.4.5-MariaDB"), "{w}"); + assert!(w.contains("26.7.0"), "{w}"); + assert!(w.contains("mariadb-dump"), "{w}"); + // The `\` continuation in the format literal must not leave stray leading + // indentation, so assert the exact single-line render (no newline, no run + // of consecutive spaces). + assert!(!w.contains('\n'), "warning must be one line: {w:?}"); + assert!(!w.contains(" "), "warning must not contain double spaces: {w:?}"); + assert_eq!( + w, + concat!( + "Client/server version mismatch: the resolved dump tool `mariadb-dump` reports ", + "mariadb-dump 11.4.5-MariaDB, but the server is 26.7.0. ", + "The backup may fail or omit data — installing a matching MySQL client is recommended." + ) + ); +} + +#[test] +fn parse_major_never_panics_on_hostile_input() { + // A digit group that overflows u32 yields None, never a panic. + assert_eq!(parse_major("99999999999999999999.1.1"), None); + assert_eq!(parse_major(&"9".repeat(4096)), None); + // Non-ASCII digits, unicode and control characters are simply skipped. + assert_eq!(parse_major("\u{fc}nicode ٨.٠.٣٦"), None); + assert_eq!(parse_major("\u{1F600}"), None); + assert_eq!(parse_major("\u{0}\u{1}\u{2}"), None); + assert_eq!(parse_major("....."), None); + // Long adversarial input: the group collector is capped at six, and only + // the first group decides the major. + assert_eq!(parse_major(&"1.2.3.4.".repeat(1000)), Some(1)); + // An overflowing *trailing* group is skipped by the final flush, so a + // genuine-looking `5.5.5` prefix still yields 5 rather than panicking. + // Harmless: no real tool prints such a version, and the result is advisory. + assert_eq!(parse_major("5.5.5-99999999999999999999"), Some(5)); +} + +#[test] +fn adapt_resolution_returns_program_and_source() { + let res = ToolResolution { + resolved: Some(Resolution { + program: "/x/mariadb-dump".into(), + source: ToolSource::Bundled, + name: "mariadb-dump".into(), + }), + bundled_path: Some(PathBuf::from("/x/mariadb-dump")), + bundled_available: true, + version: Some("11.4.5".into()), + }; + assert_eq!( + adapt_resolution(&res, "mariadb-dump"), + ("/x/mariadb-dump".to_string(), Some("bundled".to_string())) + ); +} + +#[test] +fn adapt_resolution_falls_back_to_the_requested_name() { + assert_eq!( + adapt_resolution(&ToolResolution::none(), "pg_dump"), + ("pg_dump".to_string(), None) + ); +} + +#[test] +fn cache_round_trips_and_clears() { + cache_clear(); + let res = ToolResolution { + resolved: Some(Resolution { + program: "/c/tool".into(), + source: ToolSource::Path, + name: "tool".into(), + }), + bundled_path: None, + bundled_available: false, + version: None, + }; + assert!(cache_get("k_task6_unique").is_none()); + cache_put("k_task6_unique", res.clone()); + assert_eq!(cache_get("k_task6_unique"), Some(res)); + cache_clear(); + assert!(cache_get("k_task6_unique").is_none()); +} + +#[test] +fn bundled_present_is_true_only_for_a_regular_file() { + let dir = unique_dir(); + assert!(!bundled_present(&dir), "a directory is not a present bundled tool"); + let file = dir.join("mariadb-dump"); + std::fs::write(&file, b"x").unwrap(); + assert!(bundled_present(&file)); +} + +#[test] +fn mysql_request_maps_known_tool_names() { + assert_eq!(mysql_request("mariadb-dump").unwrap(), mysql_dump_request()); + assert_eq!(mysql_request("mysqldump").unwrap(), mysql_dump_request()); + assert_eq!(mysql_request("mariadb").unwrap(), mysql_client_request()); + assert_eq!(mysql_request("mysql").unwrap(), mysql_client_request()); + assert!(mysql_request("mysqladmin").is_none()); +} diff --git a/desktop/src-tauri/src/models/backup.rs b/desktop/src-tauri/src/models/backup.rs index 3dd12df..ce29ad5 100644 --- a/desktop/src-tauri/src/models/backup.rs +++ b/desktop/src-tauri/src/models/backup.rs @@ -42,6 +42,12 @@ pub struct PgToolStatus { pub pg_restore_version: Option, pub pg_dump_source: Option, // "system" | "bundled" | None pub pg_restore_source: Option, + /// The binary name that actually resolved (`mariadb-dump`, `mysqldump`, …). + pub pg_dump_resolved_name: Option, + pub pg_restore_resolved_name: Option, + /// Whether a bundled file exists at the expected path (runnable or not). + pub pg_dump_bundled_available: bool, + pub pg_restore_bundled_available: bool, } /// Resolved on-disk paths for the three client tools (system-first, bundled-fallback). @@ -111,6 +117,12 @@ pub struct MySqlToolStatus { pub mysql_version: Option, pub mysqldump_source: Option, pub mysql_source: Option, + pub mysqldump_resolved_name: Option, + pub mysql_resolved_name: Option, + /// True when the dump tool that resolved is MariaDB's `mariadb-dump`. + pub mysqldump_is_mariadb: bool, + pub mysqldump_bundled_available: bool, + pub mysql_bundled_available: bool, } #[derive(Debug, Clone)] @@ -141,6 +153,9 @@ pub struct BackupProgressEvent { pub progress: Option, pub output_line: Option, pub error: Option, + /// Non-fatal advisory message (e.g. a client/server version mismatch). + /// `None` for ordinary progress events. + pub warning: Option, } #[cfg(test)] @@ -185,7 +200,11 @@ mod tests { fn pg_tool_status_reports_source() { let s = PgToolStatus { pg_dump_found: true, pg_restore_found: true, pg_dump_version: Some("pg_dump 16".into()), pg_restore_version: Some("pg_restore 16".into()), - pg_dump_source: Some("system".into()), pg_restore_source: Some("bundled".into()) }; + pg_dump_source: Some("system".into()), pg_restore_source: Some("bundled".into()), + pg_dump_resolved_name: Some("pg_dump".into()), + pg_restore_resolved_name: None, + pg_dump_bundled_available: true, + pg_restore_bundled_available: false }; let json = serde_json::to_string(&s).unwrap(); assert!(json.contains("\"pg_dump_source\":\"system\"")); assert!(json.contains("\"pg_restore_source\":\"bundled\"")); @@ -225,6 +244,11 @@ mod tests { mysql_version: Some("mariadb 10.6".into()), mysqldump_source: Some("bundled".into()), mysql_source: Some("system".into()), + mysqldump_resolved_name: Some("mariadb-dump".into()), + mysql_resolved_name: Some("mariadb".into()), + mysqldump_is_mariadb: true, + mysqldump_bundled_available: true, + mysql_bundled_available: true, }; let json = serde_json::to_string(&s).unwrap(); assert!(json.contains("\"mysqldumpSource\":\"bundled\"")); diff --git a/desktop/src/components/db-viewer/BackupDialog.tsx b/desktop/src/components/db-viewer/BackupDialog.tsx index d87247b..636e420 100644 --- a/desktop/src/components/db-viewer/BackupDialog.tsx +++ b/desktop/src/components/db-viewer/BackupDialog.tsx @@ -1,6 +1,7 @@ import { useState, useEffect, useCallback } from "react"; import { AnimatedModal } from "../ui/AnimatedModal"; import { Button } from "../ui/Button"; +import { CopyButton } from "../ui/CopyButton"; import { Select } from "../ui/Select"; import { BackupProgress } from "./BackupProgress"; import { useBackupStore } from "../../stores/backupStore"; @@ -108,6 +109,7 @@ export function BackupDialog({ open, connectionId, onClose }: BackupDialogProps) }, [filePath, format, schema, noOwner, connectionId, startJob, notify, onClose]); const toolsMissing = toolStatus && !toolStatus.pg_dump_found; + const installInstructions = getPlatformInstructions(); return ( @@ -124,9 +126,18 @@ export function BackupDialog({ open, connectionId, onClose }: BackupDialogProps)

The PostgreSQL client tools are required for backup/restore operations. Install them using:

-
-              {getPlatformInstructions()}
-            
+
+
+ Install + +
+
+                {installInstructions}
+              
+
)} diff --git a/desktop/src/components/db-viewer/BackupPage.test.tsx b/desktop/src/components/db-viewer/BackupPage.test.tsx index 7957dbe..5fac35b 100644 --- a/desktop/src/components/db-viewer/BackupPage.test.tsx +++ b/desktop/src/components/db-viewer/BackupPage.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, screen, waitFor } from "@testing-library/react"; +import { render, screen, waitFor, fireEvent } from "@testing-library/react"; import { BackupPage } from "./BackupPage"; import { useBackupStore } from "../../stores/backupStore"; import { useNotificationStore } from "../../stores/notificationStore"; @@ -83,4 +83,105 @@ describe("BackupPage DB-aware", () => { await waitFor(() => expect(screen.queryByText(/checking for pg_dump/i)).not.toBeInTheDocument()); expect(screen.getByText("Custom Archive")).toBeTruthy(); }); +}); + +describe("BackupPage tool provenance and re-check", () => { + beforeEach(() => { + vi.restoreAllMocks(); + mockConnections.length = 0; + useBackupStore.setState({ jobs: [], activeJobId: null, progress: 0 }); + useNotificationStore.setState({ notifications: [] }); + vi.spyOn(commands, "getSchemas").mockResolvedValue([]); + }); + + it("shows the resolved tool and its source", async () => { + mockConnections.push({ id: "c1", db_type: "mysql", name: "m", database: "db1" }); + vi.spyOn(commands, "detectMysqlTools").mockResolvedValue({ + mysqldumpFound: true, + mysqlFound: true, + mysqldumpVersion: "mariadb-dump 11.4.5-MariaDB", + mysqlVersion: "mariadb 11.4.5-MariaDB", + mysqldumpSource: "bundled", + mysqlSource: "bundled", + mysqldumpResolvedName: "mariadb-dump", + mysqlResolvedName: "mariadb", + mysqldumpBundledAvailable: true, + mysqlBundledAvailable: true, + }); + render(); + await waitFor(() => expect(screen.getByText(/Using bundled mariadb-dump/)).toBeTruthy()); + }); + + it("re-checks with force when Check again is clicked", async () => { + mockConnections.push({ id: "c2", db_type: "mysql", name: "m", database: "db1" }); + const spy = vi.spyOn(commands, "detectMysqlTools").mockResolvedValue({ + mysqldumpFound: false, + mysqlFound: false, + mysqldumpVersion: null, + mysqlVersion: null, + mysqldumpSource: null, + mysqlSource: null, + }); + render(); + await waitFor(() => expect(spy).toHaveBeenCalledWith(false)); + fireEvent.click(screen.getByRole("button", { name: /check again/i })); + await waitFor(() => expect(spy).toHaveBeenCalledWith(true)); + }); + + it("explains when the bundled tool is present but could not run", async () => { + mockConnections.push({ id: "c3", db_type: "mysql", name: "m", database: "db1" }); + vi.spyOn(commands, "detectMysqlTools").mockResolvedValue({ + mysqldumpFound: false, + mysqlFound: false, + mysqldumpVersion: null, + mysqlVersion: null, + mysqldumpSource: null, + mysqlSource: null, + mysqldumpBundledAvailable: true, + mysqlBundledAvailable: false, + }); + render(); + await waitFor(() => + expect(screen.getByText(/bundled copy is present but could not run/i)).toBeTruthy(), + ); + }); + + it("shows install instructions when nothing resolves at all", async () => { + mockConnections.push({ id: "c4", db_type: "mysql", name: "m", database: "db1" }); + vi.spyOn(commands, "detectMysqlTools").mockResolvedValue({ + mysqldumpFound: false, + mysqlFound: false, + mysqldumpVersion: null, + mysqlVersion: null, + mysqldumpSource: null, + mysqlSource: null, + mysqldumpBundledAvailable: false, + }); + render(); + await waitFor(() => expect(screen.getByText(/mysql-client/)).toBeTruthy()); + }); + + it("offers a copy button for the install command", async () => { + mockConnections.push({ id: "c-copy", db_type: "mysql", name: "m", database: "db1" }); + vi.spyOn(commands, "detectMysqlTools").mockResolvedValue({ + mysqldumpFound: false, + mysqlFound: false, + mysqldumpVersion: null, + mysqlVersion: null, + mysqldumpSource: null, + mysqlSource: null, + mysqldumpBundledAvailable: false, + }); + Object.defineProperty(navigator, "clipboard", { + value: { writeText: vi.fn() }, + configurable: true, + writable: true, + }); + render(); + const btn = await screen.findByTitle("Copy to clipboard"); + fireEvent.click(btn); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith( + expect.stringContaining("mysql-client"), + ); + }); }); \ No newline at end of file diff --git a/desktop/src/components/db-viewer/BackupPage.tsx b/desktop/src/components/db-viewer/BackupPage.tsx index 3cdcbe8..ce351c1 100644 --- a/desktop/src/components/db-viewer/BackupPage.tsx +++ b/desktop/src/components/db-viewer/BackupPage.tsx @@ -1,8 +1,14 @@ import { useState, useEffect, useCallback, useRef } from "react"; import { Download, FolderOpen, HardDrive } from "lucide-react"; import { save } from "@tauri-apps/plugin-dialog"; -import { Button } from "../ui/Button"; +import { CopyButton } from "../ui/CopyButton"; import { BackupProgress } from "./BackupProgress"; +import { + FormRow, + FormSectionHeader, + inputClass, + controlClass, +} from "./objects/formRow"; import { useBackupStore } from "../../stores/backupStore"; import { useNotificationStore } from "../../stores/notificationStore"; import { useConnectionStore } from "../../stores/connectionStore"; @@ -98,16 +104,16 @@ export function BackupPage({ connectionId }: BackupPageProps) { } }, [activeJob, notify]); - useEffect(() => { - setCheckingTools(true); - setPgToolStatus(null); - setMysqlToolStatus(null); - setAvailableSchemas([]); + const checkTools = useCallback( + async (force: boolean) => { + setCheckingTools(true); + setPgToolStatus(null); + setMysqlToolStatus(null); - if (isPg) { - detectPgTools() - .then((status) => setPgToolStatus(status)) - .catch(() => + if (isPg) { + try { + setPgToolStatus(await detectPgTools(force)); + } catch { setPgToolStatus({ pg_dump_found: false, pg_restore_found: false, @@ -115,17 +121,14 @@ export function BackupPage({ connectionId }: BackupPageProps) { pg_restore_version: null, pg_dump_source: null, pg_restore_source: null, - }), - ) - .finally(() => setCheckingTools(false)); - - getSchemas(connectionId) - .then((schemas) => setAvailableSchemas(schemas)) - .catch(() => setAvailableSchemas([])); - } else if (isMysql) { - detectMysqlTools() - .then((status) => setMysqlToolStatus(status)) - .catch(() => + }); + } finally { + setCheckingTools(false); + } + } else if (isMysql) { + try { + setMysqlToolStatus(await detectMysqlTools(force)); + } catch { setMysqlToolStatus({ mysqldumpFound: false, mysqlFound: false, @@ -133,17 +136,26 @@ export function BackupPage({ connectionId }: BackupPageProps) { mysqlVersion: null, mysqldumpSource: null, mysqlSource: null, - }), - ) - .finally(() => setCheckingTools(false)); + }); + } finally { + setCheckingTools(false); + } + } else { + setCheckingTools(false); + } + }, + [isPg, isMysql], + ); + useEffect(() => { + setAvailableSchemas([]); + void checkTools(false); + if (isPg || isMysql) { getSchemas(connectionId) .then((schemas) => setAvailableSchemas(schemas)) .catch(() => setAvailableSchemas([])); - } else { - setCheckingTools(false); } - }, [connectionId, isPg, isMysql]); + }, [connectionId, isPg, isMysql, checkTools]); const handlePickFile = useCallback(async () => { let defaultPath = "backup"; @@ -262,6 +274,23 @@ export function BackupPage({ connectionId }: BackupPageProps) { ? mysqlToolStatus?.mysqldumpSource === "bundled" : false; + const dumpToolFound = isPg + ? pgToolStatus?.pg_dump_found + : mysqlToolStatus?.mysqldumpFound; + const dumpSource = isPg + ? pgToolStatus?.pg_dump_source + : mysqlToolStatus?.mysqldumpSource; + const dumpResolvedName = isPg + ? pgToolStatus?.pg_dump_resolved_name + : mysqlToolStatus?.mysqldumpResolvedName; + const bundledPresent = isPg + ? pgToolStatus?.pg_dump_bundled_available + : mysqlToolStatus?.mysqldumpBundledAvailable; + const dumpFallbackName = isPg ? "pg_dump" : "mysqldump"; + const installInstructions = getPlatformInstructions( + isPg ? PG_INSTALL_INSTRUCTIONS : MYSQL_INSTALL_INSTRUCTIONS, + ); + const checkingMessage = isPg ? "Checking for pg_dump..." : isMysql @@ -275,271 +304,259 @@ export function BackupPage({ connectionId }: BackupPageProps) { : "Create a database backup"; return ( -
+
{/* Toolbar header */} -
- - Backup - - {headerDescription} - +
+
+ + Backup + + {headerDescription} + +
+
+ {(isPg || isMysql) && ( + + )} + {!checkingTools && !toolsMissing && ( + + )} +
{/* Content */} -
-
- {/* Tool check */} - {checkingTools && checkingMessage && ( -
-

- {checkingMessage} -

-
- )} +
+ {/* Tool check */} + {checkingTools && checkingMessage && ( +
+ {checkingMessage} +
+ )} - {toolsMissing && !toolsBundled && ( -
-

- {isPg ? "pg_dump not found" : "mysqldump not found"} -

+ {toolsMissing && !toolsBundled && ( +
+

+ {isPg ? "pg_dump not found" : "mysqldump not found"} +

+

+ Gridline ships its own {isPg ? "PostgreSQL" : "MySQL"} client + tools. This build could not locate or run them, so a manual + install is needed: +

+ {bundledPresent && (

- The {isPg ? "PostgreSQL" : "MySQL"} client tools are required for - backup/restore operations. Install them using: + A bundled copy is present but could not run — reinstall + Gridline, or install the tools below.

-
-                                {getPlatformInstructions(
-                                    isPg
-                                        ? PG_INSTALL_INSTRUCTIONS
-                                        : MYSQL_INSTALL_INSTRUCTIONS,
-                                )}
+                        )}
+                        
+
+ Install + +
+
+                                {installInstructions}
                             
- )} +
+ )} - {!checkingTools && !toolsMissing && ( - <> - {/* Configuration card */} -
- {/* Format */} - {isPg ? ( -
- - -
- ) : isMysql ? ( -
- -
- Plain SQL -
-
- ) : null} + {!checkingTools && !toolsMissing && ( + <> + {dumpToolFound && ( + + + Using {dumpSource === "bundled" ? "bundled" : "system"}{" "} + {dumpResolvedName ?? dumpFallbackName} + + + )} - {/* Output file */} -
- -
- - setFilePath(e.target.value) - } - placeholder="/path/to/backup.dump" - className="flex-1 px-4 py-2 text-sm text-text placeholder-text-muted/50 border-b border-border focus:border-accent focus:outline-none transition-colors" - /> - -
-
+ {/* Format */} + {isPg ? ( + + + + ) : isMysql ? ( + + + Plain SQL + + + ) : null} - {/* Schema (optional) */} - {(isPg || isMysql) && ( -
- - -
- )} + {/* Output file */} + + setFilePath(e.target.value)} + placeholder="/path/to/backup.dump" + className={inputClass} + /> + + - {/* PostgreSQL: no-owner toggle */} - {isPg && ( -
+ )} + + )}
); diff --git a/desktop/src/components/db-viewer/RestoreDialog.tsx b/desktop/src/components/db-viewer/RestoreDialog.tsx index 8977482..24fcf07 100644 --- a/desktop/src/components/db-viewer/RestoreDialog.tsx +++ b/desktop/src/components/db-viewer/RestoreDialog.tsx @@ -1,6 +1,7 @@ import { useState, useEffect, useCallback } from "react"; import { AnimatedModal } from "../ui/AnimatedModal"; import { Button } from "../ui/Button"; +import { CopyButton } from "../ui/CopyButton"; import { BackupProgress } from "./BackupProgress"; import { useBackupStore } from "../../stores/backupStore"; import { useNotificationStore } from "../../stores/notificationStore"; @@ -94,6 +95,7 @@ export function RestoreDialog({ open, connectionId, onClose }: RestoreDialogProp const toolsMissing = toolStatus && !toolStatus.pg_restore_found; const canStart = filePath && confirmed && !running; + const installInstructions = getPlatformInstructions(); return ( @@ -110,9 +112,18 @@ export function RestoreDialog({ open, connectionId, onClose }: RestoreDialogProp

The PostgreSQL client tools are required for backup/restore operations. Install them using:

-
-              {getPlatformInstructions()}
-            
+
+
+ Install + +
+
+                {installInstructions}
+              
+
)} diff --git a/desktop/src/components/db-viewer/RestorePage.tsx b/desktop/src/components/db-viewer/RestorePage.tsx index 6823669..ce3f316 100644 --- a/desktop/src/components/db-viewer/RestorePage.tsx +++ b/desktop/src/components/db-viewer/RestorePage.tsx @@ -1,8 +1,14 @@ import { useState, useEffect, useCallback, useRef } from "react"; -import { FileSearch, Upload } from "lucide-react"; +import { FolderOpen, Upload } from "lucide-react"; import { open } from "@tauri-apps/plugin-dialog"; -import { Button } from "../ui/Button"; +import { CopyButton } from "../ui/CopyButton"; import { BackupProgress } from "./BackupProgress"; +import { + FormRow, + FormSectionHeader, + inputClass, + controlClass, +} from "./objects/formRow"; import { useBackupStore } from "../../stores/backupStore"; import { useNotificationStore } from "../../stores/notificationStore"; import { useConnectionStore } from "../../stores/connectionStore"; @@ -212,6 +218,9 @@ export function RestorePage({ connectionId }: RestorePageProps) { ? pgToolStatus?.pg_restore_source === "bundled" : false; const canStart = filePath && confirmed && !isRunning; + const installInstructions = getPlatformInstructions( + isPg ? PG_INSTALL_INSTRUCTIONS : MYSQL_INSTALL_INSTRUCTIONS, + ); const checkingMessage = isPg ? "Checking for pg_restore..." @@ -225,234 +234,227 @@ export function RestorePage({ connectionId }: RestorePageProps) { ? "Restore a database from a SQL dump" : "Restore a database from a backup file"; + const cleanDisabled = isPg && format === "plain"; + const restoreToolResolvedName = + pgToolStatus?.pg_restore_resolved_name ?? "pg_restore"; + return ( -
+
{/* Toolbar header */} -
- - Restore - - {headerDescription} - -
- - {/* Content */} -
-
- {/* Tool check */} - {checkingTools && checkingMessage && ( -
-

- {checkingMessage} -

-
- )} - - {toolsMissing && !toolsBundled && ( -
-

- {isPg ? "pg_restore not found" : "mysql client not found"} -

-

- The {isPg ? "PostgreSQL" : "MySQL"} client tools are required for - backup/restore operations. Install them using: -

-
-                                {getPlatformInstructions(
-                                    isPg
-                                        ? PG_INSTALL_INSTRUCTIONS
-                                        : MYSQL_INSTALL_INSTRUCTIONS,
-                                )}
-                            
-
- )} - +
+
+ + Restore + + {headerDescription} + +
+
{!checkingTools && !toolsMissing && ( - <> - {isMysql && ( -

- Restores run in-process over the driver connection — no MySQL client - installation required. -

- )} - - {/* Configuration card */} -
- {/* Format (PostgreSQL only) */} - {isPg && ( -
- - -
- )} - - {/* Backup file */} -
- -
- - setFilePath(e.target.value) - } - placeholder="/path/to/backup.dump" - className="flex-1 px-4 py-2 text-sm text-text placeholder-text-muted/50 border-b border-border focus:border-accent focus:outline-none transition-colors" - /> - -
-
- - {/* Schema (optional) */} - {(isPg || isMysql) && ( -
- - -
- )} - - {/* Clean toggle */} - {(isPg || isMysql || isSqlite) && ( - - )} - {isPg && format === "plain" && ( -

- Plain SQL restores run via psql and don't - support DROP-before-CREATE. Use Custom - Archive for clean restores. -

- )} -
- - {/* Destructive confirmation */} -
- - {isMysql && ( -

- MySQL DDL commits as it runs and can't be rolled back — a failed - restore may leave this database partially applied. -

- )} -
- - {/* Progress */} - {activeJob && ( -
- -
- )} - - {/* Actions */} -
- -
- + )}
+ + {/* Content */} +
+ {/* Tool check */} + {checkingTools && checkingMessage && ( +
+ {checkingMessage} +
+ )} + + {toolsMissing && !toolsBundled && ( +
+

+ {isPg ? "pg_restore not found" : "mysql client not found"} +

+

+ Gridline ships its own {isPg ? "PostgreSQL" : "MySQL"}{" "} + client tools. This build could not locate or run them, so + a manual install is needed: +

+ {pgToolStatus?.pg_restore_bundled_available && ( +

+ A bundled copy is present but could not run — reinstall + Gridline, or install the tools below. +

+ )} +
+
+ + Install + + +
+
+                                {installInstructions}
+                            
+
+
+ )} + + {!checkingTools && !toolsMissing && ( + <> + {/* Restore tool */} + {isPg && pgToolStatus?.pg_restore_found && ( + + + Using{" "} + {pgToolStatus.pg_restore_source === "bundled" + ? "bundled" + : "system"}{" "} + {restoreToolResolvedName} + + + )} + + {isMysql && ( + + + Restores run in-process over the driver connection — + no MySQL client installation required + + + )} + + {/* Format (PostgreSQL only) */} + {isPg && ( + + + + )} + + {/* Backup file */} + + setFilePath(e.target.value)} + placeholder="/path/to/backup.dump" + className={inputClass} + /> + + + + {/* Schema (optional) */} + {(isPg || isMysql) && ( + + + + )} + + + + {/* Clean toggle */} + + + + + {isPg && format === "plain" && ( +
+ Plain SQL restores run via psql and don't support + DROP-before-CREATE. Use Custom Archive for clean + restores. +
+ )} + + {/* Destructive confirmation */} +
+ + {isMysql && ( +

+ MySQL DDL commits as it runs and can't be rolled + back — a failed restore may leave this database + partially applied. +

+ )} +
+ + {/* Progress */} + {activeJob && ( +
+ +
+ )} + + )} +
); -} \ No newline at end of file +} diff --git a/desktop/src/components/db-viewer/SchemaDiffPage.tsx b/desktop/src/components/db-viewer/SchemaDiffPage.tsx index 7002d80..b132551 100644 --- a/desktop/src/components/db-viewer/SchemaDiffPage.tsx +++ b/desktop/src/components/db-viewer/SchemaDiffPage.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { Play, Copy, AlertTriangle, GitCompare } from "lucide-react"; import { useVirtualizer } from "@tanstack/react-virtual"; import { compareSchemas, getSchemas, getDatabases } from "../../lib/commands"; +import { controlClass } from "./objects/formRow"; import { useConnectionStore } from "../../stores/connectionStore"; import { useDbViewerStore } from "../../stores/dbViewerStore"; import type { DiffItem, DiffReport } from "../../lib/types"; @@ -25,9 +26,9 @@ function familyOf(dbType: string): string { function SchemaDiffItemCard({ item }: { item: DiffItem }) { return ( -
+
- {item.kind} + {item.kind} {item.name} {item.destructive && ( @@ -43,7 +44,7 @@ function SchemaDiffItemCard({ item }: { item: DiffItem }) { {item.sync_sql.length > 0 && (
SQL -
{item.sync_sql.join("\n")}
+
{item.sync_sql.join("\n")}
@@ -213,7 +214,7 @@ export function SchemaDiffPage({ connectionId }: { connectionId: string }) { @@ -222,7 +223,7 @@ export function SchemaDiffPage({ connectionId }: { connectionId: string }) { @@ -236,7 +237,7 @@ export function SchemaDiffPage({ connectionId }: { connectionId: string }) { {error &&
{error}
}
-
+
{report && report.items.length === 0 && (
Schemas are identical — no differences found.
)} diff --git a/desktop/src/components/db-viewer/SyncPage.test.tsx b/desktop/src/components/db-viewer/SyncPage.test.tsx index 326ca64..bec3dd1 100644 --- a/desktop/src/components/db-viewer/SyncPage.test.tsx +++ b/desktop/src/components/db-viewer/SyncPage.test.tsx @@ -59,4 +59,50 @@ describe("SyncPage DB-aware", () => { expect(options).not.toContain("pg1"); expect(options).not.toContain("sq1"); }); +}); + +describe("SyncPage tool re-check", () => { + beforeEach(() => { + vi.restoreAllMocks(); + mockConnections.length = 0; + useBackupStore.setState({ jobs: [], activeJobId: null, progress: 0 }); + useNotificationStore.setState({ notifications: [] }); + }); + + it("re-checks MySQL tools with force when Check again is clicked", async () => { + mockConnections.push( + { id: "my1", db_type: "mysql", name: "MySQL 1", database: "db1" }, + { id: "my2", db_type: "mysql", name: "MySQL 2", database: "db2" }, + ); + vi.spyOn(commands, "detectPgTools").mockResolvedValue(pgToolsOk); + const spy = vi.spyOn(commands, "detectMysqlTools").mockResolvedValue(mysqlToolsOk); + vi.spyOn(commands, "getSchemas").mockResolvedValue([]); + render(); + const [sourceSelect] = screen.getAllByRole("combobox") as HTMLSelectElement[]; + fireEvent.change(sourceSelect, { target: { value: "my1" } }); + await waitFor(() => expect(spy).toHaveBeenCalledWith(false)); + fireEvent.click(screen.getByRole("button", { name: /check again/i })); + await waitFor(() => expect(spy).toHaveBeenCalledWith(true)); + }); + + it("lists the specific missing MySQL role", async () => { + mockConnections.push( + { id: "my1", db_type: "mysql", name: "MySQL 1", database: "db1" }, + { id: "my2", db_type: "mysql", name: "MySQL 2", database: "db2" }, + ); + vi.spyOn(commands, "detectPgTools").mockResolvedValue(pgToolsOk); + vi.spyOn(commands, "detectMysqlTools").mockResolvedValue({ + mysqldumpFound: false, + mysqlFound: true, + mysqldumpVersion: null, + mysqlVersion: "8.0", + mysqldumpSource: null, + mysqlSource: "system", + }); + vi.spyOn(commands, "getSchemas").mockResolvedValue([]); + render(); + const [sourceSelect] = screen.getAllByRole("combobox") as HTMLSelectElement[]; + fireEvent.change(sourceSelect, { target: { value: "my1" } }); + await waitFor(() => expect(screen.getByText(/mysqldump is missing/i)).toBeTruthy()); + }); }); \ No newline at end of file diff --git a/desktop/src/components/db-viewer/SyncPage.tsx b/desktop/src/components/db-viewer/SyncPage.tsx index 5aecaa2..c0ab47a 100644 --- a/desktop/src/components/db-viewer/SyncPage.tsx +++ b/desktop/src/components/db-viewer/SyncPage.tsx @@ -1,7 +1,11 @@ import { useState, useEffect, useCallback, useRef } from "react"; -import { ArrowLeftRight, Database } from "lucide-react"; -import { Button } from "../ui/Button"; +import { ArrowLeftRight } from "lucide-react"; import { BackupProgress } from "./BackupProgress"; +import { + FormRow, + FormSectionHeader, + controlClass, +} from "./objects/formRow"; import { useBackupStore } from "../../stores/backupStore"; import { useConnectionStore } from "../../stores/connectionStore"; import { useNotificationStore } from "../../stores/notificationStore"; @@ -59,16 +63,15 @@ export function SyncPage() { }, [activeJob, notify]); // Tool detection: depends on the selected source connection's DB type - useEffect(() => { - setPgToolStatus(null); - setMysqlToolStatus(null); - setConfirmed(false); - - if (isPg) { - setCheckingTools(true); - detectPgTools() - .then((status) => setPgToolStatus(status)) - .catch(() => + const checkTools = useCallback( + async (force: boolean) => { + setPgToolStatus(null); + setMysqlToolStatus(null); + if (isPg) { + setCheckingTools(true); + try { + setPgToolStatus(await detectPgTools(force)); + } catch { setPgToolStatus({ pg_dump_found: false, pg_restore_found: false, @@ -76,14 +79,15 @@ export function SyncPage() { pg_restore_version: null, pg_dump_source: null, pg_restore_source: null, - }), - ) - .finally(() => setCheckingTools(false)); - } else if (isMysql) { - setCheckingTools(true); - detectMysqlTools() - .then((status) => setMysqlToolStatus(status)) - .catch(() => + }); + } finally { + setCheckingTools(false); + } + } else if (isMysql) { + setCheckingTools(true); + try { + setMysqlToolStatus(await detectMysqlTools(force)); + } catch { setMysqlToolStatus({ mysqldumpFound: false, mysqlFound: false, @@ -91,11 +95,19 @@ export function SyncPage() { mysqlVersion: null, mysqldumpSource: null, mysqlSource: null, - }), - ) - .finally(() => setCheckingTools(false)); - } - }, [isPg, isMysql]); + }); + } finally { + setCheckingTools(false); + } + } + }, + [isPg, isMysql], + ); + + useEffect(() => { + setConfirmed(false); + void checkTools(false); + }, [checkTools]); // Fetch schemas from the source connection when it changes useEffect(() => { @@ -195,229 +207,206 @@ export function SyncPage() { ? connections.filter((c) => c.db_type === dbType) : []; + const sourceName = + connections.find((c) => c.id === sourceConnectionId)?.name ?? + sourceConnectionId; + const targetName = + connections.find((c) => c.id === targetConnectionId)?.name ?? + targetConnectionId; + return ( -
+
{/* Toolbar header */} -
- - DB Sync - - Transfer data between databases via pipe - -
- - {/* Content */} -
-
- {/* Tool check */} - {checkingTools && checkingMessage && ( -
-

- {checkingMessage} -

-
+
+
+ + DB Sync + + Transfer data between databases via pipe + +
+
+ {(isPg || isMysql) && ( + )} - - {toolsMissing && !toolsBundled && ( -
-

- {isPg - ? "PostgreSQL tools not found" - : "MySQL tools not found"} -

-

- Both {isPg ? "pg_dump and pg_restore" : "mysqldump and mysql"} are required for - database sync. -

-
    - {isPg && !pgToolStatus?.pg_dump_found && ( -
  • pg_dump is missing.
  • - )} - {isPg && !pgToolStatus?.pg_restore_found && ( -
  • pg_restore is missing.
  • - )} - {isMysql && !mysqlToolStatus?.mysqldumpFound && ( -
  • mysqldump is missing.
  • - )} - {isMysql && !mysqlToolStatus?.mysqlFound && ( -
  • mysql is missing.
  • - )} -
-
- )} - {!checkingTools && !toolsMissing && ( - <> - {/* Configuration card */} -
- {/* Source & Target connection pickers */} -
-
- - -
-
- - -
-
- - {/* Schema (optional) */} - {(isPg || isMysql) && ( -
- - -
- )} - - {/* Flow indicator */} - {sourceConnectionId && targetConnectionId && ( -
- - {connections.find( - (c) => - c.id === sourceConnectionId, - )?.name ?? sourceConnectionId} - - - - {connections.find( - (c) => - c.id === targetConnectionId, - )?.name ?? targetConnectionId} - -
- )} -
- - {/* Destructive confirmation */} -
- -
- - {/* Progress */} - {activeJob && ( -
- -
- )} - - {/* Actions */} -
- -
- + )}
+ + {/* Content */} +
+ {/* Tool check */} + {checkingTools && checkingMessage && ( +
+ {checkingMessage} +
+ )} + + {toolsMissing && !toolsBundled && ( +
+

+ {isPg + ? "PostgreSQL tools not found" + : "MySQL tools not found"} +

+

+ Both {isPg ? "pg_dump and pg_restore" : "mysqldump and mysql"} are required for + database sync. +

+
    + {isPg && !pgToolStatus?.pg_dump_found && ( +
  • pg_dump is missing.
  • + )} + {isPg && !pgToolStatus?.pg_restore_found && ( +
  • pg_restore is missing.
  • + )} + {isMysql && !mysqlToolStatus?.mysqldumpFound && ( +
  • mysqldump is missing.
  • + )} + {isMysql && !mysqlToolStatus?.mysqlFound && ( +
  • mysql is missing.
  • + )} + {isMysql && mysqlToolStatus?.mysqlResolvedName && ( +
  • + Resolved MySQL client: {mysqlToolStatus.mysqlResolvedName} +
  • + )} +
+
+ )} + + {!checkingTools && !toolsMissing && ( + <> + + + {/* Source connection */} + + + + + {/* Target connection — filtered to the source's db type */} + + + + + {/* Flow indicator */} + {sourceConnectionId && targetConnectionId && ( + +
+ + {sourceName} + + + + {targetName} + +
+
+ )} + + {/* Schema (optional) */} + {(isPg || isMysql) && ( + + + + )} + + {/* Destructive confirmation */} +
+ +
+ + {/* Progress */} + {activeJob && ( +
+ +
+ )} + + )} +
); -} \ No newline at end of file +} diff --git a/desktop/src/components/ui/CopyButton.test.tsx b/desktop/src/components/ui/CopyButton.test.tsx new file mode 100644 index 0000000..752aa70 --- /dev/null +++ b/desktop/src/components/ui/CopyButton.test.tsx @@ -0,0 +1,137 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent, waitFor, act } from "@testing-library/react"; +import { CopyButton } from "./CopyButton"; + +function mockClipboard(writeText: (text: string) => Promise) { + Object.defineProperty(navigator, "clipboard", { + value: { writeText }, + configurable: true, + writable: true, + }); +} + +/** + * Capture Node-level unhandled rejections. jsdom does not dispatch + * `window.unhandledrejection` for rejections originating in the environment, so + * the process event is the only reliable signal that a failed clipboard write + * escaped the click handler. + */ +interface UnhandledRejectionEmitter { + on(event: "unhandledRejection", listener: (reason: unknown) => void): void; + off(event: "unhandledRejection", listener: (reason: unknown) => void): void; +} + +const nodeProcess = ( + globalThis as unknown as { process?: UnhandledRejectionEmitter } +).process; + +function captureUnhandledRejections() { + const seen: unknown[] = []; + const listener = (reason: unknown) => { + seen.push(reason); + }; + nodeProcess?.on("unhandledRejection", listener); + return { + seen, + stop: () => nodeProcess?.off("unhandledRejection", listener), + }; +} + +/** Let queued microtasks and Node's unhandled-rejection check drain. */ +async function drain() { + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + await new Promise((r) => setTimeout(r, 0)); + }); +} + +describe("CopyButton", () => { + beforeEach(() => { + vi.restoreAllMocks(); + mockClipboard(vi.fn().mockResolvedValue(undefined)); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("copies the given text to the clipboard", async () => { + render(); + fireEvent.click(screen.getByTitle("Copy to clipboard")); + await waitFor(() => + expect(navigator.clipboard.writeText).toHaveBeenCalledWith( + "brew install mysql-client", + ), + ); + expect(navigator.clipboard.writeText).toHaveBeenCalledTimes(1); + }); + + it("shows a transient Copied confirmation", async () => { + render(); + const btn = screen.getByTitle("Copy to clipboard"); + expect(btn.textContent).toBe("Copy"); + + fireEvent.click(btn); + + await waitFor(() => expect(screen.getByTitle("Copy to clipboard").textContent).toBe("Copied")); + }); + + it("reverts to Copy after the confirmation delay", async () => { + vi.useFakeTimers(); + render(); + const btn = screen.getByTitle("Copy to clipboard"); + + fireEvent.click(btn); + await act(async () => { + await Promise.resolve(); + }); + expect(screen.getByTitle("Copy to clipboard").textContent).toBe("Copied"); + + await act(async () => { + vi.advanceTimersByTime(1500); + }); + expect(screen.getByTitle("Copy to clipboard").textContent).toBe("Copy"); + }); + + it("stays quiet when the clipboard rejects", async () => { + const rejected = vi.fn().mockRejectedValue(new Error("NotAllowedError")); + mockClipboard(rejected); + const unhandled = captureUnhandledRejections(); + + render(); + fireEvent.click(screen.getByTitle("Copy to clipboard")); + + await waitFor(() => expect(rejected).toHaveBeenCalled()); + await drain(); + + expect(screen.getByTitle("Copy to clipboard").textContent).toBe("Copy"); + expect(unhandled.seen).toEqual([]); + unhandled.stop(); + }); + + it("stays quiet when the clipboard API is unavailable", async () => { + Object.defineProperty(navigator, "clipboard", { + value: undefined, + configurable: true, + writable: true, + }); + const unhandled = captureUnhandledRejections(); + + render(); + expect(() => + fireEvent.click(screen.getByTitle("Copy to clipboard")), + ).not.toThrow(); + + await drain(); + + expect(screen.getByTitle("Copy to clipboard").textContent).toBe("Copy"); + expect(unhandled.seen).toEqual([]); + unhandled.stop(); + }); + + it("uses a custom label for the accessible name", () => { + render(); + expect(screen.getByTitle("Copy install command")).toBeTruthy(); + expect(screen.getByLabelText("Copy install command")).toBeTruthy(); + }); +}); diff --git a/desktop/src/components/ui/CopyButton.tsx b/desktop/src/components/ui/CopyButton.tsx new file mode 100644 index 0000000..8d076b9 --- /dev/null +++ b/desktop/src/components/ui/CopyButton.tsx @@ -0,0 +1,66 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { Check, Copy } from "lucide-react"; + +interface CopyButtonProps { + /** Text written to the clipboard. */ + text: string; + /** Accessible name; also the tooltip. */ + label?: string; + className?: string; +} + +/** + * Small inline copy control with a transient "Copied" confirmation. + * + * Clipboard writes are best-effort: `navigator.clipboard` is absent in some + * webview contexts and can reject on permission grounds, so a failure leaves + * the label unchanged instead of surfacing an error the user cannot act on. + */ +export function CopyButton({ + text, + label = "Copy to clipboard", + className = "", +}: CopyButtonProps) { + const [copied, setCopied] = useState(false); + const timerRef = useRef(null); + + useEffect( + () => () => { + if (timerRef.current !== null) window.clearTimeout(timerRef.current); + }, + [], + ); + + const handleCopy = useCallback(async () => { + try { + await navigator.clipboard.writeText(text); + } catch { + return; + } + setCopied(true); + if (timerRef.current !== null) window.clearTimeout(timerRef.current); + timerRef.current = window.setTimeout(() => setCopied(false), 1500); + }, [text]); + + return ( + + ); +} diff --git a/desktop/src/lib/commands.test.ts b/desktop/src/lib/commands.test.ts index 6a39e41..e7a53d3 100644 --- a/desktop/src/lib/commands.test.ts +++ b/desktop/src/lib/commands.test.ts @@ -432,4 +432,15 @@ describe("v0.7.8 command wrappers exist", () => { expect(typeof (cmd as Record)[name]).toBe("function"); } }); +}); + +describe("tool detection wrappers forward the force flag", () => { + it("passes force=true so an explicit re-check bypasses the Rust cache", async () => { + const { detectMysqlTools, detectPgTools } = await import("./commands"); + vi.mocked(invoke).mockClear(); + await detectMysqlTools(true); + expect(invoke).toHaveBeenCalledWith("detect_mysql_tools", { force: true }); + await detectPgTools(true); + expect(invoke).toHaveBeenCalledWith("detect_pg_tools", { force: true }); + }); }); \ No newline at end of file diff --git a/desktop/src/lib/commands.ts b/desktop/src/lib/commands.ts index 8cd1a0b..4fd25e7 100644 --- a/desktop/src/lib/commands.ts +++ b/desktop/src/lib/commands.ts @@ -147,8 +147,8 @@ export async function refreshConnection(connectionId: string): Promise { // ─── Backup / Restore / Sync ────────────────────────────────── -export async function detectPgTools(): Promise { - return invoke("detect_pg_tools"); +export async function detectPgTools(force = false): Promise { + return invoke("detect_pg_tools", { force }); } export async function pgDump(connectionId: string, options: BackupOptions): Promise { @@ -169,8 +169,8 @@ export async function cancelQuery(connectionId: string): Promise { return invoke("cancel_query", { connectionId }); } -export async function detectMysqlTools(): Promise { - return invoke("detect_mysql_tools"); +export async function detectMysqlTools(force = false): Promise { + return invoke("detect_mysql_tools", { force }); } export async function mysqlDump(connectionId: string, options: MySqlBackupOptions): Promise { diff --git a/desktop/src/lib/docs-coverage.test.ts b/desktop/src/lib/docs-coverage.test.ts index 7488323..02bf70c 100644 --- a/desktop/src/lib/docs-coverage.test.ts +++ b/desktop/src/lib/docs-coverage.test.ts @@ -4,6 +4,7 @@ import { describe, it, expect } from "vitest"; import agents from "../../../AGENTS.md?raw"; import readme from "../../../README.md?raw"; import landing from "../../../www/public/landing.js?raw"; +import roadmap from "../../../ROADMAP.md?raw"; describe("v0.8.2 docs coverage", () => { it("AGENTS.md marks inline cell editing complete", () => { @@ -79,4 +80,18 @@ describe("v0.8.2 docs coverage", () => { expect(agents).toMatch(/\.sql/); expect(agents.toLowerCase()).toContain("in-process"); }); +}); + +describe("v0.8.3 tool resolver docs", () => { + it("AGENTS.md documents the unified resolver and the nested resource path", () => { + expect(agents).toContain("tool_resolver"); + expect(agents).toContain("resources/pg_tools"); + expect(agents).toContain("resources/mysql_tools"); + }); + it("AGENTS.md documents that both engines share the resolver", () => { + expect(agents).toMatch(/mariadb-dump.*mysqldump|mysqldump.*mariadb-dump/s); + }); + it("ROADMAP describes MySQL backup via mariadb-dump/mysqldump", () => { + expect(roadmap).toContain("mariadb-dump"); + }); }); \ No newline at end of file diff --git a/desktop/src/lib/types.ts b/desktop/src/lib/types.ts index cd70973..801dec9 100644 --- a/desktop/src/lib/types.ts +++ b/desktop/src/lib/types.ts @@ -325,6 +325,10 @@ export interface PgToolStatus { pg_restore_version: string | null; pg_dump_source: string | null; pg_restore_source: string | null; + pg_dump_resolved_name?: string | null; + pg_restore_resolved_name?: string | null; + pg_dump_bundled_available?: boolean; + pg_restore_bundled_available?: boolean; } // ─── Backup Types: MySQL / SQLite / Settings (v0.7.8) ─────────── @@ -338,6 +342,11 @@ export interface MySqlToolStatus { mysqlVersion: string | null; mysqldumpSource: string | null; mysqlSource: string | null; + mysqldumpResolvedName?: string | null; + mysqlResolvedName?: string | null; + mysqldumpIsMariaDb?: boolean; + mysqldumpBundledAvailable?: boolean; + mysqlBundledAvailable?: boolean; } export interface MySqlBackupOptions { diff --git a/desktop/src/stores/backupStore.test.ts b/desktop/src/stores/backupStore.test.ts new file mode 100644 index 0000000..f3a78a4 --- /dev/null +++ b/desktop/src/stores/backupStore.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const listeners = vi.hoisted( + () => ({} as Record void>), +); +vi.mock("@tauri-apps/api/event", () => ({ + listen: vi.fn(async (name: string, cb: (e: { payload: unknown }) => void) => { + listeners[name] = cb; + return () => {}; + }), +})); + +import { useBackupStore } from "./backupStore"; +import { useNotificationStore } from "./notificationStore"; + +describe("backupStore advisory warning", () => { + beforeEach(() => { + useNotificationStore.setState({ notifications: [] }); + useBackupStore.setState({ jobs: [], activeJobId: null, progress: 0 }); + }); + + it("surfaces a non-fatal warning as an info notification", async () => { + await useBackupStore.getState().initListener(); + listeners["backup-progress"]({ + payload: { + job_id: "j1", + status: "running", + progress: 0, + warning: "Client/server version mismatch: the resolved dump tool is old", + }, + }); + const notes = useNotificationStore.getState().notifications; + expect(notes.some((n) => n.message.includes("version mismatch"))).toBe(true); + expect(notes.some((n) => n.type === "info")).toBe(true); + }); +}); diff --git a/desktop/src/stores/backupStore.ts b/desktop/src/stores/backupStore.ts index b8a5b00..828b0c3 100644 --- a/desktop/src/stores/backupStore.ts +++ b/desktop/src/stores/backupStore.ts @@ -1,11 +1,13 @@ import { create } from "zustand"; import { listen } from "@tauri-apps/api/event"; import type { BackupJob } from "../lib/types"; +import { useNotificationStore } from "./notificationStore"; interface BackupJobEvent { job_id: string; status: "running" | "completed" | "failed"; error?: string | null; + warning?: string | null; } interface BackupStore { @@ -71,7 +73,10 @@ export const useBackupStore = create((set, get) => ({ initListener: async () => { await listen("backup-progress", (event) => { - const { job_id, status, error } = event.payload; + const { job_id, status, error, warning } = event.payload; + if (warning) { + useNotificationStore.getState().notify(warning, "info"); + } if (status === "completed") { get().completeJob(job_id); } else if (status === "failed") { diff --git a/package.json b/package.json index 96a313f..8341de3 100644 --- a/package.json +++ b/package.json @@ -8,8 +8,6 @@ "tauri:dev": "bun run --filter gridline-desktop tauri:dev", "dev:www": "bun run --filter gridline-www dev", "build:www": "bun run --filter gridline-www build", - "test": "bun run --filter gridline-desktop test", - "icons": "bash scripts/regenerate-icons.sh", - "icons:verify": "bun scripts/verify-app-icon.mjs" + "test": "bun run --filter gridline-desktop test" } } diff --git a/scripts/fix-icns.mjs b/scripts/fix-icns.mjs deleted file mode 100755 index 23fc131..0000000 --- a/scripts/fix-icns.mjs +++ /dev/null @@ -1,143 +0,0 @@ -#!/usr/bin/env bun -/** - * Strip the icon representations that make macOS render Gridline's icon on a - * "legacy plate" at small sizes. - * - * ## Why this exists - * - * On macOS 26+ (Tahoe) IconServices wraps *legacy* app icons in a light - * rounded-square plate for render sizes below ~72px. `tauri icon` emits exactly - * those legacy representations, so the artifact shows up in the Dock and in any - * launcher that resolves app icons through `NSWorkspace.icon(forFile:)` - * (Spotlight, Vicinae, Raycast, …): the app icon gets a white/grey border. - * - * Two representations in `tauri icon` output trigger it, independently: - * - * - `ic12` (64x64) -> plates the 40-64px range - * - `is32` / `s8mk` (16x16 RGB+mask) - * `il32` / `l8mk` (32x32 RGB+mask) -> plates the 16-32px range - * - * Dropping them leaves a perfectly valid family (ic07/ic08/ic09/ic10/ic11/ic13/ - * ic14 — 32, 128, 256, 512 and 1024px), and macOS then renders cleanly at every - * size from 16px to 1024px. There is no visible quality cost: every small size - * is resampled from the 128px+ reps with high-quality interpolation. - * - * Note `tauri icon` re-emits all of these chunks on every run, which is why this - * post-processing step is committed rather than done once by hand. Always - * regenerate through `scripts/regenerate-icons.sh`, never with bare - * `tauri icon`. - * - * ## Usage - * - * bun scripts/fix-icns.mjs [icns-path] [--keep-ic12] - * - * Defaults to desktop/src-tauri/icons/icon.icns. Edits the file in place and - * prints a before/after chunk summary. - */ -import { readFileSync, renameSync, writeFileSync } from "node:fs"; -import { dirname, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; - -const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); -const DEFAULT_ICNS = "desktop/src-tauri/icons/icon.icns"; - -// 16x16 / 32x32 RGB + 8-bit mask pairs. These predate macOS 10.7's PNG chunks -// and are what IconServices uses for the smallest sizes. -const LEGACY_CHUNKS = ["is32", "s8mk", "il32", "l8mk"]; - -// 32x32@2x = 64x64. Legitimate modern chunk, but with legacy (padded) artwork -// macOS plates it across the 40-64px range. -const IC12 = "ic12"; - -const args = process.argv.slice(2); -const keepIc12 = args.includes("--keep-ic12"); -const pathArg = args.find((a) => !a.startsWith("-")); - -const unknown = args.filter((a) => a.startsWith("-") && a !== "--keep-ic12"); -if (unknown.length > 0) { - console.error(`fix-icns: unknown option(s): ${unknown.join(", ")}`); - console.error("usage: bun scripts/fix-icns.mjs [icns-path] [--keep-ic12]"); - process.exit(2); -} - -const target = resolve(REPO_ROOT, pathArg ?? DEFAULT_ICNS); - -/** Read an .icns container into its ordered chunks. */ -function readIcns(buf) { - if (buf.length < 8 || buf.toString("latin1", 0, 4) !== "icns") { - throw new Error(`not an .icns file: ${target}`); - } - const declared = buf.readUInt32BE(4); - if (declared !== buf.length) { - throw new Error( - `truncated/oversized .icns: header says ${declared} bytes, file is ${buf.length}`, - ); - } - const chunks = []; - let offset = 8; - while (offset + 8 <= buf.length) { - const type = buf.toString("latin1", offset, offset + 4); - const size = buf.readUInt32BE(offset + 4); - if (size < 8 || offset + size > buf.length) { - throw new Error( - `corrupt .icns: chunk "${type}" at offset ${offset} declares ${size} bytes`, - ); - } - chunks.push({ type, bytes: buf.subarray(offset, offset + size) }); - offset += size; - } - return chunks; -} - -function writeIcns(chunks) { - const body = Buffer.concat(chunks.map((c) => c.bytes)); - const header = Buffer.alloc(8); - header.write("icns", 0, "latin1"); - header.writeUInt32BE(body.length + 8, 4); - return Buffer.concat([header, body]); -} - -const drop = new Set(LEGACY_CHUNKS); -if (!keepIc12) drop.add(IC12); - -let original; -try { - original = readFileSync(target); -} catch (err) { - console.error(`fix-icns: cannot read ${target}: ${err.message}`); - process.exit(1); -} - -let chunks; -try { - chunks = readIcns(original); -} catch (err) { - console.error(`fix-icns: ${err.message}`); - process.exit(1); -} - -const before = chunks.map((c) => c.type); -const kept = chunks.filter((c) => !drop.has(c.type)); -const removed = before.filter((t) => drop.has(t)); - -if (removed.length === 0) { - console.log(`fix-icns: ${target} is already clean (no ${[...drop].join("/")} chunks)`); - process.exit(0); -} - -if (kept.length === 0) { - console.error("fix-icns: refusing to write an icon family with no chunks left"); - process.exit(1); -} - -const out = writeIcns(kept); -// Write to a sibling temp file then rename, so an interrupted run can't leave a -// half-written icon in the tree. -const tmp = `${target}.tmp`; -writeFileSync(tmp, out); -renameSync(tmp, target); - -console.log(`fix-icns: ${target}`); -console.log(` removed: ${removed.join(", ")}`); -console.log(` chunks: ${kept.map((c) => c.type).join(", ")}`); -console.log(` ${original.length} -> ${out.length} bytes`); diff --git a/scripts/regenerate-icons.sh b/scripts/regenerate-icons.sh deleted file mode 100755 index ec8b0e7..0000000 --- a/scripts/regenerate-icons.sh +++ /dev/null @@ -1,86 +0,0 @@ -#!/usr/bin/env bash -# -# Regenerate desktop/src-tauri/icons/* from icons/icon.svg. -# -# Usage: -# scripts/regenerate-icons.sh -# -# `icon.svg` is the source of truth for the app icon; the PNG/ICNS/ICO set next -# to it is generated output and must be regenerated whenever the SVG changes. -# -# Always regenerate through this script rather than calling `tauri icon` -# directly, for two reasons: -# -# 1. `tauri icon` only supports a single output directory for all platforms, so -# running it in place would also drop generated `android/` and `ios/` icon -# trees into the (desktop-only) icons directory. This generates into a temp -# dir and copies back just the desktop set. -# -# 2. `tauri icon` emits the legacy `is32`/`s8mk`/`il32`/`l8mk` and `ic12` -# representations, which make macOS 26+ draw the app icon on a grey -# "legacy plate" at small sizes (a white border in the Dock and in any -# launcher that uses NSWorkspace). fix-icns.mjs strips them — see that file -# for the details and the measurements. -# -# The committed icon set was last regenerated because those stale rasters meant -# the shipped icon no longer matched icon.svg at all. -set -euo pipefail - -REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" -DESKTOP_DIR="$REPO_ROOT/desktop" -ICONS_DIR="$DESKTOP_DIR/src-tauri/icons" -SOURCE_SVG="$ICONS_DIR/icon.svg" - -if ! command -v bun >/dev/null 2>&1; then - echo "error: bun is required (https://bun.sh)" >&2 - exit 1 -fi - -if [ ! -f "$SOURCE_SVG" ]; then - echo "error: missing icon source $SOURCE_SVG" >&2 - exit 1 -fi - -# Files `tauri icon` produces for the desktop/Windows/macOS targets. The -# android/ and ios/ output is intentionally ignored. -DESKTOP_ICON_FILES=( - 32x32.png - 64x64.png - 128x128.png - 128x128@2x.png - icon.png - icon.icns - icon.ico - Square30x30Logo.png - Square44x44Logo.png - Square71x71Logo.png - Square89x89Logo.png - Square107x107Logo.png - Square142x142Logo.png - Square150x150Logo.png - Square284x284Logo.png - Square310x310Logo.png - StoreLogo.png -) - -TMP_DIR="$(mktemp -d)" -trap 'rm -rf "$TMP_DIR"' EXIT - -echo "==> rendering $SOURCE_SVG with tauri icon" -( cd "$DESKTOP_DIR" && bunx tauri icon "src-tauri/icons/icon.svg" -o "$TMP_DIR" ) - -for file in "${DESKTOP_ICON_FILES[@]}"; do - if [ ! -f "$TMP_DIR/$file" ]; then - echo "error: tauri icon did not produce $file" >&2 - exit 1 - fi - cp "$TMP_DIR/$file" "$ICONS_DIR/$file" - echo " updated icons/$file" -done - -echo "==> stripping legacy ICNS representations" -bun "$REPO_ROOT/scripts/fix-icns.mjs" "$ICONS_DIR/icon.icns" - -echo -echo "icons regenerated. Verify with:" -echo " bun $REPO_ROOT/scripts/verify-app-icon.mjs" diff --git a/scripts/verify-app-icon.mjs b/scripts/verify-app-icon.mjs deleted file mode 100755 index c46b1cb..0000000 --- a/scripts/verify-app-icon.mjs +++ /dev/null @@ -1,147 +0,0 @@ -#!/usr/bin/env bun -/** - * Verify that the repo's macOS app icon is not being rendered on a "legacy - * plate" — the grey/white rounded-square border macOS 26+ draws behind app - * icons at small sizes (Dock, Spotlight, Vicinae, Raycast, …). - * - * Usage: - * bun scripts/verify-app-icon.mjs - * - * Builds a throwaway .app around desktop/src-tauri/icons/icon.icns, asks AppKit - * (`NSWorkspace.icon(forFile:)` — the same API launchers use) to render it at a - * range of sizes, and samples the pixels to detect the plate. Exits non-zero if - * any size comes out plated. - * - * This is the regression check for `fix-icns.mjs`: `tauri icon` re-adds the - * offending representations every time it runs, so run this (or - * `scripts/regenerate-icons.sh`, which calls the fix) after touching icons. - * - * macOS-only. Requires Swift (Xcode or the Command Line Tools). - */ -import { spawnSync } from "node:child_process"; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { dirname, join, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; - -const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); -const ICNS = join(REPO_ROOT, "desktop/src-tauri/icons/icon.icns"); - -if (process.platform !== "darwin") { - console.log("verify-app-icon: not macOS, nothing to check"); - process.exit(0); -} - -if (!existsSync(ICNS)) { - console.error(`verify-app-icon: missing ${ICNS}`); - process.exit(1); -} - -const work = mkdtempSync(join(tmpdir(), "gridline-icon-check-")); -process.on("exit", () => rmSync(work, { recursive: true, force: true })); - -// The synthetic bundle just needs to be a directory with an Info.plist pointing -// at the icon. A unique path + bundle identifier each run keeps IconServices' -// cache from serving a previous run's verdict. -const appDir = join(work, "IconCheck.app"); -mkdirSync(join(appDir, "Contents", "Resources"), { recursive: true }); -writeFileSync(join(appDir, "Contents", "Resources", "icon.icns"), readFileSync(ICNS)); -writeFileSync( - join(appDir, "Contents", "Info.plist"), - ` - - - - CFBundleIdentifiercom.gridline.iconcheck.${process.pid}${Date.now()} - CFBundleNameIconCheck - CFBundleIconFileicon.icns - CFBundlePackageTypeAPPL - - -`, -); - -// Samples the pixel just inside the icon square at 12% height. A legacy plate -// there is a neutral light grey; the artwork itself is dark at that point. -const SWIFT = ` -import AppKit - -let appPath = CommandLine.arguments[1] -let sizes = CommandLine.arguments[2].split(separator: ",").compactMap { Int($0) } -let image = NSWorkspace.shared.icon(forFile: appPath) - -for px in sizes { - let rep = NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: px, pixelsHigh: px, - bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false, - colorSpaceName: .calibratedRGB, bytesPerRow: px * 4, bitsPerPixel: 32)! - let ctx = NSGraphicsContext(bitmapImageRep: rep)! - NSGraphicsContext.saveGraphicsState() - NSGraphicsContext.current = ctx - ctx.imageInterpolation = .high - NSColor.clear.setFill() - NSRect(x: 0, y: 0, width: px, height: px).fill(using: .copy) - let natural = image.size - let scale = min(CGFloat(px) / natural.width, CGFloat(px) / natural.height) - let w = natural.width * scale - let h = natural.height * scale - image.draw(in: NSRect(x: (CGFloat(px) - w) / 2, y: (CGFloat(px) - h) / 2, width: w, height: h), - from: .zero, operation: .sourceOver, fraction: 1.0, respectFlipped: true, hints: nil) - NSGraphicsContext.restoreGraphicsState() - - let probe = rep.colorAt(x: px / 2, y: max(1, Int(Double(px) * 0.12))) - let r = probe?.redComponent ?? 0 - let g = probe?.greenComponent ?? 0 - let b = probe?.blueComponent ?? 0 - let a = probe?.alphaComponent ?? 0 - let v = Int((r * 255).rounded()) - let plated = a > 0.78 && abs(r - g) < 0.04 && abs(g - b) < 0.05 && v > 170 && v < 245 - print("\\(px) \\(plated ? "PLATED" : "OK") v=\\(v)") -} -`; - -const swiftFile = join(work, "probe.swift"); -writeFileSync(swiftFile, SWIFT); -const sizes = [16, 24, 32, 40, 48, 64, 72, 96, 128, 256, 512]; - -function runSwift(env) { - return spawnSync("swift", [swiftFile, appDir, sizes.join(",")], { - encoding: "utf8", - env: { ...process.env, ...env }, - }); -} - -// `swift` from a full Xcode install refuses to run until its licence is accepted; -// the Command Line Tools toolchain is a drop-in fallback for a plain AppKit program. -let result = runSwift({}); -if (result.error || result.status !== 0) { - const clt = "/Library/Developer/CommandLineTools"; - if (existsSync(join(clt, "usr/bin/swift"))) { - result = runSwift({ DEVELOPER_DIR: clt }); - } -} - -if (result.error || !result.stdout) { - console.error("verify-app-icon: could not run Swift/AppKit"); - console.error(result.error?.message ?? result.stderr?.trim() ?? ""); - process.exit(1); -} - -const lines = result.stdout.trim().split("\n").filter((l) => l.includes("PLATED") || l.includes("OK")); -const plated = lines.filter((l) => l.split(" ")[1] === "PLATED"); - -console.log("macOS icon render check (NSWorkspace.icon(forFile:))"); -for (const line of lines) { - const [size, verdict] = line.split(" "); - console.log(` ${size.padStart(4)}px ${verdict === "PLATED" ? "PLATED — grey legacy plate" : "ok"}`); -} - -if (plated.length > 0) { - console.error( - `\nFAIL: ${plated.length}/${lines.length} sizes render on a legacy plate.\n` + - "The icns still carries the legacy representations (ic12 / is32 / s8mk / il32 / l8mk).\n" + - "Run: bun scripts/fix-icns.mjs && bun scripts/regenerate-icons.sh", - ); - process.exit(1); -} - -console.log(`\nPASS: clean at every tested size (${sizes.join(", ")}px).`);