Add Windows WSL2 engine runtime

Windows cannot run the bundled Linux TRIM.SP-NL engine natively. Package the
same statically linked engine as a private WSL2 rootfs archive and launch it
through wsl.exe, mirroring the muSRFlow approach.

- packaging/wsl2/build-wsl2.sh builds trimspNL-runtime-rootfs.tar.gz from the
  static engine (no distro libraries needed; refuses a dynamically linked
  binary) into a minimal almalinux:8 rootfs at /opt/trimsp-runtime.
- wsl-runtime.js: pure main-process module that checks for and imports the
  distribution on first run, with an injectable spawner and a plain status
  contract (skipped/missing-rootfs/already-present/imported/import-failed).
- main.js imports the runtime in the background at startup, broadcasts
  wslRuntimeStatus, and exposes wslRuntimeStatus/ensureWslRuntime over IPC.
- TrimSPelec.js detects a Windows host with the bundled engine, translates
  Windows paths to /mnt/<drive>, routes executeFileAsync and the synchronous
  single-run command through wsl.exe, and gates the first run on the import
  via window.TrimSPRuntimeGate.
- TrimSPlib.js installs a no-op gate default and awaits it in startSequence.
- forge.config.js ships the rootfs via packagerConfig.extraResource when built.
- tests/wsl-runtime.test.js (11 cases) covers the module and the renderer
  launch translation, including inertness on Linux.
This commit is contained in:
salman committed 2026-10-20 09:31:25 +02:00
1 parent b729c9b047
commit db390d3969
11 files changed
+903 -4

No files matched your search

