From f52852493aec830f37b349181c85826d5863c44a Mon Sep 17 00:00:00 2001 From: Julian Kiedaisch Date: Sun, 4 Oct 2026 15:00:18 +0000 Subject: [PATCH] feat: F5.3b the editor on a project of server F ?project= 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 --- CHANGES.md | 5 + README.md | 2 +- package-lock.json | 3 +- packages/editor/browser/serverF.ts | 100 +++++++++++ packages/editor/browser/shared.spec.ts | 146 +++++++++++++++ packages/editor/package.json | 1 + packages/editor/src/Editor.tsx | 44 ++++- packages/editor/src/canvas/Canvas.tsx | 23 ++- .../src/design/DocumentationSidebar.tsx | 6 +- packages/editor/src/i18n/de.json | 13 ++ packages/editor/src/i18n/en.json | 13 ++ packages/editor/src/main.tsx | 23 ++- packages/editor/src/remote/SharedProject.tsx | 51 ++++++ packages/editor/src/remote/remoteSimHost.ts | 136 ++++++++++++++ packages/editor/src/remote/sharedDoc.ts | 105 +++++++++++ packages/editor/src/sheet/DeviceSheet.tsx | 31 +++- packages/editor/src/sheet/FirewallPanel.tsx | 5 +- packages/editor/src/sheet/RouterSheets.tsx | 9 +- packages/editor/src/sheet/ServicePanels.tsx | 5 +- packages/editor/src/sheet/SheetLock.tsx | 26 +++ packages/editor/src/sheet/sheet.css | 27 +++ packages/editor/src/simulation/ModeBar.tsx | 25 ++- .../editor/src/simulation/useSimulation.ts | 18 +- packages/editor/test/remoteSimHost.test.ts | 166 ++++++++++++++++++ packages/server/src/sync.ts | 20 ++- packages/server/test/access.test.ts | 36 +++- packages/server/test/editorConnection.test.ts | 158 +++++++++++++++++ packages/server/test/sim.test.ts | 3 +- 28 files changed, 1142 insertions(+), 58 deletions(-) create mode 100644 packages/editor/browser/serverF.ts create mode 100644 packages/editor/browser/shared.spec.ts create mode 100644 packages/editor/src/remote/SharedProject.tsx create mode 100644 packages/editor/src/remote/remoteSimHost.ts create mode 100644 packages/editor/src/remote/sharedDoc.ts create mode 100644 packages/editor/src/sheet/SheetLock.tsx create mode 100644 packages/editor/test/remoteSimHost.test.ts create mode 100644 packages/server/test/editorConnection.test.ts diff --git a/CHANGES.md b/CHANGES.md index 1065b0f..1e1c853 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -260,6 +260,9 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand | `browser/touch.spec.ts`, `browser/fingers.ts`, `playwright.config.ts` (Projekte `ipad-portrait`, `ipad-landscape`), `package.json` (Playwright fest auf 1.62.1) | — | neu (F4.9): der Editor nur mit dem Finger, in Chromium am Desktop und in WebKit als iPad hoch und quer — die Abnahme von F4 als Ablauf ("zwei Rechnernetze" neu gebaut, Adressen im Blatt, Kabel per Werkzeug, Befehlszeile installiert, Ping quer über den Router), Zwei-Finger-Zoom vergrößert die Fläche und nie die Seite (M7, ohne Rahmen), jede Aktion ohne Rechtsklick (langes Drücken, Dreipunkt-Knopf, Menü der Dokumentation), alle Ziele mindestens 44 px, nichts über den Rand. Echte Gesten: Chromium über CDP, WebKit über `Input.dispatchTouchEvent` seiner Seitensitzung, die Playwright nur über die Verbindung im selben Prozess hergibt (Naht im Inneren von Playwright, Version deshalb fest). Ein gezogener Finger hält vor dem Loslassen 150 ms still: sonst nimmt Chromium die Geste als Schwung, und das nächste Antippen stoppt nur den Schwung | | `src/sheet/ServicePanels.tsx`, `src/sheet/RowsEditor.tsx`, `src/sheet/SheetTabs.tsx`, `src/sheet/DeviceSheet.tsx` (Seiten) | Java `JDHCPKonfiguration` (`jdhcpkonfiguration_msg1`–`15`), `JPortForwardingDialog` (`jportforwarding_msg4`–`7`, `jgatewayconfiguration_msg19`) | neu (F4.5c): Javas Dialoge als Seiten des Blatts mit „Zurück“ — DHCP-Server (Grundeinstellungen, statische Adresszuweisung; beim Rechner, gesperrt während DHCP die Karte einrichtet, und auf dem LAN-Reiter des Heimrouters), Portfreigaben (Heimrouter); jedes Feld beim Verlassen übernommen statt mit Javas OK-Knopf. Die Zeilentabellen (Weiterleitung, Zuordnungen, Portfreigaben) teilen sich `RowsEditor`: eine neue Zeile, sobald alle Felder gültig sind, Zeile wählen, bearbeiten, löschen. Javas Tooltip zu „Manuelle Einstellungen“ als Text unter dem Kästchen (ein Finger hat kein Überfahren) | | `src/sheet/RouterSheets.tsx`, `src/sheet/NameField.tsx`, `src/canvas/Canvas.tsx` (`highlightedLink`), `src/theme/tokens.css` (`--canvas-cable-active`) | Java `JVermittlungsrechnerKonfiguration`, `JGatewayConfiguration`, `JWeiterleitungsTabelle` (Wörter `jvermittlungsrechnerkonfiguration_msg6`–`26`, `jweiterleitungstabelle_msg3`–`6`), `JCablePanel.farbeBlinken`, `Kabel.setAktiv` | neu (F4.5b): Blatt des Vermittlungsrechners — Reiter Allgemein (Name, Gateway, Automatisches Routing, IP-Weiterleitung nur angezeigt), je Karte (Reiter mit ihrer Adresse, „Verbunden mit …“, Adresse, Maske, MAC) und Weiterleitungstabelle (alle Einträge oder nur die manuellen, Javas Zeilen grau, eine neue Zeile, die mit vier gültigen Feldern übernommen wird, Zelle bearbeiten, Eintrag löschen; bei RIP gesperrt); Blatt des Heimrouters mit Allgemein, LAN, WAN (DHCP sperrt die WAN-Adresse). Das Kabel der offenen Karte leuchtet grün wie bei Java. Javas „Eintrag Löschen“ klein geschrieben | +| `src/remote/remoteSimHost.ts`, `src/remote/sharedDoc.ts`, `src/remote/SharedProject.tsx`, `src/main.tsx` (`?project=`) | Plan §5, §6.1, §8.5; Hocuspocus' Provider 3.4.4 (MIT) | neu (F5.3b): der Editor auf einem Projekt von F — das gemeinsame Dokument über `/sync` (bereit mit Dokument, Lesestufe und ob eingefroren; abgewiesen; getrennt) und der Raum über `/sim` (`RemoteSimHost`: Nachrichten vor dem Öffnen warten, nach einem Verlust kommt der Socket wieder, mit doppelter Wartezeit bis 10 s und allen abonnierten Themen; Absage und Trennung von F beenden ihn); wer liest und wieder schreiben darf (aufgetaut), bekommt das Dokument neu; statt des Editors ein Wort, warum nicht | +| `src/Editor.tsx`, `src/canvas/Canvas.tsx` (`locked`), `src/sheet/SheetLock.tsx`, `src/sheet/DeviceSheet.tsx`, `src/sheet/RouterSheets.tsx`, `src/sheet/FirewallPanel.tsx`, `src/sheet/ServicePanels.tsx`, `src/design/DocumentationSidebar.tsx`, `src/simulation/ModeBar.tsx`, `src/simulation/useSimulation.ts`, `src/i18n/*.json` | Plan §6.2, §6.4, §9.3 | geändert (F5.3b): wer nur liest, sieht alles und ändert nichts — keine Palette, kein Rückgängig, nichts bewegt sich oder hat ein Menü, die Felder des Blatts sind gesperrt (die Reiter bleiben), keine neuen Doku-Elemente, die Simulation wird von ihm weder gestartet noch angehalten; Desktops und Datenaustausch öffnen zum Zuschauen; ein „nein“ des Raums wird gesagt; „Simulation gestartet von …“ und „Nur ansehen“ bzw. „Eingefroren – nur ansehen“ neben den Modusknöpfen | +| `test/remoteSimHost.test.ts`, `browser/shared.spec.ts`, `browser/serverF.ts` | — | neu (F5.3b): `RemoteSimHost` auf nachgebauten Sockets mit gestellter Uhr; drei Personen in drei Browsern an einem echten F (aus dem Bündel gestartet): verschieben, nur lesen, eine Simulation für alle mit Namen, ein Ping in einer Befehlszeile bei der anderen, Eintippen als Leser abgewiesen, eingefroren und aufgetaut, Zugang beendet; Absagen ohne Sitzung und ohne Projekt | ## packages/server @@ -271,6 +274,8 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand | `src/sync.ts` (Anmeldung), `src/server.ts` (Wege, Herkunft) | Plan §8.5 | geändert (F5.2): `/sync` entscheidet nach der Berechtigung der Sitzung, die der Keks nennt — keine, Lesen, Schreiben; ein eingefrorenes Projekt lesen alle; jede Absage mit Grund und im Protokoll; ein WebSocket von einer Seite fremder Herkunft wird abgewiesen (eine Seite anderswo unter derselben Domain brächte den Keks mit, SameSite trennt nur Sites); unerwartete Fehler eines Wegs werden 500 und stehen im Protokoll | | `src/service.ts`, `src/store.ts` (`freeze`, `remove`), `src/persistence.ts` (`discard`), `src/main.ts` | Dashboard `backend/modules/scratch/src/service.py`, Scratch-Editor `revoke`, `active_sessions` | neu (F5.2): `/svc/*` mit Dienstschlüssel (in konstanter Zeit verglichen): Projekt anlegen (leeres FILIUS-Projekt), löschen (erst nicht mehr gespeichert, dann weg, dann alle getrennt — wer wiederkommt, findet kein Projekt), einfrieren und auftauen (wer drin ist, liest bzw. schreibt wieder und erfährt es), Berechtigung einer Person an einem Projekt oder alle ihre Sitzungen beenden (wer verbunden ist, wird getrennt und beim Wiederkommen abgewiesen), die Sitzungen auflisten; ohne Geheimnisse startet der Dienst nicht | | `src/simRooms.ts`, `src/simWorker.ts`, `src/simChannel.ts`, `src/server.ts` (`/sim`), `src/service.ts` (Räume) | Plan §6.1–§6.4, §8.2 | neu (F5.3a): die Simulationsräume auf F — einer je Projekt für alle darin, gehalten von wenigen `worker_threads` (der mit den wenigsten Räumen nimmt den nächsten), jeder Raum mit einer Kopie des Dokuments: Hocuspocus' direkte Verbindung hält das Dokument offen, solange der Raum lebt, jede Änderung geht in den Raum, was der Raum schreibt, kommt als seine Änderung zurück; `/sim?project=…` meldet an wie `/sync` (Keks, Berechtigung, Herkunft), Nachrichten vor der Anmeldung werden gehalten; ändern nur mit Schreibstufe; nach dem Letzten bleibt der Raum 60 s und hält dann, schreibt zurück und endet; Einfrieren hält eine laufende Simulation an (ihr Stand kommt noch ins Projekt), Löschen beendet den Raum ohne Zurückschreiben, Widerruf trennt auch im Raum; ein scheiternder Faden beendet seine Räume; beim Herunterfahren halten alle Räume und schreiben zurück; ein Socket ohne Antwort auf Pings wird getrennt; Nachrichten höchstens 1 MiB; `/healthz` zählt die Räume; der Build bündelt den Faden als `dist/simWorker.mjs` | +| `src/sync.ts` (Zugang gleich zu Beginn, stille Absage) | Plan §8.5 | geändert (F5.3b): jede Verbindung erfährt ihren Zugang sofort nach der Anmeldung (Nachricht `access`), damit der Editor weiß, ob er liest und warum; eine Absage trägt keinen Meldungstext — Hocuspocus schreibt sonst die Meldung eines gescheiterten Hooks auf die Konsole, außerhalb des JSON-Protokolls | +| `test/editorConnection.test.ts` | — | neu (F5.3b): `connectSharedDoc` des Editors gegen einen echten F — bereit mit Schreib- und Lesestufe, eingefroren und aufgetaut (neues Dokument, die verirrte Änderung ist weg, Schreiben geht wieder), eingefroren von Beginn an, Absage, Widerruf, Löschen, Loslassen | | `test/sim.test.ts`, `test/harness.ts` (Faden) | — | neu (F5.3a): echte Sockets, der echte Faden (esbuild bündelt ihn wie der Build), Beispiel internetwork_komplett: ein Start für alle mit Namen, Ping in einer Befehlszeile bei der anderen, Zurückschreiben ins Dokument und eine Datei aus dem Dokument im Raum, Lesestufe, Absagen, Nachlauf, Einfrieren, Löschen, Widerruf, Wahl des Fadens, scheiternder Faden, Herunterfahren, Ping | | `test/ticket.test.ts`, `test/tickets.ts`, `test/tickets.python.json` | `tools/ticket-oracle` | neu (F5.2): jede Karte des Python-Orakels liest Node wie Python (Nutzlast oder derselbe Grund der Ablehnung); in Node signierte Karten tragen dasselbe JSON zum selben Zeitpunkt (Nodes zlib packt anders, ungepackt byte-gleich); die Ansprüche | | `test/access.test.ts`, `test/harness.ts` | — | neu (F5.2): `/__auth` (Keks, `next`, schlechte Karten, unbekanntes Projekt, zweites Projekt, nächste Person, Verlängerung), Absagen an `/sync` mit Grund, Herkunft, Dienstweg (Schlüssel, Anlegen, Fehlerantworten, Löschen, Einfrieren, Widerruf einzeln und ganz, Sitzungsliste, 500) | diff --git a/README.md b/README.md index 7cbf73d..c8f92b7 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ die Herkunft je Paket. Lizenz dieses Repos: **GPL-3.0** (`LICENSE`). | `packages/doc` | gemeinsames Dokument (Yjs): Projekt ⇄ Dokument, Schnappschuss des Kerns, Hausmeister, die Änderungen der Werkzeuge und des Geräte-Blatts (mit Javas Eingabeprüfung) | | `packages/protocol` | die eine Nachrichtenliste zwischen Editor und Simulationsraum (`/sim`) | | `packages/sim` | Simulationsraum (für Server F und den Web Worker) und `LocalSimHost` | -| `packages/editor` | Oberfläche im Iframe des Dashboards (React, Vite): Fläche, Palette, Werkzeuge des Entwurfs, Geräte-Blatt, Aktionsmodus (Desktops, Anwendungen, Datenaustausch), Dokumentationsmodus (Text, Gliederungsfläche, Bild exportieren, Bericht als PDF); `npm run dev -w @mdg-filius/editor` öffnet die Entwicklungsseite | +| `packages/editor` | Oberfläche im Iframe des Dashboards (React, Vite), auf einem Projekt von F (`?project=`) oder mit einem Raum in der eigenen Seite (Entwicklung): Fläche, Palette, Werkzeuge des Entwurfs, Geräte-Blatt, Aktionsmodus (Desktops, Anwendungen, Datenaustausch), Dokumentationsmodus (Text, Gliederungsfläche, Bild exportieren, Bericht als PDF); `npm run dev -w @mdg-filius/editor` öffnet die Entwicklungsseite | | `packages/server` | Filius-Dienst auf Server F: gemeinsame Dokumente über Hocuspocus (`/sync`), gespeichert in Postgres (Stand und Aktualisierungen, verdichtet), Hausmeister; Anmeldung mit der Eintrittskarte des Dashboards (`/__auth`, Keks, Lese- und Schreibstufe); Dienstweg des Dashboards (`/svc/*`: Projekte anlegen, löschen, einfrieren, Zugänge widerrufen); Simulationsräume auf `/sim` in `worker_threads`, einer je Projekt für alle; `/healthz`; `npm run build -w @mdg-filius/server` bündelt ihn nach `dist/main.mjs` und den Faden der Räume nach `dist/simWorker.mjs` | Plan und Entscheidungen: `knowledge/plans/FILIUS_WEB_PLAN.md` im Dashboard. diff --git a/package-lock.json b/package-lock.json index 857d347..dab6c27 100644 --- a/package-lock.json +++ b/package-lock.json @@ -764,7 +764,6 @@ "version": "3.4.4", "resolved": "https://registry.npmjs.org/@hocuspocus/provider/-/provider-3.4.4.tgz", "integrity": "sha512-KbsMAfdYcIJD8eMU/5QnpXcSOvIWAcCNI33FSRSaKCIpYBFtAwkYIwWnZJmPZ8a1BMAtqQc+uvy9+UQf7GHnGQ==", - "dev": true, "license": "MIT", "dependencies": { "@hocuspocus/common": "^3.4.4", @@ -849,7 +848,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/@lifeomic/attempt/-/attempt-3.1.0.tgz", "integrity": "sha512-QZqem4QuAnAyzfz+Gj5/+SLxqwCAw2qmt7732ZXodr6VDWGeYLG6w1i/vYLa55JQM9wRuBKLmXmiZ2P0LtE5rw==", - "dev": true, "license": "MIT" }, "node_modules/@mdg-filius/core": { @@ -2853,6 +2851,7 @@ "version": "0.0.0", "license": "GPL-3.0-only", "dependencies": { + "@hocuspocus/provider": "3.4.4", "@mdg-filius/core": "0.0.0", "@mdg-filius/doc": "0.0.0", "@mdg-filius/fls": "0.0.0", diff --git a/packages/editor/browser/serverF.ts b/packages/editor/browser/serverF.ts new file mode 100644 index 0000000..427eea3 --- /dev/null +++ b/packages/editor/browser/serverF.ts @@ -0,0 +1,100 @@ +// A real server F for the editor's browser tests: bundled as the build +// bundles it (dist/main.mjs and its room thread), started as a process with +// a throwaway Postgres (packages/server/test/database.ts), on the host the +// editor's page comes from, so that F's cookie goes with the page's +// requests. A project is made in F's store straight from a FILIUS file; a +// person signs in as the dashboard would let them, with a ticket at /__auth. + +import { spawn, type ChildProcess } from 'node:child_process'; +import { mkdtempSync, readFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { randomUUID } from 'node:crypto'; +import type { BrowserContext } from '@playwright/test'; +import { build } from 'esbuild'; +import pg from 'pg'; +import * as Y from 'yjs'; +import { writeProject } from '@mdg-filius/doc'; +import { openProjectArchive } from '@mdg-filius/fls'; +import { testDatabase, type TestDatabase } from '../../server/test/database.js'; +import { signTicket } from '../../server/test/tickets.js'; + +const TICKET_SECRET = 'browser-ticket-secret'; +export const SERVICE_KEY = 'browser-service-key'; + +export interface ServerF { + readonly base: string; + /** A project made from a FILIUS file of the corpus; its devices' ids by name. */ + project(file: string): Promise<{ readonly id: string; readonly node: (name: string) => string }>; + /** Signs a person in, in this browser context. */ + signIn(context: BrowserContext, project: string, person: { readonly name: string; readonly level?: 'read' | 'write'; readonly user?: string }): Promise; + service(method: string, path: string, body?: unknown): Promise; + close(): Promise; +} + +/** Server F on the editor page's host (`editorBase`), for pages from there. */ +export async function startServerF(editorBase: string): Promise { + const editor = new URL(editorBase); + const database: TestDatabase = await testDatabase(); + const out = mkdtempSync(join(tmpdir(), 'filius-server-f-')); + const source = new URL('../../server/src/', import.meta.url).pathname; + await build({ + entryPoints: [join(source, 'main.ts'), join(source, 'simWorker.ts')], + bundle: true, + platform: 'node', + format: 'esm', + target: 'node20', + outdir: out, + outExtension: { '.js': '.mjs' }, + external: ['pg-native'], + banner: { js: "import { createRequire } from 'node:module'; const require = createRequire(import.meta.url);" }, + logLevel: 'error', + }); + const port = 20_000 + Math.floor(Math.random() * 20_000); + const child: ChildProcess = spawn(process.execPath, [join(out, 'main.mjs')], { + env: { + ...process.env, + DATABASE_URL: database.url, + FILIUS_TICKET_SECRET: TICKET_SECRET, + FILIUS_SERVICE_KEY: SERVICE_KEY, + FILIUS_ORIGINS: editor.origin, + FILIUS_PORT: String(port), + FILIUS_INSECURE_COOKIE: '1', + }, + stdio: ['ignore', 'pipe', 'inherit'], + }); + await new Promise((resolve, reject) => { + child.stdout!.on('data', (data: Buffer) => { + if (data.toString().includes('"server.listening"')) resolve(); + }); + child.once('exit', (code) => reject(new Error(`server F ended (${code})`))); + }); + const base = `${editor.protocol}//${editor.hostname}:${port}`; + const sql = new pg.Pool({ connectionString: database.url }); + return { + base, + async project(file) { + const id = randomUUID(); + const imported = openProjectArchive(new Uint8Array(readFileSync(new URL(`../../../corpus/${file}`, import.meta.url))), () => randomUUID()); + const doc = new Y.Doc(); + writeProject(doc, imported); + await sql.query('INSERT INTO filius_doc (project_uuid, state) VALUES ($1, $2)', [id, Buffer.from(Y.encodeStateAsUpdate(doc))]); + return { id, node: (name) => imported.graph.nodes.find((each) => each.displayName === name)!.id }; + }, + async signIn(context, project, { name, level = 'write', user = randomUUID() }) { + const ticket = signTicket({ u: user, n: name.toLowerCase(), d: name, p: project, lvl: level }, TICKET_SECRET); + const page = await context.newPage(); + const response = await page.goto(`${base}/__auth?ticket=${encodeURIComponent(ticket)}&next=/healthz`); + if (response?.status() !== 200) throw new Error(`not signed in: ${response?.status()}`); + await page.close(); + }, + service: (method, path, body) => fetch(`${base}${path}`, { method, headers: { 'x-filius-service-key': SERVICE_KEY }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) }), + async close() { + const ended = new Promise((resolve) => child.once('exit', resolve)); + child.kill('SIGTERM'); + await ended; + await sql.end(); + await database.stop(); + }, + }; +} diff --git a/packages/editor/browser/shared.spec.ts b/packages/editor/browser/shared.spec.ts new file mode 100644 index 0000000..b8ee478 --- /dev/null +++ b/packages/editor/browser/shared.spec.ts @@ -0,0 +1,146 @@ +// The editor on a project of a real server F (F5.3b), three people in three +// browsers: Mia and Ben write, Rita only reads. What one moves the others +// see; Rita gets everything shown and nothing to change; Mia starts the +// simulation and it runs for all, named after her; Ben's ping in a command +// line is in Mia's too (one window per computer, as in Java); Rita's typing +// there is refused, and she is told; the project frozen, Ben reads and is +// told, thawed he writes again; his grant ended, he is told and the editor +// goes. + +import { expect, test, type Browser, type Page } from '@playwright/test'; +import { drag, onDevice, serveEditor } from './editorPage.js'; +import { startServerF, type ServerF } from './serverF.js'; + +let editor: { readonly base: string; readonly close: () => Promise }; +let f: ServerF; + +test.beforeAll(async () => { + editor = await serveEditor(); + f = await startServerF(editor.base); +}); + +test.afterAll(async () => { + await f?.close(); + await editor?.close(); +}); + +/** A person in their own browser context, signed in, the project open. */ +async function person(browser: Browser, project: string, name: string, level: 'read' | 'write' = 'write', user?: string): Promise { + const context = await browser.newContext(); + await f.signIn(context, project, { name, level, ...(user === undefined ? {} : { user }) }); + const page = await context.newPage(); + await page.goto(`${editor.base}?project=${project}&server=${encodeURIComponent(f.base)}`); + await page.waitForSelector('svg.filius-canvas'); + return page; +} + +const box = (page: Page, id: string) => page.locator(`g.device[data-node="${id}"]`).getAttribute('data-box'); +const canvas = (page: Page) => page.locator('svg.filius-canvas'); +const desktop = (page: Page, id: string) => page.locator(`section[data-desktop="${id}"]`); +const note = (page: Page, kind: string) => page.locator(`[data-note="${kind}"]`); + +async function commandLine(page: Page, id: string) { + const at = await onDevice(page, id); + await page.mouse.click(at.x, at.y); + await desktop(page, id).locator('[data-program="commandPrompt"]').click(); + const window = desktop(page, id).locator('[data-program-window="commandPrompt"]'); + return { input: window.locator('input[name="terminalInput"]'), text: window.locator('.terminal-text') }; +} + +test('three people in one project of F: moving, reading only, one simulation for all, a frozen project, an ended grant', async ({ browser }) => { + test.setTimeout(120_000); + const project = await f.project('java-examples/internetwork_komplett.fls'); + const benUser = crypto.randomUUID(); + const mia = await person(browser, project.id, 'Mia'); + const ben = await person(browser, project.id, 'Ben', 'write', benUser); + const rita = await person(browser, project.id, 'Rita', 'read'); + const devices = await mia.locator('g.device').count(); + expect(devices).toBeGreaterThan(5); + for (const page of [ben, rita]) await expect(page.locator('g.device')).toHaveCount(devices); + + // Mia moves a device; Ben and Rita see it where she put it. + const notebook = project.node('Notebook 1.10'); + const before = await box(mia, notebook); + const from = await onDevice(mia, notebook); + await drag(mia, from, { x: from.x + 120, y: from.y + 40 }); + const moved = await box(mia, notebook); + expect(moved).not.toBe(before); + for (const page of [ben, rita]) await expect(page.locator(`g.device[data-node="${notebook}"]`)).toHaveAttribute('data-box', moved!); + + // Rita reads: no palette, no undo, the note; her drag moves nothing; her sheet's fields are disabled; she cannot start. + await expect(note(rita, 'readOnly')).toHaveText('Nur ansehen'); + await expect(rita.locator('[data-palette]')).toHaveCount(0); + await expect(rita.getByRole('button', { name: 'Rückgängig' })).toHaveCount(0); + await expect(rita.locator('button[name="simulationMode"]')).toBeDisabled(); + const ritaFrom = await onDevice(rita, notebook); + await drag(rita, ritaFrom, { x: ritaFrom.x + 80, y: ritaFrom.y }); + await expect(rita.locator(`g.device[data-node="${notebook}"]`)).toHaveAttribute('data-box', moved!); + await rita.mouse.click(ritaFrom.x, ritaFrom.y); + const sheet = rita.getByRole('complementary', { name: 'Einstellungen' }); + await expect(sheet).toBeVisible(); + await expect(sheet.locator('input[name="ipAddress"]')).toBeDisabled(); + await sheet.getByRole('button', { name: 'Schließen' }).click(); + await expect(note(mia, 'readOnly')).toHaveCount(0); + + // Mia starts: the simulation runs for all, named after her. + await mia.locator('button[name="simulationMode"]').click(); + for (const page of [mia, ben, rita]) { + await expect(canvas(page)).toHaveAttribute('data-mode', 'simulation'); + await expect(note(page, 'startedBy')).toHaveText('Simulation gestartet von Mia'); + } + + // Ben pings from the notebook's command line; Mia's command line of it shows the same. + const bens = await commandLine(ben, notebook); + await bens.input.fill('ping 141.99.5.10'); + await bens.input.press('Enter'); + const mias = await commandLine(mia, notebook); + for (const each of [bens, mias]) await expect(each.text).toContainText('4 Paket(e) gesendet, 4 Paket(e) empfangen', { timeout: 30_000 }); + // Rita may look, not type. + const ritas = await commandLine(rita, notebook); + await expect(ritas.text).toContainText('ping 141.99.5.10'); + await ritas.input.fill('ipconfig'); + await ritas.input.press('Enter'); + await expect(rita.getByText('Hier können Sie nur zuschauen.')).toBeVisible(); + + // Ben stops it, for everyone. + await ben.locator('button[name="designMode"]').click(); + for (const page of [mia, ben, rita]) await expect(canvas(page)).toHaveAttribute('data-mode', 'design'); + + // Frozen: Ben reads and is told; thawed, he writes again. + expect((await f.service('POST', `/svc/projects/${project.id}/freeze`, { frozen: true })).status).toBe(200); + await expect(note(ben, 'readOnly')).toHaveText('Eingefroren – nur ansehen'); + await expect(ben.locator('[data-palette]')).toHaveCount(0); + expect((await f.service('POST', `/svc/projects/${project.id}/freeze`, { frozen: false })).status).toBe(200); + await expect(note(ben, 'readOnly')).toHaveCount(0); + await expect(ben.locator('[data-palette]').first()).toBeVisible(); + const benFrom = await onDevice(ben, notebook); + await drag(ben, benFrom, { x: benFrom.x - 60, y: benFrom.y }); + const movedByBen = await box(ben, notebook); + expect(movedByBen).not.toBe(moved); + await expect(mia.locator(`g.device[data-node="${notebook}"]`)).toHaveAttribute('data-box', movedByBen!); + + // Ben's grant ends: he is told, and the editor goes. + expect((await f.service('POST', '/svc/revoke', { user: benUser, project: project.id })).status).toBe(200); + await expect(ben.getByRole('alert')).toHaveText('Ihr Zugang zu diesem Projekt wurde beendet.'); + await expect(canvas(ben)).toHaveCount(0); + await expect(canvas(mia)).toBeVisible(); +}); + +test('says why when F ends only the room: the editor goes as when the document is let go', async ({ browser }) => { + const project = await f.project('java-examples/zwei_rechnernetze_komplett.fls'); + const context = await browser.newContext(); + await f.signIn(context, project.id, { name: 'Mia' }); + const page = await context.newPage(); + await page.routeWebSocket(/\/sim/, (socket) => socket.close({ code: 4403, reason: 'noAccess' })); + await page.goto(`${editor.base}?project=${project.id}&server=${encodeURIComponent(f.base)}`); + await expect(page.getByRole('alert')).toHaveText('Sie haben keinen Zugang zu diesem Projekt.'); + await context.close(); +}); + +test('says why when F refuses: no session, a project it does not know', async ({ page }) => { + const project = await f.project('java-examples/zwei_rechnernetze_komplett.fls'); + await page.goto(`${editor.base}?project=${project.id}&server=${encodeURIComponent(f.base)}`); + await expect(page.getByRole('alert')).toHaveText('Die Anmeldung ist abgelaufen. Öffnen Sie das Projekt im Dashboard neu.'); + await page.goto(`${editor.base}?project=not-a-project&server=${encodeURIComponent(f.base)}`); + await expect(page.getByRole('alert')).toHaveText('Diese Adresse nennt kein Projekt.'); +}); diff --git a/packages/editor/package.json b/packages/editor/package.json index 26962d3..06d3124 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -9,6 +9,7 @@ "build": "vite build" }, "dependencies": { + "@hocuspocus/provider": "3.4.4", "@mdg-filius/core": "0.0.0", "@mdg-filius/doc": "0.0.0", "@mdg-filius/fls": "0.0.0", diff --git a/packages/editor/src/Editor.tsx b/packages/editor/src/Editor.tsx index d9ff6d0..5e9c532 100644 --- a/packages/editor/src/Editor.tsx +++ b/packages/editor/src/Editor.tsx @@ -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(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 (
{simulating ? null : documenting ? ( - - ) : ( + + ) : readOnly ? null : ( { @@ -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) { }} />
- - {simulating ? : ( + + {simulating && simulation.startedBy !== undefined ? ( + + {t('access.startedBy', { name: simulation.startedBy })} + + ) : null} + {readOnly ? ( + + {t(frozen ? 'access.frozen' : 'access.readOnly')} + + ) : null} + {simulating ? (readOnly ? null : ) : readOnly ? null : ( <>
diff --git a/packages/editor/src/canvas/Canvas.tsx b/packages/editor/src/canvas/Canvas.tsx index 0281a7c..c652b4a 100644 --- a/packages/editor/src/canvas/Canvas.tsx +++ b/packages/editor/src/canvas/Canvas.tsx @@ -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(function Canvas( onMenu, highlightedLink = null, mode = 'design', + locked = false, activeLinks = NO_LINKS, onActivate, onDocFrame, @@ -364,6 +371,10 @@ export const Canvas = forwardRef(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(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(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(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(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 : ( - - {mode === 'simulation' ? (