Selaa lähdekoodia

refactor(project): initialize

evshiron 8 vuotta sitten
commit
e4b9bb9d4d

+ 21 - 0
LICENSE

@@ -0,0 +1,21 @@
+The MIT License (MIT)
+
+Copyright (c) 2017 evshiron
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.

+ 63 - 0
README.md

@@ -0,0 +1,63 @@
+
+# nwjs-builder-phoenix
+
+A possible solution to build and package a ready for distribution NW.js app for Windows, macOS and Linux.
+
+## Why Bother?
+
+We already has official `nw-builder` and `nwjs-builder`, which was built as an alternative before `nw-builder` would support 0.13.x and later versions.
+`nw-builder` has made little progress on the way, and `nwjs-builder` has been hard to continue due to personal and historic reasons.
+
+`electron-builder` inspired me when I became an Electron user later, loose files excluding, various target formats, auto updater, artifacts publishing and code signing, amazing!
+
+Although NW.js has much lesser popularity than Electron, and is really troubled by historic headaches, let's have something modern.
+
+## Installation
+
+```shell
+npm install nwjs-builder-phoenix --save-dev
+```
+
+By installing it locally, `build` and `run` commands will be available in npm scripts. You can access help information via `./node_modules/.bin/{ build, run } --help`. Do NOT install it globally, as the command names are just too common.
+
+Add the following to `package.json`, and `npm run build` and `npm run launch` will work.
+
+```json
+// package.json
+{
+    "scripts": {
+        "build": "build --win --mac --linux --x86 --x64 --mirror https://dl.nwjs.io/ .",
+        "launch": "run --x86 --mirror https://dl.nwjs.io/ ."
+    }
+}
+```
+
+## Options
+
+Passing and managing commandline arguments can be painful. In `nwjs-builder-phoenix`, we configure via the `build` property of the `package.json` in the project. Also you can specify external `builder.json` file (whatever filename) by appending `--config builder.json` to CLI arguments.
+
+See all available [Options](./docs/Options.md).
+
+## Examples
+
+* [./assets/project/](./assets/project/)
+
+## Differences to `nwjs-builder`
+
+* `nwjs-builder-phoenix` queries `versions.json` only when a symbol like `lts`, `stable` or `latest` is used to specify a version.
+* `nwjs-builder-phoenix` uses `rcedit` instead of `node-resourcehacker`, thus it's up to you to create proper `.ico` files with different sizes.
+* `nwjs-builder-phoenix` supports node.js 4.x and later versions only.
+* `nwjs-builder-phoenix` writes with TypeScript and benefits from strong typing and async/await functions.
+
+## Known Mirrors
+
+If you have difficulties connecting to the official download source, you can specify a mirror via `--mirror` argument of both `build` and `run`, or by setting `NWJS_MIRROR` environment variable. Environment variables like `HTTP_PROXY`, `HTTPS_PROXY` and `ALL_PROXY` should be useful too.
+
+* China Mainland
+  * https://npm.taobao.org/mirrors/nwjs/
+* Singapore
+  * https://cnpmjs.org/mirrors/nwjs/
+
+## License
+
+MIT.

+ 10 - 0
assets/project/index.html

@@ -0,0 +1,10 @@
+<html>
+<head></head>
+<body>
+<script>
+
+process.exit(parseInt(nw.App.argv[0]));
+
+</script>
+</body>
+</html>

+ 32 - 0
assets/project/package.json

@@ -0,0 +1,32 @@
+{
+  "name": "project",
+  "version": "0.1.0",
+  "description": "description",
+  "main": "index.html",
+  "scripts": {
+    "test": "echo \"Error: no test specified\" && exit 1",
+    "build": "build --mac --x64 --mirror https://npm.taobao.org/mirrors/nwjs/ ."
+  },
+  "author": "evshiron",
+  "license": "MIT",
+  "build": {
+    "nwVersion": "lts",
+    "targets": [
+      "zip"
+    ],
+    "win": {
+      "versionStrings": {
+        "ProductName": "Project",
+        "LegalCopyright": "copyright"
+      }
+    },
+    "mac": {
+      "displayName": "Project",
+      "copyright": "copyright"
+    }
+  },
+  "devDependencies": {
+    "nwjs-builder-phoenix": "github:evshiron/nwjs-builder-phoenix",
+    "typescript": "^2.2.1"
+  }
+}

+ 42 - 0
docs/Options.md

@@ -0,0 +1,42 @@
+
+# Options
+
+Options can be defined in the `package.json` of the project under `build` property, see an [example](../assets/project/package.json).
+
+## build <- [BuildConfig](../src/lib/buildConfig.ts)
+
+Name | Type | Description
+--- | --- | ---
+nwVersion | string | Used NW.js version. Support `lts`, `stable` and `latest` symbols. Defaults to `lts`.
+nwFlavor | string | Used NW.js flavor for builder. Runner will always use `sdk`. `normal` or `sdk`. Defaults to `normal`.
+output | string | Output directory relative to the project root. Defaults to `./dist/`.
+packed | boolean | Whether to pack app or not. Packed app needed to be extracted at launch time. Defaults to `false`.
+targets | string[] | Target formats to build. `zip`, `7z`, etc. Defaults to `[]`.
+files | string[] | Glob patterns for included files. Exclude `${ output }` automatically. Defaults to `[ '**/*' ]`.
+excludes | string[] | Glob patterns for excluded files. Defaults to `[]`.
+appId | string | App identity URI. Defaults to `io.github.nwjs.${ name }`.
+ffmpegIntegration | boolean | Whether to integrate `iteufel/nwjs-ffmpeg-prebuilt`. If `true`, you can NOT use symbols in `nwVersion`. Defaults to `false`.
+
+## build.win <- [WinConfig](../src/lib/winConfig.ts)
+
+Name | Type | Description
+--- | --- | ---
+productVersion | string | Product version. Defaults to `${ version }`.
+fileVersion | string | File version. Defaults to `${ productVersion }`
+versionStrings | { [key: string]: string } | `rcedit` version strings. Defaults to `{ ProductName: "${ name }", FileDescription: "${ description }" }`.
+icon | string | .ico icon file. Defaults to `undefined`.
+
+## build.mac <- [MacConfig](../src/lib/macConfig.ts)
+
+Name | Type | Description
+--- | --- | ---
+name | string | Name in `Info.plist`. Defaults to `${ name }`.
+displayName | string | DisplayName in `Info.plist`. Defaults to `${ name }`.
+version | string | Version in `Info.plist`. Defaults to `${ version }`.
+description | string | Description in `InfoPlist.strings`. Defaults to `${ description }`.
+copyright | string | Copyright in `InfoPlist.strings`. Defaults to `""`.
+icon | string | .icns icon file. Defaults to `undefined`.
+
+## build.linux <- [LinuxConfig](../src/lib/linuxConfig.ts)
+
+Currently noop.

+ 57 - 0
package.json

@@ -0,0 +1,57 @@
+{
+  "name": "nwjs-builder-phoenix",
+  "version": "1.0.0",
+  "description": "",
+  "main": "./dist/lib/index.js",
+  "bin": {
+    "build": "./dist/bin/build.js",
+    "run": "./dist/bin/run.js"
+  },
+  "scripts": {
+    "pretest": "npm run build",
+    "test": "ava --verbose",
+    "coverage": "nyc ava",
+    "build": "tsc",
+    "release": "npm test && standard-version"
+  },
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/evshiron/nwjs-builder-phoenix.git"
+  },
+  "author": "evshiron",
+  "license": "MIT",
+  "bugs": {
+    "url": "https://github.com/evshiron/nwjs-builder-phoenix/issues"
+  },
+  "homepage": "https://github.com/evshiron/nwjs-builder-phoenix#readme",
+  "devDependencies": {
+    "@types/debug": "0.0.29",
+    "@types/es6-shim": "^0.31.32",
+    "@types/fs-extra-promise": "0.0.31",
+    "@types/node": "^7.0.5",
+    "@types/progress": "^1.1.28",
+    "@types/request": "0.0.41",
+    "@types/tmp": "0.0.32",
+    "@types/yargs": "^6.6.0",
+    "ava": "^0.18.2",
+    "cross-env": "^3.2.3",
+    "nyc": "^10.1.2",
+    "standard-version": "^4.0.0",
+    "tslint": "^4.5.1",
+    "typescript": "^2.2.1"
+  },
+  "dependencies": {
+    "7zip-bin": "^2.0.4",
+    "debug": "^2.6.1",
+    "fs-extra-promise": "^0.4.1",
+    "globby": "^6.1.0",
+    "plist": "^2.0.1",
+    "progress": "^1.1.8",
+    "rcedit": "^0.8.0",
+    "request": "^2.80.0",
+    "request-progress": "^3.0.0",
+    "source-map-support": "^0.4.11",
+    "tmp": "0.0.31",
+    "yargs": "^7.0.1"
+  }
+}

