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:
parent
f07b9970d5
commit
bb01f5d9e6
16 changed files with 665 additions and 49 deletions
|
|
@ -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"
|
||||
|
|
|
|||
33
web/standalone/src/lib/read-only-mode.test.ts
Normal file
33
web/standalone/src/lib/read-only-mode.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
28
web/standalone/src/lib/read-only-mode.ts
Normal file
28
web/standalone/src/lib/read-only-mode.ts
Normal 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";
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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))`);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue