diff --git a/README.md b/README.md index fec64cb..5c1eb46 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,16 @@ the Github Actions Nightly workflow. It can only be run against `develop`. ## Synthetic mock daemons (UI development) -To run the UI without native client daemons, start the HTTP mock servers in [`synthetic-daemons/`](synthetic-daemons/) and launch Electros with `--no-daemons`. See [synthetic-daemons/README.md](synthetic-daemons/README.md) for ports, fixtures, and verification steps. +To run the UI with HTTP mock servers instead of native client daemons, use `--synthetic-daemons` so Electros starts [`synthetic-daemons/`](synthetic-daemons/) via `npm start` alongside the GUI. See [synthetic-daemons/README.md](synthetic-daemons/README.md) for ports, fixtures, and verification steps. + +```bash +cd synthetic-daemons && npm install # once +cd electros-electron && npm start -- --synthetic-daemons +``` + +From the **Developer** menu you can switch at runtime between **Use Native Daemons** (`CmdOrCtrl+Shift+Alt+N`) and **Use Synthetic Daemons** (`CmdOrCtrl+Shift+Alt+S`) in unpackaged builds or when `--enable-devtools` is set. + +To run mocks in a separate terminal instead: ```bash cd synthetic-daemons && npm install && npm start @@ -34,7 +43,8 @@ cd electros-electron && npm start -- --no-daemons Electros has a set of custom command line switches other than the Electron switches: - `--enable-devtools` enables the devtools for the application -- `--no-daemons` disables the execution of the embedded daemons (use with synthetic-daemons above) +- `--synthetic-daemons` launches [`synthetic-daemons/`](synthetic-daemons/) via `npm start` instead of the native client daemons (unpackaged/dev only) +- `--no-daemons` disables the execution of the embedded daemons (use when running synthetic-daemons yourself) > [!NOTE] > On macOS, to add a CLI switch, you'll have to append them directly onto the executable: diff --git a/electros-electron/common/Daemons.js b/electros-electron/common/Daemons.js index 9d0a360..5d886ac 100644 --- a/electros-electron/common/Daemons.js +++ b/electros-electron/common/Daemons.js @@ -12,34 +12,154 @@ export class DaemonsNotEnabledError extends Error { export class Daemons { static _Process = null; static _Ports = {}; + static _IsSynthetic = false; + static _ElectronDir = null; + static _Platform = null; + static _SwitchSettleMs = 750; static _DaemonsLogArray = []; static BUFFER_SIZE = 2000; static DataUpdateCriticalHook = null; - static Launch(platform, __dirname) { - if (app.commandLine.hasSwitch("no-daemons")) { + /** + * @param {object} platform + * @param {string} __dirname + * @param {{ synthetic?: boolean }} [options] + */ + static Launch(platform, __dirname, options = {}) { + Daemons._ElectronDir = __dirname; + Daemons._Platform = platform; + + if (app.commandLine.hasSwitch("no-daemons") && !options.synthetic) { console.log("Daemons have been disabled by `--no-daemons`"); Terminal.Write("[INFO] Elemento Client Daemons have been disabled by `--no-daemons`."); throw new DaemonsNotEnabledError(); } + const useSynthetic = options.synthetic === true + || app.commandLine.hasSwitch("synthetic-daemons"); + + if (useSynthetic) { + Daemons._LaunchSynthetic(__dirname); + return; + } + + Daemons._LaunchNative(platform, __dirname); + } + + /** + * Switch to synthetic-daemons via `npm start` (Developer menu). + * Terminates any existing daemon process first. + * @param {string} [electronDir] + */ + static async LaunchSynthetic(electronDir) { + const dir = electronDir || Daemons._ElectronDir; + if (!dir) { + throw new Error("LaunchSynthetic requires electronDir (call Daemons.Launch first, or pass __dirname)"); + } + await Daemons._SwitchTo(() => Daemons._LaunchSynthetic(dir)); + } + + /** + * Switch to native client daemons (Developer menu). + * Terminates any existing daemon process first; ignores --no-daemons / --synthetic-daemons. + * @param {object} [platform] + * @param {string} [electronDir] + */ + static async LaunchNative(platform, electronDir) { + const dir = electronDir || Daemons._ElectronDir; + const plat = platform || Daemons._Platform; + if (!dir || !plat) { + throw new Error("LaunchNative requires platform and electronDir (call Daemons.Launch first)"); + } + await Daemons._SwitchTo(() => Daemons._LaunchNative(plat, dir)); + } + + static IsSynthetic() { + return Daemons._IsSynthetic; + } + + static IsRunning() { + return Daemons._Process !== null && !Daemons._Process.killed; + } + + static async _SwitchTo(launchFn) { + Daemons.Terminate(); + // Allow ports to be released before binding the other daemon set + await new Promise((resolve) => setTimeout(resolve, Daemons._SwitchSettleMs)); + launchFn(); + } + + static _LaunchNative(platform, __dirname) { if (!app.isPackaged) { Terminal.Write("[WARN] Electros is not packaged. Daemons might have to be manually started."); console.warn("Electros is not packaged. Daemons might have to be manually started."); } const execPath = Daemons._GetCommand(platform, __dirname); + if (!execPath) { + const msg = "[ERROR] Native daemon binary not found for this platform."; + console.error(msg); + Terminal.Write(msg); + throw new Error(msg); + } + + Terminal.Write(`[INFO] Starting native client daemons (${execPath})`); + console.log("Launching native daemons from", execPath); console.trace(execPath); - Daemons._Process = spawn( - execPath, [], { - env: {...process.env, GUI_APP: '1'}, - stdio: ['pipe', 'pipe', 'pipe'], - detached: false - } - ); + Daemons._ElectronDir = __dirname; + Daemons._Platform = platform; + Daemons._IsSynthetic = false; + Daemons._SpawnProcess(execPath, [], { + env: {...process.env, GUI_APP: '1'}, + stdio: ['pipe', 'pipe', 'pipe'], + detached: false + }); + } + + static _LaunchSynthetic(__dirname) { + if (app.isPackaged) { + const msg = "[ERROR] Synthetic daemons are only available in unpackaged (dev) builds."; + console.error(msg); + Terminal.Write(msg); + throw new Error("Synthetic daemons unavailable in packaged builds"); + } + + const syntheticRoot = Daemons._GetSyntheticPath(__dirname); + const packageJson = path.join(syntheticRoot, 'package.json'); + if (!fs.existsSync(packageJson)) { + const msg = `[ERROR] synthetic-daemons not found at ${syntheticRoot}`; + console.error(msg); + Terminal.Write(msg); + throw new Error(msg); + } + + Terminal.Write(`[INFO] Starting synthetic-daemons via npm start (${syntheticRoot})`); + console.log("Launching synthetic-daemons from", syntheticRoot); + + const isWin = process.platform === 'win32'; + const npmCmd = isWin ? 'npm.cmd' : 'npm'; + + Daemons._ElectronDir = __dirname; + Daemons._IsSynthetic = true; + Daemons._SpawnProcess(npmCmd, ['start'], { + cwd: syntheticRoot, + env: {...process.env, GUI_APP: '1'}, + stdio: ['pipe', 'pipe', 'pipe'], + shell: isWin, + // New process group on Unix so Terminate can kill npm + node children + detached: !isWin, + }); + } + + static _GetSyntheticPath(electronDir) { + return path.join(electronDir, '..', 'synthetic-daemons'); + } + + static _SpawnProcess(command, args, spawnOptions) { + Daemons._Process = spawn(command, args, spawnOptions); Daemons._Process.stdout.on("data", (data) => { this._DaemonsLogArray.push(data); @@ -75,12 +195,22 @@ export class Daemons { execSync(`taskkill /pid ${Daemons._Process.pid} /T /F`); } catch (e) { console.error("Failed to kill process with taskkill:", e); + r = false; + } + } else if (Daemons._IsSynthetic && Daemons._Process.pid) { + // Kill the whole process group (npm + node dist/index.js) + try { + process.kill(-Daemons._Process.pid, 'SIGTERM'); + } catch (e) { + console.error("Failed to kill synthetic-daemons process group:", e); + r = Daemons._Process.kill(); } } else { r = Daemons._Process.kill(); } } Daemons._Process = null; + Daemons._IsSynthetic = false; } return r; @@ -153,4 +283,3 @@ export class Daemons { return daemonsCmd; } } - diff --git a/electros-electron/common/MenuBar.js b/electros-electron/common/MenuBar.js index 859eca6..11b6cde 100644 --- a/electros-electron/common/MenuBar.js +++ b/electros-electron/common/MenuBar.js @@ -3,6 +3,14 @@ import { Terminal } from "../windows/Terminal.js"; import {app, Notification} from "electron"; +function notify(title, body, urgency = 'low') { + if (!Notification.isSupported()) { + return; + } + new Notification({title, body, silent: true, urgency}).show(); +} + + export function BuildMenuTemplate() { const baseMenu = [ { @@ -75,31 +83,46 @@ export function BuildMenuTemplate() { baseMenu.push({ label: 'Developer', submenu:[ + { + label: 'Use Native Daemons', + accelerator: 'CmdOrCtrl+Shift+Alt+N', + click: async () => { + console.log("switch to native daemons triggered"); + try { + await Daemons.LaunchNative(); + notify("Native Daemons Started", "Switched to native client daemons."); + } catch (e) { + console.error("Failed to launch native daemons:", e); + notify("Failed to Launch Native Daemons", e?.message || "Could not start native daemons.", 'normal'); + } + } + }, + { + label: 'Use Synthetic Daemons', + accelerator: 'CmdOrCtrl+Shift+Alt+S', + click: async () => { + console.log("switch to synthetic daemons triggered"); + try { + await Daemons.LaunchSynthetic(); + notify("Synthetic Daemons Started", "Switched to synthetic-daemons (npm start)."); + } catch (e) { + console.error("Failed to launch synthetic daemons:", e); + notify("Failed to Launch Synthetic Daemons", e?.message || "Could not start synthetic-daemons.", 'normal'); + } + } + }, { label: 'Terminate Daemons', click: async () => { console.log("manual daemon termination triggered"); - if(Daemons.Terminate()) { - if(Notification.isSupported()) { - new Notification({ - title: "Daemons Terminated", - body: "Electros Client Daemons successfully terminated.", - silent: true, - urgency: 'low' - }).show(); - } + if (Daemons.Terminate()) { + notify("Daemons Terminated", "Electros Client Daemons successfully terminated."); } else { - if(Notification.isSupported()) { - new Notification({ - title: "Failed to Terminate Daemons", - body: "Electros Client Daemons were not terminated.", - silent: true, - urgency: 'low' - }).show(); - } + notify("Failed to Terminate Daemons", "Electros Client Daemons were not terminated.", 'low'); } } }, + {type: 'separator'}, {label: 'Toggle DevTools', role: 'toggleDevTools'}, {label: 'Toggle Fullscreen', role: 'toggleFullScreen'}, ] diff --git a/electros-electron/main.js b/electros-electron/main.js index d143c6c..c099d46 100644 --- a/electros-electron/main.js +++ b/electros-electron/main.js @@ -61,7 +61,7 @@ function createMainWindow() { } // Inject custom titlebar after the page loads - win.webContents.once('did-finish-load', () => { + win.webContents.on('did-finish-load', () => { try { const safeJS = typeof PreloadedContent.Js.Titlebar === 'string' ? PreloadedContent.Js.Titlebar @@ -172,7 +172,7 @@ ipcMain.handle('create-popup', async (event, options = {}) => { try { // Inject custom titlebar CSS and HTML before loading the URL if (!options.defaultTitlebar) { - popup.webContents.once('did-finish-load', () => { + popup.webContents.on('did-finish-load', () => { const popupTitlebarJS = PreloadedContent.Js.Titlebar.replace( 'titleElement.textContent = document.title;', `titleElement.textContent = ${JSON.stringify(options.title)};` @@ -397,7 +397,7 @@ ipcMain.handle('open-ssh', async (event, connectionDetails) => { event.sender.ssh_port = ssh_port; - sshWindow.webContents.once('did-finish-load', () => { + sshWindow.webContents.on('did-finish-load', () => { const sshTitlebarJS = PreloadedContent.Js.Titlebar.replace( 'titleElement.textContent = document.title;', `titleElement.textContent = "SSH connection to ${connectionDetails.vmName}";` diff --git a/electros-electron/package.json b/electros-electron/package.json index e0c3173..1a02e85 100644 --- a/electros-electron/package.json +++ b/electros-electron/package.json @@ -14,7 +14,7 @@ }, "scripts": { "synthetic-daemons": "npm run start --prefix ../synthetic-daemons", - "start": "concurrently --kill-others --names \"vite,electron\" -c \"green,yellow\" -l 10 --pad-prefix \"vite\" \"electron . --trace-warnings --v=1\"", + "start": "concurrently --kill-others --names \"vite,electron\" -c \"green,yellow\" -l 10 --pad-prefix -P \"vite\" \"electron . --trace-warnings --v=1 {@}\" --", "dev": "vite", "build": "node build.mjs", "build:nosign": "node build.mjs --config.mac.identity=null", diff --git a/elemento-gui-new b/elemento-gui-new index 7084abd..44beff0 160000 --- a/elemento-gui-new +++ b/elemento-gui-new @@ -1 +1 @@ -Subproject commit 7084abd3ee6cc1f4fa34ce1f40e020242972ae25 +Subproject commit 44beff0e585265544213f2b90783a463da567d25 diff --git a/synthetic-daemons/README.md b/synthetic-daemons/README.md index d364aab..ea9baa7 100644 --- a/synthetic-daemons/README.md +++ b/synthetic-daemons/README.md @@ -4,6 +4,18 @@ Standalone mock servers that emulate Elemento **client daemons** on the ECD loca ## Quick start +One-command demo (Electros spawns this package via `npm start`): + +```bash +cd synthetic-daemons && npm install # once +cd ../electros-electron +npm start -- --synthetic-daemons +``` + +Or from the Electros **Developer** menu: **Use Synthetic Daemons** (`CmdOrCtrl+Shift+Alt+S`) / **Use Native Daemons** (`CmdOrCtrl+Shift+Alt+N`) to switch at runtime. + +### Manual (two terminals) + ```bash cd synthetic-daemons npm install @@ -130,5 +142,6 @@ npm run build # compile only From `electros-electron`: ```bash -npm run synthetic-daemons +npm start -- --synthetic-daemons # GUI + mocks together +npm run synthetic-daemons # mocks only ```