diff --git a/CHANGES.md b/CHANGES.md index ffad4e0..7e6f4e9 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -15,6 +15,7 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand | `src/runtime/config.ts` | `State/TopologyEditorState.swift` (Laufzeit-Konfigurationen, WLAN eines Rechners, DHCP, Portweiterleitung, Remote Link ohne LAN-Transport, Programme und ihre Konfiguration, Firewall, DNS-Einträge aus `State/TopologyDNSResolver.swift`, E-Mail-Konten und -Nachrichten aus `State/TopologyEmail.swift`); dazu Echo-Server- und Client-Konfiguration, die Swift nicht speichert | übertragen; Java-Beschriftungen der Oberfläche weggelassen | | `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/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) | @@ -81,6 +82,7 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand | `src/zip.ts` | `Persistence/TopologyFLSArchive.swift` (Fehler, Grenzen, Pfadregeln, ZIP-Leser/-Schreiber, CRC-32) | übertragen; DEFLATE über `fflate`, mit Apples Abschneide-Verhalten nachgebildet; Pfadfaltung (NFC, Breite, volles Case-Folding) an 41 Paaren gegen das Swift-Orakel gemessen | | `src/archive.ts` | ebenda, Archiv-Teil von Import und Export | übertragen, ohne die XML-Ebene | | `src/javabeans.ts`, `src/javaDefaults.json` | — | neu: XMLEncoder-Leser nach den Regeln von `java.beans.XMLDecoder` (Swift liest elementweise und verliert geteilte Werte); Getter-Standardwerte und statische Konstanten mit `tools/java-oracle` gemessen; merkt sich den Java-Typ jedes Primitivs an Settern, Konstruktor- und Aufrufargumenten und welche Eigenschaften ein eigener Getter angelegt hat (für `beanCapture.ts`) | +| `src/javaDefaults.json` (`#priority`) | Java `Thread.getPriority` (gemessen mit `GetterDefaults`) | nachgetragen (F4.6c): die Priorität der gespeicherten Thread-Klassen (5); Javas Datei kann eine Zahl über einen solchen Getter-Wert benennen (`idref`), unaufgelöst las der Import 0 | | `src/importProject.ts` | `Persistence/TopologyProjectStore.swift` (Knoten, Ports, Kabel, IP-Konfiguration, Switch/WLAN, Routen, RIP, Firewall, DHCP, Portweiterleitung, Modem, Dateisysteme, Programme, Doku-Elemente des Imports) | übertragen, Eingabe ist Javas Sicht: Name und Port-Anzahl wie Java, abgelehnte Adresse → Swift-Standard, Router-Standardgateway wie Java (Swift verliert es), jede Router-Firewall eingetragen, Konfigurationstexte wie in Swift getrimmt (`swiftTrim`), Rechner ohne Dateisystem leer wie in Java (Swift: Demo-Dateien), alle Programme bleiben (Swift verwirft sechs), Webserver-Dateien bleiben in `/webserver` (Swift: `/www`), keine versteckten Zustandsdateien im Dateisystem, DNS-Einträge so, wie Javas Server sie aus `/dns/hosts` beantwortet, WLAN eines Rechners eingeschaltet nach Javas Funk-Merkmal der Karte (auch ohne SSID; Swift: jede SSID); der Webserver behält Javas Schalter für virtuelle Hosts (Swift: aus); gegen Swifts importiertes Modell auf 13 Dateien geprüft; seit der Abnahme F2 die IP-Weiterleitung eines Knotens, wo sie vom Standard seiner Art abweicht (`ipForwardingByNodeId`; Java `ipForwardingEnabled`, übersehen bis das Firewall-Beispiel lief) | | `test/importProject.test.ts` | — | neu: Swifts importiertes Modell (13 Dateien, begründete Abweichungen; im Dateisystem ausgenommen nur Swifts versteckte Zustandsdateien), Vollständigkeit aller 23, Pings im Kern (über einen Router; über drei Router mit manuellen Routen und Router-Gateways; seit F2.7 über Funkkabel); seit der Abnahme F2 IP-Weiterleitung und eingeschaltete DNS-Server im Schnappschuss; seit F3.2c eine Firewall, über `konfig` im Lauf geändert, gespeichert und wieder geöffnet (`internetwork_komplett.fls`); seit F3.3a eine Mail, im Lauf per SMTP angenommen, gespeichert und wieder geöffnet (`email_komplett.fls`); seit F3.3b eine Mail vom Programm über den MX an die andere Domain, abgeholt, gespeichert und wieder geöffnet; seit F3.4a Ordner und Datei aus der Befehlszeile, gespeichert und wieder geöffnet | | `src/systemSoftware.ts` | Java `SwitchFirmware`, `Betriebssystem`, `VermittlungsrechnerBetriebssystem`, `GatewayFirmware`, `Weiterleitungstabelle`, `Firewall`, `FirewallRule`, `DHCPServer`, `ModemFirmware`, `Dateisystem`, `Datei` | neu nach Java: SSID, Lernzeit, RIP, manuelle Routen, Router-Firewall (an allen Stellen, an denen die Datei sie erreicht), DHCP (ein kaputter statischer Eintrag fällt allein weg, Java verliert alle folgenden), statische NAT, Modem, Dateisystem (bei Router und Heimrouter das, was die Datei zu den Vorlagen des Konstruktors hinzufügt, oder eine eigene Wurzel, die sie ersetzt), installierte Programme von Rechner und Notebook (eigene Anwendungen eines Projekts als erfasste Bean, `beanCapture.ts`); `Character.isWhitespace` am JDK gemessen; gegen Java geprüft; seit der Abnahme F2 `ipForwardingEnabled` jedes Knotens mit IP-Stapel, mit Javas Standard der Klasse (Router und Heimrouter an) | @@ -131,6 +133,7 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand | `src/settings.ts`, `src/sheet.ts`, `src/commands.ts` (Verbindungen) | Java `JConnectionsDialog` (`swapConnection`, `removeConnection`, Knöpfe „+“/„-“), `Vermittlungsrechner.hinzuAnschluss/removeNic`, `NetzwerkInterface()`, `InternetKnotenBetriebssystem.setStandardGateway` | erweitert (F4.5e): eine Karte nach der letzten anfügen (192.168.0.10/24, eigene MAC aus der Port-Kennung, bis acht), die letzte samt Kabel entfernen, die Kabel zweier Karten tauschen oder eines umstecken (dieselben Kabel, dieselben Geräte; zwei freie Karten: nichts), Javas Klicklogik (`connectionsClick`: markieren, abwählen, tauschen); das Blatt nennt die Adresse der Gegenstelle (Rechner, Router; Switch und Modem ohne). Abweichend von Java: Java entfernt auch die letzte Karte — ein Router ohne Karte behält kein Gateway (Java legt es in die Karten, gemessen), und keine Datei könnte es halten; hier bleibt eine Karte (`ROUTER_INTERFACES.fewest`) | | `src/view.ts` (`nodeLabel`) | Java `Knoten.holeAnzeigeName` | herausgelöst (F4.5b): die Beschriftung eines Geräts, auch für „Verbunden mit …“ | | `src/view.ts` (`desktopView`) | Java `GUIDesktopWindow.updateTitle`, `GUIDesktopPanel.updateAppPane` | neu (F4.6a): was der Desktop eines Rechners vom Dokument braucht — Art, Beschriftung, ob sie die Adresse ist (Javas Titel lässt die Adresse dann weg), installierte Programme | +| `src/software.ts`, `test/install.test.ts`, `test/fixtures/installed-programs.fls` | Java `GUIInstallationsDialog.aenderungenSpeichern` (gemessen mit `InstallRun`), `config/Desktop_*.txt` | neu (F4.6c): `setSoftware` — installieren (aus, Javas Voreinstellungen, fehlende Dateien; DNS-Einträge, E-Mail-Konten und das Konto des E-Mail-Programms aus vorhandenen Dateien) und entfernen (mit Einstellungen, Dateien bleiben) in einem Schritt, in der Reihenfolge von Javas Liste der Sprache (`SOFTWARE_ORDER`). Gegen Javas gespeicherte Installation geprüft; benannt: Javas Gnutella würfelt 3–5 Teilnehmer, hier 3 wie in einer Datei ohne Angabe | | `test/sheet.test.ts`, `test/sheet.cases.txt`, `test/sheet.java.json` | — | neu (F4.5a): 77 in Javas Felder getippte Texte, jede Regel dagegen samt der benannten Abweichungen; die Befehle, die Funkverbindungen, Rückgängig je Änderung, alles über eine gespeicherte `.fls` | | `test/devices.test.ts`, `test/fixtures/new-devices.fls` | — | neu (F4.4a): neue Geräte jeder Art gegen Javas gespeicherte (Zufall maskiert), Kabelregeln, ein selbst gebautes Netz pingt im Kern, Rückgängig nur der eigenen Änderungen über zwei Replikate | | `test/projectDoc.test.ts` | — | neu (F4.1): alle 23 Korpusdateien → Dokument → Yjs-Update → Projekt: dasselbe Projekt, derselbe Schnappschuss, dieselbe `.fls`, dieselbe Reihenfolge je Ordner; Kennungen absichtlich gegen die Dateireihenfolge | @@ -155,6 +158,7 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand | `src/room.ts` (`node`) | Plan §6.4 | erweitert (F4.6a): der Raum schickt einem Abonnenten die Netzwerkwerte eines Geräts beim Abonnieren und wenn sie sich ändern, nichts vor dem ersten Start | | `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 | | `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 @@ -180,8 +184,9 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand | `src/simulation/useSimulation.ts`, `src/simulation/ModeBar.tsx`, `src/assets/modes/*.png` | Java `GUIMainMenu.selectMode/updateLatency` (`guimainmemu_msg3`/`4`/`15`/`16`), Javas Modussymbole (`gfx/allgemein`) | neu (F4.6a): der Raum im Worker der Seite (`LocalSimHost` über `?worker`), Javas Moduswahl mit Javas Symbolen, im Aktionsmodus der Geschwindigkeitsregler mit Prozent (90 % zu Beginn wie Java); Javas Tasten Strg+D/Strg+R nicht, sie legen im Browser ein Lesezeichen an und laden neu | | `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/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 | +| `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) | | `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/index.ts b/packages/core/src/index.ts index df99bf0..c2ebe25 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -19,6 +19,7 @@ export * from './runtime/capture.js'; export * from './runtime/captureRows.js'; export * from './runtime/threads.js'; export * from './runtime/apps/program.js'; +export * from './runtime/apps/installation.js'; export * from './runtime/apps/server.js'; export * from './runtime/apps/echo.js'; export * from './runtime/apps/web.js'; diff --git a/packages/core/src/runtime/apps/installation.ts b/packages/core/src/runtime/apps/installation.ts new file mode 100644 index 0000000..7da03b3 --- /dev/null +++ b/packages/core/src/runtime/apps/installation.ts @@ -0,0 +1,122 @@ +// What FILIUS' programs bring into a computer's files when the software +// installation installs them (measured with tools/java-oracle's InstallRun): +// a web server its folder /webserver with Java's start page and picture +// (WebServer.erzeugeStandardVerzeichnis, the page in the language FILIUS +// runs in), Gnutella its folder /peer2peer (PeerToPeerAnwendung), an e-mail +// server /mailserver with an empty konten.txt (EmailServer), a DNS server +// /dns with an empty hosts (DNSServer). Each is made only where nothing of +// the name is: a file left by a program removed before stays as it is, and +// the program reads it (a DNS server its records, an e-mail server its +// accounts). Removing a program leaves its files. + +import type { InstallableProgram } from '../config.js'; +import type { VirtualFileContent } from '../../model/virtualFileSystem.js'; +import { javaBase64Decode } from '../../util/javaBase64.js'; +import type { PageLanguage } from './routerPages.js'; + +export interface InstallationFile { + readonly path: string; + readonly content: VirtualFileContent; +} + +/** tmpl/webserver_index_.txt as Java reads it: each line ended by "\n". */ +const WEB_SERVER_INDEX: Record = { + de: "\n \n Standardseite\n \n \n

FILIUS - Webserver

\n\n

Herzlich Willkommen auf dem Webserver der Anwendung FILIUS!

\n\n