+3
View File
@@ -138,3 +138,6 @@ fortran/trimspNL-profile
# Generated alternative-RNG validation output trees
/tests-pcg32/
/tests-xoshiro/
# WSL2 rootfs build output (built by packaging/wsl2)
/packaging/wsl2/out/
+9
View File
@@ -2,6 +2,15 @@
## Unreleased
### Added
- Windows builds can now run the bundled TRIM.SP-NL engine through a private
WSL2 distribution. The statically linked engine ships as a rootfs archive
(`packaging/wsl2/build-wsl2.sh`), is imported into `%LOCALAPPDATA%\TrimSP`
on first run, and is launched via `wsl.exe` with Windows paths translated to
their `/mnt/<drive>` form. A runtime gate waits for the import before the
first run, and progress is reported in the status bar.
### Fixed
- The RPM no longer packages ELF build-id links under `/usr/lib/.build-id`,
+134 -2
View File
@@ -71,10 +71,12 @@ function getFiles(dir, filelist){
}
// Run one TRIM.SP-NL process asynchronously in a given working directory so the
// scan loop can keep several local jobs in flight at once.
// scan loop can keep several local jobs in flight at once. On Windows the
// bundled Linux engine is dispatched through WSL2 (see prepareTrimspLaunch).
function executeFileAsync(binary, args, workdir) {
return new Promise((resolve, reject) => {
execFile(binary, args, {cwd: workdir}, (err) => {
let launch = prepareTrimspLaunch(binary, args);
execFile(launch.command, launch.args, {cwd: workdir}, (err) => {
if (err) {
reject(err);
return;
@@ -190,6 +192,136 @@ function getTrimBinaryPath(configuredPath) {
return "trimspNL";
}
// --- Windows / WSL2 execution -------------------------------------------
// A packaged Windows build has no native engine. Instead the statically linked
// Linux trimspNL shipped with the bundle is imported into a private WSL2
// distribution and run through wsl.exe. Keep these constants in sync with
// wsl-runtime.js (DEFAULT_DISTRO / DEFAULT_PREFIX) and
// packaging/wsl2/build-wsl2.sh (the prefix baked into the rootfs).
var TRIMSP_WSL_DISTRO = "trimspNL-runtime";
var TRIMSP_WSL_PREFIX = "/opt/trimsp-runtime";
// Where the bundled engine lives inside the packaged app. On Windows this is
// the statically linked ELF marker that triggers WSL routing; on Linux it is the
// engine itself.
function bundledTrimBinaryPath() {
if (!process.resourcesPath) { return ""; }
let candidate = path.join(process.resourcesPath, "bin", "trimspNL");
return fileExists(candidate) ? candidate : "";
}
function isWslRuntimeHost() {
return process.platform === "win32" && bundledTrimBinaryPath() !== "";
}
// Translate an absolute Windows path (C:\dir\file or C:/dir/file) to the path
// its WSL mount exposes. Non-path arguments (run names, rng keywords) pass
// through unchanged.
function windowsPathToWsl(input) {
if (typeof input !== "string" || input === "") { return input; }
let match = /^([A-Za-z]):[\\/](.*)$/.exec(input);
if (!match) { return input; }
return "/mnt/" + match[1].toLowerCase() + "/" + match[2].replace(/\\/g, "/");
}
// Wrap the bundled Linux engine as a wsl.exe invocation, translating path
// arguments. Commands outside the bundled engine (host tools, or a user-
// configured Windows-native engine) are left untouched.
function prepareTrimspLaunch(command, args) {
args = args || [];
if (!isWslRuntimeHost()) {
return { command: command, args: args, viaWsl: false };
}
let bundled = bundledTrimBinaryPath();
if (!bundled || command !== bundled) {
return { command: command, args: args, viaWsl: false };
}
let wslArgs = ["-d", TRIMSP_WSL_DISTRO, "--", TRIMSP_WSL_PREFIX + "/bin/trimspNL"]
.concat(args.map(windowsPathToWsl));
return { command: "wsl.exe", args: wslArgs, viaWsl: true };
}
// Shell-string counterpart used by the synchronous single-run path, which builds
// a command such as "cd <dir>; <trimBin> <run> <rng>". Only the bundled engine
// and any Windows paths in the string are rewritten.
function prepareTrimspCommandString(command) {
if (!isWslRuntimeHost() || typeof command !== "string") { return command; }
let bundled = bundledTrimBinaryPath();
if (!bundled) { return command; }
return command.split(bundled).join("wsl.exe -d " + TRIMSP_WSL_DISTRO + " -- "
+ TRIMSP_WSL_PREFIX + "/bin/trimspNL");
}
// First-run runtime gate. The main process imports the bundled WSL2 distribution
// in the background at startup; until that finishes a Run must wait rather than
// launch the engine inside a distribution that does not exist yet.
var wslRuntimeReadyPromise = null;
var wslRuntimeGateMessage = "";
function refreshWslRuntimeGate() {
if (wslRuntimeReadyPromise) { return wslRuntimeReadyPromise; }
wslRuntimeReadyPromise = ipcRenderer.invoke('ensureWslRuntime').then(function (result) {
if (!result || result.ok) {
wslRuntimeGateMessage = "";
return true;
}
// Let a later action retry (for example after the user installs WSL2).
wslRuntimeReadyPromise = null;
wslRuntimeGateMessage = result.error || ("runtime setup failed: " + result.status);
return false;
}).catch(function (error) {
wslRuntimeReadyPromise = null;
wslRuntimeGateMessage = error && error.message ? error.message : String(error);
return false;
});
return wslRuntimeReadyPromise;
}
function wslRuntimeGateReady() {
if (!isWslRuntimeHost()) { return Promise.resolve(true); }
let unsubscribe = function () {};
unsubscribe = function (event, state) {
if (!state) { return; }
if (state.status === "checking") {
if (typeof updateStatusBar === 'function') {
updateStatusBar({ run: "Checking the bundled engine runtime..." });
}
} else if (state.status === "importing") {
if (typeof updateStatusBar === 'function') {
updateStatusBar({ run: "Preparing the bundled engine runtime (first run only)..." });
}
}
};
ipcRenderer.on("wslRuntimeStatus", unsubscribe);
return refreshWslRuntimeGate().then(function (ready) {
ipcRenderer.removeListener("wslRuntimeStatus", unsubscribe);
return ready;
}, function (error) {
ipcRenderer.removeListener("wslRuntimeStatus", unsubscribe);
throw error;
});
}
// The shared library installs a no-op gate; the Electron shell replaces it with
// the WSL-aware one (a no-op on non-Windows hosts).
if (window.TrimSPRuntimeGate) {
window.TrimSPRuntimeGate.ready = wslRuntimeGateReady;
window.TrimSPRuntimeGate.message = function () {
return wslRuntimeGateMessage
? wslRuntimeGateMessage
: "Check that WSL2 is installed, then try again.";
};
} else {
window.TrimSPRuntimeGate = {
ready: wslRuntimeGateReady,
message: function () {
return wslRuntimeGateMessage
? wslRuntimeGateMessage
: "Check that WSL2 is installed, then try again.";
}
};
}
function setWorkFolder(foldername) {
const folder = Array.isArray(foldername) ? foldername[0] : foldername;
if (folder) {
+38
View File
@@ -4,6 +4,36 @@ const electronExecSync = (typeof module !== 'undefined' && module.exports)
? require('child_process').execSync
: null;
// Before a run touches the external engine the shell may need to wait for its
// runtime to become usable (on Windows the bundled WSL2 distribution is imported
// on first run). The Electron shell replaces this no-op with a real gate.
if (typeof window !== 'undefined' && !window.TrimSPRuntimeGate) {
window.TrimSPRuntimeGate = {
ready: function () { return Promise.resolve(true); },
message: function () { return ''; }
};
}
async function ensureRuntimeGateReady(actionLabel) {
let gate = (typeof window !== 'undefined') ? window.TrimSPRuntimeGate : null;
if (!gate || typeof gate.ready !== 'function') { return true; }
let ready = true;
try {
ready = await gate.ready(actionLabel);
} catch (error) {
ready = false;
}
if (ready === false) {
updateStatusBar({
run: "The " + String(actionLabel || 'run').toLowerCase()
+ " runtime is not ready. "
+ (typeof gate.message === 'function' ? gate.message() : '')
});
return false;
}
return true;
}
// This is an object that contains all GUI ids and values
var All = new Object();
@@ -4254,6 +4284,9 @@ async function runSimulationSequence() {
if (!webOrApp) {
cmd = "cd " + runWorkPath;
cmd += ";" + trimBin + ' ' + job.filename + ' ' + rngType;
if (typeof prepareTrimspCommandString === 'function') {
cmd = prepareTrimspCommandString(cmd);
}
electronExecSync(cmd, {stdio: 'inherit'});
}
results[job.index] = await collectRunResult(job);
@@ -4351,6 +4384,11 @@ async function startSequence() {
updateStatusBar({ run: 'A run is already in progress.' });
return(0);
}
// Wait for the shell's external runtime (the bundled WSL2 distribution on
// Windows) before launching the engine. No-op everywhere else.
if (!await ensureRuntimeGateReady('Run')) {
return(0);
}
runInProgress = true;
const runButton = document.getElementById("runSimulationButton");
if (runButton) { runButton.disabled = true; }
+12 -1
View File
@@ -1,4 +1,5 @@
const path = require('path');
const fs = require('fs');
const redhatInstaller = require('electron-installer-redhat');
const redhatDependencies = require('electron-installer-redhat/src/dependencies');
@@ -17,6 +18,16 @@ const linuxDesktopTemplate = path.resolve(__dirname, 'packaging/linux/trimsp.des
const linuxRpmSpecTemplate = path.resolve(__dirname, 'packaging/linux/trimsp.spec.ejs');
const includeFlatpak = process.env.FORGE_FLATPAK === '1';
// Windows cannot run the bundled Linux engine natively. The Windows build also
// ships the engine as a WSL2 rootfs archive (built by packaging/wsl2) which the
// app imports as a private distribution on first run. Only add the archive when
// it has actually been built, so Linux/macOS packaging is unaffected.
const wsl2Rootfs = path.resolve(__dirname, 'packaging/wsl2/out/trimspNL-runtime-rootfs.tar.gz');
const extraResource = ['resources/bin'];
if (fs.existsSync(wsl2Rootfs)) {
extraResource.push(wsl2Rootfs);
}
// The upstream RPM maker assumes Fedora-style package names. Mageia provides
// GTK 3 under lib64gtk+3_0, so allow either name in the generated Requires.
redhatDependencies.dependencyMap.gtk3 = '(gtk3 or lib64gtk+3_0)';
@@ -101,7 +112,7 @@ if (includeFlatpak) {
module.exports = {
packagerConfig: {
icon: 'appicons/icons/png/1024x1024.png',
extraResource: ['resources/bin'],
extraResource: extraResource,
ignore: [
'Readme.md',
'ToDo.txt',
+64 -1
View File
@@ -2,6 +2,7 @@ const path = require('path');
const fs = require('fs');
const { spawnSync } = require('child_process');
const { app, BrowserWindow, Menu, clipboard, dialog, ipcMain, nativeImage } = require('electron');
const wslRuntime = require('./wsl-runtime');
let currentWorkPath = process.cwd();
const startupOptions = parseStartupOptions(process.argv);
@@ -521,7 +522,58 @@ function createWindow () {
// win.openDevTools();
}
app.whenReady().then(createWindow)
// Windows runs the bundled TRIM.SP-NL engine inside a private WSL2
// distribution: the installer also ships the engine as a rootfs archive, and the
// first launch imports it. The check/import runs in the background so the window
// appears immediately; status is broadcast to the renderer and mirrored through
// IPC so the UI can surface progress or retry a failed import.
let wslRuntimeState = { status: process.platform === 'win32' ? 'pending' : 'not-windows' };
let wslRuntimeSetup = null;
function broadcastWslRuntimeState(state) {
wslRuntimeState = state;
const target = BrowserWindow.getAllWindows()[0];
if (target && !target.isDestroyed()) {
target.webContents.send('wslRuntimeStatus', state);
}
}
function setupWslRuntime() {
if (process.platform !== 'win32') {
return Promise.resolve(wslRuntimeState);
}
if (wslRuntimeSetup) return wslRuntimeSetup;
broadcastWslRuntimeState({ status: 'checking', distro: wslRuntime.DEFAULT_DISTRO });
wslRuntimeSetup = wslRuntime.ensureWSLRuntime({
resourcesPath: process.resourcesPath,
appDataPath: process.env.LOCALAPPDATA || app.getPath('userData'),
onProgress: function (progress) {
broadcastWslRuntimeState(Object.assign({ status: progress.phase }, progress));
}
}).then(function (result) {
broadcastWslRuntimeState(result);
if (!result.ok) {
wslRuntimeSetup = null;
console.log('[wsl-runtime] setup failed: ' + result.status
+ (result.error ? ' - ' + result.error : ''));
} else {
console.log('[wsl-runtime] ' + result.status);
}
return result;
}).catch(function (error) {
wslRuntimeSetup = null;
const result = { ok: false, status: 'error', error: error.message || String(error) };
broadcastWslRuntimeState(result);
console.log('[wsl-runtime] error: ' + result.error);
return result;
});
return wslRuntimeSetup;
}
app.whenReady().then(() => {
createWindow();
setupWslRuntime();
})
global.path = app.getAppPath();
//console.log("From main: ",global.path);
@@ -568,6 +620,17 @@ ipcMain.handle('getStartupOptions', async () => {
return startupOptions;
});
// Windows bundled-runtime lifecycle: expose the current status and let the
// renderer drive (or retry) the first-run import through the same memoized
// promise the background startup path uses.
ipcMain.handle('wslRuntimeStatus', async () => {
return wslRuntimeState;
});
ipcMain.handle('ensureWslRuntime', async () => {
return setupWslRuntime();
});
ipcMain.on('openQuickStartWindow', (event, options) => {
const sectionId = options && options.sectionId ? options.sectionId : 'helpLayers';
showQuickStartWindow(sectionId);
+2
View File
@@ -14,6 +14,8 @@
"make": "electron-forge make",
"make:dynamic": "npm run build:fortran:dynamic && electron-forge make",
"make:flatpak": "npm run build:fortran && FORGE_FLATPAK=1 electron-forge make --targets=@electron-forge/maker-flatpak",
"build:wsl2": "bash packaging/wsl2/build-wsl2.sh",
"test:wsl-runtime": "node tests/wsl-runtime.test.js",
"checksums:md5": "sh -c 'for d in out/make/deb/x64 out/make/rpm/x64 out/make/flatpak/x86_64; do [ -d \"$d\" ] || continue; for f in \"$d\"/*; do [ -f \"$f\" ] || continue; case \"$f\" in *.deb|*.rpm|*.flatpak) base=$(basename \"$f\"); (cd \"$d\" && md5sum \"$base\" > \"$base.md5\");; esac; done; done'",
"checksums:sha256": "sh -c 'for d in out/make/deb/x64 out/make/rpm/x64 out/make/flatpak/x86_64; do [ -d \"$d\" ] || continue; for f in \"$d\"/*; do [ -f \"$f\" ] || continue; case \"$f\" in *.deb|*.rpm|*.flatpak) base=$(basename \"$f\"); (cd \"$d\" && sha256sum \"$base\" > \"$base.sha256\");; esac; done; done'",
"checksums": "npm run checksums:md5 && npm run checksums:sha256"
+92
View File
@@ -0,0 +1,92 @@
# Windows (WSL2) engine runtime
Windows has no native TRIM.SP-NL engine; the Linux engine cannot be run
directly. Instead TRIMSP-NL Workbench ships the **same statically linked engine**
used by the Linux bundle and runs it inside a bundled WSL2 distribution.
## How it works
`TrimSPelec.js` detects the Windows host and the bundled engine
(`process.platform === "win32"` and `<resources>/bin/trimspNL` present) and
routes the engine through `wsl.exe`:
```
wsl.exe -d trimspNL-runtime -- /opt/trimsp-runtime/bin/trimspNL <run> [rng]
```
Path arguments (`C:\dir\file`) are translated to their WSL mount form
(`/mnt/c/dir/file`) so the engine reads and writes the user's Windows files.
Run inputs and outputs therefore stay on the Windows side.
A user-configured **engine binaries** directory with a Windows-native executable
is launched directly, and on non-Windows hosts the translation is inert, so the
Linux build and development checkouts are unaffected.
Constants (in `TrimSPelec.js`): the distribution name `trimspNL-runtime` and the
in-distro prefix `/opt/trimsp-runtime`.
## Building the rootfs
Requires Docker or Podman and the static engine
(`npm run build:fortran`, built automatically when missing).
```sh
npm run build:wsl2 # packaging/wsl2/build-wsl2.sh
```
Because `trimspNL-static` is a fully static executable (no libc/libgfortran
runtime dependencies) the rootfs is just a minimal base filesystem plus
`/opt/trimsp-runtime/bin/trimspNL`. The script writes `/etc/wsl.conf` and exports
the container filesystem to
`packaging/wsl2/out/trimspNL-runtime-rootfs.tar.gz`.
The script refuses to run if `resources/bin/trimspNL` is not statically linked.
## Importing on Windows
The import is automated. On first run `main.js` (through `wsl-runtime.js`) checks
whether the `trimspNL-runtime` distribution is registered and, if not, imports
the bundled archive into `%LOCALAPPDATA%\TrimSP\trimspNL-runtime` with
`wsl --import ... --version 2`. The work happens in the background so the window
opens immediately; progress and failures are broadcast to the renderer on the
`wslRuntimeStatus` channel. A failed import resets so a later `ensureWslRuntime`
retries. Before running the engine the renderer awaits `window.TrimSPRuntimeGate`,
so the first run after startup shows a "Preparing the bundled engine runtime"
notice and proceeds once the distribution is ready; the gate is a no-op off
Windows.
The equivalent manual command, useful for debugging:
```powershell
wsl --import trimspNL-runtime "$env:LOCALAPPDATA\TrimSP\trimspNL-runtime" trimspNL-runtime-rootfs.tar.gz --version 2
```
Verify:
```powershell
wsl -d trimspNL-runtime -- /opt/trimsp-runtime/bin/trimspNL
```
## Packaging
The Windows build ships the rootfs through electron-forge. `forge.config.js`
adds the archive to `packagerConfig.extraResource` when it exists, landing it at
`resources/wsl-runtime/`, while the engine marker at `resources/bin/trimspNL`
lets the renderer detect the bundled runtime and route through WSL.
## Tests
```sh
npm run test:wsl-runtime # tests/wsl-runtime.test.js
```
Covers the pure main-process module (with an injected `wsl.exe` spawner) and the
renderer-side launch translation, including inertness on Linux.
## Open items
- The rootfs is built from `almalinux:8`. Adjust `BASE_IMAGE` if the engine is
rebuilt against a different baseline.
- `wsl.exe` availability is assumed; older Windows builds without WSL2 must
install it (`wsl --install`) first.
- The macOS runtime is not built.
+108
View File
@@ -0,0 +1,108 @@
#!/usr/bin/env bash
# Copyright (C) 2026 Zaher Salman
# SPDX-License-Identifier: GPL-3.0-or-later
#
# Build a WSL2 rootfs tarball that carries the TRIM.SP-NL engine for the Windows
# edition of TRIMSP-NL Workbench. Windows cannot run the Linux ELF engine
# natively, so the same statically linked trimspNL shipped in the Linux bundle
# (resources/bin/trimspNL, produced by `make -C fortran trimspNL-static`) is
# installed into a private WSL2 distribution. The renderer runs it through
# wsl.exe (see TrimSPelec.js::prepareTrimspLaunch).
#
# Because trimspNL-static is a fully static executable (no libc/libgfortran
# runtime dependencies) the rootfs needs no distro-level libraries: it is just a
# base filesystem plus /opt/trimsp-runtime/bin/trimspNL.
#
# Requires Docker or Podman and a built static engine
# (npm run build:fortran, invoked automatically when resources/bin/trimspNL is
# missing).
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO="$(cd "$HERE/../.." && pwd)"
BIN="$REPO/resources/bin/trimspNL"
OUT_DIR="$HERE/out"
BASE_IMAGE="${BASE_IMAGE:-docker.io/library/almalinux:8}"
DISTRO_NAME="${DISTRO_NAME:-trimspNL-runtime}"
PREFIX="${PREFIX:-/opt/trimsp-runtime}"
OUTPUT="${OUTPUT:-$OUT_DIR/trimspNL-runtime-rootfs.tar.gz}"
# Honour an explicit ENGINE=docker|podman; otherwise pick the first engine that
# is both installed and usable.
ENGINE="${ENGINE:-}"
if [ -z "$ENGINE" ]; then
if command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then
ENGINE=docker
elif command -v podman >/dev/null 2>&1 && podman info >/dev/null 2>&1; then
ENGINE=podman
else
echo "ERROR: no usable docker or podman engine (set ENGINE to override)" >&2
exit 1
fi
fi
echo "Using container engine: $ENGINE"
if [ ! -x "$BIN" ]; then
echo "resources/bin/trimspNL not built yet; running npm run build:fortran"
( cd "$REPO" && npm run build:fortran )
fi
# The Windows runtime ships the engine as a relocatable binary, so a static
# build is required. Fail loudly rather than produce a rootfs that cannot run.
if command -v file >/dev/null 2>&1; then
if ! file "$BIN" | grep -q "statically linked"; then
echo "ERROR: $BIN is not statically linked." >&2
echo " Build it with: npm run build:fortran (make -C fortran trimspNL-static)" >&2
exit 1
fi
fi
mkdir -p "$OUT_DIR"
CONTAINER="trimspNL-wsl2-build"
"$ENGINE" rm -f "$CONTAINER" >/dev/null 2>&1 || true
trap '"$ENGINE" rm -f "$CONTAINER" >/dev/null 2>&1 || true' EXIT
echo "Staging WSL2 rootfs in $BASE_IMAGE"
"$ENGINE" run --name "$CONTAINER" \
-v "$BIN:/tmp/trimspNL:ro" \
-e "PREFIX=$PREFIX" \
"$BASE_IMAGE" \
bash -c '
set -euo pipefail
mkdir -p "$PREFIX/bin"
install -m 0755 /tmp/trimspNL "$PREFIX/bin/trimspNL"
# WSL defaults: no systemd in these tarballs, run as root, and keep the
# Windows PATH interop so wsl.exe can be launched with translated paths.
printf "[user]\ndefault=root\n[interop]\nappendWindowsPath=true\n" > /etc/wsl.conf
printf "TRIMSP-NL bundled TRIM.SP-NL engine\n" > "$PREFIX/README.txt"
if [ -x "$PREFIX/bin/trimspNL" ]; then
echo " ok trimspNL"
else
echo " MISSING trimspNL" >&2
exit 1
fi
# Smoke test: the engine prints its banner/usage when run without a run
# file, so a non-zero exit is still proof that it executed.
"$PREFIX/bin/trimspNL" >/dev/null 2>&1 || true
echo "runtime self-check OK"
'
echo "Exporting rootfs to $OUTPUT"
"$ENGINE" export "$CONTAINER" | gzip -c > "$OUTPUT"
cat <<EOF
Done: $OUTPUT
Import once on Windows (per user):
wsl --import $DISTRO_NAME %LOCALAPPDATA%\\TrimSP\\$DISTRO_NAME "$(basename "$OUTPUT")" --version 2
Then TRIMSP-NL Workbench launches the bundled engine as:
wsl.exe -d $DISTRO_NAME -- $PREFIX/bin/trimspNL <run> [rng]
EOF
+231
View File
@@ -0,0 +1,231 @@
// Tests for the Windows/WSL2 bundled-engine runtime plumbing.
//
// Two layers are covered:
// 1. the pure main-process module wsl-runtime.js, driven with an injected
// spawner so no Windows host is required;
// 2. the renderer-side launch translation and runtime gate in TrimSPelec.js,
// lifted out of the shipped source and run against minimal stubs.
//
// Run with: node tests/wsl-runtime.test.js
const assert = require('assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const wsl = require('../wsl-runtime');
// --- helpers ---------------------------------------------------------------
function makeRun(responses) {
const calls = [];
const run = function (args, onOutput) {
calls.push(args);
const next = responses.shift() || { status: 0, stdout: '', stderr: '' };
if (typeof onOutput === 'function' && next.stderr) onOutput(next.stderr);
return Promise.resolve(next);
};
run.calls = calls;
return run;
}
function makeRootfs() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'trimsp-wsl-'));
const file = path.join(dir, wsl.ROOTFS_BASENAME);
fs.writeFileSync(file, 'rootfs');
return { dir, file };
}
// --- 1. pure module --------------------------------------------------------
function testDecodeWslOutput() {
assert.strictEqual(wsl.decodeWslOutput(Buffer.from('abc', 'utf16le')), 'abc');
assert.strictEqual(wsl.decodeWslOutput(Buffer.from('abc', 'utf8')), 'abc');
assert.strictEqual(wsl.decodeWslOutput('plain'), 'plain');
assert.strictEqual(wsl.decodeWslOutput(null), '');
assert.strictEqual(wsl.decodeWslOutput(Buffer.alloc(0)), '');
}
function testParseDistroList() {
assert.deepStrictEqual(wsl.parseDistroList('a\r\nb\r\n'), ['a', 'b']);
assert.deepStrictEqual(wsl.parseDistroList(' a \n\n b '), ['a', 'b']);
assert.deepStrictEqual(wsl.parseDistroList('a\u0000\nb\u0000'), ['a', 'b']);
assert.deepStrictEqual(wsl.parseDistroList(''), []);
assert.deepStrictEqual(wsl.parseDistroList(null), []);
}
function testDistroIsRegistered() {
assert.strictEqual(wsl.distroIsRegistered('Ubuntu\ntrimspNL-runtime\n', wsl.DEFAULT_DISTRO), true);
assert.strictEqual(wsl.distroIsRegistered('TRIMSPNL-RUNTIME\n', wsl.DEFAULT_DISTRO), true);
assert.strictEqual(wsl.distroIsRegistered('Ubuntu\n', wsl.DEFAULT_DISTRO), false);
}
function testArgumentBuilders() {
assert.deepStrictEqual(wsl.listArgs(), ['--list', '--quiet']);
assert.deepStrictEqual(
wsl.importArgs('/tmp/r.tar.gz', 'C:\\dir', 'trimspNL-runtime'),
['--import', 'trimspNL-runtime', 'C:\\dir', '/tmp/r.tar.gz', '--version', '2']);
assert.strictEqual(
wsl.distroInstallDir('C:\\Users\\x\\AppData\\Local', 'trimspNL-runtime'),
path.join('C:\\Users\\x\\AppData\\Local', 'TrimSP', 'trimspNL-runtime'));
}
function testFindRootfsTarball() {
assert.strictEqual(wsl.findRootfsTarball('/nonexistent'), '');
const { dir, file } = makeRootfs();
const primary = path.join(dir, 'wsl-runtime');
fs.mkdirSync(primary);
fs.renameSync(file, path.join(primary, wsl.ROOTFS_BASENAME));
assert.strictEqual(wsl.findRootfsTarball(dir), path.join(primary, wsl.ROOTFS_BASENAME));
assert.ok(wsl.rootfsCandidates('/x')[0].endsWith(wsl.ROOTFS_BASENAME));
}
async function testNotWindows() {
const run = makeRun([]);
const result = await wsl.ensureWSLRuntime({ platform: 'linux', run });
assert.strictEqual(result.ok, true);
assert.strictEqual(result.status, 'skipped');
assert.strictEqual(result.reason, 'not-windows');
assert.strictEqual(run.calls.length, 0);
}
async function testMissingRootfs() {
const run = makeRun([]);
const result = await wsl.ensureWSLRuntime({
platform: 'win32', resourcesPath: '/nonexistent', run
});
assert.strictEqual(result.ok, false);
assert.strictEqual(result.status, 'missing-rootfs');
}
async function testAlreadyPresentSkipsImport() {
const { file } = makeRootfs();
const run = makeRun([{ status: 0, stdout: wsl.DEFAULT_DISTRO + '\r\n', stderr: '' }]);
const result = await wsl.ensureWSLRuntime({
platform: 'win32', rootfsPath: file, appDataPath: '/tmp/appdata', run
});
assert.strictEqual(result.status, 'already-present');
assert.strictEqual(run.calls.length, 1);
}
async function testImportSucceeds() {
const { file } = makeRootfs();
const phases = [];
const run = makeRun([
{ status: 0, stdout: 'No installed distributions\r\n', stderr: '' },
{ status: 0, stdout: '', stderr: 'importing...' }
]);
const result = await wsl.ensureWSLRuntime({
platform: 'win32', rootfsPath: file, appDataPath: '/tmp/appdata', run,
onProgress: function (p) { phases.push(p.phase); }
});
assert.strictEqual(result.ok, true);
assert.strictEqual(result.status, 'imported');
assert.strictEqual(result.target, wsl.distroInstallDir('/tmp/appdata', wsl.DEFAULT_DISTRO));
assert.deepStrictEqual(run.calls[1], wsl.importArgs(file, result.target, wsl.DEFAULT_DISTRO));
assert.strictEqual(phases[0], 'checking');
assert.ok(phases.includes('importing'));
assert.strictEqual(phases[phases.length - 1], 'ready');
}
async function testImportFailure() {
const { file } = makeRootfs();
const run = makeRun([
{ status: 0, stdout: '', stderr: '' },
{ status: 1, stdout: '', stderr: 'boom' }
]);
const result = await wsl.ensureWSLRuntime({
platform: 'win32', rootfsPath: file, appDataPath: '/tmp/appdata', run
});
assert.strictEqual(result.ok, false);
assert.strictEqual(result.status, 'import-failed');
assert.ok(result.error.includes('boom'));
}
// --- 2. renderer translation + gate ---------------------------------------
function testRendererTranslation() {
const src = path.join(__dirname, '..', 'TrimSPelec.js');
const srcText = fs.readFileSync(src, 'utf8');
// Extract just the translation helpers by satisfying their free variables.
const bundled = 'C:\\app\\resources\\bin\\trimspNL';
const fakeProcess = { platform: 'win32', resourcesPath: 'C:\\app\\resources' };
const fakePath = path.win32;
const fileExists = function (p) { return p === bundled; };
function grab(name) {
const re = new RegExp('function ' + name + '\\s*\\([^)]*\\)\\s*\\{[\\s\\S]*?\\n\\}', 'm');
const m = srcText.match(re);
if (!m) throw new Error('function not found: ' + name);
return m[0];
}
const body = [
'var TRIMSP_WSL_DISTRO = ' + JSON.stringify('trimspNL-runtime') + ';',
'var TRIMSP_WSL_PREFIX = ' + JSON.stringify('/opt/trimsp-runtime') + ';',
grab('bundledTrimBinaryPath'),
grab('isWslRuntimeHost'),
grab('windowsPathToWsl'),
grab('prepareTrimspLaunch'),
'return { bundledTrimBinaryPath, isWslRuntimeHost, windowsPathToWsl, prepareTrimspLaunch };'
].join('\n');
const api = new Function('process', 'path', 'fileExists', body)(
fakeProcess, fakePath, fileExists);
assert.strictEqual(api.isWslRuntimeHost(), true);
assert.strictEqual(api.windowsPathToWsl('C:\\work\\job.inp'), '/mnt/c/work/job.inp');
assert.strictEqual(api.windowsPathToWsl('D:/data/x.rge'), '/mnt/d/data/x.rge');
assert.strictEqual(api.windowsPathToWsl('pcg32'), 'pcg32');
const launch = api.prepareTrimspLaunch(bundled, ['job', 'pcg32']);
assert.strictEqual(launch.command, 'wsl.exe');
assert.strictEqual(launch.viaWsl, true);
assert.deepStrictEqual(launch.args,
['-d', 'trimspNL-runtime', '--', '/opt/trimsp-runtime/bin/trimspNL', 'job', 'pcg32']);
// A user-configured Windows-native binary must pass through untouched.
const native = api.prepareTrimspLaunch('C:\\tools\\trimspNL.exe', ['job']);
assert.strictEqual(native.command, 'C:\\tools\\trimspNL.exe');
assert.strictEqual(native.viaWsl, false);
// On Linux the translation is inert.
fakeProcess.platform = 'linux';
assert.strictEqual(api.isWslRuntimeHost(), false);
const inert = api.prepareTrimspLaunch(bundled, ['job']);
assert.strictEqual(inert.viaWsl, false);
assert.strictEqual(inert.command, bundled);
}
function run() {
const tests = [
['decodeWslOutput', testDecodeWslOutput],
['parseDistroList', testParseDistroList],
['distroIsRegistered', testDistroIsRegistered],
['argumentBuilders', testArgumentBuilders],
['findRootfsTarball', testFindRootfsTarball],
['notWindows', testNotWindows],
['missingRootfs', testMissingRootfs],
['alreadyPresent', testAlreadyPresentSkipsImport],
['importSucceeds', testImportSucceeds],
['importFailure', testImportFailure],
['rendererTranslation', testRendererTranslation]
];
let failed = 0;
(async function () {
for (const [name, fn] of tests) {
try {
await fn();
console.log('PASS ' + name);
} catch (error) {
failed++;
console.log('FAIL ' + name + ' ' + (error && error.message));
}
}
console.log('\n' + (tests.length - failed) + ' passed, ' + failed + ' failed');
process.exit(failed ? 1 : 0);
})();
}
run();
+210
View File
@@ -0,0 +1,210 @@
// Copyright (C) 2026 Zaher Salman
// SPDX-License-Identifier: GPL-3.0-or-later
// Windows support for the bundled TRIM.SP-NL engine. The Linux engine is a
// statically linked ELF binary; it cannot run natively on Windows, so the
// Windows installer also ships the whole engine as a WSL2 root filesystem and
// the main process imports it as a private distribution on first run. From then
// on the renderer launches the engine as
// wsl.exe -d trimspNL-runtime -- /opt/trimsp-runtime/bin/trimspNL <run> [rng]
// (see prepareTrimspLaunch in TrimSPelec.js).
//
// All heavy lifting is expressed as small pure helpers so the import flow can be
// unit-tested without Windows: the only Windows-specific piece is the default
// spawner, which is injectable.
const fs = require("fs");
const path = require("path");
const childProcess = require("child_process");
// Keep these in sync with TrimSPelec.js (TRIMSP_WSL_DISTRO / TRIMSP_WSL_PREFIX)
// and packaging/wsl2/build-wsl2.sh (the prefix baked into the rootfs).
const DEFAULT_DISTRO = "trimspNL-runtime";
const DEFAULT_PREFIX = "/opt/trimsp-runtime";
const ROOTFS_BASENAME = "trimspNL-runtime-rootfs.tar.gz";
// Candidate locations for the shipped rootfs tarball, in priority order. The
// first is the packaged layout (packagerConfig.extraResource maps
// packaging/wsl2/out -> wsl-runtime); the second tolerates running from a
// development checkout without repackaging.
function rootfsCandidates(resourcesPath) {
const base = resourcesPath || ".";
return [
path.join(base, "wsl-runtime", ROOTFS_BASENAME),
path.join(base, "packaging", "wsl2", "out", ROOTFS_BASENAME)
];
}
function findRootfsTarball(resourcesPath) {
const candidates = rootfsCandidates(resourcesPath);
for (const candidate of candidates) {
try {
if (fs.statSync(candidate).isFile()) return candidate;
} catch (error) {
// Missing candidate: keep looking.
}
}
return "";
}
// Where the imported distribution's virtual disk is stored on the Windows host.
function distroInstallDir(appDataPath, distro) {
const name = distro || DEFAULT_DISTRO;
return path.join(appDataPath || ".", "TrimSP", name);
}
// wsl.exe writes UTF-16LE for --list output but UTF-8 for progress text. Detect
// UTF-16LE by the presence of NUL bytes rather than trusting the locale.
function decodeWslOutput(chunk) {
if (chunk == null) return "";
if (!Buffer.isBuffer(chunk)) return String(chunk);
if (chunk.length === 0) return "";
return chunk.includes(0) ? chunk.toString("utf16le") : chunk.toString("utf8");
}
// Turn `wsl.exe --list --quiet` output into a clean list of distribution names.
function parseDistroList(text) {
return String(text || "")
.replace(/\u0000/g, "")
.split(/\r?\n/)
.map((line) => line.replace(/[\u0000\s]+$/g, "").replace(/^\u0000+/, "").trim())
.filter((line) => line.length > 0);
}
function distroIsRegistered(text, distro) {
const wanted = String(distro || DEFAULT_DISTRO).toLowerCase();
return parseDistroList(text).some((name) => name.toLowerCase() === wanted);
}
// `wsl --import <Distro> <InstallLocation> <FileName> [--version <1|2>]`.
function importArgs(rootfsPath, targetDir, distro) {
return [
"--import",
distro || DEFAULT_DISTRO,
targetDir,
rootfsPath,
"--version",
"2"
];
}
function listArgs() {
return ["--list", "--quiet"];
}
// Default spawner: runs wsl.exe and resolves with { status, stdout, stderr }.
// Output is streamed to onOutput as it arrives so long imports can report
// progress without blocking the Electron main process.
function defaultRunWsl(args, onOutput) {
return new Promise((resolve) => {
let child;
try {
child = childProcess.spawn("wsl.exe", args, { windowsHide: true });
} catch (error) {
resolve({ status: -1, stdout: "", stderr: "", error: error.message || String(error) });
return;
}
let stdout = "";
let stderr = "";
const forward = (stream, sink) => {
stream.on("data", (chunk) => {
const text = decodeWslOutput(chunk);
if (sink === "stdout") stdout += text; else stderr += text;
if (typeof onOutput === "function") onOutput(text);
});
};
forward(child.stdout, "stdout");
forward(child.stderr, "stderr");
child.on("error", (error) => {
resolve({ status: -1, stdout, stderr, error: error.message || String(error) });
});
child.on("close", (code) => {
resolve({ status: code == null ? -1 : code, stdout, stderr, error: null });
});
});
}
// Ensure the private WSL2 distribution exists, importing it from the bundled
// rootfs on first run. Resolves with a plain status object; never throws.
//
// options:
// platform - override process.platform (tests)
// resourcesPath - Electron process.resourcesPath (where the rootfs ships)
// appDataPath - %LOCALAPPDATA% (where the distribution is imported)
// distro - distribution name override
// rootfsPath - explicit rootfs path (tests)
// run - spawner override, signature run(args, onOutput) -> Promise
// onProgress - callback receiving { phase, ... } as work proceeds
async function ensureWSLRuntime(options) {
const opts = options || {};
const platform = opts.platform || process.platform;
const distro = opts.distro || DEFAULT_DISTRO;
const run = opts.run || defaultRunWsl;
const onProgress = typeof opts.onProgress === "function" ? opts.onProgress : function () {};
if (platform !== "win32") {
return { ok: true, status: "skipped", reason: "not-windows", distro: distro };
}
const rootfs = opts.rootfsPath || findRootfsTarball(opts.resourcesPath || "");
if (!rootfs) {
return {
ok: false,
status: "missing-rootfs",
distro: distro,
error: "Bundled WSL runtime archive not found under " + (opts.resourcesPath || "resources")
};
}
onProgress({ phase: "checking", distro: distro });
const listed = await run(listArgs());
if (listed && listed.status === 0 && distroIsRegistered(listed.stdout, distro)) {
onProgress({ phase: "ready", distro: distro });
return { ok: true, status: "already-present", distro: distro, rootfs: rootfs };
}
const target = distroInstallDir(opts.appDataPath || "", distro);
try {
fs.mkdirSync(path.dirname(target), { recursive: true });
} catch (error) {
return {
ok: false,
status: "error",
distro: distro,
error: "Cannot create install directory: " + (error.message || String(error))
};
}
onProgress({ phase: "importing", distro: distro, rootfs: rootfs, target: target });
const imported = await run(importArgs(rootfs, target, distro), (text) => {
onProgress({ phase: "importing", distro: distro, output: text });
});
if (!imported || imported.status !== 0) {
return {
ok: false,
status: "import-failed",
distro: distro,
target: target,
error: (imported && (imported.error || imported.stderr || imported.stdout)) || "wsl --import failed"
};
}
onProgress({ phase: "ready", distro: distro, target: target });
return { ok: true, status: "imported", distro: distro, target: target, rootfs: rootfs };
}
module.exports = {
DEFAULT_DISTRO,
DEFAULT_PREFIX,
ROOTFS_BASENAME,
rootfsCandidates,
findRootfsTarball,
distroInstallDir,
decodeWslOutput,
parseDistroList,
distroIsRegistered,
importArgs,
listArgs,
defaultRunWsl,
ensureWSLRuntime
};