refactor(web): split into GPL standalone editor + thin reference backend

Reduce web/ to a single generic editor app plus a minimal example backend,
moving all project-specific/app code out to the closed root repo:

- apps/frontend -> standalone (@pcbjam/standalone): the GPL editor. Strips the
  project-management UI (ProjectsPage/ProjectDetailPage/UploadDropzone and the
  write half of lib/api.ts). WasmTool now takes fetchBytes + assetBaseUrl as
  injected props (decoupled from the API client) so it can be driven by either
  a backend or a local folder. New HomePage (local-folder loader + backend
  project list) and read-only ProjectView. BroadcastChannel collab unchanged.
- backend (@pcbjam/backend-example): thin Fastify+ts-rest reference impl of the
  @pcbjam/shared contract — serves a single project off the local filesystem
  (PROJECT_DIR), no DB/auth/uploads.
- packages/contract -> web/pcbjam-shared (git submodule, MIT @pcbjam/shared).
- Remove apps/server, packages/storage, packages/contract, docker-compose
  (server + storage move to the closed root repo; preserved via subtree
  branches). Rewrite pnpm-workspace/turbo/.env for the frontend-only layout.
- Add tests/fixtures/demo so the editor + example backend run out of the box.

Standalone typechecks + builds; backend serves the contract end-to-end.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gergő Törcsvári 2026-06-09 12:35:46 +02:00
commit b6a77ee93c
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
80 changed files with 671 additions and 2992 deletions

View file

@ -0,0 +1,16 @@
# Standalone editor (Vite). Copy to standalone/.env. All vars must be VITE_-prefixed.
# Backend implementing the @pcbjam/shared contract (the example backend, or any
# conforming backend). Leave it pointing anywhere unreachable to use only the
# local-folder loader on the home page.
VITE_API_BASE_URL=http://localhost:3060
# Where the WASM glue/artifacts are served from. SAME-ORIGIN "/wasm" is required
# (KiCad WASM pthread workers cannot be created cross-origin). On `dev` the
# artifacts are symlinked into public/wasm and served by Vite at /wasm. For prod,
# set an absolute URL whose origin also satisfies the COEP/COOP rules.
VITE_WASM_ASSET_BASE_URL=/wasm
# Override the artifact source dir the dev symlink points at (default:
# <repo>/tests/apps/kicad). Useful when serving prebuilt artifacts from elsewhere.
# WASM_SRC_DIR=

12
web/standalone/index.html Normal file
View file

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>KiCad Web</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View file

@ -0,0 +1,41 @@
{
"name": "@pcbjam/standalone",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "node scripts/link-wasm.mjs && vite",
"link-wasm": "node scripts/link-wasm.mjs",
"build": "tsc --noEmit && vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@pcbjam/shared": "workspace:*",
"@radix-ui/react-dialog": "^1.1.4",
"@radix-ui/react-label": "^2.1.1",
"@radix-ui/react-slot": "^1.1.1",
"@tanstack/react-query": "^5.62.11",
"@ts-rest/core": "^3.52.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.469.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.28.1",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"yjs": "^13.6.18",
"zod": "^3.24.1"
},
"devDependencies": {
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.3",
"vite": "^6.0.7"
}
}

View file

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View file

@ -0,0 +1,105 @@
#!/usr/bin/env node
// Make the WASM runtime artifacts available SAME-ORIGIN to the app without a
// third copy: symlink standalone/public/wasm -> the synced artifact dir
// (tests/apps/kicad, populated by tests/scripts/setup-kicad-wasm.sh). Vite then
// serves them at /wasm from the app's own origin — required because KiCad WASM
// (Asyncify build, COEP/cross-origin-isolated document) refuses to load its
// glue/wasm from a different origin.
//
// No bytes are duplicated; the link points straight at the synced dir.
// Override the target with WASM_SRC_DIR (absolute, or relative to cwd).
import { spawnSync } from "node:child_process";
import * as fs from "node:fs/promises";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const publicDir = path.resolve(scriptDir, "../public");
const linkPath = path.join(publicDir, "wasm");
// scriptDir = web/standalone/scripts → repo root is three levels up.
const repoRoot = path.resolve(scriptDir, "../../..");
const setupScript = path.join(repoRoot, "tests/scripts/setup-kicad-wasm.sh");
const targetAbs = process.env.WASM_SRC_DIR
? path.resolve(process.cwd(), process.env.WASM_SRC_DIR)
: path.join(repoRoot, "tests/apps/kicad");
// Sync freshly-built artifacts from output/ into tests/apps/kicad before
// linking, so `npm run dev` always serves the latest build instead of whatever
// was synced last. Skipped when WASM_SRC_DIR overrides the target: the setup
// script only writes to tests/apps/kicad, so running it would be a no-op there
// (and would mislead by syncing a dir we're not even linking to).
// Non-fatal: a missing/empty build must not block the dev server from starting.
// The setup script exits non-zero when no artifacts exist anywhere; we warn and
// fall through to the symlink, which already degrades gracefully (see below).
function syncArtifacts() {
if (process.env.WASM_SRC_DIR) {
console.log("[link-wasm] WASM_SRC_DIR set — skipping output/ sync");
return;
}
const res = spawnSync("bash", [setupScript], { stdio: "inherit" });
if (res.error) {
console.warn(
`[link-wasm] could not run setup-kicad-wasm.sh: ${res.error.message}`,
);
} else if (res.status !== 0) {
console.warn(
`[link-wasm] setup-kicad-wasm.sh exited ${res.status} — ` +
`serving whatever is already in ${path.relative(repoRoot, targetAbs)}`,
);
}
}
// Relative link so it stays valid if the repo moves.
const targetRel = path.relative(publicDir, targetAbs);
async function statOrNull(p, { follow = true } = {}) {
try {
return follow ? await fs.stat(p) : await fs.lstat(p);
} catch {
return null;
}
}
async function main() {
syncArtifacts();
await fs.mkdir(publicDir, { recursive: true });
const link = await statOrNull(linkPath, { follow: false });
if (link?.isSymbolicLink()) {
const current = await fs.readlink(linkPath);
if (path.resolve(publicDir, current) === targetAbs) {
// already correct
} else {
await fs.rm(linkPath);
await fs.symlink(targetRel, linkPath, "dir");
}
} else if (link) {
// A real dir/file (e.g. an earlier copy) — remove and link.
await fs.rm(linkPath, { recursive: true, force: true });
await fs.symlink(targetRel, linkPath, "dir");
} else {
await fs.symlink(targetRel, linkPath, "dir");
}
const target = await statOrNull(targetAbs);
if (!target?.isDirectory()) {
console.warn(
`[link-wasm] target not found: ${targetAbs}\n` +
`[link-wasm] run tests/scripts/setup-kicad-wasm.sh (or set WASM_SRC_DIR). ` +
`App will run but tools won't load.`,
);
return;
}
const hasWx = await statOrNull(path.join(targetAbs, "wx.js"));
console.log(
`[link-wasm] public/wasm -> ${path.relative(repoRoot, targetAbs)}` +
(hasWx ? "" : " (warning: wx.js missing in target)"),
);
}
main().catch((err) => {
console.error("[link-wasm]", err);
process.exit(1);
});

