fix(wasm): dynCall signature-mismatch fallback + eeschema collab wire converters
Two things, both verified in the real web app (two-tab eeschema collab). 1. dynCall crash fix (all apps) — scripts/common/shims/dyncall-binding.js.tmpl. Programmatic editor edits trapped with 'indirect call signature mismatch': the asyncify-instrumented wasmExports[dynCall_<sig>] trampoline does call_indirect with a stale type for some table indices (post-asyncify+O2) even though the table entry is valid. Proven by patching the built js: at the trap getWasmTableEntry(index) SUCCEEDS where the trampoline fails. Fix: the shim now catches the 'signature mismatch' RuntimeError and falls back to getWasmTableEntry; the Asyncify unwind sentinel and real exceptions re-throw, so instrumentation/unwind is untouched for normal calls. This unblocks ALL programmatic edits, not just collab (e.g. eeschema SCH_ITEM::Move). 2. eeschema collab apply converters (wasm/bindings/eeschema_embind.cpp). doApply now handles added-item construction (build the SCH_ITEM with the delta's uuid via const_cast — as the s-expr parser does — + commit.Add) and richer SCH_LINE serialization (start/end/layer) so wire edits reconstruct on the peer. Implemented for SCH_LINE (wires) + SCH_JUNCTION; other types log 'no converter for added type' and are skipped (next batch). eeschema re-enabled in the web app collab gate. Tests: eeschema-collab.spec snapshot (green); apply/two-tab skipped — they no-op headless because the e2e harness's kicadOpenFile returns false (OpenProjectFiles bails before building the connectivity graph), so SCH_COMMIT::Push doesn't persist. Verified in-app. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
4f8c2d1f56
commit
b5b1a0ac05
5 changed files with 228 additions and 55 deletions
|
|
@ -1,5 +1,6 @@
|
|||
#!/bin/bash
|
||||
# Post-process Emscripten-generated pcbnew.js for KiCad WASM.
|
||||
# Post-process the Emscripten-generated <app>.js for KiCad WASM (pcbnew, eeschema,
|
||||
# pl_editor, calculator, …).
|
||||
#
|
||||
# The actual JavaScript that gets injected lives in readable, standalone files in
|
||||
# scripts/common/shims/ (not inline heredocs):
|
||||
|
|
|
|||
|
|
@ -4,9 +4,22 @@
|
|||
//
|
||||
// Binds the bare name to the REAL asyncify-instrumented wasm export
|
||||
// (wasmExports["dynCall_<sig>"], present because the build links -sDYNCALLS=1).
|
||||
// Falls back to getWasmTableEntry only if no such export exists.
|
||||
// Falls back to getWasmTableEntry if no such export exists, OR if the instrumented
|
||||
// trampoline traps with "indirect call signature mismatch": post-asyncify+O2 the
|
||||
// trampoline can call_indirect with a stale type for some table indices even though
|
||||
// the table entry itself is valid (hit by programmatic editor edits, e.g. eeschema
|
||||
// SCH_ITEM::Move via invoke_vii — see features/yjs-bridge/0003). The direct
|
||||
// getWasmTableEntry call uses the correct per-entry signature and succeeds. Only the
|
||||
// mismatch trap is caught; the Asyncify unwind sentinel and real exceptions re-throw,
|
||||
// so instrumentation/unwind still work for every normal indirect call.
|
||||
function dynCall_@SIG@(@ARGS@) {
|
||||
var f = (typeof wasmExports !== 'undefined') && wasmExports["dynCall_@SIG@"];
|
||||
if (f) return f(@ARGS@);
|
||||
if (f) {
|
||||
try { return f(@ARGS@); }
|
||||
catch (_dce) {
|
||||
if (!(_dce instanceof WebAssembly.RuntimeError) || !/signature mismatch/.test(_dce.message))
|
||||
throw _dce;
|
||||
}
|
||||
}
|
||||
return getWasmTableEntry(index)(@CALLARGS@);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,20 +1,19 @@
|
|||
import { execSync } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { test, expect } from "./fixtures";
|
||||
|
||||
/**
|
||||
* eeschema Yjs collaborative bridge (features/yjs-bridge commit 3) — READ side.
|
||||
* eeschema Yjs collaborative bridge (features/yjs-bridge commit 3).
|
||||
*
|
||||
* eeschema reuses the same wire contract + generic JS reconciler as pl_editor; the new
|
||||
* code is the C++ adapter (native SCHEMATIC_LISTENER emit + SCH_COMMIT apply). The
|
||||
* read/emit half works (verified in the real web app: a real edit fires the listener and
|
||||
* broadcasts a delta). This spec covers what is reproducible headlessly: kicadCollabSnapshot
|
||||
* reflecting the schematic by uuid/type/position.
|
||||
*
|
||||
* APPLY is a known open follow-up (0003): editor write ops — specifically
|
||||
* SCH_ITEM::Move — trap with "indirect call signature mismatch" when invoked outside a
|
||||
* KiCad tool coroutine (Asyncify+fiber interaction). The apply/two-tab tests are skipped
|
||||
* until apply is routed through the tool framework. See
|
||||
* memory/eeschema-collab-asyncify-apply.
|
||||
* code is the C++ adapter — native SCHEMATIC_LISTENER emit + SCH_COMMIT apply, the latter
|
||||
* run inside a COROUTINE so SCH_ITEM::Move has the Asyncify/fiber (tool-coroutine) context
|
||||
* it requires. Coverage:
|
||||
* - snapshot (read): kicadCollabSnapshot reflects items by uuid/type/position.
|
||||
* - apply (single page): kicadCollabApply moves/removes by uuid (deferred via CallAfter
|
||||
* + coroutine, so poll for the result).
|
||||
* - two-tab: a real local move propagates A→B over BroadcastChannel.
|
||||
*/
|
||||
|
||||
const WIRE1 = "22222222-0000-0000-0000-000000000001";
|
||||
|
|
@ -36,6 +35,8 @@ type FS = { mkdirTree(p: string): void; writeFile(p: string, d: string): void };
|
|||
type Mod = {
|
||||
kicadOpenFile(p: string): unknown;
|
||||
kicadCollabSnapshot(): string;
|
||||
kicadCollabApply(j: string): unknown;
|
||||
kicadCollabTestMoveFirst(dx: number, dy: number): string;
|
||||
kicadCollabGetPos(id: string): string;
|
||||
};
|
||||
|
||||
|
|
@ -52,7 +53,9 @@ async function bootAndOpen(page: Page, name: string): Promise<void> {
|
|||
const m = (window as unknown as { Module?: Mod }).Module;
|
||||
return (
|
||||
typeof m?.kicadOpenFile === "function" &&
|
||||
typeof m?.kicadCollabSnapshot === "function"
|
||||
typeof m?.kicadCollabSnapshot === "function" &&
|
||||
typeof m?.kicadCollabApply === "function" &&
|
||||
typeof m?.kicadCollabTestMoveFirst === "function"
|
||||
);
|
||||
},
|
||||
null,
|
||||
|
|
@ -87,35 +90,122 @@ async function bootAndOpen(page: Page, name: string): Promise<void> {
|
|||
await expect.poll(() => page.title(), { timeout: 30000 }).toMatch(new RegExp(name, "i"));
|
||||
}
|
||||
|
||||
test.describe("eeschema collab bridge — snapshot (read side)", () => {
|
||||
test("kicadCollabSnapshot reflects schematic items by uuid/type/position", async ({
|
||||
page,
|
||||
testLogger,
|
||||
}) => {
|
||||
await bootAndOpen(page, "single");
|
||||
test.beforeAll(() => {
|
||||
execSync("node collab/build.mjs", { cwd: path.resolve(__dirname, ".."), stdio: "inherit" });
|
||||
});
|
||||
|
||||
test.describe("eeschema collab bridge — single page", () => {
|
||||
test("snapshot reflects schematic by uuid/type/position", async ({ page, testLogger }) => {
|
||||
await bootAndOpen(page, "snap");
|
||||
const snap = await page.evaluate(() => JSON.parse(window.Module.kicadCollabSnapshot()));
|
||||
const byId = new Map<string, { type: string; x: number; y: number }>(
|
||||
snap.added.map((i: { id: string; type: string; x: number; y: number }) => [i.id, i]),
|
||||
);
|
||||
|
||||
expect(byId.has(WIRE1)).toBe(true);
|
||||
expect(byId.has(WIRE2)).toBe(true);
|
||||
expect(byId.get(WIRE1)!.type).toBe("SCH_LINE");
|
||||
// 50.8 mm in eeschema internal units (×10000) = 508000.
|
||||
expect(byId.get(WIRE1)!.x).toBe(508000);
|
||||
expect(byId.get(WIRE1)!.y).toBe(508000);
|
||||
|
||||
// getPos resolves the same item by uuid.
|
||||
const pos = await page.evaluate((id) => window.Module.kicadCollabGetPos(id), WIRE1);
|
||||
expect(pos).toBe("508000,508000");
|
||||
|
||||
expect(byId.get(WIRE1)!.x).toBe(508000); // 50.8mm × 10000 IU
|
||||
expect(hasAbort(testLogger), "no WASM abort").toBe(false);
|
||||
});
|
||||
|
||||
// BLOCKED (0003 follow-up): apply traps on SCH_ITEM::Move outside a tool coroutine.
|
||||
// Re-enable once apply is routed through the TOOL_MANAGER. The read/emit side is proven
|
||||
// working in the real web app; only programmatic apply is affected.
|
||||
test.skip("apply moves an item by uuid (blocked: SCH_ITEM::Move coroutine trap)", () => {});
|
||||
test.skip("two-tab move propagates A→B (blocked: same apply trap)", () => {});
|
||||
// SKIP headless: verified working in the real web app (wire move/add/remove syncs A↔B),
|
||||
// but the e2e harness's kicadOpenFile returns false (OpenProjectFiles bails before the
|
||||
// connectivity graph is built — files-io.cpp), so SCH_COMMIT::Push no-ops here. Re-enable
|
||||
// once the harness loads a full project. (Crash-free + no-echo still hold; the move just
|
||||
// doesn't persist in this incomplete-load editor.)
|
||||
test.skip("apply moves/removes by uuid, no echo", async ({ page, testLogger }) => {
|
||||
await bootAndOpen(page, "apply");
|
||||
|
||||
const before = await page.evaluate((id) => window.Module.kicadCollabGetPos(id), WIRE1);
|
||||
const [bx, by] = before.split(",").map(Number);
|
||||
const nx = bx + 1_000_000;
|
||||
|
||||
await page.evaluate(() => {
|
||||
(window as unknown as { __echo: string[] }).__echo = [];
|
||||
(window as unknown as { kicadCollab: { onDelta: (j: string) => void } }).kicadCollab = {
|
||||
onDelta: (j: string) => (window as unknown as { __echo: string[] }).__echo.push(j),
|
||||
};
|
||||
});
|
||||
|
||||
// Move WIRE1 (deferred via CallAfter+coroutine → poll).
|
||||
await page.evaluate(
|
||||
({ id, nx, by }) =>
|
||||
window.Module.kicadCollabApply(
|
||||
JSON.stringify({ changed: [{ id, type: "SCH_LINE", x: nx, y: by }], added: [], removed: [] }),
|
||||
),
|
||||
{ id: WIRE1, nx, by },
|
||||
);
|
||||
await expect
|
||||
.poll(() => page.evaluate((id) => window.Module.kicadCollabGetPos(id), WIRE1), {
|
||||
timeout: 10000,
|
||||
intervals: [200],
|
||||
})
|
||||
.toBe(`${nx},${by}`);
|
||||
|
||||
// Remove WIRE2.
|
||||
await page.evaluate(
|
||||
(wire) =>
|
||||
window.Module.kicadCollabApply(JSON.stringify({ changed: [], added: [], removed: [wire] })),
|
||||
WIRE2,
|
||||
);
|
||||
await expect
|
||||
.poll(() => page.evaluate((id) => window.Module.kicadCollabGetPos(id), WIRE2), {
|
||||
timeout: 10000,
|
||||
intervals: [200],
|
||||
})
|
||||
.toBe("");
|
||||
|
||||
const echoes = await page.evaluate(() => (window as unknown as { __echo: string[] }).__echo);
|
||||
expect(echoes, "apply() must not echo a local onDelta").toHaveLength(0);
|
||||
expect(hasAbort(testLogger), "no WASM abort").toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("eeschema collab bridge — two tabs (BroadcastChannel)", () => {
|
||||
// SKIP headless for the same reason as the single-page apply test (harness open=false →
|
||||
// SCH_COMMIT no-ops). Verified working in the real web app.
|
||||
test.skip("a local move propagates A→B", async ({ context, testLogger }) => {
|
||||
const channel = `ee-collab-e2e-${test.info().workerIndex}`;
|
||||
const bundle = path.resolve(__dirname, "../apps/kicad/collab-bundle.js");
|
||||
|
||||
const tabA = await context.newPage();
|
||||
const tabB = await context.newPage();
|
||||
await bootAndOpen(tabA, "tabA");
|
||||
await bootAndOpen(tabB, "tabB");
|
||||
for (const p of [tabA, tabB]) await p.addScriptTag({ path: bundle });
|
||||
|
||||
const startCollab = (p: Page) =>
|
||||
p.evaluate(async (ch) => {
|
||||
const w = window as unknown as {
|
||||
KicadCollab: { start: (m: unknown, win: unknown, o: unknown) => Promise<unknown> };
|
||||
Module: unknown;
|
||||
};
|
||||
await w.KicadCollab.start(w.Module, window, { channel: ch, settleMs: 500 });
|
||||
}, channel);
|
||||
await startCollab(tabA);
|
||||
await startCollab(tabB);
|
||||
|
||||
const uuid = await tabA.evaluate(() => window.Module.kicadCollabTestMoveFirst(2_000_000, 0));
|
||||
expect(uuid).toMatch(/[0-9a-f-]{36}/);
|
||||
const orig = await tabA.evaluate((id) => window.Module.kicadCollabGetPos(id), uuid);
|
||||
|
||||
// Wait until tab A's item actually moved (guards against a no-op false pass).
|
||||
await expect
|
||||
.poll(() => tabA.evaluate((id) => window.Module.kicadCollabGetPos(id), uuid), {
|
||||
timeout: 15000,
|
||||
intervals: [300],
|
||||
})
|
||||
.not.toBe(orig);
|
||||
const posA = await tabA.evaluate((id) => window.Module.kicadCollabGetPos(id), uuid);
|
||||
|
||||
await expect
|
||||
.poll(() => tabB.evaluate((id) => window.Module.kicadCollabGetPos(id), uuid), {
|
||||
timeout: 15000,
|
||||
intervals: [300],
|
||||
})
|
||||
.toBe(posA);
|
||||
|
||||
expect(hasAbort(testLogger), "no WASM abort").toBe(false);
|
||||
await tabA.close();
|
||||
await tabB.close();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -19,10 +19,13 @@
|
|||
#include <wx/window.h>
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <kiid.h>
|
||||
#include <layer_ids.h>
|
||||
#include <schematic.h>
|
||||
#include <sch_edit_frame.h>
|
||||
#include <sch_commit.h>
|
||||
#include <sch_item.h>
|
||||
#include <sch_line.h>
|
||||
#include <sch_junction.h>
|
||||
#include <sch_screen.h>
|
||||
#include <sch_sheet_path.h>
|
||||
|
||||
|
|
@ -78,12 +81,53 @@ SCH_EDIT_FRAME* schFrame()
|
|||
json itemToJson( SCH_ITEM* aItem )
|
||||
{
|
||||
VECTOR2I p = aItem->GetPosition();
|
||||
return json{
|
||||
json j = {
|
||||
{ "id", toUtf8( aItem->m_Uuid.AsString() ) },
|
||||
{ "type", toUtf8( aItem->GetClass() ) },
|
||||
{ "x", p.x }, // internal units (nm); integral, so no quantization needed
|
||||
{ "x", p.x }, // internal units; integral, so no quantization needed
|
||||
{ "y", p.y },
|
||||
};
|
||||
|
||||
// Per-type fields needed to reconstruct the item on `added` (0003 converters).
|
||||
// Hand-mapped for the wire-editing types; other types sync position-only (move) and
|
||||
// are skipped on `added` (logged) until their converters are added.
|
||||
if( aItem->Type() == SCH_LINE_T )
|
||||
{
|
||||
auto* line = static_cast<SCH_LINE*>( aItem );
|
||||
j["sx"] = line->GetStartPoint().x;
|
||||
j["sy"] = line->GetStartPoint().y;
|
||||
j["ex"] = line->GetEndPoint().x;
|
||||
j["ey"] = line->GetEndPoint().y;
|
||||
j["layer"] = (int) line->GetLayer();
|
||||
}
|
||||
|
||||
return j;
|
||||
}
|
||||
|
||||
// Construct a new SCH_ITEM from a delta item (for `added`), with the delta's uuid
|
||||
// (m_Uuid is const → const_cast, exactly as the s-expr parser does). Returns nullptr for
|
||||
// types without a converter yet.
|
||||
SCH_ITEM* makeItem( const json& j )
|
||||
{
|
||||
std::string type = j.value( "type", "" );
|
||||
SCH_ITEM* item = nullptr;
|
||||
|
||||
if( type == "SCH_LINE" )
|
||||
{
|
||||
int layer = j.value( "layer", (int) LAYER_NOTES );
|
||||
auto* line = new SCH_LINE( VECTOR2I( j.value( "sx", 0 ), j.value( "sy", 0 ) ), layer );
|
||||
line->SetEndPoint( VECTOR2I( j.value( "ex", 0 ), j.value( "ey", 0 ) ) );
|
||||
item = line;
|
||||
}
|
||||
else if( type == "SCH_JUNCTION" )
|
||||
{
|
||||
item = new SCH_JUNCTION( VECTOR2I( j.value( "x", 0 ), j.value( "y", 0 ) ) );
|
||||
}
|
||||
|
||||
if( item )
|
||||
const_cast<KIID&>( item->m_Uuid ) = KIID( wxString::FromUTF8( j.value( "id", "" ).c_str() ) );
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
// Full current model as an array of item json, deduped by uuid across the hierarchy.
|
||||
|
|
@ -197,11 +241,10 @@ SCHEMATIC* ensureBridge()
|
|||
|
||||
namespace {
|
||||
|
||||
// The actual model mutation. Must run inside a KiCad tool coroutine — calling editor
|
||||
// write ops (notably SCH_ITEM::Move) outside one traps with "indirect call signature
|
||||
// mismatch" (the Asyncify+fiber+exception-trampoline machinery; see
|
||||
// memory/eeschema-collab-asyncify-apply / 0003). Routing apply through the tool
|
||||
// framework is the open follow-up; reads (GetPosition) and Clone/Modify already work.
|
||||
// The actual model mutation, via SCH_COMMIT so connectivity/ERC recompute as for a UI
|
||||
// edit. (Editor write ops like SCH_ITEM::Move are called through invoke_vii, whose
|
||||
// asyncify-instrumented dynCall trampoline traps on a stale type — fixed at the JS shim
|
||||
// layer in scripts/common/shims/dyncall-binding.js.tmpl; see 0003.)
|
||||
void doApply( SCH_EDIT_FRAME* aFrame, const json& aDelta )
|
||||
{
|
||||
SCHEMATIC& sch = aFrame->Schematic();
|
||||
|
|
@ -232,7 +275,14 @@ void doApply( SCH_EDIT_FRAME* aFrame, const json& aDelta )
|
|||
{
|
||||
commit.Modify( item, path.LastScreen() );
|
||||
|
||||
if( j.contains( "x" ) && j.contains( "y" ) )
|
||||
if( item->Type() == SCH_LINE_T && j.contains( "sx" ) )
|
||||
{
|
||||
// Wires reshape (endpoints move independently), so set both points.
|
||||
auto* line = static_cast<SCH_LINE*>( item );
|
||||
line->SetStartPoint( VECTOR2I( j["sx"].get<int>(), j["sy"].get<int>() ) );
|
||||
line->SetEndPoint( VECTOR2I( j["ex"].get<int>(), j["ey"].get<int>() ) );
|
||||
}
|
||||
else if( j.contains( "x" ) && j.contains( "y" ) )
|
||||
{
|
||||
VECTOR2I newPos( j["x"].get<int>(), j["y"].get<int>() );
|
||||
item->Move( newPos - item->GetPosition() );
|
||||
|
|
@ -242,8 +292,24 @@ void doApply( SCH_EDIT_FRAME* aFrame, const json& aDelta )
|
|||
}
|
||||
}
|
||||
|
||||
// TODO(0003 follow-up): `added` requires constructing the right SCH_ITEM subclass
|
||||
// per KICAD_T from JSON (no per-item blob path in eeschema — §serialization note).
|
||||
for( const json& j : aDelta.value( "added", json::array() ) )
|
||||
{
|
||||
KIID id( wxString::FromUTF8( j.value( "id", "" ).c_str() ) );
|
||||
|
||||
if( sch.ResolveItem( id, nullptr, /*allowNull*/ true ) )
|
||||
continue; // already present (our own echo)
|
||||
|
||||
if( SCH_ITEM* item = makeItem( j ) )
|
||||
{
|
||||
commit.Add( item, aFrame->GetScreen() );
|
||||
staged = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
EM_ASM( { console.log( "[collab] eeschema apply: no converter for added type " + UTF8ToString( $0 ) ); },
|
||||
j.value( "type", "?" ).c_str() );
|
||||
}
|
||||
}
|
||||
|
||||
if( staged )
|
||||
commit.Push( wxT( "Collaborative edit" ) );
|
||||
|
|
@ -251,6 +317,16 @@ void doApply( SCH_EDIT_FRAME* aFrame, const json& aDelta )
|
|||
s_applyingRemote = false;
|
||||
}
|
||||
|
||||
// Test/PoC move (the SCH_COMMIT body for kicadCollabTestMoveFirst, deferred via CallAfter).
|
||||
void collabTestMove( SCH_EDIT_FRAME* aFrame, SCH_ITEM* aItem, SCH_SCREEN* aScreen, int aDx,
|
||||
int aDy )
|
||||
{
|
||||
SCH_COMMIT commit( aFrame );
|
||||
commit.Modify( aItem, aScreen );
|
||||
aItem->Move( VECTOR2I( aDx, aDy ) );
|
||||
commit.Push( wxT( "Collab test move" ) );
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
|
||||
|
|
@ -275,6 +351,7 @@ void kicadCollabApply( std::string aJson )
|
|||
if( !fr )
|
||||
return;
|
||||
|
||||
// Defer to the editor's main-loop context so SCH_COMMIT runs like a normal edit.
|
||||
fr->CallAfter( [fr, delta]() { doApply( fr, delta ); } );
|
||||
}
|
||||
|
||||
|
|
@ -312,13 +389,7 @@ std::string kicadCollabTestMoveFirst( int aDx, int aDy )
|
|||
|
||||
for( SCH_ITEM* item : screen->Items() )
|
||||
{
|
||||
// Defer the SCH_COMMIT to the main loop (same Asyncify reason as apply).
|
||||
fr->CallAfter( [fr, item, screen, aDx, aDy]() {
|
||||
SCH_COMMIT commit( fr );
|
||||
commit.Modify( item, screen );
|
||||
item->Move( VECTOR2I( aDx, aDy ) );
|
||||
commit.Push( wxT( "Collab test move" ) );
|
||||
} );
|
||||
fr->CallAfter( [fr, item, screen, aDx, aDy]() { collabTestMove( fr, item, screen, aDx, aDy ); } );
|
||||
return toUtf8( item->m_Uuid.AsString() );
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,10 +8,8 @@ import { driveProjectIntoTool } from "@/wasm/kicad-runner";
|
|||
import type { CollabWindow } from "@/wasm/collab";
|
||||
import { clog, cwarn } from "@/wasm/collab/debug";
|
||||
|
||||
// Tools with a *fully working* collab bridge. eeschema's bridge exists (read/emit work)
|
||||
// but its apply traps on SCH_ITEM::Move outside a tool coroutine (features/yjs-bridge
|
||||
// 0003 follow-up), so it stays gated off to avoid crashing a peer tab.
|
||||
const COLLAB_TOOLS = new Set<Tool>(["pl_editor"]);
|
||||
// Tools with a working collab bridge (kicadCollabSnapshot/Apply embind exports).
|
||||
const COLLAB_TOOLS = new Set<Tool>(["pl_editor", "eeschema"]);
|
||||
|
||||
/**
|
||||
* Opt-in collaborative editing (features/yjs-bridge). Enabled when the URL carries
|
||||
|
|
|
|||
Loading…
Reference in a new issue