···2121- **Background gradient** used on all main screens: `bg-gradient-to-b from-[#f9f9f9] to-[#f0f0ff]`
2222- **`@radix-ui/react-select`** is installed for the shadcn Select component
23232424+## Pyodide Asset Serving — Vite SPA Fallback Problem
2525+2626+Vite's `historyApiFallback` returns `index.html` for **all** `fetch()` requests from web workers, including `/@fs/` and `publicDir` paths. This breaks Pyodide's package loading entirely.
2727+2828+**Solution (two-part):**
2929+1. A custom Vite middleware in `vite.config.ts` intercepts `/pyodide/` and `/packages/` requests before the SPA fallback and serves them directly from `src/renderer/utils/webworker/src/`.
3030+2. An Electron `http` server on **port 17173** (started in `src/main/index.ts`) serves the same directory. Web workers use `http://127.0.0.1:17173` as `PYODIDE_ASSET_BASE`. This is the authoritative path — web worker `fetch()` calls bypass Vite entirely.
3131+3232+Port 17173 is hardcoded in both `src/main/index.ts` and `src/renderer/utils/webworker/webworker.js` and in the CSP (`src/renderer/index.html`).
3333+3434+**Other Pyodide loading gotchas:**
3535+- `pyodide.mjs` must be loaded via dynamic `import()` (not `fetch()`), using a `?url` Vite import — `import()` bypasses the SPA fallback, `fetch()` does not
3636+- The lock file is embedded via `?raw` and wrapped in a `Blob` + `createObjectURL` to avoid an HTTP fetch
3737+- Use `packageBaseUrl` (not `indexURL`) to tell Pyodide where to find `.whl` files; `indexURL` is for WASM/stdlib
3838+- `checkIntegrity: false` is required — SHA256 hashes in the npm lock file don't match CDN-downloaded wheels
3939+- Workers must be created with `type: 'module'` (Pyodide 0.26+ ships `pyodide.mjs` as ESM)
4040+- `optimizeDeps.exclude: ['pyodide']` in `vite.config.ts` prevents Vite from pre-bundling it
4141+4242+## Pyodide Offline Package Installation (InstallMNE.mjs)
4343+4444+`internals/scripts/InstallMNE.mjs` runs on `postinstall` and downloads two sets of packages:
4545+- **Pyodide binary packages** (numpy, scipy, matplotlib, pandas + transitive deps) from the Pyodide CDN → `src/renderer/utils/webworker/src/pyodide/`
4646+- **Pure-Python packages** (mne, pooch, tqdm, platformdirs) from PyPI → `src/renderer/utils/webworker/src/packages/`
4747+4848+A `manifest.json` is written to `packages/` so `webworker.js` knows the exact `.whl` filenames to pass to `micropip.install()`.
4949+5050+The CDN version is derived from `node_modules/pyodide/package.json` — **not** from `pyodide-lock.json`'s `info.version`, which may be a dev label like `0.28.0.dev0`.
5151+2452## Pre-existing TypeScript errors (do not treat as regressions)
25532654- `src/renderer/epics/experimentEpics.ts` (lines 170, 205) — RxJS operator type mismatch
+1-1
internals/scripts/InstallMNE.mjs
···3939// Root packages whose full transitive dependency tree we need from Pyodide CDN
4040// ---------------------------------------------------------------------------
41414242-const PYODIDE_ROOT_PACKAGES = ['numpy', 'scipy', 'matplotlib', 'pandas'];
4242+const PYODIDE_ROOT_PACKAGES = ['numpy', 'scipy', 'matplotlib', 'pandas', 'micropip'];
43434444// ---------------------------------------------------------------------------
4545// Pure-Python packages to download from PyPI (not bundled with Pyodide)
+59
src/main/index.ts
···88import { app, BrowserWindow, ipcMain, dialog, shell, session } from 'electron';
99import path from 'path';
1010import fs from 'fs';
1111+import http from 'http';
1112import os from 'os';
1213import Papa from 'papaparse';
1314import mkdirp from 'mkdirp';
···2223 'enable-experimental-web-platform-features',
2324 'true'
2425);
2626+2727+// Port for the local pyodide asset server (serves whl files to web workers,
2828+// bypassing Vite's dev server which returns HTML for all fetch() requests).
2929+const PYODIDE_ASSET_PORT = 17173;
3030+3131+const PYODIDE_CONTENT_TYPES: Record<string, string> = {
3232+ '.json': 'application/json',
3333+ '.whl': 'application/zip',
3434+ '.zip': 'application/zip',
3535+ '.wasm': 'application/wasm',
3636+ '.js': 'application/javascript',
3737+ '.mjs': 'application/javascript',
3838+};
3939+4040+function startPyodideAssetServer(rootDir: string): void {
4141+ const server = http.createServer((req, res) => {
4242+ res.setHeader('Access-Control-Allow-Origin', '*');
4343+ res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
4444+ if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; }
4545+4646+ const urlPath = (req.url || '').split('?')[0];
4747+ const filePath = path.join(rootDir, urlPath);
4848+ const ext = path.extname(filePath).toLowerCase();
4949+5050+ fs.stat(filePath, (statErr, stat) => {
5151+ if (statErr || !stat.isFile()) {
5252+ res.writeHead(404, { 'Content-Type': 'text/plain' });
5353+ res.end(`Not found: ${urlPath}`);
5454+ return;
5555+ }
5656+ res.setHeader('Content-Type', PYODIDE_CONTENT_TYPES[ext] || 'application/octet-stream');
5757+ res.setHeader('Content-Length', stat.size);
5858+ res.setHeader('Cache-Control', 'no-cache');
5959+ res.writeHead(200);
6060+ fs.createReadStream(filePath).pipe(res);
6161+ });
6262+ });
6363+6464+ server.listen(PYODIDE_ASSET_PORT, '127.0.0.1', () => {
6565+ console.log(`[main] Pyodide asset server: http://127.0.0.1:${PYODIDE_ASSET_PORT}`);
6666+ });
6767+6868+ server.on('error', (err: NodeJS.ErrnoException) => {
6969+ console.error('[main] Pyodide asset server error:', err.message);
7070+ });
7171+}
25722673export default class AppUpdater {
2774 constructor() {
···453500});
454501455502app.whenReady().then(async () => {
503503+ // Serve pyodide assets (whl files, runtime files) via a local HTTP server so
504504+ // web workers can fetch() them without hitting Vite's SPA fallback, which
505505+ // returns HTML for ALL fetch() requests regardless of path.
506506+ // Port 17173 is hardcoded and matched in webworker.js.
507507+ // In dev: files are in src/renderer/utils/webworker/src/
508508+ // In prod: files are in resources/webworker/src/ (via extraResources)
509509+ const pyodideRoot = is.dev
510510+ ? path.join(app.getAppPath(), 'src/renderer/utils/webworker/src')
511511+ : path.join(process.resourcesPath, 'webworker/src');
512512+513513+ startPyodideAssetServer(pyodideRoot);
514514+456515 // Enable F12 devtools shortcut and Ctrl+R reload in dev, disable in prod
457516 app.on('browser-window-created', (_, window) => {
458517 optimizer.watchWindowShortcuts(window);
···33 *
44 * Loading strategy
55 * ----------------
66- * Use Vite's `?url` suffix on 'pyodide/pyodide.mjs' to get the resolved file URL
77- * at build/dev time (/@fs/... in dev, an asset URL in prod), then dynamically
88- * import from that URL. This bypasses Vite's SPA fallback and lets pyodide.mjs
99- * resolve all sibling assets (pyodide.asm.wasm, pyodide-lock.json, etc.) via
1010- * import.meta.url — no CDN required.
66+ * pyodide.mjs is imported via Vite's `?url` suffix, which gives us an
77+ * /@fs/... URL in dev. We use dynamic import() from that URL — this works
88+ * because import() bypasses Vite's SPA fallback (only fetch() is affected).
99+ *
1010+ * The lock file is embedded via `?raw` to avoid an HTTP fetch that Vite
1111+ * intercepts. A blob URL is created from the embedded JSON so loadPyodide
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.
1119 *
1212- * Production builds use the files copied to publicDir by InstallPyodide.mjs.
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/.
1322 */
14231515-// ?url tells Vite to resolve the path and return a URL string rather than bundling
1616-// the module. In dev mode this is a /@fs/ URL (bypasses SPA fallback); in prod it
1717-// is an asset URL. We then dynamically import from that URL so pyodide.mjs can
1818-// resolve all its sibling assets (pyodide.asm.wasm, etc.) via import.meta.url.
2424+// ?url → Vite resolves to /@fs/... in dev; asset URL in prod.
2525+// ?raw → Vite embeds file content as a string (no HTTP fetch at runtime).
1926import pyodideMjsUrl from 'pyodide/pyodide.mjs?url';
2727+import lockFileRaw from 'pyodide/pyodide-lock.json?raw';
2828+2929+// 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';
20342135const pyodideReadyPromise = (async () => {
2236 const { loadPyodide } = await import(/* @vite-ignore */ pyodideMjsUrl);
2323- return loadPyodide();
3737+3838+ // Wrap the embedded lock file in a blob URL so loadPyodide can "fetch" it
3939+ // without making an HTTP request that Vite would intercept and transform.
4040+ const lockBlob = new Blob([lockFileRaw], { type: 'application/json' });
4141+ const lockFileURL = URL.createObjectURL(lockBlob);
4242+4343+ // packageBaseUrl tells pyodide's PackageManager where to fetch .whl files.
4444+ // This is the correct option — NOT indexURL, which is for the runtime files
4545+ // (WASM, stdlib) that are already loaded via import.meta.url from node_modules.
4646+ const packageBaseUrl = `${PYODIDE_ASSET_BASE}/pyodide/`;
4747+4848+ const pyodide = await loadPyodide({ lockFileURL, packageBaseUrl });
4949+ URL.revokeObjectURL(lockFileURL);
5050+5151+ // Load scientific packages from local whl files via the asset server.
5252+ // checkIntegrity: false skips SHA256 verification — hashes in the npm lock
5353+ // file may not match the CDN-downloaded whl files we're actually serving.
5454+ await pyodide.loadPackage(
5555+ ['numpy', 'scipy', 'matplotlib', 'pandas', 'pillow'],
5656+ { checkIntegrity: false }
5757+ );
5858+5959+ // Load micropip so we can install MNE and its pure-Python deps.
6060+ await pyodide.loadPackage('micropip', { checkIntegrity: false });
6161+ const micropip = pyodide.pyimport('micropip');
6262+6363+ // MNE + pure-Python deps are served from /packages/ via pyodide-asset://.
6464+ const manifestUrl = `${PYODIDE_ASSET_BASE}/packages/manifest.json`;
6565+ const manifest = await fetch(manifestUrl).then((r) => r.json());
6666+ const whlUrls = Object.values(manifest).map(
6767+ ({ filename }) => `${PYODIDE_ASSET_BASE}/packages/${filename}`
6868+ );
6969+ await micropip.install(whlUrls);
7070+7171+ return pyodide;
2472})();
25732674self.onmessage = async (event) => {
+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';
45import { createRequire } from 'module';
56const _require = createRequire(import.meta.url);
67···4849 // /pyodide/pyodide.mjs, /pyodide/pyodide.asm.js, /packages/*.whl, etc.
4950 publicDir: path.resolve(__dirname, 'src/renderer/utils/webworker/src'),
5051 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+ },
5191 react({
5292 jsxRuntime: 'classic', // React 16 does not ship react/jsx-runtime
5393 babel: {