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/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/spec/config/vite.base.config.spec.ts b/packages/plugin/vite/spec/config/vite.base.config.spec.ts index 2a05abe4dd..f2b7de2a80 100644 --- a/packages/plugin/vite/spec/config/vite.base.config.spec.ts +++ b/packages/plugin/vite/spec/config/vite.base.config.spec.ts @@ -9,8 +9,11 @@ import { getDefineKeys, pluginExposeRenderer, pluginHotRestart, + pluginViteEntryFallback, } from '../../src/config/vite.base.config'; +import type { Rollup } from 'vite'; + import type { VitePluginConfig } from '../../src/Config'; const configRoot = path.join(import.meta.dirname, 'fixtures/vite-configs'); @@ -48,10 +51,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', }, }; @@ -69,13 +74,59 @@ describe('vite.base.config', () => { const define2 = { MAIN_WINDOW_VITE_DEV_SERVER_URL: undefined, MAIN_WINDOW_VITE_NAME: '"main_window"', + // 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. The define must stay a bare member + // expression (the one non-JSON shape esbuild accepts on Vite < 8); + // pluginViteEntryFallback's banner assigns the URL to the global. + MAIN_WINDOW_VITE_ENTRY: + 'globalThis.__electronForge_MAIN_WINDOW_VITE_ENTRY', + SECOND_WINDOW_VITE_DEV_SERVER_URL: undefined, + SECOND_WINDOW_VITE_NAME: '"second_window"', + SECOND_WINDOW_VITE_ENTRY: + 'globalThis.__electronForge_SECOND_WINDOW_VITE_ENTRY', + }; + + 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); }); + 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 }) => @@ -102,8 +153,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) { @@ -142,9 +195,11 @@ describe('vite.base.config', () => { // Custom string hosts are exposed as-is. MAIN_WINDOW_VITE_DEV_SERVER_URL: '"http://127.0.0.1:5183"', MAIN_WINDOW_VITE_NAME: '"main_window"', + MAIN_WINDOW_VITE_ENTRY: '"http://127.0.0.1:5183"', // Wildcard hosts fall back to localhost. SECOND_WINDOW_VITE_DEV_SERVER_URL: '"http://localhost:5184"', SECOND_WINDOW_VITE_NAME: '"second_window"', + SECOND_WINDOW_VITE_ENTRY: '"http://localhost:5184"', }; for (const server of servers) { @@ -154,6 +209,75 @@ describe('vite.base.config', () => { expect(define1).toEqual(define2); }); + describe('pluginViteEntryFallback', () => { + const applyOutputOptions = ( + output: Rollup.OutputOptions, + names = ['main_window'], + ) => { + const plugin = pluginViteEntryFallback(names); + const hook = plugin.outputOptions as ( + output: Rollup.OutputOptions, + ) => Rollup.OutputOptions | null; + return hook.call(undefined, output); + }; + + const bannerOf = (result: Rollup.OutputOptions | null) => { + expect(result).not.toBeNull(); + expect(typeof result!.banner).toEqual('string'); + return result!.banner as string; + }; + + it('assigns the entry global with require()/__dirname in CJS bundles', () => { + const banner = bannerOf(applyOutputOptions({ format: 'cjs' })); + expect(() => new Function(banner)).not.toThrow(); + // The banner displaces Rollup's own 'use strict' prologue directive, so + // it must re-assert it. + expect(banner).toMatch(/^'use strict';\n/); + expect(banner).toContain( + `globalThis.__electronForge_MAIN_WINDOW_VITE_ENTRY = require('node:url').pathToFileURL(require('node:path').join(__dirname, "../renderer/main_window/index.html")).href;`, + ); + }); + + it('assigns the entry global with import.meta.url in ESM bundles', () => { + // A user's own `build.lib.formats: ['es']` skips the plugin's CJS + // default — the CJS expression would throw ReferenceError there. + const banner = bannerOf(applyOutputOptions({ format: 'es' })); + expect(banner).toContain( + `globalThis.__electronForge_MAIN_WINDOW_VITE_ENTRY = new URL("../renderer/main_window/index.html", import.meta.url).href;`, + ); + expect(banner).not.toContain('require('); + expect(banner).not.toContain(`'use strict'`); + }); + + it('treats a missing format as ESM, matching Rollup', () => { + expect(bannerOf(applyOutputOptions({}))).toContain('import.meta.url'); + }); + + it('covers every renderer in one banner', () => { + const banner = bannerOf( + applyOutputOptions({ format: 'cjs' }, ['main_window', 'second-window']), + ); + expect(banner).toContain('__electronForge_MAIN_WINDOW_VITE_ENTRY'); + // Kebab-case names map to the same key the define uses. + expect(banner).toContain('__electronForge_SECOND_WINDOW_VITE_ENTRY'); + expect(banner).toContain('"../renderer/second-window/index.html"'); + }); + + it('leaves formats no Electron main process uses untouched', () => { + expect(applyOutputOptions({ format: 'umd' })).toBeNull(); + }); + + it('composes with an existing banner instead of replacing it', () => { + const result = applyOutputOptions({ + format: 'cjs', + banner: '/* user banner */', + }); + const banner = bannerOf(result); + expect(banner).toContain('__electronForge_MAIN_WINDOW_VITE_ENTRY'); + expect(banner).toMatch(/\/\* user banner \*\/$/); + }); + }); + describe('pluginHotRestart', () => { let dispose: (() => void) | undefined; 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..e06512095a --- /dev/null +++ b/packages/plugin/vite/spec/config/vite.main.config.spec.ts @@ -0,0 +1,241 @@ +import path from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +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, + }; +} + +/** + * 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, + // The plugin's default main config emits CJS bundles. + existingOutput: Rollup.OutputOptions = { format: 'cjs' }, +): 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', () => { + 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('composes with a user-configured output banner instead of replacing it', () => { + const config = getConfig( + buildEnv({ forgeConfig: { ...forgeConfig, appProtocol: true } }), + ); + const banner = getBanner(config, { + format: 'cjs', + banner: '/* user banner */', + }); + expect(banner).toMatch(/^'use strict';/); + expect(banner).toContain('registerSchemesAsPrivileged'); + expect(banner).toMatch(/\/\* user banner \*\/$/); + }); + + it('wraps the runtime in a createRequire prelude for ESM main bundles', () => { + // A user's own `build.lib.formats: ['es']` skips the plugin's CJS + // default; the CJS runtime must not hit an ESM bundle unwrapped, where + // its require('electron') throws before any app code runs. + const config = getConfig( + buildEnv({ forgeConfig: { ...forgeConfig, appProtocol: true } }), + ); + const banner = getBanner(config, { format: 'es' }) as string; + expect(banner).toContain(`createRequire`); + expect(banner).toContain('import.meta.url'); + expect(banner).toContain('registerSchemesAsPrivileged'); + // ESM is implicitly strict; the file-level directive is CJS-only, and the + // require/__dirname bindings stay block-scoped to avoid colliding with + // Rollup's own shims at module level. + expect(banner).not.toMatch(/^'use strict';/); + expect(banner).toMatch(/^import \{ createRequire/); + }); + + it('fails the build for output formats no Electron main process can use', () => { + const config = getConfig( + buildEnv({ forgeConfig: { ...forgeConfig, appProtocol: true } }), + ); + expect(() => getBanner(config, { format: 'umd' })).toThrow( + /CommonJS or ESM/, + ); + }); + + 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('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( + buildEnv({ + forgeConfig: { + ...forgeConfig, + appProtocol: { + additionalPrivilegedSchemes: [{ scheme: 'app' }], + }, + }, + }), + ), + ).toThrow(/reserved/); + }); + + it('injects only the serving handler with registerSchemes: false', () => { + const config = getConfig( + buildEnv({ + forgeConfig: { + ...forgeConfig, + appProtocol: { registerSchemes: false }, + }, + }), + ); + const banner = getBanner(config); + expect(banner).toContain('protocol.handle'); + expect(banner).not.toContain('registerSchemesAsPrivileged'); + }); + + it('injects nothing for dev server builds with registerSchemes: false', () => { + const config = getConfig( + buildEnv({ + command: 'serve', + mode: 'development', + forgeConfig: { + ...forgeConfig, + appProtocol: { registerSchemes: false }, + }, + }), + ); + expect(getBanner(config)).toBeUndefined(); + }); + + it('injects the entry fallback plugin only for builds without appProtocol', () => { + const hasFallback = (config: ReturnType) => + ((config.plugins ?? []).flat() as Rollup.Plugin[]).some( + (plugin) => + plugin?.name === '@electron-forge/plugin-vite:vite-entry-fallback', + ); + + // Without appProtocol the *_VITE_ENTRY defines point at globals that the + // fallback plugin's banner assigns packaged file:// URLs. + expect(hasFallback(getConfig(buildEnv()))).toEqual(true); + expect( + hasFallback( + getConfig( + buildEnv({ forgeConfig: { ...forgeConfig, appProtocol: true } }), + ), + ), + ).toEqual(false); + // In dev the defines are dev-server URL strings. + expect( + hasFallback( + getConfig(buildEnv({ command: 'serve', mode: 'development' })), + ), + ).toEqual(false); + }); + + 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', + mode: 'development', + forgeConfig: { ...forgeConfig, appProtocol: true }, + }), + ); + const banner = getBanner(config); + expect(banner).toContain('registerSchemesAsPrivileged'); + expect(banner).not.toContain('protocol.handle'); + }); +}); 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..1f247bb65a 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,112 @@ 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('__electronForgeAppProtocol'); + // 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('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: { + scheme: 'custom-app', + 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 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)/); + }); + + 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 43b3910178..61a206bbdb 100644 --- a/packages/plugin/vite/src/Config.ts +++ b/packages/plugin/vite/src/Config.ts @@ -1,5 +1,8 @@ +import type { AppProtocolConfig } from '@electron-forge/core-utils'; import type { LibraryOptions } from 'vite'; +export type VitePluginAppProtocolConfig = AppProtocolConfig; + export interface VitePluginBuildConfig { /** * Alias of `build.lib.entry` in `config`. @@ -50,6 +53,38 @@ export interface VitePluginConfig { */ 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, + * 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. + * - The object form's `registerSchemes: false` hands the + * `registerSchemesAsPrivileged` call to your app instead — Forge then + * injects only the serving handler, and your registration must include + * the serving scheme (see `APP_PROTOCOL_DEFAULT_PRIVILEGES` in + * `@electron-forge/core-utils`). + * - 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` + */ + appProtocol?: boolean | VitePluginAppProtocolConfig; + /** * Restart the running app whenever the main process bundle is rebuilt during * `electron-forge start`. Has no effect when packaging. diff --git a/packages/plugin/vite/src/VitePlugin.ts b/packages/plugin/vite/src/VitePlugin.ts index 34af7e18b6..aa1db8ab43 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,23 @@ 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 deliberately not + * serialized: the watch workers have no IPC bridge for restart requests, so + * installing pluginHotRestart there would warn on every rebuild without + * restarting anything (bridging it like `reload-renderers` is a separate + * fix). */ private get serializableConfig(): Pick< VitePluginConfig, - 'build' | 'renderer' + 'build' | 'renderer' | 'appProtocol' > { return { build: this.config.build, renderer: this.config.renderer, + appProtocol: this.config.appProtocol, }; } diff --git a/packages/plugin/vite/src/config/vite.base.config.ts b/packages/plugin/vite/src/config/vite.base.config.ts index 1d35ec220a..388b4eb1db 100644 --- a/packages/plugin/vite/src/config/vite.base.config.ts +++ b/packages/plugin/vite/src/config/vite.base.config.ts @@ -3,8 +3,19 @@ import { styleText } from 'node:util'; import { requestAppRestart } from '@electron-forge/core-utils/restart'; +import { + getAppProtocolEntryUrl, + resolveAppProtocolConfig, +} from '@electron-forge/core-utils'; + import type { AddressInfo } from 'node:net'; -import type { ConfigEnv, Plugin, UserConfig, ViteDevServer } from 'vite'; +import type { + ConfigEnv, + Plugin, + Rollup, + UserConfig, + ViteDevServer, +} from 'vite'; export const external = [ 'electron', @@ -45,27 +56,60 @@ 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 }; }, define); } +/** + * Name of the global that carries a renderer's packaged `file://` entry URL + * when `appProtocol` is off — the define substitutes reads of the entry + * constant with it, and {@link pluginViteEntryFallback}'s banner assigns it. + */ +function viteEntryFallbackGlobal(viteEntryKey: string): string { + return `__electronForge_${viteEntryKey}`; +} + export function getBuildDefine(env: ConfigEnv<'build'>) { const { command, forgeConfig } = env; const names = forgeConfig.renderer .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 } = 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]) + : appProtocol + ? JSON.stringify(getAppProtocolEntryUrl(name, appProtocol.scheme)) + : // 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)`. The value must stay a bare member + // expression — the one non-JSON define shape esbuild + // (Vite < 8) accepts — so the actual file:// URL is assigned + // by pluginViteEntryFallback's banner, which sees the + // bundle's real output format. A define could not: a CJS + // require() expression throws in an ESM main bundle, and + // import.meta is a syntax error in a CJS one. + `globalThis.${viteEntryFallbackGlobal(VITE_ENTRY)}`, }; return { ...acc, ...def }; }, @@ -108,6 +152,110 @@ export function pluginExposeRenderer(name: string): Plugin { }; } +function prependOutputBanner( + output: Rollup.OutputOptions, + banner: string, +): Rollup.OutputOptions { + const existing = output.banner; + return { + ...output, + banner: + typeof existing === 'function' + ? async (chunk) => banner + (await existing(chunk)) + : banner + (existing ?? ''), + }; +} + +/** + * 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) { + // Rollup defaults to 'es' when no format is set. + const format = output.format ?? 'es'; + const isCjs = format === 'cjs' || format === 'commonjs'; + const isEsm = format === 'es' || format === 'esm' || format === 'module'; + if (!isCjs && !isEsm) { + // Prepending the runtime anyway would surface as a cryptic + // ReferenceError at app startup; fail the build instead. + throw new Error( + `[@electron-forge/plugin-vite] appProtocol requires the main-process bundle to be CommonJS or ESM, but it is built with output format '${format}'.`, + ); + } + const banner = isCjs + ? // The banner sits above Rollup's own 'use strict' prologue + // directive, which stops being a directive once displaced — so + // re-assert it at file level here. (The shared banner itself must + // not carry a file-level directive: webpack's BannerPlugin path + // would force bundled sloppy-mode CJS deps strict.) + `'use strict';\n${runtime}` + : // The shared runtime is CommonJS; a user's own `build.lib.formats: + // ['es']` skips the plugin's CJS default, so give the runtime + // `require`/`__dirname` bindings scoped to a block — module-level + // consts could collide with Rollup's own createRequire shims. + [ + `import { createRequire as __electronForgeAppProtocolCreateRequire } from 'node:module';`, + `{`, + `const require = __electronForgeAppProtocolCreateRequire(import.meta.url);`, + `const __dirname = require('node:path').dirname(require('node:url').fileURLToPath(import.meta.url));`, + runtime, + `}`, + ].join('\n'); + return prependOutputBanner(output, banner); + }, + }; +} + +/** + * Assigns each renderer's packaged `file://` entry URL to the global that the + * `*_VITE_ENTRY` define points at when `appProtocol` is off. Runs in + * `outputOptions`, where the bundle's actual output format is known, so the + * URL is computed with `require()`/`__dirname` in CJS bundles and + * `import.meta.url` in ESM ones — neither expression is valid in the other + * format, which is why the define itself cannot carry it. + */ +export function pluginViteEntryFallback(names: string[]): Plugin { + return { + name: '@electron-forge/plugin-vite:vite-entry-fallback', + outputOptions(output) { + // Rollup defaults to 'es' when no format is set. + const format = output.format ?? 'es'; + const isCjs = format === 'cjs' || format === 'commonjs'; + const isEsm = format === 'es' || format === 'esm' || format === 'module'; + if (!isCjs && !isEsm) { + // umd/iife/etc. are not Electron main-process formats; leave the + // globals unset so the entry constants read as undefined. + return null; + } + const assignments = Object.entries(getDefineKeys(names)).map( + ([name, { VITE_ENTRY }]) => { + const global = `globalThis.${viteEntryFallbackGlobal(VITE_ENTRY)}`; + // JSON.stringify the path — this path runs without appProtocol's + // renderer-name validation, so a name containing a quote must not + // break the emitted code. pathToFileURL/the URL resolver encode + // '#', '?' and '%' in install paths the way the loadFile call this + // replaces did; for the URL resolver the name segment is + // percent-encoded at build time since it joins a URL, not a path. + return isCjs + ? `${global} = require('node:url').pathToFileURL(require('node:path').join(__dirname, ${JSON.stringify(`../renderer/${name}/index.html`)})).href;` + : `${global} = new URL(${JSON.stringify(`../renderer/${encodeURIComponent(name)}/index.html`)}, import.meta.url).href;`; + }, + ); + // In CJS the banner displaces Rollup's 'use strict' prologue directive, + // so re-assert it (ESM is strict implicitly). + const banner = + (isCjs ? `'use strict';\n` : '') + assignments.join('\n') + '\n'; + return prependOutputBanner(output, banner); + }, + }; +} + 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 385d918faa..60ce2dae99 100644 --- a/packages/plugin/vite/src/config/vite.main.config.ts +++ b/packages/plugin/vite/src/config/vite.main.config.ts @@ -1,18 +1,34 @@ import { type ConfigEnv, mergeConfig, type UserConfig } from 'vite'; +import { getAppProtocolBanner } from '@electron-forge/core-utils'; import { external, getBuildConfig, getBuildDefine, + pluginAppProtocolRuntime, pluginHotRestart, + pluginViteEntryFallback, } from './vite.base.config.js'; export function getConfig( forgeEnv: ConfigEnv<'build'>, userConfig: UserConfig = {}, ): UserConfig { - const { forgeConfigSelf } = forgeEnv; + const { command, forgeConfig, forgeConfigSelf } = forgeEnv; const define = getBuildDefine(forgeEnv); + const rendererNames = forgeConfig.renderer + .map(({ name }) => name) + .filter((name) => name != null); + // 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(rendererNames, forgeConfig.appProtocol, { + serveRenderers: command === 'build', + }) + : undefined; const config: UserConfig = { build: { copyPublicDir: false, @@ -21,6 +37,17 @@ export function getConfig( }, }, plugins: [ + ...(appProtocolBanner + ? [pluginAppProtocolRuntime(appProtocolBanner)] + : []), + // Without appProtocol the *_VITE_ENTRY defines point at globals that + // this plugin's banner assigns packaged file:// URLs (in dev they are + // dev-server URL strings, so no banner is needed). + ...(command === 'build' && + !forgeConfig.appProtocol && + rendererNames.length > 0 + ? [pluginViteEntryFallback(rendererNames)] + : []), ...(forgeEnv.forgeConfig.hotRestart ? [pluginHotRestart('restart')] : []), ], define, diff --git a/packages/plugin/webpack/spec/AssetRelocatorPatch.slow.spec.ts b/packages/plugin/webpack/spec/AssetRelocatorPatch.slow.spec.ts index f678028737..8dbaefc684 100644 --- a/packages/plugin/webpack/spec/AssetRelocatorPatch.slow.spec.ts +++ b/packages/plugin/webpack/spec/AssetRelocatorPatch.slow.spec.ts @@ -145,7 +145,11 @@ describe('AssetRelocatorPatch', () => { } } - await spawn(pmCmd, ['ci'], { + // --no-audit: a fixture install gains nothing from the security audit, + // and the audit round-trip is a hidden external dependency with no + // timeout — a hanging registry advisory endpoint turned this hook into a + // 90s timeout on every platform. + await spawn(pmCmd, ['ci', '--no-audit', '--no-fund'], { cwd: appPath, shell: true, }); diff --git a/packages/plugin/webpack/spec/WebpackConfig.spec.ts b/packages/plugin/webpack/spec/WebpackConfig.spec.ts index 45ccb52bbe..ec28c29f68 100644 --- a/packages/plugin/webpack/spec/WebpackConfig.spec.ts +++ b/packages/plugin/webpack/spec/WebpackConfig.spec.ts @@ -183,6 +183,118 @@ 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(); + + // 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', () => { + 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/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 +410,209 @@ 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('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, + false, + 3000, + ); + const webpackConfig = await generator.getMainConfig(); + const bannerPlugin = findBannerPlugin(webpackConfig.plugins); + expect(bannerPlugin).toBeDefined(); + expect(bannerPlugin!.options.banner).toContain( + 'registerSchemesAsPrivileged', + ); + expect(bannerPlugin!.options.banner).not.toContain('protocol.handle'); + }); + + it('injects only the serving handler with registerSchemes: false', async () => { + const generator = new WebpackConfigGenerator( + { ...appProtocolConfig, appProtocol: { registerSchemes: false } }, + mockProjectDir, + true, + 3000, + ); + const webpackConfig = await generator.getMainConfig(); + const bannerPlugin = findBannerPlugin(webpackConfig.plugins); + expect(bannerPlugin!.options.banner).toContain('protocol.handle'); + expect(bannerPlugin!.options.banner).not.toContain( + 'registerSchemesAsPrivileged', + ); + }); + + it('injects nothing in development with registerSchemes: false', async () => { + const generator = new WebpackConfigGenerator( + { ...appProtocolConfig, appProtocol: { registerSchemes: false } }, + mockProjectDir, + false, + 3000, + ); + const webpackConfig = await generator.getMainConfig(); + expect(findBannerPlugin(webpackConfig.plugins)).toBeUndefined(); + }); + + it('tolerates unservable names on entries the scheme never serves', async () => { + // `toEnvironmentVariable` supports names with spaces and file:// + // tolerated them; JS-only entries stay on file://, so their names + // must be neither host-validated nor allowlisted. + const generator = new WebpackConfigGenerator( + { + ...appProtocolConfig, + renderer: { + entryPoints: [ + { name: 'main_window', html: 'index.html', js: 'renderer.js' }, + { name: 'background worker', js: 'worker.js' }, + ], + }, + }, + mockProjectDir, + true, + 3000, + ); + const webpackConfig = await generator.getMainConfig(); + const bannerPlugin = findBannerPlugin(webpackConfig.plugins); + expect(bannerPlugin!.options.banner).toContain('["main_window"]'); + expect(bannerPlugin!.options.banner).not.toContain('background worker'); + }); + + it('splits served and unserved entries into separate compilations in production', async () => { + const rendererOptions = { + config: {}, + entryPoints: [ + { name: 'main_window', html: 'index.html', js: 'renderer.js' }, + { name: 'background_worker', js: 'worker.js' }, + ], + }; + const generator = new WebpackConfigGenerator( + { ...appProtocolConfig, renderer: rendererOptions }, + mockProjectDir, + true, + 3000, + ); + const configs = await generator.getRendererConfig( + rendererOptions as WebpackPluginRendererConfig, + ); + const webConfigs = configs.filter((config) => config.target === 'web'); + expect(webConfigs).toHaveLength(2); + const servedConfig = webConfigs.find( + (config) => (config.entry as Entry)['main_window'], + ); + const unservedConfig = webConfigs.find( + (config) => (config.entry as Entry)['background_worker'], + ); + // The served compilation needs root-relative asset URLs; the JS-only + // one must keep webpack's 'auto' script-relative resolution for + // file:// loading. + expect(servedConfig?.output?.publicPath).toEqual('/'); + expect(unservedConfig?.output?.publicPath).toBeUndefined(); + }); + + 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 () => { + 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 33a3c2e380..db1141a9fb 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'; @@ -146,6 +147,44 @@ 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 (every + * origin is rooted at the shared renderer output directory, so the path + * carries the per-entry subdirectory; 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 and + * `nodeIntegration: true` entry points keep their `file://` paths — + * Electron only derives the renderer's `__dirname` from `file:` URLs, which + * relocated native modules rely on. + * + * 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. + * - The object form's `registerSchemes: false` hands the + * `registerSchemesAsPrivileged` call to your app instead — Forge then + * injects only the serving handler, and your registration must include + * the serving scheme (see `APP_PROTOCOL_DEFAULT_PRIVILEGES` in + * `@electron-forge/core-utils`). + * - 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` + */ + 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 5c84edfc0d..2b80e3a3ca 100644 --- a/packages/plugin/webpack/src/WebpackConfig.ts +++ b/packages/plugin/webpack/src/WebpackConfig.ts @@ -1,11 +1,16 @@ import path from 'node:path'; +import { + getAppProtocolBanner, + getAppProtocolEntryUrl, + resolveAppProtocolConfig, +} 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 { @@ -134,8 +139,29 @@ 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. `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, + ); + // 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}')}\``; } const protocol = @@ -185,10 +211,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]; @@ -224,6 +262,54 @@ export default class WebpackConfigGenerator { }; mainConfig.entry = fix(mainConfig.entry as EntryType); + // 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 appProtocolBanner = this.pluginConfig.appProtocol + ? getAppProtocolBanner( + // Only entries the scheme actually serves (the same predicate + // `rendererEntryPoint` uses): JS-only and `nodeIntegration` entries + // keep `file://`, so their names are neither validated as URL hosts + // nor added to the handler's origin allowlist. + this.allPluginRendererOptions.flatMap((rendererOptions) => + (rendererOptions.entryPoints ?? []) + .filter( + (entryPoint) => + isLocalWindow(entryPoint) && + !( + entryPoint.nodeIntegration ?? + rendererOptions.nodeIntegration ?? + false + ), + ) + .map((entryPoint) => entryPoint.name), + ), + 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, + }, + ) + : ''; + // The banner is empty in development with `registerSchemes: false` — the + // call above still validates the config either way. + const appProtocolPlugins = appProtocolBanner + ? [ + new BannerPlugin({ + banner: appProtocolBanner, + raw: true, + entryOnly: true, + }), + ] + : []; + return webpackMerge( { devtool: 'source-map', @@ -234,7 +320,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, @@ -311,7 +397,24 @@ export default class WebpackConfigGenerator { return rendererConfigs.filter(isNotNull); } - buildRendererBaseConfig(target: RendererTarget): webpack.Configuration { + /** + * 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://`. Only compilations whose + * every entry is actually served get it — JS-only and `nodeIntegration` + * entries stay on `file://` and rely on `'auto'` script-relative URLs, so + * served and unserved entries are built as separate compilations. + */ + private rendererPublicPath(servedOverAppProtocol: boolean) { + if (!this.isProd) return { publicPath: '/' }; + return servedOverAppProtocol ? { publicPath: '/' } : {}; + } + + buildRendererBaseConfig( + target: RendererTarget, + servedOverAppProtocol = false, + ): webpack.Configuration { return { target: rendererTargetToWebpackTarget(target), devtool: this.rendererSourceMapOption, @@ -320,7 +423,7 @@ export default class WebpackConfigGenerator { path: path.resolve(this.webpackDir, 'renderer'), filename: '[name]/index.js', globalObject: 'self', - ...(this.isProd ? {} : { publicPath: '/' }), + ...this.rendererPublicPath(servedOverAppProtocol), }, node: { __dirname: false, @@ -340,21 +443,24 @@ export default class WebpackConfigGenerator { rendererOptions: WebpackPluginRendererConfig, entryPoints: WebpackPluginEntryPoint[], target: RendererTarget.Web | RendererTarget.ElectronRenderer, + servedOverAppProtocol = false, ): Promise { if (!isLocalOrNoWindowEntries(entryPoints)) { throw new Error('Invalid renderer entry point detected.'); } const entry: webpack.Entry = {}; - const baseConfig: webpack.Configuration = - this.buildRendererBaseConfig(target); + const baseConfig: webpack.Configuration = this.buildRendererBaseConfig( + target, + servedOverAppProtocol, + ); const rendererConfig = await this.resolveConfig(rendererOptions.config); const output = { path: path.resolve(this.webpackDir, 'renderer'), filename: '[name]/index.js', globalObject: 'self', - ...(this.isProd ? {} : { publicPath: '/' }), + ...this.rendererPublicPath(servedOverAppProtocol), }; const plugins: webpack.WebpackPluginInstance[] = []; @@ -441,13 +547,39 @@ export default class WebpackConfigGenerator { target === RendererTarget.Web || target === RendererTarget.ElectronRenderer ) { - rendererConfigs.push( - this.buildRendererConfigForWebOrRendererTarget( - rendererOptions, - entryPoints, - target, - ), - ); + // With `appProtocol`, only local-window Web-target entries are served + // over the scheme; JS-only entries keep `file://` URLs and `'auto'` + // script-relative asset resolution. The two need different prod + // `publicPath` values, so they build as separate compilations. + const splitServedEntries = + this.isProd && + !!this.pluginConfig.appProtocol && + target === RendererTarget.Web; + const served = splitServedEntries + ? entryPoints.filter((entryPoint) => isLocalWindow(entryPoint)) + : []; + const unserved = splitServedEntries + ? entryPoints.filter((entryPoint) => !isLocalWindow(entryPoint)) + : entryPoints; + if (served.length > 0) { + rendererConfigs.push( + this.buildRendererConfigForWebOrRendererTarget( + rendererOptions, + served, + target, + true, + ), + ); + } + if (unserved.length > 0) { + rendererConfigs.push( + this.buildRendererConfigForWebOrRendererTarget( + rendererOptions, + unserved, + target, + ), + ); + } return rendererConfigs; } else if ( target === RendererTarget.ElectronPreload || diff --git a/packages/plugin/webpack/src/WebpackPlugin.ts b/packages/plugin/webpack/src/WebpackPlugin.ts index 87873bb8d0..4ef9c76253 100644 --- a/packages/plugin/webpack/src/WebpackPlugin.ts +++ b/packages/plugin/webpack/src/WebpackPlugin.ts @@ -151,7 +151,19 @@ export default class WebpackPlugin extends PluginBase { webpack(options).run(async (err, stats) => { if (rendererOptions && rendererOptions.jsonStats) { for (const [index, entryStats] of (stats?.stats ?? []).entries()) { - const name = rendererOptions.entryPoints[index].name; + // Name each stats file from its own compilation's entries — the + // config array does not align positionally with entryPoints + // (preload scripts and appProtocol's served/unserved split both + // build separate compilations). Preload compilations reuse their + // window's entry name, so tag them to keep the filenames unique. + const entryNames = Object.keys(options[index].entry ?? {}); + const isPreloadCompilation = String( + options[index].output?.filename ?? '', + ).includes('preload'); + const name = + (entryNames.join('-') || + rendererOptions.entryPoints[index]?.name || + String(index)) + (isPreloadCompilation ? '-preload' : ''); await this.writeJSONStats( 'renderer', entryStats, 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-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/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/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 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-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/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/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..35b221f870 --- /dev/null +++ b/packages/utils/core-utils/spec/app-protocol.spec.ts @@ -0,0 +1,306 @@ +import { describe, expect, it } from 'vitest'; + +import { + APP_PROTOCOL_DEFAULT_PRIVILEGES, + getAppProtocolBanner, + getAppProtocolEntryUrl, + resolveAppProtocolConfig, +} from '../src/app-protocol'; + +describe('app-protocol', () => { + 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 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('rejects renderer names that collide case-insensitively', () => { + // URL hosts are case-insensitive, so these would share one origin and + // the second window would silently be served the first one's files. + expect(() => getAppProtocolBanner(['MainWindow', 'mainwindow'])).toThrow( + /same origin/, + ); + }); + + it.each(['1', '2024', '1.2', '0x10'])( + 'rejects the IPv4-canonicalising renderer name %j', + (name) => { + // Standard schemes canonicalise IPv4-like hosts (`app://1/` becomes + // `app://0.0.0.1/`), so these names could never match the handler. + expect(() => getAppProtocolEntryUrl(name)).toThrow(/URL host/); + }, + ); + + it('rejects disabling standard on the serving scheme', () => { + expect(() => + resolveAppProtocolConfig({ privileges: { standard: false } }), + ).toThrow(/standard/); + }); + + it('rejects codeCache without standard on additional schemes', () => { + expect(() => + resolveAppProtocolConfig({ + additionalPrivilegedSchemes: [ + { scheme: 'media', privileges: { codeCache: true } }, + ], + }), + ).toThrow(/codeCache/); + }); + + 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('keeps strict mode scoped to the runtime and no-ops outside the browser process', () => { + const banner = getAppProtocolBanner(['main_window']); + // The directive lives inside the IIFE: a file-level one would force + // webpack's deliberately-sloppy bundled CJS deps into strict mode (the + // Vite path re-adds a file-level directive in pluginAppProtocolRuntime, + // where the banner displaces Rollup's own prologue). + expect(banner).not.toMatch(/^'use strict';/); + expect(banner).toMatch(/\(function \(\) \{\s*'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('serves single-range requests so media can seek', () => { + const banner = getAppProtocolBanner(['main_window']); + // net.fetch(file:) drops the Range header (electron/electron#38749); + // the handler answers ranges from the file directly and advertises + // Accept-Ranges on full responses. + expect(() => new Function(banner)).not.toThrow(); + expect(banner).toContain(`request.headers.get('range')`); + expect(banner).toContain('status: 206'); + expect(banner).toContain('status: 416'); + expect(banner).toContain(`'Accept-Ranges', 'bytes'`); + // Only known media types take the fs-range path — everything else goes + // through net.fetch and keeps its sniffed Content-Type instead of + // getting application/octet-stream. + expect(banner).toContain('mediaType !== undefined'); + expect(banner).not.toContain('application/octet-stream'); + for (const mediaExtension of ['aac', 'mkv', 'oga', 'weba', 'mp4', 'mp3']) { + expect(banner).toContain(`${mediaExtension}: '`); + } + }); + + 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`); + // With a shared root, '/' must map into the origin's own subdirectory — + // routers push '/' and reloads request it. + expect(shared).toContain(`path.join(name, 'index.html')`); + }); + + it('guards traversal without rejecting names that merely start with dots', () => { + const banner = getAppProtocolBanner(['main_window']); + // A '..' prefix check alone would 404 a real file named '..manifest.json'. + expect(banner).toContain( + `relative === '..' || relative.startsWith('..' + path.sep)`, + ); + }); + + 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(); + 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 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 serving scheme', () => { + expect(() => + resolveAppProtocolConfig({ + additionalPrivilegedSchemes: [{ scheme: 'app' }], + }), + ).toThrow(/reserved/); + expect(() => + resolveAppProtocolConfig({ + scheme: 'myapp', + additionalPrivilegedSchemes: [{ scheme: 'myapp' }], + }), + ).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', + 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', + ); + }); + + describe('registerSchemes: false', () => { + it('emits the serving handler but no scheme registration', () => { + const banner = getAppProtocolBanner(['main_window'], { + registerSchemes: false, + }); + expect(() => new Function(banner)).not.toThrow(); + expect(banner).toContain('protocol.handle'); + expect(banner).not.toContain('registerSchemesAsPrivileged'); + }); + + it('emits nothing in development', () => { + // The app owns the registration call and the dev server serves the + // renderers, so there is nothing left for the runtime to do. + expect( + getAppProtocolBanner( + ['main_window'], + { registerSchemes: false }, + { + serveRenderers: false, + }, + ), + ).toEqual(''); + }); + + it('rejects options that configure the registration Forge no longer makes', () => { + expect(() => + resolveAppProtocolConfig({ + registerSchemes: false, + additionalPrivilegedSchemes: [{ scheme: 'media' }], + }), + ).toThrow(/registerSchemes: false/); + expect(() => + resolveAppProtocolConfig({ + registerSchemes: false, + privileges: { stream: false }, + }), + ).toThrow(/registerSchemes: false/); + }); + + it('exports the default serving privileges for app-owned registration', () => { + expect(APP_PROTOCOL_DEFAULT_PRIVILEGES).toEqual({ + standard: true, + secure: true, + supportFetchAPI: true, + stream: true, + codeCache: true, + }); + }); + }); + + it('resolves the boolean form to the defaults', () => { + expect(resolveAppProtocolConfig(true)).toEqual({ + scheme: 'app', + registerSchemes: true, + 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 new file mode 100644 index 0000000000..797352ae67 --- /dev/null +++ b/packages/utils/core-utils/src/app-protocol.ts @@ -0,0 +1,502 @@ +/** + * 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.). + * + * The code returned by {@link getAppProtocolBanner} is prepended to the + * 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`. 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://...')`. + * + * 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'; + +/** + * 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+.-]*$/; + +/** + * 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 + * 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. + */ +export interface PrivilegedScheme { + scheme: string; + privileges?: { + standard?: boolean; + secure?: boolean; + bypassCSP?: boolean; + allowServiceWorkers?: boolean; + supportFetchAPI?: boolean; + corsEnabled?: boolean; + stream?: boolean; + codeCache?: boolean; + }; +} + +/** + * Default privileges for the serving scheme. `standard` + `secure` make it a + * real secure origin, `supportFetchAPI` lets renderer code `fetch()` its own + * resources, `stream` keeps `