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 13 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
| private rendererPublicPath(target: RendererTarget) { | ||
| if (!this.isProd) return { publicPath: '/' }; | ||
| return this.pluginConfig.appProtocol && target === RendererTarget.Web | ||
| ? { publicPath: '/' } | ||
| : {}; | ||
| } |
There was a problem hiding this comment.
This sets publicPath: '/' for the whole Web-target compilation in prod when appProtocol is on. That compilation also contains the JS-only (isNoWindow) entries, and rendererEntryPoint() keeps those on file:// on purpose. A JS-only bundle now gets __webpack_require__.p = '/', so any asset module, lazy import() chunk, or new URL(..., import.meta.url) in it resolves to file:///<hash>.ext at the filesystem root instead of the old 'auto' script-relative URL. Packaged only.
Either put JS-only entries on the scheme too, split served and unserved entries into separate compilations, or only set publicPath: '/' when every entry in the compilation is served.
| 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.
| * When enabled, the plugin injects the scheme registration and protocol | ||
| * handler into the production main-process bundle, and the `*_WEBPACK_ENTRY` | ||
| * magic constant for HTML entry points resolves to an | ||
| * `app://<entry-name>/index.html` URL in production (it is a dev server URL | ||
| * in development either way, so `mainWindow.loadURL(MAIN_WINDOW_WEBPACK_ENTRY)` | ||
| * keeps working unchanged). JS-only (no-window) entry points keep their | ||
| * `file://` paths. |
There was a problem hiding this comment.
This still says app://<entry-name>/index.html. After c9d72b0 rendererEntryPoint() returns app://<name>/<name>/index.html because all origins share the .webpack/renderer/ root. It also does not say that nodeIntegration entries stay on file://. Someone writing a CSP or will-navigate allowlist from this gets the wrong path. Update it to the new URL shape and mention the nodeIntegration exception.
| return net.fetch(pathToFileURL(target).toString(), { | ||
| bypassCustomProtocolHandlers: true, | ||
| }); |
There was a problem hiding this comment.
net.fetch(fileUrl, { bypassCustomProtocolHandlers: true }) does not forward request.headers, so the Range header from the media pipeline is dropped and every response is a full 200 with no Content-Range or Accept-Ranges. This is the known protocol.handle + net.fetch(file:) gap, electron/electron#38749. <video>/<audio> load but cannot seek. That is a regression from file:// and it contradicts the APP_PROTOCOL_DEFAULT_PRIVILEGES comment that says stream "keeps <video>/<audio> working". The serving handler is injected and apps cannot patch it. Either handle Range here (parse it, return a 206 with Content-Range) or drop the claim and document the limitation.
| /** | ||
| * 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.
| [VITE_ENTRY]: | ||
| command === 'serve' | ||
| ? JSON.stringify(viteDevServerUrls[VITE_DEV_SERVER_URL]) | ||
| : appProtocol | ||
| ? JSON.stringify(getAppProtocolEntryUrl(name, appProtocol.scheme)) | ||
| : // Keep the constant a valid URL without `appProtocol` too, so | ||
| // an app that calls `loadURL(MAIN_WINDOW_VITE_ENTRY)` and later | ||
| // turns the option off keeps working when packaged instead of | ||
| // failing only in production with `loadURL(undefined)`. | ||
| `\`file://\${require('node:path').join(__dirname, '../renderer/${name}/index.html')}\``, |
There was a problem hiding this comment.
The new fallback (no appProtocol, the default path) is a template literal with require('node:path').join(...) in it. Vite 5 to 7 do build-time define through esbuild. esbuild only accepts JSON literals or identifier/member expressions there and fails with "Invalid define value" for the whole define map as soon as any define key (e.g. MAIN_WINDOW_VITE_NAME) is used in a file. plugin-vite has no vite peer range and imports the project's Vite, so an existing Vite 7 project that upgrades Forge without opting in can no longer build its main target. Only Vite 8 (oxc define) is exercised by the specs.
Keep this JSON-serializable or undefined, or gate the expression on Vite >= 8, or declare Vite 8 as the minimum. If the expression stays, use require('node:url').pathToFileURL(...).href instead of 'file://' + path so install paths with #, ? or % are encoded the same way the loadFile call this replaces did.
| }); | ||
| }); | ||
| });`; | ||
| return `'use strict'; |
There was a problem hiding this comment.
The banner now starts with a file-level 'use strict';. On the Vite path that is a no-op, Rollup CJS output is already strict. plugin-webpack prepends the same string with BannerPlugin({ raw: true }) as the first statement of .webpack/main/index.js, in dev and prod. So the whole webpack main bundle is now strict, including the webpack runtime and every bundled sloppy-mode CJS dep. webpack keeps CJS modules sloppy on purpose unless every module is strict. With appProtocol on (the webpack template default now), one dep with a legacy octal escape or with makes the whole file a SyntaxError, and implicit globals and this change silently.
Keep the directive inside the IIFE for the webpack path and only add the file-level one in pluginAppProtocolRuntime, or inject via Rollup output.intro which lands after Rollup's own 'use strict'.
| 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>
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