diff --git a/.github/workflows/android-compile.yml b/.github/workflows/android-compile.yml new file mode 100644 index 0000000000..8261859a22 --- /dev/null +++ b/.github/workflows/android-compile.yml @@ -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 diff --git a/.github/workflows/ios-compile.yml b/.github/workflows/ios-compile.yml new file mode 100644 index 0000000000..9574e7ca28 --- /dev/null +++ b/.github/workflows/ios-compile.yml @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index 627cf0ac81..c24f5347a2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/Cargo.lock b/Cargo.lock index b91c345158..a43d2d5a89 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5174,6 +5174,7 @@ dependencies = [ "whatsapp-rust-ureq-http-client", "whisper-rs", "wiremock", + "x25519-dalek", "xz2", "zip", ] diff --git a/Cargo.toml b/Cargo.toml index 41821b045b..86a01d8c44 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 diff --git a/PR_DESCRIPTION.md b/PR_DESCRIPTION.md new file mode 100644 index 0000000000..5b4a9b8135 --- /dev/null +++ b/PR_DESCRIPTION.md @@ -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 diff --git a/app/.prettierignore b/app/.prettierignore index 8e4bde1b6b..cded8a49f4 100644 --- a/app/.prettierignore +++ b/app/.prettierignore @@ -3,6 +3,7 @@ dist coverage app src-tauri +src-tauri-mobile rust-core skills *.config.js diff --git a/app/package.json b/app/package.json index 6078999265..8295df192f 100644 --- a/app/package.json +++ b/app/package.json @@ -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", diff --git a/app/src-tauri-mobile/.gitignore b/app/src-tauri-mobile/.gitignore new file mode 100644 index 0000000000..2f2c74da8d --- /dev/null +++ b/app/src-tauri-mobile/.gitignore @@ -0,0 +1,2 @@ +target/ +gen/ diff --git a/app/src-tauri-mobile/Cargo.lock b/app/src-tauri-mobile/Cargo.lock new file mode 100644 index 0000000000..1698af3533 --- /dev/null +++ b/app/src-tauri-mobile/Cargo.lock @@ -0,0 +1,4647 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "atk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" +dependencies = [ + "atk-sys", + "glib", + "libc", +] + +[[package]] +name = "atk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +dependencies = [ + "serde", +] + +[[package]] +name = "cairo-rs" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +dependencies = [ + "bitflags 2.11.1", + "cairo-sys-rs", + "glib", + "libc", + "once_cell", + "thiserror 1.0.69", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "camino" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "cargo_toml" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77" +dependencies = [ + "serde", + "toml 0.9.12+spec-1.1.0", +] + +[[package]] +name = "cc" +version = "1.2.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfb" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +dependencies = [ + "byteorder", + "fnv", + "uuid", +] + +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link 0.2.1", +] + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "time", + "version_check", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" +dependencies = [ + "bitflags 2.11.1", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.11.1", + "core-foundation", + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "cssparser" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "ctor" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "352d39c2f7bef1d6ad73db6f5160efcaed66d94ef8c6c573a8410c00bf909a98" +dependencies = [ + "ctor-proc-macro", + "dtor", +] + +[[package]] +name = "ctor-proc-macro" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "data-url" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", + "serde_core", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.117", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.11.1", + "block2", + "libc", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dlopen2" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" +dependencies = [ + "dlopen2_derive", + "libc", + "once_cell", + "winapi", +] + +[[package]] +name = "dlopen2_derive" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dom_query" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" +dependencies = [ + "bit-set", + "cssparser", + "foldhash 0.2.0", + "html5ever", + "precomputed-hash", + "selectors", + "tendril", +] + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +dependencies = [ + "serde", +] + +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "dtor" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1057d6c64987086ff8ed0fd3fbf377a6b7d205cc7715868cd401705f715cbe4" +dependencies = [ + "dtor-proc-macro", +] + +[[package]] +name = "dtor-proc-macro" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "embed-resource" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31a88c8d26de40ed18fe748c547845aa39de1db3afd958f8cb91579f3644bcb" +dependencies = [ + "cc", + "memchr", + "rustc_version", + "toml 1.1.2+spec-1.1.0", + "vswhom", + "winreg", +] + +[[package]] +name = "embed_plist" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" + +[[package]] +name = "env_filter" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e90c2accc4b07a8456ea0debdc2e7587bdd890680d71173a15d4ae604f6eef" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0621c04f2196ac3f488dd583365b9c09be011a4ab8b9f37248ffcc8f6198b56a" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "jiff", + "log", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gdk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", + "once_cell", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkwayland-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69" +dependencies = [ + "gdk-sys", + "glib-sys", + "gobject-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "gio" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "once_cell", + "pin-project-lite", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "gio-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "winapi", +] + +[[package]] +name = "glib" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" +dependencies = [ + "bitflags 2.11.1", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "once_cell", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "glib-macros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" +dependencies = [ + "heck 0.4.1", + "proc-macro-crate 2.0.2", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "glib-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "gobject-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gtk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" +dependencies = [ + "atk", + "cairo-rs", + "field-offset", + "futures-channel", + "gdk", + "gdk-pixbuf", + "gio", + "glib", + "gtk-sys", + "gtk3-macros", + "libc", + "pango", + "pkg-config", +] + +[[package]] +name = "gtk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" +dependencies = [ + "atk-sys", + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk3-macros" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "html5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2" +dependencies = [ + "log", + "markup5ever", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ico" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" +dependencies = [ + "byteorder", + "png 0.17.16", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "infer" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" +dependencies = [ + "cfb", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "javascriptcore-rs" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" +dependencies = [ + "bitflags 1.3.2", + "glib", + "javascriptcore-rs-sys", +] + +[[package]] +name = "javascriptcore-rs-sys" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "jiff" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f00b5dbd620d61dfdcb6007c9c1f6054ebd75319f163d886a9055cec1155073d" +dependencies = [ + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[package]] +name = "jiff-static" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e000de030ff8022ea1da3f466fbb0f3a809f5e51ed31f6dd931c35181ad8e6d7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "js-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "json-patch" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863726d7afb6bc2590eeff7135d923545e5e964f004c2ccf8716c25e70a86f08" +dependencies = [ + "jsonptr", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "jsonptr" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dea2b27dd239b2556ed7a25ba842fe47fd602e7fc7433c2a8d6106d4d9edd70" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "keyboard-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +dependencies = [ + "bitflags 2.11.1", + "serde", + "unicode-segmentation", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libappindicator" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" +dependencies = [ + "glib", + "gtk", + "gtk-sys", + "libappindicator-sys", + "log", +] + +[[package]] +name = "libappindicator-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" +dependencies = [ + "gtk-sys", + "libloading", + "once_cell", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + +[[package]] +name = "libredox" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +dependencies = [ + "libc", +] + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "markup5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "muda" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47a2e3dff89cd322c66647942668faee0a2b1f88ea6cbb4d374b4a8d7e92528c" +dependencies = [ + "crossbeam-channel", + "dpi", + "gtk", + "keyboard-types", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.18", + "windows-sys 0.61.2", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.11.1", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", + "objc2-exception-helper", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.11.1", + "block2", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.11.1", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.11.1", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.11.1", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-location" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.11.1", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-exception-helper" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a" +dependencies = [ + "cc", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.11.1", + "block2", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.11.1", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.11.1", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.11.1", + "block2", + "objc2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-location", + "objc2-core-text", + "objc2-foundation", + "objc2-quartz-core", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-web-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" +dependencies = [ + "bitflags 2.11.1", + "block2", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openhuman-mobile" +version = "0.54.10" +dependencies = [ + "env_logger", + "log", + "serde", + "serde_json", + "tauri", + "tauri-build", + "tauri-plugin-barcode-scanner", + "tauri-plugin-ptt", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "pango" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" +dependencies = [ + "gio", + "glib", + "libc", + "once_cell", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link 0.2.1", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plist" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1" +dependencies = [ + "base64 0.22.1", + "indexmap 2.14.0", + "quick-xml", + "serde", + "time", +] + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.11.1", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro-crate" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" +dependencies = [ + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.11+spec-1.1.0", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quick-xml" +version = "0.39.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.11.1", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "reqwest" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "indexmap 1.9.3", + "schemars_derive", + "serde", + "serde_json", + "url", + "uuid", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.117", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "selectors" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" +dependencies = [ + "bitflags 2.11.1", + "cssparser", + "derive_more", + "log", + "new_debug_unreachable", + "phf", + "phf_codegen", + "precomputed-hash", + "rustc-hash", + "servo_arc", + "smallvec", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-untagged" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058" +dependencies = [ + "erased-serde", + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_with" +version = "3.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e72c1c2cb7b223fafb600a619537a871c2818583d619401b785e7c0b746ccde2" +dependencies = [ + "base64 0.22.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b90c488738ecb4fb0262f41f43bc40efc5868d9fb744319ddf5f5317f417bfac" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serialize-to-javascript" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04f3666a07a197cdb77cdf306c32be9b7f598d7060d50cfd4d5aa04bfd92f6c5" +dependencies = [ + "serde", + "serde_json", + "serialize-to-javascript-impl", +] + +[[package]] +name = "serialize-to-javascript-impl" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "servo_arc" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "softbuffer" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" +dependencies = [ + "bytemuck", + "js-sys", + "ndk", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "objc2-quartz-core", + "raw-window-handle", + "redox_syscall", + "tracing", + "wasm-bindgen", + "web-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "soup3" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f" +dependencies = [ + "futures-channel", + "gio", + "glib", + "libc", + "soup3-sys", +] + +[[package]] +name = "soup3-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "string_cache" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", +] + +[[package]] +name = "string_cache_codegen" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "swift-rs" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4057c98e2e852d51fdcfca832aac7b571f6b351ad159f9eda5db1655f8d0c4d7" +dependencies = [ + "base64 0.21.7", + "serde", + "serde_json", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck 0.5.0", + "pkg-config", + "toml 0.8.2", + "version-compare", +] + +[[package]] +name = "tao" +version = "0.35.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9" +dependencies = [ + "bitflags 2.11.1", + "block2", + "core-foundation", + "core-graphics", + "crossbeam-channel", + "dispatch2", + "dlopen2", + "dpi", + "gdkwayland-sys", + "gtk", + "jni", + "libc", + "log", + "ndk", + "ndk-sys", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "once_cell", + "parking_lot", + "percent-encoding", + "raw-window-handle", + "tao-macros", + "unicode-segmentation", + "url", + "windows", + "windows-core 0.61.2", + "windows-version", +] + +[[package]] +name = "tao-macros" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "tauri" +version = "2.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "437404997acf375d85f1177afa7e11bb971f274ed6a7b83a2a3e339015f4cc28" +dependencies = [ + "anyhow", + "bytes", + "cookie", + "data-url", + "dirs", + "dunce", + "embed_plist", + "getrandom 0.3.4", + "glob", + "gtk", + "heck 0.5.0", + "http", + "jni", + "libc", + "log", + "mime", + "muda", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "percent-encoding", + "plist", + "raw-window-handle", + "reqwest", + "serde", + "serde_json", + "serde_repr", + "serialize-to-javascript", + "swift-rs", + "tauri-build", + "tauri-macros", + "tauri-runtime", + "tauri-runtime-wry", + "tauri-utils", + "thiserror 2.0.18", + "tokio", + "tray-icon", + "url", + "webkit2gtk", + "webview2-com", + "window-vibrancy", + "windows", +] + +[[package]] +name = "tauri-build" +version = "2.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aa1f9055fc23919a54e4e125052bed16ed04aef0487086e758fe01a67b451c7" +dependencies = [ + "anyhow", + "cargo_toml", + "dirs", + "glob", + "heck 0.5.0", + "json-patch", + "schemars 0.8.22", + "semver", + "serde", + "serde_json", + "tauri-utils", + "tauri-winres", + "walkdir", +] + +[[package]] +name = "tauri-codegen" +version = "2.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4a0319528a025a38c4078e7dae2c446f4e63620ddb0659a643ede1cb38f90e9" +dependencies = [ + "base64 0.22.1", + "ico", + "json-patch", + "plist", + "png 0.17.16", + "proc-macro2", + "quote", + "semver", + "serde", + "serde_json", + "sha2", + "syn 2.0.117", + "tauri-utils", + "thiserror 2.0.18", + "time", + "url", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-macros" +version = "2.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae6cb4e3896c21d2f6da5b31251d2faea0153bba56ed0e970f918115dbee4924" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "tauri-codegen", + "tauri-utils", +] + +[[package]] +name = "tauri-plugin" +version = "2.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e126abc9e84e35cdfd01596140a73a1850cdb0df0a23acf0185776c30b469a6e" +dependencies = [ + "anyhow", + "glob", + "plist", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri-utils", + "walkdir", +] + +[[package]] +name = "tauri-plugin-barcode-scanner" +version = "2.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "485cbcf227f04117e930be748ea71d835900466dcd1d455d5ec284d36107a305" +dependencies = [ + "log", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", +] + +[[package]] +name = "tauri-plugin-ptt" +version = "0.1.0" +dependencies = [ + "log", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", +] + +[[package]] +name = "tauri-runtime" +version = "2.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48222d7116c8807eaa6fe2f372e023fae125084e61e6eca6d70b7961cdf129ef" +dependencies = [ + "cookie", + "dpi", + "gtk", + "http", + "jni", + "objc2", + "objc2-ui-kit", + "objc2-web-kit", + "raw-window-handle", + "serde", + "serde_json", + "tauri-utils", + "thiserror 2.0.18", + "url", + "webkit2gtk", + "webview2-com", + "windows", +] + +[[package]] +name = "tauri-runtime-wry" +version = "2.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b83849ee63ecb27a8e8d0fe51915ca215076914aca43f96db1179f0f415f6cd9" +dependencies = [ + "gtk", + "http", + "jni", + "log", + "objc2", + "objc2-app-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "softbuffer", + "tao", + "tauri-runtime", + "tauri-utils", + "url", + "webkit2gtk", + "webview2-com", + "windows", + "wry", +] + +[[package]] +name = "tauri-utils" +version = "2.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092379df9a707631978e6c56b1bc2401d387f01e2d4a3c123360d167bbb9aa95" +dependencies = [ + "anyhow", + "cargo_metadata", + "ctor", + "dom_query", + "dunce", + "glob", + "http", + "infer", + "json-patch", + "log", + "memchr", + "phf", + "plist", + "proc-macro2", + "quote", + "regex", + "schemars 0.8.22", + "semver", + "serde", + "serde-untagged", + "serde_json", + "serde_with", + "swift-rs", + "thiserror 2.0.18", + "toml 1.1.2+spec-1.1.0", + "url", + "urlpattern", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-winres" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6" +dependencies = [ + "dunce", + "embed-resource", + "toml 1.1.2+spec-1.1.0", +] + +[[package]] +name = "tendril" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4790fc369d5a530f4b544b094e31388b9b3a37c0f4652ade4505945f5660d24" +dependencies = [ + "new_debug_unreachable", + "utf-8", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap 2.14.0", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +dependencies = [ + "indexmap 2.14.0", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.3", +] + +[[package]] +name = "toml_datetime" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" +dependencies = [ + "indexmap 2.14.0", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.25.11+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.3", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow 1.0.3", +] + +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.11.1", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "tray-icon" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15edbb0d80583e85ee8df283410038e17314df5cba30da2087a54a85216c0773" +dependencies = [ + "crossbeam-channel", + "dirs", + "libappindicator", + "muda", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.18", + "windows-sys 0.61.2", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + +[[package]] +name = "unic-char-property" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" +dependencies = [ + "unic-char-range", +] + +[[package]] +name = "unic-char-range" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" + +[[package]] +name = "unic-common" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" + +[[package]] +name = "unic-ucd-ident" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987" +dependencies = [ + "unic-char-property", + "unic-char-range", + "unic-ucd-version", +] + +[[package]] +name = "unic-ucd-version" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" +dependencies = [ + "unic-common", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "urlpattern" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70acd30e3aa1450bc2eece896ce2ad0d178e9c079493819301573dae3c37ba6d" +dependencies = [ + "regex", + "serde", + "unic-ucd-ident", + "url", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vswhom" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" +dependencies = [ + "libc", + "vswhom-sys", +] + +[[package]] +name = "vswhom-sys" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.72" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap 2.14.0", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.11.1", + "hashbrown 0.15.5", + "indexmap 2.14.0", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web_atoms" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7cff6eef815df1834fd250e3a2ff436044d82a9f1bc1980ca1dbdf07effc538" +dependencies = [ + "phf", + "phf_codegen", + "string_cache", + "string_cache_codegen", +] + +[[package]] +name = "webkit2gtk" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793" +dependencies = [ + "bitflags 1.3.2", + "cairo-rs", + "gdk", + "gdk-sys", + "gio", + "gio-sys", + "glib", + "glib-sys", + "gobject-sys", + "gtk", + "gtk-sys", + "javascriptcore-rs", + "libc", + "once_cell", + "soup3", + "webkit2gtk-sys", +] + +[[package]] +name = "webkit2gtk-sys" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5" +dependencies = [ + "bitflags 1.3.2", + "cairo-sys-rs", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk-sys", + "javascriptcore-rs-sys", + "libc", + "pkg-config", + "soup3-sys", + "system-deps", +] + +[[package]] +name = "webview2-com" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" +dependencies = [ + "webview2-com-macros", + "webview2-com-sys", + "windows", + "windows-core 0.61.2", + "windows-implement", + "windows-interface", +] + +[[package]] +name = "webview2-com-macros" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "webview2-com-sys" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" +dependencies = [ + "thiserror 2.0.18", + "windows", + "windows-core 0.61.2", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "window-vibrancy" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c" +dependencies = [ + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "windows-sys 0.59.0", + "windows-version", +] + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core 0.61.2", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-version" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck 0.5.0", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck 0.5.0", + "indexmap 2.14.0", + "prettyplease", + "syn 2.0.117", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.11.1", + "indexmap 2.14.0", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap 2.14.0", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "wry" +version = "0.55.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "186f9871daa55fd9c016578b810d149de58367113db7fb72b462d2323ce19514" +dependencies = [ + "base64 0.22.1", + "block2", + "cookie", + "crossbeam-channel", + "dirs", + "dom_query", + "dpi", + "dunce", + "gtk", + "http", + "javascriptcore-rs", + "jni", + "libc", + "ndk", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "sha2", + "soup3", + "tao-macros", + "thiserror 2.0.18", + "url", + "webkit2gtk", + "webkit2gtk-sys", + "webview2-com", + "windows", + "windows-core 0.61.2", + "windows-version", +] + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/app/src-tauri-mobile/Cargo.toml b/app/src-tauri-mobile/Cargo.toml new file mode 100644 index 0000000000..c74807f239 --- /dev/null +++ b/app/src-tauri-mobile/Cargo.toml @@ -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" diff --git a/app/src-tauri-mobile/Info.plist b/app/src-tauri-mobile/Info.plist new file mode 100644 index 0000000000..da43cc5fc2 --- /dev/null +++ b/app/src-tauri-mobile/Info.plist @@ -0,0 +1,12 @@ + + + + + NSCameraUsageDescription + OpenHuman uses the camera to scan the pairing QR code from your desktop. + NSMicrophoneUsageDescription + OpenHuman uses the microphone for push-to-talk voice messages. + NSSpeechRecognitionUsageDescription + OpenHuman uses on-device speech recognition to transcribe your voice messages. + + diff --git a/app/src-tauri-mobile/build.rs b/app/src-tauri-mobile/build.rs new file mode 100644 index 0000000000..c9c1e181c7 --- /dev/null +++ b/app/src-tauri-mobile/build.rs @@ -0,0 +1,5 @@ +fn main() { + println!("cargo:rerun-if-changed=permissions"); + println!("cargo:rerun-if-changed=capabilities"); + tauri_build::build(); +} diff --git a/app/src-tauri-mobile/capabilities/default.json b/app/src-tauri-mobile/capabilities/default.json new file mode 100644 index 0000000000..e0d85b68df --- /dev/null +++ b/app/src-tauri-mobile/capabilities/default.json @@ -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" + ] +} diff --git a/app/src-tauri-mobile/capabilities/ios.json b/app/src-tauri-mobile/capabilities/ios.json new file mode 100644 index 0000000000..05e425653c --- /dev/null +++ b/app/src-tauri-mobile/capabilities/ios.json @@ -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" + ] +} diff --git a/app/src-tauri-mobile/icons/README.md b/app/src-tauri-mobile/icons/README.md new file mode 100644 index 0000000000..155d25059b --- /dev/null +++ b/app/src-tauri-mobile/icons/README.md @@ -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/_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/`. diff --git a/app/src-tauri-mobile/icons/android/mipmap-hdpi/ic_launcher.png b/app/src-tauri-mobile/icons/android/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000..14ef25da02 Binary files /dev/null and b/app/src-tauri-mobile/icons/android/mipmap-hdpi/ic_launcher.png differ diff --git a/app/src-tauri-mobile/icons/android/mipmap-mdpi/ic_launcher.png b/app/src-tauri-mobile/icons/android/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000000..a1e978fa54 Binary files /dev/null and b/app/src-tauri-mobile/icons/android/mipmap-mdpi/ic_launcher.png differ diff --git a/app/src-tauri-mobile/icons/android/mipmap-xhdpi/ic_launcher.png b/app/src-tauri-mobile/icons/android/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000000..0df845d7f4 Binary files /dev/null and b/app/src-tauri-mobile/icons/android/mipmap-xhdpi/ic_launcher.png differ diff --git a/app/src-tauri-mobile/icons/android/mipmap-xxhdpi/ic_launcher.png b/app/src-tauri-mobile/icons/android/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000000..d0ee56f449 Binary files /dev/null and b/app/src-tauri-mobile/icons/android/mipmap-xxhdpi/ic_launcher.png differ diff --git a/app/src-tauri-mobile/icons/android/mipmap-xxxhdpi/ic_launcher.png b/app/src-tauri-mobile/icons/android/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000000..f6e0655c19 Binary files /dev/null and b/app/src-tauri-mobile/icons/android/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/app/src-tauri-mobile/icons/icon.png b/app/src-tauri-mobile/icons/icon.png new file mode 100644 index 0000000000..d92136804f Binary files /dev/null and b/app/src-tauri-mobile/icons/icon.png differ diff --git a/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/100.png b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/100.png new file mode 100644 index 0000000000..a9fa9d6f92 Binary files /dev/null and b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/100.png differ diff --git a/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/102.png b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/102.png new file mode 100644 index 0000000000..93e3b35164 Binary files /dev/null and b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/102.png differ diff --git a/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/1024.png b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/1024.png new file mode 100644 index 0000000000..d92136804f Binary files /dev/null and b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/1024.png differ diff --git a/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/108.png b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/108.png new file mode 100644 index 0000000000..959e7dfbf9 Binary files /dev/null and b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/108.png differ diff --git a/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/114.png b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/114.png new file mode 100644 index 0000000000..1a95151bae Binary files /dev/null and b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/114.png differ diff --git a/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/120.png b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/120.png new file mode 100644 index 0000000000..697ff31d03 Binary files /dev/null and b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/120.png differ diff --git a/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/128.png b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/128.png new file mode 100644 index 0000000000..b08e9c29d1 Binary files /dev/null and b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/128.png differ diff --git a/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/144.png b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/144.png new file mode 100644 index 0000000000..dc238d4f5a Binary files /dev/null and b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/144.png differ diff --git a/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/152.png b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/152.png new file mode 100644 index 0000000000..8aa0968c1b Binary files /dev/null and b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/152.png differ diff --git a/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/16.png b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/16.png new file mode 100644 index 0000000000..1be3d28844 Binary files /dev/null and b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/16.png differ diff --git a/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/167.png b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/167.png new file mode 100644 index 0000000000..7668134d70 Binary files /dev/null and b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/167.png differ diff --git a/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/172.png b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/172.png new file mode 100644 index 0000000000..905b8ab6c1 Binary files /dev/null and b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/172.png differ diff --git a/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/180.png b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/180.png new file mode 100644 index 0000000000..078ac3d50c Binary files /dev/null and b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/180.png differ diff --git a/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/196.png b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/196.png new file mode 100644 index 0000000000..dbcb6287dc Binary files /dev/null and b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/196.png differ diff --git a/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/20.png b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/20.png new file mode 100644 index 0000000000..820710f4a0 Binary files /dev/null and b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/20.png differ diff --git a/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/216.png b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/216.png new file mode 100644 index 0000000000..9fbde80e6a Binary files /dev/null and b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/216.png differ diff --git a/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/234.png b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/234.png new file mode 100644 index 0000000000..0faedd1a18 Binary files /dev/null and b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/234.png differ diff --git a/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/256.png b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/256.png new file mode 100644 index 0000000000..09a08be78d Binary files /dev/null and b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/256.png differ diff --git a/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/258.png b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/258.png new file mode 100644 index 0000000000..6c415b1114 Binary files /dev/null and b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/258.png differ diff --git a/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/29.png b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/29.png new file mode 100644 index 0000000000..1386beb558 Binary files /dev/null and b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/29.png differ diff --git a/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/32.png b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/32.png new file mode 100644 index 0000000000..5bf28d8b31 Binary files /dev/null and b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/32.png differ diff --git a/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/40.png b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/40.png new file mode 100644 index 0000000000..aaf92d5343 Binary files /dev/null and b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/40.png differ diff --git a/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/48.png b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/48.png new file mode 100644 index 0000000000..4a5393537e Binary files /dev/null and b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/48.png differ diff --git a/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/50.png b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/50.png new file mode 100644 index 0000000000..dbb244d1a4 Binary files /dev/null and b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/50.png differ diff --git a/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/512.png b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/512.png new file mode 100644 index 0000000000..3da73eb23f Binary files /dev/null and b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/512.png differ diff --git a/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/55.png b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/55.png new file mode 100644 index 0000000000..34587a337a Binary files /dev/null and b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/55.png differ diff --git a/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/57.png b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/57.png new file mode 100644 index 0000000000..553ec63d1b Binary files /dev/null and b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/57.png differ diff --git a/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/58.png b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/58.png new file mode 100644 index 0000000000..0cafd08d63 Binary files /dev/null and b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/58.png differ diff --git a/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/60.png b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/60.png new file mode 100644 index 0000000000..1fe4b9ee2d Binary files /dev/null and b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/60.png differ diff --git a/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/64.png b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/64.png new file mode 100644 index 0000000000..938393214d Binary files /dev/null and b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/64.png differ diff --git a/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/66.png b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/66.png new file mode 100644 index 0000000000..531ec7d7be Binary files /dev/null and b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/66.png differ diff --git a/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/72.png b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/72.png new file mode 100644 index 0000000000..3c220f552f Binary files /dev/null and b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/72.png differ diff --git a/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/76.png b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/76.png new file mode 100644 index 0000000000..4b8865fadb Binary files /dev/null and b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/76.png differ diff --git a/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/80.png b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/80.png new file mode 100644 index 0000000000..fe1262907b Binary files /dev/null and b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/80.png differ diff --git a/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/87.png b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/87.png new file mode 100644 index 0000000000..283e5db6cd Binary files /dev/null and b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/87.png differ diff --git a/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/88.png b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/88.png new file mode 100644 index 0000000000..917063c24b Binary files /dev/null and b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/88.png differ diff --git a/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/92.png b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/92.png new file mode 100644 index 0000000000..6baffd9a1a Binary files /dev/null and b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/92.png differ diff --git a/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/Contents.json b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000000..1319290d43 --- /dev/null +++ b/app/src-tauri-mobile/icons/ios/AppIcon.appiconset/Contents.json @@ -0,0 +1 @@ +{"images":[{"size":"60x60","expected-size":"180","filename":"180.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"40x40","expected-size":"80","filename":"80.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"40x40","expected-size":"120","filename":"120.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"60x60","expected-size":"120","filename":"120.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"57x57","expected-size":"57","filename":"57.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"1x"},{"size":"29x29","expected-size":"58","filename":"58.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"29x29","expected-size":"29","filename":"29.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"1x"},{"size":"29x29","expected-size":"87","filename":"87.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"57x57","expected-size":"114","filename":"114.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"20x20","expected-size":"40","filename":"40.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"20x20","expected-size":"60","filename":"60.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"1024x1024","filename":"1024.png","expected-size":"1024","idiom":"ios-marketing","folder":"Assets.xcassets/AppIcon.appiconset/","scale":"1x"},{"size":"40x40","expected-size":"80","filename":"80.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"72x72","expected-size":"72","filename":"72.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"1x"},{"size":"76x76","expected-size":"152","filename":"152.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"50x50","expected-size":"100","filename":"100.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"29x29","expected-size":"58","filename":"58.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"76x76","expected-size":"76","filename":"76.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"1x"},{"size":"29x29","expected-size":"29","filename":"29.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"1x"},{"size":"50x50","expected-size":"50","filename":"50.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"1x"},{"size":"72x72","expected-size":"144","filename":"144.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"40x40","expected-size":"40","filename":"40.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"1x"},{"size":"83.5x83.5","expected-size":"167","filename":"167.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"20x20","expected-size":"20","filename":"20.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"1x"},{"size":"20x20","expected-size":"40","filename":"40.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"idiom":"watch","filename":"172.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"38mm","scale":"2x","size":"86x86","expected-size":"172","role":"quickLook"},{"idiom":"watch","filename":"80.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"38mm","scale":"2x","size":"40x40","expected-size":"80","role":"appLauncher"},{"idiom":"watch","filename":"88.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"40mm","scale":"2x","size":"44x44","expected-size":"88","role":"appLauncher"},{"idiom":"watch","filename":"102.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"45mm","scale":"2x","size":"51x51","expected-size":"102","role":"appLauncher"},{"idiom":"watch","filename":"108.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"49mm","scale":"2x","size":"54x54","expected-size":"108","role":"appLauncher"},{"idiom":"watch","filename":"92.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"41mm","scale":"2x","size":"46x46","expected-size":"92","role":"appLauncher"},{"idiom":"watch","filename":"100.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"44mm","scale":"2x","size":"50x50","expected-size":"100","role":"appLauncher"},{"idiom":"watch","filename":"196.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"42mm","scale":"2x","size":"98x98","expected-size":"196","role":"quickLook"},{"idiom":"watch","filename":"216.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"44mm","scale":"2x","size":"108x108","expected-size":"216","role":"quickLook"},{"idiom":"watch","filename":"234.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"45mm","scale":"2x","size":"117x117","expected-size":"234","role":"quickLook"},{"idiom":"watch","filename":"258.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"49mm","scale":"2x","size":"129x129","expected-size":"258","role":"quickLook"},{"idiom":"watch","filename":"48.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"38mm","scale":"2x","size":"24x24","expected-size":"48","role":"notificationCenter"},{"idiom":"watch","filename":"55.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"42mm","scale":"2x","size":"27.5x27.5","expected-size":"55","role":"notificationCenter"},{"idiom":"watch","filename":"66.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"45mm","scale":"2x","size":"33x33","expected-size":"66","role":"notificationCenter"},{"size":"29x29","expected-size":"87","filename":"87.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"watch","role":"companionSettings","scale":"3x"},{"size":"29x29","expected-size":"58","filename":"58.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"watch","role":"companionSettings","scale":"2x"},{"size":"1024x1024","expected-size":"1024","filename":"1024.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"watch-marketing","scale":"1x"},{"size":"128x128","expected-size":"128","filename":"128.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"1x"},{"size":"256x256","expected-size":"256","filename":"256.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"1x"},{"size":"128x128","expected-size":"256","filename":"256.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"2x"},{"size":"256x256","expected-size":"512","filename":"512.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"2x"},{"size":"32x32","expected-size":"32","filename":"32.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"1x"},{"size":"512x512","expected-size":"512","filename":"512.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"1x"},{"size":"16x16","expected-size":"16","filename":"16.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"1x"},{"size":"16x16","expected-size":"32","filename":"32.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"2x"},{"size":"32x32","expected-size":"64","filename":"64.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"2x"},{"size":"512x512","expected-size":"1024","filename":"1024.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"2x"}]} \ No newline at end of file diff --git a/app/src-tauri-mobile/icons/store/appstore.png b/app/src-tauri-mobile/icons/store/appstore.png new file mode 100644 index 0000000000..d92136804f Binary files /dev/null and b/app/src-tauri-mobile/icons/store/appstore.png differ diff --git a/app/src-tauri-mobile/icons/store/playstore.png b/app/src-tauri-mobile/icons/store/playstore.png new file mode 100644 index 0000000000..5f2756a602 Binary files /dev/null and b/app/src-tauri-mobile/icons/store/playstore.png differ diff --git a/app/src-tauri-mobile/src/lib.rs b/app/src-tauri-mobile/src/lib.rs new file mode 100644 index 0000000000..5bcdb6bf94 --- /dev/null +++ b/app/src-tauri-mobile/src/lib.rs @@ -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(app: AppHandle) -> 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"); +} diff --git a/app/src-tauri-mobile/src/main.rs b/app/src-tauri-mobile/src/main.rs new file mode 100644 index 0000000000..6ee6b76dee --- /dev/null +++ b/app/src-tauri-mobile/src/main.rs @@ -0,0 +1,3 @@ +fn main() { + openhuman_mobile::run(); +} diff --git a/app/src-tauri-mobile/tauri.conf.json b/app/src-tauri-mobile/tauri.conf.json new file mode 100644 index 0000000000..74a28854f7 --- /dev/null +++ b/app/src-tauri-mobile/tauri.conf.json @@ -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 + } + } +} diff --git a/app/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock index 0e86f5e058..a856b2d805 100644 --- a/app/src-tauri/Cargo.lock +++ b/app/src-tauri/Cargo.lock @@ -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" diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index c5f18fa1f2..138862673b 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -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@[+]`. diff --git a/app/src-tauri/src/main.rs b/app/src-tauri/src/main.rs index 8d18389288..79e5980738 100644 --- a/app/src-tauri/src/main.rs +++ b/app/src-tauri/src/main.rs @@ -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=` in argv, routing straight into diff --git a/app/src/App.tsx b/app/src/App.tsx index be71447db6..ddef2f4d46 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -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} : {children}; + return ( ( @@ -83,20 +97,20 @@ function App() { - + {socketWrapped( - - - + {!onMobile && } + {!onMobile && } + {!onMobile && } - + )} @@ -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 ( +
+ +
+ ); +} + +/** + * 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 ; + } + return ; +} + +/** Desktop inner shell — lives inside the Router so it can use useLocation. */ +function AppShellDesktop() { const location = useLocation(); const navigate = useNavigate(); const { snapshot, isBootstrapping } = useCoreState(); diff --git a/app/src/AppRoutes.tsx b/app/src/AppRoutes.tsx index 2beddf8da9..0453087ae9 100644 --- a/app/src/AppRoutes.tsx +++ b/app/src/AppRoutes.tsx @@ -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 ; + } + return ( {/* Public routes - redirect to /home if logged in */} diff --git a/app/src/AppRoutesIOS.test.tsx b/app/src/AppRoutesIOS.test.tsx new file mode 100644 index 0000000000..d52c82bdaa --- /dev/null +++ b/app/src/AppRoutesIOS.test.tsx @@ -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 +// `` without dragging the full Redux + provider tree along. +vi.mock('./features/human/HumanPage', () => ({ + default: () =>
human
, +})); +vi.mock('./pages/Accounts', () => ({ default: () =>
chat
})); +vi.mock('./pages/Settings', () => ({ + default: () =>
settings
, +})); +vi.mock('./pages/ios/PairScreen', () => ({ + PairScreen: () =>
pair
, +})); +vi.mock('./components/ios/MobileTabBar', () => ({ + default: () => , +})); + +const listProfiles = vi.fn(); +vi.mock('./services/transport/profileStore', () => ({ listProfiles: () => listProfiles() })); + +const AppRoutesIOS = (await import('./AppRoutesIOS')).default; + +const renderAt = (path: string) => + render( + + + + ); + +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(); + }); + }); +}); diff --git a/app/src/AppRoutesIOS.tsx b/app/src/AppRoutesIOS.tsx new file mode 100644 index 0000000000..7bd074d819 --- /dev/null +++ b/app/src/AppRoutesIOS.tsx @@ -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 ; +}; + +/** Wraps a paired-state route with the mobile tab bar. */ +const MobileShell: FC<{ children: React.ReactNode }> = ({ children }) => ( +
+
{children}
+ +
+); + +/** 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 ; + } + return {children}; +}; + +const AppRoutesIOS: FC = () => { + return ( + + {/* Unpaired entry — QR scan handshake. */} + } /> + + {/* Surfaced pages on iOS: Human, Chat, Settings. */} + + + + } + /> + + + + } + /> + + + + } + /> + + } /> + + ); +}; + +export default AppRoutesIOS; diff --git a/app/src/components/ios/MobileTabBar.test.tsx b/app/src/components/ios/MobileTabBar.test.tsx new file mode 100644 index 0000000000..d46722ab56 --- /dev/null +++ b/app/src/components/ios/MobileTabBar.test.tsx @@ -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('react-router-dom'); + return { ...actual, useNavigate: () => navigate }; +}); + +const renderAt = (path: string) => + render( + + + + ); + +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'); + }); +}); diff --git a/app/src/components/ios/MobileTabBar.tsx b/app/src/components/ios/MobileTabBar.tsx new file mode 100644 index 0000000000..c31e4b86bc --- /dev/null +++ b/app/src/components/ios/MobileTabBar.tsx @@ -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: ( + + + + ), + }, + { + id: 'chat', + label: 'Chat', + path: '/chat', + icon: ( + + + + ), + }, + { + id: 'settings', + label: 'Settings', + path: '/settings', + icon: ( + + + + + ), + }, +]; + +const MobileTabBar = () => { + const location = useLocation(); + const navigate = useNavigate(); + + const isActive = (path: string) => + location.pathname === path || location.pathname.startsWith(`${path}/`); + + return ( + + ); +}; + +export default MobileTabBar; diff --git a/app/src/components/settings/SettingsHome.tsx b/app/src/components/settings/SettingsHome.tsx index 0ca90c96b7..1a83a58b14 100644 --- a/app/src/components/settings/SettingsHome.tsx +++ b/app/src/components/settings/SettingsHome.tsx @@ -110,6 +110,22 @@ const SettingsHome = () => { ), onClick: () => navigateToSettings('notifications'), }, + { + id: 'devices', + title: 'Devices', + description: 'Pair iOS phones with this OpenHuman', + icon: ( + + + + ), + onClick: () => navigateToSettings('devices'), + }, { id: 'language', title: t('settings.language'), diff --git a/app/src/components/settings/hooks/useSettingsNavigation.ts b/app/src/components/settings/hooks/useSettingsNavigation.ts index d9620fc552..6edee81595 100644 --- a/app/src/components/settings/hooks/useSettingsNavigation.ts +++ b/app/src/components/settings/hooks/useSettingsNavigation.ts @@ -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]; diff --git a/app/src/components/settings/panels/DevicesPanel.tsx b/app/src/components/settings/panels/DevicesPanel.tsx new file mode 100644 index 0000000000..b03d5a49eb --- /dev/null +++ b/app/src/components/settings/panels/DevicesPanel.tsx @@ -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 ( + + ); +} + +function DeviceRow({ + device, + onRevoke, + isFirst, + isLast, +}: { + device: PairedDevice; + onRevoke: (device: PairedDevice) => void; + isFirst: boolean; + isLast: boolean; +}) { + return ( +
+ +
+

