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;