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