import { asyncMap } from "@/lib/async-map"; import { handleModel3dRequest } from "./models-bridge"; import { libIdFromUri, libUri } from "./uri"; /** * The data a `LibsSource` provides. A source abstracts WHERE library data comes * from (a remote backend over the shared contract, a static public snapshot, a * local folder); the WASM-facing provider below is the same regardless. */ export interface LibInfo { /** Opaque id used in the lib-table URI (/mnt/pcbjam/). */ id: string; /** Display nickname for the sym-lib-table row. */ name: string; description?: string | null; /** 'origin' | 'mirror' | 'user' — drives "ensure a user lib exists" at boot. */ type?: string; /** Item count, when the source knows it cheaply (manifest/backend) — shown as a * badge in the home-page library list. Omitted ⇒ no badge. */ itemCount?: number; } export interface LibItemInfo { kind: string; // 'symbol' | 'footprint' | 'model3d' name: string; } /** Per-lib progress for {@link LibsSource.presync} (drives the load screen). */ export interface LibPresyncProgress { done: number; total: number; /** Display name of the lib currently being synced (one of the in-flight set). */ current: string; } export interface LibsSource { /** * Libraries to expose to the editor (one lib-table row each). `kind` (the * current tool's item kind, "symbol" | "footprint") filters origins to those * holding that kind; user libs are kind-agnostic containers, always listed. * Omitted ⇒ all libs. */ listLibs(kind?: string): Promise; /** Items in a library (by lib id). */ listItems(libId: string): Promise; /** * One self-contained item body (a complete `kicad_symbol_lib` s-expr), or * null if absent. `kind` is 'symbol' for now. */ getItemBody(libId: string, kind: string, name: string): Promise; /** * ALL items in a library with their bodies, in one shot — the "fat list" that * lets the WASM plugin hydrate a whole library in a single bridge crossing * instead of N per-item `get`s (see docs/features/libs/0011). Optional: sources * that can't bulk-read omit it and the provider falls back to listItems + N * getItemBody (the old slow path, kept working for the example backend). */ getAllItems?( libId: string, ): Promise>; /** * Pre-warm this source's per-lib caches (IndexedDB bundles) WITHOUT touching the * WASM runtime — call it in parallel with the wasm download so the editor's * first enumerate reads a warm cache instead of freezing on N cold bundle * fetches. Best-effort: a lib that fails to presync is skipped (it still loads * lazily later); the SyncStack dedups, so a lib the wasm reaches mid-presync * just awaits the same in-flight fetch. `onProgress` reports per-lib so the load * screen can name what's syncing. Optional: sources without a client-side cache * (per-item remote) omit it. */ presync?(opts?: { /** Limit to libs holding this item kind ("symbol" | "footprint"). */ kind?: string; /** Max concurrent bundle fetches (default 8). */ concurrency?: number; onProgress?: (p: LibPresyncProgress) => void; signal?: AbortSignal; }): Promise; /** * Persist one item body into a writable (user) lib. Optional: read-only * sources omit it (a save into a non-writable source resolves false). * `body` is a complete fork-native `kicad_symbol_lib` s-expr. */ saveItemBody?( libId: string, kind: string, name: string, body: string, ): Promise; /** * Create a user library (returns its `LibInfo`, or null if unsupported / on * conflict). Used by boot to ensure the owner has a writable target. */ createLib?(name: string): Promise; /** * The publish-time footprint index as raw JSON text: * { schema, tag, libs: { "": [["", ], …] } } * One small artifact covering EVERY footprint lib, so the editor's symbol- * chooser footprint selector can pin-count/wildcard-filter the full set * without fat-loading a single body (kicad `filterFootprints`, pcbnew.cpp). * Optional: sources without a published index omit it (or resolve null) and * the C++ side falls back to the per-lib lazy load. */ getFpIndex?(): Promise; } /** * The function the WASM lib plugins call via the JS bridge. Both the symbol * plugin (`SCH_IO_PCBJAM_LIB`) and the footprint plugin (`PCB_IO_PCBJAM_FP`) * call the same hook; `kind` (4th arg) discriminates the item kind. The symbol * plugin omits it (passes 3 args) so it defaults to "symbol" — keeping the * existing eeschema binary correct with no rebuild. */ export type KicadLibsRequest = ( op: string, lib: string, arg: string, kind?: string, // "bodies" returns a framed Uint8Array (raw item bytes, copied as-is across the // bridge); every other op returns a string (or null). ) => Promise; declare global { interface Window { kicadLibs?: { request: KicadLibsRequest }; } } /** * Events the libs bridge dispatches on `window` so the editor chrome (WasmTool) * can show a loading state for the otherwise-invisible item fetch, and surface an * error when a body can't be loaded (e.g. a backend 404). Decoupled via events so * `wasm/libs` stays UI-agnostic. */ export const LIB_BUSY_EVENT = "pcbjam:lib-busy"; export const LIB_ERROR_EVENT = "pcbjam:lib-error"; /** * Fired around the bulk "fat list" crossing (`list`/`bodies`) — the eager * idb→wasm library load that can take tens of seconds on the full CDN set. Unlike * LIB_BUSY (per-item open/save), this brackets the whole-library hydrate so the * editor chrome can show a "loading libraries, just slow" overlay instead of a * silent freeze. One `loading:true` per lib as its crossing starts, `loading:false` * as the bytes are handed to the bridge; the consumer coalesces the per-lib run. */ export const LIB_LOADING_EVENT = "pcbjam:lib-loading"; export interface LibBusyDetail { busy: boolean; op: string; kind: string; name: string; } export interface LibErrorDetail { message: string; } export interface LibLoadingDetail { loading: boolean; kind: string; /** Libraries whose fat-load has started this burst (1-based, increasing). */ done: number; /** Total libs of this kind to load (from listLibs), or 0 if unknown. */ total: number; } function emitLibBusy(detail: LibBusyDetail): void { if (typeof window === "undefined") return; window.dispatchEvent(new CustomEvent(LIB_BUSY_EVENT, { detail })); } function emitLibLoading(detail: LibLoadingDetail): void { if (typeof window === "undefined") return; window.dispatchEvent(new CustomEvent(LIB_LOADING_EVENT, { detail })); } function emitLibError(message: string): void { if (typeof window === "undefined") return; window.dispatchEvent( new CustomEvent(LIB_ERROR_EVENT, { detail: { message } }), ); } /** Optional artificial latency (`?libdelay=1500`) to exercise the bridge. */ function artificialDelayMs(): number { const raw = new URLSearchParams(window.location.search).get("libdelay"); const n = raw ? Number(raw) : 0; return Number.isFinite(n) && n > 0 ? n : 0; } const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); /** * Slow-path "fat list" for sources without a bulk `getAllItems` (the example * backend / per-item remote): just listItems + one getItemBody each. Same N * round-trips as before — but it keeps the single WASM-side code path (the plugin * always asks for bodies once) working against every source. The per-item gets * run 8 at a time (they're independent HTTP round-trips); order is preserved. */ async function fallbackGetAllItems( source: LibsSource, libId: string, ): Promise> { const items = await source.listItems(libId); const enc = new TextEncoder(); const bodies = await asyncMap( items, (it) => source.getItemBody(libId, it.kind, it.name).catch(() => null), 8, ); return items.flatMap((it, i) => { const body = bodies[i]; return body != null ? [{ kind: it.kind, name: it.name, body: enc.encode(body) }] : []; }); } /** Escape a string for a KiCad s-expr quoted token. */ function sexprEscape(s: string): string { return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); } /** Build sym-lib-table content (KiCad v7) with one PCBJAM row per lib. */ export function buildSymLibTable(libsList: LibInfo[]): string { const rows = libsList.map((l) => { const descr = l.description ? sexprEscape(l.description) : ""; return ` (lib (name "${sexprEscape(l.name)}")(type "PCBJAM")(uri "${libUri( l.id, )}")(options "")(descr "${descr}"))`; }); return `(sym_lib_table\n (version 7)\n${rows.join("\n")}${ rows.length ? "\n" : "" })\n`; } /** * Build fp-lib-table content (KiCad v7) with one PCBJAM_FP row per lib. The * footprint editor selects the plugin from this row's `type` field (via * PCB_IO_MGR::EnumFromStr), so it MUST be "PCBJAM_FP" to match the registered * plugin name. Same /mnt/pcbjam/ URI as symbols (the same lib id can appear * in both tables — user libs are kind-agnostic containers). */ export function buildFpLibTable(libsList: LibInfo[]): string { const rows = libsList.map((l) => { const descr = l.description ? sexprEscape(l.description) : ""; return ` (lib (name "${sexprEscape( l.name, )}")(type "PCBJAM_FP")(uri "${libUri( l.id, )}")(options "")(descr "${descr}"))`; }); return `(fp_lib_table\n (version 7)\n${rows.join("\n")}${ rows.length ? "\n" : "" })\n`; } /** * Install `window.kicadLibs` backed by a `LibsSource`. Both lib plugins call * `request(op, "/mnt/pcbjam/", arg, kind)` (kind defaults to "symbol" so the * symbol plugin's 3-arg calls still work): * "list" -> JSON {"symbols":[...]} | {"footprints":[...]} (names of that kind) * "get" -> the item body s-expr (arg = item name; null if absent) * "save" -> "ok" / null (arg = JSON {"name":..,"body":..}) * "index" -> the publish-time footprint index JSON (source-global; lib arg * ignored) / null when the source has none — see getFpIndex. */ export function installLibsProvider( source: LibsSource, log: (msg: string) => void, ): void { if (window.kicadLibs) return; const delay = artificialDelayMs(); // Per-burst fat-load progress. The plugin fat-loads every library of a kind // one bridge crossing at a time, so counting `bodies` requests gives real // per-lib progress; `total` comes from listLibs(kind) (cached). A trailing // timer resets the counter once a burst goes quiet, so a later open starts // fresh (mirrors the overlay's own hide debounce). let fatDone = 0; let fatTotal = 0; let fatResetTimer: ReturnType | undefined; const request: KicadLibsRequest = async (op, lib, arg, kind = "symbol") => { // 3D models are addressed by ref, not lib-table URI (the C++ ensure bridge // passes an empty lib) — dispatch before the lib-id parse would null it out. if (kind === "model3d") { log(`[libs] request op=${op} kind=model3d arg=${arg}`); return handleModel3dRequest(op, arg); } // The footprint index is source-global (not per-lib) — dispatch before the // lib-id parse, which would reject the bare mount URI the C++ side passes. if (op === "index") { log(`[libs] request op=index kind=${kind}`); return kind === "footprint" && source.getFpIndex ? await source.getFpIndex() : null; } const id = libIdFromUri(lib); log(`[libs] request op=${op} kind=${kind} lib=${lib} (id=${id}) arg=${arg}`); if (!id) return null; if (delay) await sleep(delay); // "get"/"save" are user-triggered (open/save an item) and otherwise give no // visible feedback — broadcast busy + errors so the editor can show them. const userFacing = op === "get" || op === "save"; if (userFacing) emitLibBusy({ busy: true, op, kind, name: arg }); try { switch (op) { case "list": { // Each plugin parses its own key: footprints / symbols. const key = kind === "footprint" ? "footprints" : "symbols"; // "bodies" (arg) = the fat list: every item's body in one crossing, so // the plugin pre-fills its cache and never per-item `get`s. Falls back // to listItems + N getItemBody for sources without bulk read. if (arg === "bodies") { // Bracket the whole-library hydrate so the editor can overlay a // "loading libraries (slow, not hung)" state over the otherwise // silent multi-second freeze. `true` before the (async) IDB read so // the overlay can paint while the C++ side is Asyncify-suspended; // `false` once the bytes are framed and about to cross the bridge. clearTimeout(fatResetTimer); if (fatTotal === 0) { // First lib of the burst — learn the total for the progress bar. try { fatTotal = (await source.listLibs(kind)).length; } catch { fatTotal = 0; } } fatDone++; emitLibLoading({ loading: true, kind, done: fatDone, total: fatTotal }); try { const all = source.getAllItems ? await source.getAllItems(id) : await fallbackGetAllItems(source, id); const items = all.filter((i) => i.kind === kind); // "Copy as-is" framing: a one-line JSON header (names + UTF-8 byte // lengths), a newline, then every body's RAW bytes concatenated — no // JSON escaping. The C++ bridge memcpy's this straight into the wasm // heap; the plugin parses the small header and slices the bodies, so // none of the (hundreds of MB of) s-expr gets un-escaped. const header = JSON.stringify({ [key]: items.map((i) => ({ name: i.name, len: i.body.length })), }); const headerBytes = new TextEncoder().encode(header + "\n"); const total = headerBytes.length + items.reduce((n, i) => n + i.body.length, 0); const out = new Uint8Array(total); out.set(headerBytes, 0); let off = headerBytes.length; for (const i of items) { out.set(i.body, off); off += i.body.length; } return out; } finally { emitLibLoading({ loading: false, kind, done: fatDone, total: fatTotal, }); // Reset the per-burst counter once the run goes quiet, so the next // open starts from zero (the WASM drives these back-to-back). fatResetTimer = setTimeout(() => { fatDone = 0; fatTotal = 0; }, 1500); } } const items = await source.listItems(id); const names = items .filter((i) => i.kind === kind) .map((i) => i.name); return JSON.stringify({ [key]: names }); } case "get": { const body = await source.getItemBody(id, kind, arg); if (body === null) { emitLibError( `Couldn't open "${arg}" — the backend has no body for it (404).`, ); } return body; } case "save": { let parsed: { name?: string; body?: string }; try { parsed = JSON.parse(arg) as { name?: string; body?: string }; } catch { log(`[libs] save: bad JSON arg`); return null; } if (!parsed.name || !parsed.body) return null; if (!source.saveItemBody) { log(`[libs] save: source has no write support (lib=${id})`); return null; } const ok = await source.saveItemBody( id, kind, parsed.name, parsed.body, ); if (!ok) emitLibError(`Couldn't save "${parsed.name}".`); return ok ? "ok" : null; } default: return null; } } catch (e) { log(`[libs] request failed: ${String(e)}`); if (userFacing) emitLibError(`Failed to ${op} "${arg}".`); return null; } finally { if (userFacing) emitLibBusy({ busy: false, op, kind, name: arg }); } }; window.kicadLibs = { request }; log("[libs] provider installed"); }