Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ All notable changes to QueryDen are documented here. This project adheres to [Se
- **[#15](https://github.com/openidle-dev/queryden/issues/15) — Argon2id parameters are now explicitly locked.** Replaced `Argon2::default()` with an explicit `Params::new(19456, 2, 1, None)` — 19 MiB memory cost, 2 iterations, 1 parallel thread, using Argon2id v1.3. These match the actual defaults of the argon2 0.5 crate and prevent silent parameter drift across crate version bumps.

### Fixed
- **Double-clicking a table-nested trigger showed "Trigger schema.table-triggerName not found."** The double-click DDL handler reconstructed a `schema.name` path from the tree node's id for every leaf type, but table-scoped trigger nodes have an id of the form `trig-schema.table-triggerName` — one dash-joined segment more than the reconstruction logic expected — so the table name got folded into the trigger name. `pg_get_triggerdef` looks up triggers by plain `tgname` only, so triggers now skip the path reconstruction and use the node's plain name.
- **Saved query tabs stopped being restored on app launch.** The "save session" effect fired on mount with the initial empty tab list and could win a race against the async session-restore effect (which waits on `initialLoadDone` plus an IPC round-trip), overwriting `sessions.json` with an empty tab list before it was ever read back. The save effect now waits until the restore attempt has completed before persisting.
- **MySQL CLI auto-download did not work on Windows.** `download_spec` had no Windows URL for MySQL, so `cli_ensure`/`cli_download_version` always fell back to the system-install hint on Windows even when auto-download was expected. Added the Windows `mysql-9.0.0-winx64.zip` archive URL alongside the existing macOS ones.
- **Downloaded CLI archives (mongosh, and now MySQL) silently failed to install on Windows.** `relocate_binaries` searched for unsuffixed binary names (`mongosh`, `mysql`) inside the extracted archive, but Windows archives contain `mongosh.exe`/`mysql.exe`, so the relocation step found nothing and the post-download verification reported "Binary not found after download." Fixed to search/move platform-suffixed binary names, with a fallback for archives that extract straight into `bin/` without a nested versioned subdirectory.
- **CLI subprocess calls flashed a console window on Windows.** `cli_get_version`, `cli_detect_pg_version`, `cli_test_connection`, `cli_execute_query`, and `cli_list_databases` all spawned `psql`/`mysql`/etc. without `CREATE_NO_WINDOW`, so each query or connection test briefly popped a console window. All five spawn sites now apply the same no-window flag already used elsewhere in the codebase for PowerShell calls.
- **Saved queries could silently disappear on Windows.** Windows machine-ID detection shells out to PowerShell/WMI (`Get-CimInstance`), which can be flaky between app launches (AV/EDR interference, WMI not yet ready on a fresh boot). A different result on a later launch silently rotated the machine-locked encryption key, and `load_saved_queries` treated the resulting decrypt/fingerprint failure as "no saved queries" with zero diagnostics. The first successfully-detected machine ID is now persisted to the OS keyring (matching the existing master-key persistence pattern) so it stays stable across launches, and fingerprint-mismatch/decrypt-failure paths now log a warning instead of failing silently. The frontend's `loadFromFile` also had a bare `catch {}` that swallowed the error with no log — it now logs via `logger.error`, matching `saveToFile`.
- **[#217](https://github.com/openidle-dev/queryden/issues/217) — Running a query now gives instant feedback and can't be triggered twice.** Pressing `Ctrl+Enter` previously showed no "running" state until *after* the database connection handshake (`Database.load`), which can take seconds on a cold connection — so the editor looked idle and a second press would run the query again. `setIsExecuting(true)` now fires synchronously at the start of execution (the spinner and tab status glyph appear the instant you press the shortcut), and a re-entrancy guard drops a duplicate trigger while a run is already in flight.
- **You can now close the last remaining query tab.** The tab close button was hidden whenever only one tab was open, leaving no way to close the final editor. The button is always available now; closing the last tab falls back to the empty-state launcher.
- **PSQL Console error messages no longer flash and disappear.** Errors in the CLI execution path (PostgreSQL version unknown, download cancelled, download failed, psql not found, `\watch` errors) are now committed as persistent `psqlConsoleEntry` entries before the live-output grace period expires, preventing the output from going blank after 300ms. The `\watch` loop-end path also commits accumulated output. Fixes Windows WebView2 race where `showLiveGrace` expired without entries.
Expand Down Expand Up @@ -54,6 +60,7 @@ All notable changes to QueryDen are documented here. This project adheres to [Se
- **PK detection now prefers real database metadata over name heuristics.** `loadTableDetails` queries actual PK columns from `information_schema.table_constraints` (PostgreSQL/MySQL) or `PRAGMA table_info` (SQLite). The post-SELECT FK auto-load path also detects PKs in parallel. All CRUD operations use a unified `getPrimaryKey` helper that checks `tableSchema.primaryKeys` first, then falls back to expanded name heuristics (`id`, `uuid`, `uid`, `pk`, `row_id`, `object_id`, `key`, `code`, `_id`, `{table}_id`).

### Added
- **[#39](https://github.com/openidle-dev/queryden/issues/39) — Settings → CLI Tools management UI.** A new settings section lists PostgreSQL/MySQL/MongoDB/Redis CLI status (system PATH detection), cached downloaded versions with disk usage, a remove button per cached version (new `cli_remove_cached` command), and a version picker to pre-download a tool before you need it — avoiding the wait for an ~80MB download mid-query. The existing inline download-on-demand flow during query execution remains as a fallback for anyone who skips this UI.
- **[#216](https://github.com/openidle-dev/queryden/issues/216) — The query editor now highlights the statement under the cursor (DataGrip-style "will run" block).** A subtle accent-tinted band (plus a thin accent bar in the line-number margin) marks the statement that `Ctrl+Enter` will execute, updating as the caret moves. It is suppressed when you have an active selection (the selection itself shows what runs) and when the whole buffer is a single statement. Run-at-cursor and the highlight share one resolver (`resolveStatementAtOffset`), so the highlight can never disagree with what actually runs.
- **FK-aware inline navigation in the results grid.** FK columns render as styled link cells with an external-link icon. Clicking the icon fires a `SELECT * FROM refTable WHERE refCol = value` query, navigating directly to the referenced row — enabling quick parent-row lookup from any query result.
- **Schema-aware Add Row modal.** The Add Row dialog now reads real column metadata (name, type, nullable, default) from the table schema. NOT NULL columns without a default are validated before save with inline error badges. FK columns render as searchable dropdowns (`loadFKOptions` queries the referenced table with ILIKE/LIKE search across display columns, returning up to 50 matches). Default values from column definitions are pre-filled (stripping type casts and surrounding quotes).
Expand Down
106 changes: 87 additions & 19 deletions src-tauri/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,20 @@ use tauri::{AppHandle, Emitter, Manager, State};
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::Command;

#[cfg(target_os = "windows")]
const CREATE_NO_WINDOW: u32 = 0x08000000;

/// Suppress the flashing console window Windows spawns for each subprocess.
/// No-op on other platforms.
fn no_console_window(cmd: &mut Command) -> &mut Command {
#[cfg(target_os = "windows")]
{
use tokio::process::CommandExt;
cmd.creation_flags(CREATE_NO_WINDOW);
}
cmd
}

// ─── Tool kind ───────────────────────────────────────────────────────────────

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
Expand Down Expand Up @@ -148,11 +162,13 @@ fn download_spec(kind: ToolKind, _major_version: Option<u32>) -> Option<Download
}
}
ToolKind::MySql => {
// MySQL compressed tarballs on dev.mysql.com
// MySQL compressed archives on dev.mysql.com
let url = if cfg!(all(target_os = "macos", target_arch = "aarch64")) {
"https://dev.mysql.com/get/Downloads/mysql-9.0.0-macos14-arm64.tar.gz"
} else if cfg!(all(target_os = "macos", target_arch = "x86_64")) {
"https://dev.mysql.com/get/Downloads/mysql-9.0.0-macos14-x86_64.tar.gz"
} else if cfg!(target_os = "windows") {
"https://dev.mysql.com/get/Downloads/mysql-9.0.0-winx64.zip"
} else {
return None;
};
Expand Down Expand Up @@ -360,6 +376,8 @@ impl CliManager {
let bin_dir = extracted_dir.join("bin");
std::fs::create_dir_all(&bin_dir).map_err(|e| e.to_string())?;

let ext = if cfg!(target_os = "windows") { ".exe" } else { "" };

// Scan extracted_dir for the actual binaries (they're in a versioned subdir)
let entries = std::fs::read_dir(extracted_dir).map_err(|e| e.to_string())?;
let top_level_dirs: Vec<PathBuf> = entries
Expand All @@ -369,17 +387,22 @@ impl CliManager {
.collect();

// Find the subdirectory that contains our binaries
// PostgreSQL: postgresql-16.8/bin/psql exists
// PostgreSQL: postgresql-16.8/bin/psql(.exe) exists
let subdir = top_level_dirs.iter().find(|d| {
let bin = d.join("bin");
bin.join(kind.primary_binary()).exists()
bin.join(format!("{}{}", kind.primary_binary(), ext)).exists()
});

if let Some(src_dir) = subdir {
for binary in kind.all_binaries() {
let src = src_dir.join("bin").join(binary);
if src.exists() {
let dst = bin_dir.join(binary);
// Some archives (e.g. mongosh, mysql on Windows) extract binaries straight
// into extracted_dir/bin rather than a nested versioned subdir.
let search_dir = subdir.cloned().unwrap_or_else(|| extracted_dir.clone());

for binary in kind.all_binaries() {
let name = format!("{}{}", binary, ext);
let src = search_dir.join("bin").join(&name);
if src.exists() {
let dst = bin_dir.join(&name);
if dst != src {
if dst.exists() {
std::fs::remove_file(&dst).ok();
}
Expand Down Expand Up @@ -465,6 +488,33 @@ pub struct CachedTool {
pub major_version: u32,
pub binaries: Vec<String>,
pub path: String,
pub size_bytes: u64,
}

/// Recursively sum file sizes under `path`.
fn dir_size(path: &std::path::Path) -> u64 {
let mut total = 0u64;
if let Ok(entries) = std::fs::read_dir(path) {
for entry in entries.flatten() {
let p = entry.path();
if p.is_dir() {
total += dir_size(&p);
} else if let Ok(meta) = entry.metadata() {
total += meta.len();
}
}
}
total
}

fn parse_tool_kind(tool_kind: &str) -> Result<ToolKind, String> {
match tool_kind {
"postgresql" => Ok(ToolKind::Psql),
"mysql" => Ok(ToolKind::MySql),
"mongodb" => Ok(ToolKind::Mongo),
"redis" => Ok(ToolKind::Redis),
_ => Err(format!("Unknown tool: {}", tool_kind)),
}
}

/// Check all tools — system and bundled — and return their status.
Expand Down Expand Up @@ -541,12 +591,29 @@ pub async fn cli_list_cached(
major_version,
binaries,
path: entry.path().to_string_lossy().to_string(),
size_bytes: dir_size(&entry.path()),
});
}

Ok(cached)
}

/// Remove a cached (downloaded) CLI tool version from disk to free space.
/// Does not touch system-installed binaries — only the versioned cache dir.
#[tauri::command]
pub async fn cli_remove_cached(
tool_kind: String,
major_version: u32,
manager: State<'_, CliManager>,
) -> Result<(), String> {
let kind = parse_tool_kind(&tool_kind)?;
let dir = manager.versioned_dir(kind, major_version);
if !dir.exists() {
return Ok(());
}
std::fs::remove_dir_all(&dir).map_err(|e| format!("Failed to remove cached tool: {}", e))
}

/// Download a specific version of a CLI tool.
#[tauri::command]
pub async fn cli_download_version(
Expand Down Expand Up @@ -734,11 +801,10 @@ pub async fn cli_get_version(
ToolKind::Mongo | ToolKind::Redis => "--version",
};

let output = Command::new(&binary)
.arg(flag)
.output()
.await
.map_err(|e| e.to_string())?;
let mut cmd = Command::new(&binary);
cmd.arg(flag);
no_console_window(&mut cmd);
let output = cmd.output().await.map_err(|e| e.to_string())?;

if !output.status.success() {
return Err("Version check failed".to_string());
Expand All @@ -765,8 +831,8 @@ pub async fn cli_detect_pg_version(
.await
.ok_or_else(|| ToolKind::Psql.system_install_hint().to_string())?;

let output = Command::new(&binary)
.env("PGPASSWORD", &password)
let mut cmd = Command::new(&binary);
cmd.env("PGPASSWORD", &password)
.arg("-h")
.arg(&host)
.arg("-p")
Expand All @@ -779,10 +845,9 @@ pub async fn cli_detect_pg_version(
.arg("-A") // unaligned
.arg("-w") // never prompt
.arg("-c")
.arg("SELECT version()")
.output()
.await
.map_err(|e| e.to_string())?;
.arg("SELECT version()");
no_console_window(&mut cmd);
let output = cmd.output().await.map_err(|e| e.to_string())?;

if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
Expand Down Expand Up @@ -876,6 +941,7 @@ pub async fn cli_test_connection(
}
}

no_console_window(&mut cmd);
let output = cmd.output().await.map_err(|e| e.to_string())?;

if !output.status.success() {
Expand Down Expand Up @@ -965,6 +1031,7 @@ pub async fn cli_execute_query(

cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());
no_console_window(&mut cmd);

let start = std::time::Instant::now();
let mut child = cmd.spawn().map_err(|e| format!("Spawn failed: {}", e))?;
Expand Down Expand Up @@ -1060,6 +1127,7 @@ pub async fn cli_list_databases(
_ => unreachable!(),
}

no_console_window(&mut cmd);
let output = cmd.output().await.map_err(|e| e.to_string())?;

if !output.status.success() {
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ pub fn run() {
get_build_info,
cli::cli_check_tools,
cli::cli_list_cached,
cli::cli_remove_cached,
cli::cli_check_tool,
cli::cli_check_system_tool,
cli::cli_ensure,
Expand Down
45 changes: 44 additions & 1 deletion src-tauri/src/storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,11 @@ const FALLBACK_MACHINE_ID: &str = "default-machine-id";
// result so the cost is paid once per process.
static MACHINE_ID_CACHE: OnceLock<String> = OnceLock::new();

// Lock guarding machine ID initialization to prevent concurrent cache-miss
// races (issue: concurrent threads could all miss the cache, run detection,
// and then race to persist different values to the keyring).
static MACHINE_ID_INIT_LOCK: Mutex<()> = Mutex::new(());

// Process-wide cache for the *derived* 32-byte encryption key.
//
// `get_encryption_key()` runs Argon2id on every call. With OWASP defaults
Expand Down Expand Up @@ -130,17 +135,51 @@ fn vault_password_fingerprint(pwd: &str) -> [u8; 32] {
// Use `try_get_machine_id()` for new encryption — it errors instead of weakening
// the key with a globally-known sentinel.
fn get_machine_id() -> String {
// Fast path: if cache is already populated, return immediately.
if let Some(cached) = MACHINE_ID_CACHE.get() {
return cached.clone();
}

// Acquire the init lock to serialize cache-miss initialization (double-
// checked locking pattern). Only one thread will perform detection and
// persist to the keyring; others will wait and then recheck the cache.
let _guard = MACHINE_ID_INIT_LOCK.lock().unwrap();

// Recheck the cache after acquiring the lock — a concurrent thread may
// have already populated it while we were waiting.
if let Some(cached) = MACHINE_ID_CACHE.get() {
return cached.clone();
}

// Windows machine-id detection shells out to PowerShell/WMI, which can be
// slow or transiently unavailable (AV/EDR hooks, WMI service not yet up
// on a fresh boot). A different result across app launches silently
// rotates the encryption key, so once we detect a real ID we persist it
// in the OS keyring and prefer that value over live re-detection.
if let Ok(entry) = Entry::new("queryden", "machine_id_cache") {
if let Ok(id) = entry.get_password() {
if !id.is_empty() {
let _ = MACHINE_ID_CACHE.set(id.clone());
return id;
}
}
}

let id = detect_machine_id();

// Only memoize real IDs. A transient detection failure (e.g. PowerShell
// missing from PATH momentarily) must not lock the process into the
// fallback sentinel — that would silently weaken every subsequent key.
if id != FALLBACK_MACHINE_ID {
// The lock is still held, so only the winning thread persists to both
// the cache and the keyring. Concurrent losers get nothing — they'll
// read the winner's cached value on their cache recheck above.
let _ = MACHINE_ID_CACHE.set(id.clone());
if let Ok(entry) = Entry::new("queryden", "machine_id_cache") {
if let Err(e) = entry.set_password(&id) {
warn!("Failed to persist machine ID to OS keyring: {e}");
}
}
Comment thread
kix007 marked this conversation as resolved.
}

id
Expand Down Expand Up @@ -1276,11 +1315,15 @@ pub fn load_saved_queries(app: tauri::AppHandle) -> Result<Vec<SavedQueryItem>,
match serde_json::from_str::<SavedQueryData>(&json) {
Ok(data) => {
if data.machine_fingerprint != get_machine_fingerprint() {
warn!("saved-queries.json machine fingerprint mismatch — refusing to load (file was created on a different machine, or machine ID detection is unstable on this one)");
return Ok(vec![]);
}
Ok(data.queries)
},
Err(_) => Ok(vec![])
Err(e) => {
warn!("saved-queries.json failed to decrypt/parse — treating as empty: {e}");
Ok(vec![])
}
}
}

Expand Down
Loading