velxio/frontend/src/desktop/index.ts

259 lines
8.8 KiB
TypeScript

/**
* Velxio Desktop SPA hooks - mounted from main.tsx when VITE_DESKTOP is set.
*
* Responsibilities since v0.3.0:
*
* 1. Decide on first paint whether to mount the welcome / lockout
* overlay or let the editor open directly. Driven by
* `license_gate_info()` (returns {state, grandfather_days_remaining}).
* 2. Listen for `velxio://license-required` from the Tauri shell -
* fires when the sidecar refuses to start (gate closed) OR
* exits with code 78 mid-session. Mounts the LockoutOverlay.
* 3. Mount the persistent side panels (grace banner + ESP32 prompt).
*
* Variant decision (no key paths):
*
* gate.state === 'grandfather' -> soft welcome with "use anyway for N days"
* gate.state === null -> lockout (no_credential variant)
* gate.state === 'valid'|'soft'|'hard' -> normal app, banner handles grace
*
* Tampered tokens currently surface via the sidecar exit-78 path
* because the shell's runtime_state_for_sidecar() maps `Tampered` to
* None. We could distinguish the two by passing a reason in the event
* payload (Phase 4 polish).
*/
import { createRoot, type Root } from 'react-dom/client';
import { createElement, createElement as h, Fragment } from 'react';
import { DesktopWelcomePage } from './DesktopWelcomePage';
import { Esp32QemuPrompt } from './Esp32QemuPrompt';
import { Stm32QemuPrompt } from './Stm32QemuPrompt';
import { GraceBanner } from './GraceBanner';
import { LockoutOverlay, type LockoutReason } from './LockoutOverlay';
import { UpdateAvailableToast } from './UpdateAvailableToast';
import {
getGateInfo,
invoke,
isTauri,
listen,
type GateInfo,
type ValidationResult,
} from './tauriBridge';
import { installDesktopMenuListener } from './menu';
import { dlog } from './log';
import './desktop.css';
let mounted = false;
let overlayRoot: Root | null = null;
let overlayHost: HTMLElement | null = null;
let sidePanelRoot: Root | null = null;
function unmountOverlay(): void {
if (overlayRoot) {
try { overlayRoot.unmount(); } catch { /* noop */ }
overlayRoot = null;
}
if (overlayHost) {
overlayHost.remove();
overlayHost = null;
}
}
function ensureOverlayHost(): HTMLElement {
if (overlayHost) return overlayHost;
overlayHost = document.createElement('div');
overlayHost.id = 'velxio-desktop-overlay-root';
document.body.appendChild(overlayHost);
return overlayHost;
}
function mountWelcome(grandfatherDaysRemaining: number | null): void {
unmountOverlay();
const host = ensureOverlayHost();
overlayRoot = createRoot(host);
overlayRoot.render(
createElement(DesktopWelcomePage, {
onAuthorised: () => unmountOverlay(),
grandfatherDaysRemaining,
}),
);
dlog('mountWelcome', { grandfatherDaysRemaining });
}
function mountLockout(reason: LockoutReason): void {
unmountOverlay();
const host = ensureOverlayHost();
overlayRoot = createRoot(host);
overlayRoot.render(createElement(LockoutOverlay, { reason }));
dlog('mountLockout', { reason });
}
function mountSidePanels(): void {
if (sidePanelRoot) return;
const host = document.createElement('div');
host.id = 'velxio-desktop-side-panels';
document.body.appendChild(host);
sidePanelRoot = createRoot(host);
sidePanelRoot.render(
h(
Fragment,
null,
h(GraceBanner, null),
h(Esp32QemuPrompt, null),
h(Stm32QemuPrompt, null),
// v0.4.0 auto-update toast (~30s after mount). Lives below the
// grace banner z-index so a lockout / hard-grace doesn't get
// covered by a "new version available" pitch.
h(UpdateAvailableToast, null),
),
);
}
/**
* Decide initial state on first paint. Three outcomes:
*
* 1. State is valid / soft_grace / hard_grace -> editor opens, no
* overlay (the GraceBanner side panel handles in-app messaging
* for soft/hard grace).
* 2. State is grandfather -> soft welcome with grandfather days
* remaining + a "continue without signing in" escape.
* 3. State is null -> hard lockout (no_credential variant).
*
* Tampered tokens currently hit case 3 with a generic reason; we
* could refine by passing a richer payload from the shell later.
*/
async function evaluateAndMount(gate: GateInfo): Promise<void> {
switch (gate.state) {
case 'valid':
case 'soft_grace':
case 'hard_grace':
dlog('evaluateAndMount: state=valid/grace - no overlay', { state: gate.state });
return;
case 'grandfather':
mountWelcome(gate.grandfather_days_remaining);
return;
case null:
default:
mountLockout('no_credential');
return;
}
}
/**
* Background: validate the cached key. Logs the result for the
* desktop-debug.log file. Does not gate the editor - the shell
* already did that at startup. This is mostly an observability hook
* so we can debug a "valid in shell, invalid in API" mismatch.
*/
async function validateInBackground(): Promise<void> {
if (!isTauri()) return;
try {
const key = await invoke<string | null>('license_get_key');
if (!key) {
dlog('validateInBackground: no key (grandfather or anonymous mode)');
return;
}
const result = await invoke<ValidationResult>('license_validate', { key });
dlog('validateInBackground: validated', {
valid: result.valid,
plan: result.plan,
reason_code: result.reason_code,
});
} catch (err) {
dlog('validateInBackground: failed', { err: String(err) });
}
}
/**
* Install the `velxio://license-required` listener. The shell fires
* this in two cases:
*
* - At startup, when `runtime_state_for_sidecar()` returns None
* (no key + grandfather expired, or tampered token). Sidecar
* wasn't spawned; we mount the lockout.
* - Mid-session, when the sidecar exits with code 78 (the gate
* refused mid-run). Same treatment.
*
* Payload is either the string "locked" (startup case) or a number
* (the sidecar's exit code, currently always 78). Either way we
* mount the no_credential lockout - the user resolves it with
* sign-in or paste-key, then restartApp() reboots the shell.
*/
function installLicenseRequiredListener(): void {
void listen('velxio://license-required', (event) => {
dlog('license-required event', { payload: event.payload });
mountLockout('no_credential');
});
}
/**
* Foreground polling for gate transitions the shell can't push us.
*
* The shell's checkin_loop emits `velxio://license-status` every 6h
* after a network refresh - but the JWT exp is a fixed timestamp, so
* the soft -> hard -> locked transitions happen purely on wall-clock
* elapse. The shell doesn't notice them until the next checkin.
*
* We poll `license_gate_info` every 10 min while the document is
* visible. When the state slips from valid/grace into a locked state
* (gate.state === null), we mount the lockout overlay AND emit a
* console log for the debug file. Same trigger as the startup
* evaluation - the user can sign in / paste-key from the same UI.
*
* Polling is suspended while the document is hidden (minimised,
* background tab in dev) so a backgrounded laptop doesn't burn
* cycles on a fixed-output computation.
*/
let lastGateState: GateInfo['state'] = 'valid';
function installGatePoller(): void {
const POLL_MS = 10 * 60 * 1000;
const tick = async () => {
if (document.visibilityState !== 'visible') return;
try {
const gate = await getGateInfo();
// Only react on a TRANSITION INTO a locked state - flicking
// the overlay on every poll would re-mount during the user
// typing in the paste field. The lockout-overlay sticks
// around until restartApp() resolves it.
if (gate.state === null && lastGateState !== null) {
dlog('gate transitioned to locked while running', { from: lastGateState });
mountLockout('expired');
}
lastGateState = gate.state;
} catch (err) {
dlog('gate poller: getGateInfo failed', { err: String(err) });
}
};
window.setInterval(tick, POLL_MS);
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') void tick();
});
}
export const mountDesktop = (): void => {
if (mounted) return;
mounted = true;
dlog('mountDesktop - Tauri shell active');
// Native menubar (Velxio / File / Edit / View / Help) sends events
// here. Hook the listener before any UI is mounted so the first
// user click is never dropped.
void installDesktopMenuListener();
mountSidePanels();
installLicenseRequiredListener();
installGatePoller();
// First-paint gate evaluation. If the shell is pre-0.3.0 the
// command isn't registered and getGateInfo() returns the legacy
// "always valid" stub so the editor opens normally.
void (async () => {
const gate = await getGateInfo();
dlog('initial gate', gate);
lastGateState = gate.state;
await evaluateAndMount(gate);
void validateInBackground();
})();
};