From 74953135ce61e5e508117c70f8d370f78e61c9f2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 06:36:52 +0000 Subject: [PATCH] feat: add opt-in Devtron support to the webpack and vite plugins Adds a `devtron: true` config option to @electron-forge/plugin-webpack and @electron-forge/plugin-vite that installs the @electron/devtron DevTools extension into the app during `electron-forge start`. The bootstrap is prepended as a raw banner to the compiled main process bundle in development only (webpack BannerPlugin / Rollup output.banner), so the bundler never parses it and `@electron/devtron` resolves from the app's node_modules at runtime. The snippet guards against packaged apps, non-main Electron processes, and ESM output without `require`. A shared helper in @electron-forge/core-utils validates that @electron/devtron is installed in the app (actionable error otherwise) and that the app's Electron version is >= 36 (warn and skip otherwise). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PnzP6e9JFCCaDFgbpiWVms --- packages/plugin/vite/spec/ViteConfig.spec.ts | 55 +++++++++++ packages/plugin/vite/src/Config.ts | 12 +++ packages/plugin/vite/src/VitePlugin.ts | 24 ++++- .../vite/src/config/vite.main.config.ts | 20 +++- .../plugin/webpack/spec/WebpackConfig.spec.ts | 84 +++++++++++++++- packages/plugin/webpack/src/Config.ts | 11 +++ packages/plugin/webpack/src/WebpackConfig.ts | 28 +++++- .../utils/core-utils/spec/devtron.spec.ts | 95 +++++++++++++++++++ packages/utils/core-utils/src/devtron.ts | 92 ++++++++++++++++++ packages/utils/core-utils/src/index.ts | 1 + 10 files changed, 412 insertions(+), 10 deletions(-) create mode 100644 packages/utils/core-utils/spec/devtron.spec.ts create mode 100644 packages/utils/core-utils/src/devtron.ts diff --git a/packages/plugin/vite/spec/ViteConfig.spec.ts b/packages/plugin/vite/spec/ViteConfig.spec.ts index 938788f40d..96da54b0d6 100644 --- a/packages/plugin/vite/spec/ViteConfig.spec.ts +++ b/packages/plugin/vite/spec/ViteConfig.spec.ts @@ -94,6 +94,61 @@ describe('ViteConfigGenerator', () => { ).toEqual(['@electron-forge/plugin-vite:hot-restart']); }); + describe('devtron', () => { + const devtronForgeConfig = (devtron?: boolean): VitePluginConfig => ({ + build: [ + { + entry: 'src/main.js', + config: path.join(configRoot, 'vite.main.config.mjs'), + target: 'main', + }, + ], + renderer: [], + devtron, + }); + + const mainOutput = (config: { + build?: { rollupOptions?: { output?: unknown } }; + }) => + config.build?.rollupOptions?.output as { banner?: string } | undefined; + + it('injects the bootstrap banner into dev main builds when enabled', async () => { + const generator = new ViteConfigGenerator( + devtronForgeConfig(true), + configRoot, + false, + ); + const buildConfig = (await generator.getBuildConfigs())[0]; + expect(mainOutput(buildConfig)?.banner).toContain('@electron/devtron'); + expect(buildConfig.build?.rollupOptions?.external).toContain( + '@electron/devtron', + ); + }); + + it('does not inject the bootstrap into production builds', async () => { + const generator = new ViteConfigGenerator( + devtronForgeConfig(true), + configRoot, + true, + ); + const buildConfig = (await generator.getBuildConfigs())[0]; + expect(mainOutput(buildConfig)?.banner).toBeUndefined(); + expect(buildConfig.build?.rollupOptions?.external).not.toContain( + '@electron/devtron', + ); + }); + + it('does not inject the bootstrap when not enabled', async () => { + const generator = new ViteConfigGenerator( + devtronForgeConfig(), + configRoot, + false, + ); + const buildConfig = (await generator.getBuildConfigs())[0]; + expect(mainOutput(buildConfig)?.banner).toBeUndefined(); + }); + }); + it('getRendererConfig:renderer', async () => { const forgeConfig = { build: [], diff --git a/packages/plugin/vite/src/Config.ts b/packages/plugin/vite/src/Config.ts index b7cd6847be..aaf617381b 100644 --- a/packages/plugin/vite/src/Config.ts +++ b/packages/plugin/vite/src/Config.ts @@ -49,4 +49,16 @@ export interface VitePluginConfig { * @defaultValue `true` */ concurrent?: boolean | number; + + /** + * Installs the [Devtron](https://github.com/electron/devtron) DevTools + * extension into your app while running `electron-forge start`. + * + * Requires `@electron/devtron` to be installed as a devDependency of your + * app and Electron >= 36. The extension is only injected in development; + * packaged builds are never affected. + * + * @defaultValue false + */ + devtron?: boolean; } diff --git a/packages/plugin/vite/src/VitePlugin.ts b/packages/plugin/vite/src/VitePlugin.ts index 7219b25051..4bc82c83e8 100644 --- a/packages/plugin/vite/src/VitePlugin.ts +++ b/packages/plugin/vite/src/VitePlugin.ts @@ -3,7 +3,11 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import { styleText } from 'node:util'; -import { readJson, writeJson } from '@electron-forge/core-utils'; +import { + canInjectDevtron, + readJson, + writeJson, +} from '@electron-forge/core-utils'; import { namedHookWithTaskFn, PluginBase } from '@electron-forge/plugin-base'; import debug from 'debug'; import { Listr, PRESET_TIMER } from 'listr2'; @@ -30,7 +34,7 @@ const subprocessWorkerPath = path.resolve( ); function spawnViteBuild( - pluginConfig: Pick, + pluginConfig: Pick, kind: 'build' | 'renderer', index: number, projectDir: string, @@ -80,7 +84,7 @@ function spawnViteBuild( } function spawnViteBuildWatch( - pluginConfig: Pick, + pluginConfig: Pick, index: number, projectDir: string, devServerUrls: Record, @@ -174,6 +178,13 @@ export default class VitePlugin extends PluginBase { private servers: vite.ViteDevServer[] = []; + /** + * Whether the Devtron bootstrap should be injected into the main process + * bundle. Only ever set during `start`, after verifying the environment + * supports it. + */ + private devtronEnabled = false; + init = (dir: string): void => { this.setDirectories(dir); @@ -209,6 +220,10 @@ export default class VitePlugin extends PluginBase { d(`preStart: removing old content from ${this.baseDir}`); await fs.rm(this.baseDir, { recursive: true, force: true }); + if (this.config.devtron) { + this.devtronEnabled = await canInjectDevtron(this.projectDir); + } + return task?.newListr( [ { @@ -340,11 +355,12 @@ the generated files). Instead, it is ${JSON.stringify(pj.main)}.`); */ private get serializableConfig(): Pick< VitePluginConfig, - 'build' | 'renderer' + 'build' | 'renderer' | 'devtron' > { return { build: this.config.build, renderer: this.config.renderer, + devtron: this.devtronEnabled, }; } diff --git a/packages/plugin/vite/src/config/vite.main.config.ts b/packages/plugin/vite/src/config/vite.main.config.ts index ab6d1bc2d1..c58aaff7f3 100644 --- a/packages/plugin/vite/src/config/vite.main.config.ts +++ b/packages/plugin/vite/src/config/vite.main.config.ts @@ -1,3 +1,4 @@ +import { getDevtronBootstrapCode } from '@electron-forge/core-utils'; import { type ConfigEnv, mergeConfig, type UserConfig } from 'vite'; import { @@ -11,13 +12,28 @@ export function getConfig( forgeEnv: ConfigEnv<'build'>, userConfig: UserConfig = {}, ): UserConfig { - const { forgeConfigSelf } = forgeEnv; + const { command, forgeConfig, forgeConfigSelf } = forgeEnv; const define = getBuildDefine(forgeEnv); + // Only inject Devtron into dev builds — `command` is only ever 'serve' + // during `electron-forge start`. + const injectDevtron = Boolean(forgeConfig.devtron) && command === 'serve'; const config: UserConfig = { build: { copyPublicDir: false, rollupOptions: { - external: [...external, 'electron/main'], + external: [ + ...external, + 'electron/main', + ...(injectDevtron ? ['@electron/devtron'] : []), + ], + ...(injectDevtron + ? { + // The banner is raw code prepended to the emitted CJS bundle, + // so Rollup never parses it; `@electron/devtron` is resolved + // from the app's node_modules at runtime. + output: { banner: getDevtronBootstrapCode() }, + } + : {}), }, }, plugins: [pluginHotRestart('restart')], diff --git a/packages/plugin/webpack/spec/WebpackConfig.spec.ts b/packages/plugin/webpack/spec/WebpackConfig.spec.ts index 45ccb52bbe..b3fe429349 100644 --- a/packages/plugin/webpack/spec/WebpackConfig.spec.ts +++ b/packages/plugin/webpack/spec/WebpackConfig.spec.ts @@ -1,7 +1,7 @@ import path from 'node:path'; -import { describe, expect, it } from 'vitest'; -import { Configuration, Entry } from 'webpack'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import webpackPkg, { Configuration, Entry } from 'webpack'; import { WebpackConfiguration, @@ -13,8 +13,23 @@ import WebpackConfigGenerator, { ConfigurationFactory, } from '../src/WebpackConfig'; +vi.mock(import('@electron-forge/core-utils'), async (importOriginal) => { + const mod = await importOriginal(); + return { ...mod, canInjectDevtron: vi.fn(async () => true) }; +}); + +const { canInjectDevtron } = await import('@electron-forge/core-utils'); + const mockProjectDir = process.platform === 'win32' ? 'C:\\path' : '/path'; +function hasDevtronBannerPlugin( + plugins?: Required['plugins'], +): boolean { + return (plugins || []).some( + (plugin) => plugin instanceof webpackPkg.BannerPlugin, + ); +} + function hasAssetRelocatorPatchPlugin( plugins?: Required['plugins'], ): boolean { @@ -298,6 +313,71 @@ describe('WebpackConfigGenerator', () => { ); }); + describe('devtron', () => { + beforeEach(() => { + vi.mocked(canInjectDevtron).mockClear(); + vi.mocked(canInjectDevtron).mockResolvedValue(true); + }); + + const devtronConfig = (devtron?: boolean) => + ({ + mainConfig: { + entry: 'main.js', + }, + renderer: { + entryPoints: [] as WebpackPluginEntryPoint[], + }, + devtron, + }) as WebpackPluginConfig; + + it('injects the bootstrap banner in development when enabled', async () => { + const generator = new WebpackConfigGenerator( + devtronConfig(true), + mockProjectDir, + false, + 3000, + ); + const webpackConfig = await generator.getMainConfig(); + expect(hasDevtronBannerPlugin(webpackConfig.plugins)).toEqual(true); + }); + + it('does not inject the bootstrap in production', async () => { + const generator = new WebpackConfigGenerator( + devtronConfig(true), + mockProjectDir, + true, + 3000, + ); + const webpackConfig = await generator.getMainConfig(); + expect(hasDevtronBannerPlugin(webpackConfig.plugins)).toEqual(false); + expect(canInjectDevtron).not.toHaveBeenCalled(); + }); + + it('does not inject the bootstrap when not enabled', async () => { + const generator = new WebpackConfigGenerator( + devtronConfig(), + mockProjectDir, + false, + 3000, + ); + const webpackConfig = await generator.getMainConfig(); + expect(hasDevtronBannerPlugin(webpackConfig.plugins)).toEqual(false); + expect(canInjectDevtron).not.toHaveBeenCalled(); + }); + + it('does not inject the bootstrap when the environment does not support it', async () => { + vi.mocked(canInjectDevtron).mockResolvedValue(false); + const generator = new WebpackConfigGenerator( + devtronConfig(true), + mockProjectDir, + false, + 3000, + ); + const webpackConfig = await generator.getMainConfig(); + expect(hasDevtronBannerPlugin(webpackConfig.plugins)).toEqual(false); + }); + }); + it('generates a config with a relative entry path', async () => { const config = { mainConfig: { diff --git a/packages/plugin/webpack/src/Config.ts b/packages/plugin/webpack/src/Config.ts index 504151ca2b..2be4c724a3 100644 --- a/packages/plugin/webpack/src/Config.ts +++ b/packages/plugin/webpack/src/Config.ts @@ -186,6 +186,17 @@ export interface WebpackPluginConfig { WebpackDevServer.Configuration, 'port' | 'static' | 'setupExitSignals' | 'Content-Security-Policy' >; + /** + * Installs the [Devtron](https://github.com/electron/devtron) DevTools + * extension into your app while running `electron-forge start`. + * + * Requires `@electron/devtron` to be installed as a devDependency of your + * app and Electron >= 36. The extension is only injected in development; + * packaged builds are never affected. + * + * @defaultValue false + */ + devtron?: boolean; } export type WebpackConfiguration = diff --git a/packages/plugin/webpack/src/WebpackConfig.ts b/packages/plugin/webpack/src/WebpackConfig.ts index 0b7193d852..9b61f08fc5 100644 --- a/packages/plugin/webpack/src/WebpackConfig.ts +++ b/packages/plugin/webpack/src/WebpackConfig.ts @@ -5,7 +5,11 @@ import HtmlWebpackPlugin from 'html-webpack-plugin'; import type * as webpack from 'webpack'; import webpackPkg from 'webpack'; -const { DefinePlugin, ExternalsPlugin } = webpackPkg; +const { BannerPlugin, DefinePlugin, ExternalsPlugin } = webpackPkg; +import { + canInjectDevtron, + getDevtronBootstrapCode, +} from '@electron-forge/core-utils'; import { merge as webpackMerge } from 'webpack-merge'; import { @@ -223,6 +227,26 @@ export default class WebpackConfigGenerator { }; mainConfig.entry = fix(mainConfig.entry as EntryType); + const plugins: webpack.WebpackPluginInstance[] = [ + new DefinePlugin(this.getDefines()), + ]; + if ( + !this.isProd && + this.pluginConfig.devtron && + (await canInjectDevtron(this.projectDir)) + ) { + // The banner is raw code prepended to the emitted bundle, so webpack + // never parses it; `@electron/devtron` is resolved from the app's + // node_modules at runtime instead of being bundled. + plugins.push( + new BannerPlugin({ + banner: getDevtronBootstrapCode(), + raw: true, + entryOnly: true, + }), + ); + } + return webpackMerge( { devtool: 'source-map', @@ -233,7 +257,7 @@ export default class WebpackConfigGenerator { filename: 'index.js', libraryTarget: 'commonjs2', }, - plugins: [new DefinePlugin(this.getDefines())], + plugins, node: { __dirname: false, __filename: false, diff --git a/packages/utils/core-utils/spec/devtron.spec.ts b/packages/utils/core-utils/spec/devtron.spec.ts new file mode 100644 index 0000000000..8c4c7fb9f9 --- /dev/null +++ b/packages/utils/core-utils/spec/devtron.spec.ts @@ -0,0 +1,95 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { canInjectDevtron, getDevtronBootstrapCode } from '../src/devtron'; + +async function makeProject({ + electronVersion, + withDevtron, +}: { + electronVersion?: string; + withDevtron: boolean; +}): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'forge-devtron-spec-')); + await fs.writeFile( + path.join(dir, 'package.json'), + JSON.stringify({ + name: 'devtron-spec-app', + devDependencies: electronVersion ? { electron: electronVersion } : {}, + }), + ); + if (withDevtron) { + const devtronDir = path.join(dir, 'node_modules', '@electron', 'devtron'); + await fs.mkdir(devtronDir, { recursive: true }); + await fs.writeFile( + path.join(devtronDir, 'package.json'), + JSON.stringify({ + name: '@electron/devtron', + version: '2.0.0', + main: 'index.js', + }), + ); + await fs.writeFile( + path.join(devtronDir, 'index.js'), + 'module.exports = {};\n', + ); + } + return dir; +} + +describe('getDevtronBootstrapCode', () => { + it('is valid JavaScript', () => { + expect(() => new Function(getDevtronBootstrapCode())).not.toThrow(); + }); + + it('guards against packaged apps and non-main processes', () => { + const code = getDevtronBootstrapCode(); + expect(code).toContain('isPackaged'); + expect(code).toContain("process.type !== 'browser'"); + expect(code).toContain("require('@electron/devtron')"); + }); +}); + +describe('canInjectDevtron', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('throws when @electron/devtron is not installed', async () => { + const dir = await makeProject({ + electronVersion: '36.0.0', + withDevtron: false, + }); + await expect(canInjectDevtron(dir)).rejects.toThrow( + /@electron\/devtron.*could not be resolved/, + ); + }); + + it('returns true when devtron is installed and Electron is new enough', async () => { + const dir = await makeProject({ + electronVersion: '36.0.0', + withDevtron: true, + }); + await expect(canInjectDevtron(dir)).resolves.toBe(true); + }); + + it('returns false with a warning when Electron is too old', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const dir = await makeProject({ + electronVersion: '35.0.0', + withDevtron: true, + }); + await expect(canInjectDevtron(dir)).resolves.toBe(false); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('requires Electron >= 36.0.0'), + ); + }); + + it('returns true when the Electron version cannot be determined', async () => { + const dir = await makeProject({ withDevtron: true }); + await expect(canInjectDevtron(dir)).resolves.toBe(true); + }); +}); diff --git a/packages/utils/core-utils/src/devtron.ts b/packages/utils/core-utils/src/devtron.ts new file mode 100644 index 0000000000..56d69083c5 --- /dev/null +++ b/packages/utils/core-utils/src/devtron.ts @@ -0,0 +1,92 @@ +import { createRequire } from 'node:module'; +import path from 'node:path'; +import { styleText } from 'node:util'; + +import debug from 'debug'; +import semver from 'semver'; + +import { getElectronVersion } from './electron-version.js'; +import { readJson } from './fs.js'; + +const d = debug('electron-forge:core-utils:devtron'); + +/** + * Devtron loads as a DevTools extension via APIs that only exist in + * Electron >= 36. + */ +export const DEVTRON_MIN_ELECTRON_VERSION = '36.0.0'; + +/** + * Main process bootstrap code that installs the Devtron DevTools extension. + * + * This snippet is prepended verbatim to the compiled main process bundle in + * development, so it is plain CommonJS and guards itself against every + * context it could accidentally run in: packaged apps, non-main Electron + * processes, and ESM output where `require` is not defined. + */ +export function getDevtronBootstrapCode(): string { + return `;(function () { + try { + if (typeof require !== 'function' || typeof process === 'undefined' || process.type !== 'browser') { + return; + } + var electron = require('electron'); + if (!electron || !electron.app || electron.app.isPackaged) { + return; + } + Promise.resolve() + .then(function () { return require('@electron/devtron').devtron.install(); }) + .catch(function (err) { + console.warn('[electron-forge] Failed to install Devtron:', err); + }); + } catch (err) { + console.warn('[electron-forge] Failed to install Devtron:', err); + } +})(); +`; +} + +/** + * Determines whether the Devtron bootstrap can be injected into the app's + * main process bundle. + * + * Throws if `@electron/devtron` is not installed in the project, since the + * bootstrap resolves it from the app's `node_modules` at runtime. Returns + * false (with a warning) if the project's Electron version is too old to + * support Devtron. + */ +export async function canInjectDevtron(projectDir: string): Promise { + const projectRequire = createRequire(path.join(projectDir, 'package.json')); + try { + projectRequire.resolve('@electron/devtron'); + } catch { + throw new Error( + `The "devtron" option is enabled, but "@electron/devtron" could not be resolved from ${projectDir}. ` + + 'Install it as a devDependency of your app, e.g. "npm install --save-dev @electron/devtron".', + ); + } + + let electronVersion: string; + try { + const packageJSON = await readJson(path.join(projectDir, 'package.json')); + electronVersion = await getElectronVersion(projectDir, packageJSON); + } catch (err) { + // If we can't determine the Electron version, optimistically inject; the + // bootstrap itself fails soft inside the app. + d('could not determine Electron version for devtron check:', err); + return true; + } + + const parsed = semver.coerce(electronVersion); + if (parsed && semver.lt(parsed, DEVTRON_MIN_ELECTRON_VERSION)) { + console.warn( + styleText( + 'yellow', + `Devtron requires Electron >= ${DEVTRON_MIN_ELECTRON_VERSION}, but this app uses Electron ${electronVersion}. Skipping Devtron installation.`, + ), + ); + return false; + } + + return true; +} diff --git a/packages/utils/core-utils/src/index.ts b/packages/utils/core-utils/src/index.ts index 80a3fe28a9..681f5af6fc 100644 --- a/packages/utils/core-utils/src/index.ts +++ b/packages/utils/core-utils/src/index.ts @@ -1,4 +1,5 @@ export * from './rebuild.js'; +export * from './devtron.js'; export * from './electron-version.js'; export * from './fs.js'; export * from './package-manager.js';