+ 73 - 0
src/bin/build.ts

@@ -0,0 +1,73 @@
+#!/usr/bin/env node
+
+import * as yargs from 'yargs';
+
+const debug = require('debug')('build:commandline:build');
+
+import { Builder } from '../lib';
+
+const argv = require('yargs')
+.option('x86', {
+    type: 'boolean',
+    describe: 'Build for x86 arch',
+    default: Builder.DEFAULT_OPTIONS.x86,
+})
+.option('x64', {
+    type: 'boolean',
+    describe: 'Build for x64 arch',
+    default: Builder.DEFAULT_OPTIONS.x64,
+})
+.option('win', {
+    type: 'boolean',
+    describe: 'Build for Windows platform',
+    default: Builder.DEFAULT_OPTIONS.win,
+    alias: 'w',
+})
+.option('mac', {
+    type: 'boolean',
+    describe: 'Build for macOS platform',
+    default: Builder.DEFAULT_OPTIONS.mac,
+    alias: 'm',
+})
+.option('linux', {
+    type: 'boolean',
+    describe: 'Build for Linux platform',
+    default: Builder.DEFAULT_OPTIONS.linux,
+    alias: 'l',
+})
+.option('mirror', {
+    describe: 'Modify NW.js mirror',
+    default: Builder.DEFAULT_OPTIONS.mirror,
+})
+.option('config', {
+    describe: 'Specify external config',
+    default: Builder.DEFAULT_OPTIONS.config,
+})
+.help()
+.argv;
+
+(async () => {
+
+    debug('in commandline', 'argv', argv);
+
+    const builder = new Builder({
+        win: argv.win,
+        mac: argv.mac,
+        linux: argv.linux,
+        x86: argv.x86,
+        x64: argv.x64,
+        mirror: argv.mirror,
+        mute: false,
+    }, argv._.shift());
+
+    await builder.build();
+
+    process.exitCode = 0;
+
+})()
+.catch((err) => {
+
+    console.error(err);
+    process.exitCode = -1;
+
+});

+ 58 - 0
src/bin/run.ts

@@ -0,0 +1,58 @@
+#!/usr/bin/env node
+
+import * as yargs from 'yargs';
+
+const debug = require('debug')('build:commandline:run');
+
+import { Runner } from '../lib';
+
+const argv = require('yargs')
+.option('x86', {
+    type: 'boolean',
+    describe: 'Build for x86 arch',
+    default: Runner.DEFAULT_OPTIONS.x86,
+})
+.option('x64', {
+    type: 'boolean',
+    describe: 'Build for x64 arch',
+    default: Runner.DEFAULT_OPTIONS.x64,
+})
+.option('mirror', {
+    describe: 'Modify NW.js mirror',
+    default: Runner.DEFAULT_OPTIONS.mirror,
+})
+.option('detached', {
+    describe: 'Detach after launching',
+    type: 'boolean',
+    default: Runner.DEFAULT_OPTIONS.detached,
+})
+.option('config', {
+    describe: 'Specify external config',
+    default: Runner.DEFAULT_OPTIONS.config,
+})
+.help()
+.argv;
+
+(async () => {
+
+    debug('in commandline', 'argv', argv);
+
+    const runner = new Runner({
+        x86: argv.x86,
+        x64: argv.x64,
+        mirror: argv.mirror,
+        detached: argv.detached,
+        mute: false,
+    }, argv._);
+
+    const code = await runner.run();
+
+    process.exitCode = code;
+
+})()
+.catch((err) => {
+
+    console.error(err);
+    process.exitCode = -1;
+
+});

+ 65 - 0
src/lib/BuildConfig.ts

@@ -0,0 +1,65 @@
+
+import { normalize } from 'path';
+
+import { WinConfig } from './WinConfig';
+import { MacConfig } from './MacConfig';
+import { LinuxConfig } from './LinuxConfig';
+
+export class BuildConfig {
+
+    public nwVersion: string = 'lts';
+    public nwFlavor: string = 'normal';
+
+    public output: string = './dist/';
+    public packed: boolean = false;
+    public targets: string[] = [];
+    public files: string[] = [ '**/*' ];
+    public excludes: string[] = [];
+
+    public win: WinConfig = new WinConfig();
+    public mac: MacConfig = new MacConfig();
+    public linux: LinuxConfig = new LinuxConfig();
+
+    public appId: string = undefined;
+    public ffmpegIntegration: boolean = false;
+
+    constructor(pkg: any = {}) {
+
+        const options = pkg.build ? pkg.build : {};
+
+        Object.keys(this).map((key) => {
+            if(options[key] !== undefined) {
+                switch(key) {
+                case 'win':
+                    this.win = new WinConfig(options.win);
+                    break;
+                case 'mac':
+                    this.mac = new MacConfig(options.mac);
+                    break;
+                case 'linux':
+                    this.linux = new LinuxConfig(options.linux);
+                    break;
+                default:
+                    (<any>this)[key] = options[key];
+                    break;
+                }
+            }
+        });
+
+        this.output = normalize(this.output);
+
+        this.appId = `io.github.nwjs.${ pkg.name }`;
+
+        this.win.versionStrings.ProductName = this.win.versionStrings.ProductName ? this.win.versionStrings.ProductName : pkg.name;
+        this.win.versionStrings.FileDescription = this.win.versionStrings.FileDescription ? this.win.versionStrings.FileDescription : pkg.description;
+        this.win.productVersion = this.win.productVersion ? this.win.productVersion : pkg.version;
+        this.win.fileVersion = this.win.fileVersion ? this.win.fileVersion : this.win.productVersion;
+
+        this.mac.name = this.mac.name ? this.mac.name : pkg.name;
+        this.mac.displayName = this.mac.displayName ? this.mac.displayName : this.mac.name;
+        this.mac.version = this.mac.version ? this.mac.version : pkg.version;
+        this.mac.description = this.mac.description ? this.mac.description : pkg.description;
+
+    }
+
+}

+ 502 - 0
src/lib/Builder.ts

