diff --git a/.codeclimate.yml b/.codeclimate.yml deleted file mode 100644 index 64f4747..0000000 --- a/.codeclimate.yml +++ /dev/null @@ -1,18 +0,0 @@ ---- -engines: - csslint: - enabled: true - duplication: - enabled: true - config: - languages: - - javascript - eslint: - enabled: false - fixme: - enabled: true -ratings: - paths: - - "**.js" -exclude_paths: -- test/ diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 0000000..503225e --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,23 @@ +// # 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', + 'prettier', + 'plugin:@typescript-eslint/eslint-recommended', + 'plugin:@typescript-eslint/recommended', + 'plugin:@typescript-eslint/recommended-requiring-type-checking', + ], + rules: { + 'prettier/prettier': 'error', + // TODO remove when done killing anys and making tsc strict + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/ban-ts-ignore': 'off', + }, +}; diff --git a/.eslintrc.yml b/.eslintrc.yml deleted file mode 100644 index d81db00..0000000 --- a/.eslintrc.yml +++ /dev/null @@ -1,15 +0,0 @@ -extends: - - airbnb-base - - prettier -env: - # TODO: find out how to turn this on only for src/**/*.test.js files - jest: true -plugins: - - import - - prettier -rules: - # TODO: Remove this when we have shifted away from the async package - no-shadow: 'warn' - # Gulpfiles and tests use dev dependencies - import/no-extraneous-dependencies: ['error', { devDependencies: ['gulpfile.babel.js', 'gulp/**/**.js', 'test/**/**.js']}] - prettier/prettier: "error" diff --git a/.gitignore b/.gitignore index d9a44d1..d615764 100644 --- a/.gitignore +++ b/.gitignore @@ -5,7 +5,7 @@ package-lock.json # ignore compiled lib files -lib/* +lib* app/lib/* built-tests @@ -48,3 +48,6 @@ node_modules *.iml out gen + +# Builds when testing npm pack +nativefier*.tgz diff --git a/.hound.yml b/.hound.yml deleted file mode 100644 index a2b4dcf..0000000 --- a/.hound.yml +++ /dev/null @@ -1,7 +0,0 @@ -eslint: - enabled: true - config_file: .eslintrc.yml - ignore_file: .eslintignore - -jshint: - enabled: false diff --git a/.npmignore b/.npmignore index 90f595f..f1bc61f 100644 --- a/.npmignore +++ b/.npmignore @@ -1,7 +1,19 @@ # OSX -.DS_Store - /* !lib/ -!app/lib -!bin +!app/lib/ +!icon-scripts +.DS_Store +.eslintrc.yml +src/ +app/src/ +app/node_modules +*tsconfig.tsbuildinfo +*package-lock.json +*tsconfig.json +*jestSetupFiles* +*-test.js +*-test.js.map +*.test.d.ts +*.test.js +*.test.js.map diff --git a/.travis.yml b/.travis.yml index c83800b..c83c43b 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,21 +1,19 @@ language: node_js -addons: - code_climate: - repo_token: CODE_CLIMATE_TOKEN +os: +- linux +- osx +- windows node_js: -- '11' -- '10' +- '13' # Changing this? Remind to adjust the linter condition below causing linter to run for only one version (for faster CI) +- '12' - '8' -- '7' -- '6' -before_install: -- npm install -g npm@5.8.x install: -- npm run dev-up +- npm install +- npm run build script: -- npm run ci -after_script: -- codeclimate-test-reporter < ./coverage/lcov.info +# Only run linter once, for faster CI +- if [ "$TRAVIS_OS_NAME" = "linux" ] && [ "$TRAVIS_NODE_VERSION" = "13" ]; then npm run lint; fi +- npm test deploy: provider: npm skip_cleanup: true @@ -25,4 +23,4 @@ deploy: on: tags: true repo: jiahaog/nativefier - node: '8' + node: '12' diff --git a/docs/changelog.md b/CHANGELOG.md similarity index 100% rename from docs/changelog.md rename to CHANGELOG.md diff --git a/README.md b/README.md index ef7fe65..3520a3e 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,9 @@ # Nativefier -[![Build Status](https://travis-ci.org/jiahaog/nativefier.svg?branch=development)](https://travis-ci.org/jiahaog/nativefier) -[![Code Climate](https://codeclimate.com/github/jiahaog/nativefier/badges/gpa.svg)](https://codeclimate.com/github/jiahaog/nativefier) +[![Build Status](https://travis-ci.org/jiahaog/nativefier.svg)](https://travis-ci.org/jiahaog/nativefier) [![npm version](https://badge.fury.io/js/nativefier.svg)](https://www.npmjs.com/package/nativefier) -[![Dependency Status](https://david-dm.org/jiahaog/nativefier.svg)](https://david-dm.org/jiahaog/nativefier) -![Dock](screenshots/dock.png) +![Dock](dock.png) You want to make a native wrapper for WhatsApp Web (or any web page). @@ -13,7 +11,7 @@ You want to make a native wrapper for WhatsApp Web (or any web page). nativefier web.whatsapp.com ``` -![Walkthrough](screenshots/walkthrough.gif) +![Walkthrough animation](walkthrough.gif) You're done. @@ -21,33 +19,31 @@ You're done. - [Installation](#installation) - [Usage](#usage) - - [Optional dependencies](#optional-dependencies) - [How it works](#how-it-works) - [Development](docs/development.md) - [License](#license) ## Introduction -Nativefier is a command-line tool to easily create a desktop application for any web site with succinct and minimal configuration. Apps are wrapped by [Electron](http://electron.atom.io) in an OS executable (`.app`, `.exe`, etc.) for use on Windows, macOS and Linux. +Nativefier is a command-line tool to easily create a desktop application for any web site with succinct and minimal configuration. Apps are wrapped by [Electron](https://www.electronjs.org/) in an OS executable (`.app`, `.exe`, etc.) for use on Windows, macOS and Linux. -I did this because I was tired of having to `⌘-tab` or `alt-tab` to my browser and then search through the numerous open tabs when I was using [Facebook Messenger](http://messenger.com) or [Whatsapp Web](http://web.whatsapp.com) ([relevant Hacker News thread](https://news.ycombinator.com/item?id=10930718)). +I did this because I was tired of having to `⌘-tab` or `alt-tab` to my browser and then search through the numerous open tabs when I was using [Facebook Messenger](https://messenger.com) or [Whatsapp Web](https://web.whatsapp.com) ([relevant Hacker News thread](https://news.ycombinator.com/item?id=10930718)). -[Changelog](https://github.com/jiahaog/nativefier/blob/master/docs/changelog.md). [Developer docs](https://github.com/jiahaog/nativefier/blob/master/docs/development.md). +[Changelog](https://github.com/jiahaog/nativefier/blob/master/CHANGELOG.md). [Developer docs](https://github.com/jiahaog/nativefier/blob/master/docs/development.md). -### Features +Features: - Automatically retrieves the correct icon and app name. - JavaScript and CSS injection. -- Flash Support (with [`--flash`](docs/api.md#flash) flag). - Many more, see the [API docs](docs/api.md) or `nativefier --help` ## Installation -### Requirements - - macOS 10.9+ / Windows / Linux -- [Node.js](https://nodejs.org/) `>=6` (4.x may work but is no longer tested, please upgrade) -- See [optional dependencies](#optional-dependencies) for more. +- [Node.js](https://nodejs.org/) `>=8` +- Optional dependencies: + - [ImageMagick](http://www.imagemagick.org/) to convert icons. Make sure `convert` and `identify` are in your `$PATH`. + - [Wine](https://www.winehq.org/) to package Windows apps under non-Windows platforms. Make sure `wine` is in your `$PATH`. ```bash npm install nativefier -g @@ -55,42 +51,23 @@ npm install nativefier -g ## Usage -Creating a native desktop app for [medium.com](http://medium.com): +Creating a native desktop app for [medium.com](https://medium.com): ```bash -nativefier "http://medium.com" +nativefier "medium.com" ``` -Nativefier will intelligently attempt to determine the app name, your OS and processor architecture, among other options. If desired, the app name or other options can be overwritten by specifying the `--name "Medium"` as part of the command line options: +Nativefier will attempt to determine the app name, your OS and processor architecture, among other options. If desired, the app name or other options can be overwritten by specifying the `--name "Medium"` as part of the command line options: ```bash -nativefier --name "Some Awesome App" "http://medium.com" +nativefier --name "Some Awesome App" "medium.com" ``` -Read the [API documentation](docs/api.md) (or `nativefier --help`) for other command line flags and options that can be used to configure the packaged app. -If you would like high resolution icons to be used, please contribute to the [icon repository](https://github.com/jiahaog/nativefier-icons)! +Read the [API documentation](docs/api.md) (or `nativefier --help`) for other command-line flags that can be used to configure the packaged app. -**Windows Users:** Take note that the application menu is automatically hidden by default, you can press `alt` on your keyboard to access it. +To have high-resolution icons used by default for an app/domain, please contribute to the [icon repository](https://github.com/jiahaog/nativefier-icons)! -**Linux Users:** Do not put spaces if you define the app name yourself with `--name`, as this will cause problems when pinning a packaged app to the launcher. - -## Optional dependencies - -### Icons for Windows apps packaged under non-Windows platforms - -You need [Wine](https://www.winehq.org/) installed; make sure that `wine` is in your `$PATH`. - -### Icon conversion for macOS - -To support conversion of a `.png` or `.ico` into a `.icns` for a packaged macOS app icon (currently only supported on macOS), you need the following dependencies. - -* [iconutil](https://developer.apple.com/library/mac/documentation/GraphicsAnimation/Conceptual/HighResolutionOSX/Optimizing/Optimizing.html) (comes with [Xcode](https://developer.apple.com/xcode/)). -* [imagemagick](http://www.imagemagick.org/script/index.php). Make sure `convert` and `identify` are in your `$PATH`. -* If the tools are not found, then Nativefier will fall back to the built-in macOS tool `sips` to perform the conversion, which is more limited. - -### Flash - -[Google Chrome](https://www.google.com/chrome/) is required for flash to be supported; you should pass the path to its embedded Flash plugin to the `--flash` flag. See the [API docs](docs/api.md) for more details. +Note that the application menu is hidden by default for a minimal UI. You can press the `alt` keyboard key to access it. ## How it works diff --git a/app/.eslintrc.js b/app/.eslintrc.js new file mode 100644 index 0000000..e474023 --- /dev/null +++ b/app/.eslintrc.js @@ -0,0 +1,24 @@ +// # https://github.com/typescript-eslint/typescript-eslint/blob/master/docs/getting-started/linting/README.md +module.exports = { + parser: '@typescript-eslint/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', + ], + rules: { + 'prettier/prettier': 'error', + // TODO remove when done killing anys and making tsc strict + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/ban-ts-ignore': 'off', + '@typescript-eslint/no-use-before-define': 'off', + '@typescript-eslint/explicit-function-return-type': 'off', + }, +}; diff --git a/app/.eslintrc.yml b/app/.eslintrc.yml deleted file mode 100644 index d4c13bf..0000000 --- a/app/.eslintrc.yml +++ /dev/null @@ -1,2 +0,0 @@ -settings: - import/core-modules: [ electron ] diff --git a/app/package.json b/app/package.json index 543d35e..cb351a1 100644 --- a/app/package.json +++ b/app/package.json @@ -3,23 +3,24 @@ "version": "1.0.0", "description": "Placeholder for the nativefier cli to override with a target url", "main": "lib/main.js", - "dependencies": { - "electron-context-menu": "^0.10.0", - "electron-dl": "^1.10.0", - "electron-squirrel-startup": "^1.0.0", - "electron-window-state": "^4.1.1", - "loglevel": "^1.5.1", - "source-map-support": "^0.5.0", - "wurl": "^2.5.2" - }, - "devDependencies": {}, - "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" - }, + "author": "Jia Hao", + "license": "MIT", "keywords": [ "desktop", - "electron" + "electron", + "placeholder" ], - "author": "Jia Hao", - "license": "MIT" + "scripts": {}, + "dependencies": { + "electron-context-menu": "0.x", + "electron-dl": "3.x", + "electron-squirrel-startup": "1.x", + "electron-window-state": "5.x", + "loglevel": "1.x", + "source-map-support": "0.x", + "wurl": "2.x" + }, + "devDependencies": { + "electron": "8.x" + } } diff --git a/app/src/components/contextMenu/contextMenu.js b/app/src/components/contextMenu.ts similarity index 86% rename from app/src/components/contextMenu/contextMenu.js rename to app/src/components/contextMenu.ts index 6cc7758..37eebf9 100644 --- a/app/src/components/contextMenu/contextMenu.js +++ b/app/src/components/contextMenu.ts @@ -1,9 +1,9 @@ import { shell } from 'electron'; import contextMenu from 'electron-context-menu'; -function initContextMenu(createNewWindow, createNewTab) { +export function initContextMenu(createNewWindow, createNewTab): void { contextMenu({ - prepend: (params) => { + prepend: (actions, params) => { const items = []; if (params.linkURL) { items.push({ @@ -31,5 +31,3 @@ function initContextMenu(createNewWindow, createNewTab) { }, }); } - -export default initContextMenu; diff --git a/app/src/components/login/loginWindow.js b/app/src/components/loginWindow.ts similarity index 65% rename from app/src/components/login/loginWindow.js rename to app/src/components/loginWindow.ts index ba653c1..beb25fa 100644 --- a/app/src/components/login/loginWindow.js +++ b/app/src/components/loginWindow.ts @@ -1,18 +1,19 @@ -import { BrowserWindow, ipcMain } from 'electron'; -import path from 'path'; +import * as path from 'path'; -function createLoginWindow(loginCallback) { +import { BrowserWindow, ipcMain } from 'electron'; + +export function createLoginWindow(loginCallback): BrowserWindow { const loginWindow = new BrowserWindow({ width: 300, height: 400, frame: false, resizable: false, webPreferences: { - nodeIntegration: true, + nodeIntegration: true, // TODO work around this; insecure }, }); loginWindow.loadURL( - `file://${path.join(__dirname, '/static/login/login.html')}`, + `file://${path.join(__dirname, '..', 'static/login.html')}`, ); ipcMain.once('login-message', (event, usernameAndPassword) => { @@ -21,5 +22,3 @@ function createLoginWindow(loginCallback) { }); return loginWindow; } - -export default createLoginWindow; diff --git a/app/src/components/mainWindow/mainWindow.js b/app/src/components/mainWindow.ts similarity index 53% rename from app/src/components/mainWindow/mainWindow.js rename to app/src/components/mainWindow.ts index dcff241..d25b2bd 100644 --- a/app/src/components/mainWindow/mainWindow.js +++ b/app/src/components/mainWindow.ts @@ -1,13 +1,10 @@ -import fs from 'fs'; -import path from 'path'; -import { BrowserWindow, shell, ipcMain, dialog } from 'electron'; -import windowStateKeeper from 'electron-window-state'; -import mainWindowHelpers from './mainWindowHelpers'; -import helpers from '../../helpers/helpers'; -import createMenu from '../menu/menu'; -import initContextMenu from '../contextMenu/contextMenu'; +import * as fs from 'fs'; +import * as path from 'path'; -const { +import { BrowserWindow, shell, ipcMain, dialog, Event } from 'electron'; +import windowStateKeeper from 'electron-window-state'; + +import { isOSX, linkIsInternal, getCssToInject, @@ -15,13 +12,19 @@ const { getAppIcon, nativeTabsSupported, getCounterValue, -} = helpers; - -const { onNewWindowHelper } = mainWindowHelpers; +} from '../helpers/helpers'; +import { initContextMenu } from './contextMenu'; +import { onNewWindowHelper } from './mainWindowHelpers'; +import { createMenu } from './menu'; const ZOOM_INTERVAL = 0.1; -function maybeHideWindow(window, event, fastQuit, tray) { +function hideWindow( + window: BrowserWindow, + event: Event, + fastQuit: boolean, + tray, +): void { if (isOSX() && !fastQuit) { // this is called when exiting from clicking the cross button on the window event.preventDefault(); @@ -33,66 +36,51 @@ function maybeHideWindow(window, event, fastQuit, tray) { // will close the window on other platforms } -function maybeInjectCss(browserWindow) { +function injectCss(browserWindow: BrowserWindow): void { if (!shouldInjectCss()) { return; } const cssToInject = getCssToInject(); - const injectCss = () => { - browserWindow.webContents.insertCSS(cssToInject); - }; - const onHeadersReceived = (details, callback) => { - injectCss(); - callback({ cancel: false, responseHeaders: details.responseHeaders }); - }; - - browserWindow.webContents.on('did-finish-load', () => { - // remove the injection of css the moment the page is loaded - browserWindow.webContents.session.webRequest.onHeadersReceived(null); - }); - - // on every page navigation inject the css browserWindow.webContents.on('did-navigate', () => { - // we have to inject the css in onHeadersReceived so they're early enough - // will run multiple times, so did-finish-load will remove this handler + // We must inject css early enough; so onHeadersReceived is a good place. + // Will run multiple times, see `did-finish-load` below that unsets this handler. browserWindow.webContents.session.webRequest.onHeadersReceived( { urls: [] }, // Pass an empty filter list; null will not match _any_ urls - onHeadersReceived, + (details, callback) => { + browserWindow.webContents.insertCSS(cssToInject); + callback({ cancel: false, responseHeaders: details.responseHeaders }); + }, ); }); } -function clearCache(browserWindow, targetUrl = null) { +async function clearCache(browserWindow: BrowserWindow): Promise { const { session } = browserWindow.webContents; - session.clearStorageData(() => { - session.clearCache(() => { - if (targetUrl) { - browserWindow.loadURL(targetUrl); - } - }); + await session.clearStorageData(); + await session.clearCache(); +} + +function setProxyRules(browserWindow: BrowserWindow, proxyRules): void { + browserWindow.webContents.session.setProxy({ + proxyRules, + pacScript: '', + proxyBypassRules: '', }); } -function setProxyRules(browserWindow, proxyRules) { - browserWindow.webContents.session.setProxy( - { - proxyRules, - }, - () => {}, - ); -} - /** - * - * @param {{}} inpOptions AppArgs from nativefier.json + * @param {{}} nativefierOptions AppArgs from nativefier.json * @param {function} onAppQuit * @param {function} setDockBadge - * @returns {electron.BrowserWindow} */ -function createMainWindow(inpOptions, onAppQuit, setDockBadge) { - const options = Object.assign({}, inpOptions); +export function createMainWindow( + nativefierOptions, + onAppQuit, + setDockBadge, +): BrowserWindow { + const options = { ...nativefierOptions }; const mainWindowState = windowStateKeeper({ defaultWidth: options.width || 1280, defaultHeight: options.height || 800, @@ -105,43 +93,37 @@ function createMainWindow(inpOptions, onAppQuit, setDockBadge) { webPreferences: { javascript: true, plugins: true, - // node globals causes problems with sites like messenger.com - nodeIntegration: false, + nodeIntegration: false, // `true` is *insecure*, and cause trouble with messenger.com webSecurity: !options.insecure, - preload: path.join(__dirname, 'static', 'preload.js'), + preload: path.join(__dirname, '..', 'preload.js'), zoomFactor: options.zoom, }, }; - const browserwindowOptions = Object.assign({}, options.browserwindowOptions); + const browserwindowOptions = { ...options.browserwindowOptions }; - const mainWindow = new BrowserWindow( - Object.assign( - { - frame: !options.hideWindowFrame, - width: mainWindowState.width, - height: mainWindowState.height, - minWidth: options.minWidth, - minHeight: options.minHeight, - maxWidth: options.maxWidth, - maxHeight: options.maxHeight, - x: options.x, - y: options.y, - autoHideMenuBar: !options.showMenuBar, - // after webpack path here should reference `resources/app/` - icon: getAppIcon(), - // set to undefined and not false because explicitly setting to false will disable full screen - fullscreen: options.fullScreen || undefined, - // Whether the window should always stay on top of other windows. Default is false. - alwaysOnTop: options.alwaysOnTop, - titleBarStyle: options.titleBarStyle, - show: options.tray !== 'start-in-tray', - backgroundColor: options.backgroundColor, - }, - DEFAULT_WINDOW_OPTIONS, - browserwindowOptions, - ), - ); + const mainWindow = new BrowserWindow({ + frame: !options.hideWindowFrame, + width: mainWindowState.width, + height: mainWindowState.height, + minWidth: options.minWidth, + minHeight: options.minHeight, + maxWidth: options.maxWidth, + maxHeight: options.maxHeight, + x: options.x, + 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, + // Whether the window should always stay on top of other windows. Default is false. + alwaysOnTop: options.alwaysOnTop, + titleBarStyle: options.titleBarStyle, + show: options.tray !== 'start-in-tray', + backgroundColor: options.backgroundColor, + ...DEFAULT_WINDOW_OPTIONS, + ...browserwindowOptions, + }); mainWindowState.manage(mainWindow); @@ -151,7 +133,7 @@ function createMainWindow(inpOptions, onAppQuit, setDockBadge) { options.maximize = undefined; try { fs.writeFileSync( - path.join(__dirname, '..', 'nativefier.json'), + path.join(__dirname, '../..', 'nativefier.json'), JSON.stringify(options), ); } catch (exception) { @@ -160,7 +142,7 @@ function createMainWindow(inpOptions, onAppQuit, setDockBadge) { } } - const withFocusedWindow = (block) => { + const withFocusedWindow = (block: (window: BrowserWindow) => void): void => { const focusedWindow = BrowserWindow.getFocusedWindow(); if (focusedWindow) { return block(focusedWindow); @@ -168,75 +150,85 @@ function createMainWindow(inpOptions, onAppQuit, setDockBadge) { return undefined; }; - const adjustWindowZoom = (window, adjustment) => { - window.webContents.getZoomFactor((zoomFactor) => { - window.webContents.setZoomFactor(zoomFactor + adjustment); - }); + const adjustWindowZoom = (window: BrowserWindow, adjustment): void => { + window.webContents.zoomFactor = window.webContents.zoomFactor + adjustment; }; - const onZoomIn = () => { - withFocusedWindow((focusedWindow) => + const onZoomIn = (): void => { + withFocusedWindow((focusedWindow: BrowserWindow) => adjustWindowZoom(focusedWindow, ZOOM_INTERVAL), ); }; - const onZoomOut = () => { - withFocusedWindow((focusedWindow) => + const onZoomOut = (): void => { + withFocusedWindow((focusedWindow: BrowserWindow) => adjustWindowZoom(focusedWindow, -ZOOM_INTERVAL), ); }; - const onZoomReset = () => { - withFocusedWindow((focusedWindow) => { - focusedWindow.webContents.setZoomFactor(options.zoom); + const onZoomReset = (): void => { + withFocusedWindow((focusedWindow: BrowserWindow) => { + focusedWindow.webContents.zoomFactor = options.zoom; }); }; - const clearAppData = () => { - dialog.showMessageBox( - mainWindow, - { - type: 'warning', - buttons: ['Yes', 'Cancel'], - defaultId: 1, - title: 'Clear cache confirmation', - message: - 'This will clear all data (cookies, local storage etc) from this app. Are you sure you wish to proceed?', - }, - (response) => { - if (response !== 0) { - return; - } - clearCache(mainWindow, options.targetUrl); - }, - ); + const clearAppData = async (): Promise => { + const response = await dialog.showMessageBox(mainWindow, { + type: 'warning', + buttons: ['Yes', 'Cancel'], + defaultId: 1, + title: 'Clear cache confirmation', + message: + 'This will clear all data (cookies, local storage etc) from this app. Are you sure you wish to proceed?', + }); + + if (response.response !== 0) { + return; + } + await clearCache(mainWindow); }; - const onGoBack = () => { + const onGoBack = (): void => { withFocusedWindow((focusedWindow) => { focusedWindow.webContents.goBack(); }); }; - const onGoForward = () => { + const onGoForward = (): void => { withFocusedWindow((focusedWindow) => { focusedWindow.webContents.goForward(); }); }; - const getCurrentUrl = () => + const getCurrentUrl = (): void => withFocusedWindow((focusedWindow) => focusedWindow.webContents.getURL()); - const onWillNavigate = (event, urlToGo) => { + const onWillNavigate = (event: Event, urlToGo: string): void => { if (!linkIsInternal(options.targetUrl, urlToGo, options.internalUrls)) { event.preventDefault(); shell.openExternal(urlToGo); } }; - let createNewWindow; + const createNewWindow: (url: string) => BrowserWindow = (url: string) => { + const window = new BrowserWindow(DEFAULT_WINDOW_OPTIONS); + if (options.userAgent) { + window.webContents.userAgent = options.userAgent; + } - const createNewTab = (url, foreground) => { + if (options.proxyRules) { + setProxyRules(window, options.proxyRules); + } + + injectCss(window); + sendParamsOnDidFinishLoad(window); + window.webContents.on('new-window', onNewWindow); + window.webContents.on('will-navigate', onWillNavigate); + window.loadURL(url); + return window; + }; + + const createNewTab = (url: string, foreground: boolean): BrowserWindow => { withFocusedWindow((focusedWindow) => { const newTab = createNewWindow(url); focusedWindow.addTabbedWindow(newTab); @@ -248,7 +240,7 @@ function createMainWindow(inpOptions, onAppQuit, setDockBadge) { return undefined; }; - const createAboutBlankWindow = () => { + const createAboutBlankWindow = (): BrowserWindow => { const window = createNewWindow('about:blank'); window.hide(); window.webContents.once('did-stop-loading', () => { @@ -261,11 +253,15 @@ function createMainWindow(inpOptions, onAppQuit, setDockBadge) { return window; }; - const onNewWindow = (event, urlToGo, _, disposition) => { - const preventDefault = (newGuest) => { + const onNewWindow = ( + event: Event & { newGuest?: any }, + urlToGo: string, + frameName: string, + disposition, + ): void => { + const preventDefault = (newGuest: any): void => { event.preventDefault(); if (newGuest) { - // eslint-disable-next-line no-param-reassign event.newGuest = newGuest; } }; @@ -275,37 +271,24 @@ function createMainWindow(inpOptions, onAppQuit, setDockBadge) { options.targetUrl, options.internalUrls, preventDefault, - shell.openExternal, + shell.openExternal.bind(this), createAboutBlankWindow, nativeTabsSupported, createNewTab, ); }; - const sendParamsOnDidFinishLoad = (window) => { + const sendParamsOnDidFinishLoad = (window: BrowserWindow): void => { window.webContents.on('did-finish-load', () => { + // In children windows too: Restore pinch-to-zoom, disabled by default in recent Electron. + // See https://github.com/jiahaog/nativefier/issues/379#issuecomment-598612128 + // and https://github.com/electron/electron/pull/12679 + window.webContents.setVisualZoomLevelLimits(1, 3); + window.webContents.send('params', JSON.stringify(options)); }); }; - createNewWindow = (url) => { - const window = new BrowserWindow(DEFAULT_WINDOW_OPTIONS); - if (options.userAgent) { - window.webContents.setUserAgent(options.userAgent); - } - - if (options.proxyRules) { - setProxyRules(window, options.proxyRules); - } - - maybeInjectCss(window); - sendParamsOnDidFinishLoad(window); - window.webContents.on('new-window', onNewWindow); - window.webContents.on('will-navigate', onWillNavigate); - window.loadURL(url); - return window; - }; - const menuOptions = { nativefierVersion: options.nativefierVersion, appQuit: onAppQuit, @@ -329,14 +312,14 @@ function createMainWindow(inpOptions, onAppQuit, setDockBadge) { } if (options.userAgent) { - mainWindow.webContents.setUserAgent(options.userAgent); + mainWindow.webContents.userAgent = options.userAgent; } if (options.proxyRules) { setProxyRules(mainWindow, options.proxyRules); } - maybeInjectCss(mainWindow); + injectCss(mainWindow); sendParamsOnDidFinishLoad(mainWindow); if (options.counter) { @@ -366,6 +349,15 @@ function createMainWindow(inpOptions, onAppQuit, setDockBadge) { mainWindow.webContents.on('new-window', onNewWindow); mainWindow.webContents.on('will-navigate', onWillNavigate); + mainWindow.webContents.on('did-finish-load', () => { + // Restore pinch-to-zoom, disabled by default in recent Electron. + // See https://github.com/jiahaog/nativefier/issues/379#issuecomment-598309817 + // and https://github.com/electron/electron/pull/12679 + mainWindow.webContents.setVisualZoomLevelLimits(1, 3); + + // Remove potential css injection code set in `did-navigate`) (see injectCss code) + mainWindow.webContents.session.webRequest.onHeadersReceived(null); + }); if (options.clearCache) { clearCache(mainWindow); @@ -373,6 +365,7 @@ function createMainWindow(inpOptions, onAppQuit, setDockBadge) { mainWindow.loadURL(options.targetUrl); + // @ts-ignore mainWindow.on('new-tab', () => createNewTab(options.targetUrl, true)); mainWindow.on('close', (event) => { @@ -383,10 +376,10 @@ function createMainWindow(inpOptions, onAppQuit, setDockBadge) { mainWindow.setFullScreen(false); mainWindow.once( 'leave-full-screen', - maybeHideWindow.bind(this, mainWindow, event, options.fastQuit), + hideWindow.bind(this, mainWindow, event, options.fastQuit), ); } - maybeHideWindow(mainWindow, event, options.fastQuit, options.tray); + hideWindow(mainWindow, event, options.fastQuit, options.tray); if (options.clearCache) { clearCache(mainWindow); @@ -395,5 +388,3 @@ function createMainWindow(inpOptions, onAppQuit, setDockBadge) { return mainWindow; } - -export default createMainWindow; diff --git a/app/src/components/mainWindow/mainWindowHelpers.test.js b/app/src/components/mainWindowHelpers.test.ts similarity index 97% rename from app/src/components/mainWindow/mainWindowHelpers.test.js rename to app/src/components/mainWindowHelpers.test.ts index d1a3fe3..eb69702 100644 --- a/app/src/components/mainWindow/mainWindowHelpers.test.js +++ b/app/src/components/mainWindowHelpers.test.ts @@ -1,6 +1,4 @@ -import mainWindowHelpers from './mainWindowHelpers'; - -const { onNewWindowHelper } = mainWindowHelpers; +import { onNewWindowHelper } from './mainWindowHelpers'; const originalUrl = 'https://medium.com/'; const internalUrl = 'https://medium.com/topics/technology'; diff --git a/app/src/components/mainWindow/mainWindowHelpers.js b/app/src/components/mainWindowHelpers.ts similarity index 79% rename from app/src/components/mainWindow/mainWindowHelpers.js rename to app/src/components/mainWindowHelpers.ts index dee75f4..ddabd81 100644 --- a/app/src/components/mainWindow/mainWindowHelpers.js +++ b/app/src/components/mainWindowHelpers.ts @@ -1,18 +1,16 @@ -import helpers from '../../helpers/helpers'; +import { linkIsInternal } from '../helpers/helpers'; -const { linkIsInternal } = helpers; - -function onNewWindowHelper( - urlToGo, +export function onNewWindowHelper( + urlToGo: string, disposition, - targetUrl, + targetUrl: string, internalUrls, preventDefault, openExternal, createAboutBlankWindow, nativeTabsSupported, createNewTab, -) { +): void { if (!linkIsInternal(targetUrl, urlToGo, internalUrls)) { openExternal(urlToGo); preventDefault(); @@ -29,5 +27,3 @@ function onNewWindowHelper( } } } - -export default { onNewWindowHelper }; diff --git a/app/src/components/menu/menu.js b/app/src/components/menu.ts similarity index 76% rename from app/src/components/menu/menu.js rename to app/src/components/menu.ts index 0a61551..37a9519 100644 --- a/app/src/components/menu/menu.js +++ b/app/src/components/menu.ts @@ -1,19 +1,6 @@ -import { Menu, shell, clipboard } from 'electron'; +import { Menu, clipboard, globalShortcut, shell } from 'electron'; -/** - * @param nativefierVersion - * @param appQuit - * @param zoomIn - * @param zoomOut - * @param zoomReset - * @param zoomBuildTimeValue - * @param goBack - * @param goForward - * @param getCurrentUrl - * @param clearAppData - * @param disableDevTools - */ -function createMenu({ +export function createMenu({ nativefierVersion, appQuit, zoomIn, @@ -25,13 +12,13 @@ function createMenu({ getCurrentUrl, clearAppData, disableDevTools, -}) { +}): void { const zoomResetLabel = zoomBuildTimeValue === 1.0 ? 'Reset Zoom' : `Reset Zoom (to ${zoomBuildTimeValue * 100}%, set at build time)`; - const template = [ + const template: any[] = [ { label: '&Edit', submenu: [ @@ -83,9 +70,7 @@ function createMenu({ }, { label: 'Clear App Data', - click: () => { - clearAppData(); - }, + click: clearAppData, }, ], }, @@ -94,17 +79,19 @@ function createMenu({ submenu: [ { label: 'Back', - accelerator: 'CmdOrCtrl+[', - click: () => { - goBack(); - }, + accelerator: (() => { + globalShortcut.register('Alt+Left', goBack); + return 'CmdOrCtrl+['; + })(), + click: goBack, }, { label: 'Forward', - accelerator: 'CmdOrCtrl+]', - click: () => { - goForward(); - }, + accelerator: (() => { + globalShortcut.register('Alt+Right', goForward); + return 'CmdOrCtrl+]'; + })(), + click: goForward, }, { label: 'Reload', @@ -122,7 +109,7 @@ function createMenu({ label: 'Toggle Full Screen', accelerator: (() => { if (process.platform === 'darwin') { - return 'Ctrl+Command+F'; + return 'Ctrl+Cmd+F'; } return 'F11'; })(), @@ -135,44 +122,32 @@ function createMenu({ { label: 'Zoom In', accelerator: (() => { - if (process.platform === 'darwin') { - return 'Command+='; - } - return 'Ctrl+='; + globalShortcut.register('CmdOrCtrl+numadd', zoomIn); + return 'CmdOrCtrl+='; })(), - click: () => { - zoomIn(); - }, + click: zoomIn, }, { label: 'Zoom Out', accelerator: (() => { - if (process.platform === 'darwin') { - return 'Command+-'; - } - return 'Ctrl+-'; + globalShortcut.register('CmdOrCtrl+numsub', zoomOut); + return 'CmdOrCtrl+-'; })(), - click: () => { - zoomOut(); - }, + click: zoomOut, }, { label: zoomResetLabel, accelerator: (() => { - if (process.platform === 'darwin') { - return 'Command+0'; - } - return 'Ctrl+0'; + globalShortcut.register('CmdOrCtrl+num0', zoomReset); + return 'CmdOrCtrl+0'; })(), - click: () => { - zoomReset(); - }, + click: zoomReset, }, { label: 'Toggle Developer Tools', accelerator: (() => { if (process.platform === 'darwin') { - return 'Alt+Command+I'; + return 'Alt+Cmd+I'; } return 'Ctrl+Shift+I'; })(), @@ -240,12 +215,12 @@ function createMenu({ }, { label: 'Hide App', - accelerator: 'Command+H', + accelerator: 'Cmd+H', role: 'hide', }, { label: 'Hide Others', - accelerator: 'Command+Shift+H', + accelerator: 'Cmd+Shift+H', role: 'hideothers', }, { @@ -257,10 +232,8 @@ function createMenu({ }, { label: 'Quit', - accelerator: 'Command+Q', - click: () => { - appQuit(); - }, + accelerator: 'Cmd+Q', + click: appQuit, }, ], }); @@ -278,5 +251,3 @@ function createMenu({ const menu = Menu.buildFromTemplate(template); Menu.setApplicationMenu(menu); } - -export default createMenu; diff --git a/app/src/components/trayIcon/trayIcon.js b/app/src/components/trayIcon.ts similarity index 71% rename from app/src/components/trayIcon/trayIcon.js rename to app/src/components/trayIcon.ts index 79aff5e..778e3c0 100644 --- a/app/src/components/trayIcon/trayIcon.js +++ b/app/src/components/trayIcon.ts @@ -1,17 +1,12 @@ -import helpers from '../../helpers/helpers'; +import { app, Tray, Menu, ipcMain, nativeImage, BrowserWindow } from 'electron'; -const { app, Tray, Menu, ipcMain, nativeImage } = require('electron'); +import { getAppIcon, getCounterValue } from '../helpers/helpers'; -const { getAppIcon, getCounterValue } = helpers; - -/** - * - * @param {{}} inpOptions AppArgs from nativefier.json - * @param {electron.BrowserWindow} mainWindow MainWindow created from main.js - * @returns {electron.Tray} - */ -function createTrayIcon(inpOptions, mainWindow) { - const options = Object.assign({}, inpOptions); +export function createTrayIcon( + nativefierOptions, + mainWindow: BrowserWindow, +): Tray { + const options = { ...nativefierOptions }; if (options.tray) { const iconPath = getAppIcon(); @@ -33,7 +28,7 @@ function createTrayIcon(inpOptions, mainWindow) { }, { label: 'Quit', - click: app.exit, + click: app.exit.bind(this), }, ]); @@ -69,5 +64,3 @@ function createTrayIcon(inpOptions, mainWindow) { return null; } - -export default createTrayIcon; diff --git a/app/src/helpers/helpers.js b/app/src/helpers/helpers.js deleted file mode 100644 index 259b20f..0000000 --- a/app/src/helpers/helpers.js +++ /dev/null @@ -1,87 +0,0 @@ -import wurl from 'wurl'; -import os from 'os'; -import fs from 'fs'; -import path from 'path'; - -const INJECT_CSS_PATH = path.join(__dirname, '..', 'inject/inject.css'); -const log = require('loglevel'); - -function isOSX() { - return os.platform() === 'darwin'; -} - -function isLinux() { - return os.platform() === 'linux'; -} - -function isWindows() { - return os.platform() === 'win32'; -} - -function linkIsInternal(currentUrl, newUrl, internalUrlRegex) { - if (newUrl === 'about:blank') { - return true; - } - - if (internalUrlRegex) { - const regex = RegExp(internalUrlRegex); - return regex.test(newUrl); - } - - const currentDomain = wurl('domain', currentUrl); - const newDomain = wurl('domain', newUrl); - return currentDomain === newDomain; -} - -function shouldInjectCss() { - try { - fs.accessSync(INJECT_CSS_PATH, fs.F_OK); - return true; - } catch (e) { - return false; - } -} - -function getCssToInject() { - return fs.readFileSync(INJECT_CSS_PATH).toString(); -} - -/** - * Helper method to print debug messages from the main process in the browser window - * @param {BrowserWindow} browserWindow - * @param message - */ -function debugLog(browserWindow, message) { - // need the timeout as it takes time for the preload javascript to be loaded in the window - setTimeout(() => { - browserWindow.webContents.send('debug', message); - }, 3000); - log.info(message); -} - -function getAppIcon() { - return path.join(__dirname, '../', `/icon.${isWindows() ? 'ico' : 'png'}`); -} - -function nativeTabsSupported() { - return isOSX(); -} - -function getCounterValue(title) { - const itemCountRegex = /[([{]([\d.,]*)\+?[}\])]/; - const match = itemCountRegex.exec(title); - return match ? match[1] : undefined; -} - -export default { - isOSX, - isLinux, - isWindows, - linkIsInternal, - getCssToInject, - debugLog, - shouldInjectCss, - getAppIcon, - nativeTabsSupported, - getCounterValue, -}; diff --git a/app/src/helpers/helpers.test.js b/app/src/helpers/helpers.test.ts similarity index 94% rename from app/src/helpers/helpers.test.js rename to app/src/helpers/helpers.test.ts index 3164c4d..c7792d9 100644 --- a/app/src/helpers/helpers.test.js +++ b/app/src/helpers/helpers.test.ts @@ -1,6 +1,4 @@ -import helpers from './helpers'; - -const { linkIsInternal, getCounterValue } = helpers; +import { linkIsInternal, getCounterValue } from './helpers'; const internalUrl = 'https://medium.com/'; const internalUrlSubPath = 'topic/technology'; diff --git a/app/src/helpers/helpers.ts b/app/src/helpers/helpers.ts new file mode 100644 index 0000000..d7a59b9 --- /dev/null +++ b/app/src/helpers/helpers.ts @@ -0,0 +1,78 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +import { BrowserWindow } from 'electron'; +import * as log from 'loglevel'; +import wurl from 'wurl'; + +const INJECT_CSS_PATH = path.join(__dirname, '../..', 'inject/inject.css'); + +export function isOSX(): boolean { + return os.platform() === 'darwin'; +} + +export function isLinux(): boolean { + return os.platform() === 'linux'; +} + +export function isWindows(): boolean { + return os.platform() === 'win32'; +} + +export function linkIsInternal( + currentUrl: string, + newUrl: string, + internalUrlRegex: string | RegExp, +): boolean { + if (newUrl === 'about:blank') { + return true; + } + + if (internalUrlRegex) { + const regex = RegExp(internalUrlRegex); + return regex.test(newUrl); + } + + const currentDomain = wurl('domain', currentUrl); + const newDomain = wurl('domain', newUrl); + return currentDomain === newDomain; +} + +export function shouldInjectCss(): boolean { + try { + fs.accessSync(INJECT_CSS_PATH); + return true; + } catch (e) { + return false; + } +} + +export function getCssToInject(): string { + return fs.readFileSync(INJECT_CSS_PATH).toString(); +} + +/** + * Helper to print debug messages from the main process in the browser window + */ +export function debugLog(browserWindow: BrowserWindow, message: string): void { + // Need a delay, as it takes time for the preloaded js to be loaded by the window + setTimeout(() => { + browserWindow.webContents.send('debug', message); + }, 3000); + log.info(message); +} + +export function getAppIcon(): string { + return path.join(__dirname, `../../icon.${isWindows() ? 'ico' : 'png'}`); +} + +export function nativeTabsSupported(): boolean { + return isOSX(); +} + +export function getCounterValue(title: string): string { + const itemCountRegex = /[([{]([\d.,]*)\+?[}\])]/; + const match = itemCountRegex.exec(title); + return match ? match[1] : undefined; +} diff --git a/app/src/helpers/inferFlash.js b/app/src/helpers/inferFlash.ts similarity index 56% rename from app/src/helpers/inferFlash.js rename to app/src/helpers/inferFlash.ts index e674754..091d8e2 100644 --- a/app/src/helpers/inferFlash.js +++ b/app/src/helpers/inferFlash.ts @@ -1,31 +1,32 @@ -import fs from 'fs'; -import path from 'path'; -import helpers from './helpers'; +import * as fs from 'fs'; +import * as path from 'path'; + +import * as log from 'loglevel'; + +import { isOSX, isWindows, isLinux } from './helpers'; -const { isOSX, isWindows, isLinux } = helpers; -const log = require('loglevel'); /** - * Synchronously find a file or directory - * @param {RegExp} pattern regex - * @param {string} base path - * @param {boolean} [findDir] if true, search results will be limited to only directories - * @returns {Array} + * Find a file or directory */ -function findSync(pattern, basePath, findDir) { - const matches = []; +function findSync( + pattern: RegExp, + basePath: string, + limitSearchToDirectories = false, +): string[] { + const matches: string[] = []; (function findSyncRecurse(base) { - let children; + let children: string[]; try { children = fs.readdirSync(base); - } catch (exception) { - if (exception.code === 'ENOENT') { + } catch (err) { + if (err.code === 'ENOENT') { return; } - throw exception; + throw err; } - children.forEach((child) => { + for (const child of children) { const childPath = path.join(base, child); const childIsDirectory = fs.lstatSync(childPath).isDirectory(); const patternMatches = pattern.test(childPath); @@ -38,7 +39,7 @@ function findSync(pattern, basePath, findDir) { return; } - if (!findDir) { + if (!limitSearchToDirectories) { matches.push(childPath); return; } @@ -46,23 +47,23 @@ function findSync(pattern, basePath, findDir) { if (childIsDirectory) { matches.push(childPath); } - }); + } })(basePath); return matches; } -function linuxMatch() { +function findFlashOnLinux() { return findSync(/libpepflashplayer\.so/, '/opt/google/chrome')[0]; } -function windowsMatch() { +function findFlashOnWindows() { return findSync( /pepflashplayer\.dll/, 'C:\\Program Files (x86)\\Google\\Chrome', )[0]; } -function darwinMatch() { +function findFlashOnMac() { return findSync( /PepperFlashPlayer.plugin/, '/Applications/Google Chrome.app/', @@ -70,20 +71,19 @@ function darwinMatch() { )[0]; } -function inferFlash() { +export function inferFlashPath() { if (isOSX()) { - return darwinMatch(); + return findFlashOnMac(); } if (isWindows()) { - return windowsMatch(); + return findFlashOnWindows(); } if (isLinux()) { - return linuxMatch(); + return findFlashOnLinux(); } log.warn('Unable to determine OS to infer flash player'); return null; } -export default inferFlash; diff --git a/app/src/main.js b/app/src/main.ts similarity index 79% rename from app/src/main.js rename to app/src/main.ts index 343c9c2..28dead4 100644 --- a/app/src/main.js +++ b/app/src/main.ts @@ -1,29 +1,26 @@ import 'source-map-support/register'; + import fs from 'fs'; import path from 'path'; + import { app, crashReporter, globalShortcut } from 'electron'; import electronDownload from 'electron-dl'; -import createLoginWindow from './components/login/loginWindow'; -import createMainWindow from './components/mainWindow/mainWindow'; -import createTrayIcon from './components/trayIcon/trayIcon'; -import helpers from './helpers/helpers'; -import inferFlash from './helpers/inferFlash'; +import { createLoginWindow } from './components/loginWindow'; +import { createMainWindow } from './components/mainWindow'; +import { createTrayIcon } from './components/trayIcon'; +import { isOSX } from './helpers/helpers'; +import { inferFlashPath } from './helpers/inferFlash'; -const electronSquirrelStartup = require('electron-squirrel-startup'); - -// Entrypoint for electron-squirrel-startup. -// See https://github.com/jiahaog/nativefier/pull/744 for sample use case -if (electronSquirrelStartup) { +// Entrypoint for Squirrel, a windows update framework. See https://github.com/jiahaog/nativefier/pull/744 +if (require('electron-squirrel-startup')) { app.exit(); } -const { isOSX } = helpers; - const APP_ARGS_FILE_PATH = path.join(__dirname, '..', 'nativefier.json'); const appArgs = JSON.parse(fs.readFileSync(APP_ARGS_FILE_PATH, 'utf8')); -const fileDownloadOptions = Object.assign({}, appArgs.fileDownloadOptions); +const fileDownloadOptions = { ...appArgs.fileDownloadOptions }; electronDownload(fileDownloadOptions); if (appArgs.processEnvs) { @@ -38,7 +35,7 @@ let mainWindow; if (typeof appArgs.flashPluginDir === 'string') { app.commandLine.appendSwitch('ppapi-flash-path', appArgs.flashPluginDir); } else if (appArgs.flashPluginDir) { - const flashPath = inferFlash(); + const flashPath = inferFlashPath(); app.commandLine.appendSwitch('ppapi-flash-path', flashPath); } @@ -76,18 +73,15 @@ if (appArgs.basicAuthPassword) { ); } -// do nothing for setDockBadge if not OSX -let setDockBadge = () => {}; - -if (isOSX()) { - let currentBadgeCount = 0; - - setDockBadge = (count, bounce = false) => { - app.dock.setBadge(count); - if (bounce && count > currentBadgeCount) app.dock.bounce(); - currentBadgeCount = count; - }; -} +const isRunningMacos = isOSX(); +let currentBadgeCount = 0; +const setDockBadge = isRunningMacos + ? (count: number, bounce = false) => { + app.dock.setBadge(count.toString()); + if (bounce && count > currentBadgeCount) app.dock.bounce(); + currentBadgeCount = count; + } + : () => undefined; app.on('window-all-closed', () => { if (!isOSX() || appArgs.fastQuit) { @@ -147,7 +141,7 @@ if (shouldQuit) { }); app.on('ready', () => { - mainWindow = createMainWindow(appArgs, app.quit, setDockBadge); + mainWindow = createMainWindow(appArgs, app.quit.bind(this), setDockBadge); createTrayIcon(appArgs, mainWindow); // Register global shortcuts diff --git a/app/src/static/preload.js b/app/src/preload.ts similarity index 62% rename from app/src/static/preload.js rename to app/src/preload.ts index c409a4a..3b12be9 100644 --- a/app/src/static/preload.js +++ b/app/src/preload.ts @@ -1,25 +1,19 @@ /** - Preload file that will be executed in the renderer process - */ - -/** - * Note: This needs to be attached prior to the imports, as the they will delay - * the attachment till after the event has been raised. + * Preload file that will be executed in the renderer process. + * Note: This needs to be attached **prior to imports**, as imports + * would delay the attachment till after the event has been raised. */ document.addEventListener('DOMContentLoaded', () => { - // Due to the early attachment, this triggers a linter error - // because it's not yet been defined. - // eslint-disable-next-line no-use-before-define - injectScripts(); + injectScripts(); // eslint-disable-line @typescript-eslint/no-use-before-define }); -// Disable imports being first due to the above event attachment -import { ipcRenderer } from 'electron'; // eslint-disable-line import/first -import path from 'path'; // eslint-disable-line import/first -import fs from 'fs'; // eslint-disable-line import/first +import * as fs from 'fs'; +import * as path from 'path'; -const INJECT_JS_PATH = path.join(__dirname, '../../', 'inject/inject.js'); -const log = require('loglevel'); +import { ipcRenderer } from 'electron'; +import * as log from 'loglevel'; + +const INJECT_JS_PATH = path.join(__dirname, '..', 'inject/inject.js'); /** * Patches window.Notification to: * - set a callback on a new Notification @@ -40,6 +34,7 @@ function setNotificationCallback(createCallback, clickCallback) { get: () => OldNotify.permission, }); + // @ts-ignore window.Notification = newNotify; } @@ -49,7 +44,6 @@ function injectScripts() { return; } // Dynamically require scripts - // eslint-disable-next-line global-require, import/no-dynamic-require require(INJECT_JS_PATH); } @@ -68,6 +62,5 @@ ipcRenderer.on('params', (event, message) => { }); ipcRenderer.on('debug', (event, message) => { - // eslint-disable-next-line no-console log.info('debug:', message); }); diff --git a/app/src/static/login/login.css b/app/src/static/login.css similarity index 100% rename from app/src/static/login/login.css rename to app/src/static/login.css diff --git a/app/src/static/login/login.html b/app/src/static/login.html similarity index 100% rename from app/src/static/login/login.html rename to app/src/static/login.html diff --git a/app/src/static/login.js b/app/src/static/login.js new file mode 100644 index 0000000..65617dc --- /dev/null +++ b/app/src/static/login.js @@ -0,0 +1,10 @@ +const { ipcRenderer } = require('electron'); + +document.getElementById('login-form').addEventListener('submit', (event) => { + event.preventDefault(); + const usernameInput = document.getElementById('username-input'); + const username = usernameInput.nodeValue || usernameInput.value; + const passwordInput = document.getElementById('password-input'); + const password = passwordInput.nodeValue || passwordInput.value; + ipcRenderer.send('login-message', [username, password]); +}); diff --git a/app/src/static/login/login.js b/app/src/static/login/login.js deleted file mode 100644 index 2e35dfc..0000000 --- a/app/src/static/login/login.js +++ /dev/null @@ -1,12 +0,0 @@ -import electron from 'electron'; - -const { ipcRenderer } = electron; - -const form = document.getElementById('login-form'); - -form.addEventListener('submit', (event) => { - event.preventDefault(); - const username = document.getElementById('username-input').value; - const password = document.getElementById('password-input').value; - ipcRenderer.send('login-message', [username, password]); -}); diff --git a/app/tsconfig.json b/app/tsconfig.json new file mode 100644 index 0000000..516a9c1 --- /dev/null +++ b/app/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "allowJs": true, + "declaration": false, + "esModuleInterop": true, + "incremental": true, + "module": "commonjs", + "moduleResolution": "node", + "outDir": "./lib", + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "target": "es2017", + "lib": ["es2017", "dom"] + }, + "include": [ + "./src/**/*" + ] +} diff --git a/docs/development.md b/docs/development.md index 7992090..c5b2838 100644 --- a/docs/development.md +++ b/docs/development.md @@ -1,6 +1,6 @@ -# Development +# Development Guide -## Environment Setup +## Setup First, clone the project @@ -9,49 +9,59 @@ git clone https://github.com/jiahaog/nativefier.git cd nativefier ``` -Install dependencies and build: +Install dependencies: ```bash -# macOS and Linux -npm run dev-up - -# Windows -npm run dev-up-win +npm install ``` -If dependencies are installed and you just want to re-build, +Build nativefier: ```bash npm run build ``` -You can set up a symbolic link so that running `nativefier` invokes your development version including your changes: +Set up a symbolic link so that running `nativefier` calls your dev version with your changes: ```bash npm link +which nativefier +# -> Should return a path, e.g. /home/youruser/.node_modules/lib/node_modules/nativefier +# If not, be sure your `npm_config_prefix` env var is set and in your `PATH` ``` -After doing so (and not forgetting to build with `npm run build`), you can run Nativefier with your test parameters: +After doing so, you can run Nativefier with your test parameters: ```bash -nativefier <--your-awesome-new-flag> +nativefier --your-awesome-new-flag 'https://your-test-site.com' ``` -Or you can automatically watch the files for changes with: - +Then run your nativefier app *through the command line too* (to see logs & errors): ```bash -npm run watch +# Under Linux +./your-test-site-linux-x64/your-test-site + +# Under Windows +your-test-site-win32-x64/your-test-site.exe + +# Under macOS +open -a YourTestSite.app ``` +## Linting & formatting + +Nativefier uses [Prettier](https://prettier.io/), which will shout at you for +not formatting code exactly like it expects. This guarantees a homogenous style, +but is painful to do manually. Do yourself a favor and install a +[Prettier plugin for your editor](https://prettier.io/docs/en/editors.html). + ## Tests -```bash -# To run all tests (unit, end-to-end), -npm test - -# To run only unit tests, -npm run jest - -# To run only end-to-end tests, -npm run e2e -``` +- To run all tests, `npm t` +- To run only unit tests, `npm run test:unit` +- To run only integration tests, `npm run test:integration` +- Logging is suppressed by default in tests, to avoid polluting Jest output. + To get debug logs, `npm run test:withlog` or set the `LOGLEVEL` env. var. +- For a good live experience, open two terminal panes/tabs running code/tests watchers: + 2. Run a TSC watcher: `npm run build:watch` + 3. Run a Jest unit tests watcher: `npm run test:watch` diff --git a/docs/dock.png b/docs/dock.png new file mode 100644 index 0000000..a81ca6d Binary files /dev/null and b/docs/dock.png differ diff --git a/scripts/changelog b/docs/generate-changelog similarity index 94% rename from scripts/changelog rename to docs/generate-changelog index 8b170d8..f1d2610 100755 --- a/scripts/changelog +++ b/docs/generate-changelog @@ -43,9 +43,9 @@ mv package.json.tmp package.json # Unset the editor so that git changelog does not open a editor EDITOR=: -git changelog docs/changelog.md --tag "$VERSION" +git changelog CHANGELOG.md --tag "$VERSION" # Commit these changes -git add docs/changelog.md +git add CHANGELOG.md git add package.json git commit -m "Update changelog for \`v$VERSION\`" diff --git a/docs/manual-test b/docs/manual-test new file mode 100755 index 0000000..ef3f1a4 --- /dev/null +++ b/docs/manual-test @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# Manual test to validate some hard-to-programmatically-test features work. + +set -eo pipefail + +missingDeps=false +if ! command -v mktemp > /dev/null; then echo "Missing mktemp"; missingDeps=true; fi +if ! command -v uname > /dev/null; then echo "Missing uname"; missingDeps=true; fi +if ! command -v node > /dev/null; then echo "Missing node"; missingDeps=true; fi +if [ "$missingDeps" = true ]; then exit 1; fi + + +script_dir="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" +nativefier_dir="$script_dir/.." +pushd "$nativefier_dir" + +printf "\n***** Creating test dirs & resources *****\n" +tmp_dir=$(mktemp -d -t nativefier-manual-test-XXXXX) +resources_dir="$tmp_dir/resources" +mkdir "$resources_dir" +injected_css="$resources_dir/inject.css" +injected_js="$resources_dir/inject.js" +echo '* { background-color: blue; }' > "$injected_css" +echo 'alert("hello world from inject");' > "$injected_js" + +printf "\n***** Building test app *****\n" +node ./lib/cli.js 'https://npmjs.com/' \ + --inject "$injected_css" \ + --inject "$injected_js" \ + --name "app" \ + "$tmp_dir" + +printf "\n***** Test checklist ***** +- Injected js: should show an alert saying hello +- Injected css: should make npmjs all blue +- Internal links open internally +- External links open in browser +- Keyboard shortcuts: {back, forward, zoom in/out/zero} work +- Console: no Electron runtime deprecation warnings/error logged +" + +printf "\n***** Running app *****\n" +if [ "$(uname -s)" = "Darwin" ]; then + open -a 'app-darwin-x64/app.app' +else + "$tmp_dir/app-linux-x64/app" +fi + +printf "\nDid everything work as expected? [yN] " +read -r response +if [ "$response" != 'y' ]; then + echo "Back to fixing" + exit 1 +else + echo "Yayyyyyyyyyyy" +fi + +if [ -n "$tmp_dir" ]; then + printf "\n***** Deleting test dir %s *****\n" "$tmp_dir" + rm -rf "$tmp_dir" +fi diff --git a/docs/release.md b/docs/release.md index fb372b5..be4ae49 100644 --- a/docs/release.md +++ b/docs/release.md @@ -1,17 +1,28 @@ # Release -Releases are automatically deployed to NPM on Travis, when they are tagged. However, we have to make sure that the version in the `package.json`, and the changelog is updated. +Releases are automatically deployed to npm from Travis, when they are tagged. +However, we have to make sure that the version in the `package.json`, +and the changelog is updated. -## Dependencies -- [Git Extras](https://github.com/tj/git-extras/blob/master/Installation.md) -- [jq](https://stedolan.github.io/jq/download/) +## Tests -## How to Release `$VERSION` +Before anything, run a little manual smoke test of some of our +hard-to-programatically-test features: + +```bash +npm run test:manual +``` + +## How to release + +With [Git Extras](https://github.com/tj/git-extras/blob/master/Installation.md) +and [jq](https://stedolan.github.io/jq/download/) installed. While on `master`, with no uncommitted changes, ```bash npm run changelog -- $VERSION +# For example, npm run changelog -- 7.7.1 ``` This command does 3 things: @@ -22,15 +33,17 @@ This command does 3 things: Now we may want to cleanup the changelog: ```bash -vim docs/changelog.md - +vim CHANGELOG.md git commit --amend ``` Once we are satisfied, + ```bash git push origin master ``` -On [GitHub Releases](https://github.com/jiahaog/nativefier/releases), draft and publish a new release with title `Nativefier vX.X.X`. +On [GitHub Releases](https://github.com/jiahaog/nativefier/releases), +draft and publish a new release with title `Nativefier vX.X.X` (yes, with a `v`). +The new version will be visible on npm within a few minutes/hours. diff --git a/screenshots/walkthrough.gif b/docs/walkthrough.gif similarity index 100% rename from screenshots/walkthrough.gif rename to docs/walkthrough.gif diff --git a/e2e/index.test.js b/e2e/index.test.js deleted file mode 100644 index d37555b..0000000 --- a/e2e/index.test.js +++ /dev/null @@ -1,83 +0,0 @@ -import tmp from 'tmp'; -import fs from 'fs'; -import path from 'path'; -import async from 'async'; - -import nativefier from '../src'; - -const PLATFORMS = ['darwin', 'linux']; -tmp.setGracefulCleanup(); - -function checkApp(appPath, inputOptions, callback) { - try { - let relPathToConfig; - - switch (inputOptions.platform) { - case 'darwin': - relPathToConfig = path.join( - 'google-test-app.app', - 'Contents/Resources/app', - ); - break; - case 'linux': - relPathToConfig = 'resources/app'; - break; - case 'win32': - relPathToConfig = 'resources/app'; - break; - default: - throw new Error('Unknown app platform'); - } - - const nativefierConfigPath = path.join( - appPath, - relPathToConfig, - 'nativefier.json', - ); - const nativefierConfig = JSON.parse(fs.readFileSync(nativefierConfigPath)); - - expect(inputOptions.targetUrl).toBe(nativefierConfig.targetUrl); - // app name is not consistent for linux - // assert.strictEqual(inputOptions.appName, nativefierConfig.name, - // 'Packaged app must have the same name as the input parameters'); - callback(); - } catch (exception) { - callback(exception); - } -} - -describe('Nativefier Module', () => { - jest.setTimeout(240000); - test('Can build an app from a target url', (done) => { - async.eachSeries( - PLATFORMS, - (platform, callback) => { - const tmpObj = tmp.dirSync({ unsafeCleanup: true }); - - const tmpPath = tmpObj.name; - const options = { - name: 'google-test-app', - targetUrl: 'http://google.com', - out: tmpPath, - overwrite: true, - platform: null, - }; - - options.platform = platform; - nativefier(options, (error, appPath) => { - if (error) { - callback(error); - return; - } - - checkApp(appPath, options, (err) => { - callback(err); - }); - }); - }, - (error) => { - done(error); - }, - ); - }); -}); diff --git a/gulp/build.js b/gulp/build.js deleted file mode 100644 index 74bad1f..0000000 --- a/gulp/build.js +++ /dev/null @@ -1,18 +0,0 @@ -import gulp from 'gulp'; -import del from 'del'; -import runSequence from 'run-sequence'; -import PATHS from './helpers/src-paths'; - -gulp.task('build', (callback) => { - runSequence('clean', ['build-cli', 'build-app'], callback); -}); - -gulp.task('clean', (callback) => { - del(PATHS.CLI_DEST).then(() => { - del(PATHS.APP_DEST).then(() => { - del(PATHS.TEST_DEST).then(() => { - callback(); - }); - }); - }); -}); diff --git a/gulp/build/build-app.js b/gulp/build/build-app.js deleted file mode 100644 index aaa5dc9..0000000 --- a/gulp/build/build-app.js +++ /dev/null @@ -1,12 +0,0 @@ -import gulp from 'gulp'; -import webpack from 'webpack-stream'; -import PATHS from '../helpers/src-paths'; - -const webpackConfig = require('./../../webpack.config.js'); - -gulp.task('build-app', ['build-static'], () => - gulp - .src(PATHS.APP_MAIN_JS) - .pipe(webpack(webpackConfig)) - .pipe(gulp.dest(PATHS.APP_DEST)), -); diff --git a/gulp/build/build-cli.js b/gulp/build/build-cli.js deleted file mode 100644 index 991c7f0..0000000 --- a/gulp/build/build-cli.js +++ /dev/null @@ -1,9 +0,0 @@ -import gulp from 'gulp'; -import PATHS from '../helpers/src-paths'; -import helpers from '../helpers/gulp-helpers'; - -const { buildES6 } = helpers; - -gulp.task('build-cli', (done) => - buildES6(PATHS.CLI_SRC_JS, PATHS.CLI_DEST, done), -); diff --git a/gulp/build/build-static.js b/gulp/build/build-static.js deleted file mode 100644 index 7d4cd3f..0000000 --- a/gulp/build/build-static.js +++ /dev/null @@ -1,17 +0,0 @@ -import gulp from 'gulp'; -import PATHS from '../helpers/src-paths'; -import helpers from '../helpers/gulp-helpers'; - -const { buildES6 } = helpers; - -gulp.task('build-static-not-js', () => - gulp - .src([PATHS.APP_STATIC_ALL, '!**/*.js']) - .pipe(gulp.dest(PATHS.APP_STATIC_DEST)), -); - -gulp.task('build-static-js', (done) => - buildES6(PATHS.APP_STATIC_JS, PATHS.APP_STATIC_DEST, done), -); - -gulp.task('build-static', ['build-static-js', 'build-static-not-js']); diff --git a/gulp/helpers/gulp-helpers.js b/gulp/helpers/gulp-helpers.js deleted file mode 100644 index fa3109d..0000000 --- a/gulp/helpers/gulp-helpers.js +++ /dev/null @@ -1,29 +0,0 @@ -import gulp from 'gulp'; -import shellJs from 'shelljs'; -import sourcemaps from 'gulp-sourcemaps'; -import babel from 'gulp-babel'; - -function shellExec(cmd, silent, callback) { - shellJs.exec(cmd, { silent }, (code, stdout, stderr) => { - if (code) { - callback(JSON.stringify({ code, stdout, stderr })); - return; - } - callback(); - }); -} - -function buildES6(src, dest, callback) { - return gulp - .src(src) - .pipe(sourcemaps.init()) - .pipe(babel()) - .on('error', callback) - .pipe(sourcemaps.write('.')) - .pipe(gulp.dest(dest)); -} - -export default { - shellExec, - buildES6, -}; diff --git a/gulp/helpers/src-paths.js b/gulp/helpers/src-paths.js deleted file mode 100644 index 512f64f..0000000 --- a/gulp/helpers/src-paths.js +++ /dev/null @@ -1,22 +0,0 @@ -import path from 'path'; - -const paths = { - APP_SRC: 'app/src', - APP_DEST: 'app/lib', - CLI_SRC: 'src', - CLI_DEST: 'lib', - TEST_SRC: 'test', - TEST_DEST: 'built-tests', -}; - -paths.APP_MAIN_JS = path.join(paths.APP_SRC, '/main.js'); -paths.APP_ALL = `${paths.APP_SRC}/**/*`; -paths.APP_STATIC_ALL = `${path.join(paths.APP_SRC, 'static')}/**/*`; -paths.APP_STATIC_JS = `${path.join(paths.APP_SRC, 'static')}/**/*.js`; -paths.APP_STATIC_DEST = path.join(paths.APP_DEST, 'static'); -paths.CLI_SRC_JS = `${paths.CLI_SRC}/**/*.js`; -paths.CLI_DEST_JS = `${paths.CLI_DEST}/**/*.js`; -paths.TEST_SRC_JS = `${paths.TEST_SRC}/**/*.js`; -paths.TEST_DEST_JS = `${paths.TEST_DEST}/**/*.js`; - -export default paths; diff --git a/gulp/release.js b/gulp/release.js deleted file mode 100644 index d22f344..0000000 --- a/gulp/release.js +++ /dev/null @@ -1,11 +0,0 @@ -import gulp from 'gulp'; -import runSequence from 'run-sequence'; -import helpers from './helpers/gulp-helpers'; - -const { shellExec } = helpers; - -gulp.task('publish', (done) => { - shellExec('npm publish', false, done); -}); - -gulp.task('release', (callback) => runSequence('build', 'publish', callback)); diff --git a/gulp/watch.js b/gulp/watch.js deleted file mode 100644 index df96118..0000000 --- a/gulp/watch.js +++ /dev/null @@ -1,13 +0,0 @@ -import gulp from 'gulp'; -import PATHS from './helpers/src-paths'; - -const log = require('loglevel'); - -gulp.task('watch', ['build'], () => { - const handleError = function watch(error) { - log.error(error); - }; - gulp.watch(PATHS.APP_ALL, ['build-app']).on('error', handleError); - - gulp.watch(PATHS.CLI_SRC_JS, ['build-cli']).on('error', handleError); -}); diff --git a/gulpfile.babel.js b/gulpfile.babel.js deleted file mode 100644 index 0cfb1c6..0000000 --- a/gulpfile.babel.js +++ /dev/null @@ -1,9 +0,0 @@ -import gulp from 'gulp'; -import requireDir from 'require-dir'; - -requireDir('./gulp', { - recurse: true, - duplicates: true, -}); - -gulp.task('default', ['build']); diff --git a/bin/convertToIcns b/icon-scripts/convertToIcns similarity index 100% rename from bin/convertToIcns rename to icon-scripts/convertToIcns diff --git a/bin/convertToIco b/icon-scripts/convertToIco similarity index 100% rename from bin/convertToIco rename to icon-scripts/convertToIco diff --git a/bin/convertToIconset b/icon-scripts/convertToIconset similarity index 100% rename from bin/convertToIconset rename to icon-scripts/convertToIconset diff --git a/bin/convertToPng b/icon-scripts/convertToPng similarity index 85% rename from bin/convertToPng rename to icon-scripts/convertToPng index 60b9ac6..ff77284 100755 --- a/bin/convertToPng +++ b/icon-scripts/convertToPng @@ -8,8 +8,8 @@ set -e -type convert >/dev/null 2>&1 || { echo >&2 "Cannot find required ImageMagick Convert executable"; exit 1; } -type identify >/dev/null 2>&1 || { echo >&2 "Cannot find required ImageMagick Identify executable"; exit 1; } +type convert >/dev/null 2>&1 || { echo >&2 "Cannot find required ImageMagick 'convert' executable, please install it and make sure it is in your PATH"; exit 1; } +type identify >/dev/null 2>&1 || { echo >&2 "Cannot find required ImageMagick 'identify' executable, please install it and make sure it is in your PATH"; exit 1; } # Parameters SOURCE="$1" diff --git a/jest.config.js b/jest.config.js deleted file mode 100644 index 25c9bac..0000000 --- a/jest.config.js +++ /dev/null @@ -1,3 +0,0 @@ -module.exports = { - testEnvironment: 'node', -}; diff --git a/package.json b/package.json index 3ace376..f545a03 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,12 @@ "name": "nativefier", "version": "7.7.1", "description": "Wrap web apps natively", + "license": "MIT", + "author": "Goh Jia Hao", + "engines": { + "node": ">= 8.10.0", + "npm": ">= 5.6.0" + }, "keywords": [ "desktop", "electron", @@ -9,97 +15,85 @@ "native", "wrapper" ], - "main": "lib/index.js", - "scripts": { - "dev-up": "npm install && (cd ./app && npm install) && npm run build", - "dev-up-win": "npm install & cd app & npm install & cd .. & npm run build", - "test": "jest src", - "guard": "jest --watch src", - "e2e": "jest e2e", - "tdd": "gulp tdd", - "lint": "eslint .", - "lint:fix": "eslint . --fix", - "ci": "npm run lint && npm test && npm run e2e", - "clean": "gulp clean", - "build": "gulp build", - "watch": "while true ; do gulp watch ; done", - "package-placeholder": "npm run build && node lib/cli.js http://www.bennish.net/web-notifications.html ~/Desktop --overwrite --name notification-test --icon ./test-resources/iconSampleGrey.png --inject ./test-resources/test-injection.js --inject ./test-resources/test-injection.css && open ~/Desktop/notification-test-darwin-x64/notification-test.app", - "start-placeholder": "npm run build && electron app", - "changelog": "./scripts/changelog", - "format": "prettier --write '{gulp,src}/**/*.js' 'app/src/**/*.js'" - }, + "main": "lib/main.js", "bin": { "nativefier": "lib/cli.js" }, + "homepage": "https://github.com/jiahaog/nativefier", "repository": { "type": "git", "url": "git+https://github.com/jiahaog/nativefier.git" }, - "author": "Goh Jia Hao", - "license": "MIT", "bugs": { "url": "https://github.com/jiahaog/nativefier/issues" }, - "homepage": "https://github.com/jiahaog/nativefier#readme", + "scripts": { + "build-app-static": "ncp app/src/static/ app/lib/static/", + "build": "npm run clean && tsc --build . app && npm run build-app-static", + "build:watch": "tsc --build . app --watch", + "changelog": "./docs/generate-changelog", + "ci": "npm run lint && npm test", + "clean": "rimraf lib/ app/lib/", + "clean:full": "rimraf lib/ app/lib/ node_modules/ app/node_modules/", + "lint:fix": "eslint . --fix", + "lint:format": "prettier --write 'src/**/*.js' 'app/src/**/*.js'", + "lint": "eslint . --ext .ts", + "list-outdated-deps": "npm out; cd app && npm out; true", + "postinstall": "cd app && yarn install --no-lockfile --no-progress --silent", + "test:integration": "jest --testRegex '.*integration-test.js'", + "test:manual": "npm run build && ./docs/manual-test", + "test:unit": "jest", + "test:watch": "jest --watch", + "test:withlog": "LOGLEVEL=trace npm run test", + "test": "jest --testRegex '[-.]test\\.js$'" + }, "dependencies": { - "async": "^2.6.0", - "axios": "^0.18.0", - "babel-polyfill": "^6.26.0", - "cheerio": "^1.0.0-rc.2", - "commander": "^2.14.0", - "electron-packager": "^12.2.0", - "gitcloud": "^0.1.0", - "hasbin": "^1.2.3", - "lodash": "^4.17.5", - "loglevel": "^1.6.1", - "ncp": "^2.0.0", - "page-icon": "^0.3.0", - "progress": "^2.0.0", - "sanitize-filename": "^1.6.1", - "shelljs": "^0.8.1", - "source-map-support": "^0.5.3", - "tmp": "0.0.33", - "validator": "^10.2.0" + "@types/cheerio": "0.x", + "@types/electron-packager": "14.x", + "@types/lodash": "4.x", + "@types/ncp": "2.x", + "@types/node": "8.x", + "@types/page-icon": "0.x", + "@types/shelljs": "0.x", + "@types/tmp": "0.x", + "axios": "0.x", + "cheerio": "^1.0.0-rc.3", + "commander": "4.x", + "electron-packager": "14.x", + "gitcloud": "0.x", + "hasbin": "1.x", + "lodash": "4.x", + "loglevel": "1.x", + "ncp": "2.x", + "page-icon": "0.x", + "sanitize-filename": "1.x", + "shelljs": "0.x", + "source-map-support": "0.x", + "tmp": "0.x", + "yarn": "1.x" }, "devDependencies": { - "babel-core": "^6.26.0", - "babel-jest": "^23.4.0", - "babel-loader": "^7.1.2", - "babel-plugin-transform-object-rest-spread": "^6.26.0", - "babel-preset-env": "^1.6.1", - "babel-register": "^6.26.0", - "chai": "^4.1.2", - "del": "^3.0.0", - "eslint": "^5.2.0", - "eslint-config-airbnb-base": "^13.0.0", - "eslint-config-prettier": "^4.0.0", - "eslint-plugin-import": "^2.8.0", - "eslint-plugin-prettier": "^3.0.0", - "gulp": "^3.9.1", - "gulp-babel": "^7.0.1", - "gulp-sourcemaps": "^2.6.4", - "jest": "^23.4.1", - "prettier": "^1.12.1", - "require-dir": "^1.0.0", - "run-sequence": "^2.2.1", - "webpack-stream": "^5.0.0" + "@types/jest": "25.x", + "@typescript-eslint/eslint-plugin": "2.x", + "@typescript-eslint/parser": "2.x", + "eslint": "6.x", + "eslint-config-prettier": "6.x", + "eslint-plugin-prettier": "3.x", + "jest": "25.x", + "prettier": "1.x", + "rimraf": "3.x", + "typescript": "3.x" }, - "engines": { - "node": ">= 4.0" - }, - "babel": { - "plugins": [ - "transform-object-rest-spread" + "jest": { + "collectCoverage": true, + "setupFiles": [ + "./lib/jestSetupFiles" ], - "presets": [ - [ - "env", - { - "targets": { - "node": "4.0.0" - } - } - ] + "testEnvironment": "node", + "testPathIgnorePatterns": [ + "/node_modules/", + "/app/src.*", + "/src.*" ] } } diff --git a/screenshots/dock.png b/screenshots/dock.png deleted file mode 100644 index 9c8e961..0000000 Binary files a/screenshots/dock.png and /dev/null differ diff --git a/src/build/buildApp.js b/src/build/buildApp.js deleted file mode 100644 index a92317c..0000000 --- a/src/build/buildApp.js +++ /dev/null @@ -1,163 +0,0 @@ -import fs from 'fs'; -import crypto from 'crypto'; -import _ from 'lodash'; -import path from 'path'; -import ncp from 'ncp'; - -const copy = ncp.ncp; -const log = require('loglevel'); -/** - * Only picks certain app args to pass to nativefier.json - * @param options - */ -function selectAppArgs(options) { - return { - name: options.name, - targetUrl: options.targetUrl, - counter: options.counter, - bounce: options.bounce, - width: options.width, - height: options.height, - minWidth: options.minWidth, - minHeight: options.minHeight, - maxWidth: options.maxWidth, - maxHeight: options.maxHeight, - x: options.x, - y: options.y, - showMenuBar: options.showMenuBar, - fastQuit: options.fastQuit, - userAgent: options.userAgent, - nativefierVersion: options.nativefierVersion, - ignoreCertificate: options.ignoreCertificate, - disableGpu: options.disableGpu, - ignoreGpuBlacklist: options.ignoreGpuBlacklist, - enableEs3Apis: options.enableEs3Apis, - insecure: options.insecure, - flashPluginDir: options.flashPluginDir, - diskCacheSize: options.diskCacheSize, - fullScreen: options.fullScreen, - hideWindowFrame: options.hideWindowFrame, - maximize: options.maximize, - disableContextMenu: options.disableContextMenu, - disableDevTools: options.disableDevTools, - zoom: options.zoom, - internalUrls: options.internalUrls, - proxyRules: options.proxyRules, - crashReporter: options.crashReporter, - singleInstance: options.singleInstance, - clearCache: options.clearCache, - appCopyright: options.appCopyright, - appVersion: options.appVersion, - buildVersion: options.buildVersion, - win32metadata: options.win32metadata, - versionString: options.versionString, - processEnvs: options.processEnvs, - fileDownloadOptions: options.fileDownloadOptions, - tray: options.tray, - basicAuthUsername: options.basicAuthUsername, - basicAuthPassword: options.basicAuthPassword, - alwaysOnTop: options.alwaysOnTop, - titleBarStyle: options.titleBarStyle, - globalShortcuts: options.globalShortcuts, - browserwindowOptions: options.browserwindowOptions, - backgroundColor: options.backgroundColor, - darwinDarkModeSupport: options.darwinDarkModeSupport, - }; -} - -function maybeCopyScripts(srcs, dest) { - if (!srcs) { - return new Promise((resolve) => { - resolve(); - }); - } - const promises = srcs.map( - (src) => - new Promise((resolve, reject) => { - if (!fs.existsSync(src)) { - reject(new Error('Error copying injection files: file not found')); - return; - } - - let destFileName; - if (path.extname(src) === '.js') { - destFileName = 'inject.js'; - } else if (path.extname(src) === '.css') { - destFileName = 'inject.css'; - } else { - resolve(); - return; - } - - copy(src, path.join(dest, 'inject', destFileName), (error) => { - if (error) { - reject(new Error(`Error Copying injection files: ${error}`)); - return; - } - resolve(); - }); - }), - ); - - return new Promise((resolve, reject) => { - Promise.all(promises) - .then(() => { - resolve(); - }) - .catch((error) => { - reject(error); - }); - }); -} - -function normalizeAppName(appName, url) { - // use a simple 3 byte random string to prevent collision - const hash = crypto.createHash('md5'); - hash.update(url); - const postFixHash = hash.digest('hex').substring(0, 6); - const normalized = _.kebabCase(appName.toLowerCase()); - return `${normalized}-nativefier-${postFixHash}`; -} - -function changeAppPackageJsonName(appPath, name, url) { - const packageJsonPath = path.join(appPath, '/package.json'); - const packageJson = JSON.parse(fs.readFileSync(packageJsonPath)); - packageJson.name = normalizeAppName(name, url); - fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson)); -} - -/** - * Creates a temporary directory and copies the './app folder' inside, - * and adds a text file with the configuration for the single page app. - * - * @param {string} src - * @param {string} dest - * @param {{}} options - * @param callback - */ -function buildApp(src, dest, options, callback) { - const appArgs = selectAppArgs(options); - - copy(src, dest, (error) => { - if (error) { - callback(`Error Copying temporary directory: ${error}`); - return; - } - - fs.writeFileSync( - path.join(dest, '/nativefier.json'), - JSON.stringify(appArgs), - ); - - maybeCopyScripts(options.inject, dest) - .catch((err) => { - log.warn(err); - }) - .then(() => { - changeAppPackageJsonName(dest, appArgs.name, appArgs.targetUrl); - callback(); - }); - }); -} - -export default buildApp; diff --git a/src/build/buildIcon.ts b/src/build/buildIcon.ts new file mode 100644 index 0000000..7e4490a --- /dev/null +++ b/src/build/buildIcon.ts @@ -0,0 +1,96 @@ +import * as path from 'path'; + +import * as log from 'loglevel'; + +import { isOSX } from '../helpers/helpers'; +import { + convertToPng, + convertToIco, + convertToIcns, +} from '../helpers/iconShellHelpers'; +import { AppOptions } from '../options/model'; + +function iconIsIco(iconPath: string): boolean { + return path.extname(iconPath) === '.ico'; +} + +function iconIsPng(iconPath: string): boolean { + return path.extname(iconPath) === '.png'; +} + +function iconIsIcns(iconPath: string): boolean { + return path.extname(iconPath) === '.icns'; +} + +/** + * Will convert a `.png` icon to the appropriate arch format (if necessary), + * and return adjusted options + */ +export async function convertIconIfNecessary( + options: AppOptions, +): Promise { + if (!options.packager.icon) { + log.debug('Option "icon" not set, skipping icon conversion.'); + return; + } + + if (options.packager.platform === 'win32') { + if (iconIsIco(options.packager.icon)) { + log.debug( + 'Building for Windows and icon is already a .ico, no conversion needed', + ); + return; + } + + try { + const iconPath = await convertToIco(options.packager.icon); + options.packager.icon = iconPath; + return; + } catch (error) { + log.warn('Failed to convert icon to .ico, skipping.', error); + return; + } + } + + if (options.packager.platform === 'linux') { + if (iconIsPng(options.packager.icon)) { + log.debug( + 'Building for Linux and icon is already a .png, no conversion needed', + ); + return; + } + + try { + const iconPath = await convertToPng(options.packager.icon); + options.packager.icon = iconPath; + return; + } catch (error) { + log.warn('Failed to convert icon to .png, skipping.', error); + return; + } + } + + if (iconIsIcns(options.packager.icon)) { + log.debug( + 'Building for macOS and icon is already a .icns, no conversion needed', + ); + return; + } + + if (!isOSX()) { + log.warn( + 'Skipping icon conversion to .icns, conversion is only supported on macOS', + ); + return; + } + + try { + const iconPath = await convertToIcns(options.packager.icon); + options.packager.icon = iconPath; + return; + } catch (error) { + log.warn('Failed to convert icon to .icns, skipping.', error); + options.packager.icon = undefined; + return; + } +} diff --git a/src/build/buildMain.js b/src/build/buildMain.js deleted file mode 100644 index bde1e3b..0000000 --- a/src/build/buildMain.js +++ /dev/null @@ -1,247 +0,0 @@ -import path from 'path'; -import packager from 'electron-packager'; -import tmp from 'tmp'; -import ncp from 'ncp'; -import async from 'async'; -import hasBinary from 'hasbin'; -import log from 'loglevel'; -import DishonestProgress from '../helpers/dishonestProgress'; -import optionsFactory from '../options/optionsMain'; -import iconBuild from './iconBuild'; -import helpers from '../helpers/helpers'; -import PackagerConsole from '../helpers/packagerConsole'; -import buildApp from './buildApp'; - -const copy = ncp.ncp; -const { isWindows } = helpers; - -/** - * Checks the app path array to determine if the packaging was completed successfully - * @param appPathArray Result from electron-packager - * @returns {*} - */ -function getAppPath(appPathArray) { - if (appPathArray.length === 0) { - // directory already exists, --overwrite is not set - // exit here - return null; - } - - if (appPathArray.length > 1) { - log.warn( - 'Warning: This should not be happening, packaged app path contains more than one element:', - appPathArray, - ); - } - - return appPathArray[0]; -} - -/** - * Removes the `icon` parameter from options if building for Windows while not on Windows - * and Wine is not installed - * @param options - */ -function maybeNoIconOption(options) { - const packageOptions = JSON.parse(JSON.stringify(options)); - if (options.platform === 'win32' && !isWindows()) { - if (!hasBinary.sync('wine')) { - log.warn( - 'Wine is required to set the icon for a Windows app when packaging on non-windows platforms', - ); - packageOptions.icon = null; - } - } - return packageOptions; -} - -/** - * For windows and linux, we have to copy over the icon to the resources/app folder, which the - * BrowserWindow is hard coded to read the icon from - * @param {{}} options - * @param {string} appPath - * @param callback - */ -function maybeCopyIcons(options, appPath, callback) { - if (!options.icon) { - callback(); - return; - } - - if (options.platform === 'darwin' || options.platform === 'mas') { - callback(); - return; - } - - // windows & linux - // put the icon file into the app - const destIconPath = path.join(appPath, 'resources/app'); - const destFileName = `icon${path.extname(options.icon)}`; - copy(options.icon, path.join(destIconPath, destFileName), (error) => { - callback(error); - }); -} - -/** - * Removes invalid parameters from options if building for Windows while not on Windows - * and Wine is not installed - * @param options - */ -function removeInvalidOptions(options, param) { - const packageOptions = JSON.parse(JSON.stringify(options)); - if (options.platform === 'win32' && !isWindows()) { - if (!hasBinary.sync('wine')) { - log.warn( - `Wine is required to use "${param}" option for a Windows app when packaging on non-windows platforms`, - ); - packageOptions[param] = null; - } - } - return packageOptions; -} - -/** - * Removes the `appCopyright` parameter from options if building for Windows while not on Windows - * and Wine is not installed - * @param options - */ -function maybeNoAppCopyrightOption(options) { - return removeInvalidOptions(options, 'appCopyright'); -} - -/** - * Removes the `buildVersion` parameter from options if building for Windows while not on Windows - * and Wine is not installed - * @param options - */ -function maybeNoBuildVersionOption(options) { - return removeInvalidOptions(options, 'buildVersion'); -} - -/** - * Removes the `appVersion` parameter from options if building for Windows while not on Windows - * and Wine is not installed - * @param options - */ -function maybeNoAppVersionOption(options) { - return removeInvalidOptions(options, 'appVersion'); -} - -/** - * Removes the `versionString` parameter from options if building for Windows while not on Windows - * and Wine is not installed - * @param options - */ -function maybeNoVersionStringOption(options) { - return removeInvalidOptions(options, 'versionString'); -} - -/** - * Removes the `win32metadata` parameter from options if building for Windows while not on Windows - * and Wine is not installed - * @param options - */ -function maybeNoWin32metadataOption(options) { - return removeInvalidOptions(options, 'win32metadata'); -} - -/** - * @callback buildAppCallback - * @param error - * @param {string} appPath - */ - -/** - * - * @param {{}} inpOptions - * @param {buildAppCallback} callback - */ -function buildMain(inpOptions, callback) { - const options = Object.assign({}, inpOptions); - - // pre process app - const tmpObj = tmp.dirSync({ mode: '0755', unsafeCleanup: true }); - const tmpPath = tmpObj.name; - - // todo check if this is still needed on later version of packager - const packagerConsole = new PackagerConsole(); - - const progress = new DishonestProgress(5); - - async.waterfall( - [ - (cb) => { - progress.tick('inferring'); - optionsFactory(options) - .then((result) => { - cb(null, result); - }) - .catch((error) => { - cb(error); - }); - }, - (opts, cb) => { - progress.tick('copying'); - buildApp(opts.dir, tmpPath, opts, (error) => { - if (error) { - cb(error); - return; - } - // Change the reference file for the Electron app to be the temporary path - const newOptions = Object.assign({}, opts, { - dir: tmpPath, - }); - cb(null, newOptions); - }); - }, - (opts, cb) => { - progress.tick('icons'); - iconBuild(opts, (error, optionsWithIcon) => { - cb(null, optionsWithIcon); - }); - }, - (opts, cb) => { - progress.tick('packaging'); - // maybe skip passing icon parameter to electron packager - let packageOptions = maybeNoIconOption(opts); - // maybe skip passing other parameters to electron packager - packageOptions = maybeNoAppCopyrightOption(packageOptions); - packageOptions = maybeNoAppVersionOption(packageOptions); - packageOptions = maybeNoBuildVersionOption(packageOptions); - packageOptions = maybeNoVersionStringOption(packageOptions); - packageOptions = maybeNoWin32metadataOption(packageOptions); - - packagerConsole.override(); - - packager(packageOptions) - .then((appPathArray) => { - packagerConsole.restore(); // restore console.error - cb(null, opts, appPathArray); // options still contain the icon to waterfall - }) - .catch((error) => { - packagerConsole.restore(); // restore console.error - cb(error, opts); // options still contain the icon to waterfall - }); - }, - (opts, appPathArray, cb) => { - progress.tick('finalizing'); - // somehow appPathArray is a 1 element array - const appPath = getAppPath(appPathArray); - if (!appPath) { - cb(); - return; - } - - maybeCopyIcons(opts, appPath, (error) => { - cb(error, appPath); - }); - }, - ], - (error, appPath) => { - packagerConsole.playback(); - callback(error, appPath); - }, - ); -} - -export default buildMain; diff --git a/src/build/buildNativefierApp.ts b/src/build/buildNativefierApp.ts new file mode 100644 index 0000000..7598d3a --- /dev/null +++ b/src/build/buildNativefierApp.ts @@ -0,0 +1,125 @@ +import * as path from 'path'; + +import * as electronGet from '@electron/get'; +import * as electronPackager from 'electron-packager'; +import * as hasbin from 'hasbin'; +import * as log from 'loglevel'; + +import { isWindows, getTempDir, copyFileOrDir } from '../helpers/helpers'; +import { getOptions } from '../options/optionsMain'; +import { prepareElectronApp } from './prepareElectronApp'; +import { convertIconIfNecessary } from './buildIcon'; +import { AppOptions } from '../options/model'; + +const OPTIONS_REQUIRING_WINDOWS_FOR_WINDOWS_BUILD = [ + 'icon', + 'appCopyright', + 'appVersion', + 'buildVersion', + 'versionString', + 'win32metadata', +]; + +/** + * Checks the app path array to determine if packaging completed successfully + */ +function getAppPath(appPath: string | string[]): string { + if (!Array.isArray(appPath)) { + return appPath; + } + + if (appPath.length === 0) { + return null; // directory already exists and `--overwrite` not set + } + + if (appPath.length > 1) { + log.warn( + 'Warning: This should not be happening, packaged app path contains more than one element:', + appPath, + ); + } + + return appPath[0]; +} + +/** + * For Windows & Linux, we have to copy over the icon to the resources/app + * folder, which the BrowserWindow is hard-coded to read the icon from + */ +async function copyIconsIfNecessary( + options: AppOptions, + appPath: string, +): Promise { + log.debug('Copying icons if necessary'); + if (!options.packager.icon) { + log.debug('No icon specified in options; aborting'); + return; + } + + if ( + options.packager.platform === 'darwin' || + options.packager.platform === 'mas' + ) { + log.debug('No copying necessary on macOS; aborting'); + return; + } + + // windows & linux: put the icon file into the app + const destAppPath = path.join(appPath, 'resources/app'); + const destFileName = `icon${path.extname(options.packager.icon)}`; + const destIconPath = path.join(destAppPath, destFileName); + + log.debug(`Copying icon ${options.packager.icon} to`, destIconPath); + await copyFileOrDir(options.packager.icon, destIconPath); +} + +function trimUnprocessableOptions(options: AppOptions): void { + if ( + options.packager.platform === 'win32' && + !isWindows() && + !hasbin.sync('wine') + ) { + const optionsPresent = Object.entries(options) + .filter( + ([key, value]) => + OPTIONS_REQUIRING_WINDOWS_FOR_WINDOWS_BUILD.includes(key) && !!value, + ) + .map(([key]) => key); + if (optionsPresent.length === 0) { + return; + } + log.warn( + `*Not* setting [${optionsPresent.join(', ')}], as couldn't find Wine.`, + 'Wine is required when packaging a Windows app under on non-Windows platforms.', + ); + for (const keyToUnset of optionsPresent) { + options[keyToUnset] = null; + } + } +} + +export async function buildNativefierApp(rawOptions: any): Promise { + log.info('Processing options...'); + const options = await getOptions(rawOptions); + + log.info('\nPreparing Electron app...'); + const tmpPath = getTempDir('app', 0o755); + await prepareElectronApp(options.packager.dir, tmpPath, options); + + log.info('\nConverting icons...'); + options.packager.dir = tmpPath; // const optionsWithTmpPath = { ...options, dir: tmpPath }; + await convertIconIfNecessary(options); + + log.info( + "\nPackaging... This will take a few seconds, maybe minutes if the requested Electron isn't cached yet...", + ); + trimUnprocessableOptions(options); + electronGet.initializeProxy(); // https://github.com/electron/get#proxies + const appPathArray = await electronPackager(options.packager); + + log.info('\nFinalizing build...'); + const appPath = getAppPath(appPathArray); + await copyIconsIfNecessary(options, appPath); + + return appPath; +} diff --git a/src/build/iconBuild.js b/src/build/iconBuild.js deleted file mode 100644 index 4a1798f..0000000 --- a/src/build/iconBuild.js +++ /dev/null @@ -1,106 +0,0 @@ -import path from 'path'; -import log from 'loglevel'; -import helpers from '../helpers/helpers'; -import iconShellHelpers from '../helpers/iconShellHelpers'; - -const { isOSX } = helpers; -const { convertToPng, convertToIco, convertToIcns } = iconShellHelpers; - -function iconIsIco(iconPath) { - return path.extname(iconPath) === '.ico'; -} - -function iconIsPng(iconPath) { - return path.extname(iconPath) === '.png'; -} - -function iconIsIcns(iconPath) { - return path.extname(iconPath) === '.icns'; -} - -/** - * @callback augmentIconsCallback - * @param error - * @param options - */ - -/** - * Will check and convert a `.png` to `.icns` if necessary and augment - * options.icon with the result - * - * @param inpOptions will need options.platform and options.icon - * @param {augmentIconsCallback} callback - */ -function iconBuild(inpOptions, callback) { - const options = Object.assign({}, inpOptions); - const returnCallback = () => { - callback(null, options); - }; - - if (!options.icon) { - returnCallback(); - return; - } - - if (options.platform === 'win32') { - if (iconIsIco(options.icon)) { - returnCallback(); - return; - } - - convertToIco(options.icon) - .then((outPath) => { - options.icon = outPath; - returnCallback(); - }) - .catch((error) => { - log.warn('Skipping icon conversion to .ico', error); - returnCallback(); - }); - return; - } - - if (options.platform === 'linux') { - if (iconIsPng(options.icon)) { - returnCallback(); - return; - } - - convertToPng(options.icon) - .then((outPath) => { - options.icon = outPath; - returnCallback(); - }) - .catch((error) => { - log.warn('Skipping icon conversion to .png', error); - returnCallback(); - }); - return; - } - - if (iconIsIcns(options.icon)) { - returnCallback(); - return; - } - - if (!isOSX()) { - log.warn( - 'Skipping icon conversion to .icns, conversion is only supported on OSX', - ); - returnCallback(); - return; - } - - convertToIcns(options.icon) - .then((outPath) => { - options.icon = outPath; - returnCallback(); - }) - .catch((error) => { - log.warn('Skipping icon conversion to .icns', error); - options.icon = undefined; - returnCallback(); - }); -} - -export default iconBuild; diff --git a/src/build/prepareElectronApp.ts b/src/build/prepareElectronApp.ts new file mode 100644 index 0000000..7d2cb98 --- /dev/null +++ b/src/build/prepareElectronApp.ts @@ -0,0 +1,157 @@ +import * as fs from 'fs'; +import * as crypto from 'crypto'; +import * as path from 'path'; +import { promisify } from 'util'; + +import { kebabCase } from 'lodash'; +import * as log from 'loglevel'; + +import { copyFileOrDir } from '../helpers/helpers'; +import { AppOptions } from '../options/model'; + +const writeFileAsync = promisify(fs.writeFile); + +/** + * Only picks certain app args to pass to nativefier.json + */ +function pickElectronAppArgs(options: AppOptions): any { + return { + alwaysOnTop: options.nativefier.alwaysOnTop, + appCopyright: options.packager.appCopyright, + appVersion: options.packager.appVersion, + backgroundColor: options.nativefier.backgroundColor, + basicAuthPassword: options.nativefier.basicAuthPassword, + basicAuthUsername: options.nativefier.basicAuthUsername, + bounce: options.nativefier.bounce, + browserwindowOptions: options.nativefier.browserwindowOptions, + buildVersion: options.packager.buildVersion, + clearCache: options.nativefier.clearCache, + counter: options.nativefier.counter, + crashReporter: options.nativefier.crashReporter, + darwinDarkModeSupport: options.packager.darwinDarkModeSupport, + disableContextMenu: options.nativefier.disableContextMenu, + disableDevTools: options.nativefier.disableDevTools, + disableGpu: options.nativefier.disableGpu, + diskCacheSize: options.nativefier.diskCacheSize, + enableEs3Apis: options.nativefier.enableEs3Apis, + fastQuit: options.nativefier.fastQuit, + fileDownloadOptions: options.nativefier.fileDownloadOptions, + flashPluginDir: options.nativefier.flashPluginDir, + fullScreen: options.nativefier.fullScreen, + globalShortcuts: options.nativefier.globalShortcuts, + height: options.nativefier.height, + hideWindowFrame: options.nativefier.hideWindowFrame, + ignoreCertificate: options.nativefier.ignoreCertificate, + ignoreGpuBlacklist: options.nativefier.ignoreGpuBlacklist, + insecure: options.nativefier.insecure, + internalUrls: options.nativefier.internalUrls, + maxHeight: options.nativefier.maxHeight, + maximize: options.nativefier.maximize, + maxWidth: options.nativefier.maxWidth, + minHeight: options.nativefier.minHeight, + minWidth: options.nativefier.minWidth, + name: options.packager.name, + nativefierVersion: options.nativefier.nativefierVersion, + processEnvs: options.nativefier.processEnvs, + proxyRules: options.nativefier.proxyRules, + showMenuBar: options.nativefier.showMenuBar, + singleInstance: options.nativefier.singleInstance, + targetUrl: options.packager.targetUrl, + titleBarStyle: options.nativefier.titleBarStyle, + tray: options.nativefier.tray, + userAgent: options.nativefier.userAgent, + versionString: options.nativefier.versionString, + width: options.nativefier.width, + win32metadata: options.packager.win32metadata, + x: options.nativefier.x, + y: options.nativefier.y, + zoom: options.nativefier.zoom, + }; +} + +async function maybeCopyScripts(srcs: string[], dest: string): Promise { + if (!srcs || srcs.length === 0) { + log.debug('No files to inject, skipping copy.'); + return; + } + + log.debug(`Copying ${srcs.length} files to inject in app.`); + for (const src of srcs) { + if (!fs.existsSync(src)) { + throw new Error( + `File ${src} not found. Note that Nativefier expects *local* files, not URLs.`, + ); + } + + let destFileName: string; + if (path.extname(src) === '.js') { + destFileName = 'inject.js'; + } else if (path.extname(src) === '.css') { + destFileName = 'inject.css'; + } else { + return; + } + + const destPath = path.join(dest, 'inject', destFileName); + log.debug(`Copying injection file "${src}" to "${destPath}"`); + await copyFileOrDir(src, destPath); + } +} + +function normalizeAppName(appName: string, url: string): string { + // use a simple 3 byte random string to prevent collision + const hash = crypto.createHash('md5'); + hash.update(url); + const postFixHash = hash.digest('hex').substring(0, 6); + const normalized = kebabCase(appName.toLowerCase()); + return `${normalized}-nativefier-${postFixHash}`; +} + +function changeAppPackageJsonName( + appPath: string, + name: string, + url: string, +): void { + const packageJsonPath = path.join(appPath, '/package.json'); + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath).toString()); + const normalizedAppName = normalizeAppName(name, url); + packageJson.name = normalizedAppName; + log.debug(`Updating ${packageJsonPath} 'name' field to ${normalizedAppName}`); + + fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson)); +} + +/** + * Creates a temporary directory, copies the './app folder' inside, + * and adds a text file with the app configuration. + */ +export async function prepareElectronApp( + src: string, + dest: string, + options: AppOptions, +): Promise { + log.debug(`Copying electron app from ${src} to ${dest}`); + try { + await copyFileOrDir(src, dest); + } catch (err) { + throw `Error copying electron app from ${src} to temp dir ${dest}. Error: ${err}`; + } + + const appJsonPath = path.join(dest, '/nativefier.json'); + log.debug(`Writing app config to ${appJsonPath}`); + await writeFileAsync( + appJsonPath, + JSON.stringify(pickElectronAppArgs(options)), + ); + + try { + await maybeCopyScripts(options.nativefier.inject, dest); + } catch (err) { + log.error('Error copying injection files.', err); + } + changeAppPackageJsonName( + dest, + options.packager.name, + options.packager.targetUrl, + ); +} diff --git a/src/cli.js b/src/cli.ts similarity index 50% rename from src/cli.js rename to src/cli.ts index f2355d3..20cd41b 100755 --- a/src/cli.js +++ b/src/cli.ts @@ -1,19 +1,22 @@ -#! /usr/bin/env node - +#!/usr/bin/env node import 'source-map-support/register'; -import program from 'commander'; -import nativefier from './index'; -const dns = require('dns'); -const log = require('loglevel'); -const packageJson = require('./../package'); +import * as commander from 'commander'; +import * as dns from 'dns'; +import * as log from 'loglevel'; -function collect(val, memo) { +import { buildNativefierApp } from './main'; + +// package.json is `require`d to let tsc strip the `src` folder by determining +// baseUrl=src. A static import would prevent that and cause an ugly extra "src" folder +const packageJson = require('../package.json'); // eslint-disable-line @typescript-eslint/no-var-requires + +function collect(val: any, memo: any[]): any[] { memo.push(val); return memo; } -function parseMaybeBoolString(val) { +function parseBooleanOrString(val: string): boolean | string { switch (val) { case 'true': return true; @@ -24,19 +27,19 @@ function parseMaybeBoolString(val) { } } -function parseJson(val) { +function parseJson(val: string): any { if (!val) return {}; return JSON.parse(val); } -function getProcessEnvs(val) { - if (!val) return {}; - const pEnv = {}; - pEnv.processEnvs = parseJson(val); - return pEnv; +function getProcessEnvs(val: string): any { + if (!val) { + return {}; + } + return { processEnvs: parseJson(val) }; } -function checkInternet() { +function checkInternet(): void { dns.lookup('npmjs.com', (err) => { if (err && err.code === 'ENOTFOUND') { log.warn( @@ -63,31 +66,36 @@ if (require.main === module) { sanitizedArgs.push(arg); }); - program + const positionalOptions = { + targetUrl: '', + out: '', + }; + commander + .name('nativefier') .version(packageJson.version, '-v, --version') .arguments(' [dest]') - .action((targetUrl, appDir) => { - program.targetUrl = targetUrl; - program.out = appDir; + .action((url, outputDirectory) => { + positionalOptions.targetUrl = url; + positionalOptions.out = outputDirectory; }) .option('-n, --name ', 'app name') - .option('-p, --platform ', "'osx', 'mas', 'linux' or 'windows'") + .option('-p, --platform ', "'mac', 'mas', 'linux' or 'windows'") .option('-a, --arch ', "'ia32' or 'x64' or 'armv7l'") .option( '--app-version ', - 'The release version of the application. Maps to the `ProductVersion` metadata property on Windows, and `CFBundleShortVersionString` on OS X.', + '(macOS, windows only) the version of the app. Maps to the `ProductVersion` metadata property on Windows, and `CFBundleShortVersionString` on macOS.', ) .option( '--build-version ', - 'The build version of the application. Maps to the `FileVersion` metadata property on Windows, and `CFBundleVersion` on OS X.', + '(macOS, windows only) The build version of the app. Maps to `FileVersion` metadata property on Windows, and `CFBundleVersion` on macOS', ) .option( '--app-copyright ', - 'The human-readable copyright line for the app. Maps to the `LegalCopyright` metadata property on Windows, and `NSHumanReadableCopyright` on OS X', + '(macOS, windows only) a human-readable copyright line for the app. Maps to `LegalCopyright` metadata property on Windows, and `NSHumanReadableCopyright` on macOS', ) .option( '--win32metadata ', - 'a JSON string of key/value pairs of application metadata (ProductName, InternalName, FileDescription) to embed into the executable (Windows only).', + '(windows only) a JSON string of key/value pairs (ProductName, InternalName, FileDescription) to embed as executable metadata', parseJson, ) .option( @@ -96,19 +104,19 @@ if (require.main === module) { ) .option( '--no-overwrite', - 'do not override output directory if it already exists, defaults to false', + 'do not override output directory if it already exists; defaults to false', ) .option( '-c, --conceal', - 'packages the source code within your app into an archive, defaults to false, see https://electronjs.org/docs/tutorial/application-packaging', + 'packages the app source code into an asar archive; defaults to false', ) .option( '--counter', - 'if the target app should use a persistent counter badge in the dock (macOS only), defaults to false', + '(macOS only) set a dock count badge, determined by looking for a number in the window title; defaults to false', ) .option( '--bounce', - 'if the the dock icon should bounce when counter increases (macOS only), defaults to false', + '(macOS only) make he dock icon bounce when the counter increases; defaults to false', ) .option( '-i, --icon ', @@ -116,61 +124,61 @@ if (require.main === module) { ) .option( '--width ', - 'set window default width, defaults to 1280px', + 'set window default width; defaults to 1280px', parseInt, ) .option( '--height ', - 'set window default height, defaults to 800px', + 'set window default height; defaults to 800px', parseInt, ) .option( '--min-width ', - 'set window minimum width, defaults to 0px', + 'set window minimum width; defaults to 0px', parseInt, ) .option( '--min-height ', - 'set window minimum height, defaults to 0px', + 'set window minimum height; defaults to 0px', parseInt, ) .option( '--max-width ', - 'set window maximum width, default is no limit', + 'set window maximum width; default is unlimited', parseInt, ) .option( '--max-height ', - 'set window maximum height, default is no limit', + 'set window maximum height; default is unlimited', parseInt, ) .option('--x ', 'set window x location', parseInt) .option('--y ', 'set window y location', parseInt) - .option('-m, --show-menu-bar', 'set menu bar visible, defaults to false') + .option('-m, --show-menu-bar', 'set menu bar visible; defaults to false') .option( '-f, --fast-quit', - 'quit app after window close (macOS only), defaults to false', + '(macOS only) quit app on window close; defaults to false', ) - .option('-u, --user-agent ', 'set the user agent string for the app') + .option('-u, --user-agent ', 'set the app user agent string') .option( '--honest', - 'prevent the nativefied app from changing the user agent string to masquerade as a regular chrome browser', + 'prevent the normal changing of the user agent string to appear as a regular Chrome browser', ) - .option('--ignore-certificate', 'ignore certificate related errors') + .option('--ignore-certificate', 'ignore certificate-related errors') .option('--disable-gpu', 'disable hardware acceleration') .option( '--ignore-gpu-blacklist', - 'allow WebGl apps to work on non supported graphics cards', + 'force WebGL apps to work on unsupported GPUs', ) - .option('--enable-es3-apis', 'force activation of WebGl 2.0') + .option('--enable-es3-apis', 'force activation of WebGL 2.0') .option( '--insecure', - 'enable loading of insecure content, defaults to false', + 'enable loading of insecure content; defaults to false', ) - .option('--flash', 'if flash should be enabled') + .option('--flash', 'enables Adobe Flash; defaults to false') .option( '--flash-path ', - 'path to Chrome flash plugin, find it in `Chrome://plugins`', + 'path to Chrome flash plugin; find it in `chrome://plugins`', ) .option( '--disk-cache-size ', @@ -178,31 +186,31 @@ if (require.main === module) { ) .option( '--inject ', - 'path to a CSS/JS file to be injected', + 'path to a CSS/JS file to be injected. Pass multiple times to inject multiple files.', collect, [], ) - .option( - '--full-screen', - 'if the app should always be started in full screen', - ) - .option('--maximize', 'if the app should always be started maximized') + .option('--full-screen', 'always start the app full screen') + .option('--maximize', 'always start the app maximized') .option('--hide-window-frame', 'disable window frame and controls') - .option('--verbose', 'if verbose logs should be displayed') - .option('--disable-context-menu', 'disable the context menu') - .option('--disable-dev-tools', 'disable developer tools') + .option('--verbose', 'enable verbose/debug/troubleshooting logs') + .option('--disable-context-menu', 'disable the context menu (right click)') + .option( + '--disable-dev-tools', + 'disable developer tools (Ctrl+Shift+I / F12)', + ) .option( '--zoom ', - 'default zoom factor to use when the app is opened, defaults to 1.0', + 'default zoom factor to use when the app is opened; defaults to 1.0', parseFloat, ) .option( '--internal-urls ', - 'regular expression of URLs to consider "internal"; all other URLs will be opened in an external browser. (default: URLs on same second-level domain as app)', + 'regex of URLs to consider "internal"; all other URLs will be opened in an external browser. Default: URLs on same second-level domain as app', ) .option( '--proxy-rules ', - 'proxy rules. See https://electronjs.org/docs/api/session?q=proxy#sessetproxyconfig-callback', + 'proxy rules; see https://www.electronjs.org/docs/api/session#sessetproxyconfig', ) .option( '--crash-reporter ', @@ -218,29 +226,29 @@ if (require.main === module) { ) .option( '--processEnvs ', - 'a JSON string of key/value pairs to be set as environment variables before any browser windows are opened.', + 'a JSON string of key/value pairs to be set as environment variables before any browser windows are opened', getProcessEnvs, ) .option( '--file-download-options ', - 'a JSON string of key/value pairs to be set as file download options. See https://github.com/sindresorhus/electron-dl for available options.', + 'a JSON string of key/value pairs to be set as file download options. See https://github.com/sindresorhus/electron-dl for available options.', parseJson, ) .option( '--tray [start-in-tray]', - "Allow app to stay in system tray. If 'start-in-tray' is given as argument, don't show main window on first start", - parseMaybeBoolString, + "Allow app to stay in system tray. If 'start-in-tray' is set as argument, don't show main window on first start", + parseBooleanOrString, ) .option('--basic-auth-username ', 'basic http(s) auth username') .option('--basic-auth-password ', 'basic http(s) auth password') .option('--always-on-top', 'enable always on top window') .option( '--title-bar-style ', - "(macOS only) set title bar style ('hidden', 'hiddenInset'). Consider injecting custom CSS (via --inject) for better integration.", + "(macOS only) set title bar style ('hidden', 'hiddenInset'). Consider injecting custom CSS (via --inject) for better integration", ) .option( '--global-shortcuts ', - 'JSON file with global shortcut configuration. See https://github.com/jiahaog/nativefier/blob/master/docs/api.md#global-shortcuts', + 'JSON file defining global shortcuts. See https://github.com/jiahaog/nativefier/blob/master/docs/api.md#global-shortcuts', ) .option( '--browserwindow-options ', @@ -249,7 +257,7 @@ if (require.main === module) { ) .option( '--background-color ', - "Sets the background color (for seamless experience while the app is loading). Example value: '#2e2c29'", + "sets the app background color, for better integration while the app is loading. Example value: '#2e2c29'", ) .option( '--darwin-dark-mode-support', @@ -258,19 +266,19 @@ if (require.main === module) { .parse(sanitizedArgs); if (!process.argv.slice(2).length) { - program.help(); + commander.help(); } checkInternet(); - nativefier(program, (error, appPath) => { - if (error) { - log.error(error); - return; - } - - if (!appPath) { - // app exists and --overwrite is not passed - return; - } - log.info(`App built to ${appPath}`); - }); + const options = { ...positionalOptions, ...commander.opts() }; + buildNativefierApp(options) + .then((appPath) => { + if (!appPath) { + log.info(`App *not* built to ${appPath}`); + return; + } + log.info(`App built to ${appPath}`); + }) + .catch((error) => { + log.error('Error during build. Run with --verbose for details.', error); + }); } diff --git a/src/constants.js b/src/constants.js deleted file mode 100644 index b1873f0..0000000 --- a/src/constants.js +++ /dev/null @@ -1,5 +0,0 @@ -import path from 'path'; - -export const DEFAULT_APP_NAME = 'APP'; -export const ELECTRON_VERSION = '5.0.13'; -export const PLACEHOLDER_APP_DIR = path.join(__dirname, './../', 'app'); diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 0000000..25f2a45 --- /dev/null +++ b/src/constants.ts @@ -0,0 +1,13 @@ +import * as path from 'path'; + +export const DEFAULT_APP_NAME = 'APP'; + +// Update both together +export const DEFAULT_ELECTRON_VERSION = '8.1.1'; +export const DEFAULT_CHROME_VERSION = '80.0.3987.141'; + +export const ELECTRON_MAJOR_VERSION = parseInt( + DEFAULT_ELECTRON_VERSION.split('.')[0], + 10, +); +export const PLACEHOLDER_APP_DIR = path.join(__dirname, './../', 'app'); diff --git a/src/helpers/convertToIcns.js b/src/helpers/convertToIcns.js deleted file mode 100644 index ea0b9fa..0000000 --- a/src/helpers/convertToIcns.js +++ /dev/null @@ -1,65 +0,0 @@ -import shell from 'shelljs'; -import path from 'path'; -import tmp from 'tmp'; -import helpers from './helpers'; - -const { isOSX } = helpers; -tmp.setGracefulCleanup(); - -const PNG_TO_ICNS_BIN_PATH = path.join(__dirname, '../..', 'bin/convertToIcns'); - -/** - * @callback pngToIcnsCallback - * @param error - * @param {string} icnsDest If error, will return the original png src - */ - -/** - * - * @param {string} pngSrc - * @param {string} icnsDest - * @param {pngToIcnsCallback} callback - */ -function convertToIcns(pngSrc, icnsDest, callback) { - if (!isOSX()) { - callback('OSX is required to convert .png to .icns icon', pngSrc); - return; - } - - shell.exec( - `"${PNG_TO_ICNS_BIN_PATH}" "${pngSrc}" "${icnsDest}"`, - { silent: true }, - (exitCode, stdOut, stdError) => { - if (stdOut.includes('icon.iconset:error') || exitCode) { - if (exitCode) { - callback( - { - stdOut, - stdError, - }, - pngSrc, - ); - return; - } - - callback(stdOut, pngSrc); - return; - } - - callback(null, icnsDest); - }, - ); -} - -/** - * Converts the png to a temporary directory which will be cleaned up on process exit - * @param {string} pngSrc - * @param {pngToIcnsCallback} callback - */ -function convertToIcnsTmp(pngSrc, callback) { - const tempIconDirObj = tmp.dirSync({ unsafeCleanup: true }); - const tempIconDirPath = tempIconDirObj.name; - convertToIcns(pngSrc, `${tempIconDirPath}/icon.icns`, callback); -} - -export default convertToIcnsTmp; diff --git a/src/helpers/convertToIcns.test.js b/src/helpers/convertToIcns.test.js deleted file mode 100644 index 275cc27..0000000 --- a/src/helpers/convertToIcns.test.js +++ /dev/null @@ -1,40 +0,0 @@ -import fs from 'fs'; -import os from 'os'; -import path from 'path'; -import convertToIcns from './convertToIcns'; - -// Prerequisite for test: to use OSX with sips, iconutil and imagemagick convert - -function testConvertPng(pngName) { - if (os.platform() !== 'darwin') { - // Skip png conversion tests, OSX is required - return Promise.resolve(); - } - - return new Promise((resolve, reject) => - convertToIcns( - path.join(__dirname, '../../', 'test-resources', pngName), - (error, icnsPath) => { - if (error) { - reject(error); - return; - } - - const stat = fs.statSync(icnsPath); - - expect(stat.isFile()).toBe(true); - resolve(); - }, - ), - ); -} - -describe('Get Icon Module', () => { - test('Can convert a rgb png to icns', async () => { - await testConvertPng('iconSample.png'); - }); - - test('Can convert a grey png to icns', async () => { - await testConvertPng('iconSampleGrey.png'); - }); -}); diff --git a/src/helpers/dishonestProgress.js b/src/helpers/dishonestProgress.js deleted file mode 100644 index beac6fe..0000000 --- a/src/helpers/dishonestProgress.js +++ /dev/null @@ -1,70 +0,0 @@ -import ProgressBar from 'progress'; - -class DishonestProgress { - constructor(total) { - this.tickParts = total * 10; - - this.bar = new ProgressBar(' :task [:bar] :percent', { - complete: '=', - incomplete: ' ', - total: total * this.tickParts, - width: 50, - clear: true, - }); - - this.tickingPrevious = { - message: '', - remainder: 0, - interval: null, - }; - } - - tick(message) { - const { - remainder: prevRemainder, - message: prevMessage, - interval: prevInterval, - } = this.tickingPrevious; - - if (prevRemainder) { - this.bar.tick(prevRemainder, { - task: prevMessage, - }); - clearInterval(prevInterval); - } - - const realRemainder = this.bar.total - this.bar.curr; - if (realRemainder === this.tickParts) { - this.bar.tick(this.tickParts, { - task: message, - }); - return; - } - - this.bar.tick({ - task: message, - }); - - this.tickingPrevious = { - message, - remainder: this.tickParts, - interval: null, - }; - - this.tickingPrevious.remainder -= 1; - - this.tickingPrevious.interval = setInterval(() => { - if (this.tickingPrevious.remainder === 1) { - clearInterval(this.tickingPrevious.interval); - return; - } - - this.bar.tick({ - task: message, - }); - this.tickingPrevious.remainder -= 1; - }, 200); - } -} - -export default DishonestProgress; diff --git a/src/helpers/helpers.js b/src/helpers/helpers.js deleted file mode 100644 index ba525b8..0000000 --- a/src/helpers/helpers.js +++ /dev/null @@ -1,107 +0,0 @@ -import os from 'os'; -import axios from 'axios'; -import hasBinary from 'hasbin'; -import path from 'path'; - -function isOSX() { - return os.platform() === 'darwin'; -} - -function isWindows() { - return os.platform() === 'win32'; -} - -function downloadFile(fileUrl) { - return axios - .get(fileUrl, { - responseType: 'arraybuffer', - }) - .then((response) => { - if (!response.data) { - return null; - } - return { - data: response.data, - ext: path.extname(fileUrl), - }; - }); -} - -function allowedIconFormats(platform) { - const hasIdentify = hasBinary.sync('identify'); - const hasConvert = hasBinary.sync('convert'); - const hasIconUtil = hasBinary.sync('iconutil'); - - const pngToIcns = hasConvert && hasIconUtil; - const pngToIco = hasConvert; - const icoToIcns = pngToIcns && hasIdentify; - const icoToPng = hasConvert; - - // todo scripts for the following - const icnsToPng = false; - const icnsToIco = false; - - const formats = []; - - // todo shell scripting is not supported on windows, temporary override - if (isWindows()) { - switch (platform) { - case 'darwin': - formats.push('.icns'); - break; - case 'linux': - formats.push('.png'); - break; - case 'win32': - formats.push('.ico'); - break; - default: - throw new Error( - `function allowedIconFormats error: Unknown platform ${platform}`, - ); - } - return formats; - } - - switch (platform) { - case 'darwin': - formats.push('.icns'); - if (pngToIcns) { - formats.push('.png'); - } - if (icoToIcns) { - formats.push('.ico'); - } - break; - case 'linux': - formats.push('.png'); - if (icoToPng) { - formats.push('.ico'); - } - if (icnsToPng) { - formats.push('.icns'); - } - break; - case 'win32': - formats.push('.ico'); - if (pngToIco) { - formats.push('.png'); - } - if (icnsToIco) { - formats.push('.icns'); - } - break; - default: - throw new Error( - `function allowedIconFormats error: Unknown platform ${platform}`, - ); - } - return formats; -} - -export default { - isOSX, - isWindows, - downloadFile, - allowedIconFormats, -}; diff --git a/src/helpers/helpers.ts b/src/helpers/helpers.ts new file mode 100644 index 0000000..3f7df8a --- /dev/null +++ b/src/helpers/helpers.ts @@ -0,0 +1,141 @@ +import * as os from 'os'; +import * as path from 'path'; + +import axios from 'axios'; +import * as hasbin from 'hasbin'; +import { ncp } from 'ncp'; +import * as log from 'loglevel'; +import * as tmp from 'tmp'; +tmp.setGracefulCleanup(); // cleanup temp dirs even when an uncaught exception occurs + +const now = new Date(); +const TMP_TIME = `${now.getHours()}-${now.getMinutes()}-${now.getSeconds()}`; + +type DownloadResult = { + data: Buffer; + ext: string; +}; + +export function isOSX(): boolean { + return os.platform() === 'darwin'; +} + +export function isWindows(): boolean { + return os.platform() === 'win32'; +} + +/** + * Create a temp directory with a debug-friendly name, and return its path. + * Will be automatically deleted on exit. + */ +export function getTempDir(prefix: string, mode?: number): string { + return tmp.dirSync({ + mode, + unsafeCleanup: true, // recursively remove tmp dir on exit, even if not empty. + prefix: `nativefier-${TMP_TIME}-${prefix}-`, + }).name; +} + +export async function copyFileOrDir( + sourceFileOrDir: string, + dest: string, +): Promise { + return new Promise((resolve, reject) => { + ncp(sourceFileOrDir, dest, (error: any) => { + if (error) { + reject(error); + } + resolve(); + }); + }); +} + +export async function downloadFile(fileUrl: string): Promise { + log.debug(`Downloading ${fileUrl}`); + return axios + .get(fileUrl, { + responseType: 'arraybuffer', + }) + .then((response) => { + if (!response.data) { + return null; + } + return { + data: response.data, + ext: path.extname(fileUrl), + }; + }); +} + +export function getAllowedIconFormats(platform: string): string[] { + const hasIdentify = hasbin.sync('identify'); + const hasConvert = hasbin.sync('convert'); + const hasIconUtil = hasbin.sync('iconutil'); + + const pngToIcns = hasConvert && hasIconUtil; + const pngToIco = hasConvert; + const icoToIcns = pngToIcns && hasIdentify; + const icoToPng = hasConvert; + + // Unsupported + const icnsToPng = false; + const icnsToIco = false; + + const formats = []; + + // Shell scripting is not supported on windows, temporary override + if (isWindows()) { + switch (platform) { + case 'darwin': + formats.push('.icns'); + break; + case 'linux': + formats.push('.png'); + break; + case 'win32': + formats.push('.ico'); + break; + default: + throw new Error(`Unknown platform ${platform}`); + } + log.debug( + `Allowed icon formats when building for ${platform} (limited on Windows):`, + formats, + ); + return formats; + } + + switch (platform) { + case 'darwin': + formats.push('.icns'); + if (pngToIcns) { + formats.push('.png'); + } + if (icoToIcns) { + formats.push('.ico'); + } + break; + case 'linux': + formats.push('.png'); + if (icoToPng) { + formats.push('.ico'); + } + if (icnsToPng) { + formats.push('.icns'); + } + break; + case 'win32': + formats.push('.ico'); + if (pngToIco) { + formats.push('.png'); + } + if (icnsToIco) { + formats.push('.icns'); + } + break; + default: + throw new Error(`Unknown platform ${platform}`); + } + log.debug(`Allowed icon formats when building for ${platform}:`, formats); + return formats; +} diff --git a/src/helpers/iconShellHelpers.js b/src/helpers/iconShellHelpers.js deleted file mode 100644 index 4259d72..0000000 --- a/src/helpers/iconShellHelpers.js +++ /dev/null @@ -1,102 +0,0 @@ -import shell from 'shelljs'; -import path from 'path'; -import tmp from 'tmp'; -import helpers from './helpers'; - -const { isWindows, isOSX } = helpers; - -tmp.setGracefulCleanup(); - -const SCRIPT_PATHS = { - singleIco: path.join(__dirname, '../..', 'bin/singleIco'), - convertToPng: path.join(__dirname, '../..', 'bin/convertToPng'), - convertToIco: path.join(__dirname, '../..', 'bin/convertToIco'), - convertToIcns: path.join(__dirname, '../..', 'bin/convertToIcns'), -}; - -/** - * Executes a shell script with the form "./pathToScript param1 param2" - * @param {string} shellScriptPath - * @param {string} icoSrc input .ico - * @param {string} dest has to be a .ico path - */ -function iconShellHelper(shellScriptPath, icoSrc, dest) { - return new Promise((resolve, reject) => { - if (isWindows()) { - reject(new Error('OSX or Linux is required')); - return; - } - - shell.exec( - `"${shellScriptPath}" "${icoSrc}" "${dest}"`, - { silent: true }, - (exitCode, stdOut, stdError) => { - if (exitCode) { - // eslint-disable-next-line prefer-promise-reject-errors - reject({ - stdOut, - stdError, - }); - return; - } - - resolve(dest); - }, - ); - }); -} - -function getTmpDirPath() { - const tempIconDirObj = tmp.dirSync({ unsafeCleanup: true }); - return tempIconDirObj.name; -} - -/** - * Converts the ico to a temporary directory which will be cleaned up on process exit - * @param {string} icoSrc path to a .ico file - * @return {Promise} - */ - -function singleIco(icoSrc) { - return iconShellHelper( - SCRIPT_PATHS.singleIco, - icoSrc, - `${getTmpDirPath()}/icon.ico`, - ); -} - -function convertToPng(icoSrc) { - return iconShellHelper( - SCRIPT_PATHS.convertToPng, - icoSrc, - `${getTmpDirPath()}/icon.png`, - ); -} - -function convertToIco(icoSrc) { - return iconShellHelper( - SCRIPT_PATHS.convertToIco, - icoSrc, - `${getTmpDirPath()}/icon.ico`, - ); -} - -function convertToIcns(icoSrc) { - if (!isOSX()) { - return new Promise((resolve, reject) => - reject(new Error('OSX is required to convert to a .icns icon')), - ); - } - return iconShellHelper( - SCRIPT_PATHS.convertToIcns, - icoSrc, - `${getTmpDirPath()}/icon.icns`, - ); -} - -export default { - singleIco, - convertToPng, - convertToIco, - convertToIcns, -}; diff --git a/src/helpers/iconShellHelpers.ts b/src/helpers/iconShellHelpers.ts new file mode 100644 index 0000000..059aca9 --- /dev/null +++ b/src/helpers/iconShellHelpers.ts @@ -0,0 +1,89 @@ +import * as path from 'path'; + +import * as shell from 'shelljs'; + +import { isWindows, isOSX, getTempDir } from './helpers'; +import * as log from 'loglevel'; + +const SCRIPT_PATHS = { + singleIco: path.join(__dirname, '../..', 'icon-scripts/singleIco'), + convertToPng: path.join(__dirname, '../..', 'icon-scripts/convertToPng'), + convertToIco: path.join(__dirname, '../..', 'icon-scripts/convertToIco'), + convertToIcns: path.join(__dirname, '../..', 'icon-scripts/convertToIcns'), +}; + +/** + * Executes a shell script with the form "./pathToScript param1 param2" + */ +async function iconShellHelper( + shellScriptPath: string, + icoSource: string, + icoDestination: string, +): Promise { + return new Promise((resolve, reject) => { + if (isWindows()) { + reject( + new Error( + 'Icon conversion only supported on macOS or Linux. ' + + 'If building for Windows, download/create a .ico and pass it with --icon favicon.ico . ' + + 'If building for macOS/Linux, do it from macOS/Linux', + ), + ); + return; + } + + const shellCommand = `"${shellScriptPath}" "${icoSource}" "${icoDestination}"`; + log.debug( + `Converting icon ${icoSource} to ${icoDestination}.`, + `Calling: ${shellCommand}`, + ); + shell.exec(shellCommand, { silent: true }, (exitCode, stdOut, stdError) => { + if (exitCode) { + reject({ + stdOut, + stdError, + }); + return; + } + + log.debug(`Conversion succeeded and produced icon at ${icoDestination}`); + resolve(icoDestination); + }); + }); +} + +export function singleIco(icoSrc: string): Promise { + return iconShellHelper( + SCRIPT_PATHS.singleIco, + icoSrc, + `${getTempDir('iconconv')}/icon.ico`, + ); +} + +export async function convertToPng(icoSrc: string): Promise { + return iconShellHelper( + SCRIPT_PATHS.convertToPng, + icoSrc, + `${getTempDir('iconconv')}/icon.png`, + ); +} + +export async function convertToIco(icoSrc: string): Promise { + return iconShellHelper( + SCRIPT_PATHS.convertToIco, + icoSrc, + `${getTempDir('iconconv')}/icon.ico`, + ); +} + +export async function convertToIcns(icoSrc: string): Promise { + if (!isOSX()) { + throw new Error('macOS is required to convert to a .icns icon'); + } + + return iconShellHelper( + SCRIPT_PATHS.convertToIcns, + icoSrc, + `${getTempDir('iconconv')}/icon.icns`, + ); +} diff --git a/src/helpers/packagerConsole.js b/src/helpers/packagerConsole.js deleted file mode 100644 index 72006f1..0000000 --- a/src/helpers/packagerConsole.js +++ /dev/null @@ -1,31 +0,0 @@ -// TODO: remove this file and use quiet mode of new version of electron packager -const log = require('loglevel'); - -class PackagerConsole { - constructor() { - this.logs = []; - } - - // eslint-disable-next-line no-underscore-dangle - _log(...messages) { - this.logs.push(...messages); - } - - override() { - this.consoleError = log.error; - - // need to bind because somehow when _log() is called this refers to console - // eslint-disable-next-line no-underscore-dangle - log.error = this._log.bind(this); - } - - restore() { - log.error = this.consoleError; - } - - playback() { - log.log(this.logs.join(' ')); - } -} - -export default PackagerConsole; diff --git a/src/index.js b/src/index.js deleted file mode 100644 index c7cb271..0000000 --- a/src/index.js +++ /dev/null @@ -1,6 +0,0 @@ -import 'source-map-support/register'; -import 'babel-polyfill'; - -import buildApp from './build/buildMain'; - -export default buildApp; diff --git a/src/infer/index.js b/src/infer/index.js deleted file mode 100644 index 0495ef0..0000000 --- a/src/infer/index.js +++ /dev/null @@ -1,4 +0,0 @@ -export { default as inferIcon } from './inferIcon'; -export { default as inferOs } from './inferOs'; -export { default as inferTitle } from './inferTitle'; -export { default as inferUserAgent } from './inferUserAgent'; diff --git a/src/infer/inferIcon.js b/src/infer/inferIcon.js deleted file mode 100644 index fa5f86b..0000000 --- a/src/infer/inferIcon.js +++ /dev/null @@ -1,130 +0,0 @@ -import pageIcon from 'page-icon'; -import path from 'path'; -import fs from 'fs'; -import tmp from 'tmp'; -import gitCloud from 'gitcloud'; -import helpers from '../helpers/helpers'; - -const { downloadFile, allowedIconFormats } = helpers; -tmp.setGracefulCleanup(); - -const GITCLOUD_SPACE_DELIMITER = '-'; - -function getMaxMatchScore(iconWithScores) { - return iconWithScores.reduce((maxScore, currentIcon) => { - const currentScore = currentIcon.score; - if (currentScore > maxScore) { - return currentScore; - } - return maxScore; - }, 0); -} - -/** - * also maps ext to icon object - */ -function getMatchingIcons(iconsWithScores, maxScore) { - return iconsWithScores - .filter((item) => item.score === maxScore) - .map((item) => Object.assign({}, item, { ext: path.extname(item.url) })); -} - -function mapIconWithMatchScore(fileIndex, targetUrl) { - const normalisedTargetUrl = targetUrl.toLowerCase(); - return fileIndex.map((item) => { - const itemWords = item.name.split(GITCLOUD_SPACE_DELIMITER); - const score = itemWords.reduce((currentScore, word) => { - if (normalisedTargetUrl.includes(word)) { - return currentScore + 1; - } - return currentScore; - }, 0); - - return Object.assign({}, item, { score }); - }); -} - -function inferIconFromStore(targetUrl, platform) { - const allowedFormats = new Set(allowedIconFormats(platform)); - - return gitCloud('https://jiahaog.github.io/nativefier-icons/').then( - (fileIndex) => { - const iconWithScores = mapIconWithMatchScore(fileIndex, targetUrl); - const maxScore = getMaxMatchScore(iconWithScores); - - if (maxScore === 0) { - return null; - } - - const iconsMatchingScore = getMatchingIcons(iconWithScores, maxScore); - const iconsMatchingExt = iconsMatchingScore.filter((icon) => - allowedFormats.has(icon.ext), - ); - const matchingIcon = iconsMatchingExt[0]; - const iconUrl = matchingIcon && matchingIcon.url; - - if (!iconUrl) { - return null; - } - return downloadFile(iconUrl); - }, - ); -} - -function writeFilePromise(outPath, data) { - return new Promise((resolve, reject) => { - fs.writeFile(outPath, data, (error) => { - if (error) { - reject(error); - return; - } - resolve(outPath); - }); - }); -} - -function inferFromPage(targetUrl, platform, outDir) { - let preferredExt = '.png'; - if (platform === 'win32') { - preferredExt = '.ico'; - } - - // todo might want to pass list of preferences instead - return pageIcon(targetUrl, { ext: preferredExt }).then((icon) => { - if (!icon) { - return null; - } - - const outfilePath = path.join(outDir, `/icon${icon.ext}`); - return writeFilePromise(outfilePath, icon.data); - }); -} - -/** - * - * @param {string} targetUrl - * @param {string} platform - * @param {string} outDir - */ -function inferIconFromUrlToPath(targetUrl, platform, outDir) { - return inferIconFromStore(targetUrl, platform).then((icon) => { - if (!icon) { - return inferFromPage(targetUrl, platform, outDir); - } - - const outfilePath = path.join(outDir, `/icon${icon.ext}`); - return writeFilePromise(outfilePath, icon.data); - }); -} - -/** - * @param {string} targetUrl - * @param {string} platform - */ -function inferIcon(targetUrl, platform) { - const tmpObj = tmp.dirSync({ unsafeCleanup: true }); - const tmpPath = tmpObj.name; - return inferIconFromUrlToPath(targetUrl, platform, tmpPath); -} - -export default inferIcon; diff --git a/src/infer/inferIcon.ts b/src/infer/inferIcon.ts new file mode 100644 index 0000000..9dee947 --- /dev/null +++ b/src/infer/inferIcon.ts @@ -0,0 +1,111 @@ +import * as path from 'path'; +import { writeFile } from 'fs'; +import { promisify } from 'util'; + +import * as gitCloud from 'gitcloud'; +import * as pageIcon from 'page-icon'; + +import { + downloadFile, + getAllowedIconFormats, + getTempDir, +} from '../helpers/helpers'; +import * as log from 'loglevel'; + +const writeFileAsync = promisify(writeFile); + +const GITCLOUD_SPACE_DELIMITER = '-'; +const GITCLOUD_URL = 'https://jiahaog.github.io/nativefier-icons/'; + +function getMaxMatchScore(iconWithScores: any[]): number { + const score = iconWithScores.reduce((maxScore, currentIcon) => { + const currentScore = currentIcon.score; + if (currentScore > maxScore) { + return currentScore; + } + return maxScore; + }, 0); + log.debug('Max icon match score:', score); + return score; +} + +function getMatchingIcons(iconsWithScores: any[], maxScore: number): any[] { + return iconsWithScores + .filter((item) => item.score === maxScore) + .map((item) => ({ ...item, ext: path.extname(item.url) })); +} + +function mapIconWithMatchScore(cloudIcons: any[], targetUrl: string): any { + const normalisedTargetUrl = targetUrl.toLowerCase(); + return cloudIcons.map((item) => { + const itemWords = item.name.split(GITCLOUD_SPACE_DELIMITER); + const score = itemWords.reduce((currentScore, word) => { + if (normalisedTargetUrl.includes(word)) { + return currentScore + 1; + } + return currentScore; + }, 0); + + return { ...item, score }; + }); +} + +async function inferIconFromStore( + targetUrl: string, + platform: string, +): Promise { + log.debug(`Inferring icon from store for ${targetUrl} on ${platform}`); + const allowedFormats = new Set(getAllowedIconFormats(platform)); + + const cloudIcons = await gitCloud(GITCLOUD_URL); + log.debug(`Got ${cloudIcons.length} icons from gitcloud`); + const iconWithScores = mapIconWithMatchScore(cloudIcons, targetUrl); + const maxScore = getMaxMatchScore(iconWithScores); + + if (maxScore === 0) { + log.debug('No relevant icon in store.'); + return null; + } + + const iconsMatchingScore = getMatchingIcons(iconWithScores, maxScore); + const iconsMatchingExt = iconsMatchingScore.filter((icon) => + allowedFormats.has(icon.ext), + ); + const matchingIcon = iconsMatchingExt[0]; + const iconUrl = matchingIcon && matchingIcon.url; + + if (!iconUrl) { + log.debug('Could not infer icon from store'); + return null; + } + return downloadFile(iconUrl); +} + +export async function inferIcon( + targetUrl: string, + platform: string, +): Promise { + log.debug(`Inferring icon for ${targetUrl} on ${platform}`); + const tmpDirPath = getTempDir('iconinfer'); + + let icon: { ext: string; data: Buffer } = await inferIconFromStore( + targetUrl, + platform, + ); + if (!icon) { + const ext = platform === 'win32' ? '.ico' : '.png'; + log.debug(`Trying to extract a ${ext} icon from the page.`); + icon = await pageIcon(targetUrl, { ext }); + } + if (!icon) { + return null; + } + log.debug(`Got an icon from the page.`); + + const iconPath = path.join(tmpDirPath, `/icon${icon.ext}`); + log.debug( + `Writing ${(icon.data.length / 1024).toFixed(1)} kb icon to ${iconPath}`, + ); + await writeFileAsync(iconPath, icon.data); + return iconPath; +} diff --git a/src/infer/inferOs.js b/src/infer/inferOs.ts similarity index 63% rename from src/infer/inferOs.js rename to src/infer/inferOs.ts index 7f639da..7bd9038 100644 --- a/src/infer/inferOs.js +++ b/src/infer/inferOs.ts @@ -1,28 +1,27 @@ -import os from 'os'; +import * as os from 'os'; +import * as log from 'loglevel'; -function inferPlatform() { +export function inferPlatform(): string { const platform = os.platform(); if ( platform === 'darwin' || + // @ts-ignore platform === 'mas' || platform === 'win32' || platform === 'linux' ) { + log.debug('Inferred platform', platform); return platform; } throw new Error(`Untested platform ${platform} detected`); } -function inferArch() { +export function inferArch(): string { const arch = os.arch(); if (arch !== 'ia32' && arch !== 'x64' && arch !== 'arm') { throw new Error(`Incompatible architecture ${arch} detected`); } + log.debug('Inferred arch', arch); return arch; } - -export default { - inferPlatform, - inferArch, -}; diff --git a/src/infer/inferTitle.js b/src/infer/inferTitle.js deleted file mode 100644 index 7636f93..0000000 --- a/src/infer/inferTitle.js +++ /dev/null @@ -1,25 +0,0 @@ -import axios from 'axios'; -import cheerio from 'cheerio'; - -const USER_AGENT = - 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2227.1 Safari/537.36'; - -function inferTitle(url) { - const options = { - method: 'get', - url, - headers: { - // fake a user agent because pages like http://messenger.com will throw 404 error - 'User-Agent': USER_AGENT, - }, - }; - - return axios(options).then(({ data }) => { - const $ = cheerio.load(data); - return $('title') - .first() - .text(); - }); -} - -export default inferTitle; diff --git a/src/infer/inferTitle.test.js b/src/infer/inferTitle.test.js deleted file mode 100644 index 700cc77..0000000 --- a/src/infer/inferTitle.test.js +++ /dev/null @@ -1,21 +0,0 @@ -import axios from 'axios'; -import inferTitle from './inferTitle'; - -jest.mock('axios', () => - jest.fn(() => - Promise.resolve({ - data: ` - - - TEST_TITLE - - `, - }), - ), -); - -test('it returns the correct title', async () => { - const result = await inferTitle('someurl'); - expect(axios).toHaveBeenCalledTimes(1); - expect(result).toBe('TEST_TITLE'); -}); diff --git a/src/infer/inferTitle.test.ts b/src/infer/inferTitle.test.ts new file mode 100644 index 0000000..c7153b7 --- /dev/null +++ b/src/infer/inferTitle.test.ts @@ -0,0 +1,19 @@ +import axios from 'axios'; + +import { inferTitle } from './inferTitle'; + +test('it returns the correct title', async () => { + const axiosGetMock = jest.spyOn(axios, 'get'); + axiosGetMock.mockResolvedValue({ + data: ` + + + TEST_TITLE + + `, + }); + const result = await inferTitle('someurl'); + + expect(axiosGetMock).toHaveBeenCalledTimes(1); + expect(result).toBe('TEST_TITLE'); +}); diff --git a/src/infer/inferTitle.ts b/src/infer/inferTitle.ts new file mode 100644 index 0000000..1eee360 --- /dev/null +++ b/src/infer/inferTitle.ts @@ -0,0 +1,23 @@ +import axios from 'axios'; +import * as cheerio from 'cheerio'; +import * as log from 'loglevel'; + +const USER_AGENT = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2227.1 Safari/537.36'; + +export async function inferTitle(url: string): Promise { + const { data } = await axios.get(url, { + headers: { + // Fake user agent for pages like http://messenger.com + 'User-Agent': USER_AGENT, + }, + }); + log.debug(`Fetched ${(data.length / 1024).toFixed(1)} kb page at`, url); + const $ = cheerio.load(data); + const inferredTitle = $('title') + .first() + .text(); + + log.debug('Inferred title:', inferredTitle); + return inferredTitle; +} diff --git a/src/infer/inferUserAgent.js b/src/infer/inferUserAgent.js deleted file mode 100644 index 44f78b6..0000000 --- a/src/infer/inferUserAgent.js +++ /dev/null @@ -1,69 +0,0 @@ -import axios from 'axios'; -import _ from 'lodash'; -import log from 'loglevel'; - -const ELECTRON_VERSIONS_URL = 'https://atom.io/download/atom-shell/index.json'; -const DEFAULT_CHROME_VERSION = '61.0.3163.100'; - -function getChromeVersionForElectronVersion( - electronVersion, - url = ELECTRON_VERSIONS_URL, -) { - return axios.get(url, { timeout: 5000 }).then((response) => { - if (response.status !== 200) { - throw new Error(`Bad request: Status code ${response.status}`); - } - - const { data } = response; - const electronVersionToChromeVersion = _.zipObject( - data.map((d) => d.version), - data.map((d) => d.chrome), - ); - - if (!(electronVersion in electronVersionToChromeVersion)) { - throw new Error( - `Electron version '${electronVersion}' not found in retrieved version list!`, - ); - } - - return electronVersionToChromeVersion[electronVersion]; - }); -} - -export function getUserAgentString(chromeVersion, platform) { - let userAgent; - switch (platform) { - case 'darwin': - case 'mas': - userAgent = `Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${chromeVersion} Safari/537.36`; - break; - case 'win32': - userAgent = `Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${chromeVersion} Safari/537.36`; - break; - case 'linux': - userAgent = `Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${chromeVersion} Safari/537.36`; - break; - default: - throw new Error( - 'Error invalid platform specified to getUserAgentString()', - ); - } - return userAgent; -} - -function inferUserAgent( - electronVersion, - platform, - url = ELECTRON_VERSIONS_URL, -) { - return getChromeVersionForElectronVersion(electronVersion, url) - .then((chromeVersion) => getUserAgentString(chromeVersion, platform)) - .catch(() => { - log.warn( - `Unable to infer chrome version for user agent, using ${DEFAULT_CHROME_VERSION}`, - ); - return getUserAgentString(DEFAULT_CHROME_VERSION, platform); - }); -} - -export default inferUserAgent; diff --git a/src/infer/inferUserAgent.test.js b/src/infer/inferUserAgent.test.js deleted file mode 100644 index 8ae0f2b..0000000 --- a/src/infer/inferUserAgent.test.js +++ /dev/null @@ -1,37 +0,0 @@ -import _ from 'lodash'; -import inferUserAgent from './inferUserAgent'; - -const TEST_RESULT = { - darwin: - 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/49.0.2623.75 Safari/537.36', - mas: - 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/49.0.2623.75 Safari/537.36', - win32: - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/49.0.2623.75 Safari/537.36', - linux: - 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/49.0.2623.75 Safari/537.36', -}; - -function testPlatform(platform) { - return expect(inferUserAgent('0.37.1', platform)).resolves.toBe( - TEST_RESULT[platform], - ); -} - -describe('Infer User Agent', () => { - test('Can infer userAgent for all platforms', async () => { - const testPromises = _.keys(TEST_RESULT).map((platform) => - testPlatform(platform), - ); - await Promise.all(testPromises); - }); - - test('Connection error will still get a user agent', async () => { - jest.setTimeout(6000); - - const TIMEOUT_URL = 'http://www.google.com:81/'; - await expect(inferUserAgent('1.6.7', 'darwin', TIMEOUT_URL)).resolves.toBe( - 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/61.0.3163.100 Safari/537.36', - ); - }); -}); diff --git a/src/infer/inferUserAgent.test.ts b/src/infer/inferUserAgent.test.ts new file mode 100644 index 0000000..8ac9d29 --- /dev/null +++ b/src/infer/inferUserAgent.test.ts @@ -0,0 +1,29 @@ +import { inferUserAgent } from './inferUserAgent'; +import { DEFAULT_ELECTRON_VERSION, DEFAULT_CHROME_VERSION } from '../constants'; + +const EXPECTED_USERAGENTS = { + darwin: `Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${DEFAULT_CHROME_VERSION} Safari/537.36`, + mas: `Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${DEFAULT_CHROME_VERSION} Safari/537.36`, + win32: `Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${DEFAULT_CHROME_VERSION} Safari/537.36`, + linux: `Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${DEFAULT_CHROME_VERSION} Safari/537.36`, +}; + +describe('Infer User Agent', () => { + test('Can infer userAgent for all platforms', async () => { + jest.setTimeout(10000); + for (const [arch, archUa] of Object.entries(EXPECTED_USERAGENTS)) { + const ua = await inferUserAgent(DEFAULT_ELECTRON_VERSION, arch); + expect(ua).toBe(archUa); + } + }); + + // TODO make fast by mocking timeout, and un-skip + test.skip('Connection error will still get a user agent', async () => { + jest.setTimeout(6000); + + const TIMEOUT_URL = 'http://www.google.com:81/'; + await expect(inferUserAgent('1.6.7', 'darwin', TIMEOUT_URL)).resolves.toBe( + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/61.0.3163.100 Safari/537.36', + ); + }); +}); diff --git a/src/infer/inferUserAgent.ts b/src/infer/inferUserAgent.ts new file mode 100644 index 0000000..4213e89 --- /dev/null +++ b/src/infer/inferUserAgent.ts @@ -0,0 +1,82 @@ +import * as _ from 'lodash'; +import axios from 'axios'; +import * as log from 'loglevel'; +import { DEFAULT_CHROME_VERSION } from '../constants'; + +const ELECTRON_VERSIONS_URL = 'https://atom.io/download/atom-shell/index.json'; + +async function getChromeVersionForElectronVersion( + electronVersion: string, + url = ELECTRON_VERSIONS_URL, +): Promise { + log.debug('Grabbing electron<->chrome versions file from', url); + const response = await axios.get(url, { timeout: 5000 }); + if (response.status !== 200) { + throw new Error(`Bad request: Status code ${response.status}`); + } + const { data } = response; + const electronVersionToChromeVersion: _.Dictionary = _.zipObject( + data.map((d) => d.version), + data.map((d) => d.chrome), + ); + if (!(electronVersion in electronVersionToChromeVersion)) { + throw new Error( + `Electron version '${electronVersion}' not found in retrieved version list!`, + ); + } + const chromeVersion = electronVersionToChromeVersion[electronVersion]; + log.debug( + `Associated electron v${electronVersion} to chrome v${chromeVersion}`, + ); + return chromeVersion; +} + +export function getUserAgentString( + chromeVersion: string, + platform: string, +): string { + let userAgent: string; + switch (platform) { + case 'darwin': + case 'mas': + userAgent = `Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${chromeVersion} Safari/537.36`; + break; + case 'win32': + userAgent = `Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${chromeVersion} Safari/537.36`; + break; + case 'linux': + userAgent = `Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${chromeVersion} Safari/537.36`; + break; + default: + throw new Error( + 'Error invalid platform specified to getUserAgentString()', + ); + } + log.debug( + `Given chrome ${chromeVersion} on ${platform},`, + `using user agent: ${userAgent}`, + ); + return userAgent; +} + +export async function inferUserAgent( + electronVersion: string, + platform: string, + url = ELECTRON_VERSIONS_URL, +): Promise { + log.debug( + `Inferring user agent for electron ${electronVersion} / ${platform}`, + ); + try { + const chromeVersion = await getChromeVersionForElectronVersion( + electronVersion, + url, + ); + return getUserAgentString(chromeVersion, platform); + } catch (e) { + log.warn( + `Unable to infer chrome version for user agent, using ${DEFAULT_CHROME_VERSION}`, + ); + return getUserAgentString(DEFAULT_CHROME_VERSION, platform); + } +} diff --git a/src/integration-test.ts b/src/integration-test.ts new file mode 100644 index 0000000..25eb164 --- /dev/null +++ b/src/integration-test.ts @@ -0,0 +1,57 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +import { getTempDir } from './helpers/helpers'; +import { buildNativefierApp } from './main'; + +function checkApp(appRoot: string, inputOptions: any): void { + let relativeAppFolder: string; + + switch (inputOptions.platform) { + case 'darwin': + relativeAppFolder = path.join('Google.app', 'Contents/Resources/app'); + break; + case 'linux': + relativeAppFolder = 'resources/app'; + break; + case 'win32': + relativeAppFolder = 'resources/app'; + break; + default: + throw new Error('Unknown app platform'); + } + + const appPath = path.join(appRoot, relativeAppFolder); + + const configPath = path.join(appPath, 'nativefier.json'); + const nativefierConfig = JSON.parse(fs.readFileSync(configPath).toString()); + expect(inputOptions.targetUrl).toBe(nativefierConfig.targetUrl); + + // Test name inferring + expect(nativefierConfig.name).toBe('Google'); + + // Test icon writing + const iconFile = + inputOptions.platform === 'darwin' ? '../electron.icns' : 'icon.png'; + const iconPath = path.join(appPath, iconFile); + expect(fs.existsSync(iconPath)).toBe(true); + expect(fs.statSync(iconPath).size).toBeGreaterThan(1000); +} + +describe('Nativefier', () => { + jest.setTimeout(300000); + + test('builds a Nativefier app for several platforms', async () => { + for (const platform of ['darwin', 'linux']) { + const tempDirectory = getTempDir('integtest'); + const options = { + targetUrl: 'https://google.com/', + out: tempDirectory, + overwrite: true, + platform, + }; + const appPath = await buildNativefierApp(options); + checkApp(appPath, options); + } + }); +}); diff --git a/src/jestSetupFiles.ts b/src/jestSetupFiles.ts new file mode 100644 index 0000000..c2cb9fd --- /dev/null +++ b/src/jestSetupFiles.ts @@ -0,0 +1,7 @@ +import * as log from 'loglevel'; + +if (process.env.LOGLEVEL) { + log.setLevel(process.env.LOGLEVEL as log.LogLevelDesc); +} else { + log.disableAll(); +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..db5b1b2 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,20 @@ +import 'source-map-support/register'; + +import { buildNativefierApp } from './build/buildNativefierApp'; + +export { buildNativefierApp }; + +/** + * Only for compatibility with Nativefier <= 7.7.1 ! + * Use the better, modern async `buildNativefierApp` instead if you can! + */ +function buildNativefierAppOldCallbackStyle( + options: any, + callback: (err: any, result?: any) => void, +): void { + buildNativefierApp(options) + .then((result) => callback(null, result)) + .catch((err) => callback(err)); +} + +export default buildNativefierAppOldCallbackStyle; diff --git a/src/options/asyncConfig.js b/src/options/asyncConfig.js deleted file mode 100644 index 597ea84..0000000 --- a/src/options/asyncConfig.js +++ /dev/null @@ -1,22 +0,0 @@ -import fields from './fields'; - -function resultArrayToObject(fieldResults) { - return fieldResults.reduce( - (accumulator, value) => Object.assign({}, accumulator, value), - {}, - ); -} - -function inferredOptions(oldOptions, fieldResults) { - const newOptions = resultArrayToObject(fieldResults); - return Object.assign({}, oldOptions, newOptions); -} - -// Takes the options object and infers new values -// which may need async work -export default function(options) { - const tasks = fields(options); - return Promise.all(tasks).then((fieldResults) => - inferredOptions(options, fieldResults), - ); -} diff --git a/src/options/asyncConfig.test.js b/src/options/asyncConfig.test.js deleted file mode 100644 index 11b64f4..0000000 --- a/src/options/asyncConfig.test.js +++ /dev/null @@ -1,18 +0,0 @@ -import asyncConfig from './asyncConfig'; -import fields from './fields'; - -jest.mock('./fields'); - -fields.mockImplementation(() => [ - Promise.resolve({ - someField: 'newValue', - }), -]); - -test('it should merge the result of the promise', async () => { - const param = { another: 'field', someField: 'oldValue' }; - const expected = { another: 'field', someField: 'newValue' }; - - const result = await asyncConfig(param); - expect(result).toEqual(expected); -}); diff --git a/src/options/asyncConfig.ts b/src/options/asyncConfig.ts new file mode 100644 index 0000000..7e4edec --- /dev/null +++ b/src/options/asyncConfig.ts @@ -0,0 +1,12 @@ +import * as log from 'loglevel'; + +import { processOptions } from './fields/fields'; +import { AppOptions } from './model'; + +/** + * Takes the options object and infers new values needing async work + */ +export async function asyncConfig(options: AppOptions): Promise { + log.debug('\nPerforming async options post-processing.'); + await processOptions(options); +} diff --git a/src/options/fields/fields.test.ts b/src/options/fields/fields.test.ts new file mode 100644 index 0000000..7ac9357 --- /dev/null +++ b/src/options/fields/fields.test.ts @@ -0,0 +1,36 @@ +import { processOptions } from './fields'; + +test('fully-defined async options are returned as-is', async () => { + const options = { + packager: { + icon: '/my/icon.png', + name: 'my beautiful app ', + targetUrl: 'https://myurl.com', + dir: '/tmp/myapp', + }, + nativefier: { userAgent: 'random user agent' }, + }; + // @ts-ignore + await processOptions(options); + + expect(options.packager.icon).toEqual('/my/icon.png'); + expect(options.packager.name).toEqual('my beautiful app'); + expect(options.nativefier.userAgent).toEqual('random user agent'); +}); + +test('user agent is inferred if not passed', async () => { + const options = { + packager: { + icon: '/my/icon.png', + name: 'my beautiful app ', + targetUrl: 'https://myurl.com', + dir: '/tmp/myapp', + platform: 'linux', + }, + nativefier: { userAgent: undefined }, + }; + // @ts-ignore + await processOptions(options); + + expect(options.nativefier.userAgent).toMatch(/Linux.*Chrome/); +}); diff --git a/src/options/fields/fields.ts b/src/options/fields/fields.ts new file mode 100644 index 0000000..0ff7718 --- /dev/null +++ b/src/options/fields/fields.ts @@ -0,0 +1,29 @@ +import { icon } from './icon'; +import { userAgent } from './userAgent'; +import { AppOptions } from '../model'; +import { name } from './name'; + +const OPTION_POSTPROCESSORS = [ + { namespace: 'nativefier', option: 'userAgent', processor: userAgent }, + { namespace: 'packager', option: 'icon', processor: icon }, + { namespace: 'packager', option: 'name', processor: name }, +]; + +export async function processOptions(options: AppOptions): Promise { + const processedOptions = await Promise.all( + OPTION_POSTPROCESSORS.map(async ({ namespace, option, processor }) => { + const result = await processor(options); + return { + namespace, + option, + result, + }; + }), + ); + + for (const { namespace, option, result } of processedOptions) { + if (result !== null) { + options[namespace][option] = result; + } + } +} diff --git a/src/options/fields/icon.js b/src/options/fields/icon.js deleted file mode 100644 index 9303685..0000000 --- a/src/options/fields/icon.js +++ /dev/null @@ -1,14 +0,0 @@ -import log from 'loglevel'; -import { inferIcon } from '../../infer'; - -export default function({ icon, targetUrl, platform }) { - // Icon is the path to the icon - if (icon) { - return Promise.resolve(icon); - } - - return inferIcon(targetUrl, platform).catch((error) => { - log.warn('Cannot automatically retrieve the app icon:', error); - return null; - }); -} diff --git a/src/options/fields/icon.test.js b/src/options/fields/icon.test.js deleted file mode 100644 index 35f1c07..0000000 --- a/src/options/fields/icon.test.js +++ /dev/null @@ -1,43 +0,0 @@ -import log from 'loglevel'; -import icon from './icon'; -import { inferIcon } from '../../infer'; - -jest.mock('./../../infer/inferIcon'); -jest.mock('loglevel'); - -const mockedResult = 'icon path'; - -describe('when the icon parameter is passed', () => { - test('it should return the icon parameter', async () => { - expect(inferIcon).toHaveBeenCalledTimes(0); - - const params = { icon: './icon.png' }; - await expect(icon(params)).resolves.toBe(params.icon); - }); -}); - -describe('when the icon parameter is not passed', () => { - test('it should call inferIcon', async () => { - inferIcon.mockImplementationOnce(() => Promise.resolve(mockedResult)); - const params = { targetUrl: 'some url', platform: 'mac' }; - - const result = await icon(params); - - expect(result).toBe(mockedResult); - expect(inferIcon).toHaveBeenCalledWith(params.targetUrl, params.platform); - }); - - describe('when inferIcon resolves with an error', () => { - test('it should handle the error', async () => { - inferIcon.mockImplementationOnce(() => - Promise.reject(new Error('some error')), - ); - const params = { targetUrl: 'some url', platform: 'mac' }; - - const result = await icon(params); - expect(result).toBe(null); - expect(inferIcon).toHaveBeenCalledWith(params.targetUrl, params.platform); - expect(log.warn).toHaveBeenCalledTimes(1); - }); - }); -}); diff --git a/src/options/fields/icon.test.ts b/src/options/fields/icon.test.ts new file mode 100644 index 0000000..ca1f300 --- /dev/null +++ b/src/options/fields/icon.test.ts @@ -0,0 +1,60 @@ +import * as log from 'loglevel'; + +import { icon } from './icon'; +import { inferIcon } from '../../infer/inferIcon'; + +jest.mock('./../../infer/inferIcon'); +jest.mock('loglevel'); + +const mockedResult = 'icon path'; +const ICON_PARAMS_PROVIDED = { + packager: { + icon: './icon.png', + targetUrl: 'https://google.com', + platform: 'mac', + }, +}; +const ICON_PARAMS_NEEDS_INFER = { + packager: { + targetUrl: 'https://google.com', + platform: 'mac', + }, +}; + +describe('when the icon parameter is passed', () => { + test('it should return the icon parameter', async () => { + expect(inferIcon).toHaveBeenCalledTimes(0); + await expect(icon(ICON_PARAMS_PROVIDED)).resolves.toBe(null); + }); +}); + +describe('when the icon parameter is not passed', () => { + test('it should call inferIcon', async () => { + (inferIcon as jest.Mock).mockImplementationOnce(() => + Promise.resolve(mockedResult), + ); + const result = await icon(ICON_PARAMS_NEEDS_INFER); + + expect(result).toBe(mockedResult); + expect(inferIcon).toHaveBeenCalledWith( + ICON_PARAMS_NEEDS_INFER.packager.targetUrl, + ICON_PARAMS_NEEDS_INFER.packager.platform, + ); + }); + + describe('when inferIcon resolves with an error', () => { + test('it should handle the error', async () => { + (inferIcon as jest.Mock).mockImplementationOnce(() => + Promise.reject(new Error('some error')), + ); + const result = await icon(ICON_PARAMS_NEEDS_INFER); + + expect(result).toBe(null); + expect(inferIcon).toHaveBeenCalledWith( + ICON_PARAMS_NEEDS_INFER.packager.targetUrl, + ICON_PARAMS_NEEDS_INFER.packager.platform, + ); + expect(log.warn).toHaveBeenCalledTimes(1); // eslint-disable-line @typescript-eslint/unbound-method + }); + }); +}); diff --git a/src/options/fields/icon.ts b/src/options/fields/icon.ts new file mode 100644 index 0000000..1a568d4 --- /dev/null +++ b/src/options/fields/icon.ts @@ -0,0 +1,28 @@ +import * as log from 'loglevel'; + +import { inferIcon } from '../../infer/inferIcon'; + +type IconParams = { + packager: { + icon?: string; + targetUrl: string; + platform?: string; + }; +}; + +export async function icon(options: IconParams): Promise { + if (options.packager.icon) { + log.debug('Got icon from options. Using it, no inferring needed'); + return null; + } + + try { + return await inferIcon( + options.packager.targetUrl, + options.packager.platform, + ); + } catch (error) { + log.warn('Cannot automatically retrieve the app icon:', error); + return null; + } +} diff --git a/src/options/fields/index.js b/src/options/fields/index.js deleted file mode 100644 index e8cd312..0000000 --- a/src/options/fields/index.js +++ /dev/null @@ -1,32 +0,0 @@ -import icon from './icon'; -import userAgent from './userAgent'; -import name from './name'; - -const fields = [ - { - field: 'userAgent', - task: userAgent, - }, - { - field: 'icon', - task: icon, - }, - { - field: 'name', - task: name, - }, -]; - -// Modifies the result of each promise from a scalar -// value to a object containing its fieldname -function wrap(fieldName, promise, args) { - return promise(args).then((result) => ({ - [fieldName]: result, - })); -} - -// Returns a list of promises which will all resolve -// with the following result: {[fieldName]: fieldvalue} -export default function(options) { - return fields.map(({ field, task }) => wrap(field, task, options)); -} diff --git a/src/options/fields/index.test.js b/src/options/fields/index.test.js deleted file mode 100644 index 045857e..0000000 --- a/src/options/fields/index.test.js +++ /dev/null @@ -1,21 +0,0 @@ -import fields from './index'; -import icon from './icon'; -import userAgent from './userAgent'; -import name from './name'; - -jest.mock('./icon'); -jest.mock('./name'); -jest.mock('./userAgent'); - -const modules = [icon, userAgent, name]; -modules.forEach((module) => { - module.mockImplementation(() => Promise.resolve()); -}); - -test('it should return a list of promises', () => { - const result = fields({}); - expect(result).toHaveLength(3); - result.forEach((value) => { - expect(value).toBeInstanceOf(Promise); - }); -}); diff --git a/src/options/fields/name.js b/src/options/fields/name.js deleted file mode 100644 index 62c8c21..0000000 --- a/src/options/fields/name.js +++ /dev/null @@ -1,26 +0,0 @@ -import log from 'loglevel'; -import { sanitizeFilename } from '../../utils'; -import { inferTitle } from '../../infer'; -import { DEFAULT_APP_NAME } from '../../constants'; - -function tryToInferName({ name, targetUrl }) { - // .length also checks if its the commanderJS function or a string - if (name && name.length > 0) { - return Promise.resolve(name); - } - - return inferTitle(targetUrl) - .then((pageTitle) => pageTitle || DEFAULT_APP_NAME) - .catch((error) => { - log.warn( - `Unable to automatically determine app name, falling back to '${DEFAULT_APP_NAME}'. Reason: ${error}`, - ); - return DEFAULT_APP_NAME; - }); -} - -export default function({ platform, name, targetUrl }) { - return tryToInferName({ name, targetUrl }).then((result) => - sanitizeFilename(platform, result), - ); -} diff --git a/src/options/fields/name.test.js b/src/options/fields/name.test.js deleted file mode 100644 index 12673ec..0000000 --- a/src/options/fields/name.test.js +++ /dev/null @@ -1,93 +0,0 @@ -import log from 'loglevel'; -import name from './name'; -import { DEFAULT_APP_NAME } from '../../constants'; -import { inferTitle } from '../../infer'; -import { sanitizeFilename } from '../../utils'; - -jest.mock('./../../infer/inferTitle'); -jest.mock('./../../utils/sanitizeFilename'); -jest.mock('loglevel'); - -sanitizeFilename.mockImplementation((_, filename) => filename); - -const mockedResult = 'mock name'; - -describe('well formed name parameters', () => { - const params = { name: 'appname', platform: 'something' }; - test('it should not call inferTitle', async () => { - const result = await name(params); - - expect(inferTitle).toHaveBeenCalledTimes(0); - expect(result).toBe(params.name); - }); - - test('it should call sanitize filename', async () => { - const result = await name(params); - expect(sanitizeFilename).toHaveBeenCalledWith(params.platform, result); - }); -}); - -describe('bad name parameters', () => { - beforeEach(() => { - inferTitle.mockImplementationOnce(() => Promise.resolve(mockedResult)); - }); - - const params = { targetUrl: 'some url' }; - describe('when the name is undefined', () => { - test('it should call inferTitle', async () => { - await name(params); - expect(inferTitle).toHaveBeenCalledWith(params.targetUrl); - }); - }); - - describe('when the name is an empty string', () => { - test('it should call inferTitle', async () => { - const testParams = { - ...params, - name: '', - }; - - await name(testParams); - expect(inferTitle).toHaveBeenCalledWith(params.targetUrl); - }); - }); - - test('it should call sanitize filename', () => - name(params).then((result) => { - expect(sanitizeFilename).toHaveBeenCalledWith(params.platform, result); - })); -}); - -describe('handling inferTitle results', () => { - const params = { targetUrl: 'some url', name: '', platform: 'something' }; - test('it should return the result from inferTitle', async () => { - inferTitle.mockImplementationOnce(() => Promise.resolve(mockedResult)); - - const result = await name(params); - expect(result).toBe(mockedResult); - expect(inferTitle).toHaveBeenCalledWith(params.targetUrl); - }); - - describe('when the returned pageTitle is falsey', () => { - test('it should return the default app name', async () => { - inferTitle.mockImplementationOnce(() => Promise.resolve(null)); - - const result = await name(params); - expect(result).toBe(DEFAULT_APP_NAME); - expect(inferTitle).toHaveBeenCalledWith(params.targetUrl); - }); - }); - - describe('when inferTitle resolves with an error', () => { - test('it should return the default app name', async () => { - inferTitle.mockImplementationOnce(() => - Promise.reject(new Error('some error')), - ); - - const result = await name(params); - expect(result).toBe(DEFAULT_APP_NAME); - expect(inferTitle).toHaveBeenCalledWith(params.targetUrl); - expect(log.warn).toHaveBeenCalledTimes(1); - }); - }); -}); diff --git a/src/options/fields/name.test.ts b/src/options/fields/name.test.ts new file mode 100644 index 0000000..c7758c4 --- /dev/null +++ b/src/options/fields/name.test.ts @@ -0,0 +1,108 @@ +import * as log from 'loglevel'; + +import { name } from './name'; +import { DEFAULT_APP_NAME } from '../../constants'; +import { inferTitle } from '../../infer/inferTitle'; +import { sanitizeFilename } from '../../utils/sanitizeFilename'; + +jest.mock('./../../infer/inferTitle'); +jest.mock('./../../utils/sanitizeFilename'); +jest.mock('loglevel'); + +const inferTitleMockedResult = 'mock name'; +const NAME_PARAMS_PROVIDED = { + packager: { + name: 'appname', + targetUrl: 'https://google.com', + platform: 'linux', + }, +}; +const NAME_PARAMS_NEEDS_INFER = { + packager: { + targetUrl: 'https://google.com', + platform: 'mac', + }, +}; +beforeAll(() => { + (sanitizeFilename as jest.Mock).mockImplementation((_, filename) => filename); +}); + +describe('well formed name parameters', () => { + test('it should not call inferTitle', async () => { + const result = await name(NAME_PARAMS_PROVIDED); + + expect(inferTitle).toHaveBeenCalledTimes(0); + expect(result).toBe(NAME_PARAMS_PROVIDED.packager.name); + }); + + test('it should call sanitize filename', async () => { + const result = await name(NAME_PARAMS_PROVIDED); + + expect(sanitizeFilename).toHaveBeenCalledWith( + NAME_PARAMS_PROVIDED.packager.platform, + result, + ); + }); +}); + +describe('bad name parameters', () => { + beforeEach(() => { + (inferTitle as jest.Mock).mockResolvedValue(inferTitleMockedResult); + }); + + const params = { packager: { targetUrl: 'some url', platform: 'whatever' } }; + test('it should call inferTitle when the name is undefined', async () => { + await name(params); + expect(inferTitle).toHaveBeenCalledWith(params.packager.targetUrl); + }); + + test('it should call inferTitle when the name is an empty string', async () => { + const testParams = { + ...params, + name: '', + }; + + await name(testParams); + expect(inferTitle).toHaveBeenCalledWith(params.packager.targetUrl); + }); + + test('it should call sanitize filename', async () => { + const result = await name(params); + expect(sanitizeFilename).toHaveBeenCalledWith( + params.packager.platform, + result, + ); + }); +}); + +describe('handling inferTitle results', () => { + test('it should return the result from inferTitle', async () => { + const result = await name(NAME_PARAMS_NEEDS_INFER); + + expect(result).toEqual(inferTitleMockedResult); + expect(inferTitle).toHaveBeenCalledWith( + NAME_PARAMS_NEEDS_INFER.packager.targetUrl, + ); + }); + + test('it should return the default app name when the returned pageTitle is falsey', async () => { + (inferTitle as jest.Mock).mockResolvedValue(null); + const result = await name(NAME_PARAMS_NEEDS_INFER); + + expect(result).toEqual(DEFAULT_APP_NAME); + expect(inferTitle).toHaveBeenCalledWith( + NAME_PARAMS_NEEDS_INFER.packager.targetUrl, + ); + }); + + test('it should return the default app name when inferTitle rejects', async () => { + (inferTitle as jest.Mock).mockRejectedValue('some error'); + const result = await name(NAME_PARAMS_NEEDS_INFER); + + expect(result).toEqual(DEFAULT_APP_NAME); + expect(inferTitle).toHaveBeenCalledWith( + NAME_PARAMS_NEEDS_INFER.packager.targetUrl, + ); + expect(log.warn).toHaveBeenCalledTimes(1); // eslint-disable-line @typescript-eslint/unbound-method + }); +}); diff --git a/src/options/fields/name.ts b/src/options/fields/name.ts new file mode 100644 index 0000000..d63208f --- /dev/null +++ b/src/options/fields/name.ts @@ -0,0 +1,38 @@ +import * as log from 'loglevel'; + +import { sanitizeFilename } from '../../utils/sanitizeFilename'; +import { inferTitle } from '../../infer/inferTitle'; +import { DEFAULT_APP_NAME } from '../../constants'; + +type NameParams = { + packager: { + name?: string; + platform?: string; + targetUrl: string; + }; +}; + +async function tryToInferName(targetUrl: string): Promise { + try { + log.debug('Inferring name for', targetUrl); + const pageTitle = await inferTitle(targetUrl); + return pageTitle || DEFAULT_APP_NAME; + } catch (error) { + log.warn( + `Unable to automatically determine app name, falling back to '${DEFAULT_APP_NAME}'. Reason: ${error}`, + ); + return DEFAULT_APP_NAME; + } +} + +export async function name(options: NameParams): Promise { + if (options.packager.name) { + log.debug( + `Got name ${options.packager.name} from options. No inferring needed`, + ); + return sanitizeFilename(options.packager.platform, options.packager.name); + } + + const inferredName = await tryToInferName(options.packager.targetUrl); + return sanitizeFilename(options.packager.platform, inferredName); +} diff --git a/src/options/fields/userAgent.js b/src/options/fields/userAgent.js deleted file mode 100644 index 510d4b0..0000000 --- a/src/options/fields/userAgent.js +++ /dev/null @@ -1,9 +0,0 @@ -import { inferUserAgent } from '../../infer'; - -export default function({ userAgent, electronVersion, platform }) { - if (userAgent) { - return Promise.resolve(userAgent); - } - - return inferUserAgent(electronVersion, platform); -} diff --git a/src/options/fields/userAgent.test.js b/src/options/fields/userAgent.test.js deleted file mode 100644 index eed6c62..0000000 --- a/src/options/fields/userAgent.test.js +++ /dev/null @@ -1,20 +0,0 @@ -import userAgent from './userAgent'; -import { inferUserAgent } from '../../infer'; - -jest.mock('./../../infer/inferUserAgent'); - -test('when a userAgent parameter is passed', async () => { - expect(inferUserAgent).toHaveBeenCalledTimes(0); - - const params = { userAgent: 'valid user agent' }; - await expect(userAgent(params)).resolves.toBe(params.userAgent); -}); - -test('no userAgent parameter is passed', async () => { - const params = { electronVersion: '123', platform: 'mac' }; - await userAgent(params); - expect(inferUserAgent).toHaveBeenCalledWith( - params.electronVersion, - params.platform, - ); -}); diff --git a/src/options/fields/userAgent.test.ts b/src/options/fields/userAgent.test.ts new file mode 100644 index 0000000..fdc8b81 --- /dev/null +++ b/src/options/fields/userAgent.test.ts @@ -0,0 +1,26 @@ +import { userAgent } from './userAgent'; +import { inferUserAgent } from '../../infer/inferUserAgent'; + +jest.mock('./../../infer/inferUserAgent'); + +test('when a userAgent parameter is passed', async () => { + expect(inferUserAgent).toHaveBeenCalledTimes(0); + + const params = { + packager: {}, + nativefier: { userAgent: 'valid user agent' }, + }; + await expect(userAgent(params)).resolves.toBe(null); +}); + +test('no userAgent parameter is passed', async () => { + const params = { + packager: { electronVersion: '123', platform: 'mac' }, + nativefier: {}, + }; + await userAgent(params); + expect(inferUserAgent).toHaveBeenCalledWith( + params.packager.electronVersion, + params.packager.platform, + ); +}); diff --git a/src/options/fields/userAgent.ts b/src/options/fields/userAgent.ts new file mode 100644 index 0000000..1fd5f65 --- /dev/null +++ b/src/options/fields/userAgent.ts @@ -0,0 +1,22 @@ +import { inferUserAgent } from '../../infer/inferUserAgent'; + +type UserAgentOpts = { + packager: { + electronVersion?: string; + platform?: string; + }; + nativefier: { + userAgent?: string; + }; +}; + +export async function userAgent(options: UserAgentOpts): Promise { + if (options.nativefier.userAgent) { + return null; + } + + return inferUserAgent( + options.packager.electronVersion, + options.packager.platform, + ); +} diff --git a/src/options/model.ts b/src/options/model.ts new file mode 100644 index 0000000..4d3ea4e --- /dev/null +++ b/src/options/model.ts @@ -0,0 +1,56 @@ +import * as electronPackager from 'electron-packager'; + +export interface ElectronPackagerOptions extends electronPackager.Options { + targetUrl: string; +} + +export interface AppOptions { + packager: ElectronPackagerOptions; + nativefier: { + alwaysOnTop: boolean; + backgroundColor: string; + basicAuthPassword: string; + basicAuthUsername: string; + bounce: boolean; + browserwindowOptions: any; + clearCache: boolean; + counter: boolean; + crashReporter: string; + disableContextMenu: boolean; + disableDevTools: boolean; + disableGpu: boolean; + diskCacheSize: number; + enableEs3Apis: boolean; + fastQuit: boolean; + fileDownloadOptions: any; + flashPluginDir: string; + fullScreen: boolean; + globalShortcuts: any; + hideWindowFrame: boolean; + ignoreCertificate: boolean; + ignoreGpuBlacklist: boolean; + inject: string[]; + insecure: boolean; + internalUrls: string; + maximize: boolean; + nativefierVersion: string; + processEnvs: string; + proxyRules: string; + showMenuBar: boolean; + singleInstance: boolean; + titleBarStyle: string; + tray: string | boolean; + userAgent: string; + verbose: boolean; + versionString: string; + width: number; + height: number; + minWidth: number; + minHeight: number; + maxWidth: number; + maxHeight: number; + x: number; + y: number; + zoom: number; + }; +} diff --git a/src/options/normalizeUrl.js b/src/options/normalizeUrl.js deleted file mode 100644 index d4d487c..0000000 --- a/src/options/normalizeUrl.js +++ /dev/null @@ -1,26 +0,0 @@ -import url from 'url'; -import validator from 'validator'; - -function appendProtocol(testUrl) { - const parsed = url.parse(testUrl); - if (!parsed.protocol) { - return `http://${testUrl}`; - } - return testUrl; -} - -function normalizeUrl(testUrl) { - const urlWithProtocol = appendProtocol(testUrl); - - const validatorOptions = { - require_protocol: true, - require_tld: false, - allow_trailing_dot: true, // mDNS addresses, https://github.com/jiahaog/nativefier/issues/308 - }; - if (!validator.isURL(urlWithProtocol, validatorOptions)) { - throw new Error(`Your Url: "${urlWithProtocol}" is invalid!`); - } - return urlWithProtocol; -} - -export default normalizeUrl; diff --git a/src/options/normalizeUrl.test.js b/src/options/normalizeUrl.test.js deleted file mode 100644 index 7c36356..0000000 --- a/src/options/normalizeUrl.test.js +++ /dev/null @@ -1,17 +0,0 @@ -import normalizeUrl from './normalizeUrl'; - -test("a proper URL shouldn't be mangled", () => { - expect(normalizeUrl('http://www.google.com')).toEqual( - 'http://www.google.com', - ); -}); - -test('missing protocol should default to http', () => { - expect(normalizeUrl('www.google.com')).toEqual('http://www.google.com'); -}); - -test("a proper URL shouldn't be mangled", () => { - expect(() => { - normalizeUrl('http://ssddfoo bar'); - }).toThrow('Your Url: "http://ssddfoo bar" is invalid!'); -}); diff --git a/src/options/normalizeUrl.test.ts b/src/options/normalizeUrl.test.ts new file mode 100644 index 0000000..2788b3d --- /dev/null +++ b/src/options/normalizeUrl.test.ts @@ -0,0 +1,17 @@ +import { normalizeUrl } from './normalizeUrl'; + +test("a proper URL shouldn't be mangled", () => { + expect(normalizeUrl('http://www.google.com')).toEqual( + 'http://www.google.com/', + ); +}); + +test('missing protocol should default to https', () => { + expect(normalizeUrl('www.google.com')).toEqual('https://www.google.com/'); +}); + +test("a proper URL shouldn't be mangled", () => { + expect(() => { + normalizeUrl('http://ssddfoo bar'); + }).toThrow('Your url "http://ssddfoo bar" is invalid'); +}); diff --git a/src/options/normalizeUrl.ts b/src/options/normalizeUrl.ts new file mode 100644 index 0000000..e513b19 --- /dev/null +++ b/src/options/normalizeUrl.ts @@ -0,0 +1,31 @@ +import * as url from 'url'; + +import * as log from 'loglevel'; + +function appendProtocol(inputUrl: string): string { + const parsed = url.parse(inputUrl); + if (!parsed.protocol) { + const urlWithProtocol = `https://${inputUrl}`; + log.warn( + `URL "${inputUrl}" lacks a protocol.`, + `Will try to parse it as HTTPS: "${urlWithProtocol}".`, + `Please pass "http://${inputUrl}" if this is what you meant.`, + ); + return urlWithProtocol; + } + return inputUrl; +} + +export function normalizeUrl(urlToNormalize: string): string { + const urlWithProtocol = appendProtocol(urlToNormalize); + + let parsedUrl: url.URL; + try { + parsedUrl = new url.URL(urlWithProtocol); + } catch (err) { + throw `Your url "${urlWithProtocol}" is invalid`; + } + const normalizedUrl = parsedUrl.toString(); + log.debug(`Normalized URL ${urlToNormalize} to:`, normalizedUrl); + return normalizedUrl; +} diff --git a/src/options/optionsMain.js b/src/options/optionsMain.js deleted file mode 100644 index 5cf883d..0000000 --- a/src/options/optionsMain.js +++ /dev/null @@ -1,133 +0,0 @@ -import fs from 'fs'; -import log from 'loglevel'; - -import inferOs from '../infer/inferOs'; -import normalizeUrl from './normalizeUrl'; -import packageJson from '../../package.json'; -import { ELECTRON_VERSION, PLACEHOLDER_APP_DIR } from '../constants'; -import asyncConfig from './asyncConfig'; - -const { inferPlatform, inferArch } = inferOs; - -/** - * Extracts only desired keys from inpOptions and augments it with defaults - * @param {Object} inpOptions - * @returns {Promise} - */ -export default function(inpOptions) { - const options = { - dir: PLACEHOLDER_APP_DIR, - name: inpOptions.name, - targetUrl: normalizeUrl(inpOptions.targetUrl), - platform: inpOptions.platform || inferPlatform(), - arch: inpOptions.arch || inferArch(), - electronVersion: inpOptions.electronVersion || ELECTRON_VERSION, - nativefierVersion: packageJson.version, - out: inpOptions.out || process.cwd(), - overwrite: inpOptions.overwrite, - asar: inpOptions.conceal || false, - icon: inpOptions.icon, - counter: inpOptions.counter || false, - bounce: inpOptions.bounce || false, - width: inpOptions.width || 1280, - height: inpOptions.height || 800, - minWidth: inpOptions.minWidth, - minHeight: inpOptions.minHeight, - maxWidth: inpOptions.maxWidth, - maxHeight: inpOptions.maxHeight, - showMenuBar: inpOptions.showMenuBar || false, - fastQuit: inpOptions.fastQuit || false, - userAgent: inpOptions.userAgent, - ignoreCertificate: inpOptions.ignoreCertificate || false, - disableGpu: inpOptions.disableGpu || false, - ignoreGpuBlacklist: inpOptions.ignoreGpuBlacklist || false, - enableEs3Apis: inpOptions.enableEs3Apis || false, - insecure: inpOptions.insecure || false, - flashPluginDir: inpOptions.flashPath || inpOptions.flash || null, - diskCacheSize: inpOptions.diskCacheSize || null, - inject: inpOptions.inject || null, - ignore: 'src', - fullScreen: inpOptions.fullScreen || false, - maximize: inpOptions.maximize || false, - hideWindowFrame: inpOptions.hideWindowFrame, - verbose: inpOptions.verbose, - disableContextMenu: inpOptions.disableContextMenu, - disableDevTools: inpOptions.disableDevTools, - crashReporter: inpOptions.crashReporter, - // workaround for electron-packager#375 - tmpdir: false, - zoom: inpOptions.zoom || 1.0, - internalUrls: inpOptions.internalUrls || null, - proxyRules: inpOptions.proxyRules || null, - singleInstance: inpOptions.singleInstance || false, - clearCache: inpOptions.clearCache || false, - appVersion: inpOptions.appVersion, - buildVersion: inpOptions.buildVersion, - appCopyright: inpOptions.appCopyright, - versionString: inpOptions.versionString, - win32metadata: inpOptions.win32metadata || { - ProductName: inpOptions.name, - InternalName: inpOptions.name, - FileDescription: inpOptions.name, - }, - processEnvs: inpOptions.processEnvs, - fileDownloadOptions: inpOptions.fileDownloadOptions, - tray: inpOptions.tray || false, - basicAuthUsername: inpOptions.basicAuthUsername || null, - basicAuthPassword: inpOptions.basicAuthPassword || null, - alwaysOnTop: inpOptions.alwaysOnTop || false, - titleBarStyle: inpOptions.titleBarStyle || null, - globalShortcuts: inpOptions.globalShortcuts || null, - browserwindowOptions: inpOptions.browserwindowOptions, - backgroundColor: inpOptions.backgroundColor || null, - darwinDarkModeSupport: inpOptions.darwinDarkModeSupport || false, - }; - - if (options.verbose) { - log.setLevel('trace'); - } else { - log.setLevel('error'); - } - - if (options.flashPluginDir) { - options.insecure = true; - } - - if (inpOptions.honest) { - options.userAgent = null; - } - - if (options.platform.toLowerCase() === 'windows') { - options.platform = 'win32'; - } - - if ( - options.platform.toLowerCase() === 'osx' || - options.platform.toLowerCase() === 'mac' - ) { - options.platform = 'darwin'; - } - - if (options.width > options.maxWidth) { - options.width = options.maxWidth; - } - - if (options.height > options.maxHeight) { - options.height = options.maxHeight; - } - - if (typeof inpOptions.x !== 'undefined') { - options.x = inpOptions.x; - } - - if (typeof inpOptions.y !== 'undefined') { - options.y = inpOptions.y; - } - - if (options.globalShortcuts) { - const globalShortcutsFileContent = fs.readFileSync(options.globalShortcuts); - options.globalShortcuts = JSON.parse(globalShortcutsFileContent); - } - - return asyncConfig(options); -} diff --git a/src/options/optionsMain.test.js b/src/options/optionsMain.test.js deleted file mode 100644 index 345fd41..0000000 --- a/src/options/optionsMain.test.js +++ /dev/null @@ -1,17 +0,0 @@ -import optionsMain from './optionsMain'; -import asyncConfig from './asyncConfig'; - -jest.mock('./asyncConfig'); -const mockedAsyncConfig = { some: 'options' }; -asyncConfig.mockImplementation(() => Promise.resolve(mockedAsyncConfig)); - -test('it should call the async config', async () => { - const params = { - targetUrl: 'http://example.com', - }; - const result = await optionsMain(params); - expect(asyncConfig).toHaveBeenCalledWith(expect.objectContaining(params)); - expect(result).toEqual(mockedAsyncConfig); -}); - -// TODO add more tests diff --git a/src/options/optionsMain.test.ts b/src/options/optionsMain.test.ts new file mode 100644 index 0000000..2e1b229 --- /dev/null +++ b/src/options/optionsMain.test.ts @@ -0,0 +1,25 @@ +import { getOptions } from './optionsMain'; +import * as asyncConfig from './asyncConfig'; + +const mockedAsyncConfig = { some: 'options' }; +let asyncConfigMock: jasmine.Spy; + +beforeAll(() => { + asyncConfigMock = spyOn(asyncConfig, 'asyncConfig').and.returnValue( + mockedAsyncConfig, + ); +}); + +test('it should call the async config', async () => { + const params = { + targetUrl: 'https://example.com/', + }; + const result = await getOptions(params); + expect(asyncConfigMock).toHaveBeenCalledWith( + expect.objectContaining({ + packager: expect.anything(), + nativefier: expect.anything(), + }), + ); + expect(result.packager.targetUrl).toEqual(params.targetUrl); +}); diff --git a/src/options/optionsMain.ts b/src/options/optionsMain.ts new file mode 100644 index 0000000..3987b9a --- /dev/null +++ b/src/options/optionsMain.ts @@ -0,0 +1,167 @@ +import * as fs from 'fs'; + +import * as log from 'loglevel'; + +// package.json is `require`d to let tsc strip the `src` folder by determining +// baseUrl=src. A static import would prevent that and cause an ugly extra `src` folder in `lib` +const packageJson = require('../../package.json'); // eslint-disable-line @typescript-eslint/no-var-requires +import { + DEFAULT_ELECTRON_VERSION, + PLACEHOLDER_APP_DIR, + ELECTRON_MAJOR_VERSION, +} from '../constants'; +import { inferPlatform, inferArch } from '../infer/inferOs'; +import { asyncConfig } from './asyncConfig'; +import { AppOptions } from './model'; +import { normalizeUrl } from './normalizeUrl'; + +const SEMVER_VERSION_NUMBER_REGEX = /\d+\.\d+\.\d+[-_\w\d.]*/; + +/** + * Process and validate raw user arguments + */ +export async function getOptions(rawOptions: any): Promise { + const options: AppOptions = { + packager: { + appCopyright: rawOptions.appCopyright, + appVersion: rawOptions.appVersion, + arch: rawOptions.arch || inferArch(), + asar: rawOptions.conceal || false, + buildVersion: rawOptions.buildVersion, + darwinDarkModeSupport: rawOptions.darwinDarkModeSupport || false, + dir: PLACEHOLDER_APP_DIR, + electronVersion: rawOptions.electronVersion || DEFAULT_ELECTRON_VERSION, + icon: rawOptions.icon, + name: typeof rawOptions.name === 'string' ? rawOptions.name : '', + out: rawOptions.out || process.cwd(), + overwrite: rawOptions.overwrite, + platform: rawOptions.platform || inferPlatform(), + targetUrl: normalizeUrl(rawOptions.targetUrl), + tmpdir: false, // workaround for electron-packager#375 + win32metadata: rawOptions.win32metadata || { + ProductName: rawOptions.name, + InternalName: rawOptions.name, + FileDescription: rawOptions.name, + }, + }, + nativefier: { + alwaysOnTop: rawOptions.alwaysOnTop || false, + backgroundColor: rawOptions.backgroundColor || null, + basicAuthPassword: rawOptions.basicAuthPassword || null, + basicAuthUsername: rawOptions.basicAuthUsername || null, + bounce: rawOptions.bounce || false, + browserwindowOptions: rawOptions.browserwindowOptions, + clearCache: rawOptions.clearCache || false, + counter: rawOptions.counter || false, + crashReporter: rawOptions.crashReporter, + disableContextMenu: rawOptions.disableContextMenu, + disableDevTools: rawOptions.disableDevTools, + disableGpu: rawOptions.disableGpu || false, + diskCacheSize: rawOptions.diskCacheSize || null, + enableEs3Apis: rawOptions.enableEs3Apis || false, + fastQuit: rawOptions.fastQuit || false, + fileDownloadOptions: rawOptions.fileDownloadOptions, + flashPluginDir: rawOptions.flashPath || rawOptions.flash || null, + fullScreen: rawOptions.fullScreen || false, + globalShortcuts: null, + hideWindowFrame: rawOptions.hideWindowFrame, + ignoreCertificate: rawOptions.ignoreCertificate || false, + ignoreGpuBlacklist: rawOptions.ignoreGpuBlacklist || false, + inject: rawOptions.inject || [], + insecure: rawOptions.insecure || false, + internalUrls: rawOptions.internalUrls || null, + maximize: rawOptions.maximize || false, + nativefierVersion: packageJson.version, + processEnvs: rawOptions.processEnvs, + proxyRules: rawOptions.proxyRules || null, + showMenuBar: rawOptions.showMenuBar || false, + singleInstance: rawOptions.singleInstance || false, + titleBarStyle: rawOptions.titleBarStyle || null, + tray: rawOptions.tray || false, + userAgent: rawOptions.userAgent, + verbose: rawOptions.verbose, + versionString: rawOptions.versionString, + width: rawOptions.width || 1280, + height: rawOptions.height || 800, + minWidth: rawOptions.minWidth, + minHeight: rawOptions.minHeight, + maxWidth: rawOptions.maxWidth, + maxHeight: rawOptions.maxHeight, + x: rawOptions.x, + y: rawOptions.y, + zoom: rawOptions.zoom || 1.0, + }, + }; + + if (options.nativefier.verbose) { + log.setLevel('trace'); + try { + require('debug').enable('electron-packager'); + } catch (err) { + log.debug( + 'Failed to enable electron-packager debug output. This should not happen,', + 'and suggests their internals changed. Please report an issue.', + ); + } + + log.debug( + 'Running in verbose mode! This will produce a mountain of logs and', + 'is recommended only for troubleshooting or if you like Shakespeare.', + ); + } else { + log.setLevel('info'); + } + + if (rawOptions.electronVersion) { + const requestedVersion: string = rawOptions.electronVersion; + if (!SEMVER_VERSION_NUMBER_REGEX.exec(requestedVersion)) { + throw `Invalid Electron version number "${requestedVersion}". Aborting.`; + } + const requestedMajorVersion = parseInt(requestedVersion.split('.')[0], 10); + if (requestedMajorVersion < ELECTRON_MAJOR_VERSION) { + log.warn( + `\nATTENTION: Using **old** Electron version ${requestedVersion} as requested.`, + "\nIt's untested, bugs and horror will happen, you're on your own.", + `\nSimply abort & re-run without passing the version flag to default to ${DEFAULT_ELECTRON_VERSION}`, + ); + } + } + + if (options.nativefier.flashPluginDir) { + options.nativefier.insecure = true; + } + + if (rawOptions.honest) { + options.nativefier.userAgent = null; + } + + if (options.packager.platform.toLowerCase() === 'windows') { + options.packager.platform = 'win32'; + } + + if ( + ['osx', 'mac', 'macos'].includes(options.packager.platform.toLowerCase()) + ) { + options.packager.platform = 'darwin'; + } + + if (options.nativefier.width > options.nativefier.maxWidth) { + options.nativefier.width = options.nativefier.maxWidth; + } + + if (options.nativefier.height > options.nativefier.maxHeight) { + options.nativefier.height = options.nativefier.maxHeight; + } + + if (rawOptions.globalShortcuts) { + log.debug('Use global shortcuts file at', rawOptions.globalShortcuts); + const globalShortcuts = JSON.parse( + fs.readFileSync(rawOptions.globalShortcuts).toString(), + ); + options.nativefier.globalShortcuts = globalShortcuts; + } + + await asyncConfig(options); + + return options; +} diff --git a/src/utils/index.js b/src/utils/index.js deleted file mode 100644 index e36cf72..0000000 --- a/src/utils/index.js +++ /dev/null @@ -1,3 +0,0 @@ -// TODO remove the eslint disable when we have more than one -// eslint-disable-next-line import/prefer-default-export -export { default as sanitizeFilename } from './sanitizeFilename'; diff --git a/src/utils/sanitizeFilename.js b/src/utils/sanitizeFilename.js deleted file mode 100644 index 3da7164..0000000 --- a/src/utils/sanitizeFilename.js +++ /dev/null @@ -1,16 +0,0 @@ -import sanitizeFilenameLib from 'sanitize-filename'; -import { DEFAULT_APP_NAME } from '../constants'; - -export default function(platform, str) { - let result = sanitizeFilenameLib(str); - - // remove all non ascii or use default app name - // eslint-disable-next-line no-control-regex - result = result.replace(/[^\x00-\x7F]/g, '') || DEFAULT_APP_NAME; - - // spaces will cause problems with Ubuntu when pinned to the dock - if (platform === 'linux') { - return result.replace(/ /g, ''); - } - return result; -} diff --git a/src/utils/sanitizeFilename.test.js b/src/utils/sanitizeFilename.test.ts similarity index 68% rename from src/utils/sanitizeFilename.test.js rename to src/utils/sanitizeFilename.test.ts index 772b1bd..6bce560 100644 --- a/src/utils/sanitizeFilename.test.js +++ b/src/utils/sanitizeFilename.test.ts @@ -1,16 +1,6 @@ -import sanitizeFilenameLib from 'sanitize-filename'; -import sanitizeFilename from './sanitizeFilename'; +import { sanitizeFilename } from './sanitizeFilename'; import { DEFAULT_APP_NAME } from '../constants'; -jest.mock('sanitize-filename'); -sanitizeFilenameLib.mockImplementation((str) => str); - -test('it should call the sanitize-filename npm module', () => { - const param = 'abc'; - sanitizeFilename('', param); - expect(sanitizeFilenameLib).toHaveBeenCalledWith(param); -}); - describe('replacing non ascii characters', () => { const nonAscii = '�'; test('it should return a result without non ascii characters', () => { diff --git a/src/utils/sanitizeFilename.ts b/src/utils/sanitizeFilename.ts new file mode 100644 index 0000000..7e16fc7 --- /dev/null +++ b/src/utils/sanitizeFilename.ts @@ -0,0 +1,24 @@ +import * as log from 'loglevel'; + +import { DEFAULT_APP_NAME } from '../constants'; + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const sanitize = require('sanitize-filename'); + +export function sanitizeFilename( + platform: string, + filenameToSanitize: string, +): string { + let result = sanitize(filenameToSanitize); + + // remove all non ascii or use default app name + // eslint-disable-next-line no-control-regex + result = result.replace(/[^\x00-\x7F]/g, '') || DEFAULT_APP_NAME; + + // spaces will cause problems with Ubuntu when pinned to the dock + if (platform === 'linux') { + result = result.replace(/ /g, ''); + } + log.debug(`Sanitized filename for ${filenameToSanitize} : ${result}`); + return result; +} diff --git a/test-resources/test-injection.js b/test-resources/test-injection.js index 1a14e35..b535a98 100644 --- a/test-resources/test-injection.js +++ b/test-resources/test-injection.js @@ -1,3 +1,3 @@ -const log = require('loglevel'); +const log = require('loglevel'); // eslint-disable-line @typescript-eslint/no-var-requires log.info('This is a test injecton script'); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..2f93f01 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "allowJs": false, + "declaration": false, + "incremental": true, + "module": "commonjs", + "moduleResolution": "node", + "outDir": "./lib", + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + // https://stackoverflow.com/questions/48378495/recommended-typescript-config-for-node-8 + // and 'dom' to tell tsc it's okay to use the URL object (which is in Node >= 7) + "target": "es2017", + "lib": ["es2017", "dom"] + }, + "include": [ + "./src/**/*" + ] +} diff --git a/webpack.config.js b/webpack.config.js deleted file mode 100644 index ad47a31..0000000 --- a/webpack.config.js +++ /dev/null @@ -1,23 +0,0 @@ -const electronPublicApi = ['electron']; - -const nodeModules = {}; -electronPublicApi.forEach((apiString) => { - nodeModules[apiString] = `commonjs ${apiString}`; -}); - -module.exports = { - target: 'node', - output: { - filename: 'main.js', - }, - node: { - global: false, - __dirname: false, - }, - externals: nodeModules, - module: { - rules: [{ test: /\.js$/, exclude: /node_modules/, loader: 'babel-loader' }], - }, - devtool: 'source-map', - mode: 'none', -};