Skip to content

Commit

Permalink
feat(mac): macOS pkg installer
Browse files Browse the repository at this point in the history
  • Loading branch information
develar committed Nov 6, 2016
1 parent adacf1e commit 265ad20
Show file tree
Hide file tree
Showing 17 changed files with 132 additions and 87 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ A complete solution to package and build a ready for distribution Electron app f
* [Build version management](https://github.com/electron-userland/electron-builder/wiki/Options#build-version-management).
* Numerous target formats:
* All platforms: `7z`, `zip`, `tar.xz`, `tar.lz`, `tar.gz`, `tar.bz2`, `dir` (unpacked directory).
* [MacOS](https://github.com/electron-userland/electron-builder/wiki/Options#MacOptions-target): `dmg`, `mas`.
* [MacOS](https://github.com/electron-userland/electron-builder/wiki/Options#MacOptions-target): `dmg`, `pkg`, `mas`.
* [Linux](https://github.com/electron-userland/electron-builder/wiki/Options#LinuxBuildOptions-target): `AppImage`, `deb`, `rpm`, `freebsd`, `pacman`, `p5p`, `apk`.
* [Windows](https://github.com/electron-userland/electron-builder/wiki/Options#WinBuildOptions-target): NSIS, Squirrel.Windows.
* [Publishing artifacts](https://github.com/electron-userland/electron-builder/wiki/Publishing-Artifacts) to GitHub Releases and Bintray.
Expand Down
3 changes: 3 additions & 0 deletions docs/Code Signing.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
macOS and Windows code signing is supported. Windows is dual code-signed (SHA1 & SHA256 hashing algorithms).

On a macOS development machine valid and appropriate identity from your keychain will be automatically used.

| Env Name | Description
| -------------- | -----------
| `CSC_LINK` | The HTTPS link (or base64-encoded data, or `file://` link) to certificate (`*.p12` or `*.pfx` file).
| `CSC_KEY_PASSWORD` | The password to decrypt the certificate given in `CSC_LINK`.
| `CSC_NAME` | *macOS-only* Name of certificate (to retrieve from login.keychain). Useful on a development machine (not on CI) if you have several identities (otherwise don't specify it).
| `CSC_IDENTITY_AUTO_DISCOVERY`| `true` or `false`. Defaults to `true` — on a macOS development machine valid and appropriate identity from your keychain will be automatically used.

If you are building Windows on macOS and need to set a different certificate and password (than the ones set in `CSC_*` env vars) you can use `WIN_CSC_LINK` and `WIN_CSC_KEY_PASSWORD`.

Expand Down Expand Up @@ -34,6 +36,7 @@ Please note — Gatekeeper only recognises [Apple digital certificates](http://s
3. Select all required certificates (hint: use cmd-click to select several):
* `Developer ID Application:` to sign app for macOS.
* `3rd Party Mac Developer Application:` and `3rd Party Mac Developer Installer:` to sign app for MAS (Mac App Store).
* `Developer ID Application:` and `Developer ID Installer` to sign app and installer for distribution outside of the Mac App Store.

Please note – you can select as many certificates, as need. No restrictions on electron-builder side.
All selected certificates will be imported into temporary keychain on CI server.
Expand Down
2 changes: 1 addition & 1 deletion docs/Options.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ MacOS specific build options.
| Name | Description
| --- | ---
| category | <a name="MacOptions-category"></a><p>The application category type, as shown in the Finder via *View -&gt; Arrange by Application Category* when viewing the Applications directory.</p> <p>For example, <code>&quot;category&quot;: &quot;public.app-category.developer-tools&quot;</code> will set the application category to *Developer Tools*.</p> <p>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).</p>
| target | <a name="MacOptions-target"></a>Target package type: list of `default`, `dmg`, `mas`, `7z`, `zip`, `tar.xz`, `tar.lz`, `tar.gz`, `tar.bz2`, `dir`. Defaults to `default` (dmg and zip for Squirrel.Mac).
| target | <a name="MacOptions-target"></a>The target package type: list of `default`, `dmg`, `mas`, `pkg`, `7z`, `zip`, `tar.xz`, `tar.lz`, `tar.gz`, `tar.bz2`, `dir`. Defaults to `default` (dmg and zip for Squirrel.Mac).
| identity | <a name="MacOptions-identity"></a><p>The name of certificate to use when signing. Consider using environment variables [CSC_LINK or CSC_NAME](https://github.com/electron-userland/electron-builder/wiki/Code-Signing). MAS installer identity is specified in the [.build.mas](#MasBuildOptions-identity).</p>
| icon | <a name="MacOptions-icon"></a>The path to application icon. Defaults to `build/icon.icns` (consider using this convention instead of complicating your configuration).
| entitlements | <a name="MacOptions-entitlements"></a><p>The path to entitlements file for signing the app. <code>build/entitlements.mac.plist</code> will be used if exists (it is a recommended way to set). MAS entitlements is specified in the [.build.mas](#MasBuildOptions-entitlements).</p>
Expand Down
4 changes: 2 additions & 2 deletions src/codeSign.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ import BluebirdPromise from "bluebird-lst-c"
import { randomBytes } from "crypto"
import { TmpDir } from "./util/tmp"

const appleCertificatePrefixes = ["Developer ID Application:", "3rd Party Mac Developer Application:", "Developer ID Installer:", "3rd Party Mac Developer Installer:"]
const appleCertificatePrefixes = ["Developer ID Application:", "Developer ID Installer:", "3rd Party Mac Developer Application:", "3rd Party Mac Developer Installer:"]

export type CertType = "Developer ID Application" | "3rd Party Mac Developer Application" | "Developer ID Installer" | "3rd Party Mac Developer Installer" | "Mac Developer"
export type CertType = "Developer ID Application" | "Developer ID Installer" | "3rd Party Mac Developer Application" | "3rd Party Mac Developer Installer" | "Mac Developer"

export interface CodeSigningInfo {
keychainName?: string | null
Expand Down
104 changes: 58 additions & 46 deletions src/macPackager.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,20 @@
import { PlatformPackager, BuildInfo, Target } from "./platformPackager"
import { PlatformPackager, BuildInfo, Target, TargetEx } from "./platformPackager"
import { Platform, Arch } from "./metadata"
import { MasBuildOptions, MacOptions } from "./options/macOptions"
import * as path from "path"
import BluebirdPromise from "bluebird-lst-c"
import { log, warn, task } from "./util/log"
import { createKeychain, CodeSigningInfo, findIdentity } from "./codeSign"
import { deepAssign } from "./util/deepAssign"
import { signAsync, BaseSignOptions, SignOptions } from "electron-osx-sign-tf"
import { signAsync, SignOptions } from "electron-osx-sign-tf"
import { DmgTarget } from "./targets/dmg"
import { createCommonTarget, DEFAULT_TARGET } from "./targets/targetFactory"
import { createCommonTarget, DEFAULT_TARGET, DIR_TARGET } from "./targets/targetFactory"
import { AppInfo } from "./appInfo"
import { flatApplication } from "./targets/pkg"
import { PkgTarget, prepareProductBuildArgs } from "./targets/pkg"
import { exec } from "./util/util"

export default class MacPackager extends PlatformPackager<MacOptions> {
codeSigningInfo: Promise<CodeSigningInfo>
readonly codeSigningInfo: Promise<CodeSigningInfo>

constructor(info: BuildInfo) {
super(info)
Expand Down Expand Up @@ -44,19 +45,26 @@ export default class MacPackager extends PlatformPackager<MacOptions> {

createTargets(targets: Array<string>, mapper: (name: string, factory: () => Target) => void, cleanupTasks: Array<() => Promise<any>>): void {
for (let name of targets) {
if (name === "dir") {
continue
}

if (name === DEFAULT_TARGET) {
mapper("dmg", () => new DmgTarget(this))
mapper("zip", () => new Target("zip"))
}
else if (name === "dmg") {
mapper("dmg", () => new DmgTarget(this))
}
else {
mapper(name, () => name === "mas" ? new Target("mas") : createCommonTarget(name))
switch (name) {
case DIR_TARGET:
break

case DEFAULT_TARGET:
mapper("dmg", () => new DmgTarget(this))
mapper("zip", () => new Target("zip"))
break

case "dmg":
mapper("dmg", () => new DmgTarget(this))
break

case "pkg":
mapper("pkg", () => new PkgTarget(this))
break

default:
mapper(name, () => name === "mas" ? new Target(name) : createCommonTarget(name))
break
}
}
}
Expand All @@ -74,16 +82,12 @@ export default class MacPackager extends PlatformPackager<MacOptions> {
const appOutDir = this.computeAppOutDir(outDir, arch)
nonMasPromise = this.doPack(outDir, appOutDir, this.platform.nodeName, arch, this.platformSpecificBuildOptions)
.then(() => this.sign(appOutDir, null))
.then(() => {
this.packageInDistributableFormat(appOutDir, targets, postAsyncTasks)
})
.then(() => this.packageInDistributableFormat(appOutDir, targets, postAsyncTasks))
}

if (hasMas) {
// osx-sign - disable warning
const appOutDir = path.join(outDir, "mas")
const masBuildOptions = deepAssign({}, this.platformSpecificBuildOptions, (<any>this.devMetadata.build).mas)
//noinspection JSUnusedGlobalSymbols
await this.doPack(outDir, appOutDir, "mas", arch, masBuildOptions)
await this.sign(appOutDir, masBuildOptions)
}
Expand All @@ -95,11 +99,11 @@ export default class MacPackager extends PlatformPackager<MacOptions> {

private async sign(appOutDir: string, masOptions: MasBuildOptions | null): Promise<void> {
if (process.platform !== "darwin") {
warn("macOS application code signing is not supported on this platform, skipping.")
warn("macOS application code signing is supported only on macOS, skipping.")
return
}

let keychainName = (await this.codeSigningInfo).keychainName
const keychainName = (await this.codeSigningInfo).keychainName
const isMas = masOptions != null
const masQualifier = isMas ? (masOptions!!.identity || this.platformSpecificBuildOptions.identity) : null

Expand All @@ -124,24 +128,14 @@ export default class MacPackager extends PlatformPackager<MacOptions> {
}
}

let installerName: string | null = null
if (masOptions != null) {
installerName = await findIdentity("3rd Party Mac Developer Installer", masQualifier, keychainName)
if (installerName == null) {
throw new Error('Cannot find valid "3rd Party Mac Developer Installer" identity to sign MAS installer, see https://github.com/electron-userland/electron-builder/wiki/Code-Signing')
}
}

const baseSignOptions: BaseSignOptions = {
app: path.join(appOutDir, `${this.appInfo.productFilename}.app`),
keychain: keychainName || undefined,
}

const signOptions = Object.assign({
const appPath = path.join(appOutDir, `${this.appInfo.productFilename}.app`)
const signOptions: any = {
identity: name,
platform: isMas ? "mas" : "darwin",
version: this.info.electronVersion,
}, (<any>this.devMetadata.build)["osx-sign"], baseSignOptions)
app: appPath,
keychain: keychainName || undefined,
}

const resourceList = await this.resourceList
if (resourceList.includes(`entitlements.osx.plist`)) {
Expand Down Expand Up @@ -176,29 +170,47 @@ export default class MacPackager extends PlatformPackager<MacOptions> {

if (masOptions != null) {
const pkg = path.join(appOutDir, `${this.appInfo.productFilename}-${this.appInfo.version}.pkg`)
await this.doFlat(baseSignOptions, pkg, installerName!!)
await this.doFlat(appPath, pkg, await this.findInstallerIdentity(true, keychainName), keychainName)
this.dispatchArtifactCreated(pkg, `${this.appInfo.name}-${this.appInfo.version}.pkg`)
}
}

async findInstallerIdentity(isMas: boolean, keychainName: string | n): Promise<string> {
const targetSpecificOptions: MacOptions = (<any>this.devMetadata.build)[isMas ? "mas" : "pkg"] || this.platformSpecificBuildOptions
const name = isMas ? "3rd Party Mac Developer Installer" : "Developer ID Installer"
let installerName = await findIdentity(name, targetSpecificOptions.identity, keychainName)
if (installerName != null) {
return installerName
}

if (isMas) {
throw new Error(`Cannot find valid "${name}" identity to sign MAS installer, see https://github.com/electron-userland/electron-builder/wiki/Code-Signing`)
}
else {
throw new Error(`Cannot find valid "${name}" to sign standalone installer, see https://github.com/electron-userland/electron-builder/wiki/Code-Signing`)
}
}

//noinspection JSMethodCanBeStatic
protected async doSign(opts: SignOptions): Promise<any> {
return signAsync(opts)
}

//noinspection JSMethodCanBeStatic
protected async doFlat(opts: BaseSignOptions, outFile: string, identity: string): Promise<any> {
return flatApplication(opts, outFile, identity)
protected async doFlat(appPath: string, outFile: string, identity: string, keychain: string | n): Promise<any> {
const args = prepareProductBuildArgs(appPath, identity, keychain)
args.push(outFile)
return exec("productbuild", args)
}

protected packageInDistributableFormat(appOutDir: string, targets: Array<Target>, promises: Array<Promise<any>>): void {
for (let t of targets) {
const target = t.name
if (t instanceof DmgTarget) {
promises.push(t.build(appOutDir))
if (t instanceof TargetEx) {
promises.push(t.build(appOutDir, Arch.x64))
}
else if (target !== "mas") {
log(`Creating MacOS ${target}`)
log(`Building macOS ${target}`)
// we use app name here - see https://github.com/electron-userland/electron-builder/pull/204
const outFile = path.join(appOutDir, this.generateName2(target, "mac", false))
promises.push(this.archiveApp(target, appOutDir, outFile)
Expand Down
6 changes: 4 additions & 2 deletions src/options/macOptions.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { PlatformSpecificBuildOptions } from "../metadata"

export type MacOsTargetName = "default" | "dmg" | "mas" | "pkg" | "7z" | "zip" | "tar.xz" | "tar.lz" | "tar.gz" | "tar.bz2" | "dir"

/*
### `.build.mac`
Expand All @@ -16,9 +18,9 @@ export interface MacOptions extends PlatformSpecificBuildOptions {
readonly category?: string | null

/*
Target package type: list of `default`, `dmg`, `mas`, `7z`, `zip`, `tar.xz`, `tar.lz`, `tar.gz`, `tar.bz2`, `dir`. Defaults to `default` (dmg and zip for Squirrel.Mac).
The target package type: list of `default`, `dmg`, `mas`, `pkg`, `7z`, `zip`, `tar.xz`, `tar.lz`, `tar.gz`, `tar.bz2`, `dir`. Defaults to `default` (dmg and zip for Squirrel.Mac).
*/
readonly target?: Array<string> | null
readonly target?: Array<MacOsTargetName> | null

/*
The name of certificate to use when signing. Consider using environment variables [CSC_LINK or CSC_NAME](https://github.com/electron-userland/electron-builder/wiki/Code-Signing).
Expand Down
4 changes: 4 additions & 0 deletions src/packager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,10 @@ export class Packager implements BuildInfo {
throw new Error(util.format(errorMessages.buildIsMissed, devAppPackageFile))
}
else {
if (build["osx-sign"] != null) {
throw new Error("osx-sign is deprecated and not supported — please see https://github.com/electron-userland/electron-builder/wiki/Code-Signing")
}

const author = appMetadata.author
if (author == null) {
throw new Error(`Please specify "author" in the application package.json ('${appPackageFile}') — it is used as company name.`)
Expand Down
7 changes: 2 additions & 5 deletions src/packager/mac.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,11 +84,8 @@ export async function createApp(packager: PlatformPackager<any>, appOutDir: stri
helperNPPlist.CFBundleName = `${appInfo.productName} Helper NP`
helperNPPlist.CFBundleExecutable = `${appFilename} Helper NP`

use(appInfo.version, it => {
appPlist.CFBundleShortVersionString = it
appPlist.CFBundleVersion = it
})
use(appInfo.buildVersion, it => appPlist.CFBundleVersion = it)
appPlist.CFBundleShortVersionString = appInfo.version
appPlist.CFBundleVersion = appInfo.buildVersion

const protocols = asArray(buildMetadata.protocols).concat(asArray(packager.platformSpecificBuildOptions.protocols))
if (protocols.length > 0) {
Expand Down
4 changes: 2 additions & 2 deletions src/platformPackager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ export interface BuildInfo {
}

export class Target {
constructor(public name: string) {
constructor(public readonly name: string) {
}

finishBuild(): Promise<any> {
Expand Down Expand Up @@ -103,7 +103,7 @@ export abstract class PlatformPackager<DC extends PlatformSpecificBuildOptions>

readonly appInfo: AppInfo

constructor(public info: BuildInfo) {
constructor(public readonly info: BuildInfo) {
this.devMetadata = info.devMetadata
this.platformSpecificBuildOptions = this.normalizePlatformSpecificBuildOptions((<any>info.devMetadata.build)[this.platform.buildConfigurationKey])
this.appInfo = this.prepareAppInfo(info.appInfo)
Expand Down
9 changes: 5 additions & 4 deletions src/targets/dmg.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,26 @@
import { deepAssign } from "../util/deepAssign"
import * as path from "path"
import { log, warn } from "../util/log"
import { Target, PlatformPackager } from "../platformPackager"
import { PlatformPackager, TargetEx } from "../platformPackager"
import { MacOptions, DmgOptions, DmgContent } from "../options/macOptions"
import BluebirdPromise from "bluebird-lst-c"
import { debug, use, exec, statOrNull, isEmptyOrSpaces, spawn } from "../util/util"
import { copy, unlink, outputFile, remove } from "fs-extra-p"
import { executeFinally } from "../util/promise"
import sanitizeFileName from "sanitize-filename"
import { Arch } from "../metadata"

export class DmgTarget extends Target {
export class DmgTarget extends TargetEx {
private helperDir = path.join(__dirname, "..", "..", "templates", "dmg")

constructor(private packager: PlatformPackager<MacOptions>) {
super("dmg")
}

async build(appOutDir: string) {
async build(appOutDir: string, arch: Arch) {
const packager = this.packager
const appInfo = packager.appInfo
log("Creating DMG")
log("Building DMG")

const specification = await this.computeDmgOptions()

Expand Down
34 changes: 27 additions & 7 deletions src/targets/pkg.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,34 @@
import { exec } from "../util/util"
import { BaseSignOptions } from "electron-osx-sign-tf"
import { TargetEx } from "../platformPackager"
import { Arch } from "../metadata"
import MacPackager from "../macPackager"
import * as path from "path"

export function flatApplication(opts: BaseSignOptions, outFile: string, identity: string): Promise<any> {
export class PkgTarget extends TargetEx {
constructor(private packager: MacPackager) {
super("pkg")
}

async build(appOutDir: string, arch: Arch): Promise<any> {
const packager = this.packager
const appInfo = packager.appInfo
const outFile = path.join(appOutDir, `${appInfo.productFilename}-${appInfo.version}.pkg`)
const keychainName = (await packager.codeSigningInfo).keychainName
const args = prepareProductBuildArgs(path.join(appOutDir, `${appInfo.productFilename}.app`), await packager.findInstallerIdentity(false, keychainName), keychainName)
args.push("--version", appInfo.buildVersion)
args.push(outFile)
await exec("productbuild", args)
packager.dispatchArtifactCreated(outFile, `${appInfo.name}-${appInfo.version}.pkg`)
}
}

export function prepareProductBuildArgs(appPath: string, identity: string, keychain: string | n) {
const args = [
"--component", opts.app, "/Applications",
"--component", appPath, "/Applications",
"--sign", identity,
]
if (opts.keychain != null) {
args.push("--keychain", opts.keychain)
if (keychain != null) {
args.push("--keychain", keychain)
}
args.push(outFile)
return exec("productbuild", args)
return args
}
9 changes: 4 additions & 5 deletions src/targets/targetFactory.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { PlatformPackager, Target } from "../platformPackager"

export const commonTargets = ["dir", "zip", "7z", "tar.xz", "tar.lz", "tar.gz", "tar.bz2"]
const commonTargets = new Set(["dir", "zip", "7z", "tar.xz", "tar.lz", "tar.gz", "tar.bz2"])
export const DEFAULT_TARGET = "default"
export const DIR_TARGET = "dir"

Expand Down Expand Up @@ -31,9 +31,8 @@ function normalizeTargets(targets: Array<string> | string | null | undefined): A
}

export function createCommonTarget(target: string): Target {
if (!commonTargets.includes(target)) {
throw new Error(`Unknown target: ${target}`)
if (commonTargets.has(target)) {
return new Target(target)
}

return new Target(target)
throw new Error(`Unknown target: ${target}`)
}
Loading

0 comments on commit 265ad20

Please sign in to comment.