Revamp and move to TypeScript (#898)

## Breaking changes

- Require **Node >= 8.10.0 and npm 5.6.0**
- Move to **Electron 8.1.1**.
- That's it. Lots of care went into breaking CLI & programmatic behavior
  as little as possible. **Please report regressions**.
- Known issue: build may fail behind a proxy. Get in touch if you use one:
  https://github.com/jiahaog/nativefier/issues/907#issuecomment-596144768

## Changes summary

Nativefier didn't get much love recently, to the point that it's
becoming hard to run on recent Node, due to old dependencies.
Also, some past practices now seem weird, as better expressible
by modern JS/TS, discouraging contributions including mine.

Addressing this, and one thing leading to another, came a
bigger-than-expected revamp, aiming at making Nativefier more
**lean, stable, future-proof, user-friendly and dev-friendly**,
while **not changing the CLI/programmatic interfaces**. Highlights:

- **Require Node>=8**, as imposed by many of our dependencies. Node 8
  is twice LTS, and easily available even in conservative Linux distros.
  No reason not to demand it.
- **Default to Electron 8**.
- **Bump** all dependencies to latest version, including electron-packager.
- **Move to TS**. TS is great. As of today, I see no reason not to use it,
  and fight interface bugs at runtime rather than at compile time.
  With that, get rid of everything Babel/Webpack.
- **Move away from Gulp**. Gulp's selling point is perf via streaming,
  but for small builds like Nativefier, npm tasks are plenty good
  and less dependency bloat. Gulp was the driver for this PR: broken
  on Node 12, and I didn't feel like just upgrading and keeping it.
- Add tons of **verbose logs** everywhere it makes sense, to have a
  fine & clear trace of the program flow. This will be helpful to
  debug user-reported issues, and already helped me fix a few bugs.
    - With better simple logging, get rid of the quirky and buggy
      progress bar based on package `progress`. Nice logging (minimal
      by default, the verbose logging mentioned above is only used
      when passing `--verbose`) is better and one less dependency.
- **Dump `async` package**, a relic from old callback-hell early Node.
  Also dump a few other micro-packages unnecessary now.
- A first pass of code **cleanup** thanks to modern JS/TS features:
  fixes, simplifications, jsdoc type annotations to types, etc.
- **Remove GitHub integrations Hound & CodeClimate**, which are more
  exotic than good'ol'linters, and whose signal-to-noise ratio is too low.
- Quality: **Add tests** and add **Windows + macOS CI builds**.
  Also, add a **manual test script**, helping to quickly verify the
  hard-to-programatically-test stuff before releases, and limit regressions.
- **Fix a very small number of existing bugs**. The goal of this PR was
  *not* to fix bugs, but to get Nativefier in better shape to do so.
  Bugfixes will come later. Still, these got addressed:
  - Add common `Alt`+`Left`/`Right` for previous/next navigation.
  - Improve #379: fix zoom with `Ctrl` + numpad `+`/`-`
  - Fix pinch-to-zoom (see https://github.com/jiahaog/nativefier/issues/379#issuecomment-598612128 )
This commit is contained in:
Ronan Jouchet 2020-03-15 16:50:01 -04:00 committed by GitHub
parent f115beed0d
commit c9ee6667d4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
121 changed files with 2427 additions and 2736 deletions

View File

@ -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/

23
.eslintrc.js Normal file
View File

@ -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',
},
};

View File

@ -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"

5
.gitignore vendored
View File

@ -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

View File

@ -1,7 +0,0 @@
eslint:
enabled: true
config_file: .eslintrc.yml
ignore_file: .eslintignore
jshint:
enabled: false

View File

@ -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

View File

@ -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'

View File

@ -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

24
app/.eslintrc.js Normal file
View File

@ -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',
},
};

View File

@ -1,2 +0,0 @@
settings:
import/core-modules: [ electron ]

View File

@ -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"
}
}

View File

@ -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;

View File

@ -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;

View File

@ -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<void> {
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<void> => {
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;

View File

@ -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';

View File

@ -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 };

View File

@ -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;

View File

@ -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;

View File

@ -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,
};

View File

@ -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';