@@ -0,0 +1,502 @@
+
+import { dirname, basename, join, resolve } from 'path';
+
+import { ensureDirAsync, emptyDir, readFileAsync, readJsonAsync, writeFileAsync, copyAsync, removeAsync, createReadStream, createWriteStream, renameAsync } from 'fs-extra-promise';
+
+const debug = require('debug')('build:builder');
+const globby = require('globby');
+const rcedit = require('rcedit');
+const plist = require('plist');
+
+import { Downloader } from './Downloader';
+import { FFmpegDownloader } from './FFmpegDownloader';
+import { extract, extractTarGz, compress } from './archive';
+import { BuildConfig } from './BuildConfig';
+import { mergeOptions, findExecutable, findFFmpeg, findRuntimeRoot, findExcludableDependencies, tmpName, tmpFile, tmpDir, cpAsync } from './util';
+
+interface IBuilderOptions {
+    win?: boolean;
+    mac?: boolean;
+    linux?: boolean;
+    x86?: boolean;
+    x64?: boolean;
+    mirror?: string;
+    config?: string;
+    mute?: boolean;
+}
+
+export class Builder {
+
+    public static DEFAULT_OPTIONS: IBuilderOptions = {
+        win: false,
+        mac: false,
+        linux: false,
+        x86: false,
+        x64: false,
+        mirror: Downloader.DEFAULT_OPTIONS.mirror,
+        config: undefined,
+        mute: true,
+    };
+
+    public options: IBuilderOptions;
+
+    constructor(options: IBuilderOptions = {}, public dir: string) {
+
+        this.options = mergeOptions(Builder.DEFAULT_OPTIONS, options);
+
+        debug('in constructor', 'dir', dir);
+        debug('in constructor', 'options', this.options);
+
+    }
+
+    public async build() {
+
+        const tasks: string[][] = [];
+
+        [ 'win', 'mac', 'linux' ].map((platform) => {
+            [ 'x86', 'x64' ].map((arch) => {
+                if((<any>this.options)[platform] && (<any>this.options)[arch]) {
+                    tasks.push([ platform, arch ]);
+                }
+            });
+        });
+
+        if(!this.options.mute) {
+            console.info('Starting building tasks...', {
+                tasks,
+            });
+        }
+
+        if(tasks.length == 0) {
+            throw new Error('ERROR_NO_TASK');
+        }
+
+        const configPath = this.options.config ? this.options.config : join(this.dir, 'package.json');
+
+        const pkg: any = await readJsonAsync(configPath);
+        const config = new BuildConfig(pkg);
+
+        debug('in build', 'configPath', configPath);
+        debug('in build', 'config', config);
+
+        for(const [ platform, arch ] of tasks) {
+
+            await this.buildTask(platform, arch, pkg, config);
+
+        }
+
+    }
+
+    protected combineExecutable(executable: string, nwFile: string) {
+        return new Promise((resolve, reject) => {
+
+            const nwStream = createReadStream(nwFile);
+            const stream = createWriteStream(executable, {
+                flags: 'a',
+            });
+
+            nwStream.on('error', reject);
+            stream.on('error', reject);
+
+            stream.on('finish', resolve);
+
+            nwStream.pipe(stream);
+
+        });
+    }
+
+    protected readPlist(path: string): Promise<any> {
+        return readFileAsync(path, {
+                encoding: 'utf-8',
+        })
+        .then(data => plist.parse(data));
+    }
+
+    protected writePlist(path: string, p: any) {
+        return writeFileAsync(path, plist.build(p));
+    }
+
+    protected updateWinResources(targetDir: string, appRoot: string, pkg: any, config: BuildConfig) {
+        return new Promise((resolve, reject) => {
+
+            const path = join(targetDir, './nw.exe');
+
+            const rc = {
+                'product-version': config.win.productVersion,
+                'file-version': config.win.fileVersion,
+                'version-string': config.win.versionStrings,
+                'icon': config.win.icon,
+            };
+
+            rcedit(path, rc, (err: Error) => err ? reject(err) : resolve());
+
+        });
+    }
+
+    protected renameWinApp(targetDir: string, appRoot: string, pkg: any, config: BuildConfig) {
+
+        const src = join(targetDir, 'nw.exe');
+        const dest = join(targetDir, `${ config.win.versionStrings.ProductName }.exe`);
+
+        return renameAsync(src, dest);
+
+    }
+
+    protected async updatePlist(targetDir: string, appRoot: string, pkg: any, config: BuildConfig) {
+
+        const path = join(targetDir, './nwjs.app/Contents/Info.plist');
+
+        const plist = await this.readPlist(path);
+
+        plist.CFBundleIdentifier = config.appId;
+        plist.CFBundleName = config.mac.name;
+        plist.CFBundleDisplayName = config.mac.displayName;
+        plist.CFBundleVersion = config.mac.version;
+        plist.CFBundleShortVersionString = config.mac.version;
+
+        await this.writePlist(path, plist);
+
+    }
+
+    protected async updateMacIcon(targetDir: string, appRoot: string, pkg: any, config: BuildConfig) {
+
+        const path = join(targetDir, './nwjs.app/Contents/Resources/app.icns');
+
+        if(!config.mac.icon) {
+            return;
+        }
+
+        await copyAsync(config.mac.icon, path);
+
+    }
+
+    protected async fixMacMeta(targetDir: string, appRoot: string, pkg: any, config: BuildConfig) {
+
+        const files = await globby([ '**/InfoPlist.strings' ], {
+            cwd: targetDir,
+        });
+
+        for(const file of files) {
+
+            const path = join(targetDir, file);
+
+            const strings = <string>(await readFileAsync(path, {
+                encoding: 'ucs2',
+            }));
+
+            const newStrings = strings.replace(/([A-Za-z]+)\s+=\s+"(.+?)";/g, (match: string, key: string, value: string) => {
+                switch(key) {
+                case 'CFBundleName':
+                    return `${ key } = "${ config.mac.name }";`;
+                case 'CFBundleDisplayName':
+                    return `${ key } = "${ config.mac.displayName }";`;
+                case 'CFBundleGetInfoString':
+                    return `${ key } = "${ config.mac.version }";`;
+                case 'NSContactsUsageDescription':
+                    return `${ key } = "${ config.mac.description }";`;
+                case 'NSHumanReadableCopyright':
+                    return `${ key } = "${ config.mac.copyright }";`;
+                default:
+                    return `${ key } = "${ value }";`;
+                }
+            });
+
+            await writeFileAsync(path, Buffer.from(newStrings, 'ucs2'));
+
+        }
+
+    }
+
+    protected renameMacApp(targetDir: string, appRoot: string, pkg: any, config: BuildConfig) {
+
+            const src = join(targetDir, 'nwjs.app');
+            const dest = join(targetDir, `${ config.mac.displayName }.app`);
+
+            return renameAsync(src, dest);
+
+    }
+
+    protected renameLinuxApp(targetDir: string, appRoot: string, pkg: any, config: BuildConfig) {
+
+        const src = join(targetDir, 'nw');
+        const dest = join(targetDir, `${ pkg.name }`);
+
+        return renameAsync(src, dest);
+
+    }
+
+    protected async prepareWinBuild(targetDir: string, appRoot: string, pkg: any, config: BuildConfig) {
+
+        await this.updateWinResources(targetDir, appRoot, pkg, config);
+
+    }
+
+    protected async prepareMacBuild(targetDir: string, appRoot: string, pkg: any, config: BuildConfig) {
+
+        await this.updatePlist(targetDir, appRoot, pkg, config);
+        await this.updateMacIcon(targetDir, appRoot, pkg, config);
+        await this.fixMacMeta(targetDir, appRoot, pkg, config);
+
+    }
+
+    protected async prepareLinuxBuild(targetDir: string, appRoot: string, pkg: any, config: BuildConfig) {
+
+    }
+
+    protected async copyFiles(platform: string, targetDir: string, appRoot: string, pkg: any, config: BuildConfig) {
+
+        const generalExcludes = [
+            '**/node_modules/.bin',
+            '**/node_modules/*/{ example, examples, test, tests }',
+            '**/{ .DS_Store, .git, .hg, .svn, *.log }',
+        ];
+
+        const dependenciesExcludes = await findExcludableDependencies(this.dir, pkg)
+        .then((excludable) => {
+            return excludable.map(excludable => [ excludable, `${ excludable }/**/*` ]);
+        })
+        .then((excludes) => {
+            return Array.prototype.concat.apply([], excludes);
+        });
+
+        debug('in copyFiles', 'dependenciesExcludes', dependenciesExcludes);
+
+        const ignore = [
+            ...config.excludes,
+            ...generalExcludes,
+            ...dependenciesExcludes,
+            ...[ config.output, `${ config.output }/**/*` ]
+        ];
+
+        debug('in copyFiles', 'ignore', ignore);
+
+        const files = await globby(config.files, {
+            cwd: this.dir,
+            mark: true,
+            ignore,
+        });
+
+        debug('in copyFiles', 'config.files', config.files);
+        debug('in copyFiles', 'files', files);
+
+        if(config.packed) {
+
+            switch(platform) {
+            case 'win32':
+            case 'win':
+            case 'linux':
+                const nwFile = await tmpName();
+                await compress(this.dir, files, 'zip', nwFile);
+                const executable = await findExecutable(platform, targetDir);
+                await this.combineExecutable(executable, nwFile);
+                await removeAsync(nwFile);
+                break;
+            case 'darwin':
+            case 'osx':
+            case 'mac':
+                for(const file of files) {
+                    await cpAsync(join(this.dir, file), join(appRoot, file));
+                }
+                break;
+            default:
+                throw new Error('ERROR_UNKNOWN_PLATFORM');
+            }
+
+        }
+        else {
+
+            for(const file of files) {
+                await cpAsync(join(this.dir, file), join(appRoot, file));
+            }
+
+        }
+
+    }
+
+    protected async integrateFFmpeg(platform: string, arch: string, runtimeDir: string, pkg: any, config: BuildConfig) {
+
+        const downloader = new FFmpegDownloader({
+            platform, arch,
+            version: config.nwVersion,
+            useCaches: true,
+            showProgress: this.options.mute ? false : true,
+        });
+
+        if(!this.options.mute) {
+            console.info('Fetching FFmpeg prebuilt...', {
+                platform: downloader.options.platform,
+                arch: downloader.options.arch,
+                version: downloader.options.version,
+            });
+        }
+
+        const archivePath = await downloader.fetch();
+
+        const { path: ffmpegDir, cleanup } = await tmpDir();
+
+        if(!this.options.mute) {
+            console.info('Extracting FFmpeg prebuilt...', {
+                ffmpegDir,
+            });
+        }
+
+        if(archivePath.endsWith('.zip')) {
+            await extract(archivePath, ffmpegDir);
+        }
+        else {
+            throw new Error('ERROR_UNKNOWN_EXTENSION');
+        }
+
+        const src = await findFFmpeg(platform, ffmpegDir);
+        const dest = await findFFmpeg(platform, runtimeDir);
+
+        await copyAsync(src, dest);
+
+    }
+
+    protected async buildDirTarget(platform: string, arch: string, runtimeDir: string, pkg: any, config: BuildConfig): Promise<string> {
+
+        const targetDir = join(this.dir, config.output, `${ pkg.name }-${ pkg.version }-${ platform }-${ arch }`);
+        const runtimeRoot = await findRuntimeRoot(platform, runtimeDir);
+        const appRoot = join(targetDir, (() => {
+            switch(platform) {
+            case 'win32':
+            case 'win':
+            case 'linux':
+                return './';
+            case 'darwin':
+            case 'osx':
+            case 'mac':
+                return './nwjs.app/Contents/Resources/app.nw/';
+            default:
+                throw new Error('ERROR_UNKNOWN_PLATFORM');
+            }
+        })());
+
+        await new Promise((resolve, reject) => {
+            emptyDir(targetDir, err => err ? reject(err) : resolve());
+        });
+
+        await copyAsync(runtimeRoot, targetDir);
+
+        await ensureDirAsync(appRoot);
+
+        // Copy before refining might void the effort.
+
+        switch(platform) {
+        case 'win32':
+        case 'win':
+            await this.prepareWinBuild(targetDir, appRoot, pkg, config);
+            await this.copyFiles(platform, targetDir, appRoot, pkg, config);
+            await this.renameWinApp(targetDir, appRoot, pkg, config);
+            break;
+        case 'darwin':
+        case 'osx':
+        case 'mac':
+            await this.prepareMacBuild(targetDir, appRoot, pkg, config);
+            await this.copyFiles(platform, targetDir, appRoot, pkg, config);
+            await this.renameMacApp(targetDir, appRoot, pkg, config);
+            break;
+        case 'linux':
+            await this.prepareLinuxBuild(targetDir, appRoot, pkg, config);
+            await this.copyFiles(platform, targetDir, appRoot, pkg, config);
+            await this.renameLinuxApp(targetDir, appRoot, pkg, config);
+            break;
+        default:
+            throw new Error('ERROR_UNKNOWN_PLATFORM');
+        }
+
+        return targetDir;
+
+    }
+
+    protected async buildArchiveTarget(type: string, targetDir: string) {
+
+        const targetZip = join(dirname(targetDir), `${ basename(targetDir) }.${ type }`);
+
+        await removeAsync(targetZip);
+
+        const files = await globby([ '**/*' ], {
+            cwd: targetDir,
+        });
+
+        await compress(targetDir, files, type, targetZip);
+
+        return targetZip;
+
+    }
+
+    protected async buildTask(platform: string, arch: string, pkg: any, config: BuildConfig) {
+
+        const downloader = new Downloader({
+            platform, arch,
+            version: config.nwVersion,
+            flavor: config.nwFlavor,
+            mirror: this.options.mirror,
+            useCaches: true,
+            showProgress: this.options.mute ? false : true,
+        });
+
+        if(!this.options.mute) {
+            console.info('Fetching NW.js binary...', {
+                platform: downloader.options.platform,
+                arch: downloader.options.arch,
+                version: downloader.options.version,
+                flavor: downloader.options.flavor,
+            });
+        }
+
+        const path = await downloader.fetch();
+
+        const { path: runtimeDir, cleanup } = await tmpDir();
+
+        if(!this.options.mute) {
+            console.info('Extracting NW.js binary...', {
+                runtimeDir,
+            });
+        }
+
+        if(path.endsWith('.zip')) {
+            await extract(path, runtimeDir);
+        }
+        else if(path.endsWith('tar.gz')) {
+            await extractTarGz(path, runtimeDir);
+        }
+        else {
+            throw new Error('ERROR_UNKNOWN_EXTENSION');
+        }
+
+        if(config.ffmpegIntegration) {
+            await this.integrateFFmpeg(platform, arch, runtimeDir, pkg, config);
+        }
+
+        if(!this.options.mute) {
+            console.info('Building directory target...');
+        }
+
+        const targetDir = await this.buildDirTarget(platform, arch, runtimeDir, pkg, config);
+
+        for(const target of config.targets) {
+            switch(target) {
+            case 'zip':
+            case '7z':
+                if(!this.options.mute) {
+                    console.info(`Building ${ target } archive target...`);
+                }
+                await this.buildArchiveTarget(target, targetDir);
+                break;
+            case 'nsis':
+            default:
+                throw new Error('ERROR_UNKNOWN_TARGET');
+            }
+        }
+
+        cleanup();
+
+        if(!this.options.mute) {
+            console.info(`Building for ${ platform }, ${ arch } ends.`);
+        }
+
+    }
+
+}

