Skip to content

Commit

Permalink
feat: Allow for NSIS windows installer to be wrapped in an MSI (#7407)
Browse files Browse the repository at this point in the history
  • Loading branch information
ghost1face committed Feb 14, 2023
1 parent ece7f88 commit a338730
Show file tree
Hide file tree
Showing 20 changed files with 758 additions and 112 deletions.
5 changes: 5 additions & 0 deletions .changeset/plenty-bees-give.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"app-builder-lib": patch
---

feat: Allow for NSIS windows installer to be wrapped in an MSI
171 changes: 171 additions & 0 deletions packages/app-builder-lib/scheme.json
Original file line number Diff line number Diff line change
Expand Up @@ -3699,6 +3699,167 @@
},
"type": "object"
},
"MsiWrappedOptions": {
"additionalProperties": false,
"properties": {
"additionalWixArgs": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "null"
}
],
"description": "Any additional arguments to be passed to the WiX installer compiler, such as `[\"-ext\", \"WixUtilExtension\"]`"
},
"artifactName": {
"description": "The [artifact file name template](/configuration/configuration#artifact-file-name-template).",
"type": [
"null",
"string"
]
},
"createDesktopShortcut": {
"default": true,
"description": "Whether to create desktop shortcut. Set to `always` if to recreate also on reinstall (even if removed by user).",
"enum": [
"always",
false,
true
]
},
"createStartMenuShortcut": {
"default": true,
"description": "Whether to create start menu shortcut.",
"type": "boolean"
},
"impersonate": {
"default": false,
"description": "Determines if the wrapped installer should be executed with impersonation",
"type": "boolean"
},
"menuCategory": {
"default": false,
"description": "Whether to create submenu for start menu shortcut and program files directory. If `true`, company name will be used. Or string value.",
"type": [
"string",
"boolean"
]
},
"oneClick": {
"type": "boolean"
},
"perMachine": {
"default": false,
"description": "Whether to install per all users (per-machine).",
"type": "boolean"
},
"publish": {
"anyOf": [
{
"$ref": "#/definitions/GithubOptions"
},
{
"$ref": "#/definitions/S3Options"
},
{
"$ref": "#/definitions/SpacesOptions"
},
{
"$ref": "#/definitions/GenericServerOptions"
},
{
"$ref": "#/definitions/CustomPublishOptions"
},
{
"$ref": "#/definitions/KeygenOptions"
},
{
"$ref": "#/definitions/SnapStoreOptions"
},
{
"$ref": "#/definitions/BitbucketOptions"
},
{
"items": {
"anyOf": [
{
"$ref": "#/definitions/GithubOptions"
},
{
"$ref": "#/definitions/S3Options"
},
{
"$ref": "#/definitions/SpacesOptions"
},
{
"$ref": "#/definitions/GenericServerOptions"
},
{
"$ref": "#/definitions/CustomPublishOptions"
},
{
"$ref": "#/definitions/KeygenOptions"
},
{
"$ref": "#/definitions/SnapStoreOptions"
},
{
"$ref": "#/definitions/BitbucketOptions"
},
{
"type": "string"
}
]
},
"type": "array"
},
{
"type": [
"null",
"string"
]
}
]
},
"runAfterFinish": {
"default": true,
"description": "Whether to run the installed application after finish. For assisted installer corresponding checkbox will be removed.",
"type": "boolean"
},
"shortcutName": {
"description": "The name that will be used for all shortcuts. Defaults to the application name.",
"type": [
"null",
"string"
]
},
"upgradeCode": {
"description": "The [upgrade code](https://msdn.microsoft.com/en-us/library/windows/desktop/aa372375(v=vs.85).aspx). Optional, by default generated using app id.",
"type": [
"null",
"string"
]
},
"warningsAsErrors": {
"default": true,
"description": "If `warningsAsErrors` is `true` (default): treat warnings as errors. If `warningsAsErrors` is `false`: allow warnings.",
"type": "boolean"
},
"wrappedInstallerArgs": {
"description": "Extra arguments to provide to the wrapped installer (ie: /S for silent install)",
"type": [
"null",
"string"
]
}
},
"type": "object"
},
"NotarizeOptions": {
"additionalProperties": false,
"properties": {
Expand Down Expand Up @@ -6791,6 +6952,16 @@
],
"description": "MSI project created on disk - not packed into .msi package yet."
},
"msiWrapped": {
"anyOf": [
{
"$ref": "#/definitions/MsiWrappedOptions"
},
{
"type": "null"
}
]
},
"nodeGypRebuild": {
"default": false,
"description": "Whether to execute `node-gyp rebuild` before starting to package the app.\n\nDon't [use](https://github.com/electron-userland/electron-builder/issues/683#issuecomment-241214075) [npm](http://electron.atom.io/docs/tutorial/using-native-node-modules/#using-npm) (neither `.npmrc`) for configuring electron headers. Use `electron-builder node-gyp-rebuild` instead.",
Expand Down
3 changes: 3 additions & 0 deletions packages/app-builder-lib/src/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { AppXOptions } from "./options/AppXOptions"
import { AppImageOptions, DebOptions, FlatpakOptions, LinuxConfiguration, LinuxTargetSpecificOptions } from "./options/linuxOptions"
import { DmgOptions, MacConfiguration, MasConfiguration } from "./options/macOptions"
import { MsiOptions } from "./options/MsiOptions"
import { MsiWrappedOptions } from "./options/MsiWrappedOptions"
import { PkgOptions } from "./options/pkgOptions"
import { PlatformSpecificBuildOptions } from "./options/PlatformSpecificBuildOptions"
import { SnapOptions } from "./options/SnapOptions"
Expand Down Expand Up @@ -73,6 +74,8 @@ export interface Configuration extends PlatformSpecificBuildOptions {
readonly appx?: AppXOptions | null
/** @private */
readonly msi?: MsiOptions | null
/** @private */
readonly msiWrapped?: MsiWrappedOptions | null
readonly squirrelWindows?: SquirrelWindowsOptions | null

/**
Expand Down
1 change: 1 addition & 0 deletions packages/app-builder-lib/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export { PkgOptions, PkgBackgroundOptions, BackgroundAlignment, BackgroundScalin
export { WindowsConfiguration } from "./options/winOptions"
export { AppXOptions } from "./options/AppXOptions"
export { MsiOptions } from "./options/MsiOptions"
export { MsiWrappedOptions } from "./options/MsiWrappedOptions"
export { CommonWindowsInstallerConfiguration } from "./options/CommonWindowsInstallerConfiguration"
export { NsisOptions, NsisWebOptions, PortableOptions, CommonNsisOptions } from "./targets/nsis/nsisOptions"
export { LinuxConfiguration, DebOptions, CommonLinuxOptions, LinuxTargetSpecificOptions, AppImageOptions, FlatpakOptions } from "./options/linuxOptions"
Expand Down
31 changes: 31 additions & 0 deletions packages/app-builder-lib/src/options/MsiWrappedOptions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { TargetSpecificOptions } from "../core"
import { CommonWindowsInstallerConfiguration } from "./CommonWindowsInstallerConfiguration"

export interface MsiWrappedOptions extends CommonWindowsInstallerConfiguration, TargetSpecificOptions {
/**
* Extra arguments to provide to the wrapped installer (ie: /S for silent install)
*/
readonly wrappedInstallerArgs?: string | null

/**
* Determines if the wrapped installer should be executed with impersonation
* @default false
*/
readonly impersonate?: boolean

/**
* The [upgrade code](https://msdn.microsoft.com/en-us/library/windows/desktop/aa372375(v=vs.85).aspx). Optional, by default generated using app id.
*/
readonly upgradeCode?: string | null

/**
* If `warningsAsErrors` is `true` (default): treat warnings as errors. If `warningsAsErrors` is `false`: allow warnings.
* @default true
*/
readonly warningsAsErrors?: boolean

/**
* Any additional arguments to be passed to the WiX installer compiler, such as `["-ext", "WixUtilExtension"]`
*/
readonly additionalWixArgs?: Array<string> | null
}
11 changes: 10 additions & 1 deletion packages/app-builder-lib/src/packager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,7 @@ export class Packager {

private async doBuild(): Promise<Map<Platform, Map<string, Target>>> {
const taskManager = new AsyncTaskManager(this.cancellationToken)
const syncTargetsIfAny = [] as Target[]

const platformToTarget = new Map<Platform, Map<string, Target>>()
const createdOutDirs = new Set<string>()
Expand Down Expand Up @@ -446,11 +447,19 @@ export class Packager {
}

for (const target of nameToTarget.values()) {
taskManager.addTask(target.finishBuild())
if (target.isAsyncSupported) {
taskManager.addTask(target.finishBuild())
} else {
syncTargetsIfAny.push(target)
}
}
}

await taskManager.awaitTasks()

for (const target of syncTargetsIfAny) {
await target.finishBuild()
}
return platformToTarget
}

Expand Down
62 changes: 35 additions & 27 deletions packages/app-builder-lib/src/targets/MsiTarget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,24 +21,24 @@ import { createStageDir, getWindowsInstallationDirName } from "./targetUtil"
const ELECTRON_BUILDER_UPGRADE_CODE_NS_UUID = UUID.parse("d752fe43-5d44-44d5-9fc9-6dd1bf19d5cc")
const ROOT_DIR_ID = "APPLICATIONFOLDER"

const projectTemplate = new Lazy<(data: any) => string>(async () => {
const template = (await readFile(path.join(getTemplatePath("msi"), "template.xml"), "utf8"))
.replace(/{{/g, "<%")
.replace(/}}/g, "%>")
.replace(/\${([^}]+)}/g, "<%=$1%>")
return ejs.compile(template)
})

