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:
@@ -2,3 +2,4 @@
|
||||
export * from './host.js';
|
||||
export * from './sim.js';
|
||||
export * from './terminal.js';
|
||||
export * from './web.js';
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// 6.1). The room runs on F (RemoteSimHost, F5) or in a web worker
|
||||
// (LocalSimHost); the messages are the same.
|
||||
|
||||
import type { CaptureRow } from '@mdg-filius/core';
|
||||
import type { CaptureRow, PageLanguage } from '@mdg-filius/core';
|
||||
|
||||
export const SIM_PROTOCOL_VERSION = 1;
|
||||
|
||||
@@ -26,8 +26,13 @@ export type ClientMessageKind = (typeof CLIENT_MESSAGE_KINDS)[number];
|
||||
export type ClientMessage =
|
||||
| { readonly v: 1; readonly kind: 'sub'; readonly topics: readonly Topic[] }
|
||||
| { readonly v: 1; readonly kind: 'unsub'; readonly topics: readonly Topic[] }
|
||||
/** Starts the simulation with the document as it is now. */
|
||||
| { readonly v: 1; readonly kind: 'start' }
|
||||
/**
|
||||
* Starts the simulation with the document as it is now; the pages the
|
||||
* devices write themselves (a router's) come in the language FILIUS runs in
|
||||
* for the person who starts it, as Java's come in its locale (German
|
||||
* without one).
|
||||
*/
|
||||
| { readonly v: 1; readonly kind: 'start'; readonly language?: PageLanguage }
|
||||
| { readonly v: 1; readonly kind: 'stop' }
|
||||
/** Java's speed slider, 1 to 100 per cent: how long a frame takes over a cable. */
|
||||
| { readonly v: 1; readonly kind: 'speed'; readonly value: number }
|
||||
@@ -42,7 +47,7 @@ export type ClientMessage =
|
||||
readonly reqId: string;
|
||||
};
|
||||
|
||||
export const HOST_MESSAGE_KINDS = ['state', 'activity', 'trace', 'app', 'node', 'ack', 'error'] as const;
|
||||
export const HOST_MESSAGE_KINDS = ['state', 'activity', 'trace', 'app', 'view', 'node', 'ack', 'error'] as const;
|
||||
export type HostMessageKind = (typeof HOST_MESSAGE_KINDS)[number];
|
||||
|
||||
export type SimMode = 'design' | 'simulation';
|
||||
@@ -67,6 +72,13 @@ export type HostMessage =
|
||||
| { readonly v: 1; readonly kind: 'trace'; readonly portId: string; readonly frames: readonly TraceFrame[]; readonly reset: boolean }
|
||||
/** A program's new window lines from line `from` on; on subscribing, all (from 0). */
|
||||
| { readonly v: 1; readonly kind: 'app'; readonly node: string; readonly app: AppKind; readonly from: number; readonly lines: readonly unknown[] }
|
||||
/**
|
||||
* What a program's window shows besides its lines (plan §7.3), whole: on
|
||||
* subscribing and when it changes; null while it has nothing (its program
|
||||
* does not run, a browser that never ran). The browser's (BrowserView) and
|
||||
* the web server's (WebServerView) so far.
|
||||
*/
|
||||
| { readonly v: 1; readonly kind: 'view'; readonly node: string; readonly app: AppKind; readonly view: unknown }
|
||||
/**
|
||||
* A computer's first card as the simulation runs it (DHCP may change it):
|
||||
* on subscribing, when it changes, and none while the simulation never ran.
|
||||
@@ -115,8 +127,11 @@ export function parseClientMessage(value: unknown): Parsed<ClientMessage> {
|
||||
return { message: { v: 1, kind: kind as 'sub' | 'unsub', topics: topics as Topic[] } };
|
||||
}
|
||||
case 'start':
|
||||
if (value.language === undefined) return { message: { v: 1, kind: 'start' } };
|
||||
if (value.language !== 'de' && value.language !== 'en') return bad('badMessage', `language ${String(value.language)}`);
|
||||
return { message: { v: 1, kind: 'start', language: value.language } };
|
||||
case 'stop':
|
||||
return { message: { v: 1, kind: kind as 'start' | 'stop' } };
|
||||
return { message: { v: 1, kind: 'stop' } };
|
||||
case 'speed':
|
||||
if (typeof value.value !== 'number' || !Number.isFinite(value.value)) return bad('badMessage', 'speed is no number');
|
||||
return { message: { v: 1, kind: 'speed', value: value.value } };
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
// What the web browser's and the web server's windows show, as the room
|
||||
// keeps them and every client shows them (plan §7.3), after
|
||||
// GUIApplicationWebBrowserWindow and GUIApplicationWebServerWindow
|
||||
// (measured with tools/java-oracle's BrowserWindowRun).
|
||||
//
|
||||
// The browser: Java's address line shows "http://", then the address of each
|
||||
// load as "http://" + host + path without its query (also when nothing
|
||||
// comes); its title is the program's name, then that address, then the
|
||||
// title of the page once it came. The page itself goes to the client as it
|
||||
// came — the client shows it in a sandbox, its images from the data that
|
||||
// came for them.
|
||||
|
||||
/** The page the browser shows. */
|
||||
export type BrowserPage =
|
||||
/** Java's start page: the browser's logo. */
|
||||
| { readonly kind: 'start' }
|
||||
/** sw_webbrowser_msg1: the server could not be reached. */
|
||||
| { readonly kind: 'unreachable' }
|
||||
/** A status other than 200: the browser's error page for it (tmpl/http_fehler). */
|
||||
| { readonly kind: 'error'; readonly statusCode: number }
|
||||
/** An HTML page, with the images that came for it by their src as written (their content type and data in Base64). */
|
||||
| { readonly kind: 'html'; readonly html: string; readonly images: Readonly<Record<string, BrowserImage>> }
|
||||
/** Any other text (Java shows it as HTML). */
|
||||
| { readonly kind: 'text'; readonly text: string }
|
||||
/** An image on its own (Java shows its Base64 as HTML). */
|
||||
| { readonly kind: 'image'; readonly image: BrowserImage };
|
||||
|
||||
export interface BrowserImage {
|
||||
readonly contentType: string;
|
||||
readonly data: string;
|
||||
}
|
||||
|
||||
export interface BrowserView {
|
||||
/** The address line as the room last set it: "http://" at first and after an address that is none. */
|
||||
readonly address: string;
|
||||
/** The address of the last load, the title until its page came; null before the first load (the title is the program's name). */
|
||||
readonly loading: string | null;
|
||||
/** Whether the page shown came for the last load (its title is the window's). */
|
||||
readonly arrived: boolean;
|
||||
readonly page: BrowserPage;
|
||||
}
|
||||
|
||||
/** The browser before it loaded anything. */
|
||||
export const BROWSER_START: BrowserView = { address: 'http://', loading: null, arrived: false, page: { kind: 'start' } };
|
||||
|
||||
/** The web server's window besides its lines: the start button, the virtual hosts' check box and table (five rows of host name and folder). */
|
||||
export interface WebServerView {
|
||||
readonly active: boolean;
|
||||
readonly useVirtualHosts: boolean;
|
||||
readonly virtualHosts: readonly (readonly [string, string])[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The text of an HTML page's first title, as it is written (entities not
|
||||
* decoded); undefined without one or with an empty one (Java keeps the
|
||||
* title it had).
|
||||
*/
|
||||
export function htmlTitle(html: string): string | undefined {
|
||||
const match = /<title(?:\s[^>]*)?>([\s\S]*?)<\/title\s*>/i.exec(html);
|
||||
if (match === null) return undefined;
|
||||
const text = match[1]!.replace(/<[^>]*>/g, '');
|
||||
return text.trim() === '' ? undefined : text;
|
||||
}
|
||||
|
||||
/**
|
||||
* The browser window's title, as Java's: the program's name before the
|
||||
* first load, then the address of the last load, then the title of its page
|
||||
* once it came, where the page has one (`pageTitle`: an HTML page's, an
|
||||
* error page's in the client's language).
|
||||
*/
|
||||
export function browserTitle(view: BrowserView, program: string, pageTitle: (page: BrowserPage) => string | undefined): string {
|
||||
if (view.loading === null) return program;
|
||||
return (view.arrived ? pageTitle(view.page) : undefined) ?? view.loading;
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
// it refuses, each with its reason.
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { CLIENT_MESSAGE_KINDS, HOST_MESSAGE_KINDS, isTopic, parseClientMessage, parseHostMessage } from '../src/index.js';
|
||||
import { BROWSER_START, browserTitle, CLIENT_MESSAGE_KINDS, HOST_MESSAGE_KINDS, htmlTitle, isTopic, parseClientMessage, parseHostMessage, type BrowserView } from '../src/index.js';
|
||||
|
||||
describe("a client's messages", () => {
|
||||
it('reads each kind', () => {
|
||||
@@ -16,6 +16,9 @@ describe("a client's messages", () => {
|
||||
];
|
||||
expect(messages.map((each) => each.kind)).toEqual([...CLIENT_MESSAGE_KINDS]);
|
||||
for (const each of messages) expect(parseClientMessage(each)).toEqual({ message: each });
|
||||
// A start in a language, and the language dropped where it is none.
|
||||
for (const language of ['de', 'en']) expect(parseClientMessage({ v: 1, kind: 'start', language })).toEqual({ message: { v: 1, kind: 'start', language } });
|
||||
expect(parseClientMessage({ v: 1, kind: 'stop', language: 'en' })).toEqual({ message: { v: 1, kind: 'stop' } });
|
||||
});
|
||||
|
||||
it('names what is wrong', () => {
|
||||
@@ -27,6 +30,8 @@ describe("a client's messages", () => {
|
||||
expect(code({ v: 1, kind: 'sub', topics: 'activity' })).toBe('badMessage');
|
||||
expect(code({ v: 1, kind: 'sub', topics: ['activity', 'trace:'] })).toBe('badTopic');
|
||||
expect(code({ v: 1, kind: 'unsub', topics: ['app:node/paint'] })).toBe('badTopic');
|
||||
expect(code({ v: 1, kind: 'start', language: 'fr' })).toBe('badMessage');
|
||||
expect(code({ v: 1, kind: 'start', language: null })).toBe('badMessage');
|
||||
expect(code({ v: 1, kind: 'speed', value: '50' })).toBe('badMessage');
|
||||
expect(code({ v: 1, kind: 'speed', value: Number.NaN })).toBe('badMessage');
|
||||
expect(code({ v: 1, kind: 'input', node: 'n', app: 'terminal', action: 'enter', args: {} })).toBe('badMessage');
|
||||
@@ -47,3 +52,25 @@ describe("a host's messages", () => {
|
||||
expect(parseHostMessage({ v: 0, kind: 'state' })).toEqual({ error: { code: 'badVersion', detail: 'version 0' } });
|
||||
});
|
||||
});
|
||||
|
||||
describe("the browser window's view", () => {
|
||||
it("reads an HTML page's first title as it is written, none where it is empty", () => {
|
||||
expect(htmlTitle('<html><head><TITLE lang="de">Links & mehr</TITLE><title>Zwei</title></head></html>')).toBe('Links & mehr');
|
||||
expect(htmlTitle('<title>\nRouting-Tabelle\n </title >')).toBe('\nRouting-Tabelle\n ');
|
||||
expect(htmlTitle('<title><b>Fett</b> und mehr</title>')).toBe('Fett und mehr');
|
||||
expect(htmlTitle('<title> </title>')).toBeUndefined();
|
||||
expect(htmlTitle('<titles>Nein</titles><p>Kein Titel</p>')).toBeUndefined();
|
||||
});
|
||||
|
||||
it("titles the window with the program's name, then the address being loaded, then the page's title once it came", () => {
|
||||
const pageTitle = (page: BrowserView['page']) => (page.kind === 'html' ? htmlTitle(page.html) : page.kind === 'unreachable' ? 'Fehler' : undefined);
|
||||
expect(browserTitle(BROWSER_START, 'Webbrowser', pageTitle)).toBe('Webbrowser');
|
||||
const shown: BrowserView = { address: 'http://a/', loading: 'http://a/', arrived: true, page: { kind: 'html', html: '<title>A</title>', images: {} } };
|
||||
expect(browserTitle(shown, 'Webbrowser', pageTitle)).toBe('A');
|
||||
// The next load: its address, while the page before still shows.
|
||||
expect(browserTitle({ ...shown, loading: 'http://b/', arrived: false }, 'Webbrowser', pageTitle)).toBe('http://b/');
|
||||
// A page without a title keeps the address.
|
||||
expect(browserTitle({ ...shown, page: { kind: 'text', text: 'x' } }, 'Webbrowser', pageTitle)).toBe('http://a/');
|
||||
expect(browserTitle({ ...shown, page: { kind: 'unreachable' } }, 'Webbrowser', pageTitle)).toBe('Fehler');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user