feat(standalone): device-capability preflight check (0001)
Add a pre-boot "potato check" for the standalone editor: probe the device against what the KiCad WASM tools actually require and warn (or block, with an override) before the expensive WASM asset fetch. - preflight/capabilities.ts: pure, unit-testable probe → CapabilityReport with stable codes. Fatal: no SharedArrayBuffer/cross-origin-isolation, no WebAssembly, no Workers+Atomics, no WebGL2. Warn: low deviceMemory, low JS heap limit (Chromium), few cores, mobile, small screen. Thresholds in one tunable const block. - preflight/BlockingDialog.tsx: reusable modal blocking overlay (no dismiss except via explicit action) — also for feature 0002's terminal dialog. - preflight/PreflightGate.tsx: runs the probe once on mount; fatal → blocking dialog with "Try anyway" (short-circuits the asset fetch); warnings → dismissible advisory banner with localStorage "don't show again" keyed to the exact warning codes. - ToolPage: wrap WasmTool in PreflightGate so the gate runs before boot. Optional micro-bench (phase 3) deferred per the spec. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
8bf3bd372f
commit
ae20d49241
4 changed files with 462 additions and 8 deletions
|
|
@ -2,6 +2,7 @@ import { useParams } from "react-router-dom";
|
|||
import { toolSchema } from "@pcbjam/shared";
|
||||
import { fetchFileBytes, useProject } from "@/lib/api";
|
||||
import { WasmTool } from "@/components/WasmTool";
|
||||
import { PreflightGate } from "@/preflight/PreflightGate";
|
||||
|
||||
export function ToolPage() {
|
||||
const params = useParams();
|
||||
|
|
@ -30,14 +31,18 @@ export function ToolPage() {
|
|||
);
|
||||
}
|
||||
|
||||
// PreflightGate runs the device-capability check; on a fatal mismatch it blocks
|
||||
// here (before WasmTool mounts) so the expensive WASM asset fetch is skipped.
|
||||
return (
|
||||
<WasmTool
|
||||
tool={parsedTool.data}
|
||||
slug={slug}
|
||||
projectId={data.project.id}
|
||||
files={data.files}
|
||||
targetPath={targetPath}
|
||||
fetchBytes={(relPath) => fetchFileBytes(slug, relPath)}
|
||||
/>
|
||||
<PreflightGate>
|
||||
<WasmTool
|
||||
tool={parsedTool.data}
|
||||
slug={slug}
|
||||
projectId={data.project.id}
|
||||
files={data.files}
|
||||
targetPath={targetPath}
|
||||
fetchBytes={(relPath) => fetchFileBytes(slug, relPath)}
|
||||
/>
|
||||
</PreflightGate>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
77
web/standalone/src/preflight/BlockingDialog.tsx
Normal file
77
web/standalone/src/preflight/BlockingDialog.tsx
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import * as React from "react";
|
||||
import { Button, type ButtonProps } from "@/components/ui/button";
|
||||
|
||||
/**
|
||||
* A self-contained, modal blocking overlay — intentionally NOT the radix
|
||||
* `Dialog` (which ships a close "X" and closes on outside-click). Preflight's
|
||||
* fatal case and feature 0002's terminal "out of memory" dialog both need a
|
||||
* modal the user cannot dismiss except via an explicit action button, so this
|
||||
* presentational component owns the whole overlay.
|
||||
*/
|
||||
export interface BlockingReason {
|
||||
title: string;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export interface BlockingAction {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
variant?: ButtonProps["variant"];
|
||||
}
|
||||
|
||||
export function BlockingDialog({
|
||||
title,
|
||||
description,
|
||||
reasons = [],
|
||||
primary,
|
||||
secondary,
|
||||
}: {
|
||||
title: string;
|
||||
description?: string;
|
||||
reasons?: BlockingReason[];
|
||||
/** Right-most button (e.g. the "Try anyway" escape hatch). */
|
||||
primary?: BlockingAction;
|
||||
/** Left-of-primary button. */
|
||||
secondary?: BlockingAction;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4"
|
||||
>
|
||||
<div className="w-full max-w-lg rounded-lg border bg-background p-6 shadow-lg">
|
||||
<h2 className="text-lg font-semibold leading-none tracking-tight">{title}</h2>
|
||||
{description && (
|
||||
<p className="mt-2 text-sm text-muted-foreground">{description}</p>
|
||||
)}
|
||||
|
||||
{reasons.length > 0 && (
|
||||
<ul className="mt-4 space-y-2 text-sm">
|
||||
{reasons.map((r) => (
|
||||
<li key={r.title} className="rounded-md border border-border/60 p-3">
|
||||
<div className="font-medium">{r.title}</div>
|
||||
<div className="text-muted-foreground">{r.detail}</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{(primary || secondary) && (
|
||||
<div className="mt-6 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||
{secondary && (
|
||||
<Button variant={secondary.variant ?? "outline"} onClick={secondary.onClick}>
|
||||
{secondary.label}
|
||||
</Button>
|
||||
)}
|
||||
{primary && (
|
||||
<Button variant={primary.variant ?? "default"} onClick={primary.onClick}>
|
||||
{primary.label}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
122
web/standalone/src/preflight/PreflightGate.tsx
Normal file
122
web/standalone/src/preflight/PreflightGate.tsx
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import * as React from "react";
|
||||
import { X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { BlockingDialog } from "./BlockingDialog";
|
||||
import { probeCapabilities, type CapabilityReport } from "./capabilities";
|
||||
|
||||
/**
|
||||
* Wraps the tool boot with a device-capability check (feature 0001). On mount it
|
||||
* runs `probeCapabilities()` exactly once and decides:
|
||||
*
|
||||
* - no issues → render `children` immediately (boot proceeds).
|
||||
* - warnings → render `children` + a dismissible advisory banner overlay.
|
||||
* - any fatal → render a blocking dialog INSTEAD of `children` (so the
|
||||
* expensive WASM asset fetch is short-circuited), with a
|
||||
* "Try anyway" override that then renders `children`.
|
||||
*
|
||||
* The override decision (user): warn but allow proceeding in every case.
|
||||
*/
|
||||
|
||||
const DISMISS_PREFIX = "pcbjam:preflight:dismissed:";
|
||||
|
||||
/** Dismissal is keyed to the exact set of warning codes seen, so a new kind of
|
||||
* warning still shows even after the user dismissed a previous one. */
|
||||
function dismissKey(report: CapabilityReport): string {
|
||||
const codes = report.warnings.map((w) => w.code).sort();
|
||||
return DISMISS_PREFIX + codes.join(",");
|
||||
}
|
||||
|
||||
function readDismissed(key: string): boolean {
|
||||
try {
|
||||
return localStorage.getItem(key) === "1";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function PreflightGate({ children }: { children: React.ReactNode }) {
|
||||
// Probe once; capabilities don't change within a page load.
|
||||
const [report] = React.useState<CapabilityReport>(() => probeCapabilities());
|
||||
const [override, setOverride] = React.useState(false);
|
||||
const key = dismissKey(report);
|
||||
const [bannerHidden, setBannerHidden] = React.useState(() => readDismissed(key));
|
||||
|
||||
const hasFatal = report.fatal.length > 0;
|
||||
const hasWarnings = report.warnings.length > 0;
|
||||
|
||||
// Fatal + not yet overridden: block before children mount (no asset fetch).
|
||||
if (hasFatal && !override) {
|
||||
return (
|
||||
<BlockingDialog
|
||||
title="This device can't run the editor"
|
||||
description="We detected one or more requirements that aren't met. You can try anyway, but the editor will most likely fail to start."
|
||||
reasons={report.fatal.map((f) => ({ title: f.title, detail: f.detail }))}
|
||||
primary={{ label: "Try anyway", variant: "destructive", onClick: () => setOverride(true) }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const showBanner = hasWarnings && !bannerHidden;
|
||||
|
||||
return (
|
||||
<>
|
||||
{showBanner && (
|
||||
<PreflightBanner
|
||||
report={report}
|
||||
onDismiss={() => setBannerHidden(true)}
|
||||
onDontShowAgain={() => {
|
||||
try {
|
||||
localStorage.setItem(key, "1");
|
||||
} catch {
|
||||
/* private mode / storage disabled — dismiss for this session only */
|
||||
}
|
||||
setBannerHidden(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PreflightBanner({
|
||||
report,
|
||||
onDismiss,
|
||||
onDontShowAgain,
|
||||
}: {
|
||||
report: CapabilityReport;
|
||||
onDismiss: () => void;
|
||||
onDontShowAgain: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="pointer-events-auto fixed inset-x-0 top-0 z-30 border-b border-amber-500/40 bg-amber-950/90 px-4 py-3 text-amber-100 shadow-lg backdrop-blur">
|
||||
<div className="mx-auto flex max-w-3xl items-start gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-semibold">
|
||||
This device may struggle to run the editor
|
||||
</div>
|
||||
<ul className="mt-1 list-disc space-y-0.5 pl-5 text-xs text-amber-200/90">
|
||||
{report.warnings.map((w) => (
|
||||
<li key={w.code}>
|
||||
<span className="font-medium">{w.title}:</span> {w.detail}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="mt-2 flex gap-2">
|
||||
<Button size="sm" variant="secondary" onClick={onDontShowAgain}>
|
||||
Don't show again
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Dismiss"
|
||||
onClick={onDismiss}
|
||||
className="rounded-sm opacity-70 transition-opacity hover:opacity-100"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
250
web/standalone/src/preflight/capabilities.ts
Normal file
250
web/standalone/src/preflight/capabilities.ts
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
/**
|
||||
* Device-capability preflight ("potato check") — feature 0001.
|
||||
*
|
||||
* A pure, unit-testable probe of the browser/device against what the KiCad WASM
|
||||
* editor actually requires. It does NOT block anything by itself; it returns a
|
||||
* report that `PreflightGate` turns into a blocking dialog (fatal) or an advisory
|
||||
* banner (warnings). The four `fatal` checks are the genuinely-cannot-run ones
|
||||
* (no SharedArrayBuffer / WASM / threads / WebGL2 — all load-bearing facts in
|
||||
* boot.ts and vite.config.ts); everything else is a soft `warn`.
|
||||
*
|
||||
* Every issue carries a stable `code` so warnings can be correlated with real OOM
|
||||
* reports later (feature 0003) and so a "don't show again" choice can be keyed to
|
||||
* the exact set of issues seen.
|
||||
*/
|
||||
|
||||
export interface CapabilityIssue {
|
||||
/** Stable identifier for analytics + dismissal keys (e.g. "no-sab"). */
|
||||
code: string;
|
||||
title: string;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export interface CapabilityReport {
|
||||
/** Cannot run at all (blocking dialog, with a "Try anyway" override). */
|
||||
fatal: CapabilityIssue[];
|
||||
/** May run but risky (dismissible advisory banner). */
|
||||
warnings: CapabilityIssue[];
|
||||
/** Raw measured values, for warning copy + telemetry/debug. */
|
||||
info: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tunable thresholds in one place so they can be adjusted without touching the
|
||||
* probe logic. `deviceMemory` is capped at 8 and coarse by spec — treat as a hint.
|
||||
*/
|
||||
export const THRESHOLDS = {
|
||||
/** GB; `navigator.deviceMemory` below this warns. */
|
||||
minDeviceMemoryGb: 4,
|
||||
/** Bytes; Chromium `performance.memory.jsHeapSizeLimit` below this warns. */
|
||||
minHeapLimitBytes: 2 * 1024 ** 3,
|
||||
/** `navigator.hardwareConcurrency` below this warns. */
|
||||
minCores: 4,
|
||||
/** Device pixels (`screen.width * devicePixelRatio`) below this warns. */
|
||||
minScreenWidthPx: 1024,
|
||||
} as const;
|
||||
|
||||
// `navigator`/`performance` carry non-standard, browser-specific fields we read
|
||||
// only when present. Narrow casts keep the probe honest without `any`.
|
||||
interface DeviceNavigator {
|
||||
deviceMemory?: number;
|
||||
hardwareConcurrency?: number;
|
||||
userAgent?: string;
|
||||
userAgentData?: { mobile?: boolean };
|
||||
}
|
||||
interface MemoryPerformance {
|
||||
memory?: { jsHeapSizeLimit?: number };
|
||||
}
|
||||
|
||||
function getNavigator(): DeviceNavigator | undefined {
|
||||
return typeof navigator === "undefined"
|
||||
? undefined
|
||||
: (navigator as unknown as DeviceNavigator);
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe WebGL2 once: returns whether a context can be created and, if so, the
|
||||
* unmasked renderer string (handy for the warning copy + OOM correlation).
|
||||
* Returns `supported: null` when no canvas API is available to test with (e.g.
|
||||
* a non-DOM test env) — the caller must NOT treat "couldn't test" as fatal.
|
||||
*/
|
||||
function probeWebgl2(): { supported: boolean | null; renderer?: string } {
|
||||
let canvas: HTMLCanvasElement | OffscreenCanvas | undefined;
|
||||
try {
|
||||
if (typeof OffscreenCanvas !== "undefined") {
|
||||
canvas = new OffscreenCanvas(1, 1);
|
||||
} else if (typeof document !== "undefined") {
|
||||
canvas = document.createElement("canvas");
|
||||
}
|
||||
} catch {
|
||||
/* fall through to "untestable" */
|
||||
}
|
||||
if (!canvas) return { supported: null };
|
||||
|
||||
let gl: WebGL2RenderingContext | null = null;
|
||||
try {
|
||||
gl = canvas.getContext("webgl2") as WebGL2RenderingContext | null;
|
||||
} catch {
|
||||
return { supported: false };
|
||||
}
|
||||
if (!gl) return { supported: false };
|
||||
|
||||
let renderer: string | undefined;
|
||||
try {
|
||||
const ext = gl.getExtension("WEBGL_debug_renderer_info");
|
||||
if (ext) {
|
||||
renderer = gl.getParameter(
|
||||
(ext as { UNMASKED_RENDERER_WEBGL: number }).UNMASKED_RENDERER_WEBGL,
|
||||
) as string;
|
||||
}
|
||||
} catch {
|
||||
/* renderer is best-effort */
|
||||
}
|
||||
return { supported: true, renderer };
|
||||
}
|
||||
|
||||
/** Inspect the current environment; never throws. */
|
||||
export function probeCapabilities(): CapabilityReport {
|
||||
const fatal: CapabilityIssue[] = [];
|
||||
const warnings: CapabilityIssue[] = [];
|
||||
const info: Record<string, unknown> = {};
|
||||
|
||||
const nav = getNavigator();
|
||||
info.userAgent = nav?.userAgent;
|
||||
|
||||
// --- fatal: SharedArrayBuffer + cross-origin isolation ---------------------
|
||||
// No SAB ⇒ KiCad's pthreads cannot exist (vite sets COOP/COEP precisely to
|
||||
// enable it). `crossOriginIsolated` is the reliable truth; prefer it over UA.
|
||||
const hasSAB = typeof SharedArrayBuffer !== "undefined";
|
||||
const isolated =
|
||||
typeof crossOriginIsolated === "boolean" ? crossOriginIsolated : undefined;
|
||||
info.sharedArrayBuffer = hasSAB;
|
||||
info.crossOriginIsolated = isolated;
|
||||
if (!hasSAB || isolated === false) {
|
||||
fatal.push({
|
||||
code: "no-sab",
|
||||
title: "Shared memory is unavailable",
|
||||
detail:
|
||||
"The editor needs SharedArrayBuffer with cross-origin isolation. " +
|
||||
"This usually means a desktop Chrome or Edge browser is required; " +
|
||||
"Safari and iOS lack the threading support KiCad relies on.",
|
||||
});
|
||||
}
|
||||
|
||||
// --- fatal: WebAssembly ----------------------------------------------------
|
||||
const hasWasm = typeof WebAssembly === "object";
|
||||
info.webAssembly = hasWasm;
|
||||
if (!hasWasm) {
|
||||
fatal.push({
|
||||
code: "no-wasm",
|
||||
title: "WebAssembly is not supported",
|
||||
detail: "This browser cannot run WebAssembly, which the editor is built on.",
|
||||
});
|
||||
}
|
||||
|
||||
// --- fatal: threads (Workers + Atomics) ------------------------------------
|
||||
// Only meaningful when SAB exists; otherwise no-sab already explains it.
|
||||
const hasThreads =
|
||||
typeof Worker !== "undefined" && typeof Atomics !== "undefined";
|
||||
info.threads = hasThreads;
|
||||
if (hasSAB && !hasThreads) {
|
||||
fatal.push({
|
||||
code: "no-threads",
|
||||
title: "Threads are unavailable",
|
||||
detail:
|
||||
"Web Workers and Atomics are required for KiCad's threaded runtime.",
|
||||
});
|
||||
}
|
||||
|
||||
// --- fatal: WebGL2 ---------------------------------------------------------
|
||||
const gl = probeWebgl2();
|
||||
info.webgl2 = gl.supported;
|
||||
info.glRenderer = gl.renderer;
|
||||
if (gl.supported === false) {
|
||||
fatal.push({
|
||||
code: "no-webgl2",
|
||||
title: "WebGL2 is unavailable",
|
||||
detail:
|
||||
"The editor renders through WebGL2. Enable hardware acceleration, " +
|
||||
"or try a different browser.",
|
||||
});
|
||||
}
|
||||
|
||||
// --- warn: low device memory ----------------------------------------------
|
||||
const deviceMemory = nav?.deviceMemory;
|
||||
info.deviceMemory = deviceMemory;
|
||||
if (typeof deviceMemory === "number" && deviceMemory < THRESHOLDS.minDeviceMemoryGb) {
|
||||
warnings.push({
|
||||
code: "low-memory",
|
||||
title: "Limited memory",
|
||||
detail:
|
||||
`This device reports about ${deviceMemory} GB of RAM. The editor is ` +
|
||||
"memory-hungry and may run out of memory on large boards.",
|
||||
});
|
||||
}
|
||||
|
||||
// --- warn: low JS heap limit (Chromium only) -------------------------------
|
||||
const perf =
|
||||
typeof performance === "undefined"
|
||||
? undefined
|
||||
: (performance as unknown as MemoryPerformance);
|
||||
const heapLimit = perf?.memory?.jsHeapSizeLimit;
|
||||
info.jsHeapSizeLimit = heapLimit;
|
||||
if (typeof heapLimit === "number" && heapLimit < THRESHOLDS.minHeapLimitBytes) {
|
||||
warnings.push({
|
||||
code: "low-heap",
|
||||
title: "Small memory budget",
|
||||
detail:
|
||||
"The browser's JavaScript heap limit is low, so large designs may run " +
|
||||
"out of memory.",
|
||||
});
|
||||
}
|
||||
|
||||
// --- warn: few cores -------------------------------------------------------
|
||||
const cores = nav?.hardwareConcurrency;
|
||||
info.hardwareConcurrency = cores;
|
||||
if (typeof cores === "number" && cores < THRESHOLDS.minCores) {
|
||||
warnings.push({
|
||||
code: "few-cores",
|
||||
title: "Few CPU cores",
|
||||
detail:
|
||||
`This device reports ${cores} logical core(s); the editor may feel slow.`,
|
||||
});
|
||||
}
|
||||
|
||||
// --- warn: mobile ----------------------------------------------------------
|
||||
const uaMobile = nav?.userAgentData?.mobile;
|
||||
const coarsePointer =
|
||||
typeof matchMedia === "function" &&
|
||||
matchMedia("(pointer: coarse)").matches &&
|
||||
matchMedia("(max-width: 900px)").matches;
|
||||
const isMobile = uaMobile === true || coarsePointer;
|
||||
info.mobile = isMobile;
|
||||
if (isMobile) {
|
||||
warnings.push({
|
||||
code: "mobile",
|
||||
title: "Mobile device detected",
|
||||
detail:
|
||||
"The editor is designed for desktop. A mouse and a larger screen are " +
|
||||
"strongly recommended.",
|
||||
});
|
||||
}
|
||||
|
||||
// --- warn: small screen ----------------------------------------------------
|
||||
const dpr = typeof devicePixelRatio === "number" ? devicePixelRatio : 1;
|
||||
const screenWidthPx =
|
||||
typeof screen === "undefined" ? undefined : screen.width * dpr;
|
||||
info.screenWidthPx = screenWidthPx;
|
||||
if (
|
||||
typeof screenWidthPx === "number" &&
|
||||
screenWidthPx < THRESHOLDS.minScreenWidthPx
|
||||
) {
|
||||
warnings.push({
|
||||
code: "small-screen",
|
||||
title: "Small screen",
|
||||
detail: "The editor's panels and toolbars need more screen space to be usable.",
|
||||
});
|
||||
}
|
||||
|
||||
return { fatal, warnings, info };
|
||||
}
|
||||
Loading…
Reference in a new issue