View File

@ -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;
}

View File

@ -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;

View File

@ -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

View File

@ -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);
});

10
app/src/static/login.js Normal file
View File

@ -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]);
});

View File

@ -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]);
});

19
app/tsconfig.json Normal file
View File

@ -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/**/*"
]
}

View File

@ -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`

BIN
docs/dock.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@ -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\`"

61
docs/manual-test Executable file
View File

@ -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

View File

@ -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.

View File

Before

Width:  |  Height:  |  Size: 1.9 MiB

After

Width:  |  Height:  |  Size: 1.9 MiB

View File

@ -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);
},
);
});
});

View File

@ -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();
});
});
});
});

View File

@ -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)),
);

View File

@ -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),
);

View File

@ -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']);

View File

@ -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,
};

View File

@ -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;

View File

@ -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));

View File

@ -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);
});

View File

@ -1,9 +0,0 @@
import gulp from 'gulp';
import requireDir from 'require-dir';
requireDir('./gulp', {
recurse: true,
duplicates: true,
});
gulp.task('default', ['build']);

View File

@ -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"

View File

@ -1,3 +0,0 @@
module.exports = {
testEnvironment: 'node',
};

View File

@ -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/",
"<rootDir>/app/src.*",
"<rootDir>/src.*"
]
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

View File

@ -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;

96
src/build/buildIcon.ts Normal file
View File

@ -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<void> {
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;
}
}

View File

@ -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;

View File

@ -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<void> {
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<string> {
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;
}

View File

@ -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;

View File

@ -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<void> {
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<void> {
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,
);
}

View File

@ -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('<targetUrl> [dest]')
.action((targetUrl, appDir) => {
program.targetUrl = targetUrl;
program.out = appDir;
.action((url, outputDirectory) => {
positionalOptions.targetUrl = url;
positionalOptions.out = outputDirectory;
})
.option('-n, --name <value>', 'app name')
.option('-p, --platform <value>', "'osx', 'mas', 'linux' or 'windows'")
.option('-p, --platform <value>', "'mac', 'mas', 'linux' or 'windows'")
.option('-a, --arch <value>', "'ia32' or 'x64' or 'armv7l'")
.option(
'--app-version <value>',
'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 <value>',
'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 <value>',
'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 <json-string>',
'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 <value>',
@ -116,61 +124,61 @@ if (require.main === module) {
)
.option(
'--width <value>',
'set window default width, defaults to 1280px',
'set window default width; defaults to 1280px',
parseInt,
)
.option(
'--height <value>',
'set window default height, defaults to 800px',
'set window default height; defaults to 800px',
parseInt,
)
.option(
'--min-width <value>',
'set window minimum width, defaults to 0px',
'set window minimum width; defaults to 0px',
parseInt,
)
.option(
'--min-height <value>',
'set window minimum height, defaults to 0px',
'set window minimum height; defaults to 0px',
parseInt,
)
.option(
'--max-width <value>',
'set window maximum width, default is no limit',
'set window maximum width; default is unlimited',
parseInt,
)
.option(
'--max-height <value>',
'set window maximum height, default is no limit',
'set window maximum height; default is unlimited',
parseInt,
)
.option('--x <value>', 'set window x location', parseInt)
.option('--y <value>', '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 <value>', 'set the user agent string for the app')
.option('-u, --user-agent <value>', '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 <value>',
'path to Chrome flash plugin, find it in `Chrome://plugins`',
'path to Chrome flash plugin; find it in `chrome://plugins`',
)
.option(
'--disk-cache-size <value>',
@ -178,31 +186,31 @@ if (require.main === module) {
)
.option(
'--inject <value>',
'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 <value>',
'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 <value>',
'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 <value>',
'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 <value>',
@ -218,29 +226,29 @@ if (require.main === module) {
)
.option(
'--processEnvs <json-string>',
'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 <json-string>',
'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 <value>', 'basic http(s) auth username')
.option('--basic-auth-password <value>', 'basic http(s) auth password')
.option('--always-on-top', 'enable always on top window')
.option(
'--title-bar-style <value>',
"(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 <value>',
'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 <json-string>',
@ -249,7 +257,7 @@ if (require.main === module) {
)
.option(
'--background-color <value>',
"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);
});
}

View File

@ -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');

13
src/constants.ts Normal file
View File

@ -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');

View File

@ -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;

View File

@ -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');
});
});

