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" // 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 { 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) // wix 4.0.0.5512.2 doesn't support the arm64 architecture so default to x64 when building for arm64. // This will result in an x64 MSI installer that installs an arm64 version of the application. This is a // stopgap until the electron-builder-binaries wix version is upgraded to a version that supports arm64: // https://github.com/electron-userland/electron-builder/issues/6077 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) // noinspection SpellCheckingInspection const vendorPath = await getBinFromUrl("wix", "4.0.0.5512.2", "/X5poahdCc3199Vt6AP7gluTlT1nxi9cbbHhZhCMEu+ngyP1LiBMn+oZX7QAZVaKeBMc2SjVp7fJqNLqsUnPNQ==") // noinspection SpellCheckingInspection 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, vm: VmManager, artifactPath: string, appOutDir: string, vendorPath: string, tempDir: string) { // noinspection SpellCheckingInspection const lightArgs = [ "-out", vm.toVmFile(artifactPath), "-v", // https://github.com/wixtoolset/issues/issues/5169 "-spdb", // https://sourceforge.net/p/wix/bugs/2405/ // error LGHT1076 : ICE61: This product should remove only older versions of itself. The Maximum version is not less than the current product. (1.1.0.42 1.1.0.42) "-sw1076", `-dappDir=${vm.toVmFile(appOutDir)}`, // "-dcl:high", ] .concat(this.getCommonWixArgs()) .concat(this.getAdditionalLightArgs()) // http://windows-installer-xml-wix-toolset.687559.n2.nabble.com/Build-3-5-2229-0-give-me-the-following-error-error-LGHT0216-An-unexpected-Win32-exception-with-errorn-td5707443.html if (process.platform !== "win32") { // noinspection SpellCheckingInspection lightArgs.push("-sval") } if (this.options.oneClick === false) { lightArgs.push("-ext", "WixUIExtension") } // objectFiles - only filenames, we set current directory to our temp stage dir lightArgs.push(...objectFiles) await vm.exec(vm.toVmFile(path.join(vendorPath, "light.exe")), lightArgs, { cwd: tempDir, }) } private getAdditionalLightArgs() { const args: Array = [] if (this.options.additionalLightArgs != null) { args.push(...this.options.additionalLightArgs) } return args } private getCommonWixArgs() { const args: Array = ["-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, // https://stackoverflow.com/questions/1929038/compilation-error-ice80-the-64bitcomponent-uses-32bitdirectory programFilesId: wixArch === 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 { 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() const dirs: Array = [] 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 = "" // Wix Directory.FileSource doesn't work - https://stackoverflow.com/questions/21519388/wix-filesource-confusion if (lastSlash > 0) { // This Name attribute may also define multiple directories using the inline directory syntax. // For example, "ProgramFilesFolder:\My Company\My Product\bin" would create a reference to a Directory element with Id="ProgramFilesFolder" then create directories named "My Company" then "My Product" then "bin" nested beneath each other. // This syntax is a shortcut to defining each directory in an individual Directory element. dirName = packagePath.substring(0, lastSlash) // https://github.com/electron-userland/electron-builder/issues/3027 directoryId = "d" + createHash("md5").update(dirName).digest("base64").replace(/\//g, "_").replace(/\+/g, ".").replace(/=+$/, "") if (!dirNames.has(dirName)) { dirNames.add(dirName) dirs.push(``) } } else if (!isRootDirAddedToRemoveTable) { isRootDirAddedToRemoveTable = true } // since RegistryValue can be part of Component, *** *** *** *** *** *** *** *** *** wix cannot auto generate guid // https://stackoverflow.com/questions/1405100/change-my-component-guid-in-wix let result = `` result += `\n${fileSpace} \n` const shortcutName = commonOptions.shortcutName if (isCreateDesktopShortcut) { result += `${fileSpace} \n` } const hasMenuCategory = commonOptions.menuCategory != null const startMenuShortcutDirectoryId = hasMenuCategory ? "AppProgramMenuDir" : "ProgramMenuFolder" if (commonOptions.isCreateStartMenuShortcut) { if (hasMenuCategory) { dirs.push(``) } result += `${fileSpace} \n` result += `${fileSpace} \n` result += `${fileSpace} \n` } result += `${fileSpace}` if (hasMenuCategory) { result += `\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} \n` result += `${fileSpace} \n` result += `${fileSpace} \n` result += `${fileSpace} \n` result += `${fileSpace} \n` } } } return `${result}\n${fileSpace}` }) return { dirs: listToString(dirs, 2), files: listToString(files, 3) } } } function listToString(list: Array, 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, "'") }