diff --git a/.changeset/poor-bats-clap.md b/.changeset/poor-bats-clap.md new file mode 100644 index 00000000000..588eb8328ef --- /dev/null +++ b/.changeset/poor-bats-clap.md @@ -0,0 +1,6 @@ +--- +"app-builder-lib": minor +"builder-util": minor +--- + +feat: migrate `electronDownload` to use `electron/get` official package. provides much better support for mirrors diff --git a/packages/app-builder-lib/package.json b/packages/app-builder-lib/package.json index 39aa98c16db..bd65cc2a8c6 100644 --- a/packages/app-builder-lib/package.json +++ b/packages/app-builder-lib/package.json @@ -50,6 +50,7 @@ "@develar/schema-utils": "~2.6.5", "@electron/asar": "3.4.1", "@electron/fuses": "^1.8.0", + "@electron/get": "^3.1.0", "@electron/notarize": "2.5.0", "@electron/osx-sign": "1.3.3", "@electron/rebuild": "4.0.1", diff --git a/packages/app-builder-lib/scheme.json b/packages/app-builder-lib/scheme.json index cfd4404b396..76df3e4c052 100644 --- a/packages/app-builder-lib/scheme.json +++ b/packages/app-builder-lib/scheme.json @@ -1071,10 +1071,34 @@ }, "version": { "type": "string" + }, + "checksums": { + "type": "object" } }, "type": "object" }, + "ElectronGetOptions": { + "additionalProperties": false, + "properties": { + "checksums": { + "$ref": "#/definitions/Record", + "description": "Provides checksums for the artifact as strings.\nCan be used if you already know the checksums of the Electron artifact\nyou are downloading and want to skip the checksum file download\nwithout skipping the checksum validation.\n\nThis should be an object whose keys are the file names of the artifacts and\nthe values are their respective SHA256 checksums." + }, + "force": { + "description": "Whether to download an artifact regardless of whether it's in the cache directory.", + "type": "boolean" + }, + "isGeneric": { + "const": false, + "type": "boolean" + }, + "unsafelyDisableChecksums": { + "description": "When set to `true`, disables checking that the artifact download completed successfully\nwith the correct payload.", + "type": "boolean" + } + } + }, "FileAssociation": { "additionalProperties": false, "description": "File associations.\n\nmacOS (corresponds to [CFBundleDocumentTypes](https://developer.apple.com/library/content/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html#//apple_ref/doc/uid/20001431-101685)), NSIS, and MSI only.\n\nOn Windows (NSIS) works only if [nsis.perMachine](https://www.electron.build/nsis) is set to `true`.", @@ -5574,6 +5598,10 @@ ], "type": "object" }, + "Record": { + "additionalProperties": false, + "type": "object" + }, "ReleaseInfo": { "additionalProperties": false, "properties": { @@ -7337,8 +7365,18 @@ "description": "The function (or path to file or module id) to be run when staging the electron artifact environment.\nReturns the path to custom Electron build (e.g. `~/electron/out/R`) or folder of electron zips.\n\nZip files must follow the pattern `electron-v${version}-${platformName}-${arch}.zip`, otherwise it will be assumed to be an unpacked Electron app directory" }, "electronDownload": { - "$ref": "#/definitions/ElectronDownloadOptions", - "description": "The [electron-download](https://github.com/electron-userland/electron-download#usage) options." + "anyOf": [ + { + "$ref": "#/definitions/ElectronDownloadOptions" + }, + { + "$ref": "#/definitions/ElectronGetOptions" + }, + { + "type": "null" + } + ], + "description": "The [electron-download](https://github.com/electron-userland/electron-download#usage) options. (legacy)\nAlternatively, you can use [electron/get](https://github.com/electron/get#usage) options." }, "electronFuses": { "anyOf": [ diff --git a/packages/app-builder-lib/src/configuration.ts b/packages/app-builder-lib/src/configuration.ts index 1d7283e3ff5..d6bfe8c1b45 100644 --- a/packages/app-builder-lib/src/configuration.ts +++ b/packages/app-builder-lib/src/configuration.ts @@ -1,6 +1,6 @@ import { Arch } from "builder-util" import { BeforeBuildContext, Target } from "./core" -import { ElectronBrandingOptions, ElectronDownloadOptions } from "./electron/ElectronFramework" +import { ElectronBrandingOptions } from "./electron/ElectronFramework" import { PrepareApplicationStageDirectoryOptions } from "./Framework" import { AppXOptions } from "./options/AppXOptions" import { AppImageOptions, DebOptions, FlatpakOptions, LinuxConfiguration, LinuxTargetSpecificOptions } from "./options/linuxOptions" @@ -16,6 +16,7 @@ import { BuildResult } from "./packager" import { ArtifactBuildStarted, ArtifactCreated } from "./packagerApi" import { PlatformPackager } from "./platformPackager" import { NsisOptions, NsisWebOptions, PortableOptions } from "./targets/nsis/nsisOptions" +import { ElectronDownloadOptions, ElectronGetOptions } from "./util/electronGet" // duplicate appId here because it is important /** @@ -201,9 +202,10 @@ export interface Configuration extends CommonConfiguration, PlatformSpecificBuil readonly electronCompile?: boolean /** - * The [electron-download](https://github.com/electron-userland/electron-download#usage) options. + * The [electron-download](https://github.com/electron-userland/electron-download#usage) options. (legacy) + * Alternatively, you can use [electron/get](https://github.com/electron/get#usage) options. */ - readonly electronDownload?: ElectronDownloadOptions + readonly electronDownload?: ElectronDownloadOptions | ElectronGetOptions | null /** * The branding used by Electron's distributables. This is needed if a fork has modified Electron's BRANDING.json file. diff --git a/packages/app-builder-lib/src/electron/ElectronFramework.ts b/packages/app-builder-lib/src/electron/ElectronFramework.ts index e9c1cc5ada7..dfad37711a7 100644 --- a/packages/app-builder-lib/src/electron/ElectronFramework.ts +++ b/packages/app-builder-lib/src/electron/ElectronFramework.ts @@ -1,5 +1,7 @@ -import { asArray, copyDir, DO_NOT_USE_HARD_LINKS, executeAppBuilder, isEmptyOrSpaces, log, MAX_FILE_REQUESTS, statOrNull, unlinkIfExists } from "builder-util" -import { emptyDir, readdir, rename, rm } from "fs-extra" +import { asArray, copyDir, DO_NOT_USE_HARD_LINKS, executeAppBuilder, exists, log, MAX_FILE_REQUESTS, statOrNull, unlinkIfExists } from "builder-util" +import { MultiProgress } from "electron-publish/out/multiProgress" +import { emptyDir, readdir, rename } from "fs-extra" +import * as fs from "fs/promises" import * as path from "path" import asyncPool from "tiny-async-pool" import { Configuration } from "../configuration" @@ -12,7 +14,8 @@ import { resolveFunction } from "../util/resolve" import { createMacApp } from "./electronMac" import { computeElectronVersion, getElectronVersionFromInstalled } from "./electronVersion" import { addWinAsarIntegrity } from "./electronWin" -import injectFFMPEG from "./injectFFMPEG" +import { downloadArtifact } from "../util/electronGet" +import { FFMPEGInjector } from "./injectFFMPEG" export type ElectronPlatformName = "darwin" | "linux" | "win32" | "mas" @@ -32,94 +35,23 @@ export function createBrandingOpts(opts: Configuration): Required { - await asyncPool(MAX_FILE_REQUESTS, await readdir(dir), async file => { - if (path.extname(file) !== langFileExt) { - return - } - - const language = path.basename(file, langFileExt) - if (!wantedLanguages.includes(language)) { - return rm(path.join(dir, file), { recursive: true, force: true }) +export async function createElectronFrameworkSupport(configuration: Configuration, packager: Packager): Promise { + let version = configuration.electronVersion + if (version == null) { + // for prepacked app asar no dev deps in the app.asar + if (packager.isPrepackedAppAsar) { + version = await getElectronVersionFromInstalled(packager.projectDir) + if (version == null) { + throw new Error(`Cannot compute electron version for prepacked asar`) } - return - }) - } - await Promise.all(dirs.map(deletedFiles)) - - function getLocalesConfig(options: BeforeCopyExtraFilesOptions) { - const { appOutDir, packager } = options - if (packager.platform === Platform.MAC) { - return { dirs: [packager.getResourcesDir(appOutDir), packager.getMacOsElectronFrameworkResourcesDir(appOutDir)], langFileExt: ".lproj" } + } else { + version = await computeElectronVersion(packager.projectDir) } - return { dirs: [path.join(packager.getResourcesDir(appOutDir), "..", "locales")], langFileExt: ".pak" } + configuration.electronVersion = version } + + const branding = createBrandingOpts(configuration) + return new ElectronFramework(branding.projectName, version, `${branding.productName}.app`) } class ElectronFramework implements Framework { @@ -132,6 +64,8 @@ class ElectronFramework implements Framework { // noinspection JSUnusedGlobalSymbols readonly isNpmRebuildRequired = true + readonly progress = process.stdout?.isTTY ? new MultiProgress() : null + constructor( readonly name: string, readonly version: string, @@ -148,135 +82,175 @@ class ElectronFramework implements Framework { } async prepareApplicationStageDirectory(options: PrepareApplicationStageDirectoryOptions) { - const downloadOptions = createDownloadOpts(options.packager.config, options.platformName, options.arch, this.version) - const shouldCleanup = await unpack(options, downloadOptions, this.distMacOsAppName) - await cleanupAfterUnpack(options, this.distMacOsAppName, shouldCleanup) + await this.unpack(options) if (options.packager.config.downloadAlternateFFmpeg) { - await injectFFMPEG(options, this.version) + await new FFMPEGInjector(this.progress, options, this.version, createBrandingOpts(options.packager.config)).inject() } } - beforeCopyExtraFiles(options: BeforeCopyExtraFilesOptions) { - return beforeCopyExtraFiles(options) - } -} - -export async function createElectronFrameworkSupport(configuration: Configuration, packager: Packager): Promise { - let version = configuration.electronVersion - if (version == null) { - // for prepacked app asar no dev deps in the app.asar - if (packager.isPrepackedAppAsar) { - version = await getElectronVersionFromInstalled(packager.projectDir) - if (version == null) { - throw new Error(`Cannot compute electron version for prepacked asar`) + async beforeCopyExtraFiles(options: BeforeCopyExtraFilesOptions) { + const { appOutDir, packager } = options + const electronBranding = createBrandingOpts(packager.config) + + if (packager.platform === Platform.LINUX) { + const linuxPackager = packager as LinuxPackager + const executable = path.join(appOutDir, linuxPackager.executableName) + await rename(path.join(appOutDir, electronBranding.projectName), executable) + } else if (packager.platform === Platform.WINDOWS) { + const executable = path.join(appOutDir, `${packager.appInfo.productFilename}.exe`) + await rename(path.join(appOutDir, `${electronBranding.projectName}.exe`), executable) + if (options.asarIntegrity) { + await addWinAsarIntegrity(executable, options.asarIntegrity) } } else { - version = await computeElectronVersion(packager.projectDir) + await createMacApp(packager as MacPackager, appOutDir, options.asarIntegrity, (options.platformName as ElectronPlatformName) === "mas") } - configuration.electronVersion = version } - const branding = createBrandingOpts(configuration) - return new ElectronFramework(branding.projectName, version, `${branding.productName}.app`) -} + private async unpack(prepareOptions: PrepareApplicationStageDirectoryOptions) { + const { platformName, arch } = prepareOptions + const zipFileName = `electron-v${this.version}-${platformName}-${arch}.zip` -/** - * Unpacks a custom or default Electron distribution into the app output directory. - */ -async function unpack(prepareOptions: PrepareApplicationStageDirectoryOptions, downloadOptions: ElectronDownloadOptions, distMacOsAppName: string): Promise { - const downloadUsingAdjustedConfig = (options: ElectronDownloadOptions) => { - return executeAppBuilder(["unpack-electron", "--configuration", JSON.stringify([options]), "--output", appOutDir, "--distMacOsAppName", distMacOsAppName]) - } + const dist = await this.resolveElectronDist(prepareOptions, zipFileName) - const copyUnpackedElectronDistribution = async (folderPath: string) => { - log.info({ electronDist: log.filePath(folderPath) }, "using custom unpacked Electron distribution") - const source = packager.getElectronSrcDir(folderPath) - const destination = packager.getElectronDestinationDir(appOutDir) - log.info({ source, destination }, "copying unpacked Electron") - await emptyDir(appOutDir) - await copyDir(source, destination, { - isUseHardLink: DO_NOT_USE_HARD_LINKS, - }) - return false + await this.copyOrDownloadElectronDist(dist, prepareOptions, zipFileName) + + await this.removeFiles(prepareOptions) } - const selectElectron = async (filepath: string) => { - const resolvedDist = path.isAbsolute(filepath) ? filepath : path.resolve(packager.projectDir, filepath) + private async resolveElectronDist(prepareOptions: PrepareApplicationStageDirectoryOptions, zipFileName: string) { + const { packager } = prepareOptions + + const electronDist = packager.config.electronDist || null + let dist: string | null = null + // check if supplied a custom electron distributable/fork/predownloaded directory + if (typeof electronDist === "string") { + let resolvedDist: string + // check if custom electron hook file for import resolving + if ((await statOrNull(electronDist))?.isFile() && !electronDist.endsWith(zipFileName)) { + const customElectronDist = await resolveFunction string)>( + packager.appInfo.type, + electronDist, + "electronDist" + ) + resolvedDist = await Promise.resolve(typeof customElectronDist === "function" ? customElectronDist(prepareOptions) : customElectronDist) + } else { + resolvedDist = electronDist + } + dist = path.isAbsolute(resolvedDist) ? resolvedDist : path.resolve(packager.projectDir, resolvedDist) + } + return dist + } - const electronDistStats = await statOrNull(resolvedDist) - if (!electronDistStats) { - throw new Error( - `The specified electronDist does not exist: ${resolvedDist}. Please provide a valid path to the Electron zip file, cache directory, or electron build directory.` + private async copyOrDownloadElectronDist(dist: string | null, prepareOptions: PrepareApplicationStageDirectoryOptions, zipFileName: string) { + const { packager, appOutDir, platformName, arch } = prepareOptions + const { + config: { electronDownload }, + } = packager + + if (dist != null) { + const source = path.isAbsolute(dist) ? dist : packager.getElectronSrcDir(dist) + const zipFilePath = path.join(source, zipFileName) + + const stats = await statOrNull(source) + if (stats?.isFile() && source.endsWith(".zip")) { + log.info({ dist: log.filePath(source) }, "using Electron zip") + dist = source + } else if (await exists(zipFilePath)) { + log.info({ dist: log.filePath(zipFilePath) }, "using Electron zip") + dist = zipFilePath + } else if (stats?.isDirectory()) { + const destination = packager.getElectronDestinationDir(appOutDir) + log.info({ source: log.filePath(source), destination: log.filePath(destination) }, "copying Electron build directory") + await emptyDir(appOutDir) + await copyDir(source, destination, { + isUseHardLink: DO_NOT_USE_HARD_LINKS, + }) + dist = null + } else { + const errorMessage = "Please provide a valid path to the Electron zip file, cache directory, or Electron build directory." + log.error({ searchDir: log.filePath(source) }, errorMessage) + throw new Error(errorMessage) + } + } else { + log.info({ zipFile: zipFileName }, "downloading Electron") + dist = await downloadArtifact( + { + electronDownload, + artifactName: "electron", + platformName, + arch, + version: this.version, + }, + this.progress ) } - if (resolvedDist.endsWith(".zip")) { - log.info({ zipFile: resolvedDist }, "using custom electronDist zip file") - await downloadUsingAdjustedConfig({ - ...downloadOptions, - cache: path.dirname(resolvedDist), // set custom directory to the zip file's directory - customFilename: path.basename(resolvedDist), // set custom filename to the zip file's name - }) - return false // do not clean up after unpacking, it's a custom bundle and we should respect its configuration/contents as required + if (dist?.endsWith(".zip")) { + await this.extractAndRenameElectron(dist, appOutDir) } + return dist + } - if (electronDistStats.isDirectory()) { - // backward compatibility: if electronDist is a directory, check for the default zip file inside it - const files = await readdir(resolvedDist) - if (files.includes(defaultZipName)) { - log.info({ electronDist: log.filePath(resolvedDist) }, "using custom electronDist directory") - await downloadUsingAdjustedConfig({ - ...downloadOptions, - cache: resolvedDist, - customFilename: defaultZipName, - }) - return false - } - // if we reach here, it means the provided electronDist is neither a zip file nor a directory with the default zip file - // e.g. we treat it as a custom already-unpacked Electron distribution - return await copyUnpackedElectronDistribution(resolvedDist) - } - throw new Error(`The specified electronDist is neither a zip file nor a directory: ${resolvedDist}. Please provide a valid path to the Electron zip file or cache directory.`) + private async extractAndRenameElectron(dist: string, appOutDir: string) { + log.debug(null, "extracting Electron zip") + await executeAppBuilder(["unzip", "--input", dist, "--output", appOutDir]) + log.debug(null, "Electron unpacked successfully") } - const { packager, appOutDir, platformName } = prepareOptions - const { version, arch } = downloadOptions - const defaultZipName = `electron-v${version}-${platformName}-${arch}.zip` + private async removeFiles(prepareOptions: PrepareApplicationStageDirectoryOptions) { + const out = prepareOptions.appOutDir + const isMac = prepareOptions.packager.platform === Platform.MAC + let resourcesPath = path.resolve(out, "resources") + if (isMac) { + resourcesPath = path.resolve(out, this.distMacOsAppName, "Contents", "Resources") + } - const electronDist = packager.config.electronDist - if (typeof electronDist === "string" && !isEmptyOrSpaces(electronDist)) { - return selectElectron(electronDist) + await unlinkIfExists(path.join(resourcesPath, "default_app.asar")) + await unlinkIfExists(path.join(resourcesPath, "inspector", ".htaccess")) + await unlinkIfExists(path.join(out, "version")) + if (!isMac) { + await rename(path.join(out, "LICENSE"), path.join(out, "LICENSE.electron.txt")).catch(() => { + /* ignore */ + }) + } + await this.removeUnusedLanguagesIfNeeded(prepareOptions, resourcesPath) } - let resolvedDist: string | null = null - try { - const electronDistHook: any = await resolveFunction(packager.appInfo.type, electronDist, "electronDist") - resolvedDist = typeof electronDistHook === "function" ? await Promise.resolve(electronDistHook(prepareOptions)) : electronDistHook - } catch (error: any) { - log.warn({ error }, "Failed to resolve electronDist, using default unpack logic") - } + async removeUnusedLanguagesIfNeeded(options: PrepareApplicationStageDirectoryOptions, resourcesPath: string) { + const { + packager: { config, platformSpecificBuildOptions, platform }, + } = options + const wantedLanguages = asArray(platformSpecificBuildOptions.electronLanguages || config.electronLanguages) + if (!wantedLanguages.length) { + return + } - if (resolvedDist == null) { - // if no custom electronDist is provided, use the default unpack logic - log.debug(null, "no custom electronDist provided, unpacking default Electron distribution") - await downloadUsingAdjustedConfig(downloadOptions) - return true // indicates that we should clean up after unpacking + const { dirs, langFileExt } = this.getLocalesConfig(platform, resourcesPath) + for (const dir of dirs) { + const contents = await readdir(dir) + await asyncPool(MAX_FILE_REQUESTS, contents, async file => { + if (path.extname(file) !== langFileExt) { + return + } + + const language = path.basename(file, langFileExt) + if (!wantedLanguages.includes(language)) { + return fs.rm(path.join(dir, file), { recursive: true, force: true }) + } + return + }) + } } - return selectElectron(resolvedDist) -} - -function cleanupAfterUnpack(prepareOptions: PrepareApplicationStageDirectoryOptions, distMacOsAppName: string, isFullCleanup: boolean) { - const out = prepareOptions.appOutDir - const isMac = prepareOptions.packager.platform === Platform.MAC - const resourcesPath = isMac ? path.join(out, distMacOsAppName, "Contents", "Resources") : path.join(out, "resources") - return Promise.all([ - isFullCleanup ? unlinkIfExists(path.join(resourcesPath, "default_app.asar")) : Promise.resolve(), - isFullCleanup ? unlinkIfExists(path.join(out, "version")) : Promise.resolve(), - isMac - ? Promise.resolve() - : rename(path.join(out, "LICENSE"), path.join(out, "LICENSE.electron.txt")).catch(() => { - /* ignore */ - }), - ]) + private getLocalesConfig(platform: Platform, resourcesPath: string) { + if (platform === Platform.MAC) { + const frameworkName = `${path.basename(this.distMacOsAppName, ".app")} Framework.framework` + return { + dirs: [resourcesPath, path.resolve(resourcesPath, "..", "Frameworks", frameworkName, "Resources")], + langFileExt: ".lproj", + } + } + return { dirs: [path.resolve(resourcesPath, "..", "locales")], langFileExt: ".pak" } + } } diff --git a/packages/app-builder-lib/src/electron/injectFFMPEG.ts b/packages/app-builder-lib/src/electron/injectFFMPEG.ts index 0d4815d47b4..ad239f7eb99 100644 --- a/packages/app-builder-lib/src/electron/injectFFMPEG.ts +++ b/packages/app-builder-lib/src/electron/injectFFMPEG.ts @@ -1,49 +1,81 @@ -import * as fs from "fs" +import { executeAppBuilder, log } from "builder-util" +import { MultiProgress } from "electron-publish/out/multiProgress" +import * as fs from "fs-extra" import * as path from "path" -import { ElectronPlatformName } from "./ElectronFramework" - -import { log } from "builder-util" -import { getBin } from "../binDownload" import { PrepareApplicationStageDirectoryOptions } from "../Framework" +import { downloadArtifact } from "../util/electronGet" +import { ElectronBrandingOptions } from "./ElectronFramework" +import { Platform } from "../core" -// NOTE: Adapted from https://github.com/MarshallOfSound/electron-packager-plugin-non-proprietary-codecs-ffmpeg to resolve dependency vulnerabilities -const downloadFFMPEG = async (electronVersion: string, platform: ElectronPlatformName, arch: string) => { - const ffmpegFileName = `ffmpeg-v${electronVersion}-${platform}-${arch}.zip` - const url = `https://github.com/electron/electron/releases/download/v${electronVersion}/${ffmpegFileName}` +export class FFMPEGInjector { + constructor( + private readonly progress: MultiProgress | null, + private readonly options: PrepareApplicationStageDirectoryOptions, + private readonly electronVersion: string, + private readonly branding: Required + ) {} - log.info({ file: ffmpegFileName }, "downloading non-proprietary FFMPEG") - return getBin(ffmpegFileName, url) -} + async inject() { + const libPath = + this.options.platformName === Platform.MAC.nodeName + ? path.join(this.options.appOutDir, `${this.branding.productName}.app`, `/Contents/Frameworks/${this.branding.productName} Framework.framework/Versions/A/Libraries`) + : this.options.appOutDir -const copyFFMPEG = (targetPath: string, platform: ElectronPlatformName) => (sourcePath: string) => { - let fileName = "ffmpeg.dll" - if (["darwin", "mas"].includes(platform)) { - fileName = "libffmpeg.dylib" - } else if (platform === "linux") { - fileName = "libffmpeg.so" + const ffmpegDir = await this.downloadFFMPEG() + return this.copyFFMPEG(libPath, ffmpegDir) } - const libPath = path.resolve(sourcePath, fileName) - const libTargetPath = path.resolve(targetPath, fileName) - log.info({ lib: log.filePath(libPath), target: log.filePath(libTargetPath) }, "copying non-proprietary FFMPEG") + private async downloadFFMPEG(): Promise { + const ffmpegFileName = `ffmpeg-v${this.electronVersion}-${this.options.platformName}-${this.options.arch}` - // If the source doesn't exist we have a problem - if (!fs.existsSync(libPath)) { - throw new Error(`Failed to find FFMPEG library file at path: ${libPath}`) - } + log.info({ ffmpegFileName }, "downloading") - // If we are copying to the source we can stop immediately - if (libPath !== libTargetPath) { - fs.copyFileSync(libPath, libTargetPath) - } - return libTargetPath -} + const { + packager: { + config: { electronDownload }, + }, + platformName, + arch, + } = this.options -export default function injectFFMPEG(options: PrepareApplicationStageDirectoryOptions, electrionVersion: string) { - let libPath = options.appOutDir - if (options.platformName === "darwin") { - libPath = path.resolve(options.appOutDir, "Electron.app/Contents/Frameworks/Electron Framework.framework/Versions/A/Libraries") + const file = await downloadArtifact( + { + electronDownload, + artifactName: "ffmpeg", + platformName, + arch, + version: this.electronVersion, + }, + this.progress + ) + + const ffmpegDir = await this.options.packager.info.tempDirManager.getTempDir({ prefix: "ffmpeg" }) + log.debug(null, "extracting FFMPEG zip") + await executeAppBuilder(["unzip", "--input", file, "--output", ffmpegDir]) + return ffmpegDir } - return downloadFFMPEG(electrionVersion, options.platformName, options.arch).then(copyFFMPEG(libPath, options.platformName)) + async copyFFMPEG(targetPath: string, sourcePath: string) { + let fileName = "ffmpeg.dll" + if (["darwin", "mas"].includes(this.options.platformName)) { + fileName = "libffmpeg.dylib" + } else if (this.options.platformName === "linux") { + fileName = "libffmpeg.so" + } + + const libPath = path.resolve(sourcePath, fileName) + const libTargetPath = path.resolve(targetPath, fileName) + log.info({ lib: log.filePath(libPath), target: libTargetPath }, "copying non-proprietary FFMPEG") + + // If the source doesn't exist we have a problem + if (!fs.existsSync(libPath)) { + throw new Error(`Failed to find FFMPEG library file at path: ${libPath}`) + } + + // If we are copying to the source we can stop immediately + if (libPath !== libTargetPath) { + await fs.copyFile(libPath, libTargetPath) + } + return libTargetPath + } } diff --git a/packages/app-builder-lib/src/index.ts b/packages/app-builder-lib/src/index.ts index b2c2b69707b..d0e896cd247 100644 --- a/packages/app-builder-lib/src/index.ts +++ b/packages/app-builder-lib/src/index.ts @@ -32,7 +32,8 @@ export { TargetConfiguration, TargetSpecificOptions, } from "./core" -export { ElectronBrandingOptions, ElectronDownloadOptions, ElectronPlatformName } from "./electron/ElectronFramework" +export { ElectronBrandingOptions, ElectronPlatformName } from "./electron/ElectronFramework" +export { ElectronDownloadOptions } from "./util/electronGet" export { AppXOptions } from "./options/AppXOptions" export { CommonWindowsInstallerConfiguration } from "./options/CommonWindowsInstallerConfiguration" export { FileAssociation } from "./options/FileAssociation" diff --git a/packages/app-builder-lib/src/macPackager.ts b/packages/app-builder-lib/src/macPackager.ts index 003dfc74ecb..fbb70e6b399 100644 --- a/packages/app-builder-lib/src/macPackager.ts +++ b/packages/app-builder-lib/src/macPackager.ts @@ -18,7 +18,6 @@ import { use, } from "builder-util" import { MemoLazy, Nullish } from "builder-util-runtime" -import * as fs from "fs/promises" import { mkdir, readdir } from "fs/promises" import { Lazy } from "lazy-val" import * as path from "path" @@ -36,6 +35,7 @@ import { isMacOsHighSierra } from "./util/macosVersion" import { getTemplatePath } from "./util/pathManager" import { resolveFunction } from "./util/resolve" import { expandMacro as doExpandMacro } from "./util/macroExpander" +import { writeFile } from "fs-extra" export type CustomMacSignOptions = SignOptions export type CustomMacSign = (configuration: CustomMacSignOptions, packager: MacPackager) => Promise @@ -139,7 +139,7 @@ export class MacPackager extends PlatformPackager { return super.doPack(config) } case Arch.universal: { - const outDirName = (arch: Arch) => `${appOutDir}-${Arch[arch]}-temp` + const outDirName = (arch: Arch) => this.info.tempDirManager.createTempDir({ prefix: `mac-${Arch[arch]}` }) const options = { ...config, options: { @@ -150,7 +150,7 @@ export class MacPackager extends PlatformPackager { } const x64Arch = Arch.x64 - const x64AppOutDir = outDirName(x64Arch) + const x64AppOutDir = await outDirName(x64Arch) await super.doPack({ ...options, appOutDir: x64AppOutDir, arch: x64Arch }) if (this.info.cancellationToken.cancelled) { @@ -158,7 +158,7 @@ export class MacPackager extends PlatformPackager { } const arm64Arch = Arch.arm64 - const arm64AppOutPath = outDirName(arm64Arch) + const arm64AppOutPath = await outDirName(arm64Arch) await super.doPack({ ...options, appOutDir: arm64AppOutPath, arch: arm64Arch }) if (this.info.cancellationToken.cancelled) { @@ -181,7 +181,7 @@ export class MacPackager extends PlatformPackager { const sourceCatalogPath = path.join(x64AppOutDir, appFile, "Contents/Resources/Assets.car") if (await exists(sourceCatalogPath)) { const targetCatalogPath = path.join(arm64AppOutPath, appFile, "Contents/Resources/Assets.car") - await fs.copyFile(sourceCatalogPath, targetCatalogPath) + await copyFile(sourceCatalogPath, targetCatalogPath) } const { makeUniversalApp } = require("@electron/universal") @@ -194,8 +194,6 @@ export class MacPackager extends PlatformPackager { singleArchFiles: platformSpecificBuildOptions.singleArchFiles, x64ArchFiles: platformSpecificBuildOptions.x64ArchFiles, }) - await fs.rm(x64AppOutDir, { recursive: true, force: true }) - await fs.rm(arm64AppOutPath, { recursive: true, force: true }) // Give users a final opportunity to perform things on the combined universal package before signing const packContext: AfterPackContext = { @@ -559,7 +557,7 @@ export class MacPackager extends PlatformPackager { // Create and setup the asset catalog appPlist.CFBundleIconName = "Icon" - await fs.writeFile(path.join(resourcesPath, "Assets.car"), assetCatalog) + await writeFile(path.join(resourcesPath, "Assets.car"), assetCatalog) } } diff --git a/packages/app-builder-lib/src/platformPackager.ts b/packages/app-builder-lib/src/platformPackager.ts index 4889b51722e..cd6717a1f6b 100644 --- a/packages/app-builder-lib/src/platformPackager.ts +++ b/packages/app-builder-lib/src/platformPackager.ts @@ -198,11 +198,8 @@ export abstract class PlatformPackager } private getExtraFileMatchers(isResources: boolean, appOutDir: string, options: GetFileMatchersOptions): Array | null { - const base = isResources - ? this.getResourcesDir(appOutDir) - : this.platform === Platform.MAC - ? path.join(appOutDir, `${this.appInfo.productFilename}.app`, "Contents") - : appOutDir + const outDir = this.platform === Platform.MAC ? path.join(appOutDir, this.info.framework.distMacOsAppName, "Contents") : appOutDir + const base = isResources ? this.getResourcesDir(appOutDir) : outDir return getFileMatchers(this.config, isResources ? "extraResources" : "extraFiles", base, options) } diff --git a/packages/app-builder-lib/src/util/electronGet.ts b/packages/app-builder-lib/src/util/electronGet.ts new file mode 100644 index 00000000000..2fc834ecc02 --- /dev/null +++ b/packages/app-builder-lib/src/util/electronGet.ts @@ -0,0 +1,133 @@ +import { downloadArtifact as _downloadArtifact, ElectronDownloadCacheMode, ElectronPlatformArtifactDetails, GotDownloaderOptions, MirrorOptions } from "@electron/get" +import { getUserDefinedCacheDir, log, PADDING } from "builder-util" +import { MultiProgress } from "electron-publish/out/multiProgress" +import { ElectronPlatformName } from "../electron/ElectronFramework" + +const configToPromise = new Map>() + +export type ElectronGetOptions = Omit< + ElectronPlatformArtifactDetails, + | "platform" + | "arch" + | "version" + | "artifactName" + | "artifactSuffix" + | "customFilename" + | "tempDirectory" + | "downloader" + | "cacheMode" + | "cacheRoot" + | "downloadOptions" + | "mirrorOptions" // to be added below +> & { + mirrorOptions: Omit +} + +type ArtifactDownloadOptions = { + electronDownload?: ElectronGetOptions | ElectronDownloadOptions | null + artifactName: string + platformName: string + arch: string + version: string + cacheDir?: string +} + +export interface ElectronDownloadOptions { + // https://github.com/electron-userland/electron-builder/issues/3077 + // must be optional + version?: string + + /** + * The [cache location](https://github.com/electron-userland/electron-download#cache-location). + */ + cache?: string | null + + /** + * The mirror. + */ + mirror?: string | null + + /** @private */ + customDir?: string | null + /** @private */ + customFilename?: string | null + + strictSSL?: boolean + isVerifyChecksum?: boolean + + platform?: ElectronPlatformName + arch?: string +} + +export async function downloadArtifact(config: ArtifactDownloadOptions, progress: MultiProgress | null) { + // Old cache is ignored if cache environment variable changes + const cacheDir = config.cacheDir || (await getUserDefinedCacheDir()) + const cacheName = JSON.stringify({ ...config, cacheDir }) + + let promise = configToPromise.get(cacheName) // if rejected, we will try to download again + + if (promise != null) { + return await promise + } + + promise = doDownloadArtifact(config, cacheDir, progress) + configToPromise.set(cacheName, promise) + return await promise +} + +async function doDownloadArtifact(config: ArtifactDownloadOptions, cacheDir: string | undefined, progress: MultiProgress | null) { + const { electronDownload, arch, version, platformName: platform, artifactName } = config + + let artifactConfig: ElectronPlatformArtifactDetails = { + cacheRoot: cacheDir, + platform, + arch, + version, + artifactName, + } + log.debug(artifactConfig, "artifact download initiated") + + if (electronDownload != null) { + // determine whether electronDownload is ElectronGetOptions or ElectronDownloadOptions + if (Object.hasOwnProperty.call(electronDownload, "mirrorOptions")) { + const options = electronDownload as ElectronGetOptions + artifactConfig = { ...artifactConfig, ...options } + } else { + // legacy + const { mirror, customDir, cache, customFilename, isVerifyChecksum, platform: platformName, arch: downloadArch } = electronDownload as ElectronDownloadOptions + artifactConfig = { + ...artifactConfig, + unsafelyDisableChecksums: isVerifyChecksum === false, + cacheRoot: cache ?? cacheDir, + cacheMode: cache != null ? ElectronDownloadCacheMode.ReadOnly : ElectronDownloadCacheMode.ReadWrite, + mirrorOptions: { + mirror: mirror || undefined, + customDir: customDir || undefined, + customFilename: customFilename || undefined, + }, + } + if (platformName != null) { + artifactConfig.platform = platformName + } + if (downloadArch != null) { + artifactConfig.arch = downloadArch + } + } + } + + const progressBar = progress?.createBar(`${" ".repeat(PADDING + 2)}[:bar] :percent | ${artifactName}`, { total: 100 }) + progressBar?.render() + + const downloadOptions: GotDownloaderOptions = { + getProgressCallback: progress => { + progressBar?.update(progress.percent) + return Promise.resolve() + }, + } + + const dist = await _downloadArtifact({ ...artifactConfig, downloadOptions }) + progressBar?.update(100) + progressBar?.terminate() + + return dist +} diff --git a/packages/builder-util/src/util.ts b/packages/builder-util/src/util.ts index 360631a4266..b3dead1193b 100644 --- a/packages/builder-util/src/util.ts +++ b/packages/builder-util/src/util.ts @@ -10,6 +10,8 @@ import * as path from "path" import { install as installSourceMap } from "source-map-support" import { getPath7za } from "./7za" import { debug, log } from "./log" +import { exists } from "./fs" +import { mkdir } from "fs-extra" if (process.env.JEST_WORKER_ID == null) { installSourceMap() @@ -307,6 +309,18 @@ export function isTokenCharValid(token: string) { return /^[.\w/=+-]+$/.test(token) } +export async function getUserDefinedCacheDir() { + let cacheEnv = process.env.ELECTRON_BUILDER_CACHE + if (!isEmptyOrSpaces(cacheEnv)) { + cacheEnv = path.resolve(cacheEnv) + if (!(await exists(cacheEnv))) { + await mkdir(cacheEnv) + } + return cacheEnv + } + return undefined +} + export function addValue(map: Map>, key: K, value: T) { const list = map.get(key) if (list == null) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ac6e755f4b6..48728f98ad1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -110,6 +110,9 @@ importers: '@electron/fuses': specifier: ^1.8.0 version: 1.8.0 + '@electron/get': + specifier: ^3.1.0 + version: 3.1.0 '@electron/notarize': specifier: 2.5.0 version: 2.5.0 @@ -1625,6 +1628,10 @@ packages: resolution: {integrity: sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==} engines: {node: '>=12'} + '@electron/get@3.1.0': + resolution: {integrity: sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ==} + engines: {node: '>=14'} + '@electron/notarize@2.5.0': resolution: {integrity: sha512-jNT8nwH1f9X5GEITXaQ8IF/KdskvIkOFfB2CvwumsveVidzpSc+mvhhTMdAGSYF3O+Nq49lJ7y+ssODRXu06+A==} engines: {node: '>= 10.0.0'} @@ -6626,6 +6633,20 @@ snapshots: transitivePeerDependencies: - supports-color + '@electron/get@3.1.0': + dependencies: + debug: 4.4.0 + env-paths: 2.2.1 + fs-extra: 8.1.0 + got: 11.8.6 + progress: 2.0.3 + semver: 6.3.1 + sumchecker: 3.0.1 + optionalDependencies: + global-agent: 3.0.0 + transitivePeerDependencies: + - supports-color + '@electron/notarize@2.5.0': dependencies: debug: 4.4.0 diff --git a/scripts/fix-schema.js b/scripts/fix-schema.js index c0cd8c95c26..5a4277d33fb 100644 --- a/scripts/fix-schema.js +++ b/scripts/fix-schema.js @@ -9,20 +9,21 @@ delete o.typeof o.type = "object" schema.definitions.OutgoingHttpHeaders.additionalProperties = { - "anyOf": [ + anyOf: [ { - "items": { - "type": "string" + items: { + type: "string", }, - "type": "array" + type: "array", }, { - "type": [ - "string", - "number" - ] - } - ] + type: ["string", "number"], + }, + ], +} + +schema.definitions.ElectronDownloadOptions.properties.checksums = { + type: "object", } o = schema.definitions.SnapOptions.properties.environment.anyOf[0] = { @@ -31,8 +32,8 @@ o = schema.definitions.SnapOptions.properties.environment.anyOf[0] = { } o = schema.properties["$schema"] = { - "description": "JSON Schema for this document.", - "type": ["null", "string"], + description: "JSON Schema for this document.", + type: ["null", "string"], } fs.writeFileSync(schemaFile, JSON.stringify(schema, null, 2)) diff --git a/test/snapshots/ExtraBuildResourcesTest.js.snap b/test/snapshots/ExtraBuildResourcesTest.js.snap index 7edffa7e925..4c8d70c8dcc 100644 --- a/test/snapshots/ExtraBuildResourcesTest.js.snap +++ b/test/snapshots/ExtraBuildResourcesTest.js.snap @@ -4,9 +4,9 @@ exports[`custom buildResources and output dirs: linux 1`] = ` { "linux": [ { - "arch": "x64", - "file": "Test App ßW-1.1.0.AppImage", - "safeArtifactName": "TestApp-1.1.0-x86_64.AppImage", + "arch": "arm64", + "file": "Test App ßW-1.1.0-arm64.AppImage", + "safeArtifactName": "TestApp-1.1.0-arm64.AppImage", "updateInfo": { "blockMapSize": "@blockMapSize", "sha512": "@sha512", @@ -17,17 +17,11 @@ exports[`custom buildResources and output dirs: linux 1`] = ` } `; -exports[`custom buildResources and output dirs: mac 1`] = ` -{ - "mac": [], -} -`; - exports[`custom buildResources and output dirs: win 1`] = ` { "win": [ { - "arch": "x64", + "arch": "arm64", "file": "Test App ßW Setup 1.1.0.exe", "safeArtifactName": "TestApp-Setup-1.1.0.exe", }, @@ -41,6 +35,12 @@ exports[`do not exclude build entirely (respect files) 1`] = ` } `; +exports[`electronDist as callback function for path to local electron zipped artifact 1`] = ` +{ + "linux": [], +} +`; + exports[`electronDist as callback function for path to local folder with electron builds zipped 1`] = ` { "linux": [], @@ -57,7 +57,6 @@ exports[`electronDist as callback function for path to locally unzipped 2`] = ` [ "LICENSE.electron.txt", "LICENSES.chromium.html", - "Test App ßW", "chrome-sandbox", "chrome_100_percent.pak", "chrome_200_percent.pak", @@ -72,8 +71,8 @@ exports[`electronDist as callback function for path to locally unzipped 2`] = ` "resources", "resources.pak", "snapshot_blob.bin", + "testapp", "v8_context_snapshot.bin", - "version", "vk_swiftshader_icd.json", ] `; @@ -118,6 +117,21 @@ exports[`electronDist as standard path to node_modules electron 2`] = ` exports[`override targets in the config - only arch 1`] = ` { "win": [ + { + "arch": "ia32", + "file": "beta-TestApp.exe", + "updateInfo": { + "sha512": "@sha512", + "size": "@size", + }, + }, + { + "file": "beta-TestApp.exe.blockmap", + "updateInfo": { + "sha512": "@sha512", + "size": "@size", + }, + }, { "file": "beta.yml", "fileContent": { @@ -134,21 +148,6 @@ exports[`override targets in the config - only arch 1`] = ` "version": "1.0.0-beta.1", }, }, - { - "arch": "ia32", - "file": "beta-TestApp.exe", - "updateInfo": { - "sha512": "@sha512", - "size": "@size", - }, - }, - { - "file": "beta-TestApp.exe.blockmap", - "updateInfo": { - "sha512": "@sha512", - "size": "@size", - }, - }, ], } `; diff --git a/test/src/ExtraBuildResourcesTest.ts b/test/src/ExtraBuildResourcesTest.ts index 3652f279f6a..a5ad1efe154 100644 --- a/test/src/ExtraBuildResourcesTest.ts +++ b/test/src/ExtraBuildResourcesTest.ts @@ -1,14 +1,15 @@ +import { downloadArtifact } from "app-builder-lib/src/util/electronGet" import { Arch, build, PackagerOptions, Platform } from "electron-builder" import * as fs from "fs" +import { readdir } from "fs/promises" import * as path from "path" +import { TmpDir } from "temp-file" +import * as unzipper from "unzipper" +import { ExpectStatic } from "vitest" import { assertThat } from "./helpers/fileAssert" import { app, assertPack, linuxDirTarget, modifyPackageJson } from "./helpers/packTester" -import { ELECTRON_VERSION, getElectronCacheDir } from "./helpers/testConfig" +import { ELECTRON_VERSION } from "./helpers/testConfig" import { expectUpdateMetadata } from "./helpers/winHelper" -import { ExpectStatic } from "vitest" -import * as unzipper from "unzipper" -import { TmpDir } from "temp-file" -import { readdir } from "fs/promises" function createBuildResourcesTest(expect: ExpectStatic, packagerOptions: PackagerOptions) { return app( @@ -174,25 +175,52 @@ test.ifDevOrWinCi("override targets in the config - only arch", ({ expect }) => // test on all CI to check path separators test("do not exclude build entirely (respect files)", ({ expect }) => assertPack(expect, "test-app-build-sub", { targets: linuxDirTarget })) -test.ifNotWindows("electronDist as path to local folder with electron builds zipped ", ({ expect }) => - app(expect, { - targets: linuxDirTarget, +test.ifNotWindows("electronDist as path to local folder with electron builds zipped ", async ({ expect }) => { + const tmpDir = new TmpDir() + const cacheDir = await tmpDir.createTempDir({ prefix: "electronDistCache" }) + const targets = Platform.LINUX.createTarget("dir", Arch.x64) + const file = await downloadArtifact( + { + artifactName: "electron", + platformName: Platform.LINUX.nodeName, + arch: "x64", + version: ELECTRON_VERSION, + cacheDir, + }, + null + ) + await app(expect, { + targets, config: { - electronDist: getElectronCacheDir(), + electronDist: path.dirname(file), }, }) -) + await tmpDir.cleanup() +}) -test.ifNotWindows("electronDist as callback function for path to local folder with electron builds zipped ", ({ expect }) => - app(expect, { +test.ifNotWindows("electronDist as callback function for path to local electron zipped artifact ", async ({ expect }) => { + await app(expect, { targets: linuxDirTarget, config: { - electronDist: _context => { - return Promise.resolve(getElectronCacheDir()) + electronDist: async context => { + const { platformName, arch, version, packager } = context + + const cacheDir = await packager.info.tempDirManager.createTempDir({ prefix: "electronDistCache" }) + const file = await downloadArtifact( + { + artifactName: "electron", + platformName, + arch, + version, + cacheDir, + }, + null + ) + return file }, }, }) -) +}) test.ifLinux("electronDist as standard path to node_modules electron", ({ expect }) => { return app( diff --git a/test/src/mac/macPackagerTest.ts b/test/src/mac/macPackagerTest.ts index c0c2d0f3c31..0e5aca229c1 100644 --- a/test/src/mac/macPackagerTest.ts +++ b/test/src/mac/macPackagerTest.ts @@ -152,7 +152,7 @@ test.ifMac("electronDist", ({ expect }) => }, }, {}, - error => expect(error.message).toContain("Please provide a valid path to the Electron zip file, cache directory, or electron build directory.") + error => expect(error.message).toContain("Please provide a valid path to the Electron zip file, cache directory, or Electron build directory.") ) ) diff --git a/test/src/windows/winCodeSignTest.ts b/test/src/windows/winCodeSignTest.ts index 9a2622a2ce4..51f209dd6d5 100644 --- a/test/src/windows/winCodeSignTest.ts +++ b/test/src/windows/winCodeSignTest.ts @@ -112,7 +112,7 @@ test("electronDist", ({ expect }) => }, }, {}, - error => expect(error.message).toContain("Please provide a valid path to the Electron zip file, cache directory, or electron build directory.") + error => expect(error.message).toContain("Please provide a valid path to the Electron zip file, cache directory, or Electron build directory.") )) test("azure signing without credentials", ({ expect }) =>