diff --git a/.eslintrc.js b/.eslintrc.js index 1b8021e..1929965 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -13,7 +13,7 @@ module.exports = { ], plugins: ['prettier'], parserOptions: { - ecmaVersion: 12, + ecmaVersion: 15, sourceType: 'module' }, globals: { @@ -22,6 +22,7 @@ module.exports = { rules: { 'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off', 'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off', + 'no-await-in-loop': 'off', 'import/no-extraneous-dependencies': 'off', 'no-underscore-dangle': 'off', 'no-param-reassign': 'off', diff --git a/src/main/WindowManager.js b/src/main/WindowManager.js new file mode 100644 index 0000000..47270dc --- /dev/null +++ b/src/main/WindowManager.js @@ -0,0 +1,214 @@ +import { is } from '@electron-toolkit/utils' +import { BrowserWindow, screen } from 'electron' +import { join } from 'path' +import store from '../store' +// eslint-disable-next-line import/no-unresolved +// eslint-disable-next-line import/no-unresolved + +/** + * Custom BrowserWindow class that is used to spawn a window + * on each display connected to the computer (based on settings). + * When a method is called on this class, it will be called on all windows + */ +export default class WindowManager { + /** + * @type { BrowserWindow[] } + */ + windows = [] + + /** + * @type { import('electron').BrowserViewConstructorOptions } + * */ + options = {} + + /** @param { import('electron').BrowserViewConstructorOptions } options */ + constructor(options) { + this.options = options + + // Create a window for each display + const displays = screen.getAllDisplays() + + const multipleDisplays = store.get('settings.multipleDisplays') + if (!multipleDisplays) { + this.windows.push( + new BrowserWindow({ + ...options + }) + ) + return + } + + const displaySettings = store.get('settings.displays') + + for (const setting of displaySettings) { + const { id } = setting + const display = displays.find(d => d.id === id) + if (!display) { + console.warn(`Display with id ${id} not found`) + // eslint-disable-next-line no-continue + continue + } + + const window = new BrowserWindow({ + ...options, + x: display.bounds.x, + y: display.bounds.y + // width: display.workArea.width, + // height: display.workArea.height + }) + + window.displayId = id + + this.windows.push(window) + } + } + + loadMain() { + for (const window of this.windows) { + const idQuery = + window.displayId !== undefined + ? `?displayId=${window.displayId}` + : '' + // Fixes error https://github.com/electron/electron/issues/19847 + try { + // example from https://github.com/alex8088/electron-vite-boilerplate/blob/master/electron.vite.config.ts + if (is.dev && process.env['ELECTRON_RENDERER_URL']) { + window.loadURL( + process.env['ELECTRON_RENDERER_URL'] + idQuery + ) + } else { + window.loadFile(join(__dirname, '../renderer/index.html'), { + query: { displayId: window.displayId } + }) + } + } catch (error) { + console.error('Error while loading url', error) + if (error.code === 'ERR_ABORTED') { + // ignore ERR_ABORTED error + } else { + store.set('settings.autoLoad', false) + this.loadMain() + } + } + } + } + + /** @param {string} url */ + loadURL(url) { + for (const window of this.windows) { + window.loadURL(url) + } + } + + /** @param {string} file */ + loadFile(file) { + for (const window of this.windows) { + window.loadFile(file) + } + } + + reload() { + for (const window of this.windows) { + window.reload() + } + } + + /** @param {boolean} kiosk */ + setKiosk(kiosk) { + for (const window of this.windows) { + window.setKiosk(kiosk) + } + } + + toggleKiosk() { + for (const window of this.windows) { + window.setKiosk(!window.isKiosk()) + } + } + + focus() { + for (const window of this.windows) { + if (window.isMinimized()) window.restore() + window.focus() + } + } + + /** + * + * @param {string} event + * @param { Function } listener + * @memberof WindowManager + */ + attachWebContentEvent(event, listener) { + for (const window of this.windows) { + window.webContents.on(event, listener) + } + } + + /** + * @param {Function} listener + * @memberof WindowManager + */ + onBeforeSendHeaders(listener) { + for (const window of this.windows) { + window.webContents.session.webRequest.onBeforeSendHeaders(listener) + } + } + + /** + * @param {Function} listener + * @memberof WindowManager + */ + onHeadersReceived(listener) { + for (const window of this.windows) { + window.webContents.session.webRequest.onHeadersReceived(listener) + } + } + + async checkCache() { + for (const window of this.windows) { + const actualCache = await window.webContents.session.getCacheSize() + const limit = + (store.get('settings.cacheLimit') || 500) * 1024 * 1024 + + // console.log(`Actual cache is: ${actualCache / 1024 / 1024}`) + // console.log(`Limit is: ${limit / 1024 / 1024}`) + + if (actualCache > limit) { + await window.webContents.session.clearCache() + window.reload() + } + } + } + + openDevTools() { + for (const window of this.windows) { + window.webContents.openDevTools() + } + } + + async clearCache() { + for (const window of this.windows) { + await window.webContents.session.clearCache() + } + } + + async clearStorageData() { + for (const window of this.windows) { + await window.webContents.session.clearStorageData({ + storages: [ + 'appcache', + 'cookies', + 'localstorage', + 'cachestorage' + ] + }) + } + } + + destroy() { + for (const window of this.windows) { + window.destroy() + } + } +} diff --git a/src/main/index.js b/src/main/index.js index 3b48d9f..5e456e1 100644 --- a/src/main/index.js +++ b/src/main/index.js @@ -1,43 +1,34 @@ -import { app, protocol, BrowserWindow, globalShortcut, ipcMain } from 'electron' +import { electronApp, is, optimizer } from '@electron-toolkit/utils' +import { + BrowserWindow, + app, + globalShortcut, + ipcMain, + protocol, + screen +} from 'electron' import installExtension, { VUEJS_DEVTOOLS } from 'electron-devtools-installer' -import { join } from 'path' import { platform } from 'os' import parse from 'parse-duration' -import { electronApp, optimizer, is } from '@electron-toolkit/utils' +import { join } from 'path' import store from '../store' // eslint-disable-next-line import/no-unresolved import icon from '../../resources/logo.png?asset' // eslint-disable-next-line import/no-unresolved import iconWin from '../../resources/favicon.ico?asset' +import WindowManager from './WindowManager' const CACHE_INTERVAL = 3 * 1000 let reloadTimeout = null +let restarting = false -// BrowserWindow instance -let win +// fixes https://github.com/electron/electron/issues/19775 +process.env['ELECTRON_DISABLE_SECURITY_WARNINGS'] = 'true' -/** UTILS */ +/** @type { WindowManager } */ +let manager -/** Load main settings page */ -async function loadMain() { - // Fixes error https://github.com/electron/electron/issues/19847 - try { - // example from https://github.com/alex8088/electron-vite-boilerplate/blob/master/electron.vite.config.ts - if (is.dev && process.env['ELECTRON_RENDERER_URL']) { - win.loadURL(process.env['ELECTRON_RENDERER_URL']) - } else { - win.loadFile(join(__dirname, '../renderer/index.html')) - } - } catch (error) { - console.error('Error while loading url', error) - if (error.code === 'ERR_ABORTED') { - // ignore ERR_ABORTED error - } else { - store.set('settings.autoLoad', false) - loadMain() - } - } -} +/** UTILS */ function UpsertKeyValue(obj, keyToChange, value) { const keyToChangeLower = keyToChange.toLowerCase() @@ -54,9 +45,9 @@ function UpsertKeyValue(obj, keyToChange, value) { } /** Create the KIOSK fullscreen window */ -function createWindow() { +function setupWindowsManager() { // Create the browser window. - win = new BrowserWindow({ + manager = new WindowManager({ width: 1200, height: 1000, fullscreen: !is.dev, @@ -77,7 +68,7 @@ function createWindow() { }) // FIX: https://github.com/innovation-system/electron-kiosk/issues/3 - win.webContents.on('render-process-gone', (event, detailed) => { + manager.attachWebContentEvent('render-process-gone', (event, detailed) => { console.log( `!crashed, reason: ${detailed.reason}, exitCode = ${detailed.exitCode}` ) @@ -91,44 +82,22 @@ function createWindow() { }) // FIX CORS ERROR: https://pratikpc.medium.com/bypassing-cors-with-electron-ab7eaf331605 - win.webContents.session.webRequest.onBeforeSendHeaders( - (details, callback) => { - const { requestHeaders } = details - UpsertKeyValue(requestHeaders, 'Access-Control-Allow-Origin', ['*']) - callback({ requestHeaders }) - } - ) - - win.webContents.session.webRequest.onHeadersReceived( - (details, callback) => { - const { responseHeaders } = details - UpsertKeyValue(responseHeaders, 'Access-Control-Allow-Origin', [ - '*' - ]) - UpsertKeyValue(responseHeaders, 'Access-Control-Allow-Headers', [ - '*' - ]) - callback({ - responseHeaders - }) - } - ) - - loadMain() -} - -/** Periodic check of session cache, when limit is reached clear cache and reload page */ -async function checkCache() { - const actualCache = await win.webContents.session.getCacheSize() - const limit = (store.get('settings.cacheLimit') || 500) * 1024 * 1024 + manager.onBeforeSendHeaders((details, callback) => { + const { requestHeaders } = details + UpsertKeyValue(requestHeaders, 'Access-Control-Allow-Origin', ['*']) + callback({ requestHeaders }) + }) - // console.log(`Actual cache is: ${actualCache / 1024 / 1024}`) - // console.log(`Limit is: ${limit / 1024 / 1024}`) + manager.onHeadersReceived((details, callback) => { + const { responseHeaders } = details + UpsertKeyValue(responseHeaders, 'Access-Control-Allow-Origin', ['*']) + UpsertKeyValue(responseHeaders, 'Access-Control-Allow-Headers', ['*']) + callback({ + responseHeaders + }) + }) - if (actualCache > limit) { - await win.webContents.session.clearCache() - await win.reload() - } + manager.loadMain() } /** When `settings.autoReload` is enabled schedule a reload to a specific hour or every tot ms */ @@ -180,7 +149,7 @@ function scheduleReload() { clearTimeout(reloadTimeout) reloadTimeout = null } - win.reload() + manager.reload() scheduleReload() }, wait < 0 ? 0 : wait @@ -193,22 +162,47 @@ function scheduleReload() { clearTimeout(reloadTimeout) reloadTimeout = null } - win.reload() + manager.reload() scheduleReload() }, ms) } } +function onSettingsChanged(newSettings, oldSettings) { + scheduleReload() + + if (newSettings.multipleDisplays) { + // check if settings.displays changed + const changed = + oldSettings.multipleDisplays !== newSettings.multipleDisplays || + oldSettings.displays.length !== newSettings.displays.length || + oldSettings.displays.some( + (display, index) => + display.id !== newSettings.displays[index].id || + display.url !== newSettings.displays[index].url + ) + + if (changed) { + restarting = true + manager.destroy() + setupWindowsManager() + restarting = false + } else { + manager.loadMain() + } + } +} + /** Setup store related events and listeners */ function setupStore() { setInterval(() => { - checkCache() + manager.checkCache() }, CACHE_INTERVAL) // watch for settings changes - store.onDidChange('settings', () => { - scheduleReload() - }) + // store.onDidChange('settings', (newValue, oldValue) => { + // onSettingsChanged(newValue, oldValue) + // }) scheduleReload() } @@ -216,20 +210,20 @@ function setupStore() { /** Global application shortcuts */ function registerShortcuts() { globalShortcut.register('CommandOrControl+Shift+I', () => { - win.webContents.openDevTools() + manager.openDevTools() }) globalShortcut.register('CommandOrControl+Shift+K', async () => { store.set('settings.autoLoad', false) - loadMain() + manager.loadMain() }) globalShortcut.register('CommandOrControl+Shift+L', () => { - win.setKiosk(!win.isKiosk()) + manager.toggleKiosk() }) globalShortcut.register('CommandOrControl+Shift+R', () => { - win.reload() + manager.reload() }) globalShortcut.register('CommandOrControl+Shift+Q', () => { @@ -263,14 +257,15 @@ function registerShortcuts() { /** Register to IPC releated events */ function registerIpc() { - ipcMain.on('action', async (event, action) => { + ipcMain.on('action', async (event, action, ...args) => { + let data = null try { switch (action) { case 'clearCache': - await win.webContents.session.clearCache() + await manager.clearCache() break case 'clearStorage': - await win.webContents.session.clearStorageData({ + await manager.clearStorageData({ storages: [ 'appcache', 'cookies', @@ -279,13 +274,24 @@ function registerIpc() { ] }) break + case 'getDisplays': + data = screen.getAllDisplays().map(display => { + return { + id: display.id, + label: display.label + } + }) + break + case 'settingsUpdated': + onSettingsChanged(...args) + break default: break } } catch (error) { console.error(error) } - event.reply('action', action) + event.reply('action', action, data) }) } @@ -304,9 +310,8 @@ if (!gotTheLock) { // When another instance is started, focus the already running instance app.on('second-instance', () => { // Someone tried to run a second instance, we should focus our window. - if (win) { - if (win.isMinimized()) win.restore() - win.focus() + if (manager) { + manager.focus() } }) @@ -314,7 +319,7 @@ if (!gotTheLock) { app.on('window-all-closed', () => { // On macOS it is common for applications and their menu bar // to stay active until the user quits explicitly with Cmd + Q - if (process.platform !== 'darwin') { + if (process.platform !== 'darwin' && !restarting) { app.quit() } }) @@ -322,7 +327,7 @@ if (!gotTheLock) { app.on('activate', () => { // On macOS it's common to re-create a window in the app when the // dock icon is clicked and there are no other windows open. - if (BrowserWindow.getAllWindows().length === 0) createWindow() + if (BrowserWindow.getAllWindows().length === 0) setupWindowsManager() }) // This method will be called when Electron has finished @@ -351,7 +356,7 @@ if (!gotTheLock) { registerShortcuts() registerIpc() setupStore() - createWindow() + setupWindowsManager() }) // Ignore certificates errors on page diff --git a/src/preload/index.js b/src/preload/index.js index 521d16f..1c5a65a 100644 --- a/src/preload/index.js +++ b/src/preload/index.js @@ -4,11 +4,11 @@ import store from '../store' // Expose protected methods that allow the renderer process to use // the ipcRenderer without exposing the entire object contextBridge.exposeInMainWorld('ipc', { - send: (channel, data) => { + send: (channel, action, ...args) => { // whitelist channels const validChannels = ['action'] if (validChannels.includes(channel)) { - ipcRenderer.send(channel, data) + ipcRenderer.send(channel, action, ...args) } }, on: (channel, func) => { diff --git a/src/renderer/src/App.vue b/src/renderer/src/App.vue index 7165013..ab6a80f 100644 --- a/src/renderer/src/App.vue +++ b/src/renderer/src/App.vue @@ -14,6 +14,7 @@ + + + + + + +

Displays

+ + + + + + + + + + + +
+ + + Add display + +
+
+ + + ({ + displayId: '', settings: { autoReloadMode: 'every' }, + displays: [], valid: true, snackbar: { show: false, @@ -178,7 +271,8 @@ export default { hours: new Array(24).fill(0).map((v, i) => ({ title: `${i < 10 ? '0' : ''}${i}:00`, value: i - })) + })), + required: v => v != null || 'This is required' }), computed: { store() { @@ -186,7 +280,10 @@ export default { } }, mounted() { - window.ipc.on('action', action => { + const urlParams = new URLSearchParams(window.location.search) + this.displayId = parseInt(urlParams.get('displayId') ?? 0, 10) + + window.ipc.on('action', (action, data) => { let text = '' const color = 'success' @@ -197,6 +294,13 @@ export default { case 'clearStorage': text = 'Storage cleared!' break + case 'settingsUpdated': + text = 'Settings updated!' + break + case 'getDisplays': + this.displays = data + return + default: text = `Unknown action ${action}` break @@ -210,17 +314,32 @@ export default { }) this.settings = this.store.settings() + this.sendAction('getDisplays') if (this.settings.autoLoad) { this.checkUrl() } }, + methods: { parse, + validDisplay(id) { + if (!this.displays.find(d => d.id === id)) { + return 'Invalid display' + } + if (this.settings.displays.filter(d => d.id === id).length > 1) { + return 'Duplicate display' + } + + return true + }, updateSettings() { if (this.$refs.form.validate()) { this.settings.autoLoad = true - this.store.setSettings({ ...this.settings }) + const oldSettings = this.store.settings() + const newSettings = JSON.parse(JSON.stringify(this.settings)) + this.store.setSettings(newSettings) + this.sendAction('settingsUpdated', newSettings, oldSettings) this.checkUrl() } }, @@ -238,14 +357,27 @@ export default { }, async checkUrl() { try { + const url = this.displayId + ? this.settings.displays.find(d => d.id === this.displayId) + ?.url + : this.settings.url + + if (!url) { + this.snackbar = { + show: true, + text: 'No URL to load found', + color: 'error' + } + return + } // check if url is reachable - await fetch(this.settings.url, { + await fetch(url, { method: 'HEAD' }) this.urlReady = true // redirect to url - window.location.href = this.settings.url + window.location.href = url } catch (error) { // noop } @@ -256,8 +388,8 @@ export default { }, 2000) } }, - async sendAction(action) { - window.ipc.send('action', action) + async sendAction(action, ...args) { + window.ipc.send('action', action, ...args) }, isNumber(value, coerce = false) { if (coerce) { diff --git a/src/store.js b/src/store.js index 8e4e226..58fe53b 100644 --- a/src/store.js +++ b/src/store.js @@ -14,6 +14,27 @@ const store = new Store({ type: 'boolean', default: false }, + multipleDisplays: { + type: 'boolean', + default: false + }, + displays: { + type: 'array', + default: [], + items: { + type: 'object', + properties: { + id: { + type: 'number', + default: 0 + }, + url: { + type: 'string', + default: '' + } + } + } + }, dark: { type: 'boolean', default: true