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.

nearly there

+189 -14
+28
.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 + 24 52 ## Pre-existing TypeScript errors (do not treat as regressions) 25 53 26 54 - `src/renderer/epics/experimentEpics.ts` (lines 170, 205) — RxJS operator type mismatch
+1 -1
internals/scripts/InstallMNE.mjs
··· 39 39 // Root packages whose full transitive dependency tree we need from Pyodide CDN 40 40 // --------------------------------------------------------------------------- 41 41 42 - const PYODIDE_ROOT_PACKAGES = ['numpy', 'scipy', 'matplotlib', 'pandas']; 42 + const PYODIDE_ROOT_PACKAGES = ['numpy', 'scipy', 'matplotlib', 'pandas', 'micropip']; 43 43 44 44 // --------------------------------------------------------------------------- 45 45 // Pure-Python packages to download from PyPI (not bundled with Pyodide)
+59
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'; ··· 22 23 'enable-experimental-web-platform-features', 23 24 'true' 24 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 + } 25 72 26 73 export default class AppUpdater { 27 74 constructor() { ··· 453 500 }); 454 501 455 502 app.whenReady().then(async () => { 503 + // Serve pyodide assets (whl files, runtime files) via a local HTTP server so 504 + // web workers can fetch() them without hitting Vite's SPA fallback, which 505 + // returns HTML for ALL fetch() requests regardless of path. 506 + // Port 17173 is hardcoded and matched in webworker.js. 507 + // In dev: files are in src/renderer/utils/webworker/src/ 508 + // In prod: files are in resources/webworker/src/ (via extraResources) 509 + const pyodideRoot = is.dev 510 + ? path.join(app.getAppPath(), 'src/renderer/utils/webworker/src') 511 + : path.join(process.resourcesPath, 'webworker/src'); 512 + 513 + startPyodideAssetServer(pyodideRoot); 514 + 456 515 // Enable F12 devtools shortcut and Ctrl+R reload in dev, disable in prod 457 516 app.on('browser-window-created', (_, window) => { 458 517 optimizer.watchWindowShortcuts(window);
+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>
+1 -1
src/renderer/utils/webworker/index.ts
··· 127 127 } 128 128 return worker.postMessage({ 129 129 // data: `import matplotlib.pyplot as plt; fig= plt.plot([1,2,3,4])`, 130 - data: `[1,2,3,4]` 130 + data: `sum([1,2,3,4])` 131 131 }); 132 132 }; 133 133
+59 -11
src/renderer/utils/webworker/webworker.js
··· 3 3 * 4 4 * Loading strategy 5 5 * ---------------- 6 - * Use Vite's `?url` suffix on 'pyodide/pyodide.mjs' to get the resolved file URL 7 - * at build/dev time (/@fs/... in dev, an asset URL in prod), then dynamically 8 - * import from that URL. This bypasses Vite's SPA fallback and lets pyodide.mjs 9 - * resolve all sibling assets (pyodide.asm.wasm, pyodide-lock.json, etc.) via 10 - * import.meta.url — no CDN required. 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. 11 19 * 12 - * Production builds use the files copied to publicDir by InstallPyodide.mjs. 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/. 13 22 */ 14 23 15 - // ?url tells Vite to resolve the path and return a URL string rather than bundling 16 - // the module. In dev mode this is a /@fs/ URL (bypasses SPA fallback); in prod it 17 - // is an asset URL. We then dynamically import from that URL so pyodide.mjs can 18 - // resolve all its sibling assets (pyodide.asm.wasm, etc.) via import.meta.url. 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). 19 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'; 20 34 21 35 const pyodideReadyPromise = (async () => { 22 36 const { loadPyodide } = await import(/* @vite-ignore */ pyodideMjsUrl); 23 - return loadPyodide(); 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 + // Load micropip so we can install MNE and its pure-Python deps. 60 + await pyodide.loadPackage('micropip', { checkIntegrity: false }); 61 + const micropip = pyodide.pyimport('micropip'); 62 + 63 + // MNE + pure-Python deps are served from /packages/ via pyodide-asset://. 64 + const manifestUrl = `${PYODIDE_ASSET_BASE}/packages/manifest.json`; 65 + const manifest = await fetch(manifestUrl).then((r) => r.json()); 66 + const whlUrls = Object.values(manifest).map( 67 + ({ filename }) => `${PYODIDE_ASSET_BASE}/packages/${filename}` 68 + ); 69 + await micropip.install(whlUrls); 70 + 71 + return pyodide; 24 72 })(); 25 73 26 74 self.onmessage = async (event) => {
+40
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 ··· 48 49 // /pyodide/pyodide.mjs, /pyodide/pyodide.asm.js, /packages/*.whl, etc. 49 50 publicDir: path.resolve(__dirname, 'src/renderer/utils/webworker/src'), 50 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 + }, 51 91 react({ 52 92 jsxRuntime: 'classic', // React 16 does not ship react/jsx-runtime 53 93 babel: {