diff --git a/app/src/components/mainWindow.ts b/app/src/components/mainWindow.ts index 1cb01e7..3386260 100644 --- a/app/src/components/mainWindow.ts +++ b/app/src/components/mainWindow.ts @@ -10,21 +10,12 @@ import { getCounterValue, isOSX, nativeTabsSupported, - openExternal, } from '../helpers/helpers'; import { setupNativefierWindow } from '../helpers/windowEvents'; import { - clearAppData, clearCache, - getCurrentURL, getDefaultWindowOptions, - goBack, - goForward, - goToURL, hideWindow, - zoomIn, - zoomOut, - zoomReset, } from '../helpers/windowHelpers'; import { initContextMenu } from './contextMenu'; import { createMenu } from './menu'; @@ -47,12 +38,10 @@ type SessionInteractionResult = { /** * @param {{}} nativefierOptions AppArgs from nativefier.json - * @param {function} onAppQuit * @param {function} setDockBadge */ export async function createMainWindow( nativefierOptions, - onAppQuit: () => void, setDockBadge: (value: number | string, bounce?: boolean) => void, ): Promise { const options = { ...nativefierOptions }; @@ -74,8 +63,7 @@ export async function createMainWindow( y: options.y, autoHideMenuBar: !options.showMenuBar, icon: getAppIcon(), - // set to undefined and not false because explicitly setting to false will disable full screen - fullscreen: options.fullScreen ?? undefined, + fullscreen: options.fullScreen, // Whether the window should always stay on top of other windows. Default is false. alwaysOnTop: options.alwaysOnTop, titleBarStyle: options.titleBarStyle, @@ -96,8 +84,7 @@ export async function createMainWindow( if (options.tray === 'start-in-tray') { mainWindow.hide(); } - - createMainMenu(options, mainWindow, onAppQuit); + createMenu(options, mainWindow); createContextMenu(options, mainWindow); setupNativefierWindow(options, mainWindow); @@ -180,30 +167,6 @@ function setupCounter( }); } -function createMainMenu( - options: any, - window: BrowserWindow, - onAppQuit: () => void, -) { - const menuOptions = { - nativefierVersion: options.nativefierVersion, - appQuit: onAppQuit, - clearAppData: () => clearAppData(window), - disableDevTools: options.disableDevTools, - getCurrentURL, - goBack, - goForward, - goToURL, - openExternal, - zoomBuildTimeValue: options.zoom, - zoomIn, - zoomOut, - zoomReset, - }; - - createMenu(menuOptions); -} - function setupNotificationBadge( options, window: BrowserWindow, @@ -275,7 +238,7 @@ function setupSessionInteraction(options, window: BrowserWindow): void { result.value = window.webContents.session[request.property]; } else { // Why even send the event if you're going to do this? You're just wasting time! ;) - throw Error( + throw new Error( 'Received neither a func nor a property in the request. Unable to process.', ); } diff --git a/app/src/components/menu.test.ts b/app/src/components/menu.test.ts new file mode 100644 index 0000000..e72a14a --- /dev/null +++ b/app/src/components/menu.test.ts @@ -0,0 +1,159 @@ +import { BrowserWindow } from 'electron'; + +jest.mock('../helpers/helpers'); +import { isOSX } from '../helpers/helpers'; +import { generateMenu } from './menu'; + +describe('generateMenu', () => { + let window: BrowserWindow; + const mockIsOSX: jest.SpyInstance = isOSX as jest.Mock; + let mockIsFullScreen: jest.SpyInstance; + let mockIsFullScreenable: jest.SpyInstance; + let mockIsSimpleFullScreen: jest.SpyInstance; + let mockSetFullScreen: jest.SpyInstance; + let mockSetSimpleFullScreen: jest.SpyInstance; + + beforeEach(() => { + window = new BrowserWindow(); + mockIsOSX.mockReset(); + mockIsFullScreen = jest.spyOn(window, 'isFullScreen'); + mockIsFullScreenable = jest.spyOn(window, 'isFullScreenable'); + mockIsSimpleFullScreen = jest.spyOn(window, 'isSimpleFullScreen'); + mockSetFullScreen = jest.spyOn(window, 'setFullScreen'); + mockSetSimpleFullScreen = jest.spyOn(window, 'setSimpleFullScreen'); + }); + + afterAll(() => { + mockIsFullScreen.mockRestore(); + mockIsFullScreenable.mockRestore(); + mockIsSimpleFullScreen.mockRestore(); + mockSetFullScreen.mockRestore(); + mockSetSimpleFullScreen.mockRestore(); + }); + + test('does not have fullscreen if not supported', () => { + mockIsOSX.mockReturnValue(false); + mockIsFullScreenable.mockReturnValue(false); + + const menu = generateMenu( + { + nativefierVersion: '1.0.0', + zoomBuildTimeValue: 1.0, + disableDevTools: false, + }, + window, + ); + + const editMenu = menu.filter((item) => item.label === '&View'); + + const fullscreen = (editMenu[0].submenu as any[]).filter( + (item) => item.label === 'Toggle Full Screen', + ); + + expect(fullscreen).toHaveLength(1); + expect(fullscreen[0].enabled).toBe(false); + expect(fullscreen[0].visible).toBe(false); + + expect(mockIsOSX).toHaveBeenCalled(); + expect(mockIsFullScreenable).toHaveBeenCalled(); + }); + + test('has fullscreen no matter what on mac', () => { + mockIsOSX.mockReturnValue(true); + mockIsFullScreenable.mockReturnValue(false); + + const menu = generateMenu( + { + nativefierVersion: '1.0.0', + zoomBuildTimeValue: 1.0, + disableDevTools: false, + }, + window, + ); + + const editMenu = menu.filter((item) => item.label === '&View'); + + const fullscreen = (editMenu[0].submenu as any[]).filter( + (item) => item.label === 'Toggle Full Screen', + ); + + expect(fullscreen).toHaveLength(1); + expect(fullscreen[0].enabled).toBe(true); + expect(fullscreen[0].visible).toBe(true); + + expect(mockIsOSX).toHaveBeenCalled(); + expect(mockIsFullScreenable).toHaveBeenCalled(); + }); + + test.each([true, false])( + 'has a fullscreen menu item that toggles fullscreen', + (isFullScreen) => { + mockIsOSX.mockReturnValue(false); + mockIsFullScreenable.mockReturnValue(true); + mockIsFullScreen.mockReturnValue(isFullScreen); + + const menu = generateMenu( + { + nativefierVersion: '1.0.0', + zoomBuildTimeValue: 1.0, + disableDevTools: false, + }, + window, + ); + + const editMenu = menu.filter((item) => item.label === '&View'); + + const fullscreen = (editMenu[0].submenu as any[]).filter( + (item) => item.label === 'Toggle Full Screen', + ); + + expect(fullscreen).toHaveLength(1); + expect(fullscreen[0].enabled).toBe(true); + expect(fullscreen[0].visible).toBe(true); + + expect(mockIsOSX).toHaveBeenCalled(); + expect(mockIsFullScreenable).toHaveBeenCalled(); + + fullscreen[0].click(null, window); + + expect(mockSetFullScreen).toHaveBeenCalledWith(!isFullScreen); + expect(mockSetSimpleFullScreen).not.toHaveBeenCalled(); + }, + ); + + test.each([true, false])( + 'has a fullscreen menu item that toggles simplefullscreen as a fallback on mac', + (isFullScreen) => { + mockIsOSX.mockReturnValue(true); + mockIsFullScreenable.mockReturnValue(false); + mockIsSimpleFullScreen.mockReturnValue(isFullScreen); + + const menu = generateMenu( + { + nativefierVersion: '1.0.0', + zoomBuildTimeValue: 1.0, + disableDevTools: false, + }, + window, + ); + + const editMenu = menu.filter((item) => item.label === '&View'); + + const fullscreen = (editMenu[0].submenu as any[]).filter( + (item) => item.label === 'Toggle Full Screen', + ); + + expect(fullscreen).toHaveLength(1); + expect(fullscreen[0].enabled).toBe(true); + expect(fullscreen[0].visible).toBe(true); + + expect(mockIsOSX).toHaveBeenCalled(); + expect(mockIsFullScreenable).toHaveBeenCalled(); + + fullscreen[0].click(null, window); + + expect(mockSetSimpleFullScreen).toHaveBeenCalledWith(!isFullScreen); + expect(mockSetFullScreen).not.toHaveBeenCalled(); + }, + ); +}); diff --git a/app/src/components/menu.ts b/app/src/components/menu.ts index c7d6595..ed761ee 100644 --- a/app/src/components/menu.ts +++ b/app/src/components/menu.ts @@ -1,39 +1,60 @@ import * as fs from 'fs'; import path from 'path'; -import { clipboard, Menu, MenuItemConstructorOptions } from 'electron'; +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'; + 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({ - nativefierVersion, - appQuit, - zoomIn, - zoomOut, - zoomReset, - zoomBuildTimeValue, - goBack, - goForward, - getCurrentURL, - goToURL, - clearAppData, - disableDevTools, - openExternal, -}): void { +export function createMenu(options, 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, + mainWindow: BrowserWindow, +): MenuItemConstructorOptions[] { + const { nativefierVersion, zoomBuildTimeValue, disableDevTools } = options; const zoomResetLabel = zoomBuildTimeValue === 1.0 ? 'Reset Zoom' @@ -69,8 +90,7 @@ export function createMenu({ label: 'Copy Current URL', accelerator: 'CmdOrCtrl+L', click: () => { - const currentURL = getCurrentURL(); - clipboard.writeText(currentURL); + clipboard.writeText(getCurrentURL()); }, }, { @@ -90,7 +110,19 @@ export function createMenu({ }, { label: 'Clear App Data', - click: clearAppData, + click: (item: MenuItem, focusedWindow: BrowserWindow) => { + log.debug('Clear App Data.click', { + item, + focusedWindow, + mainWindow, + }); + if (!focusedWindow) { + focusedWindow = mainWindow; + } + clearAppData(focusedWindow).catch((err) => + log.error('clearAppData ERROR', err), + ); + }, }, ], }; @@ -100,11 +132,7 @@ export function createMenu({ submenu: [ { label: 'Back', - accelerator: (() => { - const backKbShortcut = - process.platform === 'darwin' ? 'Cmd+Left' : 'Alt+Left'; - return backKbShortcut; - })(), + accelerator: isOSX() ? 'CmdOrAlt+Left' : 'Alt+Left', click: goBack, }, { @@ -116,11 +144,7 @@ export function createMenu({ }, { label: 'Forward', - accelerator: (() => { - const forwardKbShortcut = - process.platform === 'darwin' ? 'Cmd+Right' : 'Alt+Right'; - return forwardKbShortcut; - })(), + accelerator: isOSX() ? 'Cmd+Right' : 'Alt+Right', click: goForward, }, { @@ -132,27 +156,32 @@ export function createMenu({ }, { label: 'Reload', - accelerator: 'CmdOrCtrl+R', - click: (item, focusedWindow) => { - if (focusedWindow) { - focusedWindow.reload(); - } - }, + role: 'reload', }, { type: 'separator', }, { label: 'Toggle Full Screen', - accelerator: (() => { - if (process.platform === 'darwin') { - return 'Ctrl+Cmd+F'; + accelerator: isOSX() ? 'Ctrl+Cmd+F' : 'F11', + enabled: mainWindow.isFullScreenable() || isOSX(), + visible: mainWindow.isFullScreenable() || isOSX(), + click: (item: MenuItem, focusedWindow: BrowserWindow) => { + log.debug('Toggle Full Screen.click()', { + item, + focusedWindow, + isFullScreen: focusedWindow?.isFullScreen(), + isFullScreenable: focusedWindow?.isFullScreenable(), + }); + if (!focusedWindow) { + focusedWindow = mainWindow; } - return 'F11'; - })(), - click: (item, focusedWindow) => { - if (focusedWindow) { + if (focusedWindow.isFullScreenable()) { focusedWindow.setFullScreen(!focusedWindow.isFullScreen()); + } else if (isOSX()) { + focusedWindow.setSimpleFullScreen( + !focusedWindow.isSimpleFullScreen(), + ); } }, }, @@ -202,16 +231,13 @@ export function createMenu({ }, { label: 'Toggle Developer Tools', - accelerator: (() => { - if (process.platform === 'darwin') { - return 'Alt+Cmd+I'; - } - return 'Ctrl+Shift+I'; - })(), - click: (item, focusedWindow) => { - if (focusedWindow) { - focusedWindow.webContents.toggleDevTools(); + accelerator: isOSX() ? 'Alt+Cmd+I' : 'Ctrl+Shift+I', + click: (item: MenuItem, focusedWindow: BrowserWindow) => { + log.debug('Toggle Developer Tools.click()', { item, focusedWindow }); + if (!focusedWindow) { + focusedWindow = mainWindow; } + focusedWindow.webContents.toggleDevTools(); }, }, ); @@ -241,13 +267,21 @@ export function createMenu({ { label: `Built with Nativefier v${nativefierVersion}`, click: () => { - openExternal('https://github.com/nativefier/nativefier'); + openExternal('https://github.com/nativefier/nativefier').catch( + (err) => + log.error( + 'Built with Nativefier v${nativefierVersion}.click ERROR', + err, + ), + ); }, }, { label: 'Report an Issue', click: () => { - openExternal('https://github.com/nativefier/nativefier/issues'); + openExternal('https://github.com/nativefier/nativefier/issues').catch( + (err) => log.error('Report an Issue.click ERROR', err), + ); }, }, ], @@ -255,7 +289,7 @@ export function createMenu({ let menuTemplate: MenuItemConstructorOptions[]; - if (process.platform === 'darwin') { + if (isOSX()) { const electronMenu: MenuItemConstructorOptions = { label: 'E&lectron', submenu: [ @@ -287,7 +321,7 @@ export function createMenu({ { label: 'Quit', accelerator: 'Cmd+Q', - click: appQuit, + role: 'quit', }, ], }; @@ -305,55 +339,58 @@ export function createMenu({ 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 bookmarkConfigPath = path.join(__dirname, '..', 'bookmarks.json'); - if (fs.existsSync(bookmarkConfigPath)) { - const bookmarksMenuConfig: BookmarksMenuConfig = JSON.parse( - fs.readFileSync(bookmarkConfigPath, 'utf-8'), - ); - const bookmarksMenu: MenuItemConstructorOptions = { - label: bookmarksMenuConfig.menuLabel, - submenu: bookmarksMenuConfig.bookmarks.map((bookmark) => { - if (bookmark.type === 'link') { + const bookmarksMenuConfig: BookmarksMenuConfig = JSON.parse( + fs.readFileSync(bookmarkConfigPath, 'utf-8'), + ); + const bookmarksMenu: MenuItemConstructorOptions = { + label: bookmarksMenuConfig.menuLabel, + submenu: bookmarksMenuConfig.bookmarks.map((bookmark) => { + switch (bookmark.type) { + case 'link': if (!('title' in bookmark && 'url' in bookmark)) { - throw Error( + throw new Error( 'All links in the bookmarks menu must have a title and url.', ); } try { new URL(bookmark.url); - } catch (_) { - throw Error('Bookmark URL "' + bookmark.url + '"is invalid.'); - } - let accelerator = null; - if ('shortcut' in bookmark) { - accelerator = bookmark.shortcut; + } catch { + throw new Error('Bookmark URL "' + bookmark.url + '"is invalid.'); } return { label: bookmark.title, click: () => { - goToURL(bookmark.url); + goToURL(bookmark.url).catch((err) => + log.error(`${bookmark.title}.click ERROR`, err), + ); }, - accelerator: accelerator, + accelerator: 'shortcut' in bookmark ? bookmark.shortcut : null, }; - } else if (bookmark.type === 'separator') { + case 'separator': return { type: 'separator', }; - } else { - throw Error( + default: + throw new Error( 'A bookmarks menu entry has an invalid type; type must be one of "link", "separator".', ); - } - }), - }; - // Insert custom bookmarks menu between menus "View" and "Window" - menuTemplate.splice(menuTemplate.length - 2, 0, bookmarksMenu); - } + } + }), + }; + // Insert custom bookmarks menu between menus "View" and "Window" + menuTemplate.splice(menuTemplate.length - 2, 0, bookmarksMenu); } catch (err) { log.error('Failed to load & parse bookmarks configuration JSON file.', err); } - - const menu = Menu.buildFromTemplate(menuTemplate); - Menu.setApplicationMenu(menu); } diff --git a/app/src/helpers/windowHelpers.ts b/app/src/helpers/windowHelpers.ts index 13e5615..58766b4 100644 --- a/app/src/helpers/windowHelpers.ts +++ b/app/src/helpers/windowHelpers.ts @@ -137,16 +137,16 @@ export function getDefaultWindowOptions( ...(options.browserwindowOptions?.webPreferences ?? {}), }; - const defaultOptions = { - // Convert dashes to spaces because on linux the app name is joined with dashes - title: options.name, + const defaultOptions: BrowserWindowConstructorOptions = { + fullscreenable: true, tabbingIdentifier: nativeTabsSupported() ? options.name : undefined, + title: options.name, webPreferences: { javascript: true, - plugins: true, nodeIntegration: false, // `true` is *insecure*, and cause trouble with messenger.com - webSecurity: !options.insecure, preload: path.join(__dirname, 'preload.js'), + plugins: true, + webSecurity: !options.insecure, zoomFactor: options.zoom, ...webPreferences, }, diff --git a/app/src/main.ts b/app/src/main.ts index 43778d8..7993860 100644 --- a/app/src/main.ts +++ b/app/src/main.ts @@ -263,11 +263,7 @@ if (shouldQuit) { } async function onReady(): Promise { - const mainWindow = await createMainWindow( - appArgs, - app.quit.bind(this), - setDockBadge, - ); + const mainWindow = await createMainWindow(appArgs, setDockBadge); createTrayIcon(appArgs, mainWindow); diff --git a/app/src/mocks/electron.ts b/app/src/mocks/electron.ts index ac15294..1e451df 100644 --- a/app/src/mocks/electron.ts +++ b/app/src/mocks/electron.ts @@ -42,9 +42,29 @@ class MockBrowserWindow extends EventEmitter { return window ?? new MockBrowserWindow(); } + isSimpleFullScreen(): boolean { + return undefined; + } + + isFullScreen(): boolean { + return undefined; + } + + isFullScreenable(): boolean { + return undefined; + } + loadURL(url: string, options?: any): Promise { return Promise.resolve(undefined); } + + setFullScreen(flag: boolean): void { + return; + } + + setSimpleFullScreen(flag: boolean): void { + return; + } } class MockDialog { diff --git a/package.json b/package.json index 9be2add..85225de 100644 --- a/package.json +++ b/package.json @@ -101,14 +101,14 @@ "/src.*", "/node_modules.*", "/app/src.*", - "/app/dist.*", + "/app/lib.*", "/app/node_modules.*" ], "watchPathIgnorePatterns": [ "/src.*", "/tsconfig.json", "/app/src.*", - "/app/dist.*", + "/app/lib.*", "/app/tsconfig.json" ] },