From a098289856f173ca350034f9334bd2978ad738a9 Mon Sep 17 00:00:00 2001 From: Martin Fournier Date: Wed, 29 Dec 2021 08:46:56 -0500 Subject: [PATCH] Refactor electron app into multiple files Gracefully handle http-server start error & cleanup logs --- electron/achievements.js | 35 +++++++ electron/api-server.js | 45 +++++++-- electron/gameWindow.js | 55 ++++++++++ electron/main.js | 210 ++++----------------------------------- electron/menu.js | 101 +++++++++++++++++++ electron/utils.js | 78 +++++++++++++++ 6 files changed, 323 insertions(+), 201 deletions(-) create mode 100644 electron/achievements.js create mode 100644 electron/gameWindow.js create mode 100644 electron/menu.js create mode 100644 electron/utils.js diff --git a/electron/achievements.js b/electron/achievements.js new file mode 100644 index 000000000..b7283f28e --- /dev/null +++ b/electron/achievements.js @@ -0,0 +1,35 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ +const greenworks = require("./greenworks"); + +function enableAchievementsInterval(window) { + // This is backward but the game fills in an array called `document.achievements` and we retrieve it from + // here. Hey if it works it works. + const steamAchievements = greenworks.getAchievementNames(); + const intervalID = setInterval(async () => { + try { + const playerAchievements = await window.webContents.executeJavaScript("document.achievements"); + for (const ach of playerAchievements) { + if (!steamAchievements.includes(ach)) continue; + greenworks.activateAchievement(ach, () => undefined); + } + } catch (error) { + log.error(error); + + // The interval probably did not get cleared after a window kill + log.warn('Clearing achievements timer'); + clearInterval(intervalID); + return; + } + }, 1000); + window.achievementsIntervalID = intervalID; +} + +function disableAchievementsInterval(window) { + if (window.achievementsIntervalID) { + clearInterval(window.achievementsIntervalID); + } +} + +module.exports = { + enableAchievementsInterval, disableAchievementsInterval +} diff --git a/electron/api-server.js b/electron/api-server.js index 8ac7531eb..5de6f2bcb 100644 --- a/electron/api-server.js +++ b/electron/api-server.js @@ -1,14 +1,14 @@ /* eslint-disable @typescript-eslint/no-var-requires */ const http = require("http"); -const crypto = require('crypto'); -const log = require('electron-log'); -const Config = require('electron-config'); +const crypto = require("crypto"); +const log = require("electron-log"); +const Config = require("electron-config"); const config = new Config(); let server; let window; -function initialize(win, callback) { +async function initialize(win) { window = win; server = http.createServer(async function (req, res) { let body = ""; @@ -20,7 +20,7 @@ function initialize(win, callback) { const providedToken = req.headers?.authorization?.replace('Bearer ', '') ?? ''; const isValid = providedToken === getAuthenticationToken(); if (isValid) { - log.log('Valid authentication token'); + log.debug('Valid authentication token'); } else { log.log('Invalid authentication token'); res.writeHead(401); @@ -51,29 +51,52 @@ function initialize(win, callback) { const autostart = config.get('autostart', false); if (autostart) { - return enable(callback); + try { + await enable() + } catch (error) { + return Promise.reject(error); + } } - if (callback) return callback(); return Promise.resolve(); } -function enable(callback) { +function enable() { if (isListening()) { log.warn('API server already listening'); - return; + return Promise.resolve(); } const port = config.get('port', 9990); log.log(`Starting http server on port ${port}`); - return server.listen(port, "127.0.0.1", callback); + + // https://stackoverflow.com/a/62289870 + let startFinished = false; + return new Promise((resolve, reject) => { + server.listen(port, "127.0.0.1", () => { + if (!startFinished) { + startFinished = true; + resolve(); + } + }); + server.once('error', (err) => { + if (!startFinished) { + startFinished = true; + console.log( + 'There was an error starting the server in the error listener:', + err + ); + reject(err); + } + }); + }); } function disable() { if (!isListening()) { log.warn('API server not listening'); - return; + return Promise.resolve(); } log.log('Stopping http server'); diff --git a/electron/gameWindow.js b/electron/gameWindow.js new file mode 100644 index 000000000..c16891558 --- /dev/null +++ b/electron/gameWindow.js @@ -0,0 +1,55 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ +const { app, BrowserWindow, shell } = require("electron"); +const log = require("electron-log"); +const utils = require("./utils"); +const achievements = require("./achievements"); +const menu = require("./menu"); +const api = require("./api-server"); + +const debug = process.argv.includes("--debug"); + +async function createWindow(killall) { + const window = new BrowserWindow({ + show: false, + backgroundThrottling: false, + backgroundColor: "#000000", + }); + + window.removeMenu(); + window.maximize(); + noScripts = killall ? { query: { noScripts: killall } } : {}; + window.loadFile("index.html", noScripts); + window.show(); + if (debug) window.webContents.openDevTools(); + + window.webContents.on("new-window", function (e, url) { + // make sure local urls stay in electron perimeter + if (url.substr(0, "file://".length) === "file://") { + return; + } + + // and open every other protocols on the browser + e.preventDefault(); + shell.openExternal(url); + }); + window.webContents.backgroundThrottling = false; + + achievements.enableAchievementsInterval(window); + utils.attachUnresponsiveAppHandler(window); + + try { + await api.initialize(window); + } catch (error) { + log.error(error); + utils.showErrorBox('Error starting http server', error); + } + + menu.refreshMenu(window); + utils.setStopProcessHandler(app, window, true); + + return window; +} + +module.exports = { + createWindow, +} diff --git a/electron/main.js b/electron/main.js index 885ee2464..6a7d814d2 100644 --- a/electron/main.js +++ b/electron/main.js @@ -1,9 +1,12 @@ /* eslint-disable no-process-exit */ /* eslint-disable @typescript-eslint/no-var-requires */ -const { app, BrowserWindow, Menu, shell, dialog, clipboard } = require("electron"); -const log = require('electron-log'); +const { app } = require("electron"); +const log = require("electron-log"); const greenworks = require("./greenworks"); const api = require("./api-server"); +const gameWindow = require("./gameWindow"); +const achievements = require("./achievements"); +const utils = require("./utils"); log.catchErrors(); log.info(`Started app: ${JSON.stringify(process.argv)}`); @@ -19,203 +22,25 @@ if (greenworks.init()) { log.warn("Steam API has failed to initialize."); } -const debug = false; -let win = null; - -const getMenu = (win) => Menu.buildFromTemplate([ - { - label: "Edit", - submenu: [ - { label: "Undo", accelerator: "CmdOrCtrl+Z", selector: "undo:" }, - { label: "Redo", accelerator: "Shift+CmdOrCtrl+Z", selector: "redo:" }, - { type: "separator" }, - { label: "Cut", accelerator: "CmdOrCtrl+X", selector: "cut:" }, - { label: "Copy", accelerator: "CmdOrCtrl+C", selector: "copy:" }, - { label: "Paste", accelerator: "CmdOrCtrl+V", selector: "paste:" }, - { label: "Select All", accelerator: "CmdOrCtrl+A", selector: "selectAll:" }, - ], - }, - { - label: "Reloads", - submenu: [ - { - label: "Reload", - accelerator: "f5", - click: () => { - win.loadFile("index.html"); - }, - }, - { - label: "Reload & Kill All Scripts", - click: () => reloadAndKill(win) - }, - ], - }, - { - label: "Fullscreen", - submenu: [ - { - label: "Toggle", - accelerator: "f9", - click: (() => { - let full = false; - return () => { - full = !full; - win.setFullScreen(full); - }; - })(), - }, - ], - }, - { - label: "API Server", - submenu: [ - { - label: api.isListening() ? 'Disable Server' : 'Enable Server', - click: (async () => { - await api.toggleServer(); - Menu.setApplicationMenu(getMenu()); - }) - }, - { - label: api.isAutostart() ? 'Disable Autostart' : 'Enable Autostart', - click: (async () => { - api.toggleAutostart(); - Menu.setApplicationMenu(getMenu()); - }) - }, - { - label: 'Copy Auth Token', - click: (async () => { - const token = api.getAuthenticationToken(); - log.log('Wrote authentication token to clipboard'); - clipboard.writeText(token); - }) - }, - ] - }, - { - label: "Debug", - submenu: [ - { - label: "Activate", - click: () => win.webContents.openDevTools(), - }, - ], - }, -]); - -const reloadAndKill = (win, killScripts = true) => { - log.info('Reloading & Killing all scripts...'); - setStopProcessHandler(app, win, false); - if (win.achievementsIntervalID) clearInterval(win.achievementsIntervalID); - win.webContents.forcefullyCrashRenderer(); - win.on('closed', () => { - // Wait for window to be closed before opening the new one to prevent race conditions - log.debug('Opening new window'); - const newWindow = createWindow(killScripts); - api.initialize(newWindow, () => Menu.setApplicationMenu(getMenu(win))); - setStopProcessHandler(app, newWindow, true); - }) - win.close(); -}; - - -function createWindow(killall) { - win = new BrowserWindow({ - show: false, - backgroundThrottling: false, - backgroundColor: "#000000", - }); - - win.removeMenu(); - win.maximize(); - noScripts = killall ? { query: { noScripts: killall } } : {}; - win.loadFile("index.html", noScripts); - win.show(); - if (debug) win.webContents.openDevTools(); - - win.webContents.on("new-window", function (e, url) { - // make sure local urls stay in electron perimeter - if (url.substr(0, "file://".length) === "file://") { - return; - } - - // and open every other protocols on the browser - e.preventDefault(); - shell.openExternal(url); - }); - win.webContents.backgroundThrottling = false; - - // This is backward but the game fills in an array called `document.achievements` and we retrieve it from - // here. Hey if it works it works. - const achievements = greenworks.getAchievementNames(); - const intervalID = setInterval(async () => { - try { - const achs = await win.webContents.executeJavaScript("document.achievements"); - for (const ach of achs) { - if (!achievements.includes(ach)) continue; - greenworks.activateAchievement(ach, () => undefined); - } - } catch (error) { - // The interval properly did not properly get cleared after a window kill - log.warn('Clearing achievements timer'); - clearInterval(intervalID); - return; - } - }, 1000); - win.achievementsIntervalID = intervalID; - - - const promptForReload = () => { - win.off('unresponsive', promptForReload); - dialog.showMessageBox({ - type: 'error', - title: 'Bitburner > Application Unresponsive', - message: 'The application is unresponsive, possibly due to an infinite loop in your scripts.', - detail:' Did you forget a ns.sleep(x)?\n\n' + - 'The application will be restarted for you, do you want to kill all running scripts?', - buttons: ['Restart', 'Cancel'], - defaultId: 0, - checkboxLabel: 'Kill all running scripts', - checkboxChecked: true, - noLink: true, - }).then(({response, checkboxChecked}) => { - if (response === 0) { - reloadAndKill(win, checkboxChecked); - } else { - win.on('unresponsive', promptForReload) - } - }); - } - win.on('unresponsive', promptForReload); - - // // Create the Application's main menu - // Menu.setApplicationMenu(getMenu()); - - return win; -} - function setStopProcessHandler(app, window, enabled) { const closingWindowHandler = async (e) => { // We need to prevent the default closing event to add custom logic e.preventDefault(); // First we clear the achievement timer - if (window.achievementsIntervalID) { - clearInterval(window.achievementsIntervalID); - } + achievements.disableAchievementsInterval(window); + // Shutdown the http server api.disable(); // We'll try to execute javascript on the page to see if we're stuck let canRunJS = false; - win.webContents.executeJavaScript('window.stop(); document.close()', true) + window.webContents.executeJavaScript('window.stop(); document.close()', true) .then(() => canRunJS = true); setTimeout(() => { // Wait a few milliseconds to prevent a race condition before loading the exit screen - win.webContents.stop(); - win.loadFile("exit.html") + window.webContents.stop(); + window.loadFile("exit.html") }, 20); // Wait 200ms, if the promise has not yet resolved, let's crash the process since we're possibly in a stuck scenario @@ -223,11 +48,11 @@ function setStopProcessHandler(app, window, enabled) { if (!canRunJS) { // We're stuck, let's crash the process log.warn('Forcefully crashing the renderer process'); - win.webContents.forcefullyCrashRenderer(); + gameWindow.webContents.forcefullyCrashRenderer(); } log.debug('Destroying the window'); - win.destroy(); + window.destroy(); }, 200); } @@ -255,9 +80,14 @@ function setStopProcessHandler(app, window, enabled) { } } +function startWindow(noScript) { + gameWindow.createWindow(noScript); +} + +utils.initialize(setStopProcessHandler, startWindow); + app.whenReady().then(async () => { log.info('Application is ready!'); - const win = createWindow(process.argv.includes("--no-scripts")); - await api.initialize(win, () => Menu.setApplicationMenu(getMenu(win))); - setStopProcessHandler(app, win, true); + utils.initialize(setStopProcessHandler, startWindow); + startWindow(process.argv.includes("--no-scripts")) }); diff --git a/electron/menu.js b/electron/menu.js new file mode 100644 index 000000000..9b73b984c --- /dev/null +++ b/electron/menu.js @@ -0,0 +1,101 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ +const { Menu, clipboard } = require("electron"); +const log = require("electron-log"); +const api = require("./api-server"); +const utils = require("./utils"); + +function getMenu(window) { + return Menu.buildFromTemplate([ + { + label: "Edit", + submenu: [ + { label: "Undo", accelerator: "CmdOrCtrl+Z", selector: "undo:" }, + { label: "Redo", accelerator: "Shift+CmdOrCtrl+Z", selector: "redo:" }, + { type: "separator" }, + { label: "Cut", accelerator: "CmdOrCtrl+X", selector: "cut:" }, + { label: "Copy", accelerator: "CmdOrCtrl+C", selector: "copy:" }, + { label: "Paste", accelerator: "CmdOrCtrl+V", selector: "paste:" }, + { label: "Select All", accelerator: "CmdOrCtrl+A", selector: "selectAll:" }, + ], + }, + { + label: "Reloads", + submenu: [ + { + label: "Reload", + accelerator: "f5", + click: () => window.loadFile("index.html"), + }, + { + label: "Reload & Kill All Scripts", + click: () => utils.reloadAndKill(window, true), + }, + ], + }, + { + label: "Fullscreen", + submenu: [ + { + label: "Toggle", + accelerator: "f9", + click: (() => { + let full = false; + return () => { + full = !full; + window.setFullScreen(full); + }; + })(), + }, + ], + }, + { + label: "API Server", + submenu: [ + { + label: api.isListening() ? 'Disable Server' : 'Enable Server', + click: (async () => { + try { + await api.toggleServer(); + } catch (error) { + log.error(error); + utils.showErrorBox('Error Toggling Server', error); + } + refreshMenu(window); + }) + }, + { + label: api.isAutostart() ? 'Disable Autostart' : 'Enable Autostart', + click: (async () => { + api.toggleAutostart(); + refreshMenu(window); + }) + }, + { + label: 'Copy Auth Token', + click: (async () => { + const token = api.getAuthenticationToken(); + log.log('Wrote authentication token to clipboard'); + clipboard.writeText(token); + }) + }, + ] + }, + { + label: "Debug", + submenu: [ + { + label: "Activate", + click: () => window.webContents.openDevTools(), + }, + ], + }, + ]); +} + +function refreshMenu(window) { + Menu.setApplicationMenu(getMenu(window)); +} + +module.exports = { + getMenu, refreshMenu, +} diff --git a/electron/utils.js b/electron/utils.js new file mode 100644 index 000000000..4821360f2 --- /dev/null +++ b/electron/utils.js @@ -0,0 +1,78 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ +const { app, dialog } = require("electron"); +const log = require("electron-log"); + +const achievements = require("./achievements"); +const api = require("./api-server"); + +let setStopProcessHandler = () => { + // Will be overwritten by the initialize function called in main +} +let createWindowHandler = () => { + // Will be overwritten by the initialize function called in main +} + +function initialize(stopHandler, createHandler) { + setStopProcessHandler = stopHandler; + createWindowHandler = createHandler +} + +function reloadAndKill(window, killScripts) { + log.info('Reloading & Killing all scripts...'); + setStopProcessHandler(app, window, false); + + achievements.disableAchievementsInterval(window); + api.disable(); + + window.webContents.forcefullyCrashRenderer(); + window.on('closed', () => { + // Wait for window to be closed before opening the new one to prevent race conditions + log.debug('Opening new window'); + createWindowHandler(killScripts); + }) + + window.close(); +} + +function promptForReload(window) { + detachUnresponsiveAppHandler(window); + dialog.showMessageBox({ + type: 'error', + title: 'Bitburner > Application Unresponsive', + message: 'The application is unresponsive, possibly due to an infinite loop in your scripts.', + detail:' Did you forget a ns.sleep(x)?\n\n' + + 'The application will be restarted for you, do you want to kill all running scripts?', + buttons: ['Restart', 'Cancel'], + defaultId: 0, + checkboxLabel: 'Kill all running scripts', + checkboxChecked: true, + noLink: true, + }).then(({response, checkboxChecked}) => { + if (response === 0) { + reloadAndKill(window, checkboxChecked); + } else { + attachUnresponsiveAppHandler(window); + } + }); +} + +function attachUnresponsiveAppHandler(window) { + window.on('unresponsive', () => promptForReload(window)); +} + +function detachUnresponsiveAppHandler(window) { + window.off('unresponsive', () => promptForReload(window)); +} + +function showErrorBox(title, error) { + dialog.showErrorBox( + title, + `${error.name}\n\n${error.message}` + ); +} + +module.exports = { + initialize, setStopProcessHandler, reloadAndKill, showErrorBox, + attachUnresponsiveAppHandler, detachUnresponsiveAppHandler, +} +