Files
TRIMSP/wsl-runtime.js
salman b3e75b2300 Ship the WSL2 runtime in a Squirrel Windows .exe
The Windows installer now bundles the WSL2 engine runtime so the installed
app works out of the box.

- forge.config.js: configure the Squirrel maker (setupExe name, setup icon) and
  require the rootfs for any Windows build, throwing a clear error if
  packaging/wsl2/out/ is empty so the .exe can never ship without its engine.
- wsl-runtime.js: rootfsCandidates now checks the flat resources/ path first,
  which is where forge's extraResource copies the archive (the wsl-runtime/
  subdirectory and the dev-checkout path remain as fallbacks).
- package.json: add package:win / make:win scripts that build the static engine,
  the rootfs, then package/make for win32.
- tests: cover the flat-first rootfs lookup.
- README/CHANGELOG: document the .exe build, its Wine+Mono cross-build
  prerequisite on Linux, and the required-rootfs guard.
2026-10-20 09:42:47 +02:00

213 lines
7.9 KiB
JavaScript

// 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. A
// forge/electron-packager `extraResource` entry is copied *flat* into the
// resources directory (the first candidate); the `wsl-runtime/` subdirectory is
// kept for an explicit directory layout; the last tolerates running from a
// development checkout without repackaging.
function rootfsCandidates(resourcesPath) {
const base = resourcesPath || ".";
return [
path.join(base, ROOTFS_BASENAME),
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
};