feat(editor): F4.6d the web browser and web server on a computer's desktop

The oracle BrowserWindowRun uses Java's browser and web server windows as a
person does; the room keeps a browser window per computer and sends a
program window's view, the start names the person's language. The editor
shows the page in a sandboxed frame, links and forms go to the room, the web
server has its button, virtual hosts and lines. Better than Java: relative
images and links count from the page's folder, a mark stays in the page, the
error page from Java's template, the virtual hosts' file with complete rows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 16:09:05 +00:00
co-authored by Claude Opus 5.5
parent f0e7c73ec7
commit aa54e5400e
31 changed files with 2773 additions and 55 deletions
+132 -17
View File
@@ -15,8 +15,12 @@
//
// 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' windows, as Java's do
// (TerminalWindow).
// 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 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
@@ -35,6 +39,8 @@ import {
type Id,
type InstallableProgram,
type LogLine,
type PageLanguage,
type TopologySnapshot,
} from '@mdg-filius/core';
import { docSnapshot, installedSoftware, ROOTS } from '@mdg-filius/doc';
import {
@@ -48,7 +54,9 @@ import {
type SimMode,
type Topic,
type TraceFrame,
type WebServerView,
} from '@mdg-filius/protocol';
import { BrowserWindow } from './browserWindow.js';
import { TerminalWindow } from './terminalWindow.js';
export const TICK_MILLISECONDS = 200;
@@ -70,6 +78,8 @@ interface Client {
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. */
@@ -93,6 +103,10 @@ export class SimulationRoom {
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 computer's programs as the running engine has them. */
private software = new Map<Id, readonly InstallableProgram[]>();
@@ -129,31 +143,44 @@ export class SimulationRoom {
if (installed.length > 0 || removed.length > 0) changes.push({ node, installed, removed });
}
if (changes.length === 0) return;
const source = docSnapshot(this.doc);
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.syncTerminalWindows();
this.syncWindows();
this.flush([...this.clients.values()], false);
}
/** A window for each command line the engine runs, the same over stops (TerminalWindow). */
private syncTerminalWindows(): void {
/** 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) continue;
const window = this.terminalWindows.get(node.id);
if (window === undefined) this.terminalWindows.set(node.id, new TerminalWindow(terminal));
else window.attach(terminal);
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() });
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;
}
@@ -190,7 +217,10 @@ export class SimulationRoom {
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);
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);
@@ -199,7 +229,7 @@ export class SimulationRoom {
for (const topic of input.topics) client.topics.delete(topic);
return;
case 'start':
this.start();
this.start(input.language);
return;
case 'stop':
this.stop();
@@ -227,14 +257,15 @@ export class SimulationRoom {
for (const client of this.clients.values()) client.send(output);
}
start(): void {
start(language?: PageLanguage): void {
if (this.mode === 'simulation') return;
const snapshot = docSnapshot(this.doc);
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.syncTerminalWindows();
this.syncWindows();
this.mode = 'simulation';
this.lastWall = this.options.now();
this.broadcast(this.state());
@@ -286,7 +317,19 @@ export class SimulationRoom {
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';
if (input.app !== 'terminal') return 'unknownAction';
switch (input.app) {
case 'terminal':
return this.actInTerminal(input);
case 'webBrowser':
return this.actInBrowser(input);
case 'webServer':
return this.actInWebServer(input);
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';
@@ -303,6 +346,66 @@ export class SimulationRoom {
}
}
/** 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;
}
default:
return undefined;
}
}
private program(node: Id, app: AppKind): WithLog | undefined {
switch (app) {
case 'terminal':
@@ -328,6 +431,7 @@ export class SimulationRoom {
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);
@@ -358,6 +462,17 @@ export class SimulationRoom {
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.
if (app === 'webBrowser') 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.