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 + <tool>.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) <noreply@anthropic.com>
This commit is contained in:
Gergő Törcsvári 2026-06-02 13:30:32 +02:00
commit 83462f7090
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
8 changed files with 311 additions and 75 deletions

View file

@ -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/<tool>.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/<tool>.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` + `<tool>.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 `<base>` 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

View file

@ -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/<tool>.html, the
* same file the e2e tests use) in a same-origin iframe, then injects the project
* tree into its MEMFS and drives FileOpen. 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 +
* <tool>.js, the same artifacts the e2e tests use) into the page, then syncs the
* project tree into MEMFS and drives FileOpen. 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<HTMLIFrameElement>(null);
const containerRef = React.useRef<HTMLDivElement>(null);
const startedRef = React.useRef(false);
const [status, setStatus] = React.useState("Loading tool…");
const [logs, setLogs] = React.useState<string[]>([]);
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 (
<div className="relative h-screen w-screen overflow-hidden bg-[#1a1a2e]">
<iframe
ref={iframeRef}
src={src}
title={`${tool} (${slug})`}
onLoad={onLoad}
className="absolute inset-0 h-full w-full border-0"
allow="cross-origin-isolated; fullscreen"
/>
{/*
wx.js addresses the DOM by id: #main-window is its top-level (id=0)
window it owns #canvas (created in boot's preRun) and #window-container
parents every child window. Both ids must exist before the runtime boots,
mirroring the harness HTML (tests/apps/kicad/<tool>.html).
*/}
<div ref={containerRef} id="main-window" className="absolute inset-0 h-full w-full" />
<div id="window-container" />
{status && (
<div className="pointer-events-none absolute left-3 top-3 z-20 rounded bg-black/70 px-3 py-2 font-mono text-xs text-white">

View file

@ -9,9 +9,10 @@ const queryClient = new QueryClient({
});
// NOTE: deliberately NOT wrapped in <React.StrictMode>. StrictMode double-mounts
// components in dev, which tears down and recreates the WasmTool iframe and thus
// instantiates a 175338 MB KiCad wasm twice — enough to OOM the tab. The tool
// view must instantiate exactly once per navigation.
// components in dev, which would re-run WasmTool's boot effect and try to
// instantiate a 175338 MB KiCad wasm twice — enough to OOM the tab (and the
// runtime is process-global anyway; see src/wasm/boot.ts). The tool view must
// instantiate exactly once per navigation.
ReactDOM.createRoot(document.getElementById("root")!).render(
<QueryClientProvider client={queryClient}>
<BrowserRouter>

View file

@ -0,0 +1,219 @@
import type { Tool } from "@kicad-web/contract";
import {
KICAD_CONFIG_DIR,
RESOURCE_PATH,
TOOL_ARGV0,
TOOL_NEEDS_CONFIG_SEED,
} from "./constants";
/**
* Boot a KiCad tool directly in the main React document no iframe.
*
* This is a faithful port of the proven harness HTML (tests/apps/kicad/<tool>.html):
* it builds the same global `Module` config, runs the same preRun steps (create
* canvas, write images.tar.gz, seed config), then injects `wx.js` followed by
* `<tool>.js`. The KiCad WASM build is NON-modularized, so it reads a global
* `var Module` and publishes `FS`/`wxElementRegistry` onto `window` exactly the
* surface the iframe approach used, only now in the top-level window.
*
* Two browser facts make running in the main document (rather than at /wasm/...)
* work without touching the build:
* - `locateFile` is overridden to resolve `<base>/<file>`, so the .wasm and the
* pthread worker script are fetched from the asset dir regardless of the
* SPA route the user is on.
* - `mainScriptUrlOrBlob` pins the pthread worker to `<base>/<tool>.js`
* (same-origin required: KiCad's pthreads cannot spawn cross-origin).
*
* Single-instance: the build owns process-global state (one `Module`, one wasm
* memory) so only ONE tool can run per page load. A second boot switching
* tools, or a stray double-mount is rejected; switching tools requires a full
* page navigation (which gives a fresh global scope, same as loading a new HTML).
*/
export interface BootOptions {
tool: Tool;
/** Asset base (no trailing slash) where wx.js / <tool>.{js,wasm} / images.tar.gz live. */
base: string;
/** Full-screen element that will host the Emscripten <canvas>. */
container: HTMLElement;
log: (msg: string) => void;
onStatus: (text: string) => void;
}
let booted: { tool: Tool; promise: Promise<void> } | null = null;
/**
* Inject and start the tool's WASM into `window`. Resolves once the glue scripts
* are loaded (runtime init continues asynchronously afterwards callers that
* need the filesystem should wait on `window.FS`, as driveProjectIntoTool does).
*/
export function bootKicadTool(opts: BootOptions): Promise<void> {
if (booted) {
if (booted.tool === opts.tool) return booted.promise;
return Promise.reject(
new Error(
`KiCad "${booted.tool}" is already running in this page; its WASM runtime ` +
`is process-global and cannot be torn down. Reload the page to open ` +
`"${opts.tool}".`,
),
);
}
const promise = doBoot(opts);
booted = { tool: opts.tool, promise };
return promise;
}
function loadScript(src: string): Promise<void> {
return new Promise((resolve, reject) => {
const s = document.createElement("script");
s.src = src;
// currentScript.src (absolute) is what Emscripten captures as `_scriptName`
// and uses to derive the script dir + the pthread worker URL.
s.onload = () => resolve();
s.onerror = () => reject(new Error(`failed to load script: ${src}`));
document.body.appendChild(s);
});
}
async function doBoot(opts: BootOptions): Promise<void> {
const { tool, base, container, log, onStatus } = opts;
const w = window as ToolWindow;
// The wasm reads the top-level frame geometry from a GLOBAL `mainWindow`
// (mainWindow.offsetWidth/offsetHeight/offsetTop — see <tool>.js). The harness
// HTML defines it as `var mainWindow = document.getElementById('main-window')`;
// we must do the same or the wasm falls back to a hardcoded 1280x720 frame that
// mismatches the viewport, breaking the whole AUI layout (toolbars/panels).
(w as unknown as { mainWindow: HTMLElement }).mainWindow = container;
onStatus("Downloading…");
// Prefetch images.tar.gz in parallel with the (much larger) wasm download —
// exactly as the harness does. writeResources (in preRun) writes it once ready.
let resourceData: Uint8Array | null = null;
void fetch(`${base}/images.tar.gz`)
.then((r) => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.arrayBuffer();
})
.then((buf) => {
resourceData = new Uint8Array(buf);
log(`[boot] prefetched images.tar.gz (${resourceData.length} bytes)`);
})
.catch((err) => log(`[boot] images.tar.gz prefetch failed: ${String(err)}`));
// Use the GLOBAL `FS` (window.FS), exactly as the harness HTML does. This build
// does NOT export `Module.FS` (touching it aborts: "'FS' was not exported"), but
// the non-modularized glue declares `var FS` at global scope, so window.FS is
// live from script-eval time — before preRun runs.
const moduleFS = (): EmscriptenFS => {
const FS = w.FS;
if (!FS) throw new Error("global FS not available in preRun");
return FS;
};
// preRun: create the canvas the tool renders into and mount it in our container.
const createCanvas = () => {
const canvas = w.document.createElement("canvas");
canvas.id = "canvas";
canvas.style.display = "none";
// wx.js owns the backing-store size via setWindowRect(); we set only CSS size.
const width = w.innerWidth;
const height = w.innerHeight;
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
canvas.oncontextmenu = (e) => e.preventDefault();
canvas.addEventListener(
"webglcontextlost",
(e) => {
onStatus("WebGL context lost — reload the page.");
e.preventDefault();
},
false,
);
container.appendChild(canvas);
(w.Module as { canvas: HTMLCanvasElement }).canvas = canvas;
log(`[boot] canvas created ${width}x${height}`);
};
// preRun: write the compiled-in KICAD_DATA resources (icons, etc.).
const writeResources = () => {
const FS = moduleFS();
FS.mkdirTree(RESOURCE_PATH);
if (resourceData) {
FS.writeFile(`${RESOURCE_PATH}/images.tar.gz`, resourceData);
log(`[boot] wrote images.tar.gz to ${RESOURCE_PATH}`);
} else {
log("[boot] images.tar.gz not ready at preRun (wasm beat the fetch)");
}
};
// preRun (seeding tools only): suppress the first-run setup wizard, whose modal
// loop crashes Asyncify in our ephemeral MEMFS. Make all settings providers
// report NeedsUserInput()==false — the wizard's "use defaults" path.
const seedKicadConfig = () => {
const FS = moduleFS();
FS.mkdirTree(KICAD_CONFIG_DIR);
const writeIfAbsent = (path: string, contents: string) => {
if (FS.analyzePath(path).exists) return;
FS.writeFile(path, contents);
log(`[boot] seeded ${path}`);
};
writeIfAbsent(
`${KICAD_CONFIG_DIR}/kicad_common.json`,
JSON.stringify(
{
do_not_show_again: {
update_check_prompt: true,
data_collection_prompt: true,
},
},
null,
2,
),
);
writeIfAbsent(
`${KICAD_CONFIG_DIR}/sym-lib-table`,
"(sym_lib_table\n (version 7)\n)\n",
);
writeIfAbsent(
`${KICAD_CONFIG_DIR}/fp-lib-table`,
"(fp_lib_table\n (version 7)\n)\n",
);
writeIfAbsent(
`${KICAD_CONFIG_DIR}/design-block-lib-table`,
"(design_block_lib_table\n (version 7)\n)\n",
);
};
const preRun = [createCanvas, writeResources];
if (TOOL_NEEDS_CONFIG_SEED[tool]) preRun.push(seedKicadConfig);
w.Module = {
thisProgram: TOOL_ARGV0[tool], // argv[0] for KiCad's DEBUG check
preRun,
postRun: [],
print: (...args: unknown[]) => log(`[out] ${args.join(" ")}`),
printErr: (...args: unknown[]) => log(`[err] ${args.join(" ")}`),
setStatus: (text: string) => {
if (text) onStatus(text);
},
monitorRunDependencies: () => {},
onRuntimeInitialized: () => {
log("[boot] runtime initialized");
const canvas = (w.Module as { canvas?: HTMLCanvasElement }).canvas;
if (canvas) canvas.style.display = "block";
onStatus("");
},
// Resolve wasm + pthread worker against the asset base, not the SPA route.
locateFile: (path: string) => `${base}/${path}`,
// Pin the pthread worker script (must be same-origin).
mainScriptUrlOrBlob: `${base}/${tool}.js`,
};
// wx.js MUST load first: it defines globals the wasm imports (getConfigEntryLength,
// …) and the wxElementRegistry the open-flow drives. Then the tool glue, whose
// execution captures currentScript.src as Emscripten's _scriptName.
await loadScript(`${base}/wx.js`);
await loadScript(`${base}/${tool}.js`);
log(`[boot] injected wx.js + ${tool}.js (base=${base})`);
}

View file

@ -15,13 +15,32 @@ export const MEMFS_PROJECTS_DIR = `/home/kicad/documents/kicad/${KICAD_VERSION_D
export const RESOURCE_PATH =
"/workspace/build-wasm/sysroot/share/kicad/resources";
/** argv[0] each tool's DEBUG check expects (see tests/apps/kicad/pcbnew.html). */
/**
* argv[0] each tool's DEBUG check expects. These MUST match the values the
* proven harness HTMLs set as `Module.thisProgram` (tests/apps/kicad/<tool>.html)
* notably the calculator binary is `pcb_calculator`, not `calculator`.
*/
export const TOOL_ARGV0: Record<Tool, string> = {
pcbnew: "/usr/bin/pcbnew",
eeschema: "/usr/bin/eeschema",
calculator: "/usr/bin/calculator",
calculator: "/usr/bin/pcb_calculator",
};
/**
* Tools whose standalone entry (single_top.cpp) runs the first-run setup wizard
* on launch. That wizard's modal loop crashes Asyncify in our ephemeral MEMFS,
* so for these we seed a default KiCad config before main() to suppress it.
* Mirrors which harness HTMLs include `seedKicadConfig` in preRun (eeschema only).
*/
export const TOOL_NEEDS_CONFIG_SEED: Record<Tool, boolean> = {
pcbnew: false,
eeschema: true,
calculator: false,
};
/** KiCad user settings dir for this build (PATHS::GetUserSettingsPath()). */
export const KICAD_CONFIG_DIR = `/home/kicad/.config/kicad/kicad/${KICAD_VERSION_DIR}`;
export function memfsProjectDir(slug: string): string {
return `${MEMFS_PROJECTS_DIR}/${slug}`;
}

View file

@ -46,8 +46,9 @@ declare global {
wxElementRegistry?: WxElementRegistry;
}
// A real browsing-context window (iframe.contentWindow): the Window interface
// PLUS the global declarations (console, PointerEvent, document, …) that live
// on `typeof globalThis`, not on the bare Window interface.
// The browsing-context window the tool runs in — now the top-level `window`
// (the WASM boots in-document, not in an iframe). The Window interface PLUS the
// global declarations (console, PointerEvent, document, …) that live on
// `typeof globalThis`, not on the bare Window interface.
type ToolWindow = Window & typeof globalThis;
}

View file

@ -29,32 +29,13 @@ async function waitFor<T>(
}
}
/**
* Forward the iframe's console (where the harness routes Module.print/printErr
* and KiCad logs) into our on-page log panel, preserving the original output.
*/
export function hookIframeConsole(win: ToolWindow, log: (msg: string) => void): void {
const wrap = (level: "log" | "info" | "warn" | "error") => {
const orig = win.console[level].bind(win.console);
win.console[level] = (...args: unknown[]) => {
try {
log(args.map((a) => (typeof a === "string" ? a : String(a))).join(" "));
} catch {
/* ignore logging errors */
}
orig(...args);
};
};
(["log", "info", "warn", "error"] as const).forEach(wrap);
}
function getFS(win: ToolWindow): EmscriptenFS {
const fs = win.FS ?? win.Module?.FS;
if (!fs) throw new Error("Emscripten FS not available in iframe");
if (!fs) throw new Error("Emscripten FS not available");
return fs as EmscriptenFS;
}
/** Mirror the whole project tree into the iframe's MEMFS (sync-whole-tree). */
/** Mirror the whole project tree into the tool's MEMFS (sync-whole-tree). */
async function syncProjectToMemfs(win: ToolWindow, opts: DriveOptions): Promise<void> {
const fs = getFS(win);
fs.mkdirTree(memfsProjectDir(opts.slug));
@ -69,9 +50,9 @@ async function syncProjectToMemfs(win: ToolWindow, opts: DriveOptions): Promise<
}
/**
* Drive a project into an already-booting tool harness (loaded in a same-origin
* iframe at /wasm/<tool>.html). Waits for the Emscripten FS, syncs the project
* tree into MEMFS, then auto-opens the target file.
* Drive a project into an already-booting tool runtime (booted into `win` by
* bootKicadTool the top-level window). Waits for the Emscripten FS, syncs the
* project tree into MEMFS, then auto-opens the target file.
*/
export async function driveProjectIntoTool(
win: ToolWindow,

View file

@ -1,6 +1,6 @@
/**
* Drive the tool (running in a same-origin iframe `win`) to open a file already
* written into its MEMFS.
* Drive the tool (running in `win` the top-level window) to open a file
* already written into its MEMFS.
*
* Two strategies, tried in order:
* 1. Programmatic hook `win.Module.kicadOpenFile(path)` if the build exposes
@ -48,7 +48,7 @@ function canvasOf(win: ToolWindow): HTMLCanvasElement | null {
return (win.Module?.canvas as HTMLCanvasElement) ?? null;
}
/** Dispatch a full pointer+mouse click at page coordinates on the iframe canvas. */
/** Dispatch a full pointer+mouse click at page coordinates on the tool canvas. */
function clickAt(win: ToolWindow, x: number, y: number): void {
const el = canvasOf(win);
if (!el) return;