feat: libs 0003 — remote provider + example backend serving; bump kicad, shared

standalone: LibsSource abstraction (remote over the contract + static fallback),
generic window.kicadLibs provider, sym-lib-table generated from the backend's
lib list at boot (replaces the spike). backend: serve pre-built self-contained
symbol bodies from LIBS_DIR (listLibs/listLibItems + raw item-body route).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Gergő Törcsvári 2026-06-12 19:15:37 +02:00
commit d2d2c19a8c
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
12 changed files with 447 additions and 145 deletions

2
kicad

@ -1 +1 @@
Subproject commit 51743e6c7981d4349adaabc9416eb35469a8c0d6 Subproject commit 7932b3ace1e5e21fb6ab38ece2dc1faa41a25a12

107
web/backend/src/libs.ts Normal file
View file

@ -0,0 +1,107 @@
// Minimal reference libraries serving for the @pcbjam/shared libs contract.
//
// Serves PRE-BUILT self-contained symbol bodies from a directory (LIBS_DIR),
// laid out as:
//
// <LIBS_DIR>/<LibName>/index.json { items: [{kind,name,description,keywords}], ... }
// <LIBS_DIR>/<LibName>/<Symbol>.kicad_sym a complete kicad_symbol_lib s-expr
// <LIBS_DIR>/<LibName>/LICENSE.md attribution (CC-BY-SA travels with data)
//
// This backend does NO parsing — bodies are produced upstream (the closed
// ingestion/extractor) so the open reference server stays trivial. If LIBS_DIR
// is unset or empty, the lib endpoints simply report no libraries.
import { createReadStream } from "node:fs";
import * as fs from "node:fs/promises";
import * as path from "node:path";
import type { Lib, LibItem } from "@pcbjam/shared";
export interface LibsConfig {
dir: string | null;
}
export function libsConfig(): LibsConfig {
const dir = process.env.LIBS_DIR;
return { dir: dir ? path.resolve(process.cwd(), dir) : null };
}
/** A lib id is its directory name; reject anything that isn't a plain segment. */
function safeLibDir(root: string, lib: string): string | null {
if (!/^[A-Za-z0-9][A-Za-z0-9._+-]*$/.test(lib)) return null;
return path.join(root, lib);
}
interface IndexFile {
items?: { kind: string; name: string; description?: string | null; keywords?: string | null }[];
description?: string | null;
}
async function readIndex(dir: string): Promise<IndexFile | null> {
try {
return JSON.parse(await fs.readFile(path.join(dir, "index.json"), "utf8"));
} catch {
return null;
}
}
export async function listLibs(cfg: LibsConfig): Promise<Lib[]> {
if (!cfg.dir) return [];
let entries: import("node:fs").Dirent[];
try {
entries = await fs.readdir(cfg.dir, { withFileTypes: true });
} catch {
return [];
}
const libs: Lib[] = [];
for (const e of entries) {
if (!e.isDirectory() || e.name.startsWith(".")) continue;
const idx = await readIndex(path.join(cfg.dir, e.name));
libs.push({
id: e.name,
name: e.name,
type: "origin",
description: idx?.description ?? null,
itemCount: idx?.items?.length ?? undefined,
});
}
libs.sort((a, b) => a.name.localeCompare(b.name));
return libs;
}
export async function listLibItems(
cfg: LibsConfig,
lib: string,
): Promise<LibItem[] | null> {
if (!cfg.dir) return null;
const dir = safeLibDir(cfg.dir, lib);
if (!dir) return null;
const idx = await readIndex(dir);
if (!idx) return null;
return (idx.items ?? []).map((i) => ({
kind: i.kind,
name: i.name,
description: i.description ?? null,
keywords: i.keywords ?? null,
}));
}
/** Resolve the on-disk body file for one item, or null (guarded). */
export function itemBodyPath(
cfg: LibsConfig,
lib: string,
kind: string,
name: string,
): string | null {
if (!cfg.dir || kind !== "symbol") return null;
const dir = safeLibDir(cfg.dir, lib);
if (!dir) return null;
// Symbol names allow a wide charset but never path separators.
if (name.includes("/") || name.includes("\\") || name.includes("..")) {
return null;
}
return path.join(dir, `${name}.kicad_sym`);
}
export function streamBody(absPath: string) {
return createReadStream(absPath);
}

