feat(read-only-viewer): editor viewer mode + kicadSetReadOnly binding

Anonymous / non-member sessions open PUBLIC projects as read-only viewers.

- kicadSetReadOnly(bool) embind (merged kicad_editor + pcbnew/eeschema/
  pl_editor TUs): sets the PCBJAM_READ_ONLY flag + Prj().SetReadOnly (greys
  the setup dialogs). Polls until the frame exists.
- read-only-mode.ts: resolveReadOnly(access, win) — server `access:"read"` or
  ?readonly=1 (narrow-only; no ?readonly=0). ToolPage threads it in, omits
  saveBytes (MEMFS-only saves).
- WasmTool: chrome force-hidden with a "View only" pill (toggle + Cmd+\
  disabled), presence/cross-app/comments/drift skipped, save-driven room
  writers unregistered, wasm frame locked via kicadSetReadOnly failing CLOSED
  (stale bundle → boot error, never a writable frame).
- collab: bindKicadCollab {readOnly} — inert DOWN hook, never seeds a room;
  UP observer + adopt stay live so peer edits render. index.ts / sheet-manager
  thread readOnly + drop initial awareness (invisible observer).
- Reference backend emits access:"write".

Bumps kicad + web/pcbjam-shared to the read-only-viewer commits.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012DN9py5GuPdExaaFzE4k27
This commit is contained in:
Istvan Matejcsok 2026-07-10 20:27:04 +02:00
commit bb01f5d9e6
16 changed files with 665 additions and 49 deletions

View file

