14 Commits
Author SHA1 Message Date
adrianbonpin 092b3b2831 chore(release): v0.8.2 (#43)
Release / linux (push) Successful in 39m3s
Release / windows (push) Canceled after 0s
Version bump 0.8.1 -> 0.8.2 across the app shell, the website and the README, plus v0.8.2 changelog entries for the native MySQL execution work merged in #42.

- desktop/package.json, desktop/src-tauri/Cargo.toml, desktop/src-tauri/tauri.conf.json, Cargo.lock (cargo-regenerated; gridline only - webview2-com-macros stays 0.8.1)
- www/public/landing.js + www/src/pages/index.astro const version; new top entry in www/src/data/changelog.ts
- version.test.ts, bundle-config.test.ts, docs-coverage.test.ts updated to 0.8.2
- README: both banners, both download tables (asset names + tags, incl. apt/dnf strings), MAINTENANCE comment, new v0.8.2 bullet

Verified: cargo build (gridline v0.8.2) + cargo test --lib 442 passed / 0 failed; bun run test 133 files / 1151 tests exit 0; tsc clean.
2026-09-29 08:57:32 +08:00
adrianbonpin 88e7e45818 feat(mysql): native SQL execution — fix #41 type decoding, in-process restore, .sql open-with (#42)
Fixes #41.

MySQL columns now decode by their declared type instead of a blind try_get chain,
so every non-string type (integers, DECIMAL, BIT, YEAR, DATE/TIME/DATETIME/TIMESTAMP)
renders its value instead of NULL. MySQL restore runs in-process over the sqlx driver
(no bundled MariaDB client, so MySQL 8 caching_sha2_password works) and the Clean toggle
is implemented. .sql files can be opened with Gridline from the OS file manager.

Verified: cargo test --lib 442 passed / 0 failed; bun run test 133 files / 1151 tests
exit 0; tsc clean; both live MySQL 8 integration tests pass with the bundled CLI hidden.
2026-09-29 08:52:47 +08:00
adrianbonpin ce4a73b0bb fix(icons): stop macOS drawing the app icon on a legacy plate (#40)
On macOS 26+ the Dock and any launcher that resolves app icons through
NSWorkspace.icon(forFile:) (Spotlight, Vicinae, Raycast, ...) drew a grey
rounded-square "legacy plate" behind Gridline's icon, i.e. a white border
around the app icon at every render size below ~72px.

Reproduced outside any launcher: NSWorkspace.icon(forFile:) on
Gridline.app returns the plated icon at 16-64px and the clean icon at
>=72px. The trigger lives in icon.icns, not in the app -- moving that file
into Zed.app reproduces the border, moving Zed's icns into Gridline.app
removes it.

Two things in the file cause it:

  - ic12 (64x64)                     -> plates the 40-64px range
  - is32/s8mk (16x16 RGB+mask)
    il32/l8mk (32x32 RGB+mask)       -> plates the 16-32px range

macOS treats padded small representations as legacy, and this icns carries
them in both places.

Fix, in two parts.

1. Strip those chunks. scripts/fix-icns.mjs does it; `tauri icon` re-emits
   them on every run, so it has to be a committed, repeatable step.
   scripts/verify-app-icon.mjs is the regression check: it builds a
   throwaway .app around the committed icon.icns, asks AppKit to render it
   at 16..512px and fails if any size comes back plated. It fails (6/11
   plated) on the previous icns and passes on this one.

2. Regenerate the raster set from icon.svg via
   scripts/regenerate-icons.sh, so the icons directory matches its source
   of truth again. The SVG has been full-bleed since the initial commit
   (md5 unchanged at d872e42/3dab09f/6854d88/HEAD) while the rasters were
   replaced with a hand-tuned padded version at v0.2.0 and had drifted
   from it ever since. The script generates into a temp dir because
   `tauri icon` only takes one output directory and would otherwise drop
   generated android/ and ios/ trees into the desktop-only icons dir.

No visual change on macOS: IconServices normalizes app-icon artwork when
rendering, so padded and full-bleed sets come out pixel-identical there
(same bbox and art fraction at 48/128/512px).

Windows and Linux do consume the PNG/ICO directly, so the icon there does
change from padded to full-bleed -- consistent with the SVG, but a
visible difference worth eyeballing before release.

Verified: verify-app-icon passes at 16, 24, 32, 40, 48, 64, 72, 96, 128,
256, 512px; iconutil and `file` still decode the new .icns and .ico
(6 sizes); frontend tests pass (131 files / 1134 tests).
2026-09-28 18:03:22 +08:00
adrianbonpin 8b08ad0e46 chore(release): point the macOS fallback script and docs at GitHub (#39)
`scripts/release-mac.sh` still targeted the retired Gitea release flow: it
looked for `.gitea/workflows/release.yml`, keyed off GITEA_SERVER/REPO_OWNER,
and created/uploaded assets with `tea`. Releases are GitHub-only now, so the
script uploads with `gh release upload --clobber` (a locally signed DMG pair
supersedes the CI-built ones, which the next draft publish then ships).

Also corrects the release docs, which contradicted each other: AGENTS.md said
macOS is built manually and never in CI, while README said CI signs it. In
reality the `release.yml` macOS jobs sign + notarize when the APPLE_* secrets
are present and log a `notice` when they are not — so the checklist now says to
read the run output before publishing a draft (verified on v0.8.1: stapler
validate + spctl report `source=Notarized Developer ID`), lists the local
`scripts/release-mac.sh` as the fallback, and documents the cask bump that the
`release: published` event triggers.

The "keep versions in sync" checklist was also incomplete and had already been
the source of drift: it missed `desktop/src-tauri/Cargo.lock` (only the
`gridline` entry — the sqlx family shares 0.8.x), `www/public/landing.js`, the
site changelog, `bundle-config.test.ts`, the two README banners and the
MAINTENANCE comment.
2026-09-24 04:14:11 +08:00
adrianbonpin b17bf95b6d chore(mirror): keep the Gitea fallback current from local git (#38)
GitHub is the source of truth; Gitea (origin) is a just-in-case copy that had
drifted two commits behind. Rather than require a credential on GitHub's side
or convert the repo to a read-only Gitea mirror, wire it into the local
workflow:

- scripts/mirror-gitea.sh — pushes prod + all tags to the Gitea remote
  (--branches for every branch). Refuses to push to a GitHub remote, prints
  what it did, exits non-zero when the mirror could not be updated.
- scripts/hooks/pre-push — git has no post-push hook, so the mirror runs before
  a push that includes prod/main or a tag; skips other remotes, deletions and
  its own recursion.
- scripts/hooks/post-merge — refreshes the mirror after `git pull`/merge on
  prod, which is how a PR squash-merged on GitHub reaches the mirror.
- scripts/install-git-hooks.sh — one-time per clone; sets
  core.hooksPath=scripts/hooks so the hooks stay versioned with the repo.

Mirror failures are warnings: a Gitea outage never blocks a push to GitHub.
AGENTS.md documents the setup plus the GitHub-only release flow.

Verified: script + hooks exercised for the prod/tag case, the feature-branch
skip, the Gitea-remote skip and the recursion guard; the mirror is idempotent
(no-op when already in sync).
2026-09-24 03:56:27 +08:00
adrianbonpin 9089f313f8 fix(keychain) + release: macOS keychain ACL self-repair, bump 0.8.1 (#37)
Release / linux (push) Successful in 22m30s
Release / windows (push) Canceled after 0s
* fix(keychain): repair stale macOS keychain ACLs before reads

macOS records the trusted application of a legacy keychain item as the
executable *path* for an ad-hoc (dev/linker-signed) build, and as a code
requirement + cdhash partition list for a signed bundle. When the path
changes — the monorepo move (`src-tauri/` -> `desktop/src-tauri/`), a
reinstalled app, running the installed app after a dev build — the ACL no
longer admits the running binary, so macOS demands the keychain password on
*every* access: once per secret, every time, with no "Always Allow".

The pin only ran after writes, and writing to an inaccessible item needs the
same authorization, so the ACL was never repaired.

- keychain_acl::is_item_trusted() answers "am I already in this item's ACL?"
  with a metadata-only, prompt-free lookup (SecKeychainItemCopyAccess +
  SecAccessCopyACLList + comparison against this process's own
  SecTrustedApplication representation; a signed bundle's `cdhash:` partition
  lists count as trusted and are left alone).
- ensure_item_acl() now no-ops when already trusted (no duplicate ACL entries,
  no redundant authorization) and otherwise pins exactly as before.
- New before_read() hook, called from every keychain read path (DB password,
  SSH password, SSH passphrase — commands and internal helpers), so a stale
  item costs one authorization and is silent from then on.
- ItemSearchOptions reports "nothing matched" as errSecItemNotFound, which is
  now handled as "no item" instead of a lookup error — previously every
  connection without a stored password logged a spurious failure.

Verified: cargo test --lib (408 passed) plus the ignored live test
(`cargo test --lib -- --ignored keychain_acl`), which asserts a fresh item is
already trusted, repairs are idempotent and the secret still reads back. That
test now finishes in 0.25s instead of ~8s because the redundant
SecKeychainItemSetAccess is gone.

* chore(release): bump 0.8.1 and document GitHub-only releases

Version 0.8.0 -> 0.8.1 across every synced location: desktop/package.json,
desktop/src-tauri/Cargo.toml (+ Cargo.lock gridline entry), tauri.conf.json,
www/src/pages/index.astro, www/public/landing.js, both README download tables
(+ the MAINTENANCE note), and the version/docs-coverage/bundle-config tests.
Adds the 0.8.1 changelog entry (site + README Recent Changes).

AGENTS.md: releases are GitHub-only — tag `prod` and `git push github vN.M.N`
(origin/Gitea is a just-in-case mirror; never push branches, PRs or tags
there), and the keychain row documents the self-healing ACL repair.
2026-09-24 03:43:46 +08:00
adrianbonpin 3454ce67fe fix(connections): persist SSL settings and honor a URL's sslmode (#36)
Connecting to a managed provider (Neon in the report) failed with
"connection is insecure (try using `sslmode=require`)" because the TLS mode
never reached the backend:

- ConnectionFormData carried no ssl_* fields and both submit handlers
  (NewConnectionScreen.buildPayload, EditConnectionModal save + test) omitted
  them, so the SSH/SSL tab's mode was silently dropped before IPC.
- SslFields wrote ssl_ca_cert / ssl_client_cert / ssl_client_key — names the
  Rust ConnectionInput ignores (it expects ssl_ca_path / ssl_cert_path /
  ssl_key_path).
- parseConnectionString ignored query params, so a pasted Neon/Supabase URL's
  ?sslmode=require was discarded.

Pasting a URI now fills every setting it can (sslmode / ssl-mode, including
MySQL's REQUIRED and VERIFY_IDENTITY spellings) and offers a derived label
(database name, else host/file stem) as a placeholder that Test/Save uses when
the field is left blank. Managed presets pre-select their TLS mode (Supabase /
Neon → Require, PlanetScale → Verify Full).

Backend safety net: db::tls::resolve_ssl_mode defaults *.neon.tech /
*.supabase.co / *.psdb.cloud to `require` when a connection has no ssl_mode, so
connections saved before this fix keep working; an explicit mode (including
disable) always wins.

Tests: +15 frontend (parser, form payload, SSL field names, edit modal),
+4 Rust (resolve_ssl_mode / tls_decision_for).
2026-09-24 03:25:13 +08:00
adrianbonpin f807c22a36 docs(www): point the website, docs and badges back at GitHub (#35)
* docs(www): point the website, docs and badges back at GitHub

GitHub is the release destination again, so every user-facing reference to the
self-hosted Gitea instance now points at github.com/AdrianBonpin:

- www: nav + footer links, Download / Releases buttons, 'View on GitHub' and
  'Star on GitHub' CTAs, landing.js download fallback label, the repo
  preconnect/dns-prefetch hints, and the ROADMAP link (Gitea's
  src/branch/prod path becomes GitHub's blob/prod)
- www/src/lib/seo.test.ts: matching structured-data fixtures
- README: shields.io badge and the tap prose
- ROADMAP: Homebrew tap URL

Deliberately left pointing at Gitea (they are the fallback, not destinations):
.gitea/workflows/release.yml and scripts/release-mac.sh use git.ranio.xyz as
an API host, so rewriting those would break the fallback path.

* fix(www): update the stale FAQ count assertion

faq.test.ts asserted exactly 5 FAQ items but the list has had 6 since
bdf2bb5 (Release 0.7.15), so the www suite has been red since then. No CI
runs the www tests, which is why it went unnoticed. Found while verifying the
docs change above; unrelated to it.
2026-09-20 17:43:15 +08:00
adrianbonpin e4de489f53 fix(release): notarize and staple the DMGs, not just the .app (#34)
tauri-action notarizes and staples the .app but never the .dmg wrapping it, so
Gatekeeper rejected the downloaded DMG as "Unnotarized Developer ID" even
though the app inside was fine:

  spctl --assess --type open --context context:primary-signature -v \
    Gridline_0.8.0_aarch64.dmg
  -> rejected, source=Unnotarized Developer ID

Gatekeeper assesses the DMG itself when a user opens the download, so the app
being notarized does not save the mount. Submit each DMG to the same notary
service and staple the ticket into it.

- release.yml: new macOS-only step after tauri-action replaces each uploaded
  DMG with its stapled copy. Assets are replaced via the release ID because the
  GitHub releases/tags endpoint 404s for drafts, so uploading by tag cannot
  work while the release is still a draft.
- release-mac.sh: same notarize + staple pass for the local/Gitea path before
  upload, respecting GRIDLINE_SKIP_NOTARIZE.
- README: state that both the .app and the .dmg carry a stapled ticket.
2026-09-20 16:55:27 +08:00
adrianbonpin fdbfd3d0d2 fix(release): make the cask bump reliably runnable (#33)
The `release: published` trigger does not fire for a release whose draft was
created by tauri-action's GITHUB_TOKEN — verified on v0.8.0, which produced
zero workflow runs after publishing, so the Homebrew cask was never bumped and
the tap stayed empty.

Adds `bump_cask` to the workflow_dispatch inputs so the cask bump can be run
on demand for any tag, and switches the cask job to RELEASE_TAG (which resolves
for both dispatch and release events) instead of GITHUB_REF_NAME.

Also stops the cask template's header comment from being rewritten with the
substituted version and checksums on every render.
2026-09-20 16:37:33 +08:00
adrianbonpin 228660044a feat(release): allow rebuilding an existing tag via workflow_dispatch (#32)
Adds a `tag` input so a release can be re-run without rewriting its tag.
Tag-triggered runs use the workflow file at the tagged commit, so fixing the
workflow and re-pushing the same tag was previously the only way to rebuild —
which rewrites a published release ref and misrepresents what the tag points
at.

RELEASE_TAG resolves to the input on workflow_dispatch and to the ref
otherwise, and drives both the checkout ref and tauri-action's tagName.
2026-09-20 16:06:44 +08:00
adrianbonpin c4dc1c1c83 chore(release): push the Homebrew cask with a deploy key (#31)
Replace the HOMEBREW_TAP_TOKEN PAT with HOMEBREW_TAP_DEPLOY_KEY, a
write-enabled SSH deploy key scoped to only AdrianBonpin/homebrew-gridline.
actions/checkout's `ssh-key` input wires the key into the SSH agent for the
job, so the cask push goes over SSH and no broadly-scoped personal access
token has to be stored as a repository secret.

Also drops the now-wrong claim that the job no-ops until the secret is set.
2026-09-20 16:05:29 +08:00
adrianbonpin f1d9fdae3a fix(release): write the notarization .p8 with a trailing newline (#30)
`gh secret set` strips the trailing newline when storing APPLE_API_KEY_CONTENT,
and Apple's PEM parser expects the key file to end with one. Write it back
explicitly so notarization can load the key.
2026-09-20 15:58:05 +08:00
adrianbonpin 18157f453f ci(release): make GitHub Actions the primary release path (#29)
GitHub is unsuspended, so releases move back from Gitea to GitHub, with the
Gitea mirror kept as a fallback.

- release.yml: re-enable as the active path and wire up Developer-ID signing
  + notarization for the macOS jobs. The Apple steps are guarded on the
  secrets being present, so the workflow stays green (ad-hoc signed) until
  they are configured.
- release.yml: re-sign the bundled PostgreSQL/MariaDB client binaries with
  the Developer ID identity + hardened runtime + timestamp — Apple rejects
  ad-hoc-signed nested executables during notarization.
- release.yml: move the Homebrew cask bump to the `release: published` event.
  It previously ran on tag push, but the releases/tags API does not serve
  drafts, so it could never download the DMGs. It now also skips cleanly
  when HOMEBREW_TAP_TOKEN is unset rather than failing the workflow.
- release.yml: drop the stale header note claiming this is not the active
  release path.
- README + cask template: point downloads, issues, releases and the Homebrew
  tap back at github.com/AdrianBonpin.
2026-09-20 15:55:59 +08:00
90 changed files with 4128 additions and 454 deletions
+209 -14
View File
@@ -7,22 +7,65 @@ name: Release
# git checkout prod && git pull
# git tag v0.5.0 && git push origin v0.5.0
#
# SIGNING STATUS: macOS is Developer-ID signed + notarized via
# scripts/release-mac.sh (built manually on Adrian's machine). NOTE: this
# GitHub workflow is NOT the active release path — Gitea Actions uses
# .gitea/workflows/release.yml (Linux/Windows only). Linux/Windows installers
# SIGNING STATUS: this is the ACTIVE release path. macOS is Developer-ID
# signed + notarized in CI when the Apple secrets below are configured (see
# README "macOS signing & notarization"). When the secrets are absent the mac
# jobs still build, but ad-hoc signed / unnotarized. Linux/Windows installers
# remain unsigned.
#
# Required secrets for signed + notarized macOS builds:
# APPLE_CERTIFICATE base64 of the Developer ID Application .p12
# APPLE_CERTIFICATE_PASSWORD password for that .p12
# APPLE_SIGNING_IDENTITY "Developer ID Application: Name (TEAMID)"
# KEYCHAIN_PASSWORD any throwaway password for the temp keychain
# APPLE_API_KEY App Store Connect Key ID
# APPLE_API_ISSUER App Store Connect Issuer ID
# APPLE_API_KEY_CONTENT contents of the AuthKey_<KeyID>.p8
#
# The cask bump runs only after the draft release is PUBLISHED (the GitHub
# releases/tags API does not serve drafts), so it is wired to the `release:
# published` event rather than the tag push. It pushes with a write-enabled
# deploy key scoped to ONLY the tap repo (HOMEBREW_TAP_DEPLOY_KEY), so no
# broad personal access token is stored — the default GITHUB_TOKEN cannot
# push to a different repository.
on:
push:
tags:
- 'v*'
release:
types: [published]
# Rebuild an EXISTING tag without moving it — e.g. to re-run a release after
# a workflow fix. Dispatch from `prod` and pass the tag to build; the tag
# itself is never rewritten, so the release ref stays truthful.
workflow_dispatch:
inputs:
tag:
description: 'Tag to build or bump the cask for, e.g. v0.8.0'
required: true
bump_cask:
description: 'Only bump the Homebrew cask for `tag` (skip the build)'
type: boolean
default: false
permissions:
contents: write
env:
# workflow_dispatch passes the tag as an input; push/release events derive it
# from the ref. Drives the checkout ref and tauri-action's tagName.
RELEASE_TAG: ${{ github.event.inputs.tag || github.ref_name }}
jobs:
publish:
# Build + draft release happen on a tag push or a dispatch that isn't a
# cask-only run. The `release: published` event exists only to trigger the
# cask bump below.
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.bump_cask != true)
env:
# Empty when the secret is unset — the tool steps below test for a
# non-empty value before re-signing.
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
strategy:
fail-fast: false
matrix:
@@ -39,6 +82,69 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ env.RELEASE_TAG }}
# ---- Apple signing setup (macOS only, skipped when secrets absent) ---
- name: Detect Apple signing secrets
id: apple
shell: bash
run: |
if [ -n "${{ secrets.APPLE_CERTIFICATE }}" ]; then
echo "enabled=true" >> "$GITHUB_OUTPUT"
else
echo "enabled=false" >> "$GITHUB_OUTPUT"
echo "::notice::APPLE_CERTIFICATE not set — macOS build will be ad-hoc signed and NOT notarized."
fi
# The bundled pg/mariadb tools are re-signed in their own steps below, so
# the identity must be in a keychain BEFORE those steps run. tauri-action
# is deliberately NOT given APPLE_CERTIFICATE (only the identity), so it
# does not create a second keychain.
- name: Import Apple Developer ID certificate
if: startsWith(matrix.platform, 'macos') && steps.apple.outputs.enabled == 'true'
shell: bash
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
run: |
set -euo pipefail
KEYCHAIN="gridline-signing.keychain-db"
echo "$APPLE_CERTIFICATE" | base64 --decode > "$RUNNER_TEMP/cert.p12"
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
security set-keychain-settings -lut 21600 "$KEYCHAIN"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
security import "$RUNNER_TEMP/cert.p12" -k "$KEYCHAIN" \
-P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign
security set-key-partition-list -S apple-tool:,apple:,codesign: \
-s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN" >/dev/null
EXISTING="$(security list-keychain -d user | tr -d ' \"')"
security list-keychain -d user -s "$KEYCHAIN" $EXISTING
rm -f "$RUNNER_TEMP/cert.p12"
security find-identity -v -p codesigning
- name: Prepare notarization credentials
if: startsWith(matrix.platform, 'macos') && steps.apple.outputs.enabled == 'true'
shell: bash
env:
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
APPLE_API_KEY_CONTENT: ${{ secrets.APPLE_API_KEY_CONTENT }}
run: |
set -euo pipefail
KEY_PATH="$RUNNER_TEMP/private_keys/AuthKey_${APPLE_API_KEY}.p8"
mkdir -p "$(dirname "$KEY_PATH")"
# Apple's PEM parser expects a terminating newline, which
# `gh secret set` strips from the stored value — so write one
# explicitly. A doubled trailing newline is harmless to PEM parsers,
# but a missing one can make notarization fail to load the key.
printf '%s\n' "$APPLE_API_KEY_CONTENT" > "$KEY_PATH"
{
echo "APPLE_API_KEY=${APPLE_API_KEY}"
echo "APPLE_API_ISSUER=${APPLE_API_ISSUER}"
echo "APPLE_API_KEY_PATH=${KEY_PATH}"
} >> "$GITHUB_ENV"
- name: Install Linux dependencies
if: matrix.platform == 'ubuntu-22.04'
@@ -131,6 +237,16 @@ jobs:
libpq=$(otool -L "$OUT/$b" | awk '/libpq/ {print $1; exit}')
install_name_tool -change "$libpq" "@loader_path/libpq.5.dylib" "$OUT/$b"
done
# install_name_tool invalidates the linker signature. Re-sign with
# the Developer ID identity + hardened runtime + secure timestamp:
# Apple rejects ad-hoc-signed nested executables at notarization.
# No-op until the Apple secrets are configured.
if [ -n "${APPLE_SIGNING_IDENTITY:-}" ]; then
for f in "$OUT"/pg_dump "$OUT"/pg_restore "$OUT"/psql "$OUT"/libpq.5.dylib; do
codesign --force --options runtime --timestamp \
--sign "$APPLE_SIGNING_IDENTITY" "$f"
done
fi
;;
windows-latest)
URL="https://get.enterprisedb.com/postgresql/postgresql-${PG_VER}-1-windows-x64-binaries.zip"
@@ -263,7 +379,12 @@ jobs:
if [ ! -f "$OUT/$base" ]; then cp "$lib" "$OUT/$base" 2>/dev/null || true; fi
install_name_tool -change "$lib" "@loader_path/$base" "$dylib" 2>/dev/null || true
done || true
if [ -n "${APPLE_SIGNING_IDENTITY:-}" ]; then
codesign --force --options runtime --timestamp \
--sign "$APPLE_SIGNING_IDENTITY" "$dylib"
else
codesign --force --sign - "$dylib" 2>/dev/null || true
fi
done
done
;;
@@ -309,8 +430,8 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
projectPath: desktop
tagName: ${{ github.ref_name }}
releaseName: 'Gridline ${{ github.ref_name }}'
tagName: ${{ env.RELEASE_TAG }}
releaseName: 'Gridline ${{ env.RELEASE_TAG }}'
releaseDraft: true
args: ${{ matrix.args }}
# Assets use tauri-action's default versioned naming (e.g.
@@ -318,44 +439,118 @@ jobs:
# README download tables link to them statically per release —
# remember to update both README tables when cutting a new version.
# tauri-action notarizes + staples the .app but NOT the .dmg wrapping it,
# so Gatekeeper rejects the downloaded DMG as "Unnotarized Developer ID"
# even though the app inside is fine. Submit the DMG to the same notary
# service, staple the ticket, and replace the uploaded asset.
#
# Assets are replaced via the release ID rather than `gh release upload`:
# the GitHub releases/tags endpoint returns 404 for drafts, so uploading
# by tag cannot work while the release is still a draft.
- name: Notarize and staple the DMGs
if: startsWith(matrix.platform, 'macos') && steps.apple.outputs.enabled == 'true'
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
shopt -s nullglob
dmgs=(desktop/src-tauri/target/*/release/bundle/dmg/*.dmg)
if [ ${#dmgs[@]} -eq 0 ]; then
echo "no DMG produced" >&2
exit 1
fi
REL_ID=$(gh api "repos/${GITHUB_REPOSITORY}/releases?per_page=100" \
--jq ".[] | select(.tag_name == \"${RELEASE_TAG}\") | .id" | head -1)
if [ -z "$REL_ID" ]; then
echo "could not resolve a release id for ${RELEASE_TAG}" >&2
exit 1
fi
for dmg in "${dmgs[@]}"; do
name=$(basename "$dmg")
echo ">> notarizing $name (this can take a few minutes)"
xcrun notarytool submit "$dmg" \
--key "$APPLE_API_KEY_PATH" \
--key-id "$APPLE_API_KEY" \
--issuer "$APPLE_API_ISSUER" \
--wait
xcrun stapler staple "$dmg"
xcrun stapler validate "$dmg"
# Replace the copy tauri-action uploaded, which carries no DMG ticket.
old=$(gh api "repos/${GITHUB_REPOSITORY}/releases/${REL_ID}/assets" \
--jq ".[] | select(.name == \"${name}\") | .id" | head -1)
if [ -n "$old" ]; then
gh api -X DELETE "repos/${GITHUB_REPOSITORY}/releases/assets/${old}"
fi
echo ">> re-uploading stapled $name"
curl -fsS -X POST \
-H "Authorization: token ${GH_TOKEN}" \
-H "Content-Type: application/octet-stream" \
--data-binary "@${dmg}" \
"https://uploads.github.com/repos/${GITHUB_REPOSITORY}/releases/${REL_ID}/assets?name=${name}"
echo
done
echo "all DMGs notarized and stapled"
# After the installers are uploaded, refresh the Homebrew cask in the
# AdrianBonpin/homebrew-gridline tap: hash the freshly-published DMGs,
# render Casks/gridline.rb from the template, and push it. Users then get
# the new version via `brew upgrade --cask gridline`.
#
# Requires the HOMEBREW_TAP_TOKEN secret (a PAT with `repo` scope on the tap
# repo — the default GITHUB_TOKEN cannot push to a different repo). The job
# no-ops until that secret is set, so releases stay green before this is
# Requires the HOMEBREW_TAP_DEPLOY_KEY secret — a write-enabled SSH deploy
# key on the tap repo (the default GITHUB_TOKEN cannot push to a different
# repository, and a deploy key avoids storing a broad PAT). The job skips
# cleanly until that secret is set, so releases stay green before this is
# configured.
bump-cask:
needs: publish
# Normally fires on `release: published`, which requires the DMGs to be
# downloadable — the GitHub releases/tags API returns 404 for drafts, so
# this cannot run on the tag push. NOTE: a release whose DRAFT was created
# by tauri-action's GITHUB_TOKEN does not reliably emit a `release` event,
# so `workflow_dispatch` with bump_cask=true is the dependable path.
if: github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && inputs.bump_cask == true)
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Check for tap deploy key
id: tap
shell: bash
run: |
if [ -n "${{ secrets.HOMEBREW_TAP_DEPLOY_KEY }}" ]; then
echo "enabled=true" >> "$GITHUB_OUTPUT"
else
echo "enabled=false" >> "$GITHUB_OUTPUT"
echo "::notice::HOMEBREW_TAP_DEPLOY_KEY not set - skipping Homebrew cask bump."
fi
- name: Checkout
if: steps.tap.outputs.enabled == 'true'
uses: actions/checkout@v4
- name: Resolve version and DMG checksums
id: meta
if: steps.tap.outputs.enabled == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
VERSION="${GITHUB_REF_NAME#v}"
gh release download "$GITHUB_REF_NAME" -p "Gridline_${VERSION}_aarch64.dmg" -O /tmp/arm.dmg
gh release download "$GITHUB_REF_NAME" -p "Gridline_${VERSION}_x64.dmg" -O /tmp/intel.dmg
VERSION="${RELEASE_TAG#v}"
gh release download "$RELEASE_TAG" -p "Gridline_${VERSION}_aarch64.dmg" -O /tmp/arm.dmg
gh release download "$RELEASE_TAG" -p "Gridline_${VERSION}_x64.dmg" -O /tmp/intel.dmg
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "sha_arm=$(shasum -a 256 /tmp/arm.dmg | cut -d' ' -f1)" >> "$GITHUB_OUTPUT"
echo "sha_intel=$(shasum -a 256 /tmp/intel.dmg | cut -d' ' -f1)" >> "$GITHUB_OUTPUT"
- name: Checkout tap repo
if: steps.tap.outputs.enabled == 'true'
uses: actions/checkout@v4
with:
repository: AdrianBonpin/homebrew-gridline
token: ${{ secrets.HOMEBREW_TAP_TOKEN }}
ssh-key: ${{ secrets.HOMEBREW_TAP_DEPLOY_KEY }}
path: tap
- name: Render and push cask
if: steps.tap.outputs.enabled == 'true'
env:
VERSION: ${{ steps.meta.outputs.version }}
SHA_ARM: ${{ steps.meta.outputs.sha_arm }}
+48 -15
View File
@@ -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) | ❌ | |
+45 -35
View File
@@ -18,34 +18,34 @@
<a href="https://react.dev/"><img src="https://img.shields.io/badge/react-%2320232a.svg?style=for-the-badge&logo=react&logoColor=%2361DAFB" alt="React" /></a>
<a href="https://www.typescriptlang.org/"><img src="https://img.shields.io/badge/TypeScript-3178C6?style=for-the-badge&logo=typescript&logoColor=white" alt="TypeScript" /></a>
<a href="./LICENSE"><img src="https://img.shields.io/badge/License-Apache_2.0-D22128.svg?style=for-the-badge" alt="Apache 2.0 License" /></a>
<a href="https://git.ranio.xyz/adrianbonpin/gridline"><img src="https://img.shields.io/badge/Open_Source-Gitea-4183C4.svg?style=for-the-badge" alt="Open source on Gitea" /></a>
<a href="https://github.com/AdrianBonpin/gridline"><img src="https://img.shields.io/badge/Open_Source-GitHub-181717.svg?style=for-the-badge" alt="Open source on GitHub" /></a>
</p>
<p>
<a href="https://git.ranio.xyz/adrianbonpin/gridline/releases"><img src="https://img.shields.io/badge/Download_Latest_Release-2ea44f?style=for-the-badge" alt="Download Latest" /></a>
<a href="https://git.ranio.xyz/adrianbonpin/gridline/issues/new"><img src="https://img.shields.io/badge/Open_an_Issue-%23E4405F.svg?style=for-the-badge" alt="Open an Issue" /></a>
<a href="https://github.com/AdrianBonpin/gridline/releases"><img src="https://img.shields.io/badge/Download_Latest_Release-2ea44f?style=for-the-badge" alt="Download Latest" /></a>
<a href="https://github.com/AdrianBonpin/gridline/issues/new"><img src="https://img.shields.io/badge/Open_an_Issue-%23E4405F.svg?style=for-the-badge" alt="Open an Issue" /></a>
</p>
---
## Download
Grab the installer for your OS from the [latest release](https://git.ranio.xyz/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://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.8.0/Gridline_0.8.0_aarch64.dmg) |
| **macOS** | Intel | [Gridline_0.8.0_x64.dmg](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.8.0/Gridline_0.8.0_x64.dmg) |
| **Windows** | x64 | [Gridline_0.8.0_x64-setup.exe](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.8.0/Gridline_0.8.0_x64-setup.exe) |
| **Debian / Ubuntu** | amd64 | [Gridline_0.8.0_amd64.deb](https://git.ranio.xyz/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://git.ranio.xyz/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://git.ranio.xyz/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):
```bash
# one-time setup (the tap is hosted on Gitea, not GitHub):
brew tap AdrianBonpin/gridline https://git.ranio.xyz/adrianbonpin/homebrew-gridline.git
# one-time setup (the tap is hosted on GitHub):
brew tap AdrianBonpin/gridline https://github.com/AdrianBonpin/homebrew-gridline.git
brew trust adrianbonpin/gridline
# install / upgrade:
@@ -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,9 +102,13 @@ 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://git.ranio.xyz/adrianbonpin/homebrew-gridline.git` then `brew install --cask gridline`; 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.
- **v0.7.13** — **macOS is now Developer-ID signed and notarized**: the app is properly code-signed with a Developer ID Application certificate and notarized by Apple, so macOS opens it without the Gatekeeper "Open Anyway" prompt or the `xattr` quarantine workaround. The release script (`scripts/release-mac.sh`) auto-detects the signing identity from the keychain, notarizes + staples both architectures, and verifies the result before uploading; version bump.
@@ -312,44 +316,48 @@ _Notes: DB Pro is an Electron app (launched Nov 2025) whose marketing copy overc
### Download a release
Pre-built installers for macOS, Windows, and Linux are published on the [Releases](https://git.ranio.xyz/adrianbonpin/gridline/releases) page.
Pre-built installers for macOS, Windows, and Linux are published on the [Releases](https://github.com/AdrianBonpin/gridline/releases) page.
> ⚠️ Gridline is under active development. Expect rough edges and please [open issues](https://git.ranio.xyz/adrianbonpin/gridline/issues/new) when you hit them.
> ⚠️ Gridline is under active development. Expect rough edges and please [open issues](https://github.com/AdrianBonpin/gridline/issues/new) when you hit them.
#### macOS signing & notarization
Gridline's macOS builds are **Developer-ID signed and notarized**, so macOS opens them without a Gatekeeper prompt. This is configured on the machine that runs `scripts/release-mac.sh` (macOS is built manually, not in CI).
Gridline's macOS builds are **Developer-ID signed and notarized by GitHub Actions**, so macOS opens them without a Gatekeeper prompt. Both the `.app` and the `.dmg` it ships inside are notarized and carry a stapled ticket — notarizing only the `.app` is not enough, because Gatekeeper assesses the downloaded DMG itself and rejects it as "Unnotarized Developer ID" even when the app within is fine.
**One-time setup** (only the repo owner needs to do this):
1. **Create a Developer ID Application certificate** — [developer.apple.com](https://developer.apple.com) → Certificates, Identifiers & Profiles → **+** → **Developer ID Application**. Generate a Certificate Signing Request from Keychain Access (Certificate Assistant → *Request a Certificate From a Certificate Authority*), upload it, download the `.cer`, and double-click to install into your login keychain. Verify with `security find-identity -v -p codesigning`.
2. **Create an App Store Connect API key** for notarization — [appstoreconnect.apple.com](https://appstoreconnect.apple.com) → Users and Access → **Integrations** → **App Store Connect API** → **+** (name it, *Developer* access). Download the `.p8` (shown once) and note the **Key ID** and **Issuer ID**.
3. **Save the notarization credentials** to `~/.config/gridline/notarize.env` (never committed):
3. **Add the signing secrets** to the repository (**Settings → Secrets and variables → Actions**):
```bash
APPLE_API_KEY=<Key ID>
APPLE_API_ISSUER=<Issuer ID>
APPLE_API_KEY_PATH=/absolute/path/to/AuthKey_<KeyID>.p8
```
| Secret | Value |
| :--------------------------- | :------------------------------------------------------------------------------------------------- |
| `APPLE_CERTIFICATE` | base64 of the exported Developer ID `.p12` — `base64 -i DeveloperID.p12 \| pbcopy` |
| `APPLE_CERTIFICATE_PASSWORD` | the password you set when exporting the `.p12` |
| `APPLE_SIGNING_IDENTITY` | e.g. `Developer ID Application: Your Name (TEAMID)` — from `security find-identity -v -p codesigning` |
| `KEYCHAIN_PASSWORD` | any throwaway password for the temporary CI keychain |
| `APPLE_API_KEY` | App Store Connect **Key ID** |
| `APPLE_API_ISSUER` | App Store Connect **Issuer ID** |
| `APPLE_API_KEY_CONTENT` | contents of the downloaded `AuthKey_<KeyID>.p8` |
(Alternatively, drop the `.p8` at `~/.appstoreconnect/private_keys/AuthKey_<KeyID>.p8` and omit `APPLE_API_KEY_PATH`.)
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` auto-detects the Developer ID identity from your keychain, 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://git.ranio.xyz/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://git.ranio.xyz/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://git.ranio.xyz/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://git.ranio.xyz/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://git.ranio.xyz/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://git.ranio.xyz/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.
@@ -363,7 +371,9 @@ git tag v0.7.15
git push origin v0.7.15
```
Gitea Actions (`.gitea/workflows/release.yml`) builds the **Linux and Windows** installers on the self-hosted runners, then opens a **draft release** on the [Releases](https://git.ranio.xyz/adrianbonpin/gridline/releases) page. **macOS is built manually** on the dev Mac and its DMGs uploaded to the same release via `scripts/release-mac.sh`. Review the draft and hit **Publish release**.
GitHub Actions (`.github/workflows/release.yml`) builds the **macOS (Apple Silicon + Intel), Windows, and Linux** installers in parallel and opens a **draft release** on the [Releases](https://github.com/AdrianBonpin/gridline/releases) page. Review the draft and hit **Publish release** — publishing triggers a follow-up job that bumps the Homebrew cask in [`AdrianBonpin/homebrew-gridline`](https://github.com/AdrianBonpin/homebrew-gridline) (pushed via a write-enabled deploy key on the tap, `HOMEBREW_TAP_DEPLOY_KEY`; skipped when unset).
The self-hosted Gitea mirror (`.gitea/workflows/release.yml`) still builds Linux and Windows for the Gitea release, and `scripts/release-mac.sh` can build macOS locally — both are kept as a fallback if GitHub is unavailable.
Before tagging, make sure the version number is in sync across `desktop/package.json`, `desktop/src-tauri/Cargo.toml`, and `desktop/src-tauri/tauri.conf.json`, and update the **README download tables** (Download + Which file should I download?) to the new version's asset names.
@@ -371,7 +381,7 @@ Before tagging, make sure the version number is in sync across `desktop/package.
```bash
# 1. Clone the repository
git clone https://git.ranio.xyz/adrianbonpin/gridline.git
git clone https://github.com/AdrianBonpin/gridline.git
cd gridline
# 2. Install all workspace dependencies
@@ -487,7 +497,7 @@ Highlights of what's next:
Contributions, bug reports, and feature ideas are welcome. Gridline is Apache 2.0-licensed and intentionally stays open — no paywalled tiers, no bundled proprietary services.
- Open an issue via the **[template chooser](https://git.ranio.xyz/adrianbonpin/gridline/issues/new)** — pick **Bug report** (with an environment table so we can reproduce issues quickly), **Feature request**, or **Improvement** (UX polish for existing features). New issues are auto-assigned to the maintainer.
- Open an issue via the **[template chooser](https://github.com/AdrianBonpin/gridline/issues/new)** — pick **Bug report** (with an environment table so we can reproduce issues quickly), **Feature request**, or **Improvement** (UX polish for existing features). New issues are auto-assigned to the maintainer.
- Submit a pull request. Keep Tauri commands thin, type IPC boundaries explicitly, and follow the existing Rust/React conventions.
---
+8 -3
View File
@@ -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
@@ -104,7 +109,7 @@ Still deferred from 0.7.0, slated for this bucket:
## ✅ Shipped (0.7.14)
- **No more repeated macOS keychain prompts** — saved connection passwords and SSH secrets are ACL-pinned to the app's code signature when stored, so app upgrades never re-trigger the macOS keychain access dialog (users upgrading from older builds see one final prompt — choose Always Allow)
- **Install via Homebrew** — add the Gitea tap (`brew tap AdrianBonpin/gridline https://git.ranio.xyz/adrianbonpin/homebrew-gridline.git`) and run `brew install --cask gridline`
- **Install via Homebrew** — add the Homebrew tap (`brew tap AdrianBonpin/gridline https://github.com/AdrianBonpin/homebrew-gridline.git`) and run `brew install --cask gridline`
- **Version bump** 0.7.13 → **0.7.14**.
## ✅ Shipped (0.7.13)
@@ -112,7 +117,7 @@ Still deferred from 0.7.0, slated for this bucket:
- **Developer-ID signed + notarized macOS** — the app is properly code-signed with a Developer ID Application certificate and notarized by Apple, so macOS opens it without the Gatekeeper "Open Anyway" prompt or the `xattr` quarantine workaround
- **Version bump** 0.7.12 → **0.7.13**.
## ✅ Shipped (0.7.12) *(includes 0.7.11: release-pipeline/docs refresh — README download links moved from GitHub to the self-hosted Gitea `git.ranio.xyz`)*
## ✅ Shipped (0.7.12) *(includes 0.7.11: release-pipeline/docs refresh)*
- **Reliable cell editing for non-text columns** — editing a PostgreSQL cell whose type isn't text (integers, booleans, UUIDs, json/jsonb, timestamps, enums, numerics) now works; edited values are sent to PostgreSQL in text wire format so the server parses them into the column's own type
- **Recent connections fix** — the Recent strip no longer renders empty on first launch when the connections list hadn't loaded yet
+1 -1
View File
@@ -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": {
+39 -1
View File
@@ -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"
+11 -2
View File
@@ -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"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1001 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 20 KiB

+57
View File
@@ -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")
+259 -32
View File
@@ -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(&params, &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);
});
+112 -9
View File
@@ -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:?}");
}
+106 -26
View File
@@ -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))
+1
View File
@@ -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");
}
+213 -19
View File
@@ -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,
}
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);
} 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,
}
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);
}
}
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),
}
}
/// 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::<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
View File
@@ -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;
+220
View File
@@ -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:?}");
}
+79 -7
View File
@@ -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(&current) {
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(&current) {
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");
}
+75
View File
@@ -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)
+308 -110
View File
@@ -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) =
+55 -7
View File
@@ -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();
}
}
_ => {}
});
}
+11 -1
View File
@@ -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,
+4
View File
@@ -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);
if (!parsed) {
setManagedPreset(null);
setForm((prev) => ({ ...prev, connection_string: value }));
return;
}
setManagedPreset(providerForParsed(parsed));
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,
...parsedFormPatch(parsed, prev),
}));
} else {
setManagedPreset(null);
setForm((prev) => ({ ...prev, connection_string: value }));
}
}, []);
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) =>
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,15 +202,14 @@ 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;
const toolsBundled = isPg
? pgToolStatus?.pg_restore_source === "bundled"
: isMysql
? mysqlToolStatus?.mysqlSource === "bundled"
: false;
const canStart = filePath && confirmed && !isRunning;
@@ -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"
/>
{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 */}
+1 -1
View File
@@ -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>
);
}
+96
View File
@@ -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");
});
});
});
+93
View File
@@ -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");
}
+13 -3
View File
@@ -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");
});
});
+6 -1
View File
@@ -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,
+78 -1
View File
@@ -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,
});
});
});
+69
View File
@@ -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
+15 -9
View File
@@ -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");
});
});
+7
View File
@@ -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 -2
View File
@@ -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");
});
});
+43
View File
@@ -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();
});
});
+18 -4
View File
@@ -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 });
},
+16 -1
View File
@@ -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();
});
});
+8 -2
View File
@@ -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.
+3 -1
View File
@@ -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"
}
}
+6 -6
View File
@@ -1,9 +1,9 @@
# Homebrew Cask formula for Gridline.
#
# This file is the template; `scripts/render-cask.sh` substitutes
# @VERSION@ / @ARM_SHA256@ / @INTEL_SHA256@ on each release and pushes the
# rendered file to the `AdrianBonpin/homebrew-gridline` tap as
# `Casks/gridline.rb`. End users then install with
# This file is the template; `scripts/render-cask.sh` substitutes the version
# and the per-arch SHA-256 checksums on each release and pushes the rendered
# file to the `AdrianBonpin/homebrew-gridline` tap as `Casks/gridline.rb`.
# End users then install with
#
# brew install --cask AdrianBonpin/gridline/gridline
#
@@ -21,10 +21,10 @@ cask "gridline" do
sha256 arm: "@ARM_SHA256@",
intel: "@INTEL_SHA256@"
url "https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v#{version}/Gridline_#{version}_#{arch}.dmg"
url "https://github.com/AdrianBonpin/gridline/releases/download/v#{version}/Gridline_#{version}_#{arch}.dmg"
name "Gridline"
desc "Open-source, cross-platform database GUI client for PostgreSQL"
homepage "https://git.ranio.xyz/adrianbonpin/gridline"
homepage "https://github.com/AdrianBonpin/gridline"
depends_on macos: :monterey
+143
View File
@@ -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`);
+25
View File
@@ -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
+46
View File
@@ -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
+37
View File
@@ -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"
+90
View File
@@ -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"
+86
View File
@@ -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"
+59 -28
View File
@@ -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
@@ -197,22 +198,52 @@ for APP in \
|| echo " (spctl assessment pending — normal right after notarization; stapler validate passed)"
done
# ---- notarize + staple the DMGs --------------------------------------------
# Notarizing the .app is not enough. When a user opens the downloaded DMG,
# Gatekeeper assesses the DMG itself — and an unnotarized DMG is rejected as
# "Unnotarized Developer ID" even though the app inside it is fine. Submit each
# DMG to the same notary service and staple the ticket into the DMG.
if [ "${GRIDLINE_SKIP_NOTARIZE:-0}" = "1" ]; then
echo ">> Skipping DMG notarization (GRIDLINE_SKIP_NOTARIZE=1)."
else
API_KEY_PATH="${APPLE_API_KEY_PATH:-${HOME}/.appstoreconnect/private_keys/AuthKey_${APPLE_API_KEY}.p8}"
[ -f "$API_KEY_PATH" ] || {
echo "ERROR: notarization API key not found at $API_KEY_PATH" >&2
echo " Set APPLE_API_KEY_PATH in ${NOTARIZE_ENV} or place the .p8 at the default location." >&2
exit 1
}
for DMG in \
"${BUNDLE_ARM}/dmg/Gridline_${VERSION}_aarch64.dmg" \
"${BUNDLE_INTEL}/dmg/Gridline_${VERSION}_x64.dmg"; do
[ -f "$DMG" ] || { echo "missing $DMG" >&2; exit 1; }
echo ">> Notarizing $(basename "$DMG") (this can take a few minutes)"
xcrun notarytool submit "$DMG" \
--key "$API_KEY_PATH" \
--key-id "$APPLE_API_KEY" \
--issuer "$APPLE_API_ISSUER" \
--wait
xcrun stapler staple "$DMG" \
|| { echo "failed to staple $(basename "$DMG")" >&2; exit 1; }
xcrun stapler validate "$DMG" \
|| { echo "notarization ticket not stapled to $(basename "$DMG")" >&2; exit 1; }
done
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}"
+147
View File
@@ -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).`);
+3 -3
View File
@@ -59,12 +59,12 @@
const isMobile = /Mobi|Android|iPhone|iPad|iPod/i.test(userAgent);
const keepLabel = downloadBtn.hasAttribute("data-download-label");
const releasesUrl = "https://git.ranio.xyz/adrianbonpin/gridline/releases";
const version = "0.8.0";
const releasesUrl = "https://github.com/AdrianBonpin/gridline/releases";
const version = "0.8.2";
if (isMobile) {
downloadBtn.href = releasesUrl;
if (!keepLabel) downloadBtn.textContent = "Get it on Gitea";
if (!keepLabel) downloadBtn.textContent = "Get it on GitHub";
} else if (isMac) {
downloadBtn.href = `${releasesUrl}/download/v${version}/Gridline_${version}_aarch64.dmg`;
if (!keepLabel) downloadBtn.textContent = "Download for macOS";
+19 -1
View File
@@ -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",
@@ -32,7 +50,7 @@ export const changelogEntries: ChangelogEntry[] = [
date: "2026-09-04",
highlights: [
"No more repeated macOS keychain prompts — saved connection passwords and SSH secrets are now ACL-pinned to the app's code signature when stored, so app upgrades never re-trigger the macOS keychain access dialog (users upgrading from older builds see one final prompt — choose Always Allow).",
"Install via Homebrew — add the Gitea tap (brew tap AdrianBonpin/gridline https://git.ranio.xyz/adrianbonpin/homebrew-gridline.git) and run brew install --cask gridline.",
"Install via Homebrew — add the Homebrew tap (brew tap AdrianBonpin/gridline https://github.com/AdrianBonpin/homebrew-gridline.git) and run brew install --cask gridline.",
],
},
{
+2 -2
View File
@@ -2,8 +2,8 @@ import { describe, it, expect } from "vitest";
import { faqItems } from "./faq";
describe("faqItems", () => {
it("has exactly 5 items", () => {
expect(faqItems).toHaveLength(5);
it("has exactly 6 items", () => {
expect(faqItems).toHaveLength(6);
});
it("every item has a non-empty question and answer", () => {
+6 -6
View File
@@ -40,9 +40,9 @@ const isProd = import.meta.env.PROD;
<link rel="manifest" href="/site.webmanifest" />
<link rel="preload" as="image" href="/og.png" />
<link rel="preconnect" href="https://stat.ranio.xyz" crossorigin />
<link rel="preconnect" href="https://git.ranio.xyz" />
<link rel="preconnect" href="https://github.com" />
<link rel="dns-prefetch" href="https://stat.ranio.xyz" />
<link rel="dns-prefetch" href="https://git.ranio.xyz" />
<link rel="dns-prefetch" href="https://github.com" />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
@@ -104,7 +104,7 @@ const isProd = import.meta.env.PROD;
</a>
<div class="hidden md:flex items-center gap-6 text-sm">
<a href="/#features" class="nav-link">Features</a>
<a href="https://git.ranio.xyz/adrianbonpin/gridline" target="_blank" rel="noopener" class="nav-link">Gitea</a>
<a href="https://github.com/AdrianBonpin/gridline" target="_blank" rel="noopener" class="nav-link">GitHub</a>
<a href="/#download" data-download data-download-label class="nav-link">Download</a>
<button id="theme-toggle" class="p-2 rounded-full hover:bg-[var(--surface-elevated)] transition-colors" aria-label="Toggle theme">
<span class="theme-icon-sun hidden">☀</span>
@@ -139,9 +139,9 @@ const isProd = import.meta.env.PROD;
<div>
<h4 class="font-semibold text-sm mb-4">Project</h4>
<ul class="space-y-2 text-sm text-[var(--text-muted)]">
<li><a href="https://git.ranio.xyz/adrianbonpin/gridline" target="_blank" rel="noopener" class="footer-link">Gitea</a></li>
<li><a href="https://git.ranio.xyz/adrianbonpin/gridline/issues" target="_blank" rel="noopener" class="footer-link">Issues</a></li>
<li><a href="https://git.ranio.xyz/adrianbonpin/gridline/src/branch/prod/ROADMAP.md" target="_blank" rel="noopener" class="footer-link">Roadmap</a></li>
<li><a href="https://github.com/AdrianBonpin/gridline" target="_blank" rel="noopener" class="footer-link">GitHub</a></li>
<li><a href="https://github.com/AdrianBonpin/gridline/issues" target="_blank" rel="noopener" class="footer-link">Issues</a></li>
<li><a href="https://github.com/AdrianBonpin/gridline/blob/prod/ROADMAP.md" target="_blank" rel="noopener" class="footer-link">Roadmap</a></li>
</ul>
</div>
<div>
+3 -3
View File
@@ -20,7 +20,7 @@ describe("seo builders", () => {
applicationCategory: "DeveloperApplication",
offersPrice: 0,
offersCurrency: "USD",
downloadUrl: "https://git.ranio.xyz/adrianbonpin/gridline/releases",
downloadUrl: "https://github.com/AdrianBonpin/gridline/releases",
});
expect(ld["@type"]).toBe("SoftwareApplication");
expect(ld.name).toBe("Gridline");
@@ -43,10 +43,10 @@ describe("seo builders", () => {
name: "Gridline",
url: "https://getgridline.app/",
logo: "https://getgridline.app/gridline-icon.svg",
sameAs: ["https://git.ranio.xyz/adrianbonpin/gridline"],
sameAs: ["https://github.com/AdrianBonpin/gridline"],
});
expect(ld["@type"]).toBe("Organization");
expect(ld.sameAs).toContain("https://git.ranio.xyz/adrianbonpin/gridline");
expect(ld.sameAs).toContain("https://github.com/AdrianBonpin/gridline");
});
it("buildWebSiteJsonLd includes SearchAction", () => {
+2 -2
View File
@@ -38,8 +38,8 @@ const jsonLd = [
<p class="mt-8 text-sm text-[var(--text-muted)]">
Full release history on{" "}
<a href="https://git.ranio.xyz/adrianbonpin/gridline/releases" target="_blank" rel="noopener" class="text-[var(--primary)] hover:text-[var(--primary-hover)]">
Gitea Releases
<a href="https://github.com/AdrianBonpin/gridline/releases" target="_blank" rel="noopener" class="text-[var(--primary)] hover:text-[var(--primary-hover)]">
GitHub Releases
</a>.
</p>
</div>
+8 -8
View File
@@ -15,7 +15,7 @@ import queryEditor from "../assets/screenshots/query-editor.png";
import erDiagram from "../assets/screenshots/er-diagram.png";
import home from "../assets/screenshots/home.png";
import dataGrid from "../assets/screenshots/data-grid.png";
const version = "0.8.0";
const version = "0.8.2";
const site = "https://getgridline.app";
const jsonLd = [
buildSoftwareApplicationJsonLd({
@@ -28,7 +28,7 @@ const jsonLd = [
applicationCategory: "DeveloperApplication",
offersPrice: 0,
offersCurrency: "USD",
downloadUrl: "https://git.ranio.xyz/adrianbonpin/gridline/releases",
downloadUrl: "https://github.com/AdrianBonpin/gridline/releases",
}),
buildFaqJsonLd(faqItems),
buildOrganizationJsonLd({
@@ -36,7 +36,7 @@ const jsonLd = [
url: `${site}/`,
logo: `${site}/gridline-icon.svg`,
sameAs: [
"https://git.ranio.xyz/adrianbonpin/gridline",
"https://github.com/AdrianBonpin/gridline",
"https://x.com/AdrianBonpin",
],
}),
@@ -73,11 +73,11 @@ const jsonLd = [
Query, explore, sync, and edit PostgreSQL, MySQL, SQLite, and Redis, with no paywalls, no tab limits, no account required.
</p>
<div class="mt-8 flex flex-wrap items-center justify-center gap-3">
<a id="download" href="https://git.ranio.xyz/adrianbonpin/gridline/releases" class="primary-btn" data-download>
<a id="download" href="https://github.com/AdrianBonpin/gridline/releases" class="primary-btn" data-download>
Download
</a>
<a href="https://git.ranio.xyz/adrianbonpin/gridline" target="_blank" rel="noopener" class="ghost-btn">
View on Gitea
<a href="https://github.com/AdrianBonpin/gridline" target="_blank" rel="noopener" class="ghost-btn">
View on GitHub
</a>
</div>
</div>
@@ -181,8 +181,8 @@ const jsonLd = [
Gridline is Apache 2.0 and developed openly. Contributions, issues, and feature requests are welcome.
</p>
<div class="flex flex-wrap items-center justify-center gap-3">
<a href="https://git.ranio.xyz/adrianbonpin/gridline" target="_blank" rel="noopener" class="ghost-btn">
Star on Gitea
<a href="https://github.com/AdrianBonpin/gridline" target="_blank" rel="noopener" class="ghost-btn">
Star on GitHub
</a>
<a href="https://ko-fi.com/adrianbonpin" target="_blank" rel="noopener" class="ghost-btn">
☕ Support on Ko-fi