diff --git a/README.md b/README.md index 4d4c4dcd..c380877b 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ Electron Packager is known to run on the following **host** platforms: It generates executables/bundles for the following **target** platforms: * Windows (also known as `win32`, for x86, x86_64, and arm64 architectures) -* macOS (also known as `darwin`) / [Mac App Store](https://electronjs.org/docs/tutorial/mac-app-store-submission-guide/) (also known as `mas`)* (for x86_64 and arm64 architectures) +* macOS (also known as `darwin`) / [Mac App Store](https://electronjs.org/docs/tutorial/mac-app-store-submission-guide/) (also known as `mas`)* (for x86_64, arm64, and universal architectures) * Linux (for x86, x86_64, armv7l, arm64, and mips64el architectures) * *Note for macOS / Mac App Store target bundles: the `.app` bundle can only be signed when building on a host macOS platform.* diff --git a/package.json b/package.json index 8a0be124..d4110011 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ ], "dependencies": { "@electron/get": "^1.6.0", + "@electron/universal": "^1.2.1", "asar": "^3.1.0", "cross-spawn-windows-exe": "^1.2.0", "debug": "^4.0.1", diff --git a/src/index.d.ts b/src/index.d.ts index 8d2f77f1..316ba01d 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -16,6 +16,9 @@ import { TransporterOptions } from 'electron-notarize/lib/types'; import { SignOptions } from 'electron-osx-sign'; +import type { makeUniversalApp } from '@electron/universal'; + +type MakeUniversalOpts = Parameters[0] type NotarizeLegacyOptions = LegacyNotarizeCredentials & TransporterOptions; @@ -128,6 +131,12 @@ declare namespace electronPackager { | ({ tool?: 'legacy' } & NotarizeLegacyOptions) | ({ tool: 'notarytool' } & NotaryToolCredentials); + /** + * See the documentation for [`@electron/universal`](https://github.com/electron/universal) + * for details. + */ + type OsxUniversalOptions = Omit + /** * Defines URL protocol schemes to be used on macOS. */ @@ -444,6 +453,12 @@ declare namespace electronPackager { * @category macOS */ osxSign?: true | OsxSignOptions; + /** + * Used to provide custom options to the internal call to `@electron/universal` when building a macOS + * app with the target architecture of "universal". Unused otherwise, providing a value does not imply + * a universal app is built. + */ + osxUniversal?: OsxUniversalOptions; /** * The base directory where the finished package(s) are created. * diff --git a/src/index.js b/src/index.js index e1323835..bab5fa90 100644 --- a/src/index.js +++ b/src/index.js @@ -10,6 +10,7 @@ const hooks = require('./hooks') const path = require('path') const targets = require('./targets') const unzip = require('./unzip') +const { packageUniversalMac } = require('./universal') function debugHostInfo () { debug(common.hostInfo()) @@ -73,14 +74,18 @@ class Packager { await hooks.promisifyHooks(this.opts.afterExtract, [buildDir, comboOpts.electronVersion, comboOpts.platform, comboOpts.arch]) } - async createApp (comboOpts, zipPath) { + buildDir (platform, arch) { let buildParentDir if (this.useTempDir) { buildParentDir = this.tempBase } else { buildParentDir = this.opts.out || process.cwd() } - const buildDir = path.resolve(buildParentDir, `${comboOpts.platform}-${comboOpts.arch}-template`) + return path.resolve(buildParentDir, `${platform}-${arch}-template`) + } + + async createApp (comboOpts, zipPath) { + const buildDir = this.buildDir(comboOpts.platform, comboOpts.arch) common.info(`Packaging app for platform ${comboOpts.platform} ${comboOpts.arch} using electron v${comboOpts.electronVersion}`, this.opts.quiet) debug(`Creating ${buildDir}`) @@ -125,15 +130,8 @@ class Packager { } } - async packageForPlatformAndArch (downloadOpts) { + async packageForPlatformAndArchWithOpts (comboOpts, downloadOpts) { const zipPath = await this.getElectronZipPath(downloadOpts) - // Create delegated options object with specific platform and arch, for output directory naming - const comboOpts = { - ...this.opts, - arch: downloadOpts.arch, - platform: downloadOpts.platform, - electronVersion: downloadOpts.version - } if (!this.useTempDir) { return this.createApp(comboOpts, zipPath) @@ -150,6 +148,22 @@ class Packager { return this.checkOverwrite(comboOpts, zipPath) } + + async packageForPlatformAndArch (downloadOpts) { + // Create delegated options object with specific platform and arch, for output directory naming + const comboOpts = { + ...this.opts, + arch: downloadOpts.arch, + platform: downloadOpts.platform, + electronVersion: downloadOpts.version + } + + if (common.isPlatformMac(comboOpts.platform) && comboOpts.arch === 'universal') { + return packageUniversalMac(this.packageForPlatformAndArchWithOpts.bind(this), this.buildDir(comboOpts.platform, comboOpts.arch), comboOpts, downloadOpts, this.tempBase) + } + + return this.packageForPlatformAndArchWithOpts(comboOpts, downloadOpts) + } } async function packageAllSpecifiedCombos (opts, archs, platforms) { diff --git a/src/targets.js b/src/targets.js index 5a5cc3c0..39b50dd7 100644 --- a/src/targets.js +++ b/src/targets.js @@ -4,25 +4,27 @@ const common = require('./common') const { getHostArch } = require('@electron/get') const semver = require('semver') -const officialArchs = ['ia32', 'x64', 'armv7l', 'arm64', 'mips64el'] +const officialArchs = ['ia32', 'x64', 'armv7l', 'arm64', 'mips64el', 'universal'] const officialPlatforms = ['darwin', 'linux', 'mas', 'win32'] const officialPlatformArchCombos = { - darwin: ['x64', 'arm64'], + darwin: ['x64', 'arm64', 'universal'], linux: ['ia32', 'x64', 'armv7l', 'arm64', 'mips64el'], - mas: ['x64', 'arm64'], + mas: ['x64', 'arm64', 'universal'], win32: ['ia32', 'x64', 'arm64'] } const buildVersions = { darwin: { - arm64: '>= 11.0.0-beta.1' + arm64: '>= 11.0.0-beta.1', + universal: '>= 11.0.0-beta.1' }, linux: { arm64: '>= 1.8.0', mips64el: '^1.8.2-beta.5' }, mas: { - arm64: '>= 11.0.0-beta.1' + arm64: '>= 11.0.0-beta.1', + universal: '>= 11.0.0-beta.1' }, win32: { arm64: '>= 6.0.8' diff --git a/src/universal.js b/src/universal.js new file mode 100644 index 00000000..54a2a7c7 --- /dev/null +++ b/src/universal.js @@ -0,0 +1,75 @@ +'use strict' + +const universal = require('@electron/universal') +const common = require('./common') +const fs = require('fs-extra') +const path = require('path') + +async function packageUniversalMac (packageForPlatformAndArchWithOpts, buildDir, comboOpts, downloadOpts, tempBase) { + // In order to generate a universal macOS build we actually need to build the x64 and the arm64 app + // and then glue them together + common.info(`Packaging app for platform ${comboOpts.platform} universal using electron v${comboOpts.electronVersion} - Building x64 and arm64 slices now`, comboOpts.quiet) + await fs.mkdirp(tempBase) + const tempDir = await fs.mkdtemp(path.resolve(tempBase, 'electron-packager-universal-')) + + const { App } = require('./mac') + const app = new App(comboOpts, buildDir) + const universalStagingPath = app.stagingPath + const finalUniversalPath = common.generateFinalPath(app.opts) + + if (await fs.pathExists(finalUniversalPath)) { + if (comboOpts.overwrite) { + await fs.remove(finalUniversalPath) + } else { + common.info(`Skipping ${comboOpts.platform} ${comboOpts.arch} (output dir already exists, use --overwrite to force)`, comboOpts.quiet) + return true + } + } + + const [x64AppPath, arm64AppPath] = await Promise.all(['x64', 'arm64'].map((tempArch) => { + const tempOpts = { + ...comboOpts, + arch: tempArch, + out: tempDir + } + const tempDownloadOpts = { + ...downloadOpts, + arch: tempArch + } + // Do not sign or notarize the individual slices, we sign and notarize the merged app later + delete tempOpts.osxSign + delete tempOpts.osxNotarize + + return packageForPlatformAndArchWithOpts(tempOpts, tempDownloadOpts) + })) + + common.info(`Stitching universal app for platform ${comboOpts.platform}`, comboOpts.quiet) + + const generatedFiles = await fs.readdir(x64AppPath) + const appName = generatedFiles.filter(file => path.extname(file) === '.app')[0] + + await universal.makeUniversalApp({ + ...comboOpts.osxUniversal, + x64AppPath: path.resolve(x64AppPath, appName), + arm64AppPath: path.resolve(arm64AppPath, appName), + outAppPath: path.resolve(universalStagingPath, appName) + }) + + await app.signAppIfSpecified() + await app.notarizeAppIfSpecified() + await app.move() + + for (const generatedFile of generatedFiles) { + if (path.extname(generatedFile) === '.app') continue + + await fs.copy(path.resolve(x64AppPath, generatedFile), path.resolve(finalUniversalPath, generatedFile)) + } + + await fs.remove(tempDir) + + return finalUniversalPath +} + +module.exports = { + packageUniversalMac +} diff --git a/test/_setup.js b/test/_setup.js index ef156aef..a5fc0b69 100644 --- a/test/_setup.js +++ b/test/_setup.js @@ -38,7 +38,7 @@ async function downloadAll (version) { await downloadElectronChecksum(version) return Promise.all( [ - ...combinations.map(combination => downloadElectronZip(version, combination)), + ...combinations.map(combination => combination.arch === 'universal' ? null : downloadElectronZip(version, combination)), downloadElectronZip('6.0.0', { platform: 'darwin' }) diff --git a/test/_util.js b/test/_util.js index 64a82493..66ae45f8 100644 --- a/test/_util.js +++ b/test/_util.js @@ -47,7 +47,7 @@ function packagerTestOptions (t) { } module.exports = { - allPlatformArchCombosCount: 12, + allPlatformArchCombosCount: 14, assertDirectory: async function assertDirectory (t, pathToCheck, message) { const stats = await fs.stat(pathToCheck) t.true(stats.isDirectory(), message) diff --git a/test/basic.js b/test/basic.js index 1b954377..01a45ef6 100644 --- a/test/basic.js +++ b/test/basic.js @@ -152,7 +152,7 @@ test.serial('overwrite', util.testSinglePlatform(async (t, opts) => { })) test.serial('overwrite sans platform/arch set', util.testSinglePlatform(async (t, opts) => { - delete opts.platfrom + delete opts.platform delete opts.arch opts.dir = util.fixtureSubdir('basic') opts.overwrite = true diff --git a/test/targets.js b/test/targets.js index f7507628..b88f21bc 100644 --- a/test/targets.js +++ b/test/targets.js @@ -77,10 +77,10 @@ test('validateListFromOptions works for armv7l host and target arch', t => { }) test('build for all available official targets', - testMultiTarget({ all: true, electronVersion: '1.8.2' }, util.allPlatformArchCombosCount - 3, + testMultiTarget({ all: true, electronVersion: '1.8.2' }, util.allPlatformArchCombosCount - 5, 'Packages should be generated for all possible platforms (except win32/arm64)')) test('build for all available official targets for a version without arm64 or mips64el support', - testMultiTarget({ all: true }, util.allPlatformArchCombosCount - 5, + testMultiTarget({ all: true }, util.allPlatformArchCombosCount - 7, 'Packages should be generated for all possible platforms (except linux/arm64, linux/mips64el, or win32/arm64)')) test('platform=all (one arch)', testMultiTarget({ arch: 'ia32', platform: 'all' }, 2, 'Packages should be generated for both 32-bit platforms')) @@ -119,6 +119,12 @@ test('platform=darwin and arch=arm64 with an unsupported official Electron versi test('platform=mas and arch=arm64 with a supported official Electron version', testMultiTarget({ arch: 'arm64', platform: 'mas', electronVersion: '11.0.0-beta.5' }, 1, 'Package should be generated for mas/arm64')) test('platform=mas and arch=arm64 with an unsupported official Electron version', testMultiTarget({ arch: 'arm64', platform: 'mas' }, 0, 'Package should not be generated for mas/arm64')) +test('platform=darwin and arch=universal with a supported official Electron version', testMultiTarget({ arch: 'universal', platform: 'darwin', electronVersion: '11.0.0-beta.5' }, 1, 'Package should be generated for darwin/universal')) +test('platform=darwin and arch=universal with an unsupported official Electron version', testMultiTarget({ arch: 'universal', platform: 'darwin' }, 0, 'Package should not be generated for darwin/universal')) + +test('platform=mas and arch=universal with a supported official Electron version', testMultiTarget({ arch: 'universal', platform: 'mas', electronVersion: '11.0.0-beta.5' }, 1, 'Package should be generated for mas/universal')) +test('platform=mas and arch=universal with an unsupported official Electron version', testMultiTarget({ arch: 'universal', platform: 'mas' }, 0, 'Package should not be generated for mas/universal')) + test('unofficial arch', testMultiTarget({ arch: 'z80', platform: 'linux', download: { mirrorOptions: { mirror: 'mirror' } } }, 1, 'Package should be generated for non-standard arch from non-official mirror')) test('unofficial platform', testMultiTarget({ arch: 'ia32', platform: 'minix', download: { mirrorOptions: { mirror: 'mirror' } } }, 1, diff --git a/usage.txt b/usage.txt index 54b80f9e..c0ff9459 100644 --- a/usage.txt +++ b/usage.txt @@ -22,8 +22,9 @@ version prints the version of Electron Packager and Node, plus the ta all equivalent to --platform=all --arch=all app-copyright human-readable copyright line for the app app-version release version to set for the app -arch all, or one or more of: ia32, x64, armv7l, arm64, mips64el (comma-delimited if - multiple). Defaults to the host arch +arch all, or one or more of: ia32, x64, armv7l, arm64, mips64el, universal (comma-delimited if + multiple). Defaults to the host arch. + For info on arch/platform support see https://github.com/electron/electron-packager/#supported-platforms asar whether to package the source code within your app into an archive. You can either pass --asar by itself to use the default configuration, OR use dot notation to configure a list of sub-properties, e.g. --asar.unpackDir=sub_dir - do not use