Diese Seite wurde automatisch mit der Installation des \n Webservers eingerichtet, es lassen sich jedoch auch \n eigene Seiten hier unterbringen.

\n\n

\n\n

https://www.lernsoftware-filius.de

\n \n\n", + en: "\n \n Standardseite\n \n \n

FILIUS - Webserver

\n\n

Welcome at FILIUS!

\n\n

This page has been delivered as default page.\n Feel free to add your own contents!

\n\n

\n\n

https://www.lernsoftware-filius.de

\n \n\n", +}; + +/** gfx/allgemein/splashscreen-mini.png, the picture on the web server's start page. */ +const SPLASH_PNG = + "iVBORw0KGgoAAAANSUhEUgAAAIAAAABgCAYAAADVenpJAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAgY0hSTQAAeiYAAICEAAD6AAAAgOgAAHUw" + + "AADqYAAAOpgAABdwnLpRPAAAAB90RVh0U29mdHdhcmUATWFjcm9tZWRpYSBGaXJld29ya3MgOLVo0ngAABebSURBVHhe7Z13sFRFFsYvWorigjxWBNct1/3D" + + "UmuroAxlSWHABVRETCgiKKJEwUTSV4gYQAQREAXEQBITKGYUMbEqggkzSRRFBcWAGZBwdn6N39iO83jzZu69b3jcruq6M307nvP1Oafjrfb6669bkLjtlwJv" + + "vvmmJX77pUHwxhtvWBheIAojr209D2iBZMVna0sx0SoIq/fT2FdeeSWRJr8zf9myZbZkyRIHgEwaz58/31599dWioFWwYMECK9TTwEWLFhmO37nkN2/evJzj" + + "5pJfrnFee+01R/xc4+cTDwZv2LDBfvjhh7+URdm//vqrffvtt0Zd8sk/zDShAACUg3YBABD4og+CICGoOO/eeustF/edd95Jh/OOOH5cNZRw0kEw5aMwpaE8" + + "nzD8Jy/SCJQQ/5tvvrHvv//eSSviKD/Vzf+vNvj1UP18oCseaSmDOOvWrbPvvvsuXb5f308++cQ+/PDDtHTw2yfJ4LfFp4vihgWCAGYU6qmMAMBvCPLFF184" + + "qUBj169fb1999ZUjDgxZs2aNAwDMIJ6YRRx6zurVq1086sWT/++//77rUcRBenz99df27rvvuncQG5FLPqSBAR9//LErF2aLWNRn8+bNtnHjRvvyyy9dGtKL" + + "mYsXL3b5kl5t+uyzz9JAIe5vv/3meq/y9NtKnpT50ksvuacAANDJl/JgIHl+9NFHLl/aRb7kR/t++eUXe++999x/tYV8ocuqVascvZYuXZp+XyjvgrffftsK" + + "9T4AqBCMhgByn376qfu5YsUK9w7G42AmYfRGmEgjpUpgHATinRxhMBYCy/3000+OITjKJg154sgLguJefvll958yYCKEhPE4mAGoeIcDWCqXuuu36rd27VoX" + + "j7L8tvJ+5cqVNnfu3DQAXnzxRRd306ZNaYlEPNpCWkAnB4NRDzjqw3vogqODiW4AgroXyjfShyIBqIwkgFBLxelpEEMMg+j0AAgsQvNfKgGxSKXEQAgv4kMc" + + "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" + + "TkSuQmCC"; + +const folder = (path: string): InstallationFile => ({ path, content: { kind: 'directory' } }); +const text = (path: string, value: string): InstallationFile => ({ path, content: { kind: 'text', text: value } }); + +/** The folders and files a program makes when it is installed, in the order Java makes them. */ +export function installationFiles(program: InstallableProgram, language: PageLanguage): readonly InstallationFile[] { + switch (program) { + case 'webServer': + return [ + folder('/webserver'), + text('/webserver/index.html', WEB_SERVER_INDEX[language]), + { path: '/webserver/splashscreen-mini.png', content: { kind: 'image', data: javaBase64Decode(SPLASH_PNG)!, mediaType: 'image/png' } }, + ]; + case 'gnutella': + return [folder('/peer2peer')]; + case 'emailServer': + return [folder('/mailserver'), text('/mailserver/konten.txt', '')]; + case 'dnsServer': + return [folder('/dns'), text('/dns/hosts', '')]; + default: + return []; + } +} diff --git a/packages/core/src/runtime/engine.ts b/packages/core/src/runtime/engine.ts index fba974f..380fedf 100644 --- a/packages/core/src/runtime/engine.ts +++ b/packages/core/src/runtime/engine.ts @@ -76,6 +76,7 @@ import { FIREWALL_PROTOCOL, firewallConfiguration, interfaceKey, + type InstallableProgram, } from './config.js'; import { formatIPv4, maskAddress, parseIPv4, sameSubnet } from './ipv4.js'; import { javaIpCheck, javaParseInt } from '../util/javaIp.js'; @@ -112,6 +113,7 @@ import { ICMP_TYPE_CODE } from './captureRows.js'; import { JavaCompatibleRandom } from './random.js'; import { routeTableRows, type RouteRow } from './routeTable.js'; import { EMPTY_TOPOLOGY_SNAPSHOT, withSnapshot, type NodeSnapshot, type TopologySnapshot } from './snapshot.js'; +import { installationFiles } from './apps/installation.js'; import { RuntimeSpeed } from './speed.js'; import { ApplicationThreads, waitUntil, type Steps, type Wait } from './threads.js'; import { EchoServer, SimpleClient } from './apps/echo.js'; @@ -874,6 +876,10 @@ function getOrCreate(map: Map, key: K, create: () => V): V { // ── Engine ───────────────────────────────────────────────────────────────── +/** The programs the engine runs (the others — file explorer, image viewer, text editor — are the editor's), in the order a start makes them. */ +const RUNNING_PROGRAMS = ['echoServer', 'simpleClient', 'webServer', 'webBrowser', 'emailServer', 'commandPrompt', 'gnutella', 'emailClient'] as const; +type RunningProgram = (typeof RUNNING_PROGRAMS)[number]; + export class NetworkRuntimeEngine { private stateValue: RuntimeState; private random: JavaCompatibleRandom; @@ -1247,18 +1253,52 @@ export class NetworkRuntimeEngine { this.terminalsByNodeId.clear(); this.gnutellaPeersByNodeId.clear(); for (const node of snapshot.nodes) { - const echoServer = snapshot.echoServersByNodeId.get(node.id); - if (echoServer !== undefined) this.echoServersByNodeId.set(node.id, new EchoServer(this, node.id, echoServer)); - const simpleClient = snapshot.simpleClientsByNodeId.get(node.id); - if (simpleClient !== undefined) this.simpleClientsByNodeId.set(node.id, new SimpleClient(this, node.id, simpleClient)); - const webServer = snapshot.webServersByNodeId.get(node.id); - if (webServer !== undefined) { - this.webServersByNodeId.set(node.id, new WebServer(this, node.id, webServer, snapshot.fileSystemsByNodeId.get(node.id))); - } - if (snapshot.webBrowserNodeIds.has(node.id)) this.webBrowsersByNodeId.set(node.id, new WebBrowser(this, node.id)); - const emailServer = snapshot.emailServersByNodeId.get(node.id); - if (emailServer !== undefined) { - const nodeId = node.id; + for (const program of RUNNING_PROGRAMS) if (this.hasProgram(node.id, program)) this.startProgram(node.id, program); + if ((node.kind === 'router' || node.kind === 'gateway') && !this.swiftRouters) this.startRouterWebServer(node.id, node.kind); + } + } + + /** Whether the snapshot gives a node a program the engine runs. */ + private hasProgram(nodeId: Id, program: RunningProgram): boolean { + const snapshot = this.stateValue.topologySnapshot; + switch (program) { + case 'echoServer': + return snapshot.echoServersByNodeId.has(nodeId); + case 'simpleClient': + return snapshot.simpleClientsByNodeId.has(nodeId); + case 'webServer': + return snapshot.webServersByNodeId.has(nodeId); + case 'webBrowser': + return snapshot.webBrowserNodeIds.has(nodeId); + case 'emailServer': + return snapshot.emailServersByNodeId.has(nodeId); + case 'commandPrompt': + return snapshot.terminalNodeIds.has(nodeId); + case 'gnutella': + return snapshot.gnutellaByNodeId.has(nodeId); + case 'emailClient': + return snapshot.emailClientsByNodeId.has(nodeId); + } + } + + /** Starts a node's program from the snapshot (Anwendung.starten); what it shows starts empty. */ + private startProgram(nodeId: Id, program: RunningProgram): void { + const snapshot = this.stateValue.topologySnapshot; + switch (program) { + case 'echoServer': + this.echoServersByNodeId.set(nodeId, new EchoServer(this, nodeId, snapshot.echoServersByNodeId.get(nodeId)!)); + return; + case 'simpleClient': + this.simpleClientsByNodeId.set(nodeId, new SimpleClient(this, nodeId, snapshot.simpleClientsByNodeId.get(nodeId)!)); + return; + case 'webServer': + this.webServersByNodeId.set(nodeId, new WebServer(this, nodeId, snapshot.webServersByNodeId.get(nodeId)!, snapshot.fileSystemsByNodeId.get(nodeId))); + return; + case 'webBrowser': + this.webBrowsersByNodeId.set(nodeId, new WebBrowser(this, nodeId)); + return; + case 'emailServer': { + const emailServer = snapshot.emailServersByNodeId.get(nodeId)!; this.emailServersByNodeId.set( nodeId, new EmailServer( @@ -1271,24 +1311,128 @@ export class NetworkRuntimeEngine { ), ); this.writeEmailServerAccountsFile(nodeId, emailServer.domain, emailServer.accounts); + return; } - if (snapshot.terminalNodeIds.has(node.id)) this.terminalsByNodeId.set(node.id, new Terminal(this, node.id, this.nodeFiles(node.id))); - const gnutella = snapshot.gnutellaByNodeId.get(node.id); - if (gnutella !== undefined) { + case 'commandPrompt': + this.terminalsByNodeId.set(nodeId, new Terminal(this, nodeId, this.nodeFiles(nodeId))); + return; + case 'gnutella': { // PeerToPeerAnwendung.setSystemSoftware: the folder of the files to share. - const files = this.nodeFiles(node.id); + const files = this.nodeFiles(nodeId); if (!files.contains(GNUTELLA_FOLDER)) files.createDirectory(GNUTELLA_FOLDER); - this.gnutellaPeersByNodeId.set(node.id, new GnutellaPeer(this, node.id, gnutella)); + this.gnutellaPeersByNodeId.set(nodeId, new GnutellaPeer(this, nodeId, snapshot.gnutellaByNodeId.get(nodeId)!)); + return; } - const emailClient = snapshot.emailClientsByNodeId.get(node.id); - if (emailClient !== undefined) { - const nodeId = node.id; - this.emailClientsByNodeId.set(nodeId, new EmailClient(this, nodeId, emailClient, (folders) => this.setEmailClientFolders(nodeId, folders))); - } - if ((node.kind === 'router' || node.kind === 'gateway') && !this.swiftRouters) this.startRouterWebServer(node.id, node.kind); + case 'emailClient': + this.emailClientsByNodeId.set(nodeId, new EmailClient(this, nodeId, snapshot.emailClientsByNodeId.get(nodeId)!, (folders) => this.setEmailClientFolders(nodeId, folders))); + return; } } + /** + * A program installed while the simulation runs (Java's software + * installation: installAndStartAppIfAvailable): its settings, and the + * folders and files it makes where the node has none of the name, as + * `source` has them — the document's snapshot after the installation —, + * and the program started. A program installed already is left as it is. + */ + installProgram(nodeId: Id, program: InstallableProgram, source: TopologySnapshot): void { + const state = this.stateValue; + if (state.phase !== 'running' || !state.topologySnapshot.nodes.some((node) => node.id === nodeId)) return; + const running = (RUNNING_PROGRAMS as readonly string[]).includes(program) ? (program as RunningProgram) : undefined; + if (running !== undefined && this.hasProgram(nodeId, running)) return; + // The node's files first: a node without any gets them now, in the snapshot taken after. + const files = this.nodeFiles(nodeId); + const snapshot = state.topologySnapshot; + const from = source.fileSystemsByNodeId.get(nodeId); + for (const file of installationFiles(program, snapshot.language)) { + if (files.contains(file.path) || from?.contains(file.path) !== true) continue; + const content = from.entry(file.path).content; + if (content.kind === 'directory') files.createDirectory(file.path, true); + else if (content.kind === 'text') files.writeTextFile(file.path, content.text, false); + else if (content.kind === 'image') files.writeImageFile(file.path, content.data, content.mediaType, false); + else files.writeBinaryFile(file.path, content.data, content.mediaType, false); + } + const copied = (own: ReadonlyMap, theirs: ReadonlyMap): ReadonlyMap => { + const value = theirs.get(nodeId); + return value === undefined ? own : new Map([...own, [nodeId, value]]); + }; + const added = (own: ReadonlySet, theirs: ReadonlySet): ReadonlySet => (theirs.has(nodeId) ? new Set([...own, nodeId]) : own); + state.topologySnapshot = withSnapshot(snapshot, { + echoServersByNodeId: program === 'echoServer' ? copied(snapshot.echoServersByNodeId, source.echoServersByNodeId) : snapshot.echoServersByNodeId, + simpleClientsByNodeId: program === 'simpleClient' ? copied(snapshot.simpleClientsByNodeId, source.simpleClientsByNodeId) : snapshot.simpleClientsByNodeId, + webServersByNodeId: program === 'webServer' ? copied(snapshot.webServersByNodeId, source.webServersByNodeId) : snapshot.webServersByNodeId, + webBrowserNodeIds: program === 'webBrowser' ? added(snapshot.webBrowserNodeIds, source.webBrowserNodeIds) : snapshot.webBrowserNodeIds, + emailServersByNodeId: program === 'emailServer' ? copied(snapshot.emailServersByNodeId, source.emailServersByNodeId) : snapshot.emailServersByNodeId, + emailClientsByNodeId: program === 'emailClient' ? copied(snapshot.emailClientsByNodeId, source.emailClientsByNodeId) : snapshot.emailClientsByNodeId, + terminalNodeIds: program === 'commandPrompt' ? added(snapshot.terminalNodeIds, source.terminalNodeIds) : snapshot.terminalNodeIds, + gnutellaByNodeId: program === 'gnutella' ? copied(snapshot.gnutellaByNodeId, source.gnutellaByNodeId) : snapshot.gnutellaByNodeId, + dnsServerConfigurationsByNodeId: program === 'dnsServer' ? copied(snapshot.dnsServerConfigurationsByNodeId, source.dnsServerConfigurationsByNodeId) : snapshot.dnsServerConfigurationsByNodeId, + firewallConfigurationsByNodeId: program === 'firewall' ? copied(snapshot.firewallConfigurationsByNodeId, source.firewallConfigurationsByNodeId) : snapshot.firewallConfigurationsByNodeId, + }); + if (running !== undefined && this.hasProgram(nodeId, running)) this.startProgram(nodeId, running); + this.applicationThreads.resume(); + } + + /** + * A program removed while the simulation runs (entferneSoftware): a server + * is switched off, the program and its settings go; its files stay. + */ + removeProgram(nodeId: Id, program: InstallableProgram): void { + const state = this.stateValue; + if (state.phase !== 'running') return; + const snapshot = state.topologySnapshot; + const without = (own: ReadonlyMap): ReadonlyMap => new Map([...own].filter(([id]) => id !== nodeId)); + const withoutId = (own: ReadonlySet): ReadonlySet => new Set([...own].filter((id) => id !== nodeId)); + switch (program) { + case 'echoServer': + this.echoServersByNodeId.get(nodeId)?.setActive(false); + this.echoServersByNodeId.delete(nodeId); + state.topologySnapshot = withSnapshot(snapshot, { echoServersByNodeId: without(snapshot.echoServersByNodeId) }); + break; + case 'simpleClient': + this.simpleClientsByNodeId.delete(nodeId); + state.topologySnapshot = withSnapshot(snapshot, { simpleClientsByNodeId: without(snapshot.simpleClientsByNodeId) }); + break; + case 'webServer': + this.webServersByNodeId.get(nodeId)?.setActive(false); + this.webServersByNodeId.delete(nodeId); + state.topologySnapshot = withSnapshot(snapshot, { webServersByNodeId: without(snapshot.webServersByNodeId) }); + break; + case 'webBrowser': + this.webBrowsersByNodeId.delete(nodeId); + state.topologySnapshot = withSnapshot(snapshot, { webBrowserNodeIds: withoutId(snapshot.webBrowserNodeIds) }); + break; + case 'emailServer': + this.emailServersByNodeId.get(nodeId)?.setActive(false); + this.emailServersByNodeId.delete(nodeId); + state.topologySnapshot = withSnapshot(snapshot, { emailServersByNodeId: without(snapshot.emailServersByNodeId) }); + break; + case 'emailClient': + this.emailClientsByNodeId.delete(nodeId); + state.topologySnapshot = withSnapshot(snapshot, { emailClientsByNodeId: without(snapshot.emailClientsByNodeId) }); + break; + case 'commandPrompt': + this.terminalsByNodeId.get(nodeId)?.interrupt(); + this.terminalsByNodeId.delete(nodeId); + state.topologySnapshot = withSnapshot(snapshot, { terminalNodeIds: withoutId(snapshot.terminalNodeIds) }); + break; + case 'gnutella': + this.gnutellaPeersByNodeId.delete(nodeId); + state.topologySnapshot = withSnapshot(snapshot, { gnutellaByNodeId: without(snapshot.gnutellaByNodeId) }); + break; + case 'dnsServer': + state.topologySnapshot = withSnapshot(snapshot, { dnsServerConfigurationsByNodeId: without(snapshot.dnsServerConfigurationsByNodeId) }); + break; + case 'firewall': + state.topologySnapshot = withSnapshot(snapshot, { firewallConfigurationsByNodeId: without(snapshot.firewallConfigurationsByNodeId) }); + break; + default: + break; + } + this.applicationThreads.resume(); + } + /** * VermittlungsrechnerBetriebssystem, GatewayFirmware: a router and a home * router switch on their web server on port 80 when they start, with diff --git a/packages/core/test/installation.test.ts b/packages/core/test/installation.test.ts new file mode 100644 index 0000000..a818bf1 --- /dev/null +++ b/packages/core/test/installation.test.ts @@ -0,0 +1,107 @@ +// A program installed or removed while the simulation runs (Java's software +// installation): its settings and files from the document's snapshot, the +// program started at once; removed, a server is switched off and the program +// goes, its files staying. + +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { installationFiles, topologySnapshot, VirtualFileSystem, type TopologySnapshot } from '../src/index.js'; +import { device, pc, startedEngine, uuid } from './fixtures.js'; + +const PC = uuid(1); +const PORT = uuid(2); + +/** The document's snapshot after a web server and a command line were installed. */ +function source(files: VirtualFileSystem): TopologySnapshot { + for (const file of installationFiles('webServer', 'de')) { + if (file.content.kind === 'directory') files.createDirectory(file.path); + else if (file.content.kind === 'text') files.writeTextFile(file.path, file.content.text); + else if (file.content.kind === 'image') files.writeImageFile(file.path, file.content.data, file.content.mediaType); + } + return topologySnapshot({ + nodes: [pc(PC, PORT)], + links: [], + deviceConfigurations: new Map([[PC, device('10.0.0.1')]]), + webServersByNodeId: new Map([[PC, { port: 80, documentRoot: '/webserver', virtualHosts: false, isActive: false }]]), + terminalNodeIds: new Set([PC]), + fileSystemsByNodeId: new Map([[PC, files]]), + }); +} + +describe('a program installed while the simulation runs', () => { + it("takes its settings and the files it makes from the document's snapshot, and starts", () => { + const own = new VirtualFileSystem(); + own.createDirectory('/webserver'); + own.writeTextFile('/webserver/index.html', 'Meine Seite'); + const engine = startedEngine({ nodes: [pc(PC, PORT)], links: [], deviceConfigurations: new Map([[PC, device('10.0.0.1')]]), fileSystemsByNodeId: new Map([[PC, own]]) }); + expect([engine.webServer(PC), engine.terminal(PC)]).toEqual([undefined, undefined]); + const from = source(new VirtualFileSystem()); + engine.installProgram(PC, 'webServer', from); + engine.installProgram(PC, 'commandPrompt', from); + expect(engine.webServer(PC)).toBeDefined(); + expect(engine.terminal(PC)).toBeDefined(); + expect(engine.state.topologySnapshot.webServersByNodeId.get(PC)).toEqual({ port: 80, documentRoot: '/webserver', virtualHosts: false, isActive: false }); + // The running files keep what is there; what is missing comes from the document. + const files = engine.state.topologySnapshot.fileSystemsByNodeId.get(PC)!; + expect(files.textFile('/webserver/index.html')).toBe('Meine Seite'); + expect(files.imageFile('/webserver/splashscreen-mini.png').mediaType).toBe('image/png'); + // Installed again: nothing changes, the same program runs on. + const terminal = engine.terminal(PC); + engine.installProgram(PC, 'commandPrompt', from); + expect(engine.terminal(PC)).toBe(terminal); + }); + + it('goes when removed, its files staying, and nothing happens outside a run or on a device the run has not', () => { + const engine = startedEngine({ nodes: [pc(PC, PORT)], links: [], deviceConfigurations: new Map([[PC, device('10.0.0.1')]]) }); + const from = source(new VirtualFileSystem()); + engine.installProgram(uuid(9), 'webServer', from); + expect(engine.webServer(uuid(9))).toBeUndefined(); + engine.installProgram(PC, 'webServer', from); + engine.installProgram(PC, 'commandPrompt', from); + engine.removeProgram(PC, 'webServer'); + engine.removeProgram(PC, 'commandPrompt'); + expect([engine.webServer(PC), engine.terminal(PC)]).toEqual([undefined, undefined]); + expect(engine.state.topologySnapshot.webServersByNodeId.has(PC)).toBe(false); + expect(engine.state.topologySnapshot.terminalNodeIds.has(PC)).toBe(false); + expect(engine.state.topologySnapshot.fileSystemsByNodeId.get(PC)!.contains('/webserver/index.html')).toBe(true); + engine.handle({ type: 'stop' }); + engine.installProgram(PC, 'commandPrompt', from); + expect(engine.terminal(PC)).toBeUndefined(); + }); +}); + +const templates = join(dirname(fileURLToPath(import.meta.url)), '../../../tools/java-oracle/.upstream/src/main/resources/tmpl'); + +describe("the files a program makes", () => { + it.skipIf(!existsSync(templates))("give the web server Java's start page in the language FILIUS runs in, as Java reads it (each line ended by a line break)", () => { + for (const [language, file] of [['de', 'webserver_index_de_DE.txt'], ['en', 'webserver_index_en_GB.txt']] as const) { + const lines = readFileSync(join(templates, file), 'utf8').split(/\r?\n/); + if (lines.at(-1) === '') lines.pop(); + const page = installationFiles('webServer', language).find((each) => each.path === '/webserver/index.html')!; + expect(page.content, language).toEqual({ kind: 'text', text: lines.map((line) => `${line}\n`).join('') }); + } + }); + + it('are none for a program that makes none', () => { + for (const program of ['commandPrompt', 'webBrowser', 'textEditor', 'fileExplorer', 'imageViewer', 'echoServer', 'simpleClient', 'emailClient', 'firewall'] as const) { + expect(installationFiles(program, 'de'), program).toEqual([]); + } + }); +}); + +describe('a server removed while the simulation runs', () => { + it('is switched off: its port listens no more', () => { + const engine = startedEngine({ nodes: [pc(PC, PORT)], links: [], deviceConfigurations: new Map([[PC, device('10.0.0.1')]]) }); + const from = topologySnapshot({ ...source(new VirtualFileSystem()), webServersByNodeId: new Map([[PC, { port: 80, documentRoot: '/webserver', virtualHosts: false, isActive: true }]]) }); + engine.installProgram(PC, 'webServer', from); + const listening = () => [...engine.state.socketsById.values()].filter((socket) => socket.nodeId === PC && socket.localPort === 80 && socket.tcpState === 'LISTEN'); + expect(listening()).toHaveLength(1); + engine.removeProgram(PC, 'webServer'); + // The server's thread closes the socket, as Java's does, a moment later. + engine.handle({ type: 'advance', toMilliseconds: engine.state.currentTimeMilliseconds + 100 }); + expect(listening()).toEqual([]); + }); +}); + diff --git a/packages/doc/src/index.ts b/packages/doc/src/index.ts index bbf9d39..434e9d1 100644 --- a/packages/doc/src/index.ts +++ b/packages/doc/src/index.ts @@ -8,3 +8,4 @@ export * from './sheet.js'; export * from './snapshot.js'; export * from './values.js'; export * from './view.js'; +export * from './software.js'; diff --git a/packages/doc/src/projectDoc.ts b/packages/doc/src/projectDoc.ts index 9ffc177..e6cab81 100644 --- a/packages/doc/src/projectDoc.ts +++ b/packages/doc/src/projectDoc.ts @@ -98,7 +98,7 @@ function ownApplications(text: string): FlsOwnApplication[] { ) as FlsOwnApplication[]; } -function fileEntry(order: number, content: VirtualFileContent): Y.Map { +export function fileEntry(order: number, content: VirtualFileContent): Y.Map { const entry = new Y.Map(); entry.set('order', order); entry.set('kind', content.kind); diff --git a/packages/doc/src/software.ts b/packages/doc/src/software.ts new file mode 100644 index 0000000..fd06f7b --- /dev/null +++ b/packages/doc/src/software.ts @@ -0,0 +1,144 @@ +// A computer's software installation, after Java's (GUIInstallationsDialog +// on the desktop; measured with tools/java-oracle's InstallRun): the +// programs taken on with "apply changes" are installed — switched off, with +// the settings a new program of Java's has, and the folders and files it +// makes where nothing of the name is (installationFiles) —, those taken off +// are removed with their settings, their files staying. A program installed +// where a removed one left its files reads them, as Java's does: a DNS +// server its records from /dns/hosts, an e-mail server its accounts from +// /mailserver/konten.txt, an e-mail program its account from /konten.txt. +// +// Not adopted: Java's Gnutella rolls how many peers it knows (3 to 5); here a +// new one knows 3, as Java's file without the number reads. + +import * as Y from 'yjs'; +import { + DEFAULT_WEB_SERVER_PORT, + installationFiles, + JAVA_PERSONAL_FIREWALL_DEFAULTS, + type InstallableProgram, + type NodeKind, + type PageLanguage, +} from '@mdg-filius/core'; +import { dnsRecordsOf, emailClientConfiguration, emailServerConfiguration, JAVA_WEB_ROOT, readDnsHosts, readEmailClientFile, readEmailServerFile, type FlsApplication } from '@mdg-filius/fls'; +import { fileEntry, ROOTS } from './projectDoc.js'; +import { toY, type PlainValue } from './values.js'; + +/** The programs the installation offers: Java's list (config/Desktop_*.txt); the DHCP server is set up in the device sheet. */ +export type Software = Exclude; + +/** Where a program keeps its settings in a node's settings. */ +const SETTINGS_KEY: Partial> = { + webServer: 'webServer', + echoServer: 'echoServer', + simpleClient: 'simpleClient', + dnsServer: 'dnsServer', + emailServer: 'emailServer', + emailClient: 'emailClient', + gnutella: 'gnutella', + firewall: 'firewall', +}; + +/** + * The order of Java's list of programs in each language + * (config/Desktop_.txt): the desktop shows them so, and "apply + * changes" installs them so (the English list is alphabetical, the German + * one is not). + */ +export const SOFTWARE_ORDER: Readonly> = { + de: ['commandPrompt', 'imageViewer', 'fileExplorer', 'dnsServer', 'emailClient', 'emailServer', 'echoServer', 'simpleClient', 'firewall', 'gnutella', 'textEditor', 'webServer', 'webBrowser'], + en: ['commandPrompt', 'dnsServer', 'echoServer', 'emailServer', 'emailClient', 'fileExplorer', 'firewall', 'simpleClient', 'gnutella', 'imageViewer', 'textEditor', 'webBrowser', 'webServer'], +}; + +/** Java's new Gnutella rolls 3 to 5 peers; a file without the number reads 3. */ +export const NEW_GNUTELLA_PEERS = 3; + +const nodes = (doc: Y.Doc) => doc.getMap>(ROOTS.nodes); +const ignore = () => {}; + +/** A text file of a node's files, undefined without it. */ +function textOf(files: Y.Map>, path: string): string | undefined { + const entry = files.get(path); + return entry?.get('kind') === 'text' ? (entry.get('text') as Y.Text).toString() : undefined; +} + +/** The settings a new program of Java's has on a computer with these files. */ +function newSettings(program: Software, files: Y.Map>): PlainValue | undefined { + const application = (fields: Partial) => fields as FlsApplication; + switch (program) { + case 'webServer': + return { port: DEFAULT_WEB_SERVER_PORT, documentRoot: JAVA_WEB_ROOT, virtualHosts: false }; + case 'echoServer': + return { port: 55_555 }; + case 'simpleClient': + return { targetAddress: '', targetPort: 55_555 }; + case 'dnsServer': + return { recursiveResolutionEnabled: false, records: dnsRecordsOf(readDnsHosts(textOf(files, '/dns/hosts') ?? ''), ignore) } as unknown as PlainValue; + case 'emailServer': { + const accounts = textOf(files, '/mailserver/konten.txt'); + return emailServerConfiguration(application({ fileAccounts: accounts === undefined ? null : readEmailServerFile(accounts, ignore) }), ignore) as unknown as PlainValue; + } + case 'emailClient': { + const account = textOf(files, '/konten.txt'); + return emailClientConfiguration(application({ fileAccount: account === undefined ? null : readEmailClientFile(account, ignore) }), ignore) as unknown as PlainValue; + } + case 'gnutella': + return { maximumKnownPeers: NEW_GNUTELLA_PEERS }; + case 'firewall': + return JAVA_PERSONAL_FIREWALL_DEFAULTS as unknown as PlainValue; + default: + return undefined; + } +} + +/** A computer's installed programs; undefined for a device that has none (no PC or notebook). */ +export function installedSoftware(doc: Y.Doc, id: string): readonly InstallableProgram[] | undefined { + const node = nodes(doc).get(id); + const kind = node?.get('kind') as NodeKind | undefined; + if (node === undefined || (kind !== 'pc' && kind !== 'notebook')) return undefined; + return [...(node.get('programs') as Y.Map).keys()] as InstallableProgram[]; +} + +/** + * Java's "apply changes": installs and removes a computer's programs in one + * step. A program already installed is not installed again, one not + * installed is not removed. + */ +export function setSoftware( + doc: Y.Doc, + id: string, + change: { readonly install: readonly Software[]; readonly remove: readonly Software[] }, + language: PageLanguage, + origin: unknown, +): void { + const node = nodes(doc).get(id); + if (node === undefined || installedSoftware(doc, id) === undefined) return; + const programs = node.get('programs') as Y.Map>; + const settings = node.get('settings') as Y.Map; + const files = node.get('files') as Y.Map>; + doc.transact(() => { + for (const program of change.remove) { + if (!programs.has(program)) continue; + programs.delete(program); + const key = SETTINGS_KEY[program]; + if (key !== undefined) settings.delete(key); + } + // In the order of Java's list, as its dialog installs them. + for (const program of SOFTWARE_ORDER[language].filter((each) => change.install.includes(each))) { + if (programs.has(program)) continue; + let order = Math.max(-1, ...[...files.values()].map((entry) => entry.get('order') as number)) + 1; + // Java's web server makes its page and picture only where it has no page yet. + const keepsPage = program === 'webServer' && files.has('/webserver/index.html'); + for (const file of installationFiles(program, language)) { + if (keepsPage && file.path !== '/webserver') continue; + if (!files.has(file.path)) files.set(file.path, fileEntry(order++, file.content)); + } + const entry = new Y.Map(); + entry.set('active', false); + programs.set(program, entry); + const key = SETTINGS_KEY[program]; + const value = newSettings(program, files); + if (key !== undefined && value !== undefined) settings.set(key, toY(value)); + } + }, origin); +} diff --git a/packages/doc/test/fixtures/installed-programs.fls b/packages/doc/test/fixtures/installed-programs.fls new file mode 100644 index 0000000..d85f237 Binary files /dev/null and b/packages/doc/test/fixtures/installed-programs.fls differ diff --git a/packages/doc/test/install.test.ts b/packages/doc/test/install.test.ts new file mode 100644 index 0000000..0c43099 --- /dev/null +++ b/packages/doc/test/install.test.ts @@ -0,0 +1,162 @@ +// The software installation against Java's (tools/java-oracle InstallRun, +// fixtures/installed-programs.fls): each program installed on a new computer, +// all on one, each installed and removed again, a DNS server and an e-mail +// server installed again where their removed ones left a record and an +// account — the same programs, settings and files as Java saved, read with +// the same reader. Named: Java's Gnutella rolls its number of peers. + +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import * as Y from 'yjs'; +import type { Id, InstallableProgram, VirtualFileSystem } from '@mdg-filius/core'; +import { openProjectArchive, PROGRAM_OF_CLASS, saveProjectArchive, type ImportedProject } from '@mdg-filius/fls'; +import { addDevice, createUndoManager, emptyProject, installedSoftware, NEW_GNUTELLA_PEERS, readProject, ROOTS, setSoftware, writeProject, type Software } from '../src/index.js'; + +const fixture = join(dirname(fileURLToPath(import.meta.url)), 'fixtures/installed-programs.fls'); +let counter = 0; +const java = openProjectArchive(new Uint8Array(readFileSync(fixture)), () => `90000000-0000-0000-0000-${(++counter).toString(16).padStart(12, '0')}`); +const PROGRAMS = Object.entries(PROGRAM_OF_CLASS).map(([javaClass, program]) => [javaClass.slice(javaClass.lastIndexOf('.') + 1), program as Software] as const); + +/** A new PC in a new document. */ +function computer() { + const doc = new Y.Doc(); + writeProject(doc, emptyProject()); + let ports = 0; + addDevice(doc, { kind: 'pc', id: 'pc', name: 'PC', x: 0, y: 0, newId: () => `port-${++ports}` }, 'me'); + return doc; +} + +/** Folders and files, each with its content: text as it is, pictures by kind and size. */ +function filesOf(files: VirtualFileSystem | undefined): string[] { + const listed: string[] = []; + const walk = (folder: string) => { + for (const entry of files?.entriesInCreationOrder(folder) ?? []) { + const content = entry.content; + listed.push(`${entry.path} ${content.kind === 'text' ? JSON.stringify(content.text) : content.kind === 'directory' ? '/' : `${content.kind} ${content.data.length}`}`); + if (content.kind === 'directory') walk(entry.path); + } + }; + walk('/'); + return listed; +} + +/** What a computer holds of its programs, as the model has it. */ +function slice(project: ImportedProject, id: Id) { + const gnutella = project.gnutellaConfigurations.get(id); + return { + programs: [...(project.installedPrograms.get(id) ?? [])].sort(), + active: [...(project.activePrograms.get(id) ?? [])], + webServer: project.webServerConfigurations.get(id), + echoServer: project.echoServerConfigurations.get(id), + simpleClient: project.simpleClientConfigurations.get(id), + dnsServer: project.dnsServerConfigurations.get(id), + emailServer: project.emailServerConfigurations.get(id), + emailClient: project.emailClientConfigurations.get(id), + // Java rolls it (3 to 5), and its file may name it by any number that came before (an idref). + gnutella: gnutella === undefined ? undefined : { maximumKnownPeers: NEW_GNUTELLA_PEERS }, + firewall: project.firewallConfigurationsByNodeId.get(id), + files: filesOf(project.virtualFileSystems.get(id)), + }; +} + +const javaComputer = (name: string) => java.graph.nodes.find((node) => node.displayName === name)!.id; + +describe("the software installation, against Java's", () => { + it('installs each program with its settings and files', () => { + for (const [name, program] of PROGRAMS) { + const doc = computer(); + setSoftware(doc, 'pc', { install: [program], remove: [] }, 'de', 'me'); + expect(slice(readProject(doc), 'pc'), name).toEqual(slice(java, javaComputer(name))); + } + }); + + it('installs all programs on one computer, in the order of Java’s list', () => { + const doc = computer(); + // Taken on in any order: the dialog installs them in the order of its list. + setSoftware(doc, 'pc', { install: PROGRAMS.map(([, program]) => program).reverse(), remove: [] }, 'de', 'me'); + expect(slice(readProject(doc), 'pc')).toEqual(slice(java, javaComputer('Alle'))); + }); + + it('removes a program with its settings, its files staying', () => { + for (const [name, program] of PROGRAMS) { + const doc = computer(); + setSoftware(doc, 'pc', { install: [program], remove: [] }, 'de', 'me'); + setSoftware(doc, 'pc', { install: [], remove: [program] }, 'de', 'me'); + expect(slice(readProject(doc), 'pc'), name).toEqual(slice(java, javaComputer(`Entfernt-${name}`))); + } + }); + + it('reads the records and accounts a removed server left, when it is installed again', () => { + for (const [name, program, path, text] of [ + ['DNSServer', 'dnsServer', '/dns/hosts', 'www.filius.de. A 3600 1.2.3.4\n'], + ['EmailServer', 'emailServer', '/mailserver/konten.txt', 'anna;filius.de;geheim;Muster;Anna;\n'], + ] as const) { + const doc = computer(); + setSoftware(doc, 'pc', { install: [program], remove: [] }, 'de', 'me'); + // What Java's server wrote when it got the record or the account (its windows follow in F4.6g). + const files = doc.getMap>(ROOTS.nodes).get('pc')!.get('files') as Y.Map>; + (files.get(path)!.get('text') as Y.Text).insert(0, text); + setSoftware(doc, 'pc', { install: [], remove: [program] }, 'de', 'me'); + setSoftware(doc, 'pc', { install: [program], remove: [] }, 'de', 'me'); + expect(slice(readProject(doc), 'pc'), name).toEqual(slice(java, javaComputer(`Wieder-${name}`))); + } + }); +}); + +describe('the software installation', () => { + it("writes the web server's start page in the language FILIUS runs in, and keeps a page that is there", () => { + const doc = computer(); + setSoftware(doc, 'pc', { install: ['webServer'], remove: [] }, 'en', 'me'); + const files = () => readProject(doc).virtualFileSystems.get('pc')!; + const page = files().entriesInCreationOrder('/webserver')[0]!; + expect(page.content.kind === 'text' && page.content.text).toContain('

