From e4ecefe46af73526cd32f65b4c6c249f8bbcca88 Mon Sep 17 00:00:00 2001 From: davidmonterocrespo24 Date: Sat, 23 May 2026 17:57:46 -0300 Subject: [PATCH] feat(desktop): skip welcome screen, robust openExternal, in-app nav MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three coordinated changes that fix the "Waiting for browser…" hang and unblock first-launch UX on the Tauri desktop build: 1. desktop/index.ts — DON'T mountWelcome unconditionally on first launch. Before, an empty keychain (no key yet) forced the welcome / sign-in screen on top of the editor, gating 100% of the app behind an account. Now the editor opens directly: compile + run + sim + save .vlx all work for free (they're upstream OSS features), and the license check still runs in the background just to populate state for the GraceBanner (which shows for invalid keys — locked, tampered, in soft/hard grace). Pro-only features (ESP32 QEMU download, agent IA) prompt for license at use time, where it actually matters. Matches the "try before you buy" expectation a desktop install creates. 2. desktop/tauriBridge.ts — rewrite `openExternal` to try every known IPC path in cascade order and log via the desktop debug file which one worked. The previous implementation invoked `plugin:shell|open` with `{ path: url }`, which silently failed (no ACL match + wrong arg shape) and fell back to `window.open`, which inside a Tauri webview is a no-op for external URLs — the browser never opened. New cascade: plugin:opener|open_url (paired with tauri-plugin-opener which ships in this revision), then plugin:shell|open with both `{ path, with: null }` and `{ url }` shapes, then the window.__TAURI__.shell / opener high-level wrappers that specific Tauri 2.x flag combos expose. Each attempt logged via the dlog helper so the next operator can see exactly which path was used (or that all failed) without devtools. 3. desktop/menu.ts — new `navigate-route` action type. Routes bundled in the SPA (DocsPage, ExamplesPage, AboutPage) that used to open velxio.dev in the system browser now navigate in-window via history.pushState + popstate (mirrors the locale-switch handler). Respects the current locale prefix so `/examples` from `/es/editor` lands at `/es/examples` instead of jumping back to English. Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/src/desktop/index.ts | 47 ++++++++++++------- frontend/src/desktop/menu.ts | 29 +++++++++++- frontend/src/desktop/tauriBridge.ts | 73 ++++++++++++++++++++++++++--- 3 files changed, 125 insertions(+), 24 deletions(-) diff --git a/frontend/src/desktop/index.ts b/frontend/src/desktop/index.ts index f17ac00c..4d1ba279 100644 --- a/frontend/src/desktop/index.ts +++ b/frontend/src/desktop/index.ts @@ -71,32 +71,45 @@ function mountSidePanels(): void { } /** - * Resolve the initial license state without blocking the SPA's mount. - * If there's no key OR the key doesn't authorise desktop, mount the - * welcome screen on top. Otherwise stay invisible — the editor takes - * over the window. + * Resolve the initial license state in the background. + * + * Policy: the editor ALWAYS opens directly on first launch. Compile, + * run, simulate AVR/RP2040/ATtiny, save .vlx — all that works + * without a license because it's upstream OSS functionality. + * + * The welcome / sign-in screen used to mount unconditionally when + * the license check failed; that gated 100% of the app behind an + * account and broke the "try before you buy" expectation. Now the + * check just runs to populate state for downstream consumers: + * + * - GraceBanner subscribes to `velxio://license-status` and shows + * the amber/red banner only when an EXISTING license enters + * soft/hard grace, lock, or tampered state. + * - Pro-only features (ESP32 QEMU download, agent IA) check + * entitlements at use time and prompt then. + * + * Sign-in is still reachable via the native menubar + * (View → ... in pro/desktop/src-tauri/src/menu.rs). */ async function checkInitialLicense(): Promise { - if (!isTauri()) { - // Running outside Tauri (e.g. `vite dev` in a regular browser tab). - // Skip the welcome screen so the SPA is debuggable. - return; - } + if (!isTauri()) return; try { const key = await invoke('license_get_key'); if (!key) { - mountWelcome(); + dlog('checkInitialLicense: no key — anonymous mode (editor open, free OSS features)'); return; } const result = await invoke('license_validate', { key }); - if (!result.valid || !result.entitlements?.desktop) { - mountWelcome(); - } + dlog('checkInitialLicense: validated', { + valid: result.valid, + plan: result.plan, + reason_code: result.reason_code, + }); + // We deliberately don't mountWelcome here even if invalid — the + // GraceBanner shows for invalid keys (locked / tampered), and an + // anonymous-mode user (no key at all) sees nothing extra. } catch (err) { - // Network / keychain error — show welcome with the error message - // surfaced via the onAuthorised contract. - console.warn('[desktop] initial license check failed:', err); - mountWelcome(); + dlog('checkInitialLicense: failed', { err: String(err) }); } } diff --git a/frontend/src/desktop/menu.ts b/frontend/src/desktop/menu.ts index e760f163..cb54f49a 100644 --- a/frontend/src/desktop/menu.ts +++ b/frontend/src/desktop/menu.ts @@ -37,13 +37,18 @@ type MenuAction = | 'toggle-file-explorer' | 'toggle-serial-monitor' | 'check-for-updates' - | 'set-locale'; + | 'set-locale' + | 'navigate-route'; interface MenuEventPayload { action: MenuAction; // Only present when action='set-locale'. Matches an entry in // i18n/config.ts::LOCALES. locale?: string; + // Only present when action='navigate-route'. Absolute pathname + // (e.g. '/examples', '/docs', '/about') — navigated via React + // Router so the current locale prefix gets applied. + route?: string; } let installed = false; @@ -79,9 +84,31 @@ async function handle(action: MenuAction, payload?: MenuEventPayload): Promise { export async function openExternal(url: string): Promise { const t = tauri(); + + // Outside Tauri (vite dev in a regular browser tab) — just delegate + // to window.open. Works because the real browser obeys it. if (!t) { window.open(url, '_blank', 'noopener,noreferrer'); return; } - // tauri-plugin-shell exposes `plugin:shell|open`. The exact JS API - // depends on the runtime version, so try both paths. - try { - await invoke('plugin:shell|open', { path: url }); - } catch { - window.open(url, '_blank', 'noopener,noreferrer'); + + // Inside Tauri the global API path changed between versions and + // between `withGlobalTauri` exposure flags. Try every known path + // and stop at the first one that returns without throwing. Each + // attempt is logged best-effort so the desktop-debug.log file + // shows exactly which one worked (or that none did). + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const tg = t as any; + const attempts: Array<[string, () => Promise]> = [ + // tauri-plugin-opener — the official Tauri 2.x way for opening + // URLs in the system browser. Most reliable, try first. + ['invoke opener.open_url', () => invoke('plugin:opener|open_url', { url })], + ['invoke opener.open', () => invoke('plugin:opener|open_url', { path: url })], + // tauri-plugin-shell open — older path, arg shape varies between + // 2.x releases; try both. + ['invoke shell.open path', () => invoke('plugin:shell|open', { path: url, with: null })], + ['invoke shell.open url', () => invoke('plugin:shell|open', { url })], + // Global wrappers (only present in specific Tauri 2.x configs). + ['shell.open', () => tg.shell?.open?.(url)], + ['opener.openUrl', () => tg.opener?.openUrl?.(url)], + ['opener.open', () => tg.opener?.open?.(url)], + ]; + + let lastError: unknown = null; + for (const [name, fn] of attempts) { + try { + const r = fn(); + if (r && typeof (r as Promise).then === 'function') { + await r; + } else if (r === undefined) { + // The wrapper didn't exist (optional chaining short-circuited + // to undefined). Skip silently and try the next path. + continue; + } + tryLog(`openExternal: ${name} succeeded`, { url }); + return; + } catch (err) { + lastError = err; + // Keep trying. + } } + + tryLog('openExternal: every IPC path failed, falling back to window.open', { + url, + lastError: lastError ? String(lastError) : null, + }); + window.open(url, '_blank', 'noopener,noreferrer'); +} + +// Best-effort, no-throw: log via the desktop write_debug_log command +// when available. Defined here so openExternal can use it without +// importing from desktop/log.ts (which would create a cycle). +function tryLog(message: string, extra?: unknown): void { + // eslint-disable-next-line no-console + console.log('[velxio-desktop]', message, extra ?? ''); + const t = tauri(); + if (!t) return; + const fn = t.core?.invoke ?? t.invoke; + if (!fn) return; + let line = message; + if (extra !== undefined) { + try { line += ' ' + JSON.stringify(extra); } + catch { line += ' ' + String(extra); } + } + void (fn as TauriInvoke)('write_debug_log', { message: line }).catch(() => {}); } function randomNonce(): string {