View File

@ -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;

View File

@ -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,
};

141
src/helpers/helpers.ts Normal file
View File

@ -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<any> {
return new Promise((resolve, reject) => {
ncp(sourceFileOrDir, dest, (error: any) => {
if (error) {
reject(error);
}
resolve();
});
});
}
export async function downloadFile(fileUrl: string): Promise<DownloadResult> {
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;
}

View File

@ -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,
};

View File

@ -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<string> {
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<string> {
return iconShellHelper(
SCRIPT_PATHS.singleIco,
icoSrc,
`${getTempDir('iconconv')}/icon.ico`,
);
}
export async function convertToPng(icoSrc: string): Promise<string> {
return iconShellHelper(
SCRIPT_PATHS.convertToPng,
icoSrc,
`${getTempDir('iconconv')}/icon.png`,
);
}
export async function convertToIco(icoSrc: string): Promise<string> {
return iconShellHelper(
SCRIPT_PATHS.convertToIco,
icoSrc,
`${getTempDir('iconconv')}/icon.ico`,
);
}
export async function convertToIcns(icoSrc: string): Promise<string> {
if (!isOSX()) {
throw new Error('macOS is required to convert to a .icns icon');
}
return iconShellHelper(
SCRIPT_PATHS.convertToIcns,
icoSrc,
`${getTempDir('iconconv')}/icon.icns`,
);
}

View File

@ -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;

View File

@ -1,6 +0,0 @@
import 'source-map-support/register';
import 'babel-polyfill';
import buildApp from './build/buildMain';
export default buildApp;

View File

@ -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';

View File

@ -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;

111
src/infer/inferIcon.ts Normal file
View File

@ -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<any> {
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<string> {
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;
}

View File

@ -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,
};

View File

@ -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;

View File

@ -1,21 +0,0 @@
import axios from 'axios';
import inferTitle from './inferTitle';
jest.mock('axios', () =>
jest.fn(() =>
Promise.resolve({
data: `
<HTML>
<head>
<title>TEST_TITLE</title>
</head>
</HTML>`,
}),
),
);
test('it returns the correct title', async () => {
const result = await inferTitle('someurl');
expect(axios).toHaveBeenCalledTimes(1);
expect(result).toBe('TEST_TITLE');
});

View File

@ -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: `
<HTML>
<head>
<title>TEST_TITLE</title>
</head>
</HTML>`,
});
const result = await inferTitle('someurl');
expect(axiosGetMock).toHaveBeenCalledTimes(1);
expect(result).toBe('TEST_TITLE');
});

23
src/infer/inferTitle.ts Normal file
View File

@ -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<string> {
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;
}

View File

@ -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;

View File

@ -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',
);
});
});

View File

@ -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',
);
});
});

View File

@ -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<string> {
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<string> = _.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<string> {
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);
}
}

57
src/integration-test.ts Normal file
View File

@ -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);
}
});
});

7
src/jestSetupFiles.ts Normal file
View File

@ -0,0 +1,7 @@
import * as log from 'loglevel';
if (process.env.LOGLEVEL) {
log.setLevel(process.env.LOGLEVEL as log.LogLevelDesc);
} else {
log.disableAll();
}

20
src/main.ts Normal file
View File

@ -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;

View File

@ -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),
);
}

View File

@ -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);
});

View File

@ -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<any> {
log.debug('\nPerforming async options post-processing.');
await processOptions(options);
}

View File

@ -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/);
});

View File

@ -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<void> {
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;
}
}
}

View File

@ -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;
});
}

View File

@ -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);
});
});
});

View File

@ -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
});
});
});

View File

@ -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<string> {
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;
}
}

View File

@ -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));
}

View File

@ -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);
});
});

View File

@ -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),
);
}

View File

@ -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);
});
});
});

View File

@ -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
});
});

Some files were not shown because too many files have changed in this diff Show More