diff --git a/electron-app/.gitignore b/electron-app/.gitignore index ce4204b7b..2f4d5e1f1 100644 --- a/electron-app/.gitignore +++ b/electron-app/.gitignore @@ -7,6 +7,11 @@ dist-ssr build binaries renderer +!renderer/ +renderer/testing-view/ +renderer/flashing-view/ +!renderer/mode-selector/ +!renderer/mode-selector/** out *.local diff --git a/electron-app/README.md b/electron-app/README.md index d28d055ad..03c363201 100644 --- a/electron-app/README.md +++ b/electron-app/README.md @@ -24,7 +24,7 @@ When running in development mode (unpackaged), the application creates temporary - `binaries/` - Directory containing compiled backend and BLCU programming executables for your platform. These are generated during the build process, when running `pnpm run build`. -- `renderer/` - Directory containing built frontend views (control-station, ethernet-view). These are generated during the build process, when running `pnpm run build`. +- `renderer/` - Directory containing built frontend views (control-station, ethernet-view). These are generated during the build process, when running `pnpm run build`. All its content is ignored, except `mode-selector` - `dist/` - Build output directory containing compiled and packaged application files. Generated during build and distribution processes, when running `pnpm run dist`. @@ -100,9 +100,10 @@ sudo ifconfig lo0 alias 127.0.0.9 up - **Backend Process**: Go backend for data processing - **BLCU Programming Process**: Packaged FastAPI/TFTP API for firmware transfers -- **Packet Sender**: Tool for sending test packets - **Configuration**: TOML-based config management -- **Views**: Multiple frontend interfaces (Competition/Testing) +- **Views**: Multiple frontend interfaces (Competition, Testing & Flashing) + +- **Mode Selector**: html5 file to chose the mode of the app: testing, competition or flashing. Placed at `renderer/mode-selector` ## Dependencies diff --git a/electron-app/main.js b/electron-app/main.js index b5067bd32..6ce31fd17 100644 --- a/electron-app/main.js +++ b/electron-app/main.js @@ -1,121 +1,91 @@ /** * @module main * @description Main entry point for the Electron application. - * Handles application lifecycle, initialization, and cleanup of processes and windows. + * + * Orchestrates application lifecycle and initialization through modular components: + * - initialization: Config, IPC, and process cleanup + * - modeSelector: Mode selection UI and main window creation + * - updater: Auto-update functionality + * - lifecycle: App lifecycle event handling */ -import { app, BrowserWindow, dialog, screen } from "electron"; -import pkg from "electron-updater"; -import { getConfigManager } from "./src/config/configInstance.js"; -import { setupIpcHandlers } from "./src/ipc/handlers.js"; -import { startBackend, stopBackend } from "./src/processes/backend.js"; -import { startBlcuProgramming, stopBlcuProgramming } from "./src/processes/blcuProgramming.js"; +import { app, BrowserWindow, screen } from "electron"; +import { + handleSelectorFallback, + initializeApp, + setTransitionMode, + setupLifecycleHandlers, + setupUpdater, + showModeSelector, +} from "./src/app/index.js"; +import { stopBackend } from "./src/processes/backend.js"; +import { stopBlcuProgramming } from "./src/processes/blcuProgramming.js"; import { logger } from "./src/utils/logger.js"; -import { createLogWindow } from "./src/windows/logWindow.js"; -import { createWindow } from "./src/windows/mainWindow.js"; -const { autoUpdater } = pkg; - -// Setup IPC handlers for renderer process communication -setupIpcHandlers(); - -// App lifecycle: wait for Electron to be ready +/** + * Initializes the application when Electron is ready. + * Orchestrates startup sequence: + * 1. Initialize config and IPC + * 2. Show mode selector + * 3. Setup auto-updater + * 4. Setup lifecycle handlers + */ app.whenReady().then(async () => { - // Get the screen width and height - // Only can be used inside app.whenReady() - const { width: screenWidth, height: screenHeight } = - screen.getPrimaryDisplay().workAreaSize; - - // Initialize ConfigManager and ensure config exists BEFORE starting backend - logger.electron.header("Initializing configuration..."); - // Get ConfigManager instance (creates config from template if needed) - await getConfigManager(); - logger.electron.header("Configuration ready"); - - const logWindow = createLogWindow(screenWidth, screenHeight); - - // Start backend process try { - await startBackend(logWindow); - logger.electron.header("Backend process spawned"); - } catch (error) { - // Start backend already shows these errors - } - - try { - await startBlcuProgramming(logWindow); - logger.electron.header("BLCU programming process spawned"); - } catch (error) { - logger.electron.error("Failed to start BLCU programming:", error); - } - - // Create main application window - const mainWindow = createWindow(screenWidth, screenHeight); - mainWindow.maximize(); - - logger.electron.header("Main application window created"); - - // Updater setup - if (!app.isPackaged) { - autoUpdater.forceDevUpdateConfig = true; - } - - autoUpdater.logger = { - info: (message) => logger.electron.info(message), - error: (message) => logger.electron.error(message), - warn: (message) => logger.electron.warning(message), - debug: (message) => logger.electron.debug(message), - }; - - // Check for updates - autoUpdater.checkForUpdates(); - - // Handle update downloaded event - autoUpdater.on("update-downloaded", (info) => { - dialog - .showMessageBox({ - type: "info", - title: "Update Ready", - message: `Version ${info.version} has been downloaded. Restart now to install?`, - buttons: ["Restart", "Later"], - }) - .then((result) => { - if (result.response === 0) { - autoUpdater.quitAndInstall(); - } - }); - }); - - // Handle macOS app activation (reopen window when dock icon clicked) - app.on("activate", () => { - // Only create window if no windows exist - if (BrowserWindow.getAllWindows().length === 0) { - createWindow(); + // Initialize configuration, IPC, and cleanup + await initializeApp(); + + // Get screen dimensions for window creation + const { width: screenWidth, height: screenHeight } = screen.getPrimaryDisplay().workAreaSize; + + // Register return-to-selector handler before starting the app. + app.on("return-to-selector", async () => { + try { + // Set transition mode to prevent window-all-closed from quitting + setTransitionMode(true); + + logger.electron.info("Returning to selector mode..."); + await Promise.all([stopBackend(), stopBlcuProgramming()]); + + // Give ports time to be released (increased to 3s for TCP TIME_WAIT state) + await new Promise((resolve) => setTimeout(resolve, 3000)); + + const existingWindows = BrowserWindow.getAllWindows().filter((window) => !window.isDestroyed()); + existingWindows.forEach((window) => window.hide()); + + const { width: selectorWidth, height: selectorHeight } = screen.getPrimaryDisplay().workAreaSize; + await showModeSelector(selectorWidth, selectorHeight); + + existingWindows.forEach((window) => { + if (!window.isDestroyed()) { + window.removeAllListeners("close"); + window.close(); + } + }); + + // Exit transition mode once selector is shown + setTransitionMode(false); + } catch (error) { + logger.electron.error("Failed to return to selector:", error); + setTransitionMode(false); + } + }); + + // Show mode selector and get user choice + try { + await showModeSelector(screenWidth, screenHeight); + } catch (error) { + logger.electron.error("Mode selector failed:", error); + await handleSelectorFallback(screenWidth, screenHeight); } - }); -}); -// Handle window close behavior -app.on("window-all-closed", () => { - // On macOS, keep app running even when all windows are closed - if (process.platform !== "darwin") { - // Quit app on other platforms when all windows are closed + // Setup auto-updater + setupUpdater(); + + // Setup application lifecycle handlers (window-all-closed, before-quit, activate, exceptions) + setupLifecycleHandlers(); + } catch (error) { + logger.electron.error("Failed to initialize application:", error); app.quit(); } }); - -// Cleanup before app quits -app.on("before-quit", (e) => { - e.preventDefault(); - Promise.all([stopBackend(), stopBlcuProgramming()]) - .catch((error) => logger.electron.error("Error during shutdown:", error)) - .finally(() => app.exit()); -}); - -// Handle uncaught exceptions globally -process.on("uncaughtException", (error) => { - // Log error to console - logger.electron.error("Uncaught exception:", error); - // Show error dialog to user - dialog.showErrorBox("Error", error.message); -}); diff --git a/electron-app/package.json b/electron-app/package.json index 083a5bd56..58265f789 100644 --- a/electron-app/package.json +++ b/electron-app/package.json @@ -1,6 +1,6 @@ { "name": "hyperloop-control-station", - "version": "1.0.0", + "version": "11.1.0", "description": "Hyperloop UPV Control Station", "main": "main.js", "type": "module", diff --git a/electron-app/preload.js b/electron-app/preload.js index 5fda40215..e9fcb690d 100644 --- a/electron-app/preload.js +++ b/electron-app/preload.js @@ -38,6 +38,13 @@ contextBridge.exposeInMainWorld("electronAPI", { selectFolder: () => ipcRenderer.invoke("select-folder"), // Open a folder path in the OS file explorer openFolder: (path) => ipcRenderer.invoke("open-folder", path), + // Get the application version from the main process + getAppVersion: () => ipcRenderer.invoke("get-app-version"), + // Set initial mode (used by mode selector renderer) + setInitialMode: (mode) => { + ipcRenderer.send("mode-selected", mode); + return Promise.resolve(); + }, // Receive log message from backend onLog: (callback) => { const listener = (_event, value) => callback(value); diff --git a/electron-app/renderer/mode-selector/index.html b/electron-app/renderer/mode-selector/index.html new file mode 100644 index 000000000..e554d12fc --- /dev/null +++ b/electron-app/renderer/mode-selector/index.html @@ -0,0 +1,245 @@ + + + + + + + Seleccione Modo + + + + +
+ Departamento + +
+

