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.
536 lines
17 KiB
JavaScript
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);
|
|
}
|
|
});
|