From cbd790c331729ee05319c13985be0785d637bf51 Mon Sep 17 00:00:00 2001 From: broisnischal Date: Mon, 6 Jul 2026 17:44:28 +0545 Subject: [PATCH 01/23] fix: Ctrl+A in cell editor, light-mode badge/glyph contrast, FK sub-view scroll --- src/lib/components/DataTable.svelte | 10 ++++++---- src/lib/components/FkSubviewPanel.svelte | 8 ++++++++ src/lib/plugins/extensions/boolean-glyph.js | 15 ++++++++++---- src/lib/plugins/extensions/status-badge.js | 22 +++++++++++++++++++-- 4 files changed, 45 insertions(+), 10 deletions(-) diff --git a/src/lib/components/DataTable.svelte b/src/lib/components/DataTable.svelte index 88582c11..dd50e274 100644 --- a/src/lib/components/DataTable.svelte +++ b/src/lib/components/DataTable.svelte @@ -2487,10 +2487,13 @@ import FilterX from "@lucide/svelte/icons/filter-x"; if (e.key === '0') { e.preventDefault(); resetZoom(); return } } - // Ctrl+A: select all rows + // Ctrl+A: select all rows — but while editing a cell, let the input handle + // its native "select all text" (don't preventDefault, or it's swallowed). if ((e.ctrlKey || e.metaKey) && !e.altKey && (e.key === "a" || e.key === "A")) { - e.preventDefault(); - if (!editingCell) selected = new Set(rows.map((_, i) => i)); + if (!editingCell) { + e.preventDefault(); + selected = new Set(rows.map((_, i) => i)); + } return; } @@ -4426,7 +4429,6 @@ import FilterX from "@lucide/svelte/icons/filter-x"; >
{ if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) e.stopPropagation() }} > 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/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 } }, } From 1d653a44d89b063baa30b5295a801629f8a89553 Mon Sep 17 00:00:00 2001 From: broisnischal Date: Mon, 6 Jul 2026 17:44:28 +0545 Subject: [PATCH 02/23] =?UTF-8?q?feat:=20database=20provider=20adapters=20?= =?UTF-8?q?=E2=80=94=20Neon/Supabase/PlanetScale/Prisma=20sign-in=20+=20co?= =?UTF-8?q?nnect?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/database-providers.md | 10 + src-tauri/src/lib.rs | 8 + src-tauri/src/providers/mod.rs | 652 ++++++++++++++++++++++ src-tauri/src/providers/neon.rs | 146 +++++ src-tauri/src/providers/planetscale.rs | 113 ++++ src-tauri/src/providers/prisma.rs | 215 +++++++ src-tauri/src/providers/supabase.rs | 136 +++++ src/lib/components/ConnectionModal.svelte | 64 ++- src/lib/components/ProviderConnect.svelte | 365 ++++++++++++ src/lib/components/StatusBar.svelte | 34 +- src/lib/components/StudioShell.svelte | 28 + src/lib/providers.js | 60 ++ src/lib/stores/connections.js | 1 + 13 files changed, 1826 insertions(+), 6 deletions(-) create mode 100644 .changeset/database-providers.md create mode 100644 src-tauri/src/providers/mod.rs create mode 100644 src-tauri/src/providers/neon.rs create mode 100644 src-tauri/src/providers/planetscale.rs create mode 100644 src-tauri/src/providers/prisma.rs create mode 100644 src-tauri/src/providers/supabase.rs create mode 100644 src/lib/components/ProviderConnect.svelte create mode 100644 src/lib/providers.js 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/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 92b28843..0f77b2f3 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}; @@ -296,6 +297,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/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/lib/components/ConnectionModal.svelte b/src/lib/components/ConnectionModal.svelte index 2895297a..1803c9d5 100644 --- a/src/lib/components/ConnectionModal.svelte +++ b/src/lib/components/ConnectionModal.svelte @@ -21,6 +21,7 @@ import ChevronDown from '@lucide/svelte/icons/chevron-down' import Check from '@lucide/svelte/icons/check' import CloudflareLogin from './CloudflareLogin.svelte' + import ProviderConnect from './ProviderConnect.svelte' import SearchableMenu from './SearchableMenu.svelte' import { testPostgresConnection, connectPostgres, @@ -84,6 +85,15 @@ { id: 'bigquery', label: 'BigQuery', desc: 'Google analytics warehouse', soon: true }, ], }, + { + label: 'Hosting providers', + drivers: [ + { id: 'neon', label: 'Neon', desc: 'Serverless Postgres — sign in & pick a database' }, + { id: 'supabase', label: 'Supabase', desc: 'Postgres platform — sign in & pick a project' }, + { id: 'planetscale', label: 'PlanetScale', desc: 'Serverless MySQL — sign in & pick a database' }, + { id: 'prisma', label: 'Prisma Postgres', desc: 'Paste a Prisma Postgres connection string' }, + ], + }, ] const ALL_DRIVERS = CATEGORIES.flatMap(c => c.drivers) @@ -93,7 +103,8 @@ // PostgreSQL → SQLite → MySQL pinned to the top, then the rest. const DRIVER_ORDER = [ 'postgres', 'sqlite', 'mysql', 'mariadb', 'cockroachdb', 'mssql', - 'clickhouse', 'duckdb', 'sqlite-memory', 'duckdb-memory', 'libsql', 'd1', 'bigquery', + 'clickhouse', 'duckdb', 'sqlite-memory', 'duckdb-memory', 'libsql', + 'neon', 'supabase', 'planetscale', 'prisma', 'd1', 'bigquery', ] const driverItems = DRIVER_ORDER .map((id) => ALL_DRIVERS.find((d) => d.id === id)) @@ -234,6 +245,38 @@ d1Reset() } + /** + * A provider adapter resolved a database (with a password if it needed one): + * build a SavedConnection and connect immediately via connectWith — no detour + * through the manual form, so picking a project just connects. Runs the same + * connect/save/close path as any other connection. + * @param {import('$lib/providers.js').ProviderConnection} conn + */ + async function connectProviderConnection(conn) { + error = '' + // dbType is the provider id while the provider flow is showing — tag the + // connection with it so the status bar can offer switching to the account's + // other databases later. + const providerId = ['neon', 'supabase', 'planetscale', 'prisma'].includes(dbType) ? dbType : undefined + const type = conn.db_type === 'mysql' ? 'mysql' : 'postgres' + // Reuse an existing saved entry for this exact database (host + user) instead + // of piling up duplicates — connectWith upserts it, keeping the saved password. + const existing = saved.find((s) => s.host === conn.host && s.user === conn.username && s.type === type) + await connectWith({ + id: existing?.id ?? newConnectionId(), + type, + name: conn.name, + host: conn.host, + port: conn.port, + database: conn.database, + user: conn.username, + password: conn.password, + ssl: conn.ssl, + provider: providerId, + readOnly: readOnly || undefined, + }) + } + function switchDriver(id) { dbType = id if (id === 'postgres') port = '5432' @@ -414,6 +457,10 @@ if (id === 'duckdb') return 'text-yellow-500' if (id === 'duckdb-memory') return 'text-amber-300' if (id === 'mssql') return 'text-red-400' + if (id === 'neon') return 'text-emerald-400' + if (id === 'supabase') return 'text-green-400' + if (id === 'planetscale') return 'text-purple-400' + if (id === 'prisma') return 'text-indigo-300' return 'text-muted-foreground' } function driverBg(id) { @@ -429,6 +476,10 @@ if (id === 'duckdb') return 'bg-yellow-500/10' if (id === 'duckdb-memory') return 'bg-amber-500/10' if (id === 'mssql') return 'bg-red-500/10' + if (id === 'neon') return 'bg-emerald-500/10' + if (id === 'supabase') return 'bg-green-500/10' + if (id === 'planetscale') return 'bg-purple-500/10' + if (id === 'prisma') return 'bg-indigo-500/10' return 'bg-muted/40' } @@ -518,6 +569,7 @@ {:else if id === 'duckdb'} {:else if id === 'duckdb-memory'} {:else if id === 'mssql'} + {:else if id === 'neon' || id === 'supabase' || id === 'planetscale' || id === 'prisma'} {:else} {/if} {/snippet} @@ -669,8 +721,16 @@
+ + {#if dbType === 'neon' || dbType === 'supabase' || dbType === 'planetscale' || dbType === 'prisma'} + saved.find((s) => s.host === host && s.user === user && s.password)?.password} + onselect={(conn) => connectProviderConnection(conn)} + /> + - {#if dbType === 'postgres' || dbType === 'cockroachdb'} + {:else if dbType === 'postgres' || dbType === 'cockroachdb'}
diff --git a/src/lib/components/ProviderConnect.svelte b/src/lib/components/ProviderConnect.svelte new file mode 100644 index 00000000..9ccae06f --- /dev/null +++ b/src/lib/components/ProviderConnect.svelte @@ -0,0 +1,365 @@ + + +
+ {#if phase === 'idle'} + +
+
+ +
+
+

Connect with {meta?.name}

+

{meta?.blurb}

+
+ + {#if meta?.mode === 'token'} +
+ { if (e.key === 'Enter') saveToken() }} + /> + +
+ {:else} + +

Opens your browser to authorize (secure PKCE flow)

+ {/if} +
+ + {:else if phase === 'authorizing'} +
+ +
+

Browser opened

+

Authorize Stroke in the {meta?.name} page, then return here.

+
+

Waiting… (5 min timeout)

+ +
+ + {:else if phase === 'fetching'} +
+ Loading your databases… +
+ + {:else if phase === 'selecting' || phase === 'building' || phase === 'password'} + +
+
+ +
+
+

Connected to {meta?.name}

+
+ +
+ + {#if phase === 'password'} + +
+

+ Database password for {resolved?.name} +

+

+ {meta?.name} doesn't expose the database password through its API — enter it once. + Find or reset it in your {meta?.name} dashboard under Database settings. +

+
+ + { if (e.key === 'Enter') confirmPassword() }} + /> + +
+
+ + +
+
+ + + {:else if databases.length > 0} +
+ {#if databases.length > 6} +
+ + { if (e.key === 'Escape') search = '' }} + /> +
+ {/if} +
+ {#each filtered as db (db.db_ref)} + {@const active = db.db_ref === selectedRef} + + {/each} + {#if filtered.length === 0} +

No match for “{search}”

+ {/if} +
+
+ {:else} +
+ +

No databases found on this account.

+ +
+ {/if} + + {:else if phase === 'error'} +
+
+ + {errorMsg || 'Something went wrong.'} +
+ +
+ {/if} +
diff --git a/src/lib/components/StatusBar.svelte b/src/lib/components/StatusBar.svelte index 890818f3..c7e05d04 100644 --- a/src/lib/components/StatusBar.svelte +++ b/src/lib/components/StatusBar.svelte @@ -41,6 +41,7 @@ import { aiProfiles, activeProfileId, setActiveProfile } from '$lib/stores/ai-settings.js' import { toggleLightDark, isCurrentThemeDark } from '$lib/stores/settings.js' import { executeSql, cloudflareListD1Databases } from '$lib/api.js' + import { providerListDatabases } from '$lib/providers.js' import { engineFamily } from '$lib/stores/connections.js' import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js' import AppearanceMenu from './AppearanceMenu.svelte' @@ -59,6 +60,7 @@ onconnect = /** @type {() => void} */ (() => {}), onswitchtodb = /** @type {(db: string) => void} */ ((_db) => {}), onswitchd1database = /** @type {(db: { databaseId: string, name: string }) => void} */ ((_db) => {}), + onswitchproviderdb = /** @type {(db: { provider: string, dbRef: string, name: string }) => void} */ ((_db) => {}), onswitchconnection = /** @type {(conn: import('$lib/stores/connections.js').SavedConnection) => void} */ ((_c) => {}), oncheckupdate = /** @type {() => void} */ (() => {}), onopenmodelsettings = /** @type {() => void} */ (() => {}), @@ -229,10 +231,18 @@ ) const isPostgres = $derived(engineFamily(connection?.type) === 'postgres' || engineFamily(connection?.type) === 'mysql') const isD1 = $derived(connection?.type === 'd1') + /** Connection that originated from a provider sign-in (Neon/Supabase/…). */ + const isProvider = $derived(!!connection?.provider) /** Whether this connection supports switching databases in-place. */ - const canSwitchDb = $derived(isPostgres || isD1) - /** Label shown in the trigger for the active db (D1 has no `database` field, so fall back to the connection name). */ - const currentDbLabel = $derived(isD1 ? (connection?.database || connection?.name || '') : currentDb) + const canSwitchDb = $derived(isPostgres || isD1 || isProvider) + /** Label shown in the trigger for the active db. Provider connections all use + * the db name "postgres", so show the project name instead (from the connection + * name, e.g. "Prisma · stroke-testing" → "stroke-testing"). */ + const currentDbLabel = $derived( + isProvider ? ((connection?.name?.split(' · ').pop()) || connection?.name || currentDb) + : isD1 ? (connection?.database || connection?.name || '') + : currentDb, + ) /** Key of the active db, used to mark the current row. */ const currentDbKey = $derived(isD1 ? (connection?.databaseId ?? '') : currentDb) @@ -243,6 +253,20 @@ ) async function fetchDatabases() { + // Provider connections list the account's other databases/projects via the + // provider API (checked first — a Supabase/Neon connection is also postgres). + if (isProvider && connection?.provider) { + dbLoading = true + try { + const dbs = await providerListDatabases(connection.provider) + dbList = (dbs ?? []).map((/** @type {{ db_ref: string, name: string }} */ d) => ({ key: d.db_ref, label: d.name })) + } catch { + dbList = [] + } finally { + dbLoading = false + } + return + } if (isPostgres) { dbLoading = true try { @@ -278,7 +302,9 @@ } function switchDb(/** @type {{ key: string, label: string }} */ db) { - if (db.key !== currentDbKey) { + if (isProvider && connection?.provider) { + onswitchproviderdb({ provider: connection.provider, dbRef: db.key, name: db.label }) + } else if (db.key !== currentDbKey) { if (isD1) onswitchd1database({ databaseId: db.key, name: db.label }) else onswitchtodb(db.label) } diff --git a/src/lib/components/StudioShell.svelte b/src/lib/components/StudioShell.svelte index cae8e31d..5c9782ae 100644 --- a/src/lib/components/StudioShell.svelte +++ b/src/lib/components/StudioShell.svelte @@ -4915,6 +4915,34 @@ let rowSearch = $state('') if (!connection) return void handleSwitchDatabase({ ...connection, databaseId, database: name, name }) }} + onswitchproviderdb={async ({ provider, dbRef, name }) => { + if (!connection) return + try { + const { providerBuildConnection } = await import('$lib/providers.js') + const built = await providerBuildConnection(provider, dbRef) + if (built.needs_password) { + // Supabase needs a per-project password — can't switch silently, so + // send the user to the connect dialog to finish it. + toast.message(`${name} needs its database password — opening the connection dialog.`) + showConnectionModal = true + return + } + void handleSwitchDatabase({ + ...connection, + type: built.db_type === 'mysql' ? 'mysql' : 'postgres', + host: built.host, + port: built.port, + user: built.username, + password: built.password, + database: built.database, + ssl: built.ssl, + name: built.name, + provider, + }) + } catch (e) { + toast.error('Could not switch database', { description: String(e) }) + } + }} oncheckupdate={() => updateDialog?.checkNow()} onopenmodelsettings={() => (showAiModelSettings = true)} sidebarVisible={sidebarOpen} 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 */ From d4992fe6bc3ed6b202667fdc139319bcbbc7e43e Mon Sep 17 00:00:00 2001 From: broisnischal Date: Mon, 6 Jul 2026 21:08:00 +0545 Subject: [PATCH 03/23] feat: full-window connection screen + monochrome brand icons - Replace the floating connection dialog with a full-window screen (fixed inset-0) built on the bits-ui Dialog primitive, keeping focus-trap / Escape / scroll-lock. Roomier, centered form column, no more Type-dropdown overflow. - Add DbIcon.svelte + db-icons.js: real monochrome brand marks (Postgres, MySQL, MariaDB, SQLite, CockroachDB, ClickHouse, DuckDB, Cloudflare, BigQuery, Turso, Neon, Supabase, PlanetScale, Prisma) rendered with currentColor. SQL Server + unknowns fall back to a filled cylinder. Paths sourced from simple-icons, inlined so no runtime dependency. - Use brand marks in the saved-connection list, the Type dropdown (trigger + items), and the provider sign-in panel. - Revamp ProviderConnect idle/connected states with the brand mark and cleaner typography; drop the generic database glyph. - Clarify MS SQL Server 'Encrypt' / 'Trust server certificate' options with helper text; gate Trust on Encrypt being enabled. --- src/lib/components/ConnectionModal.svelte | 180 +++++++++------------- src/lib/components/DbIcon.svelte | 27 ++++ src/lib/components/ProviderConnect.svelte | 39 +++-- src/lib/db-icons.js | 32 ++++ 4 files changed, 150 insertions(+), 128 deletions(-) create mode 100644 src/lib/components/DbIcon.svelte create mode 100644 src/lib/db-icons.js diff --git a/src/lib/components/ConnectionModal.svelte b/src/lib/components/ConnectionModal.svelte index 1803c9d5..8e9a11f9 100644 --- a/src/lib/components/ConnectionModal.svelte +++ b/src/lib/components/ConnectionModal.svelte @@ -8,13 +8,6 @@ import CheckCircle2 from '@lucide/svelte/icons/check-circle-2' import AlertCircle from '@lucide/svelte/icons/alert-circle' import Trash2 from '@lucide/svelte/icons/trash-2' - import Database from '@lucide/svelte/icons/database' - import HardDrive from '@lucide/svelte/icons/hard-drive' - import Zap from '@lucide/svelte/icons/zap' - import Globe from '@lucide/svelte/icons/globe' - import Cloud from '@lucide/svelte/icons/cloud' - import BarChart2 from '@lucide/svelte/icons/bar-chart-2' - import Server from '@lucide/svelte/icons/server' import FolderOpen from '@lucide/svelte/icons/folder-open' import Terminal from '@lucide/svelte/icons/terminal' import Lock from '@lucide/svelte/icons/lock' @@ -23,6 +16,7 @@ import CloudflareLogin from './CloudflareLogin.svelte' import ProviderConnect from './ProviderConnect.svelte' import SearchableMenu from './SearchableMenu.svelte' + import DbIcon from './DbIcon.svelte' import { testPostgresConnection, connectPostgres, testSqliteConnection, connectSqlite, @@ -41,7 +35,7 @@ import { Input } from '$lib/components/ui/input/index.js' import { Checkbox } from '$lib/components/ui/checkbox/index.js' import { ScrollArea } from '$lib/components/ui/scroll-area/index.js' - import * as Dialog from '$lib/components/ui/dialog/index.js' + import { Dialog as DialogPrimitive } from 'bits-ui' import { cn } from '$lib/utils.js' import { parseConnectionUri } from '$lib/connection-uri.js' @@ -443,46 +437,6 @@ const inp = 'h-[34px] w-full rounded-md border border-border/50 bg-muted/20 px-3 text-[13px] text-foreground placeholder:text-muted-foreground/40 placeholder:font-normal transition-colors focus-visible:border-ring/60 focus-visible:ring-1 focus-visible:ring-ring/20 focus-visible:outline-none' const inpNum = inp + ' [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none' - /** Color class for a driver id */ - function driverColor(id) { - if (id === 'postgres') return 'text-blue-400' - if (id === 'mysql') return 'text-amber-400' - if (id === 'mariadb') return 'text-amber-500' - if (id === 'cockroachdb') return 'text-indigo-400' - if (id === 'sqlite') return 'text-emerald-400' - if (id === 'sqlite-memory') return 'text-violet-400' - if (id === 'libsql') return 'text-sky-400' - if (id === 'd1') return 'text-orange-400' - if (id === 'clickhouse') return 'text-yellow-400' - if (id === 'duckdb') return 'text-yellow-500' - if (id === 'duckdb-memory') return 'text-amber-300' - if (id === 'mssql') return 'text-red-400' - if (id === 'neon') return 'text-emerald-400' - if (id === 'supabase') return 'text-green-400' - if (id === 'planetscale') return 'text-purple-400' - if (id === 'prisma') return 'text-indigo-300' - return 'text-muted-foreground' - } - function driverBg(id) { - if (id === 'postgres') return 'bg-blue-500/10' - if (id === 'mysql') return 'bg-amber-500/10' - if (id === 'mariadb') return 'bg-amber-500/10' - if (id === 'cockroachdb') return 'bg-indigo-500/10' - if (id === 'sqlite') return 'bg-emerald-500/10' - if (id === 'sqlite-memory') return 'bg-violet-500/10' - if (id === 'libsql') return 'bg-sky-500/10' - if (id === 'd1') return 'bg-orange-500/10' - if (id === 'clickhouse') return 'bg-yellow-500/10' - if (id === 'duckdb') return 'bg-yellow-500/10' - if (id === 'duckdb-memory') return 'bg-amber-500/10' - if (id === 'mssql') return 'bg-red-500/10' - if (id === 'neon') return 'bg-emerald-500/10' - if (id === 'supabase') return 'bg-green-500/10' - if (id === 'planetscale') return 'bg-purple-500/10' - if (id === 'prisma') return 'bg-indigo-500/10' - return 'bg-muted/40' - } - async function pickSqliteFile() { try { const { open } = await import('@tauri-apps/plugin-dialog') @@ -554,45 +508,38 @@
{/snippet} - -{#snippet dicon(id, cls = 'size-4', colored = false)} - {@const c = colored ? driverColor(id) : 'text-muted-foreground'} - {#if id === 'postgres'} - {:else if id === 'mysql'} - {:else if id === 'mariadb'} - {:else if id === 'cockroachdb'} - {:else if id === 'sqlite'} - {:else if id === 'sqlite-memory'} - {:else if id === 'libsql'} - {:else if id === 'd1'} - {:else if id === 'clickhouse'} - {:else if id === 'duckdb'} - {:else if id === 'duckdb-memory'} - {:else if id === 'mssql'} - {:else if id === 'neon' || id === 'supabase' || id === 'planetscale' || id === 'prisma'} - {:else} {/if} -{/snippet} - - - -
+ + + + + Connections +
-
-{#snippet toggleRow(/** @type {string} */ label, /** @type {string} */ description, /** @type {boolean} */ checked, /** @type {() => void} */ ontoggle)} -
+ +{#snippet secLabel(/** @type {string} */ text)} + {#if !searching} +

{text}

+
+ {/if} +{/snippet} + +{#snippet switchRow(/** @type {string} */ label, /** @type {string} */ desc, /** @type {boolean} */ checked, /** @type {() => void} */ ontoggle)} +
-

{label}

-

{description}

+

{label}

+

{desc}

{/snippet} -{#snippet navRow(/** @type {string} */ iconName, /** @type {string} */ label, /** @type {() => void} */ onselect, /** @type {{ label: string, class: string } | null} */ badge = null)} - +{#snippet actionRow(/** @type {string} */ label, /** @type {string} */ desc, /** @type {string} */ btn, /** @type {() => void} */ onclick, /** @type {{label:string,class:string}|null} */ badge = null)} +
+
+

+ {label} + {#if badge}{badge.label}{/if} +

+

{desc}

+
+ +
+{/snippet} + +{#snippet generalContent()} + {@render secLabel('Startup & behavior')} + {#if show('Launch at login', 'Start Stroke when you sign in')} + {@render switchRow('Launch at login', 'Start Stroke when you sign in', launchAtLogin ?? false, toggleLaunchAtLogin)} + {/if} + {#if show('Auto reconnect on startup', 'Reconnect to the last database on launch')} + {@render switchRow('Auto reconnect on startup', 'Reconnect to the last database on launch', settings.autoReconnectOnStartup, toggleAutoReconnect)} + {/if} + {#if show('Preview SQL before applying', 'Review the DML before edits, inserts, and deletes run')} + {@render switchRow('Preview SQL before applying', 'Review the DML before edits, inserts, and deletes run', settings.previewDmlBeforeApply, togglePreviewDml)} + {/if} + {#if show('MCP auto-start', 'Start the MCP server on database connect')} + {@render switchRow('MCP auto-start', 'Start the MCP server on database connect', settings.mcpAutoStart, toggleMcpAutoStart)} + {/if} +{/snippet} + +{#snippet appearanceContent()} + {@render secLabel('Theme & typeface')} + + {#if show('Theme', 'Color theme for the whole app')} +
+
+

Theme

+

Color theme for the whole app.

+
+ { if (v) setTheme(/** @type {import('$lib/themes/registry.js').ThemeId} */ (v)); }}> + + + + {activeTheme.name} + + + + {#each themeGroups as group, i (group.id)} + {#if i > 0}{/if} + + {group.label} + {#each group.themes as theme (theme.id)} + + {#snippet children()} + + + + {theme.name} + {theme.description} + + + {/snippet} + + {/each} + + {/each} + + +
+ {/if} + + {#if show('Font', 'UI and editor typeface')} +
+
+

Font

+

UI and editor typeface.

+
+ { if (v) setFont(/** @type {import('$lib/stores/settings.js').FontId} */ (v)); }}> + + + + {FONT_PRESETS[settings.font]?.label ?? "Geist"} + + + + {#each fontEntries as [id, preset] (id)} + + {#snippet children()} + + + + {preset.label} + {preset.description} + + + {/snippet} + + {/each} + + +
+ {/if} + + {@render secLabel('Icons')} + + {#if show('Icon weight', 'Stroke thickness of Lucide icons')} +
+
+

Icon weight

+

Stroke thickness of the icon set.

+
+ { if (v) setIconStyle(/** @type {import('$lib/stores/settings.js').IconStyleId} */ (v)); }}> + + + + + + {#each iconStyleEntries as [id, preset] (id)} + + {#snippet children()} + + + + + {preset.label} + {preset.description} + + + {/snippet} + + {/each} + + +
+ {/if} + + {#if show('Icon set', 'Icon family used across the app')} +
+
+

Icon set

+

Icon family used across the app.

+
+ { if (v) setIconSet(/** @type {import('$lib/stores/settings.js').IconSetId} */ (v)); }}> + + + + {ICON_SETS[settings.iconSet]?.label ?? "Lucide"} + + + + {#each iconSetEntries as [id, preset] (id)} + + {#snippet children()} + + + {#if id === "hugeicons"} + + + {:else} + + + {/if} + + + {preset.label} + {preset.description} + + + {/snippet} + + {/each} + + +
+ {/if} + + {@render secLabel('Display')} + {#if show('Zoom', 'Scale the whole interface')} +
+
+

Zoom

+

Scale the whole interface.

+
+
+ + + +
+
+ {/if} +{/snippet} + +{#snippet integrationsContent()} + {@render secLabel('Tools')} + {#if show('Extensions', 'Cell formatters, ID generators & transforms')} + {@render actionRow('Extensions', 'Cell formatters, ID generators & transforms.', 'Open', () => { open = false; onopenextensions(); })} + {/if} + {#if show('MCP configuration', 'Expose your database to external AI tools')} + {@render actionRow('MCP configuration', 'Expose your database to external AI tools.', 'Open', () => { open = false; onopenmcp(); })} + {/if} + {#if show('AI models', 'Configure AI providers and API keys')} + {@render actionRow('AI models', 'Configure AI providers and API keys.', 'Configure', openModelConfiguration)} + {/if} + + {@render secLabel('Account')} + {#if show('License', 'Activate or manage your Stroke license')} + {@render actionRow('License', 'Activate or manage your Stroke license.', 'Manage', () => { open = false; onopenlicense(); }, planBadge)} + {/if} +{/snippet} + +{#snippet aboutContent()} + {@render secLabel('About')} + {#if show('About Stroke', 'Version, credits, and release notes')} + {@render actionRow('About Stroke', 'Version, credits, and release notes.', 'View', () => { open = false; onopenabout(); })} + {/if} + {#if show('Website', 'stroke.click — docs, licensing, and support')} +
+
+

Website

+

Docs, licensing, and support.

+
+ + stroke.click + +
+ {/if} + + {#if !searching} +

Keyboard

+
+
+ {@render shortcut('⌘M', 'cycle theme')} + {@render shortcut('⌘⇧M', 'previous theme')} + {@render shortcut('⌘+ / ⌘−', 'zoom')} + {@render shortcut('⌘0', 'reset zoom')} +
+ {/if} {/snippet} {#snippet shortcut(/** @type {string} */ keys, /** @type {string} */ action)} - + {keys} {action} From beb625f55b42f6b5220311c65bc4a48d4c7d8c47 Mon Sep 17 00:00:00 2001 From: broisnischal Date: Mon, 6 Jul 2026 22:50:38 +0545 Subject: [PATCH 16/23] fix: use GridTableIcon for table glyph (real data grid, not furniture) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hugeicons Table01/02/03 are all furniture-style (tabletop + legs). Map 'table-2' to GridTableIcon — a rounded rect with row/column lines — so the sidebar table rows, tabs, and everywhere else read as a data table. --- src/lib/icon-registry.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/icon-registry.js b/src/lib/icon-registry.js index 2a1248e4..bcf9b900 100644 --- a/src/lib/icon-registry.js +++ b/src/lib/icon-registry.js @@ -132,7 +132,7 @@ import ArrowUpAZ from '@lucide/svelte/icons/arrow-up-a-z' import { Cancel01Icon, Tick01Icon, CheckmarkCircle02Icon, ArrowDown01Icon, ArrowUp01Icon, ArrowRight01Icon, ArrowLeft01Icon, Add01Icon, MinusSignIcon, Search01Icon, - Delete02Icon, Table01Icon, Table02Icon, RefreshIcon, Copy01Icon, Loading03Icon, Key01Icon, + Delete02Icon, Table01Icon, GridTableIcon, RefreshIcon, Copy01Icon, Loading03Icon, Key01Icon, LinkSquare01Icon, Database01Icon, SourceCodeIcon, TerminalIcon, ChartLineData02Icon, SparklesIcon, GitBranchIcon, Download04Icon, PlayIcon, ViewIcon, ViewOffSlashIcon, AiBrain01Icon, Alert02Icon, AlertCircleIcon, Shield01Icon, PencilEdit01Icon, @@ -165,7 +165,7 @@ export const ICON_MAP = { 'minus': { lucide: Minus, huge: MinusSignIcon }, 'search': { lucide: Search, huge: Search01Icon }, 'trash-2': { lucide: Trash2, huge: Delete02Icon }, - 'table-2': { lucide: Table2, huge: Table02Icon }, + 'table-2': { lucide: Table2, huge: GridTableIcon }, 'refresh-cw': { lucide: RefreshCw, huge: RefreshIcon }, 'copy': { lucide: Copy, huge: Copy01Icon }, 'loader-2': { lucide: Loader2, huge: Loading03Icon }, From f5f7613b49c1e0b5ca654387d7717416f05e8c5e Mon Sep 17 00:00:00 2001 From: broisnischal Date: Mon, 6 Jul 2026 23:04:05 +0545 Subject: [PATCH 17/23] feat: 17-day trial, reinstall-proof, and hard block on expiry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TRIAL_DAYS 5 → 17. - Trial start is mirrored to /.trial AND /.stroke/trial; the earliest timestamp wins and both are repaired on read. A reinstall wipes app-data but the home copy survives, so the trial can't be reset by uninstall/reinstall. (Server-side device registration remains the path for full-disk-wipe enforcement; API scaffolding already exists.) - LicenseGate now hard-blocks: when the trial is over with no valid license, the entire app is replaced by a non-dismissible activation screen (activate or quit). Loads local status on first paint, then phones home. cargo check + frontend build pass. --- src-tauri/src/license.rs | 89 ++++++++++++++------ src/lib/components/LicenseGate.svelte | 18 +++- src/lib/components/TrialExpiredScreen.svelte | 60 +++++++++++++ 3 files changed, 138 insertions(+), 29 deletions(-) create mode 100644 src/lib/components/TrialExpiredScreen.svelte diff --git a/src-tauri/src/license.rs b/src-tauri/src/license.rs index ec48c347..09fd0898 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,23 +196,34 @@ 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 @@ -200,17 +233,23 @@ pub fn trial_days_remaining(data_dir: &Path) -> i64 { /// 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(()) } diff --git a/src/lib/components/LicenseGate.svelte b/src/lib/components/LicenseGate.svelte index 5b2e94bb..e6ae9bc7 100644 --- a/src/lib/components/LicenseGate.svelte +++ b/src/lib/components/LicenseGate.svelte @@ -1,11 +1,21 @@ - -{@render children()} +{#if $isBlocked} + + +{:else} + {@render children()} +{/if} diff --git a/src/lib/components/TrialExpiredScreen.svelte b/src/lib/components/TrialExpiredScreen.svelte new file mode 100644 index 00000000..a593a084 --- /dev/null +++ b/src/lib/components/TrialExpiredScreen.svelte @@ -0,0 +1,60 @@ + + +
+ +
+ +
+
+ +
+ +
+

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 + + · + +
+
+
From 1b9a19ff1e27f4d0f4cbdb0dfb8d2528fd627bde Mon Sep 17 00:00:00 2001 From: broisnischal Date: Mon, 6 Jul 2026 23:09:19 +0545 Subject: [PATCH 18/23] fix: Data Diff grid fills the pane (no empty hole) + opaque sticky header - The results table was width:totalWidth, so with few columns it left a big empty 'hole' to the right. Now w-full + min-width + a flexible filler column so the grid always fills the pane; wide diffs still scroll horizontally. - Sticky header cells were transparent, so scrolled rows bled through it. Give every header cell an opaque bg-background. - Hide the junk 'null' column type label; subtle hover highlight on unchanged rows (status tints preserved). --- src/lib/components/DataDiffPage.svelte | 28 +++++++++++++++----------- 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/src/lib/components/DataDiffPage.svelte b/src/lib/components/DataDiffPage.svelte index a8d31fa2..03327141 100644 --- a/src/lib/components/DataDiffPage.svelte +++ b/src/lib/components/DataDiffPage.svelte @@ -677,22 +677,24 @@ class:cursor-col-resize={resizingCol >= 0} onscroll={handleTableScroll} > - +
- + {#each colWidths as w}{/each} + + - - - + + + {#each columns as col, ci} {/each} + - {#if topPad > 0}{/if} + {#if topPad > 0}{/if} {#each displayRows.slice(vStart, vEnd) as row} {@const isAdded = row.status === 'added'} {@const isRemoved = row.status === 'removed'} @@ -727,7 +730,7 @@ isAdded ? 'bg-emerald-500/35' : isRemoved ? 'bg-red-500/35' : isModified? 'bg-amber-500/25' : ''} - + {/each} + {/each} - {#if bottomPad > 0}{/if} + {#if bottomPad > 0}{/if}
-
- {col.name} - {#if col.dataType}{col.dataType}{/if} +
+ {col.name} + {#if col.dataType && col.dataType.toLowerCase() !== 'null'}{col.dataType}{/if}
@@ -704,10 +706,11 @@
{#if accentColor}{/if} {statusGlyph} @@ -766,9 +769,10 @@ {/if}
From 0ce5d2377ba1272e4db6c38cb1445e7fd07dffbe Mon Sep 17 00:00:00 2001 From: broisnischal Date: Mon, 6 Jul 2026 23:15:09 +0545 Subject: [PATCH 19/23] feat: server-authoritative trial (reconcile with /api/license/trial) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On launch, when not licensed, phone home to POST device_id (+ local start) to the trial endpoint. The server keeps the EARLIEST start per device and returns it; we mirror that back to the local .trial files. So a reinstall or disk wipe can't grant a fresh trial — the server remembers the device. Offline is a silent no-op (local grace). cargo check passes. --- src-tauri/src/commands.rs | 7 ++++++ src-tauri/src/license.rs | 50 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+) 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/license.rs b/src-tauri/src/license.rs index 09fd0898..f2712937 100644 --- a/src-tauri/src/license.rs +++ b/src-tauri/src/license.rs @@ -229,6 +229,38 @@ pub fn trial_days_remaining(data_dir: &Path) -> 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)] @@ -439,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) { From 47c05d18db82c3f3ce285d03fba8b55ff853550f Mon Sep 17 00:00:00 2001 From: broisnischal Date: Mon, 6 Jul 2026 23:20:13 +0545 Subject: [PATCH 20/23] feat: eye icon for the show/hide columns toolbar button The column visibility control used a furniture-ish 'columns-3' glyph in the Hugeicons set. Give it a clear eye (eye when all visible, eye-off when some columns are hidden) + a 'Show / hide columns' label. Keeps the hidden-count badge. --- src/lib/components/TableToolbar.svelte | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/lib/components/TableToolbar.svelte b/src/lib/components/TableToolbar.svelte index 6c2bc50d..d0e9e2e3 100644 --- a/src/lib/components/TableToolbar.svelte +++ b/src/lib/components/TableToolbar.svelte @@ -571,10 +571,11 @@
- -
- + +
+
From 5eafc8176559dbb7c4540ccbdb6ee0b5a32e3fc3 Mon Sep 17 00:00:00 2001 From: broisnischal Date: Mon, 6 Jul 2026 23:31:10 +0545 Subject: [PATCH 23/23] chore: release 1.6.0 --- package.json | 2 +- src-tauri/tauri.conf.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 4f5fa3f3..3d70577b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "stroke", "private": true, - "version": "1.5.0", + "version": "1.6.0", "type": "module", "scripts": { "dev": "vite", diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index c5a0a4c9..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.0", + "version": "1.6.0", "identifier": "com.broisnischal.stroke", "build": { "frontendDist": "../dist",