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:
parent
b672c54189
commit
b6a77ee93c
80 changed files with 671 additions and 2992 deletions
16
web/standalone/.env.example
Normal file
16
web/standalone/.env.example
Normal 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
12
web/standalone/index.html
Normal 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>
|
||||
41
web/standalone/package.json
Normal file
41
web/standalone/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
web/standalone/postcss.config.js
Normal file
6
web/standalone/postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
105
web/standalone/scripts/link-wasm.mjs
Normal file
105
web/standalone/scripts/link-wasm.mjs
Normal 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);
|
||||
});
|
||||
14
web/standalone/src/App.tsx
Normal file
14
web/standalone/src/App.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
173
web/standalone/src/components/WasmTool.tsx
Normal file
173
web/standalone/src/components/WasmTool.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
55
web/standalone/src/components/ui/button.tsx
Normal file
55
web/standalone/src/components/ui/button.tsx
Normal 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 };
|
||||
82
web/standalone/src/components/ui/card.tsx
Normal file
82
web/standalone/src/components/ui/card.tsx
Normal 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,
|
||||
};
|
||||
106
web/standalone/src/components/ui/dialog.tsx
Normal file
106
web/standalone/src/components/ui/dialog.tsx
Normal 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,
|
||||
};
|
||||
19
web/standalone/src/components/ui/input.tsx
Normal file
19
web/standalone/src/components/ui/input.tsx
Normal 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 };
|
||||
20
web/standalone/src/components/ui/label.tsx
Normal file
20
web/standalone/src/components/ui/label.tsx
Normal 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 };
|
||||
87
web/standalone/src/index.css
Normal file
87
web/standalone/src/index.css
Normal 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;
|
||||
}
|
||||
61
web/standalone/src/lib/api.ts
Normal file
61
web/standalone/src/lib/api.ts
Normal 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());
|
||||
}
|
||||
9
web/standalone/src/lib/config.ts
Normal file
9
web/standalone/src/lib/config.ts
Normal 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";
|
||||
18
web/standalone/src/lib/utils.ts
Normal file
18
web/standalone/src/lib/utils.ts
Normal 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]}`;
|
||||
}
|
||||
22
web/standalone/src/main.tsx
Normal file
22
web/standalone/src/main.tsx
Normal 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>,
|
||||
);
|
||||
187
web/standalone/src/pages/HomePage.tsx
Normal file
187
web/standalone/src/pages/HomePage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
106
web/standalone/src/pages/ProjectView.tsx
Normal file
106
web/standalone/src/pages/ProjectView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
42
web/standalone/src/pages/ToolPage.tsx
Normal file
42
web/standalone/src/pages/ToolPage.tsx
Normal 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
10
web/standalone/src/vite-env.d.ts
vendored
Normal 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;
|
||||
}
|
||||
219
web/standalone/src/wasm/boot.ts
Normal file
219
web/standalone/src/wasm/boot.ts
Normal 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})`);
|
||||
}
|
||||
65
web/standalone/src/wasm/collab/broadcast-transport.ts
Normal file
65
web/standalone/src/wasm/collab/broadcast-transport.ts
Normal 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();
|
||||
},
|
||||
};
|
||||
}
|
||||
23
web/standalone/src/wasm/collab/debug.ts
Normal file
23
web/standalone/src/wasm/collab/debug.ts
Normal 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);
|
||||
}
|
||||
82
web/standalone/src/wasm/collab/index.ts
Normal file
82
web/standalone/src/wasm/collab/index.ts
Normal 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();
|
||||
},
|
||||
};
|
||||
}
|
||||
203
web/standalone/src/wasm/collab/reconciler.ts
Normal file
203
web/standalone/src/wasm/collab/reconciler.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
36
web/standalone/src/wasm/collab/types.ts
Normal file
36
web/standalone/src/wasm/collab/types.ts
Normal 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;
|
||||
}
|
||||
59
web/standalone/src/wasm/constants.ts
Normal file
59
web/standalone/src/wasm/constants.ts
Normal 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
54
web/standalone/src/wasm/global.d.ts
vendored
Normal 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;
|
||||
}
|
||||
89
web/standalone/src/wasm/kicad-runner.ts
Normal file
89
web/standalone/src/wasm/kicad-runner.ts
Normal 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("");
|
||||
}
|
||||
204
web/standalone/src/wasm/open-flow.ts
Normal file
204
web/standalone/src/wasm/open-flow.ts
Normal 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";
|
||||
}
|
||||
53
web/standalone/tailwind.config.js
Normal file
53
web/standalone/tailwind.config.js
Normal 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],
|
||||
};
|
||||
15
web/standalone/tsconfig.json
Normal file
15
web/standalone/tsconfig.json
Normal 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"]
|
||||
}
|
||||
59
web/standalone/vite.config.ts
Normal file
59
web/standalone/vite.config.ts
Normal 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",
|
||||
},
|
||||
},
|
||||
});
|
||||
Loading…
Reference in a new issue