Skip to content

Commit

Permalink
feat: A crossplatform way to create file associations
Browse files Browse the repository at this point in the history
Closes #409
  • Loading branch information
develar committed Aug 29, 2016
1 parent 0b84868 commit f8840e1
Show file tree
Hide file tree
Showing 6 changed files with 90 additions and 58 deletions.
5 changes: 3 additions & 2 deletions docs/Options.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,14 +164,15 @@ Linux specific build options.
<a name="FileAssociation"></a>
### `.build.fileAssociations`

NSIS only, [in progress](https://github.com/electron-userland/electron-builder/issues/409).
NSIS and MacOS only.

| Name | Description
| --- | ---
| **ext** | <a name="FileAssociation-ext"></a>The extension (minus the leading period). e.g. `png`.
| **name** | <a name="FileAssociation-name"></a>The name. e.g. `PNG`.
| description | <a name="FileAssociation-description"></a>*windows-only.* The description.
| icon | <a name="FileAssociation-icon"></a>*windows-only.* The path to icon (`.ico`), relative to `build` (build resources directory). Defaults to `${ext}.ico`.
| icon | <a name="FileAssociation-icon"></a>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 | <a name="FileAssociation-role"></a>*macOS-only* The app’s role with respect to the type. The value can be `Editor`, `Viewer`, `Shell`, or `None`. Defaults to `Editor`.

<a name="Protocol"></a>
### `.build.protocols`
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
15 changes: 10 additions & 5 deletions src/metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -355,8 +355,6 @@ export interface WinBuildOptions extends PlatformSpecificBuildOptions {
*/
readonly icon?: string | null

readonly fileAssociations?: Array<FileAssociation> | FileAssociation

/*
The trademarks and registered trademarks.
*/
Expand Down Expand Up @@ -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<string>

/*
The name. e.g. `PNG`.
Expand All @@ -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
}

/*
Expand Down Expand Up @@ -564,6 +567,8 @@ export interface PlatformSpecificBuildOptions {
readonly target?: Array<string> | null

readonly icon?: string | null

readonly fileAssociations?: Array<FileAssociation> | FileAssociation
}

export class Platform {
Expand Down
22 changes: 20 additions & 2 deletions src/packager/mac.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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 {
Expand All @@ -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<FileAssociation>(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 <any>{
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)

Expand All @@ -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)))
}
Expand Down
28 changes: 26 additions & 2 deletions src/platformPackager.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -432,6 +432,25 @@ export abstract class PlatformPackager<DC extends PlatformSpecificBuildOptions>
getTempFile(suffix: string): Promise<string> {
return this.info.tempDirManager.getTempFile(suffix)
}

getFileAssociations(): Array<FileAssociation> {
return asArray(this.devMetadata.build.fileAssociations).concat(asArray(this.platformSpecificBuildOptions.fileAssociations))
}

async getResource(custom: string | n, name: string): Promise<string | null> {
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 {
Expand All @@ -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
}
74 changes: 29 additions & 45 deletions src/targets/nsis.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -30,8 +30,6 @@ export default class NsisTarget extends Target {

private readonly nsisTemplatesDir = path.join(__dirname, "..", "..", "templates", "nsis")

private readonly fileAssociations: Array<FileAssociation>

constructor(private packager: WinPackager, private outDir: string) {
super("nsis")

Expand All @@ -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) {
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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) {
Expand All @@ -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<string | null> {
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<string> = ["-WX"]
for (let name of Object.keys(defines)) {
Expand Down Expand Up @@ -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}`
}
Expand All @@ -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
}

0 comments on commit f8840e1

Please sign in to comment.