Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
cbd790c
fix: Ctrl+A in cell editor, light-mode badge/glyph contrast, FK sub-v…
broisnees Jul 6, 2026
1d653a4
feat: database provider adapters — Neon/Supabase/PlanetScale/Prisma s…
broisnees Jul 6, 2026
d4992fe
feat: full-window connection screen + monochrome brand icons
broisnees Jul 6, 2026
a59f448
polish: center connection form in a card, taller softer inputs
broisnees Jul 6, 2026
85a26db
feat: switchable Lucide / Hugeicons icon system
broisnees Jul 6, 2026
37c28e1
feat: extend icon registry to status-bar glyphs; crisper Input focus …
broisnees Jul 6, 2026
4532d4a
revamp: premium onboarding flow
broisnees Jul 6, 2026
c4cc353
fix: connection screen not a card, provider + cloudflare panels revamped
broisnees Jul 6, 2026
208f112
feat: Settings nav rows switch icon set live; add info glyph
broisnees Jul 6, 2026
1e1b135
revamp: declutter onboarding step 1, license page, empty state, sideb…
broisnees Jul 6, 2026
72f1290
feat: route app chrome through <Icon> — Lucide/Hugeicons now switches…
broisnees Jul 6, 2026
84c5947
fix: native Edit menu (undo/redo/word-delete in inputs), refine Input…
broisnees Jul 6, 2026
980c4d4
fix: keep cell-edit font size stable (drop speculative leading-none)
broisnees Jul 6, 2026
f4a9a19
fix: grid-style table icon, remove sidebar middle divider, hint on Po…
broisnees Jul 6, 2026
9863daa
revamp: two-pane Settings (category nav + search + sectioned rows)
broisnees Jul 6, 2026
beb625f
fix: use GridTableIcon for table glyph (real data grid, not furniture)
broisnees Jul 6, 2026
f5f7613
feat: 17-day trial, reinstall-proof, and hard block on expiry
broisnees Jul 6, 2026
1b9a19f
fix: Data Diff grid fills the pane (no empty hole) + opaque sticky he…
broisnees Jul 6, 2026
0ce5d23
feat: server-authoritative trial (reconcile with /api/license/trial)
broisnees Jul 6, 2026
47c05d1
feat: eye icon for the show/hide columns toolbar button
broisnees Jul 6, 2026
b08c381
chore: dev-only window.__stroke console helpers for trial testing
broisnees Jul 6, 2026
54ecadc
fix: no-license screen — drop the card, cleaner activation
broisnees Jul 6, 2026
5eafc81
chore: release 1.6.0
broisnees Jul 6, 2026
eb86a63
Merge remote-tracking branch 'origin/master' into worktree-conn-revamp
broisnees Jul 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/database-providers.md
Original file line number Diff line number Diff line change
@@ -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
22 changes: 20 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "stroke",
"private": true,
"version": "1.5.1",
"version": "1.6.0",
"type": "module",
"scripts": {
"dev": "vite",
Expand Down Expand Up @@ -32,6 +32,8 @@
"@fontsource-variable/geist-mono": "^5.2.8",
"@fontsource-variable/inter": "^5.2.8",
"@fontsource-variable/jetbrains-mono": "^5.2.8",
"@hugeicons/core-free-icons": "^4.2.2",
"@hugeicons/svelte": "^1.1.4",
"@tailwindcss/vite": "^4.3.0",
"@tanstack/svelte-hotkeys": "^0.10.0",
"@tauri-apps/plugin-autostart": "^2.5.1",
Expand Down
7 changes: 7 additions & 0 deletions src-tauri/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
17 changes: 17 additions & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ mod docker;
mod license;
mod mcp;
mod metrics;
mod providers;
mod secrets;

use db::{ActiveConnection, DbState, TunnelState};
Expand Down Expand Up @@ -146,6 +147,15 @@ pub fn run() {
view.setMagnification(1.0);
view.setPageZoom(1.0);
});

// The window is frameless (decorations = false), so no application
// menu is created automatically. On macOS, WKWebView text fields
// rely on the app menu's Edit items for the standard editing
// shortcuts — ⌘Z undo, ⌘⇧Z redo, ⌘X/⌘C/⌘V, ⌘A select-all, and
// ⌥⌫ / ⌘⌫ word/line delete. Install the standard menu so native
// text editing works everywhere (inputs, textareas, cell editors).
let menu = tauri::menu::Menu::default(app.handle())?;
app.set_menu(menu)?;
}

