From aa54e5400e650c3c9d422e7405eabed766ec49b6 Mon Sep 17 00:00:00 2001 From: Julian Kiedaisch Date: Sat, 3 Oct 2026 16:09:05 +0000 Subject: [PATCH] feat(editor): F4.6d the web browser and web server on a computer's desktop The oracle BrowserWindowRun uses Java's browser and web server windows as a person does; the room keeps a browser window per computer and sends a program window's view, the start names the person's language. The editor shows the page in a sandboxed frame, links and forms go to the room, the web server has its button, virtual hosts and lines. Better than Java: relative images and links count from the page's folder, a mark stays in the page, the error page from Java's template, the virtual hosts' file with complete rows. Co-Authored-By: Claude Opus 5.5 --- CHANGES.md | 6 + packages/core/src/runtime/apps/web.ts | 123 ++++- packages/core/test/web.test.ts | 79 +++ .../editor/browser/fixtures/web-pages.fls | Bin 0 -> 26604 bytes packages/editor/browser/web.spec.ts | 205 ++++++++ packages/editor/src/Editor.tsx | 5 +- .../assets/desktop/browser_waterwolf_logo.png | Bin 0 -> 18706 bytes packages/editor/src/desktop/BrowserPanel.tsx | 104 ++++ packages/editor/src/desktop/DesktopWindow.tsx | 18 +- .../editor/src/desktop/WebServerPanel.tsx | 92 ++++ packages/editor/src/desktop/browserPage.ts | 71 +++ packages/editor/src/desktop/desktop.css | 157 ++++++ packages/editor/src/desktop/serverLines.ts | 25 + packages/editor/src/i18n/de.json | 23 + packages/editor/src/i18n/en.json | 23 + .../editor/src/simulation/useSimulation.ts | 18 +- packages/editor/test/i18n.test.ts | 45 ++ packages/editor/test/serverLines.test.ts | 41 ++ packages/editor/test/webPagesFixture.test.ts | 92 ++++ packages/protocol/src/index.ts | 1 + packages/protocol/src/sim.ts | 25 +- packages/protocol/src/web.ts | 74 +++ packages/protocol/test/sim.test.ts | 29 +- packages/sim/src/browserWindow.ts | 102 ++++ packages/sim/src/room.ts | 149 +++++- packages/sim/test/browserWindow.java.json | 490 ++++++++++++++++++ packages/sim/test/browserWindow.test.ts | 343 ++++++++++++ packages/sim/test/room.test.ts | 98 +++- tools/java-oracle/README.md | 40 ++ tools/java-oracle/run.sh | 12 +- tools/java-oracle/src/BrowserWindowRun.java | 338 ++++++++++++ 31 files changed, 2773 insertions(+), 55 deletions(-) create mode 100644 packages/editor/browser/fixtures/web-pages.fls create mode 100644 packages/editor/browser/web.spec.ts create mode 100644 packages/editor/src/assets/desktop/browser_waterwolf_logo.png create mode 100644 packages/editor/src/desktop/BrowserPanel.tsx create mode 100644 packages/editor/src/desktop/WebServerPanel.tsx create mode 100644 packages/editor/src/desktop/browserPage.ts create mode 100644 packages/editor/src/desktop/serverLines.ts create mode 100644 packages/editor/test/serverLines.test.ts create mode 100644 packages/editor/test/webPagesFixture.test.ts create mode 100644 packages/protocol/src/web.ts create mode 100644 packages/sim/src/browserWindow.ts create mode 100644 packages/sim/test/browserWindow.java.json create mode 100644 packages/sim/test/browserWindow.test.ts create mode 100644 tools/java-oracle/src/BrowserWindowRun.java diff --git a/CHANGES.md b/CHANGES.md index 7e6f4e9..d989e84 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -16,6 +16,7 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand | `src/runtime/engine.ts` | `State/TopologyNetworkRuntimeEngine.swift` Z. ~313–3585, ~4472–5187 (UDP), ~4753–4850 | übertragen: Warteschlange, Uhr, Ethernet, Switch, ARP, IPv4, ICMP, Routing, Firewall, UDP-Sockets (binden, freie Ports nach Javas Zufall, senden samt Broadcast, zustellen, empfangen mit Wartezeit, schließen), TCP (Z. ~5187–5913: Server- und Client-Sockets, Handschlag mit Ergebnis, Segmente zu 1460 Byte bis zum PSH, höchstens drei Versuche, Abbau über alle Java-Zustände samt `TIME_WAIT`; seit F2.3c nach Javas `TCPSocket` an Java-Filius gemessen: je Versuch und für `TIME_WAIT` 3 RTT nach der Geschwindigkeit (8250 ms bei 90 %, Swift fest 7500 bzw. 2500 ms), der Zeitablauf zählt ab der Übergabe des Segments, vor ARP; ein Segment beendet `TIME_WAIT`; die Gegenseite schließt beim FIN von selbst; nicht bestätigte Daten schließen mit FIN statt sofort; der angenommene Socket hat die Startnummer des Listeners, der Listener eine neue. seit F2.3e wie Java: SYN+ACK und FIN gehen nie ein zweites Mal hinaus; der Server gibt einen Handschlag nach 3 RTT auf und schließt mit FIN; nach dem FIN wartet ein Socket höchstens fünfmal 3 RTT, jedes Segment beendet eine Wartezeit, dann CLOSED (Java lässt den Zustand stehen). Abweichend von Java: drei SYN statt einem, weil Javas Wiederholungsschleife nach dem ersten Zeitablauf abbricht, und ein wiederholtes SYN in `SYN_RCVD` bekommt das SYN+ACK noch einmal, statt den Socket zu schließen), DHCP (Z. ~5913–6694: Server mit festen Zuordnungen, Adressbereich, Angeboten, Leases und Sperrliste; Client seit F2.3b nach Javas `DHCPClient` neu gebaut und an Java-Filius gemessen: je Versuch eine Nachricht, eine Antwort, die nicht passt, wird gelesen und verworfen; die ARP-Prüfung wartet eine RTT, eine Antwort für die Adresse beendet sie sofort, dann DECLINE; nach einem Zeitablauf wiederholt der Zustand seine Nachricht, nach dem fünften nimmt der Client eine Link-Local-Adresse `169.254.1.x/16` (ARP-Probe, ARP-Reply an alle) und gibt keine alte Adresse zurück; Swift sendete doppelt, wartete nicht auf ARP und kehrte nach zehn Versuchen zur alten Adresse zurück. Abweichend von Java: Nach fünf belegten Link-Local-Adressen gibt der Client auf (Java sucht endlos), und ein ARP-Paket für eine andere Adresse beendet die Prüfung nicht. Seit F2.3d befördert ein Kabel einen Frame nach dem anderen wie Javas `SimplexVerbindung` (ein Frame wartet im Ausgangspuffer, bis der vorige angekommen ist, und erscheint im Protokoll, wenn er losgeht); Swift schickt alle zugleich, die Option `swiftCables` behält das für den Swift-Vergleich. Die RTT der DHCP-Zeiten (Warten, Angebote, Sperrliste) folgt wie in Java der Geschwindigkeit (50 Kabel, 2750 ms bei 90 %); Swift rechnet fest mit 2500 ms. ARP seit F2.4b nach Javas `holeARPTabellenEintrag`, an Java-Filius gemessen: eine Auflösung fragt, wartet eine RTT, fragt ein zweites Mal und gibt nach einer weiteren RTT auf; dann „Host unreachable" beim Absender selbst oder vom Router zurück (bei ICMP nur für Echo-Anfragen); ein wartender Absender wacht nach den übrigen Ereignissen desselben Zeitpunkts auf; der Ping wartet danach eine RTT auf die Antwort. Swift schickt in der Ereignisverarbeitung beide Anfragen zugleich und lässt das Paket ohne Ende warten, wenn die Adresse irgendwo im Netz vorkommt (Wissen, das kein Knoten hat), und blockiert direkte Aufrufe ohne Wartezeit; die Option `swiftArp` behält das für den Swift-Vergleich. Seit F2.4b-2 kehrt ein Senden wie in Java sofort zurück, die Frames reisen, während die Uhr läuft (Swift: der Aufruf kehrt erst zurück, wenn alle Frames angekommen sind; die Option `swiftSends` behält das für den Swift-Vergleich), und UDP und TCP senden über Javas Transport-Thread je Knoten und Protokoll: ein Segment nach dem anderen; solange IP auf ARP wartet, blockiert das nächste `senden`. Wartende Absender kommen in der Reihenfolge an die Reihe, in der sie kamen (Javas Sperre überlässt das dem Zufall). Seit F2.4b-3 geht jedes ankommende Paket wie in Java durch den IP-Thread (UDP, TCP) oder den ICMP-Thread (ICMP) seines Knotens: zustellen, Echo-Reply, weiterleiten, eins nach dem anderen; solange ARP wartet, warten die folgenden. Abweichend von Java weckt eine ARP-Antwort alle, die auf die Adresse warten (Javas `notify` weckt einen beliebigen). DNS seit F2.4a nach Javas `DNSServer`/`Resolver`, an Java-Filius gemessen: Server auf Port 53 mit Einträgen aus dem Modell (die Antwort wiederholt die Frage nicht, RCODE immer 0, ein Verweis mit NS und A im Antwortteil), iterativer Resolver mit 5 RTT je Anfrage, der nur die Server behält, an die er verwiesen wurde; seit F2.4c auf den Ereignissen statt blockierend und mit rekursivem Server nach `DNSServer.answerWithRemoteData` (Nameserver der Zone, dann der eigene DNS-Server, sonst Verweis), abweichend von Java fragt er sich nie selbst (Java endlos). Gateway-NAT seit F2.6 (Z. ~3585–3900) nach Javas `filius.software.nat`, an Java-Filius gemessen: die Übersetzung vor dem IP-Thread (wie Javas NAT-Threads zwischen Karte und Ethernet), ausgehend WAN-Adresse und der Port des Eintrags (ICMP behält die Kennung), eingehend nach Gegenstelle, Port und Protokoll, sonst nach der Portweiterleitung (legt einen dynamischen Eintrag an), ein Ping an die WAN-Adresse beantwortet der Heimrouter, Pakete von außen an das LAN verschwinden, beide Seiten durch die Firewall des Heimrouters, ausgehend TTL 1 bekommt „Zeit überschritten“; dynamische Einträge verfallen nach 300 s mal Javas Verzögerungsfaktor (Swift fest 300 s), aufgefrischt nur eingehend, seit 01.10.2026 genau in diesem Alter (Java findet sie erst bei seiner sekündlichen Prüfung, bis zu 1 s später; so auch der Switch: Javas `checkSAT` prüft jede Sekunde, der Kern verfällt genau zur Lernzeit); der Heimrouter leitet weiter wie ein Router. Abweichend von Java: ein LAN-Endpunkt bekommt für jede neue Gegenstelle einen Eintrag auf demselben WAN-Port (Java behält nur den für die erste, die Antworten weiterer bleiben am Heimrouter hängen). RIP seit F2.5a (Z. ~4700–5090) nach Javas `filius.software.rip`, seit F2.5b an Java-Filius gemessen (die Pause in Javas `float`-Rechnung): eine Route mit 0 Hops je eigenem Netz, die nie verfällt; der erste Beacon nach einer Sekunde, dann alle 25,2–35,2 s aus einem Zufall je Router (Java: ein ungesetzter `Random` je Router; der Kern setzt ihn aus Szenario und Router-ID, `ripSeed`; Swift: ein Zufall für alle); eine Nachricht je Schnittstelle ohne die Routen über sie (Split Horizon); eine neue Route oder mehr Hops vom selben Router senden sofort; eine gelernte Route wird genau nach ihren 75 s auf 16 gesetzt und sofort gemeldet (Java erst beim nächsten eigenen Beacon, bis zu gut 30 s später; Swift bei jedem Ereignis; seit 01.10.2026, vorher wie Java); Nachrichten von jedem Quellport (Swift: nur 521); ein RIP-Router leitet nur nach seiner Tabelle weiter, wenige Hops vor langer Maske. `receiveUdp` kehrt wie Javas `empfangen` zurück, sobald ein Datagramm da ist. Seit der Abnahme F2 nach Javas `ARPThread`: ein Eintrag entsteht nur aus einer Anfrage nach der eigenen Adresse oder aus einer Antwort (Swift: aus jedem Paket; `swiftArp` behält das), ein Paket von der Netzadresse wird verworfen; ein Rechner leitet weiter, wenn seine IP-Weiterleitung an ist (`ipForwardingByNodeId`). Seit F2.8b nach Javas Tabellen gemessen: eine ARP-Anfrage trägt als Ziel-MAC die Broadcast-Adresse (Swift: Nullen; `swiftArp` behält sie), eine ICMP-Fehlermeldung Identifier und Sequenznummer 0 (RFC 792, Java; Swift kopiert die des Echos, ein Ping erkennt die Meldung am eingebetteten Echo), und „Host nicht erreichbar“ vom Router trägt das Paket, wie er es weitergeleitet hat (TTL gesenkt). Die Broadcast-Adresse schreibt sie immer groß (Java in der Antwort an 0.0.0.0 klein). Den Mitschnitt je Karte (`capture.ts`) schreibt sie seit F2.8a dort, wo Java ihn schreibt: beim Abgang auf das Kabel und bei der Ankunft an der Karte. Seit F3.4a läuft die Befehlszeile mit (`terminal`, auf den Dateien des Knotens). Seit F3.4b nach Javas `ICMP`/`ICMPThread`, an Java-Filius gemessen: ein Echo im Programm-Thread (`icmpEchoSteps`: Warteschlange leeren, senden mit ARP-Wartezeit, eine RTT auf die erste ICMP-Nachricht warten, gleich welche), ein ICMP-Paket an den Broadcast eines eigenen Netzes geht mit TTL 1 an alle und wird von dort beantwortet, einer für ein fremdes Netz verworfen; Javas Routenwahl (`routeRow`), die ARP-Tabelle mit Javas Broadcast-Zeile (`arpTable`, `removeArpEntry`, `resetArpTable`), DNS im Programm-Thread mit Antwort- und Autoritätsteil (`resolveDnsSteps`), ein UDP-Socket weiß, dass er ohne Gegenstelle lauscht (`listening`); ein Rechner nimmt Pakete an 127.0.0.1 an. Eine ARP-Wartezeit endet wie Javas `wait` nach allem, was zur selben Zeit sonst geschieht (eine Antwort zur selben Millisekunde zählt noch). Seit F3.4c die Uhrzeit eines Frames (`wallTime`) für `tcpdump`. Seit F3.6a läuft das Gnutella-Programm mit (`gnutella`, Schnappschuss `gnutellaByNodeId`), mit eigener Zufallsquelle für die GUIDs (`drawGnutellaGuid`). Seit F3.5a schreibt der Mailserver seine Kontendatei `/mailserver/konten.txt` beim Start und bei jeder Änderung seiner Konten in die Dateien des Knotens (Java schreibt die Benachrichtigung an einen lokalen Absender erst beim nächsten Speichern hinein). Abweichend von Java: eine Echo-Antwort kommt von der gefragten Adresse (RFC 792; Java antwortet von 127.0.0.1, wenn die eigene Adresse gefragt war, und von der nahen Karte, wenn die ferne eines Routers gefragt war), und „Netz nicht erreichbar“ vom Router trägt wie „Host nicht erreichbar“ das Paket, wie er es weitergeleitet hätte, mit Absender (Java bettet ein halb gebautes ohne Absender ein). Seit F3.3b läuft das E-Mail-Programm mit (`emailClient`), seine gesendeten und abgeholten Mails stehen im Schnappschuss (`emailClientsByNodeId`); ein Programm-Thread kann MX nachschlagen (`resolveDnsMailServerSteps`). Seit F3.3a läuft der E-Mail-Server mit (`emailServer`); was er annimmt oder löscht, steht im Schnappschuss (`emailServersByNodeId`), aus dem es zurück ins Projekt geht; die Uhrzeit für seine Benachrichtigungen kommt aus der Option `wallClock`. Seit F3.2c ändert die Seite `konfig` der Router und Heimrouter die Firewall zur Laufzeit; die Änderung steht im Schnappschuss (`firewallConfiguration`), aus dem sie zurück ins Projekt geht. Seit F3.2b starten Router und Heimrouter ihren Webserver auf Port 80 mit Javas Seiten (Javas Server lauscht wegen eines Wettlaufs in `setAktiv` manchmal nie, der Kern immer; `swiftRouters` lässt ihn für den Swift-Vergleich weg), die Firewall führt ein Protokoll je Knoten (Regeltreffer bei TCP und UDP, verworfenes ICMP), das wie Javas Protokollseite einen Neustart überlebt, eine RIP-Route kennt die öffentliche Adresse des meldenden Routers (`hopPublicIp`), und TCP zählt Sequenznummern und Segmentgröße wie Java in Zeichen (UTF-16-Einheiten) statt in Bytes, ohne ein Zeichenpaar zu zerreißen; Bytes, die kein Text sind, zählen als Bytes. Seit F3.2a schließt ein Programm wie Javas `schliessen()` nur auf Bitte (`requestTcpClose`): Das FIN geht zur selben Zeit, nach dem, was der Programm-Thread noch tut. Seit F3.1 laufen Verbinden, Senden, Empfangen und Annehmen auch als Schritte eines Programm-Threads (`connectTcpSteps`, `sendTcpSteps`, `receiveTcpSteps`, `acceptTcpSteps`, `resolveHostSteps`); die blockierenden Aufrufe sind nur noch eine Hülle darum. Abweichend von Java: Ein Verbindungsaufbau ohne Route scheitert sofort (Javas Socket-Thread stirbt an der `RouteNotFoundException`, `verbinden` wartet ewig), eine Nachricht, die vor dem Schließen ankam, wird noch ausgegeben (Java verwirft sie), und Senden unterscheidet wie Java „nicht verbunden", „unterbrochen" und „Zeitüberschreitung". WLAN seit F2.7 an Java-Filius gemessen: zur Laufzeit ist ein Funkkabel ein Kabel wie jedes andere, und eine SSID verbindet nichts (nach ihr legt nur Javas Editor das Funkkabel, `JHostKonfiguration.updateWifiConnection`); Swifts Verbindung über die SSID zur Laufzeit, auch ohne Kabel (`wirelessAssociations`), nicht übernommen. Klasse statt Werttyp. Noch nicht übertragen (wirft `NotPortedError`): Remote Link | | `src/runtime/engine.ts` (ganze Millisekunden) | Java `System.currentTimeMillis` (long) | behoben (F4.6b): `start` und `advance` runden auf ganze Millisekunden ab, wie Javas Uhr zählt. Gefunden im Browser: der Raum gab `performance.now()` mit Bruchteilen weiter, und ein Ping blieb nach der ersten Zeitüberschreitung hängen; alle Tests liefen zuvor mit glatten Takten | | `src/runtime/engine.ts` (Installation), `src/runtime/apps/installation.ts`, `test/installation.test.ts` | Java `InternetKnotenBetriebssystem.installAndStartAppIfAvailable/entferneSoftware`, `WebServer.erzeugeStandardVerzeichnis`, `PeerToPeerAnwendung`/`EmailServer`/`DNSServer.setSystemSoftware/starten`, `tmpl/webserver_index_*`, `gfx/allgemein/splashscreen-mini.png` | neu (F4.6c): was ein Programm bei der Installation in die Dateien legt (Javas Startseite je Sprache, Bild, `/peer2peer`, `/mailserver/konten.txt`, `/dns/hosts`); `installProgram`/`removeProgram` im laufenden Betrieb — Einstellungen und fehlende Dateien aus dem Schnappschuss des Dokuments, Programm sofort gestartet; entfernt wird ein Server abgeschaltet, die Dateien bleiben. Die Programmerzeugung beim Start läuft über dieselbe `startProgram` in der bisherigen Reihenfolge | +| `src/runtime/apps/web.ts` (F4.6d), `test/web.test.ts` | Java `WebBrowser.retrieveImages`, `GUIApplicationWebBrowserWindow.hyperLinkListener`, `WebServer.changeVHostTable/saveVHosts/setUseVHost` (gemessen mit `BrowserWindowRun`) | erweitert (F4.6d): die Zeile einer Seite nennt Host und Pfad, die eines Bildes trägt seine Daten (für das Fenster); `pageReference` und `linkTarget` — relative Bilder und Links zählen vom Ordner der Seite (Java vom Wurzelordner des Servers; für eine Seite im Wurzelordner sind die Anfragen Javas), eine Marke (`#…`) und andere Schemata führen nirgends hin; der Webserver schaltet seine virtuellen Hosts um und schreibt ihre Tabelle, nur mit vollständigen Zeilen (Java schreibt jede Zeile, und beim Lesen rückt eine Zelle einer unvollständigen in die Zeile davor) | | `src/runtime/threads.ts` | Javas Threads der Programme (`Anwendung extends Thread`, `ServerMitarbeiter`) | neu (F3.1): ein Programm-Thread als Generator, der liefert, worauf er wartet (Bedingung, Zeit oder beides); die Engine setzt nach jedem Ereignis fort, wessen Warten vorbei ist, in der Reihenfolge, in der die Threads begannen; nichts blockiert die Engine. Abweichend von Java: ein Thread wacht sofort, wenn seine Bedingung gilt (Java sieht oft nur alle 100 ms nach) | | `src/runtime/apps/program.ts` | Java `filius.software.Anwendung` | neu (F3.1): die Zeilen im Fenster eines Programms (strukturiert, damit der Editor sie übersetzt) und der Befehls-Thread (`ausfuehren`) | | `src/runtime/apps/server.ts` | Java `ServerAnwendung`, `ServerMitarbeiter` | neu (F3.2a, aus `echo.ts` herausgelöst): was jeder Server teilt — ein Thread nimmt Verbindungen an, solange er eingeschaltet ist, je Verbindung einer beantwortet jede Nachricht; ausgeschaltet schließt er Socket und Verbindungen; Port belegt → er schaltet sich aus; seit F3.3a kann ein Server das Gespräch einer Verbindung selbst führen (`converse`: Begrüßung, keine oder mehrere Antworten, Schließen) | @@ -147,6 +148,7 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand | `test/sim.test.ts` | — | neu (F4.2): jede Art gelesen, jede Ablehnung mit ihrem Grund | | `src/sim.ts` (`node`) | Java `NetworkInfoPanel.updateInfo`, `GUIDesktopWindow.updateTitle` | erweitert (F4.6a): Thema `node:` und Nachricht `node` — die erste Karte eines Geräts, wie die Simulation sie fährt (DHCP ändert sie), für Titel und Netzwerk-Info des Desktops | | `src/terminal.ts` | Java `GUIApplicationTerminalWindow` (gemessen mit `TerminalWindowRun`) | neu (F4.6b): die Zeilen eines Befehlszeilen-Fensters (Text, Eingabe nach ihrem Prompt, cls, Beschäftigt, Prompt, exit) und wie ein Client sie liest — Text ab dem letzten cls, Prompt, ob er sichtbar ist (verborgen, solange ein Befehl läuft), Verlauf; Javas Pfeiltasten (`commandHistoryPointer`) | +| `src/web.ts`, `src/sim.ts` (`view`, `start.language`) | Java `GUIApplicationWebBrowserWindow`, `GUIApplicationWebServerWindow` (gemessen mit `BrowserWindowRun`) | neu (F4.6d): der Bildschirmstand eines Programmfensters (`view`, ganz, wenn er sich ändert) — der des Browsers (Adresszeile, Adresse des letzten Abrufs, Seite: Start, nicht erreichbar, Fehler mit Status, HTML mit den gekommenen Bildern, Text, Bild) und der des Webservers (Knopf, Kästchen, Tabelle); `browserTitle` wie Javas Fenster (Programmname, dann die Adresse, dann der Titel der Seite), `htmlTitle`; der Start nennt die Sprache, in der FILIUS für die startende Person läuft | ## packages/sim @@ -159,6 +161,7 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand | `src/room.ts` (Takt) | — | behoben (F4.6b): der Raum gibt der Engine ganze Millisekunden und trägt den Bruchteil der Wanduhr in den nächsten Takt; nach einem Sprung holt er höchstens zwei Sekunden nach, auch über die folgenden Takte nicht mehr | | `src/terminalWindow.ts`, `src/room.ts` (Befehlszeile), `test/terminalWindow.test.ts`, `test/terminalWindow.java.json` | Java `GUIApplicationTerminalWindow`, `Terminal.setSystemSoftware` | neu (F4.6b): das Fenster der Befehlszeile im Raum, eines je Rechner für alle — Startbildschirm, jede Eingabe nach ihrem Prompt, leere Zeile, cls, exit, danach die Zeilen des Programms in ihrer Reihenfolge; es überdauert Stopp und Start mit Text, Verlauf und Arbeitsverzeichnis wie Javas. Gegen Javas Fenster Taste für Taste geprüft; benannt: was ein Lauf in den Dateien anlegt, steht nach dem Neustart noch nicht im Dokument (F4.6f), ein dort angelegter Ordner fehlt dann | | `src/room.ts` (Installation) | Java `GUIInstallationsDialog` (Programme starten sofort) | erweitert (F4.6c): im laufenden Betrieb beobachtet der Raum die installierten Programme des Dokuments und installiert oder entfernt sie in der Engine; eine neue Befehlszeile bekommt ihr Fenster | +| `src/browserWindow.ts`, `src/room.ts` (Browser, Webserver), `test/browserWindow.test.ts`, `test/browserWindow.java.json` | Java `GUIApplicationWebBrowserWindow`, `GUIApplicationWebServerWindow` (gemessen mit `BrowserWindowRun`) | neu (F4.6d): das Browserfenster im Raum, eines je Rechner für alle, über Stopps behalten — Adresse öffnen (keine Adresse: die Zeile zurück auf „http://“), Links und Formulare der gezeigten Seite folgen, was das Programm bekam, in den Bildschirmstand; der Raum schickt Bildschirmstände (Browser statt Zeilen, Webserver zu seinen Zeilen), nimmt die Aktionen `open`/`follow` und `setActive`/`useVirtualHosts`/`setVirtualHost` an und startet in der Sprache der Person. Gegen Javas Fenster geprüft; benannt: Javas Fehlerseite findet ihre Vorlage nie, Bilder und Links einer Seite im Ordner, eine Marke lädt in Java die Startseite, die Datei der virtuellen Hosts | | `browser/m9.spec.ts`, `browser/traceDigest.ts`, `browser/*Entry.ts`, `/playwright.config.ts` | Plan §13 M9 | neu (F4.2): M9 in Chromium — 17 Beispiele, je 30 s mit Eingaben, ergeben Zeichen für Zeichen denselben Mitschnitt wie in Node; der Raum in einem echten Worker | ## packages/editor @@ -185,8 +188,11 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand | `src/desktop/DesktopWindow.tsx`, `src/desktop/programs.ts`, `src/desktop/desktop.css`, `src/assets/desktop/*.png` | Java `GUIDesktopWindow`, `GUIDesktopPanel`, `GUIDesktopIcon`, `NetworkInfoPanel`, `config/Desktop_de_DE.txt`/`_en_GB.txt` (`desktoppanel_*`, `network_msg*`) | neu (F4.6a): der Desktop eines Rechners — Titel „Name - Adresse“ (nur die Beschriftung, wo sie die Adresse ist), Javas Hintergrund, Software-Installation zuerst, dann die Programme in der Reihenfolge der Java-Liste der Sprache, graue Taskleiste mit „Startbildschirm“ und dem Netzwerksymbol (leuchtet, solange das Kabel trägt), Netzwerk-Info mit den laufenden Werten; auf breiten Schirmen verschiebbar über der Fläche, mehrere gestapelt, auf dem Handy flächig unter den Modusknöpfen. Javas Taskleiste hat 30 px, hier 44 für Finger; Javas Knopf „Ändern“ unter schreibgeschützten Feldern fehlt. Die Programmfenster folgen ab F4.6b | | `src/desktop/TerminalPanel.tsx`, `src/desktop/ProgramFrame.tsx` | Java `GUIApplicationTerminalWindow`, `GUIApplicationWindow` | neu (F4.6b): ein Programmfenster über dem Desktop mit Javas dunkelgrauer Titelleiste (kein Schließknopf, „Startbildschirm“ führt zurück; das offene Programm überdauert den Entwurf wie bei Java) und die Befehlszeile — grau auf schwarz, Prompt vor der Eingabe (verborgen, solange ein Befehl läuft), Enter, Pfeiltasten, Strg+C (mit markiertem Text kopiert es); für Finger Befehle als Chips, Pfeile und Strg+C als Knöpfe; `exit` schließt das Fenster für alle, die es offen haben | | `src/desktop/InstallationPanel.tsx`, `src/assets/desktop/pfeil_*.png` | Java `GUIInstallationsDialog` (`installationsdialog_msg1`–`4`), Javas Pfeile | neu (F4.6c): die Software-Installation auf dem Desktop — „Installiert:“ und „Verfügbar:“ in der Reihenfolge von Javas Liste, Pfeile oder Doppelklick verschieben, ein Tippen wählt (ein zweites lässt los), „Änderungen annehmen“ wendet an und führt zurück; Javas Liste der installierten Programme kommt in der Reihenfolge einer HashMap, hier in der der Liste | +| `src/desktop/BrowserPanel.tsx`, `src/desktop/browserPage.ts`, `src/assets/desktop/browser_waterwolf_logo.png` | Java `GUIApplicationWebBrowserWindow` (`webbrowser_msg2`, `sw_webbrowser_msg1`, `tmpl/http_fehler_*`), Javas Logo (`gfx/desktop`) | neu (F4.6d): der Webbrowser — Adresszeile mit „Start“, Javas Startseite mit Logo, die Seite in einem Rahmen mit Sandbox und Richtlinie (kein Skript, nichts lädt außer den gekommenen Bildern als Daten, kein Formular geht ab), Links und Formulare gehen an den Raum (ein Formular wie Javas Fenster: seine Felder in Reihenfolge samt Knopf), eine Marke springt in der Seite; der Titel des Fensters aus der Seite mit aufgelösten Entities (Java zeigt „&amp;“), die Fehlerseite aus Javas Vorlage (Java findet sie nie und zeigt „Fehler“), Text als Text und ein Bild als Bild (Java zeigt beides als HTML) | +| `src/desktop/WebServerPanel.tsx`, `src/desktop/serverLines.ts` | Java `GUIApplicationWebServerWindow` (`webserver_msg1`–`6`), `ServerAnwendung`/`ServerMitarbeiter`/`TCPSocket` (`sw_serveranwendung_msg1`–`6`, `sw_servermitarbeiter_msg1`/`2`, `sw_tcpsocket_msg6`–`8`) | neu (F4.6d): der Webserver — „Starten“/„Anhalten“, „Verwende virtuelle Hosts“ mit der Tabelle (eine Zelle wird beim Verlassen oder mit Enter geschrieben, Escape nimmt sie zurück), darunter die Zeilen des Servers in Javas Worten | | `src/Editor.tsx`, `src/canvas/Canvas.tsx` (Aktionsmodus) | Java `GUIContainer.setActiveSite/showDesktop/closeDialogs/reopenDialogs`, `GUIKnotenItem` (Klick im Aktionsmodus), `GUIEvents.kontextMenueAktionsmodus` (`guievents_msg3`), `JCablePanel` (`farbeBlinken`) | erweitert (F4.6a): im Aktionsmodus ist der Entwurf gesperrt wie bei Java — keine Palette, kein Blatt, nichts bewegt sich, kein Rückgängig, kein Löschen —, Kabel mit Rahmen leuchten grün, ein Tipp auf einen Rechner öffnet seinen Desktop (Langdruck: „Desktop anzeigen“); offene Desktops überstehen den Entwurf und erscheinen beim nächsten Start wieder, die eines entfernten Rechners nicht | | `browser/simulation.spec.ts`, `test/programs.test.ts` | — | neu (F4.6a): Moduswechsel, Sperren, Geschwindigkeit, Kabelaktivität, Desktop mit Titel, Programmen, Netzwerk-Info, Verschieben und Stapeln, Handy, Englisch; die Programmliste gegen Javas `Desktop_*.txt`; seit F4.6b die Befehlszeile (Ping über drei Router, Verlauf, Chips, Strg+C, Behalten, exit) und der Fehlerfall des Raums; seit F4.6c die Software-Installation (sofort lauffähig, Doppelklick, Abwählen) | +| `browser/web.spec.ts`, `browser/fixtures/web-pages.fls`, `test/webPagesFixture.test.ts`, `test/serverLines.test.ts`, `src/simulation/useSimulation.ts` (`views`), `src/Editor.tsx` (Sprache beim Start), `src/desktop/DesktopWindow.tsx` | — | neu (F4.6d): Browser und Webserver in Chromium auf einer eigenen Projektdatei (mit den Befehlen des Dokuments gebaut und bei jedem Lauf verglichen): Adresszeile, Titel, Sandbox, Bilder, Marke, Links im Ordner, beide Formulare bis in die Zeilen des Servers, Fehlerseite, nicht erreichbarer Server, Knopf, Kästchen und Tabelle, die Routerseiten auf Englisch; die Zeilen des Servers gegen Javas Fenster | | `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 | diff --git a/packages/core/src/runtime/apps/web.ts b/packages/core/src/runtime/apps/web.ts index 8fb9c2b..7eb0f1c 100644 --- a/packages/core/src/runtime/apps/web.ts +++ b/packages/core/src/runtime/apps/web.ts @@ -10,7 +10,13 @@ // cannot read gets 400 or 501, one without a version is HTTP/1.1 (Java's // thread dies of it and closes the connection); the browser shows "server // not reachable" when its request cannot go out or the answer cannot be -// read (Java shows nothing). +// read (Java shows nothing). A page's relative images and links count from +// its folder, as in every browser (Java's window counts them from the +// server's root, so that a page in a folder misses its images and links; +// for a page in the root the requests are Java's). The table of virtual +// hosts is written with its complete rows only (Java writes every row, and +// reading the file again a row with an empty cell takes the next row's +// cell). // // A server may make pages itself (Java's plug-ins, WebServerPlugIn): a // router's or home router's firewall log and routing table (routerPages.ts). @@ -74,10 +80,13 @@ function contentTypeOf(name: string): string { const IMAGE_TYPES = new Set(['image/bmp', 'image/gif', 'image/jpg', 'image/png']); +/** WebServer's table of virtual hosts: five rows of host name and folder, "" where empty. */ +export type VirtualHostTable = readonly (readonly [string, string])[]; + export class WebServer extends TcpServerProgram { private readonly documentRoot: string; - private readonly virtualHosts: readonly (readonly [string, string])[]; - private readonly useVirtualHosts: boolean; + private virtualHosts: [string, string][]; + private useVirtualHostsValue: boolean; constructor( engine: NetworkRuntimeEngine, @@ -96,13 +105,49 @@ export class WebServer extends TcpServerProgram { for (let index = 0; index < lines.length && rows.length < VIRTUAL_HOST_ROWS; index += 2) { rows.push([lines[index]!, lines[index + 1] ?? '']); } + while (rows.length < VIRTUAL_HOST_ROWS) rows.push(['', '']); this.virtualHosts = rows; - this.useVirtualHosts = configuration.virtualHosts || vhosts !== undefined; + this.useVirtualHostsValue = configuration.virtualHosts || vhosts !== undefined; + } + + /** Whether the server looks up the request's host in its table (the window's check box). */ + get useVirtualHosts(): boolean { + return this.useVirtualHostsValue; + } + + /** WebServer.setUseVHost: from the next request on. */ + setUseVirtualHosts(use: boolean): void { + this.useVirtualHostsValue = use; + } + + get virtualHostTable(): VirtualHostTable { + return this.virtualHosts; + } + + /** + * WebServer.changeVHostTable: a cell of the table (column 0 the host name, + * 1 the folder), saved to /www.conf/vhosts — with the complete rows only, + * so that Java reads the file as the table is. + */ + setVirtualHost(row: number, column: 0 | 1, value: string): void { + const cells = this.virtualHosts[row]; + if (cells === undefined) return; + cells[column] = value; + const text = this.virtualHosts + .filter(([host, folder]) => host !== '' && folder !== '') + .map(([host, folder]) => `${host}\n${folder}\n`) + .join(''); + try { + if (this.fileSystem !== undefined && !this.fileSystem.contains('/www.conf')) this.fileSystem.createDirectory('/www.conf'); + this.fileSystem?.writeTextFile('/www.conf/vhosts', text); + } catch { + // A file where the folder should be: the table holds, unsaved, as Java's does when it cannot save. + } } /** WebServer.vhostPrefix: the folder of the host name, ignoring case; "" when none. */ private virtualHostFolder(host: string | undefined): string { - if (!this.useVirtualHosts || host === undefined) return ''; + if (!this.useVirtualHostsValue || host === undefined) return ''; return this.virtualHosts.find(([name]) => name.toLowerCase() === host.toLowerCase())?.[1] ?? ''; } @@ -189,6 +234,35 @@ function javaSplit(text: string, separator: string): string[] { return parts; } +/** + * A page's reference (an image's src) as the browser requests it: one with + * a leading slash or a scheme as it is, a relative one from the page's + * folder, without a leading slash as Java writes it (for a page in the + * root, Java's request). + */ +export function pageReference(pagePath: string, reference: string): string { + if (reference.startsWith('/') || /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(reference)) return reference; + const path = pagePath.split('?')[0]!; + return path.slice(path.startsWith('/') ? 1 : 0, path.lastIndexOf('/') + 1) + reference; +} + +/** + * Where a link of a page leads, as the browser window loads it: an http + * address to its host, a path to the page's host (relative from the page's + * folder, with a leading slash as Java's window writes it); undefined for a + * link the browser does not follow (another scheme, a mark in the page). + */ +export function linkTarget(page: { readonly host: string; readonly path: string }, href: string): { readonly host: string; readonly path: string } | undefined { + const reference = href.trim(); + if (reference === '' || reference.startsWith('#')) return undefined; + if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(reference)) { + if (!/^http:/i.test(reference)) return undefined; + return webBrowserAddress(reference.split('#')[0]!); + } + const target = pageReference(page.path, reference.split('#')[0]!); + return { host: page.host, path: target.startsWith('/') ? target : `/${target}` }; +} + /** EingabenUeberpruefung.musterDomain and musterIpAdresse. */ const DOMAIN_PATTERN = /^([a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9]){0,1}(\.[a-zA-Z]([a-zA-Z0-9\\-]{0,61}[a-zA-Z0-9]){0,1})*){0,1}\.{0,1}$/; export const IP_ADDRESS_PATTERN = @@ -218,15 +292,24 @@ export function imageSources(html: string): string[] { } export type WebBrowserLogEntry = - /** A page came: shown as it is with status 200, else as the browser's error page for the status. */ - | { readonly kind: 'page'; readonly statusCode: number; readonly contentType: string | undefined; readonly body: string | undefined } + /** A page came from the host at the path: shown as it is with status 200, else as the browser's error page for the status. */ + | { + readonly kind: 'page'; + readonly host: string; + readonly path: string; + readonly statusCode: number; + readonly contentType: string | undefined; + readonly body: string | undefined; + } /** sw_webbrowser_msg1: the server could not be reached (no address, no connection, no answer). */ | { readonly kind: 'unreachable' } - /** An image of the page came (or not: its status); Java tells its window, which loads it anew. */ - | { readonly kind: 'image'; readonly source: string; readonly statusCode: number; readonly contentType: string | undefined }; + /** An image of the page (its src as written) came, with its data — or not: its status; Java tells its window, which loads it anew. */ + | { readonly kind: 'image'; readonly source: string; readonly statusCode: number; readonly contentType: string | undefined; readonly data?: Uint8Array }; -/** The page the browser shows: as it came, with the images that came for it. */ +/** The page the browser shows: as it came, with the images that came for it (by their src as written). */ export interface WebBrowserPage { + readonly host: string; + readonly path: string; readonly statusCode: number; readonly contentType: string | undefined; readonly body: string | undefined; @@ -238,7 +321,7 @@ export class WebBrowser { private hostValue: string | undefined; private socketId: Id | undefined; private images: string[] = []; - private shown: { statusCode: number; contentType: string | undefined; body: string | undefined; images: Map } | undefined; + private shown: (Omit & { readonly images: Map }) | undefined; private readonly commands: CommandThread; constructor( @@ -326,22 +409,22 @@ export class WebBrowser { if (response.statusCode === 200 && response.contentType?.toLowerCase() === 'text/html' && response.body !== undefined) { this.images = imageSources(response.body); } - this.shown = { statusCode: response.statusCode, contentType: response.contentType, body: response.body, images: new Map() }; - this.log.write({ kind: 'page', statusCode: response.statusCode, contentType: response.contentType, body: response.body }); + const page = { host: this.hostValue ?? '', path, statusCode: response.statusCode, contentType: response.contentType, body: response.body }; + this.shown = { ...page, images: new Map() }; + this.log.write({ kind: 'page', ...page }); } - /** WebBrowser.retrieveImages: each over the same connection, as long as it lasts. */ + /** WebBrowser.retrieveImages: each over the same connection, as long as it lasts, from the page's folder. */ private *retrieveImages(): Steps { + const pagePath = this.shown?.path ?? '/'; for (const source of this.images) { if (!this.isConnected) return; - const response = yield* this.exchange(source, undefined); + const response = yield* this.exchange(pageReference(pagePath, source), undefined); if (response === undefined) continue; const isImage = response.contentType !== undefined && IMAGE_TYPES.has(response.contentType.toLowerCase()); - if (response.statusCode === 200 && isImage) { - const data = javaBase64Decode(response.body ?? ''); - if (data !== undefined) this.shown?.images.set(source, data); - } - this.log.write({ kind: 'image', source, statusCode: response.statusCode, contentType: response.contentType }); + const data = response.statusCode === 200 && isImage ? javaBase64Decode(response.body ?? '') : undefined; + if (data !== undefined) this.shown?.images.set(source, data); + this.log.write({ kind: 'image', source, statusCode: response.statusCode, contentType: response.contentType, ...(data === undefined ? {} : { data }) }); } } diff --git a/packages/core/test/web.test.ts b/packages/core/test/web.test.ts index 6d0da2e..138c35f 100644 --- a/packages/core/test/web.test.ts +++ b/packages/core/test/web.test.ts @@ -10,6 +10,8 @@ import { formatHttpResponse, imageSources, javaBase64, + linkTarget, + pageReference, javaBase64Decode, parseHttpRequest, parseHttpResponse, @@ -80,6 +82,29 @@ describe("the browser's address line (erzeugeURL)", () => { }); }); +describe("a page's references", () => { + it('count from the page\'s folder, written as Java writes them for a page in the root (Java counts from the root)', () => { + expect(pageReference('/', 'bild.png')).toBe('bild.png'); + expect(pageReference('/seite.html?x=1/2', 'unter/bild.png')).toBe('unter/bild.png'); + expect(pageReference('/ordner/seite.html', 'bild.png')).toBe('ordner/bild.png'); + expect(pageReference('/vhost1/', '../bild.png')).toBe('vhost1/../bild.png'); + expect(pageReference('ohne/schraegstrich.html', 'a.png')).toBe('ohne/a.png'); + expect(pageReference('/ordner/seite.html', '/bild.png')).toBe('/bild.png'); + expect(pageReference('/ordner/seite.html', 'http://h/x.png')).toBe('http://h/x.png'); + }); + + it('lead a link to the page\'s host, an http address to its own, and nowhere for a mark or another scheme', () => { + const page = { host: 'www.filius.de', path: '/ordner/seite.html' }; + expect(linkTarget(page, 'vier.html')).toEqual({ host: 'www.filius.de', path: '/ordner/vier.html' }); + expect(linkTarget(page, ' /text.txt ')).toEqual({ host: 'www.filius.de', path: '/text.txt' }); + expect(linkTarget(page, 'ziel.html?a=1#oben')).toEqual({ host: 'www.filius.de', path: '/ordner/ziel.html?a=1' }); + expect(linkTarget({ host: 'h', path: '/' }, 'a.html')).toEqual({ host: 'h', path: '/a.html' }); + expect(linkTarget(page, 'HTTP://192.168.0.30/x/y#z')).toEqual({ host: '192.168.0.30', path: '/x/y' }); + expect(linkTarget(page, 'http://kein host/')).toBeUndefined(); + for (const href of ['', ' ', '#oben', 'mailto:anna@filius.de', 'javascript:alert(1)', 'ftp://h/']) expect(linkTarget(page, href), href).toBeUndefined(); + }); +}); + describe('the images of a page', () => { it('finds each img tag, in any case, its src in any quotes', () => { expect(imageSources(' xno src')).toEqual([ @@ -181,6 +206,42 @@ describe('the web server', () => { expect(ask(plain, 'GET / HTTP/1.1\nHost: h0.de')[0]).toBe('HTTP/1.1 200 OK\nContent-type: text/html\n\nStart'); }); + it('switches its virtual hosts and writes their table with the complete rows, which it reads again as written', () => { + const fileSystem = files({ '/webserver/index.html': 'Start', '/webserver/v/index.html': 'V' }); + const engine = startedEngine(network(fileSystem)); + const server = engine.webServer(SERVER)!; + expect([server.useVirtualHosts, server.virtualHostTable]).toEqual([false, [['', ''], ['', ''], ['', ''], ['', ''], ['', '']]]); + server.setVirtualHost(0, 0, 'h.de'); + // An incomplete row is not written: the folder is made, the file empty. + expect(fileSystem.textFile('/www.conf/vhosts')).toBe(''); + server.setVirtualHost(0, 1, 'v'); + server.setVirtualHost(2, 1, 'nur'); + server.setVirtualHost(4, 0, 'z.de'); + server.setVirtualHost(4, 1, 'w'); + server.setVirtualHost(5, 0, 'nein.de'); + expect(server.virtualHostTable).toEqual([['h.de', 'v'], ['', ''], ['', 'nur'], ['', ''], ['z.de', 'w']]); + expect(fileSystem.textFile('/www.conf/vhosts')).toBe('h.de\nv\nz.de\nw\n'); + // Off, the table counts for nothing; on, from the next request. + expect(ask(engine, 'GET / HTTP/1.1\nHost: h.de')[0]!.endsWith('\n\nStart')).toBe(true); + server.setUseVirtualHosts(true); + expect(ask(engine, 'GET / HTTP/1.1\nHost: H.DE', 'GET / HTTP/1.1\nHost: x.de')[0]!.endsWith('\n\nV')).toBe(true); + const again = startedEngine(network(fileSystem)).webServer(SERVER)!; + expect([again.useVirtualHosts, again.virtualHostTable]).toEqual([true, [['h.de', 'v'], ['z.de', 'w'], ['', ''], ['', ''], ['', '']]]); + }); + + it('keeps its table where /www.conf is a file and the vhosts cannot be written', () => { + const fileSystem = files({ '/www.conf': 'kein Ordner' }); + const server = startedEngine(network(fileSystem)).webServer(SERVER)!; + server.setVirtualHost(0, 0, 'h.de'); + server.setVirtualHost(0, 1, 'v'); + expect(server.virtualHostTable[0]).toEqual(['h.de', 'v']); + expect(fileSystem.textFile('/www.conf')).toBe('kein Ordner'); + // Without files at all, the table holds too. + const bare = startedEngine({ ...network(new VirtualFileSystem()), fileSystemsByNodeId: new Map() }).webServer(SERVER)!; + bare.setVirtualHost(1, 1, 'x'); + expect(bare.virtualHostTable[1]).toEqual(['', 'x']); + }); + it('serves nothing without files', () => { const engine = startedEngine({ ...network(new VirtualFileSystem()), fileSystemsByNodeId: new Map() }); expect(ask(engine, 'GET / HTTP/1.1')).toEqual(['HTTP/1.1 404 Not Found\n']); @@ -206,6 +267,24 @@ describe('the web browser', () => { expect(browser.page!.images.size).toBe(0); }); + it("asks for a page's images from the page's folder and keeps them by their src, with the page's host and path", () => { + const fileSystem = files({ '/webserver/ordner/seite.html': '' }); + fileSystem.writeImageFile('/webserver/ordner/a.png', new Uint8Array([1]), 'image/png'); + fileSystem.writeImageFile('/webserver/b.png', new Uint8Array([2]), 'image/png'); + const engine = startedEngine(network(fileSystem)); + const browser = engine.webBrowser(CLIENT)!; + browser.open('10.0.0.1/ordner/seite.html?x=1'); + engine.handle({ type: 'advance', toMilliseconds: 10_000 }); + expect(browser.page).toMatchObject({ host: '10.0.0.1', path: '/ordner/seite.html?x=1' }); + expect([...browser.page!.images]).toEqual([['a.png', new Uint8Array([1])], ['/b.png', new Uint8Array([2])]]); + const lines = browser.log.all.map(({ timeMilliseconds: _, ...line }) => line); + expect(lines).toEqual([ + { kind: 'page', host: '10.0.0.1', path: '/ordner/seite.html?x=1', statusCode: 200, contentType: 'text/html', body: '' }, + { kind: 'image', source: 'a.png', statusCode: 200, contentType: 'image/png', data: new Uint8Array([1]) }, + { kind: 'image', source: '/b.png', statusCode: 200, contentType: 'image/png', data: new Uint8Array([2]) }, + ]); + }); + it('fetches images only for an HTML page', () => { const engine = startedEngine(network(files({ '/webserver/text.txt': '' }))); const browser = engine.webBrowser(CLIENT)!; diff --git a/packages/editor/browser/fixtures/web-pages.fls b/packages/editor/browser/fixtures/web-pages.fls new file mode 100644 index 0000000000000000000000000000000000000000..340ff915f1d6acd38a79ac4c6724d90de1444d01 GIT binary patch literal 26604 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 literal 0 HcmV?d00001 diff --git a/packages/editor/browser/web.spec.ts b/packages/editor/browser/web.spec.ts new file mode 100644 index 0000000..1cd774a --- /dev/null +++ b/packages/editor/browser/web.spec.ts @@ -0,0 +1,205 @@ +// The web browser and the web server on a computer's desktop in Chromium +// (F4.6d), on browser/fixtures/web-pages.fls (test/webPagesFixture.test.ts): +// Java's address line and title, the page in a sandbox that runs no script +// and loads nothing but the images that came, links and forms handed to the +// room as Java's window hands them to its program, the server's button, +// virtual hosts and lines, the routers' pages in the language FILIUS runs in. + +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { expect, test, type Page } from '@playwright/test'; +import { drawing, onDevice, serveEditor } from './editorPage.js'; + +let server: { readonly base: string; readonly close: () => Promise }; + +test.beforeAll(async () => { + server = await serveEditor(); +}); + +test.afterAll(async () => { + await server.close(); +}); + +const FIXTURE = join(dirname(fileURLToPath(import.meta.url)), 'fixtures/web-pages.fls'); + +async function openPages(page: Page, query = ''): Promise { + await page.goto(`${server.base}?example=new${query}`); + await page.waitForSelector('svg.filius-canvas'); + await page.locator('input[type="file"]').setInputFiles(FIXTURE); + await expect.poll(async () => (await drawing(page).catch(() => ({ nodes: [] }))).nodes.length).toBe(4); +} + +async function start(page: Page, name = 'Wechselt in den Aktionsmodus'): Promise { + await page.getByRole('button', { name }).click(); + await expect(page.locator('svg.filius-canvas')).toHaveAttribute('data-mode', 'simulation'); +} + +/** Opens a computer's desktop with a tap and a program's window on it. */ +async function openProgram(page: Page, name: string, program: string) { + const id = (await drawing(page)).nodes.find((node) => node.name === name)!.id; + const at = await onDevice(page, id); + await page.mouse.click(at.x, at.y); + const desktop = page.locator(`section[data-desktop="${id}"]`); + await desktop.locator(`[data-program="${program}"]`).click(); + const window = desktop.locator(`[data-program-window="${program}"]`); + return { desktop, window, title: window.locator('.program-titlebar h3') }; +} + +test("browses as Java's browser does: the address line, the page's title, links and forms to the server, the page in a sandbox", async ({ page }) => { + test.setTimeout(120_000); + await openPages(page); + await start(page); + const { desktop, window, title } = await openProgram(page, 'Browser', 'webBrowser'); + const address = window.getByRole('textbox', { name: 'Adresse' }); + const frame = window.frameLocator('iframe[title="Seite"]'); + // Java's start: the program's name, "http://", its browser's logo. + await expect(title).toHaveText('Webbrowser'); + await expect(address).toHaveValue('http://'); + await expect(window.locator('.browser-start img')).toBeVisible(); + + await address.fill('192.168.0.20'); + await address.press('Enter'); + await expect(address).toHaveValue('http://192.168.0.20/'); + await expect(title).toHaveText('Seiten & Formulare', { timeout: 30_000 }); + await expect(frame.locator('h1')).toHaveText('Start'); + // The image that came shows from its data, the missing one does not load; no script ran, nor a handler. + await expect(frame.locator('img[alt="Bild"]')).toHaveAttribute('src', /^data:image\/png;base64,iVBOR/, { timeout: 30_000 }); + await expect(frame.locator('img[alt="Fehlt"]')).not.toHaveAttribute('src', /./); + await expect(frame.locator('script')).toHaveCount(0); + expect(await frame.locator('body').getAttribute('data-ran')).toBeNull(); + expect(await frame.locator('body').getAttribute('onload')).toBeNull(); + await expect(frame.locator('meta[http-equiv="Content-Security-Policy"]')).toHaveAttribute('content', /default-src 'none'; img-src data:/); + await expect(window.locator('iframe')).toHaveAttribute('sandbox', 'allow-same-origin allow-forms'); + + // A mark in the page stays in it; a link goes to the server. + await frame.getByRole('link', { name: 'Unten' }).click(); + await expect(frame.locator('#unten')).toBeInViewport(); + await expect(address).toHaveValue('http://192.168.0.20/'); + await frame.getByRole('link', { name: 'Drei' }).click(); + await expect(title).toHaveText('Drei', { timeout: 30_000 }); + await expect(address).toHaveValue('http://192.168.0.20/ordner/drei.html'); + // A link of a page in a folder goes beside it (Java's from the server's root). + await frame.getByRole('link', { name: 'Vier' }).click(); + await expect(title).toHaveText('Vier im Ordner', { timeout: 30_000 }); + await expect(address).toHaveValue('http://192.168.0.20/ordner/vier.html'); + + // The forms: the POST one sends its fields as Java's window sends them. + await address.fill('http://192.168.0.20/'); + await window.getByRole('button', { name: 'Start' }).click(); + await expect(title).toHaveText('Seiten & Formulare', { timeout: 30_000 }); + await frame.getByRole('button', { name: 'Gut' }).click(); + await expect(title).toHaveText('Ziel', { timeout: 30_000 }); + await expect(address).toHaveValue('http://192.168.0.20/ziel.html'); + await address.fill('192.168.0.20'); + await address.press('Enter'); + await expect(title).toHaveText('Seiten & Formulare', { timeout: 30_000 }); + // The GET one's data stays in the address, which Java's browser does not send (nor show). + await frame.getByRole('button', { name: 'Los' }).click(); + await expect(title).toHaveText('Ziel', { timeout: 30_000 }); + await expect(address).toHaveValue('http://192.168.0.20/ziel.html'); + + // An error: the browser's page for its status (Java's never finds its template). + await address.fill('192.168.0.20/nichts.html'); + await address.press('Enter'); + await expect(title).toHaveText('Fehler Not Found (404)', { timeout: 30_000 }); + await expect(frame.locator('h1')).toHaveText('Not Found'); + await expect(frame.locator('p')).toHaveText('Es ist ein Fehler aufgetreten (HTTP-Status: 404)'); + // No address: the line goes back to "http://", the page stays. + await address.fill('kein host!'); + await address.press('Enter'); + await expect(address).toHaveValue('http://'); + await expect(title).toHaveText('Fehler Not Found (404)'); + + // The server got the forms as Java's browser sends them. + await desktop.getByRole('button', { name: 'Schließen' }).click(); + const web = await openProgram(page, 'Server', 'webServer'); + const log = web.window.getByLabel('Meldungen'); + await expect(log).toContainText('>>POST /ziel.html HTTP/1.1'); + await expect(log).toContainText('name=Fil+ius&c=1&s=y&t=%C3%BC%26%3D&ok=Gut'); + await expect(log).toContainText('>>GET /ziel.html HTTP/1.1'); + await expect(log).not.toContainText('q=a+b'); + await expect(log).toContainText('Verbindung zu 192.168.0.10:'); +}); + +test("starts and stops the web server, and edits its virtual hosts, as Java's window does", async ({ page }) => { + test.setTimeout(120_000); + await openPages(page); + await start(page); + const { desktop, window, title } = await openProgram(page, 'Server', 'webServer'); + await expect(title).toHaveText('Webserver'); + const button = window.getByRole('button', { name: 'Anhalten' }); + await expect(button).toBeVisible(); + const box = window.getByRole('checkbox', { name: 'Verwende virtuelle Hosts' }); + await expect(box).not.toBeChecked(); + await expect(window.getByRole('table')).toHaveCount(0); + await button.click(); + await expect(window.getByRole('button', { name: 'Starten' })).toBeVisible(); + await expect(window.getByLabel('Meldungen')).toContainText('Annahme von Verbindungsanfragen beendet'); + await expect(window.getByLabel('Meldungen')).toContainText('Server-Socket geschlossen'); + + // The virtual hosts' table: five rows, a cell written when it is left. + // The box shows what the server holds: ticked once the room answers. + await box.click(); + await expect(box).toBeChecked(); + const table = window.getByRole('table', { name: 'Virtuelle Hosts:' }); + await expect(table.getByRole('columnheader')).toHaveText(['Hostname', 'Unterverzeichnis']); + await expect(table.getByRole('textbox')).toHaveCount(10); + await table.getByRole('textbox', { name: 'Hostname 1' }).fill('www.filius.de'); + await table.getByRole('textbox', { name: 'Unterverzeichnis 1' }).fill('ordner'); + await table.getByRole('textbox', { name: 'Unterverzeichnis 1' }).press('Enter'); + // Escape goes back to what the server holds: what Enter wrote. + await table.getByRole('textbox', { name: 'Unterverzeichnis 1' }).press('Escape'); + await expect(table.getByRole('textbox', { name: 'Unterverzeichnis 1' })).toHaveValue('ordner'); + await table.getByRole('textbox', { name: 'Hostname 2' }).fill('weg'); + await table.getByRole('textbox', { name: 'Hostname 2' }).press('Escape'); + await expect(table.getByRole('textbox', { name: 'Hostname 2' })).toHaveValue(''); + // Left and opened again, the window shows what the server holds. + await desktop.getByRole('button', { name: 'Startbildschirm' }).click(); + await desktop.locator('[data-program="webServer"]').click(); + await expect(table.getByRole('textbox', { name: 'Hostname 1' })).toHaveValue('www.filius.de'); + await expect(table.getByRole('textbox', { name: 'Unterverzeichnis 1' })).toHaveValue('ordner'); + await window.getByRole('button', { name: 'Starten' }).click(); + await expect(window.getByLabel('Meldungen')).toContainText('Annahme von Verbindungsanfragen gestartet'); + + // Asked for by its address, which no row names, the server gives the root's pages. + await desktop.getByRole('button', { name: 'Schließen' }).click(); + const browser = await openProgram(page, 'Browser', 'webBrowser'); + const address = browser.window.getByRole('textbox', { name: 'Adresse' }); + await address.fill('192.168.0.20'); + await address.press('Enter'); + await expect(browser.title).toHaveText('Seiten & Formulare', { timeout: 30_000 }); +}); + +test("shows the server it cannot reach as Java's browser does", async ({ page }) => { + test.setTimeout(120_000); + await openPages(page); + await start(page); + const web = await openProgram(page, 'Server', 'webServer'); + await web.window.getByRole('button', { name: 'Anhalten' }).click(); + await web.desktop.getByRole('button', { name: 'Schließen' }).click(); + const { window, title } = await openProgram(page, 'Browser', 'webBrowser'); + const address = window.getByRole('textbox', { name: 'Adresse' }); + await address.fill('192.168.0.20'); + await address.press('Enter'); + await expect(title).toHaveText('http://192.168.0.20/'); + await expect(title).toHaveText('Fehler', { timeout: 60_000 }); + await expect(window.frameLocator('iframe').locator('h1')).toHaveText('Server konnte nicht erreicht werden!'); +}); + +test("shows a router's pages in English when FILIUS speaks English", async ({ page }) => { + test.setTimeout(120_000); + await openPages(page, '&lang=en'); + await start(page, 'Change to Simulation Mode'); + const { window, title } = await openProgram(page, 'Browser', 'webBrowser'); + await expect(title).toHaveText('Webbrowser'); + const address = window.getByRole('textbox', { name: 'Address' }); + await address.fill('192.168.0.1'); + await address.press('Enter'); + await expect(title).toHaveText('Router Administration', { timeout: 30_000 }); + await window.frameLocator('iframe').getByRole('link', { name: 'Show current routing/forwarding table' }).click(); + await expect(title).toHaveText('Routing/Forwarding Table', { timeout: 30_000 }); + await expect(address).toHaveValue('http://192.168.0.1/routes'); + await address.fill('192.168.0.20/nichts.html'); + await address.press('Enter'); + await expect(title).toHaveText('Error Not Found (404)', { timeout: 30_000 }); +}); diff --git a/packages/editor/src/Editor.tsx b/packages/editor/src/Editor.tsx index 2c6a273..f19342c 100644 --- a/packages/editor/src/Editor.tsx +++ b/packages/editor/src/Editor.tsx @@ -20,7 +20,7 @@ import { Palette, type PaletteKind } from './design/Palette.js'; import { RouterDialog } from './design/RouterDialog.js'; import { DeviceSheet } from './sheet/DeviceSheet.js'; import { DesktopWindow, type DesktopWindowName } from './desktop/DesktopWindow.js'; -import { useTranslate } from './i18n/i18n.js'; +import { useLanguage, useTranslate } from './i18n/i18n.js'; import { ModeBar } from './simulation/ModeBar.js'; import { useSimulation, type SimHostFactory } from './simulation/useSimulation.js'; @@ -90,6 +90,7 @@ const toFront = (open: readonly OpenDesktop[], id: string): readonly OpenDesktop export function Editor({ doc, createSimHost }: { readonly doc: Y.Doc; readonly createSimHost?: SimHostFactory }) { const t = useTranslate(); + const language = useLanguage(); const view = useTopologyView(doc); const simulation = useSimulation(doc, createSimHost); const simulating = simulation.mode === 'simulation'; @@ -285,7 +286,7 @@ export function Editor({ doc, createSimHost }: { readonly doc: Y.Doc; readonly c }} />
- (mode === 'simulation' ? simulation.start() : simulation.stop())} onSpeed={simulation.setSpeed} /> + (mode === 'simulation' ? simulation.start(language) : simulation.stop())} onSpeed={simulation.setSpeed} /> {simulating ? null : ( <> + +
+ {document === undefined ? ( +
+ +
+ ) : ( +