UNPKG

@happikitsune/nw-builder

Version:
865 lines (722 loc) 30.6 kB
var _ = require('lodash'); var inherits = require('inherits'); var EventEmitter = require('events').EventEmitter; var fs = require('graceful-fs-extra'); var recursiveReaddirSync = require('recursive-readdir-sync'); var path = require('path'); var url = require('url'); var thenify = require('thenify'); var rcedit = thenify(require('rcedit')); var winresourcer = thenify(require('winresourcer')); var spawn = require('child_process').spawn; var semver = require('semver'); var platformOverrides = require('./platformOverrides.js'); var deprecate = require('deprecate'); var updateNotifier = require('update-notifier'); var lazyRequire = require('lazy-req')(require); var boxen = lazyRequire('boxen'); var chalk = lazyRequire('chalk'); var detectCurrentPlatform = require('./detectCurrentPlatform.js'); var NwVersions = require('./versions'); var Version = require('./Version'); var Utils = require('./utils'); var Downloader = require('./downloader'); var platforms = require('./platforms'); var pkg = require('./../package.json'); // Update notification var packageUpdate = updateNotifier({ pkg: pkg, updateCheckInterval: 0 }).update; if(packageUpdate){ var message = chalk().yellow(pkg.name) + ' update available (' + chalk().dim(packageUpdate.current) + chalk().reset(' → ') + chalk().green(packageUpdate.latest) + ')' + '\nTo update, run one of the following commands:' + '\n- ' + chalk().cyan('npm i -g ' + pkg.name) + ' (if ' + pkg.name + ' is installed globally)' + '\n- ' + chalk().cyan('npm i -D ' + pkg.name) + ' (if ' + pkg.name + ' is installed locally)'; var boxenOptions = { padding: 1, margin: 1, align: 'left', borderColor: 'yellow', borderStyle: 'round' }; console.error(boxen()(message, boxenOptions)); } // We inherit from EventEmitter for logging inherits(NwBuilder, EventEmitter); module.exports = NwBuilder; function NwBuilder(options) { var defaults = { files: null, appName: false, appVersion: false, platforms: ['osx64', 'win32', 'win64'], currentPlatform: detectCurrentPlatform(), version: 'latest', buildDir: './build', cacheDir: './cache', downloadUrl: 'https://dl.nwjs.io/', manifestUrl: 'https://nwjs.io/versions.json', flavor: 'sdk', buildType: 'default', forceDownload: false, macCredits: false, macIcns: false, macZip: null, zip: null, zipOptions: null, macPlist: false, winVersionString: {}, winIco: null, argv: process.argv.slice(2) }; // Intercept the platforms and check for the legacy platforms of 'osx' and 'win' and // replace with 'osx32', 'osx64', and 'win32', 'win64' respectively. if(typeof options.platforms != 'undefined'){ if(options.platforms.indexOf('osx') >= 0){ options.platforms.splice(options.platforms.indexOf('osx'), 1, 'osx32', 'osx64'); } if(options.platforms.indexOf('win') >= 0){ options.platforms.splice(options.platforms.indexOf('win'), 1, 'win32', 'win64'); } if(options.platforms.indexOf('linux') >= 0){ options.platforms.splice(options.platforms.indexOf('linux'), 1, 'linux32', 'linux64'); } } // Assign options this.options = _.defaults(options, defaults); // Some Option checking if(!this.options.files) { throw new Error('Please specify some files'); } if (this.options.platforms.length === 0) throw new Error('No platform to build!'); // verify all the platforms specifed by the user are supported // this + previous check assures as we have only buildable platforms specified this.options.platforms.forEach(function(platform) { if (!(platform in platforms)) throw new Error('Unknown platform ' + platform); }); this._platforms = _.cloneDeep(platforms); // clear all unused platforms for (var name in this._platforms) { if (this.options.platforms.indexOf(name) === -1) delete this._platforms[name]; } } NwBuilder.prototype.build = function (callback) { // Let's create a NWjs app var build = this.checkFiles() .then(this.resolveLatestVersion.bind(this)) .then(this.checkVersion.bind(this)) .then(this.platformFilesForVersion.bind(this)) .then(this.downloadNwjs.bind(this)) .then(this.preparePlatformSpecificManifests.bind(this)) .then(this.createReleaseFolder.bind(this)) .then(this.copyNwjs.bind(this)) .then(this.handleMacApp.bind(this)) .then(this.handleWinApp.bind(this)) .then(this.zipAppFiles.bind(this)) .then(this.mergeAppFiles.bind(this)) .then(function(info){ // the promise(s) resolves to nothing in some cases return info || this; }); if(typeof callback === 'function'){ build .then(function(result){ callback(false, result); }) .catch(callback); return true; } return build; }; NwBuilder.prototype.run = function (callback) { // Let's run this NWjs app var run = this.checkFiles() .then(this.resolveLatestVersion.bind(this)) .then(this.checkVersion.bind(this)) .then(this.platformFilesForVersion.bind(this)) .then(this.downloadNwjs.bind(this)) .then(this.runApp.bind(this)); if(typeof callback === 'function'){ run.then(function(result){ callback(false, result); }) .catch(function(error){ callback(true, error); }); return true; } return run; }; NwBuilder.prototype.checkFiles = function () { var self = this; return Utils.getFileList(this.options.files) .then(function (data) { self._appPkg = data.json; self._files = data.files; return self._appPkg; }) .then(Utils.getPackageInfo) .then(function (appPkg) { self._appPkg = appPkg; if(!self.options.appName || !self.options.appVersion) { self.options.appName = (self.options.appName ? self.options.appName : appPkg.name); self.options.appVersion = (self.options.appVersion ? self.options.appVersion : appPkg.version); } }); }; NwBuilder.prototype.resolveLatestVersion = function () { var self = this; if(self.options.version !== 'latest') return Promise.resolve(); return NwVersions.getLatestVersion(self.options.downloadUrl, self.options.manifestUrl, self.options.flavor).then(function(latestVersion){ self.emit('log', 'Latest Version: v' + latestVersion.version); self.options.version = latestVersion.version; return latestVersion; }); }; NwBuilder.prototype.checkVersion = function () { var version = this.options.version, flavor = semver.valid(version) && semver.satisfies(version, '<0.12.3') ? 'sdk' : this.options.flavor, self = this; if(!semver.valid(version)){ return Promise.reject('The version ' + version + ' is not valid.'); } var getVersionFromManifest = function(){ return NwVersions.getVersion({ desiredVersion: version, downloadUrl: self.options.downloadUrl, manifestUrl: self.options.manifestUrl, flavor: flavor }); }; var getVersion; // if the user specified the exact version and all its platforms are cached, don't hit the manifest at all; // just trust the ones are cached and assume they're supported if(self.options.version !== 'latest'){ var areAllPlatformsCached = true; this._forEachPlatform(function(name, platform){ var platformToCheck = platform; if(semver.satisfies(self.options.version, '>=0.12.3')) { platformToCheck = _.clone(platform); platformToCheck.files = ['*']; // otherwise it'll try to check cache legacy version files } if(!self.isPlatformCached(name, platformToCheck, self.options.version, flavor)){ areAllPlatformsCached = false; } }); if(areAllPlatformsCached){ getVersion = Promise.resolve(new Version({ version: version, flavors: [flavor], downloadUrl: self.options.downloadUrl, supportedPlatforms: Object.keys(this._platforms) })); } else { // otherwise hit the manifest getVersion = getVersionFromManifest(); } } else { // otherwise hit the manifest getVersion = getVersionFromManifest(); } return getVersion .then(function(version){ self._version = version; self._version.flavor = flavor; self.emit('log', 'Using v' + self._version.version + ' (' + ((self._version.flavor === '') ? 'normal' : self._version.flavor + ')')); if(self._version.isLegacy) { deprecate('NW.js / node-webkit versions <0.12.3 are deprecated.'); } }); }; NwBuilder.prototype.platformFilesForVersion = function () { var self = this; this._forEachPlatform(function (name, platform) { var satisfied = self.preparePlatformFiles(name, platform); // need the second condition for newer NW.js versions if (!(satisfied && !!self._version.platforms[name + "-" + self._version.flavor])) { throw new Error("Unsupported NW.js version '" + self._version.version + " (" + self._version.flavor + ")' for platform '" + name + "'"); } }); return Promise.resolve(); }; NwBuilder.prototype.downloadNwjs = function () { var self = this, downloads = []; this._forEachPlatform(function (name, platform) { self.setPlatformCacheDirectory(name, platform, self._version.version, self._version.flavor); platform.url = self._version.platforms[name + '-' + self._version.flavor]; // Ensure that there is a cache folder if(self.options.forceDownload) { fs.removeSync(platform.cache); } fs.mkdirpSync(platform.cache); self.emit('log', 'Create cache folder in ' + path.resolve(self.options.cacheDir, self._version.version + '-' + self._version.flavor)); if(!self.isPlatformCached(name, platform, self._version.version, self._version.flavor)) { downloads.push( Downloader.downloadAndUnpack(platform.cache, platform.url) .catch(function(err){ if(err.statusCode === 404){ self.emit('log', 'ERROR: The version '+self._version.version+ ' (' + self._version.flavor + ') does not have a corresponding build posted at ' + self.options.downloadUrl + '. Please choose a version from that list.'); } else { self.emit('log', err.msg); } return Promise.reject('Unable to download NWjs.'); }) ); self.emit('log', 'Downloading: ' + platform.url); } else { self.emit('log', 'Using cache for: ' + name); } }); return Promise.all(downloads) .then(function(data) { Downloader.clearProgressbar(); return data; }); }; NwBuilder.prototype.buildGypModules = function () { // @todo // If we trigger a rebuild we have to copy // the node_modules to a tmp location because // we don't want to change the source files }; NwBuilder.prototype.preparePlatformSpecificManifests = function(){ if(!(this._appPkg.platformOverrides && Object.keys(this._appPkg.platformOverrides).length)){ return Promise.resolve(); } var self = this; var promises = []; self._forEachPlatform(function (name, platform) { promises.push(new Promise(function(resolve, reject){ var overrides = self._appPkg.platformOverrides; if (overrides[name] || overrides[name.substr(0, name.length-2)]) { platformOverrides({ options: self._appPkg, platform: name }, function(err, result){ if(err){ return reject(err); } platform.platformSpecificManifest = result; resolve(); }); } else { resolve(); } })); }); return Promise.all(promises); }; NwBuilder.prototype.createReleaseFolder = function () { var self = this, releasePath, directoryCreationPromises = []; if (_.isFunction(self.options.buildType)) { releasePath = self.options.buildType.call(self.options); } else { // buildTypes switch(self.options.buildType) { case 'timestamped': releasePath = self.options.appName + ' - ' + Math.round(Date.now() / 1000).toString(); break; case 'versioned': releasePath = self.options.appName + ' - v' + self.options.appVersion; break; default: releasePath = self.options.appName; } } this._forEachPlatform(function (name, platform) { directoryCreationPromises.push(new Promise(function(resolve, reject){ platform.releasePath = path.resolve(self.options.buildDir, releasePath, name); // Ensure that there is a release Folder, delete and create it. fs.remove(platform.releasePath, function(err){ if(err) return reject(err); fs.mkdirp(platform.releasePath, function(err){ if(err) return reject(err); self.emit('log', 'Create release folder in ' + platform.releasePath); resolve(); }); }); })); }); return Promise.all(directoryCreationPromises); }; NwBuilder.prototype.copyNwjs = function () { var self = this, copiedFiles = []; this._forEachPlatform(function (name, platform) { // >= v0.12.3 // Since we only have `*`, we're going to recursively get all the files, then copy them // Since a .app is treated like a directory, we need to ignore files inside them and just copy them entirely if(platform.files.length === 1 && platform.files[0] === '*'){ // convert all paths inside a .app, etc. to just point to the .app // then remove duplicates var files = recursiveReaddirSync(platform.cache).map(function(file){ var matches = file.match(/^(.+?(\.app|\.framework))/); if(matches){ return matches[1]; } return file; }); var totalFiles = _.uniq(files); platform.files = totalFiles; totalFiles.forEach(function(file){ var destFile = path.relative(platform.cache, file); var options = {}; if(['nw', 'nwjs.app', 'nw.exe'].indexOf(destFile) !== -1){ // ignore nwjs.app/Contents/Resources/*.lproj, // otherwise the app name will show as nwjs.app in Finder. // *.lproj directory itself needs to be kept to support multiple locales. if(destFile === 'nwjs.app'){ options.filter = function(filepath){ return !/nwjs\.app\/Contents\/Resources\/[^.]+\.lproj\/InfoPlist\.strings$/.test(filepath); }; } // rename executable to app name destFile = self.options.appName + path.extname(destFile); } copiedFiles.push(Utils.copyFile(file, path.join(platform.releasePath, destFile), self, options)); platform.files.push(destFile); }); return; } // legacy platform.files.forEach(function (file, i) { var destFile = file; if(i===0) { // rename executable to app name destFile = self.options.appName + path.extname(file); // save new filename back to files list platform.files[0] = destFile; } copiedFiles.push(Utils.copyFile(path.resolve(platform.cache, file), path.resolve(platform.releasePath, destFile), self)); }); }); return Promise.all(copiedFiles); }; NwBuilder.prototype.isPlatformNeedingZip = function(name, platform) { var self = this, needsZip = platform.needsZip; if(name.indexOf('osx') === 0 && self.options.macZip != null) { deprecate('macZip is deprecated. Use the zip option instead.'); needsZip = self.options.macZip; } else if(self.options.zip != null) { needsZip = self.options.zip; } return needsZip; }; NwBuilder.prototype.zipAppFiles = function () { var self = this; // Check if zip is needed var doAnyNeedZip = false, _needsZip = false, zipOptions = this.options.zipOptions; numberOfPlatformsWithoutOverrides = 0; self._zips = {}; this._forEachPlatform(function(name, platform) { var needsZip = self.isPlatformNeedingZip(name, platform); if(needsZip) { var platformSpecific = !!platform.platformSpecificManifest; self._zips[name] = { platformSpecific: platformSpecific }; numberOfPlatformsWithoutOverrides += !platformSpecific; } doAnyNeedZip = doAnyNeedZip || needsZip; }); self._needsZip = doAnyNeedZip; return new Promise(function(resolve, reject) { if(!self._needsZip){ resolve(); return; } // create (or don't create) a ZIP for multiple platforms new Promise(function(resolve, reject) { if(numberOfPlatformsWithoutOverrides > 1){ Utils.generateZipFile(self._files, self, null, zipOptions).then(function (zip) { resolve(zip); }, reject); } else { resolve(); } }) .then(function(platformAgnosticZip){ var zipPromises = []; _.forEach(self._zips, function(zip, platformName){ if(platformAgnosticZip && !zip.platformSpecific){ zip.file = platformAgnosticZip; return; } zipPromises.push(Utils.generateZipFile( self._files, self, JSON.stringify(self._platforms[platformName].platformSpecificManifest), zipOptions ).then(function(file){ zip.file = file; })); }); Promise.all(zipPromises).then(resolve, reject); }, reject); }); }; NwBuilder.prototype.mergeAppFiles = function () { var self = this; var copyPromises = []; this._forEachPlatform(function (name, platform) { var zipping = self.isPlatformNeedingZip(name, platform); // We copy the app files if we are on mac and don't force zip if(!zipping) { // no zip, copy the files self._files.forEach(function (file) { var dest; if(name == 'osx32' || name === 'osx64') { dest = path.resolve(self.getResourcesDirectoryPath(platform), 'app.nw', file.dest); } else { dest = path.resolve(platform.releasePath, file.dest); } if(file.dest === 'package.json' && platform.platformSpecificManifest){ copyPromises.push(self.writePlatformSpecificManifest(platform, dest)); } else { copyPromises.push(Utils.copyFile(file.src, dest, self)); } }); } else if(name == 'osx32' || name == 'osx64') { // zip just copy the app.nw copyPromises.push(Utils.copyFile( self.getZipFile(name), path.resolve(self.getResourcesDirectoryPath(platform), 'app.nw'), self )); } else { var executableToMergeWith = self._version.isLegacy ? _.first(platform.files) : self.getExecutableName(name); // We cat the app.nw file into the .exe / nw copyPromises.push(Utils.mergeFiles( path.resolve(platform.releasePath, executableToMergeWith), self.getZipFile(name), platform.chmod )); } }); return Promise.all(copyPromises); }; NwBuilder.prototype.getZipFile = function(platformName){ return this._zips[platformName] && this._zips[platformName].file || null; }; NwBuilder.prototype.writePlatformSpecificManifest = function(platform, dest){ return new Promise(function(resolve, reject){ var pkgParentDirectory = path.join(dest, '../'); if(!fs.existsSync(pkgParentDirectory)) fs.mkdirpSync(pkgParentDirectory); fs.writeFile(dest, JSON.stringify(platform.platformSpecificManifest), function(err){ if(err) return reject(err); resolve(); }); }); }; NwBuilder.prototype.handleMacApp = function () { var self = this, allDone = []; this._forEachPlatform(function (name, platform) { if(['osx32', 'osx64'].indexOf(name) < 0) return; // Let's first handle the mac icon if(self.options.macIcns) { if(semver.satisfies(self._version.version, '<=0.12.3')) { allDone.push(Utils.copyFile(self.options.macIcns, path.resolve(self.getResourcesDirectoryPath(platform), 'nw.icns'), self)); } else { allDone.push(Utils.copyFile(self.options.macIcns, path.resolve(self.getResourcesDirectoryPath(platform), 'app.icns'), self)); allDone.push(Utils.copyFile(self.options.macIcns, path.resolve(self.getResourcesDirectoryPath(platform), 'document.icns'), self)); } } // Handle mac credits if(self.options.macCredits) { allDone.push(Utils.copyFile(self.options.macCredits, path.resolve(self.getResourcesDirectoryPath(platform), 'Credits.html'), self)); } // Let's handle the Plist var PlistPath = path.resolve(platform.releasePath, self.options.appName+'.app', 'Contents', 'Info.plist'); // If the macPlist is a string we just copy the file if(typeof self.options.macPlist === 'string') { allDone.push(Utils.copyFile(self.options.macPlist, PlistPath, self)); } else { // Setup the Plist var plistOptions = Utils.getPlistOptions( { name: self.options.appName, version: self.options.appVersion, copyright: self._appPkg.copyright }, self.options.macPlist ); allDone.push(Utils.editPlist(PlistPath, PlistPath, plistOptions)); } }); return Promise.all(allDone); }; NwBuilder.prototype.handleWinApp = function () { var self = this, allDone = []; this._forEachPlatform(function (name, platform) { if(!self.options.winIco || ['win32', 'win64'].indexOf(name) < 0) return; var executableName = self._version.isLegacy ? _.first(platform.files) : self.getExecutableName(name); var executablePath = path.resolve(platform.releasePath, executableName); var updateVersionStringPromise = rcedit(executablePath, { 'version-string': Object.assign({}, { // The process name used in the Task Manager FileDescription: self.options.appName, }, self.options.winVersionString) }); var updateIconsPromise = updateVersionStringPromise.then(function() { return new Promise(function(resolve, reject) { self.emit('log', 'Update ' + name + ' executable icon'); // Set icon winresourcer({ operation: "Update", exeFile: executablePath, resourceType: "Icongroup", resourceName: "IDR_MAINFRAME", lang: 1033, // Required, except when updating or deleting resourceFile: path.resolve(self.options.winIco) }, function(err) { if(!err) { resolve(); } else { reject('Error while updating the Windows icon.' + (process.platform !== "win32" ? ' Wine (winehq.org) must be installed to add custom icons from Mac and Linux.' : '' ) ); } }); }); }); // build a promise chain allDone.push(updateIconsPromise); }); return Promise.all(allDone); }; NwBuilder.prototype.runApp = function () { var self = this; var currentPlatform = this.options.currentPlatform; var platform = this._platforms[currentPlatform]; // if the user is on Windows/OS X 64-bit, but there is no 64-bit build, try the 32-bit build if(!platform){ if(['osx64', 'win64'].indexOf(this.options.currentPlatform) !== -1) { currentPlatform = currentPlatform.split('64')[0] + '32'; platform = this._platforms[currentPlatform]; if(!platform) { throw new Error('currentPlatform selected (' + this.options.currentPlatform + ") doesn't exist in selected platforms (" + Object.keys(this._platforms).join(', ') + '). We also tried ' + currentPlatform + " and that doesn't exist either" ); } } else { throw new Error('currentPlatform selected (' + currentPlatform + ") doesn't exist in selected platforms (" + Object.keys(this._platforms).join(', ') + ')' ); } } var runnable; if(this._version.isLegacy) { runnable = platform.getRunnable(this.options.version); } else { if(currentPlatform.indexOf('osx') === 0){ runnable = 'nwjs.app/Contents/MacOS/nwjs' } else if(currentPlatform.indexOf('win') === 0) { runnable = 'nw.exe' } else { runnable = 'nw' } } var executable = path.resolve(platform.cache, runnable); self.emit('log', 'Launching App'); return new Promise(function(resolve, reject) { var parentDirectory = (_.isArray(self.options.files) ? self.options.files[0] : self.options.files ).replace(/\*[\/\*]*/,""); var nwProcess = self._nwProcess = spawn(executable, ['--enable-logging', parentDirectory].concat(self.options.argv)); self.emit('appstart'); nwProcess.stdout.on('data', function(data) { self.emit('stdout', data); }); nwProcess.stderr.on('data', function(data) { self.emit('stderr', data); }); nwProcess.on('error', function(err) { self.emit('log', 'App launch error: ' + err); reject(err); }); nwProcess.on('close', function(code) { self._nwProcess = undefined; self.emit('log', 'App exited with code ' + code); resolve(); }); }); }; NwBuilder.prototype.isAppRunning = function () { return this._nwProcess !== undefined; }; NwBuilder.prototype.getAppProcess = function () { return this._nwProcess; }; NwBuilder.prototype._forEachPlatform = function (fn) { _.forEach(this._platforms, function(platform, name) { return fn(name, platform); }); }; // Mac only NwBuilder.prototype.getResourcesDirectoryPath = function (platform) { return path.resolve(platform.releasePath, this.options.appName+'.app', 'Contents', 'Resources'); }; // Don't use if legacy version NwBuilder.prototype.getExecutableName = function (platform) { var executableExtension = ''; if(platform.indexOf('osx') === 0){ executableExtension = '.app'; } else if(platform.indexOf('win') === 0){ executableExtension = '.exe'; } return this.options.appName + executableExtension; }; NwBuilder.prototype.setPlatformCacheDirectory = function (platformName, platform, version, flavor) { if(!platform.cache) { platform.cache = path.resolve(this.options.cacheDir, version + "-" + flavor, platformName); } }; NwBuilder.prototype.isPlatformCached = function (platformName, platform, version, flavor) { this.setPlatformCacheDirectory(platformName, platform, version, flavor); if (this.options.forceDownload) { return false; } this.preparePlatformFiles(platformName, platform, version); return Downloader.checkCache(platform.cache, platform.files); }; // returns a Boolean; true if the desired platform is supported NwBuilder.prototype.preparePlatformFiles = function(platformName, platform, version) { // return if platform.files is already prepared if(Object.keys(platform.files)[0] !== Object.keys(platforms[platformName].files)[0]){ return true; } if(semver.satisfies(version, '<0.12.3')) { return !Object.keys(platform.files).every(function (range) { if (semver.satisfies(version, range)) { platform.files = platform.files[range]; if ('string' === typeof platform.files) { platform.files = [platform.files]; } return false; } return true; }); } platform.files = ['*']; // otherwise bad stuff will happen like at attempt to download legacy version files // all we can do here is assume it's oke because this._version might not exist yet, but callers of this function // will check properly where necessary return true; };