feat: F4.6g1 the echo server and the generic client windows

Oracle EchoWindowRun uses Java's echo server and client windows as a
person does; SaveAfterRunRun echo saves what Java keeps after the run.
The room takes setActive with a port, connect/disconnect/send, sends
both views and writes the server's port and the client's target back.
The editor's windows show Java's buttons, fields and lines; a port that
is no port is marked and not handed over (Java's button throws).
Core fix: an empty message (one empty segment with the push, as Java's
erstelleSegmente makes) counted as a repeat and never arrived.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 20:04:33 +00:00
co-authored by Claude Opus 5.5
parent 3dc2713f81
commit e31da62ae0
24 changed files with 1481 additions and 18 deletions
@@ -13,8 +13,8 @@
// icon opens its window over the desktop (ProgramFrame), the home button
// leaves it; so far the command line (F4.6b), the web browser and the web
// server (F4.6d), the e-mail program and server (F4.6e), the text editor
// (F4.6f2), the file explorer and the image viewer (F4.6f3) have one, the
// others follow. The browser's window takes its title from its page, the
// (F4.6f2), the file explorer and the image viewer (F4.6f3), the echo
// server and the generic client (F4.6g1) have one, the others follow. The browser's window takes its title from its page, the
// text editor's from its open file, the image viewer's from its picture, as
// Java's do.
@@ -38,6 +38,7 @@ import { InstallationPanel } from './InstallationPanel.js';
import { TerminalPanel } from './TerminalPanel.js';
import { TextEditorPanel, useTextEditorWindow } from './TextEditorPanel.js';
import { FileExplorerPanel } from './FileExplorerPanel.js';
import { EchoServerPanel, SimpleClientPanel } from './EchoPanels.js';
import { ImageViewerPanel, useImageViewer } from './ImageViewerPanel.js';
import { WebServerPanel } from './WebServerPanel.js';
import './desktop.css';
@@ -68,7 +69,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', 'webBrowser', 'webServer', 'emailClient', 'emailServer', 'textEditor', 'fileExplorer', 'imageViewer']);
const WITH_WINDOW: ReadonlySet<DesktopProgram> = new Set(['commandPrompt', 'webBrowser', 'webServer', 'emailClient', 'emailServer', 'textEditor', 'fileExplorer', 'imageViewer', 'echoServer', 'simpleClient']);
/** 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) {
@@ -183,6 +184,8 @@ export function DesktopWindow({ doc, id, simulation, active, x, y, z, program: o
{shown === 'textEditor' ? <TextEditorPanel doc={doc} node={id} /> : null}
{shown === 'fileExplorer' ? <FileExplorerPanel doc={doc} node={id} /> : null}
{shown === 'imageViewer' ? <ImageViewerPanel doc={doc} node={id} /> : null}
{shown === 'echoServer' ? <EchoServerPanel node={id} simulation={simulation} /> : null}
{shown === 'simpleClient' ? <SimpleClientPanel node={id} simulation={simulation} /> : null}
</ProgramFrame>
)}
{info ? (
+132
View File
@@ -0,0 +1,132 @@
// A computer's echo server and generic client, after Java's
// GUIApplicationServerBausteinWindow and GUIApplicationClientBausteinWindow
// (measured with tools/java-oracle's EchoWindowRun). The server: its start
// button ("Starten"/"Anhalten") with the port beside it, which can be typed
// into only while it does not run, and its lines. The client: the server's
// address and port (typed into only while it is not connected; they show the
// program's again when it changes, as Java's window sets them from it),
// "Verbinden"/"Trennen", the message and "Senden" (only while connected; the
// message is emptied once it is sent) and the lines. The programs are the
// room's: the panels show their views and lines and hand over what is done.
//
// Better than Java: a port that is no port (Java's Integer.parseInt throws,
// and the button does nothing) is marked red, and the button hands it not
// over.
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import type { LogLine, ServerLogEntry, SimpleClientLogEntry } from '@mdg-filius/core';
import { portOfText, type EchoServerView, type SimpleClientView } from '@mdg-filius/protocol';
import { useTranslate } from '../i18n/i18n.js';
import type { Simulation } from '../simulation/useSimulation.js';
import { serverLine, simpleClientLine } from './serverLines.js';
const NO_LINES: readonly LogLine<never>[] = [];
/** A program's lines as text, the newest in sight as Java's text area scrolls. */
function useLines<Entry>(simulation: Simulation, topic: `app:${string}`, line: (entry: Entry) => string) {
const lines = (simulation.apps.get(topic as never)?.lines as readonly LogLine<Entry>[] | undefined) ?? NO_LINES;
const text = lines.map((each) => `${line(each)}\n`).join('');
const element = useRef<HTMLPreElement>(null);
useLayoutEffect(() => {
if (element.current !== null) element.current.scrollTop = element.current.scrollHeight;
}, [text]);
return { text, element };
}
export function EchoServerPanel({ node, simulation }: { readonly node: string; readonly simulation: Simulation }) {
const t = useTranslate();
const topic = `app:${node}/echoServer` as const;
const { watch, act } = simulation;
useEffect(() => watch([topic]), [watch, topic]);
const view = simulation.views.get(topic) as EchoServerView | null | undefined;
const [port, setPort] = useState(view === null || view === undefined ? '' : String(view.port));
useEffect(() => {
if (view !== null && view !== undefined) setPort(String(view.port));
}, [view]);
const { text, element } = useLines<ServerLogEntry>(simulation, topic, (entry) => serverLine(entry, t));
if (view === null || view === undefined) return <div className="web-server" />;
const typed = portOfText(port);
const press = () => {
if (view.active) act(node, 'echoServer', 'setActive', { active: false });
else if (typed !== undefined) act(node, 'echoServer', 'setActive', { active: true, port: typed });
};
return (
<div className="web-server echo-server">
<div className="web-server-bar">
<button type="button" onClick={press}>
{t(view.active ? 'echoServer.stop' : 'echoServer.start')}
</button>
<label>
{t('echoServer.port')}
<input
value={port}
inputMode="numeric"
readOnly={view.active}
aria-invalid={typed === undefined ? 'true' : undefined}
onChange={(event) => setPort(event.target.value)}
onKeyDown={(event) => event.key === 'Enter' && press()}
/>
</label>
</div>
<pre className="web-server-log" ref={element} aria-label={t('echoServer.log')} tabIndex={0}>
{text}
</pre>
</div>
);
}
export function SimpleClientPanel({ node, simulation }: { readonly node: string; readonly simulation: Simulation }) {
const t = useTranslate();
const topic = `app:${node}/simpleClient` as const;
const { watch, act } = simulation;
useEffect(() => watch([topic]), [watch, topic]);
const view = simulation.views.get(topic) as SimpleClientView | null | undefined;
const [address, setAddress] = useState(view?.address ?? '');
const [port, setPort] = useState(view === null || view === undefined ? '' : String(view.port));
const [message, setMessage] = useState('');
useEffect(() => {
if (view === null || view === undefined) return;
setAddress(view.address);
setPort(String(view.port));
}, [view]);
const { text, element } = useLines<SimpleClientLogEntry>(simulation, topic, (entry) => simpleClientLine(entry, t));
if (view === null || view === undefined) return <div className="web-server" />;
const typed = portOfText(port);
const connect = () => {
if (view.connected) act(node, 'simpleClient', 'disconnect');
else if (typed !== undefined) act(node, 'simpleClient', 'connect', { address, port: typed });
};
const send = () => {
act(node, 'simpleClient', 'send', { text: message });
setMessage('');
};
return (
<div className="web-server simple-client">
<label className="simple-client-field">
<span>{t('simpleClient.address')}</span>
<input value={address} readOnly={view.connected} autoCapitalize="off" autoCorrect="off" spellCheck={false} onChange={(event) => setAddress(event.target.value)} />
</label>
<label className="simple-client-field">
<span>{t('simpleClient.port')}</span>
<input value={port} inputMode="numeric" readOnly={view.connected} aria-invalid={typed === undefined ? 'true' : undefined} onChange={(event) => setPort(event.target.value)} />
</label>
<div className="web-server-bar">
<button type="button" onClick={connect}>
{t(view.connected ? 'simpleClient.disconnect' : 'simpleClient.connect')}
</button>
</div>
<label className="simple-client-message">
<span>{t('simpleClient.message')}</span>
<textarea value={message} onChange={(event) => setMessage(event.target.value)} />
</label>
<div className="web-server-bar">
<button type="button" disabled={!view.connected} onClick={send}>
{t('simpleClient.send')}
</button>
</div>
<pre className="web-server-log" ref={element} aria-label={t('simpleClient.output')} tabIndex={0}>
{text}
</pre>
</div>
);
}
+57
View File
@@ -1026,3 +1026,60 @@
.image-viewer-picture img {
max-width: 100%;
}
/* The echo server and the generic client (GUIApplicationServerBausteinWindow,
* GUIApplicationClientBausteinWindow), on the web server's look. */
.echo-server input,
.simple-client input,
.simple-client textarea {
min-height: 32px;
padding: 0 6px;
border: 1px solid #a0a0a0;
background: #ffffff;
color: #000000;
font: 16px Dialog, sans-serif;
}
.echo-server input {
width: 8em;
}
.echo-server [aria-invalid='true'],
.simple-client [aria-invalid='true'] {
border-color: #ff0000;
color: #ff0000;
}
.echo-server input:read-only,
.simple-client input:read-only {
background: #eeeeee;
}
.simple-client-field,
.simple-client-message {
display: flex;
align-items: center;
gap: 6px;
flex: none;
}
.simple-client-field span {
flex: none;
width: 140px;
}
.simple-client-field input {
flex: 1;
min-width: 0;
}
.simple-client-message {
flex-direction: column;
align-items: stretch;
}
.simple-client-message textarea {
min-height: 3.5em;
padding: 4px 6px;
resize: vertical;
}
+25 -3
View File
@@ -1,8 +1,10 @@
// 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.
// ServerMitarbeiter, TCPSocket's failures), and the generic client's as its
// window writes them (ClientBaustein, Socket, TCPSocket), in the person's
// language: one line each (a failed connection two), as Java appends them to
// its window's text area.
import type { ServerLogEntry } from '@mdg-filius/core';
import type { ServerLogEntry, SimpleClientLogEntry } from '@mdg-filius/core';
import type { Translate } from '../i18n/i18n.js';
export function serverLine(entry: ServerLogEntry, t: Translate): string {
@@ -23,3 +25,23 @@ export function serverLine(entry: ServerLogEntry, t: Translate): string {
return t(`server.sendFailed.${entry.failure}`);
}
}
export function simpleClientLine(entry: SimpleClientLogEntry, t: Translate): string {
switch (entry.kind) {
case 'connected':
case 'disconnected':
case 'notConnected':
case 'receiveFailed':
return t(`simpleClient.${entry.kind}`);
case 'connectFailed':
return `${t('simpleClient.notConnected')}\n${t(`simpleClient.failure.${entry.failure}`)}`;
case 'connectionClosed':
return t('simpleClient.connectionClosed', { address: `${entry.address}:${entry.port}` });
case 'received':
return `>>${entry.text}`;
case 'sent':
return `<<${entry.text}`;
case 'sendFailed':
return t(`server.sendFailed.${entry.failure}`);
}
}
+19
View File
@@ -59,6 +59,10 @@
"dhcp.static": "Statische Adresszuweisung",
"dhcp.subnetMask": "Netzmaske",
"dhcp.upperBound": "Adress-Obergrenze",
"echoServer.log": "Ausgabe",
"echoServer.port": "Port:",
"echoServer.start": "Starten",
"echoServer.stop": "Anhalten",
"emailClient.account": "Konto einrichten",
"emailClient.accountHint": "E-Mail-Konto verwalten",
"emailClient.accountInvalid": "Die Angaben sind entweder fehlerhaft oder unvollständig!",
@@ -293,6 +297,21 @@
"sheet.title": "Einstellungen",
"sheet.wireless": "Drahtlos (WLAN)",
"sim.failed": "Die Simulation ist auf einen Fehler gestoßen und wurde beendet.",
"simpleClient.address": "Server-Adresse:",
"simpleClient.connect": "Verbinden",
"simpleClient.connected": "Verbindung hergestellt",
"simpleClient.connectionClosed": "Socket zu {address} getrennt",
"simpleClient.disconnect": "Trennen",
"simpleClient.disconnected": "Verbindung getrennt",
"simpleClient.failure.failed": "Aktives Öffnen des Sockets fehlgeschlagen",
"simpleClient.failure.timedOut": "Zeitüberschreitung beim Verbindungsaufbau",
"simpleClient.failure.unresolved": "Ziel konnte nicht bestimmt werden!",
"simpleClient.message": "Nachricht:",
"simpleClient.notConnected": "Fehler beim Verbindungsaufbau",
"simpleClient.output": "Ausgabe",
"simpleClient.port": "Server-Port:",
"simpleClient.receiveFailed": "Keine Verbindung hergestellt!",
"simpleClient.send": "Senden",
"terminal.commands": "Befehle",
"terminal.ctrlC": "Strg+C",
"terminal.input": "Befehl eingeben",
+19
View File
@@ -59,6 +59,10 @@
"dhcp.static": "Static Address Assignment",
"dhcp.subnetMask": "Netmask",
"dhcp.upperBound": "Upper bound of address",
"echoServer.log": "Output",
"echoServer.port": "Port:",
"echoServer.start": "Start",
"echoServer.stop": "Stop",
"emailClient.account": "Account",
"emailClient.accountHint": "Manage email account",
"emailClient.accountInvalid": "Wrong or incomplete data!",
@@ -293,6 +297,21 @@
"sheet.title": "Settings",
"sheet.wireless": "Wireless (Wi-Fi)",
"sim.failed": "The simulation ran into an error and was stopped.",
"simpleClient.address": "Server address:",
"simpleClient.connect": "Connect",
"simpleClient.connected": "Connection established",
"simpleClient.connectionClosed": "Socket to {address} disconnected",
"simpleClient.disconnect": "Disconnect",
"simpleClient.disconnected": "Disconnected",
"simpleClient.failure.failed": "Active opening of socket failed.",
"simpleClient.failure.timedOut": "Timeout while connecting",
"simpleClient.failure.unresolved": "Target does not exist!",
"simpleClient.message": "Message:",
"simpleClient.notConnected": "Error while connecting",
"simpleClient.output": "Output",
"simpleClient.port": "Server port:",
"simpleClient.receiveFailed": "No connection established!",
"simpleClient.send": "Send",
"terminal.commands": "Commands",
"terminal.ctrlC": "Ctrl+C",
"terminal.input": "Enter a command",