Control Station

+
+
+ + +
+
+ Logo auxiliar + +
+
+ + + + \ No newline at end of file diff --git a/electron-app/renderer/mode-selector/src/Subsystem.png b/electron-app/renderer/mode-selector/src/Subsystem.png new file mode 100644 index 000000000..aed1aee91 Binary files /dev/null and b/electron-app/renderer/mode-selector/src/Subsystem.png differ diff --git a/electron-app/renderer/mode-selector/src/logo.svg b/electron-app/renderer/mode-selector/src/logo.svg new file mode 100644 index 000000000..20d20319d --- /dev/null +++ b/electron-app/renderer/mode-selector/src/logo.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/electron-app/renderer/mode-selector/src/sign.png b/electron-app/renderer/mode-selector/src/sign.png new file mode 100644 index 000000000..5771951dd Binary files /dev/null and b/electron-app/renderer/mode-selector/src/sign.png differ diff --git a/electron-app/src/app/cleanup.js b/electron-app/src/app/cleanup.js new file mode 100644 index 000000000..3cc8c8ffc --- /dev/null +++ b/electron-app/src/app/cleanup.js @@ -0,0 +1,33 @@ +/** + * @module app/cleanup + * @description Cleanup utilities for terminating leftover processes from previous sessions. + */ + +import { execSync } from "child_process"; +import { logger } from "../utils/logger.js"; + +/** + * Terminates any leftover backend processes from previous sessions. + * Prevents backend/log windows from appearing before user selects a mode. + * @returns {Promise} + */ +async function cleanupLeftoverBackendProcesses() { + try { + const out = execSync("pgrep -f backend-linux-amd64 || true").toString().trim(); + if (out) { + const pids = out.split(/\s+/).filter(Boolean); + for (const pid of pids) { + try { + process.kill(Number(pid), "SIGTERM"); + logger.electron.info(`Terminated leftover backend pid=${pid}`); + } catch (e) { + logger.electron.debug(`Failed to terminate pid ${pid}:`, e); + } + } + } + } catch (e) { + logger.electron.debug("Error checking/killing leftover backends:", e); + } +} + +export { cleanupLeftoverBackendProcesses }; diff --git a/electron-app/src/app/index.js b/electron-app/src/app/index.js new file mode 100644 index 000000000..5ae193df7 --- /dev/null +++ b/electron-app/src/app/index.js @@ -0,0 +1,11 @@ +/** + * @module app + * @description Application lifecycle and initialization exports. + */ + +export { cleanupLeftoverBackendProcesses } from "./cleanup.js"; +export { initializeApp } from "./initialization.js"; +export { setupLifecycleHandlers, setTransitionMode } from "./lifecycle.js"; +export { handleSelectorFallback, showModeSelector } from "./modeSelector.js"; +export { setupUpdater } from "./updater.js"; + diff --git a/electron-app/src/app/initialization.js b/electron-app/src/app/initialization.js new file mode 100644 index 000000000..3e6195ca0 --- /dev/null +++ b/electron-app/src/app/initialization.js @@ -0,0 +1,31 @@ +/** + * @module app/initialization + * @description Application initialization: config setup and cleanup. + */ + +import { getConfigManager } from "../config/configInstance.js"; +import { setupIpcHandlers } from "../ipc/handlers.js"; +import { logger } from "../utils/logger.js"; +import { cleanupLeftoverBackendProcesses } from "./cleanup.js"; + +/** + * Initializes the application: + * - Sets up IPC handlers + * - Initializes configuration + * - Cleans up leftover processes + * @returns {Promise} + */ +async function initializeApp() { + // Setup IPC handlers for renderer process communication + setupIpcHandlers(); + + // Initialize ConfigManager and ensure config exists + logger.electron.header("Initializing configuration..."); + await getConfigManager(); + logger.electron.header("Configuration ready"); + + // Clean up leftover processes from previous sessions + await cleanupLeftoverBackendProcesses(); +} + +export { initializeApp }; diff --git a/electron-app/src/app/lifecycle.js b/electron-app/src/app/lifecycle.js new file mode 100644 index 000000000..acad03e7b --- /dev/null +++ b/electron-app/src/app/lifecycle.js @@ -0,0 +1,67 @@ +/** + * @module app/lifecycle + * @description Application lifecycle event handlers. + */ + +import { app, BrowserWindow, dialog } from "electron"; +import { stopBackend } from "../processes/backend.js"; +import { stopBlcuProgramming } from "../processes/blcuProgramming.js"; +import { logger } from "../utils/logger.js"; +import { createWindow } from "../windows/index.js"; + +// Flag to indicate we're in a transition (e.g., returning to selector) +let isInTransition = false; + +/** + * Sets the transition flag + * @param {boolean} inTransition + */ +function setTransitionMode(inTransition) { + isInTransition = inTransition; +} + +/** + * Sets up all application lifecycle event handlers. + * @returns {void} + */ +function setupLifecycleHandlers() { + // Handle window close behavior + app.on("window-all-closed", () => { + // Don't quit if we're in a transition (returning to selector) + if (isInTransition) { + logger.electron.debug("In transition mode - skipping app quit on window-all-closed"); + return; + } + + // On macOS, keep app running even when all windows are closed + if (process.platform !== "darwin") { + // Quit app on other platforms when all windows are closed + app.quit(); + } + }); + + // Cleanup before app quits + app.on("before-quit", (e) => { + e.preventDefault(); + Promise.all([stopBackend(), stopBlcuProgramming()]) + .catch((error) => logger.electron.error("Error during shutdown:", error)) + .finally(() => app.exit()); + }); + + // Handle macOS app activation (reopen window when dock icon clicked) + app.on("activate", () => { + // Only create window if no windows exist + if (BrowserWindow.getAllWindows().length === 0) { + createWindow(); + } + }); + + // Handle uncaught exceptions globally + process.on("uncaughtException", (error) => { + logger.electron.error("Uncaught exception:", error); + dialog.showErrorBox("Error", error.message); + }); +} + +export { setTransitionMode, setupLifecycleHandlers }; + diff --git a/electron-app/src/app/modeSelector.js b/electron-app/src/app/modeSelector.js new file mode 100644 index 000000000..a64d54fcf --- /dev/null +++ b/electron-app/src/app/modeSelector.js @@ -0,0 +1,171 @@ +/** + * @module app/modeSelector + * @description Mode selector window and logic for initial app mode selection. + */ + +import { BrowserWindow, ipcMain } from "electron"; +import fs from "fs"; +import path from "path"; +import { startBackend } from "../processes/backend.js"; +import { startBlcuProgramming } from "../processes/blcuProgramming.js"; +import { logger } from "../utils/logger.js"; +import { getAppPath } from "../utils/paths.js"; +import { createLogWindow, createWindow } from "../windows/index.js"; +import { loadView } from "../windows/mainWindow.js"; + +const VALID_MODES = { + testing: "testing-view", + flashing: "flashing-view", + default: "testing-view", +}; + +/** + * Creates and displays the mode selector window. + * Returns a Promise that resolves when user selects a mode. + * @param {number} screenWidth + * @param {number} screenHeight + * @returns {Promise<{mode: string, view: string, mainWindow: BrowserWindow}>} + */ +async function showModeSelector(screenWidth, screenHeight) { + return new Promise(async (resolve, reject) => { + let mainWindow = null; + + const selectorWindow = new BrowserWindow({ + width: 920, + height: 680, + useContentSize: true, + resizable: true, + modal: true, + parent: mainWindow, + show: true, + webPreferences: { + preload: path.join(getAppPath(), "preload.js"), + contextIsolation: true, + nodeIntegration: false, + }, + title: "Select Mode", + }); + + const selectorPath = path.join(getAppPath(), "renderer", "mode-selector", "index.html"); + + if (!fs.existsSync(selectorPath)) { + logger.electron.warning("Mode selector UI not found, using default testing-view"); + resolve({ mode: "default", view: VALID_MODES.default, mainWindow: null }); + return; + } + + logger.electron.info(`Mode selector found: ${selectorPath}`); + + try { + await selectorWindow.loadFile(selectorPath); + selectorWindow.show(); + selectorWindow.focus(); + } catch (err) { + logger.electron.error("Failed to load selector UI:", err); + resolve({ mode: "default", view: VALID_MODES.default, mainWindow: null }); + return; + } + + // Listen for mode selection from renderer + ipcMain.once("mode-selected", async (_event, mode) => { + try { + const view = VALID_MODES[mode] || VALID_MODES.default; + + // Create the main window without loading the view yet. + mainWindow = createWindow(screenWidth, screenHeight, null); + try { + mainWindow.maximize(); + } catch (e) {} + logger.electron.header("Main application window created"); + + // Start services and only then load the selected view. + if (view === "testing-view" || view === "flashing-view") { + await startServices(screenWidth, screenHeight, view); + } + + loadView(view); + + // Show and focus main window + try { + mainWindow.show(); + mainWindow.focus(); + } catch (e) {} + + resolve({ mode, view, mainWindow }); + } catch (error) { + logger.electron.error("Error handling mode selection:", error); + reject(error); + } finally { + try { + selectorWindow.close(); + } catch (e) {} + } + }); + }); +} + +/** + * Starts services based on the selected view. + * - testing-view: Backend only + * - flashing-view: BLCU Programming only + * @param {number} screenWidth + * @param {number} screenHeight + * @param {string} view - The selected view mode + * @returns {Promise} + */ +async function startServices(screenWidth, screenHeight, view) { + // Start backend only for testing view + if (view === "testing-view") { + const logWindow = createLogWindow(screenWidth, screenHeight); + logWindow.show(); // Show the log window + + try { + await startBackend(logWindow); + logger.electron.header("Backend process spawned"); + } catch (err) { + logger.electron.error("Failed to start backend:", err); + if (logWindow && !logWindow.isDestroyed()) { + logWindow.close(); + } + } + } + + // Start BLCU Programming only for flashing view + if (view === "flashing-view") { + try { + await startBlcuProgramming(); + logger.electron.header("BLCU programming process spawned"); + } catch (err) { + logger.electron.error("Failed to start BLCU programming:", err); + } + } +} + +/** + * Handles fallback when selector is not available or fails. + * @param {number} screenWidth + * @param {number} screenHeight + * @returns {Promise<{mode: string, view: string, mainWindow: BrowserWindow}>} + */ +async function handleSelectorFallback(screenWidth, screenHeight) { + const view = VALID_MODES.default; + const mainWindow = createWindow(screenWidth, screenHeight, view); + + try { + mainWindow.maximize(); + } catch (e) {} + + logger.electron.header("Main application window created"); + + try { + mainWindow.show(); + } catch (e) {} + + // Start services by default (testing view) + await startServices(screenWidth, screenHeight, view); + + return { mode: "default", view, mainWindow }; +} + +export { handleSelectorFallback, showModeSelector }; + diff --git a/electron-app/src/app/updater.js b/electron-app/src/app/updater.js new file mode 100644 index 000000000..c5a9c5dba --- /dev/null +++ b/electron-app/src/app/updater.js @@ -0,0 +1,49 @@ +/** + * @module app/updater + * @description Auto-updater configuration and event handling. + */ + +import { app, dialog } from "electron"; +import pkg from "electron-updater"; +import { logger } from "../utils/logger.js"; + +const { autoUpdater } = pkg; + +/** + * Initializes the auto-updater with appropriate logging and event handlers. + * @returns {void} + */ +function setupUpdater() { + if (!app.isPackaged) { + autoUpdater.forceDevUpdateConfig = true; + } + + // Configure auto-updater logging + autoUpdater.logger = { + info: (message) => logger.electron.info(message), + error: (message) => logger.electron.error(message), + warn: (message) => logger.electron.warning(message), + debug: (message) => logger.electron.debug(message), + }; + + // Handle update downloaded event + autoUpdater.on("update-downloaded", (info) => { + dialog + .showMessageBox({ + type: "info", + title: "Update Ready", + message: `Version ${info.version} has been downloaded. Restart now to install?`, + buttons: ["Restart", "Later"], + }) + .then((result) => { + if (result.response === 0) { + autoUpdater.quitAndInstall(); + } + }); + }); + + // Check for updates + autoUpdater.checkForUpdates(); +} + +export { setupUpdater }; diff --git a/electron-app/src/ipc/handlers.js b/electron-app/src/ipc/handlers.js index 269ea5235..5a4198d23 100644 --- a/electron-app/src/ipc/handlers.js +++ b/electron-app/src/ipc/handlers.js @@ -7,7 +7,7 @@ * - Folder selection dialogs */ -import { dialog, ipcMain, shell } from "electron"; +import { app, dialog, ipcMain, shell } from "electron"; import fs from "fs"; import { isAbsolute, join } from "path"; import { @@ -40,6 +40,8 @@ function setupIpcHandlers() { */ ipcMain.handle("get-current-view", () => getCurrentView()); + ipcMain.handle("get-app-version", () => app.getVersion()); + /** * @event switch-view * @description Switches the main window to the specified view. @@ -52,6 +54,8 @@ function setupIpcHandlers() { return view; }); + + /** * @event save-config * @async diff --git a/electron-app/src/menu/menu.js b/electron-app/src/menu/menu.js index c64b64ff4..a7cdc27e7 100644 --- a/electron-app/src/menu/menu.js +++ b/electron-app/src/menu/menu.js @@ -1,15 +1,15 @@ /** * @module menu * @description Application menu creation and management for the Electron application. - * Defines menu structure with File, View, Tools, and Help sections with keyboard shortcuts and actions. + * Defines menu structure with File, Tools, and Help sections with keyboard shortcuts and actions. */ import { Menu, app, dialog } from "electron"; -import { loadView } from "../windows/mainWindow.js"; /** - * Creates and sets the application menu with File, View, Tools, and Help sections. - * Includes menu items for reloading, exiting, switching views, toggling DevTools, and managing packet sender. + * Creates and sets the application menu with File, Tools, and Help sections. + * Includes menu items for reloading, exiting, toggling DevTools, and app information. + * View switching is no longer available since the mode is selected at startup. * @param {import("electron").BrowserWindow} mainWindow - The main browser window instance to attach menu actions to. * @returns {void} * @example @@ -29,6 +29,11 @@ function createMenu(mainWindow) { } }, }, + { + label: "Return to Selector", + accelerator: "CmdOrCtrl+Shift+S", + click: () => app.emit("return-to-selector"), + }, { type: "separator" }, { label: "Exit", @@ -38,23 +43,8 @@ function createMenu(mainWindow) { ], }, { - label: "View", + label: "Tools", submenu: [ - { - label: "Competition View", - accelerator: "CmdOrCtrl+1", - click: () => { - loadView("competition-view"); - }, - }, - { - label: "Testing View", - accelerator: "CmdOrCtrl+2", - click: () => { - loadView("testing-view"); - }, - }, - { type: "separator" }, { label: "Toggle DevTools", accelerator: "F12", diff --git a/electron-app/src/processes/backend.js b/electron-app/src/processes/backend.js index b021edfdc..64b51c838 100644 --- a/electron-app/src/processes/backend.js +++ b/electron-app/src/processes/backend.js @@ -32,6 +32,18 @@ let storedLogWindow = null; // Store error messages accumulated from the current process run let lastBackendError = null; +/** + * Clears the stored log window reference and closes it + */ +function clearLogWindow() { + if (storedLogWindow && !storedLogWindow.isDestroyed()) { + try { + storedLogWindow.close(); + } catch (e) {} + } + storedLogWindow = null; +} + /** * Starts the backend process by spawning the backend binary with the user configuration. * @returns {void} @@ -46,6 +58,8 @@ async function startBackend(logWindow = null) { const currentLogWindow = logWindow || storedLogWindow; return new Promise((resolve, reject) => { + let resolved = false; + // Get paths for binary and config const backendBin = getBinaryPath("backend"); const configPath = getUserConfigPath(); @@ -91,9 +105,10 @@ async function startBackend(logWindow = null) { // Resolve as soon as the HTTP server confirms it is listening. // Matches: "INF ... > http server listening localAddr=..." - if (text.includes("http server listening")) { + if (text.includes("http server listening") && !resolved) { logger.backend.info("Backend ready (HTTP server listening)"); clearTimeout(startupTimer); + resolved = true; resolve(backendProcess); } }); @@ -118,7 +133,10 @@ async function startBackend(logWindow = null) { "Backend Error", `Failed to start backend: ${error.message}`, ); - return reject(new Error(`Failed to start backend: ${error.message}`)); + if (!resolved) { + resolved = true; + return reject(new Error(`Failed to start backend: ${error.message}`)); + } }); // Handle process exit @@ -126,21 +144,32 @@ async function startBackend(logWindow = null) { logger.backend.info(`Backend process exited with code ${code}`); clearTimeout(startupTimer); - if (code !== 0 && code !== null) { - let errorMessage = `Backend exited with code ${code}`; - - if (lastBackendError) { - const stripped = lastBackendError.replace(/\x1b\[[0-9;]*m/g, ""); - const formatted = formatBackendError(stripped); - errorMessage += `\n\n${getHint(stripped, formatted)}`; - } else { - errorMessage += "\n\n(No error output captured)"; + // If the process closed without success, reject the promise + if (!resolved) { + if (code !== 0 && code !== null) { + let errorMessage = `Backend exited with code ${code}`; + + if (lastBackendError) { + const stripped = lastBackendError.replace(/\x1b\[[0-9;]*m/g, ""); + const formatted = formatBackendError(stripped); + errorMessage += `\n\n${getHint(stripped, formatted)}`; + } else { + errorMessage += "\n\n(No error output captured)"; + } + + dialog.showErrorBox("Backend Crashed", errorMessage); + lastBackendError = null; + backendProcess = null; + resolved = true; + return reject(new Error(errorMessage)); } - dialog.showErrorBox("Backend Crashed", errorMessage); - lastBackendError = null; - backendProcess = null; - return reject(new Error(errorMessage)); + if (code === null || code === 0) { + logger.backend.warning("Backend closed before ready signal - likely port conflict or initialization error"); + backendProcess = null; + resolved = true; + return reject(new Error("Backend process closed before initialization completed")); + } } backendProcess = null; @@ -148,10 +177,13 @@ async function startBackend(logWindow = null) { // Fallback: if the ready message never appears, resolve anyway after timeout const startupTimer = setTimeout(() => { - logger.backend.warning( - "Backend ready signal not received - resolving after timeout", - ); - resolve(backendProcess); + if (!resolved) { + logger.backend.warning( + "Backend ready signal not received - resolving after timeout", + ); + resolved = true; + resolve(backendProcess); + } }, 5000); }); } @@ -176,6 +208,8 @@ async function stopBackend() { if (localBackendProcess === backendProcess) { backendProcess = null; } + // Clean up log window + clearLogWindow(); resolve(); }); @@ -194,6 +228,8 @@ async function stopBackend() { fallbackTimer.unref(); } else { logger.backend.warning("Backend process not found, skipping stop..."); + // Clean up log window even if process doesn't exist + clearLogWindow(); resolve(); } }); @@ -209,6 +245,10 @@ async function restartBackend() { // Stop current process first await stopBackend(); + if (localBackendProcess.stdin) { + localBackendProcess.stdin.end(); + } + // Start a new process try { await startBackend(); @@ -225,4 +265,4 @@ function getBackendWorkingDir() { : path.dirname(getUserConfigPath()); } -export { getBackendWorkingDir, restartBackend, startBackend, stopBackend }; +export { clearLogWindow, getBackendWorkingDir, restartBackend, startBackend, stopBackend }; diff --git a/electron-app/src/processes/blcuProgramming.js b/electron-app/src/processes/blcuProgramming.js index aebd2a3a6..80b7d7701 100644 --- a/electron-app/src/processes/blcuProgramming.js +++ b/electron-app/src/processes/blcuProgramming.js @@ -139,12 +139,27 @@ async function startBlcuProgramming() { } async function stopBlcuProgramming() { - if (!blcuProgrammingProcess || blcuProgrammingProcess.killed) { - return; - } + return new Promise((resolve) => { + if (!blcuProgrammingProcess || blcuProgrammingProcess.killed) { + resolve(); + return; + } + + blcuProgrammingProcess.once("close", () => { + resolve(); + }); - blcuProgrammingProcess.kill("SIGTERM"); - blcuProgrammingProcess = null; + blcuProgrammingProcess.kill("SIGTERM"); + + const fallbackTimer = setTimeout(() => { + if (blcuProgrammingProcess && !blcuProgrammingProcess.killed) { + blcuProgrammingProcess.kill("SIGKILL"); + } + resolve(); + }, 3000); + + fallbackTimer.unref(); + }); } export { startBlcuProgramming, stopBlcuProgramming }; diff --git a/electron-app/src/windows/index.js b/electron-app/src/windows/index.js new file mode 100644 index 000000000..06cd375b2 --- /dev/null +++ b/electron-app/src/windows/index.js @@ -0,0 +1,8 @@ +/** + * @module windows + * @description Window creation and management exports. + */ + +export { createLogWindow } from "./logWindow.js"; +export { createWindow } from "./mainWindow.js"; + diff --git a/electron-app/src/windows/mainWindow.js b/electron-app/src/windows/mainWindow.js index 0bf125b85..3fc755f64 100644 --- a/electron-app/src/windows/mainWindow.js +++ b/electron-app/src/windows/mainWindow.js @@ -24,7 +24,7 @@ let currentView = "testing-view"; * @example * createWindow(); */ -function createWindow(screenWidth, screenHeight) { +function createWindow(screenWidth, screenHeight, initialView) { // Create new browser window with configuration mainWindow = new BrowserWindow({ x: 0, @@ -47,8 +47,15 @@ function createWindow(screenWidth, screenHeight) { backgroundColor: "#1a1a1a", }); - // Load ethernet view by default - loadView(currentView); + // If an initial view string is provided, load it. + // If `initialView` is explicitly null, skip loading so caller can decide later. + if (typeof initialView === "string") { + loadView(initialView); + } else if (initialView === null) { + // skip loading any view for now + } else { + loadView(currentView); + } // Create application menu const menu = createMenu(mainWindow); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3eb9ad9ae..06b11e8bc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -329,8 +329,6 @@ importers: specifier: ^7.3.1 version: 7.3.1(@types/node@25.2.0)(jiti@2.6.1)(lightningcss@1.30.2) - packet-sender: {} - packages: 7zip-bin@5.2.0: @@ -2109,6 +2107,7 @@ packages: '@xmldom/xmldom@0.8.11': resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==} engines: {node: '>=10.0.0'} + deprecated: this version has critical issues, please update to the latest version abbrev@3.0.1: resolution: {integrity: sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==} @@ -2325,6 +2324,7 @@ packages: basic-ftp@5.1.0: resolution: {integrity: sha512-RkaJzeJKDbaDWTIPiJwubyljaEPwpVWkm9Rt5h9Nd6h7tEXTJ3VB4qxdZBioV7JO5yLUaOKwz7vDOzlncUsegw==} engines: {node: '>=10.0.0'} + deprecated: Security vulnerability fixed in 5.2.1, please upgrade bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==}