···49495050The 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`.
51515252+**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+5259## Pre-existing TypeScript errors (do not treat as regressions)
53605461- `src/renderer/epics/experimentEpics.ts` (lines 170, 205) — RxJS operator type mismatch
+176
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+## Summary of File Locations
167167+168168+| What | Where |
169169+|------|-------|
170170+| Pyodide runtime + binary wheels | `src/renderer/utils/webworker/src/pyodide/` |
171171+| Pure-Python wheels + manifest | `src/renderer/utils/webworker/src/packages/` |
172172+| Web worker entry point | `src/renderer/utils/webworker/webworker.js` |
173173+| JS wrappers for Python calls | `src/renderer/utils/webworker/index.ts` |
174174+| Install script | `internals/scripts/InstallMNE.mjs` |
175175+| Electron asset server | `src/main/index.ts` → `startPyodideAssetServer()` |
176176+| Vite middleware | `vite.config.ts` → `serve-pyodide-assets` plugin |
+2-2
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', 'micropip'];
4242+const PYODIDE_ROOT_PACKAGES = ['numpy', 'scipy', 'matplotlib', 'pandas', 'micropip', 'pillow', 'jinja2', 'decorator', 'requests'];
43434444// ---------------------------------------------------------------------------
4545// Pure-Python packages to download from PyPI (not bundled with Pyodide)
4646// ---------------------------------------------------------------------------
47474848-const PYPI_PACKAGES = ['mne', 'pooch', 'tqdm', 'platformdirs'];
4848+const PYPI_PACKAGES = ['mne', 'pooch', 'tqdm', 'platformdirs', 'lazy-loader'];
49495050// ---------------------------------------------------------------------------
5151// Shared network helpers
-29
src/renderer/utils/webworker/patches.py
···11-# patch implemented in Pyolite
22-# https://github.com/jupyterlite/jupyterlite/blob/0d563b9a4cca4b54411229128cb51ac4ba333c8f/packages/pyolite-kernel/py/pyolite/pyolite/patches.py
33-def patch_matplotlib():
44- import os
55- from io import BytesIO
66-77- # before importing matplotlib
88- # to avoid the wasm backend (which needs `js.document`, not available in worker)
99- os.environ["MPLBACKEND"] = "AGG"
1010-1111- import matplotlib.pyplot
1212- from IPython.display import display
1313-1414- from .display import Image
1515-1616- _old_show = matplotlib.pyplot.show
1717- assert _old_show, "matplotlib.pyplot.show"
1818-1919- def show():
2020- buf = BytesIO()
2121- matplotlib.pyplot.savefig(buf, format="png")
2222- buf.seek(0)
2323- display(Image(buf.read()))
2424- matplotlib.pyplot.clf()
2525-2626- matplotlib.pyplot.show = show
2727-2828-291def patch_pillow():
302 import base64
313···43154416ALL_PATCHES = [
4517 patch_pillow,
4646- patch_matplotlib,
4718]
48194920