Files

347 lines
11 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);