if cfg!(debug_assertions) {
Expand Down Expand Up @@ -296,6 +306,13 @@ pub fn run() {
cloudflare::cloudflare_logout,
cloudflare::cloudflare_list_accounts,
cloudflare::cloudflare_list_d1_databases,
providers::provider_start_oauth,
providers::provider_cancel_oauth,
providers::provider_store_token,
providers::provider_oauth_status,
providers::provider_logout,
providers::provider_list_databases,
providers::provider_build_connection,
db::backup::backup_export,
db::backup::backup_import,
commands::check_license_status,
Expand Down
139 changes: 114 additions & 25 deletions src-tauri/src/license.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,13 @@
/// After first activation the decoded data is written to:
/// <app_data_dir>/license.json
///
/// Trial is tracked in:
/// <app_data_dir>/.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):
/// <app_data_dir>/.trial — removed on a full uninstall
/// <home>/.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.
Expand All @@ -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";

Expand Down Expand Up @@ -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<PathBuf> {
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<PathBuf> {
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.
Expand Down Expand Up @@ -174,43 +196,92 @@ fn now_secs() -> u64 {
.unwrap_or(0)
}

/// Reads trial start, creating the file on first call.
/// Returns `days_remaining` (negative = expired).
/// Reads the trial start across all mirror locations and returns `days_remaining`
/// (negative = expired). The EARLIEST timestamp found wins — so deleting one copy
/// (e.g. uninstalling, which wipes app-data) can't grant a fresh trial while the
/// home-dir copy survives. Every location is (re)written with the winning start so
/// a missing/newer copy is repaired on each launch.
pub fn trial_days_remaining(data_dir: &Path) -> i64 {
let path = trial_path(data_dir);
let start_secs = if path.exists() {
std::fs::read(&path)
.ok()
.and_then(|b| b.try_into().ok())
.map(u64::from_le_bytes)
.unwrap_or_else(now_secs)
} else {
// First launch — record now
let _ = std::fs::create_dir_all(data_dir);
let now = now_secs();
let _ = std::fs::write(&path, now.to_le_bytes());
now
};
let paths = trial_paths(data_dir);

let mut earliest: Option<u64> = None;
for p in &paths {
if let Ok(bytes) = std::fs::read(p) {
if let Ok(arr) = <[u8; 8]>::try_from(bytes.as_slice()) {
let t = u64::from_le_bytes(arr);
earliest = Some(earliest.map_or(t, |e| e.min(t)));
}
}
}

// First launch on this device (or every copy was removed) — start now.
let start_secs = earliest.unwrap_or_else(now_secs);

// Propagate/repair the winning start to every location.
for p in &paths {
if let Some(dir) = p.parent() {
let _ = std::fs::create_dir_all(dir);
}
let _ = std::fs::write(p, start_secs.to_le_bytes());
}

let elapsed_days = (now_secs().saturating_sub(start_secs) / 86_400) as i64;
TRIAL_DAYS - elapsed_days
}

/// The earliest trial start recorded across local mirrors, without writing.
/// `None` if no local trial file exists yet (e.g. a fresh install).
fn local_trial_start(data_dir: &Path) -> Option<u64> {
let mut earliest: Option<u64> = None;
for p in trial_paths(data_dir) {
if let Ok(bytes) = std::fs::read(&p) {
if let Ok(arr) = <[u8; 8]>::try_from(bytes.as_slice()) {
let t = u64::from_le_bytes(arr);
earliest = Some(earliest.map_or(t, |e| e.min(t)));
}
}
}
earliest
}

/// Phone home to sync the trial clock with the server (earliest-wins). The
/// server records the start the first time it sees this device and always
/// returns the earliest known start — so a reinstall or disk wipe can't hand
/// out a fresh trial. On success the authoritative start is written to every
/// local mirror so the next local check reflects it. No-op when offline.
pub async fn reconcile_trial(data_dir: &Path) {
let local = local_trial_start(data_dir);
if let Some(server_start) = api_trial(&device_id(), local).await {
for p in trial_paths(data_dir) {
if let Some(dir) = p.parent() {
let _ = std::fs::create_dir_all(dir);
}
let _ = std::fs::write(&p, server_start.to_le_bytes());
}
}
}

/// DEBUG ONLY — write a trial start timestamp N days in the past so the UI
/// shows "N days elapsed" without waiting for real time to pass.
#[cfg(debug_assertions)]
pub fn debug_set_trial_days_ago(data_dir: &Path, days_ago: u64) -> Result<(), String> {
std::fs::create_dir_all(data_dir).map_err(|e| e.to_string())?;
let fake_start = now_secs().saturating_sub(days_ago * 86_400);
std::fs::write(trial_path(data_dir), fake_start.to_le_bytes()).map_err(|e| e.to_string())
for p in trial_paths(data_dir) {
if let Some(dir) = p.parent() {
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
}
std::fs::write(&p, fake_start.to_le_bytes()).map_err(|e| e.to_string())?;
}
Ok(())
}

/// DEBUG ONLY — delete the trial file so the next check starts a fresh trial.
/// DEBUG ONLY — delete every trial mirror so the next check starts a fresh trial.
#[cfg(debug_assertions)]
pub fn debug_reset_trial(data_dir: &Path) -> Result<(), String> {
let p = trial_path(data_dir);
if p.exists() {
std::fs::remove_file(p).map_err(|e| e.to_string())?;
for p in trial_paths(data_dir) {
if p.exists() {
std::fs::remove_file(&p).map_err(|e| e.to_string())?;
}
}
Ok(())
}
Expand Down Expand Up @@ -400,6 +471,24 @@ pub async fn api_check(key: &str, device_id: &str) -> Option<bool> {
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<u64>) -> Option<u64> {
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) {
Expand Down
Loading
Loading