refactor: improve native menu links

This commit is contained in:
Carlos Valente
2024-10-03 20:58:38 +02:00
parent ea5de337cf
commit 220319be8a
15 changed files with 385 additions and 228 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 567 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

+15
View File
@@ -0,0 +1,15 @@
module.exports = {
appIni: {
shutdownCode: 99,
},
reactAppUrl: {
development: (port = 3000) => `http://localhost:${port}`,
production: (port = 4001) => `http://localhost:${port}`,
},
server: {
pathToEntrypoint: '../../extraResources/server/index.cjs',
},
assets: {
pathToAssets: './assets/',
},
};
+66
View File
@@ -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,
};
+285
View File
@@ -0,0 +1,285 @@
const { app, BrowserWindow, Menu, globalShortcut, Tray, dialog, ipcMain, shell, Notification } = require('electron');
const path = require('path');
const { getApplicationMenu } = require('./menu/applicationMenu.js');
const { getTrayMenu } = require('./menu/trayMenu.js');
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`);
console.log(`Ontime server at ${nodePath}`);
process.traceProcessWarnings = true;
}
/** 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
let win;
let splash;
let tray = null;
/**
* Coordinates the node process startup
* @returns {number} server port - the port at which the backend has been started at
*/
async function startBackend() {
// in dev mode, we expect both UI and server to be running
if (!isProduction) {
return;
}
const ontimeServer = require(nodePath);
const { initAssets, startServer, startIntegrations } = ontimeServer;
await initAssets();
const result = await startServer(escalateError);
loaded = result.message;
await startIntegrations();
return result.serverPort;
}
/**
* @description utility function to create a notification
* @param {string} title - Notification title
* @param {string} body - Notification body
*/
function showNotification(title, body) {
new Notification({
title,
body,
silent: true,
}).show();
}
/**
* Terminate node service and close electron app
*/
function appShutdown() {
// terminate node service
(async () => {
const ontimeServer = require(nodePath);
const { shutdown } = ontimeServer;
await shutdown(electronConfig.appIni.shutdownCode);
})();
isQuitting = true;
tray.destroy();
win.destroy();
app.quit();
}
/**
* Sets Ontime window in focus
*/
function bringToFront() {
win.show();
win.focus();
}
/**
* Coordinates the shutdown process
*/
function askToQuit() {
bringToFront();
win.send('user-request-shutdown');
}
/**
* Allows processes to escalate errors to be shown in electron
* @param {string} error
*/
function escalateError(error) {
dialog.showErrorBox('An unrecoverable error occurred', error);
}
// Ensure there isn't another instance of the app running already
const lock = app.requestSingleInstanceLock();
if (!lock) {
dialog.showErrorBox('Multiple instances', 'An instance of the App is already running.');
app.quit();
} else {
app.on('second-instance', () => {
// Someone tried to run a second instance, we should focus our window.
if (win) {
if (win.isMinimized()) {
win.restore();
}
bringToFront();
}
});
}
/**
* Coordinates creation of electron windows (splash and main)
*/
function createWindow() {
splash = new BrowserWindow({
width: 333,
height: 333,
transparent: true,
icon: appIcon,
resizable: false,
frame: false,
alwaysOnTop: true,
focusable: false,
skipTaskbar: true,
});
splash.setIgnoreMouseEvents(true);
const splashPath = path.join('file://', __dirname, '/splash/splash.html');
splash.loadURL(splashPath);
win = new BrowserWindow({
width: 1920,
height: 1000,
minWidth: 525,
minHeight: 405,
backgroundColor: '#101010', // $gray-1350
icon: appIcon,
show: false,
textAreasAreResizable: false,
enableWebSQL: false,
darkTheme: true,
webPreferences: {
preload: path.join(__dirname, './preload.js'),
nodeIntegration: true,
contextIsolation: false,
},
});
win.setMenu(null);
}
app.disableHardwareAcceleration();
app.whenReady().then(() => {
// Set app title in windows
if (isWindows) {
app.setAppUserModelId(app.name);
}
createWindow();
startBackend()
.then((port) => {
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
.loadURL(`${clientUrl}/editor`)
.then(() => {
win.webContents.setBackgroundThrottling(false);
win.show();
win.focus();
splash.destroy();
if (typeof loaded === 'string') {
tray.setToolTip(loaded);
} else {
tray.setToolTip('Initialising error: please restart Ontime');
}
})
.catch((error) => {
console.log('ERROR: Ontime failed to reach server', error);
});
})
.catch((error) => {
console.log('ERROR: Ontime failed to start', error);
});
/**
* recreate window if no others open
*/
app.on('activate', () => {
win.show();
});
/**
* Hide on close
*/
win.on('close', function (event) {
event.preventDefault();
if (!isQuitting) {
showNotification('Window Closed', 'App running in background');
win.hide();
}
});
// create tray and set its context menu
tray = new Tray(trayIcon);
const trayContextMenu = getTrayMenu(bringToFront, askToQuit);
tray.setContextMenu(trayContextMenu);
});
/**
* Unregister shortcuts before quitting
*/
app.once('will-quit', () => {
globalShortcut.unregisterAll();
});
// Ask for main window reload
// Test message
ipcMain.on('reload', () => {
win?.reload();
});
// Terminate
ipcMain.on('shutdown', () => {
console.log('Electron got IPC shutdown');
appShutdown();
});
/**
* Handles requests to set window properties
*/
ipcMain.on('set-window', (_event, arg) => {
switch (arg) {
case 'show-dev':
win.webContents.openDevTools({ mode: 'detach' });
break;
default:
console.log('Electron unhandled window request', arg);
}
});
/**
* Handles requests to open external links
*/
ipcMain.on('send-to-link', (_event, arg) => {
try {
shell.openExternal(arg);
} catch (_error) {
/** unhandled error */
}
});
+260 -178
View File
@@ -1,185 +1,267 @@
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('editor?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: 'Project',
submenu: [
{
label: 'Project data',
click: () => redirectWindow('editor?settings=project__data'),
},
{
label: 'Manage projects',
click: () => redirectWindow('editor?settings=project__manage'),
},
],
},
{
label: 'App Settings',
submenu: [
{
label: 'General settings',
click: () => redirectWindow('editor?settings=general__settings'),
},
{
label: 'Editor settings',
click: () => redirectWindow('editor?settings=general__editor'),
},
{
label: 'View settings',
click: () => redirectWindow('editor?settings=general__view'),
},
],
},
{
label: 'Feature Settings',
submenu: [
{
label: 'Custom fields',
click: () => redirectWindow('editor?settings=feature_settings__custom'),
},
{
label: 'URL presets',
click: () => redirectWindow('editor?settings=feature_settings__urlpresets'),
},
],
},
{
label: 'Data Sources',
submenu: [
{
label: 'Import spreadsheet',
click: () => redirectWindow('editor?settings=sources__xlsx'),
},
{
label: 'Sync with Google Sheet',
click: () => redirectWindow('editor?settings=sources__gsheet'),
},
],
},
{
label: 'Integrations',
submenu: [
{
label: 'OSC settings',
click: () => redirectWindow('editor?settings=integrations__osc'),
},
{
label: 'HTTP settings',
click: () => redirectWindow('editor?settings=integrations__http'),
},
],
},
{
label: 'Network',
submenu: [
{
label: 'Event log',
click: () => redirectWindow('editor?settings=network__log'),
},
{
label: 'Manage cleints',
click: () => redirectWindow('editor?settings=network__clients'),
},
],
},
],
};
}
/**
* 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 };
+8 -5
View File
@@ -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 };
+1 -1
View File
@@ -91,7 +91,7 @@
</head>
<body>
<div class="container">
<img src="../../assets/logo.png" />
<img src="../assets/logo.png" />
<h1>ontime · event timers</h1>
<div class="lds-ellipsis">
<div></div>