/** * Generic QEMU runtime download prompt (desktop / Tauri only). * * Parameterized version of Esp32QemuPrompt — watches the simulator for a * board the given runtime handles, and if the runtime isn't installed shows a * modal that downloads it behind the license (eligible), or routes the user to * sign up (grandfather), or stays dimmed (locked). * * Used to add "Download STM32 support" without duplicating the ESP32 flow. * The ESP32 prompt keeps its own dedicated component for now; both could * later collapse onto this one. */ import { useEffect, useState } from 'react'; import { useSimulatorStore } from '../store/useSimulatorStore'; import type { BoardKind } from '../types/board'; import { beginSignIn, isTauri, listen, openExternal } from './tauriBridge'; export interface QemuRuntimeConfig { /** Display name, e.g. "STM32". */ label: string; /** True for board kinds this runtime serves. */ matchKind: (kind: BoardKind) => boolean; /** Tauri command names (status/eligibility/install). */ statusCmd: string; eligibilityCmd: string; installCmd: string; /** Progress event emitted by the install command. */ progressEvent: string; /** Approx download size note, e.g. "~30 MB". */ sizeNote: string; } type QemuStatus = { installed: boolean; path?: string | null; version?: string | null }; type Eligibility = 'eligible' | 'grandfather' | 'locked'; type ProgressPayload = { bytes_downloaded: number; total_bytes: number | null; phase: 'starting' | 'downloading' | 'installing' | 'extracting' | 'done'; }; type TauriInvoke = (cmd: string, args?: Record) => Promise; function tauriInvoke(): TauriInvoke | null { const w = window as { __TAURI__?: { core?: { invoke?: TauriInvoke }; invoke?: TauriInvoke } }; return w.__TAURI__?.core?.invoke ?? w.__TAURI__?.invoke ?? null; } const VELXIO_BASE = 'https://velxio.dev'; export const QemuDownloadPrompt = ({ config }: { config: QemuRuntimeConfig }) => { const boards = useSimulatorStore((s) => s.boards); const hasBoard = boards.some((b) => config.matchKind(b.boardKind)); const [status, setStatus] = useState(null); const [eligibility, setEligibility] = useState(null); const [open, setOpen] = useState(false); const [installing, setInstalling] = useState(false); const [progress, setProgress] = useState(null); const [err, setErr] = useState(null); const [dismissed, setDismissed] = useState(false); useEffect(() => { const invoke = tauriInvoke(); if (!invoke) return; invoke(config.statusCmd).then(setStatus).catch(() => undefined); invoke(config.eligibilityCmd) .then(setEligibility) .catch(() => setEligibility('eligible')); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useEffect(() => { if (!hasBoard || !status || status.installed || dismissed) return; setOpen(true); }, [hasBoard, status, dismissed]); useEffect(() => { if (!installing) return; let dispose: (() => void) | null = null; listen(config.progressEvent, (event) => { setProgress(event.payload); }).then((off) => { dispose = off; }); return () => { if (dispose) dispose(); }; }, [installing, config.progressEvent]); if (!open) return null; const onInstall = async () => { setErr(null); setInstalling(true); setProgress({ bytes_downloaded: 0, total_bytes: null, phase: 'starting' }); const invoke = tauriInvoke(); if (!invoke) { setErr('Tauri runtime not available.'); setInstalling(false); return; } try { await invoke(config.installCmd); const fresh = await invoke(config.statusCmd); setStatus(fresh); if (fresh.installed) setOpen(false); } catch (e) { const raw = e instanceof Error ? e.message : String(e); if (/HTTP\s*404|not found/i.test(raw)) { setErr( `${config.label} support is not yet available for your platform. ` + 'The Velxio team is preparing this build — try again in a few days, ' + 'or use Arduino / RP2040 boards in the meantime.', ); } else { setErr(raw); } } finally { setInstalling(false); setProgress(null); } }; const onSignUp = async () => { if (!isTauri()) { void openExternal(`${VELXIO_BASE}/auth/desktop`); return; } try { await beginSignIn(VELXIO_BASE); } catch (e) { setErr(e instanceof Error ? e.message : String(e)); } }; const onSkip = () => { setDismissed(true); setOpen(false); }; const isGrandfather = eligibility === 'grandfather'; const isLocked = eligibility === 'locked'; const canDownload = eligibility === 'eligible' || eligibility === null; let pct = -1; if (progress?.total_bytes && progress.total_bytes > 0) { pct = Math.min(100, Math.round((progress.bytes_downloaded / progress.total_bytes) * 100)); } return (

{isGrandfather ? `Sign up to use ${config.label} boards` : isLocked ? `Reactivate to use ${config.label} boards` : `${config.label} support not installed`}

{isGrandfather ? `${config.label} simulation requires a Velxio account. Create one in 30 seconds — 30-day free trial included, no credit card needed.` : isLocked ? `Your subscription is currently locked. Renew to download ${config.label} support.` : `${config.label} boards need an additional QEMU runtime (${config.sizeNote}). One-time download. You can keep using AVR and RP2040 boards without it.`}

{err && (
{err}
)} {installing && progress && (
= 0 ? `${pct}%` : '40%', background: '#007acc', transition: 'width 0.2s ease', animation: pct < 0 ? 'vlx-indeterminate 1.5s linear infinite' : undefined, }} />
{progress.phase === 'extracting' ? 'Extracting...' : progress.phase === 'installing' ? 'Installing...' : progress.phase === 'done' ? 'Done' : pct >= 0 ? `${pct}% (${(progress.bytes_downloaded / (1 << 20)).toFixed(1)} MB)` : 'Downloading...'}
)}
{canDownload && ( )} {isGrandfather && ( )}
); };