Files
TRIMSP/main.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

659 lines
20 KiB
JavaScript

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);
let quickStartWindow = null;
// Packaged builds retain the class expected by installed desktop launchers.
// A distinct development class prevents npm start from inheriting the icon
// cached for an older system installation of TrimSP.
app.setName(app.isPackaged ? 'TrimSP' : 'TrimSPDev');
if (process.platform === 'linux' && !app.isPackaged) {
// KDE resolves taskbar icons through matching desktop entries and ignores
// Electron's development-window icon. Keep a hidden per-user entry tied
// to this checkout so npm start uses the current repository artwork.
try {
const dataHome = process.env.XDG_DATA_HOME || path.join(app.getPath('home'), '.local', 'share');
const applicationsDir = path.join(dataHome, 'applications');
const desktopFile = path.join(applicationsDir, 'trimsp-dev.desktop');
const executable = process.execPath.replace(/"/g, '\\"');
const projectPath = __dirname.replace(/"/g, '\\"');
const iconPath = path.join(__dirname, 'icon.png');
const desktopContent = [
'[Desktop Entry]',
'Type=Application',
'Name=TRIMSP-NL Workbench (Development)',
`Exec="${executable}" "${projectPath}"`,
`Icon=${iconPath}`,
`X-TrimSP-Icon-MTime=${fs.statSync(iconPath).mtimeMs}`,
'StartupWMClass=TrimSPDev',
'NoDisplay=true',
'Categories=Science;',
'',
].join('\n');
fs.mkdirSync(applicationsDir, {recursive: true});
if (!fs.existsSync(desktopFile) || fs.readFileSync(desktopFile, 'utf8') !== desktopContent) {
fs.writeFileSync(desktopFile, desktopContent, 'utf8');
// KDE caches desktop identities and icons; refresh that user cache only
// when this entry changes. Other desktops safely ignore ENOENT here.
spawnSync('kbuildsycoca6', ['--noincremental'], {stdio: 'ignore'});
}
app.setDesktopName('trimsp-dev.desktop');
} catch (error) {
console.warn('Could not register the development desktop entry:', error);
}
}
function parseStartupOptions(argv) {
const options = {
configPaths: [],
autoRun: false,
};
for (const arg of argv.slice(2)) {
if (arg === '--run') {
options.autoRun = true;
} else if (!arg.startsWith('--')) {
let candidates = [path.resolve(arg)];
if (/[*?]/.test(arg)) {
const resolvedPattern = path.resolve(arg);
const parsed = path.parse(resolvedPattern);
const relativePattern = resolvedPattern.slice(parsed.root.length);
const segments = relativePattern.split(path.sep).filter(Boolean);
candidates = [parsed.root || '.'];
for (const segment of segments) {
const nextCandidates = [];
let matcher = null;
if (/[*?]/.test(segment)) {
let pattern = '^';
for (const char of segment) {
if (char === '*') {
pattern += '.*';
} else if (char === '?') {
pattern += '.';
} else {
pattern += char.replace(/[|\\{}()[\]^$+?.]/g, '\\$&');
}
}
pattern += '$';
matcher = new RegExp(pattern);
}
for (const candidate of candidates) {
if (!matcher) {
const nextPath = path.join(candidate, segment);
if (typeof nextPath === 'string' && fs.existsSync(nextPath)) {
nextCandidates.push(nextPath);
}
continue;
}
if (typeof candidate !== 'string' || !fs.existsSync(candidate) || !fs.statSync(candidate).isDirectory()) {
continue;
}
const entries = fs.readdirSync(candidate).sort();
for (const entry of entries) {
if (matcher.test(entry)) {
nextCandidates.push(path.join(candidate, entry));
}
}
}
candidates = nextCandidates;
if (candidates.length === 0) {
break;
}
}
candidates = candidates
.filter((candidate) => typeof candidate === 'string' && fs.existsSync(candidate) && fs.statSync(candidate).isFile())
.sort();
}
for (const candidate of candidates) {
if (
typeof candidate === 'string' &&
path.extname(candidate).toLowerCase() === '.cfg' &&
fs.existsSync(candidate) &&
fs.statSync(candidate).isFile() &&
!options.configPaths.includes(candidate)
) {
options.configPaths.push(candidate);
}
}
}
}
return options;
}
function showAboutDialog(parentWindow) {
const detail = [
'Graphical workbench for low-energy muon and ion implantation simulations.',
`Workbench version: ${app.getVersion()}`,
'License: GNU General Public License v3 or later (GPL-3.0-or-later).',
'Maintained by the Low Energy Muons group at the Paul Scherrer Institute.',
'Main contributor: Zaher Salman.',
'',
'TRIM.SP-NL is the generalized multilayer Fortran transport engine.',
'TRIMSP-NL Workbench combines the engine with the GUI and scan workflow.',
'',
'If you use this software in published work, please cite the relevant',
'TRIM.SP references and the TRIMSP-NL Workbench paper.',
'',
'Key references:',
'Z. Salman, R. M. L. McFadden, and T. Prokscha, Modernization and',
'Statistical Validation of a Multilayer TRIM.SP Code for Low-Energy Muon',
'and Ion Implantation.',
'J. P. Biersack and W. Eckstein, Appl. Phys. A 34, 73-94 (1984).',
'W. Eckstein, Computer Simulation of Ion-Solid Interactions (1991).',
'E. Morenzoni et al., NIM B 192, 245-266 (2002).',
'',
'Project repository:',
'Gitea: gitea.psi.ch/LMU/TRIMSP',
'Web application: musruser.psi.ch/TRIMSP-NL/'
].join('\n');
dialog.showMessageBox(parentWindow, {
type: 'info',
title: 'About TRIMSP-NL Workbench',
message: 'TRIMSP-NL Workbench',
detail: detail,
buttons: ['OK'],
icon: parentWindow ? undefined : undefined
});
}
// Some Linux/remote desktop setups expose a broken GPU context to Electron.
// Fall back to software rendering instead of crashing on startup.
app.disableHardwareAcceleration();
if (process.platform === 'linux') {
// The XDG portal dialog backend ignores defaultPath on some systems and
// may cancel folder selection entirely in X11/SSH sessions.
app.commandLine.appendSwitch('xdg-portal-required-version', '999');
}
// Add more robust flags for packaged builds in remote/Xwayland environments
if (app.isPackaged) {
try {
app.commandLine.appendSwitch('disable-gpu');
app.commandLine.appendSwitch('disable-gpu-compositing');
app.commandLine.appendSwitch('disable-accelerated-2d-canvas');
app.commandLine.appendSwitch('disable-accelerated-video-decode');
app.commandLine.appendSwitch('disable-dev-shm-usage');
app.commandLine.appendSwitch('disable-features', 'VaapiVideoDecoder,VaapiVideoEncoder,VizDisplayCompositor');
} catch (e) {
console.log('could not append packaged startup switches:', e);
}
}
function getDefaultDialogPath() {
return currentWorkPath || process.cwd();
}
// Remember the folder in which the last file dialog opened or saved, so the
// dialogs reopen where the user actually works rather than at an OS default
// (or always at process.cwd()). Shared by every dialog type, stored in the
// app's userData dir, and durable across restarts; falls back to the current
// work path.
const DIALOG_FOLDERS_FILE = path.join(app.getPath('userData'), 'dialog-folders.json');
let dialogFolders = null;
function loadDialogFolders() {
if (dialogFolders === null) {
try {
dialogFolders = JSON.parse(fs.readFileSync(DIALOG_FOLDERS_FILE, 'utf8'));
} catch (err) {
dialogFolders = {};
}
}
return dialogFolders;
}
function rememberLastDialogFolder(folder) {
if (!folder) return;
loadDialogFolders();
dialogFolders.lastUsed = folder;
try {
fs.writeFileSync(DIALOG_FOLDERS_FILE, JSON.stringify(dialogFolders, null, 2));
} catch (err) {
console.log('could not save dialog folder memory:', err);
}
}
function rememberLastDialogFolderFromFiles(filePaths) {
if (!filePaths || filePaths.length === 0) return;
rememberLastDialogFolder(path.dirname(filePaths[0]));
}
function lastDialogFolder() {
const fallback = getDefaultDialogPath();
const saved = loadDialogFolders().lastUsed;
if (saved && fs.existsSync(saved)) return saved;
return fallback;
}
function getWindowIconPath() {
if (app.isPackaged) {
return path.join(process.resourcesPath, 'app.asar', 'icon.png');
}
return path.join(__dirname, 'icon.png');
}
function showQuickStartWindow(sectionId = 'helpLayers') {
if (quickStartWindow && !quickStartWindow.isDestroyed()) {
quickStartWindow.show();
quickStartWindow.focus();
quickStartWindow.webContents.send('quickStartSection', { sectionId: sectionId, version: app.getVersion() });
return;
}
quickStartWindow = new BrowserWindow({
title: 'TRIMSP-NL Quick Start',
width: 720,
height: 680,
minWidth: 480,
minHeight: 360,
resizable: true,
autoHideMenuBar: true,
icon: nativeImage.createFromPath(getWindowIconPath()),
webPreferences: {
contextIsolation: false,
nodeIntegration: true,
}
});
quickStartWindow.webContents.once('did-finish-load', () => {
quickStartWindow.webContents.send('quickStartSection', { sectionId: sectionId, version: app.getVersion() });
});
quickStartWindow.loadFile('quick-start.html');
quickStartWindow.on('closed', () => {
quickStartWindow = null;
});
}
function createWindow () {
const windowIcon = nativeImage.createFromPath(getWindowIconPath());
const win = new BrowserWindow({
width: 1000,
height: 610,
icon: windowIcon,
webPreferences: {
contextIsolation: false,
nodeIntegration: true,
nativeWindowOpen: true,
enableRemoteModule: true,
}
})
// Pass a native image explicitly for Linux panels that can use the window
// icon directly. KDE resolves the development icon through the matching
// desktop entry registered above.
if (process.platform === 'linux' && !windowIcon.isEmpty()) {
win.setIcon(windowIcon);
}
let template = [
{
label: 'File',
submenu: [
{
label: 'Open',
id : 'openItem',
accelerator: 'CmdOrCtrl+O',
click () {
dialog.showOpenDialog(win,
{ title : "Load configuration file",
defaultPath : lastDialogFolder(),
//buttonLabel : "Custom button",
filters :[
{name: 'Config file type', extensions: ['cfg']},
],
properties: ['openFile']}
).then(result => {
console.log(result.canceled);
console.log(result.filePaths);
if (!result.canceled) {
rememberLastDialogFolderFromFiles(result.filePaths);
setImmediate(function() {
var focusedWindow = win;
focusedWindow.webContents.send('openFile',result.filePaths);
});
}
}).catch(err => {
console.log(err);
})
}
},
{
label: 'Save Folder...',
accelerator: 'CmdOrCtrl+F',
click () {
dialog.showOpenDialog(win,
{ title: "Select folder",
defaultPath : lastDialogFolder(),
properties:["openDirectory"]}
).then(result => {
setImmediate(function() {
console.log(result.filePaths)
if (!result.canceled) {
rememberLastDialogFolder(result.filePaths[0]);
}
var focusedWindow = win;
focusedWindow.webContents.send('selectFolder',result.filePaths);
});
}).catch(err => {
console.log(err);
})
}
},
{
label: 'Save',
accelerator: 'CmdOrCtrl+S',
click () {
setImmediate(function() {
var focusedWindow = win;
focusedWindow.webContents.send('saveFile','');
});
}
},
{
label: 'Save As...',
accelerator: 'CmdOrCtrl+Shift+S',
click () {
dialog.showSaveDialog(win,
{ title : "Save configuration file",
defaultPath : path.join(lastDialogFolder(), 'TrimSP.cfg'),
filters :[
{name: 'Config file type', extensions: ['cfg']},
{name: 'All Files', extensions: ['*']}
],
properties: ['showOverwriteConfirmation']}
).then(result => {
if (result.canceled || !result.filePath) {
return;
}
rememberLastDialogFolder(path.dirname(result.filePath));
setImmediate(function() {
var focusedWindow = win;
focusedWindow.webContents.send('saveFile',result.filePath);
});
}).catch(err => {
console.log(err);
})
}
},
{
label: 'Print',
accelerator: 'CmdOrCtrl+P',
click () {
setImmediate(function() {
var focusedWindow = win;
const options = {};
focusedWindow.webContents.print(options, (success, errorType) => {
if (!success) console.log(errorType)
});
});
}
},
{
role: 'quit'
}
]
},
{
role: 'editMenu'
},
{
label: 'Plot',
submenu: [
{
label: 'Plot Profiles',
//accelerator: 'CmdOrCtrl+P P',
click () {
dialog.showOpenDialog(win,
{ title : "Select rge files",
defaultPath : lastDialogFolder(),
filters :[
{name: 'Profile file type', extensions: ['rge']},
{name: 'All Files', extensions: ['*']}
],
properties: ['openFile', 'multiSelections']}
).then(result => {
console.log(result.canceled);
console.log(result.filePaths);
if (!result.canceled) {
rememberLastDialogFolderFromFiles(result.filePaths);
setImmediate(function() {
var focusedWindow = win;
focusedWindow.webContents.send('plotProf',result.filePaths);
});
}
}).catch(err => {
console.log(err);
})
}
},
{
label: 'Plot Fractions',
//accelerator: 'CmdOrCtrl+P F',
click () {
dialog.showOpenDialog(win,
{ title : "Select sequence file",
defaultPath : lastDialogFolder(),
filters :[
{name: 'Sequence file type', extensions: ['dat']},
{name: 'All Files', extensions: ['*']}
],
properties: ['openFile']}
).then(result => {
console.log(result.canceled);
console.log(result.filePaths);
if (!result.canceled) {
rememberLastDialogFolderFromFiles(result.filePaths);
setImmediate(function() {
var focusedWindow = win;
focusedWindow.webContents.send('plotFrac',result.filePaths);
});
}
}).catch(err => {
console.log(err);
})
}
},
{
label: 'Plot Mean',
//accelerator: 'CmdOrCtrl+P M',
click () {
dialog.showOpenDialog(win,
{ title : "Select sequence file",
defaultPath : lastDialogFolder(),
filters :[
{name: 'Sequence file type', extensions: ['dat']},
{name: 'All Files', extensions: ['*']}
],
properties: ['openFile']}
).then(result => {
console.log(result.canceled);
console.log(result.filePaths);
if (!result.canceled) {
rememberLastDialogFolderFromFiles(result.filePaths);
setImmediate(function() {
var focusedWindow = win;
focusedWindow.webContents.send('plotMean',result.filePaths);
});
}
}).catch(err => {
console.log(err);
})
}
}
]
},
{
role: 'viewMenu'
},
{
role: 'help',
submenu: [
{
label: 'Quick Start',
accelerator: 'F1',
click () { showQuickStartWindow(); }
},
{
label: 'About TRIMSP-NL Workbench',
click () { showAboutDialog(win); }
}
]
}
]
let menu = Menu.buildFromTemplate(template)
Menu.setApplicationMenu(menu)
win.loadFile('TrimSP.html');
// Comment the following line to start without Dev
// win.openDevTools();
}
// 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);
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit()
}
})
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow()
}
})
// Reply to calls from browser button
ipcMain.on('browseFolder', (event, args) => {
console.log('received a message: '+args);
const senderWindow = BrowserWindow.fromWebContents(event.sender);
dialog.showOpenDialog(senderWindow, { title: "Select folder",
defaultPath : lastDialogFolder(),
properties:["openDirectory"]}
).then(result => {
console.log(result)
if (result.canceled || result.filePaths.length === 0) {
event.reply('browseFolderFallback');
return;
}
rememberLastDialogFolder(result.filePaths[0]);
event.reply('browseFolder', result.filePaths);
app.setPath('temp',result.filePaths[0]);
}).catch(err => {
console.log(err);
})
});
ipcMain.on('updateWorkPath', (event, folder) => {
if (folder) {
currentWorkPath = folder;
}
});
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);
});
ipcMain.handle('copyConfigurationLink', async (event, url) => {
if (typeof url !== 'string' || url.length > 500000
|| !url.startsWith('http://musruser.psi.ch/TRIMSP-NL/')) {
throw new Error('Invalid configuration link');
}
clipboard.writeText(url);
return true;
});
//ipcMain.on('browseFolder-send', (event, args) => {
// dialog.showOpenDialog(null, args).then(filePaths => {
// event.sender.dend('browseFolder', filePaths);
// }).catch(err => {
// console.log(err);
// })
//});
//menuItem = menu.getMenuItemById('openItem');
//console.log(menuItem);