feat: F5.3b the editor on a project of server F

?project=<uuid> opens the shared document over /sync and the room over
/sim (RemoteSimHost: messages wait for the socket, a lost socket comes
back with what it watched, F's refusal or parting ends it); instead of
the editor, a word why when F refuses or lets go. F tells each
connection its access at once. Who only reads sees everything and
changes nothing: no palette or undo, a locked surface, the sheet's
fields disabled (tabs stay), no starting or stopping; thawed, the
document comes anew. The toolbar names who started the simulation.
Refusals carry no message, so Hocuspocus writes nothing outside F's
JSON log. A browser test runs three people against a real F.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-04 15:00:18 +00:00
co-authored by Claude Opus 5.5
parent 8cfc518f0c
commit f52852493a
28 changed files with 1142 additions and 58 deletions
+36 -8
View File
@@ -13,6 +13,12 @@
// Every change goes through packages/doc's commands with this editor's own
// origin, so that its undo takes back what this person did and nothing of
// the others in the room.
//
// Who only reads (on F: a read grant, or a frozen project) sees everything
// and changes nothing: no palette, no undo, nothing moves or has a menu, the
// sheet's fields are disabled, the simulation is neither started nor
// stopped by them; desktops and the data exchange window open to watch. The
// room's simulation names who started it.
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
import type * as Y from 'yjs';
@@ -50,7 +56,7 @@ import { Palette, type PaletteKind } from './design/Palette.js';
import { RouterDialog } from './design/RouterDialog.js';
import { DeviceSheet } from './sheet/DeviceSheet.js';
import { DesktopWindow, type DesktopWindowName } from './desktop/DesktopWindow.js';
import { useLanguage, useTranslate } from './i18n/i18n.js';
import { translate, useLanguage, useTranslate } from './i18n/i18n.js';
import { CaptureWindow } from './simulation/CaptureWindow.js';
import { captureWords, reportTraffic } from './simulation/captureCells.js';
import { ModeBar, type EditorMode } from './simulation/ModeBar.js';
@@ -143,9 +149,13 @@ export interface EditorProps {
readonly createSimHost?: SimHostFactory;
/** The project's name (its file's), for what is exported. */
readonly projectName?: string;
/** This person only reads (on F). */
readonly readOnly?: boolean;
/** …because the project is frozen (its deadline passed). */
readonly frozen?: boolean;
}
export function Editor({ doc, createSimHost, projectName }: EditorProps) {
export function Editor({ doc, createSimHost, projectName, readOnly = false, frozen = false }: EditorProps) {
const t = useTranslate();
const language = useLanguage();
const view = useTopologyView(doc);
@@ -167,6 +177,11 @@ export function Editor({ doc, createSimHost, projectName }: EditorProps) {
// The cable of the card whose tab is open in the sheet (Java's Kabel.setAktiv).
const [highlightedLink, setHighlightedLink] = useState<string | null>(null);
// The room said no: told as a message, once each time.
useEffect(() => {
if (simulation.refusal !== null) setMessage(translate(language, `access.${simulation.refusal.code}Refused`));
}, [simulation.refusal, language]);
// A message goes after a while.
useEffect(() => {
if (message === null) return undefined;
@@ -352,7 +367,7 @@ export function Editor({ doc, createSimHost, projectName }: EditorProps) {
// The keyboard: undo and redo, delete what is selected, Escape lets go.
useEffect(() => {
const key = (event: KeyboardEvent) => {
if (typing(event.target) || simulating) return;
if (typing(event.target) || simulating || readOnly) return;
const command = event.ctrlKey || event.metaKey;
if (command && event.key.toLowerCase() === 'z') {
event.preventDefault();
@@ -371,13 +386,13 @@ export function Editor({ doc, createSimHost, projectName }: EditorProps) {
};
window.addEventListener('keydown', key);
return () => window.removeEventListener('keydown', key);
}, [manager, selection, menu, routerAt, remove, simulating]);
}, [manager, selection, menu, routerAt, remove, simulating, readOnly]);
return (
<div style={{ display: 'flex', height: '100%' }}>
{simulating ? null : documenting ? (
<DocumentationSidebar onItem={onDocumentationItem} onExport={exportImage} onReport={makeReport} />
) : (
<DocumentationSidebar onItem={onDocumentationItem} onExport={exportImage} onReport={makeReport} readOnly={readOnly} />
) : readOnly ? null : (
<Palette
cableTool={cableTool}
onCableTool={(on) => {
@@ -402,6 +417,7 @@ export function Editor({ doc, createSimHost, projectName }: EditorProps) {
onCable={onCable}
highlightedLink={highlightedLink}
mode={editorMode}
locked={readOnly}
onDocFrame={(id, frame) => setDocumentationFrame(doc, id, frame, origin)}
renderDocText={(item, frame) => {
const text = documentationText(doc, item.id);
@@ -426,6 +442,7 @@ export function Editor({ doc, createSimHost, projectName }: EditorProps) {
else if (kind === 'gateway') openDesktop(id, 'nat');
}}
onMenu={(target, at) => {
if (readOnly && !simulating) return;
if (editorMode === 'documentation') {
if (target.kind !== 'doc') return;
setSelection(target);
@@ -441,8 +458,18 @@ export function Editor({ doc, createSimHost, projectName }: EditorProps) {
}}
/>
<div role="toolbar" className="editor-toolbar">
<ModeBar mode={editorMode} speed={simulation.speed} onMode={changeMode} onSpeed={simulation.setSpeed} />
{simulating ? <DropControl simulation={simulation} /> : (
<ModeBar mode={editorMode} speed={simulation.speed} onMode={changeMode} onSpeed={simulation.setSpeed} canRun={!readOnly} />
{simulating && simulation.startedBy !== undefined ? (
<span className="editor-note" data-note="startedBy">
{t('access.startedBy', { name: simulation.startedBy })}
</span>
) : null}
{readOnly ? (
<span className="editor-note" data-note="readOnly">
{t(frozen ? 'access.frozen' : 'access.readOnly')}
</span>
) : null}
{simulating ? (readOnly ? null : <DropControl simulation={simulation} />) : readOnly ? null : (
<>
<button type="button" style={toolButton} disabled={!canUndo} onClick={() => manager?.undo()} aria-label={t('design.undo')} title={t('design.undo')}>
↶
@@ -578,6 +605,7 @@ export function Editor({ doc, createSimHost, projectName }: EditorProps) {
onMessage={setMessage}
onHighlight={setHighlightedLink}
onClose={() => setSelection(null)}
readOnly={readOnly}
/>
)}
</div>
+19 -4
View File
@@ -81,6 +81,12 @@ export interface CanvasProps {
* Java's simulation mode. The documentation mode: see above.
*/
readonly mode?: 'design' | 'simulation' | 'documentation';
/**
* Who only reads (on F): a tap still chooses a device (its sheet shows
* what it holds), but nothing moves, no cable is begun or chosen, no
* documentation item is chosen or changed, and nothing has a menu.
*/
readonly locked?: boolean;
/** A documentation item moved or resized: its new frame. */
readonly onDocFrame?: (id: string, frame: Frame) => void;
/** The field a focused text is typed into, over the surface at its frame. */
@@ -228,6 +234,7 @@ export const Canvas = forwardRef<CanvasHandle, CanvasProps>(function Canvas(
onMenu,
highlightedLink = null,
mode = 'design',
locked = false,
activeLinks = NO_LINKS,
onActivate,
onDocFrame,
@@ -364,6 +371,10 @@ export const Canvas = forwardRef<CanvasHandle, CanvasProps>(function Canvas(
if (pointers.current.size > 2) return;
const target = event.target as Element;
const linkId = target.closest('[data-link-hit]')?.getAttribute('data-link-hit');
if (documenting && locked) {
gesture.current = { kind: 'background', start: point, moved: false, last: point };
return;
}
if (documenting) {
const found = docAt(point);
if (found === null) {
@@ -391,11 +402,11 @@ export const Canvas = forwardRef<CanvasHandle, CanvasProps>(function Canvas(
gesture.current = { kind: 'device', id: device.id, start: point, origin: { x: box.x, y: box.y }, side: device.side, moved: false, longPressed: false };
longPress.current = setTimeout(() => {
const now = gesture.current;
if (now?.kind !== 'device' || now.moved) return;
if (now?.kind !== 'device' || now.moved || (locked && !simulating)) return;
now.longPressed = true;
onMenu?.({ kind: 'device', id: now.id }, now.start);
}, LONG_PRESS_MILLISECONDS);
} else if (linkId !== undefined && linkId !== null && !simulating) {
} else if (linkId !== undefined && linkId !== null && !simulating && !locked) {
gesture.current = { kind: 'link', id: linkId, start: point, moved: false, longPressed: false };
longPress.current = setTimeout(() => {
const now = gesture.current;
@@ -454,7 +465,7 @@ export const Canvas = forwardRef<CanvasHandle, CanvasProps>(function Canvas(
const step = { x: point.x - now.last.x, y: point.y - now.last.y };
now.last = point;
setViewport((before) => shifted(before ?? currentRef.current, step));
} else if (now.kind === 'device' && !now.longPressed && !simulating) {
} else if (now.kind === 'device' && !now.longPressed && !simulating && !locked) {
if (tool === 'cable') {
const from = boxes.get(now.id)!.center;
const target = deviceAt(point);
@@ -501,6 +512,10 @@ export const Canvas = forwardRef<CanvasHandle, CanvasProps>(function Canvas(
if (!now.moved) onActivate?.(now.id);
return;
}
if (locked) {
if (!now.moved) onSelect?.({ kind: 'device', id: now.id });
return;
}
if (tool === 'cable') {
setBand(null);
const from = { id: now.id, side: now.side };
@@ -645,7 +660,7 @@ export const Canvas = forwardRef<CanvasHandle, CanvasProps>(function Canvas(
const corner = toScreen(current, { x: item.x, y: item.y });
return renderDocText(item, { left: corner.x, top: corner.y, width: item.width * current.scale, height: item.height * current.scale, scale: current.scale });
})()}
{menuButton === null || selection === null || simulating ? null : (
{menuButton === null || selection === null || simulating || locked ? null : (
<button
type="button"
className="device-menu-button"
@@ -22,6 +22,8 @@ export interface DocumentationSidebarProps {
readonly onExport: () => Promise<void>;
/** „Report“: the report saved as a PDF; rejected, it failed. */
readonly onReport: () => Promise<void>;
/** Who only reads makes no texts or rectangles; the picture and the report stay. */
readonly readOnly?: boolean;
}
const ITEMS: readonly { readonly kind: DocumentationItemKind; readonly icon: string; readonly label: string }[] = [
@@ -48,7 +50,7 @@ const item: CSSProperties = {
touchAction: 'none',
};
export function DocumentationSidebar({ onItem, onExport, onReport }: DocumentationSidebarProps) {
export function DocumentationSidebar({ onItem, onExport, onReport, readOnly = false }: DocumentationSidebarProps) {
const t = useTranslate();
const [ghost, setGhost] = useState<{ kind: DocumentationItemKind; x: number; y: number } | null>(null);
const press = useRef<{ kind: DocumentationItemKind; x: number; y: number; dragging: boolean } | null>(null);
@@ -106,7 +108,7 @@ export function DocumentationSidebar({ onItem, onExport, onReport }: Documentati
flexShrink: 0,
}}
>
{ITEMS.map((each) => (
{(readOnly ? [] : ITEMS).map((each) => (
<button
key={each.kind}
type="button"
+13
View File
@@ -1,4 +1,9 @@
{
"access.frozen": "Eingefroren – nur ansehen",
"access.frozenRefused": "Das Projekt ist eingefroren; die Simulation startet nicht.",
"access.readOnly": "Nur ansehen",
"access.readOnlyRefused": "Hier können Sie nur zuschauen.",
"access.startedBy": "Simulation gestartet von {name}",
"browser.address": "Adresse",
"browser.errorText": "Es ist ein Fehler aufgetreten (HTTP-Status: {code})",
"browser.errorTitle": "Fehler {message} ({code})",
@@ -352,6 +357,14 @@
"program.textEditor": "Text-Editor",
"program.webBrowser": "Webbrowser",
"program.webServer": "Webserver",
"remote.connecting": "Verbindung zum Projekt …",
"remote.deleted": "Das Projekt wurde gelöscht.",
"remote.noAccess": "Sie haben keinen Zugang zu diesem Projekt.",
"remote.notAProject": "Diese Adresse nennt kein Projekt.",
"remote.notSignedIn": "Die Anmeldung ist abgelaufen. Öffnen Sie das Projekt im Dashboard neu.",
"remote.refused": "Der Filius-Dienst hat die Verbindung abgelehnt ({reason}).",
"remote.revoked": "Ihr Zugang zu diesem Projekt wurde beendet.",
"remote.unknownProject": "Dieses Projekt gibt es nicht mehr.",
"report.apps": "Anwendungen",
"report.baseConfig": "Basiskonfiguration",
"report.components": "Komponenten",
+13
View File
@@ -1,4 +1,9 @@
{
"access.frozen": "Frozen – view only",
"access.frozenRefused": "The project is frozen; the simulation does not start.",
"access.readOnly": "View only",
"access.readOnlyRefused": "You can only watch here.",
"access.startedBy": "Simulation started by {name}",
"browser.address": "Address",
"browser.errorText": "An error occurred (HTTP-Status: {code})",
"browser.errorTitle": "Error {message} ({code})",
@@ -352,6 +357,14 @@
"program.textEditor": "Text editor",
"program.webBrowser": "Webbrowser",
"program.webServer": "Webserver",
"remote.connecting": "Connecting to the project …",
"remote.deleted": "The project was deleted.",
"remote.noAccess": "You have no access to this project.",
"remote.notAProject": "This address names no project.",
"remote.notSignedIn": "Your sign-in has expired. Open the project again from the dashboard.",
"remote.refused": "The Filius service refused the connection ({reason}).",
"remote.revoked": "Your access to this project has ended.",
"remote.unknownProject": "This project no longer exists.",
"report.apps": "Applications",
"report.baseConfig": "Base Configuration",
"report.components": "Components",
+18 -5
View File
@@ -1,8 +1,11 @@
// The editor's page. Embedded in the dashboard (F6) it gets its document and
// language over the bridge; on its own — for development and the browser
// tests — it opens one of FILIUS' examples, an empty project or a file
// (`?example=<name>` opens that example at once, `?example=new` an empty
// project, `?lang=en` and `?theme=dark` set the rest).
// The editor's page. On a project of server F (`?project=<uuid>`, and
// `&server=<address>` when F is not where the page comes from) it opens the
// shared document and the room there (SharedProject); embedded in the
// dashboard (F6) it gets its language and theme over the bridge. On its own
// — for development and the browser tests — it opens one of FILIUS'
// examples, an empty project or a file (`?example=<name>` opens that example
// at once, `?example=new` an empty project, `?lang=en` and `?theme=dark` set
// the rest), with its room in a worker of the page.
import { StrictMode, useEffect, useState } from 'react';
import { createRoot } from 'react-dom/client';
@@ -11,6 +14,7 @@ import { changeFiles, desktopView, deviceSheet, emptyProject, fileText, topology
import { openProjectArchive } from '@mdg-filius/fls';
import { startBridge } from './bridge/dashboardBridge.js';
import { Editor } from './Editor.js';
import { SharedProject } from './remote/SharedProject.js';
import { LanguageProvider, translate, type Language } from './i18n/i18n.js';
import './theme/tokens.css';
@@ -32,6 +36,7 @@ function App() {
/** The open file's name, for what the editor exports. */
const [fileName, setFileName] = useState<string | undefined>(undefined);
const [example, setExample] = useState(params.get('example') ?? '');
const project = params.get('project');
useEffect(() => {
if (params.get('theme') === 'dark') document.documentElement.dataset.theme = 'dark';
@@ -81,6 +86,14 @@ function App() {
}, [doc]);
const t = (key: string) => translate(language, key);
if (project !== null)
return (
<LanguageProvider language={language}>
<main style={{ height: '100%' }}>
<SharedProject server={params.get('server') ?? window.location.origin} project={project} />
</main>
</LanguageProvider>
);
return (
<LanguageProvider language={language}>
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
@@ -0,0 +1,51 @@
// The editor on a project of server F (F5.3b): the shared document over
// /sync and the room over /sim, both signed in by the cookie F set at
// /__auth. Until the document is there, a word that it is coming; when F
// refuses or lets go — of the document or the room — a word why, instead of
// the editor. Who only reads gets the editor locked (Editor's readOnly).
import { useEffect, useMemo, useState } from 'react';
import { Editor } from '../Editor.js';
import { CATALOGS, useLanguage, useTranslate } from '../i18n/i18n.js';
import type { SimHostFactory } from '../simulation/useSimulation.js';
import { RemoteSimHost, simUrl } from './remoteSimHost.js';
import { connectSharedDoc, type SharedDocState } from './sharedDoc.js';
export interface SharedProjectProps {
/** F's address. */
readonly server: string;
readonly project: string;
}
export function SharedProject({ server, project }: SharedProjectProps) {
const t = useTranslate();
const language = useLanguage();
const [state, setState] = useState<SharedDocState>({ kind: 'connecting' });
/** F ended the room for this page: the editor goes as when the document is let go. */
const [roomEnd, setRoomEnd] = useState<string | null>(null);
useEffect(() => connectSharedDoc({ server, project, onState: setState }), [server, project]);
const createSimHost = useMemo<SimHostFactory>(
() => () => {
const host = new RemoteSimHost(simUrl(server, project));
host.onEnd((end) => setRoomEnd(end.reason));
return host;
},
[server, project],
);
const ended = roomEnd ?? (state.kind === 'refused' || state.kind === 'closed' ? state.reason : null);
if (ended !== null) {
const key = `remote.${ended}`;
return (
<p role="alert" className="remote-message">
{key in CATALOGS[language] ? t(key) : t('remote.refused', { reason: ended })}
</p>
);
}
if (state.kind !== 'ready')
return (
<p role="status" className="remote-message">
{t('remote.connecting')}
</p>
);
return <Editor key={state.doc.guid} doc={state.doc} createSimHost={createSimHost} readOnly={state.readOnly} frozen={state.frozen} />;
}
+136
View File
@@ -0,0 +1,136 @@
// The simulation room on server F (plan §6.1): a WebSocket to
// /sim?project=…, the same messages as the room in a worker of the page
// (LocalSimHost). The socket comes back after it is lost (the network, F
// restarting, the room's thread failing), with the topics it watched; F's
// own word ends it for good — refused (no session, no grant) or let go
// (revoked, deleted) — and is told to whoever listens (`onEnd`).
import {
parseHostMessage,
SIM_CLOSED_CODE,
SIM_PROTOCOL_VERSION,
SIM_REFUSED_CODE,
type ClientMessage,
type HostMessage,
type SimHost,
type SyncClosing,
type SyncRefusal,
type Topic,
} from '@mdg-filius/protocol';
/** The first wait before the socket comes back, doubled each time up to RECONNECT_MAXIMUM_MILLISECONDS. */
export const RECONNECT_FIRST_MILLISECONDS = 500;
export const RECONNECT_MAXIMUM_MILLISECONDS = 10_000;
/** Why F ended the room for this page. */
export type RoomEnd = { readonly kind: 'refused'; readonly reason: SyncRefusal } | { readonly kind: 'closed'; readonly reason: SyncClosing };
/** What RemoteSimHost needs of a WebSocket (the browser's, or ws in tests). */
export interface SocketLike {
readonly readyState: number;
send(data: string): void;
close(code?: number, reason?: string): void;
onopen: (() => void) | null;
onmessage: ((event: { readonly data: unknown }) => void) | null;
onclose: ((event: { readonly code: number; readonly reason: string }) => void) | null;
}
export type SocketFactory = (url: string) => SocketLike;
const OPEN = 1;
export class RemoteSimHost implements SimHost {
private socket: SocketLike | undefined;
private readonly listeners = new Set<(message: HostMessage) => void>();
private readonly endListeners = new Set<(end: RoomEnd) => void>();
/** What waits for the socket to open. */
private queue: ClientMessage[] = [];
/** What this page watches: sent again when the socket comes back. */
private readonly topics = new Set<Topic>();
private wait = RECONNECT_FIRST_MILLISECONDS;
private timer: ReturnType<typeof setTimeout> | undefined;
private over = false;
private openedBefore = false;
constructor(
private readonly url: string,
private readonly open: SocketFactory = (address) => new WebSocket(address) as unknown as SocketLike,
) {
this.connect();
}
private connect(): void {
const socket = this.open(this.url);
this.socket = socket;
socket.onopen = () => {
this.wait = RECONNECT_FIRST_MILLISECONDS;
let queued = this.queue;
this.queue = [];
// Back after a loss, in a room that knows nothing of this page: all it watches, before anything else.
if (this.openedBefore) {
if (this.topics.size > 0) socket.send(JSON.stringify({ v: 1, kind: 'sub', topics: [...this.topics] }));
queued = queued.filter((message) => message.kind !== 'sub' && message.kind !== 'unsub');
}
this.openedBefore = true;
for (const message of queued) socket.send(JSON.stringify(message));
};
socket.onmessage = (event) => {
let value: unknown;
try {
value = JSON.parse(String(event.data));
} catch {
value = undefined;
}
const parsed = parseHostMessage(value);
const message: HostMessage = 'message' in parsed ? parsed.message : { v: SIM_PROTOCOL_VERSION, kind: 'error', code: parsed.error.code, detail: parsed.error.detail };
for (const listener of this.listeners) listener(message);
};
socket.onclose = ({ code, reason }) => {
if (this.over) return;
if (code === SIM_REFUSED_CODE || code === SIM_CLOSED_CODE) {
this.over = true;
const end = (code === SIM_REFUSED_CODE ? { kind: 'refused', reason } : { kind: 'closed', reason }) as RoomEnd;
for (const listener of this.endListeners) listener(end);
return;
}
this.timer = setTimeout(() => this.connect(), this.wait);
this.wait = Math.min(RECONNECT_MAXIMUM_MILLISECONDS, this.wait * 2);
};
}
send(message: ClientMessage): void {
if (message.kind === 'sub') for (const topic of message.topics) this.topics.add(topic);
if (message.kind === 'unsub') for (const topic of message.topics) this.topics.delete(topic);
if (this.over) return;
if (this.socket?.readyState === OPEN) this.socket.send(JSON.stringify(message));
// Sent once the socket is open (again).
else this.queue.push(message);
}
onMessage(listener: (message: HostMessage) => void): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
/** Hears F end the room for this page; returns how to stop hearing. */
onEnd(listener: (end: RoomEnd) => void): () => void {
this.endListeners.add(listener);
return () => this.endListeners.delete(listener);
}
close(): void {
this.over = true;
if (this.timer !== undefined) clearTimeout(this.timer);
this.listeners.clear();
this.endListeners.clear();
this.socket?.close();
}
}
/** The address of a project's room on F, from F's address (http or https). */
export function simUrl(server: string, project: string): string {
const url = new URL('/sim', server);
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
url.searchParams.set('project', project);
return url.href;
}
+105
View File
@@ -0,0 +1,105 @@
// The project's shared document on server F (plan §5): Hocuspocus' client
// on /sync, signed in by the cookie F set at /__auth. What it says is the
// editor's state: still connecting; ready — with the document, whether this
// person only reads, and whether because the project is frozen; refused
// (no session, no grant, no such project); or let go (revoked, deleted).
//
// A person who read and may write again (a frozen project thawed) gets the
// document anew: a change made while reading stayed in the page's copy and
// F did not take it, and Yjs on F would hold back every later change that
// builds on it (measured in F5.2). The editor locks editing while it reads,
// so that nothing gets there; the fresh copy is for what got there anyway.
import { HocuspocusProvider, HocuspocusProviderWebsocket } from '@hocuspocus/provider';
import * as Y from 'yjs';
import { SYNC_CLOSINGS, type AccessMessage } from '@mdg-filius/protocol';
export type SharedDocState =
| { readonly kind: 'connecting' }
| { readonly kind: 'ready'; readonly doc: Y.Doc; readonly readOnly: boolean; readonly frozen: boolean }
/** F refused the connection; the reason is one of SYNC_REFUSALS (or Hocuspocus' own word). */
| { readonly kind: 'refused'; readonly reason: string }
/** F let the connection go; the reason is one of SYNC_CLOSINGS. */
| { readonly kind: 'closed'; readonly reason: string };
export interface SharedDocOptions {
/** F's address (http or https). */
readonly server: string;
readonly project: string;
readonly onState: (state: SharedDocState) => void;
/** A WebSocket other than the page's (tests in Node). */
readonly WebSocketPolyfill?: unknown;
}
/** The address of F's shared documents, from F's address. */
export function syncUrl(server: string): string {
const url = new URL('/sync', server);
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
return url.href;
}
/** Connects to the project's document; returns how to let it go. */
export function connectSharedDoc({ server, project, onState, WebSocketPolyfill }: SharedDocOptions): () => void {
let current: { readonly provider: HocuspocusProvider; readonly socket: HocuspocusProviderWebsocket } | undefined;
const letGo = () => {
current?.provider.destroy();
current?.socket.destroy();
current = undefined;
};
const end = (state: SharedDocState) => {
letGo();
onState(state);
};
const open = () => {
const doc = new Y.Doc();
const socket = new HocuspocusProviderWebsocket({ url: syncUrl(server), ...(WebSocketPolyfill === undefined ? {} : { WebSocketPolyfill }) });
let readOnly = false;
let frozen = false;
let synced = false;
/** F tells the access at once (`access`): ready only with it, so that a reader never sees a writable editor. */
let told = false;
const tell = () => {
if (synced && told) onState({ kind: 'ready', doc, readOnly, frozen });
};
const provider = new HocuspocusProvider({
websocketProvider: socket,
name: project,
document: doc,
// Who comes in is told by the cookie; the token is not looked at.
token: 'cookie',
onSynced: () => {
if (synced) return;
synced = true;
tell();
},
onAuthenticationFailed: ({ reason }) => end({ kind: 'refused', reason }),
onStateless: ({ payload }) => {
let message: Partial<AccessMessage>;
try {
message = JSON.parse(payload) as Partial<AccessMessage>;
} catch {
return;
}
if (message.kind !== 'access' || typeof message.readOnly !== 'boolean') return;
if (readOnly && !message.readOnly && synced) {
letGo();
onState({ kind: 'connecting' });
open();
return;
}
readOnly = message.readOnly;
frozen = message.frozen === true;
told = true;
tell();
},
onClose: ({ event }) => {
if ((SYNC_CLOSINGS as readonly string[]).includes(event.reason)) end({ kind: 'closed', reason: event.reason });
},
});
provider.attach();
current = { provider, socket };
};
onState({ kind: 'connecting' });
open();
return letGo;
}
+24 -7
View File
@@ -34,6 +34,7 @@ import { ConnectionsPanel } from './ConnectionsPanel.js';
import { FirewallPanel } from './FirewallPanel.js';
import { DhcpServerPanel, PortForwardingPanel } from './ServicePanels.js';
import { SheetCheck, SheetField } from './SheetField.js';
import { SheetLock, SheetLocked } from './SheetLock.js';
import './sheet.css';
/** The device's sheet, read anew after every change of the document. */
@@ -60,6 +61,8 @@ export interface DeviceSheetProps {
readonly onClose: () => void;
/** The cable to draw active (a router's card whose tab is open), or none. */
readonly onHighlight: (link: string | null) => void;
/** Who only reads (on F): everything shown, nothing changed (SheetLock). */
readonly readOnly?: boolean;
}
/** A page of the sheet that Java opens as a dialog. */
@@ -67,7 +70,7 @@ export type SheetPage = 'dhcp' | 'forwarding' | 'firewall' | 'connections';
const PAGE_TITLES: Readonly<Record<SheetPage, string>> = { dhcp: 'sheet.dhcpServer', forwarding: 'sheet.portForwarding', firewall: 'sheet.firewall', connections: 'sheet.connections' };
export function DeviceSheet({ doc, id, label, origin, onChanged, onMessage, onClose, onHighlight }: DeviceSheetProps) {
export function DeviceSheet({ doc, id, label, origin, onChanged, onMessage, onClose, onHighlight, readOnly = false }: DeviceSheetProps) {
const t = useTranslate();
const sheet = useDeviceSheet(doc, id);
// The page open, if any; the editor makes a sheet per device, so another device starts at its fields.
@@ -86,6 +89,7 @@ export function DeviceSheet({ doc, id, label, origin, onChanged, onMessage, onCl
};
const title = t(`device.${sheet.kind}`);
return (
<SheetLocked.Provider value={readOnly}>
<aside className="device-sheet" aria-label={t('sheet.title')} data-device={id}>
<header>
{open === null ? (
@@ -103,17 +107,25 @@ export function DeviceSheet({ doc, id, label, origin, onChanged, onMessage, onCl
{/* A field per device: a text typed for one device is not offered to the next. Kept while a page is open, so that its tab stays as Java's panel does behind a dialog. */}
<div key={id} hidden={open !== null}>
{sheet.kind === 'pc' || sheet.kind === 'notebook' ? (
<HostFields sheet={sheet} label={label} change={change} onMessage={onMessage} onOpen={onOpen} />
<SheetLock>
<HostFields sheet={sheet} label={label} change={change} onMessage={onMessage} onOpen={onOpen} />
</SheetLock>
) : sheet.kind === 'networkSwitch' ? (
<SwitchFields sheet={sheet} change={change} />
<SheetLock>
<SwitchFields sheet={sheet} change={change} />
</SheetLock>
) : sheet.kind === 'remoteLink' ? (
<ModemFields sheet={sheet} change={change} />
<SheetLock>
<ModemFields sheet={sheet} change={change} />
</SheetLock>
) : sheet.kind === 'router' ? (
<RouterFields sheet={sheet} change={change} onHighlight={onHighlight} onOpen={onOpen} />
) : sheet.kind === 'gateway' ? (
<HomeRouterFields sheet={sheet} change={change} onHighlight={onHighlight} onOpen={onOpen} />
) : (
<NameField name={sheet.name} change={change} />
<SheetLock>
<NameField name={sheet.name} change={change} />
</SheetLock>
)}
</div>
{open === 'dhcp' && (sheet.kind === 'pc' || sheet.kind === 'notebook' || sheet.kind === 'gateway') ? (
@@ -126,14 +138,19 @@ export function DeviceSheet({ doc, id, label, origin, onChanged, onMessage, onCl
</div>
) : open === 'connections' && sheet.kind === 'router' ? (
<div key={`${id}-connections`}>
<ConnectionsPanel sheet={sheet} change={change} />
<SheetLock>
<ConnectionsPanel sheet={sheet} change={change} />
</SheetLock>
</div>
) : open === 'forwarding' && sheet.kind === 'gateway' ? (
<div key={`${id}-forwarding`}>
<PortForwardingPanel sheet={sheet} change={change} />
<SheetLock>
<PortForwardingPanel sheet={sheet} change={change} />
</SheetLock>
</div>
) : null}
</aside>
</SheetLocked.Provider>
);
}
+3 -2
View File
@@ -24,6 +24,7 @@ import { useTranslate } from '../i18n/i18n.js';
import type { Change } from './NameField.js';
import { SheetCheck, SheetField, SheetNote } from './SheetField.js';
import { SheetTabs } from './SheetTabs.js';
import { SheetPanel } from './SheetLock.js';
/** Java's protocol names in the rule table (ruleAsVector): "*" for all. */
const PROTOCOL_NAMES: Readonly<Record<FirewallProtocol, string>> = { all: '*', tcp: 'TCP', udp: 'UDP', icmp: 'ICMP' };
@@ -51,7 +52,7 @@ export function FirewallPanel({ firewall, change }: { readonly firewall: Firewal
selected={tab}
onSelect={setTab}
/>
<div role="tabpanel" key={tab}>
<SheetPanel key={tab}>
{tab === 'general' ? (
<>
<SheetCheck name="firewallActive" label={t('firewall.active')} checked={firewall.isActive} onChange={flag('isActive')} />
@@ -174,7 +175,7 @@ export function FirewallPanel({ firewall, change }: { readonly firewall: Firewal
)}
</>
)}
</div>
</SheetPanel>
</>
);
}
+5 -4
View File
@@ -27,6 +27,7 @@ import { NameField, type Change } from './NameField.js';
import { RowsEditor } from './RowsEditor.js';
import { SheetTabs, type Tab } from './SheetTabs.js';
import { SheetCheck, SheetField } from './SheetField.js';
import { SheetPanel } from './SheetLock.js';
/** Reports the cable of the card whose tab is open, and none once the sheet goes. */
function useHighlight(link: string | null, onHighlight: (link: string | null) => void): void {
@@ -94,7 +95,7 @@ export function RouterFields({
<>
<SheetTabs tabs={tabs} selected={shown} onSelect={setTab} />
{/* A panel per tab: a text typed on one card is not offered to the next. */}
<div role="tabpanel" key={shown}>
<SheetPanel key={shown}>
{shown === 'general' ? (
<>
<NameField name={sheet.name} change={change} />
@@ -115,7 +116,7 @@ export function RouterFields({
) : (
<RouteTable sheet={sheet} change={change} />
)}
</div>
</SheetPanel>
</>
);
}
@@ -144,7 +145,7 @@ export function HomeRouterFields({
return (
<>
<SheetTabs tabs={tabs} selected={tab} onSelect={setTab} />
<div role="tabpanel" key={tab}>
<SheetPanel key={tab}>
{tab === 'general' ? (
<>
<NameField name={sheet.name} change={change} />
@@ -175,7 +176,7 @@ export function HomeRouterFields({
<SheetCheck name="dhcpClient" label={t('sheet.dhcpClient')} checked={sheet.dhcpClient} onChange={(on) => change.run(() => setDhcpClient(change.doc, change.id, on, change.origin))} />
</>
)}
</div>
</SheetPanel>
</>
);
}
+3 -2
View File
@@ -28,6 +28,7 @@ import type { Change } from './NameField.js';
import { RowsEditor, type RowsColumn } from './RowsEditor.js';
import { SheetCheck, SheetField } from './SheetField.js';
import { SheetTabs } from './SheetTabs.js';
import { SheetPanel } from './SheetLock.js';
export function DhcpServerPanel({ server, change }: { readonly server: DhcpServerSheet; readonly change: Change }) {
const t = useTranslate();
@@ -44,7 +45,7 @@ export function DhcpServerPanel({ server, change }: { readonly server: DhcpServe
selected={tab}
onSelect={setTab}
/>
<div role="tabpanel" key={tab}>
<SheetPanel key={tab}>
{tab === 'basic' ? (
<>
<SheetField name="lowerBound" label={t('dhcp.lowerBound')} value={server.lowerBoundIPAddress} rule="ipAddress" inputMode="decimal" onCommit={address('lowerBoundIPAddress')} />
@@ -105,7 +106,7 @@ export function DhcpServerPanel({ server, change }: { readonly server: DhcpServe
onRemove={(index) => change.run(() => removeDhcpStaticAssignment(change.doc, change.id, index, change.origin))}
/>
)}
</div>
</SheetPanel>
</>
);
}
+26
View File
@@ -0,0 +1,26 @@
// The sheet of a person who only reads (on F): what it holds is shown, every
// field, box and button that changes something is disabled — a fieldset
// around a sheet's body and around each tab's panel, so that the tabs stay
// to be read through.
import { createContext, useContext, type ReactNode } from 'react';
export const SheetLocked = createContext(false);
/** A sheet's body, disabled while the sheet is locked. */
export function SheetLock({ children }: { readonly children: ReactNode }) {
return (
<fieldset className="sheet-lock" disabled={useContext(SheetLocked)}>
{children}
</fieldset>
);
}
/** A tab's panel, disabled while the sheet is locked. */
export function SheetPanel({ children }: { readonly children: ReactNode }) {
return (
<fieldset className="sheet-lock" role="tabpanel" disabled={useContext(SheetLocked)}>
{children}
</fieldset>
);
}
+27
View File
@@ -27,6 +27,18 @@
pointer-events: auto;
}
/* A word beside the mode buttons: who started the simulation, that this person only reads. */
.editor-note {
display: flex;
align-items: center;
min-height: 44px;
padding: 0 12px;
border: 1px solid var(--border-default);
border-radius: 8px;
background: var(--surface-raised);
color: var(--text-primary);
}
.editor-area {
position: relative;
flex: 1;
@@ -336,3 +348,18 @@
color: var(--text-secondary);
}
/* A locked sheet's body and tab panels (SheetLock): a fieldset without its own frame. */
.sheet-lock {
min-width: 0;
margin: 0;
padding: 0;
border: 0;
}
/* Instead of the editor on a project of F: that it is coming, or why not. */
.remote-message {
margin: 0;
padding: 24px;
color: var(--text-primary);
}
+20 -5
View File
@@ -1,6 +1,7 @@
// Java's mode buttons (GUIMainMenu: documentation, design and simulation
// mode, in Java's order, with Java's icons) and, while the simulation runs, its speed slider with the per cent
// beside it. Java's keys for the modes (Ctrl+D, Ctrl+R) are not offered: in
// beside it. Who only reads (on F) neither starts nor stops the room's
// simulation nor sets its speed; the documentation mode is a view of one's own. Java's keys for the modes (Ctrl+D, Ctrl+R) are not offered: in
// a browser they bookmark and reload the page.
import type { CSSProperties } from 'react';
@@ -25,7 +26,20 @@ const modeButton: CSSProperties = {
/** The editor's modes: the room's two, and the documentation mode, which is the design's for the room. */
export type EditorMode = 'documentation' | 'design' | 'simulation';
export function ModeBar({ mode, speed, onMode, onSpeed }: { readonly mode: EditorMode; readonly speed: number; readonly onMode: (mode: EditorMode) => void; readonly onSpeed: (value: number) => void }) {
export function ModeBar({
mode,
speed,
onMode,
onSpeed,
canRun = true,
}: {
readonly mode: EditorMode;
readonly speed: number;
readonly onMode: (mode: EditorMode) => void;
readonly onSpeed: (value: number) => void;
/** Whether this person may start and stop the simulation (a write grant on F). */
readonly canRun?: boolean;
}) {
const t = useTranslate();
return (
<>
@@ -36,19 +50,20 @@ export function ModeBar({ mode, speed, onMode, onSpeed }: { readonly mode: Edito
aria-pressed={mode === 'documentation'}
aria-label={t('mode.documentation')}
title={t('mode.documentation')}
disabled={!canRun && mode === 'simulation'}
onClick={() => onMode('documentation')}
>
<img src={mode === 'documentation' ? documentationOn : documentation} alt="" width={50} height={50} />
</button>
<button type="button" name="designMode" style={modeButton} aria-pressed={mode === 'design'} aria-label={t('mode.design')} title={t('mode.design')} onClick={() => onMode('design')}>
<button type="button" name="designMode" style={modeButton} aria-pressed={mode === 'design'} aria-label={t('mode.design')} title={t('mode.design')} disabled={!canRun && mode === 'simulation'} onClick={() => onMode('design')}>
<img src={mode === 'design' ? designOn : design} alt="" width={50} height={50} />
</button>
<button type="button" name="simulationMode" style={modeButton} aria-pressed={mode === 'simulation'} aria-label={t('mode.simulation')} title={t('mode.simulation')} onClick={() => onMode('simulation')}>
<button type="button" name="simulationMode" style={modeButton} aria-pressed={mode === 'simulation'} aria-label={t('mode.simulation')} title={t('mode.simulation')} disabled={!canRun} onClick={() => onMode('simulation')}>
<img src={mode === 'simulation' ? simulationOn : simulation} alt="" width={50} height={50} />
</button>
{mode === 'simulation' ? (
<label style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '0 8px', minHeight: 52, borderRadius: 8, background: 'var(--surface-raised)', border: '1px solid var(--border-default)' }}>
<input type="range" name="speed" min={1} max={100} value={speed} aria-label={t('mode.speed')} title={t('mode.speed')} onChange={(event) => onSpeed(Number(event.target.value))} style={{ width: 100 }} />
<input type="range" name="speed" min={1} max={100} value={speed} disabled={!canRun} aria-label={t('mode.speed')} title={t('mode.speed')} onChange={(event) => onSpeed(Number(event.target.value))} style={{ width: 100 }} />
<span title={t('mode.currentSpeed')} style={{ minWidth: 40, fontVariantNumeric: 'tabular-nums' }}>
{speed}%
</span>
@@ -2,8 +2,9 @@
// the cables that carry frames, a computer's network as it runs, the lines
// and views of the programs' windows and the actions taken in them, the
// cards' frames for the data exchange window. The room
// runs behind a SimHost — in a worker of this page (LocalSimHost) until F
// holds it (RemoteSimHost, F5); the editor keeps no simulation of its own.
// runs behind a SimHost — on F for everyone in the project (RemoteSimHost),
// or in a worker of this page (LocalSimHost) on the development page; the
// editor keeps no simulation of its own.
import { useCallback, useEffect, useRef, useState } from 'react';
import type * as Y from 'yjs';
@@ -41,6 +42,10 @@ export interface Simulation {
readonly views: ReadonlyMap<string, unknown>;
/** Whether the core failed and the room went back to design mode, since the last start. */
readonly failed: boolean;
/** Who started the simulation that runs, on F. */
readonly startedBy: string | undefined;
/** The room's last no to this person — it only reads, the project is frozen — counted, so that each is told. */
readonly refusal: { readonly code: 'readOnly' | 'frozen'; readonly count: number } | null;
/** The time of day (milliseconds since 1970) the simulation's time 0 stands for: a viewer's times of day are written from it. */
readonly clock: number;
/** The frames "Pakete verwerfen" dropped in this simulation, while `dropped` is watched. */
@@ -75,6 +80,8 @@ export function useSimulation(doc: Y.Doc, createHost: SimHostFactory = localSimH
const [activeLinks, setActiveLinks] = useState<ReadonlySet<string>>(NO_LINKS);
const [nodes, setNodes] = useState<ReadonlyMap<string, NodeSettings>>(new Map());
const [failed, setFailed] = useState(false);
const [startedBy, setStartedBy] = useState<string | undefined>(undefined);
const [refusal, setRefusal] = useState<Simulation['refusal']>(null);
const [apps, setApps] = useState<ReadonlyMap<string, AppLines>>(new Map());
const [views, setViews] = useState<ReadonlyMap<string, unknown>>(new Map());
const [clock, setClock] = useState(0);
@@ -90,6 +97,7 @@ export function useSimulation(doc: Y.Doc, createHost: SimHostFactory = localSimH
setMode(message.mode);
setSpeedState(message.speed);
setClock(message.clock);
setStartedBy(message.startedBy);
if (message.mode === 'design') setActiveLinks(NO_LINKS);
return;
case 'activity':
@@ -125,6 +133,10 @@ export function useSimulation(doc: Y.Doc, createHost: SimHostFactory = localSimH
return;
case 'error':
if (message.code === 'engineFailed') setFailed(true);
if (message.code === 'readOnly' || message.code === 'frozen') {
const code = message.code;
setRefusal((before) => ({ code, count: (before?.count ?? 0) + 1 }));
}
return;
default:
return;
@@ -199,5 +211,5 @@ export function useSimulation(doc: Y.Doc, createHost: SimHostFactory = localSimH
host.current?.send({ v: 1, kind: 'input', node, app, action, args, reqId: crypto.randomUUID() });
}, []);
return { mode, speed, activeLinks, nodes, apps, views, failed, clock, dropped, traces, start, stop, setSpeed, drop, clearCapture, watch, captures, act };
return { mode, speed, activeLinks, nodes, apps, views, failed, startedBy, refusal, clock, dropped, traces, start, stop, setSpeed, drop, clearCapture, watch, captures, act };
}