const { app, BrowserWindow, ipcMain, nativeTheme, Menu, MenuItem } = require('electron/main'); const path = require('node:path'); const fs = require('fs'); const os = require('os'); const log = require('electron-log/main'); const isReachable = require('is-reachable'); const { setInterval } = require('node:timers/promises'); const configModule = require('./js/config.js'); const isMac = process.platform === 'darwin'; //------------------------------------------------------------------------------ // Properties //------------------------------------------------------------------------------ let config; let configPath; let configWindow; let win; let menu; //------------------------------------------------------------------------------ // Function //------------------------------------------------------------------------------ /** * Loads a config file from disc. * The file is expected to be named `config.json` and is searched for in these * locations and in this order: * * 1. `/assets/presentation/config.json` (Tooloop OS) * 2. appData directory * 3. Path of the executable * - Linux: `app.getPath('exe')` * - MacOS: `path.resolve(app.getPath('exe'), "../../../../")` * 4. `__dirname` (Development) */ function loadConfig() { const locations = [ '/assets/presentation', path.join(app.getPath('appData'), app.name), isMac ? path.resolve(app.getPath('exe'), "../../../../") : app.getPath('exe'), __dirname ]; // Check all locations for (const location of locations) { // Update the filepath configPath = location; let configFile = path.join(configPath, 'config.json'); try { // Try access fs.accessSync(configFile); // Parse the file if found console.info('Found config file at ' + configFile); const data = fs.readFileSync(configFile, { encoding: 'utf8' }); config = JSON.parse(data); // Break the loop break; } catch (err) { console.warn('No config file found at ' + configPath); } } } function saveConfig(newConfig) { // If there was no config file before create a new object // and store a default save path if (!config) { config = { url: "", whitelist: [], logPath: "" }; try { configPath = fs.accessSync('/assets/presentation'); } catch (error) { configPath = path.join(app.getPath('appData'), app.name); } } // Store changes if (config.url != newConfig.url) { config.url = newConfig.url; loadUrlAsync(config.url); } config.whitelist = []; let whitelist = newConfig.whitelist.split(";"); whitelist = whitelist.filter(function (entry) { return entry.trim() != ''; }); whitelist.forEach( token => { config.whitelist.push(token.replace(/\r?\n|\r/g, "")); } ); config.logPath = newConfig.logPath; // Write file let configFile = path.join(configPath, 'config.json'); fs.writeFile( configFile, JSON.stringify(config, null, " "), (error) => { if (error) log.warn('Error writing to ' + configPath, error); log.info("Saved config to " + configFile); } ); // Update UI with new values configWindow.webContents.send('update-config', config); } /** * Creates a modal config view and attaches as to the main window */ function showConfigWindow() { // create lazily if (configWindow == undefined) { configWindow = new BrowserWindow({ parent: win, width: 640, height: 460, minimizable: false, maximizable: false, fullscreenable: false, backgroundColor: '#1f1f1f', autoHideMenuBar: true, excludedFromShownWindowsMenu: true, webPreferences: { preload: path.join(__dirname, 'js/configPreload.js') } }); configWindow.on('close', (event) => { event.preventDefault(); configWindow.hide(); }); ipcMain.on('save-config', (event, configData) => { configWindow.hide(); saveConfig(configData); }); ipcMain.on('cancel-config', (event) => { configWindow.hide(); configWindow.webContents.send('update-config', config); }); configWindow.loadFile('./html/config.html'); } // update text field values configWindow.webContents.send('update-config', config); // show window configWindow.show(); } /** * Creates the browser window */ function createMainWindow() { win = new BrowserWindow({ width: 1920, height: 1080, backgroundColor: '#000000', icon: 'images/icon-512.png', autoHideMenuBar: true, // kiosk: 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(url); } return { action: 'deny' }; }); win.on("page-title-updated", (event) => event.preventDefault()); // Load page from config file if (config != undefined && 'url' in config) { loadUrlAsync(config.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'); } } } /** * 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: () => { showConfigWindow(); } } ] })); 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 (config == undefined) return true; let url = new URL(event.url); // allow local urls if (['file:', 'file'].includes(url.protocol)) return true; if ('whitelist' in config && !config.whitelist.includes(url.hostname)) { event.preventDefault(); log.info("Navigation to " + event.url + " prevented"); return false; } return true; } /** * 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) { 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)) { reachable = await isReachable(url.href); if (reachable) { log.info("Successfull access to " + url.hostname); log.info("Loading " + config.url); win.loadURL(config.url); } else { log.warn("Could not reach " + url.hostname); for await (_ of setInterval(1000)) { reachable = await isReachable(url.href); if (reachable) { log.info("Successfull access to " + url.hostname); log.info("Loading " + config.url); win.loadURL(config.url); // break the interval break; } else { log.warn("Could not reach " + url.hostname); } } } } // Load other protocols (i. e. offline) immedately else { win.loadURL(config.url); } } catch (err) { log.error(err); } } //------------------------------------------------------------------------------ // Init electron app //------------------------------------------------------------------------------ // https://github.com/electron/electron/issues/17972 app.commandLine.appendSwitch('--no-sandbox'); app.commandLine.appendSwitch('no-sandbox'); app.whenReady().then(() => { // configModule.load(); loadConfig(); log.initialize(); if (config != undefined && 'logPath' in config) { log.transports.file.resolvePathFn = () => config.logPath; } log.info('----------------------------------------------'); log.info('Starting Tooloop Kiosk Browser...'); 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', function (error) { log.error(error); });