View file

@ -0,0 +1,14 @@
import { Route, Routes } from "react-router-dom";
import { HomePage } from "@/pages/HomePage";
import { ProjectView } from "@/pages/ProjectView";
import { ToolPage } from "@/pages/ToolPage";
export default function App() {
return (
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/p/:project" element={<ProjectView />} />
<Route path="/p/:project/:tool/*" element={<ToolPage />} />
</Routes>
);
}

View file

@ -0,0 +1,173 @@
import * as React from "react";
import type { Tool } from "@pcbjam/shared";
import { ChevronDown, ChevronUp } from "lucide-react";
import { WASM_ASSET_BASE_URL } from "@/lib/config";
import { bootKicadTool } from "@/wasm/boot";
import { driveProjectIntoTool, type ToolFile } from "@/wasm/kicad-runner";
import type { CollabWindow } from "@/wasm/collab";
import { clog, cwarn } from "@/wasm/collab/debug";
// Tools with a working collab bridge (kicadCollabSnapshot/Apply embind exports).
const COLLAB_TOOLS = new Set<Tool>(["pl_editor", "eeschema", "pcbnew"]);
/**
* Collaborative editing (features/yjs-bridge), ON BY DEFAULT for any tool that has the
* collab bridge. Open the same project URL in two tabs to edit together: the channel is
* keyed to project+file, so both tabs share one Y.Doc over BroadcastChannel. Editor edits
* (add/move items) fire the tool's change hook → the bridge → the peer tab.
*
* Opt OUT with `?collab=0` (or `collab=false`). Tools without a bridge are skipped anyway.
*/
async function maybeStartCollab(
win: ToolWindow,
opts: {
tool: Tool;
slug: string;
targetPath?: string;
log: (m: string) => void;
onStatus: (t: string) => void;
},
): Promise<void> {
const collabParam = new URLSearchParams(win.location.search).get("collab");
const mod = win.Module;
clog("maybeStartCollab gate:", {
collabParam,
tool: opts.tool,
hasModule: !!mod,
hasSnapshot: typeof mod?.kicadCollabSnapshot,
hasApply: typeof mod?.kicadCollabApply,
url: win.location.href,
});
// On by default; only an explicit opt-out disables it.
if (collabParam === "0" || collabParam === "false") {
clog("disabled (?collab=0) — skipping");
return;
}
if (!COLLAB_TOOLS.has(opts.tool)) {
clog(`tool ${opts.tool} has no collab bridge — skipping`);
return;
}
if (typeof mod?.kicadCollabSnapshot !== "function") {
cwarn(
"BRIDGE NOT PRESENT: Module.kicadCollabSnapshot is",
typeof mod?.kicadCollabSnapshot,
`— the loaded ${opts.tool}.wasm predates the collab bridge. Rebuild + \`npm run setup:kicad\` and restart the dev server.`,
);
return;
}
const { startCollab } = await import("@/wasm/collab");
const channel = `kicad-collab:${opts.slug}:${opts.targetPath ?? ""}`;
clog("starting on channel", channel);
await startCollab(mod, win as unknown as CollabWindow, { channel });
opts.log(`[collab] connected on ${channel}`);
opts.onStatus("Collab: connected");
clog("connected ✓ — edit in one tab, watch the other");
}
/**
* 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 File→Open. See src/wasm/boot.ts for why the
* runtime is single-instance per page load.
*/
export function WasmTool({
tool,
slug,
files,
targetPath,
fetchBytes,
assetBaseUrl,
}: {
tool: Tool;
slug: string;
files: ToolFile[];
targetPath?: string;
/** Fetch one project-relative file's bytes (contract loader or local folder). */
fetchBytes: (relPath: string) => Promise<Uint8Array>;
/** Where the WASM glue/artifacts are served from; defaults to VITE_WASM_ASSET_BASE_URL. */
assetBaseUrl?: string;
}) {
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 = (assetBaseUrl ?? WASM_ASSET_BASE_URL).replace(/\/$/, "");
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 container = containerRef.current;
if (!container) {
setStatus("Error: tool container not mounted");
return;
}
const append = (msg: string) =>
setLogs((prev) => [...prev.slice(-800), msg]);
const win = window as ToolWindow;
void (async () => {
try {
await bootKicadTool({ tool, base, container, log: append, onStatus: setStatus });
await driveProjectIntoTool(win, {
tool,
slug,
files,
targetPath,
fetchBytes,
log: append,
onStatus: setStatus,
});
await maybeStartCollab(win, { tool, slug, targetPath, 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]">
{/*
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">
{status}
</div>
)}
<div className="absolute bottom-0 left-0 right-0 z-20">
<button
className="flex items-center gap-1 bg-black/70 px-3 py-1 font-mono text-xs text-white"
onClick={() => setShowLog((s) => !s)}
>
{showLog ? <ChevronDown size={14} /> : <ChevronUp size={14} />} console
({logs.length})
</button>
{showLog && (
<pre className="max-h-64 overflow-auto bg-black/85 p-3 font-mono text-[11px] leading-tight text-green-300">
{logs.join("\n")}
</pre>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,55 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
},
);
Button.displayName = "Button";
export { Button, buttonVariants };

View file

@ -0,0 +1,82 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className,
)}
{...props}
/>
));
Card.displayName = "Card";
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
));
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("font-semibold leading-none tracking-tight", className)}
{...props}
/>
));
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
));
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
));
CardFooter.displayName = "CardFooter";
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardDescription,
CardContent,
};

View file

@ -0,0 +1,106 @@
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 sm:rounded-lg",
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
function DialogHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)}
{...props}
/>
);
}
function DialogFooter({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className,
)}
{...props}
/>
);
}
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};

View file

@ -0,0 +1,19 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => (
<input
type={type}
ref={ref}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
/>
),
);
Input.displayName = "Input";
export { Input };

View file

@ -0,0 +1,20 @@
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { cn } from "@/lib/utils";
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
className,
)}
{...props}
/>
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };

View file

@ -0,0 +1,87 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
}
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
margin: 0;
}
}
/*
* wxWidgets WASM child-window styling.
*
* The wx.js runtime glue (createWindow/setWindowRect) creates a `.window`
* <div> (+ `.window-canvas`) per top-level window — every KiCad dialog
* (text properties, symbol chooser, power chooser, …) is one of these,
* appended to #window-container. setWindowRect positions it via inline
* left/top, which only take effect when the element is `position: absolute`.
*
* The standalone test harness HTML ships these rules; the React shell
* (WasmTool.tsx) did not — so dialogs computed `position: static`, ignored
* their left/top, and rendered jammed in the top-left corner with the OK
* button clipped off-screen. These rules (mirrored from the test harness)
* restore correct centered positioning. Input is routed through the main
* #canvas, so the window layers are pointer-events: none.
*/
.window {
position: absolute;
pointer-events: none;
z-index: 10;
background-color: black;
overflow: hidden;
width: 0;
height: 0;
}
.window-canvas {
position: absolute;
top: 0;
left: 0;
pointer-events: none;
}

View file

@ -0,0 +1,61 @@
import {
contract,
type Project,
type ProjectWithFiles,
} from "@pcbjam/shared";
import { initClient } from "@ts-rest/core";
import { useQuery } from "@tanstack/react-query";
import { API_BASE_URL } from "./config";
/**
* Read-only client over the shared contract. The standalone editor only ever
* READS projects from a backend (enumerate, get file tree, stream bytes) — it
* never creates/deletes/uploads. Those management concerns live in the closed
* application that hosts this editor.
*/
export const client = initClient(contract, {
baseUrl: API_BASE_URL,
baseHeaders: {},
});
export function useProjects() {
return useQuery({
queryKey: ["projects"],
queryFn: async (): Promise<Project[]> => {
const res = await client.listProjects();
if (res.status !== 200) throw new Error("failed to list projects");
return res.body;
},
});
}
export function useProject(slug: string) {
return useQuery({
queryKey: ["project", slug],
queryFn: async (): Promise<ProjectWithFiles> => {
const res = await client.getProject({ params: { project: slug } });
if (res.status === 404) throw new Error("project not found");
if (res.status !== 200) throw new Error("failed to load project");
return res.body;
},
});
}
// --- raw file-byte download (streamed binary, not a ts-rest endpoint) ---
export function fileBytesUrl(slug: string, relPath: string): string {
const encoded = relPath
.split("/")
.map((seg) => encodeURIComponent(seg))
.join("/");
return `${API_BASE_URL}/api/projects/${encodeURIComponent(slug)}/files/${encoded}`;
}
export async function fetchFileBytes(
slug: string,
relPath: string,
): Promise<Uint8Array> {
const res = await fetch(fileBytesUrl(slug, relPath));
if (!res.ok) throw new Error(`download failed (${res.status}): ${relPath}`);
return new Uint8Array(await res.arrayBuffer());
}

View file

@ -0,0 +1,9 @@
export const API_BASE_URL =
import.meta.env.VITE_API_BASE_URL ?? "http://localhost:3050";
// Default is SAME-ORIGIN ("/wasm", served from public/wasm by Vite). KiCad WASM
// pthread workers cannot be created cross-origin, so dev must serve same-origin.
// Override with an absolute URL (e.g. a CDN) only if that origin is configured
// to also satisfy the worker/COEP constraints.
export const WASM_ASSET_BASE_URL =
import.meta.env.VITE_WASM_ASSET_BASE_URL ?? "/wasm";

View file

@ -0,0 +1,18 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs));
}
export function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
const units = ["KB", "MB", "GB"];
let value = bytes / 1024;
let i = 0;
while (value >= 1024 && i < units.length - 1) {
value /= 1024;
i++;
}
return `${value.toFixed(1)} ${units[i]}`;
}

View file

@ -0,0 +1,22 @@
import ReactDOM from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { BrowserRouter } from "react-router-dom";
import App from "./App";
import "./index.css";
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
// NOTE: deliberately NOT wrapped in <React.StrictMode>. StrictMode double-mounts
// components in dev, which would re-run WasmTool's boot effect and try to
// instantiate a 175–338 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>
<App />
</BrowserRouter>
</QueryClientProvider>,
);

View file

@ -0,0 +1,187 @@
import * as React from "react";
import { Link } from "react-router-dom";
import {
EXTENSION_TOOL,
FILELESS_TOOLS,
TOOL_LABELS,
TOOLS,
type Tool,
} from "@pcbjam/shared";
import { FolderOpen, Loader2 } from "lucide-react";
import { useProjects } from "@/lib/api";
import { Button } from "@/components/ui/button";
import type { ToolFile } from "@/wasm/kicad-runner";
import { WasmTool } from "@/components/WasmTool";
/** A KiCad project picked from the local filesystem (no backend involved). */
interface LocalProject {
name: string;
files: ToolFile[];
fetchBytes: (relPath: string) => Promise<Uint8Array>;
defaultTool?: Tool;
defaultTarget?: string;
}
function toolForPath(path: string): Tool | null {
const dot = path.lastIndexOf(".");
if (dot < 0) return null;
return EXTENSION_TOOL[path.slice(dot).toLowerCase()] ?? null;
}
/** Build a LocalProject from a webkitdirectory FileList, stripping the top folder. */
function buildLocalProject(fileList: FileList): LocalProject {
const map = new Map<string, File>();
const first = fileList[0];
const topPrefix =
first?.webkitRelativePath?.includes("/")
? first.webkitRelativePath.split("/")[0] + "/"
: "";
for (const f of Array.from(fileList)) {
const rel = f.webkitRelativePath || f.name;
map.set(rel.startsWith(topPrefix) ? rel.slice(topPrefix.length) : rel, f);
}
const files: ToolFile[] = [...map.keys()].map((path) => ({ path }));
let defaultTool: Tool | undefined;
let defaultTarget: string | undefined;
for (const { path } of files) {
const tool = toolForPath(path);
if (tool) {
defaultTool = tool;
defaultTarget = path;
break;
}
}
return {
name: topPrefix ? topPrefix.slice(0, -1) : "local",
files,
defaultTool,
defaultTarget,
fetchBytes: async (relPath) => {
const f = map.get(relPath);
if (!f) throw new Error(`local file not found: ${relPath}`);
return new Uint8Array(await f.arrayBuffer());
},
};
}
export function HomePage() {
const { data: projects, isLoading, error } = useProjects();
const inputRef = React.useRef<HTMLInputElement>(null);
const [local, setLocal] = React.useState<LocalProject | null>(null);
const [tool, setTool] = React.useState<Tool | "">("");
const [launched, setLaunched] = React.useState(false);
// <input webkitdirectory> is non-standard; set it imperatively.
React.useEffect(() => {
if (inputRef.current) inputRef.current.setAttribute("webkitdirectory", "");
}, []);
// Once launched, the WASM runtime is process-global and one-shot for this page
// load — render the editor full-screen and nothing else (no going back).
if (launched && local && tool) {
const target = tool === local.defaultTool ? local.defaultTarget : undefined;
return (
<WasmTool
tool={tool}
slug="local"
files={local.files}
targetPath={FILELESS_TOOLS.has(tool) ? undefined : target}
fetchBytes={local.fetchBytes}
/>
);
}
return (
<div className="container max-w-3xl py-10">
<h1 className="text-2xl font-semibold tracking-tight">PCBJam</h1>
<p className="mb-8 text-sm text-muted-foreground">
Open KiCad files in the browser — from a backend, or straight from a
local folder.
</p>
{/* --- Local folder --- */}
<section className="mb-10 rounded-lg border p-5">
<h2 className="mb-1 flex items-center gap-2 text-lg font-medium">
<FolderOpen size={18} /> Open a local folder
</h2>
<p className="mb-4 text-sm text-muted-foreground">
No upload — files stay in your browser. Pick a folder containing a
KiCad project.
</p>
<input
ref={inputRef}
type="file"
multiple
className="block text-sm"
onChange={(e) => {
const fl = e.target.files;
if (!fl || fl.length === 0) return;
const proj = buildLocalProject(fl);
setLocal(proj);
setTool(proj.defaultTool ?? "");
}}
/>
{local && (
<div className="mt-4 flex flex-wrap items-center gap-3">
<span className="text-sm text-muted-foreground">
{local.files.length} files
</span>
<select
className="rounded-md border px-2 py-1.5 text-sm"
value={tool}
onChange={(e) => setTool(e.target.value as Tool)}
>
<option value="">Select a tool…</option>
{TOOLS.map((t) => (
<option key={t} value={t}>
{TOOL_LABELS[t]}
</option>
))}
</select>
<Button disabled={!tool} onClick={() => setLaunched(true)}>
Open editor
</Button>
</div>
)}
</section>
{/* --- Backend projects --- */}
<section>
<h2 className="mb-3 text-lg font-medium">Projects from the backend</h2>
{isLoading && (
<p className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="animate-spin" /> loading…
</p>
)}
{error && (
<p className="text-sm text-muted-foreground">
No backend reachable ({(error as Error).message}). Use a local folder
above, or configure VITE_API_BASE_URL.
</p>
)}
<div className="divide-y rounded-lg border">
{projects?.map((p) => (
<div
key={p.id}
className="flex items-center justify-between px-4 py-3"
>
<div>
<p className="font-medium">{p.name}</p>
<p className="text-xs text-muted-foreground">/p/{p.slug}</p>
</div>
<Button asChild variant="secondary" size="sm">
<Link to={`/p/${p.slug}`}>Open</Link>
</Button>
</div>
))}
{projects && projects.length === 0 && (
<div className="px-4 py-6 text-sm text-muted-foreground">
The backend has no projects.
</div>
)}
</div>
</section>
</div>
);
}

View file

@ -0,0 +1,106 @@
import { Link, useParams } from "react-router-dom";
import {
EXTENSION_TOOL,
FILELESS_TOOLS,
TOOL_LABELS,
type Tool,
} from "@pcbjam/shared";
import { ArrowLeft, ExternalLink, Loader2 } from "lucide-react";
import { useProject } from "@/lib/api";
import { formatBytes } from "@/lib/utils";
import { Button } from "@/components/ui/button";
function toolForPath(path: string): Tool | null {
const dot = path.lastIndexOf(".");
if (dot < 0) return null;
return EXTENSION_TOOL[path.slice(dot).toLowerCase()] ?? null;
}
/**
* Read-only view of a backend project: list its files and open them in a tool.
* The editor is GPL and intentionally has no create/delete/upload — those live
* in the closed application that hosts this editor.
*/
export function ProjectView() {
const { project: slug = "" } = useParams();
const { data, isLoading, error } = useProject(slug);
return (
<div className="container py-10">
<Button asChild variant="ghost" size="sm" className="mb-4">
<Link to="/">
<ArrowLeft /> Home
</Link>
</Button>
{isLoading && (
<p className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="animate-spin" /> loading…
</p>
)}
{error && <p className="text-destructive">{(error as Error).message}</p>}
{data && (
<>
<div className="mb-6">
<h1 className="text-2xl font-semibold tracking-tight">
{data.project.name}
</h1>
<p className="text-sm text-muted-foreground">
/p/{data.project.slug}
</p>
</div>
<div className="mb-6 flex flex-wrap gap-3">
{/* File-less tools — launched without a target file. Full reload
(anchor) so Emscripten boots into a clean page. */}
{[...FILELESS_TOOLS].map((tool) => (
<a
key={tool}
className="text-sm underline underline-offset-4"
href={`/p/${slug}/${tool}/`}
>
Open {TOOL_LABELS[tool]}
</a>
))}
</div>
<h2 className="mb-3 text-lg font-medium">
Files ({data.files.length})
</h2>
<div className="divide-y rounded-lg border">
{data.files.map((f) => {
const tool = toolForPath(f.path);
return (
<div
key={f.id}
className="flex items-center justify-between gap-4 px-4 py-2.5"
>
<div className="min-w-0">
<p className="truncate font-mono text-sm">{f.path}</p>
<p className="text-xs text-muted-foreground">
{formatBytes(f.size)}
</p>
</div>
{tool && (
<a
className="inline-flex shrink-0 items-center gap-1 rounded-md border px-3 py-1.5 text-sm hover:bg-accent"
href={`/p/${slug}/${tool}/${f.path}`}
>
<ExternalLink size={14} /> Open in {TOOL_LABELS[tool]}
</a>
)}
</div>
);
})}
{data.files.length === 0 && (
<div className="px-4 py-6 text-sm text-muted-foreground">
No files in this project.
</div>
)}
</div>
</>
)}
</div>
);
}

View file

@ -0,0 +1,42 @@
import { useParams } from "react-router-dom";
import { toolSchema } from "@pcbjam/shared";
import { fetchFileBytes, useProject } from "@/lib/api";
import { WasmTool } from "@/components/WasmTool";
export function ToolPage() {
const params = useParams();
const slug = params.project ?? "";
const targetPath = params["*"] || undefined;
const parsedTool = toolSchema.safeParse(params.tool);
const { data, isLoading, error } = useProject(slug);
if (!parsedTool.success) {
return (
<div className="container py-10 text-destructive">
Unknown tool: {params.tool}
</div>
);
}
if (isLoading) {
return <div className="container py-10 text-muted-foreground">loading…</div>;
}
if (error || !data) {
return (
<div className="container py-10 text-destructive">
{(error as Error)?.message ?? "project not found"}
</div>
);
}
return (
<WasmTool
tool={parsedTool.data}
slug={slug}
files={data.files}
targetPath={targetPath}
fetchBytes={(relPath) => fetchFileBytes(slug, relPath)}
/>
);
}

10
web/standalone/src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1,10 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_BASE_URL?: string;
readonly VITE_WASM_ASSET_BASE_URL?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

View file

@ -0,0 +1,219 @@
import type { Tool } from "@pcbjam/shared";
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

@ -0,0 +1,65 @@
import * as Y from "yjs";
import { clog } from "./debug";
/**
* Minimal BroadcastChannel sync provider for a Y.Doc (features/yjs-bridge/0001 §6,
* PoC transport — two same-origin tabs share one doc, zero backend). Encodes Yjs
* updates and ferries them between tabs; on join it requests the current state so a
* late tab catches up.
*
* Remote updates are applied with origin "remote" so (a) the reconciler treats them
* as peer changes (its own writes use a different origin) and (b) we don't re-broadcast
* them into a loop.
*/
export interface Transport {
destroy(): void;
}
type Msg =
| { t: "update"; u: Uint8Array }
| { t: "query" }
| { t: "state"; u: Uint8Array };
export const REMOTE_ORIGIN = "remote";
export function connectBroadcastChannel(
doc: Y.Doc,
channelName: string,
): Transport {
const bc = new BroadcastChannel(channelName);
clog("BroadcastChannel open:", channelName);
const onUpdate = (update: Uint8Array, origin: unknown) => {
if (origin === REMOTE_ORIGIN) return; // don't echo applied remote updates
clog("→ BC send update", update.byteLength, "bytes (origin:", String(origin) + ")");
bc.postMessage({ t: "update", u: update } satisfies Msg);
};
doc.on("update", onUpdate);
bc.onmessage = (e: MessageEvent<Msg>) => {
const msg = e.data;
if (!msg) return;
switch (msg.t) {
case "update":
case "state":
clog("← BC recv", msg.t, msg.u?.byteLength, "bytes → applyUpdate");
Y.applyUpdate(doc, new Uint8Array(msg.u), REMOTE_ORIGIN);
break;
case "query":
clog("← BC recv query → replying with state");
bc.postMessage({ t: "state", u: Y.encodeStateAsUpdate(doc) } satisfies Msg);
break;
}
};
// Ask any existing tab for the current state.
clog("→ BC send query (asking peers for state)");
bc.postMessage({ t: "query" } satisfies Msg);
return {
destroy: () => {
doc.off("update", onUpdate);
bc.close();
},
};
}

View file

@ -0,0 +1,23 @@
// Lightweight collab debug logging to the BROWSER DEVTOOLS console (not the in-app
// log panel). On by default while we bring the bridge up; silence with
// `window.__COLLAB_DEBUG = false` (or `localStorage.collabDebug = "0"`).
function on(): boolean {
const w = window as unknown as { __COLLAB_DEBUG?: boolean };
if (typeof w.__COLLAB_DEBUG === "boolean") return w.__COLLAB_DEBUG;
try {
if (localStorage.getItem("collabDebug") === "0") return false;
} catch {
/* ignore */
}
return true;
}
const STYLE = "color:#0bd;font-weight:bold";
export function clog(...args: unknown[]): void {
if (on()) console.log("%c[collab]", STYLE, ...args);
}
export function cwarn(...args: unknown[]): void {
if (on()) console.warn("%c[collab]", STYLE, ...args);
}

View file

@ -0,0 +1,82 @@
import * as Y from "yjs";
import { connectBroadcastChannel, type Transport } from "./broadcast-transport";
import { clog } from "./debug";
import { createReconciler, type Reconciler } from "./reconciler";
import type { CollabBridge } from "./types";
export type { CollabBridge, CollabDelta, CollabItem } from "./types";
export { createReconciler } from "./reconciler";
export { connectBroadcastChannel } from "./broadcast-transport";
/** The subset of the Emscripten Module the collab bridge needs (embind functions). */
export interface CollabModule {
kicadCollabSnapshot(): string;
kicadCollabApply(deltaJson: string): void;
}
/** The window slot the C++ emit side calls into. */
export interface CollabWindow {
kicadCollab?: { onDelta: (deltaJson: string) => void };
}
export function moduleBridge(mod: CollabModule, win: CollabWindow): CollabBridge {
return {
snapshot: () => mod.kicadCollabSnapshot(),
apply: (deltaJson) => mod.kicadCollabApply(deltaJson),
onDelta: (cb) => {
win.kicadCollab = { onDelta: cb };
clog("registered window.kicadCollab.onDelta (wasm emit sink)");
},
};
}
export interface StartCollabOptions {
/** BroadcastChannel name — tabs sharing this name share the document. */
channel: string;
/**
* How long to wait for an existing tab's state before deciding seed-vs-adopt
* (seed-once rule). First tab: no reply, seeds from its local model. Later tab:
* receives state within this window, then adopts it. Default 300ms.
*/
settleMs?: number;
}
export interface CollabHandle {
doc: Y.Doc;
reconciler: Reconciler;
transport: Transport;
destroy(): void;
}
/**
* Wire a running pl_editor wasm Module into a collaborative session: Module ⇄ Y.Doc ⇄
* BroadcastChannel. Returns once the initial seed/adopt has run. The editor must
* already have its document loaded (so kicadCollabSnapshot reflects it).
*/
export async function startCollab(
mod: CollabModule,
win: CollabWindow,
opts: StartCollabOptions,
): Promise<CollabHandle> {
clog("startCollab: channel =", opts.channel);
const doc = new Y.Doc();
const bridge = moduleBridge(mod, win);
const reconciler = createReconciler(doc, bridge);
const transport = connectBroadcastChannel(doc, opts.channel);
// Let any existing tab answer our state query before we decide to seed.
await new Promise((r) => setTimeout(r, opts.settleMs ?? 300));
reconciler.seed();
clog("startCollab: ready; doc items =", reconciler.items.size);
return {
doc,
reconciler,
transport,
destroy() {
reconciler.destroy();
transport.destroy();
doc.destroy();
},
};
}

View file

@ -0,0 +1,203 @@
import * as Y from "yjs";
import { clog } from "./debug";
import {
type CollabBridge,
type CollabDelta,
type CollabItem,
emptyDelta,
isEmptyDelta,
} from "./types";
/**
* The generic, schema-agnostic reconciler (features/yjs-bridge/0001 §4). It binds a
* KiCad editor's bridge (snapshot/apply/onDelta) to a Y.Doc and keeps them in sync:
*
* DOWN (model → Y): bridge.onDelta → write changed items into the Y.Map, in a
* transaction tagged with our local origin.
* UP (Y → model): observe the Y.Map; on remote-origin events, build a per-item
* delta and call bridge.apply. Own-origin events are skipped
* (standard Yjs echo-suppression).
*
* CRDT shape: a top-level Y.Map keyed by item uuid, each value a Y.Map of scalar
* fields. (0001 names "Y.Array<Y.Map>"; a uuid-keyed Y.Map is the better fit for
* id-stable items — O(1) by-id add/remove/change and no index-shift conflicts — and
* the reconciler stays equally schema-agnostic.) Adding a C++ field needs zero JS
* change here: fields are copied generically by name.
*/
export interface Reconciler {
/**
* Seed-once join (0001 §2). Reads the local model snapshot; if the shared doc is
* empty this client seeds it, otherwise it adopts the shared doc into the local
* model. Call once after the doc/provider are connected.
*/
seed(): void;
destroy(): void;
/** The underlying items map (exposed for tests/inspection). */
readonly items: Y.Map<Y.Map<unknown>>;
}
const ITEMS_KEY = "items";
function itemToYMap(item: CollabItem): Y.Map<unknown> {
const ym = new Y.Map<unknown>();
for (const [k, v] of Object.entries(item)) {
if (k === "id") continue; // id is the map key, not a field
ym.set(k, v);
}
return ym;
}
/** Copy a delta item's fields into an existing/new Y.Map, writing only real changes. */
function upsertItem(items: Y.Map<Y.Map<unknown>>, item: CollabItem): void {
let ym = items.get(item.id);
if (!ym) {
items.set(item.id, itemToYMap(item));
return;
}
for (const [k, v] of Object.entries(item)) {
if (k === "id") continue;
if (ym.get(k) !== v) ym.set(k, v);
}
}
function yMapToItem(id: string, ym: Y.Map<unknown>): CollabItem {
const item: CollabItem = { id, type: String(ym.get("type") ?? "") };
ym.forEach((v, k) => {
item[k] = v;
});
item.id = id;
return item;
}
function findId(
items: Y.Map<Y.Map<unknown>>,
target: Y.Map<unknown>,
): string | undefined {
let found: string | undefined;
items.forEach((ym, id) => {
if (ym === target) found = id;
});
return found;
}
export function createReconciler(
doc: Y.Doc,
bridge: CollabBridge,
): Reconciler {
const items = doc.getMap<Y.Map<unknown>>(ITEMS_KEY);
// Opaque per-instance origin tag so we can distinguish our own writes from peers'.
const ORIGIN = { local: true };
// DOWN: local model change → Y.Doc
bridge.onDelta((deltaJson: string) => {
let delta: CollabDelta;
try {
delta = JSON.parse(deltaJson);
} catch {
clog("⬇ onDelta from wasm: UNPARSEABLE", deltaJson);
return;
}
clog("⬇ onDelta from wasm (local edit):", {
added: delta.added?.length ?? 0,
changed: delta.changed?.length ?? 0,
removed: delta.removed?.length ?? 0,
});
doc.transact(() => {
for (const it of delta.added ?? []) upsertItem(items, it);
for (const it of delta.changed ?? []) upsertItem(items, it);
for (const id of delta.removed ?? []) items.delete(id);
}, ORIGIN);
});
// UP: remote Y.Doc change → local model
const observer = (events: Y.YEvent<Y.Map<unknown>>[], txn: Y.Transaction) => {
if (txn.origin === ORIGIN) {
clog("⬆ Y change (own origin) — ignored");
return; // our own echo — ignore
}
const delta = emptyDelta();
const changedIds = new Set<string>();
for (const ev of events) {
if (ev.target === items) {
// Top-level: items added / removed (or whole-entry replaced).
(ev as Y.YMapEvent<Y.Map<unknown>>).changes.keys.forEach((change, id) => {
if (change.action === "delete") {
delta.removed.push(id);
} else {
const ym = items.get(id);
if (ym) {
if (change.action === "add") delta.added.push(yMapToItem(id, ym));
else changedIds.add(id); // "update"
}
}
});
} else {
// A child field map changed → that item changed.
const ym = ev.target as Y.Map<unknown>;
const id = findId(items, ym);
if (id) changedIds.add(id);
}
}
for (const id of changedIds) {
const ym = items.get(id);
if (ym) delta.changed.push(yMapToItem(id, ym));
}
if (!isEmptyDelta(delta)) {
clog("⬆ remote Y change → apply to wasm:", {
added: delta.added.length,
changed: delta.changed.length,
removed: delta.removed.length,
});
bridge.apply(JSON.stringify(delta));
}
};
items.observeDeep(observer);
function seed(): void {
let snap: CollabDelta;
try {
snap = JSON.parse(bridge.snapshot());
} catch {
return;
}
clog(
`seed: doc has ${items.size} item(s), local model has ${snap.added?.length ?? 0} →`,
items.size === 0 ? "SEEDING doc (first tab)" : "ADOPTING doc (joining)",
);
if (items.size === 0) {
// We're first: seed the shared doc from our local model. Our backfilled uuids win.
doc.transact(() => {
for (const it of snap.added) upsertItem(items, it);
}, ORIGIN);
} else {
// Joining a populated doc: make the local model *match* it (seed-once authority).
// We add/replace the doc's items and drop any local items not in the doc — this
// resolves the never-saved-file cold-open race (0001 §2): a file with no uuids
// gets random backfill, so our local uuids differ from the seeder's; adopting the
// doc's identity (and removing our divergent copies) keeps both clients consistent.
const docIds = new Set<string>();
const added: CollabItem[] = [];
items.forEach((ym, id) => {
docIds.add(id);
added.push(yMapToItem(id, ym));
});
const removed = (snap.added ?? [])
.map((it) => it.id)
.filter((id) => !docIds.has(id));
bridge.apply(JSON.stringify({ added, changed: [], removed }));
}
}
return {
seed,
destroy: () => items.unobserveDeep(observer),
items,
};
}

View file

@ -0,0 +1,36 @@
// Wire contract shared with the C++ bridge (wasm/bindings/pl_editor_embind.cpp).
// Schema-agnostic by design: an item is just { id, type, …arbitrary fields }. The
// reconciler hardcodes no field names — it diffs values keyed by field name.
export type CollabItem = {
id: string;
type: string;
[field: string]: unknown;
};
export type CollabDelta = {
added: CollabItem[];
changed: CollabItem[];
removed: string[]; // uuids
};
/**
* The two C++ bridge entry points + the emit hook, abstracted so the reconciler is
* testable without a real wasm Module. In the browser these map to:
* snapshot() -> Module.kicadCollabSnapshot()
* apply(d) -> Module.kicadCollabApply(d)
* onDelta(cb): set window.kicadCollab = { onDelta: cb }
*/
export interface CollabBridge {
snapshot(): string;
apply(deltaJson: string): void;
onDelta(cb: (deltaJson: string) => void): void;
}
export function emptyDelta(): CollabDelta {
return { added: [], changed: [], removed: [] };
}
export function isEmptyDelta(d: CollabDelta): boolean {
return d.added.length === 0 && d.changed.length === 0 && d.removed.length === 0;
}

View file

@ -0,0 +1,59 @@
import type { Tool } from "@pcbjam/shared";
/**
* KiCad config/version dir baked into the WASM build. The File→Open dialog
* starts in MEMFS_PROJECTS_DIR; we mirror each project under a subfolder of it.
*
* NOTE (spec §11.1): this path is KiCad-version dependent and confirmed by
* tests/kicad/load-pcb-probe.spec.ts. If the build's version dir changes, this
* must change too — a candidate for reading from the module at runtime later.
*/
export const KICAD_VERSION_DIR = "9.99";
export const MEMFS_PROJECTS_DIR = `/home/kicad/documents/kicad/${KICAD_VERSION_DIR}/projects`;
/** Where KiCad expects images.tar.gz (compiled-in KICAD_DATA path). */
export const RESOURCE_PATH =
"/workspace/build-wasm/sysroot/share/kicad/resources";
/**
* 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/pcb_calculator",
pl_editor: "/usr/bin/pl_editor",
symbol_editor: "/usr/bin/symbol_editor",
gerbview: "/usr/bin/gerbview",
};
/**
* Every standalone tool here boots through common/single_top.cpp, which runs
* STARTWIZARD::CheckAndRun() — the first-run "KiCad Setup" wizard. It shows
* whenever any provider (SETTINGS / LIBRARIES / PRIVACY) reports
* NeedsUserInput(), which is always true on our ephemeral MEMFS with no config,
* and its modal loop crashes Asyncify. So for all of them we seed a default
* KiCad config before main() (kicad_common.json privacy flags + the lib-tables
* the providers check) so NeedsUserInput() is false and the wizard is skipped.
*/
export const TOOL_NEEDS_CONFIG_SEED: Record<Tool, boolean> = {
pcbnew: true,
eeschema: true,
calculator: true,
pl_editor: true,
symbol_editor: true,
gerbview: true,
};
/** 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}`;
}
export function memfsFilePath(slug: string, relPath: string): string {
return `${memfsProjectDir(slug)}/${relPath}`;
}

54
web/standalone/src/wasm/global.d.ts vendored Normal file
View file

@ -0,0 +1,54 @@
export {};
declare global {
interface EmscriptenFS {
mkdirTree(path: string): void;
writeFile(path: string, data: Uint8Array | string): void;
readFile(path: string, opts?: { encoding?: "binary" | "utf8" }): unknown;
analyzePath(path: string): { exists: boolean };
}
// Loose shape of the wxWidgets-WASM element registry exposed by wx.js.
interface WxElementInfo {
id: string;
typeName: string;
name: string;
label: string;
visible: boolean;
enabled: boolean;
screenX: number;
screenY: number;
centerX: number;
centerY: number;
width: number;
height: number;
}
interface WxElementRegistry {
findAll(filter?: {
visible?: boolean;
enabled?: boolean;
type?: string;
label?: string;
name?: string;
}): WxElementInfo[];
findByLabel(label: string, options?: Record<string, unknown>): WxElementInfo[];
findRenderedByLabel?(
label: string,
options?: Record<string, unknown>,
): WxElementInfo[];
}
interface Window {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Module?: any;
FS?: EmscriptenFS;
wxElementRegistry?: WxElementRegistry;
}
// 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

@ -0,0 +1,89 @@
import type { Tool } from "@pcbjam/shared";
import { FILELESS_TOOLS } from "@pcbjam/shared";
import { memfsFilePath, memfsProjectDir } from "./constants";
import { openFileInTool } from "./open-flow";
/**
* The only thing the editor needs to know about a file to sync it into MEMFS:
* its project-relative POSIX path. Both the contract loader (whose ProjectFile
* is a superset of this) and the local-folder loader satisfy it.
*/
export interface ToolFile {
path: string;
}
export interface DriveOptions {
tool: Tool;
slug: string;
files: ToolFile[];
targetPath?: string;
fetchBytes: (relPath: string) => Promise<Uint8Array>;
log: (msg: string) => void;
onStatus: (text: string) => void;
}
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
async function waitFor<T>(
fn: () => T | null | undefined | false,
timeoutMs: number,
intervalMs = 200,
): Promise<T | null> {
const deadline = performance.now() + timeoutMs;
for (;;) {
const v = fn();
if (v) return v as T;
if (performance.now() >= deadline) return null;
await sleep(intervalMs);
}
}
function getFS(win: ToolWindow): EmscriptenFS {
const fs = win.FS ?? win.Module?.FS;
if (!fs) throw new Error("Emscripten FS not available");
return fs as EmscriptenFS;
}
/** 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));
for (const file of opts.files) {
const dest = memfsFilePath(opts.slug, file.path);
const dir = dest.slice(0, dest.lastIndexOf("/"));
fs.mkdirTree(dir);
const bytes = await opts.fetchBytes(file.path);
fs.writeFile(dest, bytes);
opts.log(`[memfs] wrote ${dest} (${bytes.length} bytes)`);
}
}
/**
* 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,
opts: DriveOptions,
): Promise<void> {
const { log, onStatus } = opts;
onStatus("Waiting for runtime…");
const fsReady = await waitFor(
() => !!(win.FS && typeof win.FS.writeFile === "function"),
90000,
);
if (!fsReady) throw new Error("runtime did not initialize (no FS) in 90s");
onStatus("Loading project files…");
await syncProjectToMemfs(win, opts);
if (opts.targetPath && !FILELESS_TOOLS.has(opts.tool)) {
onStatus("Opening file…");
const abs = memfsFilePath(opts.slug, opts.targetPath);
const result = await openFileInTool(win, abs, { log });
log(`[open] result: ${result}`);
}
onStatus("");
}

View file

@ -0,0 +1,204 @@
/**
* 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
* one. PREFERRED (spec §11.2): deterministic, no UI automation. Not present
* in the current build; adding it is a small embind change.
* 2. UI automation fallback — synthesize canvas mouse/keyboard events using
* win.wxElementRegistry coordinates (a browser port of
* tests/kicad/load-pcb.spec.ts). Inherently fragile; EXPERIMENTAL, needs
* in-browser validation.
*/
export interface OpenFlowOptions {
log: (msg: string) => void;
timeoutMs?: number;
}
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
async function waitFor<T>(
fn: () => T | null | undefined | false,
timeoutMs: number,
intervalMs = 200,
): Promise<T | null> {
const deadline = performance.now() + timeoutMs;
for (;;) {
const v = fn();
if (v) return v as T;
if (performance.now() >= deadline) return null;
await sleep(intervalMs);
}
}
function registry(win: ToolWindow): WxElementRegistry | undefined {
return win.wxElementRegistry;
}
function visible(
win: ToolWindow,
filter: { type?: string; name?: string; label?: string },
): WxElementInfo[] {
return registry(win)?.findAll({ ...filter, visible: true }) ?? [];
}
function canvasOf(win: ToolWindow): HTMLCanvasElement | null {
return (win.Module?.canvas as HTMLCanvasElement) ?? null;
}
/** 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;
const PE = win.PointerEvent ?? PointerEvent;
const ME = win.MouseEvent ?? MouseEvent;
const base = { clientX: x, clientY: y, bubbles: true, cancelable: true };
el.dispatchEvent(new PE("pointerdown", { ...base, pointerId: 1 }));
el.dispatchEvent(new ME("mousedown", { ...base, button: 0 }));
el.dispatchEvent(new PE("pointerup", { ...base, pointerId: 1 }));
el.dispatchEvent(new ME("mouseup", { ...base, button: 0 }));
el.dispatchEvent(new ME("click", { ...base, button: 0 }));
}
function typeText(win: ToolWindow, text: string): void {
const el = canvasOf(win) ?? win.document.body;
const KE = win.KeyboardEvent ?? KeyboardEvent;
for (const ch of text) {
const init = { key: ch, bubbles: true, cancelable: true } as KeyboardEventInit;
el.dispatchEvent(new KE("keydown", init));
el.dispatchEvent(new KE("keypress", init));
el.dispatchEvent(new KE("keyup", init));
}
}
function pressKey(win: ToolWindow, key: string): void {
const el = canvasOf(win) ?? win.document.body;
const KE = win.KeyboardEvent ?? KeyboardEvent;
const init = { key, bubbles: true, cancelable: true } as KeyboardEventInit;
el.dispatchEvent(new KE("keydown", init));
el.dispatchEvent(new KE("keyup", init));
}
/** True if the build exposes the programmatic open hook. */
function hasProgrammaticHook(win: ToolWindow): boolean {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const mod = win.Module as any;
return !!mod && typeof mod.kicadOpenFile === "function";
}
/**
* Invoke the programmatic hook. NOTE: kicadOpenFile runs OpenProjectFiles under
* Asyncify, so the call SUSPENDS and unwinds back to JS before the load finishes
* — its synchronous return is a falsy placeholder, not the real bool. So we fire
* it and ignore the return; the caller polls for the loaded schematic instead.
*/
function invokeProgrammaticOpen(
win: ToolWindow,
absPath: string,
log: (m: string) => void,
): void {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const mod = win.Module as any;
mod.kicadOpenFile(absPath);
log(`[open] invoked Module.kicadOpenFile(${absPath}) (async; polling for load)`);
}
/** Heuristic: the editor frame title drops "untitled" once a real file is open. */
function schematicLoaded(win: ToolWindow): boolean {
const title = win.document?.title ?? "";
return title.length > 0 && !/untitled/i.test(title);
}
export async function openFileInTool(
win: ToolWindow,
absPath: string,
opts: OpenFlowOptions,
): Promise<"programmatic" | "ui" | "failed"> {
const { log } = opts;
const timeoutMs = opts.timeoutMs ?? 60000;
// Both strategies need the editor frame up first. Crucially, the programmatic
// hook (Module.kicadOpenFile → OpenProjectFiles) requires a top window, and
// embind only registers the hook during runtime init — which lands AFTER the
// Emscripten FS is ready, i.e. after driveProjectIntoTool calls us. Probing
// the hook before the frame exists therefore always missed and fell back to
// UI automation (and the wizard's modal loop then crashed Asyncify). Waiting
// for a visible Frame guarantees the runtime is initialized, the hook is
// registered, and a top window exists — so we probe only after this point.
const ready = await waitFor(
() =>
visible(win, {}).some(
(e) => /Frame$/.test(e.typeName) || e.name.endsWith("Frame"),
),
timeoutMs,
);
if (!ready) {
log("[open] app frame never became visible");
return "failed";
}
// Strategy 1: programmatic hook (preferred — deterministic, no UI automation).
// Because the call is Asyncify-async we can't trust its return value; instead
// we invoke it and poll the frame title until the schematic loads. We must NOT
// fall back to UI automation while the hook is in flight — synthesizing input
// would re-enter the suspended Asyncify call and corrupt it.
if (hasProgrammaticHook(win)) {
invokeProgrammaticOpen(win, absPath, log);
const loaded = await waitFor(() => schematicLoaded(win), timeoutMs);
if (loaded) {
log(`[open] schematic loaded: ${win.document.title}`);
return "programmatic";
}
log("[open] kicadOpenFile did not load the schematic within timeout");
return "failed";
}
// Strategy 2: UI automation fallback (EXPERIMENTAL, fragile). Only when the
// build has no programmatic hook at all.
log("[open] no programmatic hook; using EXPERIMENTAL UI automation");
const fileMenu =
registry(win)
?.findByLabel("File", {})
?.find((e) => e.visible) ??
visible(win, {}).find((e) => e.label === "File");
if (!fileMenu) {
log("[open] could not find File menu");
return "failed";
}
clickAt(win, fileMenu.centerX, fileMenu.centerY);
await sleep(400);
const openItem =
registry(win)?.findRenderedByLabel?.("Open...", {})?.[0] ??
registry(win)?.findByLabel("Open...", {})?.[0];
if (!openItem) {
log("[open] could not find Open... item");
return "failed";
}
clickAt(win, openItem.centerX, openItem.centerY);
const dlg = await waitFor(() => visible(win, { type: "wxFileDialog" })[0], 15000);
if (!dlg) {
log("[open] wxFileDialog never appeared");
return "failed";
}
await sleep(800);
const textInput = visible(win, { type: "wxTextCtrl" }).find(
(e) => e.name === "text",
);
if (!textInput) {
log("[open] filename text input not found");
return "failed";
}
clickAt(win, textInput.centerX, textInput.centerY);
await sleep(150);
typeText(win, absPath);
await sleep(150);
pressKey(win, "Enter");
log(`[open] typed path and pressed Enter: ${absPath}`);
return "ui";
}

View file

@ -0,0 +1,53 @@
import tailwindcssAnimate from "tailwindcss-animate";
/** @type {import('tailwindcss').Config} */
export default {
darkMode: ["class"],
content: ["./index.html", "./src/**/*.{ts,tsx}"],
theme: {
container: {
center: true,
padding: "2rem",
screens: { "2xl": "1400px" },
},
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
},
},
plugins: [tailwindcssAnimate],
};