Welcome at FILIUS!

'); + // A page of its own: installed again, the server keeps it and brings no picture. + const entries = doc.getMap>(ROOTS.nodes).get('pc')!.get('files') as Y.Map>; + setSoftware(doc, 'pc', { install: [], remove: ['webServer'] }, 'en', 'me'); + entries.delete('/webserver/splashscreen-mini.png'); + (entries.get('/webserver/index.html')!.get('text') as Y.Text).insert(0, 'Meine Seite '); + setSoftware(doc, 'pc', { install: ['webServer'], remove: [] }, 'de', 'me'); + expect(filesOf(files()).filter((each) => each.startsWith('/webserver'))).toEqual(['/webserver /', expect.stringMatching(/^\/webserver\/index\.html "Meine Seite /)]); + }); + + it('installs nothing twice, removes nothing not there, takes one step of the undo, and installs only on computers', () => { + const doc = computer(); + const undo = createUndoManager(doc, 'me'); + setSoftware(doc, 'pc', { install: ['commandPrompt', 'webServer'], remove: ['textEditor'] }, 'de', 'me'); + expect([...installedSoftware(doc, 'pc')!].sort()).toEqual(['commandPrompt', 'webServer']); + const before = readProject(doc); + setSoftware(doc, 'pc', { install: ['webServer'], remove: [] }, 'de', 'me'); + expect(slice(readProject(doc), 'pc')).toEqual(slice(before, 'pc')); + undo.undo(); + expect(installedSoftware(doc, 'pc')).toEqual([]); + expect(filesOf(readProject(doc).virtualFileSystems.get('pc'))).toEqual([]); + let ports = 0; + addDevice(doc, { kind: 'router', id: 'router', name: 'R', x: 0, y: 0, newId: () => `r-${++ports}` }, 'me'); + setSoftware(doc, 'router', { install: ['commandPrompt'], remove: [] }, 'de', 'me'); + expect(installedSoftware(doc, 'router')).toBeUndefined(); + expect(readProject(doc).installedPrograms.has('router')).toBe(false); + setSoftware(doc, 'nobody', { install: ['commandPrompt'], remove: [] }, 'de', 'me'); + }); + + it("gives a new Gnutella 3 peers, as Java's file without the number reads (Java rolls 3 to 5)", () => { + const doc = computer(); + setSoftware(doc, 'pc', { install: ['gnutella'], remove: [] }, 'de', 'me'); + expect(readProject(doc).gnutellaConfigurations.get('pc')).toEqual({ maximumKnownPeers: 3 }); + let ids = 0; + const reopened = openProjectArchive(saveProjectArchive(readProject(doc)).archive, () => `70000000-0000-0000-0000-${(++ids).toString(16).padStart(12, '0')}`); + expect(reopened.gnutellaConfigurations.get(reopened.graph.nodes[0]!.id)).toEqual({ maximumKnownPeers: 3 }); + }); + + it('goes into the file as Java would read it', () => { + const doc = computer(); + setSoftware(doc, 'pc', { install: ['webServer', 'emailServer', 'commandPrompt'], remove: [] }, 'de', 'me'); + const project = readProject(doc); + expect(project.installedPrograms.get('pc')).toEqual(new Set(['webServer', 'emailServer', 'commandPrompt'])); + let ids = 0; + const reopened = openProjectArchive(saveProjectArchive(project).archive, () => `80000000-0000-0000-0000-${(++ids).toString(16).padStart(12, '0')}`); + const again = reopened.graph.nodes[0]!.id; + expect(slice(reopened, again)).toEqual(slice(project, 'pc')); + }); +}); diff --git a/packages/editor/browser/simulation.spec.ts b/packages/editor/browser/simulation.spec.ts index 474cd5c..c837422 100644 --- a/packages/editor/browser/simulation.spec.ts +++ b/packages/editor/browser/simulation.spec.ts @@ -257,6 +257,25 @@ test.describe('on a phone', () => { await window.locator('input[name="terminalInput"]').press('Enter'); await expect(window.locator('.terminal-text')).toContainText('IP Adresse . . . : 141.99.1.10'); }); + + test('offers the installation’s programs as targets for a finger', async ({ page }) => { + await openEditor(page, server.base, 'internetwork_komplett.fls'); + await simulationButton(page).tap(); + await expect(page.locator('svg.filius-canvas')).toHaveAttribute('data-mode', 'simulation'); + await page.getByRole('button', { name: 'Alles zeigen' }).tap(); + const id = await deviceNamed(page, 'Notebook 1.10'); + const at = await onDevice(page, id); + await page.touchscreen.tap(at.x, at.y); + await desktop(page, id).locator('[data-program="installation"]').tap(); + const panel = desktop(page, id).locator('[data-program-window="installation"]'); + const first = (await panel.locator('[data-list="available"] [role="option"]').first().boundingBox())!; + expect(first.height).toBeGreaterThanOrEqual(44); + const arrow = (await panel.getByRole('button', { name: 'Installieren' }).boundingBox())!; + expect(arrow.height).toBeGreaterThanOrEqual(44); + await panel.locator('[data-list="available"] [role="option"]').first().tap(); + await panel.getByRole('button', { name: 'Installieren' }).tap(); + await expect(panel.locator('[data-list="installed"] [role="option"]')).toHaveText(['Befehlszeile', 'Bildbetrachter', 'Webbrowser']); + }); }); /** Opens a computer's command line on its desktop. */ @@ -343,6 +362,61 @@ test('keeps the command line over leaving it, over a return to the design, and e await expect(window).toBeVisible(); }); +test("installs and removes programs as Java's installation does, and a program installed runs at once", async ({ page }) => { + await openEditor(page, server.base, 'internetwork_komplett.fls'); + const id = await deviceNamed(page, 'Rechner 3.10'); + expect((await desktopOf(page, id))!.programs).toEqual([]); + await start(page); + await tap(page, id); + const window = desktop(page, id); + await window.locator('[data-program="installation"]').click(); + const panel = window.locator('[data-program-window="installation"]'); + await expect(panel.getByRole('heading')).toHaveText('Software-Installation'); + const installed = panel.locator('[data-list="installed"] [role="option"]'); + const available = panel.locator('[data-list="available"] [role="option"]'); + await expect(installed).toHaveCount(0); + await expect(available).toHaveText(['Befehlszeile', 'Bildbetrachter', 'Datei-Explorer', 'DNS-Server', 'E-Mail-Programm', 'E-Mail-Server', 'Echo-Server', 'Einfacher Client', 'Firewall', 'Gnutella', 'Text-Editor', 'Webserver', 'Webbrowser']); + // Chosen with a tap each, moved over with the arrow; the list keeps Java's order. + await available.filter({ hasText: 'Webserver' }).click(); + await available.filter({ hasText: 'Befehlszeile' }).click(); + await expect(available.filter({ hasText: 'Webserver' })).toHaveAttribute('aria-selected', 'true'); + // A second tap lets a program go again. + await available.filter({ hasText: 'Gnutella' }).click(); + await available.filter({ hasText: 'Gnutella' }).click(); + await expect(available.filter({ hasText: 'Gnutella' })).toHaveAttribute('aria-selected', 'false'); + await panel.getByRole('button', { name: 'Installieren' }).click(); + await expect(installed).toHaveText(['Befehlszeile', 'Webserver']); + // Nothing changes before "apply changes". + expect((await desktopOf(page, id))!.programs).toEqual([]); + await panel.getByRole('button', { name: 'Änderungen annehmen' }).click(); + await expect(panel).toHaveCount(0); + await expect(window.locator('.desktop-icon span')).toHaveText(['Software-Installation', 'Befehlszeile', 'Webserver']); + expect([...(await desktopOf(page, id))!.programs].sort()).toEqual(['commandPrompt', 'webServer']); + + // The command line runs at once, and sees the web server's files. + await window.locator('[data-program="commandPrompt"]').click(); + const input = window.locator('input[name="terminalInput"]'); + await input.fill('ls /webserver'); + await input.press('Enter'); + await expect(window.locator('.terminal-text')).toContainText('index.html'); + await window.getByRole('button', { name: 'Startbildschirm' }).click(); + + // A double click moves a program back; applied, it is gone. + await window.locator('[data-program="installation"]').click(); + await installed.filter({ hasText: 'Webserver' }).dblclick(); + await expect(installed).toHaveText(['Befehlszeile']); + await panel.getByRole('button', { name: 'Änderungen annehmen' }).click(); + await expect(window.locator('.desktop-icon span')).toHaveText(['Software-Installation', 'Befehlszeile']); + expect((await desktopOf(page, id))!.programs).toEqual(['commandPrompt']); + // Taken on and given back before applying: nothing happens. + await window.locator('[data-program="installation"]').click(); + await available.filter({ hasText: 'Gnutella' }).dblclick(); + await installed.filter({ hasText: 'Gnutella' }).click(); + await panel.getByRole('button', { name: 'Entfernen' }).click(); + await panel.getByRole('button', { name: 'Änderungen annehmen' }).click(); + expect((await desktopOf(page, id))!.programs).toEqual(['commandPrompt']); +}); + test('says so when the simulation fails, and goes back to the design', async ({ page }) => { // FILIUS' modem example: the core has no modems yet (remote links); a ping to the other side fails it. await openEditor(page, server.base, 'modem_beispiel.fls'); diff --git a/packages/editor/src/Editor.tsx b/packages/editor/src/Editor.tsx index d7ad53c..2c6a273 100644 --- a/packages/editor/src/Editor.tsx +++ b/packages/editor/src/Editor.tsx @@ -19,8 +19,7 @@ import { ContextMenu, type MenuItem } from './design/ContextMenu.js'; import { Palette, type PaletteKind } from './design/Palette.js'; import { RouterDialog } from './design/RouterDialog.js'; import { DeviceSheet } from './sheet/DeviceSheet.js'; -import { DesktopWindow } from './desktop/DesktopWindow.js'; -import type { DesktopProgram } from './desktop/programs.js'; +import { DesktopWindow, type DesktopWindowName } from './desktop/DesktopWindow.js'; import { useTranslate } from './i18n/i18n.js'; import { ModeBar } from './simulation/ModeBar.js'; import { useSimulation, type SimHostFactory } from './simulation/useSimulation.js'; @@ -80,7 +79,7 @@ interface OpenDesktop { readonly x: number; readonly y: number; readonly z: number; - readonly program: DesktopProgram | null; + readonly program: DesktopWindowName | null; } /** Brings a desktop to the top by its stacking, not its place in the page: moved in the page, it would lose the press on it. */ @@ -310,6 +309,7 @@ export function Editor({ doc, createSimHost }: { readonly doc: Y.Doc; readonly c y={desktop.y} z={desktop.z} program={desktop.program} + origin={origin} onProgram={(program) => setDesktops((open) => open.map((each) => (each.id === desktop.id ? { ...each, program } : each)))} onMove={(x, y) => setDesktops((open) => open.map((each) => (each.id === desktop.id ? { ...each, x, y } : each)))} onFront={() => setDesktops((open) => toFront(open, desktop.id))} diff --git a/packages/editor/src/assets/desktop/pfeil_links.png b/packages/editor/src/assets/desktop/pfeil_links.png new file mode 100644 index 0000000..6327b76 Binary files /dev/null and b/packages/editor/src/assets/desktop/pfeil_links.png differ diff --git a/packages/editor/src/assets/desktop/pfeil_rechts.png b/packages/editor/src/assets/desktop/pfeil_rechts.png new file mode 100644 index 0000000..3f9fbe3 Binary files /dev/null and b/packages/editor/src/assets/desktop/pfeil_rechts.png differ diff --git a/packages/editor/src/desktop/DesktopWindow.tsx b/packages/editor/src/desktop/DesktopWindow.tsx index 0a9ade8..7df6432 100644 --- a/packages/editor/src/desktop/DesktopWindow.tsx +++ b/packages/editor/src/desktop/DesktopWindow.tsx @@ -24,6 +24,7 @@ import { useLanguage, useTranslate } from '../i18n/i18n.js'; import type { Simulation } from '../simulation/useSimulation.js'; import { ProgramFrame } from './ProgramFrame.js'; import { desktopPrograms, INSTALLATION_ICON, JAVA_PROGRAMS, type DesktopProgram } from './programs.js'; +import { InstallationPanel } from './InstallationPanel.js'; import { TerminalPanel } from './TerminalPanel.js'; import './desktop.css'; @@ -39,14 +40,19 @@ export interface DesktopWindowProps { readonly y: number; /** Its place in the stack of desktops. */ readonly z: number; - /** The program whose window is open on it, kept by the editor over a return to the design (Java's desktop keeps it). */ - readonly program: DesktopProgram | null; - readonly onProgram: (program: DesktopProgram | null) => void; + /** The window open on it — a program's or the installation's —, kept by the editor over a return to the design (Java's desktop keeps it). */ + readonly program: DesktopWindowName | null; + readonly onProgram: (program: DesktopWindowName | null) => void; + /** The person's origin, for what the installation changes in the document. */ + readonly origin: object; readonly onMove: (x: number, y: number) => void; readonly onFront: () => void; readonly onClose: () => void; } +/** What can be open on a desktop: a program's window or the software installation. */ +export type DesktopWindowName = DesktopProgram | 'installation'; + /** The programs whose windows are built so far. */ const WITH_WINDOW: ReadonlySet = new Set(['commandPrompt']); @@ -62,7 +68,7 @@ function useDesktopView(doc: Y.Doc, id: string) { return view; } -export function DesktopWindow({ doc, id, simulation, active, x, y, z, program: open, onProgram: setOpen, onMove, onFront, onClose }: DesktopWindowProps) { +export function DesktopWindow({ doc, id, simulation, active, x, y, z, program: open, onProgram: setOpen, origin, onMove, onFront, onClose }: DesktopWindowProps) { const t = useTranslate(); const language = useLanguage(); const view = useDesktopView(doc, id); @@ -75,7 +81,7 @@ export function DesktopWindow({ doc, id, simulation, active, x, y, z, program: o if (view === undefined) return null; const programs = desktopPrograms(view.programs, language); // A program removed while its window was open: the desktop shows. - const shown = open !== null && programs.includes(open) ? open : null; + const shown = open === 'installation' || (open !== null && programs.includes(open)) ? open : null; const address = settings?.ipAddress ?? ''; const title = view.addressAsName ? view.label : `${view.label} - ${address}`; @@ -114,8 +120,7 @@ export function DesktopWindow({ doc, id, simulation, active, x, y, z, program: o
  • - {/* The installation's window comes with F4.6c. */} - @@ -136,7 +141,11 @@ export function DesktopWindow({ doc, id, simulation, active, x, y, z, program: o
  • ))}
