diff --git a/.changeset/database-providers.md b/.changeset/database-providers.md new file mode 100644 index 00000000..15acb879 --- /dev/null +++ b/.changeset/database-providers.md @@ -0,0 +1,10 @@ +### New Features + +#### Database Providers +- **Sign in to a database provider** — connect **Neon, Supabase, PlanetScale, and Prisma Postgres** from inside Stroke. Authorize once (OAuth in your browser, or a pasted key), see every database on your account, and connect in one click — no hunting for connection strings. Supabase remembers your database password after the first connect. +- **Switch provider databases from the status bar** — the database switcher now lists your account's other databases and jumps to them directly. + +### Bug Fixes +- **Ctrl/Cmd+A** now selects the text inside a cell editor instead of selecting every row +- **Status badges** and **boolean glyphs** are now legible in light mode (they were tuned only for dark) +- The **foreign-key sub-view** now scrolls vertically and horizontally instead of trapping the wheel diff --git a/package-lock.json b/package-lock.json index 7a45e79f..608c6a0c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,20 @@ { "name": "stroke", - "version": "0.7.1", + "version": "1.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "stroke", - "version": "0.7.1", + "version": "1.5.0", "dependencies": { "@dagrejs/dagre": "^3.0.0", "@fontsource-variable/geist": "^5.2.9", "@fontsource-variable/geist-mono": "^5.2.8", "@fontsource-variable/inter": "^5.2.8", "@fontsource-variable/jetbrains-mono": "^5.2.8", + "@hugeicons/core-free-icons": "^4.2.2", + "@hugeicons/svelte": "^1.1.4", "@tailwindcss/vite": "^4.3.0", "@tanstack/svelte-hotkeys": "^0.10.0", "@tauri-apps/plugin-autostart": "^2.5.1", @@ -194,6 +196,22 @@ "url": "https://github.com/sponsors/ayuhito" } }, + "node_modules/@hugeicons/core-free-icons": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@hugeicons/core-free-icons/-/core-free-icons-4.2.2.tgz", + "integrity": "sha512-fjQW3p6cwoJmwK3oCnV8Z3PC5sHihDvr2jkAHax8cxqp62GaV/D7B/Rnao+JwicW8fmLZUQ6QrzWfoyMFOEQlQ==", + "license": "MIT" + }, + "node_modules/@hugeicons/svelte": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@hugeicons/svelte/-/svelte-1.1.4.tgz", + "integrity": "sha512-G/oYlqhJ6DTN64/BuOPiFBz6zfAvocUHiewA7x/ktOElwKPl0NM3TAnZcvTy+DsYRH6CD03JdNYrQTu/mPFMBw==", + "hasInstallScript": true, + "license": "MIT", + "peerDependencies": { + "svelte": "^5.0.0" + } + }, "node_modules/@iconify/types": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", diff --git a/package.json b/package.json index 9e2d54ce..3d70577b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "stroke", "private": true, - "version": "1.5.1", + "version": "1.6.0", "type": "module", "scripts": { "dev": "vite", @@ -32,6 +32,8 @@ "@fontsource-variable/geist-mono": "^5.2.8", "@fontsource-variable/inter": "^5.2.8", "@fontsource-variable/jetbrains-mono": "^5.2.8", + "@hugeicons/core-free-icons": "^4.2.2", + "@hugeicons/svelte": "^1.1.4", "@tailwindcss/vite": "^4.3.0", "@tanstack/svelte-hotkeys": "^0.10.0", "@tauri-apps/plugin-autostart": "^2.5.1", diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 28fc2359..0de4d5d3 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -669,6 +669,13 @@ pub async fn run_license_check( } } + // No valid paid license → reconcile the trial clock with the server so a + // reinstall/disk-wipe can't grant a fresh trial (server keeps the earliest + // start per device). Silent no-op when offline. + if crate::license::load_license(&dir).is_none() { + crate::license::reconcile_trial(&dir).await; + } + crate::license::check_status(&dir) } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 92b28843..911f322f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -6,6 +6,7 @@ mod docker; mod license; mod mcp; mod metrics; +mod providers; mod secrets; use db::{ActiveConnection, DbState, TunnelState}; @@ -146,6 +147,15 @@ pub fn run() { view.setMagnification(1.0); view.setPageZoom(1.0); }); + + // The window is frameless (decorations = false), so no application + // menu is created automatically. On macOS, WKWebView text fields + // rely on the app menu's Edit items for the standard editing + // shortcuts — ⌘Z undo, ⌘⇧Z redo, ⌘X/⌘C/⌘V, ⌘A select-all, and + // ⌥⌫ / ⌘⌫ word/line delete. Install the standard menu so native + // text editing works everywhere (inputs, textareas, cell editors). + let menu = tauri::menu::Menu::default(app.handle())?; + app.set_menu(menu)?; } if cfg!(debug_assertions) { @@ -296,6 +306,13 @@ pub fn run() { cloudflare::cloudflare_logout, cloudflare::cloudflare_list_accounts, cloudflare::cloudflare_list_d1_databases, + providers::provider_start_oauth, + providers::provider_cancel_oauth, + providers::provider_store_token, + providers::provider_oauth_status, + providers::provider_logout, + providers::provider_list_databases, + providers::provider_build_connection, db::backup::backup_export, db::backup::backup_import, commands::check_license_status, diff --git a/src-tauri/src/license.rs b/src-tauri/src/license.rs index ec48c347..f2712937 100644 --- a/src-tauri/src/license.rs +++ b/src-tauri/src/license.rs @@ -6,8 +6,13 @@ /// After first activation the decoded data is written to: /// /license.json /// -/// Trial is tracked in: -/// /.trial (8 LE bytes = u64 unix timestamp of first launch) +/// Trial is tracked in TWO locations (8 LE bytes = u64 unix timestamp of first launch): +/// /.trial — removed on a full uninstall +/// /.stroke/trial — survives uninstall, so reinstalling does NOT +/// grant a fresh trial. The earliest timestamp +/// across both wins, and both are repaired on read. +/// (For tamper-proof enforcement across full disk wipes, register the trial +/// start server-side keyed by device_id — the API scaffolding already exists.) /// /// ------------------------------------------------------------------- /// SETUP: run `node scripts/license-tool.mjs keygen` once. @@ -27,7 +32,7 @@ const STROKE_API: &str = "https://stroke.click/api/license"; pub const PUBLIC_KEY_HEX: &str = "b5f14fed4694302a767e32c4e6a50ba0074c4e50f5198ad781de00d0e8ba8771"; -pub const TRIAL_DAYS: i64 = 5; +pub const TRIAL_DAYS: i64 = 17; const LICENSE_FILE: &str = "license.json"; const TRIAL_FILE: &str = ".trial"; @@ -92,6 +97,23 @@ pub fn trial_path(data_dir: &Path) -> PathBuf { data_dir.join(TRIAL_FILE) } +/// The user's home directory, cross-platform (HOME on unix/macOS, USERPROFILE on Windows). +fn home_dir() -> Option { + std::env::var_os("HOME") + .or_else(|| std::env::var_os("USERPROFILE")) + .map(PathBuf::from) +} + +/// All locations the trial start is mirrored to. The app-data copy is wiped on +/// uninstall; the home-dir copy survives, so a reinstall can't reset the trial. +fn trial_paths(data_dir: &Path) -> Vec { + let mut paths = vec![trial_path(data_dir)]; + if let Some(home) = home_dir() { + paths.push(home.join(".stroke").join("trial")); + } + paths +} + // ── Device fingerprint ──────────────────────────────────────────────────────── /// Returns a stable SHA-256 hex fingerprint for this machine. @@ -174,43 +196,92 @@ fn now_secs() -> u64 { .unwrap_or(0) } -/// Reads trial start, creating the file on first call. -/// Returns `days_remaining` (negative = expired). +/// Reads the trial start across all mirror locations and returns `days_remaining` +/// (negative = expired). The EARLIEST timestamp found wins — so deleting one copy +/// (e.g. uninstalling, which wipes app-data) can't grant a fresh trial while the +/// home-dir copy survives. Every location is (re)written with the winning start so +/// a missing/newer copy is repaired on each launch. pub fn trial_days_remaining(data_dir: &Path) -> i64 { - let path = trial_path(data_dir); - let start_secs = if path.exists() { - std::fs::read(&path) - .ok() - .and_then(|b| b.try_into().ok()) - .map(u64::from_le_bytes) - .unwrap_or_else(now_secs) - } else { - // First launch — record now - let _ = std::fs::create_dir_all(data_dir); - let now = now_secs(); - let _ = std::fs::write(&path, now.to_le_bytes()); - now - }; + let paths = trial_paths(data_dir); + + let mut earliest: Option = None; + for p in &paths { + if let Ok(bytes) = std::fs::read(p) { + if let Ok(arr) = <[u8; 8]>::try_from(bytes.as_slice()) { + let t = u64::from_le_bytes(arr); + earliest = Some(earliest.map_or(t, |e| e.min(t))); + } + } + } + + // First launch on this device (or every copy was removed) — start now. + let start_secs = earliest.unwrap_or_else(now_secs); + + // Propagate/repair the winning start to every location. + for p in &paths { + if let Some(dir) = p.parent() { + let _ = std::fs::create_dir_all(dir); + } + let _ = std::fs::write(p, start_secs.to_le_bytes()); + } let elapsed_days = (now_secs().saturating_sub(start_secs) / 86_400) as i64; TRIAL_DAYS - elapsed_days } +/// The earliest trial start recorded across local mirrors, without writing. +/// `None` if no local trial file exists yet (e.g. a fresh install). +fn local_trial_start(data_dir: &Path) -> Option { + let mut earliest: Option = None; + for p in trial_paths(data_dir) { + if let Ok(bytes) = std::fs::read(&p) { + if let Ok(arr) = <[u8; 8]>::try_from(bytes.as_slice()) { + let t = u64::from_le_bytes(arr); + earliest = Some(earliest.map_or(t, |e| e.min(t))); + } + } + } + earliest +} + +/// Phone home to sync the trial clock with the server (earliest-wins). The +/// server records the start the first time it sees this device and always +/// returns the earliest known start — so a reinstall or disk wipe can't hand +/// out a fresh trial. On success the authoritative start is written to every +/// local mirror so the next local check reflects it. No-op when offline. +pub async fn reconcile_trial(data_dir: &Path) { + let local = local_trial_start(data_dir); + if let Some(server_start) = api_trial(&device_id(), local).await { + for p in trial_paths(data_dir) { + if let Some(dir) = p.parent() { + let _ = std::fs::create_dir_all(dir); + } + let _ = std::fs::write(&p, server_start.to_le_bytes()); + } + } +} + /// DEBUG ONLY — write a trial start timestamp N days in the past so the UI /// shows "N days elapsed" without waiting for real time to pass. #[cfg(debug_assertions)] pub fn debug_set_trial_days_ago(data_dir: &Path, days_ago: u64) -> Result<(), String> { - std::fs::create_dir_all(data_dir).map_err(|e| e.to_string())?; let fake_start = now_secs().saturating_sub(days_ago * 86_400); - std::fs::write(trial_path(data_dir), fake_start.to_le_bytes()).map_err(|e| e.to_string()) + for p in trial_paths(data_dir) { + if let Some(dir) = p.parent() { + std::fs::create_dir_all(dir).map_err(|e| e.to_string())?; + } + std::fs::write(&p, fake_start.to_le_bytes()).map_err(|e| e.to_string())?; + } + Ok(()) } -/// DEBUG ONLY — delete the trial file so the next check starts a fresh trial. +/// DEBUG ONLY — delete every trial mirror so the next check starts a fresh trial. #[cfg(debug_assertions)] pub fn debug_reset_trial(data_dir: &Path) -> Result<(), String> { - let p = trial_path(data_dir); - if p.exists() { - std::fs::remove_file(p).map_err(|e| e.to_string())?; + for p in trial_paths(data_dir) { + if p.exists() { + std::fs::remove_file(&p).map_err(|e| e.to_string())?; + } } Ok(()) } @@ -400,6 +471,24 @@ pub async fn api_check(key: &str, device_id: &str) -> Option { Some(json.get("valid").and_then(|v| v.as_bool()).unwrap_or(false)) } +/// POST /api/license/trial — register/sync this device's trial start. +/// Sends the local start (if any) so the server can keep the earliest; returns +/// the server's authoritative start (unix seconds), or None on network error. +pub async fn api_trial(device_id: &str, local_started_at: Option) -> Option { + let mut body = serde_json::json!({ "device_id": device_id, "hostname": hostname() }); + if let Some(s) = local_started_at { + body["started_at"] = serde_json::json!(s); + } + let resp = http_client() + .post(format!("{STROKE_API}/trial")) + .json(&body) + .send() + .await + .ok()?; + let json: serde_json::Value = resp.json().await.ok()?; + json.get("started_at").and_then(|v| v.as_u64()) +} + /// POST /api/license/deactivate — releases the seat on the server. /// Fire-and-forget; silently ignores errors. pub async fn api_deactivate(key: &str, device_id: &str) { diff --git a/src-tauri/src/providers/mod.rs b/src-tauri/src/providers/mod.rs new file mode 100644 index 00000000..e18d0cf4 --- /dev/null +++ b/src-tauri/src/providers/mod.rs @@ -0,0 +1,652 @@ +/*! + * Database provider adapters — sign in to a hosting provider (Neon, Supabase, + * PlanetScale, Prisma Postgres) with OAuth, list every database on the account, + * and connect in one click without hunting for connection strings. + * + * Design: this module owns the shared OAuth 2.0 + PKCE machinery (mirrors + * `cloudflare.rs`, kept separate so the working D1 flow is untouched) and + * dispatches provider-specific work — listing databases, building a connectable + * spec — to the per-provider submodules via a `Provider` enum. Every provider is + * a *public* PKCE client: no client secret is shipped in the binary. + * + * Adding a provider = add an enum variant, its `OAuthConfig`, and a submodule + * with `list_databases()` + `build_connection()`. + */ + +mod neon; +mod planetscale; +mod prisma; +mod supabase; + +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::sync::OnceLock; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpListener; + +// ── How long to wait for the user to authorize before giving up ──────────────── +const AUTH_TIMEOUT_SECS: u64 = 300; + +// Localhost callback ports Stroke registers as redirect URIs on each provider's +// OAuth app: http://localhost:{port}/oauth/callback. Any one that is free is used. +const CALLBACK_PORTS: &[u16] = &[8989, 8990, 8991, 8992, 8993]; + +// Token-exchange proxy. Neon/Supabase/PlanetScale are confidential OAuth clients +// whose token endpoint requires a client_secret — which must never ship in a +// desktop binary. The stroke-web app (TanStack Start on Cloudflare Workers) +// hosts a server route that holds the secrets, injects them server-side, and +// forwards to the real provider token endpoint. The app sends only the public +// client_id + PKCE verifier. See stroke-web: src/routes/api/oauth/token.ts. +const TOKEN_PROXY: &str = "https://stroke.click/api/oauth/token"; + +// Lets the frontend abort an in-flight OAuth wait (Cancel button). Notifying it +// drops the callback listener future, which frees the localhost port instead of +// holding it until the 5-minute timeout. +static OAUTH_CANCEL: OnceLock = OnceLock::new(); +fn oauth_cancel() -> &'static tokio::sync::Notify { + OAUTH_CANCEL.get_or_init(tokio::sync::Notify::new) +} + +// ── Provider registry ────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Provider { + Neon, + Supabase, + PlanetScale, + Prisma, +} + +impl Provider { + fn parse(s: &str) -> Result { + match s { + "neon" => Ok(Self::Neon), + "supabase" => Ok(Self::Supabase), + "planetscale" => Ok(Self::PlanetScale), + "prisma" => Ok(Self::Prisma), + other => Err(format!("Unknown provider: {other}")), + } + } + + /// Stable key used to namespace stored tokens (`__{key}_refresh__`, …). + fn key(&self) -> &'static str { + match self { + Self::Neon => "neon", + Self::Supabase => "supabase", + Self::PlanetScale => "planetscale", + Self::Prisma => "prisma", + } + } + + fn oauth(&self) -> OAuthConfig { + match self { + Self::Neon => neon::OAUTH, + Self::Supabase => supabase::OAUTH, + Self::PlanetScale => planetscale::OAUTH, + Self::Prisma => prisma::OAUTH, + } + } + + /// Whether a provider uses a pasted API token instead of the browser OAuth + /// dance. None currently do (Prisma moved to Management-API OAuth), but the + /// hook stays so a future token-only provider can opt in. + fn is_token_based(&self) -> bool { + false + } + + /// Localhost callback ports to try, in order. PlanetScale accepts only ONE + /// registered redirect URI, so it must always land on a fixed port (8989) or + /// the redirect_uri won't match. Providers that allow multiple registered + /// redirects use the full range so a busy port can fall back. + fn callback_ports(&self) -> &'static [u16] { + match self { + Self::PlanetScale => &[8989], + _ => CALLBACK_PORTS, + } + } + + /// Whether the provider's authorize/token endpoints support PKCE. PlanetScale + /// does NOT — sending code_challenge makes it reject the request — so it uses + /// a plain confidential authorization-code flow (secret only, via the proxy). + fn uses_pkce(&self) -> bool { + !matches!(self, Self::PlanetScale) + } + + /// Public PKCE clients ship no secret, so the token exchange goes DIRECTLY to + /// the provider (no stroke.click proxy). Neon reuses neonctl's public client. + fn is_public_client(&self) -> bool { + matches!(self, Self::Neon) + } + + /// The loopback redirect URI. Most providers registered + /// `http://localhost:{port}/oauth/callback`; Neon reuses neonctl's client, + /// which registered `http://127.0.0.1:{port}/callback`. + fn redirect_uri(&self, port: u16) -> String { + match self { + Self::Neon => format!("http://127.0.0.1:{port}/callback"), + _ => format!("http://localhost:{port}/oauth/callback"), + } + } + + async fn list_databases(&self, token: &str) -> Result, String> { + match self { + Self::Neon => neon::list_databases(token).await, + Self::Supabase => supabase::list_databases(token).await, + Self::PlanetScale => planetscale::list_databases(token).await, + Self::Prisma => prisma::list_databases(token).await, + } + } + + async fn build_connection( + &self, + token: &str, + db_ref: &str, + ) -> Result { + match self { + Self::Neon => neon::build_connection(token, db_ref).await, + Self::Supabase => supabase::build_connection(token, db_ref).await, + Self::PlanetScale => planetscale::build_connection(token, db_ref).await, + Self::Prisma => prisma::build_connection(token, db_ref).await, + } + } +} + +/// Per-provider OAuth endpoints. `client_id` is a public identifier registered +/// with the provider; there is intentionally no secret (public PKCE client). +#[derive(Clone, Copy)] +pub struct OAuthConfig { + pub client_id: &'static str, + pub auth_url: &'static str, + // Kept for documentation/parity; the real exchange happens in the proxy. + #[allow(dead_code)] + pub token_url: &'static str, + pub scopes: &'static str, +} + +// ── Shared types returned to the frontend ─────────────────────────────────────── + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ProviderDatabase { + /// Opaque reference (often JSON) consumed by `build_connection`. + pub db_ref: String, + pub name: String, + pub region: Option, + /// Kind label shown in the UI, e.g. "Project", "Branch", "Database". + pub kind: Option, + /// Host preview for the list row, when cheaply available. + pub host: Option, +} + +/// Everything the frontend needs to construct a `SavedConnection` and connect. +#[derive(Debug, Serialize, Deserialize, Clone, Default)] +pub struct ProviderConnection { + pub db_type: String, // "postgres" | "mysql" + pub host: String, + pub port: u16, + pub username: String, + pub password: String, + pub database: String, + pub ssl: bool, + /// True when the provider will not expose a password (Supabase). The UI must + /// prompt the user for it; every other field is prefilled. + pub needs_password: bool, + pub name: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ProviderOAuthStatus { + pub connected: bool, + pub email: Option, +} + +// ── Shared HTTP client ────────────────────────────────────────────────────────── + +static CLIENT: OnceLock = OnceLock::new(); + +pub(crate) fn http() -> &'static reqwest::Client { + CLIENT.get_or_init(|| { + reqwest::Client::builder() + .user_agent("stroke/1.0") + .tcp_keepalive(std::time::Duration::from_secs(60)) + .pool_max_idle_per_host(4) + .build() + .expect("failed to build provider HTTP client") + }) +} + +// ── PKCE helpers ───────────────────────────────────────────────────────────────── + +fn random_base64url(n: usize) -> String { + let mut bytes = vec![0u8; n]; + getrandom::getrandom(&mut bytes).expect("getrandom failed"); + URL_SAFE_NO_PAD.encode(&bytes) +} + +/// (verifier, challenge) where challenge = BASE64URL(SHA256(verifier)). +fn pkce_pair() -> (String, String) { + let verifier = random_base64url(32); + let challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes())); + (verifier, challenge) +} + +fn now_secs() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +// ── Local callback server ──────────────────────────────────────────────────────── + +async fn bind_callback_listener(ports: &[u16]) -> Result<(TcpListener, u16), String> { + for &port in ports { + if let Ok(listener) = TcpListener::bind(format!("127.0.0.1:{port}")).await { + return Ok((listener, port)); + } + } + if ports.len() == 1 { + return Err(format!( + "Port {} is in use — this provider requires that exact port for its OAuth redirect. \ + Close whatever is using it (another Stroke window or dev server) and retry.", + ports[0] + )); + } + Err(format!( + "Could not bind any callback port ({}-{}). Close other apps using them and retry.", + ports[0], + ports[ports.len() - 1] + )) +} + +const OK_HTML: &str = r#"Stroke — authorized + +