+ 250 - 0
src/lib/Downloader.ts

@@ -0,0 +1,250 @@
+
+import { dirname, basename, join, resolve } from 'path';
+
+import * as request from 'request';
+import * as ProgressBar from 'progress';
+import { ensureDirSync, exists, writeFile } from 'fs-extra-promise';
+
+const debug = require('debug')('build:downloader');
+const progress = require('request-progress');
+
+import { Event } from './Event';
+import { mergeOptions } from './util';
+
+const DIR_CACHES = resolve(dirname(module.filename), '..', '..', 'caches');
+ensureDirSync(DIR_CACHES);
+
+interface IRequestProgress {
+    percent: number;
+    speed: number;
+    size: {
+        total: number,
+        transferred: number,
+    };
+    time: {
+        elapsed: number,
+        remaining: number,
+    };
+}
+
+interface IDownloaderOptions {
+    platform?: string;
+    arch?: string;
+    version?: string;
+    flavor?: string;
+    mirror?: string;
+    useCaches?: boolean;
+    showProgress?: boolean;
+}
+
+export class Downloader {
+
+    public static DEFAULT_OPTIONS: IDownloaderOptions = {
+        platform: process.platform,
+        arch: process.arch,
+        version: '0.14.7',
+        flavor: 'normal',
+        mirror: 'https://dl.nwjs.io/',
+        useCaches: true,
+        showProgress: true,
+    };
+
+    public onProgress: Event<IRequestProgress> = new Event('progress');
+
+    public options: IDownloaderOptions;
+
+    private destination: string = DIR_CACHES;
+
+    constructor(options: IDownloaderOptions) {
+
+        this.options = mergeOptions(Downloader.DEFAULT_OPTIONS, options);
+
+        if(process.env.NWJS_MIRROR) {
+            this.options.mirror = process.env.NWJS_MIRROR;
+        }
+
+        debug('in constructor', 'options', this.options);
+
+    }
+
+    public async fetch() {
+
+        const { mirror, platform, arch, version, flavor, showProgress } = this.options;
+
+        const partVersion = await this.handleVersion(version);
+        const partFlavor = flavor == 'normal' ? '' : '-' + flavor;
+        const partPlatform = this.handlePlatform(platform);
+        const partArch = this.handleArch(arch);
+        const partExtension = this.extensionByPlatform(platform);
+
+        const url = `${ mirror }/${ partVersion }/nwjs${ partFlavor }-${ partVersion }-${ partPlatform }-${ partArch }.${ partExtension }`;
+        const filename = basename(url);
+        const path = join(this.destination, filename);
+
+        debug('in fetch', 'url', url);
+        debug('in fetch', 'filename', filename);
+        debug('in fetch', 'path', path);
+
+        if(!(await this.isFileExists(path))) {
+            await this.download(url, filename, path, showProgress);
+        }
+
+        return path;
+
+    }
+
+    protected handlePlatform(platform: string) {
+
+        switch(platform) {
+        case 'win32':
+        case 'win':
+            return 'win';
+        case 'darwin':
+        case 'osx':
+        case 'mac':
+            return 'osx';
+        case 'linux':
+            return 'linux';
+        default:
+            throw new Error('ERROR_UNKNOWN_PLATFORM');
+        }
+
+    }
+
+    protected handleArch(arch: string) {
+
+        switch(arch) {
+        case 'x86':
+        case 'ia32':
+            return 'ia32';
+        case 'x64':
+            return 'x64';
+        default:
+            throw new Error('ERROR_UNKNOWN_PLATFORM');
+        }
+
+    }
+
+    protected getVersions(): Promise<any> {
+        return new Promise((resolve, reject) => {
+            request('https://nwjs.io/versions.json', (err, res, body) => {
+
+                if(err) {
+                    return reject(err);
+                }
+
+                const json = JSON.parse(body);
+                resolve(json);
+
+            });
+        });
+    }
+
+    protected async handleVersion(version: string) {
+        switch(version) {
+        case 'latest':
+        case 'stable':
+        case 'lts':
+            const versions = await this.getVersions();
+            //debug('in handleVersion', 'versions', versions);
+            return versions[version];
+        default:
+            return version[0] == 'v' ? version : 'v' + version;
+        }
+    }
+
+    protected extensionByPlatform(platform: string) {
+
+        switch(platform) {
+        case 'win32':
+        case 'win':
+            return 'zip';
+        case 'darwin':
+        case 'osx':
+        case 'mac':
+            return 'zip';
+        case 'linux':
+            return 'tar.gz';
+        default:
+            throw new Error('ERROR_UNKNOWN_PLATFORM');
+        }
+
+    }
+
+    protected setDestination(destination: string) {
+        this.destination = destination;
+    }
+
+    protected isFileExists(path: string) {
+        return new Promise((resolve, reject) => {
+            exists(path, resolve);
+        });
+    }
+
+    protected async download(url: string, filename: string, path: string, showProgress: boolean) {
+
+        let bar: ProgressBar = null;
+
+        const onProgress = (state: IRequestProgress) => {
+
+            if(!state.size.total) {
+                return;
+            }
+
+            if(!bar) {
+                bar = new ProgressBar('[:bar] :speedKB/s :etas', {
+                    width: 50,
+                    total: state.size.total,
+                });
+                console.info('');
+            }
+
+            bar.update(state.size.transferred / state.size.total, {
+                speed: (state.speed / 1000).toFixed(2),
+            });
+
+        };
+
+        if(showProgress) {
+            this.onProgress.subscribe(onProgress);
+        }
+
+        debug('in download', 'start downloading', filename);
+
+        await new Promise((resolve, reject) => {
+            progress(request(url, {
+                encoding: null,
+            }, (err, res, data) => {
+
+                if(err) {
+                    return reject(err);
+                }
+
+                if(res.statusCode != 200) {
+                    const e = new Error(`ERROR_STATUS_CODE statusCode = ${ res.statusCode }`);
+                    return reject(e);
+                }
+
+                writeFile(path, data, err => err ? reject(err) : resolve());
+
+            }))
+            .on('progress', (state: IRequestProgress) => {
+                this.onProgress.trigger(state);
+            });
+        });
+
+        debug('in fetch', 'end downloading', filename);
+
+        if(showProgress) {
+            this.onProgress.unsubscribe(onProgress);
+            if(bar) {
+                console.info('');
+                bar.terminate();
+            }
+        }
+
+        return path;
+
+    }
+
+}