@ -454,6 +454,8 @@ async function maybeStartCollab(
collabSession?: KicadDocSession;
/** The opened file was materialized from collabSession's doc (ydoc source). */
editorMatchesDoc?: boolean;
/** Read-only viewer (read-only-viewer): see `bindKicadCollab`. */
readOnly?: boolean;
log: (m: string) => void;
onStatus: (t: string) => void;
},
@ -500,6 +502,7 @@ async function maybeStartCollab(
const handle = attachKicadCollab(mod, win as unknown as KicadItemsWindow, opts.collabSession, {
seedDoc,
editorMatchesDoc: opts.editorMatchesDoc,
readOnly: opts.readOnly,
});
opts.log(`[collab] attached to Y.Doc session`);
opts.onStatus("Collab: connected");
@ -517,6 +520,7 @@ async function maybeStartCollab(
provider,
room,
seedDoc,
readOnly: opts.readOnly,
});
opts.log(`[collab] ${provider.kind} connected on ${room}`);
opts.onStatus("Collab: connected");
@ -549,6 +553,8 @@ async function startSheetCollab(
onActiveChange: (active: ActiveSheet | null) => void;
/** Upload sink (project-backed sessions) — used to register a just-created subsheet. */
saveBytes?: SaveBytes;
/** Read-only viewer (read-only-viewer): see `createSheetCollabManager`. */
readOnly?: boolean;
log: (m: string) => void;
onStatus: (t: string) => void;
},
@ -577,8 +583,10 @@ async function startSheetCollab(
seedDocForPath: (sheet) => seedDocFromMemfs(win, opts.slug, sheet),
onActiveChange: opts.onActiveChange,
// Parked rooms carry a skeleton presence ("this user is on sheet X") so
// any sheet's roster shows the whole schematic's crew (0003).
presenceUser: presenceUser(),
// any sheet's roster shows the whole schematic's crew (0003). Read-only
// viewers publish none (invisible observer) — skeletons are broadcasts.
presenceUser: opts.readOnly ? undefined : presenceUser(),
readOnly: opts.readOnly,
log: opts.log,
initial:
opts.session && opts.targetPath
@ -680,6 +688,7 @@ export function WasmTool({
assetBaseUrl,
libsSource,
sourceDescriptor,
readOnly = false,
}: {
tool: Tool;
slug: string;
@ -715,6 +724,14 @@ export function WasmTool({
/** 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);
@ -725,6 +742,9 @@ export function WasmTool({
// 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);
@ -958,6 +978,9 @@ export function WasmTool({
// 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) => {
// 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);
@ -1015,6 +1038,9 @@ export function WasmTool({
// 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);
@ -1061,6 +1087,7 @@ export function WasmTool({
// 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();
@ -1118,31 +1145,39 @@ export function WasmTool({
});
// 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.
registerSaveHook(win, {
slug,
saveBytes,
saveBytes: readOnly ? undefined : saveBytes,
log: append,
onStatus: setStatus,
// 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) => {
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, text) => {
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)}`);
}
}
},
...(readOnly
? {}
: {
// 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)}`);
}
}
},
}),
});
const { session, targetBytes } = await maybeConnectDocSession(win, {
docSource,
@ -1166,6 +1201,31 @@ export function WasmTool({
log: append,
onStatus: setStatus,
});
// 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 } | undefined
)?.kicadSetReadOnly;
if (typeof setRo === "function") {
const t0 = Date.now();
while (setRo(true) !== true) {
if (Date.now() - t0 > 30_000) {
throw new Error("read-only lock did not apply");
}
await new Promise((r) => setTimeout(r, 150));
}
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)",
);
}
}
// 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.
@ -1177,7 +1237,9 @@ export function WasmTool({
const collabOptOut =
new URLSearchParams(win.location.search).get("collab") === "0" ||
new URLSearchParams(win.location.search).get("collab") === "false";
if ((tool === "pcbnew" || tool === "eeschema") && !collabOptOut) {
// 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({
projectId,
@ -1201,15 +1263,16 @@ export function WasmTool({
targetPath,
files,
session,
saveBytes,
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;
startPresence(activeRoom?.provider, activeRoom?.sheetPath);
startComments(activeRoom?.doc);
if (activeRoom) {
if (activeRoom && !readOnly) {
driftRef.current = startDriftDetection({
doc: activeRoom.doc,
mod: win.Module,
@ -1232,13 +1295,14 @@ export function WasmTool({
targetPath,
collabSession: session,
editorMatchesDoc: !!targetBytes,
readOnly,
log: append,
onStatus: setStatus,
});
collabDocRef.current = collabHandle?.doc ?? null;
startPresence(collabHandle?.provider);
startComments(collabHandle?.doc);
if (collabHandle && targetPath && COLLAB_TOOLS.has(tool)) {
if (collabHandle && targetPath && COLLAB_TOOLS.has(tool) && !readOnly) {
driftRef.current = startDriftDetection({
doc: collabHandle.doc,
mod: win.Module,
@ -1303,19 +1367,19 @@ export function WasmTool({
const appliedRef = React.useRef<boolean | null>(null);
React.useLayoutEffect(() => {
if (!setChromeFn) return;
if (appliedRef.current === chromeHidden) return;
if (appliedRef.current === null && !chromeHidden) return;
if (appliedRef.current === effectiveChromeHidden) return;
if (appliedRef.current === null && !effectiveChromeHidden) return;
const apply = () => {
try {
return setChromeFn(!chromeHidden) === true;
return setChromeFn(!effectiveChromeHidden) === true;
} catch (err) {
append(`[chrome] kicadSetChrome failed: ${String(err)}`);
return true; // don't retry a throwing binding
}
};
if (apply()) {
appliedRef.current = chromeHidden;
appliedRef.current = effectiveChromeHidden;
return;
}
// The editor frame can lag `ready` (waitForWxUi falls through after 25 s)
@ -1323,14 +1387,14 @@ export function WasmTool({
const t0 = Date.now();
const tick = window.setInterval(() => {
if (apply()) {
appliedRef.current = chromeHidden;
appliedRef.current = effectiveChromeHidden;
window.clearInterval(tick);
} else if (Date.now() - t0 > 30_000) {
window.clearInterval(tick);
}
}, 300);
return () => window.clearInterval(tick);
}, [setChromeFn, chromeHidden, append]);
}, [setChromeFn, effectiveChromeHidden, append]);
return (
<div className="relative h-screen w-screen overflow-hidden bg-[#1a1a2e]">
@ -1472,11 +1536,13 @@ export function WasmTool({
{/* Top-right overlay row: who else is in this file (awareness roster),
where this project lives / whether Save persists (chip hidden while
the UI is hidden), and the Figma-like hide/show-UI toggle the one
control that stays up in canvas-only mode. */}
control that stays up in canvas-only mode. Read-only sessions swap
the toggle for a "View only" pill (chrome stays force-hidden). */}
{ready &&
(setChromeFn !== null ||
(readOnly ||
setChromeFn !== null ||
peers.length > 0 ||
(sourceDescriptor && !chromeHidden)) && (
(sourceDescriptor && !effectiveChromeHidden)) && (
<div className="absolute right-3 top-3 z-20 flex items-center gap-2">
{peers.length > 0 && (
<PresenceRoster
@ -1489,10 +1555,18 @@ export function WasmTool({
}}
/>
)}
{sourceDescriptor && !chromeHidden && (
{sourceDescriptor && !effectiveChromeHidden && (
<SourceChip descriptor={sourceDescriptor} />
)}
{setChromeFn !== null && (
{readOnly && (
<span
data-testid="view-only-pill"
className="flex h-8 items-center rounded-full bg-black/70 px-3 text-xs font-medium text-white shadow-sm ring-1 ring-inset ring-white/20"
>
View only
</span>
)}
{setChromeFn !== null && !readOnly && (
<button
data-testid="chrome-toggle"
aria-pressed={chromeHidden}
@ -1566,7 +1640,7 @@ export function WasmTool({
</button>
)}
{!chromeHidden && (
{!effectiveChromeHidden && (
<div className="absolute bottom-0 left-0 right-0 z-20">
<button
className="flex items-center gap-1 bg-black/70 px-3 py-1 font-mono text-xs text-white"

View file

@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { resolveReadOnly, type ReadOnlyWindow } from "./read-only-mode";
/**
* Read-only session resolution (read-only-viewer): the server-granted
* `access` capability decides; `?readonly=1` can only NARROW (force viewer
* mode for tests / authz-free deployments), never widen.
*/
function fakeWin(search = ""): ReadOnlyWindow {
return { location: { search } };
}
describe("resolveReadOnly", () => {
it("follows the server capability", () => {
expect(resolveReadOnly("read", fakeWin())).toBe(true);
expect(resolveReadOnly("write", fakeWin())).toBe(false);
});
it("treats an absent capability as writable (authz-free backends)", () => {
expect(resolveReadOnly(undefined, fakeWin())).toBe(false);
});
it("?readonly=1 forces viewer mode regardless of capability", () => {
expect(resolveReadOnly(undefined, fakeWin("?readonly=1"))).toBe(true);
expect(resolveReadOnly("write", fakeWin("?foo=bar&readonly=true"))).toBe(true);
});
it("has no ?readonly=0 escape — a URL never widens a server grant", () => {
expect(resolveReadOnly("read", fakeWin("?readonly=0"))).toBe(true);
expect(resolveReadOnly("read", fakeWin("?readonly=false"))).toBe(true);
});
});

View file

@ -0,0 +1,28 @@
/**
* Read-only session resolution (read-only-viewer).
*
* A read-only session runs the editor as a pure viewer: chrome force-hidden,
* no presence/comments/drift, no save upload, the collab binding never seeds
* or pushes local edits, and the wasm frame is locked via kicadSetReadOnly
* (zoom/pan only). The signal is server-authoritative the project GET's
* `access` capability field ("read" for callers without write access) with
* a `?readonly=1` URL override for tests and authz-free GPL deployments
* (house `?mobile=` pattern). There is deliberately no `?readonly=0`: a URL
* parameter must never widen a server-granted capability, and the real
* enforcement lives in the sync server + wasm gates anyway.
*/
/** The window surface resolveReadOnly reads — narrow, so tests can fake it. */
export interface ReadOnlyWindow {
location: { search: string };
}
export function resolveReadOnly(
access: "read" | "write" | undefined,
win: ReadOnlyWindow = window,
): boolean {
const param = new URLSearchParams(win.location.search).get("readonly");
if (param === "1" || param === "true") return true;
// Absent ⇒ write: authz-free backends never emit the field.
return access === "read";
}

View file

@ -7,6 +7,7 @@ import {
useSourceDescriptor,
} from "@/lib/api";
import { docSourceConfig } from "@/lib/config";
import { resolveReadOnly } from "@/lib/read-only-mode";
import { WasmTool } from "@/components/WasmTool";
import { PreflightGate } from "@/preflight/PreflightGate";
@ -54,6 +55,12 @@ export function ToolPage() {
// on reload when it holds newer state; the upload is the registration + fallback copy.
const docSource = docSourceConfig();
// Read-only viewer (read-only-viewer): the server's `access` capability
// (or `?readonly=1`) turns this session into a pure viewer — no save
// upload (absent saveBytes ⇒ MEMFS-only saves), and WasmTool disables
// every other outbound writer + locks the wasm frame.
const readOnly = resolveReadOnly(data.access);
// PreflightGate runs the device-capability check; on a fatal mismatch it blocks
// here (before WasmTool mounts) so the expensive WASM asset fetch is skipped.
// fetch/upload go through the active project source (api.ts): a backend
@ -67,9 +74,14 @@ export function ToolPage() {
files={data.files}
targetPath={targetPath}
fetchBytes={(relPath) => fetchFileBytes(slug, relPath)}
saveBytes={(relPath, bytes) => uploadFileBytes(slug, relPath, bytes)}
saveBytes={
readOnly
? undefined
: (relPath, bytes) => uploadFileBytes(slug, relPath, bytes)
}
docSource={docSource}
sourceDescriptor={sourceDescriptor}
readOnly={readOnly}
/>
</PreflightGate>
);

View file

@ -64,6 +64,8 @@ export interface StartCollabOptions {
* snapshot. Ignored by the legacy scalar `startCollab`.
*/
seedDoc?: KicadDoc;
/** Read-only viewer (read-only-viewer): see `bindKicadCollab`. */
readOnly?: boolean;
}
export interface CollabHandle {
@ -149,9 +151,17 @@ export function attachKicadCollab(
mod: KicadItemsModule,
win: KicadItemsWindow,
session: KicadDocSession,
opts?: { seedDoc?: KicadDoc; editorMatchesDoc?: boolean },
opts?: { seedDoc?: KicadDoc; editorMatchesDoc?: boolean; readOnly?: boolean },
): KicadCollabHandle {
const binding = bindKicadCollab(session.doc, moduleItemsBridge(mod, win));
if (opts?.readOnly) {
// Invisible observer: drop the provider's initial empty awareness state so
// the viewer never appears in anyone's roster (the sync server drops these
// frames from read-only connections too — this keeps the client quiet).
session.provider.awareness?.setLocalState(null);
}
const binding = bindKicadCollab(session.doc, moduleItemsBridge(mod, win), {
readOnly: opts?.readOnly,
});
binding.seed(opts?.seedDoc, { editorMatchesDoc: opts?.editorMatchesDoc });
clog("attachKicadCollab: ready; doc items =", binding.items.size);
@ -181,5 +191,8 @@ export async function startKicadCollab(
): Promise<KicadCollabHandle> {
clog("startKicadCollab:", opts.provider.kind, "room =", opts.room);
const session = await connectKicadDoc({ provider: opts.provider, room: opts.room });
return attachKicadCollab(mod, win, session, { seedDoc: opts.seedDoc });
return attachKicadCollab(mod, win, session, {
seedDoc: opts.seedDoc,
readOnly: opts.readOnly,
});
}

View file

@ -9,6 +9,7 @@ import {
renderItem,
SEXPR_VERSION_CURRENT,
sexprToItems,
ydocHasState,
ydocSexprVersion,
yToDoc,
type KicadItem,
@ -344,7 +345,6 @@ describe("lib_symbols flow through the binding (miss 08A)", () => {
expect(symWire!.sexpr).toMatch(/^\(lib_symbols \(symbol "Device:R"/);
});
});
describe("sexprVersion skew guard (ysync 0009 §5)", () => {
it("binds a fresh (empty) room and a current-version doc", () => {
const { a, b } = pair();
@ -362,3 +362,83 @@ describe("sexprVersion skew guard (ysync 0009 §5)", () => {
expect(() => bindKicadCollab(doc, new FakeEditor())).toThrow(/update required/);
});
});
describe("bindKicadCollab — read-only viewer (read-only-viewer)", () => {
const WKS = `(kicad_wks (version 20220228) (generator "pl_editor")
(setup (textsize 1.5 1.5) (linewidth 0.15))
(rect (uuid "r-1") (name "border") (start 0 0 ltcorner) (end 0 0 rbcorner))
)
`;
it("never seeds an empty room — neither from the file nor the snapshot", () => {
const { a } = pair();
const viewer = new FakeEditor();
const seedDoc = fileToDoc(WKS);
// The viewer opened the file via the API fallback (room empty).
Object.assign(viewer.store, seedDoc.items);
bindKicadCollab(a, viewer, { readOnly: true }).seed(seedDoc);
// A writable binding would have file-seeded here; the viewer must not.
expect(ydocHasState(a)).toBe(false);
// And without a seedDoc, the editor-snapshot seed is skipped too.
const { b } = pair();
const viewer2 = new FakeEditor();
seedEditor(viewer2, FP);
bindKicadCollab(b, viewer2, { readOnly: true }).seed();
expect(ydocHasState(b)).toBe(false);
});
it("local edits never reach the doc (inert DOWN hook)", () => {
const { a, b } = pair();
const writer = new FakeEditor();
const viewer = new FakeEditor();
seedEditor(writer, FP);
bindKicadCollab(a, writer).seed();
const bindViewer = bindKicadCollab(b, viewer, { readOnly: true });
bindViewer.seed(); // adopts the writer's state
expect(viewer.store["fp-1"]).toBeDefined();
const appliedOnWriter = writer.applied.length;
viewer.localUpsert(`(pad "1" smd (at 9 9) (uuid "pad-1"))`, "fp-1");
// Nothing crossed: the writer's editor received no apply, and the shared
// doc still holds the writer's pad geometry.
expect(writer.applied.length).toBe(appliedOnWriter);
expect(writer.store["pad-1"]!.body).toEqual(
sexprToItems(`(pad "1" smd (at 0 0) (uuid "pad-1"))`, "fp-1").items["pad-1"]!.body,
);
});
it("remote edits still stream into the viewer (UP observer live)", () => {
const { a, b } = pair();
const writer = new FakeEditor();
const viewer = new FakeEditor();
seedEditor(writer, FP);
bindKicadCollab(a, writer).seed();
bindKicadCollab(b, viewer, { readOnly: true }).seed();
writer.localUpsert(`(pad "1" smd (at 5 5) (uuid "pad-1"))`, "fp-1");
expect(viewer.store["pad-1"]!.body).toEqual(
sexprToItems(`(pad "1" smd (at 5 5) (uuid "pad-1"))`, "fp-1").items["pad-1"]!.body,
);
});
it("a viewer parked on an empty room streams a late writer's seed in", () => {
const { a, b } = pair();
const viewer = new FakeEditor();
const writer = new FakeEditor();
const seedDoc = fileToDoc(WKS);
Object.assign(viewer.store, seedDoc.items);
Object.assign(writer.store, seedDoc.items);
// Viewer first (empty room, no seed), writer arrives later and file-seeds.
bindKicadCollab(a, viewer, { readOnly: true }).seed(seedDoc);
bindKicadCollab(b, writer).seed(seedDoc);
// The writer's seed reached the viewer's doc; the room is authored by the
// writer alone and stays file-recoverable.
expect(ydocHasState(a)).toBe(true);
expect(docToFile(yToDoc(a))).toBe(docToFile(seedDoc));
});
});

View file

@ -90,10 +90,26 @@ export class SexprVersionError extends Error {
}
}
export function bindKicadCollab(doc: Y.Doc, bridge: KicadItemsBridge): KicadBinding {
export function bindKicadCollab(
doc: Y.Doc,
bridge: KicadItemsBridge,
opts?: {
/**
* Read-only viewer (read-only-viewer): the binding never writes the Y.Doc
* the DOWN hook is inert (zero local-edit pushes even if a wasm gate were
* bypassed) and seed() skips both seeding branches (a viewer must never
* author a room). The UP observer and the adopt branch stay live, so
* remote edits keep rendering. The sync server enforces the same thing
* server-side; this keeps the client honest and quiet.
*/
readOnly?: boolean;
},
): KicadBinding {
const readOnly = opts?.readOnly === true;
// Version skew guard — callers bind AFTER the provider's initial sync, so the
// doc's version is authoritative here (an empty room reads as v1 and is
// stamped CURRENT by the first write).
// stamped CURRENT by the first write). A read-only viewer never writes, but
// it must not adopt a doc it can't correctly render either, so still guard.
const version = ydocSexprVersion(doc);
if (!SEXPR_VERSION_SUPPORTED.includes(version)) throw new SexprVersionError(version);
const items = kicadItemsMap(doc);
@ -133,6 +149,7 @@ export function bindKicadCollab(doc: Y.Doc, bridge: KicadItemsBridge): KicadBind
// DOWN: local editor change → Y.Doc
bridge.onItems((json: string) => {
if (readOnly) return; // viewer: local state never reaches the doc
if (destroyed) return; // stale hook (bug 07) — a destroyed binding is inert
let wire: ItemsWireDelta;
try {
@ -193,6 +210,13 @@ export function bindKicadCollab(doc: Y.Doc, bridge: KicadItemsBridge): KicadBind
return;
}
if (!ydocHasState(doc) && seedDoc) {
if (readOnly) {
// A viewer never authors a room. The editor keeps showing the file it
// opened; when a writer later seeds this room, the (now-open) UP
// observer streams their state in.
clog("seed: read-only viewer on an empty room — not seeding");
return;
}
// First tab, file-seeded: write the FULL doc (meta + layout + items) so
// the Y.Doc — not the editor snapshot — is the lossless source of truth
// (the file is recoverable via docToFile). The editor already opened the
@ -248,6 +272,10 @@ export function bindKicadCollab(doc: Y.Doc, bridge: KicadItemsBridge): KicadBind
const hasState = ydocHasState(doc);
if (!hasState) {
if (readOnly) {
clog("seed: read-only viewer on an empty room — not snapshot-seeding");
return;
}
// First tab, no file source: seed the shared doc from the editor model.
const local = itemsWireToDelta(wire, {});
clog(`seed: doc empty → SEEDING from editor snapshot (${local.added.length} item(s))`);

View file

@ -95,6 +95,14 @@ export interface SheetManagerOptions {
* the skeleton on rebind.
*/
presenceUser?: PresenceUser;
/**
* Read-only viewer (read-only-viewer): every room's binding is created
* read-only (never seeds, never pushes local edits see bindKicadCollab)
* and each connected room's initial awareness state is dropped so the
* viewer stays out of rosters. Pass `presenceUser: undefined` alongside
* skeleton presence is a broadcast too.
*/
readOnly?: boolean;
log: (m: string) => void;
/**
* `docSource: "ydoc"` only: the entry sheet's room is already connected (and possibly
@ -139,6 +147,7 @@ export function createSheetCollabManager(opts: SheetManagerOptions): SheetCollab
if (opts.initial) {
const { sheetPath, session, editorMatchesDoc } = opts.initial;
if (opts.readOnly) session.provider.awareness?.setLocalState(null);
rooms.set(sheetPath, {
session,
doc: session.doc,
@ -172,6 +181,9 @@ export function createSheetCollabManager(opts: SheetManagerOptions): SheetCollab
provider,
room: collabRoomId(projectId, sheetPath),
});
// Invisible observer (read-only-viewer): drop the provider's initial
// empty awareness state before anyone can see it.
if (opts.readOnly) session.provider.awareness?.setLocalState(null);
const room: Room = {
session,
doc: session.doc,
@ -233,7 +245,7 @@ export function createSheetCollabManager(opts: SheetManagerOptions): SheetCollab
room.detachWatch?.();
room.detachWatch = undefined;
const binding = bindKicadCollab(room.doc, bridge);
const binding = bindKicadCollab(room.doc, bridge, { readOnly: opts.readOnly });
room.binding = binding;
if (!room.seeded) {