Authorization successful

You can close this tab and return to Stroke.

"#; + +const ERR_HTML: &str = r#"Stroke — error + +

Authorization failed

You can close this tab and try again in Stroke.

"#; + +/// Wait for one OAuth redirect and return the authorization code. +async fn await_oauth_callback(listener: TcpListener, expected_state: &str) -> Result { + let send = |html: &str| { + format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + html.len(), + html + ) + }; + + let (mut stream, _) = listener + .accept() + .await + .map_err(|e| format!("Callback accept failed: {e}"))?; + + let mut buf = vec![0u8; 8192]; + let n = stream + .read(&mut buf) + .await + .map_err(|e| format!("Callback read failed: {e}"))?; + let req = String::from_utf8_lossy(&buf[..n]); + + let first_line = req.lines().next().unwrap_or(""); + let query = first_line + .split_whitespace() + .nth(1) + .unwrap_or("") + .split('?') + .nth(1) + .unwrap_or(""); + + let (mut code, mut state, mut error) = (None, None, None); + for pair in query.split('&') { + let mut kv = pair.splitn(2, '='); + let key = kv.next().unwrap_or(""); + let val = kv + .next() + .map(|v| urlencoding::decode(v).unwrap_or_default().into_owned()) + .unwrap_or_default(); + match key { + "code" => code = Some(val), + "state" => state = Some(val), + "error" => error = Some(val), + "error_description" if error.is_none() => error = Some(val), + _ => {} + } + } + + if let Some(err) = error { + let _ = stream.write_all(send(ERR_HTML).as_bytes()).await; + return Err(format!("Provider denied authorization: {err}")); + } + let code = match code { + Some(c) if !c.is_empty() => c, + _ => { + let _ = stream.write_all(send(ERR_HTML).as_bytes()).await; + return Err("No authorization code in callback".into()); + } + }; + if state.as_deref() != Some(expected_state) { + let _ = stream.write_all(send(ERR_HTML).as_bytes()).await; + return Err("OAuth state mismatch — possible CSRF".into()); + } + + let _ = stream.write_all(send(OK_HTML).as_bytes()).await; + let _ = stream.flush().await; + Ok(code) +} + +// ── Token exchange / refresh ───────────────────────────────────────────────────── + +struct TokenResponse { + access_token: String, + refresh_token: Option, + expires_in: Option, +} + +/// POST a token request to `url` — either the real provider token endpoint +/// (public clients) or the stroke.click proxy (confidential clients, where the +/// proxy injects the secret and `params` includes `provider`). +async fn post_token(url: &str, params: &[(&str, &str)]) -> Result { + let resp = http() + .post(url) + .form(params) + .send() + .await + .map_err(|e| format!("Token request failed: {e}"))?; + let status = resp.status().as_u16(); + let text = resp + .text() + .await + .map_err(|e| format!("Token request failed: {e}"))?; + // A non-JSON body from the proxy usually means it isn't deployed (the request + // hit the marketing site's HTML). Surface that clearly. + let body: serde_json::Value = serde_json::from_str(&text).map_err(|_| { + let snippet: String = text.chars().take(160).collect(); + let hint = if url == TOKEN_PROXY { + " (is the stroke.click/api/oauth/token proxy deployed?)" + } else { + "" + }; + format!("Token exchange returned a non-JSON response (HTTP {status}){hint}: {snippet}") + })?; + if status != 200 { + let msg = body["error_description"] + .as_str() + .or_else(|| body["error"].as_str()) + .unwrap_or("Token request failed"); + return Err(format!("OAuth token error ({status}): {msg}")); + } + Ok(TokenResponse { + access_token: body["access_token"] + .as_str() + .ok_or("Missing access_token")? + .to_string(), + refresh_token: body["refresh_token"].as_str().map(String::from), + expires_in: body["expires_in"].as_u64(), + }) +} + +async fn exchange_code( + cfg: &OAuthConfig, + provider_key: &str, + is_public: bool, + code: &str, + verifier: Option<&str>, + redirect_uri: &str, +) -> Result { + let mut params = vec![ + ("grant_type", "authorization_code"), + ("code", code), + ("redirect_uri", redirect_uri), + ("client_id", cfg.client_id), + ]; + if let Some(v) = verifier { + params.push(("code_verifier", v)); + } + if is_public { + // Direct to the provider — no secret, no proxy. + post_token(cfg.token_url, ¶ms).await + } else { + // Via the proxy, which injects the client_secret. + params.insert(0, ("provider", provider_key)); + post_token(TOKEN_PROXY, ¶ms).await + } +} + +async fn refresh_token( + cfg: &OAuthConfig, + provider_key: &str, + is_public: bool, + refresh: &str, +) -> Result { + let mut params = vec![ + ("grant_type", "refresh_token"), + ("refresh_token", refresh), + ("client_id", cfg.client_id), + ]; + if is_public { + post_token(cfg.token_url, ¶ms).await + } else { + params.insert(0, ("provider", provider_key)); + post_token(TOKEN_PROXY, ¶ms).await + } +} + +// ── Token storage (namespaced per provider in the secrets store) ───────────────── + +fn store_tokens( + app: &tauri::AppHandle, + p: Provider, + access: &str, + refresh: Option<&str>, + expires_in: Option, + email: Option<&str>, +) -> Result<(), String> { + let k = p.key(); + let mut map = crate::secrets::read_all(app); + map.insert(format!("__{k}_access__"), access.to_string()); + if let Some(r) = refresh { + map.insert(format!("__{k}_refresh__"), r.to_string()); + } + if let Some(exp) = expires_in { + map.insert(format!("__{k}_expires__"), (now_secs() + exp - 30).to_string()); + } + if let Some(e) = email { + map.insert(format!("__{k}_email__"), e.to_string()); + } + crate::secrets::write_all(app, &map) +} + +fn clear_tokens(app: &tauri::AppHandle, p: Provider) -> Result<(), String> { + let k = p.key(); + let mut map = crate::secrets::read_all(app); + for suffix in ["access", "refresh", "expires", "email"] { + map.remove(&format!("__{k}_{suffix}__")); + } + crate::secrets::write_all(app, &map) +} + +/// A valid access token, refreshing transparently if the stored one expired. +/// For token-based providers, the "access token" is the pasted API key. +async fn valid_token(app: &tauri::AppHandle, p: Provider) -> Result { + let k = p.key(); + let map = crate::secrets::read_all(app); + let access = map + .get(&format!("__{k}_access__")) + .cloned() + .ok_or("Not signed in to this provider")?; + + if p.is_token_based() { + return Ok(access); + } + + let expires = map + .get(&format!("__{k}_expires__")) + .and_then(|s| s.parse::().ok()) + .unwrap_or(0); + if now_secs() < expires { + return Ok(access); + } + + let refresh = map + .get(&format!("__{k}_refresh__")) + .cloned() + .ok_or("Session expired — please sign in again")?; + let cfg = p.oauth(); + let t = refresh_token(&cfg, p.key(), p.is_public_client(), &refresh).await?; + store_tokens( + app, + p, + &t.access_token, + t.refresh_token.as_deref().or(Some(&refresh)), + t.expires_in, + None, + )?; + Ok(t.access_token) +} + +// ── Tauri commands ─────────────────────────────────────────────────────────────── + +/// Start the browser OAuth flow for a provider and store the resulting tokens. +#[tauri::command] +pub async fn provider_start_oauth( + app: tauri::AppHandle, + provider: String, +) -> Result { + let p = Provider::parse(&provider)?; + if p.is_token_based() { + return Err("This provider uses an API token, not OAuth".into()); + } + let cfg = p.oauth(); + let (verifier, challenge) = pkce_pair(); + let state = random_base64url(16); + + // Abort any prior in-flight OAuth wait (e.g. a sign-in the user abandoned in + // the browser) so its callback listener is dropped and the port is freed + // before we bind. Otherwise a stuck flow holding 8989 forces this one onto a + // different port, which won't match the redirect URI registered with the + // provider ("Invalid Redirect URI"). The short pause lets the listener drop. + oauth_cancel().notify_waiters(); + tokio::time::sleep(std::time::Duration::from_millis(150)).await; + + let (listener, port) = bind_callback_listener(p.callback_ports()).await?; + let redirect_uri = p.redirect_uri(port); + + // Only include &scope= when the provider uses request-time scopes. + let scope_param = if cfg.scopes.is_empty() { + String::new() + } else { + format!("&scope={}", urlencoding::encode(cfg.scopes)) + }; + // Only include PKCE params for providers that support it (PlanetScale doesn't). + let pkce_param = if p.uses_pkce() { + format!("&code_challenge={}&code_challenge_method=S256", urlencoding::encode(&challenge)) + } else { + String::new() + }; + let auth_url = format!( + "{}?response_type=code&client_id={}&redirect_uri={}{}&state={}{}", + cfg.auth_url, + urlencoding::encode(cfg.client_id), + urlencoding::encode(&redirect_uri), + scope_param, + urlencoding::encode(&state), + pkce_param, + ); + + eprintln!("[provider oauth] {} authorize URL: {auth_url}", p.key()); + tauri_plugin_opener::OpenerExt::opener(&app) + .open_url(auth_url, None::<&str>) + .map_err(|e| format!("Could not open browser: {e}"))?; + + // Register the cancel waiter BEFORE awaiting so a Cancel click can't slip + // through between opening the browser and starting to wait. + let cancelled = oauth_cancel().notified(); + let code = tokio::select! { + r = tokio::time::timeout( + std::time::Duration::from_secs(AUTH_TIMEOUT_SECS), + await_oauth_callback(listener, &state), + ) => r.map_err(|_| "Authorization timed out".to_string())??, + // Dropping the other branch's future here drops `listener` → port freed. + _ = cancelled => return Err("cancelled".to_string()), + }; + + let verifier_opt = if p.uses_pkce() { Some(verifier.as_str()) } else { None }; + let t = exchange_code(&cfg, p.key(), p.is_public_client(), &code, verifier_opt, &redirect_uri).await?; + store_tokens( + &app, + p, + &t.access_token, + t.refresh_token.as_deref(), + t.expires_in, + None, + )?; + + Ok(ProviderOAuthStatus { + connected: true, + email: None, + }) +} + +/// Abort an in-flight OAuth wait (Cancel button) — frees the callback port. +#[tauri::command] +pub fn provider_cancel_oauth() { + oauth_cancel().notify_waiters(); +} + +/// Store a pasted API token for a token-based provider (e.g. Prisma). +#[tauri::command] +pub fn provider_store_token( + app: tauri::AppHandle, + provider: String, + token: String, +) -> Result<(), String> { + let p = Provider::parse(&provider)?; + if token.trim().is_empty() { + return clear_tokens(&app, p); + } + store_tokens(&app, p, token.trim(), None, None, None) +} + +#[tauri::command] +pub fn provider_oauth_status( + app: tauri::AppHandle, + provider: String, +) -> Result { + let p = Provider::parse(&provider)?; + let map = crate::secrets::read_all(&app); + let k = p.key(); + Ok(ProviderOAuthStatus { + connected: map.contains_key(&format!("__{k}_access__")), + email: map.get(&format!("__{k}_email__")).cloned(), + }) +} + +#[tauri::command] +pub fn provider_logout(app: tauri::AppHandle, provider: String) -> Result<(), String> { + clear_tokens(&app, Provider::parse(&provider)?) +} + +#[tauri::command] +pub async fn provider_list_databases( + app: tauri::AppHandle, + provider: String, +) -> Result, String> { + let p = Provider::parse(&provider)?; + let token = valid_token(&app, p).await?; + p.list_databases(&token).await +} + +#[tauri::command] +pub async fn provider_build_connection( + app: tauri::AppHandle, + provider: String, + db_ref: String, +) -> Result { + let p = Provider::parse(&provider)?; + let token = valid_token(&app, p).await?; + p.build_connection(&token, &db_ref).await +} diff --git a/src-tauri/src/providers/neon.rs b/src-tauri/src/providers/neon.rs new file mode 100644 index 00000000..93f92dd6 --- /dev/null +++ b/src-tauri/src/providers/neon.rs @@ -0,0 +1,146 @@ +/*! + * Neon adapter — the reference implementation. Neon is a public PKCE OAuth + * client and its API hands back a full `postgres://` connection URI (password + * included), so sign-in → list projects → one-click connect works end-to-end. + */ + +use super::{http, OAuthConfig, ProviderConnection, ProviderDatabase}; +use serde_json::Value; + +pub const OAUTH: OAuthConfig = OAuthConfig { + // Neon doesn't offer self-serve OAuth apps (partner-only), so — like we do + // with Cloudflare/Wrangler — we reuse the official neonctl CLI's PUBLIC PKCE + // client. No secret, no proxy: the token exchange goes straight to Neon + // (see Provider::is_public_client). Redirect must be 127.0.0.1/callback, + // which neonctl's client registered (see Provider::redirect_uri). + client_id: "neonctl", + auth_url: "https://oauth2.neon.tech/oauth2/auth", + token_url: "https://oauth2.neon.tech/oauth2/token", + scopes: "openid offline offline_access urn:neoncloud:projects:read urn:neoncloud:projects:create urn:neoncloud:orgs:read", +}; + +const API: &str = "https://console.neon.tech/api/v2"; + +async fn get(token: &str, path: &str) -> Result { + let resp = http() + .get(format!("{API}{path}")) + .bearer_auth(token) + .send() + .await + .map_err(|e| format!("Neon request failed: {e}"))?; + let status = resp.status().as_u16(); + let text = resp + .text() + .await + .map_err(|e| format!("Neon read failed: {e}"))?; + let body: Value = serde_json::from_str(&text).map_err(|_| { + let snippet: String = text.chars().take(200).collect(); + format!("Neon non-JSON response from {path} (HTTP {status}): {snippet}") + })?; + if status != 200 { + let msg = body["message"].as_str().unwrap_or("request failed"); + return Err(format!("Neon API error ({status}) on {path}: {msg}")); + } + Ok(body) +} + +/// Each Neon project is presented as one connectable database. Neon scopes +/// projects under organizations, so `/projects` requires an `org_id` — list the +/// user's orgs first, then each org's projects, and flatten them. +pub async fn list_databases(token: &str) -> Result, String> { + let orgs_body = get(token, "/users/me/organizations").await?; + let orgs = orgs_body["organizations"].as_array().cloned().unwrap_or_default(); + + let mut out = Vec::new(); + for org in &orgs { + let Some(org_id) = org["id"].as_str() else { continue }; + let body = get(token, &format!("/projects?org_id={}", urlencoding::encode(org_id))).await?; + for p in body["projects"].as_array().cloned().unwrap_or_default() { + if let Some(id) = p["id"].as_str() { + out.push(ProviderDatabase { + db_ref: id.to_string(), + name: p["name"].as_str().unwrap_or(id).to_string(), + region: p["region_id"].as_str().map(String::from), + kind: Some("Project".into()), + host: None, + }); + } + } + } + Ok(out) +} + +/// Resolve a project → default branch → its first database + owner role → +/// connection URI. Databases/roles live under a branch in Neon, not the project. +pub async fn build_connection(token: &str, project_id: &str) -> Result { + // Find the project's default branch (fall back to the first). + let branches = get(token, &format!("/projects/{project_id}/branches")).await?; + let branch_list = branches["branches"].as_array().cloned().unwrap_or_default(); + let branch = branch_list + .iter() + .find(|b| b["default"].as_bool() == Some(true) || b["primary"].as_bool() == Some(true)) + .or_else(|| branch_list.first()) + .ok_or("This Neon project has no branches")?; + let branch_id = branch["id"].as_str().ok_or("Neon: missing branch id")?; + + let dbs = get(token, &format!("/projects/{project_id}/branches/{branch_id}/databases")).await?; + let first = dbs["databases"] + .as_array() + .and_then(|a| a.first()) + .ok_or("This Neon branch has no databases yet")?; + let db_name = first["name"].as_str().ok_or("Neon: missing database name")?; + let role = first["owner_name"].as_str().ok_or("Neon: missing owner role")?; + + let uri_body = get( + token, + &format!( + "/projects/{project_id}/connection_uri?branch_id={}&database_name={}&role_name={}&pooled=true", + urlencoding::encode(branch_id), + urlencoding::encode(db_name), + urlencoding::encode(role), + ), + ) + .await?; + let uri = uri_body["uri"] + .as_str() + .ok_or("Neon did not return a connection URI")?; + + let (host, port, username, password, database) = parse_pg_uri(uri)?; + Ok(ProviderConnection { + db_type: "postgres".into(), + host, + port, + username, + password, + database, + ssl: true, + needs_password: false, + name: format!("Neon · {db_name}"), + }) +} + +/// Parse `postgres://user:pass@host[:port]/db?query` into its parts (URL-decoding +/// the userinfo, which Neon percent-encodes). +pub(crate) fn parse_pg_uri(uri: &str) -> Result<(String, u16, String, String, String), String> { + let rest = uri.split("://").nth(1).ok_or("Malformed connection URI")?; + let (creds_host, db_and_query) = rest.split_once('/').ok_or("Connection URI has no database")?; + let (creds, hostport) = creds_host + .rsplit_once('@') + .ok_or("Connection URI has no credentials")?; + let (user_raw, pass_raw) = creds.split_once(':').unwrap_or((creds, "")); + + let (host, port) = match hostport.rsplit_once(':') { + Some((h, p)) if !h.is_empty() && p.chars().all(|c| c.is_ascii_digit()) => { + (h.to_string(), p.parse().unwrap_or(5432)) + } + _ => (hostport.to_string(), 5432), + }; + let database = db_and_query + .split(['?', '&']) + .next() + .unwrap_or("") + .to_string(); + + let dec = |s: &str| urlencoding::decode(s).map(|c| c.into_owned()).unwrap_or_else(|_| s.to_string()); + Ok((host, port, dec(user_raw), dec(pass_raw), dec(&database))) +} diff --git a/src-tauri/src/providers/planetscale.rs b/src-tauri/src/providers/planetscale.rs new file mode 100644 index 00000000..1bdfe394 --- /dev/null +++ b/src-tauri/src/providers/planetscale.rs @@ -0,0 +1,113 @@ +/*! + * PlanetScale adapter — MySQL/Vitess. Sign in via OAuth, list databases across + * the account's organizations, and mint fresh connection credentials on connect + * (PlanetScale returns a password exactly once, at password-creation time). + */ + +use super::{http, OAuthConfig, ProviderConnection, ProviderDatabase}; +use serde_json::Value; + +pub const OAUTH: OAuthConfig = OAuthConfig { + // Public client_id (safe to embed). The client SECRET lives only on the + // stroke.click proxy as PLANETSCALE_CLIENT_SECRET — never in this binary. + client_id: "pscale_app_1c8af8139904805f4506cfb88a4f9967", + auth_url: "https://auth.planetscale.com/oauth/authorize", + // token_url is used by the proxy, not the app (the app posts to TOKEN_PROXY). + token_url: "https://auth.planetscale.com/oauth/token", + // Exact scope set PlanetScale's own CLI uses (proven valid). NOTE the real + // scope strings differ from the dashboard checkbox labels: it's + // `read_organization` (singular), and password creation is covered by + // `write_databases` (there is no `manage_passwords` OAuth scope). Sent + // space-separated, WITHOUT PKCE (see Provider::uses_pkce). Each must be + // enabled on the OAuth app. + scopes: "read_databases write_databases read_user read_organization", +}; + +const API: &str = "https://api.planetscale.com/v1"; + +async fn get(token: &str, path: &str) -> Result { + let resp = http() + .get(format!("{API}{path}")) + .bearer_auth(token) + .send() + .await + .map_err(|e| format!("PlanetScale request failed: {e}"))?; + let status = resp.status().as_u16(); + let body: Value = resp + .json() + .await + .map_err(|e| format!("PlanetScale: bad JSON: {e}"))?; + if status != 200 { + return Err(format!("PlanetScale API error ({status})")); + } + Ok(body) +} + +/// db_ref encodes "{org}/{database}" so build_connection can act without a +/// second lookup. +pub async fn list_databases(token: &str) -> Result, String> { + let orgs = get(token, "/organizations").await?; + let mut out = Vec::new(); + for org in orgs["data"].as_array().cloned().unwrap_or_default() { + let Some(org_name) = org["name"].as_str() else { continue }; + let dbs = get(token, &format!("/organizations/{org_name}/databases")).await?; + for db in dbs["data"].as_array().cloned().unwrap_or_default() { + let Some(name) = db["name"].as_str() else { continue }; + out.push(ProviderDatabase { + db_ref: format!("{org_name}/{name}"), + name: name.to_string(), + region: db["region"]["slug"].as_str().map(String::from), + kind: Some("Database".into()), + host: None, + }); + } + } + Ok(out) +} + +pub async fn build_connection(token: &str, db_ref: &str) -> Result { + let (org, database) = db_ref + .split_once('/') + .ok_or("Invalid PlanetScale database reference")?; + + // Default branch, then mint a password on it. + let db = get(token, &format!("/organizations/{org}/databases/{database}")).await?; + let branch = db["default_branch"].as_str().unwrap_or("main"); + + let resp = http() + .post(format!( + "{API}/organizations/{org}/databases/{database}/branches/{branch}/passwords" + )) + .bearer_auth(token) + .json(&serde_json::json!({ "name": "stroke", "role": "admin" })) + .send() + .await + .map_err(|e| format!("PlanetScale password create failed: {e}"))?; + let status = resp.status().as_u16(); + let body: Value = resp + .json() + .await + .map_err(|e| format!("PlanetScale: bad JSON: {e}"))?; + if !(200..300).contains(&status) { + return Err(format!("PlanetScale could not create credentials ({status})")); + } + + let username = body["username"].as_str().ok_or("PlanetScale: missing username")?; + let password = body["plain_text"].as_str().ok_or("PlanetScale: missing password")?; + let host = body["access_host_url"] + .as_str() + .or_else(|| body["database_branch"]["access_host_url"].as_str()) + .ok_or("PlanetScale: missing host")?; + + Ok(ProviderConnection { + db_type: "mysql".into(), + host: host.to_string(), + port: 3306, + username: username.to_string(), + password: password.to_string(), + database: database.to_string(), + ssl: true, + needs_password: false, + name: format!("PlanetScale · {database}"), + }) +} diff --git a/src-tauri/src/providers/prisma.rs b/src-tauri/src/providers/prisma.rs new file mode 100644 index 00000000..5ebaeeda --- /dev/null +++ b/src-tauri/src/providers/prisma.rs @@ -0,0 +1,215 @@ +/*! + * Prisma Postgres adapter — Prisma's Management API OAuth (authorization-code, + * confidential client). Sign in, list projects, and connect using the direct + * Postgres credentials (`ppgDirectConnection`) the API exposes — no pasted + * connection string, and never the Accelerate `prisma+postgres://` form (which + * a normal Postgres driver can't speak). + */ + +use super::{http, OAuthConfig, ProviderConnection, ProviderDatabase}; +use serde_json::Value; + +pub const OAUTH: OAuthConfig = OAuthConfig { + // Public client_id (safe to embed). The client SECRET lives only on the + // stroke.click proxy as PRISMA_CLIENT_SECRET — never in this binary. + client_id: "cmr8zjp5z012evvf7cycv88xn", + auth_url: "https://auth.prisma.io/authorize", + // token_url is used by the proxy, not the app (the app posts to TOKEN_PROXY). + token_url: "https://auth.prisma.io/token", + scopes: "workspace:admin", +}; + +const API: &str = "https://api.prisma.io/v1"; + +async fn get(token: &str, path: &str) -> Result { + let resp = http() + .get(format!("{API}{path}")) + .bearer_auth(token) + .send() + .await + .map_err(|e| format!("Prisma request failed: {e}"))?; + let status = resp.status().as_u16(); + let body: Value = resp + .json() + .await + .map_err(|e| format!("Prisma: bad JSON: {e}"))?; + if status != 200 { + let msg = body["message"] + .as_str() + .or_else(|| body["error"].as_str()) + .unwrap_or("request failed"); + return Err(format!("Prisma API error ({status}): {msg}")); + } + Ok(body) +} + +async fn post(token: &str, path: &str, body: serde_json::Value) -> Result { + let resp = http() + .post(format!("{API}{path}")) + .bearer_auth(token) + .json(&body) + .send() + .await + .map_err(|e| format!("Prisma request failed: {e}"))?; + let status = resp.status().as_u16(); + let body: Value = resp + .json() + .await + .map_err(|e| format!("Prisma: bad JSON: {e}"))?; + if !(200..300).contains(&status) { + let msg = body["message"] + .as_str() + .or_else(|| body["error"].as_str()) + .unwrap_or("request failed"); + return Err(format!("Prisma API error ({status}): {msg}")); + } + Ok(body) +} + +/// The projects array may live under `data`, `projects`, `items`, or be bare. +fn as_list(body: &Value) -> Vec { + for key in ["data", "projects", "items"] { + if let Some(a) = body[key].as_array() { + return a.clone(); + } + } + body.as_array().cloned().unwrap_or_default() +} + +pub async fn list_databases(token: &str) -> Result, String> { + let body = get(token, "/projects").await?; + Ok(as_list(&body) + .iter() + .filter_map(|p| { + let id = p["id"].as_str()?; + Some(ProviderDatabase { + db_ref: id.to_string(), + name: p["name"].as_str().unwrap_or(id).to_string(), + region: p["region"].as_str().map(String::from), + kind: Some("Project".into()), + host: None, + }) + }) + .collect()) +} + +/// Recursively search the whole response tree for a `ppgDirectConnection` +/// object, wherever Prisma nests it (project → environments → database → …). +fn find_direct(v: &Value) -> Option<(String, String, String, String)> { + let creds = |obj: &Value, host: &str| { + let user = obj["username"] + .as_str() + .or_else(|| obj["user"].as_str()) + .unwrap_or("") + .to_string(); + let pass = obj["password"] + .as_str() + .or_else(|| obj["pass"].as_str()) + .or_else(|| obj["apiKey"].as_str()) + .or_else(|| obj["api_key"].as_str()) + .unwrap_or("") + .to_string(); + // Only accept if we actually have a password to authenticate with. + (!pass.is_empty()).then(|| (host.to_string(), user, pass, "postgres".to_string())) + }; + + // A directConnection / ppgDirectConnection object carrying host + creds. + for key in ["directConnection", "ppgDirectConnection"] { + if let Some(d) = v.get(key).filter(|d| d.is_object()) { + if let Some(host) = d["host"].as_str() { + if let Some(found) = creds(d, host) { + return Some(found); + } + } + } + } + // Current shape: a connection object with endpoints.direct = { host, port }. + // Credentials live on the connection object alongside `endpoints`. + if let Some(direct) = v.get("endpoints").and_then(|e| e.get("direct")) { + if let Some(host) = direct["host"].as_str() { + if let Some(found) = creds(v, host) { + return Some(found); + } + } + } + match v { + Value::Object(map) => map.values().find_map(find_direct), + Value::Array(arr) => arr.iter().find_map(find_direct), + _ => None, + } +} + +/// Fallback: any usable direct `postgres://` connection string anywhere in the +/// response (not the `prisma+postgres://` Accelerate form, which drivers can't use). +fn find_pg_uri(v: &Value) -> Option { + match v { + Value::String(s) if s.starts_with("postgres://") || s.starts_with("postgresql://") => { + Some(s.clone()) + } + Value::Object(map) => map.values().find_map(find_pg_uri), + Value::Array(arr) => arr.iter().find_map(find_pg_uri), + _ => None, + } +} + +pub async fn build_connection(token: &str, project_id: &str) -> Result { + let proj = get(token, &format!("/projects/{project_id}")).await?; + let name = proj["data"]["name"] + .as_str() + .or_else(|| proj["name"].as_str()) + .unwrap_or(project_id) + .to_string(); + + // A project's databases live at a separate endpoint; the read responses never + // include the password (it's a secret shown once). Grab the database id, then + // CREATE a fresh connection — that call returns usable direct credentials. + let dbs = get(token, &format!("/projects/{project_id}/databases")).await?; + let list = as_list(&dbs); + let db = list.first().ok_or( + "This Prisma project has no Postgres database yet. Create one in the Prisma \ + Console (or pick a project that already has Prisma Postgres), then try again.", + )?; + let db_id = db["id"].as_str().ok_or("Prisma: couldn't read the database id")?; + + let created = post( + token, + &format!("/databases/{db_id}/connections"), + serde_json::json!({ "name": "Stroke" }), + ) + .await?; + + if let Some((host, user, pass, dbn)) = find_direct(&created) { + return Ok(ProviderConnection { + db_type: "postgres".into(), + host, + port: 5432, + username: user, + password: pass, + database: if dbn.is_empty() { "postgres".into() } else { dbn }, + ssl: true, + needs_password: false, + name: format!("Prisma · {name}"), + }); + } + + if let Some(uri) = find_pg_uri(&created) { + let (host, port, username, password, database) = super::neon::parse_pg_uri(&uri)?; + return Ok(ProviderConnection { + db_type: "postgres".into(), + host, + port, + username, + password, + database: if database.is_empty() { "postgres".into() } else { database }, + ssl: true, + needs_password: false, + name: format!("Prisma · {name}"), + }); + } + + let shape: String = serde_json::to_string(&created).unwrap_or_default().chars().take(1600).collect(); + Err(format!( + "Prisma created a connection but returned no direct credentials we could parse. \ + Shape: {shape}" + )) +} diff --git a/src-tauri/src/providers/supabase.rs b/src-tauri/src/providers/supabase.rs new file mode 100644 index 00000000..a37215fd --- /dev/null +++ b/src-tauri/src/providers/supabase.rs @@ -0,0 +1,136 @@ +/*! + * Supabase adapter — sign in via the Management API (OAuth2 + PKCE) and list + * every project. The database password is never exposed by the API for security, + * so `build_connection` prefills host/port/user/database and sets + * `needs_password = true`; the UI prompts for the password once. + */ + +use super::{http, OAuthConfig, ProviderConnection, ProviderDatabase}; +use serde_json::Value; + +pub const OAUTH: OAuthConfig = OAuthConfig { + // Public client_id (safe to embed). The client SECRET is held only by the + // stroke.click proxy as SUPABASE_CLIENT_SECRET — never in this binary. + client_id: "77c997a8-fd73-4eff-8b6b-1123182ef16a", + auth_url: "https://api.supabase.com/v1/oauth/authorize", + // token_url is used by the proxy, not the app (the app posts to TOKEN_PROXY). + token_url: "https://api.supabase.com/v1/oauth/token", + scopes: "all", +}; + +const API: &str = "https://api.supabase.com/v1"; + +async fn get(token: &str, path: &str) -> Result { + let resp = http() + .get(format!("{API}{path}")) + .bearer_auth(token) + .send() + .await + .map_err(|e| format!("Supabase request failed: {e}"))?; + let status = resp.status().as_u16(); + let body: Value = resp + .json() + .await + .map_err(|e| format!("Supabase: bad JSON: {e}"))?; + if status != 200 { + let msg = body["message"].as_str().unwrap_or("request failed"); + return Err(format!("Supabase API error ({status}): {msg}")); + } + Ok(body) +} + +pub async fn list_databases(token: &str) -> Result, String> { + // /v1/projects returns a top-level array. + let body = get(token, "/projects").await?; + let projects = body.as_array().cloned().unwrap_or_default(); + Ok(projects + .iter() + .filter_map(|p| { + let id = p["id"].as_str()?; // project ref + Some(ProviderDatabase { + db_ref: id.to_string(), + name: p["name"].as_str().unwrap_or(id).to_string(), + region: p["region"].as_str().map(String::from), + kind: Some("Project".into()), + host: p["database"]["host"].as_str().map(String::from), + }) + }) + .collect()) +} + +pub async fn build_connection(token: &str, project_ref: &str) -> Result { + let proj = get(token, &format!("/projects/{project_ref}")).await?; + let name = proj["name"].as_str().unwrap_or(project_ref); + let region = proj["region"].as_str().unwrap_or(""); + + // Use the Supavisor pooler, NOT the direct host. The direct connection + // `db..supabase.co:5432` is IPv6-only, so it fails with "network + // unreachable" on the many networks without IPv6. The shared pooler is + // IPv4-compatible on every tier. Its username embeds the project ref + // (`postgres.`), and session mode (port 5432) behaves like a normal + // Postgres connection — the right choice for a GUI client. + // + // Fetch the exact pooler host from the Management API when we can (avoids + // guessing the region prefix); otherwise construct the conventional host. + let _ = region; + // Ask the Management API for the exact pooler host — never guess the region + // prefix (aws-0 vs aws-1), since a wrong host connects to a pooler node that + // doesn't host this project's tenant → "tenant/user … not found". + let pooler = get(token, &format!("/projects/{project_ref}/config/database/pooler")) + .await + .map_err(|e| { + if e.contains("scope") || e.contains("403") { + "Your Supabase OAuth app is missing the \"Database Pooling Config: Read\" scope, \ + which Stroke needs to find the correct pooler host. Fix: in the Supabase \ + dashboard open your OAuth app → Scopes → enable Database Pooling Config (Read) \ + (enabling all Read scopes is fine), save, then Disconnect here and sign in again \ + to re-authorize." + .to_string() + } else { + format!("Couldn't fetch Supabase pooler config (needed for a working host): {e}") + } + })?; + let obj = pooler.as_array().and_then(|a| a.first()).unwrap_or(&pooler); + + let mut host = String::new(); + let mut user = format!("postgres.{project_ref}"); + // The connection_string carries the exact pooler host + tenant user — parse it. + if let Some(cs) = obj["connection_string"].as_str().or_else(|| obj["connectionString"].as_str()) { + if let Ok((h, _p, u, _pw, _d)) = super::neon::parse_pg_uri(cs) { + if !h.is_empty() { + host = h; + } + if !u.is_empty() { + user = u; + } + } + } + if host.is_empty() { + if let Some(h) = obj["db_host"].as_str() { + host = h.to_string(); + } + } + if let Some(u) = obj["db_user"].as_str() { + if !u.is_empty() { + user = u.to_string(); + } + } + if host.is_empty() { + let shape: String = serde_json::to_string(obj).unwrap_or_default().chars().take(400).collect(); + return Err(format!( + "Supabase didn't return a pooler host for this project. Pooler config: {shape}" + )); + } + + Ok(ProviderConnection { + db_type: "postgres".into(), + host, + port: 5432, // session mode — normal Postgres semantics, best for a GUI + username: user, + password: String::new(), + database: "postgres".into(), + ssl: true, + needs_password: true, // Supabase never returns the DB password + name: format!("Supabase · {name}"), + }) +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index cd172335..58ba91cc 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", "productName": "Stroke", - "version": "1.5.1", + "version": "1.6.0", "identifier": "com.broisnischal.stroke", "build": { "frontendDist": "../dist", diff --git a/src/App.svelte b/src/App.svelte index 6b8f2d93..1e02e457 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -12,6 +12,21 @@ applySettings(loadSettings()) installZoomShortcuts() + // Dev-only console helpers (window.__TAURI__ isn't exposed without + // withGlobalTauri, which we keep off in production). Debug trial commands + // only exist in debug builds, so these are dev-only too. + if (import.meta.env.DEV) { + const { invoke } = await import('@tauri-apps/api/core') + const { refreshLicenseStatus } = await import('$lib/stores/license.js') + // @ts-expect-error — dev-only debug surface + window.__stroke = { + status: () => invoke('check_license_status'), + resetTrial: async () => { await invoke('debug_reset_trial'); return refreshLicenseStatus() }, + expireTrial: async (daysAgo = 20) => { await invoke('debug_set_trial_days_ago', { daysAgo }); return refreshLicenseStatus() }, + } + console.info('[stroke] dev helpers → window.__stroke.status() / .expireTrial(20) / .resetTrial()') + } + // Suppress Tauri's internal "Couldn't find callback id" warning. // This is harmless noise that fires when Rust resolves a promise after a // hot-reload or fast navigation has already torn down the JS callback. diff --git a/src/lib/components/CloudflareLogin.svelte b/src/lib/components/CloudflareLogin.svelte index 5fdfbb90..61b9b920 100644 --- a/src/lib/components/CloudflareLogin.svelte +++ b/src/lib/components/CloudflareLogin.svelte @@ -1,14 +1,13 @@ + + diff --git a/src/lib/components/FkSubviewPanel.svelte b/src/lib/components/FkSubviewPanel.svelte index 53475b8a..f5b09f15 100644 --- a/src/lib/components/FkSubviewPanel.svelte +++ b/src/lib/components/FkSubviewPanel.svelte @@ -78,9 +78,17 @@ >10 rows: cap height and add vertical scroll. No overscroll-contain so scroll chains naturally to the main table at limits. --> {@const needsScroll = rowCount > 10} +
diff --git a/src/lib/components/Icon.svelte b/src/lib/components/Icon.svelte new file mode 100644 index 00000000..4f8675e4 --- /dev/null +++ b/src/lib/components/Icon.svelte @@ -0,0 +1,33 @@ + + +{#if useHuge} + +{:else if Lucide} + +{/if} diff --git a/src/lib/components/LicenseActivation.svelte b/src/lib/components/LicenseActivation.svelte index f8f9174c..b1cecc4a 100644 --- a/src/lib/components/LicenseActivation.svelte +++ b/src/lib/components/LicenseActivation.svelte @@ -1,10 +1,8 @@ - -{@render children()} +{#if $isBlocked} + + +{:else} + {@render children()} +{/if} diff --git a/src/lib/components/Onboarding.svelte b/src/lib/components/Onboarding.svelte index 940b5b2b..2313d3e2 100644 --- a/src/lib/components/Onboarding.svelte +++ b/src/lib/components/Onboarding.svelte @@ -1,79 +1,62 @@ {#if open} -
- +
+
+
-
+
- - Stroke + + Stroke
- -
- {#each Array(TOTAL) as _, i} - + +
+ {#if step < TOTAL} - + {:else} -
+
{/if}
-
+
{#key step} -
-
-

{heading.title}

-

{heading.desc}

+
+ {#if step === 1} +
+ +
+ {/if} +

{heading.title}

+

{heading.desc}

{#if step === 1} -
- -
- {#each FEATURES as f, i} - {@const active = i === activeFeature} - - {/each} -
- - -
diff --git a/src/lib/components/TableToolbar.svelte b/src/lib/components/TableToolbar.svelte index 0ddb40cc..d0e9e2e3 100644 --- a/src/lib/components/TableToolbar.svelte +++ b/src/lib/components/TableToolbar.svelte @@ -1,28 +1,5 @@ + +
+ +
+ +
+
+ +
+ +
+

Your free trial has ended

+

+ Activate a license to keep using Stroke. Your saved connections and settings are untouched — they'll be right here. +

+
+ + +
+ +
+ +
+ + Get a license + + · + +
+
+
diff --git a/src/lib/components/ui/input/input.svelte b/src/lib/components/ui/input/input.svelte index 1fbcbcc1..23efba38 100644 --- a/src/lib/components/ui/input/input.svelte +++ b/src/lib/components/ui/input/input.svelte @@ -19,7 +19,7 @@ bind:this={ref} data-slot={dataSlot} class={cn( - "dark:bg-input/30 border-input focus-visible:border-ring focus-visible:ring-ring/25 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 disabled:bg-input/50 dark:disabled:bg-input/80 h-8 rounded-lg border bg-transparent px-2.5 py-1 text-base transition-colors file:h-6 file:text-sm file:font-medium focus-visible:ring-1 aria-invalid:ring-1 md:text-sm file:text-foreground placeholder:text-muted-foreground w-full min-w-0 outline-none file:inline-flex file:border-0 file:bg-transparent disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50", + "dark:bg-input/30 border-input focus-visible:border-ring focus-visible:ring-ring/25 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 disabled:bg-input/50 dark:disabled:bg-input/80 h-9 rounded-lg border bg-transparent px-3 py-1 text-base transition-[color,box-shadow,border-color] hover:border-ring/40 file:h-6 file:text-sm file:font-medium focus-visible:ring-2 aria-invalid:ring-1 md:text-sm file:text-foreground placeholder:text-muted-foreground w-full min-w-0 outline-none file:inline-flex file:border-0 file:bg-transparent disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50", className )} type="file" @@ -32,7 +32,7 @@ bind:this={ref} data-slot={dataSlot} class={cn( - "dark:bg-input/30 border-input focus-visible:border-ring focus-visible:ring-ring/25 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 disabled:bg-input/50 dark:disabled:bg-input/80 h-8 rounded-lg border bg-transparent px-2.5 py-1 text-base transition-colors file:h-6 file:text-sm file:font-medium focus-visible:ring-1 aria-invalid:ring-1 md:text-sm file:text-foreground placeholder:text-muted-foreground w-full min-w-0 outline-none file:inline-flex file:border-0 file:bg-transparent disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50", + "dark:bg-input/30 border-input focus-visible:border-ring focus-visible:ring-ring/25 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 disabled:bg-input/50 dark:disabled:bg-input/80 h-9 rounded-lg border bg-transparent px-3 py-1 text-base transition-[color,box-shadow,border-color] hover:border-ring/40 file:h-6 file:text-sm file:font-medium focus-visible:ring-2 aria-invalid:ring-1 md:text-sm file:text-foreground placeholder:text-muted-foreground w-full min-w-0 outline-none file:inline-flex file:border-0 file:bg-transparent disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50", className )} {type} diff --git a/src/lib/db-icons.js b/src/lib/db-icons.js new file mode 100644 index 00000000..f396c0ed --- /dev/null +++ b/src/lib/db-icons.js @@ -0,0 +1,32 @@ +// Auto-generated brand-mark path data (monochrome, 24x24 viewBox). +// Real marks are from simple-icons (CC0/MIT); SQL Server + fallback are hand-authored. +// Rendered by DbIcon.svelte with fill=currentColor so the parent controls tint. + +/** @type {Record} */ +export const BRAND_MARKS = { + postgres: { d: 'M23.5594 14.7228a.5269.5269 0 0 0-.0563-.1191c-.139-.2632-.4768-.3418-1.0074-.2321-1.6533.3411-2.2935.1312-2.5256-.0191 1.342-2.0482 2.445-4.522 3.0411-6.8297.2714-1.0507.7982-3.5237.1222-4.7316a1.5641 1.5641 0 0 0-.1509-.235C21.6931.9086 19.8007.0248 17.5099.0005c-1.4947-.0158-2.7705.3461-3.1161.4794a9.449 9.449 0 0 0-.5159-.0816 8.044 8.044 0 0 0-1.3114-.1278c-1.1822-.0184-2.2038.2642-3.0498.8406-.8573-.3211-4.7888-1.645-7.2219.0788C.9359 2.1526.3086 3.8733.4302 6.3043c.0409.818.5069 3.334 1.2423 5.7436.4598 1.5065.9387 2.7019 1.4334 3.582.553.9942 1.1259 1.5933 1.7143 1.7895.4474.1491 1.1327.1441 1.8581-.7279.8012-.9635 1.5903-1.8258 1.9446-2.2069.4351.2355.9064.3625 1.39.3772a.0569.0569 0 0 0 .0004.0041 11.0312 11.0312 0 0 0-.2472.3054c-.3389.4302-.4094.5197-1.5002.7443-.3102.064-1.1344.2339-1.1464.8115-.0025.1224.0329.2309.0919.3268.2269.4231.9216.6097 1.015.6331 1.3345.3335 2.5044.092 3.3714-.6787-.017 2.231.0775 4.4174.3454 5.0874.2212.5529.7618 1.9045 2.4692 1.9043.2505 0 .5263-.0291.8296-.0941 1.7819-.3821 2.5557-1.1696 2.855-2.9059.1503-.8707.4016-2.8753.5388-4.1012.0169-.0703.0357-.1207.057-.1362.0007-.0005.0697-.0471.4272.0307a.3673.3673 0 0 0 .0443.0068l.2539.0223.0149.001c.8468.0384 1.9114-.1426 2.5312-.4308.6438-.2988 1.8057-1.0323 1.5951-1.6698zM2.371 11.8765c-.7435-2.4358-1.1779-4.8851-1.2123-5.5719-.1086-2.1714.4171-3.6829 1.5623-4.4927 1.8367-1.2986 4.8398-.5408 6.108-.13-.0032.0032-.0066.0061-.0098.0094-2.0238 2.044-1.9758 5.536-1.9708 5.7495-.0002.0823.0066.1989.0162.3593.0348.5873.0996 1.6804-.0735 2.9184-.1609 1.1504.1937 2.2764.9728 3.0892.0806.0841.1648.1631.2518.2374-.3468.3714-1.1004 1.1926-1.9025 2.1576-.5677.6825-.9597.5517-1.0886.5087-.3919-.1307-.813-.5871-1.2381-1.3223-.4796-.839-.9635-2.0317-1.4155-3.5126zm6.0072 5.0871c-.1711-.0428-.3271-.1132-.4322-.1772.0889-.0394.2374-.0902.4833-.1409 1.2833-.2641 1.4815-.4506 1.9143-1.0002.0992-.126.2116-.2687.3673-.4426a.3549.3549 0 0 0 .0737-.1298c.1708-.1513.2724-.1099.4369-.0417.156.0646.3078.26.3695.4752.0291.1016.0619.2945-.0452.4444-.9043 1.2658-2.2216 1.2494-3.1676 1.0128zm2.094-3.988-.0525.141c-.133.3566-.2567.6881-.3334 1.003-.6674-.0021-1.3168-.2872-1.8105-.8024-.6279-.6551-.9131-1.5664-.7825-2.5004.1828-1.3079.1153-2.4468.079-3.0586-.005-.0857-.0095-.1607-.0122-.2199.2957-.2621 1.6659-.9962 2.6429-.7724.4459.1022.7176.4057.8305.928.5846 2.7038.0774 3.8307-.3302 4.7363-.084.1866-.1633.3629-.2311.5454zm7.3637 4.5725c-.0169.1768-.0358.376-.0618.5959l-.146.4383a.3547.3547 0 0 0-.0182.1077c-.0059.4747-.054.6489-.115.8693-.0634.2292-.1353.4891-.1794 1.0575-.11 1.4143-.8782 2.2267-2.4172 2.5565-1.5155.3251-1.7843-.4968-2.0212-1.2217a6.5824 6.5824 0 0 0-.0769-.2266c-.2154-.5858-.1911-1.4119-.1574-2.5551.0165-.5612-.0249-1.9013-.3302-2.6462.0044-.2932.0106-.5909.019-.8918a.3529.3529 0 0 0-.0153-.1126 1.4927 1.4927 0 0 0-.0439-.208c-.1226-.4283-.4213-.7866-.7797-.9351-.1424-.059-.4038-.1672-.7178-.0869.067-.276.1831-.5875.309-.9249l.0529-.142c.0595-.16.134-.3257.213-.5012.4265-.9476 1.0106-2.2453.3766-5.1772-.2374-1.0981-1.0304-1.6343-2.2324-1.5098-.7207.0746-1.3799.3654-1.7088.5321a5.6716 5.6716 0 0 0-.1958.1041c.0918-1.1064.4386-3.1741 1.7357-4.4823a4.0306 4.0306 0 0 1 .3033-.276.3532.3532 0 0 0 .1447-.0644c.7524-.5706 1.6945-.8506 2.802-.8325.4091.0067.8017.0339 1.1742.081 1.939.3544 3.2439 1.4468 4.0359 2.3827.8143.9623 1.2552 1.9315 1.4312 2.4543-1.3232-.1346-2.2234.1268-2.6797.779-.9926 1.4189.543 4.1729 1.2811 5.4964.1353.2426.2522.4522.2889.5413.2403.5825.5515.9713.7787 1.2552.0696.087.1372.1714.1885.245-.4008.1155-1.1208.3825-1.0552 1.717-.0123.1563-.0423.4469-.0834.8148-.0461.2077-.0702.4603-.0994.7662zm.8905-1.6211c-.0405-.8316.2691-.9185.5967-1.0105a2.8566 2.8566 0 0 0 .135-.0406 1.202 1.202 0 0 0 .1342.103c.5703.3765 1.5823.4213 3.0068.1344-.2016.1769-.5189.3994-.9533.6011-.4098.1903-1.0957.333-1.7473.3636-.7197.0336-1.0859-.0807-1.1721-.151zm.5695-9.2712c-.0059.3508-.0542.6692-.1054 1.0017-.055.3576-.112.7274-.1264 1.1762-.0142.4368.0404.8909.0932 1.3301.1066.887.216 1.8003-.2075 2.7014a3.5272 3.5272 0 0 1-.1876-.3856c-.0527-.1276-.1669-.3326-.3251-.6162-.6156-1.1041-2.0574-3.6896-1.3193-4.7446.3795-.5427 1.3408-.5661 2.1781-.463zm.2284 7.0137a12.3762 12.3762 0 0 0-.0853-.1074l-.0355-.0444c.7262-1.1995.5842-2.3862.4578-3.4385-.0519-.4318-.1009-.8396-.0885-1.2226.0129-.4061.0666-.7543.1185-1.0911.0639-.415.1288-.8443.1109-1.3505.0134-.0531.0188-.1158.0118-.1902-.0457-.4855-.5999-1.938-1.7294-3.253-.6076-.7073-1.4896-1.4972-2.6889-2.0395.5251-.1066 1.2328-.2035 2.0244-.1859 2.0515.0456 3.6746.8135 4.8242 2.2824a.908.908 0 0 1 .0667.1002c.7231 1.3556-.2762 6.2751-2.9867 10.5405zm-8.8166-6.1162c-.025.1794-.3089.4225-.6211.4225a.5821.5821 0 0 1-.0809-.0056c-.1873-.026-.3765-.144-.5059-.3156-.0458-.0605-.1203-.178-.1055-.2844.0055-.0401.0261-.0985.0925-.1488.1182-.0894.3518-.1226.6096-.0867.3163.0441.6426.1938.6113.4186zm7.9305-.4114c.0111.0792-.049.201-.1531.3102-.0683.0717-.212.1961-.4079.2232a.5456.5456 0 0 1-.075.0052c-.2935 0-.5414-.2344-.5607-.3717-.024-.1765.2641-.3106.5611-.352.297-.0414.6111.0088.6356.1851z', hex: '#4169E1' }, + mysql: { d: 'M16.405 5.501c-.115 0-.193.014-.274.033v.013h.014c.054.104.146.18.214.273.054.107.1.214.154.32l.014-.015c.094-.066.14-.172.14-.333-.04-.047-.046-.094-.08-.14-.04-.067-.126-.1-.18-.153zM5.77 18.695h-.927a50.854 50.854 0 00-.27-4.41h-.008l-1.41 4.41H2.45l-1.4-4.41h-.01a72.892 72.892 0 00-.195 4.41H0c.055-1.966.192-3.81.41-5.53h1.15l1.335 4.064h.008l1.347-4.064h1.095c.242 2.015.384 3.86.428 5.53zm4.017-4.08c-.378 2.045-.876 3.533-1.492 4.46-.482.716-1.01 1.073-1.583 1.073-.153 0-.34-.046-.566-.138v-.494c.11.017.24.026.386.026.268 0 .483-.075.647-.222.197-.18.295-.382.295-.605 0-.155-.077-.47-.23-.944L6.23 14.615h.91l.727 2.36c.164.536.233.91.205 1.123.4-1.064.678-2.227.835-3.483zm12.325 4.08h-2.63v-5.53h.885v4.85h1.745zm-3.32.135l-1.016-.5c.09-.076.177-.158.255-.25.433-.506.648-1.258.648-2.253 0-1.83-.718-2.746-2.155-2.746-.704 0-1.254.232-1.65.697-.43.508-.646 1.256-.646 2.245 0 .972.19 1.686.574 2.14.35.41.877.615 1.583.615.264 0 .506-.033.725-.098l1.325.772.36-.622zM15.5 17.588c-.225-.36-.337-.94-.337-1.736 0-1.393.424-2.09 1.27-2.09.443 0 .77.167.977.5.224.362.336.936.336 1.723 0 1.404-.424 2.108-1.27 2.108-.445 0-.77-.167-.978-.5zm-1.658-.425c0 .47-.172.856-.516 1.156-.344.3-.803.45-1.384.45-.543 0-1.064-.172-1.573-.515l.237-.476c.438.22.833.328 1.19.328.332 0 .593-.073.783-.22a.754.754 0 00.3-.615c0-.33-.23-.61-.648-.845-.388-.213-1.163-.657-1.163-.657-.422-.307-.632-.636-.632-1.177 0-.45.157-.81.47-1.085.315-.278.72-.415 1.22-.415.512 0 .98.136 1.4.41l-.213.476a2.726 2.726 0 00-1.064-.23c-.283 0-.502.068-.654.206a.685.685 0 00-.248.524c0 .328.234.61.666.85.393.215 1.187.67 1.187.67.433.305.648.63.648 1.168zm9.382-5.852c-.535-.014-.95.04-1.297.188-.1.04-.26.04-.274.167.055.053.063.14.11.214.08.134.218.313.346.407.14.11.28.216.427.31.26.16.555.255.81.416.145.094.293.213.44.313.073.05.12.14.214.172v-.02c-.046-.06-.06-.147-.105-.214-.067-.067-.134-.127-.2-.193a3.223 3.223 0 00-.695-.675c-.214-.146-.682-.35-.77-.595l-.013-.014c.146-.013.32-.066.46-.106.227-.06.435-.047.67-.106.106-.027.213-.06.32-.094v-.06c-.12-.12-.21-.283-.334-.395a8.867 8.867 0 00-1.104-.823c-.21-.134-.476-.22-.697-.334-.08-.04-.214-.06-.26-.127-.12-.146-.19-.34-.275-.514a17.69 17.69 0 01-.547-1.163c-.12-.262-.193-.523-.34-.763-.69-1.137-1.437-1.826-2.586-2.5-.247-.14-.543-.2-.856-.274-.167-.008-.334-.02-.5-.027-.11-.047-.216-.174-.31-.235-.38-.24-1.364-.76-1.644-.072-.18.434.267.862.422 1.082.115.153.26.328.34.5.047.116.06.235.107.356.106.294.207.622.347.897.073.14.153.287.247.413.054.073.146.107.167.227-.094.136-.1.334-.154.5-.24.757-.146 1.693.194 2.25.107.166.362.534.703.393.3-.12.234-.5.32-.835.02-.08.007-.133.048-.187v.015c.094.188.188.367.274.555.206.328.566.668.867.895.16.12.287.328.487.402v-.02h-.015c-.043-.058-.1-.086-.154-.133a3.445 3.445 0 01-.35-.4 8.76 8.76 0 01-.747-1.218c-.11-.21-.202-.436-.29-.643-.04-.08-.04-.2-.107-.24-.1.146-.247.273-.32.453-.127.288-.14.642-.188 1.01-.027.007-.014 0-.027.014-.214-.052-.287-.274-.367-.46-.2-.475-.233-1.238-.06-1.785.047-.14.247-.582.167-.716-.042-.127-.174-.2-.247-.303a2.478 2.478 0 01-.24-.427c-.16-.374-.24-.788-.414-1.162-.08-.173-.22-.354-.334-.513-.127-.18-.267-.307-.368-.52-.033-.073-.08-.194-.027-.274.014-.054.042-.075.094-.09.088-.072.335.022.422.062.247.1.455.194.662.334.094.066.195.193.315.226h.14c.214.047.455.014.655.073.355.114.675.28.962.46a5.953 5.953 0 012.085 2.286c.08.154.115.295.188.455.14.33.313.663.455.982.14.315.275.636.476.897.1.14.502.213.682.286.133.06.34.115.46.188.23.14.454.3.67.454.11.076.443.243.463.378z', hex: '#4479A1' }, + mariadb: { d: 'M23.157 4.412c-.676.284-.79.31-1.673.372-.65.045-.757.057-1.212.209-.75.246-1.395.75-2.02 1.59-.296.398-1.249 1.913-1.249 1.988 0 .057-.65.998-.915 1.32-.574.713-1.08 1.079-2.14 1.59-.77.36-1.224.524-4.102 1.477-1.073.353-2.133.738-2.367.864-.852.449-1.515 1.036-2.203 1.938-1.003 1.32-.972 1.313-3.042.947a12.264 12.264 0 00-.675-.063c-.644-.05-1.023.044-1.332.334L0 17.193l.177.088c.094.05.353.234.561.398.215.17.461.347.55.391.088.044.17.088.183.101.012.013-.089.17-.228.353-.435.581-.593.871-.574 1.048.019.164.032.17.43.17.517-.006.826-.056 1.261-.208.65-.233 2.058-.94 2.784-1.4.776-.5 1.717-.998 1.956-1.042.082-.02.354-.07.594-.114.58-.107 1.464-.095 2.587.05.108.013.373.045.6.064.227.025.43.057.454.076.026.012.474.037.998.056.934.026 1.104.007 1.3-.189.126-.133.385-.631.498-.985.209-.643.417-.921.366-.492-.113.966-.322 1.692-.713 2.411-.259.499-.663 1.092-.934 1.395-.322.347-.315.36.088.315.619-.063 1.471-.397 2.096-.82.827-.562 1.647-1.691 2.19-3.03.107-.27.22-.22.183.083-.013.094-.038.315-.057.498l-.031.328.353-.202c.833-.48 1.414-1.262 2.127-2.884.227-.518.877-2.922 1.073-3.976a9.64 9.64 0 01.271-1.042c.127-.429.196-.555.48-.858.183-.19.625-.555.978-.808.72-.505.953-.75 1.187-1.205.208-.417.284-1.13.132-1.357-.132-.202-.284-.196-.763.006Z', hex: '#003545' }, + cockroachdb: { d: 'M17.645 16.155a13.013 13.013 0 0 0-4.479-13.287 11.617 11.617 0 0 1 5.815-1.546c.774 0 1.545.075 2.304.226l.32-1.282A13.092 13.092 0 0 0 18.98 0 12.93 12.93 0 0 0 12 2.03 12.933 12.933 0 0 0 5.021 0C4.139 0 3.26.089 2.396.266l.319 1.282a11.83 11.83 0 0 1 2.306-.226 11.64 11.64 0 0 1 5.817 1.544 13.01 13.01 0 0 0 .792 20.877l.375.257.373-.257c2.624-1.801 4.5-4.5 5.27-7.588zm-6.305 5.7A11.687 11.687 0 0 1 7.825 9.519a13.08 13.08 0 0 0 2.457 2.977 3.202 3.202 0 0 1 1.058 2.383zm.661-9.657a11.717 11.717 0 0 1-2.343-2.315 3.23 3.23 0 0 1 0-3.912 11.67 11.67 0 0 1 2.347-2.333c.9.671 1.697 1.468 2.37 2.366a3.197 3.197 0 0 1 0 3.832 11.816 11.816 0 0 1-2.374 2.362zm.659 9.657v-6.976a3.202 3.202 0 0 1 1.06-2.383 13.026 13.026 0 0 0 2.455-2.977 11.694 11.694 0 0 1-3.515 12.336Z', hex: '#6933FF' }, + sqlite: { d: 'M21.678.521c-1.032-.92-2.28-.55-3.513.544a8.71 8.71 0 0 0-.547.535c-2.109 2.237-4.066 6.38-4.674 9.544.237.48.422 1.093.544 1.561a13.044 13.044 0 0 1 .164.703s-.019-.071-.096-.296l-.05-.146a1.689 1.689 0 0 0-.033-.08c-.138-.32-.518-.995-.686-1.289-.143.423-.27.818-.376 1.176.484.884.778 2.4.778 2.4s-.025-.099-.147-.442c-.107-.303-.644-1.244-.772-1.464-.217.804-.304 1.346-.226 1.478.152.256.296.698.422 1.186.286 1.1.485 2.44.485 2.44l.017.224a22.41 22.41 0 0 0 .056 2.748c.095 1.146.273 2.13.5 2.657l.155-.084c-.334-1.038-.47-2.399-.41-3.967.09-2.398.642-5.29 1.661-8.304 1.723-4.55 4.113-8.201 6.3-9.945-1.993 1.8-4.692 7.63-5.5 9.788-.904 2.416-1.545 4.684-1.931 6.857.666-2.037 2.821-2.912 2.821-2.912s1.057-1.304 2.292-3.166c-.74.169-1.955.458-2.362.629-.6.251-.762.337-.762.337s1.945-1.184 3.613-1.72C21.695 7.9 24.195 2.767 21.678.521m-18.573.543A1.842 1.842 0 0 0 1.27 2.9v16.608a1.84 1.84 0 0 0 1.835 1.834h9.418a22.953 22.953 0 0 1-.052-2.707c-.006-.062-.011-.141-.016-.2a27.01 27.01 0 0 0-.473-2.378c-.121-.47-.275-.898-.369-1.057-.116-.197-.098-.31-.097-.432 0-.12.015-.245.037-.386a9.98 9.98 0 0 1 .234-1.045l.217-.028c-.017-.035-.014-.065-.031-.097l-.041-.381a32.8 32.8 0 0 1 .382-1.194l.2-.019c-.008-.016-.01-.038-.018-.053l-.043-.316c.63-3.28 2.587-7.443 4.8-9.791.066-.069.133-.128.198-.194Z', hex: '#003B57' }, + clickhouse: { d: 'M21.333 10H24v4h-2.667ZM16 1.335h2.667v21.33H16Zm-5.333 0h2.666v21.33h-2.666ZM0 22.665V1.335h2.667v21.33zm5.333-21.33H8v21.33H5.333Z', hex: '#FFCC01' }, + duckdb: { d: 'M12 0C5.363 0 0 5.363 0 12s5.363 12 12 12 12-5.363 12-12S18.637 0 12 0zM9.502 7.03a4.974 4.974 0 0 1 4.97 4.97 4.974 4.974 0 0 1-4.97 4.97A4.974 4.974 0 0 1 4.532 12a4.974 4.974 0 0 1 4.97-4.97zm6.563 3.183h2.351c.98 0 1.787.782 1.787 1.762s-.807 1.789-1.787 1.789h-2.351v-3.551z', hex: '#FFF000' }, + d1: { d: 'M16.5088 16.8447c.1475-.5068.0908-.9707-.1553-1.3154-.2246-.3164-.6045-.499-1.0615-.5205l-8.6592-.1123a.1559.1559 0 0 1-.1333-.0713c-.0283-.042-.0351-.0986-.021-.1553.0278-.084.1123-.1484.2036-.1562l8.7359-.1123c1.0351-.0489 2.1601-.8868 2.5537-1.9136l.499-1.3013c.0215-.0561.0293-.1128.0147-.168-.5625-2.5463-2.835-4.4453-5.5499-4.4453-2.5039 0-4.6284 1.6177-5.3876 3.8614-.4927-.3658-1.1187-.5625-1.794-.499-1.2026.119-2.1665 1.083-2.2861 2.2856-.0283.31-.0069.6128.0635.894C1.5683 13.171 0 14.7754 0 16.752c0 .1748.0142.3515.0352.5273.0141.083.0844.1475.1689.1475h15.9814c.0909 0 .1758-.0645.2032-.1553l.12-.4268zm2.7568-5.5634c-.0771 0-.1611 0-.2383.0112-.0566 0-.1054.0415-.127.0976l-.3378 1.1744c-.1475.5068-.0918.9707.1543 1.3164.2256.3164.6055.498 1.0625.5195l1.8437.1133c.0557 0 .1055.0263.1329.0703.0283.043.0351.1074.0214.1562-.0283.084-.1132.1485-.204.1553l-1.921.1123c-1.041.0488-2.1582.8867-2.5527 1.914l-.1406.3585c-.0283.0713.0215.1416.0986.1416h6.5977c.0771 0 .1474-.0489.169-.126.1122-.4082.1757-.837.1757-1.2803 0-2.6025-2.125-4.727-4.7344-4.727', hex: '#F38020' }, + bigquery: { d: 'M5.676 10.595h2.052v5.244a5.892 5.892 0 0 1-2.052-2.088v-3.156zm18.179 10.836a.504.504 0 0 1 0 .708l-1.716 1.716a.504.504 0 0 1-.708 0l-4.248-4.248a.206.206 0 0 1-.007-.007c-.02-.02-.028-.045-.043-.066a10.736 10.736 0 0 1-6.334 2.065C4.835 21.599 0 16.764 0 10.799S4.835 0 10.8 0s10.799 4.835 10.799 10.8c0 2.369-.772 4.553-2.066 6.333.025.017.052.028.074.05l4.248 4.248zm-5.028-10.632a8.015 8.015 0 1 0-8.028 8.028h.024a8.016 8.016 0 0 0 8.004-8.028zm-4.86 4.98a6.002 6.002 0 0 0 2.04-2.184v-1.764h-2.04v3.948zm-4.5.948c.442.057.887.08 1.332.072.4.025.8.025 1.2 0V7.692H9.468v9.035z', hex: '#669DF6' }, + neon: { d: 'M24 0V24l-9.365-8.045V24H0V0ZM2.942 21.087h8.751V9.563l9.365 8.204V2.919L2.942 2.914Z', hex: '#34D59A' }, + supabase: { d: 'M11.9 1.036c-.015-.986-1.26-1.41-1.874-.637L.764 12.05C-.33 13.427.65 15.455 2.409 15.455h9.579l.113 7.51c.014.985 1.259 1.408 1.873.636l9.262-11.653c1.093-1.375.113-3.403-1.645-3.403h-9.642z', hex: '#3FCF8E' }, + planetscale: { d: 'M0 12C0 5.373 5.373 0 12 0c4.873 0 9.067 2.904 10.947 7.077l-15.87 15.87a11.981 11.981 0 0 1-1.935-1.099L14.99 12H12l-8.485 8.485A11.962 11.962 0 0 1 0 12Zm12.004 12L24 12.004C23.998 18.628 18.628 23.998 12.004 24Z', hex: '#000000' }, + prisma: { d: 'M21.8068 18.2848L13.5528.7565c-.207-.4382-.639-.7273-1.1286-.7541-.5023-.0293-.9523.213-1.2062.6253L2.266 15.1271c-.2773.4518-.2718 1.0091.0158 1.4555l4.3759 6.7786c.2608.4046.7127.6388 1.1823.6388.1332 0 .267-.0188.3987-.0577l12.7019-3.7568c.3891-.1151.7072-.3904.8737-.7553s.1633-.7828-.0075-1.1454zm-1.8481.7519L9.1814 22.2242c-.3292.0975-.6448-.1873-.5756-.5194l3.8501-18.4386c.072-.3448.5486-.3996.699-.0803l7.1288 15.138c.1344.2856-.019.6224-.325.7128z', hex: '#2D3748' }, + turso: { d: 'm23.31.803-.563-.42-1.11 1.189-.891-1.286-.512.235.704 1.798-.326.35L18.082 0l-.574.284 2.25 4.836-2.108.741h-.05l-1.143-1.359-1.144 1.36H8.687l-1.144-1.36-1.146 1.363H6.36l-2.12-.745L6.491.284 5.919 0l-2.53 2.668-.327-.349.705-1.798-.512-.236-.89 1.287L1.253.382.69.804 2.42 3.69l-.89.939.311 2.375 2.061.787L3.9 8.817H1.947v.444l.755 1.078 1.197.433v6.971l3.057 4.55L7.657 24l1.101-1.606L9.9 24l.999-1.606L12 24l1.102-1.606L14.1 24l1.141-1.606L16.343 24l.701-1.706 3.058-4.55v-6.972l1.196-.433.756-1.078v-.444h-1.952l.003-1.03 2.054-.784.311-2.375-.89-.939zm-8.93 18.718H8.033l.793-1.615.794 1.615.793-1.083.793 1.083.794-1.083.793 1.083.794-1.083.793 1.083.793-1.615.794 1.615zm3.886-7.39-3.3 1.084-.143 3.061-2.827.627-2.826-.627-.142-3.06-3.3-1.085v-1.635l4.266 1.21-.052 4.126h4.109l-.052-4.127 4.266-1.209z', hex: '#4FF8D2' }, + mssql: { d: 'M12 2C7.03 2 3 3.34 3 5v14c0 1.66 4.03 3 9 3s9-1.34 9-3V5c0-1.66-4.03-3-9-3zm7 17c0 .3-2.54 1.5-7 1.5S5 19.3 5 19v-2.23c1.61.74 4.19 1.23 7 1.23s5.39-.49 7-1.23V19zm0-5c0 .3-2.54 1.5-7 1.5S5 14.3 5 14v-2.23c1.61.74 4.19 1.23 7 1.23s5.39-.49 7-1.23V14zM12 8.5C7.54 8.5 5 7.3 5 7s2.54-1.5 7-1.5 7 1.2 7 1.5-2.54 1.5-7 1.5z', hex: '#CC2927' }, +} + +/** Fallback filled-cylinder mark for any unknown driver id. */ +export const FALLBACK_MARK = 'M12 2C7.03 2 3 3.34 3 5v14c0 1.66 4.03 3 9 3s9-1.34 9-3V5c0-1.66-4.03-3-9-3zm7 17c0 .3-2.54 1.5-7 1.5S5 19.3 5 19v-2.23c1.61.74 4.19 1.23 7 1.23s5.39-.49 7-1.23V19zm0-5c0 .3-2.54 1.5-7 1.5S5 14.3 5 14v-2.23c1.61.74 4.19 1.23 7 1.23s5.39-.49 7-1.23V14zM12 8.5C7.54 8.5 5 7.3 5 7s2.54-1.5 7-1.5 7 1.2 7 1.5-2.54 1.5-7 1.5z' + +/** Driver ids that reuse another driver's mark. */ +export const MARK_ALIASES = { + 'sqlite-memory': 'sqlite', + 'duckdb-memory': 'duckdb', + libsql: 'turso', +} diff --git a/src/lib/icon-registry.js b/src/lib/icon-registry.js new file mode 100644 index 00000000..bcf9b900 --- /dev/null +++ b/src/lib/icon-registry.js @@ -0,0 +1,270 @@ +/** + * Shared icon registry — maps a stable semantic name to both a Lucide component + * and a Hugeicons data object, so the wrapper can render either family + * based on the user's icon-set setting. + * + * Coverage is intentionally incremental: a name may have a Lucide entry only. + * falls back to Lucide whenever a Hugeicons mapping is missing, so the UI + * never breaks and the Hugeicons look can be extended one name at a time. + * + * Add a new icon: + * 1. import its Lucide component + Hugeicons data below, + * 2. add a `name: { lucide, huge }` entry, + * 3. render it anywhere with ``. + */ + +// ── Lucide components ──────────────────────────────────────────────────────── +import X from '@lucide/svelte/icons/x' +import Check from '@lucide/svelte/icons/check' +import CheckCircle2 from '@lucide/svelte/icons/check-circle-2' +import ChevronDown from '@lucide/svelte/icons/chevron-down' +import ChevronUp from '@lucide/svelte/icons/chevron-up' +import ChevronRight from '@lucide/svelte/icons/chevron-right' +import ChevronLeft from '@lucide/svelte/icons/chevron-left' +import Plus from '@lucide/svelte/icons/plus' +import Minus from '@lucide/svelte/icons/minus' +import Search from '@lucide/svelte/icons/search' +import Trash2 from '@lucide/svelte/icons/trash-2' +import Table2 from '@lucide/svelte/icons/table-2' +import RefreshCw from '@lucide/svelte/icons/refresh-cw' +import Copy from '@lucide/svelte/icons/copy' +import Loader2 from '@lucide/svelte/icons/loader-2' +import KeyRound from '@lucide/svelte/icons/key-round' +import ExternalLink from '@lucide/svelte/icons/external-link' +import Database from '@lucide/svelte/icons/database' +import Code2 from '@lucide/svelte/icons/code-2' +import Terminal from '@lucide/svelte/icons/terminal' +import BarChart2 from '@lucide/svelte/icons/bar-chart-2' +import Sparkles from '@lucide/svelte/icons/sparkles' +import GitBranch from '@lucide/svelte/icons/git-branch' +import Download from '@lucide/svelte/icons/download' +import Play from '@lucide/svelte/icons/play' +import Eye from '@lucide/svelte/icons/eye' +import EyeOff from '@lucide/svelte/icons/eye-off' +import Bot from '@lucide/svelte/icons/bot' +import AlertTriangle from '@lucide/svelte/icons/alert-triangle' +import AlertCircle from '@lucide/svelte/icons/alert-circle' +import ShieldCheck from '@lucide/svelte/icons/shield-check' +import Pencil from '@lucide/svelte/icons/pencil' +import Braces from '@lucide/svelte/icons/braces' +import Lock from '@lucide/svelte/icons/lock' +import LayoutTemplate from '@lucide/svelte/icons/layout-template' +import History from '@lucide/svelte/icons/history' +import ArrowRight from '@lucide/svelte/icons/arrow-right' +import Clock from '@lucide/svelte/icons/clock' +import Zap from '@lucide/svelte/icons/zap' +import Settings from '@lucide/svelte/icons/settings' +import Maximize2 from '@lucide/svelte/icons/maximize-2' +import Filter from '@lucide/svelte/icons/filter' +import MoreHorizontal from '@lucide/svelte/icons/more-horizontal' +import Menu from '@lucide/svelte/icons/menu' +import FolderOpen from '@lucide/svelte/icons/folder-open' +import Archive from '@lucide/svelte/icons/archive' +import ArrowUpCircle from '@lucide/svelte/icons/arrow-up-circle' +import ChevronsDown from '@lucide/svelte/icons/chevrons-down' +import ChevronsLeft from '@lucide/svelte/icons/chevrons-left' +import ChevronsRight from '@lucide/svelte/icons/chevrons-right' +import ChevronsUp from '@lucide/svelte/icons/chevrons-up' +import Cloud from '@lucide/svelte/icons/cloud' +import Command from '@lucide/svelte/icons/command' +import HardDrive from '@lucide/svelte/icons/hard-drive' +import LayoutDashboard from '@lucide/svelte/icons/layout-dashboard' +import LockOpen from '@lucide/svelte/icons/lock-open' +import Moon from '@lucide/svelte/icons/moon' +import Radio from '@lucide/svelte/icons/radio' +import Server from '@lucide/svelte/icons/server' +import Settings2 from '@lucide/svelte/icons/settings-2' +import Sun from '@lucide/svelte/icons/sun' +import Undo2 from '@lucide/svelte/icons/undo-2' +import Unplug from '@lucide/svelte/icons/unplug' +import Wifi from '@lucide/svelte/icons/wifi' +import WifiOff from '@lucide/svelte/icons/wifi-off' +import ArrowLeft from '@lucide/svelte/icons/arrow-left' +import FlaskConical from '@lucide/svelte/icons/flask-conical' +import Workflow from '@lucide/svelte/icons/workflow' +import Share2 from '@lucide/svelte/icons/share-2' +import GitCompare from '@lucide/svelte/icons/git-compare' +import Blocks from '@lucide/svelte/icons/blocks' +import Plug from '@lucide/svelte/icons/plug' +import NotebookPen from '@lucide/svelte/icons/notebook-pen' +import BarChart3 from '@lucide/svelte/icons/bar-chart-3' +import Info from '@lucide/svelte/icons/info' +import ArrowDown from '@lucide/svelte/icons/arrow-down' +import ArrowUp from '@lucide/svelte/icons/arrow-up' +import ArrowUpDown from '@lucide/svelte/icons/arrow-up-down' +import ArrowLeftRight from '@lucide/svelte/icons/arrow-left-right' +import ArrowDownToLine from '@lucide/svelte/icons/arrow-down-to-line' +import Bookmark from '@lucide/svelte/icons/bookmark' +import Box from '@lucide/svelte/icons/box' +import Bug from '@lucide/svelte/icons/bug' +import CircleSlash from '@lucide/svelte/icons/circle-slash' +import ClipboardCopy from '@lucide/svelte/icons/clipboard-copy' +import Cog from '@lucide/svelte/icons/cog' +import Columns3 from '@lucide/svelte/icons/columns-3' +import Crosshair from '@lucide/svelte/icons/crosshair' +import Eraser from '@lucide/svelte/icons/eraser' +import FileDown from '@lucide/svelte/icons/file-down' +import FileText from '@lucide/svelte/icons/file-text' +import FunctionSquare from '@lucide/svelte/icons/function-square' +import Hash from '@lucide/svelte/icons/hash' +import Infinity from '@lucide/svelte/icons/infinity' +import Keyboard from '@lucide/svelte/icons/keyboard' +import Layers from '@lucide/svelte/icons/layers' +import LayoutList from '@lucide/svelte/icons/layout-list' +import Link2 from '@lucide/svelte/icons/link-2' +import ListFilter from '@lucide/svelte/icons/list-filter' +import Network from '@lucide/svelte/icons/network' +import Package from '@lucide/svelte/icons/package' +import Pin from '@lucide/svelte/icons/pin' +import PinOff from '@lucide/svelte/icons/pin-off' +import RotateCcw from '@lucide/svelte/icons/rotate-ccw' +import SlidersHorizontal from '@lucide/svelte/icons/sliders-horizontal' +import Square from '@lucide/svelte/icons/square' +import SquareCheck from '@lucide/svelte/icons/square-check' +import ChevronsUpDown from '@lucide/svelte/icons/chevrons-up-down' +import ChevronsDownUp from '@lucide/svelte/icons/chevrons-down-up' +import ArrowDown01 from '@lucide/svelte/icons/arrow-down-0-1' +import ArrowDownAZ from '@lucide/svelte/icons/arrow-down-a-z' +import ArrowUp01 from '@lucide/svelte/icons/arrow-up-0-1' +import ArrowUpAZ from '@lucide/svelte/icons/arrow-up-a-z' + +// ── Hugeicons data ─────────────────────────────────────────────────────────── +import { + Cancel01Icon, Tick01Icon, CheckmarkCircle02Icon, ArrowDown01Icon, ArrowUp01Icon, + ArrowRight01Icon, ArrowLeft01Icon, Add01Icon, MinusSignIcon, Search01Icon, + Delete02Icon, Table01Icon, GridTableIcon, RefreshIcon, Copy01Icon, Loading03Icon, Key01Icon, + LinkSquare01Icon, Database01Icon, SourceCodeIcon, TerminalIcon, ChartLineData02Icon, + SparklesIcon, GitBranchIcon, Download04Icon, PlayIcon, ViewIcon, ViewOffSlashIcon, + AiBrain01Icon, Alert02Icon, AlertCircleIcon, Shield01Icon, PencilEdit01Icon, + BracketsIcon, SquareLock01Icon, DashboardSquare01Icon, HistoryIcon, Clock01Icon, + FlashIcon, Settings01Icon, Maximize01Icon, FilterIcon, MoreHorizontalIcon, + Menu01Icon, FolderOpenIcon, Archive01Icon, CircleArrowUp01Icon, ArrowDownDoubleIcon, + ArrowLeftDoubleIcon, ArrowRightDoubleIcon, ArrowUpDoubleIcon, CloudIcon, CommandIcon, + HardDriveIcon, DashboardSquare02Icon, SquareUnlock01Icon, Moon02Icon, RadioIcon, + ServerStack01Icon, Settings02Icon, Sun03Icon, ArrowTurnBackwardIcon, Unlink01Icon, + WifiConnected01Icon, WifiDisconnected01Icon, TestTubeIcon, WorkflowSquare02Icon, + Share08Icon, GitCompareIcon, PuzzleIcon, PlugSocketIcon, NoteEditIcon, ChartBarLineIcon, + InformationCircleIcon, ArrowUpDownIcon, ArrowLeftRightIcon, Bookmark01Icon, BugIcon, + UnavailableIcon, Target01Icon, EraserIcon, FileDownloadIcon, File01Icon, FunctionSquareIcon, + HashIcon, InfinityIcon, KeyboardIcon, Layers01Icon, LeftToRightListBulletIcon, Link01Icon, + WorkflowSquare01Icon, PackageIcon, PinLocation01Icon, PinOffIcon, FilterHorizontalIcon, + SquareIcon, CheckmarkSquare01Icon, UnfoldMoreIcon, UnfoldLessIcon, SortingDownIcon, + SortByDown01Icon, SortingUpIcon, SortByUp01Icon, +} from '@hugeicons/core-free-icons' + +/** @type {Record} */ +export const ICON_MAP = { + 'x': { lucide: X, huge: Cancel01Icon }, + 'check': { lucide: Check, huge: Tick01Icon }, + 'check-circle-2': { lucide: CheckCircle2, huge: CheckmarkCircle02Icon }, + 'chevron-down': { lucide: ChevronDown, huge: ArrowDown01Icon }, + 'chevron-up': { lucide: ChevronUp, huge: ArrowUp01Icon }, + 'chevron-right': { lucide: ChevronRight, huge: ArrowRight01Icon }, + 'chevron-left': { lucide: ChevronLeft, huge: ArrowLeft01Icon }, + 'plus': { lucide: Plus, huge: Add01Icon }, + 'minus': { lucide: Minus, huge: MinusSignIcon }, + 'search': { lucide: Search, huge: Search01Icon }, + 'trash-2': { lucide: Trash2, huge: Delete02Icon }, + 'table-2': { lucide: Table2, huge: GridTableIcon }, + 'refresh-cw': { lucide: RefreshCw, huge: RefreshIcon }, + 'copy': { lucide: Copy, huge: Copy01Icon }, + 'loader-2': { lucide: Loader2, huge: Loading03Icon }, + 'key-round': { lucide: KeyRound, huge: Key01Icon }, + 'external-link': { lucide: ExternalLink, huge: LinkSquare01Icon }, + 'database': { lucide: Database, huge: Database01Icon }, + 'code-2': { lucide: Code2, huge: SourceCodeIcon }, + 'terminal': { lucide: Terminal, huge: TerminalIcon }, + 'bar-chart-2': { lucide: BarChart2, huge: ChartLineData02Icon }, + 'sparkles': { lucide: Sparkles, huge: SparklesIcon }, + 'git-branch': { lucide: GitBranch, huge: GitBranchIcon }, + 'download': { lucide: Download, huge: Download04Icon }, + 'play': { lucide: Play, huge: PlayIcon }, + 'eye': { lucide: Eye, huge: ViewIcon }, + 'eye-off': { lucide: EyeOff, huge: ViewOffSlashIcon }, + 'bot': { lucide: Bot, huge: AiBrain01Icon }, + 'alert-triangle': { lucide: AlertTriangle, huge: Alert02Icon }, + 'alert-circle': { lucide: AlertCircle, huge: AlertCircleIcon }, + 'shield-check': { lucide: ShieldCheck, huge: Shield01Icon }, + 'pencil': { lucide: Pencil, huge: PencilEdit01Icon }, + 'braces': { lucide: Braces, huge: BracketsIcon }, + 'lock': { lucide: Lock, huge: SquareLock01Icon }, + 'layout-template':{ lucide: LayoutTemplate,huge: DashboardSquare01Icon }, + 'history': { lucide: History, huge: HistoryIcon }, + 'arrow-right': { lucide: ArrowRight, huge: ArrowRight01Icon }, + 'clock': { lucide: Clock, huge: Clock01Icon }, + 'zap': { lucide: Zap, huge: FlashIcon }, + 'settings': { lucide: Settings, huge: Settings01Icon }, + 'maximize-2': { lucide: Maximize2, huge: Maximize01Icon }, + 'filter': { lucide: Filter, huge: FilterIcon }, + 'more-horizontal':{ lucide: MoreHorizontal,huge: MoreHorizontalIcon }, + 'menu': { lucide: Menu, huge: Menu01Icon }, + 'folder-open': { lucide: FolderOpen, huge: FolderOpenIcon }, + 'archive': { lucide: Archive, huge: Archive01Icon }, + 'arrow-up-circle':{ lucide: ArrowUpCircle, huge: CircleArrowUp01Icon }, + 'chevrons-down': { lucide: ChevronsDown, huge: ArrowDownDoubleIcon }, + 'chevrons-left': { lucide: ChevronsLeft, huge: ArrowLeftDoubleIcon }, + 'chevrons-right': { lucide: ChevronsRight, huge: ArrowRightDoubleIcon }, + 'chevrons-up': { lucide: ChevronsUp, huge: ArrowUpDoubleIcon }, + 'cloud': { lucide: Cloud, huge: CloudIcon }, + 'command': { lucide: Command, huge: CommandIcon }, + 'hard-drive': { lucide: HardDrive, huge: HardDriveIcon }, + 'layout-dashboard':{ lucide: LayoutDashboard, huge: DashboardSquare02Icon }, + 'lock-open': { lucide: LockOpen, huge: SquareUnlock01Icon }, + 'moon': { lucide: Moon, huge: Moon02Icon }, + 'radio': { lucide: Radio, huge: RadioIcon }, + 'server': { lucide: Server, huge: ServerStack01Icon }, + 'settings-2': { lucide: Settings2, huge: Settings02Icon }, + 'sun': { lucide: Sun, huge: Sun03Icon }, + 'undo-2': { lucide: Undo2, huge: ArrowTurnBackwardIcon }, + 'unplug': { lucide: Unplug, huge: Unlink01Icon }, + 'wifi': { lucide: Wifi, huge: WifiConnected01Icon }, + 'wifi-off': { lucide: WifiOff, huge: WifiDisconnected01Icon }, + 'arrow-left': { lucide: ArrowLeft, huge: ArrowLeft01Icon }, + 'flask-conical': { lucide: FlaskConical, huge: TestTubeIcon }, + 'workflow': { lucide: Workflow, huge: WorkflowSquare02Icon }, + 'share-2': { lucide: Share2, huge: Share08Icon }, + 'git-compare': { lucide: GitCompare, huge: GitCompareIcon }, + 'blocks': { lucide: Blocks, huge: PuzzleIcon }, + 'plug': { lucide: Plug, huge: PlugSocketIcon }, + 'notebook-pen': { lucide: NotebookPen, huge: NoteEditIcon }, + 'bar-chart-3': { lucide: BarChart3, huge: ChartBarLineIcon }, + 'info': { lucide: Info, huge: InformationCircleIcon }, + 'arrow-down': { lucide: ArrowDown, huge: ArrowDown01Icon }, + 'arrow-up': { lucide: ArrowUp, huge: ArrowUp01Icon }, + 'arrow-up-down': { lucide: ArrowUpDown, huge: ArrowUpDownIcon }, + 'arrow-left-right':{ lucide: ArrowLeftRight,huge: ArrowLeftRightIcon }, + 'arrow-down-to-line':{ lucide: ArrowDownToLine, huge: Download04Icon }, + 'bookmark': { lucide: Bookmark, huge: Bookmark01Icon }, + 'box': { lucide: Box, huge: PackageIcon }, + 'bug': { lucide: Bug, huge: BugIcon }, + 'circle-slash': { lucide: CircleSlash, huge: UnavailableIcon }, + 'clipboard-copy': { lucide: ClipboardCopy, huge: Copy01Icon }, + 'cog': { lucide: Cog, huge: Settings01Icon }, + 'columns-3': { lucide: Columns3, huge: Table01Icon }, + 'crosshair': { lucide: Crosshair, huge: Target01Icon }, + 'eraser': { lucide: Eraser, huge: EraserIcon }, + 'file-down': { lucide: FileDown, huge: FileDownloadIcon }, + 'file-text': { lucide: FileText, huge: File01Icon }, + 'function-square':{ lucide: FunctionSquare, huge: FunctionSquareIcon }, + 'hash': { lucide: Hash, huge: HashIcon }, + 'infinity': { lucide: Infinity, huge: InfinityIcon }, + 'keyboard': { lucide: Keyboard, huge: KeyboardIcon }, + 'layers': { lucide: Layers, huge: Layers01Icon }, + 'layout-list': { lucide: LayoutList, huge: LeftToRightListBulletIcon }, + 'link-2': { lucide: Link2, huge: Link01Icon }, + 'list-filter': { lucide: ListFilter, huge: FilterIcon }, + 'network': { lucide: Network, huge: WorkflowSquare01Icon }, + 'package': { lucide: Package, huge: PackageIcon }, + 'pin': { lucide: Pin, huge: PinLocation01Icon }, + 'pin-off': { lucide: PinOff, huge: PinOffIcon }, + 'rotate-ccw': { lucide: RotateCcw, huge: ArrowTurnBackwardIcon }, + 'sliders-horizontal':{ lucide: SlidersHorizontal, huge: FilterHorizontalIcon }, + 'square': { lucide: Square, huge: SquareIcon }, + 'square-check': { lucide: SquareCheck, huge: CheckmarkSquare01Icon }, + 'chevrons-up-down':{ lucide: ChevronsUpDown,huge: UnfoldMoreIcon }, + 'chevrons-down-up':{ lucide: ChevronsDownUp,huge: UnfoldLessIcon }, + 'arrow-down-0-1': { lucide: ArrowDown01, huge: SortingDownIcon }, + 'arrow-down-a-z': { lucide: ArrowDownAZ, huge: SortByDown01Icon }, + 'arrow-up-0-1': { lucide: ArrowUp01, huge: SortingUpIcon }, + 'arrow-up-a-z': { lucide: ArrowUpAZ, huge: SortByUp01Icon }, +} diff --git a/src/lib/plugins/extensions/boolean-glyph.js b/src/lib/plugins/extensions/boolean-glyph.js index b34e8e7d..e44610e1 100644 --- a/src/lib/plugins/extensions/boolean-glyph.js +++ b/src/lib/plugins/extensions/boolean-glyph.js @@ -1,8 +1,15 @@ // Boolean glyphs — a colored dot (or check/cross) instead of raw true/false. import { isBooleanType } from '$lib/cell-value.js' +import { isCurrentThemeDark } from '$lib/stores/settings.js' -const GREEN = 'rgb(74,222,128)' -const RED = 'rgb(248,113,113)' +// 400-shades glow on a dark grid; on a light background they wash out, so use +// deeper 600-shades there. Kept in sync via one long-lived subscription so +// format() (per-cell hot path) reads plain values. +let _dark = true +isCurrentThemeDark.subscribe((v) => { _dark = v }) + +const GREEN = () => (_dark ? 'rgb(74,222,128)' : 'rgb(22,163,74)') +const RED = () => (_dark ? 'rgb(248,113,113)' : 'rgb(220,38,38)') export const booleanGlyph = { id: 'boolean-glyph', @@ -25,8 +32,8 @@ export const booleanGlyph = { const falsy = value === false || value === 'false' || value === 0 || value === '0' || value === 'f' if (!truthy && !falsy) return null if (config.style === 'check') { - return { display: truthy ? '✓ true' : '✗ false', fg: truthy ? GREEN : RED } + return { display: truthy ? '✓ true' : '✗ false', fg: truthy ? GREEN() : RED() } } - return { display: truthy ? 'true' : 'false', dot: truthy ? GREEN : RED } + return { display: truthy ? 'true' : 'false', dot: truthy ? GREEN() : RED() } }, } diff --git a/src/lib/plugins/extensions/status-badge.js b/src/lib/plugins/extensions/status-badge.js index 049669ab..12f89a4a 100644 --- a/src/lib/plugins/extensions/status-badge.js +++ b/src/lib/plugins/extensions/status-badge.js @@ -19,13 +19,31 @@ const MAP = { new: 'blue', created: 'blue', info: 'blue', running: 'blue', started: 'blue', medium: 'blue', normal: 'blue', } -const PALETTE = { +import { isCurrentThemeDark } from '$lib/stores/settings.js' + +// Dark mode: light-400 text on a translucent tint (glows on a dark grid). +const DARK = { green: { bg: 'rgba(34,197,94,0.16)', fg: 'rgb(74,222,128)' }, red: { bg: 'rgba(239,68,68,0.16)', fg: 'rgb(248,113,113)' }, amber: { bg: 'rgba(245,158,11,0.17)', fg: 'rgb(251,191,36)' }, blue: { bg: 'rgba(59,130,246,0.16)', fg: 'rgb(96,165,250)' }, gray: { bg: 'rgba(148,163,184,0.16)', fg: 'rgb(148,163,184)' }, } +// Light mode: saturated ~700-shade text on a pale tint — dark-on-light pills +// that stay legible on a cream background (the old palette used the dark-mode +// light-400 text, which washed out to near-invisible in light mode). +const LIGHT = { + green: { bg: 'rgba(34,197,94,0.15)', fg: 'rgb(21,128,61)' }, + red: { bg: 'rgba(239,68,68,0.13)', fg: 'rgb(185,28,28)' }, + amber: { bg: 'rgba(245,158,11,0.18)', fg: 'rgb(180,83,9)' }, + blue: { bg: 'rgba(59,130,246,0.13)', fg: 'rgb(29,78,216)' }, + gray: { bg: 'rgba(100,116,139,0.16)', fg: 'rgb(51,65,85)' }, +} + +// One long-lived subscription keeps a plain boolean in sync, so format() — a +// per-cell hot path — never touches the store on each call. +let _dark = true +isCurrentThemeDark.subscribe((v) => { _dark = v }) export const statusBadge = { id: 'status-badge', @@ -42,7 +60,7 @@ export const statusBadge = { format(value) { if (typeof value !== 'string' || !value.trim()) return null const key = value.trim().toLowerCase().replace(/[\s-]+/g, '_') - const palette = PALETTE[MAP[key] ?? 'gray'] + const palette = (_dark ? DARK : LIGHT)[MAP[key] ?? 'gray'] return { display: value, badge: palette } }, } diff --git a/src/lib/providers.js b/src/lib/providers.js new file mode 100644 index 00000000..5f031500 --- /dev/null +++ b/src/lib/providers.js @@ -0,0 +1,60 @@ +/** + * Frontend bridge for database provider adapters (Neon, Supabase, PlanetScale, + * Prisma Postgres). Mirrors `cloudflare.js`: thin invoke wrappers plus a + * metadata registry the UI renders from. The heavy lifting (OAuth, listing, + * building a connectable spec) lives in Rust (`src-tauri/src/providers`). + */ +import { invoke } from '@tauri-apps/api/core' + +/** + * @typedef {{ db_ref: string, name: string, region: string|null, kind: string|null, host: string|null }} ProviderDatabase + * @typedef {{ db_type: string, host: string, port: number, username: string, password: string, database: string, ssl: boolean, needs_password: boolean, name: string }} ProviderConnection + */ + +/** UI metadata. `mode: 'token'` providers paste a credential instead of OAuth. */ +export const PROVIDERS = [ + { id: 'neon', name: 'Neon', mode: 'oauth', engine: 'postgres', blurb: 'Serverless Postgres — one-click connect' }, + { id: 'supabase', name: 'Supabase', mode: 'oauth', engine: 'postgres', blurb: 'Postgres platform — asks for your DB password once' }, + { id: 'planetscale', name: 'PlanetScale', mode: 'oauth', engine: 'mysql', blurb: 'Serverless MySQL — mints fresh credentials on connect' }, + { id: 'prisma', name: 'Prisma Postgres', mode: 'oauth', engine: 'postgres', blurb: 'Serverless Postgres — sign in with Prisma' }, +] + +/** @param {string} id */ +export function providerMeta(id) { + return PROVIDERS.find((p) => p.id === id) ?? null +} + +/** Start the browser OAuth flow. @param {string} provider */ +export async function providerStartOAuth(provider) { + return invoke('provider_start_oauth', { provider }) +} + +/** Abort an in-flight OAuth wait — frees the localhost callback port. */ +export async function providerCancelOAuth() { + return invoke('provider_cancel_oauth') +} + +/** Store a pasted API token / connection string for a token-based provider. */ +export async function providerStoreToken(provider, token) { + return invoke('provider_store_token', { provider, token }) +} + +/** @param {string} provider @returns {Promise<{connected: boolean, email: string|null}>} */ +export async function providerOAuthStatus(provider) { + return invoke('provider_oauth_status', { provider }) +} + +/** @param {string} provider */ +export async function providerLogout(provider) { + return invoke('provider_logout', { provider }) +} + +/** @param {string} provider @returns {Promise} */ +export async function providerListDatabases(provider) { + return invoke('provider_list_databases', { provider }) +} + +/** @param {string} provider @param {string} dbRef @returns {Promise} */ +export async function providerBuildConnection(provider, dbRef) { + return invoke('provider_build_connection', { provider, dbRef }) +} diff --git a/src/lib/stores/connections.js b/src/lib/stores/connections.js index 254d26ce..59c2267c 100644 --- a/src/lib/stores/connections.js +++ b/src/lib/stores/connections.js @@ -27,6 +27,7 @@ const LAST_ID_KEY = 'stroke:last-connection-id' * ssh?: SshConfig * readOnly?: boolean * environment?: 'prod' | 'staging' | 'dev' | null + * provider?: 'neon' | 'supabase' | 'planetscale' | 'prisma' * }} SavedConnection */ diff --git a/src/lib/stores/settings.js b/src/lib/stores/settings.js index 0eda5cf7..c1cb4794 100644 --- a/src/lib/stores/settings.js +++ b/src/lib/stores/settings.js @@ -13,7 +13,8 @@ const STORAGE_KEY = 'stroke:settings' /** @typedef {import('$lib/themes/registry.js').ThemeId} ThemeId */ /** @typedef {'geist' | 'serif' | 'apple'} FontId */ /** @typedef {'regular' | 'light' | 'bold'} IconStyleId */ -/** @typedef {{ theme: ThemeId, zoom: number, font: FontId, iconStyle: IconStyleId, mcpAutoStart: boolean, launchAtLogin: boolean, autoReconnectOnStartup: boolean, previewDmlBeforeApply: boolean }} AppSettings */ +/** @typedef {'lucide' | 'hugeicons'} IconSetId */ +/** @typedef {{ theme: ThemeId, zoom: number, font: FontId, iconStyle: IconStyleId, iconSet: IconSetId, mcpAutoStart: boolean, launchAtLogin: boolean, autoReconnectOnStartup: boolean, previewDmlBeforeApply: boolean }} AppSettings */ /** UI zoom scale (font + layout). 1 = 100%. */ export const ZOOM_STEPS = [0.8, 0.85, 0.9, 0.95, 1, 1.05, 1.1, 1.15, 1.25, 1.5] @@ -70,12 +71,31 @@ function normalizeIconStyle(/** @type {unknown} */ id) { return ICON_STYLES[/** @type {IconStyleId} */ (id)] ? /** @type {IconStyleId} */ (id) : DEFAULT_ICON_STYLE } +/** + * Selectable icon families. `lucide` is the built-in stroke set (also honors the + * icon-weight setting above). `hugeicons` swaps in the Hugeicons stroke set wherever + * a component renders through the shared `Icon` wrapper; unmapped glyphs fall back + * to Lucide, so coverage can grow without ever breaking the UI. + * @type {Record} + */ +export const ICON_SETS = { + lucide: { label: 'Lucide', description: 'Crisp, minimal built-in set' }, + hugeicons: { label: 'Hugeicons', description: 'Rounded, expressive premium set' }, +} +/** @type {IconSetId} */ +export const DEFAULT_ICON_SET = 'lucide' +/** @returns {IconSetId} */ +function normalizeIconSet(/** @type {unknown} */ id) { + return ICON_SETS[/** @type {IconSetId} */ (id)] ? /** @type {IconSetId} */ (id) : DEFAULT_ICON_SET +} + /** @type {AppSettings} */ export const DEFAULT_SETTINGS = { theme: DEFAULT_THEME_ID, zoom: DEFAULT_ZOOM, font: DEFAULT_FONT, iconStyle: DEFAULT_ICON_STYLE, + iconSet: DEFAULT_ICON_SET, mcpAutoStart: false, launchAtLogin: false, autoReconnectOnStartup: true, @@ -88,6 +108,9 @@ export const appFont = writable(/** @type {FontId} */ (DEFAULT_FONT)) /** Reactive app icon style (synced by applySettings). */ export const appIconStyle = writable(/** @type {IconStyleId} */ (DEFAULT_ICON_STYLE)) +/** Reactive app icon set / family (synced by applySettings). */ +export const appIconSet = writable(/** @type {IconSetId} */ (DEFAULT_ICON_SET)) + /** Reactive app zoom scale (synced by applySettings). Monaco editors subscribe * to this to rescale their font/line-height in lockstep with the rest of the UI. */ export const appZoom = writable(DEFAULT_ZOOM) @@ -159,7 +182,8 @@ export function loadSettings() { const previewDmlBeforeApply = parsed.previewDmlBeforeApply !== false const font = normalizeFont(parsed.font) const iconStyle = normalizeIconStyle(parsed.iconStyle) - return { theme, zoom, font, iconStyle, mcpAutoStart, launchAtLogin, autoReconnectOnStartup, previewDmlBeforeApply } + const iconSet = normalizeIconSet(parsed.iconSet) + return { theme, zoom, font, iconStyle, iconSet, mcpAutoStart, launchAtLogin, autoReconnectOnStartup, previewDmlBeforeApply } } catch { return { ...DEFAULT_SETTINGS } } @@ -215,6 +239,12 @@ export function applySettings(settings) { root.setAttribute('data-icon-style', iconStyle) appIconStyle.set(iconStyle) + // Icon family — the shared wrapper subscribes to appIconSet and swaps + // between Lucide and Hugeicons. data-icon-set is exposed for any CSS hooks. + const iconSet = normalizeIconSet(settings.iconSet) + root.setAttribute('data-icon-set', iconSet) + appIconSet.set(iconSet) + // Grid-write DML preview toggle — DataTable subscribes to gate its confirm dialog. appPreviewDml.set(settings.previewDmlBeforeApply !== false)