Files
TRIMSP/TrimSPelec.js
salman db390d3969 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.
2026-10-20 09:31:25 +02:00

536 lines
17 KiB
JavaScript

// This file contains function that are Electron/Node.JS specific
// Load required packages
const Plotly = require('plotly.js-dist');
// Electron wraps every renderer <script> in its own CommonJS module scope, so
// the bundled Plotly object must be exported to the global scope explicitly.
// myplots.js and TrimSPlib.js call Plotly.plot/relayout/purge as a global on
// the main window and inside the Plot-menu popup windows.
window.Plotly = Plotly;
const ipcRenderer = require('electron').ipcRenderer;
const fs = require('fs');
const { execSync, execFile } = require('child_process');
const os = require('os');
const path = require('path');
// Renderer requests for native auxiliary windows stay in the main process.
function openElectronAuxiliaryWindow(kind, options = {}) {
if (kind !== 'quickStart') { return false; }
ipcRenderer.send('openQuickStartWindow', options || {});
return true;
}
window.openElectronAuxiliaryWindow = openElectronAuxiliaryWindow;
// Electron links always point to the public web application rather than a
// machine-local file:// URL, so they can be sent directly to another user.
var TRIMSP_CANONICAL_SHARE_URL = 'http://musruser.psi.ch/TRIMSP-NL/';
function encodeElectronSharedConfiguration(config) {
let json = JSON.stringify(config);
try {
let zipped = require('zlib').gzipSync(require('buffer').Buffer.from(json, 'utf8'));
return {
prefix: '#c=',
payload: zipped.toString('base64')
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
};
} catch (error) {
return { prefix: '#cfg=', payload: encodeURIComponent(json) };
}
}
async function copyWebConfigurationLink() {
let encoded = encodeElectronSharedConfiguration(buildSharedConfiguration());
let url = TRIMSP_CANONICAL_SHARE_URL + encoded.prefix + encoded.payload;
try {
await ipcRenderer.invoke('copyConfigurationLink', url);
showTransientWarning('Configuration link copied.', document.getElementById('shareConfigButton'), 3500);
return true;
} catch (error) {
if (typeof window.prompt === 'function') {
window.prompt('Copy configuration link:', url);
return true;
}
showTransientWarning('Could not copy configuration link.', document.getElementById('shareConfigButton'), 3500);
return false;
}
}
function getFiles(dir, filelist){
fileList = [];
var files = fs.readdirSync(dir);
for(var i of files){
if (!files.hasOwnProperty(i)) continue;
var name = dir+'/'+files[i];
if (!fs.statSync(name).isDirectory()){
fileList.push(name);
}
}
return 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. On Windows the
// bundled Linux engine is dispatched through WSL2 (see prepareTrimspLaunch).
function executeFileAsync(binary, args, workdir) {
return new Promise((resolve, reject) => {
let launch = prepareTrimspLaunch(binary, args);
execFile(launch.command, launch.args, {cwd: workdir}, (err) => {
if (err) {
reject(err);
return;
}
resolve();
});
});
}
// Use the available CPU parallelism as the default worker-pool size for local
// scan jobs. The caller still caps this against the number of scan points.
function getParallelJobCount() {
if (typeof os.availableParallelism === 'function') {
return Math.max(1, os.availableParallelism());
}
return Math.max(1, os.cpus().length);
}
function checkDir(directory) {
// Check whether directory exists, if not create it
if (typeof directory !== 'string' || directory.length === 0) {
console.log('Invalid directory passed to checkDir:', directory);
return(0);
}
try {
fs.mkdirSync(directory, { recursive: true });
} catch (err) {
alert('Cannot create data folder '+directory);
return(0);
}
return(1);
}
function writeAsciiFile(filename,content) {
// Write string content into ascii file filename
try { fs.writeFileSync(filename, content, 'utf-8'); }
catch(e) {
alert('Failed to save '+filename);
return(0);
}
return(1);
}
function readAsciiFile(filename) {
// Read ascii file filename and return content in a string
var content;
// If an array of filenames was sent take the first one only
if (Array.isArray(filename)) {filename=filename[0];}
try {
content = fs.readFileSync(filename, 'utf8');
} catch(e) {
alert('Failed to read '+filename);
return(0);
}
return(content);
}
// Async counterparts used by the shared scan pipeline. Local disk IO is fast,
// but returning promises keeps the call sites identical across web and Electron.
async function writeAsciiFileAsync(filename,content) {
try {
await fs.promises.writeFile(filename, content, 'utf-8');
} catch(e) {
alert('Failed to save '+filename);
return(0);
}
return(1);
}
async function readAsciiFileAsync(filename) {
if (Array.isArray(filename)) {filename=filename[0];}
try {
return await fs.promises.readFile(filename, 'utf8');
} catch(e) {
alert('Failed to read '+filename);
// Match the web backend contract: the scan pipeline awaits this and
// expects a rejected promise, not a falsy value that would later fail
// with a confusing "0.trim is not a function" error.
throw new Error('Failed to read '+filename);
}
}
function fileExists(filename) {
if (typeof filename !== 'string' || filename.length === 0) {
return 0;
}
try {
if (fs.existsSync(filename)) {
return 1;
}
}catch(err) {
console.log(err);
}
return 0;
}
function getTrimBinaryPath(configuredPath) {
const candidates = [];
if (configuredPath) {
candidates.push(path.join(configuredPath, "trimspNL"));
}
if (process.resourcesPath) {
candidates.push(path.join(process.resourcesPath, "bin", "trimspNL"));
candidates.push(path.join(process.resourcesPath, "trimspNL"));
}
candidates.push(path.join(process.cwd(), "trimspNL"));
for (const candidate of candidates) {
if (fileExists(candidate)) {
return candidate;
}
}
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) {
document.getElementById("workPath").value = folder;
if (typeof updateStatusBar === 'function') {
updateStatusBar({ workPath: folder });
}
ipcRenderer.send('updateWorkPath', folder);
try {
// Change process directory
process.chdir(folder);
} catch (err) {
console.log('Failed to change directory:', err);
}
}
}
function browseForFolder() {
ipcRenderer.send('browseFolder');
}
function fallbackBrowseForFolder() {
const picker = document.getElementById("folderPicker");
if (!picker) { return; }
picker.value = '';
picker.click();
}
function getConfigValue(content, key) {
const lines = content.toString().split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('[')) {
continue;
}
const separator = trimmed.indexOf('=');
if (separator === -1) {
continue;
}
const param = trimmed.slice(0, separator).trim();
if (param === key) {
return trimmed.slice(separator + 1).trim();
}
}
return null;
}
function resolveConfigWorkPath(filename, content) {
const configuredWorkPath = getConfigValue(content, 'workPath');
if (!configuredWorkPath) {
return null;
}
if (configuredWorkPath.startsWith('./')) {
return path.resolve(configuredWorkPath);
}
if (path.isAbsolute(configuredWorkPath)) {
return configuredWorkPath;
}
return null;
}
function loadConfigFile(filename, autoRun = false) {
const configPath = Array.isArray(filename) ? filename[0] : filename.toString();
console.log('Open file ' + configPath);
return new Promise((resolve, reject) => {
fs.readFile(configPath, function read(err, data) {
if (err) {
reject(err);
return;
}
setValues(data);
const resolvedWorkPath = resolveConfigWorkPath(configPath, data);
if (resolvedWorkPath) {
setWorkFolder([resolvedWorkPath]);
}
if (autoRun) {
setTimeout(() => {
startSim().then(resolve).catch(reject);
}, 0);
} else {
resolve();
}
});
});
}
async function runStartupQueue(configPaths, autoRun) {
if (!configPaths || configPaths.length === 0) {
return;
}
if (!autoRun) {
await loadConfigFile(configPaths[0], false);
return;
}
for (let i = 0; i < configPaths.length; i++) {
const configPath = configPaths[i];
const queueLabel = `Queue ${i + 1}/${configPaths.length}`;
if (typeof updateStatusBar === 'function') {
updateStatusBar({ run: `${queueLabel}: loading ${path.basename(configPath)}` });
}
await loadConfigFile(configPath, false);
if (typeof updateStatusBar === 'function') {
updateStatusBar({ run: `${queueLabel}: running ${path.basename(configPath)}` });
}
await startSim();
}
if (typeof updateStatusBar === 'function') {
updateStatusBar({ run: `Completed queue of ${configPaths.length} config file(s)` });
}
}
function firstLoad() {
document.getElementById("trimPath").value = process.cwd();
if (typeof syncStatusBar === 'function') {
syncStatusBar();
}
const folderPicker = document.getElementById("folderPicker");
if (folderPicker) {
folderPicker.addEventListener('change', function(event) {
const files = event.target.files;
if (!files || files.length === 0) {
return;
}
const folder = path.dirname(files[0].path);
setWorkFolder([folder]);
});
}
// Catch calls for open file
ipcRenderer.on('openFile', function(event, filename) {
loadConfigFile(filename, false);
});
ipcRenderer.on('openFileAndMaybeRun', function(event, payload) {
if (!payload || !payload.filename) { return; }
loadConfigFile(payload.filename, !!payload.autoRun);
});
// Catch calls for selectfolder
ipcRenderer.on('selectFolder', function(event, foldername) {
setWorkFolder(foldername);
console.log("currentdir",process.cwd());
});
// Catch calls for save as
ipcRenderer.on('saveFile', function(event, filename) {
const workPath = document.getElementById("workPath").value || process.cwd();
if (filename === undefined || filename === null) { return; }
// If filename is empty use default value in the selected work folder
if (filename == '') {filename = path.join(workPath, 'TrimSP.cfg');}
if (path.extname(filename) === '') {
filename += '.cfg';
}
// Get values from all fields and prepare config file
let trimSPcfg=prep_cfg(0);
// Save file to filename
console.log('Save file to '+filename);
try {
fs.writeFileSync(filename, trimSPcfg, 'utf-8');
if (typeof updateStatusBar === 'function') {
updateStatusBar({ lastSave: filename });
}
if (typeof showTransientWarning === 'function') {
showTransientWarning('Saved configuration to ' + filename, document.getElementById('workPath'), 4000);
}
}
catch(e) { alert('Failed to save the file !'); }
});
// Catch calls for plotProf
ipcRenderer.on('plotProf', function(event, filename) {
console.log("filename="+filename);
plotProfiles(filename);
});
// Catch calls for plotFrac
ipcRenderer.on('plotFrac', function(event, filename) {
console.log("filename="+filename);
plotFractions(filename);
});
// Catch calls for plotMean
ipcRenderer.on('plotMean', function(event, filename) {
console.log("filename="+filename);
plotMean(filename);
});
// Catch clicks for Browse button
ipcRenderer.on('browseFolder', function(event, foldername) {
console.log('browseFolder reply', foldername);
setWorkFolder(foldername);
});
ipcRenderer.on('browseFolderFallback', function() {
fallbackBrowseForFolder();
});
}
window.addEventListener('load', async function() {
try {
const startupOptions = await ipcRenderer.invoke('getStartupOptions');
if (startupOptions && startupOptions.configPaths && startupOptions.configPaths.length > 0) {
setTimeout(() => {
runStartupQueue(startupOptions.configPaths, !!startupOptions.autoRun).catch((err) => {
console.log('Failed to run startup queue:', err);
});
}, 0);
}
} catch (err) {
console.log('Failed to read startup options:', err);
}
});