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.
213 lines
7.9 KiB
JavaScript
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
|
|
};
|