Skip to content

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

Open
erickzhao wants to merge 10 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 10 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
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.

2 participants