From b74c0bf9592681e489b23e25a12492fefd0b9b90 Mon Sep 17 00:00:00 2001 From: Adam Weeden Date: Sat, 26 Jun 2021 09:59:28 -0400 Subject: [PATCH] Make app strict TypeScript + linting (and add a shared project) (#1231) * Convert app to strict typing + shared library * Fix new code post-merge * Remove extraneous lint ignores * Apply suggestions from code review Co-authored-by: Ronan Jouchet * Fix prettier complaint * Dedupe eslint files * Fix some refs after merge * Fix clean:full command Co-authored-by: Ronan Jouchet --- .github/manual-test | 2 +- app/.eslintrc.js | 34 ++--- app/package.json | 4 +- app/src/components/contextMenu.ts | 24 ++-- app/src/components/loginWindow.ts | 4 +- app/src/components/mainWindow.ts | 67 ++++++---- app/src/components/menu.test.ts | 40 +++--- app/src/components/menu.ts | 43 +++++-- app/src/components/trayIcon.ts | 26 ++-- app/src/helpers/helpers.ts | 8 +- app/src/helpers/inferFlash.ts | 4 +- app/src/helpers/windowEvents.test.ts | 151 +++++++++++------------ app/src/helpers/windowEvents.ts | 80 +++++++----- app/src/helpers/windowHelpers.test.ts | 44 ++++++- app/src/helpers/windowHelpers.ts | 64 ++++++---- app/src/main.ts | 128 +++++++++++-------- app/src/mocks/electron.ts | 31 ++--- app/src/preload.ts | 10 +- app/tsconfig.json | 60 ++++----- .eslintrc.js => base-eslintrc.js | 5 - package.json | 17 +-- shared/.eslintrc.js | 14 +++ {src => shared/src}/options/model.ts | 66 +++++++++- shared/tsconfig.json | 18 +++ src/.eslintrc.js | 13 ++ src/build/buildIcon.ts | 2 +- src/build/buildNativefierApp.ts | 4 +- src/build/prepareElectronApp.ts | 9 +- src/cli.ts | 4 +- src/helpers/upgrade/executableHelpers.ts | 2 +- src/helpers/upgrade/upgrade.ts | 5 +- src/infer/inferIcon.ts | 2 +- src/integration-test.ts | 2 +- src/main.ts | 2 +- src/options/asyncConfig.ts | 2 +- src/options/fields/fields.test.ts | 2 +- src/options/fields/fields.ts | 2 +- src/options/optionsMain.test.ts | 2 +- src/options/optionsMain.ts | 6 +- tsconfig.json => src/tsconfig.json | 21 ++-- tsconfig-base.json | 14 +++ 41 files changed, 653 insertions(+), 385 deletions(-) rename .eslintrc.js => base-eslintrc.js (93%) create mode 100644 shared/.eslintrc.js rename {src => shared/src}/options/model.ts (73%) create mode 100644 shared/tsconfig.json create mode 100644 src/.eslintrc.js rename tsconfig.json => src/tsconfig.json (77%) create mode 100644 tsconfig-base.json diff --git a/.github/manual-test b/.github/manual-test index 160c636..28adf19 100755 --- a/.github/manual-test +++ b/.github/manual-test @@ -13,7 +13,7 @@ function launch_app() { if [ "$(uname -s)" = "Darwin" ]; then open -a "$1/$2-darwin-x64/$2.app" elif [ "$(uname -o)" = "Msys" ]; then - "$1/$2-win32-x64/$2.exe" --verbose + "$1/$2-win32-x64/$2.exe" else "$1/$2-linux-x64/$2" fi diff --git a/app/.eslintrc.js b/app/.eslintrc.js index a26872f..6474e8e 100644 --- a/app/.eslintrc.js +++ b/app/.eslintrc.js @@ -1,29 +1,21 @@ +const base = require('../base-eslintrc'); + // # https://github.com/typescript-eslint/typescript-eslint/blob/master/docs/getting-started/linting/README.md module.exports = { - parser: '@typescript-eslint/parser', + parser: base.parser, parserOptions: { tsconfigRootDir: __dirname, project: ['./tsconfig.json'], }, - plugins: ['@typescript-eslint'], - extends: [ - 'eslint:recommended', - 'prettier', - 'plugin:@typescript-eslint/eslint-recommended', - 'plugin:@typescript-eslint/recommended', - 'plugin:@typescript-eslint/recommended-requiring-type-checking', + plugins: base.plugins, + extends: base.extends, + rules: base.rules, + // https://eslint.org/docs/user-guide/configuring/ignoring-code#ignorepatterns-in-config-files + ignorePatterns: [ + 'node_modules/**', + 'lib/**', + 'dist/**', + 'built-tests/**', + 'coverage/**', ], - rules: { - 'no-console': 'error', - 'prettier/prettier': 'error', - // TODO remove when done killing `any`s and making tsc strict - '@typescript-eslint/ban-ts-comment': 'off', - '@typescript-eslint/explicit-module-boundary-types': 'off', - '@typescript-eslint/no-explicit-any': 'off', - '@typescript-eslint/no-unsafe-assignment': 'off', - '@typescript-eslint/no-unsafe-call': 'off', - '@typescript-eslint/no-unsafe-member-access': 'off', - '@typescript-eslint/no-unsafe-return': 'off', - '@typescript-eslint/restrict-template-expressions': 'off' - }, }; diff --git a/app/package.json b/app/package.json index cbb18a2..64e6b94 100644 --- a/app/package.json +++ b/app/package.json @@ -12,8 +12,8 @@ ], "scripts": {}, "dependencies": { - "electron-context-menu": "^2.5.0", - "electron-dl": "^3.2.0", + "electron-context-menu": "^3.1.0", + "electron-dl": "^3.2.1", "electron-squirrel-startup": "^1.0.0", "electron-window-state": "^5.0.3", "loglevel": "^1.7.1", diff --git a/app/src/components/contextMenu.ts b/app/src/components/contextMenu.ts index d51519d..08fcc63 100644 --- a/app/src/components/contextMenu.ts +++ b/app/src/components/contextMenu.ts @@ -1,19 +1,23 @@ -import { BrowserWindow } from 'electron'; +import { BrowserWindow, ContextMenuParams } from 'electron'; +import contextMenu from 'electron-context-menu'; import log from 'loglevel'; import { nativeTabsSupported, openExternal } from '../helpers/helpers'; import { setupNativefierWindow } from '../helpers/windowEvents'; import { createNewTab, createNewWindow } from '../helpers/windowHelpers'; +import { + OutputOptions, + outputOptionsToWindowOptions, +} from '../../../shared/src/options/model'; -export function initContextMenu(options, window?: BrowserWindow): void { - // Require this at runtime, otherwise its child dependency 'electron-is-dev' - // throws an error during unit testing. - // eslint-disable-next-line @typescript-eslint/no-var-requires - const contextMenu = require('electron-context-menu'); - +export function initContextMenu( + options: OutputOptions, + window?: BrowserWindow, +): void { log.debug('initContextMenu', { options, window }); + // eslint-disable-next-line @typescript-eslint/no-unsafe-call contextMenu({ - prepend: (actions, params) => { + prepend: (actions: contextMenu.Actions, params: ContextMenuParams) => { log.debug('contextMenu.prepend', { actions, params }); const items = []; if (params.linkURL) { @@ -29,7 +33,7 @@ export function initContextMenu(options, window?: BrowserWindow): void { label: 'Open Link in New Window', click: () => createNewWindow( - options, + outputOptionsToWindowOptions(options), setupNativefierWindow, params.linkURL, window, @@ -40,7 +44,7 @@ export function initContextMenu(options, window?: BrowserWindow): void { label: 'Open Link in New Tab', click: () => createNewTab( - options, + outputOptionsToWindowOptions(options), setupNativefierWindow, params.linkURL, true, diff --git a/app/src/components/loginWindow.ts b/app/src/components/loginWindow.ts index 40edd0c..5b72103 100644 --- a/app/src/components/loginWindow.ts +++ b/app/src/components/loginWindow.ts @@ -5,7 +5,7 @@ import * as log from 'loglevel'; import { BrowserWindow, ipcMain } from 'electron'; export async function createLoginWindow( - loginCallback, + loginCallback: (username?: string, password?: string) => void, parent?: BrowserWindow, ): Promise { log.debug('createLoginWindow', { loginCallback, parent }); @@ -25,7 +25,7 @@ export async function createLoginWindow( `file://${path.join(__dirname, 'static/login.html')}`, ); - ipcMain.once('login-message', (event, usernameAndPassword) => { + ipcMain.once('login-message', (event, usernameAndPassword: string[]) => { log.debug('login-message', { event, username: usernameAndPassword[0] }); loginCallback(usernameAndPassword[0], usernameAndPassword[1]); loginWindow.close(); diff --git a/app/src/components/mainWindow.ts b/app/src/components/mainWindow.ts index c002432..ae1cf1e 100644 --- a/app/src/components/mainWindow.ts +++ b/app/src/components/mainWindow.ts @@ -1,7 +1,7 @@ import * as fs from 'fs'; import * as path from 'path'; -import { ipcMain, BrowserWindow, IpcMainEvent } from 'electron'; +import { ipcMain, BrowserWindow, Event } from 'electron'; import windowStateKeeper from 'electron-window-state'; import log from 'loglevel'; @@ -19,6 +19,10 @@ import { } from '../helpers/windowHelpers'; import { initContextMenu } from './contextMenu'; import { createMenu } from './menu'; +import { + OutputOptions, + outputOptionsToWindowOptions, +} from '../../../shared/src/options/model'; export const APP_ARGS_FILE_PATH = path.join(__dirname, '..', 'nativefier.json'); @@ -32,7 +36,7 @@ type SessionInteractionRequest = { type SessionInteractionResult = { id?: string; - value?: unknown; + value?: unknown | Promise; error?: Error; }; @@ -41,7 +45,7 @@ type SessionInteractionResult = { * @param {function} setDockBadge */ export async function createMainWindow( - nativefierOptions, + nativefierOptions: OutputOptions, setDockBadge: (value: number | string, bounce?: boolean) => void, ): Promise { const options = { ...nativefierOptions }; @@ -66,10 +70,10 @@ export async function createMainWindow( fullscreen: options.fullScreen, // Whether the window should always stay on top of other windows. Default is false. alwaysOnTop: options.alwaysOnTop, - titleBarStyle: options.titleBarStyle, + titleBarStyle: options.titleBarStyle ?? 'default', show: options.tray !== 'start-in-tray', backgroundColor: options.backgroundColor, - ...getDefaultWindowOptions(options), + ...getDefaultWindowOptions(outputOptionsToWindowOptions(options)), }); mainWindowState.manage(mainWindow); @@ -86,7 +90,7 @@ export async function createMainWindow( } createMenu(options, mainWindow); createContextMenu(options, mainWindow); - setupNativefierWindow(options, mainWindow); + setupNativefierWindow(outputOptionsToWindowOptions(options), mainWindow); // .on('new-window', ...) is deprected in favor of setWindowOpenHandler(...) // We can't quite cut over to that yet for a few reasons: @@ -104,7 +108,7 @@ export async function createMainWindow( 'new-window', (event, url, frameName, disposition) => { onNewWindow( - options, + outputOptionsToWindowOptions(options), setupNativefierWindow, event, url, @@ -131,20 +135,25 @@ export async function createMainWindow( await clearCache(mainWindow); } - await mainWindow.loadURL(options.targetUrl); + if (options.targetUrl) { + await mainWindow.loadURL(options.targetUrl); + } setupCloseEvent(options, mainWindow); return mainWindow; } -function createContextMenu(options, window: BrowserWindow): void { +function createContextMenu( + options: OutputOptions, + window: BrowserWindow, +): void { if (!options.disableContextMenu) { initContextMenu(options, window); } } -export function saveAppArgs(newAppArgs: any): void { +export function saveAppArgs(newAppArgs: OutputOptions): void { try { fs.writeFileSync(APP_ARGS_FILE_PATH, JSON.stringify(newAppArgs, null, 2)); } catch (err: unknown) { @@ -154,19 +163,29 @@ export function saveAppArgs(newAppArgs: any): void { } } -function setupCloseEvent(options, window: BrowserWindow): void { - window.on('close', (event: IpcMainEvent) => { +function setupCloseEvent(options: OutputOptions, window: BrowserWindow): void { + window.on('close', (event: Event) => { log.debug('mainWindow.close', event); if (window.isFullScreen()) { if (nativeTabsSupported()) { window.moveTabToNewWindow(); } window.setFullScreen(false); - window.once('leave-full-screen', (event: IpcMainEvent) => - hideWindow(window, event, options.fastQuit, options.tray), + window.once('leave-full-screen', (event: Event) => + hideWindow( + window, + event, + options.fastQuit ?? false, + options.tray ?? 'false', + ), ); } - hideWindow(window, event, options.fastQuit, options.tray); + hideWindow( + window, + event, + options.fastQuit ?? false, + options.tray ?? 'false', + ); if (options.clearCache) { clearCache(window).catch((err) => log.error('clearCache ERROR', err)); @@ -175,7 +194,7 @@ function setupCloseEvent(options, window: BrowserWindow): void { } function setupCounter( - options, + options: OutputOptions, window: BrowserWindow, setDockBadge: (value: number | string, bounce?: boolean) => void, ): void { @@ -191,7 +210,7 @@ function setupCounter( } function setupNotificationBadge( - options, + options: OutputOptions, window: BrowserWindow, setDockBadge: (value: number | string, bounce?: boolean) => void, ): void { @@ -208,7 +227,10 @@ function setupNotificationBadge( }); } -function setupSessionInteraction(options, window: BrowserWindow): void { +function setupSessionInteraction( + options: OutputOptions, + window: BrowserWindow, +): void { // See API.md / "Accessing The Electron Session" ipcMain.on( 'session-interaction', @@ -230,14 +252,13 @@ function setupSessionInteraction(options, window: BrowserWindow): void { } // Call func with funcArgs + // @ts-expect-error accessing a func by string name + // eslint-disable-next-line @typescript-eslint/no-unsafe-call result.value = window.webContents.session[request.func]( ...request.funcArgs, ); - if ( - result.value !== undefined && - typeof result.value['then'] === 'function' - ) { + if (result.value !== undefined && result.value instanceof Promise) { // This is a promise. We'll resolve it here otherwise it will blow up trying to serialize it in the reply (result.value as Promise) .then((trueResultValue) => { @@ -253,11 +274,13 @@ function setupSessionInteraction(options, window: BrowserWindow): void { } else if (request.property !== undefined) { if (request.propertyValue !== undefined) { // Set the property + // @ts-expect-error setting a property by string name window.webContents.session[request.property] = request.propertyValue; } // Get the property value + // @ts-expect-error accessing a property by string name 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! ;) diff --git a/app/src/components/menu.test.ts b/app/src/components/menu.test.ts index a21379c..9f8e654 100644 --- a/app/src/components/menu.test.ts +++ b/app/src/components/menu.test.ts @@ -1,4 +1,4 @@ -import { BrowserWindow } from 'electron'; +import { BrowserWindow, MenuItemConstructorOptions } from 'electron'; jest.mock('../helpers/helpers'); import { isOSX } from '../helpers/helpers'; @@ -16,9 +16,15 @@ describe('generateMenu', () => { beforeEach(() => { window = new BrowserWindow(); mockIsOSX.mockReset(); - mockIsFullScreen = jest.spyOn(window, 'isFullScreen'); - mockIsFullScreenable = jest.spyOn(window, 'isFullScreenable'); - mockIsSimpleFullScreen = jest.spyOn(window, 'isSimpleFullScreen'); + mockIsFullScreen = jest + .spyOn(window, 'isFullScreen') + .mockReturnValue(false); + mockIsFullScreenable = jest + .spyOn(window, 'isFullScreenable') + .mockReturnValue(true); + mockIsSimpleFullScreen = jest + .spyOn(window, 'isSimpleFullScreen') + .mockReturnValue(false); mockSetFullScreen = jest.spyOn(window, 'setFullScreen'); mockSetSimpleFullScreen = jest.spyOn(window, 'setSimpleFullScreen'); }); @@ -46,9 +52,9 @@ describe('generateMenu', () => { const editMenu = menu.filter((item) => item.label === '&View'); - const fullscreen = (editMenu[0].submenu as any[]).filter( - (item) => item.label === 'Toggle Full Screen', - ); + const fullscreen = ( + editMenu[0].submenu as MenuItemConstructorOptions[] + ).filter((item) => item.label === 'Toggle Full Screen'); expect(fullscreen).toHaveLength(1); expect(fullscreen[0].enabled).toBe(false); @@ -73,9 +79,9 @@ describe('generateMenu', () => { const editMenu = menu.filter((item) => item.label === '&View'); - const fullscreen = (editMenu[0].submenu as any[]).filter( - (item) => item.label === 'Toggle Full Screen', - ); + const fullscreen = ( + editMenu[0].submenu as MenuItemConstructorOptions[] + ).filter((item) => item.label === 'Toggle Full Screen'); expect(fullscreen).toHaveLength(1); expect(fullscreen[0].enabled).toBe(true); @@ -103,9 +109,9 @@ describe('generateMenu', () => { const editMenu = menu.filter((item) => item.label === '&View'); - const fullscreen = (editMenu[0].submenu as any[]).filter( - (item) => item.label === 'Toggle Full Screen', - ); + const fullscreen = ( + editMenu[0].submenu as MenuItemConstructorOptions[] + ).filter((item) => item.label === 'Toggle Full Screen'); expect(fullscreen).toHaveLength(1); expect(fullscreen[0].enabled).toBe(true); @@ -114,6 +120,7 @@ describe('generateMenu', () => { expect(mockIsOSX).toHaveBeenCalled(); expect(mockIsFullScreenable).toHaveBeenCalled(); + // @ts-expect-error click is here TypeScript... fullscreen[0].click(null, window); expect(mockSetFullScreen).toHaveBeenCalledWith(!isFullScreen); @@ -139,9 +146,9 @@ describe('generateMenu', () => { const editMenu = menu.filter((item) => item.label === '&View'); - const fullscreen = (editMenu[0].submenu as any[]).filter( - (item) => item.label === 'Toggle Full Screen', - ); + const fullscreen = ( + editMenu[0].submenu as MenuItemConstructorOptions[] + ).filter((item) => item.label === 'Toggle Full Screen'); expect(fullscreen).toHaveLength(1); expect(fullscreen[0].enabled).toBe(true); @@ -150,6 +157,7 @@ describe('generateMenu', () => { expect(mockIsOSX).toHaveBeenCalled(); expect(mockIsFullScreenable).toHaveBeenCalled(); + // @ts-expect-error click is here TypeScript... fullscreen[0].click(null, window); expect(mockSetSimpleFullScreen).toHaveBeenCalledWith(!isFullScreen); diff --git a/app/src/components/menu.ts b/app/src/components/menu.ts index 753c094..5e47388 100644 --- a/app/src/components/menu.ts +++ b/app/src/components/menu.ts @@ -21,6 +21,7 @@ import { zoomOut, zoomReset, } from '../helpers/windowHelpers'; +import { OutputOptions } from '../../../shared/src/options/model'; type BookmarksLink = { type: 'link'; @@ -40,7 +41,10 @@ type BookmarksMenuConfig = { bookmarks: BookmarkConfig[]; }; -export function createMenu(options, mainWindow: BrowserWindow): void { +export function createMenu( + options: OutputOptions, + mainWindow: BrowserWindow, +): void { log.debug('createMenu', { options, mainWindow }); const menuTemplate = generateMenu(options, mainWindow); @@ -51,7 +55,11 @@ export function createMenu(options, mainWindow: BrowserWindow): void { } export function generateMenu( - options, + options: { + disableDevTools: boolean; + nativefierVersion: string; + zoom?: number; + }, mainWindow: BrowserWindow, ): MenuItemConstructorOptions[] { const { nativefierVersion, zoom, disableDevTools } = options; @@ -108,7 +116,10 @@ export function generateMenu( }, { label: 'Clear App Data', - click: (item: MenuItem, focusedWindow: BrowserWindow): void => { + click: ( + item: MenuItem, + focusedWindow: BrowserWindow | undefined, + ): void => { log.debug('Clear App Data.click', { item, focusedWindow, @@ -164,7 +175,10 @@ export function generateMenu( accelerator: isOSX() ? 'Ctrl+Cmd+F' : 'F11', enabled: mainWindow.isFullScreenable() || isOSX(), visible: mainWindow.isFullScreenable() || isOSX(), - click: (item: MenuItem, focusedWindow: BrowserWindow): void => { + click: ( + item: MenuItem, + focusedWindow: BrowserWindow | undefined, + ): void => { log.debug('Toggle Full Screen.click()', { item, focusedWindow, @@ -230,7 +244,7 @@ export function generateMenu( { label: 'Toggle Developer Tools', accelerator: isOSX() ? 'Alt+Cmd+I' : 'Ctrl+Shift+I', - click: (item: MenuItem, focusedWindow: BrowserWindow) => { + click: (item: MenuItem, focusedWindow: BrowserWindow | undefined) => { log.debug('Toggle Developer Tools.click()', { item, focusedWindow }); if (!focusedWindow) { focusedWindow = mainWindow; @@ -349,12 +363,11 @@ function injectBookmarks(menuTemplate: MenuItemConstructorOptions[]): void { } try { - const bookmarksMenuConfig: BookmarksMenuConfig = JSON.parse( + const bookmarksMenuConfig = JSON.parse( fs.readFileSync(bookmarkConfigPath, 'utf-8'), - ); - const bookmarksMenu: MenuItemConstructorOptions = { - label: bookmarksMenuConfig.menuLabel, - submenu: bookmarksMenuConfig.bookmarks.map((bookmark) => { + ) as BookmarksMenuConfig; + const submenu: MenuItemConstructorOptions[] = + bookmarksMenuConfig.bookmarks.map((bookmark) => { switch (bookmark.type) { case 'link': if (!('title' in bookmark && 'url' in bookmark)) { @@ -370,11 +383,12 @@ function injectBookmarks(menuTemplate: MenuItemConstructorOptions[]): void { return { label: bookmark.title, click: (): void => { - goToURL(bookmark.url).catch((err: unknown): void => + goToURL(bookmark.url)?.catch((err: unknown): void => log.error(`${bookmark.title}.click ERROR`, err), ); }, - accelerator: 'shortcut' in bookmark ? bookmark.shortcut : null, + accelerator: + 'shortcut' in bookmark ? bookmark.shortcut : undefined, }; case 'separator': return { @@ -385,7 +399,10 @@ function injectBookmarks(menuTemplate: MenuItemConstructorOptions[]): void { '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); diff --git a/app/src/components/trayIcon.ts b/app/src/components/trayIcon.ts index e799a81..66f931d 100644 --- a/app/src/components/trayIcon.ts +++ b/app/src/components/trayIcon.ts @@ -2,15 +2,19 @@ import { app, Tray, Menu, ipcMain, nativeImage, BrowserWindow } from 'electron'; import log from 'loglevel'; import { getAppIcon, getCounterValue, isOSX } from '../helpers/helpers'; +import { OutputOptions } from '../../../shared/src/options/model'; export function createTrayIcon( - nativefierOptions, + nativefierOptions: OutputOptions, mainWindow: BrowserWindow, -): Tray { +): Tray | undefined { const options = { ...nativefierOptions }; if (options.tray && options.tray !== 'false') { const iconPath = getAppIcon(); + if (!iconPath) { + throw new Error('Icon path not found found to use with tray option.'); + } const nimage = nativeImage.createFromPath(iconPath); const appIcon = new Tray(nativeImage.createEmpty()); @@ -39,7 +43,7 @@ export function createTrayIcon( }, { label: 'Quit', - click: app.exit.bind(this), + click: (): void => app.exit(0), }, ]); @@ -50,9 +54,11 @@ export function createTrayIcon( log.debug('mainWindow.page-title-updated', { event, title }); const counterValue = getCounterValue(title); if (counterValue) { - appIcon.setToolTip(`(${counterValue}) ${options.name}`); + appIcon.setToolTip( + `(${counterValue}) ${options.name ?? 'Nativefier'}`, + ); } else { - appIcon.setToolTip(options.name); + appIcon.setToolTip(options.name ?? ''); } }); } else { @@ -61,20 +67,22 @@ export function createTrayIcon( if (mainWindow.isFocused()) { return; } - appIcon.setToolTip(`• ${options.name}`); + if (options.name) { + appIcon.setToolTip(`• ${options.name}`); + } }); mainWindow.on('focus', () => { log.debug('mainWindow.focus'); - appIcon.setToolTip(options.name); + appIcon.setToolTip(options.name ?? ''); }); } - appIcon.setToolTip(options.name); + appIcon.setToolTip(options.name ?? ''); appIcon.setContextMenu(contextMenu); return appIcon; } - return null; + return undefined; } diff --git a/app/src/helpers/helpers.ts b/app/src/helpers/helpers.ts index 47e5538..f815585 100644 --- a/app/src/helpers/helpers.ts +++ b/app/src/helpers/helpers.ts @@ -39,7 +39,7 @@ function domainify(url: string): string { return domain; } -export function getAppIcon(): string { +export function getAppIcon(): string | undefined { // Prefer ICO under Windows, see // https://www.electronjs.org/docs/api/browser-window#new-browserwindowoptions // https://www.electronjs.org/docs/api/native-image#supported-formats @@ -55,7 +55,7 @@ export function getAppIcon(): string { } } -export function getCounterValue(title: string): string { +export function getCounterValue(title: string): string | undefined { const itemCountRegex = /[([{]([\d.,]*)\+?[}\])]/; const match = itemCountRegex.exec(title); return match ? match[1] : undefined; @@ -74,7 +74,7 @@ export function getCSSToInject(): string { for (const cssFile of cssFiles) { log.debug('Injecting CSS file', cssFile); const cssFileData = fs.readFileSync(cssFile); - cssToInject += `/* ${cssFile} */\n\n ${cssFileData}\n\n`; + cssToInject += `/* ${cssFile} */\n\n ${cssFileData.toString()}\n\n`; } return cssToInject; } @@ -114,7 +114,7 @@ function isInternalLoginPage(url: string): boolean { export function linkIsInternal( currentUrl: string, newUrl: string, - internalUrlRegex: string | RegExp, + internalUrlRegex: string | RegExp | undefined, ): boolean { log.debug('linkIsInternal', { currentUrl, newUrl, internalUrlRegex }); if (newUrl.split('#')[0] === 'about:blank') { diff --git a/app/src/helpers/inferFlash.ts b/app/src/helpers/inferFlash.ts index 6c61bd3..86aedc2 100644 --- a/app/src/helpers/inferFlash.ts +++ b/app/src/helpers/inferFlash.ts @@ -72,7 +72,7 @@ function findFlashOnMac(): string { )[0]; } -export function inferFlashPath(): string { +export function inferFlashPath(): string | undefined { if (isOSX()) { return findFlashOnMac(); } @@ -86,5 +86,5 @@ export function inferFlashPath(): string { } log.warn('Unable to determine OS to infer flash player'); - return null; + return undefined; } diff --git a/app/src/helpers/windowEvents.test.ts b/app/src/helpers/windowEvents.test.ts index 966d279..3357a5c 100644 --- a/app/src/helpers/windowEvents.test.ts +++ b/app/src/helpers/windowEvents.test.ts @@ -3,9 +3,33 @@ jest.mock('./windowEvents'); jest.mock('./windowHelpers'); import { dialog, BrowserWindow, WebContents } from 'electron'; +import { WindowOptions } from '../../../shared/src/options/model'; import { linkIsInternal, openExternal, nativeTabsSupported } from './helpers'; -const { onNewWindowHelper, onWillNavigate, onWillPreventUnload } = - jest.requireActual('./windowEvents'); +// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment +const { + onNewWindowHelper, + onWillNavigate, + onWillPreventUnload, +}: { + onNewWindowHelper: ( + options: WindowOptions, + setupWindow: (options: WindowOptions, window: BrowserWindow) => void, + urlToGo: string, + disposition: string | undefined, + preventDefault: (newGuest: BrowserWindow) => void, + parent?: BrowserWindow, + ) => Promise; + onWillNavigate: ( + options: { + blockExternalUrls: boolean; + internalUrls?: string | RegExp; + targetUrl: string; + }, + event: unknown, + urlToGo: string, + ) => Promise; + onWillPreventUnload: (event: unknown) => void; +} = jest.requireActual('./windowEvents'); import { blockExternalURL, createAboutBlankWindow, @@ -18,7 +42,13 @@ describe('onNewWindowHelper', () => { const externalURL = 'https://www.wikipedia.org/wiki/Electron'; const foregroundDisposition = 'foreground-tab'; const backgroundDisposition = 'background-tab'; - + const baseOptions = { + blockExternalUrls: false, + insecure: false, + name: 'TEST_APP', + targetUrl: originalURL, + zoom: 1.0, + }; const mockBlockExternalURL: jest.SpyInstance = blockExternalURL as jest.Mock; const mockCreateAboutBlank: jest.SpyInstance = createAboutBlankWindow as jest.Mock; @@ -54,14 +84,9 @@ describe('onNewWindowHelper', () => { mockOpenExternal.mockRestore(); }); - test('internal urls should not be handled', () => { - const options = { - blockExternalUrls: false, - targetUrl: originalURL, - }; - - onNewWindowHelper( - options, + test('internal urls should not be handled', async () => { + await onNewWindowHelper( + baseOptions, setupWindow, internalURL, undefined, @@ -75,14 +100,11 @@ describe('onNewWindowHelper', () => { expect(preventDefault).not.toHaveBeenCalled(); }); - test('external urls should be opened externally', () => { + test('external urls should be opened externally', async () => { mockLinkIsInternal.mockReturnValue(false); - const options = { - blockExternalUrls: false, - targetUrl: originalURL, - }; - onNewWindowHelper( - options, + + await onNewWindowHelper( + baseOptions, setupWindow, externalURL, undefined, @@ -96,13 +118,13 @@ describe('onNewWindowHelper', () => { expect(preventDefault).toHaveBeenCalledTimes(1); }); - test('external urls should be ignored if blockExternalUrls is true', () => { + test('external urls should be ignored if blockExternalUrls is true', async () => { mockLinkIsInternal.mockReturnValue(false); const options = { + ...baseOptions, blockExternalUrls: true, - targetUrl: originalURL, }; - onNewWindowHelper( + await onNewWindowHelper( options, setupWindow, externalURL, @@ -117,13 +139,9 @@ describe('onNewWindowHelper', () => { expect(preventDefault).toHaveBeenCalledTimes(1); }); - test('tab disposition should be ignored if tabs are not enabled', () => { - const options = { - blockExternalUrls: false, - targetUrl: originalURL, - }; - onNewWindowHelper( - options, + test('tab disposition should be ignored if tabs are not enabled', async () => { + await onNewWindowHelper( + baseOptions, setupWindow, internalURL, foregroundDisposition, @@ -137,14 +155,11 @@ describe('onNewWindowHelper', () => { expect(preventDefault).not.toHaveBeenCalled(); }); - test('tab disposition should be ignored if url is external', () => { + test('tab disposition should be ignored if url is external', async () => { mockLinkIsInternal.mockReturnValue(false); - const options = { - blockExternalUrls: false, - targetUrl: originalURL, - }; - onNewWindowHelper( - options, + + await onNewWindowHelper( + baseOptions, setupWindow, externalURL, foregroundDisposition, @@ -158,15 +173,11 @@ describe('onNewWindowHelper', () => { expect(preventDefault).toHaveBeenCalledTimes(1); }); - test('foreground tabs with internal urls should be opened in the foreground', () => { + test('foreground tabs with internal urls should be opened in the foreground', async () => { mockNativeTabsSupported.mockReturnValue(true); - const options = { - blockExternalUrls: false, - targetUrl: originalURL, - }; - onNewWindowHelper( - options, + await onNewWindowHelper( + baseOptions, setupWindow, internalURL, foregroundDisposition, @@ -176,7 +187,7 @@ describe('onNewWindowHelper', () => { expect(mockCreateAboutBlank).not.toHaveBeenCalled(); expect(mockCreateNewTab).toHaveBeenCalledTimes(1); expect(mockCreateNewTab).toHaveBeenCalledWith( - options, + baseOptions, setupWindow, internalURL, true, @@ -187,15 +198,11 @@ describe('onNewWindowHelper', () => { expect(preventDefault).toHaveBeenCalledTimes(1); }); - test('background tabs with internal urls should be opened in background tabs', () => { + test('background tabs with internal urls should be opened in background tabs', async () => { mockNativeTabsSupported.mockReturnValue(true); - const options = { - blockExternalUrls: false, - targetUrl: originalURL, - }; - onNewWindowHelper( - options, + await onNewWindowHelper( + baseOptions, setupWindow, internalURL, backgroundDisposition, @@ -205,7 +212,7 @@ describe('onNewWindowHelper', () => { expect(mockCreateAboutBlank).not.toHaveBeenCalled(); expect(mockCreateNewTab).toHaveBeenCalledTimes(1); expect(mockCreateNewTab).toHaveBeenCalledWith( - options, + baseOptions, setupWindow, internalURL, false, @@ -216,13 +223,9 @@ describe('onNewWindowHelper', () => { expect(preventDefault).toHaveBeenCalledTimes(1); }); - test('about:blank urls should be handled', () => { - const options = { - blockExternalUrls: false, - targetUrl: originalURL, - }; - onNewWindowHelper( - options, + test('about:blank urls should be handled', async () => { + await onNewWindowHelper( + baseOptions, setupWindow, 'about:blank', undefined, @@ -236,13 +239,9 @@ describe('onNewWindowHelper', () => { expect(preventDefault).toHaveBeenCalledTimes(1); }); - test('about:blank#blocked urls should be handled', () => { - const options = { - blockExternalUrls: false, - targetUrl: originalURL, - }; - onNewWindowHelper( - options, + test('about:blank#blocked urls should be handled', async () => { + await onNewWindowHelper( + baseOptions, setupWindow, 'about:blank#blocked', undefined, @@ -256,13 +255,9 @@ describe('onNewWindowHelper', () => { expect(preventDefault).toHaveBeenCalledTimes(1); }); - test('about:blank#other urls should not be handled', () => { - const options = { - blockExternalUrls: false, - targetUrl: originalURL, - }; - onNewWindowHelper( - options, + test('about:blank#other urls should not be handled', async () => { + await onNewWindowHelper( + baseOptions, setupWindow, 'about:blank#other', undefined, @@ -302,40 +297,40 @@ describe('onWillNavigate', () => { mockOpenExternal.mockRestore(); }); - test('internal urls should not be handled', () => { + test('internal urls should not be handled', async () => { mockLinkIsInternal.mockReturnValue(true); const options = { blockExternalUrls: false, targetUrl: originalURL, }; const event = { preventDefault }; - onWillNavigate(options, event, internalURL); + await onWillNavigate(options, event, internalURL); expect(mockBlockExternalURL).not.toHaveBeenCalled(); expect(mockOpenExternal).not.toHaveBeenCalled(); expect(preventDefault).not.toHaveBeenCalled(); }); - test('external urls should be opened externally', () => { + test('external urls should be opened externally', async () => { const options = { blockExternalUrls: false, targetUrl: originalURL, }; const event = { preventDefault }; - onWillNavigate(options, event, externalURL); + await onWillNavigate(options, event, externalURL); expect(mockBlockExternalURL).not.toHaveBeenCalled(); expect(mockOpenExternal).toHaveBeenCalledTimes(1); expect(preventDefault).toHaveBeenCalledTimes(1); }); - test('external urls should be ignored if blockExternalUrls is true', () => { + test('external urls should be ignored if blockExternalUrls is true', async () => { const options = { blockExternalUrls: true, targetUrl: originalURL, }; const event = { preventDefault }; - onWillNavigate(options, event, externalURL); + await onWillNavigate(options, event, externalURL); expect(mockBlockExternalURL).toHaveBeenCalledTimes(1); expect(mockOpenExternal).not.toHaveBeenCalled(); diff --git a/app/src/helpers/windowEvents.ts b/app/src/helpers/windowEvents.ts index 602b9c3..c8dc419 100644 --- a/app/src/helpers/windowEvents.ts +++ b/app/src/helpers/windowEvents.ts @@ -1,11 +1,12 @@ import { dialog, BrowserWindow, - IpcMainEvent, + Event, NewWindowWebContentsEvent, WebContents, } from 'electron'; import log from 'loglevel'; +import { WindowOptions } from '../../../shared/src/options/model'; import { linkIsInternal, nativeTabsSupported, openExternal } from './helpers'; import { @@ -18,8 +19,8 @@ import { } from './windowHelpers'; export function onNewWindow( - options, - setupWindow: (...args) => void, + options: WindowOptions, + setupWindow: (options: WindowOptions, window: BrowserWindow) => void, event: NewWindowWebContentsEvent, urlToGo: string, frameName: string, @@ -39,7 +40,7 @@ export function onNewWindow( disposition, parent, }); - const preventDefault = (newGuest: BrowserWindow): void => { + const preventDefault = (newGuest?: BrowserWindow): void => { log.debug('onNewWindow.preventDefault', { newGuest, event }); event.preventDefault(); if (newGuest) { @@ -57,14 +58,15 @@ export function onNewWindow( } export function onNewWindowHelper( - options, - setupWindow: (...args) => void, + options: WindowOptions, + setupWindow: (options: WindowOptions, window: BrowserWindow) => void, urlToGo: string, - disposition: string, - preventDefault, + disposition: string | undefined, + preventDefault: (newGuest?: BrowserWindow) => void, parent?: BrowserWindow, ): Promise { log.debug('onNewWindowHelper', { + options, urlToGo, disposition, preventDefault, @@ -74,7 +76,13 @@ export function onNewWindowHelper( if (!linkIsInternal(options.targetUrl, urlToGo, options.internalUrls)) { preventDefault(); if (options.blockExternalUrls) { - return blockExternalURL(urlToGo).then(() => null); + return new Promise((resolve) => { + blockExternalURL(urlToGo) + .then(() => resolve()) + .catch((err: unknown) => { + throw err; + }); + }); } else { return openExternal(urlToGo); } @@ -108,15 +116,21 @@ export function onNewWindowHelper( } export function onWillNavigate( - options, - event: IpcMainEvent, + options: WindowOptions, + event: Event, urlToGo: string, ): Promise { log.debug('onWillNavigate', { options, event, urlToGo }); if (!linkIsInternal(options.targetUrl, urlToGo, options.internalUrls)) { event.preventDefault(); if (options.blockExternalUrls) { - return blockExternalURL(urlToGo).then(() => null); + return new Promise((resolve) => { + blockExternalURL(urlToGo) + .then(() => resolve()) + .catch((err: unknown) => { + throw err; + }); + }); } else { return openExternal(urlToGo); } @@ -124,29 +138,39 @@ export function onWillNavigate( return Promise.resolve(undefined); } -export function onWillPreventUnload(event: IpcMainEvent): void { +export function onWillPreventUnload( + event: Event & { sender?: WebContents }, +): void { log.debug('onWillPreventUnload', event); - const webContents: WebContents = event.sender; - if (webContents === undefined) { + const webContents = event.sender; + if (!webContents) { return; } - const browserWindow = BrowserWindow.fromWebContents(webContents); - const choice = dialog.showMessageBoxSync(browserWindow, { - type: 'question', - buttons: ['Proceed', 'Stay'], - message: 'You may have unsaved changes, are you sure you want to proceed?', - title: 'Changes you made may not be saved.', - defaultId: 0, - cancelId: 1, - }); - if (choice === 0) { - event.preventDefault(); + const browserWindow = + BrowserWindow.fromWebContents(webContents) ?? + BrowserWindow.getFocusedWindow(); + if (browserWindow) { + const choice = dialog.showMessageBoxSync(browserWindow, { + type: 'question', + buttons: ['Proceed', 'Stay'], + message: + 'You may have unsaved changes, are you sure you want to proceed?', + title: 'Changes you made may not be saved.', + defaultId: 0, + cancelId: 1, + }); + if (choice === 0) { + event.preventDefault(); + } } } -export function setupNativefierWindow(options, window: BrowserWindow): void { +export function setupNativefierWindow( + options: WindowOptions, + window: BrowserWindow, +): void { if (options.userAgent) { window.webContents.userAgent = options.userAgent; } @@ -157,7 +181,7 @@ export function setupNativefierWindow(options, window: BrowserWindow): void { injectCSS(window); - window.webContents.on('will-navigate', (event: IpcMainEvent, url: string) => { + window.webContents.on('will-navigate', (event: Event, url: string) => { onWillNavigate(options, event, url).catch((err) => { log.error('window.webContents.on.will-navigate ERROR', err); event.preventDefault(); diff --git a/app/src/helpers/windowHelpers.test.ts b/app/src/helpers/windowHelpers.test.ts index e6d9591..bb02a29 100644 --- a/app/src/helpers/windowHelpers.test.ts +++ b/app/src/helpers/windowHelpers.test.ts @@ -6,6 +6,7 @@ import { } from 'electron'; jest.mock('loglevel'); import { error } from 'loglevel'; +import { WindowOptions } from '../../../shared/src/options/model'; jest.mock('./helpers'); import { getCSSToInject } from './helpers'; @@ -57,7 +58,13 @@ describe('clearAppData', () => { describe('createNewTab', () => { const window = new BrowserWindow(); - const options = {}; + const options: WindowOptions = { + blockExternalUrls: false, + insecure: false, + name: 'Test App', + targetUrl: 'https://github.com/nativefier/natifefier', + zoom: 1.0, + }; const setupWindow = jest.fn(); const url = 'https://github.com/nativefier/nativefier'; const mockAddTabbedWindow: jest.SpyInstance = jest.spyOn( @@ -100,6 +107,7 @@ describe('injectCSS', () => { jest.setTimeout(10000); const mockGetCSSToInject: jest.SpyInstance = getCSSToInject as jest.Mock; + let mockGetURL: jest.SpyInstance; const mockLogError: jest.SpyInstance = error as jest.Mock; const mockWebContentsInsertCSS: jest.SpyInstance = jest.spyOn( WebContents.prototype, @@ -107,17 +115,21 @@ describe('injectCSS', () => { ); const css = 'body { color: white; }'; - let responseHeaders; + let responseHeaders: Record; beforeEach(() => { mockGetCSSToInject.mockReset().mockReturnValue(''); + mockGetURL = jest + .spyOn(WebContents.prototype, 'getURL') + .mockReturnValue('https://example.com'); mockLogError.mockReset(); mockWebContentsInsertCSS.mockReset().mockResolvedValue(undefined); - responseHeaders = { 'x-header': 'value', 'content-type': ['test/other'] }; + responseHeaders = { 'x-header': ['value'], 'content-type': ['test/other'] }; }); afterAll(() => { mockGetCSSToInject.mockRestore(); + mockGetURL.mockRestore(); mockLogError.mockRestore(); mockWebContentsInsertCSS.mockRestore(); }); @@ -141,6 +153,7 @@ describe('injectCSS', () => { window.webContents.emit('did-navigate'); // @ts-expect-error this function doesn't exist in the actual electron version, but will in our mock + // eslint-disable-next-line @typescript-eslint/no-unsafe-call window.webContents.session.webRequest.send( 'onHeadersReceived', { responseHeaders, webContents: window.webContents }, @@ -168,6 +181,7 @@ describe('injectCSS', () => { window.webContents.emit('did-navigate'); // @ts-expect-error this function doesn't exist in the actual electron version, but will in our mock + // eslint-disable-next-line @typescript-eslint/no-unsafe-call window.webContents.session.webRequest.send( 'onHeadersReceived', { responseHeaders, webContents: window.webContents }, @@ -190,6 +204,11 @@ describe('injectCSS', () => { 'image/png', ])( 'will not inject for content-type %s', + // @ts-expect-error because TypeScript can't recognize that + // '(contentType: string, done: jest.DoneCallback) => void' + // and + // '(...args: (string | DoneCallback)[]) => any' + // are actually compatible. (contentType: string, done: jest.DoneCallback) => { mockGetCSSToInject.mockReturnValue(css); const window = new BrowserWindow(); @@ -203,6 +222,7 @@ describe('injectCSS', () => { expect(window.webContents.emit('did-navigate')).toBe(true); mockWebContentsInsertCSS.mockReset().mockResolvedValue(undefined); // @ts-expect-error this function doesn't exist in the actual electron version, but will in our mock + // eslint-disable-next-line @typescript-eslint/no-unsafe-call window.webContents.session.webRequest.send( 'onHeadersReceived', { @@ -223,6 +243,11 @@ describe('injectCSS', () => { test.each(['text/html'])( 'will inject for content-type %s', + // @ts-expect-error because TypeScript can't recognize that + // '(contentType: string, done: jest.DoneCallback) => void' + // and + // '(...args: (string | DoneCallback)[]) => any' + // are actually compatible. (contentType: string, done: jest.DoneCallback) => { mockGetCSSToInject.mockReturnValue(css); const window = new BrowserWindow(); @@ -236,6 +261,7 @@ describe('injectCSS', () => { window.webContents.emit('did-navigate'); mockWebContentsInsertCSS.mockReset().mockResolvedValue(undefined); // @ts-expect-error this function doesn't exist in the actual electron version, but will in our mock + // eslint-disable-next-line @typescript-eslint/no-unsafe-call window.webContents.session.webRequest.send( 'onHeadersReceived', { @@ -260,6 +286,11 @@ describe('injectCSS', () => { 'xhr', ])( 'will not inject for resource type %s', + // @ts-expect-error because TypeScript can't recognize that + // '(contentType: string, done: jest.DoneCallback) => void' + // and + // '(...args: (string | DoneCallback)[]) => any' + // are actually compatible. (resourceType: string, done: jest.DoneCallback) => { mockGetCSSToInject.mockReturnValue(css); const window = new BrowserWindow(); @@ -271,6 +302,7 @@ describe('injectCSS', () => { window.webContents.emit('did-navigate'); mockWebContentsInsertCSS.mockReset().mockResolvedValue(undefined); // @ts-expect-error this function doesn't exist in the actual electron version, but will in our mock + // eslint-disable-next-line @typescript-eslint/no-unsafe-call window.webContents.session.webRequest.send( 'onHeadersReceived', { @@ -292,6 +324,11 @@ describe('injectCSS', () => { test.each(['html', 'other'])( 'will inject for resource type %s', + // @ts-expect-error because TypeScript can't recognize that + // '(contentType: string, done: jest.DoneCallback) => void' + // and + // '(...args: (string | DoneCallback)[]) => any' + // are actually compatible. (resourceType: string, done: jest.DoneCallback) => { mockGetCSSToInject.mockReturnValue(css); const window = new BrowserWindow(); @@ -303,6 +340,7 @@ describe('injectCSS', () => { window.webContents.emit('did-navigate'); mockWebContentsInsertCSS.mockReset().mockResolvedValue(undefined); // @ts-expect-error this function doesn't exist in the actual electron version, but will in our mock + // eslint-disable-next-line @typescript-eslint/no-unsafe-call window.webContents.session.webRequest.send( 'onHeadersReceived', { diff --git a/app/src/helpers/windowHelpers.ts b/app/src/helpers/windowHelpers.ts index 0f0cf3a..22f8235 100644 --- a/app/src/helpers/windowHelpers.ts +++ b/app/src/helpers/windowHelpers.ts @@ -2,14 +2,16 @@ import { dialog, BrowserWindow, BrowserWindowConstructorOptions, + Event, HeadersReceivedResponse, - IpcMainEvent, MessageBoxReturnValue, OnHeadersReceivedListenerDetails, + WebPreferences, } from 'electron'; import log from 'loglevel'; import path from 'path'; +import { TrayValue, WindowOptions } from '../../../shared/src/options/model'; import { getCSSToInject, isOSX, nativeTabsSupported } from './helpers'; const ZOOM_INTERVAL = 0.1; @@ -61,8 +63,8 @@ export async function clearCache(window: BrowserWindow): Promise { } export function createAboutBlankWindow( - options, - setupWindow: (...args) => void, + options: WindowOptions, + setupWindow: (options: WindowOptions, window: BrowserWindow) => void, parent?: BrowserWindow, ): BrowserWindow { const window = createNewWindow(options, setupWindow, 'about:blank', parent); @@ -78,12 +80,12 @@ export function createAboutBlankWindow( } export function createNewTab( - options, - setupWindow, + options: WindowOptions, + setupWindow: (options: WindowOptions, window: BrowserWindow) => void, url: string, foreground: boolean, parent?: BrowserWindow, -): BrowserWindow { +): BrowserWindow | undefined { log.debug('createNewTab', { url, foreground, parent }); return withFocusedWindow((focusedWindow) => { const newTab = createNewWindow(options, setupWindow, url, parent); @@ -96,8 +98,8 @@ export function createNewTab( } export function createNewWindow( - options, - setupWindow: (...args) => void, + options: WindowOptions, + setupWindow: (options: WindowOptions, window: BrowserWindow) => void, url: string, parent?: BrowserWindow, ): BrowserWindow { @@ -118,7 +120,7 @@ export function getCurrentURL(): string { } export function getDefaultWindowOptions( - options, + options: WindowOptions, ): BrowserWindowConstructorOptions { const browserwindowOptions: BrowserWindowConstructorOptions = { ...options.browserwindowOptions, @@ -128,7 +130,7 @@ export function getDefaultWindowOptions( // webPreferences specified in the DEFAULT_WINDOW_OPTIONS with itself delete browserwindowOptions.webPreferences; - const webPreferences = { + const webPreferences: WebPreferences = { ...(options.browserwindowOptions?.webPreferences ?? {}), }; @@ -171,15 +173,15 @@ export function goForward(): void { }); } -export function goToURL(url: string): Promise { +export function goToURL(url: string): Promise | undefined { return withFocusedWindow((focusedWindow) => focusedWindow.loadURL(url)); } export function hideWindow( window: BrowserWindow, - event: IpcMainEvent, + event: Event, fastQuit: boolean, - tray: 'true' | 'false' | 'start-in-tray', + tray: TrayValue, ): void { if (isOSX() && !fastQuit) { // this is called when exiting from clicking the cross button on the window @@ -221,7 +223,7 @@ export function injectCSS(browserWindow: BrowserWindow): void { callback: (headersReceivedResponse: HeadersReceivedResponse) => void, ) => { const contentType = - 'content-type' in details.responseHeaders + details.responseHeaders && 'content-type' in details.responseHeaders ? details.responseHeaders['content-type'][0] : undefined; @@ -252,9 +254,9 @@ export function injectCSS(browserWindow: BrowserWindow): void { async function injectCSSIntoResponse( details: OnHeadersReceivedListenerDetails, - contentType: string, + contentType: string | undefined, cssToInject: string, -): Promise> { +): Promise | undefined> { // We go with a denylist rather than a whitelist (e.g. only text/html) // to avoid "whoops I didn't think this should have been CSS-injected" cases const nonInjectableContentTypes = [ @@ -265,19 +267,26 @@ async function injectCSSIntoResponse( const nonInjectableResourceTypes = ['image', 'script', 'stylesheet', 'xhr']; if ( - nonInjectableContentTypes.filter((x) => x.exec(contentType)?.length > 0) - ?.length > 0 || + (contentType && + nonInjectableContentTypes.filter((x) => { + const matches = x.exec(contentType); + return matches && matches?.length > 0; + })?.length > 0) || nonInjectableResourceTypes.includes(details.resourceType) || !details.webContents ) { log.debug( - `Skipping CSS injection for:\n${details.url}\nwith resourceType ${details.resourceType} and content-type ${contentType}`, + `Skipping CSS injection for:\n${details.url}\nwith resourceType ${ + details.resourceType + } and content-type ${contentType as string}`, ); return details.responseHeaders; } log.debug( - `Injecting CSS for:\n${details.url}\nwith resourceType ${details.resourceType} and content-type ${contentType}`, + `Injecting CSS for:\n${details.url}\nwith resourceType ${ + details.resourceType + } and content-type ${contentType as string}`, ); await details.webContents.insertCSS(cssToInject); @@ -285,7 +294,7 @@ async function injectCSSIntoResponse( } export function sendParamsOnDidFinishLoad( - options, + options: WindowOptions, window: BrowserWindow, ): void { window.webContents.on('did-finish-load', () => { @@ -304,7 +313,10 @@ export function sendParamsOnDidFinishLoad( }); } -export function setProxyRules(window: BrowserWindow, proxyRules): void { +export function setProxyRules( + window: BrowserWindow, + proxyRules?: string, +): void { window.webContents.session .setProxy({ proxyRules, @@ -314,13 +326,15 @@ export function setProxyRules(window: BrowserWindow, proxyRules): void { .catch((err) => log.error('session.setProxy ERROR', err)); } -export function withFocusedWindow(block: (window: BrowserWindow) => T): T { +export function withFocusedWindow( + block: (window: BrowserWindow) => T, +): T | undefined { const focusedWindow = BrowserWindow.getFocusedWindow(); if (focusedWindow) { return block(focusedWindow); } - return null; + return undefined; } export function zoomOut(): void { @@ -328,7 +342,7 @@ export function zoomOut(): void { adjustWindowZoom(-ZOOM_INTERVAL); } -export function zoomReset(options): void { +export function zoomReset(options: { zoom?: number }): void { log.debug('zoomReset'); withFocusedWindow((focusedWindow) => { focusedWindow.webContents.zoomFactor = options.zoom ?? 1.0; diff --git a/app/src/main.ts b/app/src/main.ts index 8a798bc..b0bb0ce 100644 --- a/app/src/main.ts +++ b/app/src/main.ts @@ -10,7 +10,7 @@ import electron, { globalShortcut, systemPreferences, BrowserWindow, - IpcMainEvent, + Event, } from 'electron'; import electronDownload from 'electron-dl'; import * as log from 'loglevel'; @@ -25,6 +25,10 @@ import { createTrayIcon } from './components/trayIcon'; import { isOSX, removeUserAgentSpecifics } from './helpers/helpers'; import { inferFlashPath } from './helpers/inferFlash'; import { setupNativefierWindow } from './helpers/windowEvents'; +import { + OutputOptions, + outputOptionsToWindowOptions, +} from '../../shared/src/options/model'; // Entrypoint for Squirrel, a windows update framework. See https://github.com/nativefier/nativefier/pull/744 if (require('electron-squirrel-startup')) { @@ -39,7 +43,9 @@ if (process.argv.indexOf('--verbose') > -1) { let mainWindow: BrowserWindow; -const appArgs = JSON.parse(fs.readFileSync(APP_ARGS_FILE_PATH, 'utf8')); +const appArgs = JSON.parse( + fs.readFileSync(APP_ARGS_FILE_PATH, 'utf8'), +) as OutputOptions; log.debug('appArgs', appArgs); // Do this relatively early so that we can start storing appData with the app @@ -94,10 +100,13 @@ if (appArgs.processEnvs) { if (typeof appArgs.processEnvs === 'string') { process.env.processEnvs = appArgs.processEnvs; } else { - Object.keys(appArgs.processEnvs).forEach((key) => { - /* eslint-env node */ - process.env[key] = appArgs.processEnvs[key]; - }); + Object.keys(appArgs.processEnvs) + .filter((key) => key !== undefined) + .forEach((key) => { + // @ts-expect-error TS will complain this could be undefined, but we filtered those out + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + process.env[key] = appArgs.processEnvs[key]; + }); } } @@ -125,7 +134,10 @@ if (appArgs.enableEs3Apis) { } if (appArgs.diskCacheSize) { - app.commandLine.appendSwitch('disk-cache-size', appArgs.diskCacheSize); + app.commandLine.appendSwitch( + 'disk-cache-size', + appArgs.diskCacheSize.toString(), + ); } if (appArgs.basicAuthUsername) { @@ -143,7 +155,7 @@ if (appArgs.basicAuthPassword) { } if (appArgs.lang) { - const langParts = (appArgs.lang as string).split(','); + const langParts = appArgs.lang.split(','); // Convert locales to languages, because for some reason locales don't work. Stupid Chromium const langPartsParsed = Array.from( // Convert to set to dedupe in case something like "en-GB,en-US" was passed @@ -156,10 +168,12 @@ if (appArgs.lang) { let currentBadgeCount = 0; const setDockBadge = isOSX() - ? (count: number, bounce = false): void => { - app.dock.setBadge(count.toString()); - if (bounce && count > currentBadgeCount) app.dock.bounce(); - currentBadgeCount = count; + ? (count?: number | string, bounce = false): void => { + if (count) { + app.dock.setBadge(count.toString()); + if (bounce && count > currentBadgeCount) app.dock.bounce(); + currentBadgeCount = typeof count === 'number' ? count : 0; + } } : (): void => undefined; @@ -191,17 +205,17 @@ app.on('quit', (event, exitCode) => { log.debug('app.quit', { event, exitCode }); }); -if (appArgs.crashReporter) { - app.on('will-finish-launching', () => { - log.debug('app.will-finish-launching'); +app.on('will-finish-launching', () => { + log.debug('app.will-finish-launching'); + if (appArgs.crashReporter) { crashReporter.start({ - companyName: appArgs.companyName || '', + companyName: appArgs.companyName ?? '', productName: appArgs.name, submitURL: appArgs.crashReporter, uploadToServer: true, }); - }); -} + } +}); if (appArgs.widevine) { // @ts-expect-error This event only appears on the widevine version of electron, which we'd see at runtime @@ -270,19 +284,28 @@ app.on('new-window-for-tab', () => { } }); -app.on('login', (event, webContents, request, authInfo, callback) => { - log.debug('app.login', { event, request }); - // for http authentication - event.preventDefault(); +app.on( + 'login', + ( + event, + webContents, + request, + authInfo, + callback: (username?: string, password?: string) => void, + ) => { + log.debug('app.login', { event, request }); + // for http authentication + event.preventDefault(); - if (appArgs.basicAuthUsername && appArgs.basicAuthPassword) { - callback(appArgs.basicAuthUsername, appArgs.basicAuthPassword); - } else { - createLoginWindow(callback, mainWindow).catch((err) => - log.error('createLoginWindow ERROR', err), - ); - } -}); + if (appArgs.basicAuthUsername && appArgs.basicAuthPassword) { + callback(appArgs.basicAuthUsername, appArgs.basicAuthPassword); + } else { + createLoginWindow(callback, mainWindow).catch((err) => + log.error('createLoginWindow ERROR', err), + ); + } + }, +); async function onReady(): Promise { // Warning: `mainWindow` below is the *global* unique `mainWindow`, created at init time @@ -295,6 +318,7 @@ async function onReady(): Promise { appArgs.globalShortcuts.forEach((shortcut) => { globalShortcut.register(shortcut.key, () => { shortcut.inputEvents.forEach((inputEvent) => { + // @ts-expect-error without including electron in our models, these will never match mainWindow.webContents.sendInputEvent(inputEvent); }); }); @@ -319,16 +343,19 @@ async function onReady(): Promise { // the user for permission on Mac. // For reference: // https://www.electronjs.org/docs/api/global-shortcut?q=MediaPlayPause#globalshortcutregisteraccelerator-callback - const accessibilityPromptResult = dialog.showMessageBoxSync(null, { - type: 'question', - message: 'Accessibility Permissions Needed', - buttons: ['Yes', 'No', 'No and never ask again'], - defaultId: 0, - detail: - `${appArgs.name} would like to use one or more of your keyboard's media keys (start, stop, next track, or previous track) to control it.\n\n` + - `Would you like Mac OS to ask for your permission to do so?\n\n` + - `If so, you will need to restart ${appArgs.name} after granting permissions for these keyboard shortcuts to begin working.`, - }); + const accessibilityPromptResult = dialog.showMessageBoxSync( + mainWindow, + { + type: 'question', + message: 'Accessibility Permissions Needed', + buttons: ['Yes', 'No', 'No and never ask again'], + defaultId: 0, + detail: + `${appArgs.name} would like to use one or more of your keyboard's media keys (start, stop, next track, or previous track) to control it.\n\n` + + `Would you like Mac OS to ask for your permission to do so?\n\n` + + `If so, you will need to restart ${appArgs.name} after granting permissions for these keyboard shortcuts to begin working.`, + }, + ); switch (accessibilityPromptResult) { // User clicked Yes, prompt for accessibility case 0: @@ -354,7 +381,7 @@ async function onReady(): Promise { appArgs.oldBuildWarningText || 'This app was built a long time ago. Nativefier uses the Chrome browser (through Electron), and it is insecure to keep using an old version of it. Please upgrade Nativefier and rebuild this app.'; dialog - .showMessageBox(null, { + .showMessageBox(mainWindow, { type: 'warning', message: 'Old build detected', detail: oldBuildWarningText, @@ -365,7 +392,7 @@ async function onReady(): Promise { app.on( 'accessibility-support-changed', - (event: IpcMainEvent, accessibilitySupportEnabled: boolean) => { + (event: Event, accessibilitySupportEnabled: boolean) => { log.debug('app.accessibility-support-changed', { event, accessibilitySupportEnabled, @@ -375,23 +402,20 @@ app.on( app.on( 'activity-was-continued', - (event: IpcMainEvent, type: string, userInfo: unknown) => { + (event: Event, type: string, userInfo: unknown) => { log.debug('app.activity-was-continued', { event, type, userInfo }); }, ); -app.on('browser-window-blur', (event: IpcMainEvent, window: BrowserWindow) => { +app.on('browser-window-blur', (event: Event, window: BrowserWindow) => { log.debug('app.browser-window-blur', { event, window }); }); -app.on( - 'browser-window-created', - (event: IpcMainEvent, window: BrowserWindow) => { - log.debug('app.browser-window-created', { event, window }); - setupNativefierWindow(appArgs, window); - }, -); +app.on('browser-window-created', (event: Event, window: BrowserWindow) => { + log.debug('app.browser-window-created', { event, window }); + setupNativefierWindow(outputOptionsToWindowOptions(appArgs), window); +}); -app.on('browser-window-focus', (event: IpcMainEvent, window: BrowserWindow) => { +app.on('browser-window-focus', (event: Event, window: BrowserWindow) => { log.debug('app.browser-window-focus', { event, window }); }); diff --git a/app/src/mocks/electron.ts b/app/src/mocks/electron.ts index 720fd28..9f512cc 100644 --- a/app/src/mocks/electron.ts +++ b/app/src/mocks/electron.ts @@ -23,6 +23,7 @@ class MockBrowserWindow extends EventEmitter { webContents: MockWebContents; constructor(options?: unknown) { + // @ts-expect-error options is really EventEmitterOptions, but events.d.ts doesn't expose it... super(options); this.webContents = new MockWebContents(); } @@ -44,15 +45,15 @@ class MockBrowserWindow extends EventEmitter { } isSimpleFullScreen(): boolean { - return undefined; + throw new Error('Not implemented'); } isFullScreen(): boolean { - return undefined; + throw new Error('Not implemented'); } isFullScreenable(): boolean { - return undefined; + throw new Error('Not implemented'); } loadURL(url: string, options?: unknown): Promise { @@ -73,14 +74,14 @@ class MockDialog { browserWindow: MockBrowserWindow, options: unknown, ): Promise { - return Promise.resolve(undefined); + throw new Error('Not implemented'); } static showMessageBoxSync( browserWindow: MockBrowserWindow, options: unknown, ): number { - return undefined; + throw new Error('Not implemented'); } } @@ -110,11 +111,11 @@ class MockWebContents extends EventEmitter { } getURL(): string { - return undefined; + throw new Error('Not implemented'); } insertCSS(css: string, options?: unknown): Promise { - return Promise.resolve(undefined); + throw new Error('Not implemented'); } } @@ -134,13 +135,15 @@ class MockWebRequest { ) => void) | null, ): void { - this.emitter.addListener( - 'onHeadersReceived', - ( - details: unknown, - callback: (headersReceivedResponse: unknown) => void, - ) => listener(details, callback), - ); + if (listener) { + this.emitter.addListener( + 'onHeadersReceived', + ( + details: unknown, + callback: (headersReceivedResponse: unknown) => void, + ) => listener(details, callback), + ); + } } send(event: string, ...args: unknown[]): void { diff --git a/app/src/preload.ts b/app/src/preload.ts index 613f54e..e884455 100644 --- a/app/src/preload.ts +++ b/app/src/preload.ts @@ -11,6 +11,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { ipcRenderer } from 'electron'; +import { OutputOptions } from '../../shared/src/options/model'; // Do *NOT* add 3rd-party imports here in preload (except for webpack `externals` like electron). // They will work during development, but break in the prod build :-/ . @@ -56,7 +57,7 @@ function setNotificationCallback( get: () => OldNotify.permission, }); - // @ts-expect-error + // @ts-expect-error TypeScript says its not compatible, but it works? window.Notification = newNotify; } @@ -92,14 +93,15 @@ function notifyNotificationClick(): void { ipcRenderer.send('notification-click'); } +// @ts-expect-error TypeScript thinks these are incompatible but they aren't setNotificationCallback(notifyNotificationCreate, notifyNotificationClick); -ipcRenderer.on('params', (event, message) => { +ipcRenderer.on('params', (event, message: string) => { log.debug('ipcRenderer.params', { event, message }); - const appArgs = JSON.parse(message); + const appArgs = JSON.parse(message) as OutputOptions; log.info('nativefier.json', appArgs); }); -ipcRenderer.on('debug', (event, message) => { +ipcRenderer.on('debug', (event, message: string) => { log.debug('ipcRenderer.debug', { event, message }); }); diff --git a/app/tsconfig.json b/app/tsconfig.json index 2f11721..9861102 100644 --- a/app/tsconfig.json +++ b/app/tsconfig.json @@ -1,36 +1,36 @@ { + "extends": "../tsconfig-base.json", "compilerOptions": { - "allowJs": true, - "declaration": false, - "esModuleInterop": true, - "incremental": true, - "module": "commonjs", - "moduleResolution": "node", - "outDir": "./dist", - "resolveJsonModule": true, - "skipLibCheck": true, - "sourceMap": true, - // Here in app/tsconfig.json, we want to set the `target` and `lib` keys to - // the "best" values for the version of Node **coming with the chosen Electron**. - // Careful: we're *not* talking about Nativefier's (CLI) required Node version, - // we're talking about the version of the Node runtime **bundled with Electron**. - // - // Like in our main tsconfig.json, we want to be as conservative as possible, - // to support (as much as reasonable) users using old versions of Electron. - // Then, at some point, an app dependency (declared in app/package.json) - // will require a more recent Node, then it's okay to bump our app compilerOptions - // to what's supported by the more recent Node. - // - // TS doesn't offer any easy "preset" for this, so the best we have is to - // believe people who know which {syntax, library} parts of current EcmaScript - // are supported for the version of Node coming with the Electron being used, - // and use what they recommend. For the current Node version, I followed - // https://stackoverflow.com/questions/51716406/typescript-tsconfig-settings-for-node-js-10 - // and 'dom' to tell tsc it's okay to use the URL object (which is in Node >= 7) - "target": "es2018", - "lib": ["es2018", "dom"] + "outDir": "./dist", + // Here in app/tsconfig.json, we want to set the `target` and `lib` keys to + // the "best" values for the version of Node **coming with the chosen Electron**. + // Careful: we're *not* talking about Nativefier's (CLI) required Node version, + // we're talking about the version of the Node runtime **bundled with Electron**. + // + // Like in our main tsconfig.json, we want to be as conservative as possible, + // to support (as much as reasonable) users using old versions of Electron. + // Then, at some point, an app dependency (declared in app/package.json) + // will require a more recent Node, then it's okay to bump our app compilerOptions + // to what's supported by the more recent Node. + // + // TS doesn't offer any easy "preset" for this, so the best we have is to + // believe people who know which {syntax, library} parts of current EcmaScript + // are supported for the version of Node coming with the Electron being used, + // and use what they recommend. For the current Node version, I followed + // https://stackoverflow.com/questions/51716406/typescript-tsconfig-settings-for-node-js-10 + // and 'dom' to tell tsc it's okay to use the URL object (which is in Node >= 7) + "target": "es2018", + "lib": [ + "es2018", + "dom" + ] }, "include": [ - "./src/**/*" + "./src/**/*" + ], + "references": [ + { + "path": "../shared" + } ] } diff --git a/.eslintrc.js b/base-eslintrc.js similarity index 93% rename from .eslintrc.js rename to base-eslintrc.js index 5ff49dc..95b585b 100644 --- a/.eslintrc.js +++ b/base-eslintrc.js @@ -1,11 +1,6 @@ // # https://github.com/typescript-eslint/typescript-eslint/blob/master/docs/getting-started/linting/README.md module.exports = { - root: true, parser: '@typescript-eslint/parser', - parserOptions: { - tsconfigRootDir: __dirname, - project: ['./tsconfig.json'], - }, plugins: ['@typescript-eslint', 'prettier'], extends: [ 'eslint:recommended', diff --git a/package.json b/package.json index a03ef5e..3b3b589 100644 --- a/package.json +++ b/package.json @@ -33,15 +33,15 @@ "scripts": { "build-app": "cd app && webpack", "build-app-static": "ncp app/src/static/ app/lib/static/ && ncp app/dist/preload.js app/lib/preload.js && ncp app/dist/preload.js.map app/lib/preload.js.map", - "build": "npm run clean && tsc --build . app && npm run build-app && npm run build-app-static", + "build": "npm run clean && tsc --build shared src app && npm run build-app && npm run build-app-static", "build:watch": "npm run clean && tsc --build . app --watch", "changelog": "./.github/generate-changelog", "ci": "npm run lint && npm test", - "clean": "rimraf coverage/ lib/ app/lib/ app/dist/", - "clean:full": "rimraf coverage/ lib/ app/lib/ app/dist/ app/node_modules/ node_modules/", - "lint:fix": "eslint . --ext .ts --fix", - "lint:format": "prettier --write 'src/**/*.ts' 'app/src/**/*.ts'", - "lint": "eslint . --ext .ts", + "clean": "rimraf coverage/ lib/ app/lib/ app/dist/ shared/lib", + "clean:full": "npm run clean && rimraf app/node_modules/ node_modules/", + "lint:fix": "cd src && eslint . --ext .ts --fix && cd ../shared && eslint src --ext .ts --fix && cd ../app && eslint src --ext .ts --fix", + "lint:format": "prettier --write 'src/**/*.ts' 'app/src/**/*.ts' 'shared/src/**/*.ts'", + "lint": "eslint shared app src --ext .ts", "list-outdated-deps": "npm out; cd app && npm out; true", "prepare": "cd app && npm install && cd .. && npm run build", "test:integration": "jest --testRegex '.*integration-test.js'", @@ -109,10 +109,11 @@ ], "watchPathIgnorePatterns": [ "/src.*", - "/tsconfig.json", + "/tsconfig-base.json", "/app/src.*", "/app/lib.*", - "/app/tsconfig.json" + "/app/tsconfig.json", + "/shared/tsconfig.json" ] }, "prettier": { diff --git a/shared/.eslintrc.js b/shared/.eslintrc.js new file mode 100644 index 0000000..6f881af --- /dev/null +++ b/shared/.eslintrc.js @@ -0,0 +1,14 @@ +const base = require('../base-eslintrc'); + +// # https://github.com/typescript-eslint/typescript-eslint/blob/master/docs/getting-started/linting/README.md +module.exports = { + parser: base.parser, + parserOptions: { + tsconfigRootDir: __dirname, + project: ['./tsconfig.json'], + }, + plugins: base.plugins, + extends: base.extends, + rules: base.rules, + ignorePatterns: ['lib/**'], +}; diff --git a/src/options/model.ts b/shared/src/options/model.ts similarity index 73% rename from src/options/model.ts rename to shared/src/options/model.ts index f6495a0..2f04e86 100644 --- a/src/options/model.ts +++ b/shared/src/options/model.ts @@ -1,6 +1,11 @@ import { CreateOptions } from 'asar'; import * as electronPackager from 'electron-packager'; +export type TitleBarValue = + | 'default' + | 'hidden' + | 'hiddenInset' + | 'customButtonsOnHover'; export type TrayValue = 'true' | 'false' | 'start-in-tray'; export interface ElectronPackagerOptions extends electronPackager.Options { @@ -34,7 +39,7 @@ export interface AppOptions { electronVersionUsed?: string; enableEs3Apis: boolean; fastQuit: boolean; - fileDownloadOptions: unknown; + fileDownloadOptions?: Record; flashPluginDir?: string; fullScreen: boolean; globalShortcuts?: GlobalShortcut[]; @@ -46,12 +51,12 @@ export interface AppOptions { internalUrls?: string; lang?: string; maximize: boolean; - nativefierVersion?: string; + nativefierVersion: string; processEnvs?: string; proxyRules?: string; showMenuBar: boolean; singleInstance: boolean; - titleBarStyle?: string; + titleBarStyle?: TitleBarValue; tray: TrayValue; userAgent?: string; userAgentHonest: boolean; @@ -70,10 +75,26 @@ export interface AppOptions { }; } -export type BrowserWindowOptions = Record; +export type BrowserWindowOptions = Record & { + webPreferences?: Record; +}; export type GlobalShortcut = { key: string; + inputEvents: { + type: + | 'mouseDown' + | 'mouseUp' + | 'mouseEnter' + | 'mouseLeave' + | 'contextMenu' + | 'mouseWheel' + | 'mouseMove' + | 'keyDown' + | 'keyUp' + | 'char'; + keyCode: string; + }[]; }; export type NativefierOptions = Partial< @@ -81,9 +102,20 @@ export type NativefierOptions = Partial< >; export type OutputOptions = NativefierOptions & { + blockExternalUrls: boolean; + browserwindowOptions?: BrowserWindowOptions; buildDate: number; + companyName?: string; + disableDevTools: boolean; + fileDownloadOptions?: Record; + internalUrls: string | RegExp | undefined; isUpgrade: boolean; + name: string; + nativefierVersion: string; oldBuildWarningText: string; + targetUrl: string; + userAgent?: string; + zoom?: number; }; export type PackageJSON = { @@ -120,7 +152,7 @@ export type RawOptions = { electronVersionUsed?: string; enableEs3Apis?: boolean; fastQuit?: boolean; - fileDownloadOptions?: unknown; + fileDownloadOptions?: Record; flashPath?: string; flashPluginDir?: string; fullScreen?: boolean; @@ -150,7 +182,7 @@ export type RawOptions = { showMenuBar?: boolean; singleInstance?: boolean; targetUrl?: string; - titleBarStyle?: string; + titleBarStyle?: TitleBarValue; tray: TrayValue; upgrade?: string | boolean; upgradeFrom?: string; @@ -165,3 +197,25 @@ export type RawOptions = { y?: number; zoom?: number; }; + +export type WindowOptions = { + blockExternalUrls: boolean; + browserwindowOptions?: BrowserWindowOptions; + insecure: boolean; + internalUrls?: string | RegExp; + name: string; + proxyRules?: string; + targetUrl: string; + userAgent?: string; + zoom: number; +}; + +export function outputOptionsToWindowOptions( + options: OutputOptions, +): WindowOptions { + return { + ...options, + insecure: options.insecure ?? false, + zoom: options.zoom ?? 1.0, + }; +} diff --git a/shared/tsconfig.json b/shared/tsconfig.json new file mode 100644 index 0000000..060ba00 --- /dev/null +++ b/shared/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "../tsconfig-base.json", + "compilerOptions": { + "composite": true, + "outDir": "./lib", + // Here we want to set target and lib to the *worst* of app/tsconfig.json and src/tsconfig.json + // (plus "dom"), because shared code will run both in CLI Node and app Node. + // See comments in app/tsconfig.json and src/tsconfig.json + "target": "es2018", + "lib": [ + "es2018", + "dom" + ] + }, + "include": [ + "./src/**/*" + ], +} diff --git a/src/.eslintrc.js b/src/.eslintrc.js new file mode 100644 index 0000000..3b13199 --- /dev/null +++ b/src/.eslintrc.js @@ -0,0 +1,13 @@ +const base = require('../base-eslintrc'); + +// # https://github.com/typescript-eslint/typescript-eslint/blob/master/docs/getting-started/linting/README.md +module.exports = { + parser: base.parser, + parserOptions: { + tsconfigRootDir: __dirname, + project: ['./tsconfig.json'], + }, + plugins: base.plugins, + extends: base.extends, + rules: base.rules, +}; diff --git a/src/build/buildIcon.ts b/src/build/buildIcon.ts index fcc7de3..8761f81 100644 --- a/src/build/buildIcon.ts +++ b/src/build/buildIcon.ts @@ -9,7 +9,7 @@ import { convertToIcns, convertToTrayIcon, } from '../helpers/iconShellHelpers'; -import { AppOptions } from '../options/model'; +import { AppOptions } from '../../shared/src/options/model'; function iconIsIco(iconPath: string): boolean { return path.extname(iconPath) === '.ico'; diff --git a/src/build/buildNativefierApp.ts b/src/build/buildNativefierApp.ts index ba5fb50..cea2c32 100644 --- a/src/build/buildNativefierApp.ts +++ b/src/build/buildNativefierApp.ts @@ -1,7 +1,7 @@ import * as path from 'path'; import * as electronGet from '@electron/get'; -import * as electronPackager from 'electron-packager'; +import electronPackager from 'electron-packager'; import * as log from 'loglevel'; import { convertIconIfNecessary } from './buildIcon'; @@ -13,7 +13,7 @@ import { isWindowsAdmin, } from '../helpers/helpers'; import { useOldAppOptions, findUpgradeApp } from '../helpers/upgrade/upgrade'; -import { AppOptions, RawOptions } from '../options/model'; +import { AppOptions, RawOptions } from '../../shared/src/options/model'; import { getOptions } from '../options/optionsMain'; import { prepareElectronApp } from './prepareElectronApp'; diff --git a/src/build/prepareElectronApp.ts b/src/build/prepareElectronApp.ts index 51df923..187a3fa 100644 --- a/src/build/prepareElectronApp.ts +++ b/src/build/prepareElectronApp.ts @@ -6,8 +6,13 @@ import { promisify } from 'util'; import * as log from 'loglevel'; import { copyFileOrDir, generateRandomSuffix } from '../helpers/helpers'; -import { AppOptions, OutputOptions, PackageJSON } from '../options/model'; +import { + AppOptions, + OutputOptions, + PackageJSON, +} from '../../shared/src/options/model'; import { parseJson } from '../utils/parseUtils'; +import { DEFAULT_APP_NAME } from '../constants'; const writeFileAsync = promisify(fs.writeFile); @@ -66,7 +71,7 @@ function pickElectronAppArgs(options: AppOptions): OutputOptions { maxWidth: options.nativefier.maxWidth, minHeight: options.nativefier.minHeight, minWidth: options.nativefier.minWidth, - name: options.packager.name, + name: options.packager.name ?? DEFAULT_APP_NAME, nativefierVersion: options.nativefier.nativefierVersion, osxNotarize: options.packager.osxNotarize, osxSign: options.packager.osxSign, diff --git a/src/cli.ts b/src/cli.ts index f804bd3..6b7cfc0 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -3,7 +3,7 @@ import 'source-map-support/register'; import electronPackager = require('electron-packager'); import * as log from 'loglevel'; -import * as yargs from 'yargs'; +import yargs from 'yargs'; import { DEFAULT_ELECTRON_VERSION } from './constants'; import { @@ -13,7 +13,7 @@ import { } from './helpers/helpers'; import { supportedArchs, supportedPlatforms } from './infer/inferOs'; import { buildNativefierApp } from './main'; -import { RawOptions } from './options/model'; +import { RawOptions } from '../shared/src/options/model'; import { parseJson } from './utils/parseUtils'; export function initArgs(argv: string[]): yargs.Argv { diff --git a/src/helpers/upgrade/executableHelpers.ts b/src/helpers/upgrade/executableHelpers.ts index 2ec3348..7decaf2 100644 --- a/src/helpers/upgrade/executableHelpers.ts +++ b/src/helpers/upgrade/executableHelpers.ts @@ -3,7 +3,7 @@ import * as path from 'path'; import * as log from 'loglevel'; -import { NativefierOptions } from '../../options/model'; +import { NativefierOptions } from '../../../shared/src/options/model'; import { getVersionString } from './rceditGet'; import { fileExists } from '../fsHelpers'; type ExecutableInfo = { diff --git a/src/helpers/upgrade/upgrade.ts b/src/helpers/upgrade/upgrade.ts index 8a9f7c3..07730ab 100644 --- a/src/helpers/upgrade/upgrade.ts +++ b/src/helpers/upgrade/upgrade.ts @@ -3,7 +3,10 @@ import * as path from 'path'; import * as log from 'loglevel'; -import { NativefierOptions, RawOptions } from '../../options/model'; +import { + NativefierOptions, + RawOptions, +} from '../../../shared/src/options/model'; import { dirExists, fileExists } from '../fsHelpers'; import { extractBoolean, extractString } from './plistInfoXMLHelpers'; import { getOptionsFromExecutable } from './executableHelpers'; diff --git a/src/infer/inferIcon.ts b/src/infer/inferIcon.ts index 285feb9..7d7b86b 100644 --- a/src/infer/inferIcon.ts +++ b/src/infer/inferIcon.ts @@ -3,7 +3,7 @@ import { writeFile } from 'fs'; import { promisify } from 'util'; import gitCloud = require('gitcloud'); -import * as pageIcon from 'page-icon'; +import pageIcon from 'page-icon'; import { downloadFile, diff --git a/src/integration-test.ts b/src/integration-test.ts index 323d34f..b8efa76 100644 --- a/src/integration-test.ts +++ b/src/integration-test.ts @@ -10,7 +10,7 @@ import { getLatestSafariVersion } from './infer/browsers/inferSafariVersion'; import { inferArch } from './infer/inferOs'; import { buildNativefierApp } from './main'; import { userAgent } from './options/fields/userAgent'; -import { NativefierOptions, RawOptions } from './options/model'; +import { NativefierOptions, RawOptions } from '../shared/src/options/model'; import { parseJson } from './utils/parseUtils'; async function checkApp( diff --git a/src/main.ts b/src/main.ts index 98be281..cc56c1b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,7 +1,7 @@ import 'source-map-support/register'; import { buildNativefierApp } from './build/buildNativefierApp'; -import { RawOptions } from './options/model'; +import { RawOptions } from '../shared/src/options/model'; export { buildNativefierApp }; diff --git a/src/options/asyncConfig.ts b/src/options/asyncConfig.ts index 3f72618..5f5fd1c 100644 --- a/src/options/asyncConfig.ts +++ b/src/options/asyncConfig.ts @@ -1,7 +1,7 @@ import * as log from 'loglevel'; import { processOptions } from './fields/fields'; -import { AppOptions } from './model'; +import { AppOptions } from '../../shared/src/options/model'; /** * Takes the options object and infers new values needing async work diff --git a/src/options/fields/fields.test.ts b/src/options/fields/fields.test.ts index c720d4a..652bd92 100644 --- a/src/options/fields/fields.test.ts +++ b/src/options/fields/fields.test.ts @@ -1,4 +1,4 @@ -import { AppOptions } from '../model'; +import { AppOptions } from '../../../shared/src/options/model'; import { processOptions } from './fields'; describe('fields', () => { let options: AppOptions; diff --git a/src/options/fields/fields.ts b/src/options/fields/fields.ts index c516073..a4ef770 100644 --- a/src/options/fields/fields.ts +++ b/src/options/fields/fields.ts @@ -1,6 +1,6 @@ import { icon } from './icon'; import { userAgent } from './userAgent'; -import { AppOptions } from '../model'; +import { AppOptions } from '../../../shared/src/options/model'; import { name } from './name'; type OptionPostprocessor = { diff --git a/src/options/optionsMain.test.ts b/src/options/optionsMain.test.ts index 533a200..def6c06 100644 --- a/src/options/optionsMain.test.ts +++ b/src/options/optionsMain.test.ts @@ -1,7 +1,7 @@ import { getOptions, normalizePlatform } from './optionsMain'; import * as asyncConfig from './asyncConfig'; import { inferPlatform } from '../infer/inferOs'; -import { AppOptions, RawOptions } from './model'; +import { AppOptions, RawOptions } from '../../shared/src/options/model'; let asyncConfigMock: jest.SpyInstance; const mockedAsyncConfig: AppOptions = { diff --git a/src/options/optionsMain.ts b/src/options/optionsMain.ts index 67134c1..81c18b4 100644 --- a/src/options/optionsMain.ts +++ b/src/options/optionsMain.ts @@ -19,7 +19,11 @@ import { } from '../constants'; import { inferPlatform, inferArch } from '../infer/inferOs'; import { asyncConfig } from './asyncConfig'; -import { AppOptions, GlobalShortcut, RawOptions } from './model'; +import { + AppOptions, + GlobalShortcut, + RawOptions, +} from '../../shared/src/options/model'; import { normalizeUrl } from './normalizeUrl'; import { parseJson } from '../utils/parseUtils'; diff --git a/tsconfig.json b/src/tsconfig.json similarity index 77% rename from tsconfig.json rename to src/tsconfig.json index e0604bd..190994b 100644 --- a/tsconfig.json +++ b/src/tsconfig.json @@ -1,15 +1,8 @@ { + "extends": "../tsconfig-base.json", "compilerOptions": { - "allowJs": false, - "declaration": true, - "incremental": true, - "module": "commonjs", - "moduleResolution": "node", - "outDir": "./lib", - "resolveJsonModule": true, - "skipLibCheck": true, - "sourceMap": true, - "strict": true, + "outDir": "../lib", + "rootDir": ".", // Bumping the minimum required Node version? You must bump: // 1. package.json -> engines.node // 2. package.json -> devDependencies.@types/node @@ -28,9 +21,11 @@ "lib": [ "es2020", "dom" - ] + ], }, - "include": [ - "./src/**/*" + "references": [ + { + "path": "../shared" + } ] } diff --git a/tsconfig-base.json b/tsconfig-base.json new file mode 100644 index 0000000..f42d5c1 --- /dev/null +++ b/tsconfig-base.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "allowJs": false, + "declaration": true, + "esModuleInterop": true, + "incremental": true, + "module": "commonjs", + "moduleResolution": "node", + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + }, +}