From 744d84a8b5de14d32aab0daca047c179703079ae Mon Sep 17 00:00:00 2001 From: huyphan Date: Mon, 6 Jul 2026 22:02:47 -0600 Subject: [PATCH 1/2] fix(web): surface the real Tauri command error instead of a generic fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tauri commands return Result on the Rust side, so a failed command rejects invoke() with a plain string, not an Error instance. Every call site's catch (err) { err instanceof Error ? err.message : fallback } pattern — SetupScreen's first-run wizard, and both error paths in DatabaseSettings — was therefore always taking the generic fallback ("Setup failed.", "Connection failed", "Failed to switch database") and silently discarding the actual reason Rust returned (e.g. "A valid email is required.", a keychain failure, a bad connection string). Fixed once at the IPC boundary in invokeTauri() rather than patching each catch site: normalize whatever invoke() rejects with into a real Error, so err.message downstream is always the genuine message. Co-Authored-By: Claude Sonnet 5 --- apps/web/src/frontend/api/apiBase.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/apps/web/src/frontend/api/apiBase.ts b/apps/web/src/frontend/api/apiBase.ts index 40b506e..d6adc6b 100644 --- a/apps/web/src/frontend/api/apiBase.ts +++ b/apps/web/src/frontend/api/apiBase.ts @@ -23,10 +23,19 @@ export function isTauri(): boolean { } /** Invoke a Tauri command. Rejects if not running under Tauri. */ -export function invokeTauri(cmd: string, args?: unknown): Promise { +export async function invokeTauri(cmd: string, args?: unknown): Promise { const invoke = tauri()?.core?.invoke; - if (!invoke) return Promise.reject(new Error('Not running under Tauri')); - return invoke(cmd, args); + if (!invoke) throw new Error('Not running under Tauri'); + try { + return await invoke(cmd, args); + } catch (err) { + // Rust commands return Result, so a failed command rejects + // with a plain string here, not an Error — every call site's + // `err instanceof Error ? err.message : fallback` would otherwise + // always take the fallback and hide the real reason. Normalize once, + // at the IPC boundary, instead of fixing every catch site individually. + throw err instanceof Error ? err : new Error(typeof err === 'string' ? err : JSON.stringify(err)); + } } /** Set the cached API base (e.g. after the sidecar is spawned at setup). */ From e0146e4c20cc3929586f0b14580afe96262dc7c2 Mon Sep 17 00:00:00 2001 From: huyphan Date: Mon, 6 Jul 2026 23:22:53 -0600 Subject: [PATCH 2/2] feat(desktop): simplify first-run setup to need no input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wizard required an email and let you edit the database path before finishing setup — every validation failure (bad email, keychain error, a copied-DB refusal) surfaced as an opaque error with no field to even retry from, since the previous commit's swallowed-error bug hid what actually went wrong. Setup now completes automatically on first launch: email defaults to local@foxschema.app (the same placeholder the web edition's single-user mode already uses) and the database stays on its default SQLite location. No form, no required input — just a brief "Securing this install…" moment, or a real error message with a Retry button if something genuinely fails (e.g. a copied database refused by enforce_install_binding). Since email is no longer entered at setup, added a way to set a real one afterward: Settings → Security now has an editable "Change" action backed by a new update_email Tauri command. It re-stores the SAME key material under the new email's keychain account (the DEK itself never changes, so already-encrypted data keeps decrypting) and best-effort removes the old account — no sidecar respawn needed, since nothing about the metadata DB changes. Co-Authored-By: Claude Sonnet 5 --- apps/desktop/src-tauri/Cargo.lock | 2 +- apps/desktop/src-tauri/src/lib.rs | 43 ++++++ apps/web/src/frontend/api/setupApi.ts | 9 ++ .../src/frontend/components/EmailSettings.tsx | 116 ++++++++++++++ .../src/frontend/components/SettingsPanel.tsx | 18 +-- .../src/frontend/components/SetupScreen.tsx | 145 ++++++------------ 6 files changed, 216 insertions(+), 117 deletions(-) create mode 100644 apps/web/src/frontend/components/EmailSettings.tsx diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index 2340d1e..b0cb4bc 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -1003,7 +1003,7 @@ dependencies = [ [[package]] name = "foxschema-desktop" -version = "0.1.2" +version = "0.1.12" dependencies = [ "keyring", "log", diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index da0ad30..ef3ca12 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -450,6 +450,48 @@ async fn update_db_config( Ok(build_setup_state(state.inner(), &api_base)) } +/// Rebind the encryption key to a new email — from Settings, any time after +/// setup (which defaults to a placeholder email so first-run needs no input). +/// Re-stores the SAME key material under the new email's keychain account — +/// the DEK itself never changes, so already-encrypted data keeps decrypting — +/// then best-effort removes the old account. No sidecar respawn: the running +/// process already holds the DEK in memory and nothing about the metadata DB +/// changes. +#[tauri::command] +async fn update_email(state: State<'_, AppState>, email: String) -> Result { + let email_norm = email.trim().to_lowercase(); + if !email_norm.contains('@') || email_norm.contains(char::is_whitespace) { + return Err("A valid email is required.".into()); + } + + let old_email = state.setup.lock().unwrap().email.clone(); + if old_email.is_empty() { + return Err("Setup is not complete.".into()); + } + if email_norm == old_email { + let api_base = state.api_base.lock().unwrap().clone(); + return Ok(build_setup_state(state.inner(), &api_base)); + } + + let dek = keychain_get(&old_email).ok_or("Encryption key not found in keychain.")?; + keychain_set(&email_norm, &dek)?; + + let info = { + let mut s = state.setup.lock().unwrap(); + s.email = email_norm.clone(); + s.key_scheme = "v2".to_string(); + s.clone() + }; + write_setup(&state.data_dir, &info)?; + + if let Ok(entry) = keyring::Entry::new(KEY_SERVICE, &old_email) { + let _ = entry.delete_credential(); + } + + let api_base = state.api_base.lock().unwrap().clone(); + Ok(build_setup_state(state.inner(), &api_base)) +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() @@ -505,6 +547,7 @@ pub fn run() { get_setup_state, complete_setup, update_db_config, + update_email, pick_db_location ]) .build(tauri::generate_context!()) diff --git a/apps/web/src/frontend/api/setupApi.ts b/apps/web/src/frontend/api/setupApi.ts index 3dc03bf..db86b43 100644 --- a/apps/web/src/frontend/api/setupApi.ts +++ b/apps/web/src/frontend/api/setupApi.ts @@ -92,3 +92,12 @@ export async function updateDbConfig(input: { dbUrl: input.dbUrl || null, }); } + +/** + * Rebind the encryption key to a new email (desktop only, Settings → Security). + * The key material is unchanged — only which keychain account holds it, and + * setup.json's record of it, move to the new email. + */ +export async function updateEmail(email: string): Promise { + return invokeTauri('update_email', { email }); +} diff --git a/apps/web/src/frontend/components/EmailSettings.tsx b/apps/web/src/frontend/components/EmailSettings.tsx new file mode 100644 index 0000000..12007bd --- /dev/null +++ b/apps/web/src/frontend/components/EmailSettings.tsx @@ -0,0 +1,116 @@ +import React, { useState } from 'react'; +import { ShieldCheck, Loader2, AlertCircle, CheckCircle2, Pencil } from 'lucide-react'; +import { isTauri } from '../api/apiBase'; +import { updateEmail, type AppInfo } from '../api/setupApi'; + +/** + * Rebind the encryption key's email in Settings → Security. Setup itself + * defaults to a placeholder email so first-run needs no input — this is + * where a user sets (or changes) the real one whenever they want. + * Desktop-only; the web edition's key isn't email-bound the same way. + */ +export const EmailSettings: React.FC<{ info: AppInfo; onUpdated: (info: AppInfo) => void }> = ({ info, onUpdated }) => { + const [editing, setEditing] = useState(false); + const [email, setEmail] = useState(info.security.boundEmail); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [saved, setSaved] = useState(false); + + if (!isTauri() || !info.security.emailBound) { + return ( +

+ {info.security.emailBound ? ( + <> + Encryption key bound to {info.security.boundEmail} and + held in the OS keychain — a copied database can't be decrypted elsewhere. + + ) : ( + <>Encryption key stored on this install (legacy key scheme). + )} +

+ ); + } + + const submit = async (e: React.FormEvent) => { + e.preventDefault(); + setSaving(true); + setError(null); + try { + const state = await updateEmail(email.trim()); + onUpdated({ ...info, security: { ...info.security, boundEmail: state.email } }); + setEditing(false); + setSaved(true); + setTimeout(() => setSaved(false), 2500); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to update email.'); + } finally { + setSaving(false); + } + }; + + if (!editing) { + return ( +
+ +

+ Encryption key bound to {info.security.boundEmail} and + held in the OS keychain — a copied database can't be decrypted elsewhere. +

+ + {saved && ( + + Saved + + )} +
+ ); + } + + return ( +
+
+ setEmail(e.target.value)} + placeholder="your@email.com" + className="flex-1 min-w-0 bg-slate-950 border border-slate-800 focus:border-cyan-500 rounded-md px-2.5 py-1.5 text-xs outline-none" + /> + + +
+ {error && ( +
+ + {error} +
+ )} +
+ ); +}; diff --git a/apps/web/src/frontend/components/SettingsPanel.tsx b/apps/web/src/frontend/components/SettingsPanel.tsx index 7c6bb1a..2e45192 100644 --- a/apps/web/src/frontend/components/SettingsPanel.tsx +++ b/apps/web/src/frontend/components/SettingsPanel.tsx @@ -4,6 +4,7 @@ import { X, Sun, Moon, Monitor, Palette, Type, RotateCcw, ShieldCheck, Database, import { useUiStore, ACCENTS, TONES, FONT_SIZES, THEME_PRESETS, type ThemeMode, type AccentId, type ThemePreset } from '../store/uiStore'; import { fetchAppInfo, type AppInfo } from '../api/setupApi'; import { DatabaseSettings } from './DatabaseSettings'; +import { EmailSettings } from './EmailSettings'; import { UpdatesSettings } from './UpdatesSettings'; interface Props { @@ -192,21 +193,8 @@ export const SettingsPanel: React.FC = ({ open, onClose }) => { {info && (
} title="Security"> -
- -

- {info.security.emailBound ? ( - <> - Encryption key bound to{' '} - {info.security.boundEmail} and held in the OS - keychain — a copied database can't be decrypted elsewhere. - - ) : ( - <>Encryption key stored on this install (legacy key scheme). - )} -

+
+
)} diff --git a/apps/web/src/frontend/components/SetupScreen.tsx b/apps/web/src/frontend/components/SetupScreen.tsx index 279a5aa..eec3383 100644 --- a/apps/web/src/frontend/components/SetupScreen.tsx +++ b/apps/web/src/frontend/components/SetupScreen.tsx @@ -1,123 +1,66 @@ -import React, { useState } from 'react'; -import { Loader2, AlertCircle, ShieldCheck, HardDrive, FolderOpen } from 'lucide-react'; -import { completeSetup, pickDbLocation, type SetupState } from '../api/setupApi'; -import { isTauri } from '../api/apiBase'; +import React, { useEffect, useState } from 'react'; +import { Loader2, AlertCircle, ShieldCheck } from 'lucide-react'; +import { completeSetup, type SetupState } from '../api/setupApi'; import { Brand } from './Brand'; +// Matches authStore.ts's LOCAL_USER — the same placeholder identity the web +// edition's single-user mode already uses. Real per-install key binding (a +// distinct email per machine) is opt-in via Settings → Security → Change, +// rather than required to get through first run. +const DEFAULT_EMAIL = 'local@foxschema.app'; + /** - * One-time desktop first-run screen. Collects the user's email — the per-install - * encryption key is bound to it and stored in the OS keychain, so a copied - * database can't be decrypted on another machine — and where the bundled SQLite - * database lives. New installs always start on SQLite; switching to Postgres / - * MySQL is done later in Settings → Database. + * One-time desktop first-run screen. Silently binds the encryption key (kept + * in the OS keychain) and starts the app on the default SQLite location — no + * input required. Both the bound email and the database engine/location can + * be changed afterward in Settings → Security / Settings → Database. */ export const SetupScreen: React.FC<{ initial: SetupState; onDone: (s: SetupState) => void }> = ({ initial, onDone, }) => { - const [email, setEmail] = useState(initial.email); - const [dbPath, setDbPath] = useState(initial.db_path || initial.default_db_path); - const [busy, setBusy] = useState(false); const [error, setError] = useState(null); + const [attempt, setAttempt] = useState(0); - const browse = async () => { - try { - const picked = await pickDbLocation(dbPath || initial.default_db_path); - if (picked) setDbPath(picked); - } catch { - /* dialog cancelled / unavailable — keep the typed value */ - } - }; - - const submit = async (e: React.FormEvent) => { - e.preventDefault(); + useEffect(() => { + let alive = true; setError(null); - setBusy(true); - try { - const state = await completeSetup({ - email: email.trim(), - engine: 'sqlite', - dbPath: dbPath.trim() || undefined, - }); - onDone(state); - } catch (err) { - setError(err instanceof Error ? err.message : 'Setup failed.'); - setBusy(false); - } - }; + completeSetup({ + email: initial.email || DEFAULT_EMAIL, + engine: 'sqlite', + }) + .then((state) => alive && onDone(state)) + .catch((err) => alive && setError(err instanceof Error ? err.message : 'Setup failed.')); + return () => { + alive = false; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps -- retried by bumping `attempt`, not by re-deriving inputs + }, [attempt]); return (
-
-
- -

Welcome — let's secure this install. Takes a few seconds.

-
+
+ -
-
- - setEmail(e.target.value)} - placeholder="your@email.com" - className="bg-slate-950 border border-slate-800 focus:border-cyan-500 rounded-md px-3 py-2 text-sm outline-none" - /> -

- Your encryption key is generated on this machine, bound to this email, and stored in the OS keychain — - never written to disk. Copying the database to another computer won't decrypt it. -

-
- -
- -
- setDbPath(e.target.value)} - spellCheck={false} - className="flex-1 min-w-0 bg-slate-950 border border-slate-800 focus:border-cyan-500 rounded-md px-3 py-2 text-xs font-mono outline-none" - /> - {isTauri() && ( - - )} -
-

- Fox stores its data in a local SQLite database here. You can switch to your own Postgres or MySQL - server later in Settings → Database. -

-
- - {error && ( + {error ? ( +
{error}
- )} - - - + +
+ ) : ( +

+ Securing this install… +

+ )}
);