From 7784f12ebe3ae7a1b2b7c6aba365312913166d3d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 06:15:31 +0000 Subject: [PATCH 01/20] feat(plugin-vite): opt-in app:// protocol for serving packaged renderers Prototype of serving built renderer files over a privileged custom `app://` scheme instead of `file://` in packaged apps, per Electron's security recommendations, implemented as a plugin-level feature so the boilerplate lives in @electron-forge/plugin-vite rather than in every scaffolded app. - Add an opt-in `appProtocol` option to the Vite plugin config. When enabled, production main-process bundles are prefixed with a runtime banner that registers the privileged `app://` scheme and a `protocol.handle` serving `.vite/renderer/` with a path traversal guard, via `net.fetch` on the resolved file URL. - Add a `*_VITE_ENTRY` magic constant that resolves to the dev server URL during development and `app:///index.html` in production builds, so app code can unconditionally call `mainWindow.loadURL(MAIN_WINDOW_VITE_ENTRY)`. - Update the vite and vite-typescript templates to enable `appProtocol` and collapse the dev/prod loadURL/loadFile conditional to a single `loadURL(MAIN_WINDOW_VITE_ENTRY)` call. The banner runs before user code, so the scheme registration happens before app ready and the handler is registered ahead of any `createWindow()` in a user 'ready' listener. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JHmXuuGMGg1bcCCYDNVSKW --- packages/plugin/vite/forge-vite-env.d.ts | 2 + .../vite/spec/config/vite.base.config.spec.ts | 26 ++++++ .../vite/spec/config/vite.main.config.spec.ts | 92 +++++++++++++++++++ .../subprocess-build/src/main-with-entry.js | 2 + .../vite/spec/subprocess-worker.spec.ts | 68 +++++++++++++- packages/plugin/vite/src/Config.ts | 21 +++++ .../plugin/vite/src/config/app-protocol.ts | 89 ++++++++++++++++++ .../vite/src/config/vite.base.config.ts | 14 ++- .../vite/src/config/vite.main.config.ts | 16 +++- .../vite-typescript/tmpl/forge.config.ts | 4 + .../template/vite-typescript/tmpl/main.ts | 12 +-- .../template/vite/spec/ViteTemplate.spec.ts | 6 +- packages/template/vite/src/ViteTemplate.ts | 8 +- packages/template/vite/tmpl/forge.config.js | 4 + 14 files changed, 344 insertions(+), 20 deletions(-) create mode 100644 packages/plugin/vite/spec/config/vite.main.config.spec.ts create mode 100644 packages/plugin/vite/spec/fixtures/subprocess-build/src/main-with-entry.js create mode 100644 packages/plugin/vite/src/config/app-protocol.ts diff --git a/packages/plugin/vite/forge-vite-env.d.ts b/packages/plugin/vite/forge-vite-env.d.ts index 95cc56d9d7..9a6f0e7d2b 100644 --- a/packages/plugin/vite/forge-vite-env.d.ts +++ b/packages/plugin/vite/forge-vite-env.d.ts @@ -7,10 +7,12 @@ declare global { // whether you're running in development or production). const MAIN_WINDOW_VITE_DEV_SERVER_URL: string; const MAIN_WINDOW_VITE_NAME: string; + const MAIN_WINDOW_VITE_ENTRY: string; interface VitePluginRuntimeKeys { VITE_DEV_SERVER_URL: `${string}_VITE_DEV_SERVER_URL`; VITE_NAME: `${string}_VITE_NAME`; + VITE_ENTRY: `${string}_VITE_ENTRY`; } } diff --git a/packages/plugin/vite/spec/config/vite.base.config.spec.ts b/packages/plugin/vite/spec/config/vite.base.config.spec.ts index 436b9f86bd..5104119d14 100644 --- a/packages/plugin/vite/spec/config/vite.base.config.spec.ts +++ b/packages/plugin/vite/spec/config/vite.base.config.spec.ts @@ -46,10 +46,12 @@ describe('vite.base.config', () => { main_window: { VITE_DEV_SERVER_URL: 'MAIN_WINDOW_VITE_DEV_SERVER_URL', VITE_NAME: 'MAIN_WINDOW_VITE_NAME', + VITE_ENTRY: 'MAIN_WINDOW_VITE_ENTRY', }, second_window: { VITE_DEV_SERVER_URL: 'SECOND_WINDOW_VITE_DEV_SERVER_URL', VITE_NAME: 'SECOND_WINDOW_VITE_NAME', + VITE_ENTRY: 'SECOND_WINDOW_VITE_ENTRY', }, }; @@ -67,8 +69,30 @@ describe('vite.base.config', () => { const define2 = { MAIN_WINDOW_VITE_DEV_SERVER_URL: undefined, MAIN_WINDOW_VITE_NAME: '"main_window"', + MAIN_WINDOW_VITE_ENTRY: undefined, SECOND_WINDOW_VITE_DEV_SERVER_URL: undefined, SECOND_WINDOW_VITE_NAME: '"second_window"', + SECOND_WINDOW_VITE_ENTRY: undefined, + }; + + expect(define1).toEqual(define2); + }); + + it('getBuildDefine:build with appProtocol resolves entries to app:// URLs', () => { + const define1 = getBuildDefine({ + command: 'build', + mode: 'production', + root: configRoot, + forgeConfig: { ...forgeConfig, appProtocol: true }, + forgeConfigSelf: forgeConfig.build[0], + }); + const define2 = { + MAIN_WINDOW_VITE_DEV_SERVER_URL: undefined, + MAIN_WINDOW_VITE_NAME: '"main_window"', + MAIN_WINDOW_VITE_ENTRY: '"app://main_window/index.html"', + SECOND_WINDOW_VITE_DEV_SERVER_URL: undefined, + SECOND_WINDOW_VITE_NAME: '"second_window"', + SECOND_WINDOW_VITE_ENTRY: '"app://second_window/index.html"', }; expect(define1).toEqual(define2); @@ -100,8 +124,10 @@ describe('vite.base.config', () => { const define2 = { MAIN_WINDOW_VITE_DEV_SERVER_URL: '"http://localhost:5173"', MAIN_WINDOW_VITE_NAME: '"main_window"', + MAIN_WINDOW_VITE_ENTRY: '"http://localhost:5173"', SECOND_WINDOW_VITE_DEV_SERVER_URL: '"http://localhost:5174"', SECOND_WINDOW_VITE_NAME: '"second_window"', + SECOND_WINDOW_VITE_ENTRY: '"http://localhost:5174"', }; for (const server of servers) { diff --git a/packages/plugin/vite/spec/config/vite.main.config.spec.ts b/packages/plugin/vite/spec/config/vite.main.config.spec.ts new file mode 100644 index 0000000000..81d7d8837f --- /dev/null +++ b/packages/plugin/vite/spec/config/vite.main.config.spec.ts @@ -0,0 +1,92 @@ +import path from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import { + getAppProtocolBanner, + getAppProtocolEntryUrl, +} from '../../src/config/app-protocol'; +import { getConfig } from '../../src/config/vite.main.config'; + +import type { VitePluginConfig } from '../../src/Config'; +import type { ConfigEnv, Rollup } from 'vite'; + +const configRoot = path.join(import.meta.dirname, 'fixtures/vite-configs'); +const forgeConfig: VitePluginConfig = { + build: [ + { + entry: 'src/main.js', + config: path.join(configRoot, 'vite.main.config.mjs'), + target: 'main', + }, + ], + renderer: [ + { + name: 'main_window', + config: path.join(configRoot, 'vite.renderer.config.mjs'), + }, + ], +}; + +function buildEnv( + overrides: Partial> = {}, +): ConfigEnv<'build'> { + return { + command: 'build', + mode: 'production', + root: configRoot, + forgeConfig, + forgeConfigSelf: forgeConfig.build[0], + ...overrides, + }; +} + +function getBanner(config: ReturnType): string | undefined { + const output = config.build?.rollupOptions?.output as + | Rollup.OutputOptions + | undefined; + return output?.banner as string | undefined; +} + +describe('vite.main.config', () => { + it('does not inject the app protocol runtime by default', () => { + const config = getConfig(buildEnv()); + expect(getBanner(config)).toBeUndefined(); + }); + + it('injects the app protocol runtime when appProtocol is enabled', () => { + const config = getConfig( + buildEnv({ forgeConfig: { ...forgeConfig, appProtocol: true } }), + ); + const banner = getBanner(config); + expect(banner).toContain('registerSchemesAsPrivileged'); + expect(banner).toContain('protocol.handle'); + expect(banner).toContain('"main_window"'); + }); + + it('does not inject the app protocol runtime for dev server builds', () => { + const config = getConfig( + buildEnv({ + command: 'serve', + mode: 'development', + forgeConfig: { ...forgeConfig, appProtocol: true }, + }), + ); + expect(getBanner(config)).toBeUndefined(); + }); +}); + +describe('app-protocol', () => { + it('builds entry URLs on the app scheme', () => { + expect(getAppProtocolEntryUrl('main_window')).toEqual( + 'app://main_window/index.html', + ); + }); + + it('emits syntactically valid runtime code', () => { + const banner = getAppProtocolBanner(['main_window', 'second_window']); + // Throws on a syntax error without executing the code. + expect(() => new Function(banner)).not.toThrow(); + expect(banner).toContain('["main_window","second_window"]'); + }); +}); diff --git a/packages/plugin/vite/spec/fixtures/subprocess-build/src/main-with-entry.js b/packages/plugin/vite/spec/fixtures/subprocess-build/src/main-with-entry.js new file mode 100644 index 0000000000..2b38d394ed --- /dev/null +++ b/packages/plugin/vite/spec/fixtures/subprocess-build/src/main-with-entry.js @@ -0,0 +1,2 @@ +/* eslint-disable */ +export const entry = MAIN_WINDOW_VITE_ENTRY; diff --git a/packages/plugin/vite/spec/subprocess-worker.spec.ts b/packages/plugin/vite/spec/subprocess-worker.spec.ts index 12211726f5..b17286fc48 100644 --- a/packages/plugin/vite/spec/subprocess-worker.spec.ts +++ b/packages/plugin/vite/spec/subprocess-worker.spec.ts @@ -21,7 +21,7 @@ const workerPath = path.resolve( function runWorker( kind: 'build' | 'renderer', index: number, - config: Pick, + config: Pick, ) { return new Promise<{ code: number | null; stderr: string }>( (resolve, reject) => { @@ -134,6 +134,72 @@ describe('subprocess-worker', () => { expect(contents).not.toContain('MAIN_WINDOW_VITE_NAME'); }); + it('injects the app protocol runtime and entry define when appProtocol is enabled', async () => { + const config: Pick = + { + build: [ + { + entry: 'src/main-with-entry.js', + config: path.join(projectDir, 'vite.main.config.mjs'), + target: 'main', + }, + ], + renderer: [ + { + name: 'main_window', + config: path.join(projectDir, 'vite.renderer.config.mjs'), + }, + ], + appProtocol: true, + }; + + const { code, stderr } = await runWorker('build', 0, config); + expect(code, stderr).toBe(0); + + const outFile = path.join(viteOutDir, 'build', 'main-with-entry.js'); + const contents = fs.readFileSync(outFile, 'utf8'); + // The app protocol runtime should be prepended to the bundle. Only assert + // on markers that survive minification (property names and string + // literals — local identifiers like `protocol` get mangled). + expect(contents).toContain('registerSchemesAsPrivileged'); + expect(contents).toContain('bypassCustomProtocolHandlers'); + expect(contents).toContain('__electronForgeViteAppProtocol'); + // The banner must come before the bundled module code so it runs first. + expect(contents.indexOf('registerSchemesAsPrivileged')).toBeLessThan( + contents.indexOf('exports'), + ); + // MAIN_WINDOW_VITE_ENTRY should be statically replaced with the app:// URL. + expect(contents).toContain('app://main_window/index.html'); + expect(contents).not.toContain('MAIN_WINDOW_VITE_ENTRY'); + }); + + it('does not inject the app protocol runtime when appProtocol is not enabled', async () => { + const config: Pick = { + build: [ + { + entry: 'src/main.js', + config: path.join(projectDir, 'vite.main.config.mjs'), + target: 'main', + }, + ], + renderer: [ + { + name: 'main_window', + config: path.join(projectDir, 'vite.renderer.config.mjs'), + }, + ], + }; + + const { code, stderr } = await runWorker('build', 0, config); + expect(code, stderr).toBe(0); + + const contents = fs.readFileSync( + path.join(viteOutDir, 'build', 'main.js'), + 'utf8', + ); + expect(contents).not.toContain('registerSchemesAsPrivileged'); + }); + it('builds a preload target', async () => { const config: Pick = { build: [ diff --git a/packages/plugin/vite/src/Config.ts b/packages/plugin/vite/src/Config.ts index b7cd6847be..462cf17674 100644 --- a/packages/plugin/vite/src/Config.ts +++ b/packages/plugin/vite/src/Config.ts @@ -49,4 +49,25 @@ export interface VitePluginConfig { * @defaultValue `true` */ concurrent?: boolean | number; + + /** + * Serve the built renderer files over a privileged `app://` custom scheme in + * packaged apps instead of loading them from `file://`, per Electron's + * security recommendations. See + * https://www.electronjs.org/docs/latest/api/protocol + * + * When enabled, the plugin injects the scheme registration and protocol + * handler into the production main-process bundle, and the `*_VITE_ENTRY` + * magic constant resolves to the Vite dev server URL in development and an + * `app:///index.html` URL in production, so the main process + * can unconditionally call `mainWindow.loadURL(MAIN_WINDOW_VITE_ENTRY)`. + * + * Notes: + * - `protocol.registerSchemesAsPrivileged` can only be called once per app, + * so this option cannot be combined with app code that registers its own + * privileged schemes. + * - Requires the default CommonJS output for main-process targets. + * @defaultValue `false` + */ + appProtocol?: boolean; } diff --git a/packages/plugin/vite/src/config/app-protocol.ts b/packages/plugin/vite/src/config/app-protocol.ts new file mode 100644 index 0000000000..ee1a6e549b --- /dev/null +++ b/packages/plugin/vite/src/config/app-protocol.ts @@ -0,0 +1,89 @@ +/** + * Support for serving packaged renderer bundles over a privileged custom + * `app://` scheme instead of `file://`, per Electron's security + * recommendations (secure origin, working `fetch()` of local resources, + * origin-scoped storage, etc.). + * + * When `appProtocol` is enabled in the plugin config, the code returned by + * {@link getAppProtocolBanner} is prepended to the production main-process + * bundle. It must run before the app's `ready` event, so it is injected as a + * Rollup banner at the very top of the bundle: + * + * - `protocol.registerSchemesAsPrivileged` may only be called once, before + * `ready`. + * - The `protocol.handle` registration is attached with `app.once('ready')` + * from the banner, which runs before any user code. Listeners fire in + * registration order, so the handler is guaranteed to be registered before + * a `createWindow()` in the app's own `ready` handler calls + * `loadURL('app://...')`. + */ + +export const APP_PROTOCOL_SCHEME = 'app'; + +/** + * Builds the `app:///` entry URL that the + * `*_VITE_ENTRY` define resolves to in production builds. + * + * Note: `standard: true` schemes are parsed like `http://`, so the renderer + * name becomes the URL host and is lower-cased by the URL parser. The runtime + * handler compensates by matching renderer names case-insensitively. + */ +export function getAppProtocolEntryUrl(rendererName: string): string { + return `${APP_PROTOCOL_SCHEME}://${rendererName}/index.html`; +} + +/** + * Returns the runtime source injected at the top of the production + * main-process bundle. + * + * The emitted code is plain CommonJS because the plugin builds main-process + * targets with `formats: ['cjs']`. If a user overrides `build.lib` to emit + * ESM, the banner's `require('electron')` would break — `appProtocol` is + * documented as requiring the default CJS output. + */ +export function getAppProtocolBanner(rendererNames: string[]): string { + return `// Injected by @electron-forge/plugin-vite because \`appProtocol\` is enabled. +// Serves the built renderer files over the privileged \`${APP_PROTOCOL_SCHEME}://\` scheme instead +// of \`file://\`, per Electron's security recommendations. +(function () { + 'use strict'; + if (globalThis.__electronForgeViteAppProtocol) return; + globalThis.__electronForgeViteAppProtocol = true; + const { app, net, protocol } = require('electron'); + const path = require('node:path'); + const { pathToFileURL } = require('node:url'); + const rendererNames = ${JSON.stringify(rendererNames)}; + protocol.registerSchemesAsPrivileged([ + { + scheme: '${APP_PROTOCOL_SCHEME}', + privileges: { standard: true, secure: true, supportFetchApi: true }, + }, + ]); + app.once('ready', function () { + protocol.handle('${APP_PROTOCOL_SCHEME}', function (request) { + const url = new URL(request.url); + // The URL host is lower-cased by the parser; renderer names may not be. + const name = rendererNames.find(function (rendererName) { + return rendererName.toLowerCase() === url.hostname; + }); + if (name === undefined) { + return new Response(null, { status: 404 }); + } + const root = path.join(__dirname, '..', 'renderer', name); + const target = path.join( + root, + url.pathname === '/' ? 'index.html' : decodeURIComponent(url.pathname) + ); + // Never serve files from outside the renderer output directory. + const relative = path.relative(root, target); + if (relative.startsWith('..') || path.isAbsolute(relative)) { + return new Response(null, { status: 404 }); + } + return net.fetch(pathToFileURL(target).toString(), { + bypassCustomProtocolHandlers: true, + }); + }); + }); +})(); +`; +} diff --git a/packages/plugin/vite/src/config/vite.base.config.ts b/packages/plugin/vite/src/config/vite.base.config.ts index e946a2a69b..61e9d0833f 100644 --- a/packages/plugin/vite/src/config/vite.base.config.ts +++ b/packages/plugin/vite/src/config/vite.base.config.ts @@ -1,5 +1,7 @@ import { builtinModules } from 'node:module'; +import { getAppProtocolEntryUrl } from './app-protocol.js'; + import type { AddressInfo } from 'node:net'; import type { ConfigEnv, Plugin, UserConfig, ViteDevServer } from 'vite'; @@ -42,6 +44,7 @@ export function getDefineKeys(names: string[]) { const keys: VitePluginRuntimeKeys = { VITE_DEV_SERVER_URL: `${NAME}_VITE_DEV_SERVER_URL`, VITE_NAME: `${NAME}_VITE_NAME`, + VITE_ENTRY: `${NAME}_VITE_ENTRY`, }; return { ...acc, [name]: keys }; @@ -56,13 +59,22 @@ export function getBuildDefine(env: ConfigEnv<'build'>) { const defineKeys = getDefineKeys(names); const define = Object.entries(defineKeys).reduce( (acc, [name, keys]) => { - const { VITE_DEV_SERVER_URL, VITE_NAME } = keys; + const { VITE_DEV_SERVER_URL, VITE_NAME, VITE_ENTRY } = keys; const def = { [VITE_DEV_SERVER_URL]: command === 'serve' ? JSON.stringify(viteDevServerUrls[VITE_DEV_SERVER_URL]) : undefined, [VITE_NAME]: JSON.stringify(name), + // A single entry URL usable in both development and production: the + // dev server URL while serving, and (when `appProtocol` is enabled) + // the `app://` URL served by the injected protocol handler in builds. + [VITE_ENTRY]: + command === 'serve' + ? JSON.stringify(viteDevServerUrls[VITE_DEV_SERVER_URL]) + : forgeConfig.appProtocol + ? JSON.stringify(getAppProtocolEntryUrl(name)) + : undefined, }; return { ...acc, ...def }; }, diff --git a/packages/plugin/vite/src/config/vite.main.config.ts b/packages/plugin/vite/src/config/vite.main.config.ts index ab6d1bc2d1..3264864f42 100644 --- a/packages/plugin/vite/src/config/vite.main.config.ts +++ b/packages/plugin/vite/src/config/vite.main.config.ts @@ -1,5 +1,6 @@ import { type ConfigEnv, mergeConfig, type UserConfig } from 'vite'; +import { getAppProtocolBanner } from './app-protocol.js'; import { external, getBuildConfig, @@ -11,13 +12,26 @@ export function getConfig( forgeEnv: ConfigEnv<'build'>, userConfig: UserConfig = {}, ): UserConfig { - const { forgeConfigSelf } = forgeEnv; + const { command, forgeConfig, forgeConfigSelf } = forgeEnv; const define = getBuildDefine(forgeEnv); + // In production builds (not the dev server, where renderers are served over + // HTTP), prepend the runtime that registers the `app://` scheme and serves + // the built renderer files over it. It must be a banner so it runs before + // any user code — see app-protocol.ts for the ordering constraints. + const appProtocolBanner = + forgeConfig.appProtocol && command === 'build' + ? getAppProtocolBanner( + forgeConfig.renderer + .map(({ name }) => name) + .filter((name) => name != null), + ) + : undefined; const config: UserConfig = { build: { copyPublicDir: false, rollupOptions: { external: [...external, 'electron/main'], + output: appProtocolBanner ? { banner: appProtocolBanner } : undefined, }, }, plugins: [pluginHotRestart('restart')], diff --git a/packages/template/vite-typescript/tmpl/forge.config.ts b/packages/template/vite-typescript/tmpl/forge.config.ts index c59a670121..ce1b349ddb 100644 --- a/packages/template/vite-typescript/tmpl/forge.config.ts +++ b/packages/template/vite-typescript/tmpl/forge.config.ts @@ -41,6 +41,10 @@ const config: ForgeConfig = { config: 'vite.renderer.config.ts', }, ], + // Serve the built renderer over a privileged `app://` custom scheme in + // packaged apps instead of loading it from `file://`, per Electron's + // security recommendations. + appProtocol: true, }), // Fuses are used to enable/disable various Electron functionality // at package time, before code signing the application diff --git a/packages/template/vite-typescript/tmpl/main.ts b/packages/template/vite-typescript/tmpl/main.ts index f4f001e6e5..5f5439cd4d 100644 --- a/packages/template/vite-typescript/tmpl/main.ts +++ b/packages/template/vite-typescript/tmpl/main.ts @@ -17,14 +17,10 @@ const createWindow = () => { }, }); - // and load the index.html of the app. - if (MAIN_WINDOW_VITE_DEV_SERVER_URL) { - mainWindow.loadURL(MAIN_WINDOW_VITE_DEV_SERVER_URL); - } else { - mainWindow.loadFile( - path.join(__dirname, `../renderer/${MAIN_WINDOW_VITE_NAME}/index.html`), - ); - } + // and load the index.html of the app. In development this is the Vite + // dev server URL; in production it is an `app://` URL served by Forge's + // Vite plugin. + mainWindow.loadURL(MAIN_WINDOW_VITE_ENTRY); // Open the DevTools. mainWindow.webContents.openDevTools(); diff --git a/packages/template/vite/spec/ViteTemplate.spec.ts b/packages/template/vite/spec/ViteTemplate.spec.ts index b2bf29ea95..e86be2bfa1 100644 --- a/packages/template/vite/spec/ViteTemplate.spec.ts +++ b/packages/template/vite/spec/ViteTemplate.spec.ts @@ -52,10 +52,8 @@ describe('ViteTemplate', () => { const mainFile = ( await fs.promises.readFile(path.join(dir, 'src', 'main.js')) ).toString(); - expect(mainFile).toMatch(/MAIN_WINDOW_VITE_DEV_SERVER_URL/); - expect(mainFile).toMatch( - /\.\.\/renderer\/\${MAIN_WINDOW_VITE_NAME}\/index\.html/, - ); + expect(mainFile).toMatch(/mainWindow\.loadURL\(MAIN_WINDOW_VITE_ENTRY\)/); + expect(mainFile).not.toMatch(/mainWindow\.loadFile/); }); it('should remove the stylesheet link from the HTML file', async () => { diff --git a/packages/template/vite/src/ViteTemplate.ts b/packages/template/vite/src/ViteTemplate.ts index f2c6c0f50a..9325c94326 100644 --- a/packages/template/vite/src/ViteTemplate.ts +++ b/packages/template/vite/src/ViteTemplate.ts @@ -44,12 +44,10 @@ class ViteTemplate extends BaseTemplate { await this.updateFileByLine( path.resolve(directory, 'src', 'index.js'), (line) => { + if (line.includes('and load the index.html of the app')) + return " // and load the index.html of the app. In development this is the Vite\n // dev server URL; in production it is an `app://` URL served by Forge's\n // Vite plugin."; if (line.includes('mainWindow.loadFile')) - return ` if (MAIN_WINDOW_VITE_DEV_SERVER_URL) { - mainWindow.loadURL(MAIN_WINDOW_VITE_DEV_SERVER_URL); - } else { - mainWindow.loadFile(path.join(import.meta.dirname, \`../renderer/\${MAIN_WINDOW_VITE_NAME}/index.html\`)); - }`; + return ' mainWindow.loadURL(MAIN_WINDOW_VITE_ENTRY);'; return line; }, path.resolve(directory, 'src', 'main.js'), diff --git a/packages/template/vite/tmpl/forge.config.js b/packages/template/vite/tmpl/forge.config.js index f8d183977a..f979194434 100644 --- a/packages/template/vite/tmpl/forge.config.js +++ b/packages/template/vite/tmpl/forge.config.js @@ -49,6 +49,10 @@ module.exports = { config: 'vite.renderer.config.mjs', }, ], + // Serve the built renderer over a privileged `app://` custom scheme in + // packaged apps instead of loading it from `file://`, per Electron's + // security recommendations. + appProtocol: true, }, }, // Fuses are used to enable/disable various Electron functionality From 4736087a1100f39ff7082c1ce381e135f0a6abe5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 06:22:40 +0000 Subject: [PATCH 02/20] feat(plugin-vite): allow additional privileged schemes with appProtocol MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Electron only allows a single protocol.registerSchemesAsPrivileged call per app, and the runtime injected by `appProtocol` makes that call — which previously meant the option could not be combined with app code that needs its own privileged schemes. Extend `appProtocol` to accept an object form with `additionalPrivilegedSchemes`, folded into the injected runtime's single registerSchemesAsPrivileged call alongside the `app` scheme. The app still registers its own protocol.handle for those schemes — Forge only registers their privileges. The `app` scheme itself is reserved and rejected with a build-time error. The scheme type is structurally compatible with Electron's CustomScheme so values can be shared with app code without importing Electron types into the Forge config. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JHmXuuGMGg1bcCCYDNVSKW --- .../vite/spec/config/vite.main.config.spec.ts | 54 +++++++++++++++++++ .../vite/spec/subprocess-worker.spec.ts | 38 +++++++++++++ packages/plugin/vite/src/Config.ts | 43 +++++++++++++-- .../plugin/vite/src/config/app-protocol.ts | 31 ++++++++--- .../vite/src/config/vite.main.config.ts | 3 ++ 5 files changed, 159 insertions(+), 10 deletions(-) diff --git a/packages/plugin/vite/spec/config/vite.main.config.spec.ts b/packages/plugin/vite/spec/config/vite.main.config.spec.ts index 81d7d8837f..77370b58b9 100644 --- a/packages/plugin/vite/spec/config/vite.main.config.spec.ts +++ b/packages/plugin/vite/spec/config/vite.main.config.spec.ts @@ -64,6 +64,40 @@ describe('vite.main.config', () => { expect(banner).toContain('"main_window"'); }); + it('accepts the object form and includes additional privileged schemes', () => { + const config = getConfig( + buildEnv({ + forgeConfig: { + ...forgeConfig, + appProtocol: { + additionalPrivilegedSchemes: [ + { scheme: 'media', privileges: { stream: true } }, + ], + }, + }, + }), + ); + const banner = getBanner(config); + expect(banner).toContain('registerSchemesAsPrivileged'); + expect(banner).toContain('"media"'); + expect(banner).toContain('"stream":true'); + }); + + it('rejects an additional privileged scheme named app', () => { + expect(() => + getConfig( + buildEnv({ + forgeConfig: { + ...forgeConfig, + appProtocol: { + additionalPrivilegedSchemes: [{ scheme: 'app' }], + }, + }, + }), + ), + ).toThrow(/reserved/); + }); + it('does not inject the app protocol runtime for dev server builds', () => { const config = getConfig( buildEnv({ @@ -89,4 +123,24 @@ describe('app-protocol', () => { expect(() => new Function(banner)).not.toThrow(); expect(banner).toContain('["main_window","second_window"]'); }); + + it('registers additional privileged schemes alongside app', () => { + const banner = getAppProtocolBanner( + ['main_window'], + [{ scheme: 'media', privileges: { stream: true, bypassCSP: true } }], + ); + expect(() => new Function(banner)).not.toThrow(); + // A single registerSchemesAsPrivileged call containing both schemes, with + // the app scheme first. + const registrations = banner.match(/registerSchemesAsPrivileged/g); + expect(registrations).toHaveLength(1); + expect(banner).toMatch(/"scheme":"app".*"scheme":"media"/s); + expect(banner).toContain('"bypassCSP":true'); + }); + + it('throws when an additional scheme conflicts with the app scheme', () => { + expect(() => + getAppProtocolBanner(['main_window'], [{ scheme: 'APP' }]), + ).toThrow(/reserved/); + }); }); diff --git a/packages/plugin/vite/spec/subprocess-worker.spec.ts b/packages/plugin/vite/spec/subprocess-worker.spec.ts index b17286fc48..714c3fbc06 100644 --- a/packages/plugin/vite/spec/subprocess-worker.spec.ts +++ b/packages/plugin/vite/spec/subprocess-worker.spec.ts @@ -173,6 +173,44 @@ describe('subprocess-worker', () => { expect(contents).not.toContain('MAIN_WINDOW_VITE_ENTRY'); }); + it('registers additional privileged schemes from the appProtocol object form', async () => { + const config: Pick = + { + build: [ + { + entry: 'src/main.js', + config: path.join(projectDir, 'vite.main.config.mjs'), + target: 'main', + }, + ], + renderer: [ + { + name: 'main_window', + config: path.join(projectDir, 'vite.renderer.config.mjs'), + }, + ], + appProtocol: { + additionalPrivilegedSchemes: [ + { scheme: 'media', privileges: { stream: true } }, + ], + }, + }; + + const { code, stderr } = await runWorker('build', 0, config); + expect(code, stderr).toBe(0); + + const contents = fs.readFileSync( + path.join(viteOutDir, 'build', 'main.js'), + 'utf8', + ); + expect(contents).toContain('registerSchemesAsPrivileged'); + // The additional scheme survives the JSON round-trip through + // FORGE_VITE_CONFIG into the worker's banner (minifiers may re-quote + // strings, so match any quote style). + expect(contents).toMatch(/[`'"]media[`'"]/); + expect(contents).toMatch(/stream\s*:\s*(true|!0)/); + }); + it('does not inject the app protocol runtime when appProtocol is not enabled', async () => { const config: Pick = { build: [ diff --git a/packages/plugin/vite/src/Config.ts b/packages/plugin/vite/src/Config.ts index 462cf17674..9930264aea 100644 --- a/packages/plugin/vite/src/Config.ts +++ b/packages/plugin/vite/src/Config.ts @@ -27,6 +27,41 @@ export interface VitePluginRendererConfig { config: string; } +/** + * A custom scheme to register as privileged, structurally compatible with + * Electron's `CustomScheme` type so values can be shared with app code. + */ +export interface VitePluginPrivilegedScheme { + scheme: string; + privileges?: { + standard?: boolean; + secure?: boolean; + bypassCSP?: boolean; + allowServiceWorkers?: boolean; + supportFetchApi?: boolean; + corsEnabled?: boolean; + stream?: boolean; + codeCache?: boolean; + }; +} + +export interface VitePluginAppProtocolConfig { + /** + * Additional custom schemes to register as privileged alongside `app://`. + * + * Electron only allows a single `protocol.registerSchemesAsPrivileged` call + * per app, and the runtime injected by `appProtocol` makes that call. An app + * that needs its own privileged schemes must therefore declare them here + * instead of calling `registerSchemesAsPrivileged` itself. The app still + * registers its own `protocol.handle` for these schemes — Forge only + * registers their privileges. + * + * The `app` scheme itself is reserved for Forge's renderer serving and may + * not appear in this list. + */ + additionalPrivilegedSchemes?: VitePluginPrivilegedScheme[]; +} + export interface VitePluginConfig { // Reserved option, may support modification in the future. // @defaultValue '.vite' @@ -64,10 +99,12 @@ export interface VitePluginConfig { * * Notes: * - `protocol.registerSchemesAsPrivileged` can only be called once per app, - * so this option cannot be combined with app code that registers its own - * privileged schemes. + * and the injected runtime makes that call. If your app needs its own + * privileged schemes, pass them via the object form's + * {@link VitePluginAppProtocolConfig.additionalPrivilegedSchemes} instead + * of calling `registerSchemesAsPrivileged` yourself. * - Requires the default CommonJS output for main-process targets. * @defaultValue `false` */ - appProtocol?: boolean; + appProtocol?: boolean | VitePluginAppProtocolConfig; } diff --git a/packages/plugin/vite/src/config/app-protocol.ts b/packages/plugin/vite/src/config/app-protocol.ts index ee1a6e549b..b39ca610f9 100644 --- a/packages/plugin/vite/src/config/app-protocol.ts +++ b/packages/plugin/vite/src/config/app-protocol.ts @@ -18,6 +18,8 @@ * `loadURL('app://...')`. */ +import type { VitePluginPrivilegedScheme } from '../Config.js'; + export const APP_PROTOCOL_SCHEME = 'app'; /** @@ -41,7 +43,27 @@ export function getAppProtocolEntryUrl(rendererName: string): string { * ESM, the banner's `require('electron')` would break — `appProtocol` is * documented as requiring the default CJS output. */ -export function getAppProtocolBanner(rendererNames: string[]): string { +export function getAppProtocolBanner( + rendererNames: string[], + additionalPrivilegedSchemes: VitePluginPrivilegedScheme[] = [], +): string { + for (const { scheme } of additionalPrivilegedSchemes) { + if ( + typeof scheme !== 'string' || + scheme.toLowerCase() === APP_PROTOCOL_SCHEME + ) { + throw new Error( + `The '${APP_PROTOCOL_SCHEME}' scheme is reserved for serving renderer files when \`appProtocol\` is enabled — remove it from \`additionalPrivilegedSchemes\` (schemes must be non-empty strings).`, + ); + } + } + const privilegedSchemes: VitePluginPrivilegedScheme[] = [ + { + scheme: APP_PROTOCOL_SCHEME, + privileges: { standard: true, secure: true, supportFetchApi: true }, + }, + ...additionalPrivilegedSchemes, + ]; return `// Injected by @electron-forge/plugin-vite because \`appProtocol\` is enabled. // Serves the built renderer files over the privileged \`${APP_PROTOCOL_SCHEME}://\` scheme instead // of \`file://\`, per Electron's security recommendations. @@ -53,12 +75,7 @@ export function getAppProtocolBanner(rendererNames: string[]): string { const path = require('node:path'); const { pathToFileURL } = require('node:url'); const rendererNames = ${JSON.stringify(rendererNames)}; - protocol.registerSchemesAsPrivileged([ - { - scheme: '${APP_PROTOCOL_SCHEME}', - privileges: { standard: true, secure: true, supportFetchApi: true }, - }, - ]); + protocol.registerSchemesAsPrivileged(${JSON.stringify(privilegedSchemes)}); app.once('ready', function () { protocol.handle('${APP_PROTOCOL_SCHEME}', function (request) { const url = new URL(request.url); diff --git a/packages/plugin/vite/src/config/vite.main.config.ts b/packages/plugin/vite/src/config/vite.main.config.ts index 3264864f42..63dce63391 100644 --- a/packages/plugin/vite/src/config/vite.main.config.ts +++ b/packages/plugin/vite/src/config/vite.main.config.ts @@ -24,6 +24,9 @@ export function getConfig( forgeConfig.renderer .map(({ name }) => name) .filter((name) => name != null), + typeof forgeConfig.appProtocol === 'object' + ? forgeConfig.appProtocol.additionalPrivilegedSchemes + : undefined, ) : undefined; const config: UserConfig = { From ad59b3198cd5a98c653470f326296fc873b361b3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 06:29:49 +0000 Subject: [PATCH 03/20] feat(plugin-webpack): opt-in app:// protocol for serving packaged renderers Extend the appProtocol feature from plugin-vite to plugin-webpack, with the shared runtime moved into @electron-forge/core-utils so both bundler plugins inject identical protocol-serving code. - Move the app:// runtime generator (scheme registration, protocol handler with renderer-name allowlist and path traversal guard, additional privileged scheme support) from plugin-vite to core-utils. plugin-vite now re-exports the shared types and imports the shared generator; the runtime's global guard is renamed accordingly. - Add an opt-in `appProtocol` option to the webpack plugin config. When enabled, production builds inject the runtime via a raw entry-only BannerPlugin ahead of the webpack bootstrap, and `*_WEBPACK_ENTRY` defines for HTML entry points resolve to `app:///index.html` instead of a `file://` path. JS-only (no-window) entry points keep their `file://` paths, and development keeps dev server URLs, so existing `loadURL(MAIN_WINDOW_WEBPACK_ENTRY)` app code works unchanged in both modes. - Enable `appProtocol: true` in the webpack and webpack-typescript templates. The template main files need no changes since they already call loadURL unconditionally. The renderer output layout is identical across both plugins (/main bundle with ../renderer/), so the shared runtime's __dirname-relative lookup works for both. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JHmXuuGMGg1bcCCYDNVSKW --- .../vite/spec/config/vite.main.config.spec.ts | 39 ----- .../vite/spec/subprocess-worker.spec.ts | 2 +- packages/plugin/vite/src/Config.ts | 42 +---- .../vite/src/config/vite.base.config.ts | 2 +- .../vite/src/config/vite.main.config.ts | 2 +- .../plugin/webpack/spec/WebpackConfig.spec.ts | 151 ++++++++++++++++++ packages/plugin/webpack/src/Config.ts | 26 +++ packages/plugin/webpack/src/WebpackConfig.ts | 41 ++++- .../webpack-typescript/tmpl/forge.config.ts | 4 + .../template/webpack/tmpl/forge.config.js | 4 + .../core-utils/spec/app-protocol.spec.ts | 41 +++++ .../core-utils/src}/app-protocol.ts | 79 ++++++--- packages/utils/core-utils/src/index.ts | 1 + 13 files changed, 334 insertions(+), 100 deletions(-) create mode 100644 packages/utils/core-utils/spec/app-protocol.spec.ts rename packages/{plugin/vite/src/config => utils/core-utils/src}/app-protocol.ts (54%) diff --git a/packages/plugin/vite/spec/config/vite.main.config.spec.ts b/packages/plugin/vite/spec/config/vite.main.config.spec.ts index 77370b58b9..a9f1797f52 100644 --- a/packages/plugin/vite/spec/config/vite.main.config.spec.ts +++ b/packages/plugin/vite/spec/config/vite.main.config.spec.ts @@ -2,10 +2,6 @@ import path from 'node:path'; import { describe, expect, it } from 'vitest'; -import { - getAppProtocolBanner, - getAppProtocolEntryUrl, -} from '../../src/config/app-protocol'; import { getConfig } from '../../src/config/vite.main.config'; import type { VitePluginConfig } from '../../src/Config'; @@ -109,38 +105,3 @@ describe('vite.main.config', () => { expect(getBanner(config)).toBeUndefined(); }); }); - -describe('app-protocol', () => { - it('builds entry URLs on the app scheme', () => { - expect(getAppProtocolEntryUrl('main_window')).toEqual( - 'app://main_window/index.html', - ); - }); - - it('emits syntactically valid runtime code', () => { - const banner = getAppProtocolBanner(['main_window', 'second_window']); - // Throws on a syntax error without executing the code. - expect(() => new Function(banner)).not.toThrow(); - expect(banner).toContain('["main_window","second_window"]'); - }); - - it('registers additional privileged schemes alongside app', () => { - const banner = getAppProtocolBanner( - ['main_window'], - [{ scheme: 'media', privileges: { stream: true, bypassCSP: true } }], - ); - expect(() => new Function(banner)).not.toThrow(); - // A single registerSchemesAsPrivileged call containing both schemes, with - // the app scheme first. - const registrations = banner.match(/registerSchemesAsPrivileged/g); - expect(registrations).toHaveLength(1); - expect(banner).toMatch(/"scheme":"app".*"scheme":"media"/s); - expect(banner).toContain('"bypassCSP":true'); - }); - - it('throws when an additional scheme conflicts with the app scheme', () => { - expect(() => - getAppProtocolBanner(['main_window'], [{ scheme: 'APP' }]), - ).toThrow(/reserved/); - }); -}); diff --git a/packages/plugin/vite/spec/subprocess-worker.spec.ts b/packages/plugin/vite/spec/subprocess-worker.spec.ts index 714c3fbc06..67de4f77a1 100644 --- a/packages/plugin/vite/spec/subprocess-worker.spec.ts +++ b/packages/plugin/vite/spec/subprocess-worker.spec.ts @@ -163,7 +163,7 @@ describe('subprocess-worker', () => { // literals — local identifiers like `protocol` get mangled). expect(contents).toContain('registerSchemesAsPrivileged'); expect(contents).toContain('bypassCustomProtocolHandlers'); - expect(contents).toContain('__electronForgeViteAppProtocol'); + expect(contents).toContain('__electronForgeAppProtocol'); // The banner must come before the bundled module code so it runs first. expect(contents.indexOf('registerSchemesAsPrivileged')).toBeLessThan( contents.indexOf('exports'), diff --git a/packages/plugin/vite/src/Config.ts b/packages/plugin/vite/src/Config.ts index 9930264aea..c5c23ae87b 100644 --- a/packages/plugin/vite/src/Config.ts +++ b/packages/plugin/vite/src/Config.ts @@ -1,5 +1,12 @@ +import type { + AppProtocolConfig, + PrivilegedScheme, +} from '@electron-forge/core-utils'; import type { LibraryOptions } from 'vite'; +export type VitePluginPrivilegedScheme = PrivilegedScheme; +export type VitePluginAppProtocolConfig = AppProtocolConfig; + export interface VitePluginBuildConfig { /** * Alias of `build.lib.entry` in `config`. @@ -27,41 +34,6 @@ export interface VitePluginRendererConfig { config: string; } -/** - * A custom scheme to register as privileged, structurally compatible with - * Electron's `CustomScheme` type so values can be shared with app code. - */ -export interface VitePluginPrivilegedScheme { - scheme: string; - privileges?: { - standard?: boolean; - secure?: boolean; - bypassCSP?: boolean; - allowServiceWorkers?: boolean; - supportFetchApi?: boolean; - corsEnabled?: boolean; - stream?: boolean; - codeCache?: boolean; - }; -} - -export interface VitePluginAppProtocolConfig { - /** - * Additional custom schemes to register as privileged alongside `app://`. - * - * Electron only allows a single `protocol.registerSchemesAsPrivileged` call - * per app, and the runtime injected by `appProtocol` makes that call. An app - * that needs its own privileged schemes must therefore declare them here - * instead of calling `registerSchemesAsPrivileged` itself. The app still - * registers its own `protocol.handle` for these schemes — Forge only - * registers their privileges. - * - * The `app` scheme itself is reserved for Forge's renderer serving and may - * not appear in this list. - */ - additionalPrivilegedSchemes?: VitePluginPrivilegedScheme[]; -} - export interface VitePluginConfig { // Reserved option, may support modification in the future. // @defaultValue '.vite' diff --git a/packages/plugin/vite/src/config/vite.base.config.ts b/packages/plugin/vite/src/config/vite.base.config.ts index 61e9d0833f..1c228f9d73 100644 --- a/packages/plugin/vite/src/config/vite.base.config.ts +++ b/packages/plugin/vite/src/config/vite.base.config.ts @@ -1,6 +1,6 @@ import { builtinModules } from 'node:module'; -import { getAppProtocolEntryUrl } from './app-protocol.js'; +import { getAppProtocolEntryUrl } from '@electron-forge/core-utils'; import type { AddressInfo } from 'node:net'; import type { ConfigEnv, Plugin, UserConfig, ViteDevServer } from 'vite'; diff --git a/packages/plugin/vite/src/config/vite.main.config.ts b/packages/plugin/vite/src/config/vite.main.config.ts index 63dce63391..c41e740fd1 100644 --- a/packages/plugin/vite/src/config/vite.main.config.ts +++ b/packages/plugin/vite/src/config/vite.main.config.ts @@ -1,6 +1,6 @@ import { type ConfigEnv, mergeConfig, type UserConfig } from 'vite'; -import { getAppProtocolBanner } from './app-protocol.js'; +import { getAppProtocolBanner } from '@electron-forge/core-utils'; import { external, getBuildConfig, diff --git a/packages/plugin/webpack/spec/WebpackConfig.spec.ts b/packages/plugin/webpack/spec/WebpackConfig.spec.ts index 45ccb52bbe..cb7886ba52 100644 --- a/packages/plugin/webpack/spec/WebpackConfig.spec.ts +++ b/packages/plugin/webpack/spec/WebpackConfig.spec.ts @@ -183,6 +183,68 @@ describe('WebpackConfigGenerator', () => { ); }); + describe('appProtocol', () => { + it('sets HTML renderer entry points to app:// URLs in production', () => { + const config = { + appProtocol: true, + renderer: { + entryPoints: [ + { + name: 'hello', + html: 'foo.html', + js: 'foo.js', + }, + ], + }, + } as WebpackPluginConfig; + const generator = new WebpackConfigGenerator(config, '/', true, 3000); + const defines = generator.getDefines(); + + expect(defines.HELLO_WEBPACK_ENTRY).toEqual("'app://hello/index.html'"); + }); + + it('keeps JS-only entry points on file:// in production', () => { + const config = { + appProtocol: true, + renderer: { + entryPoints: [ + { + name: 'hello', + js: 'foo.js', + }, + ], + }, + } as WebpackPluginConfig; + const generator = new WebpackConfigGenerator(config, '/', true, 3000); + const defines = generator.getDefines(); + + expect(defines.HELLO_WEBPACK_ENTRY).toEqual( + "`file://${require('path').resolve(__dirname, '..', 'renderer', 'hello', 'index.js')}`", + ); + }); + + it('keeps dev server URLs in development', () => { + const config = { + appProtocol: true, + renderer: { + entryPoints: [ + { + name: 'hello', + html: 'foo.html', + js: 'foo.js', + }, + ], + }, + } as WebpackPluginConfig; + const generator = new WebpackConfigGenerator(config, '/', false, 3000); + const defines = generator.getDefines(); + + expect(defines.HELLO_WEBPACK_ENTRY).toEqual( + "'http://localhost:3000/hello/index.html'", + ); + }); + }); + describe('PRELOAD_WEBPACK_ENTRY', () => { const config = { mainConfig: {}, @@ -298,6 +360,95 @@ describe('WebpackConfigGenerator', () => { ); }); + describe('appProtocol runtime injection', () => { + const appProtocolConfig = { + mainConfig: { + entry: 'main.js', + }, + renderer: { + entryPoints: [ + { + name: 'main_window', + html: 'index.html', + js: 'renderer.js', + }, + { + name: 'worker', + preload: { + js: 'preload.js', + }, + }, + ], + }, + appProtocol: true, + } as WebpackPluginConfig; + + const findBannerPlugin = (plugins: unknown[] | undefined) => + plugins?.find( + (plugin) => plugin?.constructor?.name === 'BannerPlugin', + ) as { options: { banner: string; raw: boolean } } | undefined; + + it('injects the runtime banner into production main configs', async () => { + const generator = new WebpackConfigGenerator( + appProtocolConfig, + mockProjectDir, + true, + 3000, + ); + const webpackConfig = await generator.getMainConfig(); + const bannerPlugin = findBannerPlugin(webpackConfig.plugins); + expect(bannerPlugin).toBeDefined(); + expect(bannerPlugin!.options.raw).toBe(true); + expect(bannerPlugin!.options.banner).toContain( + 'registerSchemesAsPrivileged', + ); + // Window entry points are served; preload-only entries are not. + expect(bannerPlugin!.options.banner).toContain('["main_window"]'); + }); + + it('includes additional privileged schemes from the object form', async () => { + const generator = new WebpackConfigGenerator( + { + ...appProtocolConfig, + appProtocol: { + additionalPrivilegedSchemes: [ + { scheme: 'media', privileges: { stream: true } }, + ], + }, + }, + mockProjectDir, + true, + 3000, + ); + const webpackConfig = await generator.getMainConfig(); + const bannerPlugin = findBannerPlugin(webpackConfig.plugins); + expect(bannerPlugin!.options.banner).toContain('"media"'); + expect(bannerPlugin!.options.banner).toContain('"stream":true'); + }); + + it('does not inject the banner in development', async () => { + const generator = new WebpackConfigGenerator( + appProtocolConfig, + mockProjectDir, + false, + 3000, + ); + const webpackConfig = await generator.getMainConfig(); + expect(findBannerPlugin(webpackConfig.plugins)).toBeUndefined(); + }); + + it('does not inject the banner when appProtocol is not enabled', async () => { + const generator = new WebpackConfigGenerator( + { ...appProtocolConfig, appProtocol: undefined }, + mockProjectDir, + true, + 3000, + ); + const webpackConfig = await generator.getMainConfig(); + expect(findBannerPlugin(webpackConfig.plugins)).toBeUndefined(); + }); + }); + 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..cbf676e4ae 100644 --- a/packages/plugin/webpack/src/Config.ts +++ b/packages/plugin/webpack/src/Config.ts @@ -1,3 +1,4 @@ +import type { AppProtocolConfig } from '@electron-forge/core-utils'; import { Configuration as RawWebpackConfiguration } from 'webpack'; import WebpackDevServer from 'webpack-dev-server'; @@ -145,6 +146,31 @@ export interface WebpackPluginConfig { * single renderer configuration. Most usecases should not set this to an array. */ renderer: WebpackPluginRendererConfig | WebpackPluginRendererConfig[]; + /** + * Serve the built renderer files over a privileged `app://` custom scheme in + * packaged apps instead of loading them from `file://`, per Electron's + * security recommendations. See + * https://www.electronjs.org/docs/latest/api/protocol + * + * When enabled, the plugin injects the scheme registration and protocol + * handler into the production main-process bundle, and the `*_WEBPACK_ENTRY` + * magic constant for HTML entry points resolves to an + * `app:///index.html` URL in production (it is a dev server URL + * in development either way, so `mainWindow.loadURL(MAIN_WINDOW_WEBPACK_ENTRY)` + * keeps working unchanged). JS-only (no-window) entry points keep their + * `file://` paths. + * + * Notes: + * - `protocol.registerSchemesAsPrivileged` can only be called once per app, + * and the injected runtime makes that call. If your app needs its own + * privileged schemes, pass them via the object form's + * `additionalPrivilegedSchemes` instead of calling + * `registerSchemesAsPrivileged` yourself. + * - Requires the default CommonJS output for the main-process bundle. + * @defaultValue `false` + */ + appProtocol?: boolean | AppProtocolConfig; + /** * The TCP port for the dev servers. Defaults to 3000. */ diff --git a/packages/plugin/webpack/src/WebpackConfig.ts b/packages/plugin/webpack/src/WebpackConfig.ts index 0b7193d852..fa015715d3 100644 --- a/packages/plugin/webpack/src/WebpackConfig.ts +++ b/packages/plugin/webpack/src/WebpackConfig.ts @@ -1,11 +1,15 @@ import path from 'node:path'; +import { + getAppProtocolBanner, + getAppProtocolEntryUrl, +} from '@electron-forge/core-utils'; import debug from 'debug'; 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 { merge as webpackMerge } from 'webpack-merge'; import { @@ -135,6 +139,13 @@ export default class WebpackConfigGenerator { basename: string, ): string { if (this.isProd) { + // With `appProtocol` enabled, HTML entry points are served over the + // privileged `app://` scheme by the runtime injected into the main + // bundle. JS-only (no-window) entry points keep their `file://` paths — + // they are not window entry URLs. + if (this.pluginConfig.appProtocol && basename === 'index.html') { + return `'${getAppProtocolEntryUrl(entryPoint.name)}'`; + } return `\`file://$\{require('path').resolve(__dirname, '..', 'renderer', '${entryPoint.name}', '${basename}')}\``; } const protocol = @@ -223,6 +234,32 @@ export default class WebpackConfigGenerator { }; mainConfig.entry = fix(mainConfig.entry as EntryType); + // In production builds (not the dev server, where renderers are served + // over HTTP), prepend the runtime that registers the `app://` scheme and + // serves the built renderer files over it. `raw` emits the code verbatim + // (not wrapped in a comment) and `entryOnly` keeps it out of split chunks; + // as a banner it runs before any user code — see app-protocol.ts in + // @electron-forge/core-utils for the ordering constraints. + const appProtocolPlugins = + this.pluginConfig.appProtocol && this.isProd + ? [ + new BannerPlugin({ + banner: getAppProtocolBanner( + this.allPluginRendererOptions.flatMap((rendererOptions) => + (rendererOptions.entryPoints ?? []) + .filter((entryPoint) => !isPreloadOnly(entryPoint)) + .map((entryPoint) => entryPoint.name), + ), + typeof this.pluginConfig.appProtocol === 'object' + ? this.pluginConfig.appProtocol.additionalPrivilegedSchemes + : undefined, + ), + raw: true, + entryOnly: true, + }), + ] + : []; + return webpackMerge( { devtool: 'source-map', @@ -233,7 +270,7 @@ export default class WebpackConfigGenerator { filename: 'index.js', libraryTarget: 'commonjs2', }, - plugins: [new DefinePlugin(this.getDefines())], + plugins: [new DefinePlugin(this.getDefines()), ...appProtocolPlugins], node: { __dirname: false, __filename: false, diff --git a/packages/template/webpack-typescript/tmpl/forge.config.ts b/packages/template/webpack-typescript/tmpl/forge.config.ts index 46e40caaca..880d6689db 100644 --- a/packages/template/webpack-typescript/tmpl/forge.config.ts +++ b/packages/template/webpack-typescript/tmpl/forge.config.ts @@ -39,6 +39,10 @@ const config: ForgeConfig = { }, ], }, + // Serve the built renderer over a privileged `app://` custom scheme in + // packaged apps instead of loading it from `file://`, per Electron's + // security recommendations. + appProtocol: true, }), // Fuses are used to enable/disable various Electron functionality // at package time, before code signing the application diff --git a/packages/template/webpack/tmpl/forge.config.js b/packages/template/webpack/tmpl/forge.config.js index d6aa49c127..6854fc202a 100644 --- a/packages/template/webpack/tmpl/forge.config.js +++ b/packages/template/webpack/tmpl/forge.config.js @@ -46,6 +46,10 @@ module.exports = { }, ], }, + // Serve the built renderer over a privileged `app://` custom scheme in + // packaged apps instead of loading it from `file://`, per Electron's + // security recommendations. + appProtocol: true, }, }, // Fuses are used to enable/disable various Electron functionality diff --git a/packages/utils/core-utils/spec/app-protocol.spec.ts b/packages/utils/core-utils/spec/app-protocol.spec.ts new file mode 100644 index 0000000000..8ae529dd09 --- /dev/null +++ b/packages/utils/core-utils/spec/app-protocol.spec.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; + +import { + getAppProtocolBanner, + getAppProtocolEntryUrl, +} from '../src/app-protocol'; + +describe('app-protocol', () => { + it('builds entry URLs on the app scheme', () => { + expect(getAppProtocolEntryUrl('main_window')).toEqual( + 'app://main_window/index.html', + ); + }); + + it('emits syntactically valid runtime code', () => { + const banner = getAppProtocolBanner(['main_window', 'second_window']); + // Throws on a syntax error without executing the code. + expect(() => new Function(banner)).not.toThrow(); + expect(banner).toContain('["main_window","second_window"]'); + }); + + it('registers additional privileged schemes alongside app', () => { + const banner = getAppProtocolBanner( + ['main_window'], + [{ scheme: 'media', privileges: { stream: true, bypassCSP: true } }], + ); + expect(() => new Function(banner)).not.toThrow(); + // A single registerSchemesAsPrivileged call containing both schemes, with + // the app scheme first. + const registrations = banner.match(/registerSchemesAsPrivileged/g); + expect(registrations).toHaveLength(1); + expect(banner).toMatch(/"scheme":"app".*"scheme":"media"/s); + expect(banner).toContain('"bypassCSP":true'); + }); + + it('throws when an additional scheme conflicts with the app scheme', () => { + expect(() => + getAppProtocolBanner(['main_window'], [{ scheme: 'APP' }]), + ).toThrow(/reserved/); + }); +}); diff --git a/packages/plugin/vite/src/config/app-protocol.ts b/packages/utils/core-utils/src/app-protocol.ts similarity index 54% rename from packages/plugin/vite/src/config/app-protocol.ts rename to packages/utils/core-utils/src/app-protocol.ts index b39ca610f9..329f675688 100644 --- a/packages/plugin/vite/src/config/app-protocol.ts +++ b/packages/utils/core-utils/src/app-protocol.ts @@ -1,13 +1,13 @@ /** - * Support for serving packaged renderer bundles over a privileged custom - * `app://` scheme instead of `file://`, per Electron's security - * recommendations (secure origin, working `fetch()` of local resources, - * origin-scoped storage, etc.). + * Shared support for the bundler plugins' `appProtocol` option: serving + * packaged renderer bundles over a privileged custom `app://` scheme instead + * of `file://`, per Electron's security recommendations (secure origin, + * working `fetch()` of local resources, origin-scoped storage, etc.). * - * When `appProtocol` is enabled in the plugin config, the code returned by - * {@link getAppProtocolBanner} is prepended to the production main-process - * bundle. It must run before the app's `ready` event, so it is injected as a - * Rollup banner at the very top of the bundle: + * The code returned by {@link getAppProtocolBanner} is prepended to the + * production main-process bundle by the plugin (a Rollup banner for Vite, a + * raw `BannerPlugin` banner for webpack). It must run before the app's + * `ready` event, hence a banner at the very top of the bundle: * * - `protocol.registerSchemesAsPrivileged` may only be called once, before * `ready`. @@ -16,15 +16,52 @@ * registration order, so the handler is guaranteed to be registered before * a `createWindow()` in the app's own `ready` handler calls * `loadURL('app://...')`. + * + * Both plugins emit main-process bundles laid out as `/main-bundle.js` + * with renderers in `/../renderer//`, which is the layout the + * runtime's `__dirname`-relative lookup assumes. */ -import type { VitePluginPrivilegedScheme } from '../Config.js'; - export const APP_PROTOCOL_SCHEME = 'app'; /** - * Builds the `app:///` entry URL that the - * `*_VITE_ENTRY` define resolves to in production builds. + * A custom scheme to register as privileged, structurally compatible with + * Electron's `CustomScheme` type so values can be shared with app code. + */ +export interface PrivilegedScheme { + scheme: string; + privileges?: { + standard?: boolean; + secure?: boolean; + bypassCSP?: boolean; + allowServiceWorkers?: boolean; + supportFetchApi?: boolean; + corsEnabled?: boolean; + stream?: boolean; + codeCache?: boolean; + }; +} + +export interface AppProtocolConfig { + /** + * Additional custom schemes to register as privileged alongside `app://`. + * + * Electron only allows a single `protocol.registerSchemesAsPrivileged` call + * per app, and the runtime injected by `appProtocol` makes that call. An app + * that needs its own privileged schemes must therefore declare them here + * instead of calling `registerSchemesAsPrivileged` itself. The app still + * registers its own `protocol.handle` for these schemes — Forge only + * registers their privileges. + * + * The `app` scheme itself is reserved for Forge's renderer serving and may + * not appear in this list. + */ + additionalPrivilegedSchemes?: PrivilegedScheme[]; +} + +/** + * Builds the `app:///` entry URL that the plugins' + * entry magic constants resolve to in production builds. * * Note: `standard: true` schemes are parsed like `http://`, so the renderer * name becomes the URL host and is lower-cased by the URL parser. The runtime @@ -38,14 +75,14 @@ export function getAppProtocolEntryUrl(rendererName: string): string { * Returns the runtime source injected at the top of the production * main-process bundle. * - * The emitted code is plain CommonJS because the plugin builds main-process - * targets with `formats: ['cjs']`. If a user overrides `build.lib` to emit - * ESM, the banner's `require('electron')` would break — `appProtocol` is - * documented as requiring the default CJS output. + * The emitted code is plain CommonJS because both plugins emit CommonJS + * main-process bundles. If a user overrides their bundler config to emit ESM, + * the banner's `require('electron')` would break — `appProtocol` is + * documented as requiring the default CommonJS output. */ export function getAppProtocolBanner( rendererNames: string[], - additionalPrivilegedSchemes: VitePluginPrivilegedScheme[] = [], + additionalPrivilegedSchemes: PrivilegedScheme[] = [], ): string { for (const { scheme } of additionalPrivilegedSchemes) { if ( @@ -57,20 +94,20 @@ export function getAppProtocolBanner( ); } } - const privilegedSchemes: VitePluginPrivilegedScheme[] = [ + const privilegedSchemes: PrivilegedScheme[] = [ { scheme: APP_PROTOCOL_SCHEME, privileges: { standard: true, secure: true, supportFetchApi: true }, }, ...additionalPrivilegedSchemes, ]; - return `// Injected by @electron-forge/plugin-vite because \`appProtocol\` is enabled. + return `// Injected by Electron Forge because \`appProtocol\` is enabled. // Serves the built renderer files over the privileged \`${APP_PROTOCOL_SCHEME}://\` scheme instead // of \`file://\`, per Electron's security recommendations. (function () { 'use strict'; - if (globalThis.__electronForgeViteAppProtocol) return; - globalThis.__electronForgeViteAppProtocol = true; + if (globalThis.__electronForgeAppProtocol) return; + globalThis.__electronForgeAppProtocol = true; const { app, net, protocol } = require('electron'); const path = require('node:path'); const { pathToFileURL } = require('node:url'); diff --git a/packages/utils/core-utils/src/index.ts b/packages/utils/core-utils/src/index.ts index 80a3fe28a9..8ad023a815 100644 --- a/packages/utils/core-utils/src/index.ts +++ b/packages/utils/core-utils/src/index.ts @@ -1,3 +1,4 @@ +export * from './app-protocol.js'; export * from './rebuild.js'; export * from './electron-version.js'; export * from './fs.js'; From a862bf92965f66d2108b314079fc918be94fd398 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 06:45:02 +0000 Subject: [PATCH 04/20] fix(plugin-vite): drop unused VitePluginPrivilegedScheme type export knip flags it as an unused exported type: nothing references it since the appProtocol config only names VitePluginAppProtocolConfig, and the type was never released so there is no compatibility to preserve. Consumers who need the scheme shape can use PrivilegedScheme from @electron-forge/core-utils. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JHmXuuGMGg1bcCCYDNVSKW --- packages/plugin/vite/src/Config.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/plugin/vite/src/Config.ts b/packages/plugin/vite/src/Config.ts index 76b9903c2a..ef81fc1244 100644 --- a/packages/plugin/vite/src/Config.ts +++ b/packages/plugin/vite/src/Config.ts @@ -1,10 +1,6 @@ -import type { - AppProtocolConfig, - PrivilegedScheme, -} from '@electron-forge/core-utils'; +import type { AppProtocolConfig } from '@electron-forge/core-utils'; import type { LibraryOptions } from 'vite'; -export type VitePluginPrivilegedScheme = PrivilegedScheme; export type VitePluginAppProtocolConfig = AppProtocolConfig; export interface VitePluginBuildConfig { From 43c7d077a05c3055519d99912a640692f0baab64 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 07:33:21 +0000 Subject: [PATCH 05/20] test: verify packaged apps serve renderers over app:// in Verdaccio e2e MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a packagedRendererProtocol option to testForgeTemplate: when set, one extra test (npm only, to keep the packaging cost to a single run per template) scaffolds the template against Verdaccio, injects a probe that reports window.location.href from the preload over IPC, packages the app with electron-forge package, launches the packaged binary, and asserts the renderer window was served from that protocol. This is the only coverage the injected app:// runtime gets in a real packaged app — electron-forge start serves renderers from the dev server, so the existing start-based template tests never exercise it. All four bundler templates opt in with 'app:'. The scaffold command, Forge-script environment (lockfile/user-agent workarounds), and probe-file discovery are extracted into helpers shared with the existing start test instead of being duplicated. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JHmXuuGMGg1bcCCYDNVSKW --- ...vite-typescript-e2e.slow.verdaccio.spec.ts | 1 + .../template-vite-e2e.slow.verdaccio.spec.ts | 1 + ...pack-typescript-e2e.slow.verdaccio.spec.ts | 1 + ...emplate-webpack-e2e.slow.verdaccio.spec.ts | 1 + .../utils/test-utils/src/template-tests.ts | 350 +++++++++++++----- 5 files changed, 259 insertions(+), 95 deletions(-) diff --git a/packages/template/vite-typescript/spec/template-vite-typescript-e2e.slow.verdaccio.spec.ts b/packages/template/vite-typescript/spec/template-vite-typescript-e2e.slow.verdaccio.spec.ts index 81f4f493dc..1523e7b6ec 100644 --- a/packages/template/vite-typescript/spec/template-vite-typescript-e2e.slow.verdaccio.spec.ts +++ b/packages/template/vite-typescript/spec/template-vite-typescript-e2e.slow.verdaccio.spec.ts @@ -2,5 +2,6 @@ import { testForgeTemplate } from '@electron-forge/test-utils'; testForgeTemplate({ moduleFormats: ['cjs'], + packagedRendererProtocol: 'app:', templateName: 'vite-typescript', }); diff --git a/packages/template/vite/spec/template-vite-e2e.slow.verdaccio.spec.ts b/packages/template/vite/spec/template-vite-e2e.slow.verdaccio.spec.ts index a0151030f0..621a6587a5 100644 --- a/packages/template/vite/spec/template-vite-e2e.slow.verdaccio.spec.ts +++ b/packages/template/vite/spec/template-vite-e2e.slow.verdaccio.spec.ts @@ -2,5 +2,6 @@ import { testForgeTemplate } from '@electron-forge/test-utils'; testForgeTemplate({ moduleFormats: ['cjs'], + packagedRendererProtocol: 'app:', templateName: 'vite', }); diff --git a/packages/template/webpack-typescript/spec/template-webpack-typescript-e2e.slow.verdaccio.spec.ts b/packages/template/webpack-typescript/spec/template-webpack-typescript-e2e.slow.verdaccio.spec.ts index c515b25fff..b6012a7d14 100644 --- a/packages/template/webpack-typescript/spec/template-webpack-typescript-e2e.slow.verdaccio.spec.ts +++ b/packages/template/webpack-typescript/spec/template-webpack-typescript-e2e.slow.verdaccio.spec.ts @@ -2,5 +2,6 @@ import { testForgeTemplate } from '@electron-forge/test-utils'; testForgeTemplate({ moduleFormats: ['cjs'], + packagedRendererProtocol: 'app:', templateName: 'webpack-typescript', }); diff --git a/packages/template/webpack/spec/template-webpack-e2e.slow.verdaccio.spec.ts b/packages/template/webpack/spec/template-webpack-e2e.slow.verdaccio.spec.ts index a8ff04e6ce..5d1a002d8f 100644 --- a/packages/template/webpack/spec/template-webpack-e2e.slow.verdaccio.spec.ts +++ b/packages/template/webpack/spec/template-webpack-e2e.slow.verdaccio.spec.ts @@ -2,5 +2,6 @@ import { testForgeTemplate } from '@electron-forge/test-utils'; testForgeTemplate({ moduleFormats: ['cjs'], + packagedRendererProtocol: 'app:', templateName: 'webpack', }); diff --git a/packages/utils/test-utils/src/template-tests.ts b/packages/utils/test-utils/src/template-tests.ts index 65398eee1a..43e6ee9bd4 100644 --- a/packages/utils/test-utils/src/template-tests.ts +++ b/packages/utils/test-utils/src/template-tests.ts @@ -1,4 +1,5 @@ import { spawn } from '@malept/cross-spawn-promise'; +import { spawn as spawnChild } from 'node:child_process'; import path from 'node:path'; import { pathToFileURL } from 'node:url'; import fs from 'node:fs'; @@ -32,10 +33,198 @@ export type TestForgeTemplateOptions = { moduleFormats: SupportedModuleFormats; templateName: (typeof supportedTemplates)[number]; + + /** + * When set, adds a test that packages the scaffolded app with + * `electron-forge package`, launches the packaged binary, and asserts that + * the renderer window was served from a URL with this protocol — `app:` for + * templates that enable the bundler plugins' `appProtocol` option. This is + * the only place the injected `app://` runtime is exercised in a real + * packaged app: `electron-forge start` serves renderers from the dev server. + */ + packagedRendererProtocol?: 'app:' | 'file:'; }; const d = debug('electron-forge:testForgeTemplate'); +/** Runs the local `create-electron-app` build to scaffold a project. */ +function scaffoldProject( + tmpDir: string, + templateName: string, + packageManager: SupportedPackageManager, +) { + return spawn('node', [ + path.resolve( + __dirname, + '../../../external/create-electron-app/dist/create-electron-app.js', + ), + tmpDir, + `--template=${templateName}`, + `--package-manager=${packageManager}`, + + // Electron 41 is the last version that downloads its binary from a + // `postinstall` script. Yarn 4.18 disables install scripts by + // default (`enableScripts`), so on 41 the binary never gets + // downloaded and `electron-forge start` fails with "Electron failed + // to install correctly". Electron 42+ downloads the binary on demand + // the first time it's needed instead, so no install script is + // involved. + `--electron-version=43.4.0`, + ]); +} + +/** + * The environment for running a scaffolded project's Forge scripts (`start`, + * `package`) through its package manager. + */ +function forgeScriptEnv(packageManager: SupportedPackageManager) { + return { + PATH: process.env.PATH, + /** + * Forge scripts make the package manager check the lockfile it just + * wrote, and `XDG_CONFIG_HOME` is where the Verdaccio test harness + * puts the config that tells pnpm which registry to use, how old a + * release has to be, which packages are exempt, and to warn rather + * than fail when the check finds a difference. Dropping it would + * leave the check looking at the public registry under a policy + * the install never ran under. + */ + XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME, + ...(process.platform === 'linux' && { + DISPLAY: process.env.DISPLAY, + XAUTHORITY: process.env.XAUTHORITY, + }), + ...(packageManager !== 'yarn' && { + /** + * HACK: when running the test script with Yarn on a npm/pnpm + * project created by `create-electron-app`, + * `process.env.npm_config_user_agent` can be something like + * `yarn/4.10.3 npm/? node/v24.14.1 win32 x64`, and Forge's + * `checkPackageManager` function takes this value to mean that + * the project _also_ uses Yarn, so `electron-forge start` ends + * up failing because there's no `yarn.lock` ([relevant + * code](https://github.com/electron/forge/blob/001f41befe2c049b6f54ce7d6c55e83435141055/packages/api/cli/src/util/check-system.ts#L108-L135)). + * + * Removing the `yarn/4.10.3` user agent causes Forge to + * correctly identify the project's package manager, but since + * the version information can be missing for npm in `npm/?`, it + * fails semver validation and Forge treats it like an + * unsupported npm version, so we also have to spoof a supported + * npm version number to work around that behavior. + */ + npm_config_user_agent: process.env + .npm_config_user_agent!.replace(/^yarn\/\d+\.\d+\.\d+ /i, '') + .replace(/\bnpm\/\?/, 'npm/99.99.99'), + }), + }; +} + +/** + * Finds the preload file and main process entrypoint of a scaffolded project, + * which is where the tests inject their IPC probes. + */ +function findProbeFiles(tmpDir: string) { + const possiblePreloadFiles = ['preload.ts', 'preload.js'].map((item) => + path.resolve(tmpDir, `src/${item}`), + ); + + const possibleMainProcessEntrypoints = [ + 'main.ts', + 'main.js', + 'index.ts', + 'index.js', + ].map((item) => path.resolve(tmpDir, `src/${item}`)); + + const preloadPath = possiblePreloadFiles.find((item) => fs.existsSync(item))!; + const mainProcessEntrypoint = possibleMainProcessEntrypoints.find((item) => + fs.existsSync(item), + )!; + + let missingPreloadFileError: Error | null = null; + let missingMainProcessEntrypointError: Error | null = null; + + if (!preloadPath) { + missingPreloadFileError = new Error( + `"preload file not found in the following locations: ${JSON.stringify(possiblePreloadFiles, null, 2)}`, + ); + } + + if (!mainProcessEntrypoint) { + missingMainProcessEntrypointError = new Error( + `"main process entrypoint not found in the following locations: ${JSON.stringify(possibleMainProcessEntrypoints, null, 2)}`, + ); + } + + if (missingPreloadFileError || missingMainProcessEntrypointError) { + throw new AggregateError( + [missingPreloadFileError, missingMainProcessEntrypointError], + 'one or more files are missing', + ); + } + + return { preloadPath, mainProcessEntrypoint }; +} + +/** + * Locates the executable `electron-forge package` produced for the current + * platform. `initializePackageJSON` names the app after the project directory. + */ +function findPackagedExecutable(projectDir: string): string { + const appName = path.basename(projectDir).toLowerCase(); + const outDir = path.join(projectDir, 'out'); + const bundleDirName = fs + .readdirSync(outDir) + .find((entry) => entry.startsWith(`${appName}-${process.platform}-`)); + if (!bundleDirName) { + throw new Error( + `no packaged bundle for ${appName} in ${outDir}, only: ${fs.readdirSync(outDir).join(', ')}`, + ); + } + const bundleDir = path.join(outDir, bundleDirName); + switch (process.platform) { + case 'darwin': + return path.join( + bundleDir, + `${appName}.app`, + 'Contents', + 'MacOS', + appName, + ); + case 'win32': + return path.join(bundleDir, `${appName}.exe`); + default: + return path.join(bundleDir, appName); + } +} + +/** + * Launches a packaged app and resolves with its combined output once it + * exits. The injected probe makes the app exit itself; the kill timer only + * reaps a hung app so the assertion failure shows the collected output + * instead of a bare test timeout. + */ +function runPackagedApp(executable: string): Promise { + return new Promise((resolve, reject) => { + // --no-sandbox: CI containers don't always support Chromium's sandbox + // (e.g. when running as root); the probe only needs the window to load. + const child = spawnChild(executable, ['--no-sandbox'], { + env: { ...process.env }, + }); + let output = ''; + child.stdout.on('data', (chunk) => (output += chunk)); + child.stderr.on('data', (chunk) => (output += chunk)); + const killTimer = setTimeout(() => child.kill('SIGKILL'), 120_000); + child.on('error', (error) => { + clearTimeout(killTimer); + reject(error); + }); + child.on('close', () => { + clearTimeout(killTimer); + resolve(output); + }); + }); +} + /** * Summarizes the layout a package manager installed into a project, which is * what tells a flat `node_modules` (npm, Yarn, pnpm with `nodeLinker: hoisted`) @@ -116,6 +305,7 @@ function describePnpmInstalls(projectDir: string) { export function testForgeTemplate({ moduleFormats, templateName, + packagedRendererProtocol, }: TestForgeTemplateOptions) { describe(`${templateName} template`, () => { if (!moduleFormats.length) { @@ -158,66 +348,15 @@ export function testForgeTemplate({ throw new Error(`unknown template ${templateName}`); } - const createOutput = await spawn('node', [ - path.resolve( - __dirname, - '../../../external/create-electron-app/dist/create-electron-app.js', - ), + const createOutput = await scaffoldProject( tmpDir, - `--template=${templateName}`, - `--package-manager=${packageManager}`, - - // Electron 41 is the last version that downloads its binary from a - // `postinstall` script. Yarn 4.18 disables install scripts by - // default (`enableScripts`), so on 41 the binary never gets - // downloaded and `electron-forge start` fails with "Electron failed - // to install correctly". Electron 42+ downloads the binary on demand - // the first time it's needed instead, so no install script is - // involved. - `--electron-version=43.4.0`, - ]); - - d('tmpdir: ', pathToFileURL(tmpDir).toString()); - - const possiblePreloadFiles = ['preload.ts', 'preload.js'].map((item) => - path.resolve(tmpDir, `src/${item}`), + templateName, + packageManager, ); - const possibleMainProcessEntrypoints = [ - 'main.ts', - 'main.js', - 'index.ts', - 'index.js', - ].map((item) => path.resolve(tmpDir, `src/${item}`)); - - const preloadPath = possiblePreloadFiles.find((item) => - fs.existsSync(item), - )!; - const mainProcessEntrypoint = possibleMainProcessEntrypoints.find( - (item) => fs.existsSync(item), - )!; - - let missingPreloadFileError: Error | null = null; - let missingMainProcessEntrypointError: Error | null = null; - - if (!preloadPath) { - missingPreloadFileError = new Error( - `"preload file not found in the following locations: ${JSON.stringify(possiblePreloadFiles, null, 2)}`, - ); - } - - if (!mainProcessEntrypoint) { - missingMainProcessEntrypointError = new Error( - `"main process entrypoint not found in the following locations: ${JSON.stringify(possibleMainProcessEntrypoints, null, 2)}`, - ); - } + d('tmpdir: ', pathToFileURL(tmpDir).toString()); - if (missingPreloadFileError || missingMainProcessEntrypointError) { - throw new AggregateError( - [missingPreloadFileError, missingMainProcessEntrypointError], - 'one or more files are missing', - ); - } + const { preloadPath, mainProcessEntrypoint } = findProbeFiles(tmpDir); const preloadOkMessage = '__FORGE_INTERNAL_PRELOAD_PROCESS_OK__'; const mainProcessOkMessage = '__FORGE_INTERNAL_MAIN_PROCESS_OK__'; @@ -268,45 +407,7 @@ export function testForgeTemplate({ const startApp = () => spawn(packageManager, ['run', 'start'], { cwd: tmpDir, - env: { - PATH: process.env.PATH, - /** - * `start` makes the package manager check the lockfile it just - * wrote, and `XDG_CONFIG_HOME` is where the Verdaccio test harness - * puts the config that tells pnpm which registry to use, how old a - * release has to be, which packages are exempt, and to warn rather - * than fail when the check finds a difference. Dropping it would - * leave the check looking at the public registry under a policy - * the install never ran under. - */ - XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME, - ...(process.platform === 'linux' && { - DISPLAY: process.env.DISPLAY, - XAUTHORITY: process.env.XAUTHORITY, - }), - ...(packageManager !== 'yarn' && { - /** - * HACK: when running the test script with Yarn on a npm/pnpm - * project created by `create-electron-app`, - * `process.env.npm_config_user_agent` can be something like - * `yarn/4.10.3 npm/? node/v24.14.1 win32 x64`, and Forge's - * `checkPackageManager` function takes this value to mean that - * the project _also_ uses Yarn, so `electron-forge start` ends - * up failing because there's no `yarn.lock` ([relevant - * code](https://github.com/electron/forge/blob/001f41befe2c049b6f54ce7d6c55e83435141055/packages/api/cli/src/util/check-system.ts#L108-L135)). - * - * Removing the `yarn/4.10.3` user agent causes Forge to - * correctly identify the project's package manager, but since - * the version information can be missing for npm in `npm/?`, it - * fails semver validation and Forge treats it like an - * unsupported npm version, so we also have to spoof a supported - * npm version number to work around that behavior. - */ - npm_config_user_agent: process.env - .npm_config_user_agent!.replace(/^yarn\/\d+\.\d+\.\d+ /i, '') - .replace(/\bnpm\/\?/, 'npm/99.99.99'), - }), - }, + env: forgeScriptEnv(packageManager), }); let electronForgeStartOutput: string; @@ -360,6 +461,65 @@ export function testForgeTemplate({ }, ); + if (packagedRendererProtocol) { + test(`a packaged \`template-${templateName}\` app serves its renderer over \`${packagedRendererProtocol}//\``, async () => { + const packageManager: SupportedPackageManager = 'npm'; + const createOutput = await scaffoldProject( + tmpDir, + templateName, + packageManager, + ); + + d('tmpdir: ', pathToFileURL(tmpDir).toString()); + + const { preloadPath, mainProcessEntrypoint } = findProbeFiles(tmpDir); + const rendererLocationMessage = '__FORGE_INTERNAL_RENDERER_LOCATION__'; + + // The preload script runs inside the renderer after its navigation + // has committed, so `window.location` is the URL the window was + // actually served from — the thing the injected app:// runtime is + // supposed to determine in packaged apps. + await fs.promises.appendFile( + preloadPath, + [ + '\n', + `const { ipcRenderer } = require('electron');`, + `ipcRenderer.send('${rendererLocationMessage}', window.location.href);`, + ].join('\n'), + ); + + await fs.promises.appendFile( + mainProcessEntrypoint, + [ + '\n', + `const { ipcMain } = require('electron');`, + `ipcMain.on('${rendererLocationMessage}', (_event, href) => {`, + ` console.log('${rendererLocationMessage}:' + href);`, + ` app.exit(0);`, + `});`, + ].join('\n'), + ); + + try { + await spawn(packageManager, ['run', 'package'], { + cwd: tmpDir, + env: forgeScriptEnv(packageManager), + }); + } catch (error) { + console.error( + `[template-tests] create-electron-app said:\n${createOutput}`, + ); + throw error; + } + + const output = await runPackagedApp(findPackagedExecutable(tmpDir)); + + expect(output).toContain( + `${rendererLocationMessage}:${packagedRendererProtocol}//`, + ); + }, 480_000); // longer than the project-level timeout allows for. // Scaffolding, installing, packaging, and launching in one test takes + } + afterEach(async () => { await fs.promises.rm(tmpDir, { recursive: true, From b77ea59f33adeb04de6edc66a0c679a1f7aa4726 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 07:35:04 +0000 Subject: [PATCH 06/20] test: pass proxy configuration through to scaffolded project scripts @electron/get downloads the Electron binary during start and package; in environments that route outbound traffic through a proxy it needs the proxy variables, which forgeScriptEnv otherwise strips. Unset everywhere else, so this is a no-op on CI. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JHmXuuGMGg1bcCCYDNVSKW --- .../utils/test-utils/src/template-tests.ts | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/packages/utils/test-utils/src/template-tests.ts b/packages/utils/test-utils/src/template-tests.ts index 43e6ee9bd4..a2c3d89552 100644 --- a/packages/utils/test-utils/src/template-tests.ts +++ b/packages/utils/test-utils/src/template-tests.ts @@ -90,6 +90,29 @@ function forgeScriptEnv(packageManager: SupportedPackageManager) { * the install never ran under. */ XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME, + /** + * `@electron/get` (which downloads the Electron binary during `start` and + * `package`) and the package managers need the proxy configuration in + * environments that route outbound traffic through one; these variables + * are simply unset everywhere else. + */ + ...Object.fromEntries( + [ + 'HTTP_PROXY', + 'HTTPS_PROXY', + 'NO_PROXY', + 'http_proxy', + 'https_proxy', + 'no_proxy', + 'NODE_EXTRA_CA_CERTS', + 'ELECTRON_GET_USE_PROXY', + 'GLOBAL_AGENT_HTTP_PROXY', + 'GLOBAL_AGENT_HTTPS_PROXY', + 'GLOBAL_AGENT_NO_PROXY', + ] + .filter((name) => process.env[name] !== undefined) + .map((name) => [name, process.env[name]]), + ), ...(process.platform === 'linux' && { DISPLAY: process.env.DISPLAY, XAUTHORITY: process.env.XAUTHORITY, From 665212425408e7b6598f8e500cc369b9c1205489 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 07:45:15 +0000 Subject: [PATCH 07/20] fix(plugin-vite): pass appProtocol through to build workers serializableConfig narrowed the plugin config to {build, renderer} before handing it to the packaging build workers, silently dropping appProtocol. The workers then built main bundles whose *_VITE_ENTRY define resolved to undefined and injected no app:// runtime, so packaged apps called loadURL(undefined) and never loaded a window. Found by the new packaged-app Verdaccio test; add a unit regression test alongside. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JHmXuuGMGg1bcCCYDNVSKW --- packages/plugin/vite/spec/VitePlugin.spec.ts | 13 +++++++++++++ packages/plugin/vite/src/VitePlugin.ts | 15 +++++++++------ 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/packages/plugin/vite/spec/VitePlugin.spec.ts b/packages/plugin/vite/spec/VitePlugin.spec.ts index a67d91ea61..51c5923a46 100644 --- a/packages/plugin/vite/spec/VitePlugin.spec.ts +++ b/packages/plugin/vite/spec/VitePlugin.spec.ts @@ -19,6 +19,19 @@ describe('VitePlugin', async () => { const tmpdir = path.join(tmp, 'electron-forge-test-'); const viteTestDir = await fs.promises.mkdtemp(tmpdir); + describe('serializableConfig', () => { + it('carries appProtocol through to the build workers', () => { + // Regression test: the packaged app's `*_VITE_ENTRY` define and the + // injected app:// runtime both depend on the workers seeing this flag; + // dropping it here silently builds apps that call loadURL(undefined). + const plugin = new VitePlugin({ ...baseConfig, appProtocol: true }); + expect( + (plugin as unknown as { serializableConfig: VitePluginConfig }) + .serializableConfig.appProtocol, + ).toBe(true); + }); + }); + describe('packageAfterCopy', () => { const packageJSONPath = path.join(viteTestDir, 'package.json'); const packagedPath = path.join(viteTestDir, 'packaged'); diff --git a/packages/plugin/vite/src/VitePlugin.ts b/packages/plugin/vite/src/VitePlugin.ts index 52730783c4..12e4067389 100644 --- a/packages/plugin/vite/src/VitePlugin.ts +++ b/packages/plugin/vite/src/VitePlugin.ts @@ -30,7 +30,7 @@ const subprocessWorkerPath = path.resolve( ); function spawnViteBuild( - pluginConfig: Pick, + pluginConfig: Pick, kind: 'build' | 'renderer', index: number, projectDir: string, @@ -80,7 +80,7 @@ function spawnViteBuild( } function spawnViteBuildWatch( - pluginConfig: Pick, + pluginConfig: Pick, index: number, projectDir: string, devServerUrls: Record, @@ -335,17 +335,20 @@ the generated files). Instead, it is ${JSON.stringify(pj.main)}.`); /** * Serializable snapshot of the plugin config to pass to subprocess workers. - * We only include build[] and renderer[] — the worker needs the full renderer - * list for defines even when building a single main target. `hotRestart` is - * moot here: workers only run when packaging. + * We include build[], renderer[], and appProtocol — the worker needs the + * full renderer list for defines even when building a single main target, + * and appProtocol drives both the `*_VITE_ENTRY` defines and the runtime + * injected into production main bundles. `hotRestart` is moot here: workers + * only run when packaging. */ private get serializableConfig(): Pick< VitePluginConfig, - 'build' | 'renderer' + 'build' | 'renderer' | 'appProtocol' > { return { build: this.config.build, renderer: this.config.renderer, + appProtocol: this.config.appProtocol, }; } From 9c235dbf07da02f4e1d36aebafd0a0ccaca7b00c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 08:01:10 +0000 Subject: [PATCH 08/20] test: type the packaged-app probe for TypeScript entrypoints webpack-typescript compiles the main entrypoint with ts-loader under noImplicitAny, so the injected renderer-location probe's untyped (_event, href) callback failed the packaging build with TS7006. Type the parameters as unknown when the entrypoint is a .ts file; the .js entrypoints keep the untyped form they require. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JHmXuuGMGg1bcCCYDNVSKW --- packages/utils/test-utils/src/template-tests.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/utils/test-utils/src/template-tests.ts b/packages/utils/test-utils/src/template-tests.ts index a2c3d89552..689fed29fc 100644 --- a/packages/utils/test-utils/src/template-tests.ts +++ b/packages/utils/test-utils/src/template-tests.ts @@ -511,13 +511,20 @@ export function testForgeTemplate({ ].join('\n'), ); + // TypeScript entrypoints compile under `noImplicitAny` (webpack's + // ts-loader fails the packaging build on it), so the callback + // parameters need explicit types there — which .js entrypoints can't + // carry. + const probeParams = mainProcessEntrypoint.endsWith('.ts') + ? '_event: unknown, href: unknown' + : '_event, href'; await fs.promises.appendFile( mainProcessEntrypoint, [ '\n', `const { ipcMain } = require('electron');`, - `ipcMain.on('${rendererLocationMessage}', (_event, href) => {`, - ` console.log('${rendererLocationMessage}:' + href);`, + `ipcMain.on('${rendererLocationMessage}', (${probeParams}) => {`, + ` console.log('${rendererLocationMessage}:' + String(href));`, ` app.exit(0);`, `});`, ].join('\n'), From c70139c365dcf34242f7ce7559c4ebd301f97161 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 16:33:46 +0000 Subject: [PATCH 09/20] feat: configurable serving scheme for appProtocol, with validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a scheme field to the appProtocol object form so apps can serve their renderers over a scheme of their choosing instead of the default app://, e.g. appProtocol: { scheme: 'myapp' }. The scheme is validated at build time in a shared resolveAppProtocolConfig() normalizer: it must be a syntactically valid lowercase URI scheme (RFC 3986; Chromium lower-cases schemes at parse time so uppercase registrations could never match), and must not be a scheme Chromium/Electron already claim (http, file, devtools, ...). The additional-privileged-schemes reservation check now applies to the chosen scheme rather than the literal 'app' — which also means 'app' itself becomes usable as an additional scheme when the serving scheme differs. The docs call out that the scheme is part of the renderer's origin, so renaming it after an app has shipped orphans origin-scoped data (localStorage, IndexedDB, service worker registrations) and should be treated as a data migration. Verified by unit specs across both plugins, a real subprocess build carrying the custom scheme through the config round-trip, and a packaged asar app loading its window over the renamed scheme. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JHmXuuGMGg1bcCCYDNVSKW --- .../vite/spec/config/vite.base.config.spec.ts | 17 +++ .../vite/spec/config/vite.main.config.spec.ts | 21 +++ .../vite/spec/subprocess-worker.spec.ts | 8 +- packages/plugin/vite/src/Config.ts | 4 + .../vite/src/config/vite.base.config.ts | 12 +- .../vite/src/config/vite.main.config.ts | 4 +- .../plugin/webpack/spec/WebpackConfig.spec.ts | 21 +++ packages/plugin/webpack/src/Config.ts | 4 + packages/plugin/webpack/src/WebpackConfig.ts | 10 +- .../core-utils/spec/app-protocol.spec.ts | 81 ++++++++++-- packages/utils/core-utils/src/app-protocol.ts | 125 +++++++++++++++--- 11 files changed, 265 insertions(+), 42 deletions(-) diff --git a/packages/plugin/vite/spec/config/vite.base.config.spec.ts b/packages/plugin/vite/spec/config/vite.base.config.spec.ts index 706700a299..9c464c3fe8 100644 --- a/packages/plugin/vite/spec/config/vite.base.config.spec.ts +++ b/packages/plugin/vite/spec/config/vite.base.config.spec.ts @@ -100,6 +100,23 @@ describe('vite.base.config', () => { expect(define1).toEqual(define2); }); + it('getBuildDefine:build with a custom appProtocol scheme', () => { + const define = getBuildDefine({ + command: 'build', + mode: 'production', + root: configRoot, + forgeConfig: { ...forgeConfig, appProtocol: { scheme: 'myapp' } }, + forgeConfigSelf: forgeConfig.build[0], + }); + + expect(define.MAIN_WINDOW_VITE_ENTRY).toEqual( + '"myapp://main_window/index.html"', + ); + expect(define.SECOND_WINDOW_VITE_ENTRY).toEqual( + '"myapp://second_window/index.html"', + ); + }); + it('getBuildDefine:serve', async () => { const servers = await Promise.all( forgeConfig.renderer.map(({ name }) => diff --git a/packages/plugin/vite/spec/config/vite.main.config.spec.ts b/packages/plugin/vite/spec/config/vite.main.config.spec.ts index a9f1797f52..7c1ff638f7 100644 --- a/packages/plugin/vite/spec/config/vite.main.config.spec.ts +++ b/packages/plugin/vite/spec/config/vite.main.config.spec.ts @@ -79,6 +79,27 @@ describe('vite.main.config', () => { expect(banner).toContain('"stream":true'); }); + it('serves over a custom scheme when configured', () => { + const config = getConfig( + buildEnv({ + forgeConfig: { ...forgeConfig, appProtocol: { scheme: 'myapp' } }, + }), + ); + const banner = getBanner(config); + expect(banner).toContain('"scheme":"myapp"'); + expect(banner).not.toContain('"scheme":"app"'); + }); + + it('rejects an invalid scheme at config time', () => { + expect(() => + getConfig( + buildEnv({ + forgeConfig: { ...forgeConfig, appProtocol: { scheme: 'My App' } }, + }), + ), + ).toThrow(/valid lowercase URI scheme/); + }); + it('rejects an additional privileged scheme named app', () => { expect(() => getConfig( diff --git a/packages/plugin/vite/spec/subprocess-worker.spec.ts b/packages/plugin/vite/spec/subprocess-worker.spec.ts index 67de4f77a1..1f247bb65a 100644 --- a/packages/plugin/vite/spec/subprocess-worker.spec.ts +++ b/packages/plugin/vite/spec/subprocess-worker.spec.ts @@ -190,6 +190,7 @@ describe('subprocess-worker', () => { }, ], appProtocol: { + scheme: 'custom-app', additionalPrivilegedSchemes: [ { scheme: 'media', privileges: { stream: true } }, ], @@ -204,9 +205,10 @@ describe('subprocess-worker', () => { 'utf8', ); expect(contents).toContain('registerSchemesAsPrivileged'); - // The additional scheme survives the JSON round-trip through - // FORGE_VITE_CONFIG into the worker's banner (minifiers may re-quote - // strings, so match any quote style). + // The custom serving scheme and the additional scheme survive the JSON + // round-trip through FORGE_VITE_CONFIG into the worker's banner + // (minifiers may re-quote strings, so match any quote style). + expect(contents).toMatch(/[`'"]custom-app[`'"]/); expect(contents).toMatch(/[`'"]media[`'"]/); expect(contents).toMatch(/stream\s*:\s*(true|!0)/); }); diff --git a/packages/plugin/vite/src/Config.ts b/packages/plugin/vite/src/Config.ts index ef81fc1244..4cb2af32b2 100644 --- a/packages/plugin/vite/src/Config.ts +++ b/packages/plugin/vite/src/Config.ts @@ -71,6 +71,10 @@ export interface VitePluginConfig { * privileged schemes, pass them via the object form's * {@link VitePluginAppProtocolConfig.additionalPrivilegedSchemes} instead * of calling `registerSchemesAsPrivileged` yourself. + * - The object form's `scheme` renames the serving scheme (default `app`). + * The scheme is part of the renderer's origin, so pick it before the + * first release — renaming later orphans origin-scoped data such as + * `localStorage` and IndexedDB. * - Requires the default CommonJS output for main-process targets. * @defaultValue `false` */ diff --git a/packages/plugin/vite/src/config/vite.base.config.ts b/packages/plugin/vite/src/config/vite.base.config.ts index 4c769962eb..dfbcc5b8d7 100644 --- a/packages/plugin/vite/src/config/vite.base.config.ts +++ b/packages/plugin/vite/src/config/vite.base.config.ts @@ -3,7 +3,10 @@ import { styleText } from 'node:util'; import { requestAppRestart } from '@electron-forge/core-utils/restart'; -import { getAppProtocolEntryUrl } from '@electron-forge/core-utils'; +import { + getAppProtocolEntryUrl, + resolveAppProtocolConfig, +} from '@electron-forge/core-utils'; import type { AddressInfo } from 'node:net'; import type { ConfigEnv, Plugin, UserConfig, ViteDevServer } from 'vite'; @@ -60,6 +63,9 @@ export function getBuildDefine(env: ConfigEnv<'build'>) { .filter(({ name }) => name != null) .map(({ name }) => name!); const defineKeys = getDefineKeys(names); + const appProtocol = forgeConfig.appProtocol + ? resolveAppProtocolConfig(forgeConfig.appProtocol) + : undefined; const define = Object.entries(defineKeys).reduce( (acc, [name, keys]) => { const { VITE_DEV_SERVER_URL, VITE_NAME, VITE_ENTRY } = keys; @@ -75,8 +81,8 @@ export function getBuildDefine(env: ConfigEnv<'build'>) { [VITE_ENTRY]: command === 'serve' ? JSON.stringify(viteDevServerUrls[VITE_DEV_SERVER_URL]) - : forgeConfig.appProtocol - ? JSON.stringify(getAppProtocolEntryUrl(name)) + : appProtocol + ? JSON.stringify(getAppProtocolEntryUrl(name, appProtocol.scheme)) : undefined, }; return { ...acc, ...def }; diff --git a/packages/plugin/vite/src/config/vite.main.config.ts b/packages/plugin/vite/src/config/vite.main.config.ts index 98173a40b1..a5055a7e01 100644 --- a/packages/plugin/vite/src/config/vite.main.config.ts +++ b/packages/plugin/vite/src/config/vite.main.config.ts @@ -24,9 +24,7 @@ export function getConfig( forgeConfig.renderer .map(({ name }) => name) .filter((name) => name != null), - typeof forgeConfig.appProtocol === 'object' - ? forgeConfig.appProtocol.additionalPrivilegedSchemes - : undefined, + forgeConfig.appProtocol, ) : undefined; const config: UserConfig = { diff --git a/packages/plugin/webpack/spec/WebpackConfig.spec.ts b/packages/plugin/webpack/spec/WebpackConfig.spec.ts index cb7886ba52..b09b5ffd3e 100644 --- a/packages/plugin/webpack/spec/WebpackConfig.spec.ts +++ b/packages/plugin/webpack/spec/WebpackConfig.spec.ts @@ -203,6 +203,27 @@ describe('WebpackConfigGenerator', () => { expect(defines.HELLO_WEBPACK_ENTRY).toEqual("'app://hello/index.html'"); }); + it('uses a custom scheme for entry URLs when configured', () => { + const config = { + appProtocol: { scheme: 'myapp' }, + renderer: { + entryPoints: [ + { + name: 'hello', + html: 'foo.html', + js: 'foo.js', + }, + ], + }, + } as WebpackPluginConfig; + const generator = new WebpackConfigGenerator(config, '/', true, 3000); + const defines = generator.getDefines(); + + expect(defines.HELLO_WEBPACK_ENTRY).toEqual( + "'myapp://hello/index.html'", + ); + }); + it('keeps JS-only entry points on file:// in production', () => { const config = { appProtocol: true, diff --git a/packages/plugin/webpack/src/Config.ts b/packages/plugin/webpack/src/Config.ts index cbf676e4ae..0ebb35542b 100644 --- a/packages/plugin/webpack/src/Config.ts +++ b/packages/plugin/webpack/src/Config.ts @@ -166,6 +166,10 @@ export interface WebpackPluginConfig { * privileged schemes, pass them via the object form's * `additionalPrivilegedSchemes` instead of calling * `registerSchemesAsPrivileged` yourself. + * - The object form's `scheme` renames the serving scheme (default `app`). + * The scheme is part of the renderer's origin, so pick it before the + * first release — renaming later orphans origin-scoped data such as + * `localStorage` and IndexedDB. * - Requires the default CommonJS output for the main-process bundle. * @defaultValue `false` */ diff --git a/packages/plugin/webpack/src/WebpackConfig.ts b/packages/plugin/webpack/src/WebpackConfig.ts index fa015715d3..adaf780686 100644 --- a/packages/plugin/webpack/src/WebpackConfig.ts +++ b/packages/plugin/webpack/src/WebpackConfig.ts @@ -3,6 +3,7 @@ import path from 'node:path'; import { getAppProtocolBanner, getAppProtocolEntryUrl, + resolveAppProtocolConfig, } from '@electron-forge/core-utils'; import debug from 'debug'; import HtmlWebpackPlugin from 'html-webpack-plugin'; @@ -144,7 +145,10 @@ export default class WebpackConfigGenerator { // bundle. JS-only (no-window) entry points keep their `file://` paths — // they are not window entry URLs. if (this.pluginConfig.appProtocol && basename === 'index.html') { - return `'${getAppProtocolEntryUrl(entryPoint.name)}'`; + const { scheme } = resolveAppProtocolConfig( + this.pluginConfig.appProtocol, + ); + return `'${getAppProtocolEntryUrl(entryPoint.name, scheme)}'`; } return `\`file://$\{require('path').resolve(__dirname, '..', 'renderer', '${entryPoint.name}', '${basename}')}\``; } @@ -250,9 +254,7 @@ export default class WebpackConfigGenerator { .filter((entryPoint) => !isPreloadOnly(entryPoint)) .map((entryPoint) => entryPoint.name), ), - typeof this.pluginConfig.appProtocol === 'object' - ? this.pluginConfig.appProtocol.additionalPrivilegedSchemes - : undefined, + this.pluginConfig.appProtocol, ), raw: true, entryOnly: true, diff --git a/packages/utils/core-utils/spec/app-protocol.spec.ts b/packages/utils/core-utils/spec/app-protocol.spec.ts index 8ae529dd09..26f9f11122 100644 --- a/packages/utils/core-utils/spec/app-protocol.spec.ts +++ b/packages/utils/core-utils/spec/app-protocol.spec.ts @@ -3,15 +3,22 @@ import { describe, expect, it } from 'vitest'; import { getAppProtocolBanner, getAppProtocolEntryUrl, + resolveAppProtocolConfig, } from '../src/app-protocol'; describe('app-protocol', () => { - it('builds entry URLs on the app scheme', () => { + it('builds entry URLs on the app scheme by default', () => { expect(getAppProtocolEntryUrl('main_window')).toEqual( 'app://main_window/index.html', ); }); + it('builds entry URLs on a custom scheme', () => { + expect(getAppProtocolEntryUrl('main_window', 'myapp')).toEqual( + 'myapp://main_window/index.html', + ); + }); + it('emits syntactically valid runtime code', () => { const banner = getAppProtocolBanner(['main_window', 'second_window']); // Throws on a syntax error without executing the code. @@ -19,23 +26,79 @@ describe('app-protocol', () => { expect(banner).toContain('["main_window","second_window"]'); }); - it('registers additional privileged schemes alongside app', () => { - const banner = getAppProtocolBanner( - ['main_window'], - [{ scheme: 'media', privileges: { stream: true, bypassCSP: true } }], - ); + it('registers and handles a custom scheme', () => { + const banner = getAppProtocolBanner(['main_window'], { scheme: 'myapp' }); + expect(() => new Function(banner)).not.toThrow(); + expect(banner).toContain('"scheme":"myapp"'); + expect(banner).toContain('protocol.handle("myapp"'); + expect(banner).not.toContain('"scheme":"app"'); + }); + + it('registers additional privileged schemes alongside the serving scheme', () => { + const banner = getAppProtocolBanner(['main_window'], { + additionalPrivilegedSchemes: [ + { scheme: 'media', privileges: { stream: true, bypassCSP: true } }, + ], + }); expect(() => new Function(banner)).not.toThrow(); // A single registerSchemesAsPrivileged call containing both schemes, with - // the app scheme first. + // the serving scheme first. const registrations = banner.match(/registerSchemesAsPrivileged/g); expect(registrations).toHaveLength(1); expect(banner).toMatch(/"scheme":"app".*"scheme":"media"/s); expect(banner).toContain('"bypassCSP":true'); }); - it('throws when an additional scheme conflicts with the app scheme', () => { + it('throws when an additional scheme conflicts with the serving scheme', () => { + expect(() => + getAppProtocolBanner(['main_window'], { + additionalPrivilegedSchemes: [{ scheme: 'APP' }], + }), + ).toThrow(/reserved/); expect(() => - getAppProtocolBanner(['main_window'], [{ scheme: 'APP' }]), + resolveAppProtocolConfig({ + scheme: 'myapp', + additionalPrivilegedSchemes: [{ scheme: 'myapp' }], + }), ).toThrow(/reserved/); }); + + it('allows app as an additional scheme when the serving scheme differs', () => { + const { additionalPrivilegedSchemes } = resolveAppProtocolConfig({ + scheme: 'myapp', + additionalPrivilegedSchemes: [{ scheme: 'app' }], + }); + expect(additionalPrivilegedSchemes).toEqual([{ scheme: 'app' }]); + }); + + it.each(['MyApp', '1app', 'my app', 'my_app', ''])( + 'rejects the syntactically invalid scheme %j', + (scheme) => { + expect(() => resolveAppProtocolConfig({ scheme })).toThrow( + /valid lowercase URI scheme/, + ); + }, + ); + + it.each(['http', 'https', 'file', 'devtools', 'chrome'])( + 'rejects the reserved scheme %j', + (scheme) => { + expect(() => resolveAppProtocolConfig({ scheme })).toThrow( + /already claimed/, + ); + }, + ); + + it('accepts RFC 3986 scheme characters', () => { + expect(resolveAppProtocolConfig({ scheme: 'my-app.v2+x' }).scheme).toEqual( + 'my-app.v2+x', + ); + }); + + it('resolves the boolean form to the defaults', () => { + expect(resolveAppProtocolConfig(true)).toEqual({ + scheme: 'app', + additionalPrivilegedSchemes: [], + }); + }); }); diff --git a/packages/utils/core-utils/src/app-protocol.ts b/packages/utils/core-utils/src/app-protocol.ts index 329f675688..52b1e229cb 100644 --- a/packages/utils/core-utils/src/app-protocol.ts +++ b/packages/utils/core-utils/src/app-protocol.ts @@ -24,6 +24,38 @@ export const APP_PROTOCOL_SCHEME = 'app'; +/** + * URI scheme syntax per RFC 3986, restricted to lowercase: Chromium + * lower-cases schemes at parse time, so an uppercase registration could never + * match a request. + */ +const SCHEME_SYNTAX = /^[a-z][a-z0-9+.-]*$/; + +/** + * Schemes Chromium or Electron already claim; registering one of these as the + * serving scheme would clash with built-in handling instead of serving the + * renderer. + */ +const RESERVED_SCHEMES = new Set([ + 'about', + 'blob', + 'chrome', + 'chrome-error', + 'chrome-extension', + 'data', + 'devtools', + 'file', + 'filesystem', + 'ftp', + 'http', + 'https', + 'javascript', + 'mailto', + 'view-source', + 'ws', + 'wss', +]); + /** * A custom scheme to register as privileged, structurally compatible with * Electron's `CustomScheme` type so values can be shared with app code. @@ -44,7 +76,23 @@ export interface PrivilegedScheme { export interface AppProtocolConfig { /** - * Additional custom schemes to register as privileged alongside `app://`. + * The custom scheme the built renderer files are served over. + * + * ⚠️ The scheme is part of the renderer's origin (`scheme://renderer-name`), + * which keys `localStorage`, IndexedDB, service worker registrations, and + * everything else origin-scoped. Changing it after an app has shipped + * orphans that data — pick it before the first release and treat a later + * rename as a data migration. + * + * Must be a valid lowercase URI scheme (a letter followed by letters, + * digits, `+`, `-`, or `.`) that Chromium/Electron do not already claim. + * @defaultValue 'app' + */ + scheme?: string; + + /** + * Additional custom schemes to register as privileged alongside the serving + * scheme. * * Electron only allows a single `protocol.registerSchemesAsPrivileged` call * per app, and the runtime injected by `appProtocol` makes that call. An app @@ -53,22 +101,67 @@ export interface AppProtocolConfig { * registers its own `protocol.handle` for these schemes — Forge only * registers their privileges. * - * The `app` scheme itself is reserved for Forge's renderer serving and may - * not appear in this list. + * The serving scheme itself ({@link scheme}, `app` by default) is reserved + * for Forge's renderer serving and may not appear in this list. */ additionalPrivilegedSchemes?: PrivilegedScheme[]; } +export interface ResolvedAppProtocolConfig { + scheme: string; + additionalPrivilegedSchemes: PrivilegedScheme[]; +} + +/** + * Normalizes the `appProtocol` option's boolean/object forms and validates + * the chosen scheme. Throws (failing the build) rather than emitting a + * runtime that could never serve a window. + */ +export function resolveAppProtocolConfig( + appProtocol: boolean | AppProtocolConfig, +): ResolvedAppProtocolConfig { + const config = typeof appProtocol === 'object' ? appProtocol : {}; + const scheme = config.scheme ?? APP_PROTOCOL_SCHEME; + + if (typeof scheme !== 'string' || !SCHEME_SYNTAX.test(scheme)) { + throw new Error( + `\`appProtocol.scheme\` must be a valid lowercase URI scheme (a letter followed by letters, digits, '+', '-', or '.'), got ${JSON.stringify(scheme)}.`, + ); + } + if (RESERVED_SCHEMES.has(scheme)) { + throw new Error( + `\`appProtocol.scheme\` cannot be '${scheme}' — that scheme is already claimed by Chromium/Electron. Pick a scheme of your own, e.g. 'app'.`, + ); + } + + const additionalPrivilegedSchemes = config.additionalPrivilegedSchemes ?? []; + for (const additional of additionalPrivilegedSchemes) { + if ( + typeof additional.scheme !== 'string' || + additional.scheme.toLowerCase() === scheme + ) { + throw new Error( + `The '${scheme}' scheme is reserved for serving renderer files when \`appProtocol\` is enabled — remove it from \`additionalPrivilegedSchemes\` (schemes must be non-empty strings).`, + ); + } + } + + return { scheme, additionalPrivilegedSchemes }; +} + /** - * Builds the `app:///` entry URL that the plugins' + * Builds the `:///` entry URL that the plugins' * entry magic constants resolve to in production builds. * * Note: `standard: true` schemes are parsed like `http://`, so the renderer * name becomes the URL host and is lower-cased by the URL parser. The runtime * handler compensates by matching renderer names case-insensitively. */ -export function getAppProtocolEntryUrl(rendererName: string): string { - return `${APP_PROTOCOL_SCHEME}://${rendererName}/index.html`; +export function getAppProtocolEntryUrl( + rendererName: string, + scheme: string = APP_PROTOCOL_SCHEME, +): string { + return `${scheme}://${rendererName}/index.html`; } /** @@ -82,27 +175,19 @@ export function getAppProtocolEntryUrl(rendererName: string): string { */ export function getAppProtocolBanner( rendererNames: string[], - additionalPrivilegedSchemes: PrivilegedScheme[] = [], + appProtocol: boolean | AppProtocolConfig = true, ): string { - for (const { scheme } of additionalPrivilegedSchemes) { - if ( - typeof scheme !== 'string' || - scheme.toLowerCase() === APP_PROTOCOL_SCHEME - ) { - throw new Error( - `The '${APP_PROTOCOL_SCHEME}' scheme is reserved for serving renderer files when \`appProtocol\` is enabled — remove it from \`additionalPrivilegedSchemes\` (schemes must be non-empty strings).`, - ); - } - } + const { scheme, additionalPrivilegedSchemes } = + resolveAppProtocolConfig(appProtocol); const privilegedSchemes: PrivilegedScheme[] = [ { - scheme: APP_PROTOCOL_SCHEME, + scheme, privileges: { standard: true, secure: true, supportFetchApi: true }, }, ...additionalPrivilegedSchemes, ]; return `// Injected by Electron Forge because \`appProtocol\` is enabled. -// Serves the built renderer files over the privileged \`${APP_PROTOCOL_SCHEME}://\` scheme instead +// Serves the built renderer files over the privileged \`${scheme}://\` scheme instead // of \`file://\`, per Electron's security recommendations. (function () { 'use strict'; @@ -114,7 +199,7 @@ export function getAppProtocolBanner( const rendererNames = ${JSON.stringify(rendererNames)}; protocol.registerSchemesAsPrivileged(${JSON.stringify(privilegedSchemes)}); app.once('ready', function () { - protocol.handle('${APP_PROTOCOL_SCHEME}', function (request) { + protocol.handle(${JSON.stringify(scheme)}, function (request) { const url = new URL(request.url); // The URL host is lower-cased by the parser; renderer names may not be. const name = rendererNames.find(function (rendererName) { From c9d72b05797da12da082ad58b4161b795848b9d3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 22:19:56 +0000 Subject: [PATCH 10/20] fix: address app:// protocol review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses MarshallOfSound's review of the appProtocol feature: - webpack: serve all origins from the shared .webpack/renderer/ root with publicPath '/' for Web-target renderers, and carry the per-entry subdirectory in entry URLs (app:////index.html). The previous per-name origin root broke every asset URL html-webpack-plugin emitted under publicPath 'auto', so packaged webpack apps loaded HTML but none of their JS or CSS. - Make the packaged-app Verdaccio probe prove the renderer bundle actually executed: the renderer script posts a message, the preload forwards it with window.location.href over IPC, the main process logs it. Navigation alone no longer passes the test. - Guard the injected runtime with process.type !== 'browser' so utility-process/forked-worker bundles built through main targets no-op instead of crashing on require('electron').app. - vite: inject the runtime via a Forge-owned plugin's outputOptions hook instead of build.rollupOptions.output.banner, so a user's own banner composes with the runtime instead of replacing it; prefix the runtime with its own 'use strict' so the bundle's directive prologue stays effective. - Emit a registration-only runtime in development for both plugins so the serving scheme and additionalPrivilegedSchemes carry the same privileges under electron-forge start as in the packaged app; this also makes webpack validate the config in dev, matching vite. - webpack: keep nodeIntegration renderers on file:// — Electron only derives renderer __dirname from file: URLs, which AssetRelocatorPatch relies on for relocated native modules and assets in production. - vite: resolve *_VITE_ENTRY to a file:// expression in builds without appProtocol so template-derived loadURL(MAIN_WINDOW_VITE_ENTRY) code cannot break only when packaged. - Grant the serving scheme stream and codeCache by default and accept a privileges override in the object form (the runtime owns the app's single registerSchemesAsPrivileged call). - Validate additionalPrivilegedSchemes entries against the scheme syntax, and validate renderer names as URL hosts. - Wrap the handler's decodeURIComponent so malformed escapes 400 instead of failing with ERR_UNEXPECTED, and fix the mangled timeout comment in template-tests. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JHmXuuGMGg1bcCCYDNVSKW --- .../vite/spec/config/vite.base.config.spec.ts | 9 +- .../vite/spec/config/vite.main.config.spec.ts | 43 +++- .../vite/src/config/vite.base.config.ts | 29 ++- .../vite/src/config/vite.main.config.ts | 32 +-- .../plugin/webpack/spec/WebpackConfig.spec.ts | 69 ++++++- packages/plugin/webpack/src/WebpackConfig.ts | 102 +++++++--- .../core-utils/spec/app-protocol.spec.ts | 89 ++++++++- packages/utils/core-utils/src/app-protocol.ts | 188 +++++++++++++----- .../utils/test-utils/src/template-tests.ts | 140 +++++++------ 9 files changed, 539 insertions(+), 162 deletions(-) diff --git a/packages/plugin/vite/spec/config/vite.base.config.spec.ts b/packages/plugin/vite/spec/config/vite.base.config.spec.ts index 9c464c3fe8..a780070b28 100644 --- a/packages/plugin/vite/spec/config/vite.base.config.spec.ts +++ b/packages/plugin/vite/spec/config/vite.base.config.spec.ts @@ -71,10 +71,15 @@ describe('vite.base.config', () => { const define2 = { MAIN_WINDOW_VITE_DEV_SERVER_URL: undefined, MAIN_WINDOW_VITE_NAME: '"main_window"', - MAIN_WINDOW_VITE_ENTRY: undefined, + // Without `appProtocol`, the entry constant still resolves to a valid + // (file://) URL in builds so `loadURL(MAIN_WINDOW_VITE_ENTRY)` app code + // does not break only when packaged. + MAIN_WINDOW_VITE_ENTRY: + "`file://${require('node:path').join(__dirname, '../renderer/main_window/index.html')}`", SECOND_WINDOW_VITE_DEV_SERVER_URL: undefined, SECOND_WINDOW_VITE_NAME: '"second_window"', - SECOND_WINDOW_VITE_ENTRY: undefined, + SECOND_WINDOW_VITE_ENTRY: + "`file://${require('node:path').join(__dirname, '../renderer/second_window/index.html')}`", }; expect(define1).toEqual(define2); diff --git a/packages/plugin/vite/spec/config/vite.main.config.spec.ts b/packages/plugin/vite/spec/config/vite.main.config.spec.ts index 7c1ff638f7..a94204dd1b 100644 --- a/packages/plugin/vite/spec/config/vite.main.config.spec.ts +++ b/packages/plugin/vite/spec/config/vite.main.config.spec.ts @@ -37,11 +37,25 @@ function buildEnv( }; } -function getBanner(config: ReturnType): string | undefined { - const output = config.build?.rollupOptions?.output as - | Rollup.OutputOptions - | undefined; - return output?.banner as string | undefined; +/** + * The runtime is injected by a Forge-owned plugin's `outputOptions` hook (so + * a user's own `output.banner` composes with it instead of replacing it); + * resolve the banner the way Rollup would. + */ +function getBanner( + config: ReturnType, + existingOutput: Rollup.OutputOptions = {}, +): string | Rollup.OutputOptions['banner'] | undefined { + const plugins = (config.plugins ?? []).flat() as Rollup.Plugin[]; + const runtimePlugin = plugins.find( + (plugin) => + plugin?.name === '@electron-forge/plugin-vite:app-protocol-runtime', + ); + if (!runtimePlugin) return undefined; + const outputOptions = runtimePlugin.outputOptions as ( + output: Rollup.OutputOptions, + ) => Rollup.OutputOptions; + return outputOptions.call(undefined as never, existingOutput).banner; } describe('vite.main.config', () => { @@ -60,6 +74,16 @@ describe('vite.main.config', () => { expect(banner).toContain('"main_window"'); }); + it('composes with a user-configured output banner instead of replacing it', () => { + const config = getConfig( + buildEnv({ forgeConfig: { ...forgeConfig, appProtocol: true } }), + ); + const banner = getBanner(config, { banner: '/* user banner */' }); + expect(banner).toMatch(/^'use strict';/); + expect(banner).toContain('registerSchemesAsPrivileged'); + expect(banner).toMatch(/\/\* user banner \*\/$/); + }); + it('accepts the object form and includes additional privileged schemes', () => { const config = getConfig( buildEnv({ @@ -115,7 +139,10 @@ describe('vite.main.config', () => { ).toThrow(/reserved/); }); - it('does not inject the app protocol runtime for dev server builds', () => { + it('only registers privileged schemes for dev server builds', () => { + // Schemes must carry the same privileges under `electron-forge start` as + // in the packaged app, but the dev server serves the renderers, so the + // protocol handler itself is production-only. const config = getConfig( buildEnv({ command: 'serve', @@ -123,6 +150,8 @@ describe('vite.main.config', () => { forgeConfig: { ...forgeConfig, appProtocol: true }, }), ); - expect(getBanner(config)).toBeUndefined(); + const banner = getBanner(config); + expect(banner).toContain('registerSchemesAsPrivileged'); + expect(banner).not.toContain('protocol.handle'); }); }); diff --git a/packages/plugin/vite/src/config/vite.base.config.ts b/packages/plugin/vite/src/config/vite.base.config.ts index dfbcc5b8d7..d684c67989 100644 --- a/packages/plugin/vite/src/config/vite.base.config.ts +++ b/packages/plugin/vite/src/config/vite.base.config.ts @@ -83,7 +83,11 @@ export function getBuildDefine(env: ConfigEnv<'build'>) { ? JSON.stringify(viteDevServerUrls[VITE_DEV_SERVER_URL]) : appProtocol ? JSON.stringify(getAppProtocolEntryUrl(name, appProtocol.scheme)) - : undefined, + : // Keep the constant a valid URL without `appProtocol` too, so + // an app that calls `loadURL(MAIN_WINDOW_VITE_ENTRY)` and later + // turns the option off keeps working when packaged instead of + // failing only in production with `loadURL(undefined)`. + `\`file://\${require('node:path').join(__dirname, '../renderer/${name}/index.html')}\``, }; return { ...acc, ...def }; }, @@ -112,6 +116,29 @@ export function pluginExposeRenderer(name: string): Plugin { }; } +/** + * Prepends the appProtocol runtime to the main-process bundle. Implemented as + * a plugin rather than `build.rollupOptions.output.banner` so that a user's + * own `banner` in their Vite config composes with the runtime instead of + * silently replacing it (plugin arrays concatenate under `mergeConfig`; + * plain config values do not). + */ +export function pluginAppProtocolRuntime(runtime: string): Plugin { + return { + name: '@electron-forge/plugin-vite:app-protocol-runtime', + outputOptions(output) { + const existing = output.banner; + return { + ...output, + banner: + typeof existing === 'function' + ? async (chunk) => runtime + (await existing(chunk)) + : runtime + (existing ?? ''), + }; + }, + }; +} + export function pluginHotRestart(command: 'reload' | 'restart'): Plugin { let builtOnce = false; diff --git a/packages/plugin/vite/src/config/vite.main.config.ts b/packages/plugin/vite/src/config/vite.main.config.ts index a5055a7e01..6d7f82e27e 100644 --- a/packages/plugin/vite/src/config/vite.main.config.ts +++ b/packages/plugin/vite/src/config/vite.main.config.ts @@ -5,6 +5,7 @@ import { external, getBuildConfig, getBuildDefine, + pluginAppProtocolRuntime, pluginHotRestart, } from './vite.base.config.js'; @@ -14,28 +15,31 @@ export function getConfig( ): UserConfig { const { command, forgeConfig, forgeConfigSelf } = forgeEnv; const define = getBuildDefine(forgeEnv); - // In production builds (not the dev server, where renderers are served over - // HTTP), prepend the runtime that registers the `app://` scheme and serves - // the built renderer files over it. It must be a banner so it runs before - // any user code — see app-protocol.ts for the ordering constraints. - const appProtocolBanner = - forgeConfig.appProtocol && command === 'build' - ? getAppProtocolBanner( - forgeConfig.renderer - .map(({ name }) => name) - .filter((name) => name != null), - forgeConfig.appProtocol, - ) - : undefined; + // Prepend the appProtocol runtime so it runs before any user code — see + // app-protocol.ts for the ordering constraints. In development the runtime + // only registers the privileged schemes (so they carry the same privileges + // as in the packaged app); serving over the scheme is production-only, the + // dev server serves renderers over HTTP. + const appProtocolBanner = forgeConfig.appProtocol + ? getAppProtocolBanner( + forgeConfig.renderer + .map(({ name }) => name) + .filter((name) => name != null), + forgeConfig.appProtocol, + { serveRenderers: command === 'build' }, + ) + : undefined; const config: UserConfig = { build: { copyPublicDir: false, rollupOptions: { external: [...external, 'electron/main'], - output: appProtocolBanner ? { banner: appProtocolBanner } : undefined, }, }, plugins: [ + ...(appProtocolBanner + ? [pluginAppProtocolRuntime(appProtocolBanner)] + : []), ...(forgeEnv.forgeConfig.hotRestart ? [pluginHotRestart('restart')] : []), ], define, diff --git a/packages/plugin/webpack/spec/WebpackConfig.spec.ts b/packages/plugin/webpack/spec/WebpackConfig.spec.ts index b09b5ffd3e..003c5dcd60 100644 --- a/packages/plugin/webpack/spec/WebpackConfig.spec.ts +++ b/packages/plugin/webpack/spec/WebpackConfig.spec.ts @@ -200,7 +200,36 @@ describe('WebpackConfigGenerator', () => { const generator = new WebpackConfigGenerator(config, '/', true, 3000); const defines = generator.getDefines(); - expect(defines.HELLO_WEBPACK_ENTRY).toEqual("'app://hello/index.html'"); + // The per-entry subdirectory is part of the path: every origin is + // rooted at the shared `.webpack/renderer/` directory so that + // `publicPath: '/'` asset URLs resolve. + expect(defines.HELLO_WEBPACK_ENTRY).toEqual( + "'app://hello/hello/index.html'", + ); + }); + + it('keeps nodeIntegration entry points on file:// in production', () => { + // Electron only derives renderer __dirname from file: URLs, which + // AssetRelocatorPatch relies on for nodeIntegration renderers. + const config = { + appProtocol: true, + renderer: { + entryPoints: [ + { + name: 'hello', + html: 'foo.html', + js: 'foo.js', + nodeIntegration: true, + }, + ], + }, + } as WebpackPluginConfig; + const generator = new WebpackConfigGenerator(config, '/', true, 3000); + const defines = generator.getDefines(); + + expect(defines.HELLO_WEBPACK_ENTRY).toEqual( + "`file://${require('path').resolve(__dirname, '..', 'renderer', 'hello', 'index.html')}`", + ); }); it('uses a custom scheme for entry URLs when configured', () => { @@ -220,7 +249,7 @@ describe('WebpackConfigGenerator', () => { const defines = generator.getDefines(); expect(defines.HELLO_WEBPACK_ENTRY).toEqual( - "'myapp://hello/index.html'", + "'myapp://hello/hello/index.html'", ); }); @@ -447,7 +476,10 @@ describe('WebpackConfigGenerator', () => { expect(bannerPlugin!.options.banner).toContain('"stream":true'); }); - it('does not inject the banner in development', async () => { + it('only registers privileged schemes in development', async () => { + // Schemes must carry the same privileges under `electron-forge start` + // as in the packaged app, but the dev server serves the renderers, so + // the protocol handler itself is production-only. const generator = new WebpackConfigGenerator( appProtocolConfig, mockProjectDir, @@ -455,7 +487,36 @@ describe('WebpackConfigGenerator', () => { 3000, ); const webpackConfig = await generator.getMainConfig(); - expect(findBannerPlugin(webpackConfig.plugins)).toBeUndefined(); + const bannerPlugin = findBannerPlugin(webpackConfig.plugins); + expect(bannerPlugin).toBeDefined(); + expect(bannerPlugin!.options.banner).toContain( + 'registerSchemesAsPrivileged', + ); + expect(bannerPlugin!.options.banner).not.toContain('protocol.handle'); + }); + + it('uses root-relative publicPath for served renderers in production', async () => { + const rendererOptions = { + config: {}, + entryPoints: [ + { + name: 'main_window', + html: 'index.html', + js: 'renderer.js', + }, + ], + }; + const generator = new WebpackConfigGenerator( + { ...appProtocolConfig, renderer: rendererOptions }, + mockProjectDir, + true, + 3000, + ); + const configs = await generator.getRendererConfig( + rendererOptions as WebpackPluginRendererConfig, + ); + const webConfig = configs.find((config) => config.target === 'web'); + expect(webConfig?.output?.publicPath).toEqual('/'); }); it('does not inject the banner when appProtocol is not enabled', async () => { diff --git a/packages/plugin/webpack/src/WebpackConfig.ts b/packages/plugin/webpack/src/WebpackConfig.ts index adaf780686..05dccdf3a0 100644 --- a/packages/plugin/webpack/src/WebpackConfig.ts +++ b/packages/plugin/webpack/src/WebpackConfig.ts @@ -138,17 +138,28 @@ export default class WebpackConfigGenerator { rendererEntryPoint( entryPoint: WebpackPluginEntryPoint, basename: string, + nodeIntegration: boolean, ): string { if (this.isProd) { // With `appProtocol` enabled, HTML entry points are served over the // privileged `app://` scheme by the runtime injected into the main // bundle. JS-only (no-window) entry points keep their `file://` paths — - // they are not window entry URLs. - if (this.pluginConfig.appProtocol && basename === 'index.html') { + // they are not window entry URLs. `nodeIntegration` entry points also + // stay on `file://`: Electron only derives the renderer's `__dirname` + // from `file:` page URLs, which AssetRelocatorPatch relies on for + // relocated native modules and assets in production. + if ( + this.pluginConfig.appProtocol && + basename === 'index.html' && + !nodeIntegration + ) { const { scheme } = resolveAppProtocolConfig( this.pluginConfig.appProtocol, ); - return `'${getAppProtocolEntryUrl(entryPoint.name, scheme)}'`; + // Every origin is rooted at the shared `.webpack/renderer/` output + // directory (see `buildRendererBaseConfig`'s `publicPath`), so the + // entry path carries the per-entry subdirectory. + return `'${getAppProtocolEntryUrl(entryPoint.name, scheme, `${entryPoint.name}/index.html`)}'`; } return `\`file://$\{require('path').resolve(__dirname, '..', 'renderer', '${entryPoint.name}', '${basename}')}\``; } @@ -199,10 +210,22 @@ export default class WebpackConfigGenerator { } for (const entryPoint of pluginRendererOptions.entryPoints) { const entryKey = this.toEnvironmentVariable(entryPoint); + const nodeIntegration = + entryPoint.nodeIntegration ?? + pluginRendererOptions.nodeIntegration ?? + false; if (isLocalWindow(entryPoint)) { - defines[entryKey] = this.rendererEntryPoint(entryPoint, 'index.html'); + defines[entryKey] = this.rendererEntryPoint( + entryPoint, + 'index.html', + nodeIntegration, + ); } else { - defines[entryKey] = this.rendererEntryPoint(entryPoint, 'index.js'); + defines[entryKey] = this.rendererEntryPoint( + entryPoint, + 'index.js', + nodeIntegration, + ); } defines[`process.env.${entryKey}`] = defines[entryKey]; @@ -238,29 +261,36 @@ export default class WebpackConfigGenerator { }; mainConfig.entry = fix(mainConfig.entry as EntryType); - // In production builds (not the dev server, where renderers are served - // over HTTP), prepend the runtime that registers the `app://` scheme and - // serves the built renderer files over it. `raw` emits the code verbatim - // (not wrapped in a comment) and `entryOnly` keeps it out of split chunks; - // as a banner it runs before any user code — see app-protocol.ts in - // @electron-forge/core-utils for the ordering constraints. - const appProtocolPlugins = - this.pluginConfig.appProtocol && this.isProd - ? [ - new BannerPlugin({ - banner: getAppProtocolBanner( - this.allPluginRendererOptions.flatMap((rendererOptions) => - (rendererOptions.entryPoints ?? []) - .filter((entryPoint) => !isPreloadOnly(entryPoint)) - .map((entryPoint) => entryPoint.name), - ), - this.pluginConfig.appProtocol, + // Prepend the appProtocol runtime so it runs before any user code — see + // app-protocol.ts in @electron-forge/core-utils for the ordering + // constraints. In development the runtime only registers the privileged + // schemes (so they carry the same privileges as in the packaged app); + // serving over the scheme is production-only, the dev server serves + // renderers over HTTP. `raw` emits the code verbatim (not wrapped in a + // comment) and `entryOnly` keeps it out of split chunks. + const appProtocolPlugins = this.pluginConfig.appProtocol + ? [ + new BannerPlugin({ + banner: getAppProtocolBanner( + this.allPluginRendererOptions.flatMap((rendererOptions) => + (rendererOptions.entryPoints ?? []) + .filter((entryPoint) => !isPreloadOnly(entryPoint)) + .map((entryPoint) => entryPoint.name), ), - raw: true, - entryOnly: true, - }), - ] - : []; + this.pluginConfig.appProtocol, + { + serveRenderers: this.isProd, + // All origins share the `.webpack/renderer/` root — webpack + // emits one output directory with per-entry subdirectories + // and `publicPath: '/'`-based asset URLs. + rootIncludesName: false, + }, + ), + raw: true, + entryOnly: true, + }), + ] + : []; return webpackMerge( { @@ -349,6 +379,20 @@ export default class WebpackConfigGenerator { return rendererConfigs.filter(isNotNull); } + /** + * Renderers served over `appProtocol` need root-relative asset URLs: the + * handler roots every origin at `.webpack/renderer/`, so `publicPath: '/'` + * makes html-webpack-plugin emit `//index.js` instead of the `'auto'` + * relative URLs that only resolve under `file://`. Applies to Web-target + * renderers only — `nodeIntegration` renderers stay on `file://`. + */ + private rendererPublicPath(target: RendererTarget) { + if (!this.isProd) return { publicPath: '/' }; + return this.pluginConfig.appProtocol && target === RendererTarget.Web + ? { publicPath: '/' } + : {}; + } + buildRendererBaseConfig(target: RendererTarget): webpack.Configuration { return { target: rendererTargetToWebpackTarget(target), @@ -358,7 +402,7 @@ export default class WebpackConfigGenerator { path: path.resolve(this.webpackDir, 'renderer'), filename: '[name]/index.js', globalObject: 'self', - ...(this.isProd ? {} : { publicPath: '/' }), + ...this.rendererPublicPath(target), }, node: { __dirname: false, @@ -392,7 +436,7 @@ export default class WebpackConfigGenerator { path: path.resolve(this.webpackDir, 'renderer'), filename: '[name]/index.js', globalObject: 'self', - ...(this.isProd ? {} : { publicPath: '/' }), + ...this.rendererPublicPath(target), }; const plugins: webpack.WebpackPluginInstance[] = []; diff --git a/packages/utils/core-utils/spec/app-protocol.spec.ts b/packages/utils/core-utils/spec/app-protocol.spec.ts index 26f9f11122..b980740bfb 100644 --- a/packages/utils/core-utils/spec/app-protocol.spec.ts +++ b/packages/utils/core-utils/spec/app-protocol.spec.ts @@ -13,10 +13,18 @@ describe('app-protocol', () => { ); }); - it('builds entry URLs on a custom scheme', () => { + it('builds entry URLs on a custom scheme and entry path', () => { expect(getAppProtocolEntryUrl('main_window', 'myapp')).toEqual( 'myapp://main_window/index.html', ); + expect( + getAppProtocolEntryUrl('main_window', 'app', 'main_window/index.html'), + ).toEqual('app://main_window/main_window/index.html'); + }); + + it('rejects renderer names that cannot be URL hosts', () => { + expect(() => getAppProtocolEntryUrl('my window')).toThrow(/URL host/); + expect(() => getAppProtocolBanner(['my window'])).toThrow(/URL host/); }); it('emits syntactically valid runtime code', () => { @@ -26,6 +34,64 @@ describe('app-protocol', () => { expect(banner).toContain('["main_window","second_window"]'); }); + it('keeps the bundle strict and no-ops outside the browser process', () => { + const banner = getAppProtocolBanner(['main_window']); + // The banner sits above the bundle's own directive prologue, so it must + // carry the directive itself or the whole bundle silently goes sloppy. + expect(banner).toMatch(/^'use strict';/); + // Main-target bundles can also be loaded in utility/worker processes, + // where `app`/`protocol` do not exist. + expect(banner).toContain(`process.type !== 'browser'`); + }); + + it('grants the serving scheme secure-origin defaults including stream and codeCache', () => { + const banner = getAppProtocolBanner(['main_window']); + expect(banner).toContain( + '{"scheme":"app","privileges":{"standard":true,"secure":true,"supportFetchApi":true,"stream":true,"codeCache":true}}', + ); + }); + + it('merges privilege overrides for the serving scheme', () => { + const { privileges } = resolveAppProtocolConfig({ + privileges: { allowServiceWorkers: true, codeCache: false }, + }); + expect(privileges).toEqual({ + standard: true, + secure: true, + supportFetchApi: true, + stream: true, + codeCache: false, + allowServiceWorkers: true, + }); + }); + + it('only registers schemes when not serving renderers (development)', () => { + const banner = getAppProtocolBanner(['main_window'], true, { + serveRenderers: false, + }); + expect(() => new Function(banner)).not.toThrow(); + expect(banner).toContain('registerSchemesAsPrivileged'); + expect(banner).not.toContain('protocol.handle'); + }); + + it('supports a shared renderer root for all origins', () => { + const perName = getAppProtocolBanner(['main_window']); + expect(perName).toContain(`'renderer', name`); + const shared = getAppProtocolBanner(['main_window'], true, { + rootIncludesName: false, + }); + expect(shared).toContain(`'..', 'renderer')`); + expect(shared).not.toContain(`'renderer', name`); + }); + + it('fails malformed percent-escapes with a 400 instead of throwing', () => { + const banner = getAppProtocolBanner(['main_window']); + // decodeURIComponent throws URIError on e.g. %FF; uncaught, Electron + // fails the request with ERR_UNEXPECTED instead of a 4xx. + expect(banner).toMatch(/try \{\s*pathname = decodeURIComponent/); + expect(banner).toContain('status: 400'); + }); + it('registers and handles a custom scheme', () => { const banner = getAppProtocolBanner(['main_window'], { scheme: 'myapp' }); expect(() => new Function(banner)).not.toThrow(); @@ -51,8 +117,8 @@ describe('app-protocol', () => { it('throws when an additional scheme conflicts with the serving scheme', () => { expect(() => - getAppProtocolBanner(['main_window'], { - additionalPrivilegedSchemes: [{ scheme: 'APP' }], + resolveAppProtocolConfig({ + additionalPrivilegedSchemes: [{ scheme: 'app' }], }), ).toThrow(/reserved/); expect(() => @@ -63,6 +129,16 @@ describe('app-protocol', () => { ).toThrow(/reserved/); }); + it('applies scheme syntax validation to additional schemes too', () => { + for (const scheme of ['', 'My App', 'APP']) { + expect(() => + resolveAppProtocolConfig({ + additionalPrivilegedSchemes: [{ scheme }], + }), + ).toThrow(/valid lowercase URI scheme/); + } + }); + it('allows app as an additional scheme when the serving scheme differs', () => { const { additionalPrivilegedSchemes } = resolveAppProtocolConfig({ scheme: 'myapp', @@ -98,6 +174,13 @@ describe('app-protocol', () => { it('resolves the boolean form to the defaults', () => { expect(resolveAppProtocolConfig(true)).toEqual({ scheme: 'app', + privileges: { + standard: true, + secure: true, + supportFetchApi: true, + stream: true, + codeCache: true, + }, additionalPrivilegedSchemes: [], }); }); diff --git a/packages/utils/core-utils/src/app-protocol.ts b/packages/utils/core-utils/src/app-protocol.ts index 52b1e229cb..345aa66253 100644 --- a/packages/utils/core-utils/src/app-protocol.ts +++ b/packages/utils/core-utils/src/app-protocol.ts @@ -5,21 +5,23 @@ * working `fetch()` of local resources, origin-scoped storage, etc.). * * The code returned by {@link getAppProtocolBanner} is prepended to the - * production main-process bundle by the plugin (a Rollup banner for Vite, a - * raw `BannerPlugin` banner for webpack). It must run before the app's - * `ready` event, hence a banner at the very top of the bundle: + * main-process bundle by the plugin. It must run before the app's `ready` + * event, hence a banner at the very top of the bundle: * * - `protocol.registerSchemesAsPrivileged` may only be called once, before - * `ready`. - * - The `protocol.handle` registration is attached with `app.once('ready')` - * from the banner, which runs before any user code. Listeners fire in - * registration order, so the handler is guaranteed to be registered before - * a `createWindow()` in the app's own `ready` handler calls + * `ready`. It is registered in development builds too, so schemes carry the + * same privileges under `electron-forge start` as in the packaged app. + * - The `protocol.handle` registration (production only — the dev server + * serves renderers over HTTP) is attached with `app.once('ready')` from the + * banner, which runs before any user code. Listeners fire in registration + * order, so the handler is guaranteed to be registered before a + * `createWindow()` in the app's own `ready` handler calls * `loadURL('app://...')`. * - * Both plugins emit main-process bundles laid out as `/main-bundle.js` - * with renderers in `/../renderer//`, which is the layout the - * runtime's `__dirname`-relative lookup assumes. + * The banner is emitted into every bundle the plugins build for the main + * process target, which can include utility-process or forked-worker bundles; + * the runtime no-ops outside the browser process, where `app` and `protocol` + * do not exist. */ export const APP_PROTOCOL_SCHEME = 'app'; @@ -31,6 +33,13 @@ export const APP_PROTOCOL_SCHEME = 'app'; */ const SCHEME_SYNTAX = /^[a-z][a-z0-9+.-]*$/; +/** + * Renderer names become the URL host of a `standard: true` scheme, so when + * `appProtocol` is enabled they must survive URL parsing (no spaces or other + * host-invalid characters) to ever match the handler's hostname check. + */ +const RENDERER_NAME_AS_HOST = /^[a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?$/; + /** * Schemes Chromium or Electron already claim; registering one of these as the * serving scheme would clash with built-in handling instead of serving the @@ -74,6 +83,21 @@ export interface PrivilegedScheme { }; } +/** + * Default privileges for the serving scheme. `standard` + `secure` make it a + * real secure origin, `supportFetchApi` lets renderer code `fetch()` its own + * resources, `stream` keeps `