nativefier/app/src/components/menu.ts

413 lines
10 KiB
TypeScript

import * as fs from 'fs';
import path from 'path';
import {
BrowserWindow,
clipboard,
Menu,
MenuItem,
MenuItemConstructorOptions,
} from 'electron';
import * as log from 'loglevel';
import { isOSX, openExternal } from '../helpers/helpers';
import {
clearAppData,
getCurrentURL,
goBack,
goForward,
goToURL,
zoomIn,
zoomOut,
zoomReset,
} from '../helpers/windowHelpers';
import { OutputOptions } from '../../../shared/src/options/model';
type BookmarksLink = {
type: 'link';
title: string;
url: string;
shortcut?: string;
};
type BookmarksSeparator = {
type: 'separator';
};
type BookmarkConfig = BookmarksLink | BookmarksSeparator;
type BookmarksMenuConfig = {
menuLabel: string;
bookmarks: BookmarkConfig[];
};
export function createMenu(
options: OutputOptions,
mainWindow: BrowserWindow,
): void {
log.debug('createMenu', { options, mainWindow });
const menuTemplate = generateMenu(options, mainWindow);
injectBookmarks(menuTemplate);
const menu = Menu.buildFromTemplate(menuTemplate);
Menu.setApplicationMenu(menu);
}
export function generateMenu(
options: {
disableDevTools: boolean;
nativefierVersion: string;
zoom?: number;
},
mainWindow: BrowserWindow,
): MenuItemConstructorOptions[] {
const { nativefierVersion, zoom, disableDevTools } = options;
const zoomResetLabel =
!zoom || zoom === 1.0
? 'Reset Zoom'
: `Reset Zoom (to ${(zoom * 100).toFixed(1)}%, set at build time)`;
const editMenu: MenuItemConstructorOptions = {
label: '&Edit',
submenu: [
{
label: 'Undo',
accelerator: 'CmdOrCtrl+Z',
role: 'undo',
},
{
label: 'Redo',
accelerator: 'Shift+CmdOrCtrl+Z',
role: 'redo',
},
{
type: 'separator',
},
{
label: 'Cut',
accelerator: 'CmdOrCtrl+X',
role: 'cut',
},
{
label: 'Copy',
accelerator: 'CmdOrCtrl+C',
role: 'copy',
},
{
label: 'Copy Current URL',
accelerator: 'CmdOrCtrl+L',
click: (): void => clipboard.writeText(getCurrentURL()),
},
{
label: 'Paste',
accelerator: 'CmdOrCtrl+V',
role: 'paste',
},
{
label: 'Paste and Match Style',
accelerator: 'CmdOrCtrl+Shift+V',
role: 'pasteAndMatchStyle',
},
{
label: 'Select All',
accelerator: 'CmdOrCtrl+A',
role: 'selectAll',
},
{
label: 'Clear App Data',
click: (
item: MenuItem,
focusedWindow: BrowserWindow | undefined,
): void => {
log.debug('Clear App Data.click', {
item,
focusedWindow,
mainWindow,
});
if (!focusedWindow) {
focusedWindow = mainWindow;
}
clearAppData(focusedWindow).catch((err) =>
log.error('clearAppData ERROR', err),
);
},
},
],
};
const viewMenu: MenuItemConstructorOptions = {
label: '&View',
submenu: [
{
label: 'Back',
accelerator: isOSX() ? 'CmdOrAlt+Left' : 'Alt+Left',
click: goBack,
},
{
label: 'BackAdditionalShortcut',
visible: false,
acceleratorWorksWhenHidden: true,
accelerator: 'CmdOrCtrl+[', // What old versions of Nativefier used, kept for backwards compat
click: goBack,
},
{
label: 'Forward',
accelerator: isOSX() ? 'Cmd+Right' : 'Alt+Right',
click: goForward,
},
{
label: 'ForwardAdditionalShortcut',
visible: false,
acceleratorWorksWhenHidden: true,
accelerator: 'CmdOrCtrl+]', // What old versions of Nativefier used, kept for backwards compat
click: goForward,
},
{
label: 'Reload',
role: 'reload',
},
{
type: 'separator',
},
{
label: 'Toggle Full Screen',
accelerator: isOSX() ? 'Ctrl+Cmd+F' : 'F11',
enabled: mainWindow.isFullScreenable() || isOSX(),
visible: mainWindow.isFullScreenable() || isOSX(),
click: (
item: MenuItem,
focusedWindow: BrowserWindow | undefined,
): void => {
log.debug('Toggle Full Screen.click()', {
item,
focusedWindow,
isFullScreen: focusedWindow?.isFullScreen(),
isFullScreenable: focusedWindow?.isFullScreenable(),
});
if (!focusedWindow) {
focusedWindow = mainWindow;
}
if (focusedWindow.isFullScreenable()) {
focusedWindow.setFullScreen(!focusedWindow.isFullScreen());
} else if (isOSX()) {
focusedWindow.setSimpleFullScreen(
!focusedWindow.isSimpleFullScreen(),
);
}
},
},
{
label: 'Zoom In',
accelerator: 'CmdOrCtrl+=',
click: zoomIn,
},
{
label: 'ZoomInAdditionalShortcut',
visible: false,
acceleratorWorksWhenHidden: true,
accelerator: 'CmdOrCtrl+numadd',
click: zoomIn,
},
{
label: 'Zoom Out',
accelerator: 'CmdOrCtrl+-',
click: zoomOut,
},
{
label: 'ZoomOutAdditionalShortcut',
visible: false,
acceleratorWorksWhenHidden: true,
accelerator: 'CmdOrCtrl+numsub',
click: zoomOut,
},
{
label: zoomResetLabel,
accelerator: 'CmdOrCtrl+0',
click: (): void => zoomReset(options),
},
{
label: 'ZoomResetAdditionalShortcut',
visible: false,
acceleratorWorksWhenHidden: true,
accelerator: 'CmdOrCtrl+num0',
click: (): void => zoomReset(options),
},
],
};
if (!disableDevTools) {
(viewMenu.submenu as MenuItemConstructorOptions[]).push(
{
type: 'separator',
},
{
label: 'Toggle Developer Tools',
accelerator: isOSX() ? 'Alt+Cmd+I' : 'Ctrl+Shift+I',
click: (item: MenuItem, focusedWindow: BrowserWindow | undefined) => {
log.debug('Toggle Developer Tools.click()', { item, focusedWindow });
if (!focusedWindow) {
focusedWindow = mainWindow;
}
focusedWindow.webContents.toggleDevTools();
},
},
);
}
const windowMenu: MenuItemConstructorOptions = {
label: '&Window',
role: 'window',
submenu: [
{
label: 'Minimize',
accelerator: 'CmdOrCtrl+M',
role: 'minimize',
},
{
label: 'Close',
accelerator: 'CmdOrCtrl+W',
role: 'close',
},
],
};
const helpMenu: MenuItemConstructorOptions = {
label: '&Help',
role: 'help',
submenu: [
{
label: `Built with Nativefier v${nativefierVersion}`,
click: (): void => {
openExternal('https://github.com/nativefier/nativefier').catch(
(err: unknown): void =>
log.error(
'Built with Nativefier v${nativefierVersion}.click ERROR',
err,
),
);
},
},
{
label: 'Report an Issue',
click: (): void => {
openExternal('https://github.com/nativefier/nativefier/issues').catch(
(err: unknown): void =>
log.error('Report an Issue.click ERROR', err),
);
},
},
],
};
let menuTemplate: MenuItemConstructorOptions[];
if (isOSX()) {
const electronMenu: MenuItemConstructorOptions = {
label: 'E&lectron',
submenu: [
{
label: 'Services',
role: 'services',
submenu: [],
},
{
type: 'separator',
},
{
label: 'Hide App',
accelerator: 'Cmd+H',
role: 'hide',
},
{
label: 'Hide Others',
accelerator: 'Cmd+Shift+H',
role: 'hideOthers',
},
{
label: 'Show All',
role: 'unhide',
},
{
type: 'separator',
},
{
label: 'Quit',
accelerator: 'Cmd+Q',
role: 'quit',
},
],
};
(windowMenu.submenu as MenuItemConstructorOptions[]).push(
{
type: 'separator',
},
{
label: 'Bring All to Front',
role: 'front',
},
);
menuTemplate = [electronMenu, editMenu, viewMenu, windowMenu, helpMenu];
} else {
menuTemplate = [editMenu, viewMenu, windowMenu, helpMenu];
}
return menuTemplate;
}
function injectBookmarks(menuTemplate: MenuItemConstructorOptions[]): void {
const bookmarkConfigPath = path.join(__dirname, '..', 'bookmarks.json');
if (!fs.existsSync(bookmarkConfigPath)) {
return;
}
try {
const bookmarksMenuConfig = JSON.parse(
fs.readFileSync(bookmarkConfigPath, 'utf-8'),
) as BookmarksMenuConfig;
const submenu: MenuItemConstructorOptions[] =
bookmarksMenuConfig.bookmarks.map((bookmark) => {
switch (bookmark.type) {
case 'link':
if (!('title' in bookmark && 'url' in bookmark)) {
throw new Error(
'All links in the bookmarks menu must have a title and url.',
);
}
try {
new URL(bookmark.url);
} catch {
throw new Error('Bookmark URL "' + bookmark.url + '"is invalid.');
}
return {
label: bookmark.title,
click: (): void => {
goToURL(bookmark.url)?.catch((err: unknown): void =>
log.error(`${bookmark.title}.click ERROR`, err),
);
},
accelerator:
'shortcut' in bookmark ? bookmark.shortcut : undefined,
};
case 'separator':
return {
type: 'separator',
};
default:
throw new Error(
'A bookmarks menu entry has an invalid type; type must be one of "link", "separator".',
);
}
});
const bookmarksMenu: MenuItemConstructorOptions = {
label: bookmarksMenuConfig.menuLabel,
submenu,
};
// Insert custom bookmarks menu between menus "View" and "Window"
menuTemplate.splice(menuTemplate.length - 2, 0, bookmarksMenu);
} catch (err: unknown) {
log.error('Failed to load & parse bookmarks configuration JSON file.', err);
}
}