feat(editor): F4.6c the software installation on a computer's desktop

A new oracle, InstallRun, installs each of Java's programs on new
computers, all of them on one, each installed and removed again, and a
DNS and an e-mail server installed again after their removal; Java saves
the result as a project. A new program is switched off (a computer's
firewall is on), makes the folders and files it lacks (the web server's
start page in FILIUS' language and its picture, /peer2peer,
/mailserver/konten.txt, /dns/hosts), and removing it takes its settings
and leaves its files; a server installed again reads its records or
accounts from them. Java installs in the order of its list and starts
the program at once.

The document's setSoftware does the same, checked against Java's saved
installation. The core knows what a program makes in the files and
installs or removes a program while the simulation runs; the room
watches the document's programs and hands changes to the engine, so a
new command line works at once. The desktop offers Java's dialog with
two lists, arrows, double click and apply changes.

Also: Java's file may name a number it rolled by the result of any
getter (an idref, here a thread's priority); GetterDefaults now
measures the priority of the saved thread classes, which the import
read as 0 before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 15:03:35 +00:00
co-authored by Claude Opus 5.5
parent ac48e969f4
commit f0e7c73ec7
29 changed files with 1264 additions and 52 deletions
@@ -257,6 +257,25 @@ test.describe('on a phone', () => {
await window.locator('input[name="terminalInput"]').press('Enter');
await expect(window.locator('.terminal-text')).toContainText('IP Adresse . . . : 141.99.1.10');
});
test('offers the installation’s programs as targets for a finger', async ({ page }) => {
await openEditor(page, server.base, 'internetwork_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);
await desktop(page, id).locator('[data-program="installation"]').tap();
const panel = desktop(page, id).locator('[data-program-window="installation"]');
const first = (await panel.locator('[data-list="available"] [role="option"]').first().boundingBox())!;
expect(first.height).toBeGreaterThanOrEqual(44);
const arrow = (await panel.getByRole('button', { name: 'Installieren' }).boundingBox())!;
expect(arrow.height).toBeGreaterThanOrEqual(44);
await panel.locator('[data-list="available"] [role="option"]').first().tap();
await panel.getByRole('button', { name: 'Installieren' }).tap();
await expect(panel.locator('[data-list="installed"] [role="option"]')).toHaveText(['Befehlszeile', 'Bildbetrachter', 'Webbrowser']);
});
});
/** Opens a computer's command line on its desktop. */
@@ -343,6 +362,61 @@ test('keeps the command line over leaving it, over a return to the design, and e
await expect(window).toBeVisible();
});
test("installs and removes programs as Java's installation does, and a program installed runs at once", async ({ page }) => {
await openEditor(page, server.base, 'internetwork_komplett.fls');
const id = await deviceNamed(page, 'Rechner 3.10');
expect((await desktopOf(page, id))!.programs).toEqual([]);
await start(page);
await tap(page, id);
const window = desktop(page, id);
await window.locator('[data-program="installation"]').click();
const panel = window.locator('[data-program-window="installation"]');
await expect(panel.getByRole('heading')).toHaveText('Software-Installation');
const installed = panel.locator('[data-list="installed"] [role="option"]');
const available = panel.locator('[data-list="available"] [role="option"]');
await expect(installed).toHaveCount(0);
await expect(available).toHaveText(['Befehlszeile', 'Bildbetrachter', 'Datei-Explorer', 'DNS-Server', 'E-Mail-Programm', 'E-Mail-Server', 'Echo-Server', 'Einfacher Client', 'Firewall', 'Gnutella', 'Text-Editor', 'Webserver', 'Webbrowser']);
// Chosen with a tap each, moved over with the arrow; the list keeps Java's order.
await available.filter({ hasText: 'Webserver' }).click();
await available.filter({ hasText: 'Befehlszeile' }).click();
await expect(available.filter({ hasText: 'Webserver' })).toHaveAttribute('aria-selected', 'true');
// A second tap lets a program go again.
await available.filter({ hasText: 'Gnutella' }).click();
await available.filter({ hasText: 'Gnutella' }).click();
await expect(available.filter({ hasText: 'Gnutella' })).toHaveAttribute('aria-selected', 'false');
await panel.getByRole('button', { name: 'Installieren' }).click();
await expect(installed).toHaveText(['Befehlszeile', 'Webserver']);
// Nothing changes before "apply changes".
expect((await desktopOf(page, id))!.programs).toEqual([]);
await panel.getByRole('button', { name: 'Änderungen annehmen' }).click();
await expect(panel).toHaveCount(0);
await expect(window.locator('.desktop-icon span')).toHaveText(['Software-Installation', 'Befehlszeile', 'Webserver']);
expect([...(await desktopOf(page, id))!.programs].sort()).toEqual(['commandPrompt', 'webServer']);
// The command line runs at once, and sees the web server's files.
await window.locator('[data-program="commandPrompt"]').click();
const input = window.locator('input[name="terminalInput"]');
await input.fill('ls /webserver');
await input.press('Enter');
await expect(window.locator('.terminal-text')).toContainText('index.html');
await window.getByRole('button', { name: 'Startbildschirm' }).click();
// A double click moves a program back; applied, it is gone.
await window.locator('[data-program="installation"]').click();
await installed.filter({ hasText: 'Webserver' }).dblclick();
await expect(installed).toHaveText(['Befehlszeile']);
await panel.getByRole('button', { name: 'Änderungen annehmen' }).click();
await expect(window.locator('.desktop-icon span')).toHaveText(['Software-Installation', 'Befehlszeile']);
expect((await desktopOf(page, id))!.programs).toEqual(['commandPrompt']);
// Taken on and given back before applying: nothing happens.
await window.locator('[data-program="installation"]').click();
await available.filter({ hasText: 'Gnutella' }).dblclick();
await installed.filter({ hasText: 'Gnutella' }).click();
await panel.getByRole('button', { name: 'Entfernen' }).click();
await panel.getByRole('button', { name: 'Änderungen annehmen' }).click();
expect((await desktopOf(page, id))!.programs).toEqual(['commandPrompt']);
});
test('says so when the simulation fails, and goes back to the design', async ({ page }) => {
// FILIUS' modem example: the core has no modems yet (remote links); a ping to the other side fails it.
await openEditor(page, server.base, 'modem_beispiel.fls');
+3 -3
View File
@@ -19,8 +19,7 @@ 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 type { DesktopProgram } from './desktop/programs.js';
import { DesktopWindow, type DesktopWindowName } from './desktop/DesktopWindow.js';
import { useTranslate } from './i18n/i18n.js';
import { ModeBar } from './simulation/ModeBar.js';
import { useSimulation, type SimHostFactory } from './simulation/useSimulation.js';
@@ -80,7 +79,7 @@ interface OpenDesktop {
readonly x: number;
readonly y: number;
readonly z: number;
readonly program: DesktopProgram | null;
readonly program: DesktopWindowName | null;
}
/** 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. */
@@ -310,6 +309,7 @@ export function Editor({ doc, createSimHost }: { readonly doc: Y.Doc; readonly c
y={desktop.y}
z={desktop.z}
program={desktop.program}
origin={origin}
onProgram={(program) => setDesktops((open) => open.map((each) => (each.id === desktop.id ? { ...each, program } : each)))}
onMove={(x, y) => setDesktops((open) => open.map((each) => (each.id === desktop.id ? { ...each, x, y } : each)))}
onFront={() => setDesktops((open) => toFront(open, desktop.id))}
Binary file not shown.

After

Width:  |  Height:  |  Size: 616 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 572 B

+17 -8
View File
@@ -24,6 +24,7 @@ import { useLanguage, useTranslate } from '../i18n/i18n.js';
import type { Simulation } from '../simulation/useSimulation.js';
import { ProgramFrame } from './ProgramFrame.js';
import { desktopPrograms, INSTALLATION_ICON, JAVA_PROGRAMS, type DesktopProgram } from './programs.js';
import { InstallationPanel } from './InstallationPanel.js';
import { TerminalPanel } from './TerminalPanel.js';
import './desktop.css';
@@ -39,14 +40,19 @@ export interface DesktopWindowProps {
readonly y: number;
/** Its place in the stack of desktops. */
readonly z: number;
/** The program whose window is open on it, kept by the editor over a return to the design (Java's desktop keeps it). */
readonly program: DesktopProgram | null;
readonly onProgram: (program: DesktopProgram | null) => void;
/** The window open on it — a program's or the installation's —, kept by the editor over a return to the design (Java's desktop keeps it). */
readonly program: DesktopWindowName | null;
readonly onProgram: (program: DesktopWindowName | null) => void;
/** The person's origin, for what the installation changes in the document. */
readonly origin: object;
readonly onMove: (x: number, y: number) => void;
readonly onFront: () => void;
readonly onClose: () => void;
}
/** What can be open on a desktop: a program's window or the software installation. */
export type DesktopWindowName = DesktopProgram | 'installation';
/** The programs whose windows are built so far. */
const WITH_WINDOW: ReadonlySet<DesktopProgram> = new Set(['commandPrompt']);
@@ -62,7 +68,7 @@ function useDesktopView(doc: Y.Doc, id: string) {
return view;
}
export function DesktopWindow({ doc, id, simulation, active, x, y, z, program: open, onProgram: setOpen, onMove, onFront, onClose }: DesktopWindowProps) {
export function DesktopWindow({ doc, id, simulation, active, x, y, z, program: open, onProgram: setOpen, origin, onMove, onFront, onClose }: DesktopWindowProps) {
const t = useTranslate();
const language = useLanguage();
const view = useDesktopView(doc, id);
@@ -75,7 +81,7 @@ export function DesktopWindow({ doc, id, simulation, active, x, y, z, program: o
if (view === undefined) return null;
const programs = desktopPrograms(view.programs, language);
// A program removed while its window was open: the desktop shows.
const shown = open !== null && programs.includes(open) ? open : null;
const shown = open === 'installation' || (open !== null && programs.includes(open)) ? open : null;
const address = settings?.ipAddress ?? '';
const title = view.addressAsName ? view.label : `${view.label} - ${address}`;
@@ -114,8 +120,7 @@ export function DesktopWindow({ doc, id, simulation, active, x, y, z, program: o
<div className="desktop-screen" style={{ backgroundImage: `url(${background})` }}>
<ul className="desktop-icons">
<li>
{/* The installation's window comes with F4.6c. */}
<button type="button" className="desktop-icon" aria-disabled="true" title={t('desktop.installation')}>
<button type="button" className="desktop-icon" data-program="installation" title={t('desktop.installation')} onClick={() => setOpen('installation')}>
<img src={INSTALLATION_ICON} alt="" />
<span>{t('desktop.installation')}</span>
</button>
@@ -136,7 +141,11 @@ export function DesktopWindow({ doc, id, simulation, active, x, y, z, program: o
</li>
))}
</ul>
{shown === null ? null : (
{shown === 'installation' ? (
<ProgramFrame icon={INSTALLATION_ICON} title={t('desktop.installation')} program="installation">
<InstallationPanel doc={doc} node={id} installed={view.programs} origin={origin} onDone={home} />
</ProgramFrame>
) : shown === null ? null : (
<ProgramFrame icon={JAVA_PROGRAMS[shown].icon} title={t(`program.${shown}`)} program={shown}>
{shown === 'commandPrompt' ? <TerminalPanel node={id} simulation={simulation} onExit={home} /> : null}
</ProgramFrame>
@@ -0,0 +1,97 @@
// A computer's software installation, after Java's GUIInstallationsDialog:
// the programs installed on the left, those to be had on the right, each in
// the order of Java's list; the arrows between them (or a double click) move
// the chosen ones over, "apply changes" installs and removes them in one
// step (setSoftware) and goes back to the desktop. Java's list of installed
// programs comes in the order of a hash map; here in the order of its list.
// A tap chooses a program, another tap lets it go, as Java's lists choose
// several.
import { useState } from 'react';
import type * as Y from 'yjs';
import { setSoftware, SOFTWARE_ORDER, type Software } from '@mdg-filius/doc';
import type { InstallableProgram } from '@mdg-filius/core';
import arrowLeft from '../assets/desktop/pfeil_links.png';
import arrowRight from '../assets/desktop/pfeil_rechts.png';
import { useLanguage, useTranslate } from '../i18n/i18n.js';
function ProgramList({
name,
label,
programs,
chosen,
onChoose,
onMove,
}: {
readonly name: string;
readonly label: string;
readonly programs: readonly Software[];
readonly chosen: ReadonlySet<Software>;
readonly onChoose: (program: Software) => void;
readonly onMove: (program: Software) => void;
}) {
const t = useTranslate();
return (
<div className="installation-column">
<span id={`${name}-label`}>{label}</span>
<ul className="installation-list" role="listbox" aria-multiselectable="true" aria-labelledby={`${name}-label`} data-list={name}>
{programs.map((program) => (
<li key={program} role="option" aria-selected={chosen.has(program)} tabIndex={0} onClick={() => onChoose(program)} onDoubleClick={() => onMove(program)} onKeyDown={(event) => (event.key === ' ' || event.key === 'Enter') && (event.preventDefault(), onChoose(program))}>
{t(`program.${program}`)}
</li>
))}
</ul>
</div>
);
}
export function InstallationPanel({ doc, node, installed, origin, onDone }: { readonly doc: Y.Doc; readonly node: string; readonly installed: readonly InstallableProgram[]; readonly origin: object; readonly onDone: () => void }) {
const t = useTranslate();
const language = useLanguage();
const order = SOFTWARE_ORDER[language];
const [kept, setKept] = useState<ReadonlySet<Software>>(() => new Set(order.filter((program) => installed.includes(program))));
const [chosen, setChosen] = useState<ReadonlySet<Software>>(new Set());
const left = order.filter((program) => kept.has(program));
const right = order.filter((program) => !kept.has(program));
const choose = (program: Software) =>
setChosen((before) => {
const next = new Set(before);
if (!next.delete(program)) next.add(program);
return next;
});
/** Moves the chosen programs of one side to the other. */
const move = (toInstalled: boolean, programs: readonly Software[]) => {
setKept((before) => {
const next = new Set(before);
for (const program of programs) if (toInstalled) next.add(program);
else next.delete(program);
return next;
});
setChosen((before) => new Set([...before].filter((program) => !programs.includes(program))));
};
const apply = () => {
const install = order.filter((program) => kept.has(program) && !installed.includes(program));
const remove = order.filter((program) => !kept.has(program) && installed.includes(program));
setSoftware(doc, node, { install, remove }, language, origin);
onDone();
};
return (
<div className="installation">
<div className="installation-lists">
<ProgramList name="installed" label={t('installation.installed')} programs={left} chosen={chosen} onChoose={choose} onMove={(program) => move(false, [program])} />
<div className="installation-arrows">
<button type="button" aria-label={t('installation.add')} title={t('installation.add')} onClick={() => move(true, right.filter((program) => chosen.has(program)))}>
<img src={arrowLeft} alt="" />
</button>
<button type="button" aria-label={t('installation.remove')} title={t('installation.remove')} onClick={() => move(false, left.filter((program) => chosen.has(program)))}>
<img src={arrowRight} alt="" />
</button>
</div>
<ProgramList name="available" label={t('installation.available')} programs={right} chosen={chosen} onChoose={choose} onMove={(program) => move(true, [program])} />
</div>
<button type="button" className="installation-apply" onClick={apply}>
{t('installation.apply')}
</button>
</div>
);
}
+84
View File
@@ -295,3 +295,87 @@
font: 14px ui-monospace, Menlo, Consolas, monospace;
cursor: pointer;
}
/* The software installation (GUIInstallationsDialog): two lists of 170
* pixels in Java, the arrows between them, "apply changes" below. */
.installation {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 10px;
background: #eeeeee;
color: #000000;
font: 13px Dialog, sans-serif;
}
.installation-lists {
flex: 1;
min-height: 0;
width: 100%;
display: grid;
grid-template-columns: minmax(0, 1fr) 52px minmax(0, 1fr);
gap: 8px;
}
.installation-column {
min-height: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.installation-list {
flex: 1;
min-height: 0;
overflow-y: auto;
margin: 0;
padding: 0;
list-style: none;
background: #ffffff;
border: 1px solid #a0a0a0;
}
.installation-list li {
display: flex;
align-items: center;
min-height: 44px;
padding: 0 8px;
cursor: pointer;
user-select: none;
}
.installation-list li[aria-selected='true'] {
background: rgb(184 207 229);
}
.installation-list li:focus-visible {
outline: 2px solid rgb(99 130 191);
outline-offset: -2px;
}
.installation-arrows {
display: flex;
flex-direction: column;
justify-content: center;
gap: 10px;
}
.installation-arrows button,
.installation-apply {
min-width: 44px;
min-height: 44px;
border: 1px solid #a0a0a0;
border-radius: 6px;
background: #f8f8f8;
color: #000000;
font: 13px Dialog, sans-serif;
cursor: pointer;
}
.installation-apply {
padding: 0 16px;
flex: none;
}
+3 -5
View File
@@ -5,6 +5,7 @@
// set up in the device sheet, as in Java.
import type { InstallableProgram } from '@mdg-filius/core';
import { SOFTWARE_ORDER } from '@mdg-filius/doc';
import type { Language } from '../i18n/i18n.js';
import browser from '../assets/desktop/icon_browser.png';
import simpleClient from '../assets/desktop/icon_clientbaustein.png';
@@ -43,11 +44,8 @@ export const JAVA_PROGRAMS: Readonly<Record<DesktopProgram, { readonly javaClass
/** 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'],
};
/** The order of Java's list in each language (the installation's too). */
export const DESKTOP_ORDER: Readonly<Record<Language, readonly DesktopProgram[]>> = SOFTWARE_ORDER;
/** A computer's programs as its desktop shows them, in the language's order. */
export function desktopPrograms(installed: readonly InstallableProgram[], language: Language): DesktopProgram[] {
+5
View File
@@ -79,6 +79,11 @@
"forwarding.lanPortValue": "LAN-Port",
"forwarding.protocolValue": "Protokoll",
"forwarding.publicPortValue": "Portfreigabe",
"installation.add": "Installieren",
"installation.apply": "Änderungen annehmen",
"installation.available": "Verfügbar:",
"installation.installed": "Installiert:",
"installation.remove": "Entfernen",
"menu.delete": "Löschen",
"menu.removeAllCables": "Alle Verbindungen entfernen",
"menu.removeCable": "Verbindung entfernen",
+5
View File
@@ -79,6 +79,11 @@
"forwarding.lanPortValue": "LAN Port",
"forwarding.protocolValue": "Protocol",
"forwarding.publicPortValue": "Mapped Port",
"installation.add": "Install",
"installation.apply": "Apply changes",
"installation.available": "Available:",
"installation.installed": "Installed:",
"installation.remove": "Remove",
"menu.delete": "Delete",
"menu.removeAllCables": "Remove all cables",
"menu.removeCable": "Remove cable",
+3
View File
@@ -110,6 +110,9 @@ const FROM_JAVA: Readonly<Record<string, string>> = {
'connections.add': 'jvermittlungsrechnerkonfiguration_msg24',
'connections.most': 'jvermittlungsrechnerkonfiguration_msg25',
'mode.speed': 'guimainmemu_msg16',
'installation.installed': 'installationsdialog_msg3',
'installation.available': 'installationsdialog_msg4',
'installation.apply': 'installationsdialog_msg2',
'mode.currentSpeed': 'guimainmemu_msg15',
'menu.showDesktop': 'guievents_msg3',
'desktop.home': 'desktoppanel_application',