···77*.pid
88*.seed
991010-# Directory for instrumented libs generated by jscoverage/JSCover
1111-lib-cov
1212-1310# Coverage directory used by tools like istanbul
1411coverage
15121616-# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
1717-.grunt
1818-1919-# node-waf configuration
2020-.lock-wscript
2121-2222-# Compiled binary addons (http://nodejs.org/api/addons.html)
2323-build/Release
1313+# Compiled binary addons
2414.eslintcache
25152616# Dependency directory
···3525out
3626dist
37273838-# Old webpack build artifacts
3939-app/main.prod.js
4040-app/main.prod.js.map
4141-app/renderer.prod.js
4242-app/renderer.prod.js.map
4343-app/style.css
4444-app/style.css.map
4545-dll
4646-main.js
4747-main.js.map
4848-4928.idea
5050-npm-debug.log.*
5151-*.css.d.ts
5252-*.sass.d.ts
5353-*.scss.d.ts
5429keys.js
5555-5656-app/utils/pyodide/src
5757-src/renderer/utils/pyodide/src
3030+src/renderer/utils/webworker/src
+5-5
.llms/CLAUDE.md
···1515- **Bundler**: electron-vite / Vite
1616- **State**: Redux Toolkit + redux-observable (RxJS epics)
1717- **Language**: TypeScript (strict)
1818-- **Styling**: Semantic UI React + SCSS
1919-- **Testing**: Jest
1818+- **Styling**: Tailwind CSS + shadcn/ui (components in `src/renderer/components/ui/`)
1919+- **Testing**: Vitest
2020- **Linting**: ESLint + Prettier (single quotes, ES5 trailing commas)
21212222## Key Directories
···2424- `src/renderer/` — React renderer process
2525- `src/preload/` — Electron preload scripts
2626- `src/renderer/experiments/` — Lab.js experiment files
2727-- `src/renderer/utils/pyodide/` — Pyodide WASM Python runtime
2727+- `src/renderer/utils/webworker/` — Pyodide WASM Python runtime
28282929## Dev Workflow
3030```bash
3131npm run dev # Start dev server (patches deps first)
3232npm run build # Build all processes
3333-npm test # Run Jest tests
3333+npm test # Run Vitest tests
3434npm run typecheck # TypeScript check (no emit)
3535npm run lint # ESLint
3636npm run lint-fix # ESLint + Prettier auto-fix
···4545- Keep Electron main/renderer separation strict — use preload IPC bridges
46464747## Out of Scope
4848-- Do not modify `src/renderer/utils/pyodide/src/` directly; it is managed by `InstallPyodide.js`
4848+- Do not modify `src/renderer/utils/webworker/src/` directly; it is managed by `internals/scripts/InstallPyodide.mjs` (Pyodide runtime) and `internals/scripts/InstallMNE.mjs` (scientific packages)
4949- Do not alter `electron-builder` publish config without confirming release intent
50505151## LLM Context
+39
.llms/learnings.md
···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+5252+**Packages that must be listed explicitly** (not reachable from matplotlib/scipy/pandas deps in the lock file, but required at runtime):
5353+- `jinja2` + `markupsafe` — used by matplotlib templates and MNE HTML reports
5454+- `decorator` — MNE core dep
5555+- `requests` (+ `certifi`, `charset-normalizer`, `idna`, `urllib3`) — pulled in by `pooch` at MNE import time
5656+5757+**`micropip.install()` from JS accepts a JS array directly** — as of Pyodide 0.29.x, micropip handles the `JsProxy` conversion internally. `pyodide.toPy()` is not needed.
5858+5959+**WebAgg backend does not work in web workers** — WebAgg tries to access `js.document` to inject CSS/JS into the DOM on first import, which throws `ImportError: cannot import name 'document' from 'js'` in a worker context. Use `agg` instead. Set it via `os.environ["MPLBACKEND"] = "agg"` before any matplotlib import. `fig.savefig()` works with `agg` and is the correct way to get plot images back to the renderer.
6060+6161+**Plot result routing pattern** — `worker.postMessage()` is fire-and-forget (returns `undefined`). Plot epics should use `tap()` to fire the worker message and `mergeMap(() => EMPTY)` to emit nothing. Results come back asynchronously on the worker `message` event. Add a `plotKey` field to each worker message; the worker echoes it back; `pyodideMessageEpic` switches on `plotKey` to dispatch `SetTopoPlot`/`SetPSDPlot`/`SetERPPlot` with a `{ 'image/png': base64string }` MIME bundle. `PyodidePlotWidget` renders this via `@nteract/transforms`.
6262+2463## Pre-existing TypeScript errors (do not treat as regressions)
25642665- `src/renderer/epics/experimentEpics.ts` (lines 170, 205) — RxJS operator type mismatch
+311
docs/pyodide-in-electron-vite.md
···11+# Pyodide in Electron + Vite: What We Learned
22+33+This document captures everything we learned getting Pyodide (Python-in-WASM) running reliably inside an Electron + electron-vite app. It is intended as a reference for anyone maintaining or upgrading the Pyodide integration.
44+55+---
66+77+## The Core Problem: Vite's SPA Fallback
88+99+Vite's dev server runs a `historyApiFallback` middleware that returns `index.html` for **every** `fetch()` request it doesn't recognise — including `/@fs/` paths, `publicDir` paths, and anything from a web worker. This completely breaks Pyodide's package loader, which `fetch()`es `.whl` files at runtime.
1010+1111+This is not an obvious failure — Pyodide may partially initialise and then hang or throw cryptic errors when it tries to load packages.
1212+1313+### Solution: Serve Pyodide Assets Out-of-Band
1414+1515+We use two complementary mechanisms:
1616+1717+**1. Custom Vite middleware (dev only)**
1818+1919+In `vite.config.ts`, a plugin intercepts requests to `/pyodide/` and `/packages/` before the SPA fallback runs and streams the files directly from `src/renderer/utils/webworker/src/`:
2020+2121+```ts
2222+server.middlewares.use((req, res, next) => {
2323+ const url = req.url ?? '';
2424+ if (url.startsWith('/pyodide/') || url.startsWith('/packages/')) {
2525+ const filePath = path.join(staticDir, url.split('?')[0]);
2626+ if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
2727+ res.setHeader('Content-Type', contentTypes[ext] ?? 'application/octet-stream');
2828+ fs.createReadStream(filePath).pipe(res);
2929+ return;
3030+ }
3131+ }
3232+ next();
3333+});
3434+```
3535+3636+**2. Electron local HTTP server on port 17173 (dev + prod)**
3737+3838+Web workers cannot use Vite's dev server at all — `fetch()` from a worker always hits the SPA fallback. The main process (`src/main/index.ts`) starts a plain Node.js `http` server at `http://127.0.0.1:17173` that serves `src/renderer/utils/webworker/src/` (dev) or `resources/webworker/src/` (prod).
3939+4040+The web worker (`webworker.js`) uses this as its `PYODIDE_ASSET_BASE`:
4141+4242+```js
4343+const PYODIDE_ASSET_BASE = 'http://127.0.0.1:17173';
4444+```
4545+4646+Port 17173 is hardcoded in three places that must stay in sync:
4747+- `src/main/index.ts` — server listen port
4848+- `src/renderer/utils/webworker/webworker.js` — `PYODIDE_ASSET_BASE`
4949+- `src/renderer/index.html` — CSP `connect-src` directive
5050+5151+---
5252+5353+## Loading pyodide.mjs
5454+5555+Pyodide 0.26+ ships as an ES module (`pyodide.mjs`). You cannot `fetch()` it — Vite would intercept it. Instead, import it with Vite's `?url` suffix and use dynamic `import()`:
5656+5757+```js
5858+import pyodideMjsUrl from 'pyodide/pyodide.mjs?url';
5959+// ...
6060+const { loadPyodide } = await import(/* @vite-ignore */ pyodideMjsUrl);
6161+```
6262+6363+`import()` bypasses Vite's SPA fallback; `fetch()` does not.
6464+6565+Also required in `vite.config.ts`:
6666+6767+```ts
6868+optimizeDeps: {
6969+ exclude: ['pyodide'], // prevent Vite from pre-bundling it
7070+},
7171+worker: {
7272+ format: 'es', // ES module workers required for pyodide.mjs
7373+},
7474+```
7575+7676+And workers must be created with `type: 'module'`:
7777+7878+```ts
7979+new Worker(new URL('./webworker.js', import.meta.url), { type: 'module' });
8080+```
8181+8282+---
8383+8484+## Loading the Lock File Without a Fetch
8585+8686+`loadPyodide` needs the lock file to resolve package names to filenames. Fetching it would hit Vite's SPA fallback. Instead, embed it at build time with Vite's `?raw` suffix and wrap it in a blob URL:
8787+8888+```js
8989+import lockFileRaw from 'pyodide/pyodide-lock.json?raw';
9090+9191+const lockBlob = new Blob([lockFileRaw], { type: 'application/json' });
9292+const lockFileURL = URL.createObjectURL(lockBlob);
9393+const pyodide = await loadPyodide({ lockFileURL, packageBaseUrl });
9494+URL.revokeObjectURL(lockFileURL);
9595+```
9696+9797+---
9898+9999+## packageBaseUrl vs indexURL
100100+101101+These are easy to confuse:
102102+103103+| Option | Purpose |
104104+|--------|---------|
105105+| `indexURL` | Where Pyodide looks for its **runtime** files (WASM, stdlib). Already resolved from `node_modules` via `import.meta.url`. Do not override. |
106106+| `packageBaseUrl` | Where `loadPackage()` fetches **package `.whl` files**. Set this to `http://127.0.0.1:17173/pyodide/`. |
107107+108108+---
109109+110110+## Package Integrity Checks
111111+112112+```js
113113+await pyodide.loadPackage(['numpy', 'scipy', ...], { checkIntegrity: false });
114114+```
115115+116116+`checkIntegrity: false` is required. The SHA-256 hashes in the npm package's `pyodide-lock.json` are computed against the CDN files, but we serve locally-downloaded copies that may differ (e.g. re-compressed). Integrity checks will fail without this flag.
117117+118118+---
119119+120120+## micropip.install() from JavaScript
121121+122122+`micropip` is a Python object loaded via `pyodide.pyimport()`. Passing a JavaScript array directly works fine in Pyodide 0.29.x — micropip handles the `JsProxy` conversion internally:
123123+124124+```js
125125+const micropip = pyodide.pyimport('micropip');
126126+await micropip.install(whlUrls); // JS array works directly
127127+```
128128+129129+> Note: older guidance suggested wrapping with `pyodide.toPy(whlUrls)` — this is not necessary as of 0.29.x.
130130+131131+---
132132+133133+## Offline Package Installation (InstallMNE.mjs)
134134+135135+`internals/scripts/InstallMNE.mjs` runs on `postinstall` and pre-downloads all packages so the app works offline.
136136+137137+### Part 1 — Pyodide Binary Packages (from Pyodide CDN)
138138+139139+These are compiled packages bundled with Pyodide. The script reads `pyodide-lock.json`, recursively resolves transitive dependencies of the root packages, and downloads each `.whl` into `src/renderer/utils/webworker/src/pyodide/`.
140140+141141+**Derive the CDN version from `node_modules/pyodide/package.json`**, not from `pyodide-lock.json`'s `info.version` field — that field may be a dev label like `0.28.0.dev0` and will produce a broken CDN URL.
142142+143143+Current root packages and why each is listed explicitly:
144144+145145+| Package | Reason |
146146+|---------|--------|
147147+| `numpy`, `scipy`, `matplotlib`, `pandas` | Core scientific stack |
148148+| `micropip` | Needed to install pure-Python packages at worker startup |
149149+| `pillow` | Used by matplotlib and MNE; loaded at runtime by `loadPackage()` |
150150+| `jinja2` | MNE dep; **not** listed in matplotlib's lock-file `depends` array despite being a runtime requirement |
151151+| `decorator` | MNE core dep; not reachable from the scientific stack in the lock file |
152152+| `requests` | Pulled in by `pooch` at MNE import time; brings in `certifi`, `charset-normalizer`, `idna`, `urllib3` transitively |
153153+154154+> **Gotcha:** The `depends` arrays in `pyodide-lock.json` are incomplete. Several packages that matplotlib, scipy, or MNE require at runtime are not listed as dependencies and will not be downloaded unless added explicitly as roots.
155155+156156+### Part 2 — Pure-Python Packages (from PyPI)
157157+158158+Packages not bundled with Pyodide must be downloaded as `py3-none-any` wheels from PyPI. They are stored in `src/renderer/utils/webworker/src/packages/` and a `manifest.json` is written so the worker knows the exact filenames.
159159+160160+Current PyPI packages: `mne`, `pooch`, `tqdm`, `platformdirs`, `lazy-loader`
161161+162162+`lazy-loader` is a core MNE dependency that does not appear in the Pyodide lock at all.
163163+164164+---
165165+166166+## Plot Pipeline
167167+168168+### matplotlib Backend in Web Workers
169169+170170+Use `agg`, not `webagg`. Set it before any Python imports run:
171171+172172+```js
173173+await pyodide.runPythonAsync('import os; os.environ["MPLBACKEND"] = "agg"');
174174+```
175175+176176+WebAgg (`webagg`) fails in web workers because it tries to inject CSS via `js.document` during initialisation — and `js.document` does not exist in worker scope. The error looks like:
177177+178178+```
179179+ImportError: cannot import name 'document' from 'js'
180180+```
181181+182182+`agg` is a non-interactive raster backend that writes to a buffer, which is exactly what we need.
183183+184184+---
185185+186186+### plotKey Correlation Pattern (Fire-and-Forget Messaging)
187187+188188+`worker.postMessage()` returns `undefined` — there is no return channel. Redux-Observable plot load epics cannot receive the worker's result directly.
189189+190190+**Solution:** attach a `plotKey` string to every outgoing message; the worker echoes it back in the response object. `pyodideMessageEpic` routes by `plotKey` to the correct Redux action.
191191+192192+```js
193193+// webworker.js — echo plotKey back in every response
194194+const { data, plotKey, ...context } = event.data;
195195+self.postMessage({ results: await pyodide.runPythonAsync(data), plotKey });
196196+```
197197+198198+```ts
199199+// pyodideMessageEpic — route by plotKey
200200+switch (plotKey) {
201201+ case 'ready': return of(PyodideActions.SetWorkerReady());
202202+ case 'topo': return of(PyodideActions.SetTopoPlot(mimeBundle));
203203+ case 'psd': return of(PyodideActions.SetPSDPlot(mimeBundle));
204204+ case 'erp': return of(PyodideActions.SetERPPlot(mimeBundle));
205205+ default: return of(PyodideActions.ReceiveMessage(e.data));
206206+}
207207+```
208208+209209+Plot load epics become fire-and-forget — they call `worker.postMessage()` as a side effect and emit nothing:
210210+211211+```ts
212212+// loadTopoEpic
213213+action$.pipe(
214214+ filter(isActionOf(PyodideActions.LoadTopo)),
215215+ tap(() => plotTestPlot(state$.value.pyodide.worker!)),
216216+ mergeMap(() => EMPTY)
217217+);
218218+```
219219+220220+---
221221+222222+### Worker Readiness Gating
223223+224224+`loadUtils` posts `plotKey: 'ready'` when `utils.py` finishes loading. This drives an `isWorkerReady` flag in Redux state that gates any UI that depends on Python being initialised.
225225+226226+```ts
227227+export const loadUtils = async (worker: Worker) =>
228228+ worker.postMessage({ data: utilsPy, plotKey: 'ready' });
229229+```
230230+231231+`pyodideMessageEpic` dispatches `PyodideActions.SetWorkerReady()` on receiving `plotKey === 'ready'`.
232232+233233+---
234234+235235+### SVG Output from matplotlib
236236+237237+Produce SVG in Python — no base64 encoding needed:
238238+239239+```python
240240+import io
241241+import matplotlib.pyplot as plt
242242+243243+_fig, _ax = plt.subplots()
244244+_ax.plot([1, 2, 3, 4], [1, 4, 2, 3])
245245+_buf = io.BytesIO()
246246+_fig.savefig(_buf, format="svg", bbox_inches="tight")
247247+plt.close(_fig)
248248+_buf.getvalue().decode() # SVG string is the Python return value
249249+```
250250+251251+The SVG string flows through `pyodide.runPythonAsync()` → worker `postMessage` → Redux state as `{ 'image/svg+xml': string }`.
252252+253253+---
254254+255255+### Rendering SVG Safely in the Renderer
256256+257257+Use a data URI on an `<img>` tag — sandboxed, no script execution:
258258+259259+```tsx
260260+<img src={`data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`} />
261261+```
262262+263263+Prefer this over `dangerouslySetInnerHTML` — inline SVG executes `<script>` tags and has full DOM access.
264264+265265+---
266266+267267+### PNG Export from SVG
268268+269269+Simple canvas conversion using the SVG's natural pixel dimensions:
270270+271271+```ts
272272+function svgToPngArrayBuffer(svg: string): Promise<ArrayBuffer> {
273273+ return new Promise((resolve, reject) => {
274274+ const blob = new Blob([svg], { type: 'image/svg+xml' });
275275+ const url = URL.createObjectURL(blob);
276276+ const img = new Image();
277277+ img.onload = () => {
278278+ const canvas = document.createElement('canvas');
279279+ canvas.width = img.naturalWidth;
280280+ canvas.height = img.naturalHeight;
281281+ const ctx = canvas.getContext('2d')!;
282282+ ctx.drawImage(img, 0, 0);
283283+ URL.revokeObjectURL(url);
284284+ canvas.toBlob((pngBlob) => {
285285+ pngBlob!.arrayBuffer().then(resolve).catch(reject);
286286+ }, 'image/png');
287287+ };
288288+ img.onerror = () => { URL.revokeObjectURL(url); reject(new Error('SVG load failed')); };
289289+ img.src = url;
290290+ });
291291+}
292292+```
293293+294294+The resulting `ArrayBuffer` is passed through the IPC chain (`preload → main`) for file write. Electron's `contextBridge` serialises `ArrayBuffer` correctly without any additional conversion.
295295+296296+---
297297+298298+## Summary of File Locations
299299+300300+| What | Where |
301301+|------|-------|
302302+| Pyodide runtime + binary wheels | `src/renderer/utils/webworker/src/pyodide/` |
303303+| Pure-Python wheels + manifest | `src/renderer/utils/webworker/src/packages/` |
304304+| Web worker entry point | `src/renderer/utils/webworker/webworker.js` |
305305+| JS wrappers for Python calls | `src/renderer/utils/webworker/index.ts` |
306306+| Install script | `internals/scripts/InstallMNE.mjs` |
307307+| Electron asset server | `src/main/index.ts` → `startPyodideAssetServer()` |
308308+| Vite middleware | `vite.config.ts` → `serve-pyodide-assets` plugin |
309309+| Plot widget component | `src/renderer/components/PyodidePlotWidget.tsx` |
310310+| Plot epics (Redux-Observable) | `src/renderer/epics/pyodideEpics.ts` |
311311+| Pyodide Redux state | `src/renderer/reducers/pyodideReducer.ts` |
···11-/**
22- * This file has been copied from pyodide source and modified to allow
33- * pyodide to be used in a web worker within this
44- */
55-66-// pyodide is served as a static asset at /pyodide/ (via Vite publicDir).
77-// An absolute path is required so importScripts resolves correctly regardless
88-// of where the worker script itself is served from.
99-importScripts('/pyodide/pyodide.js');
1010-1111-async function loadPyodideAndPackages() {
1212- self.pyodide = await loadPyodide({ indexURL: '/pyodide/' });
1313- await self.pyodide.loadPackage(['matplotlib', 'mne', 'pandas']);
1414-}
1515-let pyodideReadyPromise = loadPyodideAndPackages();
1616-1717-self.onmessage = async (event) => {
1818- // make sure loading is done
1919- await pyodideReadyPromise;
2020- // Don't bother yet with this line, suppose our API is built in such a way:
2121- const { data, ...context } = event.data;
2222- // The worker copies the context in its own "memory" (an object mapping name to values)
2323- for (const key of Object.keys(context)) {
2424- self[key] = context[key];
2525- }
2626- // Now is the easy part, the one that is similar to working in the main thread:
2727- try {
2828- self.postMessage({
2929- results: await self.pyodide.runPythonAsync(data),
3030- });
3131- } catch (error) {
3232- self.postMessage({ error: error.message });
3333- }
3434-};
···11+/**
22+ * Pyodide Web Worker — local node_modules implementation.
33+ *
44+ * Loading strategy
55+ * ----------------
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.
1919+ *
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/.
2222+ */
2323+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).
2626+import 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';
3434+3535+const pyodideReadyPromise = (async () => {
3636+ const { loadPyodide } = await import(/* @vite-ignore */ pyodideMjsUrl);
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+ // Set matplotlib backend before any imports so it takes effect on first import.
6060+ // Must be 'agg' (non-interactive, buffer-based) — web workers have no DOM,
6161+ // so WebAgg fails with "cannot import name 'document' from 'js'".
6262+ await pyodide.runPythonAsync(
6363+ 'import os; os.environ["MPLBACKEND"] = "agg"'
6464+ );
6565+6666+ // Load micropip so we can install MNE and its pure-Python deps.
6767+ await pyodide.loadPackage('micropip', { checkIntegrity: false });
6868+ const micropip = pyodide.pyimport('micropip');
6969+7070+ // 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}`
7575+ );
7676+ await micropip.install(whlUrls);
7777+7878+ return pyodide;
7979+})();
8080+8181+self.onmessage = async (event) => {
8282+ // Propagate init failures back to the main thread rather than hanging silently.
8383+ let pyodide;
8484+ try {
8585+ pyodide = await pyodideReadyPromise;
8686+ } catch (error) {
8787+ self.postMessage({ error: `Pyodide init failed: ${error.message}` });
8888+ return;
8989+ }
9090+9191+ const { data, plotKey, ...context } = event.data;
9292+9393+ // Expose context values as globals so Python can access them via the js module.
9494+ for (const [key, value] of Object.entries(context)) {
9595+ self[key] = value;
9696+ }
9797+9898+ try {
9999+ self.postMessage({ results: await pyodide.runPythonAsync(data), plotKey });
100100+ } catch (error) {
101101+ self.postMessage({ error: error.message, plotKey });
102102+ }
103103+};
+50-5
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···4445 // ------------------------------------------------------------------
4546 renderer: {
4647 // Serve the pyodide runtime files as static assets so Vite does NOT
4747- // transform them. importScripts() in a classic worker cannot load
4848- // ES modules; Vite's HMR injection turns .js files into ESM, breaking
4949- // the worker. Files in publicDir are served verbatim at the root URL:
5050- // /pyodide/pyodide.js, /pyodide/pyodide.asm.js, etc.
5151- publicDir: path.resolve(__dirname, 'src/renderer/utils/pyodide/src'),
4848+ // transform them. Files in publicDir are served verbatim at the root URL:
4949+ // /pyodide/pyodide.mjs, /pyodide/pyodide.asm.js, /packages/*.whl, etc.
5050+ publicDir: path.resolve(__dirname, 'src/renderer/utils/webworker/src'),
5251 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+ },
5391 react({
5492 jsxRuntime: 'classic', // React 16 does not ship react/jsx-runtime
5593 babel: {
···77115 },
78116 optimizeDeps: {
79117 include: ['@neurosity/pipes'],
118118+ // Prevent Vite from pre-bundling pyodide. In dev mode it will be served
119119+ // raw from node_modules via /@fs/, which is what pyodide.mjs expects.
120120+ exclude: ['pyodide'],
121121+ },
122122+ worker: {
123123+ // ES module workers are required for the CDN import in webworker.js.
124124+ format: 'es',
80125 },
81126 build: {
82127 rollupOptions: {