View file

@ -19,6 +19,13 @@ import {
type Project, type Project,
type ProjectFile, type ProjectFile,
} from "@pcbjam/shared"; } from "@pcbjam/shared";
import {
itemBodyPath,
type LibsConfig,
libsConfig,
listLibItems,
listLibs,
} from "./libs.js";
const PROJECT_DIR = path.resolve( const PROJECT_DIR = path.resolve(
process.cwd(), process.cwd(),
@ -121,6 +128,8 @@ async function main(): Promise<void> {
}); });
app.get("/health", async () => ({ ok: true })); app.get("/health", async () => ({ ok: true }));
const libs: LibsConfig = libsConfig();
const s = initServer(); const s = initServer();
const router = s.router(contract, { const router = s.router(contract, {
listProjects: async () => ({ status: 200, body: [await project()] }), listProjects: async () => ({ status: 200, body: [await project()] }),
@ -139,9 +148,33 @@ async function main(): Promise<void> {
} }
return { status: 200 as const, body: await walk(PROJECT_DIR) }; return { status: 200 as const, body: await walk(PROJECT_DIR) };
}, },
listLibs: async () => ({ status: 200 as const, body: await listLibs(libs) }),
listLibItems: async ({ params }) => {
const items = await listLibItems(libs, params.lib);
if (items === null) {
return { status: 404 as const, body: { message: "library not found" } };
}
return { status: 200 as const, body: items };
},
}); });
await app.register(s.plugin(router)); await app.register(s.plugin(router));
// Streamed item-body fetch (text; intentionally not a ts-rest endpoint).
app.get<{ Params: { lib: string; kind: string; name: string } }>(
"/api/libs/:lib/items/:kind/:name",
async (req, reply) => {
const abs = itemBodyPath(libs, req.params.lib, req.params.kind, req.params.name);
if (!abs) return reply.code(400).send({ message: "invalid item" });
const st = await fs.stat(abs).catch(() => null);
if (!st?.isFile()) {
return reply.code(404).send({ message: "item not found" });
}
reply.header("Content-Type", "text/plain; charset=utf-8");
reply.header("Content-Length", st.size);
return reply.send(createReadStream(abs));
},
);
// Streamed file-byte download (binary; intentionally not a ts-rest endpoint). // Streamed file-byte download (binary; intentionally not a ts-rest endpoint).
app.get<{ Params: { project: string; "*": string } }>( app.get<{ Params: { project: string; "*": string } }>(
"/api/projects/:project/files/*", "/api/projects/:project/files/*",

@ -1 +1 @@
Subproject commit 92b2634c6d17fb387574b43a38890fd22b30891d Subproject commit eb806fc14ba1f91d8f99f03186add68d2a19a033

View file

@ -12,7 +12,12 @@ import {
type Tool, type Tool,
} from "@pcbjam/shared"; } from "@pcbjam/shared";
import { ChevronDown, ChevronUp } from "lucide-react"; import { ChevronDown, ChevronUp } from "lucide-react";
import { WASM_ASSET_BASE_URL, yjsProviderConfig, type DocSource } from "@/lib/config"; import {
libsSourceConfig,
WASM_ASSET_BASE_URL,
yjsProviderConfig,
type DocSource,
} from "@/lib/config";
import { bootKicadTool } from "@/wasm/boot"; import { bootKicadTool } from "@/wasm/boot";
import { memfsFilePath, memfsProjectDir } from "@/wasm/constants"; import { memfsFilePath, memfsProjectDir } from "@/wasm/constants";
import { driveProjectIntoTool, type ToolFile } from "@/wasm/kicad-runner"; import { driveProjectIntoTool, type ToolFile } from "@/wasm/kicad-runner";
@ -437,6 +442,7 @@ export function WasmTool({
log: append, log: append,
onStatus: setStatus, onStatus: setStatus,
onAbort: oom.onAbort, onAbort: oom.onAbort,
libsSource: libsSourceConfig(),
}); });
// Register the save sink before the file opens: from here on, every // Register the save sink before the file opens: from here on, every
// editor File→Save (MEMFS write) is routed onward through saveBytes. // editor File→Save (MEMFS write) is routed onward through saveBytes.

View file

