Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
092b3b2831 | ||
|
|
88e7e45818 | ||
|
|
ce4a73b0bb | ||
|
|
8b08ad0e46 | ||
|
|
b17bf95b6d | ||
|
|
9089f313f8 | ||
|
|
3454ce67fe |
@@ -104,7 +104,7 @@ gridline/
|
||||
├── .github/workflows/ # release.yml (monorepo paths) + auto-assign.yml
|
||||
├── docs/ # Project docs
|
||||
├── screenshots/ # README marketing images
|
||||
├── scripts/ # Release tooling (render-cask.sh)
|
||||
├── scripts/ # Release tooling (render-cask.sh, release-mac.sh) + Gitea mirror (mirror-gitea.sh, hooks/, install-git-hooks.sh)
|
||||
└── AGENTS.md # This file
|
||||
```
|
||||
|
||||
@@ -161,30 +161,61 @@ 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.
|
||||
|
||||
**Never commit or push directly to `prod`** — `prod` is protected (branch rules require a pull request). Always:
|
||||
|
||||
1. Branch out first — `git checkout -b feat/<short-name>` (or `fix/…`, `docs/…`) from `prod`
|
||||
2. Commit and push the feature branch
|
||||
3. Open a PR into `prod` and merge it there
|
||||
2. Commit and push the feature branch to `github` (`git push -u github <branch>`)
|
||||
3. Open a PR into `prod` on GitHub (`gh pr create --repo AdrianBonpin/gridline --base prod`) and merge it there
|
||||
|
||||
Direct pushes to `prod` will be bypassed only in emergencies; prefer the PR path so changes land through the PR gate.
|
||||
|
||||
**The Gitea fallback keeps itself current — run `scripts/install-git-hooks.sh` once per clone.** It sets `core.hooksPath=scripts/hooks`, so:
|
||||
|
||||
- `pre-push` mirrors `prod` + all tags to `origin` (Gitea) whenever you push to the `github` remote,
|
||||
- `post-merge` mirrors again after a `git pull`/merge while on `prod` (a PR squash-merged on GitHub only reaches the mirror when the local `prod` pulls it).
|
||||
|
||||
`scripts/mirror-gitea.sh` does the same by hand (`--branches` also pushes feature branches); it refuses to push to a GitHub remote and exits non-zero if the mirror could not be updated. The hooks downgrade that to a warning — **a Gitea outage never blocks a push to GitHub**.
|
||||
|
||||
### Releases
|
||||
|
||||
Cut a release by tagging the **`prod`** branch once the PR is merged — `git tag vN.M.N && git push origin vN.M.N`. GitHub Actions (`release.yml`) builds installers for macOS (Apple Silicon + Intel), Windows, and Linux and opens a **draft** release (review + publish on GitHub).
|
||||
**Releases are GitHub-only.** Tag `prod` and push the tag to the `github` remote — `git tag vN.M.N && git push github vN.M.N`. GitHub Actions (`release.yml`) builds installers for macOS (Apple Silicon + Intel), Windows, and Linux and opens a **draft** release (review + publish on GitHub). The Gitea tag arrives automatically via the `pre-push` hook (or `scripts/mirror-gitea.sh`) — never `git push origin vN.M.N` expecting a build, and never publish a release on Gitea.
|
||||
|
||||
**Before tagging**, keep everything in sync:
|
||||
- Version number across `desktop/package.json`, `desktop/src-tauri/Cargo.toml`, `desktop/src-tauri/tauri.conf.json`, and `www/src/pages/index.astro` (the landing page version string)
|
||||
- `desktop/src/lib/version.test.ts` and `desktop/src/lib/docs-coverage.test.ts` if they assert the 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.7.9/<file>`). tauri-action uses default versioned asset names (`Gridline_<ver>_aarch64.dmg`, `Gridline-<ver>-1.x86_64.rpm`, etc.) — update BOTH tables to the new names on every release (see the MAINTENANCE comment in README.md).
|
||||
- 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.
|
||||
- **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** via `scripts/release-mac.sh` (built manually on Adrian's machine; not in CI). The script auto-detects the Developer ID identity from the login keychain, reads notarization credentials from `~/.config/gridline/notarize.env` (App Store Connect API key), and verifies codesign + stapled notarization before uploading. If the cert or credentials are missing it fails with setup instructions (see the README's "macOS signing & notarization" section).
|
||||
- **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.
|
||||
|
||||
### Adding a Tauri Command
|
||||
|
||||
@@ -239,18 +270,18 @@ Planned work is prioritized in the [Project Roadmap](./ROADMAP.md) (source of tr
|
||||
| :--- | :---: | :--- |
|
||||
| Connections CRUD (PostgreSQL, MySQL, SQLite, Redis, MariaDB) | ✅ | Full create/read/update/delete with form validation |
|
||||
| MariaDB connections | ✅ | First-class db_type on the MySQL driver path (v0.8.0) |
|
||||
| New Connection screen (revamped) | ✅ | Two-stage entry → configured flow: Connection URI + 6-card provider grid (PostgreSQL / MySQL / SQLite / Redis / Supabase / NeonDB) with an OR divider → expands into label + tags/env/folder + General|SSH·SSL tabs. Supabase & NeonDB are managed-PostgreSQL presets (persist as `postgresql`) with in-app setup guides + SSL hints; PlanetScale is a managed-MySQL (Vitess) preset (persists as `mysql`) with a setup guide + `.psdb.cloud` host detection (v0.8.0); SQLite swaps the URI field for a file-path + Browse input (v0.7.0) |
|
||||
| New Connection screen (revamped) | ✅ | Two-stage entry → configured flow: Connection URI + 6-card provider grid (PostgreSQL / MySQL / SQLite / Redis / Supabase / NeonDB) with an OR divider → expands into label + tags/env/folder + General|SSH·SSL tabs. **Pasting a URI fills every setting it can** — type, host, port, user, password, database, `?sslmode=`/`ssl-mode=` (Neon/Supabase → Require, MySQL `VERIFY_IDENTITY` → verify-full) — and offers a URL-derived label (database name, else host) as a placeholder that Save/Test uses when the field is left blank; Supabase & NeonDB are managed-PostgreSQL presets (persist as `postgresql`) with in-app setup guides + SSL hints, PlanetScale is a managed-MySQL (Vitess) preset (persists as `mysql`) with a setup guide + `.psdb.cloud` host detection (v0.8.0); presets pre-select their TLS mode (Supabase/Neon Require, PlanetScale Verify Full); SQLite swaps the URI field for a file-path + Browse input (v0.7.0)
|
||||
| Connection testing (all DB types) | ✅ | PostgreSQL, MySQL, SQLite, Redis all testable |
|
||||
| DB Viewer: PostgreSQL browse + query | ✅ | Schemas, tables, paginated data, FK preview, JSON viewer |
|
||||
| DB Viewer: SQLite browse + query | ✅ | Full support via rusqlite |
|
||||
| DB Viewer: MySQL browse + query + edit | ✅ | Full viewer: connect (SSL + SSH tunnel), databases/tables/columns/FKs, query + pagination, inline cell editing + changes queue, DDL copy (`SHOW CREATE TABLE`), CSV/JSON import — added in v0.7.0. PK-only editing (no ctid equivalent); VARBINARY `information_schema` columns decoded correctly |
|
||||
| DB Viewer: MySQL browse + query + edit | ✅ | Full viewer: connect (SSL + SSH tunnel), databases/tables/columns/FKs, query + pagination, inline cell editing + changes queue, DDL copy (`SHOW CREATE TABLE`), CSV/JSON import — added in v0.7.0. PK-only editing (no ctid equivalent); VARBINARY `information_schema` columns decoded correctly. **All MySQL column types decode by their declared type** (`mysql_cell_kind` → `mysql_cell_to_json` in `commands/query.rs`, v0.8.2) — `int`/`bigint` (64-bit stringified so the value survives IPC), `decimal` (exact digits, never a float), `date`/`time`/`datetime`/`timestamp` (naive, never an instant — MySQL converts TIMESTAMP via the session time zone), `float`/`double`, `bit`, `year`, `json`, text and binary — where the previous blind `try_get` try-chain rendered every non-string type as NULL (issue #41: 21 of 30 types) |
|
||||
| DB Viewer: Redis browse | ❌ | Connection + test only; browsing gated off with a clean "not supported" state (v0.7.0) |
|
||||
| Password storage in OS keychain | ✅ | macOS Keychain, Linux Secret Service, Windows Credential Manager; macOS items get an explicit `SecAccess` ACL pinning them to the app's code-signature requirement on every save (`keychain_acl.rs`) — items survive re-signs/upgrades without keychain permission prompts |
|
||||
| Password storage in OS keychain | ✅ | macOS Keychain, Linux Secret Service, Windows Credential Manager. macOS items get an explicit `SecAccess` ACL pinning them to the app's code-signature requirement on every save (`keychain_acl.rs`) — items survive re-signs/upgrades without keychain permission prompts. **Self-healing ACLs**: the ACL is also (silently) verified *before* reads and repaired when this app isn't in it, so items pinned to an older binary path (pre-monorepo `src-tauri/`, a reinstalled app, an ad-hoc dev build) cost one authorization instead of asking for the keychain password on every access, per secret |
|
||||
| Enable keychain toggle | ✅ | Default ON (opt-out); OFF = don't persist the DB password (session-only, re-prompt on connect) + purge existing keychain entry; SSH secrets stay keychain-only (v0.7.6) |
|
||||
| SSH tunnel config UI | ✅ | Host, port, user, auth method, key path, passphrase fields |
|
||||
| SSH tunnel runtime | ✅ | Real ssh2 tunnel (password + key auth), binds 127.0.0.1 only, secrets in OS keychain (`ssh_password:<id>` / `ssh_passphrase:<id>`), closed on pool eviction / app exit; TLS downgraded to `require` through the tunnel |
|
||||
| SSL/TLS config UI | ✅ | Mode (disable/require/verify-ca/verify-full), cert paths |
|
||||
| SSL/TLS runtime | ✅ | PostgreSQL all modes via rustls (disable/require/verify-ca/verify-full; **v1: `verify-ca` behaves as `verify-full`** — documented refinement), client certs PKCS#1/PKCS#8/EC, encrypted client keys rejected; MySQL test path maps modes (verify-full → VerifyIdentity) |
|
||||
| SSL/TLS config UI | ✅ | Mode (disable/require/verify-ca/verify-full), cert paths. Selections (mode + CA/client cert/key) are persisted on create **and** edit — the form data type carries the `ssl_*` fields and the submit handlers pass them through (previously the SSH/SSL tab's values were dropped, so connections saved with TLS off and managed providers rejected them) |
|
||||
| SSL/TLS runtime | ✅ | PostgreSQL all modes via rustls (disable/require/verify-ca/verify-full; **v1: `verify-ca` behaves as `verify-full`** — documented refinement), client certs PKCS#1/PKCS#8/EC, encrypted client keys rejected; MySQL test path maps modes (verify-full → VerifyIdentity). Managed hosts (`*.neon.tech` / `*.supabase.co` / `*.psdb.cloud`) default to `require` when the connection has no `ssl_mode` (`db::tls::resolve_ssl_mode`), so pre-fix saved connections still connect; an explicit mode always wins |
|
||||
| Roles & grants (PG) | ✅ | Create/edit/drop PostgreSQL roles and GRANT/REVOKE privileges per object class from a UI (v0.7.7) |
|
||||
|
||||
### Home Screen & Organization
|
||||
@@ -373,9 +404,11 @@ 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) |
|
||||
| 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 |
|
||||
| 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/restore via mysqldump (system-first); DB-to-DB sync extended beyond PostgreSQL (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** |
|
||||
| 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) |
|
||||
| Table structure export (DDL) | ❌ | |
|
||||
|
||||
|
||||
@@ -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.0**):
|
||||
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**):
|
||||
|
||||
| OS | Architecture | Download |
|
||||
| :--------------------------- | :--------------------------- | :----------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **macOS** | Apple Silicon (M1/M2/M3/M4…) | [Gridline_0.8.0_aarch64.dmg](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.0/Gridline_0.8.0_aarch64.dmg) |
|
||||
| **macOS** | Intel | [Gridline_0.8.0_x64.dmg](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.0/Gridline_0.8.0_x64.dmg) |
|
||||
| **Windows** | x64 | [Gridline_0.8.0_x64-setup.exe](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.0/Gridline_0.8.0_x64-setup.exe) |
|
||||
| **Debian / Ubuntu** | amd64 | [Gridline_0.8.0_amd64.deb](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.0/Gridline_0.8.0_amd64.deb) |
|
||||
| **Fedora / RHEL / openSUSE** | x86_64 | [Gridline-0.8.0-1.x86_64.rpm](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.0/Gridline-0.8.0-1.x86_64.rpm) |
|
||||
| **Other Linux** | amd64 | [Gridline_0.8.0_amd64.AppImage](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.0/Gridline_0.8.0_amd64.AppImage) |
|
||||
| **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 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.0
|
||||
MAINTENANCE: These links are STATIC (versioned) — they point at the v0.8.2
|
||||
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,10 @@ Gridline is built for developers and small teams who manage multiple database en
|
||||
<details>
|
||||
<summary>View the full changelog</summary>
|
||||
|
||||
- **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.
|
||||
|
||||
- **v0.7.15** — **Support channel on the landing page**: optional coffee via [Ko-fi](https://ko-fi.com/adrianbonpin), never gates a feature. Landing copy refreshed (em dashes dropped, OSS section simplified, funding FAQ); version bump.
|
||||
|
||||
- **v0.7.14** — **No more repeated macOS keychain prompts**: saved connection passwords and SSH secrets are now ACL-pinned to the app's code signature on every save (`keychain_acl`), so app upgrades never re-trigger the macOS keychain access dialog (users upgrading from pre-signing builds see one final prompt — choose **Always Allow**). **Install via Homebrew**: `brew tap AdrianBonpin/gridline https://github.com/AdrianBonpin/homebrew-gridline.git` then `brew install --cask gridline`; version bump.
|
||||
@@ -338,22 +342,22 @@ Gridline's macOS builds are **Developer-ID signed and notarized by GitHub Action
|
||||
|
||||
When these secrets are absent the macOS jobs still build, but the DMGs are ad-hoc signed and **not** notarized (users get the “Open Anyway” prompt). The CI re-signs the bundled PostgreSQL/MariaDB client binaries with the same Developer ID identity, because Apple rejects ad-hoc-signed nested executables during notarization.
|
||||
|
||||
`scripts/release-mac.sh` is the **local fallback** path (used before GitHub Actions took over macOS builds, and still handy for a signed build without burning CI minutes). It auto-detects the Developer ID identity from your keychain, reads notarization credentials from `~/.config/gridline/notarize.env` (`APPLE_API_KEY`, `APPLE_API_ISSUER`, `APPLE_API_KEY_PATH`), signs both architectures, notarizes + staples them, and verifies the result before uploading. If the certificate or credentials are missing it fails with instructions (set `GRIDLINE_SKIP_NOTARIZE=1` to build signed-but-unnotarized for testing only).
|
||||
`scripts/release-mac.sh` is the **local fallback** path (handy for a signed build without burning CI minutes, or when Actions is unavailable). It auto-detects the Developer ID identity from your keychain, reads notarization credentials from `~/.config/gridline/notarize.env` (`APPLE_API_KEY`, `APPLE_API_ISSUER`, `APPLE_API_KEY_PATH`), signs both architectures, notarizes + staples them, verifies the result, then uploads the DMGs to the **GitHub** release for the tag (`gh release upload --clobber`, so a locally signed pair supersedes the CI-built ones — publish the draft afterwards). If the certificate or credentials are missing it fails with instructions (set `GRIDLINE_SKIP_NOTARIZE=1` to build signed-but-unnotarized for testing only).
|
||||
|
||||
**Windows** still shows a SmartScreen prompt (no Windows signing cert yet); **Linux** installs without a warning.
|
||||
|
||||
#### 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.0**):
|
||||
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**):
|
||||
|
||||
| Your system | Download this | Notes |
|
||||
| :------------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------- | :--------------------------------------------------------------- |
|
||||
| macOS **Apple Silicon** (M1/M2/M3/M4…) | [Gridline_0.8.0_aarch64.dmg](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.0/Gridline_0.8.0_aarch64.dmg) | `aarch64` = Apple's own chip |
|
||||
| macOS **Intel** | [Gridline_0.8.0_x64.dmg](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.0/Gridline_0.8.0_x64.dmg) | `x64` = Intel/AMD |
|
||||
| **Windows** (most PCs) | [Gridline_0.8.0_x64-setup.exe](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.0/Gridline_0.8.0_x64-setup.exe) | The `.msi` is an alternate installer (for enterprises/IT admins) |
|
||||
| **Debian / Ubuntu** | [Gridline_0.8.0_amd64.deb](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.0/Gridline_0.8.0_amd64.deb) | Install: `sudo apt install ./Gridline_0.8.0_amd64.deb` |
|
||||
| **Fedora / RHEL / openSUSE** | [Gridline-0.8.0-1.x86_64.rpm](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.0/Gridline-0.8.0-1.x86_64.rpm) | Install: `sudo dnf install Gridline-0.8.0-1.x86_64.rpm` |
|
||||
| **Any other Linux** | [Gridline_0.8.0_amd64.AppImage](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.0/Gridline_0.8.0_amd64.AppImage) | Works on every distro: `chmod +x` the file, then double-click it |
|
||||
| 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 |
|
||||
|
||||
**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.
|
||||
|
||||
|
||||
@@ -16,6 +16,12 @@ This file is the **source of truth** for what Gridline is building. [AGENTS.md](
|
||||
- **PlanetScale preset** — managed MySQL (Vitess) provider card + setup guide + `.psdb.cloud` host detection; persists as `db_type: mysql`
|
||||
- **Version bump** 0.7.15 → **0.8.0**.
|
||||
|
||||
## ✅ Shipped (unreleased)
|
||||
|
||||
- **Connection-string SSL mode is honored end to end** — pasting a provider URL now carries its `?sslmode=` (Neon/Supabase `require`, `verify-full`, MySQL `ssl-mode=`) into the saved connection instead of silently connecting with TLS off, and the SSH/SSL tab's mode + certificate paths are persisted on create/edit (they were previously dropped before reaching the backend). Save/Test also fall back to a URL-derived label, so a pasted URL can be saved without typing anything. Fixes managed-provider connections failing with *"connection is insecure (try using `sslmode=require`)"*.
|
||||
- **Managed hosts default to TLS** — `*.neon.tech` / `*.supabase.co` / `*.psdb.cloud` resolve to `require` when a connection has no `ssl_mode`, so connections saved before the form persisted it (or imported without it) connect again without being re-saved. An explicit mode — including `disable` — always wins.
|
||||
- **Managed presets preselect their TLS mode** — Supabase/NeonDB → Require, PlanetScale → Verify Full.
|
||||
|
||||
## 📋 In the queue
|
||||
|
||||
### Full Redis Support
|
||||
@@ -36,7 +42,6 @@ MariaDB and TimescaleDB moved to [0.8.0](#-next-up-080). Remaining candidates: C
|
||||
Supabase and NeonDB presets shipped in v0.7.0; PlanetScale moved to [0.8.0](#-next-up-080). Remaining:
|
||||
|
||||
- **Turso** (libSQL — not SQLite over the wire; needs a `libsql` driver path in the Rust backend, not just a connection preset)
|
||||
- Provider detection guidance: paste a provider URL → Gridline auto-fills host/port/SSL mode; provider "connect" docs linked from the connection form
|
||||
|
||||
### Query Workbench Upgrades
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "gridline-desktop",
|
||||
"private": true,
|
||||
"version": "0.8.0",
|
||||
"version": "0.8.2",
|
||||
"description": "An open-source, high-performance database GUI client for PostgreSQL and beyond",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -339,6 +339,19 @@ dependencies = [
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bigdecimal"
|
||||
version = "0.4.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fa3f3d8cbf4dffcfe4991de61d012bef509a409ecbe9dd41049bfe32b4d4653"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"libm",
|
||||
"num-bigint",
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bit-set"
|
||||
version = "0.8.0"
|
||||
@@ -1783,7 +1796,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridline"
|
||||
version = "0.8.0"
|
||||
version = "0.8.2"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"chrono",
|
||||
@@ -1809,6 +1822,7 @@ dependencies = [
|
||||
"tauri-plugin-fs",
|
||||
"tauri-plugin-keyring-store",
|
||||
"tauri-plugin-opener",
|
||||
"tauri-plugin-single-instance",
|
||||
"tokio",
|
||||
"tokio-postgres",
|
||||
"tokio-postgres-rustls",
|
||||
@@ -4425,8 +4439,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a999083c1af5b5d6c071d34a708a19ba3e02106ad82ef7bbd69f5e48266b613b"
|
||||
dependencies = [
|
||||
"atoi",
|
||||
"bigdecimal",
|
||||
"byteorder",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"crc",
|
||||
"crossbeam-queue",
|
||||
"either",
|
||||
@@ -4507,9 +4523,11 @@ checksum = "5afe4c38a9b417b6a9a5eeffe7235d0a106716495536e7727d1c7f4b1ff3eba6"
|
||||
dependencies = [
|
||||
"atoi",
|
||||
"base64 0.22.1",
|
||||
"bigdecimal",
|
||||
"bitflags 2.13.1",
|
||||
"byteorder",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"crc",
|
||||
"digest 0.10.7",
|
||||
"dotenvy",
|
||||
@@ -4549,8 +4567,10 @@ checksum = "b1dbb157e65f10dbe01f729339c06d239120221c9ad9fa0ba8408c4cc18ecf21"
|
||||
dependencies = [
|
||||
"atoi",
|
||||
"base64 0.22.1",
|
||||
"bigdecimal",
|
||||
"bitflags 2.13.1",
|
||||
"byteorder",
|
||||
"chrono",
|
||||
"crc",
|
||||
"dotenvy",
|
||||
"etcetera",
|
||||
@@ -4566,6 +4586,7 @@ dependencies = [
|
||||
"log",
|
||||
"md-5 0.10.6",
|
||||
"memchr",
|
||||
"num-bigint",
|
||||
"once_cell",
|
||||
"rand 0.8.7",
|
||||
"serde",
|
||||
@@ -4586,6 +4607,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b2cdd83c008a622d94499c0006d8ee5f821f36c89b7d625c900e5dc30b5c5ee"
|
||||
dependencies = [
|
||||
"atoi",
|
||||
"chrono",
|
||||
"flume",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
@@ -5020,6 +5042,22 @@ dependencies = [
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-single-instance"
|
||||
version = "2.4.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "db817fe9295e19b7d8357e900af31edb93703dd9fb6de524b007b47b6afc63b0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"thiserror 2.0.19",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"windows-sys 0.60.2",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-runtime"
|
||||
version = "2.11.3"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "gridline"
|
||||
version = "0.8.0"
|
||||
version = "0.8.2"
|
||||
description = "An open-source, high-performance database GUI client for PostgreSQL and beyond"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
@@ -33,7 +33,9 @@ tokio = { version = "1", features = ["full"] }
|
||||
tokio-postgres = { version = "0.7", features = ["with-chrono-0_4", "with-uuid-1", "with-serde_json-1"] }
|
||||
bytes = "1"
|
||||
futures-util = "0.3"
|
||||
sqlx = { version = "0.8", features = ["runtime-tokio", "mysql", "tls-rustls", "json"] }
|
||||
# chrono/bigdecimal: MySQL DECIMAL and DATE/TIME/DATETIME/TIMESTAMP have no
|
||||
# decoder without them, so those columns silently rendered as NULL (#41).
|
||||
sqlx = { version = "0.8", features = ["runtime-tokio", "mysql", "tls-rustls", "json", "chrono", "bigdecimal"] }
|
||||
redis = { version = "0.27", features = ["tokio-comp"] }
|
||||
# async-ssh2 is not available at v0.4; using synchronous ssh2 via tokio::task::spawn_blocking per plan's fallback
|
||||
# vendored-openssl: link OpenSSL statically so the release binary has NO
|
||||
@@ -59,3 +61,10 @@ security-framework-sys = "2.17"
|
||||
# (already a transitive dep of security-framework; pinned to the same 0.10 line)
|
||||
core-foundation = "0.10"
|
||||
|
||||
# Windows/Linux deliver an OS "open with" request as argv. Without this a
|
||||
# second double-click would launch a second app instead of forwarding the path
|
||||
# to the running one. macOS needs no equivalent: LaunchServices routes to the
|
||||
# running app via RunEvent::Opened.
|
||||
[target.'cfg(any(target_os = "windows", target_os = "linux"))'.dependencies]
|
||||
tauri-plugin-single-instance = "2"
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 8.3 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 8.4 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 6.7 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 9.4 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1001 B |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 20 KiB |
@@ -1,6 +1,7 @@
|
||||
//! Per-connection cancellation handles, stored independently of the pool
|
||||
//! lock so `cancel_query` can dispatch while a long query holds the pool mutex.
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use rusqlite::InterruptHandle;
|
||||
@@ -59,6 +60,12 @@ pub enum CancelHandle {
|
||||
#[derive(Default)]
|
||||
pub struct CancelRegistry {
|
||||
map: Mutex<HashMap<String, CancelHandle>>,
|
||||
/// Live "cancel requested" flags, keyed by connection id. `cancel_query`
|
||||
/// sets one for MySQL; long-running in-process operations (currently the
|
||||
/// native restore engine) poll it between statements and clear it when
|
||||
/// they finish. Kept in a separate map from the handles so it needs no
|
||||
/// `Clone` on the registry and survives handle replacement.
|
||||
flags: Mutex<HashMap<String, Arc<AtomicBool>>>,
|
||||
}
|
||||
|
||||
impl CancelRegistry {
|
||||
@@ -66,6 +73,41 @@ impl CancelRegistry {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Get (or create) the cancellation flag for a connection. Polling callers
|
||||
/// should clear it before starting and after finishing.
|
||||
pub fn cancel_flag(&self, id: &str) -> Arc<AtomicBool> {
|
||||
self.flags
|
||||
.lock()
|
||||
.unwrap()
|
||||
.entry(id.to_string())
|
||||
.or_default()
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// Mark a connection as cancel-requested. Called by `cancel_query` before
|
||||
/// it sends the driver-specific cancel so in-process consumers observe the
|
||||
/// request even when the driver surfaces it as an unrecognized error.
|
||||
pub fn mark_cancelled(&self, id: &str) {
|
||||
self.cancel_flag(id).store(true, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
/// Whether a cancel has been requested for this connection.
|
||||
pub fn is_cancelled(&self, id: &str) -> bool {
|
||||
self.flags
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(id)
|
||||
.map(|f| f.load(Ordering::SeqCst))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Clear the cancel-requested flag for this connection.
|
||||
pub fn clear_cancelled(&self, id: &str) {
|
||||
if let Some(f) = self.flags.lock().unwrap().get(id) {
|
||||
f.store(false, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_pg(&self, id: &str, c: PgCancel) {
|
||||
self.set(id, CancelHandle::Pg(c));
|
||||
}
|
||||
@@ -157,6 +199,21 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_flag_is_tracked_per_connection() {
|
||||
let reg = CancelRegistry::new();
|
||||
assert!(!reg.is_cancelled("c1"));
|
||||
reg.mark_cancelled("c1");
|
||||
assert!(reg.is_cancelled("c1"));
|
||||
assert!(!reg.is_cancelled("c2"), "flag must not leak across connections");
|
||||
reg.clear_cancelled("c1");
|
||||
assert!(!reg.is_cancelled("c1"));
|
||||
// The same Arc is shared between getter and setter.
|
||||
let flag = reg.cancel_flag("c1");
|
||||
reg.mark_cancelled("c1");
|
||||
assert!(flag.load(Ordering::SeqCst));
|
||||
}
|
||||
|
||||
fn fake_opts() -> sqlx::mysql::MySqlConnectOptions {
|
||||
sqlx::mysql::MySqlConnectOptions::new()
|
||||
.host("127.0.0.1").port(1).username("u").password("p").database("d")
|
||||
|
||||
@@ -516,12 +516,6 @@ pub fn build_mysql_dump_args(conn: &MySqlConnParams, options: &MySqlBackupOption
|
||||
a
|
||||
}
|
||||
|
||||
pub fn build_mysql_restore_args(conn: &MySqlConnParams, options: &MySqlRestoreOptions) -> Vec<String> {
|
||||
let mut a = base_mysql_args(conn);
|
||||
a.push(format!("--database={}", options.database));
|
||||
a
|
||||
}
|
||||
|
||||
/// System-first mariadb-dump/mariadb (bundled fallback in resources/mysql_tools).
|
||||
pub fn resolve_mysql_tool(app: &AppHandle, tool: &str) -> (String, Option<String>) {
|
||||
let system_ok = Command::new(tool).arg("--version").output().is_ok();
|
||||
@@ -556,22 +550,6 @@ pub fn run_mysql_dump(
|
||||
if out.status.success() { Ok(()) } else { Err(sanitize_error(&String::from_utf8_lossy(&out.stderr))) }
|
||||
}
|
||||
|
||||
pub fn run_mysql_restore(
|
||||
conn: &MySqlConnParams,
|
||||
options: &MySqlRestoreOptions,
|
||||
tools: &MySqlToolPaths,
|
||||
tls_mode: Option<&str>,
|
||||
) -> Result<(), String> {
|
||||
let mut args = build_mysql_restore_args(conn, options);
|
||||
if let Some(m) = tls_mode {
|
||||
args.push(format!("--ssl-mode={m}"));
|
||||
}
|
||||
let file = std::fs::File::open(&options.file_path).map_err(|e| format!("open dump: {e}"))?;
|
||||
let out = Command::new(&tools.mysql).env("MYSQL_PWD", &conn.password).args(&args).stdin(Stdio::from(file)).output()
|
||||
.map_err(|e| e.to_string())?;
|
||||
if out.status.success() { Ok(()) } else { Err(sanitize_error(&String::from_utf8_lossy(&out.stderr))) }
|
||||
}
|
||||
|
||||
pub fn run_mysql_sync(
|
||||
source: &MySqlConnParams,
|
||||
target: &MySqlConnParams,
|
||||
@@ -598,6 +576,147 @@ pub fn run_mysql_sync(
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Native MySQL restore engine (in-process via sqlx — no CLI, no subprocess)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Restore reads from disk, so it is capped at the existing CSV/JSON import
|
||||
/// limit (100 MB) rather than the 5 MB *editor* cap — a file too large to open
|
||||
/// in the editor must still be restorable (spec §2).
|
||||
pub const MAX_RESTORE_FILE_BYTES: u64 = 100 * 1024 * 1024;
|
||||
|
||||
/// Enforce the restore size cap. Extracted from the command so the boundary is
|
||||
/// testable; restore reads from disk, so it uses the 100 MB bulk cap rather
|
||||
/// than the 5 MB *editor* cap (spec §2).
|
||||
pub fn check_restore_file_size(bytes: u64, cap: u64) -> Result<(), String> {
|
||||
if bytes <= cap {
|
||||
return Ok(());
|
||||
}
|
||||
Err(format!(
|
||||
"Restore file is {} MB; the limit is {} MB",
|
||||
bytes.div_ceil(1024 * 1024),
|
||||
cap / (1024 * 1024)
|
||||
))
|
||||
}
|
||||
|
||||
/// Inputs for one native restore run. A struct rather than a long argument
|
||||
/// list so the headless core stays readable and directly testable.
|
||||
pub struct MySqlRestoreRun<'a> {
|
||||
pub database: &'a str,
|
||||
pub statements: &'a [String],
|
||||
pub clean: bool,
|
||||
}
|
||||
|
||||
/// Progress units for a restore: every statement, plus one for the clean
|
||||
/// drop phase so the fraction never jumps backwards.
|
||||
pub fn restore_progress_total(statement_count: usize, clean: bool) -> usize {
|
||||
statement_count + usize::from(clean)
|
||||
}
|
||||
|
||||
/// The error a failed restore reports. Includes the position so the user can
|
||||
/// find the statement in their file, and never echoes the statement text
|
||||
/// (SQL must not be logged — project guardrail).
|
||||
pub fn format_restore_statement_error(index_1based: usize, total: usize, detail: &str) -> String {
|
||||
format!("Statement {index_1based} of {total} failed: {detail}")
|
||||
}
|
||||
|
||||
/// Map a driver error for the restore path: a `KILL QUERY` cancellation must
|
||||
/// read as a cancellation, not as a broken statement.
|
||||
fn restore_statement_error(e: &sqlx::Error) -> String {
|
||||
if crate::commands::query::is_mysql_cancel_error(e) {
|
||||
"Query cancelled".to_string()
|
||||
} else {
|
||||
sanitize_error(&format!("{e}"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute an already-split restore script over one live MySQL connection.
|
||||
///
|
||||
/// Stop-at-first-error by contract: MySQL DDL is non-transactional and
|
||||
/// auto-commits, so there is nothing to roll back and continuing past a
|
||||
/// failure would compound an already-partial restore (spec §6).
|
||||
pub async fn run_mysql_restore_on(
|
||||
conn: &mut sqlx::mysql::MySqlConnection,
|
||||
run: MySqlRestoreRun<'_>,
|
||||
on_progress: &mut (dyn FnMut(usize, usize) + Send),
|
||||
is_cancelled: &(dyn Fn() -> bool + Sync),
|
||||
) -> Result<(), String> {
|
||||
crate::commands::query::check_statement_budget(
|
||||
run.statements.len(),
|
||||
crate::commands::query::MAX_RESTORE_STATEMENTS,
|
||||
true,
|
||||
)?;
|
||||
|
||||
// The form lets the user target a database other than the one this
|
||||
// connection defaults to, so select it explicitly.
|
||||
exec_restore_statement(
|
||||
conn,
|
||||
&format!("USE {}", crate::db::mysql::mysql_quote_ident(run.database)),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| sanitize_error(&format!("{e}")))?;
|
||||
|
||||
let total = restore_progress_total(run.statements.len(), run.clean);
|
||||
let mut done = 0usize;
|
||||
|
||||
if run.clean {
|
||||
let drops = crate::db::mysql_clean::plan_clean_drops(run.database, run.statements);
|
||||
if !drops.is_empty() {
|
||||
// Foreign keys off for the drop phase, so object ordering cannot
|
||||
// fail the restore; restored on the same session right after.
|
||||
exec_restore_statement(conn, "SET FOREIGN_KEY_CHECKS = 0")
|
||||
.await
|
||||
.map_err(|e| sanitize_error(&format!("{e}")))?;
|
||||
for drop_sql in &drops {
|
||||
if is_cancelled() {
|
||||
return Err("Query cancelled".to_string());
|
||||
}
|
||||
exec_restore_statement(conn, drop_sql)
|
||||
.await
|
||||
.map_err(|e| restore_statement_error(&e))?;
|
||||
}
|
||||
exec_restore_statement(conn, "SET FOREIGN_KEY_CHECKS = 1")
|
||||
.await
|
||||
.map_err(|e| sanitize_error(&format!("{e}")))?;
|
||||
}
|
||||
done += 1;
|
||||
on_progress(done, total);
|
||||
}
|
||||
|
||||
for (idx, stmt) in run.statements.iter().enumerate() {
|
||||
if is_cancelled() {
|
||||
return Err("Query cancelled".to_string());
|
||||
}
|
||||
if let Err(e) = exec_restore_statement(conn, stmt).await {
|
||||
if crate::commands::query::is_mysql_cancel_error(&e) {
|
||||
return Err("Query cancelled".to_string());
|
||||
}
|
||||
return Err(format_restore_statement_error(
|
||||
idx + 1,
|
||||
run.statements.len(),
|
||||
&sanitize_error(&format!("{e}")),
|
||||
));
|
||||
}
|
||||
done += 1;
|
||||
on_progress(done, total);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Run a single restore statement, preserving the driver error so the caller
|
||||
/// can distinguish a cancellation from a genuine statement failure.
|
||||
///
|
||||
/// Uses the *simple* query protocol (a bare string through `Executor`), not the
|
||||
/// prepared-statement protocol: MySQL rejects several restore statements
|
||||
/// (`USE`, `SET`, some DDL) with error 1295 when prepared.
|
||||
async fn exec_restore_statement(
|
||||
conn: &mut sqlx::mysql::MySqlConnection,
|
||||
sql: &str,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
use sqlx::Executor;
|
||||
conn.execute(sql).await.map(|_| ())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tauri commands (thin wrappers: store lookup + keychain + event emission)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -901,20 +1020,128 @@ pub async fn mysql_restore(
|
||||
.unwrap_or_default();
|
||||
|
||||
let (host, port, via_tunnel) = mysql_endpoint(&state, &connection_id, &conn);
|
||||
let params = MySqlConnParams::new(
|
||||
host,
|
||||
port,
|
||||
conn.username.clone().unwrap_or_else(|| "root".into()),
|
||||
options.database.clone(),
|
||||
password,
|
||||
|
||||
// Native restore: no tool resolution, no subprocess, no MYSQL_PWD in a
|
||||
// child environment. The sqlx driver handles MySQL 8 auth and TLS.
|
||||
let statements = {
|
||||
let bytes = std::fs::metadata(&options.file_path)
|
||||
.map_err(|e| format!("Cannot read restore file: {e}"))?
|
||||
.len();
|
||||
check_restore_file_size(bytes, MAX_RESTORE_FILE_BYTES)?;
|
||||
let text = std::fs::read_to_string(&options.file_path)
|
||||
.map_err(|e| format!("Cannot read restore file: {e}"))?;
|
||||
let statements = crate::db::sql_split::split_statements(&text);
|
||||
crate::commands::query::check_statement_budget(
|
||||
statements.len(),
|
||||
crate::commands::query::MAX_RESTORE_STATEMENTS,
|
||||
true,
|
||||
)?;
|
||||
statements
|
||||
};
|
||||
|
||||
let database = options.database.clone();
|
||||
let clean = options.clean;
|
||||
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());
|
||||
|
||||
// Real cancellation: a per-restore flag that the existing `cancel_query`
|
||||
// command sets, plus the restore session's MySQL thread id registered so
|
||||
// `KILL QUERY` aborts the in-flight statement. Cleared before each run so a
|
||||
// stale request from a previous operation cannot abort this one.
|
||||
let cancel_flag = state.cancel_registry.cancel_flag(&connection_id);
|
||||
cancel_flag.store(false, std::sync::atomic::Ordering::SeqCst);
|
||||
|
||||
let cancel_opts = crate::commands::db_viewer::mysql_connect_options(
|
||||
&host,
|
||||
port as u16,
|
||||
&username,
|
||||
&password,
|
||||
&database,
|
||||
&original_host,
|
||||
ssl_mode.as_deref(),
|
||||
ssl_ca.as_deref(),
|
||||
via_tunnel,
|
||||
);
|
||||
let tools = resolve_mysql_tool_paths(&app_handle);
|
||||
let tls = if via_tunnel { Some("REQUIRED") } else { None };
|
||||
|
||||
let job_id_clone = job_id.clone();
|
||||
let app_handle_clone = app_handle.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let result = run_mysql_restore(¶ms, &options, &tools, tls);
|
||||
let connection_id_for_cancel = connection_id.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut last_emit = std::time::Instant::now() - std::time::Duration::from_secs(1);
|
||||
let mut emit_progress = |done: usize, total: usize| {
|
||||
// Throttle: a 50k-statement dump must not flood the IPC channel.
|
||||
if last_emit.elapsed() < std::time::Duration::from_millis(200) && done < total {
|
||||
return;
|
||||
}
|
||||
last_emit = std::time::Instant::now();
|
||||
let _ = app_handle_clone.emit(
|
||||
"backup-progress",
|
||||
BackupProgressEvent {
|
||||
job_id: job_id_clone.clone(),
|
||||
status: "running".into(),
|
||||
progress: Some(done as f64 / total.max(1) as f64),
|
||||
output_line: Some(format!("Statement {done} of {total}")),
|
||||
error: None,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
let is_cancelled = {
|
||||
let flag = cancel_flag.clone();
|
||||
move || flag.load(std::sync::atomic::Ordering::SeqCst)
|
||||
};
|
||||
|
||||
let result = async {
|
||||
let mut mconn = 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?;
|
||||
|
||||
// Register the restore session so the UI's `cancel_query` can
|
||||
// `KILL QUERY` it (mirrors how browsing registers pooled conns).
|
||||
// The registry is reached through the app handle because it is not
|
||||
// `Clone`.
|
||||
let registry = app_handle_clone.state::<crate::AppState>();
|
||||
let restore_conn_id: i64 =
|
||||
sqlx::query_scalar("SELECT CAST(CONNECTION_ID() AS SIGNED)")
|
||||
.fetch_one(&mut mconn)
|
||||
.await
|
||||
.unwrap_or(-1);
|
||||
registry.cancel_registry.set_mysql(
|
||||
&connection_id_for_cancel,
|
||||
crate::cancel::MySqlCancel {
|
||||
conn_id: Some(restore_conn_id),
|
||||
connect_options: cancel_opts,
|
||||
},
|
||||
);
|
||||
|
||||
let r = run_mysql_restore_on(
|
||||
&mut mconn,
|
||||
MySqlRestoreRun { database: &database, statements: &statements, clean },
|
||||
&mut emit_progress,
|
||||
&is_cancelled,
|
||||
)
|
||||
.await;
|
||||
|
||||
registry
|
||||
.cancel_registry
|
||||
.set_mysql_conn_id(&connection_id_for_cancel, None);
|
||||
registry.cancel_registry.clear_cancelled(&connection_id_for_cancel);
|
||||
r
|
||||
}
|
||||
.await;
|
||||
|
||||
emit_result(&app_handle_clone, &job_id_clone, result);
|
||||
});
|
||||
|
||||
|
||||
@@ -517,18 +517,121 @@ fn mysql_dump_args_single_transaction_no_data_routines() {
|
||||
assert!(args.iter().all(|a| !a.starts_with("--password")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mysql_restore_args_no_clean_flags() {
|
||||
let opts = MySqlRestoreOptions { database: "shop".into(), file_path: "/tmp/d.sql".into(), clean: false };
|
||||
let args = build_mysql_restore_args(&MySqlConnParams::new("h".into(), 3306, "u".into(), "shop".into(), "p".into()), &opts);
|
||||
assert!(args.iter().any(|a| a == "--database=shop"));
|
||||
assert!(args.iter().any(|a| a == "--host=h"));
|
||||
assert!(args.iter().all(|a| a != "--force"));
|
||||
}
|
||||
|
||||
#[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);
|
||||
assert!(args.iter().all(|a| !a.starts_with("--password")));
|
||||
}
|
||||
|
||||
// ── native MySQL restore: error reporting contract ──────────────────
|
||||
|
||||
#[test]
|
||||
fn restore_failure_names_the_statement_position() {
|
||||
// The user must be able to find the offending statement in their file.
|
||||
let err = format_restore_statement_error(3, 10, "syntax error near 'CREAT'");
|
||||
assert_eq!(err, "Statement 3 of 10 failed: syntax error near 'CREAT'");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_progress_total_counts_the_clean_phase() {
|
||||
// clean adds one progress unit (the drop phase) so the bar never resets.
|
||||
assert_eq!(restore_progress_total(5, false), 5);
|
||||
assert_eq!(restore_progress_total(5, true), 6);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn mysql_native_restore_applies_a_script_and_honours_clean() {
|
||||
use sqlx::ConnectOptions;
|
||||
use sqlx::Connection;
|
||||
|
||||
let host = match std::env::var("GRIDLINE_TEST_MYSQL_HOST") { Ok(v) => v, Err(_) => return };
|
||||
let port: u16 = std::env::var("GRIDLINE_TEST_MYSQL_PORT").unwrap_or_else(|_| "3306".into()).parse().unwrap();
|
||||
let user = std::env::var("GRIDLINE_TEST_MYSQL_USER").unwrap_or_else(|_| "root".into());
|
||||
let pass = std::env::var("GRIDLINE_TEST_MYSQL_PASS").unwrap_or_default();
|
||||
let db = match std::env::var("GRIDLINE_TEST_MYSQL_DB") { Ok(v) => v, Err(_) => return };
|
||||
|
||||
let mut conn = sqlx::mysql::MySqlConnectOptions::new()
|
||||
.host(&host).port(port).username(&user).password(&pass).database(&db)
|
||||
.ssl_mode(sqlx::mysql::MySqlSslMode::Disabled)
|
||||
.disable_statement_logging()
|
||||
.connect().await.expect("connect");
|
||||
|
||||
// Scratch table, removed again at the end, so the live DB is never harmed.
|
||||
let table = "gl_restore_probe";
|
||||
sqlx::query(&format!("DROP TABLE IF EXISTS `{db}`.`{table}`"))
|
||||
.execute(&mut conn).await.expect("pre-clean");
|
||||
|
||||
let script = format!(
|
||||
"CREATE TABLE `{table}` (id INT PRIMARY KEY, name VARCHAR(20));\n\
|
||||
INSERT INTO `{table}` VALUES (1, 'first');"
|
||||
);
|
||||
let statements = crate::db::sql_split::split_statements(&script);
|
||||
|
||||
let mut events: Vec<(usize, usize)> = Vec::new();
|
||||
let mut on_progress = |done: usize, total: usize| events.push((done, total));
|
||||
let never = || false;
|
||||
|
||||
run_mysql_restore_on(
|
||||
&mut conn,
|
||||
MySqlRestoreRun { database: &db, statements: &statements, clean: true },
|
||||
&mut on_progress,
|
||||
&never,
|
||||
).await.expect("first restore");
|
||||
|
||||
let count: i64 = sqlx::query_scalar(&format!("SELECT COUNT(*) FROM `{db}`.`{table}`"))
|
||||
.fetch_one(&mut conn).await.expect("count");
|
||||
assert_eq!(count, 1);
|
||||
assert_eq!(events.last().copied(), Some((statements.len() + 1, statements.len() + 1)));
|
||||
|
||||
// Second run with clean: the table must be dropped and recreated, so the
|
||||
// insert cannot collide with the existing primary key.
|
||||
let mut events2: Vec<(usize, usize)> = Vec::new();
|
||||
let mut on_progress2 = |done: usize, total: usize| events2.push((done, total));
|
||||
run_mysql_restore_on(
|
||||
&mut conn,
|
||||
MySqlRestoreRun { database: &db, statements: &statements, clean: true },
|
||||
&mut on_progress2,
|
||||
&never,
|
||||
).await.expect("clean restore must not hit a duplicate key");
|
||||
|
||||
let count2: i64 = sqlx::query_scalar(&format!("SELECT COUNT(*) FROM `{db}`.`{table}`"))
|
||||
.fetch_one(&mut conn).await.expect("count2");
|
||||
assert_eq!(count2, 1, "clean restore must replace, not append");
|
||||
|
||||
// A statement that cannot succeed must fail and name its position.
|
||||
let bad = crate::db::sql_split::split_statements("SELECT 1;\nSELECT * FROM gl_no_such_table_xyz;");
|
||||
let mut noop = |_: usize, _: usize| {};
|
||||
let err = run_mysql_restore_on(
|
||||
&mut conn,
|
||||
MySqlRestoreRun { database: &db, statements: &bad, clean: false },
|
||||
&mut noop,
|
||||
&never,
|
||||
).await.expect_err("must fail");
|
||||
assert!(err.starts_with("Statement 2 of 2 failed:"), "got {err:?}");
|
||||
|
||||
let _ = conn.close().await;
|
||||
let mut cleanup = sqlx::mysql::MySqlConnectOptions::new()
|
||||
.host(&host).port(port).username(&user).password(&pass).database(&db)
|
||||
.ssl_mode(sqlx::mysql::MySqlSslMode::Disabled).disable_statement_logging()
|
||||
.connect().await.expect("reconnect");
|
||||
sqlx::query(&format!("DROP TABLE IF EXISTS `{db}`.`{table}`"))
|
||||
.execute(&mut cleanup).await.expect("cleanup");
|
||||
let _ = cleanup.close().await;
|
||||
}
|
||||
|
||||
// ── restore file-size cap ───────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn restore_size_cap_accepts_a_file_at_the_limit() {
|
||||
assert!(check_restore_file_size(MAX_RESTORE_FILE_BYTES, MAX_RESTORE_FILE_BYTES).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_size_cap_rejects_a_file_over_the_limit_and_states_both_sizes() {
|
||||
let err = check_restore_file_size(MAX_RESTORE_FILE_BYTES + 1, MAX_RESTORE_FILE_BYTES)
|
||||
.expect_err("must reject");
|
||||
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:?}");
|
||||
}
|
||||
|
||||
@@ -941,6 +941,102 @@ where
|
||||
Ok((client, handle))
|
||||
}
|
||||
|
||||
/// Apply Gridline's MySQL TLS policy to connect options.
|
||||
///
|
||||
/// Extracted from `run_mysql_connect` so the native restore path connects
|
||||
/// *exactly* as browsing does. That equivalence is the whole point of the fix:
|
||||
/// the sqlx driver reaches MySQL 8 with `caching_sha2_password` and handles
|
||||
/// TLS in-process, where the bundled MariaDB client cannot.
|
||||
pub(crate) fn apply_mysql_tls(
|
||||
mut opts: sqlx::mysql::MySqlConnectOptions,
|
||||
original_host: &str,
|
||||
ssl_mode: Option<&str>,
|
||||
ssl_ca_path: Option<&str>,
|
||||
via_tunnel: bool,
|
||||
) -> sqlx::mysql::MySqlConnectOptions {
|
||||
use sqlx::mysql::MySqlSslMode;
|
||||
|
||||
// TLS: through a tunnel the peer is loopback, so verify-ca/verify-full
|
||||
// degrade to encrypt-only `require`. Direct connections honor the mode.
|
||||
let decision = crate::commands::ssh::effective_tls_decision(
|
||||
crate::db::tls::tls_decision_for(original_host, ssl_mode),
|
||||
via_tunnel,
|
||||
);
|
||||
match decision {
|
||||
crate::db::tls::TlsDecision::Disable => {
|
||||
opts = opts.ssl_mode(MySqlSslMode::Disabled);
|
||||
}
|
||||
crate::db::tls::TlsDecision::Require => {
|
||||
opts = opts.ssl_mode(MySqlSslMode::Required);
|
||||
}
|
||||
crate::db::tls::TlsDecision::Verify => {
|
||||
// sqlx 0.8 has no VerifyFull: verify-ca -> VerifyCa (chain only),
|
||||
// verify-full -> VerifyIdentity (chain + hostname).
|
||||
match ssl_mode {
|
||||
Some("verify-ca") => opts = opts.ssl_mode(MySqlSslMode::VerifyCa),
|
||||
_ => opts = opts.ssl_mode(MySqlSslMode::VerifyIdentity),
|
||||
}
|
||||
if let Some(ca) = ssl_ca_path {
|
||||
opts = opts.ssl_ca(ca);
|
||||
}
|
||||
}
|
||||
}
|
||||
opts
|
||||
}
|
||||
|
||||
/// Build the final MySQL connect options (base params + TLS policy) for a
|
||||
/// headless, non-pooled connection.
|
||||
pub(crate) fn mysql_connect_options(
|
||||
host: &str,
|
||||
port: u16,
|
||||
username: &str,
|
||||
password: &str,
|
||||
database: &str,
|
||||
original_host: &str,
|
||||
ssl_mode: Option<&str>,
|
||||
ssl_ca_path: Option<&str>,
|
||||
via_tunnel: bool,
|
||||
) -> sqlx::mysql::MySqlConnectOptions {
|
||||
let opts = sqlx::mysql::MySqlConnectOptions::new()
|
||||
.host(host)
|
||||
.port(port)
|
||||
.username(username)
|
||||
.password(password)
|
||||
.database(database);
|
||||
apply_mysql_tls(opts, original_host, ssl_mode, ssl_ca_path, via_tunnel)
|
||||
}
|
||||
|
||||
/// Open a single standalone MySQL connection for a non-browsing operation
|
||||
/// (currently: native restore). Deliberately not pooled — the restore owns the
|
||||
/// session so `USE` and `FOREIGN_KEY_CHECKS` stay coherent for its whole run.
|
||||
pub(crate) async fn open_mysql_connection(
|
||||
host: &str,
|
||||
port: u16,
|
||||
username: &str,
|
||||
password: &str,
|
||||
database: &str,
|
||||
original_host: &str,
|
||||
ssl_mode: Option<&str>,
|
||||
ssl_ca_path: Option<&str>,
|
||||
via_tunnel: bool,
|
||||
) -> Result<sqlx::mysql::MySqlConnection, String> {
|
||||
use sqlx::ConnectOptions;
|
||||
let opts = mysql_connect_options(
|
||||
host,
|
||||
port,
|
||||
username,
|
||||
password,
|
||||
database,
|
||||
original_host,
|
||||
ssl_mode,
|
||||
ssl_ca_path,
|
||||
via_tunnel,
|
||||
);
|
||||
opts.connect()
|
||||
.await
|
||||
.map_err(|e| sanitize_error(&format!("MySQL connection failed: {e}")))
|
||||
}
|
||||
|
||||
/// Headless MySQL connect (no Tauri `State`). Opens an SSH tunnel when
|
||||
/// configured (binding 127.0.0.1 only), maps SSL modes, and registers a
|
||||
/// `DbHandle::MySql` pool. Errors are sanitized so no `mysql://user:pass@host`
|
||||
@@ -952,7 +1048,7 @@ pub(crate) async fn run_mysql_connect(
|
||||
pool_manager: &tokio::sync::Mutex<crate::db::pool::ConnectionPoolManager>,
|
||||
cancel_registry: &crate::cancel::CancelRegistry,
|
||||
) -> Result<(), String> {
|
||||
use sqlx::mysql::{MySqlConnectOptions, MySqlPoolOptions, MySqlSslMode};
|
||||
use sqlx::mysql::{MySqlConnectOptions, MySqlPoolOptions};
|
||||
if config.host.trim().is_empty() {
|
||||
return Err("host is required".to_string());
|
||||
}
|
||||
@@ -995,38 +1091,22 @@ pub(crate) async fn run_mysql_connect(
|
||||
via_tunnel = false;
|
||||
}
|
||||
|
||||
let mut opts = MySqlConnectOptions::new()
|
||||
let opts = MySqlConnectOptions::new()
|
||||
.host(&target_host)
|
||||
.port(target_port)
|
||||
.username(config.username.as_deref().unwrap_or("root"))
|
||||
.password(config.password.as_deref().unwrap_or(""))
|
||||
.database(config.database.as_deref().unwrap_or("mysql"));
|
||||
|
||||
// TLS: through a tunnel the peer is loopback, so verify-ca/verify-full
|
||||
// degrade to encrypt-only `require`. Direct connections honor the mode.
|
||||
let decision = crate::commands::ssh::effective_tls_decision(
|
||||
crate::db::tls::tls_decision(config.ssl_mode.as_deref()),
|
||||
// TLS: shared with the native restore path so browsing and restore make
|
||||
// identical TLS decisions.
|
||||
let opts = apply_mysql_tls(
|
||||
opts,
|
||||
&config.host,
|
||||
config.ssl_mode.as_deref(),
|
||||
config.ssl_ca_path.as_deref(),
|
||||
via_tunnel,
|
||||
);
|
||||
match decision {
|
||||
crate::db::tls::TlsDecision::Disable => {
|
||||
opts = opts.ssl_mode(MySqlSslMode::Disabled);
|
||||
}
|
||||
crate::db::tls::TlsDecision::Require => {
|
||||
opts = opts.ssl_mode(MySqlSslMode::Required);
|
||||
}
|
||||
crate::db::tls::TlsDecision::Verify => {
|
||||
// sqlx 0.8 has no VerifyFull: verify-ca -> VerifyCa (chain only),
|
||||
// verify-full -> VerifyIdentity (chain + hostname).
|
||||
match config.ssl_mode.as_deref() {
|
||||
Some("verify-ca") => opts = opts.ssl_mode(MySqlSslMode::VerifyCa),
|
||||
_ => opts = opts.ssl_mode(MySqlSslMode::VerifyIdentity),
|
||||
}
|
||||
if let Some(ca) = config.ssl_ca_path.as_deref() {
|
||||
opts = opts.ssl_ca(ca);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clone the (already final) options before `connect_with` consumes them so
|
||||
// `cancel_query` can open its own connection to run `KILL QUERY ?`.
|
||||
@@ -1084,7 +1164,7 @@ pub async fn db_connect(
|
||||
// connections honor the user's mode. Building this before opening the
|
||||
// tunnel means a config error can't leak the tunnel.
|
||||
let decision = crate::commands::ssh::effective_tls_decision(
|
||||
crate::db::tls::tls_decision(config.ssl_mode.as_deref()),
|
||||
crate::db::tls::tls_decision_for(&config.host, config.ssl_mode.as_deref()),
|
||||
will_tunnel,
|
||||
);
|
||||
let tls = crate::db::tls::build_tls_config(
|
||||
|
||||
@@ -25,6 +25,9 @@ pub fn get_connection_password(
|
||||
app: tauri::AppHandle,
|
||||
connection_id: String,
|
||||
) -> Result<Option<String>, String> {
|
||||
// Repair a stale item ACL first: after a rebuild/path change macOS would
|
||||
// otherwise ask for the keychain password on every access (once per secret).
|
||||
keychain_acl::before_read(&app, &connection_id);
|
||||
app.keyring()
|
||||
.store
|
||||
.get_password(&connection_id)
|
||||
@@ -37,6 +40,7 @@ pub fn get_connection_password_internal(
|
||||
app: &tauri::AppHandle,
|
||||
connection_id: &str,
|
||||
) -> Result<Option<String>, String> {
|
||||
keychain_acl::before_read(app, connection_id);
|
||||
app.keyring()
|
||||
.store
|
||||
.get_password(connection_id)
|
||||
@@ -71,6 +75,7 @@ pub fn get_connection_ssh_password(
|
||||
app: tauri::AppHandle,
|
||||
connection_id: String,
|
||||
) -> Result<Option<String>, String> {
|
||||
keychain_acl::before_read(&app, &ssh_account("password", &connection_id));
|
||||
app.keyring()
|
||||
.store
|
||||
.get_password(&ssh_account("password", &connection_id))
|
||||
@@ -111,6 +116,7 @@ pub fn get_connection_ssh_passphrase(
|
||||
app: tauri::AppHandle,
|
||||
connection_id: String,
|
||||
) -> Result<Option<String>, String> {
|
||||
keychain_acl::before_read(&app, &ssh_account("passphrase", &connection_id));
|
||||
app.keyring()
|
||||
.store
|
||||
.get_password(&ssh_account("passphrase", &connection_id))
|
||||
@@ -135,6 +141,7 @@ pub fn get_connection_ssh_password_internal(
|
||||
app: &tauri::AppHandle,
|
||||
connection_id: &str,
|
||||
) -> Result<Option<String>, String> {
|
||||
keychain_acl::before_read(app, &ssh_account("password", connection_id));
|
||||
app.keyring()
|
||||
.store
|
||||
.get_password(&ssh_account("password", connection_id))
|
||||
|
||||
@@ -7,6 +7,7 @@ pub mod import_export;
|
||||
pub mod keychain;
|
||||
pub mod maintenance;
|
||||
pub mod objects;
|
||||
pub mod open_files;
|
||||
pub mod query;
|
||||
pub mod query_export;
|
||||
pub mod schema_diff;
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
//! OS-delivered "open this .sql file with Gridline" requests.
|
||||
//!
|
||||
//! Every platform funnels into [`accept_path`], which validates strictly and
|
||||
//! queues the path. The frontend only ever calls [`take_pending_sql_files`],
|
||||
//! which takes **no path argument** and drains what was queued — so the webview
|
||||
//! cannot name a file to read. That is the boundary that keeps this feature
|
||||
//! from becoming an arbitrary-filesystem-read primitive and defeating the
|
||||
//! deliberate `fs` scope (spec §2).
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Mutex;
|
||||
use tauri::State;
|
||||
|
||||
/// Editor-load cap. A larger script is routed to Tools → Restore instead of
|
||||
/// being handed to Monaco (spec §2).
|
||||
pub const MAX_SQL_FILE_BYTES: u64 = 5 * 1024 * 1024;
|
||||
|
||||
/// A validated open request, delivered to the frontend with its contents.
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PendingSqlFile {
|
||||
pub path: String,
|
||||
pub name: String,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
/// Process-wide FIFO of accepted paths. Lives in Rust (not the webview) so a
|
||||
/// window reload cannot lose an open request.
|
||||
#[derive(Default)]
|
||||
pub struct PendingOpenFiles(Mutex<Vec<PathBuf>>);
|
||||
|
||||
/// Strict validation. Fail closed: anything we are not certain about is
|
||||
/// rejected rather than opened (spec §6).
|
||||
pub fn validate_sql_path_with_cap(raw: &str, cap: u64) -> Option<PathBuf> {
|
||||
let path = Path::new(raw);
|
||||
|
||||
// Absolute only — the OS always hands us absolute paths, and accepting a
|
||||
// relative one would resolve against the process CWD. Note the app is
|
||||
// often launched with CWD `/` (see the app-data-dir note in lib.rs).
|
||||
if !path.is_absolute() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let ext = path.extension()?.to_str()?;
|
||||
if !ext.eq_ignore_ascii_case("sql") {
|
||||
return None;
|
||||
}
|
||||
|
||||
// symlink_metadata, not metadata: a symlink must not be followed into an
|
||||
// arbitrary target (and `file_type().is_file()` is false for one).
|
||||
let meta = std::fs::symlink_metadata(path).ok()?;
|
||||
if !meta.file_type().is_file() {
|
||||
return None;
|
||||
}
|
||||
if meta.len() > cap {
|
||||
return None;
|
||||
}
|
||||
Some(path.to_path_buf())
|
||||
}
|
||||
|
||||
/// Validate against the production cap.
|
||||
pub fn validate_sql_path(raw: &str) -> Option<PathBuf> {
|
||||
validate_sql_path_with_cap(raw, MAX_SQL_FILE_BYTES)
|
||||
}
|
||||
|
||||
/// Queue an OS-supplied path. Returns `true` when it was newly queued, so
|
||||
/// callers can decide whether a nudge event is worth emitting.
|
||||
pub fn accept_path(buffer: &PendingOpenFiles, raw: &str) -> bool {
|
||||
let Some(path) = validate_sql_path(raw) else {
|
||||
return false;
|
||||
};
|
||||
let mut queue = buffer.0.lock().unwrap();
|
||||
if queue.iter().any(|p| p == &path) {
|
||||
return false;
|
||||
}
|
||||
queue.push(path);
|
||||
true
|
||||
}
|
||||
|
||||
/// Read and remove everything queued. Separated from the command so it is
|
||||
/// testable without a Tauri `State`.
|
||||
pub fn take_from_buffer(buffer: &PendingOpenFiles) -> Result<Vec<PendingSqlFile>, String> {
|
||||
let drained: Vec<PathBuf> = {
|
||||
let mut queue = buffer.0.lock().unwrap();
|
||||
std::mem::take(&mut *queue)
|
||||
};
|
||||
|
||||
drained
|
||||
.into_iter()
|
||||
.map(|path| {
|
||||
let name = path
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| path.to_string_lossy().into_owned());
|
||||
// Fail closed on undecodable bytes rather than guessing an
|
||||
// encoding for the user's SQL.
|
||||
let bytes =
|
||||
std::fs::read(&path).map_err(|e| format!("Cannot read {}: {e}", name))?;
|
||||
let content =
|
||||
String::from_utf8(bytes).map_err(|_| format!("{name} is not valid UTF-8"))?;
|
||||
Ok(PendingSqlFile {
|
||||
path: path.to_string_lossy().into_owned(),
|
||||
name,
|
||||
content,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Drain every queued open request. The only file-reading command the webview
|
||||
/// can call — deliberately argument-free (see the module docs).
|
||||
#[tauri::command]
|
||||
pub fn take_pending_sql_files(
|
||||
state: State<'_, PendingOpenFiles>,
|
||||
) -> Result<Vec<PendingSqlFile>, String> {
|
||||
take_from_buffer(&state)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "open_files.test.rs"]
|
||||
mod open_files_tests;
|
||||
@@ -0,0 +1,107 @@
|
||||
use super::*;
|
||||
|
||||
fn tmp_sql(name: &str, repeats: usize) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("gridline-open-{name}"));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let file = dir.join("sample.sql");
|
||||
std::fs::write(&file, "SELECT 1;".repeat(repeats.max(1))).unwrap();
|
||||
file
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_a_relative_path() {
|
||||
assert!(validate_sql_path_with_cap("student_db.sql", 1024).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_a_non_sql_extension() {
|
||||
let f = tmp_sql("ext", 1);
|
||||
let renamed = f.with_extension("txt");
|
||||
std::fs::rename(&f, &renamed).unwrap();
|
||||
assert!(validate_sql_path_with_cap(&renamed.to_string_lossy(), 1024).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_a_directory() {
|
||||
let dir = std::env::temp_dir().join("gridline-open-dir");
|
||||
std::fs::create_dir_all(dir.join("not-a-file.sql")).unwrap();
|
||||
assert!(
|
||||
validate_sql_path_with_cap(&dir.join("not-a-file.sql").to_string_lossy(), 1024).is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_a_missing_file() {
|
||||
let p = std::env::temp_dir().join("gridline-definitely-missing-xyz.sql");
|
||||
assert!(validate_sql_path_with_cap(&p.to_string_lossy(), 1024).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_a_file_over_the_cap() {
|
||||
let f = tmp_sql("big", 200);
|
||||
assert!(validate_sql_path_with_cap(&f.to_string_lossy(), 16).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_a_symlink() {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let f = tmp_sql("sym", 1);
|
||||
let link = f.with_file_name("link.sql");
|
||||
let _ = std::fs::remove_file(&link);
|
||||
std::os::unix::fs::symlink(&f, &link).unwrap();
|
||||
assert!(
|
||||
validate_sql_path_with_cap(&link.to_string_lossy(), 1024).is_none(),
|
||||
"a symlink must not be accepted (spec §6)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_a_valid_sql_file_with_an_uppercase_extension() {
|
||||
let f = tmp_sql("ok", 1);
|
||||
let upper = f.with_file_name("SAMPLE.SQL");
|
||||
std::fs::rename(&f, &upper).unwrap();
|
||||
assert!(validate_sql_path_with_cap(&upper.to_string_lossy(), 1024).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accept_path_dedupes_the_same_path() {
|
||||
let buffer = PendingOpenFiles::default();
|
||||
let f = tmp_sql("dedupe", 1);
|
||||
let s = f.to_string_lossy().to_string();
|
||||
assert!(accept_path(&buffer, &s));
|
||||
assert!(!accept_path(&buffer, &s), "second accept must be deduped");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accept_path_ignores_an_invalid_path() {
|
||||
let buffer = PendingOpenFiles::default();
|
||||
assert!(!accept_path(&buffer, "relative.sql"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn take_returns_contents_then_drains() {
|
||||
let buffer = PendingOpenFiles::default();
|
||||
let f = tmp_sql("drain", 1);
|
||||
assert!(accept_path(&buffer, &f.to_string_lossy()));
|
||||
|
||||
let first = take_from_buffer(&buffer).expect("first drain");
|
||||
assert_eq!(first.len(), 1);
|
||||
assert_eq!(first[0].name, "sample.sql");
|
||||
assert_eq!(first[0].content, "SELECT 1;");
|
||||
|
||||
let second = take_from_buffer(&buffer).expect("second drain");
|
||||
assert!(second.is_empty(), "buffer must be drained, not reread");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_a_non_utf8_file() {
|
||||
let dir = std::env::temp_dir().join("gridline-open-binary");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let f = dir.join("binary.sql");
|
||||
std::fs::write(&f, [0xff, 0xfe, 0x00]).unwrap();
|
||||
let buffer = PendingOpenFiles::default();
|
||||
assert!(accept_path(&buffer, &f.to_string_lossy()));
|
||||
assert!(take_from_buffer(&buffer).is_err(), "non-UTF8 must fail closed");
|
||||
}
|
||||
@@ -34,7 +34,7 @@ fn is_pg_cancel_error(e: &tokio_postgres::Error) -> bool {
|
||||
e.code() == Some(&tokio_postgres::error::SqlState::QUERY_CANCELED)
|
||||
}
|
||||
|
||||
fn is_mysql_cancel_error(e: &sqlx::Error) -> bool {
|
||||
pub(crate) fn is_mysql_cancel_error(e: &sqlx::Error) -> bool {
|
||||
match e.as_database_error().and_then(|d| d.code()) {
|
||||
Some(code) if code == "1317" => true, // ER_QUERY_INTERRUPTED (KILL QUERY)
|
||||
_ => e.to_string().to_lowercase().contains("interrupted"),
|
||||
@@ -565,25 +565,194 @@ pub(crate) fn mysql_wrap_count(query: &str) -> String {
|
||||
format!("SELECT COUNT(*) FROM ({}) AS _gridline_cnt", query.trim())
|
||||
}
|
||||
|
||||
/// Convert a sqlx MySql row cell to serde_json::Value (via the `json` feature).
|
||||
/// How a MySQL column value must be decoded, decided from the column's
|
||||
/// declared type name.
|
||||
///
|
||||
/// Why type-directed rather than a blind try-chain: sqlx's MySQL integer
|
||||
/// compatibility is broad, so `try_get::<i16>` succeeds for a `BIGINT`
|
||||
/// holding a small value but fails once the value grows — the same column
|
||||
/// would emit different JSON types for different rows. `MySqlTypeInfo`
|
||||
/// exposes only `name()` publicly, which is sufficient because it already
|
||||
/// encodes signedness (`INT UNSIGNED`) and boolean-ness (`BOOLEAN`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum MysqlCellKind {
|
||||
/// Width in bits and signedness; drives the number-vs-string policy.
|
||||
Int { bits: u8, signed: bool },
|
||||
/// `BIT(M)` — a bitmask, rendered as a number (spec §4.1).
|
||||
Bit,
|
||||
Float,
|
||||
Double,
|
||||
Decimal,
|
||||
Date,
|
||||
Time,
|
||||
DateTime,
|
||||
Json,
|
||||
Text,
|
||||
Bytes,
|
||||
Null,
|
||||
/// Anything unrecognised (including `GEOMETRY`, which no sqlx decoder
|
||||
/// accepts even with `chrono`/`bigdecimal` enabled).
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// Map a `sqlx` MySQL type name to its decode strategy.
|
||||
pub(crate) fn mysql_cell_kind(type_name: &str) -> MysqlCellKind {
|
||||
use MysqlCellKind::*;
|
||||
let t = type_name.trim().to_ascii_uppercase();
|
||||
match t.as_str() {
|
||||
"BOOLEAN" | "TINYINT" => Int { bits: 8, signed: true },
|
||||
"TINYINT UNSIGNED" => Int { bits: 8, signed: false },
|
||||
"SMALLINT" => Int { bits: 16, signed: true },
|
||||
"SMALLINT UNSIGNED" => Int { bits: 16, signed: false },
|
||||
"MEDIUMINT" => Int { bits: 24, signed: true },
|
||||
"MEDIUMINT UNSIGNED" => Int { bits: 24, signed: false },
|
||||
"INT" => Int { bits: 32, signed: true },
|
||||
"INT UNSIGNED" => Int { bits: 32, signed: false },
|
||||
"BIGINT" => Int { bits: 64, signed: true },
|
||||
"BIGINT UNSIGNED" => Int { bits: 64, signed: false },
|
||||
// YEAR is an unsigned 16-bit value; it needs the unsigned rung, not
|
||||
// the signed one (int_compatible excludes UNSIGNED columns).
|
||||
"YEAR" => Int { bits: 16, signed: false },
|
||||
"BIT" => Bit,
|
||||
"FLOAT" => Float,
|
||||
"DOUBLE" => Double,
|
||||
"DECIMAL" => Decimal,
|
||||
"DATE" => Date,
|
||||
"TIME" => Time,
|
||||
// TIMESTAMP shares the Datetime wire type. Decoded naive on purpose:
|
||||
// MySQL converts TIMESTAMP to/from the *session* time zone, so
|
||||
// claiming UTC would assert something the value does not support.
|
||||
"DATETIME" | "TIMESTAMP" => DateTime,
|
||||
"JSON" => Json,
|
||||
"CHAR" | "VARCHAR" | "TEXT" | "TINYTEXT" | "MEDIUMTEXT" | "LONGTEXT"
|
||||
| "ENUM" | "SET" => Text,
|
||||
"BINARY" | "VARBINARY" | "BLOB" | "TINYBLOB" | "MEDIUMBLOB" | "LONGBLOB" => Bytes,
|
||||
"NULL" => Null,
|
||||
_ => Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a sqlx MySql row cell to serde_json::Value, driven by the column's
|
||||
/// **declared** type (see [`mysql_cell_kind`]).
|
||||
///
|
||||
/// Type-directed rather than a blind try-chain because sqlx's MySQL integer
|
||||
/// compatibility is broad: `try_get::<i16>` succeeds for a `BIGINT` holding
|
||||
/// a small value and fails once the value grows, so a value-driven chain
|
||||
/// would emit different JSON types for different rows of one column.
|
||||
/// Shared with the DB-viewer commands (pub(crate)).
|
||||
pub(crate) fn mysql_cell_to_json(row: &sqlx::mysql::MySqlRow, i: usize) -> serde_json::Value {
|
||||
if let Ok(Some(v)) = row.try_get::<Option<serde_json::Value>, _>(i) {
|
||||
return v;
|
||||
use sqlx::{Row, TypeInfo, ValueRef};
|
||||
|
||||
// `MySqlTypeInfo.r#type`/`.flags` are `pub(crate)`, so the declared type is
|
||||
// read through the public `name()`, which already distinguishes
|
||||
// `INT UNSIGNED`, `BIGINT UNSIGNED` and `BOOLEAN` (TINYINT(1)).
|
||||
let type_name = match row.try_get_raw(i) {
|
||||
Ok(v) if !v.is_null() => v.type_info().name().to_string(),
|
||||
// Genuine SQL NULL, or a column we cannot even describe.
|
||||
_ => return serde_json::Value::Null,
|
||||
};
|
||||
|
||||
match mysql_cell_kind(&type_name) {
|
||||
MysqlCellKind::Int { bits, signed } => {
|
||||
if signed {
|
||||
match row.try_get::<i64, _>(i) {
|
||||
// <=32-bit fits a JS Number exactly; 64-bit is stringified
|
||||
// so the value survives the IPC boundary (matches PG).
|
||||
Ok(v) if bits <= 32 => serde_json::json!(v),
|
||||
Ok(v) => crate::commands::db_viewer::i64_to_json(v),
|
||||
Err(_) => serde_json::Value::Null,
|
||||
}
|
||||
} else {
|
||||
match row.try_get::<u64, _>(i) {
|
||||
Ok(v) if bits <= 32 => serde_json::json!(v),
|
||||
Ok(v) => serde_json::Value::String(v.to_string()),
|
||||
Err(_) => serde_json::Value::Null,
|
||||
}
|
||||
}
|
||||
}
|
||||
MysqlCellKind::Bit => match row.try_get::<u64, _>(i) {
|
||||
Ok(v) => serde_json::json!(v),
|
||||
Err(_) => serde_json::Value::Null,
|
||||
},
|
||||
MysqlCellKind::Float => match row.try_get::<f32, _>(i) {
|
||||
Ok(v) => serde_json::json!(v),
|
||||
Err(_) => serde_json::Value::Null,
|
||||
},
|
||||
MysqlCellKind::Double => match row.try_get::<f64, _>(i) {
|
||||
Ok(v) => serde_json::json!(v),
|
||||
Err(_) => serde_json::Value::Null,
|
||||
},
|
||||
MysqlCellKind::Decimal => match row.try_get::<sqlx::types::BigDecimal, _>(i) {
|
||||
// Stringified for the same reason as 64-bit ints: exact digits.
|
||||
Ok(v) => serde_json::Value::String(v.to_string()),
|
||||
Err(_) => serde_json::Value::Null,
|
||||
},
|
||||
// Temporal values are emitted naive, never as an instant: MySQL
|
||||
// converts TIMESTAMP to and from the *session* time zone, so an
|
||||
// explicit `Z` would assert something the value does not support.
|
||||
MysqlCellKind::Date => match row.try_get::<chrono::NaiveDate, _>(i) {
|
||||
Ok(v) => serde_json::Value::String(v.to_string()),
|
||||
Err(_) => serde_json::Value::Null,
|
||||
},
|
||||
MysqlCellKind::Time => match row.try_get::<chrono::NaiveTime, _>(i) {
|
||||
Ok(v) => serde_json::Value::String(v.to_string()),
|
||||
Err(_) => serde_json::Value::Null,
|
||||
},
|
||||
MysqlCellKind::DateTime => {
|
||||
// `try_get_unchecked` (not `try_get`) because sqlx's `NaiveDateTime`
|
||||
// `Type`/`Compatible` impl matches `ColumnType::Datetime` only, so the
|
||||
// checked path rejects a TIMESTAMP column even though both share the
|
||||
// exact same wire format and `Decode` impl. Only the type-compat gate
|
||||
// is bypassed here; the decode is the same one DATETIME uses.
|
||||
match row.try_get_unchecked::<chrono::NaiveDateTime, _>(i) {
|
||||
Ok(v) => serde_json::Value::String(v.to_string()),
|
||||
Err(_) => serde_json::Value::Null,
|
||||
}
|
||||
}
|
||||
MysqlCellKind::Json => row
|
||||
.try_get::<serde_json::Value, _>(i)
|
||||
.unwrap_or(serde_json::Value::Null),
|
||||
MysqlCellKind::Text => mysql_text_to_json(row, i),
|
||||
MysqlCellKind::Bytes => mysql_bytes_to_json(row, i),
|
||||
MysqlCellKind::Null => serde_json::Value::Null,
|
||||
MysqlCellKind::Unknown => mysql_unknown_to_json(row, i),
|
||||
}
|
||||
if let Ok(s) = row.try_get::<Option<String>, _>(i) {
|
||||
return s
|
||||
.map(|s| serde_json::Value::String(s))
|
||||
.unwrap_or(serde_json::Value::Null);
|
||||
}
|
||||
|
||||
/// Char/varchar/text/enum/set: a UTF-8 string, falling back to a lossy
|
||||
/// decode for the odd VARBINARY-shaped metadata column.
|
||||
fn mysql_text_to_json(row: &sqlx::mysql::MySqlRow, i: usize) -> serde_json::Value {
|
||||
use sqlx::Row;
|
||||
if let Ok(v) = row.try_get::<String, _>(i) {
|
||||
return serde_json::Value::String(v);
|
||||
}
|
||||
if let Ok(b) = row.try_get::<Option<Vec<u8>>, _>(i) {
|
||||
return b
|
||||
.map(|b| serde_json::Value::String(String::from_utf8_lossy(&b).into_owned()))
|
||||
.unwrap_or(serde_json::Value::Null);
|
||||
if let Ok(b) = row.try_get::<Vec<u8>, _>(i) {
|
||||
return serde_json::Value::String(String::from_utf8_lossy(&b).into_owned());
|
||||
}
|
||||
serde_json::Value::Null
|
||||
}
|
||||
|
||||
/// Binary/blob columns. Preserves the pre-existing lossy-UTF-8 rendering;
|
||||
/// changing binary presentation is explicitly out of scope (spec §3).
|
||||
fn mysql_bytes_to_json(row: &sqlx::mysql::MySqlRow, i: usize) -> serde_json::Value {
|
||||
use sqlx::Row;
|
||||
if let Ok(b) = row.try_get::<Vec<u8>, _>(i) {
|
||||
return serde_json::Value::String(String::from_utf8_lossy(&b).into_owned());
|
||||
}
|
||||
mysql_text_to_json(row, i)
|
||||
}
|
||||
|
||||
/// Conservative fallback for a type we do not recognise — exactly the three
|
||||
/// decoders that were safe before this change, so an unknown type can never
|
||||
/// be worse than before (`GEOMETRY` lands here and stays null).
|
||||
fn mysql_unknown_to_json(row: &sqlx::mysql::MySqlRow, i: usize) -> serde_json::Value {
|
||||
use sqlx::Row;
|
||||
if let Ok(v) = row.try_get::<serde_json::Value, _>(i) {
|
||||
return v;
|
||||
}
|
||||
mysql_text_to_json(row, i)
|
||||
}
|
||||
|
||||
async fn execute_mysql_query_on(
|
||||
conn: &mut sqlx::mysql::MySqlConnection,
|
||||
query: &str,
|
||||
@@ -716,7 +885,33 @@ async fn execute_mysql_raw(
|
||||
// deliberately omitted because re-running a script would re-execute DML);
|
||||
// DML/DDL produce notices; the first error stops the run, prior sets survive.
|
||||
|
||||
const MAX_STATEMENTS: usize = 50;
|
||||
/// Hard safety cap for an interactive multi-statement Run. Raised from 50
|
||||
/// because opening a `.sql` file and pressing Run is now a supported flow, and
|
||||
/// 50 rejected anything but toy scripts.
|
||||
pub(crate) const MAX_STATEMENTS: usize = 10_000;
|
||||
|
||||
/// Cap for the deliberate bulk operation (Tools → Restore). Far higher than
|
||||
/// the interactive cap because it is an explicit, confirmed, cancellable
|
||||
/// operation bounded by the 100 MB file cap rather than by a keystroke.
|
||||
pub(crate) const MAX_RESTORE_STATEMENTS: usize = 1_000_000;
|
||||
|
||||
/// Enforce a statement budget. `bulk` selects the restore wording: the
|
||||
/// interactive path tells the user where large scripts *should* run, while the
|
||||
/// bulk path must not point at a tool the user is already using.
|
||||
pub(crate) fn check_statement_budget(count: usize, cap: usize, bulk: bool) -> Result<(), String> {
|
||||
if count <= cap {
|
||||
return Ok(());
|
||||
}
|
||||
if bulk {
|
||||
Err(format!(
|
||||
"Script has {count} statements, exceeding the {cap} statement limit for a restore"
|
||||
))
|
||||
} else {
|
||||
Err(format!(
|
||||
"Too many statements: {count} (max {cap}). For large scripts use Tools → Restore."
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) enum StatementOutcome {
|
||||
Set(QueryResult),
|
||||
@@ -1135,12 +1330,7 @@ pub async fn execute_query_multi(
|
||||
if statements.is_empty() {
|
||||
return Err("Query cannot be empty".to_string());
|
||||
}
|
||||
if statements.len() > MAX_STATEMENTS {
|
||||
return Err(format!(
|
||||
"Too many statements: {} (max {MAX_STATEMENTS})",
|
||||
statements.len()
|
||||
));
|
||||
}
|
||||
check_statement_budget(statements.len(), MAX_STATEMENTS, false)?;
|
||||
|
||||
let mut result_sets: Vec<QueryResult> = Vec::new();
|
||||
let mut notices: Vec<StatementNotice> = Vec::new();
|
||||
@@ -1346,6 +1536,10 @@ pub async fn cancel_query(
|
||||
.map_err(|e| crate::commands::db_viewer::sanitize_error(&format!("{e}")))
|
||||
}
|
||||
Some(crate::cancel::CancelHandle::MySql(m)) => {
|
||||
// Record the request before the KILL lands: the in-process restore
|
||||
// engine polls this flag between statements, and the killed
|
||||
// statement's error is the only other signal.
|
||||
state.cancel_registry.mark_cancelled(&connection_id);
|
||||
let id = m
|
||||
.conn_id
|
||||
.ok_or_else(|| "No active query on this connection".to_string())?;
|
||||
|
||||
@@ -132,3 +132,238 @@ async fn pg_multi_statement_live() {
|
||||
|
||||
let _ = handle;
|
||||
}
|
||||
|
||||
// ── sqlx type-feature coverage ──────────────────────────────────────
|
||||
|
||||
/// Connect to the optional live MySQL test server. Returns `None` when the
|
||||
/// env vars are absent so the whole suite stays green without a server
|
||||
/// (matching the existing `#[ignore]`d integration-test convention).
|
||||
pub(crate) async fn mysql_test_connection() -> Option<sqlx::mysql::MySqlConnection> {
|
||||
use sqlx::ConnectOptions;
|
||||
let host = std::env::var("GRIDLINE_TEST_MYSQL_HOST").ok()?;
|
||||
let port: u16 = std::env::var("GRIDLINE_TEST_MYSQL_PORT")
|
||||
.ok()?
|
||||
.parse()
|
||||
.ok()?;
|
||||
let user = std::env::var("GRIDLINE_TEST_MYSQL_USER").unwrap_or_else(|_| "root".into());
|
||||
let pass = std::env::var("GRIDLINE_TEST_MYSQL_PASS").unwrap_or_default();
|
||||
let db = std::env::var("GRIDLINE_TEST_MYSQL_DB").ok()?;
|
||||
sqlx::mysql::MySqlConnectOptions::new()
|
||||
.host(&host)
|
||||
.port(port)
|
||||
.username(&user)
|
||||
.password(&pass)
|
||||
.database(&db)
|
||||
.ssl_mode(sqlx::mysql::MySqlSslMode::Disabled)
|
||||
.disable_statement_logging()
|
||||
.connect()
|
||||
.await
|
||||
.ok()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn mysql_decimal_and_date_decode_with_features_enabled() {
|
||||
use sqlx::Row;
|
||||
let Some(mut conn) = mysql_test_connection().await else { return };
|
||||
|
||||
sqlx::query(
|
||||
"CREATE TEMPORARY TABLE gl_feature_probe (
|
||||
c_decimal DECIMAL(20,4), c_date DATE, c_datetime DATETIME(6) )",
|
||||
)
|
||||
.execute(&mut conn)
|
||||
.await
|
||||
.expect("create temporary table");
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO gl_feature_probe VALUES (
|
||||
12345678901234.5678, '2024-01-15', '2024-01-15 10:30:00.123456' )",
|
||||
)
|
||||
.execute(&mut conn)
|
||||
.await
|
||||
.expect("insert");
|
||||
|
||||
let row = sqlx::query("SELECT * FROM gl_feature_probe")
|
||||
.fetch_one(&mut conn)
|
||||
.await
|
||||
.expect("select");
|
||||
|
||||
let dec: sqlx::types::BigDecimal = row.try_get(0).expect("DECIMAL must decode");
|
||||
assert_eq!(dec.to_string(), "12345678901234.5678");
|
||||
|
||||
let date: chrono::NaiveDate = row.try_get(1).expect("DATE must decode");
|
||||
assert_eq!(date, chrono::NaiveDate::from_ymd_opt(2024, 1, 15).unwrap());
|
||||
|
||||
let dt: chrono::NaiveDateTime = row.try_get(2).expect("DATETIME(6) must decode");
|
||||
assert_eq!(dt.to_string(), "2024-01-15 10:30:00.123456");
|
||||
}
|
||||
|
||||
// ── mysql_cell_kind (pure) ──────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn mysql_cell_kind_maps_every_type_name_sqlx_can_report() {
|
||||
use MysqlCellKind::*;
|
||||
// Every string here was observed from a live MySQL 8 server during the
|
||||
// architect phase (ColumnType::name(flags, max_size)).
|
||||
let cases: &[(&str, MysqlCellKind)] = &[
|
||||
("BOOLEAN", Int { bits: 8, signed: true }),
|
||||
("TINYINT", Int { bits: 8, signed: true }),
|
||||
("TINYINT UNSIGNED", Int { bits: 8, signed: false }),
|
||||
("SMALLINT", Int { bits: 16, signed: true }),
|
||||
("SMALLINT UNSIGNED", Int { bits: 16, signed: false }),
|
||||
("MEDIUMINT", Int { bits: 24, signed: true }),
|
||||
("MEDIUMINT UNSIGNED", Int { bits: 24, signed: false }),
|
||||
("INT", Int { bits: 32, signed: true }),
|
||||
("INT UNSIGNED", Int { bits: 32, signed: false }),
|
||||
("BIGINT", Int { bits: 64, signed: true }),
|
||||
("BIGINT UNSIGNED", Int { bits: 64, signed: false }),
|
||||
("YEAR", Int { bits: 16, signed: false }),
|
||||
("BIT", Bit),
|
||||
("FLOAT", Float),
|
||||
("DOUBLE", Double),
|
||||
("DECIMAL", Decimal),
|
||||
("DATE", Date),
|
||||
("TIME", Time),
|
||||
("DATETIME", DateTime),
|
||||
("TIMESTAMP", DateTime),
|
||||
("JSON", Json),
|
||||
("CHAR", Text),
|
||||
("VARCHAR", Text),
|
||||
("TEXT", Text),
|
||||
("TINYTEXT", Text),
|
||||
("MEDIUMTEXT", Text),
|
||||
("LONGTEXT", Text),
|
||||
("ENUM", Text),
|
||||
("SET", Text),
|
||||
("BINARY", Bytes),
|
||||
("VARBINARY", Bytes),
|
||||
("BLOB", Bytes),
|
||||
("TINYBLOB", Bytes),
|
||||
("MEDIUMBLOB", Bytes),
|
||||
("LONGBLOB", Bytes),
|
||||
("NULL", Null),
|
||||
("GEOMETRY", Unknown),
|
||||
("SOMETHING_NEW", Unknown),
|
||||
];
|
||||
for (name, expected) in cases {
|
||||
assert_eq!(&mysql_cell_kind(name), expected, "type name {name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mysql_cell_kind_is_case_insensitive_and_trims() {
|
||||
assert_eq!(mysql_cell_kind(" bigint unsigned "), MysqlCellKind::Int { bits: 64, signed: false });
|
||||
assert_eq!(mysql_cell_kind("Datetime"), MysqlCellKind::DateTime);
|
||||
}
|
||||
|
||||
// ── mysql_cell_to_json: full type coverage (regression for #41) ─────
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn mysql_cell_to_json_decodes_every_type() {
|
||||
use sqlx::Row;
|
||||
let Some(mut conn) = mysql_test_connection().await else { return };
|
||||
|
||||
sqlx::query(
|
||||
"CREATE TEMPORARY TABLE gl_decode_probe (
|
||||
c_tiny TINYINT, c_tiny_u TINYINT UNSIGNED, c_bool BOOLEAN,
|
||||
c_small SMALLINT, c_small_u SMALLINT UNSIGNED, c_medium MEDIUMINT,
|
||||
c_int INT, c_int_u INT UNSIGNED, c_big BIGINT, c_big_u BIGINT UNSIGNED,
|
||||
c_float FLOAT, c_double DOUBLE, c_decimal DECIMAL(10,2), c_bit BIT(8),
|
||||
c_year YEAR, c_date DATE, c_time TIME, c_datetime DATETIME,
|
||||
c_datetime6 DATETIME(6), c_timestamp TIMESTAMP NULL,
|
||||
c_char CHAR(4), c_varchar VARCHAR(20), c_text TEXT,
|
||||
c_binary BINARY(4), c_varbinary VARBINARY(8), c_blob BLOB,
|
||||
c_json JSON, c_enum ENUM('a','b'), c_set SET('x','y'),
|
||||
c_geometry GEOMETRY )",
|
||||
)
|
||||
.execute(&mut conn)
|
||||
.await
|
||||
.expect("create temporary table");
|
||||
|
||||
// c_int is 70000 so a 16-bit rung cannot hold it; c_big is small so a
|
||||
// value-dependent chain would wrongly emit a number instead of a string.
|
||||
sqlx::query(
|
||||
"INSERT INTO gl_decode_probe VALUES (
|
||||
1, 2, 1, 5, 6, 7, 70000, 9, 300, 4294967296,
|
||||
11.5, 12.25, 13.75, b'10101010', 2024,
|
||||
'2024-01-15', '10:30:00', '2024-01-15 10:30:00',
|
||||
'2024-01-15 10:30:00.123456', '2024-01-15 10:30:00',
|
||||
'abcd', 'hello', 'text value', 'WXYZ', 0x0102, 0x01020304,
|
||||
JSON_OBJECT('k','v'), 'a', 'x,y', ST_GeomFromText('POINT(1 2)') )",
|
||||
)
|
||||
.execute(&mut conn)
|
||||
.await
|
||||
.expect("insert");
|
||||
|
||||
let row = sqlx::query("SELECT * FROM gl_decode_probe")
|
||||
.fetch_one(&mut conn)
|
||||
.await
|
||||
.expect("select");
|
||||
|
||||
use serde_json::{json, Value};
|
||||
let expected: Vec<(&str, Value)> = vec![
|
||||
("c_tiny", json!(1)),
|
||||
("c_tiny_u", json!(2)),
|
||||
("c_bool", json!(1)),
|
||||
("c_small", json!(5)),
|
||||
("c_small_u", json!(6)),
|
||||
("c_medium", json!(7)),
|
||||
("c_int", json!(70000)),
|
||||
("c_int_u", json!(9)),
|
||||
("c_big", Value::String("300".into())),
|
||||
("c_big_u", Value::String("4294967296".into())),
|
||||
("c_float", json!(11.5)),
|
||||
("c_double", json!(12.25)),
|
||||
("c_decimal", Value::String("13.75".into())),
|
||||
("c_bit", json!(170)),
|
||||
("c_year", json!(2024)),
|
||||
("c_date", Value::String("2024-01-15".into())),
|
||||
("c_time", Value::String("10:30:00".into())),
|
||||
("c_datetime", Value::String("2024-01-15 10:30:00".into())),
|
||||
("c_datetime6", Value::String("2024-01-15 10:30:00.123456".into())),
|
||||
("c_timestamp", Value::String("2024-01-15 10:30:00".into())),
|
||||
("c_char", Value::String("abcd".into())),
|
||||
("c_varchar", Value::String("hello".into())),
|
||||
("c_text", Value::String("text value".into())),
|
||||
("c_binary", Value::String("WXYZ".into())),
|
||||
("c_varbinary", Value::String("\u{1}\u{2}".into())),
|
||||
("c_blob", Value::String("\u{1}\u{2}\u{3}\u{4}".into())),
|
||||
("c_json", json!({"k": "v"})),
|
||||
("c_enum", Value::String("a".into())),
|
||||
("c_set", Value::String("x,y".into())),
|
||||
// GEOMETRY has no sqlx decoder even with chrono/bigdecimal enabled;
|
||||
// staying null is the documented limitation (spec §9).
|
||||
("c_geometry", Value::Null),
|
||||
];
|
||||
|
||||
assert_eq!(row.len(), expected.len(), "column count drifted from the fixture");
|
||||
for (i, (name, want)) in expected.iter().enumerate() {
|
||||
assert_eq!(row.columns()[i].name(), *name, "column order drifted");
|
||||
assert_eq!(&mysql_cell_to_json(&row, i), want, "column {name} decoded wrong");
|
||||
}
|
||||
}
|
||||
|
||||
// ── statement-count policy ──────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn statement_budget_allows_a_count_at_the_cap() {
|
||||
assert!(check_statement_budget(MAX_STATEMENTS, MAX_STATEMENTS, false).is_ok());
|
||||
assert!(check_statement_budget(MAX_RESTORE_STATEMENTS, MAX_RESTORE_STATEMENTS, true).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interactive_over_budget_error_points_at_restore() {
|
||||
let err = check_statement_budget(MAX_STATEMENTS + 1, MAX_STATEMENTS, false)
|
||||
.expect_err("must reject");
|
||||
assert!(err.contains("Tools"), "error must route to Tools → Restore; got {err:?}");
|
||||
assert!(err.contains(&(MAX_STATEMENTS + 1).to_string()), "error must state the count; got {err:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bulk_over_budget_error_states_the_limit_without_the_tool_hint() {
|
||||
let err = check_statement_budget(MAX_RESTORE_STATEMENTS + 1, MAX_RESTORE_STATEMENTS, true)
|
||||
.expect_err("must reject");
|
||||
assert!(!err.contains("Tools"), "bulk path is already in Tools; got {err:?}");
|
||||
assert!(err.contains(&MAX_RESTORE_STATEMENTS.to_string()), "got {err:?}");
|
||||
}
|
||||
|
||||
@@ -265,7 +265,7 @@ async fn test_pg_connection(config: &DbConfig, ssh: &SshManager) -> TestConnecti
|
||||
// TLS: through a tunnel the peer is loopback, so verify-ca/verify-full
|
||||
// degrade to encrypt-only `require`. Direct connections honor the mode.
|
||||
let decision = crate::commands::ssh::effective_tls_decision(
|
||||
crate::db::tls::tls_decision(config.ssl_mode.as_deref()),
|
||||
crate::db::tls::tls_decision_for(&config.host, config.ssl_mode.as_deref()),
|
||||
target.via_tunnel,
|
||||
);
|
||||
let tls = match crate::db::tls::build_tls_config(
|
||||
@@ -368,7 +368,7 @@ async fn test_mysql_connection(config: &DbConfig, ssh: &SshManager) -> TestConne
|
||||
// TLS: through a tunnel the peer is loopback, so verify-ca/verify-full
|
||||
// degrade to encrypt-only `require`. Direct connections honor the mode.
|
||||
let decision = crate::commands::ssh::effective_tls_decision(
|
||||
crate::db::tls::tls_decision(config.ssl_mode.as_deref()),
|
||||
crate::db::tls::tls_decision_for(&config.host, config.ssl_mode.as_deref()),
|
||||
target.via_tunnel,
|
||||
);
|
||||
match decision {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod introspection;
|
||||
pub mod mysql;
|
||||
pub mod mysql_clean;
|
||||
pub mod sql_split;
|
||||
pub mod object_crud;
|
||||
pub mod object_ddl;
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
//! Planning for a MySQL "clean" restore.
|
||||
//!
|
||||
//! `clean` drops only the objects the dump recreates, matching the UI label
|
||||
//! "Clean — DROP before CREATE" and `pg_restore --clean` semantics: objects in
|
||||
//! the target database that the dump does not mention survive. It is
|
||||
//! deliberately *not* `DROP DATABASE` (spec §4.3).
|
||||
//!
|
||||
//! Pure by design: takes already-split statements, returns the DROP script.
|
||||
|
||||
use crate::db::mysql::mysql_quote_ident;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum ObjectKind {
|
||||
Table,
|
||||
View,
|
||||
Procedure,
|
||||
Function,
|
||||
Trigger,
|
||||
Event,
|
||||
}
|
||||
|
||||
impl ObjectKind {
|
||||
fn keyword(self) -> &'static str {
|
||||
match self {
|
||||
ObjectKind::Table => "TABLE",
|
||||
ObjectKind::View => "VIEW",
|
||||
ObjectKind::Procedure => "PROCEDURE",
|
||||
ObjectKind::Function => "FUNCTION",
|
||||
ObjectKind::Trigger => "TRIGGER",
|
||||
ObjectKind::Event => "EVENT",
|
||||
}
|
||||
}
|
||||
|
||||
/// Dependents first, tables last, so the drop phase reads predictably.
|
||||
/// Foreign-key ordering is handled by the caller disabling
|
||||
/// `FOREIGN_KEY_CHECKS`, so this is about clarity, not correctness.
|
||||
fn order(self) -> u8 {
|
||||
match self {
|
||||
ObjectKind::Trigger => 0,
|
||||
ObjectKind::Event => 1,
|
||||
ObjectKind::Procedure => 2,
|
||||
ObjectKind::Function => 3,
|
||||
ObjectKind::View => 4,
|
||||
ObjectKind::Table => 5,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Tokens that may appear between the object keyword and its name.
|
||||
const MODIFIERS: &[&str] = &[
|
||||
"OR", "REPLACE", "TEMPORARY", "IF", "NOT", "EXISTS", "AGGREGATE", "SQL", "SECURITY",
|
||||
"DEFINER", "ALGORITHM", "UNDEFINED", "MERGE", "TEMPTABLE", "CASCADED", "LOCAL", "INVOKER",
|
||||
"=",
|
||||
];
|
||||
|
||||
/// Build the ordered `DROP … IF EXISTS` script for a clean restore.
|
||||
/// Returns an empty vector when no object can be identified (fail closed).
|
||||
pub fn plan_clean_drops(default_schema: &str, statements: &[String]) -> Vec<String> {
|
||||
let mut found: Vec<(ObjectKind, String)> = Vec::new();
|
||||
for stmt in statements {
|
||||
if let Some((kind, name)) = parse_create_target(stmt) {
|
||||
let qualified = qualify(default_schema, &name);
|
||||
if !found.iter().any(|(k, n)| *k == kind && *n == qualified) {
|
||||
found.push((kind, qualified));
|
||||
}
|
||||
}
|
||||
}
|
||||
found.sort_by_key(|(k, _)| k.order());
|
||||
found
|
||||
.into_iter()
|
||||
.map(|(kind, name)| format!("DROP {} IF EXISTS {}", kind.keyword(), name))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Extract `(kind, raw name)` from a `CREATE …` statement, or `None`.
|
||||
fn parse_create_target(stmt: &str) -> Option<(ObjectKind, String)> {
|
||||
let tokens = unwrap_version_comments(stmt);
|
||||
let tokens = tokenize(&tokens);
|
||||
let mut it = tokens.iter().peekable();
|
||||
while let Some(tok) = it.next() {
|
||||
if !tok.eq_ignore_ascii_case("CREATE") {
|
||||
continue;
|
||||
}
|
||||
// Walk forward until an object keyword is found.
|
||||
while let Some(t) = it.next() {
|
||||
let upper = t.to_ascii_uppercase();
|
||||
let kind = match upper.as_str() {
|
||||
"TABLE" => ObjectKind::Table,
|
||||
"VIEW" => ObjectKind::View,
|
||||
"PROCEDURE" => ObjectKind::Procedure,
|
||||
"FUNCTION" => ObjectKind::Function,
|
||||
"TRIGGER" => ObjectKind::Trigger,
|
||||
"EVENT" => ObjectKind::Event,
|
||||
_ => continue,
|
||||
};
|
||||
// The first following token that is neither a modifier nor an
|
||||
// `x=y` clause is the object name.
|
||||
for cand in it.by_ref() {
|
||||
let up = cand.to_ascii_uppercase();
|
||||
if MODIFIERS.contains(&up.as_str()) || cand.contains('=') {
|
||||
continue;
|
||||
}
|
||||
// A routine's name is followed by its argument list with no
|
||||
// space (`p`()`, hence a single token); strip that trailing
|
||||
// `()` so the DROP names the routine, not its signature. This
|
||||
// must be a *string* suffix — `trim_end_matches('(')` (a char
|
||||
// pattern) strips nothing from `p`()`.
|
||||
let name = cand.strip_suffix("()").unwrap_or(cand).to_string();
|
||||
if name.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// Fail closed: a token that is not a plausible identifier means
|
||||
// we cannot name the object, so plan no DROP rather than emit a
|
||||
// bogus one. `CREATE TABLE (id int)` tokenizes as
|
||||
// `["CREATE", "TABLE", "(id", "int)"]`, and `(id` is a column
|
||||
// definition fragment, not a table name.
|
||||
if !is_plausible_identifier(&name) {
|
||||
return None;
|
||||
}
|
||||
return Some((kind, name));
|
||||
}
|
||||
return None;
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Reject candidates that are clearly not object names. A `(`, `)`, `,` or `;`
|
||||
/// outside a backtick-quoted region means the token is part of an argument
|
||||
/// list, a column definition, or a statement terminator — not an identifier.
|
||||
fn is_plausible_identifier(cand: &str) -> bool {
|
||||
if cand.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let mut in_backtick = false;
|
||||
for ch in cand.chars() {
|
||||
match ch {
|
||||
'`' => in_backtick = !in_backtick,
|
||||
'(' | ')' | ',' | ';' if !in_backtick => return false,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Replace `/*!50003 … */` executable comments with their inner text — the
|
||||
/// shape mysqldump uses to wrap routine and trigger definitions.
|
||||
fn unwrap_version_comments(stmt: &str) -> String {
|
||||
let mut out = String::with_capacity(stmt.len());
|
||||
let chars: Vec<char> = stmt.chars().collect();
|
||||
let mut i = 0usize;
|
||||
while i < chars.len() {
|
||||
if chars[i] == '/' && i + 2 < chars.len() && chars[i + 1] == '*' && chars[i + 2] == '!' {
|
||||
// skip `/*!` and any leading digits
|
||||
let mut j = i + 3;
|
||||
while j < chars.len() && chars[j].is_ascii_digit() {
|
||||
j += 1;
|
||||
}
|
||||
out.push(' ');
|
||||
while j < chars.len()
|
||||
&& !(chars[j] == '*' && j + 1 < chars.len() && chars[j + 1] == '/')
|
||||
{
|
||||
out.push(chars[j]);
|
||||
j += 1;
|
||||
}
|
||||
out.push(' ');
|
||||
i = (j + 2).min(chars.len());
|
||||
} else {
|
||||
out.push(chars[i]);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Split on whitespace, treating backtick-quoted regions as part of one token.
|
||||
fn tokenize(s: &str) -> Vec<String> {
|
||||
let mut tokens = Vec::new();
|
||||
let mut cur = String::new();
|
||||
let mut in_backtick = false;
|
||||
for ch in s.chars() {
|
||||
match ch {
|
||||
'`' => {
|
||||
in_backtick = !in_backtick;
|
||||
cur.push(ch);
|
||||
}
|
||||
c if c.is_whitespace() && !in_backtick => {
|
||||
if !cur.is_empty() {
|
||||
tokens.push(std::mem::take(&mut cur));
|
||||
}
|
||||
}
|
||||
c => cur.push(c),
|
||||
}
|
||||
}
|
||||
if !cur.is_empty() {
|
||||
tokens.push(cur);
|
||||
}
|
||||
tokens
|
||||
}
|
||||
|
||||
/// `a` -> `schema`.`a`; `db`.`a` / `db.a` -> `db`.`a`.
|
||||
fn qualify(default_schema: &str, name: &str) -> String {
|
||||
let parts: Vec<String> = name
|
||||
.split('.')
|
||||
.map(|p| mysql_quote_ident(strip_backticks(p)))
|
||||
.collect();
|
||||
if parts.len() == 1 {
|
||||
format!("{}.{}", mysql_quote_ident(default_schema), parts[0])
|
||||
} else {
|
||||
parts.join(".")
|
||||
}
|
||||
}
|
||||
|
||||
fn strip_backticks(s: &str) -> &str {
|
||||
s.trim_matches('`')
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "mysql_clean.test.rs"]
|
||||
mod mysql_clean_tests;
|
||||
@@ -0,0 +1,78 @@
|
||||
use super::*;
|
||||
|
||||
fn plan(sql: &str) -> Vec<String> {
|
||||
plan_clean_drops("shop", &crate::db::sql_split::split_statements(sql))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plans_a_drop_for_each_recreated_table() {
|
||||
let got = plan("CREATE TABLE `orders` (id int);\nCREATE TABLE IF NOT EXISTS users (id int);");
|
||||
assert_eq!(
|
||||
got,
|
||||
vec![
|
||||
"DROP TABLE IF EXISTS `shop`.`orders`".to_string(),
|
||||
"DROP TABLE IF EXISTS `shop`.`users`".to_string(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn qualifies_unqualified_names_with_the_target_schema() {
|
||||
assert_eq!(
|
||||
plan("CREATE TABLE orders (id int);"),
|
||||
vec!["DROP TABLE IF EXISTS `shop`.`orders`".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_an_explicit_schema_qualifier() {
|
||||
assert_eq!(
|
||||
plan("CREATE TABLE `other`.`orders` (id int);"),
|
||||
vec!["DROP TABLE IF EXISTS `other`.`orders`".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unwraps_mysqldump_version_comments_for_routines_and_triggers() {
|
||||
// This is the shape mysqldump actually emits for routines/triggers.
|
||||
let got = plan(
|
||||
"/*!50003 CREATE*/ /*!50020 DEFINER=`root`@`localhost`*/ /*!50003 PROCEDURE `p`() BEGIN END */;;\n\
|
||||
/*!50003 CREATE*/ /*!50017 DEFINER=`root`@`localhost`*/ /*!50003 TRIGGER `trg` BEFORE INSERT ON `orders` FOR EACH ROW BEGIN END */;;",
|
||||
);
|
||||
assert_eq!(
|
||||
got,
|
||||
vec![
|
||||
"DROP TRIGGER IF EXISTS `shop`.`trg`".to_string(),
|
||||
"DROP PROCEDURE IF EXISTS `shop`.`p`".to_string(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deduplicates_repeated_objects() {
|
||||
let got = plan("CREATE TABLE a (id int);\nCREATE TABLE a (id int);");
|
||||
assert_eq!(got, vec!["DROP TABLE IF EXISTS `shop`.`a`".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orders_dependents_before_tables() {
|
||||
let got = plan("CREATE TABLE t (id int);\nCREATE VIEW v AS SELECT 1;\nCREATE TRIGGER g BEFORE INSERT ON t FOR EACH ROW BEGIN END;");
|
||||
let kinds: Vec<&str> = got
|
||||
.iter()
|
||||
.map(|s| s.split_whitespace().nth(1).unwrap())
|
||||
.collect();
|
||||
assert_eq!(kinds, vec!["TRIGGER", "VIEW", "TABLE"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_create_statements() {
|
||||
let got = plan("INSERT INTO t VALUES (1);\nDROP TABLE x;\nALTER TABLE t ADD c int;\nSELECT 1;");
|
||||
assert!(got.is_empty(), "got {got:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_a_create_whose_name_cannot_be_determined() {
|
||||
// Fail closed: if we cannot name the object we must not guess at a DROP.
|
||||
let got = plan("CREATE TABLE (id int);");
|
||||
assert!(got.is_empty(), "got {got:?}");
|
||||
}
|
||||
@@ -19,9 +19,24 @@ pub fn split_statements(sql: &str) -> Vec<String> {
|
||||
let mut stmts: Vec<String> = Vec::new();
|
||||
let mut current = String::new();
|
||||
let mut i = 0usize;
|
||||
// MySQL's client-side `DELIMITER` directive. Default matches the SQL
|
||||
// standard terminator so behaviour is unchanged when no directive appears.
|
||||
let mut delimiter: Vec<char> = vec![';'];
|
||||
|
||||
while i < chars.len() {
|
||||
// A `DELIMITER` line is a client directive, not SQL. Only recognised
|
||||
// at the start of a line: quoted regions, block comments and line
|
||||
// comments are copied atomically below, so a directive inside any of
|
||||
// them can never reach this check.
|
||||
if i == 0 || chars[i - 1] == '\n' {
|
||||
if let Some((end, token)) = match_delimiter_directive(&chars, i) {
|
||||
delimiter = token.chars().collect();
|
||||
i = end;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let c = chars[i];
|
||||
let at_delimiter = starts_with(&chars, i, &delimiter);
|
||||
match c {
|
||||
'\'' | '"' | '`' => {
|
||||
copy_quoted(&chars, &mut i, &mut current, c);
|
||||
@@ -32,6 +47,13 @@ pub fn split_statements(sql: &str) -> Vec<String> {
|
||||
'/' if peek(&chars, i + 1) == Some('*') => {
|
||||
copy_block_comment(&chars, &mut i, &mut current);
|
||||
}
|
||||
_ if at_delimiter => {
|
||||
if has_executable_text(¤t) {
|
||||
stmts.push(current.trim().to_string());
|
||||
}
|
||||
current.clear();
|
||||
i += delimiter.len();
|
||||
}
|
||||
'$' => {
|
||||
if let Some(end) = dollar_quote_end(&chars, i) {
|
||||
for _ in i..end {
|
||||
@@ -45,13 +67,6 @@ pub fn split_statements(sql: &str) -> Vec<String> {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
';' => {
|
||||
if has_executable_text(¤t) {
|
||||
stmts.push(current.trim().to_string());
|
||||
}
|
||||
current.clear();
|
||||
i += 1;
|
||||
}
|
||||
_ => {
|
||||
current.push(c);
|
||||
i += 1;
|
||||
@@ -177,6 +192,15 @@ pub fn has_executable_text(stmt: &str) -> bool {
|
||||
skip_until(&chars, &mut i, |ch| ch == '\n');
|
||||
}
|
||||
'/' if peek(&chars, i + 1) == Some('*') => {
|
||||
// MySQL executable comments (`/*! … */`, optionally
|
||||
// version-gated like `/*!50003 … */`) are executed by the
|
||||
// server, so a fragment that is only such a comment is still
|
||||
// a statement. mysqldump wraps routine and trigger definitions
|
||||
// in them with no surrounding SQL. Ordinary block comments
|
||||
// remain skipped.
|
||||
if peek(&chars, i + 2) == Some('!') {
|
||||
return true;
|
||||
}
|
||||
skip_block_comment(&chars, &mut i);
|
||||
}
|
||||
c if c.is_whitespace() => i += 1,
|
||||
@@ -207,6 +231,54 @@ fn skip_block_comment(chars: &[char], i: &mut usize) {
|
||||
}
|
||||
}
|
||||
|
||||
/// True when `chars[i..]` begins with `pat`.
|
||||
fn starts_with(chars: &[char], i: usize, pat: &[char]) -> bool {
|
||||
!pat.is_empty() && i + pat.len() <= chars.len() && chars[i..i + pat.len()] == *pat
|
||||
}
|
||||
|
||||
/// Match a MySQL client `DELIMITER <token>` directive that occupies a whole
|
||||
/// line. Returns the index just past the directive's newline and the new
|
||||
/// delimiter token. Conservative: anything other than
|
||||
/// `^[ \t]*DELIMITER[ \t]+<token>[ \t]*$` is left as ordinary text.
|
||||
fn match_delimiter_directive(chars: &[char], i: usize) -> Option<(usize, String)> {
|
||||
let mut j = i;
|
||||
while j < chars.len() && matches!(chars[j], ' ' | '\t') {
|
||||
j += 1;
|
||||
}
|
||||
for kw in ['D', 'E', 'L', 'I', 'M', 'I', 'T', 'E', 'R'] {
|
||||
if j >= chars.len() || chars[j].to_ascii_uppercase() != kw {
|
||||
return None;
|
||||
}
|
||||
j += 1;
|
||||
}
|
||||
if j >= chars.len() || !matches!(chars[j], ' ' | '\t') {
|
||||
return None; // `DELIMITERx` is not a directive
|
||||
}
|
||||
while j < chars.len() && matches!(chars[j], ' ' | '\t') {
|
||||
j += 1;
|
||||
}
|
||||
let start = j;
|
||||
while j < chars.len() && !matches!(chars[j], ' ' | '\t' | '\r' | '\n') {
|
||||
j += 1;
|
||||
}
|
||||
if start == j {
|
||||
return None; // no token
|
||||
}
|
||||
let token: String = chars[start..j].iter().collect();
|
||||
// The rest of the line must be blank, otherwise this is not a directive.
|
||||
let mut k = j;
|
||||
while k < chars.len() && matches!(chars[k], ' ' | '\t') {
|
||||
k += 1;
|
||||
}
|
||||
if k < chars.len() && !matches!(chars[k], '\r' | '\n') {
|
||||
return None;
|
||||
}
|
||||
while k < chars.len() && matches!(chars[k], '\r' | '\n') {
|
||||
k += 1;
|
||||
}
|
||||
Some((k, token))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "sql_split.test.rs"]
|
||||
mod sql_split_tests;
|
||||
|
||||
@@ -84,3 +84,58 @@ fn empty_and_whitespace_input() {
|
||||
assert_eq!(split_statements(""), Vec::<String>::new());
|
||||
assert_eq!(split_statements(" ; ; "), Vec::<String>::new());
|
||||
}
|
||||
|
||||
// ── MySQL client DELIMITER directive ────────────────────────────────
|
||||
//
|
||||
// NOTE: the splitter strips the active statement terminator, exactly as the
|
||||
// pre-existing tests above assert (`splits_two_statements` -> ["SELECT 1",
|
||||
// "SELECT 2"]). The expectations below therefore never contain a trailing
|
||||
// `;`/delimiter, matching that established contract.
|
||||
|
||||
#[test]
|
||||
fn delimiter_directive_changes_statement_terminator() {
|
||||
let sql = "DELIMITER //\nCREATE PROCEDURE p()\nBEGIN\n SELECT 1;\nEND//\nDELIMITER ;\nSELECT 2;";
|
||||
let stmts = split_statements(sql);
|
||||
assert_eq!(stmts.len(), 2, "got {stmts:?}");
|
||||
assert!(stmts[0].starts_with("CREATE PROCEDURE p()"), "got {:?}", stmts[0]);
|
||||
// The inner `;` must NOT have split the body.
|
||||
assert!(stmts[0].contains("SELECT 1;"), "body was split: {:?}", stmts[0]);
|
||||
assert!(!stmts[0].contains("DELIMITER"), "directive leaked into statement: {:?}", stmts[0]);
|
||||
assert_eq!(stmts[1], "SELECT 2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delimiter_directive_is_case_and_space_tolerant() {
|
||||
let sql = " delimiter $$\n SELECT 1$$\n Delimiter ;\nSELECT 2;";
|
||||
let stmts = split_statements(sql);
|
||||
assert_eq!(stmts, vec!["SELECT 1".to_string(), "SELECT 2".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delimiter_text_inside_a_string_is_not_a_directive() {
|
||||
let stmts = split_statements("SELECT 'DELIMITER //';\nSELECT 2;");
|
||||
assert_eq!(stmts.len(), 2);
|
||||
assert_eq!(stmts[0], "SELECT 'DELIMITER //'");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delimiter_text_inside_a_line_comment_is_not_a_directive() {
|
||||
let stmts = split_statements("-- DELIMITER //\nSELECT 1;");
|
||||
assert_eq!(stmts.len(), 1);
|
||||
assert!(stmts[0].contains("SELECT 1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delimiter_with_trailing_junk_is_ordinary_text() {
|
||||
let stmts = split_statements("DELIMITER // extra\nSELECT 1;");
|
||||
assert_eq!(stmts.len(), 1);
|
||||
assert!(stmts[0].contains("DELIMITER // extra"), "got {:?}", stmts[0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_single_semicolon_is_ordinary_text_under_a_custom_delimiter() {
|
||||
let stmts = split_statements("DELIMITER //\nSELECT 1; SELECT 2//\nDELIMITER ;\nSELECT 3;");
|
||||
assert_eq!(stmts.len(), 2);
|
||||
assert!(stmts[0].contains("SELECT 1; SELECT 2"), "got {:?}", stmts[0]);
|
||||
assert_eq!(stmts[1], "SELECT 3");
|
||||
}
|
||||
|
||||
@@ -29,6 +29,37 @@ pub fn tls_decision(ssl_mode: Option<&str>) -> TlsDecision {
|
||||
}
|
||||
}
|
||||
|
||||
/// Host suffixes of managed providers that reject plaintext connections with
|
||||
/// `connection is insecure (try using 'sslmode=require')`.
|
||||
const MANAGED_SSL_HOST_SUFFIXES: [&str; 3] = [".neon.tech", ".supabase.co", ".psdb.cloud"];
|
||||
|
||||
/// Resolve the SSL mode to use for a connection, defaulting these managed
|
||||
/// providers to `require` when the connection carries no explicit mode.
|
||||
///
|
||||
/// Connections saved before the UI persisted `ssl_mode` (or imported without
|
||||
/// it) would otherwise connect with TLS disabled and be refused by the server.
|
||||
/// An explicit mode always wins, so a deliberate `disable` is never overridden,
|
||||
/// and the inferred mode only encrypts — it never verifies certificates.
|
||||
pub fn resolve_ssl_mode(host: &str, ssl_mode: Option<&str>) -> Option<String> {
|
||||
if let Some(mode) = ssl_mode {
|
||||
return Some(mode.to_string());
|
||||
}
|
||||
let host = host.to_ascii_lowercase();
|
||||
if MANAGED_SSL_HOST_SUFFIXES
|
||||
.iter()
|
||||
.any(|suffix| host.ends_with(suffix))
|
||||
{
|
||||
return Some("require".to_string());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// TLS decision for a connection, applying the managed-provider default from
|
||||
/// [`resolve_ssl_mode`] before mapping to a [`TlsDecision`].
|
||||
pub fn tls_decision_for(host: &str, ssl_mode: Option<&str>) -> TlsDecision {
|
||||
tls_decision(resolve_ssl_mode(host, ssl_mode).as_deref())
|
||||
}
|
||||
|
||||
/// Build a rustls `ClientConfig` for tokio-postgres, or `None` for disable.
|
||||
/// `ca_path` is required for Verify; `cert_path`/`key_path` are optional client auth.
|
||||
pub fn build_tls_config(
|
||||
@@ -197,6 +228,50 @@ mod tests {
|
||||
assert!(matches!(tls_decision(Some("bogus")), TlsDecision::Disable));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_ssl_mode_defaults_managed_hosts_to_require() {
|
||||
// Saved connections that predate the form storing `ssl_mode` still work.
|
||||
for host in [
|
||||
"ep-hidden-star-b3hqtp4x-pooler.c-4.ap-southeast-1.aws.neon.tech",
|
||||
"db.abcdefghijklmnopqrst.supabase.co",
|
||||
"xxxx.us-east-2.psdb.cloud",
|
||||
"EP-X-POOLER.EU.AWS.NEON.TECH",
|
||||
] {
|
||||
assert_eq!(resolve_ssl_mode(host, None).as_deref(), Some("require"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_ssl_mode_leaves_other_hosts_and_explicit_modes_alone() {
|
||||
assert_eq!(resolve_ssl_mode("localhost", None), None);
|
||||
assert_eq!(resolve_ssl_mode("prod.example.com", None), None);
|
||||
// An explicit mode always wins — a deliberate `disable` is never overridden.
|
||||
assert_eq!(
|
||||
resolve_ssl_mode("ep-x.eu.aws.neon.tech", Some("disable")).as_deref(),
|
||||
Some("disable")
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_ssl_mode("ep-x.eu.aws.neon.tech", Some("verify-full")).as_deref(),
|
||||
Some("verify-full")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tls_decision_for_honors_managed_default_and_explicit_modes() {
|
||||
assert!(matches!(
|
||||
tls_decision_for("ep-x.eu.aws.neon.tech", None),
|
||||
TlsDecision::Require
|
||||
));
|
||||
assert!(matches!(
|
||||
tls_decision_for("ep-x.eu.aws.neon.tech", Some("disable")),
|
||||
TlsDecision::Disable
|
||||
));
|
||||
assert!(matches!(
|
||||
tls_decision_for("db.example.com", None),
|
||||
TlsDecision::Disable
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_tls_disable_returns_none() {
|
||||
assert!(build_tls_config(TlsDecision::Disable, None, None, None)
|
||||
|
||||
@@ -1,117 +1,45 @@
|
||||
//! Keychain ACL normalization for stored secrets (macOS).
|
||||
//! Keychain ACL normalization and repair for stored secrets (macOS).
|
||||
//!
|
||||
//! # Why this exists
|
||||
//!
|
||||
//! Generic-password items stored via the legacy keychain API get a default ACL
|
||||
//! that grants access to "the application that created the item". When a Tauri
|
||||
//! app is rebuilt, its code signature changes (dev builds are ad-hoc signed, so
|
||||
//! the identity differs on every build), and the new binary no longer matches
|
||||
//! the item's ACL — macOS then shows the "Gridline wants to access your
|
||||
//! keychain" prompt.
|
||||
//! Generic-password items stored via the legacy keychain API are guarded by an
|
||||
//! item ACL. For a dev (linker-signed / ad-hoc) binary macOS records that ACL's
|
||||
//! trusted application as **the path of the executable** — not a code
|
||||
//! requirement — so the item stays readable across rebuilds *as long as the
|
||||
//! binary keeps living at that path*. A properly signed bundle is recorded as a
|
||||
//! code requirement plus a `cdhash` partition list, which is stable across
|
||||
//! versions and even moves.
|
||||
//!
|
||||
//! The fix: after storing a secret, replace the item's ACL with an explicit
|
||||
//! `SecAccess` whose trusted-application list contains exactly *this app*,
|
||||
//! resolved by `SecTrustedApplicationCreateFromPath(NULL)` — the current
|
||||
//! process's code-signature requirement. For a Developer ID-signed release
|
||||
//! build that requirement is stable across versions (anchored to the signing
|
||||
//! certificate), so items stay accessible without prompts through upgrades.
|
||||
//! Two failure modes follow from that:
|
||||
//!
|
||||
//! # Prompt behavior
|
||||
//! 1. **The path changes** (monorepo move `src-tauri/` → `desktop/src-tauri/`,
|
||||
//! app reinstalled elsewhere, release vs. dev binary). The stored paths no
|
||||
//! longer match the running binary, so macOS treats it as a *foreign* app and
|
||||
//! asks for the keychain password on **every** access — per secret, and with
|
||||
//! no "Always Allow", because the item's ACL lists only other apps.
|
||||
//! 2. **A differently-signed build wrote the item** and the pin never ran again.
|
||||
//!
|
||||
//! - Item created by this build → the ACL set is silent.
|
||||
//! - Item created by an older/differently-signed build → macOS shows the
|
||||
//! standard one-time authorization dialog; "Always Allow" replaces the ACL
|
||||
//! and the prompt never reappears (the same dialog the app would show on the
|
||||
//! first read anyway — this just makes the result permanent and clean).
|
||||
//! Pinning only after writes (`after_store`) can't repair either case: writing
|
||||
//! to an item we can't access already needs the same authorization, so the ACL
|
||||
//! is never corrected. This module therefore also *repairs before reads*.
|
||||
//!
|
||||
//! All operations are best-effort: `after_store` swallows errors so keychain
|
||||
//! writes never fail because of ACL normalization.
|
||||
|
||||
/// Best-effort ACL normalization after a successful keychain write.
|
||||
///
|
||||
/// Resolves the service name the same way `tauri-plugin-keyring-store` does
|
||||
/// (the Tauri bundle identifier) and never fails the caller's operation.
|
||||
pub fn after_store(app: &tauri::AppHandle, account: &str) {
|
||||
let service = app.config().identifier.clone();
|
||||
if let Err(e) = ensure_item_acl(&service, account) {
|
||||
eprintln!("[keychain_acl] skipped for {account}: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Grant this app (by its code-signature requirement) access to a keychain item.
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn ensure_item_acl(service: &str, account: &str) -> Result<(), String> {
|
||||
use core_foundation::array::CFArray;
|
||||
use core_foundation::base::{CFTypeRef, TCFType};
|
||||
use core_foundation::string::CFString;
|
||||
use security_framework::item::{ItemClass, ItemSearchOptions, Reference, SearchResult};
|
||||
use security_framework::os::macos::access::SecAccess;
|
||||
use security_framework_sys::base::SecAccessRef;
|
||||
|
||||
// Locate the item WITHOUT reading its secret: a ref-only lookup
|
||||
// (`kSecReturnRef`, no data/attributes) never triggers an authorization
|
||||
// prompt, so merely probing an item we can't yet access is safe.
|
||||
let found = ItemSearchOptions::new()
|
||||
.class(ItemClass::generic_password())
|
||||
.service(service)
|
||||
.account(account)
|
||||
.load_refs(true)
|
||||
.load_data(false)
|
||||
.load_attributes(false)
|
||||
.search()
|
||||
.map_err(|e| format!("keychain item lookup failed: {e}"))?;
|
||||
|
||||
let item = match found.first() {
|
||||
Some(SearchResult::Ref(Reference::KeychainItem(item))) => item,
|
||||
// No item (yet) or an unexpected result shape — nothing to normalize.
|
||||
_ => return Ok(()),
|
||||
};
|
||||
|
||||
fn cvt(status: i32) -> Result<(), String> {
|
||||
if status == 0 {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("security framework error {status}"))
|
||||
}
|
||||
}
|
||||
|
||||
// Build an access object whose trusted list is exactly this app. A NULL
|
||||
// path resolves the trusted application to the current process's code
|
||||
// signature requirement.
|
||||
unsafe {
|
||||
let mut trusted_app_ref: *mut OpaqueSecTrustedApplication = std::ptr::null_mut();
|
||||
cvt(SecTrustedApplicationCreateFromPath(std::ptr::null(), &mut trusted_app_ref))?;
|
||||
let trusted_app = SecTrustedApplication::wrap_under_create_rule(trusted_app_ref);
|
||||
let trusted_list = CFArray::from_CFTypes(&[trusted_app]);
|
||||
|
||||
let descriptor = CFString::new("Gridline");
|
||||
let mut access_ref: SecAccessRef = std::ptr::null_mut();
|
||||
cvt(SecAccessCreate(
|
||||
descriptor.as_concrete_TypeRef() as CFTypeRef,
|
||||
trusted_list.as_concrete_TypeRef() as CFTypeRef,
|
||||
&mut access_ref,
|
||||
))?;
|
||||
let access = SecAccess::wrap_under_create_rule(access_ref);
|
||||
|
||||
// Replace the item's ACL. Silent when we already own the item;
|
||||
// one-time authorization dialog when the item was created by a
|
||||
// differently-signed build.
|
||||
cvt(SecKeychainItemSetAccess(
|
||||
item.as_concrete_TypeRef(),
|
||||
access.as_concrete_TypeRef(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Non-macOS platforms have no keychain ACL concept — nothing to do.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub fn ensure_item_acl(_service: &str, _account: &str) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
//! # How the repair works
|
||||
//!
|
||||
//! [`ensure_access`] first asks, **without prompting**, whether this process is
|
||||
//! already represented in the item's ACL (see [`is_item_trusted`]). Only when it
|
||||
//! is not does it call `SecKeychainItemSetAccess` with an ACL whose trusted list
|
||||
//! is exactly this app. That call is what macOS gates behind the authorization
|
||||
//! dialog — so a stale item costs the user **one** authorization, and every
|
||||
//! later access (including after future rebuilds at the same path) is silent.
|
||||
//! Items that are already trusted are left alone, so the ACL never accumulates
|
||||
//! duplicate entries.
|
||||
//!
|
||||
//! All operations are best-effort: they log to stderr and never fail the
|
||||
//! caller's keychain read/write.
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod macos_ffi {
|
||||
use core_foundation::base::{CFTypeRef, CFTypeID};
|
||||
use core_foundation::base::{CFTypeID, CFTypeRef};
|
||||
|
||||
/// Minimal `TCFType` wrapper for `SecTrustedApplication` — the
|
||||
/// security-framework crate exposes no high-level API for it.
|
||||
@@ -140,11 +68,29 @@ mod macos_ffi {
|
||||
path: CFTypeRef,
|
||||
app: *mut SecTrustedApplicationRef,
|
||||
) -> i32;
|
||||
pub fn SecTrustedApplicationCopyData(
|
||||
app: CFTypeRef,
|
||||
data: *mut core_foundation::data::CFDataRef,
|
||||
) -> i32;
|
||||
pub fn SecAccessCreate(
|
||||
descriptor: CFTypeRef,
|
||||
trustedlist: CFTypeRef,
|
||||
trustedlist: core_foundation::array::CFArrayRef,
|
||||
access: *mut security_framework_sys::base::SecAccessRef,
|
||||
) -> i32;
|
||||
pub fn SecKeychainItemCopyAccess(
|
||||
item: security_framework_sys::base::SecKeychainItemRef,
|
||||
access: *mut security_framework_sys::base::SecAccessRef,
|
||||
) -> i32;
|
||||
pub fn SecAccessCopyACLList(
|
||||
access: security_framework_sys::base::SecAccessRef,
|
||||
list: *mut core_foundation::array::CFArrayRef,
|
||||
) -> i32;
|
||||
pub fn SecACLCopyContents(
|
||||
acl: CFTypeRef,
|
||||
apps: *mut core_foundation::array::CFArrayRef,
|
||||
desc: *mut core_foundation::string::CFStringRef,
|
||||
prompt: *mut u32,
|
||||
) -> i32;
|
||||
pub fn SecKeychainItemSetAccess(
|
||||
item: security_framework_sys::base::SecKeychainItemRef,
|
||||
access: security_framework_sys::base::SecAccessRef,
|
||||
@@ -152,21 +98,260 @@ mod macos_ffi {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
use core_foundation::base::TCFType;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
use macos_ffi::{
|
||||
OpaqueSecTrustedApplication, SecAccessCreate, SecKeychainItemSetAccess, SecTrustedApplication,
|
||||
SecACLCopyContents, SecAccessCopyACLList, SecAccessCreate, SecKeychainItemCopyAccess,
|
||||
SecKeychainItemSetAccess, SecTrustedApplication, SecTrustedApplicationCopyData,
|
||||
SecTrustedApplicationCreateFromPath,
|
||||
};
|
||||
|
||||
/// Best-effort ACL normalization after a successful keychain write.
|
||||
pub fn after_store(app: &tauri::AppHandle, account: &str) {
|
||||
ensure_access(app, account);
|
||||
}
|
||||
|
||||
/// Best-effort ACL repair *before* reading a secret.
|
||||
///
|
||||
/// A stale ACL is what makes macOS ask for the keychain password on every
|
||||
/// access, so this runs before reads: it authorizes/repairs the item once and
|
||||
/// leaves it silent from then on. Prompt-free (and cheap) when the item is
|
||||
/// already trusted or does not exist.
|
||||
pub fn before_read(app: &tauri::AppHandle, account: &str) {
|
||||
ensure_access(app, account);
|
||||
}
|
||||
|
||||
/// Resolve the keyring service name (the Tauri bundle identifier) and repair the
|
||||
/// item's ACL when this app is not already trusted. Never fails the caller.
|
||||
fn ensure_access(app: &tauri::AppHandle, account: &str) {
|
||||
let service = app.config().identifier.clone();
|
||||
if let Err(e) = ensure_item_acl(&service, account) {
|
||||
eprintln!("[keychain_acl] skipped for {account}: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Make sure this app can read `account` without an authorization prompt.
|
||||
///
|
||||
/// Idempotent and cheap when the item is already trusted (the common case):
|
||||
/// the trust check is a metadata-only, prompt-free lookup. Returns `Ok(())` when
|
||||
/// the item exists and now trusts this app, or when there is nothing to do.
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn ensure_item_acl(service: &str, account: &str) -> Result<(), String> {
|
||||
use security_framework::os::macos::access::SecAccess;
|
||||
use security_framework_sys::base::SecAccessRef;
|
||||
|
||||
// Locate the item WITHOUT reading its secret: a ref-only lookup
|
||||
// (`kSecReturnRef`, no data/attributes) never triggers an authorization
|
||||
// prompt, so merely probing an item we can't yet access is safe.
|
||||
let item = match item_ref(service, account)? {
|
||||
Some(item) => item,
|
||||
// No item (yet) — nothing to normalize.
|
||||
None => return Ok(()),
|
||||
};
|
||||
|
||||
// Already trusted → leave the ACL alone (repairing again would only
|
||||
// authorize-and-append a duplicate entry).
|
||||
if is_item_trusted(&item) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
fn cvt(status: i32) -> Result<(), String> {
|
||||
if status == 0 {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("security framework error {status}"))
|
||||
}
|
||||
}
|
||||
|
||||
// Build an access object whose trusted list is exactly this app. A NULL
|
||||
// path resolves the trusted application to the current process's identity
|
||||
// (its executable path for ad-hoc dev builds, its code requirement for a
|
||||
// signed bundle).
|
||||
unsafe {
|
||||
let mut trusted_app_ref = std::ptr::null_mut();
|
||||
cvt(SecTrustedApplicationCreateFromPath(
|
||||
std::ptr::null(),
|
||||
&mut trusted_app_ref,
|
||||
))?;
|
||||
let trusted_app = SecTrustedApplication::wrap_under_create_rule(trusted_app_ref);
|
||||
let trusted_list = core_foundation::array::CFArray::from_CFTypes(&[trusted_app]);
|
||||
|
||||
let descriptor = core_foundation::string::CFString::new("Gridline");
|
||||
let mut access_ref: SecAccessRef = std::ptr::null_mut();
|
||||
cvt(SecAccessCreate(
|
||||
descriptor.as_concrete_TypeRef() as core_foundation::base::CFTypeRef,
|
||||
trusted_list.as_concrete_TypeRef(),
|
||||
&mut access_ref,
|
||||
))?;
|
||||
let access = SecAccess::wrap_under_create_rule(access_ref);
|
||||
|
||||
// Replace the item's ACL. Silent when this app already owns the item;
|
||||
// the one-time authorization dialog otherwise (this is what makes a
|
||||
// stale item permanently silent again).
|
||||
cvt(SecKeychainItemSetAccess(
|
||||
item.as_concrete_TypeRef(),
|
||||
access.as_concrete_TypeRef(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Ref-only lookup for a generic-password item. Prompt-free.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn item_ref(
|
||||
service: &str,
|
||||
account: &str,
|
||||
) -> Result<Option<security_framework::os::macos::keychain_item::SecKeychainItem>, String> {
|
||||
use security_framework::item::{ItemClass, ItemSearchOptions, Reference, SearchResult};
|
||||
|
||||
let found = match ItemSearchOptions::new()
|
||||
.class(ItemClass::generic_password())
|
||||
.service(service)
|
||||
.account(account)
|
||||
.load_refs(true)
|
||||
.load_data(false)
|
||||
.load_attributes(false)
|
||||
.search()
|
||||
{
|
||||
Ok(found) => found,
|
||||
// `search()` reports "nothing matched" as an error, not an empty vec.
|
||||
// That is the normal case for connections without a stored secret.
|
||||
Err(e) if e.code() == security_framework_sys::base::errSecItemNotFound => return Ok(None),
|
||||
Err(e) => return Err(format!("keychain item lookup failed: {e}")),
|
||||
};
|
||||
|
||||
Ok(match found.first() {
|
||||
Some(SearchResult::Ref(Reference::KeychainItem(item))) => Some(item.clone()),
|
||||
// No item or an unexpected result shape — nothing to normalize.
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// This process's identity as a `SecTrustedApplication`, serialized.
|
||||
///
|
||||
/// For a signed bundle this is the code requirement; for a dev/ad-hoc binary it
|
||||
/// is the resolved executable path (NUL-terminated). Either way, byte-comparing
|
||||
/// it against an item's ACL entries tells us whether the ACL already admits us.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn own_identity() -> Option<Vec<u8>> {
|
||||
unsafe {
|
||||
let mut app = std::ptr::null_mut();
|
||||
if SecTrustedApplicationCreateFromPath(std::ptr::null(), &mut app) != 0 || app.is_null() {
|
||||
return None;
|
||||
}
|
||||
let app = SecTrustedApplication::wrap_under_create_rule(app);
|
||||
let mut data: core_foundation::data::CFDataRef = std::ptr::null();
|
||||
if SecTrustedApplicationCopyData(
|
||||
app.as_concrete_TypeRef() as core_foundation::base::CFTypeRef,
|
||||
&mut data,
|
||||
) != 0
|
||||
|| data.is_null()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let bytes = std::slice::from_raw_parts(
|
||||
core_foundation::data::CFDataGetBytePtr(data),
|
||||
core_foundation::data::CFDataGetLength(data) as usize,
|
||||
)
|
||||
.to_vec();
|
||||
core_foundation::base::CFRelease(data as core_foundation::base::CFTypeRef);
|
||||
Some(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this process is already admitted by the item's ACL — **prompt-free**.
|
||||
///
|
||||
/// True when an ACL entry's serialized trusted application is byte-identical to
|
||||
/// this process's identity, or when the item carries a `cdhash:` partition list
|
||||
/// (how macOS records access for a signed bundle — we leave those untouched).
|
||||
#[cfg(target_os = "macos")]
|
||||
fn is_item_trusted(item: &security_framework::os::macos::keychain_item::SecKeychainItem) -> bool {
|
||||
use core_foundation::array::{CFArray, CFArrayRef};
|
||||
use core_foundation::base::{CFTypeRef, TCFType};
|
||||
use core_foundation::string::{CFString, CFStringRef};
|
||||
|
||||
let Some(own) = own_identity() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
unsafe {
|
||||
let mut access: security_framework_sys::base::SecAccessRef = std::ptr::null_mut();
|
||||
if SecKeychainItemCopyAccess(item.as_concrete_TypeRef(), &mut access) != 0
|
||||
|| access.is_null()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let mut acl_list: CFArrayRef = std::ptr::null();
|
||||
if SecAccessCopyACLList(access, &mut acl_list) != 0 || acl_list.is_null() {
|
||||
return false;
|
||||
}
|
||||
let acls = CFArray::<CFTypeRef>::wrap_under_create_rule(acl_list);
|
||||
|
||||
for acl in acls.iter() {
|
||||
let mut apps: CFArrayRef = std::ptr::null();
|
||||
let mut desc: CFStringRef = std::ptr::null();
|
||||
let mut prompt: u32 = 0;
|
||||
if SecACLCopyContents(*acl, &mut apps, &mut desc, &mut prompt) != 0 {
|
||||
continue;
|
||||
}
|
||||
if !desc.is_null() {
|
||||
// Signed-bundle items store a code-signing partition list here.
|
||||
if CFString::wrap_under_create_rule(desc)
|
||||
.to_string()
|
||||
.contains("cdhash:")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if apps.is_null() {
|
||||
continue;
|
||||
}
|
||||
let list = CFArray::<CFTypeRef>::wrap_under_create_rule(apps);
|
||||
for app in list.iter() {
|
||||
let mut data: core_foundation::data::CFDataRef = std::ptr::null();
|
||||
if SecTrustedApplicationCopyData(*app, &mut data) != 0 || data.is_null() {
|
||||
continue;
|
||||
}
|
||||
let bytes = std::slice::from_raw_parts(
|
||||
core_foundation::data::CFDataGetBytePtr(data),
|
||||
core_foundation::data::CFDataGetLength(data) as usize,
|
||||
);
|
||||
if bytes == own.as_slice() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Non-macOS platforms have no keychain ACL concept — nothing to do.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub fn ensure_item_acl(_service: &str, _account: &str) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(all(test, target_os = "macos"))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use security_framework::os::macos::keychain::SecKeychain;
|
||||
use security_framework::os::macos::passwords::find_generic_password;
|
||||
|
||||
/// Real-keychain integration test: creates a generic-password item the
|
||||
/// same way the keyring store does, normalizes its ACL, verifies the
|
||||
/// secret is still readable, and cleans up.
|
||||
/// A missing item is a silent no-op — no lookup errors, no prompts. This is
|
||||
/// the path taken for every connection that never stored a password, so it
|
||||
/// must stay cheap and quiet.
|
||||
#[test]
|
||||
fn ensure_item_acl_missing_item_is_noop() {
|
||||
let service = "com.adrianbonpin.gridline.acl-test.missing";
|
||||
let account = "definitely-not-stored";
|
||||
ensure_item_acl(service, account).expect("no-op for a missing item");
|
||||
}
|
||||
|
||||
/// Real-keychain integration test: creates a generic-password item the same
|
||||
/// way the keyring store does, asserts the trust check, repairs the ACL,
|
||||
/// verifies the item is now trusted (and that repairing again is a silent
|
||||
/// no-op), reads the secret back, and cleans up.
|
||||
///
|
||||
/// Ignored by default (`cargo test`) so CI/dev runs never touch the login
|
||||
/// keychain. Run explicitly with:
|
||||
@@ -184,8 +369,21 @@ mod tests {
|
||||
.set_generic_password(service, &account, b"test-secret")
|
||||
.expect("create test item");
|
||||
|
||||
// ACL normalization must succeed on an item we just created.
|
||||
// Fresh items are created with this build as the trusted app, so the
|
||||
// trust check must already be true and the repair a no-op.
|
||||
let item = item_ref(service, &account)
|
||||
.expect("lookup")
|
||||
.expect("item exists");
|
||||
assert!(is_item_trusted(&item), "fresh item should already trust us");
|
||||
|
||||
ensure_item_acl(service, &account).expect("normalize ACL");
|
||||
assert!(
|
||||
is_item_trusted(&item),
|
||||
"item must be trusted after repair"
|
||||
);
|
||||
|
||||
// Idempotent: a second repair must not need authorization again.
|
||||
ensure_item_acl(service, &account).expect("second repair");
|
||||
|
||||
// The item must remain readable with the correct secret.
|
||||
let (password, item) =
|
||||
|
||||
@@ -13,7 +13,7 @@ use commands::ssh::{Ssh2Backend, SshTunnelManager};
|
||||
use db::pool::ConnectionPoolManager;
|
||||
use std::sync::{Arc, Mutex as StdMutex};
|
||||
use store::Store;
|
||||
use tauri::Manager;
|
||||
use tauri::{Emitter, Manager};
|
||||
|
||||
pub struct AppState {
|
||||
pub db_store: StdMutex<Store>,
|
||||
@@ -39,11 +39,32 @@ pub fn run() {
|
||||
// another provider is already installed).
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
|
||||
tauri::Builder::default()
|
||||
let builder = tauri::Builder::default()
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_fs::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_keyring_store::init())
|
||||
// OS "open with" paths are queued here before the webview exists.
|
||||
.manage(crate::commands::open_files::PendingOpenFiles::default());
|
||||
|
||||
// Shadowed, not `mut`: on macOS this cfg block is compiled out, so a
|
||||
// mutable binding would only earn an `unused_mut` warning. The plugin
|
||||
// itself stays a Windows/Linux-only, target-scoped dependency.
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
let builder = builder.plugin(tauri_plugin_single_instance::init(|app, argv, _cwd| {
|
||||
let pending = app.state::<crate::commands::open_files::PendingOpenFiles>();
|
||||
let mut accepted = false;
|
||||
for arg in argv.iter().skip(1) {
|
||||
if crate::commands::open_files::accept_path(&pending, arg) {
|
||||
accepted = true;
|
||||
}
|
||||
}
|
||||
if accepted {
|
||||
let _ = app.emit("sql-file-opened", ());
|
||||
}
|
||||
}));
|
||||
|
||||
builder
|
||||
.setup(move |app| {
|
||||
// Open the local store under the OS app-data directory. When the
|
||||
// app is launched from Finder/LaunchServices the working directory
|
||||
@@ -59,6 +80,14 @@ pub fn run() {
|
||||
let store =
|
||||
Store::open(&data_dir.join("gridline.db").to_string_lossy()).expect("failed to open db");
|
||||
|
||||
// Cold-start "open with": the path arrived as a process argument
|
||||
// before the webview existed, so queue it here. macOS uses
|
||||
// RunEvent::Opened instead.
|
||||
for arg in std::env::args().skip(1) {
|
||||
let pending = app.state::<crate::commands::open_files::PendingOpenFiles>();
|
||||
let _ = crate::commands::open_files::accept_path(&pending, &arg);
|
||||
}
|
||||
|
||||
app.manage(AppState {
|
||||
db_store: StdMutex::new(store),
|
||||
pool_manager: tokio::sync::Mutex::new(ConnectionPoolManager::new()),
|
||||
@@ -178,6 +207,7 @@ pub fn run() {
|
||||
query::execute_query_multi,
|
||||
query_export::export_query_to_file,
|
||||
query::cancel_query,
|
||||
crate::commands::open_files::take_pending_sql_files,
|
||||
query::get_query_history,
|
||||
query::clear_query_history,
|
||||
query::set_history_favorite,
|
||||
@@ -188,16 +218,34 @@ pub fn run() {
|
||||
])
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while building tauri application")
|
||||
.run(|app_handle, event| {
|
||||
.run(|app_handle, event| match event {
|
||||
// macOS: Finder / LaunchServices "open with". File URLs only.
|
||||
#[cfg(target_os = "macos")]
|
||||
tauri::RunEvent::Opened { urls } => {
|
||||
let pending = app_handle
|
||||
.state::<crate::commands::open_files::PendingOpenFiles>();
|
||||
let mut accepted = false;
|
||||
for url in urls {
|
||||
if let Ok(path) = url.to_file_path() {
|
||||
if crate::commands::open_files::accept_path(
|
||||
&pending,
|
||||
&path.to_string_lossy(),
|
||||
) {
|
||||
accepted = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if accepted {
|
||||
let _ = app_handle.emit("sql-file-opened", ());
|
||||
}
|
||||
}
|
||||
// Close all SSH tunnels on exit: ExitRequested fires before the
|
||||
// event loop ends, Exit fires after it has.
|
||||
if matches!(
|
||||
event,
|
||||
tauri::RunEvent::ExitRequested { .. } | tauri::RunEvent::Exit
|
||||
) {
|
||||
tauri::RunEvent::ExitRequested { .. } | tauri::RunEvent::Exit => {
|
||||
if let Ok(mut mgr) = app_handle.state::<AppState>().ssh_manager.lock() {
|
||||
mgr.close_all();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Gridline",
|
||||
"version": "0.8.0",
|
||||
"version": "0.8.2",
|
||||
"identifier": "com.adrianbonpin.gridline",
|
||||
"build": {
|
||||
"beforeDevCommand": "bun run dev",
|
||||
@@ -27,6 +27,16 @@
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"resources": ["resources/pg_tools/*", "resources/mysql_tools/*"],
|
||||
"fileAssociations": [
|
||||
{
|
||||
"ext": ["sql"],
|
||||
"name": "SQL Script",
|
||||
"description": "SQL script",
|
||||
"mimeType": "application/sql",
|
||||
"role": "Editor",
|
||||
"rank": "Alternate"
|
||||
}
|
||||
],
|
||||
"macOS": {
|
||||
"signingIdentity": "-",
|
||||
"hardenedRuntime": true,
|
||||
|
||||
@@ -9,7 +9,9 @@ import { NewConnectionScreen } from "./components/connections/NewConnectionScree
|
||||
import { ErrorBanner } from "./components/ui/ErrorBanner";
|
||||
import { ToastContainer } from "./components/ui/Toast";
|
||||
import { DbViewerScreen } from "./components/db-viewer/DbViewerScreen";
|
||||
import { PendingSqlFileBanner } from "./components/layout/PendingSqlFileBanner";
|
||||
import { useAppearance } from "./hooks/useAppearance";
|
||||
import { useSqlFileOpen } from "./hooks/useSqlFileOpen";
|
||||
import { isMacOS } from "./lib/platform";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
|
||||
@@ -36,6 +38,7 @@ export default function App() {
|
||||
const clearPrefilledConnectionString = useUiStore((s) => s.clearPrefilledConnectionString);
|
||||
|
||||
useAppearance(settings?.theme ?? "system", settings?.font_size ?? "medium", settings?.accent_color ?? "#2563EB");
|
||||
useSqlFileOpen();
|
||||
|
||||
useEffect(() => {
|
||||
loadConnections();
|
||||
@@ -90,6 +93,7 @@ export default function App() {
|
||||
className="h-7 shrink-0 bg-canvas select-none"
|
||||
/>
|
||||
)}
|
||||
<PendingSqlFileBanner />
|
||||
<div className="flex-1 min-h-0">
|
||||
{connectionError && (
|
||||
<div className="px-6 pt-4">
|
||||
|
||||
@@ -4,16 +4,19 @@ import type { ConnectionFormData } from "./connectionFormData";
|
||||
interface ConnectionMetadataRowProps {
|
||||
form: ConnectionFormData;
|
||||
onChange: (updates: Partial<ConnectionFormData>) => void;
|
||||
/** Shown when the label is empty — usually the name derived from a pasted
|
||||
* connection string, which Test/Save uses as a fallback. */
|
||||
namePlaceholder?: string;
|
||||
}
|
||||
|
||||
export function ConnectionMetadataRow({ form, onChange }: ConnectionMetadataRowProps) {
|
||||
export function ConnectionMetadataRow({ form, onChange, namePlaceholder }: ConnectionMetadataRowProps) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<label className="block text-sm text-text mb-1.5">Connection Label</label>
|
||||
<Input
|
||||
value={form.name}
|
||||
onChange={(value) => onChange({ name: value })}
|
||||
placeholder="My Production Database"
|
||||
placeholder={namePlaceholder || "My Production Database"}
|
||||
aria-label="Connection Label"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -9,14 +9,17 @@ export interface DetailedConnectionFormProps {
|
||||
form: ConnectionFormData;
|
||||
onChange: (updates: Partial<ConnectionFormData>) => void;
|
||||
managedPreset?: "supabase" | "neon" | "planetscale" | null;
|
||||
/** Placeholder for the Connection Label when the field is empty, e.g. the
|
||||
* name derived from a pasted connection string. */
|
||||
namePlaceholder?: string;
|
||||
}
|
||||
|
||||
export function DetailedConnectionForm({ form, onChange, managedPreset }: DetailedConnectionFormProps) {
|
||||
export function DetailedConnectionForm({ form, onChange, managedPreset, namePlaceholder }: DetailedConnectionFormProps) {
|
||||
const [activeTab, setActiveTab] = useState<"general" | "tagsEnv" | "ssh">("general");
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<ConnectionMetadataRow form={form} onChange={onChange} />
|
||||
<ConnectionMetadataRow form={form} onChange={onChange} namePlaceholder={namePlaceholder} />
|
||||
<div>
|
||||
<div className="flex gap-6 border-b border-border mb-4">
|
||||
<button type="button" onClick={() => setActiveTab("general")} className={`pb-2 text-sm cursor-pointer transition-colors ${activeTab === "general" ? "text-text border-b-2 border-text" : "text-text-muted hover:text-text"}`}>General</button>
|
||||
|
||||
@@ -52,6 +52,60 @@ describe("NewConnectionScreen", () => {
|
||||
expect(screen.getByLabelText("User")).toHaveValue("u");
|
||||
});
|
||||
|
||||
it("paste a Neon URL pre-fills every required setting, including SSL mode", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<NewConnectionScreen />);
|
||||
await user.type(
|
||||
screen.getByLabelText(/connection uri/i),
|
||||
"postgresql://neondb_owner:npg_secret@ep-hidden-star-b3hqtp4x-pooler.c-4.ap-southeast-1.aws.neon.tech/neondb?sslmode=require&channel_binding=require",
|
||||
);
|
||||
// Every field the URL can supply is filled, so no manual cleanup is needed
|
||||
expect(screen.getByLabelText("Host")).toHaveValue(
|
||||
"ep-hidden-star-b3hqtp4x-pooler.c-4.ap-southeast-1.aws.neon.tech",
|
||||
);
|
||||
expect(screen.getByLabelText("Port")).toHaveValue(5432);
|
||||
expect(screen.getByLabelText("User")).toHaveValue("neondb_owner");
|
||||
expect(screen.getByLabelText("Password")).toHaveValue("npg_secret");
|
||||
expect(screen.getByLabelText("Database")).toHaveValue("neondb");
|
||||
// The label stays untouched but is suggested from the database name.
|
||||
expect(screen.getByLabelText(/connection label/i)).toHaveValue("");
|
||||
expect(screen.getByLabelText(/connection label/i)).toHaveAttribute(
|
||||
"placeholder",
|
||||
"neondb",
|
||||
);
|
||||
|
||||
// SSL mode is visible on the SSH / SSL tab...
|
||||
await user.click(screen.getByRole("button", { name: /ssh \/ ssl/i }));
|
||||
await user.click(screen.getByRole("button", { name: "SSL" }));
|
||||
expect(screen.getByRole("button", { name: "SSL Mode" })).toHaveTextContent("Require");
|
||||
|
||||
// ...and reaches the save payload with no extra clicks.
|
||||
await user.click(screen.getByText("Save Connection"));
|
||||
await waitFor(() => expect(createConnection).toHaveBeenCalledTimes(1));
|
||||
expect(createConnection).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: "neondb",
|
||||
db_type: "postgresql",
|
||||
ssl_mode: "require",
|
||||
username: "neondb_owner",
|
||||
password: "npg_secret",
|
||||
database: "neondb",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("a typed label wins over the URL-derived suggestion", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<NewConnectionScreen />);
|
||||
await user.type(screen.getByLabelText(/connection uri/i), "postgresql://u:p@localhost:5432/db");
|
||||
await user.type(screen.getByLabelText(/connection label/i), "My DB");
|
||||
await user.click(screen.getByText("Save Connection"));
|
||||
await waitFor(() => expect(createConnection).toHaveBeenCalledTimes(1));
|
||||
expect(createConnection).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: "My DB" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("clicking a provider tab reveals the form with that db_type", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<NewConnectionScreen />);
|
||||
@@ -60,6 +114,19 @@ describe("NewConnectionScreen", () => {
|
||||
expect(screen.getByLabelText("Port")).toHaveValue(3306);
|
||||
});
|
||||
|
||||
it("NeonDB card preselects Require SSL in the save payload", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<NewConnectionScreen />);
|
||||
await user.click(screen.getByRole("button", { name: /NeonDB/i }));
|
||||
await user.type(screen.getByLabelText(/connection label/i), "Neon prod");
|
||||
await user.type(screen.getByLabelText("Host"), "ep-x-pooler.eu.aws.neon.tech");
|
||||
await user.click(screen.getByText("Save Connection"));
|
||||
await waitFor(() => expect(createConnection).toHaveBeenCalledTimes(1));
|
||||
expect(createConnection).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: "Neon prod", ssl_mode: "require" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("SQLite tab swaps the URI input for a File Path input", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<NewConnectionScreen />);
|
||||
@@ -103,6 +170,20 @@ describe("NewConnectionScreen", () => {
|
||||
expect(notify).toHaveBeenCalledWith("Connection successful", "success");
|
||||
});
|
||||
|
||||
it("tests a pasted URL with its SSL mode", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<NewConnectionScreen />);
|
||||
await user.type(
|
||||
screen.getByLabelText(/connection uri/i),
|
||||
"postgresql://u:p@ep-x-pooler.eu.aws.neon.tech/db?sslmode=verify-full",
|
||||
);
|
||||
await user.click(screen.getByText("Test Connection"));
|
||||
await waitFor(() => expect(testConnection).toHaveBeenCalledTimes(1));
|
||||
expect(testConnection).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ ssl_mode: "verify-full" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("invokes onCancel when Back is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onCancel = vi.fn();
|
||||
|
||||
@@ -9,6 +9,8 @@ import { ProviderSetupGuide } from "./ProviderSetupGuide";
|
||||
import {
|
||||
parseConnectionString,
|
||||
detectProviderFromHost,
|
||||
suggestConnectionLabel,
|
||||
type ParsedConnectionString,
|
||||
} from "../../lib/connectionString";
|
||||
import { getProviderById, type ProviderId } from "../../lib/providers";
|
||||
import { validateConnectionInput } from "../../lib/utils";
|
||||
@@ -53,6 +55,10 @@ function createEmptyForm(
|
||||
database: null,
|
||||
use_keychain: true,
|
||||
ssh_password: null,
|
||||
ssl_mode: null,
|
||||
ssl_ca_path: null,
|
||||
ssl_cert_path: null,
|
||||
ssl_key_path: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -64,6 +70,40 @@ function getDefaultPort(dbType: string): number {
|
||||
);
|
||||
}
|
||||
|
||||
type ManagedPreset = "supabase" | "neon" | "planetscale" | null;
|
||||
|
||||
/** Managed-provider highlight for a parsed URL (Postgres/MySQL hosts only). */
|
||||
function providerForParsed(parsed: ParsedConnectionString): ManagedPreset {
|
||||
return parsed.db_type === "postgresql" || parsed.db_type === "mysql"
|
||||
? detectProviderFromHost(parsed.host)
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything a pasted connection string can populate, so the connection can be
|
||||
* tested and saved with no manual cleanup: connection type, host, port,
|
||||
* user/password/database, and the `?sslmode=` TLS mode.
|
||||
*
|
||||
* The URL's SSL mode wins when present; otherwise the mode the user already
|
||||
* picked under SSH / SSL is kept. The label is left blank when the user has not
|
||||
* typed one (the derived name is offered as a placeholder and used on save by
|
||||
* `buildPayload`), so a suggestion never clobbers or gets appended to.
|
||||
*/
|
||||
function parsedFormPatch(
|
||||
parsed: ParsedConnectionString,
|
||||
prev: ConnectionFormData,
|
||||
): Partial<ConnectionFormData> {
|
||||
return {
|
||||
db_type: parsed.db_type,
|
||||
host: parsed.host,
|
||||
port: parsed.port ?? getDefaultPort(parsed.db_type),
|
||||
username: parsed.username,
|
||||
password: parsed.password,
|
||||
database: parsed.database,
|
||||
ssl_mode: parsed.ssl_mode ?? prev.ssl_mode ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function NewConnectionScreen({
|
||||
defaultFolderId = null,
|
||||
prefilledConnectionString = "",
|
||||
@@ -73,9 +113,7 @@ export function NewConnectionScreen({
|
||||
onCancel,
|
||||
}: NewConnectionScreenProps) {
|
||||
const [stage, setStage] = useState<Stage>("entry");
|
||||
const [managedPreset, setManagedPreset] = useState<
|
||||
"supabase" | "neon" | "planetscale" | null
|
||||
>(null);
|
||||
const [managedPreset, setManagedPreset] = useState<ManagedPreset>(null);
|
||||
const [form, setForm] = useState<ConnectionFormData>(() =>
|
||||
createEmptyForm(
|
||||
defaultFolderId,
|
||||
@@ -86,6 +124,12 @@ export function NewConnectionScreen({
|
||||
const [saveLoading, setSaveLoading] = useState(false);
|
||||
const createConnection = useConnectionStore((s) => s.createConnection);
|
||||
const notify = useNotificationStore((s) => s.notify);
|
||||
// Name to use when the label is left blank: derived from the pasted URL
|
||||
// (database name, else host/file stem). Shown as the Connection Label's
|
||||
// placeholder and applied at save/test time, so a suggestion never
|
||||
// clobbers or gets appended to typed text.
|
||||
const parsedUri = parseConnectionString(form.connection_string);
|
||||
const suggestedName = parsedUri ? suggestConnectionLabel(parsedUri) : "";
|
||||
|
||||
const revealConfigured = useCallback(
|
||||
(updates: Partial<ConnectionFormData>) => {
|
||||
@@ -97,26 +141,17 @@ export function NewConnectionScreen({
|
||||
|
||||
const handleConnectionStringChange = useCallback((value: string) => {
|
||||
const parsed = parseConnectionString(value);
|
||||
if (parsed) {
|
||||
const provider =
|
||||
parsed.db_type === "postgresql" || parsed.db_type === "mysql"
|
||||
? detectProviderFromHost(parsed.host)
|
||||
: null;
|
||||
setManagedPreset(provider);
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
connection_string: value,
|
||||
db_type: parsed.db_type,
|
||||
host: parsed.host,
|
||||
port: parsed.port ?? getDefaultPort(parsed.db_type),
|
||||
username: parsed.username,
|
||||
password: parsed.password,
|
||||
database: parsed.database,
|
||||
}));
|
||||
} else {
|
||||
if (!parsed) {
|
||||
setManagedPreset(null);
|
||||
setForm((prev) => ({ ...prev, connection_string: value }));
|
||||
return;
|
||||
}
|
||||
setManagedPreset(providerForParsed(parsed));
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
connection_string: value,
|
||||
...parsedFormPatch(parsed, prev),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -150,7 +185,15 @@ export function NewConnectionScreen({
|
||||
? null
|
||||
: getDefaultPort(provider.dbType),
|
||||
...(provider.dbType === "sqlite" ? { host: "" } : {}),
|
||||
...(id === "planetscale" ? { ssl_mode: "verify-full" } : {}),
|
||||
// Managed providers refuse plaintext connections. Preselect
|
||||
// their documented mode so Test/Save works on the first try.
|
||||
// (PlanetScale/MySQL requires identity verification.)
|
||||
...(id === "planetscale"
|
||||
? { ssl_mode: "verify-full" as const }
|
||||
: {}),
|
||||
...(id === "supabase" || id === "neon"
|
||||
? { ssl_mode: "require" as const }
|
||||
: {}),
|
||||
});
|
||||
},
|
||||
[revealConfigured],
|
||||
@@ -166,22 +209,11 @@ export function NewConnectionScreen({
|
||||
const value = updates.connection_string;
|
||||
const parsed = parseConnectionString(value);
|
||||
if (parsed) {
|
||||
const provider =
|
||||
parsed.db_type === "postgresql" || parsed.db_type === "mysql"
|
||||
? detectProviderFromHost(parsed.host)
|
||||
: null;
|
||||
setManagedPreset(provider);
|
||||
setManagedPreset(providerForParsed(parsed));
|
||||
return {
|
||||
...prev,
|
||||
...updates,
|
||||
db_type: parsed.db_type,
|
||||
host: parsed.host,
|
||||
port:
|
||||
parsed.port ??
|
||||
getDefaultPort(parsed.db_type),
|
||||
username: parsed.username,
|
||||
password: parsed.password,
|
||||
database: parsed.database,
|
||||
...parsedFormPatch(parsed, prev),
|
||||
};
|
||||
}
|
||||
return { ...prev, ...updates };
|
||||
@@ -194,7 +226,7 @@ export function NewConnectionScreen({
|
||||
|
||||
const buildPayload = useCallback((): ConnectionInput => {
|
||||
return {
|
||||
name: form.name,
|
||||
name: form.name.trim() || suggestedName,
|
||||
db_type: form.db_type,
|
||||
host: form.host,
|
||||
port: form.port,
|
||||
@@ -213,8 +245,12 @@ export function NewConnectionScreen({
|
||||
ssh_private_key_path: form.ssh_private_key ?? null,
|
||||
ssh_password: form.ssh_password ?? null,
|
||||
ssh_passphrase: form.ssh_passphrase ?? null,
|
||||
ssl_mode: form.ssl_mode ?? null,
|
||||
ssl_ca_path: form.ssl_ca_path ?? null,
|
||||
ssl_cert_path: form.ssl_cert_path ?? null,
|
||||
ssl_key_path: form.ssl_key_path ?? null,
|
||||
};
|
||||
}, [form]);
|
||||
}, [form, suggestedName]);
|
||||
|
||||
const validate = useCallback((): string | null => {
|
||||
const result = validateConnectionInput(buildPayload());
|
||||
@@ -336,6 +372,7 @@ export function NewConnectionScreen({
|
||||
<DetailedConnectionForm
|
||||
form={form}
|
||||
onChange={updateForm}
|
||||
namePlaceholder={suggestedName || undefined}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { SslFields } from "./SslFields";
|
||||
|
||||
const notify = vi.fn();
|
||||
@@ -35,4 +35,33 @@ describe("SslFields", () => {
|
||||
expect(screen.queryByLabelText("Client Certificate")).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("Client Key")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("writes certificate paths under the names the backend persists", () => {
|
||||
// Regression guard: these used to be ssl_ca_cert / ssl_client_cert /
|
||||
// ssl_client_key, which the Rust ConnectionInput silently ignores — the
|
||||
// connection was then saved with TLS disabled.
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<SslFields
|
||||
values={{ ssl_mode: "verify-full", ssl_ca_path: "/tmp/ca.pem" }}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText("CA Certificate")).toHaveValue("/tmp/ca.pem");
|
||||
fireEvent.change(screen.getByLabelText("CA Certificate"), {
|
||||
target: { value: "/tmp/root.pem" },
|
||||
});
|
||||
expect(onChange).toHaveBeenLastCalledWith({ ssl_ca_path: "/tmp/root.pem" });
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Client Certificate"), {
|
||||
target: { value: "/tmp/client.pem" },
|
||||
});
|
||||
expect(onChange).toHaveBeenLastCalledWith({ ssl_cert_path: "/tmp/client.pem" });
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Client Key"), {
|
||||
target: { value: "/tmp/client.key" },
|
||||
});
|
||||
expect(onChange).toHaveBeenLastCalledWith({ ssl_key_path: "/tmp/client.key" });
|
||||
});
|
||||
});
|
||||
@@ -55,14 +55,14 @@ export function SslFields({ values, onChange }: SslFieldsProps) {
|
||||
<label className="block text-sm text-text mb-1.5">CA Certificate</label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={(values.ssl_ca_cert as string) ?? ""}
|
||||
onChange={(value) => onChange({ ssl_ca_cert: value })}
|
||||
value={(values.ssl_ca_path as string) ?? ""}
|
||||
onChange={(value) => onChange({ ssl_ca_path: value })}
|
||||
placeholder="/path/to/ca-cert.pem"
|
||||
aria-label="CA Certificate"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handlePickFile("ssl_ca_cert")}
|
||||
onClick={() => handlePickFile("ssl_ca_path")}
|
||||
className="px-4 py-2 rounded-full bg-surface border border-border text-sm text-text hover:bg-surface-raised transition-colors cursor-pointer"
|
||||
>
|
||||
Browse
|
||||
@@ -74,14 +74,14 @@ export function SslFields({ values, onChange }: SslFieldsProps) {
|
||||
<label className="block text-sm text-text mb-1.5">Client Certificate</label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={(values.ssl_client_cert as string) ?? ""}
|
||||
onChange={(value) => onChange({ ssl_client_cert: value })}
|
||||
value={(values.ssl_cert_path as string) ?? ""}
|
||||
onChange={(value) => onChange({ ssl_cert_path: value })}
|
||||
placeholder="/path/to/client-cert.pem"
|
||||
aria-label="Client Certificate"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handlePickFile("ssl_client_cert")}
|
||||
onClick={() => handlePickFile("ssl_cert_path")}
|
||||
className="px-4 py-2 rounded-full bg-surface border border-border text-sm text-text hover:bg-surface-raised transition-colors cursor-pointer"
|
||||
>
|
||||
Browse
|
||||
@@ -93,14 +93,14 @@ export function SslFields({ values, onChange }: SslFieldsProps) {
|
||||
<label className="block text-sm text-text mb-1.5">Client Key</label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={(values.ssl_client_key as string) ?? ""}
|
||||
onChange={(value) => onChange({ ssl_client_key: value })}
|
||||
value={(values.ssl_key_path as string) ?? ""}
|
||||
onChange={(value) => onChange({ ssl_key_path: value })}
|
||||
placeholder="/path/to/client-key.pem"
|
||||
aria-label="Client Key"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handlePickFile("ssl_client_key")}
|
||||
onClick={() => handlePickFile("ssl_key_path")}
|
||||
className="px-4 py-2 rounded-full bg-surface border border-border text-sm text-text hover:bg-surface-raised transition-colors cursor-pointer"
|
||||
>
|
||||
Browse
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { DbType } from "../../lib/types";
|
||||
import type { Environment } from "./EnvironmentSelect";
|
||||
import type { SslMode } from "../../lib/connectionString";
|
||||
|
||||
export interface ConnectionFormData {
|
||||
name: string;
|
||||
@@ -24,4 +25,13 @@ export interface ConnectionFormData {
|
||||
ssh_private_key?: string | null;
|
||||
ssh_password: string | null;
|
||||
ssh_passphrase?: string | null;
|
||||
// SSL/TLS fields. These must live on the form data (and survive into the
|
||||
// submit payload) or the SSH/SSL tab's selections are silently dropped — the
|
||||
// backend then connects with TLS disabled, which managed providers (Neon,
|
||||
// Supabase, PlanetScale) reject with "connection is insecure".
|
||||
// `null` = unset, which the backend treats as TLS disabled.
|
||||
ssl_mode?: SslMode | null;
|
||||
ssl_ca_path?: string | null;
|
||||
ssl_cert_path?: string | null;
|
||||
ssl_key_path?: string | null;
|
||||
}
|
||||
|
||||
@@ -10,18 +10,23 @@ const { updateConnection, loadAll } = vi.hoisted(() => ({
|
||||
}));
|
||||
|
||||
vi.mock("../../stores/connectionStore", () => ({
|
||||
useConnectionStore: (sel: (s: any) => any) =>
|
||||
sel({ updateConnection, loadAll, folders: [], tags: [] }),
|
||||
useConnectionStore: Object.assign(
|
||||
(sel: (s: any) => any) =>
|
||||
sel({ updateConnection, loadAll, folders: [], tags: [] }),
|
||||
{ getState: () => ({ getConnectionPassword: async () => "pw" }) },
|
||||
),
|
||||
}));
|
||||
vi.mock("../../lib/commands", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../lib/commands")>();
|
||||
return {
|
||||
...actual,
|
||||
updateConnection: vi.fn(),
|
||||
testConnection: vi.fn(),
|
||||
testConnection: vi.fn().mockResolvedValue({ ok: true }),
|
||||
saveConnectionPassword: vi.fn(),
|
||||
saveConnectionSshPassword: vi.fn(),
|
||||
saveConnectionSshPassphrase: vi.fn(),
|
||||
getConnectionSshPassword: vi.fn().mockResolvedValue(null),
|
||||
getConnectionSshPassphrase: vi.fn().mockResolvedValue(null),
|
||||
deleteConnectionPassword: vi.fn(),
|
||||
};
|
||||
});
|
||||
@@ -121,4 +126,62 @@ describe("EditConnectionModal", () => {
|
||||
);
|
||||
expect(vi.mocked(commands.saveConnectionPassword)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("persists the existing SSL settings on save", async () => {
|
||||
render(
|
||||
<EditConnectionModal
|
||||
connection={{
|
||||
...baseConn,
|
||||
ssl_mode: "verify-full",
|
||||
ssl_ca_path: "/tmp/ca.pem",
|
||||
ssl_cert_path: "/tmp/client.pem",
|
||||
ssl_key_path: "/tmp/client.key",
|
||||
}}
|
||||
open={true}
|
||||
onClose={() => {}}
|
||||
onSaved={() => {}}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: /^save$/i }));
|
||||
await waitFor(() => expect(vi.mocked(commands.updateConnection)).toHaveBeenCalled());
|
||||
expect(vi.mocked(commands.updateConnection)).toHaveBeenCalledWith(
|
||||
"c1",
|
||||
expect.objectContaining({
|
||||
ssl_mode: "verify-full",
|
||||
ssl_ca_path: "/tmp/ca.pem",
|
||||
ssl_cert_path: "/tmp/client.pem",
|
||||
ssl_key_path: "/tmp/client.key",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("tests with the same SSL + SSH config it would save", async () => {
|
||||
render(
|
||||
<EditConnectionModal
|
||||
connection={{
|
||||
...baseConn,
|
||||
ssl_mode: "require",
|
||||
ssh_host: "jump.example.com",
|
||||
ssh_port: 22,
|
||||
ssh_user: "tunnel",
|
||||
ssh_auth_method: "key",
|
||||
ssh_private_key_path: "/tmp/id_ed25519",
|
||||
}}
|
||||
open={true}
|
||||
onClose={() => {}}
|
||||
onSaved={() => {}}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: /^test$/i }));
|
||||
await waitFor(() => expect(vi.mocked(commands.testConnection)).toHaveBeenCalled());
|
||||
expect(vi.mocked(commands.testConnection)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
ssl_mode: "require",
|
||||
ssh_host: "jump.example.com",
|
||||
ssh_user: "tunnel",
|
||||
ssh_auth_method: "key",
|
||||
ssh_private_key_path: "/tmp/id_ed25519",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -4,9 +4,9 @@ import { Button } from "../ui/Button";
|
||||
import { DetailedConnectionForm } from "../connections/DetailedConnectionForm";
|
||||
import { useConnectionStore } from "../../stores/connectionStore";
|
||||
import { useNotificationStore } from "../../stores/notificationStore";
|
||||
import { updateConnection, testConnection, saveConnectionSshPassword, saveConnectionSshPassphrase } from "../../lib/commands";
|
||||
import { updateConnection, testConnection, saveConnectionSshPassword, saveConnectionSshPassphrase, getConnectionSshPassword, getConnectionSshPassphrase } from "../../lib/commands";
|
||||
import { persistDbPassword } from "../../lib/keychain";
|
||||
import { detectProviderFromHost } from "../../lib/connectionString";
|
||||
import { detectProviderFromHost, type SslMode } from "../../lib/connectionString";
|
||||
import type { Connection, ConnectionInput } from "../../lib/types";
|
||||
import type { ConnectionFormData } from "../connections/connectionFormData";
|
||||
|
||||
@@ -47,6 +47,10 @@ export function EditConnectionModal({
|
||||
ssh_private_key: connection.ssh_private_key_path ?? null,
|
||||
ssh_password: null,
|
||||
ssh_passphrase: null,
|
||||
ssl_mode: (connection.ssl_mode as SslMode | null | undefined) ?? null,
|
||||
ssl_ca_path: connection.ssl_ca_path ?? null,
|
||||
ssl_cert_path: connection.ssl_cert_path ?? null,
|
||||
ssl_key_path: connection.ssl_key_path ?? null,
|
||||
}));
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
@@ -75,6 +79,10 @@ export function EditConnectionModal({
|
||||
ssh_private_key_path: form.ssh_private_key ?? null,
|
||||
ssh_password: form.ssh_password ?? null,
|
||||
ssh_passphrase: form.ssh_passphrase ?? null,
|
||||
ssl_mode: form.ssl_mode ?? null,
|
||||
ssl_ca_path: form.ssl_ca_path ?? null,
|
||||
ssl_cert_path: form.ssl_cert_path ?? null,
|
||||
ssl_key_path: form.ssl_key_path ?? null,
|
||||
};
|
||||
const updated = await updateConnection(connection.id, input);
|
||||
await persistDbPassword(connection.id, form.use_keychain, form.password).catch(() => {});
|
||||
@@ -104,6 +112,18 @@ export function EditConnectionModal({
|
||||
if (!password) {
|
||||
password = await useConnectionStore.getState().getConnectionPassword(connection.id).catch(() => null);
|
||||
}
|
||||
// SSH secrets live in the keychain only, so a tunneled connection can be
|
||||
// tested without re-entering them.
|
||||
const sshPassword =
|
||||
form.ssh_password ??
|
||||
(form.ssh_host
|
||||
? await getConnectionSshPassword(connection.id).catch(() => null)
|
||||
: null);
|
||||
const sshPassphrase =
|
||||
form.ssh_passphrase ??
|
||||
(form.ssh_host
|
||||
? await getConnectionSshPassphrase(connection.id).catch(() => null)
|
||||
: null);
|
||||
|
||||
const result = await testConnection({
|
||||
name: form.name,
|
||||
@@ -116,7 +136,19 @@ export function EditConnectionModal({
|
||||
folder_id: form.folder_id,
|
||||
environment: form.environment,
|
||||
tag_ids: form.tag_ids,
|
||||
ssh_password: form.ssh_password ?? null,
|
||||
// Test the same config Save would persist — a tunneled/SSL connection
|
||||
// must be probed through its tunnel and TLS mode.
|
||||
ssh_host: form.ssh_host ?? null,
|
||||
ssh_port: form.ssh_port ?? null,
|
||||
ssh_user: form.ssh_user ?? null,
|
||||
ssh_auth_method: form.ssh_auth_method ?? null,
|
||||
ssh_private_key_path: form.ssh_private_key ?? null,
|
||||
ssh_password: sshPassword,
|
||||
ssh_passphrase: sshPassphrase,
|
||||
ssl_mode: form.ssl_mode ?? null,
|
||||
ssl_ca_path: form.ssl_ca_path ?? null,
|
||||
ssl_cert_path: form.ssl_cert_path ?? null,
|
||||
ssl_key_path: form.ssl_key_path ?? null,
|
||||
});
|
||||
if (result.ok) {
|
||||
notify("Connection successful", "success");
|
||||
|
||||
@@ -64,4 +64,41 @@ describe("RestorePage DB-aware", () => {
|
||||
expect(screen.queryByText(/pg_restore/i)).toBeNull();
|
||||
expect(screen.queryByText(/mysqldump/i)).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the MySQL restore form even when no MySQL client is installed", async () => {
|
||||
// Restore is in-process since Task 7, so a missing mariadb/mysql client must
|
||||
// no longer hide the form.
|
||||
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,
|
||||
});
|
||||
render(<RestorePage connectionId="c4" />);
|
||||
|
||||
// The tools check is async; wait until the configuration card is actually up.
|
||||
await waitFor(() => expect(screen.getByText(/Backup File/i)).toBeInTheDocument());
|
||||
expect(screen.getByText(/no MySQL client installation required/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("explains what Clean does for MySQL", async () => {
|
||||
mockConnections.push({ id: "c5", db_type: "mysql", name: "m", database: "db1" });
|
||||
vi.spyOn(commands, "detectMysqlTools").mockResolvedValue(mysqlToolsOk);
|
||||
render(<RestorePage connectionId="c5" />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText(/Backup File/i)).toBeInTheDocument());
|
||||
expect(screen.getByText(/only the objects this file defines/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("warns that a failed MySQL restore cannot be rolled back", async () => {
|
||||
mockConnections.push({ id: "c6", db_type: "mysql", name: "m", database: "db1" });
|
||||
vi.spyOn(commands, "detectMysqlTools").mockResolvedValue(mysqlToolsOk);
|
||||
render(<RestorePage connectionId="c6" />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText(/Backup File/i)).toBeInTheDocument());
|
||||
expect(screen.getByText(/can't be rolled back/i)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -10,11 +10,10 @@ import {
|
||||
detectPgTools,
|
||||
pgRestore,
|
||||
getSchemas,
|
||||
detectMysqlTools,
|
||||
mysqlRestore,
|
||||
sqliteRestore,
|
||||
} from "../../lib/commands";
|
||||
import type { PgToolStatus, MySqlToolStatus, BackupJob } from "../../lib/types";
|
||||
import type { PgToolStatus, BackupJob } from "../../lib/types";
|
||||
|
||||
interface RestorePageProps {
|
||||
connectionId: string;
|
||||
@@ -60,7 +59,6 @@ export function RestorePage({ connectionId }: RestorePageProps) {
|
||||
const [schema, setSchema] = useState("");
|
||||
const [confirmed, setConfirmed] = useState(false);
|
||||
const [pgToolStatus, setPgToolStatus] = useState<PgToolStatus | null>(null);
|
||||
const [mysqlToolStatus, setMysqlToolStatus] = useState<MySqlToolStatus | null>(null);
|
||||
const [checkingTools, setCheckingTools] = useState(true);
|
||||
const [availableSchemas, setAvailableSchemas] = useState<string[]>([]);
|
||||
|
||||
@@ -93,7 +91,6 @@ export function RestorePage({ connectionId }: RestorePageProps) {
|
||||
setCheckingTools(true);
|
||||
setConfirmed(false);
|
||||
setPgToolStatus(null);
|
||||
setMysqlToolStatus(null);
|
||||
setAvailableSchemas([]);
|
||||
|
||||
if (isPg) {
|
||||
@@ -115,19 +112,10 @@ export function RestorePage({ connectionId }: RestorePageProps) {
|
||||
.then((schemas) => setAvailableSchemas(schemas))
|
||||
.catch(() => setAvailableSchemas([]));
|
||||
} else if (isMysql) {
|
||||
detectMysqlTools()
|
||||
.then((status) => setMysqlToolStatus(status))
|
||||
.catch(() =>
|
||||
setMysqlToolStatus({
|
||||
mysqldumpFound: false,
|
||||
mysqlFound: false,
|
||||
mysqldumpVersion: null,
|
||||
mysqlVersion: null,
|
||||
mysqldumpSource: null,
|
||||
mysqlSource: null,
|
||||
}),
|
||||
)
|
||||
.finally(() => setCheckingTools(false));
|
||||
// MySQL restore runs in-process over the sqlx driver connection, so
|
||||
// there is no client to probe for or wait on here (Backup/Sync
|
||||
// still probe via detectMysqlTools for mysqldump).
|
||||
setCheckingTools(false);
|
||||
|
||||
getSchemas(connectionId)
|
||||
.then((schemas) => setAvailableSchemas(schemas))
|
||||
@@ -214,16 +202,15 @@ export function RestorePage({ connectionId }: RestorePageProps) {
|
||||
runWithProgress,
|
||||
]);
|
||||
|
||||
// pg_restore is still required for PostgreSQL. MySQL restore runs in-process
|
||||
// via the sqlx driver, so a missing mariadb/mysql client must not gate it —
|
||||
// that client could never authenticate to MySQL 8 anyway (#41 follow-up).
|
||||
const toolsMissing = isPg
|
||||
? pgToolStatus && !pgToolStatus.pg_restore_found
|
||||
: isMysql
|
||||
? mysqlToolStatus && !mysqlToolStatus.mysqlFound
|
||||
: false;
|
||||
: false;
|
||||
const toolsBundled = isPg
|
||||
? pgToolStatus?.pg_restore_source === "bundled"
|
||||
: isMysql
|
||||
? mysqlToolStatus?.mysqlSource === "bundled"
|
||||
: false;
|
||||
: false;
|
||||
const canStart = filePath && confirmed && !isRunning;
|
||||
|
||||
const checkingMessage = isPg
|
||||
@@ -282,6 +269,13 @@ export function RestorePage({ connectionId }: RestorePageProps) {
|
||||
|
||||
{!checkingTools && !toolsMissing && (
|
||||
<>
|
||||
{isMysql && (
|
||||
<p className="text-[11px] text-text-muted/70 -mb-2">
|
||||
Restores run in-process over the driver connection — no MySQL client
|
||||
installation required.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Configuration card */}
|
||||
<div className="p-5 space-y-5">
|
||||
{/* Format (PostgreSQL only) */}
|
||||
@@ -379,12 +373,21 @@ export function RestorePage({ connectionId }: RestorePageProps) {
|
||||
disabled={isPg && format === "plain"}
|
||||
className="rounded bg-surface border-border accent-accent w-4 h-4 cursor-pointer disabled:cursor-not-allowed"
|
||||
/>
|
||||
<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>
|
||||
{isMysql ? (
|
||||
<span className="text-sm text-text-muted group-hover:text-text transition-colors">
|
||||
Clean{" "}
|
||||
<code className="text-[11px] text-text-muted/60 bg-surface-raised rounded px-1.5 py-0.5">
|
||||
drops only the objects this file defines
|
||||
</code>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm text-text-muted group-hover:text-text transition-colors">
|
||||
Clean{" "}
|
||||
<code className="text-[11px] text-text-muted/60 bg-surface-raised rounded px-1.5 py-0.5">
|
||||
DROP before CREATE
|
||||
</code>
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
)}
|
||||
{isPg && format === "plain" && (
|
||||
@@ -414,6 +417,12 @@ export function RestorePage({ connectionId }: RestorePageProps) {
|
||||
be undone.
|
||||
</span>
|
||||
</label>
|
||||
{isMysql && (
|
||||
<p className="mt-2 text-[11px] text-red-400/80">
|
||||
MySQL DDL commits as it runs and can't be rolled back — a failed
|
||||
restore may leave this database partially applied.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Progress */}
|
||||
|
||||
@@ -251,7 +251,7 @@ export function TabBar({ onCommitted }: { onCommitted?: () => void } = {}) {
|
||||
<div className="flex shrink-0 items-center gap-1.5 border-l border-border px-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={openQueryTab}
|
||||
onClick={() => openQueryTab()}
|
||||
aria-label="New query tab"
|
||||
className="flex items-center gap-1.5 rounded-md bg-accent px-2.5 py-1 text-xs font-medium text-white transition-colors hover:bg-accent-hover cursor-pointer"
|
||||
>
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { PendingSqlFileBanner } from "./PendingSqlFileBanner";
|
||||
import { useUiStore } from "../../stores/uiStore";
|
||||
|
||||
beforeEach(() => {
|
||||
useUiStore.setState({ pendingSqlFile: null });
|
||||
});
|
||||
|
||||
describe("PendingSqlFileBanner", () => {
|
||||
it("renders nothing when there is no pending file", () => {
|
||||
const { container } = render(<PendingSqlFileBanner />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("names the file and tells the user to open a connection", () => {
|
||||
useUiStore.setState({
|
||||
pendingSqlFile: { path: "/tmp/a.sql", name: "student_db.sql", content: "SELECT 1;" },
|
||||
});
|
||||
render(<PendingSqlFileBanner />);
|
||||
expect(screen.getByText(/student_db\.sql/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/open a connection/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { FileCode2, X } from "lucide-react";
|
||||
import { useUiStore } from "../../stores/uiStore";
|
||||
|
||||
/**
|
||||
* Shown when a `.sql` file was opened from the OS but no database connection is
|
||||
* active. The file is already loaded in memory; picking a connection in the
|
||||
* Home screen delivers it (see `useSqlFileOpen`).
|
||||
*/
|
||||
export function PendingSqlFileBanner() {
|
||||
const pending = useUiStore((s) => s.pendingSqlFile);
|
||||
const clear = useUiStore((s) => s.clearPendingSqlFile);
|
||||
if (!pending) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
className="flex items-center gap-2 border-b border-border bg-surface-raised px-3 py-1.5 text-xs text-text-muted"
|
||||
>
|
||||
<FileCode2 className="h-3.5 w-3.5 shrink-0 text-accent" />
|
||||
<span className="truncate">
|
||||
<span className="font-medium text-text">{pending.name}</span> is ready — open a connection
|
||||
to use it.
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={clear}
|
||||
aria-label="Dismiss pending SQL file"
|
||||
className="ml-auto flex items-center rounded px-1 py-0.5 hover:bg-surface hover:text-text transition-colors cursor-pointer"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
|
||||
const listeners: Record<string, (e: unknown) => void> = {};
|
||||
vi.mock("@tauri-apps/api/event", () => ({
|
||||
listen: vi.fn(async (name: string, cb: (e: unknown) => void) => {
|
||||
listeners[name] = cb;
|
||||
return () => {};
|
||||
}),
|
||||
}));
|
||||
|
||||
const takePendingSqlFiles = vi.hoisted(() => vi.fn());
|
||||
vi.mock("../lib/commands", () => ({
|
||||
takePendingSqlFiles: () => takePendingSqlFiles(),
|
||||
}));
|
||||
|
||||
import { useSqlFileOpen } from "./useSqlFileOpen";
|
||||
import { useUiStore } from "../stores/uiStore";
|
||||
import { useDbViewerStore } from "../stores/dbViewerStore";
|
||||
import { useNotificationStore } from "../stores/notificationStore";
|
||||
|
||||
const file = { path: "/tmp/student_db.sql", name: "student_db.sql", content: "SELECT 1;" };
|
||||
|
||||
beforeEach(() => {
|
||||
takePendingSqlFiles.mockReset();
|
||||
useUiStore.setState({ activeConnectionId: null, pendingSqlFile: null, activeView: "home" });
|
||||
useDbViewerStore.setState({ tabs: [], activeTabId: null });
|
||||
});
|
||||
|
||||
describe("useSqlFileOpen", () => {
|
||||
it("opens a tab when a connection is active", async () => {
|
||||
takePendingSqlFiles.mockResolvedValue([file]);
|
||||
useUiStore.setState({ activeConnectionId: "conn-1" });
|
||||
|
||||
renderHook(() => useSqlFileOpen());
|
||||
|
||||
await waitFor(() => {
|
||||
const tab = useDbViewerStore.getState().tabs.slice(-1)[0];
|
||||
expect(tab?.query).toBe("SELECT 1;");
|
||||
});
|
||||
expect(useDbViewerStore.getState().tabs.slice(-1)[0]?.table).toBe("student_db.sql");
|
||||
expect(useUiStore.getState().pendingSqlFile).toBeNull();
|
||||
});
|
||||
|
||||
it("stashes the file and prompts when no connection is active", async () => {
|
||||
takePendingSqlFiles.mockResolvedValue([file]);
|
||||
|
||||
renderHook(() => useSqlFileOpen());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(useUiStore.getState().pendingSqlFile).toEqual(file);
|
||||
});
|
||||
expect(useDbViewerStore.getState().tabs).toHaveLength(0);
|
||||
expect(useUiStore.getState().activeView).toBe("home");
|
||||
});
|
||||
|
||||
it("drains the buffer again when the nudge event fires", async () => {
|
||||
takePendingSqlFiles.mockResolvedValue([]);
|
||||
useUiStore.setState({ activeConnectionId: "conn-1" });
|
||||
|
||||
renderHook(() => useSqlFileOpen());
|
||||
await waitFor(() => expect(takePendingSqlFiles).toHaveBeenCalledTimes(1));
|
||||
|
||||
takePendingSqlFiles.mockResolvedValue([file]);
|
||||
listeners["sql-file-opened"]?.({});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(useDbViewerStore.getState().tabs.slice(-1)[0]?.query).toBe("SELECT 1;");
|
||||
});
|
||||
});
|
||||
|
||||
it("opens the stashed file once a connection becomes active", async () => {
|
||||
takePendingSqlFiles.mockResolvedValue([file]);
|
||||
renderHook(() => useSqlFileOpen());
|
||||
|
||||
await waitFor(() => expect(useUiStore.getState().pendingSqlFile).toEqual(file));
|
||||
|
||||
useUiStore.setState({ activeConnectionId: "conn-2" });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(useDbViewerStore.getState().tabs.slice(-1)[0]?.query).toBe("SELECT 1;");
|
||||
});
|
||||
expect(useUiStore.getState().pendingSqlFile).toBeNull();
|
||||
});
|
||||
|
||||
it("surfaces a read failure as a notification instead of throwing", async () => {
|
||||
takePendingSqlFiles.mockRejectedValue(new Error("a.sql is not valid UTF-8"));
|
||||
useUiStore.setState({ activeConnectionId: "conn-1" });
|
||||
|
||||
renderHook(() => useSqlFileOpen());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(useNotificationStore.getState().notifications.slice(-1)[0]?.message).toContain("UTF-8");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useEffect } from "react";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { takePendingSqlFiles } from "../lib/commands";
|
||||
import { useUiStore } from "../stores/uiStore";
|
||||
import { useDbViewerStore } from "../stores/dbViewerStore";
|
||||
import { useNotificationStore } from "../stores/notificationStore";
|
||||
import type { PendingSqlFile } from "../lib/types";
|
||||
|
||||
/**
|
||||
* Turn OS "open this .sql file with Gridline" requests into editor tabs.
|
||||
*
|
||||
* Every platform path (macOS RunEvent::Opened, Windows/Linux argv, the
|
||||
* single-instance forward) ends up in a Rust buffer; this hook only ever
|
||||
* *drains* that buffer. That is deliberate: draining is idempotent, so a cold
|
||||
* start, a warm start, and a webview reload all converge on the same code path
|
||||
* and there is no cold-start race to handle.
|
||||
*
|
||||
* Opening never executes: the script lands in a query tab and runs only when
|
||||
* the user presses Run, which passes through the destructive-query guard.
|
||||
*/
|
||||
export function useSqlFileOpen(): void {
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
let unlisten: (() => void) | undefined;
|
||||
|
||||
const deliver = async () => {
|
||||
let files: PendingSqlFile[];
|
||||
try {
|
||||
files = await takePendingSqlFiles();
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
useNotificationStore.getState().notify(`Could not open SQL file: ${msg}`, "error");
|
||||
return;
|
||||
}
|
||||
if (disposed) return;
|
||||
for (const file of files) openOrStash(file);
|
||||
};
|
||||
|
||||
void deliver();
|
||||
void listen("sql-file-opened", () => {
|
||||
void deliver();
|
||||
})
|
||||
.then((un) => {
|
||||
unlisten = un;
|
||||
})
|
||||
.catch(() => {
|
||||
// No Tauri event bridge (browser / tests): `listen` rejects there. The
|
||||
// mount-time drain already ran, and the Rust buffer survives until a
|
||||
// real webview asks for it, so there is nothing to recover here. The
|
||||
// drain is idempotent, so a missed nudge is not a correctness problem —
|
||||
// a warm webview still drains on mount and on the next event.
|
||||
});
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
unlisten?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// A stashed file has no target until the user picks a connection, so
|
||||
// deliver it as soon as one becomes active.
|
||||
const pending = useUiStore((s) => s.pendingSqlFile);
|
||||
const activeConnectionId = useUiStore((s) => s.activeConnectionId);
|
||||
useEffect(() => {
|
||||
if (pending && activeConnectionId) {
|
||||
openQueryTabFor(pending);
|
||||
useUiStore.getState().clearPendingSqlFile();
|
||||
}
|
||||
}, [pending, activeConnectionId]);
|
||||
}
|
||||
|
||||
function openOrStash(file: PendingSqlFile): void {
|
||||
const { activeConnectionId } = useUiStore.getState();
|
||||
if (!activeConnectionId) {
|
||||
// Guide rather than error: a Finder double-click should not dead-end.
|
||||
useUiStore.getState().setPendingSqlFile(file);
|
||||
useUiStore.getState().setActiveView("home");
|
||||
useNotificationStore
|
||||
.getState()
|
||||
.notify(`${file.name} is ready — open a connection to use it.`, "info");
|
||||
return;
|
||||
}
|
||||
openQueryTabFor(file);
|
||||
}
|
||||
|
||||
function openQueryTabFor(file: PendingSqlFile): void {
|
||||
useDbViewerStore.getState().openQueryTab({
|
||||
sql: file.content,
|
||||
label: file.name,
|
||||
filePath: file.path,
|
||||
});
|
||||
useUiStore.getState().setActiveView("db-viewer");
|
||||
}
|
||||
@@ -1,11 +1,21 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import tauriConf from "../../src-tauri/tauri.conf.json";
|
||||
|
||||
describe("tauri bundle config (v0.8.0)", () => {
|
||||
describe("tauri bundle config (v0.8.2)", () => {
|
||||
it("declares bundled pg_tools resources", () => {
|
||||
expect(tauriConf.bundle.resources).toContain("resources/pg_tools/*");
|
||||
});
|
||||
it("version is 0.8.0", () => {
|
||||
expect(tauriConf.version).toBe("0.8.0");
|
||||
it("version is 0.8.2", () => {
|
||||
expect(tauriConf.version).toBe("0.8.2");
|
||||
});
|
||||
it("declares a .sql file association with alternate rank", () => {
|
||||
const assoc = tauriConf.bundle.fileAssociations;
|
||||
expect(Array.isArray(assoc)).toBe(true);
|
||||
const sql = (assoc as { ext?: string[]; rank?: string; role?: string }[])
|
||||
.find((a) => a.ext?.includes("sql"));
|
||||
expect(sql, "a .sql association must be declared").toBeDefined();
|
||||
// Alternate: appear under "Open With" without hijacking the default handler.
|
||||
expect(sql!.rank).toBe("Alternate");
|
||||
expect(sql!.role).toBe("Editor");
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { Connection, ConnectionInput, ConnectionTestResult, Folder, FolderInput, Tag, TagInput, Settings, ImportResult, TableInfo, QueryResult, BackupOptions, RestoreOptions, SyncOptions, PgToolStatus, MySqlToolStatus, MySqlBackupOptions, MySqlRestoreOptions, SqliteBackupOptions, SqliteRestoreOptions, FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo, SchemaGraph, IndexInfo, ConstraintInfo, RecentConnection, ObjectSearchHit, DependencyInfo, RoleInfo, PrivilegeEntry, RebuildReadiness, MaintenanceResult, TablespaceInfo, ColumnInfo, HypertableListResponse, MultiQueryResult } from "./types";
|
||||
import type { Connection, ConnectionInput, ConnectionTestResult, Folder, FolderInput, Tag, TagInput, Settings, ImportResult, TableInfo, QueryResult, BackupOptions, RestoreOptions, SyncOptions, PgToolStatus, MySqlToolStatus, MySqlBackupOptions, MySqlRestoreOptions, SqliteBackupOptions, SqliteRestoreOptions, FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo, SchemaGraph, IndexInfo, ConstraintInfo, RecentConnection, ObjectSearchHit, DependencyInfo, RoleInfo, PrivilegeEntry, RebuildReadiness, MaintenanceResult, TablespaceInfo, ColumnInfo, HypertableListResponse, MultiQueryResult, PendingSqlFile } from "./types";
|
||||
import type { FilterRule, SortRule } from "../stores/dbViewerStore";
|
||||
import type { ChangePayload } from "./changePayload";
|
||||
import type { DiffReport } from "./types";
|
||||
@@ -296,6 +296,11 @@ export async function executeQueryMulti(
|
||||
return invoke<MultiQueryResult>("execute_query_multi", { connectionId, query, page, pageSize });
|
||||
}
|
||||
|
||||
/** Drain the OS-delivered `.sql` open requests buffered in the Rust backend. */
|
||||
export async function takePendingSqlFiles(): Promise<PendingSqlFile[]> {
|
||||
return invoke<PendingSqlFile[]>("take_pending_sql_files");
|
||||
}
|
||||
|
||||
export async function exportQueryToFile(
|
||||
connectionId: string,
|
||||
sql: string,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { parseConnectionString, looksLikeConnectionString, detectProviderFromHost, buildConnectionUrl } from "./connectionString";
|
||||
import { parseConnectionString, looksLikeConnectionString, detectProviderFromHost, buildConnectionUrl, suggestConnectionLabel } from "./connectionString";
|
||||
import type { Connection } from "./types";
|
||||
|
||||
function makeConn(overrides: Partial<Connection> = {}): Connection {
|
||||
@@ -30,6 +30,7 @@ describe("parseConnectionString", () => {
|
||||
username: "user",
|
||||
password: "pass",
|
||||
database: "mydb",
|
||||
ssl_mode: null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -42,6 +43,7 @@ describe("parseConnectionString", () => {
|
||||
username: "root",
|
||||
password: null,
|
||||
database: "app",
|
||||
ssl_mode: null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -54,6 +56,7 @@ describe("parseConnectionString", () => {
|
||||
username: "user",
|
||||
password: "pass",
|
||||
database: "0",
|
||||
ssl_mode: null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -66,6 +69,7 @@ describe("parseConnectionString", () => {
|
||||
username: null,
|
||||
password: null,
|
||||
database: null,
|
||||
ssl_mode: null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -78,6 +82,7 @@ describe("parseConnectionString", () => {
|
||||
username: null,
|
||||
password: null,
|
||||
database: null,
|
||||
ssl_mode: null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -90,6 +95,7 @@ describe("parseConnectionString", () => {
|
||||
username: "user",
|
||||
password: null,
|
||||
database: "db",
|
||||
ssl_mode: null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -102,12 +108,51 @@ describe("parseConnectionString", () => {
|
||||
username: "user",
|
||||
password: null,
|
||||
database: "db",
|
||||
ssl_mode: "require",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null for an empty string", () => {
|
||||
expect(parseConnectionString("")).toBeNull();
|
||||
});
|
||||
it("lifts sslmode from a Neon-style URL (extra params ignored)", () => {
|
||||
const result = parseConnectionString(
|
||||
"postgresql://neondb_owner:pw@ep-hidden-star-b3hqtp4x-pooler.c-4.ap-southeast-1.aws.neon.tech/neondb?sslmode=require&channel_binding=require",
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
db_type: "postgresql",
|
||||
host: "ep-hidden-star-b3hqtp4x-pooler.c-4.ap-southeast-1.aws.neon.tech",
|
||||
username: "neondb_owner",
|
||||
password: "pw",
|
||||
database: "neondb",
|
||||
ssl_mode: "require",
|
||||
});
|
||||
});
|
||||
|
||||
it("lifts every supported sslmode value", () => {
|
||||
const mode = (q: string) => parseConnectionString(`postgresql://u@h/db?sslmode=${q}`)?.ssl_mode;
|
||||
expect(mode("disable")).toBe("disable");
|
||||
expect(mode("require")).toBe("require");
|
||||
expect(mode("verify-ca")).toBe("verify-ca");
|
||||
expect(mode("verify-full")).toBe("verify-full");
|
||||
expect(mode("VERIFY-FULL")).toBe("verify-full");
|
||||
expect(mode("verify_full")).toBe("verify-full");
|
||||
});
|
||||
|
||||
it("ignores unsupported sslmode values instead of downgrading to plaintext", () => {
|
||||
expect(parseConnectionString("postgresql://u@h/db?sslmode=allow")?.ssl_mode).toBeNull();
|
||||
expect(parseConnectionString("postgresql://u@h/db?sslmode=prefer")?.ssl_mode).toBeNull();
|
||||
expect(parseConnectionString("postgresql://u@h/db?sslmode=bogus")?.ssl_mode).toBeNull();
|
||||
});
|
||||
|
||||
it("lifts MySQL's ssl-mode vocabulary and skips TLS for non-SQL db types", () => {
|
||||
expect(parseConnectionString("mysql://u@h/db?ssl-mode=REQUIRED")?.ssl_mode).toBe("require");
|
||||
expect(parseConnectionString("mysql://u@h/db?ssl-mode=verify-full")?.ssl_mode).toBe("verify-full");
|
||||
expect(parseConnectionString("mysql://u@h/db?ssl-mode=VERIFY_IDENTITY")?.ssl_mode).toBe("verify-full");
|
||||
expect(parseConnectionString("mysql://u@h/db?ssl-mode=PREFERRED")?.ssl_mode).toBeNull();
|
||||
expect(parseConnectionString("redis://u@h?sslmode=require")?.ssl_mode).toBeNull();
|
||||
expect(parseConnectionString("sqlite:///tmp/a.db?sslmode=require")?.ssl_mode).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for a non-URL string", () => {
|
||||
expect(parseConnectionString("hello world")).toBeNull();
|
||||
@@ -122,6 +167,7 @@ describe("parseConnectionString", () => {
|
||||
username: "user",
|
||||
password: null,
|
||||
database: "db",
|
||||
ssl_mode: null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -134,6 +180,7 @@ describe("parseConnectionString", () => {
|
||||
username: "root",
|
||||
password: "pw",
|
||||
database: "app",
|
||||
ssl_mode: null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -146,6 +193,7 @@ describe("parseConnectionString", () => {
|
||||
username: "root",
|
||||
password: "pw",
|
||||
database: "app",
|
||||
ssl_mode: null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -158,6 +206,7 @@ describe("parseConnectionString", () => {
|
||||
username: null,
|
||||
password: null,
|
||||
database: null,
|
||||
ssl_mode: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -180,6 +229,33 @@ describe("looksLikeConnectionString", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("suggestConnectionLabel", () => {
|
||||
const label = (url: string) => {
|
||||
const parsed = parseConnectionString(url)!;
|
||||
return suggestConnectionLabel(parsed);
|
||||
};
|
||||
|
||||
it("prefers the database name", () => {
|
||||
expect(
|
||||
label("postgresql://neondb_owner:pw@ep-x-pooler.c-4.ap-southeast-1.aws.neon.tech/neondb?sslmode=require"),
|
||||
).toBe("neondb");
|
||||
});
|
||||
|
||||
it("falls back to the host when there is no database", () => {
|
||||
expect(label("postgresql://u@db.example.com:5432")).toBe("db.example.com");
|
||||
});
|
||||
|
||||
it("uses the file stem for SQLite paths", () => {
|
||||
expect(label("sqlite:///Users/me/data/app-demo.sqlite")).toBe("app-demo");
|
||||
expect(label("file:///tmp/notes.db")).toBe("notes");
|
||||
});
|
||||
|
||||
it("caps the label at the 100-char validation limit", () => {
|
||||
const host = `${"a".repeat(120)}.example.com`;
|
||||
expect(label(`postgresql://u@${host}/`).length).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe("detectProviderFromHost", () => {
|
||||
it("detects Supabase by host suffix", () => {
|
||||
expect(detectProviderFromHost("db.abcdefghijklmnopqrst.supabase.co")).toBe("supabase");
|
||||
@@ -291,6 +367,7 @@ describe("buildConnectionUrl", () => {
|
||||
username: "user",
|
||||
password: "pass",
|
||||
database: "mydb",
|
||||
ssl_mode: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,53 @@ export interface ParsedConnectionString {
|
||||
username: string | null;
|
||||
password: string | null;
|
||||
database: string | null;
|
||||
/** SSL mode lifted from the URL's `sslmode` / `ssl-mode` query param, or
|
||||
* `null` when absent/unsupported. Keeps managed-PostgreSQL URLs (Neon,
|
||||
* Supabase, …) working — they ship with `?sslmode=require` and the server
|
||||
* rejects a plaintext connection with "connection is insecure". */
|
||||
ssl_mode: SslMode | null;
|
||||
}
|
||||
|
||||
/** SSL modes the backend understands (mirrors `DbConfig.ssl_mode`). */
|
||||
export type SslMode = "disable" | "require" | "verify-ca" | "verify-full";
|
||||
|
||||
/** Alternate spellings used by other clients, mapped onto our four modes:
|
||||
* MySQL's `ssl-mode` vocabulary (`REQUIRED`, `VERIFY_IDENTITY`, …) and
|
||||
* plural forms. `allow` / `prefer` are intentionally absent — they permit a
|
||||
* plaintext fallback, which our mode set cannot express, so they return
|
||||
* `null` and the caller keeps its current/default mode. */
|
||||
const SSL_MODE_ALIASES: Record<string, SslMode> = {
|
||||
disable: "disable",
|
||||
disabled: "disable",
|
||||
require: "require",
|
||||
required: "require",
|
||||
"verify-ca": "verify-ca",
|
||||
"verify-full": "verify-full",
|
||||
// MySQL's strongest mode: chain + hostname verification.
|
||||
"verify-identity": "verify-full",
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract the TLS mode from a connection URL's query string.
|
||||
*
|
||||
* Accepts libpq's `sslmode` (PostgreSQL) and MySQL's `ssl-mode` spelling,
|
||||
* case-insensitively and hyphen/underscore-insensitively (`verify-full`,
|
||||
* `verify_full`, `VERIFY-FULL`, …). Unsupported values (`allow`, `prefer`)
|
||||
* return `null` so the caller keeps its current/default mode rather than
|
||||
* silently downgrading to plaintext.
|
||||
*/
|
||||
function parseSslMode(url: URL): SslMode | null {
|
||||
let raw: string | null = null;
|
||||
for (const [key, value] of url.searchParams) {
|
||||
const k = key.toLowerCase();
|
||||
if (k === "sslmode" || k === "ssl-mode") {
|
||||
raw = value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!raw) return null;
|
||||
const normalized = raw.trim().toLowerCase().replace(/_/g, "-");
|
||||
return SSL_MODE_ALIASES[normalized] ?? null;
|
||||
}
|
||||
|
||||
const DB_PROTOCOLS: Record<string, DbType> = {
|
||||
@@ -68,6 +115,9 @@ export function parseConnectionString(input: string): ParsedConnectionString | n
|
||||
username,
|
||||
password,
|
||||
database,
|
||||
ssl_mode: db_type === "postgresql" || db_type === "mysql" || db_type === "mariadb"
|
||||
? parseSslMode(url)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -95,6 +145,25 @@ export function detectProviderFromHost(
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Suggest a connection label for a freshly pasted connection string, so the
|
||||
* connection can be saved without typing a name first.
|
||||
*
|
||||
* Preference order: the database name, then (for SQLite) the file stem, then
|
||||
* the host. Capped at 100 chars to satisfy `validateConnectionInput`, which
|
||||
* rejects longer names.
|
||||
*/
|
||||
export function suggestConnectionLabel(parsed: ParsedConnectionString): string {
|
||||
let label: string;
|
||||
if (parsed.db_type === "sqlite") {
|
||||
const file = parsed.host.split(/[\\/]/).pop() ?? "";
|
||||
label = file.replace(/\.(sqlite3?|db)$/i, "") || file;
|
||||
} else {
|
||||
label = parsed.database?.replace(/^\/+/, "") || parsed.host;
|
||||
}
|
||||
return label.trim().slice(0, 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a connection URL string from a saved connection, for copying into
|
||||
* env vars / other tools. `password` is the decrypted keychain value (or
|
||||
|
||||
@@ -5,7 +5,7 @@ import agents from "../../../AGENTS.md?raw";
|
||||
import readme from "../../../README.md?raw";
|
||||
import landing from "../../../www/public/landing.js?raw";
|
||||
|
||||
describe("v0.8.0 docs coverage", () => {
|
||||
describe("v0.8.2 docs coverage", () => {
|
||||
it("AGENTS.md marks inline cell editing complete", () => {
|
||||
expect(agents).toContain("Inline cell editing");
|
||||
expect(agents).toMatch(/Inline cell editing \| ✅/);
|
||||
@@ -25,8 +25,8 @@ describe("v0.8.0 docs coverage", () => {
|
||||
expect(agents).toMatch(/Connection status indicator on cards \| ✅/);
|
||||
expect(agents).toMatch(/Move-to-folder bulk action \| ✅/);
|
||||
});
|
||||
it("README declares v0.8.0", () => {
|
||||
expect(readme).toContain("0.8.0");
|
||||
it("README declares v0.8.2", () => {
|
||||
expect(readme).toContain("0.8.2");
|
||||
});
|
||||
it("AGENTS.md marks schema CRUD complete", () => {
|
||||
expect(agents).toMatch(/Schema CRUD \| ✅/);
|
||||
@@ -65,12 +65,18 @@ describe("v0.8.0 docs coverage", () => {
|
||||
expect(agents).toMatch(/Cancel long-running queries \| ✅/);
|
||||
expect(agents).toMatch(/Settings export\/import \| ✅/);
|
||||
});
|
||||
it("README links to v0.8.0 assets in both download tables", () => {
|
||||
expect(readme).toContain("releases/download/v0.8.0/");
|
||||
expect(readme).toContain("Gridline_0.8.0_aarch64.dmg");
|
||||
expect(readme).toContain("Gridline-0.8.0-1.x86_64.rpm");
|
||||
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("landing.js platform download links point to v0.8.0", () => {
|
||||
expect(landing).toContain('const version = "0.8.0"');
|
||||
it("landing.js platform download links point to v0.8.2", () => {
|
||||
expect(landing).toContain('const version = "0.8.2"');
|
||||
});
|
||||
it("AGENTS.md documents the native MySQL restore and the .sql association", () => {
|
||||
// These claims would actively mislead the next contributor if they were
|
||||
// missing, which is why they are asserted rather than trusted to review.
|
||||
expect(agents).toMatch(/\.sql/);
|
||||
expect(agents.toLowerCase()).toContain("in-process");
|
||||
});
|
||||
});
|
||||
@@ -528,3 +528,10 @@ export interface DiffReport {
|
||||
target_label: string;
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
/** An OS-delivered `.sql` open request, already validated and read in Rust. */
|
||||
export interface PendingSqlFile {
|
||||
path: string;
|
||||
name: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest";
|
||||
import pkg from "../../package.json";
|
||||
|
||||
describe("version", () => {
|
||||
it("declares v0.8.0 across the app shell", () => {
|
||||
expect(pkg.version).toBe("0.8.0");
|
||||
it("declares v0.8.2 across the app shell", () => {
|
||||
expect(pkg.version).toBe("0.8.2");
|
||||
});
|
||||
});
|
||||
@@ -638,3 +638,46 @@ describe("roles slice", () => {
|
||||
expect(useDbViewerStore.getState().tabs.filter((x) => x.form?.kind === "table").length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("openQueryTab with an initial script", () => {
|
||||
it("seeds the tab text and label from the payload", () => {
|
||||
useDbViewerStore.setState({ tabs: [], activeTabId: null });
|
||||
|
||||
useDbViewerStore.getState().openQueryTab({
|
||||
sql: "SELECT 1;",
|
||||
label: "student_db.sql",
|
||||
filePath: "/Users/me/student_db.sql",
|
||||
});
|
||||
|
||||
const tab = useDbViewerStore.getState().tabs.slice(-1)[0]!;
|
||||
expect(tab.tabType).toBe("query");
|
||||
expect(tab.query).toBe("SELECT 1;");
|
||||
expect(tab.table).toBe("student_db.sql");
|
||||
expect(tab.filePath).toBe("/Users/me/student_db.sql");
|
||||
});
|
||||
|
||||
it("focuses an existing tab for the same file instead of duplicating it", () => {
|
||||
useDbViewerStore.setState({ tabs: [], activeTabId: null });
|
||||
const payload = {
|
||||
sql: "SELECT 1;",
|
||||
label: "student_db.sql",
|
||||
filePath: "/Users/me/student_db.sql",
|
||||
};
|
||||
useDbViewerStore.getState().openQueryTab(payload);
|
||||
useDbViewerStore.getState().openQueryTab({ ...payload, sql: "SELECT 2;" });
|
||||
|
||||
const queryTabs = useDbViewerStore.getState().tabs.filter((t) => t.tabType === "query");
|
||||
expect(queryTabs).toHaveLength(1);
|
||||
// The already-open tab keeps its text: re-opening must not clobber edits.
|
||||
expect(queryTabs[0].query).toBe("SELECT 1;");
|
||||
});
|
||||
|
||||
it("still opens a plain empty tab when called with no payload", () => {
|
||||
useDbViewerStore.setState({ tabs: [], activeTabId: null });
|
||||
useDbViewerStore.getState().openQueryTab();
|
||||
const tab = useDbViewerStore.getState().tabs.slice(-1)[0]!;
|
||||
expect(tab.tabType).toBe("query");
|
||||
expect(tab.query).toBe("");
|
||||
expect(tab.filePath).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -66,6 +66,8 @@ export interface ViewerTab {
|
||||
smartSortApplied: boolean;
|
||||
tabType: "table" | "query" | "object" | "objectForm";
|
||||
query?: string;
|
||||
/** Set when the tab's text came from an OS-opened `.sql` file. */
|
||||
filePath?: string;
|
||||
objectType?: ObjectType | null;
|
||||
objectItem?: unknown;
|
||||
form?: ViewerFormTabPayload;
|
||||
@@ -127,7 +129,7 @@ interface DbViewerState {
|
||||
|
||||
// Actions
|
||||
openTab: (schema: string, table: string, forceNew?: boolean) => void;
|
||||
openQueryTab: () => void;
|
||||
openQueryTab: (initial?: { sql?: string; label?: string; filePath?: string }) => void;
|
||||
openObjectTab: (objectType: ObjectType, schema: string, name: string, item?: unknown) => void;
|
||||
openFormTab: (opts: {
|
||||
kind: ObjectKind;
|
||||
@@ -262,13 +264,24 @@ export const useDbViewerStore = create<DbViewerState>((set, get) => ({
|
||||
set({ tabs: [...tabs, tab], activeTabId: tab.id });
|
||||
},
|
||||
|
||||
openQueryTab: () => {
|
||||
openQueryTab: (initial) => {
|
||||
const { tabs, currentSchema } = get();
|
||||
|
||||
// Re-opening the same file focuses its tab instead of duplicating it, and
|
||||
// deliberately does NOT overwrite the text — the user may have edited it.
|
||||
if (initial?.filePath) {
|
||||
const existing = tabs.find((t) => t.filePath === initial.filePath);
|
||||
if (existing) {
|
||||
set({ activeTabId: existing.id });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const queryCount = tabs.filter((t) => t.tabType === "query").length;
|
||||
const tab: ViewerTab = {
|
||||
id: `tab-${++tabCounter}`,
|
||||
schema: currentSchema ?? "public",
|
||||
table: queryCount === 0 ? "Query" : `Query ${queryCount + 1}`,
|
||||
table: initial?.label ?? (queryCount === 0 ? "Query" : `Query ${queryCount + 1}`),
|
||||
page: 1,
|
||||
pageSize: get().defaultPageSize,
|
||||
loading: false,
|
||||
@@ -279,7 +292,8 @@ export const useDbViewerStore = create<DbViewerState>((set, get) => ({
|
||||
hiddenColumns: [],
|
||||
smartSortApplied: false,
|
||||
tabType: "query",
|
||||
query: "",
|
||||
query: initial?.sql ?? "",
|
||||
filePath: initial?.filePath,
|
||||
};
|
||||
set({ tabs: [...tabs, tab], activeTabId: tab.id });
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { useUiStore } from "./uiStore";
|
||||
|
||||
beforeEach(() => useUiStore.setState({ searchQuery: "", activeFolderId: null, activeTagIds: [], activeDbTypes: [], activeEnvironment: null, activeView: "home", prefilledConnectionString: null, activeConnectionId: null, settingsReturnView: null }));
|
||||
beforeEach(() => useUiStore.setState({ searchQuery: "", activeFolderId: null, activeTagIds: [], activeDbTypes: [], activeEnvironment: null, activeView: "home", prefilledConnectionString: null, activeConnectionId: null, settingsReturnView: null, pendingSqlFile: null }));
|
||||
|
||||
describe("uiStore", () => {
|
||||
it("starts on home view", () => expect(useUiStore.getState().activeView).toBe("home"));
|
||||
@@ -95,3 +95,18 @@ describe("uiStore", () => {
|
||||
expect(useUiStore.getState().settingsReturnView).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("pending SQL file stash", () => {
|
||||
it("stores and clears a file awaiting a connection", () => {
|
||||
const file = { path: "/tmp/a.sql", name: "a.sql", content: "SELECT 1;" };
|
||||
useUiStore.getState().setPendingSqlFile(file);
|
||||
expect(useUiStore.getState().pendingSqlFile).toEqual(file);
|
||||
|
||||
useUiStore.getState().clearPendingSqlFile();
|
||||
expect(useUiStore.getState().pendingSqlFile).toBeNull();
|
||||
});
|
||||
|
||||
it("defaults to no pending file", () => {
|
||||
expect(useUiStore.getState().pendingSqlFile).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { create } from "zustand";
|
||||
import type { ActiveView, DbType } from "../lib/types";
|
||||
import type { ActiveView, DbType, PendingSqlFile } from "../lib/types";
|
||||
|
||||
interface UiState {
|
||||
searchQuery: string;
|
||||
@@ -27,10 +27,14 @@ interface UiState {
|
||||
setPrefilledConnectionString: (value: string) => void;
|
||||
clearPrefilledConnectionString: () => void;
|
||||
setActiveConnectionId: (id: string | null) => void;
|
||||
/** A `.sql` file opened from the OS while no connection was active. */
|
||||
pendingSqlFile: PendingSqlFile | null;
|
||||
setPendingSqlFile: (file: PendingSqlFile | null) => void;
|
||||
clearPendingSqlFile: () => void;
|
||||
}
|
||||
|
||||
export const useUiStore = create<UiState>((set) => ({
|
||||
searchQuery: "", activeFolderId: null, activeTagIds: [], activeDbTypes: [], activeEnvironment: null, activeView: "home", selectedItemIds: [], prefilledConnectionString: null, activeConnectionId: null, settingsReturnView: null,
|
||||
searchQuery: "", activeFolderId: null, activeTagIds: [], activeDbTypes: [], activeEnvironment: null, activeView: "home", selectedItemIds: [], prefilledConnectionString: null, activeConnectionId: null, settingsReturnView: null, pendingSqlFile: null,
|
||||
setActiveView: (view) => set({ activeView: view }),
|
||||
openSettings: () => set((s) => ({
|
||||
settingsReturnView:
|
||||
@@ -61,4 +65,6 @@ export const useUiStore = create<UiState>((set) => ({
|
||||
setPrefilledConnectionString: (value) => set({ prefilledConnectionString: value }),
|
||||
clearPrefilledConnectionString: () => set({ prefilledConnectionString: null }),
|
||||
setActiveConnectionId: (id) => set({ activeConnectionId: id }),
|
||||
setPendingSqlFile: (file) => set({ pendingSqlFile: file }),
|
||||
clearPendingSqlFile: () => set({ pendingSqlFile: null }),
|
||||
}));
|
||||
@@ -0,0 +1,63 @@
|
||||
# Smoke checklist — native MySQL execution (0.8.2)
|
||||
|
||||
These flows cannot be automated on this machine: `RunEvent::Opened` only fires
|
||||
for a bundled `.app` registered with LaunchServices, and a real MySQL 8 restore
|
||||
needs a live server. Run every box before publishing a draft release.
|
||||
|
||||
Build first (both scripts exist — `build` in `desktop/package.json`, the Tauri
|
||||
CLI via the `tauri` script in `desktop/package.json`):
|
||||
|
||||
bun run --filter gridline-desktop build
|
||||
bun run --filter gridline-desktop tauri build
|
||||
|
||||
## A. Association and OS open
|
||||
|
||||
- [ ] The built `.app` appears under **Finder → right-click a `.sql` file → Open With**.
|
||||
- [ ] The previous default handler for `.sql` is **unchanged** (rank is Alternate).
|
||||
Refresh LaunchServices if it is stale:
|
||||
`/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister -f /Applications/Gridline.app`
|
||||
- [ ] With Gridline **closed**, double-click a `.sql` file → the app launches with a
|
||||
query tab named after the file, containing the file's text.
|
||||
- [ ] With Gridline **running**, open another `.sql` file → a second tab appears
|
||||
(no second app instance, and no duplicate of the first tab).
|
||||
- [ ] Open the **same** file twice → the existing tab is focused; edits in it are NOT reset.
|
||||
- [ ] With **no connection open**, open a `.sql` file → Home is shown with a banner
|
||||
naming the file; after connecting, the tab opens automatically and the banner clears.
|
||||
- [ ] Open a file larger than 5 MB → nothing opens and no freeze occurs (a file over
|
||||
`MAX_SQL_FILE_BYTES` is rejected by the buffer and must be routed to Tools → Restore,
|
||||
whose own cap is 100 MB).
|
||||
- [ ] Open a `.txt` file via Open With → Gridline ignores it (no tab, no error dialog).
|
||||
- [ ] The notarized `Gridline_0.8.2_*.dmg` still carries the association and still launches.
|
||||
|
||||
## B. Cell decoding (issue #41)
|
||||
|
||||
- [ ] Connect to a MySQL database and open a table with `INT`/`BIGINT` columns:
|
||||
the primary key renders its values, **not** NULL.
|
||||
- [ ] Columns of type `DECIMAL`, `DATE`, `DATETIME`, `TIMESTAMP`, `FLOAT`, `TINYINT`,
|
||||
`BIT` and `YEAR` all render values.
|
||||
- [ ] Run the reporter's exact statement and confirm the ids are values:
|
||||
`SELECT id, uuid, first_name FROM users ORDER BY id;`
|
||||
- [ ] A `BIGINT` column renders as a string and a `DECIMAL` renders with its exact
|
||||
digits (no float rounding).
|
||||
- [ ] A `VARCHAR` column still renders as before (no regression on the types that worked).
|
||||
|
||||
## C. Native restore
|
||||
|
||||
- [ ] Tools → Restore against a MySQL 8 server whose user authenticates with
|
||||
`caching_sha2_password` (the case the bundled MariaDB client cannot reach):
|
||||
the restore completes.
|
||||
- [ ] The MySQL restore form is usable even when no `mariadb`/`mysql` client is installed.
|
||||
- [ ] `clean` ON into a **non-empty** target: objects the dump defines are replaced;
|
||||
an unrelated table created beforehand **survives**.
|
||||
- [ ] `clean` OFF into a non-empty target: fails with `table already exists`, and the
|
||||
error names the statement position.
|
||||
- [ ] A dump containing a trigger or stored routine (with `DELIMITER`) restores without
|
||||
a syntax error.
|
||||
- [ ] Cancel a long restore → it stops and reports cancellation rather than running to completion.
|
||||
- [ ] The form states that a failed MySQL restore cannot be rolled back.
|
||||
|
||||
## D. Suite
|
||||
|
||||
- [ ] `cd desktop/src-tauri && cargo test --lib` → 0 failures.
|
||||
- [ ] `cd desktop && bun run test` → 0 failures.
|
||||
- [ ] `cd desktop && bunx tsc --noEmit` → clean.
|
||||
@@ -8,6 +8,8 @@
|
||||
"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"
|
||||
"test": "bun run --filter gridline-desktop test",
|
||||
"icons": "bash scripts/regenerate-icons.sh",
|
||||
"icons:verify": "bun scripts/verify-app-icon.mjs"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
#!/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`);
|
||||
@@ -0,0 +1,25 @@
|
||||
#!/bin/sh
|
||||
#
|
||||
# post-merge hook — after `git pull`/merge, refresh the Gitea fallback.
|
||||
#
|
||||
# Installed via `scripts/install-git-hooks.sh` (git core.hooksPath = scripts/hooks).
|
||||
# GitHub merges (e.g. a squash-merged PR) happen server-side, so a local
|
||||
# `git pull` on prod is the moment to bring the Gitea mirror along.
|
||||
#
|
||||
# args: $1 = 1 when the merge was a squash, otherwise 0
|
||||
|
||||
if [ "${GRIDLINE_MIRROR_RUNNING:-}" = "1" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
branch="$(git symbolic-ref --short -q HEAD)" || exit 0
|
||||
case "$branch" in
|
||||
prod|main) ;;
|
||||
*) exit 0 ;;
|
||||
esac
|
||||
|
||||
root="$(git rev-parse --show-toplevel)" || exit 0
|
||||
GRIDLINE_MIRROR_RUNNING=1 sh "$root/scripts/mirror-gitea.sh" ||
|
||||
echo "post-merge: warning: Gitea mirror was not updated" >&2
|
||||
|
||||
exit 0
|
||||
@@ -0,0 +1,46 @@
|
||||
#!/bin/sh
|
||||
#
|
||||
# pre-push hook — mirrors the trunk branch and tags to the Gitea fallback.
|
||||
#
|
||||
# Installed via `scripts/install-git-hooks.sh` (git core.hooksPath = scripts/hooks).
|
||||
# Git has no client-side post-push hook, so the mirror runs here: the refs are
|
||||
# already committed locally, so whether Gitea or GitHub receives them first makes
|
||||
# no difference. A Gitea outage prints a warning and never blocks the push.
|
||||
#
|
||||
# stdin receives one line per ref being pushed:
|
||||
# <local-ref> <local-sha> <remote-ref> <remote-sha>
|
||||
#
|
||||
# args: $1 = remote name, $2 = remote URL
|
||||
|
||||
remote_url="${2:-}"
|
||||
|
||||
# Only react to pushes of the canonical GitHub remote.
|
||||
case "$remote_url" in
|
||||
*github.com*) ;;
|
||||
*) exit 0 ;;
|
||||
esac
|
||||
|
||||
# Don't recurse when the mirror script itself is pushing to Gitea.
|
||||
if [ "${GRIDLINE_MIRROR_RUNNING:-}" = "1" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
mirror_needed=0
|
||||
while read -r _local_ref local_sha remote_ref _remote_sha; do
|
||||
# Ignore deletions (all-zero object id).
|
||||
case "$local_sha" in
|
||||
0000000000000000000000000000000000000000) continue ;;
|
||||
esac
|
||||
case "$remote_ref" in
|
||||
refs/heads/prod|refs/heads/main|refs/tags/*) mirror_needed=1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[ "$mirror_needed" = "1" ] || exit 0
|
||||
|
||||
root="$(git rev-parse --show-toplevel)" || exit 0
|
||||
GRIDLINE_MIRROR_RUNNING=1 sh "$root/scripts/mirror-gitea.sh" ||
|
||||
echo "pre-push: warning: Gitea mirror was not updated (continuing with the push)" >&2
|
||||
|
||||
# Never block the push to GitHub because of a mirror problem.
|
||||
exit 0
|
||||
@@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Install the repo's git hooks (once per clone).
|
||||
#
|
||||
# Points `core.hooksPath` at `scripts/hooks/` so the hook files are versioned
|
||||
# with the repo instead of copied into `.git/hooks` (a stale copy can't drift
|
||||
# out of date this way). Currently:
|
||||
#
|
||||
# pre-push — mirror prod + tags to the Gitea fallback (git.ranio.xyz)
|
||||
# post-merge — same, after a `git pull`/merge while on prod
|
||||
#
|
||||
# The mirror itself lives in `scripts/mirror-gitea.sh`; run it by hand any time
|
||||
# with `scripts/mirror-gitea.sh`. Gitea is a just-in-case copy: GitHub is the
|
||||
# source of truth, and a Gitea outage never blocks a push.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
repo_root="$(git rev-parse --show-toplevel 2>/dev/null)" || {
|
||||
echo "install-git-hooks: not inside a git repository" >&2
|
||||
exit 1
|
||||
}
|
||||
cd "$repo_root"
|
||||
|
||||
if [ ! -d scripts/hooks ]; then
|
||||
echo "install-git-hooks: scripts/hooks/ is missing — run this from the repo root" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
chmod +x scripts/hooks/* scripts/mirror-gitea.sh 2>/dev/null || true
|
||||
git config core.hooksPath scripts/hooks
|
||||
|
||||
gitea_remote="${GRIDLINE_GITEA_REMOTE:-origin}"
|
||||
echo "install-git-hooks: core.hooksPath -> scripts/hooks"
|
||||
for hook in scripts/hooks/*; do
|
||||
echo "install-git-hooks: $(basename "$hook")"
|
||||
done
|
||||
echo "install-git-hooks: prod + tags mirror to the '$gitea_remote' remote on push/pull"
|
||||
@@ -0,0 +1,90 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Keep the Gitea "just in case" mirror (git.ranio.xyz) in step with GitHub.
|
||||
#
|
||||
# GitHub (`AdrianBonpin/gridline`, remote `github`) is the source of truth; the
|
||||
# Gitea remote (`origin`) exists only as a fallback copy of the code, so this
|
||||
# mirrors the trunk branch and every tag — not feature branches.
|
||||
#
|
||||
# Run it manually after a merge that happened on GitHub, or let the repo's git
|
||||
# hooks call it (`scripts/install-git-hooks.sh` wires up `core.hooksPath`):
|
||||
# * pre-push — before pushing prod/tags to GitHub
|
||||
# * post-merge — after `git pull`/merge while on prod
|
||||
#
|
||||
# Exit status is non-zero when the mirror could not be updated, so callers can
|
||||
# decide whether that is fatal. The hooks treat it as a warning: a Gitea outage
|
||||
# must never block a push to GitHub.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/mirror-gitea.sh # mirror prod + all tags
|
||||
# GRIDLINE_GITEA_REMOTE=mirror scripts/… # use another remote name
|
||||
# scripts/mirror-gitea.sh --branches # also mirror every local branch
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
GITEA_REMOTE="${GRIDLINE_GITEA_REMOTE:-origin}"
|
||||
MIRROR_BRANCHES=0
|
||||
TRUNK_BRANCH="${GRIDLINE_TRUNK_BRANCH:-prod}"
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--branches) MIRROR_BRANCHES=1 ;;
|
||||
-h|--help) sed -n '2,25p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
||||
*) echo "mirror-gitea: unknown argument '$arg' (try --help)" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
repo_root="$(git rev-parse --show-toplevel 2>/dev/null)" || {
|
||||
echo "mirror-gitea: not inside a git repository" >&2
|
||||
exit 1
|
||||
}
|
||||
cd "$repo_root" || exit 1
|
||||
|
||||
url="$(git remote get-url "$GITEA_REMOTE" 2>/dev/null || true)"
|
||||
if [ -z "$url" ]; then
|
||||
echo "mirror-gitea: remote '$GITEA_REMOTE' not found — nothing to mirror" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Safety: never push to GitHub through this script (it is the source, and a
|
||||
# mis-pointed remote could otherwise rewrite the wrong repository).
|
||||
case "$url" in
|
||||
*github.com*)
|
||||
echo "mirror-gitea: '$GITEA_REMOTE' points at GitHub ($url) — refusing to mirror" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
status=0
|
||||
note() { printf 'mirror-gitea: %s\n' "$1" >&2; }
|
||||
|
||||
# Trunk branch. Only mirror it when it exists locally; a fresh clone with just
|
||||
# the feature branch checked out still mirrors the tags below.
|
||||
if git show-ref --verify --quiet "refs/heads/$TRUNK_BRANCH"; then
|
||||
if ! git push "$GITEA_REMOTE" "refs/heads/$TRUNK_BRANCH:refs/heads/$TRUNK_BRANCH"; then
|
||||
status=1
|
||||
note "could not update $TRUNK_BRANCH"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Every local tag, so release tags appear on the mirror too (`--tags` is
|
||||
# idempotent — already-present tags are skipped).
|
||||
if [ -n "$(git tag --list)" ]; then
|
||||
if ! git push "$GITEA_REMOTE" --tags; then
|
||||
status=1
|
||||
note "could not update tags"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Optional: every local branch (off by default — feature branches are ephemeral).
|
||||
if [ "$MIRROR_BRANCHES" = "1" ]; then
|
||||
if ! git push "$GITEA_REMOTE" --all; then
|
||||
status=1
|
||||
note "could not update branches"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$status" = "0" ]; then
|
||||
note "mirror up to date ($url)"
|
||||
fi
|
||||
exit "$status"
|
||||
@@ -0,0 +1,86 @@
|
||||
#!/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,25 +1,29 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Build Gridline for macOS on this machine and upload the DMGs to the Gitea
|
||||
# release for a given tag. macOS is built manually (NOT auto-built on tag);
|
||||
# the Linux and Windows installers are built by the Gitea Actions workflow
|
||||
# (.gitea/workflows/release.yml) and already uploaded to the same release.
|
||||
# LOCAL FALLBACK: build Gridline for macOS on this machine and upload the signed
|
||||
# + notarized DMGs to the GitHub release for a given tag.
|
||||
#
|
||||
# macOS is normally built, Developer-ID signed and notarized by GitHub Actions
|
||||
# (.github/workflows/release.yml) using the APPLE_* repository secrets, together
|
||||
# with the Windows and Linux installers. Reach for this script when you want a
|
||||
# signed build without spending CI minutes, or when Actions is unavailable.
|
||||
#
|
||||
# Usage:
|
||||
# TAG=v0.7.11 ./scripts/release-mac.sh
|
||||
# TAG=v0.8.2 ./scripts/release-mac.sh
|
||||
#
|
||||
# The release is keyed by tag. The Linux/Windows job creates it on the first
|
||||
# tag push; this script finds it (creating it only if it somehow doesn't exist)
|
||||
# and attaches the two DMGs: Gridline_<ver>_aarch64.dmg and Gridline_<ver>_x64.dmg
|
||||
# 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
|
||||
# attaches/replaces the two DMGs — Gridline_<ver>_aarch64.dmg and
|
||||
# Gridline_<ver>_x64.dmg. Uploads use --clobber, so a locally signed DMG
|
||||
# supersedes the CI one; publish the draft afterwards on GitHub.
|
||||
#
|
||||
# Auth: uses the `tea` CLI (OAuth token, auto-refreshed).
|
||||
# Auth: the `gh` CLI (GitHub is the only release target; the Gitea mirror is
|
||||
# code-only).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ---- config ---------------------------------------------------------------
|
||||
GITEA_SERVER="${GITEA_SERVER:-https://git.ranio.xyz}"
|
||||
REPO_OWNER="${REPO_OWNER:-adrianbonpin}"
|
||||
REPO_NAME="${REPO_NAME:-gridline}"
|
||||
GH_REPO="${GH_REPO:-AdrianBonpin/gridline}"
|
||||
TAG="${TAG:-${1:-}}"
|
||||
|
||||
if [ -z "$TAG" ]; then
|
||||
@@ -27,13 +31,10 @@ if [ -z "$TAG" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ---- resolve auth (tea CLI) ------------------------------------------------
|
||||
# The `tea` CLI carries the OAuth token and refreshes it automatically, so the
|
||||
# release upload below uses `tea` rather than a raw curl + token. (The git
|
||||
# credential store can hold a stale access token and 401.)
|
||||
if ! command -v tea >/dev/null 2>&1; then
|
||||
echo "error: tea CLI not found (needed to upload release assets)" >&2
|
||||
echo " install it: https://gitea.com/gitea/tea" >&2
|
||||
# ---- resolve auth (gh CLI) -------------------------------------------------
|
||||
if ! command -v gh >/dev/null 2>&1; then
|
||||
echo "error: gh CLI not found (needed to upload release assets)" >&2
|
||||
echo " install it: https://cli.github.com" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -229,21 +230,20 @@ else
|
||||
fi
|
||||
|
||||
# ---- create/ensure release + upload DMGs -----------------------------------
|
||||
# Use `tea` for release management — it handles OAuth token refresh, unlike a
|
||||
# raw curl with a token from the git credential store (which can 401).
|
||||
cd "${WORKSPACE}"
|
||||
|
||||
echo ">> Ensuring release ${TAG} exists..."
|
||||
if ! tea release list -o json 2>/dev/null | grep -q "\"${TAG}\""; then
|
||||
tea release create --tag "${TAG}" --title "Gridline ${VERSION}" \
|
||||
--note "Gridline ${VERSION} — macOS (built manually)." --draft
|
||||
echo ">> Ensuring release ${TAG} exists on ${GH_REPO}..."
|
||||
if ! gh release view "${TAG}" --repo "${GH_REPO}" >/dev/null 2>&1; then
|
||||
gh release create "${TAG}" --repo "${GH_REPO}" --title "Gridline ${VERSION}" \
|
||||
--notes "Gridline ${VERSION} — macOS (built locally, signed + notarized)." --draft
|
||||
else
|
||||
echo ">> Release ${TAG} already exists."
|
||||
fi
|
||||
|
||||
echo ">> Uploading macOS DMGs..."
|
||||
tea releases assets create "${TAG}" \
|
||||
# --clobber replaces the CI-built (ad-hoc) DMGs with this signed + notarized pair.
|
||||
echo ">> Uploading macOS DMGs (replacing any existing ones)..."
|
||||
gh release upload "${TAG}" --repo "${GH_REPO}" --clobber \
|
||||
"${BUNDLE_ARM}/dmg/Gridline_${VERSION}_aarch64.dmg" \
|
||||
"${BUNDLE_INTEL}/dmg/Gridline_${VERSION}_x64.dmg"
|
||||
|
||||
echo "Done. Release: ${GITEA_SERVER}/${REPO_OWNER}/${REPO_NAME}/releases/tag/${TAG}"
|
||||
echo "Done. Review + publish the draft: https://github.com/${GH_REPO}/releases/tag/${TAG}"
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
#!/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).`);
|
||||
@@ -60,7 +60,7 @@
|
||||
const keepLabel = downloadBtn.hasAttribute("data-download-label");
|
||||
|
||||
const releasesUrl = "https://github.com/AdrianBonpin/gridline/releases";
|
||||
const version = "0.8.0";
|
||||
const version = "0.8.2";
|
||||
|
||||
if (isMobile) {
|
||||
downloadBtn.href = releasesUrl;
|
||||
|
||||
@@ -5,6 +5,24 @@ export interface ChangelogEntry {
|
||||
}
|
||||
|
||||
export const changelogEntries: ChangelogEntry[] = [
|
||||
{
|
||||
version: "0.8.2",
|
||||
date: "2026-09-29",
|
||||
highlights: [
|
||||
"MySQL columns now show their values. Every non-string MySQL type was decoding to NULL, so `id`/integer columns looked empty in the grid and the query editor — and it was not only integers: `DECIMAL`, `BIT`, `YEAR`, `DATE`, `TIME`, `DATETIME`, `DATETIME(6)`, `TIMESTAMP`, `FLOAT` and `DOUBLE` were affected too (21 of 30 types). Decoding is now driven by each column's declared type instead of a guess, 64-bit integers and `DECIMAL` travel as strings so no digits are lost, and `DATETIME(6)` keeps its microseconds. Temporal values are shown as stored, never silently shifted into another time zone.",
|
||||
"MySQL restore now works against MySQL 8. It used to shell out to a bundled MariaDB client, which cannot authenticate with MySQL 8's default `caching_sha2_password`, so restore failed with `ERROR 1045` (or `ERROR 2026` once TLS was involved) while browsing worked fine. Restore now runs in-process over the same driver that browsing uses: it stops at the first error and tells you which statement failed, it can be cancelled, and it understands the `DELIMITER` directive so dumps containing triggers and stored routines apply correctly. The **Clean** toggle is now real — it drops only the objects the dump recreates, so an unrelated table in the target survives — and because MySQL DDL commits as it runs, the form warns that a failed restore cannot be rolled back.",
|
||||
"`.sql` files can be opened with Gridline straight from your file manager. Right-click a script → Open With → Gridline (it appears as an alternate handler, so your default for `.sql` is left alone) and it opens in a query tab named after the file. Opening never executes anything — Run still goes through the destructive-query confirmation — and re-opening the same file focuses the existing tab without discarding edits. If no connection is open, the script is held and Home prompts you to pick one, so a double-click never dead-ends.",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.8.1",
|
||||
date: "2026-09-24",
|
||||
highlights: [
|
||||
"Managed-provider connections fixed — pasting a Neon / Supabase / PlanetScale connection string now carries its `?sslmode=` into the saved connection (it was silently dropped, so TLS stayed off and Neon answered `connection is insecure (try using sslmode=require)`). The SSH / SSL tab's mode and certificate paths are now persisted on create *and* edit, and the managed presets pre-select their TLS mode (Supabase / Neon → Require, PlanetScale → Verify Full). As a safety net, hosts under `*.neon.tech` / `*.supabase.co` / `*.psdb.cloud` default to `require` when a connection has no stored mode, so connections saved before this fix keep working.",
|
||||
"Pasting a connection URI fills every setting it can — type, host, port, user, password, database and TLS mode — and suggests a name from the URL (database name, else host), which Test and Save use when the label is left blank. A pasted URL can now be tested and saved with no retyping.",
|
||||
"macOS keychain prompts now self-heal — an item whose ACL still points at an older binary path (the pre-monorepo `src-tauri/` layout, a reinstalled app, an ad-hoc dev build) made macOS ask for your keychain password on *every* access, once per secret, with no way to make it stick. The app now checks the item's ACL (silently) before reading and repairs it when needed, so you authorize a secret once and it stays silent afterwards.",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.8.0",
|
||||
date: "2026-09-09",
|
||||
|
||||
@@ -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.0";
|
||||
const version = "0.8.2";
|
||||
const site = "https://getgridline.app";
|
||||
const jsonLd = [
|
||||
buildSoftwareApplicationJsonLd({
|
||||
|
||||