Skip to content

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

Draft
senamakel wants to merge 8 commits into
tinyhumansai:mainfrom
senamakel:feat/ios-client
Draft

feat(ios): iOS client with QR pairing, E2E-encrypted tunnel, and push-to-talk#1420
senamakel wants to merge 8 commits into
tinyhumansai:mainfrom
senamakel:feat/ios-client

Conversation

@senamakel
Copy link
Copy Markdown
Member

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

  • Tests added or updated (transport, tunnel, devices, iOS, PTT suites -- see coverage statement above).
  • 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).
  • Coverage matrix: N/A for this layer (build scripts, CI, docs, catalog).
  • 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

  • pnpm --filter openhuman-app format:check -- clean
  • pnpm typecheck -- clean
  • Focused tests: Vitest 1957 passed; cargo about_app 20 passed
  • Rust fmt/check: cargo fmt --all + cargo check on all three Cargo.toml -- clean
  • 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

senamakel added 8 commits May 9, 2026 11:37
Introduces `src/openhuman/devices/` — the Rust core pairing domain that
lets iOS clients connect to the desktop core over the tinyhumans backend
tunnel. Includes:

- `crypto.rs`: X25519 keypair gen + XChaCha20-Poly1305 frame sealing/opening
  with sliding-window replay protection (5 unit tests: round-trip, tamper,
  replay, version mismatch, DH parity).
- `store.rs`: SQLite persistence for paired devices using the existing
  `with_connection` pattern (4 unit tests).
- `tunnel_client.rs`: emit_register / emit_connect / emit_frame helpers
  over the global SocketManager; one-shot ack registry for tunnel:registered.
- `rpc.rs`: devices_create_pairing / devices_list / devices_revoke handlers
  with in-memory PENDING_KEYPAIRS / PENDING_SESSIONS / PEER_STATUS maps
  (3 unit tests).
- `bus.rs`: DeviceTunnelSubscriber — resolves tunnel:registered acks, updates
  PEER_STATUS on peer-status events, completes X25519 handshake on tunnel:frame,
  persists PairedDevice and publishes DevicePaired.
- `schemas.rs`: controller schemas + registered controllers following the
  cron/schemas.rs pattern (8 unit tests).
- Wired into src/core/all.rs (build_registered_controllers +
  build_declared_controller_schemas + namespace_description).
- Added DomainEvent variants DevicePaired / DeviceRevoked / DevicePeerOnline /
  DevicePeerOffline / DeviceTunnelFrame / DeviceTunnelRegistered.
- Added tunnel:peer-status / tunnel:frame / tunnel:registered / tunnel:evicted
  dispatch in socket/event_handlers.rs.
- Added x25519-dalek = "2" to Cargo.toml (chacha20poly1305 already present).
…2 of iOS PR)

Part A — Rust follow-ups from Layer 1:
- Encrypt pending-pairing private keys in SecretStore (enc2: ChaCha20-Poly1305),
  persist via PERSISTED_KEYPAIRS, drop on devices_revoke.
- Implement sealed handshake frame in bus.rs: version 0x01 || eph_pub(32) ||
  nonce(24) || ciphertext+tag; backward-compat plaintext fallback for pre-L2.
- Publish DomainEvent::DeviceRevoked from devices_revoke.
- Register DeviceTunnelSubscriber at startup (register_device_tunnel_subscriber
  called from jsonrpc.rs bootstrap path).
- Expose LAN RPC URL from OPENHUMAN_CORE_RPC_PORT env (defaults to 7788).

Part B — TS transport refactor:
- app/src/lib/tunnel/crypto.ts: generateKeypair, deriveSharedSecret, seal/open
  (XChaCha20-Poly1305), sealHandshake/openHandshake, ReplayTracker. Uses
  @noble/curves/ed25519 x25519 and @noble/ciphers/chacha xchacha20poly1305.
- app/src/lib/tunnel/framing.ts: chunk, Reassembler, TokenBucket (100 fps burst).
- app/src/services/transport/CoreTransport.ts: interface (call/stream/isHealthy/close).
- app/src/services/transport/LocalTransport.ts: wraps existing local HTTP path.
- app/src/services/transport/LanHttpTransport.ts: HTTP to LAN rpcUrl.
- app/src/services/transport/TunnelTransport.ts: socket.io E2E encrypted relay.
- app/src/services/transport/CloudHttpTransport.ts: HTTP to cloud core.
- app/src/services/transport/TransportManager.ts: races LAN vs Tunnel on iOS.
- app/src/services/transport/profileStore.ts: localStorage desktop backend
  with iOS stub (TODO Layer 5).
