Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
7784f12
feat(plugin-vite): opt-in app:// protocol for serving packaged renderers
claude Aug 27, 2026
4736087
feat(plugin-vite): allow additional privileged schemes with appProtocol
claude Aug 27, 2026
ad59b31
feat(plugin-webpack): opt-in app:// protocol for serving packaged ren…
claude Aug 27, 2026
e5ad859
Merge branch 'next' into claude/electron-app-protocol-templates-45kg8m
claude Aug 27, 2026
a862bf9
fix(plugin-vite): drop unused VitePluginPrivilegedScheme type export
claude Aug 27, 2026
43c7d07
test: verify packaged apps serve renderers over app:// in Verdaccio e2e
claude Aug 27, 2026
b77ea59
test: pass proxy configuration through to scaffolded project scripts
claude Aug 27, 2026
6652124
fix(plugin-vite): pass appProtocol through to build workers
claude Aug 27, 2026
9c235db
test: type the packaged-app probe for TypeScript entrypoints
claude Aug 27, 2026
c70139c
feat: configurable serving scheme for appProtocol, with validation
claude Aug 27, 2026
c9d72b0
fix: address app:// protocol review feedback
claude Sep 1, 2026
e57a3c5
feat: registerSchemes escape hatch for app-owned scheme registration
claude Sep 1, 2026
852b054
fix: address second review pass on appProtocol
claude Sep 1, 2026
0e5ebcc
fix: address third-pass review on app:// serving code
claude Sep 3, 2026
c33995c
Merge remote-tracking branch 'origin/next' into claude/electron-app-p…
claude Sep 3, 2026
4b4a954
Merge branch 'next' into claude/electron-app-protocol-templates-45kg8m
claude Sep 3, 2026
1d0c72a
fix: correct supportFetchAPI privilege casing; reject case-colliding …
claude Sep 3, 2026
abeac7b
test: skip npm audit in the AssetRelocatorPatch fixture install
claude Sep 4, 2026
406335f
fix(plugin-webpack): name jsonStats files from each compilation's ent…
claude Sep 4, 2026
001d8df
fix: disambiguate preload jsonStats names; warn on undefined VITE_ENTRY
claude Sep 4, 2026
0b0b749
fix(plugin-vite): assign packaged VITE_ENTRY from a format-aware banner
claude Sep 4, 2026
2b908d6
fix(plugin-vite): support ESM main bundles in the appProtocol runtime
claude Sep 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/plugin/vite/forge-vite-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@ declare global {
// whether you're running in development or production).
const MAIN_WINDOW_VITE_DEV_SERVER_URL: string;
const MAIN_WINDOW_VITE_NAME: string;
const MAIN_WINDOW_VITE_ENTRY: string;

interface VitePluginRuntimeKeys {
VITE_DEV_SERVER_URL: `${string}_VITE_DEV_SERVER_URL`;
VITE_NAME: `${string}_VITE_NAME`;
VITE_ENTRY: `${string}_VITE_ENTRY`;
}
}

