Files
mdg-filius/packages/sim/src/room.ts
T
julian.kiedaischandClaude Opus 5.5 5224e1a28b feat: F4.6g4 the Gnutella window, measured against Java's
GnutellaWindowRun uses Java's Gnutella window on terminal-net beside the
line of GnutellaRun's story "search". The core cancels a search as Java
does (every connection of the program's own requests closed, passed-on
queries kept), lets the number of peers change and lists /peer2peer's
files; the room takes the window's actions and sends its view; the editor
shows Java's four tabs with Java's symbols. Not adopted from Java: a number
of peers of one digit only, a results table that keeps the last search's
rows, a files table that lags behind the folder.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 21:39:29 +00:00

959 lines
44 KiB
TypeScript

// A simulation room (plan §6): one engine for one project, the clients that
// watch it, and what goes to them. The same room runs on F for everyone in a
// project and in a web worker for one (LocalSimHost); it takes the
// document's snapshot when the simulation starts, so that changes made while
// it runs come in at the next start, as in Java.
//
// Time: the engine's clock follows the wall clock, a millisecond for a
// millisecond, as Java's threads do; the speed is Java's slider, the delay of
// a cable (RuntimeSpeed), not a time lapse — unlike Swift's tick (plan §6.3).
// The engine counts whole milliseconds, as Java's clock does; the wall
// clock's fractions are carried to the next tick, so that no time is lost.
// A tick catches up at most MAXIMUM_CATCH_UP_MILLISECONDS: after a tab slept,
// the simulation goes on from where it was rather than running the missed
// minutes in one go.
//
// The engine outlives a stop: the capture stays, as Java's Lauscher does, and
// 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' and browsers' windows, as
// Java's do (TerminalWindow, BrowserWindow).
//
// A program's window may show more than its lines (plan §7.3): the browser's
// page, the web server's button and table, the e-mail program's account and
// folders, the e-mail server's domain and accounts, the echo server's button
// and port, the generic client's connection go to the clients as a view,
// whole, when they change.
//
// 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.
//
// What a run leaves that Java's project keeps (files, which servers run,
// accounts, mails, a DHCP client's address …) goes back into the document,
// as `origin: 'sim'`: at most once a second while it runs, after each action
// in a window and when it stops (writeRunState). So the next start, and every
// client, sees it — as Java's project saved after a run holds it.
//
// Files go both ways while it runs (plan §7.3: the text editor writes the
// document's Y.Text): what a person writes into the document goes into the
// engine's files at once (readFilesInto), and what the engine changed in its
// files goes into the document at the tick it changed, not a second later —
// so the document holds every change of the engine before a person's next
// one comes in, and a text two write at once keeps both.
import * as Y from 'yjs';
import {
accountOfForm,
DEFAULT_SPEED_PERCENT,
NetworkRuntimeEngine,
captureRows,
firewallRuleText,
gnutellaPeerCountOfText,
gnutellaResultRow,
interfaceKey,
IP_ADDRESS_PATTERN,
portRuleSender,
stableGeneratedMacAddress,
type CapturedFrame,
type CaptureRow,
type Id,
type InstallableProgram,
type LogLine,
type PageLanguage,
type TopologySnapshot,
} from '@mdg-filius/core';
import { docSnapshot, emailServerAfterRun, fromY, installedSoftware, readFilesInto, ROOTS, SIM_ORIGIN, writeRunState, type NodeRunState } from '@mdg-filius/doc';
import {
parseClientMessage,
SIM_PROTOCOL_VERSION,
type AppKind,
type ClientMessage,
type ErrorCode,
type HostMessage,
type NodeSettings,
type SimMode,
type Topic,
type EmailClientView,
type EmailServerView,
type DnsServerView,
type EchoServerView,
type FirewallView,
type GnutellaView,
type SimpleClientView,
type TraceFrame,
type WebServerView,
} from '@mdg-filius/protocol';
import { BrowserWindow } from './browserWindow.js';
import { TerminalWindow } from './terminalWindow.js';
export const TICK_MILLISECONDS = 200;
/** How often a running simulation writes what it left into the document. */
export const WRITE_BACK_MILLISECONDS = 1_000;
export const MAXIMUM_CATCH_UP_MILLISECONDS = 2_000;
export interface RoomOptions {
/** The wall clock, in milliseconds. */
readonly now: () => number;
/** A seed for each start. */
readonly seed: () => number;
/** How often a running simulation writes what it left into the document; WRITE_BACK_MILLISECONDS by default. */
readonly writeBackMilliseconds?: number;
}
interface Client {
readonly send: (message: HostMessage) => void;
readonly topics: Set<Topic>;
/** Per card, the number of the last frame sent. */
readonly traceSent: Map<Id, number>;
/** Per program window, how many of its program's lines it has; none while the program does not run (a stop empties it). */
readonly appSent: Map<string, number>;
/** Per computer, the settings last sent (as text). */
readonly nodeSent: Map<Id, string>;
/** Per program window, the view last sent: the browser's as it is, others as text. */
readonly viewSent: Map<string, unknown>;
}
/** A program with a window: its lines. */
interface WithLog {
readonly log: { readonly all: readonly LogLine<unknown>[] };
}
const message = <T extends Omit<HostMessage, 'v'>>(fields: T) => ({ v: SIM_PROTOCOL_VERSION, ...fields }) as unknown as HostMessage;
export class SimulationRoom {
private readonly engine: NetworkRuntimeEngine;
private mode: SimMode = 'design';
private speed = DEFAULT_SPEED_PERCENT;
private readonly clients = new Map<number, Client>();
private nextClient = 1;
private lastWall: number | undefined;
/** The cable each card is plugged into, as the simulation started. */
private linkOfPort = new Map<Id, Id>();
/** Per card, how many frames it held at the last tick. */
private framesSeen = new Map<Id, number>();
private lastActivity: string[] = [];
/** Each computer's command line window, kept over stops. */
private readonly terminalWindows = new Map<Id, TerminalWindow>();
/** Each computer's browser window, kept over stops. */
private readonly browserWindows = new Map<Id, BrowserWindow>();
/** The language of the last start (the pages the devices write themselves). */
private language: PageLanguage | undefined;
/** Each node's file system and its revision when it was last written back. */
private writtenFiles = new Map<Id, { readonly files: object; readonly revision: number }>();
/** The wall clock's time of the last write-back. */
private lastWriteBack = 0;
/** Each computer's programs as the running engine has them. */
private software = new Map<Id, readonly InstallableProgram[]>();
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());
});
doc.getMap<Y.Map<unknown>>(ROOTS.nodes).observeDeep((events, transaction) => {
// The room's own writes hold what the engine has.
if (transaction.origin === SIM_ORIGIN) return;
const nodes = new Set<Id>();
for (const event of events) {
const [node, key] = event.path as string[];
// A device added or removed comes in at the next start.
if (node !== undefined && (key === 'files' || (key === undefined && event.keys.has('files')))) nodes.add(node);
}
if (nodes.size > 0) this.guarded(() => this.syncFiles(nodes));
});
}
/** The document's files of the nodes into the running engine's (a person wrote them). */
private syncFiles(nodes: ReadonlySet<Id>): void {
const fileSystems = this.engine.state.topologySnapshot.fileSystemsByNodeId;
for (const id of nodes) {
const files = fileSystems.get(id);
// A node whose programs use no files has none in the engine; it reads the document's at the next start.
if (files !== undefined && readFilesInto(this.doc, id, files)) this.writtenFiles.set(id, { files, revision: files.revision });
}
}
/** Whether a node's files changed since they were last written back; marked written if so. */
private takeChangedFiles(id: Id, files: { readonly revision: number } | undefined): boolean {
const written = this.writtenFiles.get(id);
const changed = files !== undefined && (written?.files !== files || written.revision !== files.revision);
if (files !== undefined && changed) this.writtenFiles.set(id, { files, revision: files.revision });
return changed;
}
/** The computers' programs in the document. */
private documentSoftware(): Map<Id, readonly InstallableProgram[]> {
const software = new Map<Id, readonly InstallableProgram[]>();
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 = this.snapshot();
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.syncWindows();
this.flush([...this.clients.values()], false);
}
/**
* What the run left of each node that Java's project keeps: its files (when
* they changed since the last write), whether its servers run, a web
* server's virtual hosts, an e-mail server's domain and accounts, an
* e-mail program's account and folders, a firewall, Gnutella's number of
* peers, a DHCP client's address.
*/
private runState(): Map<Id, NodeRunState> {
const snapshot = this.engine.state.topologySnapshot;
const states = new Map<Id, NodeRunState>();
const nodes = this.doc.getMap<Y.Map<unknown>>(ROOTS.nodes);
for (const { id } of snapshot.nodes) {
const node = nodes.get(id);
if (node === undefined) continue;
const setting = (key: string) => fromY((node.get('settings') as Y.Map<unknown>).get(key));
const active: NonNullable<NodeRunState['active']> = {};
const settings: Record<string, unknown> = {};
const web = this.engine.webServer(id);
const webSettings = snapshot.webServersByNodeId.get(id);
if (web !== undefined && webSettings !== undefined) {
active.webServer = web.isActive;
settings.webServer = { port: web.port, documentRoot: webSettings.documentRoot, virtualHosts: web.useVirtualHosts };
}
const mailServer = this.engine.emailServer(id);
const mailServerSettings = snapshot.emailServersByNodeId.get(id);
const configured = setting('emailServer');
if (mailServer !== undefined && mailServerSettings !== undefined && configured !== undefined) {
active.emailServer = mailServer.isActive;
settings.emailServer = emailServerAfterRun(configured as never, mailServerSettings);
}
const echo = this.engine.echoServer(id);
if (echo !== undefined) {
active.echoServer = echo.isActive;
settings.echoServer = { port: echo.port };
}
const dns = this.engine.dnsServer(id);
const dnsSettings = setting('dnsServer');
if (dns !== undefined && dnsSettings !== undefined) {
active.dnsServer = dns.isActive;
settings.dnsServer = { ...(dnsSettings as object), recursiveResolutionEnabled: dns.recursiveResolutionEnabled, records: dns.answering };
}
const client = this.engine.simpleClient(id);
if (client !== undefined) settings.simpleClient = { targetAddress: client.targetAddress, targetPort: client.targetPort };
const gnutella = this.engine.gnutella(id);
if (gnutella !== undefined) settings.gnutella = { maximumKnownPeers: gnutella.maximumKnownPeers };
settings.emailClient = snapshot.emailClientsByNodeId.get(id);
settings.firewall = snapshot.firewallConfigurationsByNodeId.get(id);
if (snapshot.dhcpClientConfigurationsByNodeId.get(id)?.isEnabled === true) settings.device = snapshot.deviceConfigurations.get(id);
const files = snapshot.fileSystemsByNodeId.get(id);
states.set(id, { ...(this.takeChangedFiles(id, files) && files !== undefined ? { files } : {}), active, settings });
}
return states;
}
/** Writes what the run left into the document (origin 'sim'). */
private writeBack(): void {
if (this.engine.state.phase !== 'running') return;
this.lastWriteBack = this.options.now();
writeRunState(this.doc, this.runState(), SIM_ORIGIN);
}
/** Writes the files the engine changed since they were last written (origin 'sim'); nothing when none changed. */
private writeFilesBack(): void {
const states = new Map<Id, NodeRunState>();
for (const [id, files] of this.engine.state.topologySnapshot.fileSystemsByNodeId) if (this.takeChangedFiles(id, files)) states.set(id, { files });
if (states.size > 0) writeRunState(this.doc, states, SIM_ORIGIN);
}
/** The document as the engine takes it, in the language of the start. */
private snapshot(): TopologySnapshot {
const snapshot = docSnapshot(this.doc);
return this.language === undefined ? snapshot : { ...snapshot, language: this.language };
}
/** A window for each command line and browser the engine runs, the same over stops (TerminalWindow, BrowserWindow). */
private syncWindows(): void {
for (const node of this.engine.state.topologySnapshot.nodes) {
const terminal = this.engine.terminal(node.id);
if (terminal !== undefined) {
const window = this.terminalWindows.get(node.id);
if (window === undefined) this.terminalWindows.set(node.id, new TerminalWindow(terminal));
else window.attach(terminal);
}
const browser = this.engine.webBrowser(node.id);
if (browser !== undefined) {
const window = this.browserWindows.get(node.id);
if (window === undefined) this.browserWindows.set(node.id, new BrowserWindow(browser));
else window.attach(browser);
}
}
}
/** A new client; it gets the room's state at once. */
connect(send: (message: HostMessage) => void): number {
const id = this.nextClient++;
this.clients.set(id, { send, topics: new Set(), traceSent: new Map(), appSent: new Map(), nodeSent: new Map(), viewSent: new Map() });
send(this.state());
return id;
}
disconnect(client: number): void {
this.clients.delete(client);
}
/** How many clients the room has (F stops a room nobody watches). */
get clientCount(): number {
return this.clients.size;
}
get currentMode(): SimMode {
return this.mode;
}
/** A client's message, as it came over the wire. */
receive(clientId: number, raw: unknown): void {
const client = this.clients.get(clientId);
if (client === undefined) return;
const parsed = parseClientMessage(raw);
if ('error' in parsed) {
const reqId = typeof (raw as { reqId?: unknown } | null)?.reqId === 'string' ? (raw as { reqId: string }).reqId : undefined;
client.send(message({ kind: 'error', code: parsed.error.code, detail: parsed.error.detail, ...(reqId === undefined ? {} : { reqId }) }));
return;
}
this.handle(client, parsed.message);
}
private handle(client: Client, input: ClientMessage): void {
switch (input.kind) {
case 'sub':
for (const topic of input.topics) {
client.topics.add(topic);
if (topic.startsWith('trace:')) client.traceSent.delete(topic.slice('trace:'.length));
if (topic.startsWith('app:')) {
client.appSent.delete(topic);
client.viewSent.delete(topic);
}
if (topic.startsWith('node:')) client.nodeSent.delete(topic.slice('node:'.length));
}
this.flush([client], true);
return;
case 'unsub':
for (const topic of input.topics) client.topics.delete(topic);
return;
case 'start':
this.start(input.language);
return;
case 'stop':
this.stop();
return;
case 'speed':
this.speed = Math.min(100, Math.max(1, Math.trunc(input.value)));
this.engine.setSimulationSpeed(this.speed);
this.broadcast(this.state());
return;
case 'input': {
let failure: ErrorCode | undefined = 'engineFailed';
if (!this.guarded(() => (failure = this.act(input)))) return;
if (failure === undefined) this.guarded(() => this.writeBack());
client.send(failure === undefined ? message({ kind: 'ack', reqId: input.reqId }) : message({ kind: 'error', code: failure, reqId: input.reqId }));
this.flush([...this.clients.values()], false);
return;
}
}
}
private state(): HostMessage {
return message({ kind: 'state', mode: this.mode, speed: this.speed, t: this.engine.state.currentTimeMilliseconds });
}
private broadcast(output: HostMessage): void {
for (const client of this.clients.values()) client.send(output);
}
start(language?: PageLanguage): void {
if (this.mode === 'simulation') return;
this.language = language;
const snapshot = this.snapshot();
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]));
this.software = this.documentSoftware();
this.syncWindows();
this.mode = 'simulation';
this.lastWall = this.options.now();
this.lastWriteBack = this.lastWall;
this.broadcast(this.state());
this.flush([...this.clients.values()], false);
}
stop(): void {
if (this.mode === 'design') return;
// A failure already stopped the engine and went back to the design.
if (!this.guarded(() => this.writeBack())) return;
this.engine.handle({ type: 'stop' });
this.mode = 'design';
this.lastWall = undefined;
this.broadcast(this.state());
this.flush([...this.clients.values()], false);
}
/** Lets the simulation run to the wall clock's time, then sends what came of it. */
tick(): void {
if (this.mode !== 'simulation') return;
const now = this.options.now();
const behind = Math.max(0, now - (this.lastWall ?? now));
// Whole milliseconds, as the engine counts them; the wall clock's fraction is kept for the next tick.
const elapsed = Math.min(MAXIMUM_CATCH_UP_MILLISECONDS, Math.floor(behind));
this.lastWall = behind > MAXIMUM_CATCH_UP_MILLISECONDS ? now : (this.lastWall ?? now) + elapsed;
if (!this.guarded(() => this.engine.handle({ type: 'advance', toMilliseconds: this.engine.state.currentTimeMilliseconds + elapsed }))) return;
this.flush([...this.clients.values()], false);
if (now - this.lastWriteBack >= (this.options.writeBackMilliseconds ?? WRITE_BACK_MILLISECONDS)) this.guarded(() => this.writeBack());
else this.guarded(() => this.writeFilesBack());
}
/**
* Runs a step of the engine. When the core throws (a part it has not got
* yet), the room tells every client and goes back to design mode instead of
* dying with its worker; false then.
*/
private guarded(step: () => void): boolean {
try {
step();
return true;
} catch (error) {
this.broadcast(message({ kind: 'error', code: 'engineFailed', detail: error instanceof Error ? error.message : String(error) }));
if (this.engine.state.phase === 'running') this.engine.handle({ type: 'stop' });
this.mode = 'design';
this.lastWall = undefined;
this.broadcast(this.state());
this.flush([...this.clients.values()], false);
return false;
}
}
/** An action in a program's window; the failure, if any. */
private act(input: Extract<ClientMessage, { kind: 'input' }>): ErrorCode | undefined {
if (this.mode !== 'simulation') return 'notRunning';
if (!this.engine.state.topologySnapshot.nodes.some((node) => node.id === input.node)) return 'unknownNode';
switch (input.app) {
case 'terminal':
return this.actInTerminal(input);
case 'webBrowser':
return this.actInBrowser(input);
case 'webServer':
return this.actInWebServer(input);
case 'emailClient':
return this.actInEmailClient(input);
case 'emailServer':
return this.actInEmailServer(input);
case 'echoServer':
return this.actInEchoServer(input);
case 'simpleClient':
return this.actInSimpleClient(input);
case 'dnsServer':
return this.actInDnsServer(input);
case 'firewall':
return this.actInFirewall(input);
case 'gnutella':
return this.actInGnutella(input);
default:
return 'unknownAction';
}
}
/** The start button, the box for recursive resolution, a table's "Hinzufügen", "Auswahl entfernen" and a cell written. */
private actInDnsServer(input: Extract<ClientMessage, { kind: 'input' }>): ErrorCode | undefined {
const server = this.engine.dnsServer(input.node);
if (server === undefined) return 'unknownApp';
const { args } = input;
const type = args.type === 'A' || args.type === 'MX' || args.type === 'NS' ? args.type : undefined;
const row = Number.isInteger(args.row) ? (args.row as number) : undefined;
switch (input.action) {
case 'setActive':
if (typeof args.active !== 'boolean') return 'badArgs';
server.setActive(args.active);
return undefined;
case 'setRecursive':
if (typeof args.enabled !== 'boolean') return 'badArgs';
server.setRecursiveResolutionEnabled(args.enabled);
return undefined;
case 'addRecord':
if (type === undefined || typeof args.name !== 'string' || typeof args.data !== 'string') return 'badArgs';
return server.addRecord(type, args.name, args.data) ? undefined : 'badArgs';
case 'removeRecord':
if (type === undefined || row === undefined) return 'badArgs';
return server.removeRecord(type, row) ? undefined : 'badArgs';
case 'changeRecord':
if (type === undefined || row === undefined || (args.column !== 0 && args.column !== 1) || typeof args.value !== 'string') return 'badArgs';
return server.changeRecord(type, row, args.column, args.value) ? undefined : 'badArgs';
default:
return 'unknownAction';
}
}
/** The Gnutella window: "Netz beitreten" with the address, "Netz verlassen", "Suchen" with the words, "Stopp!", "Liste leeren", "Herunterladen" of the row chosen, the number of peers saved. */
private actInGnutella(input: Extract<ClientMessage, { kind: 'input' }>): ErrorCode | undefined {
const peer = this.engine.gnutella(input.node);
if (peer === undefined) return 'unknownApp';
const { args } = input;
switch (input.action) {
case 'join':
if (typeof args.address !== 'string' || !IP_ADDRESS_PATTERN.test(args.address)) return 'badArgs';
peer.join(args.address);
return undefined;
case 'leave':
peer.reset();
return undefined;
case 'search':
if (typeof args.criteria !== 'string') return 'badArgs';
peer.search(args.criteria);
return undefined;
case 'cancelSearch':
peer.cancelSearch();
return undefined;
case 'clearResults':
peer.clearResults();
return undefined;
case 'download':
if (!Number.isInteger(args.row) || (args.row as number) < 0 || (args.row as number) >= peer.results.length) return 'badArgs';
peer.download(args.row as number);
return undefined;
case 'setMaximumPeers': {
const count = Number.isInteger(args.count) ? gnutellaPeerCountOfText(String(args.count)) : undefined;
if (count === undefined) return 'badArgs';
peer.setMaximumKnownPeers(count);
return undefined;
}
default:
return 'unknownAction';
}
}
/** A computer's firewall window: its three boxes, "Hinzufügen" with the port, the protocol and the senders chosen, "Löschen" of the row chosen. */
private actInFirewall(input: Extract<ClientMessage, { kind: 'input' }>): ErrorCode | undefined {
const configuration = this.engine.personalFirewall(input.node);
if (configuration === undefined) return 'unknownApp';
const { args } = input;
const set = (fields: Partial<typeof configuration>) => this.engine.setFirewallConfiguration(input.node, { ...configuration, ...fields });
switch (input.action) {
case 'setActive':
if (typeof args.active !== 'boolean') return 'badArgs';
set({ isActive: args.active });
return undefined;
case 'setIcmpAllowed':
if (typeof args.allowed !== 'boolean') return 'badArgs';
set({ dropIcmp: !args.allowed });
return undefined;
case 'setFilterUdp':
if (typeof args.enabled !== 'boolean') return 'badArgs';
set({ filterUdp: args.enabled });
return undefined;
case 'addRule':
if (!isPort(args.port) || args.port === 0 || (args.protocol !== 'tcp' && args.protocol !== 'udp' && args.protocol !== 'all') || typeof args.sameNetworkOnly !== 'boolean') return 'badArgs';
this.engine.addFirewallPortRule(input.node, args.port, args.protocol, args.sameNetworkOnly);
return undefined;
case 'removeRule':
return Number.isInteger(args.row) && this.engine.removeFirewallRule(input.node, args.row as number) ? undefined : 'badArgs';
default:
return 'unknownAction';
}
}
/** The start button with the port typed (Java's window sets the port before and after it starts the server). */
private actInEchoServer(input: Extract<ClientMessage, { kind: 'input' }>): ErrorCode | undefined {
const server = this.engine.echoServer(input.node);
if (server === undefined) return 'unknownApp';
const { args } = input;
if (input.action !== 'setActive') return 'unknownAction';
if (typeof args.active !== 'boolean') return 'badArgs';
if (!args.active) {
server.setActive(false);
return undefined;
}
if (!isPort(args.port)) return 'badArgs';
server.setPort(args.port);
server.setActive(true);
return undefined;
}
/** "Verbinden" with the address and port typed, "Trennen", "Senden". */
private actInSimpleClient(input: Extract<ClientMessage, { kind: 'input' }>): ErrorCode | undefined {
const client = this.engine.simpleClient(input.node);
if (client === undefined) return 'unknownApp';
const { args } = input;
switch (input.action) {
case 'connect':
if (typeof args.address !== 'string' || !isPort(args.port)) return 'badArgs';
client.connect(args.address, args.port);
return undefined;
case 'disconnect':
client.disconnect();
return undefined;
case 'send':
if (typeof args.text !== 'string') return 'badArgs';
client.send(args.text);
return undefined;
default:
return 'unknownAction';
}
}
/** The account dialog (setAccount), the compose window (send), fetching and deleting a mail of a folder. */
private actInEmailClient(input: Extract<ClientMessage, { kind: 'input' }>): ErrorCode | undefined {
const client = this.engine.emailClient(input.node);
if (client === undefined) return 'unknownApp';
const { args } = input;
const text = (...names: string[]) => names.every((name) => typeof args[name] === 'string');
switch (input.action) {
case 'setAccount': {
if (!text('name', 'email', 'pop3Host', 'pop3Port', 'smtpHost', 'smtpPort', 'username', 'password')) return 'badArgs';
const account = accountOfForm(args as unknown as Parameters<typeof accountOfForm>[0]);
if (account === undefined) return 'badArgs';
client.setAccount(account);
return undefined;
}
case 'send':
if (!text('to', 'cc', 'bcc', 'subject', 'text')) return 'badArgs';
return client.send(args as unknown as Parameters<typeof client.send>[0]) === undefined ? undefined : 'badArgs';
case 'fetch':
// Without an account Java's button does nothing.
client.fetch();
return undefined;
case 'removeMail':
if ((args.folder !== 'inbox' && args.folder !== 'sent') || !Number.isInteger(args.index)) return 'badArgs';
client.removeMail(args.folder, args.index as number);
return undefined;
default:
return 'unknownAction';
}
}
/** The start button, the domain (only while the server is off, as Java's field), a new account and the removal of one. */
private actInEmailServer(input: Extract<ClientMessage, { kind: 'input' }>): ErrorCode | undefined {
const server = this.engine.emailServer(input.node);
if (server === undefined) return 'unknownApp';
const { args } = input;
switch (input.action) {
case 'setActive':
if (typeof args.active !== 'boolean') return 'badArgs';
server.setActive(args.active, true);
return undefined;
case 'setDomain':
if (typeof args.domain !== 'string') return 'badArgs';
return !server.isActive && server.setDomain(args.domain) ? undefined : 'badArgs';
case 'addAccount': {
const { username, password, firstName, lastName } = args;
if (typeof username !== 'string' || typeof password !== 'string' || typeof firstName !== 'string' || typeof lastName !== 'string') return 'badArgs';
// The window's checks: a user name and a password, the name without white space (musterKeineLeerzeichen).
if (username === '' || password === '' || /[ \t\n\v\f\r]/.test(username)) return 'badArgs';
return server.addAccount({ username, password, firstName, lastName }) ? undefined : 'badArgs';
}
case 'removeAccount':
if (typeof args.username !== 'string') return 'badArgs';
return server.removeAccount(args.username) ? undefined : 'badArgs';
default:
return 'unknownAction';
}
}
private actInTerminal(input: Extract<ClientMessage, { kind: 'input' }>): ErrorCode | undefined {
// A window has a program while the simulation runs (checked above), from its first start on.
const window = this.terminalWindows.get(input.node);
if (window === undefined || this.engine.terminal(input.node) === undefined) return 'unknownApp';
switch (input.action) {
case 'enter':
if (typeof input.args.line !== 'string') return 'badArgs';
window.enter(input.args.line);
return undefined;
case 'interrupt':
window.interrupt();
return undefined;
default:
return 'unknownAction';
}
}
/** The address line (open) and the page's links and forms (follow). */
private actInBrowser(input: Extract<ClientMessage, { kind: 'input' }>): ErrorCode | undefined {
const window = this.browserWindows.get(input.node);
if (window === undefined || this.engine.webBrowser(input.node) === undefined) return 'unknownApp';
switch (input.action) {
case 'open':
if (typeof input.args.address !== 'string') return 'badArgs';
window.open(input.args.address);
return undefined;
case 'follow': {
const { href, post } = input.args;
if (typeof href !== 'string' || (post !== undefined && typeof post !== 'string')) return 'badArgs';
window.follow(href, post);
return undefined;
}
default:
return 'unknownAction';
}
}
/** The start button, the virtual hosts' check box and a cell of their table. */
private actInWebServer(input: Extract<ClientMessage, { kind: 'input' }>): ErrorCode | undefined {
const server = this.engine.webServer(input.node);
if (server === undefined) return 'unknownApp';
const { args } = input;
switch (input.action) {
case 'setActive':
if (typeof args.active !== 'boolean') return 'badArgs';
server.setActive(args.active);
return undefined;
case 'useVirtualHosts':
if (typeof args.use !== 'boolean') return 'badArgs';
server.setUseVirtualHosts(args.use);
return undefined;
case 'setVirtualHost':
if (!Number.isInteger(args.row) || (args.row as number) < 0 || (args.row as number) >= server.virtualHostTable.length) return 'badArgs';
if ((args.column !== 0 && args.column !== 1) || typeof args.value !== 'string') return 'badArgs';
server.setVirtualHost(args.row as number, args.column, args.value);
return undefined;
default:
return 'unknownAction';
}
}
/** What a program's window shows besides its lines; null when it has nothing (the program does not run, a browser never ran). */
private viewOf(node: Id, app: AppKind): unknown {
switch (app) {
case 'webBrowser':
return this.browserWindows.get(node)?.view ?? null;
case 'webServer': {
const server = this.engine.webServer(node);
if (server === undefined) return null;
const view: WebServerView = { active: server.isActive, useVirtualHosts: server.useVirtualHosts, virtualHosts: server.virtualHostTable.map(([host, folder]) => [host, folder] as const) };
return view;
}
case 'emailClient': {
const client = this.engine.emailClient(node);
if (client === undefined) return null;
const view: EmailClientView = { account: client.account, inbox: client.inbox.map((entry) => entry.mail), sent: client.sent.map((entry) => entry.mail) };
return view;
}
case 'dnsServer': {
const server = this.engine.dnsServer(node);
if (server === undefined) return null;
const rows = (type: 'A' | 'MX' | 'NS') => server.rows(type).map(({ record }) => [record.domainName, record.rdata] as const);
const view: DnsServerView = { active: server.isActive, recursive: server.recursiveResolutionEnabled, rows: { A: rows('A'), MX: rows('MX'), NS: rows('NS') } };
return view;
}
case 'gnutella': {
const peer = this.engine.gnutella(node);
if (peer === undefined) return null;
const view: GnutellaView = { peers: peer.peers, results: peer.results.map(gnutellaResultRow), files: peer.fileRows(), maximumKnownPeers: peer.maximumKnownPeers };
return view;
}
case 'firewall': {
const configuration = this.engine.personalFirewall(node);
if (configuration === undefined) return null;
const first = this.engine.networkInterfaces(node)[0];
const rules = configuration.rules.map((rule) => ({ protocol: rule.protocol, port: rule.port, sender: portRuleSender(rule) ?? null, text: firewallRuleText(rule, first) }));
const view: FirewallView = { active: configuration.isActive, icmpAllowed: !configuration.dropIcmp, filterUdp: configuration.filterUdp, rules };
return view;
}
case 'echoServer': {
const server = this.engine.echoServer(node);
if (server === undefined) return null;
const view: EchoServerView = { active: server.isActive, port: server.port };
return view;
}
case 'simpleClient': {
const client = this.engine.simpleClient(node);
if (client === undefined) return null;
const view: SimpleClientView = { connected: client.isConnected, address: client.targetAddress, port: client.targetPort };
return view;
}
case 'emailServer': {
const server = this.engine.emailServer(node);
if (server === undefined) return null;
const view: EmailServerView = {
active: server.isActive,
domain: server.domain,
accounts: server.state.accounts.map((account) => ({ username: account.username, mails: account.mailbox.length })),
};
return view;
}
default:
return undefined;
}
}
private program(node: Id, app: AppKind): WithLog | undefined {
switch (app) {
case 'terminal':
return this.engine.terminal(node);
case 'webBrowser':
return this.engine.webBrowser(node);
case 'webServer':
return this.engine.webServer(node);
case 'emailClient':
return this.engine.emailClient(node);
case 'emailServer':
return this.engine.emailServer(node);
case 'echoServer':
return this.engine.echoServer(node);
case 'simpleClient':
return this.engine.simpleClient(node);
case 'gnutella':
return this.engine.gnutella(node);
case 'firewall':
// Its log outlives a stop, as the engine keeps it.
return this.engine.personalFirewall(node) === undefined ? undefined : { log: { all: this.engine.firewallLog(node) } };
}
}
/** What the clients are owed: the cables' activity, new frames of the cards and new lines of the windows they watch. */
private flush(clients: readonly Client[], subscribing: boolean): void {
if (!subscribing) this.updateActivity();
for (const window of this.terminalWindows.values()) window.pull();
for (const window of this.browserWindows.values()) window.pull();
const rowsCache = new Map<Id, TraceFrame[]>();
const traceFrames = (portId: Id, after: number): TraceFrame[] => {
const frames = this.engine.capture.frames(portId).filter((frame) => frame.number > after);
if (frames.length === 0) return [];
if (after === 0 && rowsCache.has(portId)) return rowsCache.get(portId)!;
const result = traceFramesOf(frames);
if (after === 0) rowsCache.set(portId, result);
return result;
};
for (const client of clients) {
for (const topic of client.topics) {
if (topic === 'activity') {
if (subscribing) client.send(message({ kind: 'activity', links: this.lastActivity }));
} else if (topic.startsWith('node:')) {
const node = topic.slice('node:'.length);
const settings = this.nodeSettings(node);
if (settings === undefined) continue;
const text = JSON.stringify(settings);
if (client.nodeSent.get(node) === text) continue;
client.nodeSent.set(node, text);
client.send(message({ kind: 'node', node, settings }));
} else if (topic.startsWith('trace:')) {
const portId = topic.slice('trace:'.length);
const reset = !client.traceSent.has(portId);
const frames = traceFrames(portId, client.traceSent.get(portId) ?? 0);
if (frames.length === 0 && !reset) continue;
client.traceSent.set(portId, frames.at(-1)?.number ?? client.traceSent.get(portId) ?? 0);
client.send(message({ kind: 'trace', portId, frames, reset }));
} else {
const [, node, app] = /^app:([^/]+)\/(.+)$/.exec(topic)!;
const view = this.viewOf(node!, app as AppKind);
if (view !== undefined) {
// The browser's view is made anew only when it changes; the server's is small, compared as text.
const key = app === 'webBrowser' ? view : JSON.stringify(view);
if (!client.viewSent.has(topic) || client.viewSent.get(topic) !== key) {
client.viewSent.set(topic, key);
client.send(message({ kind: 'view', node: node!, app: app as AppKind, view }));
}
// The browser's lines are its view; the DNS server's and Gnutella's windows have none.
if (app === 'webBrowser' || app === 'dnsServer' || app === 'gnutella') continue;
}
const sent = client.appSent.get(topic);
if (app === 'terminal') {
// The window's lines, kept over stops: a client goes on from those it has.
const lines = this.terminalWindows.get(node!)?.lines ?? [];
const from = sent ?? 0;
if (sent !== undefined && lines.length === from) continue;
client.appSent.set(topic, lines.length);
client.send(message({ kind: 'app', node: node!, app: 'terminal', from, lines: lines.slice(from) }));
continue;
}
const program = this.program(node!, app as AppKind);
if (program === undefined) {
// A window of a program that is not running: it starts empty with the next start.
if (sent !== undefined || subscribing) client.send(message({ kind: 'app', node: node!, app: app as AppKind, from: 0, lines: [] }));
client.appSent.delete(topic);
continue;
}
// The window goes on from the lines it has; after a stop (none sent) it begins anew.
const from = sent ?? 0;
const lines = program.log.all.slice(from);
if (sent !== undefined && lines.length === 0) continue;
client.appSent.set(topic, program.log.all.length);
client.send(message({ kind: 'app', node: node!, app: app as AppKind, from, lines }));
}
}
}
}
/**
* A device's first card as the engine runs it (a computer's settings, which
* DHCP may change); undefined for a device the simulation never had.
*/
private nodeSettings(id: Id): NodeSettings | undefined {
const snapshot = this.engine.state.topologySnapshot;
const node = snapshot.nodes.find((each) => each.id === id);
if (node === undefined) return undefined;
const port = node.ports[0];
const device = snapshot.deviceConfigurations.get(id);
const card = port === undefined ? undefined : snapshot.interfaceConfigurations.get(interfaceKey(id, port.id));
return {
ipAddress: device?.ipAddress ?? card?.ipAddress ?? '',
subnetMask: device?.subnetMask ?? card?.subnetMask ?? '',
defaultGateway: device?.defaultGateway ?? '',
dnsServer: device?.dnsServer ?? '',
macAddress: port === undefined ? '' : (port.macAddress ?? stableGeneratedMacAddress(port.id)),
};
}
/** The cables whose cards got frames since the last tick; sent when that changes. */
private updateActivity(): void {
const links = new Set<string>();
for (const portId of this.engine.capture.portIds()) {
const frames = this.engine.capture.frames(portId);
const count = frames.at(-1)?.number ?? 0;
if (count > (this.framesSeen.get(portId) ?? 0)) {
const link = this.linkOfPort.get(portId);
if (link !== undefined) links.add(link);
}
this.framesSeen.set(portId, count);
}
const activity = [...links].sort();
if (activity.join() === this.lastActivity.join()) return;
this.lastActivity = activity;
for (const client of this.clients.values()) if (client.topics.has('activity')) client.send(message({ kind: 'activity', links: activity }));
}
}
/** A port a socket can have. */
function isPort(value: unknown): value is number {
return Number.isInteger(value) && (value as number) >= 0 && (value as number) <= 65_535;
}
/** A card's frames as the capture window shows them, both views. */
export function traceFramesOf(frames: readonly CapturedFrame[]): TraceFrame[] {
const byNumber = (rows: readonly CaptureRow[]) => {
const grouped = new Map<number, CaptureRow[]>();
for (const row of rows) grouped.set(row.number, [...(grouped.get(row.number) ?? []), row]);
return grouped;
};
const rows = byNumber(captureRows(frames, { withAddresses: true }));
const layered = byNumber(captureRows(frames, { withAddresses: false }));
return frames.map((frame) => ({ number: frame.number, rows: rows.get(frame.number) ?? [], layeredRows: layered.get(frame.number) ?? [] }));
}