292 lines
9.4 KiB
TypeScript
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>
|
|
);
|
|
};
|