feat(bundlers): opt-in app:// protocol for serving packaged renderers - #4352
feat(bundlers): opt-in app:// protocol for serving packaged renderers#4352erickzhao wants to merge 22 commits into
app:// protocol for serving packaged renderers#4352Conversation
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/<name>` 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://<renderer-name>/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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JHmXuuGMGg1bcCCYDNVSKW
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JHmXuuGMGg1bcCCYDNVSKW
…derers 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://<entry-name>/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 (<out>/main bundle with ../renderer/<name>), so the shared runtime's __dirname-relative lookup works for both. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JHmXuuGMGg1bcCCYDNVSKW
Resolves a conflict in packages/plugin/vite/src/Config.ts where both sides appended a new option to VitePluginConfig: keep appProtocol (ours) and hotRestart (from next). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JHmXuuGMGg1bcCCYDNVSKW
app:// protocol for serving packaged renderers (Vite + webpack)app:// protocol for serving packaged renderers
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JHmXuuGMGg1bcCCYDNVSKW
app:// protocol for serving packaged renderersapp:// protocol for serving packaged renderers
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JHmXuuGMGg1bcCCYDNVSKW
@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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JHmXuuGMGg1bcCCYDNVSKW
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JHmXuuGMGg1bcCCYDNVSKW
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JHmXuuGMGg1bcCCYDNVSKW
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JHmXuuGMGg1bcCCYDNVSKW
MarshallOfSound
left a comment
There was a problem hiding this comment.
Comments inline. The webpack publicPath one is the big one, I think packaged webpack template apps do not load their JS at all with this. The rest is smaller.
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://<name>/<name>/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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JHmXuuGMGg1bcCCYDNVSKW
|
Thanks for the thorough review — all ten findings are addressed in c9d72b0. The big one (webpack The rest, briefly:
Generated by Claude Code |
| * | ||
| * Notes: | ||
| * - `protocol.registerSchemesAsPrivileged` can only be called once per app, | ||
| * and the injected runtime makes that call. If your app needs its own |
There was a problem hiding this comment.
I suspect this to end up being a little unergonomic and believe some apps will want to handle protocol registration themselves while still wanting app:// for Vite resources.
Not a blocker though, just something I'm noticing.
|
@felixrieseberg on apps wanting to own registration while keeping A clean escape hatch would be an opt-out like Generated by Claude Code |
There was a problem hiding this comment.
Seems fine. Some bike-shedding things that I don't think deserve blocking on:
- I'd love an easy way for apps to not get locked out of using
registerSchemesAsPrivilegedthemselves when using the plugin. - Some minor worries about the API contract but I doubt we'll change it anytime soon
Couldn't find any individual code I'd write differently!
Adds `appProtocol: { registerSchemes: false }` for apps that need to own
their single registerSchemesAsPrivileged call while still having Forge
serve renderers over the custom scheme. In this mode the injected
runtime carries only the protocol.handle serving part (and nothing at
all in development, where the dev server serves the renderers); the
app's own registration must include the serving scheme, and
APP_PROTOCOL_DEFAULT_PRIVILEGES is exported from core-utils so that is
one spread rather than folklore.
`privileges` and `additionalPrivilegedSchemes` are rejected in this
mode with pointed errors — both configure a registration call Forge no
longer makes.
Verified by unit specs across core-utils and both plugins, and a
packaged asar app that registers the scheme itself and loads its window
over the handler-only runtime.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JHmXuuGMGg1bcCCYDNVSKW
| this.allPluginRendererOptions.flatMap((rendererOptions) => | ||
| (rendererOptions.entryPoints ?? []) | ||
| .filter((entryPoint) => !isPreloadOnly(entryPoint)) | ||
| .map((entryPoint) => entryPoint.name), | ||
| ), |
There was a problem hiding this comment.
This list is every non-preload-only entry, and the banner validates each one as a URL host. But rendererEntryPoint() only puts isLocalWindow && !nodeIntegration entries on the scheme. JS-only and nodeIntegration entries stay on file://. So a config that was valid before, like { name: 'background worker', js: 'worker.js' } (toEnvironmentVariable supports names with spaces), now throws "cannot be used with appProtocol" in start and package for an entry the scheme never serves. Filter this with the same predicate rendererEntryPoint uses. That also keeps the handler host allowlist to origins that are actually served.
| /** | ||
| * 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, | ||
| }; | ||
| } |
There was a problem hiding this comment.
Pre-existing on next, but this PR rewrites the comment. It says hotRestart is moot because workers only run when packaging. The dev branch of build() also runs every main and preload target through spawnViteBuildWatch(this.serializableConfig, ...). hotRestart is not in the serialized config, so the worker vite.main.config.ts never installs pluginHotRestart, and the worker IPC has no restart message anyway. hotRestart: true is a silent no-op under start. At minimum the comment is wrong. Real fix is to include hotRestart in serializableConfig and bridge requestAppRestart from the worker to the parent like reload-renderers. Fine as a follow up.
| return { | ||
| scheme, | ||
| registerSchemes, | ||
| privileges: { ...APP_PROTOCOL_DEFAULT_PRIVILEGES, ...config.privileges }, | ||
| additionalPrivilegedSchemes, | ||
| }; |
There was a problem hiding this comment.
Two configs pass resolveAppProtocolConfig and validateRendererNameForAppProtocol but cannot work at runtime, which breaks the "throws rather than emitting a runtime that could never serve a window" contract:
privilegesis spread over the defaults (which now includecodeCache: true) with no validation.privileges: { standard: false }, or anadditionalPrivilegedSchemesentry withcodeCacheand nostandard, emits aregisterSchemesAsPrivilegedcall that Electron rejects ("Code cache can only be enabled when the custom scheme is registered as standard scheme"). That throws synchronously in the IIFE at the top of the main bundle.standard: falseon the serving scheme cannot work with host-based renderer matching anyway. Force or reject it, and rejectcodeCachewithoutstandard.RENDERER_NAME_AS_HOSTaccepts all-numeric names (1,2024,1.2,0x10).standard: trueschemes get Chromium IPv4 host canonicalisation,app://1/becomesapp://0.0.0.1/, so the handlerrendererName.toLowerCase() === url.hostnamecheck never matches and the window 404s, packaged only. Require at least one letter, or compare against the canonicalised host.
- Build served (local-window) and JS-only Web-target entries as separate compilations when packaging with appProtocol: only served entries get publicPath '/', so JS-only bundles keep webpack's 'auto' script-relative asset resolution under file://. - Keep the runtime's 'use strict' scoped to its IIFE. A file-level directive from webpack's BannerPlugin would force deliberately-sloppy bundled CJS deps strict; the Vite path re-adds the file-level directive in pluginAppProtocolRuntime where the banner displaces Rollup's own prologue. - Gate the *_VITE_ENTRY file:// fallback expression on Vite >= 8: esbuild-based define (Vite 5-7) rejects expression values and would fail the whole main build. Use pathToFileURL(...).href so install paths with '#', '?' or '%' stay encoded like loadFile did. - Serve single-range requests from the file directly: net.fetch(file:) drops the Range header (electron/electron#38749), which media seeking needs and file:// supported. 206/Content-Range for satisfiable ranges, 416 otherwise, Accept-Ranges advertised on full responses. - Only validate and allowlist renderer names the scheme actually serves (same predicate as rendererEntryPoint), so JS-only entries with names like 'background worker' keep working. - Reject privileges that cannot work at runtime: standard: false on the serving scheme, and codeCache without standard on additional schemes. Round-trip renderer names through URL host canonicalisation so IPv4-like names ('1', '0x10') fail the build instead of 404ing only when packaged. - Correct the serializableConfig comment about hotRestart and update the webpack appProtocol docs to the real production URL shape and the nodeIntegration exception. Verified by unit specs and packaged-app repros (Range semantics exercised over XHR from a served renderer; strict-mode and process-type guards checked in emitted bundles). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JHmXuuGMGg1bcCCYDNVSKW
Five defects, all invisible on Vite 6 and all silent on Vite 8.
1. `output.freeze` is Rollup-only. Vite 8 bundles Rolldown, whose
`OutputOptions` has no such key, so setting it is a type error
(3x TS2339/TS2353) and a no-op. Rolldown never emits `Object.freeze`
anywhere, so the opt-out is unnecessary there rather than merely
unsupported; gate it on `vite.rolldownVersion`.
2. `resolveId` ignored its `importer`, so the shim's own
`require("electron")` was re-claimed by the plugin and the virtual module
resolved to itself:
init_x = __esmMin(() => { moduleValue = (init_x(), ...) })
`__esmMin`'s `fn = 0` guard swallows the self-call, so instead of
recursing it yields `undefined` for every Electron export. Marking
shim-internal requests external is what keeps a real `require` in the
output -- simply declining them makes Rolldown resolve `electron` to the
npm package, which outside Electron is the *installer stub*, bundling
`getElectronPath()` and a "Downloading Electron binary..." branch into the
renderer with `fs`/`child_process` stubbed to `module.exports = {}`.
3. The shim called `require` through an alias
(`const runtimeRequire = require`). Rolldown only rewrites syntactically
direct `require(...)` calls into its external-module interop; the aliased
form is dropped. Call `require` directly.
4. `sharedTexture` was missing from `electronExportNames` -- a second
instance of the `ServiceWorkerMain` bug. It is declared as a `const` in
`CrossProcessExports`, so `MISSING_EXPORT` breaks the build for anyone
importing it. Found by the export-list spec, which is what it is for.
5. The specs asserted a literal `runtimeRequire(...)` and `freeze: false` --
Rollup's output shape rather than the behaviour. Assert the requested
specifier plus a `require` mention (Rolldown reaches it via
`require.apply(this, arguments)`, which a literal `require(` pattern
cannot match), and branch the freeze assertion on the bundler so the spec
keeps its teeth on Vite 6/7 instead of being loosened for both.
Also fixes a pre-existing lint error on this branch: the export-list spec
resolved `electron` as a bare specifier, but it is a devDependency of the
workspace root, not of this package, so `n/no-extraneous-require` rejected
it. The rule keys on the specifier, so `require.resolve(..., { paths })`
does not satisfy it; the typings are now located by path.
Verified in both directions, and the two version-conditional assertions were
mutation-checked so the branching did not turn them into no-ops:
Vite 8.0.3 / Rolldown 1.0.0-rc.12 (on `next`, merged with electron#4352):
tsc -b packages 0 errors
vitest --project fast 50/50 pass
Vite 6.4.3 / Rollup (this branch's base):
tsc -b packages/plugin/vite 0 errors
vitest --project fast 20/20 pass
eslint 0 problems
Mutants killed: forcing the freeze gate off fails "keeps user dependency
and Rollup settings"; pointing the typings path at a missing file fails
"re-exports every Electron API in the shipped export list" with ENOENT.
Co-Authored-By: Claude Code <noreply@anthropic.com>
MarshallOfSound
left a comment
There was a problem hiding this comment.
Third pass on 852b054. The second pass stuff looks fixed. Four small things left, all in the new serving code and the Vite fallback.
| const mediaTypes = { mp4: 'video/mp4', m4v: 'video/mp4', m4a: 'audio/mp4', webm: 'video/webm', ogg: 'audio/ogg', ogv: 'video/ogg', opus: 'audio/ogg', mp3: 'audio/mpeg', wav: 'audio/wav', flac: 'audio/flac', mov: 'video/quicktime' }; | ||
| const ext = path.extname(target).slice(1).toLowerCase(); | ||
| return new Response( | ||
| Readable.toWeb(fs.createReadStream(target, { start: start, end: end })), | ||
| { | ||
| status: 206, | ||
| headers: { | ||
| 'Content-Range': 'bytes ' + start + '-' + end + '/' + size, | ||
| 'Accept-Ranges': 'bytes', | ||
| 'Content-Length': String(end - start + 1), | ||
| 'Content-Type': mediaTypes[ext] || 'application/octet-stream', |
There was a problem hiding this comment.
The 206 branch takes Content-Type from an 11-entry table and falls back to application/octet-stream. Chromium plays .aac, .mkv, .oga and .weba, and none of them are in the table. So <audio src="app://main_window/narration.aac"> gets an octet-stream 206 on the first range request. Under file:// it got the real type. Any other file that gets a Range request, like an app fetch with a Range header for a .js or .json file, also gets application/octet-stream.
Either only take this branch for known media extensions and let the rest go through net.fetch, or get the type from the same lookup net.fetch uses.
| } catch { | ||
| return new Response(null, { status: 400 }); | ||
| } | ||
| const target = path.join(root, pathname === '/' ? 'index.html' : pathname); |
There was a problem hiding this comment.
On webpack rootIncludesName is false, so root is .webpack/renderer and / maps to .webpack/renderer/index.html. That file is never emitted. The entry URL is app://main_window/main_window/index.html, so the first load works. But app://main_window/ returns 404, and that is what <a href="/">, location.href = '/', or a reload after a router pushes / request. Vite does not have this problem because its root is the renderer directory.
When rootIncludesName is false, map / to path.join(name, 'index.html').
| // `loadURL(undefined)`. pathToFileURL encodes '#', '?' and | ||
| // '%' in install paths the way the loadFile call this | ||
| // replaces did. | ||
| `require('node:url').pathToFileURL(require('node:path').join(__dirname, '../renderer/${name}/index.html')).href` |
There was a problem hiding this comment.
name goes into a single-quoted JS string here with no escaping. validateRendererNameForAppProtocol only runs on the appProtocol branch. So a renderer named won't-fix gives an invalid define, and the main build fails with an oxc parse error that points at the define and not at the config. VITE_NAME above already uses JSON.stringify. Do the same here:
`require('node:url').pathToFileURL(require('node:path').join(__dirname, ${JSON.stringify(`../renderer/${name}/index.html`)})).href`| const target = path.join(root, pathname === '/' ? 'index.html' : pathname); | ||
| // Never serve files from outside the renderer output directory. | ||
| const relative = path.relative(root, target); | ||
| if (relative.startsWith('..') || path.isAbsolute(relative)) { |
There was a problem hiding this comment.
relative.startsWith('..') also matches a file inside root whose name starts with .., like ..manifest.json. That file gets a 404. Bundlers do not usually emit names like that, so this is minor. But this code is injected and apps cannot patch it. Use relative === '..' || relative.startsWith('..' + path.sep) || path.isAbsolute(relative).
- Only known media types take the fs-range path; every other Range request falls through to net.fetch so responses keep their sniffed Content-Type instead of application/octet-stream. Extend the media table with aac, mkv, oga, and weba. - Map '/' to the origin's own <name>/index.html under the shared (webpack) renderer root — routers push '/' and reloads request it. - Tighten the traversal guard so files whose names merely start with '..' are served instead of 404ing. - JSON.stringify the renderer path in the Vite non-appProtocol entry fallback so a name containing a quote fails legibly instead of producing an invalid define. Verified by unit specs and a packaged asar repro: shared-root '/' navigation serves the origin index, a '..manifest.json' file serves with 200, and percent-encoded traversal is still rejected. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JHmXuuGMGg1bcCCYDNVSKW
…rotocol-templates-45kg8m
Include the *_VITE_ENTRY defines in the expectations of the new custom-server-host spec from next (f9c705d) — the entry constant reads the same dev-server URL map, so it inherits the custom-host behavior. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JHmXuuGMGg1bcCCYDNVSKW
There was a problem hiding this comment.
Beyond the two inline findings, I checked whether webpack's appProtocol served/unserved split at WebpackConfig.ts (buildRendererConfigs and the banner allowlist) mishandles nodeIntegration: true window entries the way rendererEntryPoint does — it doesn't; those entries are still correctly excluded from the app:// path. I also checked that additionalPrivilegedSchemes entries skip the RESERVED_SCHEMES check applied to the main scheme — confirmed true, but low severity (self-inflicted misconfiguration, not a security bypass).
Extended reasoning...
This is a targeted, narrow addition beyond the two confirmed inline findings (mis-cased supportFetchApi and case-insensitive renderer-name collisions), which are already being posted as inline comments and should not be restated here. I independently verified from the current diff/HEAD state that the most recent third-party reviewer round (MarshallOfSound's 2026-09-03T23:25 comments on app-protocol.ts:446/402/405 and vite.base.config.ts:104) was concretely addressed by the immediate follow-up commit 0e5ebcc four minutes later — the media-type Range handling, root/index.html mapping, traversal-guard tightening, and JSON.stringify path escaping in that commit's diff line up exactly with those four comment locations. That rules out those specific third-party objections as still-open blockers, but does not change the overall picture: this PR is a large, security-sensitive change (custom privileged scheme registration, protocol handler with a path-traversal guard, banner code injected into every main-process bundle) that has already been through three rounds of reviewer feedback and still has two live, confirmed correctness/security-relevant findings in the current HEAD. Given the findings-present branch of the decision guidance, a full defer/approve review body is not appropriate; only the narrow "ruled out beyond the findings" exception applies, and I have two concrete, verified items to report there (the webpack nodeIntegration split behaving correctly, and the additionalPrivilegedSchemes reserved-scheme gap being real but low severity) that were not already surfaced by the posted findings or ruled-out list restated verbatim.
Findings marked 🟡 are optional suggestions and need no follow-up push.
…renderer names Electron's CustomScheme privileges key is supportFetchAPI (capital API); the mis-cased supportFetchApi was silently dropped by Electron, so the serving scheme shipped without Fetch API support — the feature's headline benefit over file://. Verified in a packaged asar app: with the corrected key, renderer fetch() of the scheme returns 200 (and a ranged fetch gets a proper 206 with Content-Range), where before the request never left the renderer. Also reject renderer entry names that collide case-insensitively when appProtocol is enabled: URL hosts are case-insensitive, so such names share one origin and the second window would silently be served the first one's files. Both found by automated review. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JHmXuuGMGg1bcCCYDNVSKW
The fixture's npm ci hung past the hook's 90s timeout on every platform starting ~23:30 UTC, killing the slow-tests jobs. The stall is npm's security-audit round-trip: the registry's advisory bulk endpoint is hanging (reproduced from an unrelated network — the same install completes in under a second with --no-audit and hangs indefinitely without it), and npm waits on it with no timeout. The audit buys a test fixture nothing, so drop it (and --no-fund noise) rather than leaving a hidden external dependency in the hook. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JHmXuuGMGg1bcCCYDNVSKW
There was a problem hiding this comment.
Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.
Beyond the inline finding, I checked a few other candidates and ruled them out: additionalPrivilegedSchemes bypassing RESERVED_SCHEMES isn't actually possible since scheme collision with the serving scheme is still checked separately; the *_VITE_ENTRY becoming undefined when appProtocol is off matches existing (pre-PR) dev-mode behavior, not a regression; and the webpack prod publicPath split correctly only ever sees nodeIntegration:false entries by the time it runs, since getRendererConfig has already routed nodeIntegration:true entries to the electronRenderer target bucket.
Extended reasoning...
This run's new confirmed finding is a real ordering bug in packages/plugin/webpack/src/WebpackConfig.ts where splitting Web-target entry points into served/unserved compilations for appProtocol breaks the positional index alignment that WebpackPlugin.ts's runWebpack/writeJSONStats relies on between stats.stats[index] and rendererOptions.entryPoints[index].name, causing JSON stats (and potentially other index-based lookups) to be written under the wrong entry name whenever a Web-target renderer group mixes served and unserved entries. This is a legitimate, non-trivial bug in security/build-pipeline-adjacent code that a human should look at before merge.
Separately, the two issues I flagged in my own prior reviews on this PR (the supportFetchApi/supportFetchAPI mis-casing that silently disabled Fetch API support for app:// origins, and the case-insensitive renderer-name collision that could let two windows resolve to the same origin) have both been fixed in the current code: supportFetchAPI is now correctly capitalized throughout app-protocol.ts, and getAppProtocolBanner now explicitly tracks seenHosts and throws a build-time error when two renderer names collide after lowercasing.
I also examined three additional candidates raised in this run's investigation and ruled them out as non-issues, as summarized in the body above.
Given there is an unresolved confirmed finding, plus multiple third-party (MarshallOfSound) review threads from the most recent 2026-09-03 review that show no clear resolution marker, this is not a case for approval. The one-sentence-verdict + ruled-out-note format is used here per the exception for findings-present runs where additional concrete items were checked and ruled out.
…ries runWebpack labeled each compilation's stats file by positional index into rendererOptions.entryPoints, but the config array does not align with that list: preload scripts build separate compilations, and appProtocol's served/unserved split adds another, so with jsonStats enabled the stats-<name>.json files could carry another entry's stats. Derive the name from the compilation's own entry keys instead, falling back to the old positional name and then the index. Found by automated review. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JHmXuuGMGg1bcCCYDNVSKW
A window with an inline preload builds two compilations that share the window's entry name, so their stats files collided on one path — suffix the preload compilation's name with '-preload'. On Vite < 8 without appProtocol, *_VITE_ENTRY stays undefined in production because esbuild's define rejects expression values; emit a build-time warning so loadURL(MAIN_WINDOW_VITE_ENTRY) callers learn before the packaged app fails. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JHmXuuGMGg1bcCCYDNVSKW
There was a problem hiding this comment.
Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.
Beyond the inline finding, I looked at two more candidates and ruled them out: the Vite<8 console.warn only fires in the narrow case where appProtocol is off and the Vite major is <8 (not unconditionally on every build), and additionalPrivilegedSchemes entries aren't checked against RESERVED_SCHEMES here, but registerSchemesAsPrivileged validates schemes itself at startup rather than silently accepting a bogus one.
Extended reasoning...
This run confirmed a real bug (the vite.base.config.ts:107 VITE_ENTRY define hardcodes a CJS require(...) expression regardless of the main bundle's actual output format, which will be posted inline) and found additional verified findings not being posted here. Given that, plus prior unresolved objections from a human reviewer (MarshallOfSound) on earlier pushes that are not clearly addressed by later commits, approval is not appropriate. Beyond the posted finding, I re-read getBuildDefine() in vite.base.config.ts and confirmed the console.warn is correctly scoped to the appProtocol off + Vite<8 branch only, not emitted unconditionally, so that candidate is not a bug. I also read resolveAppProtocolConfig in app-protocol.ts and confirmed additionalPrivilegedSchemes entries skip the RESERVED_SCHEMES check that the main scheme gets, but Electron's own registerSchemesAsPrivileged validates scheme names and privilege combinations at call time, so a reserved scheme there would surface as a startup error rather than a silent security gap — a lower-severity gap than the posted finding, appropriately left out of the inline comments.
Still open from earlier reviews (1):
- Unresolved: 1 minor or pre-existing.
Without appProtocol, the *_VITE_ENTRY define hardcoded a CJS require() expression that throws in an ESM main bundle, and on Vite < 8 the constant stayed undefined because esbuild's define rejects expressions. Point the define at a bare globalThis member (the one non-JSON shape esbuild accepts) and assign the file:// URL from a banner injected in outputOptions, where the bundle's real format is known: require()/ __dirname for CJS, new URL(..., import.meta.url) for ESM. This drops the Vite version sniff and the build-time warning — the constant now works on Vite 5-8 and both formats. Verified against real Vite 7 and Vite 8 builds in both formats. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JHmXuuGMGg1bcCCYDNVSKW
pluginAppProtocolRuntime prepended the CommonJS runtime regardless of
output format, so appProtocol combined with a user's own
build.lib.formats: ['es'] crashed at startup on require('electron')
before any app code ran. Branch on the output format like
pluginViteEntryFallback: ESM bundles get the unchanged runtime wrapped
in a block-scoped createRequire prelude that provides require/__dirname
without colliding with Rollup's module-level shims, and formats no
Electron main process can use (umd/iife) fail the build with a clear
error instead of a cryptic runtime ReferenceError. Verified by building
and running CJS, ESM, and UMD mains with appProtocol enabled.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JHmXuuGMGg1bcCCYDNVSKW
There was a problem hiding this comment.
I reviewed this PR and found no new bugs this round. Because it's a large, security-sensitive change (custom protocol registration, path-traversal-guarded file serving, and format-aware banner injection across both bundlers), a human look is still worthwhile before merging.
Reviewed: app-protocol.ts's scheme/privilege validation and traversal guard, the webpack/vite served-vs-unserved entry wiring and publicPath handling, and the jsonStats/entry-naming logic. Confirmed the previously flagged supportFetchApi casing bug, renderer-name case collisions, webpack jsonStats filename collisions, and the CJS-only banner/VITE_ENTRY format assumptions are now fixed in the current HEAD.
Extended reasoning...
Overview
This PR adds an opt-in appProtocol feature (serving packaged renderers over a privileged app:// scheme) shared by @ electron-forge/core-utils, plugin-webpack, and plugin-vite, plus template updates. It has gone through roughly seven review rounds (human reviewer MarshallOfSound plus this bot across multiple pushes) since 2026-08-27, with real, non-trivial bugs surfacing and being fixed each round: a mis-cased supportFetchAPI privilege that silently disabled the feature's headline fetch() benefit, case-colliding renderer names resolving to the same origin, webpack jsonStats files colliding across preload/served-vs-unserved compilations, and CJS-only assumptions in the Vite VITE_ENTRY define/banner that would crash ESM main bundles. All of these — including the ones I raised in my last review (app-protocol.ts casing, vite.main.config.ts format-awareness) — are addressed by the four most recent commits (1d0c72a, 406335f, 001d8df, 0b0b749, 2b908d6), which I traced against the current file contents.
Security risks
The feature intentionally exposes a new attack surface: a custom protocol handler serving files from the packaged app's install directory, gated by a path-traversal guard and a renderer-name allowlist derived from URL hostname matching. I re-checked the traversal guard and the renderer-name-to-host validation (including the IPv4-like-name and case-collision checks added in later commits) and they look internally consistent with the runtime's case-insensitive hostname matching. I did not attempt to independently fuzz the traversal guard or the Range-request handling beyond reading the code and the specs that exercise it.
Level of scrutiny
Given the security-sensitive nature (custom protocol registration is a once-per-app, hard-to-change-later commitment, and file-serving logic is a classic place for traversal bugs) and the sheer size of the diff (2383 insertions across 30 files touching two bundler plugins plus a new shared core-utils module), this warrants a human review pass even though this automated run found nothing new. Three of MarshallOfSound's inline threads (WebpackConfig.ts:290, VitePlugin.ts:356, app-protocol.ts:270) remain marked unresolved in the thread metadata, but commit 852b054 — pushed minutes after that review round — has commit-message language ("Only validate and allowlist renderer names the scheme actually serves", "Reject privileges that cannot work at runtime: standard: false... codeCache without standard", "Correct the serializableConfig comment about hotRestart") that closely matches those line locations, so they were plausibly addressed even though the GitHub threads were never manually resolved.
Other factors
Test coverage is extensive (new/updated unit specs for every module touched, plus new Verdaccio e2e tests for all four templates), and the author notes the e2e test caught a real config-plumbing bug during development. Given the iteration history and remaining complexity, I'm deferring rather than approving, but I'm not raising a new finding since none was found this round.
Summarize your changes:
Adds an opt-in
appProtocoloption toplugin-viteandplugin-webpackthat serves packaged renderer files over a privileged custom scheme (defaultapp://) instead offile://, per Electron's security recommendations —file://pages get an opaque origin, which breaksfetch()of local resources and origin-scoped storage.The protocol boilerplate (scheme registration,
protocol.handlewith a renderer-name allowlist and path traversal guard) lives once in@electron-forge/core-utilsand is injected into production main-process bundles as a banner, rather than being duplicated into every scaffolded app where it would drift. Templates stay minimal: the Vite templates collapse to a singlemainWindow.loadURL(MAIN_WINDOW_VITE_ENTRY)(new define: dev-server URL in dev,app://URL in prod); the webpack templates only addappProtocol: true.Notes:
file://; the base template is untouched.registerSchemesAsPrivilegedhappens beforereadyand the handler is registered ahead of any userloadURL. Since that call is once-per-app,additionalPrivilegedSchemesfolds an app's own privileged schemes into it.schemeis validated at build time (lowercase RFC 3986 syntax, not a scheme Chromium/Electron claim). It becomes part of the renderer's origin, so renaming after release orphans origin-scoped data — documented accordingly.Verified by unit specs, real builds through both pipelines, and a new Verdaccio e2e test that packages each bundler template, launches the binary, and asserts the renderer was served from
app://(it caught a real config-plumbing bug during development, fixed here). The custom-scheme path is also verified in a packaged asar app.🤖 Generated with Claude Code
https://claude.ai/code/session_01JHmXuuGMGg1bcCCYDNVSKW