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:
+132
-17
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user