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
+102
View File
@@ -0,0 +1,102 @@
// A computer's web browser window as the room keeps it (plan §7.3), after
// Java's GUIApplicationWebBrowserWindow (measured with tools/java-oracle's
// BrowserWindowRun): the address line, the title and the page, the same for
// everyone who opens it, kept over stops as Java's window keeps it. An
// address typed or a link followed goes to the program as Java's window
// hands it over; what the program got (a page, an image, no server) comes
// back into the view with the next pull.
//
// Better than Java: the title is the page's title as the page means it
// (Java shows its HTML, "&amp;" and all — read by the client); a link counts
// from the page's folder (linkTarget; Java from the server's root), a mark in
// the page (#top) stays in the page (Java loads the host's start page).
import { javaBase64, linkTarget, webBrowserAddress, type WebBrowser } from '@mdg-filius/core';
import { BROWSER_START, type BrowserPage, type BrowserView } from '@mdg-filius/protocol';
const IMAGE_TYPES = new Set(['image/bmp', 'image/gif', 'image/jpg', 'image/png']);
export class BrowserWindow {
private viewValue: BrowserView = BROWSER_START;
/** How many of its program's lines the window has read. */
private seen = 0;
/** Where the page shown came from: its links go from there. */
private pageAddress: { readonly host: string; readonly path: string } | undefined;
constructor(private browser: WebBrowser) {}
get view(): BrowserView {
return this.viewValue;
}
/** The program of a new start (Java's window outlives the stop; its page stays). */
attach(browser: WebBrowser): void {
if (browser === this.browser) return;
this.browser = browser;
this.seen = 0;
}
/** What the program got since the last pull, into the view. */
pull(): void {
const lines = this.browser.log.all;
for (const line of lines.slice(this.seen)) {
switch (line.kind) {
case 'page':
this.pageAddress = { host: line.host, path: line.path };
this.show(browserPage(line.statusCode, line.contentType, line.body));
break;
case 'unreachable':
this.show({ kind: 'unreachable' });
break;
case 'image': {
const page = this.viewValue.page;
if (page.kind !== 'html' || line.data === undefined || line.contentType === undefined) break;
const image = { contentType: line.contentType.toLowerCase(), data: javaBase64(line.data).replace(/\s/g, '') };
this.viewValue = { ...this.viewValue, page: { ...page, images: { ...page.images, [line.source]: image } } };
break;
}
}
}
this.seen = lines.length;
}
/** Java's address line, Enter or the button: the address loaded, or the line set back to "http://" when it is none. */
open(typed: string): void {
const address = webBrowserAddress(typed);
if (address === undefined) {
this.viewValue = { ...this.viewValue, address: 'http://' };
return;
}
this.load(address.host, address.path);
}
/**
* A link of the page shown followed, or its form sent (a GET form's data
* after a "?" in the href, a POST form's as `post`). Nothing happens for a
* link the browser does not follow, or without a page.
*/
follow(href: string, post?: string): void {
const target = this.pageAddress === undefined ? undefined : linkTarget(this.pageAddress, href);
if (target !== undefined) this.load(target.host, target.path, post);
}
private load(host: string, path: string, post?: string): void {
// Java's window writes the URL it made: the browser's last host where it has none, the path without its query.
const shown = `http://${host === '' ? (this.browser.host ?? '') : host}${path.split('?')[0]}`;
this.viewValue = { ...this.viewValue, address: shown, loading: shown, arrived: false };
this.browser.load(host, path, post);
}
private show(page: BrowserPage): void {
this.viewValue = { ...this.viewValue, arrived: true, page };
}
}
/** The page for what came: an error page for a status other than 200, else by its content type. */
export function browserPage(statusCode: number, contentType: string | undefined, body: string | undefined): BrowserPage {
if (statusCode !== 200) return { kind: 'error', statusCode };
const type = contentType?.toLowerCase();
if (type === 'text/html') return { kind: 'html', html: body ?? '', images: {} };
if (type !== undefined && IMAGE_TYPES.has(type)) return { kind: 'image', image: { contentType: type, data: (body ?? '').replace(/\s/g, '') } };
return { kind: 'text', text: body ?? '' };
}
+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.