import { Arch, asArray, deepAssign, log, walk } from "builder-util"
import { UUID } from "builder-util-runtime"
import { createHash } from "crypto"
import * as ejs from "ejs"
import { readFile, writeFile } from "fs/promises"
import { Lazy } from "lazy-val"
import * as path from "path"
import { MsiOptions } from "../"
import { getBinFromUrl } from "../binDownload"
import { Target } from "../core"
import { DesktopShortcutCreationPolicy, FinalCommonWindowsInstallerOptions, getEffectiveOptions } from "../options/CommonWindowsInstallerConfiguration"
import { normalizeExt } from "../platformPackager"
import { getTemplatePath } from "../util/pathManager"
import { VmManager } from "../vm/vm"
import { WineVmManager } from "../vm/WineVm"
import { WinPackager } from "../winPackager"
import { createStageDir, getWindowsInstallationDirName } from "./targetUtil"
const ELECTRON_BUILDER_UPGRADE_CODE_NS_UUID = UUID.parse("d752fe43-5d44-44d5-9fc9-6dd1bf19d5cc")
const ROOT_DIR_ID = "APPLICATIONFOLDER"
export default class MsiTarget extends Target {
protected readonly vm = process.platform === "win32" ? new VmManager() : new WineVmManager()
readonly options: MsiOptions = deepAssign(this.packager.platformSpecificBuildOptions, this.packager.config.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).
*/
private get productMsiIdPrefix() {
const sanitizedId = this.packager.appInfo.productFilename.replace(/[^\w.]/g, "").replace(/^[^A-Za-z_]+/, "")
return sanitizedId.length > 0 ? sanitizedId : "App" + this.upgradeCode.replace(/-/g, "")
}
protected get iconId() {
return `${this.productMsiIdPrefix}Icon.exe`
}
protected get upgradeCode(): string {
return (this.options.upgradeCode || UUID.v5(this.packager.appInfo.id, ELECTRON_BUILDER_UPGRADE_CODE_NS_UUID)).toUpperCase()
}
async build(appOutDir: string, arch: Arch) {
const packager = this.packager
const artifactName = packager.expandArtifactBeautyNamePattern(this.options, "msi", arch)
const artifactPath = path.join(this.outDir, artifactName)
await packager.info.emitArtifactBuildStarted({
targetPresentableName: "MSI",
file: artifactPath,
arch,
})
const stageDir = await createStageDir(this, packager, arch)
const vm = this.vm
const commonOptions = getEffectiveOptions(this.options, this.packager)
const wixArch = arch == Arch.arm64 ? Arch.x64 : arch
const projectFile = stageDir.getTempFile("project.wxs")
const objectFiles = ["project.wixobj"]
await writeFile(projectFile, await this.writeManifest(appOutDir, wixArch, commonOptions))
await packager.info.emitMsiProjectCreated(projectFile)
const vendorPath = await getBinFromUrl("wix", "4.0.0.5512.2", "/X5poahdCc3199Vt6AP7gluTlT1nxi9cbbHhZhCMEu+ngyP1LiBMn+oZX7QAZVaKeBMc2SjVp7fJqNLqsUnPNQ==")
const candleArgs = ["-arch", wixArch === Arch.ia32 ? "x86" : "x64", `-dappDir=${vm.toVmFile(appOutDir)}`].concat(this.getCommonWixArgs())
candleArgs.push("project.wxs")
await vm.exec(vm.toVmFile(path.join(vendorPath, "candle.exe")), candleArgs, {
cwd: stageDir.dir,
})
await this.light(objectFiles, vm, artifactPath, appOutDir, vendorPath, stageDir.dir)
await stageDir.cleanup()
await packager.sign(artifactPath)
await packager.info.emitArtifactBuildCompleted({
file: artifactPath,
packager,
arch,
safeArtifactName: packager.computeSafeArtifactName(artifactName, "msi"),
target: this,
isWriteUpdateInfo: false,
})
}
private async light(objectFiles: Array<string>, vm: VmManager, artifactPath: string, appOutDir: string, vendorPath: string, tempDir: string) {
const lightArgs = [
"-out",
vm.toVmFile(artifactPath),
"-v",
"-spdb",
"-sw1076",
`-dappDir=${vm.toVmFile(appOutDir)}`,
]
.concat(this.getCommonWixArgs())
.concat(this.getAdditionalLightArgs())
if (process.platform !== "win32") {
lightArgs.push("-sval")
}
if (this.options.oneClick === false) {
lightArgs.push("-ext", "WixUIExtension")
}
lightArgs.push(...objectFiles)
await vm.exec(vm.toVmFile(path.join(vendorPath, "light.exe")), lightArgs, {
cwd: tempDir,
})
}
private getAdditionalLightArgs() {
const args: Array<string> = []
if (this.options.additionalLightArgs != null) {
args.push(...this.options.additionalLightArgs)
}
return args
}
private getCommonWixArgs() {
const args: Array<string> = ["-pedantic"]
if (this.options.warningsAsErrors !== false) {
args.push("-wx")
}
if (this.options.additionalWixArgs != null) {
args.push(...this.options.additionalWixArgs)
}
return args
}
protected async writeManifest(appOutDir: string, wixArch: 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,
programFilesId: wixArch === Arch.x64 ? "ProgramFiles64Folder" : "ProgramFilesFolder",
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`)
}
return {
...commonOptions,
iconPath: iconPath == null ? null : this.vm.toVmFile(iconPath),
iconId: this.iconId,
compressionLevel: compression === "store" ? "none" : "high",
version: appInfo.getVersionInWeirdWindowsForm(),
productName: appInfo.productName,
upgradeCode: this.upgradeCode,
manufacturer: companyName || appInfo.productName,
appDescription: appInfo.description,
}
}
private async computeFileDeclaration(appOutDir: string) {
const appInfo = this.packager.appInfo
let isRootDirAddedToRemoveTable = false
const dirNames = new Set<string>()
const dirs: Array<string> = []
const fileSpace = " ".repeat(6)
const commonOptions = getEffectiveOptions(this.options, this.packager)
const files = (await walk(appOutDir)).map(file => {
const packagePath = file.substring(appOutDir.length + 1)
const lastSlash = packagePath.lastIndexOf(path.sep)
const fileName = lastSlash > 0 ? packagePath.substring(lastSlash + 1) : packagePath
let directoryId: string | null = null
let dirName = ""
if (lastSlash > 0) {
dirName = packagePath.substring(0, lastSlash)
directoryId = "d" + createHash("md5").update(dirName).digest("base64").replace(/\//g, "_").replace(/\+/g, ".").replace(/=+$/, "")
if (!dirNames.has(dirName)) {
dirNames.add(dirName)
dirs.push(`<Directory Id="${directoryId}" Name="${ROOT_DIR_ID}:\\${dirName.replace(/\//g, "\\")}\\"/>`)
}
} else if (!isRootDirAddedToRemoveTable) {
isRootDirAddedToRemoveTable = true
}
let result = `<Component${directoryId === null ? "" : ` Directory="${directoryId}"`}>`
result += `\n${fileSpace} <File Name="${xmlAttr(fileName)}" Source="$(var.appDir)${path.sep}${xmlAttr(packagePath)}" ReadOnly="yes" KeyPath="yes"`
const isMainExecutable = packagePath === `${appInfo.productFilename}.exe`
if (isMainExecutable) {
result += ' Id="mainExecutable"'
} else if (directoryId === null) {
result += ` Id="${path.basename(packagePath)}_f"`
}
const isCreateDesktopShortcut = commonOptions.isCreateDesktopShortcut !== DesktopShortcutCreationPolicy.NEVER
if (isMainExecutable && (isCreateDesktopShortcut || commonOptions.isCreateStartMenuShortcut)) {
result += `>\n`
const shortcutName = commonOptions.shortcutName
if (isCreateDesktopShortcut) {
result += `${fileSpace} <Shortcut Id="desktopShortcut" Directory="DesktopFolder" Name="${xmlAttr(
shortcutName
)}" WorkingDirectory="APPLICATIONFOLDER" Advertise="yes" Icon="${this.iconId}"/>\n`
}
const hasMenuCategory = commonOptions.menuCategory != null
const startMenuShortcutDirectoryId = hasMenuCategory ? "AppProgramMenuDir" : "ProgramMenuFolder"
if (commonOptions.isCreateStartMenuShortcut) {
if (hasMenuCategory) {
dirs.push(`<Directory Id="${startMenuShortcutDirectoryId}" Name="ProgramMenuFolder:\\${commonOptions.menuCategory}\\"/>`)
}
result += `${fileSpace} <Shortcut Id="startMenuShortcut" Directory="${startMenuShortcutDirectoryId}" Name="${xmlAttr(
shortcutName
)}" WorkingDirectory="APPLICATIONFOLDER" Advertise="yes" Icon="${this.iconId}">\n`
result += `${fileSpace} <ShortcutProperty Key="System.AppUserModel.ID" Value="${xmlAttr(this.packager.appInfo.id)}"/>\n`
result += `${fileSpace} </Shortcut>\n`
}
result += `${fileSpace}</File>`
if (hasMenuCategory) {
result += `<RemoveFolder Id="${startMenuShortcutDirectoryId}" Directory="${startMenuShortcutDirectoryId}" On="uninstall"/>\n`
}
} else {
result += `/>`
}
const fileAssociations = this.packager.fileAssociations
if (isMainExecutable && fileAssociations.length !== 0) {
for (const item of fileAssociations) {
const extensions = asArray(item.ext).map(normalizeExt)
for (const ext of extensions) {
result += `${fileSpace} <ProgId Id="${this.productMsiIdPrefix}.${ext}" Advertise="yes" Icon="${this.iconId}" ${
item.description ? `Description="${item.description}"` : ""
}>\n`
result += `${fileSpace} <Extension Id="${ext}" Advertise="yes">\n`
result += `${fileSpace} <Verb Id="open" Command="Open with ${xmlAttr(this.packager.appInfo.productName)}" Argument=""%1""/>\n`
result += `${fileSpace} </Extension>\n`
result += `${fileSpace} </ProgId>\n`
}
}
}
return `${result}\n${fileSpace}</Component>`
})
return { dirs: listToString(dirs, 2), files: listToString(files, 3) }
}
}
function listToString(list: Array<string>, indentLevel: number) {
const space = " ".repeat(indentLevel * 2)
return list.join(`\n${space}`)
}
function xmlAttr(str: string) {
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'")
}