+ 22 - 0
src/lib/Event.ts

@@ -0,0 +1,22 @@
+
+export class Event<TArgs> {
+
+    public listeners: Array<(args: TArgs) => void> = [];
+
+    constructor(name: string) {
+
+    }
+
+    public subscribe(fn: ((args: TArgs) => void)) {
+        this.listeners.push(fn);
+    }
+
+    public trigger(args: TArgs) {
+        this.listeners.map(fn => fn(args));
+    }
+
+    public unsubscribe(fn: ((args: TArgs) => void)) {
+        this.listeners = this.listeners.filter(f => f != fn);
+    }
+
+}

+ 208 - 0
src/lib/FFmpegDownloader.ts

@@ -0,0 +1,208 @@
+
+import { dirname, basename, join, resolve } from 'path';
+
+import * as request from 'request';
+import * as ProgressBar from 'progress';
+import { ensureDirSync, exists, writeFile } from 'fs-extra-promise';
+
+const debug = require('debug')('build:ffmpegDownloader');
+const progress = require('request-progress');
+
+import { Event } from './Event';
+import { mergeOptions } from './util';
+
+const DIR_CACHES = resolve(dirname(module.filename), '..', '..', 'caches');
+ensureDirSync(DIR_CACHES);
+
+interface IRequestProgress {
+    percent: number;
+    speed: number;
+    size: {
+        total: number,
+        transferred: number,
+    };
+    time: {
+        elapsed: number,
+        remaining: number,
+    };
+}
+
+interface IFFmpegDownloaderOptions {
+    platform?: string;
+    arch?: string;
+    version?: string;
+    mirror?: string;
+    useCaches?: boolean;
+    showProgress?: boolean;
+}
+
+export class FFmpegDownloader {
+
+    public static DEFAULT_OPTIONS: IFFmpegDownloaderOptions = {
+        platform: process.platform,
+        arch: process.arch,
+        version: '0.14.7',
+        mirror: 'https://github.com/iteufel/nwjs-ffmpeg-prebuilt/releases/download/',
+        useCaches: true,
+        showProgress: true,
+    };
+
+    public onProgress: Event<IRequestProgress> = new Event('progress');
+
+    public options: IFFmpegDownloaderOptions;
+
+    private destination: string = DIR_CACHES;
+
+    constructor(options: IFFmpegDownloaderOptions) {
+
+        this.options = mergeOptions(FFmpegDownloader.DEFAULT_OPTIONS, options);
+
+        debug('in constructor', 'options', options);
+
+    }
+
+    public async fetch() {
+
+        const { mirror, version, platform, arch, showProgress } = this.options;
+
+        const partVersion = this.handleVersion(version);
+        const partPlatform = this.handlePlatform(platform);
+        const partArch = this.handleArch(arch);
+
+        const url = `${ mirror }/${ partVersion }/${ partVersion }-${ partPlatform }-${ partArch }.zip`;
+
+        const filename = `ffmpeg-${ basename(url) }`;
+        const path = join(this.destination, filename);
+
+        debug('in fetch', 'url', url);
+        debug('in fetch', 'filename', filename);
+        debug('in fetch', 'path', path);
+
+        if(!(await this.isFileExists(path))) {
+            await this.download(url, filename, path, showProgress);
+        }
+
+        return path;
+
+    }
+
+    protected handlePlatform(platform: string) {
+
+        switch(platform) {
+        case 'win32':
+        case 'win':
+            return 'win';
+        case 'darwin':
+        case 'osx':
+        case 'mac':
+            return 'osx';
+        case 'linux':
+            return 'linux';
+        default:
+            throw new Error('ERROR_UNKNOWN_PLATFORM');
+        }
+
+    }
+
+    protected handleArch(arch: string) {
+
+        switch(arch) {
+        case 'x86':
+        case 'ia32':
+            return 'ia32';
+        case 'x64':
+            return 'x64';
+        default:
+            throw new Error('ERROR_UNKNOWN_PLATFORM');
+        }
+
+    }
+
+    protected handleVersion(version: string) {
+
+        switch(version) {
+        case 'lts':
+        case 'stable':
+        case 'latest':
+            throw new Error('ERROR_VERSION_UNSUPPORTED');
+        default:
+            return version[0] == 'v' ? version.slice(1) : version;
+        }
+
+    }
+
+    protected setDestination(destination: string) {
+        this.destination = destination;
+    }
+
+    protected isFileExists(path: string) {
+        return new Promise((resolve, reject) => {
+            exists(path, resolve);
+        });
+    }
+
+    protected async download(url: string, filename: string, path: string, showProgress: boolean) {
+
+        let bar: ProgressBar = null;
+
+        const onProgress = (state: IRequestProgress) => {
+
+            if(!state.size.total) {
+                return;
+            }
+
+            if(!bar) {
+                bar = new ProgressBar('[:bar] :speedKB/s :etas', {
+                    width: 50,
+                    total: state.size.total,
+                });
+            }
+
+            bar.update(state.size.transferred / state.size.total, {
+                speed: (state.speed / 1000).toFixed(2),
+            });
+
+        };
+
+        if(showProgress) {
+            this.onProgress.subscribe(onProgress);
+        }
+
+        debug('in download', 'start downloading', filename);
+
+        await new Promise((resolve, reject) => {
+            progress(request(url, {
+                encoding: null,
+            }, (err, res, data) => {
+
+                if(err) {
+                    return reject(err);
+                }
+
+                if(res.statusCode != 200) {
+                    const e = new Error(`ERROR_STATUS_CODE statusCode = ${ res.statusCode }`);
+                    return reject(e);
+                }
+
+                writeFile(path, data, err => err ? reject(err) : resolve());
+
+            }))
+            .on('progress', (state: IRequestProgress) => {
+                this.onProgress.trigger(state);
+            });
+        });
+
+        debug('in fetch', 'end downloading', filename);
+
+        if(showProgress) {
+            this.onProgress.unsubscribe(onProgress);
+            if(bar) {
+                bar.terminate();
+            }
+        }
+
+        return path;
+
+    }
+
+}

