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
+3 -2
View File
@@ -20,7 +20,7 @@ import { Palette, type PaletteKind } from './design/Palette.js';
import { RouterDialog } from './design/RouterDialog.js';
import { DeviceSheet } from './sheet/DeviceSheet.js';
import { DesktopWindow, type DesktopWindowName } from './desktop/DesktopWindow.js';
import { useTranslate } from './i18n/i18n.js';
import { useLanguage, useTranslate } from './i18n/i18n.js';
import { ModeBar } from './simulation/ModeBar.js';
import { useSimulation, type SimHostFactory } from './simulation/useSimulation.js';
@@ -90,6 +90,7 @@ const toFront = (open: readonly OpenDesktop[], id: string): readonly OpenDesktop
export function Editor({ doc, createSimHost }: { readonly doc: Y.Doc; readonly createSimHost?: SimHostFactory }) {
const t = useTranslate();
const language = useLanguage();
const view = useTopologyView(doc);
const simulation = useSimulation(doc, createSimHost);
const simulating = simulation.mode === 'simulation';
@@ -285,7 +286,7 @@ export function Editor({ doc, createSimHost }: { readonly doc: Y.Doc; readonly c
}}
/>
<div role="toolbar" style={{ position: 'absolute', left: 12, top: 12, zIndex: 50, display: 'flex', gap: 6, alignItems: 'center' }}>
<ModeBar mode={simulation.mode} speed={simulation.speed} onMode={(mode) => (mode === 'simulation' ? simulation.start() : simulation.stop())} onSpeed={simulation.setSpeed} />
<ModeBar mode={simulation.mode} speed={simulation.speed} onMode={(mode) => (mode === 'simulation' ? simulation.start(language) : simulation.stop())} onSpeed={simulation.setSpeed} />
{simulating ? null : (
<>
<button type="button" style={toolButton} disabled={!canUndo} onClick={() => manager?.undo()} aria-label={t('design.undo')} title={t('design.undo')}>
Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

@@ -0,0 +1,104 @@
// A computer's web browser, after Java's GUIApplicationWebBrowserWindow: an
// address line with Java's "Start" button above the page. The window is the
// room's (BrowserWindow) — its address line, title and page, the same for
// everyone who opens it —; this panel types into it and shows its page in a
// sandboxed frame (plan §7.3): no script runs, nothing loads but the images
// that came, and a link or a form's button goes to the room as Java's window
// hands it to its program. A mark in the page (#top) stays in the page: the
// panel scrolls to it, as the frame's own link would lead to the editor's
// address (a srcdoc frame's links count from its parent's).
//
// Java's start page, its browser's logo, shows before the first load.
import { useEffect, useRef, useState, type FormEvent } from 'react';
import { BROWSER_START, type BrowserView } from '@mdg-filius/protocol';
import logo from '../assets/desktop/browser_waterwolf_logo.png';
import { useTranslate } from '../i18n/i18n.js';
import type { Simulation } from '../simulation/useSimulation.js';
import { pageDocument } from './browserPage.js';
/** The frame's sandbox: no script, no popup, no navigation of its own; forms submit only to the panel (and the policy sends none). */
export const PAGE_SANDBOX = 'allow-same-origin allow-forms';
/** The view the room sent for a computer's browser; Java's start while it sent none. */
export function browserView(simulation: Simulation, node: string): BrowserView {
return (simulation.views.get(`app:${node}/webBrowser`) as BrowserView | null | undefined) ?? BROWSER_START;
}
/** What a form sends, as Java's window sends it: its fields in order with the button pressed, form-encoded; a GET form's after a "?" in the address. */
export function formRequest(form: HTMLFormElement, submitter: HTMLElement | null): { readonly href: string; readonly post?: string } {
const data = new URLSearchParams(new FormData(form, submitter) as unknown as Record<string, string>).toString();
const action = form.getAttribute('action') ?? '';
return form.method.toLowerCase() === 'post' ? { href: action, post: data } : { href: `${action.split('?')[0]}?${data}` };
}
export function BrowserPanel({ node, simulation }: { readonly node: string; readonly simulation: Simulation }) {
const t = useTranslate();
const topic = `app:${node}/webBrowser` as const;
const { watch, act } = simulation;
useEffect(() => watch([topic]), [watch, topic]);
const view = browserView(simulation, node);
const [typed, setTyped] = useState(view.address);
const frame = useRef<HTMLIFrameElement>(null);
// Java's window writes each load's address into its line, and "http://" for one that is none.
useEffect(() => setTyped(view.address), [view.address, view.loading]);
const document = pageDocument(view.page, t);
const open = (event: FormEvent) => {
event.preventDefault();
act(node, 'webBrowser', 'open', { address: typed });
};
// The page's links and forms go to the room; the frame runs no script, so the panel listens in it.
const onLoad = () => {
const page = frame.current?.contentDocument;
if (page === null || page === undefined) return;
page.addEventListener('click', (event) => {
const link = (event.target as Element | null)?.closest?.('a[href]');
if (link === null || link === undefined) return;
const href = link.getAttribute('href')!.trim();
event.preventDefault();
// A mark in the page: scrolled to here (the frame's own links would lead to the editor's address).
if (href.startsWith('#')) {
const name = decodeURIComponent(href.slice(1));
(page.getElementById(name) ?? page.getElementsByName(name)[0])?.scrollIntoView();
return;
}
act(node, 'webBrowser', 'follow', { href });
});
page.addEventListener('submit', (event) => {
event.preventDefault();
const request = formRequest(event.target as HTMLFormElement, (event as SubmitEvent).submitter);
act(node, 'webBrowser', 'follow', request.post === undefined ? { href: request.href } : { href: request.href, post: request.post });
});
};
return (
<div className="browser">
<form className="browser-address" onSubmit={open}>
<input
name="address"
value={typed}
aria-label={t('browser.address')}
autoCapitalize="off"
autoCorrect="off"
autoComplete="off"
spellCheck={false}
inputMode="url"
enterKeyHint="go"
onChange={(event) => setTyped(event.target.value)}
/>
<button type="submit">{t('browser.go')}</button>
</form>
<div className="browser-page">
{document === undefined ? (
<div className="browser-start">
<img src={logo} alt="" />
</div>
) : (
<iframe ref={frame} title={t('browser.page')} sandbox={PAGE_SANDBOX} srcDoc={document} onLoad={onLoad} />
)}
</div>
</div>
);
}
+15 -3
View File
@@ -11,11 +11,14 @@
// On a wide screen the window lies over the surface and moves by its title
// bar, as Java's windows do; on a phone it fills the surface. A program's
// icon opens its window over the desktop (ProgramFrame), the home button
// leaves it; so far the command line has one (F4.6b), the others follow.
// leaves it; so far the command line (F4.6b), the web browser and the web
// server (F4.6d) have one, the others follow. The browser's window takes its
// title from its page, as Java's does.
import { useCallback, useEffect, useState, type PointerEvent as ReactPointerEvent } from 'react';
import type * as Y from 'yjs';
import { desktopView } from '@mdg-filius/doc';
import { browserTitle } from '@mdg-filius/protocol';
import background from '../assets/desktop/hintergrundbild.png';
import networkOff from '../assets/desktop/netzwek_aus.png';
import networkOn from '../assets/desktop/netzwek_c.png';
@@ -24,8 +27,11 @@ import { useLanguage, useTranslate } from '../i18n/i18n.js';
import type { Simulation } from '../simulation/useSimulation.js';
import { ProgramFrame } from './ProgramFrame.js';
import { desktopPrograms, INSTALLATION_ICON, JAVA_PROGRAMS, type DesktopProgram } from './programs.js';
import { BrowserPanel, browserView } from './BrowserPanel.js';
import { pageTitle } from './browserPage.js';
import { InstallationPanel } from './InstallationPanel.js';
import { TerminalPanel } from './TerminalPanel.js';
import { WebServerPanel } from './WebServerPanel.js';
import './desktop.css';
export interface DesktopWindowProps {
@@ -54,7 +60,7 @@ export interface DesktopWindowProps {
export type DesktopWindowName = DesktopProgram | 'installation';
/** The programs whose windows are built so far. */
const WITH_WINDOW: ReadonlySet<DesktopProgram> = new Set(['commandPrompt']);
const WITH_WINDOW: ReadonlySet<DesktopProgram> = new Set(['commandPrompt', 'webBrowser', 'webServer']);
/** The document's desktop of a computer, read anew after every change (a program installed, the label changed). */
function useDesktopView(doc: Y.Doc, id: string) {
@@ -146,8 +152,14 @@ export function DesktopWindow({ doc, id, simulation, active, x, y, z, program: o
<InstallationPanel doc={doc} node={id} installed={view.programs} origin={origin} onDone={home} />
</ProgramFrame>
) : shown === null ? null : (
<ProgramFrame icon={JAVA_PROGRAMS[shown].icon} title={t(`program.${shown}`)} program={shown}>
<ProgramFrame
icon={JAVA_PROGRAMS[shown].icon}
title={shown === 'webBrowser' ? browserTitle(browserView(simulation, id), t('program.webBrowser'), (page) => pageTitle(page, t)) : t(`program.${shown}`)}
program={shown}
>
{shown === 'commandPrompt' ? <TerminalPanel node={id} simulation={simulation} onExit={home} /> : null}
{shown === 'webBrowser' ? <BrowserPanel node={id} simulation={simulation} /> : null}
{shown === 'webServer' ? <WebServerPanel node={id} simulation={simulation} /> : null}
</ProgramFrame>
)}
{info ? (
@@ -0,0 +1,92 @@
// A computer's web server, after Java's GUIApplicationWebServerWindow: the
// start button ("Starten"/"Anhalten"), beside it the virtual hosts' check
// box; while it is ticked their table (five rows of host name and folder,
// each cell written when it is left, as Java's table writes it when its
// editing stops) and below the server's lines. The server is the room's: what
// the panel shows is its view and lines, what it does goes to the room.
import { useEffect, useLayoutEffect, useRef, useState, type KeyboardEvent } from 'react';
import type { LogLine, ServerLogEntry } from '@mdg-filius/core';
import type { WebServerView } from '@mdg-filius/protocol';
import { useTranslate } from '../i18n/i18n.js';
import type { Simulation } from '../simulation/useSimulation.js';
import { serverLine } from './serverLines.js';
const NO_LINES: readonly LogLine<ServerLogEntry>[] = [];
/** A cell of the table, written to the server when it is left or Enter is pressed and it changed. */
function HostCell({ value, label, onCommit }: { readonly value: string; readonly label: string; readonly onCommit: (value: string) => void }) {
const [draft, setDraft] = useState(value);
useEffect(() => setDraft(value), [value]);
const commit = () => {
if (draft !== value) onCommit(draft);
};
const onKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') commit();
if (event.key === 'Escape') setDraft(value);
};
return <input value={draft} aria-label={label} autoCapitalize="off" autoCorrect="off" spellCheck={false} onChange={(event) => setDraft(event.target.value)} onBlur={commit} onKeyDown={onKeyDown} />;
}
export function WebServerPanel({ node, simulation }: { readonly node: string; readonly simulation: Simulation }) {
const t = useTranslate();
const topic = `app:${node}/webServer` as const;
const { watch, act } = simulation;
useEffect(() => watch([topic]), [watch, topic]);
const view = simulation.views.get(topic) as WebServerView | null | undefined;
const lines = (simulation.apps.get(topic)?.lines as readonly LogLine<ServerLogEntry>[] | undefined) ?? NO_LINES;
const text = lines.map((line) => `${serverLine(line, t)}\n`).join('');
const log = useRef<HTMLPreElement>(null);
// The newest line in sight, as Java's text area scrolls.
useLayoutEffect(() => {
const element = log.current;
if (element !== null) element.scrollTop = element.scrollHeight;
}, [text]);
if (view === null || view === undefined) return <div className="web-server" />;
return (
<div className="web-server">
<div className="web-server-bar">
<button type="button" onClick={() => act(node, 'webServer', 'setActive', { active: !view.active })}>
{t(view.active ? 'server.stop' : 'server.start')}
</button>
<label>
<input type="checkbox" checked={view.useVirtualHosts} onChange={(event) => act(node, 'webServer', 'useVirtualHosts', { use: event.target.checked })} />
{t('webServer.useVirtualHosts')}
</label>
</div>
{view.useVirtualHosts ? (
<div className="web-server-hosts">
<span id={`${node}-virtual-hosts`}>{t('webServer.virtualHosts')}</span>
<table aria-labelledby={`${node}-virtual-hosts`}>
<thead>
<tr>
<th>{t('webServer.hostName')}</th>
<th>{t('webServer.folder')}</th>
</tr>
</thead>
<tbody>
{view.virtualHosts.map(([host, folder], row) => (
<tr key={row}>
{[host, folder].map((value, column) => (
<td key={column}>
<HostCell
value={value}
label={`${t(column === 0 ? 'webServer.hostName' : 'webServer.folder')} ${row + 1}`}
onCommit={(written) => act(node, 'webServer', 'setVirtualHost', { row, column, value: written })}
/>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
) : null}
<pre className="web-server-log" ref={log} aria-label={t('server.log')} tabIndex={0}>
{text}
</pre>
</div>
);
}
@@ -0,0 +1,71 @@
// What the browser window shows of a page (BrowserPage), as a document for a
// sandboxed frame (plan §7.3): the page's HTML as it came, without what
// could reach beyond the frame — scripts, a refresh, a base, other
// documents —, under a policy that loads nothing but the images that came
// (as data) and sends no form; the error pages as Java's browser writes
// them, in the person's language. Java's start page (its logo) is not a
// document: the panel shows it itself.
import { htmlTitle, type BrowserImage, type BrowserPage } from '@mdg-filius/protocol';
import { httpStatusText } from '@mdg-filius/core';
import type { Translate } from '../i18n/i18n.js';
/** Loads nothing but images given as data, runs no script (the frame's sandbox too), sends no form (the panel takes it). */
export const PAGE_POLICY = "default-src 'none'; img-src data:; style-src 'unsafe-inline'; form-action 'none'";
/** Elements that would load, run or lead somewhere by themselves. */
const REMOVED = 'script, noscript, base, link, iframe, frame, frameset, object, embed, applet, meta[http-equiv]';
const escape = (text: string) => text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const dataUrl = (image: BrowserImage) => `data:${image.contentType};base64,${image.data}`;
/** The texts of an error page: Java's sw_webbrowser_msg1, its template tmpl/http_fehler. */
function errorTexts(page: Extract<BrowserPage, { kind: 'unreachable' | 'error' }>, t: Translate) {
if (page.kind === 'unreachable') return { title: t('browser.unreachableTitle'), heading: t('browser.unreachable'), text: undefined };
const values = { code: page.statusCode, message: httpStatusText(page.statusCode) };
return { title: t('browser.errorTitle', values), heading: values.message, text: t('browser.errorText', values) };
}
/** The window's title for the page shown, where it has one: an HTML page's (as it reads), an error page's. */
export function pageTitle(page: BrowserPage, t: Translate): string | undefined {
if (page.kind === 'unreachable' || page.kind === 'error') return errorTexts(page, t).title;
if (page.kind !== 'html') return undefined;
const title = htmlTitle(page.html);
if (title === undefined) return undefined;
const text = new DOMParser().parseFromString(`<title>${title}</title>`, 'text/html').title;
return text === '' ? undefined : text;
}
/** The document the frame shows; undefined for the start page. */
export function pageDocument(page: BrowserPage, t: Translate): string | undefined {
const policy = `<meta http-equiv="Content-Security-Policy" content="${PAGE_POLICY}">`;
switch (page.kind) {
case 'start':
return undefined;
case 'unreachable':
case 'error': {
const { title, heading, text } = errorTexts(page, t);
return `<html><head>${policy}<title>${escape(title)}</title></head><body><h1>${escape(heading)}</h1>${text === undefined ? '' : `<p> ${escape(text)} </p>`}</body></html>`;
}
case 'text':
return `<html><head>${policy}</head><body><pre style="white-space:pre-wrap">${escape(page.text)}</pre></body></html>`;
case 'image':
return `<html><head>${policy}</head><body style="margin:0"><img src="${dataUrl(page.image)}" alt=""></body></html>`;
case 'html': {
const document = new DOMParser().parseFromString(page.html, 'text/html');
for (const element of document.querySelectorAll(REMOVED)) element.remove();
for (const element of document.querySelectorAll('*')) {
for (const attribute of [...element.attributes]) if (/^on/i.test(attribute.name)) element.removeAttribute(attribute.name);
}
for (const image of document.querySelectorAll('img')) {
const came = page.images[image.getAttribute('src') ?? ''];
image.removeAttribute('srcset');
// An image that did not come shows as missing, as in Java (its alt text).
if (came === undefined) image.removeAttribute('src');
else image.setAttribute('src', dataUrl(came));
}
document.head.insertAdjacentHTML('afterbegin', policy);
return document.documentElement.outerHTML;
}
}
}
+157
View File
@@ -379,3 +379,160 @@
padding: 0 16px;
flex: none;
}
/* The web browser (GUIApplicationWebBrowserWindow): the address line with
* its button above the page, five pixels around each as in Java; the page
* in a frame of its own on white. */
.browser {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
background: #eeeeee;
font: 13px Dialog, sans-serif;
}
.browser-address {
display: flex;
gap: 5px;
flex: none;
padding: 5px;
}
.browser-address input {
flex: 1;
min-width: 0;
min-height: 36px;
padding: 0 6px;
border: 1px solid #a0a0a0;
background: #ffffff;
color: #000000;
font: 16px Dialog, sans-serif;
}
.browser-address button,
.web-server-bar button {
min-width: 44px;
min-height: 36px;
padding: 0 14px;
border: 1px solid #a0a0a0;
border-radius: 6px;
background: #f8f8f8;
color: #000000;
font: 13px Dialog, sans-serif;
cursor: pointer;
}
.browser-page {
flex: 1;
min-height: 0;
margin: 0 5px 5px;
display: flex;
background: #ffffff;
border: 1px solid #a0a0a0;
}
.browser-page iframe {
flex: 1;
width: 100%;
border: none;
background: #ffffff;
}
.browser-start {
flex: 1;
display: flex;
justify-content: center;
align-items: flex-start;
}
.browser-start img {
max-width: 100%;
}
/* The web server (GUIApplicationWebServerWindow): the button and the check
* box above, the virtual hosts' table while it is ticked, the lines below. */
.web-server {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
gap: 10px;
padding: 5px 10px 10px;
background: #eeeeee;
color: #000000;
font: 13px Dialog, sans-serif;
}
.web-server-bar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px 24px;
flex: none;
}
.web-server-bar label {
display: flex;
align-items: center;
gap: 6px;
min-height: 44px;
cursor: pointer;
}
.web-server-bar input[type='checkbox'] {
width: 20px;
height: 20px;
}
.web-server-hosts {
display: flex;
flex-direction: column;
gap: 5px;
flex: none;
}
.web-server-hosts table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
background: #ffffff;
}
.web-server-hosts th {
padding: 4px;
border: 1px solid #a0a0a0;
background: #e4e4e4;
font-weight: normal;
text-align: left;
}
.web-server-hosts td {
padding: 0;
border: 1px solid #c0c0c0;
}
.web-server-hosts td input {
width: 100%;
min-height: 32px;
box-sizing: border-box;
padding: 0 5px;
border: none;
background: transparent;
color: #000000;
font: 16px Dialog, sans-serif;
}
.web-server-log {
flex: 1;
min-height: 120px;
margin: 0;
overflow: auto;
padding: 4px;
border: 1px solid #a0a0a0;
background: #ffffff;
font: 12px ui-monospace, Menlo, Consolas, monospace;
white-space: pre-wrap;
overflow-wrap: anywhere;
user-select: text;
}
@@ -0,0 +1,25 @@
// A server's lines as Java's server windows write them (ServerAnwendung,
// ServerMitarbeiter, TCPSocket's failures), in the person's language: one
// line each, as Java appends them to its window's text area.
import type { ServerLogEntry } from '@mdg-filius/core';
import type { Translate } from '../i18n/i18n.js';
export function serverLine(entry: ServerLogEntry, t: Translate): string {
switch (entry.kind) {
case 'acceptingStarted':
case 'acceptingStopped':
case 'serverSocketClosed':
case 'serverSocketFailed':
return t(`server.${entry.kind}`);
case 'connectionAccepted':
case 'connectionClosed':
return t(`server.${entry.kind}`, { address: `${entry.address}:${entry.port}` });
case 'received':
return `>>${entry.text}`;
case 'sent':
return `<<${entry.text}`;
case 'sendFailed':
return t(`server.sendFailed.${entry.failure}`);
}
}
+23
View File
@@ -1,4 +1,11 @@
{
"browser.address": "Adresse",
"browser.errorText": "Es ist ein Fehler aufgetreten (HTTP-Status: {code})",
"browser.errorTitle": "Fehler {message} ({code})",
"browser.go": "Start",
"browser.page": "Seite",
"browser.unreachable": "Server konnte nicht erreicht werden!",
"browser.unreachableTitle": "Fehler",
"cable.alreadyConnected": "Komponenten bereits verbunden",
"cable.lan": "LAN-Anschluss (intern)",
"cable.lanOccupied": "LAN-Anschluss (intern) bereits belegt!",
@@ -117,6 +124,18 @@
"router.cancel": "Abbrechen",
"router.prompt": "Wählen Sie die Anzahl der Schnittstellen des Vermittlungsrechners aus!",
"router.title": "Anzahl Schnittstellen",
"server.acceptingStarted": "Annahme von Verbindungsanfragen gestartet",
"server.acceptingStopped": "Annahme von Verbindungsanfragen beendet",
"server.connectionAccepted": "Verbindung zu {address} hergestellt",
"server.connectionClosed": "Socket zu {address} geschlossen",
"server.log": "Meldungen",
"server.sendFailed.connectionLost": "Verbindung wurde unterbrochen",
"server.sendFailed.notConnected": "Verbindung nicht aufgebaut!",
"server.sendFailed.timedOut": "Zeitüberschreitung während des Versands einer Nachricht",
"server.serverSocketClosed": "Server-Socket geschlossen",
"server.serverSocketFailed": "Server-Socket konnte nicht geöffnet werden.",
"server.start": "Starten",
"server.stop": "Anhalten",
"sheet.back": "Zurück",
"sheet.cable": "Kabelgebunden (LAN)",
"sheet.cablePlugged": "Das Kabel ist noch eingesteckt.",
@@ -165,6 +184,10 @@
"terminal.interrupt": "Befehl abbrechen (Strg+C)",
"terminal.next": "Nächster Befehl",
"terminal.previous": "Vorheriger Befehl",
"webServer.folder": "Unterverzeichnis",
"webServer.hostName": "Hostname",
"webServer.useVirtualHosts": "Verwende virtuelle Hosts",
"webServer.virtualHosts": "Virtuelle Hosts:",
"zoom.fit": "Alles zeigen",
"zoom.in": "Vergrößern",
"zoom.level": "{percent} %",
+23
View File
@@ -1,4 +1,11 @@
{
"browser.address": "Address",
"browser.errorText": "An error occurred (HTTP-Status: {code})",
"browser.errorTitle": "Error {message} ({code})",
"browser.go": "Start",
"browser.page": "Page",
"browser.unreachable": "Server does not exist!",
"browser.unreachableTitle": "Error",
"cable.alreadyConnected": "Components already connected",
"cable.lan": "LAN port (internal)",
"cable.lanOccupied": "LAN port (internal) already occupied!",
@@ -117,6 +124,18 @@
"router.cancel": "Cancel",
"router.prompt": "Choose number of NIC of the router!",
"router.title": "Number of NIC",
"server.acceptingStarted": "Start accepting connections.",
"server.acceptingStopped": "Stop accepting connections.",
"server.connectionAccepted": "Connection to {address} established",
"server.connectionClosed": "Socket to {address} closed",
"server.log": "Messages",
"server.sendFailed.connectionLost": "Connection was interrupted",
"server.sendFailed.notConnected": "Connection could not be established!",
"server.sendFailed.timedOut": "Timeout while sending a message",
"server.serverSocketClosed": "Server socket closed",
"server.serverSocketFailed": "Server socket could not be initialized.",
"server.start": "Start",
"server.stop": "Stop",
"sheet.back": "Back",
"sheet.cable": "Cable (LAN)",
"sheet.cablePlugged": "The cable is still plugged in.",
@@ -165,6 +184,10 @@
"terminal.interrupt": "Stop the command (Ctrl+C)",
"terminal.next": "Next command",
"terminal.previous": "Previous command",
"webServer.folder": "Subdirectory",
"webServer.hostName": "Hostname",
"webServer.useVirtualHosts": "Enable virtual hosts",
"webServer.virtualHosts": "Virtual hosts:",
"zoom.fit": "Show all",
"zoom.in": "Zoom in",
"zoom.level": "{percent} %",
@@ -1,6 +1,6 @@
// The editor's side of the simulation room (plan §6): its mode and speed,
// the cables that carry frames, a computer's network as it runs, the lines
// of the programs' windows and the actions taken in them. The room
// and views of the programs' windows and the actions taken in them. The room
// runs behind a SimHost — in a worker of this page (LocalSimHost) until F
// holds it (RemoteSimHost, F5); the editor keeps no simulation of its own.
@@ -9,6 +9,7 @@ import type * as Y from 'yjs';
import type { AppKind, NodeSettings, SimHost, SimMode, Topic } from '@mdg-filius/protocol';
import { LocalSimHost } from '@mdg-filius/sim';
import SimWorker from '@mdg-filius/sim/worker?worker';
import type { Language } from '../i18n/i18n.js';
/** Makes the room of a document. */
export type SimHostFactory = (doc: Y.Doc) => SimHost;
@@ -35,9 +36,12 @@ export interface Simulation {
readonly nodes: ReadonlyMap<string, NodeSettings>;
/** Each watched program window's lines, by its topic. */
readonly apps: ReadonlyMap<string, AppLines>;
/** Each watched program window's view (a browser's page, a server's button and table), by its topic; null while it has none. */
readonly views: ReadonlyMap<string, unknown>;
/** Whether the core failed and the room went back to design mode, since the last start. */
readonly failed: boolean;
start(): void;
/** Starts in the language the person runs FILIUS in: the routers' pages come in it, as Java's in its locale. */
start(language?: Language): void;
stop(): void;
setSpeed(value: number): void;
/** Watches these topics until the returned function is called; a topic two parts watch is sent for once. */
@@ -57,6 +61,7 @@ export function useSimulation(doc: Y.Doc, createHost: SimHostFactory = localSimH
const [nodes, setNodes] = useState<ReadonlyMap<string, NodeSettings>>(new Map());
const [failed, setFailed] = useState(false);
const [apps, setApps] = useState<ReadonlyMap<string, AppLines>>(new Map());
const [views, setViews] = useState<ReadonlyMap<string, unknown>>(new Map());
useEffect(() => {
const made = createHost(doc);
@@ -83,6 +88,9 @@ export function useSimulation(doc: Y.Doc, createHost: SimHostFactory = localSimH
return new Map(before).set(topic, { lines, live: message.from === 0 ? lines.length : (before.get(topic)?.live ?? 0) });
});
return;
case 'view':
setViews((before) => new Map(before).set(`app:${message.node}/${message.app}`, message.view));
return;
case 'error':
if (message.code === 'engineFailed') setFailed(true);
return;
@@ -98,9 +106,9 @@ export function useSimulation(doc: Y.Doc, createHost: SimHostFactory = localSimH
};
}, [doc, createHost]);
const start = useCallback(() => {
const start = useCallback((language?: Language) => {
setFailed(false);
host.current?.send({ v: 1, kind: 'start' });
host.current?.send(language === undefined ? { v: 1, kind: 'start' } : { v: 1, kind: 'start', language });
}, []);
const stop = useCallback(() => host.current?.send({ v: 1, kind: 'stop' }), []);
const setSpeed = useCallback((value: number) => host.current?.send({ v: 1, kind: 'speed', value }), []);
@@ -126,5 +134,5 @@ export function useSimulation(doc: Y.Doc, createHost: SimHostFactory = localSimH
host.current?.send({ v: 1, kind: 'input', node, app, action, args, reqId: crypto.randomUUID() });
}, []);
return { mode, speed, activeLinks, nodes, apps, failed, start, stop, setSpeed, watch, act };
return { mode, speed, activeLinks, nodes, apps, views, failed, start, stop, setSpeed, watch, act };
}