- app/src/services/coreRpcClient.ts: setActiveCoreTransport() override point;
  dispatch through active transport when set; local HTTP path unchanged.
- Tests: crypto (27 tests), framing (8 tests), TransportManager (7 tests),
  TunnelTransport (4 tests). 1885 total, 0 regressions.
Adds a Devices settings panel that lists paired phones and a Pair iPhone
modal that calls devices_create_pairing, renders a QR code with the
openhuman://pair?cid&pt&cpk&rpc&exp URL, and polls devices_list to detect
handshake completion.

- New DevicesPanel + PairPhoneModal under app/src/components/settings/panels/
- Settings nav extended with a "Devices" entry
- qrcode.react dependency for SVG QR rendering
- Poll-based pairing detection (2s interval) + 3s auto-close on success
- Expiry handling with regen flow
Replace the desktop-only compile_error! guard with proper
#[cfg(not(target_os = "ios"))] / #[cfg(target_os = "ios")] gating so
the Tauri shell compiles for aarch64-apple-ios without any CEF, CDP,
or core-sidecar symbols being in scope.

Desktop behaviour is completely unchanged. iOS gets a minimal WRY-based
Tauri builder with tauri-plugin-barcode-scanner registered (needed for
Layer 5 QR pairing UI). A TODO(Layer 6) marker is left for
tauri-plugin-ptt.

Modules gated as desktop-only (not(target_os = "ios")):
  cdp, cef_preflight, cef_profile, core_process, core_rpc,
  dictation_hotkeys, discord_scanner, fake_camera, file_logging,
  gmessages_scanner, imessage_scanner, mascot_native_window,
  meet_audio, meet_call, meet_scanner, meet_video, native_notifications,
  notification_settings, process_kill, process_recovery, screen_capture,
  slack_scanner, telegram_scanner, webview_accounts, webview_apis,
  whatsapp_scanner, window_state

iOS-only: tauri-plugin-barcode-scanner

Shared (both platforms): app_quit

Other changes:
- app/src-tauri/Cargo.toml: [target.'cfg(target_os = "ios")'.dependencies]
  with tauri-plugin-barcode-scanner = "2"
- app/src-tauri/capabilities/ios.json: new iOS capability file
- app/src-tauri/tauri.conf.json: add bundle.iOS block with frameworks +
  minimumSystemVersion
- app/package.json: add tauri:ios:init / tauri:ios:dev / tauri:ios:build
  scripts using stock @tauri-apps/cli (not the vendored CEF CLI)
- main.rs: gate the cef_entry_point macro to desktop; add plain fn main()
  for iOS

Verification:
  cargo check --manifest-path app/src-tauri/Cargo.toml   → PASS
  cargo check --manifest-path app/src-tauri/Cargo.toml
    --target aarch64-apple-ios                           → fails only on
    C build-script deps (aws-lc-sys, ring, objc2-exception-helper) that
    require the iphoneos SDK — no errors from our Rust source files.
    Prerequisite: rustup target add aarch64-apple-ios +
    Xcode with iOS platform (xcode-select --install + iOS SDK).
  pnpm compile                                            → PASS
Adds the iOS-only React app surface: platform detection, routing branch,
QR pairing screen, and full-screen mascot chat screen wired through the
existing TransportManager and profileStore.

- lib/platform.ts: isIOS detection (userAgent + isTauri()) with
  setTestPlatform / clearTestPlatform test hooks
- AppRoutes.tsx: top-level iOS branch -> AppRoutesIOS (/pair + /mascot)
- AppRoutesIOS.tsx: minimal iOS routes with profile-gated redirect
- App.tsx: gate SocketProvider and desktop chrome on !isIOS;
  split AppShellDesktop from AppShellIOS to respect rules-of-hooks
- pages/ios/PairScreen.tsx: QR scan via tauri-plugin-barcode-scanner,
  openhuman://pair URL parsing + expiry check, X25519 keypair generation,
  ConnectionProfile save, TransportManager health probe -> navigate /mascot
- pages/ios/MascotScreen.tsx: full-screen YellowMascot + scrolling chat
  transcript, text input, disabled PTT placeholder (Layer 6 hook point),
  Disconnect button that clears profile and returns to /pair
