fix(tools): resolve external MySQL/PG client tools correctly (#44)
Addresses #44 (closed after the v0.8.3 release builds are green). Three independent defects in one shared tool-resolution path: bundled tools were looked up at the wrong path (so bundled fallback never worked, for PostgreSQL too), macOS GUI apps inherit launchd's minimal PATH, and only mariadb-dump/mariadb were probed — never mysqldump/mysql, which is what brew install mysql-client installs. Both engines now resolve through one headless, cached, timed resolver, plus a non-blocking client/server version warning. Also fixes passing MySQL-only CLI options to the bundled MariaDB client, and carries the tool-page UI polish and the requested #40 icon revert.
@@ -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-<real>-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) |
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 8.3 KiB |
|
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 6.7 KiB |
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 9.4 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 1001 B After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 74 KiB |
@@ -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<String> {
|
||||
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<String>) {
|
||||
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<String>) {
|
||||
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<String>,
|
||||
restore_src: Option<String>,
|
||||
) -> 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<bool>, 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<String> {
|
||||
]
|
||||
}
|
||||
|
||||
/// 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::collections::HashMap<String, bool>>,
|
||||
> = 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=<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<String> {
|
||||
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<String> {
|
||||
///
|
||||
/// `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=<db>` is rejected by
|
||||
/// clients as an invalid value.
|
||||
pub fn build_mysql_dump_args(
|
||||
conn: &MySqlConnParams,
|
||||
options: &MySqlBackupOptions,
|
||||
client_is_mariadb: bool,
|
||||
) -> Vec<String> {
|
||||
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<String>) {
|
||||
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<bool>, 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<String> {
|
||||
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<String>,
|
||||
) {
|
||||
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,
|
||||
|
||||
@@ -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=<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::<String>::new());
|
||||
assert_eq!(tls_args_for_client(true, None), Vec::<String>::new());
|
||||
assert_eq!(tls_args_for_client(true, Some("DISABLED")), Vec::<String>::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);
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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/<subdir>/`
|
||||
//! (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<PathBuf> {
|
||||
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<ToolRequest> {
|
||||
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<ToolRequest> {
|
||||
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<PathBuf> {
|
||||
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<PathBuf> {
|
||||
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 `<program> --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<F>(
|
||||
request: &ToolRequest,
|
||||
trusted: &[PathBuf],
|
||||
path_lookup: &dyn Fn(&str) -> Option<PathBuf>,
|
||||
bundled: Option<&Path>,
|
||||
mut probe_fn: F,
|
||||
) -> Option<Resolution>
|
||||
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 `<program> --version` (stdout, else stderr).
|
||||
pub fn version_of(program: &str) -> Option<String> {
|
||||
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-<real>-MariaDB` compatibility prefix, and returns
|
||||
/// `None` when there is no number at all.
|
||||
pub fn parse_major(version: &str) -> Option<u32> {
|
||||
let mut nums: Vec<u32> = 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::<u32>().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::<u32>() {
|
||||
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<u32>, server: Option<u32>) -> 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<String> {
|
||||
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<Resolution>,
|
||||
pub bundled_path: Option<PathBuf>,
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
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<String>) {
|
||||
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<Mutex<HashMap<String, ToolResolution>>> = OnceLock::new();
|
||||
|
||||
fn cache() -> &'static Mutex<HashMap<String, ToolResolution>> {
|
||||
CACHE.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
}
|
||||
|
||||
pub fn cache_get(key: &str) -> Option<ToolResolution> {
|
||||
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;
|
||||
@@ -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
|
||||
// <resource_dir>/resources/mysql_tools/mariadb-dump, never at
|
||||
// <resource_dir>/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<PathBuf> { 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<PathBuf> {
|
||||
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<PathBuf> { 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<PathBuf> { 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<PathBuf> { 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<PathBuf> { 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-<real version>-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());
|
||||
}
|
||||
@@ -42,6 +42,12 @@ pub struct PgToolStatus {
|
||||
pub pg_restore_version: Option<String>,
|
||||
pub pg_dump_source: Option<String>, // "system" | "bundled" | None
|
||||
pub pg_restore_source: Option<String>,
|
||||
/// The binary name that actually resolved (`mariadb-dump`, `mysqldump`, …).
|
||||
pub pg_dump_resolved_name: Option<String>,
|
||||
pub pg_restore_resolved_name: Option<String>,
|
||||
/// 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<String>,
|
||||
pub mysqldump_source: Option<String>,
|
||||
pub mysql_source: Option<String>,
|
||||
pub mysqldump_resolved_name: Option<String>,
|
||||
pub mysql_resolved_name: Option<String>,
|
||||
/// 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<f64>,
|
||||
pub output_line: Option<String>,
|
||||
pub error: Option<String>,
|
||||
/// Non-fatal advisory message (e.g. a client/server version mismatch).
|
||||
/// `None` for ordinary progress events.
|
||||
pub warning: Option<String>,
|
||||
}
|
||||
|
||||
#[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\""));
|
||||
|
||||
@@ -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 (
|
||||
<AnimatedModal open={open} onClose={onClose}>
|
||||
@@ -124,9 +126,18 @@ export function BackupDialog({ open, connectionId, onClose }: BackupDialogProps)
|
||||
<p className="text-amber-200/80 text-xs">
|
||||
The PostgreSQL client tools are required for backup/restore operations. Install them using:
|
||||
</p>
|
||||
<pre className="text-xs text-amber-100 bg-amber-500/10 rounded p-2 whitespace-pre-wrap">
|
||||
{getPlatformInstructions()}
|
||||
</pre>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-[11px] text-amber-200/60">Install</span>
|
||||
<CopyButton
|
||||
text={installInstructions}
|
||||
className="text-amber-200/70 hover:text-amber-100"
|
||||
/>
|
||||
</div>
|
||||
<pre className="text-xs text-amber-100 bg-amber-500/10 rounded p-2 whitespace-pre-wrap">
|
||||
{installInstructions}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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(<BackupPage connectionId="c1" />);
|
||||
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(<BackupPage connectionId="c2" />);
|
||||
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(<BackupPage connectionId="c3" />);
|
||||
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(<BackupPage connectionId="c4" />);
|
||||
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(<BackupPage connectionId="c-copy" />);
|
||||
const btn = await screen.findByTitle("Copy to clipboard");
|
||||
fireEvent.click(btn);
|
||||
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
|
||||
expect.stringContaining("mysql-client"),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Toolbar header */}
|
||||
<div className="flex items-center gap-2 border-b border-border px-3 py-1.5">
|
||||
<HardDrive size={14} className="text-accent" />
|
||||
<span className="text-xs font-medium text-text">Backup</span>
|
||||
<span className="text-[11px] text-text-muted">
|
||||
{headerDescription}
|
||||
</span>
|
||||
<div className="flex items-center justify-between border-b border-border px-4 py-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<HardDrive size={14} className="text-accent" />
|
||||
<span className="text-xs font-medium text-text">Backup</span>
|
||||
<span className="text-[11px] text-text-muted">
|
||||
{headerDescription}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{(isPg || isMysql) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void checkTools(true)}
|
||||
className="text-[11px] text-text-muted hover:text-text transition-colors cursor-pointer"
|
||||
>
|
||||
Check again
|
||||
</button>
|
||||
)}
|
||||
{!checkingTools && !toolsMissing && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleStartBackup()}
|
||||
disabled={isRunning || !filePath}
|
||||
className="inline-flex items-center rounded-lg bg-accent px-3 py-1.5 text-xs font-medium text-white hover:bg-accent-hover disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
<Download size={14} className="mr-1.5" />
|
||||
{isRunning ? "Backing up..." : "Start Backup"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="max-w-lg mx-auto space-y-6 outline outline-border">
|
||||
{/* Tool check */}
|
||||
{checkingTools && checkingMessage && (
|
||||
<div className="glass p-4 text-center">
|
||||
<p className="text-sm text-text-muted">
|
||||
{checkingMessage}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{/* Tool check */}
|
||||
{checkingTools && checkingMessage && (
|
||||
<div className="px-4 py-3 text-sm text-text-muted">
|
||||
{checkingMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{toolsMissing && !toolsBundled && (
|
||||
<div className="bg-amber-500/10 border border-amber-500/30 rounded-lg px-4 py-4 space-y-2">
|
||||
<p className="text-amber-300 text-sm font-semibold">
|
||||
{isPg ? "pg_dump not found" : "mysqldump not found"}
|
||||
</p>
|
||||
{toolsMissing && !toolsBundled && (
|
||||
<div className="border-b border-border bg-amber-500/10 px-4 py-3 space-y-2">
|
||||
<p className="text-amber-300 text-sm font-semibold">
|
||||
{isPg ? "pg_dump not found" : "mysqldump not found"}
|
||||
</p>
|
||||
<p className="text-amber-200/80 text-xs leading-relaxed">
|
||||
Gridline ships its own {isPg ? "PostgreSQL" : "MySQL"} client
|
||||
tools. This build could not locate or run them, so a manual
|
||||
install is needed:
|
||||
</p>
|
||||
{bundledPresent && (
|
||||
<p className="text-amber-200/80 text-xs leading-relaxed">
|
||||
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.
|
||||
</p>
|
||||
<pre className="text-xs text-amber-100 bg-amber-500/10 rounded-lg p-3 whitespace-pre-wrap font-mono leading-relaxed">
|
||||
{getPlatformInstructions(
|
||||
isPg
|
||||
? PG_INSTALL_INSTRUCTIONS
|
||||
: MYSQL_INSTALL_INSTRUCTIONS,
|
||||
)}
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-[11px] text-amber-200/60">Install</span>
|
||||
<CopyButton
|
||||
text={installInstructions}
|
||||
className="text-amber-200/70 hover:text-amber-100"
|
||||
/>
|
||||
</div>
|
||||
<pre className="text-xs text-amber-100 bg-amber-500/10 p-3 whitespace-pre-wrap font-mono leading-relaxed">
|
||||
{installInstructions}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!checkingTools && !toolsMissing && (
|
||||
<>
|
||||
{/* Configuration card */}
|
||||
<div className="p-5 space-y-5">
|
||||
{/* Format */}
|
||||
{isPg ? (
|
||||
<div className="space-y-1">
|
||||
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
|
||||
Format
|
||||
</label>
|
||||
<select
|
||||
value={format}
|
||||
onChange={(e) =>
|
||||
setFormat(
|
||||
e.target.value as BackupFormat,
|
||||
)
|
||||
}
|
||||
className="w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm text-text focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer"
|
||||
>
|
||||
<option value="custom">
|
||||
Custom Archive
|
||||
</option>
|
||||
<option value="plain">Plain SQL</option>
|
||||
<option value="tar">Tarball</option>
|
||||
<option value="directory">
|
||||
Directory
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
) : isMysql ? (
|
||||
<div className="space-y-1">
|
||||
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
|
||||
Format
|
||||
</label>
|
||||
<div className="text-sm text-text py-2">
|
||||
Plain SQL
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{!checkingTools && !toolsMissing && (
|
||||
<>
|
||||
{dumpToolFound && (
|
||||
<FormRow label="Tool">
|
||||
<span className="px-3 font-heading text-xs text-text">
|
||||
Using {dumpSource === "bundled" ? "bundled" : "system"}{" "}
|
||||
{dumpResolvedName ?? dumpFallbackName}
|
||||
</span>
|
||||
</FormRow>
|
||||
)}
|
||||
|
||||
{/* Output file */}
|
||||
<div className="space-y-1 w-full">
|
||||
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
|
||||
Output File
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={filePath}
|
||||
onChange={(e) =>
|
||||
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"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePickFile}
|
||||
className="flex items-center justify-center w-9 h-9 rounded-lg border border-border bg-surface text-text-muted hover:text-text hover:bg-surface-raised hover:border-border-hover transition-colors cursor-pointer shrink-0"
|
||||
aria-label="Browse for file"
|
||||
>
|
||||
<FolderOpen size={15} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Format */}
|
||||
{isPg ? (
|
||||
<FormRow label="Format">
|
||||
<select
|
||||
value={format}
|
||||
onChange={(e) =>
|
||||
setFormat(e.target.value as BackupFormat)
|
||||
}
|
||||
className={`${controlClass} mr-3`}
|
||||
>
|
||||
<option value="custom">Custom Archive</option>
|
||||
<option value="plain">Plain SQL</option>
|
||||
<option value="tar">Tarball</option>
|
||||
<option value="directory">Directory</option>
|
||||
</select>
|
||||
</FormRow>
|
||||
) : isMysql ? (
|
||||
<FormRow label="Format">
|
||||
<span className="px-3 font-heading text-xs text-text">
|
||||
Plain SQL
|
||||
</span>
|
||||
</FormRow>
|
||||
) : null}
|
||||
|
||||
{/* Schema (optional) */}
|
||||
{(isPg || isMysql) && (
|
||||
<div className="space-y-1">
|
||||
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
|
||||
Schema{" "}
|
||||
<span className="font-normal normal-case tracking-normal">
|
||||
(optional)
|
||||
</span>
|
||||
</label>
|
||||
<select
|
||||
value={schema}
|
||||
onChange={(e) =>
|
||||
setSchema(e.target.value)
|
||||
}
|
||||
className="w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm text-text focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer"
|
||||
>
|
||||
<option value="">All schemas</option>
|
||||
{availableSchemas.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
{/* Output file */}
|
||||
<FormRow label="Output file">
|
||||
<input
|
||||
type="text"
|
||||
value={filePath}
|
||||
onChange={(e) => setFilePath(e.target.value)}
|
||||
placeholder="/path/to/backup.dump"
|
||||
className={inputClass}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePickFile}
|
||||
aria-label="Browse for file"
|
||||
className="mr-2 flex h-6 w-6 shrink-0 items-center justify-center border border-border bg-surface text-text-muted hover:text-text hover:bg-surface-raised hover:border-border-hover transition-colors cursor-pointer"
|
||||
>
|
||||
<FolderOpen size={13} />
|
||||
</button>
|
||||
</FormRow>
|
||||
|
||||
{/* PostgreSQL: no-owner toggle */}
|
||||
{isPg && (
|
||||
<label className="flex items-center gap-2.5 cursor-pointer group">
|
||||
{/* Schema */}
|
||||
{(isPg || isMysql) && (
|
||||
<FormRow label="Schema">
|
||||
<select
|
||||
value={schema}
|
||||
onChange={(e) => setSchema(e.target.value)}
|
||||
className={`${controlClass} mr-3`}
|
||||
>
|
||||
<option value="">All schemas</option>
|
||||
{availableSchemas.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormRow>
|
||||
)}
|
||||
|
||||
{(isPg || isMysql) && (
|
||||
<FormSectionHeader label="Options" />
|
||||
)}
|
||||
|
||||
{/* PostgreSQL: no-owner toggle */}
|
||||
{isPg && (
|
||||
<FormRow label="No owner">
|
||||
<label className="flex w-full items-center gap-2.5 px-3 py-2 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={noOwner}
|
||||
onChange={(e) => setNoOwner(e.target.checked)}
|
||||
className="bg-surface border-border accent-accent w-4 h-4 cursor-pointer"
|
||||
/>
|
||||
<code className="text-[11px] text-text-muted/60 bg-surface-raised px-1.5 py-0.5">
|
||||
--no-owner
|
||||
</code>
|
||||
</label>
|
||||
</FormRow>
|
||||
)}
|
||||
|
||||
{/* MySQL: option toggles */}
|
||||
{isMysql && (
|
||||
<>
|
||||
<FormRow label="Single transaction">
|
||||
<label className="flex w-full items-center gap-2.5 px-3 py-2 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={noOwner}
|
||||
checked={singleTransaction}
|
||||
onChange={(e) =>
|
||||
setNoOwner(e.target.checked)
|
||||
setSingleTransaction(e.target.checked)
|
||||
}
|
||||
className="rounded bg-surface border-border accent-accent w-4 h-4 cursor-pointer"
|
||||
className="bg-surface border-border accent-accent w-4 h-4 cursor-pointer"
|
||||
/>
|
||||
<span className="text-sm text-text-muted group-hover:text-text transition-colors">
|
||||
No Owner{" "}
|
||||
<code className="text-[11px] text-text-muted/60 bg-surface-raised rounded px-1.5 py-0.5">
|
||||
--no-owner
|
||||
</code>
|
||||
</span>
|
||||
<code className="text-[11px] text-text-muted/60 bg-surface-raised px-1.5 py-0.5">
|
||||
--single-transaction
|
||||
</code>
|
||||
</label>
|
||||
)}
|
||||
</FormRow>
|
||||
<FormRow label="No data">
|
||||
<label className="flex w-full items-center gap-2.5 px-3 py-2 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={noData}
|
||||
onChange={(e) => setNoData(e.target.checked)}
|
||||
className="bg-surface border-border accent-accent w-4 h-4 cursor-pointer"
|
||||
/>
|
||||
<code className="text-[11px] text-text-muted/60 bg-surface-raised px-1.5 py-0.5">
|
||||
--no-data
|
||||
</code>
|
||||
</label>
|
||||
</FormRow>
|
||||
<FormRow label="Routines">
|
||||
<label className="flex w-full items-center gap-2.5 px-3 py-2 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={routines}
|
||||
onChange={(e) => setRoutines(e.target.checked)}
|
||||
className="bg-surface border-border accent-accent w-4 h-4 cursor-pointer"
|
||||
/>
|
||||
<code className="text-[11px] text-text-muted/60 bg-surface-raised px-1.5 py-0.5">
|
||||
--routines
|
||||
</code>
|
||||
</label>
|
||||
</FormRow>
|
||||
<FormRow label="Triggers">
|
||||
<label className="flex w-full items-center gap-2.5 px-3 py-2 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={triggers}
|
||||
onChange={(e) => setTriggers(e.target.checked)}
|
||||
className="bg-surface border-border accent-accent w-4 h-4 cursor-pointer"
|
||||
/>
|
||||
<code className="text-[11px] text-text-muted/60 bg-surface-raised px-1.5 py-0.5">
|
||||
--triggers
|
||||
</code>
|
||||
</label>
|
||||
</FormRow>
|
||||
<FormRow label="Events">
|
||||
<label className="flex w-full items-center gap-2.5 px-3 py-2 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={events}
|
||||
onChange={(e) => setEvents(e.target.checked)}
|
||||
className="bg-surface border-border accent-accent w-4 h-4 cursor-pointer"
|
||||
/>
|
||||
<code className="text-[11px] text-text-muted/60 bg-surface-raised px-1.5 py-0.5">
|
||||
--events
|
||||
</code>
|
||||
</label>
|
||||
</FormRow>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* MySQL: option toggles */}
|
||||
{isMysql && (
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-2.5 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={singleTransaction}
|
||||
onChange={(e) =>
|
||||
setSingleTransaction(e.target.checked)
|
||||
}
|
||||
className="rounded bg-surface border-border accent-accent w-4 h-4 cursor-pointer"
|
||||
/>
|
||||
<span className="text-sm text-text-muted group-hover:text-text transition-colors">
|
||||
Single Transaction{" "}
|
||||
<code className="text-[11px] text-text-muted/60 bg-surface-raised rounded px-1.5 py-0.5">
|
||||
--single-transaction
|
||||
</code>
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2.5 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={noData}
|
||||
onChange={(e) =>
|
||||
setNoData(e.target.checked)
|
||||
}
|
||||
className="rounded bg-surface border-border accent-accent w-4 h-4 cursor-pointer"
|
||||
/>
|
||||
<span className="text-sm text-text-muted group-hover:text-text transition-colors">
|
||||
No Data{" "}
|
||||
<code className="text-[11px] text-text-muted/60 bg-surface-raised rounded px-1.5 py-0.5">
|
||||
--no-data
|
||||
</code>
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2.5 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={routines}
|
||||
onChange={(e) =>
|
||||
setRoutines(e.target.checked)
|
||||
}
|
||||
className="rounded bg-surface border-border accent-accent w-4 h-4 cursor-pointer"
|
||||
/>
|
||||
<span className="text-sm text-text-muted group-hover:text-text transition-colors">
|
||||
Routines{" "}
|
||||
<code className="text-[11px] text-text-muted/60 bg-surface-raised rounded px-1.5 py-0.5">
|
||||
--routines
|
||||
</code>
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2.5 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={triggers}
|
||||
onChange={(e) =>
|
||||
setTriggers(e.target.checked)
|
||||
}
|
||||
className="rounded bg-surface border-border accent-accent w-4 h-4 cursor-pointer"
|
||||
/>
|
||||
<span className="text-sm text-text-muted group-hover:text-text transition-colors">
|
||||
Triggers{" "}
|
||||
<code className="text-[11px] text-text-muted/60 bg-surface-raised rounded px-1.5 py-0.5">
|
||||
--triggers
|
||||
</code>
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2.5 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={events}
|
||||
onChange={(e) =>
|
||||
setEvents(e.target.checked)
|
||||
}
|
||||
className="rounded bg-surface border-border accent-accent w-4 h-4 cursor-pointer"
|
||||
/>
|
||||
<span className="text-sm text-text-muted group-hover:text-text transition-colors">
|
||||
Events{" "}
|
||||
<code className="text-[11px] text-text-muted/60 bg-surface-raised rounded px-1.5 py-0.5">
|
||||
--events
|
||||
</code>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
{/* Progress */}
|
||||
{activeJob && (
|
||||
<div className="px-4 py-3">
|
||||
<BackupProgress
|
||||
progress={activeJob.status === "completed" ? 100 : 50}
|
||||
jobType="dump"
|
||||
status={activeJob.status}
|
||||
errorMessage={activeJob.error_message ?? undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Progress */}
|
||||
{activeJob && (
|
||||
<div className="px-4">
|
||||
<BackupProgress
|
||||
progress={activeJob.status === "completed" ? 100 : 50}
|
||||
jobType="dump"
|
||||
status={activeJob.status}
|
||||
errorMessage={activeJob.error_message ?? undefined}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end pb-2 pr-2">
|
||||
<Button
|
||||
onClick={handleStartBackup}
|
||||
disabled={isRunning || !filePath}
|
||||
>
|
||||
<Download size={14} className="mr-1.5" />
|
||||
{isRunning ? "Backing up..." : "Start Backup"}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<AnimatedModal open={open} onClose={onClose}>
|
||||
@@ -110,9 +112,18 @@ export function RestoreDialog({ open, connectionId, onClose }: RestoreDialogProp
|
||||
<p className="text-amber-200/80 text-xs">
|
||||
The PostgreSQL client tools are required for backup/restore operations. Install them using:
|
||||
</p>
|
||||
<pre className="text-xs text-amber-100 bg-amber-500/10 rounded p-2 whitespace-pre-wrap">
|
||||
{getPlatformInstructions()}
|
||||
</pre>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-[11px] text-amber-200/60">Install</span>
|
||||
<CopyButton
|
||||
text={installInstructions}
|
||||
className="text-amber-200/70 hover:text-amber-100"
|
||||
/>
|
||||
</div>
|
||||
<pre className="text-xs text-amber-100 bg-amber-500/10 rounded p-2 whitespace-pre-wrap">
|
||||
{installInstructions}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Toolbar header */}
|
||||
<div className="flex items-center gap-2 border-b border-border px-3 py-1.5">
|
||||
<Upload size={14} className="text-accent" />
|
||||
<span className="text-xs font-medium text-text">Restore</span>
|
||||
<span className="text-[11px] text-text-muted">
|
||||
{headerDescription}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="max-w-lg mx-auto space-y-6 outline outline-border">
|
||||
{/* Tool check */}
|
||||
{checkingTools && checkingMessage && (
|
||||
<div className="glass p-4 text-center">
|
||||
<p className="text-sm text-text-muted">
|
||||
{checkingMessage}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{toolsMissing && !toolsBundled && (
|
||||
<div className="bg-amber-500/10 border border-amber-500/30 rounded-lg px-4 py-4 space-y-2">
|
||||
<p className="text-amber-300 text-sm font-semibold">
|
||||
{isPg ? "pg_restore not found" : "mysql client not found"}
|
||||
</p>
|
||||
<p className="text-amber-200/80 text-xs leading-relaxed">
|
||||
The {isPg ? "PostgreSQL" : "MySQL"} client tools are required for
|
||||
backup/restore operations. Install them using:
|
||||
</p>
|
||||
<pre className="text-xs text-amber-100 bg-amber-500/10 rounded-lg p-3 whitespace-pre-wrap font-mono leading-relaxed">
|
||||
{getPlatformInstructions(
|
||||
isPg
|
||||
? PG_INSTALL_INSTRUCTIONS
|
||||
: MYSQL_INSTALL_INSTRUCTIONS,
|
||||
)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between border-b border-border px-4 py-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Upload size={14} className="text-accent" />
|
||||
<span className="text-xs font-medium text-text">Restore</span>
|
||||
<span className="text-[11px] text-text-muted">
|
||||
{headerDescription}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{!checkingTools && !toolsMissing && (
|
||||
<>
|
||||
{isMysql && (
|
||||
<p className="text-[11px] text-text-muted/70 -mb-2">
|
||||
Restores run in-process over the driver connection — no MySQL client
|
||||
installation required.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Configuration card */}
|
||||
<div className="p-5 space-y-5">
|
||||
{/* Format (PostgreSQL only) */}
|
||||
{isPg && (
|
||||
<div className="space-y-1">
|
||||
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
|
||||
Format
|
||||
</label>
|
||||
<select
|
||||
value={format}
|
||||
onChange={(e) =>
|
||||
setFormat(e.target.value)
|
||||
}
|
||||
className="w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm text-text focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer"
|
||||
>
|
||||
<option value="custom">
|
||||
Custom Archive
|
||||
</option>
|
||||
<option value="plain">Plain SQL</option>
|
||||
<option value="tar">Tarball</option>
|
||||
<option value="directory">
|
||||
Directory
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Backup file */}
|
||||
<div className="space-y-1 w-full">
|
||||
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
|
||||
Backup File
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={filePath}
|
||||
onChange={(e) =>
|
||||
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"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePickFile}
|
||||
className="flex items-center justify-center w-9 h-9 rounded-lg border border-border bg-surface text-text-muted hover:text-text hover:bg-surface-raised hover:border-border-hover transition-colors cursor-pointer shrink-0"
|
||||
aria-label="Browse for file"
|
||||
>
|
||||
<FileSearch size={15} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Schema (optional) */}
|
||||
{(isPg || isMysql) && (
|
||||
<div className="space-y-1">
|
||||
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
|
||||
Schema{" "}
|
||||
<span className="font-normal normal-case tracking-normal">
|
||||
(optional)
|
||||
</span>
|
||||
</label>
|
||||
<select
|
||||
value={schema}
|
||||
onChange={(e) =>
|
||||
setSchema(e.target.value)
|
||||
}
|
||||
className="w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm text-text focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer"
|
||||
>
|
||||
<option value="">All schemas</option>
|
||||
{availableSchemas.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Clean toggle */}
|
||||
{(isPg || isMysql || isSqlite) && (
|
||||
<label
|
||||
className={`flex items-center gap-2.5 cursor-pointer group ${
|
||||
isPg && format === "plain"
|
||||
? "opacity-40 pointer-events-none"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={clean}
|
||||
onChange={(e) =>
|
||||
setClean(e.target.checked)
|
||||
}
|
||||
disabled={isPg && format === "plain"}
|
||||
className="rounded bg-surface border-border accent-accent w-4 h-4 cursor-pointer disabled:cursor-not-allowed"
|
||||
/>
|
||||
{isMysql ? (
|
||||
<span className="text-sm text-text-muted group-hover:text-text transition-colors">
|
||||
Clean{" "}
|
||||
<code className="text-[11px] text-text-muted/60 bg-surface-raised rounded px-1.5 py-0.5">
|
||||
drops only the objects this file defines
|
||||
</code>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm text-text-muted group-hover:text-text transition-colors">
|
||||
Clean{" "}
|
||||
<code className="text-[11px] text-text-muted/60 bg-surface-raised rounded px-1.5 py-0.5">
|
||||
DROP before CREATE
|
||||
</code>
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
)}
|
||||
{isPg && format === "plain" && (
|
||||
<p className="text-[11px] text-text-muted/70 -mt-3">
|
||||
Plain SQL restores run via psql and don't
|
||||
support DROP-before-CREATE. Use Custom
|
||||
Archive for clean restores.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Destructive confirmation */}
|
||||
<div className="bg-red-500/5 border border-red-500/20 px-4 py-3">
|
||||
<label className="flex items-start gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={confirmed}
|
||||
onChange={(e) =>
|
||||
setConfirmed(e.target.checked)
|
||||
}
|
||||
className="mt-0.5 rounded bg-surface border-border accent-red-500 w-4 h-4 cursor-pointer"
|
||||
data-testid="restore-confirm-checkbox"
|
||||
/>
|
||||
<span className="text-sm text-red-300/90 leading-relaxed">
|
||||
I understand this will overwrite data on
|
||||
the target database. This action cannot
|
||||
be undone.
|
||||
</span>
|
||||
</label>
|
||||
{isMysql && (
|
||||
<p className="mt-2 text-[11px] text-red-400/80">
|
||||
MySQL DDL commits as it runs and can't be rolled back — a failed
|
||||
restore may leave this database partially applied.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Progress */}
|
||||
{activeJob && (
|
||||
<div className="px-4">
|
||||
<BackupProgress
|
||||
progress={activeJob.status === "completed" ? 100 : 50}
|
||||
jobType="restore"
|
||||
status={activeJob.status}
|
||||
errorMessage={activeJob.error_message ?? undefined}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end pb-2 pr-2">
|
||||
<Button
|
||||
onClick={handleStartRestore}
|
||||
disabled={!canStart}
|
||||
>
|
||||
<Upload size={14} className="mr-1.5" />
|
||||
{isRunning
|
||||
? "Restoring..."
|
||||
: "Start Restore"}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleStartRestore()}
|
||||
disabled={!canStart}
|
||||
className="inline-flex items-center rounded-lg bg-accent px-3 py-1.5 text-xs font-medium text-white hover:bg-accent-hover disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
<Upload size={14} className="mr-1.5" />
|
||||
{isRunning ? "Restoring..." : "Start Restore"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{/* Tool check */}
|
||||
{checkingTools && checkingMessage && (
|
||||
<div className="px-4 py-3 text-sm text-text-muted">
|
||||
{checkingMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{toolsMissing && !toolsBundled && (
|
||||
<div className="border-b border-border bg-amber-500/10 px-4 py-3 space-y-2">
|
||||
<p className="text-amber-300 text-sm font-semibold">
|
||||
{isPg ? "pg_restore not found" : "mysql client not found"}
|
||||
</p>
|
||||
<p className="text-amber-200/80 text-xs leading-relaxed">
|
||||
Gridline ships its own {isPg ? "PostgreSQL" : "MySQL"}{" "}
|
||||
client tools. This build could not locate or run them, so
|
||||
a manual install is needed:
|
||||
</p>
|
||||
{pgToolStatus?.pg_restore_bundled_available && (
|
||||
<p className="text-amber-200/80 text-xs leading-relaxed">
|
||||
A bundled copy is present but could not run — reinstall
|
||||
Gridline, or install the tools below.
|
||||
</p>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-[11px] text-amber-200/60">
|
||||
Install
|
||||
</span>
|
||||
<CopyButton
|
||||
text={installInstructions}
|
||||
className="text-amber-200/70 hover:text-amber-100"
|
||||
/>
|
||||
</div>
|
||||
<pre className="text-xs text-amber-100 bg-amber-500/10 p-3 whitespace-pre-wrap font-mono leading-relaxed">
|
||||
{installInstructions}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!checkingTools && !toolsMissing && (
|
||||
<>
|
||||
{/* Restore tool */}
|
||||
{isPg && pgToolStatus?.pg_restore_found && (
|
||||
<FormRow label="Tool">
|
||||
<span className="px-3 font-heading text-xs text-text">
|
||||
Using{" "}
|
||||
{pgToolStatus.pg_restore_source === "bundled"
|
||||
? "bundled"
|
||||
: "system"}{" "}
|
||||
{restoreToolResolvedName}
|
||||
</span>
|
||||
</FormRow>
|
||||
)}
|
||||
|
||||
{isMysql && (
|
||||
<FormRow label="Tool">
|
||||
<span className="px-3 font-heading text-xs text-text">
|
||||
Restores run in-process over the driver connection —
|
||||
no MySQL client installation required
|
||||
</span>
|
||||
</FormRow>
|
||||
)}
|
||||
|
||||
{/* Format (PostgreSQL only) */}
|
||||
{isPg && (
|
||||
<FormRow label="Format">
|
||||
<select
|
||||
value={format}
|
||||
onChange={(e) => setFormat(e.target.value)}
|
||||
className={`${controlClass} mr-3`}
|
||||
>
|
||||
<option value="custom">Custom Archive</option>
|
||||
<option value="plain">Plain SQL</option>
|
||||
<option value="tar">Tarball</option>
|
||||
<option value="directory">Directory</option>
|
||||
</select>
|
||||
</FormRow>
|
||||
)}
|
||||
|
||||
{/* Backup file */}
|
||||
<FormRow label="Backup file">
|
||||
<input
|
||||
type="text"
|
||||
value={filePath}
|
||||
onChange={(e) => setFilePath(e.target.value)}
|
||||
placeholder="/path/to/backup.dump"
|
||||
className={inputClass}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePickFile}
|
||||
aria-label="Browse for file"
|
||||
className="mr-2 flex h-6 w-6 shrink-0 items-center justify-center border border-border bg-surface text-text-muted hover:text-text hover:bg-surface-raised hover:border-border-hover transition-colors cursor-pointer"
|
||||
>
|
||||
<FolderOpen size={13} />
|
||||
</button>
|
||||
</FormRow>
|
||||
|
||||
{/* Schema (optional) */}
|
||||
{(isPg || isMysql) && (
|
||||
<FormRow label="Schema">
|
||||
<select
|
||||
value={schema}
|
||||
onChange={(e) => setSchema(e.target.value)}
|
||||
className={`${controlClass} mr-3`}
|
||||
>
|
||||
<option value="">All schemas</option>
|
||||
{availableSchemas.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormRow>
|
||||
)}
|
||||
|
||||
<FormSectionHeader label="Options" />
|
||||
|
||||
{/* Clean toggle */}
|
||||
<FormRow label="Clean">
|
||||
<label
|
||||
className={`flex w-full items-center gap-2.5 px-3 py-2 cursor-pointer group ${
|
||||
cleanDisabled
|
||||
? "opacity-40 pointer-events-none"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={clean}
|
||||
onChange={(e) => setClean(e.target.checked)}
|
||||
disabled={cleanDisabled}
|
||||
className="bg-surface border-border accent-accent w-4 h-4 cursor-pointer disabled:cursor-not-allowed"
|
||||
/>
|
||||
<code className="text-[11px] text-text-muted/60 bg-surface-raised px-1.5 py-0.5">
|
||||
{isMysql
|
||||
? "drops only the objects this file defines"
|
||||
: "DROP before CREATE"}
|
||||
</code>
|
||||
</label>
|
||||
</FormRow>
|
||||
|
||||
{isPg && format === "plain" && (
|
||||
<div className="border-b border-border px-4 py-2 text-[11px] text-text-muted/70">
|
||||
Plain SQL restores run via psql and don't support
|
||||
DROP-before-CREATE. Use Custom Archive for clean
|
||||
restores.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Destructive confirmation */}
|
||||
<div className="border-b border-border bg-red-500/5 px-4 py-3 space-y-2">
|
||||
<label className="flex items-start gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={confirmed}
|
||||
onChange={(e) => setConfirmed(e.target.checked)}
|
||||
className="mt-0.5 bg-surface border-border accent-red-500 w-4 h-4 cursor-pointer"
|
||||
data-testid="restore-confirm-checkbox"
|
||||
/>
|
||||
<span className="text-sm text-red-300/90 leading-relaxed">
|
||||
I understand this will overwrite data on the target
|
||||
database. This action cannot be undone.
|
||||
</span>
|
||||
</label>
|
||||
{isMysql && (
|
||||
<p className="text-[11px] text-red-400/80">
|
||||
MySQL DDL commits as it runs and can't be rolled
|
||||
back — a failed restore may leave this database
|
||||
partially applied.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Progress */}
|
||||
{activeJob && (
|
||||
<div className="px-4 py-3">
|
||||
<BackupProgress
|
||||
progress={activeJob.status === "completed" ? 100 : 50}
|
||||
jobType="restore"
|
||||
status={activeJob.status}
|
||||
errorMessage={activeJob.error_message ?? undefined}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className="rounded-lg border border-border bg-surface p-2 text-xs">
|
||||
<div className="border border-border bg-surface p-3 text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`rounded border px-1.5 py-0.5 ${KIND_BADGE[item.kind]}`}>{item.kind}</span>
|
||||
<span className={`border px-1.5 py-0.5 ${KIND_BADGE[item.kind]}`}>{item.kind}</span>
|
||||
<span className="text-text">{item.name}</span>
|
||||
{item.destructive && (
|
||||
<span className="flex items-center gap-1 text-text-muted">
|
||||
@@ -43,7 +44,7 @@ function SchemaDiffItemCard({ item }: { item: DiffItem }) {
|
||||
{item.sync_sql.length > 0 && (
|
||||
<details className="mt-1.5">
|
||||
<summary className="cursor-pointer text-text-muted">SQL</summary>
|
||||
<pre className="mt-1 rounded bg-surface-raised p-2 overflow-x-auto text-[11px]">{item.sync_sql.join("\n")}</pre>
|
||||
<pre className="mt-1 bg-surface-raised p-2 overflow-x-auto text-[11px]">{item.sync_sql.join("\n")}</pre>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void navigator.clipboard?.writeText(item.sync_sql.join("\n"))}
|
||||
@@ -165,8 +166,8 @@ export function SchemaDiffPage({ connectionId }: { connectionId: string }) {
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 min-h-0 flex-col overflow-hidden">
|
||||
<div className="px-3 pt-3 pb-3 border-b border-border shrink-0 space-y-2">
|
||||
<div className="flex h-full min-h-0 flex-col overflow-hidden">
|
||||
<div className="border-b border-border px-4 py-2 shrink-0 space-y-2">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<GitCompare size={14} className="text-text-muted" />
|
||||
<span className="text-text-muted">Compare a source connection's schema against this connection (same engine family).</span>
|
||||
@@ -175,7 +176,7 @@ export function SchemaDiffPage({ connectionId }: { connectionId: string }) {
|
||||
<select
|
||||
value={sourceId}
|
||||
onChange={(e) => void pickSource(e.target.value)}
|
||||
className="rounded-lg bg-surface-raised border border-border px-2 py-1"
|
||||
className={`${controlClass} max-w-56`}
|
||||
aria-label="Source connection"
|
||||
>
|
||||
<option value="">Source connection…</option>
|
||||
@@ -187,7 +188,7 @@ export function SchemaDiffPage({ connectionId }: { connectionId: string }) {
|
||||
value={sourceSchema}
|
||||
onChange={(e) => setSourceSchema(e.target.value)}
|
||||
disabled={!sourceId}
|
||||
className="rounded-lg bg-surface-raised border border-border px-2 py-1 disabled:opacity-50"
|
||||
className={`${controlClass} max-w-56 disabled:opacity-50 disabled:cursor-not-allowed`}
|
||||
aria-label="Source schema"
|
||||
>
|
||||
{sourceSchemas.map((s) => <option key={s} value={s}>{s}</option>)}
|
||||
@@ -196,7 +197,7 @@ export function SchemaDiffPage({ connectionId }: { connectionId: string }) {
|
||||
<select
|
||||
value={targetSchema}
|
||||
onChange={(e) => setTargetSchema(e.target.value)}
|
||||
className="rounded-lg bg-surface-raised border border-border px-2 py-1"
|
||||
className={`${controlClass} max-w-56`}
|
||||
aria-label="Target schema"
|
||||
>
|
||||
<option value={targetSchema}>{targetSchema}</option>
|
||||
@@ -205,7 +206,7 @@ export function SchemaDiffPage({ connectionId }: { connectionId: string }) {
|
||||
type="button"
|
||||
onClick={() => void runDiff()}
|
||||
disabled={running || !sourceId || !sourceSchema}
|
||||
className="flex items-center gap-1 rounded-lg bg-accent px-2.5 py-1 text-accent-foreground disabled:opacity-50 cursor-pointer"
|
||||
className="inline-flex items-center gap-1 rounded-lg bg-accent px-3 py-1.5 text-xs font-medium text-accent-foreground hover:bg-accent-hover disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
<Play size={13} /> {running ? "Diffing…" : "Run diff"}
|
||||
</button>
|
||||
@@ -213,7 +214,7 @@ export function SchemaDiffPage({ connectionId }: { connectionId: string }) {
|
||||
<button
|
||||
type="button"
|
||||
onClick={stageAll}
|
||||
className="rounded-lg bg-surface-raised border border-border px-2.5 py-1 hover:bg-surface cursor-pointer"
|
||||
className="bg-surface-raised border border-border px-3 py-1.5 text-xs text-text hover:bg-surface cursor-pointer"
|
||||
>
|
||||
Stage {stageableCount} safe item{stageableCount === 1 ? "" : "s"}
|
||||
</button>
|
||||
@@ -222,7 +223,7 @@ export function SchemaDiffPage({ connectionId }: { connectionId: string }) {
|
||||
<button
|
||||
type="button"
|
||||
onClick={copyAll}
|
||||
className="flex items-center gap-1 rounded-lg bg-surface-raised border border-border px-2.5 py-1 hover:bg-surface cursor-pointer"
|
||||
className="inline-flex items-center gap-1 bg-surface-raised border border-border px-3 py-1.5 text-xs text-text hover:bg-surface cursor-pointer"
|
||||
>
|
||||
<Copy size={13} /> Copy all SQL
|
||||
</button>
|
||||
@@ -236,7 +237,7 @@ export function SchemaDiffPage({ connectionId }: { connectionId: string }) {
|
||||
{error && <div className="text-xs text-red-400" role="alert">{error}</div>}
|
||||
</div>
|
||||
|
||||
<div ref={scrollRef} className="flex-1 min-h-0 overflow-y-auto px-3 py-3 space-y-4">
|
||||
<div ref={scrollRef} className="flex-1 min-h-0 overflow-y-auto px-4 py-3 space-y-4">
|
||||
{report && report.items.length === 0 && (
|
||||
<div className="text-xs text-text-muted">Schemas are identical — no differences found.</div>
|
||||
)}
|
||||
|
||||
@@ -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(<SyncPage />);
|
||||
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(<SyncPage />);
|
||||
const [sourceSelect] = screen.getAllByRole("combobox") as HTMLSelectElement[];
|
||||
fireEvent.change(sourceSelect, { target: { value: "my1" } });
|
||||
await waitFor(() => expect(screen.getByText(/mysqldump is missing/i)).toBeTruthy());
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Toolbar header */}
|
||||
<div className="flex items-center gap-2 border-b border-border px-3 py-1.5">
|
||||
<ArrowLeftRight size={14} className="text-accent" />
|
||||
<span className="text-xs font-medium text-text">DB Sync</span>
|
||||
<span className="text-[11px] text-text-muted">
|
||||
Transfer data between databases via pipe
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="max-w-lg mx-auto space-y-6 outline outline-border">
|
||||
{/* Tool check */}
|
||||
{checkingTools && checkingMessage && (
|
||||
<div className="glass p-4 text-center">
|
||||
<p className="text-sm text-text-muted">
|
||||
{checkingMessage}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-between border-b border-border px-4 py-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<ArrowLeftRight size={14} className="text-accent" />
|
||||
<span className="text-xs font-medium text-text">DB Sync</span>
|
||||
<span className="text-[11px] text-text-muted">
|
||||
Transfer data between databases via pipe
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{(isPg || isMysql) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void checkTools(true)}
|
||||
className="text-[11px] text-text-muted hover:text-text transition-colors cursor-pointer"
|
||||
>
|
||||
Check again
|
||||
</button>
|
||||
)}
|
||||
|
||||
{toolsMissing && !toolsBundled && (
|
||||
<div className="bg-amber-500/10 border border-amber-500/30 rounded-lg px-4 py-4 space-y-2">
|
||||
<p className="text-amber-300 text-sm font-semibold">
|
||||
{isPg
|
||||
? "PostgreSQL tools not found"
|
||||
: "MySQL tools not found"}
|
||||
</p>
|
||||
<p className="text-amber-200/80 text-xs leading-relaxed">
|
||||
Both {isPg ? "pg_dump and pg_restore" : "mysqldump and mysql"} are required for
|
||||
database sync.
|
||||
</p>
|
||||
<ul className="list-disc list-inside text-xs text-amber-200/70 space-y-0.5">
|
||||
{isPg && !pgToolStatus?.pg_dump_found && (
|
||||
<li>pg_dump is missing.</li>
|
||||
)}
|
||||
{isPg && !pgToolStatus?.pg_restore_found && (
|
||||
<li>pg_restore is missing.</li>
|
||||
)}
|
||||
{isMysql && !mysqlToolStatus?.mysqldumpFound && (
|
||||
<li>mysqldump is missing.</li>
|
||||
)}
|
||||
{isMysql && !mysqlToolStatus?.mysqlFound && (
|
||||
<li>mysql is missing.</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!checkingTools && !toolsMissing && (
|
||||
<>
|
||||
{/* Configuration card */}
|
||||
<div className="p-5 space-y-5">
|
||||
{/* Source & Target connection pickers */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1">
|
||||
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium flex items-center gap-1">
|
||||
<Database size={11} />
|
||||
Source
|
||||
</label>
|
||||
<select
|
||||
value={sourceConnectionId}
|
||||
onChange={(e) => {
|
||||
setSourceConnectionId(
|
||||
e.target.value,
|
||||
);
|
||||
setTargetConnectionId("");
|
||||
}}
|
||||
className="w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm text-text focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer"
|
||||
>
|
||||
<option value="">
|
||||
Select source...
|
||||
</option>
|
||||
{connections.map((c) => (
|
||||
<option
|
||||
key={c.id}
|
||||
value={c.id}
|
||||
>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium flex items-center gap-1">
|
||||
<Database size={11} />
|
||||
Target
|
||||
</label>
|
||||
<select
|
||||
value={targetConnectionId}
|
||||
onChange={(e) =>
|
||||
setTargetConnectionId(
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
disabled={!sourceConnectionId}
|
||||
className="w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm text-text focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<option value="">
|
||||
{sourceConnectionId
|
||||
? "Select target..."
|
||||
: "Select a source first"}
|
||||
</option>
|
||||
{targetConnections.map((c) => (
|
||||
<option
|
||||
key={c.id}
|
||||
value={c.id}
|
||||
disabled={
|
||||
c.id === sourceConnectionId
|
||||
}
|
||||
>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Schema (optional) */}
|
||||
{(isPg || isMysql) && (
|
||||
<div className="space-y-1">
|
||||
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
|
||||
Schema{" "}
|
||||
<span className="font-normal normal-case tracking-normal">
|
||||
(optional)
|
||||
</span>
|
||||
</label>
|
||||
<select
|
||||
value={schema}
|
||||
onChange={(e) =>
|
||||
setSchema(e.target.value)
|
||||
}
|
||||
disabled={!sourceConnectionId}
|
||||
className="w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm text-text focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<option value="">
|
||||
{sourceConnectionId
|
||||
? "All schemas"
|
||||
: "Select a source first"}
|
||||
</option>
|
||||
{availableSchemas.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Flow indicator */}
|
||||
{sourceConnectionId && targetConnectionId && (
|
||||
<div className="flex items-center gap-3 text-[11px] text-text-muted">
|
||||
<span className="font-medium text-text">
|
||||
{connections.find(
|
||||
(c) =>
|
||||
c.id === sourceConnectionId,
|
||||
)?.name ?? sourceConnectionId}
|
||||
</span>
|
||||
<ArrowLeftRight
|
||||
size={12}
|
||||
className="text-accent shrink-0"
|
||||
/>
|
||||
<span className="font-medium text-text">
|
||||
{connections.find(
|
||||
(c) =>
|
||||
c.id === targetConnectionId,
|
||||
)?.name ?? targetConnectionId}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Destructive confirmation */}
|
||||
<div className="bg-red-500/5 border border-red-500/20 px-4 py-3">
|
||||
<label className="flex items-start gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={confirmed}
|
||||
onChange={(e) =>
|
||||
setConfirmed(e.target.checked)
|
||||
}
|
||||
className="mt-0.5 rounded bg-surface border-border accent-red-500 w-4 h-4 cursor-pointer"
|
||||
data-testid="sync-confirm-checkbox"
|
||||
/>
|
||||
<span className="text-sm text-red-300/90 leading-relaxed">
|
||||
I understand this will overwrite data on
|
||||
the target database. This action cannot
|
||||
be undone.
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Progress */}
|
||||
{activeJob && (
|
||||
<div className="px-4">
|
||||
<BackupProgress
|
||||
progress={activeJob.status === "completed" ? 100 : 50}
|
||||
jobType="sync"
|
||||
status={activeJob.status}
|
||||
errorMessage={activeJob.error_message ?? undefined}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end pb-2 pr-2">
|
||||
<Button
|
||||
onClick={handleStartSync}
|
||||
disabled={!canStart}
|
||||
>
|
||||
<ArrowLeftRight
|
||||
size={14}
|
||||
className="mr-1.5"
|
||||
/>
|
||||
{isRunning ? "Syncing..." : "Start Sync"}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleStartSync()}
|
||||
disabled={!canStart}
|
||||
className="inline-flex items-center rounded-lg bg-accent px-3 py-1.5 text-xs font-medium text-white hover:bg-accent-hover disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
<ArrowLeftRight size={14} className="mr-1.5" />
|
||||
{isRunning ? "Syncing..." : "Start Sync"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{/* Tool check */}
|
||||
{checkingTools && checkingMessage && (
|
||||
<div className="px-4 py-3 text-sm text-text-muted">
|
||||
{checkingMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{toolsMissing && !toolsBundled && (
|
||||
<div className="border-b border-border bg-amber-500/10 px-4 py-3 space-y-2">
|
||||
<p className="text-amber-300 text-sm font-semibold">
|
||||
{isPg
|
||||
? "PostgreSQL tools not found"
|
||||
: "MySQL tools not found"}
|
||||
</p>
|
||||
<p className="text-amber-200/80 text-xs leading-relaxed">
|
||||
Both {isPg ? "pg_dump and pg_restore" : "mysqldump and mysql"} are required for
|
||||
database sync.
|
||||
</p>
|
||||
<ul className="list-disc list-inside text-xs text-amber-200/70 space-y-0.5">
|
||||
{isPg && !pgToolStatus?.pg_dump_found && (
|
||||
<li>pg_dump is missing.</li>
|
||||
)}
|
||||
{isPg && !pgToolStatus?.pg_restore_found && (
|
||||
<li>pg_restore is missing.</li>
|
||||
)}
|
||||
{isMysql && !mysqlToolStatus?.mysqldumpFound && (
|
||||
<li>mysqldump is missing.</li>
|
||||
)}
|
||||
{isMysql && !mysqlToolStatus?.mysqlFound && (
|
||||
<li>mysql is missing.</li>
|
||||
)}
|
||||
{isMysql && mysqlToolStatus?.mysqlResolvedName && (
|
||||
<li className="text-amber-200/50">
|
||||
Resolved MySQL client: {mysqlToolStatus.mysqlResolvedName}
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!checkingTools && !toolsMissing && (
|
||||
<>
|
||||
<FormSectionHeader label="Connections" />
|
||||
|
||||
{/* Source connection */}
|
||||
<FormRow label="Source">
|
||||
<select
|
||||
value={sourceConnectionId}
|
||||
onChange={(e) => {
|
||||
setSourceConnectionId(e.target.value);
|
||||
setTargetConnectionId("");
|
||||
}}
|
||||
className={`${controlClass} mr-3`}
|
||||
>
|
||||
<option value="">Select source...</option>
|
||||
{connections.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormRow>
|
||||
|
||||
{/* Target connection — filtered to the source's db type */}
|
||||
<FormRow label="Target">
|
||||
<select
|
||||
value={targetConnectionId}
|
||||
onChange={(e) => setTargetConnectionId(e.target.value)}
|
||||
disabled={!sourceConnectionId}
|
||||
className={`${controlClass} mr-3 disabled:opacity-40 disabled:cursor-not-allowed`}
|
||||
>
|
||||
<option value="">
|
||||
{sourceConnectionId
|
||||
? "Select target..."
|
||||
: "Select a source first"}
|
||||
</option>
|
||||
{targetConnections.map((c) => (
|
||||
<option
|
||||
key={c.id}
|
||||
value={c.id}
|
||||
disabled={c.id === sourceConnectionId}
|
||||
>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormRow>
|
||||
|
||||
{/* Flow indicator */}
|
||||
{sourceConnectionId && targetConnectionId && (
|
||||
<FormRow label="Flow">
|
||||
<div className="flex items-center gap-3 px-3 py-2 text-[11px] text-text-muted">
|
||||
<span className="font-medium text-text">
|
||||
{sourceName}
|
||||
</span>
|
||||
<ArrowLeftRight
|
||||
size={12}
|
||||
className="text-accent shrink-0"
|
||||
/>
|
||||
<span className="font-medium text-text">
|
||||
{targetName}
|
||||
</span>
|
||||
</div>
|
||||
</FormRow>
|
||||
)}
|
||||
|
||||
{/* Schema (optional) */}
|
||||
{(isPg || isMysql) && (
|
||||
<FormRow label="Schema">
|
||||
<select
|
||||
value={schema}
|
||||
onChange={(e) => setSchema(e.target.value)}
|
||||
className={`${controlClass} mr-3`}
|
||||
>
|
||||
<option value="">All schemas</option>
|
||||
{availableSchemas.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormRow>
|
||||
)}
|
||||
|
||||
{/* Destructive confirmation */}
|
||||
<div className="border-b border-border bg-red-500/5 px-4 py-3 space-y-2">
|
||||
<label className="flex items-start gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={confirmed}
|
||||
onChange={(e) => setConfirmed(e.target.checked)}
|
||||
className="mt-0.5 bg-surface border-border accent-red-500 w-4 h-4 cursor-pointer"
|
||||
data-testid="sync-confirm-checkbox"
|
||||
/>
|
||||
<span className="text-sm text-red-300/90 leading-relaxed">
|
||||
I understand this will overwrite data on
|
||||
the target database. This action cannot
|
||||
be undone.
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Progress */}
|
||||
{activeJob && (
|
||||
<div className="px-4 py-3">
|
||||
<BackupProgress
|
||||
progress={activeJob.status === "completed" ? 100 : 50}
|
||||
jobType="sync"
|
||||
status={activeJob.status}
|
||||
errorMessage={activeJob.error_message ?? undefined}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<void>) {
|
||||
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(<CopyButton text="brew install mysql-client" />);
|
||||
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(<CopyButton text="brew install libpq" />);
|
||||
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(<CopyButton text="brew install libpq" />);
|
||||
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(<CopyButton text="brew install libpq" />);
|
||||
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(<CopyButton text="brew install libpq" />);
|
||||
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(<CopyButton text="x" label="Copy install command" />);
|
||||
expect(screen.getByTitle("Copy install command")).toBeTruthy();
|
||||
expect(screen.getByLabelText("Copy install command")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -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<number | null>(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 (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleCopy()}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
className={`inline-flex items-center gap-1 px-1.5 py-0.5 text-[11px] transition-colors cursor-pointer text-text-muted hover:text-text ${className}`}
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check size={11} className="text-emerald-400" />
|
||||
Copied
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy size={11} />
|
||||
Copy
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -432,4 +432,15 @@ describe("v0.7.8 command wrappers exist", () => {
|
||||
expect(typeof (cmd as Record<string, unknown>)[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 });
|
||||
});
|
||||
});
|
||||
@@ -147,8 +147,8 @@ export async function refreshConnection(connectionId: string): Promise<void> {
|
||||
|
||||
// ─── Backup / Restore / Sync ──────────────────────────────────
|
||||
|
||||
export async function detectPgTools(): Promise<PgToolStatus> {
|
||||
return invoke<PgToolStatus>("detect_pg_tools");
|
||||
export async function detectPgTools(force = false): Promise<PgToolStatus> {
|
||||
return invoke<PgToolStatus>("detect_pg_tools", { force });
|
||||
}
|
||||
|
||||
export async function pgDump(connectionId: string, options: BackupOptions): Promise<string> {
|
||||
@@ -169,8 +169,8 @@ export async function cancelQuery(connectionId: string): Promise<void> {
|
||||
return invoke<void>("cancel_query", { connectionId });
|
||||
}
|
||||
|
||||
export async function detectMysqlTools(): Promise<MySqlToolStatus> {
|
||||
return invoke<MySqlToolStatus>("detect_mysql_tools");
|
||||
export async function detectMysqlTools(force = false): Promise<MySqlToolStatus> {
|
||||
return invoke<MySqlToolStatus>("detect_mysql_tools", { force });
|
||||
}
|
||||
|
||||
export async function mysqlDump(connectionId: string, options: MySqlBackupOptions): Promise<string> {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const listeners = vi.hoisted(
|
||||
() => ({} as Record<string, (e: { payload: unknown }) => 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);
|
||||
});
|
||||
});
|
||||
@@ -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<BackupStore>((set, get) => ({
|
||||
|
||||
initListener: async () => {
|
||||
await listen<BackupJobEvent>("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") {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`);
|
||||
@@ -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"
|
||||
@@ -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"),
|
||||
`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleIdentifier</key><string>com.gridline.iconcheck.${process.pid}${Date.now()}</string>
|
||||
<key>CFBundleName</key><string>IconCheck</string>
|
||||
<key>CFBundleIconFile</key><string>icon.icns</string>
|
||||
<key>CFBundlePackageType</key><string>APPL</string>
|
||||
</dict>
|
||||
</plist>
|
||||
`,
|
||||
);
|
||||
|
||||
// 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).`);
|
||||