Organize CLI flags into groups (for better --help usability) (#1191)

* Organize CLI options for better UX

* Fix some documentation

* Whoops. Stupid VS Code linter.

* Fix prettier issues

* Make paths less unixy in tests

* Update src/cli.test.ts

Co-authored-by: Ronan Jouchet <ronan@jouchet.fr>

* Apply suggestions from code review

Co-authored-by: Ronan Jouchet <ronan@jouchet.fr>

* Add example to reference CATALOG.md

* Make honest appear near user-agent

* Standardize descriptions

* Hide flash options

* Add explanation of parsed._

* Redo groups in yargs

Co-authored-by: Ronan Jouchet <ronan@jouchet.fr>
This commit is contained in:
Adam Weeden 2021-05-18 22:02:55 -04:00 committed by GitHub
parent b3c202fd33
commit 1a810e5ce5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 1427 additions and 793 deletions

File diff suppressed because it is too large Load Diff

View File

@ -54,7 +54,6 @@
},
"dependencies": {
"axios": "^0.21.1",
"commander": "^7.1.0",
"electron-packager": "^15.2.0",
"gitcloud": "^0.2.0",
"hasbin": "^1.2.3",
@ -63,7 +62,8 @@
"page-icon": "^0.4.0",
"sanitize-filename": "^1.6.3",
"source-map-support": "^0.5.19",
"tmp": "^0.2.1"
"tmp": "^0.2.1",
"yargs": "^17.0.1"
},
"devDependencies": {
"@types/jest": "^26.0.20",

View File

@ -53,6 +53,7 @@ function pickElectronAppArgs(options: AppOptions): any {
height: options.nativefier.height,
helperBundleId: options.packager.helperBundleId,
hideWindowFrame: options.nativefier.hideWindowFrame,
honest: options.nativefier.honest,
ignoreCertificate: options.nativefier.ignoreCertificate,
ignoreGpuBlacklist: options.nativefier.ignoreGpuBlacklist,
insecure: options.nativefier.insecure,

292
src/cli.test.ts Normal file
View File

@ -0,0 +1,292 @@
import 'source-map-support/register';
import { initArgs, parseArgs } from './cli';
import { parseJson } from './utils/parseUtils';
describe('initArgs + parseArgs', () => {
let mockExit: jest.SpyInstance;
beforeEach(() => {
mockExit = jest.spyOn(process, 'exit').mockImplementation();
});
afterEach(() => {
mockExit.mockRestore();
});
test('--help forces exit', () => {
// Mock console.log to not pollute the log with the yargs help text
const mockLog = jest.spyOn(console, 'log').mockImplementation();
initArgs(['https://www.google.com', '--help']);
expect(mockExit).toHaveBeenCalledTimes(1);
expect(mockLog).toBeCalled();
mockLog.mockRestore();
});
test('--version forces exit', () => {
// Mock console.log to not pollute the log with the yargs help text
const mockLog = jest.spyOn(console, 'log').mockImplementation();
initArgs(['https://www.google.com', '--version']);
expect(mockExit).toHaveBeenCalledTimes(1);
expect(mockLog).toBeCalled();
mockLog.mockRestore();
});
// Positional options
test('first positional becomes targetUrl', () => {
const args = parseArgs(initArgs(['https://google.com']));
expect(args.targetUrl).toBe('https://google.com');
expect(args.upgrade).toBeUndefined();
});
test('second positional becomes out', () => {
const args = parseArgs(initArgs(['https://google.com', 'tmp']));
expect(args.out).toBe('tmp');
expect(args.targetUrl).toBe('https://google.com');
expect(args.upgrade).toBeUndefined();
});
// App Creation Options
test('upgrade arg', () => {
const args = parseArgs(initArgs(['--upgrade', 'pathToUpgrade']));
expect(args.upgrade).toBe('pathToUpgrade');
expect(args.targetUrl).toBe('');
});
test('upgrade arg with out dir', () => {
const args = parseArgs(initArgs(['tmp', '--upgrade', 'pathToUpgrade']));
expect(args.upgrade).toBe('pathToUpgrade');
expect(args.out).toBe('tmp');
expect(args.targetUrl).toBe('');
});
test('upgrade arg with targetUrl', () => {
expect(() => {
parseArgs(
initArgs(['https://www.google.com', '--upgrade', 'path/to/upgrade']),
);
}).toThrow();
});
test('multi-inject', () => {
const args = parseArgs(
initArgs([
'https://google.com',
'--inject',
'test.js',
'--inject',
'test2.js',
'--inject',
'test.css',
'--inject',
'test2.css',
]),
);
expect(args.inject).toEqual([
'test.js',
'test2.js',
'test.css',
'test2.css',
]);
});
test.each([
{ arg: 'app-copyright', shortArg: null, value: '(c) Nativefier' },
{ arg: 'app-version', shortArg: null, value: '2.0.0' },
{ arg: 'background-color', shortArg: null, value: '#FFAA88' },
{ arg: 'basic-auth-username', shortArg: null, value: 'user' },
{ arg: 'basic-auth-password', shortArg: null, value: 'p@ssw0rd' },
{ arg: 'bookmarks-menu', shortArg: null, value: 'bookmarks.json' },
{
arg: 'browserwindow-options',
shortArg: null,
value: '{"test": 456}',
isJsonString: true,
},
{ arg: 'build-version', shortArg: null, value: '3.0.0' },
{
arg: 'crash-reporter',
shortArg: null,
value: 'https://crash-reporter.com',
},
{ arg: 'electron-version', shortArg: 'e', value: '1.0.0' },
{
arg: 'file-download-options',
shortArg: null,
value: '{"test": 789}',
isJsonString: true,
},
{ arg: 'flash-path', shortArg: null, value: 'pathToFlash' },
{ arg: 'global-shortcuts', shortArg: null, value: 'shortcuts.json' },
{ arg: 'icon', shortArg: 'i', value: 'icon.png' },
{ arg: 'internal-urls', shortArg: null, value: '.*' },
{ arg: 'lang', shortArg: null, value: 'fr' },
{ arg: 'name', shortArg: 'n', value: 'Google' },
{
arg: 'process-envs',
shortArg: null,
value: '{"test": 123}',
isJsonString: true,
},
{ arg: 'proxy-rules', shortArg: null, value: 'RULE: PROXY' },
{ arg: 'user-agent', shortArg: 'u', value: 'FIREFOX' },
{
arg: 'win32metadata',
shortArg: null,
value: '{"ProductName": "Google"}',
isJsonString: true,
},
])('test string arg %s', ({ arg, shortArg, value, isJsonString }) => {
const args = parseArgs(initArgs(['https://google.com', `--${arg}`, value]));
if (!isJsonString) {
expect(args[arg]).toBe(value);
} else {
expect(args[arg]).toEqual(parseJson(value));
}
if (shortArg) {
const argsShort = parseArgs(
initArgs(['https://google.com', `-${shortArg as string}`, value]),
);
if (!isJsonString) {
expect(argsShort[arg]).toBe(value);
} else {
expect(argsShort[arg]).toEqual(parseJson(value));
}
}
});
test.each([
{ arg: 'arch', shortArg: 'a', value: 'x64', badValue: '486' },
{ arg: 'platform', shortArg: 'p', value: 'mac', badValue: 'os2' },
{
arg: 'title-bar-style',
shortArg: null,
value: 'hidden',
badValue: 'cool',
},
])('limited choice arg %s', ({ arg, shortArg, value, badValue }) => {
const args = parseArgs(initArgs(['https://google.com', `--${arg}`, value]));
expect(args[arg]).toBe(value);
// Mock console.error to not pollute the log with the yargs help text
const mockError = jest.spyOn(console, 'error').mockImplementation();
initArgs(['https://google.com', `--${arg}`, badValue]);
expect(mockExit).toHaveBeenCalledTimes(1);
expect(mockError).toBeCalled();
mockExit.mockClear();
mockError.mockClear();
if (shortArg) {
const argsShort = parseArgs(
initArgs(['https://google.com', `-${shortArg}`, value]),
);
expect(argsShort[arg]).toBe(value);
initArgs(['https://google.com', `-${shortArg}`, badValue]);
expect(mockExit).toHaveBeenCalledTimes(1);
expect(mockError).toBeCalled();
}
mockError.mockRestore();
});
test.each([
{ arg: 'always-on-top', shortArg: null },
{ arg: 'block-external-urls', shortArg: null },
{ arg: 'bounce', shortArg: null },
{ arg: 'clear-cache', shortArg: null },
{ arg: 'conceal', shortArg: 'c' },
{ arg: 'counter', shortArg: null },
{ arg: 'darwin-dark-mode-support', shortArg: null },
{ arg: 'disable-context-menu', shortArg: null },
{ arg: 'disable-dev-tools', shortArg: null },
{ arg: 'disable-gpu', shortArg: null },
{ arg: 'disable-old-build-warning-yesiknowitisinsecure', shortArg: null },
{ arg: 'enable-es3-apis', shortArg: null },
{ arg: 'fast-quit', shortArg: 'f' },
{ arg: 'flash', shortArg: null },
{ arg: 'full-screen', shortArg: null },
{ arg: 'hide-window-frame', shortArg: null },
{ arg: 'honest', shortArg: null },
{ arg: 'ignore-certificate', shortArg: null },
{ arg: 'ignore-gpu-blacklist', shortArg: null },
{ arg: 'insecure', shortArg: null },
{ arg: 'maximize', shortArg: null },
{ arg: 'portable', shortArg: null },
{ arg: 'show-menu-bar', shortArg: 'm' },
{ arg: 'single-instance', shortArg: null },
{ arg: 'tray', shortArg: null },
{ arg: 'verbose', shortArg: null },
{ arg: 'widevine', shortArg: null },
])('test boolean arg %s', ({ arg, shortArg }) => {
const defaultArgs = parseArgs(initArgs(['https://google.com']));
expect(defaultArgs[arg]).toBe(false);
const args = parseArgs(initArgs(['https://google.com', `--${arg}`]));
expect(args[arg]).toBe(true);
if (shortArg) {
const argsShort = parseArgs(
initArgs(['https://google.com', `-${shortArg}`]),
);
expect(argsShort[arg]).toBe(true);
}
});
test.each([{ arg: 'no-overwrite', shortArg: null }])(
'test inversible boolean arg %s',
({ arg, shortArg }) => {
const inverse = arg.startsWith('no-') ? arg.substr(3) : `no-${arg}`;
const defaultArgs = parseArgs(initArgs(['https://google.com']));
expect(defaultArgs[arg]).toBe(false);
expect(defaultArgs[inverse]).toBe(true);
const args = parseArgs(initArgs(['https://google.com', `--${arg}`]));
expect(args[arg]).toBe(true);
expect(args[inverse]).toBe(false);
if (shortArg) {
const argsShort = parseArgs(
initArgs(['https://google.com', `-${shortArg as string}`]),
);
expect(argsShort[arg]).toBe(true);
expect(argsShort[inverse]).toBe(true);
}
},
);
test.each([
{ arg: 'disk-cache-size', shortArg: null, value: 100 },
{ arg: 'height', shortArg: null, value: 200 },
{ arg: 'max-height', shortArg: null, value: 300 },
{ arg: 'max-width', shortArg: null, value: 400 },
{ arg: 'min-height', shortArg: null, value: 500 },
{ arg: 'min-width', shortArg: null, value: 600 },
{ arg: 'width', shortArg: null, value: 700 },
{ arg: 'x', shortArg: null, value: 800 },
{ arg: 'y', shortArg: null, value: 900 },
])('test numeric arg %s', ({ arg, shortArg, value }) => {
const args = parseArgs(
initArgs(['https://google.com', `--${arg}`, `${value}`]),
);
expect(args[arg]).toBe(value);
const badArgs = parseArgs(
initArgs(['https://google.com', `--${arg}`, 'abcd']),
);
expect(badArgs[arg]).toBeNaN();
if (shortArg) {
const shortArgs = parseArgs(
initArgs(['https://google.com', `-${shortArg as string}`, `${value}`]),
);
expect(shortArgs[arg]).toBe(value);
const badShortArgs = parseArgs(
initArgs(['https://google.com', `-${shortArg as string}`, 'abcd']),
);
expect(badShortArgs[arg]).toBeNaN();
}
});
});

View File

@ -1,51 +1,582 @@
#!/usr/bin/env node
import 'source-map-support/register';
import * as dns from 'dns';
import * as commander from 'commander';
import * as log from 'loglevel';
import * as yargs from 'yargs';
import { isArgFormatInvalid } from './helpers/helpers';
import {
checkInternet,
getProcessEnvs,
isArgFormatInvalid,
} from './helpers/helpers';
import { supportedArchs, supportedPlatforms } from './infer/inferOs';
import { buildNativefierApp } from './main';
import { NativefierOptions } from './options/model';
import { parseBooleanOrString, parseJson } from './utils/parseUtils';
import { parseJson } from './utils/parseUtils';
import { DEFAULT_ELECTRON_VERSION } from './constants';
// 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
export function initArgs(argv: string[]): yargs.Argv<any> {
const args = yargs(argv)
.scriptName('nativefier')
.usage(
'$0 <targetUrl> [outputDirectory] [other options]\nor\n$0 --upgrade <pathToExistingApp> [other options]',
)
.example(
'$0 <targetUrl> -n <name>',
'Make an app from <targetUrl> and set the application name to <name>',
)
.example(
'$0 --upgrade <pathToExistingApp>',
'Upgrade (in place) the existing Nativefier app at <pathToExistingApp>',
)
.example(
'$0 <targetUrl> -p <platform> -a <arch>',
'Make an app from <targetUrl> for the OS <platform> and CPU architecture <arch>',
)
.example(
'for more examples and help...',
'See https://github.com/nativefier/nativefier/blob/master/CATALOG.md',
)
.positional('targetUrl', {
description:
'the URL that you wish to to turn into a native app; required if not using --upgrade',
type: 'string',
})
.positional('outputDirectory', {
defaultDescription: 'current directory',
description: 'the directory to generate the app in',
normalize: true,
type: 'string',
})
// App Creation Options
.option('a', {
alias: 'arch',
choices: supportedArchs,
defaultDescription: "current Node's arch",
description: 'the CPU architecture to build for',
type: 'string',
})
.option('c', {
alias: 'conceal',
default: false,
description: 'package the app source code into an asar archive',
type: 'boolean',
})
.option('e', {
alias: 'electron-version',
defaultDescription: DEFAULT_ELECTRON_VERSION,
description:
"specify the electron version to use (without the 'v'); see https://github.com/electron/electron/releases",
})
.option('global-shortcuts', {
description:
'define global keyboard shortcuts via a JSON file; See https://github.com/nativefier/nativefier/blob/master/docs/api.md#global-shortcuts',
normalize: true,
type: 'string',
})
.option('i', {
alias: 'icon',
description:
'the icon file to use as the icon for the app (.ico on Windows, .icns/.png on macOS, .png on Linux)',
normalize: true,
type: 'string',
})
.option('n', {
alias: 'name',
defaultDescription: 'the title of the page passed via targetUrl',
description: 'specify the name of the app',
type: 'string',
})
.option('no-overwrite', {
default: false,
description: 'do not overwrite output directory if it already exists',
type: 'boolean',
})
.option('overwrite', {
// This is needed to have the `no-overwrite` flag to work correctly
default: true,
hidden: true,
type: 'boolean',
})
.option('p', {
alias: 'platform',
choices: supportedPlatforms,
defaultDescription: 'current operating system',
description: 'the operating system platform to build for',
type: 'string',
})
.option('portable', {
default: false,
description:
'make the app store its user data in the app folder; WARNING: see https://github.com/nativefier/nativefier/blob/master/docs/api.md#portable for security risks',
type: 'boolean',
})
.option('upgrade', {
description:
'upgrade an app built by an older version of Nativefier\nYou must pass the full path to the existing app executable (app will be overwritten with upgraded version by default)',
normalize: true,
type: 'string',
})
.option('widevine', {
default: false,
description:
"use a Widevine-enabled version of Electron for DRM playback (use at your own risk, it's unofficial, provided by CastLabs)",
type: 'boolean',
})
.group(
[
'arch',
'conceal',
'electron-version',
'global-shortcuts',
'icon',
'name',
'no-overwrite',
'platform',
'portable',
'upgrade',
'widevine',
],
decorateYargOptionGroup('App Creation Options'),
)
// App Window Options
.option('always-on-top', {
default: false,
description: 'enable always on top window',
type: 'boolean',
})
.option('background-color', {
description:
"set the app background color, for better integration while the app is loading. Example value: '#2e2c29'",
type: 'string',
})
.option('bookmarks-menu', {
description:
'create a bookmarks menu (via JSON file); See https://github.com/nativefier/nativefier/blob/master/docs/api.md#bookmarks-menu',
normalize: true,
type: 'string',
})
.option('browserwindow-options', {
coerce: parseJson,
description:
'override Electron BrowserWindow options (via JSON string); see https://github.com/nativefier/nativefier/blob/master/docs/api.md#browserwindow-options',
type: 'string',
})
.option('disable-context-menu', {
default: false,
description: 'disable the context menu (right click)',
type: 'boolean',
})
.option('disable-dev-tools', {
default: false,
description: 'disable developer tools (Ctrl+Shift+I / F12)',
type: 'boolean',
})
.option('full-screen', {
default: false,
description: 'always start the app full screen',
type: 'boolean',
})
.option('height', {
defaultDescription: '800',
description: 'set window default height in pixels',
type: 'number',
})
.option('hide-window-frame', {
default: false,
description: 'disable window frame and controls',
type: 'boolean',
})
.option('m', {
alias: 'show-menu-bar',
default: false,
description: 'set menu bar visible',
type: 'boolean',
})
.option('max-height', {
defaultDescription: 'unlimited',
description: 'set window maximum height in pixels',
type: 'number',
})
.option('max-width', {
defaultDescription: 'unlimited',
description: 'set window maximum width in pixels',
type: 'number',
})
.option('maximize', {
default: false,
description: 'always start the app maximized',
type: 'boolean',
})
.option('min-height', {
defaultDescription: '0',
description: 'set window minimum height in pixels',
type: 'number',
})
.option('min-width', {
defaultDescription: '0',
description: 'set window minimum width in pixels',
type: 'number',
})
.option('process-envs', {
coerce: getProcessEnvs,
description:
'a JSON string of key/value pairs to be set as environment variables before any browser windows are opened',
type: 'string',
})
.option('single-instance', {
default: false,
description: 'allow only a single instance of the app',
type: 'boolean',
})
.option('tray', {
default: false,
description:
"allow app to stay in system tray. If 'start-in-tray' is set as argument, don't show main window on first start",
type: 'boolean',
})
.option('width', {
defaultDescription: '1280',
description: 'app window default width in pixels',
type: 'number',
})
.option('x', {
description: 'set window x location in pixels from left',
type: 'number',
})
.option('y', {
description: 'set window y location in pixels from top',
type: 'number',
})
.option('zoom', {
default: 1.0,
description: 'set the default zoom factor for the app',
type: 'number',
})
.group(
[
'always-on-top',
'background-color',
'bookmarks-menu',
'browserwindow-options',
'disable-context-menu',
'disable-dev-tools',
'full-screen',
'height',
'hide-window-frame',
'm',
'max-width',
'max-height',
'maximize',
'min-height',
'min-width',
'process-envs',
'single-instance',
'tray',
'width',
'x',
'y',
'zoom',
],
decorateYargOptionGroup('App Window Options'),
)
// Internal Browser Options
.option('file-download-options', {
coerce: parseJson,
description:
'a JSON string defining file download options; see https://github.com/sindresorhus/electron-dl',
type: 'string',
})
.option('inject', {
description:
'path to a CSS/JS file to be injected; pass multiple times to inject multiple files',
type: 'array',
})
.option('lang', {
defaultDescription: 'os language at runtime of the app',
description:
'set the language or locale to render the web site as (e.g., "fr", "en-US", "es", etc.)',
type: 'string',
})
.option('u', {
alias: 'user-agent',
description: "set the app's user agent string",
type: 'string',
})
.option('user-agent-honest', {
alias: 'honest',
default: false,
description:
'prevent the normal changing of the user agent string to appear as a regular Chrome browser',
type: 'boolean',
})
.group(
[
'file-download-options',
'inject',
'lang',
'user-agent',
'user-agent-honest',
],
decorateYargOptionGroup('Internal Browser Options'),
)
// Internal Browser Cache Options
.option('clear-cache', {
default: false,
description: 'prevent the app from preserving cache between launches',
type: 'boolean',
})
.option('disk-cache-size', {
defaultDescription: 'chromium default',
description:
'set the maximum disk space (in bytes) to be used by the disk cache',
type: 'number',
})
.group(
['clear-cache', 'disk-cache-size'],
decorateYargOptionGroup('Internal Browser Cache Options'),
)
// URL Handling Options
.option('block-external-urls', {
default: false,
description: `forbid navigation to URLs not considered "internal" (see '--internal-urls'). Instead of opening in an external browser, attempts to navigate to external URLs will be blocked`,
type: 'boolean',
})
.option('internal-urls', {
defaultDescription: 'URLs sharing the same base domain',
description:
'regex of URLs to consider "internal"; all other URLs will be opened in an external browser',
type: 'string',
})
.option('proxy-rules', {
description:
'proxy rules; see https://www.electronjs.org/docs/api/session#sessetproxyconfig',
type: 'string',
})
.group(
['block-external-urls', 'internal-urls', 'proxy-rules'],
decorateYargOptionGroup('URL Handling Options'),
)
// Auth Options
.option('basic-auth-password', {
description: 'basic http(s) auth password',
type: 'string',
})
.option('basic-auth-username', {
description: 'basic http(s) auth username',
type: 'string',
})
.group(
['basic-auth-password', 'basic-auth-username'],
decorateYargOptionGroup('Auth Options'),
)
// Graphics Options
.option('disable-gpu', {
default: false,
description: 'disable hardware acceleration',
type: 'boolean',
})
.option('enable-es3-apis', {
default: false,
description: 'force activation of WebGL 2.0',
type: 'boolean',
})
.option('ignore-gpu-blacklist', {
default: false,
description: 'force WebGL apps to work on unsupported GPUs',
type: 'boolean',
})
.group(
['disable-gpu', 'enable-es3-apis', 'ignore-gpu-blacklist'],
decorateYargOptionGroup('Graphics Options'),
)
// (In)Security Options
.option('disable-old-build-warning-yesiknowitisinsecure', {
default: false,
description:
'disable warning shown when opening an app made too long ago; Nativefier uses the Chrome browser (through Electron), and it is dangerous to keep using an old version of it',
type: 'boolean',
})
.option('ignore-certificate', {
default: false,
description: 'ignore certificate-related errors',
type: 'boolean',
})
.option('insecure', {
default: false,
description: 'enable loading of insecure content',
type: 'boolean',
})
.group(
[
'disable-old-build-warning-yesiknowitisinsecure',
'ignore-certificate',
'insecure',
],
decorateYargOptionGroup('(In)Security Options'),
)
// Flash Options (DEPRECATED)
.option('flash', {
default: false,
deprecated: true,
description: 'enable Adobe Flash',
hidden: true,
type: 'boolean',
})
.option('flash-path', {
deprecated: true,
description: 'path to Chrome flash plugin; find it in `chrome://plugins`',
hidden: true,
normalize: true,
type: 'string',
})
// Platform Specific Options
.option('app-copyright', {
description:
'(macOS, windows only) set a human-readable copyright line for the app; maps to `LegalCopyright` metadata property on Windows, and `NSHumanReadableCopyright` on macOS',
type: 'string',
})
.option('app-version', {
description:
'(macOS, windows only) set the version of the app; maps to the `ProductVersion` metadata property on Windows, and `CFBundleShortVersionString` on macOS',
type: 'string',
})
.option('bounce', {
default: false,
description:
'(macOS only) make the dock icon bounce when the counter increases',
type: 'boolean',
})
.option('build-version', {
description:
'(macOS, windows only) set the build version of the app; maps to `FileVersion` metadata property on Windows, and `CFBundleVersion` on macOS',
type: 'string',
})
.option('counter', {
default: false,
description:
'(macOS only) set a dock count badge, determined by looking for a number in the window title',
type: 'boolean',
})
.option('darwin-dark-mode-support', {
default: false,
description: '(macOS only) enable Dark Mode support on macOS 10.14+',
type: 'boolean',
})
.option('f', {
alias: 'fast-quit',
default: false,
description: '(macOS only) quit app on window close',
type: 'boolean',
})
.option('title-bar-style', {
choices: ['hidden', 'hiddenInset'],
description:
'(macOS only) set title bar style; consider injecting custom CSS (via --inject) for better integration',
type: 'string',
})
.option('win32metadata', {
coerce: parseJson,
description:
'(windows only) a JSON string of key/value pairs (ProductName, InternalName, FileDescription) to embed as executable metadata',
type: 'string',
})
.group(
[
'app-copyright',
'app-version',
'bounce',
'build-version',
'counter',
'darwin-dark-mode-support',
'fast-quit',
'title-bar-style',
'win32metadata',
],
decorateYargOptionGroup('Platform-Specific Options'),
)
// Debug Options
.option('crash-reporter', {
description: 'remote server URL to send crash reports',
type: 'string',
})
.option('verbose', {
default: false,
description: 'enable verbose/debug/troubleshooting logs',
type: 'boolean',
})
.group(
['crash-reporter', 'verbose'],
decorateYargOptionGroup('Debug Options'),
)
.version()
.help()
.group(['version', 'help'], 'Other Options')
.wrap(yargs.terminalWidth());
function collect(val: any, memo: any[]): any[] {
memo.push(val);
return memo;
// We must access argv in order to get yargs to actually process args
// Do this now to go ahead and get any errors out of the way
args.argv;
return args;
}
function getProcessEnvs(val: string): any {
if (!val) {
return {};
}
return parseJson(val);
function decorateYargOptionGroup(value: string): string {
return `====== ${value} ======`;
}
function checkInternet(): void {
dns.lookup('npmjs.com', (err) => {
if (err && err.code === 'ENOTFOUND') {
log.warn(
'\nNo Internet Connection\nTo offline build, download electron from https://github.com/electron/electron/releases\nand place in ~/AppData/Local/electron/Cache/ on Windows,\n~/.cache/electron on Linux or ~/Library/Caches/electron/ on Mac\nUse --electron-version to specify the version you downloaded.',
export function parseArgs(args: yargs.Argv<any>): any {
const parsed = { ...args.argv };
// In yargs, the _ property of the parsed args is an array of the positional args
// https://github.com/yargs/yargs/blob/master/docs/examples.md#and-non-hyphenated-options-too-just-use-argv_
// So try to extract the targetUrl and outputDirectory from these
parsed.targetUrl = parsed._.length > 0 ? parsed._[0].toString() : '';
parsed.out = parsed._.length > 1 ? parsed._[1] : '';
if (parsed.upgrade && parsed.targetUrl !== '') {
let targetAndUpgrade = false;
if (parsed.out === '') {
// If we're upgrading, the first positional args might be the outputDirectory, so swap these if we can
try {
// If this succeeds, we have a problem
new URL(parsed.targetUrl);
targetAndUpgrade = true;
} catch {
// Cool, it's not a URL
parsed.out = parsed.targetUrl;
parsed.targetUrl = '';
}
} else {
// Someone supplied a targetUrl, an outputDirectory, and --upgrade. That's not cool.
targetAndUpgrade = true;
}
if (targetAndUpgrade) {
throw new Error(
'ERROR: Nativefier must be called with either a targetUrl or the --upgrade option, not both.\n',
);
}
});
}
if (parsed.targetUrl === '' && !parsed.upgrade) {
throw new Error(
'ERROR: Nativefier must be called with either a targetUrl or the --upgrade option.\n',
);
}
parsed.noOverwrite = parsed['no-overwrite'] = !parsed.overwrite;
return parsed;
}
if (require.main === module) {
// Not sure if we still need this with yargs. Keeping for now.
const sanitizedArgs = [];
process.argv.forEach((arg) => {
if (isArgFormatInvalid(arg)) {
log.error(
throw new Error(
`Invalid argument passed: ${arg} .\nNativefier supports short options (like "-n") and long options (like "--name"), all lowercase. Run "nativefier --help" for help.\nAborting`,
);
process.exit(1);
}
if (sanitizedArgs.length > 0) {
const previousArg = sanitizedArgs[sanitizedArgs.length - 1];
@ -61,260 +592,25 @@ if (require.main === module) {
sanitizedArgs.push(arg);
});
const positionalOptions = {
targetUrl: '',
out: '',
};
const args = commander
.name('nativefier')
.version(packageJson.version, '-v, --version')
.arguments('[targetUrl] [dest]')
.action((url, outputDirectory) => {
positionalOptions.targetUrl = url;
positionalOptions.out = outputDirectory;
})
.option('-n, --name <value>', 'app name')
.option(
'--upgrade <pathToExistingApp>',
'Upgrade an app built by an older Nativefier. You must pass the full path to the existing app executable (app will be overwritten with upgraded version by default)',
)
.addOption(
new commander.Option('-p, --platform <value>').choices(
supportedPlatforms,
),
)
.addOption(
new commander.Option('-a, --arch <value>').choices(supportedArchs),
)
.option(
'--app-version <value>',
'(macOS, windows only) the version of the app. Maps to the `ProductVersion` metadata property on Windows, and `CFBundleShortVersionString` on macOS.',
)
.option(
'--build-version <value>',
'(macOS, windows only) The build version of the app. Maps to `FileVersion` metadata property on Windows, and `CFBundleVersion` on macOS',
)
.option(
'--app-copyright <value>',
'(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>',
'(windows only) a JSON string of key/value pairs (ProductName, InternalName, FileDescription) to embed as executable metadata',
parseJson,
)
.option(
'-e, --electron-version <value>',
"electron version to package, without the 'v', see https://github.com/electron/electron/releases",
)
.option(
'--widevine',
"use a Widevine-enabled version of Electron for DRM playback (use at your own risk, it's unofficial, provided by CastLabs)",
)
.option(
'--no-overwrite',
'do not override output directory if it already exists; defaults to false',
)
.option(
'-c, --conceal',
'packages the app source code into an asar archive; defaults to false',
)
.option(
'--counter',
'(macOS only) set a dock count badge, determined by looking for a number in the window title; defaults to false',
)
.option(
'--bounce',
'(macOS only) make the dock icon bounce when the counter increases; defaults to false',
)
.option(
'-i, --icon <value>',
'the icon file to use as the icon for the app (should be a .png, on macOS can also be an .icns)',
)
.option(
'--portable',
'Make the app store its user data in the app folder. WARNING: see https://github.com/nativefier/nativefier/blob/master/docs/api.md#portable for security risks',
)
.option(
'--width <value>',
'set window default width; defaults to 1280px',
parseInt,
)
.option(
'--height <value>',
'set window default height; defaults to 800px',
parseInt,
)
.option(
'--min-width <value>',
'set window minimum width; defaults to 0px',
parseInt,
)
.option(
'--min-height <value>',
'set window minimum height; defaults to 0px',
parseInt,
)
.option(
'--max-width <value>',
'set window maximum width; default is unlimited',
parseInt,
)
.option(
'--max-height <value>',
'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(
'-f, --fast-quit',
'(macOS only) quit app on window close; defaults to false',
)
.option('-u, --user-agent <value>', 'set the app user agent string')
.option(
'--lang <value>',
'set the language or locale to render the web site as (e.g., "fr", "en-US", "es", etc.)',
)
.option(
'--honest',
'prevent the normal changing of the user agent string to appear as a regular Chrome browser',
)
.option('--ignore-certificate', 'ignore certificate-related errors')
.option('--disable-gpu', 'disable hardware acceleration')
.option(
'--ignore-gpu-blacklist',
'force WebGL apps to work on unsupported GPUs',
)
.option('--enable-es3-apis', 'force activation of WebGL 2.0')
.option(
'--insecure',
'enable loading of insecure content; defaults to false',
)
.option('--flash', 'enables Adobe Flash; defaults to false')
.option(
'--flash-path <value>',
'path to Chrome flash plugin; find it in `chrome://plugins`',
)
.option(
'--disk-cache-size <value>',
'forces the maximum disk space (in bytes) to be used by the disk cache',
)
.option(
'--inject <value>',
'path to a CSS/JS file to be injected. Pass multiple times to inject multiple files.',
collect,
[],
)
.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', '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',
parseFloat,
)
.option(
'--internal-urls <value>',
'regex of URLs to consider "internal"; all other URLs will be opened in an external browser. Default: URLs sharing the same base domain, once stripped of www.',
)
.option(
'--block-external-urls',
`forbid navigation to URLs not considered "internal" (see '--internal-urls'). Instead of opening in an external browser, attempts to navigate to external URLs will be blocked. Default: false`,
)
.option(
'--proxy-rules <value>',
'proxy rules; see https://www.electronjs.org/docs/api/session#sessetproxyconfig',
)
.option(
'--crash-reporter <value>',
'remote server URL to send crash reports',
)
.option(
'--single-instance',
'allow only a single instance of the application',
)
.option(
'--clear-cache',
'prevent the application from preserving cache between launches',
)
.option(
'--processEnvs <json-string>',
'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.',
parseJson,
)
.option(
'--tray [start-in-tray]',
"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",
)
.option(
'--global-shortcuts <value>',
'JSON file defining global shortcuts. See https://github.com/nativefier/nativefier/blob/master/docs/api.md#global-shortcuts',
)
.option(
'--browserwindow-options <json-string>',
'a JSON string that will be sent directly into electron BrowserWindow options. See https://github.com/nativefier/nativefier/blob/master/docs/api.md#browserwindow-options',
parseJson,
)
.option(
'--background-color <value>',
"sets the app background color, for better integration while the app is loading. Example value: '#2e2c29'",
)
.option(
'--disable-old-build-warning-yesiknowitisinsecure',
'Disables warning when opening an app made with an old version of Nativefier. Nativefier uses the Chrome browser (through Electron), and it is dangerous to keep using an old version of it.)',
)
.option(
'--darwin-dark-mode-support',
'(macOS only) enable Dark Mode support on macOS 10.14+',
)
.option(
'--bookmarks-menu <value>',
'Path to JSON configuration file for the bookmarks menu.',
);
let args, parsedArgs;
try {
args.parse(sanitizedArgs);
args = initArgs(sanitizedArgs.slice(2));
parsedArgs = parseArgs(args);
} catch (err) {
log.error('Failed to parse command-line arguments. Aborting.');
if (args) {
log.error(err);
args.showHelp();
} else {
log.error('Failed to parse command-line arguments. Aborting.', err);
}
process.exit(1);
}
if (!process.argv.slice(2).length) {
commander.help();
}
checkInternet();
const options: NativefierOptions = {
...positionalOptions,
...commander.opts(),
...parsedArgs,
};
if (!options.targetUrl && !options.upgrade) {
log.error(
'Nativefier must be called with either a targetUrl or the --upgrade option.',
);
commander.help();
}
checkInternet();
buildNativefierApp(options).catch((error) => {
log.error('Error during build. Run with --verbose for details.', error);

View File

@ -4,11 +4,14 @@ import * as os from 'os';
import * as path from 'path';
import axios from 'axios';
import * as dns from 'dns';
import * as hasbin from 'hasbin';
import * as log from 'loglevel';
import { ncp } from 'ncp';
import * as tmp from 'tmp';
import { parseJson } from '../utils/parseUtils';
tmp.setGracefulCleanup(); // cleanup temp dirs even when an uncaught exception occurs
const now = new Date();
@ -171,3 +174,20 @@ export function generateRandomSuffix(length = 6): string {
hash.update(crypto.randomBytes(256));
return hash.digest('hex').substring(0, length);
}
export function getProcessEnvs(val: string): any {
if (!val) {
return {};
}
return parseJson(val);
}
export function checkInternet(): void {
dns.lookup('npmjs.com', (err) => {
if (err && err.code === 'ENOTFOUND') {
log.warn(
'\nNo Internet Connection\nTo offline build, download electron from https://github.com/electron/electron/releases\nand place in ~/AppData/Local/electron/Cache/ on Windows,\n~/.cache/electron on Linux or ~/Library/Caches/electron/ on Mac\nUse --electron-version to specify the version you downloaded.',
);
}
});
}

View File

@ -36,6 +36,7 @@ export interface AppOptions {
fullScreen: boolean;
globalShortcuts: any;
hideWindowFrame: boolean;
honest: boolean;
ignoreCertificate: boolean;
ignoreGpuBlacklist: boolean;
inject: string[];

View File

@ -78,6 +78,7 @@ export async function getOptions(rawOptions: any): Promise<AppOptions> {
fullScreen: rawOptions.fullScreen || false,
globalShortcuts: null,
hideWindowFrame: rawOptions.hideWindowFrame,
honest: rawOptions.honest || false,
ignoreCertificate: rawOptions.ignoreCertificate || false,
ignoreGpuBlacklist: rawOptions.ignoreGpuBlacklist || false,
inject: rawOptions.inject || [],
@ -130,8 +131,8 @@ export async function getOptions(rawOptions: any): Promise<AppOptions> {
log.setLevel('info');
}
if (rawOptions.electronVersion) {
const requestedVersion: string = rawOptions.electronVersion;
if (options.packager.electronVersion) {
const requestedVersion: string = options.packager.electronVersion;
if (!SEMVER_VERSION_NUMBER_REGEX.exec(requestedVersion)) {
throw `Invalid Electron version number "${requestedVersion}". Aborting.`;
}
@ -145,7 +146,7 @@ export async function getOptions(rawOptions: any): Promise<AppOptions> {
}
}
if (rawOptions.widevine) {
if (options.nativefier.widevine) {
const widevineElectronVersion = `${options.packager.electronVersion}-wvvmp`;
try {
await axios.get(
@ -162,8 +163,8 @@ export async function getOptions(rawOptions: any): Promise<AppOptions> {
`\nATTENTION: Using the **unofficial** Electron from castLabs`,
"\nIt implements Google's Widevine Content Decryption Module (CDM) for DRM-enabled playback.",
`\nSimply abort & re-run without passing the widevine flag to default to ${
rawOptions.electronVersion !== undefined
? (rawOptions.electronVersion as string)
options.packager.electronVersion !== undefined
? options.packager.electronVersion
: DEFAULT_ELECTRON_VERSION
}`,
);
@ -173,7 +174,7 @@ export async function getOptions(rawOptions: any): Promise<AppOptions> {
options.nativefier.insecure = true;
}
if (rawOptions.honest) {
if (options.nativefier.honest) {
options.nativefier.userAgent = null;
}