Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd6cc7fde2 | ||
|
|
7cde902978 | ||
|
|
67f3532938 | ||
|
|
1d8dfce99a | ||
|
|
8bcdc0888f | ||
|
|
fd451d219d | ||
|
|
a86f5f7199 | ||
|
|
2fccb7b405 | ||
|
|
0efabbdc2e | ||
|
|
294e72e6ff | ||
|
|
15f4dc9bac | ||
|
|
867df98958 |
@@ -244,7 +244,7 @@ Planned work is prioritized in the [Project Roadmap](./ROADMAP.md) (source of tr
|
||||
| DB Viewer: SQLite browse + query | ✅ | Full support via rusqlite |
|
||||
| DB Viewer: 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 |
|
||||
| Password storage in OS keychain | ✅ | macOS Keychain, Linux Secret Service, Windows Credential Manager; macOS items get an explicit `SecAccess` ACL pinning them to the app's code-signature requirement on every save (`keychain_acl.rs`) — items survive re-signs/upgrades without keychain permission prompts |
|
||||
| Enable keychain toggle | ✅ | Default ON (opt-out); OFF = don't persist the DB password (session-only, re-prompt on connect) + purge existing keychain entry; SSH secrets stay keychain-only (v0.7.6) |
|
||||
| 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 |
|
||||
@@ -290,7 +290,7 @@ Planned work is prioritized in the [Project Roadmap](./ROADMAP.md) (source of tr
|
||||
| Smart default sort | ✅ | 12-tier priority: updated_at → created_at → *_at → *_id → seq/rank/version |
|
||||
| Data grid pagination | ✅ | Page nav, page size selector persisted in settings |
|
||||
| Column filtering (server-side) | ✅ | eq, neq, contains, starts, ends, gt, lt, null, notnull pushed to SQL WHERE |
|
||||
| Visual filter builder | ✅ | Drag-and-drop column palette (@dnd-kit) with type-aware operators (textish → contains, else eq), AND semantics, persists in tab `filterRules` |
|
||||
| Visual filter builder | ✅ | Column Filters popover with a "+ Add filter" row editor (column / operator / value selects, per-rule remove, AND semantics, persists in tab `filterRules`). Free-text values are debounced (400ms) so a query only fires after the user stops typing; column/operator changes apply immediately. The filter button is a plain toggle (open when closed, close when open) |
|
||||
| Column sorting (server-side) | ✅ | Multi-column asc/desc pushed to SQL ORDER BY |
|
||||
| Column show/hide | ✅ | Toggle visibility per column |
|
||||
| Column resize (drag handle) | ✅ | Double-click to auto-fit |
|
||||
|
||||
@@ -30,22 +30,25 @@
|
||||
|
||||
## Download
|
||||
|
||||
Grab the installer for your OS from the [latest release](https://git.ranio.xyz/adrianbonpin/gridline/releases/latest) — the links below point at the current release (**v0.7.13**):
|
||||
Grab the installer for your OS from the [latest release](https://git.ranio.xyz/adrianbonpin/gridline/releases/latest) — the links below point at the current release (**v0.7.14**):
|
||||
|
||||
| OS | Architecture | Download |
|
||||
| :--------------------------- | :--------------------------- | :----------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **macOS** | Apple Silicon (M1/M2/M3/M4…) | [Gridline_0.7.13_aarch64.dmg](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.13/Gridline_0.7.13_aarch64.dmg) |
|
||||
| **macOS** | Intel | [Gridline_0.7.13_x64.dmg](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.13/Gridline_0.7.13_x64.dmg) |
|
||||
| **Windows** | x64 | [Gridline_0.7.13_x64-setup.exe](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.13/Gridline_0.7.13_x64-setup.exe) |
|
||||
| **Debian / Ubuntu** | amd64 | [Gridline_0.7.13_amd64.deb](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.13/Gridline_0.7.13_amd64.deb) |
|
||||
| **Fedora / RHEL / openSUSE** | x86_64 | [Gridline-0.7.13-1.x86_64.rpm](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.13/Gridline-0.7.13-1.x86_64.rpm) |
|
||||
| **Other Linux** | amd64 | [Gridline_0.7.13_amd64.AppImage](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.13/Gridline_0.7.13_amd64.AppImage) |
|
||||
| **macOS** | Apple Silicon (M1/M2/M3/M4…) | [Gridline_0.7.14_aarch64.dmg](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.14/Gridline_0.7.14_aarch64.dmg) |
|
||||
| **macOS** | Intel | [Gridline_0.7.14_x64.dmg](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.14/Gridline_0.7.14_x64.dmg) |
|
||||
| **Windows** | x64 | [Gridline_0.7.14_x64-setup.exe](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.14/Gridline_0.7.14_x64-setup.exe) |
|
||||
| **Debian / Ubuntu** | amd64 | [Gridline_0.7.14_amd64.deb](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.14/Gridline_0.7.14_amd64.deb) |
|
||||
| **Fedora / RHEL / openSUSE** | x86_64 | [Gridline-0.7.14-1.x86_64.rpm](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.14/Gridline-0.7.14-1.x86_64.rpm) |
|
||||
| **Other Linux** | amd64 | [Gridline_0.7.14_amd64.AppImage](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.14/Gridline_0.7.14_amd64.AppImage) |
|
||||
|
||||
**macOS via Homebrew** (recommended for Mac users):
|
||||
|
||||
```bash
|
||||
brew install --cask AdrianBonpin/gridline/gridline
|
||||
# or, after a one-time `brew tap AdrianBonpin/gridline`:
|
||||
# one-time setup (the tap is hosted on Gitea, not GitHub):
|
||||
brew tap AdrianBonpin/gridline https://git.ranio.xyz/adrianbonpin/homebrew-gridline.git
|
||||
brew trust adrianbonpin/gridline
|
||||
|
||||
# install / upgrade:
|
||||
brew install --cask gridline
|
||||
```
|
||||
|
||||
@@ -54,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.7.13
|
||||
MAINTENANCE: These links are STATIC (versioned) — they point at the v0.7.14
|
||||
release assets, not at a moving "latest" target. On every new release,
|
||||
update BOTH tables here (Download + Which file should I download?) to the
|
||||
new version's asset names, which are tauri-action's default naming:
|
||||
@@ -99,6 +102,8 @@ Gridline is built for developers and small teams who manage multiple database en
|
||||
<details>
|
||||
<summary>View the full changelog</summary>
|
||||
|
||||
- **v0.7.14** — **No more repeated macOS keychain prompts**: saved connection passwords and SSH secrets are now ACL-pinned to the app's code signature on every save (`keychain_acl`), so app upgrades never re-trigger the macOS keychain access dialog (users upgrading from pre-signing builds see one final prompt — choose **Always Allow**). **Install via Homebrew**: `brew tap AdrianBonpin/gridline https://git.ranio.xyz/adrianbonpin/homebrew-gridline.git` then `brew install --cask gridline`; version bump.
|
||||
|
||||
- **v0.7.13** — **macOS is now Developer-ID signed and notarized**: the app is properly code-signed with a Developer ID Application certificate and notarized by Apple, so macOS opens it without the Gatekeeper "Open Anyway" prompt or the `xattr` quarantine workaround. The release script (`scripts/release-mac.sh`) auto-detects the signing identity from the keychain, notarizes + staples both architectures, and verifies the result before uploading; version bump.
|
||||
|
||||
- **v0.7.12** — **Reliable cell editing for non-text columns**: editing a PostgreSQL cell whose type isn't `text` (integers, booleans, UUIDs, `json`/`jsonb`, timestamps, enums, numerics, …) previously failed with a Rust serialization error; edited values are now sent to PostgreSQL in text wire format so the server parses them into the column's own type. **Recent connections fix**: the Recent strip no longer renders empty on first launch when the connections list hadn't loaded yet. **Full CI release pipeline for Windows**: self-hosted Windows runner builds the `.exe`/`.msi` installers (bundled `pg_dump`/`pg_restore`/`psql` + MariaDB clients) and uploads them to the release alongside Linux; version bump.
|
||||
@@ -333,16 +338,16 @@ Gridline's macOS builds are **Developer-ID signed and notarized**, so macOS open
|
||||
|
||||
#### Which file should I download?
|
||||
|
||||
Each release contains **one file per platform** — you only need the one that matches your computer. The links below point at the current release (**v0.7.13**):
|
||||
Each release contains **one file per platform** — you only need the one that matches your computer. The links below point at the current release (**v0.7.14**):
|
||||
|
||||
| Your system | Download this | Notes |
|
||||
| :------------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------- | :--------------------------------------------------------------- |
|
||||
| macOS **Apple Silicon** (M1/M2/M3/M4…) | [Gridline_0.7.13_aarch64.dmg](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.13/Gridline_0.7.13_aarch64.dmg) | `aarch64` = Apple's own chip |
|
||||
| macOS **Intel** | [Gridline_0.7.13_x64.dmg](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.13/Gridline_0.7.13_x64.dmg) | `x64` = Intel/AMD |
|
||||
| **Windows** (most PCs) | [Gridline_0.7.13_x64-setup.exe](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.13/Gridline_0.7.13_x64-setup.exe) | The `.msi` is an alternate installer (for enterprises/IT admins) |
|
||||
| **Debian / Ubuntu** | [Gridline_0.7.13_amd64.deb](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.13/Gridline_0.7.13_amd64.deb) | Install: `sudo apt install ./Gridline_0.7.13_amd64.deb` |
|
||||
| **Fedora / RHEL / openSUSE** | [Gridline-0.7.13-1.x86_64.rpm](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.13/Gridline-0.7.13-1.x86_64.rpm) | Install: `sudo dnf install Gridline-0.7.13-1.x86_64.rpm` |
|
||||
| **Any other Linux** | [Gridline_0.7.13_amd64.AppImage](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.13/Gridline_0.7.13_amd64.AppImage) | Works on every distro: `chmod +x` the file, then double-click it |
|
||||
| macOS **Apple Silicon** (M1/M2/M3/M4…) | [Gridline_0.7.14_aarch64.dmg](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.14/Gridline_0.7.14_aarch64.dmg) | `aarch64` = Apple's own chip |
|
||||
| macOS **Intel** | [Gridline_0.7.14_x64.dmg](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.14/Gridline_0.7.14_x64.dmg) | `x64` = Intel/AMD |
|
||||
| **Windows** (most PCs) | [Gridline_0.7.14_x64-setup.exe](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.14/Gridline_0.7.14_x64-setup.exe) | The `.msi` is an alternate installer (for enterprises/IT admins) |
|
||||
| **Debian / Ubuntu** | [Gridline_0.7.14_amd64.deb](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.14/Gridline_0.7.14_amd64.deb) | Install: `sudo apt install ./Gridline_0.7.14_amd64.deb` |
|
||||
| **Fedora / RHEL / openSUSE** | [Gridline-0.7.14-1.x86_64.rpm](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.14/Gridline-0.7.14-1.x86_64.rpm) | Install: `sudo dnf install Gridline-0.7.14-1.x86_64.rpm` |
|
||||
| **Any other Linux** | [Gridline_0.7.14_amd64.AppImage](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.14/Gridline_0.7.14_amd64.AppImage) | Works on every distro: `chmod +x` the file, then double-click it |
|
||||
|
||||
**Not sure if your Mac is Intel or Apple Silicon?** Click the **Apple menu** → **About This Mac**. If it shows "Apple M1/M2/M3/M4…" download the `aarch64` file; if it shows an Intel chip, download `x64`. Downloading the wrong one won't run.
|
||||
|
||||
@@ -352,8 +357,8 @@ Cutting a release is one command — CI builds everything. **Releases are cut fr
|
||||
|
||||
```bash
|
||||
git checkout prod && git pull
|
||||
git tag v0.7.13
|
||||
git push origin v0.7.13
|
||||
git tag v0.7.14
|
||||
git push origin v0.7.14
|
||||
```
|
||||
|
||||
Gitea Actions (`.gitea/workflows/release.yml`) builds the **Linux and Windows** installers on the self-hosted runners, then opens a **draft release** on the [Releases](https://git.ranio.xyz/adrianbonpin/gridline/releases) page. **macOS is built manually** on the dev Mac and its DMGs uploaded to the same release via `scripts/release-mac.sh`. Review the draft and hit **Publish release**.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "gridline-desktop",
|
||||
"private": true,
|
||||
"version": "0.7.13",
|
||||
"version": "0.7.14",
|
||||
"description": "An open-source, high-performance database GUI client for PostgreSQL and beyond",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
Generated
+4
-1
@@ -1783,10 +1783,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridline"
|
||||
version = "0.7.12"
|
||||
version = "0.7.14"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"chrono",
|
||||
"core-foundation 0.10.1",
|
||||
"deadpool-postgres",
|
||||
"indexmap 2.14.0",
|
||||
"redis",
|
||||
@@ -1795,6 +1796,8 @@ dependencies = [
|
||||
"rustls-native-certs",
|
||||
"rustls-pemfile 2.2.0",
|
||||
"rustls-pki-types",
|
||||
"security-framework 3.7.0",
|
||||
"security-framework-sys",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sqlx",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "gridline"
|
||||
version = "0.7.13"
|
||||
version = "0.7.14"
|
||||
description = "An open-source, high-performance database GUI client for PostgreSQL and beyond"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
@@ -49,3 +49,12 @@ rustls-pemfile = "2"
|
||||
rustls-pki-types = "1"
|
||||
rustls-native-certs = "0.7"
|
||||
|
||||
# Keychain ACL normalization (explicit SecAccess on stored secrets so items
|
||||
# survive app re-signs without permission prompts) — macOS only.
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
security-framework = "3.7"
|
||||
security-framework-sys = "2.17"
|
||||
# core-foundation: CFString/CFArray/TCFType helpers for the SecAccess FFI
|
||||
# (already a transitive dep of security-framework; pinned to the same 0.10 line)
|
||||
core-foundation = "0.10"
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::keychain_acl;
|
||||
use tauri_plugin_keyring_store::KeyringExt;
|
||||
|
||||
/// Store a connection password in the OS keychain.
|
||||
@@ -11,7 +12,10 @@ pub fn save_connection_password(
|
||||
app.keyring()
|
||||
.store
|
||||
.set_password(&connection_id, &password)
|
||||
.map_err(|e| e.to_string())
|
||||
.map_err(|e| e.to_string())?;
|
||||
// Best-effort: pin the item's ACL to this app so re-signed builds never prompt.
|
||||
keychain_acl::after_store(&app, &connection_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Retrieve a connection password from the OS keychain.
|
||||
@@ -55,7 +59,9 @@ pub fn save_connection_ssh_password(
|
||||
app.keyring()
|
||||
.store
|
||||
.set_password(&ssh_account("password", &connection_id), &password)
|
||||
.map_err(|e| e.to_string())
|
||||
.map_err(|e| e.to_string())?;
|
||||
keychain_acl::after_store(&app, &ssh_account("password", &connection_id));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Retrieve an SSH tunnel password from the OS keychain.
|
||||
@@ -93,7 +99,9 @@ pub fn save_connection_ssh_passphrase(
|
||||
app.keyring()
|
||||
.store
|
||||
.set_password(&ssh_account("passphrase", &connection_id), &passphrase)
|
||||
.map_err(|e| e.to_string())
|
||||
.map_err(|e| e.to_string())?;
|
||||
keychain_acl::after_store(&app, &ssh_account("passphrase", &connection_id));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Retrieve an SSH private-key passphrase from the OS keychain.
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
//! Keychain ACL normalization for stored secrets (macOS).
|
||||
//!
|
||||
//! # Why this exists
|
||||
//!
|
||||
//! Generic-password items stored via the legacy keychain API get a default ACL
|
||||
//! that grants access to "the application that created the item". When a Tauri
|
||||
//! app is rebuilt, its code signature changes (dev builds are ad-hoc signed, so
|
||||
//! the identity differs on every build), and the new binary no longer matches
|
||||
//! the item's ACL — macOS then shows the "Gridline wants to access your
|
||||
//! keychain" prompt.
|
||||
//!
|
||||
//! The fix: after storing a secret, replace the item's ACL with an explicit
|
||||
//! `SecAccess` whose trusted-application list contains exactly *this app*,
|
||||
//! resolved by `SecTrustedApplicationCreateFromPath(NULL)` — the current
|
||||
//! process's code-signature requirement. For a Developer ID-signed release
|
||||
//! build that requirement is stable across versions (anchored to the signing
|
||||
//! certificate), so items stay accessible without prompts through upgrades.
|
||||
//!
|
||||
//! # Prompt behavior
|
||||
//!
|
||||
//! - Item created by this build → the ACL set is silent.
|
||||
//! - Item created by an older/differently-signed build → macOS shows the
|
||||
//! standard one-time authorization dialog; "Always Allow" replaces the ACL
|
||||
//! and the prompt never reappears (the same dialog the app would show on the
|
||||
//! first read anyway — this just makes the result permanent and clean).
|
||||
//!
|
||||
//! All operations are best-effort: `after_store` swallows errors so keychain
|
||||
//! writes never fail because of ACL normalization.
|
||||
|
||||
/// Best-effort ACL normalization after a successful keychain write.
|
||||
///
|
||||
/// Resolves the service name the same way `tauri-plugin-keyring-store` does
|
||||
/// (the Tauri bundle identifier) and never fails the caller's operation.
|
||||
pub fn after_store(app: &tauri::AppHandle, account: &str) {
|
||||
let service = app.config().identifier.clone();
|
||||
if let Err(e) = ensure_item_acl(&service, account) {
|
||||
eprintln!("[keychain_acl] skipped for {account}: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Grant this app (by its code-signature requirement) access to a keychain item.
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn ensure_item_acl(service: &str, account: &str) -> Result<(), String> {
|
||||
use core_foundation::array::CFArray;
|
||||
use core_foundation::base::{CFTypeRef, TCFType};
|
||||
use core_foundation::string::CFString;
|
||||
use security_framework::item::{ItemClass, ItemSearchOptions, Reference, SearchResult};
|
||||
use security_framework::os::macos::access::SecAccess;
|
||||
use security_framework_sys::base::SecAccessRef;
|
||||
|
||||
// Locate the item WITHOUT reading its secret: a ref-only lookup
|
||||
// (`kSecReturnRef`, no data/attributes) never triggers an authorization
|
||||
// prompt, so merely probing an item we can't yet access is safe.
|
||||
let found = ItemSearchOptions::new()
|
||||
.class(ItemClass::generic_password())
|
||||
.service(service)
|
||||
.account(account)
|
||||
.load_refs(true)
|
||||
.load_data(false)
|
||||
.load_attributes(false)
|
||||
.search()
|
||||
.map_err(|e| format!("keychain item lookup failed: {e}"))?;
|
||||
|
||||
let item = match found.first() {
|
||||
Some(SearchResult::Ref(Reference::KeychainItem(item))) => item,
|
||||
// No item (yet) or an unexpected result shape — nothing to normalize.
|
||||
_ => return Ok(()),
|
||||
};
|
||||
|
||||
fn cvt(status: i32) -> Result<(), String> {
|
||||
if status == 0 {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("security framework error {status}"))
|
||||
}
|
||||
}
|
||||
|
||||
// Build an access object whose trusted list is exactly this app. A NULL
|
||||
// path resolves the trusted application to the current process's code
|
||||
// signature requirement.
|
||||
unsafe {
|
||||
let mut trusted_app_ref: *mut OpaqueSecTrustedApplication = std::ptr::null_mut();
|
||||
cvt(SecTrustedApplicationCreateFromPath(std::ptr::null(), &mut trusted_app_ref))?;
|
||||
let trusted_app = SecTrustedApplication::wrap_under_create_rule(trusted_app_ref);
|
||||
let trusted_list = CFArray::from_CFTypes(&[trusted_app]);
|
||||
|
||||
let descriptor = CFString::new("Gridline");
|
||||
let mut access_ref: SecAccessRef = std::ptr::null_mut();
|
||||
cvt(SecAccessCreate(
|
||||
descriptor.as_concrete_TypeRef() as CFTypeRef,
|
||||
trusted_list.as_concrete_TypeRef() as CFTypeRef,
|
||||
&mut access_ref,
|
||||
))?;
|
||||
let access = SecAccess::wrap_under_create_rule(access_ref);
|
||||
|
||||
// Replace the item's ACL. Silent when we already own the item;
|
||||
// one-time authorization dialog when the item was created by a
|
||||
// differently-signed build.
|
||||
cvt(SecKeychainItemSetAccess(
|
||||
item.as_concrete_TypeRef(),
|
||||
access.as_concrete_TypeRef(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Non-macOS platforms have no keychain ACL concept — nothing to do.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub fn ensure_item_acl(_service: &str, _account: &str) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod macos_ffi {
|
||||
use core_foundation::base::{CFTypeRef, CFTypeID};
|
||||
|
||||
/// Minimal `TCFType` wrapper for `SecTrustedApplication` — the
|
||||
/// security-framework crate exposes no high-level API for it.
|
||||
#[repr(C)]
|
||||
pub struct OpaqueSecTrustedApplication {
|
||||
_priv: [u8; 0],
|
||||
}
|
||||
pub type SecTrustedApplicationRef = *mut OpaqueSecTrustedApplication;
|
||||
|
||||
use core_foundation::{declare_TCFType, impl_TCFType};
|
||||
declare_TCFType!(SecTrustedApplication, SecTrustedApplicationRef);
|
||||
impl_TCFType!(
|
||||
SecTrustedApplication,
|
||||
SecTrustedApplicationRef,
|
||||
SecTrustedApplicationGetTypeID
|
||||
);
|
||||
unsafe impl Send for SecTrustedApplication {}
|
||||
unsafe impl Sync for SecTrustedApplication {}
|
||||
|
||||
extern "C" {
|
||||
// SecAccess/SecTrustedApplication APIs are deprecated in the macOS SDK
|
||||
// but remain fully functional (the legacy keychain store this app uses
|
||||
// via tauri-plugin-keyring-store relies on the same API family).
|
||||
pub fn SecTrustedApplicationGetTypeID() -> CFTypeID;
|
||||
pub fn SecTrustedApplicationCreateFromPath(
|
||||
path: CFTypeRef,
|
||||
app: *mut SecTrustedApplicationRef,
|
||||
) -> i32;
|
||||
pub fn SecAccessCreate(
|
||||
descriptor: CFTypeRef,
|
||||
trustedlist: CFTypeRef,
|
||||
access: *mut security_framework_sys::base::SecAccessRef,
|
||||
) -> i32;
|
||||
pub fn SecKeychainItemSetAccess(
|
||||
item: security_framework_sys::base::SecKeychainItemRef,
|
||||
access: security_framework_sys::base::SecAccessRef,
|
||||
) -> i32;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
use macos_ffi::{
|
||||
OpaqueSecTrustedApplication, SecAccessCreate, SecKeychainItemSetAccess, SecTrustedApplication,
|
||||
SecTrustedApplicationCreateFromPath,
|
||||
};
|
||||
|
||||
#[cfg(all(test, target_os = "macos"))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use security_framework::os::macos::keychain::SecKeychain;
|
||||
use security_framework::os::macos::passwords::find_generic_password;
|
||||
|
||||
/// Real-keychain integration test: creates a generic-password item the
|
||||
/// same way the keyring store does, normalizes its ACL, verifies the
|
||||
/// secret is still readable, and cleans up.
|
||||
///
|
||||
/// Ignored by default (`cargo test`) so CI/dev runs never touch the login
|
||||
/// keychain. Run explicitly with:
|
||||
/// `cargo test -- --ignored keychain_acl`
|
||||
#[test]
|
||||
#[ignore = "touches the real login keychain"]
|
||||
fn keychain_acl_normalizes_item() {
|
||||
let keychain = SecKeychain::default().expect("default keychain");
|
||||
let service = "com.adrianbonpin.gridline.acl-test";
|
||||
let account = format!("acl-test-{}", std::process::id());
|
||||
|
||||
// Create the item exactly like apple-native-keyring-store does
|
||||
// (legacy SecKeychain generic-password API).
|
||||
keychain
|
||||
.set_generic_password(service, &account, b"test-secret")
|
||||
.expect("create test item");
|
||||
|
||||
// ACL normalization must succeed on an item we just created.
|
||||
ensure_item_acl(service, &account).expect("normalize ACL");
|
||||
|
||||
// The item must remain readable with the correct secret.
|
||||
let (password, item) =
|
||||
find_generic_password(Some(&[keychain]), service, &account).expect("read back");
|
||||
assert_eq!(password.as_ref(), b"test-secret");
|
||||
|
||||
// Cleanup: delete the test item.
|
||||
item.delete();
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
mod cancel;
|
||||
mod commands;
|
||||
mod db;
|
||||
mod keychain_acl;
|
||||
mod models;
|
||||
mod store;
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Gridline",
|
||||
"version": "0.7.13",
|
||||
"version": "0.7.14",
|
||||
"identifier": "com.adrianbonpin.gridline",
|
||||
"build": {
|
||||
"beforeDevCommand": "bun run dev",
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { FilterBuilder } from "./FilterBuilder";
|
||||
import type { ColumnInfo } from "../../lib/types";
|
||||
|
||||
const cols: ColumnInfo[] = [
|
||||
{ name: "id", data_type: "integer", is_nullable: false, is_pk: true, is_fk: false, fk_ref: null, default_value: null, editable: false, is_generated: false },
|
||||
{ name: "name", data_type: "text", is_nullable: true, is_pk: false, is_fk: false, fk_ref: null, default_value: null, editable: true, is_generated: false },
|
||||
];
|
||||
|
||||
describe("FilterBuilder", () => {
|
||||
it("drops a column chip into the drop zone to create a rule with a type-aware operator", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<FilterBuilder columns={cols} rules={[]} onChange={onChange} />);
|
||||
fireEvent.click(screen.getByText("name"));
|
||||
expect(onChange).toHaveBeenCalledWith([
|
||||
expect.objectContaining({ column: "name", operator: "contains" }),
|
||||
]);
|
||||
});
|
||||
it("removing a rule calls onChange without it", () => {
|
||||
const onChange = vi.fn();
|
||||
const rules = [{ id: "r1", column: "name", operator: "contains" as const, value: "Al" }];
|
||||
render(<FilterBuilder columns={cols} rules={rules} onChange={onChange} />);
|
||||
fireEvent.click(screen.getByLabelText("Remove filter name"));
|
||||
expect(onChange).toHaveBeenCalledWith([]);
|
||||
});
|
||||
});
|
||||
@@ -1,132 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { DndContext, useDraggable, useDroppable } from "@dnd-kit/core";
|
||||
import { X } from "lucide-react";
|
||||
import { defaultFilterOperator } from "../grid/gridEditability";
|
||||
import type { ColumnInfo } from "../../lib/types";
|
||||
import type { FilterRule, FilterOperator } from "../../stores/dbViewerStore";
|
||||
|
||||
interface Props {
|
||||
columns: ColumnInfo[];
|
||||
rules: FilterRule[];
|
||||
onChange: (rules: FilterRule[]) => void;
|
||||
}
|
||||
|
||||
function Chip({ col, onAdd }: { col: ColumnInfo; onAdd: () => void }) {
|
||||
const { setNodeRef, attributes, listeners, isDragging } = useDraggable({
|
||||
id: `col-${col.name}`,
|
||||
data: { column: col },
|
||||
});
|
||||
return (
|
||||
<button
|
||||
ref={setNodeRef}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
onClick={onAdd}
|
||||
className={`px-2 py-1 text-xs rounded border border-border bg-surface text-text hover:border-accent ${
|
||||
isDragging ? "opacity-50" : ""
|
||||
}`}
|
||||
>
|
||||
{col.name}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const OPERATORS: FilterOperator[] = [
|
||||
"eq",
|
||||
"neq",
|
||||
"contains",
|
||||
"starts",
|
||||
"ends",
|
||||
"gt",
|
||||
"lt",
|
||||
"null",
|
||||
"notnull",
|
||||
];
|
||||
|
||||
export function FilterBuilder({ columns, rules, onChange }: Props) {
|
||||
const [val, setVal] = useState<Record<string, string>>({});
|
||||
|
||||
const addRule = (col: ColumnInfo) => {
|
||||
const op = defaultFilterOperator(col.data_type);
|
||||
onChange([
|
||||
...rules,
|
||||
{
|
||||
id: `f-${Date.now()}-${col.name}`,
|
||||
column: col.name,
|
||||
operator: op,
|
||||
value: "",
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const { setNodeRef, isOver } = useDroppable({ id: "filter-dropzone" });
|
||||
|
||||
const remove = (id: string) => onChange(rules.filter((r) => r.id !== id));
|
||||
const update = (id: string, patch: Partial<FilterRule>) =>
|
||||
onChange(rules.map((r) => (r.id === id ? { ...r, ...patch } : r)));
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
onDragEnd={(e) => {
|
||||
const id = e.active.id as string;
|
||||
const colName = id.replace(/^col-/, "");
|
||||
const col = columns.find((c) => c.name === colName);
|
||||
if (col && e.over?.id === "filter-dropzone") addRule(col);
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-wrap gap-1 mb-2">
|
||||
{columns.map((c) => (
|
||||
<Chip key={c.name} col={c} onAdd={() => addRule(c)} />
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={`min-h-[40px] border border-dashed rounded p-2 space-y-1 ${
|
||||
isOver ? "border-accent bg-surface" : "border-border"
|
||||
}`}
|
||||
>
|
||||
{rules.length === 0 && (
|
||||
<span className="text-xs text-text-muted">
|
||||
Drop columns here to add filters
|
||||
</span>
|
||||
)}
|
||||
{rules.map((r) => (
|
||||
<div key={r.id} className="flex items-center gap-2 text-xs">
|
||||
<span className="text-text font-semibold">{r.column}</span>
|
||||
<select
|
||||
value={r.operator}
|
||||
onChange={(e) =>
|
||||
update(r.id, { operator: e.target.value as FilterOperator })
|
||||
}
|
||||
className="bg-surface border border-border rounded px-1"
|
||||
>
|
||||
{OPERATORS.map((o) => (
|
||||
<option key={o} value={o}>
|
||||
{o}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{!["null", "notnull"].includes(r.operator) && (
|
||||
<input
|
||||
value={val[r.id] ?? r.value}
|
||||
onChange={(e) => {
|
||||
setVal({ ...val, [r.id]: e.target.value });
|
||||
update(r.id, { value: e.target.value });
|
||||
}}
|
||||
className="bg-surface border border-border rounded px-1 flex-1"
|
||||
placeholder="value"
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
aria-label={`Remove filter ${r.column}`}
|
||||
onClick={() => remove(r.id)}
|
||||
className="text-text-muted hover:text-red-400"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
@@ -325,13 +325,100 @@ describe("TableControls", () => {
|
||||
expect(screen.queryByLabelText(/insert row/i)).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the FilterBuilder inside the filter popover", () => {
|
||||
it("renders the column filter popover with the + Add filter action", () => {
|
||||
seed([makeTab()], "tab-1");
|
||||
renderControls();
|
||||
fireEvent.click(screen.getByLabelText(/column filters/i));
|
||||
expect(
|
||||
screen.getByText("Drop columns here to add filters"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("Column Filters")).toBeInTheDocument();
|
||||
expect(screen.getByText("+ Add filter")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("debounces filter value edits so a query only fires after the user stops typing", () => {
|
||||
vi.useFakeTimers();
|
||||
seed([makeTab()], "tab-1");
|
||||
const onFilterChange = vi.fn();
|
||||
renderControls({
|
||||
onFilterChange,
|
||||
filterRules: [
|
||||
{ id: "r1", column: "id", operator: "contains", value: "" },
|
||||
],
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByLabelText(/column filters/i));
|
||||
|
||||
const input = screen.getByPlaceholderText("value");
|
||||
fireEvent.change(input, { target: { value: "a" } });
|
||||
fireEvent.change(input, { target: { value: "ab" } });
|
||||
fireEvent.change(input, { target: { value: "abc" } });
|
||||
|
||||
// No commit yet — the debounce hasn't elapsed.
|
||||
expect(onFilterChange).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(400);
|
||||
});
|
||||
|
||||
// Only the final value is committed, once.
|
||||
expect(onFilterChange).toHaveBeenCalledTimes(1);
|
||||
expect(onFilterChange).toHaveBeenCalledWith([
|
||||
expect.objectContaining({ value: "abc" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("closes the filter popover when the button is clicked while it is open", () => {
|
||||
seed([makeTab()], "tab-1");
|
||||
renderControls();
|
||||
|
||||
fireEvent.click(screen.getByLabelText(/column filters/i));
|
||||
expect(screen.getByText("Column Filters")).toBeInTheDocument();
|
||||
|
||||
// Clicking the button while open closes it (plain toggle).
|
||||
fireEvent.click(screen.getByLabelText(/column filters/i));
|
||||
expect(screen.queryByText("Column Filters")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("toggles the sort rules popover open and closed", () => {
|
||||
seed([makeTab()], "tab-1");
|
||||
renderControls();
|
||||
|
||||
fireEvent.click(screen.getByLabelText(/sort rules/i));
|
||||
expect(screen.getByText("Sort Rules")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByLabelText(/sort rules/i));
|
||||
expect(screen.queryByText("Sort Rules")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("toggles the auto-refresh dropdown open and closed", () => {
|
||||
seed([makeTab()], "tab-1");
|
||||
renderControls();
|
||||
|
||||
fireEvent.click(screen.getByLabelText(/auto-refresh/i));
|
||||
expect(screen.getByText("Off")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByLabelText(/auto-refresh/i));
|
||||
expect(screen.queryByText("Off")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("toggles the show/hide columns dropdown open and closed", () => {
|
||||
seed([makeTab()], "tab-1");
|
||||
renderControls();
|
||||
|
||||
fireEvent.click(screen.getByLabelText(/toggle columns/i));
|
||||
expect(screen.getByText("Visible columns")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByLabelText(/toggle columns/i));
|
||||
expect(screen.queryByText("Visible columns")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("toggles the export dropdown open and closed", () => {
|
||||
seed([makeTab()], "tab-1");
|
||||
renderControls();
|
||||
|
||||
fireEvent.click(screen.getByLabelText(/export/i));
|
||||
expect(screen.getByText("JSON")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByLabelText(/export/i));
|
||||
expect(screen.queryByText("JSON")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("export dropdown includes the Excel option", () => {
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
} from "lucide-react";
|
||||
import { useDbViewerStore, type FilterRule, type SortRule } from "../../stores/dbViewerStore";
|
||||
import { useNotificationStore } from "../../stores/notificationStore";
|
||||
import { FilterBuilder } from "./FilterBuilder";
|
||||
import { Tooltip } from "../ui/Tooltip";
|
||||
import { exportData } from "../../lib/exportData";
|
||||
import type { ColumnInfo } from "../../lib/types";
|
||||
@@ -49,22 +48,28 @@ function DropdownMenu({
|
||||
open,
|
||||
setOpen,
|
||||
align,
|
||||
wrapRef,
|
||||
children,
|
||||
}: {
|
||||
open: boolean;
|
||||
setOpen: (v: boolean) => void;
|
||||
align?: "left" | "right";
|
||||
/** When provided, outside-click detection uses this wrapper (which should
|
||||
* contain both the toggle button and the menu) so the button never fights
|
||||
* the outside-click handler. */
|
||||
wrapRef?: React.RefObject<HTMLDivElement | null>;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const close = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
const container = wrapRef?.current ?? ref.current;
|
||||
if (container && !container.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", close);
|
||||
return () => document.removeEventListener("mousedown", close);
|
||||
}, [open, setOpen]);
|
||||
}, [open, setOpen, wrapRef]);
|
||||
|
||||
if (!open) return null;
|
||||
return (
|
||||
@@ -79,28 +84,84 @@ function DropdownMenu({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Debounced free-text input for filter values. The value is committed to the
|
||||
* rules (and thus triggers a server-side refetch) only after the user stops
|
||||
* typing for `DEBOUNCE_MS`. Without this, every keystroke fires a query and
|
||||
* the in-flight guard in `fetchData` silently drops intermediate fetches, so
|
||||
* the final filter value can end up not being applied.
|
||||
*/
|
||||
const FILTER_DEBOUNCE_MS = 400;
|
||||
|
||||
function DebouncedValueInput({
|
||||
value,
|
||||
onCommit,
|
||||
placeholder,
|
||||
}: {
|
||||
value: string;
|
||||
onCommit: (v: string) => void;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
const [draft, setDraft] = useState(value);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Keep the draft in sync when the committed value changes externally
|
||||
// (e.g. a rule is replaced or removed while this input is mounted).
|
||||
useEffect(() => {
|
||||
setDraft(value);
|
||||
}, [value]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const v = e.target.value;
|
||||
setDraft(v);
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
timerRef.current = setTimeout(() => onCommit(v), FILTER_DEBOUNCE_MS);
|
||||
};
|
||||
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
value={draft}
|
||||
onChange={handleChange}
|
||||
placeholder={placeholder}
|
||||
className="flex-1 rounded border border-border bg-surface text-xs px-1.5 py-1 text-text min-w-0"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FilterModal({
|
||||
columns,
|
||||
rules,
|
||||
onChange,
|
||||
open,
|
||||
setOpen,
|
||||
wrapRef,
|
||||
}: {
|
||||
columns: ColumnInfo[];
|
||||
rules: FilterRule[];
|
||||
onChange: (rules: FilterRule[]) => void;
|
||||
open: boolean;
|
||||
setOpen: (v: boolean) => void;
|
||||
wrapRef: React.RefObject<HTMLDivElement | null>;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
// Close on outside click, but treat the toggle button (which lives inside
|
||||
// the same wrapper) as part of the modal so it never fights the toggle.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const close = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", close);
|
||||
return () => document.removeEventListener("mousedown", close);
|
||||
}, [open, setOpen]);
|
||||
}, [open, setOpen, wrapRef]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
@@ -116,17 +177,13 @@ function FilterModal({
|
||||
onChange(rules.map((r) => (r.id === id ? { ...r, ...patch } : r)));
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="absolute top-full left-0 mt-1 z-30 w-96 rounded-lg bg-surface border border-border shadow-lg p-3"
|
||||
>
|
||||
<div className="absolute top-full left-0 mt-1 z-30 w-96 rounded-lg bg-surface border border-border shadow-lg p-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-semibold text-text">Column Filters</span>
|
||||
<button type="button" onClick={() => setOpen(false)} className="text-text-muted hover:text-text cursor-pointer">
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<FilterBuilder columns={columns} rules={rules} onChange={onChange} />
|
||||
{rules.map((rule) => (
|
||||
<div key={rule.id} className="flex items-center gap-1.5 mb-1.5">
|
||||
<select
|
||||
@@ -152,12 +209,10 @@ function FilterModal({
|
||||
<option value="notnull">not null</option>
|
||||
</select>
|
||||
{rule.operator !== "null" && rule.operator !== "notnull" && (
|
||||
<input
|
||||
type="text"
|
||||
<DebouncedValueInput
|
||||
value={rule.value}
|
||||
onChange={(e) => updateRule(rule.id, { value: e.target.value })}
|
||||
onCommit={(v) => updateRule(rule.id, { value: v })}
|
||||
placeholder="value"
|
||||
className="flex-1 rounded border border-border bg-surface text-xs px-1.5 py-1 text-text min-w-0"
|
||||
/>
|
||||
)}
|
||||
<button type="button" onClick={() => removeRule(rule.id)} className="text-text-muted hover:text-red-400 shrink-0 cursor-pointer">
|
||||
@@ -182,22 +237,27 @@ function SortModal({
|
||||
onChange,
|
||||
open,
|
||||
setOpen,
|
||||
wrapRef,
|
||||
}: {
|
||||
columns: ColumnInfo[];
|
||||
rules: SortRule[];
|
||||
onChange: (rules: SortRule[]) => void;
|
||||
open: boolean;
|
||||
setOpen: (v: boolean) => void;
|
||||
wrapRef: React.RefObject<HTMLDivElement | null>;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
// Close on outside click, but treat the toggle button (which lives inside
|
||||
// the same wrapper) as part of the modal so it never fights the toggle.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const close = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", close);
|
||||
return () => document.removeEventListener("mousedown", close);
|
||||
}, [open, setOpen]);
|
||||
}, [open, setOpen, wrapRef]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
@@ -213,10 +273,7 @@ function SortModal({
|
||||
onChange(rules.map((r) => (r.id === id ? { ...r, ...patch } : r)));
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="absolute top-full left-0 mt-1 z-30 w-72 rounded-lg bg-surface border border-border shadow-lg p-3"
|
||||
>
|
||||
<div className="absolute top-full left-0 mt-1 z-30 w-72 rounded-lg bg-surface border border-border shadow-lg p-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-semibold text-text">Sort Rules</span>
|
||||
<button type="button" onClick={() => setOpen(false)} className="text-text-muted hover:text-text cursor-pointer">
|
||||
@@ -272,6 +329,7 @@ function BulkActionsDropdown({
|
||||
onClearSelection: () => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const wrapRef = useRef<HTMLDivElement>(null);
|
||||
const addChange = useDbViewerStore((s) => s.addChange);
|
||||
|
||||
const copyToClipboard = (text: string) => {
|
||||
@@ -338,7 +396,7 @@ function BulkActionsDropdown({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="relative" ref={wrapRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
@@ -347,7 +405,7 @@ function BulkActionsDropdown({
|
||||
<span className="text-xs font-medium">Actions</span>
|
||||
<ChevronDown size={12} />
|
||||
</button>
|
||||
<DropdownMenu open={open} setOpen={setOpen} align="right">
|
||||
<DropdownMenu open={open} setOpen={setOpen} align="right" wrapRef={wrapRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopyJSON}
|
||||
@@ -445,11 +503,16 @@ export function TableControls({
|
||||
|
||||
// local state
|
||||
const [filterOpen, setFilterOpen] = useState(false);
|
||||
const filterWrapRef = useRef<HTMLDivElement>(null);
|
||||
const [sortOpen, setSortOpen] = useState(false);
|
||||
const sortWrapRef = useRef<HTMLDivElement>(null);
|
||||
const [columnMenuOpen, setColumnMenuOpen] = useState(false);
|
||||
const columnMenuWrapRef = useRef<HTMLDivElement>(null);
|
||||
const [exportOpen, setExportOpen] = useState(false);
|
||||
const exportWrapRef = useRef<HTMLDivElement>(null);
|
||||
const [autoRefresh, setAutoRefresh] = useState(defaultRefreshRate);
|
||||
const [autoRefreshOpen, setAutoRefreshOpen] = useState(false);
|
||||
const autoRefreshWrapRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Auto-refresh: a self-restarting timer that only counts down while the tab
|
||||
// is idle. Fires a refresh, waits for it to complete (loading → false),
|
||||
@@ -545,7 +608,7 @@ export function TableControls({
|
||||
);
|
||||
|
||||
const exportControl = (
|
||||
<div className="relative">
|
||||
<div className="relative" ref={exportWrapRef}>
|
||||
<Tooltip content="Export" side="bottom">
|
||||
<button
|
||||
type="button"
|
||||
@@ -556,7 +619,7 @@ export function TableControls({
|
||||
<Download size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<DropdownMenu open={exportOpen} setOpen={setExportOpen}>
|
||||
<DropdownMenu open={exportOpen} setOpen={setExportOpen} wrapRef={exportWrapRef}>
|
||||
{EXPORT_FORMATS.map((fmt) => (
|
||||
<button
|
||||
key={fmt.ext}
|
||||
@@ -572,7 +635,7 @@ export function TableControls({
|
||||
);
|
||||
|
||||
const columnsControl = (
|
||||
<div className="relative">
|
||||
<div className="relative" ref={columnMenuWrapRef}>
|
||||
<Tooltip content="Show/hide columns" side="bottom">
|
||||
<button
|
||||
type="button"
|
||||
@@ -587,6 +650,7 @@ export function TableControls({
|
||||
open={columnMenuOpen}
|
||||
setOpen={setColumnMenuOpen}
|
||||
align={isQuery ? "left" : "right"}
|
||||
wrapRef={columnMenuWrapRef}
|
||||
>
|
||||
<div className="px-2 py-1 text-[10px] text-text-muted uppercase tracking-wider">
|
||||
Visible columns
|
||||
@@ -658,7 +722,7 @@ export function TableControls({
|
||||
{refreshControl}
|
||||
|
||||
{/* Auto-refresh */}
|
||||
<div className="relative">
|
||||
<div className="relative" ref={autoRefreshWrapRef}>
|
||||
<Tooltip content={`Auto-refresh: ${autoRefresh > 0 ? `${autoRefresh / 1000}s` : "Off"}`} side="bottom">
|
||||
<button
|
||||
type="button"
|
||||
@@ -672,7 +736,7 @@ export function TableControls({
|
||||
{autoRefresh > 0 && <span className="text-[10px] font-medium">{autoRefresh / 1000}s</span>}
|
||||
</button>
|
||||
</Tooltip>
|
||||
<DropdownMenu open={autoRefreshOpen} setOpen={setAutoRefreshOpen}>
|
||||
<DropdownMenu open={autoRefreshOpen} setOpen={setAutoRefreshOpen} wrapRef={autoRefreshWrapRef}>
|
||||
{AUTO_REFRESH_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
@@ -692,7 +756,7 @@ export function TableControls({
|
||||
<div className="w-px h-4 bg-border mx-1" />
|
||||
|
||||
{/* Filter */}
|
||||
<div className="relative">
|
||||
<div className="relative" ref={filterWrapRef}>
|
||||
<Tooltip content="Column filters" side="bottom">
|
||||
<button
|
||||
type="button"
|
||||
@@ -716,11 +780,12 @@ export function TableControls({
|
||||
onChange={onFilterChange}
|
||||
open={filterOpen}
|
||||
setOpen={setFilterOpen}
|
||||
wrapRef={filterWrapRef}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Sort */}
|
||||
<div className="relative">
|
||||
<div className="relative" ref={sortWrapRef}>
|
||||
<Tooltip content="Sort rules" side="bottom">
|
||||
<button
|
||||
type="button"
|
||||
@@ -744,6 +809,7 @@ export function TableControls({
|
||||
onChange={onSortChange}
|
||||
open={sortOpen}
|
||||
setOpen={setSortOpen}
|
||||
wrapRef={sortWrapRef}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import tauriConf from "../../src-tauri/tauri.conf.json";
|
||||
|
||||
describe("tauri bundle config (v0.7.13)", () => {
|
||||
describe("tauri bundle config (v0.7.14)", () => {
|
||||
it("declares bundled pg_tools resources", () => {
|
||||
expect(tauriConf.bundle.resources).toContain("resources/pg_tools/*");
|
||||
});
|
||||
it("version is 0.7.13", () => {
|
||||
expect(tauriConf.version).toBe("0.7.13");
|
||||
it("version is 0.7.14", () => {
|
||||
expect(tauriConf.version).toBe("0.7.14");
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,7 @@ import { describe, it, expect } from "vitest";
|
||||
import agents from "../../../AGENTS.md?raw";
|
||||
import readme from "../../../README.md?raw";
|
||||
|
||||
describe("v0.7.13 docs coverage", () => {
|
||||
describe("v0.7.14 docs coverage", () => {
|
||||
it("AGENTS.md marks inline cell editing complete", () => {
|
||||
expect(agents).toContain("Inline cell editing");
|
||||
expect(agents).toMatch(/Inline cell editing \| ✅/);
|
||||
@@ -24,8 +24,8 @@ describe("v0.7.13 docs coverage", () => {
|
||||
expect(agents).toMatch(/Connection status indicator on cards \| ✅/);
|
||||
expect(agents).toMatch(/Move-to-folder bulk action \| ✅/);
|
||||
});
|
||||
it("README declares v0.7.13", () => {
|
||||
expect(readme).toContain("0.7.13");
|
||||
it("README declares v0.7.14", () => {
|
||||
expect(readme).toContain("0.7.14");
|
||||
});
|
||||
it("AGENTS.md marks schema CRUD complete", () => {
|
||||
expect(agents).toMatch(/Schema CRUD \| ✅/);
|
||||
@@ -64,9 +64,9 @@ describe("v0.7.13 docs coverage", () => {
|
||||
expect(agents).toMatch(/Cancel long-running queries \| ✅/);
|
||||
expect(agents).toMatch(/Settings export\/import \| ✅/);
|
||||
});
|
||||
it("README links to v0.7.13 assets in both download tables", () => {
|
||||
expect(readme).toContain("releases/download/v0.7.13/");
|
||||
expect(readme).toContain("Gridline_0.7.13_aarch64.dmg");
|
||||
expect(readme).toContain("Gridline-0.7.13-1.x86_64.rpm");
|
||||
it("README links to v0.7.14 assets in both download tables", () => {
|
||||
expect(readme).toContain("releases/download/v0.7.14/");
|
||||
expect(readme).toContain("Gridline_0.7.14_aarch64.dmg");
|
||||
expect(readme).toContain("Gridline-0.7.14-1.x86_64.rpm");
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest";
|
||||
import pkg from "../../package.json";
|
||||
|
||||
describe("version", () => {
|
||||
it("declares v0.7.13 across the app shell", () => {
|
||||
expect(pkg.version).toBe("0.7.13");
|
||||
it("declares v0.7.14 across the app shell", () => {
|
||||
expect(pkg.version).toBe("0.7.14");
|
||||
});
|
||||
});
|
||||
@@ -11,13 +11,8 @@
|
||||
#
|
||||
# brew install --cask gridline
|
||||
#
|
||||
# QUARANTINE: Gridline is ad-hoc signed (not Developer-ID signed/notarized),
|
||||
# so Homebrew casks (which deliberately set com.apple.quarantine on the
|
||||
# downloaded artifact) would make Gatekeeper refuse to launch it. The
|
||||
# `postflight` block strips the quarantine flag automatically — the standard
|
||||
# GoReleaser-documented pattern for unsigned casks — so `brew install` works
|
||||
# with no manual `xattr` step. This is a Gatekeeper bypass; drop the
|
||||
# postflight once the app is properly signed + notarized.
|
||||
# SIGNING: Gridline is Developer-ID signed + notarized, so macOS trusts it
|
||||
# and no quarantine-stripping postflight is needed.
|
||||
|
||||
cask "gridline" do
|
||||
arch arm: "aarch64", intel: "x64"
|
||||
@@ -26,24 +21,15 @@ cask "gridline" do
|
||||
sha256 arm: "@ARM_SHA256@",
|
||||
intel: "@INTEL_SHA256@"
|
||||
|
||||
url "https://github.com/AdrianBonpin/gridline/releases/download/v#{version}/Gridline_#{version}_#{arch}.dmg"
|
||||
url "https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v#{version}/Gridline_#{version}_#{arch}.dmg"
|
||||
name "Gridline"
|
||||
desc "Open-source, cross-platform database GUI client for PostgreSQL"
|
||||
homepage "https://github.com/AdrianBonpin/gridline"
|
||||
homepage "https://git.ranio.xyz/adrianbonpin/gridline"
|
||||
|
||||
depends_on macos: ">= :monterey"
|
||||
depends_on macos: :monterey
|
||||
|
||||
app "Gridline.app"
|
||||
|
||||
# The "installation step" — strip the quarantine flag so the unsigned
|
||||
# (ad-hoc signed) app launches without the Gatekeeper prompt.
|
||||
postflight do
|
||||
if OS.mac?
|
||||
system_command "/usr/bin/xattr",
|
||||
args: ["-dr", "com.apple.quarantine", "#{appdir}/Gridline.app"]
|
||||
end
|
||||
end
|
||||
|
||||
zap trash: [
|
||||
"~/Library/Application Support/com.adrianbonpin.gridline",
|
||||
"~/Library/Caches/com.adrianbonpin.gridline",
|
||||
|
||||
+90
-72
@@ -45,82 +45,24 @@ get_token() {
|
||||
if [ -n "$tok" ]; then echo "$tok"; return; fi
|
||||
fi
|
||||
fi
|
||||
read -rsp "Gitea personal access token (repo write): " GITEA_TOKEN
|
||||
echo "$GITEA_TOKEN"
|
||||
# Fall back to the git credential store: tea's OAuth login stores an access
|
||||
# token as the password for every push to this remote, and Gitea accepts it
|
||||
# for API auth (Authorization: token <access_token>).
|
||||
local host cred
|
||||
host="${GITEA_SERVER#*://}"
|
||||
cred="$(printf 'protocol=https\nhost=%s\n\n' "$host" \
|
||||
| git credential fill 2>/dev/null | sed -n 's/^password=//p' | head -1 || true)"
|
||||
if [ -n "$cred" ]; then echo "$cred"; return; fi
|
||||
# Fail fast — continuing without a token fails only at the upload step,
|
||||
# after the whole build has run.
|
||||
echo "error: no Gitea token found" >&2
|
||||
echo " set GITEA_TOKEN, or run 'tea login' / 'git push' once so the" >&2
|
||||
echo " credential store has an entry for $host" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
GITEA_TOKEN="$(get_token)"
|
||||
|
||||
# ---- build bundled DB tools (macOS) ----------------------------------------
|
||||
WORKSPACE="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
OUT_PG="${WORKSPACE}/desktop/src-tauri/resources/pg_tools"
|
||||
OUT_MY="${WORKSPACE}/desktop/src-tauri/resources/mysql_tools"
|
||||
|
||||
echo ">> Building PostgreSQL client tools..."
|
||||
mkdir -p "$OUT_PG"
|
||||
PG_VER="16.4"
|
||||
curl -fsSL "https://ftp.postgresql.org/pub/source/v${PG_VER}/postgresql-${PG_VER}.tar.bz2" -o /tmp/pg.tar.bz2
|
||||
tar -xf /tmp/pg.tar.bz2 -C /tmp
|
||||
cd /tmp/postgresql-${PG_VER}
|
||||
ac_cv_func_strchrnul=no ./configure --prefix=/tmp/pgbuild --without-readline --without-icu CFLAGS="-O2"
|
||||
sed -i '' 's/strchrnul/pg_strchrnul/g' src/port/snprintf.c
|
||||
make -C src/backend generated-headers
|
||||
make -C src/interfaces/libpq all
|
||||
make -j"$(sysctl -n hw.ncpu)" -C src/bin/pg_dump all
|
||||
make -j"$(sysctl -n hw.ncpu)" -C src/bin/psql all
|
||||
cp src/bin/pg_dump/pg_dump src/bin/pg_dump/pg_restore "$OUT_PG"/
|
||||
cp src/bin/psql/psql "$OUT_PG"/
|
||||
cp src/interfaces/libpq/libpq.5.dylib "$OUT_PG"/libpq.5.dylib
|
||||
for b in pg_dump pg_restore psql; do
|
||||
libpq=$(otool -L "$OUT_PG/$b" | awk '/libpq/ {print $1; exit}')
|
||||
install_name_tool -change "$libpq" "@loader_path/libpq.5.dylib" "$OUT_PG/$b"
|
||||
done
|
||||
# install_name_tool invalidates the linker's ad-hoc signature; re-sign so the
|
||||
# bundle passes notarization (every code object inside must be signed).
|
||||
for f in "$OUT_PG"/pg_dump "$OUT_PG"/pg_restore "$OUT_PG"/psql "$OUT_PG"/libpq.5.dylib; do
|
||||
codesign --force --sign - "$f" 2>/dev/null || true
|
||||
done
|
||||
|
||||
echo ">> Building MariaDB client tools..."
|
||||
mkdir -p "$OUT_MY"
|
||||
MARIADB_VER="11.4.5"
|
||||
git clone --depth 1 --branch "mariadb-${MARIADB_VER}" https://github.com/MariaDB/server.git /tmp/mariadb-server
|
||||
cd /tmp/mariadb-server
|
||||
if [ "$(uname -m)" = arm64 ]; then
|
||||
SSL_DIR="/opt/homebrew/opt/openssl"
|
||||
else
|
||||
SSL_DIR="/usr/local/opt/openssl"
|
||||
fi
|
||||
[ -d "$SSL_DIR" ] || brew install openssl
|
||||
cmake -DCMAKE_BUILD_TYPE=Release \
|
||||
-DWITHOUT_SERVER=ON \
|
||||
-DWITHOUT_TOKUDB=1 \
|
||||
-DWITHOUT_ROCKSDB=1 \
|
||||
-DWITHOUT_MROONGA=1 \
|
||||
-DWITHOUT_SPIDER=1 \
|
||||
-DWITHOUT_SEQUENCE=1 \
|
||||
-DWITH_UNIT_TESTS=OFF \
|
||||
-DWITH_SSL="$SSL_DIR" \
|
||||
-DWITH_ZLIB=bundled .
|
||||
make -j"$(sysctl -n hw.ncpu)" mariadb-dump mariadb
|
||||
for b in mariadb-dump mariadb; do
|
||||
src=$(find client -maxdepth 1 -type f -name "$b" -print -quit 2>/dev/null || true)
|
||||
test -n "$src" || src=$(find . -type f -name "$b" -print -quit || true)
|
||||
test -n "$src" || { echo "build produced no $b binary"; exit 1; }
|
||||
cp "$src" "$OUT_MY"/
|
||||
done
|
||||
for pass in 1 2 3 4 5 6; do
|
||||
for dylib in "$OUT_MY"/*.dylib "$OUT_MY"/mariadb-dump "$OUT_MY"/mariadb; do
|
||||
[ -f "$dylib" ] || continue
|
||||
otool -L "$dylib" | awk 'NR>1 {print $1}' | grep -E '^/(usr/local|opt/homebrew)' | while read -r lib; do
|
||||
base=$(basename "$lib")
|
||||
if [ ! -f "$OUT_MY/$base" ]; then cp "$lib" "$OUT_MY/$base" 2>/dev/null || true; fi
|
||||
install_name_tool -change "$lib" "@loader_path/$base" "$dylib" 2>/dev/null || true
|
||||
done || true
|
||||
codesign --force --sign - "$dylib" 2>/dev/null || true
|
||||
done
|
||||
done
|
||||
|
||||
# ---- macOS signing + notarization -----------------------------------------
|
||||
# Requires a "Developer ID Application" certificate in the login keychain and
|
||||
# an App Store Connect API key for notarization. See the README's "macOS
|
||||
@@ -173,6 +115,82 @@ else
|
||||
echo ">> Notarization: API key ${APPLE_API_KEY} (issuer ${APPLE_API_ISSUER})"
|
||||
fi
|
||||
|
||||
# ---- build bundled DB tools (macOS) ----------------------------------------
|
||||
WORKSPACE="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
OUT_PG="${WORKSPACE}/desktop/src-tauri/resources/pg_tools"
|
||||
OUT_MY="${WORKSPACE}/desktop/src-tauri/resources/mysql_tools"
|
||||
|
||||
echo ">> Building PostgreSQL client tools..."
|
||||
mkdir -p "$OUT_PG"
|
||||
PG_VER="16.4"
|
||||
curl -fsSL "https://ftp.postgresql.org/pub/source/v${PG_VER}/postgresql-${PG_VER}.tar.bz2" -o /tmp/pg.tar.bz2
|
||||
# Idempotent re-runs: clear stale build dirs left by previous invocations.
|
||||
rm -rf "/tmp/postgresql-${PG_VER}" /tmp/pgbuild
|
||||
tar -xf /tmp/pg.tar.bz2 -C /tmp
|
||||
cd /tmp/postgresql-${PG_VER}
|
||||
ac_cv_func_strchrnul=no ./configure --prefix=/tmp/pgbuild --without-readline --without-icu CFLAGS="-O2"
|
||||
sed -i '' 's/strchrnul/pg_strchrnul/g' src/port/snprintf.c
|
||||
make -C src/backend generated-headers
|
||||
make -C src/interfaces/libpq all
|
||||
make -j"$(sysctl -n hw.ncpu)" -C src/bin/pg_dump all
|
||||
make -j"$(sysctl -n hw.ncpu)" -C src/bin/psql all
|
||||
cp src/bin/pg_dump/pg_dump src/bin/pg_dump/pg_restore "$OUT_PG"/
|
||||
cp src/bin/psql/psql "$OUT_PG"/
|
||||
cp src/interfaces/libpq/libpq.5.dylib "$OUT_PG"/libpq.5.dylib
|
||||
for b in pg_dump pg_restore psql; do
|
||||
libpq=$(otool -L "$OUT_PG/$b" | awk '/libpq/ {print $1; exit}')
|
||||
install_name_tool -change "$libpq" "@loader_path/libpq.5.dylib" "$OUT_PG/$b"
|
||||
done
|
||||
# install_name_tool invalidates the linker's signature; re-sign with the
|
||||
# Developer ID identity + hardened runtime + secure timestamp so the bundle
|
||||
# passes notarization (Apple rejects ad-hoc-signed nested executables).
|
||||
for f in "$OUT_PG"/pg_dump "$OUT_PG"/pg_restore "$OUT_PG"/psql "$OUT_PG"/libpq.5.dylib; do
|
||||
codesign --force --options runtime --timestamp --sign "$SIGNING_IDENTITY" "$f"
|
||||
done
|
||||
|
||||
echo ">> Building MariaDB client tools..."
|
||||
mkdir -p "$OUT_MY"
|
||||
MARIADB_VER="11.4.5"
|
||||
# Idempotent re-runs: git clone fails if the destination is non-empty (e.g.
|
||||
# left over from an earlier build on the same machine). Clean it first.
|
||||
rm -rf /tmp/mariadb-server
|
||||
git clone --depth 1 --branch "mariadb-${MARIADB_VER}" https://github.com/MariaDB/server.git /tmp/mariadb-server
|
||||
cd /tmp/mariadb-server
|
||||
if [ "$(uname -m)" = arm64 ]; then
|
||||
SSL_DIR="/opt/homebrew/opt/openssl"
|
||||
else
|
||||
SSL_DIR="/usr/local/opt/openssl"
|
||||
fi
|
||||
[ -d "$SSL_DIR" ] || brew install openssl
|
||||
cmake -DCMAKE_BUILD_TYPE=Release \
|
||||
-DWITHOUT_SERVER=ON \
|
||||
-DWITHOUT_TOKUDB=1 \
|
||||
-DWITHOUT_ROCKSDB=1 \
|
||||
-DWITHOUT_MROONGA=1 \
|
||||
-DWITHOUT_SPIDER=1 \
|
||||
-DWITHOUT_SEQUENCE=1 \
|
||||
-DWITH_UNIT_TESTS=OFF \
|
||||
-DWITH_SSL="$SSL_DIR" \
|
||||
-DWITH_ZLIB=bundled .
|
||||
make -j"$(sysctl -n hw.ncpu)" mariadb-dump mariadb
|
||||
for b in mariadb-dump mariadb; do
|
||||
src=$(find client -maxdepth 1 -type f -name "$b" -print -quit 2>/dev/null || true)
|
||||
test -n "$src" || src=$(find . -type f -name "$b" -print -quit || true)
|
||||
test -n "$src" || { echo "build produced no $b binary"; exit 1; }
|
||||
cp "$src" "$OUT_MY"/
|
||||
done
|
||||
for pass in 1 2 3 4 5 6; do
|
||||
for dylib in "$OUT_MY"/*.dylib "$OUT_MY"/mariadb-dump "$OUT_MY"/mariadb; do
|
||||
[ -f "$dylib" ] || continue
|
||||
otool -L "$dylib" | awk 'NR>1 {print $1}' | grep -E '^/(usr/local|opt/homebrew)' | while read -r lib; do
|
||||
base=$(basename "$lib")
|
||||
if [ ! -f "$OUT_MY/$base" ]; then cp "$lib" "$OUT_MY/$base" 2>/dev/null || true; fi
|
||||
install_name_tool -change "$lib" "@loader_path/$base" "$dylib" 2>/dev/null || true
|
||||
done || true
|
||||
codesign --force --options runtime --timestamp --sign "$SIGNING_IDENTITY" "$dylib"
|
||||
done
|
||||
done
|
||||
|
||||
# ---- build the app (both arches) ------------------------------------------
|
||||
cd "${WORKSPACE}/desktop"
|
||||
echo ">> Installing frontend dependencies..."
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
const keepLabel = downloadBtn.hasAttribute("data-download-label");
|
||||
|
||||
const releasesUrl = "https://git.ranio.xyz/adrianbonpin/gridline/releases";
|
||||
const version = "0.7.13";
|
||||
const version = "0.7.14";
|
||||
|
||||
if (isMobile) {
|
||||
downloadBtn.href = releasesUrl;
|
||||
|
||||
@@ -5,6 +5,14 @@ export interface ChangelogEntry {
|
||||
}
|
||||
|
||||
export const changelogEntries: ChangelogEntry[] = [
|
||||
{
|
||||
version: "0.7.14",
|
||||
date: "2026-09-04",
|
||||
highlights: [
|
||||
"No more repeated macOS keychain prompts — saved connection passwords and SSH secrets are now ACL-pinned to the app's code signature when stored, so app upgrades never re-trigger the macOS keychain access dialog (users upgrading from older builds see one final prompt — choose Always Allow).",
|
||||
"Install via Homebrew — add the Gitea tap (brew tap AdrianBonpin/gridline https://git.ranio.xyz/adrianbonpin/homebrew-gridline.git) and run brew install --cask gridline.",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.7.13",
|
||||
date: "2026-09-04",
|
||||
|
||||
+9
-4
@@ -7,12 +7,12 @@ export const faqItems: FaqItem[] = [
|
||||
{
|
||||
question: "Is Gridline really free for commercial use?",
|
||||
answer:
|
||||
"Yes. Gridline is released under the Apache 2.0 license. You can use it for personal, commercial, or internal company work without paying or signing up.",
|
||||
"Yes. Gridline is Apache 2.0. You can use it for personal, commercial, or internal work without paying or signing up.",
|
||||
},
|
||||
{
|
||||
question: "Which databases are supported?",
|
||||
answer:
|
||||
"PostgreSQL and SQLite are fully supported today, including browsing, querying, editing, backups, and visual tools. MySQL and Redis connections are in active development.",
|
||||
"PostgreSQL and SQLite are fully supported today — browsing, querying, editing, backups, and the visual tools. MySQL and Redis connections are in active development.",
|
||||
},
|
||||
{
|
||||
question: "How is this different from TablePlus, Beekeeper Studio, or DB Pro?",
|
||||
@@ -22,10 +22,15 @@ export const faqItems: FaqItem[] = [
|
||||
{
|
||||
question: "Do you store my database credentials?",
|
||||
answer:
|
||||
"Passwords are stored in your operating system keychain — macOS Keychain, Linux Secret Service, or Windows Credential Manager — never in Gridline's local SQLite database.",
|
||||
"No. Passwords live in your operating system keychain — macOS Keychain, Linux Secret Service, or Windows Credential Manager — never in Gridline's local SQLite database.",
|
||||
},
|
||||
{
|
||||
question: "Can I self-host or contribute?",
|
||||
question: "How do you make money?",
|
||||
answer:
|
||||
"Gridline is free and isn't built around a paywall. If it saves you time, you can buy a coffee at ko-fi.com/adrianbonpin — it's entirely optional and never gates a feature. Otherwise, issues, docs, and contributions all help.",
|
||||
},
|
||||
{
|
||||
question: "Can I contribute or self-host?",
|
||||
answer:
|
||||
"Gridline is a desktop app, not a SaaS, so there's nothing to self-host. The repo is open for contributions: code, docs, translations, and issue reports are all welcome.",
|
||||
},
|
||||
|
||||
@@ -149,12 +149,13 @@ const isProd = import.meta.env.PROD;
|
||||
<ul class="space-y-2 text-sm text-[var(--text-muted)]">
|
||||
<li><a href="https://x.com/AdrianBonpin" target="_blank" rel="noopener" class="footer-link">X / Twitter</a></li>
|
||||
<li><a href="mailto:adrianbonpin@gmail.com" class="footer-link">Email</a></li>
|
||||
<li><a href="https://ko-fi.com/adrianbonpin" target="_blank" rel="noopener" class="footer-link">Support on Ko-fi ☕</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-12 pt-8 border-t border-[var(--border)] flex flex-col md:flex-row justify-between gap-3 text-xs text-[var(--text-muted)] font-mono">
|
||||
<span>© 2026 Gridline contributors · Apache 2.0</span>
|
||||
<span>Made with care for databases.</span>
|
||||
<span>Coffee-powered.</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -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.7.13";
|
||||
const version = "0.7.14";
|
||||
const site = "https://getgridline.app";
|
||||
const jsonLd = [
|
||||
buildSoftwareApplicationJsonLd({
|
||||
@@ -176,13 +176,21 @@ const jsonLd = [
|
||||
<section id="oss" class="section-animate py-20 md:py-28 text-center">
|
||||
<div class="max-w-[800px] mx-auto px-6">
|
||||
<p class="font-mono text-xs text-[var(--accent)] uppercase tracking-[0.15em] mb-3">Open Source</p>
|
||||
<h2 class="font-serif text-3xl md:text-5xl mb-5">Built in public.<br />No VC roadmap, no rug pulls.</h2>
|
||||
<h2 class="font-serif text-3xl md:text-5xl mb-5">Built in public,<br />no VC roadmap attached.</h2>
|
||||
<p class="text-[var(--text-muted)] mb-8 max-w-md mx-auto">
|
||||
Gridline is Apache 2.0 and developed openly. Contributions, issues, and feature requests are welcome.
|
||||
</p>
|
||||
<a href="https://git.ranio.xyz/adrianbonpin/gridline" target="_blank" rel="noopener" class="ghost-btn">
|
||||
Star on Gitea
|
||||
</a>
|
||||
<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>
|
||||
<a href="https://ko-fi.com/adrianbonpin" target="_blank" rel="noopener" class="ghost-btn">
|
||||
☕ Support on Ko-fi
|
||||
</a>
|
||||
</div>
|
||||
<p class="mt-6 text-xs text-[var(--text-muted)] max-w-sm mx-auto">
|
||||
Gridline stays free — no feature is ever behind a payment. Ko-fi is only for those who want to say thanks.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user