feat(bundlers): opt-in app:// protocol for serving packaged renderers - #4352
Open
erickzhao wants to merge 10 commits into
Open
feat(bundlers): opt-in app:// protocol for serving packaged renderers#4352erickzhao wants to merge 10 commits into
app:// protocol for serving packaged renderers#4352erickzhao wants to merge 10 commits into
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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