diff --git a/.changeset/remove-electron-winstaller-dep.md b/.changeset/remove-electron-winstaller-dep.md new file mode 100644 index 00000000000..ff85ed28562 --- /dev/null +++ b/.changeset/remove-electron-winstaller-dep.md @@ -0,0 +1,7 @@ +--- +"electron-builder-squirrel-windows": major +--- + +feat(squirrel-windows)!: inline installer logic; remove electron-winstaller dependency and its vendored binaries + +BREAKING: the `squirrelWindows.customSquirrelVendorDir` option has been removed. Supply a custom Squirrel vendor bundle with the `toolsets.squirrel` config (a `ToolsetCustom` object) instead — it goes through the same nuget/rcedit provisioning as the default bundle. diff --git a/packages/app-builder-lib/scheme.json b/packages/app-builder-lib/scheme.json index a44267e0bc4..03c3ad3e48a 100644 --- a/packages/app-builder-lib/scheme.json +++ b/packages/app-builder-lib/scheme.json @@ -7866,10 +7866,6 @@ "string" ] }, - "customSquirrelVendorDir": { - "description": "The custom squirrel vendor dir. If not specified will use the Squirrel.Windows that is shipped with electron-installer(https://github.com/electron/windows-installer/tree/main/vendor).\nAfter https://github.com/electron-userland/electron-builder-binaries/pull/56 merged, will add `electron-builder-binaries` to get the latest version of squirrel.", - "type": "string" - }, "iconUrl": { "description": "A URL to an ICO file to use as the application icon (displayed in Control Panel > Programs and Features). Defaults to the Electron icon.\n\nPlease note — [local icon file url is not accepted](https://github.com/atom/grunt-electron-installer/issues/73), must be https/http.\n\nIf you don't plan to build windows installer, you can omit it.\nIf your project repository is public on GitHub, it will be `https://github.com/${u}/${p}/blob/master/build/icon.ico?raw=true` by default.", "type": [ @@ -8094,6 +8090,22 @@ "default": "latest", "description": "Version of the 7-Zip binary bundle used internally to extract `.7z` and `.tar.xz` archives.\n\nSet to a {@link ToolsetCustom} object to supply your own 7za binary.\nThe `url` must point to a directory (or a `.tar.gz`/`.zip` archive of one) that contains\n`bin/7za` (macOS/Linux) or `bin/7za.exe` (Windows).\n\n**Bootstrap constraint:** the custom bundle itself must be a `.tar.gz` or `.zip` archive\n(or a bare `file://` directory). `.7z` and `.tar.xz` archives cannot be used here because\nextracting them requires 7za — a circular dependency." }, + "squirrel": { + "anyOf": [ + { + "$ref": "#/definitions/ToolsetCustom" + }, + { + "enum": [ + "1.1.0", + "latest" + ], + "type": "string" + } + ], + "default": "latest", + "description": "Version of the `squirrel.windows` bundle used to build Squirrel.Windows installers.\n\nThe bundle ships the Squirrel vendor toolset under `electron-winstaller/vendor/`:\n- **`Squirrel.exe`** / **`Squirrel-Mono.exe`** — releasify the app into `Setup.exe` (and an optional MSI).\n- **`SyncReleases.exe`** — downloads prior releases to produce delta packages.\n- **`nuget.exe`**, **`7z`** — pack the app into a `.nupkg` and compress release assets.\n\n`rcedit.exe` is provisioned from the {@link winCodeSign} toolset at runtime, and a usable\n`nuget.exe` is ensured at runtime (the published bundle ships a shim that cannot be relocated).\n\nAvailable versions:\n| Version | Notes |\n|---------|-------|\n| `\"1.1.0\"` | Squirrel.Windows 2.0.1 (patched) |\n\nSet to a {@link ToolsetCustom} object to supply your own bundle — it must contain the\n`electron-winstaller/vendor/` subtree. Only used when building the `squirrelWindows` target.\n\nReleases: https://github.com/electron-userland/electron-builder-binaries/blob/master/packages/squirrel.windows/CHANGELOG.md" + }, "winCodeSign": { "anyOf": [ { diff --git a/packages/app-builder-lib/src/configuration.ts b/packages/app-builder-lib/src/configuration.ts index 7e81c655272..375331c9d82 100644 --- a/packages/app-builder-lib/src/configuration.ts +++ b/packages/app-builder-lib/src/configuration.ts @@ -697,6 +697,31 @@ export interface ToolsetConfig { * @default "latest" */ readonly icons?: "1.2.1" | ToolsetCustom | "latest" + + /** + * Version of the `squirrel.windows` bundle used to build Squirrel.Windows installers. + * + * The bundle ships the Squirrel vendor toolset under `electron-winstaller/vendor/`: + * - **`Squirrel.exe`** / **`Squirrel-Mono.exe`** — releasify the app into `Setup.exe` (and an optional MSI). + * - **`SyncReleases.exe`** — downloads prior releases to produce delta packages. + * - **`nuget.exe`**, **`7z`** — pack the app into a `.nupkg` and compress release assets. + * + * `rcedit.exe` is provisioned from the {@link winCodeSign} toolset at runtime, and a usable + * `nuget.exe` is ensured at runtime (the published bundle ships a shim that cannot be relocated). + * + * Available versions: + * | Version | Notes | + * |---------|-------| + * | `"1.1.0"` | Squirrel.Windows 2.0.1 (patched) | + * + * Set to a {@link ToolsetCustom} object to supply your own bundle — it must contain the + * `electron-winstaller/vendor/` subtree. Only used when building the `squirrelWindows` target. + * + * Releases: https://github.com/electron-userland/electron-builder-binaries/blob/master/packages/squirrel.windows/CHANGELOG.md + * + * @default "latest" + */ + readonly squirrel?: "1.1.0" | ToolsetCustom | "latest" } /** diff --git a/packages/app-builder-lib/src/indexInternal.ts b/packages/app-builder-lib/src/indexInternal.ts index 22a7fdab277..ff99a657b25 100644 --- a/packages/app-builder-lib/src/indexInternal.ts +++ b/packages/app-builder-lib/src/indexInternal.ts @@ -45,7 +45,9 @@ export { nsisEscapeString, NsisScriptGenerator } from "./targets/win/nsis/nsisSc export { ProgIdMaker } from "./targets/win/nsis/progId.js" export { checkMakensisOutput, verifyInstallerSize } from "./targets/win/nsis/nsisValidation.js" export { getLinuxToolsMacToolset, getLinuxToolsPath } from "./toolsets/linuxToolsMac.js" -export { getWindowsKitsBundle } from "./toolsets/winCodeSign.js" +export { getCustomToolsetPath } from "./toolsets/custom.js" +export { resolveToolsetVersion } from "./toolsets/version.js" +export { getRceditBundle, getWindowsKitsBundle } from "./toolsets/winCodeSign.js" export { CacheState } from "./util/cacheState.js" export { computeDefaultAppDirectory, createProjectMetadataLazy, doMergeConfigs, getConfig, validateConfiguration } from "./util/config/config.js" export { loadEnv, orNullIfFileNotExist } from "./util/config/load.js" diff --git a/packages/app-builder-lib/src/options/SquirrelWindowsOptions.ts b/packages/app-builder-lib/src/options/SquirrelWindowsOptions.ts index c9176c70e7f..64a90a46c67 100644 --- a/packages/app-builder-lib/src/options/SquirrelWindowsOptions.ts +++ b/packages/app-builder-lib/src/options/SquirrelWindowsOptions.ts @@ -40,12 +40,6 @@ export interface SquirrelWindowsOptions extends TargetSpecificOptions { */ readonly useAppIdAsId?: boolean - /** - * The custom squirrel vendor dir. If not specified will use the Squirrel.Windows that is shipped with electron-installer(https://github.com/electron/windows-installer/tree/main/vendor). - * After https://github.com/electron-userland/electron-builder-binaries/pull/56 merged, will add `electron-builder-binaries` to get the latest version of squirrel. - */ - readonly customSquirrelVendorDir?: string - /** * https://github.com/electron-userland/electron-builder/issues/1743 * @private diff --git a/packages/electron-builder-squirrel-windows/install-spinner.gif b/packages/electron-builder-squirrel-windows/install-spinner.gif new file mode 100644 index 00000000000..9be998f3d33 Binary files /dev/null and b/packages/electron-builder-squirrel-windows/install-spinner.gif differ diff --git a/packages/electron-builder-squirrel-windows/package.json b/packages/electron-builder-squirrel-windows/package.json index 3f87a86f083..c01c60a75ee 100644 --- a/packages/electron-builder-squirrel-windows/package.json +++ b/packages/electron-builder-squirrel-windows/package.json @@ -26,7 +26,9 @@ "homepage": "https://github.com/electron-userland/electron-builder", "files": [ "dist", - "template.nuspectemplate" + "template.nuspectemplate", + "template.wxs", + "install-spinner.gif" ], "engines": { "node": ">=22.12.0" @@ -37,12 +39,7 @@ }, "dependencies": { "app-builder-lib": "workspace:*", - "builder-util": "workspace:*", - "electron-winstaller": "5.4.0" - }, - "devDependencies": { - "@types/archiver": "5.3.1", - "@types/fs-extra": "9.0.13" + "builder-util": "workspace:*" }, "types": "./dist/SquirrelWindowsTarget.d.ts" } diff --git a/packages/electron-builder-squirrel-windows/src/SquirrelWindowsTarget.ts b/packages/electron-builder-squirrel-windows/src/SquirrelWindowsTarget.ts index 29c4b720051..7265183ff53 100644 --- a/packages/electron-builder-squirrel-windows/src/SquirrelWindowsTarget.ts +++ b/packages/electron-builder-squirrel-windows/src/SquirrelWindowsTarget.ts @@ -1,15 +1,12 @@ -import { createRequire } from "node:module" -import { InvalidConfigurationError, log, isEmptyOrSpaces, exists } from "builder-util" - -const _requireResolve = createRequire(import.meta.url).resolve -import { downloadBuilderToolset, withToolsetLock } from "app-builder-lib/internal" -import { sanitizeFileName } from "builder-util/internal" import { Arch, getArchSuffix, SquirrelWindowsOptions, Target, WinPackager } from "app-builder-lib" -import * as path from "path" +import { getRceditBundle, withToolsetLock, WineVmManager } from "app-builder-lib/internal" +import { InvalidConfigurationError, isEmptyOrSpaces, log } from "builder-util" +import { sanitizeFileName } from "builder-util/internal" import * as fs from "fs" import * as os from "os" -import { Options as SquirrelOptions, createWindowsInstaller, convertVersion } from "electron-winstaller" -import { WineVmManager } from "app-builder-lib/internal" +import * as path from "path" +import { getSquirrelToolsetPath, getWixToolsetPath, prepareNugetExe } from "./toolset.js" +import { InstallerOptions, convertVersion, createWindowsInstaller } from "./windowsInstaller.js" export default class SquirrelWindowsTarget extends Target { readonly options: SquirrelWindowsOptions @@ -25,42 +22,61 @@ export default class SquirrelWindowsTarget extends Target { } private async prepareSignedVendorDirectory(): Promise { - const customSquirrelVendorDirectory = this.options.customSquirrelVendorDir const tmpVendorDirectory = await this.packager.tempDirManager.createTempDir({ prefix: "squirrel-windows-vendor" }) - if (customSquirrelVendorDirectory && (await exists(customSquirrelVendorDirectory))) { - await fs.promises.cp(customSquirrelVendorDirectory, tmpVendorDirectory, { recursive: true }) - } else { - if (!isEmptyOrSpaces(customSquirrelVendorDirectory)) { - log.warn({ customSquirrelVendorDirectory }, "unable to access custom Squirrel.Windows vendor directory, falling back to default vendor") - } - - const windowInstallerPackage = _requireResolve("electron-winstaller/package.json") - const [squirrelBin] = await Promise.all([ - downloadBuilderToolset({ - releaseName: "squirrel.windows@1.0.0", - filenameWithExt: "squirrel.windows-2.0.1-patched.7z", - checksums: { "squirrel.windows-2.0.1-patched.7z": "76851f0c192eaf9bc6f8f3eecdfe325857ebe70d7833ec62ed846a1acd50c846" }, - }), - fs.promises.cp(path.join(path.dirname(windowInstallerPackage), "vendor"), tmpVendorDirectory, { recursive: true }), - ]) - await fs.promises.cp(path.join(squirrelBin, "electron-winstaller", "vendor"), tmpVendorDirectory, { recursive: true }) + // The vendor toolset comes from the maintained squirrel.windows bundle. Pin a version or supply a + // custom/local bundle (e.g. for air-gapped builds) via `toolsets.squirrel` (a ToolsetCustom object). + const squirrelToolset = await getSquirrelToolsetPath(this.packager.config.toolsets?.squirrel, this.packager.buildResourcesDir) + await fs.promises.cp(path.join(squirrelToolset, "electron-winstaller", "vendor"), tmpVendorDirectory, { recursive: true }) + + // TEMPORARY: the published squirrel.windows@1.1.0 bundle ships the Chocolatey shim for nuget.exe, + // which resolves the real binary relative to its own install path and fails once relocated to a + // temp vendor directory. Replace it with a standalone portable nuget.exe (no-op when the bundle + // already ships a real one). Remove once squirrel.windows bundles it (electron-builder-binaries#203). + await prepareNugetExe(tmpVendorDirectory) + + // Both Squirrel.exe and Squirrel-Mono.exe shell out to rcedit.exe (via setPEVersionInfoAndIcon) + // during --releasify to stamp version info and the app icon into Setup.exe, so rcedit must live in + // the vendor dir on every host — on non-Windows it runs under wine alongside the other Win32 vendor + // exes (Setup.exe, StubExecutable.exe, …). The bundle omits rcedit, so resolve it from the + // win-codesign toolset, which already versions and caches it across platforms. + // + // getRceditBundle honors the user's `toolsets.winCodeSign` selection, so users retain a bypass if a + // newer toolset regresses: `"0.0.0"` falls back to the legacy winCodeSign-2.6.0 rcedit, a custom + // toolset object uses their own rcedit, and the default (unset/"latest") pulls rcedit-windows-2_0_0. + const rcedit = await getRceditBundle(this.packager.config.toolsets?.winCodeSign, this.packager.buildResourcesDir) + // Pick rcedit by host arch (x64 for x64/arm64, x86 only for ia32). On non-Windows it runs under wine: + // the 32-bit build fails on arm64 macOS even under Rosetta (which translates x64, not x86), so the x64 + // build is required there. + const rceditExe = os.arch() === "ia32" ? rcedit.x86 : rcedit.x64 + await fs.promises.copyFile(rceditExe, path.join(tmpVendorDirectory, "rcedit.exe")) + + // When building an MSI, Squirrel's createMsiPackage runs candle.exe/light.exe from the vendor dir and + // reads template.wxs there. The bundle ships neither, so merge the shared WiX toolset (the same + // candle/light electron-builder's MSI target uses) into the vendor dir, plus the Squirrel MSI template + // (authored against the v4 namespace this transitional WiX 4 requires). + if (this.options.msi) { + const wixToolset = await getWixToolsetPath() + await fs.promises.cp(wixToolset, tmpVendorDirectory, { recursive: true }) + await fs.promises.copyFile(path.resolve(import.meta.dirname, "..", "template.wxs"), path.join(tmpVendorDirectory, "template.wxs")) } + // Squirrel.exe is the core updater binary the bundle always ships; a missing one means a broken + // vendor toolset. Fail loudly rather than skipping signing — skipping would produce a broken + // installer and bypass the forceCodeSigning enforcement inside signIf (its awaited promise rejects + // when the file can't be signed and forceCodeSigning is set). const files = await fs.promises.readdir(tmpVendorDirectory) - const squirrelExe = files.find(f => f === "Squirrel.exe") - if (squirrelExe) { - const filePath = path.join(tmpVendorDirectory, squirrelExe) - log.debug({ file: filePath }, "signing vendor executable") - await this.packager.signIf(filePath) - } else { - log.warn("Squirrel.exe not found in vendor directory, skipping signing") + if (!files.includes("Squirrel.exe")) { + throw new Error(`Squirrel.exe not found in vendor directory: ${tmpVendorDirectory}`) } + const squirrelExePath = path.join(tmpVendorDirectory, "Squirrel.exe") + log.debug({ file: squirrelExePath }, "signing vendor executable") + await this.packager.signIf(squirrelExePath) return tmpVendorDirectory } private assertShellSafePath(filePath: string, description: string): void { - if (/[\r\n`$;&|<>]/.test(filePath)) { + if (/[\r\n\0`$;&|<>]/.test(filePath)) { throw new InvalidConfigurationError(`${description} contains unsafe shell characters: ${filePath}`) } } @@ -95,7 +111,12 @@ export default class SquirrelWindowsTarget extends Target { throw new InvalidConfigurationError(`${description} contains invalid path segments`) } canonicalTargetPath = path.resolve(canonicalTargetParent, relativeFromResolvedParent) - } catch { + } catch (e: unknown) { + // Only fall back for filesystem errors (target parent not found, permission, etc.). + // Validation errors must propagate so callers get an actionable message. + if (e instanceof InvalidConfigurationError) { + throw e + } canonicalTargetPath = resolvedTargetPath } } @@ -149,7 +170,7 @@ export default class SquirrelWindowsTarget extends Target { arch, }) const distOptions = await this.computeEffectiveDistOptions(appOutDir, installerOutDir, setupFile) - await this.generateStubExecutableExe(appOutDir, distOptions.vendorDirectory!) + await this.generateStubExecutableExe(appOutDir, distOptions.vendorDirectory) await withToolsetLock(() => createWindowsInstaller(distOptions)) await packager.signAndEditResources(artifactPath, arch, installerOutDir) @@ -216,10 +237,18 @@ export default class SquirrelWindowsTarget extends Target { private select7zipArch(vendorDirectory: string) { // https://github.com/electron/windows-installer/blob/main/script/select-7z-arch.js // Even if we're cross-compiling for a different arch like arm64, - // we still need to use the 7-Zip executable for the host arch + // we still need to use the 7-Zip executable for the host arch. + // When arch-specific variants aren't present (vendor bundle ships a pre-selected 7z.exe), + // skip the copy — the bundle's 7z.exe is already correct. 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")) + const archExe = path.join(vendorDirectory, `7z-${resolvedArch}.exe`) + const archDll = path.join(vendorDirectory, `7z-${resolvedArch}.dll`) + if (fs.existsSync(archExe)) { + fs.copyFileSync(archExe, path.join(vendorDirectory, "7z.exe")) + } + if (fs.existsSync(archDll)) { + fs.copyFileSync(archDll, path.join(vendorDirectory, "7z.dll")) + } } private async createNuspecTemplateWithProjectUrl() { @@ -236,7 +265,7 @@ export default class SquirrelWindowsTarget extends Target { return templatePath } - async computeEffectiveDistOptions(appDirectory: string, outputDirectory: string, setupFile: string): Promise { + async computeEffectiveDistOptions(appDirectory: string, outputDirectory: string, setupFile: string): Promise { const packager = this.packager let iconUrl = this.options.iconUrl if (iconUrl == null) { @@ -250,66 +279,63 @@ export default class SquirrelWindowsTarget extends Target { } } - checkConflictingOptions(this.options) + normalizeSquirrelOptions(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: `${this.exeName}.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 - } + const description = isEmptyOrSpaces(appInfo.description) ? this.options.name || appInfo.productName : appInfo.description + let remoteReleases: string | undefined if (this.options.remoteReleases === true) { const info = await packager.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`) + remoteReleases = `https://github.com/${info.user}/${info.project}` + log.info({ remoteReleases }, `remoteReleases is set`) } } else if (typeof this.options.remoteReleases === "string" && !isEmptyOrSpaces(this.options.remoteReleases)) { - options.remoteReleases = this.options.remoteReleases + remoteReleases = this.options.remoteReleases } + let loadingGif: string if (this.options.loadingGif) { - options.loadingGif = path.resolve(packager.projectDir, this.options.loadingGif) + loadingGif = path.resolve(packager.projectDir, this.options.loadingGif) } else { - const resourceList = await packager.resourceList - if (resourceList.includes("install-spinner.gif")) { - options.loadingGif = path.join(packager.buildResourcesDir, "install-spinner.gif") - } + // User-supplied buildResources gif wins; otherwise fall back to the bundled default spinner — + // parity with electron-winstaller, which always supplied resources/install-spinner.gif when none + // was configured (a fresh-install progress animation; never omitted). + loadingGif = (await packager.getResource(undefined, "install-spinner.gif")) ?? path.resolve(import.meta.dirname, "..", "install-spinner.gif") } - return options + const vendorDirectory = await this.prepareSignedVendorDirectory() + this.select7zipArch(vendorDirectory) + + return { + appDirectory, + outputDirectory, + vendorDirectory, + name: this.options.useAppIdAsId ? appInfo.id : this.appName, + title: appInfo.productName || appInfo.name, + version: appInfo.version, + description, + exe: `${this.exeName}.exe`, + authors: appInfo.companyName || "", + nuspecTemplate: await this.createNuspecTemplateWithProjectUrl(), + iconUrl, + copyright: appInfo.copyright, + msi: this.options.msi, + fixUpPaths: true, + setupExe: setupFile, + setupMsi: this.options.msi ? setupFile.replace(".exe", ".msi") : undefined, + loadingGif, + remoteReleases, + remoteToken: this.options.remoteToken ?? process.env.GH_TOKEN ?? process.env.GITHUB_TOKEN, + createTempDir: opts => this.packager.tempDirManager.createTempDir(opts), + } } } -function checkConflictingOptions(options: any) { +function normalizeSquirrelOptions(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.`) diff --git a/packages/electron-builder-squirrel-windows/src/toolset.ts b/packages/electron-builder-squirrel-windows/src/toolset.ts new file mode 100644 index 00000000000..929bdbf0eb3 --- /dev/null +++ b/packages/electron-builder-squirrel-windows/src/toolset.ts @@ -0,0 +1,91 @@ +import { ToolsetConfig } from "app-builder-lib" +import { download, downloadBuilderToolset, getCustomToolsetPath, resolveToolsetVersion } from "app-builder-lib/internal" +import { stat } from "fs/promises" +import * as path from "path" + +// Newest squirrel.windows bundle — selected when `toolsets.squirrel` is unset / null / "latest". +const SQUIRREL_LATEST = "1.1.0" + +export const squirrelWindowsChecksums = { + "1.1.0": { + "squirrel.windows-2.0.1-patched.zip": "86e6c3e9ebf10e29cfde99dfff98f3738c29c9562495c540270129ffde1f79cf", + }, +} as const + +/** + * Returns the path to the squirrel.windows toolset directory. It contains an + * `electron-winstaller/vendor/` subtree with the Squirrel vendor executables (Squirrel.exe, + * nuget.exe, SyncReleases.exe, 7z, …). + * + * Honors `toolsets.squirrel`: a pinned version (or unset / `"latest"`) downloads the maintained + * electron-builder-binaries bundle; a {@link ToolsetCustom} object supplies a custom or local bundle + * (which must mirror the same `electron-winstaller/vendor/` layout). + */ +export async function getSquirrelToolsetPath(toolset: ToolsetConfig["squirrel"], resourcesDir: string): Promise { + if (typeof toolset === "object" && toolset != null) { + return getCustomToolsetPath(toolset, resourcesDir) + } + const version = resolveToolsetVersion(toolset, SQUIRREL_LATEST) + return downloadBuilderToolset({ + releaseName: `squirrel.windows@${version}`, + filenameWithExt: "squirrel.windows-2.0.1-patched.zip", + checksums: squirrelWindowsChecksums[version], + }) +} + +// Squirrel's createMsiPackage (msi: true) runs candle.exe/light.exe with `-ext WixNetFxExtension` from +// its own vendor dir. The squirrel.windows bundle omits the WiX toolchain, so reuse the shared WiX +// toolset (the same candle/light electron-builder's MSI target uses) and merge it into the vendor dir. +// This is a transitional WiX 4 (4.0.0.5512): it accepts the v3-style element structure but requires the +// v4 namespace, so template.wxs is authored against http://wixtoolset.org/schemas/v4/wxs. +const WIX_TOOLSET_FILE = "wix-4.0.0.5512.2.7z" +const WIX_TOOLSET_SHA256 = "fe677fcd837b18c9b912985d91636bbd8a1e800c3b3a6a841b6f96e89624e839" + +/** + * Returns the path to the WiX toolset directory (candle.exe, light.exe, WixNetFxExtension.dll, …). + * Only needed when building an MSI. + */ +export async function getWixToolsetPath(): Promise { + return downloadBuilderToolset({ + releaseName: "wix-4.0.0.5512.2", + filenameWithExt: WIX_TOOLSET_FILE, + checksums: { [WIX_TOOLSET_FILE]: WIX_TOOLSET_SHA256 }, + }) +} + +// TEMPORARY: nuget.exe override. The published squirrel.windows@1.1.0 bundle ships the Chocolatey +// shim for nuget.exe, which resolves the real binary relative to its own install path and fails once +// relocated to a temp vendor directory. Until squirrel.windows bundles the standalone portable exe +// (electron-builder-binaries#203), download a pinned NuGet.CommandLine build at runtime and overwrite +// the bundled shim. The same win-x86 exe runs natively on Windows and under mono on Linux/macOS. +// +// TODO(remove after squirrel.windows@1.1.1): once electron-builder-binaries#203 ships the pinned +// nuget.exe in the bundle, delete prepareNugetExe + isUsableNuget + the NUGET_* constants and bump +// SQUIRREL_LATEST to that release. (isUsableNuget already makes this a no-op against such a bundle, +// so the runtime download stops on its own once the version is bumped.) +const NUGET_VERSION = "6.14.0" +const NUGET_SHA256 = "92dbed160ddee0f64b901e907439e021211b428e57c089ecc12fc38dcc4bd9a5" +const NUGET_URL = `https://dist.nuget.org/win-x86-commandline/v${NUGET_VERSION}/nuget.exe` +// A Chocolatey shim is ~0.4 MB; a real nuget.exe is several MB. Anything above this is already usable. +const MIN_USABLE_NUGET_BYTES = 2_000_000 + +async function isUsableNuget(file: string): Promise { + try { + return (await stat(file)).size >= MIN_USABLE_NUGET_BYTES + } catch { + return false + } +} + +/** + * Ensures `vendorDirectory` holds a usable standalone nuget.exe. If the bundle already ships a real + * one (a future squirrel.windows release, or a custom `toolsets.squirrel` bundle), it is kept as-is; + * otherwise a pinned NuGet.CommandLine build is downloaded and cached, replacing the broken shim. + */ +export async function prepareNugetExe(vendorDirectory: string): Promise { + const target = path.join(vendorDirectory, "nuget.exe") + if (await isUsableNuget(target)) { + return + } + await download(NUGET_URL, target, NUGET_SHA256) +} diff --git a/packages/electron-builder-squirrel-windows/src/windowsInstaller.ts b/packages/electron-builder-squirrel-windows/src/windowsInstaller.ts new file mode 100644 index 00000000000..6e0462b215d --- /dev/null +++ b/packages/electron-builder-squirrel-windows/src/windowsInstaller.ts @@ -0,0 +1,162 @@ +import { exec, exists, log } from "builder-util" +import * as fs from "fs/promises" +import * as path from "path" + +export function convertVersion(version: string): string { + const parts = version.split("+")[0].split("-") + const mainVersion = parts.shift() + return parts.length > 0 ? [mainVersion, parts.join("-").replace(/\./g, "")].join("-") : mainVersion! +} + +export function escapeXml(value: string): string { + return value.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """).replace(/'/g, "'") +} + +export function renderNuspecTemplate(templateContent: string, vars: Record): string { + return Object.entries(vars).reduce((t, [k, v]) => t.split(`<%- ${k} %>`).join(v), templateContent) +} + +export async function buildAdditionalFilesXml(appDirectory: string): Promise { + const checks: Array<{ rel: string; src: string; target: string }> = [ + { rel: "swiftshader", src: "swiftshader\\**", target: "lib\\net45\\swiftshader" }, + { rel: "vk_swiftshader_icd.json", src: "vk_swiftshader_icd.json", target: "lib\\net45" }, + ] + const lines: string[] = [] + for (const { rel, src, target } of checks) { + try { + await fs.access(path.join(appDirectory, rel)) + lines.push(` `) + } catch (e: any) { + if (e?.code !== "ENOENT") { + throw e + } + } + } + return lines.join("\n") +} + +export interface InstallerOptions { + appDirectory: string + outputDirectory: string + vendorDirectory: string + name: string + title?: string | null + version: string + description?: string | null + exe: string + authors: string + owners?: string | null + iconUrl?: string | null + copyright?: string | null + nuspecTemplate: string + loadingGif?: string | null + msi?: boolean + remoteReleases?: string | null + remoteToken?: string | null + setupExe?: string | null + setupMsi?: string | null + fixUpPaths?: boolean + createTempDir: (options: { prefix: string }) => Promise +} + +export async function createWindowsInstaller(options: InstallerOptions): Promise { + const { + appDirectory, + outputDirectory, + vendorDirectory, + name, + version, + exe, + nuspecTemplate, + loadingGif, + msi, + remoteReleases, + remoteToken, + setupExe, + setupMsi, + fixUpPaths = false, + createTempDir, + } = options + + const useMono = process.platform !== "win32" + if (useMono) { + log.warn("building Squirrel.Windows on non-Windows requires mono in PATH; spawn will fail if it is missing") + } + + // Squirrel.exe must live in the app directory so the nuspec can reference it + await fs.copyFile(path.join(vendorDirectory, "Squirrel.exe"), path.join(appDirectory, "Squirrel.exe")) + + const nugetVersion = convertVersion(version) + const authors = options.authors || "" + const copyright = options.copyright || `Copyright © ${new Date().getFullYear()} ${authors}` + const owners = options.owners || authors + let additionalFilesXml = await buildAdditionalFilesXml(appDirectory) + + let templateContent = await fs.readFile(nuspecTemplate, "utf8") + if (path.sep === "/") { + templateContent = templateContent.replace(/\\/g, "/") + additionalFilesXml = additionalFilesXml.replace(/\\/g, "/") + } + + const nuspecContent = renderNuspecTemplate(templateContent, { + name: escapeXml(name), + title: escapeXml(options.title || name), + version: escapeXml(nugetVersion), + authors: escapeXml(authors), + owners: escapeXml(owners), + iconUrl: escapeXml(options.iconUrl || ""), + description: escapeXml(options.description || options.title || name), + copyright: escapeXml(copyright), + exe: escapeXml(exe), + additionalFilesXml, + }) + + const nugetOutput = await createTempDir({ prefix: "squirrel-nuget-" }) + const nuspecPath = path.join(nugetOutput, `${name}.nuspec`) + await fs.writeFile(nuspecPath, nuspecContent) + + const nugetExe = path.join(vendorDirectory, "nuget.exe") + const nugetArgs = ["pack", nuspecPath, "-BasePath", appDirectory, "-OutputDirectory", nugetOutput, "-NoDefaultExcludes"] + await (useMono ? exec("mono", [nugetExe, ...nugetArgs]) : exec(nugetExe, nugetArgs)) + + const nupkgPath = path.join(nugetOutput, `${name}.${nugetVersion}.nupkg`) + + if (remoteReleases) { + const syncReleasesExe = path.join(vendorDirectory, "SyncReleases.exe") + const syncArgs = ["-u", remoteReleases, "-r", outputDirectory] + if (remoteToken) { + syncArgs.push("-t", remoteToken) + } + await (useMono ? exec("mono", [syncReleasesExe, ...syncArgs]) : exec(syncReleasesExe, syncArgs)) + } + + const releasifyArgs = ["--releasify", nupkgPath, "--releaseDir", outputDirectory] + if (loadingGif) { + releasifyArgs.push("--loadingGif", loadingGif) + } + if (!msi) { + releasifyArgs.push("--no-msi") + } + + if (useMono) { + await exec("mono", [path.join(vendorDirectory, "Squirrel-Mono.exe"), ...releasifyArgs]) + } else { + await exec(path.join(vendorDirectory, "Squirrel.exe"), releasifyArgs) + } + + if (fixUpPaths) { + if (setupExe) { + await fs.rename(path.join(outputDirectory, "Setup.exe"), path.join(outputDirectory, setupExe)) + } + if (setupMsi) { + // setupMsi is only set when MSI output was requested (msi: true), so releasify ran without + // --no-msi and Setup.msi must exist. A missing file here is a real failure, not an expected + // absence — surface it instead of silently dropping the artifact. + const setupMsiPath = path.join(outputDirectory, "Setup.msi") + if (!(await exists(setupMsiPath))) { + throw new Error(`MSI output was requested but Squirrel.Windows did not produce ${setupMsiPath}`) + } + await fs.rename(setupMsiPath, path.join(outputDirectory, setupMsi)) + } + } +} diff --git a/packages/electron-builder-squirrel-windows/template.nuspectemplate b/packages/electron-builder-squirrel-windows/template.nuspectemplate index 688a369d20d..e5bad0bb152 100644 --- a/packages/electron-builder-squirrel-windows/template.nuspectemplate +++ b/packages/electron-builder-squirrel-windows/template.nuspectemplate @@ -25,8 +25,6 @@ - <% additionalFiles.forEach(function(f) { %> - - <% }); %> +<%- additionalFilesXml %> \ No newline at end of file diff --git a/packages/electron-builder-squirrel-windows/template.wxs b/packages/electron-builder-squirrel-windows/template.wxs new file mode 100644 index 00000000000..d11bd1e1c6d --- /dev/null +++ b/packages/electron-builder-squirrel-windows/template.wxs @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e3d968db650..44334ec11eb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -420,16 +420,6 @@ importers: builder-util: specifier: workspace:* version: link:../builder-util - electron-winstaller: - specifier: 5.4.0 - version: 5.4.0 - devDependencies: - '@types/archiver': - specifier: 5.3.1 - version: 5.3.1 - '@types/fs-extra': - specifier: 9.0.13 - version: 9.0.13 packages/electron-forge-maker-appimage: dependencies: @@ -627,9 +617,6 @@ importers: electron-updater: specifier: workspace:* version: link:../packages/electron-updater - electron-winstaller: - specifier: 5.4.0 - version: 5.4.0 fast-xml-parser: specifier: 5.7.0 version: 5.7.0 @@ -2675,11 +2662,6 @@ packages: resolution: {integrity: sha512-3ojeJry9xBYdJO6qoyyzqeJFSJBVx2mXhyDzSdjwL2+URFQMf+h25gG38iswGImicK0ELjTd1EL2xzk8hf3QPw==} engines: {node: '>=20.0'} - '@electron/asar@3.4.1': - resolution: {integrity: sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA==} - engines: {node: '>=10.12.0'} - hasBin: true - '@electron/asar@4.1.1': resolution: {integrity: sha512-grbomy1TPEauyu+N4UdRQqSjOhsm0ZoTbprLpvJWQGYeEIMVBwSPV3Rv3E3EfqU5iFSufd/k+2KZvTDbgfesnw==} engines: {node: '>=22.12.0'} @@ -2725,11 +2707,6 @@ packages: resolution: {integrity: sha512-G7mNdfZIdPbx54dTorGlV7SN7nHAKlbJU1NjjeuEo7RzEMYBG62+tKpA2VrzocGcvFLxqJI5XYIknca9EijwOw==} engines: {node: '>=22.12.0'} - '@electron/windows-sign@1.2.1': - resolution: {integrity: sha512-YfASnrhJ+ve6Q43ZiDwmpBgYgi2u0bYjeAVi2tDfN7YWAKO8X9EEOuPGtqbJpPLM6TfAHimghICjWe2eaJ8BAg==} - engines: {node: '>=14.14'} - hasBin: true - '@emnapi/core@1.10.0': resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} @@ -3974,9 +3951,6 @@ packages: '@types/adm-zip@0.5.6': resolution: {integrity: sha512-lRlcSLg5Yoo7C2H2AUiAoYlvifWoCx/se7iUNiCBTfEVVYFVn+Tr9ZGed4K73tYgLe9O4PjdJvbxlkdAOx/qiw==} - '@types/archiver@5.3.1': - resolution: {integrity: sha512-wKYZaSXaDvTZuInAWjCeGG7BEAgTWG2zZW0/f7IYFcoHB2X2d9lkVFnrOlXl3W6NrvO6Ml3FLLu8Uksyymcpnw==} - '@types/aws4@1.11.6': resolution: {integrity: sha512-5CnVUkHNyLGpD9AnOcK66YyP0qvIh6nhJJoeK8zSl5YKikUcUbdB7SlHevUYVqicgeh6j5AJa1qa/h08dSZHoA==} @@ -4133,9 +4107,6 @@ packages: '@types/geojson@7946.0.16': resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} - '@types/glob@8.1.0': - resolution: {integrity: sha512-IO+MJPVhoqz+28h1qLAcBEH2+xHMK6MTyHJc7MTnnYb6wsoLR29POVGJ7LycmVXIqyy/4/2ShP5sUwTXuOwb/w==} - '@types/graceful-fs@4.1.9': resolution: {integrity: sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==} @@ -4205,9 +4176,6 @@ packages: '@types/minimatch@3.0.5': resolution: {integrity: sha512-Klz949h02Gz2uZCMGwDUSDS1YBlTdDDgbWHi+81l29tQALUtvz4rAYi5uoVhE5Lagoq6DeqAUlbrHvW/mXDgdQ==} - '@types/minimatch@5.1.2': - resolution: {integrity: sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==} - '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} @@ -5119,10 +5087,6 @@ packages: resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} engines: {node: '>= 12'} - commander@9.5.0: - resolution: {integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==} - engines: {node: ^12.20.0 || >=14} - common-path-prefix@3.0.0: resolution: {integrity: sha512-QE33hToZseCH3jS0qN96O/bSh3kaw/h+Tq7ngyY9eWDUnTlTNUyqfqvCXioLe5Na5jFsL78ra/wuBU4iuEgd4w==} @@ -5293,9 +5257,6 @@ packages: create-require@1.1.1: resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} - cross-dirname@0.1.0: - resolution: {integrity: sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==} - cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -5805,10 +5766,6 @@ packages: electron-to-chromium@1.5.353: resolution: {integrity: sha512-kOrWphBi8TOZyiJZqsgqIle0lw+tzmnQK83pV9dZUd01Nm2POECSyFQMAuarzZdYqQW7FH9RaYOuaRo3h+bQ3w==} - electron-winstaller@5.4.0: - resolution: {integrity: sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==} - engines: {node: '>=8.0.0'} - electron@39.8.5: resolution: {integrity: sha512-q6+LiQIcTadSyvtPgLDQkCtVA9jQJXQVMrQcctfOJILh6OFMN+UJJLRkuUTy8CZDYeCIBn1ZycqsL1dAXugxZA==} engines: {node: '>= 12.20.55'} @@ -7532,10 +7489,6 @@ packages: resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} engines: {node: '>= 18'} - mkdirp@0.5.6: - resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} - hasBin: true - mri@1.2.0: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} @@ -8337,11 +8290,6 @@ packages: resolution: {integrity: sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==} engines: {node: ^10 || ^12 || >=14} - postject@1.0.0-alpha.6: - resolution: {integrity: sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==} - engines: {node: '>=14.0.0'} - hasBin: true - prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -8724,11 +8672,6 @@ packages: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} - rimraf@2.6.3: - resolution: {integrity: sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==} - deprecated: Rimraf versions prior to v4 are no longer supported - hasBin: true - roarr@2.15.4: resolution: {integrity: sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==} engines: {node: '>=8.0'} @@ -9149,10 +9092,6 @@ packages: temp-file@3.4.0: resolution: {integrity: sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg==} - temp@0.9.4: - resolution: {integrity: sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==} - engines: {node: '>=6.0.0'} - tempfile@5.0.0: resolution: {integrity: sha512-bX655WZI/F7EoTDw9JvQURqAXiPHi8o8+yFxPF2lWYyz1aHnmMRuXWqL6YB6GmeO0o4DIYWHLgGNi/X64T+X4Q==} engines: {node: '>=14.18'} @@ -13471,12 +13410,6 @@ snapshots: - uglify-js - webpack-cli - '@electron/asar@3.4.1': - dependencies: - commander: 5.1.0 - glob: 7.2.3 - minimatch: 3.1.5 - '@electron/asar@4.1.1': dependencies: commander: 13.1.0 @@ -13573,17 +13506,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@electron/windows-sign@1.2.1': - dependencies: - cross-dirname: 0.1.0 - debug: 4.4.3 - fs-extra: 11.3.0 - minimist: 1.2.8 - postject: 1.0.0-alpha.6 - transitivePeerDependencies: - - supports-color - optional: true - '@emnapi/core@1.10.0': dependencies: '@emnapi/wasi-threads': 1.2.1 @@ -14765,10 +14687,6 @@ snapshots: dependencies: '@types/node': 22.13.17 - '@types/archiver@5.3.1': - dependencies: - '@types/glob': 8.1.0 - '@types/aws4@1.11.6': dependencies: '@types/node': 22.13.17 @@ -14972,11 +14890,6 @@ snapshots: '@types/geojson@7946.0.16': {} - '@types/glob@8.1.0': - dependencies: - '@types/minimatch': 5.1.2 - '@types/node': 22.13.17 - '@types/graceful-fs@4.1.9': dependencies: '@types/node': 22.13.17 @@ -15041,8 +14954,6 @@ snapshots: '@types/minimatch@3.0.5': {} - '@types/minimatch@5.1.2': {} - '@types/ms@2.1.0': {} '@types/node@12.20.55': {} @@ -16120,9 +16031,6 @@ snapshots: commander@8.3.0: {} - commander@9.5.0: - optional: true - common-path-prefix@3.0.0: {} compare-func@2.0.0: @@ -16311,9 +16219,6 @@ snapshots: create-require@1.1.1: {} - cross-dirname@0.1.0: - optional: true - cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -16940,18 +16845,6 @@ snapshots: electron-to-chromium@1.5.353: {} - electron-winstaller@5.4.0: - dependencies: - '@electron/asar': 3.4.1 - debug: 4.4.3 - fs-extra: 7.0.1 - lodash: 4.18.1 - temp: 0.9.4 - optionalDependencies: - '@electron/windows-sign': 1.2.1 - transitivePeerDependencies: - - supports-color - electron@39.8.5: dependencies: '@electron/get': 2.0.3 @@ -19150,10 +19043,6 @@ snapshots: dependencies: minipass: 7.1.3 - mkdirp@0.5.6: - dependencies: - minimist: 1.2.8 - mri@1.2.0: {} mrmime@2.0.1: {} @@ -20139,11 +20028,6 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 - postject@1.0.0-alpha.6: - dependencies: - commander: 9.5.0 - optional: true - prelude-ls@1.2.1: {} prettier-linter-helpers@1.0.1: @@ -20604,10 +20488,6 @@ snapshots: reusify@1.1.0: {} - rimraf@2.6.3: - dependencies: - glob: 7.2.3 - roarr@2.15.4: dependencies: boolean: 3.2.0 @@ -21108,11 +20988,6 @@ snapshots: async-exit-hook: 2.0.1 fs-extra: 10.1.0 - temp@0.9.4: - dependencies: - mkdirp: 0.5.6 - rimraf: 2.6.3 - tempfile@5.0.0: dependencies: temp-dir: 3.0.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 2765f5c48fc..d4ec743bdec 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -8,7 +8,6 @@ allowBuilds: '@swc/core': true core-js: false electron: false - electron-winstaller: true esbuild: false # 10080 minutes = 7 days diff --git a/test/package.json b/test/package.json index b2e12809807..7dbceac6937 100644 --- a/test/package.json +++ b/test/package.json @@ -32,7 +32,6 @@ "electron-builder-squirrel-windows": "workspace:*", "electron-publish": "workspace:*", "electron-updater": "workspace:*", - "electron-winstaller": "5.4.0", "fast-xml-parser": "5.7.0", "fs-extra": "^10.1.0", "js-yaml": "^4.1.0", diff --git a/test/src/helpers/packTester.ts b/test/src/helpers/packTester.ts index 97490df0707..c3921d947de 100644 --- a/test/src/helpers/packTester.ts +++ b/test/src/helpers/packTester.ts @@ -4,7 +4,7 @@ import { AsarIntegrity, computeArchToTargetNamesMap, getLinuxToolsMacToolset, pa import { addValue, copyDir, exec, executeFinally, exists, FileCopier, log, retry, USE_HARD_LINKS, walk } from "builder-util" import { CancellationToken, deepAssign, UpdateFileInfo } from "builder-util-runtime" import { Arch, ArtifactCreated, Configuration, DIR_TARGET, getArchSuffix, MacOsTargetName, Packager, PackagerOptions, Platform, Target } from "electron-builder" -import { convertVersion } from "electron-winstaller" +import { convertVersion } from "electron-builder-squirrel-windows/src/windowsInstaller" import { PublishPolicy } from "electron-publish" import { copyFile, emptyDir, mkdir, writeJson } from "fs-extra" import * as fs from "fs/promises" diff --git a/test/src/windows/squirrelWindowsInstallerTest.ts b/test/src/windows/squirrelWindowsInstallerTest.ts new file mode 100644 index 00000000000..866fbecd755 --- /dev/null +++ b/test/src/windows/squirrelWindowsInstallerTest.ts @@ -0,0 +1,122 @@ +import { afterEach, beforeEach, describe, expect, test } from "vitest" +import { convertVersion, escapeXml, renderNuspecTemplate, buildAdditionalFilesXml } from "electron-builder-squirrel-windows/src/windowsInstaller" +import { mkdir, mkdtemp, rm, writeFile } from "fs/promises" +import { tmpdir } from "os" +import * as path from "path" + +describe("convertVersion", () => { + test.each([ + ["1.0.0", "1.0.0"], + ["1.2.3", "1.2.3"], + ["1.0.0-alpha", "1.0.0-alpha"], + ["1.0.0-alpha.1", "1.0.0-alpha1"], + ["1.0.0-alpha.beta", "1.0.0-alphabeta"], + ["1.0.0-rc.1", "1.0.0-rc1"], + ["2.0.0-beta.2", "2.0.0-beta2"], + ["1.0.0+build.123", "1.0.0"], + ["1.0.0-beta+build.1", "1.0.0-beta"], + ["2.0.0-rc.1+sha.abc123", "2.0.0-rc1"], + ["10.20.30", "10.20.30"], + ])("converts %s → %s", (input, expected) => { + expect(convertVersion(input)).toBe(expected) + }) +}) + +describe("escapeXml", () => { + test.each([ + ["Foo & Bar", "Foo & Bar"], + ["", "<script>alert(1)</script>"], + ['"double quoted"', ""double quoted""], + ["it's fine", "it's fine"], + ["normal text", "normal text"], + ["a & b < c > d", "a & b < c > d"], + ["", ""], + ])("escapes %s", (input, expected) => { + expect(escapeXml(input)).toBe(expected) + }) + + test("escapes all five XML special characters", () => { + expect(escapeXml(`&<>"'`)).toBe("&<>"'") + }) +}) + +describe("renderNuspecTemplate", () => { + test("substitutes a single variable", () => { + expect(renderNuspecTemplate("<%- name %>", { name: "MyApp" })).toBe("MyApp") + }) + + test("substitutes multiple variables", () => { + const template = "<%- name %><%- version %>" + expect(renderNuspecTemplate(template, { name: "App", version: "1.2.3" })).toBe("App1.2.3") + }) + + test("leaves unknown placeholders untouched", () => { + expect(renderNuspecTemplate("<%- unknown %>", {})).toBe("<%- unknown %>") + }) + + test("handles empty string values", () => { + expect(renderNuspecTemplate("<%- val %>", { val: "" })).toBe("") + }) + + test("substitutes the same key multiple times", () => { + const template = "<%- x %> and <%- x %>" + expect(renderNuspecTemplate(template, { x: "hello" })).toBe("hello and hello") + }) + + test("inserts pre-escaped XML values verbatim", () => { + const template = "<%- description %>" + const result = renderNuspecTemplate(template, { description: "A & B" }) + expect(result).toBe("A & B") + }) + + test("inserts additionalFilesXml block without double-escaping", () => { + const extra = ` ` + const template = "<%- additionalFilesXml %>" + expect(renderNuspecTemplate(template, { additionalFilesXml: extra })).toBe(extra) + }) +}) + +describe("buildAdditionalFilesXml", () => { + let tmpDir: string + + beforeEach(async () => { + tmpDir = await mkdtemp(path.join(tmpdir(), "eb-installer-test-")) + }) + + afterEach(() => rm(tmpDir, { recursive: true, force: true }).catch(() => {})) + + test("returns empty string when no optional GPU files present", async () => { + const result = await buildAdditionalFilesXml(tmpDir) + expect(result).toBe("") + }) + + test("includes swiftshader entry when directory present", async () => { + await mkdir(path.join(tmpDir, "swiftshader"), { recursive: true }) + const result = await buildAdditionalFilesXml(tmpDir) + expect(result).toContain("swiftshader") + expect(result).toContain(" { + await writeFile(path.join(tmpDir, "vk_swiftshader_icd.json"), "{}") + const result = await buildAdditionalFilesXml(tmpDir) + expect(result).toContain("vk_swiftshader_icd.json") + expect(result).toContain(" { + await mkdir(path.join(tmpDir, "swiftshader"), { recursive: true }) + await writeFile(path.join(tmpDir, "vk_swiftshader_icd.json"), "{}") + const result = await buildAdditionalFilesXml(tmpDir) + expect(result).toContain("swiftshader") + expect(result).toContain("vk_swiftshader_icd.json") + const lines = result.split("\n").filter((l: string) => l.trim()) + expect(lines).toHaveLength(2) + }) + + test("does not include unrelated files", async () => { + await writeFile(path.join(tmpDir, "some-other-file.dll"), "") + const result = await buildAdditionalFilesXml(tmpDir) + expect(result).toBe("") + }) +}) diff --git a/test/src/windows/squirrelWindowsSecurityTest.ts b/test/src/windows/squirrelWindowsSecurityTest.ts index 99099994e08..189152392a9 100644 --- a/test/src/windows/squirrelWindowsSecurityTest.ts +++ b/test/src/windows/squirrelWindowsSecurityTest.ts @@ -12,6 +12,7 @@ describe("SquirrelWindowsTarget.assertShellSafePath", () => { test.each([ ["newline \\n", "C:\\foo\nbar"], ["carriage return \\r", "C:\\foo\rbar"], + ["null byte \\0", "C:\\foo\0bar"], ["backtick", "C:\\foo`bar"], ["dollar sign", "C:\\foo$bar"], ["semicolon", "C:\\foo;bar"], @@ -60,4 +61,12 @@ describe("SquirrelWindowsTarget.ensurePathInside", { sequential: true }, () => { test("rejects path containing shell-unsafe characters", async () => { await expect(t.ensurePathInside(base, path.join(base, "file$evil.exe"), "file")).rejects.toThrow("unsafe shell characters") }) + + test("rejects a non-existent path whose filename contains a null byte", async () => { + // The target does not exist (parent base does), so we enter the parent-resolution + // branch. The null byte in the relative segment triggers inner path validation, + // and the assertShellSafePath guard also catches it — both layers must reject this. + const nullByteTarget = path.join(base, "file\0evil.exe") + await expect(t.ensurePathInside(base, nullByteTarget, "file")).rejects.toThrow() + }) }) diff --git a/test/src/windows/squirrelWindowsTargetTest.ts b/test/src/windows/squirrelWindowsTargetTest.ts new file mode 100644 index 00000000000..d1c52421d7e --- /dev/null +++ b/test/src/windows/squirrelWindowsTargetTest.ts @@ -0,0 +1,49 @@ +import { afterEach, beforeEach, describe, expect, test } from "vitest" +import SquirrelWindowsTarget from "electron-builder-squirrel-windows/src/SquirrelWindowsTarget" +import { mkdtemp, readFile, rm, writeFile } from "fs/promises" +import { arch as osArch } from "os" +import * as path from "path" +import { tmpdir } from "os" + +describe("SquirrelWindowsTarget.select7zipArch", () => { + let tmpDir: string + let t: any + + beforeEach(async () => { + tmpDir = await mkdtemp(path.join(tmpdir(), "eb-7z-test-")) + t = Object.create(SquirrelWindowsTarget.prototype) + }) + + afterEach(() => rm(tmpDir, { recursive: true, force: true }).catch(() => {})) + + test("copies arch-specific 7z.exe and 7z.dll when both are present", async () => { + const arch = osArch() + await writeFile(path.join(tmpDir, `7z-${arch}.exe`), "exe-content") + await writeFile(path.join(tmpDir, `7z-${arch}.dll`), "dll-content") + + t.select7zipArch(tmpDir) + + expect(await readFile(path.join(tmpDir, "7z.exe"), "utf8")).toBe("exe-content") + expect(await readFile(path.join(tmpDir, "7z.dll"), "utf8")).toBe("dll-content") + }) + + test("skips copy and leaves existing 7z.exe unchanged when arch-specific exe is absent", async () => { + await writeFile(path.join(tmpDir, "7z.exe"), "original-exe") + + t.select7zipArch(tmpDir) + + expect(await readFile(path.join(tmpDir, "7z.exe"), "utf8")).toBe("original-exe") + }) + + test("copies exe but skips dll when only arch-specific exe is present", async () => { + const arch = osArch() + await writeFile(path.join(tmpDir, `7z-${arch}.exe`), "exe-only") + await writeFile(path.join(tmpDir, "7z.exe"), "original-exe") + + t.select7zipArch(tmpDir) + + expect(await readFile(path.join(tmpDir, "7z.exe"), "utf8")).toBe("exe-only") + // 7z.dll was never created — should still not exist + await expect(readFile(path.join(tmpDir, "7z.dll"), "utf8")).rejects.toThrow() + }) +}) diff --git a/test/src/windows/squirrelWindowsToolsetTest.ts b/test/src/windows/squirrelWindowsToolsetTest.ts new file mode 100644 index 00000000000..3959193442c --- /dev/null +++ b/test/src/windows/squirrelWindowsToolsetTest.ts @@ -0,0 +1,50 @@ +import { afterEach, beforeEach, describe, expect, test } from "vitest" +import { getSquirrelToolsetPath, prepareNugetExe } from "electron-builder-squirrel-windows/src/toolset" +import { mkdtemp, rm, stat, writeFile } from "fs/promises" +import { tmpdir } from "os" +import * as path from "path" + +describe("getSquirrelToolsetPath", () => { + let tmpDir: string + + beforeEach(async () => { + tmpDir = await mkdtemp(path.join(tmpdir(), "eb-squirrel-toolset-test-")) + }) + + afterEach(() => rm(tmpDir, { recursive: true, force: true }).catch(() => {})) + + test("resolves a ToolsetCustom bare directory in place (no download)", async () => { + // A `file://` directory custom toolset is used as-is — no checksum, no network — so this is the + // air-gapped / local-bundle path and must resolve without touching the network. + const result = await getSquirrelToolsetPath({ url: `file://${tmpDir}` }, tmpDir) + expect(result).toBe(path.resolve(tmpDir)) + }) + + test("rejects an invalid ToolsetCustom url", async () => { + await expect(getSquirrelToolsetPath({ url: "not-a-url" }, tmpDir)).rejects.toThrow(/Invalid custom toolset/) + }) +}) + +describe("prepareNugetExe", () => { + let tmpDir: string + + beforeEach(async () => { + tmpDir = await mkdtemp(path.join(tmpdir(), "eb-squirrel-nuget-test-")) + }) + + afterEach(() => rm(tmpDir, { recursive: true, force: true }).catch(() => {})) + + test("keeps an already-usable nuget.exe and performs no download", async () => { + // A real nuget.exe is several MB; staging one larger than the shim threshold must short-circuit + // the download entirely (this is the offline / custom-bundle path, so it must not hit network). + const nugetExe = path.join(tmpDir, "nuget.exe") + await writeFile(nugetExe, Buffer.alloc(2_100_000, 1)) + const before = await stat(nugetExe) + + await prepareNugetExe(tmpDir) + + const after = await stat(nugetExe) + expect(after.size).toBe(before.size) + expect(after.mtimeMs).toBe(before.mtimeMs) + }) +}) diff --git a/website/docs/migration/v26-to-v27.md b/website/docs/migration/v26-to-v27.md index d0715692de0..a44a510824e 100644 --- a/website/docs/migration/v26-to-v27.md +++ b/website/docs/migration/v26-to-v27.md @@ -145,6 +145,7 @@ import { build } from "electron-builder" - [ ] [Replace `CI_BUILD_TAG`](./v27-breaking-changes#ci_build_tag-environment-variable) with `CI_COMMIT_TAG`. - [ ] [Replace toolset env-var overrides](./v27-breaking-changes#toolset-env-var-overrides-removed) (`APPIMAGE_TOOLS_PATH`, `ELECTRON_BUILDER_NSIS_DIR`, `USE_SYSTEM_WINE`, …) with `toolsets.X: { url, checksum }`. - [ ] [Validate toolset pins](./v27-breaking-changes#toolset-defaults-resolve-to-latest-newest-bundle) — defaults now resolve to `"latest"` (newest bundle); pin to `"0.0.0"` only if a legacy bundle is needed. +- [ ] [Remove `squirrelWindows.customSquirrelVendorDir`](./v27-breaking-changes#squirrelwindowscustomsquirrelvendordir) — the option is deleted; supply a custom Squirrel bundle via `toolsets.squirrel` (a `ToolsetCustom` object) instead (note the different bundle layout). - [ ] **Plugin/custom-target authors:** [replace `packager.info.X` and `platformSpecificBuildOptions`](./v27-breaking-changes#programmatic--plugin-author-api-changes) with the new pass-through getters. --- diff --git a/website/docs/migration/v27-breaking-changes.md b/website/docs/migration/v27-breaking-changes.md index 50ab49f0e4c..4ed8462d3aa 100644 --- a/website/docs/migration/v27-breaking-changes.md +++ b/website/docs/migration/v27-breaking-changes.md @@ -61,6 +61,7 @@ To stay on a legacy bundle, pin the toolset to `"0.0.0"`. Because `winCodeSign` | [Root-level `directories` removed](#root-level-directories-in-packagejson) | ✓ | Move under `build.directories` | | [`build.helper-bundle-id` removed](#buildhelper-bundle-id) | ✓ | Moved to `mac.helperBundleId` | | [`squirrelWindows.noMsi` removed](#squirrelwindowsnomsi) | ✓ | Replaced by `msi` (inverted) | +| [`squirrelWindows.customSquirrelVendorDir` removed](#squirrelwindowscustomsquirrelvendordir) | — | Supply a custom Squirrel bundle via `toolsets.squirrel` (a `ToolsetCustom` object) | | [`GithubOptions.vPrefixedTagName` removed](#githuboptions--gitlaboptions-vprefixedtagname) | ✓ | Use `tagNamePrefix` | | [`GitlabOptions.vPrefixedTagName` retained](#githuboptions--gitlaboptions-vprefixedtagname) | — | None — still functional; the migrator leaves GitLab entries untouched | | [`devMetadata` / `extraMetadata` in `PackagerOptions` removed](#devmetadata--extrametadata-programmatic-packageroptions) | — | Use `config` / `config.extraMetadata` | @@ -219,6 +220,25 @@ The `noMsi` boolean is removed in favor of its inverse, `msi`. { "build": { "squirrelWindows": { "msi": false } } } // After ``` +### `squirrelWindows.customSquirrelVendorDir` + +Removed. v27 inlines the Squirrel.Windows installer logic and drops the `electron-winstaller` npm dependency (and its vendored binaries); the Squirrel vendor toolset is now fetched from the maintained `squirrel.windows` electron-builder-binaries bundle. To pin a version or supply a custom/local bundle — for example an air-gapped mirror — use the standard `toolsets.squirrel` setting instead, the same way you would pin `toolsets.nsis` or `toolsets.winCodeSign`. + +The shape and behaviour differ, so this is not a 1-to-1 rename: + +- The old `customSquirrelVendorDir` pointed at a directory **whose contents were the vendor files** (`Squirrel.exe`, `nuget.exe`, …) and was copied verbatim, bypassing all provisioning. +- `toolsets.squirrel` accepts a version pin (`"1.1.0"` / `"latest"`) **or** a `ToolsetCustom` object whose `url` points at a bundle that **contains an `electron-winstaller/vendor/` subtree**. The bundle still goes through normal provisioning: a working `nuget.exe` is ensured (no download when your `vendor/` already ships a real, non-shim one) and, on Windows, `rcedit.exe` is supplied from the `winCodeSign` toolset. + +```json5 +// Before (removed): +{ "squirrelWindows": { "customSquirrelVendorDir": "./my-vendor" } } +// After: a custom toolset bundle whose electron-winstaller/vendor/ holds the binaries. +// A bare file:// directory is used as-is (no checksum); a remote/archive url needs a checksum. +{ "toolsets": { "squirrel": { "url": "file:///abs/path/to/squirrel-toolset" } } } +``` + +For a fully offline build, point `toolsets.squirrel` at a bundle whose `vendor/` already contains a real (multi-MB, non-shim) `nuget.exe` — the runtime nuget download is then skipped entirely. + ### `GithubOptions` / `GitlabOptions` `vPrefixedTagName` The `vPrefixedTagName` boolean on `GithubOptions` is removed. Use `tagNamePrefix` to control the tag prefix.