diff --git a/.gitignore b/.gitignore index 498b1a1..23a33a1 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/CHANGELOG.md b/CHANGELOG.md index d5c77a0..75ef0a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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/` 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`, diff --git a/TrimSPelec.js b/TrimSPelec.js index bf57fa5..a5db65e 100644 --- a/TrimSPelec.js +++ b/TrimSPelec.js @@ -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 ; ". 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) { diff --git a/TrimSPlib.js b/TrimSPlib.js index 5fea487..d4a3fc2 100644 --- a/TrimSPlib.js +++ b/TrimSPlib.js @@ -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; } diff --git a/forge.config.js b/forge.config.js index e94709b..a51e296 100644 --- a/forge.config.js +++ b/forge.config.js @@ -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', diff --git a/main.js b/main.js index 4ca9e32..93a76de 100644 --- a/main.js +++ b/main.js @@ -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); diff --git a/package.json b/package.json index ace2401..d05f3d7 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/packaging/wsl2/README.md b/packaging/wsl2/README.md new file mode 100644 index 0000000..2141b31 --- /dev/null +++ b/packaging/wsl2/README.md @@ -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 `/bin/trimspNL` present) and +routes the engine through `wsl.exe`: + +``` +wsl.exe -d trimspNL-runtime -- /opt/trimsp-runtime/bin/trimspNL [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. diff --git a/packaging/wsl2/build-wsl2.sh b/packaging/wsl2/build-wsl2.sh new file mode 100755 index 0000000..2d819f2 --- /dev/null +++ b/packaging/wsl2/build-wsl2.sh @@ -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 < [rng] +EOF diff --git a/tests/wsl-runtime.test.js b/tests/wsl-runtime.test.js new file mode 100644 index 0000000..0e12050 --- /dev/null +++ b/tests/wsl-runtime.test.js @@ -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(); diff --git a/wsl-runtime.js b/wsl-runtime.js new file mode 100644 index 0000000..46db37c --- /dev/null +++ b/wsl-runtime.js @@ -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 [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 [--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 +};