Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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=
6 changes: 4 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 9 additions & 4 deletions App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -56,11 +56,15 @@ export default function App() {
headerTintColor: colors.text,
headerRight: () => (
<View style={styles.headerBadge}>
<Text style={styles.headerBadgeText}>
{USE_MOCK ? 'MOCK' : 'LIVE'}
<Text style={[styles.headerBadgeText, CONFIG_ERROR ? styles.headerBadgeError : null]}>
{USE_MOCK ? 'MOCK' : CONFIG_ERROR ? 'LIVE · NO ENDPOINT' : 'LIVE'}
</Text>
<Text style={styles.headerBadgeUrl} numberOfLines={1}>
{USE_MOCK ? 'fixtures' : SERVER_URL.replace(/^https?:\/\//, '')}
{USE_MOCK
? 'fixtures'
: SERVER_URL
? SERVER_URL.replace(/^https?:\/\//, '')
: 'set EXPO_PUBLIC_SERVER_URL'}
</Text>
</View>
),
Expand Down Expand Up @@ -141,6 +145,7 @@ const styles = StyleSheet.create({
fontWeight: '700',
letterSpacing: 1,
},
headerBadgeError: { color: colors.orange },
headerBadgeUrl: {
color: colors.textMuted,
fontSize: 9,
Expand Down
34 changes: 20 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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://<m4-tailnet-host>: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://<m4-tailnet-host>: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
Expand All @@ -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.

---

Expand Down
6 changes: 3 additions & 3 deletions SDK57-REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)

Expand Down
13 changes: 10 additions & 3 deletions app.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -36,7 +42,8 @@
"usesCleartextTraffic": true
}
}
]
],
"expo-asset"
]
}
}
114 changes: 114 additions & 0 deletions docs/ios-client-audit.md
Original file line number Diff line number Diff line change
@@ -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.
Loading