From dd6cb6503b245481f0d002dcefe8529fafa12933 Mon Sep 17 00:00:00 2001 From: jdpigeon Date: Tue, 18 Aug 2026 14:21:58 -0400 Subject: [PATCH 1/3] feat: restore custom experiments Rewire the Custom bank path so students can pick image folders, preview stimuli, and persist workspace state. Serve user images over bwfile:// (Vite /@fs 403s paths outside the repo). Debounce workspace saves on keystroke. Restore PYODIDE_SOURCE_DIR after it was dropped from main. --- .llms/CLAUDE.md | 15 +- .llms/learnings.md | 28 ++- CLAUDE.md | 20 +++ CONTRIBUTING.md | 2 +- README.md | 4 +- ROADMAP.md | 43 +++-- TODOS.md | 34 +++- docs/device-connectivity.md | 72 ++++---- docs/pyodide-in-electron-vite.md | 16 +- docs/user-flow.md | 102 +++++------ src/main/__tests__/workspaceState.test.ts | 41 +++++ src/main/index.ts | 35 +++- src/main/workspaceState.ts | 17 ++ .../DesignComponent/CustomDesignComponent.tsx | 162 ++++++++++++++---- src/renderer/components/ExperimentWindow.tsx | 9 +- .../components/HomeComponent/index.tsx | 8 + src/renderer/constants/interfaces.ts | 13 ++ src/renderer/epics/experimentEpics.ts | 16 +- src/renderer/experiments/custom/index.ts | 22 ++- src/renderer/experiments/custom/params.ts | 28 +++ src/renderer/index.html | 3 +- .../labjs/__tests__/customStimuli.test.ts | 107 ++++++++++++ .../__tests__/getExperimentFromType.test.ts | 17 ++ src/renderer/utils/labjs/customStimuli.ts | 98 +++++++++++ src/renderer/utils/labjs/functions.ts | 16 +- vite.config.ts | 7 - 26 files changed, 723 insertions(+), 212 deletions(-) create mode 100644 src/main/__tests__/workspaceState.test.ts create mode 100644 src/main/workspaceState.ts create mode 100644 src/renderer/experiments/custom/params.ts create mode 100644 src/renderer/utils/labjs/__tests__/customStimuli.test.ts create mode 100644 src/renderer/utils/labjs/__tests__/getExperimentFromType.test.ts create mode 100644 src/renderer/utils/labjs/customStimuli.ts diff --git a/.llms/CLAUDE.md b/.llms/CLAUDE.md index edd0bce4..4ad853c1 100644 --- a/.llms/CLAUDE.md +++ b/.llms/CLAUDE.md @@ -19,6 +19,19 @@ A priority for this codebase is extensibility modularity and hackability. There - **Testing**: Vitest - **Linting**: ESLint + Prettier (single quotes, ES5 trailing commas) + +## Domain skills + +Read these before touching the matching seams (also listed in root `CLAUDE.md`): + +- `.claude/skills/electron-ipc-architecture/` + `electron-ipc-channel/` — process boundary, IPC +- `.claude/skills/pyodide-mne/` — analysis worker, `pyodide://`, InstallMNE +- `.claude/skills/redux-observable-epochs/` — epics, markers, empty ERPs + +## Playtest + +This is an Electron app. `http://localhost:5173` is the Vite renderer **without** preload. Do not `/qa` or `/browse` it — `electronAPI` and `pyodide://` only exist in the Electron window. See root `CLAUDE.md`. + ## Key Directories - `src/main/` — Electron main process - `src/renderer/` — React renderer process @@ -39,7 +52,7 @@ npm run package # Build + package for current platform ## Conventions - Use TypeScript; avoid `any` unless strictly necessary -- Redux state changes go through RTK slices or typed actions via `typesafe-actions` +- Redux state changes go through RTK `createAction` / `createReducer`. `typesafe-actions` remains only for `ActionType` unions — do not add more of it. - Side effects belong in RxJS epics (`redux-observable`) - Do not commit secrets or device credentials - Keep Electron main/renderer separation strict — use preload IPC bridges diff --git a/.llms/learnings.md b/.llms/learnings.md index 6eb97fd5..156f3fe4 100644 --- a/.llms/learnings.md +++ b/.llms/learnings.md @@ -69,7 +69,7 @@ Device/LSL connectivity is integration-tested with the native layers fully mocke - Renderer drivers mock `window.electronAPI` (capture the `onLSLInletData`/`onLSLInletDisconnected` handlers to "push" inlet epochs) and `@neurosity/sdk`. The Neurosity mock's `Neurosity` must be `new`-able — `neurosity.ts` does `new Neurosity(...)` — so define a plain `function Neurosity(){ return client }` inside `vi.hoisted` (arrow fns aren't constructable) and `vi.mock('@neurosity/sdk', () => ({ Neurosity: h.Neurosity }))`. - `lslBridge.ts` probes availability at module load via a top-level `isLSLAvailable()` promise; to test the gate, set `window.electronAPI.isLSLAvailable` before `vi.resetModules()` + dynamic `await import('../lslBridge')`, then flush a microtask. -**CI**: `npm run device-integration` (script targets `src/main/lsl src/renderer/utils/eeg`) runs as its own `.github/workflows/integration.yml` job with `npm ci --ignore-scripts` — since every native module is mocked, it skips the slow Pyodide/MNE postinstall and needs no liblsl. The full cross-OS suite still runs these too via `npm test` in `test.yml` (whose `Test` step runs before the lint step). +**CI**: `npm run device-integration` (script targets `src/main/lsl src/renderer/utils/eeg`) runs as its own `.github/workflows/device.yml` job with `npm ci --ignore-scripts` — since every native module is mocked, it skips the slow Pyodide/MNE postinstall and needs no liblsl. The full cross-OS suite still runs these too via `npm test` in `test.yml` (whose `Test` step runs before the lint step). ## Styling System (post Phase 4 migration) @@ -120,7 +120,7 @@ The CDN version is derived from `node_modules/pyodide/package.json` — **not** **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. -**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`. +**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 an SVG string wrapped as `{ 'image/svg+xml': svg }`. `PyodidePlotWidget` renders that as a data-URI `` (no `@nteract/transforms`). ## Lab.js 23.x API: `hooks` replaces `messageHandlers` @@ -231,3 +231,27 @@ fails on a `pyodide://` URL instead. Experiments emit `this.data.correct_response` as a real boolean (`true`/`false`) and `response_given` as `'yes'`/`'no'` (see `src/renderer/utils/labjs/functions.ts`). But all consumers in `src/renderer/utils/behavior/compute.js` read data **after** it's been written to CSV and re-parsed, so every value is a **string**. That's why existing code gates on `row.correct_response === 'true'` and `row.response_given === 'yes'`, and parses numbers with `parseFloat`. Trap: a new metric written naively (`row.correct_response === true`, or arithmetic on an unparsed string) will silently return `false`/`0`/`NaN` for post-CSV data — and may *work* on pre-CSV in-memory data, so it passes a quick test and fails in production. Always compare against the string `'true'`/`'yes'` and `parseFloat` before doing math. + +## Playtest the Electron window, never Vite `:5173` + +`npm run dev` serves the renderer at `http://localhost:5173` *and* opens Electron. +Chrome / gstack `/browse` / `/qa` against that URL has no preload: `LSLStatusListener` +throws and Pyodide cannot fetch `pyodide://host/...`. Evidence: +`.gstack/browse-console.log` (2026-07-14). Drive the Electron window (planned: +`--remote-debugging-port` + OMP CDP). Root `CLAUDE.md` bans `/qa` on Vite. + +## Custom experiments are a V1 P0 restore, not a delete + +The 2017–2020 app had a working custom-experiment builder (CHANGELOG 0.11–0.13). +HEAD still has the files (`CustomDesignComponent`, `StimuliRow`/`StimuliDesignColumn`, +`experiments/custom/`) but the bank has no Custom card, `getExperimentFromType` +falls through to Faces/Houses, and CONDITIONS/TRIALS are stubbed. Recover from +git history; do not delete the stub. See `TODOS.md`. + +## LSL inlet `injectMarker` is intentional + +`EEGDriver.injectMarker` is required for Muse/Neurosity (CSV + ERP). LSL inlet +is a separate mode: the external recorder owns markers, so `injectMarker()` +no-ops. First-party runs still `sendMarker()` to the LSL *outlet* from +`RunComponent`. Do not "fix" the inlet no-op. + diff --git a/CLAUDE.md b/CLAUDE.md index 0df4dd70..55be9741 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,3 +1,23 @@ @.llms/CLAUDE.md @.llms/learnings.md +## Skill routing + +When the user's request matches an available skill, invoke it via the Skill tool. When in doubt, invoke the skill. + +### BrainWaves domain skills (read these first) + +These live in `.claude/skills/` and are the source of truth for the hard seams. + +- Anything that crosses main / preload / renderer (IPC, `electronAPI`, native modules, Bluetooth, FS, dialogs) → `electron-ipc-architecture`, then `electron-ipc-channel` when adding/editing a channel +- Pyodide, MNE, `webworker/`, `InstallMNE.mjs`, plot/data routing, prod-only analysis failures → `pyodide-mne` +- Epics, live EEG, markers, empty ERPs, `buildMarkerRegistry` → `redux-observable-epochs` + +### This is an Electron app, not a website + +`npm run dev` starts electron-vite, which also serves the renderer at `http://localhost:5173`. That URL is **not** the app. + +- `window.electronAPI` and the `pyodide://` protocol exist only inside the Electron window (preload + main). +- Opening `:5173` in Chrome / `/qa` crashes `LSLStatusListener` and fails Pyodide init (`Failed to fetch … pyodide://host/pyodide/pyodide.asm.js`). +- **Never** `/qa`, `/qa-only`, or `/browse` against `localhost:5173`. +- Playtest the Electron window. A dedicated `electron-playtest` skill (CDP attach via `--remote-debugging-port`) is the planned harness — see `TODOS.md`. Until that exists: `npm run dev` and drive the desktop app, not the Vite URL. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 16df8b67..8130cf4c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -23,4 +23,4 @@ If you find a bug, or have a suggestion on how to improve the project, just fill If you're interested in using BrainWaves as a basis for your own work to streamline EEG experimentation or psychological data collection, we'd love to hear from you. Send an email to [dano@neurotechx.com](mailto:dano@neurotechx.com) or create an issue and we'll be in touch. -All project management for BrainWaves occurs through issues on Github (via Zenhub). If you want to see what we need help with, or what is on our [roadmap](ROADMAP.md), check out the issues on this repository. +Current work lives in [`TODOS.md`](TODOS.md) and the strategic layer in [`ROADMAP.md`](ROADMAP.md). Agent/developer conventions are in [`CLAUDE.md`](CLAUDE.md) and [`.llms/`](.llms/). GitHub issues are still welcome. diff --git a/README.md b/README.md index 212de463..ab8a0c99 100644 --- a/README.md +++ b/README.md @@ -46,8 +46,8 @@ go. This step disappears once the project is signed and notarized. The rest of this README is for developers building from source. -- **Node.js** >= 18 -- **npm** >= 9 +- **Node.js** `^20.19.0 || >=22.12.0` (see `package.json` `engines`) +- **npm** >= 8 - No Python installation required — EEG analysis runs via [Pyodide](https://pyodide.org) (Python compiled to WebAssembly), which is downloaded automatically on first `npm install`. > **Note:** `npm install` downloads ~300 MB of Pyodide WASM files on first run. This is expected and only happens once. diff --git a/ROADMAP.md b/ROADMAP.md index 071389f0..90e72837 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,29 +1,40 @@ # Roadmap -## Overview +Summer 2026: BrainWaves revival for high school. Execution detail lives in `TODOS.md` — this file is the strategic layer. If the two disagree, `TODOS.md` wins. -Summer 2026: Brainwaves revival for High School +## V1 / V1.1 — classroom MVP -## LSL +Ship a signed-off Muse classroom loop: Design → Collect → Clean → Analyze, including **custom experiments** (P0; they worked in the 2017–2020 app and must work again). -- [x] Cut out emotiv SDK garbage -- [ ] Update existing muse-js Muse connectivity. Emit connected devices on LSL stream -- [ ] Add Neurosity SDK to app. Emit connected devices on LSL stream -- [ ] Add option for users to connect external LSL streams, providing support for any theoretical EEG device -- [ ] Update stimulus markers from experiments to LSL stream -- [ ] Use LSL internally to handle data recording -- [ ] QA LSL experiment functionality, ensuring support on both Mac and Win, and easy installation +- [x] Cut Emotiv SDK +- [x] Muse + Neurosity first-party drivers (`EEGDriver` registry) +- [x] LSL outlets for connected first-party devices (epochs + stimulus markers) +- [x] External LSL inlet in ConnectModal (when liblsl is available) +- [ ] Restore custom-experiment authoring (see TODOS — P0) +- [ ] QA built-in + custom experiments on Muse hardware +- [ ] First release dry-run (`v1.0.0-rc.1`) + packaged-app smoke +- [ ] Cross-platform LSL packaging verification (macOS x64, Windows, Linux) -## Lesson Content +CSV is still the system of record. Using LSL *internally* for recording is not a V1 goal. -- [ ] Get lesson content from Steve Azeka from Brainwaves classes -- [ ] Add lesson content to Brainwaves app +## V1.5 — visual polish -## Lab.js +Epoch-reviewer onboarding (plain language, guided mode). See TODOS "Next". -- Type lab.js data (pending lab.js TypeScript library update) -- Remove jspsych and refactor lab.js usage +## V2 — lesson content +- [ ] Neuro content from Steve Azeka (2017 classroom material) +- [ ] Data-science content from Teon Brooks +- [ ] In-app lesson surface (static markdown first) +## Later +- Muse S Athena (Gen 3) +- Neurosity polish (only if a partner classroom owns Crowns) +- Type lab.js / strip leftover jsPsych strings (users never see this) +- Lesson surface beyond markdown (block-based / notebooks) +## Deliberate dual systems (not debt) + +- Live EEG in a `` (thread isolation) vs canvas epoch reviewer (high-frequency interactive) vs Pyodide SVG (static MNE/matplotlib). +- BLE `EEGDriver` (Muse / Neurosity) vs LSL inlet (external recorder). Inlet `injectMarker` is a **no-op on purpose** — the external recorder owns markers. First-party runs still `injectMarker` locally and `sendMarker` to the LSL outlet. diff --git a/TODOS.md b/TODOS.md index 6a43bd35..6699852e 100644 --- a/TODOS.md +++ b/TODOS.md @@ -1,23 +1,39 @@ # TODOS -Deferred and in-flight work. Keep this current — when something ships, delete it or move it under "Done recently". See `ROADMAP.md` for the strategic frame and CEO plan (2026-07-02 summer-revival-reframe) for the reasoning behind the priorities below. - -## Now (Summer 2026 critical path) - -- [ ] **Lesson content from Steve Azeka** — owner: Dano. Set a date for the first sample lesson. Fallback: if no lesson by that date, Dano authors one throwaway stub to unblock the lesson surface. -- [ ] **In-app lesson surface v1** — static markdown render only (no editing, no interactivity). Build against a dummy lesson; tune to real content once a sample lands. Confirm markdown is a rich-enough format with Azeka before building. +Deferred and in-flight work. Keep this current — when something ships, delete it or move it under "Done recently". See `ROADMAP.md` for higher level strategic priorities. + +## Now (V1 / V1.1: Summer 2026 MVP) + +- [ ] **P0 — Restore custom experiments.** Authoring path is rewired (Custom bank card, `getExperimentFromType(CUSTOM)`, Conditions/Trials use `StimuliDesignColumn`/`StimuliRow`, 2020 first-image-is-practice rule). Still needs a **human click-through** in the Electron window: name a workspace → pick two image folders → preview shows images → Collect runs. Then Muse ERP. Remaining: + - Click-through QA (this is an Electron app — not Vite `:5173`). + - `experiments/custom/experiment.js` kept on disk; runtime is the Faces/Houses lab.js template so images load via `filepath`. + - `utils/labjs/protocols/custom.ts` still leftover jsPsych-shaped; unused. +- [ ] **Accept `.webp` stimulus images.** `fs:readImages` (`src/main/index.ts`) only keeps files whose last three letters are `png`/`jpg`/`gif`/`peg`, and Custom Conditions copy says `.png`/`.jpg`/`.jpeg`. Folders of `.webp` look empty. Use a real extension check (`path.extname`) and include `webp` (Chromium can display it). Update the Conditions helper text. +- [ ] **Allow paste (and cut/copy) in text fields.** macOS `MenuBuilder.buildDarwinTemplate` (`src/main/menu.ts`) has no Edit menu — no `paste:` / `role: 'editMenu'` — so ⌘V does nothing in Custom Overview textareas (and every other input). Add the standard Edit menu (`undo/redo/cut/copy/paste/selectAll`) on Darwin and the default template. Right-click paste is also missing outside `DEBUG_PROD` context-menu. +- [ ] **QA Built-in Experiments** — Guarantee all built-in experiments (e.g. Faces Houses) are working with Muse hardware and can produce real results +- [ ] **QA Custom Experiments** — After the click-through above: a standard custom workflow produces valid behavioral + EEG results on Muse. +- [ ] **Replay / fixture EEGDriver** — A first-party driver that replays a checked-in CSV (samples + numeric markers) as a live `Observable`. Unblocks Collect → Clean → Analyze QA and agent playtest without a headset on the desk. Hardware Muse QA stays a separate item. Register it like Muse/Neurosity so `injectMarker` is real (or pre-baked in the fixture). +- [ ] **Electron playtest skill** — `npm run dev` + `--remote-debugging-port` → attach OMP/CDP to the **Electron window**, never `http://localhost:5173`. `/qa` and gstack `/browse` against Vite crash `LSLStatusListener` and Pyodide (`pyodide://`). See `CLAUDE.md`. - [ ] **First release dry-run (`v1.0.0-rc.1`)** — the Release workflow has *never executed*; no tag has ever been pushed. It is now gated (verify job: test/typecheck/lint; artifact check between build and publish) and publishes as a **draft**, so a tag no longer auto-updates installed clients until someone publishes the draft by hand. Still unproven end to end: push an RC tag, watch all three OS legs build, download each artifact, launch it, and confirm the app opens and Pyodide reaches `ready`. Do this before any demo depends on a released build. - [ ] **Packaged-app launch smoke test** — `tests/build.check.ts` asserts the build output and Pyodide payload are present on disk, but nothing *launches* the packaged app. The gap is a real Electron smoke test (spawn the built app under `xvfb` on Linux, assert the window opens and the worker posts `ready`) — that is what would catch a prod-only Pyodide regression automatically instead of during a demo. - [ ] **Cross-platform LSL packaging verification** — `macOS arm64` is done + self-contained (see Done recently). Still needs each build machine: **macOS x64** (`patchDeps` only symlinks on arm64, so an Intel build uses node-labstreaminglayer's original x86_64 dylib — `otool -L` the packaged liblsl to confirm self-contained; the `afterPack` hook bundles whatever external deps it finds, arch-agnostic), **Windows x64** (bundled `lsl_amd64.dll` is a self-contained PE — confirm koffi loads it from the packaged app), **Linux/Ubuntu** (bundled `.so` is x86_64 ELF — `ldd` it + Web-Bluetooth smoke test, `--enable-experimental-web-platform-features` already set). -## Deferred (post-launch) +## Next (V1.5: Visual Polish and Juice) +- [ ] **Hooks / function-component migration (AI-friendly).** Most student screens are still class components + `react-redux` `connect()` / `bindActionCreators` containers (`src/renderer/containers/`). Newer work (`EpochReviewer`, `LiveErpPane`, `RunComponent`, `TopNavComponent`) is hooks. Incremental, screen-by-screen; do not big-bang. Pattern to copy: hooks + `useDispatch`/`useSelector` like `App.tsx`. Not a V1 blocker. +- [ ] **Epoch reviewer Phase 3 — onboarding layer.** Plain-language explanations of epochs + each artifact type, a **guided mode** (step through auto-flagged epochs with "why we flagged this," student confirms/overrides), channel legend tied to head position (Muse 10-20), student-facing tone. Builds on the Phase 0-2 reviewer (PRs #223/#224/#225). **Open question OQ3 (onboarding depth) is still unresolved** — how much curriculum (tooltips only vs. a real walkthrough), guided-mode-as-default? This is product-shaped, not architecture. +## And Then (V2: Real lesson Content) +- [ ] **Neuro content from Steve Azeka** — content that was taught in 2017 alongside the Brainwaves app. Hopefully neuroscience basics, visual and auditory system, EEG, etc. Might include content from https://github.com/kylemath/EEGEdu +- [ ] **Data Science content from Teon Brooks** - content from Teon that can be used to support understanding of, and flexibile contribution to, the statistical analyses within Brainwaves. Understanding datasets, experimental design, choosing statistical tests, etc. +- [ ] **In-app lesson surface v1** — static markdown render only (no editing, no interactivity). Build against a dummy lesson; tune to real content once a sample lands. Confirm markdown is a rich-enough format with Azeka before building. +- [ ] **In-app LLM coach for custom Design Overview.** Once a student has filled Research Question, Hypothesis, and Methods, run a local (or classroom-hosted) LLM and give immediate, student-friendly feedback — testable?, confounds, whether the methods can actually answer the question. Not a cloud dependency if we can avoid it; wait until the lesson surface exists so the coach can share that context. + +## Deferred (post-launch) - [ ] **Muse S Athena (Gen 3) support** — the Athena uses a multiplexed BLE protocol that muse-js (classic protocol) doesn't speak. Reference implementation is already done in `../muse-lsl` (Python, commit `74fcb91`, ~1200 lines: tag-based packet decoder, GATT-probe routing, EEG/IMU/optics streams). Porting into muse-js or a parallel TS decoder is substantial; not on the LSL critical path. - [ ] Neurosity SDK polish — revisit only if a partner classroom owns Crowns (~$1000/unit vs ~$250 Muse). -- [ ] External / generic LSL stream support (any EEG device). +- [ ] External / generic LSL stream **polish** — inlet UI already ships when liblsl is available (ConnectModal). Remaining: classroom QA, non-EEG stream types, docs. Inlet `injectMarker` is an intentional no-op (external recorder owns markers); do not "fix" that. First-party runs already `sendMarker()` to the outlet. - [ ] Lab.js cleanup — remove jspsych, type lab.js data. Not on the content critical path; users never see it. - [ ] Lesson surface beyond markdown — block-based programming, embedded notebooks (the CLAUDE.md extensibility horizon). -- [ ] **Epoch reviewer Phase 3 — onboarding layer.** Plain-language explanations of epochs + each artifact type, a **guided mode** (step through auto-flagged epochs with "why we flagged this," student confirms/overrides), channel legend tied to head position (Muse 10-20), student-facing tone. Builds on the Phase 0-2 reviewer (PRs #223/#224/#225). **Open question OQ3 (onboarding depth) is still unresolved** — how much curriculum (tooltips only vs. a real walkthrough), guided-mode-as-default? This is product-shaped, not architecture — take it through `/office-hours` or `/plan-ceo-review` before forging. Note it overlaps the "in-app lesson surface" critical-path work above; sequence deliberately. - [ ] **Epoch reviewer Phase 4 — polish & generalize.** N-channel devices (Neurosity 8-ch, external LSL 32-64-ch) — the renderer already windows/downsamples but needs real testing at scale and possibly the WebGL swap behind `drawEpochs` (OQ1 left that a later swap). Accessibility, keyboard-first flow, performance. Also OQ7 (keep a read-only static-SVG fallback for environments where the interactive UI can't run?) is still open. ## Known issues / tech debt diff --git a/docs/device-connectivity.md b/docs/device-connectivity.md index 60ba16fa..f2ed05f4 100644 --- a/docs/device-connectivity.md +++ b/docs/device-connectivity.md @@ -16,7 +16,7 @@ Device connectivity spans three layers: |---|---|---| | **UI** | `CollectComponent/`, `EEGExplorationComponent` | Trigger search, display state, handle user selection | | **Epics** | `epics/deviceEpics.ts` | Orchestrate async device lifecycle via RxJS | -| **Driver** | `utils/eeg/index.ts` (registry) → `muse.ts`, `neurosity.ts`, `lslInlet.ts` | Per-device acquisition behind the shared `EEGDriver` interface (`utils/eeg/types.ts`) | +| **Driver** | `utils/eeg/index.ts` (registry) → `muse.ts`, `neurosity.ts`. `lslInlet.ts` is a **parallel mode**, not in the registry | All device state lives in Redux (`reducers/deviceReducer.ts`). Epics react to dispatched actions and fire new actions as side effects. @@ -71,8 +71,7 @@ All device state lives in Redux (`reducers/deviceReducer.ts`). Epics react to di │ │ SetConnectionStatus(CONNECTING) │ │ │ │ │ └──► connectEpic │ -│ connectToMuse(device) │ -│ │ navigator.bluetooth.requestDevice() [again, with name filter] │ +│ │ reuses BluetoothDevice cached by getMuse() │ │ │ deviceInstance.gatt.connect() │ │ │ client.connect(gatt) [muse-js MuseClient] │ │ │ │ @@ -114,62 +113,53 @@ All device state lives in Redux (`reducers/deviceReducer.ts`). Epics react to di ## Redux State (`deviceReducer`) ``` -deviceType: DEVICES.MUSE (only supported device) +deviceType: DEVICES.MUSE | NEUROSITY | LSL deviceAvailability: NONE | SEARCHING | AVAILABLE connectionStatus: NOT_YET_CONNECTED | CONNECTING | CONNECTED | DISCONNECTED -availableDevices: Device[] — list from getMuse() +availableDevices: Device[] — BLE scan results (Muse / Neurosity) +availableLSLStreams: DiscoveredStream[] — inlet discovery (when liblsl loaded) connectedDevice: DeviceInfo | null — { name, samplingRate, channels } rawObservable: Observable | null signalQualityObservable: Observable | null ``` ---- - -## Known Issues & Bug Analysis - -### Bug: No devices found despite nearby Muse - -**Symptom:** `SetDeviceAvailability(SEARCHING)` fires, 3-second timer elapses, state returns to NONE. No devices listed, no error shown. +`DEVICES.GANGLION` exists in the enum only ("One day") and has no driver. -**Root cause: Missing `select-bluetooth-device` handler in Electron main process.** - -Electron 22+ changed how Web Bluetooth works. When `navigator.bluetooth.requestDevice()` is called in the renderer, Electron fires a `select-bluetooth-device` event on `webContents` instead of showing the browser's built-in Bluetooth picker. If no handler is registered in the main process, the Promise **hangs indefinitely** (or rejects silently in some Electron versions), and the epic's error handler catches it and returns `[]`. +--- -**The app is running Electron 39 — this handler is mandatory.** +## Known Issues -The fix requires registering a handler in `src/main/index.ts` before the window is created: +### Fixed: `select-bluetooth-device` handler -```ts -mainWindow.webContents.on('select-bluetooth-device', (event, deviceList, callback) => { - event.preventDefault(); - // Store callback and deviceList in state, send to renderer via IPC - // so the user can pick from the ConnectModal UI. - // OR: auto-select first matching Muse device: - const muse = deviceList.find(d => d.deviceName.startsWith('Muse')); - if (muse) { - callback(muse.deviceId); - } else { - callback(''); // reject — no Muse found - } -}); -``` +Electron 22+ does not show a native Web Bluetooth picker. The renderer +`requestDevice()` hangs unless main handles `select-bluetooth-device`. -There are two approaches for the UX: +**Shipped** in `src/main/index.ts` (~line 676): auto-selects the first advertised +device. The renderer's `requestDevice()` filters already scoped the scan by GATT +UUID (Muse vs Neurosity), so the first hit is the intended headset. A timeout +calls `bluetooth:cancelSearch` (`callback('')`) if nothing appears. -- **Auto-select** (simpler): in the handler, filter `deviceList` for any device whose name starts with `'Muse'` and immediately call `callback(deviceId)`. The user never sees a picker — it just connects. -- **Show picker in app UI** (better): send the `deviceList` to the renderer via IPC, display them in `ConnectModal`, and invoke the callback with the user's selection. Requires storing the callback reference in main process state between IPC calls. +Do not re-add this handler. The file table below used to claim it was missing. -### Bug: `connectToMuse` calls `requestDevice` a second time +### Fixed: `connectToMuse` second `requestDevice` -`getMuse()` calls `requestDevice()` to scan, returns `[{ id, name }]`. Then when the user clicks Connect, `connectToMuse()` calls `requestDevice()` **again** with a name filter. This means the Bluetooth picker (or `select-bluetooth-device` event) fires twice for a single connection. Once the `select-bluetooth-device` handler is in place, both calls need to be handled. +`getMuse()` caches the `BluetoothDevice` and `connectToMuse()` reuses it, so +the picker event does not fire twice. Same pattern in `neurosity.ts`. -The cleaner fix is to cache the `BluetoothDevice` instance returned by the first `requestDevice()` call inside `getMuse()` and reuse it in `connectToMuse()`, skipping the second scan entirely. +### Still open: silent search failure -### Bug: Silent failure, no user feedback on search errors +In `searchMuseEpic` (name is historical — it calls `getDriver().scan()`), the +error path returns `[]` and nothing is dispatched. The user leaves "Searching..." +only when `searchTimerEpic` fires. The toast is silenced because Windows Web +Bluetooth rejects promiscuously. Worth revisiting once classroom QA has a +reliable Windows path. -In `searchMuseEpic`, the error handler returns `[]` and the filter `devices.length >= 1` blocks it from dispatching anything. The user only escapes the "Searching..." state when the 3-second `searchTimerEpic` fires. There is no error message, no indication of what went wrong. +### LSL inlet markers are a no-op (intentional) -The comment in the code acknowledges this: `"This error will fire a bit too promiscuously until we fix windows web bluetooth"` — the toast was intentionally silenced. Once the `select-bluetooth-device` handler is in place, errors will be more meaningful and the toast can be re-enabled. +LSL inlet is **not** in the `EEGDriver` registry. `injectMarker()` no-ops when +the active connection is an external stream — that recorder owns markers. +First-party Muse/Neurosity still inject locally (CSV + ERP) and, when liblsl +is loaded, `RunComponent` also `sendMarker()`s to the LSL outlet. --- @@ -214,4 +204,4 @@ rawObservable (SetRawObservable → Redux) | `components/CollectComponent/ConnectModal.tsx` | Search/connect UI | | `components/CollectComponent/index.tsx` | Auto-triggers search on mount | | `components/EEGExplorationComponent.tsx` | Standalone explore-mode connect UI | -| `main/index.ts` | **Missing: `select-bluetooth-device` handler** | +| `main/index.ts` | `select-bluetooth-device` auto-pick, `bluetooth:cancelSearch`, LSL IPC, `pyodide://` | diff --git a/docs/pyodide-in-electron-vite.md b/docs/pyodide-in-electron-vite.md index a6d6365c..e9ad722d 100644 --- a/docs/pyodide-in-electron-vite.md +++ b/docs/pyodide-in-electron-vite.md @@ -31,9 +31,11 @@ protocol.registerSchemesAsPrivileged([{ **Handler registered in `app.whenReady()`:** ```ts +// Dest name MUST be `pyodide` — it is `PYODIDE_RESOURCE_DIR` in +// src/shared/pyodideAssets.ts and package.json extraResources.to. const pyodideRoot = is.dev ? path.join(app.getAppPath(), 'src/renderer/utils/webworker/src') - : path.join(process.resourcesPath, 'webworker/src'); + : path.join(process.resourcesPath, 'pyodide'); protocol.handle('pyodide', (request) => { const { pathname } = new URL(request.url); @@ -110,12 +112,16 @@ These are easy to confuse: | Option | Purpose | |--------|---------| -| `indexURL` | Where Pyodide looks for its **runtime** files (WASM, stdlib). Already resolved from `node_modules` via `import.meta.url`. Do not override. | +| `indexURL` | Where Pyodide looks for its **runtime** files (WASM, stdlib). In **dev**, `import.meta.url` next to `node_modules/pyodide/` is enough. In **prod**, Vite bundles `pyodide.mjs` away from those siblings — you **must** set `indexURL` to `${PYODIDE_ASSET_BASE}/pyodide/` or it falls back to `calculateDirname()` and fetches `file:///…/app.asar/out/renderer/assets/pyodide.asm.js`. | | `packageBaseUrl` | Where `loadPackage()` fetches **package `.whl` files**. Set this to `pyodide://host/pyodide/`. | ```js const packageBaseUrl = `${PYODIDE_ASSET_BASE}/pyodide/`; -const pyodide = await loadPyodide({ lockFileURL, packageBaseUrl }); +const pyodide = await loadPyodide({ + lockFileURL, + packageBaseUrl, + indexURL: `${PYODIDE_ASSET_BASE}/pyodide/`, // required in packaged builds +}); ``` --- @@ -321,13 +327,11 @@ function svgToPngArrayBuffer(svg: string): Promise { The resulting `ArrayBuffer` is passed through the IPC chain (`preload → main`) for file write. Electron's `contextBridge` serialises `ArrayBuffer` correctly without any additional conversion. ---- - ## Summary of File Locations | What | Where | |------|-------| -| Pyodide runtime + binary wheels | `src/renderer/utils/webworker/src/pyodide/` | +| Pyodide runtime + binary wheels | `src/renderer/utils/webworker/src/pyodide/` (packaged dest: `resources/pyodide/`) | | Pure-Python wheels + manifest | `src/renderer/utils/webworker/src/packages/` | | Web worker entry point | `src/renderer/utils/webworker/webworker.js` | | JS wrappers for Python calls | `src/renderer/utils/webworker/index.ts` | diff --git a/docs/user-flow.md b/docs/user-flow.md index f9705323..f936abb1 100644 --- a/docs/user-flow.md +++ b/docs/user-flow.md @@ -1,122 +1,112 @@ # BrainWaves User Flow -This document describes the user flow through the BrainWaves application — an Electron desktop app for conducting EEG neuroscience experiments. +User flow through the BrainWaves Electron app. If this disagrees with the running code, the code wins — update this file. ## Flow Diagram ```mermaid flowchart TD - HOME["🏠 HOME"] + HOME["HOME"] HOME --> MY_EXP["MY EXPERIMENTS\n(saved workspaces)"] - HOME --> EXP_BANK["EXPERIMENT BANK\n(built-in cards)"] + HOME --> EXP_BANK["EXPERIMENT BANK\n(4 built-in cards)"] HOME --> EXPLORE["EXPLORE EEG DATA\n(raw streaming)"] MY_EXP -->|"Open Experiment"| DESIGN - EXP_BANK -->|"Pick card → Overview → Start"| DESIGN + EXP_BANK -->|"Pick card → Design"| DESIGN - EXPLORE --> CONNECT_MODAL_EXP["ConnectModal\n(find & connect device)"] + EXPLORE --> CONNECT_MODAL_EXP["ConnectModal\n(Muse / Neurosity / LSL)"] CONNECT_MODAL_EXP --> EEG_EXPLORE["Live EEG Viewer\n(signal quality + waveform)"] - subgraph DESIGN ["📋 DESIGN /design"] + subgraph DESIGN ["DESIGN /design"] direction TB - D_OV["OVERVIEW\n(title, description)"] - D_BG["BACKGROUND\n(framing questions, resources)"] - D_PR["PROTOCOL\n(step-by-step, condition images)"] - D_PV["PREVIEW\n(live experiment iframe)"] + D_OV["OVERVIEW"] + D_BG["BACKGROUND"] + D_PR["PROTOCOL"] + D_PV["PREVIEW\n(lab.js)"] D_OV --> D_BG --> D_PR --> D_PV - EEG_TOGGLE["Enable/Disable EEG toggle"] + EEG_TOGGLE["Enable/Disable EEG"] end DESIGN -->|"Top nav: Collect"| COLLECT - subgraph COLLECT ["🎧 COLLECT /collect"] + subgraph COLLECT ["COLLECT /collect"] direction TB PRE_TEST["PRE-TEST\n(signal quality + EEG viewer)"] - CONNECT_MODAL["ConnectModal\n① power on headset\n② plug in USB receiver\n③ select device → connect"] + CONNECT_MODAL["ConnectModal\n① power on headset\n② pick Muse / Neurosity / LSL\n③ select device → connect"] PRE_TEST -->|"EEG enabled & not connected"| CONNECT_MODAL CONNECT_MODAL -->|"Connected"| PRE_TEST PRE_TEST -->|"Run & Record"| RUN RUN["RUN\n(subject ID / group / session)"] - EXP_WINDOW["ExperimentWindow\n(full-screen lab.js iframe\n+ EEG timing markers)"] + EXP_WINDOW["ExperimentWindow\n(lab.js + EEG markers)"] RUN -->|"Run Experiment"| EXP_WINDOW - EXP_WINDOW -->|"Experiment complete\n(behavioral CSV saved)"| DONE_COLLECT["Recording saved ✓"] + EXP_WINDOW -->|"complete"| DONE_COLLECT["Recording saved"] end DONE_COLLECT -->|"EEG enabled\nTop nav: Clean"| CLEAN DONE_COLLECT -->|"Behavior only\nTop nav: Analyze"| ANALYZE - subgraph CLEAN ["🧹 CLEAN /clean\n(EEG only)"] + subgraph CLEAN ["CLEAN /clean\n(EEG only)"] direction TB - CL_SEL["Select subject\n+ select recording(s)"] - CL_LOAD["Load Dataset\n(Pyodide → epoch stats)"] - CL_CLEAN["Clean Data\n(artifact rejection in Pyodide)"] + CL_SEL["Select subject + recording(s)"] + CL_LOAD["Load Dataset\n(Pyodide epochs + reviewer)"] + CL_CLEAN["Clean Data\n(reject artifacts → .fif)"] CL_SEL --> CL_LOAD --> CL_CLEAN end - CLEAN -->|"Analyze Dataset →"| ANALYZE + CLEAN -->|"Analyze Dataset"| ANALYZE - subgraph ANALYZE ["📊 ANALYZE /analyze"] + subgraph ANALYZE ["ANALYZE /analyze"] direction TB - AN_OV["OVERVIEW\n(topoplot — scalp map)"] - AN_ERP["ERP\n(waveform by electrode)"] - AN_BEH["BEHAVIOR\n(RT / Accuracy charts\nbar · box · scatter)"] - AN_EXP["Export aggregated data"] - AN_OV --> AN_ERP - AN_ERP --> AN_BEH - AN_BEH --> AN_EXP + AN_OV["OVERVIEW\n(topoplot)"] + AN_ERP["ERP"] + AN_BEH["BEHAVIOR"] + AN_EXP["Export"] + AN_OV --> AN_ERP --> AN_BEH --> AN_EXP end - DESIGN -->|"Home button"| HOME - COLLECT -->|"Home button"| HOME - CLEAN -->|"Home button"| HOME - ANALYZE -->|"Home button"| HOME - - style HOME fill:#4A90D9,color:#fff - style DESIGN fill:#7B68EE,color:#fff - style COLLECT fill:#E8763A,color:#fff - style CLEAN fill:#3BAF7A,color:#fff - style ANALYZE fill:#D95B5B,color:#fff + DESIGN -->|"Home"| HOME + COLLECT -->|"Home"| HOME + CLEAN -->|"Home"| HOME + ANALYZE -->|"Home"| HOME ``` ## Stage Descriptions ### 1. Home (`/` and `/home`) -Entry point with three tabs: +Three tabs: -- **My Experiments** — table of previously saved workspaces; each row has Delete, Go to Folder, and Open Experiment actions. -- **Experiment Bank** — card grid of four built-in EEG paradigms: Faces/Houses (N170), Stroop Task, Multi-tasking, and Visual Search. Clicking a card opens an Overview panel before starting. -- **Explore EEG Data** — connects directly to a headset and streams live EEG without running a formal experiment. +- **My Experiments** — saved workspaces; Delete, Go to Folder, Open Experiment. +- **Experiment Bank** — five cards: Faces/Houses (N170), Stroop, Multi-tasking, Visual Search, and **Custom**. Built-in cards start a workspace and go to Design. Custom opens a title prompt, then Design with extra authoring tabs. +- **Explore EEG Data** — connect a headset and stream live EEG with no experiment. ### 2. Design (`/design`) -Four review tabs walk the researcher through the experiment before data collection: - | Tab | Content | |---|---| | **Overview** | Title and experiment description | -| **Background** | Framing questions and external reading resources | +| **Background** | Framing questions and external reading | | **Protocol** | Step-by-step instructions with condition images | -| **Preview** | Live experiment iframe (lab.js) | +| **Preview** | Live lab.js preview | -An **Enable EEG** toggle controls whether the Clean step appears downstream. Custom experiments have additional tabs for configuring conditions, trials, timing parameters, and instructions. +**Enable EEG** (gear / toggle) controls whether Clean appears downstream. -### 3. Collect (`/collect`) +Custom experiments add Conditions / Trials / Parameters / Instructions. Pick 1–4 image folders and key responses; the first image of each condition is a practice trial. Runtime is the Faces/Houses lab.js template parameterized by those stimuli (`filepath` URLs). `experiments/custom/experiment.js` is kept on disk but is not the runtime (it still uses the pre-Vite `this.files[dir/filename]` lookup). -Two sub-views: +### 3. Collect (`/collect`) -- **Pre-Test** — walks the user through `ConnectModal` (power on headset → plug in USB receiver → select device → connect), then shows live signal quality and a real-time EEG waveform. -- **Run** — collects subject ID, group name, and session number, then launches the experiment in a full-screen iframe. EEG timing markers are injected during the task. On completion, the behavioral CSV is saved automatically. +- **Pre-Test** — `ConnectModal` (headset on → pick **Muse**, **Neurosity Crown**, or **External LSL stream** if liblsl loaded → connect), then signal quality + live waveform. Muse/Neurosity are Web Bluetooth. There is no USB receiver (that was Emotiv). +- **Run** — subject ID, group, session → full-screen lab.js. Markers go through `injectMarker()` (active BLE driver) and, when LSL is available, `sendMarker()` to the outlet. Behavioral CSV is saved on end. ### 4. Clean (`/clean`) — EEG only -Shown only when EEG is enabled. +Shown when EEG is enabled. 1. Select a subject and one or more recordings. -2. **Load Dataset** — loads epochs into Pyodide (Python-in-browser) and returns epoch statistics. -3. **Clean Data** — runs artifact rejection via Pyodide. Once the drop percentage reaches a threshold, the *Analyze Dataset* button becomes available. +2. **Load Dataset** — Pyodide epochs + interactive `EpochReviewer` / `LiveErpPane`. +3. **Clean Data** — reject artifacts, write `.fif`. **Analyze Dataset** is available once `epochsInfo` exists; it is not gated on a drop-percentage threshold. ### 5. Analyze (`/analyze`) -- **EEG mode** — three tabs: topoplot (scalp map overview), ERP waveforms per electrode, and behavioral analysis. -- **Behavior-only mode** — one tab: interactive bar, box, or scatter plots for response time or accuracy, with an outlier-removal option and an export button. +- **EEG mode** — topoplot (Pyodide/matplotlib SVG), ERP waveforms, behavioral plots (Plotly). +- **Behavior-only mode** — RT / accuracy (bar, box, scatter), outlier removal, export. diff --git a/src/main/__tests__/workspaceState.test.ts b/src/main/__tests__/workspaceState.test.ts new file mode 100644 index 00000000..03cd8d42 --- /dev/null +++ b/src/main/__tests__/workspaceState.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { persistExperimentState } from '../workspaceState'; + +describe('persistExperimentState', () => { + let root: string; + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'bw-workspace-')); + }); + + afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + it('creates the workspace directory and writes appState.json when the dir is missing', () => { + const file = persistExperimentState(root, { + title: 'My_Custom', + type: 'Custom', + params: { + description: { + question: 'Do faces pop?', + hypothesis: 'Yes', + methods: 'Show pictures', + }, + }, + }); + + expect(file).toBe(path.join(root, 'My_Custom', 'appState.json')); + expect(fs.existsSync(file!)).toBe(true); + const written = JSON.parse(fs.readFileSync(file!, 'utf8')); + expect(written.params.description.hypothesis).toBe('Yes'); + }); + + it('does not write when title is missing', () => { + expect(persistExperimentState(root, { type: 'Custom' })).toBeNull(); + expect(fs.readdirSync(root)).toEqual([]); + }); +}); diff --git a/src/main/index.ts b/src/main/index.ts index a17d1a9b..d74f6073 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -24,14 +24,17 @@ import { autoUpdater } from 'electron-updater'; import log from 'electron-log'; import { is, optimizer } from '@electron-toolkit/utils'; import MenuBuilder from './menu'; -import { FILE_TYPES } from '../renderer/constants/constants'; +import { lslOutlets } from './lsl/outlets'; +import { lslInlets } from './lsl/inlets'; +import { isLSLAvailable } from './lsl/native'; +import { persistExperimentState } from './workspaceState'; import { PYODIDE_SOURCE_DIR, PYODIDE_RESOURCE_DIR, } from '../shared/pyodideAssets'; -import { lslOutlets } from './lsl/outlets'; -import { lslInlets } from './lsl/inlets'; -import { isLSLAvailable } from './lsl/native'; +import { FILE_TYPES } from '../renderer/constants/constants'; + + import type { LSLEpoch, LSLMarker, @@ -84,8 +87,7 @@ app.on('second-instance', (_event, argv) => { } }); -// Register pyodide:// as a privileged custom scheme so web workers can -// fetch() package .whl files from it. Must be called before app.whenReady(). +// Register privileged custom schemes before app.whenReady(). protocol.registerSchemesAsPrivileged([ { scheme: 'pyodide', @@ -96,8 +98,19 @@ protocol.registerSchemesAsPrivileged([ corsEnabled: true, // no CORS errors when Pyodide fetches its own assets }, }, + { + scheme: 'bwfile', + privileges: { + standard: true, + secure: true, + supportFetchAPI: true, + corsEnabled: true, + stream: true, + }, + }, ]); + export default class AppUpdater { constructor() { log.transports.file.level = 'info'; @@ -217,9 +230,7 @@ ipcMain.handle('fs:readAndParseState', (_event, dir) => { ipcMain.handle( 'fs:storeExperimentState', (_event, state: Record) => { - const dir = getWorkspaceDir(state.title as string); - if (!fs.existsSync(dir)) return; - fs.writeFileSync(path.join(dir, 'appState.json'), JSON.stringify(state)); + persistExperimentState(workspaces, state); } ); @@ -753,6 +764,12 @@ app.whenReady().then(async () => { return net.fetch(pathToFileURL(filePath).href); }); + protocol.handle('bwfile', (request) => { + const { pathname } = new URL(request.url); + return net.fetch(pathToFileURL(decodeURIComponent(pathname)).href); + }); + + // Enable F12 devtools shortcut and Ctrl+R reload in dev, disable in prod app.on('browser-window-created', (_, window) => { optimizer.watchWindowShortcuts(window); diff --git a/src/main/workspaceState.ts b/src/main/workspaceState.ts new file mode 100644 index 00000000..e5b3116a --- /dev/null +++ b/src/main/workspaceState.ts @@ -0,0 +1,17 @@ +import fs from 'fs'; +import path from 'path'; + +/** Write appState.json, creating the workspace directory if needed. */ +export function persistExperimentState( + workspacesRoot: string, + state: { title?: unknown } & Record +): string | null { + if (typeof state.title !== 'string' || state.title.length < 1) { + return null; + } + const dir = path.join(workspacesRoot, state.title); + fs.mkdirSync(dir, { recursive: true }); + const file = path.join(dir, 'appState.json'); + fs.writeFileSync(file, JSON.stringify(state)); + return file; +} diff --git a/src/renderer/components/DesignComponent/CustomDesignComponent.tsx b/src/renderer/components/DesignComponent/CustomDesignComponent.tsx index 332e985c..92840926 100644 --- a/src/renderer/components/DesignComponent/CustomDesignComponent.tsx +++ b/src/renderer/components/DesignComponent/CustomDesignComponent.tsx @@ -11,12 +11,23 @@ import { import { isString } from 'lodash'; import { SCREENS } from '../../constants/constants'; -import { ExperimentParameters } from '../../constants/interfaces'; +import { ExperimentParameters, Stimulus } from '../../constants/interfaces'; import { DesignProps } from './index'; import SecondaryNavComponent from '../SecondaryNavComponent'; import PreviewExperimentComponent from '../PreviewExperimentComponent'; import { ParamSlider } from './ParamSlider'; import PreviewButton from '../PreviewButtonComponent'; +import StimuliDesignColumn from './StimuliDesignColumn'; +import { StimuliRow } from './StimuliRow'; +import { readImages } from '../../utils/filesystem/storage'; +import { + CONDITION_SLOTS, + ConditionSlotName, + countPhases, + emptyConditionSlot, + rebuildStimuliFromSlots, +} from '../../utils/labjs/customStimuli'; +import { params as defaultCustomParams } from '../../experiments/custom/params'; import researchQuestionImage from '../../assets/common/ResearchQuestion2.png'; import methodsImage from '../../assets/common/Methods2.png'; import hypothesisImage from '../../assets/common/Hypothesis2.png'; @@ -51,7 +62,26 @@ export default class CustomDesign extends Component { this.state = { activeStep: CUSTOM_STEPS.OVERVIEW, isPreviewing: true, - params: props.params, + params: { + ...defaultCustomParams, + ...props.params, + stimulus1: { + ...defaultCustomParams.stimulus1, + ...props.params?.stimulus1, + }, + stimulus2: { + ...defaultCustomParams.stimulus2, + ...props.params?.stimulus2, + }, + stimulus3: { + ...defaultCustomParams.stimulus3, + ...props.params?.stimulus3, + }, + stimulus4: { + ...defaultCustomParams.stimulus4, + ...props.params?.stimulus4, + }, + }, saved: false, }; this.handleStepClick = this.handleStepClick.bind(this); @@ -63,6 +93,10 @@ export default class CustomDesign extends Component { this.endPreview = this.endPreview.bind(this); } + componentWillUnmount() { + this.handleSaveParams(); + } + endPreview() { this.setState({ isPreviewing: false }); } @@ -92,30 +126,71 @@ export default class CustomDesign extends Component { this.setState({ isPreviewing: !this.state.isPreviewing }); } - handleSaveParams() { - this.props.ExperimentActions.SetParams(this.state.params); + handleSaveParams(params: ExperimentParameters = this.state.params) { + this.props.ExperimentActions.SetParams(params); this.props.ExperimentActions.SaveWorkspace(); - this.setState({ saved: true }); + this.setState({ saved: true, params }); } handleSetText(text: string, section: 'hypothesis' | 'methods' | 'question') { - // @ts-expect-error - this.setState((prevState) => ({ - params: { - ...prevState.params, - description: { ...prevState.params.description, [section]: text }, - }, - saved: false, - })); + const params: ExperimentParameters = { + ...this.state.params, + description: { ...this.state.params.description, [section]: text }, + }; + this.setState({ params, saved: false }); + this.handleSaveParams(params); } + handleConditionChange = async ( + key: string, + data: string, + changedName: string + ) => { + const slotName = changedName as ConditionSlotName; + const slotMeta = CONDITION_SLOTS.find((s) => s.name === slotName); + if (!slotMeta) return; + const prev = + this.state.params[slotName] ?? + emptyConditionSlot(slotMeta.type, ''); + const nextParams: ExperimentParameters = { + ...this.state.params, + [slotName]: { ...prev, [key]: data }, + }; + const stimuli = await rebuildStimuliFromSlots(nextParams, readImages); + const { nbTrials, nbPracticeTrials } = countPhases(stimuli); + this.setState({ + params: { ...nextParams, stimuli, nbTrials, nbPracticeTrials }, + saved: false, + }); + this.handleSaveParams({ + ...nextParams, + stimuli, + nbTrials, + nbPracticeTrials, + }); + }; + + handleDeleteTrial = (deletedNum: number) => { + const stimuli = [...(this.state.params.stimuli ?? [])]; + stimuli.splice(deletedNum, 1); + const { nbTrials, nbPracticeTrials } = countPhases(stimuli); + const params = { ...this.state.params, stimuli, nbTrials, nbPracticeTrials }; + this.setState({ params, saved: false }); + this.handleSaveParams(params); + }; + + handleChangeTrial = (changedNum: number, key: string, data: string) => { + const stimuli: Stimulus[] = [...(this.state.params.stimuli ?? [])]; + const current = stimuli[changedNum]; + if (!current) return; + stimuli[changedNum] = { ...current, [key]: data }; + const { nbTrials, nbPracticeTrials } = countPhases(stimuli); + const params = { ...this.state.params, stimuli, nbTrials, nbPracticeTrials }; + this.setState({ params, saved: false }); + this.handleSaveParams(params); + }; + renderSectionContent() { - const stimi = [ - { name: 'stimulus1', number: 1 }, - { name: 'stimulus2', number: 2 }, - { name: 'stimulus3', number: 3 }, - { name: 'stimulus4', number: 4 }, - ]; switch (this.state.activeStep) { case CUSTOM_STEPS.OVERVIEW: default: @@ -204,18 +279,25 @@ export default class CustomDesign extends Component { - - - Stimulus customization is currently unavailable - - - {stimi.map(({ name, number }) => ( - - {`Stimulus name: ${name}, number: ${number}`} - - ))} + {CONDITION_SLOTS.map(({ name, number, type }) => { + const slot = + this.state.params[name] ?? emptyConditionSlot(type, ''); + return ( + trial.type === number + ).length + } + onChange={this.handleConditionChange} + /> + ); + })} @@ -302,11 +384,19 @@ export default class CustomDesign extends Component { - - - Stimulus customization is currently unavailable - - + {(this.state.params.stimuli ?? []).map((trial, num) => ( + + ))} diff --git a/src/renderer/components/ExperimentWindow.tsx b/src/renderer/components/ExperimentWindow.tsx index 1165b3c0..14b0f56b 100644 --- a/src/renderer/components/ExperimentWindow.tsx +++ b/src/renderer/components/ExperimentWindow.tsx @@ -18,13 +18,14 @@ export interface ExperimentWindowProps { onFinish: (csv: any) => void; // lab.js finish event data — shape is opaque third-party type } -// Converts an absolute filesystem path to a URL the renderer can load. -// In Vite dev mode, /@fs/ serves files outside publicDir. -// In production the renderer has a file:// origin so file:// URLs work directly. +// Custom experiments load user-chosen images from outside the repo. +// Vite /@fs 403s those paths (electron-vite drops server.fs.allow). +// Main serves them over bwfile:// in both dev and prod. function absPathToUrl(absPath: string): string { - return import.meta.env.DEV ? `/@fs${absPath}` : `file://${absPath}`; + return `bwfile://host${absPath}`; } + export const ExperimentWindow: React.FC = ({ title, experimentObject, diff --git a/src/renderer/components/HomeComponent/index.tsx b/src/renderer/components/HomeComponent/index.tsx index 55f33cf7..5911f760 100644 --- a/src/renderer/components/HomeComponent/index.tsx +++ b/src/renderer/components/HomeComponent/index.tsx @@ -18,6 +18,7 @@ import faceHouseIcon from '../../experiments/faces_houses/icon.png'; import stroopIcon from '../../experiments/stroop/icon.png'; import multitaskingIcon from '../../experiments/multitasking/icon.png'; import searchIcon from '../../experiments/search/icon.png'; +import customIcon from '../../experiments/custom/icon.png'; import appLogo from '../../assets/common/app_logo.png'; import divingMan from '../../assets/common/divingMan.svg'; import { @@ -309,6 +310,13 @@ export default class Home extends Component { description={`Examine why it is difficult to find your keys in a messy room.`} /> + this.handleNewExperiment(EXPERIMENTS.CUSTOM)} + icon={customIcon} + title="Custom" + description={`Design your own image experiment. Choose + condition folders and key responses.`} + /> ); case HOME_STEPS.EXPLORE: diff --git a/src/renderer/constants/interfaces.ts b/src/renderer/constants/interfaces.ts index a64bfa9c..2825061e 100644 --- a/src/renderer/constants/interfaces.ts +++ b/src/renderer/constants/interfaces.ts @@ -19,6 +19,13 @@ export interface WorkSpaceInfo { } // All mutable aspects of an experiment that can be updated by the DesignComponent +export type StimulusCondition = { + dir?: string; + title: string; + type: EVENTS; + response: string; +}; + export type ExperimentParameters = { // TODO: consider refactoring to expose lab.js sample.mode description?: ExperimentDescription; @@ -34,6 +41,12 @@ export type ExperimentParameters = { stimuli?: Stimulus[]; taskHelp?: string; trialDuration: number; + imageHeight?: string; + // Custom-experiment condition folders (2020 builder). Absent on built-ins. + stimulus1?: StimulusCondition; + stimulus2?: StimulusCondition; + stimulus3?: StimulusCondition; + stimulus4?: StimulusCondition; }; export interface Stimulus { diff --git a/src/renderer/epics/experimentEpics.ts b/src/renderer/epics/experimentEpics.ts index bced918f..1ff9a181 100644 --- a/src/renderer/epics/experimentEpics.ts +++ b/src/renderer/epics/experimentEpics.ts @@ -5,7 +5,7 @@ import { mergeMap, filter, takeUntil, - throttleTime, + debounceTime, tap, } from 'rxjs/operators'; import { isActionOf } from '../utils/redux'; @@ -46,15 +46,17 @@ const createNewWorkspaceEpic: Epic< action$.pipe( filter(isActionOf(ExperimentActions.CreateNewWorkspace)), map((action) => action.payload as WorkSpaceInfo), - mergeMap((workspaceInfo) => { + mergeMap(async (workspaceInfo) => { + await createWorkspaceDir(workspaceInfo.title); const experiment = getExperimentFromType(workspaceInfo.type); - return of( + return [ ExperimentActions.SetTitle(workspaceInfo.title), ExperimentActions.SetType(workspaceInfo.type), ExperimentActions.SetExperimentObject(experiment?.experimentObject), - ExperimentActions.SetParams(experiment?.params) - ); - }) + ExperimentActions.SetParams(experiment?.params), + ]; + }), + mergeMap((actions) => of(...actions)) ); const startEpic = (action$, state$) => @@ -181,7 +183,7 @@ const saveWorkspaceEpic: Epic< > = (action$, state$) => action$.pipe( filter(isActionOf(ExperimentActions.SaveWorkspace)), - throttleTime(1000), + debounceTime(400), filter(() => state$.value.experiment.title ? state$.value.experiment.title.length > 1 diff --git a/src/renderer/experiments/custom/index.ts b/src/renderer/experiments/custom/index.ts index 2d765568..12a8e127 100644 --- a/src/renderer/experiments/custom/index.ts +++ b/src/renderer/experiments/custom/index.ts @@ -1,4 +1,18 @@ -export { facesHousesExperiment } from './experiment'; -export { background } from './content_background'; -export { protocol } from './content_protocol'; -export { overview } from './content_overview'; +import { facesHousesExperiment as experimentObject } from '../faces_houses/experiment'; +import { params } from './params'; +import { background } from './content_background'; +import { protocol } from './content_protocol'; +import { overview } from './content_overview'; +import icon from './icon.png'; +import type { Experiment } from '../../constants/interfaces'; + +export default { + icon, + experimentObject, + params, + text: { + protocol, + background, + overview, + }, +} as Experiment; diff --git a/src/renderer/experiments/custom/params.ts b/src/renderer/experiments/custom/params.ts new file mode 100644 index 00000000..5d1cc629 --- /dev/null +++ b/src/renderer/experiments/custom/params.ts @@ -0,0 +1,28 @@ +import { EVENTS } from '../../constants/constants'; +import { ExperimentParameters } from '../../constants/interfaces'; +import { emptyConditionSlot } from '../../utils/labjs/customStimuli'; + +export const params: ExperimentParameters = { + randomize: 'random', + trialDuration: 1000, + imageHeight: '500px', + nbTrials: 0, + nbPracticeTrials: 0, + iti: 500, + presentationTime: 1000, + selfPaced: true, + sampleType: 'with-replacement', + intro: '', + taskHelp: '', + showProgressBar: false, + description: { + question: '', + hypothesis: '', + methods: '', + }, + stimulus1: emptyConditionSlot(EVENTS.STIMULUS_1, 'Condition 1'), + stimulus2: emptyConditionSlot(EVENTS.STIMULUS_2, 'Condition 2'), + stimulus3: emptyConditionSlot(EVENTS.STIMULUS_3, ''), + stimulus4: emptyConditionSlot(EVENTS.STIMULUS_4, ''), + stimuli: [], +}; diff --git a/src/renderer/index.html b/src/renderer/index.html index 60a65372..3e53cfcc 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -4,8 +4,9 @@ + BrainWaves diff --git a/src/renderer/utils/labjs/__tests__/customStimuli.test.ts b/src/renderer/utils/labjs/__tests__/customStimuli.test.ts new file mode 100644 index 00000000..5199c127 --- /dev/null +++ b/src/renderer/utils/labjs/__tests__/customStimuli.test.ts @@ -0,0 +1,107 @@ +import { describe, it, expect } from 'vitest'; +import { EVENTS } from '../../../constants/constants'; +import { ExperimentParameters } from '../../../constants/interfaces'; +import { + emptyConditionSlot, + rebuildStimuliFromSlots, + stimuliFromImageLists, +} from '../customStimuli'; + +describe('stimuliFromImageLists', () => { + it('builds one stimulus per image and marks the first of each condition as practice', () => { + const stimuli = stimuliFromImageLists([ + { + dir: '/faces', + title: 'Face', + response: '1', + type: EVENTS.STIMULUS_1, + images: ['Face1.jpg', 'Face2.jpg'], + }, + { + dir: '/houses', + title: 'House', + response: '9', + type: EVENTS.STIMULUS_2, + images: ['House1.jpg'], + }, + ]); + + expect(stimuli).toEqual([ + { + dir: '/faces', + filename: 'Face1.jpg', + title: 'Face1.jpg', + condition: 'Face', + response: '1', + phase: 'practice', + type: EVENTS.STIMULUS_1, + }, + { + dir: '/faces', + filename: 'Face2.jpg', + title: 'Face2.jpg', + condition: 'Face', + response: '1', + phase: 'main', + type: EVENTS.STIMULUS_1, + }, + { + dir: '/houses', + filename: 'House1.jpg', + title: 'House1.jpg', + condition: 'House', + response: '9', + phase: 'practice', + type: EVENTS.STIMULUS_2, + }, + ]); + }); + + it('skips condition slots with no directory or no images', () => { + expect( + stimuliFromImageLists([ + { + dir: '', + title: 'Empty', + response: '1', + type: EVENTS.STIMULUS_1, + images: ['x.jpg'], + }, + { + dir: '/cats', + title: 'Cat', + response: '2', + type: EVENTS.STIMULUS_2, + images: [], + }, + ]) + ).toEqual([]); + }); +}); + +describe('rebuildStimuliFromSlots', () => { + it('reads images per condition dir and applies the 2020 practice rule', async () => { + const params = { + stimulus1: { + ...emptyConditionSlot(EVENTS.STIMULUS_1, 'Face'), + dir: '/faces', + response: '1', + }, + stimulus2: { + ...emptyConditionSlot(EVENTS.STIMULUS_2, 'House'), + dir: '/houses', + response: '9', + }, + } as ExperimentParameters; + + const readImages = async (dir: string) => + dir === '/faces' ? ['Face1.jpg', 'Face2.jpg'] : ['House1.jpg']; + + const stimuli = await rebuildStimuliFromSlots(params, readImages); + expect(stimuli.map((s) => [s.condition, s.phase, s.filename])).toEqual([ + ['Face', 'practice', 'Face1.jpg'], + ['Face', 'main', 'Face2.jpg'], + ['House', 'practice', 'House1.jpg'], + ]); + }); +}); diff --git a/src/renderer/utils/labjs/__tests__/getExperimentFromType.test.ts b/src/renderer/utils/labjs/__tests__/getExperimentFromType.test.ts new file mode 100644 index 00000000..c78bc87e --- /dev/null +++ b/src/renderer/utils/labjs/__tests__/getExperimentFromType.test.ts @@ -0,0 +1,17 @@ +import { describe, it, expect } from 'vitest'; +import { EXPERIMENTS } from '../../../constants/constants'; +import { getExperimentFromType } from '../functions'; + +describe('getExperimentFromType', () => { + it('returns the custom pack, not Faces/Houses, for EXPERIMENTS.CUSTOM', () => { + const custom = getExperimentFromType(EXPERIMENTS.CUSTOM); + const faces = getExperimentFromType(EXPERIMENTS.N170); + + expect(custom.params.stimuli).toEqual([]); + expect(custom.params.stimulus1?.title).toBe('Condition 1'); + expect(custom.params.nbTrials).toBe(0); + expect(custom.experimentObject).toBeTruthy(); + expect(custom.params).not.toBe(faces.params); + expect(custom.text.overview.title).toBe('Custom Experiment'); + }); +}); diff --git a/src/renderer/utils/labjs/customStimuli.ts b/src/renderer/utils/labjs/customStimuli.ts new file mode 100644 index 00000000..23b4cc9d --- /dev/null +++ b/src/renderer/utils/labjs/customStimuli.ts @@ -0,0 +1,98 @@ +import { EVENTS } from '../../constants/constants'; +import { + ExperimentParameters, + Stimulus, + StimulusCondition, +} from '../../constants/interfaces'; + +export type ConditionSlotName = + | 'stimulus1' + | 'stimulus2' + | 'stimulus3' + | 'stimulus4'; + +export type ConditionSlot = StimulusCondition; +export const CONDITION_SLOTS: Array<{ + name: ConditionSlotName; + number: number; + type: EVENTS; +}> = [ + { name: 'stimulus1', number: 1, type: EVENTS.STIMULUS_1 }, + { name: 'stimulus2', number: 2, type: EVENTS.STIMULUS_2 }, + { name: 'stimulus3', number: 3, type: EVENTS.STIMULUS_3 }, + { name: 'stimulus4', number: 4, type: EVENTS.STIMULUS_4 }, +]; + +export const emptyConditionSlot = ( + type: EVENTS, + title: string +): ConditionSlot => ({ + dir: '', + title, + type, + response: '', +}); + +export type ConditionImageList = ConditionSlot & { images: string[] }; + +/** 2020 CustomDesign rule: first image of each condition is practice. */ +export function stimuliFromImageLists( + slots: ConditionImageList[] +): Stimulus[] { + const stimuli: Stimulus[] = []; + for (const slot of slots) { + if (!slot.dir || slot.images.length === 0) continue; + const title = slot.title || `Condition ${slot.type}`; + slot.images.forEach((filename, index) => { + stimuli.push({ + dir: slot.dir, + filename, + title: filename, + condition: title, + response: slot.response, + phase: index === 0 ? 'practice' : 'main', + type: slot.type, + }); + }); + } + return stimuli; +} + +export function countPhases(stimuli: Stimulus[]): { + nbTrials: number; + nbPracticeTrials: number; +} { + return { + nbTrials: stimuli.filter((s) => s.phase === 'main').length, + nbPracticeTrials: stimuli.filter((s) => s.phase === 'practice').length, + }; +} + +export async function rebuildStimuliFromSlots( + params: ExperimentParameters, + readImages: (dir: string) => Promise +): Promise { + const lists: ConditionImageList[] = []; + for (const slot of CONDITION_SLOTS) { + const cond = params[slot.name]; + if (!cond?.dir) { + lists.push({ + dir: '', + title: cond?.title ?? '', + type: slot.type, + response: cond?.response ?? '', + images: [], + }); + continue; + } + const images = await readImages(cond.dir); + lists.push({ + dir: cond.dir, + title: cond.title, + type: cond.type ?? slot.type, + response: cond.response, + images, + }); + } + return stimuliFromImageLists(lists); +} diff --git a/src/renderer/utils/labjs/functions.ts b/src/renderer/utils/labjs/functions.ts index 4e2dd754..9d115667 100644 --- a/src/renderer/utils/labjs/functions.ts +++ b/src/renderer/utils/labjs/functions.ts @@ -8,14 +8,15 @@ import { } from '../../constants/interfaces'; import facesHousesExperiment from '../../experiments/faces_houses'; import stroopExperiment from '../../experiments/stroop'; -// import customExperiment from '../../experiments/custom'; +import customExperiment from '../../experiments/custom'; import searchExperiment from '../../experiments/search'; import multitaskingExperiment from '../../experiments/multitasking'; function absPathToUrl(absPath: string): string { - return import.meta.env.DEV ? `/@fs${absPath}` : `file://${absPath}`; + return `bwfile://host${absPath}`; } + /** * Returns all data necessary to fully describe an experiment from the experiment type * Used in order to instantiate experiment state in redux when creating a new workspace, @@ -27,16 +28,11 @@ export function getExperimentFromType(type: EXPERIMENTS): Experiment { return multitaskingExperiment; case EXPERIMENTS.STROOP: return stroopExperiment; - case EXPERIMENTS.NONE: - return facesHousesExperiment; - // case EXPERIMENTS.CUSTOM: - // return facesHousesExperiment; - // case EXPERIMENTS.P300: - // return p300Experiment; case EXPERIMENTS.SEARCH: return searchExperiment; - // case EXPERIMENTS.SSVEP: - // return ssvepExperiment; + case EXPERIMENTS.CUSTOM: + return customExperiment; + case EXPERIMENTS.NONE: case EXPERIMENTS.N170: default: return facesHousesExperiment; diff --git a/vite.config.ts b/vite.config.ts index 7cc1b916..5797b2a0 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -51,13 +51,6 @@ export default defineConfig({ events: 'events', }, }, - server: { - fs: { - // Allow /@fs/ access to the whole repo root so lab.js can load - // experiment stimuli from their absolute filesystem paths. - allow: ['.'], - }, - }, optimizeDeps: { include: ['@neurosity/pipes'], // Prevent Vite from pre-bundling pyodide. In dev mode it will be served From 432f7981d229ae3696a6ca82062395e5eb8d8559 Mon Sep 17 00:00:00 2001 From: jdpigeon Date: Tue, 18 Aug 2026 14:59:01 -0400 Subject: [PATCH 2/3] fix: resolve PR #241 merge blockers - Add concrete defaults merge for sparse custom params (fixes Ubuntu typecheck) - Capture workspace state before debounce to survive navigation cleanup - Serialize async condition rebuilds with revision guards - Replace raw bwfile:// path concatenation with authorized URL registry - Add .jpeg/.webp support and cross-platform URL encoding - Add regression tests for all fixed behaviors --- .gstack/browse-audit.jsonl | 4 + .gstack/claude-available.json | 6 + src/main/__tests__/stimulusFileAccess.test.ts | 62 ++++++++++ src/main/index.ts | 31 +++-- src/main/stimulusFileAccess.ts | 72 ++++++++++++ .../DesignComponent/CustomDesignComponent.tsx | 106 +++++++++++------- .../__tests__/CustomDesignComponent.test.ts | 57 ++++++++++ src/renderer/components/ExperimentWindow.tsx | 14 +-- src/renderer/constants/interfaces.ts | 2 +- .../epics/__tests__/experimentEpics.test.ts | 99 ++++++++++++++++ src/renderer/epics/experimentEpics.ts | 14 +-- .../custom/__tests__/params.test.ts | 24 ++++ src/renderer/experiments/custom/params.ts | 33 +++++- src/renderer/utils/labjs/functions.ts | 10 +- src/shared/stimulusUrl.ts | 7 ++ 15 files changed, 460 insertions(+), 81 deletions(-) create mode 100644 .gstack/browse-audit.jsonl create mode 100644 .gstack/claude-available.json create mode 100644 src/main/__tests__/stimulusFileAccess.test.ts create mode 100644 src/main/stimulusFileAccess.ts create mode 100644 src/renderer/components/DesignComponent/__tests__/CustomDesignComponent.test.ts create mode 100644 src/renderer/epics/__tests__/experimentEpics.test.ts create mode 100644 src/renderer/experiments/custom/__tests__/params.test.ts create mode 100644 src/shared/stimulusUrl.ts diff --git a/.gstack/browse-audit.jsonl b/.gstack/browse-audit.jsonl new file mode 100644 index 00000000..ff7f8bc7 --- /dev/null +++ b/.gstack/browse-audit.jsonl @@ -0,0 +1,4 @@ +{"ts":"2026-07-14T21:39:03.322Z","cmd":"status","args":"","origin":"about:blank","durationMs":2,"status":"ok","hasCookies":false,"mode":"launched"} +{"ts":"2026-07-14T21:44:12.653Z","cmd":"goto","args":"http://localhost:5173/","origin":"http://localhost:5173/","durationMs":714,"status":"ok","hasCookies":false,"mode":"launched"} +{"ts":"2026-07-14T21:44:14.724Z","cmd":"console","args":"--errors","origin":"http://localhost:5173/","durationMs":1,"status":"ok","hasCookies":false,"mode":"launched"} +{"ts":"2026-07-14T21:44:14.780Z","cmd":"snapshot","args":"-i -o /Users/dano/work/BrainWaves/.gstack/qa-reports/screenshots/initial.png","origin":"http://localhost:5173/","durationMs":17,"status":"ok","hasCookies":false,"mode":"launched"} diff --git a/.gstack/claude-available.json b/.gstack/claude-available.json new file mode 100644 index 00000000..18e96659 --- /dev/null +++ b/.gstack/claude-available.json @@ -0,0 +1,6 @@ +{ + "available": true, + "path": "/Users/dano/.local/bin/claude", + "install_url": "https://docs.anthropic.com/en/docs/claude-code", + "checked_at": "2026-07-14T21:40:03.299Z" +} \ No newline at end of file diff --git a/src/main/__tests__/stimulusFileAccess.test.ts b/src/main/__tests__/stimulusFileAccess.test.ts new file mode 100644 index 00000000..b1f203ba --- /dev/null +++ b/src/main/__tests__/stimulusFileAccess.test.ts @@ -0,0 +1,62 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { StimulusFileAccess } from '../stimulusFileAccess'; +import { toStimulusFileUrl } from '../../shared/stimulusUrl'; + +describe('StimulusFileAccess', () => { + let root: string; + let selected: string; + let outside: string; + let access: StimulusFileAccess; + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'bw-stimulus-')); + selected = path.join(root, 'selected'); + outside = path.join(root, 'outside'); + fs.mkdirSync(selected); + fs.mkdirSync(outside); + access = new StimulusFileAccess(path.join(root, 'authorized.json')); + access.authorizeDirectory(selected); + }); + + afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + it('resolves encoded files beneath a selected directory', () => { + const file = path.join(selected, 'face #1?.jpeg'); + fs.writeFileSync(file, 'image'); + expect(access.resolveUrl(toStimulusFileUrl(file))).toBe( + fs.realpathSync(file) + ); + }); + + it('rejects files outside selected directories', () => { + const file = path.join(outside, 'secret.png'); + fs.writeFileSync(file, 'image'); + + expect(() => access.resolveUrl(toStimulusFileUrl(file))).toThrow( + 'StimulusFileAccess.resolveUrl: file is not in an authorized directory' + ); + }); + + it('reloads selected directories for restored workspaces', () => { + const reloaded = new StimulusFileAccess(path.join(root, 'authorized.json')); + const file = path.join(selected, 'restored.webp'); + fs.writeFileSync(file, 'image'); + expect(reloaded.resolveUrl(toStimulusFileUrl(file))).toBe( + fs.realpathSync(file) + ); + }); +}); + +describe('toStimulusFileUrl', () => { + it('round-trips Windows paths and URL-reserved characters', () => { + const windowsPath = String.raw`C:\Users\Student\faces\face #1?.png`; + const url = new URL(toStimulusFileUrl(windowsPath)); + + expect(url.searchParams.get('path')).toBe(windowsPath); + }); +}); diff --git a/src/main/index.ts b/src/main/index.ts index d74f6073..44b085c2 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -28,13 +28,12 @@ import { lslOutlets } from './lsl/outlets'; import { lslInlets } from './lsl/inlets'; import { isLSLAvailable } from './lsl/native'; import { persistExperimentState } from './workspaceState'; +import { StimulusFileAccess } from './stimulusFileAccess'; import { PYODIDE_SOURCE_DIR, PYODIDE_RESOURCE_DIR, } from '../shared/pyodideAssets'; import { FILE_TYPES } from '../renderer/constants/constants'; - - import type { LSLEpoch, LSLMarker, @@ -103,14 +102,11 @@ protocol.registerSchemesAsPrivileged([ privileges: { standard: true, secure: true, - supportFetchAPI: true, - corsEnabled: true, stream: true, }, }, ]); - export default class AppUpdater { constructor() { log.transports.file.level = 'info'; @@ -131,6 +127,14 @@ let pendingBluetoothCallback: ((deviceId: string) => void) | null = null; // ------------------------------------------------------------------ const workspaces = path.join(os.homedir(), 'BrainWaves_Workspaces'); +let stimulusFileAccess: StimulusFileAccess | undefined; + +const getStimulusFileAccess = () => { + stimulusFileAccess ??= new StimulusFileAccess( + path.join(app.getPath('userData'), 'stimulus-directories.json') + ); + return stimulusFileAccess; +}; const getWorkspaceDir = (title: string) => path.join(workspaces, title); @@ -163,7 +167,10 @@ ipcMain.handle('loadDialog', async (_event, fileType) => { title: 'Select a folder of images', properties: ['openDirectory'], }); - return result.canceled ? '' : result.filePaths[0]; + if (result.canceled) return ''; + const directory = result.filePaths[0]; + getStimulusFileAccess().authorizeDirectory(directory); + return directory; } const result = await dialog.showOpenDialog(mainWindow!, { title: 'Select a jsPsych timeline file', @@ -375,10 +382,10 @@ ipcMain.handle('fs:deleteWorkspaceDir', (_event, title) => ); ipcMain.handle('fs:readImages', (_event, dir) => { - return fs.readdirSync(dir).filter((filename) => { - const ext = filename.slice(-3).toLowerCase(); - return ext === 'png' || ext === 'jpg' || ext === 'gif' || ext === 'peg'; - }); + const imageExtensions = new Set(['.gif', '.jpeg', '.jpg', '.png', '.webp']); + return fs + .readdirSync(dir) + .filter((filename) => imageExtensions.has(path.extname(filename).toLowerCase())); }); ipcMain.handle( @@ -765,8 +772,8 @@ app.whenReady().then(async () => { }); protocol.handle('bwfile', (request) => { - const { pathname } = new URL(request.url); - return net.fetch(pathToFileURL(decodeURIComponent(pathname)).href); + const filePath = getStimulusFileAccess().resolveUrl(request.url); + return net.fetch(pathToFileURL(filePath).href); }); diff --git a/src/main/stimulusFileAccess.ts b/src/main/stimulusFileAccess.ts new file mode 100644 index 00000000..adbe8af3 --- /dev/null +++ b/src/main/stimulusFileAccess.ts @@ -0,0 +1,72 @@ +import fs from 'fs'; +import path from 'path'; + +export class StimulusFileAccess { + private readonly directories = new Set(); + + constructor(private readonly storagePath: string) { + for (const directory of this.loadDirectories()) { + this.directories.add(directory); + } + } + + authorizeDirectory(directory: string): void { + const canonical = fs.realpathSync(directory); + if (!fs.statSync(canonical).isDirectory()) { + throw new Error( + 'StimulusFileAccess.authorizeDirectory: expected a directory' + ); + } + if (this.directories.has(canonical)) return; + + this.directories.add(canonical); + fs.mkdirSync(path.dirname(this.storagePath), { recursive: true }); + fs.writeFileSync( + this.storagePath, + JSON.stringify([...this.directories].sort()) + ); + } + + resolveUrl(requestUrl: string): string { + const url = new URL(requestUrl); + const requestedPath = url.searchParams.get('path'); + if (url.protocol !== 'bwfile:' || !requestedPath || !path.isAbsolute(requestedPath)) { + throw new Error('StimulusFileAccess.resolveUrl: invalid stimulus URL'); + } + + const canonical = fs.realpathSync(requestedPath); + const authorized = [...this.directories].some((directory) => { + const relative = path.relative(directory, canonical); + return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)); + }); + if (!authorized) { + throw new Error( + 'StimulusFileAccess.resolveUrl: file is not in an authorized directory' + ); + } + if (!fs.statSync(canonical).isFile()) { + throw new Error('StimulusFileAccess.resolveUrl: expected a file'); + } + return canonical; + } + + private loadDirectories(): string[] { + try { + const stored = JSON.parse(fs.readFileSync(this.storagePath, 'utf8')); + if (!Array.isArray(stored)) return []; + return stored.flatMap((directory) => { + if (typeof directory !== 'string') return []; + try { + const canonical = fs.realpathSync(directory); + return fs.statSync(canonical).isDirectory() ? [canonical] : []; + } catch { + return []; + } + }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []; + if (error instanceof SyntaxError) return []; + throw error; + } + } +} diff --git a/src/renderer/components/DesignComponent/CustomDesignComponent.tsx b/src/renderer/components/DesignComponent/CustomDesignComponent.tsx index 92840926..685075e1 100644 --- a/src/renderer/components/DesignComponent/CustomDesignComponent.tsx +++ b/src/renderer/components/DesignComponent/CustomDesignComponent.tsx @@ -27,7 +27,10 @@ import { emptyConditionSlot, rebuildStimuliFromSlots, } from '../../utils/labjs/customStimuli'; -import { params as defaultCustomParams } from '../../experiments/custom/params'; +import { + mergeCustomParams, + params as defaultCustomParams, +} from '../../experiments/custom/params'; import researchQuestionImage from '../../assets/common/ResearchQuestion2.png'; import methodsImage from '../../assets/common/Methods2.png'; import hypothesisImage from '../../assets/common/Hypothesis2.png'; @@ -57,31 +60,16 @@ interface State { } export default class CustomDesign extends Component { + private conditionParams: ExperimentParameters; + private conditionRevision = 0; constructor(props: DesignProps) { super(props); + const customParams = mergeCustomParams(props.params); + this.conditionParams = customParams; this.state = { activeStep: CUSTOM_STEPS.OVERVIEW, isPreviewing: true, - params: { - ...defaultCustomParams, - ...props.params, - stimulus1: { - ...defaultCustomParams.stimulus1, - ...props.params?.stimulus1, - }, - stimulus2: { - ...defaultCustomParams.stimulus2, - ...props.params?.stimulus2, - }, - stimulus3: { - ...defaultCustomParams.stimulus3, - ...props.params?.stimulus3, - }, - stimulus4: { - ...defaultCustomParams.stimulus4, - ...props.params?.stimulus4, - }, - }, + params: customParams, saved: false, }; this.handleStepClick = this.handleStepClick.bind(this); @@ -94,7 +82,8 @@ export default class CustomDesign extends Component { } componentWillUnmount() { - this.handleSaveParams(); + this.props.ExperimentActions.SetParams(this.conditionParams); + this.props.ExperimentActions.SaveWorkspace(); } endPreview() { @@ -126,7 +115,8 @@ export default class CustomDesign extends Component { this.setState({ isPreviewing: !this.state.isPreviewing }); } - handleSaveParams(params: ExperimentParameters = this.state.params) { + handleSaveParams(params: ExperimentParameters = this.conditionParams) { + this.conditionParams = params; this.props.ExperimentActions.SetParams(params); this.props.ExperimentActions.SaveWorkspace(); this.setState({ saved: true, params }); @@ -134,8 +124,12 @@ export default class CustomDesign extends Component { handleSetText(text: string, section: 'hypothesis' | 'methods' | 'question') { const params: ExperimentParameters = { - ...this.state.params, - description: { ...this.state.params.description, [section]: text }, + ...this.conditionParams, + description: { + ...defaultCustomParams.description, + ...this.conditionParams.description, + [section]: text, + }, }; this.setState({ params, saved: false }); this.handleSaveParams(params); @@ -147,27 +141,55 @@ export default class CustomDesign extends Component { changedName: string ) => { const slotName = changedName as ConditionSlotName; - const slotMeta = CONDITION_SLOTS.find((s) => s.name === slotName); + const slotMeta = CONDITION_SLOTS.find((slot) => slot.name === slotName); if (!slotMeta) return; - const prev = - this.state.params[slotName] ?? + + const previousSlot = + this.conditionParams[slotName] ?? emptyConditionSlot(slotMeta.type, ''); - const nextParams: ExperimentParameters = { - ...this.state.params, - [slotName]: { ...prev, [key]: data }, + let nextParams: ExperimentParameters = { + ...this.conditionParams, + [slotName]: { ...previousSlot, [key]: data }, }; - const stimuli = await rebuildStimuliFromSlots(nextParams, readImages); - const { nbTrials, nbPracticeTrials } = countPhases(stimuli); - this.setState({ - params: { ...nextParams, stimuli, nbTrials, nbPracticeTrials }, - saved: false, - }); - this.handleSaveParams({ - ...nextParams, - stimuli, - nbTrials, - nbPracticeTrials, + this.conditionParams = nextParams; + + if (key !== 'dir') { + const changedSlot = nextParams[slotName]!; + const stimuli = (nextParams.stimuli ?? []).map((stimulus) => + stimulus.type === slotMeta.type + ? { + ...stimulus, + condition: changedSlot.title, + response: changedSlot.response, + } + : stimulus + ); + nextParams = { ...nextParams, stimuli }; + this.setState({ params: nextParams, saved: false }); + this.handleSaveParams(nextParams); + return; + } + + const revision = ++this.conditionRevision; + const rebuiltStimuli = await rebuildStimuliFromSlots(nextParams, readImages); + if (revision !== this.conditionRevision) return; + + const latestParams = this.conditionParams; + const stimuli = rebuiltStimuli.map((stimulus) => { + const slot = CONDITION_SLOTS.find(({ type }) => type === stimulus.type); + const condition = slot ? latestParams[slot.name] : undefined; + return condition + ? { + ...stimulus, + condition: condition.title, + response: condition.response, + } + : stimulus; }); + const { nbTrials, nbPracticeTrials } = countPhases(stimuli); + const params = { ...latestParams, stimuli, nbTrials, nbPracticeTrials }; + this.setState({ params, saved: false }); + this.handleSaveParams(params); }; handleDeleteTrial = (deletedNum: number) => { diff --git a/src/renderer/components/DesignComponent/__tests__/CustomDesignComponent.test.ts b/src/renderer/components/DesignComponent/__tests__/CustomDesignComponent.test.ts new file mode 100644 index 00000000..2b07e2cc --- /dev/null +++ b/src/renderer/components/DesignComponent/__tests__/CustomDesignComponent.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('lab.js', () => ({})); +import { EXPERIMENTS } from '../../../constants/constants'; +import type { DesignProps } from '../index'; +import CustomDesign from '../CustomDesignComponent'; +import { params } from '../../../experiments/custom/params'; + +let finishRead: ((images: string[]) => void) | undefined; + +vi.mock('../../../utils/filesystem/storage', () => ({ + readImages: vi.fn( + () => + new Promise((resolve) => { + finishRead = resolve; + }) + ), +})); + +const makeProps = (): DesignProps => + ({ + navigate: vi.fn(), + type: EXPERIMENTS.CUSTOM, + title: 'My_Custom', + params, + experimentObject: {}, + ExperimentActions: { + SetParams: vi.fn(), + SaveWorkspace: vi.fn(), + SetEEGEnabled: vi.fn(), + }, + isEEGEnabled: true, + }) as unknown as DesignProps; + +describe('CustomDesign condition updates', () => { + it('does not let an older folder scan overwrite a newer condition edit', async () => { + const design = new CustomDesign(makeProps()); + design.setState = ((update) => { + const patch = + typeof update === 'function' + ? update(design.state, design.props) + : update; + design.state = { ...design.state, ...patch }; + }) as typeof design.setState; + + const folderUpdate = design.handleConditionChange( + 'dir', + '/slow-folder', + 'stimulus1' + ); + await design.handleConditionChange('title', 'Newest title', 'stimulus1'); + finishRead?.(['face.png']); + await folderUpdate; + + expect(design.state.params.stimulus1?.title).toBe('Newest title'); + }); +}); diff --git a/src/renderer/components/ExperimentWindow.tsx b/src/renderer/components/ExperimentWindow.tsx index 14b0f56b..0f6082a7 100644 --- a/src/renderer/components/ExperimentWindow.tsx +++ b/src/renderer/components/ExperimentWindow.tsx @@ -7,6 +7,7 @@ import { ExperimentParameters, Stimulus, } from '../constants/interfaces'; +import { toStimulusFileUrl } from '../../shared/stimulusUrl'; export interface ExperimentWindowProps { title: string; @@ -18,14 +19,6 @@ export interface ExperimentWindowProps { onFinish: (csv: any) => void; // lab.js finish event data — shape is opaque third-party type } -// Custom experiments load user-chosen images from outside the repo. -// Vite /@fs 403s those paths (electron-vite drops server.fs.allow). -// Main serves them over bwfile:// in both dev and prod. -function absPathToUrl(absPath: string): string { - return `bwfile://host${absPath}`; -} - - export const ExperimentWindow: React.FC = ({ title, experimentObject, @@ -51,7 +44,10 @@ export const ExperimentWindow: React.FC = ({ experimentToRun.options.media.images = params.stimuli?.reduce( (images, stimulus) => { if (stimulus.dir && stimulus.filename) { - return [...images, absPathToUrl(path.join(stimulus.dir, stimulus.filename))]; + return [ + ...images, + toStimulusFileUrl(path.join(stimulus.dir, stimulus.filename)), + ]; } return images; }, diff --git a/src/renderer/constants/interfaces.ts b/src/renderer/constants/interfaces.ts index 2825061e..39d1ef49 100644 --- a/src/renderer/constants/interfaces.ts +++ b/src/renderer/constants/interfaces.ts @@ -59,7 +59,7 @@ export interface Stimulus { filename?: string; } -interface ExperimentDescription { +export interface ExperimentDescription { question: string; hypothesis: string; methods: string; diff --git a/src/renderer/epics/__tests__/experimentEpics.test.ts b/src/renderer/epics/__tests__/experimentEpics.test.ts new file mode 100644 index 00000000..69f857eb --- /dev/null +++ b/src/renderer/epics/__tests__/experimentEpics.test.ts @@ -0,0 +1,99 @@ +import { of, Subject } from 'rxjs'; +import { describe, it, afterEach, expect, vi } from 'vitest'; +import { ExperimentActions } from '../../actions'; +import type { ExperimentActionType } from '../../actions'; +import { + EXPERIMENTS, + CONNECTION_STATUS, + DEVICE_AVAILABILITY, + DEVICES, +} from '../../constants/constants'; +import type { RootState } from '../../reducers'; +import { saveWorkspaceEpic } from '../experimentEpics'; +import { storeExperimentState } from '../../utils/filesystem/storage'; + +vi.mock('../../utils/filesystem/storage', () => ({ + createEEGWriteStream: vi.fn(), + writeHeader: vi.fn(), + writeEEGData: vi.fn(), + writeEEGEvents: vi.fn(), + storeExperimentState: vi.fn().mockResolvedValue(undefined), + restoreExperimentState: vi.fn(), + createWorkspaceDir: vi.fn(), + storeBehavioralData: vi.fn(), + readWorkspaceBehaviorData: vi.fn(), + getWorkspaceDir: vi.fn(), +})); + +const experiment = { + type: EXPERIMENTS.CUSTOM, + title: 'My_Custom', + params: { + trialDuration: 1000, + intro: '', + iti: 500, + nbTrials: 0, + sampleType: 'with-replacement', + showProgressBar: false, + }, + experimentObject: { type: 'lab.flow.Sequence' }, + subject: '', + group: '', + session: 1, + isRunning: false, + isEEGEnabled: true, + dateModified: null, +}; +const rootState = (title: string): RootState => + ({ + experiment: { ...experiment, title }, + device: { + availableDevices: [], + availableLSLStreams: [], + connectedDevice: { name: 'disconnected', samplingRate: 0, channels: [] }, + connectionStatus: CONNECTION_STATUS.NOT_YET_CONNECTED, + deviceAvailability: DEVICE_AVAILABILITY.NONE, + rawObservable: null, + signalQualityObservable: null, + deviceType: DEVICES.MUSE, + }, + pyodide: { + epochsInfo: [], + channelInfo: [], + psdPlot: null, + topoPlot: null, + erpPlot: null, + epochArrays: null, + suggestedRejections: [], + worker: null, + isWorkerReady: false, + }, + }) as RootState; + +describe('saveWorkspaceEpic', () => { + afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); + }); + + it('persists the state captured when save was requested even after cleanup', async () => { + vi.useFakeTimers(); + const actions = new Subject(); + const state = { + value: rootState('My_Custom'), + subscribe: vi.fn(), + lift: vi.fn(), + } as unknown as import('redux-observable').StateObservable; + + const output = saveWorkspaceEpic(actions, state, undefined).subscribe(); + + actions.next(ExperimentActions.SaveWorkspace()); + state.value = rootState(''); + await vi.advanceTimersByTimeAsync(400); + + expect(storeExperimentState).toHaveBeenCalledWith( + expect.objectContaining({ title: 'My_Custom' }) + ); + output.unsubscribe(); + }); +}); diff --git a/src/renderer/epics/experimentEpics.ts b/src/renderer/epics/experimentEpics.ts index 1ff9a181..8eee5ae9 100644 --- a/src/renderer/epics/experimentEpics.ts +++ b/src/renderer/epics/experimentEpics.ts @@ -176,26 +176,22 @@ const autoSaveEpic: Epic = ( map(() => ExperimentActions.SaveWorkspace()) ); -const saveWorkspaceEpic: Epic< +export const saveWorkspaceEpic: Epic< ExperimentActionType, ExperimentActionType, RootState > = (action$, state$) => action$.pipe( filter(isActionOf(ExperimentActions.SaveWorkspace)), + map(() => state$.value.experiment), debounceTime(400), - filter(() => - state$.value.experiment.title - ? state$.value.experiment.title.length > 1 - : false - ), - mergeMap(async () => { + filter(({ title }) => title.length > 1), + mergeMap(async (experiment) => { const now = Date.now(); // experimentObject contains function references (hooks) that cannot be // serialized via IPC structured clone. It is always re-derived from // `type` on load (see handleLoadRecentWorkspace), so omit it here. - const { experimentObject: _omit, ...serializableState } = - state$.value.experiment; + const { experimentObject: _omit, ...serializableState } = experiment; await storeExperimentState({ ...serializableState, dateModified: now }); return now; }), diff --git a/src/renderer/experiments/custom/__tests__/params.test.ts b/src/renderer/experiments/custom/__tests__/params.test.ts new file mode 100644 index 00000000..8cea60cf --- /dev/null +++ b/src/renderer/experiments/custom/__tests__/params.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; +import { EVENTS } from '../../../constants/constants'; +import { mergeCustomParams } from '../params'; + +describe('mergeCustomParams', () => { + it('fills required nested fields in sparse restored parameters', () => { + const merged = mergeCustomParams({ + description: { question: 'Question' }, + stimulus1: { title: 'Faces' }, + }); + + expect(merged.description).toEqual({ + question: 'Question', + hypothesis: '', + methods: '', + }); + expect(merged.stimulus1).toEqual({ + dir: '', + title: 'Faces', + type: EVENTS.STIMULUS_1, + response: '', + }); + }); +}); diff --git a/src/renderer/experiments/custom/params.ts b/src/renderer/experiments/custom/params.ts index 5d1cc629..4b4bbb8c 100644 --- a/src/renderer/experiments/custom/params.ts +++ b/src/renderer/experiments/custom/params.ts @@ -1,8 +1,12 @@ import { EVENTS } from '../../constants/constants'; -import { ExperimentParameters } from '../../constants/interfaces'; +import { + ExperimentDescription, + ExperimentParameters, + StimulusCondition, +} from '../../constants/interfaces'; import { emptyConditionSlot } from '../../utils/labjs/customStimuli'; -export const params: ExperimentParameters = { +export const params = { randomize: 'random', trialDuration: 1000, imageHeight: '500px', @@ -25,4 +29,29 @@ export const params: ExperimentParameters = { stimulus3: emptyConditionSlot(EVENTS.STIMULUS_3, ''), stimulus4: emptyConditionSlot(EVENTS.STIMULUS_4, ''), stimuli: [], +} satisfies ExperimentParameters; + +export type CustomParamsInput = Omit< + Partial, + 'description' | 'stimulus1' | 'stimulus2' | 'stimulus3' | 'stimulus4' +> & { + description?: Partial; + stimulus1?: Partial; + stimulus2?: Partial; + stimulus3?: Partial; + stimulus4?: Partial; }; + +export function mergeCustomParams( + restored: CustomParamsInput = {} +): ExperimentParameters { + return { + ...params, + ...restored, + description: { ...params.description, ...restored.description }, + stimulus1: { ...params.stimulus1, ...restored.stimulus1 }, + stimulus2: { ...params.stimulus2, ...restored.stimulus2 }, + stimulus3: { ...params.stimulus3, ...restored.stimulus3 }, + stimulus4: { ...params.stimulus4, ...restored.stimulus4 }, + }; +} diff --git a/src/renderer/utils/labjs/functions.ts b/src/renderer/utils/labjs/functions.ts index 9d115667..3241a4da 100644 --- a/src/renderer/utils/labjs/functions.ts +++ b/src/renderer/utils/labjs/functions.ts @@ -11,11 +11,7 @@ import stroopExperiment from '../../experiments/stroop'; import customExperiment from '../../experiments/custom'; import searchExperiment from '../../experiments/search'; import multitaskingExperiment from '../../experiments/multitasking'; - -function absPathToUrl(absPath: string): string { - return `bwfile://host${absPath}`; -} - +import { toStimulusFileUrl } from '../../../shared/stimulusUrl'; /** * Returns all data necessary to fully describe an experiment from the experiment type @@ -120,7 +116,9 @@ function balanceStimuliByCondition( if (stimulus.dir && stimulus.filename) { return { ...stimulus, - filepath: absPathToUrl(path.join(stimulus.dir, stimulus.filename)), + filepath: toStimulusFileUrl( + path.join(stimulus.dir, stimulus.filename) + ), }; } return stimulus; diff --git a/src/shared/stimulusUrl.ts b/src/shared/stimulusUrl.ts new file mode 100644 index 00000000..42a552c4 --- /dev/null +++ b/src/shared/stimulusUrl.ts @@ -0,0 +1,7 @@ +const STIMULUS_SCHEME_ORIGIN = 'bwfile://host'; + +export function toStimulusFileUrl(absolutePath: string): string { + const url = new URL(STIMULUS_SCHEME_ORIGIN); + url.searchParams.set('path', absolutePath); + return url.href; +} From fac93c9423e59c890d8e162d6264f553c3b4f775 Mon Sep 17 00:00:00 2001 From: jdpigeon Date: Tue, 18 Aug 2026 16:05:04 -0400 Subject: [PATCH 3/3] fixed file access test --- src/main/__tests__/stimulusFileAccess.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main/__tests__/stimulusFileAccess.test.ts b/src/main/__tests__/stimulusFileAccess.test.ts index b1f203ba..6c20eaf2 100644 --- a/src/main/__tests__/stimulusFileAccess.test.ts +++ b/src/main/__tests__/stimulusFileAccess.test.ts @@ -26,7 +26,10 @@ describe('StimulusFileAccess', () => { }); it('resolves encoded files beneath a selected directory', () => { - const file = path.join(selected, 'face #1?.jpeg'); + // '#' and '&' are URL-reserved but legal in filenames on every OS; + // '?' is illegal on Windows, so it's only exercised in the pure + // string round-trip test below. + const file = path.join(selected, 'face #1 & 2.jpeg'); fs.writeFileSync(file, 'image'); expect(access.resolveUrl(toStimulusFileUrl(file))).toBe( fs.realpathSync(file)