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 <nlohmann/json.hpp>
|
||||||
#include <kiid.h>
|
#include <kiid.h>
|
||||||
#include <layer_ids.h>
|
#include <layer_ids.h>
|
||||||
|
#include <pcbjam_read_only.h>
|
||||||
|
#include <project.h>
|
||||||
#include <schematic.h>
|
#include <schematic.h>
|
||||||
#include <sch_edit_frame.h>
|
#include <sch_edit_frame.h>
|
||||||
#include <sch_io/kicad_sexpr/sch_io_kicad_sexpr.h>
|
#include <sch_io/kicad_sexpr/sch_io_kicad_sexpr.h>
|
||||||
|
|
@ -91,6 +93,24 @@ bool kicadOpenFile( std::string path )
|
||||||
return frame->OpenProjectFiles(
|
return frame->OpenProjectFiles(
|
||||||
std::vector<wxString>( 1, wxString::FromUTF8( path.c_str() ) ) );
|
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
|
#endif // !KICAD_MERGED_EMBIND
|
||||||
|
|
||||||
// ───────────────────────────── Yjs collaborative bridge ─────────────────────────────
|
// ───────────────────────────── Yjs collaborative bridge ─────────────────────────────
|
||||||
|
|
@ -1714,6 +1734,8 @@ EMSCRIPTEN_BINDINGS(eeschema) {
|
||||||
// registered once by kicad_editor_embind.cpp, dispatching on the active frame.
|
// registered once by kicad_editor_embind.cpp, dispatching on the active frame.
|
||||||
// Programmatic file open (preferred over UI automation from the web app).
|
// Programmatic file open (preferred over UI automation from the web app).
|
||||||
function("kicadOpenFile", &kicadOpenFile);
|
function("kicadOpenFile", &kicadOpenFile);
|
||||||
|
// Read-only viewer lock (read-only-viewer).
|
||||||
|
function("kicadSetReadOnly", &kicadSetReadOnly);
|
||||||
// Yjs collaborative bridge entry points (same contract as pl_editor).
|
// Yjs collaborative bridge entry points (same contract as pl_editor).
|
||||||
function("kicadCollabApply", &schCollabApply);
|
function("kicadCollabApply", &schCollabApply);
|
||||||
function("kicadCollabSnapshot", &schCollabSnapshot);
|
function("kicadCollabSnapshot", &schCollabSnapshot);
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,8 @@
|
||||||
#include <wx/aui/framemanager.h>
|
#include <wx/aui/framemanager.h>
|
||||||
#include <kiway.h>
|
#include <kiway.h>
|
||||||
#include <kiway_player.h>
|
#include <kiway_player.h>
|
||||||
|
#include <pcbjam_read_only.h>
|
||||||
|
#include <project.h>
|
||||||
|
|
||||||
#include "pcbjam_libs_reload.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
|
// C++ → JS save notification. Called from BOTH fork save chokepoints
|
||||||
// (PCB_EDIT_FRAME::SavePcbFile and SCH_EDIT_FRAME::saveSchematicFile) — one shared
|
// (PCB_EDIT_FRAME::SavePcbFile and SCH_EDIT_FRAME::saveSchematicFile) — one shared
|
||||||
// definition serves the merged image. No-op without a JS listener.
|
// 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).
|
// Canvas-only mobile mode (features/mobile).
|
||||||
function("kicadSetChrome", &kicadSetChrome);
|
function("kicadSetChrome", &kicadSetChrome);
|
||||||
|
|
||||||
|
// Read-only viewer lock (read-only-viewer).
|
||||||
|
function("kicadSetReadOnly", &kicadSetReadOnly);
|
||||||
|
|
||||||
// Yjs collaborative bridge entry points — same JS contract as the standalone
|
// Yjs collaborative bridge entry points — same JS contract as the standalone
|
||||||
// bundles, dispatched on the active editor frame.
|
// bundles, dispatched on the active editor frame.
|
||||||
function("kicadCollabApply", &collabApply);
|
function("kicadCollabApply", &collabApply);
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,8 @@
|
||||||
#include <tool/coroutine.h>
|
#include <tool/coroutine.h>
|
||||||
#include <tool/tool_manager.h>
|
#include <tool/tool_manager.h>
|
||||||
#include <pcbjam_remote_lock.h>
|
#include <pcbjam_remote_lock.h>
|
||||||
|
#include <pcbjam_read_only.h>
|
||||||
|
#include <project.h>
|
||||||
#include <view/view.h>
|
#include <view/view.h>
|
||||||
#include <view/view_overlay.h>
|
#include <view/view_overlay.h>
|
||||||
#include <pcb_draw_panel_gal.h>
|
#include <pcb_draw_panel_gal.h>
|
||||||
|
|
@ -93,6 +95,24 @@ bool kicadOpenFile( std::string path )
|
||||||
return frame->OpenProjectFiles(
|
return frame->OpenProjectFiles(
|
||||||
std::vector<wxString>( 1, wxString::FromUTF8( path.c_str() ) ) );
|
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
|
#endif // !KICAD_MERGED_EMBIND
|
||||||
|
|
||||||
// ───────────────────────────── Yjs collaborative bridge ─────────────────────────────
|
// ───────────────────────────── Yjs collaborative bridge ─────────────────────────────
|
||||||
|
|
@ -1980,6 +2000,8 @@ EMSCRIPTEN_BINDINGS(pcbnew) {
|
||||||
// registered once by kicad_editor_embind.cpp, dispatching on the active frame.
|
// registered once by kicad_editor_embind.cpp, dispatching on the active frame.
|
||||||
// Programmatic file open (preferred over UI automation from the web app).
|
// Programmatic file open (preferred over UI automation from the web app).
|
||||||
function("kicadOpenFile", &kicadOpenFile);
|
function("kicadOpenFile", &kicadOpenFile);
|
||||||
|
// Read-only viewer lock (read-only-viewer).
|
||||||
|
function("kicadSetReadOnly", &kicadSetReadOnly);
|
||||||
// Yjs collaborative bridge entry points (same contract as pl_editor / eeschema).
|
// Yjs collaborative bridge entry points (same contract as pl_editor / eeschema).
|
||||||
function("kicadCollabApply", &pcbCollabApply);
|
function("kicadCollabApply", &pcbCollabApply);
|
||||||
function("kicadCollabSnapshot", &pcbCollabSnapshot);
|
function("kicadCollabSnapshot", &pcbCollabSnapshot);
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,8 @@
|
||||||
#include <nlohmann/json.hpp>
|
#include <nlohmann/json.hpp>
|
||||||
#include <eda_draw_frame.h>
|
#include <eda_draw_frame.h>
|
||||||
#include <kiid.h>
|
#include <kiid.h>
|
||||||
|
#include <pcbjam_read_only.h>
|
||||||
|
#include <project.h>
|
||||||
#include <font/text_attributes.h>
|
#include <font/text_attributes.h>
|
||||||
#include <drawing_sheet/ds_data_model.h>
|
#include <drawing_sheet/ds_data_model.h>
|
||||||
#include <drawing_sheet/ds_data_item.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() ) ) );
|
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
|
// 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
|
// driving the Save As dialog. pl_editor edits the process-wide singleton
|
||||||
// DS_DATA_MODEL::GetTheInstance(), so this serializes exactly what the editor has
|
// 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) {
|
EMSCRIPTEN_BINDINGS(pl_editor) {
|
||||||
// Programmatic file open (preferred over UI automation from the web app).
|
// Programmatic file open (preferred over UI automation from the web app).
|
||||||
function("kicadOpenFile", &kicadOpenFile);
|
function("kicadOpenFile", &kicadOpenFile);
|
||||||
|
// Read-only viewer lock (read-only-viewer).
|
||||||
|
function("kicadSetReadOnly", &kicadSetReadOnly);
|
||||||
function("kicadSaveDrawingSheet", &kicadSaveDrawingSheet);
|
function("kicadSaveDrawingSheet", &kicadSaveDrawingSheet);
|
||||||
// Yjs collaborative bridge entry points.
|
// Yjs collaborative bridge entry points.
|
||||||
function("kicadCollabApply", &kicadCollabApply);
|
function("kicadCollabApply", &kicadCollabApply);
|
||||||
|
|
|
||||||
|
|
@ -190,6 +190,8 @@ async function main(): Promise<void> {
|
||||||
body: {
|
body: {
|
||||||
project: await project(params.scope),
|
project: await project(params.scope),
|
||||||
files: await walk(PROJECT_DIR),
|
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;
|
collabSession?: KicadDocSession;
|
||||||
/** The opened file was materialized from collabSession's doc (ydoc source). */
|
/** The opened file was materialized from collabSession's doc (ydoc source). */
|
||||||
editorMatchesDoc?: boolean;
|
editorMatchesDoc?: boolean;
|
||||||
|
/** Read-only viewer (read-only-viewer): see `bindKicadCollab`. */
|
||||||
|
readOnly?: boolean;
|
||||||
log: (m: string) => void;
|
log: (m: string) => void;
|
||||||
onStatus: (t: string) => void;
|
onStatus: (t: string) => void;
|
||||||
},
|
},
|
||||||
|
|
@ -500,6 +502,7 @@ async function maybeStartCollab(
|
||||||
const handle = attachKicadCollab(mod, win as unknown as KicadItemsWindow, opts.collabSession, {
|
const handle = attachKicadCollab(mod, win as unknown as KicadItemsWindow, opts.collabSession, {
|
||||||
seedDoc,
|
seedDoc,
|
||||||
editorMatchesDoc: opts.editorMatchesDoc,
|
editorMatchesDoc: opts.editorMatchesDoc,
|
||||||
|
readOnly: opts.readOnly,
|
||||||
});
|
});
|
||||||
opts.log(`[collab] attached to Y.Doc session`);
|
opts.log(`[collab] attached to Y.Doc session`);
|
||||||
opts.onStatus("Collab: connected");
|
opts.onStatus("Collab: connected");
|
||||||
|
|
@ -517,6 +520,7 @@ async function maybeStartCollab(
|
||||||
provider,
|
provider,
|
||||||
room,
|
room,
|
||||||
seedDoc,
|
seedDoc,
|
||||||
|
readOnly: opts.readOnly,
|
||||||
});
|
});
|
||||||
opts.log(`[collab] ${provider.kind} connected on ${room}`);
|
opts.log(`[collab] ${provider.kind} connected on ${room}`);
|
||||||
opts.onStatus("Collab: connected");
|
opts.onStatus("Collab: connected");
|
||||||
|
|
@ -549,6 +553,8 @@ async function startSheetCollab(
|
||||||
onActiveChange: (active: ActiveSheet | null) => void;
|
onActiveChange: (active: ActiveSheet | null) => void;
|
||||||
/** Upload sink (project-backed sessions) — used to register a just-created subsheet. */
|
/** Upload sink (project-backed sessions) — used to register a just-created subsheet. */
|
||||||
saveBytes?: SaveBytes;
|
saveBytes?: SaveBytes;
|
||||||
|
/** Read-only viewer (read-only-viewer): see `createSheetCollabManager`. */
|
||||||
|
readOnly?: boolean;
|
||||||
log: (m: string) => void;
|
log: (m: string) => void;
|
||||||
onStatus: (t: string) => void;
|
onStatus: (t: string) => void;
|
||||||
},
|
},
|
||||||
|
|
@ -577,8 +583,10 @@ async function startSheetCollab(
|
||||||
seedDocForPath: (sheet) => seedDocFromMemfs(win, opts.slug, sheet),
|
seedDocForPath: (sheet) => seedDocFromMemfs(win, opts.slug, sheet),
|
||||||
onActiveChange: opts.onActiveChange,
|
onActiveChange: opts.onActiveChange,
|
||||||
// Parked rooms carry a skeleton presence ("this user is on sheet X") so
|
// Parked rooms carry a skeleton presence ("this user is on sheet X") so
|
||||||
// any sheet's roster shows the whole schematic's crew (0003).
|
// any sheet's roster shows the whole schematic's crew (0003). Read-only
|
||||||
presenceUser: presenceUser(),
|
// viewers publish none (invisible observer) — skeletons are broadcasts.
|
||||||
|
presenceUser: opts.readOnly ? undefined : presenceUser(),
|
||||||
|
readOnly: opts.readOnly,
|
||||||
log: opts.log,
|
log: opts.log,
|
||||||
initial:
|
initial:
|
||||||
opts.session && opts.targetPath
|
opts.session && opts.targetPath
|
||||||
|
|
@ -680,6 +688,7 @@ export function WasmTool({
|
||||||
assetBaseUrl,
|
assetBaseUrl,
|
||||||
libsSource,
|
libsSource,
|
||||||
sourceDescriptor,
|
sourceDescriptor,
|
||||||
|
readOnly = false,
|
||||||
}: {
|
}: {
|
||||||
tool: Tool;
|
tool: Tool;
|
||||||
slug: string;
|
slug: string;
|
||||||
|
|
@ -715,6 +724,14 @@ export function WasmTool({
|
||||||
/** Override the resolved WASM asset base (used verbatim, e.g. e2e fixtures).
|
/** Override the resolved WASM asset base (used verbatim, e.g. e2e fixtures).
|
||||||
* Default: resolveWasmBase(tool) — the CDN manifest folder, or flat /wasm. */
|
* Default: resolveWasmBase(tool) — the CDN manifest folder, or flat /wasm. */
|
||||||
assetBaseUrl?: string;
|
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 containerRef = React.useRef<HTMLDivElement>(null);
|
||||||
const startedRef = React.useRef(false);
|
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
|
// button / Cmd+\ flips it live; shell overlays key off this, and the layout
|
||||||
// effect below applies it to the wasm frame.
|
// effect below applies it to the wasm frame.
|
||||||
const chromeHidden = useChromeHidden();
|
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 driftRef = React.useRef<{ stop(): void } | null>(null);
|
||||||
const presenceRef = React.useRef<PresenceHandle | null>(null);
|
const presenceRef = React.useRef<PresenceHandle | null>(null);
|
||||||
const presenceBridgeRef = React.useRef<{ destroy(): void } | 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
|
// pcbnew/pl_editor bind once; eeschema rebinds per active sheet, so the
|
||||||
// roster shows who is on the SAME sheet (room = sheet).
|
// roster shows who is on the SAME sheet (room = sheet).
|
||||||
const startPresence = (provider: YjsProvider | undefined, sheetPath?: string) => {
|
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?.destroy();
|
||||||
followRef.current = null;
|
followRef.current = null;
|
||||||
setFollowingTarget(null);
|
setFollowingTarget(null);
|
||||||
|
|
@ -1015,6 +1038,9 @@ export function WasmTool({
|
||||||
// GAL pin dots + the DOM layer's thread data. Follows the same lifecycle as
|
// GAL pin dots + the DOM layer's thread data. Follows the same lifecycle as
|
||||||
// presence — eeschema rebinds per active sheet.
|
// presence — eeschema rebinds per active sheet.
|
||||||
const startComments = (doc: import("yjs").Doc | undefined) => {
|
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?.destroy();
|
||||||
commentsRef.current = null;
|
commentsRef.current = null;
|
||||||
setCommentsCtl(null);
|
setCommentsCtl(null);
|
||||||
|
|
@ -1061,6 +1087,7 @@ export function WasmTool({
|
||||||
// NOT reach the wx layer, so also stop propagation — capture on window
|
// NOT reach the wx layer, so also stop propagation — capture on window
|
||||||
// fires before wx's bubble-phase window listeners (wasm/app.cpp).
|
// fires before wx's bubble-phase window listeners (wasm/app.cpp).
|
||||||
const chromeHotkey = (e: KeyboardEvent) => {
|
const chromeHotkey = (e: KeyboardEvent) => {
|
||||||
|
if (readOnly) return; // viewers can't reveal the chrome (read-only-viewer)
|
||||||
if (!isChromeToggleHotkey(e)) return;
|
if (!isChromeToggleHotkey(e)) return;
|
||||||
if (!chromeSetter(win)) return; // bundle without the export
|
if (!chromeSetter(win)) return; // bundle without the export
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -1118,31 +1145,39 @@ export function WasmTool({
|
||||||
});
|
});
|
||||||
// Register the save sink before the file opens: from here on, every
|
// Register the save sink before the file opens: from here on, every
|
||||||
// editor File→Save (MEMFS write) is routed onward through saveBytes.
|
// 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, {
|
registerSaveHook(win, {
|
||||||
slug,
|
slug,
|
||||||
saveBytes,
|
saveBytes: readOnly ? undefined : saveBytes,
|
||||||
log: append,
|
log: append,
|
||||||
onStatus: setStatus,
|
onStatus: setStatus,
|
||||||
// A sheet created mid-session ("Add Sheet") saves to a new .kicad_sch path the
|
...(readOnly
|
||||||
// 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);
|
// 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.
|
||||||
// Non-item document state (title block, paper, setup…) only reaches the
|
onSaved: (relPath: string) => {
|
||||||
// room at seed time; reconcile it from every save (miss 08B).
|
if (relPath.endsWith(".kicad_sch"))
|
||||||
onSavedText: (relPath, text) => {
|
void sheetManagerRef.current?.onboard(relPath);
|
||||||
if (sheetManagerRef.current) {
|
},
|
||||||
sheetManagerRef.current.syncLayoutFromSave(relPath, text);
|
// Non-item document state (title block, paper, setup…) only reaches the
|
||||||
return;
|
// room at seed time; reconcile it from every save (miss 08B).
|
||||||
}
|
onSavedText: (relPath: string, text: string) => {
|
||||||
if (collabDocRef.current && relPath === targetPath) {
|
if (sheetManagerRef.current) {
|
||||||
try {
|
sheetManagerRef.current.syncLayoutFromSave(relPath, text);
|
||||||
syncLayoutToY(fileToDoc(text), collabDocRef.current, "layout-save");
|
return;
|
||||||
} catch (err) {
|
}
|
||||||
append(`[save] layout sync failed: ${String(err)}`);
|
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, {
|
const { session, targetBytes } = await maybeConnectDocSession(win, {
|
||||||
docSource,
|
docSource,
|
||||||
|
|
@ -1166,6 +1201,31 @@ export function WasmTool({
|
||||||
log: append,
|
log: append,
|
||||||
onStatus: setStatus,
|
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
|
// 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
|
// 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.
|
// divergence. Gated on a real collab session; re-targeted per active sheet below.
|
||||||
|
|
@ -1177,7 +1237,9 @@ export function WasmTool({
|
||||||
const collabOptOut =
|
const collabOptOut =
|
||||||
new URLSearchParams(win.location.search).get("collab") === "0" ||
|
new URLSearchParams(win.location.search).get("collab") === "0" ||
|
||||||
new URLSearchParams(win.location.search).get("collab") === "false";
|
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 =
|
crossAppRef.current =
|
||||||
(await startCrossAppPresence({
|
(await startCrossAppPresence({
|
||||||
projectId,
|
projectId,
|
||||||
|
|
@ -1201,15 +1263,16 @@ export function WasmTool({
|
||||||
targetPath,
|
targetPath,
|
||||||
files,
|
files,
|
||||||
session,
|
session,
|
||||||
saveBytes,
|
saveBytes: readOnly ? undefined : saveBytes,
|
||||||
editorMatchesDoc: !!targetBytes,
|
editorMatchesDoc: !!targetBytes,
|
||||||
|
readOnly,
|
||||||
// Re-point drift detection + presence at whichever sheet is bound.
|
// Re-point drift detection + presence at whichever sheet is bound.
|
||||||
onActiveChange: (activeRoom) => {
|
onActiveChange: (activeRoom) => {
|
||||||
driftRef.current?.stop();
|
driftRef.current?.stop();
|
||||||
driftRef.current = null;
|
driftRef.current = null;
|
||||||
startPresence(activeRoom?.provider, activeRoom?.sheetPath);
|
startPresence(activeRoom?.provider, activeRoom?.sheetPath);
|
||||||
startComments(activeRoom?.doc);
|
startComments(activeRoom?.doc);
|
||||||
if (activeRoom) {
|
if (activeRoom && !readOnly) {
|
||||||
driftRef.current = startDriftDetection({
|
driftRef.current = startDriftDetection({
|
||||||
doc: activeRoom.doc,
|
doc: activeRoom.doc,
|
||||||
mod: win.Module,
|
mod: win.Module,
|
||||||
|
|
@ -1232,13 +1295,14 @@ export function WasmTool({
|
||||||
targetPath,
|
targetPath,
|
||||||
collabSession: session,
|
collabSession: session,
|
||||||
editorMatchesDoc: !!targetBytes,
|
editorMatchesDoc: !!targetBytes,
|
||||||
|
readOnly,
|
||||||
log: append,
|
log: append,
|
||||||
onStatus: setStatus,
|
onStatus: setStatus,
|
||||||
});
|
});
|
||||||
collabDocRef.current = collabHandle?.doc ?? null;
|
collabDocRef.current = collabHandle?.doc ?? null;
|
||||||
startPresence(collabHandle?.provider);
|
startPresence(collabHandle?.provider);
|
||||||
startComments(collabHandle?.doc);
|
startComments(collabHandle?.doc);
|
||||||
if (collabHandle && targetPath && COLLAB_TOOLS.has(tool)) {
|
if (collabHandle && targetPath && COLLAB_TOOLS.has(tool) && !readOnly) {
|
||||||
driftRef.current = startDriftDetection({
|
driftRef.current = startDriftDetection({
|
||||||
doc: collabHandle.doc,
|
doc: collabHandle.doc,
|
||||||
mod: win.Module,
|
mod: win.Module,
|
||||||
|
|
@ -1303,19 +1367,19 @@ export function WasmTool({
|
||||||
const appliedRef = React.useRef<boolean | null>(null);
|
const appliedRef = React.useRef<boolean | null>(null);
|
||||||
React.useLayoutEffect(() => {
|
React.useLayoutEffect(() => {
|
||||||
if (!setChromeFn) return;
|
if (!setChromeFn) return;
|
||||||
if (appliedRef.current === chromeHidden) return;
|
if (appliedRef.current === effectiveChromeHidden) return;
|
||||||
if (appliedRef.current === null && !chromeHidden) return;
|
if (appliedRef.current === null && !effectiveChromeHidden) return;
|
||||||
|
|
||||||
const apply = () => {
|
const apply = () => {
|
||||||
try {
|
try {
|
||||||
return setChromeFn(!chromeHidden) === true;
|
return setChromeFn(!effectiveChromeHidden) === true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
append(`[chrome] kicadSetChrome failed: ${String(err)}`);
|
append(`[chrome] kicadSetChrome failed: ${String(err)}`);
|
||||||
return true; // don't retry a throwing binding
|
return true; // don't retry a throwing binding
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if (apply()) {
|
if (apply()) {
|
||||||
appliedRef.current = chromeHidden;
|
appliedRef.current = effectiveChromeHidden;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// The editor frame can lag `ready` (waitForWxUi falls through after 25 s)
|
// The editor frame can lag `ready` (waitForWxUi falls through after 25 s)
|
||||||
|
|
@ -1323,14 +1387,14 @@ export function WasmTool({
|
||||||
const t0 = Date.now();
|
const t0 = Date.now();
|
||||||
const tick = window.setInterval(() => {
|
const tick = window.setInterval(() => {
|
||||||
if (apply()) {
|
if (apply()) {
|
||||||
appliedRef.current = chromeHidden;
|
appliedRef.current = effectiveChromeHidden;
|
||||||
window.clearInterval(tick);
|
window.clearInterval(tick);
|
||||||
} else if (Date.now() - t0 > 30_000) {
|
} else if (Date.now() - t0 > 30_000) {
|
||||||
window.clearInterval(tick);
|
window.clearInterval(tick);
|
||||||
}
|
}
|
||||||
}, 300);
|
}, 300);
|
||||||
return () => window.clearInterval(tick);
|
return () => window.clearInterval(tick);
|
||||||
}, [setChromeFn, chromeHidden, append]);
|
}, [setChromeFn, effectiveChromeHidden, append]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative h-screen w-screen overflow-hidden bg-[#1a1a2e]">
|
<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),
|
{/* Top-right overlay row: who else is in this file (awareness roster),
|
||||||
where this project lives / whether Save persists (chip hidden while
|
where this project lives / whether Save persists (chip hidden while
|
||||||
the UI is hidden), and the Figma-like hide/show-UI toggle — the one
|
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 &&
|
{ready &&
|
||||||
(setChromeFn !== null ||
|
(readOnly ||
|
||||||
|
setChromeFn !== null ||
|
||||||
peers.length > 0 ||
|
peers.length > 0 ||
|
||||||
(sourceDescriptor && !chromeHidden)) && (
|
(sourceDescriptor && !effectiveChromeHidden)) && (
|
||||||
<div className="absolute right-3 top-3 z-20 flex items-center gap-2">
|
<div className="absolute right-3 top-3 z-20 flex items-center gap-2">
|
||||||
{peers.length > 0 && (
|
{peers.length > 0 && (
|
||||||
<PresenceRoster
|
<PresenceRoster
|
||||||
|
|
@ -1489,10 +1555,18 @@ export function WasmTool({
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{sourceDescriptor && !chromeHidden && (
|
{sourceDescriptor && !effectiveChromeHidden && (
|
||||||
<SourceChip descriptor={sourceDescriptor} />
|
<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
|
<button
|
||||||
data-testid="chrome-toggle"
|
data-testid="chrome-toggle"
|
||||||
aria-pressed={chromeHidden}
|
aria-pressed={chromeHidden}
|
||||||
|
|
@ -1566,7 +1640,7 @@ export function WasmTool({
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!chromeHidden && (
|
{!effectiveChromeHidden && (
|
||||||
<div className="absolute bottom-0 left-0 right-0 z-20">
|
<div className="absolute bottom-0 left-0 right-0 z-20">
|
||||||
<button
|
<button
|
||||||
className="flex items-center gap-1 bg-black/70 px-3 py-1 font-mono text-xs text-white"
|
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,
|
useSourceDescriptor,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import { docSourceConfig } from "@/lib/config";
|
import { docSourceConfig } from "@/lib/config";
|
||||||
|
import { resolveReadOnly } from "@/lib/read-only-mode";
|
||||||
import { WasmTool } from "@/components/WasmTool";
|
import { WasmTool } from "@/components/WasmTool";
|
||||||
import { PreflightGate } from "@/preflight/PreflightGate";
|
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.
|
// on reload when it holds newer state; the upload is the registration + fallback copy.
|
||||||
const docSource = docSourceConfig();
|
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
|
// PreflightGate runs the device-capability check; on a fatal mismatch it blocks
|
||||||
// here (before WasmTool mounts) so the expensive WASM asset fetch is skipped.
|
// here (before WasmTool mounts) so the expensive WASM asset fetch is skipped.
|
||||||
// fetch/upload go through the active project source (api.ts): a backend
|
// fetch/upload go through the active project source (api.ts): a backend
|
||||||
|
|
@ -67,9 +74,14 @@ export function ToolPage() {
|
||||||
files={data.files}
|
files={data.files}
|
||||||
targetPath={targetPath}
|
targetPath={targetPath}
|
||||||
fetchBytes={(relPath) => fetchFileBytes(slug, relPath)}
|
fetchBytes={(relPath) => fetchFileBytes(slug, relPath)}
|
||||||
saveBytes={(relPath, bytes) => uploadFileBytes(slug, relPath, bytes)}
|
saveBytes={
|
||||||
|
readOnly
|
||||||
|
? undefined
|
||||||
|
: (relPath, bytes) => uploadFileBytes(slug, relPath, bytes)
|
||||||
|
}
|
||||||
docSource={docSource}
|
docSource={docSource}
|
||||||
sourceDescriptor={sourceDescriptor}
|
sourceDescriptor={sourceDescriptor}
|
||||||
|
readOnly={readOnly}
|
||||||
/>
|
/>
|
||||||
</PreflightGate>
|
</PreflightGate>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,8 @@ export interface StartCollabOptions {
|
||||||
* snapshot. Ignored by the legacy scalar `startCollab`.
|
* snapshot. Ignored by the legacy scalar `startCollab`.
|
||||||
*/
|
*/
|
||||||
seedDoc?: KicadDoc;
|
seedDoc?: KicadDoc;
|
||||||
|
/** Read-only viewer (read-only-viewer): see `bindKicadCollab`. */
|
||||||
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CollabHandle {
|
export interface CollabHandle {
|
||||||
|
|
@ -149,9 +151,17 @@ export function attachKicadCollab(
|
||||||
mod: KicadItemsModule,
|
mod: KicadItemsModule,
|
||||||
win: KicadItemsWindow,
|
win: KicadItemsWindow,
|
||||||
session: KicadDocSession,
|
session: KicadDocSession,
|
||||||
opts?: { seedDoc?: KicadDoc; editorMatchesDoc?: boolean },
|
opts?: { seedDoc?: KicadDoc; editorMatchesDoc?: boolean; readOnly?: boolean },
|
||||||
): KicadCollabHandle {
|
): 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 });
|
binding.seed(opts?.seedDoc, { editorMatchesDoc: opts?.editorMatchesDoc });
|
||||||
clog("attachKicadCollab: ready; doc items =", binding.items.size);
|
clog("attachKicadCollab: ready; doc items =", binding.items.size);
|
||||||
|
|
||||||
|
|
@ -181,5 +191,8 @@ export async function startKicadCollab(
|
||||||
): Promise<KicadCollabHandle> {
|
): Promise<KicadCollabHandle> {
|
||||||
clog("startKicadCollab:", opts.provider.kind, "room =", opts.room);
|
clog("startKicadCollab:", opts.provider.kind, "room =", opts.room);
|
||||||
const session = await connectKicadDoc({ provider: opts.provider, 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,
|
renderItem,
|
||||||
SEXPR_VERSION_CURRENT,
|
SEXPR_VERSION_CURRENT,
|
||||||
sexprToItems,
|
sexprToItems,
|
||||||
|
ydocHasState,
|
||||||
ydocSexprVersion,
|
ydocSexprVersion,
|
||||||
yToDoc,
|
yToDoc,
|
||||||
type KicadItem,
|
type KicadItem,
|
||||||
|
|
@ -344,7 +345,6 @@ describe("lib_symbols flow through the binding (miss 08A)", () => {
|
||||||
expect(symWire!.sexpr).toMatch(/^\(lib_symbols \(symbol "Device:R"/);
|
expect(symWire!.sexpr).toMatch(/^\(lib_symbols \(symbol "Device:R"/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("sexprVersion skew guard (ysync 0009 §5)", () => {
|
describe("sexprVersion skew guard (ysync 0009 §5)", () => {
|
||||||
it("binds a fresh (empty) room and a current-version doc", () => {
|
it("binds a fresh (empty) room and a current-version doc", () => {
|
||||||
const { a, b } = pair();
|
const { a, b } = pair();
|
||||||
|
|
@ -362,3 +362,83 @@ describe("sexprVersion skew guard (ysync 0009 §5)", () => {
|
||||||
expect(() => bindKicadCollab(doc, new FakeEditor())).toThrow(/update required/);
|
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
|
// 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
|
// 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);
|
const version = ydocSexprVersion(doc);
|
||||||
if (!SEXPR_VERSION_SUPPORTED.includes(version)) throw new SexprVersionError(version);
|
if (!SEXPR_VERSION_SUPPORTED.includes(version)) throw new SexprVersionError(version);
|
||||||
const items = kicadItemsMap(doc);
|
const items = kicadItemsMap(doc);
|
||||||
|
|
@ -133,6 +149,7 @@ export function bindKicadCollab(doc: Y.Doc, bridge: KicadItemsBridge): KicadBind
|
||||||
|
|
||||||
// DOWN: local editor change → Y.Doc
|
// DOWN: local editor change → Y.Doc
|
||||||
bridge.onItems((json: string) => {
|
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
|
if (destroyed) return; // stale hook (bug 07) — a destroyed binding is inert
|
||||||
let wire: ItemsWireDelta;
|
let wire: ItemsWireDelta;
|
||||||
try {
|
try {
|
||||||
|
|
@ -193,6 +210,13 @@ export function bindKicadCollab(doc: Y.Doc, bridge: KicadItemsBridge): KicadBind
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!ydocHasState(doc) && seedDoc) {
|
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
|
// 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 Y.Doc — not the editor snapshot — is the lossless source of truth
|
||||||
// (the file is recoverable via docToFile). The editor already opened the
|
// (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);
|
const hasState = ydocHasState(doc);
|
||||||
|
|
||||||
if (!hasState) {
|
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.
|
// First tab, no file source: seed the shared doc from the editor model.
|
||||||
const local = itemsWireToDelta(wire, {});
|
const local = itemsWireToDelta(wire, {});
|
||||||
clog(`seed: doc empty → SEEDING from editor snapshot (${local.added.length} item(s))`);
|
clog(`seed: doc empty → SEEDING from editor snapshot (${local.added.length} item(s))`);
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,14 @@ export interface SheetManagerOptions {
|
||||||
* the skeleton on rebind.
|
* the skeleton on rebind.
|
||||||
*/
|
*/
|
||||||
presenceUser?: PresenceUser;
|
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;
|
log: (m: string) => void;
|
||||||
/**
|
/**
|
||||||
* `docSource: "ydoc"` only: the entry sheet's room is already connected (and possibly
|
* `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) {
|
if (opts.initial) {
|
||||||
const { sheetPath, session, editorMatchesDoc } = opts.initial;
|
const { sheetPath, session, editorMatchesDoc } = opts.initial;
|
||||||
|
if (opts.readOnly) session.provider.awareness?.setLocalState(null);
|
||||||
rooms.set(sheetPath, {
|
rooms.set(sheetPath, {
|
||||||
session,
|
session,
|
||||||
doc: session.doc,
|
doc: session.doc,
|
||||||
|
|
@ -172,6 +181,9 @@ export function createSheetCollabManager(opts: SheetManagerOptions): SheetCollab
|
||||||
provider,
|
provider,
|
||||||
room: collabRoomId(projectId, sheetPath),
|
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 = {
|
const room: Room = {
|
||||||
session,
|
session,
|
||||||
doc: session.doc,
|
doc: session.doc,
|
||||||
|
|
@ -233,7 +245,7 @@ export function createSheetCollabManager(opts: SheetManagerOptions): SheetCollab
|
||||||
room.detachWatch?.();
|
room.detachWatch?.();
|
||||||
room.detachWatch = undefined;
|
room.detachWatch = undefined;
|
||||||
|
|
||||||
const binding = bindKicadCollab(room.doc, bridge);
|
const binding = bindKicadCollab(room.doc, bridge, { readOnly: opts.readOnly });
|
||||||
room.binding = binding;
|
room.binding = binding;
|
||||||
|
|
||||||
if (!room.seeded) {
|
if (!room.seeded) {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue