feat(ios): iOS client with QR pairing, E2E-encrypted tunnel, and push-to-talk (#1420)
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -5174,6 +5174,7 @@ dependencies = [
|
||||
"whatsapp-rust-ureq-http-client",
|
||||
"whisper-rs",
|
||||
"wiremock",
|
||||
"x25519-dalek",
|
||||
"xz2",
|
||||
"zip",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -3,6 +3,7 @@ dist
|
||||
coverage
|
||||
app
|
||||
src-tauri
|
||||
src-tauri-mobile
|
||||
rust-core
|
||||
skills
|
||||
*.config.js
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
target/
|
||||
gen/
|
||||
@@ -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"
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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/`.
|
||||
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 8.8 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 6.6 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 7.8 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 791 B |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 45 KiB |
@@ -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");
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
openhuman_mobile::run();
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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>]`.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 > Devices, and tap “Pair
|
||||
phone” 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>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||