feat(fls): installed applications as Java loads them

readSystemSoftware lists a PC's or notebook's installed applications with
the settings the model needs: server applications switched on and their
port, the generic client's target, DNS recursion, web server virtual
hosts, Gnutella's peer limit and the personal firewall (on by default,
unlike a router's). Defaults the file leaves out are measured in Java.
All 23 corpus files match Java.

Two examples install an application of their own (Accumulator,
Broadcast), compiled into projekt/anwendungen; it is left out with a
warning, as this reader cannot run Java classes. It must survive a save.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-30 06:05:20 +00:00
co-authored by Claude Opus 5.5
parent be3b332655
commit c48d15def4
12 changed files with 1438 additions and 9 deletions
+8 -1
View File
@@ -1,5 +1,8 @@
{
"ClientBaustein#zielIPAdresse": null,
"ClientBaustein#zielPort": 55555,
"DHCPServer#port": 67,
"DNSServer#aktiv": false,
"DNSServer#port": 53,
"Gateway#maxLayerOfOperation": 2,
"JSidebarButton#debugGraphicsOptions": 0,
@@ -8,11 +11,15 @@
"JSidebarButton#iconTextGap": 4,
"JSidebarButton#verticalTextPosition": 3,
"Notebook#maxLayerOfOperation": 4,
"PeerToPeerAnwendung#maxTeilnehmerZahl": 5,
"RIPServer#port": 520,
"Rechner#maxLayerOfOperation": 4,
"ServerBaustein#aktiv": false,
"ServerBaustein#port": 55555,
"SwingConstants.CENTER": 0,
"ThreadGroup#maxPriority": 10,
"Vermittlungsrechner#maxLayerOfOperation": 2,
"WebServer#port": 80
"WebServer#aktiv": false,
"WebServer#port": 80,
"WebServer#useVHost": false
}
+9
View File
@@ -219,6 +219,15 @@ export function decodeXmlBytes(bytes: Uint8Array): string {
const PRIMITIVE_ID_PREFIXES = new Set(['Integer', 'Boolean', 'Long', 'Short', 'Byte', 'Float', 'Double', 'Character']);
const DEFAULTS = javaDefaults as Readonly<Record<string, string | number | boolean | null>>;
/**
* What a getter returns on a freshly constructed object of `simpleClass`, as
* measured (javaDefaults.json) — undefined where nothing was measured.
*/
export function javaDefault(simpleClass: string, property: string): string | number | boolean | null | undefined {
const key = `${simpleClass}#${property}`;
return Object.hasOwn(DEFAULTS, key) ? DEFAULTS[key] : undefined;
}
/** `getFoo`/`isFoo` → `foo`, as java.beans.Introspector derives it. */
function propertyOfGetter(method: string): string | undefined {
const match = /^(?:get|is)([A-Z].*)$/.exec(method);
+127 -3
View File
@@ -27,7 +27,16 @@
// not know Java's constructors and sees a placeholder per path; they are
// merged here.
import { isJavaObject, isUnresolved, listItems, mapEntries, type JavaObject, type JavaValue } from './javabeans.js';
import {
isJavaObject,
isUnresolved,
javaDefault,
listItems,
mapEntries,
simpleClassName,
type JavaObject,
type JavaValue,
} from './javabeans.js';
import type { FlsNodeClass } from './topology.js';
export interface FlsFirewallRule {
@@ -79,6 +88,55 @@ export type FlsFileTree =
/** A node whose user object is neither a name nor a file. */
| { kind: 'other'; className: string; children: FlsFileTree[] };
/** The applications FILIUS 2.14 can install (config/Desktop_*.txt). */
export const FILIUS_APPLICATION_CLASSES = [
'filius.software.clientserver.ClientBaustein',
'filius.software.clientserver.ServerBaustein',
'filius.software.dateiaustausch.PeerToPeerAnwendung',
'filius.software.dns.DNSServer',
'filius.software.email.EmailAnwendung',
'filius.software.email.EmailServer',
'filius.software.firewall.Firewall',
'filius.software.lokal.FileExplorer',
'filius.software.lokal.ImageViewer',
'filius.software.lokal.Terminal',
'filius.software.lokal.TextEditor',
'filius.software.www.WebBrowser',
'filius.software.www.WebServer',
] as const;
export type FlsApplicationClass = (typeof FILIUS_APPLICATION_CLASSES)[number];
/** Server applications (ServerAnwendung): they listen on a port and can be switched on. */
const SERVER_APPLICATIONS = new Set<string>([
'filius.software.clientserver.ServerBaustein',
'filius.software.dns.DNSServer',
'filius.software.www.WebServer',
]);
/** An installed application with the settings the model reads; e-mail contents are a later layer. */
export interface FlsApplication {
/** The key in installierteAnwendungen — the class name in every corpus file. */
key: string | null;
javaClass: FlsApplicationClass;
/** Server applications: switched on (Java's aktiv) and the port. */
isActive?: boolean;
port?: number;
/** ClientBaustein: the server it talks to (Java's zielIPAdresse, zielPort). */
targetAddress?: string | null;
targetPort?: number;
/** DNSServer: whether it asks other servers for names it does not know. */
recursiveResolutionEnabled?: boolean;
/** WebServer: serve by host name (Java's useVHost). */
useVirtualHosts?: boolean;
/** PeerToPeerAnwendung: how many peers it keeps (Java's maxTeilnehmerZahl). */
maxPeers?: number;
/** Firewall: the personal firewall. */
firewall?: FlsFirewall;
/** The application's JavaBean, for the e-mail layer. */
source: JavaObject;
}
export interface FlsSystemSoftware {
/**
* Switch: its SSID, undefined when the file has none (Java rolls one).
@@ -101,6 +159,8 @@ export interface FlsSystemSoftware {
staticNAT?: (string | null)[][];
/** Modem. */
modem?: FlsModem;
/** Rechner, Notebook: the installed applications, in the file's order. */
applications?: FlsApplication[];
/** Rechner, Notebook: the root directory (named "root" by Java's constructor). */
fileSystem?: FlsFileTree;
}
@@ -125,6 +185,7 @@ export function readSystemSoftware(device: JavaObject, javaClass: FlsNodeClass,
case 'Notebook':
result.ssid = optionalString(value('ssid'), 'ssid', warn) ?? null;
result.fileSystem = fileSystem(value('dateisystem'), warn);
result.applications = applications(value('installierteAnwendungen'), warn);
break;
case 'Vermittlungsrechner':
result.ripEnabled = boolean(value('ripEnabled'), false, 'ripEnabled', warn);
@@ -165,6 +226,60 @@ function modemAddress(value: string | null | undefined): string | null {
return value === undefined ? 'localhost' : value;
}
/**
* installierteAnwendungen: key → application. A class FILIUS does not ship —
* a project's own application, compiled into projekt/anwendungen — is left
* out: this reader cannot run Java classes.
*/
function applications(map: JavaValue | undefined, warn: Warn): FlsApplication[] {
const result: FlsApplication[] = [];
for (const [key, application] of mapEntries(map)) {
if (!isJavaObject(application)) {
warn(`application ${describe(key)} is ${describe(application)}, left out`);
continue;
}
const javaClass = application.className;
if (!(FILIUS_APPLICATION_CLASSES as readonly string[]).includes(javaClass)) {
warn(`application ${javaClass} is not part of FILIUS (a project's own application), left out`);
continue;
}
const simple = simpleClassName(javaClass);
const at = (property: string) => application.properties.get(property);
const measured = (property: string) => javaDefault(simple, property);
const entry: FlsApplication = {
key: typeof key === 'string' ? key : null,
javaClass: javaClass as FlsApplicationClass,
source: application,
};
if (SERVER_APPLICATIONS.has(javaClass)) {
entry.isActive = boolean(at('aktiv'), measured('aktiv') === true, `${simple} aktiv`, warn);
entry.port = integer(at('port'), Number(measured('port')), `${simple} port`, warn);
}
switch (javaClass) {
case 'filius.software.clientserver.ClientBaustein': {
const target = optionalString(at('zielIPAdresse'), 'ClientBaustein zielIPAdresse', warn);
entry.targetAddress = target === undefined ? null : target;
entry.targetPort = integer(at('zielPort'), Number(measured('zielPort')), 'ClientBaustein zielPort', warn);
break;
}
case 'filius.software.dns.DNSServer':
entry.recursiveResolutionEnabled = boolean(at('recursiveResolutionEnabled'), false, 'DNSServer recursion', warn);
break;
case 'filius.software.www.WebServer':
entry.useVirtualHosts = boolean(at('useVHost'), measured('useVHost') === true, 'WebServer useVHost', warn);
break;
case 'filius.software.dateiaustausch.PeerToPeerAnwendung':
entry.maxPeers = integer(at('maxTeilnehmerZahl'), Number(measured('maxTeilnehmerZahl')), 'Gnutella peers', warn);
break;
case 'filius.software.firewall.Firewall':
entry.firewall = firewallOf([application], true, warn);
break;
}
result.push(entry);
}
return result;
}
/**
* Dateisystem: its root is `arbeitsVerzeichnis` in files up to 1.10 and
* `root` after. The constructor's root is a node named "root"; a node the
@@ -282,8 +397,17 @@ function firewallObjects(software: JavaObject | undefined): JavaObject[] {
/** The firewall as the router's constructor leaves it (off, drop), then the file's settings. */
function firewall(software: JavaObject | undefined, warn: Warn): FlsFirewall {
return firewallOf(firewallObjects(software), false, warn);
}
/**
* A firewall's settings from every object the file reaches it by. `activated`
* starts as the constructor leaves it: a router's is switched off, a PC's
* personal firewall (new Firewall()) is on.
*/
function firewallOf(objects: JavaObject[], activatedByDefault: boolean, warn: Warn): FlsFirewall {
const properties = new Map<string, JavaValue>();
for (const object of firewallObjects(software)) {
for (const object of objects) {
for (const [property, value] of object.properties) {
if (properties.has(property) && properties.get(property) !== value) {
warn(`firewall: ${property} is set in two places, the later one is used`);
@@ -295,7 +419,7 @@ function firewall(software: JavaObject | undefined, warn: Warn): FlsFirewall {
// setAllowRelatedPackets is the old name of setFilterSYNSegmentsOnly.
const filterSyn = at('filterSYNSegmentsOnly') ?? at('allowRelatedPackets');
return {
activated: boolean(at('activated'), false, 'firewall activated', warn),
activated: boolean(at('activated'), activatedByDefault, 'firewall activated', warn),
defaultPolicy: integer(at('defaultPolicy'), 0, 'firewall defaultPolicy', warn),
dropICMP: boolean(at('dropICMP'), false, 'firewall dropICMP', warn),
filterSYNSegmentsOnly: boolean(filterSyn, true, 'firewall filterSYNSegmentsOnly', warn),
+16
View File
@@ -0,0 +1,16 @@
// Warnings the corpus is expected to produce, shared by the corpus tests.
/**
* Two examples install an application of their own, compiled into the
* archive (projekt/anwendungen). Java loads it from there; this reader
* cannot run it — and the Java oracle, reading only konfiguration.xml, does
* not see it either.
*/
export const EXPECTED_WARNINGS: Record<string, string[]> = {
'java-examples/anwendung_komplett.fls': [
"node 1: application filius.software.clientserver.Accumulator is not part of FILIUS (a project's own application), left out",
],
'java-examples/multicast_server.fls': [
"node 1: application filius.software.clientserver.Broadcast is not part of FILIUS (a project's own application), left out",
],
};
+3 -2
View File
@@ -33,6 +33,7 @@ import {
type FlsTopology,
type ImportedProject,
} from '../src/index.js';
import { EXPECTED_WARNINGS } from './expectedWarnings.js';
const corpusRoot = join(dirname(fileURLToPath(import.meta.url)), '../../../corpus');
interface SwiftSummary {
@@ -289,7 +290,7 @@ describe('corpus: the imported model as Swift builds it', () => {
expect(withoutApplicationDirectories(asSwiftState(project), swiftWebRoots(expected))).toEqual(
withoutApplicationDirectories(expected),
);
expect(project.warnings).toEqual([]);
expect(project.warnings).toEqual(EXPECTED_WARNINGS[name] ?? []);
});
}
});
@@ -309,7 +310,7 @@ describe('corpus: every file imports completely', () => {
expect(project.graph.nodes).toHaveLength(javaView[name]!.nodes.length);
expect(project.graph.links).toHaveLength(javaView[name]!.cables.length);
expect(project.warnings).toEqual([]);
expect(project.warnings).toEqual(EXPECTED_WARNINGS[name] ?? []);
});
}
});
+55 -1
View File
@@ -12,12 +12,14 @@ import {
decodeJavaBeans,
readFlsArchive,
readTopology,
type FlsApplication,
type FlsDhcpServer,
type FlsFileTree,
type FlsModem,
type FlsNodeClass,
type FlsTopology,
} from '../src/index.js';
import { EXPECTED_WARNINGS } from './expectedWarnings.js';
const corpusRoot = join(dirname(fileURLToPath(import.meta.url)), '../../../corpus');
const javaView = JSON.parse(readFileSync(join(corpusRoot, 'corpus.java.json'), 'utf8')) as Record<string, unknown>;
@@ -63,6 +65,35 @@ function javaFileTree(tree: FlsFileTree): unknown {
}
}
/** FlsOracle.applications' shape: Java's property names, sorted by key. */
function javaApplications(applications: FlsApplication[]): unknown[] {
return [...applications]
.sort((lhs, rhs) => ((lhs.key ?? '') < (rhs.key ?? '') ? -1 : 1))
.map((application) => ({
key: application.key,
class: application.javaClass,
...(application.isActive === undefined ? {} : { aktiv: application.isActive, port: application.port }),
...(application.targetPort === undefined
? {}
: { zielIPAdresse: application.targetAddress, zielPort: application.targetPort }),
...(application.recursiveResolutionEnabled === undefined
? {}
: { recursiveResolutionEnabled: application.recursiveResolutionEnabled }),
...(application.useVirtualHosts === undefined ? {} : { useVHost: application.useVirtualHosts }),
...(application.maxPeers === undefined ? {} : { maxTeilnehmerZahl: application.maxPeers }),
...(application.firewall === undefined
? {}
: {
activated: application.firewall.activated,
defaultPolicy: application.firewall.defaultPolicy,
dropICMP: application.firewall.dropICMP,
filterSYNSegmentsOnly: application.firewall.filterSYNSegmentsOnly,
filterUdp: application.firewall.filterUdp,
rules: application.firewall.rules.length,
}),
}));
}
/** Java's property names, as FlsOracle.java writes them. */
function javaDhcpServer(server: FlsDhcpServer) {
return {
@@ -106,6 +137,7 @@ function asJavaView(topology: FlsTopology, javaBounds: (number[] | null)[]) {
}),
...(node.system.modem === undefined ? {} : { modem: javaModem(node.system.modem) }),
...(node.system.fileSystem === undefined ? {} : { fileSystem: javaFileTree(node.system.fileSystem) }),
...(node.system.applications === undefined ? {} : { applications: javaApplications(node.system.applications) }),
...(node.system.firewall === undefined
? {}
: {
@@ -164,7 +196,7 @@ describe('corpus: the topology as Java loads it', () => {
const expected = javaView[name] as { nodes: { bounds: number[] | null }[] };
expect(asJavaView(topology, expected.nodes.map((node) => node.bounds))).toEqual(withoutTemplateFiles(expected));
expect(topology.warnings).toEqual([]);
expect(topology.warnings).toEqual(EXPECTED_WARNINGS[name] ?? []);
});
}
});
@@ -368,6 +400,28 @@ describe('readTopology rules', () => {
expect(warnings).toEqual(['node 2: file system: insert on a node is not read']);
});
it("gives installed applications Java's measured defaults for what the file leaves out", () => {
const put = (className: string, body = '') =>
`<void method="put"><string>${className}</string><object class="${className}">${body}</object></void>`;
const { nodes } = topologyOf(`<object class="java.util.LinkedList">${nodeItem(`<object class="filius.hardware.knoten.Rechner">
<void property="systemSoftware"><void property="installierteAnwendungen">
${put('filius.software.www.WebServer')}
${put('filius.software.clientserver.ClientBaustein')}
${put('filius.software.dateiaustausch.PeerToPeerAnwendung')}
${put('filius.software.firewall.Firewall')}
${put('filius.software.dns.DNSServer', '<void property="aktiv"><boolean>true</boolean></void><void property="port"><int>5353</int></void>')}
${put('filius.software.lokal.Terminal')}
</void></void></object>`)}</object>`);
const byClass = new Map(nodes[0]!.system.applications!.map((application) => [application.javaClass, application]));
expect(byClass.get('filius.software.www.WebServer')).toMatchObject({ isActive: false, port: 80, useVirtualHosts: false });
expect(byClass.get('filius.software.clientserver.ClientBaustein')).toMatchObject({ targetAddress: null, targetPort: 55_555 });
expect(byClass.get('filius.software.dateiaustausch.PeerToPeerAnwendung')).toMatchObject({ maxPeers: 4 });
expect(byClass.get('filius.software.firewall.Firewall')?.firewall).toMatchObject({ activated: true, defaultPolicy: 0 });
expect(byClass.get('filius.software.dns.DNSServer')).toMatchObject({ isActive: true, port: 5353, recursiveResolutionEnabled: false });
expect(Object.keys(byClass.get('filius.software.lokal.Terminal')!).sort()).toEqual(['javaClass', 'key', 'source']);
});
it("gives a modem Java's defaults and keeps an address the file sets to null", () => {
const { nodes } = topologyOf(`<object class="java.util.LinkedList">
${nodeItem('<object class="filius.hardware.knoten.Modem"/>')}