Expand Down
13 changes: 13 additions & 0 deletions packages/plugin/vite/spec/VitePlugin.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,19 @@ describe('VitePlugin', async () => {
const tmpdir = path.join(tmp, 'electron-forge-test-');
const viteTestDir = await fs.promises.mkdtemp(tmpdir);

describe('serializableConfig', () => {
it('carries appProtocol through to the build workers', () => {
// Regression test: the packaged app's `*_VITE_ENTRY` define and the
// injected app:// runtime both depend on the workers seeing this flag;
// dropping it here silently builds apps that call loadURL(undefined).
const plugin = new VitePlugin({ ...baseConfig, appProtocol: true });
expect(
(plugin as unknown as { serializableConfig: VitePluginConfig })
.serializableConfig.appProtocol,
).toBe(true);
});
});

describe('packageAfterCopy', () => {
const packageJSONPath = path.join(viteTestDir, 'package.json');
const packagedPath = path.join(viteTestDir, 'packaged');
Expand Down
124 changes: 124 additions & 0 deletions packages/plugin/vite/spec/config/vite.base.config.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,11 @@ import {
getDefineKeys,
pluginExposeRenderer,
pluginHotRestart,
pluginViteEntryFallback,
} from '../../src/config/vite.base.config';

import type { Rollup } from 'vite';

import type { VitePluginConfig } from '../../src/Config';

const configRoot = path.join(import.meta.dirname, 'fixtures/vite-configs');
Expand Down Expand Up @@ -48,10 +51,12 @@ describe('vite.base.config', () => {
main_window: {
VITE_DEV_SERVER_URL: 'MAIN_WINDOW_VITE_DEV_SERVER_URL',
VITE_NAME: 'MAIN_WINDOW_VITE_NAME',
VITE_ENTRY: 'MAIN_WINDOW_VITE_ENTRY',
},
second_window: {
VITE_DEV_SERVER_URL: 'SECOND_WINDOW_VITE_DEV_SERVER_URL',
VITE_NAME: 'SECOND_WINDOW_VITE_NAME',
VITE_ENTRY: 'SECOND_WINDOW_VITE_ENTRY',
},
};

Expand All @@ -69,13 +74,59 @@ describe('vite.base.config', () => {
const define2 = {
MAIN_WINDOW_VITE_DEV_SERVER_URL: undefined,
MAIN_WINDOW_VITE_NAME: '"main_window"',
// Without `appProtocol`, the entry constant still resolves to a valid
// (file://) URL in builds so `loadURL(MAIN_WINDOW_VITE_ENTRY)` app code
// does not break only when packaged. The define must stay a bare member
// expression (the one non-JSON shape esbuild accepts on Vite < 8);
// pluginViteEntryFallback's banner assigns the URL to the global.
MAIN_WINDOW_VITE_ENTRY:
'globalThis.__electronForge_MAIN_WINDOW_VITE_ENTRY',
SECOND_WINDOW_VITE_DEV_SERVER_URL: undefined,
SECOND_WINDOW_VITE_NAME: '"second_window"',
SECOND_WINDOW_VITE_ENTRY:
'globalThis.__electronForge_SECOND_WINDOW_VITE_ENTRY',
};

expect(define1).toEqual(define2);
});

it('getBuildDefine:build with appProtocol resolves entries to app:// URLs', () => {
const define1 = getBuildDefine({
command: 'build',
mode: 'production',
root: configRoot,
forgeConfig: { ...forgeConfig, appProtocol: true },
forgeConfigSelf: forgeConfig.build[0],
});
const define2 = {
MAIN_WINDOW_VITE_DEV_SERVER_URL: undefined,
MAIN_WINDOW_VITE_NAME: '"main_window"',
MAIN_WINDOW_VITE_ENTRY: '"app://main_window/index.html"',
SECOND_WINDOW_VITE_DEV_SERVER_URL: undefined,
SECOND_WINDOW_VITE_NAME: '"second_window"',
SECOND_WINDOW_VITE_ENTRY: '"app://second_window/index.html"',
};

expect(define1).toEqual(define2);
});

it('getBuildDefine:build with a custom appProtocol scheme', () => {
const define = getBuildDefine({
command: 'build',
mode: 'production',
root: configRoot,
forgeConfig: { ...forgeConfig, appProtocol: { scheme: 'myapp' } },
forgeConfigSelf: forgeConfig.build[0],
});

expect(define.MAIN_WINDOW_VITE_ENTRY).toEqual(
'"myapp://main_window/index.html"',
);
expect(define.SECOND_WINDOW_VITE_ENTRY).toEqual(
'"myapp://second_window/index.html"',
);
});

it('getBuildDefine:serve', async () => {
const servers = await Promise.all(
forgeConfig.renderer.map(({ name }) =>
Expand All @@ -102,8 +153,10 @@ describe('vite.base.config', () => {
const define2 = {
MAIN_WINDOW_VITE_DEV_SERVER_URL: '"http://localhost:5173"',
MAIN_WINDOW_VITE_NAME: '"main_window"',
MAIN_WINDOW_VITE_ENTRY: '"http://localhost:5173"',
SECOND_WINDOW_VITE_DEV_SERVER_URL: '"http://localhost:5174"',
SECOND_WINDOW_VITE_NAME: '"second_window"',
SECOND_WINDOW_VITE_ENTRY: '"http://localhost:5174"',
};

for (const server of servers) {
Expand Down Expand Up @@ -142,9 +195,11 @@ describe('vite.base.config', () => {
// Custom string hosts are exposed as-is.
MAIN_WINDOW_VITE_DEV_SERVER_URL: '"http://127.0.0.1:5183"',
MAIN_WINDOW_VITE_NAME: '"main_window"',
MAIN_WINDOW_VITE_ENTRY: '"http://127.0.0.1:5183"',
// Wildcard hosts fall back to localhost.
SECOND_WINDOW_VITE_DEV_SERVER_URL: '"http://localhost:5184"',
SECOND_WINDOW_VITE_NAME: '"second_window"',
SECOND_WINDOW_VITE_ENTRY: '"http://localhost:5184"',
};

for (const server of servers) {
Expand All @@ -154,6 +209,75 @@ describe('vite.base.config', () => {
expect(define1).toEqual(define2);
});

describe('pluginViteEntryFallback', () => {
const applyOutputOptions = (
output: Rollup.OutputOptions,
names = ['main_window'],
) => {
const plugin = pluginViteEntryFallback(names);
const hook = plugin.outputOptions as (
output: Rollup.OutputOptions,
) => Rollup.OutputOptions | null;
return hook.call(undefined, output);
};

const bannerOf = (result: Rollup.OutputOptions | null) => {
expect(result).not.toBeNull();
expect(typeof result!.banner).toEqual('string');
return result!.banner as string;
};

it('assigns the entry global with require()/__dirname in CJS bundles', () => {
const banner = bannerOf(applyOutputOptions({ format: 'cjs' }));
expect(() => new Function(banner)).not.toThrow();
// The banner displaces Rollup's own 'use strict' prologue directive, so
// it must re-assert it.
expect(banner).toMatch(/^'use strict';\n/);
expect(banner).toContain(
`globalThis.__electronForge_MAIN_WINDOW_VITE_ENTRY = require('node:url').pathToFileURL(require('node:path').join(__dirname, "../renderer/main_window/index.html")).href;`,
);
});

it('assigns the entry global with import.meta.url in ESM bundles', () => {
// A user's own `build.lib.formats: ['es']` skips the plugin's CJS
// default — the CJS expression would throw ReferenceError there.
const banner = bannerOf(applyOutputOptions({ format: 'es' }));
expect(banner).toContain(
`globalThis.__electronForge_MAIN_WINDOW_VITE_ENTRY = new URL("../renderer/main_window/index.html", import.meta.url).href;`,
);
expect(banner).not.toContain('require(');
expect(banner).not.toContain(`'use strict'`);
});

it('treats a missing format as ESM, matching Rollup', () => {
expect(bannerOf(applyOutputOptions({}))).toContain('import.meta.url');
});

it('covers every renderer in one banner', () => {
const banner = bannerOf(
applyOutputOptions({ format: 'cjs' }, ['main_window', 'second-window']),
);
expect(banner).toContain('__electronForge_MAIN_WINDOW_VITE_ENTRY');
// Kebab-case names map to the same key the define uses.
expect(banner).toContain('__electronForge_SECOND_WINDOW_VITE_ENTRY');
expect(banner).toContain('"../renderer/second-window/index.html"');
});

it('leaves formats no Electron main process uses untouched', () => {
expect(applyOutputOptions({ format: 'umd' })).toBeNull();
});

it('composes with an existing banner instead of replacing it', () => {
const result = applyOutputOptions({
format: 'cjs',
banner: '/* user banner */',
});
const banner = bannerOf(result);
expect(banner).toContain('__electronForge_MAIN_WINDOW_VITE_ENTRY');
expect(banner).toMatch(/\/\* user banner \*\/$/);
});
});

describe('pluginHotRestart', () => {
let dispose: (() => void) | undefined;

Expand Down
Loading