const { app, BrowserWindow, nativeTheme, Menu, MenuItem, ipcMain } = require('electron/main'); const path = require('node:path'); const { fileURLToPath } = require('node:url'); const fs = require('fs'); const log = require('electron-log/main'); const { default: isReachable } = require('is-reachable'); const { setTimeout: delay } = require('node:timers/promises'); const config = require('./js/config.js'); const { Config } = require('./js/config.js'); const { isAuthorizedWatchdogEvent: isWatchdogEventAuthorized, registerWatchdogHandler } = require('./js/watchdog.js'); //------------------------------------------------------------------------------ // Properties //------------------------------------------------------------------------------ let win; let menu; let navigationController; let navigationRequest = 0; let restartScheduled = false; let rendererRecoveryScheduled = false; let restartTimer; const restartArgument = '--tooloop-restart-count='; const restartCount = Number( process.argv.find(argument => argument.startsWith(restartArgument))?.slice(restartArgument.length) || 0 ); const maxAutomaticRestarts = 3; const isMac = process.platform === 'darwin'; //------------------------------------------------------------------------------ // Function //------------------------------------------------------------------------------ /** * Creates the browser window */ function createMainWindow() { win = new BrowserWindow({ width: 1920, height: 1080, backgroundColor: '#000000', icon: 'images/icon-512.png', autoHideMenuBar: true, kiosk: true, alwaysOnTop: true, webPreferences: { preload: path.join(__dirname, 'js/preload.js'), webSecurity: false, disableDialogs: true }, }); nativeTheme.themeSource = 'dark'; // register event callbacks win.on("closed", function () { win = null; }); win.webContents.on("will-frame-navigate", (event) => validateDomain(event)); win.webContents.setWindowOpenHandler(({ url }) => { // we need to manually validate the url as `loadURL` // doesn’t trigger the `will-navigate` event let event = new Event("DummyNavigation"); event.url = url; if (validateDomain(event)) { win.loadURL(config.data.url); } return { action: 'deny' }; }); win.webContents.on("did-fail-load", (event, errorCode, errorDescription, validatedURL, isMainFrame) => { log.error(`Failed to load URL: ${validatedURL} with error: ${errorDescription}`); if (errorCode !== -3 && config.data?.url) { loadOfflinePage(); } }); win.webContents.on("render-process-gone", (_event, details) => { rendererRecoveryScheduled = false; log.error('Renderer process gone: ' + JSON.stringify(details)); loadOfflinePage(); }); win.webContents.on("unresponsive", () => { log.error('Renderer became unresponsive'); if (rendererRecoveryScheduled || win.isDestroyed()) return; rendererRecoveryScheduled = true; try { win.webContents.forcefullyCrashRenderer(); } catch (error) { rendererRecoveryScheduled = false; log.error('Could not restart renderer: ' + (error?.stack || error)); loadOfflinePage(); } }); win.webContents.on("responsive", () => { rendererRecoveryScheduled = false; log.info('Renderer became responsive'); }); win.on("page-title-updated", (event) => event.preventDefault()); // Load page from config file if (config.data && 'url' in config.data && config.data.url) { loadUrlAsync(config.data.url); } else { try { // Load file from data folder if available fs.accessSync('/assets/data/index.html'); win.loadFile('/assets/data/index.html'); } catch { // Load fallback page win.loadFile('./html/onboarding.html'); } } } function loadOfflinePage() { log.warn('Loading offline page'); if (win && !win.isDestroyed()) { win.loadFile(path.join(__dirname, 'html/offline.html')); } } /** * Creates all available keyboard shortcuts */ function createMenu() { menu = new Menu(); menu.append(new MenuItem({ label: app.name, submenu: [ { label: 'Toggle Fullscreen', accelerator: isMac ? 'Cmd+F' : 'Ctrl+F', click: () => { win.setKiosk(!win.kiosk); } }, { role: 'reload' }, { role: 'quit' }, { label: 'Config', accelerator: isMac ? 'Cmd+,' : 'Ctrl+,', click: () => { config.showWindow( win, (newConfig) => { loadUrlAsync(newConfig.url); } ); } } ] })); Menu.setApplicationMenu(menu); } /** * Validates the url of a navigatio event against the list of allowed domains in * the config file. See https://www.electronjs.org/docs/latest/api/web-contents#event-will-frame-navigate * @param {Event} event * @returns `true` if the url is allowed, `false` otherwise */ function validateDomain(event) { if (isUrlAllowed(event.url)) return true; event.preventDefault(); log.info("Navigation to " + event.url + " prevented"); return false; } function isUrlAllowed(urlString) { if (config == undefined || config.data == undefined) return true; let url; try { url = new URL(urlString); } catch { return false; } if (['file:', 'file'].includes(url.protocol)) return true; if ('whitelist' in config.data && Array.isArray(config.data.whitelist) && config.data.whitelist.length > 0 && !config.data.whitelist.includes(url.hostname)) { return false; } return true; } function isAuthorizedWatchdogEvent(event) { return isWatchdogEventAuthorized(event, win, isUrlAllowed); } function logWatchdogError(error) { log.warn('Watchdog reset failed: ' + (error?.message || error)); } function registerWatchdogBridge() { registerWatchdogHandler(ipcMain, { isAuthorized: isAuthorizedWatchdogEvent, onError: logWatchdogError }); } /** * Tests if the host of the url is reachable. * Access is tried in a 1 second interval and the url is loaded if successfull. * @param {string} testurl */ async function loadUrlAsync(testurl) { const request = ++navigationRequest; navigationController?.abort(); navigationController = new AbortController(); const { signal } = navigationController; try { let url = new URL(testurl); // If it’s an online url, test whether it’s reachable if (['https:', 'http:', 'https', 'http'].includes(url.protocol)) { let fallbackShown = false; while (!signal.aborted && request === navigationRequest) { const timeoutSignal = AbortSignal.timeout(3000); const reachable = await isReachable(url.href, { signal: AbortSignal.any([signal, timeoutSignal]) }); if (signal.aborted || request !== navigationRequest) return; if (reachable) { log.info("Successful access to " + url.hostname); log.info("Loading " + testurl); if (!win.isDestroyed()) win.loadURL(testurl); return; } log.warn("Could not reach " + url.hostname); if (!fallbackShown && !win.isDestroyed()) { loadOfflinePage(); fallbackShown = true; } await delay(1000, undefined, { signal }); } } // Load local files and other protocols immediately else if (url.protocol === 'file:') { if (!signal.aborted && request === navigationRequest && !win.isDestroyed()) { await win.loadFile(fileURLToPath(url), { search: url.search, hash: url.hash }); } } else { if (!signal.aborted && request === navigationRequest && !win.isDestroyed()) { win.loadURL(testurl); } } } catch (err) { if (signal.aborted || request !== navigationRequest) return; log.error(err.message); // Load fallback page loadOfflinePage(); } } function resolveLogPath() { if (config.data != undefined && 'logPath' in config.data) { if (fs.existsSync(config.data.logPath)) { return config.data.logPath; } else { return "tooloop-kiosk-browser.log"; } } } function restartAfterFatalError(error) { log.error('Fatal main-process error: ' + (error?.stack || error)); if (restartScheduled) return; restartScheduled = true; if (restartCount >= maxAutomaticRestarts) { log.error('Automatic restart limit reached; exiting'); app.exit(1); return; } const relaunchArgs = process.argv.slice(1) .filter(argument => !argument.startsWith(restartArgument)); relaunchArgs.push(restartArgument + (restartCount + 1)); log.warn('Restarting application in 5 seconds (attempt ' + (restartCount + 1) + '/' + maxAutomaticRestarts + ')'); restartTimer = setTimeout(() => { app.relaunch({ args: relaunchArgs }); app.exit(1); }, 5000); } //------------------------------------------------------------------------------ // Init electron app //------------------------------------------------------------------------------ // https://github.com/electron/electron/issues/17972 app.commandLine.appendSwitch('--no-sandbox'); app.commandLine.appendSwitch('no-sandbox'); app.whenReady().then(() => { config.load(); log.initialize(); log.transports.file.resolvePathFn = resolveLogPath; log.info('----------------------------------------------'); log.info('Starting Tooloop Kiosk Browser...'); log.info('Logging to ' + log.transports.file.resolvePathFn()); registerWatchdogBridge(); createMenu(); createMainWindow(); }); app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) { createMainWindow(); } }) app.on('window-all-closed', () => { app.quit() }); app.on('quit', () => { log.info('Quit'); }); // prevent error dialogs in case of exceptions process.on('uncaughtException', restartAfterFatalError); process.on('unhandledRejection', restartAfterFatalError);