···10101111This is not an obvious failure — Pyodide may partially initialise and then hang or throw cryptic errors when it tries to load packages.
12121313-### Solution: Serve Pyodide Assets Out-of-Band
1313+### Solution: Electron Custom Protocol Scheme (`pyodide://`)
14141515-We use two complementary mechanisms:
1515+We register a privileged custom Electron protocol scheme that serves Pyodide assets directly from the filesystem — no network socket, no Vite interception, works identically in dev and production.
16161717-**1. Custom Vite middleware (dev only)**
1717+**Registration in `src/main/index.ts` — must happen before `app.whenReady()`:**
18181919-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/`:
1919+```ts
2020+protocol.registerSchemesAsPrivileged([{
2121+ scheme: 'pyodide',
2222+ privileges: {
2323+ standard: true, // treat like http for URL parsing / resolution
2424+ secure: true, // counts as a secure origin (needed for WASM, SAB)
2525+ supportFetchAPI: true, // allow fetch() from renderer and worker contexts
2626+ corsEnabled: true, // no CORS errors when Pyodide fetches its own assets
2727+ },
2828+}]);
2929+```
3030+3131+**Handler registered in `app.whenReady()`:**
20322133```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();
3434+const pyodideRoot = is.dev
3535+ ? path.join(app.getAppPath(), 'src/renderer/utils/webworker/src')
3636+ : path.join(process.resourcesPath, 'webworker/src');
3737+3838+protocol.handle('pyodide', (request) => {
3939+ const { pathname } = new URL(request.url);
4040+ const filePath = path.join(pyodideRoot, pathname);
4141+ return net.fetch(pathToFileURL(filePath).href);
3342});
3443```
35443636-**2. Electron local HTTP server on port 17173 (dev + prod)**
4545+The web worker uses `pyodide://host` as its asset base:
37463838-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).
4747+```js
4848+const PYODIDE_ASSET_BASE = 'pyodide://host';
4949+```
39504040-The web worker (`webworker.js`) uses this as its `PYODIDE_ASSET_BASE`:
5151+**CSP in `src/renderer/index.html`** includes `pyodide:` in `connect-src`:
41524242-```js
4343-const PYODIDE_ASSET_BASE = 'http://127.0.0.1:17173';
5353+```html
5454+connect-src 'self' ws: wss: webpack: pyodide:
4455```
45564646-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
5757+> **Previous approach (removed):** We previously ran a plain Node.js HTTP server on port 17173 in the main process and a custom Vite middleware that intercepted `/pyodide/` and `/packages/` requests. Both were replaced by the `pyodide://` protocol — cleaner, no open port, no hardcoded port numbers to keep in sync, and no dev-only code path.
50585159---
5260···7381},
7482```
75837676-And workers must be created with `type: 'module'`:
8484+Workers must be created with `type: 'module'`:
77857886```ts
7987new Worker(new URL('./webworker.js', import.meta.url), { type: 'module' });
···103111| Option | Purpose |
104112|--------|---------|
105113| `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/`. |
114114+| `packageBaseUrl` | Where `loadPackage()` fetches **package `.whl` files**. Set this to `pyodide://host/pyodide/`. |
115115+116116+```js
117117+const packageBaseUrl = `${PYODIDE_ASSET_BASE}/pyodide/`;
118118+const pyodide = await loadPyodide({ lockFileURL, packageBaseUrl });
119119+```
107120108121---
109122···117130118131---
119132120120-## micropip.install() from JavaScript
133133+## micropip and the `pyodide://` URL Limitation
121134122122-`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:
135135+`micropip.install()` only accepts `http://`, `https://`, and `emfs://` URLs — it rejects custom schemes like `pyodide://`. This means we cannot install pure-Python `.whl` files (MNE and its deps) directly from the protocol.
136136+137137+**Workaround: fetch via JS → write to emscripten FS → install via `emfs://`**
123138124139```js
140140+const manifest = await fetch(`${PYODIDE_ASSET_BASE}/packages/manifest.json`)
141141+ .then(r => r.json());
142142+143143+for (const { filename } of Object.values(manifest)) {
144144+ const buffer = await fetch(`${PYODIDE_ASSET_BASE}/packages/${filename}`)
145145+ .then(r => r.arrayBuffer());
146146+ pyodide.FS.writeFile(`/tmp/${filename}`, new Uint8Array(buffer));
147147+}
148148+125149const micropip = pyodide.pyimport('micropip');
126126-await micropip.install(whlUrls); // JS array works directly
150150+await micropip.install(
151151+ Object.values(manifest).map(({ filename }) => `emfs:///tmp/${filename}`)
152152+);
127153```
128154129129-> Note: older guidance suggested wrapping with `pyodide.toPy(whlUrls)` — this is not necessary as of 0.29.x.
155155+The `fetch()` calls here use the `pyodide://` protocol directly (which Electron's protocol handler supports) and write the bytes into Pyodide's virtual emscripten filesystem. `micropip` then installs from `emfs://` paths, which it does accept.
156156+157157+> Note: In Pyodide 0.29.x, passing a JS array directly to `micropip.install()` works — no `pyodide.toPy()` wrapper needed.
130158131159---
132160···304332| Web worker entry point | `src/renderer/utils/webworker/webworker.js` |
305333| JS wrappers for Python calls | `src/renderer/utils/webworker/index.ts` |
306334| 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 |
335335+| Electron protocol handler | `src/main/index.ts` → `protocol.handle('pyodide', ...)` |
309336| Plot widget component | `src/renderer/components/PyodidePlotWidget.tsx` |
310337| Plot epics (Redux-Observable) | `src/renderer/epics/pyodideEpics.ts` |
311338| Pyodide Redux state | `src/renderer/reducers/pyodideReducer.ts` |
+2-8
src/renderer/utils/webworker/utils.py
···11-from glob import glob
22-import os
33-from time import time, strftime, gmtime
41from collections import OrderedDict
5263import numpy as np
74from matplotlib import pyplot as plt
85import pandas as pd # maybe we can remove this dependency
99-# import seaborn as sns
1061111-from mne import (Epochs, concatenate_raws, concatenate_epochs, create_info,
1212- find_events, read_epochs, set_eeg_reference, viz)
77+from mne import (concatenate_raws, create_info, viz)
138from mne.io import RawArray
149from io import StringIO
15101616-1111+# import seaborn as sns
1712# plt.style.use(fivethirtyeight)
1818-1913# sns.set_context('talk')
2014# sns.set_style('white')
2115