diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..855a239 --- /dev/null +++ b/.env.example @@ -0,0 +1,27 @@ +# NeuralCompose client environment. Copy to .env.local (gitignored) and edit. +# +# EXPO_PUBLIC_* values are PUBLIC — they are inlined into the JS bundle and +# visible to anyone with the app. Never put secrets, tokens, or credentials here. +# Do not commit permanent private Tailnet/LAN addresses to the repo. + +# "true" (default when unset) = mock fixtures, labeled MOCK in the header. +# "false" = live mode against your own NeuralCompose server. Live failures stay +# visible; the app never silently falls back to mock data. +EXPO_PUBLIC_USE_MOCK=true + +# Your NeuralCompose server (M4 Mac), e.g. https://your-mac.tailnet.ts.net:8081 +# or http://localhost:8081 for the iOS Simulator with a server on the same Mac. +# iOS App Transport Security blocks cleartext http:// to non-localhost hosts — +# use HTTPS (e.g. `tailscale serve`) for physical iPhones. +EXPO_PUBLIC_SERVER_URL= + +# EEG stream WebSocket. Optional: derived from EXPO_PUBLIC_SERVER_URL +# (http→ws + /api/eeg/stream) when left empty. +EXPO_PUBLIC_EEG_WS_URL= + +# Optional overrides for the on-device (Termux/Android) loopback services. +# On iOS these loopback services do not exist; point at a reachable host or +# leave unset (features degrade visibly). +# EXPO_PUBLIC_LLM_URL= +# EXPO_PUBLIC_EMBEDDING_URL= +# EXPO_PUBLIC_STT_URL= diff --git a/.gitignore b/.gitignore index 05d5e3f..7cb6cbd 100644 --- a/.gitignore +++ b/.gitignore @@ -30,8 +30,10 @@ yarn-error.* .DS_Store *.pem -# local env files -.env*.local +# local env files — only .env.example is tracked +.env +.env.* +!.env.example # typescript *.tsbuildinfo diff --git a/App.tsx b/App.tsx index 5c03f39..08b4ea9 100644 --- a/App.tsx +++ b/App.tsx @@ -15,7 +15,7 @@ import { ClassifierScreen } from './src/screens/ClassifierScreen'; import { DreamJournalScreen } from './src/screens/DreamJournalScreen'; import { DialecticSessionScreen } from './src/screens/DialecticSessionScreen'; import { colors, spacing, typography } from './src/theme'; -import { USE_MOCK, SERVER_URL } from './src/config'; +import { USE_MOCK, SERVER_URL, CONFIG_ERROR } from './src/config'; const Tab = createBottomTabNavigator(); @@ -56,11 +56,15 @@ export default function App() { headerTintColor: colors.text, headerRight: () => ( - - {USE_MOCK ? 'MOCK' : 'LIVE'} + + {USE_MOCK ? 'MOCK' : CONFIG_ERROR ? 'LIVE · NO ENDPOINT' : 'LIVE'} - {USE_MOCK ? 'fixtures' : SERVER_URL.replace(/^https?:\/\//, '')} + {USE_MOCK + ? 'fixtures' + : SERVER_URL + ? SERVER_URL.replace(/^https?:\/\//, '') + : 'set EXPO_PUBLIC_SERVER_URL'} ), @@ -141,6 +145,7 @@ const styles = StyleSheet.create({ fontWeight: '700', letterSpacing: 1, }, + headerBadgeError: { color: colors.orange }, headerBadgeUrl: { color: colors.textMuted, fontSize: 9, diff --git a/README.md b/README.md index 3f73fdd..e4e99a8 100644 --- a/README.md +++ b/README.md @@ -16,9 +16,10 @@ cd ~/neuralcompose-client npx expo start ``` -Then open Expo Go on the Pixel and scan the QR code. The app launches in **MOCK** -mode (the green pill in the top-right says `MOCK`) and renders all 5 screens with -synthetic data — no M4 server needed. +Then open Expo Go on the Pixel and scan the QR code (or press `i` for the iOS +Simulator — see `docs/ios-client.md`). The app launches in **MOCK** mode (the +green pill in the top-right says `MOCK`) and renders all 6 tabs with synthetic +data — no M4 server needed. To cut over to the real M4 server, see **Tailscale cutover** below. @@ -110,18 +111,19 @@ App.tsx # NavigationContainer + bottom tab navigator The M4 server is a separate PR — when it's up: -1. **Confirm Tailscale routing from Termux:** +1. **Confirm Tailscale routing from Termux** (substitute your M4's Tailnet IP or + MagicDNS name — never commit it): ```sh tailscale status # should show the M4 - curl http://100.105.8.22:8081/api/diagnostics + curl http://:8081/api/diagnostics ``` If the curl hangs, check `tailscale status` and `ip route` in Termux. -2. **Edit `src/config.ts`:** - ```ts - export const USE_MOCK = false; - export const SERVER_URL = "http://100.105.8.22:8081"; // confirm port - export const EEG_WS_URL = "ws://100.105.8.22:8081/api/eeg/stream"; +2. **Create `.env.local`** (gitignored — see `.env.example`): + ```sh + EXPO_PUBLIC_USE_MOCK=false + EXPO_PUBLIC_SERVER_URL=http://:8081 + # EXPO_PUBLIC_EEG_WS_URL is derived automatically (ws://…/api/eeg/stream) ``` 3. **Reload the app** (or restart Metro). The top-right pill flips from `MOCK` to @@ -134,10 +136,14 @@ The M4 server is a separate PR — when it's up: - EEG → traces scroll, the 4 channels match TP9/AF7/AF8/TP10 - Pipeline mode → matches the macOS app's mode banner -5. **Cleartext traffic is already enabled** in `app.json` via `expo-build-properties` - with `usesCleartextTraffic: true`. The Tailscale 100.x IP is plain HTTP, and - Android 9+ blocks plain HTTP by default — this flag unblocks it for the dev - server. For production, terminate TLS in front of the M4 instead. +5. **Cleartext traffic is already enabled on Android** in `app.json` via + `expo-build-properties` with `usesCleartextTraffic: true`. Tailscale 100.x IPs + are plain HTTP, and Android 9+ blocks plain HTTP by default — this flag + unblocks it for the dev server. **iOS has no equivalent flag here on purpose:** + App Transport Security blocks cleartext to non-localhost hosts, and this + project does not add `NSAllowsArbitraryLoads`. For iOS hardware, serve HTTPS + (e.g. `tailscale serve`) — see `docs/ios-client.md`. For production, + terminate TLS in front of the M4 on both platforms. --- diff --git a/SDK57-REFERENCE.md b/SDK57-REFERENCE.md index 43d5c04..b66c7d2 100644 --- a/SDK57-REFERENCE.md +++ b/SDK57-REFERENCE.md @@ -73,14 +73,14 @@ - **Known issues:** None documented. No SDK 57-specific regressions. The library is "dependency free" and "WebSocket API compatible", so it works against any RN version that has a working `WebSocket` global. - **Source:** [github.com/pladaria/reconnecting-websocket README](https://github.com/pladaria/reconnecting-websocket) · npm: `registry.npmjs.org/reconnecting-websocket` (4.4.0) -## 8. Tailscale on Android + RN 0.86 + cleartext traffic for `http://100.105.x.x` +## 8. Tailscale on Android + RN 0.86 + cleartext traffic for `http://100.x.y.z` **Direct, citable answers:** - **Tailscale 100.x "MagicDNS" IPs are normal CGNAT-style private IPs.** Android routes them through the Tailscale VPN interface; no special RN config is needed for the OS-level routing. React Native's `fetch` and `WebSocket` go through the same OkHttp socket layer that the rest of the OS uses, so VPN-routed traffic works out-of-the-box on Android 9+. - **HOWEVER: cleartext traffic (plain `http://`, no TLS) is BLOCKED by default on Android 9+.** Both the [React Native Networking docs](https://reactnative.dev/docs/network) and the [Expo build-properties docs](https://docs.expo.dev/versions/v57.0.0/sdk/build-properties/) confirm this. Quoting the Expo build-properties page verbatim: > `usesCleartextTraffic` (optional) `boolean` — Indicates whether the app intends to use cleartext network traffic. **For Android 8 and below, the default platform-specific value is `true`. For Android 9 and above, the default platform-specific value is `false`.** -- **Therefore for `http://100.105.8.22:8081` on a Pixel 8a (Android 16, which is >9):** **YES, you must enable cleartext.** The cleanest way in SDK 57 is the `expo-build-properties` config plugin (this is the *only* mechanism — the old `expo.http` app.json key was removed in SDK 53): +- **Therefore for `http://100.x.y.z:8081` on a Pixel 8a (Android 16, which is >9):** **YES, you must enable cleartext.** The cleanest way in SDK 57 is the `expo-build-properties` config plugin (this is the *only* mechanism — the old `expo.http` app.json key was removed in SDK 53): ```json // app.json @@ -98,7 +98,7 @@ ``` Then run `npx expo prebuild --clean` (or `npx expo run:android` which triggers prebuild). The plugin writes `android:usesCleartextTraffic="true"` into the generated `AndroidManifest.xml`. -- **Better alternative for production:** put a TLS terminator (Caddy/nginx) in front of your dev server and use `https://100.105.8.22:8443`. Then you don't need cleartext and you avoid the warning. For pure local dev on Tailscale, the cleartext flag is fine. +- **Better alternative for production:** put a TLS terminator (Caddy/nginx) in front of your dev server and use `https://100.x.y.z:8443`. Then you don't need cleartext and you avoid the warning. For pure local dev on Tailscale, the cleartext flag is fine. - **Tailscale-specific gotcha (not in Expo docs, well-known):** Tailscale's `MagicDNS` resolves node names like `my-devbox.tailnet.ts.net` → `100.x` IP. Android's WebView (and some libraries' HttpURLConnection) resolves DNS through the VPN, so this works — but if you ever see "ERR_CLEARTEXT_NOT_PERMITTED" in logcat, that's the manifest flag missing, **not** a Tailscale problem. - **Source:** [Expo build-properties `usesCleartextTraffic`](https://docs.expo.dev/versions/v57.0.0/sdk/build-properties/) · [React Native Networking](https://reactnative.dev/docs/network) diff --git a/app.json b/app.json index df18d83..6eaa1be 100644 --- a/app.json +++ b/app.json @@ -2,17 +2,23 @@ "expo": { "name": "NeuralCompose Client", "slug": "neuralcompose-client", + "scheme": "neuralcompose", "version": "0.1.0", "orientation": "portrait", "icon": "./assets/icon.png", "userInterfaceStyle": "dark", - "newArchEnabled": true, "ios": { "supportsTablet": true, - "bundleIdentifier": "com.aurascoper.neuralcompose" + "bundleIdentifier": "com.aurascoper.neuralcomposeclient", + "buildNumber": "1", + "infoPlist": { + "NSMicrophoneUsageDescription": "NeuralCompose records optional voice notes for your local dream journal. Audio stays on this device and is never uploaded.", + "NSLocalNetworkUsageDescription": "NeuralCompose connects to your own NeuralCompose server on your local network to display EEG monitoring data." + } }, "android": { "package": "com.aurascoper.neuralcompose", + "versionCode": 1, "adaptiveIcon": { "backgroundColor": "#0E1116", "foregroundImage": "./assets/android-icon-foreground.png", @@ -36,7 +42,8 @@ "usesCleartextTraffic": true } } - ] + ], + "expo-asset" ] } } diff --git a/docs/ios-client-audit.md b/docs/ios-client-audit.md new file mode 100644 index 0000000..b146b34 --- /dev/null +++ b/docs/ios-client-audit.md @@ -0,0 +1,114 @@ +# iOS Client Audit — baseline (pre-implementation) + +Date: 2026-07-28 +Auditor: Claude Code (M4 Mac, Terminal) +Worktree: `/Users/aurascoper/Developer/NeuralCompose-ios-client` +Branch: `feat/ios-client`, created from `origin/feat/dialect-synthesis` @ `2a2451fc4c1bb6856fc6640623f6959835fc8355` + +> Note: the task prompt named the source branch `feat/dialectical-synthesis`. That +> ref does not exist on the remote. The operator confirmed `feat/dialect-synthesis` +> (the only branch containing the Expo client) as the intended source. + +## MOBILE_IMPLEMENTATION_PRESENT=true + +The branch root *is* the Expo project (`neuralcompose-client`). Real source, +real tests, real docs — not documentation-only. It was authored on a Pixel 8a +in Termux (jest config carries Android phantom-process workarounds; EAS was the +APK build path). + +## Project facts + +| # | Item | Finding | +|---|------|---------| +| 1 | Expo SDK | `expo ~57.0.8` (resolved `sdkVersion 57.0.0`) | +| 2 | React Native | `0.86.0` (React 19.2.3) | +| 3 | Node engines | none declared in `package.json`; `eas.json` pins Node `20.11.1` for EAS builds. Local Node v26.4.0 works. | +| 4 | Package manager | npm (`package-lock.json`, lockfileVersion 3). No other lockfiles. | +| 5 | Workflow | Managed / Continuous Native Generation. `/ios` and `/android` are gitignored ("generated native folders"). | +| 6 | `android/` committed | No | +| 7 | `ios/` committed | No | +| 8 | Native dirs hand-maintained | No — none exist; CNG intended. `expo prebuild` is safe here (still avoiding `--clean` per policy). | +| 9 | Expo Go support | All runtime deps are Expo-Go-bundled: expo-audio, expo-speech, expo-network, expo-status-bar, async-storage 2.2.0, react-navigation (bottom-tabs/native), safe-area-context, screens, react-native-svg, reconnecting-websocket (pure JS). | +| 10 | Deps requiring dev build | None at runtime. `expo-build-properties` is a config plugin (build-time only; inert in Expo Go). | +| 11 | Android-only deps | None in JS deps. Android-only *config*: `android.permissions: [RECORD_AUDIO]`, `usesCleartextTraffic: true` (Android block of expo-build-properties). | +| 12 | `expo-av` | **Not used.** | +| 13 | `expo-audio` | **Yes**, `~57.0.3` — recording + playback in `DreamJournalScreen` (`useAudioRecorder`, `useAudioPlayer`). Migration already done upstream. | +| 14 | Android package | `com.aurascoper.neuralcompose` (must remain unchanged) | +| 15 | iOS bundleIdentifier | Present at baseline: `com.aurascoper.neuralcompose`. No iOS build has ever used it (no credentials, no `ios/`). macOS app uses `com.neuralcompose.app` — no collision either way. | +| 16 | Endpoint config | Hard-coded compile-time constants in `src/config.ts`: `USE_MOCK = true`, `SERVER_URL`, `EEG_WS_URL`. On-device (Termux) services hard-coded: LLM `127.0.0.1:8081`, embeddings `:8082`, whisper.cpp STT `:8083`. No `EXPO_PUBLIC_*` usage anywhere. | +| 17 | Private Tailnet addresses committed | **Yes** — a `100.x` CGNAT address (the M4's Tailscale IP, commented "placeholder") in `src/config.ts` lines 6–7 and the README cutover section. Scrubbed in this branch; a hygiene test now guards against reintroduction. | +| 18 | Credentials / provider tokens | None found. No `.env*` files tracked. `scripts/termux/neuralcompose-services.env.example` is a server-side example without secrets. `.gitignore` covers `.env*.local`, keys, certs. | +| 19 | Baseline gate results | See below. | +| 20 | Android implementation real? | **Yes** — 6 screens, mock + live API clients, WS stream with throttled rendering, 13 Jest suites; `docs/fable5/verification-evidence.md` + `docs/pixel-benchmark-results.json` document on-device (Pixel 8a, Expo Go) verification by the prior effort. Not re-verified on Android hardware in this session. | + +## Baseline gate results (exact commands, this machine) + +| Gate | Command | Result | +|------|---------|--------| +| Install | `npm ci` | OK (warnings about install scripts for fsevents/unrs-resolver only) | +| Jest | `npm test -- --runInBand` | **PASS** — 11 suites passed, 2 skipped; 95 tests passed, 3 skipped. Warning: "Jest did not exit one second after the test run" (pre-existing open handle). | +| TypeScript | `npx tsc --noEmit` | **PASS** (exit 0) | +| Lint | `npm run lint --if-present` | No lint script defined — nothing ran. | +| expo-doctor | `npx expo-doctor` | **3 failures**: (a) app.json schema — `newArchEnabled` is no longer a valid key on SDK 57; (b) missing peer dependency `expo-asset` (required by `expo-audio`; "app may crash outside of Expo Go"); (c) jest 30.4.2 / @types/jest 30 vs SDK-expected ~29.7.0 / 29.5.14 (dev-only; suite is green on 30). | +| Public config | `npx expo config --type public` | Resolves; platforms `["ios","android"]` already. | +| Android export | `npx expo export --platform android` | **PASS** — Hermes bundle 2.1 MB. | +| iOS export | `npx expo export --platform ios` | **PASS** — Hermes bundle 2.1 MB. (JS bundling + static config evidence only.) | + +## Architecture notes (thin-client conformance at baseline) + +- `ApiClient` interface with `MockApiClient` / `LiveApiClient` selected **once** at + module load from `USE_MOCK` — there is no runtime fallback path, so no silent + live→mock substitution exists. Header badge shows `MOCK`/`LIVE` + endpoint. +- EEG: `useEEGStream` buffers outside React state, flushes at ~30 fps, caps the + raw buffer at 2× `EEG_BUFFER_SAMPLES` (1280 = 5 s @ 256 Hz), cleans up socket + + interval on unmount. `LiveApiClient` reconnects with exponential backoff capped + at 3 attempts, drops malformed frames. Channel order TP9/AF7/AF8/TP10 fixed in + fixtures and `HealthScreen`. +- Note: WS protocol is **one JSON sample per message** (~256 Hz), not batched. + Display is throttled, so React state churn is bounded, but message decode runs + per-sample. +- Journal: AsyncStorage stores metadata + text + audio **file URI** only; audio + bytes live in the expo-audio recording file. Explicit mic permission request + with a visible denied alert; text-only path preserved on denial. +- Locality labels are honest: `PrivacyBadge` renders the **server-reported** + pipeline mode (FULLY LIVE / SUBSTITUTED); Dialectic screen labels `Gates: MOCK` + and disables synthesis under mock embeddings. `runtime/identity.ts` verifies + runtime identity by probing, defaulting to `unknown`. +- No `claude` CLI invocation, no Anthropic/OpenAI keys, no scientific EEG + processing in JS. On-device LLM/STT/embedding clients target Termux-local + `llama-server`/`whisper.cpp` loopback services (Android-only reality; on iOS + these endpoints simply fail → visible "synthesis unavailable" / degraded + states). + +## Issues to address for universal iOS support + +1. **`src/config.ts`**: committed Tailnet IP; compile-time `USE_MOCK`; no + `EXPO_PUBLIC_*` env plumbing; no `.env.example`. +2. **app.json**: invalid `newArchEnabled` key; no `scheme`; no iOS + `NSMicrophoneUsageDescription` despite recording; no `ios.buildNumber` / + `android.versionCode`; bundle ID to be set to the preferred + `com.aurascoper.neuralcomposeclient` (safe — never used by any build). +3. **Missing `expo-asset` peer dep** (expo-doctor failure; dev-build crash risk). +4. **jest 30 vs SDK expectation** — keep 30 (green), silence via + `expo.install.exclude` with rationale. +5. **iOS ATS vs `http://` endpoints**: Android uses `usesCleartextTraffic`; on + iOS, cleartext to non-loopback hosts is blocked by ATS. Policy: do **not** + add `NSAllowsArbitraryLoads` or broad exemptions. Simulator→`http://localhost` + is ATS-exempt; physical iPhone needs HTTPS (e.g. `tailscale serve`) — document. +6. **eas.json**: no `development` profile (`developmentClient`); add without + touching working Android profiles. +7. iOS Local Network: LAN endpoints on a dev/EAS build need + `NSLocalNetworkUsageDescription` (Expo Go carries its own). + +## Phase 3 decision + +``` +IOS_DEVELOPMENT_PATH=expo-go +``` + +Evidence: every runtime native module (expo-audio, expo-speech, expo-network, +expo-status-bar, async-storage, react-native-svg, screens, safe-area-context) is +bundled in Expo Go for SDK 57; no custom native code, no custom entitlements, no +unsupported packages. `expo-build-properties` only affects generated native +builds and is inert in Expo Go. A development build is *not* required for the +current feature set; EAS profiles are kept for distribution builds. diff --git a/docs/ios-client.md b/docs/ios-client.md new file mode 100644 index 0000000..76ea10b --- /dev/null +++ b/docs/ios-client.md @@ -0,0 +1,193 @@ +# NeuralCompose Universal Client — iOS + +Universal Android/iOS Expo thin client for the macOS NeuralCompose BCI pipeline. +Baseline audit: `docs/ios-client-audit.md`. + +## Thin-client architecture and privacy boundary + +The **M4 Mac is authoritative** for everything scientific: Muse/EEG acquisition, +preprocessing, channel health, classification, Core ML, MLX, LLM execution, +dialectical competition, Witness processing, provenance, raw artifacts, +recording and replay. + +The phone only does: screen rendering, API/WebSocket consumption, bounded +display buffering, local caching, local journal text, local audio +recording/playback, platform TTS, and connection/locality/staleness +presentation. + +The mobile waveform is a **monitoring visualization**, not a scientific source +artifact. The phone never decodes cognition or brain state. The `claude` CLI is +never invoked from the phone. No provider secrets exist in client source — +`EXPO_PUBLIC_*` values are **public** (inlined into the JS bundle). + +Locality labels distinguish: + +- **phone-local** — rendering, cache, journal, audio recording +- **Mac-local** — EEG acquisition/processing, classification, Core ML, MLX +- **remote cloud** — only when the Mac's pipeline mode explicitly reports it + +The UI never claims `On-device` for work the M4 performs; the Overview banner +renders the **server-reported** pipeline mode verbatim. + +## Paths + +- Repository: `https://github.com/aurascoper/NeuralCompose.git` +- Mobile project: repository root of the `feat/ios-client` / + `feat/dialect-synthesis` branch lineage (the branch tree *is* the Expo app) +- Expo SDK 57 / React Native 0.86 / npm / managed workflow (CNG; `ios/` and + `android/` are generated, gitignored, never hand-maintained) + +## Identifiers + +- Android package (unchanged): `com.aurascoper.neuralcompose` +- iOS bundle identifier: `com.aurascoper.neuralcomposeclient` +- URL scheme: `neuralcompose` +- macOS app (separate product): `com.neuralcompose.app` + +## Environment variables + +Copy `.env.example` → `.env.local` (gitignored). All are `EXPO_PUBLIC_*` and +therefore **public — never place secrets or permanent private Tailnet addresses +in them or in committed source** (a Jest hygiene test enforces the latter). + +| Var | Meaning | +|-----|---------| +| `EXPO_PUBLIC_USE_MOCK` | `true` (default when unset) = mock fixtures, labeled `MOCK`; `false` = live. No silent live→mock fallback exists: live failures surface as error/stale states, and a live config without a server URL shows `LIVE · NO ENDPOINT`. | +| `EXPO_PUBLIC_SERVER_URL` | The M4 server, e.g. `https://..ts.net:8081` | +| `EXPO_PUBLIC_EEG_WS_URL` | Optional; derived from the server URL (`ws(s)://…/api/eeg/stream`) when empty | +| `EXPO_PUBLIC_LLM_URL` / `EXPO_PUBLIC_EMBEDDING_URL` / `EXPO_PUBLIC_STT_URL` | Optional overrides for the loopback services that exist only on the Termux/Android deployment; unset on iOS → features degrade visibly | + +## macOS prerequisites + +- Xcode (full app, not just CLT); verify with `xcode-select -p` / + `xcodebuild -version` +- iOS **Simulator runtime** — Xcode 26 ships it separately: + `xcodebuild -downloadPlatform iOS` (multi-GB, one-time), then + `xcrun simctl list devices available` +- Node ≥ 20, npm; install with `npm ci` + +## Workflows + +### Android (Expo Go) — regression path + +```sh +npm ci +npx expo start --clear # scan QR from Expo Go on the device +``` + +Regression gates before any commit: + +```sh +npm test -- --runInBand +npx tsc --noEmit +npx expo-doctor +npx expo export --platform android +``` + +### iOS (Expo Go) — chosen development path + +`IOS_DEVELOPMENT_PATH=expo-go`. Every native dependency (expo-audio, +expo-speech, expo-network, expo-status-bar, async-storage, react-native-svg, +screens, safe-area-context, expo-asset) is bundled in Expo Go for SDK 57; there +is no custom native code and no custom entitlement. `expo-build-properties` +only affects generated native builds and is inert in Expo Go. + +```sh +npx expo start --clear # press i for the iOS Simulator (installs Expo Go there) +``` + +### Local iOS development build (when native needs appear) + +Not required today. When it becomes necessary: + +```sh +npx expo install expo-dev-client +npx expo prebuild --platform ios # CNG project — safe; never use --clean by habit +npx expo run:ios # compiles natively, installs to Simulator +``` + +### EAS builds (cloud) + +Requires `eas` authentication (`npx eas-cli@latest whoami`) and, for device +builds, Apple Developer membership + signing. Profiles in `eas.json`: + +- `development` — `developmentClient: true`, internal distribution +- `development-simulator` — the same, built for the iOS Simulator (no signing) +- `preview` / `preview-simulator` — internal distribution (Android profile + predates this work and is untouched) +- `production` — auto-increment, Android app-bundle + +```sh +npx eas-cli@latest build --platform ios --profile development-simulator +``` + +Do not print or commit Apple credentials, tokens, certificates, or +provisioning profiles. + +### Physical iPhone + +Optional; needs a connected iPhone (`xcrun xctrace list devices`), Apple +signing, and operator participation for credentials. Then +`npx expo run:ios --device` or an EAS `development` build. Networking: a +physical iPhone cannot reach the Mac via `localhost` — see transport below. + +## Transport security (HTTPS / LAN / Tailnet) + +- Android cleartext HTTP is enabled (`usesCleartextTraffic`, Android-only, + pre-existing). +- iOS **App Transport Security is left fully intact**: no + `NSAllowsArbitraryLoads`, no exception domains. Consequences: + - iOS **Simulator** → `http://localhost:8081` works (loopback is ATS-exempt) + when the server runs on the same Mac. + - **Physical iPhone** → use HTTPS, e.g. `tailscale serve` in front of the M4 + server, or a LAN reverse proxy with a trusted cert. Plain `http://100.x…` + or `http://192.168…` will be blocked by ATS — this is intentional; do not + "fix" it by disabling ATS. +- `NSLocalNetworkUsageDescription` is declared because the live endpoint is + user-owned LAN/Tailnet equipment; iOS shows the Local Network prompt for LAN + access from a native build (Expo Go carries its own). + +## Microphone, speech, audio storage + +- Recording uses `expo-audio` (already migrated off `expo-av` upstream; nothing + to migrate). Explicit permission request on the Journal screen; denial shows + a visible alert and text entry keeps working. +- `NSMicrophoneUsageDescription` is declared for iOS; `RECORD_AUDIO` for + Android (pre-existing). +- Audio bytes live in device **file storage** (expo-audio recording URI); + AsyncStorage holds only metadata (timestamp, journal text, audio file URI, + duration). Local-only; no upload. +- **Speech-to-text status:** the app does not implement application-controlled + STT on iOS. The Termux deployment can point `EXPO_PUBLIC_STT_URL` at a local + whisper.cpp server; without it, transcription degrades visibly. The system + keyboard's dictation remains a user-controlled option. A native + Apple-Speech bridge would be a separately justified, tested track. +- Spoken output uses `expo-speech` (platform TTS) on both platforms. + +## Evidence ledger (2026-07-28 session, M4 Mac) + +Never collapse these into "iOS works": + +| Evidence class | Status | +|----------------|--------| +| Jest + TypeScript | **Observed green** — 15 suites / 184 tests passed (2 suites, 3 tests skipped); `tsc --noEmit` clean; `expo-doctor` 20/20 | +| Android JS bundle | **Observed green** — `npx expo export --platform android` (Hermes, 2.1 MB) | +| Android Expo Go / device | **Not observed this session** — prior effort's Pixel 8a evidence in `docs/fable5/verification-evidence.md`; re-run the regression path above | +| Static iOS export | **Observed green** — `npx expo export --platform ios` (JS bundling + static config only; proves nothing about native compilation, signing, audio, or devices) | +| Native iOS compilation | **Not observed** — Expo Go path chosen; no prebuild run | +| iOS Simulator (Expo Go) | **Observed** — iPhone 17, iOS 26.5 (runtime installed via `xcodebuild -downloadPlatform iOS`): Expo Go installed by the CLI; bundle `iOS Bundled … (1029 modules)`; app launched; all six tabs rendered and navigated (Overview with green `MOCK DATA` pill + live-updating mock diagnostics, EEG 4 traces in TP9/AF7/AF8/TP10 order at "~30 fps render", Health fixture states incl. AF8 SATURATED, Classifier intent + distribution, Dialectic idle with "Gates: MOCK … Synthesis is disabled", Journal entry UI); iOS microphone permission prompt appeared on Journal (Expo Go's own usage string — the app's `NSMicrophoneUsageDescription` applies to dev/EAS builds); fast refresh applied a code change live. **Not observed on Simulator:** in-app mic-denied alert state, recording start/stop, stale-stream state, background/foreground cycling. | +| Physical iPhone | **Not observed** — no signing configured this session | +| EAS cloud build | **Not run** — `eas whoami`: not logged in; profiles configured | + +## Known limitations + +- Dialectic tab's loopback LLM/embedding/STT services are an Android/Termux + deployment reality; on iOS they are absent and the screen shows its degraded, + labeled states (`Gates: MOCK`, synthesis disabled) unless overrides point at + reachable hosts. +- Jest emits a benign pre-existing "did not exit one second after the test + run" warning (open handle in a mock timer). +- `jest`/`@types/jest` are pinned at v30 (SDK expects 29; suite is green on + 30) and excluded from `expo install --check` via `expo.install.exclude`. +- No splash-screen config is declared; both platforms use Expo defaults with + the existing icon set. diff --git a/eas.json b/eas.json index 43538b6..9336c1d 100644 --- a/eas.json +++ b/eas.json @@ -11,6 +11,17 @@ "gradleVersion": "8.10.2" } }, + "development": { + "extends": "base", + "developmentClient": true, + "distribution": "internal" + }, + "development-simulator": { + "extends": "development", + "ios": { + "simulator": true + } + }, "preview": { "extends": "base", "distribution": "internal", diff --git a/package-lock.json b/package-lock.json index 1d2188c..5d2e8e4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@react-navigation/bottom-tabs": "^7.18.13", "@react-navigation/native": "^7.3.13", "expo": "~57.0.8", + "expo-asset": "~57.0.7", "expo-audio": "~57.0.3", "expo-build-properties": "~57.0.7", "expo-network": "~57.0.1", diff --git a/package.json b/package.json index 976fb8d..bd3bb6d 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "@react-navigation/bottom-tabs": "^7.18.13", "@react-navigation/native": "^7.3.13", "expo": "~57.0.8", + "expo-asset": "~57.0.7", "expo-audio": "~57.0.3", "expo-build-properties": "~57.0.7", "expo-network": "~57.0.1", @@ -33,5 +34,13 @@ "web": "expo start --web", "test": "jest" }, + "expo": { + "install": { + "exclude": [ + "jest", + "@types/jest" + ] + } + }, "private": true } diff --git a/src/__tests__/committedConfig.test.ts b/src/__tests__/committedConfig.test.ts new file mode 100644 index 0000000..502de37 --- /dev/null +++ b/src/__tests__/committedConfig.test.ts @@ -0,0 +1,85 @@ +// committedConfig — guards the repo's committed configuration: +// no private Tailnet (CGNAT) addresses, no credential-shaped strings, and the +// platform config keys iOS support depends on. + +import * as fs from 'fs'; +import * as path from 'path'; + +const ROOT = path.resolve(__dirname, '..', '..'); + +function walk(dir: string, out: string[] = []): string[] { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + // __tests__ excluded: fixtures may use example CGNAT IPs (e.g. locality + // classification tests). The guard targets shipped source and config. + if (entry.name === 'node_modules' || entry.name === '__tests__' || entry.name.startsWith('.')) continue; + const p = path.join(dir, entry.name); + if (entry.isDirectory()) walk(p, out); + else if (/\.(ts|tsx|js|json)$/.test(entry.name)) out.push(p); + } + return out; +} + +// The Tailscale/CGNAT range: 100.64/10. +const TAILNET_RE = /\b100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\.\d{1,3}\.\d{1,3}\b/; + +// Common credential shapes. EXPO_PUBLIC_* values are public; nothing +// credential-shaped may appear anywhere in committed source or config. +const SECRET_RES = [ + /sk-ant-[A-Za-z0-9_-]{8,}/, // Anthropic + /sk-[A-Za-z0-9]{20,}/, // OpenAI-style + /ghp_[A-Za-z0-9]{20,}/, // GitHub PAT + /xox[baprs]-[A-Za-z0-9-]{10,}/, // Slack + /AKIA[0-9A-Z]{16}/, // AWS + /tskey-[A-Za-z0-9-]{10,}/, // Tailscale key + /-----BEGIN [A-Z ]*PRIVATE KEY-----/, +]; + +const scanTargets = [ + ...walk(path.join(ROOT, 'src')), + path.join(ROOT, 'app.json'), + path.join(ROOT, 'eas.json'), + path.join(ROOT, '.env.example'), + path.join(ROOT, 'App.tsx'), +]; + +describe('committed configuration hygiene', () => { + it.each(scanTargets.map((f) => [path.relative(ROOT, f), f]))( + '%s contains no private Tailnet address or credential-shaped string', + (_rel, file) => { + const text = fs.readFileSync(file, 'utf8'); + expect(text).not.toMatch(TAILNET_RE); + for (const re of SECRET_RES) { + expect(text).not.toMatch(re); + } + }, + ); +}); + +describe('platform configuration (app.json)', () => { + const app = JSON.parse(fs.readFileSync(path.join(ROOT, 'app.json'), 'utf8')).expo; + + it('keeps the Android package identifier unchanged', () => { + expect(app.android.package).toBe('com.aurascoper.neuralcompose'); + expect(app.android.permissions).toContain('RECORD_AUDIO'); + }); + + it('declares the iOS bundle identifier and build number', () => { + expect(app.ios.bundleIdentifier).toBe('com.aurascoper.neuralcomposeclient'); + expect(app.ios.buildNumber).toBeTruthy(); + expect(app.android.versionCode).toBeGreaterThanOrEqual(1); + }); + + it('has microphone usage text because recording exists', () => { + expect(app.ios.infoPlist.NSMicrophoneUsageDescription).toMatch(/voice|record/i); + }); + + it('never disables App Transport Security broadly', () => { + const raw = JSON.stringify(app); + expect(raw).not.toContain('NSAllowsArbitraryLoads'); + expect(raw).not.toContain('NSExceptionDomains'); + }); + + it('has a stable URL scheme', () => { + expect(app.scheme).toBe('neuralcompose'); + }); +}); diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts new file mode 100644 index 0000000..3c518b5 --- /dev/null +++ b/src/__tests__/config.test.ts @@ -0,0 +1,68 @@ +// config resolution — mock/live selection, endpoint handling, no silent fallback. + +import { parseUseMock, deriveWsUrl, resolveClientMode } from '../config'; + +describe('parseUseMock', () => { + it('defaults to mock when unset or empty', () => { + expect(parseUseMock(undefined)).toBe(true); + expect(parseUseMock('')).toBe(true); + }); + + it('selects live only on an explicit false-y string', () => { + expect(parseUseMock('false')).toBe(false); + expect(parseUseMock('FALSE')).toBe(false); + expect(parseUseMock('0')).toBe(false); + expect(parseUseMock('no')).toBe(false); + }); + + it('treats affirmative and junk values as mock (safe default)', () => { + expect(parseUseMock('true')).toBe(true); + expect(parseUseMock('1')).toBe(true); + expect(parseUseMock('banana')).toBe(true); + }); +}); + +describe('deriveWsUrl', () => { + it('maps http(s) to ws(s) and appends the stream path', () => { + expect(deriveWsUrl('http://host:8081')).toBe('ws://host:8081/api/eeg/stream'); + expect(deriveWsUrl('https://host:8081')).toBe('wss://host:8081/api/eeg/stream'); + }); + + it('returns empty for non-http inputs', () => { + expect(deriveWsUrl('')).toBe(''); + expect(deriveWsUrl('host:8081')).toBe(''); + }); +}); + +describe('resolveClientMode', () => { + it('mock mode needs no endpoints and reports no error', () => { + const c = resolveClientMode(undefined, undefined, undefined); + expect(c.mode).toBe('mock'); + expect(c.configError).toBeNull(); + }); + + it('live mode with a valid server URL resolves endpoints', () => { + const c = resolveClientMode('false', 'http://mac.example:8081/', undefined); + expect(c.mode).toBe('live'); + expect(c.serverUrl).toBe('http://mac.example:8081'); // trailing slash trimmed + expect(c.eegWsUrl).toBe('ws://mac.example:8081/api/eeg/stream'); + expect(c.configError).toBeNull(); + }); + + it('an explicit WS URL wins over derivation', () => { + const c = resolveClientMode('false', 'https://mac.example', 'wss://mac.example/custom'); + expect(c.eegWsUrl).toBe('wss://mac.example/custom'); + }); + + it('live mode with an empty server URL is a visible config error, NOT mock', () => { + const c = resolveClientMode('false', '', ''); + expect(c.mode).toBe('live'); // never silently substitutes mock + expect(c.configError).toMatch(/EXPO_PUBLIC_SERVER_URL is not set/); + }); + + it('live mode with a malformed server URL is a visible config error, NOT mock', () => { + const c = resolveClientMode('false', 'not-a-url', undefined); + expect(c.mode).toBe('live'); + expect(c.configError).toMatch(/invalid EXPO_PUBLIC_SERVER_URL/); + }); +}); diff --git a/src/api/LiveApiClient.ts b/src/api/LiveApiClient.ts index 09c0511..b5eedd4 100644 --- a/src/api/LiveApiClient.ts +++ b/src/api/LiveApiClient.ts @@ -13,9 +13,9 @@ import type { IntentPrediction, PipelineMode, StreamDiagnostics, - EEGSample, } from '../types/api'; import { EEG_WS_URL, SERVER_URL } from '../config'; +import { decodeEEGFrame } from './wireFormat'; async function fetchJson(path: string, init?: RequestInit): Promise { const res = await fetch(`${SERVER_URL}${path}`, { @@ -66,11 +66,9 @@ export class LiveApiClient implements ApiClient { onStatus('open'); }; ws.onmessage = (ev) => { - try { - const sample = JSON.parse(String(ev.data)) as EEGSample; + // decodeEEGFrame validates shape and finiteness; malformed frames → []. + for (const sample of decodeEEGFrame(String(ev.data))) { onSample(sample); - } catch { - // Drop malformed frames. } }; ws.onerror = () => onStatus('error'); diff --git a/src/api/__tests__/wireFormat.test.ts b/src/api/__tests__/wireFormat.test.ts new file mode 100644 index 0000000..330890b --- /dev/null +++ b/src/api/__tests__/wireFormat.test.ts @@ -0,0 +1,42 @@ +// wireFormat — WS frame decoding: batches, malformed rejection, channel order. + +import { decodeEEGFrame } from '../wireFormat'; + +const good = { timestamp: 1.5, channels: [10, 20, 30, 40] }; + +describe('decodeEEGFrame', () => { + it('decodes a single valid sample', () => { + const out = decodeEEGFrame(JSON.stringify(good)); + expect(out).toHaveLength(1); + expect(out[0].channels).toEqual([10, 20, 30, 40]); + }); + + it('decodes a batch array, dropping only the invalid entries', () => { + const batch = [good, { timestamp: 2, channels: [1, 2, 3] }, { ...good, timestamp: 3 }]; + const out = decodeEEGFrame(JSON.stringify(batch)); + expect(out.map((s) => s.timestamp)).toEqual([1.5, 3]); + }); + + it('preserves fixed TP9, AF7, AF8, TP10 order — values are never reordered', () => { + const out = decodeEEGFrame(JSON.stringify({ timestamp: 0, channels: [4, 3, 2, 1] })); + expect(out[0].channels).toEqual([4, 3, 2, 1]); + }); + + it.each([ + ['bad JSON', 'not json{'], + ['null', 'null'], + ['string payload', '"hello"'], + ['missing channels', JSON.stringify({ timestamp: 1 })], + ['wrong channel count', JSON.stringify({ timestamp: 1, channels: [1, 2, 3, 4, 5] })], + ['non-numeric channel', JSON.stringify({ timestamp: 1, channels: [1, 'x', 3, 4] })], + ['NaN channel', JSON.stringify({ timestamp: 1, channels: [1, null, 3, 4] })], + ['missing timestamp', JSON.stringify({ channels: [1, 2, 3, 4] })], + ])('rejects %s', (_name, payload) => { + expect(decodeEEGFrame(payload)).toEqual([]); + }); + + it('rejects non-finite values that JSON cannot even carry when injected as objects', () => { + expect(decodeEEGFrame({ timestamp: NaN, channels: [1, 2, 3, 4] } as unknown)).toEqual([]); + expect(decodeEEGFrame({ timestamp: 1, channels: [Infinity, 2, 3, 4] } as unknown)).toEqual([]); + }); +}); diff --git a/src/api/wireFormat.ts b/src/api/wireFormat.ts new file mode 100644 index 0000000..dc7db91 --- /dev/null +++ b/src/api/wireFormat.ts @@ -0,0 +1,36 @@ +// wireFormat — validation for EEG frames arriving on the WebSocket. +// The server sends one JSON sample per message (~256Hz) today; a frame may also +// be a JSON array of samples (batch), which decodes to the same output. +// Channel order is fixed TP9, AF7, AF8, TP10 — samples are rejected, never +// reordered. Malformed frames yield [] and are dropped upstream. + +import type { EEGSample } from '../types/api'; + +export const EEG_CHANNEL_COUNT = 4; + +function isValidSample(v: unknown): v is EEGSample { + if (typeof v !== 'object' || v === null) return false; + const s = v as { timestamp?: unknown; channels?: unknown }; + if (typeof s.timestamp !== 'number' || !Number.isFinite(s.timestamp)) return false; + if (!Array.isArray(s.channels) || s.channels.length !== EEG_CHANNEL_COUNT) return false; + return s.channels.every((c) => typeof c === 'number' && Number.isFinite(c)); +} + +/** + * Decode one WebSocket message into zero or more valid samples. + * - single sample object → [sample] + * - batch array → the valid samples, invalid entries dropped + * - anything else (bad JSON, wrong shape, NaN/Infinity, wrong channel count) → [] + */ +export function decodeEEGFrame(data: unknown): EEGSample[] { + let parsed: unknown; + try { + parsed = typeof data === 'string' ? JSON.parse(data) : data; + } catch { + return []; + } + if (Array.isArray(parsed)) { + return parsed.filter(isValidSample); + } + return isValidSample(parsed) ? [parsed] : []; +} diff --git a/src/config.ts b/src/config.ts index 0a15e00..3ef6b2b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,10 +1,80 @@ -// Single point of config. Flip USE_MOCK to false and update SERVER_URL when the M4 server is ready. -// See Part 7 of the prompt for the Tailscale integration steps. +// Single point of config. Endpoints and mode come from EXPO_PUBLIC_* env vars +// (statically referenced — Expo inlines them at bundle time; they are PUBLIC, +// never put secrets here). Copy .env.example to .env.local and edit to point at +// your own server. No private addresses belong in this file. +// +// Mode rules (see resolveClientMode): +// - EXPO_PUBLIC_USE_MOCK unset/empty/"true" → mock mode, labeled MOCK in the UI. +// - EXPO_PUBLIC_USE_MOCK="false" → live mode. A live-server failure stays +// visible; there is NO automatic fallback to mock data. +// - Live mode with an empty/malformed EXPO_PUBLIC_SERVER_URL is a visible +// configuration error ("no endpoint"), not a silent downgrade to mock. -export const USE_MOCK = true; +export type ClientMode = 'mock' | 'live'; -export const SERVER_URL = 'http://100.105.8.22:8081'; // M4's Tailscale IP (placeholder) -export const EEG_WS_URL = 'ws://100.105.8.22:8081/api/eeg/stream'; +export interface ResolvedClientConfig { + mode: ClientMode; + serverUrl: string; // '' when unset + eegWsUrl: string; // '' when unresolvable + /** Non-null when live mode is selected but endpoints are unusable. */ + configError: string | null; +} + +/** "false"/"0"/"no" (any case) select live mode; everything else stays mock. */ +export function parseUseMock(raw: string | undefined): boolean { + if (raw === undefined) return true; + const v = raw.trim().toLowerCase(); + if (v === 'false' || v === '0' || v === 'no') return false; + return true; +} + +function normalizeUrl(raw: string | undefined): string { + const v = (raw ?? '').trim(); + return v.replace(/\/+$/, ''); +} + +/** Derive ws(s):// stream URL from the HTTP server URL when not given explicitly. */ +export function deriveWsUrl(serverUrl: string): string { + if (!/^https?:\/\//.test(serverUrl)) return ''; + return serverUrl.replace(/^http/, 'ws') + '/api/eeg/stream'; +} + +export function resolveClientMode( + useMockRaw: string | undefined, + serverRaw: string | undefined, + wsRaw: string | undefined, +): ResolvedClientConfig { + const mock = parseUseMock(useMockRaw); + const serverUrl = normalizeUrl(serverRaw); + const eegWsUrl = normalizeUrl(wsRaw) || deriveWsUrl(serverUrl); + if (mock) { + return { mode: 'mock', serverUrl, eegWsUrl, configError: null }; + } + if (!/^https?:\/\//.test(serverUrl)) { + return { + mode: 'live', + serverUrl, + eegWsUrl, + configError: serverUrl + ? `invalid EXPO_PUBLIC_SERVER_URL: ${serverUrl}` + : 'EXPO_PUBLIC_SERVER_URL is not set', + }; + } + return { mode: 'live', serverUrl, eegWsUrl, configError: null }; +} + +// EXPO_PUBLIC_* vars must be referenced statically (no dynamic indexing) so the +// Expo bundler can inline them. +export const CLIENT_CONFIG: ResolvedClientConfig = resolveClientMode( + process.env.EXPO_PUBLIC_USE_MOCK, + process.env.EXPO_PUBLIC_SERVER_URL, + process.env.EXPO_PUBLIC_EEG_WS_URL, +); + +export const USE_MOCK = CLIENT_CONFIG.mode === 'mock'; +export const SERVER_URL = CLIENT_CONFIG.serverUrl; +export const EEG_WS_URL = CLIENT_CONFIG.eegWsUrl; +export const CONFIG_ERROR = CLIENT_CONFIG.configError; // Polling intervals (ms). The M4 server emits at 256Hz over WS; HTTP endpoints are polled by the client. export const POLL = { @@ -15,14 +85,14 @@ export const POLL = { } as const; // EEG stream buffering: how many samples per channel to keep in the rolling buffer. -// 5 seconds @ 256Hz = 1280 samples per channel. The prompt allows downgrading to 512 (2s) if rendering stutters. +// 5 seconds @ 256Hz = 1280 samples per channel. Downgrade to 512 (2s) if rendering stutters. export const EEG_BUFFER_SAMPLES = 1280; -// Local LLM endpoint — llama-server (Qwen 0.5B Q4_K_M) running on the device. -// 127.0.0.1 from the JS thread resolves to the device loopback in Expo Go on a real device -// because both the Expo runtime and llama-server share the host's network stack. -// If llama-server is on a different host, set LLM_URL in the .env / build config. -export const LLM_URL = 'http://127.0.0.1:8081'; +// Local LLM endpoint — llama-server (Qwen 0.5B Q4_K_M). On the Pixel this runs +// in Termux on the device loopback. On iOS there is no loopback server, so +// synthesis fails visibly ("synthesis unavailable") unless an override points +// at a reachable host. +export const LLM_URL = process.env.EXPO_PUBLIC_LLM_URL || 'http://127.0.0.1:8081'; // Staleness thresholds (ms). Above these, the StaleIndicator turns orange and screens dim. export const STALE = { diff --git a/src/hooks/__tests__/eegBuffer.test.ts b/src/hooks/__tests__/eegBuffer.test.ts new file mode 100644 index 0000000..b8b6323 --- /dev/null +++ b/src/hooks/__tests__/eegBuffer.test.ts @@ -0,0 +1,44 @@ +// eegBuffer — boundedness of the raw sample buffer and channel splitting. + +import { pushBounded, toChannelArrays } from '../eegBuffer'; +import type { EEGSample } from '../../types/api'; + +const sample = (t: number): EEGSample => ({ timestamp: t, channels: [t, t + 1, t + 2, t + 3] }); + +describe('pushBounded', () => { + it('never grows beyond 2x the keep size', () => { + const keep = 8; + let buf: EEGSample[] = []; + for (let i = 0; i < 1000; i++) { + buf = pushBounded(buf, sample(i), keep); + expect(buf.length).toBeLessThanOrEqual(keep * 2); + } + }); + + it('keeps the newest samples after trimming', () => { + const keep = 4; + let buf: EEGSample[] = []; + for (let i = 0; i < 100; i++) buf = pushBounded(buf, sample(i), keep); + const timestamps = buf.map((s) => s.timestamp); + expect(timestamps[timestamps.length - 1]).toBe(99); + // strictly increasing — order preserved + expect([...timestamps].sort((a, b) => a - b)).toEqual(timestamps); + }); +}); + +describe('toChannelArrays', () => { + it('splits into 4 arrays in fixed TP9, AF7, AF8, TP10 order', () => { + const chans = toChannelArrays([sample(10), sample(20)], 100); + expect(chans[0]).toEqual([10, 20]); + expect(chans[1]).toEqual([11, 21]); + expect(chans[2]).toEqual([12, 22]); + expect(chans[3]).toEqual([13, 23]); + }); + + it('windows to the newest `keep` samples', () => { + const samples = Array.from({ length: 50 }, (_, i) => sample(i)); + const chans = toChannelArrays(samples, 10); + expect(chans[0]).toHaveLength(10); + expect(chans[0][9]).toBe(49); + }); +}); diff --git a/src/hooks/__tests__/streamPresentation.test.ts b/src/hooks/__tests__/streamPresentation.test.ts new file mode 100644 index 0000000..1bb4ee9 --- /dev/null +++ b/src/hooks/__tests__/streamPresentation.test.ts @@ -0,0 +1,49 @@ +// streamPresentation — open-but-silent streams must present STALE, not OPEN. +// Regression for the Gate 4 finding: socket state alone is not stream health. + +import { presentStream } from '../streamPresentation'; + +const THRESHOLD = 2000; + +describe('presentStream', () => { + it('presents a fresh open stream as OPEN/ok with no banner', () => { + const v = presentStream('open', 500, THRESHOLD); + expect(v).toEqual({ label: 'OPEN', tone: 'ok', banner: null }); + }); + + it('treats the threshold itself as still fresh, one past it as stale', () => { + expect(presentStream('open', THRESHOLD, THRESHOLD).tone).toBe('ok'); + expect(presentStream('open', THRESHOLD + 1, THRESHOLD).tone).toBe('stale'); + }); + + it('presents an open-but-silent stream as STALE with an explanatory banner', () => { + const v = presentStream('open', 7000, THRESHOLD); + expect(v.label).toBe('STALE 7s'); + expect(v.tone).toBe('stale'); + expect(v.banner).toMatch(/no samples for 7s/); + expect(v.banner).toMatch(/socket still open/); + }); + + it('presents open-with-no-samples-ever as stale NO DATA, not healthy', () => { + const v = presentStream('open', Infinity, THRESHOLD); + expect(v.label).toBe('OPEN · NO DATA'); + expect(v.tone).toBe('stale'); + }); + + it('presents connecting as its own tone without a disconnect banner', () => { + const v = presentStream('connecting', Infinity, THRESHOLD); + expect(v).toEqual({ label: 'CONNECTING', tone: 'connecting', banner: null }); + }); + + it.each(['closed', 'error'] as const)('presents %s as down with the cached-data banner', (s) => { + const v = presentStream(s, 1000, THRESHOLD); + expect(v.label).toBe(s.toUpperCase()); + expect(v.tone).toBe('down'); + expect(v.banner).toMatch(/Stream disconnected/); + }); + + it('never reports ok for a non-open socket regardless of age', () => { + expect(presentStream('closed', 0, THRESHOLD).tone).toBe('down'); + expect(presentStream('connecting', 0, THRESHOLD).tone).toBe('connecting'); + }); +}); diff --git a/src/hooks/eegBuffer.ts b/src/hooks/eegBuffer.ts new file mode 100644 index 0000000..808f240 --- /dev/null +++ b/src/hooks/eegBuffer.ts @@ -0,0 +1,35 @@ +// eegBuffer — pure helpers behind useEEGStream, extracted so boundedness and +// channel-order behavior are unit-testable without a React renderer. + +import type { EEGSample } from '../types/api'; + +/** + * Append a sample to the raw buffer, keeping memory bounded: once the buffer + * exceeds 2×keep (flush running behind), it is trimmed to the newest `keep`. + */ +export function pushBounded(buf: EEGSample[], sample: EEGSample, keep: number): EEGSample[] { + buf.push(sample); + if (buf.length > keep * 2) { + return buf.slice(-keep); + } + return buf; +} + +/** + * Split the newest `keep` samples into per-channel arrays in the fixed + * TP9, AF7, AF8, TP10 order. Never reorders channels. + */ +export function toChannelArrays( + samples: EEGSample[], + keep: number, +): [number[], number[], number[], number[]] { + const tail = samples.length > keep ? samples.slice(-keep) : samples; + const channels: [number[], number[], number[], number[]] = [[], [], [], []]; + for (const s of tail) { + channels[0].push(s.channels[0]); + channels[1].push(s.channels[1]); + channels[2].push(s.channels[2]); + channels[3].push(s.channels[3]); + } + return channels; +} diff --git a/src/hooks/streamPresentation.ts b/src/hooks/streamPresentation.ts new file mode 100644 index 0000000..bbfc377 --- /dev/null +++ b/src/hooks/streamPresentation.ts @@ -0,0 +1,50 @@ +// streamPresentation — truthful presentation of a live stream's health. +// Socket state alone is not health: an open socket with no recent samples is +// STALE, not OPEN (found by the Gate 4 open-but-silent fixture). Pure so the +// pill/banner logic is unit-testable without a renderer. + +import type { EEGStreamStatus } from '../api/ApiClient'; + +export type StreamTone = 'ok' | 'stale' | 'connecting' | 'down'; + +export interface StreamPresentation { + label: string; + tone: StreamTone; + /** Non-null when a warning banner should be shown alongside the pill. */ + banner: string | null; +} + +/** + * @param status underlying WebSocket status + * @param ageMs ms since the newest sample was received; Infinity if none yet + * @param staleAfterMs silence threshold before an open stream is presented stale + */ +export function presentStream( + status: EEGStreamStatus, + ageMs: number, + staleAfterMs: number, +): StreamPresentation { + if (status === 'connecting') { + return { label: 'CONNECTING', tone: 'connecting', banner: null }; + } + if (status === 'closed' || status === 'error') { + return { + label: status.toUpperCase(), + tone: 'down', + banner: 'Stream disconnected — showing last cached data', + }; + } + // status === 'open' + if (!Number.isFinite(ageMs)) { + return { label: 'OPEN · NO DATA', tone: 'stale', banner: null }; + } + if (ageMs > staleAfterMs) { + const s = Math.floor(ageMs / 1000); + return { + label: `STALE ${s}s`, + tone: 'stale', + banner: `Stream silent — no samples for ${s}s (socket still open)`, + }; + } + return { label: 'OPEN', tone: 'ok', banner: null }; +} diff --git a/src/hooks/useEEGStream.ts b/src/hooks/useEEGStream.ts index 9219998..5eb05a4 100644 --- a/src/hooks/useEEGStream.ts +++ b/src/hooks/useEEGStream.ts @@ -7,6 +7,7 @@ import { apiClient } from '../api'; import type { EEGSample } from '../types/api'; import type { EEGStreamStatus } from '../api/ApiClient'; import { EEG_BUFFER_SAMPLES } from '../config'; +import { pushBounded, toChannelArrays } from './eegBuffer'; export interface EEGBuffer { // Per-channel rolling history, oldest → newest. Length === EEG_BUFFER_SAMPLES when full. @@ -30,48 +31,49 @@ export function useEEGStream(): { } { const [buffer, setBuffer] = useState(EMPTY); const [status, setStatus] = useState('connecting'); + // Wall-clock time the newest sample was RECEIVED (not flushed) — this is what + // staleness must be computed from. A socket can stay open while the server + // goes silent; flush time keeps ticking, receive time does not. const [lastUpdate, setLastUpdate] = useState(0); const bufRef = useRef([]); const receivedRef = useRef(0); + const lastSampleAtRef = useRef(0); + const flushedCountRef = useRef(0); // Throttle re-render to ~30fps so React doesn't melt under 250Hz sample rate. const flushTimerRef = useRef | null>(null); useEffect(() => { bufRef.current = []; receivedRef.current = 0; + lastSampleAtRef.current = 0; + flushedCountRef.current = 0; setBuffer(EMPTY); setStatus('connecting'); const sub = apiClient.subscribeEEG( (sample) => { - bufRef.current.push(sample); + // Bounded: trimmed to the newest EEG_BUFFER_SAMPLES once 2x is exceeded. + bufRef.current = pushBounded(bufRef.current, sample, EEG_BUFFER_SAMPLES); receivedRef.current += 1; - // Cap memory: keep only the last 2x buffer in case flush is slow. - if (bufRef.current.length > EEG_BUFFER_SAMPLES * 2) { - bufRef.current = bufRef.current.slice(-EEG_BUFFER_SAMPLES); - } + lastSampleAtRef.current = Date.now(); }, (s) => setStatus(s), ); // ~30fps renderer flush. Drops samples if the producer is faster; that's intentional. + // Skips entirely when nothing new arrived, so a silent stream stops + // producing state churn and lastUpdate honestly stops advancing. flushTimerRef.current = setInterval(() => { + if (receivedRef.current === flushedCountRef.current) return; + flushedCountRef.current = receivedRef.current; const samples = bufRef.current; - if (samples.length === 0) return; - const tail = samples.slice(-EEG_BUFFER_SAMPLES); - const channels: [number[], number[], number[], number[]] = [[], [], [], []]; - for (const s of tail) { - channels[0].push(s.channels[0]); - channels[1].push(s.channels[1]); - channels[2].push(s.channels[2]); - channels[3].push(s.channels[3]); - } + const channels = toChannelArrays(samples, EEG_BUFFER_SAMPLES); setBuffer({ channels, - latest: tail.length - 1, + latest: channels[0].length - 1, received: receivedRef.current, }); - setLastUpdate(Date.now()); + setLastUpdate(lastSampleAtRef.current); }, 33); return () => { diff --git a/src/runtime/__tests__/identity.test.ts b/src/runtime/__tests__/identity.test.ts index 5d0e0d4..f10c383 100644 --- a/src/runtime/__tests__/identity.test.ts +++ b/src/runtime/__tests__/identity.test.ts @@ -67,13 +67,13 @@ describe('classifyLocality', () => { expect(classifyLocality('http://127.0.0.1:8081', 'none')).toBe('unknown'); }); test('non-loopback endpoints are remote_service; garbage is unknown', () => { - expect(classifyLocality('http://100.105.8.22:8081', 'exact')).toBe('remote_service'); + expect(classifyLocality('http://100.100.10.20:8081', 'exact')).toBe('remote_service'); expect(classifyLocality('https://api.example.com/v1', 'exact')).toBe('remote_service'); expect(classifyLocality('not a url', 'exact')).toBe('unknown'); }); test('endpointClass never exposes the raw URL', () => { expect(endpointClass('http://127.0.0.1:8081')).toBe('loopback-http'); - expect(endpointClass('http://100.105.8.22:8081')).toBe('remote-http'); + expect(endpointClass('http://100.100.10.20:8081')).toBe('remote-http'); }); }); diff --git a/src/screens/DreamJournalScreen.tsx b/src/screens/DreamJournalScreen.tsx index f5323e8..f49ba9b 100644 --- a/src/screens/DreamJournalScreen.tsx +++ b/src/screens/DreamJournalScreen.tsx @@ -52,6 +52,8 @@ export function DreamJournalScreen() { const insets = useSafeAreaInsets(); const [text, setText] = useState(''); const [entries, setEntries] = useState([]); + // null = permission not yet resolved, false = denied, true = granted. + const [micGranted, setMicGranted] = useState(null); const recorder = useAudioRecorder(RecordingPresets.HIGH_QUALITY); const recorderState = useAudioRecorderState(recorder); const now = useNow(1000); @@ -69,6 +71,7 @@ export function DreamJournalScreen() { (async () => { try { const status = await AudioModule.requestRecordingPermissionsAsync(); + setMicGranted(status.granted); if (!status.granted) { Alert.alert('Microphone access denied', 'Voice entries will not work without microphone permission. Text entries are still available.'); } @@ -146,7 +149,11 @@ export function DreamJournalScreen() { }; const recording = recorderState.isRecording; - const canRecord = recorderState.canRecord; + // Gate the button on PERMISSION, not on recorderState.canRecord: canRecord + // only becomes true after prepareToRecordAsync(), which startRecording + // itself calls — gating on it made the button permanently disabled + // (chicken-and-egg found by the Gate 3 acceptance run on both platforms). + const canRecord = micGranted !== false; return ( diff --git a/src/screens/EEGScreen.tsx b/src/screens/EEGScreen.tsx index fdb42dd..51de897 100644 --- a/src/screens/EEGScreen.tsx +++ b/src/screens/EEGScreen.tsx @@ -7,18 +7,32 @@ import { ScrollView, StyleSheet, Text, View, useWindowDimensions } from 'react-n import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { EEGTrace } from '../components/EEGTrace'; import { useEEGStream } from '../hooks/useEEGStream'; +import { presentStream, type StreamTone } from '../hooks/streamPresentation'; +import { useNow } from '../hooks/useNow'; +import { STALE } from '../config'; import { colors, radius, spacing, typography } from '../theme'; const CHANNELS = ['TP9', 'AF7', 'AF8', 'TP10'] as const; +const TONE_COLORS: Record = { + ok: colors.green, + stale: colors.orange, + connecting: colors.orange, + down: colors.red, +}; + export function EEGScreen() { const insets = useSafeAreaInsets(); const { width } = useWindowDimensions(); const traceWidth = width - spacing.lg * 2 - spacing.md * 2; // outer padding + card padding const traceHeight = 96; const { buffer, status, lastUpdate } = useEEGStream(); + const now = useNow(1000); + + // Age of the newest RECEIVED sample — Infinity until the first one arrives. + const sampleAgeMs = lastUpdate > 0 ? Math.max(0, now - lastUpdate) : Infinity; + const view = presentStream(status, sampleAgeMs, STALE.channelSample); - const streamOk = status === 'open'; const connecting = status === 'connecting'; const disconnected = status === 'closed' || status === 'error'; @@ -30,8 +44,8 @@ export function EEGScreen() { EEG Stream - - {status.toUpperCase()} + + {view.label} @@ -41,9 +55,9 @@ export function EEGScreen() { : 'Waiting for first sample…'} - {disconnected ? ( - - Stream disconnected — showing last cached data + {view.banner ? ( + + {view.banner} ) : null} @@ -62,7 +76,7 @@ export function EEGScreen() { - {lastUpdate > 0 ? `last frame ${Math.floor((Date.now() - lastUpdate) / 1000)}s ago · 5s window · ~30 fps render` : 'idle'} + {lastUpdate > 0 ? `last sample ${Math.floor(sampleAgeMs / 1000)}s ago · 5s window · ~30 fps render` : 'idle'} @@ -96,6 +110,8 @@ const styles = StyleSheet.create({ padding: spacing.md, }, bannerText: { color: colors.red, fontSize: typography.caption, fontWeight: '600', textAlign: 'center' }, + bannerStale: { backgroundColor: colors.orange + '22', borderColor: colors.orange }, + bannerTextStale: { color: colors.orange }, traceList: { gap: spacing.sm }, footer: { alignItems: 'center' }, footerText: { color: colors.textDim, fontSize: typography.micro, textAlign: 'center' }, diff --git a/src/screens/OverviewScreen.tsx b/src/screens/OverviewScreen.tsx index f8b7fd0..5200f05 100644 --- a/src/screens/OverviewScreen.tsx +++ b/src/screens/OverviewScreen.tsx @@ -11,7 +11,7 @@ import { useDiagnostics } from '../hooks/useDiagnostics'; import { usePipelineMode } from '../hooks/usePipelineMode'; import { useNow, relativeTime } from '../hooks/useNow'; import { colors, radius, spacing, typography } from '../theme'; -import { STALE } from '../config'; +import { STALE, USE_MOCK, SERVER_URL, CONFIG_ERROR } from '../config'; export function OverviewScreen() { const insets = useSafeAreaInsets(); @@ -40,8 +40,27 @@ export function OverviewScreen() { /> } > - Overview - NeuralCompose pipeline status + + + Overview + NeuralCompose pipeline status + + {/* Client data-source pill. Tab headers are hidden, so this is the one + unmistakable MOCK/LIVE label. Distinct from the server-reported + pipeline mode below: this is what the PHONE is consuming. */} + + + {USE_MOCK ? 'MOCK DATA' : CONFIG_ERROR ? 'LIVE · NO ENDPOINT' : 'LIVE'} + + + {USE_MOCK + ? 'fixtures on this phone' + : SERVER_URL + ? SERVER_URL.replace(/^https?:\/\//, '') + : 'set EXPO_PUBLIC_SERVER_URL'} + + + @@ -97,6 +116,23 @@ const styles = StyleSheet.create({ container: { padding: spacing.lg, paddingBottom: spacing.xxl, gap: spacing.lg }, heading: { color: colors.text, fontSize: typography.title, fontWeight: '700' }, subheading: { color: colors.textMuted, fontSize: typography.caption, marginTop: -spacing.sm }, + headingRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-start', gap: spacing.md }, + sourcePill: { + borderRadius: radius.pill, + borderWidth: 1, + paddingHorizontal: spacing.md, + paddingVertical: 4, + alignItems: 'flex-end', + maxWidth: 180, + }, + sourcePillMock: { borderColor: colors.green, backgroundColor: colors.green + '22' }, + sourcePillLive: { borderColor: colors.accent, backgroundColor: colors.accent + '22' }, + sourcePillError: { borderColor: colors.orange, backgroundColor: colors.orange + '22' }, + sourcePillText: { fontSize: typography.micro, fontWeight: '700', letterSpacing: 1 }, + sourcePillTextMock: { color: colors.green }, + sourcePillTextLive: { color: colors.accent }, + sourcePillTextError: { color: colors.orange }, + sourcePillDetail: { color: colors.textMuted, fontSize: 9, fontVariant: ['tabular-nums'] }, section: { gap: spacing.sm }, sectionTitle: { color: colors.text, fontSize: typography.heading, fontWeight: '600' }, diagHeader: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }, diff --git a/src/services/EmbeddingClient.ts b/src/services/EmbeddingClient.ts index de19ed2..00d6b8e 100644 --- a/src/services/EmbeddingClient.ts +++ b/src/services/EmbeddingClient.ts @@ -5,7 +5,7 @@ import type { Embedding } from '../dialectic/types'; -export const EMBEDDING_URL = 'http://127.0.0.1:8082'; +export const EMBEDDING_URL = process.env.EXPO_PUBLIC_EMBEDDING_URL || 'http://127.0.0.1:8082'; export const EMBEDDING_MODEL_PATH = '/data/data/com.termux/files/home/models/bge-small-en-v1.5-q8_0.gguf'; export interface EmbeddingResult { diff --git a/src/services/TranscriptionClient.ts b/src/services/TranscriptionClient.ts index 531d3c1..fa3d5ab 100644 --- a/src/services/TranscriptionClient.ts +++ b/src/services/TranscriptionClient.ts @@ -3,7 +3,7 @@ // API: POST /inference with multipart form data (file, temperature, response_format). // When no STT is available, falls back to manual text injection (clearly labeled). -export const STT_URL = 'http://127.0.0.1:8083'; +export const STT_URL = process.env.EXPO_PUBLIC_STT_URL || 'http://127.0.0.1:8083'; export interface TranscriptionResult { text: string;