2 Commits
Author SHA1 Message Date
adrianbonpin 62e3b96bad chore(release): v0.8.3 version bump + changelog (#46)
Release / linux (push) Successful in 59m20s
Release / windows (push) Canceled after 0s
* chore(release): bump 0.8.2 -> 0.8.3

Version bumped across desktop/package.json, Cargo.toml (+ only the `gridline`
entry in Cargo.lock — the sqlx family is untouched), tauri.conf.json, both
landing-page `const version` strings, the README's two download tables and
'current release' banners, the MAINTENANCE comment, and the version tests.

Adds the 0.8.3 changelog entry and README release note covering the external
client-tool resolution fix (issue #44), the MariaDB client-option fix, the four
restyled tool pages, and the app-icon revert.

Historical references (older changelog entries, '(v0.8.2)' feature notes) are
deliberately left alone.

* test(ui): scope CopyButton's unhandled-rejection capture to its own error

The listener is process-global and Vitest runs several test files per worker, so
an unrelated file's rejection could be recorded and fail this test. Filter the
capture to the error this test creates, and stop the listener in a finally so a
failing assertion cannot leak it into later tests.

Verified non-vacuous: against a CopyButton without the try/catch both 'stays
quiet' tests still fail with the expected assertion.
2026-09-30 20:29:13 +08:00
adrianbonpin daa38d163e 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.
2026-09-30 20:04:16 +08:00
56 changed files with 2664 additions and 1305 deletions
+3 -23
View File
@@ -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.
@@ -212,7 +192,7 @@ Direct pushes to `prod` will be bypassed only in emergencies; prefer the PR path
- Version number across `desktop/package.json`, `desktop/src-tauri/Cargo.toml` (+ the `gridline` entry in `desktop/src-tauri/Cargo.lock` — run any `cargo metadata`/`cargo build` to sync it; do NOT blanket-replace `0.8.x` in the lock, the sqlx family shares that version), `desktop/src-tauri/tauri.conf.json`
- Landing page version strings: `www/src/pages/index.astro` and `www/public/landing.js` (both carry a `const version`), plus the new entry at the top of `www/src/data/changelog.ts`
- Tests that assert the version: `desktop/src/lib/version.test.ts`, `desktop/src/lib/bundle-config.test.ts`, `desktop/src/lib/docs-coverage.test.ts` (this one also asserts the README's asset names and the landing `const version`)
- **README download links are static (versioned)** — both download tables (top **Download** section + **Which file should I download?**) link directly to the release-tag assets (`releases/download/v0.8.2/<file>`). tauri-action uses default versioned asset names (`Gridline_<ver>_aarch64.dmg`, `Gridline-<ver>-1.x86_64.rpm`, etc.) — update BOTH tables, the two "current release (**vX.Y.Z**)" banners, and the MAINTENANCE comment on every release.
- **README download links are static (versioned)** — both download tables (top **Download** section + **Which file should I download?**) link directly to the release-tag assets (`releases/download/v0.8.3/<file>`). tauri-action uses default versioned asset names (`Gridline_<ver>_aarch64.dmg`, `Gridline-<ver>-1.x86_64.rpm`, etc.) — update BOTH tables, the two "current release (**vX.Y.Z**)" banners, and the MAINTENANCE comment on every release.
- **Bundled pg tools:** `tauri.conf.json` `bundle.resources` lists `resources/pg_tools/*`; the `release.yml` matrix builds/downloads + checksum-verifies the static binaries before the Tauri build step.
- **macOS is Developer-ID signed + notarized in CI** from the repository secrets (`APPLE_CERTIFICATE`, `APPLE_CERTIFICATE_PASSWORD`, `APPLE_SIGNING_IDENTITY`, `APPLE_API_KEY`, `APPLE_API_ISSUER`, `APPLE_API_KEY_CONTENT`, `KEYCHAIN_PASSWORD`) — the workflow logs a `notice` when they are absent and falls back to ad-hoc, so **check the run output before publishing a draft**. Verify a published DMG with `xcrun stapler validate <dmg>` plus `spctl --assess --type execute` on the `.app` inside (`source=Notarized Developer ID`). `scripts/release-mac.sh` is the **local fallback**: it auto-detects the Developer ID identity from the login keychain, reads notarization credentials from `~/.config/gridline/notarize.env`, signs + notarizes + staples both architectures, verifies the result, then uploads to the **GitHub** release with `gh release upload --clobber`.
- **After publishing the draft**, the `release: published` event runs the cask bump (`HOMEBREW_TAP_DEPLOY_KEY` pushes `Casks/gridline.rb` to `AdrianBonpin/homebrew-gridline`). Verify the cask `version` + `sha256` match the published DMGs.
@@ -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) |
+17 -15
View File
@@ -30,16 +30,16 @@
## Download
Grab the installer for your OS from the [latest release](https://github.com/AdrianBonpin/gridline/releases/latest) — the links below point at the current release (**v0.8.2**):
Grab the installer for your OS from the [latest release](https://github.com/AdrianBonpin/gridline/releases/latest) — the links below point at the current release (**v0.8.3**):
| OS | Architecture | Download |
| :--------------------------- | :--------------------------- | :----------------------------------------------------------------------------------------------------------------------------------- |
| **macOS** | Apple Silicon (M1/M2/M3/M4…) | [Gridline_0.8.2_aarch64.dmg](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.2/Gridline_0.8.2_aarch64.dmg) |
| **macOS** | Intel | [Gridline_0.8.2_x64.dmg](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.2/Gridline_0.8.2_x64.dmg) |
| **Windows** | x64 | [Gridline_0.8.2_x64-setup.exe](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.2/Gridline_0.8.2_x64-setup.exe) |
| **Debian / Ubuntu** | amd64 | [Gridline_0.8.2_amd64.deb](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.2/Gridline_0.8.2_amd64.deb) |
| **Fedora / RHEL / openSUSE** | x86_64 | [Gridline-0.8.2-1.x86_64.rpm](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.2/Gridline-0.8.2-1.x86_64.rpm) |
| **Other Linux** | amd64 | [Gridline_0.8.2_amd64.AppImage](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.2/Gridline_0.8.2_amd64.AppImage) |
| **macOS** | Apple Silicon (M1/M2/M3/M4…) | [Gridline_0.8.3_aarch64.dmg](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.3/Gridline_0.8.3_aarch64.dmg) |
| **macOS** | Intel | [Gridline_0.8.3_x64.dmg](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.3/Gridline_0.8.3_x64.dmg) |
| **Windows** | x64 | [Gridline_0.8.3_x64-setup.exe](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.3/Gridline_0.8.3_x64-setup.exe) |
| **Debian / Ubuntu** | amd64 | [Gridline_0.8.3_amd64.deb](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.3/Gridline_0.8.3_amd64.deb) |
| **Fedora / RHEL / openSUSE** | x86_64 | [Gridline-0.8.3-1.x86_64.rpm](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.3/Gridline-0.8.3-1.x86_64.rpm) |
| **Other Linux** | amd64 | [Gridline_0.8.3_amd64.AppImage](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.3/Gridline_0.8.3_amd64.AppImage) |
**macOS via Homebrew** (recommended for Mac users):
@@ -57,7 +57,7 @@ The cask installs the app directly. The DMGs are Developer-ID signed and notariz
> **macOS first launch:** Gridline is Developer-ID signed and notarized, so macOS opens it without a Gatekeeper prompt. Not sure if your Mac is Intel or Apple Silicon? See [Which file should I download?](#which-file-should-i-download) below.
<!--
MAINTENANCE: These links are STATIC (versioned) — they point at the v0.8.2
MAINTENANCE: These links are STATIC (versioned) — they point at the v0.8.3
release assets, not at a moving "latest" target. On every new release,
update BOTH tables here (Download + Which file should I download?) to the
new version's asset names, which are tauri-action's default naming:
@@ -102,6 +102,8 @@ Gridline is built for developers and small teams who manage multiple database en
<details>
<summary>View the full changelog</summary>
- **v0.8.3** — **Your installed database tools are found again** ([issue #44](https://github.com/AdrianBonpin/gridline/issues/44)): Gridline reported *"mysqldump not found"* even when the tools were installed, for three separate reasons — the bundled binaries were looked up one directory too high (`<resource_dir>/mysql_tools` instead of the `<resource_dir>/resources/mysql_tools` the installer actually bundles, which silently disabled the bundled fallback for PostgreSQL too), a macOS GUI app inherits launchd's minimal `PATH` rather than your shell's, and only `mariadb-dump`/`mariadb` were ever probed — never `mysqldump`/`mysql`, which is what `brew install mysql-client` installs. Both engines now resolve through one ordered chain (trusted absolute directories → your `PATH` → bundled), a candidate counts only if it is a real file that runs `--version`, and an explicit **Check again** re-detects on demand. **The bundled MySQL client now actually runs**: backup and sync were passing MySQL-only options (`--databases=<db>`, `--skip-column-statistics`, `--ssl-mode=…`) to the bundled MariaDB binary, so the job died at option parsing before connecting; options are now tailored per client. **Backup, Restore, DB Sync and Schema Diff say which tool they use and where it came from** (`Using system mysqldump`, `Using bundled mariadb-dump`), explain a bundled copy that is present but cannot run, offer one-click copy of the install command, warn non-blockingly when the dump client's major version is older than the server's, and share the Objects view's form layout. **The app icon is back to its previous rendering** — the macOS "legacy plate" strip from #40 was reverted at the maintainer's request.
- **v0.8.2** — **MySQL values actually show up**: every non-string MySQL column — ids and other integers, `DECIMAL`, `BIT`, `YEAR`, `DATE`/`TIME`/`DATETIME`/`TIMESTAMP`, `FLOAT`/`DOUBLE` — was rendering as `NULL`, so primary-key columns looked empty; all 30 column types now decode by their declared type, with 64-bit integers and `DECIMAL` carried as strings so no digits are lost and `DATETIME(6)` keeping its microseconds. **MySQL restore works against MySQL 8** — it no longer shells out to a bundled MariaDB client (which cannot authenticate with `caching_sha2_password`), running in-process over the same driver browsing uses, stopping at the first error with the failing statement's position, cancellable, and `DELIMITER`-aware so dumps with triggers and stored routines apply. **The `Clean` toggle is now real**: it drops only the objects the dump recreates, so an unrelated table in the target survives — and since MySQL DDL auto-commits, the Restore form warns a failed restore cannot be rolled back. **`.sql` files open with Gridline**: right-click → Open With loads the script into a query tab named after the file; it never runs on open (Run still confirms anything destructive), and with no connection open the file is held with a prompt until you pick one.
- **v0.8.1** — **Neon / Supabase / PlanetScale connections fixed**: a pasted connection string's `?sslmode=` is now carried into the saved connection (it was silently dropped, so TLS stayed off and Neon replied *"connection is insecure (try using `sslmode=require`)"*); the SSH / SSL tab's mode and certificate paths persist on create *and* edit, managed presets pre-select their TLS mode, and `*.neon.tech` / `*.supabase.co` / `*.psdb.cloud` hosts default to `require` when no mode is stored — so previously-saved connections keep working. Pasting a URI also fills every setting it can and suggests a name, so a URL can be tested and saved with no retyping. **macOS keychain prompts self-heal**: items whose ACL still points at an older binary path made macOS ask for the keychain password on *every* access, once per secret — the app now checks the ACL (silently, before reading) and repairs it when needed, so you authorize a secret once. Version bump.
@@ -348,16 +350,16 @@ Gridline's macOS builds are **Developer-ID signed and notarized by GitHub Action
#### Which file should I download?
Each release contains **one file per platform** — you only need the one that matches your computer. The links below point at the current release (**v0.8.2**):
Each release contains **one file per platform** — you only need the one that matches your computer. The links below point at the current release (**v0.8.3**):
| Your system | Download this | Notes |
| :------------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------- | :--------------------------------------------------------------- |
| macOS **Apple Silicon** (M1/M2/M3/M4…) | [Gridline_0.8.2_aarch64.dmg](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.2/Gridline_0.8.2_aarch64.dmg) | `aarch64` = Apple's own chip |
| macOS **Intel** | [Gridline_0.8.2_x64.dmg](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.2/Gridline_0.8.2_x64.dmg) | `x64` = Intel/AMD |
| **Windows** (most PCs) | [Gridline_0.8.2_x64-setup.exe](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.2/Gridline_0.8.2_x64-setup.exe) | The `.msi` is an alternate installer (for enterprises/IT admins) |
| **Debian / Ubuntu** | [Gridline_0.8.2_amd64.deb](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.2/Gridline_0.8.2_amd64.deb) | Install: `sudo apt install ./Gridline_0.8.2_amd64.deb` |
| **Fedora / RHEL / openSUSE** | [Gridline-0.8.2-1.x86_64.rpm](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.2/Gridline-0.8.2-1.x86_64.rpm) | Install: `sudo dnf install Gridline-0.8.2-1.x86_64.rpm` |
| **Any other Linux** | [Gridline_0.8.2_amd64.AppImage](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.2/Gridline_0.8.2_amd64.AppImage) | Works on every distro: `chmod +x` the file, then double-click it |
| macOS **Apple Silicon** (M1/M2/M3/M4…) | [Gridline_0.8.3_aarch64.dmg](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.3/Gridline_0.8.3_aarch64.dmg) | `aarch64` = Apple's own chip |
| macOS **Intel** | [Gridline_0.8.3_x64.dmg](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.3/Gridline_0.8.3_x64.dmg) | `x64` = Intel/AMD |
| **Windows** (most PCs) | [Gridline_0.8.3_x64-setup.exe](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.3/Gridline_0.8.3_x64-setup.exe) | The `.msi` is an alternate installer (for enterprises/IT admins) |
| **Debian / Ubuntu** | [Gridline_0.8.3_amd64.deb](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.3/Gridline_0.8.3_amd64.deb) | Install: `sudo apt install ./Gridline_0.8.3_amd64.deb` |
| **Fedora / RHEL / openSUSE** | [Gridline-0.8.3-1.x86_64.rpm](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.3/Gridline-0.8.3-1.x86_64.rpm) | Install: `sudo dnf install Gridline-0.8.3-1.x86_64.rpm` |
| **Any other Linux** | [Gridline_0.8.3_amd64.AppImage](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.3/Gridline_0.8.3_amd64.AppImage) | Works on every distro: `chmod +x` the file, then double-click it |
**Not sure if your Mac is Intel or Apple Silicon?** Click the **Apple menu** → **About This Mac**. If it shows "Apple M1/M2/M3/M4…" download the `aarch64` file; if it shows an Intel chip, download `x64`. Downloading the wrong one won't run.
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "gridline-desktop",
"private": true,
"version": "0.8.2",
"version": "0.8.3",
"description": "An open-source, high-performance database GUI client for PostgreSQL and beyond",
"type": "module",
"scripts": {
+1 -1
View File
@@ -1796,7 +1796,7 @@ dependencies = [
[[package]]
name = "gridline"
version = "0.8.2"
version = "0.8.3"
dependencies = [
"bytes",
"chrono",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "gridline"
version = "0.8.2"
version = "0.8.3"
description = "An open-source, high-performance database GUI client for PostgreSQL and beyond"
authors = ["you"]
edition = "2021"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1001 B

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 74 KiB

+280 -77
View File
@@ -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));
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,
+255 -25
View File
@@ -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);
}
+1
View File
@@ -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)]
+415
View File
@@ -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());
}
+25 -1
View File
@@ -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 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Gridline",
"version": "0.8.2",
"version": "0.8.3",
"identifier": "com.adrianbonpin.gridline",
"build": {
"beforeDevCommand": "bun run dev",
@@ -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,10 +126,19 @@ 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>
<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">
{getPlatformInstructions()}
{installInstructions}
</pre>
</div>
</div>
)}
{!checkingTools && !toolsMissing && (
@@ -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";
@@ -84,3 +84,104 @@ describe("BackupPage DB-aware", () => {
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"),
);
});
});
+173 -156
View File
@@ -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(() => {
const checkTools = useCallback(
async (force: boolean) => {
setCheckingTools(true);
setPgToolStatus(null);
setMysqlToolStatus(null);
setAvailableSchemas([]);
if (isPg) {
detectPgTools()
.then((status) => setPgToolStatus(status))
.catch(() =>
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([]));
});
} finally {
setCheckingTools(false);
}
} else if (isMysql) {
detectMysqlTools()
.then((status) => setMysqlToolStatus(status))
.catch(() =>
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));
getSchemas(connectionId)
.then((schemas) => setAvailableSchemas(schemas))
.catch(() => setAvailableSchemas([]));
});
} finally {
setCheckingTools(false);
}
} else {
setCheckingTools(false);
}
}, [connectionId, isPg, isMysql]);
},
[isPg, isMysql],
);
useEffect(() => {
setAvailableSchemas([]);
void checkTools(false);
if (isPg || isMysql) {
getSchemas(connectionId)
.then((schemas) => setAvailableSchemas(schemas))
.catch(() => setAvailableSchemas([]));
}
}, [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,128 +304,141 @@ 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">
<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">
<div className="flex-1 overflow-auto">
{/* Tool check */}
{checkingTools && checkingMessage && (
<div className="glass p-4 text-center">
<p className="text-sm text-text-muted">
<div className="px-4 py-3 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">
<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">
The {isPg ? "PostgreSQL" : "MySQL"} client tools are required for
backup/restore operations. Install them using:
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">
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">
{dumpToolFound && (
<FormRow label="Tool">
<span className="px-3 font-heading text-xs text-text">
Using {dumpSource === "bundled" ? "bundled" : "system"}{" "}
{dumpResolvedName ?? dumpFallbackName}
</span>
</FormRow>
)}
{/* Format */}
{isPg ? (
<div className="space-y-1">
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
Format
</label>
<FormRow label="Format">
<select
value={format}
onChange={(e) =>
setFormat(
e.target.value as BackupFormat,
)
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"
className={`${controlClass} mr-3`}
>
<option value="custom">
Custom Archive
</option>
<option value="custom">Custom Archive</option>
<option value="plain">Plain SQL</option>
<option value="tar">Tarball</option>
<option value="directory">
Directory
</option>
<option value="directory">Directory</option>
</select>
</div>
</FormRow>
) : 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">
<FormRow label="Format">
<span className="px-3 font-heading text-xs text-text">
Plain SQL
</div>
</div>
</span>
</FormRow>
) : null}
{/* 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">
<FormRow label="Output file">
<input
type="text"
value={filePath}
onChange={(e) =>
setFilePath(e.target.value)
}
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"
className={inputClass}
/>
<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"
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={15} />
<FolderOpen size={13} />
</button>
</div>
</div>
</FormRow>
{/* Schema (optional) */}
{/* Schema */}
{(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>
<FormRow label="Schema">
<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"
onChange={(e) => setSchema(e.target.value)}
className={`${controlClass} mr-3`}
>
<option value="">All schemas</option>
{availableSchemas.map((s) => (
@@ -405,119 +447,106 @@ export function BackupPage({ connectionId }: BackupPageProps) {
</option>
))}
</select>
</div>
</FormRow>
)}
{(isPg || isMysql) && (
<FormSectionHeader label="Options" />
)}
{/* PostgreSQL: no-owner toggle */}
{isPg && (
<label className="flex items-center gap-2.5 cursor-pointer group">
<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="rounded bg-surface border-border accent-accent w-4 h-4 cursor-pointer"
onChange={(e) => setNoOwner(e.target.checked)}
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">
<code className="text-[11px] text-text-muted/60 bg-surface-raised px-1.5 py-0.5">
--no-owner
</code>
</span>
</label>
</FormRow>
)}
{/* MySQL: option toggles */}
{isMysql && (
<div className="space-y-2">
<label className="flex items-center gap-2.5 cursor-pointer group">
<>
<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={singleTransaction}
onChange={(e) =>
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">
Single Transaction{" "}
<code className="text-[11px] text-text-muted/60 bg-surface-raised rounded px-1.5 py-0.5">
<code className="text-[11px] text-text-muted/60 bg-surface-raised px-1.5 py-0.5">
--single-transaction
</code>
</span>
</label>
<label className="flex items-center gap-2.5 cursor-pointer group">
</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="rounded bg-surface border-border accent-accent w-4 h-4 cursor-pointer"
onChange={(e) => setNoData(e.target.checked)}
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 Data{" "}
<code className="text-[11px] text-text-muted/60 bg-surface-raised rounded px-1.5 py-0.5">
<code className="text-[11px] text-text-muted/60 bg-surface-raised px-1.5 py-0.5">
--no-data
</code>
</span>
</label>
<label className="flex items-center gap-2.5 cursor-pointer group">
</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="rounded bg-surface border-border accent-accent w-4 h-4 cursor-pointer"
onChange={(e) => setRoutines(e.target.checked)}
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">
Routines{" "}
<code className="text-[11px] text-text-muted/60 bg-surface-raised rounded px-1.5 py-0.5">
<code className="text-[11px] text-text-muted/60 bg-surface-raised px-1.5 py-0.5">
--routines
</code>
</span>
</label>
<label className="flex items-center gap-2.5 cursor-pointer group">
</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="rounded bg-surface border-border accent-accent w-4 h-4 cursor-pointer"
onChange={(e) => setTriggers(e.target.checked)}
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">
Triggers{" "}
<code className="text-[11px] text-text-muted/60 bg-surface-raised rounded px-1.5 py-0.5">
<code className="text-[11px] text-text-muted/60 bg-surface-raised px-1.5 py-0.5">
--triggers
</code>
</span>
</label>
<label className="flex items-center gap-2.5 cursor-pointer group">
</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="rounded bg-surface border-border accent-accent w-4 h-4 cursor-pointer"
onChange={(e) => setEvents(e.target.checked)}
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">
Events{" "}
<code className="text-[11px] text-text-muted/60 bg-surface-raised rounded px-1.5 py-0.5">
<code className="text-[11px] text-text-muted/60 bg-surface-raised px-1.5 py-0.5">
--events
</code>
</span>
</label>
</div>
</FormRow>
</>
)}
</div>
{/* Progress */}
{activeJob && (
<div className="px-4">
<div className="px-4 py-3">
<BackupProgress
progress={activeJob.status === "completed" ? 100 : 50}
jobType="dump"
@@ -526,21 +555,9 @@ export function BackupPage({ connectionId }: BackupPageProps) {
/>
</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,10 +112,19 @@ 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>
<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">
{getPlatformInstructions()}
{installInstructions}
</pre>
</div>
</div>
)}
{!checkingTools && !toolsMissing && (
+120 -118
View File
@@ -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,125 +234,144 @@ 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">
<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 && (
<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-y-auto">
<div className="max-w-lg mx-auto space-y-6 outline outline-border">
<div className="flex-1 overflow-auto">
{/* Tool check */}
{checkingTools && checkingMessage && (
<div className="glass p-4 text-center">
<p className="text-sm text-text-muted">
<div className="px-4 py-3 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">
<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">
The {isPg ? "PostgreSQL" : "MySQL"} client tools are required for
backup/restore operations. Install them using:
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>
<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 && (
<>
{/* 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 && (
<p className="text-[11px] text-text-muted/70 -mb-2">
Restores run in-process over the driver connection — no MySQL client
installation required.
</p>
<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>
)}
{/* 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>
<FormRow label="Format">
<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"
onChange={(e) => setFormat(e.target.value)}
className={`${controlClass} mr-3`}
>
<option value="custom">
Custom Archive
</option>
<option value="custom">Custom Archive</option>
<option value="plain">Plain SQL</option>
<option value="tar">Tarball</option>
<option value="directory">
Directory
</option>
<option value="directory">Directory</option>
</select>
</div>
</FormRow>
)}
{/* 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">
<FormRow label="Backup file">
<input
type="text"
value={filePath}
onChange={(e) =>
setFilePath(e.target.value)
}
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"
className={inputClass}
/>
<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"
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"
>
<FileSearch size={15} />
<FolderOpen size={13} />
</button>
</div>
</div>
</FormRow>
{/* 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>
<FormRow label="Schema">
<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"
onChange={(e) => setSchema(e.target.value)}
className={`${controlClass} mr-3`}
>
<option value="">All schemas</option>
{availableSchemas.map((s) => (
@@ -352,14 +380,16 @@ export function RestorePage({ connectionId }: RestorePageProps) {
</option>
))}
</select>
</div>
</FormRow>
)}
<FormSectionHeader label="Options" />
{/* Clean toggle */}
{(isPg || isMysql || isSqlite) && (
<FormRow label="Clean">
<label
className={`flex items-center gap-2.5 cursor-pointer group ${
isPg && format === "plain"
className={`flex w-full items-center gap-2.5 px-3 py-2 cursor-pointer group ${
cleanDisabled
? "opacity-40 pointer-events-none"
: ""
}`}
@@ -367,67 +397,53 @@ export function RestorePage({ connectionId }: RestorePageProps) {
<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"
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"
/>
{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 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>
</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>
)}
</FormRow>
{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 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="bg-red-500/5 border border-red-500/20 px-4 py-3">
<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 rounded bg-surface border-border accent-red-500 w-4 h-4 cursor-pointer"
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.
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 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">
<div className="px-4 py-3">
<BackupProgress
progress={activeJob.status === "completed" ? 100 : 50}
jobType="restore"
@@ -436,23 +452,9 @@ export function RestorePage({ connectionId }: RestorePageProps) {
/>
</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>
</>
)}
</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>
)}
@@ -60,3 +60,49 @@ describe("SyncPage DB-aware", () => {
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());
});
});
+114 -125
View File
@@ -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(() => {
const checkTools = useCallback(
async (force: boolean) => {
setPgToolStatus(null);
setMysqlToolStatus(null);
setConfirmed(false);
if (isPg) {
setCheckingTools(true);
detectPgTools()
.then((status) => setPgToolStatus(status))
.catch(() =>
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));
});
} finally {
setCheckingTools(false);
}
} else if (isMysql) {
setCheckingTools(true);
detectMysqlTools()
.then((status) => setMysqlToolStatus(status))
.catch(() =>
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));
});
} finally {
setCheckingTools(false);
}
}, [isPg, isMysql]);
}
},
[isPg, isMysql],
);
useEffect(() => {
setConfirmed(false);
void checkTools(false);
}, [checkTools]);
// Fetch schemas from the source connection when it changes
useEffect(() => {
@@ -195,31 +207,59 @@ 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">
<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>
)}
{!checkingTools && !toolsMissing && (
<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-y-auto">
<div className="max-w-lg mx-auto space-y-6 outline outline-border">
<div className="flex-1 overflow-auto">
{/* Tool check */}
{checkingTools && checkingMessage && (
<div className="glass p-4 text-center">
<p className="text-sm text-text-muted">
<div className="px-4 py-3 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">
<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"
@@ -242,58 +282,45 @@ export function SyncPage() {
{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 && (
<>
{/* 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>
<FormSectionHeader label="Connections" />
{/* Source connection */}
<FormRow label="Source">
<select
value={sourceConnectionId}
onChange={(e) => {
setSourceConnectionId(
e.target.value,
);
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"
className={`${controlClass} mr-3`}
>
<option value="">
Select source...
</option>
<option value="">Select source...</option>
{connections.map((c) => (
<option
key={c.id}
value={c.id}
>
<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>
</FormRow>
{/* Target connection — filtered to the source's db type */}
<FormRow label="Target">
<select
value={targetConnectionId}
onChange={(e) =>
setTargetConnectionId(
e.target.value,
)
}
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"
className={`${controlClass} mr-3 disabled:opacity-40 disabled:cursor-not-allowed`}
>
<option value="">
{sourceConnectionId
@@ -304,81 +331,58 @@ export function SyncPage() {
<option
key={c.id}
value={c.id}
disabled={
c.id === sourceConnectionId
}
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>
)}
</FormRow>
{/* Flow indicator */}
{sourceConnectionId && targetConnectionId && (
<div className="flex items-center gap-3 text-[11px] text-text-muted">
<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">
{connections.find(
(c) =>
c.id === sourceConnectionId,
)?.name ?? sourceConnectionId}
{sourceName}
</span>
<ArrowLeftRight
size={12}
className="text-accent shrink-0"
/>
<span className="font-medium text-text">
{connections.find(
(c) =>
c.id === targetConnectionId,
)?.name ?? targetConnectionId}
{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>
)}
</div>
{/* Destructive confirmation */}
<div className="bg-red-500/5 border border-red-500/20 px-4 py-3">
<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 rounded bg-surface border-border accent-red-500 w-4 h-4 cursor-pointer"
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">
@@ -391,7 +395,7 @@ export function SyncPage() {
{/* Progress */}
{activeJob && (
<div className="px-4">
<div className="px-4 py-3">
<BackupProgress
progress={activeJob.status === "completed" ? 100 : 50}
jobType="sync"
@@ -400,24 +404,9 @@ export function SyncPage() {
/>
</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>
</>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,149 @@
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.
*
* `match` narrows the capture to this test's own error. It is required, not a
* convenience: the listener is process-global, and Vitest runs several test
* files in the same worker process — without a filter another file's rejection
* would be recorded here and fail this test. Filtering keeps the assertion
* precise ("did *our* rejection escape?") instead of order-dependent.
*/
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(match: string) {
const seen: unknown[] = [];
const listener = (reason: unknown) => {
if (String(reason).includes(match)) 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("NotAllowedError");
try {
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([]);
} finally {
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("writeText");
try {
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([]);
} finally {
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();
});
});
+66
View File
@@ -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>
);
}
+3 -3
View File
@@ -1,12 +1,12 @@
import { describe, it, expect } from "vitest";
import tauriConf from "../../src-tauri/tauri.conf.json";
describe("tauri bundle config (v0.8.2)", () => {
describe("tauri bundle config (v0.8.3)", () => {
it("declares bundled pg_tools resources", () => {
expect(tauriConf.bundle.resources).toContain("resources/pg_tools/*");
});
it("version is 0.8.2", () => {
expect(tauriConf.version).toBe("0.8.2");
it("version is 0.8.3", () => {
expect(tauriConf.version).toBe("0.8.3");
});
it("declares a .sql file association with alternate rank", () => {
const assoc = tauriConf.bundle.fileAssociations;
+11
View File
@@ -433,3 +433,14 @@ describe("v0.7.8 command wrappers exist", () => {
}
});
});
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 });
});
});
+4 -4
View File
@@ -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> {
+24 -9
View File
@@ -4,8 +4,9 @@ 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", () => {
describe("v0.8.3 docs coverage", () => {
it("AGENTS.md marks inline cell editing complete", () => {
expect(agents).toContain("Inline cell editing");
expect(agents).toMatch(/Inline cell editing \| ✅/);
@@ -25,8 +26,8 @@ describe("v0.8.2 docs coverage", () => {
expect(agents).toMatch(/Connection status indicator on cards \| ✅/);
expect(agents).toMatch(/Move-to-folder bulk action \| ✅/);
});
it("README declares v0.8.2", () => {
expect(readme).toContain("0.8.2");
it("README declares v0.8.3", () => {
expect(readme).toContain("0.8.3");
});
it("AGENTS.md marks schema CRUD complete", () => {
expect(agents).toMatch(/Schema CRUD \| ✅/);
@@ -65,13 +66,13 @@ describe("v0.8.2 docs coverage", () => {
expect(agents).toMatch(/Cancel long-running queries \| ✅/);
expect(agents).toMatch(/Settings export\/import \| ✅/);
});
it("README links to v0.8.2 assets in both download tables", () => {
expect(readme).toContain("releases/download/v0.8.2/");
expect(readme).toContain("Gridline_0.8.2_aarch64.dmg");
expect(readme).toContain("Gridline-0.8.2-1.x86_64.rpm");
it("README links to v0.8.3 assets in both download tables", () => {
expect(readme).toContain("releases/download/v0.8.3/");
expect(readme).toContain("Gridline_0.8.3_aarch64.dmg");
expect(readme).toContain("Gridline-0.8.3-1.x86_64.rpm");
});
it("landing.js platform download links point to v0.8.2", () => {
expect(landing).toContain('const version = "0.8.2"');
it("landing.js platform download links point to v0.8.3", () => {
expect(landing).toContain('const version = "0.8.3"');
});
it("AGENTS.md documents the native MySQL restore and the .sql association", () => {
// These claims would actively mislead the next contributor if they were
@@ -80,3 +81,17 @@ describe("v0.8.2 docs coverage", () => {
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");
});
});
+9
View File
@@ -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 {
+2 -2
View File
@@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest";
import pkg from "../../package.json";
describe("version", () => {
it("declares v0.8.2 across the app shell", () => {
expect(pkg.version).toBe("0.8.2");
it("declares v0.8.3 across the app shell", () => {
expect(pkg.version).toBe("0.8.3");
});
});
+36
View File
@@ -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);
});
});
+6 -1
View File
@@ -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") {
+1 -3
View File
@@ -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"
}
}
-143
View File
@@ -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`);
-86
View File
@@ -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 -1
View File
@@ -9,7 +9,7 @@
# signed build without spending CI minutes, or when Actions is unavailable.
#
# Usage:
# TAG=v0.8.2 ./scripts/release-mac.sh
# TAG=v0.8.3 ./scripts/release-mac.sh
#
# The release is keyed by tag: the Actions job creates the draft on a tag push,
# and this script finds it (creating it only if it somehow doesn't exist) and
-147
View File
@@ -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).`);
+1 -1
View File
@@ -60,7 +60,7 @@
const keepLabel = downloadBtn.hasAttribute("data-download-label");
const releasesUrl = "https://github.com/AdrianBonpin/gridline/releases";
const version = "0.8.2";
const version = "0.8.3";
if (isMobile) {
downloadBtn.href = releasesUrl;
+10
View File
@@ -5,6 +5,16 @@ export interface ChangelogEntry {
}
export const changelogEntries: ChangelogEntry[] = [
{
version: "0.8.3",
date: "2026-09-30",
highlights: [
"External tools are found again, and this is the fix for issue #44 — Gridline reported “mysqldump not found” even with the tools installed, for three independent reasons. The bundled binaries were looked up one directory too high (`<resource_dir>/mysql_tools` instead of the `<resource_dir>/resources/mysql_tools` Tauri actually bundles), which silently disabled the bundled fallback for PostgreSQL as well as MySQL; a macOS GUI app inherits launchd's minimal `PATH` rather than your shell's, so Homebrew's `/opt/homebrew/bin` was invisible; and only `mariadb-dump`/`mariadb` were ever probed, never `mysqldump`/`mysql` — which is what `brew install mysql-client` actually installs. Both engines now resolve through one ordered chain: trusted absolute directories (Homebrew, MacPorts, Postgres.app, `/usr/local`), then your inherited `PATH`, then the bundled copy, where a candidate counts only if it is a real file that runs `--version`. Results are cached, and an explicit **Check again** re-detects.",
"The bundled MySQL client works too. The dump step was passing MySQL-only options to the bundled MariaDB binary (`--databases=<db>`, `--skip-column-statistics`, `--ssl-mode=…`), so the job aborted at option parsing before it ever connected. Options are now tailored per client — MariaDB clients get `--ssl` and a positional `--databases` — so backup and DB sync succeed with either client.",
"Backup, Restore, DB Sync and Schema Diff now tell you which tool they are using and where it came from (`Using system mysqldump`, `Using bundled mariadb-dump`), say so explicitly when a bundled copy is present but cannot run, and let you copy the install command in one click. A dump client whose major version is older than the server's produces a **non-blocking** warning at job start instead of silence. Those four pages also moved onto the same form layout the Objects view uses.",
"The app icon is back to its previous rendering — at the maintainer's request the macOS “legacy plate” strip added in #40 was reverted, so the Dock and launcher icon can show a light border again below roughly 72px.",
],
},
{
version: "0.8.2",
date: "2026-09-29",
+1 -1
View File
@@ -15,7 +15,7 @@ import queryEditor from "../assets/screenshots/query-editor.png";
import erDiagram from "../assets/screenshots/er-diagram.png";
import home from "../assets/screenshots/home.png";
import dataGrid from "../assets/screenshots/data-grid.png";
const version = "0.8.2";
const version = "0.8.3";
const site = "https://getgridline.app";
const jsonLd = [
buildSoftwareApplicationJsonLd({