- {shown === null ? null : ( + {shown === 'installation' ? ( + + + + ) : shown === null ? null : ( {shown === 'commandPrompt' ? : null} diff --git a/packages/editor/src/desktop/InstallationPanel.tsx b/packages/editor/src/desktop/InstallationPanel.tsx new file mode 100644 index 0000000..233c701 --- /dev/null +++ b/packages/editor/src/desktop/InstallationPanel.tsx @@ -0,0 +1,97 @@ +// A computer's software installation, after Java's GUIInstallationsDialog: +// the programs installed on the left, those to be had on the right, each in +// the order of Java's list; the arrows between them (or a double click) move +// the chosen ones over, "apply changes" installs and removes them in one +// step (setSoftware) and goes back to the desktop. Java's list of installed +// programs comes in the order of a hash map; here in the order of its list. +// A tap chooses a program, another tap lets it go, as Java's lists choose +// several. + +import { useState } from 'react'; +import type * as Y from 'yjs'; +import { setSoftware, SOFTWARE_ORDER, type Software } from '@mdg-filius/doc'; +import type { InstallableProgram } from '@mdg-filius/core'; +import arrowLeft from '../assets/desktop/pfeil_links.png'; +import arrowRight from '../assets/desktop/pfeil_rechts.png'; +import { useLanguage, useTranslate } from '../i18n/i18n.js'; + +function ProgramList({ + name, + label, + programs, + chosen, + onChoose, + onMove, +}: { + readonly name: string; + readonly label: string; + readonly programs: readonly Software[]; + readonly chosen: ReadonlySet; + readonly onChoose: (program: Software) => void; + readonly onMove: (program: Software) => void; +}) { + const t = useTranslate(); + return ( +
+ {label} +
    + {programs.map((program) => ( +
  • onChoose(program)} onDoubleClick={() => onMove(program)} onKeyDown={(event) => (event.key === ' ' || event.key === 'Enter') && (event.preventDefault(), onChoose(program))}> + {t(`program.${program}`)} +
  • + ))} +
+
+ ); +} + +export function InstallationPanel({ doc, node, installed, origin, onDone }: { readonly doc: Y.Doc; readonly node: string; readonly installed: readonly InstallableProgram[]; readonly origin: object; readonly onDone: () => void }) { + const t = useTranslate(); + const language = useLanguage(); + const order = SOFTWARE_ORDER[language]; + const [kept, setKept] = useState>(() => new Set(order.filter((program) => installed.includes(program)))); + const [chosen, setChosen] = useState>(new Set()); + const left = order.filter((program) => kept.has(program)); + const right = order.filter((program) => !kept.has(program)); + const choose = (program: Software) => + setChosen((before) => { + const next = new Set(before); + if (!next.delete(program)) next.add(program); + return next; + }); + /** Moves the chosen programs of one side to the other. */ + const move = (toInstalled: boolean, programs: readonly Software[]) => { + setKept((before) => { + const next = new Set(before); + for (const program of programs) if (toInstalled) next.add(program); + else next.delete(program); + return next; + }); + setChosen((before) => new Set([...before].filter((program) => !programs.includes(program)))); + }; + const apply = () => { + const install = order.filter((program) => kept.has(program) && !installed.includes(program)); + const remove = order.filter((program) => !kept.has(program) && installed.includes(program)); + setSoftware(doc, node, { install, remove }, language, origin); + onDone(); + }; + return ( +
+
+ move(false, [program])} /> +
+ + +
+ move(true, [program])} /> +
+ +
+ ); +} diff --git a/packages/editor/src/desktop/desktop.css b/packages/editor/src/desktop/desktop.css index 41b834f..79a7fcb 100644 --- a/packages/editor/src/desktop/desktop.css +++ b/packages/editor/src/desktop/desktop.css @@ -295,3 +295,87 @@ font: 14px ui-monospace, Menlo, Consolas, monospace; cursor: pointer; } + +/* The software installation (GUIInstallationsDialog): two lists of 170 + * pixels in Java, the arrows between them, "apply changes" below. */ +.installation { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + padding: 10px; + background: #eeeeee; + color: #000000; + font: 13px Dialog, sans-serif; +} + +.installation-lists { + flex: 1; + min-height: 0; + width: 100%; + display: grid; + grid-template-columns: minmax(0, 1fr) 52px minmax(0, 1fr); + gap: 8px; +} + +.installation-column { + min-height: 0; + display: flex; + flex-direction: column; + gap: 6px; +} + +.installation-list { + flex: 1; + min-height: 0; + overflow-y: auto; + margin: 0; + padding: 0; + list-style: none; + background: #ffffff; + border: 1px solid #a0a0a0; +} + +.installation-list li { + display: flex; + align-items: center; + min-height: 44px; + padding: 0 8px; + cursor: pointer; + user-select: none; +} + +.installation-list li[aria-selected='true'] { + background: rgb(184 207 229); +} + +.installation-list li:focus-visible { + outline: 2px solid rgb(99 130 191); + outline-offset: -2px; +} + +.installation-arrows { + display: flex; + flex-direction: column; + justify-content: center; + gap: 10px; +} + +.installation-arrows button, +.installation-apply { + min-width: 44px; + min-height: 44px; + border: 1px solid #a0a0a0; + border-radius: 6px; + background: #f8f8f8; + color: #000000; + font: 13px Dialog, sans-serif; + cursor: pointer; +} + +.installation-apply { + padding: 0 16px; + flex: none; +} diff --git a/packages/editor/src/desktop/programs.ts b/packages/editor/src/desktop/programs.ts index a01327e..2801f6c 100644 --- a/packages/editor/src/desktop/programs.ts +++ b/packages/editor/src/desktop/programs.ts @@ -5,6 +5,7 @@ // set up in the device sheet, as in Java. import type { InstallableProgram } from '@mdg-filius/core'; +import { SOFTWARE_ORDER } from '@mdg-filius/doc'; import type { Language } from '../i18n/i18n.js'; import browser from '../assets/desktop/icon_browser.png'; import simpleClient from '../assets/desktop/icon_clientbaustein.png'; @@ -43,11 +44,8 @@ export const JAVA_PROGRAMS: Readonly> = { - de: ['commandPrompt', 'imageViewer', 'fileExplorer', 'dnsServer', 'emailClient', 'emailServer', 'echoServer', 'simpleClient', 'firewall', 'gnutella', 'textEditor', 'webServer', 'webBrowser'], - en: ['commandPrompt', 'dnsServer', 'echoServer', 'emailServer', 'emailClient', 'fileExplorer', 'firewall', 'simpleClient', 'gnutella', 'imageViewer', 'textEditor', 'webBrowser', 'webServer'], -}; +/** The order of Java's list in each language (the installation's too). */ +export const DESKTOP_ORDER: Readonly> = SOFTWARE_ORDER; /** A computer's programs as its desktop shows them, in the language's order. */ export function desktopPrograms(installed: readonly InstallableProgram[], language: Language): DesktopProgram[] { diff --git a/packages/editor/src/i18n/de.json b/packages/editor/src/i18n/de.json index 30f72a3..8b0fb03 100644 --- a/packages/editor/src/i18n/de.json +++ b/packages/editor/src/i18n/de.json @@ -79,6 +79,11 @@ "forwarding.lanPortValue": "LAN-Port", "forwarding.protocolValue": "Protokoll", "forwarding.publicPortValue": "Portfreigabe", + "installation.add": "Installieren", + "installation.apply": "Änderungen annehmen", + "installation.available": "Verfügbar:", + "installation.installed": "Installiert:", + "installation.remove": "Entfernen", "menu.delete": "Löschen", "menu.removeAllCables": "Alle Verbindungen entfernen", "menu.removeCable": "Verbindung entfernen", diff --git a/packages/editor/src/i18n/en.json b/packages/editor/src/i18n/en.json index 1a7102d..a275485 100644 --- a/packages/editor/src/i18n/en.json +++ b/packages/editor/src/i18n/en.json @@ -79,6 +79,11 @@ "forwarding.lanPortValue": "LAN Port", "forwarding.protocolValue": "Protocol", "forwarding.publicPortValue": "Mapped Port", + "installation.add": "Install", + "installation.apply": "Apply changes", + "installation.available": "Available:", + "installation.installed": "Installed:", + "installation.remove": "Remove", "menu.delete": "Delete", "menu.removeAllCables": "Remove all cables", "menu.removeCable": "Remove cable", diff --git a/packages/editor/test/i18n.test.ts b/packages/editor/test/i18n.test.ts index af976bf..2f2be87 100644 --- a/packages/editor/test/i18n.test.ts +++ b/packages/editor/test/i18n.test.ts @@ -110,6 +110,9 @@ const FROM_JAVA: Readonly> = { 'connections.add': 'jvermittlungsrechnerkonfiguration_msg24', 'connections.most': 'jvermittlungsrechnerkonfiguration_msg25', 'mode.speed': 'guimainmemu_msg16', + 'installation.installed': 'installationsdialog_msg3', + 'installation.available': 'installationsdialog_msg4', + 'installation.apply': 'installationsdialog_msg2', 'mode.currentSpeed': 'guimainmemu_msg15', 'menu.showDesktop': 'guievents_msg3', 'desktop.home': 'desktoppanel_application', diff --git a/packages/fls/src/index.ts b/packages/fls/src/index.ts index 9a6d998..abe4b1a 100644 --- a/packages/fls/src/index.ts +++ b/packages/fls/src/index.ts @@ -12,3 +12,4 @@ export * from './topologyWriter.js'; export * from './zip.js'; export * from './exportProject.js'; export * from './projectSnapshot.js'; +export * from './importEmail.js'; diff --git a/packages/fls/src/javaDefaults.json b/packages/fls/src/javaDefaults.json index 9eb82e8..b472adb 100644 --- a/packages/fls/src/javaDefaults.json +++ b/packages/fls/src/javaDefaults.json @@ -1,10 +1,18 @@ { + "ClientBaustein#priority": 5, "ClientBaustein#zielIPAdresse": null, "ClientBaustein#zielPort": 55555, "DHCPServer#port": 67, + "DHCPServer#priority": 5, "DNSServer#aktiv": false, "DNSServer#port": 53, + "DNSServer#priority": 5, + "EmailAnwendung#priority": 5, + "EmailServer#priority": 5, + "FileExplorer#priority": 5, + "Firewall#priority": 5, "Gateway#maxLayerOfOperation": 2, + "ImageViewer#priority": 5, "JSidebarButton#debugGraphicsOptions": 0, "JSidebarButton#displayedMnemonicIndex": -1, "JSidebarButton#enabled": true, @@ -18,14 +26,21 @@ "5" ] }, + "PeerToPeerAnwendung#priority": 5, "RIPServer#port": 520, + "RIPServer#priority": 5, "Rechner#maxLayerOfOperation": 4, "ServerBaustein#aktiv": false, "ServerBaustein#port": 55555, + "ServerBaustein#priority": 5, "SwingConstants.CENTER": 0, + "Terminal#priority": 5, + "TextEditor#priority": 5, "ThreadGroup#maxPriority": 10, "Vermittlungsrechner#maxLayerOfOperation": 2, + "WebBrowser#priority": 5, "WebServer#aktiv": false, "WebServer#port": 80, + "WebServer#priority": 5, "WebServer#useVHost": false } diff --git a/packages/sim/src/room.ts b/packages/sim/src/room.ts index d8ec4ab..b71647f 100644 --- a/packages/sim/src/room.ts +++ b/packages/sim/src/room.ts @@ -17,10 +17,26 @@ // the next start goes on at the time it stopped, so that the capture's times // keep rising (plan §6.2). So do the command lines' windows, as Java's do // (TerminalWindow). +// +// While the simulation runs, the document's programs are watched: a program +// installed or removed on the desktop (Java's software installation) is +// installed or removed in the running engine at once, as Java starts or ends +// it; any other change comes in at the next start. import * as Y from 'yjs'; -import { DEFAULT_SPEED_PERCENT, NetworkRuntimeEngine, captureRows, interfaceKey, stableGeneratedMacAddress, type CapturedFrame, type CaptureRow, type Id, type LogLine } from '@mdg-filius/core'; -import { docSnapshot } from '@mdg-filius/doc'; +import { + DEFAULT_SPEED_PERCENT, + NetworkRuntimeEngine, + captureRows, + interfaceKey, + stableGeneratedMacAddress, + type CapturedFrame, + type CaptureRow, + type Id, + type InstallableProgram, + type LogLine, +} from '@mdg-filius/core'; +import { docSnapshot, installedSoftware, ROOTS } from '@mdg-filius/doc'; import { parseClientMessage, SIM_PROTOCOL_VERSION, @@ -77,12 +93,61 @@ export class SimulationRoom { private lastActivity: string[] = []; /** Each computer's command line window, kept over stops. */ private readonly terminalWindows = new Map(); + /** Each computer's programs as the running engine has them. */ + private software = new Map(); constructor( private readonly doc: Y.Doc, private readonly options: RoomOptions, ) { this.engine = new NetworkRuntimeEngine(options.seed(), undefined, { wallClock: options.now }); + doc.on('update', () => { + if (this.mode === 'simulation') this.guarded(() => this.syncSoftware()); + }); + } + + /** The computers' programs in the document. */ + private documentSoftware(): Map { + const software = new Map(); + for (const id of this.doc.getMap(ROOTS.nodes).keys()) { + const programs = installedSoftware(this.doc, id); + if (programs !== undefined) software.set(id, programs); + } + return software; + } + + /** Installs and removes in the running engine what the document's programs changed (Java's software installation). */ + private syncSoftware(): void { + const now = this.documentSoftware(); + const changes: { readonly node: Id; readonly installed: readonly InstallableProgram[]; readonly removed: readonly InstallableProgram[] }[] = []; + for (const [node, programs] of now) { + const before = this.software.get(node); + // A computer added since the start comes in at the next start. + if (before === undefined) continue; + const installed = programs.filter((program) => !before.includes(program)); + const removed = before.filter((program) => !programs.includes(program)); + if (installed.length > 0 || removed.length > 0) changes.push({ node, installed, removed }); + } + if (changes.length === 0) return; + const source = docSnapshot(this.doc); + for (const { node, installed, removed } of changes) { + for (const program of removed) this.engine.removeProgram(node, program); + for (const program of installed) this.engine.installProgram(node, program, source); + this.software.set(node, now.get(node)!); + } + this.syncTerminalWindows(); + this.flush([...this.clients.values()], false); + } + + /** A window for each command line the engine runs, the same over stops (TerminalWindow). */ + private syncTerminalWindows(): void { + for (const node of this.engine.state.topologySnapshot.nodes) { + const terminal = this.engine.terminal(node.id); + if (terminal === undefined) continue; + const window = this.terminalWindows.get(node.id); + if (window === undefined) this.terminalWindows.set(node.id, new TerminalWindow(terminal)); + else window.attach(terminal); + } } /** A new client; it gets the room's state at once. */ @@ -168,13 +233,8 @@ export class SimulationRoom { this.engine.setSimulationSpeed(this.speed); if (!this.guarded(() => this.engine.handle({ type: 'start', snapshot, seed: this.options.seed(), initialTimeMilliseconds: this.engine.state.currentTimeMilliseconds }))) return; this.linkOfPort = new Map(snapshot.links.flatMap((link) => [[link.sourcePortId, link.id] as const, [link.targetPortId, link.id] as const])); - for (const node of snapshot.nodes) { - const terminal = this.engine.terminal(node.id); - if (terminal === undefined) continue; - const window = this.terminalWindows.get(node.id); - if (window === undefined) this.terminalWindows.set(node.id, new TerminalWindow(terminal)); - else window.attach(terminal); - } + this.software = this.documentSoftware(); + this.syncTerminalWindows(); this.mode = 'simulation'; this.lastWall = this.options.now(); this.broadcast(this.state()); diff --git a/packages/sim/src/terminalWindow.ts b/packages/sim/src/terminalWindow.ts index 9bb303a..2c4f7ac 100644 --- a/packages/sim/src/terminalWindow.ts +++ b/packages/sim/src/terminalWindow.ts @@ -32,6 +32,7 @@ export class TerminalWindow { /** The program of a start: the first time the window shows its start screen, later it goes on in the working directory it had. */ attach(terminal: Terminal): void { + if (this.terminal === terminal) return; this.terminal = terminal; this.seen = 0; terminal.continueIn(this.directory); diff --git a/packages/sim/test/terminalWindow.test.ts b/packages/sim/test/terminalWindow.test.ts index 1fb1bdd..3245b41 100644 --- a/packages/sim/test/terminalWindow.test.ts +++ b/packages/sim/test/terminalWindow.test.ts @@ -10,7 +10,7 @@ import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; import * as Y from 'yjs'; -import { addDevice, emptyProject, ROOTS, writeProject } from '@mdg-filius/doc'; +import { addDevice, emptyProject, ROOTS, setSoftware, writeProject } from '@mdg-filius/doc'; import { historyLine, historyStep, terminalScreen, type HostMessage, type TerminalLine } from '@mdg-filius/protocol'; import { SimulationRoom, TICK_MILLISECONDS } from '../src/index.js'; @@ -214,3 +214,46 @@ describe("the window's history", () => { expect([historyLine(['a', 'b'], -1), historyLine(['a', 'b'], 0), historyLine(['a', 'b'], 1)]).toEqual(['', 'b', 'a']); }); }); + +describe('the software installation while the simulation runs', () => { + it("installs a program at once, as Java starts it: a command line to type into, a web server's files", () => { + const { doc, send, run, read, got } = setUp([]); + send({ kind: 'sub', topics: ['app:pc/terminal'] }); + send({ kind: 'start' }); + run(TICK_MILLISECONDS); + expect(read()).toEqual([]); + setSoftware(doc, 'pc', { install: ['commandPrompt', 'webServer'], remove: [] }, 'de', 'me'); + run(TICK_MILLISECONDS); + expect(terminalScreen(read()).text).toContain("Verwende den Befehl 'help'"); + send({ kind: 'input', node: 'pc', app: 'terminal', action: 'enter', args: { line: 'ls /webserver' }, reqId: 'a' }); + run(400); + expect(terminalScreen(read()).text).toMatch(/index\.html[\s\S]*splashscreen-mini\.png/); + expect(got.filter((message) => message.kind === 'error')).toEqual([]); + + // Removed: the window stays with its text, and takes no line. + setSoftware(doc, 'pc', { install: [], remove: ['commandPrompt'] }, 'de', 'me'); + const shown = terminalScreen(read()).text; + send({ kind: 'input', node: 'pc', app: 'terminal', action: 'enter', args: { line: 'pwd' }, reqId: 'b' }); + expect(got.filter((message) => message.kind === 'error')).toEqual([{ v: 1, kind: 'error', code: 'unknownApp', reqId: 'b' }]); + expect(terminalScreen(read()).text).toBe(shown); + // Installed again, it goes on in the same window. + setSoftware(doc, 'pc', { install: ['commandPrompt'], remove: [] }, 'de', 'me'); + send({ kind: 'input', node: 'pc', app: 'terminal', action: 'enter', args: { line: 'pwd' }, reqId: 'c' }); + run(400); + expect(terminalScreen(read()).text.startsWith(shown)).toBe(true); + expect(terminalScreen(read()).text.slice(shown.length)).toBe('\n/> pwd\n/'); + }); + + it('leaves the running engine alone in the design, and starts with the programs then', () => { + const { doc, send, run, read } = setUp([]); + send({ kind: 'sub', topics: ['app:pc/terminal'] }); + send({ kind: 'start' }); + send({ kind: 'stop' }); + setSoftware(doc, 'pc', { install: ['commandPrompt'], remove: [] }, 'de', 'me'); + expect(read()).toEqual([]); + send({ kind: 'start' }); + run(TICK_MILLISECONDS); + expect(terminalScreen(read()).text).toContain("Verwende den Befehl 'help'"); + }); +}); + diff --git a/tools/java-oracle/README.md b/tools/java-oracle/README.md index 6507679..8fb277e 100644 --- a/tools/java-oracle/README.md +++ b/tools/java-oracle/README.md @@ -1265,3 +1265,30 @@ Gemessen (03.10.2026): Javas Terminal setzt den Ordner nur bei der Installation (`setSystemSoftware`), und das Fenster ist dasselbe Objekt. +### Software-Installation (F4.6c) + +`InstallRun` installiert jedes Programm aus Javas Liste, wie +`GUIInstallationsDialog.aenderungenSpeichern` es tut +(`installAndStartAppIfAvailable`), auf einem eigenen neuen Rechner, alle auf +einem („Alle“), jedes einmal mit anschließendem `entferneSoftware` +(„Entfernt-…“), dazu einen DNS-Server mit Eintrag und einen E-Mail-Server +mit Konto, die entfernt und neu installiert werden („Wieder-…“); Java +speichert das als Projekt (`packages/doc/test/fixtures/installed-programs.fls`). + +Gemessen (03.10.2026): + +* Ein neues Programm ist aus (kein Server läuft); die Firewall eines + Rechners dagegen ist sofort aktiv (verwerfen, UDP filtern). +* Es legt an, was fehlt: der Webserver `/webserver` mit Javas Startseite in + der Sprache, in der FILIUS läuft, und `splashscreen-mini.png` (nur, wenn + noch keine `index.html` da ist), Gnutella `/peer2peer`, der E-Mail-Server + `/mailserver/konten.txt` (leer), der DNS-Server `/dns/hosts` (leer). +* Entfernt, verschwinden Programm und Einstellungen; die Dateien bleiben. + Neu installiert, liest der DNS-Server seine Einträge aus `/dns/hosts`, der + E-Mail-Server seine Konten aus `konten.txt`. +* Gnutellas Teilnehmerzahl würfelt der Konstruktor (3 bis 5); die Datei + verweist darauf mit `idref` auf irgendeine frühere gleiche Zahl — hier den + Rückgabewert von `iconTextGap` (4) oder einer Thread-Priorität (5). + Darum misst `GetterDefaults` jetzt auch `#priority` der gespeicherten + Thread-Klassen (alle 5), die vorher unaufgelöst als 0 gelesen wurden. + diff --git a/tools/java-oracle/run.sh b/tools/java-oracle/run.sh index 6b0ce15..4871a0f 100755 --- a/tools/java-oracle/run.sh +++ b/tools/java-oracle/run.sh @@ -21,7 +21,7 @@ if [ ! -f "$upstream/target/filius.jar" ]; then fi classpath="$upstream/target/filius.jar:$upstream/target/lib/*" mkdir -p "$here/.build" -javac -encoding UTF-8 -d "$here/.build" -cp "$classpath" "$here/src/FlsOracle.java" "$here/src/GetterDefaults.java" "$here/src/IpCheckCases.java" "$here/src/DnsHostsCases.java" "$here/src/EmailFileCases.java" "$here/src/BeansDump.java" "$here/src/Base64Cases.java" "$here/src/ScenarioNetwork.java" "$here/src/DhcpRun.java" "$here/src/TcpRun.java" "$here/src/DnsRun.java" "$here/src/ArpRun.java" "$here/src/RipRun.java" "$here/src/NatRun.java" "$here/src/WlanRun.java" "$here/src/ExampleRun.java" "$here/src/EchoRun.java" "$here/src/WebRun.java" "$here/src/RouterWebRun.java" "$here/src/KonfigCases.java" "$here/src/EmailRun.java" "$here/src/MailCases.java" "$here/src/EmailClientRun.java" "$here/src/TerminalCases.java" "$here/src/TerminalRun.java" "$here/src/FileCases.java" "$here/src/GnutellaRun.java" "$here/src/NewDeviceRun.java" "$here/src/SheetRun.java" "$here/src/TerminalWindowRun.java" +javac -encoding UTF-8 -d "$here/.build" -cp "$classpath" "$here/src/FlsOracle.java" "$here/src/GetterDefaults.java" "$here/src/IpCheckCases.java" "$here/src/DnsHostsCases.java" "$here/src/EmailFileCases.java" "$here/src/BeansDump.java" "$here/src/Base64Cases.java" "$here/src/ScenarioNetwork.java" "$here/src/DhcpRun.java" "$here/src/TcpRun.java" "$here/src/DnsRun.java" "$here/src/ArpRun.java" "$here/src/RipRun.java" "$here/src/NatRun.java" "$here/src/WlanRun.java" "$here/src/ExampleRun.java" "$here/src/EchoRun.java" "$here/src/WebRun.java" "$here/src/RouterWebRun.java" "$here/src/KonfigCases.java" "$here/src/EmailRun.java" "$here/src/MailCases.java" "$here/src/EmailClientRun.java" "$here/src/TerminalCases.java" "$here/src/TerminalRun.java" "$here/src/FileCases.java" "$here/src/GnutellaRun.java" "$here/src/NewDeviceRun.java" "$here/src/SheetRun.java" "$here/src/TerminalWindowRun.java" "$here/src/InstallRun.java" # FILIUS writes its settings below $HOME; keep them out of the real one. mkdir -p "$here/.build/home" # Removed first: a crashed run must not leave the previous result standing. @@ -57,6 +57,21 @@ HOME="$here/.build/home" xvfb-run -a java -cp "$classpath:$here/.build" GetterDe filius.software.www.WebServer#aktiv \ filius.software.www.WebServer#useVHost \ filius.software.dateiaustausch.PeerToPeerAnwendung#maxTeilnehmerZahl \ + filius.software.dhcp.DHCPServer#priority \ + filius.software.rip.RIPServer#priority \ + filius.software.lokal.Terminal#priority \ + filius.software.lokal.ImageViewer#priority \ + filius.software.lokal.FileExplorer#priority \ + filius.software.dns.DNSServer#priority \ + filius.software.email.EmailAnwendung#priority \ + filius.software.email.EmailServer#priority \ + filius.software.clientserver.ServerBaustein#priority \ + filius.software.clientserver.ClientBaustein#priority \ + filius.software.firewall.Firewall#priority \ + filius.software.dateiaustausch.PeerToPeerAnwendung#priority \ + filius.software.lokal.TextEditor#priority \ + filius.software.www.WebServer#priority \ + filius.software.www.WebBrowser#priority \ 2>&1 | grep -v 'logback\|^$' || true test -s "$defaults" @@ -328,6 +343,14 @@ rm -f "$output" HOME="$here/.build/home" xvfb-run -a java -cp "$classpath:$here/.build" NewDeviceRun "$output" 2>&1 | grep -v 'logback\|^$' || true test -s "$output" +# FILIUS' programs as its software installation installs and removes them +# (InstallRun), saved as Java saves a project: the editor's installation +# (packages/doc/test/install.test.ts). +output="$root/packages/doc/test/fixtures/installed-programs.fls" +rm -f "$output" +HOME="$here/.build/home" xvfb-run -a java -Duser.language=de -Duser.country=DE -cp "$classpath:$here/.build" InstallRun "$output" 2>&1 | grep -v 'logback\|^$\|INFO' || true +test -s "$output" + # Text typed into FILIUS' settings panels of a host, a switch and a modem and # taken as they take it (SheetRun): what the editor's device sheet marks and # keeps (packages/doc/test/sheet.test.ts). diff --git a/tools/java-oracle/src/InstallRun.java b/tools/java-oracle/src/InstallRun.java new file mode 100644 index 0000000..364a3bc --- /dev/null +++ b/tools/java-oracle/src/InstallRun.java @@ -0,0 +1,81 @@ +// Installs FILIUS' programs as its software installation does +// (GUIInstallationsDialog.aenderungenSpeichern: installAndStartAppIfAvailable, +// entferneSoftware) on new computers and saves them as Java saves a project +// (SzenarioVerwaltung.speichern): what a program brings along when it is +// installed — its settings, its folders and files — and what stays when it +// is removed. The reference for the editor's software installation +// (packages/doc/test/install.test.ts). One computer per program, named after +// its class's simple name; "Alle" has them all; "Entfernt-" had the +// program installed and removed again; "Wieder-" had a DNS server with +// a record or an e-mail server with an account installed, removed and +// installed again. +// +// Usage: java -cp filius.jar:lib/*:classes InstallRun + +import filius.gui.GUIContainer; +import filius.gui.netzwerksicht.GUIDesignSidebar; +import filius.gui.netzwerksicht.GUIDocuItem; +import filius.gui.netzwerksicht.GUIKabelItem; +import filius.gui.netzwerksicht.GUIKnotenItem; +import filius.hardware.knoten.Rechner; +import filius.rahmenprogramm.Information; +import filius.rahmenprogramm.SzenarioVerwaltung; +import filius.software.system.Betriebssystem; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +public class InstallRun { + public static void main(String[] args) throws Exception { + try { + run(args); + } catch (Throwable error) { + error.printStackTrace(); + System.exit(1); + } + System.exit(0); + } + + static Rechner computer(List items, String name, int place) { + Rechner host = new Rechner(); + host.setName(name); + NewDeviceRun.add(items, host, GUIDesignSidebar.RECHNER, 100 + 150 * (place % 6), 100 + 150 * (place / 6)); + return host; + } + + static void run(String[] args) throws Exception { + GUIContainer.getGUIContainer(); + List items = new ArrayList<>(); + List> programs = Information.getInformation().ladeProgrammListe(); + int place = 0; + Rechner all = computer(items, "Alle", place++); + for (Map program : programs) { + String className = program.get("Klasse"); + String name = className.substring(className.lastIndexOf('.') + 1); + Betriebssystem system = (Betriebssystem) computer(items, name, place++).getSystemSoftware(); + if (!system.installAndStartAppIfAvailable(className)) throw new IllegalStateException("not installed: " + className); + ((Betriebssystem) all.getSystemSoftware()).installAndStartAppIfAvailable(className); + Betriebssystem removed = (Betriebssystem) computer(items, "Entfernt-" + name, place++).getSystemSoftware(); + removed.installAndStartAppIfAvailable(className); + if (!removed.entferneSoftware(className)) throw new IllegalStateException("not removed: " + className); + } + // Installed again where a removed program left its files: a DNS server with a record, an e-mail server with an account. + Betriebssystem dns = (Betriebssystem) computer(items, "Wieder-DNSServer", place++).getSystemSoftware(); + dns.installAndStartAppIfAvailable("filius.software.dns.DNSServer"); + ((filius.software.dns.DNSServer) dns.holeSoftware("filius.software.dns.DNSServer")).hinzuRecord("www.filius.de", "A", "1.2.3.4"); + dns.entferneSoftware("filius.software.dns.DNSServer"); + dns.installAndStartAppIfAvailable("filius.software.dns.DNSServer"); + Betriebssystem mail = (Betriebssystem) computer(items, "Wieder-EmailServer", place++).getSystemSoftware(); + mail.installAndStartAppIfAvailable("filius.software.email.EmailServer"); + ((filius.software.email.EmailServer) mail.holeSoftware("filius.software.email.EmailServer")).benutzerHinzufuegen("anna", "geheim", "Muster", "Anna"); + mail.entferneSoftware("filius.software.email.EmailServer"); + mail.installAndStartAppIfAvailable("filius.software.email.EmailServer"); + // Let the programs' threads come to rest before the files are read. + Thread.sleep(1000); + if (!SzenarioVerwaltung.getInstance().speichern(args[0], items, new ArrayList(), new ArrayList())) { + throw new IllegalStateException("FILIUS could not save " + args[0]); + } + System.out.println("computers: " + items.size()); + } +}