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
2
kicad
2
kicad
|
|
@ -1 +1 @@
|
|||
Subproject commit 8a25db244dec1362ed276deb2e06ef76ad196337
|
||||
Subproject commit aeb2b7d72828ab28acd6400fa7bf7faab69b8124
|
||||
242
tests/web/read-only-editor.spec.ts
Normal file
242
tests/web/read-only-editor.spec.ts
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
import { test, expect, type Browser, type Page } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Read-only viewer e2e (read-only-viewer): `?readonly=1` boots the pcbnew
|
||||
* editor as a locked viewer — chrome force-hidden with no toggle, the
|
||||
* Cmd/Ctrl+\ chord inert, nothing selectable or deletable through the REAL
|
||||
* input paths (the kicad PCBJAM_READ_ONLY gates), zoom/pan alive — while a
|
||||
* writer tab on the same board (broadcastchannel room) stays fully editable.
|
||||
*
|
||||
* The viewer boots FIRST on the fresh room (fresh browser context ⇒ empty
|
||||
* broadcastchannel room): a read-only binding must not seed it; the writer
|
||||
* then file-seeds as the first author, exactly like production where the
|
||||
* viewer's server connection is read-only (enforced in the closed repo).
|
||||
*
|
||||
* Boots once (beforeAll) and runs over the shared pages — the config is
|
||||
* workers:1 / fullyParallel:false, so file order holds.
|
||||
*/
|
||||
|
||||
const SCOPE = 'default';
|
||||
const FRONTEND_URL = process.env.WEB_APP_URL ?? 'http://localhost:3048';
|
||||
|
||||
type Mod = {
|
||||
kicadCollabGetSelection(): string;
|
||||
kicadCollabGetViewport(): string;
|
||||
kicadCollabTestSelectFirst(): string;
|
||||
kicadCollabTestClearSelection(): boolean;
|
||||
kicadCollabGetPos(id: string): string;
|
||||
kicadCollabTestMoveFirst(dx: number, dy: number): string;
|
||||
};
|
||||
type W = { Module: Mod };
|
||||
|
||||
let viewer: Page;
|
||||
let writer: Page;
|
||||
|
||||
async function bootBoard(page: Page, params: string, user: string): Promise<void> {
|
||||
await page.goto(`/${SCOPE}/projects/demo/demo.kicad_pcb?user=${user}${params}`);
|
||||
await expect(page.locator('#canvas')).toBeVisible({ timeout: 180000 });
|
||||
await expect
|
||||
.poll(() => page.title(), {
|
||||
message: `${user}: board editor never reached the expected title`,
|
||||
timeout: 120000,
|
||||
intervals: [1000],
|
||||
})
|
||||
.toMatch(/demo — PCB Editor/i);
|
||||
// Loading overlays (boot + lib fat-load, both `inset-0 z-30`) gone before
|
||||
// geometry/selection is trusted.
|
||||
await expect(page.locator('div.inset-0.z-30')).toHaveCount(0, { timeout: 180000 });
|
||||
}
|
||||
|
||||
/** Count of visible menubar titles (0 ⇒ menubar hidden) — chrome-toggle.spec.ts. */
|
||||
async function visibleMenuTitles(pg: Page): Promise<number> {
|
||||
return pg.evaluate(
|
||||
() =>
|
||||
Array.from(document.querySelectorAll('.wx-menu-title')).filter((el) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
return r.width > 0 && r.height > 0 && getComputedStyle(el).display !== 'none';
|
||||
}).length,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a world position ("x,y" internal units, kicadCollabGetPos) to CSS pixels
|
||||
* through the page's live GAL viewport — the same mapping the comment pins
|
||||
* render through (comments-viewport-resize.spec.ts).
|
||||
*/
|
||||
async function screenPosOf(pg: Page, worldCsv: string): Promise<{ x: number; y: number }> {
|
||||
return pg.evaluate((csv: string) => {
|
||||
const win = window as unknown as W;
|
||||
const [wx, wy] = csv.split(',').map(Number);
|
||||
const vp = JSON.parse(win.Module.kicadCollabGetViewport()) as {
|
||||
cx: number; cy: number; scale: number; w: number; h: number;
|
||||
};
|
||||
const gl = Array.from(document.querySelectorAll('[id^="glcanvas-"]')).find((c) => {
|
||||
const r = (c as HTMLElement).getBoundingClientRect();
|
||||
return getComputedStyle(c as HTMLElement).display !== 'none' && r.width > 0;
|
||||
}) as HTMLElement;
|
||||
const r = gl.getBoundingClientRect();
|
||||
const ratio = r.width / vp.w;
|
||||
return {
|
||||
x: r.x + ((wx - vp.cx) * vp.scale + vp.w / 2) * ratio,
|
||||
y: r.y + ((wy - vp.cy) * vp.scale + vp.h / 2) * ratio,
|
||||
};
|
||||
}, worldCsv);
|
||||
}
|
||||
|
||||
const selection = (pg: Page) =>
|
||||
pg.evaluate(() => JSON.parse((window as unknown as W).Module.kicadCollabGetSelection()));
|
||||
const posOf = (pg: Page, id: string) =>
|
||||
pg.evaluate((i: string) => (window as unknown as W).Module.kicadCollabGetPos(i), id);
|
||||
// Zoom probe: w/h are the fixed canvas pixel size — zoom moves `scale`.
|
||||
const viewportScale = (pg: Page) =>
|
||||
pg.evaluate(
|
||||
() =>
|
||||
(JSON.parse((window as unknown as W).Module.kicadCollabGetViewport()) as { scale: number })
|
||||
.scale,
|
||||
);
|
||||
|
||||
test.beforeAll(async ({ browser }: { browser: Browser }) => {
|
||||
test.setTimeout(480_000); // two full board boots
|
||||
const ctx = await browser.newContext({ baseURL: FRONTEND_URL });
|
||||
// Viewer FIRST: an empty room a read-only binding must NOT seed.
|
||||
viewer = await ctx.newPage();
|
||||
await bootBoard(viewer, '&readonly=1', 'viewer');
|
||||
// Writer second: file-seeds the room the viewer left untouched.
|
||||
writer = await ctx.newPage();
|
||||
await bootBoard(writer, '', 'writer');
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await writer?.close();
|
||||
await viewer?.close();
|
||||
});
|
||||
|
||||
test('viewer boots locked: chrome-less, nothing selectable, hotkey edits inert, zoom alive', async () => {
|
||||
test.setTimeout(240_000);
|
||||
|
||||
// Chrome force-hidden: no menubar, no console footer, no toggle — the
|
||||
// "View only" pill is the one read-only affordance; Ctrl+\ stays inert.
|
||||
await expect.poll(() => visibleMenuTitles(viewer), { timeout: 15000 }).toBe(0);
|
||||
await expect(viewer.getByText(/console \(/)).toHaveCount(0);
|
||||
await expect(viewer.locator('[data-testid="chrome-toggle"]')).toHaveCount(0);
|
||||
await expect(viewer.getByTestId('view-only-pill')).toBeVisible();
|
||||
await viewer.keyboard.press('Control+\\');
|
||||
// Bounded settle via a real round-trip (no blind sleep): the canvas keeps
|
||||
// its full-bleed width through the keypress.
|
||||
const vp = viewer.viewportSize()!;
|
||||
const glWidth = () =>
|
||||
viewer.evaluate(() => {
|
||||
const gl = Array.from(document.querySelectorAll('[id^="glcanvas-"]')).find((c) => {
|
||||
const r = (c as HTMLElement).getBoundingClientRect();
|
||||
return getComputedStyle(c as HTMLElement).display !== 'none' && r.width > 0;
|
||||
}) as HTMLElement | undefined;
|
||||
return gl ? gl.getBoundingClientRect().width : 0;
|
||||
});
|
||||
await expect.poll(glWidth, { timeout: 15000 }).toBeGreaterThan(vp.width * 0.95);
|
||||
expect(await visibleMenuTitles(viewer)).toBe(0);
|
||||
|
||||
// The writer beside it keeps the full UI (positive control for the above).
|
||||
await expect(writer.locator('[data-testid="chrome-toggle"]')).toBeVisible();
|
||||
await expect(writer.getByTestId('view-only-pill')).toHaveCount(0);
|
||||
|
||||
// No presence/comments surfaces for a viewer.
|
||||
await expect(viewer.locator('[data-testid="presence-roster"]')).toHaveCount(0);
|
||||
expect(
|
||||
await viewer.evaluate(
|
||||
() => '__pcbjamComments' in (window as unknown as Record<string, unknown>),
|
||||
),
|
||||
).toBe(false);
|
||||
|
||||
// ── the kicad gates, probed through the REAL paths ─────────────────────────
|
||||
// A known item + its world position (the test hook force-selects on the
|
||||
// WRITER — fine there — and is cleared again before the click probes).
|
||||
const itemId = await writer.evaluate(() =>
|
||||
(window as unknown as W).Module.kicadCollabTestSelectFirst(),
|
||||
);
|
||||
expect(itemId, 'demo board should have a first item').toBeTruthy();
|
||||
const itemWorld = await posOf(writer, itemId);
|
||||
expect(itemWorld).toContain(',');
|
||||
await writer.evaluate(() => (window as unknown as W).Module.kicadCollabTestClearSelection());
|
||||
|
||||
// Gate 2 (Selectable): a real canvas click ON the item finds candidates for
|
||||
// the writer — a selection, or KiCad's clarify popup when several items
|
||||
// overlap ("Show More Choices" is unique to it) — and NOTHING for the
|
||||
// viewer: the gate empties the collector, so neither selection nor popup
|
||||
// can appear (both boards boot zoom-fit; each page maps through its own
|
||||
// live viewport).
|
||||
const writerClick = await screenPosOf(writer, itemWorld);
|
||||
await writer.mouse.click(writerClick.x, writerClick.y);
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(await selection(writer)).length > 0 ||
|
||||
(await writer.getByText(/Show More Choices/).count()) > 0,
|
||||
{
|
||||
timeout: 20000,
|
||||
message: "writer's click should select the item or pop the clarify menu",
|
||||
},
|
||||
)
|
||||
.toBe(true);
|
||||
await writer.keyboard.press('Escape'); // dismiss a clarify popup, drop any selection
|
||||
await writer.evaluate(() => (window as unknown as W).Module.kicadCollabTestClearSelection());
|
||||
|
||||
const viewerClick = await screenPosOf(viewer, itemWorld);
|
||||
await viewer.mouse.click(viewerClick.x, viewerClick.y);
|
||||
expect(await selection(viewer), 'viewer click on the item must select nothing').toEqual([]);
|
||||
await expect(viewer.getByText(/Show More Choices/)).toHaveCount(0);
|
||||
|
||||
// Gate 1 (action allowlist): even with an item force-selected through the
|
||||
// test hook (AddItemToSel bypasses Selectable by design), the Delete hotkey
|
||||
// is swallowed — the item survives on the viewer's own board.
|
||||
const forced = await viewer.evaluate(() =>
|
||||
(window as unknown as W).Module.kicadCollabTestSelectFirst(),
|
||||
);
|
||||
expect(forced).toBe(itemId); // same file, same board order
|
||||
const posBefore = await posOf(viewer, forced);
|
||||
expect(posBefore).toBeTruthy();
|
||||
await viewer.keyboard.press('Delete');
|
||||
// Documented interaction dwell: a hotkey delete commits within a frame or
|
||||
// two and has no observable on the swallowed path — give it time to have
|
||||
// acted if it were going to, then assert nothing happened.
|
||||
await viewer.waitForTimeout(800); // dwell
|
||||
expect(await posOf(viewer, forced), 'Delete must be swallowed in read-only').toBe(posBefore);
|
||||
|
||||
// Zoom stays alive on the viewer (wheel over the canvas changes the scale).
|
||||
const canvasBox = (await viewer.locator('#canvas').boundingBox())!;
|
||||
const scaleBefore = await viewportScale(viewer);
|
||||
await viewer.mouse.move(canvasBox.x + canvasBox.width / 2, canvasBox.y + canvasBox.height / 2);
|
||||
await viewer.mouse.wheel(0, -240);
|
||||
await expect.poll(() => viewportScale(viewer), { timeout: 15000 }).not.toBe(scaleBefore);
|
||||
|
||||
await viewer.screenshot({ path: 'test-results/web-read-only-viewer.png', scale: 'css' });
|
||||
});
|
||||
|
||||
test("a writer's edits stream into the viewer live (and never the reverse)", async () => {
|
||||
// Blocked by a PRE-EXISTING applyItems regression at kicad 8364527: the
|
||||
// board-envelope wrap trips "1 is not a valid layer count" in the clipboard
|
||||
// parser, so NO cross-tab item apply lands — verified failing identically
|
||||
// for two fully WRITABLE tabs on a fresh kicad_editor build (the previous
|
||||
// CI artifacts were built at an older kicad rev). Un-fixme once the
|
||||
// envelope/parser mismatch is fixed on main.
|
||||
test.fixme();
|
||||
|
||||
const itemId = await writer.evaluate(() =>
|
||||
(window as unknown as W).Module.kicadCollabTestSelectFirst(),
|
||||
);
|
||||
await writer.evaluate(() => (window as unknown as W).Module.kicadCollabTestClearSelection());
|
||||
const posBefore = await posOf(viewer, itemId);
|
||||
|
||||
// 2 mm — small nudges vanish in s-expr formatting (see ysync-two-tab.spec.ts).
|
||||
const moved = await writer.evaluate(() =>
|
||||
(window as unknown as W).Module.kicadCollabTestMoveFirst(2_000_000, 0),
|
||||
);
|
||||
expect(moved).toBe(itemId);
|
||||
await expect
|
||||
.poll(() => posOf(viewer, itemId), {
|
||||
timeout: 20000,
|
||||
message: "the writer's move never reached the viewer",
|
||||
})
|
||||
.not.toBe(posBefore);
|
||||
// The viewer's board still matches the writer's (nothing flowed back).
|
||||
expect(await posOf(writer, itemId)).toBe(await posOf(viewer, itemId));
|
||||
});
|
||||
|
|
@ -22,6 +22,8 @@
|
|||
#include <nlohmann/json.hpp>
|
||||
#include <kiid.h>
|
||||
#include <layer_ids.h>
|
||||
#include <pcbjam_read_only.h>
|
||||
#include <project.h>
|
||||
#include <schematic.h>
|
||||
#include <sch_edit_frame.h>
|
||||
#include <sch_io/kicad_sexpr/sch_io_kicad_sexpr.h>
|
||||
|
|
@ -91,6 +93,24 @@ bool kicadOpenFile( std::string path )
|
|||
return frame->OpenProjectFiles(
|
||||
std::vector<wxString>( 1, wxString::FromUTF8( path.c_str() ) ) );
|
||||
}
|
||||
|
||||
// Read-only viewer lock (read-only-viewer): flips the process-global
|
||||
// PCBJAM_READ_ONLY flag consumed by TOOL_MANAGER (view-only action allowlist)
|
||||
// and the selection tools (nothing selectable), and mirrors it onto the
|
||||
// project so the setup dialogs grey out. Returns false until the editor frame
|
||||
// exists so JS polls; the shell fails CLOSED if it never applies.
|
||||
bool kicadSetReadOnly( bool aReadOnly )
|
||||
{
|
||||
KIWAY_PLAYER* frame =
|
||||
wxTheApp ? dynamic_cast<KIWAY_PLAYER*>( wxTheApp->GetTopWindow() ) : nullptr;
|
||||
|
||||
if( !frame )
|
||||
return false;
|
||||
|
||||
PCBJAM_READ_ONLY::Set( aReadOnly );
|
||||
frame->Prj().SetReadOnly( aReadOnly );
|
||||
return true;
|
||||
}
|
||||
#endif // !KICAD_MERGED_EMBIND
|
||||
|
||||
// ───────────────────────────── Yjs collaborative bridge ─────────────────────────────
|
||||
|
|
@ -1714,6 +1734,8 @@ EMSCRIPTEN_BINDINGS(eeschema) {
|
|||
// registered once by kicad_editor_embind.cpp, dispatching on the active frame.
|
||||
// Programmatic file open (preferred over UI automation from the web app).
|
||||
function("kicadOpenFile", &kicadOpenFile);
|
||||
// Read-only viewer lock (read-only-viewer).
|
||||
function("kicadSetReadOnly", &kicadSetReadOnly);
|
||||
// Yjs collaborative bridge entry points (same contract as pl_editor).
|
||||
function("kicadCollabApply", &schCollabApply);
|
||||
function("kicadCollabSnapshot", &schCollabSnapshot);
|
||||
|
|
|
|||
|
|
@ -36,6 +36,8 @@
|
|||
#include <wx/aui/framemanager.h>
|
||||
#include <kiway.h>
|
||||
#include <kiway_player.h>
|
||||
#include <pcbjam_read_only.h>
|
||||
#include <project.h>
|
||||
|
||||
#include "pcbjam_libs_reload.h"
|
||||
|
||||
|
|
@ -255,6 +257,27 @@ static bool kicadSetChrome( bool aShow )
|
|||
}
|
||||
|
||||
|
||||
// Read-only viewer lock (read-only-viewer): flips the process-global
|
||||
// PCBJAM_READ_ONLY flag consumed by TOOL_MANAGER (view-only action allowlist)
|
||||
// and the selection tools (nothing selectable), and mirrors it onto the
|
||||
// project so the setup dialogs grey out. Zoom/pan stay live (mouse/touch
|
||||
// bypass the tool system; keyboard zoom/pan is allowlisted). Returns false
|
||||
// until the editor frame exists — main() builds it after runtime init — so
|
||||
// JS polls this; the shell fails CLOSED if it never applies.
|
||||
static bool kicadSetReadOnly( bool aReadOnly )
|
||||
{
|
||||
KIWAY_PLAYER* frame =
|
||||
wxTheApp ? dynamic_cast<KIWAY_PLAYER*>( wxTheApp->GetTopWindow() ) : nullptr;
|
||||
|
||||
if( !frame )
|
||||
return false;
|
||||
|
||||
PCBJAM_READ_ONLY::Set( aReadOnly );
|
||||
frame->Prj().SetReadOnly( aReadOnly );
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
// C++ → JS save notification. Called from BOTH fork save chokepoints
|
||||
// (PCB_EDIT_FRAME::SavePcbFile and SCH_EDIT_FRAME::saveSchematicFile) — one shared
|
||||
// definition serves the merged image. No-op without a JS listener.
|
||||
|
|
@ -427,6 +450,9 @@ EMSCRIPTEN_BINDINGS(kicad_editor) {
|
|||
// Canvas-only mobile mode (features/mobile).
|
||||
function("kicadSetChrome", &kicadSetChrome);
|
||||
|
||||
// Read-only viewer lock (read-only-viewer).
|
||||
function("kicadSetReadOnly", &kicadSetReadOnly);
|
||||
|
||||
// Yjs collaborative bridge entry points — same JS contract as the standalone
|
||||
// bundles, dispatched on the active editor frame.
|
||||
function("kicadCollabApply", &collabApply);
|
||||
|
|
|
|||
|
|
@ -44,6 +44,8 @@
|
|||
#include <tool/coroutine.h>
|
||||
#include <tool/tool_manager.h>
|
||||
#include <pcbjam_remote_lock.h>
|
||||
#include <pcbjam_read_only.h>
|
||||
#include <project.h>
|
||||
#include <view/view.h>
|
||||
#include <view/view_overlay.h>
|
||||
#include <pcb_draw_panel_gal.h>
|
||||
|
|
@ -93,6 +95,24 @@ bool kicadOpenFile( std::string path )
|
|||
return frame->OpenProjectFiles(
|
||||
std::vector<wxString>( 1, wxString::FromUTF8( path.c_str() ) ) );
|
||||
}
|
||||
|
||||
// Read-only viewer lock (read-only-viewer): flips the process-global
|
||||
// PCBJAM_READ_ONLY flag consumed by TOOL_MANAGER (view-only action allowlist)
|
||||
// and the selection tools (nothing selectable), and mirrors it onto the
|
||||
// project so the setup dialogs grey out. Returns false until the editor frame
|
||||
// exists so JS polls; the shell fails CLOSED if it never applies.
|
||||
bool kicadSetReadOnly( bool aReadOnly )
|
||||
{
|
||||
KIWAY_PLAYER* frame =
|
||||
wxTheApp ? dynamic_cast<KIWAY_PLAYER*>( wxTheApp->GetTopWindow() ) : nullptr;
|
||||
|
||||
if( !frame )
|
||||
return false;
|
||||
|
||||
PCBJAM_READ_ONLY::Set( aReadOnly );
|
||||
frame->Prj().SetReadOnly( aReadOnly );
|
||||
return true;
|
||||
}
|
||||
#endif // !KICAD_MERGED_EMBIND
|
||||
|
||||
// ───────────────────────────── Yjs collaborative bridge ─────────────────────────────
|
||||
|
|
@ -1980,6 +2000,8 @@ EMSCRIPTEN_BINDINGS(pcbnew) {
|
|||
// registered once by kicad_editor_embind.cpp, dispatching on the active frame.
|
||||
// Programmatic file open (preferred over UI automation from the web app).
|
||||
function("kicadOpenFile", &kicadOpenFile);
|
||||
// Read-only viewer lock (read-only-viewer).
|
||||
function("kicadSetReadOnly", &kicadSetReadOnly);
|
||||
// Yjs collaborative bridge entry points (same contract as pl_editor / eeschema).
|
||||
function("kicadCollabApply", &pcbCollabApply);
|
||||
function("kicadCollabSnapshot", &pcbCollabSnapshot);
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@
|
|||
#include <nlohmann/json.hpp>
|
||||
#include <eda_draw_frame.h>
|
||||
#include <kiid.h>
|
||||
#include <pcbjam_read_only.h>
|
||||
#include <project.h>
|
||||
#include <font/text_attributes.h>
|
||||
#include <drawing_sheet/ds_data_model.h>
|
||||
#include <drawing_sheet/ds_data_item.h>
|
||||
|
|
@ -48,6 +50,24 @@ bool kicadOpenFile( std::string path )
|
|||
std::vector<wxString>( 1, wxString::FromUTF8( path.c_str() ) ) );
|
||||
}
|
||||
|
||||
// Read-only viewer lock (read-only-viewer): flips the process-global
|
||||
// PCBJAM_READ_ONLY flag consumed by TOOL_MANAGER (view-only action allowlist)
|
||||
// and the selection tools (nothing selectable), and mirrors it onto the
|
||||
// project so the setup dialogs grey out. Returns false until the editor frame
|
||||
// exists so JS polls; the shell fails CLOSED if it never applies.
|
||||
bool kicadSetReadOnly( bool aReadOnly )
|
||||
{
|
||||
KIWAY_PLAYER* frame =
|
||||
wxTheApp ? dynamic_cast<KIWAY_PLAYER*>( wxTheApp->GetTopWindow() ) : nullptr;
|
||||
|
||||
if( !frame )
|
||||
return false;
|
||||
|
||||
PCBJAM_READ_ONLY::Set( aReadOnly );
|
||||
frame->Prj().SetReadOnly( aReadOnly );
|
||||
return true;
|
||||
}
|
||||
|
||||
// Programmatically save the in-memory drawing sheet to a .kicad_wks file, without
|
||||
// driving the Save As dialog. pl_editor edits the process-wide singleton
|
||||
// DS_DATA_MODEL::GetTheInstance(), so this serializes exactly what the editor has
|
||||
|
|
@ -553,6 +573,8 @@ std::string kicadCollabTestAddText( std::string aText, double aX, double aY )
|
|||
EMSCRIPTEN_BINDINGS(pl_editor) {
|
||||
// Programmatic file open (preferred over UI automation from the web app).
|
||||
function("kicadOpenFile", &kicadOpenFile);
|
||||
// Read-only viewer lock (read-only-viewer).
|
||||
function("kicadSetReadOnly", &kicadSetReadOnly);
|
||||
function("kicadSaveDrawingSheet", &kicadSaveDrawingSheet);
|
||||
// Yjs collaborative bridge entry points.
|
||||
function("kicadCollabApply", &kicadCollabApply);
|
||||
|
|
|
|||
|
|
@ -190,6 +190,8 @@ async function main(): Promise<void> {
|
|||
body: {
|
||||
project: await project(params.scope),
|
||||
files: await walk(PROJECT_DIR),
|
||||
// No auth here: every caller may write (explicit contract conformance).
|
||||
access: "write" as const,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
Subproject commit e847ee42da2cf988221c774a1041db3f113dccc8
|
||||
Subproject commit 77eea4933734fbb410c9dabbede970e84944d4a0
|
||||
|
|
@ -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