// The network drawn in a .fls file — devices, cables, documentation items — // read from the decoded JavaBeans graph (javabeans.ts) the way Java FILIUS // loads it (SzenarioVerwaltung.netzwerkLaden), with the system software // settings the topology needs (systemSoftware.ts). File systems and // applications are later layers, which start from `FlsNode.source`. // // The result is Java's view, indexed like Java's lists, and is checked // against Java itself (corpus.java.json). The editor model (node kinds, UUIDs, // ports as Swift's TopologyPortMetadata, runtime configurations) is built // from it in a separate step. // // Where this departs from filius-ipad's reader, Java is right (plan §13, M2): // - the device kind comes from the Java class; the palette label ("Heimrouter") // is not file data — Java overwrites it with the class's type name; // - a device without coordinates stands at 0,0, as in Java, instead of // being dropped; // - interface values go through Java's setters (IP.ipCheck for address and // mask, upper-cased MAC). import { isJavaObject, isUnresolved, listItems, simpleClassName, type JavaObject, type JavaValue } from './javabeans.js'; import { javaIpCheck } from '@mdg-filius/core'; import { readSystemSoftware, type FlsSystemSoftware } from './systemSoftware.js'; export type FlsNodeClass = 'Rechner' | 'Notebook' | 'Switch' | 'Vermittlungsrechner' | 'Gateway' | 'Modem'; export interface FlsInterface { /** After IP.ipCheck: a canonical dotted quad, or null for text that is not one. */ ip: string | null; mask: string | null; /** Upper-cased, as getMac() returns it; undefined when the file has none (Java rolls a random one). */ mac: string | undefined; gateway: string | null; dns: string | null; /** A notebook's or PC's card joining a switch's wireless network (Betriebssystem.wireless). */ wireless: boolean; } export interface FlsNode { javaClass: FlsNodeClass; name: string | null; x: number; y: number; /** Hosts only (Rechner, Notebook); false for the others. */ useIpAsName: boolean; useMacAsName: boolean; /** A Switch drawn as a cloud (Switch.cloud, the Internet in the examples); false for the others. */ cloud: boolean; /** The interfaces of a Rechner, Notebook, Vermittlungsrechner or Gateway; empty for the others. */ interfaces: FlsInterface[]; /** Interfaces, or the ports of a Switch or Modem. */ portCount: number; /** SSIDs, routes, RIP, firewall (systemSoftware.ts). */ system: FlsSystemSoftware; /** The node's JavaBean (`filius.hardware.knoten.*`), for the software layers. */ source: JavaObject; } /** A cable end: the node's index and the port's index on it, as in Java's lists. */ export interface FlsPortReference { node: number; port: number; } export interface FlsLink { ends: [FlsPortReference, FlsPortReference]; wireless: boolean; /** * The nodes the editor draws the cable between (JCablePanel ziel1, ziel2), * by index; null where the file names none. */ panel: [number | null, number | null]; } export type FlsColor = [red: number, green: number, blue: number, alpha: number]; export interface FlsFont { name: string; size: number; style: number; } export interface FlsDocumentationItem { /** GUIDocuItem.RECT = 1, TEXT = 2; 0 when the file sets none. */ type: number; text: string | null; x: number; y: number; width: number; height: number; color: FlsColor | null; font: FlsFont | null; } export interface FlsTopology { /** The version line newer files start with ("Filius version: 1.7.4 (…)"), else null. */ versionLine: string | null; nodes: FlsNode[]; links: FlsLink[]; documentation: FlsDocumentationItem[]; /** What Java would read and this reader could not, and what it left out. */ warnings: string[]; } interface NodeClassInfo { /** Elements the constructor creates: interfaces (InternetKnoten) or ports (LokalerKnoten). */ constructed: number; local: boolean; host: boolean; /** * The name the constructor gives, in German — the locale Java FILIUS starts * in (and the one corpus.java.json was measured with). An English FILIUS * would say "Computer" or "Router" here. */ defaultName: string; } const NODE_CLASSES: Record = { Rechner: { constructed: 1, local: false, host: true, defaultName: 'Rechner' }, Notebook: { constructed: 1, local: false, host: true, defaultName: 'Notebook' }, Vermittlungsrechner: { constructed: 1, local: false, host: false, defaultName: 'Vermittlungsrechner', }, Gateway: { constructed: 2, local: false, host: false, defaultName: 'Heimrouter' }, Switch: { constructed: 24, local: true, host: false, defaultName: 'Switch / WLAN' }, Modem: { constructed: 1, local: true, host: false, defaultName: 'Modem' }, }; const DEVICE_PACKAGE = 'filius.hardware.knoten.'; function isFlsNodeClass(name: string): name is FlsNodeClass { return Object.hasOwn(NODE_CLASSES, name); } /** Maps the top-level objects of `konfiguration.xml` to the topology. */ export function readTopology(values: readonly JavaValue[]): FlsTopology { const warnings: string[] = []; const context = new Reader(warnings); // netzwerkLaden: an optional version string, then the node list, the // cable list and the documentation list, each by position. A list counts // only if its first element has the expected class. let position = 0; let versionLine: string | null = null; if (typeof values[0] === 'string') { versionLine = values[0]; position = 1; } const nodeItems = listOf(values[position], 'filius.gui.netzwerksicht.GUIKnotenItem'); const cableItems = listOf(values[position + 1], 'filius.gui.netzwerksicht.GUIKabelItem'); const documentationItems = listOf(values[position + 2], 'filius.gui.netzwerksicht.GUIDocuItem'); const nodes: FlsNode[] = []; for (const [index, item] of nodeItems.entries()) { const node = context.node(item, index, nodes.length); if (node !== undefined) { if (isJavaObject(item)) context.nodeItems.set(item, nodes.length); nodes.push(node); } } const links: FlsLink[] = []; for (const [index, item] of cableItems.entries()) { const link = context.link(item, index); if (link !== undefined) links.push(link); } const documentation = documentationItems.flatMap((item) => (isJavaObject(item) ? [context.documentation(item)] : [])); return { versionLine, nodes, links, documentation, warnings }; } /** The elements of `value` if it is a list whose first element is an object of `className`. */ function listOf(value: JavaValue | undefined, className: string): JavaValue[] { const items = listItems(value); const first = items[0]; return isJavaObject(first) && first.className === className ? items : []; } class Reader { /** Every port object a node owns, to resolve cable ends by identity as Java does. */ private readonly portOwners = new Map(); /** Every node item read, to resolve a cable panel's ends. */ readonly nodeItems = new Map(); constructor(private readonly warnings: string[]) {} node(item: JavaValue, itemIndex: number, nodeIndex: number): FlsNode | undefined { const device = isJavaObject(item) ? item.properties.get('knoten') : undefined; if (!isJavaObject(device)) { this.warnings.push(`node ${itemIndex + 1}: no device (knoten), left out`); return undefined; } const simpleName = simpleClassName(device.className); if (!device.className.startsWith(DEVICE_PACKAGE) || !isFlsNodeClass(simpleName)) { this.warnings.push(`node ${itemIndex + 1}: unknown device class ${device.className}, left out`); return undefined; } const info = NODE_CLASSES[simpleName]; const [x, y] = this.position(item as JavaObject, itemIndex); const node: FlsNode = { javaClass: simpleName, name: this.stringProperty(device, 'name', info.defaultName, `node ${itemIndex + 1}`), x, y, useIpAsName: info.host && this.booleanProperty(device, 'useIPAsName', `node ${itemIndex + 1}`), useMacAsName: info.host && this.booleanProperty(device, 'useMACAsName', `node ${itemIndex + 1}`), cloud: simpleName === 'Switch' && this.booleanProperty(device, 'cloud', `node ${itemIndex + 1}`), interfaces: [], portCount: 0, system: readSystemSoftware(device, simpleName, (message) => this.warnings.push(`node ${itemIndex + 1}: ${message}`), ), source: device, }; if (info.local) { const ports = this.constructedList(device, 'anschluesse', info.constructed); node.portCount = ports.length; ports.forEach((port, portIndex) => this.ownPort(port, nodeIndex, portIndex)); } else { const networkInterfaces = this.constructedList(device, 'netzwerkInterfaces', info.constructed); node.interfaces = networkInterfaces.map((nic, nicIndex) => this.networkInterface(nic, simpleName === 'Gateway' && nicIndex === 0, `node ${itemIndex + 1}`), ); node.portCount = networkInterfaces.length; networkInterfaces.forEach((nic, nicIndex) => { if (isJavaObject(nic)) this.ownPort(nic.properties.get('port'), nodeIndex, nicIndex); }); } return node; } /** * A list property the constructor filled: a getter's result keeps the * constructed elements, a list the file sets replaces them. */ private constructedList(device: JavaObject, property: string, constructed: number): JavaValue[] { const list = device.properties.get(property); // Untouched: the constructed elements, which carry nothing but defaults. if (list === undefined) return Array.from({ length: constructed }, () => null); return listItems(list, isJavaObject(list) && list.fromGetter ? constructed : 0); } private ownPort(port: JavaValue | undefined, node: number, index: number): void { if (isJavaObject(port) && !this.portOwners.has(port)) this.portOwners.set(port, { node, port: index }); } /** * The imageLabel's bounds: fields set one by one in newer files, * `new Rectangle(x, y, width, height)` in older ones. Without either, Java's * label sits at 0,0. */ private position(item: JavaObject, itemIndex: number): [number, number] { const label = item.properties.get('imageLabel'); const bounds = isJavaObject(label) ? label.properties.get('bounds') : undefined; if (!isJavaObject(bounds)) return [0, 0]; return (['x', 'y'] as const).map((field, index) => { const value = bounds.properties.get(field) ?? bounds.args[index] ?? 0; if (typeof value === 'number') return value; this.warnings.push(`node ${itemIndex + 1}: position ${field} is ${describe(value)}, used 0`); return 0; }) as [number, number]; } /** * NetzwerkInterface as its constructor leaves it, then the file's setters. * A Gateway's constructor gives its first interface (WAN) its own address. */ private networkInterface(nic: JavaValue, gatewayWan: boolean, where: string): FlsInterface { const result: FlsInterface = { ip: gatewayWan ? '42.0.0.10' : '192.168.0.10', mask: gatewayWan ? '255.0.0.0' : '255.255.255.0', mac: undefined, gateway: '', dns: '', wireless: false, }; if (!isJavaObject(nic)) return result; // Only the constructor's own interface gets the WAN address; one the file // creates with `new NetzwerkInterface()` starts like any other. if (gatewayWan && !nic.fromGetter) { result.ip = '192.168.0.10'; result.mask = '255.255.255.0'; } const at = `${where}, interface`; for (const [property, field] of [ ['ip', 'ip'], ['subnetzMaske', 'mask'], ] as const) { const value = nic.properties.get(property); if (value === undefined) continue; // setIp(null) throws inside Java's setter; XMLDecoder swallows that and // the constructor's value stays. if (typeof value === 'string') result[field] = javaIpCheck(value); else this.unexpected(at, property, value); } const mac = nic.properties.get('mac'); if (typeof mac === 'string') result.mac = mac.toUpperCase(); else if (mac !== undefined && mac !== null) this.unexpected(at, 'mac', mac); for (const [property, field] of [ ['gateway', 'gateway'], ['dns', 'dns'], ] as const) { const value = nic.properties.get(property); if (value === undefined) continue; if (typeof value === 'string' || value === null) result[field] = value; else this.unexpected(at, property, value); } const wireless = nic.properties.get('wireless'); if (typeof wireless === 'boolean') result.wireless = wireless; else if (wireless !== undefined) this.unexpected(at, 'wireless', wireless); return result; } link(item: JavaValue, itemIndex: number): FlsLink | undefined { const cable = isJavaObject(item) ? item.properties.get('dasKabel') : undefined; if (!isJavaObject(cable)) { this.warnings.push(`cable ${itemIndex + 1}: no cable (dasKabel), left out`); return undefined; } // Kabel.setAnschluesse(Port[]) in newer files, new Kabel(port, port) in older ones. const declared = cable.properties.get('anschluesse'); const ports = declared === undefined ? cable.args : listItems(declared); const ends = ports.map((port) => (isJavaObject(port) ? this.portOwners.get(port) : undefined)); const [first, second] = ends; if (ends.length !== 2 || first === undefined || second === undefined) { this.warnings.push(`cable ${itemIndex + 1}: an end is not a port of a device, left out`); return undefined; } const panel = (item as JavaObject).properties.get('kabelpanel'); const panelEnd = (property: string) => { const target = isJavaObject(panel) ? panel.properties.get(property) : undefined; return isJavaObject(target) ? (this.nodeItems.get(target) ?? null) : null; }; return { ends: [first, second], wireless: this.booleanProperty(cable, 'wireless', `cable ${itemIndex + 1}`), panel: [panelEnd('ziel1'), panelEnd('ziel2')], }; } documentation(item: JavaObject): FlsDocumentationItem { const where = 'documentation item'; const integer = (property: string): number => { const value = item.properties.get(property); if (value === undefined) return 0; if (typeof value === 'number' && Number.isInteger(value)) return value; this.unexpected(where, property, value); return 0; }; const text = item.properties.get('text'); return { type: integer('type'), text: typeof text === 'string' ? text : null, x: integer('x'), y: integer('y'), width: integer('width'), height: integer('height'), color: this.color(item.properties.get('color'), where), font: this.font(item.properties.get('font'), where), }; } /** * `java.awt.Color` as XMLEncoder writes it: `new Color(r, g, b, a)`. Also * `new Color(rgb)` and `new Color(r, g, b)` (ColorUIResource). A component * outside 0…255 makes Java's constructor throw — the color stays unset. */ private color(value: JavaValue | undefined, where: string): FlsColor | null { if (value === undefined || value === null) return null; if (!isJavaObject(value)) { this.unexpected(where, 'color', value); return null; } const args = value.args; if (!args.every((arg) => typeof arg === 'number' && Number.isInteger(arg))) { this.warnings.push(`${where}: color ${value.className} with arguments this reader does not know, left unset`); return null; } const numbers = args as number[]; if (numbers.length === 1) { const rgb = numbers[0]!; return [(rgb >> 16) & 0xff, (rgb >> 8) & 0xff, rgb & 0xff, 255]; } if (numbers.length === 3 || numbers.length === 4) { const rgba = numbers.length === 3 ? [...numbers, 255] : numbers; if (rgba.every((component) => component >= 0 && component <= 255)) return rgba as FlsColor; this.warnings.push(`${where}: color component outside 0…255, left unset (as in Java)`); return null; } this.warnings.push(`${where}: color with ${numbers.length} arguments, left unset`); return null; } /** `new Font(name, style, size)`, also as FontUIResource. Java keeps style 0…3 and makes others 0. */ private font(value: JavaValue | undefined, where: string): FlsFont | null { if (value === undefined || value === null) return null; const [name, style, size] = isJavaObject(value) ? value.args : []; if ( !isJavaObject(value) || value.args.length !== 3 || (typeof name !== 'string' && name !== null) || typeof style !== 'number' || typeof size !== 'number' ) { this.unexpected(where, 'font', value); return null; } return { name: name ?? 'Default', size, style: (style & ~0x03) === 0 ? style : 0 }; } private stringProperty(target: JavaObject, property: string, fallback: string, where: string): string | null { const value = target.properties.get(property); if (value === undefined) return fallback; if (typeof value === 'string' || value === null) return value; this.unexpected(where, property, value); return fallback; } private booleanProperty(target: JavaObject, property: string, where: string): boolean { const value = target.properties.get(property); if (value === undefined) return false; if (typeof value === 'boolean') return value; this.unexpected(where, property, value); return false; } private unexpected(where: string, property: string, value: JavaValue): void { this.warnings.push(`${where}: ${property} is ${describe(value)}, ignored`); } } function describe(value: JavaValue): string { if (isUnresolved(value)) return `unresolved (${value.reason})`; if (isJavaObject(value)) return `an object of ${value.className}`; return JSON.stringify(typeof value === 'bigint' ? String(value) : value) ?? String(value); }