From 83462f7090f43e419d3de6b0456446938f982e78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20T=C3=B6rcsv=C3=A1ri?= Date: Tue, 2 Jun 2026 13:30:32 +0200 Subject: [PATCH] fix(web): boot KiCad WASM in-document (no iframe) and fix eeschema frame sizing Replace the same-origin iframe in WasmTool with a direct in-document boot (src/wasm/boot.ts): build the global Emscripten Module + preRun steps and inject wx.js + .js into the page, the same artifacts the e2e harness uses. The build is non-modularized (global Module/FS) and pthread-based, so locateFile/mainScriptUrlOrBlob are set so the wasm + worker load regardless of the SPA route, and only one tool runs per page load. Two bugs found during in-browser verification: - This build does not export Module.FS (touching it aborts); use the global window.FS like the harness does. - The wasm reads top-level frame geometry from a global `mainWindow` (offsetWidth/offsetHeight/offsetTop), falling back to a hardcoded 1280x720 when undefined. The harness sets it via `var mainWindow = ...`; we must too, or the frame mismatches the viewport and the whole AUI layout breaks (missing toolbars, transparent/ghosted panels). Expose the #main-window element as window.mainWindow. Verified: eeschema renders the full UI (menus, toolbars, panels, schematic) matching the e2e baseline. pcbnew remains pre-existing-broken at the build level (raw pcbnew.html harness is equally broken: empty registry, dynCall "ii signature" errors), independent of this change. Co-Authored-By: Claude Opus 4.8 (1M context) --- web/README.md | 13 +- web/apps/frontend/src/components/WasmTool.tsx | 82 ++++--- web/apps/frontend/src/main.tsx | 7 +- web/apps/frontend/src/wasm/boot.ts | 219 ++++++++++++++++++ web/apps/frontend/src/wasm/constants.ts | 23 +- web/apps/frontend/src/wasm/global.d.ts | 7 +- web/apps/frontend/src/wasm/kicad-runner.ts | 29 +-- web/apps/frontend/src/wasm/open-flow.ts | 6 +- 8 files changed, 311 insertions(+), 75 deletions(-) create mode 100644 web/apps/frontend/src/wasm/boot.ts diff --git a/web/README.md b/web/README.md index 1a2f7e7..43d7afd 100644 --- a/web/README.md +++ b/web/README.md @@ -66,10 +66,15 @@ them at `/wasm` (same origin). `VITE_WASM_ASSET_BASE_URL` defaults to `/wasm`. - If the tool won't load, the target dir is probably empty — run `tests/scripts/setup-kicad-wasm.sh` to populate `tests/apps/kicad/`. -The tool view (`WasmTool.tsx`) loads the **actual harness** (`/wasm/.html`, -the same page the e2e tests use) in a same-origin iframe, then injects the -project tree into its MEMFS and drives File→Open — reusing the proven loader -rather than re-implementing the Emscripten bootstrap. +The tool view (`WasmTool.tsx` + `src/wasm/boot.ts`) boots the tool **directly in +the React document** — no iframe. It replicates the proven harness HTML +(`tests/apps/kicad/.html`): builds the same global Emscripten `Module` +config and preRun steps (create canvas, write `images.tar.gz`, seed config), then +injects the same `wx.js` + `.js` artifacts into the page. It then syncs the +project tree into MEMFS and drives File→Open. The build is non-modularized +(global `Module`/`FS`) and pthread-based, so only **one** tool runs per page load; +switching tools requires a full navigation. `locateFile` resolves the wasm and +the pthread worker against `` so they load regardless of the SPA route. **prod**: point `VITE_WASM_ASSET_BASE_URL` at a CDN URL — but that origin must itself satisfy the same-origin / COEP constraints (e.g. served under the app's diff --git a/web/apps/frontend/src/components/WasmTool.tsx b/web/apps/frontend/src/components/WasmTool.tsx index 8609d89..e2fd423 100644 --- a/web/apps/frontend/src/components/WasmTool.tsx +++ b/web/apps/frontend/src/components/WasmTool.tsx @@ -3,13 +3,15 @@ import type { ProjectFile, Tool } from "@kicad-web/contract"; import { ChevronDown, ChevronUp } from "lucide-react"; import { fetchFileBytes } from "@/lib/api"; import { WASM_ASSET_BASE_URL } from "@/lib/config"; -import { driveProjectIntoTool, hookIframeConsole } from "@/wasm/kicad-runner"; +import { bootKicadTool } from "@/wasm/boot"; +import { driveProjectIntoTool } from "@/wasm/kicad-runner"; /** - * Boots a KiCad tool by loading the proven harness HTML (/wasm/.html, the - * same file the e2e tests use) in a same-origin iframe, then injects the project - * tree into its MEMFS and drives File→Open. Same-origin is required: KiCad WASM - * refuses to load its glue/wasm from a different origin under COEP. + * Boots a KiCad tool directly in this React document (no iframe): builds the + * Emscripten `Module` config, injects the proven harness scripts (wx.js + + * .js, the same artifacts the e2e tests use) into the page, then syncs the + * project tree into MEMFS and drives File→Open. See src/wasm/boot.ts for why the + * runtime is single-instance per page load. */ export function WasmTool({ tool, @@ -22,54 +24,62 @@ export function WasmTool({ files: ProjectFile[]; targetPath?: string; }) { - const iframeRef = React.useRef(null); + const containerRef = React.useRef(null); const startedRef = React.useRef(false); const [status, setStatus] = React.useState("Loading tool…"); const [logs, setLogs] = React.useState([]); const [showLog, setShowLog] = React.useState(false); const base = WASM_ASSET_BASE_URL.replace(/\/$/, ""); - const src = `${base}/${tool}.html`; - const onLoad = () => { + React.useEffect(() => { + // Guard re-entry: the WASM runtime is process-global and must boot exactly + // once (see boot.ts). StrictMode is disabled app-wide for the same reason. if (startedRef.current) return; startedRef.current = true; - const win = iframeRef.current?.contentWindow as - | ToolWindow - | null - | undefined; - if (!win) { - setStatus("Error: iframe has no window"); + + const container = containerRef.current; + if (!container) { + setStatus("Error: tool container not mounted"); return; } + const append = (msg: string) => setLogs((prev) => [...prev.slice(-800), msg]); - hookIframeConsole(win, append); + const win = window as ToolWindow; - void driveProjectIntoTool(win, { - tool, - slug, - files, - targetPath, - fetchBytes: (relPath) => fetchFileBytes(slug, relPath), - log: append, - onStatus: setStatus, - }).catch((err) => { - append(`[fatal] ${String(err)}`); - setStatus(`Error: ${String(err)}`); - }); - }; + void (async () => { + try { + await bootKicadTool({ tool, base, container, log: append, onStatus: setStatus }); + await driveProjectIntoTool(win, { + tool, + slug, + files, + targetPath, + fetchBytes: (relPath) => fetchFileBytes(slug, relPath), + log: append, + onStatus: setStatus, + }); + } catch (err) { + append(`[fatal] ${String(err)}`); + setStatus(`Error: ${String(err)}`); + } + })(); + // Boot is one-shot per mount; deps intentionally exclude files/targetPath so + // they don't retrigger a (rejected) second boot. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [tool, slug, base]); return (
-