@ -9,6 +9,9 @@ export const WASM_ASSET_BASE_URL =
import.meta.env.VITE_WASM_ASSET_BASE_URL ?? "/wasm"; import.meta.env.VITE_WASM_ASSET_BASE_URL ?? "/wasm";
import type { ProviderConfig, ProviderKind } from "@/wasm/collab"; import type { ProviderConfig, ProviderKind } from "@/wasm/collab";
import { remoteLibsSource } from "@/wasm/libs/remote-source";
import type { LibsSource } from "@/wasm/libs/source";
import { staticLibsSource } from "@/wasm/libs/static-source";
/** /**
* Which Yjs collab provider this deployment uses (one active per env), and its * Which Yjs collab provider this deployment uses (one active per env), and its
@ -43,3 +46,18 @@ export type DocSource = "api" | "ydoc";
export function docSourceConfig(): DocSource { export function docSourceConfig(): DocSource {
return import.meta.env.VITE_DOC_SOURCE === "ydoc" ? "ydoc" : "api"; return import.meta.env.VITE_DOC_SOURCE === "ydoc" ? "ydoc" : "api";
} }
/**
* Which library source backs the editor's symbol chooser (env `VITE_LIBS_SOURCE`):
* "remote" (default) fetch from the backend at `API_BASE_URL` over the
* shared contract (origins served by the registry, or the
* GPL example backend).
* "static" built-in offline example symbols (no backend).
* "off" disable libs (empty sym-lib-table).
*/
export function libsSourceConfig(): LibsSource | null {
const kind = import.meta.env.VITE_LIBS_SOURCE ?? "remote";
if (kind === "off") return null;
if (kind === "static") return staticLibsSource();
return remoteLibsSource(API_BASE_URL);
}

View file

