Serve the standalone editor under a CMMS sub-path with no change to
@pcbjam/shared or the wasm/C++ side. No-op for a root deploy (BASE_URL='/').
- main.tsx: <BrowserRouter basename={import.meta.env.BASE_URL}>
- lib/base-path.ts (new): stripBase() for raw location.pathname reads,
hardNav() for the cross-document window.location.assign() navs (the
process-global wasm runtime means a tool switch / File->Quit is a full
page load, and @pcbjam/shared's projectPath() is origin-absolute)
- config.ts currentScope(): stripBase() before splitting for the scope segment
- WasmTool.tsx quit-path: stripBase() for the projects/libs segment test
- tool-navigation.ts (x2), NewFileDialog.tsx, ProjectView.tsx, quit-hook.ts:
window.location.assign(projectPath(...)) -> hardNav(...)
- .env.production: VITE_API_BASE_URL=/pcb-project-api.php, PROJECT_SOURCE=remote,
DOC_SOURCE=api, LIBS_SOURCE=off, WASM_ROOT=/vendor/pcbjam/wasm,
YJS_PROVIDER=broadcastchannel; APP_URL/USER/SCOPE unset
Build: vite build --base=/vendor/pcbjam/
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1765 lines
No EOL
80 KiB
TypeScript
1765 lines
No EOL
80 KiB
TypeScript
import * as React from "react";
|
|
import {
|
|
commentAuthorColors,
|
|
FILELESS_TOOLS,
|
|
fileToDoc,
|
|
projectPath,
|
|
syncLayoutToY,
|
|
type Tool,
|
|
} from "@pcbjam/shared";
|
|
import { Download } from "lucide-react";
|
|
import {
|
|
API_BASE_URL,
|
|
APP_URL,
|
|
commentAuthor,
|
|
currentScope,
|
|
libsSourceConfig,
|
|
modelsSourceConfig,
|
|
presenceUser,
|
|
PRESENCE_TUNER_ENABLED,
|
|
yjsProviderConfig,
|
|
type DocSource,
|
|
} from "@/lib/config";
|
|
import { stripBase } from "@/lib/base-path";
|
|
import { redirectTargetFor } from "@/lib/redirect";
|
|
import { loadSessionIdentity, seedSessionIdentity } from "@/lib/session-identity";
|
|
import { useThemeValue } from "@/lib/theme";
|
|
import { bootKicadTool } from "@/wasm/boot";
|
|
import {
|
|
autoDownloadEnabled,
|
|
isWasmDownloaded,
|
|
markWasmDownloaded,
|
|
resolveWasmMeta,
|
|
} from "@/wasm/wasm-assets";
|
|
import {
|
|
type LibsSource,
|
|
} from "@/wasm/libs/source";
|
|
|
|
import { TOOL_FRAME } from "@/wasm/constants";
|
|
import {
|
|
driveProjectIntoTool,
|
|
readStagedFile,
|
|
restageFile,
|
|
usedLibNicknames,
|
|
type ToolFile,
|
|
} from "@/wasm/kicad-runner";
|
|
import { startFilesWatch, type FilesWatchHandle } from "@/wasm/collab/files-watch";
|
|
import { dump as dumpTrace, mark } from "@/wasm/load-trace";
|
|
import { errorMessage, isTerminalError } from "@/wasm/terminal-error";
|
|
import { registerSaveHook, type SaveBlock, type SaveBytes } from "@/wasm/save-flow";
|
|
import type {
|
|
KicadCollabHandle,
|
|
KicadDocSession,
|
|
YjsProvider,
|
|
} from "@/wasm/collab";
|
|
import {
|
|
createPresence,
|
|
type PresenceHandle,
|
|
type PresencePeer,
|
|
} from "@/wasm/collab/presence";
|
|
import {
|
|
bindKicadPresence,
|
|
hasPresenceBridge,
|
|
type PresenceKicadModule,
|
|
type PresenceKicadWindow,
|
|
} from "@/wasm/collab/presence-kicad";
|
|
import {
|
|
createFollow,
|
|
type FollowHandle,
|
|
type FollowTarget,
|
|
} from "@/wasm/collab/follow-user";
|
|
import { startCrossAppPresence, type CrossAppHandle } from "@/wasm/collab/cross-app";
|
|
import {
|
|
startSiblingRestage,
|
|
type SiblingRestageHandle,
|
|
} from "@/wasm/collab/sibling-restage";
|
|
import {
|
|
createComments,
|
|
hasCommentsBridge,
|
|
type CommentsController,
|
|
type ViewportState,
|
|
} from "@/wasm/collab/comments";
|
|
import { PresenceRoster } from "@/components/PresenceRoster";
|
|
import { CommentLayer } from "@/components/CommentLayer";
|
|
import { hasTunerBridge, PresenceTuner, type TunerModule } from "@/components/PresenceTuner";
|
|
import { hasLayersBridge, LayerPanel, type LayersModule } from "@/components/LayerPanel";
|
|
import { SelectionInspector } from "@/components/SelectionInspector";
|
|
import { hasSheetsBridge, SheetPanel, type SheetsModule } from "@/components/SheetPanel";
|
|
import { bindLocalSelectionFeed } from "@/wasm/collab/local-selection";
|
|
import {
|
|
type SheetCollabManager,
|
|
} from "@/wasm/collab/sheet-manager";
|
|
import type * as Y from "yjs";
|
|
import { createOomWatch, respawnInNewTab } from "@/recovery/oom-watch";
|
|
import { MemoryExhaustedDialog } from "@/recovery/MemoryExhaustedDialog";
|
|
import type { SourceDescriptor } from "@/lib/project-source-shared";
|
|
import { SourceChip } from "@/components/SourceChip";
|
|
import { isMobileMode } from "@/lib/mobile-mode";
|
|
import {
|
|
isChromeToggleHotkey,
|
|
toggleChromeHidden,
|
|
useChromeHidden,
|
|
} from "@/lib/chrome-visibility";
|
|
import { recordFatalLog, showFatalScreen } from "@/wasm/fatal-screen";
|
|
import { WasmErrorBoundary } from "@/components/wasm-tool/WasmErrorBoundary";
|
|
import { BootOverlay } from "@/components/wasm-tool/BootOverlay";
|
|
import { ConsolePanel } from "@/components/wasm-tool/ConsolePanel";
|
|
import { FatalOverlay } from "@/components/wasm-tool/FatalOverlay";
|
|
import { LibLoadingOverlay } from "@/components/wasm-tool/LibLoadingOverlay";
|
|
import { NoticeStack } from "@/components/wasm-tool/NoticeStack";
|
|
import { FollowBanner, SessionMenu } from "@/components/wasm-tool/SessionMenu";
|
|
import { useLibNotices } from "@/components/wasm-tool/useLibNotices";
|
|
import {
|
|
gatherConsentInfo,
|
|
type ConsentInfo,
|
|
} from "@/components/wasm-tool/DownloadConsent";
|
|
import {
|
|
maybeConnectDocSession,
|
|
maybeStartCollab,
|
|
startSheetCollab,
|
|
waitForWxUi,
|
|
} from "@/components/wasm-tool/collab-start";
|
|
import { installQuitHook } from "@/components/wasm-tool/quit-hook";
|
|
import { runDeferredModelPrescan } from "@/wasm/libs/models-bridge";
|
|
import { installToolNavigationHook } from "@/components/wasm-tool/tool-navigation";
|
|
import {
|
|
chromeSetter,
|
|
show3DOpener,
|
|
COLLAB_TOOLS,
|
|
INSPECTOR_OPEN_KEY,
|
|
LAYERS_OPEN_KEY,
|
|
LIB_KIND_FOR_TOOL,
|
|
SHEETS_OPEN_KEY,
|
|
} from "@/components/wasm-tool/ui-helpers";
|
|
|
|
/**
|
|
* Boots a KiCad tool directly in this React document (no iframe): builds the
|
|
* Emscripten `Module` config, injects the proven harness scripts (wx.js +
|
|
* <tool>.js, the same artifacts the e2e tests use) into the page, then syncs the
|
|
* project tree into MEMFS and drives File→Open. See src/wasm/boot.ts for why the
|
|
* runtime is single-instance per page load.
|
|
*/
|
|
export function WasmTool({
|
|
tool,
|
|
slug,
|
|
scopeId,
|
|
projectId,
|
|
files,
|
|
targetPath,
|
|
fetchBytes,
|
|
onStagedRevision,
|
|
observedRevision,
|
|
rememberObservedRevision,
|
|
saveBytes,
|
|
createFile,
|
|
docSource,
|
|
assetBaseUrl,
|
|
libsSource,
|
|
sourceDescriptor,
|
|
readOnly = false,
|
|
boot = null,
|
|
}: {
|
|
tool: Tool;
|
|
slug: string;
|
|
/** Owning team's stable id (`"local"` when scope-less) — first room-id segment. */
|
|
scopeId: string;
|
|
/** Stable project id — used to key the collab room (see @pcbjam/shared). */
|
|
projectId: string;
|
|
files: ToolFile[];
|
|
targetPath?: string;
|
|
/** Where this project lives (local / remote-ro / remote-rw) — shown as a chip
|
|
* so the user knows whether/how Save persists. Omitted ⇒ no chip. */
|
|
sourceDescriptor?: SourceDescriptor;
|
|
/**
|
|
* Override the library source the editor browses. Omitted ⇒ the configured
|
|
* default (`libsSourceConfig`). Used to open a single library scoped to itself
|
|
* — a specific backend lib, or a local `.kicad_sym`/`.kicad_mod` file.
|
|
*/
|
|
libsSource?: LibsSource | null;
|
|
/**
|
|
* The composed boot payload (load-path-rework 0001 §6), when the page's ONE
|
|
* boot round-trip answered: seeds identity, the lib listing + stack
|
|
* resolves, and the project sync digest. Null ⇒ each consumer uses its
|
|
* individual endpoint — the pre-boot behavior.
|
|
*/
|
|
boot?: import("@/lib/boot-payload").BootPayload | null;
|
|
/** Fetch one project-relative file's bytes (contract loader or local folder). */
|
|
fetchBytes: (relPath: string) => Promise<Uint8Array>;
|
|
/**
|
|
* Files staged from the project sync namespace bundle never pass through
|
|
* `fetchBytes`; this reports their listing revision so the source can record
|
|
* the CAS ancestry `saveBytes` must publish against (see DriveOptions).
|
|
*/
|
|
onStagedRevision?: (relPath: string, revision: number) => void;
|
|
/**
|
|
* Files-route change hints (project-sync 0002): the latest server revision
|
|
* this client observed for a path (its own PUT ack — the echo check) and
|
|
* the recorder for revisions learned from a peer's hint. Absent ⇒ hints
|
|
* still restage siblings but every hint stamped with our user is treated
|
|
* as a peer's (no echo suppression).
|
|
*/
|
|
observedRevision?: (relPath: string) => number | undefined;
|
|
rememberObservedRevision?: (relPath: string, revision: number) => void;
|
|
/**
|
|
* Persist one file the user saved in the editor (File→Save writes MEMFS, then
|
|
* the wasm fires window.kicadCollab.onSave → this). API upload for backend
|
|
* projects, disk write-back/download for local folders; omit to keep saves
|
|
* MEMFS-only (e.g. Y.Doc-backed sessions).
|
|
*/
|
|
saveBytes?: SaveBytes;
|
|
/**
|
|
* Create a new file in the project (tool-switch auto-create: eeschema's
|
|
* "Switch to PCB Editor" when no board exists yet). Persisted BEFORE the
|
|
* hook navigates, so the next ToolPage load finds it. Omit for sessions
|
|
* that can't persist a new project file (read-only viewers, scratch and
|
|
* local-folder sessions) — a missing switch target then stays a logged
|
|
* no-op.
|
|
*/
|
|
createFile?: (relPath: string, bytes: Uint8Array) => Promise<void>;
|
|
/**
|
|
* Where this project's DOCUMENT lives (see lib/config docSourceConfig):
|
|
* "ydoc" materializes the target file from its collab room when the room has
|
|
* state, with `fetchBytes` as the first-open fallback that seeds it. Defaults
|
|
* to "api" (plain fetch + open). Local-folder sessions don't pass this.
|
|
*/
|
|
docSource?: DocSource;
|
|
/** Override the resolved WASM asset base (used verbatim, e.g. e2e fixtures).
|
|
* Default: resolveWasmBase(tool) — the CDN manifest folder, or flat /wasm. */
|
|
assetBaseUrl?: string;
|
|
/**
|
|
* Read-only viewer session (read-only-viewer; see lib/read-only-mode): chrome
|
|
* force-hidden with the toggle disabled, no presence/comments/drift, the
|
|
* collab binding never seeds or pushes local edits, and the wasm frame is
|
|
* locked via kicadSetReadOnly (zoom/pan only) — failing CLOSED when the
|
|
* bundle lacks the export. Pair with an omitted `saveBytes`.
|
|
*/
|
|
readOnly?: boolean;
|
|
}) {
|
|
const containerRef = React.useRef<HTMLDivElement>(null);
|
|
const startedRef = React.useRef(false);
|
|
// Mobile device (features/mobile): boot installs the touch-gesture shim.
|
|
// Chrome/overlay visibility is the separate runtime toggle below.
|
|
const mobileUi = React.useMemo(() => isMobileMode(), []);
|
|
// Figma-like "hide UI" toggle: small screens (phones, tablets, narrow
|
|
// windows — startsChromeHidden) default to hidden, the floating button /
|
|
// Cmd+\ flips it live; shell overlays key off this, and the layout
|
|
// effect below applies it to the wasm frame.
|
|
const chromeHidden = useChromeHidden();
|
|
// Read-only sessions force-hide the chrome without touching the module-global
|
|
// toggle state (SPA-navigating away keeps normal behavior elsewhere).
|
|
const effectiveChromeHidden = readOnly || chromeHidden;
|
|
const driftRef = React.useRef<{ stop(): void } | null>(null);
|
|
const presenceRef = React.useRef<PresenceHandle | null>(null);
|
|
const presenceBridgeRef = React.useRef<{ destroy(): void } | null>(null);
|
|
// Follow-user (0008): mirror a peer's viewport until local input breaks it.
|
|
const followRef = React.useRef<FollowHandle | null>(null);
|
|
// Project-wide presence room (0006): joined once per session, survives
|
|
// eeschema sheet rebinds — the bridge re-reads it on every startPresence.
|
|
const crossAppRef = React.useRef<CrossAppHandle | null>(null);
|
|
const siblingRestageRef = React.useRef<SiblingRestageHandle | null>(null);
|
|
const filesWatchRef = React.useRef<FilesWatchHandle | null>(null);
|
|
// Set at the boot effect's cleanup; deferred starters bail on it (the
|
|
// sibling-restage idle stagger can fire after unmount).
|
|
const disposedRef = React.useRef(false);
|
|
const sheetManagerRef = React.useRef<SheetCollabManager | null>(null);
|
|
// The single-room collab doc (pcbnew/pl_editor), for the layout save-sync
|
|
// (miss 08B); eeschema routes per sheet through the manager instead.
|
|
const collabDocRef = React.useRef<import("yjs").Doc | null>(null);
|
|
// Its owning handle, so unmount tears the room socket + doc down — eeschema's
|
|
// equivalent lives inside sheetManagerRef.
|
|
const collabHandleRef = React.useRef<KicadCollabHandle | null>(null);
|
|
const [status, setStatus] = React.useState("Loading tool…");
|
|
const [logs, setLogs] = React.useState<string[]>([]);
|
|
const [showLog, setShowLog] = React.useState(false);
|
|
const consolePanelRef = React.useRef<HTMLDivElement>(null);
|
|
const [oomExhausted, setOomExhausted] = React.useState(false);
|
|
// Terminal failure, rendered INDEPENDENTLY of `ready`. The boot overlay only
|
|
// exists while `!ready`, so anything that killed the runtime after the editor
|
|
// came up (a wasm abort/trap, a failed staging fetch surfacing late) used to
|
|
// leave a blank page with no explanation at all — the "white screen of death".
|
|
const [fatal, setFatal] = React.useState<string | null>(null);
|
|
// Editor lifecycle for the loading chrome: false until the tool has booted +
|
|
// opened (covers the big WASM-compile freeze with a full-screen overlay).
|
|
const [ready, setReady] = React.useState(false);
|
|
// Download progress for the (large) wasm, and a "this is taking too long" flag
|
|
// the overlay raises after a while so a stuck load doesn't read as a silent hang.
|
|
const [progress, setProgress] = React.useState<{
|
|
loaded: number;
|
|
total: number;
|
|
} | null>(null);
|
|
const [slow, setSlow] = React.useState(false);
|
|
// Download-consent gate (standalone-load-ux 0001): non-null while the boot
|
|
// waits on the user's OK before pulling the (large) cold wasm + lib bundles.
|
|
const [consent, setConsent] = React.useState<ConsentInfo | null>(null);
|
|
const consentResolveRef = React.useRef<((ok: boolean) => void) | null>(null);
|
|
// This bundle+version finished downloading before (completion marker) — the
|
|
// load overlay says "from cache" instead of the first-download excuse.
|
|
const [warmBoot, setWarmBoot] = React.useState(false);
|
|
// Load-screen pre-sync progress: warming the project's lib bundles into IDB in
|
|
// parallel with the wasm download. Null when idle/done. Counts only (no
|
|
// "current lib") — the fetches run several-at-a-time, so there is no single
|
|
// current one, and a fixed label keeps the line still while it ticks.
|
|
const [libSync, setLibSync] = React.useState<{
|
|
kind: string;
|
|
done: number;
|
|
total: number;
|
|
} | null>(null);
|
|
// Project-file staging progress (fetch + MEMFS write, overlapping the wasm
|
|
// download) for the boot overlay's "Project files" line. Null when idle/done.
|
|
const [fileSync, setFileSync] = React.useState<{
|
|
done: number;
|
|
total: number;
|
|
} | null>(null);
|
|
// The one libs source instance the running editor uses (set by the boot
|
|
// effect) — the libset toast's action needs it to re-list and load.
|
|
const activeLibsSourceRef = React.useRef<LibsSource | null>(null);
|
|
// Every library / document notice (toasts, stale-lib state, load badges) —
|
|
// state + listeners in useLibNotices, rendered by NoticeStack / SessionMenu
|
|
// / LibLoadingOverlay below.
|
|
const notices = useLibNotices({ getLibsSource: () => activeLibsSourceRef.current });
|
|
// A save path entered the DURABLE blocked state (409 conflict / unknown
|
|
// commit state — save-flow's absorbing blockedPaths). Rendered as a
|
|
// persistent banner, never auto-dismissed: further Ctrl+S on the path is
|
|
// silently absorbed, so without this surface the user would keep "saving"
|
|
// into the void.
|
|
const [saveBlocked, setSaveBlocked] = React.useState<SaveBlock | null>(null);
|
|
// The OTHER users in this document's collab room (awareness roster) — drives
|
|
// the PresenceRoster chip next to SourceChip. Empty when collab is off, the
|
|
// provider has no awareness (kind "none"), or nobody else is here.
|
|
const [peers, setPeers] = React.useState<PresencePeer[]>([]);
|
|
// eeschema: the sheet THIS client is bound to — the roster dims peers whose
|
|
// skeleton state says they're on a different sheet (collab-presence 0003).
|
|
const [activeSheetPath, setActiveSheetPath] = React.useState<string | undefined>();
|
|
// Follow-user (0008): the followed roster client, for the ring + banner.
|
|
const [followingTarget, setFollowingTarget] = React.useState<FollowTarget | null>(null);
|
|
// Comments (0005): the bound doc's controller + the live viewport transform
|
|
// the DOM layer maps world→CSS with. Both rebind with the collab session
|
|
// (per sheet in eeschema).
|
|
const [commentsCtl, setCommentsCtl] = React.useState<CommentsController | null>(null);
|
|
// The overlay menu's comments section (0010): a ref-callback slot the
|
|
// CommentLayer portals its bar/panel into; null while the menu is closed.
|
|
const [commentsSlot, setCommentsSlot] = React.useState<HTMLDivElement | null>(null);
|
|
const [viewportState, setViewportState] = React.useState<ViewportState | null>(null);
|
|
const commentsRef = React.useRef<CommentsController | null>(null);
|
|
// Viewer panels (viewer-panels): the SelectionInspector's data doc — the
|
|
// bound collab doc (pcbnew: the board room; eeschema: the ACTIVE sheet's
|
|
// room, re-pointed on navigation). Null without a doc room (?collab=0).
|
|
const [panelDoc, setPanelDoc] = React.useState<Y.Doc | null>(null);
|
|
// Read-only sessions never bind presence, so the inspector's selection
|
|
// store is fed by this minimal local handler (+ the C++ input hooks).
|
|
const localSelectionRef = React.useRef<{ destroy(): void } | null>(null);
|
|
// Read-only sessions boot with BOTH panels open as collapsed headers
|
|
// (viewer-panels): discoverable without a trip through the menu; a stored
|
|
// per-browser choice wins over the default.
|
|
const [layersOpen, setLayersOpenState] = React.useState<boolean>(() => {
|
|
try {
|
|
const stored = localStorage.getItem(LAYERS_OPEN_KEY);
|
|
if (stored !== null) return stored === "1";
|
|
} catch {
|
|
/* private mode */
|
|
}
|
|
return readOnly === true;
|
|
});
|
|
const setLayersOpen = React.useCallback((v: boolean) => {
|
|
setLayersOpenState(v);
|
|
try {
|
|
localStorage.setItem(LAYERS_OPEN_KEY, v ? "1" : "0");
|
|
} catch {
|
|
/* private mode */
|
|
}
|
|
}, []);
|
|
const [inspectorOpen, setInspectorOpenState] = React.useState<boolean>(() => {
|
|
try {
|
|
const stored = localStorage.getItem(INSPECTOR_OPEN_KEY);
|
|
if (stored !== null) return stored === "1";
|
|
} catch {
|
|
/* private mode */
|
|
}
|
|
return readOnly === true;
|
|
});
|
|
const setInspectorOpen = React.useCallback((v: boolean) => {
|
|
setInspectorOpenState(v);
|
|
try {
|
|
localStorage.setItem(INSPECTOR_OPEN_KEY, v ? "1" : "0");
|
|
} catch {
|
|
/* private mode */
|
|
}
|
|
}, []);
|
|
// Sheet navigator (sheet-panel): eeschema's stand-in for the wx hierarchy
|
|
// pane. Open by default like the other viewer panels, but only ever RENDERS
|
|
// when the hierarchy has sub-sheets (see sheetsMod) — a flat schematic
|
|
// shouldn't pay for an empty panel.
|
|
const [sheetsOpen, setSheetsOpenState] = React.useState<boolean>(() => {
|
|
try {
|
|
const stored = localStorage.getItem(SHEETS_OPEN_KEY);
|
|
if (stored !== null) return stored === "1";
|
|
} catch {
|
|
/* private mode */
|
|
}
|
|
return true;
|
|
});
|
|
const setSheetsOpen = React.useCallback((v: boolean) => {
|
|
setSheetsOpenState(v);
|
|
try {
|
|
localStorage.setItem(SHEETS_OPEN_KEY, v ? "1" : "0");
|
|
} catch {
|
|
/* private mode */
|
|
}
|
|
}, []);
|
|
// A doc session that connected but has not been ADOPTED by an owner yet
|
|
// (collab handle / sheet manager). Owned here so a boot failure, an
|
|
// open-never-settled degrade, or unmount can destroy it instead of leaking
|
|
// the socket + doc (findings C-1).
|
|
const pendingDocSessionRef = React.useRef<KicadDocSession | null>(null);
|
|
// Tear down every collab surface that dispatches into the wasm on ws/doc
|
|
// events. Shared by the unmount cleanup AND the terminal-error promote
|
|
// (findings C-7): after a terminal wasm death, a still-connected room kept
|
|
// delivering awareness/doc updates and each one re-entered the dead
|
|
// instance — an unbounded ticket storm underneath the fatal overlay.
|
|
const teardownCollab = React.useCallback(() => {
|
|
localSelectionRef.current?.destroy();
|
|
localSelectionRef.current = null;
|
|
setPanelDoc(null);
|
|
commentsRef.current?.destroy();
|
|
commentsRef.current = null;
|
|
followRef.current?.destroy();
|
|
followRef.current = null;
|
|
presenceBridgeRef.current?.destroy();
|
|
presenceBridgeRef.current = null;
|
|
presenceRef.current?.destroy();
|
|
presenceRef.current = null;
|
|
crossAppRef.current?.destroy();
|
|
crossAppRef.current = null;
|
|
siblingRestageRef.current?.destroy();
|
|
siblingRestageRef.current = null;
|
|
filesWatchRef.current?.destroy();
|
|
filesWatchRef.current = null;
|
|
driftRef.current?.stop();
|
|
driftRef.current = null;
|
|
// Tears down every warm room's provider/doc (the only place providers are
|
|
// destroyed — switching sheets keeps them connected) and clears drift via
|
|
// onActiveChange(null).
|
|
sheetManagerRef.current?.destroy();
|
|
sheetManagerRef.current = null;
|
|
// The single-room (pcbnew/pl_editor) counterpart: binding + provider +
|
|
// doc. Without this the board room's socket survived navigation.
|
|
collabHandleRef.current?.destroy();
|
|
collabHandleRef.current = null;
|
|
collabDocRef.current = null;
|
|
const pending = pendingDocSessionRef.current;
|
|
pendingDocSessionRef.current = null;
|
|
if (pending) {
|
|
try {
|
|
pending.provider.destroy();
|
|
pending.doc.destroy();
|
|
} catch {
|
|
/* teardown is best-effort */
|
|
}
|
|
}
|
|
}, []);
|
|
// Unread-comments rollup for the FAB badge (comments-ux 0001 C).
|
|
const [commentsUnread, setCommentsUnread] = React.useState({ threads: 0, mentioned: false });
|
|
const onCommentsUnread = React.useCallback(
|
|
(threads: number, mentioned: boolean) => setCommentsUnread({ threads, mentioned }),
|
|
[],
|
|
);
|
|
// Live canvas theme (comments-ux 0002 F4): shell toggles drive the GAL color
|
|
// theme through the bridge when the loaded wasm exposes it; older builds
|
|
// just keep their boot-seeded theme.
|
|
const theme = useThemeValue();
|
|
React.useEffect(() => {
|
|
if (!ready) return;
|
|
const mod = (window as { Module?: { kicadSetColorTheme?: (name: string) => void } }).Module;
|
|
mod?.kicadSetColorTheme?.(theme === "dark" ? "pcbjam-dark" : "_builtin_default");
|
|
}, [theme, ready]);
|
|
// Dev-time presence style tuner (VITE_PRESENCE_TUNER=1) — set once the wasm
|
|
// exposes the style bridge, mounts the floating panel.
|
|
const [tunerMod, setTunerMod] = React.useState<TunerModule | null>(null);
|
|
|
|
// wx's window-level keydown handler forwards Ctrl/Cmd+C to the wasm app and
|
|
// preventDefaults it, so the browser's native "copy selection" never runs —
|
|
// log text could be selected but not copied. When the selection lives in the
|
|
// console panel, intercept the chord in the CAPTURE phase (ahead of wx's
|
|
// bubble-phase listener) and stop propagation; the default copy still fires.
|
|
// A canvas mousedown would normally collapse a selection, but wx
|
|
// preventDefaults that too — mirror it, or a stale log selection would keep
|
|
// stealing the editor's own Ctrl+C.
|
|
React.useEffect(() => {
|
|
const selectionInConsole = () => {
|
|
const sel = window.getSelection();
|
|
if (!sel || sel.isCollapsed || !sel.anchorNode) return false;
|
|
return consolePanelRef.current?.contains(sel.anchorNode) ?? false;
|
|
};
|
|
const onKeyDown = (e: KeyboardEvent) => {
|
|
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "c" && selectionInConsole())
|
|
e.stopPropagation();
|
|
};
|
|
const onPointerDown = (e: PointerEvent) => {
|
|
if (e.target instanceof HTMLCanvasElement && selectionInConsole())
|
|
window.getSelection()?.removeAllRanges();
|
|
};
|
|
window.addEventListener("keydown", onKeyDown, true);
|
|
window.addEventListener("pointerdown", onPointerDown, true);
|
|
return () => {
|
|
window.removeEventListener("keydown", onKeyDown, true);
|
|
window.removeEventListener("pointerdown", onPointerDown, true);
|
|
};
|
|
}, []);
|
|
|
|
const append = React.useCallback((msg: string) => {
|
|
// Mirror into the React-independent fatal-screen ring: if React ever
|
|
// unmounts itself on a crash, the DOM floor still has the full log.
|
|
recordFatalLog(msg);
|
|
setLogs((prev) => [...prev.slice(-800), msg]);
|
|
}, []);
|
|
|
|
// "Taking too long": once the tool has been loading for a while without
|
|
// becoming ready, surface a hint (slow link / something may be wrong) + a
|
|
// reload, so a stalled boot doesn't look like a frozen blank screen. Paused
|
|
// while the consent dialog is up — waiting on the user isn't "slow".
|
|
React.useEffect(() => {
|
|
if (ready || consent) return;
|
|
const t = setTimeout(() => setSlow(true), 60_000);
|
|
return () => clearTimeout(t);
|
|
}, [ready, consent]);
|
|
|
|
// Runtime death AFTER the boot succeeded. A wasm trap ("indirect call
|
|
// signature mismatch", "table index is out of bounds") or an abort() arrives
|
|
// as a window error / unhandled rejection long after `ready` flipped, with the
|
|
// boot overlay already gone — so without this the page just goes blank. Only
|
|
// genuinely terminal signatures promote to the fatal overlay; ordinary app
|
|
// errors must not hijack a working editor.
|
|
React.useEffect(() => {
|
|
// The predicate lives in wasm/terminal-error.ts (unit-tested there) and is
|
|
// shared with the error reporter, so the overlay and Better Stack can never
|
|
// disagree about what "terminal" means.
|
|
//
|
|
// It checks the error's TYPE first — every trap in this family is a
|
|
// `WebAssembly.RuntimeError` whatever the engine calls it — with the message
|
|
// patterns kept only as a fallback for the paths that lose the Error object
|
|
// (a worker ErrorEvent crosses the realm boundary with `error: null`).
|
|
//
|
|
// That ends the per-engine spelling chase this check kept losing. Matching
|
|
// the message alone had three live holes: `RuntimeError` was listed but
|
|
// never appears IN `.message`; Chrome's bare "unreachable" and "null
|
|
// function" (the v0.1.20 prod log) matched nothing; and narrowing
|
|
// "table index is out of bounds" to `\bindex out of bounds` for Firefox's
|
|
// spelling silently stopped matching Chrome's. The type check covers all
|
|
// of them, and the fallback pattern is now a superset of the old one.
|
|
// Promote to the fatal screen AND pop the console open: the log panel is
|
|
// the only account of what was loading, so a fatal must never leave it
|
|
// collapsed behind a mystery blue screen.
|
|
const promote = (kind: string, msg: string) => {
|
|
append(`[fatal] ${kind}: ${msg}`);
|
|
append(dumpTrace());
|
|
// The scheduler flight recorder: event ring + wait/activation state at
|
|
// death — the targeting data for suspension-machinery traps
|
|
// (__wxWaitDump, jspi-scheduler.js).
|
|
const dumper = (
|
|
window as Window & {
|
|
__wxWaitDump?: () => unknown;
|
|
}
|
|
);
|
|
const rec = dumper.__wxWaitDump?.();
|
|
if (rec) append(JSON.stringify(rec));
|
|
// Stop the ticket storm (findings C-7): every ws-driven collab ingress
|
|
// (remote applies, presence push, comment pins, follow fit, drift saves)
|
|
// re-armed on the next event and re-entered the DEAD instance behind
|
|
// the overlay. Terminal means the native lifetime is over — unhook it.
|
|
teardownCollab();
|
|
setFatal(msg);
|
|
setShowLog(true);
|
|
// Arm the React-independent floor too: it stays invisible while our
|
|
// overlay is up, and takes over the instant React dies.
|
|
showFatalScreen(msg);
|
|
};
|
|
const onError = (e: ErrorEvent) => {
|
|
const msg = errorMessage(e.error, e.message);
|
|
if (!isTerminalError(e.error, msg)) return;
|
|
promote("window error", msg);
|
|
};
|
|
const onRejection = (e: PromiseRejectionEvent) => {
|
|
const msg = errorMessage(e.reason);
|
|
if (!isTerminalError(e.reason, msg)) return;
|
|
promote("unhandled rejection", msg);
|
|
};
|
|
// With PROXY_TO_PTHREAD, main()/wx/timers — and therefore every wasm
|
|
// trap in this family — throw INSIDE a pthread worker. A worker's uncaught
|
|
// error fires an ErrorEvent on the Worker OBJECT, never on `window`, so the
|
|
// two listeners below can't see the very traps this overlay exists for
|
|
// (v0.1.19 prod: three "Uncaught RuntimeError"s, overlay never promoted).
|
|
// Wrap the constructor attach-only: the glue spawns all pthread workers
|
|
// from this realm, so every one gets an error tap.
|
|
const NativeWorker = window.Worker;
|
|
const onWorkerError = (e: ErrorEvent) => {
|
|
const msg = errorMessage(e.error, e.message);
|
|
if (!isTerminalError(e.error, msg)) return;
|
|
promote("worker error", msg);
|
|
};
|
|
const PatchedWorker = function (
|
|
this: unknown,
|
|
...args: ConstructorParameters<typeof Worker>
|
|
) {
|
|
const w = new NativeWorker(...args);
|
|
w.addEventListener("error", onWorkerError);
|
|
return w;
|
|
} as unknown as typeof Worker;
|
|
PatchedWorker.prototype = NativeWorker.prototype;
|
|
window.Worker = PatchedWorker;
|
|
window.addEventListener("error", onError);
|
|
window.addEventListener("unhandledrejection", onRejection);
|
|
return () => {
|
|
window.Worker = NativeWorker;
|
|
window.removeEventListener("error", onError);
|
|
window.removeEventListener("unhandledrejection", onRejection);
|
|
};
|
|
}, [append]);
|
|
|
|
React.useEffect(() => {
|
|
const win = window as ToolWindow;
|
|
const removeNavigationHook = installToolNavigationHook(win, {
|
|
slug,
|
|
files,
|
|
targetPath,
|
|
createFile,
|
|
log: append,
|
|
});
|
|
|
|
// File→Quit leaves the editor for the project overview — lib editors
|
|
// (/:scope/libs/:name) have none, so they exit home instead. Both are
|
|
// non-editor surfaces: on a backed deploy (APP_URL set) they belong to the
|
|
// management app, so quit goes straight there (one hop instead of letting
|
|
// App.tsx's 0006 redirect bounce it).
|
|
// stripBase: segments are relative to the mount, so segments[1] is
|
|
// "projects"/"libs" even under /vendor/pcbjam/. exitPath stays app-absolute
|
|
// (no mount prefix) — installQuitHook's hardNav() re-adds it.
|
|
const segments = stripBase(win.location.pathname).split("/").filter(Boolean);
|
|
const exitPath =
|
|
segments[1] === "libs" ? "/" : projectPath(currentScope(), slug);
|
|
const removeQuitHook = installQuitHook(win, {
|
|
exitUrl: redirectTargetFor(APP_URL, exitPath) ?? exitPath,
|
|
log: append,
|
|
});
|
|
|
|
return () => {
|
|
removeNavigationHook();
|
|
removeQuitHook();
|
|
};
|
|
}, [slug, files, targetPath, createFile, append]);
|
|
|
|
React.useEffect(() => {
|
|
// Guard re-entry: the WASM runtime is process-global and must boot exactly
|
|
// once (see boot.ts). StrictMode is disabled app-wide for the same reason.
|
|
if (startedRef.current) return;
|
|
startedRef.current = true;
|
|
|
|
const container = containerRef.current;
|
|
if (!container) {
|
|
setStatus("Error: tool container not mounted");
|
|
return;
|
|
}
|
|
|
|
// Fresh mount (or StrictMode re-run): re-arm the deferred starters.
|
|
disposedRef.current = false;
|
|
|
|
const win = window as ToolWindow;
|
|
|
|
// OOM recovery (feature 0002): watch for soft aborts + a stale hard-kill
|
|
// sentinel, respawning a fresh tab (capped). If the chain is already
|
|
// exhausted, skip boot and show the terminal dialog.
|
|
const oom = createOomWatch({
|
|
channelKey: `${slug}:${targetPath ?? tool}`,
|
|
showExhaustedDialog: () => setOomExhausted(true),
|
|
log: append,
|
|
});
|
|
const { proceed } = oom.start();
|
|
if (!proceed) return;
|
|
|
|
// (Re)bind presence to a collab room's awareness (collab-presence 0001):
|
|
// publish this user's identity and mirror the peers into the roster chip.
|
|
// pcbnew/pl_editor bind once; eeschema rebinds per active sheet, so the
|
|
// roster shows who is on the SAME sheet (room = sheet).
|
|
const startPresence = (
|
|
provider: YjsProvider | undefined,
|
|
sheetPath?: string,
|
|
doc?: import("yjs").Doc,
|
|
) => {
|
|
// Invisible observer (read-only-viewer): never bind presence — no roster,
|
|
// no cursor/selection emit, no awareness state (peers stays empty).
|
|
if (readOnly) return;
|
|
followRef.current?.destroy();
|
|
followRef.current = null;
|
|
setFollowingTarget(null);
|
|
presenceBridgeRef.current?.destroy();
|
|
presenceBridgeRef.current = null;
|
|
presenceRef.current?.destroy();
|
|
presenceRef.current = null;
|
|
const awareness = provider?.awareness;
|
|
if (!awareness) {
|
|
setPeers([]);
|
|
return;
|
|
}
|
|
const presence = createPresence({
|
|
awareness,
|
|
user: presenceUser(),
|
|
tool,
|
|
sheetPath,
|
|
// Round-robin colors seeded by the doc's comment authors (0009 C):
|
|
// claims avoid their slots, an author rejoining adopts their own.
|
|
...(doc ? { seedColors: () => commentAuthorColors(doc) } : {}),
|
|
});
|
|
presenceRef.current = presence;
|
|
presence.subscribe(setPeers);
|
|
setPeers(presence.peers());
|
|
setActiveSheetPath(sheetPath);
|
|
// Canvas presence (0002 pcbnew / 0003 eeschema): cursor + selection emit
|
|
// and the remote VIEW_OVERLAY render. The bridge gate skips tools without
|
|
// the exports and wasm builds predating them.
|
|
if ((tool === "pcbnew" || tool === "eeschema") && hasPresenceBridge(win.Module)) {
|
|
// Follow-user (0008): available when the wasm exports FitViewport.
|
|
const fitFn = (win.Module as PresenceKicadModule).kicadCollabFitViewport;
|
|
if (fitFn) {
|
|
const follow = createFollow({
|
|
presence,
|
|
fit: (cx, cy, halfW, halfH) => fitFn.call(win.Module, cx, cy, halfW, halfH),
|
|
ownSheetPath: () => sheetPath,
|
|
});
|
|
follow.subscribe(setFollowingTarget);
|
|
followRef.current = follow;
|
|
}
|
|
presenceBridgeRef.current = bindKicadPresence({
|
|
mod: win.Module,
|
|
win: win as unknown as PresenceKicadWindow,
|
|
presence,
|
|
// Cross-app selection (0006): the project presence room, if joined.
|
|
crossApp: crossAppRef.current ?? undefined,
|
|
// Live world↔screen transform for the DOM comment layer (0005) +
|
|
// the follow controller's echo/break detection (0008).
|
|
onViewport: (vp) => {
|
|
setViewportState(vp);
|
|
followRef.current?.noteLocalViewport(vp);
|
|
},
|
|
});
|
|
}
|
|
};
|
|
|
|
// (Re)bind the comments controller to the collab doc (collab-presence 0005):
|
|
// GAL pin dots + the DOM layer's thread data. Follows the same lifecycle as
|
|
// presence — eeschema rebinds per active sheet.
|
|
const startComments = (doc: import("yjs").Doc | undefined) => {
|
|
// Comments are hidden entirely for read-only viewers (read-only-viewer):
|
|
// no pins, no panel, no thread reads — commentsCtl stays null.
|
|
if (readOnly) return;
|
|
commentsRef.current?.destroy();
|
|
commentsRef.current = null;
|
|
setCommentsCtl(null);
|
|
if (!doc || (tool !== "pcbnew" && tool !== "eeschema") || !hasCommentsBridge(win.Module)) {
|
|
return;
|
|
}
|
|
const ctl = createComments({
|
|
doc,
|
|
mod: win.Module,
|
|
user: commentAuthor(),
|
|
tool,
|
|
// Author colors follow the live nth-in-room assignment when the
|
|
// author is present; offline authors fall back to the name hash.
|
|
colorFor: (id) => presenceRef.current?.colorOf(id),
|
|
});
|
|
commentsRef.current = ctl;
|
|
setCommentsCtl(ctl);
|
|
// Test/debug handle (mirrors window.kicadCollab): lets the e2e reset
|
|
// persisted threads deterministically without driving the whole UI.
|
|
(win as { __pcbjamComments?: CommentsController }).__pcbjamComments = ctl;
|
|
if (PRESENCE_TUNER_ENABLED && hasTunerBridge(win.Module)) {
|
|
setTunerMod(win.Module);
|
|
}
|
|
// Seed the transform (pushes only happen on input events after this).
|
|
try {
|
|
const vp = JSON.parse(win.Module.kicadCollabGetViewport() || "null");
|
|
if (vp && vp.w > 0) setViewportState(vp);
|
|
} catch {
|
|
/* frame not up yet — the first input push seeds it */
|
|
}
|
|
};
|
|
|
|
// Cmd/Ctrl+S belongs to the editor: preventDefault suppresses ONLY the
|
|
// browser's "save page" dialog (observed in Firefox) — the keydown still
|
|
// propagates to the wx canvas handler, which performs the actual save.
|
|
const swallowBrowserSave = (e: KeyboardEvent) => {
|
|
if ((e.metaKey || e.ctrlKey) && !e.altKey && e.key.toLowerCase() === "s") {
|
|
e.preventDefault();
|
|
}
|
|
};
|
|
win.addEventListener("keydown", swallowBrowserSave, true);
|
|
|
|
// Cmd/Ctrl+\ (Figma's hide-UI chord) is ours alone: unlike Cmd+S it must
|
|
// NOT reach the wx layer, so also stop propagation — capture on window
|
|
// fires before wx's bubble-phase window listeners (wasm/app.cpp).
|
|
const chromeHotkey = (e: KeyboardEvent) => {
|
|
if (readOnly) return; // viewers can't reveal the chrome (read-only-viewer)
|
|
if (!isChromeToggleHotkey(e)) return;
|
|
if (!chromeSetter(win)) return; // bundle without the export
|
|
e.preventDefault();
|
|
e.stopImmediatePropagation();
|
|
toggleChromeHidden();
|
|
};
|
|
win.addEventListener("keydown", chromeHotkey, true);
|
|
|
|
// The background lib pre-sync outlives the boot IIFE (it keeps warming IDB
|
|
// long after the editor is interactive), so unmount has to be able to stop
|
|
// it: `presync` checks this signal between libs. Declared out here — the
|
|
// cleanup below closes over it — and never rejected, so aborting mid-sync
|
|
// leaks nothing and throws nothing.
|
|
const presyncAbort = new AbortController();
|
|
// The save sink is a global slot (D-9): keep its teardown edge so a
|
|
// remount can't leave the dead mount uploading + publishing status.
|
|
let unregisterSaveHook: (() => void) | null = null;
|
|
// The libs source THIS boot created (vs. one injected via props, which the
|
|
// caller owns) — cleanup disposes it so its SyncStack sockets don't outlive
|
|
// the editor.
|
|
let ownedLibsSource: LibsSource | null = null;
|
|
|
|
void (async () => {
|
|
try {
|
|
// Real identity (collab-presence 0009 A): resolve the session user in
|
|
// parallel with the WASM download; awaited after boot, before anything
|
|
// binds presence/comments, so presenceUser()/userSlug() speak for the
|
|
// authenticated user (anonymous/example backends resolve to null and
|
|
// the pre-auth slug fallback stays). A boot payload already carries
|
|
// the /api/me shape — seeding it makes this a resolved no-op flight.
|
|
if (boot?.me) seedSessionIdentity(boot.me);
|
|
const identityReady = loadSessionIdentity(API_BASE_URL);
|
|
// Resolve the per-tool asset base at runtime (CDN manifest → versioned
|
|
// folder, or the flat local /wasm in dev). See wasm/wasm-assets.ts.
|
|
const meta = await resolveWasmMeta(tool, assetBaseUrl);
|
|
const base = meta.base;
|
|
// One source instance, shared by the wasm provider AND the pre-sync below
|
|
// (libsSourceConfig builds a fresh one each call — their SyncStack caches
|
|
// must be the same object for the warm-up to benefit the editor). A boot
|
|
// payload pre-seeds its lib listing + stack resolves (zero lib HTTP).
|
|
const source =
|
|
libsSource !== undefined
|
|
? libsSource
|
|
: libsSourceConfig(
|
|
projectId,
|
|
boot ? { libs: boot.libs, stacks: boot.stacks } : undefined,
|
|
);
|
|
if (libsSource === undefined) ownedLibsSource = source;
|
|
activeLibsSourceRef.current = source;
|
|
// Download-consent gate (standalone-load-ux 0001): before pulling the
|
|
// (large) cold wasm + lib bundles, say how many MB and wait for the OK.
|
|
// Runs only on versioned CDN deploys (`meta.ver` — flat dev roots and
|
|
// e2e assetBaseUrl overrides have no version and skip it), when this
|
|
// exact bundle+version hasn't finished downloading before, and the user
|
|
// hasn't opted into silent downloads. Only small JSON/HEAD requests
|
|
// happen before consent.
|
|
const warm = isWasmDownloaded(meta.bundle, meta.ver);
|
|
setWarmBoot(warm);
|
|
if (meta.ver && !warm && !autoDownloadEnabled()) {
|
|
const info = await gatherConsentInfo(meta, source, tool);
|
|
const ok = await new Promise<boolean>((resolve) => {
|
|
consentResolveRef.current = resolve;
|
|
setConsent(info);
|
|
});
|
|
consentResolveRef.current = null;
|
|
setConsent(null);
|
|
if (!ok) return; // unmounted while waiting — never start the download
|
|
}
|
|
// Boot fan-out (load-fanout): everything that doesn't need the wasm
|
|
// starts NOW, in parallel with the (large) wasm download.
|
|
//
|
|
// - Lib presync: warms the per-lib IDB bundles. It briefly lived AFTER
|
|
// the project open (2a47103) because opening a board took minutes —
|
|
// but the real problem was ordering (libs awaited before the project
|
|
// files, which fetched serially), not bandwidth: the ~155 lib
|
|
// requests are latency-bound, and the files now stage 8-wide the
|
|
// moment FS is up. So the presync overlaps the whole download again.
|
|
// - Doc-room connect: the collab websocket is up BEFORE the project
|
|
// files are fetched, so a file changed while we load still reaches
|
|
// this client (the room materializes the target; sheet/sibling rooms
|
|
// bind after open, as before).
|
|
// - Project files: driveProjectIntoTool below starts fetching as soon
|
|
// as the glue scripts have evaluated — the non-modularized glue runs
|
|
// FS.staticInit() at script-eval, so MEMFS staging overlaps the wasm
|
|
// download/compile rather than following it.
|
|
const libKind = LIB_KIND_FOR_TOOL[tool];
|
|
// Best-effort and NEVER rejects: `presyncSettled` resolves when the
|
|
// warm-up finished, failed, or was aborted (immediately when the source
|
|
// has none). A lib that fails to presync still loads lazily later, and
|
|
// the SyncStack dedups — a lib the wasm reaches mid-presync just awaits
|
|
// the same in-flight fetch. The signal stops the walk on unmount; the
|
|
// setLibSync guards keep a late progress callback from re-lighting the
|
|
// indicator of a torn-down session.
|
|
const startLibPresync = (): Promise<void> => {
|
|
if (!source?.presync || !libKind) return Promise.resolve();
|
|
return source
|
|
.presync({
|
|
kind: libKind,
|
|
signal: presyncAbort.signal,
|
|
onProgress: ({ done, total }) => {
|
|
if (presyncAbort.signal.aborted) return;
|
|
setLibSync(done >= total ? null : { kind: libKind, done, total });
|
|
},
|
|
})
|
|
.then(() => setLibSync(null))
|
|
.catch((e) => {
|
|
append(`[presync] ${String(e)}`);
|
|
setLibSync(null);
|
|
});
|
|
};
|
|
const presyncSettled = startLibPresync();
|
|
void presyncSettled.then(() => append(mark("presync:settled")));
|
|
// Lib editors (fileless): their frame eagerly enumerates EVERY lib of
|
|
// its kind at boot, one mutex-serialized bridge crossing at a time
|
|
// (g_pcbjamProxyMutex in the plugins) — a cold lib would network-fetch
|
|
// inside its serial crossing. Hold enumerates until the 8-wide presync
|
|
// settles so the crossings read warm IDB. pcbnew/eeschema are NOT
|
|
// gated: their choosers open on demand, and a mid-session park with no
|
|
// overlay would read as a hang.
|
|
const enumerateGate =
|
|
FILELESS_TOOLS.has(tool) && libKind
|
|
? (kind: string) =>
|
|
kind === libKind ? presyncSettled : Promise.resolve()
|
|
: undefined;
|
|
// ?collab=0 is a FULL kill-switch as of 2026-08-02: it now also skips
|
|
// the doc-room join below, so the target file falls back to the plain
|
|
// fetch path instead of ydoc materialization. Before, it only gated
|
|
// the attach — which made the flag useless as a crash-hunt bisection
|
|
// lever (the 8/2 "collab=0" prod test still materialized from the
|
|
// room and even attached).
|
|
const collabOptOut =
|
|
new URLSearchParams(win.location.search).get("collab") === "0" ||
|
|
new URLSearchParams(win.location.search).get("collab") === "false";
|
|
// Connect the doc's collab room in parallel with the wasm download (it
|
|
// needs identity, not the wasm). Errors are captured and rethrown at
|
|
// the await below — rejecting here would surface as an unhandled
|
|
// rejection while the download is still running.
|
|
const docSessionReady: Promise<
|
|
| { session?: KicadDocSession; targetBytes?: Uint8Array }
|
|
| { error: unknown }
|
|
> = (async () => {
|
|
if (collabOptOut) {
|
|
append("[collab] ?collab=0 — doc room skipped, file loads from plain fetch");
|
|
return {};
|
|
}
|
|
try {
|
|
await identityReady;
|
|
return await maybeConnectDocSession(win, {
|
|
docSource,
|
|
tool,
|
|
scopeId,
|
|
projectId,
|
|
targetPath,
|
|
signal: presyncAbort.signal,
|
|
log: append,
|
|
});
|
|
} catch (error) {
|
|
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 crossAppReady: Promise<CrossAppHandle | undefined> =
|
|
(tool === "pcbnew" || tool === "eeschema") && !collabOptOut && !readOnly
|
|
? (async () => {
|
|
try {
|
|
await identityReady;
|
|
return await startCrossAppPresence({
|
|
scopeId,
|
|
projectId,
|
|
provider: yjsProviderConfig(),
|
|
user: presenceUser(),
|
|
tool,
|
|
// Announce the open document (the active sheet for
|
|
// eeschema, re-published on navigation below) — peers'
|
|
// sibling-restage scopes its sockets to announced files.
|
|
docPath: targetPath,
|
|
});
|
|
} 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,
|
|
container,
|
|
log: append,
|
|
onStatus: setStatus,
|
|
onAbort: oom.onAbort,
|
|
onProgress: (loaded, total) => setProgress({ loaded, total }),
|
|
// Truthful loading states (standalone-load-ux 0001): the manifest's
|
|
// raw wasm size fixes the progress total under br/gzip; the marker
|
|
// flips the label to "Loading" (HTTP cache) vs "Downloading"; the
|
|
// completion marker is recorded once download + compile succeeded.
|
|
expectedWasmBytes: meta.sizes?.wasm ?? null,
|
|
warmStart: warm,
|
|
onWasmInstantiated: () => markWasmDownloaded(meta.bundle, meta.ver),
|
|
libsSource: source,
|
|
enumerateGate,
|
|
// 3D models: lazy per-board source (null unless a model backing is
|
|
// configured) — feeds the board prescan + the viewer's ensure
|
|
// fallback. Registry mode reuses the boot payload's lib listing +
|
|
// stack resolves (zero extra model requests on a preloaded boot).
|
|
modelsSource: modelsSourceConfig(
|
|
boot ? { libs: boot.libs, stacks: boot.stacks } : undefined,
|
|
),
|
|
// footprint_editor/symbol_editor load the pcbnew/eeschema bundle; the
|
|
// frame token tells its single_top launcher which editor frame to open.
|
|
frame: TOOL_FRAME[tool],
|
|
mobile: mobileUi,
|
|
// Viewers can't write: skip the boot-time default-lib create (a
|
|
// session-gated POST that 401s anonymously and used to blank the
|
|
// lib tables — anonymous public-schematic open on staging).
|
|
readOnly,
|
|
});
|
|
// Identity must be settled before the doc session / presence binds
|
|
// below — effectively instant, it raced the multi-second wasm boot.
|
|
await identityReady;
|
|
// Register the save sink before the file opens: from here on, every
|
|
// editor File→Save (MEMFS write) is routed onward through saveBytes.
|
|
// Read-only sessions register neither upload nor the save-driven room
|
|
// writers (onSaved onboarding, onSavedText layout sync) — saves, were
|
|
// any reachable past the wasm lock, stay MEMFS-only.
|
|
// Room-backed files (ydoc/live in the boot listing) never upload on
|
|
// save in ydoc mode: the room owns their state (save-flow uploadPolicy).
|
|
// A file created this session, or one whose room is first seeded now,
|
|
// has no ydoc row yet and still uploads — that is the registration +
|
|
// first fallback copy the backend file list needs.
|
|
const roomBacked = new Set(
|
|
docSource === "ydoc"
|
|
? files.filter((f) => f.hasYdoc || f.isLive).map((f) => f.path)
|
|
: [],
|
|
);
|
|
const saveHookHandle = registerSaveHook(win, {
|
|
slug,
|
|
saveBytes: readOnly ? undefined : saveBytes,
|
|
uploadPolicy: (relPath) => (roomBacked.has(relPath) ? "room" : "upload"),
|
|
log: append,
|
|
onStatus: setStatus,
|
|
...(readOnly
|
|
? {}
|
|
: {
|
|
// Durable per-path block (409 conflict / unknown commit
|
|
// state): surface it as the persistent save-blocked banner.
|
|
onBlocked: (block: SaveBlock) => setSaveBlocked(block),
|
|
// A sheet created mid-session ("Add Sheet") saves to a new .kicad_sch path the
|
|
// page-load file list can't contain — warm its collab room so it stays in sync.
|
|
onSaved: (relPath: string) => {
|
|
if (relPath.endsWith(".kicad_sch"))
|
|
void sheetManagerRef.current?.onboard(relPath);
|
|
},
|
|
// Non-item document state (title block, paper, setup…) only reaches the
|
|
// room at seed time; reconcile it from every save (miss 08B).
|
|
onSavedText: (relPath: string, text: string) => {
|
|
if (sheetManagerRef.current) {
|
|
sheetManagerRef.current.syncLayoutFromSave(relPath, text);
|
|
return;
|
|
}
|
|
if (collabDocRef.current && relPath === targetPath) {
|
|
try {
|
|
syncLayoutToY(fileToDoc(text), collabDocRef.current, "layout-save");
|
|
} catch (err) {
|
|
append(`[save] layout sync failed: ${String(err)}`);
|
|
}
|
|
}
|
|
},
|
|
}),
|
|
});
|
|
unregisterSaveHook = () => saveHookHandle.stop();
|
|
// The room connect started in the fan-out above — settle it before
|
|
// staging so the target file materializes from the doc when it has one.
|
|
const docResult = await docSessionReady;
|
|
if ("error" in docResult) throw docResult.error;
|
|
const { session, targetBytes } = docResult;
|
|
// The session is connected but ownerless until a collab handle or the
|
|
// sheet manager adopts it below — register it so every failure exit
|
|
// (boot throw, open-never-settled, degrade, unmount) destroys it
|
|
// instead of leaking the socket + doc (findings C-1).
|
|
pendingDocSessionRef.current = session ?? null;
|
|
const openResult = await driveProjectIntoTool(win, {
|
|
tool,
|
|
slug,
|
|
files,
|
|
targetPath,
|
|
// Viewers fetch 3D bodies only if they open the viewer (session menu).
|
|
deferModelPrescan: readOnly,
|
|
// ydoc source with a populated room: the target file's bytes come
|
|
// from the doc; everything else (sibling files) still fetches.
|
|
fetchBytes:
|
|
targetBytes && targetPath
|
|
? (relPath) =>
|
|
relPath === targetPath ? Promise.resolve(targetBytes) : fetchBytes(relPath)
|
|
: fetchBytes,
|
|
// Plain files stage via the project sync namespace — one bundle GET
|
|
// cold, a manifest diff warm (0001 §4 full). Backend projects only:
|
|
// the gallery/local sources have no sync routes and no CAS rows.
|
|
projectSync:
|
|
sourceDescriptor?.kind === "remote-rw" && scopeId !== "local"
|
|
? {
|
|
apiBase: API_BASE_URL,
|
|
scope: currentScope(),
|
|
scopeId,
|
|
projectId,
|
|
// Boot's fresh digest: a warm match stages with ZERO HTTP.
|
|
digest: boot?.projectSync.digest,
|
|
}
|
|
: null,
|
|
onStagedRevision,
|
|
log: append,
|
|
onStatus: setStatus,
|
|
onFileProgress: (done, total) =>
|
|
setFileSync(done >= total ? null : { done, total }),
|
|
});
|
|
// Read-only viewer (read-only-viewer): lock the wasm frame BEFORE the
|
|
// boot overlay drops — the file is open, so the frame exists; poll the
|
|
// export like the chrome toggle does. Fails CLOSED (boot error overlay):
|
|
// a viewer must never get a writable-feeling frame. gerbview/calculator
|
|
// bundles have no lock export and nothing project-mutating to lock —
|
|
// they proceed (saves are already MEMFS-only above).
|
|
if (readOnly) {
|
|
const setRo = (
|
|
win.Module as
|
|
| { kicadSetReadOnly?: (v: boolean) => boolean | Promise<boolean> }
|
|
| undefined
|
|
)?.kicadSetReadOnly;
|
|
if (typeof setRo === "function") {
|
|
// The scheduler's mutator lane returns the boolean synchronously
|
|
// when the wasm side is idle, and a Promise for the SAME call when
|
|
// it queued behind a live open — await covers both. (The old
|
|
// poll-until-literal-true loop could spin forever under JSPI: a
|
|
// queued call re-enqueues on every retry and never compares true.)
|
|
const applied = await Promise.race([
|
|
Promise.resolve(setRo(true)),
|
|
new Promise<never>((_, reject) =>
|
|
setTimeout(
|
|
() => reject(new Error("read-only lock did not apply")),
|
|
30_000,
|
|
),
|
|
),
|
|
]);
|
|
if (applied !== true) {
|
|
throw new Error("read-only lock did not apply");
|
|
}
|
|
append("[readonly] wasm frame locked (kicadSetReadOnly)");
|
|
} else if (tool !== "gerbview" && tool !== "calculator") {
|
|
throw new Error(
|
|
"read-only mode is not supported by this build (kicadSetReadOnly missing)",
|
|
);
|
|
}
|
|
}
|
|
// Everything below drives BARE embind entries that walk the loaded
|
|
// model (collab snapshot/adopt, presence bind, drift). Deferred until
|
|
// the open chain settled (openResult) — calling them while the
|
|
// kicadOpenFile activation is still parked mid-load walks a
|
|
// half-built model and traps ("indirect call signature mismatch").
|
|
const attachCollabAndPresence = async () => {
|
|
// Drift detection: while a sheet is collaboratively edited, periodically (every N
|
|
// edits + at session end) compare the WASM serialization to the Y.Doc and report
|
|
// divergence. Gated on a real collab session; re-targeted per active sheet below.
|
|
const { startDriftDetection } = await import("@/wasm/collab/drift-detect");
|
|
|
|
// 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.
|
|
// 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.
|
|
const crossAppHandle = (await crossAppReady) ?? null;
|
|
if (disposedRef.current) {
|
|
// Unmounted while awaiting — cleanup already ran; adopting now would
|
|
// leak a live socket behind a dead component (findings C-1).
|
|
crossAppHandle?.destroy();
|
|
return;
|
|
}
|
|
crossAppRef.current = crossAppHandle;
|
|
// 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
|
|
// active sheet is bound, navigation re-routes it (C++ onSheetChanged hook).
|
|
sheetManagerRef.current =
|
|
(await startSheetCollab(win, {
|
|
slug,
|
|
scopeId,
|
|
projectId,
|
|
targetPath,
|
|
files,
|
|
session,
|
|
saveBytes: readOnly ? undefined : saveBytes,
|
|
editorMatchesDoc: !!targetBytes,
|
|
readOnly,
|
|
// Re-point drift detection + presence at whichever sheet is bound.
|
|
onActiveChange: (activeRoom) => {
|
|
driftRef.current?.stop();
|
|
driftRef.current = null;
|
|
// Re-announce the actively-edited sheet in the project room —
|
|
// peers' sibling-restage tracks it (a pcbnew tab only mirrors
|
|
// sheets someone actually has open).
|
|
crossAppRef.current?.setDocPath(activeRoom?.sheetPath);
|
|
startPresence(activeRoom?.provider, activeRoom?.sheetPath, activeRoom?.doc);
|
|
startComments(activeRoom?.doc);
|
|
setPanelDoc(activeRoom?.doc ?? null);
|
|
if (activeRoom && !readOnly) {
|
|
driftRef.current = startDriftDetection({
|
|
doc: activeRoom.doc,
|
|
mod: win.Module,
|
|
win,
|
|
tool,
|
|
slug,
|
|
targetPath: activeRoom.sheetPath,
|
|
log: append,
|
|
});
|
|
}
|
|
},
|
|
log: append,
|
|
onStatus: setStatus,
|
|
})) ?? null;
|
|
if (sheetManagerRef.current) {
|
|
// The manager's room pool now owns the entry session (destroy()
|
|
// tears it down with every other warm room).
|
|
pendingDocSessionRef.current = null;
|
|
}
|
|
if (disposedRef.current) {
|
|
// Late handoff after unmount (findings C-1): cleanup already ran.
|
|
sheetManagerRef.current?.destroy();
|
|
sheetManagerRef.current = null;
|
|
return;
|
|
}
|
|
} else {
|
|
const collabHandle = await maybeStartCollab(win, {
|
|
tool,
|
|
slug,
|
|
scopeId,
|
|
projectId,
|
|
targetPath,
|
|
collabSession: session,
|
|
editorMatchesDoc: !!targetBytes,
|
|
readOnly,
|
|
log: append,
|
|
onStatus: setStatus,
|
|
});
|
|
if (collabHandle) {
|
|
// The handle owns the session now (destroy() covers binding +
|
|
// provider + doc).
|
|
pendingDocSessionRef.current = null;
|
|
}
|
|
if (disposedRef.current) {
|
|
// Late handoff after unmount (findings C-1): cleanup already ran.
|
|
collabHandle?.destroy();
|
|
return;
|
|
}
|
|
collabHandleRef.current = collabHandle ?? null;
|
|
collabDocRef.current = collabHandle?.doc ?? null;
|
|
startPresence(collabHandle?.provider, undefined, collabHandle?.doc);
|
|
startComments(collabHandle?.doc);
|
|
setPanelDoc(collabHandle?.doc ?? null);
|
|
// Live sibling mirror (project-sync 0001 bug 3): keep the schematic
|
|
// files a PCB session syncs from fresh in MEMFS, instead of the
|
|
// one-shot boot snapshot. Same opt-out as the room collab; read-only
|
|
// viewers skip it (they can't run the sync anyway).
|
|
//
|
|
// STAGGERED out of the settle window (2026-08-02, the ladder
|
|
// result): warm sibling-heavy projects crash at exactly this moment
|
|
// (V1/V4 fail 2/2 warm, V2/V3 without siblings never do), and the
|
|
// scheduler flight recorder places the fatal interleave inside the
|
|
// settle-time wake windows. The restage's room connects + restage
|
|
// fetches were the only sibling-specific traffic contending with
|
|
// those windows. Nothing here is needed for first paint — the boot
|
|
// snapshot staged every sibling seconds ago — so it starts when the
|
|
// main thread is idle (or after 5s, whichever first), well clear of
|
|
// the settle storm. Unmount-safety: the ref may be populated after
|
|
// unmount, so the cleanup check runs inside the callback too.
|
|
if (tool === "pcbnew" && collabHandle && !readOnly) {
|
|
const startRestageIdle = () => {
|
|
if (disposedRef.current) return;
|
|
void startSiblingRestage({
|
|
win,
|
|
slug,
|
|
scopeId,
|
|
projectId,
|
|
files,
|
|
targetPath,
|
|
// Presence-scoped: connect a sheet's room only while a peer
|
|
// announces it open (zero sibling sockets when alone). Absent
|
|
// (provider "none" / connect failed) ⇒ eager fallback.
|
|
presence: crossAppRef.current ?? undefined,
|
|
provider: yjsProviderConfig(),
|
|
log: append,
|
|
}).then((handle) => {
|
|
if (disposedRef.current) {
|
|
handle?.destroy();
|
|
return;
|
|
}
|
|
siblingRestageRef.current = handle;
|
|
});
|
|
};
|
|
type IdleWindow = Window & {
|
|
requestIdleCallback?: (cb: () => void, opts?: { timeout: number }) => number;
|
|
};
|
|
const w = window as IdleWindow;
|
|
if (typeof w.requestIdleCallback === "function") {
|
|
w.requestIdleCallback(startRestageIdle, { timeout: 5000 });
|
|
} else {
|
|
window.setTimeout(startRestageIdle, 3000);
|
|
}
|
|
}
|
|
if (collabHandle && targetPath && COLLAB_TOOLS.has(tool) && !readOnly) {
|
|
driftRef.current = startDriftDetection({
|
|
doc: collabHandle.doc,
|
|
mod: win.Module,
|
|
win,
|
|
tool,
|
|
slug,
|
|
targetPath,
|
|
log: append,
|
|
});
|
|
}
|
|
}
|
|
// Files-route change hints (project-sync 0002 §3): peers' PUT-channel
|
|
// writes (.kicad_pro after assign-footprints, uploads, job resaves)
|
|
// restage into MEMFS; a hint for the open non-room target becomes a
|
|
// reload/conflict notice. Rides the same gateway socket as presence.
|
|
if ((tool === "pcbnew" || tool === "eeschema") && !readOnly && !collabOptOut) {
|
|
void startFilesWatch({
|
|
scopeId,
|
|
projectId,
|
|
provider: yjsProviderConfig(),
|
|
targetPath,
|
|
selfUser: presenceUser().id,
|
|
knownPaths: files.map((f) => f.path),
|
|
isRoomBacked: (p) => roomBacked.has(p),
|
|
observedRevision: (p) => observedRevision?.(p),
|
|
rememberObserved: (p, r) => rememberObservedRevision?.(p, r),
|
|
fetchBytes,
|
|
restage: (p, bytes) => restageFile(win, slug, p, bytes, append),
|
|
onNewPath: (p) => {
|
|
if (p.endsWith(".kicad_sch")) void sheetManagerRef.current?.onboard(p);
|
|
},
|
|
// A cold room replaced at rest (re-upload / resave install, 0004
|
|
// §2.5): a parked sheet doc must not carry the old epoch.
|
|
onRoomBackedChanged: (p) => sheetManagerRef.current?.invalidate(p),
|
|
onTargetChanged: (c) => {
|
|
const who = c.by ?? "a collaborator";
|
|
setStatus(
|
|
`${c.path} was updated by ${who} (rev ${c.revision}) — reload to see it; your next save will report a conflict`,
|
|
);
|
|
},
|
|
onListingStale: () => append("[files] hint gap — project listing is stale until reload"),
|
|
log: append,
|
|
}).then((handle) => {
|
|
if (!handle) return;
|
|
if (disposedRef.current) handle.destroy();
|
|
else filesWatchRef.current = handle;
|
|
});
|
|
}
|
|
// Viewer selection feed (viewer-panels): read-only sessions never
|
|
// bind presence (no room, no awareness), so the SelectionInspector's
|
|
// store is fed by a minimal onSelection handler + the C++ canvas
|
|
// input hooks. Edit sessions get the same store fed from
|
|
// bindKicadPresence's handler instead.
|
|
if (readOnly && (tool === "pcbnew" || tool === "eeschema")) {
|
|
localSelectionRef.current = bindLocalSelectionFeed({
|
|
mod: win.Module,
|
|
win: win as unknown as PresenceKicadWindow,
|
|
});
|
|
}
|
|
};
|
|
// Degrading without an adoption must not strand the pre-connected doc
|
|
// session (findings C-1: the open-never-settled path was the most
|
|
// reproducible leak — a live socket + doc with no owner, forever).
|
|
const releasePendingDocSession = (why: string) => {
|
|
const pending = pendingDocSessionRef.current;
|
|
if (!pending) return;
|
|
pendingDocSessionRef.current = null;
|
|
try {
|
|
pending.provider.destroy();
|
|
pending.doc.destroy();
|
|
} catch {
|
|
/* best-effort */
|
|
}
|
|
append(`[collab] released unadopted doc session (${why})`);
|
|
};
|
|
if (openResult === "failed") {
|
|
// The load never settled (or a legacy-wasm open timed out): entering
|
|
// the wasm now would race the parked open chain. Boot on without
|
|
// collab/presence — the board stays viewable, saves still route.
|
|
append("[collab] file open never settled — collab/presence disabled for this session");
|
|
releasePendingDocSession("open never settled");
|
|
} else {
|
|
try {
|
|
await attachCollabAndPresence();
|
|
} catch (err) {
|
|
// Version-skew refusal must still surface as the boot error.
|
|
if ((err as { name?: string } | undefined)?.name === "SexprVersionError") throw err;
|
|
// Degrade, don't die: a residual wasm trap here (reentrancy during
|
|
// some other parked chain) used to fail the whole boot.
|
|
append(`[collab] attach failed — continuing without collab: ${String(err)}`);
|
|
releasePendingDocSession("attach failed");
|
|
}
|
|
// A no-op attach (bridge missing / non-collab tool) adopts nothing.
|
|
releasePendingDocSession("not adopted");
|
|
}
|
|
// Deferred-realtime upgrade: the scope libs source opens its stacks
|
|
// channel-less (no socket per org lib), so promote the libs the OPEN
|
|
// DOCUMENT references — a peer editing a PLACED symbol must still
|
|
// reach this session live (lib-update toast); everything else syncs
|
|
// on the next load. Fire-and-forget: boot never waits on sockets.
|
|
// Read-only sessions carry no editable libs (the boot payload is
|
|
// model3d-only for them) and may not open the team's scope room —
|
|
// skip the socket rather than collect a 401.
|
|
if (targetPath && source?.enableRealtime && !readOnly) {
|
|
const staged = readStagedFile(win, slug, targetPath);
|
|
const nicks = staged
|
|
? usedLibNicknames(new TextDecoder().decode(staged))
|
|
: [];
|
|
append(
|
|
`[libs] doc references ${nicks.length} lib nickname(s)` +
|
|
(staged ? "" : " (target not staged?)"),
|
|
);
|
|
if (nicks.length) {
|
|
void source
|
|
.enableRealtime(nicks)
|
|
.catch((e) => append(`[libs] realtime upgrade: ${String(e)}`));
|
|
}
|
|
}
|
|
// Lib editors: the enumerate gate holds their whole-set hydrate until
|
|
// the presync settles — wait for it here too, so the boot overlay (with
|
|
// its ticking lib line) stays up instead of revealing an empty tree.
|
|
if (enumerateGate) await presyncSettled;
|
|
// Tool booted + project opened. Wait for the wx UI to actually build
|
|
// before dropping the overlay, so we don't reveal a still-blank editor.
|
|
// First paint reached. Past this point the GAL is initialized, so the
|
|
// refresh-timer self-rearm loop — the precondition for the production
|
|
// load trap — is over. A crash report that contains this mark rules
|
|
// that mechanism out; one that stops before it does not.
|
|
await waitForWxUi(win);
|
|
append(mark("ui:ready"));
|
|
setStatus("");
|
|
setReady(true);
|
|
} catch (err) {
|
|
append(`[fatal] ${String(err)}`);
|
|
append(dumpTrace());
|
|
setStatus(`Error: ${String(err)}`);
|
|
setFatal(String(err));
|
|
// A boot that died between the doc-room connect and its adoption
|
|
// (open failure, read-only lock, version skew) must not strand the
|
|
// live session behind the error overlay (findings C-1).
|
|
const pending = pendingDocSessionRef.current;
|
|
pendingDocSessionRef.current = null;
|
|
if (pending) {
|
|
try {
|
|
pending.provider.destroy();
|
|
pending.doc.destroy();
|
|
} catch {
|
|
/* best-effort */
|
|
}
|
|
}
|
|
}
|
|
})();
|
|
|
|
return () => {
|
|
// Deferred starters (the sibling-restage idle stagger) check this
|
|
// before creating anything after unmount.
|
|
disposedRef.current = true;
|
|
// A consent dialog pending at unmount resolves false — the boot IIFE
|
|
// bails without ever starting the download.
|
|
consentResolveRef.current?.(false);
|
|
consentResolveRef.current = null;
|
|
// Stop the background lib warm-up: presync checks the signal between libs,
|
|
// so in-flight bundle fetches finish (their IDB writes are still useful for
|
|
// the next mount) and no further ones start.
|
|
presyncAbort.abort();
|
|
win.removeEventListener("keydown", swallowBrowserSave, true);
|
|
win.removeEventListener("keydown", chromeHotkey, true);
|
|
// The global save sink must not outlive this mount (D-9).
|
|
unregisterSaveHook?.();
|
|
unregisterSaveHook = null;
|
|
// Every collab surface + any not-yet-adopted doc session (C-1/C-7).
|
|
teardownCollab();
|
|
// Close the lib SyncStacks this boot opened (mirror mux + any dedicated
|
|
// sockets); IDB caches stay. Injected sources belong to the caller.
|
|
ownedLibsSource?.dispose?.();
|
|
ownedLibsSource = null;
|
|
oom.stop();
|
|
};
|
|
// Boot is one-shot per mount; deps intentionally exclude files/targetPath so
|
|
// they don't retrigger a (rejected) second boot.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [tool, slug, assetBaseUrl, append]);
|
|
|
|
// kicadSetChrome, once the editor is up (null on bundles without it).
|
|
const setChromeFn = React.useMemo(
|
|
() => (ready ? chromeSetter(window) : null),
|
|
[ready],
|
|
);
|
|
|
|
// kicadShow3DViewer — the session-menu 3D entry (read-only-viewer / hide-UI
|
|
// have no wx View menu). Runs the deferred model prescan first so a viewer's
|
|
// first open resolves its refs from MEMFS instead of one C++ ensure each.
|
|
const show3DFn = React.useMemo(() => {
|
|
if (!ready || tool !== "pcbnew") return null;
|
|
const open = show3DOpener(window);
|
|
if (!open) return null;
|
|
return () => {
|
|
void runDeferredModelPrescan()
|
|
.catch((e) => append(`[3d] deferred prescan failed: ${String(e)}`))
|
|
.then(() => {
|
|
if (!open()) append("[3d] kicadShow3DViewer: no board frame");
|
|
});
|
|
};
|
|
}, [ready, tool, append]);
|
|
|
|
// Layer bridge (viewer-panels), pcbnew sessions only — the merged bundle
|
|
// exports the names for every frame, but they no-op on a non-PCB frame.
|
|
const layersMod = React.useMemo<LayersModule | null>(() => {
|
|
if (!ready || tool !== "pcbnew") return null;
|
|
const mod = (window as { Module?: unknown }).Module;
|
|
return hasLayersBridge(mod) ? mod : null;
|
|
}, [ready, tool]);
|
|
|
|
// Sheet bridge (sheet-panel), eeschema sessions only. Re-evaluated on every
|
|
// sheet switch (activeSheetPath) so a hierarchy that only gains sub-sheets
|
|
// later ("Add Sheet") surfaces the panel; hidden for a flat schematic.
|
|
const sheetsMod = React.useMemo<SheetsModule | null>(() => {
|
|
if (!ready || tool !== "eeschema") return null;
|
|
const mod = (window as { Module?: unknown }).Module;
|
|
if (!hasSheetsBridge(mod)) return null;
|
|
try {
|
|
const raw = mod.kicadSheetsGetTree() || "null";
|
|
const parsed = JSON.parse(raw) as { sheets?: unknown[] } | null;
|
|
return (parsed?.sheets?.length ?? 0) > 1 ? mod : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}, [ready, tool, activeSheetPath]);
|
|
|
|
// Apply the chrome-visibility state to the wasm frame. A LAYOUT effect with
|
|
// a synchronous first attempt: `ready` unmounts the opaque boot overlay in
|
|
// this same commit, and a passive effect would let one frame of full chrome
|
|
// paint on mobile. appliedRef skips the initial "shown" apply — never
|
|
// relayout a frame this component never hid.
|
|
const appliedRef = React.useRef<boolean | null>(null);
|
|
React.useLayoutEffect(() => {
|
|
if (!setChromeFn) return;
|
|
if (appliedRef.current === effectiveChromeHidden) return;
|
|
if (appliedRef.current === null && !effectiveChromeHidden) return;
|
|
|
|
const apply = () => {
|
|
try {
|
|
return setChromeFn(!effectiveChromeHidden) === true;
|
|
} catch (err) {
|
|
append(`[chrome] kicadSetChrome failed: ${String(err)}`);
|
|
return true; // don't retry a throwing binding
|
|
}
|
|
};
|
|
if (apply()) {
|
|
appliedRef.current = effectiveChromeHidden;
|
|
return;
|
|
}
|
|
// The editor frame can lag `ready` (waitForWxUi falls through after 25 s)
|
|
// — retry briefly rather than dropping the toggle.
|
|
const t0 = Date.now();
|
|
const tick = window.setInterval(() => {
|
|
if (apply()) {
|
|
appliedRef.current = effectiveChromeHidden;
|
|
window.clearInterval(tick);
|
|
} else if (Date.now() - t0 > 30_000) {
|
|
window.clearInterval(tick);
|
|
}
|
|
}, 300);
|
|
return () => window.clearInterval(tick);
|
|
}, [setChromeFn, effectiveChromeHidden, append]);
|
|
|
|
return (
|
|
<div className="relative h-screen w-screen overflow-hidden bg-[#1a1a2e]">
|
|
{/*
|
|
wx.js addresses the DOM by id: #main-window is its top-level (id=0)
|
|
window — it owns #canvas (created in boot's preRun) — and #window-container
|
|
parents every child window. Both ids must exist before the runtime boots,
|
|
mirroring the harness HTML (tests/apps/kicad/<tool>.html).
|
|
*/}
|
|
<div ref={containerRef} id="main-window" className="absolute inset-0 h-full w-full" />
|
|
<div id="window-container" />
|
|
|
|
{/* Everything that can throw into React's commit lives INSIDE the
|
|
boundary; the fatal screen and the console panel live OUTSIDE it, so
|
|
a runtime death can no longer white-screen the very UI that reports
|
|
it (see WasmErrorBoundary). */}
|
|
<WasmErrorBoundary
|
|
onFatal={(msg) => {
|
|
append(`[fatal] react tree died: ${msg}`);
|
|
append(dumpTrace());
|
|
setFatal(msg);
|
|
setShowLog(true);
|
|
showFatalScreen(msg);
|
|
}}
|
|
>
|
|
{oomExhausted && (
|
|
<MemoryExhaustedDialog
|
|
onOpenNewTab={() => respawnInNewTab()}
|
|
onReload={() => window.location.reload()}
|
|
/>
|
|
)}
|
|
|
|
{!ready && (
|
|
<BootOverlay
|
|
status={status}
|
|
consent={consent}
|
|
onConsentAccept={() => consentResolveRef.current?.(true)}
|
|
progress={progress}
|
|
fileSync={fileSync}
|
|
libSync={libSync}
|
|
warmBoot={warmBoot}
|
|
slow={slow}
|
|
/>
|
|
)}
|
|
|
|
{ready && notices.libLoading && <LibLoadingOverlay libLoading={notices.libLoading} />}
|
|
|
|
{/* Transient post-boot status (e.g. file open). */}
|
|
{ready && status && (
|
|
<div className="pointer-events-none absolute left-3 top-3 z-20 rounded bg-black/70 px-3 py-2 font-mono text-xs text-white">
|
|
{status}
|
|
</div>
|
|
)}
|
|
|
|
{ready && followingTarget && (
|
|
<FollowBanner target={followingTarget} onStop={() => followRef.current?.unfollow()} />
|
|
)}
|
|
|
|
{/* Session menu (0010): the draggable FAB + its sections. */}
|
|
{ready && (
|
|
<SessionMenu
|
|
tool={tool}
|
|
readOnly={readOnly}
|
|
theme={theme}
|
|
peers={peers}
|
|
activeSheetPath={activeSheetPath}
|
|
followingTarget={followingTarget}
|
|
onFollow={(t) => {
|
|
if (t) followRef.current?.follow(t);
|
|
else followRef.current?.unfollow();
|
|
}}
|
|
sourceDescriptor={sourceDescriptor}
|
|
staleLibItems={notices.staleLibItems}
|
|
staleUpdating={notices.staleUpdating}
|
|
onUpdateStale={() => void notices.updateStaleFromLibrary()}
|
|
onDismissStale={() => notices.clearStale()}
|
|
commentsUnread={commentsUnread}
|
|
hasComments={commentsCtl !== null}
|
|
setCommentsSlot={setCommentsSlot}
|
|
effectiveChromeHidden={effectiveChromeHidden}
|
|
hasLayers={layersMod !== null}
|
|
layersOpen={layersOpen}
|
|
setLayersOpen={setLayersOpen}
|
|
hasSheets={sheetsMod !== null}
|
|
sheetsOpen={sheetsOpen}
|
|
setSheetsOpen={setSheetsOpen}
|
|
inspectorOpen={inspectorOpen}
|
|
setInspectorOpen={setInspectorOpen}
|
|
canToggleChrome={setChromeFn !== null}
|
|
chromeHidden={chromeHidden}
|
|
onToggleChrome={() => toggleChromeHidden()}
|
|
onShow3D={show3DFn}
|
|
/>
|
|
)}
|
|
|
|
{/* Figma-like comments (0005): GAL pin dots + this DOM layer (hit targets,
|
|
thread popovers, comment mode, panel). The bar + list panel render
|
|
into the overlay menu's comments slot (0010). */}
|
|
{ready && commentsCtl && (
|
|
<CommentLayer
|
|
controller={commentsCtl}
|
|
viewport={viewportState}
|
|
currentUser={presenceUser().id}
|
|
menuSlot={commentsSlot}
|
|
onUnreadChange={onCommentsUnread}
|
|
mentionPeers={peers.map((p) => ({
|
|
slug: p.user.id,
|
|
name: p.user.name || p.user.id,
|
|
}))}
|
|
/>
|
|
)}
|
|
|
|
{/* Viewer panels (viewer-panels): floating layer selector + selection
|
|
inspector for canvas-only sessions — the React stand-ins for the wx
|
|
Appearance/Properties panes that kicadSetChrome(false) hides. */}
|
|
{ready && effectiveChromeHidden && layersOpen && layersMod && (
|
|
<LayerPanel
|
|
mod={layersMod}
|
|
defaultCollapsed={readOnly}
|
|
onClose={() => setLayersOpen(false)}
|
|
/>
|
|
)}
|
|
{ready && effectiveChromeHidden && sheetsOpen && sheetsMod && (
|
|
<SheetPanel
|
|
mod={sheetsMod}
|
|
defaultCollapsed={readOnly}
|
|
onClose={() => setSheetsOpen(false)}
|
|
/>
|
|
)}
|
|
{ready &&
|
|
effectiveChromeHidden &&
|
|
inspectorOpen &&
|
|
(tool === "pcbnew" || tool === "eeschema") && (
|
|
<SelectionInspector
|
|
doc={panelDoc}
|
|
defaultCollapsed={readOnly}
|
|
onClose={() => setInspectorOpen(false)}
|
|
/>
|
|
)}
|
|
|
|
{/* DEV: presence style tuner (VITE_PRESENCE_TUNER=1). */}
|
|
{ready && tunerMod && <PresenceTuner mod={tunerMod} tool={tool} />}
|
|
|
|
<NoticeStack
|
|
ready={ready}
|
|
libSync={libSync}
|
|
modelsSync={notices.modelsSync}
|
|
libBusy={notices.libBusy}
|
|
saveBlocked={saveBlocked}
|
|
libError={notices.libError}
|
|
onDismissLibError={notices.dismissLibError}
|
|
libUpdate={notices.libUpdate}
|
|
onDismissLibUpdate={notices.dismissLibUpdate}
|
|
libSetNotice={notices.libSetNotice}
|
|
onLibSetClick={notices.onLibSetClick}
|
|
docReverted={notices.docReverted}
|
|
onDismissDocReverted={notices.dismissDocReverted}
|
|
/>
|
|
|
|
</WasmErrorBoundary>
|
|
|
|
{fatal && <FatalOverlay message={fatal} />}
|
|
|
|
{/* The log console (z-40, above boot + fatal overlays) — forced visible
|
|
on a fatal even with chrome hidden: the log is the only account of
|
|
WHY a load failed. */}
|
|
{(!effectiveChromeHidden || fatal) && (
|
|
<ConsolePanel ref={consolePanelRef} logs={logs} open={showLog} setOpen={setShowLog} append={append} />
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** "~173 MB" — coarse on purpose; these are quotes, not meters. */ |