Skip to content

feat(bundlers): opt-in app:// protocol for serving packaged renderers - #4352

Open
erickzhao wants to merge 22 commits into
nextfrom
claude/electron-app-protocol-templates-45kg8m
Open

feat(bundlers): opt-in app:// protocol for serving packaged renderers#4352
erickzhao wants to merge 22 commits into
nextfrom
claude/electron-app-protocol-templates-45kg8m

Conversation

@erickzhao

@erickzhao erickzhao commented Aug 27, 2026

Copy link
Copy Markdown
Member
  • I have read the contribution documentation for this project.
  • I agree to follow the code of conduct that this project follows, as appropriate.
  • The changes are appropriately documented (if applicable).
  • The changes have sufficient test coverage (if applicable).
  • The testsuite passes successfully on my local machine (if applicable).

Summarize your changes:

Adds an opt-in appProtocol option to plugin-vite and plugin-webpack that serves packaged renderer files over a privileged custom scheme (default app://) instead of file://, per Electron's security recommendationsfile:// pages get an opaque origin, which breaks fetch() of local resources and origin-scoped storage.

// forge.config.js
{
  name: '@electron-forge/plugin-vite', // or plugin-webpack
  config: {
    build: [/* ... */],
    renderer: [/* ... */],

    // simplest form: serve renderers over app:// in packaged apps
    appProtocol: true,

    // or the object form:
    appProtocol: {
      scheme: 'myapp', // default: 'app' — validated at build time
      additionalPrivilegedSchemes: [
        { scheme: 'media', privileges: { stream: true } },
      ],
    },
  },
}

The protocol boilerplate (scheme registration, protocol.handle with a renderer-name allowlist and path traversal guard) lives once in @electron-forge/core-utils and 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 single mainWindow.loadURL(MAIN_WINDOW_VITE_ENTRY) (new define: dev-server URL in dev, app:// URL in prod); the webpack templates only add appProtocol: true.

Notes:

  • Opt-in only — existing apps are unaffected. Dev mode keeps dev-server URLs; webpack JS-only entries keep file://; the base template is untouched.
  • The banner runs before any user code, so registerSchemesAsPrivileged happens before ready and the handler is registered ahead of any user loadURL. Since that call is once-per-app, additionalPrivilegedSchemes folds an app's own privileged schemes into it.
  • scheme is 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

claude added 4 commits August 27, 2026 06:15
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
@github-actions github-actions Bot added the next label Aug 27, 2026
@erickzhao erickzhao changed the title feat: opt-in app:// protocol for serving packaged renderers (Vite + webpack) feat: opt-in app:// protocol for serving packaged renderers Aug 27, 2026
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
@erickzhao erickzhao changed the title feat: opt-in app:// protocol for serving packaged renderers feat(bundlers): opt-in app:// protocol for serving packaged renderers Aug 27, 2026
claude added 5 commits August 27, 2026 07:33
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
@erickzhao
erickzhao marked this pull request as ready for review August 27, 2026 17:21
@erickzhao
erickzhao requested a review from a team as a code owner August 27, 2026 17:21
@erickzhao erickzhao mentioned this pull request Aug 27, 2026
20 tasks

@MarshallOfSound MarshallOfSound left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread packages/plugin/webpack/src/WebpackConfig.ts Outdated
Comment thread packages/plugin/vite/src/config/vite.main.config.ts Outdated
Comment thread packages/plugin/vite/src/config/vite.main.config.ts
Comment thread packages/plugin/webpack/src/WebpackConfig.ts Outdated
Comment thread packages/plugin/webpack/src/WebpackConfig.ts Outdated
Comment thread packages/plugin/vite/src/config/vite.base.config.ts Outdated
Comment thread packages/utils/core-utils/src/app-protocol.ts
Comment thread packages/utils/core-utils/src/app-protocol.ts
Comment thread packages/utils/core-utils/src/app-protocol.ts Outdated
Comment thread packages/utils/test-utils/src/template-tests.ts Outdated
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

Copy link
Copy Markdown
Member Author

Thanks for the thorough review — all ten findings are addressed in c9d72b0.

The big one (webpack publicPath): confirmed real. Served renderers now share the .webpack/renderer/ origin root with publicPath: '/' (Web-target compilations only, mirroring how the dev server already serves them), and entry URLs carry the per-entry subdirectory (app://main_window/main_window/index.html). Per-entry publicPath can't work since one compilation serves multiple entries, so the shared root was the viable option of the two you sketched. The Verdaccio probe now proves the renderer bundle executed — the renderer script posts a message, the preload forwards it with window.location.href over IPC — so an app whose subresources 404 fails the test; all four bundler templates pass it packaged.

The rest, briefly:

  • Non-browser main-target bundles: if (process.type !== 'browser') return; guard (took the cheap fix — the plugin config can't distinguish the real main entry from workers).
  • output.banner fragility: moved to a Forge-owned Vite plugin whose outputOptions hook composes with a user's banner, and the runtime carries its own leading 'use strict'; so the bundle's prologue stays effective (also applies to the webpack BannerPlugin path).
  • Dev/prod privilege parity: both plugins now emit a registration-only runtime in dev (schemes registered, no handler), which also makes webpack validate the config under start like vite does.
  • nodeIntegration renderers stay on file:// (kept AssetRelocatorPatch untouched).
  • *_VITE_ENTRY resolves to a file:// expression in builds without appProtocol, so it's always a valid URL.
  • Serving-scheme privileges: defaults now include stream + codeCache, and the object form accepts a privileges override merged over them.
  • Validation: scheme syntax applies to additionalPrivilegedSchemes entries; renderer names are validated as URL hosts with a message pointing at the constraint.
  • decodeURIComponent wrapped, malformed escapes return 400; the mangled timeout comment is fixed (vitest options-object form so the formatter can't collapse it again).

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

@felixrieseberg on apps wanting to own registration while keeping app:// serving — agreed that funneling every privileged scheme through Forge config is the least ergonomic part of this design. It follows from registerSchemesAsPrivileged being once-per-app and needing to run before ready, which forces a single owner; the injected runtime claimed that role so the common case stays zero-config.

A clean escape hatch would be an opt-out like appProtocol: { registerSchemes: false }: Forge injects only the protocol.handle serving part, and the app makes its own registerSchemesAsPrivileged call (which must then include the serving scheme — we'd export the default privileges so that's one spread rather than folklore). That splits ownership exactly along the line you're describing: the app owns the registration call, Forge owns serving. Happy to add it to this PR if you and @MarshallOfSound think it's worth the extra surface now, or leave it as a documented follow-up since the current object form covers the known cases.


Generated by Claude Code

@felixrieseberg felixrieseberg left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 registerSchemesAsPrivileged themselves 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

@MarshallOfSound MarshallOfSound left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Second pass on c9d72b0 and e57a3c5. The earlier stuff looks fixed. A few of these are new from the fix commit itself: the webpack publicPath: '/' on JS-only entries, the file-level 'use strict' on the webpack main bundle, and the *_VITE_ENTRY define breaking Vite < 8.

Comment thread packages/plugin/webpack/src/WebpackConfig.ts Outdated
Comment on lines +273 to +277
this.allPluginRendererOptions.flatMap((rendererOptions) =>
(rendererOptions.entryPoints ?? [])
.filter((entryPoint) => !isPreloadOnly(entryPoint))
.map((entryPoint) => entryPoint.name),
),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread packages/plugin/webpack/src/Config.ts Outdated
Comment thread packages/utils/core-utils/src/app-protocol.ts Outdated
Comment on lines 336 to 353
/**
* 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,
};
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread packages/plugin/vite/src/config/vite.base.config.ts Outdated
Comment thread packages/utils/core-utils/src/app-protocol.ts Outdated
Comment on lines +239 to +244
return {
scheme,
registerSchemes,
privileges: { ...APP_PROTOCOL_DEFAULT_PRIVILEGES, ...config.privileges },
additionalPrivilegedSchemes,
};

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  • privileges is spread over the defaults (which now include codeCache: true) with no validation. privileges: { standard: false }, or an additionalPrivilegedSchemes entry with codeCache and no standard, emits a registerSchemesAsPrivileged call 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: false on the serving scheme cannot work with host-based renderer matching anyway. Force or reject it, and reject codeCache without standard.
  • RENDERER_NAME_AS_HOST accepts all-numeric names (1, 2024, 1.2, 0x10). standard: true schemes get Chromium IPv4 host canonicalisation, app://1/ becomes app://0.0.0.1/, so the handler rendererName.toLowerCase() === url.hostname check 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
tzh476 added a commit to tzh476/forge that referenced this pull request Sep 2, 2026
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 MarshallOfSound left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Third pass on 852b054. The second pass stuff looks fixed. Four small things left, all in the new serving code and the Vite fallback.

Comment on lines +436 to +446
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',

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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`

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
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

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread packages/utils/core-utils/src/app-protocol.ts Outdated
Comment thread packages/utils/core-utils/src/app-protocol.ts
…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

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread packages/plugin/webpack/src/WebpackConfig.ts
…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

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Still open from earlier reviews (1):

  • Unresolved: 1 minor or pre-existing.

Comment thread packages/plugin/vite/src/config/vite.base.config.ts Outdated
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

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread packages/plugin/vite/src/config/vite.base.config.ts Outdated
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

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Comment thread packages/plugin/vite/src/config/vite.main.config.ts
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

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants