feat(editor): F4.6c the software installation on a computer's desktop
A new oracle, InstallRun, installs each of Java's programs on new computers, all of them on one, each installed and removed again, and a DNS and an e-mail server installed again after their removal; Java saves the result as a project. A new program is switched off (a computer's firewall is on), makes the folders and files it lacks (the web server's start page in FILIUS' language and its picture, /peer2peer, /mailserver/konten.txt, /dns/hosts), and removing it takes its settings and leaves its files; a server installed again reads its records or accounts from them. Java installs in the order of its list and starts the program at once. The document's setSoftware does the same, checked against Java's saved installation. The core knows what a program makes in the files and installs or removes a program while the simulation runs; the room watches the document's programs and hands changes to the engine, so a new command line works at once. The desktop offers Java's dialog with two lists, arrows, double click and apply changes. Also: Java's file may name a number it rolled by the result of any getter (an idref, here a thread's priority); GetterDefaults now measures the priority of the saved thread classes, which the import read as 0 before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -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_<language>.txt as Java reads it: each line ended by "\n". */
|
||||
const WEB_SERVER_INDEX: Record<PageLanguage, string> = {
|
||||
de: "<html>\n <head>\n <title>Standardseite</title>\n </head>\n <body bgcolor=\"#ccddff\" style=\"font-family:Verdana; text-align:center;\">\n <h2> FILIUS - Webserver </h2>\n\n <p>Herzlich Willkommen auf dem Webserver der Anwendung FILIUS!</p>\n\n <p> Diese Seite wurde automatisch mit der Installation des \n Webservers eingerichtet, es lassen sich jedoch auch \n eigene Seiten hier unterbringen. </p>\n\n <p align=\"center\"> <img src=\"splashscreen-mini.png\"> </p>\n\n <p> https://www.lernsoftware-filius.de </p>\n </body>\n</html>\n",
|
||||
en: "<html>\n <head>\n <title>Standardseite</title>\n </head>\n <body bgcolor=\"#ccddff\" style=\"font-family:Verdana; text-align:center;\">\n <h2> FILIUS - Webserver </h2>\n\n <p>Welcome at FILIUS!</p>\n\n <p> This page has been delivered as default page.\n Feel free to add your own contents! </p>\n\n <p align=\"center\"> <img src=\"splashscreen-mini.png\"> </p>\n\n <p> https://www.lernsoftware-filius.de </p>\n </body>\n</html>\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 [];
|
||||
}
|
||||
}
|
||||
@@ -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<K, V>(map: Map<K, V>, 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 = <T>(own: ReadonlyMap<Id, T>, theirs: ReadonlyMap<Id, T>): ReadonlyMap<Id, T> => {
|
||||
const value = theirs.get(nodeId);
|
||||
return value === undefined ? own : new Map([...own, [nodeId, value]]);
|
||||
};
|
||||
const added = (own: ReadonlySet<Id>, theirs: ReadonlySet<Id>): ReadonlySet<Id> => (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 = <T>(own: ReadonlyMap<Id, T>): ReadonlyMap<Id, T> => new Map([...own].filter(([id]) => id !== nodeId));
|
||||
const withoutId = (own: ReadonlySet<Id>): ReadonlySet<Id> => 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
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user