@ -5,11 +5,8 @@ import {
TOOL_ARGV0, TOOL_ARGV0,
TOOL_NEEDS_CONFIG_SEED, TOOL_NEEDS_CONFIG_SEED,
} from "./constants"; } from "./constants";
import { import { buildSymLibTable, installLibsProvider, type LibsSource } from "./libs/source";
installSpikeLibsProvider, import { PCBJAM_LIB_MOUNT } from "./libs/uri";
PCBJAM_LIB_MOUNT,
SPIKE_LIB_TABLE_ROW,
} from "./libs/spike-provider";
/** /**
* Boot a KiCad tool directly in the main React document no iframe. * Boot a KiCad tool directly in the main React document no iframe.
@ -45,6 +42,9 @@ export interface BootOptions {
/** OOM recovery hook (feature 0002): emscripten `abort()` routes here so a /** OOM recovery hook (feature 0002): emscripten `abort()` routes here so a
* soft OOM can respawn a fresh tab. Optional boot works without it. */ * soft OOM can respawn a fresh tab. Optional boot works without it. */
onAbort?: (what: string) => void; onAbort?: (what: string) => void;
/** Library source backing `window.kicadLibs`. Null/omitted disables libs
* (an empty sym-lib-table is seeded). Its libs become sym-lib-table rows. */
libsSource?: LibsSource | null;
} }
let booted: { tool: Tool; promise: Promise<void> } | null = null; let booted: { tool: Tool; promise: Promise<void> } | null = null;
@ -83,7 +83,7 @@ function loadScript(src: string): Promise<void> {
} }
async function doBoot(opts: BootOptions): Promise<void> { async function doBoot(opts: BootOptions): Promise<void> {
const { tool, base, container, log, onStatus, onAbort } = opts; const { tool, base, container, log, onStatus, onAbort, libsSource } = opts;
const w = window as ToolWindow; const w = window as ToolWindow;
// The wasm reads the top-level frame geometry from a GLOBAL `mainWindow` // The wasm reads the top-level frame geometry from a GLOBAL `mainWindow`
@ -93,8 +93,21 @@ async function doBoot(opts: BootOptions): Promise<void> {
// mismatches the viewport, breaking the whole AUI layout (toolbars/panels). // mismatches the viewport, breaking the whole AUI layout (toolbars/panels).
(w as unknown as { mainWindow: HTMLElement }).mainWindow = container; (w as unknown as { mainWindow: HTMLElement }).mainWindow = container;
// libs 0002 spike: must exist before any plugin call can suspend on it. // libs: install the provider (must exist before any plugin call can suspend
installSpikeLibsProvider(log); // on it) and generate the sym-lib-table from the source's libs — both before
// the wasm boots (the table is seeded in preRun below). No source → empty
// table, libs disabled.
let symLibTable = "(sym_lib_table\n (version 7)\n)\n";
if (libsSource) {
installLibsProvider(libsSource, log);
try {
const libsList = await libsSource.listLibs();
symLibTable = buildSymLibTable(libsList);
log(`[libs] seeded ${libsList.length} lib(s) into sym-lib-table`);
} catch (e) {
log(`[libs] listLibs failed, seeding empty table: ${String(e)}`);
}
}
onStatus("Downloading…"); onStatus("Downloading…");
@ -186,12 +199,9 @@ async function doBoot(opts: BootOptions): Promise<void> {
2, 2,
), ),
); );
// libs 0002 spike: one remote lib row; the PCBJAM plugin resolves it // libs: rows generated in doBoot from the lib source; the PCBJAM plugin
// through window.kicadLibs (installed in doBoot). // resolves each via window.kicadLibs.
writeIfAbsent( writeIfAbsent(`${KICAD_CONFIG_DIR}/sym-lib-table`, symLibTable);
`${KICAD_CONFIG_DIR}/sym-lib-table`,
`(sym_lib_table\n (version 7)\n${SPIKE_LIB_TABLE_ROW}\n)\n`,
);
writeIfAbsent( writeIfAbsent(
`${KICAD_CONFIG_DIR}/fp-lib-table`, `${KICAD_CONFIG_DIR}/fp-lib-table`,
"(fp_lib_table\n (version 7)\n)\n", "(fp_lib_table\n (version 7)\n)\n",

View file

@ -0,0 +1,44 @@
import { contract } from "@pcbjam/shared";
import { initClient } from "@ts-rest/core";
import type { LibInfo, LibItemInfo, LibsSource } from "./source";
/**
* A `LibsSource` backed by a contract-conforming backend (the closed registry
* server, or the GPL example backend). List ops go through the ts-rest client;
* item bodies stream from the raw text route `GET /api/libs/:lib/items/:kind/:name`
* (binary/text does not round-trip ts-rest same as file-byte download).
*/
export function remoteLibsSource(apiBase: string): LibsSource {
const client = initClient(contract, { baseUrl: apiBase, baseHeaders: {} });
return {
async listLibs(): Promise<LibInfo[]> {
const res = await client.listLibs();
if (res.status !== 200) return [];
return res.body.map((l) => ({
id: l.id,
name: l.name,
description: l.description ?? null,
}));
},
async listItems(libId: string): Promise<LibItemInfo[]> {
const res = await client.listLibItems({ params: { lib: libId } });
if (res.status !== 200) return [];
return res.body.map((i) => ({ kind: i.kind, name: i.name }));
},
async getItemBody(
libId: string,
kind: string,
name: string,
): Promise<string | null> {
const url =
`${apiBase}/api/libs/${encodeURIComponent(libId)}/items/` +
`${encodeURIComponent(kind)}/${encodeURIComponent(name)}`;
const res = await fetch(url);
if (!res.ok) return null;
return await res.text();
},
};
}

View file

@ -0,0 +1,114 @@
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>). */
id: string;
/** Display nickname for the sym-lib-table row. */
name: string;
description?: string | null;
}
export interface LibItemInfo {
kind: string; // 'symbol' | 'footprint' | 'model3d'
name: string;
}
export interface LibsSource {
/** Libraries to expose to the editor (one sym-lib-table row each). */
listLibs(): Promise<LibInfo[]>;
/** Items in a library (by lib id). */
listItems(libId: string): Promise<LibItemInfo[]>;
/**
* 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<string | null>;
}
/** The function the WASM `SCH_IO_PCBJAM_LIB` plugin calls via the JS bridge. */
export type KicadLibsRequest = (
op: string,
lib: string,
arg: string,
) => Promise<string | null>;
declare global {
interface Window {
kicadLibs?: { request: KicadLibsRequest };
}
}
/** 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));
/** 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`;
}
/**
* Install `window.kicadLibs` backed by a `LibsSource`. The plugin calls
* `request(op, "/mnt/pcbjam/<id>", arg)`:
* "list" -> JSON {"symbols":[...]} (symbol names in the lib)
* "get" -> the item body s-expr (arg = symbol name)
*/
export function installLibsProvider(
source: LibsSource,
log: (msg: string) => void,
): void {
if (window.kicadLibs) return;
const delay = artificialDelayMs();
const request: KicadLibsRequest = async (op, lib, arg) => {
const id = libIdFromUri(lib);
log(`[libs] request op=${op} lib=${lib} (id=${id}) arg=${arg}`);
if (!id) return null;
if (delay) await sleep(delay);
try {
switch (op) {
case "list": {
const items = await source.listItems(id);
const symbols = items
.filter((i) => i.kind === "symbol")
.map((i) => i.name);
return JSON.stringify({ symbols });
}
case "get":
return await source.getItemBody(id, "symbol", arg);
default:
return null;
}
} catch (e) {
log(`[libs] request failed: ${String(e)}`);
return null;
}
};
window.kicadLibs = { request };
log("[libs] provider installed");
}

View file

@ -1,128 +0,0 @@
/**
* libs 0002 spike: a static JS-side library provider.
*
* The kicad fork's SCH_IO_PCBJAM_LIB plugin (lib-table type "PCBJAM") calls
* `globalThis.kicadLibs.request(op, lib, arg)` through an Asyncify suspension
* (EM_ASYNC_JS) and blocks the C++ stack until the returned promise resolves:
*
* request("list", "pcbjam://spike", "") -> JSON {"symbols": [...]}
* request("get", "pcbjam://spike", "R") -> full kicad_symbol_lib s-expr
* document holding that symbol
*
* This stub serves two handcrafted symbols from memory no server. An
* artificial delay (`?libdelay=1500` in the URL) exercises the reentrancy
* question: the tab must stay alive while C++ is suspended mid-fetch.
*/
export const SPIKE_LIB_NICKNAME = "pcbjam-spike";
/**
* pcbjam lib URIs are absolute POSIX paths under this prefix. Absolute so that
* KiCad's lib-table URI expansion (ExpandURI -> wxFileName::MakeAbsolute) is a
* no-op and the path reaches the plugin/provider unmangled a "scheme://" URI
* gets rewritten to "/scheme:/..." against the cwd, which differs per project.
*/
export const PCBJAM_LIB_MOUNT = "/mnt/pcbjam";
export const PCBJAM_LIB_PREFIX = `${PCBJAM_LIB_MOUNT}/`;
export const SPIKE_LIB_URI = `${PCBJAM_LIB_PREFIX}spike`;
/** sym-lib-table row seeded at boot (see boot.ts seedKicadConfig). */
export const SPIKE_LIB_TABLE_ROW = ` (lib (name "${SPIKE_LIB_NICKNAME}")(type "PCBJAM")(uri "${SPIKE_LIB_URI}")(options "")(descr "pcbjam libs 0002 spike"))`;
const wrapLib = (symbolBody: string) =>
`(kicad_symbol_lib (version 20241209) (generator "pcbjam") (generator_version "0.1")
${symbolBody}
)
`;
const SYMBOLS: Record<string, string> = {
R: ` (symbol "R" (pin_numbers hide) (pin_names (offset 0)) (exclude_from_sim no) (in_bom yes) (on_board yes)
(property "Reference" "R" (at 2.032 0 90) (effects (font (size 1.27 1.27))))
(property "Value" "R" (at 0 0 90) (effects (font (size 1.27 1.27))))
(property "Footprint" "" (at -1.778 0 90) (effects (font (size 1.27 1.27)) hide))
(property "Datasheet" "~" (at 0 0 0) (effects (font (size 1.27 1.27)) hide))
(property "Description" "Resistor (pcbjam spike)" (at 0 0 0) (effects (font (size 1.27 1.27)) hide))
(property "ki_keywords" "R res resistor" (at 0 0 0) (effects (font (size 1.27 1.27)) hide))
(symbol "R_0_1"
(rectangle (start -1.016 -2.54) (end 1.016 2.54) (stroke (width 0.254) (type default)) (fill (type none)))
)
(symbol "R_1_1"
(pin passive line (at 0 3.81 270) (length 1.27)
(name "~" (effects (font (size 1.27 1.27)))) (number "1" (effects (font (size 1.27 1.27)))))
(pin passive line (at 0 -3.81 90) (length 1.27)
(name "~" (effects (font (size 1.27 1.27)))) (number "2" (effects (font (size 1.27 1.27)))))
)
)`,
C: ` (symbol "C" (pin_numbers hide) (pin_names (offset 0.254)) (exclude_from_sim no) (in_bom yes) (on_board yes)
(property "Reference" "C" (at 0.635 2.54 0) (effects (font (size 1.27 1.27)) (justify left)))
(property "Value" "C" (at 0.635 -2.54 0) (effects (font (size 1.27 1.27)) (justify left)))
(property "Footprint" "" (at 0.9652 -3.81 0) (effects (font (size 1.27 1.27)) hide))
(property "Datasheet" "~" (at 0 0 0) (effects (font (size 1.27 1.27)) hide))
(property "Description" "Capacitor (pcbjam spike)" (at 0 0 0) (effects (font (size 1.27 1.27)) hide))
(property "ki_keywords" "cap capacitor" (at 0 0 0) (effects (font (size 1.27 1.27)) hide))
(symbol "C_0_1"
(polyline (pts (xy -2.032 -0.762) (xy 2.032 -0.762)) (stroke (width 0.508) (type default)) (fill (type none)))
(polyline (pts (xy -2.032 0.762) (xy 2.032 0.762)) (stroke (width 0.508) (type default)) (fill (type none)))
)
(symbol "C_1_1"
(pin passive line (at 0 3.81 270) (length 2.794)
(name "~" (effects (font (size 1.27 1.27)))) (number "1" (effects (font (size 1.27 1.27)))))
(pin passive line (at 0 -3.81 90) (length 2.794)
(name "~" (effects (font (size 1.27 1.27)))) (number "2" (effects (font (size 1.27 1.27)))))
)
)`,
};
export type KicadLibsRequest = (
op: string,
lib: string,
arg: string,
) => Promise<string | null>;
declare global {
interface Window {
kicadLibs?: { request: KicadLibsRequest };
}
}
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));
/** Recover the lib id from a "/mnt/pcbjam/<id>" URI (arrives unmangled). */
function libIdFromUri(lib: string): string | null {
return lib.startsWith(PCBJAM_LIB_PREFIX)
? lib.slice(PCBJAM_LIB_PREFIX.length)
: null;
}
export function installSpikeLibsProvider(log: (msg: string) => void): void {
if (window.kicadLibs) return;
const delay = artificialDelayMs();
const request: KicadLibsRequest = async (op, lib, arg) => {
log(`[libs] request op=${op} lib=${lib} arg=${arg} (delay=${delay}ms)`);
if (libIdFromUri(lib) !== "spike") return null;
if (delay) await sleep(delay);
switch (op) {
case "list":
return JSON.stringify({ symbols: Object.keys(SYMBOLS) });
case "get": {
const body = SYMBOLS[arg];
return body ? wrapLib(body) : null;
}
default:
return null;
}
};
window.kicadLibs = { request };
log(`[libs] spike provider installed (${SPIKE_LIB_URI})`);
}

