feat(editor): F4.6a simulation mode and a computer's desktop

Java's mode buttons start and stop the room, now in a worker of the
editor's page (LocalSimHost), with Java's speed slider. While the
simulation runs the design is locked as in Java: no palette, no sheet,
nothing moves, no undo. Cables that carry a frame light up in Java's
green, and a tap on a computer opens its desktop: Java's title, the
software installation first, then the installed programs in the order
of Java's list for the language, a taskbar with the network icon and
the network as the simulation runs it. The room sends that over a new
topic, node:<id>. Desktops stay over a return to the design and show
again at the next start. The programs' windows follow from F4.6b.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 13:41:41 +00:00
co-authored by Claude Opus 5.5
parent 6e97e3565d
commit dfd5ec03ce
44 changed files with 1190 additions and 41 deletions
+26 -1
View File
@@ -4,7 +4,7 @@
// so that it can run on every change while someone drags a device.
import * as Y from 'yjs';
import { compareIds, stableGeneratedMacAddress, type DocumentationItemKind, type HostLabelPolicy, type NodeKind } from '@mdg-filius/core';
import { compareIds, stableGeneratedMacAddress, type DocumentationItemKind, type HostLabelPolicy, type InstallableProgram, type NodeKind } from '@mdg-filius/core';
import { inOrder, ROOTS } from './projectDoc.js';
export interface NodeView {
@@ -115,3 +115,28 @@ export function topologyView(doc: Y.Doc): TopologyView {
docs.sort((left, right) => layer(left) - layer(right) || left.order - right.order || compareIds(left.id, right.id));
return { nodes, links, docs };
}
/** What a computer's desktop needs of the document (Java's GUIDesktopWindow, GUIDesktopPanel). */
export interface DesktopView {
readonly kind: 'pc' | 'notebook';
/** Its label (Host.holeAnzeigeName). */
readonly label: string;
/** Whether the label is its address: Java's title then leaves the address out (isUseIPAsName). */
readonly addressAsName: boolean;
readonly programs: readonly InstallableProgram[];
}
/** A PC's or notebook's desktop; undefined for any other device. */
export function desktopView(doc: Y.Doc, id: string): DesktopView | undefined {
const node = doc.getMap<Y.Map<unknown>>(ROOTS.nodes).get(id);
const kind = node?.get('kind') as NodeKind | undefined;
if (node === undefined || (kind !== 'pc' && kind !== 'notebook')) return undefined;
const policy = node.get('labelPolicy') as HostLabelPolicy;
return {
kind,
label: nodeLabel(node),
addressAsName: policy === 'ipAddress' || policy === 'ipAndMAC',
programs: [...(node.get('programs') as Y.Map<unknown>).keys()] as InstallableProgram[],
};
}
+36 -1
View File
@@ -10,7 +10,7 @@ import { describe, expect, it } from 'vitest';
import * as Y from 'yjs';
import { stableGeneratedMacAddress } from '@mdg-filius/core';
import { exportProject, openProjectArchive, type ImportedProject } from '@mdg-filius/fls';
import { readProject, topologyView, writeProject } from '../src/index.js';
import { addDevice, desktopView, readProject, setHostLabels, topologyView, writeProject } from '../src/index.js';
const corpus = join(dirname(fileURLToPath(import.meta.url)), '../../../corpus');
const files = ['java-examples', 'java-tests', 'ipad-fixtures'].flatMap((folder) =>
@@ -70,3 +70,38 @@ describe('the drawing of a document', () => {
expect(switchLabel).toBe('Switch 1');
});
});
describe("a computer's desktop", () => {
it('names its programs and its label, and whether the label is its address, for computers only', () => {
for (const file of files) {
const doc = docOf(file);
const project = readProject(doc);
for (const node of project.graph.nodes) {
const desktop = desktopView(doc, node.id);
if (node.kind !== 'pc' && node.kind !== 'notebook') {
expect(desktop, `${file} ${node.displayName}`).toBeUndefined();
continue;
}
expect(new Set(desktop!.programs), `${file} ${node.displayName}`).toEqual(project.installedPrograms.get(node.id) ?? new Set());
expect(desktop!.kind).toBe(node.kind);
expect(desktop!.label).toBe(topologyView(doc).nodes.find((each) => each.id === node.id)!.label);
expect(desktop!.addressAsName).toBe(node.hostLabelPolicy === 'ipAddress' || node.hostLabelPolicy === 'ipAndMAC');
}
}
expect(desktopView(docOf(files[0]!), 'nobody')).toBeUndefined();
});
it("takes a label of address and MAC address for the address, as Java's title does (isUseIPAsName), the MAC address alone not", () => {
const doc = new Y.Doc();
let counter = 0;
addDevice(doc, { kind: 'pc', id: 'pc', name: 'PC', x: 0, y: 0, newId: () => `port-${++counter}` }, 'me');
expect(desktopView(doc, 'pc')).toMatchObject({ kind: 'pc', label: 'PC', addressAsName: false, programs: [] });
setHostLabels(doc, 'pc', { ipAddress: true, macAddress: true }, 'me');
expect(desktopView(doc, 'pc')!.addressAsName).toBe(true);
setHostLabels(doc, 'pc', { ipAddress: false, macAddress: true }, 'me');
expect(desktopView(doc, 'pc')!.addressAsName).toBe(false);
setHostLabels(doc, 'pc', { ipAddress: true, macAddress: false }, 'me');
expect(desktopView(doc, 'pc')).toMatchObject({ label: '192.168.0.10', addressAsName: true });
});
});
+6 -1
View File
@@ -6,7 +6,7 @@ import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import type { Page } from '@playwright/test';
import { build, preview, type PreviewServer } from 'vite';
import type { DeviceSheet, TopologyView } from '@mdg-filius/doc';
import type { DesktopView, DeviceSheet, TopologyView } from '@mdg-filius/doc';
const config = join(dirname(fileURLToPath(import.meta.url)), '../vite.config.ts');
@@ -35,6 +35,11 @@ export function sheetOf(page: Page, id: string): Promise<DeviceSheet | undefined
return page.evaluate((each) => (window as unknown as { filiusDev: { sheet: (id: string) => DeviceSheet | undefined } }).filiusDev.sheet(each), id);
}
/** What a computer's desktop reads of the document. */
export function desktopOf(page: Page, id: string): Promise<DesktopView | undefined> {
return page.evaluate((each) => (window as unknown as { filiusDev: { desktop: (id: string) => DesktopView | undefined } }).filiusDev.desktop(each), id);
}
/** A device's button as data-box says it (surface pixels). */
export async function boxOf(page: Page, id: string): Promise<{ x: number; y: number; width: number; height: number }> {
const [x, y, width, height] = (await page.locator(`g.device[data-node="${id}"]`).getAttribute('data-box'))!.split(' ').map(Number) as [number, number, number, number];
+250
View File
@@ -0,0 +1,250 @@
// The simulation in Chromium (F4.6a): Java's mode buttons, the design locked
// while it runs, the speed, the cables that carry frames, a computer's
// desktop opened with a tap — Java's title, its programs in Java's order, its
// network as it runs — and desktops kept over a return to the design.
import { expect, test, type Page } from '@playwright/test';
import { desktopOf, drawing, onDevice, openEditor, serveEditor } from './editorPage.js';
let server: { readonly base: string; readonly close: () => Promise<void> };
test.beforeAll(async () => {
server = await serveEditor();
});
test.afterAll(async () => {
await server.close();
});
async function deviceNamed(page: Page, name: string): Promise<string> {
return (await drawing(page)).nodes.find((node) => node.name === name)!.id;
}
async function tap(page: Page, id: string): Promise<void> {
const at = await onDevice(page, id);
await page.mouse.click(at.x, at.y);
}
const simulationButton = (page: Page) => page.getByRole('button', { name: 'Wechselt in den Aktionsmodus' });
const designButton = (page: Page) => page.getByRole('button', { name: 'Wechselt in den Entwurfsmodus' });
const desktop = (page: Page, id: string) => page.locator(`section[data-desktop="${id}"]`);
/** Starts the simulation and waits until the room says it runs (it answers from its worker). */
async function start(page: Page): Promise<void> {
await simulationButton(page).click();
await expect(page.locator('svg.filius-canvas')).toHaveAttribute('data-mode', 'simulation');
}
test('locks the design while the simulation runs, as Java does, and gives it back', async ({ page }) => {
await openEditor(page, server.base, 'zwei_rechnernetze_komplett.fls');
const router = (await drawing(page)).nodes.find((node) => node.kind === 'router')!;
await tap(page, router.id);
await expect(page.getByRole('complementary', { name: 'Einstellungen' })).toBeVisible();
await expect(designButton(page)).toHaveAttribute('aria-pressed', 'true');
await simulationButton(page).click();
await expect(simulationButton(page)).toHaveAttribute('aria-pressed', 'true');
await expect(page.locator('svg.filius-canvas')).toHaveAttribute('data-mode', 'simulation');
await expect(page.getByRole('complementary', { name: 'Einstellungen' })).toHaveCount(0);
await expect(page.locator('[data-palette]')).toHaveCount(0);
await expect(page.getByRole('button', { name: 'Rückgängig' })).toHaveCount(0);
// Java's slider: 90 per cent at first (Verbindung's delay factor 10).
const speed = page.locator('input[name="speed"]');
await expect(speed).toHaveValue('90');
await expect(page.getByTitle('Aktuelle Simulationsgeschwindigkeit')).toHaveText('90%');
await speed.fill('40');
await expect(page.getByTitle('Aktuelle Simulationsgeschwindigkeit')).toHaveText('40%');
// Nothing moves, nothing is chosen, a key removes nothing.
const at = await onDevice(page, router.id);
await page.mouse.move(at.x, at.y);
await page.mouse.down();
await page.mouse.move(at.x + 80, at.y + 60, { steps: 5 });
await page.mouse.up();
await page.keyboard.press('Delete');
const still = (await drawing(page)).nodes.find((node) => node.id === router.id)!;
expect([still.x, still.y]).toEqual([router.x, router.y]);
await expect(page.locator('g.device[data-selected]')).toHaveCount(0);
await designButton(page).click();
await expect(page.locator('svg.filius-canvas')).toHaveAttribute('data-mode', 'design');
await expect(page.locator('[data-palette]').first()).toBeVisible();
await expect(page.getByRole('button', { name: 'Rückgängig' })).toBeVisible();
await expect(page.locator('input[name="speed"]')).toHaveCount(0);
// The speed stays the room's for the next start.
await simulationButton(page).click();
await expect(page.locator('input[name="speed"]')).toHaveValue('40');
});
test("lights a cable while it carries a frame, in Java's green", async ({ page }) => {
// The notebooks ask for their addresses by DHCP as the simulation starts.
await openEditor(page, server.base, 'einfaches_rechnernetz_komplett.fls');
await simulationButton(page).click();
await expect(page.locator('path[data-link][data-active="true"]').first()).toBeAttached({ timeout: 10_000 });
await expect(page.locator('path[data-link][data-active="true"]').first()).toHaveCSS('stroke', 'rgb(0, 255, 64)');
await designButton(page).click();
await expect(page.locator('path[data-link][data-active]')).toHaveCount(0);
});
test("opens a computer's desktop with a tap: Java's title, its programs in Java's order, its network as it runs", async ({ page }) => {
await openEditor(page, server.base, 'zwei_rechnernetze_komplett.fls');
const id = await deviceNamed(page, 'DNS-Server 2.10');
expect((await desktopOf(page, id))!.programs).toHaveLength(3);
await start(page);
await tap(page, id);
const window = desktop(page, id);
await expect(window.getByRole('heading')).toHaveText('DNS-Server 2.10 - 141.99.2.10');
await expect(window.locator('.desktop-icon span')).toHaveText(['Software-Installation', 'Datei-Explorer', 'DNS-Server', 'Webserver']);
await expect(window.getByRole('button', { name: 'Startbildschirm' })).toBeVisible();
await window.getByRole('button', { name: 'Netzwerk' }).click();
const values = window.locator('.desktop-network dd');
await expect(window.locator('.desktop-network dt')).toHaveText(['IP-Adresse:', 'Netzmaske:', 'DNS-Server:', 'Gateway:', 'MAC-Adresse:']);
await expect(values.nth(0)).toHaveText('141.99.2.10');
await expect(values.nth(4)).toHaveText(/^([0-9A-F]{2}:){5}[0-9A-F]{2}$/);
// A press beside the panel closes it.
await window.locator('.desktop-info-layer').click({ position: { x: 20, y: 20 } });
await expect(window.locator('.desktop-network')).toHaveCount(0);
// A switch has no desktop; its long-press menu has nothing either.
await tap(page, await deviceNamed(page, 'Switch 1'));
await expect(page.locator('section[data-desktop]')).toHaveCount(1);
await window.getByRole('button', { name: 'Schließen' }).click();
await expect(page.locator('section[data-desktop]')).toHaveCount(0);
// Java's menu: "show desktop".
const at = await onDevice(page, await deviceNamed(page, 'Notebook 1.10'));
await page.mouse.move(at.x, at.y);
await page.mouse.down();
await page.waitForTimeout(700);
await page.mouse.up();
await page.getByRole('menuitem', { name: 'Desktop anzeigen' }).click();
await expect(page.locator('section[data-desktop] h2')).toHaveText('Notebook 1.10 - 141.99.1.10');
await expect(page.locator('section[data-desktop] .desktop-icon span')).toHaveText(['Software-Installation', 'Webbrowser']);
});
test('moves a desktop by its title bar, brings the one touched to the front, and keeps them over a return to the design', async ({ page }) => {
await openEditor(page, server.base, 'zwei_rechnernetze_komplett.fls');
const [first, second] = [await deviceNamed(page, 'Notebook 1.10'), await deviceNamed(page, 'Notebook 1.11')];
await start(page);
await tap(page, first);
// Moved by its title bar into the half of the page the second notebook is not in.
const at = await onDevice(page, second);
const width = page.viewportSize()!.width;
const bar = (await desktop(page, first).locator('.desktop-titlebar h2').boundingBox())!;
const before = (await desktop(page, first).boundingBox())!;
const step = { x: (at.x < width / 2 ? width - 650 : 0) - before.x, y: 30 };
await page.mouse.move(bar.x + 10, bar.y + 10);
await page.mouse.down();
await page.mouse.move(bar.x + 10 + step.x, bar.y + 10 + step.y, { steps: 8 });
await page.mouse.up();
const after = (await desktop(page, first).boundingBox())!;
expect([Math.round(after.x - before.x), Math.round(after.y - before.y)]).toEqual([Math.round(step.x), step.y]);
await tap(page, second);
await expect(page.locator('section[data-desktop]')).toHaveCount(2);
const z = async (id: string) => Number(await desktop(page, id).evaluate((element) => getComputedStyle(element).zIndex));
expect(await z(second)).toBeGreaterThan(await z(first));
await desktop(page, first).locator('.desktop-titlebar h2').click();
expect(await z(first)).toBeGreaterThan(await z(second));
await designButton(page).click();
await expect(page.locator('section[data-desktop]')).toHaveCount(0);
await simulationButton(page).click();
await expect(page.locator('section[data-desktop]')).toHaveCount(2);
expect(Math.round((await desktop(page, first).boundingBox())!.x)).toBe(Math.round(after.x));
});
test('takes nothing back and keeps no design tool while the simulation runs', async ({ page }) => {
await openEditor(page, server.base, 'zwei_rechnernetze_komplett.fls');
const id = await deviceNamed(page, 'Notebook 1.10');
const at = await onDevice(page, id);
await page.mouse.move(at.x, at.y);
await page.mouse.down();
await page.mouse.move(at.x + 60, at.y + 40, { steps: 5 });
await page.mouse.up();
const moved = (await drawing(page)).nodes.find((node) => node.id === id)!;
await page.locator('[data-palette="cable"]').click();
await expect(page.locator('svg.filius-canvas')).toHaveAttribute('data-tool', 'cable');
await start(page);
await page.keyboard.press('Control+z');
const still = (await drawing(page)).nodes.find((node) => node.id === id)!;
expect([still.x, still.y]).toEqual([moved.x, moved.y]);
await designButton(page).click();
await expect(page.locator('svg.filius-canvas')).toHaveAttribute('data-tool', 'select');
// Back in the design, the step can be taken back.
await page.getByRole('button', { name: 'Rückgängig' }).click();
await expect.poll(async () => (await drawing(page)).nodes.find((node) => node.id === id)!.x).not.toBe(moved.x);
});
test("titles a desktop with the address alone where the address is the computer's name, and lights its network icon while its cable carries a frame", async ({ page }) => {
await openEditor(page, server.base, 'einfaches_rechnernetz_komplett.fls');
const id = await deviceNamed(page, 'Rechner 1');
await tap(page, id);
await page.getByRole('complementary', { name: 'Einstellungen' }).getByLabel('IP-Adresse als Name verwenden').check();
await page.getByRole('complementary', { name: 'Einstellungen' }).getByRole('button', { name: 'Schließen' }).click();
// The notebooks ask for their addresses by DHCP as the simulation starts; the switch passes their broadcasts on to Rechner 1.
await start(page);
await tap(page, id);
const window = desktop(page, id);
await expect(window.getByRole('heading')).toHaveText('192.168.0.11');
await expect(window.locator('.desktop-network-icon[data-active="true"]')).toBeAttached({ timeout: 10_000 });
await window.getByRole('button', { name: 'Netzwerk' }).click();
await expect(window.locator('.desktop-network')).toBeVisible();
await window.getByRole('button', { name: 'Startbildschirm' }).click();
await expect(window.locator('.desktop-network')).toHaveCount(0);
});
test("closes a desktop whose computer is removed while the design is shown", async ({ page }) => {
await openEditor(page, server.base, 'zwei_rechnernetze_komplett.fls');
const id = await deviceNamed(page, 'Notebook 1.10');
await start(page);
await tap(page, id);
await designButton(page).click();
await expect(page.locator('svg.filius-canvas')).toHaveAttribute('data-mode', 'design');
await tap(page, id);
await page.keyboard.press('Delete');
await simulationButton(page).click();
await expect(page.locator('svg.filius-canvas')).toHaveAttribute('data-mode', 'simulation');
await expect(page.locator('section[data-desktop]')).toHaveCount(0);
// The next desktop takes the first place: the gone one is not counted.
const next = await deviceNamed(page, 'Notebook 1.11');
await tap(page, next);
const area = (await page.locator('.editor-area').boundingBox())!;
const box = (await desktop(page, next).boundingBox())!;
expect([Math.round(box.x - area.x), Math.round(box.y - area.y)]).toEqual([24, 76]);
});
test.describe('on a phone', () => {
test.use({ viewport: { width: 390, height: 844 }, hasTouch: true });
test('fills the surface with a desktop opened by a tap, below the mode buttons', async ({ page }) => {
await openEditor(page, server.base, 'zwei_rechnernetze_komplett.fls');
await simulationButton(page).tap();
await expect(page.locator('svg.filius-canvas')).toHaveAttribute('data-mode', 'simulation');
await page.getByRole('button', { name: 'Alles zeigen' }).tap();
const id = await deviceNamed(page, 'Notebook 1.10');
const at = await onDevice(page, id);
await page.touchscreen.tap(at.x, at.y);
const window = desktop(page, id);
await expect(window).toBeVisible();
const box = (await window.boundingBox())!;
const area = (await page.locator('.editor-area').boundingBox())!;
const modes = (await simulationButton(page).boundingBox())!;
expect(box.width).toBeCloseTo(area.width, 0);
expect(box.y).toBeGreaterThanOrEqual(modes.y + modes.height);
expect(box.y + box.height).toBeCloseTo(area.y + area.height, 0);
const home = (await window.getByRole('button', { name: 'Startbildschirm' }).boundingBox())!;
expect(home.height).toBeGreaterThanOrEqual(44);
});
});
test('speaks English when asked', async ({ page }) => {
await openEditor(page, server.base, 'zwei_rechnernetze_komplett.fls', '&lang=en');
await page.getByRole('button', { name: 'Change to Simulation Mode' }).click();
await expect(page.locator('svg.filius-canvas')).toHaveAttribute('data-mode', 'simulation');
const id = await deviceNamed(page, 'DNS-Server 2.10');
await tap(page, id);
await expect(desktop(page, id).locator('.desktop-icon span')).toHaveText(['Software Installation', 'DNS server', 'File explorer', 'Webserver']);
await expect(desktop(page, id).getByRole('button', { name: 'Home Screen' })).toBeVisible();
});
+122 -20
View File
@@ -1,6 +1,10 @@
// The editor on a document: the palette, the surface, the tools of the design
// mode (F4.4b), the device sheet of the selected device (F4.5). The
// simulation follows (F4.6).
// mode (F4.4b), the device sheet of the selected device (F4.5), Java's mode
// buttons and the simulation (F4.6): while it runs, the design is locked as
// in Java — no palette, no sheet, nothing moves — the cables that carry a
// frame light up, and a tap on a computer opens its desktop. Desktops left
// open stay over a return to the design and show again at the next start,
// as Java's do (GUIContainer.closeDialogs, reopenDialogs).
//
// Every change goes through packages/doc's commands with this editor's own
// origin, so that its undo takes back what this person did and nothing of
@@ -15,7 +19,10 @@ import { ContextMenu, type MenuItem } from './design/ContextMenu.js';
import { Palette, type PaletteKind } from './design/Palette.js';
import { RouterDialog } from './design/RouterDialog.js';
import { DeviceSheet } from './sheet/DeviceSheet.js';
import { DesktopWindow } from './desktop/DesktopWindow.js';
import { useTranslate } from './i18n/i18n.js';
import { ModeBar } from './simulation/ModeBar.js';
import { useSimulation, type Simulation, type SimHostFactory } from './simulation/useSimulation.js';
/** The document's drawing, read anew after every change. */
export function useTopologyView(doc: Y.Doc): TopologyView {
@@ -66,9 +73,33 @@ const toolButton: CSSProperties = {
/** Whether a key press belongs to a field being typed in. */
const typing = (target: EventTarget | null) => target instanceof HTMLElement && (target.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName));
export function Editor({ doc }: { readonly doc: Y.Doc }) {
/** A desktop open on the surface: whose, where its window lies, and how far up (the last one touched on top). */
interface OpenDesktop {
readonly id: string;
readonly x: number;
readonly y: number;
readonly z: number;
}
/** Brings a desktop to the top by its stacking, not its place in the page: moved in the page, it would lose the press on it. */
const toFront = (open: readonly OpenDesktop[], id: string): readonly OpenDesktop[] => {
const top = Math.max(0, ...open.map((each) => each.z));
return open.some((each) => each.id === id && each.z === top) ? open : open.map((each) => (each.id === id ? { ...each, z: top + 1 } : each));
};
/** A desktop that watches its computer's network while it is shown. */
function WatchedDesktop({ simulation, ...props }: Omit<Parameters<typeof DesktopWindow>[0], 'settings'> & { readonly simulation: Simulation }) {
const { watch } = simulation;
useEffect(() => watch([`node:${props.id}`]), [watch, props.id]);
return <DesktopWindow {...props} settings={simulation.nodes.get(props.id)} />;
}
export function Editor({ doc, createSimHost }: { readonly doc: Y.Doc; readonly createSimHost?: SimHostFactory }) {
const t = useTranslate();
const view = useTopologyView(doc);
const simulation = useSimulation(doc, createSimHost);
const simulating = simulation.mode === 'simulation';
const [desktops, setDesktops] = useState<readonly OpenDesktop[]>([]);
const origin = useMemo(() => ({ editor: crypto.randomUUID() }), []);
const { manager, canUndo, canRedo } = useUndo(doc, origin);
const canvas = useRef<CanvasHandle>(null);
@@ -88,6 +119,37 @@ export function Editor({ doc }: { readonly doc: Y.Doc }) {
return () => clearTimeout(timer);
}, [message]);
// The simulation locks the design: what was chosen or begun is let go.
useEffect(() => {
if (!simulating) return;
setSelection(null);
setCableTool(false);
setCableFrom(null);
setMenu(null);
setRouterAt(null);
}, [simulating]);
useEffect(() => {
if (simulation.failed) setMessage(t('sim.failed'));
// The message is shown once per failure.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [simulation.failed]);
// A desktop whose computer is gone, or is no computer any more, closes (Java: reopenDialogs).
useEffect(() => {
const computers = new Set(view.nodes.filter((node) => node.kind === 'pc' || node.kind === 'notebook').map((node) => node.id));
setDesktops((open) => (open.every((each) => computers.has(each.id)) ? open : open.filter((each) => computers.has(each.id))));
}, [view]);
/** Opens a computer's desktop, or brings it to the front. */
const openDesktop = useCallback((id: string) => {
setDesktops((open) => {
if (open.some((each) => each.id === id)) return toFront(open, id);
// Java stacks a desktop that does not fit beside the others a little further on.
return [...open, { id, x: 24 + 24 * open.length, y: 76 + 24 * open.length, z: Math.max(0, ...open.map((each) => each.z)) + 1 }];
});
}, []);
// What is selected may be gone (removed here or by someone else).
useEffect(() => {
if (selection?.kind === 'device' && !view.nodes.some((node) => node.id === selection.id)) setSelection(null);
@@ -137,6 +199,11 @@ export function Editor({ doc }: { readonly doc: Y.Doc }) {
);
const menuItems = (target: Selection): MenuItem[] => {
if (simulating) {
// Java's menu in the simulation: a computer's desktop (its data exchange follows with the capture, F4.7).
const node = view.nodes.find((each) => each.id === target.id);
return target.kind === 'device' && (node?.kind === 'pc' || node?.kind === 'notebook') ? [{ key: 'showDesktop', label: t('menu.showDesktop'), onChoose: () => openDesktop(target.id) }] : [];
}
if (target.kind === 'link') return [{ key: 'removeCable', label: t('menu.removeCable'), onChoose: () => remove(target) }];
const node = view.nodes.find((each) => each.id === target.id);
const cables = view.links.some((link) => link.sourceNode === target.id || link.targetNode === target.id);
@@ -161,7 +228,7 @@ export function Editor({ doc }: { readonly doc: Y.Doc }) {
// The keyboard: undo and redo, delete what is selected, Escape lets go.
useEffect(() => {
const key = (event: KeyboardEvent) => {
if (typing(event.target)) return;
if (typing(event.target) || simulating) return;
const command = event.ctrlKey || event.metaKey;
if (command && event.key.toLowerCase() === 'z') {
event.preventDefault();
@@ -180,18 +247,20 @@ export function Editor({ doc }: { readonly doc: Y.Doc }) {
};
window.addEventListener('keydown', key);
return () => window.removeEventListener('keydown', key);
}, [manager, selection, menu, routerAt, remove]);
}, [manager, selection, menu, routerAt, remove, simulating]);
return (
<div style={{ display: 'flex', height: '100%' }}>
<Palette
cableTool={cableTool}
onCableTool={(on) => {
setCableTool(on);
setCableFrom(null);
}}
onDevice={onDevice}
/>
{simulating ? null : (
<Palette
cableTool={cableTool}
onCableTool={(on) => {
setCableTool(on);
setCableFrom(null);
}}
onDevice={onDevice}
/>
)}
<div className="editor-work">
<div className="editor-area">
<Canvas
@@ -206,19 +275,51 @@ export function Editor({ doc }: { readonly doc: Y.Doc }) {
onCableStart={setCableFrom}
onCable={onCable}
highlightedLink={highlightedLink}
mode={simulation.mode}
activeLinks={simulation.activeLinks}
onActivate={(id) => {
const kind = view.nodes.find((node) => node.id === id)?.kind;
if (kind === 'pc' || kind === 'notebook') openDesktop(id);
}}
onMenu={(target, at) => {
if (simulating) {
if (menuItems(target).length > 0) setMenu({ target, at });
return;
}
setSelection(target);
setMenu({ target, at });
}}
/>
<div role="toolbar" style={{ position: 'absolute', left: 12, top: 12, display: 'flex', gap: 6 }}>
<button type="button" style={toolButton} disabled={!canUndo} onClick={() => manager?.undo()} aria-label={t('design.undo')} title={t('design.undo')}>
↶
</button>
<button type="button" style={toolButton} disabled={!canRedo} onClick={() => manager?.redo()} aria-label={t('design.redo')} title={t('design.redo')}>
↷
</button>
<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} />
{simulating ? null : (
<>
<button type="button" style={toolButton} disabled={!canUndo} onClick={() => manager?.undo()} aria-label={t('design.undo')} title={t('design.undo')}>
↶
</button>
<button type="button" style={toolButton} disabled={!canRedo} onClick={() => manager?.redo()} aria-label={t('design.redo')} title={t('design.redo')}>
↷
</button>
</>
)}
</div>
{simulating
? desktops.map((desktop) => (
<WatchedDesktop
key={desktop.id}
simulation={simulation}
doc={doc}
id={desktop.id}
active={view.links.some((link) => (link.sourceNode === desktop.id || link.targetNode === desktop.id) && simulation.activeLinks.has(link.id))}
x={desktop.x}
y={desktop.y}
z={desktop.z}
onMove={(x, y) => setDesktops((open) => open.map((each) => (each.id === desktop.id ? { ...each, x, y } : each)))}
onFront={() => setDesktops((open) => toFront(open, desktop.id))}
onClose={() => setDesktops((open) => open.filter((each) => each.id !== desktop.id))}
/>
))
: null}
{menu === null ? null : <ContextMenu at={menu.at} items={menuItems(menu.target)} onClose={() => setMenu(null)} />}
{routerAt === null ? null : (
<RouterDialog
@@ -233,6 +334,7 @@ export function Editor({ doc }: { readonly doc: Y.Doc }) {
role="alert"
style={{
position: 'absolute',
zIndex: 60,
left: '50%',
bottom: 72,
transform: 'translateX(-50%)',
Binary file not shown.

After

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 600 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 608 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 958 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

+23 -6
View File
@@ -56,8 +56,19 @@ export interface CanvasProps {
readonly onMenu?: (target: Selection, at: Point) => void;
/** A cable drawn active, as Java draws the cable of the card whose tab is open (Kabel.setAktiv). */
readonly highlightedLink?: string | null;
/**
* The simulation: a tap on a device activates it (a computer's desktop),
* a long press opens its menu; nothing moves, nothing is selected, as in
* Java's simulation mode.
*/
readonly mode?: 'design' | 'simulation';
/** The cables that carry a frame now, drawn as Java draws them then (JCablePanel.farbeBlinken). */
readonly activeLinks?: ReadonlySet<string>;
readonly onActivate?: (id: string) => void;
}
const NO_LINKS: ReadonlySet<string> = new Set();
/** The label's width in Java's font, measured once per text. */
function useTextWidth(): (text: string) => number {
const cache = useRef(new Map<string, number>());
@@ -141,7 +152,7 @@ type Gesture =
| { readonly kind: 'link'; readonly id: string; readonly start: Point; moved: boolean; longPressed: boolean };
export const Canvas = forwardRef<CanvasHandle, CanvasProps>(function Canvas(
{ view, tool = 'select', selection = null, cableFrom = null, onSelect, onMoveDevice, onMoveEnd, onCableStart, onCable, onMenu, highlightedLink = null },
{ view, tool = 'select', selection = null, cableFrom = null, onSelect, onMoveDevice, onMoveEnd, onCableStart, onCable, onMenu, highlightedLink = null, mode = 'design', activeLinks = NO_LINKS, onActivate },
handle,
) {
const t = useTranslate();
@@ -222,6 +233,7 @@ export const Canvas = forwardRef<CanvasHandle, CanvasProps>(function Canvas(
[kinds, t],
);
const simulating = mode === 'simulation';
const pointers = useRef(new Map<number, Point>());
const gesture = useRef<Gesture | null>(null);
const longPress = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -258,7 +270,7 @@ export const Canvas = forwardRef<CanvasHandle, CanvasProps>(function Canvas(
now.longPressed = true;
onMenu?.({ kind: 'device', id: now.id }, now.start);
}, LONG_PRESS_MILLISECONDS);
} else if (linkId !== undefined && linkId !== null) {
} else if (linkId !== undefined && linkId !== null && !simulating) {
gesture.current = { kind: 'link', id: linkId, start: point, moved: false, longPressed: false };
longPress.current = setTimeout(() => {
const now = gesture.current;
@@ -302,7 +314,7 @@ export const Canvas = forwardRef<CanvasHandle, CanvasProps>(function Canvas(
const step = { x: point.x - now.last.x, y: point.y - now.last.y };
now.last = point;
setViewport((before) => shifted(before ?? currentRef.current, step));
} else if (now.kind === 'device' && !now.longPressed) {
} else if (now.kind === 'device' && !now.longPressed && !simulating) {
if (tool === 'cable') {
const from = boxes.get(now.id)!.center;
const target = deviceAt(point);
@@ -328,7 +340,7 @@ export const Canvas = forwardRef<CanvasHandle, CanvasProps>(function Canvas(
return;
}
if (now.kind === 'background') {
if (!now.moved) {
if (!now.moved && !simulating) {
onSelect?.(null);
if (cableFrom !== null) onCableStart?.(null);
}
@@ -339,6 +351,10 @@ export const Canvas = forwardRef<CanvasHandle, CanvasProps>(function Canvas(
return;
}
if (now.longPressed) return;
if (simulating) {
if (!now.moved) onActivate?.(now.id);
return;
}
if (tool === 'cable') {
setBand(null);
const from = { id: now.id, side: now.side };
@@ -399,6 +415,7 @@ export const Canvas = forwardRef<CanvasHandle, CanvasProps>(function Canvas(
ref={surface}
className="filius-canvas"
data-tool={tool}
data-mode={mode}
role="application"
aria-label={t('canvas.label')}
style={{ ...style, cursor: tool === 'cable' ? 'crosshair' : 'default' }}
@@ -421,7 +438,7 @@ export const Canvas = forwardRef<CanvasHandle, CanvasProps>(function Canvas(
if (source === undefined || target === undefined) return null;
const d = cablePath(source.center, target.center);
const selected = selection?.kind === 'link' && selection.id === link.id;
const active = highlightedLink === link.id;
const active = simulating ? activeLinks.has(link.id) : highlightedLink === link.id;
return (
<g key={link.id}>
<path
@@ -463,7 +480,7 @@ export const Canvas = forwardRef<CanvasHandle, CanvasProps>(function Canvas(
)}
</g>
</svg>
{menuButton === null || selection === null ? null : (
{menuButton === null || selection === null || simulating ? null : (
<button
type="button"
className="device-menu-button"
@@ -0,0 +1,141 @@
// A computer's desktop in the simulation, after Java's GUIDesktopWindow and
// GUIDesktopPanel: a window titled with the computer's label and address
// (the label alone where it is the address), Java's background, the
// software installation's icon first and then the installed programs in the
// order of Java's list, a grey taskbar with "home screen" in the middle and,
// on the right, the network icon — lit while the computer's cable carries a
// frame — that shows its network as it runs (NetworkInfoPanel). Java's panel
// has a "change" button under read-only fields, which writes back what they
// show; it is left out.
//
// 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. The programs'
// windows follow (F4.6b on).
import { useEffect, useState, type PointerEvent as ReactPointerEvent } from 'react';
import type * as Y from 'yjs';
import { desktopView } from '@mdg-filius/doc';
import type { NodeSettings } 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';
import { ICON_URLS } from '../canvas/icons.js';
import { useLanguage, useTranslate } from '../i18n/i18n.js';
import { desktopPrograms, INSTALLATION_ICON, JAVA_PROGRAMS } from './programs.js';
import './desktop.css';
export interface DesktopWindowProps {
readonly doc: Y.Doc;
readonly id: string;
/** The computer's network as the simulation runs it; undefined until the room has sent it. */
readonly settings: NodeSettings | undefined;
/** Whether its cable carried a frame at the last tick. */
readonly active: boolean;
/** The window's top left corner on the surface. */
readonly x: number;
readonly y: number;
/** Its place in the stack of desktops. */
readonly z: number;
readonly onMove: (x: number, y: number) => void;
readonly onFront: () => void;
readonly onClose: () => void;
}
/** 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) {
const [view, setView] = useState(() => desktopView(doc, id));
useEffect(() => {
setView(desktopView(doc, id));
const update = () => setView(desktopView(doc, id));
doc.on('update', update);
return () => doc.off('update', update);
}, [doc, id]);
return view;
}
export function DesktopWindow({ doc, id, settings, active, x, y, z, onMove, onFront, onClose }: DesktopWindowProps) {
const t = useTranslate();
const language = useLanguage();
const view = useDesktopView(doc, id);
const [info, setInfo] = useState(false);
const [drag, setDrag] = useState<{ readonly pointer: number; readonly dx: number; readonly dy: number; moved: boolean } | null>(null);
if (view === undefined) return null;
const address = settings?.ipAddress ?? '';
const title = view.addressAsName ? view.label : `${view.label} - ${address}`;
// The title bar moves the window; the pointer is captured once it moves (pointer-capture trap).
const onPointerDown = (event: ReactPointerEvent<HTMLElement>) => {
if ((event.target as Element).closest('button') !== null) return;
setDrag({ pointer: event.pointerId, dx: event.clientX - x, dy: event.clientY - y, moved: false });
};
const onPointerMove = (event: ReactPointerEvent<HTMLElement>) => {
if (drag === null || drag.pointer !== event.pointerId) return;
if (!drag.moved) {
drag.moved = true;
event.currentTarget.setPointerCapture(event.pointerId);
}
onMove(Math.max(0, event.clientX - drag.dx), Math.max(0, event.clientY - drag.dy));
};
const onPointerUp = () => setDrag(null);
const rows: readonly [string, string][] = [
[t('network.ipAddress'), address],
[t('network.subnetMask'), settings?.subnetMask ?? ''],
[t('network.dnsServer'), settings?.dnsServer ?? ''],
[t('network.gateway'), settings?.defaultGateway ?? ''],
[t('network.macAddress'), settings?.macAddress ?? ''],
];
return (
<section className="desktop-window" role="dialog" aria-label={title} data-desktop={id} style={{ left: x, top: y, zIndex: 5 + z }} onPointerDownCapture={onFront}>
<header className="desktop-titlebar" onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={onPointerUp} onPointerCancel={onPointerUp}>
<img src={ICON_URLS[view.kind === 'notebook' ? 'laptop' : 'server']} alt="" />
<h2>{title}</h2>
<button type="button" className="desktop-close" aria-label={t('sheet.close')} title={t('sheet.close')} onClick={onClose}>
×
</button>
</header>
<div className="desktop-screen" style={{ backgroundImage: `url(${background})` }}>
<ul className="desktop-icons">
<li>
{/* The programs' windows come with the next steps. */}
<button type="button" className="desktop-icon" aria-disabled="true" title={t('desktop.installation')}>
<img src={INSTALLATION_ICON} alt="" />
<span>{t('desktop.installation')}</span>
</button>
</li>
{desktopPrograms(view.programs, language).map((program) => (
<li key={program}>
<button type="button" className="desktop-icon" data-program={program} aria-disabled="true" title={t(`program.${program}`)}>
<img src={JAVA_PROGRAMS[program].icon} alt="" />
<span>{t(`program.${program}`)}</span>
</button>
</li>
))}
</ul>
{info ? (
// A press beside the panel closes it, as in Java.
<div className="desktop-info-layer" onPointerDown={(event) => event.target === event.currentTarget && setInfo(false)}>
<dl className="desktop-network" aria-label={t('desktop.network')}>
{rows.map(([label, value]) => (
<div key={label}>
<dt>{label}</dt>
<dd>{value}</dd>
</div>
))}
</dl>
</div>
) : null}
</div>
<footer className="desktop-taskbar">
<span />
<button type="button" className="desktop-home" onClick={() => setInfo(false)}>
{t('desktop.home')}
</button>
<button type="button" className="desktop-network-icon" data-active={active ? 'true' : undefined} aria-label={t('desktop.network')} aria-expanded={info} title={address} onClick={() => setInfo(!info)}>
<img src={active ? networkOn : networkOff} alt="" />
</button>
</footer>
</section>
);
}
+185
View File
@@ -0,0 +1,185 @@
/* A computer's desktop after Java's GUIDesktopPanel: 640 × 460 pixels below
* the title bar, a grey taskbar at the bottom (180, 180, 180; 44 pixels high
* for a finger, Java's has 30), icons of 120 × 96 with white names on Java's
* background. On a phone it fills the surface. Java's colours stay in both
* themes: the screen is the simulated computer's, not the editor's. */
.desktop-window {
position: absolute;
z-index: 5;
display: flex;
flex-direction: column;
width: 640px;
max-width: 100%;
height: calc(460px + 14px + 36px);
max-height: 100%;
box-sizing: border-box;
border: 1px solid var(--border-strong);
border-radius: 8px;
overflow: hidden;
background: var(--surface-card);
box-shadow: 0 8px 24px color-mix(in srgb, var(--shadow-color) 25%, transparent);
}
/* Below the mode buttons, which stay in reach. */
@media (max-width: 639px) {
.desktop-window {
inset: 72px 0 0 0 !important;
width: auto;
height: auto;
border-radius: 0;
}
}
.desktop-titlebar {
display: flex;
align-items: center;
gap: 8px;
min-height: 36px;
padding: 0 0 0 8px;
background: var(--surface-raised);
border-bottom: 1px solid var(--border-default);
cursor: move;
touch-action: none;
user-select: none;
}
.desktop-titlebar img {
width: 24px;
height: 24px;
object-fit: contain;
}
.desktop-titlebar h2 {
flex: 1;
margin: 0;
font-size: 15px;
font-weight: 600;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
color: var(--text-primary);
}
.desktop-close {
min-width: 44px;
min-height: 36px;
border: none;
background: transparent;
color: var(--text-secondary);
font: inherit;
font-size: 22px;
cursor: pointer;
}
.desktop-screen {
position: relative;
flex: 1;
min-height: 0;
overflow: auto;
background-size: cover;
background-position: top left;
}
.desktop-icons {
display: flex;
flex-wrap: wrap;
gap: 10px 5px;
margin: 0;
padding: 10px 5px;
list-style: none;
}
.desktop-icon {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
width: 120px;
height: 96px;
padding: 0;
border: none;
border-radius: 6px;
background: transparent;
color: #ffffff;
font: 13px Dialog, sans-serif;
cursor: pointer;
}
.desktop-icon[aria-disabled='true'] {
cursor: default;
}
.desktop-icon:focus-visible {
outline: 2px solid #ffffff;
}
.desktop-icon img {
width: 64px;
height: 64px;
}
.desktop-info-layer {
position: absolute;
inset: 0;
display: flex;
justify-content: flex-end;
align-items: flex-end;
}
.desktop-network {
margin: 0;
padding: 8px 12px;
border: 2px inset #c0c0c0;
background: #eeeeee;
color: #000000;
font: 13px Dialog, sans-serif;
}
.desktop-network div {
display: grid;
grid-template-columns: 110px 140px;
align-items: center;
min-height: 26px;
}
.desktop-network dt,
.desktop-network dd {
margin: 0;
}
.desktop-network dd {
padding: 2px 6px;
background: #ffffff;
border: 1px solid #b0b0b0;
font-variant-numeric: tabular-nums;
user-select: text;
}
.desktop-taskbar {
display: grid;
grid-template-columns: 52px 1fr 52px;
align-items: stretch;
height: 44px;
flex: none;
background: rgb(180 180 180);
}
.desktop-home,
.desktop-network-icon {
border: none;
background: transparent;
color: #000000;
font: 13px Dialog, sans-serif;
cursor: pointer;
}
.desktop-home {
justify-self: center;
padding: 0 12px;
}
.desktop-network-icon img {
width: 18px;
height: 18px;
}
+56
View File
@@ -0,0 +1,56 @@
// The programs of a computer's desktop, after Java's list of them
// (config/Desktop_<language>.txt: name, class, window, icon), which also sets
// their order there — each language's own (the English list is alphabetical,
// the German one is not). The DHCP server has no window on the desktop; it is
// set up in the device sheet, as in Java.
import type { InstallableProgram } from '@mdg-filius/core';
import type { Language } from '../i18n/i18n.js';
import browser from '../assets/desktop/icon_browser.png';
import simpleClient from '../assets/desktop/icon_clientbaustein.png';
import dns from '../assets/desktop/icon_dns.png';
import emailClient from '../assets/desktop/icon_emailprogramm.png';
import emailServer from '../assets/desktop/icon_emailserver.png';
import fileExplorer from '../assets/desktop/icon_filebrowser.png';
import firewall from '../assets/desktop/icon_firewall.png';
import imageViewer from '../assets/desktop/icon_imageviewer.png';
import gnutella from '../assets/desktop/icon_peertopeer.png';
import echoServer from '../assets/desktop/icon_serverbaustein.png';
import installation from '../assets/desktop/icon_softwareinstallation.png';
import terminal from '../assets/desktop/icon_terminal.png';
import textEditor from '../assets/desktop/icon_texteditor.png';
import webServer from '../assets/desktop/icon_webserver.png';
export type DesktopProgram = Exclude<InstallableProgram, 'dhcpServer'>;
/** Each program's class in Java and its icon (gfx/desktop). */
export const JAVA_PROGRAMS: Readonly<Record<DesktopProgram, { readonly javaClass: string; readonly icon: string; readonly iconFile: string }>> = {
commandPrompt: { javaClass: 'filius.software.lokal.Terminal', icon: terminal, iconFile: 'icon_terminal.png' },
imageViewer: { javaClass: 'filius.software.lokal.ImageViewer', icon: imageViewer, iconFile: 'icon_imageviewer.png' },
fileExplorer: { javaClass: 'filius.software.lokal.FileExplorer', icon: fileExplorer, iconFile: 'icon_filebrowser.png' },
dnsServer: { javaClass: 'filius.software.dns.DNSServer', icon: dns, iconFile: 'icon_dns.png' },
emailClient: { javaClass: 'filius.software.email.EmailAnwendung', icon: emailClient, iconFile: 'icon_emailprogramm.png' },
emailServer: { javaClass: 'filius.software.email.EmailServer', icon: emailServer, iconFile: 'icon_emailserver.png' },
echoServer: { javaClass: 'filius.software.clientserver.ServerBaustein', icon: echoServer, iconFile: 'icon_serverbaustein.png' },
simpleClient: { javaClass: 'filius.software.clientserver.ClientBaustein', icon: simpleClient, iconFile: 'icon_clientbaustein.png' },
firewall: { javaClass: 'filius.software.firewall.Firewall', icon: firewall, iconFile: 'icon_firewall.png' },
gnutella: { javaClass: 'filius.software.dateiaustausch.PeerToPeerAnwendung', icon: gnutella, iconFile: 'icon_peertopeer.png' },
textEditor: { javaClass: 'filius.software.lokal.TextEditor', icon: textEditor, iconFile: 'icon_texteditor.png' },
webServer: { javaClass: 'filius.software.www.WebServer', icon: webServer, iconFile: 'icon_webserver.png' },
webBrowser: { javaClass: 'filius.software.www.WebBrowser', icon: browser, iconFile: 'icon_browser.png' },
};
/** The software installation's icon, always first on the desktop (GUIDesktopPanel.initInstaller). */
export const INSTALLATION_ICON = installation;
/** The order of Java's list in each language. */
export const DESKTOP_ORDER: Readonly<Record<Language, readonly DesktopProgram[]>> = {
de: ['commandPrompt', 'imageViewer', 'fileExplorer', 'dnsServer', 'emailClient', 'emailServer', 'echoServer', 'simpleClient', 'firewall', 'gnutella', 'textEditor', 'webServer', 'webBrowser'],
en: ['commandPrompt', 'dnsServer', 'echoServer', 'emailServer', 'emailClient', 'fileExplorer', 'firewall', 'simpleClient', 'gnutella', 'imageViewer', 'textEditor', 'webBrowser', 'webServer'],
};
/** A computer's programs as its desktop shows them, in the language's order. */
export function desktopPrograms(installed: readonly InstallableProgram[], language: Language): DesktopProgram[] {
const have = new Set(installed);
return DESKTOP_ORDER[language].filter((program) => have.has(program));
}
+27
View File
@@ -22,6 +22,9 @@
"design.palette": "Komponenten",
"design.redo": "Wiederholen",
"design.undo": "Rückgängig",
"desktop.home": "Startbildschirm",
"desktop.installation": "Software-Installation",
"desktop.network": "Netzwerk",
"dev.example": "Beispiel",
"dev.newProject": "Neues Projekt",
"dev.openFile": "Datei öffnen …",
@@ -79,6 +82,29 @@
"menu.delete": "Löschen",
"menu.removeAllCables": "Alle Verbindungen entfernen",
"menu.removeCable": "Verbindung entfernen",
"menu.showDesktop": "Desktop anzeigen",
"mode.currentSpeed": "Aktuelle Simulationsgeschwindigkeit",
"mode.design": "Wechselt in den Entwurfsmodus",
"mode.simulation": "Wechselt in den Aktionsmodus",
"mode.speed": "Simulationsgeschwindigkeit anpassen",
"network.dnsServer": "DNS-Server:",
"network.gateway": "Gateway:",
"network.ipAddress": "IP-Adresse:",
"network.macAddress": "MAC-Adresse:",
"network.subnetMask": "Netzmaske:",
"program.commandPrompt": "Befehlszeile",
"program.dnsServer": "DNS-Server",
"program.echoServer": "Echo-Server",
"program.emailClient": "E-Mail-Programm",
"program.emailServer": "E-Mail-Server",
"program.fileExplorer": "Datei-Explorer",
"program.firewall": "Firewall",
"program.gnutella": "Gnutella",
"program.imageViewer": "Bildbetrachter",
"program.simpleClient": "Einfacher Client",
"program.textEditor": "Text-Editor",
"program.webBrowser": "Webbrowser",
"program.webServer": "Webserver",
"route.destinationNetwork": "Ziel",
"route.gateway": "Nächstes Gateway",
"route.interfaceIPAddress": "Über Schnittstelle",
@@ -127,6 +153,7 @@
"sheet.switchSsid": "Name WLAN (SSID)",
"sheet.title": "Einstellungen",
"sheet.wireless": "Drahtlos (WLAN)",
"sim.failed": "Die Simulation ist auf einen Fehler gestoßen und wurde beendet.",
"zoom.fit": "Alles zeigen",
"zoom.in": "Vergrößern",
"zoom.level": "{percent} %",
+27
View File
@@ -22,6 +22,9 @@
"design.palette": "Components",
"design.redo": "Redo",
"design.undo": "Undo",
"desktop.home": "Home Screen",
"desktop.installation": "Software Installation",
"desktop.network": "Network",
"dev.example": "Example",
"dev.newProject": "New project",
"dev.openFile": "Open file …",
@@ -79,6 +82,29 @@
"menu.delete": "Delete",
"menu.removeAllCables": "Remove all cables",
"menu.removeCable": "Remove cable",
"menu.showDesktop": "Show desktop",
"mode.currentSpeed": "Current Simulation Velocity",
"mode.design": "Change to Design Mode",
"mode.simulation": "Change to Simulation Mode",
"mode.speed": "Adjust Simulation Velocity",
"network.dnsServer": "DNS server:",
"network.gateway": "Gateway:",
"network.ipAddress": "IP address:",
"network.macAddress": "MAC address:",
"network.subnetMask": "Netmask:",
"program.commandPrompt": "Command Line",
"program.dnsServer": "DNS server",
"program.echoServer": "Echo server",
"program.emailClient": "Email program",
"program.emailServer": "Email server",
"program.fileExplorer": "File explorer",
"program.firewall": "Firewall",
"program.gnutella": "Gnutella",
"program.imageViewer": "Image viewer",
"program.simpleClient": "Generic client",
"program.textEditor": "Text editor",
"program.webBrowser": "Webbrowser",
"program.webServer": "Webserver",
"route.destinationNetwork": "Destination",
"route.gateway": "Next gateway",
"route.interfaceIPAddress": "NIC",
@@ -127,6 +153,7 @@
"sheet.switchSsid": "Wi-Fi Name (SSID)",
"sheet.title": "Settings",
"sheet.wireless": "Wireless (Wi-Fi)",
"sim.failed": "The simulation ran into an error and was stopped.",
"zoom.fit": "Show all",
"zoom.in": "Zoom in",
"zoom.level": "{percent} %",
+4
View File
@@ -24,6 +24,10 @@ export function LanguageProvider({ language, children }: { readonly language: La
return <LanguageContext.Provider value={language}>{children}</LanguageContext.Provider>;
}
export function useLanguage(): Language {
return useContext(LanguageContext);
}
export function useTranslate(): Translate {
const language = useContext(LanguageContext);
return (key, values) => translate(language, key, values);
+2 -2
View File
@@ -7,7 +7,7 @@
import { StrictMode, useEffect, useState } from 'react';
import { createRoot } from 'react-dom/client';
import * as Y from 'yjs';
import { deviceSheet, emptyProject, topologyView, writeProject } from '@mdg-filius/doc';
import { desktopView, deviceSheet, emptyProject, topologyView, writeProject } from '@mdg-filius/doc';
import { openProjectArchive } from '@mdg-filius/fls';
import { startBridge } from './bridge/dashboardBridge.js';
import { Editor } from './Editor.js';
@@ -62,7 +62,7 @@ function App() {
// The development page lets its browser tests read the document's drawing (never the editor embedded in the dashboard).
useEffect(() => {
(window as unknown as { filiusDev?: unknown }).filiusDev = doc === null ? undefined : { view: () => topologyView(doc), sheet: (id: string) => deviceSheet(doc, id) };
(window as unknown as { filiusDev?: unknown }).filiusDev = doc === null ? undefined : { view: () => topologyView(doc), sheet: (id: string) => deviceSheet(doc, id), desktop: (id: string) => desktopView(doc, id) };
}, [doc]);
const t = (key: string) => translate(language, key);
@@ -0,0 +1,44 @@
// Java's mode buttons (GUIMainMenu: design and simulation mode, with Java's
// icons) and, while the simulation runs, its speed slider with the per cent
// beside it. Java's keys for the modes (Ctrl+D, Ctrl+R) are not offered: in
// a browser they bookmark and reload the page.
import type { CSSProperties } from 'react';
import type { SimMode } from '@mdg-filius/protocol';
import simulation from '../assets/modes/aktionsmodus.png';
import simulationOn from '../assets/modes/aktionsmodus_aktiv.png';
import design from '../assets/modes/entwurfsmodus.png';
import designOn from '../assets/modes/entwurfsmodus_aktiv.png';
import { useTranslate } from '../i18n/i18n.js';
const modeButton: CSSProperties = {
width: 52,
height: 52,
padding: 0,
border: '1px solid var(--border-default)',
borderRadius: 8,
background: 'var(--surface-raised)',
cursor: 'pointer',
};
export function ModeBar({ mode, speed, onMode, onSpeed }: { readonly mode: SimMode; readonly speed: number; readonly onMode: (mode: SimMode) => void; readonly onSpeed: (value: number) => void }) {
const t = useTranslate();
return (
<>
<button type="button" name="designMode" style={modeButton} aria-pressed={mode === 'design'} aria-label={t('mode.design')} title={t('mode.design')} onClick={() => onMode('design')}>
<img src={mode === 'design' ? designOn : design} alt="" width={50} height={50} />
</button>
<button type="button" name="simulationMode" style={modeButton} aria-pressed={mode === 'simulation'} aria-label={t('mode.simulation')} title={t('mode.simulation')} onClick={() => onMode('simulation')}>
<img src={mode === 'simulation' ? simulationOn : simulation} alt="" width={50} height={50} />
</button>
{mode === 'simulation' ? (
<label style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '0 8px', minHeight: 52, borderRadius: 8, background: 'var(--surface-raised)', border: '1px solid var(--border-default)' }}>
<input type="range" name="speed" min={1} max={100} value={speed} aria-label={t('mode.speed')} title={t('mode.speed')} onChange={(event) => onSpeed(Number(event.target.value))} style={{ width: 100 }} />
<span title={t('mode.currentSpeed')} style={{ minWidth: 40, fontVariantNumeric: 'tabular-nums' }}>
{speed}%
</span>
</label>
) : null}
</>
);
}
@@ -0,0 +1,105 @@
// 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 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.
import { useCallback, useEffect, useRef, useState } from 'react';
import type * as Y from 'yjs';
import type { NodeSettings, SimHost, SimMode, Topic } from '@mdg-filius/protocol';
import { LocalSimHost } from '@mdg-filius/sim';
import SimWorker from '@mdg-filius/sim/worker?worker';
/** Makes the room of a document. */
export type SimHostFactory = (doc: Y.Doc) => SimHost;
/** The room in a worker of this page. */
export const localSimHost: SimHostFactory = (doc) => {
const worker = new SimWorker();
return new LocalSimHost(doc, worker, () => worker.terminate());
};
export interface Simulation {
readonly mode: SimMode;
/** Java's slider, 1 to 100 per cent. */
readonly speed: number;
/** The cables that carried a frame at the last tick. */
readonly activeLinks: ReadonlySet<string>;
/** Each watched computer's network as the simulation runs it. */
readonly nodes: ReadonlyMap<string, NodeSettings>;
/** Whether the core failed and the room went back to design mode, since the last start. */
readonly failed: boolean;
start(): 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. */
watch(topics: readonly Topic[]): () => void;
}
const NO_LINKS: ReadonlySet<string> = new Set();
export function useSimulation(doc: Y.Doc, createHost: SimHostFactory = localSimHost): Simulation {
const host = useRef<SimHost | null>(null);
const watched = useRef(new Map<Topic, number>());
const [mode, setMode] = useState<SimMode>('design');
const [speed, setSpeedState] = useState(100);
const [activeLinks, setActiveLinks] = useState<ReadonlySet<string>>(NO_LINKS);
const [nodes, setNodes] = useState<ReadonlyMap<string, NodeSettings>>(new Map());
const [failed, setFailed] = useState(false);
useEffect(() => {
const made = createHost(doc);
host.current = made;
const stop = made.onMessage((message) => {
switch (message.kind) {
case 'state':
setMode(message.mode);
setSpeedState(message.speed);
if (message.mode === 'design') setActiveLinks(NO_LINKS);
return;
case 'activity':
setActiveLinks(new Set(message.links));
return;
case 'node':
setNodes((before) => new Map(before).set(message.node, message.settings));
return;
case 'error':
if (message.code === 'engineFailed') setFailed(true);
return;
default:
return;
}
});
made.send({ v: 1, kind: 'sub', topics: ['activity', ...watched.current.keys()] });
return () => {
stop();
made.close();
host.current = null;
};
}, [doc, createHost]);
const start = useCallback(() => {
setFailed(false);
host.current?.send({ v: 1, kind: 'start' });
}, []);
const stop = useCallback(() => host.current?.send({ v: 1, kind: 'stop' }), []);
const setSpeed = useCallback((value: number) => host.current?.send({ v: 1, kind: 'speed', value }), []);
const watch = useCallback((topics: readonly Topic[]) => {
const fresh = topics.filter((topic) => {
const count = watched.current.get(topic) ?? 0;
watched.current.set(topic, count + 1);
return count === 0;
});
if (fresh.length > 0) host.current?.send({ v: 1, kind: 'sub', topics: fresh });
return () => {
const gone = topics.filter((topic) => {
const count = (watched.current.get(topic) ?? 1) - 1;
if (count > 0) watched.current.set(topic, count);
else watched.current.delete(topic);
return count <= 0;
});
if (gone.length > 0) host.current?.send({ v: 1, kind: 'unsub', topics: gone });
};
}, []);
return { mode, speed, activeLinks, nodes, failed, start, stop, setSpeed, watch };
}
+13
View File
@@ -109,6 +109,16 @@ const FROM_JAVA: Readonly<Record<string, string>> = {
'connections.local': 'jvermittlungsrechnerkonfiguration_msg22',
'connections.add': 'jvermittlungsrechnerkonfiguration_msg24',
'connections.most': 'jvermittlungsrechnerkonfiguration_msg25',
'mode.speed': 'guimainmemu_msg16',
'mode.currentSpeed': 'guimainmemu_msg15',
'menu.showDesktop': 'guievents_msg3',
'desktop.home': 'desktoppanel_application',
'desktop.installation': 'desktoppanel_msg1',
'network.ipAddress': 'network_msg1',
'network.subnetMask': 'network_msg4',
'network.dnsServer': 'network_msg2',
'network.gateway': 'network_msg3',
'network.macAddress': 'network_msg9',
};
/** Java's words, mended: the prompt says "Vermittlungsrechnerns" (guicontainer_msg1). */
@@ -128,6 +138,9 @@ const FROM_JAVA_MENDED: Readonly<Record<string, readonly [string, (java: string)
// Java's "\n" as line breaks; the last sentence of the German starts with a small letter there.
'firewall.intro': ['jfirewalldialog_msg37', (java) => java.replaceAll('\\n', '\n').replace('\nüber', '\nÜber')],
'firewall.filterSynHint': ['jfirewalldialog_msg43', (java) => java.replaceAll('\\n', '\n')],
// Without Java's key (Strg+D, Strg+R): in a browser they bookmark and reload the page.
'mode.design': ['guimainmemu_msg3', (java) => java.replace(/ \((Strg|Ctrl)\+D\)$/, '')],
'mode.simulation': ['guimainmemu_msg4', (java) => java.replace(/ \((Strg|Ctrl)\+R\)$/, '')],
// Java's lines without their leading space and the empty one; a step starts with a capital letter.
'connections.hint': [
'jvermittlungsrechnerkonfiguration_msg19',
+34
View File
@@ -0,0 +1,34 @@
// The desktop's programs against Java's lists (config/Desktop_<language>.txt):
// the same programs, in each language's order, with Java's names and icons.
import { existsSync, readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import { desktopPrograms, DESKTOP_ORDER, JAVA_PROGRAMS } from '../src/desktop/programs.js';
import { CATALOGS, type Language } from '../src/i18n/i18n.js';
const config = join(dirname(fileURLToPath(import.meta.url)), '../../../tools/java-oracle/.upstream/src/main/resources/config');
const FILES: Readonly<Record<Language, string>> = { de: 'Desktop_de_DE.txt', en: 'Desktop_en_GB.txt' };
describe("the desktop's programs", () => {
it.skipIf(!existsSync(config))("are Java's, in the order of each language's list, with Java's names and icons", () => {
for (const language of ['de', 'en'] as const) {
const lines = readFileSync(join(config, FILES[language]), 'utf8')
.split(/\r?\n/)
.filter((line) => line.trim() !== '' && !line.trim().startsWith('#'))
.map((line) => line.split(';'));
expect(DESKTOP_ORDER[language].map((program) => JAVA_PROGRAMS[program].javaClass)).toEqual(lines.map((line) => line[1]));
expect(DESKTOP_ORDER[language].map((program) => CATALOGS[language][`program.${program}`])).toEqual(lines.map((line) => line[0]));
expect(DESKTOP_ORDER[language].map((program) => `gfx/desktop/${JAVA_PROGRAMS[program].iconFile}`)).toEqual(lines.map((line) => line[3]!.trim()));
}
});
it("shows a computer's programs only, in the language's order, without the DHCP server", () => {
expect(desktopPrograms(['webBrowser', 'commandPrompt', 'dhcpServer', 'imageViewer'], 'de')).toEqual(['commandPrompt', 'imageViewer', 'webBrowser']);
expect(desktopPrograms(['webBrowser', 'commandPrompt', 'dhcpServer', 'imageViewer'], 'en')).toEqual(['commandPrompt', 'imageViewer', 'webBrowser']);
expect(desktopPrograms(['webServer', 'webBrowser'], 'de')).toEqual(['webServer', 'webBrowser']);
expect(desktopPrograms(['webServer', 'webBrowser'], 'en')).toEqual(['webBrowser', 'webServer']);
expect(desktopPrograms([], 'de')).toEqual([]);
});
});
+19 -3
View File
@@ -15,9 +15,10 @@ export type AppKind = (typeof APP_KINDS)[number];
/**
* What a client subscribes to: which cables carry frames (`activity`), a
* card's capture (`trace:<portId>`), a program's window
* (`app:<nodeId>/<app>`).
* (`app:<nodeId>/<app>`), a computer's network settings as it runs with them
* (`node:<nodeId>`: its desktop's title and network info).
*/
export type Topic = 'activity' | `trace:${string}` | `app:${string}/${AppKind}`;
export type Topic = 'activity' | `trace:${string}` | `app:${string}/${AppKind}` | `node:${string}`;
export const CLIENT_MESSAGE_KINDS = ['sub', 'unsub', 'start', 'stop', 'speed', 'input'] as const;
export type ClientMessageKind = (typeof CLIENT_MESSAGE_KINDS)[number];
@@ -41,7 +42,7 @@ export type ClientMessage =
readonly reqId: string;
};
export const HOST_MESSAGE_KINDS = ['state', 'activity', 'trace', 'app', 'ack', 'error'] as const;
export const HOST_MESSAGE_KINDS = ['state', 'activity', 'trace', 'app', 'node', 'ack', 'error'] as const;
export type HostMessageKind = (typeof HOST_MESSAGE_KINDS)[number];
export type SimMode = 'design' | 'simulation';
@@ -66,9 +67,23 @@ 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[] }
/**
* 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.
*/
| { readonly v: 1; readonly kind: 'node'; readonly node: string; readonly settings: NodeSettings }
| { readonly v: 1; readonly kind: 'ack'; readonly reqId: string }
| { readonly v: 1; readonly kind: 'error'; readonly code: ErrorCode; readonly reqId?: string; readonly detail?: string };
/** What Java's desktop shows of a computer's network (NetworkInfoPanel, the window's title). */
export interface NodeSettings {
readonly ipAddress: string;
readonly subnetMask: string;
readonly defaultGateway: string;
readonly dnsServer: string;
readonly macAddress: string;
}
export type Parsed<T> = { readonly message: T } | { readonly error: { readonly code: ErrorCode; readonly detail: string } };
const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null && !Array.isArray(value);
@@ -79,6 +94,7 @@ export function isTopic(value: unknown): value is Topic {
if (value === 'activity') return true;
if (typeof value !== 'string') return false;
if (value.startsWith('trace:')) return value.length > 'trace:'.length;
if (value.startsWith('node:')) return /^node:[^/]+$/.test(value);
const app = /^app:([^/]+)\/(.+)$/.exec(value);
return app !== null && (APP_KINDS as readonly string[]).includes(app[2]!);
}
+3 -3
View File
@@ -35,15 +35,15 @@ describe("a client's messages", () => {
});
it('knows its topics', () => {
expect(['activity', 'trace:x', 'app:n/gnutella', 'app:n/webBrowser'].every(isTopic)).toBe(true);
expect(['', 'trace:', 'app:/terminal', 'app:n/', 'app:n/terminal/x', 'node:n', 7].some(isTopic)).toBe(false);
expect(['activity', 'trace:x', 'app:n/gnutella', 'app:n/webBrowser', 'node:n'].every(isTopic)).toBe(true);
expect(['', 'trace:', 'app:/terminal', 'app:n/', 'app:n/terminal/x', 'node:', 'node:n/x', 'nodes:n', 7].some(isTopic)).toBe(false);
});
});
describe("a host's messages", () => {
it('reads each kind, and names an unknown one', () => {
for (const kind of HOST_MESSAGE_KINDS) expect('message' in parseHostMessage({ v: 1, kind })).toBe(true);
expect(parseHostMessage({ v: 1, kind: 'node' })).toEqual({ error: { code: 'unknownKind', detail: 'kind node' } });
expect(parseHostMessage({ v: 1, kind: 'nodes' })).toEqual({ error: { code: 'unknownKind', detail: 'kind nodes' } });
expect(parseHostMessage({ v: 0, kind: 'state' })).toEqual({ error: { code: 'badVersion', detail: 'version 0' } });
});
});
+34 -2
View File
@@ -16,7 +16,7 @@
// keep rising (plan §6.2).
import * as Y from 'yjs';
import { DEFAULT_SPEED_PERCENT, NetworkRuntimeEngine, captureRows, type CapturedFrame, type CaptureRow, type Id, type LogLine } from '@mdg-filius/core';
import { DEFAULT_SPEED_PERCENT, NetworkRuntimeEngine, captureRows, interfaceKey, stableGeneratedMacAddress, type CapturedFrame, type CaptureRow, type Id, type LogLine } from '@mdg-filius/core';
import { docSnapshot } from '@mdg-filius/doc';
import {
parseClientMessage,
@@ -25,6 +25,7 @@ import {
type ClientMessage,
type ErrorCode,
type HostMessage,
type NodeSettings,
type SimMode,
type Topic,
type TraceFrame,
@@ -47,6 +48,8 @@ interface Client {
readonly traceSent: Map<Id, number>;
/** Per program window, how many of its program's lines it has; none while the program does not run (a stop empties it). */
readonly appSent: Map<string, number>;
/** Per computer, the settings last sent (as text). */
readonly nodeSent: Map<Id, string>;
}
/** A program with a window: its lines. */
@@ -79,7 +82,7 @@ export class SimulationRoom {
/** 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() });
this.clients.set(id, { send, topics: new Set(), traceSent: new Map(), appSent: new Map(), nodeSent: new Map() });
send(this.state());
return id;
}
@@ -117,6 +120,7 @@ export class SimulationRoom {
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('node:')) client.nodeSent.delete(topic.slice('node:'.length));
}
this.flush([client], true);
return;
@@ -260,6 +264,14 @@ export class SimulationRoom {
for (const topic of client.topics) {
if (topic === 'activity') {
if (subscribing) client.send(message({ kind: 'activity', links: this.lastActivity }));
} else if (topic.startsWith('node:')) {
const node = topic.slice('node:'.length);
const settings = this.nodeSettings(node);
if (settings === undefined) continue;
const text = JSON.stringify(settings);
if (client.nodeSent.get(node) === text) continue;
client.nodeSent.set(node, text);
client.send(message({ kind: 'node', node, settings }));
} else if (topic.startsWith('trace:')) {
const portId = topic.slice('trace:'.length);
const reset = !client.traceSent.has(portId);
@@ -288,6 +300,26 @@ export class SimulationRoom {
}
}
/**
* A device's first card as the engine runs it (a computer's settings, which
* DHCP may change); undefined for a device the simulation never had.
*/
private nodeSettings(id: Id): NodeSettings | undefined {
const snapshot = this.engine.state.topologySnapshot;
const node = snapshot.nodes.find((each) => each.id === id);
if (node === undefined) return undefined;
const port = node.ports[0];
const device = snapshot.deviceConfigurations.get(id);
const card = port === undefined ? undefined : snapshot.interfaceConfigurations.get(interfaceKey(id, port.id));
return {
ipAddress: device?.ipAddress ?? card?.ipAddress ?? '',
subnetMask: device?.subnetMask ?? card?.subnetMask ?? '',
defaultGateway: device?.defaultGateway ?? '',
dnsServer: device?.dnsServer ?? '',
macAddress: port === undefined ? '' : (port.macAddress ?? stableGeneratedMacAddress(port.id)),
};
}
/** The cables whose cards got frames since the last tick; sent when that changes. */
private updateActivity(): void {
const links = new Set<string>();
+2 -2
View File
@@ -117,8 +117,8 @@ describe('the simulation in a worker of the page', () => {
const host = new LocalSimHost(doc, channel.port1 as unknown as Port, () => channel.port1.close());
const got: HostMessage[] = [];
host.onMessage((message) => got.push(message));
channel.port2.postMessage({ type: 'host', message: { v: 1, kind: 'node', id: 'x' } });
expect(await until(() => got[0])).toEqual({ v: 1, kind: 'error', code: 'unknownKind', detail: 'kind node' });
channel.port2.postMessage({ type: 'host', message: { v: 1, kind: 'nodes', id: 'x' } });
expect(await until(() => got[0])).toEqual({ v: 1, kind: 'error', code: 'unknownKind', detail: 'kind nodes' });
host.close();
channel.port2.close();
});
+24
View File
@@ -306,4 +306,28 @@ describe('a simulation room', () => {
expect(ofKind(got, 'error')).toHaveLength(1);
expect(ofKind(got, 'state').at(-1)).toMatchObject({ mode: 'simulation' });
});
it("sends a computer's network as it runs, anew when DHCP changes it, and nothing before a start", () => {
const { got, send, run, node } = setUp(exampleDoc('java-examples/einfaches_rechnernetz_komplett.fls'));
const notebook = node('Notebook 1');
const server = node('Server');
send({ kind: 'sub', topics: [`node:${notebook.id}`, `node:${server.id}`] });
expect(ofKind(got, 'node')).toEqual([]);
send({ kind: 'start' });
run(20_000);
const sent = ofKind(got, 'node').filter((each) => each.node === notebook.id);
expect(sent.length).toBeGreaterThanOrEqual(2);
expect(sent.at(-1)!.settings).toMatchObject({ subnetMask: '255.255.255.0', dnsServer: '192.168.0.100', macAddress: expect.stringMatching(/^([0-9A-F]{2}:){5}[0-9A-F]{2}$/) });
expect(sent.at(-1)!.settings.ipAddress).toMatch(/^192\.168\.0\.\d+$/);
// Sent when it changes, not every tick.
expect(new Set(sent.map((each) => JSON.stringify(each.settings))).size).toBe(sent.length);
expect(ofKind(got, 'node').filter((each) => each.node === server.id).map((each) => each.settings)).toEqual([
{ ipAddress: '192.168.0.100', subnetMask: '255.255.255.0', defaultGateway: '', dnsServer: '192.168.0.100', macAddress: expect.any(String) },
]);
// A client that subscribes again gets it again.
const before = got.length;
send({ kind: 'sub', topics: [`node:${server.id}`] });
expect(ofKind(got.slice(before), 'node')).toHaveLength(1);
});
});