velxio/frontend/src/desktop/QemuDownloadPrompt.tsx

292 lines
9.4 KiB
TypeScript

/**
* 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 = <T = unknown>(cmd: string, args?: Record<string, unknown>) => Promise<T>;
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<QemuStatus | null>(null);
const [eligibility, setEligibility] = useState<Eligibility | null>(null);
const [open, setOpen] = useState(false);
const [installing, setInstalling] = useState(false);
const [progress, setProgress] = useState<ProgressPayload | null>(null);
const [err, setErr] = useState<string | null>(null);
const [dismissed, setDismissed] = useState(false);
useEffect(() => {
const invoke = tauriInvoke();
if (!invoke) return;
invoke<QemuStatus>(config.statusCmd).then(setStatus).catch(() => undefined);
invoke<Eligibility>(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<ProgressPayload>(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<QemuStatus>(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 (
<div
role="dialog"
aria-modal="true"
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,0.6)',
zIndex: 9500,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<div
style={{
width: 440,
background: '#1e1e23',
color: '#e6e6e9',
border: '1px solid #2c2c33',
borderRadius: 8,
padding: 24,
boxShadow: '0 12px 48px rgba(0,0,0,0.5)',
}}
>
<h2 style={{ margin: '0 0 8px', fontSize: 18 }}>
{isGrandfather
? `Sign up to use ${config.label} boards`
: isLocked
? `Reactivate to use ${config.label} boards`
: `${config.label} support not installed`}
</h2>
<p style={{ margin: '0 0 16px', color: '#aaa', lineHeight: 1.5 }}>
{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.`}
</p>
{err && (
<div
style={{
padding: '8px 12px',
borderRadius: 4,
background: '#3a1a1a',
color: '#ff8585',
fontSize: 13,
marginBottom: 12,
}}
>
{err}
</div>
)}
{installing && progress && (
<div style={{ marginBottom: 16 }}>
<div
style={{
height: 6,
background: '#0c0c11',
borderRadius: 3,
overflow: 'hidden',
marginBottom: 6,
}}
>
<div
style={{
height: '100%',
width: pct >= 0 ? `${pct}%` : '40%',
background: '#007acc',
transition: 'width 0.2s ease',
animation: pct < 0 ? 'vlx-indeterminate 1.5s linear infinite' : undefined,
}}
/>
</div>
<div style={{ fontSize: 12, color: '#888' }}>
{progress.phase === 'extracting'
? 'Extracting...'
: progress.phase === 'installing'
? 'Installing...'
: progress.phase === 'done'
? 'Done'
: pct >= 0
? `${pct}% (${(progress.bytes_downloaded / (1 << 20)).toFixed(1)} MB)`
: 'Downloading...'}
</div>
</div>
)}
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button
type="button"
onClick={onSkip}
disabled={installing}
style={{
padding: '8px 16px',
background: 'transparent',
color: '#aaa',
border: '1px solid #2c2c33',
borderRadius: 4,
cursor: 'pointer',
}}
>
Not now
</button>
{canDownload && (
<button
type="button"
onClick={onInstall}
disabled={installing}
style={{
padding: '8px 16px',
background: '#007acc',
color: 'white',
border: 'none',
borderRadius: 4,
cursor: installing ? 'wait' : 'pointer',
opacity: installing ? 0.7 : 1,
}}
>
{installing ? 'Downloading...' : `Download ${config.label} support`}
</button>
)}
{isGrandfather && (
<button
type="button"
onClick={onSignUp}
style={{
padding: '8px 16px',
background: '#007acc',
color: 'white',
border: 'none',
borderRadius: 4,
cursor: 'pointer',
}}
>
Start free trial
</button>
)}
</div>
</div>
</div>
);
};