···1212 * can "fetch" it from memory.
1313 *
1414 * Package whl files (numpy, scipy, etc.) live in
1515- * src/renderer/utils/webworker/src/pyodide/ and are served by a tiny Node.js
1616- * HTTP server on port 17173 started in the Electron main process. This bypasses
1717- * Vite's dev server, which returns HTML (SPA fallback) for ALL fetch() requests
1818- * from web workers, including /@fs/ and publicDir paths.
1515+ * src/renderer/utils/webworker/src/pyodide/ and are served via a custom
1616+ * Electron protocol scheme (pyodide://) registered in the main process.
1717+ * This requires no network socket and works in both dev and production.
1918 *
2020- * MNE and its pure-Python deps are installed via micropip from local .whl
2121- * files served by the same pyodide-asset:// protocol under /packages/.
1919+ * MNE and its pure-Python deps: JS fetches each .whl via pyodide://, writes
2020+ * the bytes into Pyodide's emscripten FS (/tmp/), then micropip installs from
2121+ * emfs:///tmp/ — micropip only accepts http/https/emfs URLs, not custom schemes.
2222 */
23232424// ?url → Vite resolves to /@fs/... in dev; asset URL in prod.
···2626import pyodideMjsUrl from 'pyodide/pyodide.mjs?url';
2727import lockFileRaw from 'pyodide/pyodide-lock.json?raw';
28282929-// A tiny Node.js HTTP server on port 17173 (started in the Electron main
3030-// process) serves pyodide assets from src/renderer/utils/webworker/src/.
3131-// This bypasses Vite's dev server, which returns index.html (SPA fallback)
3232-// for ALL fetch() requests from web workers, including /@fs/ and publicDir paths.
3333-const PYODIDE_ASSET_BASE = 'http://127.0.0.1:17173';
2929+// Custom Electron protocol scheme registered in src/main/index.ts.
3030+// Serves files from src/renderer/utils/webworker/src/ (dev) or
3131+// resources/webworker/src/ (prod) without opening a network socket.
3232+const PYODIDE_ASSET_BASE = 'pyodide://host';
34333534const pyodideReadyPromise = (async () => {
3635 const { loadPyodide } = await import(/* @vite-ignore */ pyodideMjsUrl);
···6766 await pyodide.loadPackage('micropip', { checkIntegrity: false });
6867 const micropip = pyodide.pyimport('micropip');
69687070- // MNE + pure-Python deps are served from /packages/ via pyodide-asset://.
7171- const manifestUrl = `${PYODIDE_ASSET_BASE}/packages/manifest.json`;
7272- const manifest = await fetch(manifestUrl).then((r) => r.json());
7373- const whlUrls = Object.values(manifest).map(
7474- ({ filename }) => `${PYODIDE_ASSET_BASE}/packages/${filename}`
6969+ // MNE + pure-Python deps: micropip only accepts http://, https://, emfs://,
7070+ // and relative paths — it rejects the pyodide:// custom scheme.
7171+ // Workaround: JS-fetch each .whl via the protocol handler (which supports it),
7272+ // write the bytes into Pyodide's emscripten virtual FS, then install via emfs://.
7373+ const manifest = await fetch(`${PYODIDE_ASSET_BASE}/packages/manifest.json`)
7474+ .then((r) => r.json());
7575+7676+ for (const { filename } of Object.values(manifest)) {
7777+ const buffer = await fetch(`${PYODIDE_ASSET_BASE}/packages/${filename}`)
7878+ .then((r) => r.arrayBuffer());
7979+ pyodide.FS.writeFile(`/tmp/${filename}`, new Uint8Array(buffer));
8080+ }
8181+8282+ await micropip.install(
8383+ Object.values(manifest).map(({ filename }) => `emfs:///tmp/${filename}`)
7584 );
7676- await micropip.install(whlUrls);
77857886 return pyodide;
7987})();
-40
vite.config.ts
···11import { defineConfig } from 'electron-vite';
22import react from '@vitejs/plugin-react';
33import path from 'path';
44-import fs from 'node:fs';
54import { createRequire } from 'module';
65const _require = createRequire(import.meta.url);
76···4948 // /pyodide/pyodide.mjs, /pyodide/pyodide.asm.js, /packages/*.whl, etc.
5049 publicDir: path.resolve(__dirname, 'src/renderer/utils/webworker/src'),
5150 plugins: [
5252- // Serve pyodide runtime and package .whl files directly from the filesystem
5353- // before Vite's SPA fallback can intercept them. publicDir alone is not
5454- // reliable — Vite's historyApiFallback returns index.html for fetch()
5555- // requests to these paths in dev mode.
5656- {
5757- name: 'serve-pyodide-assets',
5858- configureServer(server) {
5959- const staticDir = path.resolve(
6060- __dirname,
6161- 'src/renderer/utils/webworker/src'
6262- );
6363- const contentTypes: Record<string, string> = {
6464- '.json': 'application/json',
6565- '.whl': 'application/zip',
6666- '.zip': 'application/zip',
6767- '.wasm': 'application/wasm',
6868- '.js': 'application/javascript',
6969- '.mjs': 'application/javascript',
7070- };
7171- server.middlewares.use((req, res, next) => {
7272- const url = req.url ?? '';
7373- if (url.startsWith('/pyodide/') || url.startsWith('/packages/')) {
7474- console.log('[serve-pyodide-assets] intercepted:', url);
7575- const filePath = path.join(staticDir, url.split('?')[0]);
7676- if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
7777- const ext = path.extname(filePath).toLowerCase();
7878- res.setHeader(
7979- 'Content-Type',
8080- contentTypes[ext] ?? 'application/octet-stream'
8181- );
8282- res.setHeader('Cache-Control', 'no-cache');
8383- fs.createReadStream(filePath).pipe(res);
8484- return;
8585- }
8686- }
8787- next();
8888- });
8989- },
9090- },
9151 react({
9252 jsxRuntime: 'classic', // React 16 does not ship react/jsx-runtime
9353 babel: {