+ 18 - 0
src/lib/LinuxConfig.ts

@@ -0,0 +1,18 @@
+
+export class LinuxConfig {
+
+    constructor(options: any = {}) {
+
+        Object.keys(this).map((key) => {
+            if(options[key] !== undefined) {
+                switch(key) {
+                default:
+                    (<any>this)[key] = options[key];
+                    break;
+                }
+            }
+        });
+
+    }
+
+}

+ 25 - 0
src/lib/MacConfig.ts

@@ -0,0 +1,25 @@
+
+export class MacConfig {
+
+    public name: string = '';
+    public displayName: string = '';
+    public version: string = '';
+    public description: string = '';
+    public copyright: string = '';
+    public icon: string = undefined;
+
+    constructor(options: any = {}) {
+
+        Object.keys(this).map((key) => {
+            if(options[key] !== undefined) {
+                switch(key) {
+                default:
+                    (<any>this)[key] = options[key];
+                    break;
+                }
+            }
+        });
+
+    }
+
+}

+ 177 - 0
src/lib/Runner.ts

@@ -0,0 +1,177 @@
+
+import { join } from 'path';
+import { spawn } from 'child_process';
+
+import { copyAsync, readJsonAsync, chmodAsync } from 'fs-extra-promise';
+
+const debug = require('debug')('build:runner');
+
+import { Downloader } from './Downloader';
+import { FFmpegDownloader } from './FFmpegDownloader';
+import { BuildConfig } from './BuildConfig';
+import { extract, extractTarGz } from './archive';
+import { mergeOptions, findExecutable, findFFmpeg, tmpDir, spawnAsync } from './util';
+
+interface IRunnerOptions {
+    x86?: boolean;
+    x64?: boolean;
+    mirror?: string;
+    detached?: boolean;
+    config?: string;
+    mute?: boolean;
+}
+
+export class Runner {
+
+    public static DEFAULT_OPTIONS: IRunnerOptions = {
+        x86: false,
+        x64: false,
+        mirror: Downloader.DEFAULT_OPTIONS.mirror,
+        detached: false,
+        config: undefined,
+        mute: true,
+    };
+
+    public options: IRunnerOptions;
+
+    constructor(options: IRunnerOptions = {}, public args: string[]) {
+
+        this.options = mergeOptions(Runner.DEFAULT_OPTIONS, options);
+
+        debug('in constructor', 'args', args);
+        debug('in constructor', 'options', this.options);
+
+    }
+
+    public async run(): Promise<number> {
+
+        const platform = process.platform;
+        const arch = this.options.x86 || this.options.x64
+        ? (this.options.x86 ? 'ia32' : 'x64')
+        : process.arch;
+
+        const configPath = this.options.config ? this.options.config : join(this.args[0], 'package.json');
+
+        const pkg: any = await readJsonAsync(configPath);
+        const config = new BuildConfig(pkg);
+
+        debug('in run', 'configPath', configPath);
+        debug('in run', 'config', config);
+
+        const downloader = new Downloader({
+            platform, arch,
+            version: config.nwVersion,
+            flavor: 'sdk',
+            mirror: this.options.mirror,
+            useCaches: true,
+            showProgress: this.options.mute ? false : true,
+        });
+
+        if(!this.options.mute) {
+            console.info('Fetching NW.js binary...', {
+                platform: downloader.options.platform,
+                arch: downloader.options.arch,
+                version: downloader.options.version,
+                flavor: downloader.options.flavor,
+            });
+        }
+
+        const archivePath = await downloader.fetch();
+
+        const { path: runtimeDir, cleanup } = await tmpDir();
+
+        if(!this.options.mute) {
+            console.info('Extracting NW.js binary...', {
+                runtimeDir,
+            });
+        }
+
+        if(archivePath.endsWith('.zip')) {
+            await extract(archivePath, runtimeDir);
+        }
+        else if(archivePath.endsWith('tar.gz')) {
+            await extractTarGz(archivePath, runtimeDir);
+        }
+        else {
+            throw new Error('ERROR_UNKNOWN_EXTENSION');
+        }
+
+        if(config.ffmpegIntegration) {
+            await this.integrateFFmpeg(platform, arch, runtimeDir, pkg, config);
+        }
+
+        const executable = await findExecutable(platform, runtimeDir);
+
+        await chmodAsync(executable, 0o555);
+
+        if(!this.options.mute) {
+            console.info('Launching NW.js app...');
+        }
+
+        const { code, signal } = await spawnAsync(executable, this.args, {
+            detached: this.options.detached,
+        });
+
+        cleanup();
+
+        if(!this.options.mute) {
+            if(this.options.detached) {
+
+                console.info('NW.js app detached.');
+
+                await new Promise((resolve, reject) => {
+                    setTimeout(resolve, 3000);
+                });
+
+            }
+            else {
+                console.info(`NW.js app exited with ${ code }.`);
+            }
+        }
+
+        return code;
+
+    }
+
+    protected async integrateFFmpeg(platform: string, arch: string, runtimeDir: string, pkg: any, config: BuildConfig) {
+
+        const downloader = new FFmpegDownloader({
+            platform, arch,
+            version: config.nwVersion,
+            useCaches: true,
+            showProgress: this.options.mute ? false : true,
+        });
+
+        if(!this.options.mute) {
+            console.info('Fetching FFmpeg prebuilt...', {
+                platform: downloader.options.platform,
+                arch: downloader.options.arch,
+                version: downloader.options.version,
+            });
+        }
+
+        const path = await downloader.fetch();
+
+        const { path: ffmpegDir, cleanup } = await tmpDir();
+
+        if(!this.options.mute) {
+            console.info('Extracting FFmpeg prebuilt...', {
+                ffmpegDir,
+            });
+        }
+
+        if(path.endsWith('.zip')) {
+            await extract(path, ffmpegDir);
+        }
+        else {
+            throw new Error('ERROR_UNKNOWN_EXTENSION');
+        }
+
+        const src = await findFFmpeg(platform, ffmpegDir);
+        const dest = await findFFmpeg(platform, runtimeDir);
+
+        await copyAsync(src, dest);
+
+    }
+
+}

+ 27 - 0
src/lib/WinConfig.ts

@@ -0,0 +1,27 @@
+
+export class WinConfig {
+
+    public productVersion: string = '';
+    public fileVersion: string = '';
+    public versionStrings: {
+        ProductName?: undefined,
+        FileDescription?: undefined,
+        LegalCopyright?: undefined,
+    } = {};
+    public icon: string = undefined;
+
+    constructor(options: any = {}) {
+
+        Object.keys(this).map((key) => {
+            if(options[key] !== undefined) {
+                switch(key) {
+                default:
+                    (<any>this)[key] = options[key];
+                    break;
+                }
+            }
+        });
+
+    }
+
+}

+ 63 - 0
src/lib/archive.ts

@@ -0,0 +1,63 @@
+
+import { dirname, basename, join, resolve, normalize } from 'path';
+
+import { removeAsync, writeFileAsync } from 'fs-extra-promise';
+import { path7za } from '7zip-bin';
+
+const debug = require('debug')('build:archive');
+
+import { tmpFile, spawnAsync } from './util';
+
+export async function extract(archive: string, dest: string = dirname(archive)) {
+
+    debug('in extract', 'archive', archive);
+    debug('in extract', 'dest', dest);
+
+    const { code, signal } = await spawnAsync(path7za, [ 'x', '-y', `-o${ resolve(dest) }`, resolve(archive) ]);
+
+    if(code == 2) {
+        throw new Error(`ERROR_PATH_NOT_FOUND path = ${ archive }`);
+    }
+
+    if(code != 0) {
+        throw new Error(`ERROR_EXIT_CODE code = ${ code }`);
+    }
+
+    return dest;
+
+}
+
+export async function extractTarGz(archive: string, dest: string = dirname(archive)) {
+
+    await extract(archive, dest);
+
+    const tar = join(dest, basename(archive.slice(0, -3)));
+
+    await extract(tar, dest);
+
+    await removeAsync(tar);
+
+}
+
+export async function compress(dir: string, files: string[], type: string, archive: string) {
+
+    debug('in compress', 'dir', dir);
+    debug('in compress', 'files', files);
+    debug('in compress', 'type', type);
+    debug('in compress', 'archive', archive);
+
+    const { path: listfiles, cleanup } = await tmpFile();
+
+    debug('in compress', 'listfiles', listfiles);
+
+    await writeFileAsync(listfiles, files.map(file => normalize(file)).join('\r\n'));
+
+    const { code, signal } = await spawnAsync(path7za, [ 'a', `-t${ type }`, resolve(archive), `@${ resolve(listfiles) }` ], {
+        cwd: dir,
+    });
+
+    cleanup();
+
+    return code;
+
+}