View file

@ -0,0 +1,78 @@
import type { LibInfo, LibItemInfo, LibsSource } from "./source";
/**
* A static, in-memory `LibsSource` two handcrafted symbols, no backend. The
* offline fallback when no API base is configured (and the origin of the libs
* 0002 spike). Keeps the editor's lib chooser populated in a bare checkout.
*/
const STATIC_LIB: LibInfo = {
id: "examples",
name: "pcbjam-examples",
description: "Built-in example symbols (offline)",
};
const wrapLib = (symbolBody: string) =>
`(kicad_symbol_lib (version 20241209) (generator "pcbjam") (generator_version "0.1")
${symbolBody}
)
`;
const SYMBOLS: Record<string, string> = {
R: ` (symbol "R" (pin_numbers hide) (pin_names (offset 0)) (exclude_from_sim no) (in_bom yes) (on_board yes)
(property "Reference" "R" (at 2.032 0 90) (effects (font (size 1.27 1.27))))
(property "Value" "R" (at 0 0 90) (effects (font (size 1.27 1.27))))
(property "Footprint" "" (at -1.778 0 90) (effects (font (size 1.27 1.27)) hide))
(property "Datasheet" "~" (at 0 0 0) (effects (font (size 1.27 1.27)) hide))
(property "Description" "Resistor (pcbjam example)" (at 0 0 0) (effects (font (size 1.27 1.27)) hide))
(property "ki_keywords" "R res resistor" (at 0 0 0) (effects (font (size 1.27 1.27)) hide))
(symbol "R_0_1"
(rectangle (start -1.016 -2.54) (end 1.016 2.54) (stroke (width 0.254) (type default)) (fill (type none)))
)
(symbol "R_1_1"
(pin passive line (at 0 3.81 270) (length 1.27)
(name "~" (effects (font (size 1.27 1.27)))) (number "1" (effects (font (size 1.27 1.27)))))
(pin passive line (at 0 -3.81 90) (length 1.27)
(name "~" (effects (font (size 1.27 1.27)))) (number "2" (effects (font (size 1.27 1.27)))))
)
)`,
C: ` (symbol "C" (pin_numbers hide) (pin_names (offset 0.254)) (exclude_from_sim no) (in_bom yes) (on_board yes)
(property "Reference" "C" (at 0.635 2.54 0) (effects (font (size 1.27 1.27)) (justify left)))
(property "Value" "C" (at 0.635 -2.54 0) (effects (font (size 1.27 1.27)) (justify left)))
(property "Footprint" "" (at 0.9652 -3.81 0) (effects (font (size 1.27 1.27)) hide))
(property "Datasheet" "~" (at 0 0 0) (effects (font (size 1.27 1.27)) hide))
(property "Description" "Capacitor (pcbjam example)" (at 0 0 0) (effects (font (size 1.27 1.27)) hide))
(property "ki_keywords" "cap capacitor" (at 0 0 0) (effects (font (size 1.27 1.27)) hide))
(symbol "C_0_1"
(polyline (pts (xy -2.032 -0.762) (xy 2.032 -0.762)) (stroke (width 0.508) (type default)) (fill (type none)))
(polyline (pts (xy -2.032 0.762) (xy 2.032 0.762)) (stroke (width 0.508) (type default)) (fill (type none)))
)
(symbol "C_1_1"
(pin passive line (at 0 3.81 270) (length 2.794)
(name "~" (effects (font (size 1.27 1.27)))) (number "1" (effects (font (size 1.27 1.27)))))
(pin passive line (at 0 -3.81 90) (length 2.794)
(name "~" (effects (font (size 1.27 1.27)))) (number "2" (effects (font (size 1.27 1.27)))))
)
)`,
};
export function staticLibsSource(): LibsSource {
return {
async listLibs(): Promise<LibInfo[]> {
return [STATIC_LIB];
},
async listItems(libId: string): Promise<LibItemInfo[]> {
if (libId !== STATIC_LIB.id) return [];
return Object.keys(SYMBOLS).map((name) => ({ kind: "symbol", name }));
},
async getItemBody(
libId: string,
_kind: string,
name: string,
): Promise<string | null> {
if (libId !== STATIC_LIB.id) return null;
const body = SYMBOLS[name];
return body ? wrapLib(body) : null;
},
};
}

View file

@ -0,0 +1,20 @@
/**
* pcbjam lib URIs are absolute POSIX paths under this mount. Absolute so that
* KiCad's lib-table URI expansion (ExpandURI -> wxFileName::MakeAbsolute) is a
* no-op and the path reaches the plugin/provider unmangled a "scheme://" URI
* gets rewritten to "/scheme:/..." against the cwd, which differs per project.
*/
export const PCBJAM_LIB_MOUNT = "/mnt/pcbjam";
export const PCBJAM_LIB_PREFIX = `${PCBJAM_LIB_MOUNT}/`;
/** The lib-table URI for a lib id. */
export function libUri(id: string): string {
return `${PCBJAM_LIB_PREFIX}${id}`;
}
/** Recover the lib id from a "/mnt/pcbjam/<id>" URI (arrives unmangled). */
export function libIdFromUri(uri: string): string | null {
return uri.startsWith(PCBJAM_LIB_PREFIX)
? uri.slice(PCBJAM_LIB_PREFIX.length)
: null;
}