fix(load): close the dispatch-interlock hole at open + open gerbers from a project route
- kicadOpenFile now holds wxWasmDispatchGuard (open_gate.h). It enters through embind, so the interlock read "nothing parked" for the whole load and wx timers dispatched into the half-built board — the residual prod "index out of bounds" that survived the settle gate. - new wasm/bindings/gerbview_embind.cpp (the bundle had no embind surface at all): kicadOpenFile / kicadOpenFiles / kicadOpenFileBusy. Clicking one gerber opens the whole fabrication set in its folder, since a lone layer is not a useful view. - cross-app presence rejoins in the boot fan-out (network-only; the wasm-bound half still waits for the open to settle) — it had been pushed behind the board load. - tests: gerber-set selection units + a gerbview multi-file open e2e. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0137pGo8W7asomGUTRMB7RzM
This commit is contained in:
parent
a26ef4ebeb
commit
d35cf4f4eb
7 changed files with 372 additions and 23 deletions
|
|
@ -13,8 +13,30 @@ import { waitForEditorReady, stableShot } from '../e2e/utils/element-tracker';
|
|||
*
|
||||
* Determinism: no waitForTimeout, no wizard click-through loop, screenshots via
|
||||
* stableShot (stabilizes before comparing).
|
||||
*
|
||||
* The embind file-open surface (wasm/bindings/gerbview_embind.cpp) IS in scope:
|
||||
* the project page deep-links a gerber here, and the shell opens the whole
|
||||
* fabrication set through `kicadOpenFiles`.
|
||||
*/
|
||||
|
||||
/** Minimal valid RS-274X gerber drawing one trace, so a layer really loads. */
|
||||
function gerber(xEndMm: number): string {
|
||||
return [
|
||||
'%FSLAX46Y46*%',
|
||||
'%MOMM*%',
|
||||
'%ADD10C,0.200000*%',
|
||||
'D10*',
|
||||
'X10000000Y10000000D02*',
|
||||
`X${xEndMm * 1_000_000}Y10000000D01*`,
|
||||
'M02*',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/** Minimal Excellon drill file — GerbView routes .drl to its own loader. */
|
||||
const DRILL = ['M48', 'FMAT,2', 'METRIC', 'T1C0.800', '%', 'G90', 'G05', 'T1',
|
||||
'X20.0Y20.0', 'T0', 'M30', ''].join('\n');
|
||||
|
||||
function hasAbort(testLogger: { consoleLogs: string[]; errors: string[] }): boolean {
|
||||
return [...testLogger.consoleLogs, ...testLogger.errors].some(line => line.includes('Aborted('));
|
||||
}
|
||||
|
|
@ -62,4 +84,64 @@ test.describe('gerbview WASM', () => {
|
|||
expect(metrics.glCanvasOk, 'GL canvas has nonzero dimensions').toBe(true);
|
||||
expect(hasAbort(testLogger)).toBe(false);
|
||||
});
|
||||
|
||||
/**
|
||||
* kicadOpenFiles: the whole-set entry the project page's gerber links use.
|
||||
* A fabrication set is a stack, so opening one layer alone is not the job —
|
||||
* this asserts a multi-file open lands every layer (and the drill file) in
|
||||
* one call, which is what GERBVIEW_FRAME::OpenProjectFiles gives us.
|
||||
*/
|
||||
test('kicadOpenFiles opens a whole fabrication set in one call', async ({ page, testLogger }) => {
|
||||
await waitForEditorReady(page);
|
||||
|
||||
const opened = await page.evaluate(({ gerbers, drill }) => {
|
||||
const w = window as unknown as {
|
||||
FS: { mkdirTree(p: string): void; writeFile(p: string, d: string): void };
|
||||
Module: { kicadOpenFiles?: (json: string) => boolean };
|
||||
};
|
||||
const dir = '/home/kicad/documents/fab';
|
||||
w.FS.mkdirTree(dir);
|
||||
const paths: string[] = [];
|
||||
for (const [name, content] of Object.entries(gerbers)) {
|
||||
const p = `${dir}/${name}`;
|
||||
w.FS.writeFile(p, content as string);
|
||||
paths.push(p);
|
||||
}
|
||||
const drillPath = `${dir}/board-PTH.drl`;
|
||||
w.FS.writeFile(drillPath, drill);
|
||||
paths.push(drillPath);
|
||||
const registryBefore = window.wxElementRegistry!.findAll({ visible: true }).length;
|
||||
if (typeof w.Module.kicadOpenFiles !== 'function') return { hook: false, registryBefore };
|
||||
w.Module.kicadOpenFiles(JSON.stringify(paths));
|
||||
return { hook: true, registryBefore };
|
||||
}, {
|
||||
gerbers: {
|
||||
'board-F_Cu.gbr': gerber(30),
|
||||
'board-B_Cu.gbr': gerber(40),
|
||||
'board-Edge_Cuts.gbr': gerber(50),
|
||||
},
|
||||
drill: DRILL,
|
||||
});
|
||||
|
||||
expect(opened.hook, 'gerbview exposes kicadOpenFiles (gerbview_embind.cpp)').toBe(true);
|
||||
// NOT the return value: OpenProjectFiles parks under Asyncify, so the
|
||||
// embind call unwinds and hands back a falsy placeholder long before the
|
||||
// load finishes (same reason open-flow.ts ignores kicadOpenFile's bool).
|
||||
// The truthful completion signal is the open-gate probe.
|
||||
await expect.poll(
|
||||
async () => page.evaluate(() => {
|
||||
const w = window as unknown as { Module: { kicadOpenFileBusy?: () => boolean } };
|
||||
return w.Module.kicadOpenFileBusy?.() ?? true;
|
||||
}),
|
||||
{ timeout: 30000, intervals: [250] },
|
||||
).toBe(false);
|
||||
|
||||
// Each file became its own draw layer, so the UI gained rows/entries.
|
||||
expect(
|
||||
await page.evaluate(() => window.wxElementRegistry!.findAll({ visible: true }).length),
|
||||
'the layers UI grew once the set loaded',
|
||||
).toBeGreaterThan(opened.registryBefore);
|
||||
|
||||
expect(hasAbort(testLogger), 'no WASM abort during the multi-file open').toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
91
wasm/bindings/gerbview_embind.cpp
Normal file
91
wasm/bindings/gerbview_embind.cpp
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
/*
|
||||
* GerbView embind bindings.
|
||||
*
|
||||
* GerbView boots without a document (it is one of `FILELESS_TOOLS`), but a
|
||||
* project route CAN name a gerber: clicking `Production/gerbers/board-F_Cu.gbr`
|
||||
* on the project page deep-links here. Until this TU existed the bundle had no
|
||||
* embind surface at all, so the shell staged the file into MEMFS and then had
|
||||
* no way to say "open it" — GerbView came up empty and the user had to walk
|
||||
* File→Open themselves.
|
||||
*
|
||||
* A gerber is rarely useful alone (a fabrication set is a stack of layers plus
|
||||
* drill files), and `GERBVIEW_FRAME::OpenProjectFiles` already takes a LIST and
|
||||
* auto-routes each entry by filename to the gerber / Excellon / job / archive
|
||||
* loader, then zoom-fits. So the primary entry point here is the multi-file
|
||||
* one: the shell hands over every gerber+drill sibling in the clicked file's
|
||||
* folder, and the whole board renders.
|
||||
*/
|
||||
#include <emscripten.h>
|
||||
#include <emscripten/bind.h>
|
||||
#include <gerbview_frame.h>
|
||||
#include <kiway_player.h>
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <wx/app.h>
|
||||
#include <wx/string.h>
|
||||
#include "open_gate.h"
|
||||
|
||||
using namespace emscripten;
|
||||
using json = nlohmann::json;
|
||||
|
||||
static GERBVIEW_FRAME* gerbFrame()
|
||||
{
|
||||
return wxTheApp ? dynamic_cast<GERBVIEW_FRAME*>( wxTheApp->GetTopWindow() ) : nullptr;
|
||||
}
|
||||
|
||||
static bool openFileSet( const std::vector<wxString>& aFiles )
|
||||
{
|
||||
// Held across every Asyncify park of the load (open_gate.h): the layer load
|
||||
// parks, and a wx timer dispatched into a half-built layer set traps.
|
||||
pcbjam_open::BusyGuard busy;
|
||||
|
||||
GERBVIEW_FRAME* frame = gerbFrame();
|
||||
|
||||
if( !frame || aFiles.empty() )
|
||||
return false;
|
||||
|
||||
return frame->OpenProjectFiles( aFiles, 0 );
|
||||
}
|
||||
|
||||
/** Open ONE gerber/drill file (the generic single-file entry every app has). */
|
||||
static bool kicadOpenFile( std::string path )
|
||||
{
|
||||
return openFileSet( { wxString::FromUTF8( path.c_str() ) } );
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a whole fabrication set: a JSON array of MEMFS paths. Order is the
|
||||
* caller's (the shell sorts, so layer order is stable across reloads).
|
||||
* GERBVIEW_FRAME caps the set at GERBER_DRAWLAYERS_COUNT internally.
|
||||
*/
|
||||
static bool kicadOpenFiles( std::string pathsJson )
|
||||
{
|
||||
json paths = json::parse( pathsJson, nullptr, /*allow_exceptions*/ false );
|
||||
|
||||
if( !paths.is_array() )
|
||||
return false;
|
||||
|
||||
std::vector<wxString> files;
|
||||
|
||||
for( const auto& entry : paths )
|
||||
{
|
||||
if( entry.is_string() )
|
||||
files.push_back( wxString::FromUTF8( entry.get<std::string>().c_str() ) );
|
||||
}
|
||||
|
||||
return openFileSet( files );
|
||||
}
|
||||
|
||||
/** JS-pollable open-in-flight probe — same contract as the editors. */
|
||||
static bool kicadOpenFileBusy()
|
||||
{
|
||||
return pcbjam_open::busy();
|
||||
}
|
||||
|
||||
EMSCRIPTEN_BINDINGS( gerbview )
|
||||
{
|
||||
function( "kicadOpenFile", &kicadOpenFile );
|
||||
function( "kicadOpenFiles", &kicadOpenFiles );
|
||||
function( "kicadOpenFileBusy", &kicadOpenFileBusy );
|
||||
}
|
||||
|
|
@ -18,6 +18,8 @@
|
|||
*/
|
||||
#pragma once
|
||||
|
||||
#include <wx/wasm/private/dispatch.h>
|
||||
|
||||
namespace pcbjam_open
|
||||
{
|
||||
|
||||
|
|
@ -27,10 +29,30 @@ inline int& busyCount()
|
|||
return s_count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Held for the whole open. Two counters, same Asyncify-RAII trick:
|
||||
*
|
||||
* - `busyCount` is OURS: it answers kicadOpenFileBusy() for the web shell and
|
||||
* gates the collab entries (JS → embind reentry).
|
||||
* - `wxWasmDispatchGuard` enrolls the open in the WX DISPATCH INTERLOCK. This
|
||||
* matters because `kicadOpenFile` enters through embind, not through a wx
|
||||
* dispatch entry point, so without it `wxWasmDispatchParked()` reads FALSE
|
||||
* for the entire load: every park (progress pump, thread-pool futex wait,
|
||||
* lib bridge) lets the pump dispatch a QUEUED WX TIMER into the half-built
|
||||
* board — src/wasm/timer.cpp fires it because nothing looks parked — and
|
||||
* the handler walks half-mutated widget/board state ("index out of bounds",
|
||||
* the same signature as the symbol-chooser crash the interlock was built
|
||||
* for). Holding the guard makes those timers defer (retry 17 ms later)
|
||||
* until the load truly completes. Paints keep running; the progress
|
||||
* dialog's own pump is the designed exception (it zeroes the count).
|
||||
*/
|
||||
struct BusyGuard
|
||||
{
|
||||
BusyGuard() { ++busyCount(); }
|
||||
~BusyGuard() { --busyCount(); }
|
||||
|
||||
private:
|
||||
wxWasmDispatchGuard m_dispatch;
|
||||
};
|
||||
|
||||
/** JS-pollable: is a kicadOpenFile chain still in flight (possibly parked)? */
|
||||
|
|
|
|||
|
|
@ -1445,6 +1445,44 @@ export function WasmTool({
|
|||
return { error };
|
||||
}
|
||||
})();
|
||||
// Project presence room, same fan-out slot as the doc room: it needs
|
||||
// identity and a socket, NOT the wasm, so the websocket handshake
|
||||
// happens while the wasm still downloads instead of queueing behind a
|
||||
// multi-second board load (which also starves the handshake — Firefox
|
||||
// drops it as "interrupted while the page was loading"). Only the
|
||||
// wasm-bound half (bindKicadPresence) waits for the open to settle.
|
||||
// Never rejects: presence is best-effort, exactly as before.
|
||||
// Read-only viewers skip the room entirely — the server rejects their
|
||||
// connection anyway (presence requires write).
|
||||
const collabOptOut =
|
||||
new URLSearchParams(win.location.search).get("collab") === "0" ||
|
||||
new URLSearchParams(win.location.search).get("collab") === "false";
|
||||
const crossAppReady: Promise<CrossAppHandle | undefined> =
|
||||
(tool === "pcbnew" || tool === "eeschema") && !collabOptOut && !readOnly
|
||||
? (async () => {
|
||||
try {
|
||||
await identityReady;
|
||||
return await startCrossAppPresence({
|
||||
scopeId,
|
||||
projectId,
|
||||
provider: yjsProviderConfig(),
|
||||
user: presenceUser(),
|
||||
tool,
|
||||
});
|
||||
} catch (err) {
|
||||
append(`[collab] cross-app presence connect failed: ${String(err)}`);
|
||||
return undefined;
|
||||
}
|
||||
})()
|
||||
: Promise.resolve(undefined);
|
||||
// Take ownership the moment it lands — a boot that dies before the
|
||||
// handoff below would otherwise leave this socket open, since unmount
|
||||
// only tears down what reached crossAppRef.
|
||||
void crossAppReady.then((h) => {
|
||||
if (!h) return;
|
||||
if (presyncAbort.signal.aborted) h.destroy(); // unmounted mid-connect
|
||||
else crossAppRef.current = h;
|
||||
});
|
||||
await bootKicadTool({
|
||||
tool,
|
||||
base,
|
||||
|
|
@ -1570,25 +1608,14 @@ export function WasmTool({
|
|||
// Cross-app selection (0006): join the project-wide presence room BEFORE
|
||||
// the per-file collab starts, so the first startPresence bind already
|
||||
// routes xsel. Honors the same ?collab=0 opt-out as the room collab.
|
||||
const collabOptOut =
|
||||
new URLSearchParams(win.location.search).get("collab") === "0" ||
|
||||
new URLSearchParams(win.location.search).get("collab") === "false";
|
||||
// Read-only viewers skip the project presence room entirely — the
|
||||
// server rejects their connection anyway (presence requires write).
|
||||
if ((tool === "pcbnew" || tool === "eeschema") && !collabOptOut && !readOnly) {
|
||||
crossAppRef.current =
|
||||
(await startCrossAppPresence({
|
||||
scopeId,
|
||||
projectId,
|
||||
provider: yjsProviderConfig(),
|
||||
user: presenceUser(),
|
||||
tool,
|
||||
})) ?? null;
|
||||
// Test/debug handle (mirrors __pcbjamComments): lets the e2e assert
|
||||
// the project-room peer view without driving pixels.
|
||||
(win as { __pcbjamCrossApp?: CrossAppHandle | null }).__pcbjamCrossApp =
|
||||
crossAppRef.current;
|
||||
}
|
||||
// Cross-app presence: the ROOM was joined back in the boot fan-out
|
||||
// (pure network + Y.Doc, no wasm) — only the handoff to the wasm-bound
|
||||
// presence below has to wait for the open. Settle it here.
|
||||
crossAppRef.current = (await crossAppReady) ?? null;
|
||||
// Test/debug handle (mirrors __pcbjamComments): lets the e2e assert
|
||||
// the project-room peer view without driving pixels.
|
||||
(win as { __pcbjamCrossApp?: CrossAppHandle | null }).__pcbjamCrossApp =
|
||||
crossAppRef.current;
|
||||
|
||||
if (tool === "eeschema") {
|
||||
// Multi-room (subschema) collab: every .kicad_sch is its own warm room; the
|
||||
|
|
|
|||
48
web/standalone/src/wasm/gerber-set.test.ts
Normal file
48
web/standalone/src/wasm/gerber-set.test.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { gerberSiblings } from "./kicad-runner";
|
||||
|
||||
/**
|
||||
* Clicking one gerber on the project page opens the whole fabrication SET in
|
||||
* its folder (a single copper layer alone is a near-empty canvas). Drill files
|
||||
* come along — GerbView routes them to the Excellon loader itself.
|
||||
*/
|
||||
const files = (...paths: string[]) => paths.map((path) => ({ path }));
|
||||
|
||||
describe("gerberSiblings", () => {
|
||||
const SET = [
|
||||
"Production/gerbers/board-B_Cu.gbr",
|
||||
"Production/gerbers/board-Edge_Cuts.gbr",
|
||||
"Production/gerbers/board-F_Cu.gbr",
|
||||
"Production/gerbers/board-PTH.drl",
|
||||
];
|
||||
|
||||
it("collects every gerber and drill file in the clicked folder, sorted", () => {
|
||||
expect(
|
||||
gerberSiblings(files(...SET), "Production/gerbers/board-F_Cu.gbr"),
|
||||
).toEqual(SET);
|
||||
});
|
||||
|
||||
it("ignores other folders, nested subfolders, and non-gerber files", () => {
|
||||
const all = files(
|
||||
...SET,
|
||||
"Production/gerbers/readme.md",
|
||||
"Production/gerbers/old/board-F_Cu.gbr",
|
||||
"Production/other/board-F_Cu.gbr",
|
||||
"KiCad Projects/board.kicad_pcb",
|
||||
);
|
||||
expect(gerberSiblings(all, "Production/gerbers/board-F_Cu.gbr")).toEqual(SET);
|
||||
});
|
||||
|
||||
it("handles a gerber at the project root", () => {
|
||||
const all = files("a-F_Cu.gbr", "a-B_Cu.gbr", "sub/deep.gbr");
|
||||
expect(gerberSiblings(all, "a-F_Cu.gbr")).toEqual(["a-B_Cu.gbr", "a-F_Cu.gbr"]);
|
||||
});
|
||||
|
||||
it("always includes the clicked file, even with an unrecognised extension", () => {
|
||||
const all = files("g/board-F_Cu.gbr", "g/weird.xyz");
|
||||
expect(gerberSiblings(all, "g/weird.xyz")).toEqual([
|
||||
"g/weird.xyz",
|
||||
"g/board-F_Cu.gbr",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import type { Tool } from "@pcbjam/shared";
|
||||
import { FILELESS_TOOLS } from "@pcbjam/shared";
|
||||
import { FILELESS_TOOLS, toolForFile } from "@pcbjam/shared";
|
||||
import { defaultKicadPro } from "../lib/new-file";
|
||||
import { memfsFilePath, memfsProjectDir } from "./constants";
|
||||
import { prescanBoardModels } from "./libs/models-bridge";
|
||||
|
|
@ -137,6 +137,70 @@ function synthesizeProjectFile(win: ToolWindow, opts: DriveOptions): void {
|
|||
opts.log(`[memfs] synthesized ${proPath} (project has no project file)`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every gerber/drill sibling of `targetPath`, in stable filename order.
|
||||
*
|
||||
* A fabrication set is a stack — copper layers, mask, silk, edge cuts, plus the
|
||||
* Excellon drill files — and they are conventionally emitted into one folder.
|
||||
* Opening only the clicked layer shows a near-empty canvas, so the clicked
|
||||
* FOLDER is the real unit. `toolForFile` owns the extension list (it is what
|
||||
* decided this route is gerbview in the first place); drill files come along
|
||||
* because GerbView routes them to the Excellon loader itself.
|
||||
*/
|
||||
export function gerberSiblings(files: ToolFile[], targetPath: string): string[] {
|
||||
const slash = targetPath.lastIndexOf("/");
|
||||
const dir = slash < 0 ? "" : targetPath.slice(0, slash + 1);
|
||||
const inDir = files
|
||||
.map((f) => f.path)
|
||||
.filter((path) => {
|
||||
if (!path.startsWith(dir)) return false;
|
||||
if (path.slice(dir.length).includes("/")) return false; // nested deeper
|
||||
return toolForFile(path) === "gerbview";
|
||||
})
|
||||
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
|
||||
// The clicked file always opens, even if its extension is unusual enough that
|
||||
// toolForFile missed it (the route named it, so the user meant it).
|
||||
return inDir.includes(targetPath) ? inDir : [targetPath, ...inDir];
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a whole gerber set in GerbView. Prefers the multi-file embind entry
|
||||
* (`kicadOpenFiles`); a bundle predating it falls back to the single-file open
|
||||
* of just the clicked layer, which is the old behavior and still renders.
|
||||
*/
|
||||
async function openGerberSet(
|
||||
win: ToolWindow,
|
||||
opts: DriveOptions,
|
||||
): Promise<"programmatic" | "ui" | "failed"> {
|
||||
const paths = gerberSiblings(opts.files, opts.targetPath!);
|
||||
const abs = paths.map((path) => memfsFilePath(opts.slug, path));
|
||||
return openFileInTool(win, abs[0]!, {
|
||||
log: opts.log,
|
||||
open: () => {
|
||||
// Feature-detect HERE, not before the call: embind registers the Module
|
||||
// functions during runtime init, which lands AFTER the Emscripten FS is
|
||||
// ready — i.e. after we get here from driveProjectIntoTool. Probing any
|
||||
// earlier always misses and silently degrades to the single-file open
|
||||
// (openFileInTool's frame wait is what guarantees the exports exist).
|
||||
const mod = win.Module as
|
||||
| {
|
||||
kicadOpenFiles?: (json: string) => boolean;
|
||||
kicadOpenFile?: (path: string) => unknown;
|
||||
}
|
||||
| undefined;
|
||||
if (typeof mod?.kicadOpenFiles === "function") {
|
||||
opts.log(`[open] gerbview: opening ${abs.length} file(s) from ${opts.targetPath}`);
|
||||
mod.kicadOpenFiles(JSON.stringify(abs));
|
||||
return;
|
||||
}
|
||||
opts.log(
|
||||
"[open] gerbview bundle has no kicadOpenFiles — opening the clicked layer only",
|
||||
);
|
||||
mod?.kicadOpenFile?.(abs[0]!);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
|
@ -166,7 +230,14 @@ export async function driveProjectIntoTool(
|
|||
synthesizeProjectFile(win, opts);
|
||||
|
||||
let result: "programmatic" | "ui" | "failed" | "none" = "none";
|
||||
if (opts.targetPath && !FILELESS_TOOLS.has(opts.tool)) {
|
||||
if (opts.targetPath && opts.tool === "gerbview") {
|
||||
// GerbView boots fileless, but a project route can still name a gerber —
|
||||
// and a single layer on its own is not a useful view, so open the whole
|
||||
// fabrication set that lives beside it (see openGerberSet).
|
||||
onStatus("Opening gerbers…");
|
||||
result = await openGerberSet(win, opts);
|
||||
log(`[open] result: ${result}`);
|
||||
} else if (opts.targetPath && !FILELESS_TOOLS.has(opts.tool)) {
|
||||
onStatus("Opening file…");
|
||||
const abs = memfsFilePath(opts.slug, opts.targetPath);
|
||||
result = await openFileInTool(win, abs, { log });
|
||||
|
|
|
|||
|
|
@ -17,6 +17,13 @@ export interface OpenFlowOptions {
|
|||
timeoutMs?: number;
|
||||
/** Override the load-settle budget (kicadOpenFileBusy poll) — tests only. */
|
||||
settleTimeoutMs?: number;
|
||||
/**
|
||||
* Replace the programmatic invocation (default: `Module.kicadOpenFile(path)`)
|
||||
* while keeping the readiness handling around it — the frame wait, the
|
||||
* settle gate, the no-UI-automation-while-parked rule. GerbView uses this to
|
||||
* open a whole fabrication set through `kicadOpenFiles`.
|
||||
*/
|
||||
open?: () => void;
|
||||
}
|
||||
|
||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||
|
|
@ -220,8 +227,9 @@ export async function openFileInTool(
|
|||
// waitForOpenSettled). 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);
|
||||
if (opts.open || hasProgrammaticHook(win)) {
|
||||
if (opts.open) opts.open();
|
||||
else invokeProgrammaticOpen(win, absPath, log);
|
||||
const settled = await waitForOpenSettled(win, log, timeoutMs, opts.settleTimeoutMs);
|
||||
return settled ? "programmatic" : "failed";
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue