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.

upgrade pyodide to 0.29.3

+332 -75
+177
internals/scripts/InstallMNE.mjs
··· 1 + #!/usr/bin/env node 2 + /** 3 + * Downloads MNE-Python and its pure-Python dependencies from PyPI as wheel 4 + * files for offline use with Pyodide. Binary dependencies (numpy, scipy, 5 + * matplotlib, pandas) are already included via the `pyodide` npm package and 6 + * do NOT need to be downloaded here. 7 + * 8 + * Downloaded wheels are saved to: 9 + * src/renderer/utils/pyodide/src/packages/ 10 + * 11 + * A manifest.json is written there so the web worker knows which filenames 12 + * to pass to micropip.install() at startup. 13 + * 14 + * Usage: node internals/scripts/InstallMNE.mjs 15 + */ 16 + 17 + import fs from 'fs'; 18 + import https from 'https'; 19 + import path from 'path'; 20 + import chalk from 'chalk'; 21 + 22 + const PACKAGES_DIR = path.resolve( 23 + 'src/renderer/utils/pyodide/src/packages' 24 + ); 25 + const MANIFEST_FILE = path.join(PACKAGES_DIR, 'manifest.json'); 26 + 27 + /** 28 + * Pure-Python packages required by MNE that are not bundled with Pyodide. 29 + * Each entry is resolved against the PyPI JSON API to find the latest 30 + * pure-Python wheel (py3-none-any or py2.py3-none-any). 31 + */ 32 + const PACKAGES_TO_DOWNLOAD = [ 33 + 'mne', 34 + 'pooch', 35 + 'tqdm', 36 + 'platformdirs', 37 + ]; 38 + 39 + // --------------------------------------------------------------------------- 40 + // Network helpers 41 + // --------------------------------------------------------------------------- 42 + 43 + function httpsGet(url) { 44 + return new Promise((resolve, reject) => { 45 + const req = https.get(url, { headers: { 'User-Agent': 'BrainWaves-installer/1.0' } }, (res) => { 46 + if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { 47 + resolve(httpsGet(res.headers.location)); 48 + return; 49 + } 50 + if (res.statusCode !== 200) { 51 + reject(new Error(`HTTP ${res.statusCode} for ${url}`)); 52 + return; 53 + } 54 + let body = ''; 55 + res.setEncoding('utf8'); 56 + res.on('data', (chunk) => { body += chunk; }); 57 + res.on('end', () => resolve(body)); 58 + res.on('error', reject); 59 + }); 60 + req.on('error', reject); 61 + }); 62 + } 63 + 64 + function downloadBinary(url, dest) { 65 + return new Promise((resolve, reject) => { 66 + const doGet = (reqUrl) => { 67 + https.get(reqUrl, { headers: { 'User-Agent': 'BrainWaves-installer/1.0' } }, (res) => { 68 + if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { 69 + doGet(res.headers.location); 70 + return; 71 + } 72 + if (res.statusCode !== 200) { 73 + reject(new Error(`HTTP ${res.statusCode} for ${reqUrl}`)); 74 + return; 75 + } 76 + const file = fs.createWriteStream(dest); 77 + res.pipe(file); 78 + file.on('finish', () => file.close(resolve)); 79 + file.on('error', (err) => { fs.unlink(dest, () => {}); reject(err); }); 80 + }).on('error', (err) => { fs.unlink(dest, () => {}); reject(err); }); 81 + }; 82 + doGet(url); 83 + }); 84 + } 85 + 86 + // --------------------------------------------------------------------------- 87 + // PyPI helpers 88 + // --------------------------------------------------------------------------- 89 + 90 + /** 91 + * Returns the best pure-Python wheel for the latest release of `packageName`. 92 + * Preference: py3-none-any > py2.py3-none-any > *-none-any 93 + */ 94 + async function resolvePureWheel(packageName) { 95 + const raw = await httpsGet(`https://pypi.org/pypi/${packageName}/json`); 96 + const data = JSON.parse(raw); 97 + const version = data.info.version; 98 + const urls = data.urls; // files for the latest release 99 + 100 + const wheels = urls.filter((f) => f.filename.endsWith('.whl')); 101 + 102 + const ranked = [ 103 + wheels.find((f) => f.filename.endsWith('-py3-none-any.whl')), 104 + wheels.find((f) => f.filename.endsWith('-py2.py3-none-any.whl')), 105 + wheels.find((f) => f.filename.includes('-none-any.whl')), 106 + ].filter(Boolean); 107 + 108 + if (ranked.length === 0) { 109 + throw new Error( 110 + `No pure-Python wheel found for ${packageName} ${version}. ` + 111 + `Binary packages must come from the Pyodide npm bundle.` 112 + ); 113 + } 114 + 115 + return { version, wheel: ranked[0] }; 116 + } 117 + 118 + // --------------------------------------------------------------------------- 119 + // Main 120 + // --------------------------------------------------------------------------- 121 + 122 + async function installPackage(packageName, manifest) { 123 + process.stdout.write(chalk.blue(` ${packageName}: `)); 124 + 125 + let version, wheel; 126 + try { 127 + ({ version, wheel } = await resolvePureWheel(packageName)); 128 + } catch (err) { 129 + console.log(chalk.red(`FAILED — ${err.message}`)); 130 + return; 131 + } 132 + 133 + const dest = path.join(PACKAGES_DIR, wheel.filename); 134 + 135 + if (fs.existsSync(dest)) { 136 + console.log(chalk.gray(`${version} already present, skipping`)); 137 + manifest[packageName] = { version, filename: wheel.filename }; 138 + return; 139 + } 140 + 141 + try { 142 + await downloadBinary(wheel.url, dest); 143 + console.log(chalk.green(`${version} downloaded`)); 144 + manifest[packageName] = { version, filename: wheel.filename }; 145 + } catch (err) { 146 + console.log(chalk.red(`FAILED — ${err.message}`)); 147 + if (fs.existsSync(dest)) fs.unlinkSync(dest); 148 + } 149 + } 150 + 151 + async function main() { 152 + fs.mkdirSync(PACKAGES_DIR, { recursive: true }); 153 + 154 + // Preserve any previously downloaded packages in the manifest. 155 + let manifest = {}; 156 + if (fs.existsSync(MANIFEST_FILE)) { 157 + try { 158 + manifest = JSON.parse(fs.readFileSync(MANIFEST_FILE, 'utf8')); 159 + } catch { 160 + manifest = {}; 161 + } 162 + } 163 + 164 + console.log(chalk.blue.bold('Downloading MNE-Python wheels from PyPI…')); 165 + for (const pkg of PACKAGES_TO_DOWNLOAD) { 166 + await installPackage(pkg, manifest); 167 + } 168 + 169 + fs.writeFileSync(MANIFEST_FILE, JSON.stringify(manifest, null, 2)); 170 + console.log(chalk.green.bold('\nAll MNE wheels ready.')); 171 + console.log(chalk.gray(`Manifest → ${MANIFEST_FILE}`)); 172 + } 173 + 174 + main().catch((err) => { 175 + console.error(chalk.red('Fatal error:'), err); 176 + process.exit(1); 177 + });
-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 - })();
+108
internals/scripts/InstallPyodide.mjs
··· 1 + #!/usr/bin/env node 2 + /** 3 + * Downloads the Pyodide core tarball from GitHub releases and extracts it 4 + * into the renderer's public directory so Vite serves the runtime as a static 5 + * asset at /pyodide/… (Vite publicDir → src/renderer/utils/pyodide/src/). 6 + * 7 + * The "core" tarball is ~40 MB and includes the runtime plus the most 8 + * commonly used scientific packages (numpy, scipy, matplotlib, pandas, …). 9 + * It is much smaller than the full Pyodide build (~500 MB). 10 + * 11 + * Usage: node internals/scripts/InstallPyodide.mjs 12 + * Runs automatically via the postinstall npm hook. 13 + */ 14 + 15 + import fs from 'fs'; 16 + import https from 'https'; 17 + import path from 'path'; 18 + import { pipeline } from 'stream/promises'; 19 + import chalk from 'chalk'; 20 + import bz2 from 'unbzip2-stream'; 21 + import tar from 'tar-fs'; 22 + 23 + const PYODIDE_VERSION = '0.29.3'; 24 + const TARBALL_NAME = `pyodide-core-${PYODIDE_VERSION}.tar.bz2`; 25 + const TARBALL_URL = `https://github.com/pyodide/pyodide/releases/download/${PYODIDE_VERSION}/${TARBALL_NAME}`; 26 + 27 + // Vite publicDir root — everything here is served verbatim by the dev server. 28 + const PUBLIC_ROOT = path.resolve('src/renderer/utils/pyodide/src'); 29 + // The tarball extracts into a `pyodide/` subdirectory, which ends up at: 30 + // src/renderer/utils/pyodide/src/pyodide/ → served at /pyodide/ 31 + const DEST_DIR = path.join(PUBLIC_ROOT, 'pyodide'); 32 + const VERSION_FILE = path.join(DEST_DIR, '.pyodide-version'); 33 + 34 + // --------------------------------------------------------------------------- 35 + // Network helpers (follow redirects) 36 + // --------------------------------------------------------------------------- 37 + 38 + function httpsGetResponse(url) { 39 + return new Promise((resolve, reject) => { 40 + https 41 + .get(url, { headers: { 'User-Agent': 'BrainWaves-installer/1.0' } }, (res) => { 42 + if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { 43 + resolve(httpsGetResponse(res.headers.location)); 44 + } else { 45 + resolve(res); 46 + } 47 + }) 48 + .on('error', reject); 49 + }); 50 + } 51 + 52 + // --------------------------------------------------------------------------- 53 + // Main 54 + // --------------------------------------------------------------------------- 55 + 56 + async function main() { 57 + // Skip if this exact version is already extracted. 58 + if ( 59 + fs.existsSync(VERSION_FILE) && 60 + fs.readFileSync(VERSION_FILE, 'utf8').trim() === PYODIDE_VERSION 61 + ) { 62 + console.log( 63 + chalk.green.bold(`Pyodide ${PYODIDE_VERSION} already installed, skipping.`) 64 + ); 65 + return; 66 + } 67 + 68 + fs.mkdirSync(PUBLIC_ROOT, { recursive: true }); 69 + 70 + const tarballPath = path.join(PUBLIC_ROOT, TARBALL_NAME); 71 + 72 + // Download the tarball if not already cached. 73 + if (!fs.existsSync(tarballPath)) { 74 + console.log( 75 + chalk.blue.bold(`Downloading Pyodide ${PYODIDE_VERSION} core tarball…`) 76 + ); 77 + const res = await httpsGetResponse(TARBALL_URL); 78 + if (res.statusCode !== 200) { 79 + throw new Error(`Failed to download tarball: HTTP ${res.statusCode}`); 80 + } 81 + await pipeline(res, fs.createWriteStream(tarballPath)); 82 + console.log(chalk.gray(` Saved → ${tarballPath}`)); 83 + } else { 84 + console.log(chalk.gray(` Tarball already cached, skipping download.`)); 85 + } 86 + 87 + // Extract the tarball. The archive contains a top-level `pyodide/` 88 + // directory, so extracting into PUBLIC_ROOT gives us PUBLIC_ROOT/pyodide/. 89 + console.log(chalk.blue.bold(`Extracting…`)); 90 + await pipeline( 91 + fs.createReadStream(tarballPath), 92 + bz2(), 93 + tar.extract(PUBLIC_ROOT) 94 + ); 95 + 96 + // Stamp the installed version and clean up the cached tarball. 97 + fs.writeFileSync(VERSION_FILE, PYODIDE_VERSION); 98 + fs.unlinkSync(tarballPath); 99 + 100 + console.log( 101 + chalk.green.bold(`Pyodide ${PYODIDE_VERSION} installed successfully.`) 102 + ); 103 + } 104 + 105 + main().catch((err) => { 106 + console.error(chalk.red('Fatal error:'), err); 107 + process.exit(1); 108 + });
+1 -1
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 19 "lint": "cross-env NODE_ENV=development eslint . --cache", 20 20 "lint-fix": "npm run lint -- --fix", 21 21 "lint-styles": "stylelint '**/*.*(css|scss)'",
+46 -8
src/renderer/utils/pyodide/webworker.js
··· 1 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 2 + * Pyodide Web Worker 3 + * 4 + * Load order: 5 + * 1. Pyodide runtime (served as a static asset at /pyodide/). 6 + * 2. Binary packages bundled with Pyodide (numpy, scipy, matplotlib, pandas). 7 + * 3. MNE-Python and its pure-Python deps, installed offline from local 8 + * wheel files that were pre-downloaded by `npm run install-mne-wheels`. 9 + * The manifest at /packages/manifest.json maps package names → filenames. 4 10 */ 5 11 6 - // pyodide is served as a static asset at /pyodide/ (via Vite publicDir). 12 + // Pyodide is served as a static asset at /pyodide/ (via Vite publicDir). 7 13 // An absolute path is required so importScripts resolves correctly regardless 8 14 // of where the worker script itself is served from. 9 15 importScripts('/pyodide/pyodide.js'); 10 16 11 17 async function loadPyodideAndPackages() { 12 18 self.pyodide = await loadPyodide({ indexURL: '/pyodide/' }); 13 - await self.pyodide.loadPackage(['matplotlib', 'mne', 'pandas']); 19 + 20 + // Load binary packages that are bundled with the Pyodide npm package and 21 + // therefore available locally without any network request. 22 + await self.pyodide.loadPackage(['numpy', 'scipy', 'matplotlib', 'pandas']); 23 + 24 + // Load MNE and its pure-Python dependencies from pre-downloaded wheel files. 25 + // The manifest was written by `node internals/scripts/InstallMNE.mjs`. 26 + let manifest = {}; 27 + try { 28 + const response = await fetch('/packages/manifest.json'); 29 + if (response.ok) { 30 + manifest = await response.json(); 31 + } else { 32 + console.warn('[pyodide worker] manifest.json not found — MNE will not be available'); 33 + } 34 + } catch (err) { 35 + console.warn('[pyodide worker] Could not fetch manifest.json:', err); 36 + } 37 + 38 + const wheelUrls = Object.values(manifest) 39 + .map((entry) => `/packages/${entry.filename}`); 40 + 41 + if (wheelUrls.length > 0) { 42 + await self.pyodide.loadPackage('micropip'); 43 + const micropip = self.pyodide.pyimport('micropip'); 44 + // micropip resolves relative URLs against the worker's base URL. 45 + // Pass absolute URLs so it works regardless of worker location. 46 + const absoluteUrls = wheelUrls.map( 47 + (u) => new URL(u, self.location.origin).href 48 + ); 49 + await micropip.install(absoluteUrls); 50 + } else { 51 + console.warn('[pyodide worker] No MNE wheels found in manifest.'); 52 + } 14 53 } 54 + 15 55 let pyodideReadyPromise = loadPyodideAndPackages(); 16 56 17 57 self.onmessage = async (event) => { 18 - // make sure loading is done 19 58 await pyodideReadyPromise; 20 - // Don't bother yet with this line, suppose our API is built in such a way: 59 + 21 60 const { data, ...context } = event.data; 22 - // The worker copies the context in its own "memory" (an object mapping name to values) 23 61 for (const key of Object.keys(context)) { 24 62 self[key] = context[key]; 25 63 } 26 - // Now is the easy part, the one that is similar to working in the main thread: 64 + 27 65 try { 28 66 self.postMessage({ 29 67 results: await self.pyodide.runPythonAsync(data),