+ 8 - 0
src/lib/index.ts

@@ -0,0 +1,8 @@
+
+import 'source-map-support/register';
+
+import { Runner } from './Runner';
+import { Builder } from './Builder';
+import { Downloader } from './Downloader';
+
+export { Runner, Builder, Downloader };

+ 308 - 0
src/lib/util.ts

@@ -0,0 +1,308 @@
+
+import { join, dirname, relative } from 'path';
+import { spawn, exec } from 'child_process';
+
+import * as tmp from 'tmp';
+tmp.setGracefulCleanup();
+import { lstatAsync, ensureDirAsync, readFileAsync, outputFileAsync } from 'fs-extra-promise';
+
+const debug = require('debug')('build:util');
+const globby = require('globby');
+
+export function mergeOptions(defaults: any, options: any) {
+
+    const opts: any = {};
+
+    Object.keys(defaults).map((key) => {
+        opts[key] = defaults[key];
+    });
+
+    Object.keys(defaults).map((key) => {
+        opts[key] = options[key] === undefined ? opts[key] : options[key];
+    });
+
+    return opts;
+
+}
+
+export function findExecutable(platform: string, runtimeDir: string): Promise<string> {
+    return new Promise((resolve, reject) => {
+
+        const pattern = (() => {
+            switch(platform) {
+            case 'win32':
+            case 'win':
+                return '**/nw.exe';
+            case 'darwin':
+            case 'osx':
+            case 'mac':
+                return '**/nwjs.app/Contents/MacOS/nwjs';
+            case 'linux':
+                return '**/nw';
+            default:
+                throw new Error('ERROR_UNKNOWN_PLATFORM');
+            }
+        })();
+
+        // FIXME: globby.d.ts.
+        globby([ pattern ], {
+            cwd: runtimeDir,
+        })
+        .then((matches: string[]) => {
+
+            if(matches.length == 0) {
+                const err = new Error('ERROR_EMPTY_MATCHES');
+                return reject(err);
+            }
+
+            debug('in findExecutable', 'matches', matches);
+
+            resolve(join(runtimeDir, matches[0]));
+
+        });
+
+    });
+}
+
+export function findFFmpeg(platform: string, dir: string): Promise<string> {
+    return new Promise((resolve, reject) => {
+
+        const pattern = (() => {
+            switch(platform) {
+            case 'win32':
+            case 'win':
+                return '**/ffmpeg.dll';
+            case 'darwin':
+            case 'osx':
+            case 'mac':
+                return '**/libffmpeg.dylib';
+            case 'linux':
+                return '**/libffmpeg.so';
+            default:
+                throw new Error('ERROR_UNKNOWN_PLATFORM');
+            }
+        })();
+
+        // FIXME: globby.d.ts.
+        globby([ pattern ], {
+            cwd: dir,
+        })
+        .then((matches: string[]) => {
+
+            if(matches.length == 0) {
+                const err = new Error('ERROR_EMPTY_MATCHES');
+                return reject(err);
+            }
+
+            debug('in findFFmpeg', 'matches', matches);
+
+            resolve(join(dir, matches[0]));
+
+        });
+
+    });
+}
+
+export function findRuntimeRoot(platform: string, runtimeDir: string): Promise<string> {
+    return new Promise((resolve, reject) => {
+
+        const pattern = (() => {
+            switch(platform) {
+            case 'win32':
+            case 'win':
+                return '**/nw.exe';
+            case 'darwin':
+            case 'osx':
+            case 'mac':
+                return '**/nwjs.app';
+            case 'linux':
+                return '**/nw';
+            default:
+                throw new Error('ERROR_UNKNOWN_PLATFORM');
+            }
+        })();
+
+        // FIXME: globby.d.ts.
+        globby([ pattern ], {
+            cwd: runtimeDir,
+        })
+        .then((matches: string[]) => {
+
+            if(matches.length == 0) {
+                const err = new Error('ERROR_EMPTY_MATCHES');
+                return reject(err);
+            }
+
+            debug('in findExecutable', 'matches', matches);
+
+            resolve(join(runtimeDir, dirname(matches[0])));
+
+        });
+
+    });
+}
+
+export async function findExcludableDependencies(dir: string, pkg: any) {
+
+    const prod = await execAsync('npm ls --prod --parseable', {
+        cwd: dir,
+    })
+    .then(({
+        stdout, stderr,
+    }) => {
+        return stdout.split(/\r?\n/)
+        .filter(path => path)
+        .map((path) => {
+            return relative(dir, path);
+        });
+    });
+
+    debug('in findExcludableDependencies', 'prod', prod);
+
+    const dev = await execAsync('npm ls --dev --parseable', {
+        cwd: dir,
+    })
+    .then(({
+        stdout, stderr,
+    }) => {
+        return stdout.split(/\r?\n/)
+        .filter(path => path)
+        .map((path) => {
+            return relative(dir, path);
+        });
+    });
+
+    debug('in findExcludableDependencies', 'dev', dev);
+
+    const excludable = [];
+    for(const d of dev) {
+        if(prod.indexOf(d) == -1) {
+            excludable.push(d);
+        }
+    }
+
+    debug('in findExcludableDependencies', 'excludable', excludable);
+
+    return excludable;
+
+}
+
+export function tmpName(options: any = {}): Promise<string> {
+    return new Promise((resolve, reject) => {
+        tmp.tmpName(Object.assign({}, {
+        }, options), (err, path) => err ? reject(err) : resolve(path));
+    });
+}
+
+export function tmpFile(options: any = {}): Promise<{
+    path: string,
+    fd: number,
+    cleanup: () => void,
+}> {
+    return new Promise((resolve, reject) => {
+        tmp.file(Object.assign({}, {
+            //discardDescriptor: true,
+        }, options), (err, path, fd, cleanup) => err ? reject(err) : resolve({
+            path, fd, cleanup,
+        }));
+    });
+}
+
+export function tmpDir(options: any = {}): Promise<{
+    path: string,
+    cleanup: () => void,
+}> {
+    return new Promise((resolve, reject) => {
+        tmp.dir(Object.assign({}, {
+            unsafeCleanup: true,
+        }, options), (err, path, cleanup) => err ? reject(err) : resolve({
+            path, cleanup,
+        }));
+    });
+}
+
+export async function cpAsync(src: string, dest: string) {
+
+    const stats = await lstatAsync(src);
+
+    if(stats.isDirectory()) {
+        //await ensureDirAsync(dest);
+    }
+    else {
+        await outputFileAsync(dest, await readFileAsync(src));
+    }
+
+}
+
+export function spawnAsync(executable: string, args: string[], options: any = {}): Promise<{
+    code: number,
+    signal: string,
+}> {
+    return new Promise((resolve, reject) => {
+
+        debug('in spawnAsync', 'executable', executable);
+        debug('in spawnAsync', 'args', args);
+        debug('in spawnAsync', 'options', options);
+
+        const child = spawn(executable, args, options);
+
+        if(child.stdout) {
+            child.stdout.on('data', chunk => debug('in spawnAsync', 'stdout', chunk.toString()));
+        }
+
+        if(child.stderr) {
+            child.stderr.on('data', chunk => debug('in spawnAsync', 'stderr', chunk.toString()));
+        }
+
+        child.on('close', (code, signal) => {
+            if(!options.detached) {
+                resolve({
+                    code, signal,
+                });
+            }
+        });
+
+        if(options.detached) {
+
+            child.unref();
+
+            resolve({
+                code: 0,
+                signal: '',
+            });
+
+        }
+
+    });
+}
+
+export function execAsync(command: string, options: any = {}): Promise<{
+    stdout: string,
+    stderr: string,
+}> {
+    return new Promise((resolve, reject) => {
+
+        debug('in execAsync', 'command', command);
+        debug('in execAsync', 'options', options);
+
+        const child = exec(command, options, (err, stdout, stderr) => {
+            if(!options.detached) {
+                resolve({
+                    stdout, stderr,
+                });
+            }
+        });
+
+        if(options.detached) {
+
+            child.unref();
+
+            resolve({
+                stdout: null,
+                stderr: null,
+            });
+
+        }
+
+    });
+}

