import { InvalidConfigurationError, log, isEmptyOrSpaces } from "builder-util"
import { sanitizeFileName } from "builder-util/out/filename"
import { Arch, getArchSuffix, SquirrelWindowsOptions, Target, WinPackager } from "app-builder-lib"
import * as path from "path"
import * as fs from "fs"
import * as os from "os"
import { Options as SquirrelOptions, createWindowsInstaller, convertVersion } from "electron-winstaller"
export default class SquirrelWindowsTarget extends Target {
readonly options: SquirrelWindowsOptions = { ...this.packager.platformSpecificBuildOptions, ...this.packager.config.squirrelWindows } as SquirrelWindowsOptions
isAsyncSupported = false
constructor(
private readonly packager: WinPackager,
readonly outDir: string
) {
super("squirrel")
}
private async prepareSignedVendorDirectory(): Promise<string> {
let vendorDirectory = this.options.customSquirrelVendorDir || path.join(require.resolve("electron-winstaller/package.json"), "..", "vendor")
if (isEmptyOrSpaces(vendorDirectory) || !fs.existsSync(vendorDirectory)) {
log.warn({ vendorDirectory }, "unable to access Squirrel.Windows vendor directory, falling back to default electron-winstaller")
vendorDirectory = path.join(require.resolve("electron-winstaller/package.json"), "..", "vendor")
}
const tmpVendorDirectory = await this.packager.info.tempDirManager.createTempDir({ prefix: "squirrel-windows-vendor" })
await fs.promises.cp(vendorDirectory, tmpVendorDirectory, { recursive: true })
log.debug({ from: vendorDirectory, to: tmpVendorDirectory }, "copied vendor directory")
const files = await fs.promises.readdir(tmpVendorDirectory)
for (const file of files) {
if (["Squirrel.exe", "StubExecutable.exe"].includes(file)) {
const filePath = path.join(tmpVendorDirectory, file)
log.debug({ file: filePath }, "signing vendor executable")
await this.packager.sign(filePath)
}
}
return tmpVendorDirectory
}
async build(appOutDir: string, arch: Arch) {
const packager = this.packager
const version = packager.appInfo.version
const sanitizedName = sanitizeFileName(this.appName)
const setupFile = packager.expandArtifactNamePattern(this.options, "exe", arch, "${productName} Setup ${version}.${ext}")
const installerOutDir = path.join(this.outDir, `squirrel-windows${getArchSuffix(arch)}`)
const artifactPath = path.join(installerOutDir, setupFile)
const msiArtifactPath = path.join(installerOutDir, packager.expandArtifactNamePattern(this.options, "msi", arch, "${productName} Setup ${version}.${ext}"))
this.buildQueueManager.add(async () => {
await packager.info.emitArtifactBuildStarted({
targetPresentableName: "Squirrel.Windows",
file: artifactPath,
arch,
})
const distOptions = await this.computeEffectiveDistOptions(appOutDir, installerOutDir, setupFile)
await createWindowsInstaller(distOptions)
await packager.signAndEditResources(artifactPath, arch, installerOutDir)
if (this.options.msi) {
await packager.sign(msiArtifactPath)
}
const safeArtifactName = (ext: string) => `${sanitizedName}-Setup-${version}${getArchSuffix(arch)}.${ext}`
await packager.info.emitArtifactBuildCompleted({
file: artifactPath,
target: this,
arch,
safeArtifactName: safeArtifactName("exe"),
packager: this.packager,
})
if (this.options.msi) {
await packager.info.emitArtifactCreated({
file: msiArtifactPath,
target: this,
arch,
safeArtifactName: safeArtifactName("msi"),
packager: this.packager,
})
}
const packagePrefix = `${this.appName}-${convertVersion(version)}-`
await packager.info.emitArtifactCreated({
file: path.join(installerOutDir, `${packagePrefix}full.nupkg`),
target: this,
arch,
packager,
})
if (distOptions.remoteReleases != null) {
await packager.info.emitArtifactCreated({
file: path.join(installerOutDir, `${packagePrefix}delta.nupkg`),
target: this,
arch,
packager,
})
}
await packager.info.emitArtifactCreated({
file: path.join(installerOutDir, "RELEASES"),
target: this,
arch,
packager,
})
})
return Promise.resolve()
}
private get appName() {
return this.options.name || this.packager.appInfo.name
}
private select7zipArch(vendorDirectory: string) {
const resolvedArch = os.arch
fs.copyFileSync(path.join(vendorDirectory, `7z-${resolvedArch}.exe`), path.join(vendorDirectory, "7z.exe"))
fs.copyFileSync(path.join(vendorDirectory, `7z-${resolvedArch}.dll`), path.join(vendorDirectory, "7z.dll"))
}
private async createNuspecTemplateWithProjectUrl() {
const templatePath = path.resolve(__dirname, "..", "template.nuspectemplate")
const projectUrl = await this.packager.appInfo.computePackageUrl()
if (projectUrl != null) {
const nuspecTemplate = await this.packager.info.tempDirManager.getTempFile({ prefix: "template", suffix: ".nuspectemplate" })
let templateContent = await fs.promises.readFile(templatePath, "utf8")
const searchString = "<copyright><%- copyright %></copyright>"
templateContent = templateContent.replace(searchString, `${searchString}\n <projectUrl>${projectUrl}</projectUrl>`)
await fs.promises.writeFile(nuspecTemplate, templateContent)
return nuspecTemplate
}
return templatePath
}
async computeEffectiveDistOptions(appDirectory: string, outputDirectory: string, setupFile: string): Promise<SquirrelOptions> {
const packager = this.packager
let iconUrl = this.options.iconUrl
if (iconUrl == null) {
const info = await packager.info.repositoryInfo
if (info != null) {
iconUrl = `https://github.com/${info.user}/${info.project}/blob/master/${packager.info.relativeBuildResourcesDirname}/icon.ico?raw=true`
}
if (iconUrl == null) {
throw new InvalidConfigurationError("squirrelWindows.iconUrl is not specified, please see https://www.electron.build/squirrel-windows#SquirrelWindowsOptions-iconUrl")
}
}
checkConflictingOptions(this.options)
const appInfo = packager.appInfo
const options: SquirrelOptions = {
appDirectory: appDirectory,
outputDirectory: outputDirectory,
name: this.options.useAppIdAsId ? appInfo.id : this.appName,
title: appInfo.productName || appInfo.name,
version: appInfo.version,
description: appInfo.description,
exe: `${appInfo.productFilename || this.options.name || appInfo.productName}.exe`,
authors: appInfo.companyName || "",
nuspecTemplate: await this.createNuspecTemplateWithProjectUrl(),
iconUrl,
copyright: appInfo.copyright,
noMsi: !this.options.msi,
usePackageJson: false,
}
options.vendorDirectory = await this.prepareSignedVendorDirectory()
this.select7zipArch(options.vendorDirectory)
options.fixUpPaths = true
options.setupExe = setupFile
if (this.options.msi) {
options.setupMsi = setupFile.replace(".exe", ".msi")
}
if (isEmptyOrSpaces(options.description)) {
options.description = this.options.name || appInfo.productName
}
if (options.remoteToken == null) {
options.remoteToken = process.env.GH_TOKEN || process.env.GITHUB_TOKEN
}
if (this.options.remoteReleases === true) {
const info = await packager.info.repositoryInfo
if (info == null) {
log.warn("remoteReleases set to true, but cannot get repository info")
} else {
options.remoteReleases = `https://github.com/${info.user}/${info.project}`
log.info({ remoteReleases: options.remoteReleases }, `remoteReleases is set`)
}
} else if (typeof this.options.remoteReleases === "string" && !isEmptyOrSpaces(this.options.remoteReleases)) {
options.remoteReleases = this.options.remoteReleases
}
if (!("loadingGif" in options)) {
const resourceList = await packager.resourceList
if (resourceList.includes("install-spinner.gif")) {
options.loadingGif = path.join(packager.buildResourcesDir, "install-spinner.gif")
}
}
return options
}
}
function checkConflictingOptions(options: any) {
for (const name of ["outputDirectory", "appDirectory", "exe", "fixUpPaths", "usePackageJson", "extraFileSpecs", "extraMetadataSpecs", "skipUpdateIcon", "setupExe"]) {
if (name in options) {
throw new InvalidConfigurationError(`Option ${name} is ignored, do not specify it.`)
}
}
if ("noMsi" in options) {
log.warn(`noMsi is deprecated, please specify as "msi": true if you want to create an MSI installer`)
options.msi = !options.noMsi
}
const msi = options.msi
if (msi != null && typeof msi !== "boolean") {
throw new InvalidConfigurationError(`msi expected to be boolean value, but string '"${msi}"' was specified`)
}
}