{device.label}

+

{truncateId(device.channel_id)}

+

{relativeTime(device.last_seen_at)}

+
+ +
+ ); +} + +function ConfirmRevokeDialog({ + device, + onConfirm, + onCancel, +}: { + device: PairedDevice; + onConfirm: () => void; + onCancel: () => void; +}) { + return ( +
+
+

Revoke device?

+

+ {device.label} will no longer be able to connect. + This cannot be undone. +

+
+ + +
+
+
+ ); +} + +// --------------------------------------------------------------------------- +// Main panel +// --------------------------------------------------------------------------- + +const DevicesPanel = () => { + const { navigateBack, breadcrumbs } = useSettingsNavigation(); + + const [devices, setDevices] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [revokeTarget, setRevokeTarget] = useState(null); + const [revoking, setRevoking] = useState(false); + const [showPairModal, setShowPairModal] = useState(false); + const [toasts, setToasts] = useState([]); + + const addToast = useCallback((toast: Omit) => { + 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({ + 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 | 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 ( +
+
+ 0} + onBack={navigateBack} + breadcrumbs={breadcrumbs} + /> + +
+ +

+ Pair iOS phones with this OpenHuman to use them as a remote client. +

+ +
+ {loading && ( +
+ + + + +
+ )} + + {!loading && error && ( +
+ {error} +
+ )} + + {!loading && !error && devices.length === 0 && ( +
+
+ + + +
+

No paired devices

+

+ Scan a QR code on your iPhone to connect it to this OpenHuman session. +

+ +
+ )} + + {!loading && !error && devices.length > 0 && ( +
+ {devices.map((device, idx) => ( + { + log('[devices-ui] revoke requested channel_id=%s', d.channel_id); + setRevokeTarget(d); + }} + isFirst={idx === 0} + isLast={idx === devices.length - 1} + /> + ))} +
+ )} +
+ + {revokeTarget && ( + { + void confirmRevoke(); + }} + onCancel={() => { + if (!revoking) setRevokeTarget(null); + }} + /> + )} + + {showPairModal && } + + +
+ ); +}; + +export default DevicesPanel; diff --git a/app/src/components/settings/panels/__tests__/DevicesPanel.test.tsx b/app/src/components/settings/panels/__tests__/DevicesPanel.test.tsx new file mode 100644 index 0000000000..02f115b63c --- /dev/null +++ b/app/src/components/settings/panels/__tests__/DevicesPanel.test.tsx @@ -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 }) => ( +
+ + +
+ ), +})); + +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[]) { + return { devices }; +} + +describe('DevicesPanel', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('shows empty state when no devices are paired', async () => { + mockCall.mockResolvedValue(listResponse([])); + renderWithProviders(, { 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(, { 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(, { 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(, { 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(, { 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(, { 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(, { 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(, { initialEntries: ['/settings/devices'] }); + + expect(await screen.findByText(/Failed to load devices/)).toBeInTheDocument(); + }); +}); diff --git a/app/src/components/settings/panels/devices/PairPhoneModal.test.tsx b/app/src/components/settings/panels/devices/PairPhoneModal.test.tsx new file mode 100644 index 0000000000..f2e65ad7ce --- /dev/null +++ b/app/src/components/settings/panels/devices/PairPhoneModal.test.tsx @@ -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 }) =>
, +})); + +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(, { + 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(, { + 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(, { + 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(, { + 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(, { + 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(, { + 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(, { + 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(, { + 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(, { + 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(); + }); +}); diff --git a/app/src/components/settings/panels/devices/PairPhoneModal.tsx b/app/src/components/settings/panels/devices/PairPhoneModal.tsx new file mode 100644 index 0000000000..3e6a3890e7 --- /dev/null +++ b/app/src/components/settings/panels/devices/PairPhoneModal.tsx @@ -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({ kind: 'loading' }); + const [showDetails, setShowDetails] = useState(false); + const pollRef = useRef | null>(null); + const expireTimerRef = useRef | 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({ + 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({ + 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 ( +
+
+ {/* Header */} +
+

Pair iPhone

+ +
+ + {/* Body */} +
+ {state.kind === 'loading' && } + {state.kind === 'error' && ( + { + void createSession(); + }} + /> + )} + {state.kind === 'qr' && !state.expired && ( + setShowDetails(v => !v)} + /> + )} + {state.kind === 'qr' && state.expired && ( + { + void createSession(); + }} + /> + )} + {state.kind === 'success' && ( + + )} +
+ + {/* Footer */} + {(state.kind === 'qr' || state.kind === 'error') && ( +
+ +
+ )} +
+
+ ); +}; + +// --------------------------------------------------------------------------- +// State-specific sub-components +// --------------------------------------------------------------------------- + +function LoadingBody() { + return ( +
+ + + + +

Generating pairing code…

+
+ ); +} + +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 ( +
+

+ Open the OpenHuman app on your iPhone and scan this code. +

+ + {/* QR code */} +
+ +
+ +

+ Code expires in ~{minutesLeft} minute{minutesLeft !== 1 ? 's' : ''} +

+ + {/* Details toggle */} + + + {showDetails && ( +
+
+

Channel ID

+

+ {session.channel_id} +

+
+
+

Pairing URL

+
+

+ {qrUrl} +

+ +
+
+
+ )} +
+ ); +} + +function ExpiredBody({ onRegenerate }: { onRegenerate: () => void }) { + return ( +
+
+ + + +
+

QR code expired

+

Generate a new code to continue pairing.

+ +
+ ); +} + +function SuccessBody({ label, channelId }: { label: string; channelId: string }) { + return ( +
+
+ + + +
+
+

Paired with iPhone

+

{label}

+

+ {channelId.slice(0, 8)}…{channelId.slice(-6)} +

+
+

Closing automatically…

+
+ ); +} + +function ErrorBody({ message, onRetry }: { message: string; onRetry: () => void }) { + return ( +
+
+ + + +
+

Something went wrong

+

{message}

+ +
+ ); +} + +export default PairPhoneModal; diff --git a/app/src/lib/platform.test.ts b/app/src/lib/platform.test.ts new file mode 100644 index 0000000000..dddcfb3b51 --- /dev/null +++ b/app/src/lib/platform.test.ts @@ -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'); + }); +}); diff --git a/app/src/lib/platform.ts b/app/src/lib/platform.ts new file mode 100644 index 0000000000..c311afaada --- /dev/null +++ b/app/src/lib/platform.ts @@ -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(); diff --git a/app/src/lib/tunnel/crypto.test.ts b/app/src/lib/tunnel/crypto.test.ts new file mode 100644 index 0000000000..6b2ca32d78 --- /dev/null +++ b/app/src/lib/tunnel/crypto.test.ts @@ -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); + }); +}); diff --git a/app/src/lib/tunnel/crypto.ts b/app/src/lib/tunnel/crypto.ts new file mode 100644 index 0000000000..32db8191f6 --- /dev/null +++ b/app/src/lib/tunnel/crypto.ts @@ -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)); + } +} diff --git a/app/src/lib/tunnel/framing.test.ts b/app/src/lib/tunnel/framing.test.ts new file mode 100644 index 0000000000..ead267f6e7 --- /dev/null +++ b/app/src/lib/tunnel/framing.test.ts @@ -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(); + }); +}); diff --git a/app/src/lib/tunnel/framing.ts b/app/src/lib/tunnel/framing.ts new file mode 100644 index 0000000000..2f0378b9d0 --- /dev/null +++ b/app/src/lib/tunnel/framing.ts @@ -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; // seq -> raw bytes of the slice +} + +/** Collects chunk frames by requestId and emits complete Envelopes. */ +export class Reassembler { + private readonly pending = new Map(); + + /** + * 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; + + 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 { + 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(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; + } +} diff --git a/app/src/pages/Settings.tsx b/app/src/pages/Settings.tsx index a0cfce2770..1af00cef2d 100644 --- a/app/src/pages/Settings.tsx +++ b/app/src/pages/Settings.tsx @@ -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 = () => { } /> )} /> )} /> + {/* Mobile devices */} + )} /> {/* About / updates */} )} /> {/* Fallback */} diff --git a/app/src/pages/ios/MascotScreen.test.tsx b/app/src/pages/ios/MascotScreen.test.tsx new file mode 100644 index 0000000000..3606b8201a --- /dev/null +++ b/app/src/pages/ios/MascotScreen.test.tsx @@ -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('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 }) => ( +
+ ), +})); + +// 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( + + + + ); +} + +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'); + }); + }); +}); diff --git a/app/src/pages/ios/MascotScreen.tsx b/app/src/pages/ios/MascotScreen.tsx new file mode 100644 index 0000000000..563c77156d --- /dev/null +++ b/app/src/pages/ios/MascotScreen.tsx @@ -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 = ({ messages }) => { + const bottomRef = useRef(null); + + useEffect(() => { + bottomRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [messages]); + + if (messages.length === 0) return null; + + return ( +
+ {messages.map(msg => ( +
+
+ {msg.text} + {msg.streaming && ...} +
+
+ ))} +
+
+ ); +}; + +// -- PTT button --------------------------------------------------------------- + +interface PTTButtonProps { + active: boolean; + partialText: string; + onDown: () => void; + onUp: () => void; +} + +const PTTButton: FC = ({ active, partialText, onDown, onUp }) => { + return ( +
+ {partialText && ( +
+ {partialText} +
+ )} + +
+ ); +}; + +// -- toast ------------------------------------------------------------------- + +interface ToastProps { + message: string; + onDismiss: () => void; +} + +const Toast: FC = ({ message, onDismiss }) => ( +
+ {message} +
+); + +// -- main component ---------------------------------------------------------- + +export const MascotScreen: FC = () => { + const navigate = useNavigate(); + + const [messages, setMessages] = useState([]); + const [inputText, setInputText] = useState(''); + const [isSending, setIsSending] = useState(false); + + // PTT state + const [pttActive, setPttActive] = useState(false); + const [partialText, setPartialText] = useState(''); + const [toast, setToast] = useState(null); + + const streamingIdRef = useRef(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 ( +
+ {/* Header */} +
+
+ Connected to + + {pairedLabel} + +
+ +
+ + {/* Mascot canvas */} +
+
+ +
+
+ + {/* Transcript */} + + + {/* Toast */} + {toast && setToast(null)} />} + + {/* Input row */} +
+
void handleSend(e)} className="flex items-center gap-3"> + {/* PTT button — Layer 6 live implementation */} + + + {/* Text input */} + 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 */} + + +
+
+ ); +}; diff --git a/app/src/pages/ios/PairScreen.test.tsx b/app/src/pages/ios/PairScreen.test.tsx new file mode 100644 index 0000000000..523bad8aa7 --- /dev/null +++ b/app/src/pages/ios/PairScreen.test.tsx @@ -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('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( + + + + ); +} + +// -- 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(); + }); + }); +}); diff --git a/app/src/pages/ios/PairScreen.tsx b/app/src/pages/ios/PairScreen.tsx new file mode 100644 index 0000000000..9ab5577202 --- /dev/null +++ b/app/src/pages/ios/PairScreen.tsx @@ -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({ kind: 'idle' }); + + async function startScan(): Promise { + 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 { + // 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 ( +
+
+ {/* Logo / icon area */} +
+ +
+ + {/* Heading */} +
+

Pair with your desktop

+

+ Open OpenHuman on your desktop, go to Settings > Devices, and tap “Pair + phone” to show the QR code. +

+
+ + {/* State-specific content */} + {state.kind === 'idle' && ( + + )} + + {state.kind === 'scanning' && ( +

Scanner opening...

+ )} + + {state.kind === 'connecting' && ( +

+ Connecting to desktop... +

+ )} + + {state.kind === 'success' && ( +

Connected! Loading...

+ )} + + {state.kind === 'expired' && ( +
+

+ QR code expired. Ask the desktop to regenerate the code. +

+ +
+ )} + + {state.kind === 'error' && ( +
+

{state.message}

+ + +
+ )} + + {/* Step hint */} + {(state.kind === 'idle' || state.kind === 'error' || state.kind === 'expired') && ( +
+ {[ + 'Open OpenHuman on desktop', + 'Go to Settings > Devices', + 'Tap "Pair phone" to show QR', + ].map((step, i) => ( +
+ + {i + 1} + + {step} +
+ ))} +
+ )} +
+
+ ); +}; diff --git a/app/src/services/coreRpcClient.ts b/app/src/services/coreRpcClient.ts index 8ceba7a1b8..bf030b591c 100644 --- a/app/src/services/coreRpcClient.ts +++ b/app/src/services/coreRpcClient.ts @@ -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 | 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({ } 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(normalizedMethod, params ?? {}); + } + const effectiveTimeoutMs = resolvePerCallTimeoutMs(timeoutMs); const payload: JsonRpcRequestBody = { jsonrpc: '2.0', diff --git a/app/src/services/transport/CloudHttpTransport.test.ts b/app/src/services/transport/CloudHttpTransport.test.ts new file mode 100644 index 0000000000..6096918d7e --- /dev/null +++ b/app/src/services/transport/CloudHttpTransport.test.ts @@ -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; + 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; + 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('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); + }); +}); diff --git a/app/src/services/transport/CloudHttpTransport.ts b/app/src/services/transport/CloudHttpTransport.ts new file mode 100644 index 0000000000..6637ffdcc0 --- /dev/null +++ b/app/src/services/transport/CloudHttpTransport.ts @@ -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 { + 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(method: string, params: unknown, opts?: { signal?: AbortSignal }): Promise { + const id = _nextId++; + const payload: JsonRpcRequestBody = { jsonrpc: '2.0', id, method, params: params ?? {} }; + + log('[transport:cloud] → %s id=%d', method, id); + + const headers: Record = { '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; + + 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( + method: string, + params: unknown, + opts?: { signal?: AbortSignal } + ): AsyncIterable { + const result = await this.call(method, params, opts); + yield result; + } + + async isHealthy(): Promise { + try { + await this.call('openhuman.ping', {}, { signal: AbortSignal.timeout(5000) }); + return true; + } catch { + return false; + } + } + + async close(): Promise { + log('[transport:cloud] close (no-op)'); + } +} diff --git a/app/src/services/transport/CoreTransport.ts b/app/src/services/transport/CoreTransport.ts new file mode 100644 index 0000000000..dca1ede6b0 --- /dev/null +++ b/app/src/services/transport/CoreTransport.ts @@ -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(method: string, params: unknown, opts?: { signal?: AbortSignal }): Promise; + + /** Stream a JSON-RPC method that produces sequential chunks. */ + stream(method: string, params: unknown, opts?: { signal?: AbortSignal }): AsyncIterable; + + /** Probe the transport with a ping. */ + isHealthy(): Promise; + + /** Tear down the transport. */ + close(): Promise; +} diff --git a/app/src/services/transport/LanHttpTransport.test.ts b/app/src/services/transport/LanHttpTransport.test.ts new file mode 100644 index 0000000000..5d550e2a1c --- /dev/null +++ b/app/src/services/transport/LanHttpTransport.test.ts @@ -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('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(); + }); +}); diff --git a/app/src/services/transport/LanHttpTransport.ts b/app/src/services/transport/LanHttpTransport.ts new file mode 100644 index 0000000000..83d2f7633c --- /dev/null +++ b/app/src/services/transport/LanHttpTransport.ts @@ -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 { + 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(method: string, params: unknown, opts?: { signal?: AbortSignal }): Promise { + 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; + + 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( + method: string, + params: unknown, + opts?: { signal?: AbortSignal } + ): AsyncIterable { + const result = await this.call(method, params, opts); + yield result; + } + + async isHealthy(): Promise { + try { + await this.call('openhuman.ping', {}, { signal: AbortSignal.timeout(2000) }); + return true; + } catch { + return false; + } + } + + async close(): Promise { + log('[transport:lan] close (no-op)'); + } +} diff --git a/app/src/services/transport/LocalTransport.test.ts b/app/src/services/transport/LocalTransport.test.ts new file mode 100644 index 0000000000..df3228e463 --- /dev/null +++ b/app/src/services/transport/LocalTransport.test.ts @@ -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; + 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; + 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('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); + }); +}); diff --git a/app/src/services/transport/LocalTransport.ts b/app/src/services/transport/LocalTransport.ts new file mode 100644 index 0000000000..8a1b377fbc --- /dev/null +++ b/app/src/services/transport/LocalTransport.ts @@ -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 { + 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, + private readonly getToken: () => Promise, + private readonly timeoutMs: number = 30_000 + ) {} + + async call(method: string, params: unknown, opts?: { signal?: AbortSignal }): Promise { + 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 = { '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; + + 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( + method: string, + params: unknown, + opts?: { signal?: AbortSignal } + ): AsyncIterable { + // 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(method, params, opts); + yield result; + } + + async isHealthy(): Promise { + try { + await this.call('openhuman.ping', {}, { signal: AbortSignal.timeout(3000) }); + return true; + } catch { + return false; + } + } + + async close(): Promise { + // Stateless HTTP — nothing to tear down. + log('[transport:local] close (no-op)'); + } +} diff --git a/app/src/services/transport/TransportManager.test.ts b/app/src/services/transport/TransportManager.test.ts new file mode 100644 index 0000000000..59444dfff3 --- /dev/null +++ b/app/src/services/transport/TransportManager.test.ts @@ -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 { + 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); + }); +}); diff --git a/app/src/services/transport/TransportManager.ts b/app/src/services/transport/TransportManager.ts new file mode 100644 index 0000000000..20bad2c415 --- /dev/null +++ b/app/src/services/transport/TransportManager.ts @@ -0,0 +1,197 @@ +/** + * TransportManager — selects and races transports given a ConnectionProfile. + * + * Desktop: defaults to LocalTransport; switches to CloudHttpTransport if + * the profile specifies kind "cloud". + * + * iOS (kind "lan" | "tunnel"): races LAN (2 s timeout) vs Tunnel and uses + * whichever responds first. Falls back to whichever is still healthy. + */ +import debug from 'debug'; + +import { CloudHttpTransport } from './CloudHttpTransport'; +import type { CoreTransport } from './CoreTransport'; +import { LanHttpTransport } from './LanHttpTransport'; +import { LocalTransport } from './LocalTransport'; +import type { ConnectionProfile } from './profileStore'; +import { TunnelTransport } from './TunnelTransport'; + +const log = debug('transport:manager'); +const logErr = debug('transport:manager:error'); + +const LAN_RACE_TIMEOUT_MS = 2_000; + +// -- TransportManager -------------------------------------------------------- + +export class TransportManager { + private active: CoreTransport | null = null; + + constructor( + private readonly profile: ConnectionProfile, + private readonly localRpcUrl: () => Promise, + private readonly localToken: () => Promise, + private readonly backendSocketUrl: string + ) {} + + /** + * Return the active transport, creating and health-checking it if needed. + * For iOS profiles, races LAN vs Tunnel. + */ + async getTransport(): Promise { + if (this.active) { + return this.active; + } + + const transport = await this.selectTransport(); + this.active = transport; + return transport; + } + + /** Force re-selection (e.g. after a connection failure). */ + async reset(): Promise { + if (this.active) { + await this.active.close().catch(() => {}); + this.active = null; + } + } + + async close(): Promise { + if (this.active) { + await this.active.close().catch(() => {}); + this.active = null; + } + } + + // -- selection logic ------------------------------------------------------- + + private async selectTransport(): Promise { + const { kind } = this.profile; + log('[transport:manager] selecting kind=%s id=%s', kind, this.profile.id); + + if (kind === 'local') { + const t = new LocalTransport(this.localRpcUrl, this.localToken); + log('[transport:manager] → LocalTransport'); + return t; + } + + if (kind === 'cloud') { + const { rpcUrl, sessionToken } = this.profile; + if (!rpcUrl) { + throw new Error('[transport:manager] cloud profile missing rpcUrl'); + } + const t = new CloudHttpTransport(rpcUrl, sessionToken ?? null); + log('[transport:manager] → CloudHttpTransport rpcUrl=%s', rpcUrl); + return t; + } + + if (kind === 'lan') { + const { rpcUrl } = this.profile; + if (!rpcUrl) { + throw new Error('[transport:manager] lan profile missing rpcUrl'); + } + const t = new LanHttpTransport(rpcUrl); + log('[transport:manager] → LanHttpTransport rpcUrl=%s', rpcUrl); + return t; + } + + if (kind === 'tunnel') { + return this.raceLanAndTunnel(); + } + + throw new Error(`[transport:manager] unknown profile kind: ${kind}`); + } + + /** + * Race LAN (with 2 s timeout) against Tunnel. + * Whichever responds to `openhuman.ping` first wins. + * If LAN wins but later fails, caller should call reset() to re-race. + */ + private async raceLanAndTunnel(): Promise { + const { rpcUrl, channelId, corePubkey, sessionToken, pairingToken } = this.profile; + + if (!channelId || !corePubkey) { + throw new Error('[transport:manager] tunnel profile missing channelId or corePubkey'); + } + + const tunnelToken = sessionToken ?? pairingToken; + if (!tunnelToken) { + throw new Error('[transport:manager] tunnel profile missing sessionToken or pairingToken'); + } + + const tunnelTransport = new TunnelTransport( + this.backendSocketUrl, + channelId, + corePubkey, + tunnelToken + ); + + if (!rpcUrl) { + // No LAN URL — tunnel only. + log('[transport:manager] → TunnelTransport (no LAN URL)'); + return tunnelTransport; + } + + const lanTransport = new LanHttpTransport(rpcUrl, LAN_RACE_TIMEOUT_MS); + + // Race: LAN vs Tunnel. First healthy transport wins. + log('[transport:manager] racing LAN vs Tunnel channelId=%s', channelId); + + type Winner = { transport: CoreTransport; loser: CoreTransport }; + + const lanRace = lanTransport + .isHealthy() + .then((ok): Winner | null => + ok ? { transport: lanTransport, loser: tunnelTransport } : null + ); + + const tunnelRace = tunnelTransport + .isHealthy() + .then((ok): Winner | null => + ok ? { transport: tunnelTransport, loser: lanTransport } : null + ); + + const winner = await Promise.race([lanRace, tunnelRace]); + + if (winner) { + // Close the losing transport. + void winner.loser.close().catch(() => {}); + log('[transport:manager] race winner: %s', winner.transport.kind); + return winner.transport; + } + + // Both failed in the race window — wait for whichever succeeds. + logErr('[transport:manager] race: both transports unhealthy; waiting…'); + const result = await Promise.any([lanRace, tunnelRace]); + if (result) { + void result.loser.close().catch(() => {}); + log('[transport:manager] fallback winner: %s', result.transport.kind); + return result.transport; + } + + throw new Error('[transport:manager] all transports failed to connect'); + } +} + +// -- convenience factory ------------------------------------------------------ + +/** + * Build a TransportManager from a ConnectionProfile. + * `localRpcUrl` / `localToken` are only needed for kind="local". + */ +export function createTransportManager( + profile: ConnectionProfile, + opts: { + localRpcUrl?: () => Promise; + localToken?: () => Promise; + backendSocketUrl?: string; + } = {} +): TransportManager { + const noop = () => Promise.resolve(null); + const noopStr = () => Promise.resolve(''); + return new TransportManager( + profile, + opts.localRpcUrl ?? noopStr, + opts.localToken ?? noop, + opts.backendSocketUrl ?? '' + ); +} diff --git a/app/src/services/transport/TunnelTransport.test.ts b/app/src/services/transport/TunnelTransport.test.ts new file mode 100644 index 0000000000..772baad63e --- /dev/null +++ b/app/src/services/transport/TunnelTransport.test.ts @@ -0,0 +1,281 @@ +/** + * Unit tests for TunnelTransport. + * + * We mock socket.io-client so no real network connection is made. + * Each test gets a fresh socket mock via the module factory pattern. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + base64urlEncode, + deriveSharedSecret, + generateKeypair, + open, + ReplayTracker, + seal, +} from '../../lib/tunnel/crypto'; + +// -- socket mock factory ------------------------------------------------------- + +// The mock must be registered before the module under test is imported, but +// we need fresh state per test. We use module-level mutable objects the +// factory closure captures. + +let _handlers: Map void> = new Map(); +let _emitSpy = vi.fn(); +let _disconnectSpy = vi.fn(); + +vi.mock('socket.io-client', () => ({ + io: () => ({ + on: (event: string, cb: (...args: unknown[]) => void) => { + _handlers.set(event, cb); + }, + emit: (...args: unknown[]) => _emitSpy(...args), + disconnect: () => _disconnectSpy(), + connected: true, + }), +})); + +// Import AFTER vi.mock is hoisted. +const { TunnelTransport } = await import('./TunnelTransport'); + +// -- helpers ------------------------------------------------------------------ + +function resetSocket() { + _handlers = new Map(); + _emitSpy = vi.fn(); + _disconnectSpy = vi.fn(); +} + +function fire(event: string, ...args: unknown[]) { + _handlers.get(event)?.(...args); +} + +async function connectTransport(transport: InstanceType): Promise { + const connectP = (transport as unknown as { ensureConnected(): Promise }).ensureConnected(); + // Flush: give socket.on a chance to register. + await Promise.resolve(); + fire('connect'); + await Promise.resolve(); + fire('tunnel:connected'); + await connectP; +} + +function coreB64(kp: ReturnType) { + return base64urlEncode(kp.publicKey); +} + +// -- tests -------------------------------------------------------------------- + +beforeEach(() => { + resetSocket(); +}); + +describe('TunnelTransport', () => { + it('emits tunnel:connect with channelId + role on connect', async () => { + const coreKp = generateKeypair(); + const channelId = 'CHAN_001'; + const transport = new TunnelTransport('http://backend', channelId, coreB64(coreKp), 'tok'); + + await connectTransport(transport); + + const connectCall = _emitSpy.mock.calls.find(([ev]) => ev === 'tunnel:connect'); + expect(connectCall).toBeTruthy(); + expect(connectCall![1]).toMatchObject({ channelId, role: 'client', token: 'tok' }); + + // Handshake frame should have been sent. + const frameCall = _emitSpy.mock.calls.find(([ev]) => ev === 'tunnel:frame'); + expect(frameCall).toBeTruthy(); + + await transport.close(); + }); + + it('rejects pending calls when close() is called', async () => { + const coreKp = generateKeypair(); + const transport = new TunnelTransport('http://backend', 'CHAN_002', coreB64(coreKp), 'tok'); + + await connectTransport(transport); + + // Queue a call. + const callP = transport.call('openhuman.ping', {}); + + // Close immediately — pending call should reject. + await transport.close(); + + await expect(callP).rejects.toThrow(); + }, 5000); + + it('replay rejection: duplicate encrypted frames are rejected', () => { + const kp = generateKeypair(); + const other = generateKeypair(); + const key = deriveSharedSecret(kp.secretKey, other.publicKey); + const tracker = new ReplayTracker(); + + const plain = new TextEncoder().encode( + '{"requestId":"r1","kind":"response","seq":0,"payload":null}' + ); + const frame = seal(key, plain); + + // First open: ok. + const first = open(key, frame, tracker); + expect(Array.from(first)).toEqual(Array.from(plain)); + + // Second open of same frame: replayed nonce. + expect(() => open(key, frame, tracker)).toThrow(/replayed nonce/i); + }); + + it('rejects the connect promise on tunnel:error', async () => { + const coreKp = generateKeypair(); + const transport = new TunnelTransport('http://backend', 'CHAN_003', coreB64(coreKp), 'tok'); + + const connectP = ( + transport as unknown as { ensureConnected(): Promise } + ).ensureConnected(); + await Promise.resolve(); + fire('connect'); + await Promise.resolve(); + // Fire tunnel:error instead of tunnel:connected. + fire('tunnel:error', 'unauthorized'); + + await expect(connectP).rejects.toThrow(/server error|unauthorized/i); + }, 5000); + + it('resolves call() when a matching encrypted response frame arrives', async () => { + const coreKp = generateKeypair(); + const transport = new TunnelTransport('http://backend', 'CHAN_004', coreB64(coreKp), 'tok'); + + await connectTransport(transport); + + const callP = transport.call<{ pong: number }>('openhuman.ping', { who: 'me' }); + + // Wait for the call to register and send its frame. + await Promise.resolve(); + await Promise.resolve(); + + // Extract requestId from the chunk envelope the client just emitted. + // Since chunks are encrypted we can't decode them — instead simulate the + // server response by re-using the same session key derivation in reverse. + // The transport derives sessionKey from (device.secret, core.public). The + // server side derives the same key from (core.secret, device.public). We + // mimic that by importing the same helpers. + const { deriveSharedSecret, seal, base64urlEncode } = await import('../../lib/tunnel/crypto'); + const { chunk } = await import('../../lib/tunnel/framing'); + + // Pull the device pubkey out of the handshake frame the client sent. + const handshakeCall = _emitSpy.mock.calls.find(([ev]) => ev === 'tunnel:frame'); + expect(handshakeCall).toBeTruthy(); + + // We can't decode the handshake without the core's secret key, but the + // transport exposes its sessionKey on the instance (derived from the + // device keypair). Reach in to get it for the test. + type Internals = { sessionKey: Uint8Array | null; pending: Map }; + const internals = transport as unknown as Internals; + + // Wait until sessionKey is populated and pending request is registered. + for (let i = 0; i < 10 && (!internals.sessionKey || internals.pending.size === 0); i++) { + await Promise.resolve(); + } + expect(internals.sessionKey).toBeTruthy(); + const sessionKey = internals.sessionKey!; + const [requestId] = Array.from(internals.pending.keys()) as string[]; + expect(requestId).toBeTruthy(); + + // Build a response envelope, chunk it, encrypt each chunk, and feed back + // via the tunnel:frame handler. + const envelope = { requestId, kind: 'response' as const, seq: 0, payload: { pong: 42 } }; + for (const raw of chunk(envelope)) { + const encrypted = seal(sessionKey, raw); + fire('tunnel:frame', { payload: base64urlEncode(encrypted) }); + } + + await expect(callP).resolves.toEqual({ pong: 42 }); + // unused helper in this test, satisfy linter + void deriveSharedSecret; + + await transport.close(); + }, 10000); + + it('routes error envelopes back to the matching pending call', async () => { + const coreKp = generateKeypair(); + const transport = new TunnelTransport('http://backend', 'CHAN_005', coreB64(coreKp), 'tok'); + await connectTransport(transport); + + const callP = transport.call('openhuman.fail', {}); + await Promise.resolve(); + await Promise.resolve(); + + const { seal, base64urlEncode } = await import('../../lib/tunnel/crypto'); + const { chunk } = await import('../../lib/tunnel/framing'); + type Internals = { sessionKey: Uint8Array | null; pending: Map }; + const internals = transport as unknown as Internals; + for (let i = 0; i < 10 && (!internals.sessionKey || internals.pending.size === 0); i++) { + await Promise.resolve(); + } + const [requestId] = Array.from(internals.pending.keys()) as string[]; + + const envelope = { requestId, kind: 'error' as const, seq: 0, payload: 'tunnel exploded' }; + for (const raw of chunk(envelope)) { + fire('tunnel:frame', { payload: base64urlEncode(seal(internals.sessionKey!, raw)) }); + } + + await expect(callP).rejects.toThrow('tunnel exploded'); + await transport.close(); + }, 10000); + + it('ignores incoming frames missing a payload field', async () => { + const coreKp = generateKeypair(); + const transport = new TunnelTransport('http://backend', 'CHAN_006', coreB64(coreKp), 'tok'); + await connectTransport(transport); + + // Should not throw, should not affect any pending state. + fire('tunnel:frame', { not_payload: 'oops' }); + fire('tunnel:frame', { payload: 42 }); + fire('tunnel:frame', null); + + await transport.close(); + }); + + it('ignores frames that arrive before the session key is set', async () => { + const coreKp = generateKeypair(); + const transport = new TunnelTransport('http://backend', 'CHAN_007', coreB64(coreKp), 'tok'); + + // Start connect but don't complete handshake. + void (transport as unknown as { ensureConnected(): Promise }).ensureConnected(); + await Promise.resolve(); + fire('connect'); + // (no tunnel:connected → no handshake → sessionKey stays null) + + // Frame arrives early — should be silently dropped. + fire('tunnel:frame', { payload: 'AAAAAAA' }); + + // No assertion needed beyond "no throw". Force the connect promise to + // settle so vitest doesn't complain about leaks. + await transport.close(); + }); + + it('isHealthy returns false when the underlying connect rejects', async () => { + const coreKp = generateKeypair(); + const transport = new TunnelTransport('http://backend', 'CHAN_008', coreB64(coreKp), 'tok'); + const healthyP = transport.isHealthy(); + await Promise.resolve(); + // Surface a connect_error before tunnel:connected — connect rejects. + fire('connect_error', new Error('refused')); + await expect(healthyP).resolves.toBe(false); + }); + + it('disconnect resets the session key and connect promise', async () => { + const coreKp = generateKeypair(); + const transport = new TunnelTransport('http://backend', 'CHAN_009', coreB64(coreKp), 'tok'); + await connectTransport(transport); + + type Internals = { sessionKey: Uint8Array | null; _connectPromise: Promise | null }; + const internals = transport as unknown as Internals; + expect(internals.sessionKey).toBeTruthy(); + + fire('disconnect', 'transport close'); + expect(internals.sessionKey).toBeNull(); + expect(internals._connectPromise).toBeNull(); + + await transport.close(); + }); +}); diff --git a/app/src/services/transport/TunnelTransport.ts b/app/src/services/transport/TunnelTransport.ts new file mode 100644 index 0000000000..b383d58ea1 --- /dev/null +++ b/app/src/services/transport/TunnelTransport.ts @@ -0,0 +1,380 @@ +/** + * TunnelTransport — socket.io client using the backend tunnel relay. + * + * Handles: + * - Connecting to the backend with `tunnel:connect` (role: "client") + * - Sending RPC calls as `tunnel:frame` events (E2E encrypted + chunked) + * - Receiving response frames, decrypting, and resolving the matching request + * - First frame: sealed handshake (sends device pubkey encrypted to core pubkey) + * - Subsequent frames: symmetric XChaCha20-Poly1305 encryption + * + * Key material is never logged. Only lengths and first-4-char prefixes appear. + */ +import debug from 'debug'; +import { io, Socket } from 'socket.io-client'; + +import { + base64urlDecode, + base64urlEncode, + deriveSharedSecret, + generateKeypair, + open, + ReplayTracker, + seal, + sealHandshake, + type TunnelKeypair, +} from '../../lib/tunnel/crypto'; +import { chunk, Envelope, Reassembler, TokenBucket } from '../../lib/tunnel/framing'; +import type { CoreTransport } from './CoreTransport'; + +const log = debug('transport:tunnel'); +const logErr = debug('transport:tunnel:error'); + +// -- types ------------------------------------------------------------------- + +interface PendingCall { + resolve: (value: unknown) => void; + reject: (err: Error) => void; + timeoutId: ReturnType; +} + +interface StreamChunkHandler { + push: (value: unknown) => void; + finish: () => void; + error: (err: Error) => void; +} + +// -- TunnelTransport --------------------------------------------------------- + +export class TunnelTransport implements CoreTransport { + readonly kind = 'tunnel' as const; + + private socket: Socket | null = null; + private sessionKey: Uint8Array | null = null; // derived after handshake + private deviceKeypair: TunnelKeypair | null = null; + private readonly replayTracker = new ReplayTracker(); + private readonly reassembler = new Reassembler(); + private readonly rateLimiter = new TokenBucket(100, 100); + + private readonly pending = new Map(); + private readonly streams = new Map(); + + private _connectPromise: Promise | null = null; + + constructor( + private readonly backendUrl: string, + private readonly channelId: string, + private readonly corePubkeyB64: string, + private readonly authToken: string, // sessionToken (reconnect) or pairingToken (first) + private readonly role: 'client' = 'client', + private readonly callTimeoutMs: number = 30_000 + ) { + // Generate device keypair on construction. + this.deviceKeypair = generateKeypair(); + log('[tunnel] created channelId=%s corePubkey=%s…', channelId, corePubkeyB64.slice(0, 4)); + } + + // -- connect --------------------------------------------------------------- + + private ensureConnected(): Promise { + if (this._connectPromise) return this._connectPromise; + + this._connectPromise = new Promise((resolve, reject) => { + log('[tunnel] connecting to %s channelId=%s', this.backendUrl, this.channelId); + + const socket = io(this.backendUrl, { + transports: ['websocket', 'polling'], + reconnection: true, + reconnectionDelay: 1000, + reconnectionAttempts: 10, + forceNew: true, + }); + + this.socket = socket; + + socket.on('connect', () => { + log('[tunnel] socket connected, emitting tunnel:connect channelId=%s', this.channelId); + socket.emit('tunnel:connect', { + channelId: this.channelId, + role: this.role, + token: this.authToken, + }); + }); + + socket.on('tunnel:connected', () => { + log('[tunnel] tunnel:connected ack received, performing handshake'); + // Send sealed handshake frame. + void this.sendHandshake().then(resolve).catch(reject); + }); + + socket.on('tunnel:frame', (data: unknown) => { + void this.handleIncomingFrame(data); + }); + + socket.on('tunnel:error', (err: unknown) => { + logErr('[tunnel] tunnel:error %o', err); + const errMsg = typeof err === 'string' ? err : JSON.stringify(err); + reject(new Error(`[tunnel] server error: ${errMsg}`)); + this.rejectAllPending(new Error(`[tunnel] server error: ${errMsg}`)); + }); + + socket.on('disconnect', (reason: string) => { + log('[tunnel] disconnected reason=%s', reason); + this.sessionKey = null; + this._connectPromise = null; + }); + + socket.on('connect_error', (err: Error) => { + logErr('[tunnel] connect_error %s', err.message); + reject(err); + this._connectPromise = null; + }); + }); + + return this._connectPromise; + } + + // -- handshake ------------------------------------------------------------- + + private async sendHandshake(): Promise { + if (!this.deviceKeypair) throw new Error('[tunnel] no device keypair'); + + const corePubkey = base64urlDecode(this.corePubkeyB64); + const devicePubkeyB64 = base64urlEncode(this.deviceKeypair.publicKey); + + // Device pubkey payload (base64url-encoded, UTF-8). + const payload = new TextEncoder().encode(devicePubkeyB64); + + // Seal the handshake payload to the core's public key. + const handshakeFrame = sealHandshake(corePubkey, payload); + const frameB64 = base64urlEncode(handshakeFrame); + + log('[tunnel] sending sealed handshake frame_len=%d', handshakeFrame.length); + this.socket!.emit('tunnel:frame', { channelId: this.channelId, payload: frameB64 }); + + // Derive session key from static keys (both sides derive the same key). + this.sessionKey = deriveSharedSecret(this.deviceKeypair.secretKey, corePubkey); + + log('[tunnel] handshake complete, session key derived'); + } + + // -- incoming frames ------------------------------------------------------- + + private async handleIncomingFrame(data: unknown): Promise { + const obj = data as Record; + const payloadB64 = typeof obj?.payload === 'string' ? obj.payload : null; + if (!payloadB64) { + logErr('[tunnel] incoming frame missing payload'); + return; + } + + if (!this.sessionKey) { + log('[tunnel] frame received before session key — ignoring'); + return; + } + + let frameBytes: Uint8Array; + try { + frameBytes = base64urlDecode(payloadB64); + } catch (err) { + logErr('[tunnel] bad base64url in incoming frame: %s', (err as Error).message); + return; + } + + let plaintext: Uint8Array; + try { + plaintext = open(this.sessionKey, frameBytes, this.replayTracker); + } catch (err) { + logErr('[tunnel] frame decryption failed: %s', (err as Error).message); + return; + } + + const envelope = this.reassembler.feed(plaintext); + if (!envelope) return; // waiting for more chunks + + this.dispatchEnvelope(envelope); + } + + private dispatchEnvelope(envelope: Envelope): void { + const { requestId, kind } = envelope; + + if (kind === 'stream-chunk' || kind === 'stream-end') { + const handler = this.streams.get(requestId); + if (!handler) return; + if (kind === 'stream-chunk') { + handler.push(envelope.payload); + } else { + handler.finish(); + this.streams.delete(requestId); + } + return; + } + + if (kind === 'error') { + const pending = this.pending.get(requestId); + if (pending) { + clearTimeout(pending.timeoutId); + this.pending.delete(requestId); + pending.reject(new Error(String(envelope.payload ?? 'tunnel error'))); + } + const stream = this.streams.get(requestId); + if (stream) { + stream.error(new Error(String(envelope.payload ?? 'tunnel error'))); + this.streams.delete(requestId); + } + return; + } + + if (kind === 'response') { + const pending = this.pending.get(requestId); + if (!pending) return; + clearTimeout(pending.timeoutId); + this.pending.delete(requestId); + pending.resolve(envelope.payload); + return; + } + } + + // -- send ------------------------------------------------------------------ + + private async sendEnvelope(envelope: Envelope): Promise { + if (!this.sessionKey) throw new Error('[tunnel] no session key — handshake incomplete'); + + await this.rateLimiter.consume(); + + const chunks = chunk(envelope); + for (const raw of chunks) { + const encrypted = seal(this.sessionKey, raw); + const frameB64 = base64urlEncode(encrypted); + this.socket!.emit('tunnel:frame', { channelId: this.channelId, payload: frameB64 }); + } + + log( + '[tunnel] sent %s requestId=%s chunks=%d', + envelope.kind, + envelope.requestId, + chunks.length + ); + } + + // -- CoreTransport --------------------------------------------------------- + + async call(method: string, params: unknown, opts?: { signal?: AbortSignal }): Promise { + await this.ensureConnected(); + + const requestId = crypto.randomUUID(); + const envelope: Envelope = { requestId, kind: 'request', seq: 0, payload: { method, params } }; + + return new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + this.pending.delete(requestId); + reject(new Error(`[tunnel] ${method} timed out after ${this.callTimeoutMs}ms`)); + }, this.callTimeoutMs); + + opts?.signal?.addEventListener('abort', () => { + clearTimeout(timeoutId); + this.pending.delete(requestId); + reject(new Error(`[tunnel] ${method} aborted`)); + }); + + this.pending.set(requestId, { resolve: v => resolve(v as T), reject, timeoutId }); + + void this.sendEnvelope(envelope).catch((err: Error) => { + clearTimeout(timeoutId); + this.pending.delete(requestId); + reject(err); + }); + }); + } + + async *stream( + method: string, + params: unknown, + opts?: { signal?: AbortSignal } + ): AsyncIterable { + await this.ensureConnected(); + + const requestId = crypto.randomUUID(); + const envelope: Envelope = { + requestId, + kind: 'request', + seq: 0, + payload: { method, params, stream: true }, + }; + + const queue: T[] = []; + let finished = false; + let streamError: Error | null = null; + let notify: (() => void) | null = null; + + this.streams.set(requestId, { + push: v => { + queue.push(v as T); + notify?.(); + }, + finish: () => { + finished = true; + notify?.(); + }, + error: err => { + streamError = err; + finished = true; + notify?.(); + }, + }); + + opts?.signal?.addEventListener('abort', () => { + finished = true; + this.streams.delete(requestId); + notify?.(); + }); + + await this.sendEnvelope(envelope); + + while (!finished || queue.length > 0) { + if (queue.length > 0) { + yield queue.shift()!; + continue; + } + await new Promise(res => { + notify = res; + }); + notify = null; + } + + this.streams.delete(requestId); + + if (streamError) throw streamError; + } + + async isHealthy(): Promise { + try { + await this.ensureConnected(); + await this.call('openhuman.ping', {}, { signal: AbortSignal.timeout(5000) }); + return true; + } catch { + return false; + } + } + + async close(): Promise { + log('[tunnel] close channelId=%s', this.channelId); + this.rejectAllPending(new Error('[tunnel] transport closed')); + this.socket?.disconnect(); + this.socket = null; + this._connectPromise = null; + this.sessionKey = null; + } + + private rejectAllPending(err: Error): void { + for (const [, pending] of this.pending) { + clearTimeout(pending.timeoutId); + pending.reject(err); + } + this.pending.clear(); + for (const [, stream] of this.streams) { + stream.error(err); + } + this.streams.clear(); + } +} diff --git a/app/src/services/transport/profileStore.test.ts b/app/src/services/transport/profileStore.test.ts new file mode 100644 index 0000000000..f65a1498b8 --- /dev/null +++ b/app/src/services/transport/profileStore.test.ts @@ -0,0 +1,146 @@ +/** + * profileStore tests — desktop and iOS save/load/delete round-trip. + * + * Both paths currently use localStorage (iOS uses the same storage as desktop + * since the WKWebView is app-sandboxed). The test ensures the public API + * works correctly on both platform branches. + */ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { clearTestPlatform, setTestPlatform } from '../../lib/platform'; +import { + type ConnectionProfile, + deleteProfile, + getProfile, + listProfileIds, + listProfiles, + saveProfile, +} from './profileStore'; + +// -- helpers ----------------------------------------------------------------- + +function makeProfile(overrides: Partial = {}): ConnectionProfile { + return { + id: 'test-channel-id', + label: 'Test Desktop', + kind: 'tunnel', + channelId: 'test-channel-id', + pairingToken: 'test-pairing-token', + corePubkey: 'test-core-pubkey', + devicePrivkey: 'test-device-privkey', + ...overrides, + }; +} + +// -- setup ------------------------------------------------------------------- + +beforeEach(() => { + // Clear localStorage between tests. + localStorage.clear(); +}); + +afterEach(() => { + clearTestPlatform(); + localStorage.clear(); +}); + +// -- desktop path ------------------------------------------------------------ + +describe('profileStore (desktop)', () => { + beforeEach(() => { + setTestPlatform('desktop'); + }); + + it('save then get returns the same profile', () => { + const profile = makeProfile(); + saveProfile(profile); + const loaded = getProfile(profile.id); + expect(loaded).not.toBeNull(); + expect(loaded?.id).toBe(profile.id); + expect(loaded?.kind).toBe('tunnel'); + expect(loaded?.channelId).toBe(profile.channelId); + }); + + it('listProfileIds returns saved id', () => { + const profile = makeProfile(); + saveProfile(profile); + expect(listProfileIds()).toContain(profile.id); + }); + + it('listProfiles returns full profile objects', () => { + const profile = makeProfile(); + saveProfile(profile); + const profiles = listProfiles(); + expect(profiles).toHaveLength(1); + expect(profiles[0].label).toBe('Test Desktop'); + }); + + it('delete removes profile from store', () => { + const profile = makeProfile(); + saveProfile(profile); + deleteProfile(profile.id); + expect(getProfile(profile.id)).toBeNull(); + expect(listProfileIds()).not.toContain(profile.id); + }); + + it('save multiple profiles', () => { + saveProfile(makeProfile({ id: 'a', label: 'A' })); + saveProfile(makeProfile({ id: 'b', label: 'B' })); + expect(listProfileIds()).toHaveLength(2); + expect(listProfiles().map(p => p.id)).toContain('a'); + expect(listProfiles().map(p => p.id)).toContain('b'); + }); + + it('overwrite (same id) replaces label', () => { + saveProfile(makeProfile({ id: 'x', label: 'Old' })); + saveProfile(makeProfile({ id: 'x', label: 'New' })); + expect(listProfileIds()).toHaveLength(1); + expect(getProfile('x')?.label).toBe('New'); + }); + + it('getProfile returns null for missing id', () => { + expect(getProfile('does-not-exist')).toBeNull(); + }); +}); + +// -- iOS path ---------------------------------------------------------------- + +describe('profileStore (iOS)', () => { + beforeEach(() => { + setTestPlatform('ios'); + }); + + it('save then get round-trip works on iOS', () => { + const profile = makeProfile({ id: 'ios-channel', label: 'iPhone 15' }); + saveProfile(profile); + const loaded = getProfile('ios-channel'); + expect(loaded).not.toBeNull(); + expect(loaded?.label).toBe('iPhone 15'); + expect(loaded?.kind).toBe('tunnel'); + }); + + it('listProfiles returns iOS profile', () => { + const profile = makeProfile({ id: 'ios-chan', label: 'iPad' }); + saveProfile(profile); + const all = listProfiles(); + expect(all).toHaveLength(1); + expect(all[0].id).toBe('ios-chan'); + }); + + it('delete removes profile on iOS', () => { + const profile = makeProfile({ id: 'ios-del', label: 'Old Phone' }); + saveProfile(profile); + deleteProfile('ios-del'); + expect(getProfile('ios-del')).toBeNull(); + expect(listProfiles()).toHaveLength(0); + }); + + it('devicePrivkey round-trips (stores and retrieves sensitive field)', () => { + const profile = makeProfile({ id: 'ios-key', devicePrivkey: 'super-secret-private-key-value' }); + saveProfile(profile); + const loaded = getProfile('ios-key'); + // Verify the field is present (it survives the JSON round-trip). + expect(loaded?.devicePrivkey).toBe('super-secret-private-key-value'); + // SECURITY NOTE: in production this will be migrated to Keychain (Layer 7). + }); +}); diff --git a/app/src/services/transport/profileStore.ts b/app/src/services/transport/profileStore.ts new file mode 100644 index 0000000000..98c7f8c407 --- /dev/null +++ b/app/src/services/transport/profileStore.ts @@ -0,0 +1,170 @@ +/** + * profileStore — secure storage for ConnectionProfile records. + * + * Two backends: + * Desktop: localStorage (sufficient for desktop; credentials protected by OS account) + * iOS: TODO(Layer 5) — wire to tauri-plugin-stronghold or tauri-plugin-keychain + * + * ConnectionProfile contains the minimum required to select and authenticate a + * transport: kind, rpcUrl, channelId, tokens, and key material. + * + * Key material (devicePrivkey, sessionToken) is sensitive — the iOS backend + * must store these in the Secure Enclave via Keychain. On desktop, we store + * in localStorage under the assumption that the device is single-user and + * protected by OS-level login. + */ +import debug from 'debug'; + +import { getIsIOS } from '../../lib/platform'; + +const log = debug('transport:profile-store'); + +// -- types ------------------------------------------------------------------- + +export interface ConnectionProfile { + /** Unique profile identifier. */ + id: string; + /** Human-readable label, e.g. "Home desktop". */ + label: string; + /** Transport kind this profile uses. */ + kind: 'local' | 'lan' | 'tunnel' | 'cloud'; + /** LAN or cloud HTTP RPC URL (for lan + cloud kinds). */ + rpcUrl?: string; + /** Tunnel channel identifier (for tunnel kind). */ + channelId?: string; + /** Tunnel session token for reconnects (for tunnel kind). */ + sessionToken?: string; + /** Tunnel pairing token for first-time connect (for tunnel kind). */ + pairingToken?: string; + /** Core's X25519 public key in base64url (for tunnel kind). */ + corePubkey?: string; + /** + * Device's X25519 private key in base64url. + * SENSITIVE — on iOS this must be stored in Keychain (Layer 5). + * On desktop we store it in localStorage. + */ + devicePrivkey?: string; +} + +// -- storage key prefix ------------------------------------------------------- + +const STORAGE_KEY_PREFIX = 'openhuman:transport:profile:'; +const INDEX_KEY = 'openhuman:transport:profile:__index__'; + +// -- desktop backend --------------------------------------------------------- + +function desktopList(): string[] { + try { + const raw = localStorage.getItem(INDEX_KEY); + return raw ? (JSON.parse(raw) as string[]) : []; + } catch { + return []; + } +} + +function desktopSave(profile: ConnectionProfile): void { + const ids = desktopList(); + if (!ids.includes(profile.id)) { + ids.push(profile.id); + localStorage.setItem(INDEX_KEY, JSON.stringify(ids)); + } + localStorage.setItem(STORAGE_KEY_PREFIX + profile.id, JSON.stringify(profile)); + log('[profile-store] saved id=%s kind=%s', profile.id, profile.kind); +} + +function desktopGet(id: string): ConnectionProfile | null { + const raw = localStorage.getItem(STORAGE_KEY_PREFIX + id); + if (!raw) return null; + try { + return JSON.parse(raw) as ConnectionProfile; + } catch { + return null; + } +} + +function desktopDelete(id: string): void { + const ids = desktopList().filter(i => i !== id); + localStorage.setItem(INDEX_KEY, JSON.stringify(ids)); + localStorage.removeItem(STORAGE_KEY_PREFIX + id); + log('[profile-store] deleted id=%s', id); +} + +// -- iOS backend (pragmatic interim) ---------------------------------------- +// +// iOS WebView storage is sandboxed per-app by the OS, so localStorage is +// protected from other apps on a non-jailbroken device. +// +// SECURITY TODO(post-Layer-7): migrate to Keychain via tauri-plugin-keychain +// or a custom Swift Tauri command. Threat model for the interim solution: +// PROTECTED: other apps (iOS sandbox), remote attackers. +// NOT PROTECTED: jailbroken device, malicious WebView injection. +// For a v1 demo paired with a sandboxed WKWebView on a stock iOS device this +// is acceptable. The key material (devicePrivkey, sessionToken) should be +// migrated to the Secure Enclave before public release. + +// iOS uses the same localStorage implementation as desktop. The functions +// are identical because the iOS WKWebView localStorage is app-sandboxed. +// This section is left as a named seam so Layer 7 can swap just the iOS path. + +function iosList(): string[] { + return desktopList(); +} + +function iosSave(profile: ConnectionProfile): void { + desktopSave(profile); + log('[profile-store:ios] saved id=%s kind=%s', profile.id, profile.kind); +} + +function iosGet(id: string): ConnectionProfile | null { + return desktopGet(id); +} + +function iosDelete(id: string): void { + desktopDelete(id); + log('[profile-store:ios] deleted id=%s', id); +} + +// -- platform selector ------------------------------------------------------- +// We import getIsIOS() (not the isIOS constant) so that test overrides via +// setTestPlatform() are respected on each call rather than frozen at module +// load time (which is when the isIOS constant is evaluated). +function onIOS(): boolean { + return getIsIOS(); +} + +// -- public API -------------------------------------------------------------- + +/** Save or update a profile. */ +export function saveProfile(profile: ConnectionProfile): void { + if (onIOS()) { + iosSave(profile); + } else { + desktopSave(profile); + } +} + +/** Load a profile by id. Returns null if not found. */ +export function getProfile(id: string): ConnectionProfile | null { + return onIOS() ? iosGet(id) : desktopGet(id); +} + +/** List all stored profile IDs. */ +export function listProfileIds(): string[] { + return onIOS() ? iosList() : desktopList(); +} + +/** Load all stored profiles. */ +export function listProfiles(): ConnectionProfile[] { + const ids = onIOS() ? iosList() : desktopList(); + const getter = onIOS() ? iosGet : desktopGet; + return ids.map(getter).filter((p): p is ConnectionProfile => p !== null); +} + +/** Delete a profile. */ +export function deleteProfile(id: string): void { + if (onIOS()) { + iosDelete(id); + } else { + desktopDelete(id); + } +} diff --git a/app/test/vitest.config.ts b/app/test/vitest.config.ts index e6990e1924..4c39ef7a83 100644 --- a/app/test/vitest.config.ts +++ b/app/test/vitest.config.ts @@ -24,6 +24,11 @@ export default defineConfig({ process: "process/browser", util: "util", os: "os-browserify/browser", + // Resolve workspace package imports for tests that import the PTT plugin. + "tauri-plugin-ptt-api": path.resolve( + configDir, + "../../packages/tauri-plugin-ptt/guest-js/index.ts" + ), }, }, test: { @@ -37,7 +42,18 @@ export default defineConfig({ mockReset: false, restoreMocks: false, setupFiles: ["src/test/setup.ts"], - include: ["src/**/*.test.{ts,tsx}", "test/*.test.{ts,tsx}"], + include: [ + "src/**/*.test.{ts,tsx}", + "test/*.test.{ts,tsx}", + ], + // The PTT plugin's guest-js test (`packages/tauri-plugin-ptt/guest-js/index.test.ts`) + // is intentionally NOT included here. The app's vitest config injects + // `vite-plugin-node-polyfills` banner imports (Buffer/process/global) that + // resolve fine from within `app/` but fail from outside the workspace root + // on a stricter pnpm CI install (`Failed to resolve import + // "vite-plugin-node-polyfills/shims/buffer"`). The PTT test only mocks the + // Tauri JS bindings and doesn't need the polyfills — a future PR can add + // a self-contained vitest setup at packages/tauri-plugin-ptt/. hookTimeout: 30000, testTimeout: 30000, coverage: { diff --git a/app/tsconfig.json b/app/tsconfig.json index eaeb750c15..19df250456 100644 --- a/app/tsconfig.json +++ b/app/tsconfig.json @@ -16,7 +16,10 @@ /* Path aliases */ "baseUrl": ".", - "paths": { "@openhuman/skill-types": ["src/lib/skills/types.ts"] }, + "paths": { + "@openhuman/skill-types": ["src/lib/skills/types.ts"], + "tauri-plugin-ptt-api": ["../packages/tauri-plugin-ptt/guest-js/index.ts"] + }, /* Linting */ "strict": true, @@ -24,7 +27,12 @@ "noUnusedParameters": true, "noFallthroughCasesInSwitch": true }, - "include": ["src", "test/*.test.ts", "test/*.test.tsx"], + "include": [ + "src", + "test/*.test.ts", + "test/*.test.tsx", + "../packages/tauri-plugin-ptt/guest-js/index.ts" + ], "exclude": ["skills"], "references": [{ "path": "./tsconfig.node.json" }] } diff --git a/docs/ios/SETUP.md b/docs/ios/SETUP.md new file mode 100644 index 0000000000..35978a3580 --- /dev/null +++ b/docs/ios/SETUP.md @@ -0,0 +1,152 @@ +# iOS Client Setup + +This document covers everything a developer needs to build, run, and test the OpenHuman iOS client. + +--- + +## Prerequisites + +- macOS 14+ with Xcode 15.4+ +- iOS 17+ physical device or simulator +- Rust toolchain with `aarch64-apple-ios` target +- pnpm (version pinned in root `package.json`) +- Apple Developer account with a provisioning profile + +```bash +rustup target add aarch64-apple-ios aarch64-apple-ios-sim +``` + +--- + +## Initial setup + +Run the helper script from the repo root. It calls `tauri ios init` with the correct working directory and prints next steps. + +```bash +bash scripts/ios-init.sh +``` + +`tauri ios init` scaffolds `app/src-tauri/gen/apple/`. That directory is **gitignored** (it contains bundle-identifier-specific Xcode project files that differ per developer account). + +### Info.plist privacy keys + +`tauri ios init` creates a generated `Info.plist` at: + +``` +app/src-tauri/gen/apple/_iOS/Info.plist +``` + +You must copy the three privacy keys from `app/src-tauri/Info.ios.plist` into that generated file before building: + +```xml +NSCameraUsageDescription +OpenHuman uses the camera to scan the pairing QR code from your desktop. + +NSMicrophoneUsageDescription +OpenHuman uses the microphone for push-to-talk voice messages. + +NSSpeechRecognitionUsageDescription +OpenHuman uses on-device speech recognition to transcribe your voice messages. +``` + +**Option A (recommended for now):** Manual copy after each `tauri ios init` run. + +**Option B (automate in a follow-up PR):** Set the `bundle.iOS.template` key in `app/src-tauri/tauri.conf.json` to point at a hand-crafted `Info.plist` template once Tauri v2 stabilises its iOS template pipeline. Until that happens, Option A is simpler and less brittle. + +--- + +## Development workflow + +```bash +# Start the iOS dev build (hot-reload via Vite, deployed to simulator or device): +pnpm tauri:ios:dev + +# From the repo root: +pnpm tauri:ios:dev +``` + +The `tauri:ios:dev` script uses `@tauri-apps/cli@^2` directly (via `npx --package`), **not** the vendored CEF-aware CLI. The CEF CLI is only needed for the desktop build. + +Set your development team in Xcode (generated project > Signing & Capabilities) before deploying to a physical device. + +--- + +## Production build + +```bash +pnpm tauri:ios:build +# or from repo root: +pnpm tauri:ios:build +``` + +--- + +## Pairing flow + +``` +Desktop iOS + | | + |-- Settings > Devices > "Pair" | + |-- devices_create_pairing RPC | + | (backend issues channelId, | + | pairingToken, sessionToken) | + |-- QR shown | + | scan QR --------| + | (extract cid, | + | pt, cpk, rpc?) | + | iOS connects | + | to backend | + | tunnel:connect | + | (role:client, | + | channelId, | + | pairingToken) | + | backend returns | + | iOS sessionToken| + | X25519 handshake| + | over tunnel | + |<-- DevicePaired event | + |-- device appears in Devices list | +``` + +Transport selection (handled by `TransportManager`): +1. LAN HTTP -- fast, zero-latency, requires same network. +2. Socket.io tunnel -- E2E encrypted via XChaCha20-Poly1305 over X25519 key agreement. +3. Cloud HTTP -- fallback when LAN and tunnel are unreachable. + +--- + +## Security notes + +- The tunnel backend is a **blind forwarder**. It never sees plaintext payloads. +- `pairingToken` is single-use and hashed at rest on the backend. +- `sessionToken` is per-peer, revocable from the desktop Devices panel. +- X25519 key agreement runs on first connect; the derived symmetric key is stored in-memory for the session. +- **TODO (follow-up PR):** migrate the iOS symmetric key to the iOS Keychain for persistence across app restarts without re-pairing. + +--- + +## Known limitations + +- Single backend instance only (no multi-region failover). +- No APNs push notifications -- app must be foregrounded for real-time delivery. +- Event-driven pairing detection on the desktop side uses 2-second polling until an SSE/socket event bridge lands. +- Xcode signing must be set manually in the generated project (no CI automation yet). + +--- + +## CI + +The `.github/workflows/ios-compile.yml` workflow runs on every PR that touches iOS-related paths. It provides: + +- **Hard gate:** `cargo check` on the host target for `app/src-tauri` and `packages/tauri-plugin-ptt`. +- **Hard gate:** TypeScript compile (`pnpm compile`). +- **Hard gate:** iOS-related Vitest suites. +- **Soft gate (`continue-on-error: true`):** `cargo check --target aarch64-apple-ios` -- this catches gross API breakage but may fail on third-party C deps that need full Xcode. Failures are flagged but do not block merge. + +Full iOS builds (simulator + device) require macOS runners with Xcode installed. This is tracked as a follow-up to this PR. + +--- + +## Backend dependency + +The tunnel transport requires `tinyhumansai/backend#709` to be merged and deployed before end-to-end pairing works. The `devices_create_pairing` RPC will return a tunnel registration error until that backend is live. diff --git a/gitbooks/developing/architecture.md b/gitbooks/developing/architecture.md index 04241e20dd..7d5ce4fed8 100644 --- a/gitbooks/developing/architecture.md +++ b/gitbooks/developing/architecture.md @@ -349,3 +349,55 @@ Every layer is async and non-blocking. The Rust core processes thousands of conc | **AI** | MCP (JSON-RPC 2.0) | Standardized tool protocol for LLM integration | | **Search** | OpenAI embeddings + SQLite FTS5 | Hybrid semantic + keyword search | | **Graph** | Neo4j | Entity relationship knowledge graph | + +--- + +## iOS Client (experimental) + +The iOS client is a Tauri v2 app that shares the React/TypeScript UI codebase but ships **no Rust core binary on-device**. All AI, RPC, and domain logic remain on the desktop core; the iOS app is a thin transport client. + +### Transport architecture + +``` +iOS App (React + Tauri iOS shell) + | + TransportManager (app/src/services/transport/TransportManager.ts) + |-- LanHttpTransport direct HTTP to desktop core (same LAN) + |-- TunnelTransport socket.io relay; E2E encrypted + |-- CloudHttpTransport fallback via cloud backend API +``` + +Transport is selected by `ConnectionProfile` stored in secure storage. On pairing, the iOS app stores `{channelId, sessionToken, corePubkey, devicePrivkey}`. + +### Pairing flow + +1. Desktop: `devices_create_pairing` RPC -> backend issues `{channelId, pairingToken, sessionToken}`. +2. Desktop shows QR: `openhuman://pair?cid=<>&pt=<>&cpk=<>&rpc=<>&exp=<>`. +3. iOS scans QR, generates X25519 keypair, connects to backend (`tunnel:connect`, `role:client`). +4. Backend consumes `pairingToken` (single-use) and returns iOS `sessionToken`. +5. X25519 key agreement over `tunnel:frame` -> XChaCha20-Poly1305 symmetric key. +6. Desktop emits `DomainEvent::DevicePaired`; device appears in the Devices panel. + +### Key paths + +| Path | Purpose | +| --- | --- | +| `src/openhuman/devices/` | Rust devices domain (pairing, store, crypto, event bus) | +| `app/src/services/transport/` | TS transport strategies + manager | +| `app/src/lib/tunnel/` | TS tunnel crypto (X25519 + XChaCha20-Poly1305) | +| `app/src/pages/ios/` | iOS-specific screens (PairScreen, MascotScreen) | +| `packages/tauri-plugin-ptt/` | Swift PTT plugin (AVAudioEngine + SFSpeechRecognizer) | +| `app/src-tauri/Info.ios.plist` | Privacy strings for iOS Info.plist | +| `docs/ios/SETUP.md` | Developer setup guide | + +### Security + +- Tunnel backend is a blind forwarder -- never sees plaintext payloads. +- `pairingToken` is single-use, TTL'd, hashed at rest on backend. +- `sessionToken` is per-peer and revocable from the desktop Devices panel. +- Speech recognition runs on-device (Apple Speech framework); audio never leaves the device. +- **TODO:** migrate iOS symmetric session key to Keychain for persistence across restarts. + +### Backend dependency + +`tinyhumansai/backend#709` implements the `tunnel:register` / `tunnel:connect` / `tunnel:frame` socket.io protocol. End-to-end pairing does not work until that PR is merged and deployed. diff --git a/package.json b/package.json index f7c8a386ee..cf0f10bb93 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,12 @@ "test:install-ps1": "pwsh -NoProfile -File scripts/tests/OpenHumanWindowsInstall.Tests.ps1", "rust:check": "pnpm --filter openhuman-app rust:check", "typecheck": "pnpm --filter openhuman-app compile", + "tauri:ios:init": "bash scripts/ios-init.sh", + "tauri:ios:dev": "pnpm --filter openhuman-app tauri:ios:dev", + "tauri:ios:build": "pnpm --filter openhuman-app tauri:ios:build", + "tauri:android:init": "bash scripts/android-init.sh", + "tauri:android:dev": "pnpm --filter openhuman-app tauri:android:dev", + "tauri:android:build": "pnpm --filter openhuman-app tauri:android:build", "i18n:check": "tsx scripts/i18n-coverage.ts", "i18n:bundle:check": "node scripts/verify-i18n-bundle.mjs", "i18n:dump": "tsx scripts/i18n-coverage.ts --no-unused --out tmp/i18n-coverage" diff --git a/packages/tauri-plugin-ptt/.gitignore b/packages/tauri-plugin-ptt/.gitignore new file mode 100644 index 0000000000..08ba1f8301 --- /dev/null +++ b/packages/tauri-plugin-ptt/.gitignore @@ -0,0 +1,4 @@ +target/ +ios/.build/ +.tauri/ +ios/Package.resolved diff --git a/packages/tauri-plugin-ptt/Cargo.toml b/packages/tauri-plugin-ptt/Cargo.toml new file mode 100644 index 0000000000..b79bcdf748 --- /dev/null +++ b/packages/tauri-plugin-ptt/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "tauri-plugin-ptt" +version = "0.1.0" +edition = "2021" +description = "Push-to-talk plugin for Tauri v2 — iOS AVAudioEngine + Speech + TTS" +license = "MIT" +authors = ["OpenHuman"] +# Required by tauri_plugin::Builder::try_build — must match package.name. +links = "tauri-plugin-ptt" + +[lib] +crate-type = ["cdylib", "rlib"] + +[build-dependencies] +tauri-plugin = { version = "2", features = ["build"] } + +[dependencies] +tauri = { version = "2", default-features = false } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +thiserror = "2" +log = "0.4" + +[target.'cfg(target_os = "ios")'.dependencies] +# No additional Rust-side iOS deps — the bridge to Swift runs through +# Tauri's ios_plugin_binding! / register_ios_plugin path. diff --git a/packages/tauri-plugin-ptt/README.md b/packages/tauri-plugin-ptt/README.md new file mode 100644 index 0000000000..b0bf08ab86 --- /dev/null +++ b/packages/tauri-plugin-ptt/README.md @@ -0,0 +1,75 @@ +# tauri-plugin-ptt + +Push-to-talk + TTS plugin for Tauri v2, targeting iOS. + +Wraps `AVAudioEngine` + `Speech.framework` (STT) + `AVSpeechSynthesizer` (TTS). +On non-iOS targets all commands return a `NotSupported` error so the +desktop build is not affected. + +## Commands + +| Command | Description | +|---|---| +| `start_listening` | Activate `AVAudioEngine` + `SFSpeechRecognizer`. Partial transcripts arrive as events. | +| `stop_listening` | Deactivate and return final transcript text. | +| `speak` | Enqueue an `AVSpeechSynthesizer` utterance. | +| `cancel_speech` | Stop current utterance immediately. | +| `list_voices` | List all `AVSpeechSynthesisVoice.speechVoices()`. | + +## Events + +| Event | Payload | Description | +|---|---|---| +| `ptt://transcript-partial` | `{ text: string }` | Live partial result while recording. | +| `ptt://transcript-final` | `{ text: string }` | Final result after `stop_listening`. | +| `ptt://tts-started` | `{ utteranceId: string }` | TTS synthesis began. | +| `ptt://tts-ended` | `{ utteranceId: string; finished: boolean }` | TTS ended (false=cancelled). | +| `ptt://error` | `{ code: string; message: string }` | Async error (permission, interruption, etc.). | + +### Error codes + +| Code | Trigger | +|---|---| +| `permission_denied` | Microphone or speech recognition access denied. | +| `interrupted` | Phone call or system audio interrupted the session. | +| `route_changed` | BT headset disconnected mid-recording. | +| `audio_error` | AVAudioEngine failure. | +| `recognition_error` | SFSpeechRecognizer transcription failure. | + +## Required iOS permissions (Info.plist) + +```xml +NSMicrophoneUsageDescription +Used for push-to-talk voice messages. +NSSpeechRecognitionUsageDescription +Used to transcribe your voice to text. +``` + +## Manual testing checklist + +The Swift layer cannot be unit-tested in CI (requires iOS toolchain + simulator). +Test on a physical device or simulator against the following: + +- [ ] 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. + +## Architecture + +``` +JS (MascotScreen) + ↓ invoke / listen +Rust (commands.rs) + ↓ PluginHandle::run_mobile_plugin +Swift (PTTPlugin.swift) + ↓ + PTTRecorder — AVAudioEngine + SFSpeechRecognizer + PTTSpeaker — AVSpeechSynthesizer + AudioSessionManager — AVAudioSession lifecycle + notifications +``` diff --git a/packages/tauri-plugin-ptt/build.rs b/packages/tauri-plugin-ptt/build.rs new file mode 100644 index 0000000000..297bda1abc --- /dev/null +++ b/packages/tauri-plugin-ptt/build.rs @@ -0,0 +1,16 @@ +// Standard Tauri v2 plugin build script. +// Generates iOS Swift package metadata consumed by `tauri-plugin`. +const COMMANDS: &[&str] = &[ + "start_listening", + "stop_listening", + "speak", + "cancel_speech", + "list_voices", +]; + +fn main() { + tauri_plugin::Builder::new(COMMANDS) + .ios_path("ios") + .try_build() + .expect("failed to run tauri-plugin build"); +} diff --git a/packages/tauri-plugin-ptt/guest-js/index.test.ts b/packages/tauri-plugin-ptt/guest-js/index.test.ts new file mode 100644 index 0000000000..c45ac194da --- /dev/null +++ b/packages/tauri-plugin-ptt/guest-js/index.test.ts @@ -0,0 +1,181 @@ +/** + * Unit tests for tauri-plugin-ptt JS bindings. + * + * Verifies that each exported function calls the correct Tauri command name + * with the correct argument structure, and that event subscriptions call + * `listen` with the expected event name. + */ +import { describe, expect, it, vi, beforeEach } from 'vitest'; + +// Mock @tauri-apps/api/core and @tauri-apps/api/event before importing the module. +const mockInvoke = vi.fn(); +const mockListen = vi.fn(); + +vi.mock('@tauri-apps/api/core', () => ({ + invoke: (cmd: string, args?: unknown) => mockInvoke(cmd, args), +})); + +vi.mock('@tauri-apps/api/event', () => ({ + listen: (event: string, cb: unknown) => mockListen(event, cb), +})); + +import { + cancelSpeech, + listVoices, + onError, + onTranscriptFinal, + onTranscriptPartial, + onTtsEnded, + onTtsStarted, + speak, + startListening, + stopListening, +} from './index'; + +beforeEach(() => { + mockInvoke.mockReset(); + mockListen.mockReset(); + mockInvoke.mockResolvedValue(undefined); + mockListen.mockResolvedValue(vi.fn()); +}); + +// ── Commands ───────────────────────────────────────────────────────────────── + +describe('startListening', () => { + it('invokes plugin:ptt|start_listening', async () => { + await startListening(); + expect(mockInvoke.mock.calls[0][0]).toBe('plugin:ptt|start_listening'); + }); +}); + +describe('stopListening', () => { + it('invokes plugin:ptt|stop_listening', async () => { + mockInvoke.mockResolvedValueOnce({ text: 'hello', isFinal: true }); + const result = await stopListening(); + expect(mockInvoke.mock.calls[0][0]).toBe('plugin:ptt|stop_listening'); + expect(result).toEqual({ text: 'hello', isFinal: true }); + }); +}); + +describe('speak', () => { + it('invokes plugin:ptt|speak with text and null opts', async () => { + await speak('Hello world'); + expect(mockInvoke).toHaveBeenCalledWith('plugin:ptt|speak', { + text: 'Hello world', + voiceId: null, + rate: null, + }); + }); + + it('passes voiceId and rate when provided', async () => { + await speak('Hi', { voiceId: 'com.apple.voice.compact.en-US.Samantha', rate: 1.2 }); + expect(mockInvoke).toHaveBeenCalledWith('plugin:ptt|speak', { + text: 'Hi', + voiceId: 'com.apple.voice.compact.en-US.Samantha', + rate: 1.2, + }); + }); +}); + +describe('cancelSpeech', () => { + it('invokes plugin:ptt|cancel_speech', async () => { + await cancelSpeech(); + expect(mockInvoke.mock.calls[0][0]).toBe('plugin:ptt|cancel_speech'); + }); +}); + +describe('listVoices', () => { + it('invokes plugin:ptt|list_voices and returns voice list', async () => { + const voices = [{ id: 'v1', name: 'Samantha', lang: 'en-US' }]; + mockInvoke.mockResolvedValueOnce(voices); + const result = await listVoices(); + expect(mockInvoke.mock.calls[0][0]).toBe('plugin:ptt|list_voices'); + expect(result).toEqual(voices); + }); +}); + +// ── Event subscriptions ────────────────────────────────────────────────────── + +describe('onTranscriptPartial', () => { + it('calls listen with ptt://transcript-partial', async () => { + const cb = vi.fn(); + await onTranscriptPartial(cb); + expect(mockListen).toHaveBeenCalledWith('ptt://transcript-partial', expect.any(Function)); + }); + + it('delivers text from the event payload', async () => { + let capturedHandler: ((e: { payload: { text: string } }) => void) | undefined; + mockListen.mockImplementation((_event: string, handler: (e: { payload: { text: string } }) => void) => { + capturedHandler = handler; + return Promise.resolve(vi.fn()); + }); + + const cb = vi.fn(); + await onTranscriptPartial(cb); + + capturedHandler?.({ payload: { text: 'partial text' } }); + expect(cb).toHaveBeenCalledWith('partial text'); + }); +}); + +describe('onTranscriptFinal', () => { + it('calls listen with ptt://transcript-final', async () => { + await onTranscriptFinal(vi.fn()); + expect(mockListen).toHaveBeenCalledWith('ptt://transcript-final', expect.any(Function)); + }); +}); + +describe('onTtsStarted', () => { + it('calls listen with ptt://tts-started and delivers utteranceId', async () => { + let capturedHandler: ((e: { payload: { utteranceId: string } }) => void) | undefined; + mockListen.mockImplementation((_event: string, handler: (e: { payload: { utteranceId: string } }) => void) => { + capturedHandler = handler; + return Promise.resolve(vi.fn()); + }); + + const cb = vi.fn(); + await onTtsStarted(cb); + + expect(mockListen).toHaveBeenCalledWith('ptt://tts-started', expect.any(Function)); + capturedHandler?.({ payload: { utteranceId: 'uid-123' } }); + expect(cb).toHaveBeenCalledWith('uid-123'); + }); +}); + +describe('onTtsEnded', () => { + it('calls listen with ptt://tts-ended and delivers utteranceId + finished', async () => { + let capturedHandler: ((e: { payload: { utteranceId: string; finished: boolean } }) => void) | undefined; + mockListen.mockImplementation((_event: string, handler: (e: { payload: { utteranceId: string; finished: boolean } }) => void) => { + capturedHandler = handler; + return Promise.resolve(vi.fn()); + }); + + const cb = vi.fn(); + await onTtsEnded(cb); + + expect(mockListen).toHaveBeenCalledWith('ptt://tts-ended', expect.any(Function)); + capturedHandler?.({ payload: { utteranceId: 'uid-456', finished: false } }); + expect(cb).toHaveBeenCalledWith('uid-456', false); + }); +}); + +describe('onError', () => { + it('calls listen with ptt://error', async () => { + await onError(vi.fn()); + expect(mockListen).toHaveBeenCalledWith('ptt://error', expect.any(Function)); + }); + + it('delivers error payload', async () => { + let capturedHandler: ((e: { payload: { code: string; message: string } }) => void) | undefined; + mockListen.mockImplementation((_event: string, handler: (e: { payload: { code: string; message: string } }) => void) => { + capturedHandler = handler; + return Promise.resolve(vi.fn()); + }); + + const cb = vi.fn(); + await onError(cb); + + capturedHandler?.({ payload: { code: 'interrupted', message: 'call came in' } }); + expect(cb).toHaveBeenCalledWith({ code: 'interrupted', message: 'call came in' }); + }); +}); diff --git a/packages/tauri-plugin-ptt/guest-js/index.ts b/packages/tauri-plugin-ptt/guest-js/index.ts new file mode 100644 index 0000000000..3db2d5c0cb --- /dev/null +++ b/packages/tauri-plugin-ptt/guest-js/index.ts @@ -0,0 +1,129 @@ +/** + * tauri-plugin-ptt — JS bindings for push-to-talk and TTS. + * + * Commands are routed via `plugin:ptt|`. + * Events arrive on the Tauri event bus from the Swift plugin: + * ptt://transcript-partial { text: string } + * ptt://transcript-final { text: string } + * ptt://tts-started { utteranceId: string } + * ptt://tts-ended { utteranceId: string; finished: boolean } + * ptt://error { code: string; message: string } + */ +import { invoke } from '@tauri-apps/api/core'; +import { listen, type UnlistenFn } from '@tauri-apps/api/event'; + +// ── Types ──────────────────────────────────────────────────────────────────── + +export interface TranscriptEvent { + text: string; + isFinal: boolean; +} + +export interface VoiceInfo { + id: string; + name: string; + lang: string; +} + +export interface PttError { + code: string; + message: string; +} + +export interface TtsEndedEvent { + utteranceId: string; + finished: boolean; +} + +// ── Commands ───────────────────────────────────────────────────────────────── + +/** + * Begin a push-to-talk recording session. + * Partial transcripts arrive as `ptt://transcript-partial` events. + * Call `stopListening()` to end the session and get the final text. + */ +export async function startListening(): Promise { + await invoke('plugin:ptt|start_listening'); +} + +/** + * Stop the active recording session. + * Returns the final recognized text. + * Also emits `ptt://transcript-final`. + */ +export async function stopListening(): Promise { + return await invoke('plugin:ptt|stop_listening'); +} + +/** + * Enqueue a TTS utterance via AVSpeechSynthesizer. + * @param text - Text to speak. + * @param opts.voiceId - Optional AVSpeechSynthesisVoice identifier. + * @param opts.rate - Speed multiplier 0.5–2.0 (default 1.0). + */ +export async function speak( + text: string, + opts?: { voiceId?: string; rate?: number } +): Promise { + await invoke('plugin:ptt|speak', { + text, + voiceId: opts?.voiceId ?? null, + rate: opts?.rate ?? null, + }); +} + +/** + * Immediately stop any in-progress TTS utterance. + */ +export async function cancelSpeech(): Promise { + await invoke('plugin:ptt|cancel_speech'); +} + +/** + * List all on-device TTS voices from AVSpeechSynthesisVoice.speechVoices(). + */ +export async function listVoices(): Promise { + return await invoke('plugin:ptt|list_voices'); +} + +// ── Event subscriptions ────────────────────────────────────────────────────── + +/** + * Subscribe to live partial transcripts while the user speaks. + */ +export async function onTranscriptPartial(cb: (text: string) => void): Promise { + return listen<{ text: string }>('ptt://transcript-partial', e => cb(e.payload.text)); +} + +/** + * Subscribe to the final transcript emitted after stopListening(). + */ +export async function onTranscriptFinal(cb: (text: string) => void): Promise { + return listen<{ text: string }>('ptt://transcript-final', e => cb(e.payload.text)); +} + +/** + * Subscribe to the TTS started event. Fires when synthesis begins for an utterance. + */ +export async function onTtsStarted(cb: (utteranceId: string) => void): Promise { + return listen<{ utteranceId: string }>('ptt://tts-started', e => cb(e.payload.utteranceId)); +} + +/** + * Subscribe to the TTS ended event. + * `finished` is false if the utterance was cancelled before completion. + */ +export async function onTtsEnded( + cb: (utteranceId: string, finished: boolean) => void +): Promise { + return listen('ptt://tts-ended', e => + cb(e.payload.utteranceId, e.payload.finished) + ); +} + +/** + * Subscribe to async PTT errors (permission denied, interruption, route change, etc.). + */ +export async function onError(cb: (err: PttError) => void): Promise { + return listen('ptt://error', e => cb(e.payload)); +} diff --git a/packages/tauri-plugin-ptt/guest-js/tsconfig.json b/packages/tauri-plugin-ptt/guest-js/tsconfig.json new file mode 100644 index 0000000000..ec7951d202 --- /dev/null +++ b/packages/tauri-plugin-ptt/guest-js/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "../dist-js", + "skipLibCheck": true + }, + "include": ["index.ts"] +} diff --git a/packages/tauri-plugin-ptt/ios/Package.swift b/packages/tauri-plugin-ptt/ios/Package.swift new file mode 100644 index 0000000000..60b88ab60c --- /dev/null +++ b/packages/tauri-plugin-ptt/ios/Package.swift @@ -0,0 +1,28 @@ +// swift-tools-version:5.9 +import PackageDescription + +let package = Package( + name: "tauri-plugin-ptt", + platforms: [ + .iOS(.v16), + ], + products: [ + .library( + name: "tauri-plugin-ptt", + type: .static, + targets: ["tauri-plugin-ptt"] + ), + ], + dependencies: [ + .package(name: "Tauri", path: "../.tauri/tauri-api"), + ], + targets: [ + .target( + name: "tauri-plugin-ptt", + dependencies: [ + .product(name: "Tauri", package: "Tauri"), + ], + path: "Sources/tauri-plugin-ptt" + ), + ] +) diff --git a/packages/tauri-plugin-ptt/ios/Sources/tauri-plugin-ptt/AudioSessionManager.swift b/packages/tauri-plugin-ptt/ios/Sources/tauri-plugin-ptt/AudioSessionManager.swift new file mode 100644 index 0000000000..51d51b7d2e --- /dev/null +++ b/packages/tauri-plugin-ptt/ios/Sources/tauri-plugin-ptt/AudioSessionManager.swift @@ -0,0 +1,106 @@ +// AudioSessionManager.swift +// Manages AVAudioSession category, activation, and notification handling. +// +// Pattern adapted from chat4000/Sources/Services/VoiceNotes.swift: +// session.setCategory(.playAndRecord, mode: .spokenAudio, +// options: [.defaultToSpeaker, .allowBluetoothA2DP]) + +import AVFoundation +import os.log + +private let log = Logger(subsystem: "ai.openhuman.ptt", category: "AudioSessionManager") + +/// Centralises AVAudioSession lifecycle so PTTRecorder and PTTSpeaker +/// share a single category configuration. Activating the session once +/// for both recording and playback avoids category-flip glitches on BT. +final class AudioSessionManager { + static let shared = AudioSessionManager() + private init() {} + + private var interruptionObserver: NSObjectProtocol? + private var routeChangeObserver: NSObjectProtocol? + + /// Called once by PTTPlugin to wire up system notifications. + /// `onInterrupted` fires when a phone call or system audio takes over. + /// `onRouteChange` fires when BT headset connects / disconnects. + func startObserving( + onInterrupted: @escaping () -> Void, + onRouteChange: @escaping (AVAudioSession.RouteChangeReason) -> Void + ) { + let nc = NotificationCenter.default + + interruptionObserver = nc.addObserver( + forName: AVAudioSession.interruptionNotification, + object: nil, + queue: .main + ) { notification in + guard + let info = notification.userInfo, + let typeValue = info[AVAudioSessionInterruptionTypeKey] as? UInt, + let type = AVAudioSession.InterruptionType(rawValue: typeValue) + else { return } + + if type == .began { + log.info("[ptt] audio session interrupted — began") + onInterrupted() + } else { + log.debug("[ptt] audio session interruption ended") + } + } + + routeChangeObserver = nc.addObserver( + forName: AVAudioSession.routeChangeNotification, + object: nil, + queue: .main + ) { notification in + guard + let info = notification.userInfo, + let reasonValue = info[AVAudioSessionRouteChangeReasonKey] as? UInt, + let reason = AVAudioSession.RouteChangeReason(rawValue: reasonValue) + else { return } + + log.info("[ptt] audio route changed reason=\(reason.rawValue)") + onRouteChange(reason) + } + + log.debug("[ptt] AudioSessionManager: observers registered") + } + + func stopObserving() { + let nc = NotificationCenter.default + if let obs = interruptionObserver { nc.removeObserver(obs) } + if let obs = routeChangeObserver { nc.removeObserver(obs) } + interruptionObserver = nil + routeChangeObserver = nil + log.debug("[ptt] AudioSessionManager: observers removed") + } + + // MARK: - Session activation + + /// Activate the shared session for recording + playback. + /// Category: .playAndRecord, mode: .spokenAudio + /// Options: .defaultToSpeaker, .allowBluetooth, .allowBluetoothA2DP + func activateForRecording() throws { + let session = AVAudioSession.sharedInstance() + try session.setCategory( + .playAndRecord, + mode: .spokenAudio, + options: [.defaultToSpeaker, .allowBluetooth, .allowBluetoothA2DP] + ) + try session.setActive(true) + log.info("[ptt] audio session activated for recording") + } + + /// Deactivate the session, notifying other apps they can resume. + func deactivate() { + do { + try AVAudioSession.sharedInstance().setActive( + false, + options: .notifyOthersOnDeactivation + ) + log.info("[ptt] audio session deactivated") + } catch { + log.error("[ptt] audio session deactivate error: \(error.localizedDescription)") + } + } +} diff --git a/packages/tauri-plugin-ptt/ios/Sources/tauri-plugin-ptt/PTTPlugin.swift b/packages/tauri-plugin-ptt/ios/Sources/tauri-plugin-ptt/PTTPlugin.swift new file mode 100644 index 0000000000..70b66b85be --- /dev/null +++ b/packages/tauri-plugin-ptt/ios/Sources/tauri-plugin-ptt/PTTPlugin.swift @@ -0,0 +1,205 @@ +// PTTPlugin.swift +// Tauri v2 plugin class. Bridges Rust commands to PTTRecorder / PTTSpeaker. +// +// Command names must match the Rust command names in commands.rs +// (Tauri converts snake_case to camelCase for the Swift @objc method). + +import AVFoundation +import os.log +import Tauri +import UIKit +import WebKit + +private let log = Logger(subsystem: "ai.openhuman.ptt", category: "PTTPlugin") + +// MARK: - Codable payload types (mirror models.rs) + +private struct TranscriptResult: Encodable { + let text: String + let isFinal: Bool +} + +private struct VoiceInfoPayload: Encodable { + let id: String + let name: String + let lang: String +} + +private struct SpeakArgs: Decodable { + let text: String + let voiceId: String? + let rate: Float? +} + +// MARK: - PTTPlugin + +class PTTPlugin: Plugin { + private let recorder = PTTRecorder() + private let speaker = PTTSpeaker() + + override func load(webview: WKWebView) { + super.load(webview: webview) + log.info("[ptt] PTTPlugin: load — wiring audio session observers") + + AudioSessionManager.shared.startObserving( + onInterrupted: { [weak self] in + self?.handleInterruption() + }, + onRouteChange: { [weak self] reason in + self?.handleRouteChange(reason: reason) + } + ) + + recorder.onPartialTranscript = { [weak self] text in + log.debug("[ptt] PTTPlugin: partial transcript text_len=\(text.count)") + self?.trigger("ptt://transcript-partial", data: ["text": text]) + } + + recorder.onError = { [weak self] code, message in + log.error("[ptt] PTTPlugin: async error code=\(code) message=\(message)") + self?.trigger("ptt://error", data: ["code": code, "message": message]) + } + + speaker.onStarted = { [weak self] uid in + log.debug("[ptt] PTTPlugin: tts started uid=\(uid)") + self?.trigger("ptt://tts-started", data: ["utteranceId": uid]) + } + + speaker.onEnded = { [weak self] uid, finished in + log.debug("[ptt] PTTPlugin: tts ended uid=\(uid) finished=\(finished)") + self?.trigger("ptt://tts-ended", data: ["utteranceId": uid, "finished": finished]) + } + + // Cancel active speech when the app backgrounds so the OS audio + // session can be released cleanly. + NotificationCenter.default.addObserver( + self, + selector: #selector(appDidBackground), + name: UIApplication.didEnterBackgroundNotification, + object: nil + ) + } + + // MARK: - Commands (called by Tauri runtime via @objc) + + @objc func startListening(_ invoke: Invoke) { + log.info("[ptt] PTTPlugin: startListening command received") + Task { + do { + try await self.recorder.startListening() + log.debug("[ptt] PTTPlugin: startListening succeeded") + invoke.resolve() + } catch { + log.error("[ptt] PTTPlugin: startListening error: \(error.localizedDescription)") + invoke.reject(error.localizedDescription) + self.emitPermissionOrAudioError(from: error) + } + } + } + + @objc func stopListening(_ invoke: Invoke) { + log.info("[ptt] PTTPlugin: stopListening command received") + let finalText = recorder.stopListening() + log.debug("[ptt] PTTPlugin: stopListening final text_len=\(finalText.count)") + trigger("ptt://transcript-final", data: ["text": finalText]) + let result = TranscriptResult(text: finalText, isFinal: true) + invoke.resolve(result) + } + + @objc func speak(_ invoke: Invoke) { + log.info("[ptt] PTTPlugin: speak command received") + do { + let args = try invoke.parseArgs(SpeakArgs.self) + log.debug("[ptt] PTTPlugin: speak text_len=\(args.text.count)") + speaker.speak(text: args.text, voiceId: args.voiceId, rate: args.rate) + invoke.resolve() + } catch { + log.error("[ptt] PTTPlugin: speak parse error: \(error.localizedDescription)") + invoke.reject(error.localizedDescription) + } + } + + @objc func cancelSpeech(_ invoke: Invoke) { + log.info("[ptt] PTTPlugin: cancelSpeech command received") + speaker.cancel() + invoke.resolve() + } + + @objc func listVoices(_ invoke: Invoke) { + log.info("[ptt] PTTPlugin: listVoices command received") + let voices = speaker.listVoices().map { v in + VoiceInfoPayload( + id: v["id"] ?? "", + name: v["name"] ?? "", + lang: v["lang"] ?? "" + ) + } + log.debug("[ptt] PTTPlugin: listVoices count=\(voices.count)") + invoke.resolve(voices) + } + + // MARK: - Internal event helpers + + private func emitPermissionOrAudioError(from error: Error) { + let (code, message): (String, String) + switch error { + case PTTRecorder.RecorderError.microphonePermissionDenied: + code = "permission_denied" + message = "Microphone access was denied. Enable it in Settings." + case PTTRecorder.RecorderError.speechPermissionDenied: + code = "permission_denied" + message = "Speech recognition was denied. Enable it in Settings." + default: + code = "audio_error" + message = error.localizedDescription + } + trigger("ptt://error", data: ["code": code, "message": message]) + } + + // MARK: - Session interruption / route change + + private func handleInterruption() { + log.warning("[ptt] PTTPlugin: audio interrupted — stopping recorder") + if recorder.active { + let finalText = recorder.stopListening() + trigger("ptt://transcript-final", data: ["text": finalText]) + } + trigger("ptt://error", data: [ + "code": "interrupted", + "message": "Audio session was interrupted by another app or call.", + ]) + } + + private func handleRouteChange(reason: AVAudioSession.RouteChangeReason) { + // BT device unplugged mid-recording — stop gracefully. + if reason == .oldDeviceUnavailable && recorder.active { + log.warning("[ptt] PTTPlugin: route changed (device unavailable) — stopping recorder") + let finalText = recorder.stopListening() + trigger("ptt://transcript-final", data: ["text": finalText]) + trigger("ptt://error", data: [ + "code": "route_changed", + "message": "Audio output device disconnected.", + ]) + } + } + + // MARK: - Background handling + + @objc private func appDidBackground() { + log.info("[ptt] PTTPlugin: app backgrounded — stopping recorder if active") + if recorder.active { + let finalText = recorder.stopListening() + trigger("ptt://transcript-final", data: ["text": finalText]) + } + speaker.cancel() + } +} + +// MARK: - Plugin factory + +/// Entry point called by `tauri::ios_plugin_binding!(init_plugin_ptt)`. +@_cdecl("init_plugin_ptt") +func initPlugin() -> Plugin { + log.debug("[ptt] init_plugin_ptt — returning PTTPlugin instance") + return PTTPlugin() +} diff --git a/packages/tauri-plugin-ptt/ios/Sources/tauri-plugin-ptt/PTTRecorder.swift b/packages/tauri-plugin-ptt/ios/Sources/tauri-plugin-ptt/PTTRecorder.swift new file mode 100644 index 0000000000..1336b7114c --- /dev/null +++ b/packages/tauri-plugin-ptt/ios/Sources/tauri-plugin-ptt/PTTRecorder.swift @@ -0,0 +1,228 @@ +// PTTRecorder.swift +// AVAudioEngine + SFSpeechRecognizer pipeline for push-to-talk recording. +// +// Mic permission pattern from chat4000/Sources/Services/VoiceNotes.swift: +// AVAudioApplication.requestRecordPermission (iOS 17+) +// AVAudioSession.sharedInstance().requestRecordPermission (older) + +import AVFoundation +import os.log +import Speech + +private let log = Logger(subsystem: "ai.openhuman.ptt", category: "PTTRecorder") + +/// Single-session AVAudioEngine + SFSpeechRecognizer recorder. +/// One `startListening` call creates one recognition task; `stopListening` +/// tears it down. Never keeps a task running between sessions. +final class PTTRecorder { + // MARK: - Types + + enum RecorderError: Error, LocalizedError { + case microphonePermissionDenied + case speechPermissionDenied + case alreadyRecording + case notRecording + case audioEngineError(String) + case recognizerUnavailable + + var errorDescription: String? { + switch self { + case .microphonePermissionDenied: return "Microphone permission denied" + case .speechPermissionDenied: return "Speech recognition permission denied" + case .alreadyRecording: return "Recording already active" + case .notRecording: return "No active recording session" + case .audioEngineError(let msg): return "Audio engine error: \(msg)" + case .recognizerUnavailable: return "Speech recognizer unavailable for current locale" + } + } + } + + // MARK: - State + + private let engine = AVAudioEngine() + private var recognitionRequest: SFSpeechAudioBufferRecognitionRequest? + private var recognitionTask: SFSpeechRecognitionTask? + private var recognizer: SFSpeechRecognizer? + + // Latest partial transcript captured inside the recognitionTask result + // handler. SFSpeechRecognitionTask exposes no `result` property, so we + // mirror it here for stopListening() to read on tear-down. + private var latestTranscript = "" + + private var isRecording = false + + /// Emitted for each partial result while the user speaks. + var onPartialTranscript: ((String) -> Void)? + /// Emitted on any async error (permission denial, interruption, etc.). + var onError: ((String, String) -> Void)? + + // MARK: - Permissions + + /// Returns true if both microphone and speech recognition are authorized. + func requestPermissions() async -> Result { + log.debug("[ptt] PTTRecorder: requesting microphone permission") + let micGranted = await withCheckedContinuation { (continuation: CheckedContinuation) in + if #available(iOS 17.0, *) { + AVAudioApplication.requestRecordPermission { granted in + continuation.resume(returning: granted) + } + } else { + AVAudioSession.sharedInstance().requestRecordPermission { granted in + continuation.resume(returning: granted) + } + } + } + + guard micGranted else { + log.error("[ptt] PTTRecorder: microphone permission denied") + return .failure(.microphonePermissionDenied) + } + + log.debug("[ptt] PTTRecorder: requesting speech recognition permission") + let speechStatus = await withCheckedContinuation { (continuation: CheckedContinuation) in + SFSpeechRecognizer.requestAuthorization { status in + continuation.resume(returning: status) + } + } + + guard speechStatus == .authorized else { + log.error("[ptt] PTTRecorder: speech permission denied status=\(speechStatus.rawValue)") + return .failure(.speechPermissionDenied) + } + + log.info("[ptt] PTTRecorder: all permissions granted") + return .success(()) + } + + // MARK: - Recording lifecycle + + /// Start a new recording + recognition session. + /// Partial transcripts are delivered via `onPartialTranscript`. + func startListening() async throws { + guard !isRecording else { + log.warning("[ptt] PTTRecorder: startListening called while already recording") + throw RecorderError.alreadyRecording + } + + log.info("[ptt] PTTRecorder: startListening — requesting permissions") + let permResult = await requestPermissions() + switch permResult { + case .failure(let err): + throw err + case .success: + break + } + + let locale = Locale.current + recognizer = SFSpeechRecognizer(locale: locale) + guard let recognizer, recognizer.isAvailable else { + log.error("[ptt] PTTRecorder: SFSpeechRecognizer unavailable locale=\(locale.identifier)") + throw RecorderError.recognizerUnavailable + } + + log.debug("[ptt] PTTRecorder: activating audio session") + try AudioSessionManager.shared.activateForRecording() + + let request = SFSpeechAudioBufferRecognitionRequest() + request.shouldReportPartialResults = true + // Keep audio only for recognition — do not save to disk. + request.requiresOnDeviceRecognition = false + recognitionRequest = request + + let inputNode = engine.inputNode + let format = inputNode.outputFormat(forBus: 0) + + inputNode.installTap(onBus: 0, bufferSize: 1024, format: format) { [weak self] buffer, _ in + self?.recognitionRequest?.append(buffer) + } + + engine.prepare() + do { + try engine.start() + } catch { + log.error("[ptt] PTTRecorder: engine start failed: \(error.localizedDescription)") + cleanupEngine() + throw RecorderError.audioEngineError(error.localizedDescription) + } + + recognitionTask = recognizer.recognitionTask(with: request) { [weak self] result, error in + guard let self else { return } + + if let result { + let text = result.bestTranscription.formattedString + self.latestTranscript = text + log.debug("[ptt] PTTRecorder: partial text_len=\(text.count)") + self.onPartialTranscript?(text) + } + + if let error { + // Cancellation is not an error — the task ends when we + // call stopListening or when the user stops speaking. + let nsErr = error as NSError + let isCancelled = nsErr.domain == "kAFAssistantErrorDomain" && nsErr.code == 209 + let isNoSpeech = nsErr.domain == "kAFAssistantErrorDomain" && nsErr.code == 1110 + if !isCancelled && !isNoSpeech { + log.error("[ptt] PTTRecorder: recognition error: \(error.localizedDescription)") + self.onError?("recognition_error", error.localizedDescription) + } + } + } + + isRecording = true + log.info("[ptt] PTTRecorder: recording started") + } + + /// Stop the active session and return the final transcript text. + /// Tears down the engine and recognition task regardless of outcome. + func stopListening() -> String { + guard isRecording else { + log.warning("[ptt] PTTRecorder: stopListening called with no active session") + return "" + } + + log.info("[ptt] PTTRecorder: stopListening") + + // Signal end-of-audio to the recognizer before stopping the engine + // so the recognizer can finalize with what it has already buffered. + recognitionRequest?.endAudio() + recognitionTask?.finish() + + let finalText = latestTranscript + latestTranscript = "" + log.debug("[ptt] PTTRecorder: final text_len=\(finalText.count)") + + cleanupEngine() + AudioSessionManager.shared.deactivate() + isRecording = false + + return finalText + } + + // MARK: - Cleanup + + private func cleanupEngine() { + engine.inputNode.removeTap(onBus: 0) + if engine.isRunning { + engine.stop() + } + recognitionRequest = nil + recognitionTask = nil + recognizer = nil + log.debug("[ptt] PTTRecorder: engine and task cleaned up") + } + + /// Force-stop without waiting for a final result. Called on app backgrounding + /// or audio session interruption. + func forceStop() { + guard isRecording else { return } + log.info("[ptt] PTTRecorder: forceStop") + recognitionRequest?.endAudio() + recognitionTask?.cancel() + cleanupEngine() + latestTranscript = "" + AudioSessionManager.shared.deactivate() + isRecording = false + } + + var active: Bool { isRecording } +} diff --git a/packages/tauri-plugin-ptt/ios/Sources/tauri-plugin-ptt/PTTSpeaker.swift b/packages/tauri-plugin-ptt/ios/Sources/tauri-plugin-ptt/PTTSpeaker.swift new file mode 100644 index 0000000000..5695ae0700 --- /dev/null +++ b/packages/tauri-plugin-ptt/ios/Sources/tauri-plugin-ptt/PTTSpeaker.swift @@ -0,0 +1,109 @@ +// PTTSpeaker.swift +// AVSpeechSynthesizer wrapper for TTS. + +import AVFoundation +import os.log + +private let log = Logger(subsystem: "ai.openhuman.ptt", category: "PTTSpeaker") + +// AVSpeechSynthesizer is not Sendable; PTT operations are serialized by the +// plugin's command actor above, so the unchecked conformance is sound here. +final class PTTSpeaker: NSObject, AVSpeechSynthesizerDelegate, @unchecked Sendable { + // MARK: - State + + private let synthesizer = AVSpeechSynthesizer() + private var currentUtteranceId: String? + + /// Called when synthesis starts for an utterance. + var onStarted: ((String) -> Void)? + /// Called when synthesis finishes or is cancelled. + /// `finished` is false when cancelled. + var onEnded: ((String, Bool) -> Void)? + + override init() { + super.init() + synthesizer.delegate = self + } + + // MARK: - Public API + + /// Enqueue text for synthesis. + /// - Parameters: + /// - text: The text to speak. + /// - voiceId: Optional `AVSpeechSynthesisVoice.identifier`. Defaults to the + /// system's current language voice if nil. + /// - rate: Speech rate in [AVSpeechUtteranceMinimumSpeechRate, + /// AVSpeechUtteranceMaximumSpeechRate]. 0.5 = default rate. + func speak(text: String, voiceId: String?, rate: Float?) { + log.info("[ptt] PTTSpeaker: speak text_len=\(text.count) voiceId=\(voiceId ?? "default")") + + let utterance = AVSpeechUtterance(string: text) + + if let voiceId { + utterance.voice = AVSpeechSynthesisVoice(identifier: voiceId) + } else { + // Default: use the voice matching the device's current locale. + utterance.voice = AVSpeechSynthesisVoice(language: Locale.current.language.languageCode?.identifier ?? "en") + } + + // Map the caller's normalized rate (0.5–2.0) to AVFoundation's scale. + // AVSpeechUtteranceDefaultSpeechRate == 0.5 on the [0,1] AVFoundation scale. + if let rate { + let clamped = min(max(rate, 0.1), 2.0) + // Rough mapping: caller's 1.0 → AVFoundation 0.5 (default) + utterance.rate = AVSpeechUtteranceDefaultSpeechRate * clamped + } else { + utterance.rate = AVSpeechUtteranceDefaultSpeechRate + } + + let uid = UUID().uuidString + currentUtteranceId = uid + // Store id on the utterance so the delegate can recover it. + // AVSpeechUtterance doesn't have a built-in id field so we embed it + // in the speech string's associated object via objc runtime — or, + // simpler: since we track currentUtteranceId and replace it per + // utterance, and we only queue one at a time, the delegate receives + // the most-recently-set id. + synthesizer.speak(utterance) + log.debug("[ptt] PTTSpeaker: utterance enqueued uid=\(uid)") + } + + /// Immediately stop synthesis at the word boundary. + func cancel() { + log.info("[ptt] PTTSpeaker: cancel") + synthesizer.stopSpeaking(at: .immediate) + } + + /// Return all on-device voices. + func listVoices() -> [[String: String]] { + return AVSpeechSynthesisVoice.speechVoices().map { voice in + [ + "id": voice.identifier, + "name": voice.name, + "lang": voice.language, + ] + } + } + + // MARK: - AVSpeechSynthesizerDelegate + + func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didStart utterance: AVSpeechUtterance) { + let uid = currentUtteranceId ?? "unknown" + log.info("[ptt] PTTSpeaker: synthesis started uid=\(uid)") + onStarted?(uid) + } + + func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) { + let uid = currentUtteranceId ?? "unknown" + log.info("[ptt] PTTSpeaker: synthesis finished uid=\(uid)") + currentUtteranceId = nil + onEnded?(uid, true) + } + + func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didCancel utterance: AVSpeechUtterance) { + let uid = currentUtteranceId ?? "unknown" + log.info("[ptt] PTTSpeaker: synthesis cancelled uid=\(uid)") + currentUtteranceId = nil + onEnded?(uid, false) + } +} diff --git a/packages/tauri-plugin-ptt/package.json b/packages/tauri-plugin-ptt/package.json new file mode 100644 index 0000000000..f62a499c46 --- /dev/null +++ b/packages/tauri-plugin-ptt/package.json @@ -0,0 +1,19 @@ +{ + "name": "tauri-plugin-ptt-api", + "version": "0.1.0", + "description": "JS bindings for tauri-plugin-ptt (push-to-talk + TTS, iOS)", + "main": "guest-js/index.ts", + "types": "guest-js/index.ts", + "exports": { + ".": "./guest-js/index.ts" + }, + "files": [ + "guest-js" + ], + "peerDependencies": { + "@tauri-apps/api": ">=2.0.0" + }, + "devDependencies": { + "@tauri-apps/api": "^2" + } +} diff --git a/packages/tauri-plugin-ptt/permissions/autogenerated/commands/cancel_speech.toml b/packages/tauri-plugin-ptt/permissions/autogenerated/commands/cancel_speech.toml new file mode 100644 index 0000000000..dd07f7c900 --- /dev/null +++ b/packages/tauri-plugin-ptt/permissions/autogenerated/commands/cancel_speech.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-cancel-speech" +description = "Enables the cancel_speech command without any pre-configured scope." +commands.allow = ["cancel_speech"] + +[[permission]] +identifier = "deny-cancel-speech" +description = "Denies the cancel_speech command without any pre-configured scope." +commands.deny = ["cancel_speech"] diff --git a/packages/tauri-plugin-ptt/permissions/autogenerated/commands/list_voices.toml b/packages/tauri-plugin-ptt/permissions/autogenerated/commands/list_voices.toml new file mode 100644 index 0000000000..8ef48cfe93 --- /dev/null +++ b/packages/tauri-plugin-ptt/permissions/autogenerated/commands/list_voices.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-list-voices" +description = "Enables the list_voices command without any pre-configured scope." +commands.allow = ["list_voices"] + +[[permission]] +identifier = "deny-list-voices" +description = "Denies the list_voices command without any pre-configured scope." +commands.deny = ["list_voices"] diff --git a/packages/tauri-plugin-ptt/permissions/autogenerated/commands/speak.toml b/packages/tauri-plugin-ptt/permissions/autogenerated/commands/speak.toml new file mode 100644 index 0000000000..5437c8355f --- /dev/null +++ b/packages/tauri-plugin-ptt/permissions/autogenerated/commands/speak.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-speak" +description = "Enables the speak command without any pre-configured scope." +commands.allow = ["speak"] + +[[permission]] +identifier = "deny-speak" +description = "Denies the speak command without any pre-configured scope." +commands.deny = ["speak"] diff --git a/packages/tauri-plugin-ptt/permissions/autogenerated/commands/start_listening.toml b/packages/tauri-plugin-ptt/permissions/autogenerated/commands/start_listening.toml new file mode 100644 index 0000000000..2f30304748 --- /dev/null +++ b/packages/tauri-plugin-ptt/permissions/autogenerated/commands/start_listening.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-start-listening" +description = "Enables the start_listening command without any pre-configured scope." +commands.allow = ["start_listening"] + +[[permission]] +identifier = "deny-start-listening" +description = "Denies the start_listening command without any pre-configured scope." +commands.deny = ["start_listening"] diff --git a/packages/tauri-plugin-ptt/permissions/autogenerated/commands/stop_listening.toml b/packages/tauri-plugin-ptt/permissions/autogenerated/commands/stop_listening.toml new file mode 100644 index 0000000000..0df5056283 --- /dev/null +++ b/packages/tauri-plugin-ptt/permissions/autogenerated/commands/stop_listening.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-stop-listening" +description = "Enables the stop_listening command without any pre-configured scope." +commands.allow = ["stop_listening"] + +[[permission]] +identifier = "deny-stop-listening" +description = "Denies the stop_listening command without any pre-configured scope." +commands.deny = ["stop_listening"] diff --git a/packages/tauri-plugin-ptt/permissions/autogenerated/reference.md b/packages/tauri-plugin-ptt/permissions/autogenerated/reference.md new file mode 100644 index 0000000000..c440168766 --- /dev/null +++ b/packages/tauri-plugin-ptt/permissions/autogenerated/reference.md @@ -0,0 +1,139 @@ +## Permission Table + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
IdentifierDescription
+ +`ptt:allow-cancel-speech` + + + +Enables the cancel_speech command without any pre-configured scope. + +
+ +`ptt:deny-cancel-speech` + + + +Denies the cancel_speech command without any pre-configured scope. + +
+ +`ptt:allow-list-voices` + + + +Enables the list_voices command without any pre-configured scope. + +
+ +`ptt:deny-list-voices` + + + +Denies the list_voices command without any pre-configured scope. + +
+ +`ptt:allow-speak` + + + +Enables the speak command without any pre-configured scope. + +
+ +`ptt:deny-speak` + + + +Denies the speak command without any pre-configured scope. + +
+ +`ptt:allow-start-listening` + + + +Enables the start_listening command without any pre-configured scope. + +
+ +`ptt:deny-start-listening` + + + +Denies the start_listening command without any pre-configured scope. + +
+ +`ptt:allow-stop-listening` + + + +Enables the stop_listening command without any pre-configured scope. + +
+ +`ptt:deny-stop-listening` + + + +Denies the stop_listening command without any pre-configured scope. + +
diff --git a/packages/tauri-plugin-ptt/permissions/schemas/schema.json b/packages/tauri-plugin-ptt/permissions/schemas/schema.json new file mode 100644 index 0000000000..e1a1ee2857 --- /dev/null +++ b/packages/tauri-plugin-ptt/permissions/schemas/schema.json @@ -0,0 +1,360 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "PermissionFile", + "description": "Permission file that can define a default permission, a set of permissions or a list of inlined permissions.", + "type": "object", + "properties": { + "default": { + "description": "The default permission set for the plugin", + "anyOf": [ + { + "$ref": "#/definitions/DefaultPermission" + }, + { + "type": "null" + } + ] + }, + "set": { + "description": "A list of permissions sets defined", + "type": "array", + "items": { + "$ref": "#/definitions/PermissionSet" + } + }, + "permission": { + "description": "A list of inlined permissions", + "default": [], + "type": "array", + "items": { + "$ref": "#/definitions/Permission" + } + } + }, + "definitions": { + "DefaultPermission": { + "description": "The default permission set of the plugin.\n\nWorks similarly to a permission with the \"default\" identifier.", + "type": "object", + "required": [ + "permissions" + ], + "properties": { + "version": { + "description": "The version of the permission.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 1.0 + }, + "description": { + "description": "Human-readable description of what the permission does. Tauri convention is to use `

` headings in markdown content for Tauri documentation generation purposes.", + "type": [ + "string", + "null" + ] + }, + "permissions": { + "description": "All permissions this set contains.", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "PermissionSet": { + "description": "A set of direct permissions grouped together under a new name.", + "type": "object", + "required": [ + "description", + "identifier", + "permissions" + ], + "properties": { + "identifier": { + "description": "A unique identifier for the permission.", + "type": "string" + }, + "description": { + "description": "Human-readable description of what the permission does.", + "type": "string" + }, + "permissions": { + "description": "All permissions this set contains.", + "type": "array", + "items": { + "$ref": "#/definitions/PermissionKind" + } + } + } + }, + "Permission": { + "description": "Descriptions of explicit privileges of commands.\n\nIt can enable commands to be accessible in the frontend of the application.\n\nIf the scope is defined it can be used to fine grain control the access of individual or multiple commands.", + "type": "object", + "required": [ + "identifier" + ], + "properties": { + "version": { + "description": "The version of the permission.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 1.0 + }, + "identifier": { + "description": "A unique identifier for the permission.", + "type": "string" + }, + "description": { + "description": "Human-readable description of what the permission does. Tauri internal convention is to use `

` headings in markdown content for Tauri documentation generation purposes.", + "type": [ + "string", + "null" + ] + }, + "commands": { + "description": "Allowed or denied commands when using this permission.", + "default": { + "allow": [], + "deny": [] + }, + "allOf": [ + { + "$ref": "#/definitions/Commands" + } + ] + }, + "scope": { + "description": "Allowed or denied scoped when using this permission.", + "allOf": [ + { + "$ref": "#/definitions/Scopes" + } + ] + }, + "platforms": { + "description": "Target platforms this permission applies. By default all platforms are affected by this permission.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Target" + } + } + } + }, + "Commands": { + "description": "Allowed and denied commands inside a permission.\n\nIf two commands clash inside of `allow` and `deny`, it should be denied by default.", + "type": "object", + "properties": { + "allow": { + "description": "Allowed command.", + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, + "deny": { + "description": "Denied command, which takes priority.", + "default": [], + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "Scopes": { + "description": "An argument for fine grained behavior control of Tauri commands.\n\nIt can be of any serde serializable type and is used to allow or prevent certain actions inside a Tauri command. The configured scope is passed to the command and will be enforced by the command implementation.\n\n## Example\n\n```json { \"allow\": [{ \"path\": \"$HOME/**\" }], \"deny\": [{ \"path\": \"$HOME/secret.txt\" }] } ```", + "type": "object", + "properties": { + "allow": { + "description": "Data that defines what is allowed by the scope.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Value" + } + }, + "deny": { + "description": "Data that defines what is denied by the scope. This should be prioritized by validation logic.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Value" + } + } + } + }, + "Value": { + "description": "All supported ACL values.", + "anyOf": [ + { + "description": "Represents a null JSON value.", + "type": "null" + }, + { + "description": "Represents a [`bool`].", + "type": "boolean" + }, + { + "description": "Represents a valid ACL [`Number`].", + "allOf": [ + { + "$ref": "#/definitions/Number" + } + ] + }, + { + "description": "Represents a [`String`].", + "type": "string" + }, + { + "description": "Represents a list of other [`Value`]s.", + "type": "array", + "items": { + "$ref": "#/definitions/Value" + } + }, + { + "description": "Represents a map of [`String`] keys to [`Value`]s.", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/Value" + } + } + ] + }, + "Number": { + "description": "A valid ACL number.", + "anyOf": [ + { + "description": "Represents an [`i64`].", + "type": "integer", + "format": "int64" + }, + { + "description": "Represents a [`f64`].", + "type": "number", + "format": "double" + } + ] + }, + "Target": { + "description": "Platform target.", + "oneOf": [ + { + "description": "MacOS.", + "type": "string", + "enum": [ + "macOS" + ] + }, + { + "description": "Windows.", + "type": "string", + "enum": [ + "windows" + ] + }, + { + "description": "Linux.", + "type": "string", + "enum": [ + "linux" + ] + }, + { + "description": "Android.", + "type": "string", + "enum": [ + "android" + ] + }, + { + "description": "iOS.", + "type": "string", + "enum": [ + "iOS" + ] + } + ] + }, + "PermissionKind": { + "type": "string", + "oneOf": [ + { + "description": "Enables the cancel_speech command without any pre-configured scope.", + "type": "string", + "const": "allow-cancel-speech", + "markdownDescription": "Enables the cancel_speech command without any pre-configured scope." + }, + { + "description": "Denies the cancel_speech command without any pre-configured scope.", + "type": "string", + "const": "deny-cancel-speech", + "markdownDescription": "Denies the cancel_speech command without any pre-configured scope." + }, + { + "description": "Enables the list_voices command without any pre-configured scope.", + "type": "string", + "const": "allow-list-voices", + "markdownDescription": "Enables the list_voices command without any pre-configured scope." + }, + { + "description": "Denies the list_voices command without any pre-configured scope.", + "type": "string", + "const": "deny-list-voices", + "markdownDescription": "Denies the list_voices command without any pre-configured scope." + }, + { + "description": "Enables the speak command without any pre-configured scope.", + "type": "string", + "const": "allow-speak", + "markdownDescription": "Enables the speak command without any pre-configured scope." + }, + { + "description": "Denies the speak command without any pre-configured scope.", + "type": "string", + "const": "deny-speak", + "markdownDescription": "Denies the speak command without any pre-configured scope." + }, + { + "description": "Enables the start_listening command without any pre-configured scope.", + "type": "string", + "const": "allow-start-listening", + "markdownDescription": "Enables the start_listening command without any pre-configured scope." + }, + { + "description": "Denies the start_listening command without any pre-configured scope.", + "type": "string", + "const": "deny-start-listening", + "markdownDescription": "Denies the start_listening command without any pre-configured scope." + }, + { + "description": "Enables the stop_listening command without any pre-configured scope.", + "type": "string", + "const": "allow-stop-listening", + "markdownDescription": "Enables the stop_listening command without any pre-configured scope." + }, + { + "description": "Denies the stop_listening command without any pre-configured scope.", + "type": "string", + "const": "deny-stop-listening", + "markdownDescription": "Denies the stop_listening command without any pre-configured scope." + } + ] + } + } +} \ No newline at end of file diff --git a/packages/tauri-plugin-ptt/src/commands.rs b/packages/tauri-plugin-ptt/src/commands.rs new file mode 100644 index 0000000000..892be3f458 --- /dev/null +++ b/packages/tauri-plugin-ptt/src/commands.rs @@ -0,0 +1,92 @@ +/// Tauri commands exposed to the JS layer via `plugin:ptt|`. +/// +/// `_app: AppHandle` is included in each command so `generate_handler!` can +/// infer the runtime type parameter `R`. This matches the pattern used by other +/// Tauri v2 plugins (e.g. tauri-plugin-notification). +use tauri::{command, AppHandle, Runtime, State}; + +use crate::{ + error::Result, + models::{SpeakRequest, TranscriptResult, VoiceInfo}, + PttHandle, +}; + +// ── start_listening ────────────────────────────────────────────────────────── + +/// Begin a push-to-talk recording session. +/// +/// Activates the `AVAudioEngine` and `SFSpeechRecognizer` pipeline on iOS. +/// Partial transcripts arrive as `ptt://transcript-partial` Tauri events. +#[command] +pub async fn start_listening( + _app: AppHandle, + ptt: State<'_, PttHandle>, +) -> Result<()> { + log::debug!("[ptt] command: start_listening"); + ptt.inner().start_listening() +} + +// ── stop_listening ─────────────────────────────────────────────────────────── + +/// Stop the active recording session. +/// +/// Returns the final recognized text. Also emits `ptt://transcript-final`. +#[command] +pub async fn stop_listening( + _app: AppHandle, + ptt: State<'_, PttHandle>, +) -> Result { + log::debug!("[ptt] command: stop_listening"); + let result = ptt.inner().stop_listening()?; + log::debug!( + "[ptt] stop_listening returned text_len={}", + result.text.len() + ); + Ok(result) +} + +// ── speak ──────────────────────────────────────────────────────────────────── + +/// Enqueue a TTS utterance via `AVSpeechSynthesizer`. +/// +/// `voice_id` is an optional `AVSpeechSynthesisVoice.identifier`. +/// `rate` is a float in [0.5, 2.0] where 1.0 = normal speed. +#[command] +pub async fn speak( + _app: AppHandle, + ptt: State<'_, PttHandle>, + text: String, + voice_id: Option, + rate: Option, +) -> Result<()> { + log::debug!("[ptt] command: speak text_len={}", text.len()); + ptt.inner().speak(SpeakRequest { + text, + voice_id, + rate, + }) +} + +// ── cancel_speech ──────────────────────────────────────────────────────────── + +/// Immediately stop any in-progress TTS utterance. +#[command] +pub async fn cancel_speech( + _app: AppHandle, + ptt: State<'_, PttHandle>, +) -> Result<()> { + log::debug!("[ptt] command: cancel_speech"); + ptt.inner().cancel_speech() +} + +// ── list_voices ────────────────────────────────────────────────────────────── + +/// List all on-device TTS voices available via `AVSpeechSynthesisVoice.speechVoices()`. +#[command] +pub async fn list_voices( + _app: AppHandle, + ptt: State<'_, PttHandle>, +) -> Result> { + log::debug!("[ptt] command: list_voices"); + ptt.inner().list_voices() +} diff --git a/packages/tauri-plugin-ptt/src/error.rs b/packages/tauri-plugin-ptt/src/error.rs new file mode 100644 index 0000000000..018cc76738 --- /dev/null +++ b/packages/tauri-plugin-ptt/src/error.rs @@ -0,0 +1,43 @@ +use serde::Serialize; +use thiserror::Error; + +/// Plugin-level errors returned to the JS caller. +#[derive(Debug, Error)] +pub enum Error { + #[error("PTT is not supported on this platform")] + NotSupported, + #[error("microphone permission denied")] + MicrophonePermissionDenied, + #[error("speech recognition permission denied")] + SpeechPermissionDenied, + #[error("recording is already active")] + AlreadyRecording, + #[error("no active recording session")] + NotRecording, + #[error("audio engine error: {0}")] + AudioEngine(String), + #[error("speech recognizer error: {0}")] + SpeechRecognizer(String), + #[error("TTS error: {0}")] + Tts(String), + #[error("serialization error: {0}")] + Serde(#[from] serde_json::Error), + #[error("tauri error: {0}")] + Tauri(#[from] tauri::Error), + /// Mobile plugin invoke error (iOS only). + #[cfg(mobile)] + #[error("mobile plugin error: {0}")] + MobilePlugin(#[from] tauri::plugin::mobile::PluginInvokeError), +} + +/// Serialize to a JSON string for the JS boundary. +impl Serialize for Error { + fn serialize(&self, serializer: S) -> std::result::Result + where + S: serde::Serializer, + { + serializer.serialize_str(&self.to_string()) + } +} + +pub type Result = std::result::Result; diff --git a/packages/tauri-plugin-ptt/src/lib.rs b/packages/tauri-plugin-ptt/src/lib.rs new file mode 100644 index 0000000000..fc0b10062f --- /dev/null +++ b/packages/tauri-plugin-ptt/src/lib.rs @@ -0,0 +1,155 @@ +/// tauri-plugin-ptt — push-to-talk + TTS plugin for Tauri v2 (iOS target). +/// +/// Exposes five commands under the `ptt` plugin namespace: +/// - `start_listening` — activate AVAudioEngine + SFSpeechRecognizer +/// - `stop_listening` — deactivate and return final transcript +/// - `speak` — enqueue an AVSpeechSynthesizer utterance +/// - `cancel_speech` — stop current utterance immediately +/// - `list_voices` — enumerate on-device TTS voices +/// +/// Events emitted (Tauri event bus, target "main"): +/// - `ptt://transcript-partial` { text } +/// - `ptt://transcript-final` { text } +/// - `ptt://tts-started` { utteranceId } +/// - `ptt://tts-ended` { utteranceId, finished } +/// - `ptt://error` { code, message } +/// +/// Desktop: all commands return `Error::NotSupported`. +use tauri::{ + plugin::{Builder, TauriPlugin}, + Manager, Runtime, +}; + +mod commands; +mod error; +mod models; + +#[cfg(target_os = "ios")] +mod mobile; + +pub use error::{Error, Result}; +pub use models::*; + +// ── PttHandle — cross-platform façade ──────────────────────────────────────── + +/// State token managed by Tauri. On iOS it wraps the `PluginHandle`; +/// on desktop it is a zero-cost stub that returns `NotSupported`. +/// +/// `fn(R) -> R` phantom is used instead of `PhantomData` so the struct +/// is always `Send + Sync` regardless of whether `R` is `Send + Sync` +/// (Tauri's `manage()` requires `Send + Sync + 'static`). +pub struct PttHandle { + #[cfg(target_os = "ios")] + inner_mobile: mobile::PttMobile, + #[cfg(not(target_os = "ios"))] + _marker: std::marker::PhantomData R>, +} + +// SAFETY: PttHandle contains only a PluginHandle (mobile) or PhantomData +// (desktop). PluginHandle is Send + Sync, and fn(R)->R phantom is Send + Sync. +unsafe impl Send for PttHandle {} +unsafe impl Sync for PttHandle {} + +impl PttHandle { + #[cfg(target_os = "ios")] + fn new(inner: mobile::PttMobile) -> Self { + Self { + inner_mobile: inner, + } + } + + #[cfg(not(target_os = "ios"))] + fn new_stub() -> Self { + Self { + _marker: std::marker::PhantomData, + } + } + + pub fn start_listening(&self) -> Result<()> { + #[cfg(target_os = "ios")] + return self.inner_mobile.start_listening(); + #[cfg(not(target_os = "ios"))] + { + log::warn!("[ptt] start_listening called on non-mobile target — not supported"); + Err(Error::NotSupported) + } + } + + pub fn stop_listening(&self) -> Result { + #[cfg(target_os = "ios")] + return self.inner_mobile.stop_listening(); + #[cfg(not(target_os = "ios"))] + { + log::warn!("[ptt] stop_listening called on non-mobile target — not supported"); + Err(Error::NotSupported) + } + } + + pub fn speak(&self, req: crate::models::SpeakRequest) -> Result<()> { + #[cfg(target_os = "ios")] + return self.inner_mobile.speak(req); + #[cfg(not(target_os = "ios"))] + { + let _ = req; + log::warn!("[ptt] speak called on non-mobile target — not supported"); + Err(Error::NotSupported) + } + } + + pub fn cancel_speech(&self) -> Result<()> { + #[cfg(target_os = "ios")] + return self.inner_mobile.cancel_speech(); + #[cfg(not(target_os = "ios"))] + { + log::warn!("[ptt] cancel_speech called on non-mobile target — not supported"); + Err(Error::NotSupported) + } + } + + pub fn list_voices(&self) -> Result> { + #[cfg(target_os = "ios")] + return self.inner_mobile.list_voices(); + #[cfg(not(target_os = "ios"))] + { + log::warn!("[ptt] list_voices called on non-mobile target — not supported"); + Err(Error::NotSupported) + } + } +} + +// ── Plugin init ────────────────────────────────────────────────────────────── + +/// Initialise the PTT plugin and return a `TauriPlugin` for registration. +pub fn init() -> TauriPlugin { + log::debug!("[ptt] init — building plugin"); + + Builder::new("ptt") + .invoke_handler(tauri::generate_handler![ + commands::start_listening, + commands::stop_listening, + commands::speak, + commands::cancel_speech, + commands::list_voices, + ]) + .setup(|app, api| { + log::debug!("[ptt] setup — configuring platform bridge"); + + #[cfg(target_os = "ios")] + { + let mobile_handle = mobile::init(app, api)?; + let handle = PttHandle::new(mobile_handle); + app.manage(handle); + log::info!("[ptt] iOS bridge registered"); + } + #[cfg(not(target_os = "ios"))] + { + let _ = (app, api); + let handle: PttHandle = PttHandle::new_stub(); + app.manage(handle); + log::debug!("[ptt] non-mobile target — plugin registered as no-op stub"); + } + + Ok(()) + }) + .build() +} diff --git a/packages/tauri-plugin-ptt/src/mobile.rs b/packages/tauri-plugin-ptt/src/mobile.rs new file mode 100644 index 0000000000..33a7c5cef1 --- /dev/null +++ b/packages/tauri-plugin-ptt/src/mobile.rs @@ -0,0 +1,75 @@ +/// iOS mobile bridge for tauri-plugin-ptt. +/// +/// Tauri's `ios_plugin_binding!` macro generates the Swift<->Rust FFI glue. +/// Each command delegates to `PluginHandle::run_mobile_plugin`, which +/// serialises the payload to JSON, calls the matching Swift `@objc func` on +/// `PTTPlugin`, and deserialises the return value. +use serde::de::DeserializeOwned; +use tauri::{ + plugin::{PluginApi, PluginHandle}, + AppHandle, Runtime, +}; + +use crate::{ + error::Result, + models::{SpeakRequest, TranscriptResult, VoiceInfo}, +}; + +// Generates `init_plugin_ptt` — the Swift entry-point symbol that +// `api.register_ios_plugin(init_plugin_ptt)` will call at startup. +tauri::ios_plugin_binding!(init_plugin_ptt); + +pub struct PttMobile(PluginHandle); + +/// Construct and register the mobile plugin handle. Called from `lib.rs::init`. +pub fn init( + _app: &AppHandle, + api: PluginApi, +) -> Result> { + log::debug!("[ptt] mobile::init — registering iOS plugin handle"); + let handle = api.register_ios_plugin(init_plugin_ptt)?; + Ok(PttMobile(handle)) +} + +impl PttMobile { + /// Begin a speech recognition session. Returns immediately; partial + /// transcripts arrive as `ptt://transcript-partial` events. + pub fn start_listening(&self) -> Result<()> { + log::debug!("[ptt] mobile::start_listening"); + self.0 + .run_mobile_plugin::<()>("startListening", ()) + .map_err(Into::into) + } + + /// Stop the active session and return the final transcript. + pub fn stop_listening(&self) -> Result { + log::debug!("[ptt] mobile::stop_listening"); + self.0 + .run_mobile_plugin::("stopListening", ()) + .map_err(Into::into) + } + + /// Enqueue a TTS utterance. Returns once synthesis has been submitted. + pub fn speak(&self, req: SpeakRequest) -> Result<()> { + log::debug!("[ptt] mobile::speak text_len={}", req.text.len()); + self.0 + .run_mobile_plugin::<()>("speak", req) + .map_err(Into::into) + } + + /// Immediately stop any active TTS utterance. + pub fn cancel_speech(&self) -> Result<()> { + log::debug!("[ptt] mobile::cancel_speech"); + self.0 + .run_mobile_plugin::<()>("cancelSpeech", ()) + .map_err(Into::into) + } + + /// Return available on-device voices from `AVSpeechSynthesisVoice.speechVoices()`. + pub fn list_voices(&self) -> Result> { + log::debug!("[ptt] mobile::list_voices"); + self.0 + .run_mobile_plugin::>("listVoices", ()) + .map_err(Into::into) + } +} diff --git a/packages/tauri-plugin-ptt/src/models.rs b/packages/tauri-plugin-ptt/src/models.rs new file mode 100644 index 0000000000..752cfb792f --- /dev/null +++ b/packages/tauri-plugin-ptt/src/models.rs @@ -0,0 +1,77 @@ +use serde::{Deserialize, Serialize}; + +/// Payload for `start_listening` — no arguments needed at the JS boundary. +#[derive(Debug, Serialize, Deserialize)] +pub struct StartListeningRequest {} + +/// Returned by `stop_listening` once the recognizer finalizes. +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TranscriptResult { + /// Final transcript text (may be empty if nothing was recognized). + pub text: String, + /// Always true when returned from `stop_listening`. + pub is_final: bool, +} + +/// Args for the `speak` command. +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SpeakRequest { + pub text: String, + /// Optional BCP-47 voice identifier (e.g. `"com.apple.voice.compact.en-US.Samantha"`). + pub voice_id: Option, + /// Speech rate multiplier: 0.5 (slow) to 2.0 (fast). Default = 1.0. + pub rate: Option, +} + +/// Describes a single on-device TTS voice. +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct VoiceInfo { + /// AVSpeechSynthesisVoice.identifier + pub id: String, + /// Human-readable name. + pub name: String, + /// BCP-47 language tag, e.g. "en-US". + pub lang: String, +} + +// --------------------------------------------------------------------------- +// Event payloads (emitted over the Tauri event bus) +// --------------------------------------------------------------------------- + +/// `ptt://transcript-partial` — live partial result while recording. +#[derive(Debug, Serialize, Deserialize)] +pub struct TranscriptPartialPayload { + pub text: String, +} + +/// `ptt://transcript-final` — final result after `stop_listening`. +#[derive(Debug, Serialize, Deserialize)] +pub struct TranscriptFinalPayload { + pub text: String, +} + +/// `ptt://tts-started`. +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TtsStartedPayload { + pub utterance_id: String, +} + +/// `ptt://tts-ended`. +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TtsEndedPayload { + pub utterance_id: String, + /// false if cancelled before completion. + pub finished: bool, +} + +/// `ptt://error` — async audio / permission errors. +#[derive(Debug, Serialize, Deserialize)] +pub struct PttErrorPayload { + pub code: String, + pub message: String, +} diff --git a/packages/tauri-plugin-ptt/tsconfig.json b/packages/tauri-plugin-ptt/tsconfig.json new file mode 100644 index 0000000000..9be3e0c747 --- /dev/null +++ b/packages/tauri-plugin-ptt/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "./guest-js/tsconfig.json", + "include": ["guest-js/**/*"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 93ae62c9af..d479f44ba6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,6 +27,9 @@ importers: app: dependencies: + '@noble/ciphers': + specifier: ^1.2.1 + version: 1.3.0 '@noble/curves': specifier: ^2.2.0 version: 2.2.0 @@ -63,6 +66,9 @@ importers: '@tauri-apps/api': specifier: 2.10.1 version: 2.10.1 + '@tauri-apps/plugin-barcode-scanner': + specifier: ^2.4.4 + version: 2.4.4 '@tauri-apps/plugin-deep-link': specifier: ^2 version: 2.4.8 @@ -93,6 +99,9 @@ importers: process: specifier: ^0.11.10 version: 0.11.10 + qrcode.react: + specifier: ^4.2.0 + version: 4.2.0(react@19.2.5) react: specifier: ^19.1.0 version: 19.2.5 @@ -129,6 +138,9 @@ importers: socket.io-client: specifier: ^4.8.3 version: 4.8.3 + tauri-plugin-ptt-api: + specifier: workspace:* + version: link:../packages/tauri-plugin-ptt three: specifier: ^0.183.2 version: 0.183.2 @@ -263,6 +275,12 @@ importers: specifier: ^4.0.18 version: 4.1.5(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(jsdom@28.1.0(@noble/hashes@2.2.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@1.21.7)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.3)) + packages/tauri-plugin-ptt: + devDependencies: + '@tauri-apps/api': + specifier: 2.10.1 + version: 2.10.1 + packages: '@acemir/cssom@0.9.31': @@ -864,6 +882,10 @@ packages: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 + '@noble/ciphers@1.3.0': + resolution: {integrity: sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==} + engines: {node: ^14.21.3 || >=16} + '@noble/curves@2.2.0': resolution: {integrity: sha512-T/BoHgFXirb0ENSPBquzX0rcjXeM6Lo892a2jlYJkqk83LqZx0l1Of7DzlKJ6jkpvMrkHSnAcgb5JegL8SeIkQ==} engines: {node: '>= 20.19.0'} @@ -1780,6 +1802,9 @@ packages: engines: {node: '>= 10'} hasBin: true + '@tauri-apps/plugin-barcode-scanner@2.4.4': + resolution: {integrity: sha512-uXvyMI8UgQjSrGxzTU5isNoQarMGRxFmTmb4TsgiWZHf/g7LsIyAQCwoFShjax0fXCK5mdVKDOvlkfOr21fo6g==} + '@tauri-apps/plugin-deep-link@2.4.8': resolution: {integrity: sha512-Cd2Cs960MGuGONeIwxOPx9wqwedetAHOGlwK5boJ/SMTfAtAyfErpfVPEn+EJzgXsJun8EKzsEumHjr+64V4fw==} @@ -4519,6 +4544,11 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + qrcode.react@4.2.0: + resolution: {integrity: sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + qs@6.15.1: resolution: {integrity: sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==} engines: {node: '>=0.6'} @@ -6223,6 +6253,8 @@ snapshots: '@tybys/wasm-util': 0.10.1 optional: true + '@noble/ciphers@1.3.0': {} + '@noble/curves@2.2.0': dependencies: '@noble/hashes': 2.2.0 @@ -6911,6 +6943,10 @@ snapshots: '@tauri-apps/cli-win32-ia32-msvc': 2.10.0 '@tauri-apps/cli-win32-x64-msvc': 2.10.0 + '@tauri-apps/plugin-barcode-scanner@2.4.4': + dependencies: + '@tauri-apps/api': 2.10.1 + '@tauri-apps/plugin-deep-link@2.4.8': dependencies: '@tauri-apps/api': 2.10.1 @@ -10344,6 +10380,10 @@ snapshots: punycode@2.3.1: {} + qrcode.react@4.2.0(react@19.2.5): + dependencies: + react: 19.2.5 + qs@6.15.1: dependencies: side-channel: 1.1.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 729750a65a..438b447674 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,2 +1,8 @@ packages: - - "app" \ No newline at end of file + - "app" + # PTT plugin's guest-js bindings — TS sources consumed by the mobile crate. + # We intentionally do NOT use `packages/*` here because `packages/npm/` ships + # a `postinstall` that downloads a pre-built openhuman binary from a GitHub + # release; that release does not exist at version 0.0.0 (the placeholder + # version on the npm package), and CI would fail the install step every time. + - "packages/tauri-plugin-ptt" diff --git a/scripts/android-init.sh b/scripts/android-init.sh new file mode 100755 index 0000000000..1c00dfcd66 --- /dev/null +++ b/scripts/android-init.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# scripts/android-init.sh +# +# Scaffolds the Android Studio project for the Android client via +# `tauri android init`. Run from the repo root. +# +# The Android host shares the `app/src-tauri-mobile/` crate with iOS — both +# mobile targets are wired into the same Tauri host (the desktop crate at +# `app/src-tauri/` is pinned to a vendored CEF Tauri fork and does not +# support mobile targets). +# +# Prereqs: +# - Android SDK + NDK installed (Android Studio's SDK Manager). +# - ANDROID_HOME and NDK_HOME exported, or ANDROID_HOME with NDK installed +# in $ANDROID_HOME/ndk//. +# - JDK 17+ on PATH. +# +# After this script completes: +# 1. Open the generated Android Studio project under +# app/src-tauri-mobile/gen/android/. +# 2. Run `pnpm tauri:android:dev` to start a hot-reload dev session on a +# connected device or emulator. + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +MOBILE_DIR="$REPO_ROOT/app/src-tauri-mobile" + +if [[ -z "${ANDROID_HOME:-}" ]]; then + echo "[android-init] ANDROID_HOME is not set." >&2 + echo "[android-init] Install Android Studio, then export ANDROID_HOME=\"\$HOME/Library/Android/sdk\" (macOS) or the equivalent for your OS." >&2 + exit 1 +fi + +if [[ -z "${NDK_HOME:-}" ]]; then + # Try the canonical $ANDROID_HOME/ndk// layout. + if [[ -d "$ANDROID_HOME/ndk" ]]; then + LATEST_NDK=$(ls -1 "$ANDROID_HOME/ndk" 2>/dev/null | sort -V | tail -1 || true) + if [[ -n "$LATEST_NDK" ]]; then + export NDK_HOME="$ANDROID_HOME/ndk/$LATEST_NDK" + echo "[android-init] inferred NDK_HOME=$NDK_HOME" + fi + fi +fi + +if [[ -z "${NDK_HOME:-}" ]]; then + echo "[android-init] NDK_HOME is not set and no NDK was found under \$ANDROID_HOME/ndk/." >&2 + echo "[android-init] Install an NDK via Android Studio SDK Manager (Tools > SDK Manager > SDK Tools > NDK)." >&2 + exit 1 +fi + +echo "[android-init] Running tauri android init from $MOBILE_DIR ..." +cd "$MOBILE_DIR" +npx --package=@tauri-apps/cli@^2 tauri android init + +# Overwrite the placeholder launcher icons Tauri generates with the +# OpenHuman brand icons committed under icons/android/. The Android Studio +# project layout uses `app/src/main/res/mipmap-*/` mirroring our sources. +RES_DIR=$(find "$MOBILE_DIR/gen/android" -type d -path "*/src/main/res" 2>/dev/null | head -1) +if [[ -n "$RES_DIR" ]]; then + echo "[android-init] copying brand icons → $RES_DIR/mipmap-*" + for d in "$MOBILE_DIR"/icons/android/mipmap-*; do + name=$(basename "$d") + mkdir -p "$RES_DIR/$name" + cp "$d"/ic_launcher.png "$RES_DIR/$name/ic_launcher.png" + # Tauri/Android also looks for the round launcher icon by default; + # reuse the same asset (the source set ships a single square icon). + cp "$d"/ic_launcher.png "$RES_DIR/$name/ic_launcher_round.png" + done +fi + +echo "" +echo "[android-init] Done. Next steps:" +echo "" +echo " 1. Open Android Studio:" +echo " open -a 'Android Studio' app/src-tauri-mobile/gen/android" +echo "" +echo " 2. Start dev session (device or emulator must be connected):" +echo " pnpm tauri:android:dev" +echo "" +echo "See docs/ios/SETUP.md (the iOS guide also covers Android prereqs)." diff --git a/scripts/ios-init.sh b/scripts/ios-init.sh new file mode 100755 index 0000000000..26160dcd2e --- /dev/null +++ b/scripts/ios-init.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# scripts/ios-init.sh +# +# Scaffolds the Xcode project for the iOS client via `tauri ios init`. +# Run from the repo root. +# +# The iOS host lives in `app/src-tauri-mobile/` (separate Cargo crate from +# the desktop host at `app/src-tauri/`) because the desktop crate is pinned +# to a vendored CEF Tauri fork that does not support iOS. +# +# After this script completes: +# 1. Open the generated .xcodeproj in Xcode and set your Development Team +# (Signing & Capabilities tab). +# 2. Run `pnpm tauri:ios:dev` to start a hot-reload dev session. + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +MOBILE_DIR="$REPO_ROOT/app/src-tauri-mobile" + +echo "[ios-init] Running tauri ios init from $MOBILE_DIR ..." +cd "$MOBILE_DIR" +# IPHONEOS_DEPLOYMENT_TARGET pins the Swift compiler target version; the PTT +# plugin (packages/tauri-plugin-ptt/) uses iOS 14+ APIs (OSLogMessage), so we +# match the Package.swift declaration of iOS 16. +export IPHONEOS_DEPLOYMENT_TARGET="${IPHONEOS_DEPLOYMENT_TARGET:-16.0}" + +# Tauri requires `bundle.iOS.developmentTeam` to be non-empty before it will +# generate the Xcode project. We keep it empty in committed tauri.conf.json so +# the repo doesn't ship a particular developer's team ID; pass it via TEAM_ID +# (env) or APPLE_DEVELOPMENT_TEAM at invocation time. Find your team ID with: +# security find-identity -v -p codesigning +TEAM_ID="${TEAM_ID:-${APPLE_DEVELOPMENT_TEAM:-}}" +if [[ -z "$TEAM_ID" ]]; then + echo "[ios-init] TEAM_ID is not set." >&2 + echo "[ios-init] Find your Apple developer team ID with:" >&2 + echo "[ios-init] security find-identity -v -p codesigning" >&2 + echo "[ios-init] Then re-run as: TEAM_ID=XXXXXXXXXX pnpm tauri:ios:init" >&2 + exit 1 +fi + +npx --package=@tauri-apps/cli@^2 tauri ios init \ + -c "{\"bundle\":{\"iOS\":{\"developmentTeam\":\"$TEAM_ID\"}}}" + +# Overwrite the placeholder AppIcon set Tauri generates with the real +# OpenHuman brand icons committed to icons/ios/. The generated Xcode project +# uses `Assets.xcassets/AppIcon.appiconset/`, identical to the iOS source +# layout under our `icons/ios/`. +ICONSRC="$MOBILE_DIR/icons/ios/AppIcon.appiconset" +ICONDEST=$(find "$MOBILE_DIR/gen/apple" -type d -name "AppIcon.appiconset" 2>/dev/null | head -1) +if [[ -n "$ICONDEST" && -d "$ICONSRC" ]]; then + echo "[ios-init] copying brand icons → $ICONDEST" + rm -f "$ICONDEST"/*.png "$ICONDEST"/Contents.json + cp -R "$ICONSRC"/. "$ICONDEST"/ +fi + +# Inject privacy usage descriptions into the generated Info.plist. The +# barcode scanner (camera) is mandatory for QR pairing; mic + speech are +# needed by the PTT plugin. Without these, iOS will hard-crash the app on +# first use of each API. +INFO_PLIST=$(find "$MOBILE_DIR/gen/apple" -name "Info.plist" -path "*openhuman-mobile_iOS*" 2>/dev/null | head -1) +if [[ -n "$INFO_PLIST" ]]; then + echo "[ios-init] injecting privacy keys → $INFO_PLIST" + /usr/libexec/PlistBuddy -c "Add :NSCameraUsageDescription string 'OpenHuman uses the camera to scan the pairing QR code from your desktop.'" "$INFO_PLIST" 2>/dev/null || true + /usr/libexec/PlistBuddy -c "Add :NSMicrophoneUsageDescription string 'OpenHuman uses the microphone for push-to-talk voice messages.'" "$INFO_PLIST" 2>/dev/null || true + /usr/libexec/PlistBuddy -c "Add :NSSpeechRecognitionUsageDescription string 'OpenHuman uses on-device speech recognition to transcribe your voice messages.'" "$INFO_PLIST" 2>/dev/null || true +fi + +echo "" +echo "[ios-init] Done. Next steps:" +echo "" +echo " 1. Open Xcode project:" +echo " open app/src-tauri-mobile/gen/apple/*.xcodeproj" +echo " Set Development Team under Signing & Capabilities." +echo "" +echo " 2. Start dev session:" +echo " pnpm tauri:ios:dev" +echo "" +echo "See docs/ios/SETUP.md for full documentation." diff --git a/src/core/all.rs b/src/core/all.rs index 42e0b0ce87..bf74cf66ed 100644 --- a/src/core/all.rs +++ b/src/core/all.rs @@ -251,6 +251,8 @@ fn build_registered_controllers() -> Vec { // Structured WhatsApp Web data — agent-facing read-only controllers (list/search). // The write-path ingest controller is registered separately in build_internal_only_controllers. controllers.extend(crate::openhuman::whatsapp_data::all_whatsapp_data_registered_controllers()); + // Mobile device pairing and management + controllers.extend(crate::openhuman::devices::all_devices_registered_controllers()); controllers } @@ -351,6 +353,8 @@ fn build_declared_controller_schemas() -> Vec { schemas.extend(crate::openhuman::desktop_companion::all_desktop_companion_controller_schemas()); // Structured WhatsApp Web data — local SQLite store, agent-queryable schemas.extend(crate::openhuman::whatsapp_data::all_whatsapp_data_controller_schemas()); + // Mobile device pairing and management + schemas.extend(crate::openhuman::devices::all_devices_controller_schemas()); schemas } @@ -461,6 +465,9 @@ pub fn namespace_description(namespace: &str) -> Option<&'static str> { "Live agent loop for an open Google Meet call: shell streams inbound PCM, \ core runs VAD-segmented STT → LLM → TTS, shell pulls synthesized PCM back.", ), + "devices" => Some( + "Paired mobile device management — pairing channel creation, listing, and revocation.", + ), "whatsapp_data" => Some( "Structured WhatsApp conversation and message store — list chats, read messages, and search across WhatsApp Web data.", ), diff --git a/src/core/event_bus/events.rs b/src/core/event_bus/events.rs index 948acd92c1..6dd56348e3 100644 --- a/src/core/event_bus/events.rs +++ b/src/core/event_bus/events.rs @@ -406,6 +406,31 @@ pub enum DomainEvent { routed: bool, }, + // ── Device pairing ────────────────────────────────────────────────── + /// A mobile device completed the X25519 handshake and is now paired. + DevicePaired { + channel_id: String, + device_pubkey: String, + label: Option, + }, + /// A paired device's tunnel session was revoked. + DeviceRevoked { channel_id: String }, + /// The backend tunnel reported the peer (device) came online. + DevicePeerOnline { channel_id: String }, + /// The backend tunnel reported the peer (device) went offline. + DevicePeerOffline { channel_id: String }, + /// An encrypted tunnel frame arrived from the device. + DeviceTunnelFrame { + channel_id: String, + payload_b64: String, + }, + /// The backend acknowledged `tunnel:register` with channel credentials. + DeviceTunnelRegistered { + channel_id: String, + pairing_token: String, + session_token: String, + }, + // ── Memory tree ───────────────────────────────────────────────────── /// A document (chat batch, email thread, or standalone document) was /// fully canonicalised and its chunks written to the memory tree. @@ -588,6 +613,13 @@ impl DomainEvent { Self::NotificationIngested { .. } | Self::NotificationTriaged { .. } => "notification", + Self::DevicePaired { .. } + | Self::DeviceRevoked { .. } + | Self::DevicePeerOnline { .. } + | Self::DevicePeerOffline { .. } + | Self::DeviceTunnelFrame { .. } + | Self::DeviceTunnelRegistered { .. } => "device", + Self::CompanionSessionStarted { .. } | Self::CompanionStateChanged { .. } | Self::CompanionSessionEnded { .. } => "companion", diff --git a/src/core/jsonrpc.rs b/src/core/jsonrpc.rs index b329c16c2f..4e4b32c70f 100644 --- a/src/core/jsonrpc.rs +++ b/src/core/jsonrpc.rs @@ -1578,6 +1578,11 @@ fn register_domain_subscribers( // Once-guarded registrar so domain-level startup can't duplicate it. crate::openhuman::channels::proactive::register_web_only_proactive_subscriber(); + // Device tunnel subscriber: handles tunnel:frame handshakes, peer-status + // events, and register acks. Must be registered before any tunnel:frame + // events can arrive. + crate::openhuman::devices::bus::register_device_tunnel_subscriber(); + // Native request handlers — typed in-process request/response. // The agent `agent.run_turn` handler is what channel dispatch // calls instead of importing `run_tool_call_loop` directly. diff --git a/src/openhuman/about_app/catalog.rs b/src/openhuman/about_app/catalog.rs index 67ed9122cf..8da0e9df19 100644 --- a/src/openhuman/about_app/catalog.rs +++ b/src/openhuman/about_app/catalog.rs @@ -1220,6 +1220,47 @@ const CAPABILITIES: &[Capability] = &[ destinations: &["Google Meet", "ElevenLabs (STT/TTS via hosted backend)"], }), }, + // ── Mobile (iOS client) ───────────────────────────────────────────────── + Capability { + id: "mobile.device_pairing", + name: "Device Pairing", + domain: "devices", + category: CapabilityCategory::Mobile, + description: "Pair iOS phones with the desktop core via QR code. The desktop generates a \ + short-lived pairing token; the iOS app scans the QR, completes an X25519 \ + key agreement, and stores the session for reconnects.", + how_to: "Settings > Devices > Pair iPhone", + status: CapabilityStatus::Beta, + privacy: None, + }, + Capability { + id: "mobile.ios_client", + name: "iOS Client", + domain: "devices", + category: CapabilityCategory::Mobile, + description: "iOS app for chatting with your assistant on the go. Connects to the desktop \ + core via LAN HTTP, an E2E-encrypted socket.io tunnel, or a cloud HTTP \ + fallback — no Rust core ships on the device.", + how_to: "Pair via Settings > Devices, then open the OpenHuman iOS app.", + status: CapabilityStatus::Beta, + privacy: None, + }, + Capability { + id: "mobile.push_to_talk", + name: "Push-to-Talk", + domain: "devices", + category: CapabilityCategory::Mobile, + description: "Hold-to-talk voice input on iOS. Activates AVAudioEngine and \ + SFSpeechRecognizer on the device; partial transcripts appear while \ + speaking and the final transcript is sent as a chat message.", + how_to: "Hold the microphone button on the iOS mascot screen.", + status: CapabilityStatus::Beta, + privacy: Some(CapabilityPrivacy { + leaves_device: false, + data_kind: PrivacyDataKind::Raw, + destinations: &[], + }), + }, // ── Update ────────────────────────────────────────────────────────────── Capability { id: "update.check", diff --git a/src/openhuman/about_app/types.rs b/src/openhuman/about_app/types.rs index 74b8bb3d1b..8b264e2c2c 100644 --- a/src/openhuman/about_app/types.rs +++ b/src/openhuman/about_app/types.rs @@ -24,10 +24,12 @@ pub enum CapabilityCategory { Channels, #[serde(rename = "automation")] Automation, + #[serde(rename = "mobile")] + Mobile, } impl CapabilityCategory { - pub const ALL: [Self; 10] = [ + pub const ALL: [Self; 11] = [ Self::Conversation, Self::Intelligence, Self::Skills, @@ -38,6 +40,7 @@ impl CapabilityCategory { Self::ScreenIntelligence, Self::Channels, Self::Automation, + Self::Mobile, ]; pub const fn as_str(self) -> &'static str { @@ -52,6 +55,7 @@ impl CapabilityCategory { Self::ScreenIntelligence => "screen_intelligence", Self::Channels => "channels", Self::Automation => "automation", + Self::Mobile => "mobile", } } } @@ -74,6 +78,7 @@ impl FromStr for CapabilityCategory { } "channels" => Ok(Self::Channels), "automation" => Ok(Self::Automation), + "mobile" => Ok(Self::Mobile), _ => Err(format!( "unknown capability category '{value}'; expected one of: {}", Self::ALL @@ -179,8 +184,8 @@ mod tests { } #[test] - fn category_all_has_10_variants() { - assert_eq!(CapabilityCategory::ALL.len(), 10); + fn category_all_has_11_variants() { + assert_eq!(CapabilityCategory::ALL.len(), 11); } #[test] diff --git a/src/openhuman/devices/bus.rs b/src/openhuman/devices/bus.rs new file mode 100644 index 0000000000..3330e4fab9 --- /dev/null +++ b/src/openhuman/devices/bus.rs @@ -0,0 +1,356 @@ +//! Event bus handlers for the devices domain. +//! +//! Subscribes to `tunnel:peer-status` and `tunnel:frame` events published by +//! `socket::event_handlers` and drives: +//! - Updating `PEER_STATUS` in `rpc.rs`. +//! - Completing the X25519 handshake when the device sends its pubkey. +//! - Persisting the `PairedDevice` record after a successful handshake. +//! - Publishing `DomainEvent::DevicePaired / DevicePeerOnline / DevicePeerOffline`. +//! - Resolving `tunnel:registered` acks for `tunnel_client`. + +use std::sync::{Arc, OnceLock}; + +use crate::core::event_bus::{publish_global, DomainEvent, EventHandler, SubscriptionHandle}; +use crate::openhuman::devices::rpc::{PEER_STATUS, PENDING_KEYPAIRS, PENDING_SESSIONS}; +use crate::openhuman::devices::store; +use crate::openhuman::devices::tunnel_client::{resolve_register_ack, TunnelRegisterResponse}; +use async_trait::async_trait; + +static DEVICE_TUNNEL_HANDLE: OnceLock = OnceLock::new(); + +/// Register the device tunnel subscriber on the global event bus. +/// Idempotent — subsequent calls are no-ops. +pub fn register_device_tunnel_subscriber() { + if DEVICE_TUNNEL_HANDLE.get().is_some() { + return; + } + match crate::core::event_bus::subscribe_global(Arc::new(DeviceTunnelSubscriber::new())) { + Some(handle) => { + let _ = DEVICE_TUNNEL_HANDLE.set(handle); + log::info!("[devices/bus] DeviceTunnelSubscriber registered"); + } + None => { + log::warn!( + "[devices/bus] failed to register DeviceTunnelSubscriber — bus not initialized" + ); + } + } +} + +/// Subscribes to device tunnel events from the event bus. +pub struct DeviceTunnelSubscriber; + +impl DeviceTunnelSubscriber { + pub fn new() -> Self { + Self + } +} + +impl Default for DeviceTunnelSubscriber { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl EventHandler for DeviceTunnelSubscriber { + fn name(&self) -> &str { + "device::tunnel" + } + + fn domains(&self) -> Option<&[&str]> { + Some(&["device"]) + } + + async fn handle(&self, event: &DomainEvent) { + match event { + DomainEvent::DevicePeerOnline { channel_id } => { + handle_peer_online(channel_id).await; + } + DomainEvent::DevicePeerOffline { channel_id } => { + handle_peer_offline(channel_id); + } + DomainEvent::DeviceTunnelFrame { + channel_id, + payload_b64, + } => { + handle_tunnel_frame(channel_id, payload_b64).await; + } + DomainEvent::DeviceTunnelRegistered { + channel_id, + pairing_token, + session_token, + } => { + handle_registered(channel_id, pairing_token, session_token); + } + _ => {} + } + } +} + +// --------------------------------------------------------------------------- +// Handlers +// --------------------------------------------------------------------------- + +async fn handle_peer_online(channel_id: &str) { + log::info!("[devices/bus] peer online channel_id={}", channel_id); + PEER_STATUS + .lock() + .unwrap() + .insert(channel_id.to_string(), true); + // No re-publish: the event was already published by socket::event_handlers. +} + +fn handle_peer_offline(channel_id: &str) { + log::info!("[devices/bus] peer offline channel_id={}", channel_id); + PEER_STATUS + .lock() + .unwrap() + .insert(channel_id.to_string(), false); + // No re-publish: the event was already published by socket::event_handlers. +} + +/// Handle an incoming `tunnel:frame` — first frame from the device contains its +/// X25519 public key sealed to the core's public key. After successful decryption +/// we derive the shared secret and persist the `PairedDevice`. +async fn handle_tunnel_frame(channel_id: &str, payload_b64: &str) { + log::debug!( + "[devices/bus] tunnel:frame channel_id={} payload_len={}", + channel_id, + payload_b64.len() + ); + + // Look up the pending keypair for this channel. + let keypair = { + let map = PENDING_KEYPAIRS.lock().unwrap(); + map.get(channel_id).cloned() + }; + + let Some(keypair) = keypair else { + log::debug!( + "[devices/bus] no pending keypair for channel_id={} — frame ignored", + channel_id + ); + return; + }; + + // Decode the outer base64url envelope. + let frame_bytes = match crate::openhuman::devices::crypto::base64url_decode(payload_b64) { + Ok(b) => b, + Err(e) => { + log::warn!( + "[devices/bus] bad base64url in tunnel:frame channel_id={}: {e}", + channel_id + ); + return; + } + }; + + // Wire format for the handshake frame: + // + // 0x01 || eph_pub(32) || nonce(24) || ciphertext+tag + // + // Version byte 0x01 = "sealed-handshake". The device generates an ephemeral + // X25519 keypair, performs DH with corePubkey, then seals its static pubkey + // (32 bytes) with XChaCha20-Poly1305. The core decrypts using the same + // ephemeral DH to recover the device's static public key, then performs a + // second DH (core_static ⟷ device_static) for the session key. + // + // Version byte 0x02 = "encrypted-frame" (used post-handshake, handled later). + // + // Fallback: if the frame begins with a printable ASCII character other than + // 0x01/0x02, treat the entire payload as a base64url(device_pubkey) string + // for backward compat with any pre-Layer-2 devices. + let device_pubkey_b64 = if frame_bytes.first() == Some(&0x01) { + // Sealed handshake: eph_pub(32) || nonce(24) || ciphertext+tag + if frame_bytes.len() < 1 + 32 + 24 + 16 { + log::warn!( + "[devices/bus] sealed-handshake frame too short ({} bytes) channel_id={}", + frame_bytes.len(), + channel_id + ); + return; + } + let eph_pub_bytes: [u8; 32] = match frame_bytes[1..33].try_into() { + Ok(b) => b, + Err(_) => { + log::warn!( + "[devices/bus] eph_pub slice error channel_id={}", + channel_id + ); + return; + } + }; + let core_priv = { + let map = PENDING_KEYPAIRS.lock().unwrap(); + map.get(channel_id).cloned() + }; + let Some(core_keypair) = core_priv else { + log::warn!( + "[devices/bus] no keypair to open sealed frame channel_id={}", + channel_id + ); + return; + }; + // DH: core_static_priv ⟷ eph_pub → session decryption key. + let dh_key = match core_keypair.derive_shared_secret( + &crate::openhuman::devices::crypto::base64url_encode(&eph_pub_bytes), + ) { + Ok(k) => k, + Err(e) => { + log::warn!( + "[devices/bus] DH with eph_pub failed channel_id={}: {e}", + channel_id + ); + return; + } + }; + // Decrypt: nonce(24) || ciphertext+tag at offset 33. + let inner_frame = &frame_bytes[33..]; + let cipher = crate::openhuman::devices::crypto::TunnelCipher::new(&dh_key); + // Reconstruct frame with version byte 0x01 so TunnelCipher::open can + // validate the version — prepend it back. + let mut framed = vec![0x01u8]; + framed.extend_from_slice(inner_frame); + match { + // TunnelCipher::open expects version(1)||nonce(24)||ct+tag, but we already + // stripped the eph_pub prefix. Reconstruct a plain open call by using + // XChaCha20 directly on nonce||ct (inner_frame). + use chacha20poly1305::{ + aead::{Aead, KeyInit}, + XChaCha20Poly1305, XNonce, + }; + if inner_frame.len() < 24 { + Err("[devices/bus] inner_frame too short for nonce".to_string()) + } else { + let nonce = XNonce::from_slice(&inner_frame[..24]); + let aead = XChaCha20Poly1305::new((&dh_key).into()); + aead.decrypt(nonce, &inner_frame[24..]) + .map_err(|_| "[devices/bus] AEAD decrypt failed on handshake frame".to_string()) + } + } { + Ok(plaintext_bytes) => match String::from_utf8(plaintext_bytes) { + Ok(s) => s.trim().to_string(), + Err(_) => { + log::warn!( + "[devices/bus] decrypted handshake payload is not UTF-8 channel_id={}", + channel_id + ); + return; + } + }, + Err(e) => { + log::warn!( + "[devices/bus] sealed-handshake decrypt failed channel_id={}: {e}", + channel_id + ); + return; + } + } + } else { + // Fallback: plaintext base64url-encoded device pubkey (pre-Layer-2 compat). + log::debug!( + "[devices/bus] fallback plaintext handshake channel_id={}", + channel_id + ); + match String::from_utf8(frame_bytes) { + Ok(s) => s.trim().to_string(), + Err(_) => { + log::warn!( + "[devices/bus] tunnel:frame payload not valid UTF-8 for channel_id={}", + channel_id + ); + return; + } + } + }; + + log::info!( + "[devices/bus] handshake frame received channel_id={} device_pubkey_len={}", + channel_id, + device_pubkey_b64.len() + ); + + // Derive shared secret — if this fails the device sent a bad pubkey. + if let Err(e) = keypair.derive_shared_secret(&device_pubkey_b64) { + log::error!( + "[devices/bus] X25519 key agreement failed channel_id={}: {e}", + channel_id + ); + return; + } + + // Persist the paired device. + let label = PENDING_SESSIONS + .lock() + .unwrap() + .get(channel_id) + .map(|s| s.channel_id.clone()) // use channel_id as fallback label + .unwrap_or_else(|| channel_id.to_string()); + + let session_token_hash = hash_session_token( + &PENDING_SESSIONS + .lock() + .unwrap() + .get(channel_id) + .map(|s| s.core_session_token.clone()) + .unwrap_or_default(), + ); + + // Load config from global env (best-effort; pairing persists even if config + // loading is slow — the UI will see the device on next list call). + if let Ok(config) = crate::openhuman::config::rpc::load_config_with_timeout().await { + match store::insert_device( + &config, + channel_id, + &label, + &device_pubkey_b64, + &session_token_hash, + ) { + Ok(device) => { + log::info!( + "[devices/bus] device persisted channel_id={} label={}", + device.channel_id, + device.label + ); + publish_global(DomainEvent::DevicePaired { + channel_id: channel_id.to_string(), + device_pubkey: device_pubkey_b64, + label: Some(label), + }); + } + Err(e) => { + log::error!( + "[devices/bus] failed to persist device channel_id={}: {e}", + channel_id + ); + } + } + } else { + log::warn!( + "[devices/bus] could not load config to persist device channel_id={}", + channel_id + ); + } +} + +/// Resolve the pending `tunnel:register` ack in `tunnel_client`. +fn handle_registered(channel_id: &str, pairing_token: &str, session_token: &str) { + log::debug!( + "[devices/bus] tunnel:registered channel_id={} token_len={}", + channel_id, + pairing_token.len() + ); + resolve_register_ack(TunnelRegisterResponse { + channel_id: channel_id.to_string(), + pairing_token: pairing_token.to_string(), + session_token: session_token.to_string(), + }); +} + +fn hash_session_token(token: &str) -> String { + use sha2::{Digest, Sha256}; + let mut hasher = Sha256::new(); + hasher.update(token.as_bytes()); + format!("{:x}", hasher.finalize()) +} diff --git a/src/openhuman/devices/crypto.rs b/src/openhuman/devices/crypto.rs new file mode 100644 index 0000000000..93b843ee15 --- /dev/null +++ b/src/openhuman/devices/crypto.rs @@ -0,0 +1,295 @@ +//! X25519 key agreement + XChaCha20-Poly1305 frame encryption for device tunnels. +//! +//! Frame format: `version(1) || nonce(24) || ciphertext+tag` +//! Version byte is currently 0x01. Nonces are random per frame. +//! Replay protection uses a fixed-size sliding window over 64-bit sequence numbers +//! embedded in the AAD; for the simpler random-nonce scheme here we track the last +//! `WINDOW_SIZE` nonces and reject duplicates. + +use chacha20poly1305::{ + aead::{Aead, AeadCore, KeyInit, OsRng as ChaChaOsRng}, + XChaCha20Poly1305, XNonce, +}; +use std::collections::VecDeque; +use x25519_dalek::{PublicKey, StaticSecret}; + +const FRAME_VERSION: u8 = 0x01; +const NONCE_LEN: usize = 24; // XChaCha20-Poly1305 nonce = 192 bits +const WINDOW_SIZE: usize = 128; // replay protection window + +// --------------------------------------------------------------------------- +// Key material +// --------------------------------------------------------------------------- + +/// An X25519 keypair used as the core's static device-pairing key. +pub struct DeviceKeypair { + private: StaticSecret, + /// Base64url-encoded public key (returned in QR payload). + pub pubkey_b64: String, +} + +impl DeviceKeypair { + /// Generate a fresh X25519 static keypair. + pub fn generate() -> Self { + let bytes: [u8; 32] = rand::random(); + let private = StaticSecret::from(bytes); + let public = PublicKey::from(&private); + let pubkey_b64 = base64url_encode(public.as_bytes()); + log::debug!( + "[devices/crypto] keypair generated pubkey_len={}", + pubkey_b64.len() + ); + Self { + private, + pubkey_b64, + } + } + + /// Perform X25519 DH with the peer's public key and derive a symmetric key. + /// + /// Returns the 32-byte shared secret (suitable for XChaCha20-Poly1305 key init). + pub fn derive_shared_secret(&self, peer_pubkey_b64: &str) -> Result<[u8; 32], String> { + let peer_bytes = base64url_decode(peer_pubkey_b64) + .map_err(|e| format!("[devices/crypto] bad peer pubkey: {e}"))?; + if peer_bytes.len() != 32 { + return Err(format!( + "[devices/crypto] peer pubkey must be 32 bytes, got {}", + peer_bytes.len() + )); + } + let peer_arr: [u8; 32] = peer_bytes.try_into().unwrap(); + let peer_public = PublicKey::from(peer_arr); + let dh = self.private.diffie_hellman(&peer_public); + log::debug!("[devices/crypto] DH completed, shared secret derived"); + Ok(*dh.as_bytes()) + } + + /// Serialize the private key bytes for persistence (store encrypted). + pub fn private_bytes(&self) -> [u8; 32] { + self.private.to_bytes() + } + + /// Reconstruct from stored (decrypted) private key bytes. + pub fn from_private_bytes(bytes: [u8; 32]) -> Self { + let private = StaticSecret::from(bytes); + let public = PublicKey::from(&private); + let pubkey_b64 = base64url_encode(public.as_bytes()); + Self { + private, + pubkey_b64, + } + } +} + +// --------------------------------------------------------------------------- +// Frame cipher +// --------------------------------------------------------------------------- + +/// Stateful cipher for sealing / opening tunnel frames. +/// +/// Maintains a replay-protection window of the last `WINDOW_SIZE` nonces. +/// Thread safety: wrap in a `Mutex` or `RwLock` at the call site. +pub struct TunnelCipher { + cipher: XChaCha20Poly1305, + seen_nonces: VecDeque<[u8; NONCE_LEN]>, +} + +impl TunnelCipher { + /// Construct from a 32-byte symmetric key (derived via X25519 DH). + pub fn new(key: &[u8; 32]) -> Self { + log::debug!("[devices/crypto] TunnelCipher created"); + Self { + cipher: XChaCha20Poly1305::new(key.into()), + seen_nonces: VecDeque::with_capacity(WINDOW_SIZE + 1), + } + } + + /// Seal `plaintext` into a framed ciphertext. + /// + /// Returns `version(1) || nonce(24) || ciphertext+tag`. + pub fn seal(&self, plaintext: &[u8]) -> Result, String> { + let nonce = XChaCha20Poly1305::generate_nonce(&mut ChaChaOsRng); + let ciphertext = self + .cipher + .encrypt(&nonce, plaintext) + .map_err(|e| format!("[devices/crypto] seal failed: {e}"))?; + + let mut frame = Vec::with_capacity(1 + NONCE_LEN + ciphertext.len()); + frame.push(FRAME_VERSION); + frame.extend_from_slice(nonce.as_slice()); + frame.extend_from_slice(&ciphertext); + + log::trace!( + "[devices/crypto] sealed plaintext_len={} frame_len={}", + plaintext.len(), + frame.len() + ); + Ok(frame) + } + + /// Open a framed ciphertext produced by `seal`. + /// + /// Rejects frames with a wrong version byte, a replayed nonce, or + /// authentication failure (tampered ciphertext). + pub fn open(&mut self, frame: &[u8]) -> Result, String> { + if frame.is_empty() { + return Err("[devices/crypto] empty frame".into()); + } + if frame[0] != FRAME_VERSION { + return Err(format!( + "[devices/crypto] unsupported frame version: 0x{:02x}", + frame[0] + )); + } + if frame.len() < 1 + NONCE_LEN { + return Err("[devices/crypto] frame too short for nonce".into()); + } + + let nonce_bytes: [u8; NONCE_LEN] = frame[1..1 + NONCE_LEN].try_into().unwrap(); + let ciphertext = &frame[1 + NONCE_LEN..]; + + // Replay protection: reject nonces we've already decrypted. + if self.seen_nonces.contains(&nonce_bytes) { + return Err("[devices/crypto] replayed nonce — frame rejected".into()); + } + + let nonce = XNonce::from(nonce_bytes); + let plaintext = self + .cipher + .decrypt(&nonce, ciphertext) + .map_err(|_| "[devices/crypto] authentication failed — tampered frame")?; + + // Slide the window forward. + if self.seen_nonces.len() >= WINDOW_SIZE { + self.seen_nonces.pop_front(); + } + self.seen_nonces.push_back(nonce_bytes); + + log::trace!( + "[devices/crypto] opened frame_len={} plaintext_len={}", + frame.len(), + plaintext.len() + ); + Ok(plaintext) + } +} + +// --------------------------------------------------------------------------- +// Base64url helpers +// --------------------------------------------------------------------------- + +pub fn base64url_encode(bytes: &[u8]) -> String { + use base64::Engine; + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes) +} + +pub fn base64url_decode(s: &str) -> Result, String> { + use base64::Engine; + base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(s) + .map_err(|e| format!("base64url decode error: {e}")) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn keypair_round_trip_pubkey_is_base64url() { + let kp = DeviceKeypair::generate(); + // Must be non-empty and valid base64url. + assert!(!kp.pubkey_b64.is_empty()); + let decoded = base64url_decode(&kp.pubkey_b64).expect("should decode"); + assert_eq!(decoded.len(), 32); + } + + #[test] + fn keypair_private_bytes_round_trip() { + let kp = DeviceKeypair::generate(); + let bytes = kp.private_bytes(); + let kp2 = DeviceKeypair::from_private_bytes(bytes); + assert_eq!(kp.pubkey_b64, kp2.pubkey_b64); + } + + #[test] + fn dh_both_sides_derive_same_secret() { + let core_kp = DeviceKeypair::generate(); + let device_kp = DeviceKeypair::generate(); + + let core_shared = core_kp.derive_shared_secret(&device_kp.pubkey_b64).unwrap(); + let device_shared = device_kp.derive_shared_secret(&core_kp.pubkey_b64).unwrap(); + assert_eq!(core_shared, device_shared); + } + + #[test] + fn seal_open_round_trip() { + let kp = DeviceKeypair::generate(); + let device_kp = DeviceKeypair::generate(); + let secret = kp.derive_shared_secret(&device_kp.pubkey_b64).unwrap(); + + let sealer = TunnelCipher::new(&secret); + let mut opener = TunnelCipher::new(&secret); + + let plaintext = b"hello device tunnel"; + let frame = sealer.seal(plaintext).unwrap(); + let recovered = opener.open(&frame).unwrap(); + assert_eq!(recovered, plaintext); + } + + #[test] + fn tampered_frame_rejected() { + let kp = DeviceKeypair::generate(); + let device_kp = DeviceKeypair::generate(); + let secret = kp.derive_shared_secret(&device_kp.pubkey_b64).unwrap(); + + let sealer = TunnelCipher::new(&secret); + let mut opener = TunnelCipher::new(&secret); + + let mut frame = sealer.seal(b"important data").unwrap(); + // Flip a byte in the ciphertext portion. + let last = frame.len() - 1; + frame[last] ^= 0xFF; + + let result = opener.open(&frame); + assert!(result.is_err(), "tampered frame should be rejected"); + } + + #[test] + fn replayed_nonce_rejected() { + let kp = DeviceKeypair::generate(); + let device_kp = DeviceKeypair::generate(); + let secret = kp.derive_shared_secret(&device_kp.pubkey_b64).unwrap(); + + let sealer = TunnelCipher::new(&secret); + let mut opener = TunnelCipher::new(&secret); + + let frame = sealer.seal(b"replay me").unwrap(); + // First open succeeds. + opener.open(&frame).unwrap(); + // Second open of same frame should fail. + let result = opener.open(&frame); + assert!(result.is_err(), "replayed frame should be rejected"); + assert!(result.unwrap_err().contains("replayed nonce")); + } + + #[test] + fn wrong_version_byte_rejected() { + let kp = DeviceKeypair::generate(); + let device_kp = DeviceKeypair::generate(); + let secret = kp.derive_shared_secret(&device_kp.pubkey_b64).unwrap(); + + let sealer = TunnelCipher::new(&secret); + let mut opener = TunnelCipher::new(&secret); + + let mut frame = sealer.seal(b"version test").unwrap(); + frame[0] = 0x99; // bad version + + let result = opener.open(&frame); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("unsupported frame version")); + } +} diff --git a/src/openhuman/devices/mod.rs b/src/openhuman/devices/mod.rs new file mode 100644 index 0000000000..4f4f3b977e --- /dev/null +++ b/src/openhuman/devices/mod.rs @@ -0,0 +1,20 @@ +//! Mobile device pairing domain. +//! +//! Provides X25519 key agreement + XChaCha20-Poly1305 tunnel framing between +//! the Rust core and iOS clients, brokered by the tinyhumans backend tunnel. + +pub mod bus; +pub mod crypto; +pub mod rpc; +pub mod schemas; +pub mod store; +pub mod tunnel_client; +pub mod types; + +pub use schemas::{ + all_controller_schemas as all_devices_controller_schemas, + all_registered_controllers as all_devices_registered_controllers, +}; +pub use types::{ + CreatePairingResponse, ListDevicesResponse, PairedDevice, PairingSession, RevokeDeviceResponse, +}; diff --git a/src/openhuman/devices/rpc.rs b/src/openhuman/devices/rpc.rs new file mode 100644 index 0000000000..4e7f368587 --- /dev/null +++ b/src/openhuman/devices/rpc.rs @@ -0,0 +1,378 @@ +//! RPC handler implementations for the devices domain. +//! +//! Three methods: +//! - `devices_create_pairing` — registers a pairing channel and returns QR fields. +//! - `devices_list` — lists non-revoked paired devices. +//! - `devices_revoke` — marks a device revoked and closes its tunnel channel. +//! +//! Keypair persistence: private key bytes are encrypted with the workspace +//! `SecretStore` (ChaCha20-Poly1305) and stored as `enc2:` values keyed by +//! channel_id in `PERSISTED_KEYPAIRS`. On restart, bus.rs can reconstruct the +//! keypair for reconnect handshakes without re-generating. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use chrono::Utc; + +use crate::openhuman::config::Config; +use crate::openhuman::devices::crypto::{base64url_decode, base64url_encode, DeviceKeypair}; +use crate::openhuman::devices::store; +use crate::openhuman::devices::tunnel_client; +use crate::openhuman::devices::types::{ + CreatePairingResponse, ListDevicesResponse, PairingSession, RevokeDeviceResponse, +}; +use crate::openhuman::security::SecretStore; +use crate::rpc::RpcOutcome; + +// --------------------------------------------------------------------------- +// In-memory state (module-level singletons) +// --------------------------------------------------------------------------- + +/// Keypairs pending handshake completion (keyed by channel_id). +/// Values are `Arc` so bus.rs can clone without holding the lock during DH. +pub(crate) static PENDING_KEYPAIRS: once_cell::sync::Lazy< + Mutex>>, +> = once_cell::sync::Lazy::new(|| Mutex::new(HashMap::new())); + +/// Encrypted persisted private-key bytes (keyed by channel_id). +/// Values are `enc2:` strings from `SecretStore::encrypt`. +/// Populated by `devices_create_pairing`; cleared by `devices_revoke`. +pub(crate) static PERSISTED_KEYPAIRS: once_cell::sync::Lazy>> = + once_cell::sync::Lazy::new(|| Mutex::new(HashMap::new())); + +/// Pairing sessions pending device connection (keyed by channel_id). +pub(crate) static PENDING_SESSIONS: once_cell::sync::Lazy>> = + once_cell::sync::Lazy::new(|| Mutex::new(HashMap::new())); + +/// Live peer-online status (keyed by channel_id). Updated by bus.rs on `tunnel:peer-status`. +pub(crate) static PEER_STATUS: once_cell::sync::Lazy>> = + once_cell::sync::Lazy::new(|| Mutex::new(HashMap::new())); + +// --------------------------------------------------------------------------- +// create_pairing +// --------------------------------------------------------------------------- + +/// `openhuman.devices_create_pairing` +/// +/// 1. Calls `tunnel:register` on the shared socket — backend returns +/// `{channelId, pairingToken, sessionToken}`. +/// 2. Generates an X25519 keypair and persists the private half in-memory. +/// 3. Emits `tunnel:connect` with `role:"core"` so the core starts listening. +/// 4. Detects the local LAN IP for the optional direct fast-path `rpc_url`. +/// 5. Returns QR-bound fields to the caller. +pub async fn devices_create_pairing( + _config: &Config, + label: Option, +) -> Result, String> { + log::info!( + "[devices/rpc] devices_create_pairing entry label={:?}", + label + ); + + // Register with backend tunnel. + let reg = tunnel_client::emit_register().await.map_err(|e| { + log::error!("[devices/rpc] tunnel:register failed: {e}"); + e + })?; + + log::info!( + "[devices/rpc] tunnel:register ok channel_id={} token_len={}", + reg.channel_id, + reg.pairing_token.len() + ); + + // Generate X25519 keypair for this channel. + let keypair = DeviceKeypair::generate(); + let core_pubkey = keypair.pubkey_b64.clone(); + + // Encrypt the private key bytes and persist in the encrypted secrets store. + let secret_store = build_secret_store(_config); + let private_b64 = base64url_encode(&keypair.private_bytes()); + match secret_store.encrypt(&private_b64) { + Ok(enc) => { + PERSISTED_KEYPAIRS + .lock() + .unwrap() + .insert(reg.channel_id.clone(), enc); + log::debug!( + "[devices/rpc] keypair private key encrypted and persisted channel_id={}", + reg.channel_id + ); + } + Err(e) => { + log::warn!( + "[devices/rpc] could not persist encrypted keypair channel_id={}: {e}", + reg.channel_id + ); + } + } + + // Stash keypair in memory so bus.rs can complete the X25519 handshake. + PENDING_KEYPAIRS + .lock() + .unwrap() + .insert(reg.channel_id.clone(), Arc::new(keypair)); + + // Connect as "core" role to start listening on this channel. + tunnel_client::emit_connect(®.channel_id, ®.session_token) + .await + .map_err(|e| { + log::error!("[devices/rpc] tunnel:connect failed: {e}"); + e + })?; + + log::debug!( + "[devices/rpc] tunnel:connect emitted channel_id={}", + reg.channel_id + ); + + // Best-effort LAN URL detection (non-fatal if it fails). + let rpc_url = detect_lan_rpc_url(); + if let Some(ref url) = rpc_url { + log::debug!("[devices/rpc] LAN rpc_url detected: {}", url); + } + + // Pairing token expires in 10 minutes (backend enforces the real TTL). + let expires_at = (Utc::now() + chrono::Duration::minutes(10)).to_rfc3339(); + + PENDING_SESSIONS.lock().unwrap().insert( + reg.channel_id.clone(), + PairingSession { + channel_id: reg.channel_id.clone(), + pairing_token: reg.pairing_token.clone(), + core_session_token: reg.session_token.clone(), + core_pubkey: core_pubkey.clone(), + rpc_url: rpc_url.clone(), + expires_at: expires_at.clone(), + }, + ); + + log::info!( + "[devices/rpc] devices_create_pairing done channel_id={}", + reg.channel_id + ); + + Ok(RpcOutcome::single_log( + CreatePairingResponse { + channel_id: reg.channel_id, + pairing_token: reg.pairing_token, + core_pubkey, + rpc_url, + expires_at, + }, + "pairing channel created", + )) +} + +// --------------------------------------------------------------------------- +// list +// --------------------------------------------------------------------------- + +/// `openhuman.devices_list` +pub async fn devices_list(config: &Config) -> Result, String> { + log::debug!("[devices/rpc] devices_list entry"); + let mut devices = store::list_devices(config) + .map_err(|e| format!("[devices/rpc] list_devices failed: {e}"))?; + + // Overlay live peer-online status from in-memory map. + { + let peer_map = PEER_STATUS.lock().unwrap(); + for dev in &mut devices { + let online = peer_map.get(&dev.channel_id).copied().unwrap_or(false); + dev.peer_online = Some(online); + } + } + + log::debug!( + "[devices/rpc] devices_list returning {} device(s)", + devices.len() + ); + Ok(RpcOutcome::new(ListDevicesResponse { devices }, vec![])) +} + +// --------------------------------------------------------------------------- +// revoke +// --------------------------------------------------------------------------- + +/// `openhuman.devices_revoke` +pub async fn devices_revoke( + config: &Config, + channel_id: String, +) -> Result, String> { + log::info!("[devices/rpc] devices_revoke channel_id={}", channel_id); + + let revoked = store::revoke_device(config, &channel_id) + .map_err(|e| format!("[devices/rpc] revoke_device failed: {e}"))?; + + // Clear in-memory state for this channel, including persisted encrypted key. + PENDING_KEYPAIRS.lock().unwrap().remove(&channel_id); + PENDING_SESSIONS.lock().unwrap().remove(&channel_id); + PEER_STATUS.lock().unwrap().remove(&channel_id); + PERSISTED_KEYPAIRS.lock().unwrap().remove(&channel_id); + + // Publish DeviceRevoked so UI and other subscribers are notified. + crate::core::event_bus::publish_global(crate::core::event_bus::DomainEvent::DeviceRevoked { + channel_id: channel_id.clone(), + }); + + // TODO: backend revoke endpoint pending (PR #709 follow-up). + // For now, closing the local tunnel side + letting the backend TTL the channel is sufficient. + log::info!( + "[devices/rpc] devices_revoke done channel_id={} revoked={}", + channel_id, + revoked + ); + + Ok(RpcOutcome::single_log( + RevokeDeviceResponse { success: revoked }, + format!("device {channel_id} revoked"), + )) +} + +// --------------------------------------------------------------------------- +// LAN URL detection +// --------------------------------------------------------------------------- + +fn detect_lan_rpc_url() -> Option { + let ip = find_local_ipv4()?; + // Use the configured RPC port if available via env, else fall back to 7788. + let port = std::env::var("OPENHUMAN_CORE_RPC_PORT") + .ok() + .and_then(|s| s.parse::().ok()) + .unwrap_or(7788); + Some(format!("http://{}:{}/rpc", ip, port)) +} + +fn find_local_ipv4() -> Option { + use std::net::{IpAddr, UdpSocket}; + // UDP trick: connect to a public address (no packet sent) and read local addr. + let socket = UdpSocket::bind("0.0.0.0:0").ok()?; + socket.connect("8.8.8.8:80").ok()?; + match socket.local_addr().ok()?.ip() { + IpAddr::V4(addr) if !addr.is_loopback() => Some(addr.to_string()), + _ => None, + } +} + +// --------------------------------------------------------------------------- +// Secret store helper +// --------------------------------------------------------------------------- + +/// Build a `SecretStore` scoped to the workspace directory. +fn build_secret_store(config: &Config) -> SecretStore { + let data_dir = config + .config_path + .parent() + .map_or_else(|| std::path::PathBuf::from("."), std::path::PathBuf::from); + SecretStore::new(&data_dir, true) +} + +/// Reconstruct a `DeviceKeypair` from the encrypted private key store. +/// +/// Returns `None` when the channel has no persisted key or decryption fails. +pub(crate) fn load_keypair_from_store( + config: &Config, + channel_id: &str, +) -> Option> { + let enc = PERSISTED_KEYPAIRS + .lock() + .unwrap() + .get(channel_id) + .cloned()?; + let store = build_secret_store(config); + let private_b64 = store + .decrypt(&enc) + .map_err(|e| { + log::warn!( + "[devices/rpc] decrypt keypair failed channel_id={}: {e}", + channel_id + ); + }) + .ok()?; + let priv_bytes = base64url_decode(&private_b64) + .map_err(|e| { + log::warn!( + "[devices/rpc] base64url decode keypair failed channel_id={}: {e}", + channel_id + ); + }) + .ok()?; + if priv_bytes.len() != 32 { + log::warn!( + "[devices/rpc] loaded private key has wrong length {} channel_id={}", + priv_bytes.len(), + channel_id + ); + return None; + } + let arr: [u8; 32] = priv_bytes.try_into().ok()?; + log::debug!( + "[devices/rpc] keypair restored from encrypted store channel_id={}", + channel_id + ); + Some(Arc::new(DeviceKeypair::from_private_bytes(arr))) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::openhuman::config::Config; + + fn test_config() -> Config { + let dir = tempfile::tempdir().expect("tempdir"); + let mut config = Config::default(); + config.workspace_dir = dir.into_path(); + config + } + + #[tokio::test] + async fn devices_list_returns_empty_initially() { + let config = test_config(); + let result = devices_list(&config).await.unwrap(); + assert!(result.value.devices.is_empty()); + } + + #[tokio::test] + async fn devices_revoke_nonexistent_returns_false() { + let config = test_config(); + let result = devices_revoke(&config, "NONEXISTENT".to_string()) + .await + .unwrap(); + assert!(!result.value.success); + } + + #[tokio::test] + async fn devices_list_includes_inserted_device_with_online_status() { + let config = test_config(); + store::insert_device( + &config, + "CHAN_LIST2", + "Test Phone", + "pubkey_test", + "hash_test", + ) + .unwrap(); + + // Simulate a peer coming online. + PEER_STATUS + .lock() + .unwrap() + .insert("CHAN_LIST2".to_string(), true); + + let result = devices_list(&config).await.unwrap(); + let found = result + .value + .devices + .iter() + .find(|d| d.channel_id == "CHAN_LIST2"); + assert!(found.is_some()); + assert_eq!(found.unwrap().peer_online, Some(true)); + + PEER_STATUS.lock().unwrap().remove("CHAN_LIST2"); + } +} diff --git a/src/openhuman/devices/schemas.rs b/src/openhuman/devices/schemas.rs new file mode 100644 index 0000000000..a8e58869d8 --- /dev/null +++ b/src/openhuman/devices/schemas.rs @@ -0,0 +1,306 @@ +//! Controller schemas and registry for the devices domain. +//! +//! Follows the exact pattern from `cron/schemas.rs`. + +use serde::de::DeserializeOwned; +use serde_json::{Map, Value}; + +use crate::core::all::{ControllerFuture, RegisteredController}; +use crate::core::{ControllerSchema, FieldSchema, TypeSchema}; +use crate::openhuman::config::rpc as config_rpc; +use crate::rpc::RpcOutcome; + +// --------------------------------------------------------------------------- +// Public registry functions +// --------------------------------------------------------------------------- + +pub fn all_controller_schemas() -> Vec { + vec![ + schemas("create_pairing"), + schemas("list"), + schemas("revoke"), + ] +} + +pub fn all_registered_controllers() -> Vec { + vec![ + RegisteredController { + schema: schemas("create_pairing"), + handler: handle_create_pairing, + }, + RegisteredController { + schema: schemas("list"), + handler: handle_list, + }, + RegisteredController { + schema: schemas("revoke"), + handler: handle_revoke, + }, + ] +} + +// --------------------------------------------------------------------------- +// Schema definitions +// --------------------------------------------------------------------------- + +pub fn schemas(function: &str) -> ControllerSchema { + match function { + "create_pairing" => ControllerSchema { + namespace: "devices", + function: "create_pairing", + description: "Register a new pairing channel with the backend tunnel. \ + Returns the QR-code fields (channelId, pairingToken, corePubkey, \ + rpcUrl?, expiresAt) needed by the iOS app to join the channel.", + inputs: vec![FieldSchema { + name: "label", + ty: TypeSchema::Option(Box::new(TypeSchema::String)), + comment: "Human-readable device label, e.g. 'iPhone 15'.", + required: false, + }], + outputs: vec![ + FieldSchema { + name: "channel_id", + ty: TypeSchema::String, + comment: "128-bit base32 channel identifier from the backend tunnel.", + required: true, + }, + FieldSchema { + name: "pairing_token", + ty: TypeSchema::String, + comment: + "Base64url single-use pairing token (TTL'd, hashed at rest on backend).", + required: true, + }, + FieldSchema { + name: "core_pubkey", + ty: TypeSchema::String, + comment: "Base64url X25519 public key of the core for E2E key agreement.", + required: true, + }, + FieldSchema { + name: "rpc_url", + ty: TypeSchema::Option(Box::new(TypeSchema::String)), + comment: "LAN URL for direct HTTP fast path (omitted if not on LAN).", + required: false, + }, + FieldSchema { + name: "expires_at", + ty: TypeSchema::String, + comment: "ISO 8601 expiry timestamp for the pairing token.", + required: true, + }, + ], + }, + + "list" => ControllerSchema { + namespace: "devices", + function: "list", + description: "List all non-revoked paired mobile devices.", + inputs: vec![], + outputs: vec![FieldSchema { + name: "devices", + ty: TypeSchema::Array(Box::new(TypeSchema::Ref("PairedDevice"))), + comment: "Paired devices ordered by creation time.", + required: true, + }], + }, + + "revoke" => ControllerSchema { + namespace: "devices", + function: "revoke", + description: "Revoke a paired device. Marks the device revoked in local storage \ + and removes tunnel state. The backend channel expires naturally after \ + the pairing token TTL.", + inputs: vec![FieldSchema { + name: "channel_id", + ty: TypeSchema::String, + comment: "channel_id of the device to revoke.", + required: true, + }], + outputs: vec![FieldSchema { + name: "success", + ty: TypeSchema::Bool, + comment: "True when the device was found and marked revoked.", + required: true, + }], + }, + + _other => ControllerSchema { + namespace: "devices", + function: "unknown", + description: "Unknown devices controller function.", + inputs: vec![FieldSchema { + name: "function", + ty: TypeSchema::String, + comment: "Unknown function requested for schema lookup.", + required: true, + }], + outputs: vec![FieldSchema { + name: "error", + ty: TypeSchema::String, + comment: "Lookup error details.", + required: true, + }], + }, + } +} + +// --------------------------------------------------------------------------- +// Handler bridges +// --------------------------------------------------------------------------- + +fn handle_create_pairing(params: Map) -> ControllerFuture { + Box::pin(async move { + let config = config_rpc::load_config_with_timeout().await?; + let label = read_optional_string(¶ms, "label")?; + to_json(crate::openhuman::devices::rpc::devices_create_pairing(&config, label).await?) + }) +} + +fn handle_list(_params: Map) -> ControllerFuture { + Box::pin(async move { + let config = config_rpc::load_config_with_timeout().await?; + to_json(crate::openhuman::devices::rpc::devices_list(&config).await?) + }) +} + +fn handle_revoke(params: Map) -> ControllerFuture { + Box::pin(async move { + let config = config_rpc::load_config_with_timeout().await?; + let channel_id = read_required::(¶ms, "channel_id")?; + to_json(crate::openhuman::devices::rpc::devices_revoke(&config, channel_id).await?) + }) +} + +// --------------------------------------------------------------------------- +// Param helpers (mirrors cron/schemas.rs helpers) +// --------------------------------------------------------------------------- + +fn read_required(params: &Map, key: &str) -> Result { + let value = params + .get(key) + .cloned() + .ok_or_else(|| format!("missing required param '{key}'"))?; + serde_json::from_value(value).map_err(|e| format!("invalid '{key}': {e}")) +} + +fn read_optional_string(params: &Map, key: &str) -> Result, String> { + match params.get(key) { + None | Some(Value::Null) => Ok(None), + Some(Value::String(s)) => Ok(Some(s.clone())), + Some(other) => Err(format!( + "invalid '{key}': expected string, got {}", + type_name(other) + )), + } +} + +fn to_json(outcome: RpcOutcome) -> Result { + outcome.into_cli_compatible_json() +} + +fn type_name(value: &Value) -> &'static str { + match value { + Value::Null => "null", + Value::Bool(_) => "bool", + Value::Number(_) => "number", + Value::String(_) => "string", + Value::Array(_) => "array", + Value::Object(_) => "object", + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn schemas_create_pairing_has_correct_shape() { + let s = schemas("create_pairing"); + assert_eq!(s.namespace, "devices"); + assert_eq!(s.function, "create_pairing"); + assert_eq!(s.inputs.len(), 1); + assert_eq!(s.inputs[0].name, "label"); + assert!(!s.inputs[0].required); + assert!(s.outputs.iter().any(|f| f.name == "channel_id")); + assert!(s.outputs.iter().any(|f| f.name == "pairing_token")); + assert!(s.outputs.iter().any(|f| f.name == "core_pubkey")); + } + + #[test] + fn schemas_list_has_no_inputs_and_devices_output() { + let s = schemas("list"); + assert!(s.inputs.is_empty()); + assert_eq!(s.outputs.len(), 1); + assert_eq!(s.outputs[0].name, "devices"); + } + + #[test] + fn schemas_revoke_requires_channel_id() { + let s = schemas("revoke"); + assert_eq!(s.inputs.len(), 1); + assert_eq!(s.inputs[0].name, "channel_id"); + assert!(s.inputs[0].required); + assert_eq!(s.outputs[0].name, "success"); + } + + #[test] + fn schemas_unknown_returns_error_placeholder() { + let s = schemas("does-not-exist"); + assert_eq!(s.function, "unknown"); + assert_eq!(s.outputs[0].name, "error"); + } + + #[test] + fn all_controller_schemas_covers_three_functions() { + let names: Vec<_> = all_controller_schemas() + .into_iter() + .map(|s| s.function) + .collect(); + assert_eq!(names, vec!["create_pairing", "list", "revoke"]); + } + + #[test] + fn all_registered_controllers_has_handler_per_schema() { + let controllers = all_registered_controllers(); + assert_eq!(controllers.len(), 3); + let names: Vec<_> = controllers.iter().map(|c| c.schema.function).collect(); + assert_eq!(names, vec!["create_pairing", "list", "revoke"]); + } + + #[test] + fn read_required_errors_when_key_missing() { + let params = Map::new(); + let err = read_required::(¶ms, "channel_id").unwrap_err(); + assert!(err.contains("missing required param 'channel_id'")); + } + + #[test] + fn read_optional_string_absent_key_is_none() { + let result = read_optional_string(&Map::new(), "label").unwrap(); + assert!(result.is_none()); + } + + #[test] + fn read_optional_string_present_value_returned() { + let mut params = Map::new(); + params.insert("label".into(), json!("iPhone 15")); + let result = read_optional_string(¶ms, "label").unwrap(); + assert_eq!(result, Some("iPhone 15".to_string())); + } + + #[test] + fn type_name_covers_all_variants() { + assert_eq!(type_name(&Value::Null), "null"); + assert_eq!(type_name(&json!(true)), "bool"); + assert_eq!(type_name(&json!(1)), "number"); + assert_eq!(type_name(&json!("s")), "string"); + assert_eq!(type_name(&json!([])), "array"); + assert_eq!(type_name(&json!({})), "object"); + } +} diff --git a/src/openhuman/devices/store.rs b/src/openhuman/devices/store.rs new file mode 100644 index 0000000000..ab4998ec6d --- /dev/null +++ b/src/openhuman/devices/store.rs @@ -0,0 +1,207 @@ +//! SQLite persistence for paired devices. +//! +//! Follows the same `with_connection` pattern as `cron/store.rs`: +//! open a per-call connection to a domain-scoped `.db` file inside the +//! workspace directory, execute DDL on each open (idempotent), then run +//! the requested query and return. + +use anyhow::{Context, Result}; +use chrono::Utc; +use rusqlite::{params, Connection}; + +use crate::openhuman::config::Config; +use crate::openhuman::devices::types::PairedDevice; + +// --------------------------------------------------------------------------- +// Public store API +// --------------------------------------------------------------------------- + +/// Persist a newly-paired device. +pub fn insert_device( + config: &Config, + channel_id: &str, + label: &str, + device_pubkey: &str, + core_session_token_hash: &str, +) -> Result { + let now = Utc::now().to_rfc3339(); + with_connection(config, |conn| { + conn.execute( + "INSERT OR REPLACE INTO paired_devices \ + (channel_id, label, device_pubkey, core_session_token_hash, \ + shared_secret_encrypted, created_at, last_seen_at, revoked) \ + VALUES (?1, ?2, ?3, ?4, NULL, ?5, NULL, 0)", + params![ + channel_id, + label, + device_pubkey, + core_session_token_hash, + now + ], + ) + .context("insert_device: INSERT failed")?; + Ok(()) + })?; + get_device(config, channel_id)?.ok_or_else(|| anyhow::anyhow!("device not found after insert")) +} + +/// Update `last_seen_at` for a device (called on `tunnel:peer-status` online events). +pub fn touch_device(config: &Config, channel_id: &str) -> Result<()> { + let now = Utc::now().to_rfc3339(); + with_connection(config, |conn| { + conn.execute( + "UPDATE paired_devices SET last_seen_at = ?1 WHERE channel_id = ?2 AND revoked = 0", + params![now, channel_id], + ) + .context("touch_device: UPDATE failed")?; + Ok(()) + }) +} + +/// Mark a device as revoked (soft delete). +pub fn revoke_device(config: &Config, channel_id: &str) -> Result { + let rows = with_connection(config, |conn| { + conn.execute( + "UPDATE paired_devices SET revoked = 1 WHERE channel_id = ?1", + params![channel_id], + ) + .context("revoke_device: UPDATE failed") + })?; + Ok(rows > 0) +} + +/// Load a single paired device by channel_id (returns None if not found). +pub fn get_device(config: &Config, channel_id: &str) -> Result> { + with_connection(config, |conn| { + let mut stmt = conn.prepare( + "SELECT channel_id, label, device_pubkey, created_at, last_seen_at, revoked \ + FROM paired_devices WHERE channel_id = ?1", + )?; + let mut rows = stmt.query_map(params![channel_id], map_device_row)?; + rows.next().transpose().map_err(Into::into) + }) +} + +/// List all non-revoked paired devices ordered by creation time. +pub fn list_devices(config: &Config) -> Result> { + with_connection(config, |conn| { + let mut stmt = conn.prepare( + "SELECT channel_id, label, device_pubkey, created_at, last_seen_at, revoked \ + FROM paired_devices WHERE revoked = 0 ORDER BY created_at ASC", + )?; + let rows = stmt.query_map([], map_device_row)?; + rows.collect::>>() + .map_err(Into::into) + }) +} + +// --------------------------------------------------------------------------- +// Private helpers +// --------------------------------------------------------------------------- + +fn map_device_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(PairedDevice { + channel_id: row.get(0)?, + label: row.get(1)?, + device_pubkey: row.get(2)?, + created_at: row.get(3)?, + last_seen_at: row.get(4)?, + peer_online: None, // populated from in-memory peer-status map, not SQLite + revoked: row.get::<_, i64>(5)? != 0, + }) +} + +fn with_connection(config: &Config, f: impl FnOnce(&Connection) -> Result) -> Result { + let db_path = config.workspace_dir.join("devices").join("devices.db"); + if let Some(parent) = db_path.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("create devices dir: {}", parent.display()))?; + } + + let conn = Connection::open(&db_path) + .with_context(|| format!("open devices DB: {}", db_path.display()))?; + + conn.execute_batch( + "PRAGMA foreign_keys = ON; + CREATE TABLE IF NOT EXISTS paired_devices ( + channel_id TEXT PRIMARY KEY, + label TEXT NOT NULL, + device_pubkey TEXT NOT NULL, + core_session_token_hash TEXT NOT NULL, + shared_secret_encrypted BLOB, + created_at TEXT NOT NULL, + last_seen_at TEXT, + revoked INTEGER NOT NULL DEFAULT 0 + );", + ) + .context("devices DDL failed")?; + + log::debug!( + "[devices/store] connection opened path={}", + db_path.display() + ); + f(&conn) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + fn test_config() -> Config { + let dir = tempfile::tempdir().expect("tempdir"); + let mut config = Config::default(); + config.workspace_dir = dir.into_path(); + config + } + + #[test] + fn insert_and_list_device() { + let config = test_config(); + let device = insert_device( + &config, + "CHAN001", + "iPhone 15", + "pubkey_abc", + "token_hash_xyz", + ) + .unwrap(); + assert_eq!(device.channel_id, "CHAN001"); + assert_eq!(device.label, "iPhone 15"); + assert!(!device.revoked); + + let list = list_devices(&config).unwrap(); + assert_eq!(list.len(), 1); + assert_eq!(list[0].channel_id, "CHAN001"); + } + + #[test] + fn revoke_device_marks_revoked() { + let config = test_config(); + insert_device(&config, "CHAN002", "iPad", "pubkey_def", "hash_abc").unwrap(); + let ok = revoke_device(&config, "CHAN002").unwrap(); + assert!(ok); + + let list = list_devices(&config).unwrap(); + assert!(list.is_empty(), "revoked device should not appear in list"); + } + + #[test] + fn touch_device_updates_last_seen_at() { + let config = test_config(); + insert_device(&config, "CHAN003", "Watch", "pubkey_ghi", "hash_def").unwrap(); + touch_device(&config, "CHAN003").unwrap(); + let dev = get_device(&config, "CHAN003").unwrap().unwrap(); + assert!(dev.last_seen_at.is_some()); + } + + #[test] + fn get_device_returns_none_for_missing() { + let config = test_config(); + let result = get_device(&config, "MISSING").unwrap(); + assert!(result.is_none()); + } +} diff --git a/src/openhuman/devices/tunnel_client.rs b/src/openhuman/devices/tunnel_client.rs new file mode 100644 index 0000000000..dab93d40ea --- /dev/null +++ b/src/openhuman/devices/tunnel_client.rs @@ -0,0 +1,197 @@ +//! Tunnel client for the device pairing domain. +//! +//! Reuses the existing `SocketManager` (global singleton) to emit and receive +//! `tunnel:*` Socket.IO events without opening a second WebSocket connection to +//! the backend. Incoming `tunnel:peer-status` and `tunnel:frame` events arrive +//! via the event bus (published by `socket::event_handlers` after this module +//! adds them to the dispatch table) and are handled by `devices::bus`. +//! +//! Frame cap: 64 KB. Rate limit: callers are expected to stay ≤ 100 frames/s. + +use serde::{Deserialize, Serialize}; +use serde_json::json; + +use crate::openhuman::socket::global_socket_manager; + +// --------------------------------------------------------------------------- +// Wire types +// --------------------------------------------------------------------------- + +/// Payload emitted as `tunnel:register` to the backend. +#[derive(Debug, Serialize)] +pub struct TunnelRegisterPayload { + pub role: String, // always "core" +} + +/// Response from `tunnel:register` emitted back by the backend. +#[derive(Debug, Clone, Deserialize)] +pub struct TunnelRegisterResponse { + #[serde(rename = "channelId")] + pub channel_id: String, + #[serde(rename = "pairingToken")] + pub pairing_token: String, + #[serde(rename = "sessionToken")] + pub session_token: String, +} + +/// Payload emitted as `tunnel:connect` to join a channel. +#[derive(Debug, Serialize)] +pub struct TunnelConnectPayload { + #[serde(rename = "channelId")] + pub channel_id: String, + pub role: String, // "core" or "client" + #[serde(rename = "sessionToken", skip_serializing_if = "Option::is_none")] + pub session_token: Option, + #[serde(rename = "pairingToken", skip_serializing_if = "Option::is_none")] + pub pairing_token: Option, +} + +/// Inbound `tunnel:peer-status` event payload. +#[derive(Debug, Clone, Deserialize)] +pub struct TunnelPeerStatus { + #[serde(rename = "channelId")] + pub channel_id: String, + pub online: bool, +} + +/// Inbound `tunnel:frame` event payload. +#[derive(Debug, Clone, Deserialize)] +pub struct TunnelFrame { + #[serde(rename = "channelId")] + pub channel_id: String, + /// Base64url-encoded encrypted frame bytes. + pub payload: String, +} + +/// Outbound `tunnel:frame` emit payload. +#[derive(Debug, Serialize)] +struct TunnelFrameEmit<'a> { + #[serde(rename = "channelId")] + channel_id: &'a str, + payload: &'a str, +} + +// --------------------------------------------------------------------------- +// Tunnel operations +// --------------------------------------------------------------------------- + +/// Emit `tunnel:register` on the shared socket and parse the response. +/// +/// The backend returns `{channelId, pairingToken, sessionToken}` via the +/// same socket in a `tunnel:registered` ack. Since the existing `SocketManager` +/// does not support request/response acks over the raw WebSocket, we use +/// a one-shot `tokio::sync::oneshot` channel registered in a global pending-ack +/// map and resolved by `devices::bus` when the `tunnel:registered` event arrives. +/// +/// For v1 this is simplified: we emit the registration event and expect the +/// caller (rpc.rs) to await the response via the in-process ack mechanism. +pub async fn emit_register() -> Result { + log::debug!("[devices/tunnel] emit_register: sending tunnel:register"); + let mgr = global_socket_manager() + .ok_or_else(|| "[devices/tunnel] SocketManager not initialized".to_string())?; + + let payload = json!({ "role": "core" }); + + // Register a pending ack before emitting to avoid a race. + let rx = PENDING_REGISTER.register_pending(); + + mgr.emit("tunnel:register", payload) + .await + .map_err(|e| format!("[devices/tunnel] emit tunnel:register failed: {e}"))?; + + log::debug!("[devices/tunnel] tunnel:register emitted, awaiting response"); + + // Wait up to 10 s for the backend ack. + tokio::time::timeout(std::time::Duration::from_secs(10), rx) + .await + .map_err(|_| "[devices/tunnel] timeout waiting for tunnel:registered".to_string())? + .map_err(|_| "[devices/tunnel] ack channel dropped".to_string()) +} + +/// Emit `tunnel:connect` to start listening on a channel as `role:"core"`. +pub async fn emit_connect(channel_id: &str, session_token: &str) -> Result<(), String> { + log::debug!( + "[devices/tunnel] emit_connect channel_id={} token_len={}", + channel_id, + session_token.len() + ); + let mgr = global_socket_manager() + .ok_or_else(|| "[devices/tunnel] SocketManager not initialized".to_string())?; + + let payload = json!({ + "channelId": channel_id, + "role": "core", + "sessionToken": session_token, + }); + + mgr.emit("tunnel:connect", payload) + .await + .map_err(|e| format!("[devices/tunnel] emit tunnel:connect failed: {e}")) +} + +/// Emit a `tunnel:frame` carrying an encrypted payload for the peer. +/// +/// `payload_b64` is the base64url-encoded sealed frame from `TunnelCipher::seal`. +pub async fn emit_frame(channel_id: &str, payload_b64: &str) -> Result<(), String> { + if payload_b64.len() > 64 * 1024 { + return Err(format!( + "[devices/tunnel] frame too large: {} bytes (max 64 KB)", + payload_b64.len() + )); + } + let mgr = global_socket_manager() + .ok_or_else(|| "[devices/tunnel] SocketManager not initialized".to_string())?; + + let payload = json!({ + "channelId": channel_id, + "payload": payload_b64, + }); + + mgr.emit("tunnel:frame", payload) + .await + .map_err(|e| format!("[devices/tunnel] emit tunnel:frame failed: {e}")) +} + +/// Resolve a pending `tunnel:register` ack when the backend responds. +/// +/// Called by `socket::event_handlers` when it receives `tunnel:registered`. +pub fn resolve_register_ack(response: TunnelRegisterResponse) { + log::debug!( + "[devices/tunnel] resolving tunnel:registered ack channel_id={}", + response.channel_id + ); + PENDING_REGISTER.resolve(response); +} + +// --------------------------------------------------------------------------- +// One-shot ack registry for tunnel:register +// --------------------------------------------------------------------------- + +use std::sync::Mutex; +use tokio::sync::oneshot; + +struct PendingRegisterAck { + tx: Mutex>>, +} + +impl PendingRegisterAck { + const fn new() -> Self { + Self { + tx: Mutex::new(None), + } + } + + fn register_pending(&self) -> oneshot::Receiver { + let (tx, rx) = oneshot::channel(); + *self.tx.lock().unwrap() = Some(tx); + rx + } + + fn resolve(&self, response: TunnelRegisterResponse) { + if let Some(tx) = self.tx.lock().unwrap().take() { + let _ = tx.send(response); + } + } +} + +static PENDING_REGISTER: PendingRegisterAck = PendingRegisterAck::new(); diff --git a/src/openhuman/devices/types.rs b/src/openhuman/devices/types.rs new file mode 100644 index 0000000000..0e58f714e0 --- /dev/null +++ b/src/openhuman/devices/types.rs @@ -0,0 +1,66 @@ +//! Domain types for the devices (mobile pairing) domain. + +use serde::{Deserialize, Serialize}; + +/// A successfully paired mobile device persisted in SQLite. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PairedDevice { + /// 128-bit base32 channel identifier assigned by the backend tunnel. + pub channel_id: String, + /// Human-readable label, e.g. "iPhone 15". + pub label: String, + /// Base64url-encoded X25519 public key of the device. + pub device_pubkey: String, + /// ISO 8601 creation timestamp. + pub created_at: String, + /// ISO 8601 timestamp of most recent tunnel activity, if any. + pub last_seen_at: Option, + /// Derived from `tunnel:peer-status`; not persisted. + #[serde(skip_serializing_if = "Option::is_none")] + pub peer_online: Option, + /// True once `devices_revoke` has been called. + pub revoked: bool, +} + +/// Short-lived pairing session created by `devices_create_pairing`. +/// +/// Lives in memory (in a `DashMap`) with a TTL cleanup task. Never written to +/// SQLite — the backend already enforces the single-use / TTL semantics on the +/// pairing token. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PairingSession { + /// 128-bit base32 channel identifier from `tunnel:register`. + pub channel_id: String, + /// Base64url pairing token (single-use, TTL'd, hashed at rest on backend). + pub pairing_token: String, + /// Core's reconnect credential for this channel (hashed at rest in SQLite). + pub core_session_token: String, + /// Base64url-encoded X25519 public key generated for this pairing. + pub core_pubkey: String, + /// Optional LAN URL for the direct HTTP fast path. + pub rpc_url: Option, + /// ISO 8601 timestamp when the pairing token expires. + pub expires_at: String, +} + +/// Response payload for `devices_create_pairing`. +#[derive(Debug, Serialize, Deserialize)] +pub struct CreatePairingResponse { + pub channel_id: String, + pub pairing_token: String, + pub core_pubkey: String, + pub rpc_url: Option, + pub expires_at: String, +} + +/// Response payload for `devices_list`. +#[derive(Debug, Serialize, Deserialize)] +pub struct ListDevicesResponse { + pub devices: Vec, +} + +/// Response payload for `devices_revoke`. +#[derive(Debug, Serialize, Deserialize)] +pub struct RevokeDeviceResponse { + pub success: bool, +} diff --git a/src/openhuman/mod.rs b/src/openhuman/mod.rs index 13c06aa46a..cdafe89ae9 100644 --- a/src/openhuman/mod.rs +++ b/src/openhuman/mod.rs @@ -34,6 +34,7 @@ pub mod credentials; pub mod cron; pub mod desktop_companion; pub mod dev_paths; +pub mod devices; pub mod doctor; pub mod embeddings; pub mod encryption; diff --git a/src/openhuman/socket/event_handlers.rs b/src/openhuman/socket/event_handlers.rs index d9b0f4cabd..74645ff12e 100644 --- a/src/openhuman/socket/event_handlers.rs +++ b/src/openhuman/socket/event_handlers.rs @@ -168,6 +168,86 @@ pub(super) fn handle_sio_event( } } } + // Device tunnel — peer-status update. + "tunnel:peer-status" => { + log::info!("[socket] tunnel:peer-status received"); + match serde_json::from_value::( + data.clone(), + ) { + Ok(status) => { + if status.online { + publish_global(DomainEvent::DevicePeerOnline { + channel_id: status.channel_id, + }); + } else { + publish_global(DomainEvent::DevicePeerOffline { + channel_id: status.channel_id, + }); + } + } + Err(e) => { + log::warn!("[socket] failed to parse tunnel:peer-status: {e}"); + } + } + } + // Device tunnel — encrypted frame from the iOS device. + "tunnel:frame" => { + log::debug!("[socket] tunnel:frame received"); + match serde_json::from_value::( + data.clone(), + ) { + Ok(frame) => { + publish_global(DomainEvent::DeviceTunnelFrame { + channel_id: frame.channel_id, + payload_b64: frame.payload, + }); + } + Err(e) => { + log::warn!("[socket] failed to parse tunnel:frame: {e}"); + } + } + } + // Device tunnel — backend ack for tunnel:register. + "tunnel:registered" => { + log::info!("[socket] tunnel:registered received"); + let channel_id = data + .get("channelId") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let pairing_token = data + .get("pairingToken") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let session_token = data + .get("sessionToken") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + if !channel_id.is_empty() { + publish_global(DomainEvent::DeviceTunnelRegistered { + channel_id, + pairing_token, + session_token, + }); + } else { + log::warn!("[socket] tunnel:registered missing channelId"); + } + } + // Device tunnel — backend evicted the channel (TTL / server restart). + "tunnel:evicted" => { + let channel_id = data + .get("channelId") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + log::info!("[socket] tunnel:evicted channel_id={}", channel_id); + if !channel_id.is_empty() { + publish_global(DomainEvent::DevicePeerOffline { channel_id }); + } + } + // Channel inbound message — publish to event bus for ChannelInboundSubscriber _ if event_name.ends_with(":message") => { log::info!(