feat: libs 0009-S — footprint write-path spike DONE + verified (footprint editor as project-scoped WASM tool + PCB_IO_PCBJAM_FP plugin + bridge kind 4th-arg; New Footprint→FootprintSave fires through bridge on main thread, fork-native body v20251028, no wedge — Firefox e2e green); bump kicad + pcbjam-shared

This commit is contained in:
Gergő Törcsvári 2026-06-14 06:22:40 +02:00
commit 19fce866c1
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
12 changed files with 385 additions and 87 deletions

@ -1 +1 @@
Subproject commit df297291b0bdfd0228c900e9c1a0c7279ffd1533
Subproject commit 3cb0a2ff3f746e52d48d389c19154ffa076cc288

View file

@ -11,7 +11,10 @@ export const WASM_ASSET_BASE_URL =
import type { ProviderConfig, ProviderKind } from "@/wasm/collab";
import { remoteLibsSource } from "@/wasm/libs/remote-source";
import type { LibsSource } from "@/wasm/libs/source";
import { withSpikeWritableLib } from "@/wasm/libs/spike-writable";
import {
withSpikeWritableFpLib,
withSpikeWritableLib,
} from "@/wasm/libs/spike-writable";
import { staticLibsSource } from "@/wasm/libs/static-source";
/**
@ -78,14 +81,18 @@ export function libsSourceConfig(projectId?: string): LibsSource | null {
? staticLibsSource()
: remoteLibsSource(API_BASE_URL, libsOwner(), projectId);
// 0004-A spike: `?libwrite=1` adds one in-memory writable user lib so the
// 0004-A spike: `?libwrite=1` adds one in-memory writable user SYMBOL lib so the
// editor save path works with no backend (a dev/test aid). The real remote
// write path (0004-C) needs no flag — boot ensures a user lib via createLib.
if (
typeof window !== "undefined" &&
new URLSearchParams(window.location.search).get("libwrite") === "1"
) {
return withSpikeWritableLib(base, (m) => console.log(m));
// 0009-S spike: `?fpwrite=1` does the same for a writable FOOTPRINT lib.
if (typeof window !== "undefined") {
const params = new URLSearchParams(window.location.search);
if (params.get("fpwrite") === "1") {
return withSpikeWritableFpLib(base, (m) => console.log(m));
}
if (params.get("libwrite") === "1") {
return withSpikeWritableLib(base, (m) => console.log(m));
}
}
return base;

View file

@ -3,9 +3,15 @@ import {
KICAD_CONFIG_DIR,
RESOURCE_PATH,
TOOL_ARGV0,
TOOL_LIB_KIND,
TOOL_NEEDS_CONFIG_SEED,
} from "./constants";
import { buildSymLibTable, installLibsProvider, type LibsSource } from "./libs/source";
import {
buildFpLibTable,
buildSymLibTable,
installLibsProvider,
type LibsSource,
} from "./libs/source";
import { libUri, PCBJAM_LIB_MOUNT } from "./libs/uri";
/** The default user lib boot ensures exists, so there's a writable save target. */
@ -101,16 +107,20 @@ async function doBoot(opts: BootOptions): Promise<void> {
// 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";
let fpLibTable = "(fp_lib_table\n (version 7)\n)\n";
// Every lib gets an empty placeholder FILE at its URI (not just the mount dir):
// the symbol-editor save path stat()s the lib file after a successful save
// (SetSymModificationTime -> wxFileName::GetModificationTime), which errors on
// a non-existent path. The bytes are virtual (served via window.kicadLibs);
// this file only satisfies incidental fs checks.
// the editor save path stat()s the lib file after a successful save
// (symbol: SetSymModificationTime; footprint: setFPWatcher -> GetModificationTime),
// which errors on a non-existent path. The bytes are virtual (served via
// window.kicadLibs); this file only satisfies incidental fs checks.
let libPlaceholderUris: string[] = [];
if (libsSource) {
// Which lib table this tool consumes: symbol → sym-lib-table, footprint →
// fp-lib-table. The same lib source feeds whichever table the tool reads.
const libKind = TOOL_LIB_KIND[tool];
if (libsSource && libKind) {
installLibsProvider(libsSource, log);
try {
// Ensure the owner has at least one writable user lib to create symbols in.
// Ensure the owner has at least one writable user lib to save items into.
let libsList = await libsSource.listLibs();
if (libsSource.createLib && !libsList.some((l) => l.type === "user")) {
const created = await libsSource.createLib(DEFAULT_USER_LIB_NAME);
@ -119,9 +129,14 @@ async function doBoot(opts: BootOptions): Promise<void> {
log(`[libs] created default user lib "${created.name}"`);
}
}
symLibTable = buildSymLibTable(libsList);
if (libKind === "footprint") {
fpLibTable = buildFpLibTable(libsList);
log(`[libs] seeded ${libsList.length} lib(s) into fp-lib-table`);
} else {
symLibTable = buildSymLibTable(libsList);
log(`[libs] seeded ${libsList.length} lib(s) into sym-lib-table`);
}
libPlaceholderUris = libsList.map((l) => libUri(l.id));
log(`[libs] seeded ${libsList.length} lib(s) into sym-lib-table`);
} catch (e) {
log(`[libs] listLibs failed, seeding empty table: ${String(e)}`);
}
@ -222,13 +237,10 @@ async function doBoot(opts: BootOptions): Promise<void> {
2,
),
);
// libs: rows generated in doBoot from the lib source; the PCBJAM plugin
// resolves each via window.kicadLibs.
// libs: rows generated in doBoot from the lib source; the PCBJAM / PCBJAM_FP
// plugins resolve each via window.kicadLibs.
writeIfAbsent(`${KICAD_CONFIG_DIR}/sym-lib-table`, symLibTable);
writeIfAbsent(
`${KICAD_CONFIG_DIR}/fp-lib-table`,
"(fp_lib_table\n (version 7)\n)\n",
);
writeIfAbsent(`${KICAD_CONFIG_DIR}/fp-lib-table`, fpLibTable);
writeIfAbsent(
`${KICAD_CONFIG_DIR}/design-block-lib-table`,
"(design_block_lib_table\n (version 7)\n)\n",

View file

@ -26,6 +26,7 @@ export const TOOL_ARGV0: Record<Tool, string> = {
calculator: "/usr/bin/pcb_calculator",
pl_editor: "/usr/bin/pl_editor",
symbol_editor: "/usr/bin/symbol_editor",
footprint_editor: "/usr/bin/footprint_editor",
gerbview: "/usr/bin/gerbview",
};
@ -44,9 +45,26 @@ export const TOOL_NEEDS_CONFIG_SEED: Record<Tool, boolean> = {
calculator: true,
pl_editor: true,
symbol_editor: true,
footprint_editor: true,
gerbview: true,
};
/**
* Which library kind a tool consumes drives which lib-table boot populates
* from the lib source (symbol sym-lib-table; footprint fp-lib-table). A
* user lib is a kind-agnostic container, so the same lib id can land in both
* tables depending on the tool. `null` = the tool uses no libraries.
*/
export const TOOL_LIB_KIND: Record<Tool, "symbol" | "footprint" | null> = {
pcbnew: "footprint",
eeschema: "symbol",
calculator: null,
pl_editor: null,
symbol_editor: "symbol",
footprint_editor: "footprint",
gerbview: null,
};
/** KiCad user settings dir for this build (PATHS::GetUserSettingsPath()). */
export const KICAD_CONFIG_DIR = `/home/kicad/.config/kicad/kicad/${KICAD_VERSION_DIR}`;

View file

@ -48,11 +48,18 @@ export interface LibsSource {
createLib?(name: string): Promise<LibInfo | null>;
}
/** The function the WASM `SCH_IO_PCBJAM_LIB` plugin calls via the JS bridge. */
/**
* 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,
) => Promise<string | null>;
declare global {
@ -89,10 +96,32 @@ export function buildSymLibTable(libsList: LibInfo[]): string {
}
/**
* Install `window.kicadLibs` backed by a `LibsSource`. The plugin calls
* `request(op, "/mnt/pcbjam[-rw]/<id>", arg)`:
* "list" -> JSON {"symbols":[...]} (symbol names in the lib)
* "get" -> the item body s-expr (arg = symbol name; null if absent)
* 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/<id> 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/<id>", 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":..})
*/
export function installLibsProvider(
@ -102,9 +131,9 @@ export function installLibsProvider(
if (window.kicadLibs) return;
const delay = artificialDelayMs();
const request: KicadLibsRequest = async (op, lib, arg) => {
const request: KicadLibsRequest = async (op, lib, arg, kind = "symbol") => {
const id = libIdFromUri(lib);
log(`[libs] request op=${op} lib=${lib} (id=${id}) arg=${arg}`);
log(`[libs] request op=${op} kind=${kind} lib=${lib} (id=${id}) arg=${arg}`);
if (!id) return null;
if (delay) await sleep(delay);
@ -112,13 +141,15 @@ export function installLibsProvider(
switch (op) {
case "list": {
const items = await source.listItems(id);
const symbols = items
.filter((i) => i.kind === "symbol")
const names = items
.filter((i) => i.kind === kind)
.map((i) => i.name);
return JSON.stringify({ symbols });
// Each plugin parses its own key: footprints / symbols.
const key = kind === "footprint" ? "footprints" : "symbols";
return JSON.stringify({ [key]: names });
}
case "get":
return await source.getItemBody(id, "symbol", arg);
return await source.getItemBody(id, kind, arg);
case "save": {
let parsed: { name?: string; body?: string };
try {
@ -134,7 +165,7 @@ export function installLibsProvider(
}
const ok = await source.saveItemBody(
id,
"symbol",
kind,
parsed.name,
parsed.body,
);

View file

@ -1,30 +1,39 @@
import type { LibInfo, LibItemInfo, LibsSource } from "./source";
/**
* 0004-A write-path spike (no backend). Wraps a `LibsSource` with ONE in-memory
* writable user lib so the editor's save-symbol flow has a target and we can
* exercise the full save enumerate load round-trip through the same WASM
* bridge without committing to the backend design first. Gated by
* `?libwrite=1`. Saved bodies are mirrored onto `window.__pcbjamSaved` so the
* Playwright probe can inspect them. Replaced in 0004-C by real remote writes.
* Write-path spike (no backend). Wraps a `LibsSource` with ONE in-memory writable
* user lib so the editor's save flow has a target and we can exercise the full
* save enumerate load round-trip through the same WASM bridge without
* committing to the backend design first.
*
* - Symbols (0004-A): `?libwrite=1` `withSpikeWritableLib`.
* - Footprints (0009-S): `?fpwrite=1` `withSpikeWritableFpLib`.
*
* Saved bodies are mirrored onto `window.__pcbjamSaved` so the Playwright probe
* can inspect them. Replaced by real remote writes once the backend lands.
*/
const SPIKE_RW_LIB_ID = "spike-user";
const SPIKE_RW_LIB_NAME = "My Symbols (spike)";
declare global {
interface Window {
__pcbjamSaved?: Record<string, string>;
}
}
export function withSpikeWritableLib(
interface SpikeLibSpec {
id: string;
name: string;
kind: "symbol" | "footprint";
}
function withSpikeKindLib(
inner: LibsSource | null,
log: (msg: string) => void,
spec: SpikeLibSpec,
): LibsSource {
const store = new Map<string, string>(); // symbol name -> body
window.__pcbjamSaved = Object.create(null) as Record<string, string>;
const store = new Map<string, string>(); // item name -> body
// Don't clobber an existing capture map (so symbol + footprint spikes coexist).
window.__pcbjamSaved ??= Object.create(null) as Record<string, string>;
const isSpike = (libId: string) => libId === SPIKE_RW_LIB_ID;
const isSpike = (libId: string) => libId === spec.id;
return {
async listLibs(): Promise<LibInfo[]> {
@ -37,15 +46,12 @@ export function withSpikeWritableLib(
} catch (e) {
log(`[libs] spike: inner listLibs failed, origins omitted: ${String(e)}`);
}
return [
...base,
{ id: SPIKE_RW_LIB_ID, name: SPIKE_RW_LIB_NAME, type: "user" },
];
return [...base, { id: spec.id, name: spec.name, type: "user" }];
},
async listItems(libId: string): Promise<LibItemInfo[]> {
if (isSpike(libId))
return [...store.keys()].map((name) => ({ kind: "symbol", name }));
return [...store.keys()].map((name) => ({ kind: spec.kind, name }));
return inner ? inner.listItems(libId) : [];
},
@ -71,8 +77,32 @@ export function withSpikeWritableLib(
}
store.set(name, body);
window.__pcbjamSaved![name] = body;
log(`[libs] spike saved symbol "${name}" (${body.length} bytes)`);
log(`[libs] spike saved ${spec.kind} "${name}" (${body.length} bytes)`);
return true;
},
};
}
/** 0004-A symbol write spike (`?libwrite=1`). */
export function withSpikeWritableLib(
inner: LibsSource | null,
log: (msg: string) => void,
): LibsSource {
return withSpikeKindLib(inner, log, {
id: "spike-user",
name: "My Symbols (spike)",
kind: "symbol",
});
}
/** 0009-S footprint write spike (`?fpwrite=1`). */
export function withSpikeWritableFpLib(
inner: LibsSource | null,
log: (msg: string) => void,
): LibsSource {
return withSpikeKindLib(inner, log, {
id: "spike-fp-user",
name: "My Footprints (spike)",
kind: "footprint",
});
}