+ 71 - 0
test/Builder.js

@@ -0,0 +1,71 @@
+
+import { test } from 'ava';
+
+import { Builder } from '../';
+import { spawnAsync } from '../dist/lib/util';
+
+const dir = './assets/project/';
+
+test.serial('commandline', async (t) => {
+
+    const platform = (() => {
+        switch(process.platform) {
+        case 'win32':
+            return '--win';
+        case 'darwin':
+            return '--mac';
+        case 'linux':
+            return '--linux';
+        default:
+            throw new Error('ERROR_UNKNOWN_PLATFORM');
+        }
+    })();
+
+    const mirror = process.env.CI ? '' : '--mirror https://npm.taobao.org/mirrors/nwjs/';
+
+    const { code, signal } = await spawnAsync('node', `./dist/bin/build.js ${ platform } --x64 ${ mirror } ${ dir }`.split(' '), {
+        stdio: 'inherit',
+    });
+    t.is(code, 0);
+
+});
+
+test.serial('commandline --config', async (t) => {
+
+    const platform = (() => {
+        switch(process.platform) {
+        case 'win32':
+            return '--win';
+        case 'darwin':
+            return '--mac';
+        case 'linux':
+            return '--linux';
+        default:
+            throw new Error('ERROR_UNKNOWN_PLATFORM');
+        }
+    })();
+
+    const mirror = process.env.CI ? '' : '--mirror https://npm.taobao.org/mirrors/nwjs/';
+
+    const { code, signal } = await spawnAsync('node', `./dist/bin/build.js ${ platform } --x64 ${ mirror } --config ${ dir }/package.json ${ dir }`.split(' '), {
+        stdio: 'inherit',
+    });
+    t.is(code, 0);
+
+});
+
+(process.env.CI ? test.skip.serial : test.serial)('module', async (t) => {
+
+    const mirror = process.env.CI ? undefined : 'https://npm.taobao.org/mirrors/nwjs/';
+
+    const builder = new Builder({
+        win: true,
+        mac: true,
+        linux: true,
+        x64: true,
+        mirror,
+    }, dir);
+
+    await builder.build();
+
+});

+ 20 - 0
test/Downloader.js

@@ -0,0 +1,20 @@
+
+import { test } from 'ava';
+
+import { Downloader } from '../';
+
+test('fetch', async (t) => {
+
+    const mirror = process.env.CI ? undefined : 'https://npm.taobao.org/mirrors/nwjs/';
+
+    const downloader = new Downloader({
+        platform: process.platform,
+        arch: 'x64',
+        version: 'lts',
+        flavor: 'normal',
+        mirror,
+    });
+
+    await downloader.fetch();
+
+});

+ 28 - 0
test/FFmpegDownloader.js

@@ -0,0 +1,28 @@
+
+import { test } from 'ava';
+
+import { FFmpegDownloader } from '../dist/lib/FFmpegDownloader';
+
+test.failing('symbol version not supported', async (t) => {
+
+    const downloader = new FFmpegDownloader({
+        platform: process.platform,
+        arch: 'x64',
+        version: 'lts',
+    });
+
+    await downloader.fetch();
+
+});
+
+test('fetch', async (t) => {
+
+    const downloader = new FFmpegDownloader({
+        platform: process.platform,
+        arch: 'x64',
+        version: '0.14.7',
+    });
+
+    await downloader.fetch();
+
+});

+ 55 - 0
test/Runner.js

@@ -0,0 +1,55 @@
+
+import { test } from 'ava';
+
+import { Runner } from '../';
+import { spawnAsync } from '../dist/lib/util';
+
+const dir = './assets/project/';
+
+// FIXME: When run with other tests, the code will be 0.
+test.serial('commandline --mirror', async (t) => {
+
+    const mirror = process.env.CI ? '' : '--mirror https://npm.taobao.org/mirrors/nwjs/';
+
+    const { code, signal } = await spawnAsync('node', `./dist/bin/run.js ${ mirror } ${ dir } 233`.split(' '), {
+        stdio: 'inherit',
+    });
+    t.is(code, 233);
+
+});
+
+test.serial('commandline with environment variables', async (t) => {
+
+    const { code, signal } = await spawnAsync('node', `./dist/bin/run.js ${ dir } 233`.split(' '), {
+        stdio: 'inherit',
+        env: Object.assign({}, process.env, {
+            NWJS_MIRROR: process.env.CI ? '' : 'https://npm.taobao.org/mirrors/nwjs/',
+        }),
+    });
+    t.is(code, 233);
+
+});
+
+test.serial('commandline --detached', async (t) => {
+
+    const mirror = process.env.CI ? '' : '--mirror https://npm.taobao.org/mirrors/nwjs/';
+
+    const { code, signal } = await spawnAsync('node', `./dist/bin/run.js ${ mirror } --detached ${ dir } 233`.split(' '), {
+        stdio: 'inherit',
+    });
+    t.is(code, 0);
+
+});
+
+test.serial('module', async (t) => {
+
+    const mirror = process.env.CI ? undefined : 'https://npm.taobao.org/mirrors/nwjs/';
+
+    const runner = new Runner({
+        mirror,
+    }, [ dir, '233' ]);
+
+    const code = await runner.run();
+    t.is(code, 233);
+
+});

+ 39 - 0
test/archive.js

@@ -0,0 +1,39 @@
+
+import { test } from 'ava';
+
+import { removeAsync } from 'fs-extra-promise';
+
+import { extract, extractTarGz, compress } from '../dist/lib/archive';
+import { tmpName, tmpFile, tmpDir } from '../dist/lib/util';
+
+(process.env.CI ? test.skip : test)('extract', async (t) => {
+
+    const { path, cleanup } = await tmpDir();
+
+    await extract('./caches/nwjs-v0.14.7-osx-x64.zip', path);
+
+    cleanup();
+
+});
+
+(process.env.CI ? test.skip : test)('extractTarGz', async (t) => {
+
+    const { path, cleanup } = await tmpDir();
+
+    await extractTarGz('./caches/nwjs-v0.14.7-linux-x64.tar.gz', path);
+
+    cleanup();
+
+});
+
+(process.env.CI ? test.skip : test)('compress', async (t) => {
+
+    // Don't use `tmpFile`, which keeps the file open and results in exceptions when spawning.
+    const path = await tmpName();
+
+    const code = await compress('./assets/', [ './project/index.html', './project/package.json' ], 'zip', path);
+    t.is(code, 0);
+
+    await removeAsync(path);
+
+});

+ 25 - 0
test/util.js

@@ -0,0 +1,25 @@
+
+import { test } from 'ava';
+
+import { mergeOptions } from '../dist/lib/util';
+
+test('mergeOptions', async (t) => {
+
+    const defaults = {
+        a: false,
+        b: 123,
+        c: '123',
+    };
+
+    const options = mergeOptions(defaults, {
+        b: undefined,
+        c: '456',
+    });
+
+    t.deepEqual(options, {
+        a: false,
+        b: 123,
+        c: '456',
+    });
+
+});

+ 12 - 0
tsconfig.json

@@ -0,0 +1,12 @@
+{
+    "compilerOptions": {
+        "outDir": "./dist/",
+        "sourceMap": true,
+        "noImplicitAny": true,
+        "module": "commonjs",
+        "target": "es5"
+    },
+    "include": [
+        "./src/**/*"
+    ]
+}

+ 29 - 0
tslint.json

@@ -0,0 +1,29 @@
+{
+    "extends": "tslint:recommended",
+    "rules": {
+        "one-line": [
+            false
+        ],
+        "max-classes-per-file": [
+            false
+        ],
+        "max-line-length": [
+            false
+        ],
+        "quotemark": [
+            true,
+            "single",
+            "avoid-escape"
+        ],
+        "whitespace": [
+            true,
+            "check-operator",
+            "check-decl",
+            "check-operator",
+            "check-module",
+            "check-separator",
+            "check-type",
+            "check-preblock"
+        ]
+    }
+}