diff --git a/docs/Options.md b/docs/Options.md index 5c9e6b2acca..846d18dce98 100644 --- a/docs/Options.md +++ b/docs/Options.md @@ -164,14 +164,15 @@ Linux specific build options. ### `.build.fileAssociations` -NSIS only, [in progress](https://github.com/electron-userland/electron-builder/issues/409). +NSIS and MacOS only. | Name | Description | --- | --- | **ext** | The extension (minus the leading period). e.g. `png`. | **name** | The name. e.g. `PNG`. | description | *windows-only.* The description. -| icon | *windows-only.* The path to icon (`.ico`), relative to `build` (build resources directory). Defaults to `${ext}.ico`. +| icon | The path to icon (`.icns` for MacOS and `.ico` for Windows), relative to `build` (build resources directory). Defaults to `${firstExt}.icns`/`${firstExt}.ico` (if several extensions specified, first is used) or to application icon. +| role | *macOS-only* The app’s role with respect to the type. The value can be `Editor`, `Viewer`, `Shell`, or `None`. Defaults to `Editor`. ### `.build.protocols` diff --git a/package.json b/package.json index 27d4671fc58..a4abed18814 100644 --- a/package.json +++ b/package.json @@ -62,7 +62,7 @@ "7zip-bin": "^1.0.6", "ansi-escapes": "^1.4.0", "archiver": "^1.0.1", - "archiver-utils": "^1.2.0", + "archiver-utils": "^1.3.0", "asar-electron-builder": "^0.13.2", "bluebird": "^3.4.3", "chalk": "^1.1.3", @@ -73,7 +73,7 @@ "electron-download": "^2.1.2", "electron-osx-sign": "^0.4.0-beta4", "extract-zip": "^1.5.0", - "fs-extra-p": "^1.1.7", + "fs-extra-p": "^1.1.8", "hosted-git-info": "^2.1.5", "image-size": "^0.5.0", "isbinaryfile": "^3.0.1", diff --git a/src/metadata.ts b/src/metadata.ts index 2662ee1a1a5..11b521aeb25 100755 --- a/src/metadata.ts +++ b/src/metadata.ts @@ -355,8 +355,6 @@ export interface WinBuildOptions extends PlatformSpecificBuildOptions { */ readonly icon?: string | null - readonly fileAssociations?: Array | FileAssociation - /* The trademarks and registered trademarks. */ @@ -493,13 +491,13 @@ export interface LinuxBuildOptions extends PlatformSpecificBuildOptions { /* ### `.build.fileAssociations` - NSIS only, [in progress](https://github.com/electron-userland/electron-builder/issues/409). + NSIS and MacOS only. */ export interface FileAssociation { /* The extension (minus the leading period). e.g. `png`. */ - readonly ext: string + readonly ext: string | Array /* The name. e.g. `PNG`. @@ -512,9 +510,14 @@ export interface FileAssociation { readonly description?: string /* - *windows-only.* The path to icon (`.ico`), relative to `build` (build resources directory). Defaults to `${ext}.ico`. + The path to icon (`.icns` for MacOS and `.ico` for Windows), relative to `build` (build resources directory). Defaults to `${firstExt}.icns`/`${firstExt}.ico` (if several extensions specified, first is used) or to application icon. */ readonly icon?: string + + /* + *macOS-only* The app’s role with respect to the type. The value can be `Editor`, `Viewer`, `Shell`, or `None`. Defaults to `Editor`. + */ + readonly role?: string } /* @@ -564,6 +567,8 @@ export interface PlatformSpecificBuildOptions { readonly target?: Array | null readonly icon?: string | null + + readonly fileAssociations?: Array | FileAssociation } export class Platform { diff --git a/src/packager/mac.ts b/src/packager/mac.ts index 816f37350ad..de7ca61c319 100644 --- a/src/packager/mac.ts +++ b/src/packager/mac.ts @@ -4,6 +4,8 @@ import * as path from "path" import { parse as parsePlist, build as buildPlist } from "plist" import { Promise as BluebirdPromise } from "bluebird" import { use, asArray } from "../util/util" +import { normalizeExt } from "../platformPackager" +import { FileAssociation } from "../metadata" //noinspection JSUnusedLocalSymbols const __awaiter = require("../util/awaiter") @@ -79,7 +81,8 @@ export async function createApp(opts: ElectronPackagerOptions, appOutDir: string }) use(appInfo.buildVersion, it => appPlist.CFBundleVersion = it) - const protocols = asArray(opts.platformPackager.devMetadata.build.protocols).concat(asArray(opts.platformPackager.platformSpecificBuildOptions.protocols)) + const packager = opts.platformPackager + const protocols = asArray(packager.devMetadata.build.protocols).concat(asArray(packager.platformSpecificBuildOptions.protocols)) if (protocols.length > 0) { appPlist.CFBundleURLTypes = protocols.map(protocol => { return { @@ -89,6 +92,21 @@ export async function createApp(opts: ElectronPackagerOptions, appOutDir: string }) } + const fileAssociations = packager.getFileAssociations() + if (fileAssociations.length > 0) { + appPlist.CFBundleDocumentTypes = await BluebirdPromise.map(fileAssociations, async fileAssociation => { + const extensions = asArray(fileAssociation.ext).map(normalizeExt) + const customIcon = await packager.getResource(fileAssociation.icon, `${extensions[0]}.icns`) + // todo rename electron.icns + return { + CFBundleTypeExtensions: extensions, + CFBundleTypeName: fileAssociation.name, + CFBundleTypeRole: fileAssociation.role || "Editor", + CFBundleTypeIconFile: customIcon || "electron.icns" + } + }) + } + use(appInfo.category, it => appPlist.LSApplicationCategoryType = it) use(appInfo.copyright, it => appPlist.NSHumanReadableCopyright = it) @@ -100,7 +118,7 @@ export async function createApp(opts: ElectronPackagerOptions, appOutDir: string doRename(path.join(contentsPath, "MacOS"), "Electron", appPlist.CFBundleExecutable) ] - const icon = await opts.platformPackager.getIconPath() + const icon = await packager.getIconPath() if (icon != null) { promises.push(copy(icon, path.join(contentsPath, "Resources", appPlist.CFBundleIconFile))) } diff --git a/src/platformPackager.ts b/src/platformPackager.ts index 1aa85ebd32e..42e39d361b3 100644 --- a/src/platformPackager.ts +++ b/src/platformPackager.ts @@ -1,9 +1,9 @@ -import { AppMetadata, DevMetadata, Platform, PlatformSpecificBuildOptions, Arch } from "./metadata" +import { AppMetadata, DevMetadata, Platform, PlatformSpecificBuildOptions, Arch, FileAssociation } from "./metadata" import EventEmitter = NodeJS.EventEmitter import { Promise as BluebirdPromise } from "bluebird" import * as path from "path" import { readdir, remove } from "fs-extra-p" -import { statOrNull, use, unlinkIfExists, isEmptyOrSpaces } from "./util/util" +import { statOrNull, use, unlinkIfExists, isEmptyOrSpaces, asArray } from "./util/util" import { Packager } from "./packager" import { AsarOptions } from "asar-electron-builder" import { archiveApp } from "./targets/archive" @@ -432,6 +432,25 @@ export abstract class PlatformPackager getTempFile(suffix: string): Promise { return this.info.tempDirManager.getTempFile(suffix) } + + getFileAssociations(): Array { + return asArray(this.devMetadata.build.fileAssociations).concat(asArray(this.platformSpecificBuildOptions.fileAssociations)) + } + + async getResource(custom: string | n, name: string): Promise { + let result = custom + if (result === undefined) { + const resourceList = await this.resourceList + if (resourceList.includes(name)) { + return path.join(this.buildResourcesDir, name) + } + } + else { + return path.resolve(this.projectDir, result) + } + + return null + } } export function getArchSuffix(arch: Arch): string { @@ -457,4 +476,9 @@ export function smarten(s: string): string { // closing doubles s = s.replace(/"/g, "\u201d") return s +} + +// remove leading dot +export function normalizeExt(ext: string) { + return ext.startsWith(".") ? ext.substring(1) : ext } \ No newline at end of file diff --git a/src/targets/nsis.ts b/src/targets/nsis.ts index 0fedf9da37a..51fa80964d0 100644 --- a/src/targets/nsis.ts +++ b/src/targets/nsis.ts @@ -1,11 +1,11 @@ import { WinPackager } from "../winPackager" -import { Arch, NsisOptions, FileAssociation } from "../metadata" +import { Arch, NsisOptions } from "../metadata" import { exec, debug, doSpawn, handleProcess, use, asArray } from "../util/util" import * as path from "path" import { Promise as BluebirdPromise } from "bluebird" import { getBinFromBintray } from "../util/binDownload" import { v5 as uuid5 } from "uuid-1345" -import { Target } from "../platformPackager" +import { Target, normalizeExt } from "../platformPackager" import { archiveApp } from "./archive" import { subTask, task, log } from "../util/log" import { unlink, readFile } from "fs-extra-p" @@ -30,8 +30,6 @@ export default class NsisTarget extends Target { private readonly nsisTemplatesDir = path.join(__dirname, "..", "..", "templates", "nsis") - private readonly fileAssociations: Array - constructor(private packager: WinPackager, private outDir: string) { super("nsis") @@ -40,7 +38,6 @@ export default class NsisTarget extends Target { // CFBundleTypeName // https://developer.apple.com/library/ios/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html#//apple_ref/doc/uid/20001431-101685 // CFBundleTypeExtensions - this.fileAssociations = asArray(packager.devMetadata.build.fileAssociations).concat(asArray(packager.platformSpecificBuildOptions.fileAssociations)) } async build(arch: Arch, appOutDir: string) { @@ -87,14 +84,14 @@ export default class NsisTarget extends Target { const oneClick = this.options.oneClick !== false - const installerHeader = oneClick ? null : await this.getResource(this.options.installerHeader, "installerHeader.bmp") + const installerHeader = oneClick ? null : await this.packager.getResource(this.options.installerHeader, "installerHeader.bmp") if (installerHeader != null) { defines.MUI_HEADERIMAGE = null defines.MUI_HEADERIMAGE_RIGHT = null defines.MUI_HEADERIMAGE_BITMAP = installerHeader } - const installerHeaderIcon = oneClick ? await this.getResource(this.options.installerHeaderIcon, "installerHeaderIcon.ico") : null + const installerHeaderIcon = oneClick ? await this.packager.getResource(this.options.installerHeaderIcon, "installerHeaderIcon.ico") : null if (installerHeaderIcon != null) { defines.HEADER_ICO = installerHeaderIcon } @@ -159,7 +156,7 @@ export default class NsisTarget extends Target { return } - const customScriptPath = await this.getResource(this.options.script, "installer.nsi") + const customScriptPath = await this.packager.getResource(this.options.script, "installer.nsi") const script = await readFile(customScriptPath || path.join(this.nsisTemplatesDir, "installer.nsi"), "utf8") if (customScriptPath == null) { @@ -185,21 +182,6 @@ export default class NsisTarget extends Target { this.packager.dispatchArtifactCreated(installerPath, `${appInfo.name}-Setup-${version}.exe`) } - protected async getResource(custom: string | n, name: string): Promise { - let result = custom - if (result === undefined) { - const resourceList = await this.packager.resourceList - if (resourceList.includes(name)) { - return path.join(this.packager.buildResourcesDir, name) - } - } - else { - return path.resolve(this.packager.projectDir, result) - } - - return null - } - private async executeMakensis(defines: any, commands: any, isInstaller: boolean, originalScript: string) { const args: Array = ["-WX"] for (let name of Object.keys(defines)) { @@ -230,35 +212,42 @@ export default class NsisTarget extends Target { const nsisPath = await nsisPathPromise let script = originalScript - const customInclude = await this.getResource(this.options.include, "installer.nsh") + const packager = this.packager + const customInclude = await packager.getResource(this.options.include, "installer.nsh") if (customInclude != null) { - script = `!include "${customInclude}"\n!addincludedir "${this.packager.buildResourcesDir}"\n${script}` + script = `!include "${customInclude}"\n!addincludedir "${packager.buildResourcesDir}"\n${script}` } - if (this.fileAssociations.length !== 0) { + const fileAssociations = packager.getFileAssociations() + if (fileAssociations.length !== 0) { script = "!include FileAssociation.nsh\n" + script if (isInstaller) { let registerFileAssociationsScript = "" - for (let item of this.fileAssociations) { - const customIcon = await this.getResource(item.icon, `${normalizeExt(item.ext)}.ico`) - let installedIconPath = "${APP_EXECUTABLE_FILENAME},0" - if (customIcon != null) { - installedIconPath = `resources\\${path.basename(customIcon)}` - //noinspection SpellCheckingInspection - registerFileAssociationsScript += ` File "/oname=${installedIconPath}" "${customIcon}"\n` + for (let item of fileAssociations) { + const extensions = asArray(item.ext).map(normalizeExt) + for (let ext of extensions) { + const customIcon = await packager.getResource(item.icon, `${extensions[0]}.ico`) + let installedIconPath = "${APP_EXECUTABLE_FILENAME},0" + if (customIcon != null) { + installedIconPath = `resources\\${path.basename(customIcon)}` + //noinspection SpellCheckingInspection + registerFileAssociationsScript += ` File "/oname=${installedIconPath}" "${customIcon}"\n` + } + + const icon = `"$INSTDIR\\${installedIconPath}"` + const commandText = `"Open with ${packager.appInfo.productName}"` + const command = '"$INSTDIR\\${APP_EXECUTABLE_FILENAME} $\\"%1$\\""' + registerFileAssociationsScript += ` !insertmacro APP_ASSOCIATE "${ext}" "${item.name}" "${item.description || ""}" ${icon} ${commandText} ${command}\n` } - - const icon = `"$INSTDIR\\${installedIconPath}"` - const commandText = `"Open with ${this.packager.appInfo.productName}"` - const command = '"$INSTDIR\\${APP_EXECUTABLE_FILENAME} $\\"%1$\\""' - registerFileAssociationsScript += ` !insertmacro APP_ASSOCIATE "${normalizeExt(item.ext)}" "${item.name}" "${item.description || ""}" ${icon} ${commandText} ${command}\n` } script = `!macro registerFileAssociations\n${registerFileAssociationsScript}!macroend\n${script}` } else { let unregisterFileAssociationsScript = "" - for (let item of this.fileAssociations) { - unregisterFileAssociationsScript += ` !insertmacro APP_UNASSOCIATE "${normalizeExt(item.ext)}" "${item.name}"\n` + for (let item of fileAssociations) { + for (let ext of asArray(item.ext)) { + unregisterFileAssociationsScript += ` !insertmacro APP_UNASSOCIATE "${normalizeExt(ext)}" "${item.name}"\n` + } } script = `!macro unregisterFileAssociations\n${unregisterFileAssociationsScript}!macroend\n${script}` } @@ -280,9 +269,4 @@ export default class NsisTarget extends Target { childProcess.stdin.end(script) }) } -} - -// remove leading dot -function normalizeExt(ext: string) { - return ext.startsWith(".") ? ext.substring(1) : ext } \ No newline at end of file