9 Commits
Author SHA1 Message Date
adrianbonpin 9089f313f8 fix(keychain) + release: macOS keychain ACL self-repair, bump 0.8.1 (#37)
Release / linux (push) Successful in 22m30s
Release / windows (push) Canceled after 0s
* fix(keychain): repair stale macOS keychain ACLs before reads

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

- release.yml: re-enable as the active path and wire up Developer-ID signing
  + notarization for the macOS jobs. The Apple steps are guarded on the
  secrets being present, so the workflow stays green (ad-hoc signed) until
  they are configured.
- release.yml: re-sign the bundled PostgreSQL/MariaDB client binaries with
  the Developer ID identity + hardened runtime + timestamp — Apple rejects
  ad-hoc-signed nested executables during notarization.
- release.yml: move the Homebrew cask bump to the `release: published` event.
  It previously ran on tag push, but the releases/tags API does not serve
  drafts, so it could never download the DMGs. It now also skips cleanly
  when HOMEBREW_TAP_TOKEN is unset rather than failing the workflow.
- release.yml: drop the stale header note claiming this is not the active
  release path.
- README + cask template: point downloads, issues, releases and the Homebrew
  tap back at github.com/AdrianBonpin.
2026-09-20 15:55:59 +08:00
36 changed files with 1214 additions and 280 deletions
+210 -15
View File
@@ -7,22 +7,65 @@ 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 is Developer-ID signed + notarized via # SIGNING STATUS: this is the ACTIVE release path. macOS is Developer-ID
# scripts/release-mac.sh (built manually on Adrian's machine). NOTE: this # signed + notarized in CI when the Apple secrets below are configured (see
# GitHub workflow is NOT the active release path — Gitea Actions uses # README "macOS signing & notarization"). When the secrets are absent the mac
# .gitea/workflows/release.yml (Linux/Windows only). Linux/Windows installers # jobs still build, but ad-hoc signed / unnotarized. Linux/Windows installers
# remain unsigned. # remain unsigned.
#
# Required secrets for signed + notarized macOS builds:
# APPLE_CERTIFICATE base64 of the Developer ID Application .p12
# APPLE_CERTIFICATE_PASSWORD password for that .p12
# APPLE_SIGNING_IDENTITY "Developer ID Application: Name (TEAMID)"
# KEYCHAIN_PASSWORD any throwaway password for the temp keychain
# APPLE_API_KEY App Store Connect Key ID
# APPLE_API_ISSUER App Store Connect Issuer ID
# APPLE_API_KEY_CONTENT contents of the AuthKey_<KeyID>.p8
#
# The cask bump runs only after the draft release is PUBLISHED (the GitHub
# releases/tags API does not serve drafts), so it is wired to the `release:
# published` event rather than the tag push. It pushes with a write-enabled
# deploy key scoped to ONLY the tap repo (HOMEBREW_TAP_DEPLOY_KEY), so no
# broad personal access token is stored — the default GITHUB_TOKEN cannot
# push to a different repository.
on: on:
push: push:
tags: tags:
- 'v*' - 'v*'
release:
types: [published]
# Rebuild an EXISTING tag without moving it — e.g. to re-run a release after
# a workflow fix. Dispatch from `prod` and pass the tag to build; the tag
# itself is never rewritten, so the release ref stays truthful.
workflow_dispatch:
inputs:
tag:
description: 'Tag to build or bump the cask for, e.g. v0.8.0'
required: true
bump_cask:
description: 'Only bump the Homebrew cask for `tag` (skip the build)'
type: boolean
default: false
permissions: permissions:
contents: write contents: write
env:
# workflow_dispatch passes the tag as an input; push/release events derive it
# from the ref. Drives the checkout ref and tauri-action's tagName.
RELEASE_TAG: ${{ github.event.inputs.tag || github.ref_name }}
jobs: jobs:
publish: publish:
# Build + draft release happen on a tag push or a dispatch that isn't a
# cask-only run. The `release: published` event exists only to trigger the
# cask bump below.
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.bump_cask != true)
env:
# Empty when the secret is unset — the tool steps below test for a
# non-empty value before re-signing.
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
@@ -39,6 +82,69 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
ref: ${{ env.RELEASE_TAG }}
# ---- Apple signing setup (macOS only, skipped when secrets absent) ---
- name: Detect Apple signing secrets
id: apple
shell: bash
run: |
if [ -n "${{ secrets.APPLE_CERTIFICATE }}" ]; then
echo "enabled=true" >> "$GITHUB_OUTPUT"
else
echo "enabled=false" >> "$GITHUB_OUTPUT"
echo "::notice::APPLE_CERTIFICATE not set — macOS build will be ad-hoc signed and NOT notarized."
fi
# The bundled pg/mariadb tools are re-signed in their own steps below, so
# the identity must be in a keychain BEFORE those steps run. tauri-action
# is deliberately NOT given APPLE_CERTIFICATE (only the identity), so it
# does not create a second keychain.
- name: Import Apple Developer ID certificate
if: startsWith(matrix.platform, 'macos') && steps.apple.outputs.enabled == 'true'
shell: bash
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
run: |
set -euo pipefail
KEYCHAIN="gridline-signing.keychain-db"
echo "$APPLE_CERTIFICATE" | base64 --decode > "$RUNNER_TEMP/cert.p12"
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
security set-keychain-settings -lut 21600 "$KEYCHAIN"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
security import "$RUNNER_TEMP/cert.p12" -k "$KEYCHAIN" \
-P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign
security set-key-partition-list -S apple-tool:,apple:,codesign: \
-s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN" >/dev/null
EXISTING="$(security list-keychain -d user | tr -d ' \"')"
security list-keychain -d user -s "$KEYCHAIN" $EXISTING
rm -f "$RUNNER_TEMP/cert.p12"
security find-identity -v -p codesigning
- name: Prepare notarization credentials
if: startsWith(matrix.platform, 'macos') && steps.apple.outputs.enabled == 'true'
shell: bash
env:
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
APPLE_API_KEY_CONTENT: ${{ secrets.APPLE_API_KEY_CONTENT }}
run: |
set -euo pipefail
KEY_PATH="$RUNNER_TEMP/private_keys/AuthKey_${APPLE_API_KEY}.p8"
mkdir -p "$(dirname "$KEY_PATH")"
# Apple's PEM parser expects a terminating newline, which
# `gh secret set` strips from the stored value — so write one
# explicitly. A doubled trailing newline is harmless to PEM parsers,
# but a missing one can make notarization fail to load the key.
printf '%s\n' "$APPLE_API_KEY_CONTENT" > "$KEY_PATH"
{
echo "APPLE_API_KEY=${APPLE_API_KEY}"
echo "APPLE_API_ISSUER=${APPLE_API_ISSUER}"
echo "APPLE_API_KEY_PATH=${KEY_PATH}"
} >> "$GITHUB_ENV"
- name: Install Linux dependencies - name: Install Linux dependencies
if: matrix.platform == 'ubuntu-22.04' if: matrix.platform == 'ubuntu-22.04'
@@ -131,6 +237,16 @@ jobs:
libpq=$(otool -L "$OUT/$b" | awk '/libpq/ {print $1; exit}') libpq=$(otool -L "$OUT/$b" | awk '/libpq/ {print $1; exit}')
install_name_tool -change "$libpq" "@loader_path/libpq.5.dylib" "$OUT/$b" install_name_tool -change "$libpq" "@loader_path/libpq.5.dylib" "$OUT/$b"
done done
# install_name_tool invalidates the linker signature. Re-sign with
# the Developer ID identity + hardened runtime + secure timestamp:
# Apple rejects ad-hoc-signed nested executables at notarization.
# No-op until the Apple secrets are configured.
if [ -n "${APPLE_SIGNING_IDENTITY:-}" ]; then
for f in "$OUT"/pg_dump "$OUT"/pg_restore "$OUT"/psql "$OUT"/libpq.5.dylib; do
codesign --force --options runtime --timestamp \
--sign "$APPLE_SIGNING_IDENTITY" "$f"
done
fi
;; ;;
windows-latest) windows-latest)
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"
@@ -263,7 +379,12 @@ jobs:
if [ ! -f "$OUT/$base" ]; then cp "$lib" "$OUT/$base" 2>/dev/null || true; fi if [ ! -f "$OUT/$base" ]; then cp "$lib" "$OUT/$base" 2>/dev/null || true; fi
install_name_tool -change "$lib" "@loader_path/$base" "$dylib" 2>/dev/null || true 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 if [ -n "${APPLE_SIGNING_IDENTITY:-}" ]; then
codesign --force --options runtime --timestamp \
--sign "$APPLE_SIGNING_IDENTITY" "$dylib"
else
codesign --force --sign - "$dylib" 2>/dev/null || true
fi
done done
done done
;; ;;
@@ -309,8 +430,8 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with: with:
projectPath: desktop projectPath: desktop
tagName: ${{ github.ref_name }} tagName: ${{ env.RELEASE_TAG }}
releaseName: 'Gridline ${{ github.ref_name }}' releaseName: 'Gridline ${{ env.RELEASE_TAG }}'
releaseDraft: true releaseDraft: true
args: ${{ matrix.args }} args: ${{ matrix.args }}
# Assets use tauri-action's default versioned naming (e.g. # Assets use tauri-action's default versioned naming (e.g.
@@ -318,44 +439,118 @@ jobs:
# README download tables link to them statically per release — # README download tables link to them statically per release —
# remember to update both README tables when cutting a new version. # remember to update both README tables when cutting a new version.
# tauri-action notarizes + staples the .app but NOT the .dmg wrapping it,
# so Gatekeeper rejects the downloaded DMG as "Unnotarized Developer ID"
# even though the app inside is fine. Submit the DMG to the same notary
# service, staple the ticket, and replace the uploaded asset.
#
# Assets are replaced via the release ID rather than `gh release upload`:
# the GitHub releases/tags endpoint returns 404 for drafts, so uploading
# by tag cannot work while the release is still a draft.
- name: Notarize and staple the DMGs
if: startsWith(matrix.platform, 'macos') && steps.apple.outputs.enabled == 'true'
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
shopt -s nullglob
dmgs=(desktop/src-tauri/target/*/release/bundle/dmg/*.dmg)
if [ ${#dmgs[@]} -eq 0 ]; then
echo "no DMG produced" >&2
exit 1
fi
REL_ID=$(gh api "repos/${GITHUB_REPOSITORY}/releases?per_page=100" \
--jq ".[] | select(.tag_name == \"${RELEASE_TAG}\") | .id" | head -1)
if [ -z "$REL_ID" ]; then
echo "could not resolve a release id for ${RELEASE_TAG}" >&2
exit 1
fi
for dmg in "${dmgs[@]}"; do
name=$(basename "$dmg")
echo ">> notarizing $name (this can take a few minutes)"
xcrun notarytool submit "$dmg" \
--key "$APPLE_API_KEY_PATH" \
--key-id "$APPLE_API_KEY" \
--issuer "$APPLE_API_ISSUER" \
--wait
xcrun stapler staple "$dmg"
xcrun stapler validate "$dmg"
# Replace the copy tauri-action uploaded, which carries no DMG ticket.
old=$(gh api "repos/${GITHUB_REPOSITORY}/releases/${REL_ID}/assets" \
--jq ".[] | select(.name == \"${name}\") | .id" | head -1)
if [ -n "$old" ]; then
gh api -X DELETE "repos/${GITHUB_REPOSITORY}/releases/assets/${old}"
fi
echo ">> re-uploading stapled $name"
curl -fsS -X POST \
-H "Authorization: token ${GH_TOKEN}" \
-H "Content-Type: application/octet-stream" \
--data-binary "@${dmg}" \
"https://uploads.github.com/repos/${GITHUB_REPOSITORY}/releases/${REL_ID}/assets?name=${name}"
echo
done
echo "all DMGs notarized and stapled"
# After the installers are uploaded, refresh the Homebrew cask in the # After the installers are uploaded, refresh the Homebrew cask in the
# AdrianBonpin/homebrew-gridline tap: hash the freshly-published DMGs, # AdrianBonpin/homebrew-gridline tap: hash the freshly-published DMGs,
# render Casks/gridline.rb from the template, and push it. Users then get # render Casks/gridline.rb from the template, and push it. Users then get
# the new version via `brew upgrade --cask gridline`. # the new version via `brew upgrade --cask gridline`.
# #
# Requires the HOMEBREW_TAP_TOKEN secret (a PAT with `repo` scope on the tap # Requires the HOMEBREW_TAP_DEPLOY_KEY secret — a write-enabled SSH deploy
# repo — the default GITHUB_TOKEN cannot push to a different repo). The job # key on the tap repo (the default GITHUB_TOKEN cannot push to a different
# no-ops until that secret is set, so releases stay green before this is # repository, and a deploy key avoids storing a broad PAT). The job skips
# cleanly until that secret is set, so releases stay green before this is
# configured. # configured.
bump-cask: bump-cask:
needs: publish # Normally fires on `release: published`, which requires the DMGs to be
# downloadable — the GitHub releases/tags API returns 404 for drafts, so
# this cannot run on the tag push. NOTE: a release whose DRAFT was created
# by tauri-action's GITHUB_TOKEN does not reliably emit a `release` event,
# so `workflow_dispatch` with bump_cask=true is the dependable path.
if: github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && inputs.bump_cask == true)
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: read contents: read
steps: steps:
- name: Check for tap deploy key
id: tap
shell: bash
run: |
if [ -n "${{ secrets.HOMEBREW_TAP_DEPLOY_KEY }}" ]; then
echo "enabled=true" >> "$GITHUB_OUTPUT"
else
echo "enabled=false" >> "$GITHUB_OUTPUT"
echo "::notice::HOMEBREW_TAP_DEPLOY_KEY not set - skipping Homebrew cask bump."
fi
- name: Checkout - name: Checkout
if: steps.tap.outputs.enabled == 'true'
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Resolve version and DMG checksums - name: Resolve version and DMG checksums
id: meta id: meta
if: steps.tap.outputs.enabled == 'true'
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: | run: |
VERSION="${GITHUB_REF_NAME#v}" VERSION="${RELEASE_TAG#v}"
gh release download "$GITHUB_REF_NAME" -p "Gridline_${VERSION}_aarch64.dmg" -O /tmp/arm.dmg gh release download "$RELEASE_TAG" -p "Gridline_${VERSION}_aarch64.dmg" -O /tmp/arm.dmg
gh release download "$GITHUB_REF_NAME" -p "Gridline_${VERSION}_x64.dmg" -O /tmp/intel.dmg gh release download "$RELEASE_TAG" -p "Gridline_${VERSION}_x64.dmg" -O /tmp/intel.dmg
echo "version=$VERSION" >> "$GITHUB_OUTPUT" echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "sha_arm=$(shasum -a 256 /tmp/arm.dmg | cut -d' ' -f1)" >> "$GITHUB_OUTPUT" echo "sha_arm=$(shasum -a 256 /tmp/arm.dmg | cut -d' ' -f1)" >> "$GITHUB_OUTPUT"
echo "sha_intel=$(shasum -a 256 /tmp/intel.dmg | cut -d' ' -f1)" >> "$GITHUB_OUTPUT" echo "sha_intel=$(shasum -a 256 /tmp/intel.dmg | cut -d' ' -f1)" >> "$GITHUB_OUTPUT"
- name: Checkout tap repo - name: Checkout tap repo
if: steps.tap.outputs.enabled == 'true'
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
repository: AdrianBonpin/homebrew-gridline repository: AdrianBonpin/homebrew-gridline
token: ${{ secrets.HOMEBREW_TAP_TOKEN }} ssh-key: ${{ secrets.HOMEBREW_TAP_DEPLOY_KEY }}
path: tap path: tap
- name: Render and push cask - name: Render and push cask
if: steps.tap.outputs.enabled == 'true'
env: env:
VERSION: ${{ steps.meta.outputs.version }} VERSION: ${{ steps.meta.outputs.version }}
SHA_ARM: ${{ steps.meta.outputs.sha_arm }} SHA_ARM: ${{ steps.meta.outputs.sha_arm }}
+9 -7
View File
@@ -167,17 +167,19 @@ cargo test # Rust tests (from desktop/sr
### Branch & PR workflow ### Branch & PR workflow
**GitHub is the only remote that matters.** The canonical repo is `github` → [AdrianBonpin/gridline](https://github.com/AdrianBonpin/gridline) (default branch `prod`, issues, PRs, Actions, releases). The `origin` remote (`git.ranio.xyz/adrianbonpin/gridline`) is a **just-in-case mirror** — do not push branches, open PRs, or publish releases/tags there. If you must mirror something manually, push to `github` first.
**Never commit or push directly to `prod`** — `prod` is protected (branch rules require a pull request). Always: **Never commit or push directly to `prod`** — `prod` is protected (branch rules require a pull request). Always:
1. Branch out first — `git checkout -b feat/<short-name>` (or `fix/…`, `docs/…`) from `prod` 1. Branch out first — `git checkout -b feat/<short-name>` (or `fix/…`, `docs/…`) from `prod`
2. Commit and push the feature branch 2. Commit and push the feature branch to `github` (`git push -u github <branch>`)
3. Open a PR into `prod` and merge it there 3. Open a PR into `prod` on GitHub (`gh pr create --repo AdrianBonpin/gridline --base prod`) and merge it there
Direct pushes to `prod` will be bypassed only in emergencies; prefer the PR path so changes land through the PR gate. Direct pushes to `prod` will be bypassed only in emergencies; prefer the PR path so changes land through the PR gate.
### Releases ### Releases
Cut a release by tagging the **`prod`** branch once the PR is merged — `git tag vN.M.N && git push origin vN.M.N`. GitHub Actions (`release.yml`) builds installers for macOS (Apple Silicon + Intel), Windows, and Linux and opens a **draft** release (review + publish on GitHub). **Releases are GitHub-only.** Tag `prod` and push the tag to the `github` remote — `git tag vN.M.N && git push github vN.M.N`. GitHub Actions (`release.yml`) builds installers for macOS (Apple Silicon + Intel), Windows, and Linux and opens a **draft** release (review + publish on GitHub). Never push release tags to `origin`/Gitea; it exists only as a fallback copy of the code.
**Before tagging**, keep everything in sync: **Before tagging**, keep everything in sync:
- Version number across `desktop/package.json`, `desktop/src-tauri/Cargo.toml`, `desktop/src-tauri/tauri.conf.json`, and `www/src/pages/index.astro` (the landing page version string) - Version number across `desktop/package.json`, `desktop/src-tauri/Cargo.toml`, `desktop/src-tauri/tauri.conf.json`, and `www/src/pages/index.astro` (the landing page version string)
@@ -239,18 +241,18 @@ Planned work is prioritized in the [Project Roadmap](./ROADMAP.md) (source of tr
| :--- | :---: | :--- | | :--- | :---: | :--- |
| Connections CRUD (PostgreSQL, MySQL, SQLite, Redis, MariaDB) | ✅ | Full create/read/update/delete with form validation | | Connections CRUD (PostgreSQL, MySQL, SQLite, Redis, MariaDB) | ✅ | Full create/read/update/delete with form validation |
| MariaDB connections | ✅ | First-class db_type on the MySQL driver path (v0.8.0) | | MariaDB connections | ✅ | First-class db_type on the MySQL driver path (v0.8.0) |
| New Connection screen (revamped) | ✅ | Two-stage entry → configured flow: Connection URI + 6-card provider grid (PostgreSQL / MySQL / SQLite / Redis / Supabase / NeonDB) with an OR divider → expands into label + tags/env/folder + General|SSH·SSL tabs. Supabase & NeonDB are managed-PostgreSQL presets (persist as `postgresql`) with in-app setup guides + SSL hints; PlanetScale is a managed-MySQL (Vitess) preset (persists as `mysql`) with a setup guide + `.psdb.cloud` host detection (v0.8.0); SQLite swaps the URI field for a file-path + Browse input (v0.7.0) | | New Connection screen (revamped) | ✅ | Two-stage entry → configured flow: Connection URI + 6-card provider grid (PostgreSQL / MySQL / SQLite / Redis / Supabase / NeonDB) with an OR divider → expands into label + tags/env/folder + General|SSH·SSL tabs. **Pasting a URI fills every setting it can** — type, host, port, user, password, database, `?sslmode=`/`ssl-mode=` (Neon/Supabase → Require, MySQL `VERIFY_IDENTITY` → verify-full) — and offers a URL-derived label (database name, else host) as a placeholder that Save/Test uses when the field is left blank; Supabase & NeonDB are managed-PostgreSQL presets (persist as `postgresql`) with in-app setup guides + SSL hints, PlanetScale is a managed-MySQL (Vitess) preset (persists as `mysql`) with a setup guide + `.psdb.cloud` host detection (v0.8.0); presets pre-select their TLS mode (Supabase/Neon Require, PlanetScale Verify Full); SQLite swaps the URI field for a file-path + Browse input (v0.7.0)
| Connection testing (all DB types) | ✅ | PostgreSQL, MySQL, SQLite, Redis all testable | | Connection testing (all DB types) | ✅ | PostgreSQL, MySQL, SQLite, Redis all testable |
| DB Viewer: PostgreSQL browse + query | ✅ | Schemas, tables, paginated data, FK preview, JSON viewer | | DB Viewer: PostgreSQL browse + query | ✅ | Schemas, tables, paginated data, FK preview, JSON viewer |
| 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; macOS items get an explicit `SecAccess` ACL pinning them to the app's code-signature requirement on every save (`keychain_acl.rs`) — items survive re-signs/upgrades without keychain permission prompts | | Password storage in OS keychain | ✅ | macOS Keychain, Linux Secret Service, Windows Credential Manager. macOS items get an explicit `SecAccess` ACL pinning them to the app's code-signature requirement on every save (`keychain_acl.rs`) — items survive re-signs/upgrades without keychain permission prompts. **Self-healing ACLs**: the ACL is also (silently) verified *before* reads and repaired when this app isn't in it, so items pinned to an older binary path (pre-monorepo `src-tauri/`, a reinstalled app, an ad-hoc dev build) cost one authorization instead of asking for the keychain password on every access, per secret |
| Enable keychain toggle | ✅ | Default ON (opt-out); OFF = don't persist the DB password (session-only, re-prompt on connect) + purge existing keychain entry; SSH secrets stay keychain-only (v0.7.6) | | 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 |
| SSL/TLS config UI | ✅ | Mode (disable/require/verify-ca/verify-full), cert paths | | SSL/TLS config UI | ✅ | Mode (disable/require/verify-ca/verify-full), cert paths. Selections (mode + CA/client cert/key) are persisted on create **and** edit — the form data type carries the `ssl_*` fields and the submit handlers pass them through (previously the SSH/SSL tab's values were dropped, so connections saved with TLS off and managed providers rejected them) |
| SSL/TLS runtime | ✅ | PostgreSQL all modes via rustls (disable/require/verify-ca/verify-full; **v1: `verify-ca` behaves as `verify-full`** — documented refinement), client certs PKCS#1/PKCS#8/EC, encrypted client keys rejected; MySQL test path maps modes (verify-full → VerifyIdentity) | | SSL/TLS runtime | ✅ | PostgreSQL all modes via rustls (disable/require/verify-ca/verify-full; **v1: `verify-ca` behaves as `verify-full`** — documented refinement), client certs PKCS#1/PKCS#8/EC, encrypted client keys rejected; MySQL test path maps modes (verify-full → VerifyIdentity). Managed hosts (`*.neon.tech` / `*.supabase.co` / `*.psdb.cloud`) default to `require` when the connection has no `ssl_mode` (`db::tls::resolve_ssl_mode`), so pre-fix saved connections still connect; an explicit mode always wins |
| Roles & grants (PG) | ✅ | Create/edit/drop PostgreSQL roles and GRANT/REVOKE privileges per object class from a UI (v0.7.7) | | Roles & grants (PG) | ✅ | Create/edit/drop PostgreSQL roles and GRANT/REVOKE privileges per object class from a UI (v0.7.7) |
### Home Screen & Organization ### Home Screen & Organization
+43 -35
View File
@@ -18,34 +18,34 @@
<a href="https://react.dev/"><img src="https://img.shields.io/badge/react-%2320232a.svg?style=for-the-badge&logo=react&logoColor=%2361DAFB" alt="React" /></a> <a href="https://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://git.ranio.xyz/adrianbonpin/gridline"><img src="https://img.shields.io/badge/Open_Source-Gitea-4183C4.svg?style=for-the-badge" alt="Open source on Gitea" /></a> <a href="https://github.com/AdrianBonpin/gridline"><img src="https://img.shields.io/badge/Open_Source-GitHub-181717.svg?style=for-the-badge" alt="Open source on GitHub" /></a>
</p> </p>
<p> <p>
<a href="https://git.ranio.xyz/adrianbonpin/gridline/releases"><img src="https://img.shields.io/badge/Download_Latest_Release-2ea44f?style=for-the-badge" alt="Download Latest" /></a> <a href="https://github.com/AdrianBonpin/gridline/releases"><img src="https://img.shields.io/badge/Download_Latest_Release-2ea44f?style=for-the-badge" alt="Download Latest" /></a>
<a href="https://git.ranio.xyz/adrianbonpin/gridline/issues/new"><img src="https://img.shields.io/badge/Open_an_Issue-%23E4405F.svg?style=for-the-badge" alt="Open an Issue" /></a> <a href="https://github.com/AdrianBonpin/gridline/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.8.0**): Grab the installer for your OS from the [latest release](https://github.com/AdrianBonpin/gridline/releases/latest) — the links below point at the current release (**v0.8.1**):
| OS | Architecture | Download | | OS | Architecture | Download |
| :--------------------------- | :--------------------------- | :----------------------------------------------------------------------------------------------------------------------------------- | | :--------------------------- | :--------------------------- | :----------------------------------------------------------------------------------------------------------------------------------- |
| **macOS** | Apple Silicon (M1/M2/M3/M4…) | [Gridline_0.8.0_aarch64.dmg](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.8.0/Gridline_0.8.0_aarch64.dmg) | | **macOS** | Apple Silicon (M1/M2/M3/M4…) | [Gridline_0.8.1_aarch64.dmg](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.1/Gridline_0.8.1_aarch64.dmg) |
| **macOS** | Intel | [Gridline_0.8.0_x64.dmg](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.8.0/Gridline_0.8.0_x64.dmg) | | **macOS** | Intel | [Gridline_0.8.1_x64.dmg](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.1/Gridline_0.8.1_x64.dmg) |
| **Windows** | x64 | [Gridline_0.8.0_x64-setup.exe](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.8.0/Gridline_0.8.0_x64-setup.exe) | | **Windows** | x64 | [Gridline_0.8.1_x64-setup.exe](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.1/Gridline_0.8.1_x64-setup.exe) |
| **Debian / Ubuntu** | amd64 | [Gridline_0.8.0_amd64.deb](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.8.0/Gridline_0.8.0_amd64.deb) | | **Debian / Ubuntu** | amd64 | [Gridline_0.8.1_amd64.deb](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.1/Gridline_0.8.1_amd64.deb) |
| **Fedora / RHEL / openSUSE** | x86_64 | [Gridline-0.8.0-1.x86_64.rpm](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.8.0/Gridline-0.8.0-1.x86_64.rpm) | | **Fedora / RHEL / openSUSE** | x86_64 | [Gridline-0.8.1-1.x86_64.rpm](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.1/Gridline-0.8.1-1.x86_64.rpm) |
| **Other Linux** | amd64 | [Gridline_0.8.0_amd64.AppImage](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.8.0/Gridline_0.8.0_amd64.AppImage) | | **Other Linux** | amd64 | [Gridline_0.8.1_amd64.AppImage](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.1/Gridline_0.8.1_amd64.AppImage) |
**macOS via Homebrew** (recommended for Mac users): **macOS via Homebrew** (recommended for Mac users):
```bash ```bash
# one-time setup (the tap is hosted on Gitea, not GitHub): # one-time setup (the tap is hosted on GitHub):
brew tap AdrianBonpin/gridline https://git.ranio.xyz/adrianbonpin/homebrew-gridline.git brew tap AdrianBonpin/gridline https://github.com/AdrianBonpin/homebrew-gridline.git
brew trust adrianbonpin/gridline brew trust adrianbonpin/gridline
# install / upgrade: # install / upgrade:
@@ -57,7 +57,7 @@ The cask installs the app directly. The DMGs are Developer-ID signed and notariz
> **macOS first launch:** Gridline is Developer-ID signed and notarized, so macOS opens it without a Gatekeeper prompt. Not sure if your Mac is Intel or Apple Silicon? See [Which file should I download?](#which-file-should-i-download) below. > **macOS first launch:** Gridline is Developer-ID signed and notarized, so macOS opens it without a Gatekeeper prompt. Not sure if your Mac is Intel or Apple Silicon? See [Which file should I download?](#which-file-should-i-download) below.
<!-- <!--
MAINTENANCE: These links are STATIC (versioned) — they point at the v0.8.0 MAINTENANCE: These links are STATIC (versioned) — they point at the v0.8.1
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:
@@ -102,9 +102,11 @@ 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.8.1** — **Neon / Supabase / PlanetScale connections fixed**: a pasted connection string's `?sslmode=` is now carried into the saved connection (it was silently dropped, so TLS stayed off and Neon replied *"connection is insecure (try using `sslmode=require`)"*); the SSH / SSL tab's mode and certificate paths persist on create *and* edit, managed presets pre-select their TLS mode, and `*.neon.tech` / `*.supabase.co` / `*.psdb.cloud` hosts default to `require` when no mode is stored — so previously-saved connections keep working. Pasting a URI also fills every setting it can and suggests a name, so a URL can be tested and saved with no retyping. **macOS keychain prompts self-heal**: items whose ACL still points at an older binary path made macOS ask for the keychain password on *every* access, once per secret — the app now checks the ACL (silently, before reading) and repairs it when needed, so you authorize a secret once. Version bump.
- **v0.7.15** — **Support channel on the landing page**: optional coffee via [Ko-fi](https://ko-fi.com/adrianbonpin), never gates a feature. Landing copy refreshed (em dashes dropped, OSS section simplified, funding FAQ); version bump. - **v0.7.15** — **Support channel on the landing page**: optional coffee via [Ko-fi](https://ko-fi.com/adrianbonpin), never gates a feature. Landing copy refreshed (em dashes dropped, OSS section simplified, funding FAQ); version bump.
- **v0.7.14** — **No more repeated macOS keychain prompts**: saved connection passwords and SSH secrets are now ACL-pinned to the app's code signature on every save (`keychain_acl`), so app upgrades never re-trigger the macOS keychain access dialog (users upgrading from pre-signing builds see one final prompt — choose **Always Allow**). **Install via Homebrew**: `brew tap AdrianBonpin/gridline https://git.ranio.xyz/adrianbonpin/homebrew-gridline.git` then `brew install --cask gridline`; version bump. - **v0.7.14** — **No more repeated macOS keychain prompts**: saved connection passwords and SSH secrets are now ACL-pinned to the app's code signature on every save (`keychain_acl`), so app upgrades never re-trigger the macOS keychain access dialog (users upgrading from pre-signing builds see one final prompt — choose **Always Allow**). **Install via Homebrew**: `brew tap AdrianBonpin/gridline https://github.com/AdrianBonpin/homebrew-gridline.git` then `brew install --cask gridline`; version bump.
- **v0.7.13** — **macOS is now Developer-ID signed and notarized**: the app is properly code-signed with a Developer ID Application certificate and notarized by Apple, so macOS opens it without the Gatekeeper "Open Anyway" prompt or the `xattr` quarantine workaround. The release script (`scripts/release-mac.sh`) auto-detects the signing identity from the keychain, notarizes + staples both architectures, and verifies the result before uploading; version bump. - **v0.7.13** — **macOS is now Developer-ID signed and notarized**: the app is properly code-signed with a Developer ID Application certificate and notarized by Apple, so macOS opens it without the Gatekeeper "Open Anyway" prompt or the `xattr` quarantine workaround. The release script (`scripts/release-mac.sh`) auto-detects the signing identity from the keychain, notarizes + staples both architectures, and verifies the result before uploading; version bump.
@@ -312,44 +314,48 @@ _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://git.ranio.xyz/adrianbonpin/gridline/releases) page. Pre-built installers for macOS, Windows, and Linux are published on the [Releases](https://github.com/AdrianBonpin/gridline/releases) page.
> ⚠️ Gridline is under active development. Expect rough edges and please [open issues](https://git.ranio.xyz/adrianbonpin/gridline/issues/new) when you hit them. > ⚠️ Gridline is under active development. Expect rough edges and please [open issues](https://github.com/AdrianBonpin/gridline/issues/new) when you hit them.
#### macOS signing & notarization #### macOS signing & notarization
Gridline's macOS builds are **Developer-ID signed and notarized**, so macOS opens them without a Gatekeeper prompt. This is configured on the machine that runs `scripts/release-mac.sh` (macOS is built manually, not in CI). Gridline's macOS builds are **Developer-ID signed and notarized by GitHub Actions**, so macOS opens them without a Gatekeeper prompt. Both the `.app` and the `.dmg` it ships inside are notarized and carry a stapled ticket — notarizing only the `.app` is not enough, because Gatekeeper assesses the downloaded DMG itself and rejects it as "Unnotarized Developer ID" even when the app within is fine.
**One-time setup** (only the repo owner needs to do this): **One-time setup** (only the repo owner needs to do this):
1. **Create a Developer ID Application certificate** — [developer.apple.com](https://developer.apple.com) → Certificates, Identifiers & Profiles → **+** → **Developer ID Application**. Generate a Certificate Signing Request from Keychain Access (Certificate Assistant → *Request a Certificate From a Certificate Authority*), upload it, download the `.cer`, and double-click to install into your login keychain. Verify with `security find-identity -v -p codesigning`. 1. **Create a Developer ID Application certificate** — [developer.apple.com](https://developer.apple.com) → Certificates, Identifiers & Profiles → **+** → **Developer ID Application**. Generate a Certificate Signing Request from Keychain Access (Certificate Assistant → *Request a Certificate From a Certificate Authority*), upload it, download the `.cer`, and double-click to install into your login keychain. Verify with `security find-identity -v -p codesigning`.
2. **Create an App Store Connect API key** for notarization — [appstoreconnect.apple.com](https://appstoreconnect.apple.com) → Users and Access → **Integrations** → **App Store Connect API** → **+** (name it, *Developer* access). Download the `.p8` (shown once) and note the **Key ID** and **Issuer ID**. 2. **Create an App Store Connect API key** for notarization — [appstoreconnect.apple.com](https://appstoreconnect.apple.com) → Users and Access → **Integrations** → **App Store Connect API** → **+** (name it, *Developer* access). Download the `.p8` (shown once) and note the **Key ID** and **Issuer ID**.
3. **Save the notarization credentials** to `~/.config/gridline/notarize.env` (never committed): 3. **Add the signing secrets** to the repository (**Settings → Secrets and variables → Actions**):
```bash | Secret | Value |
APPLE_API_KEY=<Key ID> | :--------------------------- | :------------------------------------------------------------------------------------------------- |
APPLE_API_ISSUER=<Issuer ID> | `APPLE_CERTIFICATE` | base64 of the exported Developer ID `.p12` — `base64 -i DeveloperID.p12 \| pbcopy` |
APPLE_API_KEY_PATH=/absolute/path/to/AuthKey_<KeyID>.p8 | `APPLE_CERTIFICATE_PASSWORD` | the password you set when exporting the `.p12` |
``` | `APPLE_SIGNING_IDENTITY` | e.g. `Developer ID Application: Your Name (TEAMID)` — from `security find-identity -v -p codesigning` |
| `KEYCHAIN_PASSWORD` | any throwaway password for the temporary CI keychain |
| `APPLE_API_KEY` | App Store Connect **Key ID** |
| `APPLE_API_ISSUER` | App Store Connect **Issuer ID** |
| `APPLE_API_KEY_CONTENT` | contents of the downloaded `AuthKey_<KeyID>.p8` |
(Alternatively, drop the `.p8` at `~/.appstoreconnect/private_keys/AuthKey_<KeyID>.p8` and omit `APPLE_API_KEY_PATH`.) When these secrets are absent the macOS jobs still build, but the DMGs are ad-hoc signed and **not** notarized (users get the “Open Anyway” prompt). The CI re-signs the bundled PostgreSQL/MariaDB client binaries with the same Developer ID identity, because Apple rejects ad-hoc-signed nested executables during notarization.
`scripts/release-mac.sh` auto-detects the Developer ID identity from your keychain, signs both architectures, notarizes + staples them, and verifies the result before uploading. If the certificate or credentials are missing it fails with instructions (set `GRIDLINE_SKIP_NOTARIZE=1` to build signed-but-unnotarized for testing only). `scripts/release-mac.sh` is the **local fallback** path (used before GitHub Actions took over macOS builds, and still handy for a signed build without burning CI minutes). It auto-detects the Developer ID identity from your keychain, reads notarization credentials from `~/.config/gridline/notarize.env` (`APPLE_API_KEY`, `APPLE_API_ISSUER`, `APPLE_API_KEY_PATH`), signs both architectures, notarizes + staples them, and verifies the result before uploading. If the certificate or credentials are missing it fails with instructions (set `GRIDLINE_SKIP_NOTARIZE=1` to build signed-but-unnotarized for testing only).
**Windows** still shows a SmartScreen prompt (no Windows signing cert yet); **Linux** installs without a warning. **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.8.0**): Each release contains **one file per platform** — you only need the one that matches your computer. The links below point at the current release (**v0.8.1**):
| Your system | Download this | Notes | | Your system | Download this | Notes |
| :------------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------- | :--------------------------------------------------------------- | | :------------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------- | :--------------------------------------------------------------- |
| macOS **Apple Silicon** (M1/M2/M3/M4…) | [Gridline_0.8.0_aarch64.dmg](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.8.0/Gridline_0.8.0_aarch64.dmg) | `aarch64` = Apple's own chip | | macOS **Apple Silicon** (M1/M2/M3/M4…) | [Gridline_0.8.1_aarch64.dmg](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.1/Gridline_0.8.1_aarch64.dmg) | `aarch64` = Apple's own chip |
| macOS **Intel** | [Gridline_0.8.0_x64.dmg](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.8.0/Gridline_0.8.0_x64.dmg) | `x64` = Intel/AMD | | macOS **Intel** | [Gridline_0.8.1_x64.dmg](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.1/Gridline_0.8.1_x64.dmg) | `x64` = Intel/AMD |
| **Windows** (most PCs) | [Gridline_0.8.0_x64-setup.exe](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.8.0/Gridline_0.8.0_x64-setup.exe) | The `.msi` is an alternate installer (for enterprises/IT admins) | | **Windows** (most PCs) | [Gridline_0.8.1_x64-setup.exe](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.1/Gridline_0.8.1_x64-setup.exe) | The `.msi` is an alternate installer (for enterprises/IT admins) |
| **Debian / Ubuntu** | [Gridline_0.8.0_amd64.deb](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.8.0/Gridline_0.8.0_amd64.deb) | Install: `sudo apt install ./Gridline_0.8.0_amd64.deb` | | **Debian / Ubuntu** | [Gridline_0.8.1_amd64.deb](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.1/Gridline_0.8.1_amd64.deb) | Install: `sudo apt install ./Gridline_0.8.1_amd64.deb` |
| **Fedora / RHEL / openSUSE** | [Gridline-0.8.0-1.x86_64.rpm](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.8.0/Gridline-0.8.0-1.x86_64.rpm) | Install: `sudo dnf install Gridline-0.8.0-1.x86_64.rpm` | | **Fedora / RHEL / openSUSE** | [Gridline-0.8.1-1.x86_64.rpm](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.1/Gridline-0.8.1-1.x86_64.rpm) | Install: `sudo dnf install Gridline-0.8.1-1.x86_64.rpm` |
| **Any other Linux** | [Gridline_0.8.0_amd64.AppImage](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.8.0/Gridline_0.8.0_amd64.AppImage) | Works on every distro: `chmod +x` the file, then double-click it | | **Any other Linux** | [Gridline_0.8.1_amd64.AppImage](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.1/Gridline_0.8.1_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.
@@ -363,7 +369,9 @@ git tag v0.7.15
git push origin v0.7.15 git push origin v0.7.15
``` ```
Gitea Actions (`.gitea/workflows/release.yml`) builds the **Linux and Windows** installers on the self-hosted runners, then opens a **draft release** on the [Releases](https://git.ranio.xyz/adrianbonpin/gridline/releases) page. **macOS is built manually** on the dev Mac and its DMGs uploaded to the same release via `scripts/release-mac.sh`. Review the draft and hit **Publish release**. GitHub Actions (`.github/workflows/release.yml`) builds the **macOS (Apple Silicon + Intel), Windows, and Linux** installers in parallel and opens a **draft release** on the [Releases](https://github.com/AdrianBonpin/gridline/releases) page. Review the draft and hit **Publish release** — publishing triggers a follow-up job that bumps the Homebrew cask in [`AdrianBonpin/homebrew-gridline`](https://github.com/AdrianBonpin/homebrew-gridline) (pushed via a write-enabled deploy key on the tap, `HOMEBREW_TAP_DEPLOY_KEY`; skipped when unset).
The self-hosted Gitea mirror (`.gitea/workflows/release.yml`) still builds Linux and Windows for the Gitea release, and `scripts/release-mac.sh` can build macOS locally — both are kept as a fallback if GitHub is unavailable.
Before tagging, make sure the version number is in sync across `desktop/package.json`, `desktop/src-tauri/Cargo.toml`, and `desktop/src-tauri/tauri.conf.json`, and update the **README download tables** (Download + Which file should I download?) to the new version's asset names. Before tagging, make sure the version number is in sync across `desktop/package.json`, `desktop/src-tauri/Cargo.toml`, and `desktop/src-tauri/tauri.conf.json`, and update the **README download tables** (Download + Which file should I download?) to the new version's asset names.
@@ -371,7 +379,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://git.ranio.xyz/adrianbonpin/gridline.git git clone https://github.com/AdrianBonpin/gridline.git
cd gridline cd gridline
# 2. Install all workspace dependencies # 2. Install all workspace dependencies
@@ -487,7 +495,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://git.ranio.xyz/adrianbonpin/gridline/issues/new)** — pick **Bug report** (with an environment table so we can reproduce issues quickly), **Feature request**, or **Improvement** (UX polish for existing features). New issues are auto-assigned to the maintainer. - Open an issue via the **[template chooser](https://github.com/AdrianBonpin/gridline/issues/new)** — pick **Bug report** (with an environment table so we can reproduce issues quickly), **Feature request**, or **Improvement** (UX polish for existing features). New issues are auto-assigned to the maintainer.
- Submit a pull request. Keep Tauri commands thin, type IPC boundaries explicitly, and follow the existing Rust/React conventions. - Submit a pull request. Keep Tauri commands thin, type IPC boundaries explicitly, and follow the existing Rust/React conventions.
--- ---
+8 -3
View File
@@ -16,6 +16,12 @@ This file is the **source of truth** for what Gridline is building. [AGENTS.md](
- **PlanetScale preset** — managed MySQL (Vitess) provider card + setup guide + `.psdb.cloud` host detection; persists as `db_type: mysql` - **PlanetScale preset** — managed MySQL (Vitess) provider card + setup guide + `.psdb.cloud` host detection; persists as `db_type: mysql`
- **Version bump** 0.7.15 → **0.8.0**. - **Version bump** 0.7.15 → **0.8.0**.
## ✅ Shipped (unreleased)
- **Connection-string SSL mode is honored end to end** — pasting a provider URL now carries its `?sslmode=` (Neon/Supabase `require`, `verify-full`, MySQL `ssl-mode=`) into the saved connection instead of silently connecting with TLS off, and the SSH/SSL tab's mode + certificate paths are persisted on create/edit (they were previously dropped before reaching the backend). Save/Test also fall back to a URL-derived label, so a pasted URL can be saved without typing anything. Fixes managed-provider connections failing with *"connection is insecure (try using `sslmode=require`)"*.
- **Managed hosts default to TLS** — `*.neon.tech` / `*.supabase.co` / `*.psdb.cloud` resolve to `require` when a connection has no `ssl_mode`, so connections saved before the form persisted it (or imported without it) connect again without being re-saved. An explicit mode — including `disable` — always wins.
- **Managed presets preselect their TLS mode** — Supabase/NeonDB → Require, PlanetScale → Verify Full.
## 📋 In the queue ## 📋 In the queue
### Full Redis Support ### Full Redis Support
@@ -36,7 +42,6 @@ MariaDB and TimescaleDB moved to [0.8.0](#-next-up-080). Remaining candidates: C
Supabase and NeonDB presets shipped in v0.7.0; PlanetScale moved to [0.8.0](#-next-up-080). Remaining: Supabase and NeonDB presets shipped in v0.7.0; PlanetScale moved to [0.8.0](#-next-up-080). Remaining:
- **Turso** (libSQL — not SQLite over the wire; needs a `libsql` driver path in the Rust backend, not just a connection preset) - **Turso** (libSQL — not SQLite over the wire; needs a `libsql` driver path in the Rust backend, not just a connection preset)
- Provider detection guidance: paste a provider URL → Gridline auto-fills host/port/SSL mode; provider "connect" docs linked from the connection form
### Query Workbench Upgrades ### Query Workbench Upgrades
@@ -104,7 +109,7 @@ Still deferred from 0.7.0, slated for this bucket:
## ✅ Shipped (0.7.14) ## ✅ Shipped (0.7.14)
- **No more repeated macOS keychain prompts** — saved connection passwords and SSH secrets are ACL-pinned to the app's code signature when stored, so app upgrades never re-trigger the macOS keychain access dialog (users upgrading from older builds see one final prompt — choose Always Allow) - **No more repeated macOS keychain prompts** — saved connection passwords and SSH secrets are ACL-pinned to the app's code signature when stored, so app upgrades never re-trigger the macOS keychain access dialog (users upgrading from older builds see one final prompt — choose Always Allow)
- **Install via Homebrew** — add the Gitea tap (`brew tap AdrianBonpin/gridline https://git.ranio.xyz/adrianbonpin/homebrew-gridline.git`) and run `brew install --cask gridline` - **Install via Homebrew** — add the Homebrew tap (`brew tap AdrianBonpin/gridline https://github.com/AdrianBonpin/homebrew-gridline.git`) and run `brew install --cask gridline`
- **Version bump** 0.7.13 → **0.7.14**. - **Version bump** 0.7.13 → **0.7.14**.
## ✅ Shipped (0.7.13) ## ✅ Shipped (0.7.13)
@@ -112,7 +117,7 @@ Still deferred from 0.7.0, slated for this bucket:
- **Developer-ID signed + notarized macOS** — the app is properly code-signed with a Developer ID Application certificate and notarized by Apple, so macOS opens it without the Gatekeeper "Open Anyway" prompt or the `xattr` quarantine workaround - **Developer-ID signed + notarized macOS** — the app is properly code-signed with a Developer ID Application certificate and notarized by Apple, so macOS opens it without the Gatekeeper "Open Anyway" prompt or the `xattr` quarantine workaround
- **Version bump** 0.7.12 → **0.7.13**. - **Version bump** 0.7.12 → **0.7.13**.
## ✅ Shipped (0.7.12) *(includes 0.7.11: release-pipeline/docs refresh — README download links moved from GitHub to the self-hosted Gitea `git.ranio.xyz`)* ## ✅ Shipped (0.7.12) *(includes 0.7.11: release-pipeline/docs refresh)*
- **Reliable cell editing for non-text columns** — editing a PostgreSQL cell whose type isn't text (integers, booleans, UUIDs, json/jsonb, timestamps, enums, numerics) now works; edited values are sent to PostgreSQL in text wire format so the server parses them into the column's own type - **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 - **Recent connections fix** — the Recent strip no longer renders empty on first launch when the connections list hadn't loaded yet
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "gridline-desktop", "name": "gridline-desktop",
"private": true, "private": true,
"version": "0.8.0", "version": "0.8.1",
"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": {
+1 -1
View File
@@ -1783,7 +1783,7 @@ dependencies = [
[[package]] [[package]]
name = "gridline" name = "gridline"
version = "0.8.0" version = "0.8.1"
dependencies = [ dependencies = [
"bytes", "bytes",
"chrono", "chrono",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "gridline" name = "gridline"
version = "0.8.0" version = "0.8.1"
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"
+2 -2
View File
@@ -1005,7 +1005,7 @@ pub(crate) async fn run_mysql_connect(
// TLS: through a tunnel the peer is loopback, so verify-ca/verify-full // TLS: through a tunnel the peer is loopback, so verify-ca/verify-full
// degrade to encrypt-only `require`. Direct connections honor the mode. // degrade to encrypt-only `require`. Direct connections honor the mode.
let decision = crate::commands::ssh::effective_tls_decision( let decision = crate::commands::ssh::effective_tls_decision(
crate::db::tls::tls_decision(config.ssl_mode.as_deref()), crate::db::tls::tls_decision_for(&config.host, config.ssl_mode.as_deref()),
via_tunnel, via_tunnel,
); );
match decision { match decision {
@@ -1084,7 +1084,7 @@ pub async fn db_connect(
// connections honor the user's mode. Building this before opening the // connections honor the user's mode. Building this before opening the
// tunnel means a config error can't leak the tunnel. // tunnel means a config error can't leak the tunnel.
let decision = crate::commands::ssh::effective_tls_decision( let decision = crate::commands::ssh::effective_tls_decision(
crate::db::tls::tls_decision(config.ssl_mode.as_deref()), crate::db::tls::tls_decision_for(&config.host, config.ssl_mode.as_deref()),
will_tunnel, will_tunnel,
); );
let tls = crate::db::tls::build_tls_config( let tls = crate::db::tls::build_tls_config(
@@ -25,6 +25,9 @@ pub fn get_connection_password(
app: tauri::AppHandle, app: tauri::AppHandle,
connection_id: String, connection_id: String,
) -> Result<Option<String>, String> { ) -> Result<Option<String>, String> {
// Repair a stale item ACL first: after a rebuild/path change macOS would
// otherwise ask for the keychain password on every access (once per secret).
keychain_acl::before_read(&app, &connection_id);
app.keyring() app.keyring()
.store .store
.get_password(&connection_id) .get_password(&connection_id)
@@ -37,6 +40,7 @@ pub fn get_connection_password_internal(
app: &tauri::AppHandle, app: &tauri::AppHandle,
connection_id: &str, connection_id: &str,
) -> Result<Option<String>, String> { ) -> Result<Option<String>, String> {
keychain_acl::before_read(app, connection_id);
app.keyring() app.keyring()
.store .store
.get_password(connection_id) .get_password(connection_id)
@@ -71,6 +75,7 @@ pub fn get_connection_ssh_password(
app: tauri::AppHandle, app: tauri::AppHandle,
connection_id: String, connection_id: String,
) -> Result<Option<String>, String> { ) -> Result<Option<String>, String> {
keychain_acl::before_read(&app, &ssh_account("password", &connection_id));
app.keyring() app.keyring()
.store .store
.get_password(&ssh_account("password", &connection_id)) .get_password(&ssh_account("password", &connection_id))
@@ -111,6 +116,7 @@ pub fn get_connection_ssh_passphrase(
app: tauri::AppHandle, app: tauri::AppHandle,
connection_id: String, connection_id: String,
) -> Result<Option<String>, String> { ) -> Result<Option<String>, String> {
keychain_acl::before_read(&app, &ssh_account("passphrase", &connection_id));
app.keyring() app.keyring()
.store .store
.get_password(&ssh_account("passphrase", &connection_id)) .get_password(&ssh_account("passphrase", &connection_id))
@@ -135,6 +141,7 @@ pub fn get_connection_ssh_password_internal(
app: &tauri::AppHandle, app: &tauri::AppHandle,
connection_id: &str, connection_id: &str,
) -> Result<Option<String>, String> { ) -> Result<Option<String>, String> {
keychain_acl::before_read(app, &ssh_account("password", connection_id));
app.keyring() app.keyring()
.store .store
.get_password(&ssh_account("password", connection_id)) .get_password(&ssh_account("password", connection_id))
@@ -265,7 +265,7 @@ async fn test_pg_connection(config: &DbConfig, ssh: &SshManager) -> TestConnecti
// TLS: through a tunnel the peer is loopback, so verify-ca/verify-full // TLS: through a tunnel the peer is loopback, so verify-ca/verify-full
// degrade to encrypt-only `require`. Direct connections honor the mode. // degrade to encrypt-only `require`. Direct connections honor the mode.
let decision = crate::commands::ssh::effective_tls_decision( let decision = crate::commands::ssh::effective_tls_decision(
crate::db::tls::tls_decision(config.ssl_mode.as_deref()), crate::db::tls::tls_decision_for(&config.host, config.ssl_mode.as_deref()),
target.via_tunnel, target.via_tunnel,
); );
let tls = match crate::db::tls::build_tls_config( let tls = match crate::db::tls::build_tls_config(
@@ -368,7 +368,7 @@ async fn test_mysql_connection(config: &DbConfig, ssh: &SshManager) -> TestConne
// TLS: through a tunnel the peer is loopback, so verify-ca/verify-full // TLS: through a tunnel the peer is loopback, so verify-ca/verify-full
// degrade to encrypt-only `require`. Direct connections honor the mode. // degrade to encrypt-only `require`. Direct connections honor the mode.
let decision = crate::commands::ssh::effective_tls_decision( let decision = crate::commands::ssh::effective_tls_decision(
crate::db::tls::tls_decision(config.ssl_mode.as_deref()), crate::db::tls::tls_decision_for(&config.host, config.ssl_mode.as_deref()),
target.via_tunnel, target.via_tunnel,
); );
match decision { match decision {
+75
View File
@@ -29,6 +29,37 @@ pub fn tls_decision(ssl_mode: Option<&str>) -> TlsDecision {
} }
} }
/// Host suffixes of managed providers that reject plaintext connections with
/// `connection is insecure (try using 'sslmode=require')`.
const MANAGED_SSL_HOST_SUFFIXES: [&str; 3] = [".neon.tech", ".supabase.co", ".psdb.cloud"];
/// Resolve the SSL mode to use for a connection, defaulting these managed
/// providers to `require` when the connection carries no explicit mode.
///
/// Connections saved before the UI persisted `ssl_mode` (or imported without
/// it) would otherwise connect with TLS disabled and be refused by the server.
/// An explicit mode always wins, so a deliberate `disable` is never overridden,
/// and the inferred mode only encrypts — it never verifies certificates.
pub fn resolve_ssl_mode(host: &str, ssl_mode: Option<&str>) -> Option<String> {
if let Some(mode) = ssl_mode {
return Some(mode.to_string());
}
let host = host.to_ascii_lowercase();
if MANAGED_SSL_HOST_SUFFIXES
.iter()
.any(|suffix| host.ends_with(suffix))
{
return Some("require".to_string());
}
None
}
/// TLS decision for a connection, applying the managed-provider default from
/// [`resolve_ssl_mode`] before mapping to a [`TlsDecision`].
pub fn tls_decision_for(host: &str, ssl_mode: Option<&str>) -> TlsDecision {
tls_decision(resolve_ssl_mode(host, ssl_mode).as_deref())
}
/// Build a rustls `ClientConfig` for tokio-postgres, or `None` for disable. /// Build a rustls `ClientConfig` for tokio-postgres, or `None` for disable.
/// `ca_path` is required for Verify; `cert_path`/`key_path` are optional client auth. /// `ca_path` is required for Verify; `cert_path`/`key_path` are optional client auth.
pub fn build_tls_config( pub fn build_tls_config(
@@ -197,6 +228,50 @@ mod tests {
assert!(matches!(tls_decision(Some("bogus")), TlsDecision::Disable)); assert!(matches!(tls_decision(Some("bogus")), TlsDecision::Disable));
} }
#[test]
fn resolve_ssl_mode_defaults_managed_hosts_to_require() {
// Saved connections that predate the form storing `ssl_mode` still work.
for host in [
"ep-hidden-star-b3hqtp4x-pooler.c-4.ap-southeast-1.aws.neon.tech",
"db.abcdefghijklmnopqrst.supabase.co",
"xxxx.us-east-2.psdb.cloud",
"EP-X-POOLER.EU.AWS.NEON.TECH",
] {
assert_eq!(resolve_ssl_mode(host, None).as_deref(), Some("require"));
}
}
#[test]
fn resolve_ssl_mode_leaves_other_hosts_and_explicit_modes_alone() {
assert_eq!(resolve_ssl_mode("localhost", None), None);
assert_eq!(resolve_ssl_mode("prod.example.com", None), None);
// An explicit mode always wins — a deliberate `disable` is never overridden.
assert_eq!(
resolve_ssl_mode("ep-x.eu.aws.neon.tech", Some("disable")).as_deref(),
Some("disable")
);
assert_eq!(
resolve_ssl_mode("ep-x.eu.aws.neon.tech", Some("verify-full")).as_deref(),
Some("verify-full")
);
}
#[test]
fn tls_decision_for_honors_managed_default_and_explicit_modes() {
assert!(matches!(
tls_decision_for("ep-x.eu.aws.neon.tech", None),
TlsDecision::Require
));
assert!(matches!(
tls_decision_for("ep-x.eu.aws.neon.tech", Some("disable")),
TlsDecision::Disable
));
assert!(matches!(
tls_decision_for("db.example.com", None),
TlsDecision::Disable
));
}
#[test] #[test]
fn build_tls_disable_returns_none() { fn build_tls_disable_returns_none() {
assert!(build_tls_config(TlsDecision::Disable, None, None, None) assert!(build_tls_config(TlsDecision::Disable, None, None, None)
+308 -110
View File
@@ -1,117 +1,45 @@
//! Keychain ACL normalization for stored secrets (macOS). //! Keychain ACL normalization and repair for stored secrets (macOS).
//! //!
//! # Why this exists //! # Why this exists
//! //!
//! Generic-password items stored via the legacy keychain API get a default ACL //! Generic-password items stored via the legacy keychain API are guarded by an
//! that grants access to "the application that created the item". When a Tauri //! item ACL. For a dev (linker-signed / ad-hoc) binary macOS records that ACL's
//! app is rebuilt, its code signature changes (dev builds are ad-hoc signed, so //! trusted application as **the path of the executable** — not a code
//! the identity differs on every build), and the new binary no longer matches //! requirement — so the item stays readable across rebuilds *as long as the
//! the item's ACL — macOS then shows the "Gridline wants to access your //! binary keeps living at that path*. A properly signed bundle is recorded as a
//! keychain" prompt. //! code requirement plus a `cdhash` partition list, which is stable across
//! versions and even moves.
//! //!
//! The fix: after storing a secret, replace the item's ACL with an explicit //! Two failure modes follow from that:
//! `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 //! 1. **The path changes** (monorepo move `src-tauri/` → `desktop/src-tauri/`,
//! app reinstalled elsewhere, release vs. dev binary). The stored paths no
//! longer match the running binary, so macOS treats it as a *foreign* app and
//! asks for the keychain password on **every** access — per secret, and with
//! no "Always Allow", because the item's ACL lists only other apps.
//! 2. **A differently-signed build wrote the item** and the pin never ran again.
//! //!
//! - Item created by this build → the ACL set is silent. //! Pinning only after writes (`after_store`) can't repair either case: writing
//! - Item created by an older/differently-signed build → macOS shows the //! to an item we can't access already needs the same authorization, so the ACL
//! standard one-time authorization dialog; "Always Allow" replaces the ACL //! is never corrected. This module therefore also *repairs before reads*.
//! 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 //! # How the repair works
//! writes never fail because of ACL normalization. //!
//! [`ensure_access`] first asks, **without prompting**, whether this process is
/// Best-effort ACL normalization after a successful keychain write. //! already represented in the item's ACL (see [`is_item_trusted`]). Only when it
/// //! is not does it call `SecKeychainItemSetAccess` with an ACL whose trusted list
/// Resolves the service name the same way `tauri-plugin-keyring-store` does //! is exactly this app. That call is what macOS gates behind the authorization
/// (the Tauri bundle identifier) and never fails the caller's operation. //! dialog — so a stale item costs the user **one** authorization, and every
pub fn after_store(app: &tauri::AppHandle, account: &str) { //! later access (including after future rebuilds at the same path) is silent.
let service = app.config().identifier.clone(); //! Items that are already trusted are left alone, so the ACL never accumulates
if let Err(e) = ensure_item_acl(&service, account) { //! duplicate entries.
eprintln!("[keychain_acl] skipped for {account}: {e}"); //!
} //! All operations are best-effort: they log to stderr and never fail the
} //! caller's keychain read/write.
/// 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")] #[cfg(target_os = "macos")]
mod macos_ffi { mod macos_ffi {
use core_foundation::base::{CFTypeRef, CFTypeID}; use core_foundation::base::{CFTypeID, CFTypeRef};
/// Minimal `TCFType` wrapper for `SecTrustedApplication` — the /// Minimal `TCFType` wrapper for `SecTrustedApplication` — the
/// security-framework crate exposes no high-level API for it. /// security-framework crate exposes no high-level API for it.
@@ -140,11 +68,29 @@ mod macos_ffi {
path: CFTypeRef, path: CFTypeRef,
app: *mut SecTrustedApplicationRef, app: *mut SecTrustedApplicationRef,
) -> i32; ) -> i32;
pub fn SecTrustedApplicationCopyData(
app: CFTypeRef,
data: *mut core_foundation::data::CFDataRef,
) -> i32;
pub fn SecAccessCreate( pub fn SecAccessCreate(
descriptor: CFTypeRef, descriptor: CFTypeRef,
trustedlist: CFTypeRef, trustedlist: core_foundation::array::CFArrayRef,
access: *mut security_framework_sys::base::SecAccessRef, access: *mut security_framework_sys::base::SecAccessRef,
) -> i32; ) -> i32;
pub fn SecKeychainItemCopyAccess(
item: security_framework_sys::base::SecKeychainItemRef,
access: *mut security_framework_sys::base::SecAccessRef,
) -> i32;
pub fn SecAccessCopyACLList(
access: security_framework_sys::base::SecAccessRef,
list: *mut core_foundation::array::CFArrayRef,
) -> i32;
pub fn SecACLCopyContents(
acl: CFTypeRef,
apps: *mut core_foundation::array::CFArrayRef,
desc: *mut core_foundation::string::CFStringRef,
prompt: *mut u32,
) -> i32;
pub fn SecKeychainItemSetAccess( pub fn SecKeychainItemSetAccess(
item: security_framework_sys::base::SecKeychainItemRef, item: security_framework_sys::base::SecKeychainItemRef,
access: security_framework_sys::base::SecAccessRef, access: security_framework_sys::base::SecAccessRef,
@@ -152,21 +98,260 @@ mod macos_ffi {
} }
} }
#[cfg(target_os = "macos")]
use core_foundation::base::TCFType;
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
use macos_ffi::{ use macos_ffi::{
OpaqueSecTrustedApplication, SecAccessCreate, SecKeychainItemSetAccess, SecTrustedApplication, SecACLCopyContents, SecAccessCopyACLList, SecAccessCreate, SecKeychainItemCopyAccess,
SecKeychainItemSetAccess, SecTrustedApplication, SecTrustedApplicationCopyData,
SecTrustedApplicationCreateFromPath, SecTrustedApplicationCreateFromPath,
}; };
/// Best-effort ACL normalization after a successful keychain write.
pub fn after_store(app: &tauri::AppHandle, account: &str) {
ensure_access(app, account);
}
/// Best-effort ACL repair *before* reading a secret.
///
/// A stale ACL is what makes macOS ask for the keychain password on every
/// access, so this runs before reads: it authorizes/repairs the item once and
/// leaves it silent from then on. Prompt-free (and cheap) when the item is
/// already trusted or does not exist.
pub fn before_read(app: &tauri::AppHandle, account: &str) {
ensure_access(app, account);
}
/// Resolve the keyring service name (the Tauri bundle identifier) and repair the
/// item's ACL when this app is not already trusted. Never fails the caller.
fn ensure_access(app: &tauri::AppHandle, account: &str) {
let service = app.config().identifier.clone();
if let Err(e) = ensure_item_acl(&service, account) {
eprintln!("[keychain_acl] skipped for {account}: {e}");
}
}
/// Make sure this app can read `account` without an authorization prompt.
///
/// Idempotent and cheap when the item is already trusted (the common case):
/// the trust check is a metadata-only, prompt-free lookup. Returns `Ok(())` when
/// the item exists and now trusts this app, or when there is nothing to do.
#[cfg(target_os = "macos")]
pub fn ensure_item_acl(service: &str, account: &str) -> Result<(), String> {
use security_framework::os::macos::access::SecAccess;
use security_framework_sys::base::SecAccessRef;
// Locate the item WITHOUT reading its secret: a ref-only lookup
// (`kSecReturnRef`, no data/attributes) never triggers an authorization
// prompt, so merely probing an item we can't yet access is safe.
let item = match item_ref(service, account)? {
Some(item) => item,
// No item (yet) — nothing to normalize.
None => return Ok(()),
};
// Already trusted → leave the ACL alone (repairing again would only
// authorize-and-append a duplicate entry).
if is_item_trusted(&item) {
return Ok(());
}
fn cvt(status: i32) -> Result<(), String> {
if status == 0 {
Ok(())
} else {
Err(format!("security framework error {status}"))
}
}
// Build an access object whose trusted list is exactly this app. A NULL
// path resolves the trusted application to the current process's identity
// (its executable path for ad-hoc dev builds, its code requirement for a
// signed bundle).
unsafe {
let mut trusted_app_ref = std::ptr::null_mut();
cvt(SecTrustedApplicationCreateFromPath(
std::ptr::null(),
&mut trusted_app_ref,
))?;
let trusted_app = SecTrustedApplication::wrap_under_create_rule(trusted_app_ref);
let trusted_list = core_foundation::array::CFArray::from_CFTypes(&[trusted_app]);
let descriptor = core_foundation::string::CFString::new("Gridline");
let mut access_ref: SecAccessRef = std::ptr::null_mut();
cvt(SecAccessCreate(
descriptor.as_concrete_TypeRef() as core_foundation::base::CFTypeRef,
trusted_list.as_concrete_TypeRef(),
&mut access_ref,
))?;
let access = SecAccess::wrap_under_create_rule(access_ref);
// Replace the item's ACL. Silent when this app already owns the item;
// the one-time authorization dialog otherwise (this is what makes a
// stale item permanently silent again).
cvt(SecKeychainItemSetAccess(
item.as_concrete_TypeRef(),
access.as_concrete_TypeRef(),
))
}
}
/// Ref-only lookup for a generic-password item. Prompt-free.
#[cfg(target_os = "macos")]
fn item_ref(
service: &str,
account: &str,
) -> Result<Option<security_framework::os::macos::keychain_item::SecKeychainItem>, String> {
use security_framework::item::{ItemClass, ItemSearchOptions, Reference, SearchResult};
let found = match ItemSearchOptions::new()
.class(ItemClass::generic_password())
.service(service)
.account(account)
.load_refs(true)
.load_data(false)
.load_attributes(false)
.search()
{
Ok(found) => found,
// `search()` reports "nothing matched" as an error, not an empty vec.
// That is the normal case for connections without a stored secret.
Err(e) if e.code() == security_framework_sys::base::errSecItemNotFound => return Ok(None),
Err(e) => return Err(format!("keychain item lookup failed: {e}")),
};
Ok(match found.first() {
Some(SearchResult::Ref(Reference::KeychainItem(item))) => Some(item.clone()),
// No item or an unexpected result shape — nothing to normalize.
_ => None,
})
}
/// This process's identity as a `SecTrustedApplication`, serialized.
///
/// For a signed bundle this is the code requirement; for a dev/ad-hoc binary it
/// is the resolved executable path (NUL-terminated). Either way, byte-comparing
/// it against an item's ACL entries tells us whether the ACL already admits us.
#[cfg(target_os = "macos")]
fn own_identity() -> Option<Vec<u8>> {
unsafe {
let mut app = std::ptr::null_mut();
if SecTrustedApplicationCreateFromPath(std::ptr::null(), &mut app) != 0 || app.is_null() {
return None;
}
let app = SecTrustedApplication::wrap_under_create_rule(app);
let mut data: core_foundation::data::CFDataRef = std::ptr::null();
if SecTrustedApplicationCopyData(
app.as_concrete_TypeRef() as core_foundation::base::CFTypeRef,
&mut data,
) != 0
|| data.is_null()
{
return None;
}
let bytes = std::slice::from_raw_parts(
core_foundation::data::CFDataGetBytePtr(data),
core_foundation::data::CFDataGetLength(data) as usize,
)
.to_vec();
core_foundation::base::CFRelease(data as core_foundation::base::CFTypeRef);
Some(bytes)
}
}
/// Whether this process is already admitted by the item's ACL — **prompt-free**.
///
/// True when an ACL entry's serialized trusted application is byte-identical to
/// this process's identity, or when the item carries a `cdhash:` partition list
/// (how macOS records access for a signed bundle — we leave those untouched).
#[cfg(target_os = "macos")]
fn is_item_trusted(item: &security_framework::os::macos::keychain_item::SecKeychainItem) -> bool {
use core_foundation::array::{CFArray, CFArrayRef};
use core_foundation::base::{CFTypeRef, TCFType};
use core_foundation::string::{CFString, CFStringRef};
let Some(own) = own_identity() else {
return false;
};
unsafe {
let mut access: security_framework_sys::base::SecAccessRef = std::ptr::null_mut();
if SecKeychainItemCopyAccess(item.as_concrete_TypeRef(), &mut access) != 0
|| access.is_null()
{
return false;
}
let mut acl_list: CFArrayRef = std::ptr::null();
if SecAccessCopyACLList(access, &mut acl_list) != 0 || acl_list.is_null() {
return false;
}
let acls = CFArray::<CFTypeRef>::wrap_under_create_rule(acl_list);
for acl in acls.iter() {
let mut apps: CFArrayRef = std::ptr::null();
let mut desc: CFStringRef = std::ptr::null();
let mut prompt: u32 = 0;
if SecACLCopyContents(*acl, &mut apps, &mut desc, &mut prompt) != 0 {
continue;
}
if !desc.is_null() {
// Signed-bundle items store a code-signing partition list here.
if CFString::wrap_under_create_rule(desc)
.to_string()
.contains("cdhash:")
{
return true;
}
}
if apps.is_null() {
continue;
}
let list = CFArray::<CFTypeRef>::wrap_under_create_rule(apps);
for app in list.iter() {
let mut data: core_foundation::data::CFDataRef = std::ptr::null();
if SecTrustedApplicationCopyData(*app, &mut data) != 0 || data.is_null() {
continue;
}
let bytes = std::slice::from_raw_parts(
core_foundation::data::CFDataGetBytePtr(data),
core_foundation::data::CFDataGetLength(data) as usize,
);
if bytes == own.as_slice() {
return true;
}
}
}
false
}
}
/// Non-macOS platforms have no keychain ACL concept — nothing to do.
#[cfg(not(target_os = "macos"))]
pub fn ensure_item_acl(_service: &str, _account: &str) -> Result<(), String> {
Ok(())
}
#[cfg(all(test, target_os = "macos"))] #[cfg(all(test, target_os = "macos"))]
mod tests { mod tests {
use super::*; use super::*;
use security_framework::os::macos::keychain::SecKeychain; use security_framework::os::macos::keychain::SecKeychain;
use security_framework::os::macos::passwords::find_generic_password; use security_framework::os::macos::passwords::find_generic_password;
/// Real-keychain integration test: creates a generic-password item the /// A missing item is a silent no-op — no lookup errors, no prompts. This is
/// same way the keyring store does, normalizes its ACL, verifies the /// the path taken for every connection that never stored a password, so it
/// secret is still readable, and cleans up. /// must stay cheap and quiet.
#[test]
fn ensure_item_acl_missing_item_is_noop() {
let service = "com.adrianbonpin.gridline.acl-test.missing";
let account = "definitely-not-stored";
ensure_item_acl(service, account).expect("no-op for a missing item");
}
/// Real-keychain integration test: creates a generic-password item the same
/// way the keyring store does, asserts the trust check, repairs the ACL,
/// verifies the item is now trusted (and that repairing again is a silent
/// no-op), reads the secret back, and cleans up.
/// ///
/// Ignored by default (`cargo test`) so CI/dev runs never touch the login /// Ignored by default (`cargo test`) so CI/dev runs never touch the login
/// keychain. Run explicitly with: /// keychain. Run explicitly with:
@@ -184,8 +369,21 @@ mod tests {
.set_generic_password(service, &account, b"test-secret") .set_generic_password(service, &account, b"test-secret")
.expect("create test item"); .expect("create test item");
// ACL normalization must succeed on an item we just created. // Fresh items are created with this build as the trusted app, so the
// trust check must already be true and the repair a no-op.
let item = item_ref(service, &account)
.expect("lookup")
.expect("item exists");
assert!(is_item_trusted(&item), "fresh item should already trust us");
ensure_item_acl(service, &account).expect("normalize ACL"); ensure_item_acl(service, &account).expect("normalize ACL");
assert!(
is_item_trusted(&item),
"item must be trusted after repair"
);
// Idempotent: a second repair must not need authorization again.
ensure_item_acl(service, &account).expect("second repair");
// The item must remain readable with the correct secret. // The item must remain readable with the correct secret.
let (password, item) = let (password, item) =
+1 -1
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.8.0", "version": "0.8.1",
"identifier": "com.adrianbonpin.gridline", "identifier": "com.adrianbonpin.gridline",
"build": { "build": {
"beforeDevCommand": "bun run dev", "beforeDevCommand": "bun run dev",
@@ -4,16 +4,19 @@ import type { ConnectionFormData } from "./connectionFormData";
interface ConnectionMetadataRowProps { interface ConnectionMetadataRowProps {
form: ConnectionFormData; form: ConnectionFormData;
onChange: (updates: Partial<ConnectionFormData>) => void; onChange: (updates: Partial<ConnectionFormData>) => void;
/** Shown when the label is empty — usually the name derived from a pasted
* connection string, which Test/Save uses as a fallback. */
namePlaceholder?: string;
} }
export function ConnectionMetadataRow({ form, onChange }: ConnectionMetadataRowProps) { export function ConnectionMetadataRow({ form, onChange, namePlaceholder }: ConnectionMetadataRowProps) {
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<label className="block text-sm text-text mb-1.5">Connection Label</label> <label className="block text-sm text-text mb-1.5">Connection Label</label>
<Input <Input
value={form.name} value={form.name}
onChange={(value) => onChange({ name: value })} onChange={(value) => onChange({ name: value })}
placeholder="My Production Database" placeholder={namePlaceholder || "My Production Database"}
aria-label="Connection Label" aria-label="Connection Label"
/> />
</div> </div>
@@ -9,14 +9,17 @@ export interface DetailedConnectionFormProps {
form: ConnectionFormData; form: ConnectionFormData;
onChange: (updates: Partial<ConnectionFormData>) => void; onChange: (updates: Partial<ConnectionFormData>) => void;
managedPreset?: "supabase" | "neon" | "planetscale" | null; managedPreset?: "supabase" | "neon" | "planetscale" | null;
/** Placeholder for the Connection Label when the field is empty, e.g. the
* name derived from a pasted connection string. */
namePlaceholder?: string;
} }
export function DetailedConnectionForm({ form, onChange, managedPreset }: DetailedConnectionFormProps) { export function DetailedConnectionForm({ form, onChange, managedPreset, namePlaceholder }: DetailedConnectionFormProps) {
const [activeTab, setActiveTab] = useState<"general" | "tagsEnv" | "ssh">("general"); const [activeTab, setActiveTab] = useState<"general" | "tagsEnv" | "ssh">("general");
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<ConnectionMetadataRow form={form} onChange={onChange} /> <ConnectionMetadataRow form={form} onChange={onChange} namePlaceholder={namePlaceholder} />
<div> <div>
<div className="flex gap-6 border-b border-border mb-4"> <div className="flex gap-6 border-b border-border mb-4">
<button type="button" onClick={() => setActiveTab("general")} className={`pb-2 text-sm cursor-pointer transition-colors ${activeTab === "general" ? "text-text border-b-2 border-text" : "text-text-muted hover:text-text"}`}>General</button> <button type="button" onClick={() => setActiveTab("general")} className={`pb-2 text-sm cursor-pointer transition-colors ${activeTab === "general" ? "text-text border-b-2 border-text" : "text-text-muted hover:text-text"}`}>General</button>
@@ -52,6 +52,60 @@ describe("NewConnectionScreen", () => {
expect(screen.getByLabelText("User")).toHaveValue("u"); expect(screen.getByLabelText("User")).toHaveValue("u");
}); });
it("paste a Neon URL pre-fills every required setting, including SSL mode", async () => {
const user = userEvent.setup();
render(<NewConnectionScreen />);
await user.type(
screen.getByLabelText(/connection uri/i),
"postgresql://neondb_owner:npg_secret@ep-hidden-star-b3hqtp4x-pooler.c-4.ap-southeast-1.aws.neon.tech/neondb?sslmode=require&channel_binding=require",
);
// Every field the URL can supply is filled, so no manual cleanup is needed
expect(screen.getByLabelText("Host")).toHaveValue(
"ep-hidden-star-b3hqtp4x-pooler.c-4.ap-southeast-1.aws.neon.tech",
);
expect(screen.getByLabelText("Port")).toHaveValue(5432);
expect(screen.getByLabelText("User")).toHaveValue("neondb_owner");
expect(screen.getByLabelText("Password")).toHaveValue("npg_secret");
expect(screen.getByLabelText("Database")).toHaveValue("neondb");
// The label stays untouched but is suggested from the database name.
expect(screen.getByLabelText(/connection label/i)).toHaveValue("");
expect(screen.getByLabelText(/connection label/i)).toHaveAttribute(
"placeholder",
"neondb",
);
// SSL mode is visible on the SSH / SSL tab...
await user.click(screen.getByRole("button", { name: /ssh \/ ssl/i }));
await user.click(screen.getByRole("button", { name: "SSL" }));
expect(screen.getByRole("button", { name: "SSL Mode" })).toHaveTextContent("Require");
// ...and reaches the save payload with no extra clicks.
await user.click(screen.getByText("Save Connection"));
await waitFor(() => expect(createConnection).toHaveBeenCalledTimes(1));
expect(createConnection).toHaveBeenCalledWith(
expect.objectContaining({
name: "neondb",
db_type: "postgresql",
ssl_mode: "require",
username: "neondb_owner",
password: "npg_secret",
database: "neondb",
}),
);
});
it("a typed label wins over the URL-derived suggestion", async () => {
const user = userEvent.setup();
render(<NewConnectionScreen />);
await user.type(screen.getByLabelText(/connection uri/i), "postgresql://u:p@localhost:5432/db");
await user.type(screen.getByLabelText(/connection label/i), "My DB");
await user.click(screen.getByText("Save Connection"));
await waitFor(() => expect(createConnection).toHaveBeenCalledTimes(1));
expect(createConnection).toHaveBeenCalledWith(
expect.objectContaining({ name: "My DB" }),
);
});
it("clicking a provider tab reveals the form with that db_type", async () => { it("clicking a provider tab reveals the form with that db_type", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
render(<NewConnectionScreen />); render(<NewConnectionScreen />);
@@ -60,6 +114,19 @@ describe("NewConnectionScreen", () => {
expect(screen.getByLabelText("Port")).toHaveValue(3306); expect(screen.getByLabelText("Port")).toHaveValue(3306);
}); });
it("NeonDB card preselects Require SSL in the save payload", async () => {
const user = userEvent.setup();
render(<NewConnectionScreen />);
await user.click(screen.getByRole("button", { name: /NeonDB/i }));
await user.type(screen.getByLabelText(/connection label/i), "Neon prod");
await user.type(screen.getByLabelText("Host"), "ep-x-pooler.eu.aws.neon.tech");
await user.click(screen.getByText("Save Connection"));
await waitFor(() => expect(createConnection).toHaveBeenCalledTimes(1));
expect(createConnection).toHaveBeenCalledWith(
expect.objectContaining({ name: "Neon prod", ssl_mode: "require" }),
);
});
it("SQLite tab swaps the URI input for a File Path input", async () => { it("SQLite tab swaps the URI input for a File Path input", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
render(<NewConnectionScreen />); render(<NewConnectionScreen />);
@@ -103,6 +170,20 @@ describe("NewConnectionScreen", () => {
expect(notify).toHaveBeenCalledWith("Connection successful", "success"); expect(notify).toHaveBeenCalledWith("Connection successful", "success");
}); });
it("tests a pasted URL with its SSL mode", async () => {
const user = userEvent.setup();
render(<NewConnectionScreen />);
await user.type(
screen.getByLabelText(/connection uri/i),
"postgresql://u:p@ep-x-pooler.eu.aws.neon.tech/db?sslmode=verify-full",
);
await user.click(screen.getByText("Test Connection"));
await waitFor(() => expect(testConnection).toHaveBeenCalledTimes(1));
expect(testConnection).toHaveBeenCalledWith(
expect.objectContaining({ ssl_mode: "verify-full" }),
);
});
it("invokes onCancel when Back is clicked", async () => { it("invokes onCancel when Back is clicked", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const onCancel = vi.fn(); const onCancel = vi.fn();
@@ -9,6 +9,8 @@ import { ProviderSetupGuide } from "./ProviderSetupGuide";
import { import {
parseConnectionString, parseConnectionString,
detectProviderFromHost, detectProviderFromHost,
suggestConnectionLabel,
type ParsedConnectionString,
} from "../../lib/connectionString"; } from "../../lib/connectionString";
import { getProviderById, type ProviderId } from "../../lib/providers"; import { getProviderById, type ProviderId } from "../../lib/providers";
import { validateConnectionInput } from "../../lib/utils"; import { validateConnectionInput } from "../../lib/utils";
@@ -53,6 +55,10 @@ function createEmptyForm(
database: null, database: null,
use_keychain: true, use_keychain: true,
ssh_password: null, ssh_password: null,
ssl_mode: null,
ssl_ca_path: null,
ssl_cert_path: null,
ssl_key_path: null,
}; };
} }
@@ -64,6 +70,40 @@ function getDefaultPort(dbType: string): number {
); );
} }
type ManagedPreset = "supabase" | "neon" | "planetscale" | null;
/** Managed-provider highlight for a parsed URL (Postgres/MySQL hosts only). */
function providerForParsed(parsed: ParsedConnectionString): ManagedPreset {
return parsed.db_type === "postgresql" || parsed.db_type === "mysql"
? detectProviderFromHost(parsed.host)
: null;
}
/**
* Everything a pasted connection string can populate, so the connection can be
* tested and saved with no manual cleanup: connection type, host, port,
* user/password/database, and the `?sslmode=` TLS mode.
*
* The URL's SSL mode wins when present; otherwise the mode the user already
* picked under SSH / SSL is kept. The label is left blank when the user has not
* typed one (the derived name is offered as a placeholder and used on save by
* `buildPayload`), so a suggestion never clobbers or gets appended to.
*/
function parsedFormPatch(
parsed: ParsedConnectionString,
prev: ConnectionFormData,
): Partial<ConnectionFormData> {
return {
db_type: parsed.db_type,
host: parsed.host,
port: parsed.port ?? getDefaultPort(parsed.db_type),
username: parsed.username,
password: parsed.password,
database: parsed.database,
ssl_mode: parsed.ssl_mode ?? prev.ssl_mode ?? null,
};
}
export function NewConnectionScreen({ export function NewConnectionScreen({
defaultFolderId = null, defaultFolderId = null,
prefilledConnectionString = "", prefilledConnectionString = "",
@@ -73,9 +113,7 @@ export function NewConnectionScreen({
onCancel, onCancel,
}: NewConnectionScreenProps) { }: NewConnectionScreenProps) {
const [stage, setStage] = useState<Stage>("entry"); const [stage, setStage] = useState<Stage>("entry");
const [managedPreset, setManagedPreset] = useState< const [managedPreset, setManagedPreset] = useState<ManagedPreset>(null);
"supabase" | "neon" | "planetscale" | null
>(null);
const [form, setForm] = useState<ConnectionFormData>(() => const [form, setForm] = useState<ConnectionFormData>(() =>
createEmptyForm( createEmptyForm(
defaultFolderId, defaultFolderId,
@@ -86,6 +124,12 @@ export function NewConnectionScreen({
const [saveLoading, setSaveLoading] = useState(false); const [saveLoading, setSaveLoading] = useState(false);
const createConnection = useConnectionStore((s) => s.createConnection); const createConnection = useConnectionStore((s) => s.createConnection);
const notify = useNotificationStore((s) => s.notify); const notify = useNotificationStore((s) => s.notify);
// Name to use when the label is left blank: derived from the pasted URL
// (database name, else host/file stem). Shown as the Connection Label's
// placeholder and applied at save/test time, so a suggestion never
// clobbers or gets appended to typed text.
const parsedUri = parseConnectionString(form.connection_string);
const suggestedName = parsedUri ? suggestConnectionLabel(parsedUri) : "";
const revealConfigured = useCallback( const revealConfigured = useCallback(
(updates: Partial<ConnectionFormData>) => { (updates: Partial<ConnectionFormData>) => {
@@ -97,26 +141,17 @@ export function NewConnectionScreen({
const handleConnectionStringChange = useCallback((value: string) => { const handleConnectionStringChange = useCallback((value: string) => {
const parsed = parseConnectionString(value); const parsed = parseConnectionString(value);
if (parsed) { if (!parsed) {
const provider =
parsed.db_type === "postgresql" || parsed.db_type === "mysql"
? detectProviderFromHost(parsed.host)
: null;
setManagedPreset(provider);
setForm((prev) => ({
...prev,
connection_string: value,
db_type: parsed.db_type,
host: parsed.host,
port: parsed.port ?? getDefaultPort(parsed.db_type),
username: parsed.username,
password: parsed.password,
database: parsed.database,
}));
} else {
setManagedPreset(null); setManagedPreset(null);
setForm((prev) => ({ ...prev, connection_string: value })); setForm((prev) => ({ ...prev, connection_string: value }));
return;
} }
setManagedPreset(providerForParsed(parsed));
setForm((prev) => ({
...prev,
connection_string: value,
...parsedFormPatch(parsed, prev),
}));
}, []); }, []);
useEffect(() => { useEffect(() => {
@@ -150,7 +185,15 @@ export function NewConnectionScreen({
? null ? null
: getDefaultPort(provider.dbType), : getDefaultPort(provider.dbType),
...(provider.dbType === "sqlite" ? { host: "" } : {}), ...(provider.dbType === "sqlite" ? { host: "" } : {}),
...(id === "planetscale" ? { ssl_mode: "verify-full" } : {}), // Managed providers refuse plaintext connections. Preselect
// their documented mode so Test/Save works on the first try.
// (PlanetScale/MySQL requires identity verification.)
...(id === "planetscale"
? { ssl_mode: "verify-full" as const }
: {}),
...(id === "supabase" || id === "neon"
? { ssl_mode: "require" as const }
: {}),
}); });
}, },
[revealConfigured], [revealConfigured],
@@ -166,22 +209,11 @@ export function NewConnectionScreen({
const value = updates.connection_string; const value = updates.connection_string;
const parsed = parseConnectionString(value); const parsed = parseConnectionString(value);
if (parsed) { if (parsed) {
const provider = setManagedPreset(providerForParsed(parsed));
parsed.db_type === "postgresql" || parsed.db_type === "mysql"
? detectProviderFromHost(parsed.host)
: null;
setManagedPreset(provider);
return { return {
...prev, ...prev,
...updates, ...updates,
db_type: parsed.db_type, ...parsedFormPatch(parsed, prev),
host: parsed.host,
port:
parsed.port ??
getDefaultPort(parsed.db_type),
username: parsed.username,
password: parsed.password,
database: parsed.database,
}; };
} }
return { ...prev, ...updates }; return { ...prev, ...updates };
@@ -194,7 +226,7 @@ export function NewConnectionScreen({
const buildPayload = useCallback((): ConnectionInput => { const buildPayload = useCallback((): ConnectionInput => {
return { return {
name: form.name, name: form.name.trim() || suggestedName,
db_type: form.db_type, db_type: form.db_type,
host: form.host, host: form.host,
port: form.port, port: form.port,
@@ -213,8 +245,12 @@ export function NewConnectionScreen({
ssh_private_key_path: form.ssh_private_key ?? null, ssh_private_key_path: form.ssh_private_key ?? null,
ssh_password: form.ssh_password ?? null, ssh_password: form.ssh_password ?? null,
ssh_passphrase: form.ssh_passphrase ?? null, ssh_passphrase: form.ssh_passphrase ?? null,
ssl_mode: form.ssl_mode ?? null,
ssl_ca_path: form.ssl_ca_path ?? null,
ssl_cert_path: form.ssl_cert_path ?? null,
ssl_key_path: form.ssl_key_path ?? null,
}; };
}, [form]); }, [form, suggestedName]);
const validate = useCallback((): string | null => { const validate = useCallback((): string | null => {
const result = validateConnectionInput(buildPayload()); const result = validateConnectionInput(buildPayload());
@@ -336,6 +372,7 @@ export function NewConnectionScreen({
<DetailedConnectionForm <DetailedConnectionForm
form={form} form={form}
onChange={updateForm} onChange={updateForm}
namePlaceholder={suggestedName || undefined}
/> />
</> </>
)} )}
@@ -1,5 +1,5 @@
import { describe, it, expect, vi } from "vitest"; import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react"; import { render, screen, fireEvent } from "@testing-library/react";
import { SslFields } from "./SslFields"; import { SslFields } from "./SslFields";
const notify = vi.fn(); const notify = vi.fn();
@@ -35,4 +35,33 @@ describe("SslFields", () => {
expect(screen.queryByLabelText("Client Certificate")).not.toBeInTheDocument(); expect(screen.queryByLabelText("Client Certificate")).not.toBeInTheDocument();
expect(screen.queryByLabelText("Client Key")).not.toBeInTheDocument(); expect(screen.queryByLabelText("Client Key")).not.toBeInTheDocument();
}); });
it("writes certificate paths under the names the backend persists", () => {
// Regression guard: these used to be ssl_ca_cert / ssl_client_cert /
// ssl_client_key, which the Rust ConnectionInput silently ignores — the
// connection was then saved with TLS disabled.
const onChange = vi.fn();
render(
<SslFields
values={{ ssl_mode: "verify-full", ssl_ca_path: "/tmp/ca.pem" }}
onChange={onChange}
/>,
);
expect(screen.getByLabelText("CA Certificate")).toHaveValue("/tmp/ca.pem");
fireEvent.change(screen.getByLabelText("CA Certificate"), {
target: { value: "/tmp/root.pem" },
});
expect(onChange).toHaveBeenLastCalledWith({ ssl_ca_path: "/tmp/root.pem" });
fireEvent.change(screen.getByLabelText("Client Certificate"), {
target: { value: "/tmp/client.pem" },
});
expect(onChange).toHaveBeenLastCalledWith({ ssl_cert_path: "/tmp/client.pem" });
fireEvent.change(screen.getByLabelText("Client Key"), {
target: { value: "/tmp/client.key" },
});
expect(onChange).toHaveBeenLastCalledWith({ ssl_key_path: "/tmp/client.key" });
});
}); });
@@ -55,14 +55,14 @@ export function SslFields({ values, onChange }: SslFieldsProps) {
<label className="block text-sm text-text mb-1.5">CA Certificate</label> <label className="block text-sm text-text mb-1.5">CA Certificate</label>
<div className="flex gap-2"> <div className="flex gap-2">
<Input <Input
value={(values.ssl_ca_cert as string) ?? ""} value={(values.ssl_ca_path as string) ?? ""}
onChange={(value) => onChange({ ssl_ca_cert: value })} onChange={(value) => onChange({ ssl_ca_path: value })}
placeholder="/path/to/ca-cert.pem" placeholder="/path/to/ca-cert.pem"
aria-label="CA Certificate" aria-label="CA Certificate"
/> />
<button <button
type="button" type="button"
onClick={() => handlePickFile("ssl_ca_cert")} onClick={() => handlePickFile("ssl_ca_path")}
className="px-4 py-2 rounded-full bg-surface border border-border text-sm text-text hover:bg-surface-raised transition-colors cursor-pointer" className="px-4 py-2 rounded-full bg-surface border border-border text-sm text-text hover:bg-surface-raised transition-colors cursor-pointer"
> >
Browse Browse
@@ -74,14 +74,14 @@ export function SslFields({ values, onChange }: SslFieldsProps) {
<label className="block text-sm text-text mb-1.5">Client Certificate</label> <label className="block text-sm text-text mb-1.5">Client Certificate</label>
<div className="flex gap-2"> <div className="flex gap-2">
<Input <Input
value={(values.ssl_client_cert as string) ?? ""} value={(values.ssl_cert_path as string) ?? ""}
onChange={(value) => onChange({ ssl_client_cert: value })} onChange={(value) => onChange({ ssl_cert_path: value })}
placeholder="/path/to/client-cert.pem" placeholder="/path/to/client-cert.pem"
aria-label="Client Certificate" aria-label="Client Certificate"
/> />
<button <button
type="button" type="button"
onClick={() => handlePickFile("ssl_client_cert")} onClick={() => handlePickFile("ssl_cert_path")}
className="px-4 py-2 rounded-full bg-surface border border-border text-sm text-text hover:bg-surface-raised transition-colors cursor-pointer" className="px-4 py-2 rounded-full bg-surface border border-border text-sm text-text hover:bg-surface-raised transition-colors cursor-pointer"
> >
Browse Browse
@@ -93,14 +93,14 @@ export function SslFields({ values, onChange }: SslFieldsProps) {
<label className="block text-sm text-text mb-1.5">Client Key</label> <label className="block text-sm text-text mb-1.5">Client Key</label>
<div className="flex gap-2"> <div className="flex gap-2">
<Input <Input
value={(values.ssl_client_key as string) ?? ""} value={(values.ssl_key_path as string) ?? ""}
onChange={(value) => onChange({ ssl_client_key: value })} onChange={(value) => onChange({ ssl_key_path: value })}
placeholder="/path/to/client-key.pem" placeholder="/path/to/client-key.pem"
aria-label="Client Key" aria-label="Client Key"
/> />
<button <button
type="button" type="button"
onClick={() => handlePickFile("ssl_client_key")} onClick={() => handlePickFile("ssl_key_path")}
className="px-4 py-2 rounded-full bg-surface border border-border text-sm text-text hover:bg-surface-raised transition-colors cursor-pointer" className="px-4 py-2 rounded-full bg-surface border border-border text-sm text-text hover:bg-surface-raised transition-colors cursor-pointer"
> >
Browse Browse
@@ -1,5 +1,6 @@
import type { DbType } from "../../lib/types"; import type { DbType } from "../../lib/types";
import type { Environment } from "./EnvironmentSelect"; import type { Environment } from "./EnvironmentSelect";
import type { SslMode } from "../../lib/connectionString";
export interface ConnectionFormData { export interface ConnectionFormData {
name: string; name: string;
@@ -24,4 +25,13 @@ export interface ConnectionFormData {
ssh_private_key?: string | null; ssh_private_key?: string | null;
ssh_password: string | null; ssh_password: string | null;
ssh_passphrase?: string | null; ssh_passphrase?: string | null;
// SSL/TLS fields. These must live on the form data (and survive into the
// submit payload) or the SSH/SSL tab's selections are silently dropped — the
// backend then connects with TLS disabled, which managed providers (Neon,
// Supabase, PlanetScale) reject with "connection is insecure".
// `null` = unset, which the backend treats as TLS disabled.
ssl_mode?: SslMode | null;
ssl_ca_path?: string | null;
ssl_cert_path?: string | null;
ssl_key_path?: string | null;
} }
@@ -10,18 +10,23 @@ const { updateConnection, loadAll } = vi.hoisted(() => ({
})); }));
vi.mock("../../stores/connectionStore", () => ({ vi.mock("../../stores/connectionStore", () => ({
useConnectionStore: (sel: (s: any) => any) => useConnectionStore: Object.assign(
sel({ updateConnection, loadAll, folders: [], tags: [] }), (sel: (s: any) => any) =>
sel({ updateConnection, loadAll, folders: [], tags: [] }),
{ getState: () => ({ getConnectionPassword: async () => "pw" }) },
),
})); }));
vi.mock("../../lib/commands", async (importOriginal) => { vi.mock("../../lib/commands", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../lib/commands")>(); const actual = await importOriginal<typeof import("../../lib/commands")>();
return { return {
...actual, ...actual,
updateConnection: vi.fn(), updateConnection: vi.fn(),
testConnection: vi.fn(), testConnection: vi.fn().mockResolvedValue({ ok: true }),
saveConnectionPassword: vi.fn(), saveConnectionPassword: vi.fn(),
saveConnectionSshPassword: vi.fn(), saveConnectionSshPassword: vi.fn(),
saveConnectionSshPassphrase: vi.fn(), saveConnectionSshPassphrase: vi.fn(),
getConnectionSshPassword: vi.fn().mockResolvedValue(null),
getConnectionSshPassphrase: vi.fn().mockResolvedValue(null),
deleteConnectionPassword: vi.fn(), deleteConnectionPassword: vi.fn(),
}; };
}); });
@@ -121,4 +126,62 @@ describe("EditConnectionModal", () => {
); );
expect(vi.mocked(commands.saveConnectionPassword)).not.toHaveBeenCalled(); expect(vi.mocked(commands.saveConnectionPassword)).not.toHaveBeenCalled();
}); });
it("persists the existing SSL settings on save", async () => {
render(
<EditConnectionModal
connection={{
...baseConn,
ssl_mode: "verify-full",
ssl_ca_path: "/tmp/ca.pem",
ssl_cert_path: "/tmp/client.pem",
ssl_key_path: "/tmp/client.key",
}}
open={true}
onClose={() => {}}
onSaved={() => {}}
/>
);
fireEvent.click(screen.getByRole("button", { name: /^save$/i }));
await waitFor(() => expect(vi.mocked(commands.updateConnection)).toHaveBeenCalled());
expect(vi.mocked(commands.updateConnection)).toHaveBeenCalledWith(
"c1",
expect.objectContaining({
ssl_mode: "verify-full",
ssl_ca_path: "/tmp/ca.pem",
ssl_cert_path: "/tmp/client.pem",
ssl_key_path: "/tmp/client.key",
}),
);
});
it("tests with the same SSL + SSH config it would save", async () => {
render(
<EditConnectionModal
connection={{
...baseConn,
ssl_mode: "require",
ssh_host: "jump.example.com",
ssh_port: 22,
ssh_user: "tunnel",
ssh_auth_method: "key",
ssh_private_key_path: "/tmp/id_ed25519",
}}
open={true}
onClose={() => {}}
onSaved={() => {}}
/>
);
fireEvent.click(screen.getByRole("button", { name: /^test$/i }));
await waitFor(() => expect(vi.mocked(commands.testConnection)).toHaveBeenCalled());
expect(vi.mocked(commands.testConnection)).toHaveBeenCalledWith(
expect.objectContaining({
ssl_mode: "require",
ssh_host: "jump.example.com",
ssh_user: "tunnel",
ssh_auth_method: "key",
ssh_private_key_path: "/tmp/id_ed25519",
}),
);
});
}); });
@@ -4,9 +4,9 @@ import { Button } from "../ui/Button";
import { DetailedConnectionForm } from "../connections/DetailedConnectionForm"; import { DetailedConnectionForm } from "../connections/DetailedConnectionForm";
import { useConnectionStore } from "../../stores/connectionStore"; import { useConnectionStore } from "../../stores/connectionStore";
import { useNotificationStore } from "../../stores/notificationStore"; import { useNotificationStore } from "../../stores/notificationStore";
import { updateConnection, testConnection, saveConnectionSshPassword, saveConnectionSshPassphrase } from "../../lib/commands"; import { updateConnection, testConnection, saveConnectionSshPassword, saveConnectionSshPassphrase, getConnectionSshPassword, getConnectionSshPassphrase } from "../../lib/commands";
import { persistDbPassword } from "../../lib/keychain"; import { persistDbPassword } from "../../lib/keychain";
import { detectProviderFromHost } from "../../lib/connectionString"; import { detectProviderFromHost, type SslMode } from "../../lib/connectionString";
import type { Connection, ConnectionInput } from "../../lib/types"; import type { Connection, ConnectionInput } from "../../lib/types";
import type { ConnectionFormData } from "../connections/connectionFormData"; import type { ConnectionFormData } from "../connections/connectionFormData";
@@ -47,6 +47,10 @@ export function EditConnectionModal({
ssh_private_key: connection.ssh_private_key_path ?? null, ssh_private_key: connection.ssh_private_key_path ?? null,
ssh_password: null, ssh_password: null,
ssh_passphrase: null, ssh_passphrase: null,
ssl_mode: (connection.ssl_mode as SslMode | null | undefined) ?? null,
ssl_ca_path: connection.ssl_ca_path ?? null,
ssl_cert_path: connection.ssl_cert_path ?? null,
ssl_key_path: connection.ssl_key_path ?? null,
})); }));
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false); const [testing, setTesting] = useState(false);
@@ -75,6 +79,10 @@ export function EditConnectionModal({
ssh_private_key_path: form.ssh_private_key ?? null, ssh_private_key_path: form.ssh_private_key ?? null,
ssh_password: form.ssh_password ?? null, ssh_password: form.ssh_password ?? null,
ssh_passphrase: form.ssh_passphrase ?? null, ssh_passphrase: form.ssh_passphrase ?? null,
ssl_mode: form.ssl_mode ?? null,
ssl_ca_path: form.ssl_ca_path ?? null,
ssl_cert_path: form.ssl_cert_path ?? null,
ssl_key_path: form.ssl_key_path ?? null,
}; };
const updated = await updateConnection(connection.id, input); const updated = await updateConnection(connection.id, input);
await persistDbPassword(connection.id, form.use_keychain, form.password).catch(() => {}); await persistDbPassword(connection.id, form.use_keychain, form.password).catch(() => {});
@@ -104,6 +112,18 @@ export function EditConnectionModal({
if (!password) { if (!password) {
password = await useConnectionStore.getState().getConnectionPassword(connection.id).catch(() => null); password = await useConnectionStore.getState().getConnectionPassword(connection.id).catch(() => null);
} }
// SSH secrets live in the keychain only, so a tunneled connection can be
// tested without re-entering them.
const sshPassword =
form.ssh_password ??
(form.ssh_host
? await getConnectionSshPassword(connection.id).catch(() => null)
: null);
const sshPassphrase =
form.ssh_passphrase ??
(form.ssh_host
? await getConnectionSshPassphrase(connection.id).catch(() => null)
: null);
const result = await testConnection({ const result = await testConnection({
name: form.name, name: form.name,
@@ -116,7 +136,19 @@ export function EditConnectionModal({
folder_id: form.folder_id, folder_id: form.folder_id,
environment: form.environment, environment: form.environment,
tag_ids: form.tag_ids, tag_ids: form.tag_ids,
ssh_password: form.ssh_password ?? null, // Test the same config Save would persist — a tunneled/SSL connection
// must be probed through its tunnel and TLS mode.
ssh_host: form.ssh_host ?? null,
ssh_port: form.ssh_port ?? null,
ssh_user: form.ssh_user ?? null,
ssh_auth_method: form.ssh_auth_method ?? null,
ssh_private_key_path: form.ssh_private_key ?? null,
ssh_password: sshPassword,
ssh_passphrase: sshPassphrase,
ssl_mode: form.ssl_mode ?? null,
ssl_ca_path: form.ssl_ca_path ?? null,
ssl_cert_path: form.ssl_cert_path ?? null,
ssl_key_path: form.ssl_key_path ?? null,
}); });
if (result.ok) { if (result.ok) {
notify("Connection successful", "success"); notify("Connection successful", "success");
+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.8.0)", () => { describe("tauri bundle config (v0.8.1)", () => {
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.8.0", () => { it("version is 0.8.1", () => {
expect(tauriConf.version).toBe("0.8.0"); expect(tauriConf.version).toBe("0.8.1");
}); });
}); });
+78 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { parseConnectionString, looksLikeConnectionString, detectProviderFromHost, buildConnectionUrl } from "./connectionString"; import { parseConnectionString, looksLikeConnectionString, detectProviderFromHost, buildConnectionUrl, suggestConnectionLabel } from "./connectionString";
import type { Connection } from "./types"; import type { Connection } from "./types";
function makeConn(overrides: Partial<Connection> = {}): Connection { function makeConn(overrides: Partial<Connection> = {}): Connection {
@@ -30,6 +30,7 @@ describe("parseConnectionString", () => {
username: "user", username: "user",
password: "pass", password: "pass",
database: "mydb", database: "mydb",
ssl_mode: null,
}); });
}); });
@@ -42,6 +43,7 @@ describe("parseConnectionString", () => {
username: "root", username: "root",
password: null, password: null,
database: "app", database: "app",
ssl_mode: null,
}); });
}); });
@@ -54,6 +56,7 @@ describe("parseConnectionString", () => {
username: "user", username: "user",
password: "pass", password: "pass",
database: "0", database: "0",
ssl_mode: null,
}); });
}); });
@@ -66,6 +69,7 @@ describe("parseConnectionString", () => {
username: null, username: null,
password: null, password: null,
database: null, database: null,
ssl_mode: null,
}); });
}); });
@@ -78,6 +82,7 @@ describe("parseConnectionString", () => {
username: null, username: null,
password: null, password: null,
database: null, database: null,
ssl_mode: null,
}); });
}); });
@@ -90,6 +95,7 @@ describe("parseConnectionString", () => {
username: "user", username: "user",
password: null, password: null,
database: "db", database: "db",
ssl_mode: null,
}); });
}); });
@@ -102,12 +108,51 @@ describe("parseConnectionString", () => {
username: "user", username: "user",
password: null, password: null,
database: "db", database: "db",
ssl_mode: "require",
}); });
}); });
it("returns null for an empty string", () => { it("returns null for an empty string", () => {
expect(parseConnectionString("")).toBeNull(); expect(parseConnectionString("")).toBeNull();
}); });
it("lifts sslmode from a Neon-style URL (extra params ignored)", () => {
const result = parseConnectionString(
"postgresql://neondb_owner:pw@ep-hidden-star-b3hqtp4x-pooler.c-4.ap-southeast-1.aws.neon.tech/neondb?sslmode=require&channel_binding=require",
);
expect(result).toMatchObject({
db_type: "postgresql",
host: "ep-hidden-star-b3hqtp4x-pooler.c-4.ap-southeast-1.aws.neon.tech",
username: "neondb_owner",
password: "pw",
database: "neondb",
ssl_mode: "require",
});
});
it("lifts every supported sslmode value", () => {
const mode = (q: string) => parseConnectionString(`postgresql://u@h/db?sslmode=${q}`)?.ssl_mode;
expect(mode("disable")).toBe("disable");
expect(mode("require")).toBe("require");
expect(mode("verify-ca")).toBe("verify-ca");
expect(mode("verify-full")).toBe("verify-full");
expect(mode("VERIFY-FULL")).toBe("verify-full");
expect(mode("verify_full")).toBe("verify-full");
});
it("ignores unsupported sslmode values instead of downgrading to plaintext", () => {
expect(parseConnectionString("postgresql://u@h/db?sslmode=allow")?.ssl_mode).toBeNull();
expect(parseConnectionString("postgresql://u@h/db?sslmode=prefer")?.ssl_mode).toBeNull();
expect(parseConnectionString("postgresql://u@h/db?sslmode=bogus")?.ssl_mode).toBeNull();
});
it("lifts MySQL's ssl-mode vocabulary and skips TLS for non-SQL db types", () => {
expect(parseConnectionString("mysql://u@h/db?ssl-mode=REQUIRED")?.ssl_mode).toBe("require");
expect(parseConnectionString("mysql://u@h/db?ssl-mode=verify-full")?.ssl_mode).toBe("verify-full");
expect(parseConnectionString("mysql://u@h/db?ssl-mode=VERIFY_IDENTITY")?.ssl_mode).toBe("verify-full");
expect(parseConnectionString("mysql://u@h/db?ssl-mode=PREFERRED")?.ssl_mode).toBeNull();
expect(parseConnectionString("redis://u@h?sslmode=require")?.ssl_mode).toBeNull();
expect(parseConnectionString("sqlite:///tmp/a.db?sslmode=require")?.ssl_mode).toBeNull();
});
it("returns null for a non-URL string", () => { it("returns null for a non-URL string", () => {
expect(parseConnectionString("hello world")).toBeNull(); expect(parseConnectionString("hello world")).toBeNull();
@@ -122,6 +167,7 @@ describe("parseConnectionString", () => {
username: "user", username: "user",
password: null, password: null,
database: "db", database: "db",
ssl_mode: null,
}); });
}); });
@@ -134,6 +180,7 @@ describe("parseConnectionString", () => {
username: "root", username: "root",
password: "pw", password: "pw",
database: "app", database: "app",
ssl_mode: null,
}); });
}); });
@@ -146,6 +193,7 @@ describe("parseConnectionString", () => {
username: "root", username: "root",
password: "pw", password: "pw",
database: "app", database: "app",
ssl_mode: null,
}); });
}); });
@@ -158,6 +206,7 @@ describe("parseConnectionString", () => {
username: null, username: null,
password: null, password: null,
database: null, database: null,
ssl_mode: null,
}); });
}); });
}); });
@@ -180,6 +229,33 @@ describe("looksLikeConnectionString", () => {
}); });
}); });
describe("suggestConnectionLabel", () => {
const label = (url: string) => {
const parsed = parseConnectionString(url)!;
return suggestConnectionLabel(parsed);
};
it("prefers the database name", () => {
expect(
label("postgresql://neondb_owner:pw@ep-x-pooler.c-4.ap-southeast-1.aws.neon.tech/neondb?sslmode=require"),
).toBe("neondb");
});
it("falls back to the host when there is no database", () => {
expect(label("postgresql://u@db.example.com:5432")).toBe("db.example.com");
});
it("uses the file stem for SQLite paths", () => {
expect(label("sqlite:///Users/me/data/app-demo.sqlite")).toBe("app-demo");
expect(label("file:///tmp/notes.db")).toBe("notes");
});
it("caps the label at the 100-char validation limit", () => {
const host = `${"a".repeat(120)}.example.com`;
expect(label(`postgresql://u@${host}/`).length).toBe(100);
});
});
describe("detectProviderFromHost", () => { describe("detectProviderFromHost", () => {
it("detects Supabase by host suffix", () => { it("detects Supabase by host suffix", () => {
expect(detectProviderFromHost("db.abcdefghijklmnopqrst.supabase.co")).toBe("supabase"); expect(detectProviderFromHost("db.abcdefghijklmnopqrst.supabase.co")).toBe("supabase");
@@ -291,6 +367,7 @@ describe("buildConnectionUrl", () => {
username: "user", username: "user",
password: "pass", password: "pass",
database: "mydb", database: "mydb",
ssl_mode: null,
}); });
}); });
}); });
+69
View File
@@ -7,6 +7,53 @@ export interface ParsedConnectionString {
username: string | null; username: string | null;
password: string | null; password: string | null;
database: string | null; database: string | null;
/** SSL mode lifted from the URL's `sslmode` / `ssl-mode` query param, or
* `null` when absent/unsupported. Keeps managed-PostgreSQL URLs (Neon,
* Supabase, …) working — they ship with `?sslmode=require` and the server
* rejects a plaintext connection with "connection is insecure". */
ssl_mode: SslMode | null;
}
/** SSL modes the backend understands (mirrors `DbConfig.ssl_mode`). */
export type SslMode = "disable" | "require" | "verify-ca" | "verify-full";
/** Alternate spellings used by other clients, mapped onto our four modes:
* MySQL's `ssl-mode` vocabulary (`REQUIRED`, `VERIFY_IDENTITY`, …) and
* plural forms. `allow` / `prefer` are intentionally absent — they permit a
* plaintext fallback, which our mode set cannot express, so they return
* `null` and the caller keeps its current/default mode. */
const SSL_MODE_ALIASES: Record<string, SslMode> = {
disable: "disable",
disabled: "disable",
require: "require",
required: "require",
"verify-ca": "verify-ca",
"verify-full": "verify-full",
// MySQL's strongest mode: chain + hostname verification.
"verify-identity": "verify-full",
};
/**
* Extract the TLS mode from a connection URL's query string.
*
* Accepts libpq's `sslmode` (PostgreSQL) and MySQL's `ssl-mode` spelling,
* case-insensitively and hyphen/underscore-insensitively (`verify-full`,
* `verify_full`, `VERIFY-FULL`, …). Unsupported values (`allow`, `prefer`)
* return `null` so the caller keeps its current/default mode rather than
* silently downgrading to plaintext.
*/
function parseSslMode(url: URL): SslMode | null {
let raw: string | null = null;
for (const [key, value] of url.searchParams) {
const k = key.toLowerCase();
if (k === "sslmode" || k === "ssl-mode") {
raw = value;
break;
}
}
if (!raw) return null;
const normalized = raw.trim().toLowerCase().replace(/_/g, "-");
return SSL_MODE_ALIASES[normalized] ?? null;
} }
const DB_PROTOCOLS: Record<string, DbType> = { const DB_PROTOCOLS: Record<string, DbType> = {
@@ -68,6 +115,9 @@ export function parseConnectionString(input: string): ParsedConnectionString | n
username, username,
password, password,
database, database,
ssl_mode: db_type === "postgresql" || db_type === "mysql" || db_type === "mariadb"
? parseSslMode(url)
: null,
}; };
} }
@@ -95,6 +145,25 @@ export function detectProviderFromHost(
return null; return null;
} }
/**
* Suggest a connection label for a freshly pasted connection string, so the
* connection can be saved without typing a name first.
*
* Preference order: the database name, then (for SQLite) the file stem, then
* the host. Capped at 100 chars to satisfy `validateConnectionInput`, which
* rejects longer names.
*/
export function suggestConnectionLabel(parsed: ParsedConnectionString): string {
let label: string;
if (parsed.db_type === "sqlite") {
const file = parsed.host.split(/[\\/]/).pop() ?? "";
label = file.replace(/\.(sqlite3?|db)$/i, "") || file;
} else {
label = parsed.database?.replace(/^\/+/, "") || parsed.host;
}
return label.trim().slice(0, 100);
}
/** /**
* Build a connection URL string from a saved connection, for copying into * Build a connection URL string from a saved connection, for copying into
* env vars / other tools. `password` is the decrypted keychain value (or * env vars / other tools. `password` is the decrypted keychain value (or
+9 -9
View File
@@ -5,7 +5,7 @@ import agents from "../../../AGENTS.md?raw";
import readme from "../../../README.md?raw"; import readme from "../../../README.md?raw";
import landing from "../../../www/public/landing.js?raw"; import landing from "../../../www/public/landing.js?raw";
describe("v0.8.0 docs coverage", () => { describe("v0.8.1 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 \| ✅/);
@@ -25,8 +25,8 @@ describe("v0.8.0 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.8.0", () => { it("README declares v0.8.1", () => {
expect(readme).toContain("0.8.0"); expect(readme).toContain("0.8.1");
}); });
it("AGENTS.md marks schema CRUD complete", () => { it("AGENTS.md marks schema CRUD complete", () => {
expect(agents).toMatch(/Schema CRUD \| ✅/); expect(agents).toMatch(/Schema CRUD \| ✅/);
@@ -65,12 +65,12 @@ describe("v0.8.0 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.8.0 assets in both download tables", () => { it("README links to v0.8.1 assets in both download tables", () => {
expect(readme).toContain("releases/download/v0.8.0/"); expect(readme).toContain("releases/download/v0.8.1/");
expect(readme).toContain("Gridline_0.8.0_aarch64.dmg"); expect(readme).toContain("Gridline_0.8.1_aarch64.dmg");
expect(readme).toContain("Gridline-0.8.0-1.x86_64.rpm"); expect(readme).toContain("Gridline-0.8.1-1.x86_64.rpm");
}); });
it("landing.js platform download links point to v0.8.0", () => { it("landing.js platform download links point to v0.8.1", () => {
expect(landing).toContain('const version = "0.8.0"'); expect(landing).toContain('const version = "0.8.1"');
}); });
}); });
+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.8.0 across the app shell", () => { it("declares v0.8.1 across the app shell", () => {
expect(pkg.version).toBe("0.8.0"); expect(pkg.version).toBe("0.8.1");
}); });
}); });
+6 -6
View File
@@ -1,9 +1,9 @@
# Homebrew Cask formula for Gridline. # Homebrew Cask formula for Gridline.
# #
# This file is the template; `scripts/render-cask.sh` substitutes # This file is the template; `scripts/render-cask.sh` substitutes the version
# @VERSION@ / @ARM_SHA256@ / @INTEL_SHA256@ on each release and pushes the # and the per-arch SHA-256 checksums on each release and pushes the rendered
# rendered file to the `AdrianBonpin/homebrew-gridline` tap as # file to the `AdrianBonpin/homebrew-gridline` tap as `Casks/gridline.rb`.
# `Casks/gridline.rb`. End users then install with # End users then install with
# #
# brew install --cask AdrianBonpin/gridline/gridline # brew install --cask AdrianBonpin/gridline/gridline
# #
@@ -21,10 +21,10 @@ cask "gridline" do
sha256 arm: "@ARM_SHA256@", sha256 arm: "@ARM_SHA256@",
intel: "@INTEL_SHA256@" intel: "@INTEL_SHA256@"
url "https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v#{version}/Gridline_#{version}_#{arch}.dmg" url "https://github.com/AdrianBonpin/gridline/releases/download/v#{version}/Gridline_#{version}_#{arch}.dmg"
name "Gridline" name "Gridline"
desc "Open-source, cross-platform database GUI client for PostgreSQL" desc "Open-source, cross-platform database GUI client for PostgreSQL"
homepage "https://git.ranio.xyz/adrianbonpin/gridline" homepage "https://github.com/AdrianBonpin/gridline"
depends_on macos: :monterey depends_on macos: :monterey
+31
View File
@@ -197,6 +197,37 @@ for APP in \
|| echo " (spctl assessment pending — normal right after notarization; stapler validate passed)" || echo " (spctl assessment pending — normal right after notarization; stapler validate passed)"
done done
# ---- notarize + staple the DMGs --------------------------------------------
# Notarizing the .app is not enough. When a user opens the downloaded DMG,
# Gatekeeper assesses the DMG itself — and an unnotarized DMG is rejected as
# "Unnotarized Developer ID" even though the app inside it is fine. Submit each
# DMG to the same notary service and staple the ticket into the DMG.
if [ "${GRIDLINE_SKIP_NOTARIZE:-0}" = "1" ]; then
echo ">> Skipping DMG notarization (GRIDLINE_SKIP_NOTARIZE=1)."
else
API_KEY_PATH="${APPLE_API_KEY_PATH:-${HOME}/.appstoreconnect/private_keys/AuthKey_${APPLE_API_KEY}.p8}"
[ -f "$API_KEY_PATH" ] || {
echo "ERROR: notarization API key not found at $API_KEY_PATH" >&2
echo " Set APPLE_API_KEY_PATH in ${NOTARIZE_ENV} or place the .p8 at the default location." >&2
exit 1
}
for DMG in \
"${BUNDLE_ARM}/dmg/Gridline_${VERSION}_aarch64.dmg" \
"${BUNDLE_INTEL}/dmg/Gridline_${VERSION}_x64.dmg"; do
[ -f "$DMG" ] || { echo "missing $DMG" >&2; exit 1; }
echo ">> Notarizing $(basename "$DMG") (this can take a few minutes)"
xcrun notarytool submit "$DMG" \
--key "$API_KEY_PATH" \
--key-id "$APPLE_API_KEY" \
--issuer "$APPLE_API_ISSUER" \
--wait
xcrun stapler staple "$DMG" \
|| { echo "failed to staple $(basename "$DMG")" >&2; exit 1; }
xcrun stapler validate "$DMG" \
|| { echo "notarization ticket not stapled to $(basename "$DMG")" >&2; exit 1; }
done
fi
# ---- create/ensure release + upload DMGs ----------------------------------- # ---- create/ensure release + upload DMGs -----------------------------------
# Use `tea` for release management — it handles OAuth token refresh, unlike a # Use `tea` for release management — it handles OAuth token refresh, unlike a
# raw curl with a token from the git credential store (which can 401). # raw curl with a token from the git credential store (which can 401).
+3 -3
View File
@@ -59,12 +59,12 @@
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://git.ranio.xyz/adrianbonpin/gridline/releases"; const releasesUrl = "https://github.com/AdrianBonpin/gridline/releases";
const version = "0.8.0"; const version = "0.8.1";
if (isMobile) { if (isMobile) {
downloadBtn.href = releasesUrl; downloadBtn.href = releasesUrl;
if (!keepLabel) downloadBtn.textContent = "Get it on Gitea"; if (!keepLabel) downloadBtn.textContent = "Get it on GitHub";
} 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";
+10 -1
View File
@@ -5,6 +5,15 @@ export interface ChangelogEntry {
} }
export const changelogEntries: ChangelogEntry[] = [ export const changelogEntries: ChangelogEntry[] = [
{
version: "0.8.1",
date: "2026-09-24",
highlights: [
"Managed-provider connections fixed — pasting a Neon / Supabase / PlanetScale connection string now carries its `?sslmode=` into the saved connection (it was silently dropped, so TLS stayed off and Neon answered `connection is insecure (try using sslmode=require)`). The SSH / SSL tab's mode and certificate paths are now persisted on create *and* edit, and the managed presets pre-select their TLS mode (Supabase / Neon → Require, PlanetScale → Verify Full). As a safety net, hosts under `*.neon.tech` / `*.supabase.co` / `*.psdb.cloud` default to `require` when a connection has no stored mode, so connections saved before this fix keep working.",
"Pasting a connection URI fills every setting it can — type, host, port, user, password, database and TLS mode — and suggests a name from the URL (database name, else host), which Test and Save use when the label is left blank. A pasted URL can now be tested and saved with no retyping.",
"macOS keychain prompts now self-heal — an item whose ACL still points at an older binary path (the pre-monorepo `src-tauri/` layout, a reinstalled app, an ad-hoc dev build) made macOS ask for your keychain password on *every* access, once per secret, with no way to make it stick. The app now checks the item's ACL (silently) before reading and repairs it when needed, so you authorize a secret once and it stays silent afterwards.",
],
},
{ {
version: "0.8.0", version: "0.8.0",
date: "2026-09-09", date: "2026-09-09",
@@ -32,7 +41,7 @@ export const changelogEntries: ChangelogEntry[] = [
date: "2026-09-04", date: "2026-09-04",
highlights: [ 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).", "No more repeated macOS keychain prompts — saved connection passwords and SSH secrets are now ACL-pinned to the app's code signature when stored, so app upgrades never re-trigger the macOS keychain access dialog (users upgrading from older builds see one final prompt — choose Always Allow).",
"Install via Homebrew — add the Gitea tap (brew tap AdrianBonpin/gridline https://git.ranio.xyz/adrianbonpin/homebrew-gridline.git) and run brew install --cask gridline.", "Install via Homebrew — add the Homebrew tap (brew tap AdrianBonpin/gridline https://github.com/AdrianBonpin/homebrew-gridline.git) and run brew install --cask gridline.",
], ],
}, },
{ {
+2 -2
View File
@@ -2,8 +2,8 @@ import { describe, it, expect } from "vitest";
import { faqItems } from "./faq"; import { faqItems } from "./faq";
describe("faqItems", () => { describe("faqItems", () => {
it("has exactly 5 items", () => { it("has exactly 6 items", () => {
expect(faqItems).toHaveLength(5); expect(faqItems).toHaveLength(6);
}); });
it("every item has a non-empty question and answer", () => { it("every item has a non-empty question and answer", () => {
+6 -6
View File
@@ -40,9 +40,9 @@ const isProd = import.meta.env.PROD;
<link rel="manifest" href="/site.webmanifest" /> <link rel="manifest" href="/site.webmanifest" />
<link rel="preload" as="image" href="/og.png" /> <link rel="preload" as="image" href="/og.png" />
<link rel="preconnect" href="https://stat.ranio.xyz" crossorigin /> <link rel="preconnect" href="https://stat.ranio.xyz" crossorigin />
<link rel="preconnect" href="https://git.ranio.xyz" /> <link rel="preconnect" href="https://github.com" />
<link rel="dns-prefetch" href="https://stat.ranio.xyz" /> <link rel="dns-prefetch" href="https://stat.ranio.xyz" />
<link rel="dns-prefetch" href="https://git.ranio.xyz" /> <link rel="dns-prefetch" href="https://github.com" />
<meta property="og:title" content={title} /> <meta property="og:title" content={title} />
<meta property="og:description" content={description} /> <meta property="og:description" content={description} />
@@ -104,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://git.ranio.xyz/adrianbonpin/gridline" target="_blank" rel="noopener" class="nav-link">Gitea</a> <a href="https://github.com/AdrianBonpin/gridline" target="_blank" rel="noopener" class="nav-link">GitHub</a>
<a href="/#download" data-download data-download-label class="nav-link">Download</a> <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>
@@ -139,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://git.ranio.xyz/adrianbonpin/gridline" target="_blank" rel="noopener" class="footer-link">Gitea</a></li> <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/issues" target="_blank" rel="noopener" class="footer-link">Issues</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/src/branch/prod/ROADMAP.md" target="_blank" rel="noopener" class="footer-link">Roadmap</a></li> <li><a href="https://github.com/AdrianBonpin/gridline/blob/prod/ROADMAP.md" target="_blank" rel="noopener" class="footer-link">Roadmap</a></li>
</ul> </ul>
</div> </div>
<div> <div>
+3 -3
View File
@@ -20,7 +20,7 @@ describe("seo builders", () => {
applicationCategory: "DeveloperApplication", applicationCategory: "DeveloperApplication",
offersPrice: 0, offersPrice: 0,
offersCurrency: "USD", offersCurrency: "USD",
downloadUrl: "https://git.ranio.xyz/adrianbonpin/gridline/releases", downloadUrl: "https://github.com/AdrianBonpin/gridline/releases",
}); });
expect(ld["@type"]).toBe("SoftwareApplication"); expect(ld["@type"]).toBe("SoftwareApplication");
expect(ld.name).toBe("Gridline"); expect(ld.name).toBe("Gridline");
@@ -43,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://git.ranio.xyz/adrianbonpin/gridline"], sameAs: ["https://github.com/AdrianBonpin/gridline"],
}); });
expect(ld["@type"]).toBe("Organization"); expect(ld["@type"]).toBe("Organization");
expect(ld.sameAs).toContain("https://git.ranio.xyz/adrianbonpin/gridline"); expect(ld.sameAs).toContain("https://github.com/AdrianBonpin/gridline");
}); });
it("buildWebSiteJsonLd includes SearchAction", () => { it("buildWebSiteJsonLd includes SearchAction", () => {
+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://git.ranio.xyz/adrianbonpin/gridline/releases" target="_blank" rel="noopener" class="text-[var(--primary)] hover:text-[var(--primary-hover)]"> <a href="https://github.com/AdrianBonpin/gridline/releases" target="_blank" rel="noopener" class="text-[var(--primary)] hover:text-[var(--primary-hover)]">
Gitea Releases GitHub Releases
</a>. </a>.
</p> </p>
</div> </div>
+8 -8
View File
@@ -15,7 +15,7 @@ import queryEditor from "../assets/screenshots/query-editor.png";
import erDiagram from "../assets/screenshots/er-diagram.png"; import 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.8.0"; const version = "0.8.1";
const site = "https://getgridline.app"; const site = "https://getgridline.app";
const jsonLd = [ const jsonLd = [
buildSoftwareApplicationJsonLd({ buildSoftwareApplicationJsonLd({
@@ -28,7 +28,7 @@ const jsonLd = [
applicationCategory: "DeveloperApplication", applicationCategory: "DeveloperApplication",
offersPrice: 0, offersPrice: 0,
offersCurrency: "USD", offersCurrency: "USD",
downloadUrl: "https://git.ranio.xyz/adrianbonpin/gridline/releases", downloadUrl: "https://github.com/AdrianBonpin/gridline/releases",
}), }),
buildFaqJsonLd(faqItems), buildFaqJsonLd(faqItems),
buildOrganizationJsonLd({ buildOrganizationJsonLd({
@@ -36,7 +36,7 @@ const jsonLd = [
url: `${site}/`, url: `${site}/`,
logo: `${site}/gridline-icon.svg`, logo: `${site}/gridline-icon.svg`,
sameAs: [ sameAs: [
"https://git.ranio.xyz/adrianbonpin/gridline", "https://github.com/AdrianBonpin/gridline",
"https://x.com/AdrianBonpin", "https://x.com/AdrianBonpin",
], ],
}), }),
@@ -73,11 +73,11 @@ const jsonLd = [
Query, explore, sync, and edit PostgreSQL, MySQL, SQLite, and Redis, with no paywalls, no tab limits, no account required. 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://git.ranio.xyz/adrianbonpin/gridline/releases" class="primary-btn" data-download> <a id="download" href="https://github.com/AdrianBonpin/gridline/releases" class="primary-btn" data-download>
Download Download
</a> </a>
<a href="https://git.ranio.xyz/adrianbonpin/gridline" target="_blank" rel="noopener" class="ghost-btn"> <a href="https://github.com/AdrianBonpin/gridline" target="_blank" rel="noopener" class="ghost-btn">
View on Gitea View on GitHub
</a> </a>
</div> </div>
</div> </div>
@@ -181,8 +181,8 @@ const jsonLd = [
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>
<div class="flex flex-wrap items-center justify-center gap-3"> <div class="flex flex-wrap items-center justify-center gap-3">
<a href="https://git.ranio.xyz/adrianbonpin/gridline" target="_blank" rel="noopener" class="ghost-btn"> <a href="https://github.com/AdrianBonpin/gridline" target="_blank" rel="noopener" class="ghost-btn">
Star on Gitea Star on GitHub
</a> </a>
<a href="https://ko-fi.com/adrianbonpin" target="_blank" rel="noopener" class="ghost-btn"> <a href="https://ko-fi.com/adrianbonpin" target="_blank" rel="noopener" class="ghost-btn">
☕ Support on Ko-fi ☕ Support on Ko-fi