'use strict' const App = require('./platform') const common = require('./common') const debug = require('debug')('electron-packager') const fs = require('fs-extra') const path = require('path') const plist = require('plist') const { notarize } = require('@electron/notarize') const { signApp } = require('@electron/osx-sign') class MacApp extends App { constructor (opts, templatePath) { super(opts, templatePath) this.appName = opts.name } get appCategoryType () { return this.opts.appCategoryType } get appCopyright () { return this.opts.appCopyright } get appVersion () { return this.opts.appVersion } get buildVersion () { return this.opts.buildVersion } get enableDarkMode () { return this.opts.darwinDarkModeSupport } get usageDescription () { return this.opts.usageDescription } get protocols () { return this.opts.protocols.map((protocol) => { return { CFBundleURLName: protocol.name, CFBundleURLSchemes: [].concat(protocol.schemes) } }) } get dotAppName () { return `${common.sanitizeAppName(this.appName)}.app` } get defaultBundleName () { return `com.electron.${common.sanitizeAppName(this.appName).toLowerCase()}` } get bundleName () { return filterCFBundleIdentifier(this.opts.appBundleId || this.defaultBundleName) } get originalResourcesDir () { return path.join(this.contentsPath, 'Resources') } get resourcesDir () { return path.join(this.dotAppName, 'Contents', 'Resources') } get electronBinaryDir () { return path.join(this.contentsPath, 'MacOS') } get originalElectronName () { return 'Electron' } get newElectronName () { return this.appPlist.CFBundleExecutable } get renamedAppPath () { return path.join(this.stagingPath, this.dotAppName) } get electronAppPath () { return path.join(this.stagingPath, `${this.originalElectronName}.app`) } get contentsPath () { return path.join(this.electronAppPath, 'Contents') } get frameworksPath () { return path.join(this.contentsPath, 'Frameworks') } get loginItemsPath () { return path.join(this.contentsPath, 'Library', 'LoginItems') } get loginHelperPath () { return path.join(this.loginItemsPath, 'Electron Login Helper.app') } updatePlist (basePlist, displayName, identifier, name) { return Object.assign(basePlist, { CFBundleDisplayName: displayName, CFBundleExecutable: common.sanitizeAppName(displayName), CFBundleIdentifier: identifier, CFBundleName: common.sanitizeAppName(name) }) } updateHelperPlist (basePlist, suffix, identifierIgnoresSuffix) { let helperSuffix, identifier, name if (suffix) { helperSuffix = `Helper ${suffix}` if (identifierIgnoresSuffix) { identifier = this.helperBundleIdentifier } else { identifier = `${this.helperBundleIdentifier}.${suffix}` } name = `${this.appName} ${helperSuffix}` } else { helperSuffix = 'Helper' identifier = this.helperBundleIdentifier name = this.appName } return this.updatePlist(basePlist, `${this.appName} ${helperSuffix}`, identifier, name) } async extendPlist (basePlist, propsOrFilename) { if (!propsOrFilename) { return Promise.resolve() } if (typeof propsOrFilename === 'string') { const plist = await this.loadPlist(propsOrFilename) return Object.assign(basePlist, plist) } else { return Object.assign(basePlist, propsOrFilename) } } async loadPlist (filename, propName) { const loadedPlist = plist.parse((await fs.readFile(filename)).toString()) if (propName) { this[propName] = loadedPlist } return loadedPlist } ehPlistFilename (helper) { return this.helperPlistFilename(path.join(this.frameworksPath, helper)) } helperPlistFilename (helperApp) { return path.join(helperApp, 'Contents', 'Info.plist') } async determinePlistFilesToUpdate () { const appPlistFilename = path.join(this.contentsPath, 'Info.plist') const plists = [ [appPlistFilename, 'appPlist'], [this.ehPlistFilename('Electron Helper.app'), 'helperPlist'] ] const possiblePlists = [ [this.ehPlistFilename('Electron Helper (Renderer).app'), 'helperRendererPlist'], [this.ehPlistFilename('Electron Helper (Plugin).app'), 'helperPluginPlist'], [this.ehPlistFilename('Electron Helper (GPU).app'), 'helperGPUPlist'], [this.ehPlistFilename('Electron Helper EH.app'), 'helperEHPlist'], [this.ehPlistFilename('Electron Helper NP.app'), 'helperNPPlist'], [this.helperPlistFilename(this.loginHelperPath), 'loginHelperPlist'] ] const optional = await Promise.all(possiblePlists.map(async item => (await fs.pathExists(item[0])) ? item : null)) return plists.concat(optional.filter(item => item)) } appRelativePath (p) { return path.relative(this.contentsPath, p) } async updatePlistFiles () { const appBundleIdentifier = this.bundleName this.helperBundleIdentifier = filterCFBundleIdentifier(this.opts.helperBundleId || `${appBundleIdentifier}.helper`) const plists = await this.determinePlistFilesToUpdate() await Promise.all(plists.map(plistArgs => this.loadPlist(...plistArgs))) await this.extendPlist(this.appPlist, this.opts.extendInfo) if (this.asarIntegrity) { await this.extendPlist(this.appPlist, { ElectronAsarIntegrity: this.asarIntegrity }) } else { delete this.appPlist.ElectronAsarIntegrity } this.appPlist = this.updatePlist(this.appPlist, this.executableName, appBundleIdentifier, this.appName) const updateIfExists = [ ['helperRendererPlist', '(Renderer)', true], ['helperPluginPlist', '(Plugin)', true], ['helperGPUPlist', '(GPU)', true], ['helperEHPlist', 'EH'], ['helperNPPlist', 'NP'] ] for (const [plistKey] of [...updateIfExists, ['helperPlist']]) { if (!this[plistKey]) continue await this.extendPlist(this[plistKey], this.opts.extendHelperInfo) } this.helperPlist = this.updateHelperPlist(this.helperPlist) for (const [plistKey, ...suffixArgs] of updateIfExists) { if (!this[plistKey]) continue this[plistKey] = this.updateHelperPlist(this[plistKey], ...suffixArgs) } // Some properties need to go on all helpers as well, version, usage info, etc. const plistsToUpdate = updateIfExists .filter(([key]) => !!this[key]) .map(([key]) => key) .concat(['appPlist', 'helperPlist']) if (this.loginHelperPlist) { const loginHelperName = common.sanitizeAppName(`${this.appName} Login Helper`) this.loginHelperPlist.CFBundleExecutable = loginHelperName this.loginHelperPlist.CFBundleIdentifier = `${appBundleIdentifier}.loginhelper` this.loginHelperPlist.CFBundleName = loginHelperName } if (this.appVersion) { const appVersionString = '' + this.appVersion for (const plistKey of plistsToUpdate) { this[plistKey].CFBundleShortVersionString = this[plistKey].CFBundleVersion = appVersionString } } if (this.buildVersion) { const buildVersionString = '' + this.buildVersion for (const plistKey of plistsToUpdate) { this[plistKey].CFBundleVersion = buildVersionString } } if (this.opts.protocols && this.opts.protocols.length) { this.appPlist.CFBundleURLTypes = this.protocols } if (this.appCategoryType) { this.appPlist.LSApplicationCategoryType = this.appCategoryType } if (this.appCopyright) { this.appPlist.NSHumanReadableCopyright = this.appCopyright } if (this.enableDarkMode) { this.appPlist.NSRequiresAquaSystemAppearance = false } if (this.usageDescription) { for (const [type, description] of Object.entries(this.usageDescription)) { const usageTypeKey = `NS${type}UsageDescription` for (const plistKey of plistsToUpdate) { this[plistKey][usageTypeKey] = description } this.appPlist[usageTypeKey] = description } } await Promise.all(plists.map(([filename, varName]) => fs.writeFile(filename, plist.build(this[varName])))) } async moveHelpers () { const helpers = [' Helper', ' Helper EH', ' Helper NP', ' Helper (Renderer)', ' Helper (Plugin)', ' Helper (GPU)'] await Promise.all(helpers.map(suffix => this.moveHelper(this.frameworksPath, suffix))) if (await fs.pathExists(this.loginItemsPath)) { await this.moveHelper(this.loginItemsPath, ' Login Helper') } } async moveHelper (helperDirectory, suffix) { const originalBasename = `Electron${suffix}` if (await fs.pathExists(path.join(helperDirectory, `${originalBasename}.app`))) { return this.renameHelperAndExecutable( helperDirectory, originalBasename, `${common.sanitizeAppName(this.appName)}${suffix}` ) } else { return Promise.resolve() } } async renameHelperAndExecutable (helperDirectory, originalBasename, newBasename) { const originalAppname = `${originalBasename}.app` const executableBasePath = path.join(helperDirectory, originalAppname, 'Contents', 'MacOS') await this.relativeRename(executableBasePath, originalBasename, newBasename) await this.relativeRename(helperDirectory, originalAppname, `${newBasename}.app`) } async copyIcon () { if (!this.opts.icon) { return Promise.resolve() } let icon try { icon = await this.normalizeIconExtension('.icns') } catch { // Ignore error if icon doesn't exist, in case it's only available for other OSes /* istanbul ignore next */ return Promise.resolve() } if (icon) { debug(`Copying icon "${icon}" to app's Resources as "${this.appPlist.CFBundleIconFile}"`) await fs.copy(icon, path.join(this.originalResourcesDir, this.appPlist.CFBundleIconFile)) } } async renameAppAndHelpers () { await this.moveHelpers() await fs.rename(this.electronAppPath, this.renamedAppPath) } async signAppIfSpecified () { const osxSignOpt = this.opts.osxSign const platform = this.opts.platform const version = this.opts.electronVersion if ((platform === 'all' || platform === 'mas') && osxSignOpt === undefined) { common.warning('signing is required for mas builds. Provide the osx-sign option, ' + 'or manually sign the app later.', this.opts.quiet) } if (osxSignOpt) { const signOpts = createSignOpts(osxSignOpt, platform, this.renamedAppPath, version, this.opts.quiet) debug(`Running @electron/osx-sign with the options ${JSON.stringify(signOpts)}`) try { await signApp(signOpts) } catch (err) { // Although not signed successfully, the application is packed. common.warning(`Code sign failed; please retry manually. ${err}`, this.opts.quiet) } } } async notarizeAppIfSpecified () { const osxNotarizeOpt = this.opts.osxNotarize /* istanbul ignore if */ if (osxNotarizeOpt) { const notarizeOpts = createNotarizeOpts( osxNotarizeOpt, this.bundleName, this.renamedAppPath, this.opts.quiet ) if (notarizeOpts) { return notarize(notarizeOpts) } } } async create () { await this.initialize() await this.updatePlistFiles() await this.copyIcon() await this.renameElectron() await this.renameAppAndHelpers() await this.copyExtraResources() await this.signAppIfSpecified() await this.notarizeAppIfSpecified() return this.move() } } /** * 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 */ function filterCFBundleIdentifier (identifier) { return identifier.replace(/ /g, '-').replace(/[^a-zA-Z0-9.-]/g, '') } function createSignOpts (properties, platform, app, version, quiet) { // use default sign opts if osx-sign is true, otherwise clone osx-sign object const signOpts = properties === true ? { identity: null } : { ...properties } // osx-sign options are handed off to sign module, but // with a few additions from the main options // user may think they can pass platform, app, or version, but they will be ignored common.subOptionWarning(signOpts, 'osx-sign', 'platform', platform, quiet) common.subOptionWarning(signOpts, 'osx-sign', 'app', app, quiet) common.subOptionWarning(signOpts, 'osx-sign', 'version', version, quiet) if (signOpts.binaries) { common.warning('osx-sign.binaries is not an allowed sub-option. Not passing to @electron/osx-sign.', quiet) delete signOpts.binaries } // Take argument osx-sign as signing identity: // if opts.osxSign is true (bool), fallback to identity=null for // autodiscovery. Otherwise, provide signing certificate info. if (signOpts.identity === true) { signOpts.identity = null } return signOpts } function createNotarizeOpts (properties, appBundleId, appPath, quiet) { // osxNotarize options are handed off to the @electron/notarize module, but with a few // additions from the main options. The user may think they can pass bundle ID or appPath, // but they will be ignored. if (properties.tool !== 'notarytool') { common.subOptionWarning(properties, 'osxNotarize', 'appBundleId', appBundleId, quiet) } common.subOptionWarning(properties, 'osxNotarize', 'appPath', appPath, quiet) return properties } module.exports = { App: MacApp, createNotarizeOpts: createNotarizeOpts, createSignOpts: createSignOpts, filterCFBundleIdentifier: filterCFBundleIdentifier }