View file

@ -0,0 +1,15 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"types": ["vite/client"],
"moduleResolution": "Bundler",
"noEmit": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src", "vite.config.ts"]
}

View file

@ -0,0 +1,59 @@
import * as fs from "node:fs";
import * as path from "node:path";
import react from "@vitejs/plugin-react";
import { defineConfig, type Plugin } from "vite";
/**
* Serve /wasm/*.gz as opaque bytes WITHOUT `Content-Encoding: gzip`.
*
* Vite's static (sirv) sets `Content-Encoding: gzip` for `.gz` files. The
* browser then transparently decompresses the response, so the harness's
* `fetch('images.tar.gz')` receives the DECOMPRESSED tar — and KiCad's gunzip
* of it fails with "Can't read from inflate stream: incorrect header check".
* We must hand the browser the raw gzip bytes, so we serve them ourselves with
* no Content-Encoding. Runs before the public-dir middleware.
*/
function serveWasmGzRaw(): Plugin {
const publicDir = path.resolve(__dirname, "public");
return {
name: "serve-wasm-gz-raw",
configureServer(server) {
server.middlewares.use((req, res, next) => {
const url = req.url?.split("?")[0] ?? "";
if (!/^\/wasm\/.+\.gz$/.test(url)) return next();
const filePath = path.join(publicDir, decodeURIComponent(url));
fs.stat(filePath, (err, st) => {
if (err || !st.isFile()) return next();
res.setHeader("Content-Type", "application/octet-stream");
res.setHeader("Content-Length", st.size);
// Intentionally NO Content-Encoding so fetch() yields raw gzip bytes.
fs.createReadStream(filePath).pipe(res);
});
});
},
};
}
export default defineConfig({
plugins: [serveWasmGzRaw(), react()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
server: {
port: 3048,
// KiCad WASM is cross-origin-isolated (COOP/COEP); same-origin /wasm assets
// load fine. Keep these so SharedArrayBuffer/threads are available.
headers: {
"Cross-Origin-Opener-Policy": "same-origin",
"Cross-Origin-Embedder-Policy": "require-corp",
},
},
preview: {
headers: {
"Cross-Origin-Opener-Policy": "same-origin",
"Cross-Origin-Embedder-Policy": "require-corp",
},
},
});