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
+5
View File
@@ -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 | | `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/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/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 ## 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/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/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/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/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/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) | | `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) |
+1 -1
View File
@@ -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/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/protocol` | die eine Nachrichtenliste zwischen Editor und Simulationsraum (`/sim`) |
| `packages/sim` | Simulationsraum (für Server F und den Web Worker) und `LocalSimHost` | | `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=<uuid>`) 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` | | `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. Plan und Entscheidungen: `knowledge/plans/FILIUS_WEB_PLAN.md` im Dashboard.
+1 -2
View File
@@ -764,7 +764,6 @@
"version": "3.4.4", "version": "3.4.4",
"resolved": "https://registry.npmjs.org/@hocuspocus/provider/-/provider-3.4.4.tgz", "resolved": "https://registry.npmjs.org/@hocuspocus/provider/-/provider-3.4.4.tgz",
"integrity": "sha512-KbsMAfdYcIJD8eMU/5QnpXcSOvIWAcCNI33FSRSaKCIpYBFtAwkYIwWnZJmPZ8a1BMAtqQc+uvy9+UQf7GHnGQ==", "integrity": "sha512-KbsMAfdYcIJD8eMU/5QnpXcSOvIWAcCNI33FSRSaKCIpYBFtAwkYIwWnZJmPZ8a1BMAtqQc+uvy9+UQf7GHnGQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@hocuspocus/common": "^3.4.4", "@hocuspocus/common": "^3.4.4",
@@ -849,7 +848,6 @@
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/@lifeomic/attempt/-/attempt-3.1.0.tgz", "resolved": "https://registry.npmjs.org/@lifeomic/attempt/-/attempt-3.1.0.tgz",
"integrity": "sha512-QZqem4QuAnAyzfz+Gj5/+SLxqwCAw2qmt7732ZXodr6VDWGeYLG6w1i/vYLa55JQM9wRuBKLmXmiZ2P0LtE5rw==", "integrity": "sha512-QZqem4QuAnAyzfz+Gj5/+SLxqwCAw2qmt7732ZXodr6VDWGeYLG6w1i/vYLa55JQM9wRuBKLmXmiZ2P0LtE5rw==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@mdg-filius/core": { "node_modules/@mdg-filius/core": {
@@ -2853,6 +2851,7 @@
"version": "0.0.0", "version": "0.0.0",
"license": "GPL-3.0-only", "license": "GPL-3.0-only",
"dependencies": { "dependencies": {
"@hocuspocus/provider": "3.4.4",
"@mdg-filius/core": "0.0.0", "@mdg-filius/core": "0.0.0",
"@mdg-filius/doc": "0.0.0", "@mdg-filius/doc": "0.0.0",
"@mdg-filius/fls": "0.0.0", "@mdg-filius/fls": "0.0.0",
+100
View File
@@ -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<void>;
service(method: string, path: string, body?: unknown): Promise<Response>;
close(): Promise<void>;
}
/** Server F on the editor page's host (`editorBase`), for pages from there. */
export async function startServerF(editorBase: string): Promise<ServerF> {
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<void>((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();
},
};
}
+146
View File
@@ -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<void> };
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<Page> {
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.');
});
+1
View File
@@ -9,6 +9,7 @@
"build": "vite build" "build": "vite build"
}, },
"dependencies": { "dependencies": {
"@hocuspocus/provider": "3.4.4",
"@mdg-filius/core": "0.0.0", "@mdg-filius/core": "0.0.0",
"@mdg-filius/doc": "0.0.0", "@mdg-filius/doc": "0.0.0",
"@mdg-filius/fls": "0.0.0", "@mdg-filius/fls": "0.0.0",
+36 -8
View File
@@ -13,6 +13,12 @@
// Every change goes through packages/doc's commands with this editor's own // 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 // origin, so that its undo takes back what this person did and nothing of
// the others in the room. // 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 { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
import type * as Y from 'yjs'; 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 { RouterDialog } from './design/RouterDialog.js';
import { DeviceSheet } from './sheet/DeviceSheet.js'; import { DeviceSheet } from './sheet/DeviceSheet.js';
import { DesktopWindow, type DesktopWindowName } from './desktop/DesktopWindow.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 { CaptureWindow } from './simulation/CaptureWindow.js';
import { captureWords, reportTraffic } from './simulation/captureCells.js'; import { captureWords, reportTraffic } from './simulation/captureCells.js';
import { ModeBar, type EditorMode } from './simulation/ModeBar.js'; import { ModeBar, type EditorMode } from './simulation/ModeBar.js';
@@ -143,9 +149,13 @@ export interface EditorProps {
readonly createSimHost?: SimHostFactory; readonly createSimHost?: SimHostFactory;
/** The project's name (its file's), for what is exported. */ /** The project's name (its file's), for what is exported. */
readonly projectName?: string; 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 t = useTranslate();
const language = useLanguage(); const language = useLanguage();
const view = useTopologyView(doc); 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). // The cable of the card whose tab is open in the sheet (Java's Kabel.setAktiv).
const [highlightedLink, setHighlightedLink] = useState<string | null>(null); 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. // A message goes after a while.
useEffect(() => { useEffect(() => {
if (message === null) return undefined; 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. // The keyboard: undo and redo, delete what is selected, Escape lets go.
useEffect(() => { useEffect(() => {
const key = (event: KeyboardEvent) => { const key = (event: KeyboardEvent) => {
if (typing(event.target) || simulating) return; if (typing(event.target) || simulating || readOnly) return;
const command = event.ctrlKey || event.metaKey; const command = event.ctrlKey || event.metaKey;
if (command && event.key.toLowerCase() === 'z') { if (command && event.key.toLowerCase() === 'z') {
event.preventDefault(); event.preventDefault();
@@ -371,13 +386,13 @@ export function Editor({ doc, createSimHost, projectName }: EditorProps) {
}; };
window.addEventListener('keydown', key); window.addEventListener('keydown', key);
return () => window.removeEventListener('keydown', key); return () => window.removeEventListener('keydown', key);
}, [manager, selection, menu, routerAt, remove, simulating]); }, [manager, selection, menu, routerAt, remove, simulating, readOnly]);
return ( return (
<div style={{ display: 'flex', height: '100%' }}> <div style={{ display: 'flex', height: '100%' }}>
{simulating ? null : documenting ? ( {simulating ? null : documenting ? (
<DocumentationSidebar onItem={onDocumentationItem} onExport={exportImage} onReport={makeReport} /> <DocumentationSidebar onItem={onDocumentationItem} onExport={exportImage} onReport={makeReport} readOnly={readOnly} />
) : ( ) : readOnly ? null : (
<Palette <Palette
cableTool={cableTool} cableTool={cableTool}
onCableTool={(on) => { onCableTool={(on) => {
@@ -402,6 +417,7 @@ export function Editor({ doc, createSimHost, projectName }: EditorProps) {
onCable={onCable} onCable={onCable}
highlightedLink={highlightedLink} highlightedLink={highlightedLink}
mode={editorMode} mode={editorMode}
locked={readOnly}
onDocFrame={(id, frame) => setDocumentationFrame(doc, id, frame, origin)} onDocFrame={(id, frame) => setDocumentationFrame(doc, id, frame, origin)}
renderDocText={(item, frame) => { renderDocText={(item, frame) => {
const text = documentationText(doc, item.id); const text = documentationText(doc, item.id);
@@ -426,6 +442,7 @@ export function Editor({ doc, createSimHost, projectName }: EditorProps) {
else if (kind === 'gateway') openDesktop(id, 'nat'); else if (kind === 'gateway') openDesktop(id, 'nat');
}} }}
onMenu={(target, at) => { onMenu={(target, at) => {
if (readOnly && !simulating) return;
if (editorMode === 'documentation') { if (editorMode === 'documentation') {
if (target.kind !== 'doc') return; if (target.kind !== 'doc') return;
setSelection(target); setSelection(target);
@@ -441,8 +458,18 @@ export function Editor({ doc, createSimHost, projectName }: EditorProps) {
}} }}
/> />
<div role="toolbar" className="editor-toolbar"> <div role="toolbar" className="editor-toolbar">
<ModeBar mode={editorMode} speed={simulation.speed} onMode={changeMode} onSpeed={simulation.setSpeed} /> <ModeBar mode={editorMode} speed={simulation.speed} onMode={changeMode} onSpeed={simulation.setSpeed} canRun={!readOnly} />
{simulating ? <DropControl simulation={simulation} /> : ( {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')}> <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} onMessage={setMessage}
onHighlight={setHighlightedLink} onHighlight={setHighlightedLink}
onClose={() => setSelection(null)} onClose={() => setSelection(null)}
readOnly={readOnly}
/> />
)} )}
</div> </div>
+19 -4
View File
@@ -81,6 +81,12 @@ export interface CanvasProps {
* Java's simulation mode. The documentation mode: see above. * Java's simulation mode. The documentation mode: see above.
*/ */
readonly mode?: 'design' | 'simulation' | 'documentation'; 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. */ /** A documentation item moved or resized: its new frame. */
readonly onDocFrame?: (id: string, frame: Frame) => void; readonly onDocFrame?: (id: string, frame: Frame) => void;
/** The field a focused text is typed into, over the surface at its frame. */ /** 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, onMenu,
highlightedLink = null, highlightedLink = null,
mode = 'design', mode = 'design',
locked = false,
activeLinks = NO_LINKS, activeLinks = NO_LINKS,
onActivate, onActivate,
onDocFrame, onDocFrame,
@@ -364,6 +371,10 @@ export const Canvas = forwardRef<CanvasHandle, CanvasProps>(function Canvas(
if (pointers.current.size > 2) return; if (pointers.current.size > 2) return;
const target = event.target as Element; const target = event.target as Element;
const linkId = target.closest('[data-link-hit]')?.getAttribute('data-link-hit'); 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) { if (documenting) {
const found = docAt(point); const found = docAt(point);
if (found === null) { 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 }; 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(() => { longPress.current = setTimeout(() => {
const now = gesture.current; const now = gesture.current;
if (now?.kind !== 'device' || now.moved) return; if (now?.kind !== 'device' || now.moved || (locked && !simulating)) return;
now.longPressed = true; now.longPressed = true;
onMenu?.({ kind: 'device', id: now.id }, now.start); onMenu?.({ kind: 'device', id: now.id }, now.start);
}, LONG_PRESS_MILLISECONDS); }, 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 }; gesture.current = { kind: 'link', id: linkId, start: point, moved: false, longPressed: false };
longPress.current = setTimeout(() => { longPress.current = setTimeout(() => {
const now = gesture.current; 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 }; const step = { x: point.x - now.last.x, y: point.y - now.last.y };
now.last = point; now.last = point;
setViewport((before) => shifted(before ?? currentRef.current, step)); 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') { if (tool === 'cable') {
const from = boxes.get(now.id)!.center; const from = boxes.get(now.id)!.center;
const target = deviceAt(point); const target = deviceAt(point);
@@ -501,6 +512,10 @@ export const Canvas = forwardRef<CanvasHandle, CanvasProps>(function Canvas(
if (!now.moved) onActivate?.(now.id); if (!now.moved) onActivate?.(now.id);
return; return;
} }
if (locked) {
if (!now.moved) onSelect?.({ kind: 'device', id: now.id });
return;
}
if (tool === 'cable') { if (tool === 'cable') {
setBand(null); setBand(null);
const from = { id: now.id, side: now.side }; 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 }); 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 }); 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 <button
type="button" type="button"
className="device-menu-button" className="device-menu-button"
@@ -22,6 +22,8 @@ export interface DocumentationSidebarProps {
readonly onExport: () => Promise<void>; readonly onExport: () => Promise<void>;
/** „Report“: the report saved as a PDF; rejected, it failed. */ /** „Report“: the report saved as a PDF; rejected, it failed. */
readonly onReport: () => Promise<void>; 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 }[] = [ const ITEMS: readonly { readonly kind: DocumentationItemKind; readonly icon: string; readonly label: string }[] = [
@@ -48,7 +50,7 @@ const item: CSSProperties = {
touchAction: 'none', touchAction: 'none',
}; };
export function DocumentationSidebar({ onItem, onExport, onReport }: DocumentationSidebarProps) { export function DocumentationSidebar({ onItem, onExport, onReport, readOnly = false }: DocumentationSidebarProps) {
const t = useTranslate(); const t = useTranslate();
const [ghost, setGhost] = useState<{ kind: DocumentationItemKind; x: number; y: number } | null>(null); 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); 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, flexShrink: 0,
}} }}
> >
{ITEMS.map((each) => ( {(readOnly ? [] : ITEMS).map((each) => (
<button <button
key={each.kind} key={each.kind}
type="button" 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.address": "Adresse",
"browser.errorText": "Es ist ein Fehler aufgetreten (HTTP-Status: {code})", "browser.errorText": "Es ist ein Fehler aufgetreten (HTTP-Status: {code})",
"browser.errorTitle": "Fehler {message} ({code})", "browser.errorTitle": "Fehler {message} ({code})",
@@ -352,6 +357,14 @@
"program.textEditor": "Text-Editor", "program.textEditor": "Text-Editor",
"program.webBrowser": "Webbrowser", "program.webBrowser": "Webbrowser",
"program.webServer": "Webserver", "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.apps": "Anwendungen",
"report.baseConfig": "Basiskonfiguration", "report.baseConfig": "Basiskonfiguration",
"report.components": "Komponenten", "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.address": "Address",
"browser.errorText": "An error occurred (HTTP-Status: {code})", "browser.errorText": "An error occurred (HTTP-Status: {code})",
"browser.errorTitle": "Error {message} ({code})", "browser.errorTitle": "Error {message} ({code})",
@@ -352,6 +357,14 @@
"program.textEditor": "Text editor", "program.textEditor": "Text editor",
"program.webBrowser": "Webbrowser", "program.webBrowser": "Webbrowser",
"program.webServer": "Webserver", "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.apps": "Applications",
"report.baseConfig": "Base Configuration", "report.baseConfig": "Base Configuration",
"report.components": "Components", "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 // The editor's page. On a project of server F (`?project=<uuid>`, and
// language over the bridge; on its own — for development and the browser // `&server=<address>` when F is not where the page comes from) it opens the
// tests — it opens one of FILIUS' examples, an empty project or a file // shared document and the room there (SharedProject); embedded in the
// (`?example=<name>` opens that example at once, `?example=new` an empty // dashboard (F6) it gets its language and theme over the bridge. On its own
// project, `?lang=en` and `?theme=dark` set the rest). // — 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 { StrictMode, useEffect, useState } from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
@@ -11,6 +14,7 @@ import { changeFiles, desktopView, deviceSheet, emptyProject, fileText, topology
import { openProjectArchive } from '@mdg-filius/fls'; import { openProjectArchive } from '@mdg-filius/fls';
import { startBridge } from './bridge/dashboardBridge.js'; import { startBridge } from './bridge/dashboardBridge.js';
import { Editor } from './Editor.js'; import { Editor } from './Editor.js';
import { SharedProject } from './remote/SharedProject.js';
import { LanguageProvider, translate, type Language } from './i18n/i18n.js'; import { LanguageProvider, translate, type Language } from './i18n/i18n.js';
import './theme/tokens.css'; import './theme/tokens.css';
@@ -32,6 +36,7 @@ function App() {
/** The open file's name, for what the editor exports. */ /** The open file's name, for what the editor exports. */
const [fileName, setFileName] = useState<string | undefined>(undefined); const [fileName, setFileName] = useState<string | undefined>(undefined);
const [example, setExample] = useState(params.get('example') ?? ''); const [example, setExample] = useState(params.get('example') ?? '');
const project = params.get('project');
useEffect(() => { useEffect(() => {
if (params.get('theme') === 'dark') document.documentElement.dataset.theme = 'dark'; if (params.get('theme') === 'dark') document.documentElement.dataset.theme = 'dark';
@@ -81,6 +86,14 @@ function App() {
}, [doc]); }, [doc]);
const t = (key: string) => translate(language, key); 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 ( return (
<LanguageProvider language={language}> <LanguageProvider language={language}>
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}> <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 { FirewallPanel } from './FirewallPanel.js';
import { DhcpServerPanel, PortForwardingPanel } from './ServicePanels.js'; import { DhcpServerPanel, PortForwardingPanel } from './ServicePanels.js';
import { SheetCheck, SheetField } from './SheetField.js'; import { SheetCheck, SheetField } from './SheetField.js';
import { SheetLock, SheetLocked } from './SheetLock.js';
import './sheet.css'; import './sheet.css';
/** The device's sheet, read anew after every change of the document. */ /** The device's sheet, read anew after every change of the document. */
@@ -60,6 +61,8 @@ export interface DeviceSheetProps {
readonly onClose: () => void; readonly onClose: () => void;
/** The cable to draw active (a router's card whose tab is open), or none. */ /** The cable to draw active (a router's card whose tab is open), or none. */
readonly onHighlight: (link: string | null) => void; 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. */ /** 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' }; 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 t = useTranslate();
const sheet = useDeviceSheet(doc, id); const sheet = useDeviceSheet(doc, id);
// The page open, if any; the editor makes a sheet per device, so another device starts at its fields. // 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}`); const title = t(`device.${sheet.kind}`);
return ( return (
<SheetLocked.Provider value={readOnly}>
<aside className="device-sheet" aria-label={t('sheet.title')} data-device={id}> <aside className="device-sheet" aria-label={t('sheet.title')} data-device={id}>
<header> <header>
{open === null ? ( {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. */} {/* 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}> <div key={id} hidden={open !== null}>
{sheet.kind === 'pc' || sheet.kind === 'notebook' ? ( {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' ? ( ) : sheet.kind === 'networkSwitch' ? (
<SwitchFields sheet={sheet} change={change} /> <SheetLock>
<SwitchFields sheet={sheet} change={change} />
</SheetLock>
) : sheet.kind === 'remoteLink' ? ( ) : sheet.kind === 'remoteLink' ? (
<ModemFields sheet={sheet} change={change} /> <SheetLock>
<ModemFields sheet={sheet} change={change} />
</SheetLock>
) : sheet.kind === 'router' ? ( ) : sheet.kind === 'router' ? (
<RouterFields sheet={sheet} change={change} onHighlight={onHighlight} onOpen={onOpen} /> <RouterFields sheet={sheet} change={change} onHighlight={onHighlight} onOpen={onOpen} />
) : sheet.kind === 'gateway' ? ( ) : sheet.kind === 'gateway' ? (
<HomeRouterFields sheet={sheet} change={change} onHighlight={onHighlight} onOpen={onOpen} /> <HomeRouterFields sheet={sheet} change={change} onHighlight={onHighlight} onOpen={onOpen} />
) : ( ) : (
<NameField name={sheet.name} change={change} /> <SheetLock>
<NameField name={sheet.name} change={change} />
</SheetLock>
)} )}
</div> </div>
{open === 'dhcp' && (sheet.kind === 'pc' || sheet.kind === 'notebook' || sheet.kind === 'gateway') ? ( {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> </div>
) : open === 'connections' && sheet.kind === 'router' ? ( ) : open === 'connections' && sheet.kind === 'router' ? (
<div key={`${id}-connections`}> <div key={`${id}-connections`}>
<ConnectionsPanel sheet={sheet} change={change} /> <SheetLock>
<ConnectionsPanel sheet={sheet} change={change} />
</SheetLock>
</div> </div>
) : open === 'forwarding' && sheet.kind === 'gateway' ? ( ) : open === 'forwarding' && sheet.kind === 'gateway' ? (
<div key={`${id}-forwarding`}> <div key={`${id}-forwarding`}>
<PortForwardingPanel sheet={sheet} change={change} /> <SheetLock>
<PortForwardingPanel sheet={sheet} change={change} />
</SheetLock>
</div> </div>
) : null} ) : null}
</aside> </aside>
</SheetLocked.Provider>
); );
} }
+3 -2
View File
@@ -24,6 +24,7 @@ import { useTranslate } from '../i18n/i18n.js';
import type { Change } from './NameField.js'; import type { Change } from './NameField.js';
import { SheetCheck, SheetField, SheetNote } from './SheetField.js'; import { SheetCheck, SheetField, SheetNote } from './SheetField.js';
import { SheetTabs } from './SheetTabs.js'; import { SheetTabs } from './SheetTabs.js';
import { SheetPanel } from './SheetLock.js';
/** Java's protocol names in the rule table (ruleAsVector): "*" for all. */ /** 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' }; 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} selected={tab}
onSelect={setTab} onSelect={setTab}
/> />
<div role="tabpanel" key={tab}> <SheetPanel key={tab}>
{tab === 'general' ? ( {tab === 'general' ? (
<> <>
<SheetCheck name="firewallActive" label={t('firewall.active')} checked={firewall.isActive} onChange={flag('isActive')} /> <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 { RowsEditor } from './RowsEditor.js';
import { SheetTabs, type Tab } from './SheetTabs.js'; import { SheetTabs, type Tab } from './SheetTabs.js';
import { SheetCheck, SheetField } from './SheetField.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. */ /** 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 { 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} /> <SheetTabs tabs={tabs} selected={shown} onSelect={setTab} />
{/* A panel per tab: a text typed on one card is not offered to the next. */} {/* 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' ? ( {shown === 'general' ? (
<> <>
<NameField name={sheet.name} change={change} /> <NameField name={sheet.name} change={change} />
@@ -115,7 +116,7 @@ export function RouterFields({
) : ( ) : (
<RouteTable sheet={sheet} change={change} /> <RouteTable sheet={sheet} change={change} />
)} )}
</div> </SheetPanel>
</> </>
); );
} }
@@ -144,7 +145,7 @@ export function HomeRouterFields({
return ( return (
<> <>
<SheetTabs tabs={tabs} selected={tab} onSelect={setTab} /> <SheetTabs tabs={tabs} selected={tab} onSelect={setTab} />
<div role="tabpanel" key={tab}> <SheetPanel key={tab}>
{tab === 'general' ? ( {tab === 'general' ? (
<> <>
<NameField name={sheet.name} change={change} /> <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))} /> <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 { RowsEditor, type RowsColumn } from './RowsEditor.js';
import { SheetCheck, SheetField } from './SheetField.js'; import { SheetCheck, SheetField } from './SheetField.js';
import { SheetTabs } from './SheetTabs.js'; import { SheetTabs } from './SheetTabs.js';
import { SheetPanel } from './SheetLock.js';
export function DhcpServerPanel({ server, change }: { readonly server: DhcpServerSheet; readonly change: Change }) { export function DhcpServerPanel({ server, change }: { readonly server: DhcpServerSheet; readonly change: Change }) {
const t = useTranslate(); const t = useTranslate();
@@ -44,7 +45,7 @@ export function DhcpServerPanel({ server, change }: { readonly server: DhcpServe
selected={tab} selected={tab}
onSelect={setTab} onSelect={setTab}
/> />
<div role="tabpanel" key={tab}> <SheetPanel key={tab}>
{tab === 'basic' ? ( {tab === 'basic' ? (
<> <>
<SheetField name="lowerBound" label={t('dhcp.lowerBound')} value={server.lowerBoundIPAddress} rule="ipAddress" inputMode="decimal" onCommit={address('lowerBoundIPAddress')} /> <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))} 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; 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 { .editor-area {
position: relative; position: relative;
flex: 1; flex: 1;
@@ -336,3 +348,18 @@
color: var(--text-secondary); 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 // 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 // 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. // a browser they bookmark and reload the page.
import type { CSSProperties } from 'react'; 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. */ /** 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 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(); const t = useTranslate();
return ( return (
<> <>
@@ -36,19 +50,20 @@ export function ModeBar({ mode, speed, onMode, onSpeed }: { readonly mode: Edito
aria-pressed={mode === 'documentation'} aria-pressed={mode === 'documentation'}
aria-label={t('mode.documentation')} aria-label={t('mode.documentation')}
title={t('mode.documentation')} title={t('mode.documentation')}
disabled={!canRun && mode === 'simulation'}
onClick={() => onMode('documentation')} onClick={() => onMode('documentation')}
> >
<img src={mode === 'documentation' ? documentationOn : documentation} alt="" width={50} height={50} /> <img src={mode === 'documentation' ? documentationOn : documentation} alt="" width={50} height={50} />
</button> </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} /> <img src={mode === 'design' ? designOn : design} alt="" width={50} height={50} />
</button> </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} /> <img src={mode === 'simulation' ? simulationOn : simulation} alt="" width={50} height={50} />
</button> </button>
{mode === 'simulation' ? ( {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)' }}> <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' }}> <span title={t('mode.currentSpeed')} style={{ minWidth: 40, fontVariantNumeric: 'tabular-nums' }}>
{speed}% {speed}%
</span> </span>
@@ -2,8 +2,9 @@
// the cables that carry frames, a computer's network as it runs, the lines // 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 // and views of the programs' windows and the actions taken in them, the
// cards' frames for the data exchange window. The room // cards' frames for the data exchange window. The room
// runs behind a SimHost — in a worker of this page (LocalSimHost) until F // runs behind a SimHost — on F for everyone in the project (RemoteSimHost),
// holds it (RemoteSimHost, F5); the editor keeps no simulation of its own. // 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 { useCallback, useEffect, useRef, useState } from 'react';
import type * as Y from 'yjs'; import type * as Y from 'yjs';
@@ -41,6 +42,10 @@ export interface Simulation {
readonly views: ReadonlyMap<string, unknown>; readonly views: ReadonlyMap<string, unknown>;
/** Whether the core failed and the room went back to design mode, since the last start. */ /** Whether the core failed and the room went back to design mode, since the last start. */
readonly failed: boolean; 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. */ /** 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; readonly clock: number;
/** The frames "Pakete verwerfen" dropped in this simulation, while `dropped` is watched. */ /** 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 [activeLinks, setActiveLinks] = useState<ReadonlySet<string>>(NO_LINKS);
const [nodes, setNodes] = useState<ReadonlyMap<string, NodeSettings>>(new Map()); const [nodes, setNodes] = useState<ReadonlyMap<string, NodeSettings>>(new Map());
const [failed, setFailed] = useState(false); 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 [apps, setApps] = useState<ReadonlyMap<string, AppLines>>(new Map());
const [views, setViews] = useState<ReadonlyMap<string, unknown>>(new Map()); const [views, setViews] = useState<ReadonlyMap<string, unknown>>(new Map());
const [clock, setClock] = useState(0); const [clock, setClock] = useState(0);
@@ -90,6 +97,7 @@ export function useSimulation(doc: Y.Doc, createHost: SimHostFactory = localSimH
setMode(message.mode); setMode(message.mode);
setSpeedState(message.speed); setSpeedState(message.speed);
setClock(message.clock); setClock(message.clock);
setStartedBy(message.startedBy);
if (message.mode === 'design') setActiveLinks(NO_LINKS); if (message.mode === 'design') setActiveLinks(NO_LINKS);
return; return;
case 'activity': case 'activity':
@@ -125,6 +133,10 @@ export function useSimulation(doc: Y.Doc, createHost: SimHostFactory = localSimH
return; return;
case 'error': case 'error':
if (message.code === 'engineFailed') setFailed(true); 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; return;
default: default:
return; 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() }); 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 };
} }
+166
View File
@@ -0,0 +1,166 @@
// The room on F as the editor reaches it (RemoteSimHost), on sockets made
// by the test and a clock it moves: what is sent before the socket opens
// goes once it does; the room's messages are read as LocalSimHost reads
// them; a lost socket comes back after a wait that doubles (to 10 s at
// most) and watches again what it watched; F's refusal and parting end it
// for good and are told; closing it ends it without a word.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { SIM_CLOSED_CODE, SIM_REFUSED_CODE, SIM_ROOM_FAILED_CODE, type HostMessage } from '@mdg-filius/protocol';
import { RECONNECT_FIRST_MILLISECONDS, RECONNECT_MAXIMUM_MILLISECONDS, RemoteSimHost, simUrl, type RoomEnd, type SocketLike } from '../src/remote/remoteSimHost.js';
class FakeSocket implements SocketLike {
readyState = 0;
readonly sent: unknown[] = [];
closedWith: [number | undefined, string | undefined] | undefined;
onopen: (() => void) | null = null;
onmessage: ((event: { readonly data: unknown }) => void) | null = null;
onclose: ((event: { readonly code: number; readonly reason: string }) => void) | null = null;
constructor(readonly url: string) {}
send(data: string): void {
this.sent.push(JSON.parse(data));
}
close(code?: number, reason?: string): void {
this.closedWith = [code, reason];
this.readyState = 3;
}
opened(): void {
this.readyState = 1;
this.onopen?.();
}
hear(data: unknown): void {
this.onmessage?.({ data: typeof data === 'string' ? data : JSON.stringify(data) });
}
lost(code: number, reason = ''): void {
this.readyState = 3;
this.onclose?.({ code, reason });
}
}
let sockets: FakeSocket[];
const host = () =>
new RemoteSimHost('ws://f.test/sim?project=p', (url) => {
const socket = new FakeSocket(url);
sockets.push(socket);
return socket;
});
beforeEach(() => {
sockets = [];
vi.useFakeTimers();
});
afterEach(() => vi.useRealTimers());
describe('the room on F', () => {
it('sends what came before the socket opened once it does, and then at once', () => {
const room = host();
room.send({ v: 1, kind: 'sub', topics: ['activity'] });
room.send({ v: 1, kind: 'start' });
expect(sockets[0]!.sent).toEqual([]);
sockets[0]!.opened();
room.send({ v: 1, kind: 'stop' });
expect(sockets[0]!.sent).toEqual([
{ v: 1, kind: 'sub', topics: ['activity'] },
{ v: 1, kind: 'start' },
{ v: 1, kind: 'stop' },
]);
expect(sockets[0]!.url).toBe('ws://f.test/sim?project=p');
});
it("reads the room's messages, and names what it cannot read", () => {
const room = host();
const got: HostMessage[] = [];
const stop = room.onMessage((message) => got.push(message));
sockets[0]!.opened();
sockets[0]!.hear({ v: 1, kind: 'state', mode: 'design', speed: 90, t: 0, clock: 5 });
sockets[0]!.hear('not json');
sockets[0]!.hear({ v: 1, kind: 'nonsense' });
stop();
sockets[0]!.hear({ v: 1, kind: 'activity', links: [] });
expect(got).toEqual([
{ v: 1, kind: 'state', mode: 'design', speed: 90, t: 0, clock: 5 },
{ v: 1, kind: 'error', code: 'badMessage', detail: 'not an object' },
{ v: 1, kind: 'error', code: 'unknownKind', detail: 'kind nonsense' },
]);
});
it('comes back after a loss, waiting longer each time, and watches again what it watched', () => {
const room = host();
room.send({ v: 1, kind: 'sub', topics: ['activity', 'node:a'] });
sockets[0]!.opened();
room.send({ v: 1, kind: 'unsub', topics: ['node:a'] });
sockets[0]!.lost(1006);
// Sent while it is away: kept for the room that comes.
room.send({ v: 1, kind: 'sub', topics: ['trace:p1'] });
room.send({ v: 1, kind: 'speed', value: 50 });
vi.advanceTimersByTime(RECONNECT_FIRST_MILLISECONDS - 1);
expect(sockets).toHaveLength(1);
vi.advanceTimersByTime(1);
expect(sockets).toHaveLength(2);
sockets[1]!.opened();
expect(sockets[1]!.sent).toEqual([
{ v: 1, kind: 'sub', topics: ['activity', 'trace:p1'] },
{ v: 1, kind: 'speed', value: 50 },
]);
// Lost again before it opened, again and again: 1 s, 2 s, … up to 10 s; an open socket begins anew.
const waits: number[] = [];
for (let index = 1; index <= 6; index++) {
const before = sockets.length;
sockets.at(-1)!.lost(index % 2 === 0 ? SIM_ROOM_FAILED_CODE : 1001);
let waited = 0;
while (sockets.length === before) {
vi.advanceTimersByTime(100);
waited += 100;
}
waits.push(waited);
}
expect(waits).toEqual([RECONNECT_FIRST_MILLISECONDS, 1_000, 2_000, 4_000, 8_000, RECONNECT_MAXIMUM_MILLISECONDS]);
sockets.at(-1)!.opened();
sockets.at(-1)!.lost(1006);
vi.advanceTimersByTime(RECONNECT_FIRST_MILLISECONDS);
expect(sockets.at(-1)!.readyState).toBe(0);
});
it('ends for good when F refuses or lets go, and says why', () => {
for (const [code, end] of [
[SIM_REFUSED_CODE, { kind: 'refused', reason: 'noAccess' }],
[SIM_CLOSED_CODE, { kind: 'closed', reason: 'revoked' }],
] as const) {
sockets = [];
const room = host();
const ends: RoomEnd[] = [];
room.onEnd((each) => ends.push(each));
sockets[0]!.opened();
sockets[0]!.lost(code, end.reason);
vi.advanceTimersByTime(RECONNECT_MAXIMUM_MILLISECONDS * 2);
expect(sockets).toHaveLength(1);
expect(ends).toEqual([end]);
room.send({ v: 1, kind: 'start' });
expect(sockets[0]!.sent).toEqual([]);
}
});
it('ends without a word when it is closed, and does not come back', () => {
const room = host();
const ends: RoomEnd[] = [];
const stop = room.onEnd((each) => ends.push(each));
sockets[0]!.opened();
room.close();
expect(sockets[0]!.closedWith).toEqual([undefined, undefined]);
sockets[0]!.lost(1000);
vi.advanceTimersByTime(RECONNECT_MAXIMUM_MILLISECONDS * 2);
expect([sockets.length, ends]).toEqual([1, []]);
stop();
// Closed while it waits to come back: it does not.
const other = host();
sockets.at(-1)!.lost(1006);
other.close();
vi.advanceTimersByTime(RECONNECT_MAXIMUM_MILLISECONDS * 2);
expect(sockets).toHaveLength(2);
});
it('makes the address of a room from the address of F', () => {
expect(simUrl('https://filius.hub.example/', 'abc')).toBe('wss://filius.hub.example/sim?project=abc');
expect(simUrl('http://127.0.0.1:8080', 'abc')).toBe('ws://127.0.0.1:8080/sim?project=abc');
});
});
+16 -4
View File
@@ -1,7 +1,8 @@
// The shared documents (/sync, plan §5): Hocuspocus, a project a document. // The shared documents (/sync, plan §5): Hocuspocus, a project a document.
// Who comes in is decided by the session the browser's cookie names (plan // Who comes in is decided by the session the browser's cookie names (plan
// §8.5): its grant to the project, read or write; a frozen project is read // §8.5): its grant to the project, read or write; a frozen project is read
// by everyone; a refusal says why (SYNC_REFUSALS). A project is loaded from // by everyone; a refusal says why (SYNC_REFUSALS); the access a connection
// has is told to it at once (and again when it changes). A project is loaded from
// the store when its first person comes (projects are made through the // the store when its first person comes (projects are made through the
// service), every // service), every
// update stored as it comes, compacted a minute after the last change, when // update stored as it comes, compacted a minute after the last change, when
@@ -34,6 +35,8 @@ export const SESSION_COOKIE = 'filius_session';
export interface ConnectionContext { export interface ConnectionContext {
readonly person: SessionPerson; readonly person: SessionPerson;
readonly level: Level; readonly level: Level;
/** Whether the project was frozen when the connection came. */
readonly frozen: boolean;
} }
export interface SyncOptions { export interface SyncOptions {
@@ -51,9 +54,14 @@ export interface Sync {
discard(project: string): Promise<void>; discard(project: string): Promise<void>;
} }
/** A refusal of a connection, with the reason the person's editor is told (Hocuspocus sends an error's `reason`). */ /**
* A refusal of a connection, with the reason the person's editor is told
* (Hocuspocus sends an error's `reason`). Without a message: Hocuspocus
* writes a failed hook's message onto the console, outside F's JSON log,
* which has the refusal already (`sync.refused`, `doc.unknown`).
*/
export function refusal(reason: SyncRefusal): Error & { readonly reason: SyncRefusal } { export function refusal(reason: SyncRefusal): Error & { readonly reason: SyncRefusal } {
return Object.assign(new Error(reason), { reason }); return Object.assign(new Error(), { reason });
} }
/** A cookie's value from a Cookie header. */ /** A cookie's value from a Cookie header. */
@@ -109,7 +117,11 @@ export function createSync({ store, sessions, log, compactAfterIdleMilliseconds
} }
connectionConfig.readOnly = access.level === 'read' || access.frozen; connectionConfig.readOnly = access.level === 'read' || access.frozen;
log.info('sync.joined', { project, user: access.person.login, readOnly: connectionConfig.readOnly }); log.info('sync.joined', { project, user: access.person.login, readOnly: connectionConfig.readOnly });
return { person: access.person, level: access.level }; return { person: access.person, level: access.level, frozen: access.frozen };
},
// Told at once, so that the editor knows from the start whether it reads, and why.
async connected({ connection, context }) {
connection.sendStateless(accessMessage(connection.readOnly, (context as ConnectionContext).frozen));
}, },
async onLoadDocument({ documentName, document }) { async onLoadDocument({ documentName, document }) {
const persistence = new DocumentPersistence(store, documentName, log); const persistence = new DocumentPersistence(store, documentName, log);
+28 -8
View File
@@ -11,12 +11,12 @@
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import pg from 'pg'; import pg from 'pg';
import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import WebSocket from 'ws'; import WebSocket from 'ws';
import * as Y from 'yjs'; import * as Y from 'yjs';
import { addDevice, emptyProject, readProject, ROOTS } from '@mdg-filius/doc'; import { addDevice, emptyProject, readProject, ROOTS } from '@mdg-filius/doc';
import { SYNC_CLOSINGS, SYNC_REFUSALS } from '@mdg-filius/protocol'; import { SYNC_CLOSINGS, SYNC_REFUSALS } from '@mdg-filius/protocol';
import { cookieValue, PostgresSessions, PostgresStore, safeNext, SERVICE_BODY_BYTES, serviceKeyMatches, SESSION_COOKIE, SESSION_SECONDS, sessionCookie, type SessionStore } from '../src/index.js'; import { cookieValue, PostgresSessions, PostgresStore, refusal, safeNext, SERVICE_BODY_BYTES, serviceKeyMatches, SESSION_COOKIE, SESSION_SECONDS, sessionCookie, type SessionStore } from '../src/index.js';
import { testDatabase, type TestDatabase } from './database.js'; import { testDatabase, type TestDatabase } from './database.js';
import { EDITOR_ORIGIN, enter, exchange, join, newProject as makeProject, SERVICE_KEY, signIn, startTestServer, TICKET_SECRET, until, type TestServer } from './harness.js'; import { EDITOR_ORIGIN, enter, exchange, join, newProject as makeProject, SERVICE_KEY, signIn, startTestServer, TICKET_SECRET, until, type TestServer } from './harness.js';
import { signTicket } from './tickets.js'; import { signTicket } from './tickets.js';
@@ -226,6 +226,21 @@ describe('the way into a shared document', () => {
await server.close(); await server.close();
}); });
it('refuses without a word on the console: the reason goes to the editor, the log has the rest', async () => {
expect([refusal('noAccess').reason, refusal('noAccess').message]).toEqual(['noAccess', '']);
const server = await start();
const project = await newProject();
const written: unknown[] = [];
const spy = vi.spyOn(console, 'error').mockImplementation((...args) => void written.push(args));
try {
await expect(join(server, project)).rejects.toThrow('refused: notSignedIn');
} finally {
spy.mockRestore();
}
expect(written).toEqual([]);
await server.close();
});
it('reads a cookie among others, and nothing that only looks like it', () => { it('reads a cookie among others, and nothing that only looks like it', () => {
expect(cookieValue(`a=1; ${SESSION_COOKIE}=key; b=2`, SESSION_COOKIE)).toBe('key'); expect(cookieValue(`a=1; ${SESSION_COOKIE}=key; b=2`, SESSION_COOKIE)).toBe('key');
expect(cookieValue(`${SESSION_COOKIE}=key=with=equals`, SESSION_COOKIE)).toBe('key=with=equals'); expect(cookieValue(`${SESSION_COOKIE}=key=with=equals`, SESSION_COOKIE)).toBe('key=with=equals');
@@ -306,25 +321,30 @@ describe("the dashboard's service", () => {
const project = await newProject(); const project = await newProject();
const writer = await enter(server, project, { level: 'write' }); const writer = await enter(server, project, { level: 'write' });
const reader = await enter(server, project, { level: 'read' }); const reader = await enter(server, project, { level: 'read' });
// Each is told its access as it comes in.
await until(() => writer.messages.length === 1 && reader.messages.length === 1);
expect([writer.messages, reader.messages]).toEqual([[{ v: 1, kind: 'access', readOnly: false, frozen: false }], [{ v: 1, kind: 'access', readOnly: true, frozen: false }]]);
expect(await answer(await service(server, 'POST', `/svc/projects/${project}/freeze`, { frozen: true }))).toEqual([200, { frozen: true }]); expect(await answer(await service(server, 'POST', `/svc/projects/${project}/freeze`, { frozen: true }))).toEqual([200, { frozen: true }]);
await until(() => writer.messages.length === 1); await until(() => writer.messages.length === 2);
expect(writer.messages).toEqual([{ v: 1, kind: 'access', readOnly: true, frozen: true }]); expect(writer.messages[1]).toEqual({ v: 1, kind: 'access', readOnly: true, frozen: true });
// Whoever comes now reads too, with a write grant; what they change is not taken. // Whoever comes now reads too, with a write grant; what they change is not taken.
const late = await enter(server, project, { level: 'write' }); const late = await enter(server, project, { level: 'write' });
expect(late.provider.authorizedScope).toBe('readonly'); expect(late.provider.authorizedScope).toBe('readonly');
await until(() => late.messages.length === 1);
expect(late.messages).toEqual([{ v: 1, kind: 'access', readOnly: true, frozen: true }]);
place(late.doc, 'a'); place(late.doc, 'a');
await sleep(300); await sleep(300);
expect(await store.pending(project)).toBe(0); expect(await store.pending(project)).toBe(0);
expect(deviceCount(writer.doc)).toBe(0); expect(deviceCount(writer.doc)).toBe(0);
late.close(); late.close();
expect(await answer(await service(server, 'POST', `/svc/projects/${project}/freeze`, { frozen: false }))).toEqual([200, { frozen: false }]); expect(await answer(await service(server, 'POST', `/svc/projects/${project}/freeze`, { frozen: false }))).toEqual([200, { frozen: false }]);
await until(() => writer.messages.length === 2); await until(() => writer.messages.length === 3);
expect(writer.messages[1]).toEqual({ v: 1, kind: 'access', readOnly: false, frozen: false }); expect(writer.messages[2]).toEqual({ v: 1, kind: 'access', readOnly: false, frozen: false });
place(writer.doc, 'b'); place(writer.doc, 'b');
await until(async () => (await store.pending(project)) === 1); await until(async () => (await store.pending(project)) === 1);
await until(() => deviceCount(reader.doc) === 1); await until(() => deviceCount(reader.doc) === 1);
// The reader was told nothing: it read before and reads still. // The reader was told nothing more: it read before and reads still.
expect(reader.messages).toEqual([]); expect(reader.messages).toHaveLength(1);
expect(server.log.lines.filter((line) => line.event === 'svc.frozen').map((line) => line.frozen)).toEqual([true, false]); expect(server.log.lines.filter((line) => line.event === 'svc.frozen').map((line) => line.frozen)).toEqual([true, false]);
writer.close(); writer.close();
reader.close(); reader.close();
@@ -0,0 +1,158 @@
// The editor's way to its shared document (packages/editor's
// connectSharedDoc, F5.3b) against a real server F: ready with the
// document, and whether the person only reads, and whether the project is
// frozen; frozen and thawed while it is open — thawed, the document comes
// anew (what was changed while reading must not stay in the page's copy);
// refused without a session; let go when the grant ends or the project is
// deleted; and let go by the editor.
import { randomUUID } from 'node:crypto';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import WebSocket from 'ws';
import { addDevice } from '@mdg-filius/doc';
import { connectSharedDoc, type SharedDocState } from '../../editor/src/remote/sharedDoc.js';
import { PostgresSessions, PostgresStore } from '../src/index.js';
import { testDatabase, type TestDatabase } from './database.js';
import { newProject, SERVICE_KEY, signIn, startTestServer, until, type TestServer } from './harness.js';
let database: TestDatabase;
let store: PostgresStore;
let sessions: PostgresSessions;
beforeAll(async () => {
database = await testDatabase();
store = PostgresStore.connect(database.url);
sessions = new PostgresSessions(store.pool);
await store.migrate();
await sessions.migrate();
}, 60_000);
afterAll(async () => {
await store?.close();
await database?.stop();
});
/** The editor's connection as a browser with this cookie makes it; the states it went through. */
function connectEditor(server: TestServer, project: string, cookie?: string) {
class Browser extends WebSocket {
constructor(url: string, protocols?: string | string[]) {
super(url, protocols, { headers: cookie === undefined ? {} : { cookie } });
this.on('error', () => {});
}
}
const states: SharedDocState[] = [];
const close = connectSharedDoc({ server: server.base, project, onState: (state) => states.push(state), WebSocketPolyfill: Browser });
const last = () => states.at(-1)!;
return { states, last, close, ready: () => until(() => last().kind === 'ready') };
}
const service = (server: TestServer, path: string, body: unknown, method = 'POST') =>
fetch(`${server.base}${path}`, { method, headers: { 'x-filius-service-key': SERVICE_KEY }, body: JSON.stringify(body) });
describe("the editor's shared document", () => {
it('is ready with the document, writable with a write grant and read-only with a read grant', async () => {
const server = await startTestServer(store, sessions);
const project = await newProject(store);
const writer = connectEditor(server, project, await signIn(server, project));
await writer.ready();
expect(writer.states[0]).toEqual({ kind: 'connecting' });
expect(writer.last()).toMatchObject({ kind: 'ready', readOnly: false, frozen: false });
const doc = (writer.last() as Extract<SharedDocState, { kind: 'ready' }>).doc;
addDevice(doc, { id: 'a', kind: 'pc', name: 'Rechner', x: 0, y: 0, newId: randomUUID }, 'person');
const reader = connectEditor(server, project, await signIn(server, project, { level: 'read' }));
await reader.ready();
expect(reader.last()).toMatchObject({ kind: 'ready', readOnly: true, frozen: false });
await until(() => (reader.last() as Extract<SharedDocState, { kind: 'ready' }>).doc.getMap('nodes').size === 1);
writer.close();
reader.close();
await server.close();
});
it('reads while the project is frozen and gets the document anew when it is thawed', async () => {
const server = await startTestServer(store, sessions);
const project = await newProject(store);
const editor = connectEditor(server, project, await signIn(server, project));
await editor.ready();
const first = (editor.last() as Extract<SharedDocState, { kind: 'ready' }>).doc;
await service(server, `/svc/projects/${project}/freeze`, { frozen: true });
await until(() => editor.last().kind === 'ready' && (editor.last() as Extract<SharedDocState, { kind: 'ready' }>).frozen);
expect(editor.last()).toMatchObject({ kind: 'ready', readOnly: true, frozen: true, doc: first });
// A change that got there anyway while it read: F did not take it.
addDevice(first, { id: 'stray', kind: 'pc', name: 'Rechner', x: 0, y: 0, newId: randomUUID }, 'person');
await service(server, `/svc/projects/${project}/freeze`, { frozen: false });
await until(() => editor.last().kind === 'ready' && !(editor.last() as Extract<SharedDocState, { kind: 'ready' }>).readOnly);
const fresh = editor.last() as Extract<SharedDocState, { kind: 'ready' }>;
expect(fresh.doc).not.toBe(first);
expect(fresh.doc.getMap('nodes').size).toBe(0);
expect(editor.states.map((state) => state.kind).slice(-2)).toEqual(['connecting', 'ready']);
// Writing works again: another person sees it.
addDevice(fresh.doc, { id: 'b', kind: 'pc', name: 'Rechner', x: 0, y: 0, newId: randomUUID }, 'person');
const other = connectEditor(server, project, await signIn(server, project));
await other.ready();
await until(() => (other.last() as Extract<SharedDocState, { kind: 'ready' }>).doc.getMap('nodes').size === 1);
editor.close();
other.close();
await server.close();
});
it('starts frozen and says so', async () => {
const server = await startTestServer(store, sessions);
const project = await newProject(store);
await service(server, `/svc/projects/${project}/freeze`, { frozen: true });
const editor = connectEditor(server, project, await signIn(server, project));
await until(() => editor.last().kind === 'ready' && (editor.last() as Extract<SharedDocState, { kind: 'ready' }>).frozen);
expect(editor.last()).toMatchObject({ readOnly: true, frozen: true });
editor.close();
await server.close();
});
it('is refused without a session, and let go when the grant ends or the project is deleted', async () => {
const server = await startTestServer(store, sessions);
const project = await newProject(store);
const stranger = connectEditor(server, project);
await until(() => stranger.last().kind === 'refused');
expect(stranger.last()).toEqual({ kind: 'refused', reason: 'notSignedIn' });
const user = randomUUID();
const editor = connectEditor(server, project, await signIn(server, project, { user }));
await editor.ready();
await service(server, '/svc/revoke', { user, project });
await until(() => editor.last().kind === 'closed');
expect(editor.last()).toEqual({ kind: 'closed', reason: 'revoked' });
const other = connectEditor(server, project, await signIn(server, project));
await other.ready();
await service(server, `/svc/projects/${project}`, undefined, 'DELETE');
await until(() => other.last().kind === 'closed');
expect(other.last()).toEqual({ kind: 'closed', reason: 'deleted' });
expect(server.sync.hocuspocus.getConnectionsCount()).toBe(0);
await server.close();
});
it('stays ready when only the network drops, and comes back by itself', async () => {
const server = await startTestServer(store, sessions);
const project = await newProject(store);
const editor = connectEditor(server, project, await signIn(server, project));
await editor.ready();
const doc = (editor.last() as Extract<SharedDocState, { kind: 'ready' }>).doc;
for (const connection of server.sync.connections(project)) connection.webSocket.terminate();
await until(() => server.sync.connections(project).length === 1, 10_000);
expect(editor.last()).toMatchObject({ kind: 'ready', doc });
addDevice(doc, { id: 'after', kind: 'pc', name: 'Rechner', x: 0, y: 0, newId: randomUUID }, 'person');
await until(async () => (await store.pending(project)) === 1);
editor.close();
await server.close();
});
it('lets go when the editor does, and tells nothing after', async () => {
const server = await startTestServer(store, sessions);
const project = await newProject(store);
const editor = connectEditor(server, project, await signIn(server, project));
await editor.ready();
const count = editor.states.length;
editor.close();
await until(() => server.sync.hocuspocus.getConnectionsCount() === 0);
await service(server, `/svc/projects/${project}/freeze`, { frozen: true });
await new Promise((resolve) => setTimeout(resolve, 200));
expect(editor.states).toHaveLength(count);
await server.close();
});
});
+2 -1
View File
@@ -220,7 +220,8 @@ describe('a simulation room on F', () => {
again.send({ kind: 'input', node: node('Notebook 1.10'), app: 'terminal', action: 'enter', args: { line: 'touch left.txt' }, reqId: 'r' }); again.send({ kind: 'input', node: node('Notebook 1.10'), app: 'terminal', action: 'enter', args: { line: 'touch left.txt' }, reqId: 'r' });
await until(() => again.got.some((message) => message.kind === 'ack')); await until(() => again.got.some((message) => message.kind === 'ack'));
again.socket.close(); again.socket.close();
await until(() => server.sims.stats().rooms === 0, 5_000); await until(() => server.log.lines.some((line) => line.event === 'sim.roomClosed'), 5_000);
expect(server.sims.stats().rooms).toBe(0);
expect(server.log.lines.filter((line) => line.event === 'sim.roomClosed')).toEqual([{ level: 'info', event: 'sim.roomClosed', project, writeBack: true }]); expect(server.log.lines.filter((line) => line.event === 'sim.roomClosed')).toEqual([{ level: 'info', event: 'sim.roomClosed', project, writeBack: true }]);
// The document let go once the room had written back. // The document let go once the room had written back.
await until(() => server.sync.hocuspocus.getDocumentsCount() === 0); await until(() => server.sync.hocuspocus.getDocumentsCount() === 0);