feat(ios): iOS client with QR pairing, E2E-encrypted tunnel, and push-to-talk (#1420)

This commit is contained in:
Steven Enamakel
2026-05-23 01:44:50 -07:00
committed by GitHub
parent fe37b132ee
commit 3e5a083793
155 changed files with 15662 additions and 16 deletions
+64
View File
@@ -0,0 +1,64 @@
---
name: Android Compile Sanity
on:
pull_request:
paths:
- 'app/src-tauri-mobile/**'
- 'packages/tauri-plugin-ptt/**'
- 'src/openhuman/devices/**'
- 'app/src/services/transport/**'
- 'app/src/lib/tunnel/**'
- 'app/src/pages/ios/**'
- '.github/workflows/android-compile.yml'
workflow_dispatch:
permissions:
contents: read
pull-requests: read
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.head_ref || github.ref }}
cancel-in-progress: true
jobs:
android-compile:
name: Android Compile Check
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 1
# Mobile crate uses stock Tauri (no CEF) — no submodules needed.
submodules: false
- name: Set up Rust
uses: dtolnay/rust-toolchain@stable
with:
toolchain: '1.93.0'
targets: aarch64-linux-android
- name: Cache Rust build artifacts
uses: Swatinem/rust-cache@v2
with:
workspaces: |
app/src-tauri-mobile -> target
packages/tauri-plugin-ptt -> target
cache-on-failure: true
- name: Set up pnpm
uses: pnpm/action-setup@v4
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: '24'
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
# Hard gate: mobile Tauri host compiles for Android.
- name: cargo check -- mobile host (aarch64-linux-android)
run: cargo check --manifest-path app/src-tauri-mobile/Cargo.toml --target aarch64-linux-android
+93
View File
@@ -0,0 +1,93 @@
---
name: iOS Compile Sanity
on:
pull_request:
paths:
- 'app/src-tauri-mobile/**'
- 'packages/tauri-plugin-ptt/**'
- 'src/openhuman/devices/**'
- 'app/src/services/transport/**'
- 'app/src/lib/tunnel/**'
- 'app/src/pages/ios/**'
- '.github/workflows/ios-compile.yml'
workflow_dispatch:
permissions:
contents: read
pull-requests: read
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.head_ref || github.ref }}
cancel-in-progress: true
jobs:
ios-compile:
name: iOS Compile Check
runs-on: macos-latest
env:
# Pin the deployment target so swift-rs invokes the Swift compiler with
# `-target arm64-apple-ios16.0`. Matches Package.swift in
# packages/tauri-plugin-ptt/ios/, which uses iOS 14+ APIs (OSLog).
IPHONEOS_DEPLOYMENT_TARGET: '16.0'
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 1
# The mobile crate uses stock Tauri (no CEF), so we don't need
# `submodules: recursive` — which would try to clone the
# `app/src-tauri/vendor/tauri-cef` submodule, a step that
# intermittently fails on macOS runners for fork PRs.
submodules: false
- name: Set up Rust
uses: dtolnay/rust-toolchain@stable
with:
toolchain: '1.93.0'
targets: aarch64-apple-ios
- name: Cache Rust build artifacts
uses: Swatinem/rust-cache@v2
with:
workspaces: |
. -> target
app/src-tauri-mobile -> target
packages/tauri-plugin-ptt -> target
cache-on-failure: true
- name: Set up pnpm
uses: pnpm/action-setup@v4
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: '24'
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
# Hard gate: mobile Tauri host compiles for iOS. No more soft-gate
# `continue-on-error` — the mobile crate uses stock Tauri without CEF
# so cef-dll-sys is not in the dependency graph.
- name: cargo check -- mobile host (aarch64-apple-ios)
run: cargo check --manifest-path app/src-tauri-mobile/Cargo.toml --target aarch64-apple-ios
# Hard gate: PTT plugin (host-target check; Swift sources are built
# lazily by swift-rs during the iOS-target check above).
- name: cargo check -- tauri-plugin-ptt
run: cargo check --manifest-path packages/tauri-plugin-ptt/Cargo.toml
# Hard gate: TypeScript compile.
- name: pnpm compile
run: pnpm --dir app compile
# Hard gate: iOS-relevant Vitest suites.
- name: pnpm test (iOS suites)
run: >
pnpm --dir app test --
src/services/transport
src/lib/tunnel
src/pages/ios
src/components/settings/panels/devices
+21
View File
@@ -31,6 +31,27 @@ Commands assume the **repo root**; `pnpm dev` delegates to the `app` workspace.
---
## iOS client (experimental)
The iOS client is an **in-progress, non-shipping** target in this repo. It does not ship a Rust core on-device; instead it connects to the desktop core via one of three transports selected by a `ConnectionProfile`.
**Transport strategies** (see `app/src/services/transport/`):
- `LanHttpTransport` — direct HTTP to the desktop core on the same LAN.
- `TunnelTransport` — socket.io relay through the backend; E2E encrypted with XChaCha20-Poly1305 over X25519 key agreement.
- `CloudHttpTransport` — fallback via the cloud backend API.
**Key paths:**
- PTT plugin: `packages/tauri-plugin-ptt/` (Swift + Rust, iOS-only).
- iOS screens: `app/src/pages/ios/` and `app/src/components/ios/`.
- Devices domain (Rust): `src/openhuman/devices/`.
- Tunnel crypto (TS): `app/src/lib/tunnel/`.
- iOS build entry: `pnpm tauri:ios:dev` — uses stock `@tauri-apps/cli@^2` via `npx`, **not** the vendored CEF CLI.
- Setup guide: `docs/ios/SETUP.md`.
**Backend dependency:** `tinyhumansai/backend#709` (tunnel socket.io contract) must be merged and deployed for end-to-end pairing to work.
---
## Commands (from repo root)
```bash
Generated
+1
View File
@@ -5174,6 +5174,7 @@ dependencies = [
"whatsapp-rust-ureq-http-client",
"whisper-rs",
"wiremock",
"x25519-dalek",
"xz2",
"zip",
]
+1
View File
@@ -74,6 +74,7 @@ uuid = { version = "1", features = ["v4"] }
anyhow = "1.0"
async-trait = "0.1"
chacha20poly1305 = "0.10"
x25519-dalek = { version = "2", features = ["static_secrets"] }
hex = "0.4"
tokio-util = { version = "0.7", features = ["rt", "io"] }
# tokio-tungstenite is declared per-target below so the TLS backend
+148
View File
@@ -0,0 +1,148 @@
## Summary
- Adds an iOS client for OpenHuman: device pairing via QR code, mascot chat screen, and push-to-talk voice input.
- No Rust core ships on device; the iOS app connects to the desktop core via LAN HTTP, an E2E-encrypted socket.io tunnel, or cloud HTTP fallback.
- All changes are cfg-gated or platform-guarded; the desktop build is unaffected.
- Adds the `tauri-plugin-ptt` Swift plugin (`packages/tauri-plugin-ptt/`) for AVAudioEngine + SFSpeechRecognizer on iOS.
- Adds CI sanity-check workflow, build scripts, capability catalog entries, and full docs.
## Problem
Users with iOS devices had no way to interact with their OpenHuman assistant on the go. The desktop app required a local machine. This PR adds the client-side scaffolding and transport layer needed to bridge iOS to an existing desktop core.
## Solution
The iOS app is a subset of the existing React/TypeScript UI, compiled by Tauri v2 into an iOS bundle. A `TransportManager` selects the best transport at runtime. Pairing is secured by an X25519 key agreement; all tunnel traffic uses XChaCha20-Poly1305 encryption. The backend is a blind socket.io forwarder -- it never sees plaintext.
## Layer-by-layer commits
| Commit | Layer | Summary |
|--------|-------|---------|
| `a99537f3` | Layer 1 | Rust devices domain -- pairing store, RPC handlers, event bus, crypto (`src/openhuman/devices/`) |
| `4ea14b78` | Layer 2 | TS transport refactor -- `TransportManager`, `LanHttpTransport`, `TunnelTransport`, `CloudHttpTransport`, tunnel crypto (`app/src/services/transport/`, `app/src/lib/tunnel/`) |
| `ba651705` | Layer 3 | Desktop `/settings/devices` UI -- `DevicesPanel`, `PairPhoneModal` with QR generation and 2-second poll |
| `3e0e2a67` | Layer 4 | Tauri shell cfg-gating -- `#[cfg(target_os = "ios")]` guards on CEF-specific code |
| `621fec98` | Layer 5 | iOS app shell -- `PairScreen` (QR scan via `AVCaptureSession`), `MascotScreen` (chat UI) |
| `5ca6cf21` | Layer 6 | `tauri-plugin-ptt` -- Swift PTT plugin (AVAudioEngine, SFSpeechRecognizer, AVSpeechSynthesizer) |
| `41a6a895` | Layer 6 fix | PTT Swift fix -- latest transcript tracking + `@unchecked Sendable` on PTTSpeaker |
| _(this PR)_ | Layers 7+8 | Build scripts, CI, Info.plist, capability catalog, docs, quality pass |
## Test coverage
- **Vitest:** 1957 passed, 3 skipped, 1 todo across 218 test files (includes transport, tunnel, devices, iOS, PTT suites).
- **Rust (about_app):** 20 passed -- validates catalog uniqueness, Mobile category, and new capability entries.
- **cargo check (all three Cargo.toml files):** clean (warnings only, pre-existing).
## What is gated behind the iOS target
The following only activates on `cfg(target_os = "ios")` or when explicitly called from iOS screens:
- CEF exclusions in `app/src-tauri/` (accounts webviews, etc.)
- `tauri-plugin-ptt` commands (`start_listening`, `stop_listening`, `speak`, `cancel_speech`, `list_voices`) -- return `NotSupported` on non-iOS targets.
- `packages/tauri-plugin-ptt/ios/` Swift sources -- not compiled for desktop.
Desktop users see no change.
## Known TODOs for follow-up PRs
- **Keychain migration:** iOS symmetric session key is in-memory only; persist to Keychain so the app reconnects after restart without re-pairing.
- **Event-driven pairing detection:** `PairPhoneModal` polls `devices_list` every 2 s. Switch to a socket event subscription when the SSE/socket bridge for `DomainEvent::DevicePaired` lands.
- **Full Xcode CI:** `cargo check --target aarch64-apple-ios` runs with `continue-on-error: true` in the new CI workflow because third-party C deps (cef-dll-sys) may fail without full Xcode on the runner. A follow-up should pin an Xcode-enabled runner and harden this to a hard gate.
- **APNs push notifications:** real-time delivery requires the app to be foregrounded.
- **Multi-region tunnel:** single backend instance only; no failover.
- **Info.plist automation:** developer must manually copy `Info.ios.plist` keys into the generated Xcode project after `tauri ios init`. Should automate via `bundle.iOS.template` once Tauri v2 stabilises the iOS template pipeline.
## Backend dependency
**`tinyhumansai/backend#709` must be merged and deployed before end-to-end pairing works.** The `devices_create_pairing` RPC will return a tunnel registration error until the `tunnel:register` / `tunnel:connect` / `tunnel:frame` socket.io contract is live.
## Manual test plan for iOS reviewer
_(Requires a physical iPhone or iOS 17+ simulator paired with the desktop app.)_
From `packages/tauri-plugin-ptt/README.md`:
- [ ] Permissions dialog appears on first `startListening` call.
- [ ] Partial transcripts update while speaking; final transcript matches.
- [ ] Hold button to record, release to stop, chat message is sent with transcript.
- [ ] TTS plays through speaker by default when iPhone is held away from ear.
- [ ] BT headset routes audio correctly; disconnecting mid-recording stops gracefully.
- [ ] App backgrounded mid-record produces a final transcript and stops cleanly.
- [ ] Phone call interruption emits `ptt://error` with `code: interrupted`.
- [ ] `cancelSpeech` during TTS emits `tts-ended` with `finished: false`.
- [ ] `listVoices` returns non-empty list of `AVSpeechSynthesisVoice` entries.
Additional pairing flow checks:
- [ ] Desktop: Settings > Devices > "Pair iPhone" shows QR code.
- [ ] iOS app: PairScreen scans QR and transitions to MascotScreen after handshake.
- [ ] Desktop: Devices panel lists the paired device with correct label.
- [ ] Desktop: Revoke device removes it from the list; iOS app shows reconnect prompt.
- [ ] QR code expiry: code expires after TTL, "Generate new code" creates a fresh session.
## Screenshots
> **PLACEHOLDER:** Before opening the PR, attach screenshots of:
> - Desktop `/settings/devices` panel with a paired device.
> - iOS mascot screen showing a conversation.
>
> These require a device with Xcode signing configured and `tinyhumansai/backend#709` deployed.
## Submission Checklist
- [x] Tests added or updated (transport, tunnel, devices, iOS, PTT suites -- see coverage statement above).
- [x] Diff coverage note: new Rust code in `src/openhuman/devices/` was covered in Layer 1 tests; new TS code in `app/src/services/transport/` and `app/src/lib/tunnel/` covered by Vitest suites. PTT Swift layer cannot be unit-tested without iOS toolchain (noted in README).
- [x] Coverage matrix: N/A for this layer (build scripts, CI, docs, catalog).
- [x] No new external network dependencies (all transport calls use existing mock backend or real backend behind feature flag).
- [ ] Manual smoke checklist: iOS path not in `docs/RELEASE-MANUAL-SMOKE.md` yet -- tracked as follow-up.
- [ ] Linked issue: N/A (tracked via Linear).
## Impact
- Desktop runtime: no change.
- iOS target: new experimental app bundle (not in release pipeline yet).
- `packages/tauri-plugin-ptt/` is a new crate workspace member; adds to build time only when targeting iOS.
- Capability catalog adds three new `mobile.*` entries and a new `Mobile` category.
## Related
- Closes: N/A (new feature)
- Follow-up PR(s): Keychain migration, event-driven pairing, full Xcode CI, APNs.
- Backend: tinyhumansai/backend#709
---
## AI Authored PR Metadata (required for Codex/Linear PRs)
### Linear Issue
- Key: N/A
- URL: N/A
### Commit & Branch
- Branch: `feat/ios-client`
- Commit SHA: _(set after final commit)_
### Validation Run
- [x] `pnpm --filter openhuman-app format:check` -- clean
- [x] `pnpm typecheck` -- clean
- [x] Focused tests: Vitest 1957 passed; cargo about_app 20 passed
- [x] Rust fmt/check: `cargo fmt --all` + `cargo check` on all three Cargo.toml -- clean
- [x] Tauri fmt/check: included above
### Validation Blocked
- command: `cargo check --target aarch64-apple-ios`
- error: May fail on cef-dll-sys C deps without full Xcode; guarded with `continue-on-error: true` in CI.
- impact: Soft gate only; does not block merge.
### Behavior Changes
- Intended behavior change: Desktop users see new Settings > Devices panel. iOS users can pair and chat.
- User-visible effect: Desktop gains device management UI. iOS app becomes available for sideloading/TestFlight.
### Parity Contract
- Legacy behavior preserved: All existing desktop flows unaffected. No CEF injection added. No new JS injection in webview accounts.
- Guard/fallback/dispatch parity: PTT commands return `NotSupported` on non-iOS. Transport falls back gracefully.
### Duplicate / Superseded PR Handling
- Duplicate PR(s): None
- Canonical PR: This PR
- Resolution: N/A
+1
View File
@@ -3,6 +3,7 @@ dist
coverage
app
src-tauri
src-tauri-mobile
rust-core
skills
*.config.js
+10
View File
@@ -14,6 +14,12 @@
"dev:wry": "pnpm tauri:ensure && export CEF_PATH=\"$HOME/Library/Caches/tauri-cef\" && source ../scripts/load-dotenv.sh && cargo tauri dev --no-default-features --features wry",
"core:stage": "echo '[core:stage] no-op — core is linked in-process; sidecar removed (PR #1061)'",
"tauri:ensure": "bash ../scripts/ensure-tauri-cli.sh",
"tauri:ios:init": "bash ../scripts/ios-init.sh",
"tauri:ios:dev": "cd src-tauri-mobile && IPHONEOS_DEPLOYMENT_TARGET=${IPHONEOS_DEPLOYMENT_TARGET:-16.0} npx --package=@tauri-apps/cli@^2 tauri ios dev",
"tauri:ios:build": "cd src-tauri-mobile && IPHONEOS_DEPLOYMENT_TARGET=${IPHONEOS_DEPLOYMENT_TARGET:-16.0} npx --package=@tauri-apps/cli@^2 tauri ios build",
"tauri:android:init": "bash ../scripts/android-init.sh",
"tauri:android:dev": "cd src-tauri-mobile && npx --package=@tauri-apps/cli@^2 tauri android dev",
"tauri:android:build": "cd src-tauri-mobile && npx --package=@tauri-apps/cli@^2 tauri android build",
"build": "tsc && vite build",
"build:app": "tsc && vite build",
"build:app:e2e": "tsc && vite build --mode development",
@@ -61,6 +67,7 @@
"knip:production": "knip --config knip.json --production"
},
"dependencies": {
"@noble/ciphers": "^1.2.1",
"@noble/curves": "^2.2.0",
"@noble/hashes": "^2.0.1",
"@noble/secp256k1": "^3.0.0",
@@ -73,6 +80,8 @@
"@scure/bip39": "^2.0.1",
"@sentry/react": "^10.38.0",
"@tauri-apps/api": "^2.10.0",
"@tauri-apps/plugin-barcode-scanner": "^2.4.4",
"tauri-plugin-ptt-api": "workspace:*",
"@tauri-apps/plugin-deep-link": "^2",
"@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-os": "^2.3.2",
@@ -83,6 +92,7 @@
"lottie-react": "^2.4.1",
"os-browserify": "^0.3.0",
"process": "^0.11.10",
"qrcode.react": "^4.2.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-ga4": "^3.0.1",
+2
View File
@@ -0,0 +1,2 @@
target/
gen/
+4647
View File
File diff suppressed because it is too large Load Diff
+58
View File
@@ -0,0 +1,58 @@
[package]
name = "openhuman-mobile"
version = "0.54.10"
description = "OpenHuman mobile (iOS) — Tauri host without CEF"
authors = ["OpenHuman"]
edition = "2021"
default-run = "openhuman-mobile"
autobins = false
# Mobile host is iOS-only. Block other targets so this crate never gets pulled
# into a desktop build by accident.
[lib]
name = "openhuman_mobile"
crate-type = ["staticlib", "cdylib", "rlib"]
[[bin]]
name = "openhuman-mobile"
path = "src/main.rs"
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
# Stock upstream Tauri — no vendored CEF runtime. The mobile host renders via
# WKWebView (iOS) / WebView (the Tauri default), not Chromium. CSP and the
# React app are identical to desktop; only the host process is different.
tauri = { version = "2.10", default-features = false, features = [
"common-controls-v6",
"devtools",
"unstable",
"webview-data-url",
"wry",
] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
log = "0.4"
env_logger = "0.11"
# iOS gets the QR scanner + push-to-talk plugins. PTT ships Swift sources
# under packages/tauri-plugin-ptt/ios/.
[target.'cfg(target_os = "ios")'.dependencies]
tauri-plugin-barcode-scanner = "2"
tauri-plugin-ptt = { path = "../../packages/tauri-plugin-ptt" }
# Android gets the QR scanner only. PTT returns `NotSupported` on Android —
# we don't ship a Kotlin implementation today (tracked as a follow-up).
[target.'cfg(target_os = "android")'.dependencies]
tauri-plugin-barcode-scanner = "2"
tauri-plugin-ptt = { path = "../../packages/tauri-plugin-ptt" }
[features]
default = []
custom-protocol = ["tauri/custom-protocol"]
# Match the desktop release profile for binary size.
[profile.release]
debug = "line-tables-only"
split-debuginfo = "packed"
+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSCameraUsageDescription</key>
<string>OpenHuman uses the camera to scan the pairing QR code from your desktop.</string>
<key>NSMicrophoneUsageDescription</key>
<string>OpenHuman uses the microphone for push-to-talk voice messages.</string>
<key>NSSpeechRecognitionUsageDescription</key>
<string>OpenHuman uses on-device speech recognition to transcribe your voice messages.</string>
</dict>
</plist>
+5
View File
@@ -0,0 +1,5 @@
fn main() {
println!("cargo:rerun-if-changed=permissions");
println!("cargo:rerun-if-changed=capabilities");
tauri_build::build();
}
@@ -0,0 +1,13 @@
{
"$schema": "../gen/schemas/mobile-schema.json",
"identifier": "mobile-default",
"description": "Capability shared between the iOS and Android targets.",
"platforms": ["iOS", "android"],
"windows": ["main"],
"permissions": [
"core:default",
"core:event:default",
"barcode-scanner:allow-scan",
"barcode-scanner:allow-cancel"
]
}
@@ -0,0 +1,14 @@
{
"$schema": "../gen/schemas/mobile-schema.json",
"identifier": "ios-ptt",
"description": "Push-to-talk permissions — iOS only (Swift AVAudioEngine/SFSpeechRecognizer/AVSpeechSynthesizer bridge).",
"platforms": ["iOS"],
"windows": ["main"],
"permissions": [
"ptt:allow-start-listening",
"ptt:allow-stop-listening",
"ptt:allow-speak",
"ptt:allow-cancel-speech",
"ptt:allow-list-voices"
]
}
+17
View File
@@ -0,0 +1,17 @@
# Mobile app icons
Brand-quality icons committed to the repo so initial `tauri ios init` /
`tauri android init` runs produce a real-looking app instead of the
placeholder Tauri ships.
| Path | Used by |
| --- | --- |
| `icon.png` (1024×1024) | `tauri.conf.json#bundle.icon` — Tauri build pipeline |
| `ios/AppIcon.appiconset/*` | Copied by `scripts/ios-init.sh` into `gen/apple/<bundle>_iOS/Assets.xcassets/AppIcon.appiconset/` after init |
| `android/mipmap-{m,h,xh,xxh,xxxh}dpi/ic_launcher.png` | Copied by `scripts/android-init.sh` into `gen/android/app/src/main/res/mipmap-*/` after init |
| `store/appstore.png` (1024×1024) | App Store Connect upload |
| `store/playstore.png` (512×512) | Google Play Console upload |
The `gen/` directory is `.gitignore`d (Tauri regenerates it from
`tauri.conf.json` on every `init`), so the canonical source for icons
must live here, not under `gen/`.
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 791 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

+45
View File
@@ -0,0 +1,45 @@
// OpenHuman mobile (iOS + Android) Tauri host.
//
// No CEF runtime, no Rust core sidecar, no desktop chrome. The React app
// (built from `app/src/`) is loaded into a single WKWebView (iOS) /
// Android WebView; it talks to a remote desktop core via the TS-side
// TransportManager (LAN HTTP / encrypted tunnel / cloud HTTP — see
// `app/src/services/transport/`).
#[cfg(not(any(target_os = "ios", target_os = "android")))]
compile_error!(
"openhuman-mobile only supports iOS and Android. Use app/src-tauri for desktop."
);
use tauri::{AppHandle, Manager, Runtime};
/// Tauri command: terminate the app cleanly. Used by the Settings page
/// "Sign out / forget device" flow when the user wants to back out of a
/// paired session.
#[tauri::command]
async fn app_quit<R: Runtime>(app: AppHandle<R>) -> Result<(), String> {
log::info!("[mobile] app_quit invoked");
app.exit(0);
Ok(())
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
log::info!("[mobile] run() — starting mobile Tauri builder");
tauri::Builder::default()
.plugin(tauri_plugin_barcode_scanner::init())
// PTT ships Swift sources for iOS only; on Android the plugin
// registers as a no-op stub (all commands return NotSupported).
// See packages/tauri-plugin-ptt/src/lib.rs.
.plugin(tauri_plugin_ptt::init())
.invoke_handler(tauri::generate_handler![app_quit])
.setup(|app| {
if let Some(main) = app.get_webview_window("main") {
let _ = main.show();
}
Ok(())
})
.run(tauri::generate_context!())
.expect("error while running mobile tauri application");
}
+3
View File
@@ -0,0 +1,3 @@
fn main() {
openhuman_mobile::run();
}
+41
View File
@@ -0,0 +1,41 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "OpenHuman",
"version": "0.54.10",
"identifier": "com.openhuman.app",
"build": {
"beforeDevCommand": "pnpm --filter openhuman-app run dev",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "pnpm --filter openhuman-app run build:app",
"frontendDist": "../dist"
},
"app": {
"windows": [
{
"label": "main",
"title": "OpenHuman",
"width": 390,
"height": 844,
"decorations": true,
"resizable": false
}
],
"security": {
"csp": "default-src 'self' 'unsafe-inline' data: blob: https: wss: ipc: http://ipc.localhost http://127.0.0.1:* http://localhost:*; img-src 'self' data: blob: https:; connect-src 'self' ipc: http://ipc.localhost http://127.0.0.1:* http://localhost:* http: ws://127.0.0.1:* ws://localhost:* ws: https: wss: data: blob:; frame-src 'self' https: data: blob:"
}
},
"bundle": {
"active": true,
"targets": ["app"],
"icon": ["icons/icon.png"],
"resources": [],
"iOS": {
"minimumSystemVersion": "16.0",
"frameworks": ["AVFoundation.framework", "Speech.framework"],
"developmentTeam": ""
},
"android": {
"minSdkVersion": 24
}
}
}
+27
View File
@@ -5314,6 +5314,7 @@ dependencies = [
"walkdir",
"webpki-roots 1.0.7",
"whisper-rs",
"x25519-dalek",
"xz2",
"zip 2.4.2",
]
@@ -10665,6 +10666,18 @@ version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
[[package]]
name = "x25519-dalek"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277"
dependencies = [
"curve25519-dalek",
"rand_core 0.6.4",
"serde",
"zeroize",
]
[[package]]
name = "xattr"
version = "1.6.1"
@@ -10837,6 +10850,20 @@ name = "zeroize"
version = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
dependencies = [
"zeroize_derive",
]
[[package]]
name = "zeroize_derive"
version = "1.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "zerotrie"
+4 -2
View File
@@ -1,5 +1,7 @@
// Desktop targets: Windows, macOS, Linux. iOS + Android live in
// `app/src-tauri-mobile/`.
#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
compile_error!("src-tauri host is desktop-only. Non-desktop targets are not supported.");
compile_error!("src-tauri host supports desktop (Windows/macOS/Linux) only. Mobile lives in app/src-tauri-mobile.");
mod cdp;
#[cfg(any(target_os = "macos", target_os = "linux"))]
@@ -3370,7 +3372,7 @@ pub fn run_core_from_args(args: &[String]) -> Result<(), String> {
}
// ---------------------------------------------------------------------------
// Sentry release / environment resolution (Tauri shell)
// Sentry release / environment resolution (Tauri shell — desktop only)
// ---------------------------------------------------------------------------
/// Canonical release tag: `openhuman@<version>[+<short_sha>]`.
+1
View File
@@ -6,6 +6,7 @@
// console at runtime via AttachConsole, so command-line output still works.
#![cfg_attr(target_os = "windows", windows_subsystem = "windows")]
// ── Desktop (CEF) entry point ─────────────────────────────────────────────────
// On the CEF runtime, the main binary is re-exec'd as the renderer / GPU /
// utility helper subprocesses. The `cef_entry_point` macro short-circuits
// main() when CEF has passed `--type=<role>` in argv, routing straight into
+42 -6
View File
@@ -24,6 +24,7 @@ import {
startNativeNotificationsService,
stopNativeNotificationsService,
} from './lib/nativeNotifications';
import { getIsMobile } from './lib/platform';
import {
startWebviewNotificationsService,
stopWebviewNotificationsService,
@@ -51,6 +52,8 @@ import { DEV_FORCE_ONBOARDING } from './utils/config';
// events (Google Meet captions → transcript flush, WhatsApp ingest, …)
// are handled even when the user hasn't navigated to /accounts yet.
// Idempotent — the service uses a `started` singleton guard.
// On iOS these services are no-ops (isTauri() webview guard inside each),
// but we call them unconditionally to keep the boot path consistent.
startWebviewAccountService();
startWebviewNotificationsService();
startNativeNotificationsService();
@@ -72,6 +75,17 @@ if (import.meta.hot) {
}
function App() {
const onMobile = getIsMobile();
// On mobile (iOS or Android) the SocketProvider would try to connect to the
// local core HTTP socket, which does not exist on device (the core runs on
// the remote desktop). Gate it out to prevent spurious connection errors —
// chat events arrive through TunnelTransport's socket.io relay instead.
// NOTE: useHumanMascot's subscribeChatEvents() still returns a no-op unsub
// when the socket is absent — mascot state falls back to 'idle'.
const socketWrapped = (children: React.ReactNode) =>
onMobile ? <>{children}</> : <SocketProvider>{children}</SocketProvider>;
return (
<Sentry.ErrorBoundary
fallback={({ error, componentStack, resetError }) => (
@@ -83,20 +97,20 @@ function App() {
<I18nProvider>
<BootCheckGate>
<CoreStateProvider>
<SocketProvider>
{socketWrapped(
<ChatRuntimeProvider>
<Router>
<CommandProvider>
<ServiceBlockingGate>
<AppShell />
<DictationHotkeyManager />
<LocalAIDownloadSnackbar />
<AppUpdatePrompt />
{!onMobile && <DictationHotkeyManager />}
{!onMobile && <LocalAIDownloadSnackbar />}
{!onMobile && <AppUpdatePrompt />}
</ServiceBlockingGate>
</CommandProvider>
</Router>
</ChatRuntimeProvider>
</SocketProvider>
)}
</CoreStateProvider>
</BootCheckGate>
</I18nProvider>
@@ -107,8 +121,30 @@ function App() {
);
}
/** Inner shell — lives inside the Router so it can use useLocation. */
/** Minimal mobile shell — renders routes only, no desktop chrome. */
function AppShellMobile() {
return (
<div className="relative h-screen flex flex-col overflow-hidden bg-[#0f1117]">
<AppRoutes />
</div>
);
}
/**
* Top-level shell router — chooses mobile or desktop shell at render time.
* Must NOT call hooks before the branch because each sub-component has its
* own hook calls that obey the rules-of-hooks within their own scope.
*/
function AppShell() {
const onMobile = getIsMobile();
if (onMobile) {
return <AppShellMobile />;
}
return <AppShellDesktop />;
}
/** Desktop inner shell — lives inside the Router so it can use useLocation. */
function AppShellDesktop() {
const location = useLocation();
const navigate = useNavigate();
const { snapshot, isBootstrapping } = useCoreState();
+8
View File
@@ -1,9 +1,11 @@
import { Navigate, Route, Routes } from 'react-router-dom';
import AppRoutesIOS from './AppRoutesIOS';
import DefaultRedirect from './components/DefaultRedirect';
import ProtectedRoute from './components/ProtectedRoute';
import PublicRoute from './components/PublicRoute';
import HumanPage from './features/human/HumanPage';
import { getIsMobile } from './lib/platform';
import Accounts from './pages/Accounts';
import Channels from './pages/Channels';
import Home from './pages/Home';
@@ -17,6 +19,12 @@ import Skills from './pages/Skills';
import Welcome from './pages/Welcome';
const AppRoutes = () => {
// Mobile target (iOS or Android): pair → Human/Chat/Settings only.
// Desktop routes are not rendered.
if (getIsMobile()) {
return <AppRoutesIOS />;
}
return (
<Routes>
{/* Public routes - redirect to /home if logged in */}
+83
View File
@@ -0,0 +1,83 @@
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// Stub out the surfaces the mobile shell routes to so we can mount
// `<AppRoutesIOS />` without dragging the full Redux + provider tree along.
vi.mock('./features/human/HumanPage', () => ({
default: () => <div data-testid="page-human">human</div>,
}));
vi.mock('./pages/Accounts', () => ({ default: () => <div data-testid="page-chat">chat</div> }));
vi.mock('./pages/Settings', () => ({
default: () => <div data-testid="page-settings">settings</div>,
}));
vi.mock('./pages/ios/PairScreen', () => ({
PairScreen: () => <div data-testid="page-pair">pair</div>,
}));
vi.mock('./components/ios/MobileTabBar', () => ({
default: () => <nav data-testid="mobile-tab-bar">tabs</nav>,
}));
const listProfiles = vi.fn();
vi.mock('./services/transport/profileStore', () => ({ listProfiles: () => listProfiles() }));
const AppRoutesIOS = (await import('./AppRoutesIOS')).default;
const renderAt = (path: string) =>
render(
<MemoryRouter initialEntries={[path]}>
<AppRoutesIOS />
</MemoryRouter>
);
describe('AppRoutesIOS', () => {
beforeEach(() => listProfiles.mockReset());
afterEach(() => vi.clearAllMocks());
describe('unpaired (no saved profile)', () => {
beforeEach(() => listProfiles.mockReturnValue([]));
it('redirects unknown paths to /pair', () => {
renderAt('/');
expect(screen.getByTestId('page-pair')).toBeInTheDocument();
});
it('renders the PairScreen at /pair', () => {
renderAt('/pair');
expect(screen.getByTestId('page-pair')).toBeInTheDocument();
});
it('bounces /human back to /pair when no profile exists', () => {
renderAt('/human');
expect(screen.getByTestId('page-pair')).toBeInTheDocument();
expect(screen.queryByTestId('page-human')).not.toBeInTheDocument();
});
});
describe('paired (profile exists)', () => {
beforeEach(() => listProfiles.mockReturnValue([{ id: 'p1' }]));
it('renders HumanPage with the mobile tab bar', () => {
renderAt('/human');
expect(screen.getByTestId('page-human')).toBeInTheDocument();
expect(screen.getByTestId('mobile-tab-bar')).toBeInTheDocument();
});
it('renders the chat surface at /chat', () => {
renderAt('/chat');
expect(screen.getByTestId('page-chat')).toBeInTheDocument();
expect(screen.getByTestId('mobile-tab-bar')).toBeInTheDocument();
});
it('renders Settings at /settings/devices via nested route', () => {
renderAt('/settings/devices');
expect(screen.getByTestId('page-settings')).toBeInTheDocument();
expect(screen.getByTestId('mobile-tab-bar')).toBeInTheDocument();
});
it('redirects unknown paths to /human when paired', () => {
renderAt('/');
expect(screen.getByTestId('page-human')).toBeInTheDocument();
});
});
});
+90
View File
@@ -0,0 +1,90 @@
/**
* AppRoutesIOS — routes for the iOS + Android app targets.
*
* The filename is iOS-historic; the routes apply to every mobile target.
*
* Two phases:
* 1. Unpaired — /pair only. QR scan binds the phone to a desktop core,
* writes a profile to profileStore, then redirects to /human.
* 2. Paired — /human, /chat, /settings/* are reachable. A mobile tab bar
* sits at the bottom of the viewport. Any unknown path falls back to
* /human. The existing desktop screens (HumanPage, Accounts, Settings)
* are reused as-is; they call core RPC through the TransportManager
* bound to the saved profile.
*/
import debug from 'debug';
import { type FC } from 'react';
import { Navigate, Route, Routes } from 'react-router-dom';
import MobileTabBar from './components/ios/MobileTabBar';
import HumanPage from './features/human/HumanPage';
import Accounts from './pages/Accounts';
import { PairScreen } from './pages/ios/PairScreen';
import Settings from './pages/Settings';
import { listProfiles } from './services/transport/profileStore';
const log = debug('mobile:routes');
const isPaired = (): boolean => listProfiles().length > 0;
const IOSDefaultRedirect: FC = () => {
const paired = isPaired();
log('[mobile] default redirect paired=%s', paired);
return <Navigate to={paired ? '/human' : '/pair'} replace />;
};
/** Wraps a paired-state route with the mobile tab bar. */
const MobileShell: FC<{ children: React.ReactNode }> = ({ children }) => (
<div className="relative h-screen flex flex-col overflow-hidden">
<div className="flex-1 overflow-hidden">{children}</div>
<MobileTabBar />
</div>
);
/** Bounces to /pair when no profile exists; otherwise renders children. */
const RequirePairing: FC<{ children: React.ReactNode }> = ({ children }) => {
if (!isPaired()) {
log('[mobile] no pairing — redirecting to /pair');
return <Navigate to="/pair" replace />;
}
return <MobileShell>{children}</MobileShell>;
};
const AppRoutesIOS: FC = () => {
return (
<Routes>
{/* Unpaired entry — QR scan handshake. */}
<Route path="/pair" element={<PairScreen />} />
{/* Surfaced pages on iOS: Human, Chat, Settings. */}
<Route
path="/human"
element={
<RequirePairing>
<HumanPage />
</RequirePairing>
}
/>
<Route
path="/chat"
element={
<RequirePairing>
<Accounts />
</RequirePairing>
}
/>
<Route
path="/settings/*"
element={
<RequirePairing>
<Settings />
</RequirePairing>
}
/>
<Route path="*" element={<IOSDefaultRedirect />} />
</Routes>
);
};
export default AppRoutesIOS;
@@ -0,0 +1,52 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router-dom';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import MobileTabBar from './MobileTabBar';
const navigate = vi.fn();
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual<typeof import('react-router-dom')>('react-router-dom');
return { ...actual, useNavigate: () => navigate };
});
const renderAt = (path: string) =>
render(
<MemoryRouter initialEntries={[path]}>
<MobileTabBar />
</MemoryRouter>
);
describe('MobileTabBar', () => {
beforeEach(() => navigate.mockReset());
it('renders Human, Chat, Settings tabs', () => {
renderAt('/human');
expect(screen.getByRole('button', { name: 'Human' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Chat' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Settings' })).toBeInTheDocument();
});
it('marks the active tab with aria-current=page', () => {
renderAt('/chat');
expect(screen.getByRole('button', { name: 'Chat' })).toHaveAttribute('aria-current', 'page');
expect(screen.getByRole('button', { name: 'Human' })).not.toHaveAttribute('aria-current');
});
it('treats a deeper /settings/* path as the settings tab being active', () => {
renderAt('/settings/devices');
expect(screen.getByRole('button', { name: 'Settings' })).toHaveAttribute(
'aria-current',
'page'
);
});
it('navigates when a tab is clicked', async () => {
renderAt('/human');
await userEvent.click(screen.getByRole('button', { name: 'Chat' }));
expect(navigate).toHaveBeenCalledWith('/chat');
await userEvent.click(screen.getByRole('button', { name: 'Settings' }));
expect(navigate).toHaveBeenLastCalledWith('/settings');
});
});
+105
View File
@@ -0,0 +1,105 @@
/**
* MobileTabBar — bottom tab navigation for the iOS app.
*
* Surfaces the three routes that ship on iOS: Human, Chat, Settings.
* Sits at the bottom of the viewport with a thumb-reachable safe-area
* inset so it clears the iPhone home indicator.
*/
import type { ReactNode } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
interface Tab {
id: string;
label: string;
path: string;
icon: ReactNode;
}
const tabs: Tab[] = [
{
id: 'human',
label: 'Human',
path: '/human',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.8}
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14c-4 0-7 2.5-7 6h14c0-3.5-3-6-7-6z"
/>
</svg>
),
},
{
id: 'chat',
label: 'Chat',
path: '/chat',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.8}
d="M8 10h.01M12 10h.01M16 10h.01M21 12c0 4.418-4.03 8-9 8a9.86 9.86 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
/>
</svg>
),
},
{
id: 'settings',
label: 'Settings',
path: '/settings',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.8}
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.8}
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
),
},
];
const MobileTabBar = () => {
const location = useLocation();
const navigate = useNavigate();
const isActive = (path: string) =>
location.pathname === path || location.pathname.startsWith(`${path}/`);
return (
<nav
className="flex-shrink-0 flex justify-around items-stretch border-t border-neutral-800 bg-[#0f1117]/95 backdrop-blur-md"
style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}
aria-label="Mobile navigation">
{tabs.map(tab => {
const active = isActive(tab.path);
return (
<button
key={tab.id}
type="button"
onClick={() => navigate(tab.path)}
className={`flex flex-col items-center justify-center gap-1 flex-1 py-2 transition-colors ${
active ? 'text-white' : 'text-neutral-400'
}`}
aria-current={active ? 'page' : undefined}
aria-label={tab.label}>
{tab.icon}
<span className="text-[11px] font-medium">{tab.label}</span>
</button>
);
})}
</nav>
);
};
export default MobileTabBar;
@@ -110,6 +110,22 @@ const SettingsHome = () => {
),
onClick: () => navigateToSettings('notifications'),
},
{
id: 'devices',
title: 'Devices',
description: 'Pair iOS phones with this OpenHuman',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z"
/>
</svg>
),
onClick: () => navigateToSettings('devices'),
},
{
id: 'language',
title: t('settings.language'),
@@ -38,7 +38,8 @@ export type SettingsRoute =
| 'webhooks-triggers'
| 'composio-triggers'
| 'composio-routing'
| 'mcp-server';
| 'mcp-server'
| 'devices';
export interface BreadcrumbItem {
label: string;
@@ -114,6 +115,7 @@ export const useSettingsNavigation = (): SettingsNavigationHook => {
// shorter `notifications` prefix.
if (path.includes('/settings/notification-routing')) return 'notification-routing';
if (path.includes('/settings/notifications')) return 'notifications';
if (path.includes('/settings/devices')) return 'devices';
if (path.includes('/settings/mascot')) return 'mascot';
if (path.includes('/settings/appearance')) return 'appearance';
if (path.includes('/settings/mcp-server')) return 'mcp-server';
@@ -235,6 +237,9 @@ export const useSettingsNavigation = (): SettingsNavigationHook => {
case 'notifications':
return [settingsCrumb];
case 'devices':
return [settingsCrumb];
// Mascot appearance panel sits at the top level of Settings.
case 'mascot':
return [settingsCrumb];
@@ -0,0 +1,358 @@
import createDebug from 'debug';
import { useCallback, useEffect, useRef, useState } from 'react';
import { callCoreRpc } from '../../../services/coreRpcClient';
import type { ToastNotification } from '../../../types/intelligence';
import { ToastContainer } from '../../intelligence/Toast';
import SettingsHeader from '../components/SettingsHeader';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
import PairPhoneModal from './devices/PairPhoneModal';
const log = createDebug('app:devices-ui');
// ---------------------------------------------------------------------------
// Types (mirror the Rust types.rs)
// ---------------------------------------------------------------------------
export interface PairedDevice {
channel_id: string;
label: string;
device_pubkey: string;
created_at: string;
last_seen_at: string | null;
peer_online: boolean | null;
revoked: boolean;
}
interface ListDevicesResponse {
devices: PairedDevice[];
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function truncateId(id: string): string {
if (id.length <= 10) return id;
return `${id.slice(0, 4)}${id.slice(-4)}`;
}
function relativeTime(iso: string | null): string {
if (!iso) return 'Never';
const delta = Date.now() - new Date(iso).getTime();
const minutes = Math.floor(delta / 60_000);
if (minutes < 1) return 'Just now';
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
return `${Math.floor(hours / 24)}d ago`;
}
// ---------------------------------------------------------------------------
// Sub-components
// ---------------------------------------------------------------------------
function PeerDot({ online }: { online: boolean | null }) {
const isOnline = online === true;
return (
<span
title={isOnline ? 'Online' : 'Offline'}
className={`inline-block w-2 h-2 rounded-full flex-shrink-0 ${isOnline ? 'bg-sage-500' : 'bg-stone-300'}`}
/>
);
}
function DeviceRow({
device,
onRevoke,
isFirst,
isLast,
}: {
device: PairedDevice;
onRevoke: (device: PairedDevice) => void;
isFirst: boolean;
isLast: boolean;
}) {
return (
<div
className={`flex items-center gap-3 px-4 py-3 bg-white border-b ${isLast ? 'border-b-0' : 'border-stone-100'} ${isFirst ? 'rounded-t-lg' : ''} ${isLast ? 'rounded-b-lg' : ''}`}>
<PeerDot online={device.peer_online} />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-stone-900 truncate">{device.label}</p>
<p className="text-xs text-stone-400 font-mono">{truncateId(device.channel_id)}</p>
<p className="text-xs text-stone-400">{relativeTime(device.last_seen_at)}</p>
</div>
<button
onClick={() => onRevoke(device)}
className="text-xs text-coral-600 hover:text-coral-700 transition-colors flex-shrink-0 px-2 py-1 rounded hover:bg-coral-50"
aria-label={`Revoke ${device.label}`}>
Revoke
</button>
</div>
);
}
function ConfirmRevokeDialog({
device,
onConfirm,
onCancel,
}: {
device: PairedDevice;
onConfirm: () => void;
onCancel: () => void;
}) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/30">
<div className="bg-white rounded-2xl max-w-sm w-full p-6 border border-stone-200 shadow-large">
<h3 className="text-base font-semibold text-stone-900 mb-2">Revoke device?</h3>
<p className="text-sm text-stone-600 mb-5">
<span className="font-medium">{device.label}</span> will no longer be able to connect.
This cannot be undone.
</p>
<div className="flex gap-3">
<button
onClick={onCancel}
className="flex-1 px-4 py-2 rounded-lg border border-stone-200 text-stone-700 hover:bg-stone-50 transition-colors text-sm">
Cancel
</button>
<button
onClick={onConfirm}
className="flex-1 px-4 py-2 rounded-lg bg-coral-500 hover:bg-coral-600 text-white transition-colors text-sm">
Revoke
</button>
</div>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Main panel
// ---------------------------------------------------------------------------
const DevicesPanel = () => {
const { navigateBack, breadcrumbs } = useSettingsNavigation();
const [devices, setDevices] = useState<PairedDevice[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [revokeTarget, setRevokeTarget] = useState<PairedDevice | null>(null);
const [revoking, setRevoking] = useState(false);
const [showPairModal, setShowPairModal] = useState(false);
const [toasts, setToasts] = useState<ToastNotification[]>([]);
const addToast = useCallback((toast: Omit<ToastNotification, 'id'>) => {
const newToast: ToastNotification = { ...toast, id: `toast-${Date.now()}-${Math.random()}` };
setToasts(prev => [...prev, newToast]);
}, []);
const removeToast = useCallback((id: string) => {
setToasts(prev => prev.filter(t => t.id !== id));
}, []);
// Import callCoreRpc lazily via module-level reference to avoid circular deps.
const loadDevices = useCallback(async () => {
log('[devices-ui] loadDevices start');
setError(null);
try {
const res = await callCoreRpc<ListDevicesResponse>({
method: 'openhuman.devices_list',
params: {},
});
const active = res.devices.filter(d => !d.revoked);
log('[devices-ui] loadDevices got %d device(s)', active.length);
setDevices(active);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
log('[devices-ui] loadDevices error: %s', msg);
setError(`Failed to load devices: ${msg}`);
} finally {
setLoading(false);
}
}, []);
// intervalRef keeps the poll alive when the pair modal is open.
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const startPolling = useCallback(() => {
if (pollRef.current) return;
pollRef.current = setInterval(() => {
void loadDevices();
}, 2_000);
log('[devices-ui] started 2s poll for device updates');
}, [loadDevices]);
const stopPolling = useCallback(() => {
if (pollRef.current) {
clearInterval(pollRef.current);
pollRef.current = null;
log('[devices-ui] stopped poll');
}
}, []);
useEffect(() => {
void loadDevices();
return stopPolling;
}, [loadDevices, stopPolling]);
const handleOpenPairModal = () => {
log('[devices-ui] opening pair modal');
setShowPairModal(true);
startPolling();
};
const handleClosePairModal = () => {
log('[devices-ui] closing pair modal');
setShowPairModal(false);
stopPolling();
void loadDevices();
};
const handlePaired = (channelId: string) => {
log('[devices-ui] DevicePaired event channelId=%s', channelId);
addToast({
type: 'success',
title: 'Device paired',
message: 'iPhone connected successfully.',
});
stopPolling();
setShowPairModal(false);
void loadDevices();
};
const confirmRevoke = async () => {
if (!revokeTarget) return;
const target = revokeTarget;
setRevoking(true);
log('[devices-ui] revoking channel_id=%s', target.channel_id);
try {
await callCoreRpc({
method: 'openhuman.devices_revoke',
params: { channel_id: target.channel_id },
});
log('[devices-ui] revoke ok channel_id=%s', target.channel_id);
addToast({ type: 'success', title: 'Device revoked', message: `${target.label} removed.` });
setRevokeTarget(null);
await loadDevices();
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
log('[devices-ui] revoke error: %s', msg);
addToast({ type: 'error', title: 'Revoke failed', message: msg });
} finally {
setRevoking(false);
}
};
return (
<div className="z-10 relative">
<div className="flex items-center justify-between px-5 pt-5 pb-3">
<SettingsHeader
title="Devices"
showBackButton={breadcrumbs.length > 0}
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
<button
onClick={handleOpenPairModal}
className="text-xs font-medium text-white bg-primary-500 hover:bg-primary-600 transition-colors px-3 py-1.5 rounded-lg flex-shrink-0">
Pair iPhone
</button>
</div>
<p className="px-5 pb-3 text-xs text-stone-500">
Pair iOS phones with this OpenHuman to use them as a remote client.
</p>
<div className="px-5 pb-5">
{loading && (
<div className="flex items-center justify-center py-12">
<svg className="w-5 h-5 animate-spin text-stone-400" fill="none" viewBox="0 0 24 24">
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
</div>
)}
{!loading && error && (
<div className="rounded-lg bg-coral-50 border border-coral-200 px-4 py-3 text-sm text-coral-700">
{error}
</div>
)}
{!loading && !error && devices.length === 0 && (
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="w-12 h-12 rounded-xl bg-primary-50 flex items-center justify-center mb-3">
<svg
className="w-6 h-6 text-primary-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z"
/>
</svg>
</div>
<p className="text-sm font-medium text-stone-700 mb-1">No paired devices</p>
<p className="text-xs text-stone-400 mb-4 max-w-xs">
Scan a QR code on your iPhone to connect it to this OpenHuman session.
</p>
<button
onClick={handleOpenPairModal}
className="px-4 py-2 text-sm font-medium text-white bg-primary-500 hover:bg-primary-600 transition-colors rounded-lg">
Pair iPhone
</button>
</div>
)}
{!loading && !error && devices.length > 0 && (
<div className="rounded-xl border border-stone-200 overflow-hidden">
{devices.map((device, idx) => (
<DeviceRow
key={device.channel_id}
device={device}
onRevoke={d => {
log('[devices-ui] revoke requested channel_id=%s', d.channel_id);
setRevokeTarget(d);
}}
isFirst={idx === 0}
isLast={idx === devices.length - 1}
/>
))}
</div>
)}
</div>
{revokeTarget && (
<ConfirmRevokeDialog
device={revokeTarget}
onConfirm={() => {
void confirmRevoke();
}}
onCancel={() => {
if (!revoking) setRevokeTarget(null);
}}
/>
)}
{showPairModal && <PairPhoneModal onClose={handleClosePairModal} onPaired={handlePaired} />}
<ToastContainer notifications={toasts} onRemove={removeToast} />
</div>
);
};
export default DevicesPanel;
@@ -0,0 +1,157 @@
import { fireEvent, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { callCoreRpc } from '../../../../services/coreRpcClient';
import { renderWithProviders } from '../../../../test/test-utils';
import DevicesPanel from '../DevicesPanel';
// ---------------------------------------------------------------------------
// Mocks
// ---------------------------------------------------------------------------
vi.mock('../../../../services/coreRpcClient', () => ({ callCoreRpc: vi.fn() }));
// qrcode.react is not needed in panel tests.
vi.mock('../devices/PairPhoneModal', () => ({
default: ({ onClose, onPaired }: { onClose: () => void; onPaired: (id: string) => void }) => (
<div data-testid="pair-modal">
<button onClick={onClose}>close-modal</button>
<button onClick={() => onPaired('CHAN123')}>simulate-paired</button>
</div>
),
}));
const mockCall = vi.mocked(callCoreRpc);
function makeDevice(overrides = {}) {
return {
channel_id: 'CHAN_AAABBBCCC',
label: "Alice's iPhone",
device_pubkey: 'pubkey_base64url',
created_at: new Date().toISOString(),
last_seen_at: null,
peer_online: false,
revoked: false,
...overrides,
};
}
function listResponse(devices: ReturnType<typeof makeDevice>[]) {
return { devices };
}
describe('DevicesPanel', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('shows empty state when no devices are paired', async () => {
mockCall.mockResolvedValue(listResponse([]));
renderWithProviders(<DevicesPanel />, { initialEntries: ['/settings/devices'] });
expect(await screen.findByText('No paired devices')).toBeInTheDocument();
// Two "Pair iPhone" buttons exist: header + empty-state CTA.
expect(screen.getAllByRole('button', { name: /Pair iPhone/i })).toHaveLength(2);
});
it('renders a paired device row with label, truncated id, and revoke button', async () => {
const device = makeDevice({ channel_id: 'ABCDEFGHIJ12345678', label: "Bob's iPhone" });
mockCall.mockResolvedValue(listResponse([device]));
renderWithProviders(<DevicesPanel />, { initialEntries: ['/settings/devices'] });
expect(await screen.findByText("Bob's iPhone")).toBeInTheDocument();
// Truncated: first 4 + last 4 chars
expect(screen.getByText('ABCD…5678')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Revoke/i })).toBeInTheDocument();
});
it('filters out revoked devices', async () => {
const devices = [
makeDevice({ label: 'Active', revoked: false }),
makeDevice({ channel_id: 'REVOKED_CHAN', label: 'Revoked', revoked: true }),
];
mockCall.mockResolvedValue(listResponse(devices));
renderWithProviders(<DevicesPanel />, { initialEntries: ['/settings/devices'] });
expect(await screen.findByText('Active')).toBeInTheDocument();
expect(screen.queryByText('Revoked')).not.toBeInTheDocument();
});
it('shows a confirm dialog on revoke click, then calls devices_revoke on confirm', async () => {
const device = makeDevice({ label: "Charlie's iPhone", channel_id: 'CHAN_CHARLIE' });
// First call: list. Second call: revoke. Third call: refresh after revoke.
mockCall
.mockResolvedValueOnce(listResponse([device]))
.mockResolvedValueOnce({ success: true })
.mockResolvedValueOnce(listResponse([]));
renderWithProviders(<DevicesPanel />, { initialEntries: ['/settings/devices'] });
await screen.findByText("Charlie's iPhone");
fireEvent.click(screen.getByRole('button', { name: /Revoke/i }));
// Confirmation dialog
expect(await screen.findByText('Revoke device?')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /^Revoke$/i }));
await waitFor(() => {
expect(mockCall).toHaveBeenCalledWith(
expect.objectContaining({ method: 'openhuman.devices_revoke' })
);
});
// After revoke the list should be refreshed (empty state)
expect(await screen.findByText('No paired devices')).toBeInTheDocument();
});
it('cancels revoke when the cancel button is pressed', async () => {
const device = makeDevice({ label: "Dave's iPhone" });
mockCall.mockResolvedValue(listResponse([device]));
renderWithProviders(<DevicesPanel />, { initialEntries: ['/settings/devices'] });
await screen.findByText("Dave's iPhone");
fireEvent.click(screen.getByRole('button', { name: /Revoke/i }));
expect(screen.getByText('Revoke device?')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /Cancel/i }));
await waitFor(() => {
expect(screen.queryByText('Revoke device?')).not.toBeInTheDocument();
});
// No revoke call made
expect(mockCall).toHaveBeenCalledTimes(1);
});
it('opens the pair modal when Pair iPhone is clicked', async () => {
mockCall.mockResolvedValue(listResponse([]));
renderWithProviders(<DevicesPanel />, { initialEntries: ['/settings/devices'] });
await screen.findByText('No paired devices');
// Click the header-level button (first one).
fireEvent.click(screen.getAllByRole('button', { name: /Pair iPhone/i })[0]);
expect(await screen.findByTestId('pair-modal')).toBeInTheDocument();
});
it('closes the pair modal and reloads devices after pairing', async () => {
const device = makeDevice({ label: 'New iPhone' });
mockCall.mockResolvedValueOnce(listResponse([])).mockResolvedValueOnce(listResponse([device]));
renderWithProviders(<DevicesPanel />, { initialEntries: ['/settings/devices'] });
await screen.findByText('No paired devices');
fireEvent.click(screen.getAllByRole('button', { name: /Pair iPhone/i })[0]);
await screen.findByTestId('pair-modal');
fireEvent.click(screen.getByText('simulate-paired'));
await waitFor(() => {
expect(screen.queryByTestId('pair-modal')).not.toBeInTheDocument();
});
});
it('shows an error message when devices_list fails', async () => {
mockCall.mockRejectedValue(new Error('Core offline'));
renderWithProviders(<DevicesPanel />, { initialEntries: ['/settings/devices'] });
expect(await screen.findByText(/Failed to load devices/)).toBeInTheDocument();
});
});
@@ -0,0 +1,289 @@
/**
* Tests for PairPhoneModal.
*
* Timer strategy: most tests use real timers + mocked callCoreRpc.
* Tests that validate timer-driven state (expiry, poll, auto-close) use
* vi.useFakeTimers scoped per-test and flush promises with act()+Promise.resolve().
*/
import { act, fireEvent, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { callCoreRpc } from '../../../../services/coreRpcClient';
import { renderWithProviders } from '../../../../test/test-utils';
import PairPhoneModal from './PairPhoneModal';
// ---------------------------------------------------------------------------
// Mocks
// ---------------------------------------------------------------------------
vi.mock('../../../../services/coreRpcClient', () => ({ callCoreRpc: vi.fn() }));
vi.mock('qrcode.react', () => ({
QRCodeSVG: ({ value }: { value: string }) => <div data-testid="qr-code" data-value={value} />,
}));
const mockCall = vi.mocked(callCoreRpc);
const CHANNEL_ID = 'ABCDEFGHIJ1234567890AB';
const PAIRING_TOKEN = 'tok_abc123';
const CORE_PUBKEY = 'pubkey_base64url_value';
function makePairingSession(overrides = {}) {
return {
channel_id: CHANNEL_ID,
pairing_token: PAIRING_TOKEN,
core_pubkey: CORE_PUBKEY,
rpc_url: null,
expires_at: new Date(Date.now() + 600_000).toISOString(),
...overrides,
};
}
function makeDevice(overrides = {}) {
return {
channel_id: CHANNEL_ID,
label: "Alice's iPhone",
peer_online: true,
revoked: false,
...overrides,
};
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function setupRealTimers() {
vi.useRealTimers();
}
function setupFakeTimers() {
vi.useFakeTimers({ shouldAdvanceTime: false });
}
/** Advance fake timers + flush promise microtasks. */
async function advanceAndFlush(ms: number) {
await act(async () => {
await vi.advanceTimersByTimeAsync(ms);
});
}
const onClose = vi.fn();
const onPaired = vi.fn();
describe('PairPhoneModal', () => {
beforeEach(() => {
vi.clearAllMocks();
setupRealTimers();
});
afterEach(() => {
vi.useRealTimers();
});
// ---------------------------------------------------------------------------
// QR render + URL validation (no timer tricks needed)
// ---------------------------------------------------------------------------
it('shows loading then renders a QR code after create_pairing resolves', async () => {
mockCall.mockImplementation(async ({ method }: { method: string }) => {
if (method === 'openhuman.devices_create_pairing') return makePairingSession();
return { devices: [] };
});
renderWithProviders(<PairPhoneModal onClose={onClose} onPaired={onPaired} />, {
initialEntries: ['/settings/devices'],
});
expect(screen.getByText(/Generating pairing code/i)).toBeInTheDocument();
expect(await screen.findByTestId('qr-code')).toBeInTheDocument();
});
it('QR code value contains all required URL params', async () => {
const session = makePairingSession({ rpc_url: 'http://192.168.1.5:7788/rpc' });
mockCall.mockImplementation(async ({ method }: { method: string }) => {
if (method === 'openhuman.devices_create_pairing') return session;
return { devices: [] };
});
renderWithProviders(<PairPhoneModal onClose={onClose} onPaired={onPaired} />, {
initialEntries: ['/settings/devices'],
});
const qr = await screen.findByTestId('qr-code');
const value = qr.getAttribute('data-value') ?? '';
const url = new URL(value);
expect(url.protocol).toBe('openhuman:');
expect(url.searchParams.get('cid')).toBe(CHANNEL_ID);
expect(url.searchParams.get('pt')).toBe(PAIRING_TOKEN);
expect(url.searchParams.get('cpk')).toBe(CORE_PUBKEY);
expect(url.searchParams.get('rpc')).toBe('http://192.168.1.5:7788/rpc');
expect(url.searchParams.get('exp')).toBeTruthy();
});
// ---------------------------------------------------------------------------
// Poll-based pairing detection
// ---------------------------------------------------------------------------
it('transitions to success state when device appears on poll', async () => {
setupFakeTimers();
mockCall.mockImplementation(async ({ method }: { method: string }) => {
if (method === 'openhuman.devices_create_pairing') return makePairingSession();
return { devices: [makeDevice()] };
});
renderWithProviders(<PairPhoneModal onClose={onClose} onPaired={onPaired} />, {
initialEntries: ['/settings/devices'],
});
// Flush the create_pairing promise so the QR renders.
await advanceAndFlush(0);
// Advance past the 2s poll interval and flush the list call.
await advanceAndFlush(2_100);
expect(screen.getByText(/Paired with iPhone/i)).toBeInTheDocument();
expect(screen.getByText("Alice's iPhone")).toBeInTheDocument();
});
it('calls onPaired after 3 s auto-close on success', async () => {
setupFakeTimers();
mockCall.mockImplementation(async ({ method }: { method: string }) => {
if (method === 'openhuman.devices_create_pairing') return makePairingSession();
return { devices: [makeDevice()] };
});
renderWithProviders(<PairPhoneModal onClose={onClose} onPaired={onPaired} />, {
initialEntries: ['/settings/devices'],
});
// create_pairing + 2s poll.
await advanceAndFlush(0);
await advanceAndFlush(2_100);
expect(screen.getByText(/Paired with iPhone/i)).toBeInTheDocument();
// 3 s auto-close timer.
await advanceAndFlush(3_100);
expect(onPaired).toHaveBeenCalledWith(CHANNEL_ID);
});
// ---------------------------------------------------------------------------
// Expiry
// ---------------------------------------------------------------------------
it('shows QR expired when the session deadline passes', async () => {
setupFakeTimers();
const session = makePairingSession({ expires_at: new Date(Date.now() + 50).toISOString() });
mockCall.mockImplementation(async ({ method }: { method: string }) => {
if (method === 'openhuman.devices_create_pairing') return session;
return { devices: [] };
});
renderWithProviders(<PairPhoneModal onClose={onClose} onPaired={onPaired} />, {
initialEntries: ['/settings/devices'],
});
await advanceAndFlush(0);
expect(screen.getByTestId('qr-code')).toBeInTheDocument();
// Advance past the 50 ms expiry.
await advanceAndFlush(200);
expect(screen.getByText(/QR code expired/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Generate new code/i })).toBeInTheDocument();
});
it('re-issues create_pairing when "Generate new code" is clicked', async () => {
setupFakeTimers();
const expiredSession = makePairingSession({
expires_at: new Date(Date.now() + 50).toISOString(),
});
const freshSession = makePairingSession({ channel_id: 'NEW_CHANNEL_XYZ' });
let createCount = 0;
mockCall.mockImplementation(async ({ method }: { method: string }) => {
if (method === 'openhuman.devices_create_pairing') {
return createCount++ === 0 ? expiredSession : freshSession;
}
return { devices: [] };
});
renderWithProviders(<PairPhoneModal onClose={onClose} onPaired={onPaired} />, {
initialEntries: ['/settings/devices'],
});
await advanceAndFlush(0);
expect(screen.getByTestId('qr-code')).toBeInTheDocument();
await advanceAndFlush(200);
expect(screen.getByText(/QR code expired/i)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /Generate new code/i }));
// Loading + fresh QR
await advanceAndFlush(0);
expect(screen.getByTestId('qr-code')).toBeInTheDocument();
expect(createCount).toBe(2);
});
// ---------------------------------------------------------------------------
// Error state
// ---------------------------------------------------------------------------
it('shows error state when devices_create_pairing fails', async () => {
mockCall.mockRejectedValue(new Error('tunnel unavailable'));
renderWithProviders(<PairPhoneModal onClose={onClose} onPaired={onPaired} />, {
initialEntries: ['/settings/devices'],
});
expect(await screen.findByText(/Something went wrong/i)).toBeInTheDocument();
expect(screen.getByText(/tunnel unavailable/i)).toBeInTheDocument();
});
// ---------------------------------------------------------------------------
// Close + details toggle (no timer tricks needed)
// ---------------------------------------------------------------------------
it('calls onClose when the X button is pressed', async () => {
mockCall.mockImplementation(async ({ method }: { method: string }) => {
if (method === 'openhuman.devices_create_pairing') return makePairingSession();
return { devices: [] };
});
renderWithProviders(<PairPhoneModal onClose={onClose} onPaired={onPaired} />, {
initialEntries: ['/settings/devices'],
});
await screen.findByTestId('qr-code');
fireEvent.click(screen.getByRole('button', { name: /Close/i }));
expect(onClose).toHaveBeenCalledTimes(1);
expect(onPaired).not.toHaveBeenCalled();
});
it('toggles details section when "Show details" / "Hide details" is clicked', async () => {
mockCall.mockImplementation(async ({ method }: { method: string }) => {
if (method === 'openhuman.devices_create_pairing') return makePairingSession();
return { devices: [] };
});
renderWithProviders(<PairPhoneModal onClose={onClose} onPaired={onPaired} />, {
initialEntries: ['/settings/devices'],
});
await screen.findByTestId('qr-code');
expect(screen.queryByText('Channel ID')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /Show details/i }));
expect(screen.getByText('Channel ID')).toBeInTheDocument();
expect(screen.getByText(CHANNEL_ID)).toBeInTheDocument();
// Toggle state is synchronous.
fireEvent.click(screen.getByRole('button', { name: /Hide details/i }));
expect(screen.queryByText('Channel ID')).not.toBeInTheDocument();
});
});
@@ -0,0 +1,422 @@
/**
* PairPhoneModal
*
* Opens a pairing session via `devices_create_pairing`, shows a QR code the
* iPhone user scans, then polls `devices_list` every 2 s to detect when the
* device has completed the handshake (DevicePaired). Handles expiry and lets
* the user regenerate the code.
*
* TODO(future): replace the 2-second poll with a real socket event bridge when
* the Rust core forwards DomainEvent::DevicePaired over Socket.IO to the UI.
*/
import createDebug from 'debug';
import { QRCodeSVG } from 'qrcode.react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { callCoreRpc } from '../../../../services/coreRpcClient';
const log = createDebug('app:devices-ui:pair-modal');
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface CreatePairingResponse {
channel_id: string;
pairing_token: string;
core_pubkey: string;
rpc_url: string | null;
expires_at: string;
}
interface PairedDevice {
channel_id: string;
label: string;
peer_online: boolean | null;
revoked: boolean;
}
interface ListDevicesResponse {
devices: PairedDevice[];
}
type ModalState =
| { kind: 'loading' }
| { kind: 'qr'; session: CreatePairingResponse; qrUrl: string; expired: boolean }
| { kind: 'success'; channelId: string; label: string }
| { kind: 'error'; message: string };
interface PairPhoneModalProps {
onClose: () => void;
/** Called when a device successfully completes pairing. */
onPaired: (channelId: string) => void;
}
// ---------------------------------------------------------------------------
// QR URL builder
// ---------------------------------------------------------------------------
function buildPairUrl(session: CreatePairingResponse): string {
const params = new URLSearchParams();
params.set('cid', session.channel_id);
params.set('pt', session.pairing_token);
params.set('cpk', session.core_pubkey);
if (session.rpc_url) params.set('rpc', session.rpc_url);
// expires_at is ISO 8601 — convert to unix timestamp for compact QR.
const expUnix = Math.floor(new Date(session.expires_at).getTime() / 1_000);
params.set('exp', String(expUnix));
return `openhuman://pair?${params.toString()}`;
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
const PairPhoneModal = ({ onClose, onPaired }: PairPhoneModalProps) => {
const [state, setState] = useState<ModalState>({ kind: 'loading' });
const [showDetails, setShowDetails] = useState(false);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const expireTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pairedRef = useRef(false);
const clearTimers = () => {
if (pollRef.current) {
clearInterval(pollRef.current);
pollRef.current = null;
}
if (expireTimerRef.current) {
clearTimeout(expireTimerRef.current);
expireTimerRef.current = null;
}
};
// Watch the paired-device list to detect handshake completion.
const startPollForPaired = useCallback(
(channelId: string) => {
if (pollRef.current) return;
log('[devices-ui] [pair-modal] starting poll for channel_id=%s', channelId);
pollRef.current = setInterval(async () => {
if (pairedRef.current) return;
try {
const res = await callCoreRpc<ListDevicesResponse>({
method: 'openhuman.devices_list',
params: {},
});
const matched = res.devices.find(d => d.channel_id === channelId && !d.revoked);
if (matched) {
pairedRef.current = true;
clearTimers();
log(
'[devices-ui] [pair-modal] device paired! channel_id=%s label=%s',
channelId,
matched.label
);
setState({ kind: 'success', channelId, label: matched.label });
// Auto-close after 3 s to let the user read the success message.
setTimeout(() => {
onPaired(channelId);
}, 3_000);
}
} catch (err) {
// Non-fatal poll failure — the modal stays open.
log('[devices-ui] [pair-modal] poll error: %s', String(err));
}
}, 2_000);
},
[onPaired]
);
const createSession = useCallback(async () => {
clearTimers();
pairedRef.current = false;
setState({ kind: 'loading' });
log('[devices-ui] [pair-modal] calling devices_create_pairing');
try {
const session = await callCoreRpc<CreatePairingResponse>({
method: 'openhuman.devices_create_pairing',
params: {},
});
log(
'[devices-ui] [pair-modal] session created channel_id=%s token_len=%d expires_at=%s',
session.channel_id,
session.pairing_token.length,
session.expires_at
);
const qrUrl = buildPairUrl(session);
setState({ kind: 'qr', session, qrUrl, expired: false });
// Schedule expiry transition.
const msUntilExpiry = new Date(session.expires_at).getTime() - Date.now();
if (msUntilExpiry > 0) {
expireTimerRef.current = setTimeout(() => {
log('[devices-ui] [pair-modal] QR expired channel_id=%s', session.channel_id);
setState(prev =>
prev.kind === 'qr' && prev.session.channel_id === session.channel_id
? { ...prev, expired: true }
: prev
);
}, msUntilExpiry);
}
startPollForPaired(session.channel_id);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
log('[devices-ui] [pair-modal] create pairing error: %s', msg);
setState({ kind: 'error', message: `Failed to create pairing: ${msg}` });
}
}, [startPollForPaired]);
useEffect(() => {
void createSession();
return clearTimers;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// ---------------------------------------------------------------------------
// Render
// ---------------------------------------------------------------------------
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/30">
<div className="bg-white rounded-2xl max-w-sm w-full border border-stone-200 shadow-large overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-5 pt-5 pb-3 border-b border-stone-100">
<h3 className="text-base font-semibold text-stone-900">Pair iPhone</h3>
<button
onClick={onClose}
className="w-6 h-6 flex items-center justify-center rounded-full hover:bg-stone-100 transition-colors"
aria-label="Close">
<svg
className="w-4 h-4 text-stone-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
{/* Body */}
<div className="p-5">
{state.kind === 'loading' && <LoadingBody />}
{state.kind === 'error' && (
<ErrorBody
message={state.message}
onRetry={() => {
void createSession();
}}
/>
)}
{state.kind === 'qr' && !state.expired && (
<QrBody
session={state.session}
qrUrl={state.qrUrl}
showDetails={showDetails}
onToggleDetails={() => setShowDetails(v => !v)}
/>
)}
{state.kind === 'qr' && state.expired && (
<ExpiredBody
onRegenerate={() => {
void createSession();
}}
/>
)}
{state.kind === 'success' && (
<SuccessBody label={state.label} channelId={state.channelId} />
)}
</div>
{/* Footer */}
{(state.kind === 'qr' || state.kind === 'error') && (
<div className="px-5 pb-5">
<button
onClick={onClose}
className="w-full px-4 py-2 text-sm text-stone-600 hover:text-stone-800 transition-colors">
Cancel
</button>
</div>
)}
</div>
</div>
);
};
// ---------------------------------------------------------------------------
// State-specific sub-components
// ---------------------------------------------------------------------------
function LoadingBody() {
return (
<div className="flex flex-col items-center justify-center py-10 gap-3">
<svg className="w-6 h-6 animate-spin text-primary-400" fill="none" viewBox="0 0 24 24">
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
<p className="text-sm text-stone-500">Generating pairing code</p>
</div>
);
}
function QrBody({
session,
qrUrl,
showDetails,
onToggleDetails,
}: {
session: CreatePairingResponse;
qrUrl: string;
showDetails: boolean;
onToggleDetails: () => void;
}) {
const expiresAt = new Date(session.expires_at);
const minutesLeft = Math.max(0, Math.floor((expiresAt.getTime() - Date.now()) / 60_000));
return (
<div className="flex flex-col items-center gap-4">
<p className="text-sm text-stone-600 text-center">
Open the OpenHuman app on your iPhone and scan this code.
</p>
{/* QR code */}
<div className="p-3 bg-white rounded-xl border border-stone-200 shadow-sm">
<QRCodeSVG value={qrUrl} size={200} level="M" bgColor="#ffffff" fgColor="#1c1917" />
</div>
<p className="text-xs text-stone-400">
Code expires in ~{minutesLeft} minute{minutesLeft !== 1 ? 's' : ''}
</p>
{/* Details toggle */}
<button
onClick={onToggleDetails}
className="text-xs text-primary-500 hover:text-primary-600 transition-colors">
{showDetails ? 'Hide details' : 'Show details'}
</button>
{showDetails && (
<div className="w-full space-y-2">
<div>
<p className="text-xs font-medium text-stone-500 mb-1">Channel ID</p>
<p className="text-xs font-mono text-stone-700 bg-stone-50 rounded px-2 py-1 break-all select-all">
{session.channel_id}
</p>
</div>
<div>
<p className="text-xs font-medium text-stone-500 mb-1">Pairing URL</p>
<div className="relative">
<p className="text-xs font-mono text-stone-700 bg-stone-50 rounded px-2 py-1 break-all select-all pr-16">
{qrUrl}
</p>
<button
onClick={() => {
void navigator.clipboard.writeText(qrUrl);
}}
className="absolute top-1 right-1 text-xs text-primary-500 hover:text-primary-600 px-1 py-0.5 bg-white border border-stone-200 rounded">
Copy
</button>
</div>
</div>
</div>
)}
</div>
);
}
function ExpiredBody({ onRegenerate }: { onRegenerate: () => void }) {
return (
<div className="flex flex-col items-center gap-4 py-4">
<div className="w-12 h-12 rounded-xl bg-amber-50 flex items-center justify-center">
<svg
className="w-6 h-6 text-amber-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<p className="text-sm font-medium text-stone-700">QR code expired</p>
<p className="text-xs text-stone-500 text-center">Generate a new code to continue pairing.</p>
<button
onClick={onRegenerate}
className="px-4 py-2 text-sm font-medium text-white bg-primary-500 hover:bg-primary-600 transition-colors rounded-lg">
Generate new code
</button>
</div>
);
}
function SuccessBody({ label, channelId }: { label: string; channelId: string }) {
return (
<div className="flex flex-col items-center gap-4 py-4">
<div className="w-12 h-12 rounded-xl bg-sage-50 flex items-center justify-center">
<svg
className="w-6 h-6 text-sage-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<div className="text-center">
<p className="text-sm font-medium text-stone-800">Paired with iPhone</p>
<p className="text-xs text-stone-500 mt-1">{label}</p>
<p className="text-xs font-mono text-stone-400 mt-0.5">
{channelId.slice(0, 8)}{channelId.slice(-6)}
</p>
</div>
<p className="text-xs text-stone-400">Closing automatically</p>
</div>
);
}
function ErrorBody({ message, onRetry }: { message: string; onRetry: () => void }) {
return (
<div className="flex flex-col items-center gap-4 py-4">
<div className="w-12 h-12 rounded-xl bg-coral-50 flex items-center justify-center">
<svg
className="w-6 h-6 text-coral-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</div>
<p className="text-sm font-medium text-stone-700">Something went wrong</p>
<p className="text-xs text-stone-500 text-center break-all">{message}</p>
<button
onClick={onRetry}
className="px-4 py-2 text-sm font-medium text-white bg-primary-500 hover:bg-primary-600 transition-colors rounded-lg">
Try again
</button>
</div>
);
}
export default PairPhoneModal;
+72
View File
@@ -0,0 +1,72 @@
import { afterEach, describe, expect, it } from 'vitest';
import {
clearTestPlatform,
getIsAndroid,
getIsIOS,
getIsMobile,
setTestPlatform,
} from './platform';
describe('platform detection', () => {
afterEach(() => {
clearTestPlatform();
});
it('returns false by default in test environment (not iOS UA)', () => {
// In Vitest / jsdom navigator.userAgent is not an iPhone string,
// so the result should be false with no override.
clearTestPlatform();
// Don't assert a specific value since isTauri() may vary by env;
// just confirm getIsIOS() is a boolean.
expect(typeof getIsIOS()).toBe('boolean');
});
it('returns true when test override is set to "ios"', () => {
setTestPlatform('ios');
expect(getIsIOS()).toBe(true);
});
it('returns false when test override is set to "desktop"', () => {
setTestPlatform('desktop');
expect(getIsIOS()).toBe(false);
});
it('toggle works round-trip', () => {
setTestPlatform('ios');
expect(getIsIOS()).toBe(true);
setTestPlatform('desktop');
expect(getIsIOS()).toBe(false);
clearTestPlatform();
// After clear, back to auto-detect (still a boolean).
expect(typeof getIsIOS()).toBe('boolean');
});
it('getIsAndroid reflects the "android" test override', () => {
setTestPlatform('android');
expect(getIsAndroid()).toBe(true);
expect(getIsIOS()).toBe(false);
});
it('getIsAndroid returns false on iOS and desktop overrides', () => {
setTestPlatform('ios');
expect(getIsAndroid()).toBe(false);
setTestPlatform('desktop');
expect(getIsAndroid()).toBe(false);
});
it('getIsMobile is true for both iOS and Android, false for desktop', () => {
setTestPlatform('ios');
expect(getIsMobile()).toBe(true);
setTestPlatform('android');
expect(getIsMobile()).toBe(true);
setTestPlatform('desktop');
expect(getIsMobile()).toBe(false);
});
it('returns a boolean for getIsAndroid by default', () => {
clearTestPlatform();
expect(typeof getIsAndroid()).toBe('boolean');
expect(typeof getIsMobile()).toBe('boolean');
});
});
+98
View File
@@ -0,0 +1,98 @@
/**
* Platform detection utilities.
*
* Uses navigator.userAgent plus isTauri() from webviewAccountService to decide
* whether we are running inside the Tauri runtime on a phone (iOS or Android).
*
* For tests: override via setTestPlatform() / clearTestPlatform().
* Production code must not call the override functions.
*/
import { isTauri } from '../services/webviewAccountService';
// -- test override -----------------------------------------------------------
type Platform = 'ios' | 'android' | 'desktop';
let _testOverride: Platform | null = null;
/**
* Override the detected platform in tests.
* Call clearTestPlatform() in afterEach to restore.
*/
export function setTestPlatform(platform: Platform): void {
_testOverride = platform;
}
/** Restore automatic detection (call in afterEach). */
export function clearTestPlatform(): void {
_testOverride = null;
}
// -- detection ---------------------------------------------------------------
function detectIOS(): boolean {
if (_testOverride === 'ios') return true;
if (_testOverride === 'android' || _testOverride === 'desktop') return false;
if (typeof navigator === 'undefined') return false;
const isMobileUA = /iPhone|iPad|iPod/i.test(navigator.userAgent);
// Only treat as iOS when we're actually inside the Tauri runtime.
// A web browser on an iPhone should not trigger iOS-specific Tauri flows.
return isMobileUA && isTauri();
}
function detectAndroid(): boolean {
if (_testOverride === 'android') return true;
if (_testOverride === 'ios' || _testOverride === 'desktop') return false;
if (typeof navigator === 'undefined') return false;
const isAndroidUA = /Android/i.test(navigator.userAgent);
return isAndroidUA && isTauri();
}
/**
* True when the app is running on iOS (inside the Tauri iOS target).
*
* Evaluated lazily on first access and then cached for the lifetime of the
* module — the platform never changes at runtime.
*/
let _isIOSCache: boolean | null = null;
let _isAndroidCache: boolean | null = null;
export function getIsIOS(): boolean {
if (_testOverride !== null) {
// Always re-evaluate when a test override is active.
return detectIOS();
}
if (_isIOSCache === null) {
_isIOSCache = detectIOS();
}
return _isIOSCache;
}
export function getIsAndroid(): boolean {
if (_testOverride !== null) {
return detectAndroid();
}
if (_isAndroidCache === null) {
_isAndroidCache = detectAndroid();
}
return _isAndroidCache;
}
/** True for either mobile target (iOS or Android). */
export function getIsMobile(): boolean {
return getIsIOS() || getIsAndroid();
}
/**
* Convenience re-export as a constant.
* Safe to import and use at module level — evaluated once on import.
*
* NOTE: if you need test overrides to work, call getIsIOS() instead,
* since this is evaluated at module load time.
*/
export const isIOS: boolean = detectIOS();
export const isAndroid: boolean = detectAndroid();
+173
View File
@@ -0,0 +1,173 @@
/**
* Unit tests for tunnel/crypto.ts
*/
import { describe, expect, it } from 'vitest';
import {
base64urlDecode,
base64urlEncode,
deriveSharedSecret,
generateKeypair,
open,
openHandshake,
ReplayTracker,
seal,
sealHandshake,
} from './crypto';
// -- base64url helpers -------------------------------------------------------
describe('base64url helpers', () => {
it('round-trips arbitrary bytes', () => {
const bytes = new Uint8Array([0, 1, 2, 255, 128, 64]);
expect(base64urlDecode(base64urlEncode(bytes))).toEqual(bytes);
});
it('produces no padding characters', () => {
const s = base64urlEncode(new Uint8Array(10));
expect(s).not.toMatch(/=/);
});
it('uses - and _ instead of + and /', () => {
// Generate bytes that would produce + and / in standard base64.
// 0xFB = 11111011 → standard base64 uses '+' and '/'.
for (let i = 0; i < 100; i++) {
const b = new Uint8Array([0xfb, 0xff, 0xfe]);
const s = base64urlEncode(b);
expect(s).not.toMatch(/\+|\/|=/);
}
});
});
// -- keypair generation and DH -----------------------------------------------
describe('generateKeypair', () => {
it('returns 32-byte keys', () => {
const kp = generateKeypair();
expect(kp.publicKey).toHaveLength(32);
expect(kp.secretKey).toHaveLength(32);
});
it('two keypairs are different', () => {
const a = generateKeypair();
const b = generateKeypair();
expect(a.publicKey).not.toEqual(b.publicKey);
});
});
describe('deriveSharedSecret', () => {
it('both sides derive the same secret', () => {
const alice = generateKeypair();
const bob = generateKeypair();
const aliceShared = deriveSharedSecret(alice.secretKey, bob.publicKey);
const bobShared = deriveSharedSecret(bob.secretKey, alice.publicKey);
expect(aliceShared).toEqual(bobShared);
});
});
// -- seal / open round-trip --------------------------------------------------
describe('seal / open', () => {
function makeKey(): Uint8Array {
const a = generateKeypair();
const b = generateKeypair();
return deriveSharedSecret(a.secretKey, b.publicKey);
}
it('round-trip encrypts and decrypts', () => {
const key = makeKey();
const tracker = new ReplayTracker();
const plaintext = new TextEncoder().encode('hello tunnel');
const frame = seal(key, plaintext);
const recovered = open(key, frame, tracker);
expect(Array.from(recovered)).toEqual(Array.from(plaintext));
});
it('rejects tampered frame', () => {
const key = makeKey();
const tracker = new ReplayTracker();
const frame = seal(key, new TextEncoder().encode('data'));
frame[frame.length - 1] ^= 0xff; // flip last byte
expect(() => open(key, frame, tracker)).toThrow(/tampered|authentication/i);
});
it('rejects replayed nonce', () => {
const key = makeKey();
const tracker = new ReplayTracker();
const frame = seal(key, new TextEncoder().encode('replay me'));
open(key, frame, tracker); // first: ok
expect(() => open(key, frame, tracker)).toThrow(/replayed nonce/i);
});
it('rejects wrong version byte', () => {
const key = makeKey();
const tracker = new ReplayTracker();
const frame = seal(key, new TextEncoder().encode('version test'));
const badFrame = new Uint8Array(frame);
badFrame[0] = 0x99;
expect(() => open(key, badFrame, tracker)).toThrow(/unsupported frame version/i);
});
it('rejects empty frame', () => {
const key = makeKey();
const tracker = new ReplayTracker();
expect(() => open(key, new Uint8Array(0), tracker)).toThrow(/empty frame/i);
});
});
// -- sealed handshake --------------------------------------------------------
describe('sealHandshake / openHandshake', () => {
it('round-trip via sealHandshake + openHandshake', () => {
const core = generateKeypair();
const payload = new TextEncoder().encode('device_pubkey_b64url');
const frame = sealHandshake(core.publicKey, payload);
const recovered = openHandshake(core.secretKey, frame);
expect(Array.from(recovered)).toEqual(Array.from(payload));
});
it('frame starts with version byte 0x01', () => {
const core = generateKeypair();
const frame = sealHandshake(core.publicKey, new Uint8Array(16));
expect(frame[0]).toBe(0x01);
});
it('rejects tampered handshake frame', () => {
const core = generateKeypair();
const frame = sealHandshake(core.publicKey, new TextEncoder().encode('payload'));
const bad = new Uint8Array(frame);
bad[bad.length - 1] ^= 0xff;
expect(() => openHandshake(core.secretKey, bad)).toThrow(/authentication failed/i);
});
it('rejects frame that is too short', () => {
const core = generateKeypair();
const tinyFrame = new Uint8Array([0x01, 0x00, 0x01]);
expect(() => openHandshake(core.secretKey, tinyFrame)).toThrow(/too short/i);
});
});
// -- ReplayTracker -----------------------------------------------------------
describe('ReplayTracker', () => {
it('accepts fresh nonces', () => {
const tracker = new ReplayTracker(4);
const nonce = new Uint8Array([1, 2, 3]);
expect(tracker.seen(nonce)).toBe(false);
tracker.record(nonce);
expect(tracker.seen(nonce)).toBe(true);
});
it('evicts oldest nonce when window is full', () => {
const tracker = new ReplayTracker(2);
const n1 = new Uint8Array([1]);
const n2 = new Uint8Array([2]);
const n3 = new Uint8Array([3]);
tracker.record(n1);
tracker.record(n2);
tracker.record(n3); // evicts n1
expect(tracker.seen(n1)).toBe(false); // evicted
expect(tracker.seen(n2)).toBe(true);
expect(tracker.seen(n3)).toBe(true);
});
});
+199
View File
@@ -0,0 +1,199 @@
/**
* Tunnel crypto: X25519 key agreement + XChaCha20-Poly1305 frame encryption.
*
* Wire format (encrypted frame):
* version(1=0x01) || nonce(24) || ciphertext+tag
*
* Sealed-handshake format (device → core, first frame):
* 0x01 || eph_pub(32) || nonce(24) || ciphertext+tag
*
* Mirrors src/openhuman/devices/crypto.rs — keep in sync.
*/
import { xchacha20poly1305 } from '@noble/ciphers/chacha';
import { randomBytes } from '@noble/ciphers/webcrypto';
import { x25519 } from '@noble/curves/ed25519.js';
import debug from 'debug';
const cryptoLog = debug('crypto');
const cryptoErr = debug('crypto:error');
// -- constants ---------------------------------------------------------------
const FRAME_VERSION = 0x01;
const NONCE_LEN = 24; // XChaCha20-Poly1305 nonce
const EPH_PUB_LEN = 32; // X25519 public key
const REPLAY_WINDOW = 128;
// -- base64url helpers -------------------------------------------------------
/** Encode bytes to base64url without padding. */
export function base64urlEncode(bytes: Uint8Array): string {
const b64 = btoa(String.fromCharCode(...bytes));
return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
}
/** Decode base64url (with or without padding). */
export function base64urlDecode(s: string): Uint8Array {
const padded = s.replace(/-/g, '+').replace(/_/g, '/');
const pad = (4 - (padded.length % 4)) % 4;
const b64 = padded + '='.repeat(pad);
const binary = atob(b64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}
// -- keypair -----------------------------------------------------------------
export interface TunnelKeypair {
publicKey: Uint8Array; // 32 bytes
secretKey: Uint8Array; // 32 bytes
}
/** Generate a fresh X25519 keypair. */
export function generateKeypair(): TunnelKeypair {
const secretKey = x25519.utils.randomSecretKey();
const publicKey = x25519.getPublicKey(secretKey);
cryptoLog('[crypto] keypair generated pubkey_len=%d', publicKey.length);
return { publicKey, secretKey };
}
/** Derive a 32-byte X25519 shared secret. */
export function deriveSharedSecret(myPriv: Uint8Array, theirPub: Uint8Array): Uint8Array {
const shared = x25519.getSharedSecret(myPriv, theirPub);
cryptoLog('[crypto] shared secret derived');
return shared;
}
// -- frame cipher ------------------------------------------------------------
/**
* Seal `plaintext` into a versioned frame.
* Output: version(1) || nonce(24) || ciphertext+tag
*/
export function seal(key: Uint8Array, plaintext: Uint8Array): Uint8Array {
const nonce = randomBytes(NONCE_LEN);
const cipher = xchacha20poly1305(key, nonce);
const ciphertext = cipher.encrypt(plaintext);
const frame = new Uint8Array(1 + NONCE_LEN + ciphertext.length);
frame[0] = FRAME_VERSION;
frame.set(nonce, 1);
frame.set(ciphertext, 1 + NONCE_LEN);
cryptoLog('[crypto] seal plaintext_len=%d frame_len=%d', plaintext.length, frame.length);
return frame;
}
/**
* Open a versioned frame.
* Throws on version mismatch, replay, or authentication failure.
*/
export function open(key: Uint8Array, frame: Uint8Array, tracker: ReplayTracker): Uint8Array {
if (frame.length === 0) {
throw new Error('[crypto] empty frame');
}
if (frame[0] !== FRAME_VERSION) {
throw new Error(`[crypto] unsupported frame version: 0x${frame[0].toString(16)}`);
}
if (frame.length < 1 + NONCE_LEN) {
throw new Error('[crypto] frame too short for nonce');
}
const nonce = frame.slice(1, 1 + NONCE_LEN);
const ciphertext = frame.slice(1 + NONCE_LEN);
if (tracker.seen(nonce)) {
throw new Error('[crypto] replayed nonce — frame rejected');
}
try {
const cipher = xchacha20poly1305(key, nonce);
const plaintext = cipher.decrypt(ciphertext);
tracker.record(nonce);
cryptoLog('[crypto] open frame_len=%d plaintext_len=%d', frame.length, plaintext.length);
return plaintext;
} catch (err) {
cryptoErr('[crypto] authentication failed — tampered frame', err);
throw new Error('[crypto] authentication failed — tampered frame');
}
}
// -- sealed handshake --------------------------------------------------------
/**
* Seal a handshake payload to the core's static public key using an ephemeral
* X25519 keypair + XChaCha20-Poly1305.
*
* Output: 0x01 || eph_pub(32) || nonce(24) || ciphertext+tag
*
* Mirrors the wire format expected by bus.rs handle_tunnel_frame.
*/
export function sealHandshake(corePubkey: Uint8Array, payload: Uint8Array): Uint8Array {
const eph = generateKeypair();
const sharedKey = deriveSharedSecret(eph.secretKey, corePubkey);
const nonce = randomBytes(NONCE_LEN);
const cipher = xchacha20poly1305(sharedKey, nonce);
const ciphertext = cipher.encrypt(payload);
// 0x01 || eph_pub(32) || nonce(24) || ciphertext+tag
const frame = new Uint8Array(1 + EPH_PUB_LEN + NONCE_LEN + ciphertext.length);
frame[0] = FRAME_VERSION;
frame.set(eph.publicKey, 1);
frame.set(nonce, 1 + EPH_PUB_LEN);
frame.set(ciphertext, 1 + EPH_PUB_LEN + NONCE_LEN);
cryptoLog('[crypto] sealHandshake payload_len=%d frame_len=%d', payload.length, frame.length);
return frame;
}
/**
* Open a sealed handshake frame produced by `sealHandshake`.
* Uses `myPriv` (core static key) to recover the plaintext.
*/
export function openHandshake(myPriv: Uint8Array, frame: Uint8Array): Uint8Array {
if (frame.length < 1 + EPH_PUB_LEN + NONCE_LEN + 16) {
throw new Error('[crypto] sealed-handshake frame too short');
}
if (frame[0] !== FRAME_VERSION) {
throw new Error(`[crypto] bad handshake version: 0x${frame[0].toString(16)}`);
}
const ephPub = frame.slice(1, 1 + EPH_PUB_LEN);
const nonce = frame.slice(1 + EPH_PUB_LEN, 1 + EPH_PUB_LEN + NONCE_LEN);
const ciphertext = frame.slice(1 + EPH_PUB_LEN + NONCE_LEN);
const sharedKey = deriveSharedSecret(myPriv, ephPub);
try {
const cipher = xchacha20poly1305(sharedKey, nonce);
return cipher.decrypt(ciphertext);
} catch {
throw new Error('[crypto] handshake authentication failed');
}
}
// -- replay tracker ----------------------------------------------------------
/** Sliding-window replay tracker over raw nonce bytes. */
export class ReplayTracker {
private readonly window: Uint8Array[] = [];
private readonly maxSize: number;
constructor(windowSize = REPLAY_WINDOW) {
this.maxSize = windowSize;
}
/** Returns true if `nonce` has been seen before. */
seen(nonce: Uint8Array): boolean {
return this.window.some(n => n.length === nonce.length && n.every((b, i) => b === nonce[i]));
}
/** Record a freshly-used nonce. Evicts oldest when window is full. */
record(nonce: Uint8Array): void {
if (this.window.length >= this.maxSize) {
this.window.shift();
}
this.window.push(new Uint8Array(nonce));
}
}
+139
View File
@@ -0,0 +1,139 @@
/**
* Unit tests for tunnel/framing.ts
*/
import { describe, expect, it, vi } from 'vitest';
import { chunk, Envelope, Reassembler, TokenBucket } from './framing';
// -- chunk + reassemble round-trip -------------------------------------------
function makeEnvelope(payloadSize: number): Envelope {
return { requestId: 'test-req-1', kind: 'response', seq: 0, payload: 'x'.repeat(payloadSize) };
}
describe('chunk', () => {
it('returns a single frame for small payloads', () => {
const env = makeEnvelope(100);
const frames = chunk(env);
expect(frames).toHaveLength(1);
});
it('splits large payloads into multiple chunks', () => {
const env = makeEnvelope(200 * 1024); // 200 KB
const frames = chunk(env);
expect(frames.length).toBeGreaterThan(1);
});
it('produces multiple frames for large payloads (chunked)', () => {
// Each output frame is a ChunkFrame JSON which has overhead; the test just
// verifies that 200 KB produces multiple frames, each well under 100 KB.
const env = makeEnvelope(200 * 1024);
const frames = chunk(env);
expect(frames.length).toBeGreaterThan(1);
// Each frame carries at most 60 KB of raw data, plus base64 overhead (~33%)
// plus JSON wrapper. 60 KB * 1.34 ≈ 80 KB; add wrapper ≈ 85 KB max.
for (const f of frames) {
expect(f.length).toBeLessThanOrEqual(90 * 1024);
}
});
});
describe('Reassembler', () => {
it('passes through small (non-chunked) frames directly', () => {
const r = new Reassembler();
const env: Envelope = { requestId: 'r1', kind: 'request', seq: 0, payload: { method: 'ping' } };
const raw = new TextEncoder().encode(JSON.stringify(env));
const result = r.feed(raw);
expect(result).not.toBeNull();
expect(result!.requestId).toBe('r1');
});
it('chunk + reassemble round-trip for 200 KB payload', () => {
const env = makeEnvelope(200 * 1024);
const frames = chunk(env);
const r = new Reassembler();
let result: Envelope | null = null;
for (let i = 0; i < frames.length - 1; i++) {
const partial = r.feed(frames[i]);
expect(partial).toBeNull(); // not yet complete
}
result = r.feed(frames[frames.length - 1]);
expect(result).not.toBeNull();
expect(result!.requestId).toBe('test-req-1');
expect(result!.payload).toBe('x'.repeat(200 * 1024));
});
it('reassembles out-of-order chunks', () => {
const env = makeEnvelope(200 * 1024);
const frames = chunk(env);
expect(frames.length).toBeGreaterThan(1);
const r = new Reassembler();
// Feed all but the first chunk in order, then feed first chunk last.
const reordered = [...frames.slice(1), frames[0]];
let result: Envelope | null = null;
for (let i = 0; i < reordered.length; i++) {
result = r.feed(reordered[i]);
}
expect(result).not.toBeNull();
expect(result!.payload).toBe('x'.repeat(200 * 1024));
});
it('handles different requestIds concurrently', () => {
const r = new Reassembler();
const envA: Envelope = { requestId: 'A', kind: 'response', seq: 0, payload: 'aaa' };
const envB: Envelope = { requestId: 'B', kind: 'response', seq: 0, payload: 'bbb' };
const rawA = new TextEncoder().encode(JSON.stringify(envA));
const rawB = new TextEncoder().encode(JSON.stringify(envB));
const resultA = r.feed(rawA);
const resultB = r.feed(rawB);
expect(resultA!.requestId).toBe('A');
expect(resultB!.requestId).toBe('B');
});
});
// -- TokenBucket -------------------------------------------------------------
describe('TokenBucket', () => {
it('allows up to burst capacity immediately', () => {
const tb = new TokenBucket(100, 5);
for (let i = 0; i < 5; i++) {
expect(tb.tryConsume()).toBe(true);
}
expect(tb.tryConsume()).toBe(false); // burst exhausted
});
it('refills over time (using fake timers)', async () => {
vi.useFakeTimers();
const tb = new TokenBucket(100, 1); // 1 token burst
expect(tb.tryConsume()).toBe(true);
expect(tb.tryConsume()).toBe(false);
// Advance 10ms (should add ~1 token at 100/s).
await vi.advanceTimersByTimeAsync(10);
expect(tb.tryConsume()).toBe(true);
vi.useRealTimers();
});
it('consume() resolves after waiting for a token', async () => {
vi.useFakeTimers();
const tb = new TokenBucket(100, 1);
tb.tryConsume(); // exhaust
const done = vi.fn();
const p = tb.consume().then(done);
expect(done).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(15);
await p;
expect(done).toHaveBeenCalledOnce();
vi.useRealTimers();
});
});
+208
View File
@@ -0,0 +1,208 @@
/**
* Tunnel framing: request/response/streaming envelopes over encrypted frames.
*
* Envelope JSON schema:
* { requestId, kind, seq, payload }
*
* Large envelopes are split into ≤60 KB chunks, each wrapped in:
* { requestId, kind: "chunk", seq, total, data: base64 }
*
* Rate limiting: TokenBucket at 100 frames/s with burst.
*/
import debug from 'debug';
const framingLog = debug('framing');
// -- constants ---------------------------------------------------------------
const CHUNK_SIZE = 60 * 1024; // 60 KB max per chunk (headroom under 64 KB)
// -- types -------------------------------------------------------------------
export type EnvelopeKind = 'request' | 'response' | 'stream-chunk' | 'stream-end' | 'error';
export interface Envelope {
requestId: string;
kind: EnvelopeKind;
seq: number;
payload: unknown;
}
interface ChunkFrame {
requestId: string;
kind: 'chunk';
seq: number; // chunk index (0-based)
total: number; // total chunks
data: string; // base64-encoded fragment
}
// -- chunking ----------------------------------------------------------------
/**
* Encode an envelope to UTF-8 and split into ≤60 KB chunks.
* Returns a single encoded Uint8Array when the envelope fits in one frame.
*/
export function chunk(envelope: Envelope): Uint8Array[] {
const json = JSON.stringify(envelope);
const encoded = new TextEncoder().encode(json);
if (encoded.length <= CHUNK_SIZE) {
return [encoded];
}
// Split into chunks.
const total = Math.ceil(encoded.length / CHUNK_SIZE);
const chunks: Uint8Array[] = [];
for (let i = 0; i < total; i++) {
const slice = encoded.slice(i * CHUNK_SIZE, (i + 1) * CHUNK_SIZE);
// base64 encode the raw bytes of this slice
const data = btoa(String.fromCharCode(...slice));
const frame: ChunkFrame = { requestId: envelope.requestId, kind: 'chunk', seq: i, total, data };
chunks.push(new TextEncoder().encode(JSON.stringify(frame)));
}
framingLog('[framing] chunk requestId=%s total=%d', envelope.requestId, total);
return chunks;
}
// -- reassembler -------------------------------------------------------------
interface PendingAssembly {
total: number;
parts: Map<number, Uint8Array>; // seq -> raw bytes of the slice
}
/** Collects chunk frames by requestId and emits complete Envelopes. */
export class Reassembler {
private readonly pending = new Map<string, PendingAssembly>();
/**
* Feed a raw frame (UTF-8 bytes) into the reassembler.
*
* - If the frame is a complete envelope, parse and return it immediately.
* - If the frame is a chunk, buffer it and return the assembled envelope
* once all chunks have arrived.
* - Returns null if assembly is incomplete.
*/
feed(raw: Uint8Array): Envelope | null {
let parsed: unknown;
try {
parsed = JSON.parse(new TextDecoder().decode(raw));
} catch {
framingLog('[framing] Reassembler: failed to parse frame');
return null;
}
const obj = parsed as Record<string, unknown>;
if (obj.kind === 'chunk') {
return this.handleChunk(obj as unknown as ChunkFrame);
}
// Complete envelope.
return parsed as Envelope;
}
private handleChunk(frame: ChunkFrame): Envelope | null {
const { requestId, seq, total, data } = frame;
if (!this.pending.has(requestId)) {
this.pending.set(requestId, { total, parts: new Map() });
}
const entry = this.pending.get(requestId)!;
// Decode base64 fragment back to bytes.
const binary = atob(data);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
entry.parts.set(seq, bytes);
framingLog('[framing] chunk seq=%d/%d requestId=%s', seq + 1, total, requestId);
if (entry.parts.size < total) {
return null; // still waiting
}
// All chunks present — reassemble in order.
const ordered = Array.from({ length: total }, (_, i) => entry.parts.get(i)!);
const totalLen = ordered.reduce((acc, b) => acc + b.length, 0);
const combined = new Uint8Array(totalLen);
let offset = 0;
for (const part of ordered) {
combined.set(part, offset);
offset += part.length;
}
this.pending.delete(requestId);
try {
const env = JSON.parse(new TextDecoder().decode(combined)) as Envelope;
framingLog('[framing] reassembled requestId=%s', requestId);
return env;
} catch {
framingLog('[framing] reassemble parse failed requestId=%s', requestId);
return null;
}
}
}
// -- token bucket rate limiter -----------------------------------------------
/**
* Token bucket rate limiter.
* Default: 100 frames/s with burst capacity of 100.
*/
export class TokenBucket {
private tokens: number;
private readonly capacity: number;
private readonly refillRate: number; // tokens per ms
private lastRefill: number;
constructor(ratePerSecond = 100, burstCapacity = 100) {
this.capacity = burstCapacity;
this.tokens = burstCapacity;
this.refillRate = ratePerSecond / 1000;
this.lastRefill = Date.now();
}
/**
* Attempt to consume one token.
* Returns true if allowed, false if rate-limited.
*/
tryConsume(): boolean {
this.refill();
if (this.tokens >= 1) {
this.tokens -= 1;
return true;
}
return false;
}
/**
* Wait until a token is available, then consume it.
* Resolves after the appropriate delay.
*/
async consume(): Promise<void> {
this.refill();
if (this.tokens >= 1) {
this.tokens -= 1;
return;
}
// How long until we have one token?
const waitMs = Math.ceil((1 - this.tokens) / this.refillRate);
await new Promise<void>(resolve => setTimeout(resolve, waitMs));
this.refill();
this.tokens = Math.max(0, this.tokens - 1);
}
private refill(): void {
const now = Date.now();
const elapsed = now - this.lastRefill;
this.tokens = Math.min(this.capacity, this.tokens + elapsed * this.refillRate);
this.lastRefill = now;
}
}
+3
View File
@@ -15,6 +15,7 @@ import ComposioTriagePanel from '../components/settings/panels/ComposioTriagePan
import ConnectionsPanel from '../components/settings/panels/ConnectionsPanel';
import CronJobsPanel from '../components/settings/panels/CronJobsPanel';
import DeveloperOptionsPanel from '../components/settings/panels/DeveloperOptionsPanel';
import DevicesPanel from '../components/settings/panels/DevicesPanel';
import LocalModelDebugPanel from '../components/settings/panels/LocalModelDebugPanel';
import MascotPanel from '../components/settings/panels/MascotPanel';
import McpServerPanel from '../components/settings/panels/McpServerPanel';
@@ -377,6 +378,8 @@ const Settings = () => {
<Route path="webhooks-triggers" element={<Webhooks />} />
<Route path="composio-triggers" element={wrapSettingsPage(<ComposioTriagePanel />)} />
<Route path="composio-routing" element={wrapSettingsPage(<ComposioPanel />)} />
{/* Mobile devices */}
<Route path="devices" element={wrapSettingsPage(<DevicesPanel />)} />
{/* About / updates */}
<Route path="about" element={wrapSettingsPage(<AboutPanel />)} />
{/* Fallback */}
+336
View File
@@ -0,0 +1,336 @@
/**
* MascotScreen tests — render, send message, disconnect, PTT.
*
* Mocks:
* - services/chatService: chatSend + subscribeChatEvents
* - services/transport/profileStore: listProfiles + deleteProfile
* - features/human/useHumanMascot: returns idle face
* - features/human/Mascot (YellowMascot): lightweight stub
* - react-router-dom: mock useNavigate
* - tauri-plugin-ptt-api: startListening, stopListening, speak, cancelSpeech,
* onTranscriptPartial, onError
*/
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router-dom';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { MascotScreen } from './MascotScreen';
// -- module mocks ------------------------------------------------------------
const mockNavigate = vi.fn();
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual<typeof import('react-router-dom')>('react-router-dom');
return { ...actual, useNavigate: () => mockNavigate };
});
const mockChatSend = vi.fn();
const mockUnsubscribe = vi.fn();
const mockSubscribeChatEvents = vi.fn((_listeners: unknown) => mockUnsubscribe);
vi.mock('../../services/chatService', () => ({
chatSend: (args: unknown) => mockChatSend(args),
subscribeChatEvents: (listeners: unknown) => mockSubscribeChatEvents(listeners),
}));
const mockListProfiles = vi.fn();
const mockDeleteProfile = vi.fn();
vi.mock('../../services/transport/profileStore', () => ({
listProfiles: () => mockListProfiles(),
deleteProfile: (...args: unknown[]) => mockDeleteProfile(...args),
saveProfile: vi.fn(),
getProfile: vi.fn(),
listProfileIds: vi.fn(() => []),
}));
vi.mock('../../features/human/useHumanMascot', () => ({
useHumanMascot: vi.fn(() => ({ face: 'idle', viseme: { aa: 0, E: 0, I: 0, O: 0, U: 0 } })),
}));
// Stub YellowMascot to avoid SVG / RAF complexity in tests.
vi.mock('../../features/human/Mascot', () => ({
YellowMascot: ({ face }: { face: string }) => (
<div data-testid="yellow-mascot" data-face={face} />
),
}));
// PTT plugin mock ─ intercept before any import resolution.
const mockStartListening = vi.fn();
const mockStopListening = vi.fn();
const mockSpeak = vi.fn();
const mockCancelSpeech = vi.fn();
// Listener registries so tests can fire events.
let partialListeners: Array<(text: string) => void> = [];
let pttErrorListeners: Array<(err: { code: string; message: string }) => void> = [];
const mockOnTranscriptPartial = vi.fn((cb: (text: string) => void) => {
partialListeners.push(cb);
const unsub = () => {
partialListeners = partialListeners.filter(l => l !== cb);
};
return Promise.resolve(unsub);
});
const mockOnError = vi.fn((cb: (err: { code: string; message: string }) => void) => {
pttErrorListeners.push(cb);
const unsub = () => {
pttErrorListeners = pttErrorListeners.filter(l => l !== cb);
};
return Promise.resolve(unsub);
});
vi.mock('tauri-plugin-ptt-api', () => ({
startListening: () => mockStartListening(),
stopListening: () => mockStopListening(),
speak: (text: string, opts?: unknown) => mockSpeak(text, opts),
cancelSpeech: () => mockCancelSpeech(),
onTranscriptPartial: (cb: (text: string) => void) => mockOnTranscriptPartial(cb),
onError: (cb: (err: { code: string; message: string }) => void) => mockOnError(cb),
onTranscriptFinal: vi.fn(() => Promise.resolve(vi.fn())),
onTtsStarted: vi.fn(() => Promise.resolve(vi.fn())),
onTtsEnded: vi.fn(() => Promise.resolve(vi.fn())),
}));
// -- helpers -----------------------------------------------------------------
function renderMascotScreen() {
return render(
<MemoryRouter initialEntries={['/mascot']}>
<MascotScreen />
</MemoryRouter>
);
}
function firePttPartial(text: string) {
partialListeners.forEach(l => l(text));
}
function firePttError(code: string, message: string) {
pttErrorListeners.forEach(l => l({ code, message }));
}
// -- setup / teardown --------------------------------------------------------
beforeEach(() => {
mockNavigate.mockReset();
mockChatSend.mockReset();
mockSubscribeChatEvents.mockClear();
mockUnsubscribe.mockReset();
mockStartListening.mockResolvedValue(undefined);
mockStopListening.mockResolvedValue({ text: '', isFinal: true });
mockSpeak.mockResolvedValue(undefined);
mockCancelSpeech.mockResolvedValue(undefined);
mockOnTranscriptPartial.mockClear();
mockOnError.mockClear();
partialListeners = [];
pttErrorListeners = [];
mockListProfiles.mockReturnValue([{ id: 'chan1', label: 'Home desktop', kind: 'tunnel' }]);
mockDeleteProfile.mockReset();
});
afterEach(() => {
vi.clearAllMocks();
});
// -- tests -------------------------------------------------------------------
describe('MascotScreen', () => {
it('renders mascot canvas and input', () => {
renderMascotScreen();
expect(screen.getByTestId('yellow-mascot')).toBeInTheDocument();
expect(screen.getByPlaceholderText(/type a message/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /send message/i })).toBeInTheDocument();
});
it('shows paired desktop label in header', () => {
renderMascotScreen();
expect(screen.getByText('Home desktop')).toBeInTheDocument();
});
it('shows Disconnect button', () => {
renderMascotScreen();
expect(screen.getByRole('button', { name: /disconnect/i })).toBeInTheDocument();
});
it('PTT button is present and enabled', () => {
renderMascotScreen();
const pttBtn = screen.getByRole('button', { name: /push to talk/i });
expect(pttBtn).not.toBeDisabled();
});
it('send button is disabled when input is empty', () => {
renderMascotScreen();
const sendBtn = screen.getByRole('button', { name: /send message/i });
expect(sendBtn).toBeDisabled();
});
it('typing a message enables send button', async () => {
renderMascotScreen();
const input = screen.getByPlaceholderText(/type a message/i);
await userEvent.type(input, 'Hello mascot');
expect(screen.getByRole('button', { name: /send message/i })).not.toBeDisabled();
});
it('sending a message calls chatSend with the text', async () => {
mockChatSend.mockResolvedValueOnce(undefined);
renderMascotScreen();
const input = screen.getByPlaceholderText(/type a message/i);
await userEvent.type(input, 'Hello mascot');
await userEvent.click(screen.getByRole('button', { name: /send message/i }));
await waitFor(() => {
expect(mockChatSend).toHaveBeenCalledOnce();
});
const call = mockChatSend.mock.calls[0][0];
expect(call.message).toBe('Hello mascot');
expect(typeof call.threadId).toBe('string');
});
it('sends on Enter key press', async () => {
mockChatSend.mockResolvedValueOnce(undefined);
renderMascotScreen();
const input = screen.getByPlaceholderText(/type a message/i);
await userEvent.type(input, 'Hi{Enter}');
await waitFor(() => {
expect(mockChatSend).toHaveBeenCalledOnce();
});
});
it('clears input after sending', async () => {
mockChatSend.mockResolvedValueOnce(undefined);
renderMascotScreen();
const input = screen.getByPlaceholderText(/type a message/i);
await userEvent.type(input, 'Hello');
await userEvent.click(screen.getByRole('button', { name: /send message/i }));
await waitFor(() => {
expect((input as HTMLInputElement).value).toBe('');
});
});
it('subscribes to chat events on mount', () => {
renderMascotScreen();
expect(mockSubscribeChatEvents).toHaveBeenCalledOnce();
});
it('disconnect clears profiles and navigates to /pair', async () => {
renderMascotScreen();
await userEvent.click(screen.getByRole('button', { name: /disconnect/i }));
expect(mockDeleteProfile).toHaveBeenCalledWith('chan1');
expect(mockNavigate).toHaveBeenCalledWith('/pair', { replace: true });
});
it('shows error message in transcript on chatSend rejection', async () => {
mockChatSend.mockRejectedValueOnce(new Error('Network error'));
renderMascotScreen();
const input = screen.getByPlaceholderText(/type a message/i);
await userEvent.type(input, 'Test message');
await userEvent.click(screen.getByRole('button', { name: /send message/i }));
await waitFor(() => {
expect(screen.getByText(/failed to send/i)).toBeInTheDocument();
});
});
// -- PTT tests -------------------------------------------------------------
describe('PTT', () => {
it('pressing PTT button calls startListening', async () => {
renderMascotScreen();
const pttBtn = screen.getByRole('button', { name: /push to talk/i });
fireEvent.pointerDown(pttBtn);
await waitFor(() => {
expect(mockStartListening).toHaveBeenCalledOnce();
});
});
it('releasing PTT calls stopListening and sends transcript as chat message', async () => {
mockStopListening.mockResolvedValueOnce({ text: 'Hello from voice', isFinal: true });
mockChatSend.mockResolvedValueOnce(undefined);
renderMascotScreen();
const pttBtn = screen.getByRole('button', { name: /push to talk/i });
fireEvent.pointerDown(pttBtn);
await waitFor(() => expect(mockStartListening).toHaveBeenCalledOnce());
fireEvent.pointerUp(pttBtn);
await waitFor(() => {
expect(mockStopListening).toHaveBeenCalledOnce();
});
await waitFor(() => {
expect(mockChatSend).toHaveBeenCalledOnce();
const call = mockChatSend.mock.calls[0][0];
expect(call.message).toBe('Hello from voice');
});
});
it('empty transcript from stopListening does not call chatSend', async () => {
mockStopListening.mockResolvedValueOnce({ text: ' ', isFinal: true });
renderMascotScreen();
const pttBtn = screen.getByRole('button', { name: /push to talk/i });
fireEvent.pointerDown(pttBtn);
await waitFor(() => expect(mockStartListening).toHaveBeenCalledOnce());
fireEvent.pointerUp(pttBtn);
await waitFor(() => expect(mockStopListening).toHaveBeenCalledOnce());
expect(mockChatSend).not.toHaveBeenCalled();
});
it('PTT partial transcript updates caption above button', async () => {
renderMascotScreen();
const pttBtn = screen.getByRole('button', { name: /push to talk/i });
fireEvent.pointerDown(pttBtn);
// Fire a partial transcript event via the registered listener.
firePttPartial('How are you');
await waitFor(() => {
expect(screen.getByText('How are you')).toBeInTheDocument();
});
});
it('PTT error shows toast', async () => {
renderMascotScreen();
firePttError('permission_denied', 'Microphone access was denied.');
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent('Microphone access was denied.');
});
});
it('PTT presses cancel active TTS first', async () => {
renderMascotScreen();
const pttBtn = screen.getByRole('button', { name: /push to talk/i });
fireEvent.pointerDown(pttBtn);
await waitFor(() => {
expect(mockCancelSpeech).toHaveBeenCalledOnce();
});
});
it('startListening failure shows toast and resets button state', async () => {
mockStartListening.mockRejectedValueOnce(new Error('No microphone'));
renderMascotScreen();
const pttBtn = screen.getByRole('button', { name: /push to talk/i });
fireEvent.pointerDown(pttBtn);
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent('No microphone');
});
// Button should no longer be in active (scaled) state.
expect(pttBtn).not.toHaveClass('scale-110');
});
});
});
+481
View File
@@ -0,0 +1,481 @@
/**
* MascotScreen — iOS-only full-screen mascot chat interface.
*
* Layout:
* - Small header: paired desktop label + Disconnect button
* - YellowMascot canvas (fills the upper ~60% of screen)
* - Scrolling transcript of messages above the input row
* - Text input row pinned to bottom
* - PTT round button (hold to talk, release to send)
*
* Chat:
* - Sends via openhuman.channel_web_chat RPC (same as desktop chat).
* - Subscribes to chat events (text_delta, chat_done, chat_error) for
* mascot face transitions and transcript display.
* - Uses useHumanMascot() to drive face/viseme state.
*
* PTT (Layer 6):
* - onPointerDown -> startListening(); pttActive = true.
* - onPointerUp -> stopListening() -> send transcript as chat message.
* - onTranscriptPartial -> shows live caption above button.
* - onError -> surfaces a toast.
* - Agent reply is spoken via speak() once chat_done fires.
* - Any new PTT press cancels active TTS first.
*/
import debug from 'debug';
import { type FC, type FormEvent, useCallback, useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
cancelSpeech,
onError as onPttError,
onTranscriptPartial,
speak,
startListening,
stopListening,
} from 'tauri-plugin-ptt-api';
import { YellowMascot } from '../../features/human/Mascot';
import { useHumanMascot } from '../../features/human/useHumanMascot';
import {
type ChatDoneEvent,
type ChatErrorEvent,
chatSend,
type ChatTextDeltaEvent,
subscribeChatEvents,
} from '../../services/chatService';
import { deleteProfile, listProfiles } from '../../services/transport/profileStore';
const log = debug('ios:mascot-screen');
const logErr = debug('ios:mascot-screen:error');
// -- constants ---------------------------------------------------------------
/** Default thread ID for the iOS mascot chat. Static for now. */
const IOS_THREAD_ID = 'ios-mascot-thread';
/** Model to use for iOS chat. Falls through to core default if empty. */
const IOS_CHAT_MODEL = '';
// -- types -------------------------------------------------------------------
interface Message {
id: string;
role: 'user' | 'assistant';
text: string;
/** True while a streaming response is still accumulating. */
streaming?: boolean;
}
// -- sub-components ----------------------------------------------------------
interface TranscriptProps {
messages: Message[];
}
const MascotChatTranscript: FC<TranscriptProps> = ({ messages }) => {
const bottomRef = useRef<HTMLDivElement>(null);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
if (messages.length === 0) return null;
return (
<div className="flex flex-col gap-2 px-4 pb-2 overflow-y-auto max-h-[30vh]">
{messages.map(msg => (
<div
key={msg.id}
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
<div
className={`max-w-[80%] rounded-2xl px-3 py-2 text-sm leading-snug
${
msg.role === 'user'
? 'bg-[#4A83DD] text-white rounded-br-sm'
: 'bg-white/10 text-white/90 rounded-bl-sm'
}
${msg.streaming ? 'animate-pulse' : ''}`}>
{msg.text}
{msg.streaming && <span className="ml-1 opacity-60">...</span>}
</div>
</div>
))}
<div ref={bottomRef} />
</div>
);
};
// -- PTT button ---------------------------------------------------------------
interface PTTButtonProps {
active: boolean;
partialText: string;
onDown: () => void;
onUp: () => void;
}
const PTTButton: FC<PTTButtonProps> = ({ active, partialText, onDown, onUp }) => {
return (
<div className="relative flex flex-col items-center justify-center gap-1">
{partialText && (
<div
className="absolute bottom-full mb-2 px-3 py-1.5 rounded-lg bg-black/80 text-white text-xs
max-w-[200px] text-center pointer-events-none z-10">
{partialText}
</div>
)}
<button
type="button"
aria-label="Push to talk"
onPointerDown={e => {
// setPointerCapture is not available in jsdom test environments.
if (typeof e.currentTarget.setPointerCapture === 'function') {
e.currentTarget.setPointerCapture(e.pointerId);
}
onDown();
}}
onPointerUp={onUp}
onPointerCancel={onUp}
className={`w-14 h-14 rounded-full border flex items-center justify-center
transition-all select-none touch-none
${
active
? 'bg-[#4A83DD] border-[#4A83DD] scale-110'
: 'bg-white/10 border-white/20 opacity-80'
}`}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M12 1a4 4 0 0 1 4 4v6a4 4 0 0 1-8 0V5a4 4 0 0 1 4-4z" fill="white" />
<path
d="M19 10a1 1 0 0 1 2 0 9 9 0 0 1-18 0 1 1 0 0 1 2 0 7 7 0 0 0 14 0z"
fill="white"
fillOpacity="0.8"
/>
<line
x1="12"
y1="19"
x2="12"
y2="23"
stroke="white"
strokeWidth="2"
strokeLinecap="round"
/>
</svg>
</button>
</div>
);
};
// -- toast -------------------------------------------------------------------
interface ToastProps {
message: string;
onDismiss: () => void;
}
const Toast: FC<ToastProps> = ({ message, onDismiss }) => (
<div
role="alert"
className="absolute bottom-24 left-1/2 -translate-x-1/2 z-50
px-4 py-2 rounded-xl bg-red-500/90 text-white text-sm
max-w-[80%] text-center shadow-lg"
onClick={onDismiss}>
{message}
</div>
);
// -- main component ----------------------------------------------------------
export const MascotScreen: FC = () => {
const navigate = useNavigate();
const [messages, setMessages] = useState<Message[]>([]);
const [inputText, setInputText] = useState('');
const [isSending, setIsSending] = useState(false);
// PTT state
const [pttActive, setPttActive] = useState(false);
const [partialText, setPartialText] = useState('');
const [toast, setToast] = useState<string | null>(null);
const streamingIdRef = useRef<string | null>(null);
// Ref tracks whether PTT session is live — readable from async callbacks
// without a stale closure over the pttActive state variable.
const pttActiveRef = useRef(false);
const { face } = useHumanMascot({ listening: pttActive });
// Derive label from stored profile.
const pairedLabel = (() => {
const profiles = listProfiles();
return profiles[0]?.label ?? 'Desktop';
})();
log('[ios] mascot screen mounted pairedLabel=%s', pairedLabel);
// -- chat event subscription -----------------------------------------------
useEffect(() => {
const unsub = subscribeChatEvents({
onTextDelta: (e: ChatTextDeltaEvent) => {
const sid = streamingIdRef.current;
if (!sid) return;
setMessages(prev =>
prev.map(m => (m.id === sid ? { ...m, text: m.text + e.delta, streaming: true } : m))
);
},
onDone: (e: ChatDoneEvent) => {
const sid = streamingIdRef.current;
log('[ios] chat done thread_id=%s', e.thread_id);
streamingIdRef.current = null;
setMessages(prev =>
prev.map(m => (m.id === sid ? { ...m, text: e.full_response, streaming: false } : m))
);
setIsSending(false);
// Speak the assistant reply via TTS. Do not speak if the user is
// already recording again (PTT pressed before the reply arrived).
if (e.full_response && !pttActiveRef.current) {
log('[ios] TTS: speaking assistant reply len=%d', e.full_response.length);
speak(e.full_response).catch((err: unknown) => {
logErr('[ios] TTS speak error: %o', err);
});
}
},
onError: (e: ChatErrorEvent) => {
logErr(
'[ios] chat error thread_id=%s type=%s message=%s',
e.thread_id,
e.error_type,
e.message
);
streamingIdRef.current = null;
setIsSending(false);
setMessages(prev => [
...prev,
{
id: `err-${Date.now()}`,
role: 'assistant' as const,
text: 'Something went wrong. Please try again.',
streaming: false,
},
]);
},
});
return unsub;
}, []);
// -- PTT event subscription ------------------------------------------------
useEffect(() => {
let unlistenPartial: (() => void) | undefined;
let unlistenError: (() => void) | undefined;
onTranscriptPartial(text => {
log('[ios] PTT partial text_len=%d', text.length);
setPartialText(text);
})
.then((fn: () => void) => {
unlistenPartial = fn;
})
.catch((err: unknown) => logErr('[ios] PTT partial listener setup failed: %o', err));
onPttError(err => {
logErr('[ios] PTT error code=%s message=%s', err.code, err.message);
// An interruption may have stopped the recorder without onPointerUp
// being called — reset PTT state so the button is not stuck active.
if (pttActiveRef.current) {
pttActiveRef.current = false;
setPttActive(false);
setPartialText('');
}
setToast(err.message);
setTimeout(() => setToast(null), 4000);
})
.then((fn: () => void) => {
unlistenError = fn;
})
.catch((err: unknown) => logErr('[ios] PTT error listener setup failed: %o', err));
return () => {
unlistenPartial?.();
unlistenError?.();
};
}, []);
// -- shared send (declared before PTT handlers so it is in scope) -----------
const sendMessage = useCallback(async (text: string) => {
log('[ios] sendMessage len=%d thread_id=%s', text.length, IOS_THREAD_ID);
const userMsg: Message = { id: `user-${Date.now()}`, role: 'user', text };
const assistantId = `asst-${Date.now()}`;
streamingIdRef.current = assistantId;
setMessages(prev => [
...prev,
userMsg,
{ id: assistantId, role: 'assistant', text: '', streaming: true },
]);
setIsSending(true);
try {
await chatSend({ threadId: IOS_THREAD_ID, message: text, model: IOS_CHAT_MODEL });
log('[ios] chatSend enqueued thread_id=%s', IOS_THREAD_ID);
} catch (err) {
logErr('[ios] chatSend failed: %o', err);
streamingIdRef.current = null;
setIsSending(false);
setMessages(prev =>
prev.map(m =>
m.id === assistantId
? { ...m, text: 'Failed to send. Check your connection.', streaming: false }
: m
)
);
}
}, []);
// -- PTT handlers ----------------------------------------------------------
const handlePttDown = useCallback(() => {
if (isSending) return;
log('[ios] PTT down — starting listening');
// Cancel any in-progress TTS before starting a new recording.
cancelSpeech().catch((err: unknown) =>
logErr('[ios] cancelSpeech on PTT down failed: %o', err)
);
pttActiveRef.current = true;
setPttActive(true);
setPartialText('');
startListening().catch((err: unknown) => {
logErr('[ios] startListening failed: %o', err);
pttActiveRef.current = false;
setPttActive(false);
const msg = err instanceof Error ? err.message : String(err);
setToast(msg);
setTimeout(() => setToast(null), 4000);
});
}, [isSending]);
const handlePttUp = useCallback(() => {
if (!pttActiveRef.current) return;
log('[ios] PTT up — stopping listening');
pttActiveRef.current = false;
setPttActive(false);
stopListening()
.then((result: { text: string; isFinal: boolean }) => {
const text = result.text.trim();
setPartialText('');
log('[ios] PTT transcript text_len=%d', text.length);
if (!text) return;
void sendMessage(text);
})
.catch((err: unknown) => {
logErr('[ios] stopListening failed: %o', err);
setPartialText('');
});
}, [sendMessage]);
const handleSend = useCallback(
async (e: FormEvent) => {
e.preventDefault();
const text = inputText.trim();
if (!text || isSending) return;
setInputText('');
// Cancel any active TTS when the user types a new message.
cancelSpeech().catch(() => undefined);
await sendMessage(text);
},
[inputText, isSending, sendMessage]
);
function handleDisconnect() {
log('[ios] disconnecting — clearing profile and navigating to /pair');
const profiles = listProfiles();
profiles.forEach(p => deleteProfile(p.id));
navigate('/pair', { replace: true });
}
return (
<div className="flex flex-col h-screen bg-[#0f1117] text-white overflow-hidden relative">
{/* Header */}
<div className="flex items-center justify-between px-4 pt-safe-top py-3 border-b border-white/10 shrink-0">
<div className="flex flex-col">
<span className="text-xs text-white/40 uppercase tracking-wide">Connected to</span>
<span className="text-sm font-medium text-white/90 truncate max-w-[200px]">
{pairedLabel}
</span>
</div>
<button
type="button"
onClick={handleDisconnect}
className="px-3 py-1.5 rounded-lg text-xs text-red-400 border border-red-400/30
active:opacity-70 transition-opacity">
Disconnect
</button>
</div>
{/* Mascot canvas */}
<div className="flex-1 flex items-center justify-center overflow-hidden min-h-0 py-4">
<div className="w-full max-w-xs aspect-square">
<YellowMascot face={face} arm="none" size="100%" />
</div>
</div>
{/* Transcript */}
<MascotChatTranscript messages={messages} />
{/* Toast */}
{toast && <Toast message={toast} onDismiss={() => setToast(null)} />}
{/* Input row */}
<div className="shrink-0 border-t border-white/10 px-4 pb-safe-bottom py-3">
<form onSubmit={e => void handleSend(e)} className="flex items-center gap-3">
{/* PTT button — Layer 6 live implementation */}
<PTTButton
active={pttActive}
partialText={partialText}
onDown={handlePttDown}
onUp={handlePttUp}
/>
{/* Text input */}
<input
type="text"
value={inputText}
onChange={e => setInputText(e.target.value)}
disabled={isSending}
placeholder={isSending ? 'Thinking...' : 'Type a message...'}
className="flex-1 bg-white/10 text-white placeholder-white/30 rounded-xl
px-4 py-3 text-sm outline-none border border-white/10
focus:border-[#4A83DD]/60 transition-colors
disabled:opacity-50"
/>
{/* Send button */}
<button
type="submit"
disabled={!inputText.trim() || isSending}
aria-label="Send message"
className="w-10 h-10 rounded-xl bg-[#4A83DD] flex items-center justify-center
disabled:opacity-30 active:opacity-70 transition-opacity shrink-0">
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
<path
d="M2 9h14M10 3l6 6-6 6"
stroke="white"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
</form>
</div>
</div>
);
};
+229
View File
@@ -0,0 +1,229 @@
/**
* PairScreen tests — happy path + error states.
*
* Mocks:
* - @tauri-apps/plugin-barcode-scanner: controlled scan() return
* - services/transport/TransportManager: controlled isHealthy()
* - services/transport/profileStore: spy on saveProfile
* - lib/platform: forced iOS
* - react-router-dom: mock useNavigate
*/
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router-dom';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { clearTestPlatform, setTestPlatform } from '../../lib/platform';
import { PairScreen } from './PairScreen';
// -- module mocks ------------------------------------------------------------
const mockScan = vi.fn();
vi.mock('@tauri-apps/plugin-barcode-scanner', () => ({
// Include Format enum so PairScreen can import and use Format.QRCode.
Format: {
QRCode: 'QR_CODE',
UPC_A: 'UPC_A',
EAN8: 'EAN_8',
EAN13: 'EAN_13',
Code39: 'CODE_39',
Code93: 'CODE_93',
Code128: 'CODE_128',
},
scan: (args: unknown) => mockScan(args),
}));
const mockNavigate = vi.fn();
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual<typeof import('react-router-dom')>('react-router-dom');
return { ...actual, useNavigate: () => mockNavigate };
});
const mockSaveProfile = vi.fn();
vi.mock('../../services/transport/profileStore', () => ({
saveProfile: (profile: unknown) => mockSaveProfile(profile),
getProfile: vi.fn(),
listProfileIds: vi.fn(() => []),
listProfiles: vi.fn(() => []),
deleteProfile: vi.fn(),
}));
const mockGetTransport = vi.fn();
const mockIsHealthy = vi.fn();
vi.mock('../../services/transport/TransportManager', () => ({
createTransportManager: vi.fn(() => ({
getTransport: mockGetTransport,
close: vi.fn().mockResolvedValue(undefined),
reset: vi.fn().mockResolvedValue(undefined),
})),
}));
// -- helpers -----------------------------------------------------------------
function buildPairUrl(
overrides: Partial<{ cid: string; pt: string; cpk: string; rpc: string; exp: number }> = {}
): string {
const futureSecs = Math.floor(Date.now() / 1000) + 300; // 5 min from now
const params = new URLSearchParams({
cid: overrides.cid ?? 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567',
pt: overrides.pt ?? 'dGhpcyBpcyBhIHRva2Vu',
cpk: overrides.cpk ?? 'MCowBQYDK2VuAyEAtestpubkey',
exp: String(overrides.exp ?? futureSecs),
});
if (overrides.rpc) params.set('rpc', overrides.rpc);
return `openhuman://pair?${params.toString()}`;
}
function renderPairScreen() {
return render(
<MemoryRouter initialEntries={['/pair']}>
<PairScreen />
</MemoryRouter>
);
}
// -- setup / teardown --------------------------------------------------------
beforeEach(() => {
setTestPlatform('ios');
mockScan.mockReset();
mockNavigate.mockReset();
mockSaveProfile.mockReset();
mockGetTransport.mockReset();
mockIsHealthy.mockReset();
});
afterEach(() => {
clearTestPlatform();
vi.clearAllMocks();
});
// -- tests -------------------------------------------------------------------
describe('PairScreen', () => {
it('renders welcome copy and scan button', () => {
renderPairScreen();
expect(screen.getByText(/pair with your desktop/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /scan qr code/i })).toBeInTheDocument();
});
it('happy path: valid QR -> saves profile -> navigates to /human', async () => {
const pairUrl = buildPairUrl();
mockScan.mockResolvedValueOnce({ content: pairUrl });
mockIsHealthy.mockResolvedValue(true);
mockGetTransport.mockResolvedValue({
kind: 'tunnel',
isHealthy: mockIsHealthy,
close: vi.fn().mockResolvedValue(undefined),
});
renderPairScreen();
await userEvent.click(screen.getByRole('button', { name: /scan qr code/i }));
await waitFor(() => {
expect(mockSaveProfile).toHaveBeenCalledOnce();
});
const savedProfile = mockSaveProfile.mock.calls[0][0];
expect(savedProfile.kind).toBe('tunnel');
expect(savedProfile.channelId).toBe('ABCDEFGHIJKLMNOPQRSTUVWXYZ234567');
expect(savedProfile.pairingToken).toBeTruthy();
// Sensitive fields: just check they exist, not the value.
expect(typeof savedProfile.devicePrivkey).toBe('string');
expect(savedProfile.devicePrivkey.length).toBeGreaterThan(0);
await waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith('/human', { replace: true });
});
});
it('expired QR -> shows expired message, no navigation', async () => {
const expiredUrl = buildPairUrl({ exp: Math.floor(Date.now() / 1000) - 10 });
mockScan.mockResolvedValueOnce({ content: expiredUrl });
renderPairScreen();
await userEvent.click(screen.getByRole('button', { name: /scan qr code/i }));
await waitFor(() => {
expect(screen.getByText(/qr code expired/i)).toBeInTheDocument();
});
expect(mockNavigate).not.toHaveBeenCalled();
expect(mockSaveProfile).not.toHaveBeenCalled();
});
it('invalid QR URL -> shows error message', async () => {
mockScan.mockResolvedValueOnce({ content: 'https://example.com/not-a-pair-url' });
renderPairScreen();
await userEvent.click(screen.getByRole('button', { name: /scan qr code/i }));
await waitFor(() => {
expect(screen.getByText(/invalid qr code/i)).toBeInTheDocument();
});
expect(mockNavigate).not.toHaveBeenCalled();
});
it('QR missing required fields -> shows error', async () => {
// Missing pt (pairingToken)
const badUrl = 'openhuman://pair?cid=ABCDEF&cpk=testkey&exp=9999999999';
mockScan.mockResolvedValueOnce({ content: badUrl });
renderPairScreen();
await userEvent.click(screen.getByRole('button', { name: /scan qr code/i }));
await waitFor(() => {
expect(screen.getByText(/invalid qr code/i)).toBeInTheDocument();
});
});
it('transport unhealthy -> shows connection error', async () => {
const pairUrl = buildPairUrl();
mockScan.mockResolvedValueOnce({ content: pairUrl });
mockIsHealthy.mockResolvedValue(false);
mockGetTransport.mockResolvedValue({
kind: 'tunnel',
isHealthy: mockIsHealthy,
close: vi.fn().mockResolvedValue(undefined),
});
renderPairScreen();
await userEvent.click(screen.getByRole('button', { name: /scan qr code/i }));
await waitFor(() => {
expect(screen.getByText(/could not reach the desktop/i)).toBeInTheDocument();
});
expect(mockNavigate).not.toHaveBeenCalled();
});
it('scan rejection -> shows camera error', async () => {
mockScan.mockRejectedValueOnce(new Error('Camera denied'));
renderPairScreen();
await userEvent.click(screen.getByRole('button', { name: /scan qr code/i }));
await waitFor(() => {
expect(screen.getByText(/camera scan failed/i)).toBeInTheDocument();
});
});
it('retry button resets to idle and allows another scan', async () => {
mockScan.mockRejectedValueOnce(new Error('Camera denied'));
renderPairScreen();
await userEvent.click(screen.getByRole('button', { name: /scan qr code/i }));
await waitFor(() => {
expect(screen.getByText(/camera scan failed/i)).toBeInTheDocument();
});
// Click retry scan
const retryBtn = screen.getByRole('button', { name: /retry scan/i });
mockScan.mockRejectedValueOnce(new Error('Camera denied again'));
await userEvent.click(retryBtn);
// Error should reappear after second failure
await waitFor(() => {
expect(screen.getByText(/camera scan failed/i)).toBeInTheDocument();
});
});
});
+290
View File
@@ -0,0 +1,290 @@
/**
* PairScreen — iOS-only QR pairing flow.
*
* Flow:
* 1. User taps "Scan QR code" → barcode scanner opens.
* 2. App parses the openhuman://pair?... URL from the scan result.
* 3. Validates fields; rejects expired codes.
* 4. Generates a fresh device X25519 keypair.
* 5. Builds a ConnectionProfile and saves it via profileStore.
* 6. Probes the channel via TransportManager.isHealthy().
* 7. On success: navigates to /human (mobile tab bar shows Human/Chat/Settings).
* 8. On failure: shows error + retry button.
*
* No dynamic imports. Static import of barcode scanner — caller guard is
* the iOS-only route; desktop never renders this component.
*/
import { Format, scan } from '@tauri-apps/plugin-barcode-scanner';
import debug from 'debug';
import { type FC, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { base64urlEncode, generateKeypair } from '../../lib/tunnel/crypto';
import { type ConnectionProfile, saveProfile } from '../../services/transport/profileStore';
import { createTransportManager } from '../../services/transport/TransportManager';
import { BACKEND_URL } from '../../utils/config';
const log = debug('ios:pair-screen');
const logErr = debug('ios:pair-screen:error');
// -- QR payload parsing -------------------------------------------------------
interface PairPayload {
channelId: string;
pairingToken: string;
corePubkey: string;
rpcUrl?: string;
expiresAt: number; // unix timestamp
}
function parsePairUrl(raw: string): PairPayload | null {
log('[ios] parsing pair URL len=%d', raw.length);
try {
// Accept both the openhuman:// deep-link and a plain https:// fallback.
// Normalise openhuman:// → https:// so URL() can parse it.
const normalised = raw.startsWith('openhuman://')
? raw.replace('openhuman://', 'https://openhuman.app/')
: raw;
const url = new URL(normalised);
const p = url.searchParams;
const channelId = p.get('cid');
const pairingToken = p.get('pt');
const corePubkey = p.get('cpk');
const rpcRaw = p.get('rpc');
const expRaw = p.get('exp');
if (!channelId || !pairingToken || !corePubkey || !expRaw) {
logErr(
'[ios] missing required QR fields cid=%s pt_len=%d cpk_len=%d exp=%s',
channelId,
pairingToken?.length ?? 0,
corePubkey?.length ?? 0,
expRaw
);
return null;
}
const expiresAt = parseInt(expRaw, 10);
if (isNaN(expiresAt)) {
logErr('[ios] invalid exp field: %s', expRaw);
return null;
}
return { channelId, pairingToken, corePubkey, rpcUrl: rpcRaw ?? undefined, expiresAt };
} catch (err) {
logErr('[ios] URL parse error: %o', err);
return null;
}
}
// -- component ---------------------------------------------------------------
type ScreenState =
| { kind: 'idle' }
| { kind: 'scanning' }
| { kind: 'error'; message: string }
| { kind: 'expired' }
| { kind: 'connecting' }
| { kind: 'success' };
export const PairScreen: FC = () => {
const navigate = useNavigate();
const [state, setState] = useState<ScreenState>({ kind: 'idle' });
async function startScan(): Promise<void> {
log('[ios] starting QR scan');
setState({ kind: 'scanning' });
try {
const result = await scan({ windowed: false, formats: [Format.QRCode] });
const rawContent = result.content;
log('[ios] scan result received len=%d', rawContent.length);
await handleScanResult(rawContent);
} catch (err) {
logErr('[ios] scan error: %o', err);
setState({
kind: 'error',
message: 'Camera scan failed. Check camera permissions and try again.',
});
}
}
async function handleScanResult(raw: string): Promise<void> {
// 1. Parse
const payload = parsePairUrl(raw);
if (!payload) {
setState({
kind: 'error',
message: 'Invalid QR code. Make sure you are scanning an OpenHuman pairing code.',
});
return;
}
// 2. Check expiry
const nowSecs = Math.floor(Date.now() / 1000);
if (payload.expiresAt < nowSecs) {
log('[ios] QR expired at=%d now=%d', payload.expiresAt, nowSecs);
setState({ kind: 'expired' });
return;
}
log('[ios] QR valid; expires in %ds', payload.expiresAt - nowSecs);
// 3. Generate device keypair
const keypair = generateKeypair();
const devicePubkeyB64 = base64urlEncode(keypair.publicKey);
const devicePrivkeyB64 = base64urlEncode(keypair.secretKey);
log('[ios] device keypair generated pubkey_len=%d', devicePubkeyB64.length);
// NOTE: Never log the private key value — log length only.
log('[ios] device privkey_len=%d (not logged)', devicePrivkeyB64.length);
// 4. Build and persist profile
const profile: ConnectionProfile = {
id: payload.channelId,
label: 'Desktop',
kind: 'tunnel',
channelId: payload.channelId,
pairingToken: payload.pairingToken,
corePubkey: payload.corePubkey,
rpcUrl: payload.rpcUrl,
devicePrivkey: devicePrivkeyB64,
// sessionToken will be written after the tunnel handshake completes.
};
saveProfile(profile);
log('[ios] profile saved id=%s kind=%s', profile.id, profile.kind);
// 5. Probe transport health
setState({ kind: 'connecting' });
try {
const manager = createTransportManager(profile, { backendSocketUrl: BACKEND_URL });
const transport = await manager.getTransport();
const healthy = await transport.isHealthy();
if (!healthy) {
logErr('[ios] transport health check failed kind=%s', transport.kind);
setState({
kind: 'error',
message: 'Could not reach the desktop. Make sure both devices are online and try again.',
});
return;
}
log('[ios] transport healthy kind=%s; navigating to /human', transport.kind);
} catch (err) {
logErr('[ios] transport probe error: %o', err);
setState({
kind: 'error',
message: 'Connection failed. Make sure the desktop app is running and try again.',
});
return;
}
// 6. Navigate to the Human page now that pairing is established.
setState({ kind: 'success' });
navigate('/human', { replace: true });
}
return (
<div className="flex flex-col items-center justify-center min-h-screen bg-[#0f1117] text-white px-6 py-12">
<div className="flex flex-col items-center gap-8 max-w-sm w-full">
{/* Logo / icon area */}
<div className="w-20 h-20 rounded-2xl bg-[#4A83DD] flex items-center justify-center shadow-lg">
<svg
width="40"
height="40"
viewBox="0 0 40 40"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true">
<rect x="4" y="4" width="14" height="14" rx="2" fill="white" fillOpacity="0.9" />
<rect x="22" y="4" width="14" height="14" rx="2" fill="white" fillOpacity="0.9" />
<rect x="4" y="22" width="14" height="14" rx="2" fill="white" fillOpacity="0.9" />
<rect x="26" y="26" width="6" height="6" rx="1" fill="white" fillOpacity="0.9" />
<rect x="22" y="22" width="6" height="6" rx="1" fill="white" fillOpacity="0.6" />
<rect x="32" y="22" width="6" height="6" rx="1" fill="white" fillOpacity="0.6" />
</svg>
</div>
{/* Heading */}
<div className="text-center">
<h1 className="text-2xl font-semibold text-white mb-2">Pair with your desktop</h1>
<p className="text-sm text-white/60 leading-relaxed">
Open OpenHuman on your desktop, go to Settings &gt; Devices, and tap &ldquo;Pair
phone&rdquo; to show the QR code.
</p>
</div>
{/* State-specific content */}
{state.kind === 'idle' && (
<button
onClick={() => void startScan()}
className="w-full py-4 rounded-xl bg-[#4A83DD] text-white font-medium text-base
active:opacity-80 transition-opacity shadow-md">
Scan QR code
</button>
)}
{state.kind === 'scanning' && (
<p className="text-white/60 text-sm text-center animate-pulse">Scanner opening...</p>
)}
{state.kind === 'connecting' && (
<p className="text-white/60 text-sm text-center animate-pulse">
Connecting to desktop...
</p>
)}
{state.kind === 'success' && (
<p className="text-green-400 text-sm text-center">Connected! Loading...</p>
)}
{state.kind === 'expired' && (
<div className="flex flex-col items-center gap-4 text-center">
<p className="text-amber-400 text-sm">
QR code expired. Ask the desktop to regenerate the code.
</p>
<button
onClick={() => setState({ kind: 'idle' })}
className="w-full py-3 rounded-xl border border-white/20 text-white/80 text-sm
active:opacity-70 transition-opacity">
Try again
</button>
</div>
)}
{state.kind === 'error' && (
<div className="flex flex-col items-center gap-4 text-center w-full">
<p className="text-red-400 text-sm">{state.message}</p>
<button
onClick={() => void startScan()}
className="w-full py-3 rounded-xl bg-[#4A83DD]/80 text-white text-sm
active:opacity-70 transition-opacity">
Retry scan
</button>
<button
onClick={() => setState({ kind: 'idle' })}
className="text-white/40 text-xs underline-offset-2 underline">
Cancel
</button>
</div>
)}
{/* Step hint */}
{(state.kind === 'idle' || state.kind === 'error' || state.kind === 'expired') && (
<div className="flex flex-col gap-3 w-full mt-2">
{[
'Open OpenHuman on desktop',
'Go to Settings > Devices',
'Tap "Pair phone" to show QR',
].map((step, i) => (
<div key={step} className="flex items-center gap-3 text-white/50 text-xs">
<span className="w-5 h-5 rounded-full border border-white/20 flex items-center justify-center text-[10px] shrink-0">
{i + 1}
</span>
{step}
</div>
))}
</div>
)}
</div>
</div>
);
};
+24
View File
@@ -8,6 +8,7 @@ import { redactRpcUrlForLog } from '../utils/redactRpcUrlForLog';
import { sanitizeError } from '../utils/sanitize';
import { isTauri as coreIsTauri } from '../utils/tauriCommands/common';
import { normalizeRpcMethod } from './rpcMethods';
import type { CoreTransport } from './transport/CoreTransport';
interface CoreRpcRelayRequest {
method: string;
@@ -63,6 +64,22 @@ let resolvedCoreRpcToken: string | null = null;
let didResolveCoreRpcToken = false;
let resolvingCoreRpcToken: Promise<string | null> | null = null;
// ---------------------------------------------------------------------------
// Active transport override (used by iOS / remote profiles)
// ---------------------------------------------------------------------------
/** Active transport set by TransportManager for non-local profiles. */
let _activeTransport: CoreTransport | null = null;
/**
* Override the active transport used by `callCoreRpc`.
* Set to null to revert to the default local HTTP path.
*/
export function setActiveCoreTransport(transport: CoreTransport | null): void {
_activeTransport = transport;
coreRpcLog('[transport] active transport set kind=%s', transport?.kind ?? 'null');
}
/**
* Stable classification of an RPC failure. Callers (hooks, providers, Sentry
* filters) should branch on `kind` — never on raw message regexes. The shape
@@ -456,6 +473,13 @@ export async function callCoreRpc<T>({
}
const normalizedMethod = normalizeRpcMethod(method);
// Dispatch through active transport when one is set (e.g. tunnel / cloud).
if (_activeTransport) {
coreRpcLog('[transport] dispatching via %s method=%s', _activeTransport.kind, normalizedMethod);
return _activeTransport.call<T>(normalizedMethod, params ?? {});
}
const effectiveTimeoutMs = resolvePerCallTimeoutMs(timeoutMs);
const payload: JsonRpcRequestBody = {
jsonrpc: '2.0',
@@ -0,0 +1,83 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { CloudHttpTransport } from './CloudHttpTransport';
const URL = 'https://cloud.openhuman.app/rpc';
function mockFetchOnce(
body: unknown,
init: { ok?: boolean; status?: number; statusText?: string } = {}
) {
const fetchMock = vi
.fn()
.mockResolvedValue({
ok: init.ok ?? true,
status: init.status ?? 200,
statusText: init.statusText ?? 'OK',
json: async () => body,
text: async () => (typeof body === 'string' ? body : JSON.stringify(body)),
});
vi.stubGlobal('fetch', fetchMock);
return fetchMock;
}
describe('CloudHttpTransport', () => {
afterEach(() => vi.unstubAllGlobals());
it('omits Authorization when no bearer token is configured', async () => {
const fetchMock = mockFetchOnce({ jsonrpc: '2.0', id: 1, result: 'ok' });
const t = new CloudHttpTransport(URL);
await t.call('openhuman.ping', {});
const headers = (fetchMock.mock.calls[0][1] as RequestInit).headers as Record<string, string>;
expect(headers).not.toHaveProperty('Authorization');
});
it('attaches Authorization: Bearer when a token is configured', async () => {
const fetchMock = mockFetchOnce({ jsonrpc: '2.0', id: 1, result: 'ok' });
const t = new CloudHttpTransport(URL, 'abc.def.ghi');
await t.call('openhuman.ping', {});
const headers = (fetchMock.mock.calls[0][1] as RequestInit).headers as Record<string, string>;
expect(headers.Authorization).toBe('Bearer abc.def.ghi');
});
it('throws on HTTP failure', async () => {
mockFetchOnce('nope', { ok: false, status: 502, statusText: 'Bad Gateway' });
const t = new CloudHttpTransport(URL);
await expect(t.call('openhuman.ping', {})).rejects.toThrow(/HTTP 502: nope/);
});
it('surfaces JSON-RPC error.message', async () => {
mockFetchOnce({ jsonrpc: '2.0', id: 1, error: { code: 1, message: 'cloud rpc broke' } });
const t = new CloudHttpTransport(URL);
await expect(t.call('openhuman.fail', {})).rejects.toThrow('cloud rpc broke');
});
it('throws when result key is missing', async () => {
mockFetchOnce({ jsonrpc: '2.0', id: 1 });
const t = new CloudHttpTransport(URL);
await expect(t.call('openhuman.ping', {})).rejects.toThrow('response missing result');
});
it('isHealthy + stream + close behave like LAN transport', async () => {
mockFetchOnce({ jsonrpc: '2.0', id: 1, result: 'pong' });
const t = new CloudHttpTransport(URL);
await expect(t.isHealthy()).resolves.toBe(true);
mockFetchOnce({ jsonrpc: '2.0', id: 2, result: 7 });
const yielded: number[] = [];
for await (const v of t.stream<number>('openhuman.value', {})) yielded.push(v);
expect(yielded).toEqual([7]);
await expect(t.close()).resolves.toBeUndefined();
});
it('isHealthy returns false on transport failure', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('connect refused')));
const t = new CloudHttpTransport(URL);
await expect(t.isHealthy()).resolves.toBe(false);
});
});
@@ -0,0 +1,113 @@
/**
* CloudHttpTransport — HTTP transport for user-configured cloud cores.
*
* Identical wire format to LanHttpTransport but uses a different auth header
* (Bearer token from the connection profile) and a longer default timeout.
*/
import debug from 'debug';
import type { CoreTransport } from './CoreTransport';
const log = debug('transport:cloud');
const logErr = debug('transport:cloud:error');
interface JsonRpcRequestBody {
jsonrpc: '2.0';
id: number;
method: string;
params: unknown;
}
interface JsonRpcResponse<T> {
jsonrpc?: string;
id?: number | string | null;
result?: T;
error?: { code: number; message: string; data?: unknown };
}
let _nextId = 1;
export class CloudHttpTransport implements CoreTransport {
readonly kind = 'cloud-http' as const;
constructor(
private readonly rpcUrl: string,
private readonly bearerToken: string | null = null,
private readonly timeoutMs: number = 30_000
) {
log('[transport:cloud] created rpcUrl=%s token=%s', rpcUrl, bearerToken ? 'set' : 'none');
}
async call<T>(method: string, params: unknown, opts?: { signal?: AbortSignal }): Promise<T> {
const id = _nextId++;
const payload: JsonRpcRequestBody = { jsonrpc: '2.0', id, method, params: params ?? {} };
log('[transport:cloud] → %s id=%d', method, id);
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (this.bearerToken) {
headers.Authorization = `Bearer ${this.bearerToken}`;
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.timeoutMs);
opts?.signal?.addEventListener('abort', () => controller.abort());
let response: Response;
try {
response = await fetch(this.rpcUrl, {
method: 'POST',
headers,
body: JSON.stringify(payload),
signal: controller.signal,
});
} catch (err) {
if (controller.signal.aborted) {
throw new Error(`[transport:cloud] ${method} timed out after ${this.timeoutMs}ms`);
}
throw err;
} finally {
clearTimeout(timeoutId);
}
if (!response.ok) {
const text = await response.text();
throw new Error(`[transport:cloud] HTTP ${response.status}: ${text || response.statusText}`);
}
const json = (await response.json()) as JsonRpcResponse<T>;
if (json.error) {
logErr('[transport:cloud] ← %s error: %s', method, json.error.message);
throw new Error(json.error.message ?? 'Cloud RPC returned an error');
}
if (!Object.prototype.hasOwnProperty.call(json, 'result')) {
throw new Error('[transport:cloud] response missing result');
}
log('[transport:cloud] ← %s id=%d ok', method, id);
return json.result as T;
}
async *stream<T>(
method: string,
params: unknown,
opts?: { signal?: AbortSignal }
): AsyncIterable<T> {
const result = await this.call<T>(method, params, opts);
yield result;
}
async isHealthy(): Promise<boolean> {
try {
await this.call('openhuman.ping', {}, { signal: AbortSignal.timeout(5000) });
return true;
} catch {
return false;
}
}
async close(): Promise<void> {
log('[transport:cloud] close (no-op)');
}
}
@@ -0,0 +1,27 @@
/**
* CoreTransport interface — all core-RPC transports implement this.
*
* Implementations:
* LocalTransport — local HTTP to the in-process core sidecar
* LanHttpTransport — HTTP to a LAN-accessible core URL
* TunnelTransport — socket.io E2E-encrypted relay
* CloudHttpTransport — HTTP to a user-configured cloud core URL
*/
export type TransportKind = 'local' | 'lan-http' | 'tunnel' | 'cloud-http';
export interface CoreTransport {
readonly kind: TransportKind;
/** Make a JSON-RPC call and return the result. */
call<T>(method: string, params: unknown, opts?: { signal?: AbortSignal }): Promise<T>;
/** Stream a JSON-RPC method that produces sequential chunks. */
stream<T>(method: string, params: unknown, opts?: { signal?: AbortSignal }): AsyncIterable<T>;
/** Probe the transport with a ping. */
isHealthy(): Promise<boolean>;
/** Tear down the transport. */
close(): Promise<void>;
}
@@ -0,0 +1,108 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { LanHttpTransport } from './LanHttpTransport';
const URL = 'http://192.168.1.10:7788/rpc';
function mockFetchOnce(
body: unknown,
init: { ok?: boolean; status?: number; statusText?: string } = {}
) {
const fetchMock = vi
.fn()
.mockResolvedValue({
ok: init.ok ?? true,
status: init.status ?? 200,
statusText: init.statusText ?? 'OK',
json: async () => body,
text: async () => (typeof body === 'string' ? body : JSON.stringify(body)),
});
vi.stubGlobal('fetch', fetchMock);
return fetchMock;
}
describe('LanHttpTransport', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('issues a POST to the configured rpcUrl with JSON-RPC body', async () => {
const fetchMock = mockFetchOnce({ jsonrpc: '2.0', id: 1, result: { ok: true } });
const t = new LanHttpTransport(URL);
const result = await t.call<{ ok: boolean }>('openhuman.ping', { who: 'me' });
expect(result).toEqual({ ok: true });
expect(fetchMock).toHaveBeenCalledWith(
URL,
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({ 'Content-Type': 'application/json' }),
})
);
const body = JSON.parse((fetchMock.mock.calls[0][1] as RequestInit).body as string);
expect(body).toMatchObject({ jsonrpc: '2.0', method: 'openhuman.ping', params: { who: 'me' } });
expect(typeof body.id).toBe('number');
});
it('throws when the server returns an HTTP error', async () => {
mockFetchOnce('Server is sad', { ok: false, status: 500, statusText: 'Server Error' });
const t = new LanHttpTransport(URL);
await expect(t.call('openhuman.ping', {})).rejects.toThrow(/HTTP 500: Server is sad/);
});
it('throws the JSON-RPC error message when present', async () => {
mockFetchOnce({ jsonrpc: '2.0', id: 1, error: { code: -32601, message: 'Method not found' } });
const t = new LanHttpTransport(URL);
await expect(t.call('openhuman.unknown', {})).rejects.toThrow('Method not found');
});
it('throws when result key is missing', async () => {
mockFetchOnce({ jsonrpc: '2.0', id: 1 });
const t = new LanHttpTransport(URL);
await expect(t.call('openhuman.ping', {})).rejects.toThrow('response missing result');
});
it('treats AbortController-induced abort as a timeout', async () => {
vi.useRealTimers();
const fetchMock = vi.fn().mockImplementation(
(_url: string, init?: RequestInit) =>
new Promise((_resolve, reject) => {
init?.signal?.addEventListener('abort', () => {
const e = new Error('aborted');
e.name = 'AbortError';
reject(e);
});
})
);
vi.stubGlobal('fetch', fetchMock);
const t = new LanHttpTransport(URL, 30);
await expect(t.call('openhuman.ping', {})).rejects.toThrow(/timed out after 30ms/);
});
it('isHealthy returns true on a successful ping', async () => {
mockFetchOnce({ jsonrpc: '2.0', id: 1, result: 'pong' });
const t = new LanHttpTransport(URL);
await expect(t.isHealthy()).resolves.toBe(true);
});
it('isHealthy returns false when ping rejects', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('boom')));
const t = new LanHttpTransport(URL);
await expect(t.isHealthy()).resolves.toBe(false);
});
it('stream yields the single result from call()', async () => {
mockFetchOnce({ jsonrpc: '2.0', id: 1, result: 42 });
const t = new LanHttpTransport(URL);
const yielded: number[] = [];
for await (const v of t.stream<number>('openhuman.value', {})) yielded.push(v);
expect(yielded).toEqual([42]);
});
it('close() is a no-op', async () => {
const t = new LanHttpTransport(URL);
await expect(t.close()).resolves.toBeUndefined();
});
});
@@ -0,0 +1,107 @@
/**
* LanHttpTransport — HTTP transport pointing at a rpcUrl from a Connection profile.
*
* Same JSON-RPC wire format as LocalTransport, but no bearer token (LAN
* connections rely on network-level trust + the session token in the profile).
*/
import debug from 'debug';
import type { CoreTransport } from './CoreTransport';
const log = debug('transport:lan');
const logErr = debug('transport:lan:error');
interface JsonRpcRequestBody {
jsonrpc: '2.0';
id: number;
method: string;
params: unknown;
}
interface JsonRpcResponse<T> {
jsonrpc?: string;
id?: number | string | null;
result?: T;
error?: { code: number; message: string; data?: unknown };
}
let _nextId = 1;
export class LanHttpTransport implements CoreTransport {
readonly kind = 'lan-http' as const;
constructor(
private readonly rpcUrl: string,
private readonly timeoutMs: number = 10_000
) {
log('[transport:lan] created rpcUrl=%s', rpcUrl);
}
async call<T>(method: string, params: unknown, opts?: { signal?: AbortSignal }): Promise<T> {
const id = _nextId++;
const payload: JsonRpcRequestBody = { jsonrpc: '2.0', id, method, params: params ?? {} };
log('[transport:lan] → %s id=%d', method, id);
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.timeoutMs);
opts?.signal?.addEventListener('abort', () => controller.abort());
let response: Response;
try {
response = await fetch(this.rpcUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
signal: controller.signal,
});
} catch (err) {
if (controller.signal.aborted) {
throw new Error(`[transport:lan] ${method} timed out after ${this.timeoutMs}ms`);
}
throw err;
} finally {
clearTimeout(timeoutId);
}
if (!response.ok) {
const text = await response.text();
throw new Error(`[transport:lan] HTTP ${response.status}: ${text || response.statusText}`);
}
const json = (await response.json()) as JsonRpcResponse<T>;
if (json.error) {
logErr('[transport:lan] ← %s error: %s', method, json.error.message);
throw new Error(json.error.message ?? 'LAN RPC returned an error');
}
if (!Object.prototype.hasOwnProperty.call(json, 'result')) {
throw new Error('[transport:lan] response missing result');
}
log('[transport:lan] ← %s id=%d ok', method, id);
return json.result as T;
}
async *stream<T>(
method: string,
params: unknown,
opts?: { signal?: AbortSignal }
): AsyncIterable<T> {
const result = await this.call<T>(method, params, opts);
yield result;
}
async isHealthy(): Promise<boolean> {
try {
await this.call('openhuman.ping', {}, { signal: AbortSignal.timeout(2000) });
return true;
} catch {
return false;
}
}
async close(): Promise<void> {
log('[transport:lan] close (no-op)');
}
}
@@ -0,0 +1,108 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { LocalTransport } from './LocalTransport';
const URL = 'http://127.0.0.1:7788/rpc';
function mockFetchOnce(
body: unknown,
init: { ok?: boolean; status?: number; statusText?: string } = {}
) {
const fetchMock = vi
.fn()
.mockResolvedValue({
ok: init.ok ?? true,
status: init.status ?? 200,
statusText: init.statusText ?? 'OK',
json: async () => body,
text: async () => (typeof body === 'string' ? body : JSON.stringify(body)),
});
vi.stubGlobal('fetch', fetchMock);
return fetchMock;
}
const getUrl = () => Promise.resolve(URL);
const getToken =
(token: string | null = 'tok-xyz') =>
() =>
Promise.resolve(token);
describe('LocalTransport', () => {
afterEach(() => vi.unstubAllGlobals());
it('resolves URL+token lazily and attaches Authorization when token present', async () => {
const fetchMock = mockFetchOnce({ jsonrpc: '2.0', id: 1, result: 'ok' });
const t = new LocalTransport(getUrl, getToken('tok-xyz'));
await t.call('openhuman.ping', { a: 1 });
expect(fetchMock).toHaveBeenCalledWith(URL, expect.anything());
const headers = (fetchMock.mock.calls[0][1] as RequestInit).headers as Record<string, string>;
expect(headers.Authorization).toBe('Bearer tok-xyz');
expect(headers['Content-Type']).toBe('application/json');
});
it('omits Authorization when token getter returns null', async () => {
const fetchMock = mockFetchOnce({ jsonrpc: '2.0', id: 1, result: 'ok' });
const t = new LocalTransport(getUrl, getToken(null));
await t.call('openhuman.ping', {});
const headers = (fetchMock.mock.calls[0][1] as RequestInit).headers as Record<string, string>;
expect(headers).not.toHaveProperty('Authorization');
});
it('throws on HTTP failure', async () => {
mockFetchOnce('upstream timeout', { ok: false, status: 504, statusText: 'Gateway Timeout' });
const t = new LocalTransport(getUrl, getToken());
await expect(t.call('openhuman.ping', {})).rejects.toThrow(/HTTP 504: upstream timeout/);
});
it('surfaces JSON-RPC error.message', async () => {
mockFetchOnce({ jsonrpc: '2.0', id: 1, error: { code: 1, message: 'local rpc broke' } });
const t = new LocalTransport(getUrl, getToken());
await expect(t.call('openhuman.fail', {})).rejects.toThrow('local rpc broke');
});
it('throws when result key is missing', async () => {
mockFetchOnce({ jsonrpc: '2.0', id: 1 });
const t = new LocalTransport(getUrl, getToken());
await expect(t.call('openhuman.ping', {})).rejects.toThrow('response missing result');
});
it('merges a caller-supplied abort signal with the internal timeout', async () => {
const fetchMock = vi.fn().mockImplementation(
(_url: string, init?: RequestInit) =>
new Promise((_resolve, reject) => {
init?.signal?.addEventListener('abort', () => {
const e = new Error('aborted');
e.name = 'AbortError';
reject(e);
});
})
);
vi.stubGlobal('fetch', fetchMock);
const t = new LocalTransport(getUrl, getToken(), 30);
await expect(t.call('openhuman.ping', {})).rejects.toThrow(/timed out after 30ms/);
});
it('isHealthy + stream + close', async () => {
mockFetchOnce({ jsonrpc: '2.0', id: 1, result: 'pong' });
const t = new LocalTransport(getUrl, getToken());
await expect(t.isHealthy()).resolves.toBe(true);
mockFetchOnce({ jsonrpc: '2.0', id: 2, result: 'v' });
const yielded: string[] = [];
for await (const v of t.stream<string>('openhuman.value', {})) yielded.push(v);
expect(yielded).toEqual(['v']);
await expect(t.close()).resolves.toBeUndefined();
});
it('isHealthy returns false when fetch rejects', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('ECONNREFUSED')));
const t = new LocalTransport(getUrl, getToken());
await expect(t.isHealthy()).resolves.toBe(false);
});
});
@@ -0,0 +1,118 @@
/**
* LocalTransport — wraps the existing local-spawn HTTP path.
*
* This is the transport used on desktop when the core sidecar is running
* locally. It delegates all RPC logic to the getCoreRpcUrl / getCoreRpcToken
* resolution that already lives in coreRpcClient.ts.
*/
import debug from 'debug';
import type { CoreTransport } from './CoreTransport';
const log = debug('transport:local');
const logErr = debug('transport:local:error');
interface JsonRpcRequestBody {
jsonrpc: '2.0';
id: number;
method: string;
params: unknown;
}
interface JsonRpcResponse<T> {
jsonrpc?: string;
id?: number | string | null;
result?: T;
error?: { code: number; message: string; data?: unknown };
}
let _nextId = 1;
export class LocalTransport implements CoreTransport {
readonly kind = 'local' as const;
constructor(
private readonly getRpcUrl: () => Promise<string>,
private readonly getToken: () => Promise<string | null>,
private readonly timeoutMs: number = 30_000
) {}
async call<T>(method: string, params: unknown, opts?: { signal?: AbortSignal }): Promise<T> {
const id = _nextId++;
const payload: JsonRpcRequestBody = { jsonrpc: '2.0', id, method, params: params ?? {} };
const [rpcUrl, token] = await Promise.all([this.getRpcUrl(), this.getToken()]);
log('[transport:local] → %s id=%d', method, id);
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (token) {
headers.Authorization = `Bearer ${token}`;
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.timeoutMs);
// Merge caller signal with timeout signal.
opts?.signal?.addEventListener('abort', () => controller.abort());
let response: Response;
try {
response = await fetch(rpcUrl, {
method: 'POST',
headers,
body: JSON.stringify(payload),
signal: controller.signal,
});
} catch (err) {
if (controller.signal.aborted) {
throw new Error(`[transport:local] ${method} timed out after ${this.timeoutMs}ms`);
}
throw err;
} finally {
clearTimeout(timeoutId);
}
if (!response.ok) {
const text = await response.text();
throw new Error(`[transport:local] HTTP ${response.status}: ${text || response.statusText}`);
}
const json = (await response.json()) as JsonRpcResponse<T>;
if (json.error) {
logErr('[transport:local] ← %s error: %s', method, json.error.message);
throw new Error(json.error.message ?? 'Core RPC returned an error');
}
if (!Object.prototype.hasOwnProperty.call(json, 'result')) {
throw new Error('[transport:local] response missing result');
}
log('[transport:local] ← %s id=%d ok', method, id);
return json.result as T;
}
async *stream<T>(
method: string,
params: unknown,
opts?: { signal?: AbortSignal }
): AsyncIterable<T> {
// Local HTTP doesn't support streaming natively in this project.
// Fall back to a single call and yield the result.
const result = await this.call<T>(method, params, opts);
yield result;
}
async isHealthy(): Promise<boolean> {
try {
await this.call('openhuman.ping', {}, { signal: AbortSignal.timeout(3000) });
return true;
} catch {
return false;
}
}
async close(): Promise<void> {
// Stateless HTTP — nothing to tear down.
log('[transport:local] close (no-op)');
}
}
@@ -0,0 +1,122 @@
/**
* Unit tests for TransportManager race semantics.
*/
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { ConnectionProfile } from './profileStore';
import { TransportManager } from './TransportManager';
// -- helpers -----------------------------------------------------------------
function makeProfile(
kind: ConnectionProfile['kind'],
overrides: Partial<ConnectionProfile> = {}
): ConnectionProfile {
return {
id: 'test-profile',
label: 'Test',
kind,
rpcUrl: kind === 'lan' || kind === 'cloud' ? 'http://localhost:7788/rpc' : undefined,
channelId: kind === 'tunnel' ? 'CHANNEL001' : undefined,
corePubkey: kind === 'tunnel' ? 'dGVzdHB1YmtleXRlc3RwdWJrZXl0ZXN0cHVia2V5' : undefined,
sessionToken: kind === 'tunnel' ? 'tok123' : undefined,
...overrides,
};
}
// -- tests -------------------------------------------------------------------
describe('TransportManager', () => {
// Stub LanHttpTransport and TunnelTransport constructors.
afterEach(() => {
vi.restoreAllMocks();
});
it('local profile returns LocalTransport', async () => {
const profile = makeProfile('local');
const manager = new TransportManager(
profile,
() => Promise.resolve('http://localhost:7788/rpc'),
() => Promise.resolve('tok'),
'http://backend:3000'
);
const t = await manager.getTransport();
expect(t.kind).toBe('local');
await manager.close();
});
it('lan profile returns LanHttpTransport', async () => {
const profile = makeProfile('lan');
const manager = new TransportManager(
profile,
() => Promise.resolve(''),
() => Promise.resolve(null),
''
);
const t = await manager.getTransport();
expect(t.kind).toBe('lan-http');
await manager.close();
});
it('cloud profile returns CloudHttpTransport', async () => {
const profile = makeProfile('cloud');
const manager = new TransportManager(
profile,
() => Promise.resolve(''),
() => Promise.resolve(null),
''
);
const t = await manager.getTransport();
expect(t.kind).toBe('cloud-http');
await manager.close();
});
it('tunnel profile without rpcUrl uses tunnel only', async () => {
const profile = makeProfile('tunnel', { rpcUrl: undefined });
const manager = new TransportManager(
profile,
() => Promise.resolve(''),
() => Promise.resolve(null),
'http://backend:3000'
);
const t = await manager.getTransport();
expect(t.kind).toBe('tunnel');
await manager.close();
});
it('throws when tunnel profile missing channelId', async () => {
const profile = makeProfile('tunnel', { channelId: undefined });
const manager = new TransportManager(
profile,
() => Promise.resolve(''),
() => Promise.resolve(null),
'http://backend:3000'
);
await expect(manager.getTransport()).rejects.toThrow(/channelId/);
});
it('throws when tunnel profile missing token', async () => {
const profile = makeProfile('tunnel', { sessionToken: undefined, pairingToken: undefined });
const manager = new TransportManager(
profile,
() => Promise.resolve(''),
() => Promise.resolve(null),
'http://backend:3000'
);
await expect(manager.getTransport()).rejects.toThrow(/sessionToken|pairingToken/);
});
it('reset() clears cached transport and allows re-selection', async () => {
const profile = makeProfile('local');
const manager = new TransportManager(
profile,
() => Promise.resolve('http://localhost:7788/rpc'),
() => Promise.resolve('tok'),
''
);
const t1 = await manager.getTransport();
await manager.reset();
const t2 = await manager.getTransport();
expect(t1.kind).toBe(t2.kind);
});
});

Some files were not shown because too many files have changed in this diff Show More