- services/transport/profileStore.ts: real iOS backend (pragmatic
  interim: localStorage, app-sandboxed WKWebView); replaces stub from Layer 2;
  SECURITY TODO comment for Layer 7 Keychain migration
- package.json: add @tauri-apps/plugin-barcode-scanner@^2

Tests: 27 new Vitest tests across platform, PairScreen, MascotScreen,
profileStore (desktop + iOS paths). Full suite: 1937 passing, 0 regressions.
Introduces packages/tauri-plugin-ptt/ — a Tauri v2 plugin that wraps
AVAudioEngine + SFSpeechRecognizer (STT) and AVSpeechSynthesizer (TTS)
on iOS, and wires the PTT button in MascotScreen.tsx.

Plugin layout:
- Rust: lib.rs + commands.rs + mobile.rs + error.rs + models.rs
  On iOS: delegates to PttMobile<R> via PluginHandle::run_mobile_plugin.
  On non-iOS: PttHandle stub returns NotSupported for all commands.
- Swift: PTTPlugin / PTTRecorder / PTTSpeaker / AudioSessionManager
  AVAudioSession category: .playAndRecord + .defaultToSpeaker + BT options
  (pattern from chat4000/Sources/Services/VoiceNotes.swift)
  SFSpeechRecognizer: single task per session, torn down on stopListening.
  Interruption + route-change observers stop the recorder gracefully.
  App-background observer stops recording and cancels TTS.
- guest-js: startListening / stopListening / speak / cancelSpeech / listVoices
  + onTranscriptPartial / onTranscriptFinal / onTtsStarted / onTtsEnded / onError

Events: ptt://transcript-partial, ptt://transcript-final, ptt://tts-started,
ptt://tts-ended, ptt://error.

MascotScreen changes:
- PTTButton is now live: onPointerDown -> startListening, onPointerUp ->
  stopListening -> chatSend with transcript.
- Partial captions shown above button while recording.
- cancelSpeech called on each PTT press to cancel any active TTS.
- chat_done handler speaks the assistant reply via AVSpeechSynthesizer.
- Error toast for permission denial / interruption.

Quality gates:
- cargo check packages/tauri-plugin-ptt: clean
- cargo check app/src-tauri: clean (desktop unaffected)
- pnpm typecheck: clean
- pnpm lint: 0 new errors
- pnpm test:unit: 218/218 pass (13 new guest-js tests + 7 new PTT UI tests)
…cked Sendable

SFSpeechRecognitionTask exposes no `result` property; the recognitionTask
result handler is the only source of partial transcripts. Mirror the latest
into a stored property so stopListening() can return the final text.

AVSpeechSynthesizer is not Sendable; PTTSpeaker serializes access through
the plugin command actor, so the unchecked conformance is sound.
…8 of iOS PR)

- app/src-tauri/Info.ios.plist: NSCamera, NSMicrophone, NSSpeechRecognition
  privacy strings for the iOS bundle (copied to gen/apple after tauri ios init)
- scripts/ios-init.sh: wrapper that runs tauri ios init and prints next steps
- package.json (root): adds tauri:ios:init / tauri:ios:dev / tauri:ios:build
  top-level delegates so iOS builds work from the repo root like pnpm dev
- .github/workflows/ios-compile.yml: macOS CI sanity check (hard gate on host
  cargo check + tsc + Vitest; soft gate on aarch64-apple-ios cargo check)
- src/openhuman/about_app/: new Mobile category + three capabilities:
  mobile.device_pairing, mobile.ios_client, mobile.push_to_talk
- docs/ios/SETUP.md: setup guide covering init, Info.plist, pairing flow,
  transport selection, security notes, known limitations, CI policy
- docs/ARCHITECTURE.md: iOS client subsection (transport, pairing, key paths,
  security, backend dependency)
- CLAUDE.md: iOS client section after Runtime scope (transport strategies,
  key paths, build entry, docs pointer)
- PR_DESCRIPTION.md: draft PR description following project template
- cargo fmt / prettier auto-fixes across ptt plugin and MascotScreen
- PairPhoneModal polling kept (no domain-event bridge exists yet); existing
  TODO comment documents the follow-up
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 9, 2026

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: f507392c-b835-4790-9ce8-bd667f978e0d

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Comment @coderabbitai help to get the list of available commands and usage tips.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant