Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
20 changes: 10 additions & 10 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -97,27 +97,27 @@ dep_web:
# Uses publicPath patching instead of a full second React build (~4-8 min savings).
#
# Idempotent: skips when `build/electron-app/app/index.html` already carries
# the patched `es6://static/js/main` marker. This mirrors the original
# the patched `es6://assets/` marker. This mirrors the original
# Makefile's skip semantics so downstream targets that re-declare `dep` as a
# prerequisite (e.g. `mac_x64`, `win_x64`) do not repeatedly re-copy the web
# bundle. Set `FORCE_DEP_ELECTRON=1` to force a re-sync.
dep_electron: dep_web
@if [ -f "./build/electron-app/app/index.html" ] && grep -q 'es6://static/js/main' ./build/electron-app/app/index.html && [ "$(FORCE_DEP_ELECTRON)" != "1" ]; then \
@if [ -f "./build/electron-app/app/index.html" ] && grep -q 'es6://assets/' ./build/electron-app/app/index.html && [ "$(FORCE_DEP_ELECTRON)" != "1" ]; then \
printf "$(YELLOW)Electron app already prepared, skipping$(NC)\n"; \
else \
if [ ! -d "./build/electron-app" ]; then \
mkdir -p build/electron-app; \
cp -r electron-app/* build/electron-app/; \
cp electron-app/.npmrc build/electron-app/; \
pnpm i --prefix ./build/electron-app --ignore-workspace; \
fi; \
rm -rf build/electron-app/app build/electron-app/resources build/electron-app/manifest; \
cp -Rp build/web build/electron-app/app; \
cp -Rp build/web/resources build/electron-app; \
cp -Rp build/web/manifest build/electron-app; \
node scripts/patch-electron-publicpath.js build/electron-app/app; \
mkdir -p ./build/electron-app/app/wsproxy; \
cp ./src/wsproxy/dist/wsproxy.js ./build/electron-app/app/wsproxy/wsproxy.js; \
fi && \
rm -rf build/electron-app/app build/electron-app/resources build/electron-app/manifest && \
cp -Rp build/web build/electron-app/app && \
cp -Rp build/web/resources build/electron-app && \
cp -Rp build/web/manifest build/electron-app && \
node scripts/patch-electron-publicpath.js build/electron-app/app && \
mkdir -p ./build/electron-app/app/wsproxy && \
cp ./src/wsproxy/dist/wsproxy.js ./build/electron-app/app/wsproxy/wsproxy.js && \
cp ./preload.js ./build/electron-app/preload.js; \
fi
dep: dep_electron
Expand Down
3 changes: 0 additions & 3 deletions react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@
"@microsoft/fetch-event-source": "^2.0.1",
"@monaco-editor/react": "^4.7.0",
"@react-hook/resize-observer": "^2.0.2",
"@svgr/webpack": "^8.1.0",
"@tanstack/react-query": "catalog:",
"@testing-library/react": "catalog:",
"@uiw/codemirror-extensions-langs": "^4.25.9",
Expand Down Expand Up @@ -152,9 +151,7 @@
"jsdom": "^29.0.2",
"nodemon": "^3.1.14",
"prop-types": "^15.8.1",
"react-dev-utils": "^12.0.1",
"react-grab": "^0.1.32",
"react-scripts": "5.0.1",
"react-test-renderer": "^19.2.5",
"relay-compiler": "catalog:",
"relay-test-utils": "catalog:",
Expand Down
28 changes: 4 additions & 24 deletions react/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,7 +278,7 @@ function monacoStaticPlugin(): Plugin {
};
}

export default defineConfig(({ command, mode }) => {
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, projectRoot, '');
Object.assign(process.env, env);

Expand All @@ -297,31 +297,11 @@ export default defineConfig(({ command, mode }) => {
.filter(Boolean)
: undefined;

// Electron target uses a custom `es6://` URL scheme; the main process
// registers a protocol handler via `protocol.handle('es6', ...)` in
// `electron-app/main.js` that resolves `es6://foo` to
// `<electron-app>/app/foo` on disk. This matches the craco-era
// `webpackConfig.output.publicPath = 'es6://'` override in
// `react/craco.config.cjs`.
//
// We cannot set `base: 'es6://'` directly: Vite's external-URL regex is
// `/^([a-z]+:)?\/\//`, and the `6` in `es6` breaks the `[a-z]+` group,
// so Vite treats `es6://` as a malformed absolute path and strips the
// scheme. The official escape hatch is `experimental.renderBuiltUrl`,
// which is called per built asset and whose return value replaces the
// default `base + filename` concatenation — no scheme validation.
const isElectronBuild =
command === 'build' && process.env.BUILD_TARGET === 'electron';

// The Electron target's `es6://` publicPath is applied by
// `scripts/patch-electron-publicpath.js` after the single web build
// (per FR-2612 policy: one build, post-patch — never a second `vite build`).
return {
base: '/',
experimental: isElectronBuild
? {
renderBuiltUrl(filename) {
return `es6://${filename}`;
},
}
: undefined,
// Keep root at `react/` so pnpm's react/react-dom installed in
// react/node_modules resolve correctly. Static assets from projectRoot
// are handled by projectRootStaticPlugin above.
Expand Down
112 changes: 44 additions & 68 deletions scripts/patch-electron-publicpath.js
Original file line number Diff line number Diff line change
@@ -1,21 +1,26 @@
#!/usr/bin/env node

/**
* Patch the web build output to use Electron's 'es6://' publicPath.
* Patch the Vite web build output to use Electron's 'es6://' publicPath.
*
* When building for Electron, webpack's output.publicPath must be 'es6://'
* instead of the default '/'. Previously this required a full second React
* build (~4-8 min). This script achieves the same result by patching the
* already-built files, saving significant CI time.
* When building for Electron, asset URLs must use the custom 'es6://' scheme
* (handled by electron-app/main.js) instead of the default '/'. Doing this
* with a second `vite build` (BUILD_TARGET=electron + experimental.renderBuiltUrl)
* doubles release CI time, which FR-2612 explicitly eliminated. This script
* keeps the single-build model by patching the already-built files.
*
* Usage: node scripts/patch-electron-publicpath.js <build-dir>
*
* The script patches:
* 1. index.html — static asset references (/static/... → es6://static/...)
* 2. asset-manifest.json — all asset paths
* 3. JS bundles — webpack runtime's publicPath assignment (e.g. n.p="/")
* 4. CSS files — url() references to /static/ assets
* 5. Service worker (sw.js) — precache manifest URLs
* Vite's output layout (post-FR-2606 migration):
* - index.html with `<base href="/">` and `<script src="/assets/...">`
* - assets/*.js, assets/*.css, assets/<fonts/images>
* - sw.js + workbox-*.js (precache uses relative URLs, no patch needed)
* - JS chunks reference siblings via ESM imports already resolved by the HTML
* entry, so chunk file contents themselves carry no '/assets/' literals.
*
* Patched targets:
* 1. index.html — script/link tags pointing to /assets/
* 2. CSS files under assets/ — url(/assets/...) references (fonts, images)
*/

const fs = require('fs');
Expand All @@ -33,14 +38,10 @@ if (!fs.existsSync(buildDir)) {
process.exit(1);
}

const WEB_PUBLIC_PATH = '/';
const ELECTRON_PUBLIC_PATH = 'es6://';

let patchedCount = 0;

/**
* Replace all occurrences of web publicPath with electron publicPath in a file.
*/
function patchFile(filePath, replacements) {
if (!fs.existsSync(filePath)) return false;

Expand All @@ -60,76 +61,51 @@ function patchFile(filePath, replacements) {
return false;
}

console.log(`Patching publicPath: "${WEB_PUBLIC_PATH}" → "${ELECTRON_PUBLIC_PATH}"`);
console.log(
`Patching asset URLs: "/assets/" and "url(/assets/...)" → "${ELECTRON_PUBLIC_PATH}assets/"`,
);
console.log(`Build directory: ${buildDir}\n`);

// 1. Patch index.html
// 1. Patch index.html — only root-absolute refs
//
// Note: <base href="/"> is intentionally NOT patched. The webpack-era patch
// script left it untouched too. The renderer loads index.html via file://,
// and rewriting <base> to es6:// causes window.location/origin mismatches
// (history API, same-origin checks). Relative refs like "manifest/foo" and
// "resources/bar.css" resolve against the file:// document URL and read
// from the app dir, which is what Electron actually serves.
patchFile(path.join(buildDir, 'index.html'), [
// Script/link tags: src="/static/... → src="es6://static/...
['="/static/', `="${ELECTRON_PUBLIC_PATH}static/`],
// Href references
['href="/static/', `href="${ELECTRON_PUBLIC_PATH}static/`],
// Manifest and other root-relative references
['="/manifest', `="${ELECTRON_PUBLIC_PATH}manifest`],
// src="/assets/... or href="/assets/...
['="/assets/', `="${ELECTRON_PUBLIC_PATH}assets/`],
]);

// 2. Patch asset-manifest.json
patchFile(path.join(buildDir, 'asset-manifest.json'), [
[`"/static/`, `"${ELECTRON_PUBLIC_PATH}static/`],
]);

// 3. Patch JS bundles (webpack runtime publicPath + chunk references)
const jsDir = path.join(buildDir, 'static', 'js');
if (fs.existsSync(jsDir)) {
const jsFiles = fs.readdirSync(jsDir).filter((f) => f.endsWith('.js'));
for (const file of jsFiles) {
patchFile(path.join(jsDir, file), [
// Webpack runtime: various minified forms of __webpack_require__.p = "/"
// Common patterns from webpack 5 + terser:
['.p="/"', `.p="${ELECTRON_PUBLIC_PATH}"`],
[".p='/'", `.p='${ELECTRON_PUBLIC_PATH}'`],
// Static references to /static/ in chunk loading code
['"/static/', `"${ELECTRON_PUBLIC_PATH}static/`],
]);
}
}

// 4. Patch CSS files (url() references)
const cssDir = path.join(buildDir, 'static', 'css');
if (fs.existsSync(cssDir)) {
const cssFiles = fs.readdirSync(cssDir).filter((f) => f.endsWith('.css'));
// 2. Patch CSS files under assets/ — url(/assets/...) references for fonts/images
const assetsDir = path.join(buildDir, 'assets');
if (fs.existsSync(assetsDir)) {
const cssFiles = fs.readdirSync(assetsDir).filter((f) => f.endsWith('.css'));
for (const file of cssFiles) {
patchFile(path.join(cssDir, file), [
['url(/static/', `url(${ELECTRON_PUBLIC_PATH}static/`],
patchFile(path.join(assetsDir, file), [
['url(/assets/', `url(${ELECTRON_PUBLIC_PATH}assets/`],
// Quoted forms (rare but allowed by CSS spec)
['url("/assets/', `url("${ELECTRON_PUBLIC_PATH}assets/`],
["url('/assets/", `url('${ELECTRON_PUBLIC_PATH}assets/`],
]);
}
}

// 5. Patch service worker if present
patchFile(path.join(buildDir, 'sw.js'), [
['"/static/', `"${ELECTRON_PUBLIC_PATH}static/`],
['url:"/static/', `url:"${ELECTRON_PUBLIC_PATH}static/`],
]);

// 6. Patch workbox precache manifest if present
const swFiles = fs.readdirSync(buildDir).filter((f) => /^workbox-.*\.js$/.test(f));
for (const file of swFiles) {
patchFile(path.join(buildDir, file), [
['"/static/', `"${ELECTRON_PUBLIC_PATH}static/`],
]);
}

console.log(`\nDone. Patched ${patchedCount} file(s).`);

// Verify the patch worked by checking index.html
const indexPath = path.join(buildDir, 'index.html');
if (fs.existsSync(indexPath)) {
const indexContent = fs.readFileSync(indexPath, 'utf-8');
if (indexContent.includes('es6://static/js/main')) {
console.log('✓ Verification passed: index.html contains es6://static/js/main');
if (indexContent.includes(`${ELECTRON_PUBLIC_PATH}assets/`)) {
console.log(`✓ Verification passed: index.html contains ${ELECTRON_PUBLIC_PATH}assets/`);
} else {
console.error('✗ Verification failed: index.html does not contain es6://static/js/main');
console.error(' The Electron build may not work correctly.');
console.error(
`✗ Verification failed: index.html does not contain ${ELECTRON_PUBLIC_PATH}assets/`,
);
console.error(' The Electron build will not work correctly.');
process.exit(1);
}
}
Loading