'use strict' var _ = require('lodash') var asar = require('asar') var async = require('async') var child = require('child_process') var debug = require('debug') var fs = require('fs-extra') var fsize = require('get-folder-size') var glob = require('glob') var path = require('path') var temp = require('temp').track() var wrap = require('word-wrap') var mkdirp = require('mkdirp') var dependencies = require('./dependencies') var pkg = require('../package.json') var defaultLogger = debug(pkg.name) var defaultRename = function (dest, src) { return path.join(dest, '<%= name %>_<%= version %>_<%= arch %>.deb') } /** * Spawn a child process. */ var spawn = function (options, command, args, callback) { var spawnedProcess = null var error = null var stderr = '' options.logger('Executing command ' + command + ' ' + args.join(' ')) try { spawnedProcess = child.spawn(command, args) } catch (err) { process.nextTick(function () { callback(err, stderr) }) return } spawnedProcess.stderr.on('data', function (data) { stderr += data }) spawnedProcess.on('error', function (err) { if (err.name === 'ENOENT') { var isFakeroot = err.syscall === 'spawn fakeroot' var isDpkg = !isFakeroot && err.syscall === 'spawn dpkg' if (isFakeroot || isDpkg) { var installer = process.platform === 'darwin' ? 'brew' : 'apt-get' var pkg = isFakeroot ? 'fakeroot' : 'dpkg' err.message = 'Your system is missing the fakeroot package. Try e.g. `' + installer + ' install ' + pkg + '`' } } error = error || err }) spawnedProcess.on('close', function (code, signal) { if (code !== 0) { error = error || signal || code } callback(error && new Error('Error executing command (' + (error.message || error) + '): ' + '\n' + command + ' ' + args.join(' ') + '\n' + stderr)) }) } /** * Read `package.json` either from `resources/app.asar` (if the app is packaged) * or from `resources/app/package.json` (if it is not). */ var readMeta = function (options, callback) { var withAsar = path.join(options.src, 'resources/app.asar') var withoutAsar = path.join(options.src, 'resources/app/package.json') try { fs.accessSync(withAsar) options.logger('Reading package metadata from ' + withAsar) callback(null, JSON.parse(asar.extractFile(withAsar, 'package.json'))) return } catch (err) { } try { options.logger('Reading package metadata from ' + withoutAsar) callback(null, fs.readJsonSync(withoutAsar)) } catch (err) { callback(new Error('Error reading package metadata: ' + (err.message || err))) } } /** * Read `LICENSE` from the root of the app. */ var readLicense = function (options, callback) { var licenseSrc = path.join(options.src, 'LICENSE') options.logger('Reading license file from ' + licenseSrc) fs.readFile(licenseSrc, callback) } /** * Get the size of the app. */ var getSize = function (options, callback) { fsize(options.src, callback) } /** * Get the hash of default options for the installer. Some come from the info * read from `package.json`, and some are hardcoded. */ var getDefaults = function (data, callback) { async.parallel([ async.apply(readMeta, data), async.apply(getSize, {src: data.src}), async.apply(dependencies.getTrashDepends, {src: data.src}) ], function (err, results) { var pkg = results[0] || {} var size = results[1] || 0 var trashDependencies = results[2] || 'gvfs-bin' var defaults = { name: pkg.name || 'electron', productName: pkg.productName || pkg.name, genericName: pkg.genericName || pkg.productName || pkg.name, description: pkg.description, productDescription: pkg.productDescription || pkg.description, // Use '~' on pre-releases for proper Debian version ordering. // See https://www.debian.org/doc/debian-policy/ch-controlfields.html#s-f-Version version: (pkg.version || '0.0.0').replace(/(\d)[_.+-]?((RC|rc|pre|dev|beta|alpha)[_.+-]?\d*)$/, '$1~$2'), revision: pkg.revision || '1', section: 'utils', priority: 'optional', arch: undefined, size: Math.ceil(size / 1024), depends: dependencies.getDepends(trashDependencies), recommends: [ 'pulseaudio | libasound2' ], suggests: [ 'gir1.2-gnomekeyring-1.0', 'libgnome-keyring0', 'lsb-release' ], enhances: [ ], preDepends: [ ], maintainer: pkg.author && (typeof pkg.author === 'string' ? pkg.author.replace(/\s+\([^)]+\)/, '') : pkg.author.name + (pkg.author.email != null ? ' <' + pkg.author.email + '>' : '') ), homepage: pkg.homepage || (pkg.author && (typeof pkg.author === 'string' ? pkg.author.replace(/.*\(([^)]+)\).*/, '$1') : pkg.author.url )), bin: pkg.name || 'electron', icon: path.resolve(__dirname, '../resources/icon.png'), categories: [ 'GNOME', 'GTK', 'Utility' ], mimeType: [], lintianOverrides: [] } callback(err, defaults) }) } /** * Get the hash of options for the installer. */ var getOptions = function (data, defaults, callback) { // Flatten everything for ease of use. var options = _.defaults({}, data, data.options, defaults) if (!options.description && !options.productDescription) { return callback(new Error('No Description or ProductDescription provided')) } if (options.description) { // Replace all newlines in the description with spaces, since it's supposed // to be one line. options.description = options.description.replace(/[\r\n]+/g, ' ') } if (options.productDescription) { // Ensure blank lines have the "." that denotes a blank line in the control file. options.productDescription = options.productDescription.replace(/^$/m, '.') // Wrap the extended description to avoid lintian warning about // `extended-description-line-too-long`. options.productDescription = wrap(options.productDescription, {width: 80, indent: ' '}) } // Create array with unique values from default & user-supplied dependencies for (var prop of ['depends', 'recommends', 'suggests', 'enhances', 'preDepends']) { if (data.options) { // options passed programmatically options[prop] = _.union(defaults[prop], data.options[prop]) } else { // options passed via command-line options[prop] = _.union(defaults[prop], data[prop]) } } callback(null, options) } /** * Fill in a template with the hash of options. */ var generateTemplate = function (options, file, callback) { options.logger('Generating template from ' + file) async.waterfall([ async.apply(fs.readFile, file), function (template, callback) { var result = _.template(template)(options) options.logger('Generated template from ' + file + '\n' + result) callback(null, result) } ], callback) } /** * Create the control file for the package. * * See: https://www.debian.org/doc/debian-policy/ch-controlfields.html */ var createControl = function (options, dir, callback) { var controlSrc = path.resolve(__dirname, '../resources/control.ejs') var controlDest = path.join(dir, 'DEBIAN/control') options.logger('Creating control file at ' + controlDest) async.waterfall([ async.apply(generateTemplate, options, controlSrc), async.apply(fs.outputFile, controlDest) ], function (err) { callback(err && new Error('Error creating control file: ' + (err.message || err))) }) } /** * Copy debian scripts. */ var copyScripts = function (options, dir, callback) { const scriptNames = ['preinst', 'postinst', 'prerm', 'postrm'] async.forEachOf(options.scripts, function (item, key, callback) { if (_.includes(scriptNames, key)) { var scriptFile = path.join(dir, 'DEBIAN', key) options.logger('Creating script file at ' + scriptFile) fs.copy(item, scriptFile, callback) } else { callback(new Error('Wrong executable script name: ' + key)) } }, function (err) { callback(err && new Error('Error creating script files: ' + (err.message || err))) }) } /** * Create the binary for the package. */ var createBinary = function (options, dir, callback) { var binDir = path.join(dir, 'usr/bin') var binSrc = path.join('../lib', options.name, options.bin) var binDest = path.join(binDir, options.name) options.logger('Symlinking binary from ' + binSrc + ' to ' + binDest) mkdirp(binDir, '0755', function (err, made) { if (err) callback(err && new Error('Error creating binary path: ' + (err.message || err))) fs.symlink(binSrc, binDest, 'file', function (err) { callback(err && new Error('Error creating binary file: ' + (err.message || err))) }) }) } /** * Create the desktop file for the package. * * See: http://standards.freedesktop.org/desktop-entry-spec/latest/ */ var createDesktop = function (options, dir, callback) { var desktopSrc = options.desktopTemplate || path.resolve(__dirname, '../resources/desktop.ejs') var desktopDest = path.join(dir, 'usr/share/applications', options.name + '.desktop') options.logger('Creating desktop file at ' + desktopDest) mkdirp(path.dirname(desktopDest), '0755', function (err, made) { if (err) callback(err && new Error('Error creating desktop path: ' + (err.message || err))) async.waterfall([ async.apply(generateTemplate, options, desktopSrc), async.apply(fs.outputFile, desktopDest), async.apply(fs.chmod, desktopDest, '0644') ], function (err) { callback(err && new Error('Error creating desktop file: ' + (err.message || err))) }) }) } /** * Create pixmap icon for the package. */ var createPixmapIcon = function (options, dir, callback) { var iconFile = path.join(dir, 'usr/share/pixmaps', options.name + '.png') options.logger('Creating icon file at ' + iconFile) mkdirp(path.dirname(iconFile), '0755', function (err, made) { if (err) callback(err && new Error('Error creating icon path: ' + (err.message || err))) fs.copy(options.icon, iconFile, function (err) { if (!err) fs.chmod(iconFile, '0644') callback(err && new Error('Error creating icon file: ' + (err.message || err))) }) }) } /** * Create hicolor icon for the package. */ var createHicolorIcon = function (options, dir, callback) { async.forEachOf(options.icon, function (icon, resolution, callback) { var iconFile if (resolution === 'scalable') { iconFile = path.join(dir, 'usr/share/icons/hicolor', resolution, 'apps', options.name + '.svg') } else { iconFile = path.join(dir, 'usr/share/icons/hicolor', resolution, 'apps', options.name + '.png') } options.logger('Creating icon file at ' + iconFile) mkdirp(path.dirname(iconFile), '0755', function (err, made) { if (err) callback(err) fs.copy(icon, iconFile, function (err) { if (!err) fs.chmod(iconFile, '0644') callback(err) }) }) }, function (err) { callback(err && new Error('Error creating icon file: ' + (err.message || err))) }) } /** * Create icon for the package. */ var createIcon = function (options, dir, callback) { if (_.isObject(options.icon)) { createHicolorIcon(options, dir, callback) } else { createPixmapIcon(options, dir, callback) } } /** * Create copyright for the package. */ var createCopyright = function (options, dir, callback) { var copyrightFile = path.join(dir, 'usr/share/doc', options.name, 'copyright') options.logger('Creating copyright file at ' + copyrightFile) mkdirp(path.dirname(copyrightFile), '0755', function (err, made) { if (err) callback(err && new Error('Error creating copyright path: ' + (err.message || err))) async.waterfall([ async.apply(readLicense, options), async.apply(fs.outputFile, copyrightFile), async.apply(fs.chmod, copyrightFile, '0644') ], function (err) { callback(err && new Error('Error creating copyright file: ' + (err.message || err))) }) }) } /** * Create lintian overrides for the package. */ var createOverrides = function (options, dir, callback) { var overridesSrc = path.resolve(__dirname, '../resources/overrides.ejs') var overridesDest = path.join(dir, 'usr/share/lintian/overrides', options.name) options.logger('Creating lintian overrides at ' + overridesDest) mkdirp(path.dirname(overridesDest), '0755', function (err, made) { if (err) callback(err && new Error('Error creating lintian overrides path: ' + (err.message || err))) async.waterfall([ async.apply(generateTemplate, options, overridesSrc), async.apply(fs.outputFile, overridesDest), async.apply(fs.chmod, overridesDest, '0644') ], function (err) { callback(err && new Error('Error creating lintian overrides file: ' + (err.message || err))) }) }) } /** * Copy the application into the package. */ var createApplication = function (options, dir, callback) { var applicationDir = path.join(dir, 'usr/lib', options.name) var licenseFile = path.join(applicationDir, 'LICENSE') options.logger('Copying application to ' + applicationDir) mkdirp(applicationDir, '0755', function (err, made) { if (err) callback(err && new Error('Error creating application directory: ' + (err.message || err))) async.waterfall([ async.apply(fs.copy, options.src, applicationDir), async.apply(fs.unlink, licenseFile) ], function (err) { callback(err && new Error('Error copying application directory: ' + (err.message || err))) }) }) } /** * Create temporary directory where the contents of the package will live. */ var createDir = function (options, callback) { options.logger('Creating temporary directory') async.waterfall([ async.apply(temp.mkdir, 'electron-'), function (dir, callback) { dir = path.join(dir, options.name + '_' + options.version + '_' + options.arch) mkdirp(dir, '0755', callback) } ], function (err, dir) { callback(err && new Error('Error creating temporary directory: ' + (err.message || err)), dir) }) } /** * Create the contents of the package. */ var createContents = function (options, dir, callback) { options.logger('Creating contents of package') async.parallel([ async.apply(createControl, options, dir), async.apply(copyScripts, options, dir), async.apply(createBinary, options, dir), async.apply(createDesktop, options, dir), async.apply(createIcon, options, dir), async.apply(createCopyright, options, dir), async.apply(createOverrides, options, dir), async.apply(createApplication, options, dir) ], function (err) { callback(err, dir) }) } /** * Package everything using `dpkg` and `fakeroot`. */ var createPackage = function (options, dir, callback) { options.logger('Creating package at ' + dir) spawn(options, 'fakeroot', ['dpkg-deb', '--build', dir], function (err) { callback(err, dir) }) } /** * Move the package to the specified destination. */ var movePackage = function (options, dir, callback) { options.logger('Moving package to destination') var packagePattern = path.join(dir, '../*.deb') async.waterfall([ async.apply(glob, packagePattern), function (files, callback) { async.each(files, function (file) { var dest = options.rename(options.dest, path.basename(file)) dest = _.template(dest)(options) options.logger('Moving file ' + file + ' to ' + dest) fs.move(file, dest, {clobber: true}, callback) }, callback) } ], function (err) { callback(err && new Error('Error moving package files: ' + (err.message || err)), dir) }) } /* ************************************************************************** */ module.exports = function (data, callback) { data.rename = data.rename || defaultRename data.logger = data.logger || defaultLogger async.waterfall([ async.apply(getDefaults, data), async.apply(getOptions, data), function (options, callback) { data.logger('Creating package with options\n' + JSON.stringify(options, null, 2)) async.waterfall([ async.apply(createDir, options), async.apply(createContents, options), async.apply(createPackage, options), async.apply(movePackage, options) ], function (err) { callback(err, options) }) } ], function (err, options) { if (!err) { data.logger('Successfully created package at ' + options.dest) } else { data.logger('Error creating package: ' + (err.message || err)) } callback(err, options) }) }