diff --git a/apps/electron/package.json b/apps/electron/package.json index 50fc82fd7..22b10badb 100644 --- a/apps/electron/package.json +++ b/apps/electron/package.json @@ -10,7 +10,7 @@ "timer" ], "license": "AGPL-3.0-only", - "main": "main.js", + "main": "src/main.js", "devDependencies": { "electron": "^31.2.0", "electron-builder": "^24.13.3", @@ -77,7 +77,7 @@ }, "files": [ "**/*", - "assets/", + "src/assets/", "!**/{yarn.lock,yarn-error.log}", "!**/{pnpm-lock.yaml}", "!**/{test,tests,__test__,__tests__}", @@ -85,7 +85,7 @@ "!*{.spec.js,*.test.js,*.spec.ts,.test.ts}" ], "directories": { - "buildResources": "./assets/" + "buildResources": "./src/assets/" }, "extraResources": [ { diff --git a/apps/electron/assets/background.png b/apps/electron/src/assets/background.png similarity index 100% rename from apps/electron/assets/background.png rename to apps/electron/src/assets/background.png diff --git a/apps/electron/assets/background@2x.png b/apps/electron/src/assets/background@2x.png similarity index 100% rename from apps/electron/assets/background@2x.png rename to apps/electron/src/assets/background@2x.png diff --git a/apps/electron/assets/icon.icns b/apps/electron/src/assets/icon.icns similarity index 100% rename from apps/electron/assets/icon.icns rename to apps/electron/src/assets/icon.icns diff --git a/apps/electron/assets/icon.ico b/apps/electron/src/assets/icon.ico similarity index 100% rename from apps/electron/assets/icon.ico rename to apps/electron/src/assets/icon.ico diff --git a/apps/electron/assets/icon.png b/apps/electron/src/assets/icon.png similarity index 100% rename from apps/electron/assets/icon.png rename to apps/electron/src/assets/icon.png diff --git a/apps/electron/assets/logo.png b/apps/electron/src/assets/logo.png similarity index 100% rename from apps/electron/assets/logo.png rename to apps/electron/src/assets/logo.png diff --git a/apps/electron/assets/ontime-install.bmp b/apps/electron/src/assets/ontime-install.bmp similarity index 100% rename from apps/electron/assets/ontime-install.bmp rename to apps/electron/src/assets/ontime-install.bmp diff --git a/apps/electron/assets/ontime-uninstall.bmp b/apps/electron/src/assets/ontime-uninstall.bmp similarity index 100% rename from apps/electron/assets/ontime-uninstall.bmp rename to apps/electron/src/assets/ontime-uninstall.bmp diff --git a/apps/electron/electron.config.js b/apps/electron/src/electron.config.js similarity index 81% rename from apps/electron/electron.config.js rename to apps/electron/src/electron.config.js index 6dddd5c44..e9243673a 100644 --- a/apps/electron/electron.config.js +++ b/apps/electron/src/electron.config.js @@ -7,7 +7,7 @@ module.exports = { production: (port = 4001) => `http://localhost:${port}`, }, server: { - pathToEntrypoint: '../extraResources/server/index.cjs', + pathToEntrypoint: '../../extraResources/server/index.cjs', }, assets: { pathToAssets: './assets/', diff --git a/apps/electron/src/external.js b/apps/electron/src/external.js new file mode 100644 index 000000000..992c72135 --- /dev/null +++ b/apps/electron/src/external.js @@ -0,0 +1,66 @@ +/** + * This file contains a list of constants that may need to be resolved at runtime + */ +const path = require('path'); + +const { version } = require('../package.json'); + +const electronConfig = require('./electron.config.js'); + +// external links +const linkToDocs = 'https://docs.getontime.no/'; +const linkToGitHub = 'https://github.com/cpvalente/ontime'; +const linkToDiscord = 'https://discord.com/invite/eje3CSUEXm'; + +// environment and platform constants +const env = process.env.NODE_ENV || 'production'; +const isProduction = env === 'production'; +const isMac = process.platform === 'darwin'; +const isWindows = process.platform === 'win32'; + +const releaseTag = `v${version}`; + +/** path to server directory */ +const nodePath = isProduction + ? path.join(__dirname, electronConfig.server.pathToEntrypoint) + : path.join(__dirname, '../../server/dist/index.cjs'); + +/** + * Resolves correct URL for client + * @param {number | undefined} port - the port at which the server is running + * @returns {string} + */ +const getClientUrl = (port) => + isProduction ? electronConfig.reactAppUrl.production(port) : electronConfig.reactAppUrl.development(port); + +/** + * Resolves correct URL for server + * @param {number | undefined} port - the port at which the server is running + * @returns {string} + */ +const getServerUrl = (port) => `http://localhost:${port}`; + +/** Resolves URL path to download resources */ +const downloadPath = '/data/db/'; + +/** path to tray icon */ +const trayIcon = path.join(__dirname, electronConfig.assets.pathToAssets, 'background.png'); +/** path to app icon directory */ +const appIcon = path.join(__dirname, electronConfig.assets.pathToAssets, 'logo.png'); + +module.exports = { + linkToDocs, + linkToGitHub, + linkToDiscord, + env, + isProduction, + isMac, + isWindows, + releaseTag, + nodePath, + getClientUrl, + getServerUrl, + downloadPath, + trayIcon, + appIcon, +}; diff --git a/apps/electron/main.js b/apps/electron/src/main.js similarity index 73% rename from apps/electron/main.js rename to apps/electron/src/main.js index edc3966cb..166cad083 100644 --- a/apps/electron/main.js +++ b/apps/electron/src/main.js @@ -1,18 +1,20 @@ const { app, BrowserWindow, Menu, globalShortcut, Tray, dialog, ipcMain, shell, Notification } = require('electron'); const path = require('path'); -const electronConfig = require('./electron.config'); -const { version } = require('./package.json'); -const { getApplicationMenu } = require('./src/menu/applicationMenu.js'); -const env = process.env.NODE_ENV || 'production'; -const isProduction = env === 'production'; -const isMac = process.platform === 'darwin'; -const isWindows = process.platform === 'win32'; +const { getApplicationMenu } = require('./menu/applicationMenu.js'); +const { getTrayMenu } = require('./menu/trayMenu.js'); -// path to server -const nodePath = isProduction - ? path.join(__dirname, electronConfig.server.pathToEntrypoint) - : path.join(__dirname, '../server/dist/index.cjs'); +const electronConfig = require('./electron.config.js'); +const { + env, + isProduction, + isWindows, + nodePath, + getClientUrl, + trayIcon, + appIcon, + getServerUrl, +} = require('./external.js'); if (!isProduction) { console.log(`Electron running in ${env} environment`); @@ -20,10 +22,13 @@ if (!isProduction) { process.traceProcessWarnings = true; } -// path to icons -const trayIcon = path.join(__dirname, electronConfig.assets.pathToAssets, 'background.png'); -const appIcon = path.join(__dirname, electronConfig.assets.pathToAssets, 'logo.png'); -let loaded = 'Nothing loaded'; +/** Flag holds server loading state */ +let loaded = 'Ontime running'; + +/** + * Flag whether user has requested a quit + * Used to coordinate window closes without exit + */ let isQuitting = false; // initialise @@ -56,13 +61,13 @@ async function startBackend() { /** * @description utility function to create a notification - * @param title - * @param text + * @param {string} title - Notification title + * @param {string} body - Notification body */ -function showNotification(title, text) { +function showNotification(title, body) { new Notification({ title, - body: text, + body, silent: true, }).show(); } @@ -141,7 +146,7 @@ function createWindow() { skipTaskbar: true, }); splash.setIgnoreMouseEvents(true); - const splashPath = path.join('file://', __dirname, '/src/splash/splash.html'); + const splashPath = path.join('file://', __dirname, '/splash/splash.html'); splash.loadURL(splashPath); win = new BrowserWindow({ @@ -156,7 +161,7 @@ function createWindow() { enableWebSQL: false, darkTheme: true, webPreferences: { - preload: path.join(__dirname, './src/preload.js'), + preload: path.join(__dirname, './preload.js'), nodeIntegration: true, contextIsolation: false, }, @@ -173,18 +178,19 @@ app.whenReady().then(() => { } createWindow(); - startBackend() .then((port) => { - // Load page served by node or use React dev run - const clientUrl = isProduction - ? electronConfig.reactAppUrl.production(port) - : electronConfig.reactAppUrl.development(port); - - const template = getApplicationMenu(isMac, askToQuit, clientUrl, `v${version}`, (path) => { - win.loadURL(`${clientUrl}/${path}`); - }); - const menu = Menu.buildFromTemplate(template); + const clientUrl = getClientUrl(port); + const serverUrl = getServerUrl(port); + const menu = getApplicationMenu( + askToQuit, + clientUrl, + serverUrl, + (path) => { + win.loadURL(`${clientUrl}/${path}`); + }, + (url) => win.webContents.downloadURL(url), + ); Menu.setApplicationMenu(menu); win @@ -229,13 +235,9 @@ app.whenReady().then(() => { } }); - // create tray + // create tray and set its context menu tray = new Tray(trayIcon); - - // Define context menu - const { getTrayMenu } = require('./src/menu/trayMenu.js'); - const trayMenuTemplate = getTrayMenu(bringToFront, askToQuit); - const trayContextMenu = Menu.buildFromTemplate(trayMenuTemplate); + const trayContextMenu = getTrayMenu(bringToFront, askToQuit); tray.setContextMenu(trayContextMenu); }); @@ -261,7 +263,7 @@ ipcMain.on('shutdown', () => { /** * Handles requests to set window properties */ -ipcMain.on('set-window', (event, arg) => { +ipcMain.on('set-window', (_event, arg) => { switch (arg) { case 'show-dev': win.webContents.openDevTools({ mode: 'detach' }); @@ -274,6 +276,10 @@ ipcMain.on('set-window', (event, arg) => { /** * Handles requests to open external links */ -ipcMain.on('send-to-link', (event, arg) => { - shell.openExternal(arg); +ipcMain.on('send-to-link', (_event, arg) => { + try { + shell.openExternal(arg); + } catch (_error) { + /** unhandled error */ + } }); diff --git a/apps/electron/src/menu/applicationMenu.js b/apps/electron/src/menu/applicationMenu.js index 9d2b906a8..a639ead63 100644 --- a/apps/electron/src/menu/applicationMenu.js +++ b/apps/electron/src/menu/applicationMenu.js @@ -1,185 +1,205 @@ -const { shell } = require('electron'); +const { Menu, shell } = require('electron'); + +const { linkToGitHub, linkToDocs, linkToDiscord, isMac, releaseTag, downloadPath } = require('../external'); + /** - * Build description of application menu - * @param {boolean} isMac - Whether the target platform is mac + * Creates the application menu * @param {function} askToQuit - function for quitting process + * @param {string} clientUrl - base url for the application + * @param {string} serverUrl - base url for the application + * @param {function} redirectWindow - function to redirect main window content + * @param {function} download - function to download a resource from url + * @returns {Menu} - application menu */ -function getApplicationMenu(isMac, askToQuit, urlBase, version, redirectWindow) { - return [ - ...(isMac - ? [ - { - label: 'Ontime', - submenu: [ - { role: 'about' }, - { type: 'separator' }, - { role: 'hide' }, - { role: 'hideOthers' }, - { role: 'unhide' }, - { type: 'separator' }, - { - label: 'Quit', - click: () => askToQuit(), - accelerator: 'Cmd+Q', - }, - ], - }, - ] - : []), - { - label: 'File', - submenu: [isMac ? { role: 'close' } : { role: 'quit' }], - }, - { - label: 'Edit', - submenu: [ - { role: 'undo' }, - { role: 'redo' }, - { type: 'separator' }, - { role: 'cut' }, - { role: 'copy' }, - { role: 'paste' }, - ...(isMac - ? [ - { role: 'pasteAndMatchStyle' }, - { role: 'delete' }, - { role: 'selectAll' }, - { type: 'separator' }, - { - label: 'Speech', - submenu: [{ role: 'startSpeaking' }, { role: 'stopSpeaking' }], - }, - ] - : [{ role: 'delete' }, { type: 'separator' }, { role: 'selectAll' }]), - ], - }, - { - label: 'Views', - submenu: [ - { - label: 'Ontime Views (opens in browser)', - submenu: [ - { - label: 'Public', - click: async () => { - await shell.openExternal(`${urlBase}/public`); - }, - }, - { - label: 'Lower Thirds', - click: async () => { - await shell.openExternal(`${urlBase}/lower`); - }, - }, - { type: 'separator' }, - { - label: 'Timer', - accelerator: 'CmdOrCtrl+V', - click: async () => { - await shell.openExternal(`${urlBase}/timer`); - }, - }, - { - label: 'Clock', - click: async () => { - await shell.openExternal(`${urlBase}/clock`); - }, - }, - { - label: 'Minimal Timer', - click: async () => { - await shell.openExternal(`${urlBase}/minimal`); - }, - }, - { - label: 'Backstage', - click: async () => { - await shell.openExternal(`${urlBase}/backstage`); - }, - }, - { - label: 'Timeline (beta)', - click: async () => { - await shell.openExternal(`${urlBase}/timeline`); - }, - }, - { - label: 'Studio Clock', - click: async () => { - await shell.openExternal(`${urlBase}/studio`); - }, - }, - { - label: 'Countdown', - click: async () => { - await shell.openExternal(`${urlBase}/countdown`); - }, - }, - { type: 'separator' }, - { - label: 'Editor', - click: async () => { - await shell.openExternal(`${urlBase}/editor`); - }, - }, - { - label: 'Cuesheet', - click: async () => { - await shell.openExternal(`${urlBase}/cuesheet`); - }, - }, - { - label: 'Operator', - click: async () => { - await shell.openExternal(`${urlBase}/op`); - }, - }, - ], - }, - { type: 'separator' }, - { role: 'forceReload' }, - { type: 'separator' }, - { role: 'resetZoom' }, - { role: 'zoomIn' }, - { role: 'zoomOut' }, - ], - }, - { - label: 'Window', - submenu: [ - { role: 'minimize' }, - { role: 'zoom' }, - ...(isMac - ? [{ type: 'separator' }, { role: 'front' }, { type: 'separator' }, { role: 'window' }] - : [{ role: 'close' }]), - ], - }, - { - role: 'help', - submenu: [ - { - label: 'About', - click: () => redirectWindow('editor?settings=about'), - }, - { - label: version, - click: () => redirectWindow('editor?settings=about'), - }, - { - label: 'See on github', - click: async () => { - await shell.openExternal('https://github.com/cpvalente/ontime'); - }, - }, - { - label: 'Online documentation', - click: async () => { - await shell.openExternal('https://docs.getontime.no/'); - }, - }, - ], - }, +function getApplicationMenu(askToQuit, clientUrl, serverUrl, redirectWindow, download) { + const template = [ + ...(isMac ? [makeMacMenu(askToQuit)] : []), + makeFileMenu(serverUrl, redirectWindow, download), + makeViewMenu(clientUrl), + makeSettingsMenu(redirectWindow), + makeHelpMenu(redirectWindow), ]; + return Menu.buildFromTemplate(template); +} + +/** + * Utility function generates the app menu (macOS only) + * @param {function} askToQuit - function for quitting process + * @returns {Object} + */ +function makeMacMenu(askToQuit) { + return { + label: 'Ontime', + submenu: [ + { role: 'about', label: 'About Ontime' }, + { type: 'separator' }, + { role: 'hide', label: 'Hide Ontime' }, + { role: 'hideOthers' }, + { role: 'unhide' }, + { type: 'separator' }, + { + label: 'Quit', + click: () => askToQuit(), + accelerator: isMac ? 'Cmd+Q' : 'Alt+F4', + }, + ], + }; +} + +/** + * Utility function generates the file menu + * @param {string} serverUrl - base url for the application + * @param {function} redirectWindow - function to redirect main window content + * @param {function} download - function to download a resource from url + * @returns {Object} + */ +function makeFileMenu(serverUrl, redirectWindow, download) { + const downloadProject = () => { + try { + download(serverUrl + downloadPath); + } catch (_error) { + /** unhandled error */ + } + }; + + return { + label: 'File', + submenu: [ + { + label: 'New project...', + click: () => redirectWindow('settings=project__manage&create=true'), + }, + { + label: 'Edit project info', + click: () => redirectWindow('editor?settings=project__data'), + }, + { + label: 'Manage projects...', + click: () => redirectWindow('editor?settings=project__manage'), + }, + { + label: 'Download project', + click: downloadProject, + }, + { role: isMac ? 'close' : 'quit' }, + ], + }; +} + +/** + * Utility function generates the views menu + * @param {string} clientUrl - base url for the application + * @returns {Object} + */ +function makeViewMenu(clientUrl) { + return { + label: 'Views', + submenu: [ + makeItemOpenInShell('Public', `${clientUrl}/public`), + makeItemOpenInShell('Lower Thirds', `${clientUrl}/lower`), + { type: 'separator' }, + makeItemOpenInShell('Timer', `${clientUrl}/timer`), + makeItemOpenInShell('Minimal Timer', `${clientUrl}/minimal`), + makeItemOpenInShell('Clock', `${clientUrl}/clock`), + makeItemOpenInShell('Backstage', `${clientUrl}/backstage`), + makeItemOpenInShell('Timeline (beta)', `${clientUrl}/timeline`), + makeItemOpenInShell('Studio Clock', `${clientUrl}/studio`), + makeItemOpenInShell('Countdown', `${clientUrl}/countdown`), + { type: 'separator' }, + makeItemOpenInShell('Editor', `${clientUrl}/editor`), + makeItemOpenInShell('Cuesheet', `${clientUrl}/cuesheet`), + makeItemOpenInShell('Operator', `${clientUrl}/op`), + + { type: 'separator' }, + { role: 'forceReload' }, + { type: 'separator' }, + { role: 'resetZoom' }, + { role: 'zoomIn' }, + { role: 'zoomOut' }, + { type: 'separator' }, + { role: 'togglefullscreen' }, + ], + }; +} + +/** + * Utility function generates the settings menu + * @param {function} redirectWindow - function to redirect main window content + * @returns {Object} + */ +function makeSettingsMenu(redirectWindow) { + return { + label: 'Settings', + submenu: [ + { + label: 'Open settings', + accelerator: 'CommandOrControl+,', + click: () => redirectWindow('editor?settings=project'), + }, + { + label: 'App settings', + click: () => redirectWindow('editor?settings=general'), + }, + { + label: 'Editor settings', + click: () => redirectWindow('editor?settings=general__editor'), + }, + { + label: 'View settings', + click: () => redirectWindow('editor?settings=general__view'), + }, + { + label: 'Integrations', + click: () => redirectWindow('editor?settings=integrations'), + }, + { + label: 'Network', + click: () => redirectWindow('editor?settings=network'), + }, + ], + }; +} + +/** + * Utility function generates the help menu + * @param {function} redirectWindow - function to redirect main window content + * @returns {Object} + */ +function makeHelpMenu(redirectWindow) { + return { + role: 'help', + submenu: [ + { + label: `Ontime ${releaseTag}`, + click: () => redirectWindow('editor?settings=about'), + }, + { + type: 'separator', + }, + makeItemOpenInShell('See on github', linkToGitHub), + makeItemOpenInShell('Online documentation', linkToDocs), + makeItemOpenInShell('Join us on Discord', linkToDiscord), + ], + }; +} + +/** + * Utility function to safely open a URL in the default browser + * @param {string} label + * @param {string} url + * @returns {object} - MenuItem + */ +function makeItemOpenInShell(label, url) { + return { + label: `${label} ↗`, + click: async () => { + try { + await shell.openExternal(url); + } catch (_error) { + /** unhandled error */ + } + }, + }; } module.exports = { getApplicationMenu }; diff --git a/apps/electron/src/menu/trayMenu.js b/apps/electron/src/menu/trayMenu.js index 242ee7b88..97eea05a4 100644 --- a/apps/electron/src/menu/trayMenu.js +++ b/apps/electron/src/menu/trayMenu.js @@ -1,19 +1,22 @@ +const { Menu } = require('electron'); + /** - * Build description of tray context menu + * Creates the application tray menu * @param {function} showApp - function for making the window visible * @param {function} askToQuit - function for quitting process + * @returns {Menu} - application tray menu */ function getTrayMenu(showApp, askToQuit) { - return [ + return Menu.buildFromTemplate([ { label: 'Show App', - click: () => showApp(), + click: showApp, }, { label: 'Shutdown', - click: () => askToQuit(), + click: askToQuit, }, - ]; + ]); } module.exports = { getTrayMenu }; diff --git a/apps/electron/src/splash/splash.html b/apps/electron/src/splash/splash.html index 8be547f91..d9b75c0d1 100644 --- a/apps/electron/src/splash/splash.html +++ b/apps/electron/src/splash/splash.html @@ -91,7 +91,7 @@
+