// WiX doesn't support Mono, so, dontnet462 is required to be installed for wine (preinstalled in our bundled wine)
export default class MsiTarget extends Target {
private readonly vm = process.platform === "win32" ? new VmManager() : new WineVmManager()
protected readonly vm = process.platform === "win32" ? new VmManager() : new WineVmManager()

readonly options: MsiOptions = deepAssign(this.packager.platformSpecificBuildOptions, this.packager.config.msi)

constructor(private readonly packager: WinPackager, readonly outDir: string) {
super("msi")
constructor(protected readonly packager: WinPackager, readonly outDir: string, name = "msi", isAsyncSupported = true) {
super(name, isAsyncSupported)
}

protected projectTemplate = new Lazy<(data: any) => string>(async () => {
const template = (await readFile(path.join(getTemplatePath(this.name), "template.xml"), "utf8"))
.replace(/{{/g, "<%")
.replace(/}}/g, "%>")
.replace(/\${([^}]+)}/g, "<%=$1%>")
return ejs.compile(template)
})

/**
* A product-specific string that can be used in an [MSI Identifier](https://docs.microsoft.com/en-us/windows/win32/msi/identifier).
*/
Expand All @@ -47,11 +47,11 @@ export default class MsiTarget extends Target {
return sanitizedId.length > 0 ? sanitizedId : "App" + this.upgradeCode.replace(/-/g, "")
}

private get iconId() {
protected get iconId() {
return `${this.productMsiIdPrefix}Icon.exe`
}

private get upgradeCode(): string {
protected get upgradeCode(): string {
return (this.options.upgradeCode || UUID.v5(this.packager.appInfo.id, ELECTRON_BUILDER_UPGRADE_CODE_NS_UUID)).toUpperCase()
}

Expand Down Expand Up @@ -145,22 +145,36 @@ export default class MsiTarget extends Target {
return args
}

private async writeManifest(appOutDir: string, arch: Arch, commonOptions: FinalCommonWindowsInstallerOptions) {
protected async writeManifest(appOutDir: string, arch: Arch, commonOptions: FinalCommonWindowsInstallerOptions) {
const appInfo = this.packager.appInfo
const { files, dirs } = await this.computeFileDeclaration(appOutDir)
const options = this.options

return (await this.projectTemplate.value)({
...(await this.getBaseOptions(commonOptions)),
isCreateDesktopShortcut: commonOptions.isCreateDesktopShortcut !== DesktopShortcutCreationPolicy.NEVER,
isRunAfterFinish: options.runAfterFinish !== false,
// https://stackoverflow.com/questions/1929038/compilation-error-ice80-the-64bitcomponent-uses-32bitdirectory
programFilesId: arch === Arch.x64 ? "ProgramFiles64Folder" : "ProgramFilesFolder",
// wix in the name because special wix format can be used in the name
installationDirectoryWixName: getWindowsInstallationDirName(appInfo, commonOptions.isAssisted || commonOptions.isPerMachine === true),
dirs,
files,
})
}

protected async getBaseOptions(commonOptions: FinalCommonWindowsInstallerOptions): Promise<any> {
const appInfo = this.packager.appInfo
const iconPath = await this.packager.getIconPath()
const compression = this.packager.compression

const companyName = appInfo.companyName
if (!companyName) {
log.warn(`Manufacturer is not set for MSI — please set "author" in the package.json`)
}

const compression = this.packager.compression
const options = this.options
const iconPath = await this.packager.getIconPath()
return (await projectTemplate.value)({
return {
...commonOptions,
isCreateDesktopShortcut: commonOptions.isCreateDesktopShortcut !== DesktopShortcutCreationPolicy.NEVER,
isRunAfterFinish: options.runAfterFinish !== false,
iconPath: iconPath == null ? null : this.vm.toVmFile(iconPath),
iconId: this.iconId,
compressionLevel: compression === "store" ? "none" : "high",
Expand All @@ -169,13 +183,7 @@ export default class MsiTarget extends Target {
upgradeCode: this.upgradeCode,
manufacturer: companyName || appInfo.productName,
appDescription: appInfo.description,
// https://stackoverflow.com/questions/1929038/compilation-error-ice80-the-64bitcomponent-uses-32bitdirectory
programFilesId: arch === Arch.x64 ? "ProgramFiles64Folder" : "ProgramFilesFolder",
// wix in the name because special wix format can be used in the name
installationDirectoryWixName: getWindowsInstallationDirName(appInfo, commonOptions.isAssisted || commonOptions.isPerMachine === true),
dirs,
files,
})
}
}

private async computeFileDeclaration(appOutDir: string) {
Expand Down

0 comments on commit a338730

Please sign in to comment.