An easy-to-use platform for EEG experimentation in the classroom
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request #193 from teonbrooks/pyodide-upgrade

Pyodide upgrade

authored by

Dano Morrison and committed by
GitHub
4ec0c1e9 95382782

+1187 -529
+2 -29
.gitignore
··· 7 7 *.pid 8 8 *.seed 9 9 10 - # Directory for instrumented libs generated by jscoverage/JSCover 11 - lib-cov 12 - 13 10 # Coverage directory used by tools like istanbul 14 11 coverage 15 12 16 - # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 17 - .grunt 18 - 19 - # node-waf configuration 20 - .lock-wscript 21 - 22 - # Compiled binary addons (http://nodejs.org/api/addons.html) 23 - build/Release 13 + # Compiled binary addons 24 14 .eslintcache 25 15 26 16 # Dependency directory ··· 35 25 out 36 26 dist 37 27 38 - # Old webpack build artifacts 39 - app/main.prod.js 40 - app/main.prod.js.map 41 - app/renderer.prod.js 42 - app/renderer.prod.js.map 43 - app/style.css 44 - app/style.css.map 45 - dll 46 - main.js 47 - main.js.map 48 - 49 28 .idea 50 - npm-debug.log.* 51 - *.css.d.ts 52 - *.sass.d.ts 53 - *.scss.d.ts 54 29 keys.js 55 - 56 - app/utils/pyodide/src 57 - src/renderer/utils/pyodide/src 30 + src/renderer/utils/webworker/src
+5 -5
.llms/CLAUDE.md
··· 15 15 - **Bundler**: electron-vite / Vite 16 16 - **State**: Redux Toolkit + redux-observable (RxJS epics) 17 17 - **Language**: TypeScript (strict) 18 - - **Styling**: Semantic UI React + SCSS 19 - - **Testing**: Jest 18 + - **Styling**: Tailwind CSS + shadcn/ui (components in `src/renderer/components/ui/`) 19 + - **Testing**: Vitest 20 20 - **Linting**: ESLint + Prettier (single quotes, ES5 trailing commas) 21 21 22 22 ## Key Directories ··· 24 24 - `src/renderer/` — React renderer process 25 25 - `src/preload/` — Electron preload scripts 26 26 - `src/renderer/experiments/` — Lab.js experiment files 27 - - `src/renderer/utils/pyodide/` — Pyodide WASM Python runtime 27 + - `src/renderer/utils/webworker/` — Pyodide WASM Python runtime 28 28 29 29 ## Dev Workflow 30 30 ```bash 31 31 npm run dev # Start dev server (patches deps first) 32 32 npm run build # Build all processes 33 - npm test # Run Jest tests 33 + npm test # Run Vitest tests 34 34 npm run typecheck # TypeScript check (no emit) 35 35 npm run lint # ESLint 36 36 npm run lint-fix # ESLint + Prettier auto-fix ··· 45 45 - Keep Electron main/renderer separation strict — use preload IPC bridges 46 46 47 47 ## Out of Scope 48 - - Do not modify `src/renderer/utils/pyodide/src/` directly; it is managed by `InstallPyodide.js` 48 + - 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) 49 49 - Do not alter `electron-builder` publish config without confirming release intent 50 50 51 51 ## LLM Context
+39
.llms/learnings.md
··· 21 21 - **Background gradient** used on all main screens: `bg-gradient-to-b from-[#f9f9f9] to-[#f0f0ff]` 22 22 - **`@radix-ui/react-select`** is installed for the shadcn Select component 23 23 24 + ## Pyodide Asset Serving — Vite SPA Fallback Problem 25 + 26 + 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. 27 + 28 + **Solution (two-part):** 29 + 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/`. 30 + 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. 31 + 32 + 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`). 33 + 34 + **Other Pyodide loading gotchas:** 35 + - `pyodide.mjs` must be loaded via dynamic `import()` (not `fetch()`), using a `?url` Vite import — `import()` bypasses the SPA fallback, `fetch()` does not 36 + - The lock file is embedded via `?raw` and wrapped in a `Blob` + `createObjectURL` to avoid an HTTP fetch 37 + - Use `packageBaseUrl` (not `indexURL`) to tell Pyodide where to find `.whl` files; `indexURL` is for WASM/stdlib 38 + - `checkIntegrity: false` is required — SHA256 hashes in the npm lock file don't match CDN-downloaded wheels 39 + - Workers must be created with `type: 'module'` (Pyodide 0.26+ ships `pyodide.mjs` as ESM) 40 + - `optimizeDeps.exclude: ['pyodide']` in `vite.config.ts` prevents Vite from pre-bundling it 41 + 42 + ## Pyodide Offline Package Installation (InstallMNE.mjs) 43 + 44 + `internals/scripts/InstallMNE.mjs` runs on `postinstall` and downloads two sets of packages: 45 + - **Pyodide binary packages** (numpy, scipy, matplotlib, pandas + transitive deps) from the Pyodide CDN → `src/renderer/utils/webworker/src/pyodide/` 46 + - **Pure-Python packages** (mne, pooch, tqdm, platformdirs) from PyPI → `src/renderer/utils/webworker/src/packages/` 47 + 48 + A `manifest.json` is written to `packages/` so `webworker.js` knows the exact `.whl` filenames to pass to `micropip.install()`. 49 + 50 + 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`. 51 + 52 + **Packages that must be listed explicitly** (not reachable from matplotlib/scipy/pandas deps in the lock file, but required at runtime): 53 + - `jinja2` + `markupsafe` — used by matplotlib templates and MNE HTML reports 54 + - `decorator` — MNE core dep 55 + - `requests` (+ `certifi`, `charset-normalizer`, `idna`, `urllib3`) — pulled in by `pooch` at MNE import time 56 + 57 + **`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. 58 + 59 + **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. 60 + 61 + **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`. 62 + 24 63 ## Pre-existing TypeScript errors (do not treat as regressions) 25 64 26 65 - `src/renderer/epics/experimentEpics.ts` (lines 170, 205) — RxJS operator type mismatch
+311
docs/pyodide-in-electron-vite.md
··· 1 + # Pyodide in Electron + Vite: What We Learned 2 + 3 + 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. 4 + 5 + --- 6 + 7 + ## The Core Problem: Vite's SPA Fallback 8 + 9 + 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. 10 + 11 + This is not an obvious failure — Pyodide may partially initialise and then hang or throw cryptic errors when it tries to load packages. 12 + 13 + ### Solution: Serve Pyodide Assets Out-of-Band 14 + 15 + We use two complementary mechanisms: 16 + 17 + **1. Custom Vite middleware (dev only)** 18 + 19 + 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/`: 20 + 21 + ```ts 22 + server.middlewares.use((req, res, next) => { 23 + const url = req.url ?? ''; 24 + if (url.startsWith('/pyodide/') || url.startsWith('/packages/')) { 25 + const filePath = path.join(staticDir, url.split('?')[0]); 26 + if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) { 27 + res.setHeader('Content-Type', contentTypes[ext] ?? 'application/octet-stream'); 28 + fs.createReadStream(filePath).pipe(res); 29 + return; 30 + } 31 + } 32 + next(); 33 + }); 34 + ``` 35 + 36 + **2. Electron local HTTP server on port 17173 (dev + prod)** 37 + 38 + 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). 39 + 40 + The web worker (`webworker.js`) uses this as its `PYODIDE_ASSET_BASE`: 41 + 42 + ```js 43 + const PYODIDE_ASSET_BASE = 'http://127.0.0.1:17173'; 44 + ``` 45 + 46 + Port 17173 is hardcoded in three places that must stay in sync: 47 + - `src/main/index.ts` — server listen port 48 + - `src/renderer/utils/webworker/webworker.js` — `PYODIDE_ASSET_BASE` 49 + - `src/renderer/index.html` — CSP `connect-src` directive 50 + 51 + --- 52 + 53 + ## Loading pyodide.mjs 54 + 55 + 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()`: 56 + 57 + ```js 58 + import pyodideMjsUrl from 'pyodide/pyodide.mjs?url'; 59 + // ... 60 + const { loadPyodide } = await import(/* @vite-ignore */ pyodideMjsUrl); 61 + ``` 62 + 63 + `import()` bypasses Vite's SPA fallback; `fetch()` does not. 64 + 65 + Also required in `vite.config.ts`: 66 + 67 + ```ts 68 + optimizeDeps: { 69 + exclude: ['pyodide'], // prevent Vite from pre-bundling it 70 + }, 71 + worker: { 72 + format: 'es', // ES module workers required for pyodide.mjs 73 + }, 74 + ``` 75 + 76 + And workers must be created with `type: 'module'`: 77 + 78 + ```ts 79 + new Worker(new URL('./webworker.js', import.meta.url), { type: 'module' }); 80 + ``` 81 + 82 + --- 83 + 84 + ## Loading the Lock File Without a Fetch 85 + 86 + `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: 87 + 88 + ```js 89 + import lockFileRaw from 'pyodide/pyodide-lock.json?raw'; 90 + 91 + const lockBlob = new Blob([lockFileRaw], { type: 'application/json' }); 92 + const lockFileURL = URL.createObjectURL(lockBlob); 93 + const pyodide = await loadPyodide({ lockFileURL, packageBaseUrl }); 94 + URL.revokeObjectURL(lockFileURL); 95 + ``` 96 + 97 + --- 98 + 99 + ## packageBaseUrl vs indexURL 100 + 101 + These are easy to confuse: 102 + 103 + | Option | Purpose | 104 + |--------|---------| 105 + | `indexURL` | Where Pyodide looks for its **runtime** files (WASM, stdlib). Already resolved from `node_modules` via `import.meta.url`. Do not override. | 106 + | `packageBaseUrl` | Where `loadPackage()` fetches **package `.whl` files**. Set this to `http://127.0.0.1:17173/pyodide/`. | 107 + 108 + --- 109 + 110 + ## Package Integrity Checks 111 + 112 + ```js 113 + await pyodide.loadPackage(['numpy', 'scipy', ...], { checkIntegrity: false }); 114 + ``` 115 + 116 + `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. 117 + 118 + --- 119 + 120 + ## micropip.install() from JavaScript 121 + 122 + `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: 123 + 124 + ```js 125 + const micropip = pyodide.pyimport('micropip'); 126 + await micropip.install(whlUrls); // JS array works directly 127 + ``` 128 + 129 + > Note: older guidance suggested wrapping with `pyodide.toPy(whlUrls)` — this is not necessary as of 0.29.x. 130 + 131 + --- 132 + 133 + ## Offline Package Installation (InstallMNE.mjs) 134 + 135 + `internals/scripts/InstallMNE.mjs` runs on `postinstall` and pre-downloads all packages so the app works offline. 136 + 137 + ### Part 1 — Pyodide Binary Packages (from Pyodide CDN) 138 + 139 + 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/`. 140 + 141 + **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. 142 + 143 + Current root packages and why each is listed explicitly: 144 + 145 + | Package | Reason | 146 + |---------|--------| 147 + | `numpy`, `scipy`, `matplotlib`, `pandas` | Core scientific stack | 148 + | `micropip` | Needed to install pure-Python packages at worker startup | 149 + | `pillow` | Used by matplotlib and MNE; loaded at runtime by `loadPackage()` | 150 + | `jinja2` | MNE dep; **not** listed in matplotlib's lock-file `depends` array despite being a runtime requirement | 151 + | `decorator` | MNE core dep; not reachable from the scientific stack in the lock file | 152 + | `requests` | Pulled in by `pooch` at MNE import time; brings in `certifi`, `charset-normalizer`, `idna`, `urllib3` transitively | 153 + 154 + > **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. 155 + 156 + ### Part 2 — Pure-Python Packages (from PyPI) 157 + 158 + 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. 159 + 160 + Current PyPI packages: `mne`, `pooch`, `tqdm`, `platformdirs`, `lazy-loader` 161 + 162 + `lazy-loader` is a core MNE dependency that does not appear in the Pyodide lock at all. 163 + 164 + --- 165 + 166 + ## Plot Pipeline 167 + 168 + ### matplotlib Backend in Web Workers 169 + 170 + Use `agg`, not `webagg`. Set it before any Python imports run: 171 + 172 + ```js 173 + await pyodide.runPythonAsync('import os; os.environ["MPLBACKEND"] = "agg"'); 174 + ``` 175 + 176 + 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: 177 + 178 + ``` 179 + ImportError: cannot import name 'document' from 'js' 180 + ``` 181 + 182 + `agg` is a non-interactive raster backend that writes to a buffer, which is exactly what we need. 183 + 184 + --- 185 + 186 + ### plotKey Correlation Pattern (Fire-and-Forget Messaging) 187 + 188 + `worker.postMessage()` returns `undefined` — there is no return channel. Redux-Observable plot load epics cannot receive the worker's result directly. 189 + 190 + **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. 191 + 192 + ```js 193 + // webworker.js — echo plotKey back in every response 194 + const { data, plotKey, ...context } = event.data; 195 + self.postMessage({ results: await pyodide.runPythonAsync(data), plotKey }); 196 + ``` 197 + 198 + ```ts 199 + // pyodideMessageEpic — route by plotKey 200 + switch (plotKey) { 201 + case 'ready': return of(PyodideActions.SetWorkerReady()); 202 + case 'topo': return of(PyodideActions.SetTopoPlot(mimeBundle)); 203 + case 'psd': return of(PyodideActions.SetPSDPlot(mimeBundle)); 204 + case 'erp': return of(PyodideActions.SetERPPlot(mimeBundle)); 205 + default: return of(PyodideActions.ReceiveMessage(e.data)); 206 + } 207 + ``` 208 + 209 + Plot load epics become fire-and-forget — they call `worker.postMessage()` as a side effect and emit nothing: 210 + 211 + ```ts 212 + // loadTopoEpic 213 + action$.pipe( 214 + filter(isActionOf(PyodideActions.LoadTopo)), 215 + tap(() => plotTestPlot(state$.value.pyodide.worker!)), 216 + mergeMap(() => EMPTY) 217 + ); 218 + ``` 219 + 220 + --- 221 + 222 + ### Worker Readiness Gating 223 + 224 + `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. 225 + 226 + ```ts 227 + export const loadUtils = async (worker: Worker) => 228 + worker.postMessage({ data: utilsPy, plotKey: 'ready' }); 229 + ``` 230 + 231 + `pyodideMessageEpic` dispatches `PyodideActions.SetWorkerReady()` on receiving `plotKey === 'ready'`. 232 + 233 + --- 234 + 235 + ### SVG Output from matplotlib 236 + 237 + Produce SVG in Python — no base64 encoding needed: 238 + 239 + ```python 240 + import io 241 + import matplotlib.pyplot as plt 242 + 243 + _fig, _ax = plt.subplots() 244 + _ax.plot([1, 2, 3, 4], [1, 4, 2, 3]) 245 + _buf = io.BytesIO() 246 + _fig.savefig(_buf, format="svg", bbox_inches="tight") 247 + plt.close(_fig) 248 + _buf.getvalue().decode() # SVG string is the Python return value 249 + ``` 250 + 251 + The SVG string flows through `pyodide.runPythonAsync()` → worker `postMessage` → Redux state as `{ 'image/svg+xml': string }`. 252 + 253 + --- 254 + 255 + ### Rendering SVG Safely in the Renderer 256 + 257 + Use a data URI on an `<img>` tag — sandboxed, no script execution: 258 + 259 + ```tsx 260 + <img src={`data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`} /> 261 + ``` 262 + 263 + Prefer this over `dangerouslySetInnerHTML` — inline SVG executes `<script>` tags and has full DOM access. 264 + 265 + --- 266 + 267 + ### PNG Export from SVG 268 + 269 + Simple canvas conversion using the SVG's natural pixel dimensions: 270 + 271 + ```ts 272 + function svgToPngArrayBuffer(svg: string): Promise<ArrayBuffer> { 273 + return new Promise((resolve, reject) => { 274 + const blob = new Blob([svg], { type: 'image/svg+xml' }); 275 + const url = URL.createObjectURL(blob); 276 + const img = new Image(); 277 + img.onload = () => { 278 + const canvas = document.createElement('canvas'); 279 + canvas.width = img.naturalWidth; 280 + canvas.height = img.naturalHeight; 281 + const ctx = canvas.getContext('2d')!; 282 + ctx.drawImage(img, 0, 0); 283 + URL.revokeObjectURL(url); 284 + canvas.toBlob((pngBlob) => { 285 + pngBlob!.arrayBuffer().then(resolve).catch(reject); 286 + }, 'image/png'); 287 + }; 288 + img.onerror = () => { URL.revokeObjectURL(url); reject(new Error('SVG load failed')); }; 289 + img.src = url; 290 + }); 291 + } 292 + ``` 293 + 294 + The resulting `ArrayBuffer` is passed through the IPC chain (`preload → main`) for file write. Electron's `contextBridge` serialises `ArrayBuffer` correctly without any additional conversion. 295 + 296 + --- 297 + 298 + ## Summary of File Locations 299 + 300 + | What | Where | 301 + |------|-------| 302 + | Pyodide runtime + binary wheels | `src/renderer/utils/webworker/src/pyodide/` | 303 + | Pure-Python wheels + manifest | `src/renderer/utils/webworker/src/packages/` | 304 + | Web worker entry point | `src/renderer/utils/webworker/webworker.js` | 305 + | JS wrappers for Python calls | `src/renderer/utils/webworker/index.ts` | 306 + | Install script | `internals/scripts/InstallMNE.mjs` | 307 + | Electron asset server | `src/main/index.ts` → `startPyodideAssetServer()` | 308 + | Vite middleware | `vite.config.ts` → `serve-pyodide-assets` plugin | 309 + | Plot widget component | `src/renderer/components/PyodidePlotWidget.tsx` | 310 + | Plot epics (Redux-Observable) | `src/renderer/epics/pyodideEpics.ts` | 311 + | Pyodide Redux state | `src/renderer/reducers/pyodideReducer.ts` |
+1 -1
eslint.config.mjs
··· 23 23 'dist/**', 24 24 'coverage/**', 25 25 '.worktrees/**', 26 - 'src/renderer/utils/pyodide/src/**', 26 + 'src/renderer/utils/webworker/src/**', 27 27 '**/*.css.d.ts', 28 28 '**/*.scss.d.ts', 29 29 ],
+272
internals/scripts/InstallMNE.mjs
··· 1 + #!/usr/bin/env node 2 + /** 3 + * Downloads everything MNE-Python needs to run offline inside Pyodide: 4 + * 5 + * Part 1 — Pyodide binary packages (from the Pyodide CDN) 6 + * Reads pyodide-lock.json that was extracted by InstallPyodide.mjs, 7 + * recursively resolves all dependencies of numpy / scipy / matplotlib / 8 + * pandas, and downloads each .whl (or .zip) into the same /pyodide/ 9 + * directory as the runtime. loadPackage() will find them locally and 10 + * will not need to reach the CDN at runtime. 11 + * 12 + * Part 2 — Pure-Python packages (from PyPI) 13 + * MNE itself and its pure-Python dependencies (pooch, tqdm, platformdirs) 14 + * are not bundled with Pyodide. These are downloaded as py3-none-any 15 + * wheels into src/renderer/utils/webworker/src/packages/ and installed via 16 + * micropip at worker startup. A manifest.json is written there so the 17 + * worker knows the exact filenames. 18 + * 19 + * Usage: node internals/scripts/InstallMNE.mjs 20 + * Runs automatically via the postinstall npm hook. 21 + */ 22 + 23 + import fs from 'fs'; 24 + import https from 'https'; 25 + import path from 'path'; 26 + import chalk from 'chalk'; 27 + 28 + // --------------------------------------------------------------------------- 29 + // Paths 30 + // --------------------------------------------------------------------------- 31 + 32 + const PYODIDE_DIR = path.resolve('src/renderer/utils/webworker/src/pyodide'); 33 + const LOCK_FILE = path.join(PYODIDE_DIR, 'pyodide-lock.json'); 34 + 35 + const PACKAGES_DIR = path.resolve('src/renderer/utils/webworker/src/packages'); 36 + const MANIFEST_FILE = path.join(PACKAGES_DIR, 'manifest.json'); 37 + 38 + // --------------------------------------------------------------------------- 39 + // Root packages whose full transitive dependency tree we need from Pyodide CDN 40 + // --------------------------------------------------------------------------- 41 + 42 + const PYODIDE_ROOT_PACKAGES = ['numpy', 'scipy', 'matplotlib', 'pandas', 'micropip', 'pillow', 'jinja2', 'decorator', 'requests']; 43 + 44 + // --------------------------------------------------------------------------- 45 + // Pure-Python packages to download from PyPI (not bundled with Pyodide) 46 + // --------------------------------------------------------------------------- 47 + 48 + const PYPI_PACKAGES = ['mne', 'pooch', 'tqdm', 'platformdirs', 'lazy-loader']; 49 + 50 + // --------------------------------------------------------------------------- 51 + // Shared network helpers 52 + // --------------------------------------------------------------------------- 53 + 54 + function downloadBinary(url, dest) { 55 + return new Promise((resolve, reject) => { 56 + const doGet = (reqUrl) => { 57 + https 58 + .get(reqUrl, { headers: { 'User-Agent': 'BrainWaves-installer/1.0' } }, (res) => { 59 + if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { 60 + doGet(res.headers.location); 61 + return; 62 + } 63 + if (res.statusCode !== 200) { 64 + reject(new Error(`HTTP ${res.statusCode} for ${reqUrl}`)); 65 + return; 66 + } 67 + const file = fs.createWriteStream(dest); 68 + res.pipe(file); 69 + file.on('finish', () => file.close(resolve)); 70 + file.on('error', (err) => { fs.unlink(dest, () => {}); reject(err); }); 71 + }) 72 + .on('error', (err) => { fs.unlink(dest, () => {}); reject(err); }); 73 + }; 74 + doGet(url); 75 + }); 76 + } 77 + 78 + function httpsGetText(url) { 79 + return new Promise((resolve, reject) => { 80 + https 81 + .get(url, { headers: { 'User-Agent': 'BrainWaves-installer/1.0' } }, (res) => { 82 + if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { 83 + resolve(httpsGetText(res.headers.location)); 84 + return; 85 + } 86 + if (res.statusCode !== 200) { 87 + reject(new Error(`HTTP ${res.statusCode} for ${url}`)); 88 + return; 89 + } 90 + let body = ''; 91 + res.setEncoding('utf8'); 92 + res.on('data', (c) => { body += c; }); 93 + res.on('end', () => resolve(body)); 94 + res.on('error', reject); 95 + }) 96 + .on('error', reject); 97 + }); 98 + } 99 + 100 + // --------------------------------------------------------------------------- 101 + // Part 1 — Pyodide binary packages 102 + // --------------------------------------------------------------------------- 103 + 104 + /** 105 + * Recursively walks the `depends` graph in the lock file and returns every 106 + * package entry (including root packages) needed to satisfy the given roots. 107 + * Package name matching is case-insensitive. 108 + */ 109 + function resolveAllDeps(lockPackages, rootNames) { 110 + // Build a lowercase → original-key index for case-insensitive lookup. 111 + const index = {}; 112 + for (const key of Object.keys(lockPackages)) { 113 + index[key.toLowerCase()] = key; 114 + } 115 + 116 + const resolved = new Set(); 117 + const queue = rootNames.map((n) => n.toLowerCase()); 118 + 119 + while (queue.length) { 120 + const lower = queue.shift(); 121 + const key = index[lower]; 122 + if (!key || resolved.has(key)) continue; 123 + resolved.add(key); 124 + for (const dep of lockPackages[key].depends ?? []) { 125 + queue.push(dep.toLowerCase()); 126 + } 127 + } 128 + 129 + return [...resolved].map((key) => lockPackages[key]); 130 + } 131 + 132 + async function downloadPyodidePackages() { 133 + if (!fs.existsSync(LOCK_FILE)) { 134 + console.warn( 135 + chalk.yellow( 136 + ' ⚠ pyodide-lock.json not found — run `npm install` first to ' + 137 + 'extract the Pyodide runtime, then re-run this script.' 138 + ) 139 + ); 140 + return; 141 + } 142 + 143 + const lockData = JSON.parse(fs.readFileSync(LOCK_FILE, 'utf8')); 144 + 145 + // The lock file's info.version may be an internal dev label (e.g. "0.28.0.dev0"). 146 + // Always derive the CDN URL from the installed npm package version instead. 147 + const npmPkgPath = path.resolve('node_modules/pyodide/package.json'); 148 + const cdnVersion = fs.existsSync(npmPkgPath) 149 + ? JSON.parse(fs.readFileSync(npmPkgPath, 'utf8')).version 150 + : lockData.info.version; 151 + const cdnBase = `https://cdn.jsdelivr.net/pyodide/v${cdnVersion}/full/`; 152 + 153 + const allPkgs = resolveAllDeps(lockData.packages, PYODIDE_ROOT_PACKAGES); 154 + 155 + console.log( 156 + chalk.blue.bold( 157 + `Downloading ${allPkgs.length} Pyodide packages from CDN (v${cdnVersion})…` 158 + ) 159 + ); 160 + 161 + for (const pkg of allPkgs) { 162 + process.stdout.write(chalk.blue(` ${pkg.name ?? pkg.file_name}: `)); 163 + 164 + const dest = path.join(PYODIDE_DIR, pkg.file_name); 165 + if (fs.existsSync(dest)) { 166 + console.log(chalk.gray('already present, skipping')); 167 + continue; 168 + } 169 + 170 + const url = cdnBase + pkg.file_name; 171 + try { 172 + await downloadBinary(url, dest); 173 + console.log(chalk.green('downloaded')); 174 + } catch (err) { 175 + console.log(chalk.red(`FAILED — ${err.message}`)); 176 + if (fs.existsSync(dest)) fs.unlinkSync(dest); 177 + } 178 + } 179 + } 180 + 181 + // --------------------------------------------------------------------------- 182 + // Part 2 — Pure-Python packages from PyPI 183 + // --------------------------------------------------------------------------- 184 + 185 + /** 186 + * Queries the PyPI JSON API for `packageName` and returns the best 187 + * pure-Python wheel for the latest release. 188 + * Preference: py3-none-any > py2.py3-none-any > *-none-any 189 + */ 190 + async function resolvePureWheel(packageName) { 191 + const raw = await httpsGetText(`https://pypi.org/pypi/${packageName}/json`); 192 + const data = JSON.parse(raw); 193 + const version = data.info.version; 194 + const wheels = data.urls.filter((f) => f.filename.endsWith('.whl')); 195 + 196 + const ranked = [ 197 + wheels.find((f) => f.filename.endsWith('-py3-none-any.whl')), 198 + wheels.find((f) => f.filename.endsWith('-py2.py3-none-any.whl')), 199 + wheels.find((f) => f.filename.includes('-none-any.whl')), 200 + ].filter(Boolean); 201 + 202 + if (ranked.length === 0) { 203 + throw new Error( 204 + `No pure-Python wheel found for ${packageName} ${version}. ` + 205 + `Binary packages must come from the Pyodide CDN.` 206 + ); 207 + } 208 + 209 + return { version, wheel: ranked[0] }; 210 + } 211 + 212 + async function installPyPIPackage(packageName, manifest) { 213 + process.stdout.write(chalk.blue(` ${packageName}: `)); 214 + 215 + let version, wheel; 216 + try { 217 + ({ version, wheel } = await resolvePureWheel(packageName)); 218 + } catch (err) { 219 + console.log(chalk.red(`FAILED — ${err.message}`)); 220 + return; 221 + } 222 + 223 + const dest = path.join(PACKAGES_DIR, wheel.filename); 224 + if (fs.existsSync(dest)) { 225 + console.log(chalk.gray(`${version} already present, skipping`)); 226 + manifest[packageName] = { version, filename: wheel.filename }; 227 + return; 228 + } 229 + 230 + try { 231 + await downloadBinary(wheel.url, dest); 232 + console.log(chalk.green(`${version} downloaded`)); 233 + manifest[packageName] = { version, filename: wheel.filename }; 234 + } catch (err) { 235 + console.log(chalk.red(`FAILED — ${err.message}`)); 236 + if (fs.existsSync(dest)) fs.unlinkSync(dest); 237 + } 238 + } 239 + 240 + async function downloadPyPIPackages() { 241 + fs.mkdirSync(PACKAGES_DIR, { recursive: true }); 242 + 243 + // Preserve previously downloaded packages already recorded in the manifest. 244 + let manifest = {}; 245 + if (fs.existsSync(MANIFEST_FILE)) { 246 + try { manifest = JSON.parse(fs.readFileSync(MANIFEST_FILE, 'utf8')); } 247 + catch { manifest = {}; } 248 + } 249 + 250 + console.log(chalk.blue.bold('\nDownloading MNE-Python wheels from PyPI…')); 251 + for (const pkg of PYPI_PACKAGES) { 252 + await installPyPIPackage(pkg, manifest); 253 + } 254 + 255 + fs.writeFileSync(MANIFEST_FILE, JSON.stringify(manifest, null, 2)); 256 + console.log(chalk.gray(` Manifest → ${MANIFEST_FILE}`)); 257 + } 258 + 259 + // --------------------------------------------------------------------------- 260 + // Entry point 261 + // --------------------------------------------------------------------------- 262 + 263 + async function main() { 264 + await downloadPyodidePackages(); 265 + await downloadPyPIPackages(); 266 + console.log(chalk.green.bold('\nAll packages ready.')); 267 + } 268 + 269 + main().catch((err) => { 270 + console.error(chalk.red('Fatal error:'), err); 271 + process.exit(1); 272 + });
-66
internals/scripts/InstallPyodide.js
··· 1 - import chalk from 'chalk'; 2 - import fs from 'fs'; 3 - import https from 'https'; 4 - import mkdirp from 'mkdirp'; 5 - import tar from 'tar-fs'; 6 - import url from 'url'; 7 - import bz2 from 'unbzip2-stream'; 8 - 9 - const PYODIDE_VERSION = '0.27.0'; 10 - const TAR_NAME = `pyodide-${PYODIDE_VERSION}.tar.bz2`; 11 - const TAR_URL = `https://github.com/pyodide/pyodide/releases/download/${PYODIDE_VERSION}/pyodide-${PYODIDE_VERSION}.tar.bz2`; 12 - const PYODIDE_DIR = 'src/renderer/utils/pyodide/src/'; 13 - 14 - const writeAndUnzipFile = (response) => { 15 - const filePath = `${PYODIDE_DIR}${TAR_NAME}`; 16 - const writeStream = fs.createWriteStream(filePath); 17 - response.pipe(writeStream); 18 - 19 - writeStream.on('finish', () => { 20 - console.log(`${chalk.green.bold(`Unzipping pyodide`)}`); 21 - 22 - const readStream = fs.createReadStream(filePath); 23 - try { 24 - readStream.pipe(bz2()).pipe(tar.extract(PYODIDE_DIR)); 25 - } catch (e) { 26 - throw new Error('Error in unzip:', e); 27 - } 28 - 29 - readStream.on('end', () => { 30 - console.log(`${chalk.green.bold(`Unzip successful`)}`); 31 - }); 32 - }); 33 - }; 34 - 35 - const downloadFile = (response) => { 36 - if ( 37 - response.statusCode > 300 && 38 - response.statusCode < 400 && 39 - response.headers.location 40 - ) { 41 - if (url.parse(response.headers.location).hostname) { 42 - https.get(response.headers.location, writeAndUnzipFile); 43 - } else { 44 - https.get( 45 - url.resolve(url.parse(TAR_URL).hostname, response.headers.location), 46 - writeAndUnzipFile 47 - ); 48 - } 49 - } else { 50 - writeAndUnzipFile(response); 51 - } 52 - }; 53 - 54 - (() => { 55 - if (fs.existsSync(`${PYODIDE_DIR}${TAR_NAME}`)) { 56 - console.log( 57 - `${chalk.green.bold(`Pyodide is already present: ${PYODIDE_VERSION}...`)}` 58 - ); 59 - return; 60 - } 61 - console.log( 62 - `${chalk.green.bold(`Downloading pyodide ${PYODIDE_VERSION}...`)}` 63 - ); 64 - mkdirp.sync(`src/renderer/utils/pyodide/src`); 65 - https.get(TAR_URL, downloadFile); 66 - })();
+109
internals/scripts/InstallPyodide.mjs
··· 1 + #!/usr/bin/env node 2 + /** 3 + * Copies the Pyodide runtime from the installed npm package into the renderer's 4 + * publicDir so Vite can serve it as static assets. 5 + * 6 + * Source: node_modules/pyodide/ 7 + * Dest: src/renderer/utils/webworker/src/pyodide/ 8 + * 9 + * Key files copied: 10 + * pyodide.mjs – ESM entry point (imported by the web worker via npm) 11 + * pyodide.js – UMD fallback 12 + * pyodide.asm.js – compiled Python interpreter 13 + * pyodide.asm.wasm – WebAssembly binary 14 + * python_stdlib.zip – Python standard library 15 + * pyodide-lock.json – package registry (read by InstallMNE.mjs) 16 + * 17 + * Intentionally skipped: 18 + * package.json – would make Vite treat the dir as an npm package 19 + * and attempt to transform pyodide.mjs as a module 20 + * *.d.ts – TypeScript declaration files, not needed at runtime 21 + * *.html – console demo pages 22 + * README.md – documentation 23 + * *.map – source maps (large, optional for debugging) 24 + * 25 + * A version stamp (.pyodide-version) is written so subsequent runs are skipped 26 + * when the installed version has not changed. 27 + * 28 + * Usage: node internals/scripts/InstallPyodide.mjs 29 + * Runs automatically via the postinstall npm hook. 30 + */ 31 + 32 + import fs from 'fs'; 33 + import path from 'path'; 34 + import { createRequire } from 'module'; 35 + import chalk from 'chalk'; 36 + 37 + const _require = createRequire(import.meta.url); 38 + 39 + const DEST_DIR = path.resolve('src/renderer/utils/webworker/src/pyodide'); 40 + const VERSION_FILE = path.join(DEST_DIR, '.pyodide-version'); 41 + 42 + // Files to exclude from the copy. 43 + const SKIP_EXTENSIONS = new Set(['.d.ts', '.map', '.html', '.md']); 44 + const SKIP_FILES = new Set(['package.json', 'README.md']); 45 + 46 + function shouldSkip(filename) { 47 + if (SKIP_FILES.has(filename)) return true; 48 + for (const ext of SKIP_EXTENSIONS) { 49 + if (filename.endsWith(ext)) return true; 50 + } 51 + return false; 52 + } 53 + 54 + async function main() { 55 + // Locate the pyodide package directory via Node's module resolution. 56 + let pyodideDir; 57 + try { 58 + pyodideDir = path.dirname(_require.resolve('pyodide/package.json')); 59 + } catch { 60 + console.error( 61 + chalk.red( 62 + 'pyodide not found in node_modules. Run `npm install` first.' 63 + ) 64 + ); 65 + process.exit(1); 66 + } 67 + 68 + const version = JSON.parse( 69 + fs.readFileSync(path.join(pyodideDir, 'package.json'), 'utf8') 70 + ).version; 71 + 72 + // Skip if this version was already installed. 73 + if ( 74 + fs.existsSync(VERSION_FILE) && 75 + fs.readFileSync(VERSION_FILE, 'utf8').trim() === version 76 + ) { 77 + console.log(chalk.gray(`Pyodide ${version} already installed — skipping.`)); 78 + return; 79 + } 80 + 81 + console.log( 82 + chalk.blue.bold(`Installing Pyodide ${version} from node_modules…`) 83 + ); 84 + fs.mkdirSync(DEST_DIR, { recursive: true }); 85 + 86 + const files = fs.readdirSync(pyodideDir); 87 + for (const file of files) { 88 + if (shouldSkip(file)) continue; 89 + 90 + const src = path.join(pyodideDir, file); 91 + const dest = path.join(DEST_DIR, file); 92 + 93 + if (fs.statSync(src).isDirectory()) continue; 94 + 95 + process.stdout.write(chalk.blue(` ${file}: `)); 96 + fs.copyFileSync(src, dest); 97 + console.log(chalk.green('copied')); 98 + } 99 + 100 + fs.writeFileSync(VERSION_FILE, version); 101 + console.log( 102 + chalk.green.bold(`\nPyodide ${version} ready at ${DEST_DIR}`) 103 + ); 104 + } 105 + 106 + main().catch((err) => { 107 + console.error(chalk.red('Fatal error:'), err); 108 + process.exit(1); 109 + });
+19 -204
package-lock.json
··· 14 14 "@electron-toolkit/utils": "^4.0.0", 15 15 "@fortawesome/fontawesome-free": "^5.13.0", 16 16 "@neurosity/pipes": "^5.2.1", 17 - "@nteract/transforms": "^3.2.0", 18 17 "@radix-ui/react-dialog": "^1.1.0", 19 18 "@radix-ui/react-dropdown-menu": "^2.1.0", 20 19 "@radix-ui/react-select": "^2.2.6", ··· 37 36 "papaparse": "^5.5.3", 38 37 "pathe": "^2.0.3", 39 38 "plotly.js": "^3.4.0", 39 + "pyodide": "^0.29.3", 40 40 "rc-slider": "9.2.4", 41 41 "react": "^18.x", 42 42 "react-dom": "^18.x", ··· 592 592 "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", 593 593 "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", 594 594 "license": "MIT", 595 - "engines": { 596 - "node": ">=6.9.0" 597 - } 598 - }, 599 - "node_modules/@babel/runtime-corejs2": { 600 - "version": "7.28.6", 601 - "resolved": "https://registry.npmjs.org/@babel/runtime-corejs2/-/runtime-corejs2-7.28.6.tgz", 602 - "integrity": "sha512-pOHfxftxpetWUeBacCB3ZOPc/OO6hiT9MLv0qd9j474khiCcduwO8uuJI3N7vX3m8GJotTT6lxlA89TS/PylGg==", 603 - "license": "MIT", 604 - "dependencies": { 605 - "core-js": "^2.6.12" 606 - }, 607 595 "engines": { 608 596 "node": ">=6.9.0" 609 597 } ··· 2578 2566 "node": ">=10" 2579 2567 } 2580 2568 }, 2581 - "node_modules/@nteract/transform-vdom": { 2582 - "version": "2.2.5", 2583 - "resolved": "https://registry.npmjs.org/@nteract/transform-vdom/-/transform-vdom-2.2.5.tgz", 2584 - "integrity": "sha512-q6FbWlrSEWUmQpDV1DBPcw5FZpUcQbKOQ2a59vY/qcQ/Qjh1KUCC+gortso+WIE4P36eHZRxKz5ptCu5i47OLg==", 2585 - "license": "BSD-3-Clause", 2586 - "dependencies": { 2587 - "@babel/runtime-corejs2": "^7.0.0", 2588 - "babel-runtime": "^6.26.0", 2589 - "lodash": "^4.17.4" 2590 - }, 2591 - "peerDependencies": { 2592 - "react": "^16.3.2" 2593 - } 2594 - }, 2595 - "node_modules/@nteract/transforms": { 2596 - "version": "3.2.0", 2597 - "resolved": "https://registry.npmjs.org/@nteract/transforms/-/transforms-3.2.0.tgz", 2598 - "integrity": "sha512-9P926e2tm0H1IHF2ER6f0+At5NPgrMgvNOPZTn+K6e9M9+EpNPbZq4q5YUX1xKG8YaK2fpiH+4XVkFBf06YOJg==", 2599 - "deprecated": "This package has been deprecated. Please access each transform through its own package.", 2600 - "license": "BSD-3-Clause", 2601 - "dependencies": { 2602 - "@nteract/transform-vdom": "^2.1.0", 2603 - "ansi-to-react": "^2.0.6", 2604 - "commonmark": "^0.28.0", 2605 - "commonmark-react-renderer": "^4.3.3", 2606 - "mathjax-electron": "^2.0.1", 2607 - "react-json-tree": "^0.11.0" 2608 - }, 2609 - "peerDependencies": { 2610 - "react": "^16.2.0" 2611 - } 2612 - }, 2613 - "node_modules/@nteract/transforms/node_modules/commonmark": { 2614 - "version": "0.28.1", 2615 - "resolved": "https://registry.npmjs.org/commonmark/-/commonmark-0.28.1.tgz", 2616 - "integrity": "sha512-PklsZ9pgrfFQ5hQH9BRzoWnqI9db2LeR9MhvkNk8iz97kfaTNmhTU+IE8jKDHTEfivZZXoFqzGqzddXdk14EJw==", 2617 - "license": "BSD-2-Clause", 2618 - "dependencies": { 2619 - "entities": "~ 1.1.1", 2620 - "mdurl": "~ 1.0.1", 2621 - "minimist": "~ 1.2.0", 2622 - "string.prototype.repeat": "^0.2.0" 2623 - }, 2624 - "bin": { 2625 - "commonmark": "bin/commonmark" 2626 - }, 2627 - "engines": { 2628 - "node": "*" 2629 - } 2630 - }, 2631 2569 "node_modules/@parcel/watcher": { 2632 2570 "version": "2.5.6", 2633 2571 "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", ··· 4538 4476 "dev": true, 4539 4477 "license": "MIT" 4540 4478 }, 4479 + "node_modules/@types/emscripten": { 4480 + "version": "1.41.5", 4481 + "resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.41.5.tgz", 4482 + "integrity": "sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q==", 4483 + "license": "MIT" 4484 + }, 4541 4485 "node_modules/@types/estree": { 4542 4486 "version": "1.0.8", 4543 4487 "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", ··· 5274 5218 "integrity": "sha512-0V/PkoculFl5+0Lp47JoxUcO0xSxhIBvm+BxHdD/OgXNmdRpRHCFnKVuUoWyS9EzQP+otSGv0m9Lb4yVkQBn2A==", 5275 5219 "license": "MIT" 5276 5220 }, 5277 - "node_modules/anser": { 5278 - "version": "1.4.10", 5279 - "resolved": "https://registry.npmjs.org/anser/-/anser-1.4.10.tgz", 5280 - "integrity": "sha512-hCv9AqTQ8ycjpSd3upOJd7vFwW1JaoYQ7tpham03GJ1ca8/65rqn0RpaWpItOAd6ylW9wAw6luXYPJIyPFVOww==", 5281 - "license": "MIT" 5282 - }, 5283 5221 "node_modules/ansi-regex": { 5284 5222 "version": "5.0.1", 5285 5223 "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", ··· 5307 5245 "url": "https://github.com/chalk/ansi-styles?sponsor=1" 5308 5246 } 5309 5247 }, 5310 - "node_modules/ansi-to-react": { 5311 - "version": "2.0.6", 5312 - "resolved": "https://registry.npmjs.org/ansi-to-react/-/ansi-to-react-2.0.6.tgz", 5313 - "integrity": "sha512-AnzmnQcMmCqbd72cRridR94RR0YQpv7Bvbm7YNSGnReTwFQmLkfaZzw4Ajg7HRfR6ZxCEa90sJWEZFhCOPcWhA==", 5314 - "license": "MPL-2.0", 5315 - "dependencies": { 5316 - "anser": "^1.4.1", 5317 - "escape-carriage": "^1.2.0" 5318 - }, 5319 - "peerDependencies": { 5320 - "react": "^16.2.0", 5321 - "react-dom": "^16.2.0" 5322 - } 5323 - }, 5324 5248 "node_modules/any-promise": { 5325 5249 "version": "1.3.0", 5326 5250 "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", ··· 5987 5911 "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", 5988 5912 "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", 5989 5913 "dev": true, 5990 - "license": "MIT" 5991 - }, 5992 - "node_modules/base16": { 5993 - "version": "1.0.0", 5994 - "resolved": "https://registry.npmjs.org/base16/-/base16-1.0.0.tgz", 5995 - "integrity": "sha1-4pf2DX7BAUp6lxo568ipjAtoHnA= sha512-pNdYkNPiJUnEhnfXV56+sQy8+AaPcG3POZAUnwr4EeqCUZFz4u2PePbo3e5Gj4ziYPCWGUZT9RHisvJKnwFuBQ==", 5996 5914 "license": "MIT" 5997 5915 }, 5998 5916 "node_modules/base64-arraybuffer": { ··· 6856 6774 "license": "MIT", 6857 6775 "engines": { 6858 6776 "node": ">=4.0.0" 6859 - } 6860 - }, 6861 - "node_modules/commonmark-react-renderer": { 6862 - "version": "4.3.5", 6863 - "resolved": "https://registry.npmjs.org/commonmark-react-renderer/-/commonmark-react-renderer-4.3.5.tgz", 6864 - "integrity": "sha512-UwUgplz8kFSMCe9+Dg/BcV75lc7R/V6mvMYJq2p29i5aaIBd0252k9HeSGa2VtEPHfg2/trS9qC7iAxnO7r6ng==", 6865 - "license": "MIT", 6866 - "dependencies": { 6867 - "lodash.assign": "^4.2.0", 6868 - "lodash.isplainobject": "^4.0.6", 6869 - "pascalcase": "^0.1.1", 6870 - "xss-filters": "^1.2.6" 6871 - }, 6872 - "peerDependencies": { 6873 - "commonmark": "^0.27.0 || ^0.26.0 || ^0.24.0", 6874 - "react": ">=0.14.0" 6875 6777 } 6876 6778 }, 6877 6779 "node_modules/compare-version": { ··· 9179 9081 "once": "^1.4.0" 9180 9082 } 9181 9083 }, 9182 - "node_modules/entities": { 9183 - "version": "1.1.2", 9184 - "resolved": "https://registry.npmjs.org/entities/-/entities-1.1.2.tgz", 9185 - "integrity": "sha512-f2LZMYl1Fzu7YSBKg+RoROelpOaNrcGmE9AZubeDfrCEia483oW4MI4VyFd5VNHIgQ/7qm1I0wUHK1eJnn2y2w==", 9186 - "license": "BSD-2-Clause" 9187 - }, 9188 9084 "node_modules/env-paths": { 9189 9085 "version": "2.2.1", 9190 9086 "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", ··· 9520 9416 "engines": { 9521 9417 "node": ">=6" 9522 9418 } 9523 - }, 9524 - "node_modules/escape-carriage": { 9525 - "version": "1.3.1", 9526 - "resolved": "https://registry.npmjs.org/escape-carriage/-/escape-carriage-1.3.1.tgz", 9527 - "integrity": "sha512-GwBr6yViW3ttx1kb7/Oh+gKQ1/TrhYwxKqVmg5gS+BK+Qe2KrOa/Vh7w3HPBvgGf0LfcDGoY9I6NHKoA5Hozhw==", 9528 - "license": "MIT" 9529 9419 }, 9530 9420 "node_modules/escape-string-regexp": { 9531 9421 "version": "4.0.0", ··· 12967 12857 "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", 12968 12858 "license": "MIT" 12969 12859 }, 12970 - "node_modules/lodash.assign": { 12971 - "version": "4.2.0", 12972 - "resolved": "https://registry.npmjs.org/lodash.assign/-/lodash.assign-4.2.0.tgz", 12973 - "integrity": "sha512-hFuH8TY+Yji7Eja3mGiuAxBqLagejScbG8GbG0j6o9vzn0YL14My+ktnqtZgFTosKymC9/44wP6s7xyuLfnClw==", 12974 - "license": "MIT" 12975 - }, 12976 - "node_modules/lodash.curry": { 12977 - "version": "4.1.1", 12978 - "resolved": "https://registry.npmjs.org/lodash.curry/-/lodash.curry-4.1.1.tgz", 12979 - "integrity": "sha1-JI42By7ekGUB11lmIAqG2riyMXA= sha512-/u14pXGviLaweY5JI0IUzgzF2J6Ne8INyzAZjImcryjgkZ+ebruBxy2/JaOOkTqScddcYtakjhSaeemV8lR0tA==", 12980 - "license": "MIT" 12981 - }, 12982 12860 "node_modules/lodash.escaperegexp": { 12983 12861 "version": "4.1.2", 12984 12862 "resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", 12985 12863 "integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==", 12986 12864 "license": "MIT" 12987 12865 }, 12988 - "node_modules/lodash.flow": { 12989 - "version": "3.5.0", 12990 - "resolved": "https://registry.npmjs.org/lodash.flow/-/lodash.flow-3.5.0.tgz", 12991 - "integrity": "sha1-h79AKSuM+D5OjOGjrkIJ4gBxZ1o= sha512-ff3BX/tSioo+XojX4MOsOMhJw0nZoUEF011LX8g8d3gvjVbxd89cCio4BCXronjxcTUIJUoqKEUA+n4CqvvRPw==", 12992 - "license": "MIT" 12993 - }, 12994 12866 "node_modules/lodash.isequal": { 12995 12867 "version": "4.5.0", 12996 12868 "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", 12997 12869 "integrity": "sha1-QVxEePK8wwEgwizhDtMib30+GOA= sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", 12998 12870 "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", 12999 - "license": "MIT" 13000 - }, 13001 - "node_modules/lodash.isplainobject": { 13002 - "version": "4.0.6", 13003 - "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", 13004 - "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", 13005 12871 "license": "MIT" 13006 12872 }, 13007 12873 "node_modules/lodash.merge": { ··· 13460 13326 "node": ">=0.10.0" 13461 13327 } 13462 13328 }, 13463 - "node_modules/mathjax-electron": { 13464 - "version": "2.0.1", 13465 - "resolved": "https://registry.npmjs.org/mathjax-electron/-/mathjax-electron-2.0.1.tgz", 13466 - "integrity": "sha512-bllJaZZUccbj1ReD9i0V6qwu27dZXbd7TG/Wy3M7F10NLEjl8yN0WgFFP9uYf35s0hkody6wSPO96txr68TOqg==", 13467 - "license": "MIT" 13468 - }, 13469 13329 "node_modules/mathml-tag-names": { 13470 13330 "version": "4.0.0", 13471 13331 "resolved": "https://registry.npmjs.org/mathml-tag-names/-/mathml-tag-names-4.0.0.tgz", ··· 13483 13343 "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", 13484 13344 "dev": true, 13485 13345 "license": "CC0-1.0" 13486 - }, 13487 - "node_modules/mdurl": { 13488 - "version": "1.0.1", 13489 - "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-1.0.1.tgz", 13490 - "integrity": "sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g==", 13491 - "license": "MIT" 13492 13346 }, 13493 13347 "node_modules/meow": { 13494 13348 "version": "14.0.0", ··· 14614 14468 "url": "https://github.com/fb55/entities?sponsor=1" 14615 14469 } 14616 14470 }, 14617 - "node_modules/pascalcase": { 14618 - "version": "0.1.1", 14619 - "resolved": "https://registry.npmjs.org/pascalcase/-/pascalcase-0.1.1.tgz", 14620 - "integrity": "sha512-XHXfu/yOQRy9vYOtUDVMN60OEJjW013GoObG1o+xwQTpB9eYJX/BjXMsdW13ZDPruFhYYn0AG22w0xgQMwl3Nw==", 14621 - "license": "MIT", 14622 - "engines": { 14623 - "node": ">=0.10.0" 14624 - } 14625 - }, 14626 14471 "node_modules/path-exists": { 14627 14472 "version": "4.0.0", 14628 14473 "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", ··· 15298 15143 "node": ">=6" 15299 15144 } 15300 15145 }, 15301 - "node_modules/pure-color": { 15302 - "version": "1.3.0", 15303 - "resolved": "https://registry.npmjs.org/pure-color/-/pure-color-1.3.0.tgz", 15304 - "integrity": "sha1-H+Bk+wrIUfDeYTIKi/eWg2Qi8z4= sha512-QFADYnsVoBMw1srW7OVKEYjG+MbIa49s54w1MA1EDY6r2r/sTcKKYqRX1f4GYvnXP7eN/Pe9HFcX+hwzmrXRHA==", 15305 - "license": "MIT" 15146 + "node_modules/pyodide": { 15147 + "version": "0.29.3", 15148 + "resolved": "https://registry.npmjs.org/pyodide/-/pyodide-0.29.3.tgz", 15149 + "integrity": "sha512-22UBuhOJawj7vKUnS7/F3xK+515LJdjiMAHoCfuS6/PbHiOrSQVnYwDe+2sbVwiOZ3sMMexdXICew6NqOMQGgA==", 15150 + "license": "MPL-2.0", 15151 + "dependencies": { 15152 + "@types/emscripten": "^1.41.4", 15153 + "ws": "^8.5.0" 15154 + }, 15155 + "engines": { 15156 + "node": ">=18.0.0" 15157 + } 15306 15158 }, 15307 15159 "node_modules/qified": { 15308 15160 "version": "0.6.0", ··· 15525 15377 "node": ">=0.10.0" 15526 15378 } 15527 15379 }, 15528 - "node_modules/react-base16-styling": { 15529 - "version": "0.5.3", 15530 - "resolved": "https://registry.npmjs.org/react-base16-styling/-/react-base16-styling-0.5.3.tgz", 15531 - "integrity": "sha1-OFjyTpxN2MvT9wLz901YHKKRcmk= sha512-EPuchwVvYPSFFIjGpH0k6wM0HQsmJ0vCk7BSl5ryxMVFIWW4hX4Kksu4PNtxfgOxDebTLkJQ8iC7zwAql0eusg==", 15532 - "license": "MIT", 15533 - "dependencies": { 15534 - "base16": "^1.0.0", 15535 - "lodash.curry": "^4.0.1", 15536 - "lodash.flow": "^3.3.0", 15537 - "pure-color": "^1.2.0" 15538 - } 15539 - }, 15540 15380 "node_modules/react-dom": { 15541 15381 "version": "18.3.1", 15542 15382 "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", ··· 15555 15395 "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", 15556 15396 "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", 15557 15397 "license": "MIT" 15558 - }, 15559 - "node_modules/react-json-tree": { 15560 - "version": "0.11.2", 15561 - "resolved": "https://registry.npmjs.org/react-json-tree/-/react-json-tree-0.11.2.tgz", 15562 - "integrity": "sha512-aYhUPj1y5jR3ZQ+G3N7aL8FbTyO03iLwnVvvEikLcNFqNTyabdljo9xDftZndUBFyyyL0aK3qGO9+8EilILHUw==", 15563 - "license": "MIT", 15564 - "dependencies": { 15565 - "babel-runtime": "^6.6.1", 15566 - "prop-types": "^15.5.8", 15567 - "react-base16-styling": "^0.5.1" 15568 - }, 15569 - "peerDependencies": { 15570 - "react": "^15.0.0 || ^16.0.0", 15571 - "react-dom": "^15.0.0 || ^16.0.0" 15572 - } 15573 15398 }, 15574 15399 "node_modules/react-lifecycles-compat": { 15575 15400 "version": "3.0.4", ··· 17112 16937 "funding": { 17113 16938 "url": "https://github.com/sponsors/ljharb" 17114 16939 } 17115 - }, 17116 - "node_modules/string.prototype.repeat": { 17117 - "version": "0.2.0", 17118 - "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-0.2.0.tgz", 17119 - "integrity": "sha512-1BH+X+1hSthZFW+X+JaUkjkkUPwIlLEMJBLANN3hOob3RhEk5snLWNECDnYbgn/m5c5JV7Ersu1Yubaf+05cIA==" 17120 16940 }, 17121 16941 "node_modules/string.prototype.trim": { 17122 16942 "version": "1.2.10", ··· 19363 19183 "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", 19364 19184 "dev": true, 19365 19185 "license": "MIT" 19366 - }, 19367 - "node_modules/xss-filters": { 19368 - "version": "1.2.7", 19369 - "resolved": "https://registry.npmjs.org/xss-filters/-/xss-filters-1.2.7.tgz", 19370 - "integrity": "sha512-KzcmYT/f+YzcYrYRqw6mXxd25BEZCxBQnf+uXTopQDIhrmiaLwO+f+yLsIvvNlPhYvgff8g3igqrBxYh9k8NbQ==" 19371 19186 }, 19372 19187 "node_modules/xtend": { 19373 19188 "version": "4.0.2",
+4 -3
package.json
··· 15 15 "package-mac": "npm run build && electron-builder build --mac", 16 16 "package-linux": "npm run build && electron-builder build --linux", 17 17 "package-win": "npm run build && electron-builder build --win --x64", 18 - "postinstall": "electron-builder install-app-deps && node internals/scripts/InstallPyodide.js && node internals/scripts/patchDeps.mjs", 18 + "postinstall": "electron-builder install-app-deps && node internals/scripts/InstallPyodide.mjs && node internals/scripts/InstallMNE.mjs && node internals/scripts/patchDeps.mjs", 19 + "install-pyodide": "node internals/scripts/InstallPyodide.mjs && node internals/scripts/InstallMNE.mjs", 19 20 "lint": "cross-env NODE_ENV=development eslint . --cache", 20 21 "lint-fix": "npm run lint -- --fix", 21 22 "lint-styles": "stylelint '**/*.*(css|scss)'", ··· 97 98 "filter": "**/*" 98 99 }, 99 100 { 100 - "from": "./src/renderer/utils/pyodide/src/", 101 + "from": "./src/renderer/utils/webworker/src/", 101 102 "to": "pyodide", 102 103 "filter": "**/*" 103 104 } ··· 185 186 "@electron-toolkit/utils": "^4.0.0", 186 187 "@fortawesome/fontawesome-free": "^5.13.0", 187 188 "@neurosity/pipes": "^5.2.1", 188 - "@nteract/transforms": "^3.2.0", 189 189 "@radix-ui/react-dialog": "^1.1.0", 190 190 "@radix-ui/react-dropdown-menu": "^2.1.0", 191 191 "@radix-ui/react-select": "^2.2.6", ··· 208 208 "papaparse": "^5.5.3", 209 209 "pathe": "^2.0.3", 210 210 "plotly.js": "^3.4.0", 211 + "pyodide": "^0.29.3", 211 212 "rc-slider": "9.2.4", 212 213 "react": "^18.x", 213 214 "react-dom": "^18.x",
+76 -4
src/main/index.ts
··· 8 8 import { app, BrowserWindow, ipcMain, dialog, shell, session } from 'electron'; 9 9 import path from 'path'; 10 10 import fs from 'fs'; 11 + import http from 'http'; 11 12 import os from 'os'; 12 13 import Papa from 'papaparse'; 13 14 import mkdirp from 'mkdirp'; ··· 23 24 'true' 24 25 ); 25 26 27 + // Port for the local pyodide asset server (serves whl files to web workers, 28 + // bypassing Vite's dev server which returns HTML for all fetch() requests). 29 + const PYODIDE_ASSET_PORT = 17173; 30 + 31 + const PYODIDE_CONTENT_TYPES: Record<string, string> = { 32 + '.json': 'application/json', 33 + '.whl': 'application/zip', 34 + '.zip': 'application/zip', 35 + '.wasm': 'application/wasm', 36 + '.js': 'application/javascript', 37 + '.mjs': 'application/javascript', 38 + }; 39 + 40 + function startPyodideAssetServer(rootDir: string): void { 41 + const server = http.createServer((req, res) => { 42 + res.setHeader('Access-Control-Allow-Origin', '*'); 43 + res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS'); 44 + if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; } 45 + 46 + const urlPath = (req.url || '').split('?')[0]; 47 + const filePath = path.join(rootDir, urlPath); 48 + const ext = path.extname(filePath).toLowerCase(); 49 + 50 + fs.stat(filePath, (statErr, stat) => { 51 + if (statErr || !stat.isFile()) { 52 + res.writeHead(404, { 'Content-Type': 'text/plain' }); 53 + res.end(`Not found: ${urlPath}`); 54 + return; 55 + } 56 + res.setHeader('Content-Type', PYODIDE_CONTENT_TYPES[ext] || 'application/octet-stream'); 57 + res.setHeader('Content-Length', stat.size); 58 + res.setHeader('Cache-Control', 'no-cache'); 59 + res.writeHead(200); 60 + fs.createReadStream(filePath).pipe(res); 61 + }); 62 + }); 63 + 64 + server.listen(PYODIDE_ASSET_PORT, '127.0.0.1', () => { 65 + console.log(`[main] Pyodide asset server: http://127.0.0.1:${PYODIDE_ASSET_PORT}`); 66 + }); 67 + 68 + server.on('error', (err: NodeJS.ErrnoException) => { 69 + console.error('[main] Pyodide asset server error:', err.message); 70 + }); 71 + } 72 + 26 73 export default class AppUpdater { 27 74 constructor() { 28 75 log.transports.file.level = 'info'; ··· 98 145 try { 99 146 return fs 100 147 .readdirSync(workspaces) 101 - .filter((workspace) => workspace !== '.DS_Store'); 148 + .filter((workspace) => workspace !== '.DS_Store' && workspace !== 'Test_Plot'); 102 149 } catch (e: unknown) { 103 150 if ((e as NodeJS.ErrnoException).code === 'ENOENT') { 104 151 mkdirPathSync(workspaces); ··· 215 262 ); 216 263 217 264 ipcMain.handle( 218 - 'fs:storePyodideImage', 265 + 'fs:storePyodideImageSvg', 266 + (_event, title, imageTitle, svgContent: string) => { 267 + const dir = path.join(getWorkspaceDir(title), 'Results', 'Images'); 268 + mkdirPathSync(dir); 269 + return new Promise<void>((resolve, reject) => { 270 + fs.writeFile(path.join(dir, `${imageTitle}.svg`), svgContent, 'utf8', (err) => { 271 + if (err) reject(err); 272 + else resolve(); 273 + }); 274 + }); 275 + } 276 + ); 277 + 278 + ipcMain.handle( 279 + 'fs:storePyodideImagePng', 219 280 (_event, title, imageTitle, rawData: ArrayBuffer) => { 220 281 const dir = path.join(getWorkspaceDir(title), 'Results', 'Images'); 221 - const filename = `${imageTitle}.png`; 222 282 mkdirPathSync(dir); 223 283 const buffer = Buffer.from(rawData); 224 284 return new Promise<void>((resolve, reject) => { 225 - fs.writeFile(path.join(dir, filename), buffer, (err) => { 285 + fs.writeFile(path.join(dir, `${imageTitle}.png`), buffer, (err) => { 226 286 if (err) reject(err); 227 287 else resolve(); 228 288 }); ··· 453 513 }); 454 514 455 515 app.whenReady().then(async () => { 516 + // Serve pyodide assets (whl files, runtime files) via a local HTTP server so 517 + // web workers can fetch() them without hitting Vite's SPA fallback, which 518 + // returns HTML for ALL fetch() requests regardless of path. 519 + // Port 17173 is hardcoded and matched in webworker.js. 520 + // In dev: files are in src/renderer/utils/webworker/src/ 521 + // In prod: files are in resources/webworker/src/ (via extraResources) 522 + const pyodideRoot = is.dev 523 + ? path.join(app.getAppPath(), 'src/renderer/utils/webworker/src') 524 + : path.join(process.resourcesPath, 'webworker/src'); 525 + 526 + startPyodideAssetServer(pyodideRoot); 527 + 456 528 // Enable F12 devtools shortcut and Ctrl+R reload in dev, disable in prod 457 529 app.on('browser-window-created', (_, window) => { 458 530 optimizer.watchWindowShortcuts(window);
+9 -2
src/preload/index.ts
··· 88 88 session 89 89 ), 90 90 91 - storePyodideImage: ( 91 + storePyodideImageSvg: ( 92 + title: string, 93 + imageTitle: string, 94 + svgContent: string 95 + ): Promise<void> => 96 + ipcRenderer.invoke('fs:storePyodideImageSvg', title, imageTitle, svgContent), 97 + 98 + storePyodideImagePng: ( 92 99 title: string, 93 100 imageTitle: string, 94 101 rawData: ArrayBuffer 95 102 ): Promise<void> => 96 - ipcRenderer.invoke('fs:storePyodideImage', title, imageTitle, rawData), 103 + ipcRenderer.invoke('fs:storePyodideImagePng', title, imageTitle, rawData), 97 104 98 105 deleteWorkspaceDir: (title: string): Promise<void> => 99 106 ipcRenderer.invoke('fs:deleteWorkspaceDir', title),
+1
src/renderer/actions/pyodideActions.ts
··· 36 36 ReceiveMessage: createAction<any, 'RECEIVE_MESSAGE'>('RECEIVE_MESSAGE'), // Worker message event — shape is dynamic 37 37 // eslint-disable-next-line @typescript-eslint/no-explicit-any 38 38 ReceiveError: createAction<any, 'RECEIVE_ERROR'>('RECEIVE_ERROR'), // Worker error event — shape is dynamic 39 + SetWorkerReady: createAction('SET_WORKER_READY'), 39 40 } as const; 40 41 41 42 export type PyodideActionType = ActionType<
+4 -2
src/renderer/components/HomeComponent/index.tsx
··· 63 63 topoPlot: { 64 64 [key: string]: string; 65 65 }; 66 + isWorkerReady: boolean; 66 67 } 67 68 68 69 interface State { ··· 321 322 <div> 322 323 <Button 323 324 variant="default" 325 + disabled={!this.props.isWorkerReady} 324 326 onClick={() => this.props.PyodideActions.LoadTopo()} 325 327 > 326 - Generate Plot 328 + {this.props.isWorkerReady ? 'Generate Plot' : 'Loading libraries…'} 327 329 </Button> 328 330 </div> 329 331 <div> 330 332 <PyodidePlotWidget 331 - title={'Test Plot'} 333 + title={'Test_Plot'} 332 334 imageTitle={`Test-Topoplot`} 333 335 plotMIMEBundle={this.props.topoPlot} 334 336 />
+61 -63
src/renderer/components/PyodidePlotWidget.tsx
··· 1 1 import React, { Component } from 'react'; 2 + import { toast } from 'react-toastify'; 2 3 import { Button } from './ui/button'; 3 - import { 4 - richestMimetype, 5 - standardDisplayOrder, 6 - standardTransforms, 7 - } from '@nteract/transforms'; 8 - import { isNil } from 'lodash'; 9 - import { storePyodideImage } from '../utils/filesystem/storage'; 4 + import { storePyodideImageSvg, storePyodideImagePng } from '../utils/filesystem/storage'; 10 5 11 6 interface Props { 12 7 title: string; 13 8 imageTitle: string; 14 - plotMIMEBundle: 15 - | { 16 - [key: string]: string; 17 - } 18 - | null 19 - | undefined; 9 + plotMIMEBundle: { 'image/svg+xml': string } | null | undefined; 20 10 } 21 11 22 - interface State { 23 - rawData: string; 24 - mimeType: string; 12 + function svgToPngArrayBuffer(svg: string): Promise<ArrayBuffer> { 13 + return new Promise((resolve, reject) => { 14 + const blob = new Blob([svg], { type: 'image/svg+xml' }); 15 + const url = URL.createObjectURL(blob); 16 + const img = new Image(); 17 + img.onload = () => { 18 + const canvas = document.createElement('canvas'); 19 + canvas.width = img.naturalWidth; 20 + canvas.height = img.naturalHeight; 21 + const ctx = canvas.getContext('2d'); 22 + if (!ctx) { 23 + URL.revokeObjectURL(url); 24 + reject(new Error('No 2d context')); 25 + return; 26 + } 27 + ctx.drawImage(img, 0, 0); 28 + URL.revokeObjectURL(url); 29 + canvas.toBlob((pngBlob) => { 30 + if (!pngBlob) { reject(new Error('Canvas toBlob failed')); return; } 31 + pngBlob.arrayBuffer().then(resolve).catch(reject); 32 + }, 'image/png'); 33 + }; 34 + img.onerror = () => { URL.revokeObjectURL(url); reject(new Error('SVG load failed')); }; 35 + img.src = url; 36 + }); 25 37 } 26 38 27 - export default class PyodidePlotWidget extends Component<Props, State> { 28 - // state: State; 39 + export default class PyodidePlotWidget extends Component<Props> { 29 40 constructor(props: Props) { 30 41 super(props); 31 - this.state = { 32 - rawData: '', 33 - mimeType: '', 34 - }; 35 - this.handleSave = this.handleSave.bind(this); 36 - } 37 - 38 - componentDidUpdate(prevProps: Props) { 39 - if ( 40 - this.props.plotMIMEBundle !== prevProps.plotMIMEBundle && 41 - !isNil(this.props.plotMIMEBundle) 42 - ) { 43 - const bundle = this.props.plotMIMEBundle as { [key: string]: string }; 44 - const mimeType = richestMimetype( 45 - bundle, 46 - standardDisplayOrder, 47 - standardTransforms 48 - ); 49 - if (mimeType) { 50 - this.setState({ rawData: bundle[mimeType], mimeType }); 51 - } 52 - } 42 + this.handleSaveSvg = this.handleSaveSvg.bind(this); 43 + this.handleSavePng = this.handleSavePng.bind(this); 53 44 } 54 45 55 - handleSave() { 56 - const buf = Buffer.from(this.state.rawData, 'base64'); 57 - storePyodideImage( 58 - this.props.title, 59 - this.props.imageTitle, 60 - buf.buffer as ArrayBuffer 61 - ); 46 + handleSaveSvg() { 47 + const svg = this.props.plotMIMEBundle?.['image/svg+xml']; 48 + if (!svg) return; 49 + storePyodideImageSvg(this.props.title, this.props.imageTitle, svg) 50 + .then(() => toast.success(`Saved ${this.props.imageTitle}.svg`)) 51 + .catch((err) => toast.error(`Failed to save SVG: ${err.message}`)); 62 52 } 63 53 64 - renderResults() { 65 - if (this.state.rawData) { 66 - const Transform = standardTransforms[this.state.mimeType]; 67 - return <Transform data={this.state.rawData} />; 68 - } 69 - } 70 - 71 - renderSaveButton() { 72 - if (this.state.rawData) { 73 - return ( 74 - <Button variant="default" size="sm" onClick={this.handleSave}> 75 - Save Image 76 - </Button> 77 - ); 54 + async handleSavePng() { 55 + const svg = this.props.plotMIMEBundle?.['image/svg+xml']; 56 + if (!svg) return; 57 + try { 58 + const arrayBuffer = await svgToPngArrayBuffer(svg); 59 + await storePyodideImagePng(this.props.title, this.props.imageTitle, arrayBuffer); 60 + toast.success(`Saved ${this.props.imageTitle}.png`); 61 + } catch (err: unknown) { 62 + toast.error(`Failed to save PNG: ${(err as Error).message}`); 78 63 } 79 64 } 80 65 81 66 render() { 67 + const svg = this.props.plotMIMEBundle?.['image/svg+xml']; 68 + if (!svg) return <div className="p-2" />; 82 69 return ( 83 70 <div className="p-2"> 84 - {this.renderResults()} 85 - {this.renderSaveButton()} 71 + <img 72 + className="w-full h-auto" 73 + src={`data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`} 74 + alt={this.props.imageTitle} 75 + /> 76 + <div className="flex gap-2 mt-2"> 77 + <Button variant="outline" size="sm" onClick={this.handleSaveSvg}> 78 + Save as SVG 79 + </Button> 80 + <Button variant="outline" size="sm" onClick={this.handleSavePng}> 81 + Save as PNG 82 + </Button> 83 + </div> 86 84 </div> 87 85 ); 88 86 }
+28 -35
src/renderer/epics/pyodideEpics.ts
··· 1 1 import { combineEpics, Epic } from 'redux-observable'; 2 - import { fromEvent, Observable, ObservableInput, of } from 'rxjs'; 2 + import { EMPTY, fromEvent, Observable, ObservableInput, of } from 'rxjs'; 3 3 import { map, mergeMap, tap, pluck, filter } from 'rxjs/operators'; 4 4 import { toast } from 'react-toastify'; 5 5 import { isActionOf } from '../utils/redux'; ··· 23 23 loadPatches, 24 24 applyPatches, 25 25 loadUtils, 26 - } from '../utils/pyodide'; 26 + } from '../utils/webworker'; 27 27 import { 28 28 EMOTIV_CHANNELS, 29 29 DEVICES, 30 30 MUSE_CHANNELS, 31 31 PYODIDE_VARIABLE_NAMES, 32 32 } from '../constants/constants'; 33 - import { parseSingleQuoteJSON } from '../utils/pyodide/functions'; 33 + import { parseSingleQuoteJSON } from '../utils/webworker/functions'; 34 34 35 35 import { readFiles } from '../utils/filesystem/read'; 36 36 ··· 76 76 ); 77 77 78 78 // Once pyodide webworker is created, 79 - // Create an observable of events that corresond to what it retjurns 80 - // and then emite those events as redux actions 79 + // Create an observable of events that corresond to what it returns 80 + // and then emits those events as redux actions 81 81 const pyodideMessageEpic: Epic< 82 82 PyodideActionType, 83 83 PyodideActionType, ··· 87 87 filter(isActionOf(PyodideActions.SetPyodideWorker)), 88 88 pluck('payload'), 89 89 // eslint-disable-next-line @typescript-eslint/no-explicit-any 90 - mergeMap<Worker, Observable<any>>((worker) => { 91 - // Worker message event — MessageEvent data shape is dynamic 92 - return fromEvent(worker, 'message'); 93 - }), 94 - tap((e) => { 95 - console.log(e); 96 - const { results, error } = e.data; 97 - 98 - if (results && !error) { 99 - toast.error(`Pyodide: ${results}`); 100 - } else if (error) { 90 + mergeMap<Worker, Observable<any>>((worker) => fromEvent(worker, 'message')), 91 + // eslint-disable-next-line @typescript-eslint/no-explicit-any 92 + mergeMap<any, Observable<any>>((e) => { 93 + const { results, error, plotKey } = e.data; 94 + if (error) { 101 95 toast.error(`Pyodide: ${error}`); 96 + return of(PyodideActions.ReceiveError(error)); 102 97 } 103 - }), 104 - map(PyodideActions.ReceiveMessage) 98 + // Route plot results to the appropriate Redux state slot. 99 + // results is a base64-encoded PNG string returned from Python. 100 + const mimeBundle = results ? { 'image/svg+xml': results } : null; 101 + switch (plotKey) { 102 + case 'ready': return of(PyodideActions.SetWorkerReady()); 103 + case 'topo': return of(PyodideActions.SetTopoPlot(mimeBundle)); 104 + case 'psd': return of(PyodideActions.SetPSDPlot(mimeBundle)); 105 + case 'erp': return of(PyodideActions.SetERPPlot(mimeBundle)); 106 + default: return of(PyodideActions.ReceiveMessage(e.data)); 107 + } 108 + }) 105 109 ); 106 110 107 111 const loadEpochsEpic: Epic<PyodideActionType, PyodideActionType, RootState> = ( ··· 225 229 ) => 226 230 action$.pipe( 227 231 filter(isActionOf(PyodideActions.LoadPSD)), 228 - mergeMap(() => plotPSD(state$.value.pyodide.worker!)), 229 - map(PyodideActions.SetPSDPlot) 232 + tap(() => plotPSD(state$.value.pyodide.worker!)), 233 + mergeMap(() => EMPTY) 230 234 ); 231 235 232 236 const loadTopoEpic: Epic<PyodideActionType, PyodideActionType, RootState> = ( ··· 235 239 ) => 236 240 action$.pipe( 237 241 filter(isActionOf(PyodideActions.LoadTopo)), 238 - // mergeMap(plotTopoMap), 239 - mergeMap(() => plotTestPlot(state$.value.pyodide.worker!)), 240 - tap((e) => console.log('received topo map: ', e)), 241 - mergeMap((topoPlot) => 242 - of( 243 - PyodideActions.SetTopoPlot(topoPlot) 244 - // PyodideActions.LoadERP( 245 - // state$.value.device.deviceType === DEVICES.EMOTIV 246 - // ? EMOTIV_CHANNELS[0] 247 - // : MUSE_CHANNELS[0] 248 - // ) 249 - ) 250 - ) 242 + tap(() => plotTestPlot(state$.value.pyodide.worker!)), 243 + mergeMap(() => EMPTY) 251 244 ); 252 245 253 246 const loadERPEpic: Epic<PyodideActionType, PyodideActionType, RootState> = ( ··· 273 266 ); 274 267 return parseInt(EMOTIV_CHANNELS[0], 10); 275 268 }), 276 - mergeMap((chanIndex) => plotERP(state$.value.pyodide.worker!, chanIndex)), 277 - map(PyodideActions.SetERPPlot) 269 + tap((chanIndex) => plotERP(state$.value.pyodide.worker!, chanIndex)), 270 + mergeMap(() => EMPTY) 278 271 ); 279 272 280 273 export default combineEpics(
+1 -1
src/renderer/index.html
··· 4 4 <meta charset="utf-8" /> 5 5 <meta 6 6 http-equiv="Content-Security-Policy" 7 - content="default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: blob:; connect-src 'self' ws: wss: webpack:; font-src 'self' data: https://fonts.gstatic.com; worker-src blob: 'self';" 7 + content="default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: blob:; connect-src 'self' ws: wss: webpack: http://127.0.0.1:17173; font-src 'self' data: https://fonts.gstatic.com; worker-src blob: 'self';" 8 8 /> 9 9 <title>BrainWaves</title> 10 10 </head>
+5
src/renderer/reducers/pyodideReducer.ts
··· 25 25 | null 26 26 | undefined; 27 27 readonly worker: Worker | null; 28 + readonly isWorkerReady: boolean; 28 29 } 29 30 30 31 const initialState: PyodideStateType = { ··· 34 35 topoPlot: null, 35 36 erpPlot: null, 36 37 worker: null, 38 + isWorkerReady: false, 37 39 }; 38 40 39 41 export default createReducer(initialState, (builder) => ··· 73 75 ...state, 74 76 erpPlot: action.payload, 75 77 }; 78 + }) 79 + .addCase(PyodideActions.SetWorkerReady, (state) => { 80 + return { ...state, isWorkerReady: true }; 76 81 }) 77 82 .addCase(ExperimentActions.ExperimentCleanup, (state, action) => { 78 83 return {
+8 -2
src/renderer/utils/filesystem/storage.ts
··· 50 50 ): Promise<void> => 51 51 api().storeBehavioralData(csv, title, subject, group, session); 52 52 53 - export const storePyodideImage = ( 53 + export const storePyodideImageSvg = ( 54 + title: string, 55 + imageTitle: string, 56 + svgContent: string 57 + ): Promise<void> => api().storePyodideImageSvg(title, imageTitle, svgContent); 58 + 59 + export const storePyodideImagePng = ( 54 60 title: string, 55 61 imageTitle: string, 56 62 rawData: ArrayBuffer 57 - ): Promise<void> => api().storePyodideImage(title, imageTitle, rawData); 63 + ): Promise<void> => api().storePyodideImagePng(title, imageTitle, rawData); 58 64 59 65 // ----------------------------------------------------------------------------------------------- 60 66 // Reading
src/renderer/utils/pyodide/functions.ts src/renderer/utils/webworker/functions.ts
+51 -16
src/renderer/utils/pyodide/index.ts src/renderer/utils/webworker/index.ts
··· 12 12 // Imports and Utility functions 13 13 14 14 export const loadPyodide = async () => { 15 - // Classic worker (importScripts used inside cannot run in module workers) 16 - const freshWorker = new Worker(new URL('./webworker.js', import.meta.url)); 15 + // Module worker — required for Pyodide 0.26+ which ships pyodide.mjs as ESM. 16 + const freshWorker = new Worker(new URL('./webworker.js', import.meta.url), { 17 + type: 'module', 18 + }); 17 19 return freshWorker; 18 20 }; 19 21 ··· 30 32 export const loadUtils = async (worker: Worker) => 31 33 worker.postMessage({ 32 34 data: utilsPy, 35 + plotKey: 'ready', 33 36 }); 34 37 35 38 export const loadCSV = async (worker: Worker, csvArray: Array<unknown>) => { 36 39 // TODO: Pass attached variable name as parameter to load_data 37 - // @ts-expect-error 38 - window.csvArray = csvArray; 39 - await worker.postMessage({ data: `raw = load_data()` }); 40 + await worker.postMessage({ data: `raw = load_data()`, csvArray }); 40 41 }; 41 42 42 43 // --------------------------- ··· 112 113 }; 113 114 114 115 export const plotPSD = async (worker: Worker) => { 115 - return worker.postMessage({ data: `raw.plot_psd(fmin=1, fmax=30)` }); 116 + worker.postMessage({ 117 + plotKey: 'psd', 118 + data: [ 119 + 'import io', 120 + '_fig = raw.plot_psd(fmin=1, fmax=30, show=False)', 121 + '_buf = io.BytesIO()', 122 + '_fig.savefig(_buf, format="svg", bbox_inches="tight")', 123 + 'plt.close(_fig)', 124 + '_buf.getvalue().decode()', 125 + ].join('\n'), 126 + }); 116 127 }; 117 128 118 129 export const plotTopoMap = async (worker: Worker) => { 119 - return worker.postMessage({ 120 - data: `plot_topo(clean_epochs, conditions)`, 130 + worker.postMessage({ 131 + plotKey: 'topo', 132 + data: [ 133 + 'import io', 134 + '_fig = plot_topo(clean_epochs, conditions)', 135 + '_buf = io.BytesIO()', 136 + '_fig.savefig(_buf, format="svg", bbox_inches="tight")', 137 + 'plt.close(_fig)', 138 + '_buf.getvalue().decode()', 139 + ].join('\n'), 121 140 }); 122 141 }; 123 142 124 143 export const plotTestPlot = async (worker: Worker | null) => { 125 - if (!worker) { 126 - return; 127 - } 128 - return worker.postMessage({ 129 - data: `import matplotlib.pyplot as plt; fig= plt.plot([1,2,3,4])`, 144 + if (!worker) return; 145 + worker.postMessage({ 146 + plotKey: 'topo', 147 + data: [ 148 + 'import io', 149 + 'import matplotlib.pyplot as plt', 150 + '_fig, _ax = plt.subplots()', 151 + '_ax.plot([1, 2, 3, 4], [1, 4, 2, 3])', 152 + '_ax.set_title("Test Plot")', 153 + '_buf = io.BytesIO()', 154 + '_fig.savefig(_buf, format="svg", bbox_inches="tight")', 155 + 'plt.close(_fig)', 156 + '_buf.getvalue().decode()', 157 + ].join('\n'), 130 158 }); 131 159 }; 132 160 133 161 export const plotERP = async (worker: Worker, channelIndex: number) => { 134 - return worker.postMessage({ 135 - data: `X, y = plot_conditions(clean_epochs, ch_ind=${channelIndex}, conditions=conditions, 136 - ci=97.5, n_boot=1000, title='', diff_waveform=None)`, 162 + worker.postMessage({ 163 + plotKey: 'erp', 164 + data: [ 165 + 'import io', 166 + `_fig, _ = plot_conditions(clean_epochs, ch_ind=${channelIndex}, conditions=conditions, ci=97.5, n_boot=1000, title='', diff_waveform=None)`, 167 + '_buf = io.BytesIO()', 168 + '_fig.savefig(_buf, format="svg", bbox_inches="tight")', 169 + 'plt.close(_fig)', 170 + '_buf.getvalue().decode()', 171 + ].join('\n'), 137 172 }); 138 173 }; 139 174
-57
src/renderer/utils/pyodide/patches.py
··· 1 - # patch implemented in Pyolite 2 - # https://github.com/jupyterlite/jupyterlite/blob/0d563b9a4cca4b54411229128cb51ac4ba333c8f/packages/pyolite-kernel/py/pyolite/pyolite/patches.py 3 - def patch_matplotlib(): 4 - import os 5 - from io import BytesIO 6 - 7 - # before importing matplotlib 8 - # to avoid the wasm backend (which needs `js.document`, not available in worker) 9 - os.environ["MPLBACKEND"] = "AGG" 10 - 11 - import matplotlib.pyplot 12 - from IPython.display import display 13 - 14 - from .display import Image 15 - 16 - _old_show = matplotlib.pyplot.show 17 - assert _old_show, "matplotlib.pyplot.show" 18 - 19 - def show(): 20 - buf = BytesIO() 21 - matplotlib.pyplot.savefig(buf, format="png") 22 - buf.seek(0) 23 - display(Image(buf.read())) 24 - matplotlib.pyplot.clf() 25 - 26 - matplotlib.pyplot.show = show 27 - 28 - 29 - def patch_pillow(): 30 - import base64 31 - 32 - from PIL import Image as PILImage 33 - 34 - _old_repr_png = PILImage.Image._repr_png_ 35 - assert _old_repr_png 36 - 37 - def _repr_png_(self): 38 - byte = _old_repr_png(self) 39 - return base64.b64encode(byte).decode("utf-8") 40 - 41 - PILImage.Image._repr_png_ = _repr_png_ 42 - 43 - 44 - ALL_PATCHES = [ 45 - patch_pillow, 46 - patch_matplotlib, 47 - ] 48 - 49 - 50 - def apply_patches(): 51 - import warnings 52 - 53 - for patch in ALL_PATCHES: 54 - try: 55 - patch() 56 - except Exception as err: 57 - warnings.warn("faield to apply patch", patch, err)
src/renderer/utils/pyodide/utils.py src/renderer/utils/webworker/utils.py
-34
src/renderer/utils/pyodide/webworker.js
··· 1 - /** 2 - * This file has been copied from pyodide source and modified to allow 3 - * pyodide to be used in a web worker within this 4 - */ 5 - 6 - // pyodide is served as a static asset at /pyodide/ (via Vite publicDir). 7 - // An absolute path is required so importScripts resolves correctly regardless 8 - // of where the worker script itself is served from. 9 - importScripts('/pyodide/pyodide.js'); 10 - 11 - async function loadPyodideAndPackages() { 12 - self.pyodide = await loadPyodide({ indexURL: '/pyodide/' }); 13 - await self.pyodide.loadPackage(['matplotlib', 'mne', 'pandas']); 14 - } 15 - let pyodideReadyPromise = loadPyodideAndPackages(); 16 - 17 - self.onmessage = async (event) => { 18 - // make sure loading is done 19 - await pyodideReadyPromise; 20 - // Don't bother yet with this line, suppose our API is built in such a way: 21 - const { data, ...context } = event.data; 22 - // The worker copies the context in its own "memory" (an object mapping name to values) 23 - for (const key of Object.keys(context)) { 24 - self[key] = context[key]; 25 - } 26 - // Now is the easy part, the one that is similar to working in the main thread: 27 - try { 28 - self.postMessage({ 29 - results: await self.pyodide.runPythonAsync(data), 30 - }); 31 - } catch (error) { 32 - self.postMessage({ error: error.message }); 33 - } 34 - };
+28
src/renderer/utils/webworker/patches.py
··· 1 + def patch_pillow(): 2 + import base64 3 + 4 + from PIL import Image as PILImage 5 + 6 + _old_repr_png = PILImage.Image._repr_png_ 7 + assert _old_repr_png 8 + 9 + def _repr_png_(self): 10 + byte = _old_repr_png(self) 11 + return base64.b64encode(byte).decode("utf-8") 12 + 13 + PILImage.Image._repr_png_ = _repr_png_ 14 + 15 + 16 + ALL_PATCHES = [ 17 + patch_pillow, 18 + ] 19 + 20 + 21 + def apply_patches(): 22 + import warnings 23 + 24 + for patch in ALL_PATCHES: 25 + try: 26 + patch() 27 + except Exception as err: 28 + warnings.warn("failed to apply patch", patch, err)
+103
src/renderer/utils/webworker/webworker.js
··· 1 + /** 2 + * Pyodide Web Worker — local node_modules implementation. 3 + * 4 + * Loading strategy 5 + * ---------------- 6 + * pyodide.mjs is imported via Vite's `?url` suffix, which gives us an 7 + * /@fs/... URL in dev. We use dynamic import() from that URL — this works 8 + * because import() bypasses Vite's SPA fallback (only fetch() is affected). 9 + * 10 + * The lock file is embedded via `?raw` to avoid an HTTP fetch that Vite 11 + * intercepts. A blob URL is created from the embedded JSON so loadPyodide 12 + * can "fetch" it from memory. 13 + * 14 + * Package whl files (numpy, scipy, etc.) live in 15 + * src/renderer/utils/webworker/src/pyodide/ and are served by a tiny Node.js 16 + * HTTP server on port 17173 started in the Electron main process. This bypasses 17 + * Vite's dev server, which returns HTML (SPA fallback) for ALL fetch() requests 18 + * from web workers, including /@fs/ and publicDir paths. 19 + * 20 + * MNE and its pure-Python deps are installed via micropip from local .whl 21 + * files served by the same pyodide-asset:// protocol under /packages/. 22 + */ 23 + 24 + // ?url → Vite resolves to /@fs/... in dev; asset URL in prod. 25 + // ?raw → Vite embeds file content as a string (no HTTP fetch at runtime). 26 + import pyodideMjsUrl from 'pyodide/pyodide.mjs?url'; 27 + import lockFileRaw from 'pyodide/pyodide-lock.json?raw'; 28 + 29 + // A tiny Node.js HTTP server on port 17173 (started in the Electron main 30 + // process) serves pyodide assets from src/renderer/utils/webworker/src/. 31 + // This bypasses Vite's dev server, which returns index.html (SPA fallback) 32 + // for ALL fetch() requests from web workers, including /@fs/ and publicDir paths. 33 + const PYODIDE_ASSET_BASE = 'http://127.0.0.1:17173'; 34 + 35 + const pyodideReadyPromise = (async () => { 36 + const { loadPyodide } = await import(/* @vite-ignore */ pyodideMjsUrl); 37 + 38 + // Wrap the embedded lock file in a blob URL so loadPyodide can "fetch" it 39 + // without making an HTTP request that Vite would intercept and transform. 40 + const lockBlob = new Blob([lockFileRaw], { type: 'application/json' }); 41 + const lockFileURL = URL.createObjectURL(lockBlob); 42 + 43 + // packageBaseUrl tells pyodide's PackageManager where to fetch .whl files. 44 + // This is the correct option — NOT indexURL, which is for the runtime files 45 + // (WASM, stdlib) that are already loaded via import.meta.url from node_modules. 46 + const packageBaseUrl = `${PYODIDE_ASSET_BASE}/pyodide/`; 47 + 48 + const pyodide = await loadPyodide({ lockFileURL, packageBaseUrl }); 49 + URL.revokeObjectURL(lockFileURL); 50 + 51 + // Load scientific packages from local whl files via the asset server. 52 + // checkIntegrity: false skips SHA256 verification — hashes in the npm lock 53 + // file may not match the CDN-downloaded whl files we're actually serving. 54 + await pyodide.loadPackage( 55 + ['numpy', 'scipy', 'matplotlib', 'pandas', 'pillow'], 56 + { checkIntegrity: false } 57 + ); 58 + 59 + // Set matplotlib backend before any imports so it takes effect on first import. 60 + // Must be 'agg' (non-interactive, buffer-based) — web workers have no DOM, 61 + // so WebAgg fails with "cannot import name 'document' from 'js'". 62 + await pyodide.runPythonAsync( 63 + 'import os; os.environ["MPLBACKEND"] = "agg"' 64 + ); 65 + 66 + // Load micropip so we can install MNE and its pure-Python deps. 67 + await pyodide.loadPackage('micropip', { checkIntegrity: false }); 68 + const micropip = pyodide.pyimport('micropip'); 69 + 70 + // MNE + pure-Python deps are served from /packages/ via pyodide-asset://. 71 + const manifestUrl = `${PYODIDE_ASSET_BASE}/packages/manifest.json`; 72 + const manifest = await fetch(manifestUrl).then((r) => r.json()); 73 + const whlUrls = Object.values(manifest).map( 74 + ({ filename }) => `${PYODIDE_ASSET_BASE}/packages/${filename}` 75 + ); 76 + await micropip.install(whlUrls); 77 + 78 + return pyodide; 79 + })(); 80 + 81 + self.onmessage = async (event) => { 82 + // Propagate init failures back to the main thread rather than hanging silently. 83 + let pyodide; 84 + try { 85 + pyodide = await pyodideReadyPromise; 86 + } catch (error) { 87 + self.postMessage({ error: `Pyodide init failed: ${error.message}` }); 88 + return; 89 + } 90 + 91 + const { data, plotKey, ...context } = event.data; 92 + 93 + // Expose context values as globals so Python can access them via the js module. 94 + for (const [key, value] of Object.entries(context)) { 95 + self[key] = value; 96 + } 97 + 98 + try { 99 + self.postMessage({ results: await pyodide.runPythonAsync(data), plotKey }); 100 + } catch (error) { 101 + self.postMessage({ error: error.message, plotKey }); 102 + } 103 + };
+50 -5
vite.config.ts
··· 1 1 import { defineConfig } from 'electron-vite'; 2 2 import react from '@vitejs/plugin-react'; 3 3 import path from 'path'; 4 + import fs from 'node:fs'; 4 5 import { createRequire } from 'module'; 5 6 const _require = createRequire(import.meta.url); 6 7 ··· 44 45 // ------------------------------------------------------------------ 45 46 renderer: { 46 47 // Serve the pyodide runtime files as static assets so Vite does NOT 47 - // transform them. importScripts() in a classic worker cannot load 48 - // ES modules; Vite's HMR injection turns .js files into ESM, breaking 49 - // the worker. Files in publicDir are served verbatim at the root URL: 50 - // /pyodide/pyodide.js, /pyodide/pyodide.asm.js, etc. 51 - publicDir: path.resolve(__dirname, 'src/renderer/utils/pyodide/src'), 48 + // transform them. Files in publicDir are served verbatim at the root URL: 49 + // /pyodide/pyodide.mjs, /pyodide/pyodide.asm.js, /packages/*.whl, etc. 50 + publicDir: path.resolve(__dirname, 'src/renderer/utils/webworker/src'), 52 51 plugins: [ 52 + // Serve pyodide runtime and package .whl files directly from the filesystem 53 + // before Vite's SPA fallback can intercept them. publicDir alone is not 54 + // reliable — Vite's historyApiFallback returns index.html for fetch() 55 + // requests to these paths in dev mode. 56 + { 57 + name: 'serve-pyodide-assets', 58 + configureServer(server) { 59 + const staticDir = path.resolve( 60 + __dirname, 61 + 'src/renderer/utils/webworker/src' 62 + ); 63 + const contentTypes: Record<string, string> = { 64 + '.json': 'application/json', 65 + '.whl': 'application/zip', 66 + '.zip': 'application/zip', 67 + '.wasm': 'application/wasm', 68 + '.js': 'application/javascript', 69 + '.mjs': 'application/javascript', 70 + }; 71 + server.middlewares.use((req, res, next) => { 72 + const url = req.url ?? ''; 73 + if (url.startsWith('/pyodide/') || url.startsWith('/packages/')) { 74 + console.log('[serve-pyodide-assets] intercepted:', url); 75 + const filePath = path.join(staticDir, url.split('?')[0]); 76 + if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) { 77 + const ext = path.extname(filePath).toLowerCase(); 78 + res.setHeader( 79 + 'Content-Type', 80 + contentTypes[ext] ?? 'application/octet-stream' 81 + ); 82 + res.setHeader('Cache-Control', 'no-cache'); 83 + fs.createReadStream(filePath).pipe(res); 84 + return; 85 + } 86 + } 87 + next(); 88 + }); 89 + }, 90 + }, 53 91 react({ 54 92 jsxRuntime: 'classic', // React 16 does not ship react/jsx-runtime 55 93 babel: { ··· 77 115 }, 78 116 optimizeDeps: { 79 117 include: ['@neurosity/pipes'], 118 + // Prevent Vite from pre-bundling pyodide. In dev mode it will be served 119 + // raw from node_modules via /@fs/, which is what pyodide.mjs expects. 120 + exclude: ['pyodide'], 121 + }, 122 + worker: { 123 + // ES module workers are required for the CDN import in webworker.js. 124 + format: 'es', 80 125 }, 81 126 build: { 82 127 rollupOptions: {