@electron-forge/core
Version:
A complete tool for building modern Electron applications
760 lines (717 loc) • 26.5 kB
text/typescript
import path from 'node:path';
import { promisify } from 'node:util';
import { getHostArch } from '@electron/get';
import {
FinalizePackageTargetsHookFunction,
HookFunction,
Options,
packager,
TargetDefinition,
} from '@electron/packager';
import {
getElectronVersion,
listrCompatibleRebuildHook,
} from '@electron-forge/core-utils';
import {
ForgeArch,
ForgeListrTask,
ForgeListrTaskDefinition,
ForgeListrTaskFn,
ForgePlatform,
ResolvedForgeConfig,
} from '@electron-forge/shared-types';
import { autoTrace, delayTraceTillSignal } from '@electron-forge/tracer';
import chalk from 'chalk';
import debug from 'debug';
import glob from 'fast-glob';
import fs from 'fs-extra';
import { Listr, PRESET_TIMER } from 'listr2';
import getForgeConfig from '../util/forge-config';
import { getHookListrTasks, runHook } from '../util/hook';
import importSearch from '../util/import-search';
import { warn } from '../util/messages';
import getCurrentOutDir from '../util/out-dir';
import { readMutatedPackageJson } from '../util/read-package-json';
import resolveDir from '../util/resolve-dir';
const d = debug('electron-forge:packager');
/**
* Resolves hooks if they are a path to a file (instead of a `Function`).
*/
async function resolveHooks<F = HookFunction>(
hooks: (string | F)[] | undefined,
dir: string,
) {
if (hooks) {
return await Promise.all(
hooks.map(async (hook) =>
typeof hook === 'string'
? ((await importSearch<F>(dir, [hook])) as F)
: hook,
),
);
}
return [];
}
type DoneFunction = (err?: Error) => void;
type PromisifiedHookFunction = (
buildPath: string,
electronVersion: string,
platform: string,
arch: string,
) => Promise<void>;
type PromisifiedFinalizePackageTargetsHookFunction = (
targets: TargetDefinition[],
) => Promise<void>;
/**
* @deprecated Only use until \@electron/packager publishes a new major version with promise based hooks
*/
function hidePromiseFromPromisify<P extends unknown[]>(
fn: (...args: P) => Promise<void>,
): (...args: P) => void {
return (...args: P) => {
void fn(...args);
};
}
/**
* Runs given hooks sequentially by mapping them to promises and iterating
* through while awaiting
*/
function sequentialHooks(hooks: HookFunction[]): PromisifiedHookFunction[] {
return [
hidePromiseFromPromisify(
async (
buildPath: string,
electronVersion: string,
platform: string,
arch: string,
done: DoneFunction,
) => {
for (const hook of hooks) {
try {
await promisify(hook)(buildPath, electronVersion, platform, arch);
} catch (err) {
d('hook failed:', hook.toString(), err);
return done(err as Error);
}
}
done();
},
),
] as PromisifiedHookFunction[];
}
function sequentialFinalizePackageTargetsHooks(
hooks: FinalizePackageTargetsHookFunction[],
): PromisifiedFinalizePackageTargetsHookFunction[] {
return [
hidePromiseFromPromisify(
async (targets: TargetDefinition[], done: DoneFunction) => {
for (const hook of hooks) {
try {
await promisify(hook)(targets);
} catch (err) {
return done(err as Error);
}
}
done();
},
),
] as PromisifiedFinalizePackageTargetsHookFunction[];
}
type PackageContext = {
dir: string;
forgeConfig: ResolvedForgeConfig;
packageJSON: any;
calculatedOutDir: string;
packagerPromise: Promise<string[]>;
targets: InternalTargetDefinition[];
};
type InternalTargetDefinition = TargetDefinition & {
forUniversal?: boolean;
};
type PackageResult = TargetDefinition & {
packagedPath: string;
};
export interface PackageOptions {
/**
* The path to the app to package
*/
dir?: string;
/**
* Whether to use sensible defaults or prompt the user visually
*/
interactive?: boolean;
/**
* The target arch
*/
arch?: ForgeArch;
/**
* The target platform.
*/
platform?: ForgePlatform;
/**
* The path to the output directory for packaged apps
*/
outDir?: string;
}
export const listrPackage = (
childTrace: typeof autoTrace,
{
dir: providedDir = process.cwd(),
interactive = false,
arch = getHostArch() as ForgeArch,
platform = process.platform as ForgePlatform,
outDir,
}: PackageOptions,
) => {
const runner = new Listr<PackageContext>(
[
{
title: 'Preparing to package application',
task: childTrace<Parameters<ForgeListrTaskFn<PackageContext>>>(
{ name: 'package-prepare', category: '@electron-forge/core' },
async (_, ctx) => {
const resolvedDir = await resolveDir(providedDir);
if (!resolvedDir) {
throw new Error(
'Failed to locate compilable Electron application',
);
}
ctx.dir = resolvedDir;
ctx.forgeConfig = await getForgeConfig(resolvedDir);
ctx.packageJSON = await readMutatedPackageJson(
resolvedDir,
ctx.forgeConfig,
);
if (!ctx.packageJSON.main) {
throw new Error(
'packageJSON.main must be set to a valid entry point for your Electron app',
);
}
ctx.calculatedOutDir =
outDir || getCurrentOutDir(resolvedDir, ctx.forgeConfig);
},
),
},
{
title: 'Running packaging hooks',
task: childTrace<Parameters<ForgeListrTaskFn<PackageContext>>>(
{ name: 'run-packaging-hooks', category: '@electron-forge/core' },
async (childTrace, { forgeConfig }, task) => {
return delayTraceTillSignal(
childTrace,
task.newListr([
{
title: `Running ${chalk.yellow('generateAssets')} hook`,
task: childTrace<Parameters<ForgeListrTaskFn>>(
{
name: 'run-generateAssets-hook',
category: '@electron-forge/core',
},
async (childTrace, _, task) => {
return delayTraceTillSignal(
childTrace,
task.newListr(
await getHookListrTasks(
childTrace,
forgeConfig,
'generateAssets',
platform,
arch,
),
),
'run',
);
},
),
},
{
title: `Running ${chalk.yellow('prePackage')} hook`,
task: childTrace<Parameters<ForgeListrTaskFn>>(
{
name: 'run-prePackage-hook',
category: '@electron-forge/core',
},
async (childTrace, _, task) => {
return delayTraceTillSignal(
childTrace,
task.newListr(
await getHookListrTasks(
childTrace,
forgeConfig,
'prePackage',
platform,
arch,
),
),
'run',
);
},
),
},
]),
'run',
);
},
),
},
{
title: 'Packaging application',
task: childTrace<Parameters<ForgeListrTaskFn<PackageContext>>>(
{ name: 'packaging-application', category: '@electron-forge/core' },
async (childTrace, ctx, task) => {
const { calculatedOutDir, forgeConfig, packageJSON } = ctx;
const getTargetKey = (target: TargetDefinition) =>
`${target.platform}/${target.arch}`;
task.output = 'Determining targets...';
type StepDoneSignalMap = Map<string, (() => void)[]>;
const signalCopyDone: StepDoneSignalMap = new Map();
const signalRebuildDone: StepDoneSignalMap = new Map();
const signalPackageDone: StepDoneSignalMap = new Map();
const rejects: ((err: any) => void)[] = [];
const signalDone = (
map: StepDoneSignalMap,
target: TargetDefinition,
) => {
map.get(getTargetKey(target))?.pop()?.();
};
const addSignalAndWait = async (
map: StepDoneSignalMap,
target: TargetDefinition,
) => {
const targetKey = getTargetKey(target);
await new Promise<void>((resolve, reject) => {
rejects.push(reject);
map.set(
targetKey,
(map.get(targetKey) || []).concat([resolve]),
);
});
};
let provideTargets: (targets: TargetDefinition[]) => void;
const targetsPromise = new Promise<InternalTargetDefinition[]>(
(resolve, reject) => {
provideTargets = resolve;
rejects.push(reject);
},
);
const rebuildTasks = new Map<
string,
Promise<ForgeListrTask<never>>[]
>();
const signalRebuildStart = new Map<
string,
((task: ForgeListrTask<never>) => void)[]
>();
const afterFinalizePackageTargetsHooks: FinalizePackageTargetsHookFunction[] =
[
(targets, done) => {
provideTargets(targets);
done();
},
...(await resolveHooks(
forgeConfig.packagerConfig.afterFinalizePackageTargets,
ctx.dir,
)),
];
const pruneEnabled =
!('prune' in forgeConfig.packagerConfig) ||
forgeConfig.packagerConfig.prune;
const afterCopyHooks: HookFunction[] = [
hidePromiseFromPromisify(
async (buildPath, electronVersion, platform, arch, done) => {
signalDone(signalCopyDone, { platform, arch });
done();
},
),
hidePromiseFromPromisify(
async (buildPath, electronVersion, pPlatform, pArch, done) => {
const bins = await glob(path.join(buildPath, '**/.bin/**/*'));
for (const bin of bins) {
await fs.remove(bin);
}
done();
},
),
hidePromiseFromPromisify(
async (buildPath, electronVersion, pPlatform, pArch, done) => {
await runHook(
forgeConfig,
'packageAfterCopy',
buildPath,
electronVersion,
pPlatform,
pArch,
);
done();
},
),
hidePromiseFromPromisify(
async (buildPath, electronVersion, pPlatform, pArch, done) => {
const targetKey = getTargetKey({
platform: pPlatform,
arch: pArch,
});
await listrCompatibleRebuildHook(
buildPath,
electronVersion,
pPlatform,
pArch,
forgeConfig.rebuildConfig,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
await rebuildTasks.get(targetKey)!.pop()!,
);
signalRebuildDone.get(targetKey)?.pop()?.();
done();
},
),
hidePromiseFromPromisify(
async (buildPath, electronVersion, pPlatform, pArch, done) => {
const copiedPackageJSON = await readMutatedPackageJson(
buildPath,
forgeConfig,
);
if (
copiedPackageJSON.config &&
copiedPackageJSON.config.forge
) {
delete copiedPackageJSON.config.forge;
}
await fs.writeJson(
path.resolve(buildPath, 'package.json'),
copiedPackageJSON,
{ spaces: 2 },
);
done();
},
),
...(await resolveHooks(
forgeConfig.packagerConfig.afterCopy,
ctx.dir,
)),
];
const afterCompleteHooks: HookFunction[] = [
hidePromiseFromPromisify(
async (buildPath, electronVersion, pPlatform, pArch, done) => {
signalPackageDone
.get(getTargetKey({ platform: pPlatform, arch: pArch }))
?.pop()?.();
done();
},
),
...(await resolveHooks(
forgeConfig.packagerConfig.afterComplete,
ctx.dir,
)),
];
const afterPruneHooks = [];
if (pruneEnabled) {
afterPruneHooks.push(
...(await resolveHooks(
forgeConfig.packagerConfig.afterPrune,
ctx.dir,
)),
);
}
afterPruneHooks.push(
hidePromiseFromPromisify(
async (buildPath, electronVersion, pPlatform, pArch, done) => {
await runHook(
forgeConfig,
'packageAfterPrune',
buildPath,
electronVersion,
pPlatform,
pArch,
);
done();
},
) as HookFunction,
);
const afterExtractHooks = [
hidePromiseFromPromisify(
async (buildPath, electronVersion, pPlatform, pArch, done) => {
await runHook(
forgeConfig,
'packageAfterExtract',
buildPath,
electronVersion,
pPlatform,
pArch,
);
done();
},
) as HookFunction,
];
afterExtractHooks.push(
...(await resolveHooks(
forgeConfig.packagerConfig.afterExtract,
ctx.dir,
)),
);
type PackagerArch = Exclude<ForgeArch, 'arm'>;
const packageOpts: Options = {
asar: false,
overwrite: true,
ignore: [/^\/out\//g],
quiet: true,
...forgeConfig.packagerConfig,
dir: ctx.dir,
arch: arch as PackagerArch,
platform,
afterFinalizePackageTargets:
sequentialFinalizePackageTargetsHooks(
afterFinalizePackageTargetsHooks,
),
afterComplete: sequentialHooks(afterCompleteHooks),
afterCopy: sequentialHooks(afterCopyHooks),
afterExtract: sequentialHooks(afterExtractHooks),
afterPrune: sequentialHooks(afterPruneHooks),
out: calculatedOutDir,
electronVersion: await getElectronVersion(ctx.dir, packageJSON),
};
if (packageOpts.all) {
throw new Error(
'config.forge.packagerConfig.all is not supported by Electron Forge',
);
}
if (!packageJSON.version && !packageOpts.appVersion) {
warn(
interactive,
chalk.yellow(
'Please set "version" or "config.forge.packagerConfig.appVersion" in your application\'s package.json so auto-updates work properly',
),
);
}
if (packageOpts.prebuiltAsar) {
throw new Error(
'config.forge.packagerConfig.prebuiltAsar is not supported by Electron Forge',
);
}
d('packaging with options', packageOpts);
ctx.packagerPromise = packager(packageOpts);
// Handle error by failing this task
// rejects is populated by the reject handlers for every
// signal based promise in every subtask
ctx.packagerPromise.catch((err) => {
for (const reject of rejects) {
reject(err);
}
});
const targets = await targetsPromise;
// Copy the resolved targets into the context for later
ctx.targets = [...targets];
// If we are targetting a universal build we need to add the "fake"
// x64 and arm64 builds into the list of targets so that we can
// show progress for those
for (const target of targets) {
if (target.arch === 'universal') {
targets.push(
{
platform: target.platform,
arch: 'x64',
forUniversal: true,
},
{
platform: target.platform,
arch: 'arm64',
forUniversal: true,
},
);
}
}
// Populate rebuildTasks with promises that resolve with the rebuild tasks
// that will eventually run
for (const target of targets) {
// Skip universal tasks as they do not have rebuild sub-tasks
if (target.arch === 'universal') continue;
const targetKey = getTargetKey(target);
rebuildTasks.set(
targetKey,
(rebuildTasks.get(targetKey) || []).concat([
new Promise((resolve) => {
signalRebuildStart.set(
targetKey,
(signalRebuildStart.get(targetKey) || []).concat([
resolve,
]),
);
}),
]),
);
}
d('targets:', targets);
return delayTraceTillSignal(
childTrace,
task.newListr(
targets.map(
(target): ForgeListrTaskDefinition =>
target.arch === 'universal'
? {
title: `Stitching ${chalk.cyan(`${target.platform}/x64`)} and ${chalk.cyan(`${target.platform}/arm64`)} into a ${chalk.green(
`${target.platform}/universal`,
)} package`,
task: async () => {
await addSignalAndWait(signalPackageDone, target);
},
rendererOptions: {
timer: { ...PRESET_TIMER },
},
}
: {
title: `Packaging for ${chalk.cyan(target.arch)} on ${chalk.cyan(target.platform)}${
target.forUniversal
? chalk.italic(' (for universal package)')
: ''
}`,
task: childTrace<Parameters<ForgeListrTaskFn<never>>>(
{
name: `package-app-${target.platform}-${target.arch}${target.forUniversal ? '-universal-tmp' : ''}`,
category: '@electron-forge/core',
extraDetails: {
arch: target.arch,
platform: target.platform,
},
newRoot: true,
},
async (childTrace, _, task) => {
return delayTraceTillSignal(
childTrace,
task.newListr(
[
{
title: 'Copying files',
task: childTrace(
{
name: 'copy-files',
category: '@electron-forge/core',
},
async () => {
await addSignalAndWait(
signalCopyDone,
target,
);
},
),
},
{
title: 'Preparing native dependencies',
task: childTrace(
{
name: 'prepare-native-dependencies',
category: '@electron-forge/core',
},
async (_, __, task) => {
signalRebuildStart
.get(getTargetKey(target))
?.pop()?.(task);
await addSignalAndWait(
signalRebuildDone,
target,
);
},
),
rendererOptions: {
persistentOutput: true,
bottomBar: Infinity,
timer: { ...PRESET_TIMER },
},
},
{
title: 'Finalizing package',
task: childTrace(
{
name: 'finalize-package',
category: '@electron-forge/core',
},
async () => {
await addSignalAndWait(
signalPackageDone,
target,
);
},
),
},
],
{
rendererOptions: {
collapseSubtasks: true,
collapseErrors: false,
},
},
),
'run',
);
},
),
rendererOptions: {
timer: { ...PRESET_TIMER },
},
},
),
{
concurrent: true,
rendererOptions: {
collapseSubtasks: false,
collapseErrors: false,
},
},
),
'run',
);
},
),
},
{
title: `Running ${chalk.yellow('postPackage')} hook`,
task: childTrace<Parameters<ForgeListrTaskFn<PackageContext>>>(
{ name: 'run-postPackage-hook', category: '@electron-forge/core' },
async (childTrace, { packagerPromise, forgeConfig }, task) => {
const outputPaths = await packagerPromise;
d('outputPaths:', outputPaths);
return delayTraceTillSignal(
childTrace,
task.newListr(
await getHookListrTasks(
childTrace,
forgeConfig,
'postPackage',
{
arch,
outputPaths,
platform,
},
),
),
'run',
);
},
),
},
],
{
concurrent: false,
silentRendererCondition: !interactive,
fallbackRendererCondition:
Boolean(process.env.DEBUG) || Boolean(process.env.CI),
rendererOptions: {
collapseSubtasks: false,
collapseErrors: false,
},
ctx: {} as PackageContext,
},
);
return runner;
};
export default autoTrace(
{ name: 'package()', category: '@electron-forge/core' },
async (childTrace, opts: PackageOptions): Promise<PackageResult[]> => {
const runner = listrPackage(childTrace, opts);
await runner.run();
const outputPaths = await runner.ctx.packagerPromise;
return runner.ctx.targets.map((target, index) => ({
platform: target.platform,
arch: target.arch,
packagedPath: outputPaths[index],
}));
},
);