feat(desktop): skip welcome screen, robust openExternal, in-app nav
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) <noreply@anthropic.com>
This commit is contained in:
parent
cf28b3b5ea
commit
e4ecefe46a
|
|
@ -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<void> {
|
||||
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<string | null>('license_get_key');
|
||||
if (!key) {
|
||||
mountWelcome();
|
||||
dlog('checkInitialLicense: no key — anonymous mode (editor open, free OSS features)');
|
||||
return;
|
||||
}
|
||||
const result = await invoke<ValidationResult>('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) });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<v
|
|||
case 'set-locale':
|
||||
if (payload?.locale) setLocale(payload.locale);
|
||||
return;
|
||||
case 'navigate-route':
|
||||
if (payload?.route) navigateTo(payload.route);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function navigateTo(route: string): void {
|
||||
// Prefix with the current locale if we're on a non-default one,
|
||||
// so `/examples` from `/es/editor` lands at `/es/examples` instead
|
||||
// of switching back to English. Mirrors how LanguageSwitcher does it.
|
||||
const cur = window.location.pathname;
|
||||
const localeMatch = cur.match(/^\/([a-z]{2}(?:-[a-z]{2})?)\b/);
|
||||
const prefix = localeMatch && LOCALES.includes(localeMatch[1] as Locale)
|
||||
? `/${localeMatch[1]}`
|
||||
: '';
|
||||
const normalised = route.startsWith('/') ? route : `/${route}`;
|
||||
const next = `${prefix}${normalised}`;
|
||||
if (next === cur) return;
|
||||
// history.pushState + popstate keeps React Router happy without
|
||||
// reloading the SPA (Monaco state, simulator state, sidecar
|
||||
// connection all preserved).
|
||||
window.history.pushState(null, '', next);
|
||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||
}
|
||||
|
||||
function setLocale(locale: string): void {
|
||||
// Defensive: ignore unknown locales coming from the menu so a
|
||||
// stale shell doesn't navigate to a broken URL.
|
||||
|
|
|
|||
|
|
@ -60,17 +60,78 @@ export const listen: TauriListen = async (event, cb) => {
|
|||
|
||||
export async function openExternal(url: string): Promise<void> {
|
||||
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<unknown>]> = [
|
||||
// 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<unknown>).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 {
|
||||
|
|
|
|||
Loading…
Reference in New Issue