Files
mdg-filius/packages/fls/src/importProject.ts
T
julian.kiedaischandClaude Opus 5.5 63273916b5 feat(editor): F4.3 the editor's frame and surface; a switch's cloud read and written
packages/editor (React 18, Vite) draws a document as Java FILIUS does: Java's
default hardware icons, the device button of JSidebarButton, cables as
JCablePanel's quadratic curve between the buttons' middles, wireless cables
dashed, documentation items below. The surface moves with mouse and finger
(the pointer captured only once it moved) and zooms with Ctrl and the
wheel, a trackpad pinch, two fingers and the zoom bar; the first view shows
all. German and English catalogs with Java's device names, the dashboard's
colour tokens, the bridge with its origin check. packages/doc gets the
drawing's light view of a document.

Java's Switch.cloud (the Internet in the examples) was lost on reading; it
is now read, kept in the model and written, with FlsOracle and the corpus,
written and exported checks renewed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 09:05:25 +00:00

776 lines
32 KiB
TypeScript

// From Java's view of a .fls file (topology.ts) to the editor model the
// simulation core runs: nodes with kinds and ports, links between port ids,
// the IP configurations, documentation items.
//
// Ported from filius-ipad (GPLv2/GPLv3):
// Persistence/TopologyProjectStore.swift — importFiliusConfigurationXML,
// finalizeCurrentNodeCandidate, importedPorts, registerRuntimeConfigurations,
// resolveCableCandidates, finalizeCurrentDocumentationCandidate.
// Checked against the Swift import (corpus.swift.json, `state`) on every
// corpus file the Swift reader reads completely.
//
// Departures from Swift, where the input is Java's rather than Swift's own:
// - port counts of switches and routers are Java's (see importedPorts);
// - a node's name is Java's, kept as it is — also empty (Swift would trim it
// and put a default in place of an empty one);
// - an address Java's setter rejected (null) becomes Swift's default, as
// Swift does for an empty one; Swift would keep invalid text verbatim.
// - a router's default gateway (its first interface's, as in Java) — Swift
// drops it, and routed examples lose their way back;
// - every router and home router has a firewall configuration, as every one
// has a firewall in Java (Swift keeps one only when the file sets a field;
// without one the core behaves as with an inactive one).
// - configuration text is trimmed, as Swift trims every value it reads;
// names stay as Java has them;
// - a host's wireless: enabled when its card is wireless, as in Java, which
// joins a network only then — also without an SSID; Swift enables it for
// every SSID;
// - a Java modem becomes an in-project remote link pair keyed by its port,
// as in Swift — two FILIUS instances on different hosts are not joined.
// - a PC or notebook whose file has no file system gets an empty one, as in
// Java — Swift gives it the demo files of a new device;
// - a file system's root is Java's root, whatever its user object says.
// - every installed application of FILIUS is kept: Swift drops the command
// prompt, text editor, file explorer, image viewer, echo server and
// generic client, and their settings (port, target) have a place here;
// - a web server's files stay in /webserver (its document root), where Swift
// moves them to /www, and it keeps Java's virtual hosts switch (Swift sets
// it off); and no program state is written into a host's files
// (Swift keeps Gnutella and e-mail state there as hidden JSON files).
// - e-mail: see importEmail.ts;
// - a DNS server's records are what Java's server answers from /dns/hosts
// (see dnsHosts.ts); Swift reads the file its own way — tabs, comments,
// "IN", names with a leading digit — and drops Java's duplicates.
import {
fileContentOfJavaType,
canonicalMacAddress,
forwardsIPv4ByDefault,
deepEqual,
defaultSwitchConfiguration,
dhcpServerConfiguration,
DEFAULT_REMOTE_LINK_LATENCY_MILLISECONDS,
DEFAULT_WEB_SERVER_PORT,
deterministicDocumentationUuid,
documentationColor,
documentationItem,
inDeterministicRenderOrder,
interfaceKey,
isSafeFrame,
swiftBase64Decode,
swiftLowercased,
swiftPathExtension,
swiftTrim,
normalizedAbsolutePath,
VirtualFileSystem,
VirtualFileSystemError,
topologyNode,
DOCUMENTATION_BLACK,
DOCUMENTATION_PALE_YELLOW,
type DeviceConfiguration,
type DhcpClientConfiguration,
type DhcpServerConfiguration,
type DnsServerConfiguration,
type EmailClientConfiguration,
type EmailServerConfiguration,
type EchoServerConfiguration,
type GnutellaConfiguration,
type InstallableProgram,
type SimpleClientConfiguration,
type WebServerConfiguration,
type GatewayPortForwardingRow,
type RemoteLinkConfiguration,
type FirewallAction,
type FirewallConfiguration,
type FirewallProtocol,
type FirewallRule,
type HostWirelessConfiguration,
type ManualRoute,
type SwitchConfiguration,
type DocumentationItem,
type HostLabelPolicy,
type Id,
type InterfaceConfiguration,
type InterfaceKey,
type NodeKind,
type PortMetadata,
type TopologyGraph,
type TopologyLink,
type TopologyNode,
type VirtualFileContent,
} from '@mdg-filius/core';
import { readFlsArchive } from './archive.js';
import { dnsRecordsOf, javaHostsFileContent, readDnsHosts } from './dnsHosts.js';
import { decodeJavaBeans } from './javabeans.js';
import { emailClientConfiguration, emailServerConfiguration } from './importEmail.js';
import type { FlsApplicationClass, FlsFileTree, FlsFirewall, FlsOwnApplication } from './systemSoftware.js';
import { readTopology, type FlsDocumentationItem, type FlsInterface, type FlsNode, type FlsNodeClass, type FlsTopology } from './topology.js';
export interface ImportedProject {
versionLine: string | null;
graph: TopologyGraph;
deviceConfigurations: Map<Id, DeviceConfiguration>;
interfaceConfigurations: Map<InterfaceKey, InterfaceConfiguration>;
switchConfigurations: Map<Id, SwitchConfiguration>;
hostWirelessConfigurations: Map<Id, HostWirelessConfiguration>;
manualRoutesByNodeId: Map<Id, ManualRoute[]>;
/** Only routers with RIP switched on. */
ripEnabledByNodeId: Map<Id, boolean>;
/** Nodes whose IP forwarding differs from their kind's (forwardsIPv4ByDefault). */
ipForwardingByNodeId: Map<Id, boolean>;
firewallConfigurationsByNodeId: Map<Id, FirewallConfiguration>;
dhcpClientConfigurations: Map<Id, DhcpClientConfiguration>;
/** Only servers whose settings differ from Java's constructor. */
dhcpServerConfigurations: Map<Id, DhcpServerConfiguration>;
portForwardingRowsByNodeId: Map<Id, GatewayPortForwardingRow[]>;
remoteLinkConfigurations: Map<Id, RemoteLinkConfiguration>;
/** Every PC and notebook. */
virtualFileSystems: Map<Id, VirtualFileSystem>;
installedPrograms: Map<Id, Set<InstallableProgram>>;
/** Server programs Java had switched on (aktiv). */
activePrograms: Map<Id, Set<InstallableProgram>>;
webServerConfigurations: Map<Id, WebServerConfiguration>;
gnutellaConfigurations: Map<Id, GnutellaConfiguration>;
dnsServerConfigurations: Map<Id, DnsServerConfiguration>;
emailClientConfigurations: Map<Id, EmailClientConfiguration>;
emailServerConfigurations: Map<Id, EmailServerConfiguration>;
echoServerConfigurations: Map<Id, EchoServerConfiguration>;
simpleClientConfigurations: Map<Id, SimpleClientConfiguration>;
/** In render order: rectangles below text, then by order. */
documentationItems: DocumentationItem[];
/**
* The archive's entries besides the configuration — the project's own Java
* applications (projekt/anwendungen) — carried unchanged to the next save:
* they cannot run here, but must not be lost.
*/
archiveEntries: ReadonlyMap<string, Uint8Array>;
/**
* A project's own applications on a PC or notebook, as their beans
* (beanCapture.ts): they cannot run here, but are written again as the
* file had them. Their classes are among the archive's entries.
*/
ownApplicationsByNodeId: Map<Id, FlsOwnApplication[]>;
/** The topology's warnings, then this step's. */
warnings: string[];
}
export const NODE_KIND_OF_CLASS: Readonly<Record<FlsNodeClass, NodeKind>> = {
Rechner: 'pc',
Notebook: 'notebook',
Switch: 'networkSwitch',
Vermittlungsrechner: 'router',
Gateway: 'gateway',
Modem: 'remoteLink',
};
/**
* The editor model of a topology. `newId` makes node, port and link ids
* (Swift uses random UUIDs; tests pass a counter). Documentation ids are
* derived from the item, as in Swift.
*/
export function importProject(
topology: FlsTopology,
newId: () => Id,
archiveEntries: ReadonlyMap<string, Uint8Array> = new Map(),
): ImportedProject {
const warnings = [...topology.warnings];
const deviceConfigurations = new Map<Id, DeviceConfiguration>();
const interfaceConfigurations = new Map<InterfaceKey, InterfaceConfiguration>();
const system: SystemConfigurations = {
switchConfigurations: new Map(),
hostWirelessConfigurations: new Map(),
manualRoutesByNodeId: new Map(),
ripEnabledByNodeId: new Map(),
ipForwardingByNodeId: new Map(),
firewallConfigurationsByNodeId: new Map(),
dhcpClientConfigurations: new Map(),
dhcpServerConfigurations: new Map(),
portForwardingRowsByNodeId: new Map(),
remoteLinkConfigurations: new Map(),
virtualFileSystems: new Map(),
installedPrograms: new Map(),
activePrograms: new Map(),
webServerConfigurations: new Map(),
gnutellaConfigurations: new Map(),
dnsServerConfigurations: new Map(),
emailClientConfigurations: new Map(),
emailServerConfigurations: new Map(),
echoServerConfigurations: new Map(),
simpleClientConfigurations: new Map(),
};
const ownApplicationsByNodeId = new Map<Id, FlsOwnApplication[]>();
const nodes = topology.nodes.map((source) => {
const node = importNode(source, newId, warnings);
registerConfigurations(source, node, deviceConfigurations, interfaceConfigurations);
registerSystemConfigurations(source, node, system, warnings);
registerServiceConfigurations(source, node, system, newId, warnings);
if (node.kind === 'pc' || node.kind === 'notebook') {
system.virtualFileSystems.set(node.id, importFileSystem(source.system.fileSystem, node.displayName, warnings));
registerPrograms(source, node, system, warnings);
const own = source.system.ownApplications ?? [];
if (own.length > 0) ownApplicationsByNodeId.set(node.id, own);
}
return node;
});
const links: TopologyLink[] = [];
const occupied = new Set<Id>();
topology.links.forEach((link, index) => {
const [source, target] = link.ends.map((end) => {
const node = nodes[end.node];
const port = node?.ports[end.port];
return node === undefined || port === undefined ? undefined : { node, port };
});
const where = `cable ${index + 1}`;
if (source === undefined || target === undefined) {
warnings.push(`${where}: an end is not a port of the imported node, left out`);
return;
}
if (source.node.id === target.node.id) {
warnings.push(`${where}: connects a node to itself, left out`);
return;
}
if (occupied.has(source.port.id) || occupied.has(target.port.id)) {
warnings.push(`${where}: a port already has a cable, left out`);
return;
}
occupied.add(source.port.id);
occupied.add(target.port.id);
links.push({
id: newId(),
sourceNodeId: source.node.id,
sourcePortId: source.port.id,
targetNodeId: target.node.id,
targetPortId: target.port.id,
});
});
// Swift rejects the whole import when the files exceed the quotas.
VirtualFileSystem.validateProjectQuotas(system.virtualFileSystems.values());
const documentationItems: DocumentationItem[] = [];
for (const item of topology.documentation) {
const imported = importDocumentationItem(item, documentationItems.length, warnings);
if (imported !== undefined) documentationItems.push(imported);
}
return {
versionLine: topology.versionLine,
graph: { nodes, links },
deviceConfigurations,
interfaceConfigurations,
...system,
documentationItems: inDeterministicRenderOrder(documentationItems),
archiveEntries,
ownApplicationsByNodeId,
warnings,
};
}
/** A whole .fls: its archive, Java's view of its configuration, the editor model with the archive's other entries. */
export function openProjectArchive(archive: Uint8Array, newId: () => Id): ImportedProject {
const contents = readFlsArchive(archive);
return importProject(readTopology(decodeJavaBeans(contents.configurationXml)), newId, contents.supplementalEntries);
}
function hostLabelPolicy(node: FlsNode): HostLabelPolicy {
if (node.useIpAsName && node.useMacAsName) return 'ipAndMAC';
if (node.useIpAsName) return 'ipAddress';
if (node.useMacAsName) return 'macAddress';
return 'manual';
}
/**
* Swift's importedPorts: the labels of a kind, the MAC addresses of the
* interfaces. Swift guesses a switch's and a router's port count from the
* references it saw (at least 24, at least one); Java's list is known here
* and counts — a file that sets a shorter list has fewer ports in Java too.
* A Gateway keeps two, since Java's own code needs WAN and LAN; a host and a
* modem have exactly one, as in Swift.
*/
function importedPorts(node: FlsNode, kind: NodeKind, newId: () => Id, warnings: string[]): PortMetadata[] {
const port = (label: string, nic?: FlsInterface): PortMetadata => {
const mac = canonicalMacAddress(nic?.mac);
const id = newId();
return mac === undefined ? { id, label, isOccupied: false } : { id, label, isOccupied: false, importedMacAddress: mac };
};
switch (kind) {
case 'pc':
case 'notebook':
if (node.interfaces.length > 1) {
warnings.push(`node '${node.name ?? ''}': only the first of ${node.interfaces.length} interfaces is kept`);
}
return [port('eth0', node.interfaces[0])];
case 'networkSwitch':
return Array.from({ length: node.portCount }, (_, index) => port(`sw${index + 1}`));
case 'router':
return Array.from({ length: node.interfaces.length }, (_, index) =>
port(`rt${index + 1}`, node.interfaces[index]),
);
case 'gateway':
return Array.from({ length: Math.max(2, node.interfaces.length) }, (_, index) =>
port(index === 0 ? 'wan0' : index === 1 ? 'lan0' : `gw${index}`, node.interfaces[index]),
);
case 'remoteLink':
return [port('remote0')];
case 'unsupported':
return [];
}
}
function importNode(source: FlsNode, newId: () => Id, warnings: string[]): TopologyNode {
const kind = NODE_KIND_OF_CLASS[source.javaClass];
const ports = importedPorts(source, kind, newId, warnings);
return topologyNode({
id: newId(),
kind,
...(source.name === null ? {} : { displayName: source.name }),
hostLabelPolicy: hostLabelPolicy(source),
position: { x: source.x, y: source.y },
ports,
newId,
});
}
/** Swift's `value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""`. */
function trimmedOrEmpty(value: string | null | undefined): string {
return value == null ? '' : swiftTrim(value);
}
/** Swift's normalizedOrDefault; Java's null (a rejected address) counts as empty. */
function orDefault(value: string | null, fallback: string): string {
const trimmed = trimmedOrEmpty(value);
return trimmed === '' ? fallback : trimmed;
}
/** Swift's registerRuntimeConfigurations. */
function registerConfigurations(
source: FlsNode,
node: TopologyNode,
deviceConfigurations: Map<Id, DeviceConfiguration>,
interfaceConfigurations: Map<InterfaceKey, InterfaceConfiguration>,
): void {
if (node.kind === 'pc' || node.kind === 'notebook') {
const nic = source.interfaces[0];
deviceConfigurations.set(node.id, {
ipAddress: orDefault(nic?.ip ?? null, '192.168.0.10'),
subnetMask: orDefault(nic?.mask ?? null, '255.255.255.0'),
defaultGateway: trimmedOrEmpty(nic?.gateway),
dnsServer: trimmedOrEmpty(nic?.dns),
});
return;
}
if (node.kind !== 'router' && node.kind !== 'gateway') return;
node.ports.forEach((port, index) => {
const nic = source.interfaces[index];
const wan = node.kind === 'gateway' && index === 0;
interfaceConfigurations.set(interfaceKey(node.id, port.id), {
ipAddress: orDefault(nic?.ip ?? null, wan ? '42.0.0.10' : '192.168.0.10'),
subnetMask: orDefault(nic?.mask ?? null, wan ? '255.0.0.0' : '255.255.255.0'),
});
});
// Java routes a router's traffic without a better route to the gateway of
// its first interface (Weiterleitungstabelle.holeTabelle, getStandardGateway).
// Swift's model has no router gateway at all; the core reads it from the
// router's device configuration, which is set only when there is one.
const first = source.interfaces[0];
const routerGateway = trimmedOrEmpty(first?.gateway);
if (node.kind === 'router' && first !== undefined && routerGateway !== '') {
const firstConfiguration = interfaceConfigurations.get(interfaceKey(node.id, node.ports[0]!.id));
deviceConfigurations.set(node.id, {
ipAddress: firstConfiguration?.ipAddress ?? '',
subnetMask: firstConfiguration?.subnetMask ?? '',
defaultGateway: routerGateway,
dnsServer: trimmedOrEmpty(first.dns),
});
}
const wan = source.interfaces[0];
if (node.kind === 'gateway' && wan !== undefined) {
const wanConfiguration = interfaceConfigurations.get(interfaceKey(node.id, node.ports[0]!.id));
deviceConfigurations.set(node.id, {
ipAddress: wanConfiguration?.ipAddress ?? '',
subnetMask: wanConfiguration?.subnetMask ?? '',
defaultGateway: trimmedOrEmpty(wan.gateway),
dnsServer: trimmedOrEmpty(wan.dns),
});
}
}
type SystemConfigurations = Pick<
ImportedProject,
| 'switchConfigurations'
| 'hostWirelessConfigurations'
| 'manualRoutesByNodeId'
| 'ripEnabledByNodeId'
| 'ipForwardingByNodeId'
| 'firewallConfigurationsByNodeId'
| 'dhcpClientConfigurations'
| 'dhcpServerConfigurations'
| 'portForwardingRowsByNodeId'
| 'remoteLinkConfigurations'
| 'virtualFileSystems'
| 'installedPrograms'
| 'activePrograms'
| 'webServerConfigurations'
| 'gnutellaConfigurations'
| 'dnsServerConfigurations'
| 'emailClientConfigurations'
| 'emailServerConfigurations'
| 'echoServerConfigurations'
| 'simpleClientConfigurations'
>;
/** Java's application classes as Swift's programs. */
export const PROGRAM_OF_CLASS: Readonly<Record<FlsApplicationClass, InstallableProgram>> = {
'filius.software.clientserver.ClientBaustein': 'simpleClient',
'filius.software.clientserver.ServerBaustein': 'echoServer',
'filius.software.dateiaustausch.PeerToPeerAnwendung': 'gnutella',
'filius.software.dns.DNSServer': 'dnsServer',
'filius.software.email.EmailAnwendung': 'emailClient',
'filius.software.email.EmailServer': 'emailServer',
'filius.software.firewall.Firewall': 'firewall',
'filius.software.lokal.FileExplorer': 'fileExplorer',
'filius.software.lokal.ImageViewer': 'imageViewer',
'filius.software.lokal.Terminal': 'commandPrompt',
'filius.software.lokal.TextEditor': 'textEditor',
'filius.software.www.WebBrowser': 'webBrowser',
'filius.software.www.WebServer': 'webServer',
};
/** The web server's document root: where Java keeps its files. */
export const JAVA_WEB_ROOT = '/webserver';
function warnAt(node: TopologyNode, warnings: string[]): (message: string) => void {
return (message) => warnings.push(`node '${node.displayName}': ${message}`);
}
/** The program parts of Swift's finalizeCurrentNodeCandidate, for a PC or notebook. */
function registerPrograms(source: FlsNode, node: TopologyNode, system: SystemConfigurations, warnings: string[]): void {
const installed = new Set<InstallableProgram>();
const active = new Set<InstallableProgram>();
for (const application of source.system.applications ?? []) {
const program = PROGRAM_OF_CLASS[application.javaClass];
installed.add(program);
if (application.isActive === true) active.add(program);
switch (program) {
case 'webServer':
system.webServerConfigurations.set(node.id, {
port: application.port ?? DEFAULT_WEB_SERVER_PORT,
documentRoot: JAVA_WEB_ROOT,
virtualHosts: application.useVirtualHosts === true,
});
break;
case 'gnutella':
// Without one in the file Java rolls 3, 4 or 5; Swift's default is 3.
system.gnutellaConfigurations.set(node.id, { maximumKnownPeers: application.maxPeers ?? 3 });
break;
case 'dnsServer': {
// Java's DNSServer forwards to the host's own DNS server setting;
// only one that differs from the host's own address is a forwarder.
const recursive = application.recursiveResolutionEnabled === true;
const forwarder = trimmedOrEmpty(source.interfaces[0]?.dns);
const primary = trimmedOrEmpty(source.interfaces[0]?.ip);
const forwarding = recursive && forwarder !== '' && forwarder !== primary;
const hosts = javaHostsFileContent(source.system.fileSystem);
const records = dnsRecordsOf(readDnsHosts(hosts ?? ''), warnAt(node, warnings));
system.dnsServerConfigurations.set(
node.id,
forwarding
? { recursiveResolutionEnabled: recursive, forwardingServerIPAddress: forwarder, records }
: { recursiveResolutionEnabled: recursive, records },
);
break;
}
case 'emailClient':
system.emailClientConfigurations.set(node.id, emailClientConfiguration(application, warnAt(node, warnings)));
break;
case 'emailServer':
system.emailServerConfigurations.set(node.id, emailServerConfiguration(application, warnAt(node, warnings)));
break;
case 'firewall':
if (application.firewall !== undefined) {
system.firewallConfigurationsByNodeId.set(node.id, firewallConfigurationOf(application.firewall, `node '${node.displayName}'`, warnings));
}
break;
case 'echoServer':
system.echoServerConfigurations.set(node.id, { port: application.port ?? 55_555 });
break;
case 'simpleClient':
system.simpleClientConfigurations.set(node.id, {
targetAddress: trimmedOrEmpty(application.targetAddress),
targetPort: application.targetPort ?? 55_555,
});
break;
default:
break;
}
}
if (installed.size > 0) system.installedPrograms.set(node.id, installed);
if (active.size > 0) system.activePrograms.set(node.id, active);
}
/** A row of text cells, trimmed, or undefined unless it has exactly four. */
function fourCells(row: (string | null)[]): [string, string, string, string] | undefined {
if (row.length !== 4 || row.some((cell) => cell === null)) return undefined;
return row.map((cell) => swiftTrim(cell!)) as [string, string, string, string];
}
/**
* The DHCP, NAT and modem parts of Swift's finalizeCurrentNodeCandidate and
* registerRemoteLinkConfiguration.
*/
function registerServiceConfigurations(
source: FlsNode,
node: TopologyNode,
system: SystemConfigurations,
newId: () => Id,
warnings: string[],
): void {
const where = `node '${node.displayName}'`;
const dhcpCapable = node.kind === 'pc' || node.kind === 'notebook' || node.kind === 'gateway';
if (source.system.dhcpClient === true) {
if (dhcpCapable) system.dhcpClientConfigurations.set(node.id, { isEnabled: true });
else warnings.push(`${where}: DHCP client on a ${node.kind}, ignored`);
}
const server = source.system.dhcpServer;
if (server !== undefined) {
const configuration = dhcpServerConfiguration({
isActive: server.isActive,
lowerBoundIPAddress: trimmedOrEmpty(server.lowerBound),
upperBoundIPAddress: trimmedOrEmpty(server.upperBound),
gatewayIPAddress: trimmedOrEmpty(server.gateway),
dnsServerIPAddress: trimmedOrEmpty(server.dnsServer),
useOwnSettings: server.useOwnSettings,
staticAssignments: server.staticAssignedAddresses.map((entry) => ({
id: newId(),
macAddress: entry.mac,
ipAddress: entry.ip,
})),
});
if (!deepEqual(configuration, dhcpServerConfiguration())) {
if (dhcpCapable) system.dhcpServerConfigurations.set(node.id, configuration);
else warnings.push(`${where}: DHCP server settings on a ${node.kind}, ignored`);
}
}
if (node.kind === 'gateway') {
const rows: GatewayPortForwardingRow[] = [];
for (const row of source.system.staticNAT ?? []) {
const cells = fourCells(row);
if (cells === undefined) {
warnings.push(`${where}: a port forwarding row without four entries, left out`);
continue;
}
const [protocolValue, publicPortValue, lanIPAddress, lanPortValue] = cells;
rows.push({ protocolValue, publicPortValue, lanIPAddress, lanPortValue });
}
if (rows.length > 0) system.portForwardingRowsByNodeId.set(node.id, rows);
}
if (node.kind === 'remoteLink') {
const modem = source.system.modem;
let port = modem?.port ?? 12_345;
if (port < 1 || port > 65_535) {
warnings.push(`${where}: modem port ${port} is invalid, used 12345`);
port = 12_345;
}
if (modem !== undefined && modem.mode !== 1 && modem.mode !== 2) {
warnings.push(`${where}: modem mode ${modem.mode} unknown, treated as client`);
}
// Java pairs a server and a client modem over TCP on this port.
system.remoteLinkConfigurations.set(node.id, {
pairIdentifier: `java-modem-port-${port}`,
latencyMilliseconds: DEFAULT_REMOTE_LINK_LATENCY_MILLISECONDS,
isEnabled: true,
});
}
}
/**
* Swift's registerSwitchAndWirelessConfigurations, registerManualRoutes and
* the RIP and firewall parts of finalizeCurrentNodeCandidate.
*/
function registerSystemConfigurations(
source: FlsNode,
node: TopologyNode,
system: SystemConfigurations,
warnings: string[],
): void {
const where = `node '${node.displayName}'`;
const ssid = trimmedOrEmpty(source.system.ssid);
if (node.kind === 'networkSwitch') {
// Without an SSID in the file, Java rolls one; Swift derives it from the node id.
system.switchConfigurations.set(node.id, {
ssid: ssid === '' ? defaultSwitchConfiguration(node.id).ssid : ssid,
retentionTimeMilliseconds: source.system.retentionTime ?? 300_000,
...(source.cloud ? { showsAsCloud: true } : {}),
});
}
// Java joins a wireless network when the card is wireless and an SSID is set
// (JHostKonfiguration.updateWifiConnection); the model keeps both.
const wireless = source.interfaces[0]?.wireless === true;
if ((node.kind === 'pc' || node.kind === 'notebook') && (ssid !== '' || wireless)) {
system.hostWirelessConfigurations.set(node.id, { isEnabled: wireless, ssid });
}
const routes: ManualRoute[] = [];
for (const row of source.system.manualRoutes ?? []) {
const cells = fourCells(row);
if (cells === undefined) {
warnings.push(`${where}: a manual route without four entries, left out`);
continue;
}
const [destinationNetwork, subnetMask, gateway, interfaceIPAddress] = cells;
routes.push({ destinationNetwork, subnetMask, gateway, interfaceIPAddress });
}
if (routes.length > 0) system.manualRoutesByNodeId.set(node.id, routes);
if (node.kind === 'router' && source.system.ripEnabled === true) system.ripEnabledByNodeId.set(node.id, true);
if (source.system.ipForwardingEnabled !== undefined && source.system.ipForwardingEnabled !== forwardsIPv4ByDefault(node.kind)) {
system.ipForwardingByNodeId.set(node.id, source.system.ipForwardingEnabled);
}
if ((node.kind === 'router' || node.kind === 'gateway') && source.system.firewall !== undefined) {
system.firewallConfigurationsByNodeId.set(node.id, firewallConfigurationOf(source.system.firewall, where, warnings));
}
}
const PROTOCOLS: Readonly<Record<number, FirewallProtocol>> = { [-1]: 'all', 1: 'icmp', 6: 'tcp', 17: 'udp' };
const ACTIONS: Readonly<Record<number, FirewallAction>> = { 0: 'drop', 1: 'accept' };
/** Java's numbers as the core's names; an unknown number keeps Swift's default (TCP, drop). */
function firewallConfigurationOf(firewall: FlsFirewall, where: string, warnings: string[]): FirewallConfiguration {
const action = (value: number, what: string): FirewallAction => {
const known = ACTIONS[value];
if (known === undefined) warnings.push(`${where}: firewall ${what} ${value} unknown, used drop`);
return known ?? 'drop';
};
const rules: FirewallRule[] = firewall.rules.map((rule) => {
const protocol = PROTOCOLS[rule.protocol];
if (protocol === undefined) warnings.push(`${where}: firewall protocol ${rule.protocol} unknown, used TCP`);
return {
sourceIPAddress: trimmedOrEmpty(rule.srcIP),
sourceSubnetMask: trimmedOrEmpty(rule.srcMask),
destinationIPAddress: trimmedOrEmpty(rule.destIP),
destinationSubnetMask: trimmedOrEmpty(rule.destMask),
port: rule.port,
protocol: protocol ?? 'tcp',
action: action(rule.action, 'action'),
};
});
return {
isActive: firewall.activated,
defaultPolicy: action(firewall.defaultPolicy, 'default policy'),
dropIcmp: firewall.dropICMP,
filterSynSegmentsOnly: firewall.filterSYNSegmentsOnly,
filterUdp: firewall.filterUdp,
rules,
};
}
/** A usable name: not empty after trimming, no path separator. */
function safeName(name: string | null): string | undefined {
const trimmed = trimmedOrEmpty(name);
return trimmed === '' || trimmed.includes('/') || trimmed.includes('\\') ? undefined : trimmed;
}
/**
* Swift's registerVirtualFileSystem: the children of Java's root, depth
* first; an item that cannot be written is left out with a warning, the
* rest goes on.
*/
function importFileSystem(root: FlsFileTree | undefined, nodeName: string, warnings: string[]): VirtualFileSystem {
const fileSystem = new VirtualFileSystem();
const warn = (message: string) => warnings.push(message);
const describe = (error: unknown) => (error instanceof VirtualFileSystemError ? error.message : String(error));
const append = (tree: FlsFileTree, parent: string): void => {
if (tree.kind === 'file') {
const name = safeName(tree.name);
if (name === undefined) {
warn(`Ignored Java filesystem file with missing or unsafe name on '${nodeName}'.`);
return;
}
const rawPath = parent === '/' ? `/${name}` : `${parent}/${name}`;
try {
const path = normalizedAbsolutePath(rawPath);
const content = fileContentOfJavaType(trimmedOrEmpty(tree.type), tree.content ?? '', path, warn);
switch (content.kind) {
case 'text':
fileSystem.writeTextFile(path, content.text, false);
break;
case 'binary':
fileSystem.writeBinaryFile(path, content.data, content.mediaType, false);
break;
case 'image':
fileSystem.writeImageFile(path, content.data, content.mediaType, false);
break;
case 'directory':
break;
}
} catch (error) {
warn(`Ignored Java filesystem file '${rawPath}' on '${nodeName}': ${describe(error)}`);
}
return;
}
const name = tree.kind === 'directory' ? safeName(tree.name) : undefined;
if (name === undefined) {
warn(`Ignored Java filesystem directory with missing or unsafe name on '${nodeName}'.`);
return;
}
const rawPath = parent === '/' ? `/${name}` : `${parent}/${name}`;
try {
const path = normalizedAbsolutePath(rawPath);
fileSystem.createDirectory(path, true);
for (const child of tree.children) append(child, path);
} catch (error) {
warn(`Ignored Java filesystem directory '${rawPath}' on '${nodeName}': ${describe(error)}`);
}
};
for (const child of root?.children ?? []) append(child, '/');
return fileSystem;
}
/**
* Swift's finalizeCurrentDocumentationCandidate: RECT = 1, TEXT = 2; a frame
* inside the editor's world; the id from the item's values and its order.
*/
function importDocumentationItem(
item: FlsDocumentationItem,
order: number,
warnings: string[],
): DocumentationItem | undefined {
if (item.width <= 0 || item.height <= 0) {
warnings.push('documentation item without size, left out');
return undefined;
}
const kind = item.type === 1 ? 'rectangle' : item.type === 2 ? 'text' : undefined;
if (kind === undefined) {
warnings.push(`documentation item of type ${item.type}, left out (known: RECT = 1, TEXT = 2)`);
return undefined;
}
const frame = { x: item.x, y: item.y, width: item.width, height: item.height };
if (!isSafeFrame(frame)) {
warnings.push('documentation item outside the editor bounds, left out');
return undefined;
}
const text = item.text ?? '';
const seed = `java-doc|${order}|${item.type}|${item.x}|${item.y}|${item.width}|${item.height}|${text}`;
let color;
if (item.color === null) {
color = kind === 'text' ? DOCUMENTATION_BLACK : DOCUMENTATION_PALE_YELLOW;
warnings.push(`documentation item ${order + 1} without a color, given the default`);
} else {
const [red, green, blue, alpha] = item.color;
color = documentationColor(red / 255, green / 255, blue / 255, alpha / 255);
}
return documentationItem({
id: deterministicDocumentationUuid(seed),
kind,
frame,
text,
color,
fontName: item.font?.name ?? 'Dialog',
fontSize: item.font?.size ?? 12,
isBold: ((item.font?.style ?? 0) & 1) === 1,
order,
});
}