22 Commits
Author SHA1 Message Date
adrianbonpin cd6cc7fde2 Add Ko-fi support button + de-AI copy pass on landing page 2026-09-06 12:01:28 +08:00
adrianbonpin 7cde902978 Rework column filters and toolbar toggles
- Remove the drag-and-drop FilterBuilder in favor of the existing
  '+ Add filter' row editor in the Column Filters popover
- Debounce filter value edits (400ms) so a query only fires after the
  user stops typing, fixing a race where the in-flight fetch guard
  silently dropped the final filter value
- Make every toolbar toggle (filter, sort, auto-refresh, show/hide
  columns, export, bulk actions) a clean plain toggle by wrapping the
  button + dropdown in a shared ref so the outside-click handler no
  longer fights the button click
2026-09-05 18:38:44 +08:00
adrianbonpin 67f3532938 fix: release-mac.sh resolves tokens from the git credential store; fail fast (#24) 2026-09-04 04:21:16 +00:00
adrianbonpin 1d8dfce99a fix: make release-mac.sh idempotent for re-runs (#23) 2026-09-04 04:10:29 +00:00
adrianbonpin 8bcdc0888f chore: release 0.7.14 (#22)
Release / linux (push) Successful in 16m24s
Release / windows (push) Successful in 18m27s
2026-09-04 03:44:32 +00:00
adrianbonpin fd451d219d feat: pin keychain item ACLs to the app's code signature (macOS) (#21) 2026-09-04 03:41:54 +00:00
adrianbonpin a86f5f7199 docs: Homebrew install via Gitea tap (#20) 2026-09-04 02:54:08 +00:00
adrianbonpin 2fccb7b405 fix: use non-deprecated depends_on macos syntax in cask (#19) 2026-09-04 02:53:33 +00:00
adrianbonpin 0efabbdc2e fix: point Homebrew cask at Gitea releases (#18) 2026-09-04 02:46:06 +00:00
adrianbonpin 294e72e6ff chore: drop xattr postflight from Homebrew cask template (#17) 2026-09-04 02:32:43 +00:00
adrianbonpin 15f4dc9bac chore: sync Cargo.lock version to 0.7.13 (#16) 2026-09-04 02:03:13 +00:00
adrianbonpin 867df98958 fix: sign bundled DB tools with Developer ID for notarization (#15) 2026-09-04 02:02:54 +00:00
adrianbonpin e66d7ac102 release: v0.7.13 — Developer-ID signed + notarized macOS (#14)
Release / windows (push) Successful in 19m32s
Release / linux (push) Successful in 22m48s
2026-09-04 01:43:08 +00:00
adrianbonpin 84a6f8d41c feat: Developer-ID sign + notarize macOS builds (#13) 2026-09-04 01:40:48 +00:00
adrianbonpin 08b0f79f07 feat(www): SEO technical + on-page improvements (#12) 2026-09-02 09:10:50 +00:00
adrianbonpin 9611567daa chore: sync Cargo.lock to v0.7.12 + gitignore bundled DB tools (#11) 2026-09-02 08:45:26 +00:00
adrianbonpin 0bc5110e05 fix(www): point download buttons at Gitea + v0.7.12 (#10) 2026-09-02 08:43:31 +00:00
adrianbonpin 2433697951 fix(www): point download buttons at Gitea + v0.7.12 (#9) 2026-09-01 06:15:43 +00:00
adrianbonpin 341354ecb9 ci: use ASCII release body so Windows create succeeds (#8)
Release / linux (push) Successful in 18m41s
Release / windows (push) Successful in 19m4s
2026-09-01 05:06:03 +00:00
adrianbonpin 37e6638854 ci: make release-creation race-safe across parallel jobs (#7)
Release / windows (push) Failing after 17m35s
Release / linux (push) Successful in 20m23s
2026-09-01 04:44:42 +00:00
adrianbonpin 7bd4e8d05f fix: PG cell-edit serialization + recent-connections startup race (#6)
Release / windows (push) Failing after 18m10s
Release / linux (push) Successful in 19m10s
2026-09-01 04:23:07 +00:00
adrianbonpin 7a66b70c26 fix(www): Gridline favicon + Gitea links (#4) 2026-08-21 01:10:07 +00:00
39 changed files with 999 additions and 383 deletions
+44 -8
View File
@@ -140,13 +140,19 @@ jobs:
# Ensure the release exists (idempotent — both jobs may call this). # Ensure the release exists (idempotent — both jobs may call this).
REL_ID=$(curl -fsS -H "$AUTH" "${API}/releases/tags/${TAG}" \ REL_ID=$(curl -fsS -H "$AUTH" "${API}/releases/tags/${TAG}" \
| python3 -c "import sys,json;print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true) | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2 || true)
if [ -z "$REL_ID" ]; then if [ -z "$REL_ID" ]; then
BODY="Gridline ${TAG#v} — Linux and Windows installers." BODY="Gridline ${TAG#v} - Linux and Windows installers."
REL_ID=$(curl -fsS -X POST -H "$AUTH" -H "Content-Type: application/json" \ REL_ID=$(curl -fsS -X POST -H "$AUTH" -H "Content-Type: application/json" \
-d "{\"tag_name\":\"${TAG}\",\"name\":\"Gridline ${TAG#v}\",\"body\":\"${BODY}\",\"draft\":true}" \ -d "{\"tag_name\":\"${TAG}\",\"name\":\"Gridline ${TAG#v}\",\"body\":\"${BODY}\",\"draft\":true}" \
"${API}/releases" \ "${API}/releases" \
| python3 -c "import sys,json;print(json.load(sys.stdin)['id'])") | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2 || true)
fi
if [ -z "$REL_ID" ]; then
# A parallel job may have created the release between our GET and
# POST (create returns 422). Re-fetch by tag and upload to it.
REL_ID=$(curl -fsS -H "$AUTH" "${API}/releases/tags/${TAG}" \
| grep -o '"id":[0-9]*' | head -1 | cut -d: -f2 || true)
fi fi
BUNDLE="${GITHUB_WORKSPACE}/desktop/src-tauri/target/release/bundle" BUNDLE="${GITHUB_WORKSPACE}/desktop/src-tauri/target/release/bundle"
@@ -189,7 +195,13 @@ jobs:
OUT="${GITHUB_WORKSPACE}/desktop/src-tauri/resources/pg_tools" OUT="${GITHUB_WORKSPACE}/desktop/src-tauri/resources/pg_tools"
mkdir -p "$OUT" mkdir -p "$OUT"
URL="https://get.enterprisedb.com/postgresql/postgresql-${PG_VER}-1-windows-x64-binaries.zip" URL="https://get.enterprisedb.com/postgresql/postgresql-${PG_VER}-1-windows-x64-binaries.zip"
CACHE="/c/ci/cache/postgresql-${PG_VER}-1-windows-x64-binaries.zip"
if [ -f "$CACHE" ]; then
echo "Using cached PG zip: $CACHE"
cp "$CACHE" /tmp/pg.zip
else
curl -fsSL "$URL" -o /tmp/pg.zip curl -fsSL "$URL" -o /tmp/pg.zip
fi
EXPECTED="3508d8f085bc3980f38211a82e3f31e5fcae9952105d3dc2f8be67b64a822baa" EXPECTED="3508d8f085bc3980f38211a82e3f31e5fcae9952105d3dc2f8be67b64a822baa"
echo "$EXPECTED /tmp/pg.zip" | sha256sum -c - echo "$EXPECTED /tmp/pg.zip" | sha256sum -c -
sha256sum /tmp/pg.zip sha256sum /tmp/pg.zip
@@ -205,16 +217,34 @@ jobs:
OUT="${GITHUB_WORKSPACE}/desktop/src-tauri/resources/mysql_tools" OUT="${GITHUB_WORKSPACE}/desktop/src-tauri/resources/mysql_tools"
mkdir -p "$OUT" mkdir -p "$OUT"
URL="https://archive.mariadb.org/mariadb-${MARIADB_VER}/winx64-packages/mariadb-${MARIADB_VER}-winx64.zip" URL="https://archive.mariadb.org/mariadb-${MARIADB_VER}/winx64-packages/mariadb-${MARIADB_VER}-winx64.zip"
CACHE="/c/ci/cache/mariadb-${MARIADB_VER}-winx64.zip"
if [ -f "$CACHE" ]; then
echo "Using cached MariaDB zip: $CACHE"
cp "$CACHE" /tmp/mariadb.zip
else
curl -fsSL "$URL" -o /tmp/mariadb.zip curl -fsSL "$URL" -o /tmp/mariadb.zip
fi
sha256sum /tmp/mariadb.zip sha256sum /tmp/mariadb.zip
unzip -o /tmp/mariadb.zip -d /tmp/mariadb unzip -o /tmp/mariadb.zip -d /tmp/mariadb
WINROOT="/tmp/mariadb/mariadb-${MARIADB_VER}-winx64" WINROOT="/tmp/mariadb/mariadb-${MARIADB_VER}-winx64"
cp "$WINROOT"/bin/mariadb-dump.exe "$WINROOT"/bin/mariadb.exe "$OUT"/ cp "$WINROOT"/bin/mariadb-dump.exe "$WINROOT"/bin/mariadb.exe "$OUT"/
cp "$WINROOT"/lib/libmariadb.dll "$OUT"/ cp "$WINROOT"/lib/libmariadb.dll "$OUT"/
# rustc resolves `link.exe` via PATH for the windows-msvc target, but Git
# for Windows ships GNU coreutils with its own `link.exe` that shadows
# MSVC's linker (`link: extra operand ...`). Load the MSVC dev
# environment so PATH/LIB/INCLUDE point at the real MSVC toolchain.
# Fails fast here (instead of at link time) if the toolchain is missing.
- name: Set up MSVC build environment
uses: ilammy/msvc-dev-cmd@v1
with:
arch: x64
- name: Build installers - name: Build installers
shell: bash shell: cmd
run: bun run --filter gridline-desktop tauri build run: |
call "C:\Program Files (x86)\Microsoft Visual Studio\2022\BuildTools\VC\Auxiliary\Build\vcvars64.bat"
bun run --filter gridline-desktop tauri build
- name: Create release and upload Windows assets - name: Create release and upload Windows assets
shell: bash shell: bash
@@ -227,13 +257,19 @@ jobs:
AUTH="Authorization: token ${GITHUB_TOKEN}" AUTH="Authorization: token ${GITHUB_TOKEN}"
REL_ID=$(curl -fsS -H "$AUTH" "${API}/releases/tags/${TAG}" \ REL_ID=$(curl -fsS -H "$AUTH" "${API}/releases/tags/${TAG}" \
| python3 -c "import sys,json;print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true) | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2 || true)
if [ -z "$REL_ID" ]; then if [ -z "$REL_ID" ]; then
BODY="Gridline ${TAG#v} — Linux and Windows installers." BODY="Gridline ${TAG#v} - Linux and Windows installers."
REL_ID=$(curl -fsS -X POST -H "$AUTH" -H "Content-Type: application/json" \ REL_ID=$(curl -fsS -X POST -H "$AUTH" -H "Content-Type: application/json" \
-d "{\"tag_name\":\"${TAG}\",\"name\":\"Gridline ${TAG#v}\",\"body\":\"${BODY}\",\"draft\":true}" \ -d "{\"tag_name\":\"${TAG}\",\"name\":\"Gridline ${TAG#v}\",\"body\":\"${BODY}\",\"draft\":true}" \
"${API}/releases" \ "${API}/releases" \
| python3 -c "import sys,json;print(json.load(sys.stdin)['id'])") | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2 || true)
fi
if [ -z "$REL_ID" ]; then
# A parallel job may have created the release between our GET and
# POST (create returns 422). Re-fetch by tag and upload to it.
REL_ID=$(curl -fsS -H "$AUTH" "${API}/releases/tags/${TAG}" \
| grep -o '"id":[0-9]*' | head -1 | cut -d: -f2 || true)
fi fi
BUNDLE="${GITHUB_WORKSPACE}/desktop/src-tauri/target/release/bundle" BUNDLE="${GITHUB_WORKSPACE}/desktop/src-tauri/target/release/bundle"
+5 -10
View File
@@ -7,16 +7,11 @@ name: Release
# git checkout prod && git pull # git checkout prod && git pull
# git tag v0.5.0 && git push origin v0.5.0 # git tag v0.5.0 && git push origin v0.5.0
# #
# SIGNING STATUS: macOS bundles are AD-HOC signed via tauri.conf.json # SIGNING STATUS: macOS is Developer-ID signed + notarized via
# (bundle.macOS.signingIdentity: "-", hardenedRuntime: false). That replaces # scripts/release-mac.sh (built manually on Adrian's machine). NOTE: this
# the linker-only signature that made macOS report the app as "damaged" and # GitHub workflow is NOT the active release path — Gitea Actions uses
# refuse Finder launches — users now get the standard "Open Anyway" prompt # .gitea/workflows/release.yml (Linux/Windows only). Linux/Windows installers
# (or run `sudo xattr -dr com.apple.quarantine` for the damaged-error case, see # remain unsigned.
# README). Still NOT Developer-ID signed / notarized (no certs yet).
# tauri-action automatically signs + notarizes when the signing secrets are
# present, so the moment we add APPLE_CERTIFICATE / APPLE_API_KEY /
# WINDOWS_CERTIFICATE (or Azure Trusted Signing) to repo secrets, future
# builds are properly signed — no changes to this file required.
on: on:
push: push:
+10
View File
@@ -38,3 +38,13 @@ docs/superpowers/
.env .env
!.env.example !.env.example
.worktrees/ .worktrees/
# Bundled DB client tools (built/downloaded during release, never committed)
desktop/src-tauri/resources/pg_tools/pg_dump
desktop/src-tauri/resources/pg_tools/pg_restore
desktop/src-tauri/resources/pg_tools/psql
desktop/src-tauri/resources/pg_tools/libpq.5.dylib
desktop/src-tauri/resources/mysql_tools/mariadb
desktop/src-tauri/resources/mysql_tools/mariadb-dump
desktop/src-tauri/resources/mysql_tools/libcrypto.3.dylib
desktop/src-tauri/resources/mysql_tools/libssl.3.dylib
+3 -3
View File
@@ -184,7 +184,7 @@ Cut a release by tagging the **`prod`** branch once the PR is merged — `git ta
- `desktop/src/lib/version.test.ts` and `desktop/src/lib/docs-coverage.test.ts` if they assert the version - `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). - **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).
- **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. - **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.
- **Release notes must include the macOS first-launch instructions** — until a Developer ID signing certificate is secured, releases are ad-hoc signed only (not notarized), so every macOS user hits a Gatekeeper prompt. The draft release body must include: right-click → **Open** → **Open** (or System Settings → Privacy & Security → **Open Anyway**) for "developer cannot be verified", and `sudo xattr -dr com.apple.quarantine /Applications/Gridline.app` (with the re-run-after-every-upgrade note) for "damaged and can't be opened". - **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).
### Adding a Tauri Command ### Adding a Tauri Command
@@ -244,7 +244,7 @@ Planned work is prioritized in the [Project Roadmap](./ROADMAP.md) (source of tr
| DB Viewer: SQLite browse + query | ✅ | Full support via rusqlite | | 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 |
| DB Viewer: Redis browse | ❌ | Connection + test only; browsing gated off with a clean "not supported" state (v0.7.0) | | 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 | | 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 |
| 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) | | 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 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 | | 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 |
@@ -290,7 +290,7 @@ Planned work is prioritized in the [Project Roadmap](./ROADMAP.md) (source of tr
| Smart default sort | ✅ | 12-tier priority: updated_at → created_at → *_at → *_id → seq/rank/version | | Smart default sort | ✅ | 12-tier priority: updated_at → created_at → *_at → *_id → seq/rank/version |
| Data grid pagination | ✅ | Page nav, page size selector persisted in settings | | Data grid pagination | ✅ | Page nav, page size selector persisted in settings |
| Column filtering (server-side) | ✅ | eq, neq, contains, starts, ends, gt, lt, null, notnull pushed to SQL WHERE | | Column filtering (server-side) | ✅ | eq, neq, contains, starts, ends, gt, lt, null, notnull pushed to SQL WHERE |
| Visual filter builder | ✅ | Drag-and-drop column palette (@dnd-kit) with type-aware operators (textish → contains, else eq), AND semantics, persists in tab `filterRules` | | Visual filter builder | ✅ | Column Filters popover with a "+ Add filter" row editor (column / operator / value selects, per-rule remove, AND semantics, persists in tab `filterRules`). Free-text values are debounced (400ms) so a query only fires after the user stops typing; column/operator changes apply immediately. The filter button is a plain toggle (open when closed, close when open) |
| Column sorting (server-side) | ✅ | Multi-column asc/desc pushed to SQL ORDER BY | | Column sorting (server-side) | ✅ | Multi-column asc/desc pushed to SQL ORDER BY |
| Column show/hide | ✅ | Toggle visibility per column | | Column show/hide | ✅ | Toggle visibility per column |
| Column resize (drag handle) | ✅ | Double-click to auto-fit | | Column resize (drag handle) | ✅ | Double-click to auto-fit |
+50 -38
View File
@@ -18,43 +18,46 @@
<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://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="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="./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://github.com/adrianbonpin/gridline/stargazers"><img src="https://img.shields.io/github/stars/adrianbonpin/gridline?style=for-the-badge&logo=github&label=Stars" alt="GitHub stars" /></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>
</p> </p>
<p> <p>
<a href="https://github.com/adrianbonpin/gridline/releases"><img src="https://img.shields.io/badge/Download_Latest_Release-2ea44f?style=for-the-badge&logo=github&logoColor=white" alt="Download Latest" /></a> <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://github.com/adrianbonpin/gridline/issues/new"><img src="https://img.shields.io/badge/Open_an_Issue-%23E4405F.svg?style=for-the-badge&logo=github&logoColor=white" alt="Open an Issue" /></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>
</p> </p>
--- ---
## Download ## 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.7.11**): 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.7.14**):
| OS | Architecture | Download | | OS | Architecture | Download |
| :--------------------------- | :--------------------------- | :----------------------------------------------------------------------------------------------------------------------------------- | | :--------------------------- | :--------------------------- | :----------------------------------------------------------------------------------------------------------------------------------- |
| **macOS** | Apple Silicon (M1/M2/M3/M4…) | [Gridline_0.7.11_aarch64.dmg](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.11/Gridline_0.7.11_aarch64.dmg) | | **macOS** | Apple Silicon (M1/M2/M3/M4…) | [Gridline_0.7.14_aarch64.dmg](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.14/Gridline_0.7.14_aarch64.dmg) |
| **macOS** | Intel | [Gridline_0.7.11_x64.dmg](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.11/Gridline_0.7.11_x64.dmg) | | **macOS** | Intel | [Gridline_0.7.14_x64.dmg](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.14/Gridline_0.7.14_x64.dmg) |
| **Windows** | x64 | [Gridline_0.7.11_x64-setup.exe](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.11/Gridline_0.7.11_x64-setup.exe) | | **Windows** | x64 | [Gridline_0.7.14_x64-setup.exe](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.14/Gridline_0.7.14_x64-setup.exe) |
| **Debian / Ubuntu** | amd64 | [Gridline_0.7.11_amd64.deb](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.11/Gridline_0.7.11_amd64.deb) | | **Debian / Ubuntu** | amd64 | [Gridline_0.7.14_amd64.deb](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.14/Gridline_0.7.14_amd64.deb) |
| **Fedora / RHEL / openSUSE** | x86_64 | [Gridline-0.7.11-1.x86_64.rpm](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.11/Gridline-0.7.11-1.x86_64.rpm) | | **Fedora / RHEL / openSUSE** | x86_64 | [Gridline-0.7.14-1.x86_64.rpm](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.14/Gridline-0.7.14-1.x86_64.rpm) |
| **Other Linux** | amd64 | [Gridline_0.7.11_amd64.AppImage](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.11/Gridline_0.7.11_amd64.AppImage) | | **Other Linux** | amd64 | [Gridline_0.7.14_amd64.AppImage](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.14/Gridline_0.7.14_amd64.AppImage) |
**macOS via Homebrew** (recommended for Mac users): **macOS via Homebrew** (recommended for Mac users):
```bash ```bash
brew install --cask AdrianBonpin/gridline/gridline # one-time setup (the tap is hosted on Gitea, not GitHub):
# or, after a one-time `brew tap AdrianBonpin/gridline`: brew tap AdrianBonpin/gridline https://git.ranio.xyz/adrianbonpin/homebrew-gridline.git
brew trust adrianbonpin/gridline
# install / upgrade:
brew install --cask gridline brew install --cask gridline
``` ```
The cask strips the macOS quarantine flag automatically during install, so **no manual `xattr` step is needed** on the Homebrew path — the app just launches. (Direct-DMG downloads still need the [first-launch instructions](#installers-are-unsigned-for-now) below.) The cask installs the app directly. The DMGs are Developer-ID signed and notarized, so macOS trusts them on first launch — no `xattr` or "Open Anyway" step needed.
> **macOS first launch:** macOS may say *"Gridline is damaged and can't be opened"* or *"the developer cannot be verified"* — this is expected; the app isn't Developer-ID signed/notarized yet. See the [macOS first-launch instructions](#installers-are-unsigned-for-now) (right-click → Open, or the one-time `xattr` fix). Not sure if your Mac is Intel or Apple Silicon? See [Which file should I download?](#which-file-should-i-download) below. > **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.7.11 MAINTENANCE: These links are STATIC (versioned) — they point at the v0.7.14
release assets, not at a moving "latest" target. On every new release, release assets, not at a moving "latest" target. On every new release,
update BOTH tables here (Download + Which file should I download?) to the update BOTH tables here (Download + Which file should I download?) to the
new version's asset names, which are tauri-action's default naming: new version's asset names, which are tauri-action's default naming:
@@ -99,6 +102,12 @@ Gridline is built for developers and small teams who manage multiple database en
<details> <details>
<summary>View the full changelog</summary> <summary>View the full changelog</summary>
- **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.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.
- **v0.7.12** — **Reliable cell editing for non-text columns**: editing a PostgreSQL cell whose type isn't `text` (integers, booleans, UUIDs, `json`/`jsonb`, timestamps, enums, numerics, …) previously failed with a Rust serialization error; edited values are now 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. **Full CI release pipeline for Windows**: self-hosted Windows runner builds the `.exe`/`.msi` installers (bundled `pg_dump`/`pg_restore`/`psql` + MariaDB clients) and uploads them to the release alongside Linux; version bump.
- **v0.7.10** — **Copy connection URL**: from a connection card's ⋮ menu, copy the connection string for use in env vars / other tools — **Copy connection URL** (includes the password, fetched from the OS keychain on demand) and **Copy connection URL (no password)** (safe to share). Builds `postgresql://` / `mysql://` / `sqlite://` / `redis://` strings with percent-encoded credentials and the PG `sslmode` appended; version bump. - **v0.7.10** — **Copy connection URL**: from a connection card's ⋮ menu, copy the connection string for use in env vars / other tools — **Copy connection URL** (includes the password, fetched from the OS keychain on demand) and **Copy connection URL (no password)** (safe to share). Builds `postgresql://` / `mysql://` / `sqlite://` / `redis://` strings with percent-encoded credentials and the PG `sslmode` appended; version bump.
- **v0.7.9** — **macOS install/launch fixes**: the app's local store now opens under the OS app-data directory (a cwd-relative `gridline.db` made Finder-launched copies silently exit with a Rust panic before the UI started); macOS bundles are **ad-hoc signed at build time** (`signingIdentity "-"`), replacing the Xcode linker-only signature that macOS treated as unsigned ("damaged and can't be opened", silent Finder refusals) — users now get a standard one-time Gatekeeper prompt (see the [install notes](#installers-are-unsigned-for-now), incl. the `xattr` workaround); **OpenSSL is vendored** into the `ssh2` build so the release no longer depends on a Homebrew OpenSSL path; version bump. - **v0.7.9** — **macOS install/launch fixes**: the app's local store now opens under the OS app-data directory (a cwd-relative `gridline.db` made Finder-launched copies silently exit with a Rust panic before the UI started); macOS bundles are **ad-hoc signed at build time** (`signingIdentity "-"`), replacing the Xcode linker-only signature that macOS treated as unsigned ("damaged and can't be opened", silent Finder refusals) — users now get a standard one-time Gatekeeper prompt (see the [install notes](#installers-are-unsigned-for-now), incl. the `xattr` workaround); **OpenSSL is vendored** into the `ssh2` build so the release no longer depends on a Homebrew OpenSSL path; version bump.
@@ -301,41 +310,44 @@ _Notes: DB Pro is an Electron app (launched Nov 2025) whose marketing copy overc
### Download a release ### Download a release
Pre-built installers for macOS, Windows, and Linux are published on the [Releases](https://github.com/adrianbonpin/gridline/releases) page. Pre-built installers for macOS, Windows, and Linux are published on the [Releases](https://git.ranio.xyz/adrianbonpin/gridline/releases) page.
> ⚠️ Gridline is under active development. Expect rough edges and please [open issues](https://github.com/adrianbonpin/gridline/issues/new) when you hit them. > ⚠️ Gridline is under active development. Expect rough edges and please [open issues](https://git.ranio.xyz/adrianbonpin/gridline/issues/new) when you hit them.
#### Installers are unsigned (for now) #### macOS signing & notarization
Gridline is currently distributed **unsigned** — it doesn't pay for code-signing certificates yet (macOS builds are *ad-hoc signed*, so they pass Apple Silicon's launch checks but aren't Developer-ID signed or notarized). Your OS will warn you the first time you open it. This is expected — the app is safe, it just hasn't paid the signing fee: 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).
> **Homebrew users:** the [Homebrew cask](#download) strips the quarantine flag automatically during install, so you skip all of the steps below — the app just launches. **One-time setup** (only the repo owner needs to do this):
- **macOS:** if you see *"Gridline can't be opened because the developer cannot be verified"* (or the app simply won't open from Finder), right-click the app → **Open** → **Open** (or System Settings → Privacy & Security → **Open Anyway**). Do this once per version. 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`.
- **macOS — "Gridline is damaged and can't be opened":** remove the quarantine flag macOS attaches to downloaded apps, then launch normally: 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):
```bash ```bash
sudo xattr -dr com.apple.quarantine /Applications/Gridline.app APPLE_API_KEY=<Key ID>
APPLE_API_ISSUER=<Issuer ID>
APPLE_API_KEY_PATH=/absolute/path/to/AuthKey_<KeyID>.p8
``` ```
(`sudo` is required — some files inside the bundle are read-only, so removing the flag needs admin rights.) Re-run after **every upgrade** — each freshly-downloaded copy gets re-quarantined. (Alternatively, drop the `.p8` at `~/.appstoreconnect/private_keys/AuthKey_<KeyID>.p8` and omit `APPLE_API_KEY_PATH`.)
- **Windows:** on the SmartScreen prompt, click **More info** → **Run anyway**.
- **Linux:** no warning — install and run normally.
Code signing **will be added in the future** (Apple Developer Program + a Windows signing cert, e.g. Azure Trusted Signing) — the CI workflow is already wired to pick up the signing secrets automatically the moment they exist, no workflow changes needed. `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).
**Windows** still shows a SmartScreen prompt (no Windows signing cert yet); **Linux** installs without a warning.
#### Which file should I download? #### 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.7.11**): 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.7.14**):
| Your system | Download this | Notes | | Your system | Download this | Notes |
| :------------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------- | :--------------------------------------------------------------- | | :------------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------- | :--------------------------------------------------------------- |
| macOS **Apple Silicon** (M1/M2/M3/M4…) | [Gridline_0.7.11_aarch64.dmg](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.11/Gridline_0.7.11_aarch64.dmg) | `aarch64` = Apple's own chip | | macOS **Apple Silicon** (M1/M2/M3/M4…) | [Gridline_0.7.14_aarch64.dmg](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.14/Gridline_0.7.14_aarch64.dmg) | `aarch64` = Apple's own chip |
| macOS **Intel** | [Gridline_0.7.11_x64.dmg](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.11/Gridline_0.7.11_x64.dmg) | `x64` = Intel/AMD | | macOS **Intel** | [Gridline_0.7.14_x64.dmg](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.14/Gridline_0.7.14_x64.dmg) | `x64` = Intel/AMD |
| **Windows** (most PCs) | [Gridline_0.7.11_x64-setup.exe](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.11/Gridline_0.7.11_x64-setup.exe) | The `.msi` is an alternate installer (for enterprises/IT admins) | | **Windows** (most PCs) | [Gridline_0.7.14_x64-setup.exe](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.14/Gridline_0.7.14_x64-setup.exe) | The `.msi` is an alternate installer (for enterprises/IT admins) |
| **Debian / Ubuntu** | [Gridline_0.7.11_amd64.deb](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.11/Gridline_0.7.11_amd64.deb) | Install: `sudo apt install ./Gridline_0.7.11_amd64.deb` | | **Debian / Ubuntu** | [Gridline_0.7.14_amd64.deb](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.14/Gridline_0.7.14_amd64.deb) | Install: `sudo apt install ./Gridline_0.7.14_amd64.deb` |
| **Fedora / RHEL / openSUSE** | [Gridline-0.7.11-1.x86_64.rpm](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.11/Gridline-0.7.11-1.x86_64.rpm) | Install: `sudo dnf install Gridline-0.7.11-1.x86_64.rpm` | | **Fedora / RHEL / openSUSE** | [Gridline-0.7.14-1.x86_64.rpm](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.14/Gridline-0.7.14-1.x86_64.rpm) | Install: `sudo dnf install Gridline-0.7.14-1.x86_64.rpm` |
| **Any other Linux** | [Gridline_0.7.11_amd64.AppImage](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.11/Gridline_0.7.11_amd64.AppImage) | Works on every distro: `chmod +x` the file, then double-click it | | **Any other Linux** | [Gridline_0.7.14_amd64.AppImage](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.14/Gridline_0.7.14_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. **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.
@@ -345,8 +357,8 @@ Cutting a release is one command — CI builds everything. **Releases are cut fr
```bash ```bash
git checkout prod && git pull git checkout prod && git pull
git tag v0.7.11 git tag v0.7.14
git push origin v0.7.11 git push origin v0.7.14
``` ```
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**. 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**.
@@ -357,7 +369,7 @@ Before tagging, make sure the version number is in sync across `desktop/package.
```bash ```bash
# 1. Clone the repository # 1. Clone the repository
git clone https://github.com/adrianbonpin/gridline.git git clone https://git.ranio.xyz/adrianbonpin/gridline.git
cd gridline cd gridline
# 2. Install all workspace dependencies # 2. Install all workspace dependencies
@@ -473,7 +485,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. 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://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. - 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.
- Submit a pull request. Keep Tauri commands thin, type IPC boundaries explicitly, and follow the existing Rust/React conventions. - Submit a pull request. Keep Tauri commands thin, type IPC boundaries explicitly, and follow the existing Rust/React conventions.
--- ---
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "gridline-desktop", "name": "gridline-desktop",
"private": true, "private": true,
"version": "0.7.11", "version": "0.7.14",
"description": "An open-source, high-performance database GUI client for PostgreSQL and beyond", "description": "An open-source, high-performance database GUI client for PostgreSQL and beyond",
"type": "module", "type": "module",
"scripts": { "scripts": {
+5 -1
View File
@@ -1783,9 +1783,11 @@ dependencies = [
[[package]] [[package]]
name = "gridline" name = "gridline"
version = "0.7.10" version = "0.7.14"
dependencies = [ dependencies = [
"bytes",
"chrono", "chrono",
"core-foundation 0.10.1",
"deadpool-postgres", "deadpool-postgres",
"indexmap 2.14.0", "indexmap 2.14.0",
"redis", "redis",
@@ -1794,6 +1796,8 @@ dependencies = [
"rustls-native-certs", "rustls-native-certs",
"rustls-pemfile 2.2.0", "rustls-pemfile 2.2.0",
"rustls-pki-types", "rustls-pki-types",
"security-framework 3.7.0",
"security-framework-sys",
"serde", "serde",
"serde_json", "serde_json",
"sqlx", "sqlx",
+11 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "gridline" name = "gridline"
version = "0.7.11" version = "0.7.14"
description = "An open-source, high-performance database GUI client for PostgreSQL and beyond" description = "An open-source, high-performance database GUI client for PostgreSQL and beyond"
authors = ["you"] authors = ["you"]
edition = "2021" edition = "2021"
@@ -31,6 +31,7 @@ tauri-plugin-dialog = "2"
tauri-plugin-fs = "2" tauri-plugin-fs = "2"
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
tokio-postgres = { version = "0.7", features = ["with-chrono-0_4", "with-uuid-1", "with-serde_json-1"] } tokio-postgres = { version = "0.7", features = ["with-chrono-0_4", "with-uuid-1", "with-serde_json-1"] }
bytes = "1"
sqlx = { version = "0.8", features = ["runtime-tokio", "mysql", "tls-rustls", "json"] } sqlx = { version = "0.8", features = ["runtime-tokio", "mysql", "tls-rustls", "json"] }
redis = { version = "0.27", features = ["tokio-comp"] } 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 # async-ssh2 is not available at v0.4; using synchronous ssh2 via tokio::task::spawn_blocking per plan's fallback
@@ -48,3 +49,12 @@ rustls-pemfile = "2"
rustls-pki-types = "1" rustls-pki-types = "1"
rustls-native-certs = "0.7" rustls-native-certs = "0.7"
# Keychain ACL normalization (explicit SecAccess on stored secrets so items
# survive app re-signs without permission prompts) — macOS only.
[target.'cfg(target_os = "macos")'.dependencies]
security-framework = "3.7"
security-framework-sys = "2.17"
# core-foundation: CFString/CFArray/TCFType helpers for the SecAccess FFI
# (already a transitive dep of security-framework; pinned to the same 0.10 line)
core-foundation = "0.10"
+16
View File
@@ -0,0 +1,16 @@
<?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>
<!-- Required for the WKWebView (JIT-compiled JavaScript). Without this the
app crashes under the hardened runtime. -->
<key>com.apple.security.cs.allow-jit</key>
<true/>
<!-- The WebView allocates executable memory for JIT; allow it. -->
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<!-- Tauri's dev server / debug builds rely on DYLD_* environment variables. -->
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
<true/>
</dict>
</plist>
+140 -17
View File
@@ -8,10 +8,12 @@ use crate::models::db_viewer::{
Change, ColumnInfo, ConstraintInfo, EnumInfo, ExtensionInfo, FunctionInfo, IndexInfo, Change, ColumnInfo, ConstraintInfo, EnumInfo, ExtensionInfo, FunctionInfo, IndexInfo,
QueryResult, SequenceInfo, TableInfo, TriggerInfo, QueryResult, SequenceInfo, TableInfo, TriggerInfo,
}; };
use bytes::BytesMut;
use std::collections::HashMap; use std::collections::HashMap;
use std::error::Error;
use sqlx::Row; use sqlx::Row;
use tauri::State; use tauri::State;
use tokio_postgres::types::ToSql; use tokio_postgres::types::{Format, IsNull, ToSql, Type};
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Helper functions // Helper functions
@@ -585,26 +587,75 @@ pub fn build_pg_insert_sql(
) )
} }
/// A value bound to a PostgreSQL parameter in TEXT wire format, accepting any
/// parameter type.
///
/// The frontend ships edited values as plain strings (and PK locators as
/// numbers/booleans), but binding a native `String` fails for every non-text
/// column type — `String::accepts` only matches `varchar`/`text`/`bpchar`/
/// `name` — with `error serializing parameter N: cannot convert between the
/// Rust type ... and the Postgres type ...`. Binding a native `i64` is also
/// wrong because `i64::accepts` only matches `int8`.
///
/// `PgText` accepts *any* parameter type and writes the value as UTF-8 text in
/// text wire format, so PostgreSQL itself parses the literal into the target
/// column type. This works uniformly for `integer`, `bigint`, `numeric`,
/// `boolean`, `uuid`, `json`/`jsonb`, timestamps, enums, etc. — exactly what
/// PostgreSQL does for an `'<literal>'` in a query.
#[derive(Debug, Clone)]
struct PgText(String);
impl From<&str> for PgText {
fn from(s: &str) -> Self {
PgText(s.to_string())
}
}
impl ToSql for PgText {
fn to_sql(
&self,
_ty: &Type,
out: &mut BytesMut,
) -> Result<IsNull, Box<dyn Error + Sync + Send>> {
out.extend_from_slice(self.0.as_bytes());
Ok(IsNull::No)
}
fn accepts(_ty: &Type) -> bool {
true
}
fn encode_format(&self, _ty: &Type) -> Format {
Format::Text
}
fn to_sql_checked(
&self,
ty: &Type,
out: &mut BytesMut,
) -> Result<IsNull, Box<dyn Error + Sync + Send>> {
self.to_sql(ty, out)
}
}
/// Convert a JSON value into a boxed PostgreSQL-bindable value. /// Convert a JSON value into a boxed PostgreSQL-bindable value.
/// ///
/// Maps common JSON types to `tokio_postgres::types::ToSql` implementors. /// Every non-null value is bound as a `PgText` (text wire format) so the server
/// Unknown/complex types are stringified as a fallback. /// parses it into the target column type; SQL `NULL` is bound as `None`. This
/// is deliberately type-agnostic — the caller already knows the column type
/// through the SQL context (e.g. `SET "col" = $1` lets PostgreSQL infer `$1`
/// as `col`'s type).
fn pg_box_value(v: &serde_json::Value) -> Box<dyn ToSql + Send + Sync> { fn pg_box_value(v: &serde_json::Value) -> Box<dyn ToSql + Send + Sync> {
match v { if v.is_null() {
serde_json::Value::Null => Box::new(Option::<String>::None), return Box::new(Option::<PgText>::None);
serde_json::Value::Bool(b) => Box::new(*b),
serde_json::Value::Number(n) => {
if let Some(i) = n.as_i64() {
Box::new(i)
} else if let Some(f) = n.as_f64() {
Box::new(f)
} else {
Box::new(n.to_string())
}
}
serde_json::Value::String(s) => Box::new(s.clone()),
other => Box::new(other.to_string()),
} }
let text = match v {
serde_json::Value::String(s) => s.clone(),
serde_json::Value::Bool(b) => b.to_string(),
serde_json::Value::Number(n) => n.to_string(),
other => other.to_string(),
};
Box::new(PgText::from(text.as_str()))
} }
/// Convert a JSON value into a `rusqlite::types::Value` for SQLite binding. /// Convert a JSON value into a `rusqlite::types::Value` for SQLite binding.
@@ -3320,6 +3371,78 @@ mod tests {
); );
} }
// -----------------------------------------------------------------------
// PgText: universal text-format parameter binding (serialization fix)
// -----------------------------------------------------------------------
#[test]
fn pg_text_accepts_any_postgres_type() {
// String::accepts only matches text-ish types, so a plain String can't
// bind to an integer/boolean/uuid/jsonb column. PgText must accept all
// of these (the server parses the text into the column's own type).
for ty in [
Type::TEXT,
Type::VARCHAR,
Type::INT4,
Type::INT8,
Type::FLOAT8,
Type::NUMERIC,
Type::BOOL,
Type::UUID,
Type::JSON,
Type::JSONB,
Type::TIMESTAMP,
Type::TIMESTAMPTZ,
Type::DATE,
Type::TIME,
] {
assert!(
PgText::accepts(&ty),
"PgText must accept {:?}",
ty
);
}
// Sanity: a native String must NOT accept a non-text type (proving the
// root cause this fix addresses).
assert!(!<&str as ToSql>::accepts(&Type::INT4));
}
#[test]
fn pg_text_serializes_as_utf8_text() {
use bytes::BytesMut;
use tokio_postgres::types::IsNull;
let val = pg_box_value(&serde_json::json!("42"));
let mut out = BytesMut::new();
let isnull = val.to_sql_checked(&Type::INT4, &mut out).unwrap();
assert!(matches!(isnull, IsNull::No));
assert_eq!(&out[..], b"42");
// Text wire format: the bytes are raw UTF-8, not binary-encoded int.
assert!(matches!(val.encode_format(&Type::INT4), Format::Text));
}
#[test]
fn pg_text_boxes_null_to_sql_null() {
use bytes::BytesMut;
use tokio_postgres::types::IsNull;
let val = pg_box_value(&serde_json::Value::Null);
let mut out = BytesMut::new();
assert!(matches!(
val.to_sql_checked(&Type::TEXT, &mut out).unwrap(),
IsNull::Yes
));
assert!(out.is_empty());
}
#[test]
fn pg_text_stringifies_booleans_and_numbers() {
let b = pg_box_value(&serde_json::json!(true));
assert!(matches!(b.encode_format(&Type::BOOL), Format::Text));
let n = pg_box_value(&serde_json::json!(5));
assert!(matches!(n.encode_format(&Type::INT4), Format::Text));
}
async fn fresh_pool_manager() -> tokio::sync::Mutex<ConnectionPoolManager> { async fn fresh_pool_manager() -> tokio::sync::Mutex<ConnectionPoolManager> {
tokio::sync::Mutex::new(ConnectionPoolManager::new()) tokio::sync::Mutex::new(ConnectionPoolManager::new())
} }
+11 -3
View File
@@ -1,3 +1,4 @@
use crate::keychain_acl;
use tauri_plugin_keyring_store::KeyringExt; use tauri_plugin_keyring_store::KeyringExt;
/// Store a connection password in the OS keychain. /// Store a connection password in the OS keychain.
@@ -11,7 +12,10 @@ pub fn save_connection_password(
app.keyring() app.keyring()
.store .store
.set_password(&connection_id, &password) .set_password(&connection_id, &password)
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())?;
// Best-effort: pin the item's ACL to this app so re-signed builds never prompt.
keychain_acl::after_store(&app, &connection_id);
Ok(())
} }
/// Retrieve a connection password from the OS keychain. /// Retrieve a connection password from the OS keychain.
@@ -55,7 +59,9 @@ pub fn save_connection_ssh_password(
app.keyring() app.keyring()
.store .store
.set_password(&ssh_account("password", &connection_id), &password) .set_password(&ssh_account("password", &connection_id), &password)
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())?;
keychain_acl::after_store(&app, &ssh_account("password", &connection_id));
Ok(())
} }
/// Retrieve an SSH tunnel password from the OS keychain. /// Retrieve an SSH tunnel password from the OS keychain.
@@ -93,7 +99,9 @@ pub fn save_connection_ssh_passphrase(
app.keyring() app.keyring()
.store .store
.set_password(&ssh_account("passphrase", &connection_id), &passphrase) .set_password(&ssh_account("passphrase", &connection_id), &passphrase)
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())?;
keychain_acl::after_store(&app, &ssh_account("passphrase", &connection_id));
Ok(())
} }
/// Retrieve an SSH private-key passphrase from the OS keychain. /// Retrieve an SSH private-key passphrase from the OS keychain.
+198
View File
@@ -0,0 +1,198 @@
//! Keychain ACL normalization 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.
//!
//! 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.
//!
//! # Prompt behavior
//!
//! - 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).
//!
//! 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(())
}
#[cfg(target_os = "macos")]
mod macos_ffi {
use core_foundation::base::{CFTypeRef, CFTypeID};
/// Minimal `TCFType` wrapper for `SecTrustedApplication` — the
/// security-framework crate exposes no high-level API for it.
#[repr(C)]
pub struct OpaqueSecTrustedApplication {
_priv: [u8; 0],
}
pub type SecTrustedApplicationRef = *mut OpaqueSecTrustedApplication;
use core_foundation::{declare_TCFType, impl_TCFType};
declare_TCFType!(SecTrustedApplication, SecTrustedApplicationRef);
impl_TCFType!(
SecTrustedApplication,
SecTrustedApplicationRef,
SecTrustedApplicationGetTypeID
);
unsafe impl Send for SecTrustedApplication {}
unsafe impl Sync for SecTrustedApplication {}
extern "C" {
// SecAccess/SecTrustedApplication APIs are deprecated in the macOS SDK
// but remain fully functional (the legacy keychain store this app uses
// via tauri-plugin-keyring-store relies on the same API family).
pub fn SecTrustedApplicationGetTypeID() -> CFTypeID;
pub fn SecTrustedApplicationCreateFromPath(
path: CFTypeRef,
app: *mut SecTrustedApplicationRef,
) -> i32;
pub fn SecAccessCreate(
descriptor: CFTypeRef,
trustedlist: CFTypeRef,
access: *mut security_framework_sys::base::SecAccessRef,
) -> i32;
pub fn SecKeychainItemSetAccess(
item: security_framework_sys::base::SecKeychainItemRef,
access: security_framework_sys::base::SecAccessRef,
) -> i32;
}
}
#[cfg(target_os = "macos")]
use macos_ffi::{
OpaqueSecTrustedApplication, SecAccessCreate, SecKeychainItemSetAccess, SecTrustedApplication,
SecTrustedApplicationCreateFromPath,
};
#[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.
///
/// Ignored by default (`cargo test`) so CI/dev runs never touch the login
/// keychain. Run explicitly with:
/// `cargo test -- --ignored keychain_acl`
#[test]
#[ignore = "touches the real login keychain"]
fn keychain_acl_normalizes_item() {
let keychain = SecKeychain::default().expect("default keychain");
let service = "com.adrianbonpin.gridline.acl-test";
let account = format!("acl-test-{}", std::process::id());
// Create the item exactly like apple-native-keyring-store does
// (legacy SecKeychain generic-password API).
keychain
.set_generic_password(service, &account, b"test-secret")
.expect("create test item");
// ACL normalization must succeed on an item we just created.
ensure_item_acl(service, &account).expect("normalize ACL");
// The item must remain readable with the correct secret.
let (password, item) =
find_generic_password(Some(&[keychain]), service, &account).expect("read back");
assert_eq!(password.as_ref(), b"test-secret");
// Cleanup: delete the test item.
item.delete();
}
}
+1
View File
@@ -5,6 +5,7 @@
mod cancel; mod cancel;
mod commands; mod commands;
mod db; mod db;
mod keychain_acl;
mod models; mod models;
mod store; mod store;
+3 -2
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "Gridline", "productName": "Gridline",
"version": "0.7.11", "version": "0.7.14",
"identifier": "com.adrianbonpin.gridline", "identifier": "com.adrianbonpin.gridline",
"build": { "build": {
"beforeDevCommand": "bun run dev", "beforeDevCommand": "bun run dev",
@@ -29,7 +29,8 @@
"resources": ["resources/pg_tools/*", "resources/mysql_tools/*"], "resources": ["resources/pg_tools/*", "resources/mysql_tools/*"],
"macOS": { "macOS": {
"signingIdentity": "-", "signingIdentity": "-",
"hardenedRuntime": false "hardenedRuntime": true,
"entitlements": "entitlements.plist"
}, },
"icon": [ "icon": [
"icons/32x32.png", "icons/32x32.png",
@@ -1,27 +0,0 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { FilterBuilder } from "./FilterBuilder";
import type { ColumnInfo } from "../../lib/types";
const cols: ColumnInfo[] = [
{ name: "id", data_type: "integer", is_nullable: false, is_pk: true, is_fk: false, fk_ref: null, default_value: null, editable: false, is_generated: false },
{ name: "name", data_type: "text", is_nullable: true, is_pk: false, is_fk: false, fk_ref: null, default_value: null, editable: true, is_generated: false },
];
describe("FilterBuilder", () => {
it("drops a column chip into the drop zone to create a rule with a type-aware operator", () => {
const onChange = vi.fn();
render(<FilterBuilder columns={cols} rules={[]} onChange={onChange} />);
fireEvent.click(screen.getByText("name"));
expect(onChange).toHaveBeenCalledWith([
expect.objectContaining({ column: "name", operator: "contains" }),
]);
});
it("removing a rule calls onChange without it", () => {
const onChange = vi.fn();
const rules = [{ id: "r1", column: "name", operator: "contains" as const, value: "Al" }];
render(<FilterBuilder columns={cols} rules={rules} onChange={onChange} />);
fireEvent.click(screen.getByLabelText("Remove filter name"));
expect(onChange).toHaveBeenCalledWith([]);
});
});
@@ -1,132 +0,0 @@
import { useState } from "react";
import { DndContext, useDraggable, useDroppable } from "@dnd-kit/core";
import { X } from "lucide-react";
import { defaultFilterOperator } from "../grid/gridEditability";
import type { ColumnInfo } from "../../lib/types";
import type { FilterRule, FilterOperator } from "../../stores/dbViewerStore";
interface Props {
columns: ColumnInfo[];
rules: FilterRule[];
onChange: (rules: FilterRule[]) => void;
}
function Chip({ col, onAdd }: { col: ColumnInfo; onAdd: () => void }) {
const { setNodeRef, attributes, listeners, isDragging } = useDraggable({
id: `col-${col.name}`,
data: { column: col },
});
return (
<button
ref={setNodeRef}
{...attributes}
{...listeners}
onClick={onAdd}
className={`px-2 py-1 text-xs rounded border border-border bg-surface text-text hover:border-accent ${
isDragging ? "opacity-50" : ""
}`}
>
{col.name}
</button>
);
}
const OPERATORS: FilterOperator[] = [
"eq",
"neq",
"contains",
"starts",
"ends",
"gt",
"lt",
"null",
"notnull",
];
export function FilterBuilder({ columns, rules, onChange }: Props) {
const [val, setVal] = useState<Record<string, string>>({});
const addRule = (col: ColumnInfo) => {
const op = defaultFilterOperator(col.data_type);
onChange([
...rules,
{
id: `f-${Date.now()}-${col.name}`,
column: col.name,
operator: op,
value: "",
},
]);
};
const { setNodeRef, isOver } = useDroppable({ id: "filter-dropzone" });
const remove = (id: string) => onChange(rules.filter((r) => r.id !== id));
const update = (id: string, patch: Partial<FilterRule>) =>
onChange(rules.map((r) => (r.id === id ? { ...r, ...patch } : r)));
return (
<DndContext
onDragEnd={(e) => {
const id = e.active.id as string;
const colName = id.replace(/^col-/, "");
const col = columns.find((c) => c.name === colName);
if (col && e.over?.id === "filter-dropzone") addRule(col);
}}
>
<div className="flex flex-wrap gap-1 mb-2">
{columns.map((c) => (
<Chip key={c.name} col={c} onAdd={() => addRule(c)} />
))}
</div>
<div
ref={setNodeRef}
className={`min-h-[40px] border border-dashed rounded p-2 space-y-1 ${
isOver ? "border-accent bg-surface" : "border-border"
}`}
>
{rules.length === 0 && (
<span className="text-xs text-text-muted">
Drop columns here to add filters
</span>
)}
{rules.map((r) => (
<div key={r.id} className="flex items-center gap-2 text-xs">
<span className="text-text font-semibold">{r.column}</span>
<select
value={r.operator}
onChange={(e) =>
update(r.id, { operator: e.target.value as FilterOperator })
}
className="bg-surface border border-border rounded px-1"
>
{OPERATORS.map((o) => (
<option key={o} value={o}>
{o}
</option>
))}
</select>
{!["null", "notnull"].includes(r.operator) && (
<input
value={val[r.id] ?? r.value}
onChange={(e) => {
setVal({ ...val, [r.id]: e.target.value });
update(r.id, { value: e.target.value });
}}
className="bg-surface border border-border rounded px-1 flex-1"
placeholder="value"
/>
)}
<button
aria-label={`Remove filter ${r.column}`}
onClick={() => remove(r.id)}
className="text-text-muted hover:text-red-400"
>
<X size={12} />
</button>
</div>
))}
</div>
</DndContext>
);
}
@@ -325,13 +325,100 @@ describe("TableControls", () => {
expect(screen.queryByLabelText(/insert row/i)).toBeNull(); expect(screen.queryByLabelText(/insert row/i)).toBeNull();
}); });
it("renders the FilterBuilder inside the filter popover", () => { it("renders the column filter popover with the + Add filter action", () => {
seed([makeTab()], "tab-1"); seed([makeTab()], "tab-1");
renderControls(); renderControls();
fireEvent.click(screen.getByLabelText(/column filters/i)); fireEvent.click(screen.getByLabelText(/column filters/i));
expect( expect(screen.getByText("Column Filters")).toBeInTheDocument();
screen.getByText("Drop columns here to add filters"), expect(screen.getByText("+ Add filter")).toBeInTheDocument();
).toBeInTheDocument(); });
it("debounces filter value edits so a query only fires after the user stops typing", () => {
vi.useFakeTimers();
seed([makeTab()], "tab-1");
const onFilterChange = vi.fn();
renderControls({
onFilterChange,
filterRules: [
{ id: "r1", column: "id", operator: "contains", value: "" },
],
});
fireEvent.click(screen.getByLabelText(/column filters/i));
const input = screen.getByPlaceholderText("value");
fireEvent.change(input, { target: { value: "a" } });
fireEvent.change(input, { target: { value: "ab" } });
fireEvent.change(input, { target: { value: "abc" } });
// No commit yet — the debounce hasn't elapsed.
expect(onFilterChange).not.toHaveBeenCalled();
act(() => {
vi.advanceTimersByTime(400);
});
// Only the final value is committed, once.
expect(onFilterChange).toHaveBeenCalledTimes(1);
expect(onFilterChange).toHaveBeenCalledWith([
expect.objectContaining({ value: "abc" }),
]);
});
it("closes the filter popover when the button is clicked while it is open", () => {
seed([makeTab()], "tab-1");
renderControls();
fireEvent.click(screen.getByLabelText(/column filters/i));
expect(screen.getByText("Column Filters")).toBeInTheDocument();
// Clicking the button while open closes it (plain toggle).
fireEvent.click(screen.getByLabelText(/column filters/i));
expect(screen.queryByText("Column Filters")).not.toBeInTheDocument();
});
it("toggles the sort rules popover open and closed", () => {
seed([makeTab()], "tab-1");
renderControls();
fireEvent.click(screen.getByLabelText(/sort rules/i));
expect(screen.getByText("Sort Rules")).toBeInTheDocument();
fireEvent.click(screen.getByLabelText(/sort rules/i));
expect(screen.queryByText("Sort Rules")).not.toBeInTheDocument();
});
it("toggles the auto-refresh dropdown open and closed", () => {
seed([makeTab()], "tab-1");
renderControls();
fireEvent.click(screen.getByLabelText(/auto-refresh/i));
expect(screen.getByText("Off")).toBeInTheDocument();
fireEvent.click(screen.getByLabelText(/auto-refresh/i));
expect(screen.queryByText("Off")).not.toBeInTheDocument();
});
it("toggles the show/hide columns dropdown open and closed", () => {
seed([makeTab()], "tab-1");
renderControls();
fireEvent.click(screen.getByLabelText(/toggle columns/i));
expect(screen.getByText("Visible columns")).toBeInTheDocument();
fireEvent.click(screen.getByLabelText(/toggle columns/i));
expect(screen.queryByText("Visible columns")).not.toBeInTheDocument();
});
it("toggles the export dropdown open and closed", () => {
seed([makeTab()], "tab-1");
renderControls();
fireEvent.click(screen.getByLabelText(/export/i));
expect(screen.getByText("JSON")).toBeInTheDocument();
fireEvent.click(screen.getByLabelText(/export/i));
expect(screen.queryByText("JSON")).not.toBeInTheDocument();
}); });
it("export dropdown includes the Excel option", () => { it("export dropdown includes the Excel option", () => {
@@ -6,7 +6,6 @@ import {
} from "lucide-react"; } from "lucide-react";
import { useDbViewerStore, type FilterRule, type SortRule } from "../../stores/dbViewerStore"; import { useDbViewerStore, type FilterRule, type SortRule } from "../../stores/dbViewerStore";
import { useNotificationStore } from "../../stores/notificationStore"; import { useNotificationStore } from "../../stores/notificationStore";
import { FilterBuilder } from "./FilterBuilder";
import { Tooltip } from "../ui/Tooltip"; import { Tooltip } from "../ui/Tooltip";
import { exportData } from "../../lib/exportData"; import { exportData } from "../../lib/exportData";
import type { ColumnInfo } from "../../lib/types"; import type { ColumnInfo } from "../../lib/types";
@@ -49,22 +48,28 @@ function DropdownMenu({
open, open,
setOpen, setOpen,
align, align,
wrapRef,
children, children,
}: { }: {
open: boolean; open: boolean;
setOpen: (v: boolean) => void; setOpen: (v: boolean) => void;
align?: "left" | "right"; align?: "left" | "right";
/** When provided, outside-click detection uses this wrapper (which should
* contain both the toggle button and the menu) so the button never fights
* the outside-click handler. */
wrapRef?: React.RefObject<HTMLDivElement | null>;
children: React.ReactNode; children: React.ReactNode;
}) { }) {
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
const close = (e: MouseEvent) => { const close = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); const container = wrapRef?.current ?? ref.current;
if (container && !container.contains(e.target as Node)) setOpen(false);
}; };
document.addEventListener("mousedown", close); document.addEventListener("mousedown", close);
return () => document.removeEventListener("mousedown", close); return () => document.removeEventListener("mousedown", close);
}, [open, setOpen]); }, [open, setOpen, wrapRef]);
if (!open) return null; if (!open) return null;
return ( return (
@@ -79,28 +84,84 @@ function DropdownMenu({
); );
} }
/**
* Debounced free-text input for filter values. The value is committed to the
* rules (and thus triggers a server-side refetch) only after the user stops
* typing for `DEBOUNCE_MS`. Without this, every keystroke fires a query and
* the in-flight guard in `fetchData` silently drops intermediate fetches, so
* the final filter value can end up not being applied.
*/
const FILTER_DEBOUNCE_MS = 400;
function DebouncedValueInput({
value,
onCommit,
placeholder,
}: {
value: string;
onCommit: (v: string) => void;
placeholder?: string;
}) {
const [draft, setDraft] = useState(value);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Keep the draft in sync when the committed value changes externally
// (e.g. a rule is replaced or removed while this input is mounted).
useEffect(() => {
setDraft(value);
}, [value]);
useEffect(() => {
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, []);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const v = e.target.value;
setDraft(v);
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => onCommit(v), FILTER_DEBOUNCE_MS);
};
return (
<input
type="text"
value={draft}
onChange={handleChange}
placeholder={placeholder}
className="flex-1 rounded border border-border bg-surface text-xs px-1.5 py-1 text-text min-w-0"
/>
);
}
function FilterModal({ function FilterModal({
columns, columns,
rules, rules,
onChange, onChange,
open, open,
setOpen, setOpen,
wrapRef,
}: { }: {
columns: ColumnInfo[]; columns: ColumnInfo[];
rules: FilterRule[]; rules: FilterRule[];
onChange: (rules: FilterRule[]) => void; onChange: (rules: FilterRule[]) => void;
open: boolean; open: boolean;
setOpen: (v: boolean) => void; setOpen: (v: boolean) => void;
wrapRef: React.RefObject<HTMLDivElement | null>;
}) { }) {
const ref = useRef<HTMLDivElement>(null); // Close on outside click, but treat the toggle button (which lives inside
// the same wrapper) as part of the modal so it never fights the toggle.
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
const close = (e: MouseEvent) => { const close = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) {
setOpen(false);
}
}; };
document.addEventListener("mousedown", close); document.addEventListener("mousedown", close);
return () => document.removeEventListener("mousedown", close); return () => document.removeEventListener("mousedown", close);
}, [open, setOpen]); }, [open, setOpen, wrapRef]);
if (!open) return null; if (!open) return null;
@@ -116,17 +177,13 @@ function FilterModal({
onChange(rules.map((r) => (r.id === id ? { ...r, ...patch } : r))); onChange(rules.map((r) => (r.id === id ? { ...r, ...patch } : r)));
return ( return (
<div <div className="absolute top-full left-0 mt-1 z-30 w-96 rounded-lg bg-surface border border-border shadow-lg p-3">
ref={ref}
className="absolute top-full left-0 mt-1 z-30 w-96 rounded-lg bg-surface border border-border shadow-lg p-3"
>
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<span className="text-xs font-semibold text-text">Column Filters</span> <span className="text-xs font-semibold text-text">Column Filters</span>
<button type="button" onClick={() => setOpen(false)} className="text-text-muted hover:text-text cursor-pointer"> <button type="button" onClick={() => setOpen(false)} className="text-text-muted hover:text-text cursor-pointer">
<X size={14} /> <X size={14} />
</button> </button>
</div> </div>
<FilterBuilder columns={columns} rules={rules} onChange={onChange} />
{rules.map((rule) => ( {rules.map((rule) => (
<div key={rule.id} className="flex items-center gap-1.5 mb-1.5"> <div key={rule.id} className="flex items-center gap-1.5 mb-1.5">
<select <select
@@ -152,12 +209,10 @@ function FilterModal({
<option value="notnull">not null</option> <option value="notnull">not null</option>
</select> </select>
{rule.operator !== "null" && rule.operator !== "notnull" && ( {rule.operator !== "null" && rule.operator !== "notnull" && (
<input <DebouncedValueInput
type="text"
value={rule.value} value={rule.value}
onChange={(e) => updateRule(rule.id, { value: e.target.value })} onCommit={(v) => updateRule(rule.id, { value: v })}
placeholder="value" placeholder="value"
className="flex-1 rounded border border-border bg-surface text-xs px-1.5 py-1 text-text min-w-0"
/> />
)} )}
<button type="button" onClick={() => removeRule(rule.id)} className="text-text-muted hover:text-red-400 shrink-0 cursor-pointer"> <button type="button" onClick={() => removeRule(rule.id)} className="text-text-muted hover:text-red-400 shrink-0 cursor-pointer">
@@ -182,22 +237,27 @@ function SortModal({
onChange, onChange,
open, open,
setOpen, setOpen,
wrapRef,
}: { }: {
columns: ColumnInfo[]; columns: ColumnInfo[];
rules: SortRule[]; rules: SortRule[];
onChange: (rules: SortRule[]) => void; onChange: (rules: SortRule[]) => void;
open: boolean; open: boolean;
setOpen: (v: boolean) => void; setOpen: (v: boolean) => void;
wrapRef: React.RefObject<HTMLDivElement | null>;
}) { }) {
const ref = useRef<HTMLDivElement>(null); // Close on outside click, but treat the toggle button (which lives inside
// the same wrapper) as part of the modal so it never fights the toggle.
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
const close = (e: MouseEvent) => { const close = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) {
setOpen(false);
}
}; };
document.addEventListener("mousedown", close); document.addEventListener("mousedown", close);
return () => document.removeEventListener("mousedown", close); return () => document.removeEventListener("mousedown", close);
}, [open, setOpen]); }, [open, setOpen, wrapRef]);
if (!open) return null; if (!open) return null;
@@ -213,10 +273,7 @@ function SortModal({
onChange(rules.map((r) => (r.id === id ? { ...r, ...patch } : r))); onChange(rules.map((r) => (r.id === id ? { ...r, ...patch } : r)));
return ( return (
<div <div className="absolute top-full left-0 mt-1 z-30 w-72 rounded-lg bg-surface border border-border shadow-lg p-3">
ref={ref}
className="absolute top-full left-0 mt-1 z-30 w-72 rounded-lg bg-surface border border-border shadow-lg p-3"
>
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<span className="text-xs font-semibold text-text">Sort Rules</span> <span className="text-xs font-semibold text-text">Sort Rules</span>
<button type="button" onClick={() => setOpen(false)} className="text-text-muted hover:text-text cursor-pointer"> <button type="button" onClick={() => setOpen(false)} className="text-text-muted hover:text-text cursor-pointer">
@@ -272,6 +329,7 @@ function BulkActionsDropdown({
onClearSelection: () => void; onClearSelection: () => void;
}) { }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const wrapRef = useRef<HTMLDivElement>(null);
const addChange = useDbViewerStore((s) => s.addChange); const addChange = useDbViewerStore((s) => s.addChange);
const copyToClipboard = (text: string) => { const copyToClipboard = (text: string) => {
@@ -338,7 +396,7 @@ function BulkActionsDropdown({
}; };
return ( return (
<div className="relative"> <div className="relative" ref={wrapRef}>
<button <button
type="button" type="button"
onClick={() => setOpen((v) => !v)} onClick={() => setOpen((v) => !v)}
@@ -347,7 +405,7 @@ function BulkActionsDropdown({
<span className="text-xs font-medium">Actions</span> <span className="text-xs font-medium">Actions</span>
<ChevronDown size={12} /> <ChevronDown size={12} />
</button> </button>
<DropdownMenu open={open} setOpen={setOpen} align="right"> <DropdownMenu open={open} setOpen={setOpen} align="right" wrapRef={wrapRef}>
<button <button
type="button" type="button"
onClick={handleCopyJSON} onClick={handleCopyJSON}
@@ -445,11 +503,16 @@ export function TableControls({
// local state // local state
const [filterOpen, setFilterOpen] = useState(false); const [filterOpen, setFilterOpen] = useState(false);
const filterWrapRef = useRef<HTMLDivElement>(null);
const [sortOpen, setSortOpen] = useState(false); const [sortOpen, setSortOpen] = useState(false);
const sortWrapRef = useRef<HTMLDivElement>(null);
const [columnMenuOpen, setColumnMenuOpen] = useState(false); const [columnMenuOpen, setColumnMenuOpen] = useState(false);
const columnMenuWrapRef = useRef<HTMLDivElement>(null);
const [exportOpen, setExportOpen] = useState(false); const [exportOpen, setExportOpen] = useState(false);
const exportWrapRef = useRef<HTMLDivElement>(null);
const [autoRefresh, setAutoRefresh] = useState(defaultRefreshRate); const [autoRefresh, setAutoRefresh] = useState(defaultRefreshRate);
const [autoRefreshOpen, setAutoRefreshOpen] = useState(false); const [autoRefreshOpen, setAutoRefreshOpen] = useState(false);
const autoRefreshWrapRef = useRef<HTMLDivElement>(null);
// Auto-refresh: a self-restarting timer that only counts down while the tab // Auto-refresh: a self-restarting timer that only counts down while the tab
// is idle. Fires a refresh, waits for it to complete (loading → false), // is idle. Fires a refresh, waits for it to complete (loading → false),
@@ -545,7 +608,7 @@ export function TableControls({
); );
const exportControl = ( const exportControl = (
<div className="relative"> <div className="relative" ref={exportWrapRef}>
<Tooltip content="Export" side="bottom"> <Tooltip content="Export" side="bottom">
<button <button
type="button" type="button"
@@ -556,7 +619,7 @@ export function TableControls({
<Download size={14} /> <Download size={14} />
</button> </button>
</Tooltip> </Tooltip>
<DropdownMenu open={exportOpen} setOpen={setExportOpen}> <DropdownMenu open={exportOpen} setOpen={setExportOpen} wrapRef={exportWrapRef}>
{EXPORT_FORMATS.map((fmt) => ( {EXPORT_FORMATS.map((fmt) => (
<button <button
key={fmt.ext} key={fmt.ext}
@@ -572,7 +635,7 @@ export function TableControls({
); );
const columnsControl = ( const columnsControl = (
<div className="relative"> <div className="relative" ref={columnMenuWrapRef}>
<Tooltip content="Show/hide columns" side="bottom"> <Tooltip content="Show/hide columns" side="bottom">
<button <button
type="button" type="button"
@@ -587,6 +650,7 @@ export function TableControls({
open={columnMenuOpen} open={columnMenuOpen}
setOpen={setColumnMenuOpen} setOpen={setColumnMenuOpen}
align={isQuery ? "left" : "right"} align={isQuery ? "left" : "right"}
wrapRef={columnMenuWrapRef}
> >
<div className="px-2 py-1 text-[10px] text-text-muted uppercase tracking-wider"> <div className="px-2 py-1 text-[10px] text-text-muted uppercase tracking-wider">
Visible columns Visible columns
@@ -658,7 +722,7 @@ export function TableControls({
{refreshControl} {refreshControl}
{/* Auto-refresh */} {/* Auto-refresh */}
<div className="relative"> <div className="relative" ref={autoRefreshWrapRef}>
<Tooltip content={`Auto-refresh: ${autoRefresh > 0 ? `${autoRefresh / 1000}s` : "Off"}`} side="bottom"> <Tooltip content={`Auto-refresh: ${autoRefresh > 0 ? `${autoRefresh / 1000}s` : "Off"}`} side="bottom">
<button <button
type="button" type="button"
@@ -672,7 +736,7 @@ export function TableControls({
{autoRefresh > 0 && <span className="text-[10px] font-medium">{autoRefresh / 1000}s</span>} {autoRefresh > 0 && <span className="text-[10px] font-medium">{autoRefresh / 1000}s</span>}
</button> </button>
</Tooltip> </Tooltip>
<DropdownMenu open={autoRefreshOpen} setOpen={setAutoRefreshOpen}> <DropdownMenu open={autoRefreshOpen} setOpen={setAutoRefreshOpen} wrapRef={autoRefreshWrapRef}>
{AUTO_REFRESH_OPTIONS.map((opt) => ( {AUTO_REFRESH_OPTIONS.map((opt) => (
<button <button
key={opt.value} key={opt.value}
@@ -692,7 +756,7 @@ export function TableControls({
<div className="w-px h-4 bg-border mx-1" /> <div className="w-px h-4 bg-border mx-1" />
{/* Filter */} {/* Filter */}
<div className="relative"> <div className="relative" ref={filterWrapRef}>
<Tooltip content="Column filters" side="bottom"> <Tooltip content="Column filters" side="bottom">
<button <button
type="button" type="button"
@@ -716,11 +780,12 @@ export function TableControls({
onChange={onFilterChange} onChange={onFilterChange}
open={filterOpen} open={filterOpen}
setOpen={setFilterOpen} setOpen={setFilterOpen}
wrapRef={filterWrapRef}
/> />
</div> </div>
{/* Sort */} {/* Sort */}
<div className="relative"> <div className="relative" ref={sortWrapRef}>
<Tooltip content="Sort rules" side="bottom"> <Tooltip content="Sort rules" side="bottom">
<button <button
type="button" type="button"
@@ -744,6 +809,7 @@ export function TableControls({
onChange={onSortChange} onChange={onSortChange}
open={sortOpen} open={sortOpen}
setOpen={setSortOpen} setOpen={setSortOpen}
wrapRef={sortWrapRef}
/> />
</div> </div>
+3 -3
View File
@@ -1,11 +1,11 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import tauriConf from "../../src-tauri/tauri.conf.json"; import tauriConf from "../../src-tauri/tauri.conf.json";
describe("tauri bundle config (v0.7.11)", () => { describe("tauri bundle config (v0.7.14)", () => {
it("declares bundled pg_tools resources", () => { it("declares bundled pg_tools resources", () => {
expect(tauriConf.bundle.resources).toContain("resources/pg_tools/*"); expect(tauriConf.bundle.resources).toContain("resources/pg_tools/*");
}); });
it("version is 0.7.11", () => { it("version is 0.7.14", () => {
expect(tauriConf.version).toBe("0.7.11"); expect(tauriConf.version).toBe("0.7.14");
}); });
}); });
+7 -7
View File
@@ -4,7 +4,7 @@ import { describe, it, expect } from "vitest";
import agents from "../../../AGENTS.md?raw"; import agents from "../../../AGENTS.md?raw";
import readme from "../../../README.md?raw"; import readme from "../../../README.md?raw";
describe("v0.7.11 docs coverage", () => { describe("v0.7.14 docs coverage", () => {
it("AGENTS.md marks inline cell editing complete", () => { it("AGENTS.md marks inline cell editing complete", () => {
expect(agents).toContain("Inline cell editing"); expect(agents).toContain("Inline cell editing");
expect(agents).toMatch(/Inline cell editing \| ✅/); expect(agents).toMatch(/Inline cell editing \| ✅/);
@@ -24,8 +24,8 @@ describe("v0.7.11 docs coverage", () => {
expect(agents).toMatch(/Connection status indicator on cards \| ✅/); expect(agents).toMatch(/Connection status indicator on cards \| ✅/);
expect(agents).toMatch(/Move-to-folder bulk action \| ✅/); expect(agents).toMatch(/Move-to-folder bulk action \| ✅/);
}); });
it("README declares v0.7.11", () => { it("README declares v0.7.14", () => {
expect(readme).toContain("0.7.11"); expect(readme).toContain("0.7.14");
}); });
it("AGENTS.md marks schema CRUD complete", () => { it("AGENTS.md marks schema CRUD complete", () => {
expect(agents).toMatch(/Schema CRUD \| ✅/); expect(agents).toMatch(/Schema CRUD \| ✅/);
@@ -64,9 +64,9 @@ describe("v0.7.11 docs coverage", () => {
expect(agents).toMatch(/Cancel long-running queries \| ✅/); expect(agents).toMatch(/Cancel long-running queries \| ✅/);
expect(agents).toMatch(/Settings export\/import \| ✅/); expect(agents).toMatch(/Settings export\/import \| ✅/);
}); });
it("README links to v0.7.11 assets in both download tables", () => { it("README links to v0.7.14 assets in both download tables", () => {
expect(readme).toContain("releases/download/v0.7.11/"); expect(readme).toContain("releases/download/v0.7.14/");
expect(readme).toContain("Gridline_0.7.11_aarch64.dmg"); expect(readme).toContain("Gridline_0.7.14_aarch64.dmg");
expect(readme).toContain("Gridline-0.7.11-1.x86_64.rpm"); expect(readme).toContain("Gridline-0.7.14-1.x86_64.rpm");
}); });
}); });
+2 -2
View File
@@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest";
import pkg from "../../package.json"; import pkg from "../../package.json";
describe("version", () => { describe("version", () => {
it("declares v0.7.11 across the app shell", () => { it("declares v0.7.14 across the app shell", () => {
expect(pkg.version).toBe("0.7.11"); expect(pkg.version).toBe("0.7.14");
}); });
}); });
@@ -225,6 +225,33 @@ describe("favorites / recents / move-selection", () => {
expect(commands.recordRecentConnection).toHaveBeenCalledWith("c1"); expect(commands.recordRecentConnection).toHaveBeenCalledWith("c1");
}); });
it("loadAll refreshes recents once connections are known (startup race fix)", async () => {
const conn = makeConn({ id: "c1", name: "Prod" });
vi.spyOn(commands, "getConnections").mockResolvedValue([conn]);
vi.spyOn(commands, "getFolders").mockResolvedValue([]);
vi.spyOn(commands, "getTags").mockResolvedValue([]);
vi.spyOn(commands, "getRecentConnections").mockResolvedValue([
{ connection_id: "c1", opened_at: "2026-08-02T00:00:00Z" },
]);
// Simulate the app-shell mount: loadAll fires and populates connections.
await useConnectionStore.getState().loadAll();
// The recent strip must now show the connection that was opened previously.
expect(commands.getRecentConnections).toHaveBeenCalledWith(8);
expect(useConnectionStore.getState().recent).toEqual([conn]);
});
it("loadRecent maps cached recent ids against the current connection list", async () => {
const conn = makeConn({ id: "c1", name: "Prod" });
useConnectionStore.setState({ connections: [conn] });
vi.spyOn(commands, "getRecentConnections").mockResolvedValue([
{ connection_id: "c1", opened_at: "2026-08-02T00:00:00Z" },
]);
await useConnectionStore.getState().loadRecent();
expect(useConnectionStore.getState().recent).toEqual([conn]);
});
describe("duplicateConnection", () => { describe("duplicateConnection", () => {
it("copies fields with a '(copy)' name and favorite false", async () => { it("copies fields with a '(copy)' name and favorite false", async () => {
const src = makeConn({ id: "c1", name: "Prod", folder_id: "f1", tag_ids: ["t1"], favorite: true }); const src = makeConn({ id: "c1", name: "Prod", folder_id: "f1", tag_ids: ["t1"], favorite: true });
+3
View File
@@ -46,6 +46,9 @@ export const useConnectionStore = create<ConnectionState>((set, get) => ({
set((s) => ({ connections: [...s.connections].sort((a, b) => Number(b.favorite) - Number(a.favorite)) })); set((s) => ({ connections: [...s.connections].sort((a, b) => Number(b.favorite) - Number(a.favorite)) }));
// Also load tag order // Also load tag order
get().loadTagOrder(); get().loadTagOrder();
// Refresh recents once connections are known so the recent strip is not
// computed against an empty list (avoids a startup race with loadRecent).
get().loadRecent().catch(() => {});
} catch (e) { } catch (e) {
set({ loading: false, error: e instanceof Error ? e.message : String(e) }); set({ loading: false, error: e instanceof Error ? e.message : String(e) });
} }
+5 -19
View File
@@ -11,13 +11,8 @@
# #
# brew install --cask gridline # brew install --cask gridline
# #
# QUARANTINE: Gridline is ad-hoc signed (not Developer-ID signed/notarized), # SIGNING: Gridline is Developer-ID signed + notarized, so macOS trusts it
# so Homebrew casks (which deliberately set com.apple.quarantine on the # and no quarantine-stripping postflight is needed.
# downloaded artifact) would make Gatekeeper refuse to launch it. The
# `postflight` block strips the quarantine flag automatically — the standard
# GoReleaser-documented pattern for unsigned casks — so `brew install` works
# with no manual `xattr` step. This is a Gatekeeper bypass; drop the
# postflight once the app is properly signed + notarized.
cask "gridline" do cask "gridline" do
arch arm: "aarch64", intel: "x64" arch arm: "aarch64", intel: "x64"
@@ -26,24 +21,15 @@ cask "gridline" do
sha256 arm: "@ARM_SHA256@", sha256 arm: "@ARM_SHA256@",
intel: "@INTEL_SHA256@" intel: "@INTEL_SHA256@"
url "https://github.com/AdrianBonpin/gridline/releases/download/v#{version}/Gridline_#{version}_#{arch}.dmg" url "https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v#{version}/Gridline_#{version}_#{arch}.dmg"
name "Gridline" name "Gridline"
desc "Open-source, cross-platform database GUI client for PostgreSQL" desc "Open-source, cross-platform database GUI client for PostgreSQL"
homepage "https://github.com/AdrianBonpin/gridline" homepage "https://git.ranio.xyz/adrianbonpin/gridline"
depends_on macos: ">= :monterey" depends_on macos: :monterey
app "Gridline.app" app "Gridline.app"
# The "installation step" — strip the quarantine flag so the unsigned
# (ad-hoc signed) app launches without the Gatekeeper prompt.
postflight do
if OS.mac?
system_command "/usr/bin/xattr",
args: ["-dr", "com.apple.quarantine", "#{appdir}/Gridline.app"]
end
end
zap trash: [ zap trash: [
"~/Library/Application Support/com.adrianbonpin.gridline", "~/Library/Application Support/com.adrianbonpin.gridline",
"~/Library/Caches/com.adrianbonpin.gridline", "~/Library/Caches/com.adrianbonpin.gridline",
+96 -3
View File
@@ -45,12 +45,76 @@ get_token() {
if [ -n "$tok" ]; then echo "$tok"; return; fi if [ -n "$tok" ]; then echo "$tok"; return; fi
fi fi
fi fi
read -rsp "Gitea personal access token (repo write): " GITEA_TOKEN # Fall back to the git credential store: tea's OAuth login stores an access
echo "$GITEA_TOKEN" # token as the password for every push to this remote, and Gitea accepts it
# for API auth (Authorization: token <access_token>).
local host cred
host="${GITEA_SERVER#*://}"
cred="$(printf 'protocol=https\nhost=%s\n\n' "$host" \
| git credential fill 2>/dev/null | sed -n 's/^password=//p' | head -1 || true)"
if [ -n "$cred" ]; then echo "$cred"; return; fi
# Fail fast — continuing without a token fails only at the upload step,
# after the whole build has run.
echo "error: no Gitea token found" >&2
echo " set GITEA_TOKEN, or run 'tea login' / 'git push' once so the" >&2
echo " credential store has an entry for $host" >&2
exit 1
} }
GITEA_TOKEN="$(get_token)" GITEA_TOKEN="$(get_token)"
# ---- macOS signing + notarization -----------------------------------------
# Requires a "Developer ID Application" certificate in the login keychain and
# an App Store Connect API key for notarization. See the README's "macOS
# signing" section for how to create both.
#
# Notarization credentials are read from ~/.config/gridline/notarize.env
# (never committed). Create it with:
# APPLE_API_KEY=<Key ID>
# APPLE_API_ISSUER=<Issuer ID>
# APPLE_API_KEY_PATH=/absolute/path/to/AuthKey_<KeyID>.p8
# (or drop the .p8 at ~/.appstoreconnect/private_keys/AuthKey_<KeyID>.p8 and
# omit APPLE_API_KEY_PATH — tauri auto-searches that location.)
# 1. Resolve the Developer ID Application signing identity from the keychain.
SIGNING_IDENTITY="$(security find-identity -v -p codesigning 2>/dev/null \
| grep -i 'Developer ID Application' \
| sed -E 's/.*"([^"]+)".*/\1/' \
| head -1 || true)"
if [ -z "$SIGNING_IDENTITY" ]; then
echo "ERROR: No 'Developer ID Application' certificate found in the login keychain." >&2
echo " Create one at https://developer.apple.com/account/resources/certificates/list" >&2
echo " (Certificates, IDs & Profiles -> + -> Developer ID Application), download the" >&2
echo " .cer, and double-click it to install into your login keychain." >&2
echo " Verify with: security find-identity -v -p codesigning" >&2
exit 1
fi
export APPLE_SIGNING_IDENTITY="$SIGNING_IDENTITY"
echo ">> Signing identity: $SIGNING_IDENTITY"
# 2. Load notarization credentials.
NOTARIZE_ENV="${HOME}/.config/gridline/notarize.env"
if [ -f "$NOTARIZE_ENV" ]; then
set -a
# shellcheck disable=SC1090
source "$NOTARIZE_ENV"
set +a
fi
if [ -z "${APPLE_API_KEY:-}" ] || [ -z "${APPLE_API_ISSUER:-}" ]; then
if [ "${GRIDLINE_SKIP_NOTARIZE:-0}" = "1" ]; then
echo ">> WARNING: notarization credentials missing; GRIDLINE_SKIP_NOTARIZE=1 set." >&2
echo " Building signed but NOT notarized (test-only — users will still see a prompt)." >&2
else
echo "ERROR: Notarization credentials not configured." >&2
echo " Create ${NOTARIZE_ENV} with APPLE_API_KEY and APPLE_API_ISSUER" >&2
echo " (and APPLE_API_KEY_PATH, or place the .p8 at ~/.appstoreconnect/private_keys/)." >&2
echo " See the README's 'macOS signing' section for the App Store Connect API key setup." >&2
exit 1
fi
else
echo ">> Notarization: API key ${APPLE_API_KEY} (issuer ${APPLE_API_ISSUER})"
fi
# ---- build bundled DB tools (macOS) ---------------------------------------- # ---- build bundled DB tools (macOS) ----------------------------------------
WORKSPACE="$(cd "$(dirname "$0")/.." && pwd)" WORKSPACE="$(cd "$(dirname "$0")/.." && pwd)"
OUT_PG="${WORKSPACE}/desktop/src-tauri/resources/pg_tools" OUT_PG="${WORKSPACE}/desktop/src-tauri/resources/pg_tools"
@@ -60,6 +124,8 @@ echo ">> Building PostgreSQL client tools..."
mkdir -p "$OUT_PG" mkdir -p "$OUT_PG"
PG_VER="16.4" PG_VER="16.4"
curl -fsSL "https://ftp.postgresql.org/pub/source/v${PG_VER}/postgresql-${PG_VER}.tar.bz2" -o /tmp/pg.tar.bz2 curl -fsSL "https://ftp.postgresql.org/pub/source/v${PG_VER}/postgresql-${PG_VER}.tar.bz2" -o /tmp/pg.tar.bz2
# Idempotent re-runs: clear stale build dirs left by previous invocations.
rm -rf "/tmp/postgresql-${PG_VER}" /tmp/pgbuild
tar -xf /tmp/pg.tar.bz2 -C /tmp tar -xf /tmp/pg.tar.bz2 -C /tmp
cd /tmp/postgresql-${PG_VER} cd /tmp/postgresql-${PG_VER}
ac_cv_func_strchrnul=no ./configure --prefix=/tmp/pgbuild --without-readline --without-icu CFLAGS="-O2" ac_cv_func_strchrnul=no ./configure --prefix=/tmp/pgbuild --without-readline --without-icu CFLAGS="-O2"
@@ -75,10 +141,19 @@ for b in pg_dump pg_restore psql; do
libpq=$(otool -L "$OUT_PG/$b" | awk '/libpq/ {print $1; exit}') libpq=$(otool -L "$OUT_PG/$b" | awk '/libpq/ {print $1; exit}')
install_name_tool -change "$libpq" "@loader_path/libpq.5.dylib" "$OUT_PG/$b" install_name_tool -change "$libpq" "@loader_path/libpq.5.dylib" "$OUT_PG/$b"
done done
# install_name_tool invalidates the linker's signature; re-sign with the
# Developer ID identity + hardened runtime + secure timestamp so the bundle
# passes notarization (Apple rejects ad-hoc-signed nested executables).
for f in "$OUT_PG"/pg_dump "$OUT_PG"/pg_restore "$OUT_PG"/psql "$OUT_PG"/libpq.5.dylib; do
codesign --force --options runtime --timestamp --sign "$SIGNING_IDENTITY" "$f"
done
echo ">> Building MariaDB client tools..." echo ">> Building MariaDB client tools..."
mkdir -p "$OUT_MY" mkdir -p "$OUT_MY"
MARIADB_VER="11.4.5" MARIADB_VER="11.4.5"
# Idempotent re-runs: git clone fails if the destination is non-empty (e.g.
# left over from an earlier build on the same machine). Clean it first.
rm -rf /tmp/mariadb-server
git clone --depth 1 --branch "mariadb-${MARIADB_VER}" https://github.com/MariaDB/server.git /tmp/mariadb-server git clone --depth 1 --branch "mariadb-${MARIADB_VER}" https://github.com/MariaDB/server.git /tmp/mariadb-server
cd /tmp/mariadb-server cd /tmp/mariadb-server
if [ "$(uname -m)" = arm64 ]; then if [ "$(uname -m)" = arm64 ]; then
@@ -112,7 +187,7 @@ for pass in 1 2 3 4 5 6; do
if [ ! -f "$OUT_MY/$base" ]; then cp "$lib" "$OUT_MY/$base" 2>/dev/null || true; fi if [ ! -f "$OUT_MY/$base" ]; then cp "$lib" "$OUT_MY/$base" 2>/dev/null || true; fi
install_name_tool -change "$lib" "@loader_path/$base" "$dylib" 2>/dev/null || true install_name_tool -change "$lib" "@loader_path/$base" "$dylib" 2>/dev/null || true
done || true done || true
codesign --force --sign - "$dylib" 2>/dev/null || true codesign --force --options runtime --timestamp --sign "$SIGNING_IDENTITY" "$dylib"
done done
done done
@@ -130,6 +205,24 @@ VERSION="${TAG#v}"
BUNDLE_ARM="${WORKSPACE}/desktop/src-tauri/target/aarch64-apple-darwin/release/bundle" BUNDLE_ARM="${WORKSPACE}/desktop/src-tauri/target/aarch64-apple-darwin/release/bundle"
BUNDLE_INTEL="${WORKSPACE}/desktop/src-tauri/target/x86_64-apple-darwin/release/bundle" BUNDLE_INTEL="${WORKSPACE}/desktop/src-tauri/target/x86_64-apple-darwin/release/bundle"
# ---- verify signing + notarization -----------------------------------------
# Confirm both .app bundles are properly signed and carry a stapled
# notarization ticket before we upload them. spctl can lag right after
# notarization (assessment cache), so stapler validate is the authoritative
# check; a pending spctl is only a warning.
for APP in \
"${BUNDLE_ARM}/macos/Gridline.app" \
"${BUNDLE_INTEL}/macos/Gridline.app"; do
[ -d "$APP" ] || { echo "missing $APP" >&2; exit 1; }
echo ">> Verifying $APP"
codesign --verify --deep --strict --verbose=2 "$APP" \
|| { echo "codesign verify FAILED" >&2; exit 1; }
xcrun stapler validate "$APP" \
|| { echo "notarization ticket not stapled" >&2; exit 1; }
spctl --assess --type execute --verbose=4 "$APP" \
|| echo " (spctl assessment pending — normal right after notarization; stapler validate passed)"
done
# ---- create/ensure release + upload DMGs ----------------------------------- # ---- create/ensure release + upload DMGs -----------------------------------
API="${GITEA_SERVER}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}" API="${GITEA_SERVER}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}"
AUTH="Authorization: token ${GITEA_TOKEN}" AUTH="Authorization: token ${GITEA_TOKEN}"
+1 -3
View File
@@ -10,9 +10,7 @@ export default defineConfig({
compressHTML: true, compressHTML: true,
trailingSlash: 'ignore', trailingSlash: 'ignore',
integrations: [ integrations: [
sitemap({ sitemap(),
lastmod: new Date(),
}),
react(), react(),
], ],
vite: { vite: {
+1
View File
@@ -0,0 +1 @@
/* /404.html 404
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 655 B

After

Width:  |  Height:  |  Size: 1.5 KiB

+31 -8
View File
@@ -1,9 +1,32 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 128 128"> <?xml version="1.0" encoding="UTF-8" standalone="no"?>
<path d="M50.4 78.5a75.1 75.1 0 0 0-28.5 6.9l24.2-65.7c.7-2 1.9-3.2 3.4-3.2h29c1.5 0 2.7 1.2 3.4 3.2l24.2 65.7s-11.6-7-28.5-7L67 45.5c-.4-1.7-1.6-2.8-2.9-2.8-1.3 0-2.5 1.1-2.9 2.7L50.4 78.5Zm-1.1 28.2Zm-4.2-20.2c-2 6.6-.6 15.8 4.2 20.2a17.5 17.5 0 0 1 .2-.7 5.5 5.5 0 0 1 5.7-4.5c2.8.1 4.3 1.5 4.7 4.7.2 1.1.2 2.3.2 3.5v.4c0 2.7.7 5.2 2.2 7.4a13 13 0 0 0 5.7 4.9v-.3l-.2-.3c-1.8-5.6-.5-9.5 4.4-12.8l1.5-1a73 73 0 0 0 3.2-2.2 16 16 0 0 0 6.8-11.4c.3-2 .1-4-.6-6l-.8.6-1.6 1a37 37 0 0 1-22.4 2.7c-5-.7-9.7-2-13.2-6.2Z" /> <!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<style> <svg width="100%" height="100%" viewBox="0 0 1000 1000" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:1.5;">
path { fill: #000; } <g transform="matrix(1.464129,0,0,1.464129,-67.349927,-273.792094)">
@media (prefers-color-scheme: dark) { <path d="M729,357.75L729,699.25C729,793.489 652.489,870 558.25,870L216.75,870C122.511,870 46,793.489 46,699.25L46,357.75C46,263.511 122.511,187 216.75,187L558.25,187C652.489,187 729,263.511 729,357.75Z" style="fill:url(#_Linear1);stroke:black;stroke-width:0.68px;"/>
path { fill: #FFF; } </g>
} <g id="Icon">
</style> <g transform="matrix(1.186992,0,0,1.186992,-93.495935,-96.300813)">
<path d="M325,351C325,305.743 403.415,269 500,269C596.585,269 675,305.743 675,351C675,365.931 666.465,379.936 651.557,392C640.124,401.252 624.943,409.364 606.99,415.876C577.4,426.609 540.28,433 500,433C459.711,433 422.583,426.607 392.99,415.869C375.046,409.358 359.872,401.249 348.443,392C333.535,379.936 325,365.931 325,351Z" style="fill:white;stroke:black;stroke-width:14.04px;"/>
</g>
<g transform="matrix(1.186992,0,0,1.186992,-93.495935,-96.300813)">
<path d="M325,433L325,351C325,365.931 333.535,379.936 348.443,392C359.872,401.249 375.046,409.358 392.99,415.869L392.99,497.869C375.046,491.358 359.872,483.249 348.443,474C333.535,461.936 325,447.931 325,433Z" style="fill:white;stroke:black;stroke-width:14.04px;"/>
</g>
<path d="M626.996,592.007C591.874,604.748 547.812,612.333 500,612.333L500,515C547.812,515 591.874,507.414 626.996,494.674C648.307,486.944 666.326,477.316 679.897,466.333C697.592,452.013 707.724,435.39 707.724,417.667L707.724,515C707.724,532.723 697.592,549.347 679.897,563.667C666.326,574.649 648.307,584.277 626.996,592.007Z" style="fill:white;stroke:black;stroke-width:16.67px;"/>
<g transform="matrix(1.186992,0,0,1.186992,-93.495935,-96.300813)">
<path d="M325,515L325,433C325,447.931 333.535,461.936 348.443,474C359.872,483.249 375.046,491.358 392.99,497.869L392.99,579.869C375.046,573.358 359.872,565.249 348.443,556C333.535,543.936 325,529.931 325,515Z" style="fill:white;stroke:black;stroke-width:14.04px;"/>
</g>
<g transform="matrix(1.186992,0,0,1.186992,-93.495935,-96.300813)">
<path d="M325,597L325,515C325,529.931 333.535,543.936 348.443,556C359.872,565.249 375.046,573.358 392.99,579.869L392.99,661.869C375.046,655.358 359.872,647.249 348.443,638C333.535,625.936 325,611.931 325,597Z" style="fill:white;stroke:black;stroke-width:14.04px;"/>
</g>
<g transform="matrix(1.186992,0,0,1.186992,-93.495935,-96.300813)">
<path d="M675,515L675,597C675,611.931 666.465,625.936 651.557,638C640.124,647.252 624.943,655.364 606.99,661.876L606.99,579.876C624.943,573.364 640.124,565.252 651.557,556C666.465,543.936 675,529.931 675,515Z" style="fill:white;stroke:black;stroke-width:14.04px;"/>
</g>
<g transform="matrix(1.186992,0,0,1.186992,-93.495935,-96.300813)">
<path d="M675,597L675,679C675,705.383 648.352,728.872 606.99,743.876L606.99,661.876C624.943,655.364 640.124,647.252 651.557,638C666.465,625.936 675,611.931 675,597Z" style="fill:white;stroke:black;stroke-width:14.04px;"/>
</g>
<path d="M372.98,786.665C323.898,768.856 292.276,740.978 292.276,709.667L292.276,612.333C292.276,630.057 302.408,646.68 320.103,661C333.669,671.979 351.68,681.603 372.98,689.332C408.107,702.078 452.177,709.667 500,709.667C547.812,709.667 591.874,702.081 626.996,689.341L626.996,786.674C591.874,799.414 547.812,807 500,807C452.177,807 408.107,799.411 372.98,786.665Z" style="fill:white;stroke:black;stroke-width:16.67px;"/>
</g>
<defs>
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(683,683,-683,683,46,187)"><stop offset="0" style="stop-color:rgb(108,108,108);stop-opacity:1"/><stop offset="0.17" style="stop-color:rgb(15,15,15);stop-opacity:1"/><stop offset="0.5" style="stop-color:black;stop-opacity:1"/><stop offset="0.83" style="stop-color:rgb(15,15,15);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(108,108,108);stop-opacity:1"/></linearGradient>
</defs>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 749 B

After

Width:  |  Height:  |  Size: 4.4 KiB

+5 -22
View File
@@ -59,18 +59,15 @@
const isMobile = /Mobi|Android|iPhone|iPad|iPod/i.test(userAgent); const isMobile = /Mobi|Android|iPhone|iPad|iPod/i.test(userAgent);
const keepLabel = downloadBtn.hasAttribute("data-download-label"); const keepLabel = downloadBtn.hasAttribute("data-download-label");
const releasesUrl = "https://github.com/AdrianBonpin/gridline/releases"; const releasesUrl = "https://git.ranio.xyz/adrianbonpin/gridline/releases";
const version = "0.7.10"; const version = "0.7.14";
if (isMobile) { if (isMobile) {
downloadBtn.href = releasesUrl; downloadBtn.href = releasesUrl;
if (!keepLabel) downloadBtn.textContent = "Get it on GitHub"; if (!keepLabel) downloadBtn.textContent = "Get it on Gitea";
} else if (isMac) { } else if (isMac) {
downloadBtn.href = `${releasesUrl}/download/v${version}/Gridline_${version}_aarch64.dmg`; downloadBtn.href = `${releasesUrl}/download/v${version}/Gridline_${version}_aarch64.dmg`;
if (!keepLabel) downloadBtn.textContent = "Download for macOS"; if (!keepLabel) downloadBtn.textContent = "Download for macOS";
if (downloadBtn.id === "download") {
document.getElementById("macos-hint")?.classList.remove("hidden");
}
} else if (isWin) { } else if (isWin) {
downloadBtn.href = `${releasesUrl}/download/v${version}/Gridline_${version}_x64-setup.exe`; downloadBtn.href = `${releasesUrl}/download/v${version}/Gridline_${version}_x64-setup.exe`;
if (!keepLabel) downloadBtn.textContent = "Download for Windows"; if (!keepLabel) downloadBtn.textContent = "Download for Windows";
@@ -83,18 +80,6 @@
} }
}); });
document.getElementById("copy-quarantine")?.addEventListener("click", async () => {
const command = "sudo xattr -dr com.apple.quarantine /Applications/Gridline.app";
try {
await navigator.clipboard.writeText(command);
const btn = document.getElementById("copy-quarantine");
if (!btn) return;
const original = btn.textContent;
btn.textContent = "Copied";
setTimeout(() => (btn.textContent = original), 1500);
} catch {}
});
// Nav scroll background // Nav scroll background
const nav = document.getElementById("nav"); const nav = document.getElementById("nav");
function onScroll() { function onScroll() {
@@ -141,24 +126,22 @@
const subtitle = hero.querySelector(".hero-animate > p:nth-of-type(2)"); const subtitle = hero.querySelector(".hero-animate > p:nth-of-type(2)");
const buttons = hero.querySelector(".hero-animate > div"); const buttons = hero.querySelector(".hero-animate > div");
const screenshot = hero.querySelector(".hero-screenshot"); const screenshot = hero.querySelector(".hero-screenshot");
const macosHint = hero.querySelector("#macos-hint");
if (reducedMotion) { if (reducedMotion) {
// Reduced motion: reveal the hero immediately (CSS hides it at first // Reduced motion: reveal the hero immediately (CSS hides it at first
// paint to avoid flicker, but the timeline below is skipped). Section // paint to avoid flicker, but the timeline below is skipped). Section
// reveals below still run through the IntersectionObserver. // reveals below still run through the IntersectionObserver.
anime.set([eyebrow, h1, subtitle, buttons, screenshot, macosHint], { opacity: 1, translateY: 0 }); anime.set([eyebrow, h1, subtitle, buttons, screenshot], { opacity: 1, translateY: 0 });
} else { } else {
const heroTl = anime.timeline({ easing: "easeOutExpo" }); const heroTl = anime.timeline({ easing: "easeOutExpo" });
anime.set([eyebrow, h1, subtitle, buttons, screenshot, macosHint], { opacity: 0, translateY: 20 }); anime.set([eyebrow, h1, subtitle, buttons, screenshot], { opacity: 0, translateY: 20 });
anime.set(screenshot, { opacity: 0, scale: 0.96, translateY: 0 }); anime.set(screenshot, { opacity: 0, scale: 0.96, translateY: 0 });
if (eyebrow) heroTl.add({ targets: eyebrow, opacity: [0, 1], translateY: [15, 0] }, 0); if (eyebrow) heroTl.add({ targets: eyebrow, opacity: [0, 1], translateY: [15, 0] }, 0);
if (h1) heroTl.add({ targets: h1, opacity: [0, 1], translateY: [30, 0], duration: 800 }, 100); if (h1) heroTl.add({ targets: h1, opacity: [0, 1], translateY: [30, 0], duration: 800 }, 100);
if (subtitle) heroTl.add({ targets: subtitle, opacity: [0, 1], translateY: [20, 0] }, 250); if (subtitle) heroTl.add({ targets: subtitle, opacity: [0, 1], translateY: [20, 0] }, 250);
if (buttons) heroTl.add({ targets: buttons, opacity: [0, 1], translateY: [20, 0] }, 350); if (buttons) heroTl.add({ targets: buttons, opacity: [0, 1], translateY: [20, 0] }, 350);
if (macosHint) heroTl.add({ targets: macosHint, opacity: [0, 1], translateY: [20, 0] }, 400);
if (screenshot) heroTl.add({ targets: screenshot, opacity: [0, 1], scale: [0.96, 1], duration: 900 }, 450); if (screenshot) heroTl.add({ targets: screenshot, opacity: [0, 1], scale: [0.96, 1], duration: 900 }, 450);
} }
} }
+6
View File
@@ -12,6 +12,12 @@
"sizes": "any", "sizes": "any",
"type": "image/svg+xml", "type": "image/svg+xml",
"purpose": "any" "purpose": "any"
},
{
"src": "/apple-touch-icon.png",
"sizes": "180x180",
"type": "image/png",
"purpose": "any"
} }
] ]
} }
+24
View File
@@ -5,6 +5,30 @@ export interface ChangelogEntry {
} }
export const changelogEntries: ChangelogEntry[] = [ export const changelogEntries: ChangelogEntry[] = [
{
version: "0.7.14",
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.",
],
},
{
version: "0.7.13",
date: "2026-09-04",
highlights: [
"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.",
],
},
{
version: "0.7.12",
date: "2026-09-01",
highlights: [
"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.",
"Full CI release pipeline for Windows — the self-hosted Windows runner now builds the .exe/.msi installers (bundled pg_dump/pg_restore/psql + MariaDB clients) and uploads them to the release alongside Linux.",
],
},
{ {
version: "0.7.10", version: "0.7.10",
date: "2026-08-13", date: "2026-08-13",
+9 -4
View File
@@ -7,12 +7,12 @@ export const faqItems: FaqItem[] = [
{ {
question: "Is Gridline really free for commercial use?", question: "Is Gridline really free for commercial use?",
answer: answer:
"Yes. Gridline is released under the Apache 2.0 license. You can use it for personal, commercial, or internal company work without paying or signing up.", "Yes. Gridline is Apache 2.0. You can use it for personal, commercial, or internal work without paying or signing up.",
}, },
{ {
question: "Which databases are supported?", question: "Which databases are supported?",
answer: answer:
"PostgreSQL and SQLite are fully supported today, including browsing, querying, editing, backups, and visual tools. MySQL and Redis connections are in active development.", "PostgreSQL and SQLite are fully supported today — browsing, querying, editing, backups, and the visual tools. MySQL and Redis connections are in active development.",
}, },
{ {
question: "How is this different from TablePlus, Beekeeper Studio, or DB Pro?", question: "How is this different from TablePlus, Beekeeper Studio, or DB Pro?",
@@ -22,10 +22,15 @@ export const faqItems: FaqItem[] = [
{ {
question: "Do you store my database credentials?", question: "Do you store my database credentials?",
answer: answer:
"Passwords are stored in your operating system keychain — macOS Keychain, Linux Secret Service, or Windows Credential Manager — never in Gridline's local SQLite database.", "No. Passwords live in your operating system keychain — macOS Keychain, Linux Secret Service, or Windows Credential Manager — never in Gridline's local SQLite database.",
}, },
{ {
question: "Can I self-host or contribute?", question: "How do you make money?",
answer:
"Gridline is free and isn't built around a paywall. If it saves you time, you can buy a coffee at ko-fi.com/adrianbonpin — it's entirely optional and never gates a feature. Otherwise, issues, docs, and contributions all help.",
},
{
question: "Can I contribute or self-host?",
answer: answer:
"Gridline is a desktop app, not a SaaS, so there's nothing to self-host. The repo is open for contributions: code, docs, translations, and issue reports are all welcome.", "Gridline is a desktop app, not a SaaS, so there's nothing to self-host. The repo is open for contributions: code, docs, translations, and issue reports are all welcome.",
}, },
+13 -6
View File
@@ -36,8 +36,13 @@ const isProd = import.meta.env.PROD;
<link rel="canonical" href={canonicalURL} /> <link rel="canonical" href={canonicalURL} />
<link rel="sitemap" href="/sitemap-index.xml" /> <link rel="sitemap" href="/sitemap-index.xml" />
<link rel="icon" type="image/svg+xml" href="/gridline-icon.svg" /> <link rel="icon" type="image/svg+xml" href="/gridline-icon.svg" />
<link rel="apple-touch-icon" href="/gridline-icon.svg" /> <link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="manifest" href="/site.webmanifest" /> <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="dns-prefetch" href="https://stat.ranio.xyz" />
<link rel="dns-prefetch" href="https://git.ranio.xyz" />
<meta property="og:title" content={title} /> <meta property="og:title" content={title} />
<meta property="og:description" content={description} /> <meta property="og:description" content={description} />
@@ -54,6 +59,7 @@ const isProd = import.meta.env.PROD;
<meta name="twitter:title" content={title} /> <meta name="twitter:title" content={title} />
<meta name="twitter:description" content={description} /> <meta name="twitter:description" content={description} />
<meta name="twitter:image" content={ogImageURL} /> <meta name="twitter:image" content={ogImageURL} />
<meta name="twitter:image:alt" content="Gridline — the open-source database GUI" />
<meta name="twitter:site" content="@AdrianBonpin" /> <meta name="twitter:site" content="@AdrianBonpin" />
<meta name="twitter:creator" content="@AdrianBonpin" /> <meta name="twitter:creator" content="@AdrianBonpin" />
@@ -98,7 +104,7 @@ const isProd = import.meta.env.PROD;
</a> </a>
<div class="hidden md:flex items-center gap-6 text-sm"> <div class="hidden md:flex items-center gap-6 text-sm">
<a href="/#features" class="nav-link">Features</a> <a href="/#features" class="nav-link">Features</a>
<a href="https://github.com/AdrianBonpin/gridline" target="_blank" rel="noopener" class="nav-link">GitHub</a> <a href="https://git.ranio.xyz/adrianbonpin/gridline" target="_blank" rel="noopener" class="nav-link">Gitea</a>
<a href="/#download" data-download data-download-label class="nav-link">Download</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"> <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> <span class="theme-icon-sun hidden">☀</span>
@@ -133,9 +139,9 @@ const isProd = import.meta.env.PROD;
<div> <div>
<h4 class="font-semibold text-sm mb-4">Project</h4> <h4 class="font-semibold text-sm mb-4">Project</h4>
<ul class="space-y-2 text-sm text-[var(--text-muted)]"> <ul class="space-y-2 text-sm text-[var(--text-muted)]">
<li><a href="https://github.com/AdrianBonpin/gridline" target="_blank" rel="noopener" class="footer-link">GitHub</a></li> <li><a href="https://git.ranio.xyz/adrianbonpin/gridline" target="_blank" rel="noopener" class="footer-link">Gitea</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://git.ranio.xyz/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> <li><a href="https://git.ranio.xyz/adrianbonpin/gridline/src/branch/prod/ROADMAP.md" target="_blank" rel="noopener" class="footer-link">Roadmap</a></li>
</ul> </ul>
</div> </div>
<div> <div>
@@ -143,12 +149,13 @@ const isProd = import.meta.env.PROD;
<ul class="space-y-2 text-sm text-[var(--text-muted)]"> <ul class="space-y-2 text-sm text-[var(--text-muted)]">
<li><a href="https://x.com/AdrianBonpin" target="_blank" rel="noopener" class="footer-link">X / Twitter</a></li> <li><a href="https://x.com/AdrianBonpin" target="_blank" rel="noopener" class="footer-link">X / Twitter</a></li>
<li><a href="mailto:adrianbonpin@gmail.com" class="footer-link">Email</a></li> <li><a href="mailto:adrianbonpin@gmail.com" class="footer-link">Email</a></li>
<li><a href="https://ko-fi.com/adrianbonpin" target="_blank" rel="noopener" class="footer-link">Support on Ko-fi ☕</a></li>
</ul> </ul>
</div> </div>
</div> </div>
<div class="mt-12 pt-8 border-t border-[var(--border)] flex flex-col md:flex-row justify-between gap-3 text-xs text-[var(--text-muted)] font-mono"> <div class="mt-12 pt-8 border-t border-[var(--border)] flex flex-col md:flex-row justify-between gap-3 text-xs text-[var(--text-muted)] font-mono">
<span>© 2026 Gridline contributors · Apache 2.0</span> <span>© 2026 Gridline contributors · Apache 2.0</span>
<span>Made with care for databases.</span> <span>Coffee-powered.</span>
</div> </div>
</div> </div>
</footer> </footer>
+18 -3
View File
@@ -6,6 +6,7 @@ import {
buildWebSiteJsonLd, buildWebSiteJsonLd,
buildBreadcrumbJsonLd, buildBreadcrumbJsonLd,
buildTitle, buildTitle,
buildProductJsonLd,
} from "./seo"; } from "./seo";
describe("seo builders", () => { describe("seo builders", () => {
@@ -19,7 +20,7 @@ describe("seo builders", () => {
applicationCategory: "DeveloperApplication", applicationCategory: "DeveloperApplication",
offersPrice: 0, offersPrice: 0,
offersCurrency: "USD", offersCurrency: "USD",
downloadUrl: "https://github.com/AdrianBonpin/gridline/releases", downloadUrl: "https://git.ranio.xyz/adrianbonpin/gridline/releases",
}); });
expect(ld["@type"]).toBe("SoftwareApplication"); expect(ld["@type"]).toBe("SoftwareApplication");
expect(ld.name).toBe("Gridline"); expect(ld.name).toBe("Gridline");
@@ -42,10 +43,10 @@ describe("seo builders", () => {
name: "Gridline", name: "Gridline",
url: "https://getgridline.app/", url: "https://getgridline.app/",
logo: "https://getgridline.app/gridline-icon.svg", logo: "https://getgridline.app/gridline-icon.svg",
sameAs: ["https://github.com/AdrianBonpin/gridline"], sameAs: ["https://git.ranio.xyz/adrianbonpin/gridline"],
}); });
expect(ld["@type"]).toBe("Organization"); expect(ld["@type"]).toBe("Organization");
expect(ld.sameAs).toContain("https://github.com/AdrianBonpin/gridline"); expect(ld.sameAs).toContain("https://git.ranio.xyz/adrianbonpin/gridline");
}); });
it("buildWebSiteJsonLd includes SearchAction", () => { it("buildWebSiteJsonLd includes SearchAction", () => {
@@ -70,4 +71,18 @@ describe("seo builders", () => {
it("buildTitle appends the brand", () => { it("buildTitle appends the brand", () => {
expect(buildTitle("PostgreSQL GUI")).toBe("PostgreSQL GUI | Gridline"); expect(buildTitle("PostgreSQL GUI")).toBe("PostgreSQL GUI | Gridline");
}); });
it("buildProductJsonLd returns valid Product schema", () => {
const ld = buildProductJsonLd({
name: "Gridline",
url: "https://getgridline.app/",
description: "Open-source database GUI",
image: "https://getgridline.app/og.png",
offersPrice: 0,
offersCurrency: "USD",
});
expect(ld["@type"]).toBe("Product");
expect(ld.name).toBe("Gridline");
expect((ld.offers as { price: number }).price).toBe(0);
});
}); });
+23
View File
@@ -108,3 +108,26 @@ export function buildBreadcrumbJsonLd(
export function buildTitle(primary: string, brand = "Gridline"): string { export function buildTitle(primary: string, brand = "Gridline"): string {
return `${primary} | ${brand}`; return `${primary} | ${brand}`;
} }
export function buildProductJsonLd(opts: {
name: string;
url: string;
description: string;
image: string;
offersPrice: number;
offersCurrency: string;
}): JsonLd {
return {
"@context": "https://schema.org",
"@type": "Product",
name: opts.name,
url: opts.url,
description: opts.description,
image: opts.image,
offers: {
"@type": "Offer",
price: opts.offersPrice,
priceCurrency: opts.offersCurrency,
},
};
}
+2 -2
View File
@@ -38,8 +38,8 @@ const jsonLd = [
<p class="mt-8 text-sm text-[var(--text-muted)]"> <p class="mt-8 text-sm text-[var(--text-muted)]">
Full release history on{" "} Full release history on{" "}
<a href="https://github.com/AdrianBonpin/gridline/releases" target="_blank" rel="noopener" class="text-[var(--primary)] hover:text-[var(--primary-hover)]"> <a href="https://git.ranio.xyz/adrianbonpin/gridline/releases" target="_blank" rel="noopener" class="text-[var(--primary)] hover:text-[var(--primary-hover)]">
GitHub Releases Gitea Releases
</a>. </a>.
</p> </p>
</div> </div>
+32 -18
View File
@@ -6,6 +6,8 @@ import {
buildFaqJsonLd, buildFaqJsonLd,
buildOrganizationJsonLd, buildOrganizationJsonLd,
buildWebSiteJsonLd, buildWebSiteJsonLd,
buildProductJsonLd,
buildBreadcrumbJsonLd,
} from "../lib/seo"; } from "../lib/seo";
import { Image } from "astro:assets"; import { Image } from "astro:assets";
import HeroDbViewer from "../components/hero/HeroDbViewer"; import HeroDbViewer from "../components/hero/HeroDbViewer";
@@ -13,7 +15,7 @@ import queryEditor from "../assets/screenshots/query-editor.png";
import erDiagram from "../assets/screenshots/er-diagram.png"; import erDiagram from "../assets/screenshots/er-diagram.png";
import home from "../assets/screenshots/home.png"; import home from "../assets/screenshots/home.png";
import dataGrid from "../assets/screenshots/data-grid.png"; import dataGrid from "../assets/screenshots/data-grid.png";
const version = "0.7.11"; const version = "0.7.14";
const site = "https://getgridline.app"; const site = "https://getgridline.app";
const jsonLd = [ const jsonLd = [
buildSoftwareApplicationJsonLd({ buildSoftwareApplicationJsonLd({
@@ -26,7 +28,7 @@ const jsonLd = [
applicationCategory: "DeveloperApplication", applicationCategory: "DeveloperApplication",
offersPrice: 0, offersPrice: 0,
offersCurrency: "USD", offersCurrency: "USD",
downloadUrl: "https://github.com/AdrianBonpin/gridline/releases", downloadUrl: "https://git.ranio.xyz/adrianbonpin/gridline/releases",
}), }),
buildFaqJsonLd(faqItems), buildFaqJsonLd(faqItems),
buildOrganizationJsonLd({ buildOrganizationJsonLd({
@@ -34,7 +36,7 @@ const jsonLd = [
url: `${site}/`, url: `${site}/`,
logo: `${site}/gridline-icon.svg`, logo: `${site}/gridline-icon.svg`,
sameAs: [ sameAs: [
"https://github.com/AdrianBonpin/gridline", "https://git.ranio.xyz/adrianbonpin/gridline",
"https://x.com/AdrianBonpin", "https://x.com/AdrianBonpin",
], ],
}), }),
@@ -42,9 +44,21 @@ const jsonLd = [
name: "Gridline", name: "Gridline",
url: `${site}/`, url: `${site}/`,
}), }),
buildProductJsonLd({
name: "Gridline",
url: `${site}/`,
description:
"The open-source database GUI for PostgreSQL, MySQL, SQLite, and Redis. No paywalls, no tab limits, fully open source.",
image: `${site}/og.png`,
offersPrice: 0,
offersCurrency: "USD",
}),
buildBreadcrumbJsonLd([
{ name: "Home", url: `${site}/` },
]),
]; ];
--- ---
<Layout title="Gridline — Open-Source Database GUI" jsonLd={jsonLd}> <Layout title="Gridline — Free Open-Source Database GUI" jsonLd={jsonLd}>
<script src="/landing.js" defer></script> <script src="/landing.js" defer></script>
<!-- Hero --> <!-- Hero -->
@@ -59,21 +73,13 @@ const jsonLd = [
Query, explore, sync, and edit PostgreSQL, MySQL, SQLite, and Redis — with no paywalls, no tab limits, no account required. Query, explore, sync, and edit PostgreSQL, MySQL, SQLite, and Redis — with no paywalls, no tab limits, no account required.
</p> </p>
<div class="mt-8 flex flex-wrap items-center justify-center gap-3"> <div class="mt-8 flex flex-wrap items-center justify-center gap-3">
<a id="download" href="https://github.com/AdrianBonpin/gridline/releases" class="primary-btn" data-download> <a id="download" href="https://git.ranio.xyz/adrianbonpin/gridline/releases" class="primary-btn" data-download>
Download Download
</a> </a>
<a href="https://github.com/AdrianBonpin/gridline" target="_blank" rel="noopener" class="ghost-btn"> <a href="https://git.ranio.xyz/adrianbonpin/gridline" target="_blank" rel="noopener" class="ghost-btn">
View on GitHub View on Gitea
</a> </a>
</div> </div>
<div id="macos-hint" class="hidden max-w-lg mx-auto w-full rounded-xl border border-[var(--border)] bg-[var(--surface)] p-3 text-left mt-4">
<p class="text-xs text-[var(--text-muted)] mb-2">First launch on macOS? Run this in Terminal:</p>
<div class="flex items-center justify-between gap-3 rounded-lg bg-[var(--bg)] border border-[var(--border)] px-3 py-2">
<code class="font-mono text-xs truncate">sudo xattr -dr com.apple.quarantine /Applications/Gridline.app</code>
<button id="copy-quarantine" class="text-xs font-medium text-[var(--primary)] hover:text-[var(--primary-hover)] transition-colors shrink-0" aria-label="Copy command">Copy</button>
</div>
</div>
</div> </div>
<div class="hero-screenshot mt-14 md:mt-20 mx-auto max-w-6xl"> <div class="hero-screenshot mt-14 md:mt-20 mx-auto max-w-6xl">
@@ -170,13 +176,21 @@ const jsonLd = [
<section id="oss" class="section-animate py-20 md:py-28 text-center"> <section id="oss" class="section-animate py-20 md:py-28 text-center">
<div class="max-w-[800px] mx-auto px-6"> <div class="max-w-[800px] mx-auto px-6">
<p class="font-mono text-xs text-[var(--accent)] uppercase tracking-[0.15em] mb-3">Open Source</p> <p class="font-mono text-xs text-[var(--accent)] uppercase tracking-[0.15em] mb-3">Open Source</p>
<h2 class="font-serif text-3xl md:text-5xl mb-5">Built in public.<br />No VC roadmap, no rug pulls.</h2> <h2 class="font-serif text-3xl md:text-5xl mb-5">Built in public,<br />no VC roadmap attached.</h2>
<p class="text-[var(--text-muted)] mb-8 max-w-md mx-auto"> <p class="text-[var(--text-muted)] mb-8 max-w-md mx-auto">
Gridline is Apache 2.0 and developed openly. Contributions, issues, and feature requests are welcome. Gridline is Apache 2.0 and developed openly. Contributions, issues, and feature requests are welcome.
</p> </p>
<a href="https://github.com/AdrianBonpin/gridline" target="_blank" rel="noopener" class="ghost-btn"> <div class="flex flex-wrap items-center justify-center gap-3">
Star on GitHub <a href="https://git.ranio.xyz/adrianbonpin/gridline" target="_blank" rel="noopener" class="ghost-btn">
Star on Gitea
</a> </a>
<a href="https://ko-fi.com/adrianbonpin" target="_blank" rel="noopener" class="ghost-btn">
☕ Support on Ko-fi
</a>
</div>
<p class="mt-6 text-xs text-[var(--text-muted)] max-w-sm mx-auto">
Gridline stays free — no feature is ever behind a payment. Ko-fi is only for those who want to say thanks.
</p>
</div> </div>
</section> </section>