From 3fd8096bc709460581a378c64d8a167b79ecd963 Mon Sep 17 00:00:00 2001 From: sethlu Date: Mon, 15 Feb 2016 11:13:53 +0800 Subject: [PATCH] Mac App Store platform support + OS X platform improvements - Added: platform `mas` for Mac App Store distribution - Added: CFBundleIdentifier is now filtered, according to Apple Documentation - Fixed: CFBundleIdentifiers for Helper EH and Helper NP, to maxogden#261 - Moved: `codesign` now with external `electron-osx-sign` module --- index.js | 11 +- mac.js | 67 ++++- package.json | 1 + readme.md | 22 +- test/basic.js | 6 +- test/config.json | 2 +- test/darwin.js | 13 + test/fixtures/basic/package.json | 2 +- test/index.js | 3 +- test/mac.js | 486 +++++++++++++++++++++---------- test/mas.js | 13 + test/multitarget.js | 4 +- test/util.js | 16 +- usage.txt | 16 +- 14 files changed, 459 insertions(+), 203 deletions(-) create mode 100644 test/darwin.js create mode 100644 test/mas.js diff --git a/index.js b/index.js index 21db0cb9a..b8cc3a99a 100644 --- a/index.js +++ b/index.js @@ -20,9 +20,14 @@ var supportedPlatforms = { // Maps to module ID for each platform (lazy-required if used) darwin: './mac', linux: './linux', + mas: './mac', // map to darwin win32: './win32' } +function isPlatformMac (platform) { + return platform === 'darwin' || platform === 'mas' +} + function validateList (list, supported, name) { // Validates list of architectures or platforms. // Returns a normalized array if successful, or an error message string otherwise. @@ -95,7 +100,7 @@ function createSeries (opts, archs, platforms) { archs.forEach(function (arch) { platforms.forEach(function (platform) { // Electron does not have 32-bit releases for Mac OS X, so skip that combination - if (platform === 'darwin' && arch === 'ia32') return + if (isPlatformMac(platform) && arch === 'ia32') return combinations.push({ platform: platform, arch: arch, @@ -161,11 +166,11 @@ function createSeries (opts, archs, platforms) { }) } - if (combination.platform === 'darwin') { + if (isPlatformMac(combination.platform)) { testSymlink(function (result) { if (result) return checkOverwrite() - console.error('Cannot create symlinks; skipping darwin platform') + console.error('Cannot create symlinks; skipping ' + combination.platform + ' platform') callback() }) } else { diff --git a/mac.js b/mac.js index f697f9080..47a71341e 100644 --- a/mac.js +++ b/mac.js @@ -1,12 +1,12 @@ var path = require('path') var fs = require('fs') -var child = require('child_process') var plist = require('plist') var mv = require('mv') var ncp = require('ncp').ncp var series = require('run-series') var common = require('./common') +var sign = require('electron-osx-sign') function moveHelpers (frameworksPath, appName, callback) { function rename (basePath, oldName, newName, cb) { @@ -27,6 +27,12 @@ function moveHelpers (frameworksPath, appName, callback) { }) } +function filterCFBundleIdentifier (identifier) { + // Remove special characters and allow only alphanumeric (A-Z,a-z,0-9), hyphen (-), and period (.) + // Apple documentation: https://developer.apple.com/library/mac/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html#//apple_ref/doc/uid/20001431-102070 + return identifier.replace(/ /g, '-').replace(/[^a-zA-Z0-9.-]/g, '') +} + module.exports = { createApp: function createApp (opts, templatePath, callback) { var appRelativePath = path.join('Electron.app', 'Contents', 'Resources', 'app') @@ -36,21 +42,43 @@ module.exports = { var contentsPath = path.join(tempPath, 'Electron.app', 'Contents') var frameworksPath = path.join(contentsPath, 'Frameworks') var appPlistFilename = path.join(contentsPath, 'Info.plist') - var helperPlistFilename = path.join(frameworksPath, 'Electron Helper.app', 'Contents', 'Info.plist') var appPlist = plist.parse(fs.readFileSync(appPlistFilename).toString()) + + var helperPlistFilename = path.join(frameworksPath, 'Electron Helper.app', 'Contents', 'Info.plist') var helperPlist = plist.parse(fs.readFileSync(helperPlistFilename).toString()) + var helperEHPlistFilename = path.join(frameworksPath, 'Electron Helper EH.app', 'Contents', 'Info.plist') + var helperEHPlist = plist.parse(fs.readFileSync(helperEHPlistFilename).toString()) + var helperNPPlistFilename = path.join(frameworksPath, 'Electron Helper NP.app', 'Contents', 'Info.plist') + var helperNPPlist = plist.parse(fs.readFileSync(helperNPPlistFilename).toString()) // Update plist files - var defaultBundleName = 'com.electron.' + opts.name.toLowerCase().replace(/ /g, '_') + var defaultBundleName = 'com.electron.' + opts.name.toLowerCase() + var appBundleIdentifier = filterCFBundleIdentifier(opts['app-bundle-id'] || defaultBundleName) + var helperBundleIdentifier = filterCFBundleIdentifier(opts['helper-bundle-id'] || appBundleIdentifier + '.helper') + var appVersion = opts['app-version'] var buildVersion = opts['build-version'] var appCategoryType = opts['app-category-type'] + var humanReadableCopyright = opts['app-human-readable-copyright'] - appPlist.CFBundleDisplayName = opts.name - appPlist.CFBundleIdentifier = opts['app-bundle-id'] || defaultBundleName appPlist.CFBundleName = opts.name - helperPlist.CFBundleIdentifier = opts['helper-bundle-id'] || defaultBundleName + '.helper' + appPlist.CFBundleDisplayName = opts.name + appPlist.CFBundleIdentifier = appBundleIdentifier + helperPlist.CFBundleName = opts.name + helperPlist.CFBundleDisplayName = opts.name + ' Helper' + helperPlist.CFBundleExecutable = opts.name + ' Helper' + helperPlist.CFBundleIdentifier = helperBundleIdentifier + + helperEHPlist.CFBundleName = opts.name + ' Helper EH' + helperEHPlist.CFBundleDisplayName = opts.name + ' Helper EH' + helperEHPlist.CFBundleExecutable = opts.name + ' Helper EH' + helperEHPlist.CFBundleIdentifier = helperBundleIdentifier + '.EH' + + helperNPPlist.CFBundleName = opts.name + ' Helper NP' + helperNPPlist.CFBundleDisplayName = opts.name + ' Helper NP' + helperNPPlist.CFBundleExecutable = opts.name + ' Helper NP' + helperNPPlist.CFBundleIdentifier = helperBundleIdentifier + '.NP' if (appVersion) { appPlist.CFBundleShortVersionString = appPlist.CFBundleVersion = '' + appVersion @@ -73,8 +101,14 @@ module.exports = { appPlist.LSApplicationCategoryType = appCategoryType } + if (humanReadableCopyright) { + appPlist.NSHumanReadableCopyright = humanReadableCopyright + } + fs.writeFileSync(appPlistFilename, plist.build(appPlist)) fs.writeFileSync(helperPlistFilename, plist.build(helperPlist)) + fs.writeFileSync(helperEHPlistFilename, plist.build(helperEHPlist)) + fs.writeFileSync(helperNPPlistFilename, plist.build(helperNPPlist)) var operations = [] @@ -101,7 +135,23 @@ module.exports = { if (opts.sign) { operations.push(function (cb) { - child.exec('codesign --deep --force --sign "' + opts.sign + '" "' + finalAppPath + '"', cb) + sign({ + app: finalAppPath, + platform: opts.platform, + // Take argument sign as signing identity: + // Provided in command line --sign, opts.sign will be recognized + // as boolean value true. Then fallback to null for auto discovery, + // otherwise provided signing certificate. + identity: opts.sign === true ? null : opts.sign, + entitlements: opts['sign-entitlements'] + }, function (err) { + if (err) { + console.warn('Code sign failed; please retry manually.') + // Though not signed successfully, the application is packed. + // It might have to be signed for another time manually. + } + cb() + }) }) } @@ -110,5 +160,6 @@ module.exports = { common.moveApp(opts, tempPath, callback) }) }) - } + }, + filterCFBundleIdentifier: filterCFBundleIdentifier } diff --git a/package.json b/package.json index 5a054c0f6..0df094d25 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "dependencies": { "asar": "^0.8.2", "electron-download": "^1.0.0", + "electron-osx-sign": "^0.1.6", "extract-zip": "^1.0.3", "get-package-info": "0.0.2", "minimist": "^1.1.1", diff --git a/readme.md b/readme.md index 6f3f819e7..3bc25e055 100644 --- a/readme.md +++ b/readme.md @@ -113,7 +113,7 @@ packager(opts, function done (err, appPath) { }) `platform` - *String* - Allowed values: *linux, win32, darwin, all* + Allowed values: *darwin, linux, mas, win32, all* Not required if `all` is used. Arbitrary combinations of individual platforms are also supported via a comma-delimited string or array of strings. @@ -137,15 +137,9 @@ packager(opts, function done (err, appPath) { }) Valid values are listed in [Apple's documentation](https://developer.apple.com/library/ios/documentation/General/Reference/InfoPlistKeyReference/Articles/LaunchServicesKeys.html#//apple_ref/doc/uid/TP40009250-SW8). - The bundle identifier to use in the application's plist (OS X only). - -`app-category-type` - *String* +`app-copyright` - *String* - The application category type, as shown in the Finder via *View -> Arrange by Application Category* when viewing the Applications directory (OS X only). - - For example, `app-category-type=public.app-category.developer-tools` will set the application category to *Developer Tools*. - - Valid values are listed in [Apple's documentation](https://developer.apple.com/library/ios/documentation/General/Reference/InfoPlistKeyReference/Articles/LaunchServicesKeys.html#//apple_ref/doc/uid/TP40009250-SW8). + Copyright string to use in the app plist. Maps to the `NSHumanReadableCopyright` on OS X. `app-version` - *String* @@ -193,11 +187,7 @@ If the file extension is omitted, it is auto-completed to the correct extension A pattern which specifies which files to ignore when copying files to create the package(s). Alternatively, this can be a predicate function that, given the file path, returns true if the file should be ignored or false if the file should be kept. `name` - *String* - The application name. If omitted, it will use the "productName" or "name" of the nearest package.json. - - A pattern which specifies which files to ignore when copying files to create the package(s). -`name` - *String* The application name. If omitted, it will use the "productName" or "name" of the nearest package.json. `out` - *String* @@ -214,9 +204,11 @@ If the file extension is omitted, it is auto-completed to the correct extension `sign` - *String* - The identity used when signing the package via `codesign`. (Only for the OS X target platform, when XCode is present on the build platform.) + The identity used when signing the package via `codesign`. (Only for the OS X / Mac App Store target platforms, when Xcode is present on the host platform.) -`app-category-type` - *String* +`sign-entitlements` - *String* + + The path to entitlements used in signing. (Currently limited to Mac App Store distribution.) `strict-ssl` - *Boolean* diff --git a/test/basic.js b/test/basic.js index e34322cb4..d575509f9 100644 --- a/test/basic.js +++ b/test/basic.js @@ -15,7 +15,7 @@ function generateNamePath (opts) { // Generates path to verify reflects the name given in the options. // Returns the Helper.app location on darwin since the top-level .app is already tested for the resources path; // returns the executable for other OSes - if (opts.platform === 'darwin') { + if (util.isPlatformMac(opts.platform)) { return path.join(opts.name + '.app', 'Contents', 'Frameworks', opts.name + ' Helper.app') } @@ -49,7 +49,7 @@ function createDefaultsTest (combination) { resourcesPath = path.join(finalPath, util.generateResourcesPath(opts)) fs.stat(path.join(finalPath, generateNamePath(opts)), cb) }, function (stats, cb) { - if (opts.platform === 'darwin') { + if (util.isPlatformMac(opts.platform)) { t.true(stats.isDirectory(), 'The Helper.app should reflect opts.name') } else { t.true(stats.isFile(), 'The executable should reflect opts.name') @@ -296,7 +296,7 @@ function createInferTest (combination) { opts.name = packageJSON.productName fs.stat(path.join(finalPath, generateNamePath(opts)), cb) }, function (stats, cb) { - if (opts.platform === 'darwin') { + if (util.isPlatformMac(opts.platform)) { t.true(stats.isDirectory(), 'The Helper.app should reflect productName') } else { t.true(stats.isFile(), 'The executable should reflect productName') diff --git a/test/config.json b/test/config.json index 5c6c3707e..7d6ada011 100644 --- a/test/config.json +++ b/test/config.json @@ -1,4 +1,4 @@ { "timeout": 30000, - "version": "0.28.3" + "version": "0.35.6" } diff --git a/test/darwin.js b/test/darwin.js new file mode 100644 index 000000000..2f0c9a005 --- /dev/null +++ b/test/darwin.js @@ -0,0 +1,13 @@ +var path = require('path') + +var config = require('./config.json') + +var baseOpts = { + name: 'basicTest', + dir: path.join(__dirname, 'fixtures', 'basic'), + version: config.version, + arch: 'x64', + platform: 'darwin' +} + +require('./mac')(baseOpts) diff --git a/test/fixtures/basic/package.json b/test/fixtures/basic/package.json index b4ad8f76d..3fb8f8493 100644 --- a/test/fixtures/basic/package.json +++ b/test/fixtures/basic/package.json @@ -7,6 +7,6 @@ "devDependencies": { "ncp": "^2.0.0", "run-waterfall": "^1.1.1", - "electron-prebuilt": "0.36.4" + "electron-prebuilt": "0.35.6" } } diff --git a/test/index.js b/test/index.js index 68a2a15ca..340f65312 100644 --- a/test/index.js +++ b/test/index.js @@ -23,6 +23,7 @@ series([ if (process.platform !== 'win32') { // Perform additional tests specific to building for OS X - require('./mac') + require('./darwin') + require('./mas') } }) diff --git a/test/mac.js b/test/mac.js index cde0ff365..55765fc0c 100644 --- a/test/mac.js +++ b/test/mac.js @@ -1,210 +1,382 @@ var exec = require('child_process').exec var fs = require('fs') var path = require('path') +var plist = require('plist') var packager = require('..') +var filterCFBundleIdentifier = require('../mac').filterCFBundleIdentifier var test = require('tape') var waterfall = require('run-waterfall') var config = require('./config.json') var util = require('./util') -var plist = require('plist') -var baseOpts = { - name: 'basicTest', - dir: path.join(__dirname, 'fixtures', 'basic'), - version: config.version, - arch: 'x64', - platform: 'darwin' -} +module.exports = function (baseOpts) { + function createIconTest (icon, iconPath) { + return function (t) { + t.timeoutAfter(config.timeout) -function createIconTest (icon, iconPath) { - return function (t) { - t.timeoutAfter(config.timeout) + var opts = Object.create(baseOpts) + opts.icon = icon - var opts = Object.create(baseOpts) - opts.icon = icon + var resourcesPath - var resourcesPath + waterfall([ + function (cb) { + packager(opts, cb) + }, function (paths, cb) { + resourcesPath = path.join(paths[0], util.generateResourcesPath(opts)) + fs.stat(resourcesPath, cb) + }, function (stats, cb) { + t.true(stats.isDirectory(), 'The output directory should contain the expected resources subdirectory') + util.areFilesEqual(iconPath, path.join(resourcesPath, 'atom.icns'), cb) + }, function (equal, cb) { + t.true(equal, 'atom.icns should be identical to the specified icon file') + cb() + } + ], function (err) { + t.end(err) + }) + } + } - waterfall([ - function (cb) { - packager(opts, cb) - }, function (paths, cb) { - resourcesPath = path.join(paths[0], util.generateResourcesPath(opts)) - fs.stat(resourcesPath, cb) - }, function (stats, cb) { - t.true(stats.isDirectory(), 'The output directory should contain the expected resources subdirectory') - util.areFilesEqual(iconPath, path.join(resourcesPath, 'atom.icns'), cb) - }, function (equal, cb) { - t.true(equal, 'atom.icns should be identical to the specified icon file') - cb() + function createAppVersionTest (appVersion, buildVersion) { + return function (t) { + t.timeoutAfter(config.timeout) + + var plistPath + var opts = Object.create(baseOpts) + opts['app-version'] = opts['build-version'] = appVersion + + if (buildVersion) { + opts['build-version'] = buildVersion } - ], function (err) { - t.end(err) - }) + + waterfall([ + function (cb) { + packager(opts, cb) + }, function (paths, cb) { + plistPath = path.join(paths[0], opts.name + '.app', 'Contents', 'Info.plist') + fs.stat(plistPath, cb) + }, function (stats, cb) { + t.true(stats.isFile(), 'The expected Info.plist file should exist') + fs.readFile(plistPath, 'utf8', cb) + }, function (file, cb) { + var obj = plist.parse(file) + t.equal(obj.CFBundleVersion, '' + opts['build-version'], 'CFBundleVersion should reflect opts["build-version"]') + t.equal(obj.CFBundleShortVersionString, '' + opts['app-version'], 'CFBundleShortVersionString should reflect opts["app-version"]') + t.equal(typeof obj.CFBundleVersion, 'string', 'CFBundleVersion should be a string') + t.equal(typeof obj.CFBundleShortVersionString, 'string', 'CFBundleShortVersionString should be a string') + cb() + } + ], function (err) { + t.end(err) + }) + } } -} -function createAppVersionTest (appVersion, buildVersion) { - return function (t) { - t.timeoutAfter(config.timeout) + function createAppCategoryTypeTest (appCategoryType) { + return function (t) { + t.timeoutAfter(config.timeout) - var plistPath - var opts = Object.create(baseOpts) - opts['app-version'] = opts['build-version'] = appVersion + var plistPath + var opts = Object.create(baseOpts) + opts['app-category-type'] = appCategoryType + + waterfall([ + function (cb) { + packager(opts, cb) + }, function (paths, cb) { + plistPath = path.join(paths[0], opts.name + '.app', 'Contents', 'Info.plist') + fs.stat(plistPath, cb) + }, function (stats, cb) { + t.true(stats.isFile(), 'The expected Info.plist file should exist') + fs.readFile(plistPath, 'utf8', cb) + }, function (file, cb) { + var obj = plist.parse(file) + t.equal(obj.LSApplicationCategoryType, opts['app-category-type'], 'LSApplicationCategoryType should reflect opts["app-category-type"]') + cb() + } + ], function (err) { + t.end(err) + }) + } + } + + function createAppBundleTest (appBundleId) { + return function (t) { + t.timeoutAfter(config.timeout) + + var plistPath + var opts = Object.create(baseOpts) + if (appBundleId) { + opts['app-bundle-id'] = appBundleId + } + var defaultBundleName = 'com.electron.' + opts.name.toLowerCase() + var appBundleIdentifier = filterCFBundleIdentifier(opts['app-bundle-id'] || defaultBundleName) - if (buildVersion) { - opts['build-version'] = buildVersion + waterfall([ + function (cb) { + packager(opts, cb) + }, function (paths, cb) { + plistPath = path.join(paths[0], opts.name + '.app', 'Contents', 'Info.plist') + fs.stat(plistPath, cb) + }, function (stats, cb) { + t.true(stats.isFile(), 'The expected Info.plist file should exist') + fs.readFile(plistPath, 'utf8', cb) + }, function (file, cb) { + var obj = plist.parse(file) + t.equal(obj.CFBundleDisplayName, opts.name, 'CFBundleDisplayName should reflect opts.name') + t.equal(obj.CFBundleName, opts.name, 'CFBundleName should reflect opts.name') + t.equal(obj.CFBundleIdentifier, appBundleIdentifier, 'CFBundleName should reflect opts["app-bundle-id"] or fallback to default') + t.equal(typeof obj.CFBundleDisplayName, 'string', 'CFBundleDisplayName should be a string') + t.equal(typeof obj.CFBundleName, 'string', 'CFBundleName should be a string') + t.equal(typeof obj.CFBundleIdentifier, 'string', 'CFBundleIdentifier should be a string') + t.equal(/^[a-zA-Z0-9-.]*$/.test(obj.CFBundleIdentifier), true, 'CFBundleIdentifier should allow only alphanumeric (A-Z,a-z,0-9), hyphen (-), and period (.)') + cb() + } + ], function (err) { + t.end(err) + }) } + } + + function createAppHelpersBundleTest (helperBundleId, appBundleId) { + return function (t) { + t.timeoutAfter(config.timeout) + + var tempPath, plistPath + var opts = Object.create(baseOpts) + if (helperBundleId) { + opts['helper-bundle-id'] = appBundleId + } + if (appBundleId) { + opts['app-bundle-id'] = appBundleId + } + var defaultBundleName = 'com.electron.' + opts.name.toLowerCase() + var appBundleIdentifier = filterCFBundleIdentifier(opts['app-bundle-id'] || defaultBundleName) + var helperBundleIdentifier = filterCFBundleIdentifier(opts['helper-bundle-id'] || appBundleIdentifier + '.helper') + + waterfall([ + function (cb) { + packager(opts, cb) + }, function (paths, cb) { + tempPath = paths[0] + plistPath = path.join(tempPath, opts.name + '.app', 'Contents', 'Frameworks', opts.name + ' Helper.app', 'Contents', 'Info.plist') + fs.stat(plistPath, cb) + }, function (stats, cb) { + t.true(stats.isFile(), 'The expected Info.plist file should exist in helper app') + fs.readFile(plistPath, 'utf8', cb) + }, function (file, cb) { + var obj = plist.parse(file) + t.equal(obj.CFBundleName, opts.name, 'CFBundleName should reflect opts.name in helper app') + t.equal(obj.CFBundleIdentifier, helperBundleIdentifier, 'CFBundleName should reflect opts["helper-bundle-id"], opts["app-bundle-id"] or fallback to default in helper app') + t.equal(typeof obj.CFBundleName, 'string', 'CFBundleName should be a string in helper app') + t.equal(typeof obj.CFBundleIdentifier, 'string', 'CFBundleIdentifier should be a string in helper app') + t.equal(/^[a-zA-Z0-9-.]*$/.test(obj.CFBundleIdentifier), true, 'CFBundleIdentifier should allow only alphanumeric (A-Z,a-z,0-9), hyphen (-), and period (.)') + // check helper EH + plistPath = path.join(tempPath, opts.name + '.app', 'Contents', 'Frameworks', opts.name + ' Helper EH.app', 'Contents', 'Info.plist') + fs.stat(plistPath, cb) + }, function (stats, cb) { + t.true(stats.isFile(), 'The expected Info.plist file should exist in helper EH app') + fs.readFile(plistPath, 'utf8', cb) + }, function (file, cb) { + var obj = plist.parse(file) + t.equal(obj.CFBundleName, opts.name + ' Helper EH', 'CFBundleName should reflect opts.name in helper EH app') + t.equal(obj.CFBundleDisplayName, opts.name + ' Helper EH', 'CFBundleDisplayName should reflect opts.name in helper EH app') + t.equal(obj.CFBundleExecutable, opts.name + ' Helper EH', 'CFBundleExecutable should reflect opts.name in helper EH app') + t.equal(obj.CFBundleIdentifier, helperBundleIdentifier + '.EH', 'CFBundleName should reflect opts["helper-bundle-id"], opts["app-bundle-id"] or fallback to default in helper EH app') + t.equal(typeof obj.CFBundleName, 'string', 'CFBundleName should be a string in helper EH app') + t.equal(typeof obj.CFBundleDisplayName, 'string', 'CFBundleDisplayName should be a string in helper EH app') + t.equal(typeof obj.CFBundleExecutable, 'string', 'CFBundleExecutable should be a string in helper EH app') + t.equal(typeof obj.CFBundleIdentifier, 'string', 'CFBundleIdentifier should be a string in helper EH app') + t.equal(/^[a-zA-Z0-9-.]*$/.test(obj.CFBundleIdentifier), true, 'CFBundleIdentifier should allow only alphanumeric (A-Z,a-z,0-9), hyphen (-), and period (.)') + // check helper NP + plistPath = path.join(tempPath, opts.name + '.app', 'Contents', 'Frameworks', opts.name + ' Helper NP.app', 'Contents', 'Info.plist') + fs.stat(plistPath, cb) + }, function (stats, cb) { + t.true(stats.isFile(), 'The expected Info.plist file should exist in helper NP app') + fs.readFile(plistPath, 'utf8', cb) + }, function (file, cb) { + var obj = plist.parse(file) + t.equal(obj.CFBundleName, opts.name + ' Helper NP', 'CFBundleName should reflect opts.name in helper NP app') + t.equal(obj.CFBundleDisplayName, opts.name + ' Helper NP', 'CFBundleDisplayName should reflect opts.name in helper NP app') + t.equal(obj.CFBundleExecutable, opts.name + ' Helper NP', 'CFBundleExecutable should reflect opts.name in helper NP app') + t.equal(obj.CFBundleIdentifier, helperBundleIdentifier + '.NP', 'CFBundleName should reflect opts["helper-bundle-id"], opts["app-bundle-id"] or fallback to default in helper NP app') + t.equal(typeof obj.CFBundleName, 'string', 'CFBundleName should be a string in helper NP app') + t.equal(typeof obj.CFBundleDisplayName, 'string', 'CFBundleDisplayName should be a string in helper NP app') + t.equal(typeof obj.CFBundleExecutable, 'string', 'CFBundleExecutable should be a string in helper NP app') + t.equal(typeof obj.CFBundleIdentifier, 'string', 'CFBundleIdentifier should be a string in helper NP app') + t.equal(/^[a-zA-Z0-9-.]*$/.test(obj.CFBundleIdentifier), true, 'CFBundleIdentifier should allow only alphanumeric (A-Z,a-z,0-9), hyphen (-), and period (.)') + cb() + } + ], function (err) { + t.end(err) + }) + } + } + + function createAppHumanReadableCopyrightTest (humanReadableCopyright) { + return function (t) { + t.timeoutAfter(config.timeout) + + var plistPath + var opts = Object.create(baseOpts) + opts['app-human-readable-copyright'] = humanReadableCopyright + + waterfall([ + function (cb) { + packager(opts, cb) + }, function (paths, cb) { + plistPath = path.join(paths[0], opts.name + '.app', 'Contents', 'Info.plist') + fs.stat(plistPath, cb) + }, function (stats, cb) { + t.true(stats.isFile(), 'The expected Info.plist file should exist') + fs.readFile(plistPath, 'utf8', cb) + }, function (file, cb) { + var obj = plist.parse(file) + t.equal(obj.NSHumanReadableCopyright, opts['app-human-readable-copyright'], 'NSHumanReadableCopyright should reflect opts["app-human-readable-copyright"]') + cb() + } + ], function (err) { + t.end(err) + }) + } + } + + util.setup() + test('helper app paths test', function (t) { + t.timeoutAfter(config.timeout) + + function getHelperExecutablePath (helperName) { + return path.join(helperName + '.app', 'Contents', 'MacOS', helperName) + } + + var opts = Object.create(baseOpts) + var frameworksPath waterfall([ function (cb) { packager(opts, cb) }, function (paths, cb) { - plistPath = path.join(paths[0], opts.name + '.app', 'Contents', 'Info.plist') - fs.stat(plistPath, cb) + frameworksPath = path.join(paths[0], opts.name + '.app', 'Contents', 'Frameworks') + // main Helper.app is already tested in basic test suite; test its executable and the other helpers + fs.stat(path.join(frameworksPath, getHelperExecutablePath(opts.name + ' Helper')), cb) }, function (stats, cb) { - t.true(stats.isFile(), 'The expected Info.plist file should exist') - fs.readFile(plistPath, 'utf8', cb) - }, function (file, cb) { - var obj = plist.parse(file) - t.equal(obj.CFBundleVersion, '' + opts['build-version'], 'CFBundleVersion should reflect build-version') - t.equal(obj.CFBundleShortVersionString, '' + opts['app-version'], 'CFBundleShortVersionString should reflect app-version') - t.equal(typeof obj.CFBundleVersion, 'string', 'CFBundleVersion should be a string') - t.equal(typeof obj.CFBundleShortVersionString, 'string', 'CFBundleShortVersionString should be a string') + t.true(stats.isFile(), 'The Helper.app executable should reflect opts.name') + fs.stat(path.join(frameworksPath, opts.name + ' Helper EH.app'), cb) + }, function (stats, cb) { + t.true(stats.isDirectory(), 'The Helper EH.app should reflect opts.name') + fs.stat(path.join(frameworksPath, getHelperExecutablePath(opts.name + ' Helper EH')), cb) + }, function (stats, cb) { + t.true(stats.isFile(), 'The Helper EH.app executable should reflect opts.name') + fs.stat(path.join(frameworksPath, opts.name + ' Helper NP.app'), cb) + }, function (stats, cb) { + t.true(stats.isDirectory(), 'The Helper NP.app should reflect opts.name') + fs.stat(path.join(frameworksPath, getHelperExecutablePath(opts.name + ' Helper NP')), cb) + }, function (stats, cb) { + t.true(stats.isFile(), 'The Helper NP.app executable should reflect opts.name') cb() } ], function (err) { t.end(err) }) - } -} + }) + util.teardown() + + var iconBase = path.join(__dirname, 'fixtures', 'monochrome') + var icnsPath = iconBase + '.icns' + util.setup() + test('icon test: .icns specified', createIconTest(icnsPath, icnsPath)) + util.teardown() + + util.setup() + test('icon test: .ico specified (should replace with .icns)', createIconTest(iconBase + '.ico', icnsPath)) + util.teardown() -function createAppCategoryTypeTest (appCategoryType) { - return function (t) { + util.setup() + test('icon test: basename only (should add .icns)', createIconTest(iconBase, icnsPath)) + util.teardown() + + util.setup() + test('codesign test', function (t) { t.timeoutAfter(config.timeout) - var plistPath var opts = Object.create(baseOpts) - opts['app-category-type'] = appCategoryType + opts.sign = true // Ad-hoc + + var appPath waterfall([ function (cb) { packager(opts, cb) }, function (paths, cb) { - plistPath = path.join(paths[0], opts.name + '.app', 'Contents', 'Info.plist') - fs.stat(plistPath, cb) + appPath = path.join(paths[0], opts.name + '.app') + fs.stat(appPath, cb) }, function (stats, cb) { - t.true(stats.isFile(), 'The expected Info.plist file should exist') - fs.readFile(plistPath, 'utf8', cb) - }, function (file, cb) { - var obj = plist.parse(file) - t.equal(obj.LSApplicationCategoryType, opts['app-category-type'], 'LSApplicationCategoryType should reflect opts.["app-category-type"]') + t.true(stats.isDirectory(), 'The expected .app directory should exist') + exec('codesign -v ' + appPath, cb) + }, function (stdout, stderr, cb) { + t.pass('codesign should verify successfully') cb() } ], function (err) { - t.end(err) + var notFound = err && err.code === 127 + if (notFound) console.log('codesign not installed; skipped') + t.end(notFound ? null : err) }) - } -} + }) + util.teardown() -util.setup() -test('helper app paths test', function (t) { - t.timeoutAfter(config.timeout) + util.setup() + test('app and build version test', createAppVersionTest('1.1.0', '1.1.0.1234')) + util.teardown() - function getHelperExecutablePath (helperName) { - return path.join(helperName + '.app', 'Contents', 'MacOS', helperName) - } + util.setup() + test('app version test', createAppVersionTest('1.1.0')) + util.teardown() - var opts = Object.create(baseOpts) - var frameworksPath - - waterfall([ - function (cb) { - packager(opts, cb) - }, function (paths, cb) { - frameworksPath = path.join(paths[0], opts.name + '.app', 'Contents', 'Frameworks') - // main Helper.app is already tested in basic test suite; test its executable and the other helpers - fs.stat(path.join(frameworksPath, getHelperExecutablePath(opts.name + ' Helper')), cb) - }, function (stats, cb) { - t.true(stats.isFile(), 'The Helper.app executable should reflect opts.name') - fs.stat(path.join(frameworksPath, opts.name + ' Helper EH.app'), cb) - }, function (stats, cb) { - t.true(stats.isDirectory(), 'The Helper EH.app should reflect opts.name') - fs.stat(path.join(frameworksPath, getHelperExecutablePath(opts.name + ' Helper EH')), cb) - }, function (stats, cb) { - t.true(stats.isFile(), 'The Helper EH.app executable should reflect opts.name') - fs.stat(path.join(frameworksPath, opts.name + ' Helper NP.app'), cb) - }, function (stats, cb) { - t.true(stats.isDirectory(), 'The Helper NP.app should reflect opts.name') - fs.stat(path.join(frameworksPath, getHelperExecutablePath(opts.name + ' Helper NP')), cb) - }, function (stats, cb) { - t.true(stats.isFile(), 'The Helper NP.app executable should reflect opts.name') - cb() - } - ], function (err) { - t.end(err) - }) -}) -util.teardown() - -var iconBase = path.join(__dirname, 'fixtures', 'monochrome') -var icnsPath = iconBase + '.icns' -util.setup() -test('icon test: .icns specified', createIconTest(icnsPath, icnsPath)) -util.teardown() - -util.setup() -test('icon test: .ico specified (should replace with .icns)', createIconTest(iconBase + '.ico', icnsPath)) -util.teardown() - -util.setup() -test('icon test: basename only (should add .icns)', createIconTest(iconBase, icnsPath)) -util.teardown() - -util.setup() -test('codesign test', function (t) { - t.timeoutAfter(config.timeout) - - var opts = Object.create(baseOpts) - opts.sign = '-' // Ad-hoc - - var appPath - - waterfall([ - function (cb) { - packager(opts, cb) - }, function (paths, cb) { - appPath = path.join(paths[0], opts.name + '.app') - fs.stat(appPath, cb) - }, function (stats, cb) { - t.true(stats.isDirectory(), 'The expected .app directory should exist') - exec('codesign --verify --deep ' + appPath, cb) - }, function (stdout, stderr, cb) { - t.pass('codesign should verify successfully') - cb() - } - ], function (err) { - var notFound = err && err.code === 127 - if (notFound) console.log('codesign not installed; skipped') - t.end(notFound ? null : err) - }) -}) -util.teardown() + util.setup() + test('app and build version integer test', createAppVersionTest(12, 1234)) + util.teardown() + + util.setup() + test('app categoryType test', createAppCategoryTypeTest('public.app-category.developer-tools')) + util.teardown() + + util.setup() + test('app humanReadableCopyright test', createAppHumanReadableCopyrightTest('Copyright © 2003–2015 Organization. All rights reserved.')) + util.teardown() + + util.setup() + test('app bundle test', createAppBundleTest('com.electron.basetest')) + util.teardown() -util.setup() -test('app and build version test', createAppVersionTest('1.1.0', '1.1.0.1234')) -util.teardown() + util.setup() + test('app bundle (w/ special characters) test', createAppBundleTest('com.electron."bãśè tëßt!@#$%^&*()?\'')) + util.teardown() -util.setup() -test('app version test', createAppVersionTest('1.1.0')) -util.teardown() + util.setup() + test('app bundle app-bundle-id fallback test', createAppBundleTest()) + util.teardown() -util.setup() -test('app and build version integer test', createAppVersionTest(12, 1234)) -util.teardown() + util.setup() + test('app helpers bundle test', createAppHelpersBundleTest('com.electron.basetest.helper')) + util.teardown() -util.setup() -test('app categoryType test', createAppCategoryTypeTest('public.app-category.developer-tools')) -util.teardown() + util.setup() + test('app helpers bundle (w/ special characters) test', createAppHelpersBundleTest('com.electron."bãśè tëßt!@#$%^&*()?\'.hęłpėr')) + util.teardown() + + util.setup() + test('app helpers bundle helper-bundle-id fallback to app-bundle-id test', createAppHelpersBundleTest(null, 'com.electron.basetest')) + util.teardown() + + util.setup() + test('app helpers bundle helper-bundle-id fallback to app-bundle-id (w/ special characters) test', createAppHelpersBundleTest(null, 'com.electron."bãśè tëßt!!@#$%^&*()?\'')) + util.teardown() + + util.setup() + test('app helpers bundle helper-bundle-id & app-bundle-id fallback test', createAppHelpersBundleTest()) + util.teardown() +} diff --git a/test/mas.js b/test/mas.js new file mode 100644 index 000000000..e31b4ffcc --- /dev/null +++ b/test/mas.js @@ -0,0 +1,13 @@ +var path = require('path') + +var config = require('./config.json') + +var baseOpts = { + name: 'basicTest', + dir: path.join(__dirname, 'fixtures', 'basic'), + version: config.version, + arch: 'x64', + platform: 'mas' +} + +require('./mac')(baseOpts) diff --git a/test/multitarget.js b/test/multitarget.js index 1c9240de8..14566b0b2 100644 --- a/test/multitarget.js +++ b/test/multitarget.js @@ -38,8 +38,8 @@ test('all test', function (t) { function (cb) { packager(opts, cb) }, function (finalPaths, cb) { - // Windows skips packaging for OS X, and OS X only has 64-bit releases - t.equal(finalPaths.length, process.platform === 'win32' ? 4 : 5, + // Windows skips packaging for OS X (darwin + mas), and OS X only has 64-bit releases + t.equal(finalPaths.length, process.platform === 'win32' ? 4 : 6, 'packager call should resolve with expected number of paths') verifyPackageExistence(finalPaths, cb) }, function (exists, cb) { diff --git a/test/util.js b/test/util.js index f3b23cccc..b2998171a 100644 --- a/test/util.js +++ b/test/util.js @@ -11,17 +11,21 @@ var ORIGINAL_CWD = process.cwd() var WORK_CWD = path.join(__dirname, 'work') var archs = ['ia32', 'x64'] -var platforms = ['darwin', 'linux', 'win32'] +var platforms = ['darwin', 'linux', 'mas', 'win32'] var slice = Array.prototype.slice var version = require('./config.json').version +function isPlatformMac (platform) { + return platform === 'darwin' || platform === 'mas' +} + var combinations = [] archs.forEach(function (arch) { platforms.forEach(function (platform) { // Electron does not have 32-bit releases for Mac OS X, so skip that combination - // Also skip testing darwin target on Windows since electron-packager itself skips it + // Also skip testing darwin/mas target on Windows since electron-packager itself skips it // (see https://github.com/maxogden/electron-packager/issues/71) - if (platform === 'darwin' && (arch === 'ia32' || require('os').platform() === 'win32')) return + if (isPlatformMac(platform) && (arch === 'ia32' || require('os').platform() === 'win32')) return combinations.push({ arch: arch, @@ -59,13 +63,17 @@ exports.forEachCombination = function forEachCombination (cb) { } exports.generateResourcesPath = function generateResourcesPath (opts) { - return opts.platform === 'darwin' ? path.join(opts.name + '.app', 'Contents', 'Resources') : 'resources' + return isPlatformMac(opts.platform) + ? path.join(opts.name + '.app', 'Contents', 'Resources') + : 'resources' } exports.getWorkCwd = function getWorkCwd () { return WORK_CWD } +exports.isPlatformMac = isPlatformMac + // tape doesn't seem to have a provision for before/beforeEach/afterEach/after, // so run setup/teardown and cleanup tasks as additional "tests" to put them in sequence // and run them irrespective of test failures diff --git a/usage.txt b/usage.txt index 20e9d8587..f8d9f73d6 100644 --- a/usage.txt +++ b/usage.txt @@ -2,7 +2,7 @@ Usage: electron-packager --platform= --arch=. - For example, `--asar-unpack-dir=sub_dir` will unpack the directory `//sub_dir`. -build-version build version to set for the app +build-version build version to set for the app (darwin/mas platform only) cache directory of cached Electron downloads. Defaults to '$HOME/.electron' -helper-bundle-id bundle identifier to use in the app helper plist (darwin platform only) +helper-bundle-id bundle identifier to use in the app helper plist (darwin/mas platform only) icon the icon file to use as the icon for the app. Note: Format depends on platform. ignore do not copy files into app whose filenames regex .match this string out the dir to put the app into at the end. defaults to current working dir overwrite if output directory for a platform already exists, replaces it rather than skipping it prune runs `npm prune --production` on the app -sign should contain the identity to be used when running `codesign` (only for building for the darwin platform, on OS X) +sign should contain the identity to be used when running `codesign` (only for building for the darwin/mas platform, on OS X) +sign-entitlements the path to entitlements used in signing (mas platform only) strict-ssl whether SSL certificates are required to be valid when downloading Electron. It defaults to true, use --strict-ssl=false to disable checks. tmpdir temp directory. Defaults to system temp directory.