diff --git a/.claude/skills/tri-net-development/SKILL.md b/.claude/skills/tri-net-development/SKILL.md index ce633032..320ceb16 100644 --- a/.claude/skills/tri-net-development/SKILL.md +++ b/.claude/skills/tri-net-development/SKILL.md @@ -2101,3 +2101,16 @@ phi^2 + phi^-2 = 3 | TRINITY video). The complete cure is per-enrollment/per-room keys — a bigger design change. This fix closes the remote-unauthenticated attack, which was the demonstrated exploit. Needs `import CryptoKit` in CallManager + ViewModel. + +## WAVE 2026-07-22 #16 — INVITE anti-replay (freshness timestamp) — closes the gap left by #15 + +- **Weakness in my own #15 fix: the authenticated INVITE had NO replay protection.** A valid HMAC only proves + the sender knew the PSK ONCE; an attacker who sniffs a legit INVITE off the LAN could replay it later to make + the victim ring/auto-join again. Fix (WireGuard/SIP-style): the payload gained a 4th field `TS_MS` (ms epoch), + covered by the same HMAC (so it can't be rewritten); the listener rejects an INVITE whose timestamp is missing + or outside a +/-15s freshness window (tolerates Mac<->iPhone clock skew). +- **VERIFIED LIVE:** a REPLAYED INVITE (valid MAC, 60s-old timestamp) -> 0 auto-joins; a fresh one -> auto-joins. + Both platforms; `smoke/loopback_call.sh` now stamps a current TS and still PASSes end-to-end. +- **Honest residual:** a 15s window still allows an immediate replay; a nonce/seen-MAC cache would close it fully + (cheap: remember recent INVITE MACs for ~30s and drop dups). Left as an option — the demonstrated risk + (later replay) is closed. Wire is now `[FD 11][HMAC:8][name\nips\nROOM\nTS_MS]`. diff --git a/.gitignore b/.gitignore index db3c2a84..bde7c7c9 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,8 @@ cwasm/target/ phone/desktop/.dd/ phone/desktop/build/ phone/build-ios/ + +# Build artifacts — never commit +**/.spm/ +**/.dd-*/ +**/DerivedData/ diff --git a/HANDOFF.md b/HANDOFF.md new file mode 100644 index 00000000..a865c298 --- /dev/null +++ b/HANDOFF.md @@ -0,0 +1,188 @@ +# TRI-NET — Handoff (continue this work) + +This doc lets another AI/engineer pick up a live TRI-NET call-app test setup on this Mac. +TRI-NET is a video-call app: **iOS + macOS clients + a Rust `call-api` server**, calling over +**LiveKit (Internet)** or **local mesh UDP**, with a **nickname directory** as the user identity. + +Machine: `MacBook-Pro.local` · WiFi IP **`192.168.1.102`** · Apple Silicon (arm64). + +--- + +## 0. TL;DR — current state (already working) + +- **call-api server** running on `0.0.0.0:8080` (LAN-reachable) — the prebuilt binary the user sent. +- **LiveKit dev server** running on `0.0.0.0:7880` (devkey/secret, node-ip 192.168.1.102). +- **macOS client `TriNetVideo` built + running + registered** on the server as nickname **`@m4mac`**. +- SQLite DB at `/tmp/trinet-call.sqlite` (accounts / devices / nicknames / calls / group_chats). +- iPhone (`ssd26`, nickname `@zames`) has the unified app installed but its Settings pointed at the + wrong host `SSDs-MacBook-Pro.local` — must be changed to `http://192.168.1.102:8080`. + +Verify server up: +```bash +curl -s http://192.168.1.102:8080/healthz # -> {"status":"ok"} +curl -s http://192.168.1.102:7880/ # -> OK (LiveKit) +sqlite3 /tmp/trinet-call.sqlite "select nickname,platform from nicknames n join devices d on n.device_id=d.device_id;" +``` + +--- + +## 1. Folders on disk (IMPORTANT — three different codebases) + +| Path | What it is | Use it for | +|---|---|---| +| `~/Desktop/PROJECTS/CLAUDE/trinet-unified/` | **Clone of `gHashTag/tri-net` @ `feat/regen-final` (90869fc)** — the UNIFIED app: iOS client + Mac client + `services/call-api`. **THIS is the app on the iPhone.** | Build iOS + Mac clients, edit features | +| `~/Desktop/PROJECTS/CLAUDE/call-api/` | The user's **zip server** (`trinet-call-api`), newer (main.rs 135 KB) than the checkout's (84 KB). Source does NOT compile standalone (needs regenerated `gen/`), but `prebuilt/trinet-call-api` (arm64) **runs** and already implements **full APNs**. | Run the server (prebuilt binary) | +| `~/Desktop/PROJECTS/CLAUDE/tri-net/` | An OLDER **P2P mesh-only** branch (no nicknames, no LiveKit). **DO NOT build the iPhone from here** — it downgrades the app. | ignore for this task | + +> The `feat/regen-final` branch name is diverged between the local `tri-net/` (P2P) and the remote +> (unified). The unified truth is the fresh clone in `trinet-unified/`. + +--- + +## 2. How to run everything + +### Server (call-api) — the prebuilt binary (has APNs) +The binary was quarantined by macOS on first run; strip + ad-hoc sign once: +```bash +cd ~/Desktop/PROJECTS/CLAUDE/call-api +xattr -cr prebuilt/trinet-call-api && codesign --force -s - prebuilt/trinet-call-api +TRINET_BIND=0.0.0.0:8080 \ +TRINET_DB_PATH=/tmp/trinet-call.sqlite \ +TRINET_LIVEKIT_URL=ws://192.168.1.102:7880 \ +LIVEKIT_API_KEY=devkey LIVEKIT_API_SECRET=secret \ +./prebuilt/trinet-call-api # add APNs env (section 4) to enable closed-app push +``` + +### LiveKit (media SFU, dev mode) +```bash +livekit-server --dev --bind 0.0.0.0 --node-ip 192.168.1.102 +# devkey/secret; ws://192.168.1.102:7880 ; RTC tcp 7881, udp 7882 +``` + +### macOS client (TriNetVideo, unified) +```bash +cd ~/Desktop/PROJECTS/CLAUDE/trinet-unified/phone/desktop +xcodegen generate --spec project_video.yml +xcodebuild -project TriNetVideo.xcodeproj -scheme TriNetVideo -configuration Debug \ + -derivedDataPath .dd-macvideo -clonedSourcePackagesDirPath .spm \ + -destination 'platform=macOS,arch=arm64' build +# point the client at the server (UserDefaults, NOT env — see section 3): +defaults write com.trinet.video internetAPIBaseURL "http://192.168.1.102:8080" +defaults write com.trinet.video liveKitURL "ws://192.168.1.102:7880" +open -n .dd-macvideo/Build/Products/Debug/TriNetVideo.app +``` +Built app is at `.dd-macvideo/Build/Products/Debug/TriNetVideo.app`. Ad-hoc signed (`CODE_SIGN_IDENTITY "-"`). + +### iOS client (physical iPhone) — done BY THE USER in Xcode +Open **`~/Desktop/PROJECTS/CLAUDE/trinet-unified/phone/TriNetVideo.xcodeproj`** (NOT `tri-net/…`), +select the iPhone, press **Run**. Signing team is already set (`DEVELOPMENT_TEAM`; note there is a +mismatch: iOS pbxproj = `5EM4M85VSQ`, `phone/project.yml` = `5H75B24AH5` — reconcile before device signing). +Then on the phone: **Settings → Internet service** → `http://192.168.1.102:8080`, LiveKit `ws://192.168.1.102:7880`, **Save**. +The unified iOS target builds cleanly for the Simulator (verified). VoIP push/CallKit need a **physical** device. + +--- + +## 3. Client configuration model (how the client finds the server) + +`InternetCallConfiguration.load()` in `phone/shared/CallIdentity.swift:43` reads, in order: +1. **UserDefaults** key (`internetAPIBaseURL`, `liveKitURL`, `serviceAccessToken`, `developmentRoomToken`), then +2. **Info.plist** key (`TRINET_API_BASE_URL`, …). **NOT environment variables** — `--env` is ignored. + +So configure via `defaults write com.trinet.video ` (macOS) or the in-app **Settings** screen (iOS). + +--- + +## 4. The four feature requests — status + exact work + +Findings came from a 4-agent code scout. File:line pointers are into `trinet-unified/`. + +### (A) Incoming call when app is CLOSED → mostly DONE, needs your APNs key +- **iOS device side: PRESENT.** PushKit VoIP registry + CallKit `CXProvider` + `didReceiveIncomingPush` + reporting to CallKit; Info.plist `UIBackgroundModes=[audio,voip,remote-notification]`; entitlement + `aps-environment`. Files: `phone/TriNetVideo/CallKitCoordinator.swift` (9, 42, 123, 143), `App.swift`. +- **Server side: the ZIP binary ALREADY sends APNs VoIP push** (strings show `api.push.apple.com`, + `apns-push-type:voip`, ES256 JWT, `VoipPushPayload{IncomingCall: call_id, call_uuid, caller}`). + The 90869fc `services/call-api` source does NOT (older). **We run the zip binary → push works.** +- **What's missing = credentials only.** Set these env vars on the server and restart it: + ``` + TRINET_APNS_TEAM_ID= + TRINET_APNS_KEY_ID= + TRINET_APNS_PRIVATE_KEY_PATH=/absolute/path/AuthKey_XXXX.p8 (mode 0600, ES256 .p8) + TRINET_APNS_BUNDLE_ID=com.trinet.video + TRINET_APNS_ENVIRONMENT=sandbox (dev builds use api.sandbox.push.apple.com) + ``` + Get the `.p8` at developer.apple.com → Certificates/Keys → new Key → **Apple Push Notifications service**. + Requires a **physical iPhone** (VoIP pushes are never delivered to the Simulator). +- If you must use the 90869fc source server instead of the zip binary, implement the sender there: + `services/call-api/src/main.rs` — extend the targets SELECT at ~1079 to include `voip_push_token`, + add an ES256-JWT + HTTP/2 APNs POST after `commit()` at ~1149. The `p256` crate is already a dep. + +### (B) Beautiful/original incoming-call screen → STARTED this session +- Redesigned `struct IncomingCallOverlay` in **`phone/TriNetVideo/Views.swift:394`** (gradient backdrop + + breathing glow, 3 rippling rings, gradient avatar disc with glow, `ENCRYPTED · FORWARD-SECRET` badge, + glowing Accept button). Ring/haptics/accept/decline wiring preserved (`RingSynth`, `vm.acceptIncoming`/`declineIncoming`). +- macOS twin to match: `phone/desktop/VideoCallTab.swift:147` (`IncomingCallBanner`) — not yet redesigned. +- **TODO: verify the iOS build is green** (a build was kicked off this session; check `/tmp/iosverify2.log` + for `BUILD SUCCEEDED`) and screenshot in the Simulator per CLAUDE.md. + +### (C) Chat: sound + unread count → in-call chat DONE, group chat TODO (client-side) +- **In-call 1:1 chat (peer UDP, `0xFB 0xCA`): sound + numeric badge already PRESENT** (both platforms). +- **Server-backed GROUP chat: the ZIP server already returns `unread_count` / `total_unread_count`** + and sends an APNs **Alert** push (`AlertPushAps{badge,sound,thread-id}`). The 90869fc **client does NOT + use it** — no sound, no badge; group chat polls every 3s. +- Implement on the client: + - Light sound on new group message: reuse the in-call `ChatChime` idea; add to `GroupChatController` + in `phone/shared/InternetCall.swift:599-696` (detect new message id in `refresh()`, play a chime). + - Unread badge: `GroupChatSummary` (`phone/shared/InternetCall.swift:130`) + add a client `unread` map; + render a numeric badge in `phone/TriNetVideo/Views.swift` chat list. (Server already supplies the count.) + +### (D) Nickname = unique identity bound to the iPhone → uniqueness DONE, binding to tighten +- **Unique: PRESENT.** `nickname` is PRIMARY KEY; claim is an IMMEDIATE (atomic) transaction with + confusable rejection; a live nickname held by another account cannot be stolen + (`services/call-api/src/main.rs` ~470, 920, 961). Client model in `phone/shared/NicknameDirectory.swift`. +- **Identity is UUID + P-256 signing key; nickname is a directory alias**, currently bound to the ACCOUNT, + not hard-bound to the one device. To pin it to a single device/key: `main.rs:961-970` (claim deletes+inserts + by user_id → bind to device_id/key_fingerprint) + `main.rs:469-474` (nicknames schema, add immutable device col). +- Truly hardware-bound key = generate the P-256 key in the **Secure Enclave** (`CallIdentity.swift:204`), + needs a physical iPhone (Simulator has no SE). Add a recovery/expiry so a lost device's nick isn't squatted forever. + +--- + +## 5. Mesh vs Internet connectivity (both requested) +- **Internet path**: client → call-api (`/v1/devices/register`, `/v1/directory`, `/v1/calls`) → LiveKit room token → media over LiveKit. Verified: the Mac client registered `@m4mac` end-to-end against the server. +- **Mesh path**: local UDP + Bonjour (`_trinet-call._udp`), encrypted forward-secret, no server. The app's + "Transport: Local/Mesh UDP + LiveKit WebRTC" toggles/auto-selects. Two devices on the same WiFi discover each other. +- For any LAN test: **all devices on the same WiFi router**; the iPhone in the screenshot was on **5G** — turn WiFi on. + +--- + +## 6. Network / firewall +- macOS Application Firewall is **ON**. If iPhones can't reach `192.168.1.102:8080/7880`, either click **Allow** + on the macOS prompt for `trinet-call-api` + `livekit-server`, or (user runs, needs their password): + ```bash + sudo /usr/libexec/ApplicationFirewall/socketfilterfw --setglobalstate off # re-enable with 'on' after + ``` + (The AI assistant must NOT type the user's sudo password or change the firewall — the user does this.) + +--- + +## 7. What was done this session +1. Unzipped the user's `call-api.zip` → `~/Desktop/PROJECTS/CLAUDE/call-api/` (dropped the 471 MB build cache). +2. Ran the prebuilt server + installed & ran LiveKit (`brew install livekit`). +3. Discovered the source needs `gen/rust/*` (monorepo) — cloned the unified repo for the coherent version. +4. Built the **macOS unified client** (LiveKit SDK) → it **registered `@m4mac`** on the server (verified in DB). +5. Verified the **unified iOS target compiles** for the Simulator. +6. Scouted the 4 feature areas (above) and found the zip server already implements APNs push + unread counts. +7. **Redesigned the iOS incoming-call screen** (`Views.swift:394`), fixed `answerButton` signature (added `glow`). + +## 8. Immediate next steps for whoever continues +1. Confirm `/tmp/iosverify2.log` shows `** BUILD SUCCEEDED **` for the incoming-UI change; screenshot it in the Simulator. +2. Implement group-chat sound + unread badge on the client (section 4C) — server already provides the data. +3. Get the user's APNs `.p8` + Key ID + Team ID, set the server env vars (section 4A), test closed-app ring on a physical iPhone. +4. Optionally tighten nickname→device binding (section 4D) — needs editing the SOURCE server (the 90869fc `services/call-api`, which builds in-place) and switching to it, or a new server build. +5. Point the iPhone Settings at `http://192.168.1.102:8080` + `ws://192.168.1.102:7880` and do a live @nick call. + +## 9. Repo state +This checkout is `gHashTag/tri-net` @ `feat/regen-final` `90869fc` (shallow clone). The only local edit is the +incoming-UI redesign in `phone/TriNetVideo/Views.swift`, committed on branch `feature/incoming-ui-and-notes`. +To push: `git -C ~/Desktop/PROJECTS/CLAUDE/trinet-unified push -u origin feature/incoming-ui-and-notes` (repo is `gHashTag/tri-net`). diff --git a/config/mesh-lab/node11.conf b/config/mesh-lab/node11.conf new file mode 100644 index 00000000..dbb4ffb3 --- /dev/null +++ b/config/mesh-lab/node11.conf @@ -0,0 +1,4 @@ +id 11 +listen 0.0.0.0:5000 +peer 12 192.168.1.12:5000 +peer 13 192.168.1.13:5000 diff --git a/config/mesh-lab/node12.conf b/config/mesh-lab/node12.conf new file mode 100644 index 00000000..f3a60332 --- /dev/null +++ b/config/mesh-lab/node12.conf @@ -0,0 +1,4 @@ +id 12 +listen 0.0.0.0:5000 +peer 11 192.168.1.11:5000 +peer 13 192.168.1.13:5000 diff --git a/config/mesh-lab/node13.conf b/config/mesh-lab/node13.conf new file mode 100644 index 00000000..3d4fd136 --- /dev/null +++ b/config/mesh-lab/node13.conf @@ -0,0 +1,4 @@ +id 13 +listen 0.0.0.0:5000 +peer 11 192.168.1.11:5000 +peer 12 192.168.1.12:5000 diff --git a/docs/INTERNET_CALLING.md b/docs/INTERNET_CALLING.md new file mode 100644 index 00000000..2179a750 --- /dev/null +++ b/docs/INTERNET_CALLING.md @@ -0,0 +1,171 @@ +# Internet Calling + +Multi-device owner identity, trusted-device linking, and first-answer-wins call +fan-out are documented in [MULTI_DEVICE_IDENTITY.md](MULTI_DEVICE_IDENTITY.md). + +TRI-NET uses two media transports behind one call UI: + +- Local/Mesh UDP mode keeps the direct encrypted UDP/radio path. +- Internet mode uses LiveKit WebRTC for ICE, TURN, congestion control, audio, + video, and reliable call data. +- Auto mode uses an explicit mesh peer when one is selected and otherwise uses + the internet contact directory. Automatic peer discovery is a separate mesh + service and is not inferred from a display name. + +The display name, such as `ssd26`, is not a security identity. Each installation +creates a random user ID, a random device ID, and a P-256 signing key. The +private key stays in the Apple Keychain and is marked as non-migrating. The +public key and its SHA-256 fingerprint are registered with the call service. + +## Development connection + +The iOS and macOS settings screens support a direct LiveKit mode. Create a room +token for the same room and configure both peers with: + +- LiveKit URL: `wss://...` +- Development room token: a short-lived token with room join, publish, and + subscribe grants + +The app skips the directory and push API in this mode. Use distinct participant +identities in tokens. Static or long-lived room tokens are development-only. + +For Xcode builds, the same values can be supplied through these build settings: + +- `TRINET_LIVEKIT_URL` +- `TRINET_DEVELOPMENT_ROOM_TOKEN` +- `TRINET_API_BASE_URL` +- `TRINET_SERVICE_ACCESS_TOKEN` + +Do not commit real tokens to the project or an Info.plist file. + +For a same-LAN development test, run LiveKit in development mode bound to all +interfaces, create two short-lived tokens for the same room with distinct +participant identities, and save one token on each client. A local endpoint can +use `ws://host.local:7880`; production endpoints must use TLS (`wss://`). This +test validates WebRTC signaling and media, but it does not provide production +contact lookup, remote ringing, or APNs delivery. + +## Production API + +The client currently consumes this HTTPS API: + +Nickname creation and contact lookup use the companion endpoints documented in +`NICKNAME_DIRECTORY.md`. Call creation accepts a normalized nickname in the +`callee` field; the service resolves it to the registered destination devices. + +### Register a device + +`POST /v1/devices/register` + +The JSON body contains `user_id`, `device_id`, `display_name`, +`signing_public_key`, `key_fingerprint`, `platform`, `voip_push_token`, and +`capabilities`. + +### Start a call + +`POST /v1/calls` + +The JSON body contains `callee`, `caller_user_id`, `caller_device_id`, `audio`, +and `video`. The response is: + +```json +{ + "call_id": "opaque-call-id", + "room_id": "opaque-room-id", + "livekit_url": "wss://livekit.example.net", + "token": "short-lived-participant-token", + "media_key": null +} +``` + +The service resolves `callee`, sends a VoIP push to the callee devices, and +returns a participant-scoped LiveKit token. The token should expire in no more +than five minutes and grant access to one room only. + +### Answer a call + +`POST /v1/calls/{call_id}/join` + +The JSON body contains `user_id` and `device_id`; the response has the same +shape as the start-call response. + +### Poll foreground incoming calls + +`POST /v1/calls/incoming` + +The JSON body contains `user_id` and `device_id`. The response contains a +`calls` array with `call_id`, `caller`, `audio`, `video`, and `created_at`. +The signed foreground client polls this endpoint every three seconds and +reports a new iPhone call through CallKit. This makes development and +foreground calling work without an APNs credential. It is not a replacement +for VoIP push when the iPhone app is suspended or terminated. + +## Device proof headers + +Every API request is signed with the device P-256 key and includes: + +- `X-TRINET-Device-ID` +- `X-TRINET-Timestamp` +- `X-TRINET-Nonce` +- `X-TRINET-Signature` + +The signature is DER-encoded ECDSA, then Base64 encoded. Its canonical input is +UTF-8 text with newline separators: + +```text +UPPERCASE_HTTP_METHOD +REQUEST_PATH +UNIX_TIMESTAMP_SECONDS +LOWERCASE_UUID_NONCE +LOWERCASE_HEX_SHA256_OF_EXACT_BODY +``` + +The service must reject timestamps outside the 60-second policy window and +must reject a nonce already used by that device. Registration is a signed +bootstrap: verify the request against the public key in its body before storing +the device record. Later requests use the stored key. An account access token +can be required in addition to this device proof. + +## Incoming iPhone calls + +For background production delivery, the deployment must add an APNs adapter +that sends a VoIP push whose payload contains: + +```json +{ + "call_id": "opaque-call-id", + "call_uuid": "b5afbcb6-2c2c-4e46-a86e-7e5444aa5b62", + "caller_name": "Alice", + "video": true +} +``` + +The iOS app immediately reports the call to CallKit and joins the room only +after the user answers. Production deployment requires the Push Notifications +and VoIP background capabilities, an APNs signing key, and a provisioning +profile for the application bundle identifier. + +## Call API service + +The Rust service lives in `services/call-api`. It implements device +registration, atomic nickname claims, search, call creation, signed incoming +polling, recipient-only join authorization, replay-protected request proofs, +and five-minute room-scoped LiveKit JWTs. Its isolated test covers the complete +nickname-to-call signaling flow with two generated P-256 identities. + +See `services/call-api/README.md` for local execution and deployment +configuration. An arbitrary-network deployment still requires a public HTTPS +address for this API and a public `wss://` LiveKit Cloud or self-hosted +LiveKit/TURN endpoint. Local `.local` and RFC1918 addresses cannot provide that +Internet reachability. + +## Security boundary + +WebRTC media is encrypted in transit. If `media_key` is present, the client also +enables LiveKit frame encryption. A production service should distribute a +per-call media key encrypted separately to each registered device; returning a +plain shared key from a trusted API is only an integration stage, not a +server-blind end-to-end key exchange. + +The `.t27` source of truth for routing, token freshness, device validity, and +call lifecycle is `specs/internet_call.t27`. diff --git a/docs/MULTI_DEVICE_IDENTITY.md b/docs/MULTI_DEVICE_IDENTITY.md new file mode 100644 index 00000000..b6e7371e --- /dev/null +++ b/docs/MULTI_DEVICE_IDENTITY.md @@ -0,0 +1,95 @@ +# Multi-device owner identity + +## Decision + +TRI-NET uses three separate identity layers: + +1. An account is the stable owner identity. +2. One globally unique nickname belongs to the account. +3. Every app installation is a separately keyed and separately revocable device. + +A password and a device private key are never copied between devices. This is +important for both Internet and mesh operation: losing one iPhone must allow +that iPhone to be revoked without changing every other device key. + +For production account recovery and sign-in, the preferred credential is a +passkey. Apple passkeys use public-key credentials, user verification, and +iCloud Keychain synchronization. Native passkey support requires a stable HTTPS +relying-party domain, an Apple associated-domain entitlement, and WebAuthn +challenge verification on the server. The local development deployment does +not have that domain, so the currently operational bootstrap is trusted-device +approval with a one-time link code. + +Primary references: + +- Apple passkeys: +- Apple passkey integration: +- W3C WebAuthn Level 3: +- Apple Secure Enclave keys: +- Apple multi-device PushKit tokens: +- MLS for future durable group chat: + +## Implemented behavior + +- A first installation creates an account and a device P-256 signing key. +- The device key remains in the device Keychain and is not synchronized. +- A nickname claim is account-owned. Linked devices share the same nickname. +- A trusted device can create a 128-bit, single-use link code valid for ten + minutes. Only a signed request from that trusted device can create it. +- A new device signs its own link request. Linking changes its account ID but + preserves its independent key and device ID. +- Linking a previous single-device account relinquishes its previous nickname. + An account with multiple devices cannot be silently merged into another one. +- Settings on iOS and macOS show the account ID, nickname, device list, link + controls, and per-device revocation. +- Calls target an account and fan out to every active WebRTC device. The first + device to accept wins an immediate SQLite transaction; all other targets are + marked ended and cannot join the room. +- Search returns one account result even when that account has several devices. +- Revoked devices cannot authenticate, receive new call targets, or retain a + PushKit token. The final active device cannot be revoked through this flow. +- Mesh advertisements may contain the same nickname on several devices only + when their signed account ID is the same. + +## User flow + +1. Configure the same Directory API URL on the iPhone and Mac. +2. On the device whose nickname should be kept, open Settings, choose + `Create One-Time Code`, and copy the displayed code. +3. On the other device, paste it into `link_... from trusted device` and choose + `Link This Device` or `Link This Mac`. +4. Both devices now display the same account ID and nickname but different + device IDs and key fingerprints. +5. A call to that nickname rings every active device. Answering on one prevents + all other devices from joining that call. + +Linking requires access to the authoritative Directory API. After provisioning, +the account ID and device key are cached locally, so signed local/mesh discovery +and calls continue without the Internet when a valid mesh route is available. + +## Production completion gates + +The following require deployment credentials or infrastructure and are not +simulated by the client: + +- Public HTTPS API and WSS LiveKit/TURN endpoints. +- A production domain and `webcredentials` associated-domain file for passkeys. +- Server-side WebAuthn registration, assertion, recovery, and credential + revocation endpoints. +- APNs VoIP signing credentials. PushKit must store a separate token for every + device and CallKit must be notified immediately for each incoming VoIP push. +- Durable asynchronous chat. The current chat channel is call-scoped. A future + durable store should use client-generated message IDs, idempotent submission, + server sequence numbers, per-device acknowledgements, and encrypted envelopes + for every active device. MLS (RFC 9420) is the preferred standards direction + for multi-device group encryption, forward secrecy, and post-compromise + security. + +## Why not a shared login and password + +A shared password is phishable, can be reused, and does not identify which +device performed an action. A synchronized passkey is the best user credential +for the owner, while independent device keys provide device-level audit and +revocation. The two roles complement each other: the passkey proves the person; +the device key proves the installed endpoint for every call, mesh invite, and +API request. diff --git a/docs/NICKNAME_DIRECTORY.md b/docs/NICKNAME_DIRECTORY.md new file mode 100644 index 00000000..c48afce8 --- /dev/null +++ b/docs/NICKNAME_DIRECTORY.md @@ -0,0 +1,125 @@ +# Nickname Directory + +TRI-NET resolves human-readable nicknames to stable cryptographic device +identities. A nickname is never used as the security identity itself. + +## Nickname rules + +- 3 to 20 characters +- lowercase ASCII letters, decimal digits, and underscore +- the first character must be a letter +- exact normalized collisions are rejected +- edit distance 1 is rejected as a near-copy +- edit distance 2 is rejected when at least four prefix characters match + +Restricting the alphabet removes Unicode homograph ambiguity. The authoritative +service performs normalization, similarity checks, and the unique database +insert in one atomic claim operation. Client checks are only early feedback. + +## Claim levels + +- `verified`: the internet registry has atomically reserved the nickname for the + signed user and device identity. +- `mesh-local`: the nickname does not conflict with currently reachable signed + mesh peers, but has not been checked against the global registry. + +A disconnected network partition cannot mathematically guarantee global +uniqueness. A mesh-local claim is therefore provisional and must be reconciled +when the internet registry becomes reachable. + +## Internet API + +Every request uses the device-proof headers defined in `INTERNET_CALLING.md`. + +### Atomic claim + +`POST /v1/directory/nicknames/claim` + +```json +{ + "nickname": "alice_net", + "user_id": "opaque-user-id", + "device_id": "opaque-device-id" +} +``` + +Accepted response: + +```json +{ + "claimed": true, + "normalized": "alice_net", + "reason": null, + "suggestions": [] +} +``` + +Rejected response: + +```json +{ + "claimed": false, + "normalized": "alice", + "reason": "Nickname is already used or too similar", + "suggestions": ["alice_net384", "alice_mesh421", "alice_tri458"] +} +``` + +The service must not implement availability check and reservation as separate +operations. A unique index on the normalized nickname and one atomic claim +transaction prevent a race between two clients. + +### Search + +`POST /v1/directory/search` + +```json +{ + "query": "alice", + "limit": 20 +} +``` + +The response contains `results` with `user_id`, `device_id`, `nickname`, +`display_name`, `key_fingerprint`, and `online`. Search should prefer exact and +prefix matches and must not expose private profile data. + +## Local and routed-mesh discovery + +Apple clients advertise `_trinet-call._udp` through Bonjour. Each TXT record +contains the normalized nickname, display label, user ID, device ID, public +key, fingerprint, and a P-256 signature. Bonjour advertises UDP port 7001 for +signed call invitations; encrypted media uses UDP port 7000. The directory +signature input is: + +```text +NORMALIZED_NICKNAME +USER_ID +DEVICE_ID +UDP_PORT +``` + +Unsigned, malformed, or invalid cards are ignored. A selected mesh result is +resolved to its current IPv4 address. The caller then sends a short-lived, +P-256-signed invitation with a one-time nonce. The receiver rings only when the +embedded public key, fingerprint, device identity, and signature agree. Prior +Bonjour visibility is not required to accept a valid invitation, which allows +a routed mesh peer with a known address to ring through multiple hops. +Replayed, expired, self-originated, or forged invitations are rejected. Media +starts on UDP port 7000 only after the recipient accepts and a fresh +authenticated media handshake completes. + +After a valid Bonjour resolution, the adapter caches the signed peer identity +and last usable address for at most seven days. This lets nickname routing keep +working while iOS temporarily suspends Bonjour publication. The media +handshake remains authoritative, so a stale or redirected address cannot +establish a secure session as another device. + +Bonjour on one Wi-Fi or hotspot segment is the bootstrap discovery mechanism, +not proof of a radio mesh route. A routed radio mesh can use a cached nickname +route or a direct routed IPv4 address; distributing a brand-new nickname +between isolated segments still requires mDNS relay or an equivalent signed +directory gossip service. Product UI uses `Local/Mesh UDP` until node telemetry +can prove the selected route. + +The source-of-truth policy is `specs/nickname_directory.t27`. diff --git a/gen/rust/account_identity.rs b/gen/rust/account_identity.rs new file mode 100644 index 00000000..5c93d07a --- /dev/null +++ b/gen/rust/account_identity.rs @@ -0,0 +1,30 @@ +// Generated from .t27 spec +// DO NOT EDIT — generated by t27c + +pub const DEVICE_ACTIVE: u8 = 1; + +pub const DEVICE_REVOKED: u8 = 2; + +pub const LINK_CODE_TTL_SECONDS: u32 = 600; + +pub const LINK_CODE_ENTROPY_BITS: u16 = 128; + +pub fn device_membership_is_valid(account_id: u64, device_id: u64, key_fingerprint: u64, status: u8) -> bool { + return ((((account_id != 0) && (device_id != 0)) && (key_fingerprint != 0)) && (status == DEVICE_ACTIVE)); +} + +pub fn link_code_is_fresh(created_at: u32, now: u32) -> bool { + if (now < created_at) { + return false; + } + return ((now - created_at) <= LINK_CODE_TTL_SECONDS); +} + +pub fn may_adopt_account(code_matches: bool, code_unused: bool, code_fresh: bool, source_is_single_device: bool) -> bool { + return (((code_matches && code_unused) && code_fresh) && source_is_single_device); +} + +pub fn may_revoke_device(same_account: bool, target_active: bool, active_devices: u16) -> bool { + return ((same_account && target_active) && (active_devices > 1)); +} + diff --git a/gen/rust/group_chat.rs b/gen/rust/group_chat.rs new file mode 100644 index 00000000..76af5bff --- /dev/null +++ b/gen/rust/group_chat.rs @@ -0,0 +1,45 @@ +// Generated from .t27 spec +// DO NOT EDIT — generated by t27c + +pub const MIN_GROUP_MEMBERS: u8 = 2; + +pub const MAX_GROUP_MEMBERS: u8 = 32; + +pub const MAX_GROUP_TITLE_BYTES: u16 = 80; + +pub const MAX_MESSAGE_BYTES: u16 = 4096; + +pub const MAX_MESSAGE_PAGE: u16 = 100; + +pub fn group_may_be_created(creator_valid: bool, requested: u8, resolved: u8, unique: u8) -> bool { + if (!(creator_valid) || (requested == 0)) { + return false; + } + if ((requested != resolved) || (requested != unique)) { + return false; + } + return (requested < MAX_GROUP_MEMBERS); +} + +pub fn title_is_valid(byte_length: u16) -> bool { + return ((byte_length > 0) && (byte_length <= MAX_GROUP_TITLE_BYTES)); +} + +pub fn member_may_read(active_member: bool, device_valid: bool) -> bool { + return (active_member && device_valid); +} + +pub fn message_may_be_sent(active_member: bool, device_valid: bool, byte_length: u16) -> bool { + return ((member_may_read(active_member, device_valid) && (byte_length > 0)) && (byte_length <= MAX_MESSAGE_BYTES)); +} + +pub fn message_page_size(requested: u16) -> u16 { + if (requested == 0) { + return 1; + } + if (requested > MAX_MESSAGE_PAGE) { + return MAX_MESSAGE_PAGE; + } + return requested; +} + diff --git a/gen/rust/internet_call.rs b/gen/rust/internet_call.rs new file mode 100644 index 00000000..fbfe811a --- /dev/null +++ b/gen/rust/internet_call.rs @@ -0,0 +1,124 @@ +// Generated from .t27 spec +// DO NOT EDIT — generated by t27c + +pub const ROUTE_NONE: u8 = 0; + +pub const ROUTE_MESH: u8 = 1; + +pub const ROUTE_INTERNET: u8 = 2; + +pub const CALL_IDLE: u8 = 0; + +pub const CALL_RINGING: u8 = 1; + +pub const CALL_ACTIVE: u8 = 2; + +pub const CALL_ENDED: u8 = 3; + +pub const CAP_AUDIO: u8 = 1; + +pub const CAP_VIDEO: u8 = 2; + +pub const CAP_MESH: u8 = 4; + +pub const CAP_WEBRTC: u8 = 8; + +pub const INVITE_TTL_SECONDS: u32 = 30; + +pub const TOKEN_TTL_SECONDS: u32 = 300; + +pub const REQUEST_SIGNATURE_TTL_SECONDS: u32 = 60; + +pub const PRESENCE_TTL_SECONDS: u32 = 90; + +pub fn device_is_valid(user_id: u64, device_id: u64, key_fingerprint: u64, capabilities: u8) -> bool { + if (((user_id == 0) || (device_id == 0)) || (key_fingerprint == 0)) { + return false; + } + return ((capabilities & CAP_AUDIO) != 0); +} + +pub fn supports_internet_call(capabilities: u8) -> bool { + return (((capabilities & CAP_AUDIO) != 0) && ((capabilities & CAP_WEBRTC) != 0)); +} + +pub fn supports_video_call(capabilities: u8) -> bool { + return (supports_internet_call(capabilities) && ((capabilities & CAP_VIDEO) != 0)); +} + +pub fn device_is_online(last_seen: u32, now: u32) -> bool { + if (now < last_seen) { + return false; + } + return ((now - last_seen) <= PRESENCE_TTL_SECONDS); +} + +pub fn select_route(mesh_reachable: bool, internet_reachable: bool) -> u8 { + if mesh_reachable { + return ROUTE_MESH; + } + if internet_reachable { + return ROUTE_INTERNET; + } + return ROUTE_NONE; +} + +pub fn invite_is_fresh(created_at: u32, now: u32) -> bool { + if (now < created_at) { + return false; + } + return ((now - created_at) <= INVITE_TTL_SECONDS); +} + +pub fn token_is_fresh(issued_at: u32, now: u32) -> bool { + if (now < issued_at) { + return false; + } + return ((now - issued_at) <= TOKEN_TTL_SECONDS); +} + +pub fn request_signature_is_fresh(signed_at: u32, now: u32) -> bool { + if (now < signed_at) { + return false; + } + return ((now - signed_at) <= REQUEST_SIGNATURE_TTL_SECONDS); +} + +pub fn may_answer(status: u8, invite_fresh: bool, device_valid: bool) -> bool { + return (((status == CALL_RINGING) && invite_fresh) && device_valid); +} + +pub fn call_target_is_valid(caller_user_id: u64, caller_device_id: u64, callee_user_id: u64, callee_device_id: u64, callee_capabilities: u8) -> bool { + if ((((caller_user_id == 0) || (caller_device_id == 0)) || (callee_user_id == 0)) || (callee_device_id == 0)) { + return false; + } + if (caller_user_id == callee_user_id) { + return false; + } + return supports_internet_call(callee_capabilities); +} + +pub fn call_target_is_available(caller_user_id: u64, caller_device_id: u64, callee_user_id: u64, callee_device_id: u64, callee_capabilities: u8, online: bool) -> bool { + return (online && call_target_is_valid(caller_user_id, caller_device_id, callee_user_id, callee_device_id, callee_capabilities)); +} + +pub fn join_is_authorized(request_user_id: u64, request_device_id: u64, callee_user_id: u64, callee_device_id: u64, status: u8, invite_fresh: bool, device_valid: bool) -> bool { + return (((request_user_id == callee_user_id) && (request_device_id == callee_device_id)) && may_answer(status, invite_fresh, device_valid)); +} + +pub fn next_status(status: u8, accept: bool) -> u8 { + if (status == CALL_IDLE) { + return CALL_RINGING; + } + if ((status == CALL_RINGING) && accept) { + return CALL_ACTIVE; + } + if ((status == CALL_RINGING) && !(accept)) { + return CALL_ENDED; + } + if ((status == CALL_ACTIVE) && !(accept)) { + return CALL_ENDED; + } + return status; +} + diff --git a/gen/rust/mesh_call_signaling.rs b/gen/rust/mesh_call_signaling.rs new file mode 100644 index 00000000..9c8e10e5 --- /dev/null +++ b/gen/rust/mesh_call_signaling.rs @@ -0,0 +1,25 @@ +// Generated from .t27 spec +// DO NOT EDIT — generated by t27c + +pub const INVITE_VERSION: u8 = 1; + +pub const MEDIA_PORT: u32 = 7000; + +pub const SIGNALING_PORT: u32 = 7001; + +pub const INVITE_TTL_SECONDS: u32 = 30; + +pub fn invite_is_fresh(created_at: u32, now: u32) -> bool { + if (now < created_at) { + return false; + } + return ((now - created_at) <= INVITE_TTL_SECONDS); +} + +pub fn invite_may_ring(version: u8, media_port: u32, signature_valid: bool, identity_binding_valid: bool, nonce_reused: bool, fresh: bool) -> bool { + if ((version != INVITE_VERSION) || (media_port != MEDIA_PORT)) { + return false; + } + return (((signature_valid && identity_binding_valid) && !(nonce_reused)) && fresh); +} + diff --git a/gen/rust/nickname_directory.rs b/gen/rust/nickname_directory.rs new file mode 100644 index 00000000..ee5fdeea --- /dev/null +++ b/gen/rust/nickname_directory.rs @@ -0,0 +1,64 @@ +// Generated from .t27 spec +// DO NOT EDIT — generated by t27c + +pub const NICKNAME_MIN_LENGTH: u8 = 3; + +pub const NICKNAME_MAX_LENGTH: u8 = 20; + +pub const MAX_EDIT_DISTANCE: u8 = 1; + +pub const MIN_CONFUSING_PREFIX: u8 = 4; + +pub const MESH_ROUTE_CACHE_TTL_SECONDS: u32 = 604800; + +pub const CLAIM_REJECTED: u8 = 0; + +pub const CLAIM_MESH_LOCAL: u8 = 1; + +pub const CLAIM_VERIFIED: u8 = 2; + +pub fn nickname_shape_is_valid(length: u8, starts_with_letter: bool, invalid_characters: u8) -> bool { + if ((length < NICKNAME_MIN_LENGTH) || (length > NICKNAME_MAX_LENGTH)) { + return false; + } + return (starts_with_letter && (invalid_characters == 0)); +} + +pub fn nickname_is_confusing(exact_match: bool, edit_distance: u8, shared_prefix: u8) -> bool { + if exact_match { + return true; + } + if (edit_distance <= MAX_EDIT_DISTANCE) { + return true; + } + return ((shared_prefix >= MIN_CONFUSING_PREFIX) && (edit_distance == 2)); +} + +pub fn claim_status(shape_valid: bool, confusing: bool, registry_reachable: bool, registry_accepts: bool) -> u8 { + if (!(shape_valid) || confusing) { + return CLAIM_REJECTED; + } + if (registry_reachable && registry_accepts) { + return CLAIM_VERIFIED; + } + if !(registry_reachable) { + return CLAIM_MESH_LOCAL; + } + return CLAIM_REJECTED; +} + +pub fn may_route_by_nickname(claim: u8, signature_valid: bool) -> bool { + return (((claim == CLAIM_MESH_LOCAL) || (claim == CLAIM_VERIFIED)) && signature_valid); +} + +pub fn nickname_owner_matches(claim_account_id: u64, device_account_id: u64) -> bool { + return ((claim_account_id != 0) && (claim_account_id == device_account_id)); +} + +pub fn cached_mesh_route_is_fresh(last_seen: u32, now: u32) -> bool { + if (now < last_seen) { + return false; + } + return ((now - last_seen) <= MESH_ROUTE_CACHE_TTL_SECONDS); +} + diff --git a/phone/TriNetVideo.xcodeproj/project.pbxproj b/phone/TriNetVideo.xcodeproj/project.pbxproj index 4a73f967..bef85e86 100644 --- a/phone/TriNetVideo.xcodeproj/project.pbxproj +++ b/phone/TriNetVideo.xcodeproj/project.pbxproj @@ -7,30 +7,76 @@ objects = { /* Begin PBXBuildFile section */ + 30BBD2896D5A7FC7B1925F43 /* CallKitCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77C7B555729BF4950129B2B3 /* CallKitCoordinator.swift */; }; 32F9E38E7DAE216C3F1451B4 /* MeshMapView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A440834DBBB15D89B628F943 /* MeshMapView.swift */; }; 46733E13DB201753378F9262 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 30BC89191B19A3BEEC4154B1 /* Assets.xcassets */; }; + 4A82037CE58913122DAD91B2 /* NicknameDirectory.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB09FD7E74ABA713CD5310EF /* NicknameDirectory.swift */; }; + 8BE32CD40859109FC2A7C369 /* CallIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7212A08167551ACF6967F7D0 /* CallIdentity.swift */; }; 93828957945393D93D1AE545 /* App.swift in Sources */ = {isa = PBXBuildFile; fileRef = 919A6C8C4AE3FF1BEBC24E2D /* App.swift */; }; 9E0CD413BCD9A696DA51DC24 /* VideoPipeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3ED271D273E3217E7CC3EE2 /* VideoPipeline.swift */; }; C42AEF335F11DD33CC802C77 /* Views.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3E1BEB0E6D36884DC34BAF1 /* Views.swift */; }; + D97D14140C893639F33F37D5 /* LiveKit in Frameworks */ = {isa = PBXBuildFile; productRef = C4D0DDD6535CFA404CBF8DAA /* LiveKit */; }; + DCF621B6EAFBA584120600A9 /* NicknamePolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E94DD97F9BCB86654BF8570F /* NicknamePolicyTests.swift */; }; E5F2C842E940E04413D23C77 /* ViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67409F0DFE04304A7A386F76 /* ViewModel.swift */; }; + FAE00C03F4913C43EBDEE6A2 /* InternetCall.swift in Sources */ = {isa = PBXBuildFile; fileRef = 279CEAB4FFAA47773A21AE83 /* InternetCall.swift */; }; /* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + 7AB41012168F582CBB73864F /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 46F6CE57375BBA9D8E897E3E /* Project object */; + proxyType = 1; + remoteGlobalIDString = 940A8758E3352D4C38C9BBA8; + remoteInfo = TriNetVideo; + }; +/* End PBXContainerItemProxy section */ + /* Begin PBXFileReference section */ + 1E0F86581AC710AC4AA81C5F /* TriNetVideo.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = TriNetVideo.entitlements; sourceTree = ""; }; + 279CEAB4FFAA47773A21AE83 /* InternetCall.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InternetCall.swift; sourceTree = ""; }; 30BC89191B19A3BEEC4154B1 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 39BC2F38EBB30740C9BBE500 /* TriNetVideoTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = TriNetVideoTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 5235533C9F22E43B48C7BE18 /* TriNetVideo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = TriNetVideo.app; sourceTree = BUILT_PRODUCTS_DIR; }; 67409F0DFE04304A7A386F76 /* ViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModel.swift; sourceTree = ""; }; + 7212A08167551ACF6967F7D0 /* CallIdentity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallIdentity.swift; sourceTree = ""; }; + 77C7B555729BF4950129B2B3 /* CallKitCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallKitCoordinator.swift; sourceTree = ""; }; 919A6C8C4AE3FF1BEBC24E2D /* App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App.swift; sourceTree = ""; }; A440834DBBB15D89B628F943 /* MeshMapView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshMapView.swift; sourceTree = ""; }; B531BE39B4623AAA1D6E3823 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; C3E1BEB0E6D36884DC34BAF1 /* Views.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Views.swift; sourceTree = ""; }; C3ED271D273E3217E7CC3EE2 /* VideoPipeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPipeline.swift; sourceTree = ""; }; + E94DD97F9BCB86654BF8570F /* NicknamePolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NicknamePolicyTests.swift; sourceTree = ""; }; + EB09FD7E74ABA713CD5310EF /* NicknameDirectory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NicknameDirectory.swift; sourceTree = ""; }; /* End PBXFileReference section */ +/* Begin PBXFrameworksBuildPhase section */ + A992AD0FA4A8B6D7F4F2E05C /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + D97D14140C893639F33F37D5 /* LiveKit in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + /* Begin PBXGroup section */ + 26C2741AD7A62D60BD384EE3 /* shared */ = { + isa = PBXGroup; + children = ( + 7212A08167551ACF6967F7D0 /* CallIdentity.swift */, + 279CEAB4FFAA47773A21AE83 /* InternetCall.swift */, + EB09FD7E74ABA713CD5310EF /* NicknameDirectory.swift */, + ); + path = shared; + sourceTree = ""; + }; A6C8AF585BF4DA374BF1E301 = { isa = PBXGroup; children = ( + 26C2741AD7A62D60BD384EE3 /* shared */, AB31945200E8755D13A21492 /* TriNetVideo */, + CAF0276C6DD58239B77C0AB2 /* TriNetVideoTests */, BDEBCA25FD616B1244269FB0 /* Products */, ); sourceTree = ""; @@ -40,8 +86,10 @@ children = ( 919A6C8C4AE3FF1BEBC24E2D /* App.swift */, 30BC89191B19A3BEEC4154B1 /* Assets.xcassets */, + 77C7B555729BF4950129B2B3 /* CallKitCoordinator.swift */, B531BE39B4623AAA1D6E3823 /* Info.plist */, A440834DBBB15D89B628F943 /* MeshMapView.swift */, + 1E0F86581AC710AC4AA81C5F /* TriNetVideo.entitlements */, C3ED271D273E3217E7CC3EE2 /* VideoPipeline.swift */, 67409F0DFE04304A7A386F76 /* ViewModel.swift */, C3E1BEB0E6D36884DC34BAF1 /* Views.swift */, @@ -53,10 +101,19 @@ isa = PBXGroup; children = ( 5235533C9F22E43B48C7BE18 /* TriNetVideo.app */, + 39BC2F38EBB30740C9BBE500 /* TriNetVideoTests.xctest */, ); name = Products; sourceTree = ""; }; + CAF0276C6DD58239B77C0AB2 /* TriNetVideoTests */ = { + isa = PBXGroup; + children = ( + E94DD97F9BCB86654BF8570F /* NicknamePolicyTests.swift */, + ); + path = TriNetVideoTests; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -66,6 +123,7 @@ buildPhases = ( 6D90D1F1C47C345671E3F8F9 /* Sources */, D2357CD5F9537B8A21AA2A90 /* Resources */, + A992AD0FA4A8B6D7F4F2E05C /* Frameworks */, ); buildRules = ( ); @@ -73,11 +131,30 @@ ); name = TriNetVideo; packageProductDependencies = ( + C4D0DDD6535CFA404CBF8DAA /* LiveKit */, ); productName = TriNetVideo; productReference = 5235533C9F22E43B48C7BE18 /* TriNetVideo.app */; productType = "com.apple.product-type.application"; }; + E8136BB8EB84045A1718A9D7 /* TriNetVideoTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 229469408C45D0C1A6755657 /* Build configuration list for PBXNativeTarget "TriNetVideoTests" */; + buildPhases = ( + 99DD34D2894F9AB61ADB7D10 /* Sources */, + ); + buildRules = ( + ); + dependencies = ( + BEE2656BFEFF6D42685E82F7 /* PBXTargetDependency */, + ); + name = TriNetVideoTests; + packageProductDependencies = ( + ); + productName = TriNetVideoTests; + productReference = 39BC2F38EBB30740C9BBE500 /* TriNetVideoTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -90,6 +167,9 @@ 940A8758E3352D4C38C9BBA8 = { ProvisioningStyle = Automatic; }; + E8136BB8EB84045A1718A9D7 = { + ProvisioningStyle = Automatic; + }; }; }; buildConfigurationList = 07981B78B5E85DEEF70866B1 /* Build configuration list for PBXProject "TriNetVideo" */; @@ -101,12 +181,16 @@ ); mainGroup = A6C8AF585BF4DA374BF1E301; minimizedProjectReferenceProxies = 1; + packageReferences = ( + 7396F5F92AEBADE75587ABFF /* XCRemoteSwiftPackageReference "client-sdk-swift" */, + ); preferredProjectObjectVersion = 77; productRefGroup = BDEBCA25FD616B1244269FB0 /* Products */; projectDirPath = ""; projectRoot = ""; targets = ( 940A8758E3352D4C38C9BBA8 /* TriNetVideo */, + E8136BB8EB84045A1718A9D7 /* TriNetVideoTests */, ); }; /* End PBXProject section */ @@ -128,16 +212,53 @@ buildActionMask = 2147483647; files = ( 93828957945393D93D1AE545 /* App.swift in Sources */, + 8BE32CD40859109FC2A7C369 /* CallIdentity.swift in Sources */, + 30BBD2896D5A7FC7B1925F43 /* CallKitCoordinator.swift in Sources */, + FAE00C03F4913C43EBDEE6A2 /* InternetCall.swift in Sources */, 32F9E38E7DAE216C3F1451B4 /* MeshMapView.swift in Sources */, + 4A82037CE58913122DAD91B2 /* NicknameDirectory.swift in Sources */, 9E0CD413BCD9A696DA51DC24 /* VideoPipeline.swift in Sources */, E5F2C842E940E04413D23C77 /* ViewModel.swift in Sources */, C42AEF335F11DD33CC802C77 /* Views.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; + 99DD34D2894F9AB61ADB7D10 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + DCF621B6EAFBA584120600A9 /* NicknamePolicyTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + BEE2656BFEFF6D42685E82F7 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 940A8758E3352D4C38C9BBA8 /* TriNetVideo */; + targetProxy = 7AB41012168F582CBB73864F /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin XCBuildConfiguration section */ + 050C8A476BA2E2598C492C49 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.trinet.video.tests; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/TriNetVideo.app/TriNetVideo"; + }; + name = Release; + }; 54A8B7DCB5C67571724CBC14 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -208,6 +329,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGNING_ALLOWED = YES; CODE_SIGNING_REQUIRED = YES; + CODE_SIGN_ENTITLEMENTS = TriNetVideo/TriNetVideo.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = 5EM4M85VSQ; @@ -287,6 +409,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGNING_ALLOWED = YES; CODE_SIGNING_REQUIRED = YES; + CODE_SIGN_ENTITLEMENTS = TriNetVideo/TriNetVideo.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = 5EM4M85VSQ; @@ -303,6 +426,23 @@ }; name = Release; }; + CA68EDE257D205E90053BB70 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.trinet.video.tests; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/TriNetVideo.app/TriNetVideo"; + }; + name = Debug; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -315,6 +455,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Debug; }; + 229469408C45D0C1A6755657 /* Build configuration list for PBXNativeTarget "TriNetVideoTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + CA68EDE257D205E90053BB70 /* Debug */, + 050C8A476BA2E2598C492C49 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; 8BF7BEC5E62F701272704476 /* Build configuration list for PBXNativeTarget "TriNetVideo" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -325,6 +474,25 @@ defaultConfigurationName = Debug; }; /* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + 7396F5F92AEBADE75587ABFF /* XCRemoteSwiftPackageReference "client-sdk-swift" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/livekit/client-sdk-swift.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 2.15.2; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + C4D0DDD6535CFA404CBF8DAA /* LiveKit */ = { + isa = XCSwiftPackageProductDependency; + package = 7396F5F92AEBADE75587ABFF /* XCRemoteSwiftPackageReference "client-sdk-swift" */; + productName = LiveKit; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 46F6CE57375BBA9D8E897E3E /* Project object */; } diff --git a/phone/TriNetVideo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/phone/TriNetVideo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 00000000..72f4fbfe --- /dev/null +++ b/phone/TriNetVideo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,42 @@ +{ + "originHash" : "f23dc9251b30242b9d104cd8d1c7e5c16c31c611a03561a7fa74a136843469d9", + "pins" : [ + { + "identity" : "client-sdk-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/livekit/client-sdk-swift.git", + "state" : { + "revision" : "77b5aad07909e23adf97d39f205ef7e18e2ceff5", + "version" : "2.15.2" + } + }, + { + "identity" : "livekit-uniffi-xcframework", + "kind" : "remoteSourceControl", + "location" : "https://github.com/livekit/livekit-uniffi-xcframework.git", + "state" : { + "revision" : "7c161254ce7cd55debc48023f69a917076b12a26", + "version" : "0.0.6" + } + }, + { + "identity" : "swift-protobuf", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-protobuf.git", + "state" : { + "revision" : "55d7a1cc5666b85c13464aea1c4b4a90feccb4c8", + "version" : "1.38.1" + } + }, + { + "identity" : "webrtc-xcframework", + "kind" : "remoteSourceControl", + "location" : "https://github.com/livekit/webrtc-xcframework.git", + "state" : { + "revision" : "46f2af86f06b9a8a9158d37cadda5cb5a214e4c4", + "version" : "144.7559.11" + } + } + ], + "version" : 3 +} diff --git a/phone/TriNetVideo/App.swift b/phone/TriNetVideo/App.swift index 804a17a1..f5e65332 100644 --- a/phone/TriNetVideo/App.swift +++ b/phone/TriNetVideo/App.swift @@ -3,14 +3,20 @@ import SwiftUI @main struct TriNetVideoApp: App { + @UIApplicationDelegateAdaptor(TriNetAppDelegate.self) private var appDelegate + @StateObject private var viewModel = StreamViewModel() + // Tee stderr into the in-app log before anything else runs, so the very // first audio/transport line is captured. init() { LogBus.shared.start() } var body: some Scene { WindowGroup { - HomeView() + HomeView(vm: viewModel) .preferredColorScheme(.dark) + .onAppear { + CallKitCoordinator.shared.attach(viewModel: viewModel) + } } } } diff --git a/phone/TriNetVideo/CallKitCoordinator.swift b/phone/TriNetVideo/CallKitCoordinator.swift new file mode 100644 index 00000000..b0361f25 --- /dev/null +++ b/phone/TriNetVideo/CallKitCoordinator.swift @@ -0,0 +1,188 @@ +import CallKit +import Foundation +import PushKit +import UIKit + +final class TriNetAppDelegate: NSObject, UIApplicationDelegate { + func application(_ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { + CallKitCoordinator.shared.startPushRegistry() + return true + } +} + +final class CallKitCoordinator: NSObject, CXProviderDelegate, PKPushRegistryDelegate { + static let shared = CallKitCoordinator() + + private let provider: CXProvider + private let callController = CXCallController() + private var callIDs: [UUID: String] = [:] + private var activeCallUUID: UUID? + private weak var viewModel: StreamViewModel? + private var pushRegistry: PKPushRegistry? + + private override init() { + let configuration = CXProviderConfiguration() + configuration.supportsVideo = true + configuration.supportedHandleTypes = [.generic] + configuration.maximumCallsPerCallGroup = 1 + configuration.maximumCallGroups = 1 + provider = CXProvider(configuration: configuration) + super.init() + provider.setDelegate(self, queue: nil) + } + + func attach(viewModel: StreamViewModel) { + self.viewModel = viewModel + if let token = UserDefaults.standard.string(forKey: "voipPushToken"), !token.isEmpty { + Task { try? await viewModel.internet.registerDevice(voipToken: token) } + } + } + + func startPushRegistry() { + DispatchQueue.main.async { + guard self.pushRegistry == nil else { return } + let registry = PKPushRegistry(queue: .main) + registry.delegate = self + registry.desiredPushTypes = [.voIP] + self.pushRegistry = registry + } + } + + func startOutgoing(handle: String, video: Bool) -> UUID { + // A failed or interrupted CallKit transaction can leave our single call + // group occupied. Close only the call owned by this provider before a + // new foreground attempt; otherwise CallKit rejects the next request + // with maximumCallGroupsReached while WebRTC continues independently. + if let staleUUID = activeCallUUID { + provider.reportCall(with: staleUUID, endedAt: Date(), reason: .failed) + callIDs.removeValue(forKey: staleUUID) + activeCallUUID = nil + } + let uuid = UUID() + activeCallUUID = uuid + let action = CXStartCallAction(call: uuid, handle: CXHandle(type: .generic, value: handle)) + action.isVideo = video + callController.request(CXTransaction(action: action)) { error in + guard let error else { return } + NSLog("TRINET: CallKit start failed: %@", error.localizedDescription) + DispatchQueue.main.async { + if self.activeCallUUID == uuid { + self.provider.reportCall(with: uuid, endedAt: Date(), reason: .failed) + self.activeCallUUID = nil + } + } + } + provider.reportOutgoingCall(with: uuid, startedConnectingAt: Date()) + return uuid + } + + func markOutgoingConnected(_ uuid: UUID) { + guard activeCallUUID == uuid else { return } + provider.reportOutgoingCall(with: uuid, connectedAt: Date()) + } + + func reportIncoming(callID: String, caller: String, video: Bool, uuid: UUID = UUID(), completion: (() -> Void)? = nil) { + if callIDs.contains(where: { $0.value == callID }) { + completion?() + return + } + callIDs[uuid] = callID + activeCallUUID = uuid + let update = CXCallUpdate() + update.remoteHandle = CXHandle(type: .generic, value: caller) + update.localizedCallerName = caller + update.hasVideo = video + provider.reportNewIncomingCall(with: uuid, update: update) { error in + if let error { + NSLog("TRINET: incoming CallKit report failed: %@", error.localizedDescription) + DispatchQueue.main.async { + self.callIDs.removeValue(forKey: uuid) + if self.activeCallUUID == uuid { self.activeCallUUID = nil } + } + } + completion?() + } + } + + func end(_ uuid: UUID) { + if activeCallUUID == uuid { activeCallUUID = nil } + callController.request(CXTransaction(action: CXEndCallAction(call: uuid))) { error in + if let error { + NSLog("TRINET: CallKit end failed: %@", error.localizedDescription) + self.provider.reportCall(with: uuid, endedAt: Date(), reason: .remoteEnded) + } + } + } + + func endCurrent() { + guard let uuid = activeCallUUID else { return } + end(uuid) + } + + func pushRegistry(_ registry: PKPushRegistry, + didUpdate pushCredentials: PKPushCredentials, + for type: PKPushType) { + guard type == .voIP else { return } + let token = pushCredentials.token.map { String(format: "%02x", $0) }.joined() + UserDefaults.standard.set(token, forKey: "voipPushToken") + if let viewModel { + Task { try? await viewModel.internet.registerDevice(voipToken: token) } + } + } + + func pushRegistry(_ registry: PKPushRegistry, + didInvalidatePushTokenFor type: PKPushType) { + guard type == .voIP else { return } + UserDefaults.standard.removeObject(forKey: "voipPushToken") + if let viewModel { + Task { try? await viewModel.internet.registerDevice(voipToken: nil) } + } + } + + func pushRegistry(_ registry: PKPushRegistry, + didReceiveIncomingPushWith payload: PKPushPayload, + for type: PKPushType, + completion: @escaping () -> Void) { + guard type == .voIP else { + completion() + return + } + let values = payload.dictionaryPayload + let callID = values["call_id"] as? String ?? UUID().uuidString.lowercased() + let caller = values["caller_name"] as? String ?? values["caller"] as? String ?? "TRI-NET caller" + let uuid = (values["call_uuid"] as? String).flatMap(UUID.init(uuidString:)) ?? UUID() + reportIncoming(callID: callID, + caller: caller, + video: values["video"] as? Bool ?? true, + uuid: uuid, + completion: completion) + } + + func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) { + guard let callID = callIDs[action.callUUID], let viewModel else { + action.fail() + return + } + viewModel.answerInternetCall(callID: callID) + action.fulfill() + } + + func provider(_ provider: CXProvider, perform action: CXStartCallAction) { + provider.reportOutgoingCall(with: action.callUUID, startedConnectingAt: Date()) + action.fulfill() + } + + func provider(_ provider: CXProvider, perform action: CXEndCallAction) { + callIDs.removeValue(forKey: action.callUUID) + if activeCallUUID == action.callUUID { activeCallUUID = nil } + viewModel?.stopCall() + action.fulfill() + } + + func providerDidReset(_ provider: CXProvider) { + callIDs.removeAll() + activeCallUUID = nil + viewModel?.stopCall() + } +} diff --git a/phone/TriNetVideo/Info.plist b/phone/TriNetVideo/Info.plist index c1fbda34..151784df 100644 --- a/phone/TriNetVideo/Info.plist +++ b/phone/TriNetVideo/Info.plist @@ -22,14 +22,34 @@ 1 NSBonjourServices + _trinet-call._udp _trinet._udp + NSAppTransportSecurity + + NSAllowsLocalNetworking + + NSCameraUsageDescription - TRI-NET needs camera to stream video over mesh radio + TRI-NET needs camera for secure local and internet video calls NSLocalNetworkUsageDescription - Find TRI-NET people on your local network by name instead of typing IPs + TRI-NET discovers signed contacts and connects over local UDP or a routed radio mesh NSMicrophoneUsageDescription TRI-NET needs microphone for video calls + TRINET_API_BASE_URL + http://SSDs-MacBook-Pro.local:8080 + TRINET_DEVELOPMENT_ROOM_TOKEN + + TRINET_LIVEKIT_URL + + TRINET_SERVICE_ACCESS_TOKEN + + UIBackgroundModes + + audio + voip + remote-notification + UILaunchScreen UIColorName diff --git a/phone/TriNetVideo/TriNetVideo.entitlements b/phone/TriNetVideo/TriNetVideo.entitlements new file mode 100644 index 00000000..2cf02109 --- /dev/null +++ b/phone/TriNetVideo/TriNetVideo.entitlements @@ -0,0 +1,8 @@ + + + + + aps-environment + development + + diff --git a/phone/TriNetVideo/VideoPipeline.swift b/phone/TriNetVideo/VideoPipeline.swift index 57fb8216..de328820 100644 --- a/phone/TriNetVideo/VideoPipeline.swift +++ b/phone/TriNetVideo/VideoPipeline.swift @@ -15,6 +15,9 @@ class CameraController: NSObject, AVCaptureVideoDataOutputSampleBufferDelegate { let session = AVCaptureSession() private let output = AVCaptureVideoDataOutput() private var encoder: H264Encoder? + private var rotationCoordinator: AnyObject? + private var rotationObservation: NSKeyValueObservation? + private var appliedRotationAngle: CGFloat? var onFrame: ((Data, Bool) -> Void)? var previewSession: AVCaptureSession { session } @@ -26,7 +29,6 @@ class CameraController: NSObject, AVCaptureVideoDataOutputSampleBufferDelegate { private var position: AVCaptureDevice.Position = .front private var currentDevice: AVCaptureDevice? - private var rotCoord: Any? // AVCaptureDevice.RotationCoordinator (iOS 17+); kept alive for KVO-free reads private func setupSession() { session.beginConfiguration() @@ -47,7 +49,7 @@ class CameraController: NSObject, AVCaptureVideoDataOutputSampleBufferDelegate { output.setSampleBufferDelegate(self, queue: DispatchQueue(label: "camera")) if session.canAddOutput(output) { session.addOutput(output) } session.commitConfiguration() - applyOrientation() + if let currentDevice { configureOrientation(for: currentDevice) } } func switchCamera() { @@ -62,56 +64,99 @@ class CameraController: NSObject, AVCaptureVideoDataOutputSampleBufferDelegate { configureDevice(cam) } session.commitConfiguration() - // input swap re-creates the output connection — orientation must be re-applied - applyOrientation() + // An input swap re-creates the output connection and the front and back + // cameras can require different physical buffer rotations. + if let currentDevice { configureOrientation(for: currentDevice) } // restart the encoder so its lazy setup matches the new camera's frames - if encoder != nil { - let enc = H264Encoder() - enc.onFrame = { [weak self] data, isKey in self?.onFrame?(data, isKey) } - enc.meshMode = meshMode - encoder = enc + replaceEncoderIfRunning() + } + + // Raw H.264 carries no orientation metadata, so the outgoing pixel buffers + // must be physically upright before VideoToolbox encodes them. A hardcoded + // 90 degrees is wrong for some front cameras. The system coordinator knows + // the correct angle for the active camera and physical device orientation. + private func configureOrientation(for camera: AVCaptureDevice) { + rotationObservation?.invalidate() + rotationObservation = nil + rotationCoordinator = nil + appliedRotationAngle = nil + + if #available(iOS 17.0, *) { + let coordinator = AVCaptureDevice.RotationCoordinator(device: camera, previewLayer: nil) + rotationCoordinator = coordinator + rotationObservation = coordinator.observe( + \.videoRotationAngleForHorizonLevelCapture, + options: [.initial, .new] + ) { [weak self] coordinator, _ in + self?.applyRotation(coordinator.videoRotationAngleForHorizonLevelCapture) + } + } else { + guard let connection = output.connection(with: .video), + connection.isVideoOrientationSupported else { + NSLog("TRINET: camera orientation is unsupported") + return + } + connection.videoOrientation = .portrait + NSLog("TRINET: camera capture orientation portrait") } } - // Lock the capture frame rate (min==max kills capture-interval jitter -> steadier video) and use - // continuous auto-exposure. Bracketed in lock/unlockForConfiguration as AVFoundation requires. - private func configureDevice(_ cam: AVCaptureDevice) { - guard (try? cam.lockForConfiguration()) != nil else { return } + // Lock the capture frame rate and keep exposure stable enough for the low-latency encoder. + private func configureDevice(_ camera: AVCaptureDevice) { + guard (try? camera.lockForConfiguration()) != nil else { return } let fps: Int32 = 24 - if cam.activeFormat.videoSupportedFrameRateRanges.contains(where: { $0.minFrameRate <= Double(fps) && Double(fps) <= $0.maxFrameRate }) { - let d = CMTime(value: 1, timescale: fps) - cam.activeVideoMinFrameDuration = d - cam.activeVideoMaxFrameDuration = d // min==max => hard-locked FPS - } - if cam.isExposureModeSupported(.continuousAutoExposure) { cam.exposureMode = .continuousAutoExposure } - if cam.isLowLightBoostSupported { cam.automaticallyEnablesLowLightBoostWhenAvailable = true } - cam.unlockForConfiguration() - } - - // Raw H.264 carries no orientation metadata, so the frame must be upright at CAPTURE. The data - // output delivers sensor-native LANDSCAPE buffers, so without this the (portrait) front camera is - // 90 deg off. Apple's RotationCoordinator is the gravity-aware source of truth for the correct - // angle PER CAMERA (front vs back differ) -- more robust than a hardcoded 90. Front camera is sent - // UN-mirrored so the remote reads text correctly. Also enable .standard stabilization (not - // cinematic -- that adds latency) to calm handheld shake. - private func applyOrientation() { - guard let conn = output.connection(with: .video) else { return } - if conn.isVideoMirroringSupported { - conn.automaticallyAdjustsVideoMirroring = false - conn.isVideoMirrored = false - } - if #available(iOS 17.0, *), let dev = currentDevice { - let rc = AVCaptureDevice.RotationCoordinator(device: dev, previewLayer: nil) - rotCoord = rc // keep alive - let angle = rc.videoRotationAngleForHorizonLevelCapture - if conn.isVideoRotationAngleSupported(angle) { conn.videoRotationAngle = angle } - else if conn.isVideoRotationAngleSupported(90) { conn.videoRotationAngle = 90 } - } else if conn.isVideoOrientationSupported { - conn.videoOrientation = .portrait - } - if let dev = currentDevice, dev.activeFormat.isVideoStabilizationModeSupported(.standard) { - conn.preferredVideoStabilizationMode = .standard // .standard not .cinematic (cinematic adds latency) + if camera.activeFormat.videoSupportedFrameRateRanges.contains(where: { + $0.minFrameRate <= Double(fps) && Double(fps) <= $0.maxFrameRate + }) { + let duration = CMTime(value: 1, timescale: fps) + camera.activeVideoMinFrameDuration = duration + camera.activeVideoMaxFrameDuration = duration + } + if camera.isExposureModeSupported(.continuousAutoExposure) { + camera.exposureMode = .continuousAutoExposure + } + if camera.isLowLightBoostSupported { + camera.automaticallyEnablesLowLightBoostWhenAvailable = true + } + camera.unlockForConfiguration() + } + + @available(iOS 17.0, *) + private func applyRotation(_ angle: CGFloat) { + guard let connection = output.connection(with: .video) else { + NSLog("TRINET: camera video connection unavailable for rotation") + return + } + guard connection.isVideoRotationAngleSupported(angle) else { + NSLog("TRINET: camera rotation angle \(Int(angle)) is unsupported") + return + } + guard appliedRotationAngle != angle else { return } + if connection.isVideoMirroringSupported { + connection.automaticallyAdjustsVideoMirroring = false + connection.isVideoMirrored = false } + connection.videoRotationAngle = angle + if let currentDevice, + currentDevice.activeFormat.isVideoStabilizationModeSupported(.standard) { + connection.preferredVideoStabilizationMode = .standard + } + appliedRotationAngle = angle + let cameraName = position == .front ? "front" : "back" + NSLog("TRINET: \(cameraName) camera capture rotation \(Int(angle)) degrees") + encoder?.forceKeyframe() + } + + private func replaceEncoderIfRunning() { + guard encoder != nil else { return } + let enc = H264Encoder() + enc.onFrame = { [weak self] data, isKey in self?.onFrame?(data, isKey) } + enc.meshMode = meshMode + encoder = enc + } + + deinit { + rotationObservation?.invalidate() } func start() { @@ -612,7 +657,9 @@ class BSDTransport { // timer scheduled on it would never fire. private let hsQueue = DispatchQueue(label: "mesh.hs", qos: .userInitiated) var onData: ((Data) -> Void)? + var onSecureSessionReady: (() -> Void)? var isReady = false + private var secureReadyEmitted = false // Conference (group) mode: >1 peers share ONE static conference key (HKDF of the PSK), full-mesh, // NO pairwise handshake -- mirrors the Mac (MeshTransport). recvfrom routes by SOURCE IP so each // sender decodes into its own tile. Kept ISOLATED from the working 1-1 path (own key, own reassembly). @@ -700,6 +747,8 @@ class BSDTransport { func connect(host: String, port: UInt16, recvPort: UInt16) { disconnect() + crypto = MeshCrypto() + secureReadyEmitted = false startFeedbackListener() fd = socket(AF_INET, SOCK_DGRAM, 0) @@ -816,6 +865,7 @@ class BSDTransport { } if self.crypto.isHandshake(pkt) { self.crypto.consumeHandshake(pkt) + self.emitSecureReadyIfNeeded() self.rawSendWire(self.crypto.handshakePacket()) continue } @@ -883,9 +933,15 @@ class BSDTransport { // MARK: forward-secret session (see MeshCrypto). Data is sealed under a // per-connection ephemeral session key; the static PSK only authenticates // the handshake, so a later PSK leak can't decrypt recorded traffic. - private let crypto = MeshCrypto() + private var crypto = MeshCrypto() private var handshakeTimer: DispatchSourceTimer? + private func emitSecureReadyIfNeeded() { + guard crypto.established, !secureReadyEmitted else { return } + secureReadyEmitted = true + DispatchQueue.main.async { self.onSecureSessionReady?() } + } + // MARK: application-level fragmentation // UDP datagrams are capped (~9KB default on Apple platforms) and anything // over the WiFi MTU relies on lossy IP fragmentation, so large NALs diff --git a/phone/TriNetVideo/ViewModel.swift b/phone/TriNetVideo/ViewModel.swift index edb83700..745b8d26 100644 --- a/phone/TriNetVideo/ViewModel.swift +++ b/phone/TriNetVideo/ViewModel.swift @@ -51,6 +51,13 @@ struct RecFile: Identifiable { class StreamViewModel: ObservableObject { @Published var phase: CallPhase = .idle @Published var remoteIP: String = UserDefaults.standard.string(forKey: "remoteIP") ?? "192.168.1.105" + @Published var callee: String = UserDefaults.standard.string(forKey: "internetCallee") ?? "ssd26" + @Published var route: CallRoute = CallRoute(rawValue: UserDefaults.standard.string(forKey: "callRoute") ?? "Auto") ?? .automatic + @Published private(set) var activeRoute: CallRoute? + @Published var callError: String? + @Published var identity: DeviceIdentity + @Published var internetConfiguration: InternetCallConfiguration + @Published var incomingMeshCall: IncomingMeshCall? @Published var myIP: String = "" @Published var framesSent: Int = 0 @Published var framesReceived: Int = 0 @@ -63,6 +70,21 @@ class StreamViewModel: ObservableObject { var chatOpen = false { didSet { if chatOpen { unreadChat = 0 } } } // panel open => clear the badge private let chatChime = ChatChime() // Trinity-style blip on an incoming chat message @Published var recentIPs: [String] = [] + /// Saved nicknames the user has added for one-tap calling. Persisted across + /// launches so "I added @bob" stays. Tapping a contact calls them on the + /// currently-selected route (Internet by default; switch to Mesh in the + /// Connection disclosure to use the local-network path). + @Published var savedContacts: [String] = UserDefaults.standard.stringArray(forKey: "savedContacts") ?? [] { + didSet { UserDefaults.standard.set(savedContacts, forKey: "savedContacts") } + } + func addContact(_ raw: String) { + let nick = NicknamePolicy.normalize(raw) + guard nick.count >= 3, !savedContacts.contains(nick) else { return } + savedContacts.append(nick) + } + func removeContact(_ nick: String) { + savedContacts.removeAll { $0 == nick } + } // Live audio levels (0...1) for the TX/RX meters, peak-held with decay. @Published var txLevel: Float = 0 @Published var rxLevel: Float = 0 @@ -172,12 +194,22 @@ class StreamViewModel: ObservableObject { func sendChat(_ text: String) { let t = text.trimmingCharacters(in: .whitespacesAndNewlines) guard !t.isEmpty else { return } + if activeRoute == .internet { + internet.sendChat(t) + chat.append(ChatLine(who: .me, text: t)) + return + } var d = Data([0xFB, 0xCA]); d.append(Data(t.utf8)) transport.send(d) chat.append(ChatLine(who: .me, text: t)) } func sendReaction(_ emoji: String) { + if activeRoute == .internet { + internet.sendReaction(emoji) + showReaction(emoji) + return + } var d = Data([0xFE, 0xAC]); d.append(Data(emoji.utf8)) transport.send(d) showReaction(emoji) @@ -200,6 +232,10 @@ class StreamViewModel: ObservableObject { let transport = BSDTransport() let decoder = H264Decoder() let audio = AudioController() + let internet: InternetCallController + let directory: NicknameDirectoryController + let account: AccountDeviceController + let groupChat: GroupChatController // Group call: enter several IPs (comma/space separated) -> full-mesh conference. Each remote // sender decodes into its OWN tile (per-source decoder), so 2 iPhones + a Mac = a 3-way group. @@ -253,21 +289,119 @@ class StreamViewModel: ObservableObject { private var bytesSent = 0 private var bytesRecv = 0 private var timer: Timer? + private var callKitUUID: UUID? + private var meshAttemptID: UUID? init() { + let loadedIdentity: DeviceIdentity + do { + loadedIdentity = try DeviceIdentityStore.shared.loadOrCreate(defaultName: "ssd26") + } catch { + loadedIdentity = DeviceIdentity(userID: UUID().uuidString.lowercased(), + deviceID: UUID().uuidString.lowercased(), + displayName: "ssd26", + nickname: nil, + signingPublicKey: "", + keyFingerprint: "unavailable") + } + let loadedConfiguration = InternetCallConfiguration.load() + identity = loadedIdentity + internetConfiguration = loadedConfiguration + internet = InternetCallController(identity: loadedIdentity, configuration: loadedConfiguration) + directory = NicknameDirectoryController(identity: loadedIdentity, configuration: loadedConfiguration) + account = AccountDeviceController(identity: loadedIdentity, configuration: loadedConfiguration) + groupChat = GroupChatController(identity: loadedIdentity, configuration: loadedConfiguration) + // Chime on a newly-arrived group message authored by someone else. + groupChat.onNewMessage = { [weak self] _ in + DispatchQueue.main.async { self?.chatChime.play() } + } myIP = getLocalIP() if let saved = UserDefaults.standard.array(forKey: "recentCallIPs") as? [String] { recentIPs = saved } + internet.onChat = { [weak self] text in + self?.chat.append(ChatLine(who: .them, text: text)) + } + internet.onReaction = { [weak self] value in + self?.showReaction(value) + } + internet.onIncomingCall = { [weak self] incoming in + guard let self, self.phase == .idle, self.incomingCall == nil else { return } + // Report to CallKit (system call UI + VoIP push path) AND show the + // in-app overlay with the custom tri-tone ringtone. Previously only + // mesh calls got the in-app ring; internet calls were silent in-app. + CallKitCoordinator.shared.reportIncoming(callID: incoming.callID, + caller: incoming.caller, + video: incoming.video) + self.incomingCall = IncomingCall(name: incoming.caller, + ip: "", + participants: [], + internetCallID: incoming.callID) + self.beginIncomingTimeout() + } + directory.onIdentityChanged = { [weak self] updatedIdentity in + guard let self else { return } + self.identity = updatedIdentity + self.internet.update(identity: updatedIdentity, configuration: self.internetConfiguration) + self.account.update(identity: updatedIdentity, configuration: self.internetConfiguration) + self.groupChat.update(identity: updatedIdentity, configuration: self.internetConfiguration) + self.internet.startIncomingPolling(voipToken: UserDefaults.standard.string(forKey: "voipPushToken")) + self.account.sync() + } + account.onIdentityChanged = { [weak self] updatedIdentity in + guard let self else { return } + self.identity = updatedIdentity + self.internet.update(identity: updatedIdentity, configuration: self.internetConfiguration) + self.directory.update(identity: updatedIdentity, configuration: self.internetConfiguration) + self.groupChat.update(identity: updatedIdentity, configuration: self.internetConfiguration) + } + directory.onIncomingMeshInvite = { [weak self] invite, address in + guard let self, self.phase == .idle else { return } + self.incomingMeshCall = IncomingMeshCall(invite: invite, sourceAddress: address) + } + internet.startIncomingPolling(voipToken: UserDefaults.standard.string(forKey: "voipPushToken")) + account.sync() + groupChat.startPolling() discovery.start() // advertise + browse from launch - startIdleListener() // listen on :7000 for incoming calls while idle + startIdleListener() // listen on :7000 for incoming mesh calls while idle + } + + func saveInternetSettings() { + internetConfiguration.save() + UserDefaults.standard.set(route.rawValue, forKey: "callRoute") + internet.update(identity: identity, configuration: internetConfiguration) + directory.update(identity: identity, configuration: internetConfiguration) + account.update(identity: identity, configuration: internetConfiguration) + groupChat.update(identity: identity, configuration: internetConfiguration) + internet.startIncomingPolling(voipToken: UserDefaults.standard.string(forKey: "voipPushToken")) + account.sync() + } + + func renameDevice(_ name: String) { + do { + identity = try DeviceIdentityStore.shared.rename(name) + internet.update(identity: identity, configuration: internetConfiguration) + directory.update(identity: identity, configuration: internetConfiguration) + account.update(identity: identity, configuration: internetConfiguration) + groupChat.update(identity: identity, configuration: internetConfiguration) + } catch { + callError = error.localizedDescription + } } // MARK: - Incoming call ("take the call") // While idle we hold a light listener on :7000; a caller sends a tiny plaintext INVITE there and we // pop the full-screen ringing sheet. Torn down when a call starts (the encrypted transport owns :7000), // restarted when it ends. - struct IncomingCall: Equatable { let name: String; let ip: String; let participants: [String] } + // internetCallID is set when the incoming call arrived over the Internet + // path (LiveKit) rather than mesh UDP; nil means it's a mesh call. The + // overlay's Accept button branches on this so one UI serves both routes. + struct IncomingCall: Equatable { + let name: String + let ip: String + let participants: [String] + var internetCallID: String? = nil + } @Published var incomingCall: IncomingCall? // Missed calls: an incoming that timed out unanswered (NOT a decline — that was a choice). Newest first. // Persisted across restarts so you don't lose "who called while I was away". @@ -540,6 +674,11 @@ class StreamViewModel: ObservableObject { // -- reject it so any LAN host can't pop the incoming-call UI (and block real INVITEs for 40s). guard !participants.isEmpty else { continue } let room = parts.count > 2 ? parts[2] : "" + // ANTI-REPLAY: reject a stale (or timestamp-less) INVITE. A valid HMAC only proves the sender + // knew the PSK once; the freshness window stops a captured INVITE from being replayed later. + let tsMs = parts.count > 3 ? (Int64(parts[3]) ?? 0) : 0 + let nowMs = Int64(Date().timeIntervalSince1970 * 1000) + guard tsMs != 0, abs(nowMs - tsMs) <= 15_000 else { continue } let ip = String(cString: inet_ntoa(from.sin_addr)) DispatchQueue.main.async { guard let self = self, self.phase == .idle, self.incomingCall == nil else { return } // don't ring mid-call / twice @@ -553,16 +692,7 @@ class StreamViewModel: ObservableObject { return } NSLog("TRINET: INCOMING call from \(name) (\(ip))") - self.incomingTimer?.invalidate() - self.incomingTimer = Timer.scheduledTimer(withTimeInterval: 40, repeats: false) { [weak self] _ in - guard let self = self else { return } - if let m = self.incomingCall { // auto-miss after 40s -> log it for one-tap call-back - self.missedCalls.insert(MissedCall(name: m.name, ip: m.ip, at: Date()), at: 0) - if self.missedCalls.count > 5 { self.missedCalls.removeLast() } - NSLog("TRINET: MISSED call from \(m.name) (\(m.ip))") - } - self.incomingCall = nil - } + self.beginIncomingTimeout() } } } @@ -573,8 +703,10 @@ class StreamViewModel: ObservableObject { // Caller side: ring each target's :7000 a few times (UDP is lossy) from a throwaway socket. // `participants` = every IP in this call (including me), so the callee can rejoin the FULL mesh. func sendInvite(to ips: [String], participants: [String]) { - // payload = "name\nip1,ip2\nROOM" — the room lets a same-room callee auto-accept (one-tap group). - let payload = PeerDiscovery.myName + "\n" + participants.joined(separator: ",") + "\n" + PeerDiscovery.myRoom + // payload = "name\nip1,ip2\nROOM\nTS_MS" — TS_MS is a freshness timestamp so a sniffed-and-replayed + // INVITE (even with a valid HMAC) is rejected as stale. The HMAC covers TS too, so it can't be rewritten. + let tsMs = Int64(Date().timeIntervalSince1970 * 1000) + let payload = PeerDiscovery.myName + "\n" + participants.joined(separator: ",") + "\n" + PeerDiscovery.myRoom + "\n" + String(tsMs) NSLog("TRINET: ringing \(ips.joined(separator: ",")) with INVITE (participants: \(participants.joined(separator: ",")))") // MUST NOT use idleQueue: startCall() just closed the idle socket, but a blocked recvfrom on that // serial queue may not wake (POSIX close() doesn't reliably interrupt it), which would leave the @@ -609,8 +741,16 @@ class StreamViewModel: ObservableObject { func acceptIncoming() { guard let inc = incomingCall else { return } incomingTimer?.invalidate(); incomingCall = nil - // Rebuild the exact call: caller + every other participant, minus myself. For a 1-1 invite the - // participant list is just {caller, me}, so this collapses to a plain 1-1 back to the caller. + // Internet (LiveKit) call: answer via the call-id we were handed. + if let callID = inc.internetCallID { + NSLog("TRINET: accepting INTERNET call \(callID) from \(inc.name)") + callee = inc.name + activeRoute = .internet + answerInternetCall(callID: callID) + return + } + // Mesh call: rebuild the exact call — caller + every other participant, minus myself. For a 1-1 + // invite the participant list is just {caller, me}, so this collapses to a plain 1-1 back to the caller. var mesh = Set(inc.participants); mesh.insert(inc.ip); mesh.remove(myIP) let hosts = mesh.filter { !$0.isEmpty }.sorted() remoteIP = hosts.isEmpty ? inc.ip : hosts.joined(separator: ",") @@ -619,6 +759,21 @@ class StreamViewModel: ObservableObject { } func declineIncoming() { incomingTimer?.invalidate(); incomingCall = nil } + /// Auto-miss after 40s: log the unanswered call so the user has a one-tap + /// call-back. Shared by mesh and internet incoming paths. + func beginIncomingTimeout() { + incomingTimer?.invalidate() + incomingTimer = Timer.scheduledTimer(withTimeInterval: 40, repeats: false) { [weak self] _ in + guard let self = self else { return } + if let m = self.incomingCall { + self.missedCalls.insert(MissedCall(name: m.name, ip: m.ip, at: Date()), at: 0) + if self.missedCalls.count > 5 { self.missedCalls.removeLast() } + NSLog("TRINET: MISSED call from \(m.name)") + } + self.incomingCall = nil + } + } + func checkPermission() { let s = AVCaptureDevice.authorizationStatus(for: .video) cameraAuthorized = (s == .authorized) @@ -630,6 +785,143 @@ class StreamViewModel: ObservableObject { } func startCall() { + callError = nil + let typedTarget = directory.searchQuery.trimmingCharacters(in: .whitespacesAndNewlines) + let target = NicknamePolicy.normalize(typedTarget.isEmpty ? callee : typedTarget) + callee = target + let meshContact = directory.meshContact(named: target) + let selected: CallRoute + switch route { + case .automatic: + if isMeshAddress(target) { + remoteIP = target + selected = .mesh + } else if let meshContact, let address = meshContact.meshAddress { + remoteIP = address + selected = .mesh + } else { + selected = .internet + } + case .mesh, .internet: + selected = route + } + if selected == .mesh { + if isMeshAddress(target) { + remoteIP = target + } else if let address = meshContact?.meshAddress { + remoteIP = address + } else { + callError = "@\(target) is not visible in the current mesh." + activeRoute = nil + return + } + } + activeRoute = selected + UserDefaults.standard.set(route.rawValue, forKey: "callRoute") + if selected == .internet { + startInternetCall() + } else { + do { + _ = try directory.sendMeshInvite(to: remoteIP, port: meshContact?.meshPort) + } catch { + callError = error.localizedDescription + activeRoute = nil + return + } + startMeshCall() + } + } + + func acceptIncomingMeshCall() { + guard let incoming = incomingMeshCall else { return } + incomingMeshCall = nil + callee = incoming.invite.nickname + remoteIP = incoming.sourceAddress + activeRoute = .mesh + startMeshCall() + } + + func declineIncomingMeshCall() { + incomingMeshCall = nil + } + + func claimNickname() { + directory.claimProposedNickname() + } + + func searchNicknames() { + let target = NicknamePolicy.normalize(directory.searchQuery) + if !target.isEmpty { callee = target } + directory.search() + } + + /// One-tap call to a saved nickname. Sets callee + searchQuery so startCall() + /// targets the right person, then starts the call on the selected route. + func callNickname(_ nick: String) { + callee = nick + directory.searchQuery = nick + startCall() + } + + func selectContact(_ contact: DirectoryContact) { + callee = contact.nickname + if let address = contact.meshAddress { + remoteIP = address + } + route = .automatic + } + + private func startInternetCall() { + let target = callee.trimmingCharacters(in: .whitespacesAndNewlines) + guard !target.isEmpty else { + callError = "Enter a contact or device name." + activeRoute = nil + return + } + UserDefaults.standard.set(target, forKey: "internetCallee") + internet.update(identity: identity, configuration: internetConfiguration) + callKitUUID = CallKitCoordinator.shared.startOutgoing(handle: target, video: true) + phase = .connecting + Task { [weak self] in + guard let self else { return } + do { + try await self.internet.start(callee: target, audio: true, video: true) + await MainActor.run { + self.phase = .live + if let uuid = self.callKitUUID { CallKitCoordinator.shared.markOutgoingConnected(uuid) } + } + } catch { + await MainActor.run { + if let uuid = self.callKitUUID { CallKitCoordinator.shared.end(uuid) } + self.callKitUUID = nil + self.callError = error.localizedDescription + self.phase = .idle + self.activeRoute = nil + } + } + } + } + + func answerInternetCall(callID: String) { + activeRoute = .internet + phase = .connecting + internet.update(identity: identity, configuration: internetConfiguration) + Task { [weak self] in + guard let self else { return } + do { + try await self.internet.join(callID: callID, audio: true, video: true) + await MainActor.run { self.phase = .live } + } catch { + await MainActor.run { + self.callError = error.localizedDescription + self.phase = .idle + self.activeRoute = nil + } + } + } + } + + private func startMeshCall() { UserDefaults.standard.set(remoteIP, forKey: "remoteIP") if !recentIPs.contains(remoteIP) { recentIPs.insert(remoteIP, at: 0) @@ -656,8 +948,15 @@ class StreamViewModel: ObservableObject { sendInvite(to: hosts, participants: [myIP] + hosts) // ring the callee(s); carry the full roster if hosts.count > 1 { isGroup = true; startGroupCall(hosts: hosts); return } isGroup = false + let attemptID = UUID() + meshAttemptID = attemptID // UDP: send to remoteIP:7000, listen on 7000 (same port for both) + transport.onSecureSessionReady = { [weak self] in + guard let self, self.meshAttemptID == attemptID else { return } + self.meshAttemptID = nil + self.phase = .live + } transport.connect(host: remoteIP, port: 7000, recvPort: 7000) startBWE() @@ -768,9 +1067,10 @@ class StreamViewModel: ObservableObject { startABR() - // Fallback: go live after 2s even without remote video - DispatchQueue.main.asyncAfter(deadline: .now() + 2) { - if self.phase == .connecting { self.phase = .live } + DispatchQueue.main.asyncAfter(deadline: .now() + 30) { [weak self] in + guard let self, self.meshAttemptID == attemptID, self.phase == .connecting else { return } + self.callError = "The local peer did not accept the call within 30 seconds." + self.stopCall() } } @@ -837,6 +1137,16 @@ class StreamViewModel: ObservableObject { } func stopCall() { + if activeRoute == .internet { + internet.disconnect() + CallKitCoordinator.shared.endCurrent() + callKitUUID = nil + phase = .idle + activeRoute = nil + framesSent = 0 + framesReceived = 0 + return + } // Journal a COMPLETED call (frames flowed) with duration + average link quality, before resets. if let started = callStartedAt, framesReceived > 0 || framesSent > 0 { let dur = Int(Date().timeIntervalSince(started)) @@ -861,6 +1171,7 @@ class StreamViewModel: ObservableObject { camera.stopAll() audio.stop() transport.disconnect() + meshAttemptID = nil isGroup = false; roster = []; groupDecoders.removeAll() discovery.inCall = false timer?.invalidate(); timer = nil @@ -869,7 +1180,25 @@ class StreamViewModel: ObservableObject { framesSent = 0; framesReceived = 0 bytesSent = 0; bytesRecv = 0 txKBps = 0; rxKBps = 0 - startIdleListener() // resume listening for incoming calls + activeRoute = nil + startIdleListener() // resume listening for incoming mesh calls + } + + func toggleMute() { + isMuted.toggle() + if activeRoute == .internet { internet.setMuted(isMuted) } + } + + func toggleCamera() { + cameraOff.toggle() + if activeRoute == .internet { internet.setCamera(enabled: !cameraOff) } + } + + private func isMeshAddress(_ value: String) -> Bool { + let address = value.trimmingCharacters(in: .whitespacesAndNewlines) + if address.hasSuffix(".local") { return true } + let parts = address.split(separator: ".") + return parts.count == 4 && parts.allSatisfy { Int($0).map { (0...255).contains($0) } ?? false } } // Get local WiFi IP diff --git a/phone/TriNetVideo/Views.swift b/phone/TriNetVideo/Views.swift index 46582868..e634ebca 100644 --- a/phone/TriNetVideo/Views.swift +++ b/phone/TriNetVideo/Views.swift @@ -1,13 +1,25 @@ // Views.swift — FaceTime-style video call UI for iOS import SwiftUI import AVFoundation +import LiveKit import AudioToolbox // MARK: - Home Screen struct HomeView: View { - @StateObject var vm = StreamViewModel() + @ObservedObject var vm: StreamViewModel + @ObservedObject private var directory: NicknameDirectoryController + @ObservedObject private var groupChat: GroupChatController @State private var showSettings = false + @State private var showNicknameSetup = false + @State private var showGroupChats = false + @State private var newContactNick = "" + + init(vm: StreamViewModel) { + self.vm = vm + directory = vm.directory + groupChat = vm.groupChat + } var body: some View { ZStack { @@ -18,19 +30,63 @@ struct HomeView: View { .transition(.opacity) } else { VStack(spacing: 22) { - HStack { + HStack(spacing: 12) { Text("TRI-NET").font(DS.display(22, .bold)).tracking(1).foregroundColor(DS.text) Spacer() + Button(action: { showGroupChats = true }) { + Image(systemName: groupChat.chats.isEmpty ? "bubble.left.and.bubble.right" : "bubble.left.and.bubble.right.fill") + .font(.system(size: 20)).foregroundColor(DS.dim) + .frame(width: 48, height: 48) + .background(Circle().fill(DS.surface)) + .overlay(Circle().stroke(DS.hairlineStrong, lineWidth: 1)) + .contentShape(Circle()) + .overlay(alignment: .topTrailing) { + if groupChat.totalUnread > 0 { + Text("\(min(groupChat.totalUnread, 99))") + .font(.caption2.weight(.bold)) + .foregroundColor(.white) + .padding(.horizontal, 5).padding(.vertical, 2) + .background(Capsule().fill(Color.red)) + .offset(x: 6, y: -6) + .accessibilityLabel("\(groupChat.totalUnread) unread group messages") + } + } + } + .buttonStyle(.plain) + .accessibilityLabel("Group chats") Button(action: { showSettings = true }) { - Image(systemName: "gearshape").font(.system(size: 18)).foregroundColor(DS.dim) - .frame(width: 42, height: 42).overlay(Circle().stroke(DS.hairlineStrong, lineWidth: 1)) + Image(systemName: "gearshape").font(.system(size: 20)).foregroundColor(DS.dim) + .frame(width: 48, height: 48) + .background(Circle().fill(DS.surface)) + .overlay(Circle().stroke(DS.hairlineStrong, lineWidth: 1)) + .contentShape(Circle()) } + .buttonStyle(.plain) + .accessibilityLabel("Settings") } - .padding(.horizontal, 24) + .padding(.horizontal, 20) - Text("Encrypted mesh · forward-secret") + Text("Encrypted mesh | WebRTC internet") .font(DS.ui(13)).foregroundColor(DS.dim) + Button(action: { showNicknameSetup = true }) { + HStack(spacing: 8) { + Image(systemName: directory.currentNickname == nil ? "person.crop.circle.badge.plus" : "checkmark.seal.fill") + Text(directory.currentNickname.map { "@\($0)" } ?? "Create your nickname") + .font(DS.mono(13, .medium)) + Text(directory.claimKind == .verified ? "VERIFIED" : + directory.claimKind == .meshLocal ? "MESH" : "NEW") + .font(DS.mono(9, .bold)) + .foregroundColor(directory.claimKind == .verified ? DS.live : + directory.claimKind == .meshLocal ? DS.warn : DS.dim) + } + .foregroundColor(DS.text) + .padding(.horizontal, 14).padding(.vertical, 9) + .background(DS.surface, in: Capsule()) + .overlay(Capsule().stroke(DS.hairline, lineWidth: 1)) + } + .buttonStyle(.plain) + Spacer() // Primary call button — the one white CTA @@ -46,32 +102,119 @@ struct HomeView: View { .buttonStyle(.plain) .disabled(!vm.cameraAuthorized) - // Peer field + // ===== Calling flow ===== VStack(spacing: 14) { - HStack { - SectionLabel(text: "Peer") - TextField("Mac IP", text: $vm.remoteIP) - .keyboardType(.decimalPad).font(DS.mono(16)).foregroundColor(DS.text) - .multilineTextAlignment(.center) + // Route: Internet (default) or Local Mesh. Collapsed under + // a disclosure so the common case is one tap fewer. + DisclosureGroup("Connection: \(vm.route.displayName)") { + Picker("Route", selection: $vm.route) { + ForEach(CallRoute.allCases) { route in + Text(route.displayName).tag(route) + } + } + .pickerStyle(.segmented) + .padding(.top, 4) } - .padding(.horizontal, 18).padding(.vertical, 14) + .font(DS.mono(11)).foregroundColor(DS.dim) + + // Add a contact by nickname, then tap them to call. + HStack(spacing: 10) { + Image(systemName: "person.badge.plus").foregroundColor(DS.dim) + TextField("add by @nickname", text: $newContactNick) + .textInputAutocapitalization(.never).autocorrectionDisabled() + .font(DS.mono(16)).foregroundColor(DS.text) + .submitLabel(.done) + .onSubmit { + vm.addContact(newContactNick) + newContactNick = "" + } + Button { + vm.addContact(newContactNick) + newContactNick = "" + } label: { + Text("Add").font(DS.mono(13, .bold)).foregroundColor(.white) + .padding(.horizontal, 14).padding(.vertical, 7) + .background(Capsule().fill(DS.fill)) + } + .buttonStyle(.plain) + .disabled(newContactNick.trimmingCharacters(in: .whitespaces).count < 3) + } + .padding(.horizontal, 16).padding(.vertical, 12) .background(DS.surface, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) .overlay(RoundedRectangle(cornerRadius: 16, style: .continuous).stroke(DS.hairline, lineWidth: 1)) - Text("SELF · \(vm.myIP)").font(DS.mono(12)).foregroundColor(DS.faint) + // Saved contacts: tap the phone button to call on the + // selected route (Internet by default, Mesh if chosen). + if !vm.savedContacts.isEmpty { + VStack(spacing: 8) { + ForEach(vm.savedContacts, id: \.self) { nick in + HStack(spacing: 12) { + Image(systemName: "person.crop.circle.fill") + .font(.system(size: 26)).foregroundColor(DS.live) + Text("@\(nick)").font(DS.mono(15, .medium)).foregroundColor(DS.text) + Spacer() + // Call button — Internet or Mesh per the route above. + Button { + vm.callNickname(nick) + } label: { + Image(systemName: "phone.fill") + .font(.system(size: 14)).foregroundColor(.white) + .frame(width: 40, height: 40) + .background(Circle().fill(DS.live)) + } + .buttonStyle(.plain) + .accessibilityLabel("Call @\(nick)") + Button { + vm.removeContact(nick) + } label: { + Image(systemName: "minus.circle.fill") + .font(.system(size: 16)).foregroundColor(DS.faint) + } + .buttonStyle(.plain) + .accessibilityLabel("Remove @\(nick)") + } + .padding(.horizontal, 14).padding(.vertical, 10) + .background(DS.surface, in: RoundedRectangle(cornerRadius: 14, style: .continuous)) + .overlay(RoundedRectangle(cornerRadius: 14, style: .continuous).stroke(DS.hairline, lineWidth: 1)) + } + } + } - if !vm.recentIPs.isEmpty { - HStack(spacing: 10) { - ForEach(vm.recentIPs.prefix(3), id: \.self) { ip in - Button(action: { vm.remoteIP = ip }) { - Text(ip).font(DS.mono(11)).foregroundColor(DS.dim) - .padding(.horizontal, 12).padding(.vertical, 7) - .overlay(Capsule().stroke(DS.hairline, lineWidth: 1)) + // Directory search: find who's online right now. + HStack { + Image(systemName: "magnifyingglass").foregroundColor(DS.dim) + TextField("search online by @nickname", text: Binding( + get: { vm.directory.searchQuery }, + set: { vm.directory.searchQuery = $0 } + )) + .textInputAutocapitalization(.never).autocorrectionDisabled() + .font(DS.mono(15)).foregroundColor(DS.text) + .onSubmit { vm.searchNicknames() } + Button("Search") { vm.searchNicknames() } + .font(DS.mono(12, .medium)).foregroundColor(DS.text) + } + .padding(.horizontal, 16).padding(.vertical, 12) + .background(DS.surface, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) + .overlay(RoundedRectangle(cornerRadius: 16, style: .continuous).stroke(DS.hairline, lineWidth: 1)) + + if !vm.directory.results.isEmpty { + VStack(spacing: 8) { + ForEach(vm.directory.results.prefix(5)) { contact in + DirectoryContactButton(contact: contact) { + vm.addContact(contact.nickname) + vm.callNickname(contact.nickname) } } } } + Text(directory.currentNickname.map { "You are @\($0)" } ?? "Create your nickname to be callable") + .font(DS.mono(12)).foregroundColor(DS.faint) + + if let error = vm.callError { + Text(error).font(DS.ui(12)).foregroundColor(DS.danger).multilineTextAlignment(.center) + } + iPeerRoster(vm: vm, discovery: vm.discovery) // Missed calls — one-tap call back (newest first, capped at 5). @@ -154,9 +297,111 @@ struct HomeView: View { .sheet(isPresented: $showSettings) { SettingsView(vm: vm) } + .sheet(isPresented: $showNicknameSetup) { + NicknameSetupView(vm: vm) + } + .sheet(isPresented: $showGroupChats) { + GroupChatCenterView(vm: vm) + } .sheet(item: $vm.shareFile) { f in ShareSheet(items: [f.url]) } + .alert(item: $vm.incomingMeshCall) { incoming in + Alert(title: Text("Incoming local call"), + message: Text("@\(incoming.invite.nickname) wants to start an encrypted UDP call."), + primaryButton: .default(Text("Accept"), action: vm.acceptIncomingMeshCall), + secondaryButton: .cancel(Text("Decline"), action: vm.declineIncomingMeshCall)) + } + } +} + +private struct DirectoryContactButton: View { + let contact: DirectoryContact + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: 10) { + Circle().fill(contact.online ? DS.live : DS.faint).frame(width: 7, height: 7) + VStack(alignment: .leading, spacing: 2) { + Text("@\(contact.nickname)").font(DS.mono(13, .medium)).foregroundColor(DS.text) + Text(contact.displayName).font(DS.ui(10)).foregroundColor(DS.faint) + } + Spacer() + Text(contact.source.rawValue).font(DS.mono(9, .bold)) + .foregroundColor(contact.source == .mesh ? DS.warn : DS.live) + } + .padding(.horizontal, 12).padding(.vertical, 9) + .background(DS.surfaceHi, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + } + .buttonStyle(.plain) + } +} + +private struct NicknameSetupView: View { + @ObservedObject var vm: StreamViewModel + @ObservedObject private var directory: NicknameDirectoryController + @Environment(\.dismiss) private var dismiss + + init(vm: StreamViewModel) { + self.vm = vm + directory = vm.directory + } + + var body: some View { + NavigationView { + Form { + Section("Your nickname") { + HStack { + Text("@").foregroundColor(.secondary) + TextField("nickname", text: $directory.proposedNickname) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + } + Text("3-20 characters: lowercase letters, numbers, and underscore. The first character must be a letter.") + .font(.caption).foregroundColor(.secondary) + Button(directory.isWorking ? "Checking..." : "Check and create") { + vm.claimNickname() + } + .disabled(directory.isWorking) + } + + if let message = directory.statusMessage { + Section("Status") { + Text(message) + } + } + + if !directory.suggestions.isEmpty { + Section("Available alternatives") { + ForEach(directory.suggestions, id: \.self) { suggestion in + Button("@\(suggestion)") { + directory.proposedNickname = suggestion + vm.claimNickname() + } + } + } + } + + Section("Verification") { + HRow("Current", directory.currentNickname.map { "@\($0)" } ?? "Not created") + HRow("Scope", directory.claimKind == .verified ? "Global verified" : + directory.claimKind == .meshLocal ? "Mesh local" : "Not registered") + Text("Global uniqueness requires the Directory API. Mesh-local names are checked against currently reachable signed peers.") + .font(.caption).foregroundColor(.secondary) + } + } + .navigationTitle("Nickname") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Done") { dismiss() } + } + } + .onChange(of: directory.currentNickname) { current in + if current != nil { dismiss() } + } + } } } @@ -228,48 +473,71 @@ struct IncomingCallOverlay: View { var body: some View { ZStack { - DS.ink.opacity(0.98).ignoresSafeArea() + // Deep vertical gradient + a live-colored glow that breathes behind the avatar. + LinearGradient(colors: [DS.ink, .black], startPoint: .top, endPoint: .bottom) + .ignoresSafeArea() + RadialGradient(colors: [DS.live.opacity(0.20), .clear], center: .center, startRadius: 8, endRadius: 340) + .ignoresSafeArea() + .scaleEffect(pulse ? 1.12 : 0.92) + VStack(spacing: 0) { + // Signature security badge — this is a TRI-NET encrypted call, not a stock ring. + HStack(spacing: 7) { + Image(systemName: "lock.shield.fill").font(.system(size: 12, weight: .bold)) + Text("E N C R Y P T E D · F O R W A R D - S E C R E T").font(DS.ui(11)) + } + .foregroundColor(DS.live) + .padding(.top, 72) + Spacer() + + // Avatar: three outward-rippling rings + a glowing gradient disc with the caller's initial. ZStack { - // Two expanding rings — "ringing, live now" (Reduce-Motion aware). - Circle().stroke(DS.live.opacity(0.55), lineWidth: 3) - .frame(width: 150, height: 150) - .scaleEffect(pulse ? 1.45 : 1.0).opacity(pulse ? 0 : 0.7) - Circle().stroke(DS.live.opacity(0.30), lineWidth: 2) - .frame(width: 150, height: 150) - .scaleEffect(pulse ? 1.18 : 0.9).opacity(pulse ? 0 : 0.5) - Circle().fill(DS.surfaceHi) - .overlay(Circle().stroke(DS.hairlineStrong, lineWidth: 1)) - .frame(width: 118, height: 118) - Text(initial).font(.system(size: 46, weight: .semibold)).foregroundColor(DS.text) - } - Text(inc.name).font(DS.display(26, .semibold)).foregroundColor(DS.text) - .padding(.top, 26).lineLimit(1) - Text("Incoming call · TRI-NET").font(DS.ui(14)).foregroundColor(DS.dim).padding(.top, 6) - Text(inc.ip).font(DS.mono(12)).foregroundColor(DS.faint).padding(.top, 2) + ForEach(0..<3) { i in + Circle().stroke(DS.live.opacity(0.5 - Double(i) * 0.14), lineWidth: 2) + .frame(width: 168, height: 168) + .scaleEffect(pulse ? 1.1 + Double(i) * 0.24 : 1.0) + .opacity(pulse ? 0 : 0.75) + } + Circle() + .fill(LinearGradient(colors: [DS.live.opacity(0.95), DS.live.opacity(0.45)], + startPoint: .topLeading, endPoint: .bottomTrailing)) + .frame(width: 136, height: 136) + .overlay(Circle().stroke(.white.opacity(0.22), lineWidth: 1)) + .shadow(color: DS.live.opacity(0.55), radius: 34) + Text(initial).font(.system(size: 54, weight: .bold, design: .rounded)).foregroundColor(.white) + } + + // Caller identity — nickname first (that IS the identity), then a soft subtitle + route. + Text(inc.name).font(DS.display(30, .bold)).foregroundColor(DS.text) + .padding(.top, 32).lineLimit(1).minimumScaleFactor(0.7) + Text("is calling you").font(DS.ui(15)).foregroundColor(DS.dim).padding(.top, 6) + Text(inc.ip).font(DS.mono(12)).foregroundColor(DS.faint).padding(.top, 4) + Spacer() - HStack(spacing: 80) { - answerButton(system: "phone.down.fill", label: "Decline", bg: DS.danger) { + + HStack(spacing: 88) { + answerButton(system: "phone.down.fill", label: "Decline", bg: DS.danger, glow: false) { stopRing(); vm.declineIncoming() } - answerButton(system: "phone.fill", label: "Accept", bg: DS.live) { + answerButton(system: "phone.fill", label: "Accept", bg: DS.live, glow: true) { stopRing(); vm.acceptIncoming() } } - .padding(.bottom, 70) + .padding(.bottom, 78) } } .onAppear { startRing() } .onDisappear { stopRing() } } - private func answerButton(system: String, label: String, bg: Color, action: @escaping () -> Void) -> some View { + private func answerButton(system: String, label: String, bg: Color, glow: Bool, action: @escaping () -> Void) -> some View { VStack(spacing: 10) { Button(action: action) { Image(systemName: system).font(.system(size: 30, weight: .semibold)) - .foregroundColor(.white).frame(width: 76, height: 76) + .foregroundColor(.white).frame(width: 78, height: 78) .background(Circle().fill(bg)) + .shadow(color: glow ? bg.opacity(0.6) : .clear, radius: glow ? 22 : 0) } .buttonStyle(.plain) .accessibilityLabel("\(label) call") @@ -482,12 +750,21 @@ struct CallScreen: View { @State private var wantLandscape = false private let reactions = ["👍", "❤️", "😂", "👏", "🔥"] + private var mediaConnected: Bool { + if vm.activeRoute == .internet { + return vm.internet.state == .connected + } + return vm.framesReceived > 0 + } + var body: some View { ZStack { DS.ink.ignoresSafeArea() Group { - if vm.isGroup { + if vm.activeRoute == .internet { + InternetVideoArea(controller: vm.internet, phase: vm.phase, peer: vm.callee) + } else if vm.isGroup { GroupGrid(vm: vm) } else { RemoteVideoArea(decoder: vm.decoder, phase: vm.phase, remoteIP: vm.remoteIP) @@ -531,12 +808,16 @@ struct CallScreen: View { HStack { Spacer() ZStack { - CameraPreviewView(session: vm.camera.previewSession) - if vm.cameraOff { + if vm.activeRoute == .internet, let track = vm.internet.localVideoTrack { + SwiftUIVideoView(track, layoutMode: .fill, mirrorMode: .mirror) + } else { + CameraPreviewView(session: vm.camera.previewSession) + } + if vm.activeRoute != .internet && vm.cameraOff { Rectangle().fill(Color.black) Image(systemName: "video.slash.fill").font(.system(size: 22)).foregroundColor(DS.dim) } - if vm.isBlurred && !vm.cameraOff { + if vm.activeRoute != .internet && vm.isBlurred && !vm.cameraOff { Text("BLUR").font(DS.mono(9, .medium)).foregroundColor(DS.onFill) .padding(.horizontal, 6).padding(.vertical, 2) .background(DS.live, in: Capsule()) @@ -567,8 +848,8 @@ struct CallScreen: View { if showControls && !showChat { VStack(spacing: 0) { HStack(spacing: 10) { - StatusTag(text: vm.framesReceived > 0 ? "Secure" : (vm.noAnswer ? "No answer" : "Calling…"), - live: vm.framesReceived > 0) + StatusTag(text: mediaConnected ? "Secure" : (vm.activeRoute != .internet && vm.noAnswer ? "No answer" : "Calling…"), + live: mediaConnected) .background(DS.ink.opacity(0.5), in: Capsule()) // Make link trouble visible instead of a silent freeze. if vm.linkHealth != .good { @@ -607,9 +888,12 @@ struct CallScreen: View { }.buttonStyle(.plain) // Live BWE readout: peer's receive jitter + our encode rate. Green under the 40ms // back-off threshold, red above — network health at a glance (Zoom-style indicator). - Text("\(vm.peerJitterMs)ms·\(vm.camera.bitrateKbps)k") - .font(DS.mono(10)).foregroundColor(vm.peerJitterMs > 40 ? DS.danger : .green) - Text(vm.remoteIP).font(DS.mono(11)).foregroundColor(DS.faint) + if vm.activeRoute != .internet { + Text("\(vm.peerJitterMs)ms·\(vm.camera.bitrateKbps)k") + .font(DS.mono(10)).foregroundColor(vm.peerJitterMs > 40 ? DS.danger : .green) + } + Text(vm.activeRoute == .internet ? vm.callee : vm.remoteIP) + .font(DS.mono(11)).foregroundColor(DS.faint) } .padding(.horizontal, 16).padding(.top, 8) @@ -644,9 +928,9 @@ struct CallScreen: View { // Equal-width flexible cells so the row always fits the // phone width (6 controls; each cell centers a 46pt circle). HStack(spacing: 4) { - iBtn(system: vm.isMuted ? "mic.slash.fill" : "mic.fill", active: vm.isMuted) { NSLog("TRINET: btn MUTE -> \(!vm.isMuted)"); vm.isMuted.toggle() } + iBtn(system: vm.isMuted ? "mic.slash.fill" : "mic.fill", active: vm.isMuted) { NSLog("TRINET: btn MUTE -> \(!vm.isMuted)"); vm.toggleMute() } iBtn(system: "arrow.triangle.2.circlepath.camera.fill", active: false) { NSLog("TRINET: btn FLIP camera"); vm.camera.switchCamera() } - iBtn(system: vm.cameraOff ? "video.slash.fill" : "video.fill", active: vm.cameraOff) { NSLog("TRINET: btn CAMERA-OFF -> \(!vm.cameraOff)"); vm.cameraOff.toggle() } + iBtn(system: vm.cameraOff ? "video.slash.fill" : "video.fill", active: vm.cameraOff) { NSLog("TRINET: btn CAMERA-OFF -> \(!vm.cameraOff)"); vm.toggleCamera() } iBtn(system: vm.isBlurred ? "person.crop.rectangle.badge.plus.fill" : "person.crop.rectangle", active: vm.isBlurred) { NSLog("TRINET: btn BLUR -> \(!vm.isBlurred)"); vm.toggleBlur() } ZStack(alignment: .topTrailing) { iBtn(system: "bubble.left.and.bubble.right\(vm.chat.isEmpty ? "" : ".fill")", active: false) { NSLog("TRINET: btn CHAT"); vm.chatOpen = true; withAnimation { showChat = true } } @@ -685,6 +969,29 @@ struct CallScreen: View { } } +private struct InternetVideoArea: View { + @ObservedObject var controller: InternetCallController + let phase: StreamViewModel.CallPhase + let peer: String + + var body: some View { + ZStack { + DS.surface + if let track = controller.remoteVideoTrack { + SwiftUIVideoView(track, layoutMode: .fill) + } else { + VStack(spacing: 14) { + ProgressView().tint(DS.dim) + Text(controller.state.rawValue.uppercased()) + .font(DS.mono(12, .medium)).tracking(1).foregroundColor(DS.dim) + Text(controller.participantName.isEmpty ? peer : controller.participantName) + .font(DS.mono(11)).foregroundColor(DS.faint) + } + } + } + } +} + // iOS meter — flat segmented, DS tokens. private struct iMeter: View { let label: String; let level: Float; let muted: Bool @@ -763,18 +1070,230 @@ private struct iChatPanel: View { } } +private struct GroupChatCenterView: View { + @ObservedObject var vm: StreamViewModel + @ObservedObject private var group: GroupChatController + @Environment(\.dismiss) private var dismiss + + init(vm: StreamViewModel) { + self.vm = vm + group = vm.groupChat + } + + var body: some View { + NavigationView { + Group { + if let chat = group.activeChat { + conversation(chat) + } else { + chatList + } + } + .navigationTitle(group.activeChat?.title ?? "Group Chats") + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + if group.activeChat != nil { + Button("Chats") { group.closeChat() } + } + } + ToolbarItem(placement: .navigationBarTrailing) { + Button("Done") { dismiss() } + } + } + } + .onAppear { group.startPolling() } + } + + private var chatList: some View { + Form { + Section("New group") { + TextField("Title (optional)", text: $group.titleInput) + TextField("@alice, @bob", text: $group.membersInput) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + Text("Enter unique participant nicknames separated by commas or spaces. Offline members receive messages when they reconnect.") + .font(.caption).foregroundColor(.secondary) + Button(group.isWorking ? "Creating..." : "Create group") { + group.createGroup() + } + .disabled(group.isWorking) + } + + Section("Your chats") { + if group.chats.isEmpty { + Text("No groups yet").foregroundColor(.secondary) + } + ForEach(group.chats) { chat in + Button(action: { group.open(chat) }) { + HStack(alignment: .top, spacing: 10) { + VStack(alignment: .leading, spacing: 5) { + Text(chat.title).font(.headline).foregroundColor(.primary) + Text(chat.members.map { "@\($0)" }.joined(separator: ", ")) + .font(.caption).foregroundColor(.secondary).lineLimit(1) + if let lastMessage = chat.lastMessage { + Text(lastMessage).font(.subheadline).foregroundColor(.secondary).lineLimit(1) + } + } + Spacer(minLength: 0) + if let unread = group.unreadByChat[chat.chatID], unread > 0 { + Text("\(min(unread, 99))") + .font(.caption2.weight(.bold)) + .foregroundColor(.white) + .padding(.horizontal, 7) + .padding(.vertical, 3) + .background(Capsule().fill(Color.accentColor)) + .accessibilityLabel("\(unread) unread") + } + } + } + .accessibilityLabel(Text(chat.title)) + .accessibilityHint(Text("Open group chat")) + } + } + + if let status = group.statusMessage { + Section { Text(status).font(.caption).foregroundColor(.secondary) } + } + } + } + + private func conversation(_ chat: GroupChatSummary) -> some View { + VStack(spacing: 0) { + Text(chat.members.map { "@\($0)" }.joined(separator: ", ")) + .font(.caption).foregroundColor(.secondary).lineLimit(2) + .padding(.horizontal).padding(.vertical, 8) + Divider() + ScrollViewReader { proxy in + ScrollView { + LazyVStack(spacing: 10) { + ForEach(group.messages) { message in + let mine = message.senderUserID == vm.identity.userID + HStack { + if mine { Spacer(minLength: 45) } + VStack(alignment: .leading, spacing: 3) { + Text(mine ? "You" : "@\(message.senderNickname)") + .font(.caption2).foregroundColor(.secondary) + Text(message.text).font(.body) + } + .padding(.horizontal, 12).padding(.vertical, 8) + .background(mine ? Color.accentColor.opacity(0.18) : Color.secondary.opacity(0.12), + in: RoundedRectangle(cornerRadius: 13)) + if !mine { Spacer(minLength: 45) } + } + .id(message.messageID) + } + } + .padding() + } + .onChange(of: group.messages.count) { _ in + if let last = group.messages.last { + withAnimation { proxy.scrollTo(last.messageID, anchor: .bottom) } + } + } + } + Divider() + HStack(spacing: 10) { + TextField("Message", text: $group.draft) + .textFieldStyle(.roundedBorder) + .onSubmit { group.send() } + Button(action: { group.send() }) { + Image(systemName: "arrow.up.circle.fill").font(.system(size: 30)) + } + .disabled(group.isWorking || group.draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + .padding() + if let status = group.statusMessage { + Text(status).font(.caption).foregroundColor(.secondary).padding(.bottom, 6) + } + } + } +} + // MARK: - Settings struct SettingsView: View { @ObservedObject var vm: StreamViewModel + @ObservedObject private var account: AccountDeviceController @Environment(\.dismiss) var dismiss + init(vm: StreamViewModel) { + self.vm = vm + account = vm.account + } + var body: some View { NavigationView { Form { + Section("Identity") { + TextField("Device name", text: Binding( + get: { vm.identity.displayName }, + set: { vm.renameDevice($0) } + )) + HRow("Device ID", String(vm.identity.deviceID.prefix(12))) + HRow("Key", vm.identity.keyFingerprint) + } + Section("Owner account") { + HRow("Nickname", account.nickname.map { "@\($0)" } ?? "Not created") + HRow("Account ID", String(account.accountID.prefix(12))) + Text("No password or private key is shared. Every installation has its own revocable signing key. Passkey sign-in becomes the primary recovery method after a production HTTPS domain is connected.") + .font(.caption).foregroundColor(.secondary) + Button(account.isWorking ? "Syncing..." : "Sync account") { account.sync() } + .disabled(account.isWorking) + } + Section("Add your device") { + Button("Create one-time link code") { account.createLinkCode() } + .disabled(account.isWorking) + if let code = account.generatedLinkCode { + Text(code).font(.system(.caption, design: .monospaced)).textSelection(.enabled) + Button("Copy link code") { UIPasteboard.general.string = code } + } + TextField("link_... from trusted device", text: $account.linkCodeInput) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + Button("Link this device to my account") { account.joinAccount() } + .disabled(account.isWorking) + Text("The code contains 128 bits of randomness, expires in 10 minutes, and works once. Create it on a device already in your account.") + .font(.caption).foregroundColor(.secondary) + } + if !account.devices.isEmpty { + Section("Your devices") { + ForEach(account.devices) { device in + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(device.displayName + (device.current ? " (this device)" : "")) + Text("\(device.platform) · \(device.keyFingerprint)") + .font(.caption2).foregroundColor(.secondary) + } + Spacer() + if device.revoked { + Text("Revoked").font(.caption).foregroundColor(.secondary) + } else if !device.current { + Button("Revoke", role: .destructive) { account.revoke(device) } + } + } + } + } + } + if let message = account.statusMessage { + Section("Account status") { Text(message).font(.caption) } + } Section("Connection") { - TextField("Remote Mac IP", text: $vm.remoteIP) - .keyboardType(.decimalPad) + Picker("Route", selection: $vm.route) { + ForEach(CallRoute.allCases) { Text($0.displayName).tag($0) } + } + TextField("Contact or device", text: $vm.callee) + TextField("Mesh peer IP", text: $vm.remoteIP).keyboardType(.decimalPad) + } + Section("Internet service") { + TextField("API URL", text: $vm.internetConfiguration.apiBaseURL) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + TextField("LiveKit URL", text: $vm.internetConfiguration.liveKitURL) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + SecureField("Development room token", text: $vm.internetConfiguration.developmentRoomToken) + SecureField("Service access token", text: $vm.internetConfiguration.accessToken) + Button("Save internet settings") { vm.saveInternetSettings() } } Section("Your IP") { Text(vm.myIP).font(.system(.body, design: .monospaced)) @@ -786,7 +1305,7 @@ struct SettingsView: View { } Section("About") { HRow("Version", "1.0") - HRow("Transport", "BSD UDP (direct)") + HRow("Transport", "Local/Mesh UDP + LiveKit WebRTC") } } .navigationTitle("Settings") @@ -868,6 +1387,7 @@ enum DS { static let fill = Color.white static let onFill = Color.black static let live = Color(red: 0.30, green: 0.85, blue: 0.45) + static let warn = Color(red: 0.96, green: 0.66, blue: 0.22) static let danger = Color(red: 0.95, green: 0.35, blue: 0.35) static func ui(_ s: CGFloat, _ w: Font.Weight = .regular) -> Font { .system(size: s, weight: w) } static func mono(_ s: CGFloat, _ w: Font.Weight = .regular) -> Font { .system(size: s, weight: w, design: .monospaced) } diff --git a/phone/TriNetVideoTests/NicknamePolicyTests.swift b/phone/TriNetVideoTests/NicknamePolicyTests.swift new file mode 100644 index 00000000..d9c6c46d --- /dev/null +++ b/phone/TriNetVideoTests/NicknamePolicyTests.swift @@ -0,0 +1,89 @@ +import CryptoKit +import XCTest +@testable import TriNetVideo + +final class NicknamePolicyTests: XCTestCase { + func testNormalizationAndShape() { + XCTAssertEqual(NicknamePolicy.normalize(" Alice_NET "), "alice_net") + XCTAssertNil(NicknamePolicy.validationError("alice_27")) + XCTAssertNotNil(NicknamePolicy.validationError("27alice")) + XCTAssertNotNil(NicknamePolicy.validationError("alice-net")) + XCTAssertNotNil(NicknamePolicy.validationError("al")) + } + + func testNearCopyDetection() { + XCTAssertTrue(NicknamePolicy.isConfusing("alice", with: "alice")) + XCTAssertTrue(NicknamePolicy.isConfusing("alice", with: "alixe")) + XCTAssertTrue(NicknamePolicy.isConfusing("alice", with: "alice12")) + XCTAssertFalse(NicknamePolicy.isConfusing("alice", with: "bravo")) + } + + func testSuggestionsAreValidAndDistinct() { + let suggestions = NicknamePolicy.suggestions( + for: "alice", + excluding: ["alice", "alixe"], + seed: "device-27" + ) + XCTAssertEqual(suggestions.count, 3) + XCTAssertEqual(Set(suggestions).count, 3) + XCTAssertTrue(suggestions.allSatisfy { NicknamePolicy.validationError($0) == nil }) + XCTAssertTrue(suggestions.allSatisfy { !NicknamePolicy.isConfusing($0, with: "alice") }) + } + + func testFingerprintIsDerivedFromPublicKey() { + let publicKey = P256.Signing.PrivateKey().publicKey.x963Representation + let encoded = publicKey.base64EncodedString() + let expected = SHA256.hash(data: publicKey).prefix(12).map { + String(format: "%02x", $0) + }.joined() + + XCTAssertEqual(DeviceIdentityStore.fingerprint(for: encoded), expected) + XCTAssertNil(DeviceIdentityStore.fingerprint(for: "not-base64")) + } + + func testMeshInviteSignatureRejectsTampering() throws { + let privateKey = P256.Signing.PrivateKey() + let publicKey = privateKey.publicKey.x963Representation + let publicKeyText = publicKey.base64EncodedString() + let fingerprint = SHA256.hash(data: publicKey).prefix(12).map { + String(format: "%02x", $0) + }.joined() + let timestamp: Int64 = 100 + let payload = MeshCallSignaling.signedPayload(callID: "call-1", + nickname: "alice", + displayName: "Alice", + userID: "user-1", + deviceID: "device-1", + mediaPort: 7000, + timestamp: timestamp, + nonce: "nonce-1") + let signature = try privateKey.signature(for: payload).derRepresentation.base64EncodedString() + let invite = MeshCallInvite(version: 1, + callID: "call-1", + nickname: "alice", + displayName: "Alice", + userID: "user-1", + deviceID: "device-1", + publicKey: publicKeyText, + keyFingerprint: fingerprint, + mediaPort: 7000, + timestamp: timestamp, + nonce: "nonce-1", + signature: signature) + XCTAssertTrue(MeshCallSignaling.signatureIsValid(invite)) + + let tampered = MeshCallInvite(version: invite.version, + callID: invite.callID, + nickname: "mallory", + displayName: invite.displayName, + userID: invite.userID, + deviceID: invite.deviceID, + publicKey: invite.publicKey, + keyFingerprint: invite.keyFingerprint, + mediaPort: invite.mediaPort, + timestamp: invite.timestamp, + nonce: invite.nonce, + signature: invite.signature) + XCTAssertFalse(MeshCallSignaling.signatureIsValid(tampered)) + } +} diff --git a/phone/desktop/TriNetMonitor-Info.plist b/phone/desktop/TriNetMonitor-Info.plist index 35d89f16..0c17e9fe 100644 --- a/phone/desktop/TriNetMonitor-Info.plist +++ b/phone/desktop/TriNetMonitor-Info.plist @@ -20,15 +20,29 @@ 1.0 CFBundleVersion 1 + NSAppTransportSecurity + + NSAllowsLocalNetworking + + NSBonjourServices + _trinet-call._udp _trinet._udp NSCameraUsageDescription - Mesh monitor + TRI-NET needs camera for secure local and internet video calls NSLocalNetworkUsageDescription - Find TRI-NET peers on your local network by name instead of typing IPs + TRI-NET discovers signed contacts and peers over local UDP or a routed radio mesh NSMicrophoneUsageDescription - Audio for mesh video calls + TRI-NET needs microphone for video calls + TRINET_API_BASE_URL + http://SSDs-MacBook-Pro.local:8080 + TRINET_DEVELOPMENT_ROOM_TOKEN + + TRINET_LIVEKIT_URL + + TRINET_SERVICE_ACCESS_TOKEN + diff --git a/phone/desktop/TriNetMonitor.swift b/phone/desktop/TriNetMonitor.swift index 986a71a2..180e89f0 100644 --- a/phone/desktop/TriNetMonitor.swift +++ b/phone/desktop/TriNetMonitor.swift @@ -11,6 +11,7 @@ import Network struct MeshNode: Identifiable, Hashable { let id: Int var ip: String + var name: String? var label: String var status: NodeStatus var role: NodeRole @@ -523,6 +524,32 @@ class NetworkScanner { } } + // Resolve a Bonjour/mDNS host through the macOS directory cache. Keeping the + // host name as the identity avoids pinning a phone to a changing IP address. + static func resolveIPv4(hostname: String) -> [String] { + let task = Process() + task.executableURL = URL(fileURLWithPath: "/usr/bin/dscacheutil") + task.arguments = ["-q", "host", "-a", "name", hostname] + + let pipe = Pipe() + task.standardOutput = pipe + task.standardError = pipe + + do { + try task.run() + task.waitUntilExit() + let data = pipe.fileHandleForReading.readDataToEndOfFile() + let output = String(data: data, encoding: .utf8) ?? "" + return output.components(separatedBy: "\n").compactMap { line in + let prefix = "ip_address: " + guard line.hasPrefix(prefix) else { return nil } + return String(line.dropFirst(prefix.count)) + } + } catch { + return [] + } + } + // Determine device type from MAC address static func identifyDevice(mac: String, ip: String) -> (MeshNode.DeviceType, MeshNode.NodeRole) { let macLower = mac.lowercased() @@ -586,6 +613,10 @@ class MeshMonitorEngine: ObservableObject { @Published var deviceIPs: [String] = UserDefaults.standard.stringArray(forKey: "deviceIPs") ?? ["192.168.1.11", "192.168.1.12", "192.168.1.13"] + // Named Bonjour devices stay stable even when their DHCP or link-local IP changes. + private let namedDevices: [(name: String, hostname: String)] = [ + (name: "ssd26", hostname: "ssd26.local") + ] var upnpRouterIPs = Set() // IPs that answered SSDP as InternetGatewayDevice -> shown as Router private var periodicScanCount = 0 // heavy discovery runs only every 20th periodic tick (~once/min) @@ -600,7 +631,7 @@ class MeshMonitorEngine: ObservableObject { guard !isMonitoring else { return } isMonitoring = true LogBus.shared.start() // ensure the shared log is capturing (in case only this tab is opened) - logEvent(.scan, node: 0, desc: "Monitor started — scanning \(deviceIPs.count) devices") + logEvent(.scan, node: 0, desc: "Monitor started — scanning \(deviceIPs.count + namedDevices.count) devices") scanNetwork() // Scan every 3 seconds. LIGHT probe of known devices each tick; the heavy discovery // (full /24 sweep + SSDP + mDNS) only ~once a minute, so it never starves the video call. @@ -632,6 +663,22 @@ class MeshMonitorEngine: ObservableObject { isScanning = true scanProgress = "Scanning..." + // Resolve named devices before probing. Prefer the direct link-local path when + // available; it represents the attached iPhone instead of its hotspot gateway. + var namedLabels: [String: String] = [:] + var namedResolvedIPs = Set() + var scanTargets = deviceIPs + for device in namedDevices { + let addresses = NetworkScanner.resolveIPv4(hostname: device.hostname) + namedResolvedIPs.formUnion(addresses) + let target = addresses.first(where: { $0.hasPrefix("169.254.") }) + ?? addresses.first + ?? device.hostname + scanTargets.removeAll { namedResolvedIPs.contains($0) } + if !scanTargets.contains(target) { scanTargets.append(target) } + namedLabels[target] = device.name + } + // Heavy discovery (full /24 sweep + SSDP + mDNS) ONLY on demand. Running it on the 3s // periodic timer spawned 254 pings + 2 multicast probes every tick and starved the video // call -- periodic ticks now do a light probe of known devices; this runs on Scan Now and @@ -643,7 +690,7 @@ class MeshMonitorEngine: ObservableObject { // live outside our /24 (e.g. a board or router on another segment). let prefix = NetworkScanner.subnetPrefix() ?? "192.168.1." await NetworkScanner.sweepSubnet(prefix) - for ip in deviceIPs where !ip.hasPrefix(prefix) { + for ip in scanTargets where !ip.hasPrefix(prefix) { DispatchQueue.global().async { let task = Process() task.executableURL = URL(fileURLWithPath: "/sbin/ping") @@ -659,13 +706,13 @@ class MeshMonitorEngine: ObservableObject { // board's .10 secondary-IP phantom. The GATEWAY/ROUTER is NO LONGER skipped -- it is a real // device (the main router) and must be shown; identifyDevice() labels .1 as Router. let arpDevices = NetworkScanner.getARPDevices() - var allIPs = Set(deviceIPs) + var allIPs = Set(scanTargets) // .10 phantom: the stock board init adds .10 as a SECONDARY IP to every board, so it // resolves (ARP race) to whichever board answers -- not a distinct node. var skipIPs: Set = ["\(prefix)255", "224.0.0.251", "0.0.0.0", "\(prefix)10", "255.255.255.255"] if let s = NetworkScanner.localIPv4() { skipIPs.insert(s) } for (ip, mac) in arpDevices where ip.hasPrefix(prefix) || allIPs.contains(ip) { - if !allIPs.contains(ip) && !skipIPs.contains(ip) { + if !allIPs.contains(ip) && !skipIPs.contains(ip) && !namedResolvedIPs.contains(ip) { deviceIPs.append(ip) allIPs.insert(ip) UserDefaults.standard.set(deviceIPs, forKey: "deviceIPs") @@ -694,7 +741,7 @@ class MeshMonitorEngine: ObservableObject { } // end fullDiscovery let monitorNode = MeshNode( - id: 0, ip: "self", label: "Monitor", + id: 0, ip: "self", name: "Monitor", label: "Monitor", status: .online, role: .endpoint, etx: 0, hopCount: 0, packetsForwarded: 0, packetsOriginated: 0, packetsDelivered: 0, @@ -710,7 +757,10 @@ class MeshMonitorEngine: ObservableObject { // timeout chain -- 16 devices took minutes and the UI showed stale // "Online" states the whole time. A TaskGroup bounds the pass to the // slowest single probe (~2s) no matter how many hosts are down. - let ipsSnapshot = deviceIPs + var ipsSnapshot = deviceIPs.filter { !namedResolvedIPs.contains($0) } + for target in scanTargets where !ipsSnapshot.contains(target) { + ipsSnapshot.append(target) + } let ports = probePorts scanProgress = "Probing \(ipsSnapshot.count) devices in parallel..." var results: [String: (online: Bool, rttMs: Int)] = [:] @@ -736,7 +786,10 @@ class MeshMonitorEngine: ObservableObject { // Get MAC and identify device type let macInfo = arpSnapshot.first(where: { $0.ip == ip }) let macStr = macInfo?.mac ?? "?" - var (dtype, drole) = NetworkScanner.identifyDevice(mac: macStr, ip: ip) + let displayName = namedLabels[ip] + var (dtype, drole) = displayName == nil + ? NetworkScanner.identifyDevice(mac: macStr, ip: ip) + : (.phone, .external) if upnpRouterIPs.contains(ip) { dtype = .router; drole = .gateway } // UPnP IGD = router, even if not .1 // Detect transitions @@ -753,7 +806,7 @@ class MeshMonitorEngine: ObservableObject { // Golden ratio spiral layout — Monitor in center, devices radiate outward // phi = 1.618 — each device placed at golden angle (137.5°) from previous - let total = deviceIPs.count + let total = ipsSnapshot.count let phi: CGFloat = 1.618 let goldenAngle: CGFloat = 2.39996 // 137.5° in radians let baseRadius: CGFloat = 110 @@ -775,8 +828,8 @@ class MeshMonitorEngine: ObservableObject { let etx = isOnline ? 1.0 : 0 let node = MeshNode( - id: nodeId, ip: ip, - label: "\(dtype.rawValue)\n\(ip)", + id: nodeId, ip: ip, name: displayName, + label: displayName ?? "\(dtype.rawValue)\n\(ip)", status: isOnline ? .online : .offline, role: drole, etx: etx, @@ -871,6 +924,7 @@ struct TriNetMonitorApp: App { .onAppear { // Start RTI listener immediately on app launch rtiEngine.go() + engine.scanNetwork() } } .commands { @@ -1240,7 +1294,7 @@ struct NodeView: View { .animation(.easeInOut(duration: 0.15), value: isHovered) // Label — IP only (less is more, UX Magazine principle) - Text(node.id == 0 ? "Monitor" : node.ip) + Text(node.id == 0 ? "Monitor" : (node.name ?? node.ip)) .font(.system(size: 9, weight: .medium, design: .monospaced)) .foregroundColor(node.status == .offline ? .gray.opacity(0.35) : .white.opacity(0.8)) .lineLimit(1) @@ -1248,7 +1302,7 @@ struct NodeView: View { .onHover { hovering in isHovered = hovering } - .help(node.id == 0 ? "This Mac (Monitor)" : "\(node.deviceType.rawValue) \(node.ip)\nMAC: \(node.mac)\nRTT: \(node.rttMs)ms\nStatus: \(node.status.label)") + .help(node.id == 0 ? "This Mac (Monitor)" : "\(node.name ?? node.deviceType.rawValue) \(node.ip)\nMAC: \(node.mac)\nRTT: \(node.rttMs)ms\nStatus: \(node.status.label)") } var iconName: String { @@ -1366,7 +1420,7 @@ struct DeviceRow: View { VStack(alignment: .leading, spacing: 2) { HStack { - Text(node.deviceType.rawValue) + Text(node.name ?? node.deviceType.rawValue) .font(.system(size: 9, weight: .bold)) .foregroundColor(node.deviceType.color) Text(node.ip) diff --git a/phone/desktop/TriNetMonitor.xcodeproj/project.pbxproj b/phone/desktop/TriNetMonitor.xcodeproj/project.pbxproj index 22058aee..16767333 100644 --- a/phone/desktop/TriNetMonitor.xcodeproj/project.pbxproj +++ b/phone/desktop/TriNetMonitor.xcodeproj/project.pbxproj @@ -10,6 +10,9 @@ 1321BCE2FBFBB8BFCDEEB308 /* PeerDiscovery.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8B07E5953505C3265FEAA51 /* PeerDiscovery.swift */; }; 144EA8CA8611748CC2C6498E /* VideoEncoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72EFD27FFE9177F3C365221D /* VideoEncoder.swift */; }; 16CC1C6D7EEBF2EE5C3F95A1 /* TriNetMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A55AB3C5EF91C2E49F7A086 /* TriNetMonitor.swift */; }; + 2788FE5E517F273F9E3AA342 /* InternetCall.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDB130CF8851311DC42CFA87 /* InternetCall.swift */; }; + 46FAF63A454E9A77896686AC /* NicknameDirectory.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1104EED999717896B60DA1 /* NicknameDirectory.swift */; }; + 50C75A409E524B90A1251611 /* LiveKit in Frameworks */ = {isa = PBXBuildFile; productRef = 05C6C731D0857C8322B2E173 /* LiveKit */; }; 51DB04A269C0FF798682A49F /* LinkStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1512923CAB35C8933FA4CA5E /* LinkStatus.swift */; }; 5EDE0262C99C16D7CC7A20A9 /* ScreenCapture.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9F379575CCFDE0476C19D25 /* ScreenCapture.swift */; }; 7BC6C7C0D52EF3F427643504 /* MeshCrypto.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34A698FD561A4D1344491A23 /* MeshCrypto.swift */; }; @@ -24,6 +27,7 @@ DAF6D4EF3DC01D613A72B27A /* BackgroundBlur.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6ECC2F3EAC83479F7A0D2341 /* BackgroundBlur.swift */; }; E00EF2E2E4936A31DF3F1B15 /* VideoCallTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 432A15C47C534F56D40856CC /* VideoCallTab.swift */; }; F2659BF9683552ADAD6DFD92 /* RTI3D.swift in Sources */ = {isa = PBXBuildFile; fileRef = 292CA8FEE0BA3A9A94136269 /* RTI3D.swift */; }; + F793658DD86C22793904B455 /* CallIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DDEEC8379C5CB33966E7CC3 /* CallIdentity.swift */; }; FA37124F072CB8ADC4B3905A /* DesignSystem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 227CBA24BA6F542237F2CD11 /* DesignSystem.swift */; }; /* End PBXBuildFile section */ @@ -38,17 +42,31 @@ 432A15C47C534F56D40856CC /* VideoCallTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoCallTab.swift; sourceTree = ""; }; 6ECC2F3EAC83479F7A0D2341 /* BackgroundBlur.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundBlur.swift; sourceTree = ""; }; 72EFD27FFE9177F3C365221D /* VideoEncoder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoEncoder.swift; sourceTree = ""; }; + 7DDEEC8379C5CB33966E7CC3 /* CallIdentity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallIdentity.swift; sourceTree = ""; }; 8CD42991E4D14A95B2A03439 /* TriNetMonitor.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = TriNetMonitor.app; sourceTree = BUILT_PRODUCTS_DIR; }; A55D68653305B39F6D4E6C1F /* RTIHeatmap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RTIHeatmap.swift; sourceTree = ""; }; A9F379575CCFDE0476C19D25 /* ScreenCapture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenCapture.swift; sourceTree = ""; }; AF4870614578478C431D47B9 /* MeshTransport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshTransport.swift; sourceTree = ""; }; BED4FD339A53B74B67C3AC3B /* CameraCapture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CameraCapture.swift; sourceTree = ""; }; + DD1104EED999717896B60DA1 /* NicknameDirectory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NicknameDirectory.swift; sourceTree = ""; }; + DDB130CF8851311DC42CFA87 /* InternetCall.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InternetCall.swift; sourceTree = ""; }; EC01D2BB9D49E3653F739BC9 /* CallRecorder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallRecorder.swift; sourceTree = ""; }; F8B07E5953505C3265FEAA51 /* PeerDiscovery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeerDiscovery.swift; sourceTree = ""; }; FAFD67D555791E79EA093151 /* OpusCodec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpusCodec.swift; sourceTree = ""; }; FE481AF1D2D340638A1ED080 /* CallManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallManager.swift; sourceTree = ""; }; /* End PBXFileReference section */ +/* Begin PBXFrameworksBuildPhase section */ + 243F4DF0305CC1C8D8D415F0 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 50C75A409E524B90A1251611 /* LiveKit in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + /* Begin PBXGroup section */ 4788FD180304BB5A1F2D9A84 /* Products */ = { isa = PBXGroup; @@ -78,6 +96,25 @@ path = TriNetVideo; sourceTree = ""; }; + 7FD475DC84A6C1561614298A /* shared */ = { + isa = PBXGroup; + children = ( + 7DDEEC8379C5CB33966E7CC3 /* CallIdentity.swift */, + DDB130CF8851311DC42CFA87 /* InternetCall.swift */, + DD1104EED999717896B60DA1 /* NicknameDirectory.swift */, + ); + path = shared; + sourceTree = ""; + }; + 8EA9D846F98AB87E7B218183 /* phone */ = { + isa = PBXGroup; + children = ( + 7FD475DC84A6C1561614298A /* shared */, + ); + name = phone; + path = ..; + sourceTree = ""; + }; F9A4133CDBCCE7D23B0428D1 = { isa = PBXGroup; children = ( @@ -86,6 +123,7 @@ A55D68653305B39F6D4E6C1F /* RTIHeatmap.swift */, 2A55AB3C5EF91C2E49F7A086 /* TriNetMonitor.swift */, 432A15C47C534F56D40856CC /* VideoCallTab.swift */, + 8EA9D846F98AB87E7B218183 /* phone */, 4990817AFEF5B22035759A7D /* TriNetVideo */, 4788FD180304BB5A1F2D9A84 /* Products */, ); @@ -99,6 +137,7 @@ buildConfigurationList = 5073D7A31C02AEB69107CB86 /* Build configuration list for PBXNativeTarget "TriNetMonitor" */; buildPhases = ( 9780E719EA994AF75C86C2F4 /* Sources */, + 243F4DF0305CC1C8D8D415F0 /* Frameworks */, ); buildRules = ( ); @@ -106,6 +145,7 @@ ); name = TriNetMonitor; packageProductDependencies = ( + 05C6C731D0857C8322B2E173 /* LiveKit */, ); productName = TriNetMonitor; productReference = 8CD42991E4D14A95B2A03439 /* TriNetMonitor.app */; @@ -135,6 +175,9 @@ ); mainGroup = F9A4133CDBCCE7D23B0428D1; minimizedProjectReferenceProxies = 1; + packageReferences = ( + E96CE087A2E6E14D83ADE053 /* XCRemoteSwiftPackageReference "client-sdk-swift" */, + ); preferredProjectObjectVersion = 77; productRefGroup = 4788FD180304BB5A1F2D9A84 /* Products */; projectDirPath = ""; @@ -152,13 +195,16 @@ files = ( C270D9DDE66C74E5DC1A30B3 /* AudioController.swift in Sources */, DAF6D4EF3DC01D613A72B27A /* BackgroundBlur.swift in Sources */, + F793658DD86C22793904B455 /* CallIdentity.swift in Sources */, D71FF2DC1AF5071C5554087C /* CallManager.swift in Sources */, 9883135C4EC8B3D8C9780D21 /* CallRecorder.swift in Sources */, C73AAD99E5A78718DEBAB8D8 /* CameraCapture.swift in Sources */, FA37124F072CB8ADC4B3905A /* DesignSystem.swift in Sources */, + 2788FE5E517F273F9E3AA342 /* InternetCall.swift in Sources */, 51DB04A269C0FF798682A49F /* LinkStatus.swift in Sources */, 7BC6C7C0D52EF3F427643504 /* MeshCrypto.swift in Sources */, 8FB37216EA6E4DAA9752526F /* MeshTransport.swift in Sources */, + 46FAF63A454E9A77896686AC /* NicknameDirectory.swift in Sources */, B47AD84EFE1C6408179E2242 /* OpusCodec.swift in Sources */, 1321BCE2FBFBB8BFCDEEB308 /* PeerDiscovery.swift in Sources */, F2659BF9683552ADAD6DFD92 /* RTI3D.swift in Sources */, @@ -313,6 +359,7 @@ CODE_SIGN_STYLE = Manual; COMBINE_HIDPI_IMAGES = YES; DEVELOPMENT_TEAM = 5EM4M85VSQ; + GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = "TriNetMonitor-Info.plist"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -334,6 +381,7 @@ CODE_SIGN_STYLE = Manual; COMBINE_HIDPI_IMAGES = YES; DEVELOPMENT_TEAM = 5EM4M85VSQ; + GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = "TriNetMonitor-Info.plist"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -367,6 +415,25 @@ defaultConfigurationName = Debug; }; /* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + E96CE087A2E6E14D83ADE053 /* XCRemoteSwiftPackageReference "client-sdk-swift" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/livekit/client-sdk-swift.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 2.15.2; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 05C6C731D0857C8322B2E173 /* LiveKit */ = { + isa = XCSwiftPackageProductDependency; + package = E96CE087A2E6E14D83ADE053 /* XCRemoteSwiftPackageReference "client-sdk-swift" */; + productName = LiveKit; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 0D23629986D502FB3B8E6416 /* Project object */; } diff --git a/phone/desktop/TriNetMonitor.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/phone/desktop/TriNetMonitor.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 00000000..72f4fbfe --- /dev/null +++ b/phone/desktop/TriNetMonitor.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,42 @@ +{ + "originHash" : "f23dc9251b30242b9d104cd8d1c7e5c16c31c611a03561a7fa74a136843469d9", + "pins" : [ + { + "identity" : "client-sdk-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/livekit/client-sdk-swift.git", + "state" : { + "revision" : "77b5aad07909e23adf97d39f205ef7e18e2ceff5", + "version" : "2.15.2" + } + }, + { + "identity" : "livekit-uniffi-xcframework", + "kind" : "remoteSourceControl", + "location" : "https://github.com/livekit/livekit-uniffi-xcframework.git", + "state" : { + "revision" : "7c161254ce7cd55debc48023f69a917076b12a26", + "version" : "0.0.6" + } + }, + { + "identity" : "swift-protobuf", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-protobuf.git", + "state" : { + "revision" : "55d7a1cc5666b85c13464aea1c4b4a90feccb4c8", + "version" : "1.38.1" + } + }, + { + "identity" : "webrtc-xcframework", + "kind" : "remoteSourceControl", + "location" : "https://github.com/livekit/webrtc-xcframework.git", + "state" : { + "revision" : "46f2af86f06b9a8a9158d37cadda5cb5a214e4c4", + "version" : "144.7559.11" + } + } + ], + "version" : 3 +} diff --git a/phone/desktop/TriNetVideo.xcodeproj/project.pbxproj b/phone/desktop/TriNetVideo.xcodeproj/project.pbxproj index 23b94502..4afcee21 100644 --- a/phone/desktop/TriNetVideo.xcodeproj/project.pbxproj +++ b/phone/desktop/TriNetVideo.xcodeproj/project.pbxproj @@ -7,30 +7,76 @@ objects = { /* Begin PBXBuildFile section */ + 094B0FDEEEA876B04E5C3700 /* CallIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C404DA930B30CD012F62D59 /* CallIdentity.swift */; }; + 0FD4749EFC9F8923DD0BCB49 /* MeshCrypto.swift in Sources */ = {isa = PBXBuildFile; fileRef = A95B320DB85BD9CA34441F97 /* MeshCrypto.swift */; }; + 111558B34260E5B694D543EE /* AudioController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61BC88F2C76B79E6716BD918 /* AudioController.swift */; }; + 12E514050EC8F765A8DC3472 /* OpusCodec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96DB9356EBB9897CE6C906D7 /* OpusCodec.swift */; }; + 131244A5A527881519B0BB77 /* LinkStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BCD15FBEAF2379CE2C75DC1 /* LinkStatus.swift */; }; + 1410A21A73E9E82F0622FFE1 /* BackgroundBlur.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0AA628378284C43ABC50D020 /* BackgroundBlur.swift */; }; 40F017DE28896B7D0177AC7B /* CameraCapture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05D79458F799679566EA2F11 /* CameraCapture.swift */; }; + 520A5853F1324286B7C9D328 /* CallRecorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4EA56245810CECD0C202FFE /* CallRecorder.swift */; }; 66CCD9FFAD828B288E427269 /* MeshTransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 201CD1EE7B29F77025DA7005 /* MeshTransport.swift */; }; + 6D338808E3FCAF91BE192590 /* PeerDiscovery.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA75ECF67764B6EAF235CAE1 /* PeerDiscovery.swift */; }; 7059B12C50899D17431177A1 /* CallManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 915D7663B5CD3BA6B725865A /* CallManager.swift */; }; + 925424B00856474500875967 /* ScreenCapture.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E0D7BDA34C051DCC9477E0 /* ScreenCapture.swift */; }; 93828957945393D93D1AE545 /* App.swift in Sources */ = {isa = PBXBuildFile; fileRef = 919A6C8C4AE3FF1BEBC24E2D /* App.swift */; }; C42AEF335F11DD33CC802C77 /* Views.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3E1BEB0E6D36884DC34BAF1 /* Views.swift */; }; + D97D14140C893639F33F37D5 /* LiveKit in Frameworks */ = {isa = PBXBuildFile; productRef = C4D0DDD6535CFA404CBF8DAA /* LiveKit */; }; E00B31F6C88D6223CF93B32F /* VideoEncoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7467C86E81673925398E7CA /* VideoEncoder.swift */; }; + EDE8E8CA5000C63D274F4D54 /* NicknameDirectory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27976511C5B03FA14DBE15C6 /* NicknameDirectory.swift */; }; + F6CB4CE6A1336FEBE77BA83B /* InternetCall.swift in Sources */ = {isa = PBXBuildFile; fileRef = 338452C9348505CF2B20ED39 /* InternetCall.swift */; }; F909D84E94FAA98C4929B709 /* VideoDecoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC9E1456FC3042E3A277409E /* VideoDecoder.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ 05D79458F799679566EA2F11 /* CameraCapture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CameraCapture.swift; sourceTree = ""; }; + 0AA628378284C43ABC50D020 /* BackgroundBlur.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundBlur.swift; sourceTree = ""; }; 201CD1EE7B29F77025DA7005 /* MeshTransport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshTransport.swift; sourceTree = ""; }; + 27976511C5B03FA14DBE15C6 /* NicknameDirectory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NicknameDirectory.swift; sourceTree = ""; }; + 338452C9348505CF2B20ED39 /* InternetCall.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InternetCall.swift; sourceTree = ""; }; 5235533C9F22E43B48C7BE18 /* TriNetVideo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = TriNetVideo.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 5BCD15FBEAF2379CE2C75DC1 /* LinkStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkStatus.swift; sourceTree = ""; }; + 61BC88F2C76B79E6716BD918 /* AudioController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioController.swift; sourceTree = ""; }; 915D7663B5CD3BA6B725865A /* CallManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallManager.swift; sourceTree = ""; }; 919A6C8C4AE3FF1BEBC24E2D /* App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App.swift; sourceTree = ""; }; + 96DB9356EBB9897CE6C906D7 /* OpusCodec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpusCodec.swift; sourceTree = ""; }; + 9C404DA930B30CD012F62D59 /* CallIdentity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallIdentity.swift; sourceTree = ""; }; + A5E0D7BDA34C051DCC9477E0 /* ScreenCapture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenCapture.swift; sourceTree = ""; }; + A95B320DB85BD9CA34441F97 /* MeshCrypto.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshCrypto.swift; sourceTree = ""; }; + AA75ECF67764B6EAF235CAE1 /* PeerDiscovery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeerDiscovery.swift; sourceTree = ""; }; + B4EA56245810CECD0C202FFE /* CallRecorder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallRecorder.swift; sourceTree = ""; }; + B531BE39B4623AAA1D6E3823 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; C3E1BEB0E6D36884DC34BAF1 /* Views.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Views.swift; sourceTree = ""; }; DC9E1456FC3042E3A277409E /* VideoDecoder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoDecoder.swift; sourceTree = ""; }; E7467C86E81673925398E7CA /* VideoEncoder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoEncoder.swift; sourceTree = ""; }; /* End PBXFileReference section */ +/* Begin PBXFrameworksBuildPhase section */ + A992AD0FA4A8B6D7F4F2E05C /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + D97D14140C893639F33F37D5 /* LiveKit in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + /* Begin PBXGroup section */ + 12A8BAAE3F99DCCE291DFFC1 /* shared */ = { + isa = PBXGroup; + children = ( + 9C404DA930B30CD012F62D59 /* CallIdentity.swift */, + 338452C9348505CF2B20ED39 /* InternetCall.swift */, + 27976511C5B03FA14DBE15C6 /* NicknameDirectory.swift */, + ); + path = shared; + sourceTree = ""; + }; A6C8AF585BF4DA374BF1E301 = { isa = PBXGroup; children = ( + CDACF92DA242F04F4B37857C /* phone */, AB31945200E8755D13A21492 /* TriNetVideo */, BDEBCA25FD616B1244269FB0 /* Products */, ); @@ -40,9 +86,18 @@ isa = PBXGroup; children = ( 919A6C8C4AE3FF1BEBC24E2D /* App.swift */, + 61BC88F2C76B79E6716BD918 /* AudioController.swift */, + 0AA628378284C43ABC50D020 /* BackgroundBlur.swift */, 915D7663B5CD3BA6B725865A /* CallManager.swift */, + B4EA56245810CECD0C202FFE /* CallRecorder.swift */, 05D79458F799679566EA2F11 /* CameraCapture.swift */, + B531BE39B4623AAA1D6E3823 /* Info.plist */, + 5BCD15FBEAF2379CE2C75DC1 /* LinkStatus.swift */, + A95B320DB85BD9CA34441F97 /* MeshCrypto.swift */, 201CD1EE7B29F77025DA7005 /* MeshTransport.swift */, + 96DB9356EBB9897CE6C906D7 /* OpusCodec.swift */, + AA75ECF67764B6EAF235CAE1 /* PeerDiscovery.swift */, + A5E0D7BDA34C051DCC9477E0 /* ScreenCapture.swift */, DC9E1456FC3042E3A277409E /* VideoDecoder.swift */, E7467C86E81673925398E7CA /* VideoEncoder.swift */, C3E1BEB0E6D36884DC34BAF1 /* Views.swift */, @@ -58,6 +113,15 @@ name = Products; sourceTree = ""; }; + CDACF92DA242F04F4B37857C /* phone */ = { + isa = PBXGroup; + children = ( + 12A8BAAE3F99DCCE291DFFC1 /* shared */, + ); + name = phone; + path = ..; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -66,6 +130,7 @@ buildConfigurationList = 8BF7BEC5E62F701272704476 /* Build configuration list for PBXNativeTarget "TriNetVideo" */; buildPhases = ( 6D90D1F1C47C345671E3F8F9 /* Sources */, + A992AD0FA4A8B6D7F4F2E05C /* Frameworks */, ); buildRules = ( ); @@ -73,6 +138,7 @@ ); name = TriNetVideo; packageProductDependencies = ( + C4D0DDD6535CFA404CBF8DAA /* LiveKit */, ); productName = TriNetVideo; productReference = 5235533C9F22E43B48C7BE18 /* TriNetVideo.app */; @@ -98,6 +164,9 @@ ); mainGroup = A6C8AF585BF4DA374BF1E301; minimizedProjectReferenceProxies = 1; + packageReferences = ( + 7396F5F92AEBADE75587ABFF /* XCRemoteSwiftPackageReference "client-sdk-swift" */, + ); preferredProjectObjectVersion = 77; productRefGroup = BDEBCA25FD616B1244269FB0 /* Products */; projectDirPath = ""; @@ -114,9 +183,20 @@ buildActionMask = 2147483647; files = ( 93828957945393D93D1AE545 /* App.swift in Sources */, + 111558B34260E5B694D543EE /* AudioController.swift in Sources */, + 1410A21A73E9E82F0622FFE1 /* BackgroundBlur.swift in Sources */, + 094B0FDEEEA876B04E5C3700 /* CallIdentity.swift in Sources */, 7059B12C50899D17431177A1 /* CallManager.swift in Sources */, + 520A5853F1324286B7C9D328 /* CallRecorder.swift in Sources */, 40F017DE28896B7D0177AC7B /* CameraCapture.swift in Sources */, + F6CB4CE6A1336FEBE77BA83B /* InternetCall.swift in Sources */, + 131244A5A527881519B0BB77 /* LinkStatus.swift in Sources */, + 0FD4749EFC9F8923DD0BCB49 /* MeshCrypto.swift in Sources */, 66CCD9FFAD828B288E427269 /* MeshTransport.swift in Sources */, + EDE8E8CA5000C63D274F4D54 /* NicknameDirectory.swift in Sources */, + 12E514050EC8F765A8DC3472 /* OpusCodec.swift in Sources */, + 6D338808E3FCAF91BE192590 /* PeerDiscovery.swift in Sources */, + 925424B00856474500875967 /* ScreenCapture.swift in Sources */, F909D84E94FAA98C4929B709 /* VideoDecoder.swift in Sources */, E00B31F6C88D6223CF93B32F /* VideoEncoder.swift in Sources */, C42AEF335F11DD33CC802C77 /* Views.swift in Sources */, @@ -200,11 +280,8 @@ CODE_SIGNING_REQUIRED = NO; CODE_SIGN_IDENTITY = "-"; COMBINE_HIDPI_IMAGES = YES; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_KEY_CFBundleDisplayName = "TRI-NET Video"; - INFOPLIST_KEY_NSCameraUsageDescription = "Video calls over mesh"; - INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Mesh video transport"; - INFOPLIST_KEY_NSMicrophoneUsageDescription = "Audio for video calls"; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = TriNetVideo/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", @@ -282,11 +359,8 @@ CODE_SIGNING_REQUIRED = NO; CODE_SIGN_IDENTITY = "-"; COMBINE_HIDPI_IMAGES = YES; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_KEY_CFBundleDisplayName = "TRI-NET Video"; - INFOPLIST_KEY_NSCameraUsageDescription = "Video calls over mesh"; - INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Mesh video transport"; - INFOPLIST_KEY_NSMicrophoneUsageDescription = "Audio for video calls"; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = TriNetVideo/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", @@ -319,6 +393,25 @@ defaultConfigurationName = Debug; }; /* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + 7396F5F92AEBADE75587ABFF /* XCRemoteSwiftPackageReference "client-sdk-swift" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/livekit/client-sdk-swift.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 2.15.2; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + C4D0DDD6535CFA404CBF8DAA /* LiveKit */ = { + isa = XCSwiftPackageProductDependency; + package = 7396F5F92AEBADE75587ABFF /* XCRemoteSwiftPackageReference "client-sdk-swift" */; + productName = LiveKit; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 46F6CE57375BBA9D8E897E3E /* Project object */; } diff --git a/phone/desktop/TriNetVideo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/phone/desktop/TriNetVideo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 00000000..72f4fbfe --- /dev/null +++ b/phone/desktop/TriNetVideo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,42 @@ +{ + "originHash" : "f23dc9251b30242b9d104cd8d1c7e5c16c31c611a03561a7fa74a136843469d9", + "pins" : [ + { + "identity" : "client-sdk-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/livekit/client-sdk-swift.git", + "state" : { + "revision" : "77b5aad07909e23adf97d39f205ef7e18e2ceff5", + "version" : "2.15.2" + } + }, + { + "identity" : "livekit-uniffi-xcframework", + "kind" : "remoteSourceControl", + "location" : "https://github.com/livekit/livekit-uniffi-xcframework.git", + "state" : { + "revision" : "7c161254ce7cd55debc48023f69a917076b12a26", + "version" : "0.0.6" + } + }, + { + "identity" : "swift-protobuf", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-protobuf.git", + "state" : { + "revision" : "55d7a1cc5666b85c13464aea1c4b4a90feccb4c8", + "version" : "1.38.1" + } + }, + { + "identity" : "webrtc-xcframework", + "kind" : "remoteSourceControl", + "location" : "https://github.com/livekit/webrtc-xcframework.git", + "state" : { + "revision" : "46f2af86f06b9a8a9158d37cadda5cb5a214e4c4", + "version" : "144.7559.11" + } + } + ], + "version" : 3 +} diff --git a/phone/desktop/TriNetVideo/CallManager.swift b/phone/desktop/TriNetVideo/CallManager.swift index fe8d5d35..f40c567b 100644 --- a/phone/desktop/TriNetVideo/CallManager.swift +++ b/phone/desktop/TriNetVideo/CallManager.swift @@ -48,6 +48,13 @@ class CallManager: ObservableObject { @Published var isInCall = false @Published var isStarting = false @Published var remoteIP = "192.168.1.103" + @Published var callee = UserDefaults.standard.string(forKey: "internetCallee") ?? "ssd26" + @Published var route = CallRoute(rawValue: UserDefaults.standard.string(forKey: "callRoute") ?? "Auto") ?? .automatic + @Published private(set) var activeRoute: CallRoute? + @Published var identity: DeviceIdentity + @Published var internetConfiguration: InternetCallConfiguration + @Published var incomingMeshCall: IncomingMeshCall? + @Published var incomingInternetCall: IncomingInternetCall? @Published var port = "7000" @Published var localIP = "" @Published var framesSent = 0 @@ -60,6 +67,24 @@ class CallManager: ObservableObject { var chatOpen = false { didSet { if chatOpen { unreadChat = 0 } } } // panel open => clear the badge private let chatChime = ChatChime() // Trinity-style blip on an incoming chat message @Published var recentIPs: [String] = [] + /// Saved nicknames for one-tap calling. Persisted. Tapping calls on the + /// selected route (Internet default; switch to Mesh in Connection for LAN). + @Published var savedContacts: [String] = UserDefaults.standard.stringArray(forKey: "savedContacts") ?? [] { + didSet { UserDefaults.standard.set(savedContacts, forKey: "savedContacts") } + } + func addContact(_ raw: String) { + let nick = NicknamePolicy.normalize(raw) + guard nick.count >= 3, !savedContacts.contains(nick) else { return } + savedContacts.append(nick) + } + func removeContact(_ nick: String) { + savedContacts.removeAll { $0 == nick } + } + func callNickname(_ nick: String) { + callee = nick + directory.searchQuery = nick + startCall() + } @Published var cameras: [AVCaptureDevice] = [] @Published var selectedCameraID: String = "" // Live audio levels (0...1) for the TX/RX meters. Decayed on the main @@ -74,6 +99,7 @@ class CallManager: ObservableObject { // mean the peer is losing our video → back off; a clean window → recover. private var pliCount = 0 private var abrTimer: Timer? + private var meshAttemptID: UUID? // The node's verdict on the link, if one is relaying for us. Nil on a direct // peer-to-peer call: there is no node, so there is nothing to hear. private var linkAdvice: UInt8? @@ -189,6 +215,28 @@ class CallManager: ObservableObject { @Published var selectedUIDs: Set = [] init() { + let loadedIdentity: DeviceIdentity + do { + loadedIdentity = try DeviceIdentityStore.shared.loadOrCreate(defaultName: "TRI-NET Mac") + } catch { + loadedIdentity = DeviceIdentity(userID: UUID().uuidString.lowercased(), + deviceID: UUID().uuidString.lowercased(), + displayName: "TRI-NET Mac", + nickname: nil, + signingPublicKey: "", + keyFingerprint: "unavailable") + } + let loadedConfiguration = InternetCallConfiguration.load() + identity = loadedIdentity + internetConfiguration = loadedConfiguration + internet = InternetCallController(identity: loadedIdentity, configuration: loadedConfiguration) + directory = NicknameDirectoryController(identity: loadedIdentity, configuration: loadedConfiguration) + account = AccountDeviceController(identity: loadedIdentity, configuration: loadedConfiguration) + groupChat = GroupChatController(identity: loadedIdentity, configuration: loadedConfiguration) + // Chime on a newly-arrived group message authored by someone else. + groupChat.onNewMessage = { [weak self] _ in + DispatchQueue.main.async { self?.chatChime.play() } + } LogBus.shared.start() // tee stderr (where every NSLog lands) into the UI localIP = MeshTransport.getLocalIP() // Load recent IPs from UserDefaults @@ -197,6 +245,39 @@ class CallManager: ObservableObject { } cameras = CameraCapture.availableCameras() selectedCameraID = AVCaptureDevice.default(for: .video)?.uniqueID ?? cameras.first?.uniqueID ?? "" + internet.onChat = { [weak self] text in + self?.chat.append(ChatLine(who: .them, text: text)) + } + internet.onReaction = { [weak self] value in + self?.showReaction(value) + } + internet.onIncomingCall = { [weak self] incoming in + guard let self, !self.isInCall, !self.isStarting else { return } + self.incomingInternetCall = incoming + } + directory.onIdentityChanged = { [weak self] updatedIdentity in + guard let self else { return } + self.identity = updatedIdentity + self.internet.update(identity: updatedIdentity, configuration: self.internetConfiguration) + self.account.update(identity: updatedIdentity, configuration: self.internetConfiguration) + self.groupChat.update(identity: updatedIdentity, configuration: self.internetConfiguration) + self.internet.startIncomingPolling() + self.account.sync() + } + account.onIdentityChanged = { [weak self] updatedIdentity in + guard let self else { return } + self.identity = updatedIdentity + self.internet.update(identity: updatedIdentity, configuration: self.internetConfiguration) + self.directory.update(identity: updatedIdentity, configuration: self.internetConfiguration) + self.groupChat.update(identity: updatedIdentity, configuration: self.internetConfiguration) + } + directory.onIncomingMeshInvite = { [weak self] invite, address in + guard let self, !self.isInCall, !self.isStarting else { return } + self.incomingMeshCall = IncomingMeshCall(invite: invite, sourceAddress: address) + } + internet.startIncomingPolling() + account.sync() + groupChat.startPolling() discovery.start() // advertise + browse from launch startIdleListener() // listen on :7000 for incoming calls while idle } @@ -422,6 +503,12 @@ class CallManager: ObservableObject { // -- reject it so any LAN host can't pop the incoming-call UI (and block real INVITEs for 40s). guard !participants.isEmpty else { continue } let room = parts.count > 2 ? parts[2] : "" + // ANTI-REPLAY: reject a stale (or timestamp-less) INVITE. A valid HMAC only proves the sender + // knew the PSK once; the freshness window stops a captured INVITE from being replayed later. + // +/-15s tolerates Mac<->iPhone clock skew. + let tsMs = parts.count > 3 ? (Int64(parts[3]) ?? 0) : 0 + let nowMs = Int64(Date().timeIntervalSince1970 * 1000) + guard tsMs != 0, abs(nowMs - tsMs) <= 15_000 else { continue } let ip = String(cString: inet_ntoa(from.sin_addr)) DispatchQueue.main.async { guard let self = self, !self.isInCall, self.incomingCall == nil else { return } // don't ring mid-call / twice @@ -456,8 +543,11 @@ class CallManager: ObservableObject { // Caller side: ring each target's :7000 a few times (UDP is lossy) from a throwaway socket. // `participants` = every IP in this call (including me), so the callee can rejoin the FULL mesh. func sendInvite(to ips: [String], participants: [String]) { - // payload = "name\nip1,ip2\nROOM" — the room lets a same-room callee auto-accept (one-tap group). - let payload = PeerDiscovery.myName + "\n" + participants.joined(separator: ",") + "\n" + PeerDiscovery.myRoom + // payload = "name\nip1,ip2\nROOM\nTS_MS" — the room lets a same-room callee auto-accept (one-tap group); + // TS_MS is a freshness timestamp so a sniffed-and-replayed INVITE (even with a valid HMAC) is rejected + // as stale. The HMAC covers the whole payload including TS, so an attacker can't rewrite the timestamp. + let tsMs = Int64(Date().timeIntervalSince1970 * 1000) + let payload = PeerDiscovery.myName + "\n" + participants.joined(separator: ",") + "\n" + PeerDiscovery.myRoom + "\n" + String(tsMs) NSLog("TRINET: ringing \(ips.joined(separator: ",")) with INVITE (participants: \(participants.joined(separator: ",")))") // MUST NOT use idleQueue: startCall() just closed the idle socket, but a blocked recvfrom on that // serial queue may not wake (POSIX close() doesn't reliably interrupt it), which would leave the @@ -572,6 +662,10 @@ class CallManager: ObservableObject { let decoder = VideoDecoder() let transport = MeshTransport() let audio = AudioController() + let internet: InternetCallController + let directory: NicknameDirectoryController + let account: AccountDeviceController + let groupChat: GroupChatController private var screen: Any? // ScreenCapture (macOS 12.3+), lazily created private let recorder = CallRecorder() private var recSink: AnyCancellable? @@ -636,12 +730,22 @@ class CallManager: ObservableObject { func sendChat(_ text: String) { let t = text.trimmingCharacters(in: .whitespacesAndNewlines) guard !t.isEmpty else { return } + if activeRoute == .internet { + internet.sendChat(t) + chat.append(ChatLine(who: .me, text: t)) + return + } var d = Data([0xFB, 0xCA]); d.append(Data(t.utf8)) transport.send(d) chat.append(ChatLine(who: .me, text: t)) } func sendReaction(_ emoji: String) { + if activeRoute == .internet { + internet.sendReaction(emoji) + showReaction(emoji) + return + } var d = Data([0xFE, 0xAC]); d.append(Data(emoji.utf8)) transport.send(d) showReaction(emoji) @@ -695,10 +799,155 @@ class CallManager: ObservableObject { } func startCall() { + error = nil + let typedTarget = directory.searchQuery.trimmingCharacters(in: .whitespacesAndNewlines) + let target = NicknamePolicy.normalize(typedTarget.isEmpty ? callee : typedTarget) + callee = target + let meshContact = directory.meshContact(named: target) + let selected: CallRoute + if route == .automatic { + if isMeshAddress(target) { + remoteIP = target + selected = .mesh + } else if let address = meshContact?.meshAddress { + remoteIP = address + selected = .mesh + } else { + selected = .internet + } + } else { + selected = route + } + if selected == .mesh { + if isMeshAddress(target) { + remoteIP = target + } else if let address = meshContact?.meshAddress { + remoteIP = address + } else { + error = "@\(target) is not visible in the current mesh." + activeRoute = nil + return + } + } + activeRoute = selected + UserDefaults.standard.set(route.rawValue, forKey: "callRoute") + if selected == .internet { + startInternetCall() + } else { + do { + _ = try directory.sendMeshInvite(to: remoteIP, port: meshContact?.meshPort) + } catch { + self.error = error.localizedDescription + activeRoute = nil + return + } + startMeshCall() + } + } + + func acceptIncomingMeshCall() { + guard let incoming = incomingMeshCall else { return } + incomingMeshCall = nil + callee = incoming.invite.nickname + remoteIP = incoming.sourceAddress + activeRoute = .mesh + startMeshCall() + } + + func declineIncomingMeshCall() { + incomingMeshCall = nil + } + + func acceptIncomingInternetCall() { + guard let incoming = incomingInternetCall else { return } + incomingInternetCall = nil + callee = incoming.caller + activeRoute = .internet + isStarting = true + status = "Joining Internet call..." + internet.update(identity: identity, configuration: internetConfiguration) + Task { [weak self] in + guard let self else { return } + do { + try await self.internet.join(callID: incoming.callID, + audio: incoming.audio, + video: incoming.video) + await MainActor.run { + self.isStarting = false + self.isInCall = true + self.status = "Connected via WebRTC" + } + } catch { + await MainActor.run { + self.isStarting = false + self.activeRoute = nil + self.status = "Ready" + self.error = error.localizedDescription + } + } + } + } + + func declineIncomingInternetCall() { + incomingInternetCall = nil + } + + func claimNickname() { + directory.claimProposedNickname() + } + + func searchNicknames() { + let target = NicknamePolicy.normalize(directory.searchQuery) + if !target.isEmpty { callee = target } + directory.search() + } + + func selectContact(_ contact: DirectoryContact) { + callee = contact.nickname + directory.searchQuery = contact.nickname + if let address = contact.meshAddress { remoteIP = address } + route = .automatic + } + + private func startInternetCall() { + let target = callee.trimmingCharacters(in: .whitespacesAndNewlines) + guard !target.isEmpty else { + error = "Enter a contact or device name." + activeRoute = nil + return + } + UserDefaults.standard.set(target, forKey: "internetCallee") + internet.update(identity: identity, configuration: internetConfiguration) + isStarting = true + status = "Connecting to \(target)..." + Task { [weak self] in + guard let self else { return } + do { + try await self.internet.start(callee: target, audio: true, video: true) + await MainActor.run { + self.isStarting = false + self.isInCall = true + self.status = "Connected via WebRTC" + } + } catch { + await MainActor.run { + self.isStarting = false + self.isInCall = false + self.activeRoute = nil + self.status = "Ready" + self.error = error.localizedDescription + } + } + } + } + + private func startMeshCall() { guard let p = UInt16(port) else { NSLog("TRINET: invalid port"); return } NSLog("TRINET: startCall to \(remoteIP):\(p)") isStarting = true status = "Connecting to \(remoteIP)..." + let attemptID = UUID() + meshAttemptID = attemptID stopIdleListener() // the encrypted transport is about to own :7000 // Save IP to recent @@ -851,23 +1100,40 @@ class CallManager: ObservableObject { isGroup = true transport.connectGroup(peerHosts: hosts, peerPort: p, listenPort: p) NSLog("TRINET: group call — \(hosts.count) peers") + meshAttemptID = nil + isInCall = true + isStarting = false + status = "Connected to encrypted UDP group" } else { isGroup = false + transport.onSecureSessionReady = { [weak self] in + guard let self, self.meshAttemptID == attemptID else { return } + self.meshAttemptID = nil + self.isInCall = true + self.isStarting = false + self.status = "Connected via encrypted local UDP" + } transport.connect(peerHost: remoteIP, peerPort: p, listenPort: p) + DispatchQueue.main.asyncAfter(deadline: .now() + 30) { [weak self] in + guard let self, self.meshAttemptID == attemptID, self.isStarting else { return } + self.error = "The local peer did not accept the call within 30 seconds." + self.endCall() + } } let hostStrs = hosts.map { String($0) } sendInvite(to: hostStrs, participants: [localIP] + hostStrs) // ring the callee(s); carry the full roster - isInCall = true callStartedAt = Date() // for the recent-call journal duration callStalls = 0 discovery.inCall = true // advertise "in call" so the roster shows my status - isStarting = false status = "Calling \(remoteIP)…" // Caller-side ring feedback: if nothing arrives in 30s, say so instead of "Waiting" forever. noAnswerTimer?.invalidate() noAnswerTimer = Timer.scheduledTimer(withTimeInterval: 30, repeats: false) { [weak self] _ in - guard let self = self, self.isInCall, self.framesReceived == 0 else { return } + guard let self = self, + self.activeRoute == .mesh, + (self.isStarting || self.isInCall), + self.framesReceived == 0 else { return } self.status = "No answer" NSLog("TRINET: no answer from \(self.remoteIP) after 30s") } @@ -877,6 +1143,14 @@ class CallManager: ObservableObject { } func endCall() { + if activeRoute == .internet { + internet.disconnect() + isInCall = false + isStarting = false + activeRoute = nil + status = "Idle" + return + } // Journal a COMPLETED call (frames actually flowed) with its duration + average link quality, // BEFORE the history arrays are reset below. if let started = callStartedAt, framesReceived > 0 || framesSent > 0 { @@ -899,6 +1173,7 @@ class CallManager: ObservableObject { camera.stop() audio.stop() transport.disconnect() + meshAttemptID = nil isInCall = false discovery.inCall = false isGroup = false @@ -909,6 +1184,47 @@ class CallManager: ObservableObject { framesSent = 0 framesReceived = 0 previewSession = nil - startIdleListener() // resume listening for incoming calls + activeRoute = nil + startIdleListener() // resume listening for incoming mesh calls + } + + func saveInternetSettings() { + internetConfiguration.save() + UserDefaults.standard.set(route.rawValue, forKey: "callRoute") + internet.update(identity: identity, configuration: internetConfiguration) + directory.update(identity: identity, configuration: internetConfiguration) + account.update(identity: identity, configuration: internetConfiguration) + groupChat.update(identity: identity, configuration: internetConfiguration) + internet.startIncomingPolling() + account.sync() + } + + func renameDevice(_ name: String) { + do { + identity = try DeviceIdentityStore.shared.rename(name) + internet.update(identity: identity, configuration: internetConfiguration) + directory.update(identity: identity, configuration: internetConfiguration) + account.update(identity: identity, configuration: internetConfiguration) + groupChat.update(identity: identity, configuration: internetConfiguration) + } catch { + self.error = error.localizedDescription + } + } + + func toggleMute() { + isMuted.toggle() + if activeRoute == .internet { internet.setMuted(isMuted) } + } + + func toggleCamera() { + cameraOff.toggle() + if activeRoute == .internet { internet.setCamera(enabled: !cameraOff) } + } + + private func isMeshAddress(_ value: String) -> Bool { + let address = value.trimmingCharacters(in: .whitespacesAndNewlines) + if address.hasSuffix(".local") { return true } + let parts = address.split(separator: ".") + return parts.count == 4 && parts.allSatisfy { Int($0).map { (0...255).contains($0) } ?? false } } } diff --git a/phone/desktop/TriNetVideo/Info.plist b/phone/desktop/TriNetVideo/Info.plist new file mode 100644 index 00000000..8d9e8bd2 --- /dev/null +++ b/phone/desktop/TriNetVideo/Info.plist @@ -0,0 +1,39 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + TRI-NET Video + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + NSAppTransportSecurity + + NSAllowsLocalNetworking + + + NSBonjourServices + + _trinet-call._udp + + NSCameraUsageDescription + TRI-NET needs camera for secure local and internet video calls + NSLocalNetworkUsageDescription + TRI-NET discovers signed contacts and connects over local UDP or a routed radio mesh + NSMicrophoneUsageDescription + TRI-NET needs microphone for video calls + + diff --git a/phone/desktop/TriNetVideo/MeshTransport.swift b/phone/desktop/TriNetVideo/MeshTransport.swift index bdd2214c..fc234ed8 100644 --- a/phone/desktop/TriNetVideo/MeshTransport.swift +++ b/phone/desktop/TriNetVideo/MeshTransport.swift @@ -19,6 +19,7 @@ class MeshTransport { // timer scheduled on it would never fire. private let hsQueue = DispatchQueue(label: "mesh.hs", qos: .userInitiated) var onReceive: ((Data) -> Void)? + var onSecureSessionReady: (() -> Void)? // Group calls need to know WHO sent each datagram (per-source decoding + // roster), so recvfrom carries the sender IP up alongside the payload. var onReceiveFrom: ((Data, String) -> Void)? @@ -147,6 +148,8 @@ class MeshTransport { // 1-1 (ephemeral forward-secret) — unchanged path. func connect(peerHost: String, peerPort: UInt16, listenPort: UInt16) { disconnect() + crypto = MeshCrypto() + secureReadyEmitted = false groupMode = false startFeedbackListener() @@ -229,6 +232,7 @@ class MeshTransport { // 1-1 ephemeral path if self.crypto.isHandshake(pkt) { self.crypto.consumeHandshake(pkt) + self.emitSecureReadyIfNeeded() self.rawSendWire(self.crypto.handshakePacket()) continue } @@ -290,8 +294,15 @@ class MeshTransport { // MARK: forward-secret session (see MeshCrypto). Data is sealed under a // per-connection ephemeral session key; the static PSK only authenticates // the handshake, so a later PSK leak can't decrypt recorded traffic. - private let crypto = MeshCrypto() + private var crypto = MeshCrypto() private var handshakeTimer: DispatchSourceTimer? + private var secureReadyEmitted = false + + private func emitSecureReadyIfNeeded() { + guard crypto.established, !secureReadyEmitted else { return } + secureReadyEmitted = true + DispatchQueue.main.async { self.onSecureSessionReady?() } + } func send(_ data: Data) { guard fd >= 0 else { return } diff --git a/phone/desktop/TriNetVideo/Views.swift b/phone/desktop/TriNetVideo/Views.swift index a8c04f7c..afdc59dd 100644 --- a/phone/desktop/TriNetVideo/Views.swift +++ b/phone/desktop/TriNetVideo/Views.swift @@ -2,6 +2,7 @@ import SwiftUI import AVFoundation import AppKit +import LiveKit // MARK: - Main Entry View @@ -19,6 +20,18 @@ struct CallView: View { } } .preferredColorScheme(.dark) + .alert(item: $call.incomingMeshCall) { incoming in + Alert(title: Text("Incoming local call"), + message: Text("@\(incoming.invite.nickname) wants to start an encrypted UDP call."), + primaryButton: .default(Text("Accept"), action: call.acceptIncomingMeshCall), + secondaryButton: .cancel(Text("Decline"), action: call.declineIncomingMeshCall)) + } + .alert(item: $call.incomingInternetCall) { incoming in + Alert(title: Text("Incoming Internet call"), + message: Text("@\(incoming.caller) is calling through WebRTC."), + primaryButton: .default(Text("Accept"), action: call.acceptIncomingInternetCall), + secondaryButton: .cancel(Text("Decline"), action: call.declineIncomingInternetCall)) + } } } @@ -26,9 +39,23 @@ struct CallView: View { struct HomeScreen: View { @EnvironmentObject var call: CallManager + @State private var showSettings = false + @State private var showNicknameSetup = false var body: some View { VStack(spacing: 32) { + HStack { + Text("TRI-NET") + .font(.system(size: 15, weight: .bold, design: .rounded)) + Spacer() + Button(action: { showSettings = true }) { + Image(systemName: "gearshape") + .frame(width: 32, height: 32) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 24) + Spacer() // App icon / camera button @@ -56,19 +83,46 @@ struct HomeScreen: View { .font(.system(size: 24, weight: .bold, design: .rounded)) .foregroundColor(.white) - Text("Encrypted mesh video calls") + Text("Encrypted mesh and internet video calls") .font(.system(size: 13)) .foregroundColor(.gray) } + Button(action: { showNicknameSetup = true }) { + HStack(spacing: 8) { + Image(systemName: call.directory.currentNickname == nil ? "person.crop.circle.badge.plus" : "checkmark.seal.fill") + Text(call.directory.currentNickname.map { "@\($0)" } ?? "Create your nickname") + .font(.system(size: 13, weight: .medium, design: .monospaced)) + Text(call.directory.claimKind == .verified ? "VERIFIED" : + call.directory.claimKind == .meshLocal ? "MESH" : "NEW") + .font(.system(size: 9, weight: .bold, design: .monospaced)) + .foregroundColor(call.directory.claimKind == .verified ? .green : + call.directory.claimKind == .meshLocal ? .orange : .gray) + } + .padding(.horizontal, 14).padding(.vertical, 8) + .background(Color.white.opacity(0.08), in: Capsule()) + } + .buttonStyle(.plain) + // IP configuration VStack(spacing: 12) { + Picker("Route", selection: $call.route) { + ForEach(CallRoute.allCases) { route in + Text(route.displayName).tag(route) + } + } + .pickerStyle(.segmented) + .padding(.horizontal, 24) + HStack(spacing: 12) { Image(systemName: "person.crop.circle") .foregroundColor(.gray) .font(.system(size: 18)) - TextField("Remote IP Address", text: $call.remoteIP) + TextField(call.route == .mesh ? "Nickname or IP" : "Nickname", text: Binding( + get: { call.directory.searchQuery }, + set: { call.directory.searchQuery = $0 } + )) .textFieldStyle(.plain) .font(.system(size: 16, design: .monospaced)) .foregroundColor(.white) @@ -80,19 +134,38 @@ struct HomeScreen: View { RoundedRectangle(cornerRadius: 10) .stroke(Color.blue.opacity(0.3), lineWidth: 1) ) + .onSubmit { call.searchNicknames() } + + Button(action: { call.searchNicknames() }) { + Image(systemName: "magnifyingglass") + } + .buttonStyle(.plain) } .padding(.horizontal, 24) + if !call.directory.results.isEmpty { + VStack(spacing: 7) { + ForEach(call.directory.results.prefix(3)) { contact in + MacDirectoryContactButton(contact: contact) { call.selectContact(contact) } + } + } + .padding(.horizontal, 24) + } + // Show your IP HStack { Image(systemName: "wifi") .foregroundColor(.green) .font(.system(size: 12)) - Text("You: \(call.localIP):7001") + Text("You: \(call.identity.nickname.map { "@\($0)" } ?? call.identity.displayName) | \(call.identity.keyFingerprint)") .font(.system(size: 12, design: .monospaced)) .foregroundColor(.green.opacity(0.8)) } + if let error = call.error { + Text(error).font(.system(size: 12)).foregroundColor(.red).multilineTextAlignment(.center) + } + // Recent IPs if !call.recentIPs.isEmpty { HStack(spacing: 8) { @@ -130,11 +203,209 @@ struct HomeScreen: View { .shadow(color: .green.opacity(0.3), radius: 10) } .buttonStyle(.plain) - .disabled(call.remoteIP.isEmpty) - .opacity(call.remoteIP.isEmpty ? 0.5 : 1.0) + .disabled(call.directory.searchQuery.isEmpty && call.callee.isEmpty) + .opacity((call.directory.searchQuery.isEmpty && call.callee.isEmpty) ? 0.5 : 1.0) Spacer() } + .sheet(isPresented: $showSettings) { + MacCallSettingsView(call: call) + } + .sheet(isPresented: $showNicknameSetup) { + MacNicknameSetupView(call: call) + } + } +} + +private struct MacDirectoryContactButton: View { + let contact: DirectoryContact + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: 10) { + Circle().fill(contact.online ? Color.green : Color.gray).frame(width: 7, height: 7) + Text("@\(contact.nickname)").font(.system(size: 12, weight: .medium, design: .monospaced)) + Spacer() + Text(contact.source.rawValue).font(.system(size: 9, weight: .bold, design: .monospaced)) + .foregroundColor(contact.source == .mesh ? .orange : .green) + } + .padding(.horizontal, 12).padding(.vertical, 8) + .background(Color.white.opacity(0.08), in: RoundedRectangle(cornerRadius: 10)) + } + .buttonStyle(.plain) + } +} + +private struct MacNicknameSetupView: View { + @ObservedObject var call: CallManager + @ObservedObject private var directory: NicknameDirectoryController + @Environment(\.dismiss) private var dismiss + + init(call: CallManager) { + self.call = call + directory = call.directory + } + + var body: some View { + VStack(alignment: .leading, spacing: 18) { + HStack { + Text("Create Nickname").font(.title2.bold()) + Spacer() + Button("Done") { dismiss() } + } + HStack { + Text("@").foregroundColor(.secondary) + TextField("nickname", text: $directory.proposedNickname) + .textFieldStyle(.roundedBorder) + } + Text("Use 3-20 lowercase letters, numbers, or underscore. The first character must be a letter.") + .font(.caption).foregroundColor(.secondary) + Button(directory.isWorking ? "Checking..." : "Check and create") { call.claimNickname() } + .disabled(directory.isWorking) + if let message = directory.statusMessage { + Text(message).font(.callout) + } + if !directory.suggestions.isEmpty { + Text("Alternatives").font(.headline) + HStack { + ForEach(directory.suggestions, id: \.self) { suggestion in + Button("@\(suggestion)") { + directory.proposedNickname = suggestion + call.claimNickname() + } + } + } + } + Divider() + Text(directory.claimKind == .verified ? "Globally verified" : + directory.claimKind == .meshLocal ? "Mesh-local until the Directory API confirms uniqueness" : + "Choose a nickname to register this device") + .font(.caption).foregroundColor(.secondary) + Spacer() + } + .padding(24) + .frame(width: 520, height: 340) + .onChange(of: directory.currentNickname) { current in + if current != nil { dismiss() } + } + } +} + +private struct MacCallSettingsView: View { + @ObservedObject var call: CallManager + @ObservedObject private var account: AccountDeviceController + @Environment(\.dismiss) private var dismiss + + init(call: CallManager) { + self.call = call + account = call.account + } + + var body: some View { + VStack(alignment: .leading, spacing: 18) { + HStack { + Text("Call Settings").font(.title2.bold()) + Spacer() + Button("Done") { dismiss() } + } + + Form { + Section("Identity") { + TextField("Device name", text: Binding( + get: { call.identity.displayName }, + set: { call.renameDevice($0) } + )) + LabeledContent("Device ID", value: String(call.identity.deviceID.prefix(16))) + LabeledContent("Key fingerprint", value: call.identity.keyFingerprint) + LabeledContent("Nickname", value: call.identity.nickname.map { "@\($0)" } ?? "Not created") + } + + Section("Owner account") { + LabeledContent("Account ID", value: String(account.accountID.prefix(16))) + Text("Each Mac or iPhone has a separate revocable signing key; no shared password or private key is copied between devices.") + .font(.caption).foregroundColor(.secondary) + Button(account.isWorking ? "Syncing..." : "Sync Account") { account.sync() } + .disabled(account.isWorking) + } + + Section("Add Your Device") { + HStack { + Button("Create One-Time Code") { account.createLinkCode() } + .disabled(account.isWorking) + if let code = account.generatedLinkCode { + Text(code).font(.system(.caption, design: .monospaced)).textSelection(.enabled) + Button("Copy") { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(code, forType: .string) + } + } + } + HStack { + TextField("link_... from trusted device", text: $account.linkCodeInput) + Button("Link This Mac") { account.joinAccount() } + .disabled(account.isWorking) + } + Text("The 128-bit code expires in 10 minutes and is accepted once. Passkey recovery will be enabled when the production HTTPS domain is associated with the app.") + .font(.caption).foregroundColor(.secondary) + } + + if !account.devices.isEmpty { + Section("Your Devices") { + ForEach(account.devices) { device in + HStack { + VStack(alignment: .leading) { + Text(device.displayName + (device.current ? " (this Mac)" : "")) + Text("\(device.platform) · \(device.keyFingerprint)") + .font(.caption).foregroundColor(.secondary) + } + Spacer() + if device.revoked { + Text("Revoked").foregroundColor(.secondary) + } else if !device.current { + Button("Revoke", role: .destructive) { account.revoke(device) } + } + } + } + } + } + + if let message = account.statusMessage { + Section("Account Status") { Text(message).font(.caption) } + } + + Section("Routing") { + Picker("Route", selection: $call.route) { + ForEach(CallRoute.allCases) { route in + Text(route.displayName).tag(route) + } + } + TextField("Contact or device", text: $call.callee) + TextField("Mesh peer IP", text: $call.remoteIP) + } + + Section("Internet service") { + TextField("API URL", text: $call.internetConfiguration.apiBaseURL) + TextField("LiveKit URL", text: $call.internetConfiguration.liveKitURL) + SecureField("Development room token", text: $call.internetConfiguration.developmentRoomToken) + SecureField("Service access token", text: $call.internetConfiguration.accessToken) + } + } + + HStack { + if let error = call.error { + Text(error).foregroundColor(.red).font(.caption) + } + Spacer() + Button("Save") { + call.saveInternetSettings() + dismiss() + } + .keyboardShortcut(.defaultAction) + } + } + .padding(24) + .frame(width: 680, height: 720) } } @@ -148,7 +419,13 @@ struct ActiveCallView: View { var body: some View { ZStack { // Full-screen remote video - RemoteVideoView(decoder: call.decoder) + Group { + if call.activeRoute == .internet { + MacInternetVideoView(controller: call.internet, peer: call.callee) + } else { + RemoteVideoView(decoder: call.decoder) + } + } .ignoresSafeArea() .onTapGesture { withAnimation(.easeInOut(duration: 0.2)) { @@ -160,7 +437,17 @@ struct ActiveCallView: View { VStack { HStack { Spacer() - if let session = call.previewSession { + if call.activeRoute == .internet, let track = call.internet.localVideoTrack { + SwiftUIVideoView(track, layoutMode: .fill, mirrorMode: .mirror) + .frame(width: 140, height: 105) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .overlay(RoundedRectangle(cornerRadius: 12).stroke(.white.opacity(0.3), lineWidth: 2)) + .shadow(color: .black.opacity(0.5), radius: 8) + .offset(pipOffset) + .gesture(DragGesture().onChanged { pipOffset = $0.translation }) + .padding(.trailing, 16) + .padding(.top, 16) + } else if let session = call.previewSession { CameraPreview(session: session) .frame(width: 140, height: 105) .clipShape(RoundedRectangle(cornerRadius: 12)) @@ -204,13 +491,13 @@ struct ActiveCallView: View { // Mute toggle ControlButton(icon: call.isMuted ? "mic.slash.fill" : "mic.fill", color: call.isMuted ? .red : Color.white.opacity(0.3)) { - call.isMuted.toggle() + call.toggleMute() } // Camera toggle ControlButton(icon: call.cameraOff ? "video.slash.fill" : "video.fill", color: call.cameraOff ? .red : Color.white.opacity(0.3)) { - call.cameraOff.toggle() + call.toggleCamera() } // End call @@ -234,6 +521,29 @@ struct ActiveCallView: View { } } +private struct MacInternetVideoView: View { + @ObservedObject var controller: InternetCallController + let peer: String + + var body: some View { + ZStack { + Color.black + if let track = controller.remoteVideoTrack { + SwiftUIVideoView(track, layoutMode: .fill) + } else { + VStack(spacing: 12) { + ProgressView() + Text(controller.state.rawValue.uppercased()) + .font(.system(size: 12, design: .monospaced)) + Text(controller.participantName.isEmpty ? peer : controller.participantName) + .font(.system(size: 11, design: .monospaced)) + .foregroundColor(.gray) + } + } + } + } +} + // MARK: - Reusable Components struct ControlButton: View { diff --git a/phone/desktop/VideoCallTab.swift b/phone/desktop/VideoCallTab.swift index 0a61a42e..c23ad04c 100644 --- a/phone/desktop/VideoCallTab.swift +++ b/phone/desktop/VideoCallTab.swift @@ -25,6 +25,18 @@ struct VideoCallTab: View { StartCallView(call: call) } } + .alert(item: $call.incomingMeshCall) { incoming in + Alert(title: Text("Incoming local call"), + message: Text("@\(incoming.invite.nickname) wants to start an encrypted UDP call."), + primaryButton: .default(Text("Accept"), action: call.acceptIncomingMeshCall), + secondaryButton: .cancel(Text("Decline"), action: call.declineIncomingMeshCall)) + } + .alert(item: $call.incomingInternetCall) { incoming in + Alert(title: Text("Incoming Internet call"), + message: Text("@\(incoming.caller) is calling through WebRTC."), + primaryButton: .default(Text("Accept"), action: call.acceptIncomingInternetCall), + secondaryButton: .cancel(Text("Decline"), action: call.declineIncomingInternetCall)) + } // Incoming-call banner: macOS convention is a corner/top notification card, // not a full-screen takeover (a Mac is multi-window). Floats above either view. .overlay(alignment: .top) { @@ -193,30 +205,190 @@ private struct IncomingCallBanner: View { private struct StartCallView: View { @ObservedObject var call: CallManager + @ObservedObject private var groupChat: GroupChatController + @State private var showNickname = false + @State private var showInternetSettings = false + @State private var showGroupChats = false + @State private var newContactNick = "" + + init(call: CallManager) { + self.call = call + groupChat = call.groupChat + } var body: some View { VStack(spacing: 18) { - Text("Video Call").font(DS.display(28, .semibold)).tracking(-0.5) - .foregroundColor(DS.text) + HStack { + Spacer().frame(width: 76) + Spacer() + Text("Video Call").font(DS.display(28, .semibold)).tracking(-0.5) + .foregroundColor(DS.text) + Spacer() + HStack(spacing: 4) { + Button(action: { showGroupChats = true }) { + Image(systemName: groupChat.chats.isEmpty ? "bubble.left.and.bubble.right" : "bubble.left.and.bubble.right.fill") + .foregroundColor(DS.dim).frame(width: 36, height: 36) + .overlay(alignment: .topTrailing) { + if groupChat.totalUnread > 0 { + Text("\(min(groupChat.totalUnread, 99))") + .font(.caption2.weight(.bold)) + .foregroundColor(.white) + .padding(.horizontal, 5).padding(.vertical, 1) + .background(Capsule().fill(Color.red)) + .offset(x: 6, y: -4) + .accessibilityLabel("\(groupChat.totalUnread) unread group messages") + } + } + }.buttonStyle(.plain) + Button(action: { showInternetSettings = true }) { + Image(systemName: "gearshape").foregroundColor(DS.dim).frame(width: 36, height: 36) + }.buttonStyle(.plain) + } + } // Say what this actually is. The call is direct UDP between two IP // peers over whatever interface the OS routes by (Wi-Fi today) — the // radio mesh is a separate subsystem and is NOT in this path. The old // "Encrypted mesh" line implied otherwise. - Text("Encrypted peer-to-peer · forward-secret") + Text("Encrypted local UDP | LiveKit WebRTC") .font(DS.ui(13)).foregroundColor(DS.dim) + Button(action: { showNickname = true }) { + HStack(spacing: 8) { + Image(systemName: call.directory.currentNickname == nil ? + "person.crop.circle.badge.plus" : "checkmark.seal.fill") + Text(call.directory.currentNickname.map { "@\($0)" } ?? "Create your nickname") + .font(DS.mono(12, .medium)) + Text(call.directory.claimKind == .verified ? "VERIFIED" : + call.directory.claimKind == .meshLocal ? "MESH-LOCAL" : "NEW") + .font(DS.mono(9, .bold)) + .foregroundColor(call.directory.claimKind == .verified ? DS.live : .orange) + } + .padding(.horizontal, 14).padding(.vertical, 8) + .dsCard(12) + }.buttonStyle(.plain) + VStack(spacing: 12) { + // Connection: Internet (default) or Local Mesh. Collapsed so the + // common case needs no fiddling. + DisclosureGroup("Connection: \(call.route == .automatic ? "Auto" : call.route == .mesh ? "Local Mesh" : "Internet")") { + Picker("Route", selection: $call.route) { + Text("Auto").tag(CallRoute.automatic) + Text("Internet").tag(CallRoute.internet) + Text("Local/Mesh UDP").tag(CallRoute.mesh) + } + .pickerStyle(.segmented) + .frame(width: 430) + .padding(.top, 6) + } + .font(DS.mono(10)).foregroundColor(DS.dim) + .frame(width: 430, alignment: .leading) + + // Add a contact by nickname; tap them to call. HStack(spacing: 8) { - SectionLabel(text: "Peer") - TextField("IP", text: $call.remoteIP) + Image(systemName: "person.badge.plus").foregroundColor(DS.dim) + TextField("add by @nickname", text: $newContactNick) .textFieldStyle(.plain).font(DS.mono(14)).foregroundColor(DS.text) - .frame(width: 160) + .frame(width: 240) + .onSubmit { + call.addContact(newContactNick) + newContactNick = "" + } + Button { + call.addContact(newContactNick) + newContactNick = "" + } label: { + Text("Add").font(DS.mono(11, .bold)).foregroundColor(.white) + .padding(.horizontal, 12).padding(.vertical, 6) + .background(Capsule().fill(DS.fill)) + } + .buttonStyle(.plain) + .disabled(newContactNick.trimmingCharacters(in: .whitespaces).count < 3) + } + .padding(.horizontal, 16).padding(.vertical, 12).dsCard(12) + + if !call.savedContacts.isEmpty { + VStack(spacing: 6) { + ForEach(call.savedContacts, id: \.self) { nick in + HStack(spacing: 10) { + Image(systemName: "person.crop.circle.fill") + .font(.system(size: 20)).foregroundColor(DS.live) + Text("@\(nick)").font(DS.mono(12, .medium)).foregroundColor(DS.text) + Spacer() + Button { + call.callNickname(nick) + } label: { + HStack(spacing: 5) { + Image(systemName: "phone.fill").font(.system(size: 10)) + Text("Call").font(DS.mono(11, .bold)) + } + .foregroundColor(.white) + .padding(.horizontal, 12).padding(.vertical, 5) + .background(Capsule().fill(DS.live)) + } + .buttonStyle(.plain) + Button { + call.removeContact(nick) + } label: { + Image(systemName: "minus.circle.fill") + .font(.system(size: 13)).foregroundColor(DS.faint) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 12).padding(.vertical, 8).dsCard(10) + } + } + .frame(maxWidth: 430) + } + + // Directory search: who's online now. + HStack(spacing: 8) { + Image(systemName: "magnifyingglass").foregroundColor(DS.dim) + TextField("search online by @nickname", + text: Binding(get: { call.directory.searchQuery }, + set: { call.directory.searchQuery = $0 })) + .textFieldStyle(.plain).font(DS.mono(13)).foregroundColor(DS.text) + .frame(width: 230) + .onSubmit { call.searchNicknames() } + Button("Search") { call.searchNicknames() } + .buttonStyle(.plain).font(DS.mono(10, .medium)).foregroundColor(DS.text) } .padding(.horizontal, 16).padding(.vertical, 12).dsCard(12) - Text("SELF · \(call.localIP):\(call.port)") + if !call.directory.results.isEmpty { + VStack(spacing: 4) { + ForEach(call.directory.results.prefix(5)) { contact in + Button { + call.addContact(contact.nickname) + call.callNickname(contact.nickname) + } label: { + HStack { + Text("@\(contact.nickname)").font(DS.mono(11, .medium)).foregroundColor(DS.text) + Spacer() + Text(contact.source.rawValue).font(DS.mono(8)).foregroundColor(DS.faint) + } + .padding(.horizontal, 12).padding(.vertical, 6).dsCard(8) + } + .buttonStyle(.plain) + } + } + .frame(maxWidth: 430) + } + + Text(call.directory.currentNickname.map { "You are @\($0)" } ?? call.identity.displayName) .font(DS.mono(11)).foregroundColor(DS.faint) + if call.isStarting { + HStack(spacing: 8) { + ProgressView().controlSize(.small) + Text(call.status).font(DS.ui(11)).foregroundColor(DS.dim) + } + } + + if let error = call.error { + Text(error).font(DS.ui(11)).foregroundColor(DS.danger) + .multilineTextAlignment(.center) + } + // Missed calls — one-tap call back (newest first, capped at 5). if !call.missedCalls.isEmpty { VStack(spacing: 6) { @@ -270,16 +442,8 @@ private struct StartCallView: View { .padding(.horizontal, 14).padding(.vertical, 10).dsCard(12).frame(maxWidth: 420) } - if !call.recentIPs.isEmpty { - HStack(spacing: 8) { - ForEach(call.recentIPs, id: \.self) { ip in - Button(ip) { call.remoteIP = ip } - .buttonStyle(.plain).font(DS.mono(11)).foregroundColor(DS.dim) - .padding(.horizontal, 12).padding(.vertical, 6) - .overlay(Capsule().stroke(DS.hairline, lineWidth: 1)) - } - } - } + // Recent raw-IP quick-dial removed: the nickname contacts above + // are the supported way to reach someone. PeerRoster(call: call, discovery: call.discovery) @@ -298,6 +462,245 @@ private struct StartCallView: View { .padding(.top, 6) } .padding(30) + .sheet(isPresented: $showNickname) { MonitorNicknamePanel(call: call) } + .sheet(isPresented: $showInternetSettings) { MonitorInternetSettingsPanel(call: call) } + .sheet(isPresented: $showGroupChats) { MonitorGroupChatPanel(call: call) } + } +} + +private struct MonitorNicknamePanel: View { + @ObservedObject var call: CallManager + @ObservedObject private var directory: NicknameDirectoryController + @Environment(\.dismiss) private var dismiss + + init(call: CallManager) { + self.call = call + directory = call.directory + } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + HStack { + Text("Your Nickname").font(DS.display(20, .semibold)) + Spacer() + Button("Done") { dismiss() } + } + HStack { + Text("@").foregroundColor(DS.dim) + TextField("nickname", text: $directory.proposedNickname) + .textFieldStyle(.roundedBorder) + } + Text("Use 3-20 lowercase letters, numbers, or underscore.") + .font(DS.ui(11)).foregroundColor(DS.dim) + Button(directory.isWorking ? "Checking..." : "Check and create") { + call.claimNickname() + }.disabled(directory.isWorking) + if let status = directory.statusMessage { + Text(status).font(DS.ui(11)).foregroundColor(DS.text) + } + if !directory.suggestions.isEmpty { + HStack { + ForEach(directory.suggestions, id: \.self) { suggestion in + Button("@\(suggestion)") { + directory.proposedNickname = suggestion + call.claimNickname() + } + } + } + } + Spacer() + } + .padding(24) + .frame(width: 480, height: 300) + .onChange(of: directory.currentNickname) { current in + if current != nil { dismiss() } + } + } +} + +private struct MonitorInternetSettingsPanel: View { + @ObservedObject var call: CallManager + @Environment(\.dismiss) private var dismiss + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + HStack { + Text("Internet Calling").font(DS.display(20, .semibold)) + Spacer() + Button("Cancel") { dismiss() } + } + TextField("https://api.example.com", text: $call.internetConfiguration.apiBaseURL) + .textFieldStyle(.roundedBorder) + TextField("wss://project.livekit.cloud", text: $call.internetConfiguration.liveKitURL) + .textFieldStyle(.roundedBorder) + SecureField("Service access token", text: $call.internetConfiguration.accessToken) + .textFieldStyle(.roundedBorder) + SecureField("Development room token", text: $call.internetConfiguration.developmentRoomToken) + .textFieldStyle(.roundedBorder) + Text("Production uses the signed API. Direct LiveKit mode is for development tests only.") + .font(DS.ui(11)).foregroundColor(DS.dim) + HStack { + Spacer() + Button("Save") { + call.saveInternetSettings() + dismiss() + } + } + } + .padding(24) + .frame(width: 560, height: 330) + } +} + +private struct MonitorGroupChatPanel: View { + @ObservedObject var call: CallManager + @ObservedObject private var group: GroupChatController + @Environment(\.dismiss) private var dismiss + + init(call: CallManager) { + self.call = call + group = call.groupChat + } + + var body: some View { + VStack(spacing: 14) { + HStack { + Text("Group Chats").font(DS.display(22, .semibold)).foregroundColor(DS.text) + Spacer() + Button("Done") { dismiss() } + } + + HStack(spacing: 14) { + VStack(alignment: .leading, spacing: 10) { + SectionLabel(text: "New group") + TextField("Title (optional)", text: $group.titleInput) + .textFieldStyle(.roundedBorder) + TextField("@alice, @bob", text: $group.membersInput) + .textFieldStyle(.roundedBorder) + Text("Separate unique nicknames with commas or spaces.") + .font(DS.ui(10)).foregroundColor(DS.dim) + Button(group.isWorking ? "Creating..." : "Create group") { + group.createGroup() + } + .disabled(group.isWorking) + + Hairline() + SectionLabel(text: "Your chats") + ScrollView { + LazyVStack(alignment: .leading, spacing: 7) { + if group.chats.isEmpty { + Text("No groups yet").font(DS.ui(11)).foregroundColor(DS.faint) + } + ForEach(group.chats) { chat in + Button(action: { group.open(chat) }) { + VStack(alignment: .leading, spacing: 3) { + Text(chat.title).font(DS.ui(13, .medium)).foregroundColor(DS.text) + Text(chat.members.map { "@\($0)" }.joined(separator: ", ")) + .font(DS.mono(9)).foregroundColor(DS.faint).lineLimit(1) + if let lastMessage = chat.lastMessage { + Text(lastMessage).font(DS.ui(10)).foregroundColor(DS.dim).lineLimit(1) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(9) + .background(group.activeChatID == chat.chatID ? DS.surfaceHi : DS.surface, + in: RoundedRectangle(cornerRadius: 10)) + .overlay(RoundedRectangle(cornerRadius: 10).stroke(DS.hairline, lineWidth: 1)) + .overlay(alignment: .topTrailing) { + if let unread = group.unreadByChat[chat.chatID], unread > 0 { + Text("\(min(unread, 99))") + .font(.caption2.weight(.bold)) + .foregroundColor(.white) + .padding(.horizontal, 6).padding(.vertical, 2) + .background(Capsule().fill(Color.accentColor)) + .padding(6) + .accessibilityLabel("\(unread) unread") + } + } + } + .buttonStyle(.plain) + .accessibilityLabel(Text(chat.title)) + } + } + } + } + .frame(width: 260) + + VStack(spacing: 0) { + if let chat = group.activeChat { + HStack { + VStack(alignment: .leading, spacing: 3) { + Text(chat.title).font(DS.ui(16, .semibold)).foregroundColor(DS.text) + Text(chat.members.map { "@\($0)" }.joined(separator: ", ")) + .font(DS.mono(9)).foregroundColor(DS.faint).lineLimit(1) + } + Spacer() + } + .padding(12) + Hairline() + ScrollViewReader { proxy in + ScrollView { + LazyVStack(spacing: 8) { + ForEach(group.messages) { message in + let mine = message.senderUserID == call.identity.userID + HStack { + if mine { Spacer(minLength: 70) } + VStack(alignment: .leading, spacing: 3) { + Text(mine ? "You" : "@\(message.senderNickname)") + .font(DS.mono(9)).foregroundColor(DS.faint) + Text(message.text).font(DS.ui(12)).foregroundColor(DS.text) + } + .padding(.horizontal, 11).padding(.vertical, 7) + .background(mine ? Color.white.opacity(0.10) : DS.surfaceHi, + in: RoundedRectangle(cornerRadius: 11)) + if !mine { Spacer(minLength: 70) } + } + .id(message.messageID) + } + } + .padding(12) + } + .onChange(of: group.messages.count) { _ in + if let last = group.messages.last { + proxy.scrollTo(last.messageID, anchor: .bottom) + } + } + } + Hairline() + HStack(spacing: 8) { + TextField("Message", text: $group.draft) + .textFieldStyle(.roundedBorder) + .onSubmit { group.send() } + Button(action: { group.send() }) { + Image(systemName: "arrow.up.circle.fill").font(.system(size: 24)) + } + .buttonStyle(.plain) + .disabled(group.isWorking || group.draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + .padding(12) + } else { + VStack(spacing: 10) { + Image(systemName: "bubble.left.and.bubble.right") + .font(.system(size: 42)).foregroundColor(DS.faint) + Text("Select a group or create one by nickname.") + .font(DS.ui(13)).foregroundColor(DS.dim) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + .background(DS.surface, in: RoundedRectangle(cornerRadius: 12)) + .overlay(RoundedRectangle(cornerRadius: 12).stroke(DS.hairline, lineWidth: 1)) + } + + if let status = group.statusMessage { + Text(status).font(DS.ui(10)).foregroundColor(DS.dim) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .padding(20) + .frame(width: 820, height: 600) + .background(DS.ink) + .onAppear { group.startPolling() } } } diff --git a/phone/desktop/project.yml b/phone/desktop/project.yml index 08569941..946f067a 100644 --- a/phone/desktop/project.yml +++ b/phone/desktop/project.yml @@ -16,6 +16,10 @@ settings: DEVELOPMENT_TEAM: "5EM4M85VSQ" CODE_SIGNING_REQUIRED: YES CODE_SIGNING_ALLOWED: YES +packages: + LiveKit: + url: https://github.com/livekit/client-sdk-swift.git + from: 2.15.2 targets: TriNetMonitor: type: application @@ -26,6 +30,7 @@ targets: - path: RTIHeatmap.swift - path: RTI3D.swift - path: VideoCallTab.swift + - path: ../shared - path: TriNetVideo/CallManager.swift - path: TriNetVideo/CameraCapture.swift - path: TriNetVideo/ScreenCapture.swift @@ -39,19 +44,29 @@ targets: - path: TriNetVideo/LinkStatus.swift - path: TriNetVideo/OpusCodec.swift - path: TriNetVideo/PeerDiscovery.swift + dependencies: + - package: LiveKit info: path: TriNetMonitor-Info.plist properties: CFBundleDisplayName: "TRI-NET Monitor" - NSCameraUsageDescription: "Mesh monitor" - NSMicrophoneUsageDescription: "Audio for mesh video calls" - NSLocalNetworkUsageDescription: "Find TRI-NET peers on your local network by name instead of typing IPs" + NSCameraUsageDescription: "TRI-NET needs camera for secure local and internet video calls" + NSMicrophoneUsageDescription: "TRI-NET needs microphone for video calls" + NSLocalNetworkUsageDescription: "TRI-NET discovers signed contacts and peers over local UDP or a routed radio mesh" + NSAppTransportSecurity: + NSAllowsLocalNetworking: true NSBonjourServices: + - _trinet-call._udp - _trinet._udp + TRINET_API_BASE_URL: "http://SSDs-MacBook-Pro.local:8080" + TRINET_LIVEKIT_URL: "" + TRINET_SERVICE_ACCESS_TOKEN: "" + TRINET_DEVELOPMENT_ROOM_TOKEN: "" settings: base: PRODUCT_BUNDLE_IDENTIFIER: com.trinet.monitor PRODUCT_NAME: TriNetMonitor + GENERATE_INFOPLIST_FILE: NO CODE_SIGN_IDENTITY: "Apple Development" CODE_SIGN_STYLE: Manual DEVELOPMENT_TEAM: "5EM4M85VSQ" diff --git a/phone/desktop/project_video.yml b/phone/desktop/project_video.yml index 6dc5011d..9616e092 100644 --- a/phone/desktop/project_video.yml +++ b/phone/desktop/project_video.yml @@ -10,21 +10,35 @@ settings: CODE_SIGN_IDENTITY: "-" CODE_SIGNING_REQUIRED: NO CODE_SIGNING_ALLOWED: YES +packages: + LiveKit: + url: https://github.com/livekit/client-sdk-swift.git + from: 2.15.2 targets: TriNetVideo: type: application platform: macOS sources: - path: TriNetVideo + - path: ../shared + dependencies: + - package: LiveKit + info: + path: TriNetVideo/Info.plist + properties: + CFBundleDisplayName: "TRI-NET Video" + NSCameraUsageDescription: "TRI-NET needs camera for secure local and internet video calls" + NSMicrophoneUsageDescription: "TRI-NET needs microphone for video calls" + NSLocalNetworkUsageDescription: "TRI-NET discovers signed contacts and connects over local UDP or a routed radio mesh" + NSAppTransportSecurity: + NSAllowsLocalNetworking: true + NSBonjourServices: + - _trinet-call._udp settings: base: PRODUCT_BUNDLE_IDENTIFIER: com.trinet.video PRODUCT_NAME: TriNetVideo - GENERATE_INFOPLIST_FILE: YES - INFOPLIST_KEY_CFBundleDisplayName: "TRI-NET Video" - INFOPLIST_KEY_NSCameraUsageDescription: "Video calls over mesh" - INFOPLIST_KEY_NSMicrophoneUsageDescription: "Audio for video calls" - INFOPLIST_KEY_NSLocalNetworkUsageDescription: "Mesh video transport" + GENERATE_INFOPLIST_FILE: NO CODE_SIGN_IDENTITY: "-" CODE_SIGNING_REQUIRED: NO CODE_SIGNING_ALLOWED: YES diff --git a/phone/project.yml b/phone/project.yml index 47d21410..22f6486e 100644 --- a/phone/project.yml +++ b/phone/project.yml @@ -8,21 +8,39 @@ settings: base: SWIFT_VERSION: "5.0" CODE_SIGN_STYLE: Automatic +packages: + LiveKit: + url: https://github.com/livekit/client-sdk-swift.git + from: 2.15.2 targets: TriNetVideo: type: application platform: iOS sources: - path: TriNetVideo + - path: shared + dependencies: + - package: LiveKit info: path: TriNetVideo/Info.plist properties: CFBundleDisplayName: TRI-NET Video - NSCameraUsageDescription: "TRI-NET needs camera to stream video over mesh radio" + NSCameraUsageDescription: "TRI-NET needs camera for secure local and internet video calls" NSMicrophoneUsageDescription: "TRI-NET needs microphone for video calls" - NSLocalNetworkUsageDescription: "Find TRI-NET people on your local network by name instead of typing IPs" + NSLocalNetworkUsageDescription: "TRI-NET discovers signed contacts and connects over local UDP or a routed radio mesh" + NSAppTransportSecurity: + NSAllowsLocalNetworking: true NSBonjourServices: + - _trinet-call._udp - _trinet._udp + TRINET_API_BASE_URL: "http://SSDs-MacBook-Pro.local:8080" + TRINET_LIVEKIT_URL: "" + TRINET_SERVICE_ACCESS_TOKEN: "" + TRINET_DEVELOPMENT_ROOM_TOKEN: "" + UIBackgroundModes: + - audio + - voip + - remote-notification UILaunchScreen: UIColorName: "" UISupportedInterfaceOrientations: @@ -38,4 +56,16 @@ targets: CODE_SIGNING_REQUIRED: YES CODE_SIGNING_ALLOWED: YES GENERATE_INFOPLIST_FILE: NO + CODE_SIGN_ENTITLEMENTS: TriNetVideo/TriNetVideo.entitlements PRODUCT_NAME: TriNetVideo + TriNetVideoTests: + type: bundle.unit-test + platform: iOS + sources: + - path: TriNetVideoTests + dependencies: + - target: TriNetVideo + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.trinet.video.tests + GENERATE_INFOPLIST_FILE: YES diff --git a/phone/shared/CallIdentity.swift b/phone/shared/CallIdentity.swift new file mode 100644 index 00000000..8eece120 --- /dev/null +++ b/phone/shared/CallIdentity.swift @@ -0,0 +1,260 @@ +import CryptoKit +import Foundation +import Security + +enum CallRoute: String, CaseIterable, Codable, Identifiable { + case automatic = "Auto" + case mesh = "Mesh" + case internet = "Internet" + + var id: String { rawValue } + + var displayName: String { + switch self { + case .automatic: return "Auto" + case .mesh: return "Local/Mesh UDP" + case .internet: return "Internet" + } + } +} + +struct DeviceIdentity: Codable, Equatable { + var userID: String + let deviceID: String + var displayName: String + var nickname: String? + let signingPublicKey: String + let keyFingerprint: String +} + +struct DeviceRequestSignature { + let deviceID: String + let timestamp: String + let nonce: String + let signature: String +} + +struct InternetCallConfiguration: Equatable { + var apiBaseURL: String + var liveKitURL: String + var accessToken: String + var developmentRoomToken: String + + static func load(defaults: UserDefaults = .standard, + bundle: Bundle = .main) -> InternetCallConfiguration { + func value(_ defaultsKey: String, _ plistKey: String) -> String { + if let saved = defaults.string(forKey: defaultsKey), !saved.isEmpty { + return saved + } + return bundle.object(forInfoDictionaryKey: plistKey) as? String ?? "" + } + + return InternetCallConfiguration( + apiBaseURL: value("internetAPIBaseURL", "TRINET_API_BASE_URL"), + liveKitURL: value("liveKitURL", "TRINET_LIVEKIT_URL"), + accessToken: value("serviceAccessToken", "TRINET_SERVICE_ACCESS_TOKEN"), + developmentRoomToken: value("developmentRoomToken", "TRINET_DEVELOPMENT_ROOM_TOKEN") + ) + } + + func save(defaults: UserDefaults = .standard) { + defaults.set(apiBaseURL, forKey: "internetAPIBaseURL") + defaults.set(liveKitURL, forKey: "liveKitURL") + defaults.set(accessToken, forKey: "serviceAccessToken") + defaults.set(developmentRoomToken, forKey: "developmentRoomToken") + } + + var isDevelopmentDirect: Bool { + !liveKitURL.isEmpty && !developmentRoomToken.isEmpty + } + + var isConfigured: Bool { + isDevelopmentDirect || URL(string: apiBaseURL) != nil + } + + var hasDirectoryAPI: Bool { + guard let url = URL(string: apiBaseURL), + let scheme = url.scheme?.lowercased() else { return false } + return scheme == "https" || scheme == "http" + } +} + +enum IdentityStoreError: LocalizedError { + case keychain(OSStatus) + case invalidKey + + var errorDescription: String? { + switch self { + case let .keychain(status): + return "Keychain operation failed (\(status))." + case .invalidKey: + return "The stored device identity key is invalid." + } + } +} + +final class DeviceIdentityStore { + static let shared = DeviceIdentityStore() + + private let service = "com.trinet.video.device-identity" + private let identityAccount = "identity-v1" + private let signingKeyAccount = "signing-key-v1" + + private init() {} + + func loadOrCreate(defaultName: String = "ssd26") throws -> DeviceIdentity { + let requestedName = UserDefaults.standard.string(forKey: "deviceDisplayName") ?? defaultName + if var identity: DeviceIdentity = try readCodable(account: identityAccount) { + if identity.displayName != requestedName { + identity.displayName = requestedName + try writeCodable(identity, account: identityAccount) + } + return identity + } + + let publicKey = try loadOrCreateSigningPublicKey() + let digest = SHA256.hash(data: publicKey) + let fingerprint = digest.prefix(12).map { String(format: "%02x", $0) }.joined() + let identity = DeviceIdentity( + userID: UUID().uuidString.lowercased(), + deviceID: UUID().uuidString.lowercased(), + displayName: requestedName, + nickname: nil, + signingPublicKey: publicKey.base64EncodedString(), + keyFingerprint: fingerprint + ) + try writeCodable(identity, account: identityAccount) + return identity + } + + func rename(_ displayName: String) throws -> DeviceIdentity { + let clean = displayName.trimmingCharacters(in: .whitespacesAndNewlines) + UserDefaults.standard.set(clean.isEmpty ? "ssd26" : clean, forKey: "deviceDisplayName") + return try loadOrCreate(defaultName: "ssd26") + } + + func setNickname(_ nickname: String?) throws -> DeviceIdentity { + guard var identity: DeviceIdentity = try readCodable(account: identityAccount) else { + throw IdentityStoreError.invalidKey + } + identity.nickname = nickname + try writeCodable(identity, account: identityAccount) + return identity + } + + func adoptAccount(userID: String, nickname: String?) throws -> DeviceIdentity { + guard var identity: DeviceIdentity = try readCodable(account: identityAccount), + !userID.isEmpty else { + throw IdentityStoreError.invalidKey + } + identity.userID = userID + identity.nickname = nickname + try writeCodable(identity, account: identityAccount) + return identity + } + + func signMessage(_ message: Data) throws -> String { + guard let stored = try readData(account: signingKeyAccount), + let privateKey = try? P256.Signing.PrivateKey(rawRepresentation: stored) else { + throw IdentityStoreError.invalidKey + } + return try privateKey.signature(for: message).derRepresentation.base64EncodedString() + } + + static func verifyMessage(_ message: Data, + signature: String, + publicKey: String) -> Bool { + guard let keyData = Data(base64Encoded: publicKey), + let signatureData = Data(base64Encoded: signature), + let key = try? P256.Signing.PublicKey(x963Representation: keyData), + let proof = try? P256.Signing.ECDSASignature(derRepresentation: signatureData) else { + return false + } + return key.isValidSignature(proof, for: message) + } + + static func fingerprint(for publicKey: String) -> String? { + guard let keyData = Data(base64Encoded: publicKey) else { return nil } + return SHA256.hash(data: keyData).prefix(12).map { + String(format: "%02x", $0) + }.joined() + } + + func signRequest(identity: DeviceIdentity, + method: String, + path: String, + body: Data) throws -> DeviceRequestSignature { + guard let stored = try readData(account: signingKeyAccount), + let privateKey = try? P256.Signing.PrivateKey(rawRepresentation: stored) else { + throw IdentityStoreError.invalidKey + } + let timestamp = String(Int(Date().timeIntervalSince1970)) + let nonce = UUID().uuidString.lowercased() + let bodyHash = SHA256.hash(data: body).map { String(format: "%02x", $0) }.joined() + let canonical = [method.uppercased(), path, timestamp, nonce, bodyHash].joined(separator: "\n") + let signature = try privateKey.signature(for: Data(canonical.utf8)) + return DeviceRequestSignature( + deviceID: identity.deviceID, + timestamp: timestamp, + nonce: nonce, + signature: signature.derRepresentation.base64EncodedString() + ) + } + + private func loadOrCreateSigningPublicKey() throws -> Data { + if let stored = try readData(account: signingKeyAccount) { + guard let privateKey = try? P256.Signing.PrivateKey(rawRepresentation: stored) else { + throw IdentityStoreError.invalidKey + } + return privateKey.publicKey.x963Representation + } + + let privateKey = P256.Signing.PrivateKey() + try writeData(privateKey.rawRepresentation, account: signingKeyAccount) + return privateKey.publicKey.x963Representation + } + + private func readCodable(account: String) throws -> T? { + guard let data = try readData(account: account) else { return nil } + return try JSONDecoder().decode(T.self, from: data) + } + + private func writeCodable(_ value: T, account: String) throws { + try writeData(JSONEncoder().encode(value), account: account) + } + + private func readData(account: String) throws -> Data? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + if status == errSecItemNotFound { return nil } + guard status == errSecSuccess else { throw IdentityStoreError.keychain(status) } + return item as? Data + } + + private func writeData(_ data: Data, account: String) throws { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account + ] + let attributes: [String: Any] = [kSecValueData as String: data] + let updateStatus = SecItemUpdate(query as CFDictionary, attributes as CFDictionary) + if updateStatus == errSecSuccess { return } + guard updateStatus == errSecItemNotFound else { + throw IdentityStoreError.keychain(updateStatus) + } + + var add = query + add[kSecValueData as String] = data + add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + let addStatus = SecItemAdd(add as CFDictionary, nil) + guard addStatus == errSecSuccess else { throw IdentityStoreError.keychain(addStatus) } + } +} diff --git a/phone/shared/InternetCall.swift b/phone/shared/InternetCall.swift new file mode 100644 index 00000000..ac4ce280 --- /dev/null +++ b/phone/shared/InternetCall.swift @@ -0,0 +1,1170 @@ +import Combine +import Foundation +import LiveKit + +enum InternetCallState: String { + case idle = "Idle" + case registering = "Registering device" + case ringing = "Ringing" + case connecting = "Connecting" + case connected = "Connected" + case reconnecting = "Reconnecting" + case ended = "Ended" + case failed = "Failed" +} + +enum InternetCallError: LocalizedError { + case notConfigured + case invalidResponse + case server(Int, String) + + var errorDescription: String? { + switch self { + case .notConfigured: + return "Internet calling is not configured. Set the API or LiveKit development URL in Settings." + case .invalidResponse: + return "The call service returned an invalid response." + case let .server(code, message): + return "Call service error \(code): \(message)" + } + } +} + +struct DeviceRegistrationRequest: Encodable { + let userID: String + let deviceID: String + let displayName: String + let signingPublicKey: String + let keyFingerprint: String + let platform: String + let voipPushToken: String? + let capabilities: [String] +} + +struct CreateInternetCallRequest: Encodable { + let callee: String + let callerUserID: String + let callerDeviceID: String + let audio: Bool + let video: Bool +} + +struct InternetCallSession: Decodable { + let callID: String + let roomID: String + let liveKitURL: String + let token: String + let mediaKey: String? + + enum CodingKeys: String, CodingKey { + case callID = "call_id" + case roomID = "room_id" + case liveKitURL = "livekit_url" + case token + case mediaKey = "media_key" + } +} + +struct IncomingInternetCall: Decodable, Identifiable, Equatable { + let callID: String + let caller: String + let audio: Bool + let video: Bool + let createdAt: Int64 + + var id: String { callID } + + enum CodingKeys: String, CodingKey { + case callID = "call_id" + case caller + case audio + case video + case createdAt = "created_at" + } +} + +struct AccountDevice: Decodable, Identifiable, Equatable { + let deviceID: String + let displayName: String + let platform: String + let keyFingerprint: String + let lastSeen: Int64 + let current: Bool + let revoked: Bool + + var id: String { deviceID } + + enum CodingKeys: String, CodingKey { + case deviceID = "device_id" + case displayName = "display_name" + case platform + case keyFingerprint = "key_fingerprint" + case lastSeen = "last_seen" + case current + case revoked + } +} + +struct AccountSnapshot: Decodable, Equatable { + let accountID: String + let nickname: String? + let devices: [AccountDevice] + + enum CodingKeys: String, CodingKey { + case accountID = "account_id" + case nickname + case devices + } +} + +struct DeviceLinkCode: Decodable, Equatable { + let linkCode: String + let expiresAt: Int64 + + enum CodingKeys: String, CodingKey { + case linkCode = "link_code" + case expiresAt = "expires_at" + } +} + +struct GroupChatSummary: Decodable, Identifiable, Equatable { + let chatID: String + let title: String + let members: [String] + let createdAt: Int64 + let lastMessage: String? + let lastMessageAt: Int64? + /// Unread count for the requesting account, supplied by the server. Defaults + /// to 0 when the field is absent so old servers keep working. + let unreadCount: Int + + var id: String { chatID } + + enum CodingKeys: String, CodingKey { + case chatID = "chat_id" + case title + case members + case createdAt = "created_at" + case lastMessage = "last_message" + case lastMessageAt = "last_message_at" + case unreadCount = "unread_count" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + chatID = try container.decode(String.self, forKey: .chatID) + title = try container.decode(String.self, forKey: .title) + members = try container.decode([String].self, forKey: .members) + createdAt = try container.decode(Int64.self, forKey: .createdAt) + lastMessage = try container.decodeIfPresent(String.self, forKey: .lastMessage) + lastMessageAt = try container.decodeIfPresent(Int64.self, forKey: .lastMessageAt) + unreadCount = try container.decodeIfPresent(Int.self, forKey: .unreadCount) ?? 0 + } +} + +struct GroupChatMessage: Decodable, Identifiable, Equatable { + let messageID: Int64 + let chatID: String + let senderUserID: String + let senderNickname: String + let text: String + let createdAt: Int64 + + var id: Int64 { messageID } + + enum CodingKeys: String, CodingKey { + case messageID = "message_id" + case chatID = "chat_id" + case senderUserID = "sender_user_id" + case senderNickname = "sender_nickname" + case text + case createdAt = "created_at" + } +} + +private struct IncomingInternetCallsResponse: Decodable { + let calls: [IncomingInternetCall] +} + +private struct GroupChatsResponse: Decodable { + let chats: [GroupChatSummary] + /// Total unread across all of the account's chats, supplied by the server. + /// Optional so an older server that omits it still decodes. + let totalUnreadCount: Int? + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + chats = try container.decode([GroupChatSummary].self, forKey: .chats) + totalUnreadCount = try container.decodeIfPresent(Int.self, forKey: .totalUnreadCount) + } + + enum CodingKeys: String, CodingKey { + case chats + case totalUnreadCount = "total_unread_count" + } +} + +private struct GroupMessagesResponse: Decodable { + let messages: [GroupChatMessage] +} + +private struct InternetDataMessage: Codable { + enum Kind: String, Codable { + case chat + case reaction + } + + let kind: Kind + let value: String +} + +final class InternetCallAPI { + private let configuration: InternetCallConfiguration + private let session: URLSession + private let encoder = JSONEncoder() + private let decoder = JSONDecoder() + + init(configuration: InternetCallConfiguration, session: URLSession = .shared) { + self.configuration = configuration + self.session = session + encoder.keyEncodingStrategy = .convertToSnakeCase + } + + func register(identity: DeviceIdentity, voipToken: String?) async throws { + guard !configuration.isDevelopmentDirect else { return } + let body = DeviceRegistrationRequest( + userID: identity.userID, + deviceID: identity.deviceID, + displayName: identity.displayName, + signingPublicKey: identity.signingPublicKey, + keyFingerprint: identity.keyFingerprint, + platform: platformName, + voipPushToken: voipToken, + capabilities: ["audio", "video", "mesh", "webrtc"] + ) + let _: EmptyResponse = try await request(path: "/v1/devices/register", method: "POST", body: body, identity: identity) + } + + func createCall(callee: String, + identity: DeviceIdentity, + audio: Bool, + video: Bool) async throws -> InternetCallSession { + if configuration.isDevelopmentDirect { + return InternetCallSession( + callID: UUID().uuidString.lowercased(), + roomID: "development", + liveKitURL: configuration.liveKitURL, + token: configuration.developmentRoomToken, + mediaKey: nil + ) + } + let body = CreateInternetCallRequest( + callee: callee, + callerUserID: identity.userID, + callerDeviceID: identity.deviceID, + audio: audio, + video: video + ) + return try await request(path: "/v1/calls", method: "POST", body: body, identity: identity) + } + + func joinCall(callID: String, identity: DeviceIdentity) async throws -> InternetCallSession { + struct JoinRequest: Encodable { + let userID: String + let deviceID: String + } + let body = JoinRequest(userID: identity.userID, deviceID: identity.deviceID) + return try await request(path: "/v1/calls/\(callID)/join", method: "POST", body: body, identity: identity) + } + + func incomingCalls(identity: DeviceIdentity) async throws -> [IncomingInternetCall] { + struct IncomingRequest: Encodable { + let userID: String + let deviceID: String + } + guard !configuration.isDevelopmentDirect else { return [] } + let body = IncomingRequest(userID: identity.userID, deviceID: identity.deviceID) + let response: IncomingInternetCallsResponse = try await request( + path: "/v1/calls/incoming", + method: "POST", + body: body, + identity: identity + ) + return response.calls + } + + func claimNickname(_ nickname: String, + identity: DeviceIdentity) async throws -> NicknameClaimResponse { + let body = NicknameClaimRequest(nickname: nickname, + userID: identity.userID, + deviceID: identity.deviceID) + return try await request(path: "/v1/directory/nicknames/claim", + method: "POST", + body: body, + identity: identity) + } + + func searchNicknames(_ query: String, + identity: DeviceIdentity) async throws -> NicknameSearchResponse { + let body = NicknameSearchRequest(query: query, limit: 20) + return try await request(path: "/v1/directory/search", + method: "POST", + body: body, + identity: identity) + } + + func account(identity: DeviceIdentity) async throws -> AccountSnapshot { + struct AccountRequest: Encodable { + let userID: String + let deviceID: String + } + let body = AccountRequest(userID: identity.userID, deviceID: identity.deviceID) + return try await request(path: "/v1/account", method: "POST", body: body, identity: identity) + } + + func createLinkCode(identity: DeviceIdentity) async throws -> DeviceLinkCode { + struct AccountRequest: Encodable { + let userID: String + let deviceID: String + } + let body = AccountRequest(userID: identity.userID, deviceID: identity.deviceID) + return try await request(path: "/v1/account/link-code", method: "POST", body: body, identity: identity) + } + + func linkDevice(code: String, identity: DeviceIdentity) async throws -> AccountSnapshot { + struct LinkRequest: Encodable { + let userID: String + let deviceID: String + let linkCode: String + } + let body = LinkRequest(userID: identity.userID, + deviceID: identity.deviceID, + linkCode: code) + return try await request(path: "/v1/account/link", method: "POST", body: body, identity: identity) + } + + func revokeDevice(_ deviceID: String, identity: DeviceIdentity) async throws { + struct RevokeRequest: Encodable { + let userID: String + let deviceID: String + } + let path = "/v1/account/devices/\(deviceID)/revoke" + let body = RevokeRequest(userID: identity.userID, deviceID: identity.deviceID) + let _: EmptyResponse = try await request(path: path, + method: "POST", + body: body, + identity: identity) + } + + func createGroupChat(title: String?, + members: [String], + identity: DeviceIdentity) async throws -> GroupChatSummary { + struct CreateRequest: Encodable { + let creatorUserID: String + let creatorDeviceID: String + let title: String? + let members: [String] + } + let body = CreateRequest(creatorUserID: identity.userID, + creatorDeviceID: identity.deviceID, + title: title, + members: members) + return try await request(path: "/v1/chats", + method: "POST", + body: body, + identity: identity) + } + + func groupChats(identity: DeviceIdentity) async throws -> (chats: [GroupChatSummary], totalUnread: Int) { + struct ListRequest: Encodable { + let userID: String + let deviceID: String + } + let body = ListRequest(userID: identity.userID, deviceID: identity.deviceID) + let response: GroupChatsResponse = try await request(path: "/v1/chats/list", + method: "POST", + body: body, + identity: identity) + return (response.chats, response.totalUnreadCount ?? 0) + } + + func sendGroupMessage(chatID: String, + clientMessageID: String, + text: String, + identity: DeviceIdentity) async throws -> GroupChatMessage { + struct SendRequest: Encodable { + let userID: String + let deviceID: String + let clientMessageID: String + let text: String + } + let body = SendRequest(userID: identity.userID, + deviceID: identity.deviceID, + clientMessageID: clientMessageID, + text: text) + return try await request(path: "/v1/chats/\(chatID)/messages", + method: "POST", + body: body, + identity: identity) + } + + func groupMessages(chatID: String, + afterMessageID: Int64, + limit: UInt16 = 100, + identity: DeviceIdentity) async throws -> [GroupChatMessage] { + struct ListRequest: Encodable { + let userID: String + let deviceID: String + let afterMessageID: Int64 + let limit: UInt16 + } + let body = ListRequest(userID: identity.userID, + deviceID: identity.deviceID, + afterMessageID: afterMessageID, + limit: limit) + let response: GroupMessagesResponse = try await request( + path: "/v1/chats/\(chatID)/messages/list", + method: "POST", + body: body, + identity: identity + ) + return response.messages + } + + private func request(path: String, + method: String, + body: Body, + identity: DeviceIdentity) async throws -> Response { + guard let base = URL(string: configuration.apiBaseURL), + let url = URL(string: path, relativeTo: base)?.absoluteURL else { + throw InternetCallError.notConfigured + } + var request = URLRequest(url: url) + request.httpMethod = method + let encodedBody = try encoder.encode(body) + request.httpBody = encodedBody + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + let proof = try DeviceIdentityStore.shared.signRequest( + identity: identity, + method: method, + path: path, + body: encodedBody + ) + request.setValue(proof.deviceID, forHTTPHeaderField: "X-TRINET-Device-ID") + request.setValue(proof.timestamp, forHTTPHeaderField: "X-TRINET-Timestamp") + request.setValue(proof.nonce, forHTTPHeaderField: "X-TRINET-Nonce") + request.setValue(proof.signature, forHTTPHeaderField: "X-TRINET-Signature") + if !configuration.accessToken.isEmpty { + request.setValue("Bearer \(configuration.accessToken)", forHTTPHeaderField: "Authorization") + } + let (data, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse else { throw InternetCallError.invalidResponse } + guard (200..<300).contains(http.statusCode) else { + let message = String(data: data, encoding: .utf8) ?? "Unknown error" + throw InternetCallError.server(http.statusCode, message) + } + if Response.self == EmptyResponse.self, data.isEmpty { + return EmptyResponse() as! Response + } + return try decoder.decode(Response.self, from: data) + } + + private var platformName: String { +#if os(iOS) + return "ios" +#elseif os(macOS) + return "macos" +#else + return "apple" +#endif + } +} + +private struct EmptyResponse: Codable {} + +final class AccountDeviceController: ObservableObject { + @Published private(set) var devices: [AccountDevice] = [] + @Published private(set) var accountID: String + @Published private(set) var nickname: String? + @Published private(set) var generatedLinkCode: String? + @Published private(set) var linkCodeExpiresAt: Date? + @Published private(set) var isWorking = false + @Published private(set) var statusMessage: String? + @Published var linkCodeInput = "" + + var onIdentityChanged: ((DeviceIdentity) -> Void)? + + private var identity: DeviceIdentity + private var configuration: InternetCallConfiguration + private var api: InternetCallAPI + + init(identity: DeviceIdentity, configuration: InternetCallConfiguration) { + self.identity = identity + self.configuration = configuration + accountID = identity.userID + nickname = identity.nickname + api = InternetCallAPI(configuration: configuration) + } + + func update(identity: DeviceIdentity, configuration: InternetCallConfiguration) { + self.identity = identity + self.configuration = configuration + accountID = identity.userID + nickname = identity.nickname + api = InternetCallAPI(configuration: configuration) + } + + func sync() { + guard configuration.hasDirectoryAPI, !configuration.isDevelopmentDirect else { return } + run { identity, api in + try await api.register(identity: identity, + voipToken: UserDefaults.standard.string(forKey: "voipPushToken")) + return try await api.account(identity: identity) + } + } + + func createLinkCode() { + guard configuration.hasDirectoryAPI, !configuration.isDevelopmentDirect else { + statusMessage = "Configure the Directory API before linking another device." + return + } + isWorking = true + statusMessage = nil + let identity = self.identity + let api = self.api + Task { @MainActor in + do { + try await api.register(identity: identity, + voipToken: UserDefaults.standard.string(forKey: "voipPushToken")) + let result = try await api.createLinkCode(identity: identity) + generatedLinkCode = result.linkCode + linkCodeExpiresAt = Date(timeIntervalSince1970: TimeInterval(result.expiresAt)) + statusMessage = "Use this single-use code on the new device within 10 minutes." + } catch { + statusMessage = error.localizedDescription + } + isWorking = false + } + } + + func joinAccount() { + let code = linkCodeInput.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !code.isEmpty else { + statusMessage = "Enter the link code from a trusted device." + return + } + guard configuration.hasDirectoryAPI, !configuration.isDevelopmentDirect else { + statusMessage = "Configure the same Directory API on both devices first." + return + } + isWorking = true + statusMessage = nil + let identity = self.identity + let api = self.api + Task { @MainActor in + do { + try await api.register(identity: identity, + voipToken: UserDefaults.standard.string(forKey: "voipPushToken")) + let snapshot = try await api.linkDevice(code: code, identity: identity) + try apply(snapshot) + linkCodeInput = "" + statusMessage = "This device now belongs to @\(snapshot.nickname ?? "your account")." + } catch { + statusMessage = error.localizedDescription + } + isWorking = false + } + } + + func revoke(_ device: AccountDevice) { + guard !device.current else { + statusMessage = "Revoke this device from another trusted device." + return + } + isWorking = true + statusMessage = nil + let identity = self.identity + let api = self.api + Task { @MainActor in + do { + try await api.revokeDevice(device.deviceID, identity: identity) + let snapshot = try await api.account(identity: identity) + try apply(snapshot) + statusMessage = "\(device.displayName) was revoked." + } catch { + statusMessage = error.localizedDescription + } + isWorking = false + } + } + + private func run(_ operation: @escaping (DeviceIdentity, InternetCallAPI) async throws -> AccountSnapshot) { + isWorking = true + let identity = self.identity + let api = self.api + Task { @MainActor in + do { + try apply(try await operation(identity, api)) + statusMessage = nil + } catch { + statusMessage = error.localizedDescription + } + isWorking = false + } + } + + @MainActor + private func apply(_ snapshot: AccountSnapshot) throws { + let updated = try DeviceIdentityStore.shared.adoptAccount(userID: snapshot.accountID, + nickname: snapshot.nickname) + identity = updated + accountID = snapshot.accountID + nickname = snapshot.nickname + devices = snapshot.devices + onIdentityChanged?(updated) + } +} + +final class GroupChatController: ObservableObject { + @Published private(set) var chats: [GroupChatSummary] = [] + @Published private(set) var messages: [GroupChatMessage] = [] + @Published private(set) var activeChatID: String? + @Published private(set) var isWorking = false + @Published private(set) var statusMessage: String? + @Published var titleInput = "" + @Published var membersInput = "" + @Published var draft = "" + + /// Per-chat unread counts. Seeded from the server's `unread_count` on each + /// refresh, then incremented locally as new messages arrive while the chat + /// is not open, and cleared when the user opens the chat. + @Published private(set) var unreadByChat: [String: Int] = [:] + /// Total unread across all chats (server-supplied, 0 on older servers). + @Published private(set) var totalUnread: Int = 0 + /// Fired for each newly-arrived message authored by someone else, so the + /// view layer can play a chime. Set by the owning view model. + var onNewMessage: ((GroupChatMessage) -> Void)? + + var activeChat: GroupChatSummary? { + chats.first { $0.chatID == activeChatID } + } + + private var identity: DeviceIdentity + private var configuration: InternetCallConfiguration + private var api: InternetCallAPI + private var pollTimer: Timer? + private var refreshInFlight = false + /// Locally-counted unread that hasn't been reconciled with the server yet. + private var localUnreadByChat: [String: Int] = [:] + + init(identity: DeviceIdentity, configuration: InternetCallConfiguration) { + self.identity = identity + self.configuration = configuration + api = InternetCallAPI(configuration: configuration) + } + + func update(identity: DeviceIdentity, configuration: InternetCallConfiguration) { + self.identity = identity + self.configuration = configuration + api = InternetCallAPI(configuration: configuration) + startPolling() + } + + func startPolling() { + stopPolling() + guard configuration.hasDirectoryAPI, !configuration.isDevelopmentDirect else { + statusMessage = "Configure the Directory API to use persistent group chats." + return + } + let identity = self.identity + let api = self.api + Task { @MainActor [weak self] in + do { + try await api.register(identity: identity, + voipToken: UserDefaults.standard.string(forKey: "voipPushToken")) + self?.statusMessage = nil + self?.refresh() + } catch { + self?.statusMessage = error.localizedDescription + } + } + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.pollTimer = Timer.scheduledTimer(withTimeInterval: 3, repeats: true) { [weak self] _ in + self?.refresh() + } + } + } + + func stopPolling() { + let invalidate = { [weak self] in + self?.pollTimer?.invalidate() + self?.pollTimer = nil + } + if Thread.isMainThread { + invalidate() + } else { + DispatchQueue.main.async(execute: invalidate) + } + } + + func refresh() { + guard configuration.hasDirectoryAPI, + !configuration.isDevelopmentDirect, + !refreshInFlight else { return } + refreshInFlight = true + let identity = self.identity + let api = self.api + let selectedChatID = activeChatID + let afterMessageID = selectedChatID == nil ? 0 : (messages.last?.messageID ?? 0) + Task { @MainActor [weak self] in + guard let self else { return } + defer { self.refreshInFlight = false } + do { + let (chats, totalUnread) = try await api.groupChats(identity: identity) + self.chats = chats + self.totalUnread = totalUnread + // Reconcile unread counts: prefer the server's authoritative + // per-chat count, then overlay any locally-counted unread that + // arrived since the last refresh. + var reconciled: [String: Int] = [:] + for chat in chats where chat.unreadCount > 0 { + reconciled[chat.chatID] = chat.unreadCount + } + for (chatID, count) in self.localUnreadByChat { + let serverValue = reconciled[chatID] ?? 0 + if count > serverValue { + reconciled[chatID] = count + } + } + // The open chat is being read right now — never show unread. + if let openID = self.activeChatID { + reconciled[openID] = 0 + } + self.unreadByChat = reconciled + if let selectedChatID { + let received = try await api.groupMessages(chatID: selectedChatID, + afterMessageID: afterMessageID, + identity: identity) + guard self.activeChatID == selectedChatID else { return } + self.merge(received) + } + self.statusMessage = nil + } catch { + self.statusMessage = error.localizedDescription + } + } + } + + func createGroup() { + let members = parsedMembers() + guard !members.isEmpty else { + statusMessage = "Enter at least one participant nickname." + return + } + let title = titleInput.trimmingCharacters(in: .whitespacesAndNewlines) + isWorking = true + statusMessage = nil + let identity = self.identity + let api = self.api + Task { @MainActor [weak self] in + guard let self else { return } + defer { self.isWorking = false } + do { + let chat = try await api.createGroupChat(title: title.isEmpty ? nil : title, + members: members, + identity: identity) + self.chats.removeAll { $0.chatID == chat.chatID } + self.chats.insert(chat, at: 0) + self.titleInput = "" + self.membersInput = "" + self.open(chat) + self.statusMessage = "Group created." + } catch { + self.statusMessage = error.localizedDescription + } + } + } + + func open(_ chat: GroupChatSummary) { + activeChatID = chat.chatID + messages = [] + // Reading the chat clears its unread badge locally and on the server + // (the messages fetch above marks it read server-side via the cursor). + unreadByChat[chat.chatID] = 0 + localUnreadByChat[chat.chatID] = 0 + totalUnread = unreadByChat.values.reduce(0, +) + loadMessages(chatID: chat.chatID, afterMessageID: 0) + } + + func closeChat() { + activeChatID = nil + messages = [] + } + + func send() { + let text = draft.trimmingCharacters(in: .whitespacesAndNewlines) + guard let chatID = activeChatID, !text.isEmpty else { return } + isWorking = true + statusMessage = nil + let identity = self.identity + let api = self.api + let clientMessageID = UUID().uuidString.lowercased() + Task { @MainActor [weak self] in + guard let self else { return } + defer { self.isWorking = false } + do { + let message = try await api.sendGroupMessage(chatID: chatID, + clientMessageID: clientMessageID, + text: text, + identity: identity) + guard self.activeChatID == chatID else { return } + self.draft = "" + self.merge([message]) + self.refresh() + } catch { + self.statusMessage = error.localizedDescription + } + } + } + + private func loadMessages(chatID: String, afterMessageID: Int64) { + let identity = self.identity + let api = self.api + Task { @MainActor [weak self] in + guard let self else { return } + do { + let received = try await api.groupMessages(chatID: chatID, + afterMessageID: afterMessageID, + identity: identity) + guard self.activeChatID == chatID else { return } + self.merge(received) + self.statusMessage = nil + } catch { + self.statusMessage = error.localizedDescription + } + } + } + + private func parsedMembers() -> [String] { + let separators = CharacterSet.whitespacesAndNewlines + .union(CharacterSet(charactersIn: ",;")) + var members: [String] = [] + for component in membersInput.components(separatedBy: separators) { + let nickname = NicknamePolicy.normalize(component.trimmingCharacters(in: CharacterSet(charactersIn: "@"))) + guard !nickname.isEmpty, !members.contains(nickname) else { continue } + members.append(nickname) + } + return members + } + + private func merge(_ received: [GroupChatMessage]) { + for message in received where !messages.contains(where: { $0.messageID == message.messageID }) { + messages.append(message) + // A message from someone else is unread unless this chat is open. + let fromSelf = message.senderUserID == identity.userID + if !fromSelf && activeChatID != message.chatID { + let next = (localUnreadByChat[message.chatID] ?? 0) + 1 + localUnreadByChat[message.chatID] = next + unreadByChat[message.chatID] = max(unreadByChat[message.chatID] ?? 0, next) + totalUnread = unreadByChat.values.reduce(0, +) + onNewMessage?(message) + } + } + messages.sort { $0.messageID < $1.messageID } + } +} + +final class InternetCallController: NSObject, ObservableObject, RoomDelegate, @unchecked Sendable { + @Published private(set) var state: InternetCallState = .idle + @Published private(set) var callID: String? + @Published private(set) var participantName = "" + @Published private(set) var localVideoTrack: LocalVideoTrack? + @Published private(set) var remoteVideoTrack: RemoteVideoTrack? + @Published private(set) var errorMessage: String? + @Published private(set) var isMuted = false + @Published private(set) var isCameraEnabled = true + + var onChat: ((String) -> Void)? + var onReaction: ((String) -> Void)? + var onIncomingCall: ((IncomingInternetCall) -> Void)? + + private(set) var identity: DeviceIdentity + private var configuration: InternetCallConfiguration + private var api: InternetCallAPI + private var room: Room? + private var incomingPollTimer: Timer? + private var reportedIncomingCallIDs = Set() + private var registeredVoipToken = UserDefaults.standard.string(forKey: "voipPushToken") + + init(identity: DeviceIdentity, + configuration: InternetCallConfiguration = .load()) { + self.identity = identity + self.configuration = configuration + api = InternetCallAPI(configuration: configuration) + super.init() + } + + func update(identity: DeviceIdentity, configuration: InternetCallConfiguration) { + self.identity = identity + self.configuration = configuration + api = InternetCallAPI(configuration: configuration) + } + + func startIncomingPolling(voipToken: String? = nil) { + stopIncomingPolling() + guard configuration.hasDirectoryAPI, !configuration.isDevelopmentDirect else { return } + if let voipToken { registeredVoipToken = voipToken } + Task { [weak self] in + guard let self else { return } + try? await self.api.register(identity: self.identity, voipToken: self.registeredVoipToken) + await self.pollIncomingCalls() + } + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.incomingPollTimer = Timer.scheduledTimer(withTimeInterval: 3, repeats: true) { [weak self] _ in + Task { await self?.pollIncomingCalls() } + } + } + } + + func stopIncomingPolling() { + let invalidate = { [weak self] in + self?.incomingPollTimer?.invalidate() + self?.incomingPollTimer = nil + } + if Thread.isMainThread { invalidate() } else { DispatchQueue.main.async(execute: invalidate) } + } + + private func pollIncomingCalls() async { + guard configuration.hasDirectoryAPI, !configuration.isDevelopmentDirect else { return } + guard let calls = try? await api.incomingCalls(identity: identity) else { return } + guard let incoming = calls.first(where: { !reportedIncomingCallIDs.contains($0.callID) }) else { return } + setMain { + guard !self.reportedIncomingCallIDs.contains(incoming.callID) else { return } + self.reportedIncomingCallIDs.insert(incoming.callID) + self.onIncomingCall?(incoming) + } + } + + func registerDevice(voipToken: String? = nil) async throws { + registeredVoipToken = voipToken + setState(.registering) + try await api.register(identity: identity, voipToken: registeredVoipToken) + setState(.idle) + } + + func start(callee: String, audio: Bool = true, video: Bool = true) async throws { + guard configuration.isConfigured else { throw InternetCallError.notConfigured } + setState(.registering) + try await api.register(identity: identity, voipToken: registeredVoipToken) + let session = try await api.createCall(callee: callee, + identity: identity, + audio: audio, + video: video) + try await connect(session: session, audio: audio, video: video) + } + + func join(callID: String, audio: Bool = true, video: Bool = true) async throws { + guard configuration.isConfigured else { throw InternetCallError.notConfigured } + setState(.connecting) + let session = try await api.joinCall(callID: callID, identity: identity) + try await connect(session: session, audio: audio, video: video) + } + + private func connect(session: InternetCallSession, audio: Bool, video: Bool) async throws { + setState(.connecting) + setMain { self.callID = session.callID } + NSLog("TRINET: LiveKit connecting call=%@ url=%@", session.callID, session.liveKitURL) + let encryption = session.mediaKey.map { EncryptionOptions.sharedKey($0) } + let options = RoomOptions(adaptiveStream: true, + dynacast: true, + encryptionOptions: encryption, + reportRemoteTrackStatistics: true, + singlePeerConnection: true) + let newRoom = Room(delegate: self, roomOptions: options) + room = newRoom + do { + try await newRoom.connect(url: session.liveKitURL, token: session.token) + NSLog("TRINET: LiveKit signaling connected call=%@", session.callID) + let cameraPublication = try await newRoom.localParticipant.setCamera(enabled: video) + let microphonePublication = try await newRoom.localParticipant.setMicrophone(enabled: audio) + _ = microphonePublication + let existingParticipant = newRoom.remoteParticipants.values.first + let existingVideo = existingParticipant?.trackPublications.values + .compactMap { $0.track as? RemoteVideoTrack } + .first + setMain { + self.localVideoTrack = cameraPublication?.track as? LocalVideoTrack + if let existingParticipant { + self.participantName = self.participantLabel(existingParticipant) + } + if let existingVideo { self.remoteVideoTrack = existingVideo } + self.isCameraEnabled = video + self.isMuted = !audio + self.state = .connected + } + NSLog("TRINET: LiveKit media published call=%@ camera=%d microphone=%d", + session.callID, video ? 1 : 0, audio ? 1 : 0) + } catch { + NSLog("TRINET: LiveKit connect failed call=%@ error=%@", + session.callID, error.localizedDescription) + setFailure(error) + await newRoom.disconnect() + room = nil + throw error + } + } + + func setMuted(_ muted: Bool) { + guard let room else { return } + Task { + do { + _ = try await room.localParticipant.setMicrophone(enabled: !muted) + setMain { self.isMuted = muted } + } catch { + setFailure(error) + } + } + } + + func setCamera(enabled: Bool) { + guard let room else { return } + Task { + do { + let publication = try await room.localParticipant.setCamera(enabled: enabled) + setMain { + self.localVideoTrack = publication?.track as? LocalVideoTrack + self.isCameraEnabled = enabled + } + } catch { + setFailure(error) + } + } + } + + func sendChat(_ text: String) { + publish(kind: .chat, value: text) + } + + func sendReaction(_ value: String) { + publish(kind: .reaction, value: value) + } + + private func publish(kind: InternetDataMessage.Kind, value: String) { + guard let room else { return } + Task { + do { + let data = try JSONEncoder().encode(InternetDataMessage(kind: kind, value: value)) + let options = DataPublishOptions(topic: "trinet.control", reliable: true) + try await room.localParticipant.publish(data: data, options: options) + } catch { + setFailure(error) + } + } + } + + func disconnect() { + let oldRoom = room + room = nil + setMain { + self.state = .ended + self.callID = nil + self.participantName = "" + self.localVideoTrack = nil + self.remoteVideoTrack = nil + } + Task { await oldRoom?.disconnect() } + } + + func room(_ room: Room, + didUpdateConnectionState connectionState: ConnectionState, + from oldConnectionState: ConnectionState) { + switch connectionState { + case .connected: + NSLog("TRINET: LiveKit state connected") + setState(.connected) + case .reconnecting: + NSLog("TRINET: LiveKit state reconnecting") + setState(.reconnecting) + case .disconnected: + NSLog("TRINET: LiveKit state disconnected") + setState(.ended) + default: + break + } + } + + func room(_ room: Room, participantDidConnect participant: RemoteParticipant) { + let label = participantLabel(participant) + NSLog("TRINET: LiveKit participant connected %@", label) + setMain { self.participantName = label } + } + + func room(_ room: Room, + participant: RemoteParticipant, + didSubscribeTrack publication: RemoteTrackPublication) { + guard let video = publication.track as? RemoteVideoTrack else { return } + let label = participantLabel(participant) + NSLog("TRINET: LiveKit remote video subscribed %@", label) + setMain { + self.participantName = label + self.remoteVideoTrack = video + } + } + + func room(_ room: Room, + participant: RemoteParticipant, + didUnsubscribeTrack publication: RemoteTrackPublication) { + guard publication.track is RemoteVideoTrack else { return } + setMain { self.remoteVideoTrack = nil } + } + + func room(_ room: Room, + participant: RemoteParticipant?, + didReceiveData data: Data, + forTopic topic: String, + encryptionType: EncryptionType) { + guard topic == "trinet.control", + let message = try? JSONDecoder().decode(InternetDataMessage.self, from: data) else { return } + DispatchQueue.main.async { + switch message.kind { + case .chat: + self.onChat?(message.value) + case .reaction: + self.onReaction?(message.value) + } + } + } + + func room(_ room: Room, didFailToConnectWithError error: LiveKitError?) { + setFailure(error ?? InternetCallError.invalidResponse) + } + + func room(_ room: Room, didDisconnectWithError error: LiveKitError?) { + if let error { setFailure(error) } else { setState(.ended) } + } + + private func setState(_ state: InternetCallState) { + setMain { + self.state = state + if state != .failed { self.errorMessage = nil } + } + } + + private func participantLabel(_ participant: Participant) -> String { + if let name = participant.name, !name.isEmpty { return name } + return participant.identity?.stringValue ?? "Peer" + } + + private func setFailure(_ error: Error) { + setMain { + self.state = .failed + self.errorMessage = error.localizedDescription + } + } + + private func setMain(_ action: @escaping () -> Void) { + if Thread.isMainThread { action() } else { DispatchQueue.main.async(execute: action) } + } +} diff --git a/phone/shared/NicknameDirectory.swift b/phone/shared/NicknameDirectory.swift new file mode 100644 index 00000000..e41309b1 --- /dev/null +++ b/phone/shared/NicknameDirectory.swift @@ -0,0 +1,823 @@ +import Combine +import Foundation + +enum NicknameClaimKind: String, Codable { + case none + case meshLocal = "mesh-local" + case verified +} + +enum NicknamePolicy { + static let minimumLength = 3 + static let maximumLength = 20 + + static func normalize(_ value: String) -> String { + value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + } + + static func validationError(_ value: String) -> String? { + let nickname = normalize(value) + guard (minimumLength...maximumLength).contains(nickname.count) else { + return "Use 3 to 20 characters." + } + guard nickname.first?.isASCII == true, + nickname.first?.isLetter == true else { + return "The first character must be a letter." + } + let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyz0123456789_") + guard nickname.unicodeScalars.allSatisfy({ allowed.contains($0) }) else { + return "Use lowercase letters, numbers, and underscore only." + } + return nil + } + + static func isConfusing(_ candidate: String, with existing: String) -> Bool { + let lhs = normalize(candidate) + let rhs = normalize(existing) + if lhs == rhs { return true } + let distance = editDistance(lhs, rhs) + if distance <= 1 { return true } + return commonPrefixLength(lhs, rhs) >= 4 && distance == 2 + } + + static func suggestions(for value: String, + excluding existing: [String], + seed: String) -> [String] { + var base = normalize(value).filter { $0.isASCII && ($0.isLetter || $0.isNumber || $0 == "_") } + if base.first?.isLetter != true { base = "user_" + base } + if base.count < minimumLength { base += "net" } + base = String(base.prefix(maximumLength - 3)) + let suffixSeed = seed.unicodeScalars.reduce(0) { ($0 * 31 + Int($1.value)) % 997 } + let existingNames = existing.map(normalize) + return (0..<20).compactMap { offset in + let suffix = String(format: "%03d", (suffixSeed + offset * 37) % 1000) + let proposal = String(base.prefix(maximumLength - suffix.count)) + suffix + return existingNames.contains(where: { isConfusing(proposal, with: $0) }) ? nil : proposal + }.prefix(3).map { $0 } + } + + private static func commonPrefixLength(_ lhs: String, _ rhs: String) -> Int { + zip(lhs, rhs).prefix(while: { $0 == $1 }).count + } + + private static func editDistance(_ lhs: String, _ rhs: String) -> Int { + let left = Array(lhs) + let right = Array(rhs) + if left.isEmpty { return right.count } + if right.isEmpty { return left.count } + var previous = Array(0...right.count) + for (leftIndex, leftCharacter) in left.enumerated() { + var current = [leftIndex + 1] + for (rightIndex, rightCharacter) in right.enumerated() { + current.append(min( + current[rightIndex] + 1, + previous[rightIndex + 1] + 1, + previous[rightIndex] + (leftCharacter == rightCharacter ? 0 : 1) + )) + } + previous = current + } + return previous[right.count] + } +} + +struct NicknameClaimRequest: Encodable { + let nickname: String + let userID: String + let deviceID: String +} + +struct NicknameClaimResponse: Decodable { + let claimed: Bool + let normalized: String + let reason: String? + let suggestions: [String] +} + +struct NicknameSearchRequest: Encodable { + let query: String + let limit: Int +} + +private struct InternetDirectoryContact: Decodable { + let userID: String + let deviceID: String + let nickname: String + let displayName: String? + let keyFingerprint: String + let online: Bool + + enum CodingKeys: String, CodingKey { + case userID = "user_id" + case deviceID = "device_id" + case nickname + case displayName = "display_name" + case keyFingerprint = "key_fingerprint" + case online + } +} + +struct NicknameSearchResponse: Decodable { + fileprivate let results: [InternetDirectoryContact] +} + +enum DirectorySource: String, Codable { + case mesh = "LOCAL" + case internet = "INTERNET" +} + +struct DirectoryContact: Identifiable, Equatable { + let userID: String + let deviceID: String + let nickname: String + let displayName: String + let keyFingerprint: String + let source: DirectorySource + let online: Bool + let meshAddress: String? + let meshPort: UInt16? + + var id: String { "\(source.rawValue):\(deviceID)" } +} + +private struct MeshPeer: Equatable { + let serviceName: String + let userID: String + let deviceID: String + let nickname: String + let displayName: String + let keyFingerprint: String + let address: String + let port: UInt16 +} + +private struct CachedMeshPeer: Codable { + let userID: String + let deviceID: String + let nickname: String + let displayName: String + let keyFingerprint: String + let address: String + let port: UInt16 + let lastSeen: Int64 +} + +struct MeshCallInvite: Codable, Identifiable, Equatable { + let version: UInt8 + let callID: String + let nickname: String + let displayName: String + let userID: String + let deviceID: String + let publicKey: String + let keyFingerprint: String + let mediaPort: UInt16 + let timestamp: Int64 + let nonce: String + let signature: String + + var id: String { callID } +} + +struct IncomingMeshCall: Identifiable, Equatable { + let invite: MeshCallInvite + let sourceAddress: String + + var id: String { invite.callID } +} + +enum MeshCallSignalingError: LocalizedError { + case invalidAddress + case missingIdentity + case socketFailure(Int32) + + var errorDescription: String? { + switch self { + case .invalidAddress: + return "The local peer address is invalid." + case .missingIdentity: + return "Create a nickname before placing a local call." + case let .socketFailure(code): + return "Local call signaling failed (errno \(code))." + } + } +} + +final class MeshCallSignaling { + static let port: UInt16 = 7001 + static let mediaPort: UInt16 = 7000 + + var onInvite: ((MeshCallInvite, String) -> Void)? + + private var fd: Int32 = -1 + private var running = false + private var identity: DeviceIdentity + private var seenNonces: [String: Int64] = [:] + private let receiveQueue = DispatchQueue(label: "trinet.mesh.signal", qos: .userInitiated) + + init(identity: DeviceIdentity) { + self.identity = identity + } + + func update(identity: DeviceIdentity) { + self.identity = identity + } + + func start() { + guard fd < 0 else { return } + let socketFD = socket(AF_INET, SOCK_DGRAM, 0) + guard socketFD >= 0 else { return } + var enabled: Int32 = 1 + setsockopt(socketFD, SOL_SOCKET, SO_REUSEADDR, &enabled, socklen_t(MemoryLayout.size)) + var address = sockaddr_in() + address.sin_family = sa_family_t(AF_INET) + address.sin_port = Self.port.bigEndian + address.sin_addr.s_addr = in_addr_t(0) + let result = withUnsafePointer(to: &address) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { + Darwin.bind(socketFD, $0, socklen_t(MemoryLayout.size)) + } + } + guard result == 0 else { + close(socketFD) + return + } + fd = socketFD + running = true + receiveQueue.async { [weak self] in self?.receiveLoop(socketFD) } + } + + func stop() { + running = false + if fd >= 0 { + shutdown(fd, SHUT_RDWR) + close(fd) + fd = -1 + } + } + + func sendInvite(to address: String, port: UInt16 = MeshCallSignaling.port) throws -> MeshCallInvite { + guard let nickname = identity.nickname, NicknamePolicy.validationError(nickname) == nil else { + throw MeshCallSignalingError.missingIdentity + } + let callID = UUID().uuidString.lowercased() + let timestamp = Int64(Date().timeIntervalSince1970) + let nonce = UUID().uuidString.lowercased() + let payload = Self.signedPayload(callID: callID, + nickname: nickname, + displayName: identity.displayName, + userID: identity.userID, + deviceID: identity.deviceID, + mediaPort: Self.mediaPort, + timestamp: timestamp, + nonce: nonce) + let signature = try DeviceIdentityStore.shared.signMessage(payload) + let invite = MeshCallInvite(version: 1, + callID: callID, + nickname: nickname, + displayName: identity.displayName, + userID: identity.userID, + deviceID: identity.deviceID, + publicKey: identity.signingPublicKey, + keyFingerprint: identity.keyFingerprint, + mediaPort: Self.mediaPort, + timestamp: timestamp, + nonce: nonce, + signature: signature) + guard var destinationAddress = IPv4Address(address, port: port) else { + throw MeshCallSignalingError.invalidAddress + } + let sendFD = socket(AF_INET, SOCK_DGRAM, 0) + guard sendFD >= 0 else { throw MeshCallSignalingError.socketFailure(errno) } + defer { close(sendFD) } + let data = try JSONEncoder().encode(invite) + let sent = data.withUnsafeBytes { bytes in + withUnsafePointer(to: &destinationAddress) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { + sendto(sendFD, + bytes.baseAddress, + bytes.count, + 0, + $0, + socklen_t(MemoryLayout.size)) + } + } + } + guard sent == data.count else { throw MeshCallSignalingError.socketFailure(errno) } + return invite + } + + private func receiveLoop(_ socketFD: Int32) { + var buffer = [UInt8](repeating: 0, count: 4096) + while running && fd == socketFD { + var source = sockaddr_in() + var sourceLength = socklen_t(MemoryLayout.size) + let count = withUnsafeMutablePointer(to: &source) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { + recvfrom(socketFD, &buffer, buffer.count, 0, $0, &sourceLength) + } + } + guard count > 0 else { break } + let data = Data(buffer.prefix(count)) + guard let invite = try? JSONDecoder().decode(MeshCallInvite.self, from: data), + verify(invite), + let sourceAddress = Self.string(from: source) else { continue } + DispatchQueue.main.async { self.onInvite?(invite, sourceAddress) } + } + } + + private func verify(_ invite: MeshCallInvite) -> Bool { + let now = Int64(Date().timeIntervalSince1970) + guard now >= invite.timestamp, + now - invite.timestamp <= 30, + invite.deviceID != identity.deviceID, + Self.signatureIsValid(invite), + seenNonces[invite.nonce] == nil else { return false } + seenNonces = seenNonces.filter { now - $0.value <= 30 } + seenNonces[invite.nonce] = invite.timestamp + return true + } + + static func signatureIsValid(_ invite: MeshCallInvite) -> Bool { + guard invite.version == 1, + invite.mediaPort == Self.mediaPort, + NicknamePolicy.validationError(invite.nickname) == nil, + DeviceIdentityStore.fingerprint(for: invite.publicKey) == invite.keyFingerprint else { + return false + } + let payload = signedPayload(callID: invite.callID, + nickname: invite.nickname, + displayName: invite.displayName, + userID: invite.userID, + deviceID: invite.deviceID, + mediaPort: invite.mediaPort, + timestamp: invite.timestamp, + nonce: invite.nonce) + return DeviceIdentityStore.verifyMessage(payload, + signature: invite.signature, + publicKey: invite.publicKey) + } + + static func signedPayload(callID: String, + nickname: String, + displayName: String, + userID: String, + deviceID: String, + mediaPort: UInt16, + timestamp: Int64, + nonce: String) -> Data { + Data(["mesh-invite-v1", + callID, + NicknamePolicy.normalize(nickname), + displayName, + userID, + deviceID, + String(mediaPort), + String(timestamp), + nonce].joined(separator: "\n").utf8) + } + + private static func string(from address: sockaddr_in) -> String? { + var copy = address.sin_addr + var output = [CChar](repeating: 0, count: Int(INET_ADDRSTRLEN)) + guard inet_ntop(AF_INET, ©, &output, socklen_t(output.count)) != nil else { return nil } + return String(cString: output) + } +} + +private func IPv4Address(_ address: String, port: UInt16) -> sockaddr_in? { + var result = sockaddr_in() + result.sin_family = sa_family_t(AF_INET) + result.sin_port = port.bigEndian + guard inet_pton(AF_INET, address, &result.sin_addr) == 1 else { return nil } + return result +} + +final class MeshNicknameDirectory: NSObject, NetServiceBrowserDelegate, NetServiceDelegate { + static let serviceType = "_trinet-call._udp." + private static let cacheKey = "trinet.mesh.nickname.routes" + private static let cacheTTL: Int64 = 7 * 24 * 60 * 60 + + var onPeersChanged: (([DirectoryContact]) -> Void)? + + private let browser = NetServiceBrowser() + private var publisher: NetService? + private var identity: DeviceIdentity? + private var resolving: [ObjectIdentifier: NetService] = [:] + private var peersByService: [String: MeshPeer] = [:] + private var cachedPeersByDevice: [String: CachedMeshPeer] = [:] + private var started = false + + override init() { + super.init() + loadCache() + browser.delegate = self + browser.includesPeerToPeer = true + } + + func start(identity: DeviceIdentity) { + self.identity = identity + if !started { + started = true + browser.searchForServices(ofType: Self.serviceType, inDomain: "local.") + } + publish(identity: identity) + emitPeers() + } + + func stop() { + browser.stop() + publisher?.stop() + publisher = nil + started = false + peersByService.removeAll() + emitPeers() + } + + func contact(named nickname: String) -> DirectoryContact? { + let target = NicknamePolicy.normalize(nickname) + if let active = peersByService.values.first(where: { NicknamePolicy.normalize($0.nickname) == target }) { + return contact(active) + } + return cachedPeersByDevice.values + .first(where: { NicknamePolicy.normalize($0.nickname) == target }) + .map(cachedContact) + } + + private func publish(identity: DeviceIdentity) { + publisher?.stop() + publisher = nil + guard let nickname = identity.nickname, !nickname.isEmpty else { return } + let port = MeshCallSignaling.port + let payload = signedPayload(nickname: nickname, + userID: identity.userID, + deviceID: identity.deviceID, + port: port) + guard let signature = try? DeviceIdentityStore.shared.signMessage(payload) else { return } + let service = NetService(domain: "local.", + type: Self.serviceType, + name: "trinet-\(identity.deviceID.prefix(8))", + port: Int32(port)) + service.includesPeerToPeer = true + service.delegate = self + service.setTXTRecord(NetService.data(fromTXTRecord: [ + "nick": Data(nickname.utf8), + "name": Data(identity.displayName.utf8), + "uid": Data(identity.userID.utf8), + "did": Data(identity.deviceID.utf8), + "fp": Data(identity.keyFingerprint.utf8), + "pk": Data(identity.signingPublicKey.utf8), + "sig": Data(signature.utf8) + ])) + publisher = service + service.publish() + } + + func netServiceBrowser(_ browser: NetServiceBrowser, + didFind service: NetService, + moreComing: Bool) { + guard service.name != publisher?.name else { return } + resolving[ObjectIdentifier(service)] = service + service.delegate = self + service.resolve(withTimeout: 5) + } + + func netServiceBrowser(_ browser: NetServiceBrowser, + didRemove service: NetService, + moreComing: Bool) { + peersByService.removeValue(forKey: service.name) + resolving.removeValue(forKey: ObjectIdentifier(service)) + emitPeers() + } + + func netServiceDidResolveAddress(_ sender: NetService) { + defer { resolving.removeValue(forKey: ObjectIdentifier(sender)) } + guard let record = sender.txtRecordData().map(NetService.dictionary(fromTXTRecord:)), + let nickname = text(record["nick"]), + let userID = text(record["uid"]), + let deviceID = text(record["did"]), + let fingerprint = text(record["fp"]), + let publicKey = text(record["pk"]), + let signature = text(record["sig"]), + let address = numericAddress(sender.addresses), + deviceID != identity?.deviceID else { return } + let port = UInt16(clamping: sender.port) + let payload = signedPayload(nickname: nickname, userID: userID, deviceID: deviceID, port: port) + guard NicknamePolicy.validationError(nickname) == nil, + DeviceIdentityStore.fingerprint(for: publicKey) == fingerprint, + DeviceIdentityStore.verifyMessage(payload, signature: signature, publicKey: publicKey) else { return } + peersByService[sender.name] = MeshPeer( + serviceName: sender.name, + userID: userID, + deviceID: deviceID, + nickname: nickname, + displayName: text(record["name"]) ?? nickname, + keyFingerprint: fingerprint, + address: address, + port: port + ) + cachedPeersByDevice[deviceID] = CachedMeshPeer(userID: userID, + deviceID: deviceID, + nickname: nickname, + displayName: text(record["name"]) ?? nickname, + keyFingerprint: fingerprint, + address: address, + port: port, + lastSeen: Int64(Date().timeIntervalSince1970)) + saveCache() + emitPeers() + } + + private func contact(_ peer: MeshPeer) -> DirectoryContact { + DirectoryContact(userID: peer.userID, + deviceID: peer.deviceID, + nickname: peer.nickname, + displayName: peer.displayName, + keyFingerprint: peer.keyFingerprint, + source: .mesh, + online: true, + meshAddress: peer.address, + meshPort: peer.port) + } + + private func emitPeers() { + let active = peersByService.values.map(contact) + let activeDeviceIDs = Set(active.map(\.deviceID)) + let cached = cachedPeersByDevice.values + .filter { !activeDeviceIDs.contains($0.deviceID) } + .map(cachedContact) + let contacts = (active + cached).sorted { $0.nickname < $1.nickname } + DispatchQueue.main.async { self.onPeersChanged?(contacts) } + } + + private func cachedContact(_ peer: CachedMeshPeer) -> DirectoryContact { + DirectoryContact(userID: peer.userID, + deviceID: peer.deviceID, + nickname: peer.nickname, + displayName: peer.displayName, + keyFingerprint: peer.keyFingerprint, + source: .mesh, + online: false, + meshAddress: peer.address, + meshPort: peer.port) + } + + private func loadCache() { + let now = Int64(Date().timeIntervalSince1970) + guard let data = UserDefaults.standard.data(forKey: Self.cacheKey), + let cached = try? JSONDecoder().decode([CachedMeshPeer].self, from: data) else { return } + cachedPeersByDevice = Dictionary(uniqueKeysWithValues: cached + .filter { now >= $0.lastSeen && now - $0.lastSeen <= Self.cacheTTL } + .map { ($0.deviceID, $0) }) + } + + private func saveCache() { + let now = Int64(Date().timeIntervalSince1970) + cachedPeersByDevice = cachedPeersByDevice.filter { + now >= $0.value.lastSeen && now - $0.value.lastSeen <= Self.cacheTTL + } + if let data = try? JSONEncoder().encode(Array(cachedPeersByDevice.values)) { + UserDefaults.standard.set(data, forKey: Self.cacheKey) + } + } + + private func text(_ data: Data?) -> String? { + data.flatMap { String(data: $0, encoding: .utf8) } + } + + private func signedPayload(nickname: String, + userID: String, + deviceID: String, + port: UInt16) -> Data { + Data("\(NicknamePolicy.normalize(nickname))\n\(userID)\n\(deviceID)\n\(port)".utf8) + } + + private func numericAddress(_ addresses: [Data]?) -> String? { + let candidates = (addresses ?? []).compactMap { data -> (String, Int)? in + guard addressFamily(data) == AF_INET else { return nil } + return data.withUnsafeBytes { raw -> (String, Int)? in + guard let base = raw.baseAddress else { return nil } + let socketAddress = base.assumingMemoryBound(to: sockaddr.self) + var host = [CChar](repeating: 0, count: Int(NI_MAXHOST)) + guard getnameinfo(socketAddress, + socklen_t(data.count), + &host, + socklen_t(host.count), + nil, + 0, + NI_NUMERICHOST) == 0 else { return nil } + let value = String(cString: host) + guard value != "0.0.0.0" else { return nil } + let rank = value.hasPrefix("169.254.") ? 1 : (value.hasPrefix("127.") ? 2 : 0) + return (value, rank) + } + } + return candidates.sorted { $0.1 < $1.1 }.first?.0 + } + + private func addressFamily(_ data: Data) -> sa_family_t { + data.withUnsafeBytes { raw in + raw.baseAddress?.assumingMemoryBound(to: sockaddr.self).pointee.sa_family ?? 0 + } + } +} + +final class NicknameDirectoryController: ObservableObject { + @Published var proposedNickname = "" + @Published var searchQuery = "" + @Published private(set) var currentNickname: String? + @Published private(set) var claimKind: NicknameClaimKind + @Published private(set) var suggestions: [String] = [] + @Published private(set) var results: [DirectoryContact] = [] + @Published private(set) var meshPeers: [DirectoryContact] = [] + @Published private(set) var isWorking = false + @Published private(set) var statusMessage: String? + + var onIdentityChanged: ((DeviceIdentity) -> Void)? + var onIncomingMeshInvite: ((MeshCallInvite, String) -> Void)? + + private var identity: DeviceIdentity + private var configuration: InternetCallConfiguration + private var api: InternetCallAPI + private let mesh = MeshNicknameDirectory() + private let signaling: MeshCallSignaling + + init(identity: DeviceIdentity, configuration: InternetCallConfiguration) { + self.identity = identity + self.configuration = configuration + api = InternetCallAPI(configuration: configuration) + signaling = MeshCallSignaling(identity: identity) + currentNickname = identity.nickname + claimKind = NicknameClaimKind(rawValue: UserDefaults.standard.string(forKey: "nicknameClaimKind") ?? "") + ?? (identity.nickname == nil ? .none : .meshLocal) + proposedNickname = identity.nickname ?? "" + mesh.onPeersChanged = { [weak self] peers in + guard let self else { return } + self.meshPeers = peers + self.refreshLocalResults() + self.detectLocalConflict() + } + signaling.onInvite = { [weak self] invite, address in + guard let self else { return } + self.onIncomingMeshInvite?(invite, address) + } + mesh.start(identity: identity) + signaling.start() + reconcileProvisionalNickname() + } + + func update(identity: DeviceIdentity, configuration: InternetCallConfiguration) { + self.identity = identity + currentNickname = identity.nickname + self.configuration = configuration + api = InternetCallAPI(configuration: configuration) + signaling.update(identity: identity) + mesh.start(identity: identity) + reconcileProvisionalNickname() + } + + func sendMeshInvite(to address: String, port: UInt16?) throws -> MeshCallInvite { + try signaling.sendInvite(to: address, port: port ?? MeshCallSignaling.port) + } + + func claimProposedNickname() { + let candidate = NicknamePolicy.normalize(proposedNickname) + proposedNickname = candidate + suggestions = [] + statusMessage = nil + if let error = NicknamePolicy.validationError(candidate) { + statusMessage = error + suggestions = localSuggestions(candidate) + return + } + if let collision = meshPeers.first(where: { + $0.userID != identity.userID && NicknamePolicy.isConfusing(candidate, with: $0.nickname) + }) { + statusMessage = "@\(candidate) is too similar to mesh user @\(collision.nickname)." + suggestions = localSuggestions(candidate) + return + } + + isWorking = true + Task { @MainActor in + do { + if configuration.hasDirectoryAPI { + let response = try await api.claimNickname(candidate, identity: identity) + guard response.claimed else { + suggestions = response.suggestions.isEmpty ? localSuggestions(candidate) : response.suggestions + statusMessage = response.reason ?? "That nickname is unavailable." + isWorking = false + return + } + try persistNickname(response.normalized, kind: .verified) + statusMessage = "@\(response.normalized) is globally verified." + } else { + try persistNickname(candidate, kind: .meshLocal) + statusMessage = "@\(candidate) is active in this mesh. Connect the Directory API for global verification." + } + } catch is URLError { + do { + try persistNickname(candidate, kind: .meshLocal) + statusMessage = "Directory is offline. @\(candidate) is active as a provisional mesh-local nickname." + } catch { + statusMessage = error.localizedDescription + } + } catch { + statusMessage = error.localizedDescription + suggestions = localSuggestions(candidate) + } + isWorking = false + } + } + + func search() { + let query = NicknamePolicy.normalize(searchQuery) + refreshLocalResults() + guard !query.isEmpty else { return } + if !configuration.hasDirectoryAPI { return } + isWorking = true + Task { + do { + let remote = try await api.searchNicknames(query, identity: identity).results.map { + DirectoryContact(userID: $0.userID, + deviceID: $0.deviceID, + nickname: $0.nickname, + displayName: $0.displayName ?? $0.nickname, + keyFingerprint: $0.keyFingerprint, + source: .internet, + online: $0.online, + meshAddress: nil, + meshPort: nil) + } + let meshDeviceIDs = Set(results.filter { $0.source == .mesh }.map(\.deviceID)) + results += remote.filter { !meshDeviceIDs.contains($0.deviceID) } + } catch { + statusMessage = error.localizedDescription + } + isWorking = false + } + } + + func meshContact(named nickname: String) -> DirectoryContact? { + mesh.contact(named: nickname) + } + + private func reconcileProvisionalNickname() { + guard configuration.hasDirectoryAPI, + !configuration.isDevelopmentDirect, + claimKind != .verified, + let nickname = identity.nickname, + !isWorking else { return } + isWorking = true + Task { @MainActor in + do { + try await api.register(identity: identity, + voipToken: UserDefaults.standard.string(forKey: "voipPushToken")) + let response = try await api.claimNickname(nickname, identity: identity) + if response.claimed { + try persistNickname(response.normalized, kind: .verified) + statusMessage = "@\(response.normalized) is globally verified." + } else { + suggestions = response.suggestions.isEmpty ? localSuggestions(nickname) : response.suggestions + statusMessage = response.reason ?? "Choose another nickname for global use." + } + } catch { + statusMessage = "Global nickname verification is pending: \(error.localizedDescription)" + } + isWorking = false + } + } + + private func persistNickname(_ nickname: String, kind: NicknameClaimKind) throws { + identity = try DeviceIdentityStore.shared.setNickname(nickname) + currentNickname = nickname + claimKind = kind + proposedNickname = nickname + UserDefaults.standard.set(kind.rawValue, forKey: "nicknameClaimKind") + mesh.start(identity: identity) + signaling.update(identity: identity) + onIdentityChanged?(identity) + } + + private func refreshLocalResults() { + let query = NicknamePolicy.normalize(searchQuery) + results = meshPeers.filter { + query.isEmpty || NicknamePolicy.normalize($0.nickname).contains(query) + } + } + + private func detectLocalConflict() { + guard let own = identity.nickname, + let conflict = meshPeers.first(where: { + $0.userID != identity.userID && NicknamePolicy.isConfusing(own, with: $0.nickname) + }) else { return } + statusMessage = "Nickname conflict with @\(conflict.nickname) in this mesh. Choose another nickname." + suggestions = localSuggestions(own) + } + + private func localSuggestions(_ candidate: String) -> [String] { + NicknamePolicy.suggestions(for: candidate, + excluding: meshPeers.map(\.nickname), + seed: identity.deviceID) + } +} diff --git a/services/call-api/.gitignore b/services/call-api/.gitignore new file mode 100644 index 00000000..de67c1ec --- /dev/null +++ b/services/call-api/.gitignore @@ -0,0 +1,4 @@ +/target/ +/*.sqlite +/*.sqlite-shm +/*.sqlite-wal diff --git a/services/call-api/Cargo.lock b/services/call-api/Cargo.lock new file mode 100644 index 00000000..8f2922b5 --- /dev/null +++ b/services/call-api/Cargo.lock @@ -0,0 +1,973 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[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 = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89588d05638b5b4594a3348a2d6c20277e43a7f5c5202b05cc56888475a47b8" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "pem-rfc7468", + "pkcs8", + "rand_core", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core", + "subtle", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[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.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-util" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[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 = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core", + "subtle", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" +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 = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + +[[package]] +name = "rusqlite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[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 = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core", +] + +[[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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +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" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[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", + "tracing", +] + +[[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 = [ + "log", + "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 = "trinet-call-api" +version = "0.1.0" +dependencies = [ + "axum", + "base64", + "hmac", + "http-body-util", + "p256", + "rand_core", + "rusqlite", + "serde", + "serde_json", + "sha2", + "tokio", + "tower", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zerocopy" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/services/call-api/Cargo.toml b/services/call-api/Cargo.toml new file mode 100644 index 00000000..4b9a3d84 --- /dev/null +++ b/services/call-api/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "trinet-call-api" +version = "0.1.0" +edition = "2021" +license = "Apache-2.0" +description = "TRI-NET signed nickname directory and Internet call signaling adapter." + +[dependencies] +axum = "0.8" +base64 = "0.22" +hmac = "0.12" +p256 = { version = "0.13", features = ["ecdsa"] } +rand_core = { version = "0.6", features = ["getrandom"] } +rusqlite = { version = "0.32", features = ["bundled"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.10" +tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "signal"] } + +[dev-dependencies] +http-body-util = "0.1" +tower = { version = "0.5", features = ["util"] } diff --git a/services/call-api/Dockerfile b/services/call-api/Dockerfile new file mode 100644 index 00000000..fc05a27a --- /dev/null +++ b/services/call-api/Dockerfile @@ -0,0 +1,19 @@ +FROM rust:1.88-bookworm AS builder + +WORKDIR /workspace +COPY . . +RUN cargo build --release --locked --manifest-path services/call-api/Cargo.toml + +FROM debian:bookworm-slim + +RUN useradd --create-home --uid 10001 trinet \ + && mkdir -p /data \ + && chown trinet:trinet /data +COPY --from=builder /workspace/services/call-api/target/release/trinet-call-api /usr/local/bin/trinet-call-api + +USER trinet +WORKDIR /data +ENV TRINET_BIND=0.0.0.0:8080 +ENV TRINET_DB_PATH=/data/trinet-call.sqlite +EXPOSE 8080 +ENTRYPOINT ["/usr/local/bin/trinet-call-api"] diff --git a/services/call-api/README.md b/services/call-api/README.md new file mode 100644 index 00000000..773e59fd --- /dev/null +++ b/services/call-api/README.md @@ -0,0 +1,58 @@ +# TRI-NET Call API + +This service is the signed Internet directory and call-signaling adapter for +the Apple clients. The policy remains in `specs/internet_call.t27`, +`specs/nickname_directory.t27`, `specs/account_identity.t27`, and +`specs/group_chat.t27`; this crate provides HTTP, P-256 proof verification, +replay protection, SQLite transactions, persistent account-level group chat, +and short-lived room-scoped LiveKit tokens. + +## Run locally + +Start LiveKit first, then run: + +```console +TRINET_BIND=127.0.0.1:8080 \ +TRINET_DB_PATH=/tmp/trinet-call.sqlite \ +TRINET_LIVEKIT_URL=ws://127.0.0.1:7880 \ +LIVEKIT_API_KEY=devkey \ +LIVEKIT_API_SECRET=secret \ +cargo run --manifest-path services/call-api/Cargo.toml +``` + +The `devkey` and `secret` values are only for a local LiveKit server started +in development mode. Never use them on a reachable host. + +Run the isolated service tests with: + +```console +cargo test --manifest-path services/call-api/Cargo.toml --locked +``` + +The end-to-end tests create independent P-256 device identities and exercise +signed registration, atomic nickname claims, account linking, online presence, +call fan-out with first-answer-wins semantics, group membership, idempotent +messages, recipient authorization, and room-token issuance. + +## Required production configuration + +- `TRINET_BIND`: listener address, normally `0.0.0.0:8080` +- `TRINET_DB_PATH`: durable SQLite path mounted on persistent storage +- `TRINET_LIVEKIT_URL`: public `wss://` LiveKit endpoint +- `LIVEKIT_API_KEY`: LiveKit server API key +- `LIVEKIT_API_SECRET`: LiveKit server API secret +- `TRINET_SERVICE_ACCESS_TOKEN`: optional second factor shared by approved app + builds; device P-256 signatures remain mandatory + +Run one API replica while SQLite is used. A multi-replica deployment should +replace the persistence adapter with PostgreSQL while preserving the atomic +nickname transaction and nonce uniqueness constraints. + +Build the container from the repository root: + +```console +docker build -f services/call-api/Dockerfile -t trinet-call-api . +``` + +Terminate public TLS at the hosting platform or a reverse proxy and expose +only HTTPS to clients. Keep the LiveKit API secret server-side. diff --git a/services/call-api/src/main.rs b/services/call-api/src/main.rs new file mode 100644 index 00000000..6e2d63b3 --- /dev/null +++ b/services/call-api/src/main.rs @@ -0,0 +1,2533 @@ +//! TRI-NET Internet directory and call-signaling adapter. +//! +//! Business policy is generated from `specs/*.t27`. This binary owns only +//! HTTP, cryptographic proof verification, SQLite persistence, and LiveKit +//! participant-token generation. + +use std::{ + env, + net::SocketAddr, + sync::{Arc, Mutex, MutexGuard}, + time::{SystemTime, UNIX_EPOCH}, +}; + +use axum::{ + body::Bytes, + extract::{Path, State}, + http::{HeaderMap, StatusCode}, + response::{IntoResponse, Response}, + routing::{get, post}, + Json, Router, +}; +use base64::{engine::general_purpose, Engine as _}; +use hmac::{Hmac, Mac}; +use p256::ecdsa::{signature::Verifier, Signature, VerifyingKey}; +use rand_core::{OsRng, RngCore}; +use rusqlite::{params, Connection, OptionalExtension, TransactionBehavior}; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +#[path = "../../../gen/rust/internet_call.rs"] +mod internet_call; +#[path = "../../../gen/rust/nickname_directory.rs"] +mod nickname_directory; +#[path = "../../../gen/rust/account_identity.rs"] +mod account_identity; +#[path = "../../../gen/rust/group_chat.rs"] +mod group_chat; + +type HmacSha256 = Hmac; + +#[derive(Clone)] +struct AppState { + database: Arc>, + configuration: Arc, +} + +struct Configuration { + bind: SocketAddr, + livekit_url: String, + livekit_api_key: String, + livekit_api_secret: String, + service_access_token: Option, +} + +impl Configuration { + fn load() -> Result<(Self, String), String> { + let bind = env::var("TRINET_BIND") + .unwrap_or_else(|_| "127.0.0.1:8080".to_string()) + .parse() + .map_err(|error| format!("invalid TRINET_BIND: {error}"))?; + let database_path = + env::var("TRINET_DB_PATH").unwrap_or_else(|_| "trinet-call.db".to_string()); + let livekit_url = required_environment("TRINET_LIVEKIT_URL")?; + let livekit_api_key = required_environment("LIVEKIT_API_KEY")?; + let livekit_api_secret = required_environment("LIVEKIT_API_SECRET")?; + let service_access_token = env::var("TRINET_SERVICE_ACCESS_TOKEN") + .ok() + .filter(|value| !value.is_empty()); + Ok(( + Self { + bind, + livekit_url, + livekit_api_key, + livekit_api_secret, + service_access_token, + }, + database_path, + )) + } +} + +fn required_environment(name: &str) -> Result { + env::var(name) + .ok() + .filter(|value| !value.is_empty()) + .ok_or_else(|| format!("missing required environment variable {name}")) +} + +#[derive(Debug)] +struct ApiError { + status: StatusCode, + message: String, +} + +impl ApiError { + fn bad_request(message: impl Into) -> Self { + Self { + status: StatusCode::BAD_REQUEST, + message: message.into(), + } + } + + fn unauthorized(message: impl Into) -> Self { + Self { + status: StatusCode::UNAUTHORIZED, + message: message.into(), + } + } + + fn forbidden(message: impl Into) -> Self { + Self { + status: StatusCode::FORBIDDEN, + message: message.into(), + } + } + + fn not_found(message: impl Into) -> Self { + Self { + status: StatusCode::NOT_FOUND, + message: message.into(), + } + } + + fn conflict(message: impl Into) -> Self { + Self { + status: StatusCode::CONFLICT, + message: message.into(), + } + } + + fn internal(message: impl Into) -> Self { + Self { + status: StatusCode::INTERNAL_SERVER_ERROR, + message: message.into(), + } + } +} + +impl IntoResponse for ApiError { + fn into_response(self) -> Response { + (self.status, self.message).into_response() + } +} + +impl From for ApiError { + fn from(error: rusqlite::Error) -> Self { + Self::internal(format!("database error: {error}")) + } +} + +#[derive(Deserialize)] +#[serde(rename_all = "snake_case")] +struct DeviceRegistrationRequest { + user_id: String, + device_id: String, + display_name: String, + signing_public_key: String, + key_fingerprint: String, + platform: String, + voip_push_token: Option, + capabilities: Vec, +} + +#[derive(Deserialize)] +#[serde(rename_all = "snake_case")] +struct NicknameClaimRequest { + nickname: String, + user_id: String, + device_id: String, +} + +#[derive(Serialize)] +struct NicknameClaimResponse { + claimed: bool, + normalized: String, + reason: Option, + suggestions: Vec, +} + +#[derive(Deserialize)] +struct NicknameSearchRequest { + query: String, + limit: usize, +} + +#[derive(Serialize)] +struct NicknameSearchResponse { + results: Vec, +} + +#[derive(Serialize)] +struct DirectoryContact { + user_id: String, + device_id: String, + nickname: String, + display_name: Option, + key_fingerprint: String, + online: bool, + device_count: usize, +} + +#[derive(Deserialize)] +#[serde(rename_all = "snake_case")] +struct CreateCallRequest { + callee: String, + caller_user_id: String, + caller_device_id: String, + audio: bool, + video: bool, +} + +#[derive(Deserialize)] +#[serde(rename_all = "snake_case")] +struct JoinCallRequest { + user_id: String, + device_id: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "snake_case")] +struct IncomingCallsRequest { + user_id: String, + device_id: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "snake_case")] +struct AccountRequest { + user_id: String, + device_id: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "snake_case")] +struct LinkDeviceRequest { + user_id: String, + device_id: String, + link_code: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "snake_case")] +struct RevokeDeviceRequest { + user_id: String, + device_id: String, +} + +#[derive(Serialize)] +struct LinkCodeResponse { + link_code: String, + expires_at: i64, +} + +#[derive(Serialize)] +struct AccountSnapshotResponse { + account_id: String, + nickname: Option, + devices: Vec, +} + +#[derive(Serialize)] +struct AccountDeviceSummary { + device_id: String, + display_name: String, + platform: String, + key_fingerprint: String, + last_seen: i64, + current: bool, + revoked: bool, +} + +#[derive(Serialize)] +struct IncomingCallsResponse { + calls: Vec, +} + +#[derive(Serialize)] +struct IncomingCall { + call_id: String, + caller: String, + audio: bool, + video: bool, + created_at: i64, +} + +#[derive(Deserialize)] +#[serde(rename_all = "snake_case")] +struct CreateGroupChatRequest { + creator_user_id: String, + creator_device_id: String, + title: Option, + members: Vec, +} + +#[derive(Deserialize)] +#[serde(rename_all = "snake_case")] +struct GroupChatsRequest { + user_id: String, + device_id: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "snake_case")] +struct SendGroupMessageRequest { + user_id: String, + device_id: String, + client_message_id: String, + text: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "snake_case")] +struct GroupMessagesRequest { + user_id: String, + device_id: String, + after_message_id: i64, + limit: u16, +} + +#[derive(Serialize)] +struct GroupChatsResponse { + chats: Vec, +} + +#[derive(Serialize)] +struct GroupChatSummary { + chat_id: String, + title: String, + members: Vec, + created_at: i64, + last_message: Option, + last_message_at: Option, +} + +#[derive(Serialize)] +struct GroupMessagesResponse { + messages: Vec, +} + +#[derive(Serialize)] +struct GroupChatMessage { + message_id: i64, + chat_id: String, + sender_user_id: String, + sender_nickname: String, + text: String, + created_at: i64, +} + +#[derive(Serialize)] +struct InternetCallSession { + call_id: String, + room_id: String, + livekit_url: String, + token: String, + media_key: Option, +} + +#[derive(Serialize)] +struct HealthResponse { + status: &'static str, +} + +#[derive(Clone)] +struct AuthenticatedDevice { + user_id: String, + device_id: String, + display_name: String, + capabilities: u8, +} + +#[derive(Serialize)] +struct LiveKitClaims<'a> { + iss: &'a str, + sub: &'a str, + name: &'a str, + nbf: i64, + exp: i64, + video: LiveKitVideoGrant<'a>, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct LiveKitVideoGrant<'a> { + room_join: bool, + room: &'a str, + can_publish: bool, + can_subscribe: bool, + can_publish_data: bool, +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + let (configuration, database_path) = + Configuration::load().map_err(|error| format!("configuration error: {error}"))?; + let bind = configuration.bind; + let connection = Connection::open(database_path)?; + initialize_database(&connection)?; + let state = AppState { + database: Arc::new(Mutex::new(connection)), + configuration: Arc::new(configuration), + }; + + let application = application(state); + + let listener = tokio::net::TcpListener::bind(bind).await?; + println!("TRI-NET call API listening on {bind}"); + axum::serve(listener, application) + .with_graceful_shutdown(shutdown_signal()) + .await?; + Ok(()) +} + +fn application(state: AppState) -> Router { + Router::new() + .route("/healthz", get(health)) + .route("/v1/devices/register", post(register_device)) + .route("/v1/account", post(account_snapshot)) + .route("/v1/account/link-code", post(create_link_code)) + .route("/v1/account/link", post(link_device)) + .route( + "/v1/account/devices/{device_id}/revoke", + post(revoke_device), + ) + .route("/v1/directory/nicknames/claim", post(claim_nickname)) + .route("/v1/directory/search", post(search_nicknames)) + .route("/v1/calls", post(create_call)) + .route("/v1/calls/incoming", post(incoming_calls)) + .route("/v1/calls/{call_id}/join", post(join_call)) + .route("/v1/chats", post(create_group_chat)) + .route("/v1/chats/list", post(list_group_chats)) + .route( + "/v1/chats/{chat_id}/messages", + post(send_group_message), + ) + .route( + "/v1/chats/{chat_id}/messages/list", + post(list_group_messages), + ) + .with_state(state) +} + +async fn shutdown_signal() { + let _ = tokio::signal::ctrl_c().await; +} + +fn initialize_database(connection: &Connection) -> rusqlite::Result<()> { + connection.execute_batch( + "PRAGMA journal_mode = WAL; + PRAGMA foreign_keys = ON; + CREATE TABLE IF NOT EXISTS accounts ( + user_id TEXT PRIMARY KEY, + created_at INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS devices ( + device_id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + display_name TEXT NOT NULL, + signing_public_key TEXT NOT NULL, + key_fingerprint TEXT NOT NULL, + platform TEXT NOT NULL, + voip_push_token TEXT, + capabilities INTEGER NOT NULL, + last_seen INTEGER NOT NULL, + linked_at INTEGER NOT NULL DEFAULT 0, + revoked_at INTEGER + ); + CREATE INDEX IF NOT EXISTS devices_user_id ON devices(user_id); + CREATE TABLE IF NOT EXISTS nicknames ( + nickname TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + device_id TEXT NOT NULL UNIQUE REFERENCES devices(device_id), + updated_at INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS calls ( + call_id TEXT PRIMARY KEY, + room_id TEXT NOT NULL UNIQUE, + caller_user_id TEXT NOT NULL, + caller_device_id TEXT NOT NULL, + callee_user_id TEXT NOT NULL, + callee_device_id TEXT NOT NULL, + caller_name TEXT NOT NULL, + audio INTEGER NOT NULL, + video INTEGER NOT NULL, + status INTEGER NOT NULL, + created_at INTEGER NOT NULL, + answered_at INTEGER, + answered_device_id TEXT + ); + CREATE INDEX IF NOT EXISTS calls_callee_status + ON calls(callee_device_id, status, created_at); + CREATE TABLE IF NOT EXISTS call_targets ( + call_id TEXT NOT NULL REFERENCES calls(call_id), + device_id TEXT NOT NULL REFERENCES devices(device_id), + state INTEGER NOT NULL, + PRIMARY KEY(call_id, device_id) + ); + CREATE INDEX IF NOT EXISTS call_targets_device_state + ON call_targets(device_id, state); + CREATE TABLE IF NOT EXISTS device_link_codes ( + code_hash TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + created_by_device_id TEXT NOT NULL, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + consumed_at INTEGER + ); + CREATE TABLE IF NOT EXISTS request_nonces ( + device_id TEXT NOT NULL, + nonce TEXT NOT NULL, + expires_at INTEGER NOT NULL, + PRIMARY KEY(device_id, nonce) + ); + CREATE TABLE IF NOT EXISTS group_chats ( + chat_id TEXT PRIMARY KEY, + title TEXT NOT NULL, + created_by_user_id TEXT NOT NULL, + created_at INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS group_chat_members ( + chat_id TEXT NOT NULL REFERENCES group_chats(chat_id), + user_id TEXT NOT NULL, + nickname TEXT NOT NULL, + joined_at INTEGER NOT NULL, + left_at INTEGER, + PRIMARY KEY(chat_id, user_id) + ); + CREATE INDEX IF NOT EXISTS group_chat_members_user + ON group_chat_members(user_id, left_at, chat_id); + CREATE TABLE IF NOT EXISTS group_chat_messages ( + message_id INTEGER PRIMARY KEY AUTOINCREMENT, + chat_id TEXT NOT NULL REFERENCES group_chats(chat_id), + sender_user_id TEXT NOT NULL, + sender_device_id TEXT NOT NULL, + sender_nickname TEXT NOT NULL, + client_message_id TEXT NOT NULL, + text TEXT NOT NULL, + created_at INTEGER NOT NULL, + UNIQUE(chat_id, sender_device_id, client_message_id) + ); + CREATE INDEX IF NOT EXISTS group_chat_messages_chat + ON group_chat_messages(chat_id, message_id);", + )?; + ensure_column(connection, "devices", "linked_at", "INTEGER NOT NULL DEFAULT 0")?; + ensure_column(connection, "devices", "revoked_at", "INTEGER")?; + ensure_column(connection, "calls", "answered_device_id", "TEXT")?; + connection.execute( + "INSERT OR IGNORE INTO accounts(user_id, created_at) + SELECT DISTINCT user_id, ?1 FROM devices", + params![unix_time()], + )?; + connection.execute( + "UPDATE devices SET linked_at = last_seen WHERE linked_at = 0", + [], + )?; + Ok(()) +} + +fn ensure_column( + connection: &Connection, + table: &str, + column: &str, + declaration: &str, +) -> rusqlite::Result<()> { + let mut statement = connection.prepare(&format!("PRAGMA table_info({table})"))?; + let columns = statement + .query_map([], |row| row.get::<_, String>(1))? + .collect::, _>>()?; + if !columns.iter().any(|name| name == column) { + connection.execute( + &format!("ALTER TABLE {table} ADD COLUMN {column} {declaration}"), + [], + )?; + } + Ok(()) +} + +async fn health() -> Json { + Json(HealthResponse { status: "ok" }) +} + +async fn register_device( + State(state): State, + headers: HeaderMap, + body: Bytes, +) -> Result { + let request: DeviceRegistrationRequest = decode_json(&body)?; + let public_key = decode_public_key(&request.signing_public_key)?; + let actual_fingerprint = fingerprint(&public_key); + if actual_fingerprint != request.key_fingerprint { + return Err(ApiError::bad_request("public-key fingerprint mismatch")); + } + let capabilities = capability_bits(&request.capabilities); + if !internet_call::device_is_valid( + stable_id(&request.user_id), + stable_id(&request.device_id), + stable_id(&request.key_fingerprint), + capabilities, + ) || !internet_call::supports_internet_call(capabilities) + { + return Err(ApiError::bad_request( + "device must support audio and WebRTC", + )); + } + + let auth = authenticate( + &state, + &headers, + "POST", + "/v1/devices/register", + &body, + Some((&request.user_id, &request.signing_public_key)), + )?; + if auth.device_id != request.device_id || auth.user_id != request.user_id { + return Err(ApiError::forbidden("device identity does not match request")); + } + + let now = unix_time(); + let mut database = lock_database(&state)?; + let transaction = database.transaction_with_behavior(TransactionBehavior::Immediate)?; + let registered_user = transaction + .query_row( + "SELECT user_id FROM devices WHERE device_id = ?1", + params![request.device_id], + |row| row.get::<_, String>(0), + ) + .optional()?; + let existing_members = transaction.query_row( + "SELECT COUNT(*) FROM devices + WHERE user_id = ?1 AND revoked_at IS NULL AND device_id != ?2", + params![request.user_id, request.device_id], + |row| row.get::<_, u16>(0), + )?; + if registered_user.is_none() && existing_members > 0 { + return Err(ApiError::forbidden( + "account already has devices; use a trusted-device link code", + )); + } + transaction.execute( + "INSERT OR IGNORE INTO accounts(user_id, created_at) VALUES (?1, ?2)", + params![request.user_id, now], + )?; + transaction.execute( + "INSERT INTO devices + (device_id, user_id, display_name, signing_public_key, key_fingerprint, + platform, voip_push_token, capabilities, last_seen, linked_at, revoked_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?9, NULL) + ON CONFLICT(device_id) DO UPDATE SET + display_name = excluded.display_name, + platform = excluded.platform, + voip_push_token = excluded.voip_push_token, + capabilities = excluded.capabilities, + last_seen = excluded.last_seen", + params![ + request.device_id, + request.user_id, + request.display_name, + request.signing_public_key, + request.key_fingerprint, + request.platform, + request.voip_push_token, + capabilities, + now, + ], + )?; + transaction.commit()?; + Ok(StatusCode::NO_CONTENT) +} + +async fn account_snapshot( + State(state): State, + headers: HeaderMap, + body: Bytes, +) -> Result, ApiError> { + let request: AccountRequest = decode_json(&body)?; + let auth = authenticate(&state, &headers, "POST", "/v1/account", &body, None)?; + require_identity(&auth, &request.user_id, &request.device_id)?; + let database = lock_database(&state)?; + Ok(Json(load_account_snapshot(&database, &auth)?)) +} + +async fn create_link_code( + State(state): State, + headers: HeaderMap, + body: Bytes, +) -> Result, ApiError> { + let request: AccountRequest = decode_json(&body)?; + let auth = authenticate( + &state, + &headers, + "POST", + "/v1/account/link-code", + &body, + None, + )?; + require_identity(&auth, &request.user_id, &request.device_id)?; + let now = unix_time(); + let expires_at = now + i64::from(account_identity::LINK_CODE_TTL_SECONDS); + let link_code = random_id("link_"); + let code_hash = lowercase_hex(&Sha256::digest(link_code.as_bytes())); + let mut database = lock_database(&state)?; + let transaction = database.transaction_with_behavior(TransactionBehavior::Immediate)?; + transaction.execute( + "DELETE FROM device_link_codes + WHERE expires_at < ?1 OR created_by_device_id = ?2", + params![now, auth.device_id], + )?; + transaction.execute( + "INSERT INTO device_link_codes + (code_hash, user_id, created_by_device_id, created_at, expires_at, consumed_at) + VALUES (?1, ?2, ?3, ?4, ?5, NULL)", + params![code_hash, auth.user_id, auth.device_id, now, expires_at], + )?; + transaction.commit()?; + Ok(Json(LinkCodeResponse { + link_code, + expires_at, + })) +} + +async fn link_device( + State(state): State, + headers: HeaderMap, + body: Bytes, +) -> Result, ApiError> { + let request: LinkDeviceRequest = decode_json(&body)?; + let auth = authenticate( + &state, + &headers, + "POST", + "/v1/account/link", + &body, + None, + )?; + require_identity(&auth, &request.user_id, &request.device_id)?; + if request.link_code.len() != 37 || !request.link_code.starts_with("link_") { + return Err(ApiError::bad_request("invalid link code")); + } + let code_hash = lowercase_hex(&Sha256::digest(request.link_code.as_bytes())); + let now = unix_time(); + let mut database = lock_database(&state)?; + let transaction = database.transaction_with_behavior(TransactionBehavior::Immediate)?; + let code = transaction + .query_row( + "SELECT user_id, created_at, expires_at, consumed_at + FROM device_link_codes WHERE code_hash = ?1", + params![code_hash], + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, i64>(1)?, + row.get::<_, i64>(2)?, + row.get::<_, Option>(3)?, + )) + }, + ) + .optional()? + .ok_or_else(|| ApiError::forbidden("link code is invalid"))?; + if code.0 == auth.user_id { + return Err(ApiError::conflict("device already belongs to this account")); + } + let source_device_count = transaction.query_row( + "SELECT COUNT(*) FROM devices WHERE user_id = ?1 AND revoked_at IS NULL", + params![auth.user_id], + |row| row.get::<_, u16>(0), + )?; + let code_fresh = code.1 >= 0 + && now >= 0 + && code.2 >= now + && account_identity::link_code_is_fresh(code.1 as u32, now as u32); + let source_is_single_device = source_device_count == 1; + if !account_identity::may_adopt_account( + true, + code.3.is_none(), + code_fresh, + source_is_single_device, + ) { + return Err(ApiError::forbidden( + "link code expired, was already used, or this account has multiple devices", + )); + } + let old_user_id = auth.user_id.clone(); + let updated = transaction.execute( + "UPDATE devices SET user_id = ?1, linked_at = ?2 + WHERE device_id = ?3 AND user_id = ?4 AND revoked_at IS NULL", + params![code.0, now, auth.device_id, old_user_id], + )?; + if updated != 1 { + return Err(ApiError::conflict("device membership changed concurrently")); + } + let consumed = transaction.execute( + "UPDATE device_link_codes SET consumed_at = ?1 + WHERE code_hash = ?2 AND consumed_at IS NULL", + params![now, code_hash], + )?; + if consumed != 1 { + return Err(ApiError::conflict("link code was used concurrently")); + } + transaction.execute( + "DELETE FROM nicknames WHERE user_id = ?1", + params![old_user_id], + )?; + transaction.execute( + "DELETE FROM accounts + WHERE user_id = ?1 AND NOT EXISTS + (SELECT 1 FROM devices WHERE devices.user_id = accounts.user_id)", + params![old_user_id], + )?; + transaction.commit()?; + let linked_auth = AuthenticatedDevice { + user_id: code.0, + device_id: auth.device_id, + display_name: auth.display_name, + capabilities: auth.capabilities, + }; + Ok(Json(load_account_snapshot(&database, &linked_auth)?)) +} + +async fn revoke_device( + State(state): State, + Path(target_device_id): Path, + headers: HeaderMap, + body: Bytes, +) -> Result { + let request: RevokeDeviceRequest = decode_json(&body)?; + let path = format!("/v1/account/devices/{target_device_id}/revoke"); + let auth = authenticate(&state, &headers, "POST", &path, &body, None)?; + require_identity(&auth, &request.user_id, &request.device_id)?; + let now = unix_time(); + let mut database = lock_database(&state)?; + let transaction = database.transaction_with_behavior(TransactionBehavior::Immediate)?; + let target = transaction + .query_row( + "SELECT user_id, revoked_at FROM devices WHERE device_id = ?1", + params![target_device_id], + |row| Ok((row.get::<_, String>(0)?, row.get::<_, Option>(1)?)), + ) + .optional()? + .ok_or_else(|| ApiError::not_found("device not found"))?; + let active_devices = transaction.query_row( + "SELECT COUNT(*) FROM devices WHERE user_id = ?1 AND revoked_at IS NULL", + params![auth.user_id], + |row| row.get::<_, u16>(0), + )?; + if !account_identity::may_revoke_device( + target.0 == auth.user_id, + target.1.is_none(), + active_devices, + ) { + return Err(ApiError::forbidden( + "device is not active in this account or is the last active device", + )); + } + transaction.execute( + "UPDATE devices SET revoked_at = ?1, voip_push_token = NULL + WHERE device_id = ?2 AND revoked_at IS NULL", + params![now, target_device_id], + )?; + transaction.commit()?; + Ok(StatusCode::NO_CONTENT) +} + +fn load_account_snapshot( + database: &Connection, + auth: &AuthenticatedDevice, +) -> Result { + let nickname = database + .query_row( + "SELECT nickname FROM nicknames WHERE user_id = ?1", + params![auth.user_id], + |row| row.get::<_, String>(0), + ) + .optional()?; + let mut statement = database.prepare( + "SELECT device_id, display_name, platform, key_fingerprint, + last_seen, revoked_at + FROM devices WHERE user_id = ?1 + ORDER BY revoked_at IS NOT NULL, linked_at, device_id", + )?; + let devices = statement + .query_map(params![auth.user_id], |row| { + let device_id = row.get::<_, String>(0)?; + Ok(AccountDeviceSummary { + current: device_id == auth.device_id, + device_id, + display_name: row.get(1)?, + platform: row.get(2)?, + key_fingerprint: row.get(3)?, + last_seen: row.get(4)?, + revoked: row.get::<_, Option>(5)?.is_some(), + }) + })? + .collect::, _>>()?; + Ok(AccountSnapshotResponse { + account_id: auth.user_id.clone(), + nickname, + devices, + }) +} + +async fn claim_nickname( + State(state): State, + headers: HeaderMap, + body: Bytes, +) -> Result, ApiError> { + let request: NicknameClaimRequest = decode_json(&body)?; + let auth = authenticate( + &state, + &headers, + "POST", + "/v1/directory/nicknames/claim", + &body, + None, + )?; + require_identity(&auth, &request.user_id, &request.device_id)?; + + let normalized = normalize_nickname(&request.nickname); + let shape_valid = nickname_shape_valid(&normalized); + let mut database = lock_database(&state)?; + let transaction = database.transaction_with_behavior(TransactionBehavior::Immediate)?; + let existing = { + let mut statement = transaction.prepare("SELECT nickname, user_id FROM nicknames")?; + let rows = statement + .query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)))? + .collect::, _>>()?; + rows + }; + let confusing = existing.iter().any(|(nickname, user_id)| { + user_id != &request.user_id && nicknames_are_confusing(&normalized, nickname) + }); + + if !shape_valid || confusing { + let reason = if !shape_valid { + "Nickname must be 3-20 lowercase ASCII letters, numbers, or underscore and start with a letter" + } else { + "Nickname is already used or too similar" + }; + let suggestions = nickname_suggestions( + &normalized, + &request.user_id, + existing.iter().map(|(nickname, _)| nickname.as_str()), + ); + transaction.commit()?; + return Ok(Json(NicknameClaimResponse { + claimed: false, + normalized, + reason: Some(reason.to_string()), + suggestions, + })); + } + + if nickname_directory::claim_status(true, false, true, true) + != nickname_directory::CLAIM_VERIFIED + || !nickname_directory::nickname_owner_matches( + stable_id(&request.user_id), + stable_id(&auth.user_id), + ) + { + return Err(ApiError::internal("generated nickname policy rejected claim")); + } + transaction.execute( + "DELETE FROM nicknames WHERE user_id = ?1", + params![request.user_id], + )?; + transaction + .execute( + "INSERT INTO nicknames(nickname, user_id, device_id, updated_at) + VALUES (?1, ?2, ?3, ?4)", + params![normalized, request.user_id, request.device_id, unix_time()], + ) + .map_err(|error| match error { + rusqlite::Error::SqliteFailure(_, _) => { + ApiError::conflict("nickname was claimed concurrently") + } + other => ApiError::from(other), + })?; + transaction.commit()?; + Ok(Json(NicknameClaimResponse { + claimed: true, + normalized, + reason: None, + suggestions: Vec::new(), + })) +} + +async fn search_nicknames( + State(state): State, + headers: HeaderMap, + body: Bytes, +) -> Result, ApiError> { + let request: NicknameSearchRequest = decode_json(&body)?; + let _auth = authenticate( + &state, + &headers, + "POST", + "/v1/directory/search", + &body, + None, + )?; + let query = normalize_nickname(&request.query); + let limit = request.limit.clamp(1, 50) as i64; + let now = unix_time(); + let database = lock_database(&state)?; + let mut statement = database.prepare( + "SELECT n.user_id, + (SELECT d.device_id FROM devices d + WHERE d.user_id = n.user_id AND d.revoked_at IS NULL + ORDER BY d.last_seen DESC LIMIT 1), + n.nickname, + (SELECT d.display_name FROM devices d + WHERE d.user_id = n.user_id AND d.revoked_at IS NULL + ORDER BY d.last_seen DESC LIMIT 1), + (SELECT d.key_fingerprint FROM devices d + WHERE d.user_id = n.user_id AND d.revoked_at IS NULL + ORDER BY d.last_seen DESC LIMIT 1), + (SELECT MAX(d.last_seen) FROM devices d + WHERE d.user_id = n.user_id AND d.revoked_at IS NULL), + (SELECT COUNT(*) FROM devices d + WHERE d.user_id = n.user_id AND d.revoked_at IS NULL) + FROM nicknames n + WHERE n.nickname LIKE '%' || ?1 || '%' + AND EXISTS (SELECT 1 FROM devices d + WHERE d.user_id = n.user_id AND d.revoked_at IS NULL) + ORDER BY CASE + WHEN n.nickname = ?1 THEN 0 + WHEN n.nickname LIKE ?1 || '%' THEN 1 + ELSE 2 END, + n.nickname + LIMIT ?2", + )?; + let results = statement + .query_map(params![query, limit], |row| { + let last_seen: i64 = row.get(5)?; + Ok(DirectoryContact { + user_id: row.get(0)?, + device_id: row.get(1)?, + nickname: row.get(2)?, + display_name: row.get(3)?, + key_fingerprint: row.get(4)?, + online: device_is_online(last_seen, now), + device_count: row.get(6)?, + }) + })? + .collect::, _>>()?; + Ok(Json(NicknameSearchResponse { results })) +} + +async fn create_call( + State(state): State, + headers: HeaderMap, + body: Bytes, +) -> Result, ApiError> { + let request: CreateCallRequest = decode_json(&body)?; + let auth = authenticate( + &state, + &headers, + "POST", + "/v1/calls", + &body, + None, + )?; + require_identity(&auth, &request.caller_user_id, &request.caller_device_id)?; + let callee = normalize_nickname(&request.callee); + if !nickname_shape_valid(&callee) { + return Err(ApiError::bad_request("invalid callee nickname")); + } + + let mut database = lock_database(&state)?; + let transaction = database.transaction_with_behavior(TransactionBehavior::Immediate)?; + let target_user_id = transaction + .query_row( + "SELECT user_id FROM nicknames WHERE nickname = ?1", + params![callee], + |row| row.get::<_, String>(0), + ) + .optional()? + .ok_or_else(|| ApiError::not_found("nickname not found"))?; + let targets = { + let mut statement = transaction.prepare( + "SELECT device_id, capabilities, last_seen FROM devices + WHERE user_id = ?1 AND revoked_at IS NULL", + )?; + let rows = statement + .query_map(params![target_user_id], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, u8>(1)?, + row.get::<_, i64>(2)?, + )) + })? + .collect::, _>>()?; + rows + }; + let targets = targets + .into_iter() + .filter(|target| { + internet_call::call_target_is_available( + stable_id(&auth.user_id), + stable_id(&auth.device_id), + stable_id(&target_user_id), + stable_id(&target.0), + target.1, + device_is_online(target.2, unix_time()), + ) + }) + .collect::>(); + if targets.is_empty() { + return Err(ApiError::conflict( + "destination is offline or cannot receive an Internet call", + )); + } + let caller_name = transaction + .query_row( + "SELECT nickname FROM nicknames WHERE user_id = ?1", + params![auth.user_id], + |row| row.get::<_, String>(0), + ) + .optional()? + .unwrap_or_else(|| auth.display_name.clone()); + let call_id = random_id("call_"); + let room_id = random_id("room_"); + let status = internet_call::next_status(internet_call::CALL_IDLE, true); + transaction.execute( + "INSERT INTO calls + (call_id, room_id, caller_user_id, caller_device_id, callee_user_id, + callee_device_id, caller_name, audio, video, status, created_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)", + params![ + call_id, + room_id, + auth.user_id, + auth.device_id, + target_user_id, + targets[0].0, + caller_name, + request.audio, + request.video, + status, + unix_time(), + ], + )?; + for (device_id, _, _) in &targets { + transaction.execute( + "INSERT INTO call_targets(call_id, device_id, state) + VALUES (?1, ?2, ?3)", + params![call_id, device_id, internet_call::CALL_RINGING], + )?; + } + transaction.commit()?; + drop(database); + session_for(&state.configuration, &call_id, &room_id, &auth, &caller_name) + .map(Json) +} + +async fn incoming_calls( + State(state): State, + headers: HeaderMap, + body: Bytes, +) -> Result, ApiError> { + let request: IncomingCallsRequest = decode_json(&body)?; + let auth = authenticate( + &state, + &headers, + "POST", + "/v1/calls/incoming", + &body, + None, + )?; + require_identity(&auth, &request.user_id, &request.device_id)?; + let minimum_created_at = unix_time() - i64::from(internet_call::INVITE_TTL_SECONDS); + let database = lock_database(&state)?; + let mut statement = database.prepare( + "SELECT c.call_id, c.caller_name, c.audio, c.video, c.created_at + FROM call_targets t JOIN calls c ON c.call_id = t.call_id + WHERE t.device_id = ?1 AND t.state = ?2 AND c.status = ?2 + AND c.created_at >= ?3 + ORDER BY c.created_at ASC LIMIT 10", + )?; + let calls = statement + .query_map( + params![ + auth.device_id, + internet_call::CALL_RINGING, + minimum_created_at + ], + |row| { + Ok(IncomingCall { + call_id: row.get(0)?, + caller: row.get(1)?, + audio: row.get(2)?, + video: row.get(3)?, + created_at: row.get(4)?, + }) + }, + )? + .collect::, _>>()?; + Ok(Json(IncomingCallsResponse { calls })) +} + +async fn join_call( + State(state): State, + Path(call_id): Path, + headers: HeaderMap, + body: Bytes, +) -> Result, ApiError> { + let request: JoinCallRequest = decode_json(&body)?; + let path = format!("/v1/calls/{call_id}/join"); + let auth = authenticate(&state, &headers, "POST", &path, &body, None)?; + require_identity(&auth, &request.user_id, &request.device_id)?; + let mut database = lock_database(&state)?; + let transaction = database.transaction_with_behavior(TransactionBehavior::Immediate)?; + let call = transaction + .query_row( + "SELECT c.room_id, c.callee_user_id, t.device_id, c.status, + created_at, caller_name + FROM calls c JOIN call_targets t ON t.call_id = c.call_id + WHERE c.call_id = ?1 AND t.device_id = ?2", + params![call_id, auth.device_id], + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, u8>(3)?, + row.get::<_, i64>(4)?, + row.get::<_, String>(5)?, + )) + }, + ) + .optional()? + .ok_or_else(|| ApiError::forbidden("call is unavailable to this device"))?; + let now = unix_time(); + let invite_fresh = call.4 >= 0 + && now >= 0 + && internet_call::invite_is_fresh(call.4 as u32, now as u32); + let device_valid = internet_call::device_is_valid( + stable_id(&auth.user_id), + stable_id(&auth.device_id), + stable_id(&auth.device_id), + auth.capabilities, + ); + if !internet_call::join_is_authorized( + stable_id(&auth.user_id), + stable_id(&auth.device_id), + stable_id(&call.1), + stable_id(&call.2), + call.3, + invite_fresh, + device_valid, + ) { + return Err(ApiError::forbidden( + "call is expired, already answered, or belongs to another device", + )); + } + let answered = transaction.execute( + "UPDATE calls SET status = ?1, answered_at = ?2, answered_device_id = ?3 + WHERE call_id = ?4 AND status = ?5", + params![ + internet_call::next_status(call.3, true), + now, + auth.device_id, + call_id, + internet_call::CALL_RINGING + ], + )?; + if answered != 1 { + return Err(ApiError::conflict("call was answered on another device")); + } + transaction.execute( + "UPDATE call_targets + SET state = CASE WHEN device_id = ?1 THEN ?2 ELSE ?3 END + WHERE call_id = ?4", + params![ + auth.device_id, + internet_call::CALL_ACTIVE, + internet_call::CALL_ENDED, + call_id + ], + )?; + transaction.commit()?; + drop(database); + session_for( + &state.configuration, + &call_id, + &call.0, + &auth, + &auth.display_name, + ) + .map(Json) +} + +async fn create_group_chat( + State(state): State, + headers: HeaderMap, + body: Bytes, +) -> Result, ApiError> { + let request: CreateGroupChatRequest = decode_json(&body)?; + let auth = authenticate(&state, &headers, "POST", "/v1/chats", &body, None)?; + require_identity( + &auth, + &request.creator_user_id, + &request.creator_device_id, + )?; + if request.members.len() >= group_chat::MAX_GROUP_MEMBERS as usize { + return Err(ApiError::bad_request("group has too many members")); + } + + let requested_members = request + .members + .iter() + .map(|nickname| normalize_nickname(nickname)) + .collect::>(); + if requested_members + .iter() + .any(|nickname| !nickname_shape_valid(nickname)) + { + return Err(ApiError::bad_request("group contains an invalid nickname")); + } + let unique_nicknames = requested_members + .iter() + .enumerate() + .filter(|(index, nickname)| !requested_members[..*index].contains(nickname)) + .count(); + if unique_nicknames != requested_members.len() { + return Err(ApiError::bad_request("group contains duplicate nicknames")); + } + + let mut database = lock_database(&state)?; + let transaction = database.transaction_with_behavior(TransactionBehavior::Immediate)?; + let creator_nickname = transaction + .query_row( + "SELECT nickname FROM nicknames WHERE user_id = ?1", + params![auth.user_id], + |row| row.get::<_, String>(0), + ) + .optional()? + .ok_or_else(|| ApiError::conflict("create your nickname before creating a group"))?; + + let mut resolved_members = Vec::with_capacity(requested_members.len()); + for nickname in &requested_members { + let user_id = transaction + .query_row( + "SELECT user_id FROM nicknames WHERE nickname = ?1", + params![nickname], + |row| row.get::<_, String>(0), + ) + .optional()? + .ok_or_else(|| ApiError::not_found(format!("nickname @{nickname} was not found")))?; + resolved_members.push((user_id, nickname.clone())); + } + let unique_accounts = resolved_members + .iter() + .enumerate() + .filter(|(index, member)| { + member.0 != auth.user_id + && !resolved_members[..*index] + .iter() + .any(|existing| existing.0 == member.0) + }) + .count(); + let requested_count = requested_members.len() as u8; + if !group_chat::group_may_be_created( + true, + requested_count, + resolved_members.len() as u8, + unique_accounts as u8, + ) { + return Err(ApiError::bad_request( + "group must contain distinct accounts other than your own", + )); + } + + let title = request + .title + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| default_group_title(&creator_nickname, &requested_members)); + let title_length = title.len().min(u16::MAX as usize) as u16; + if !group_chat::title_is_valid(title_length) { + return Err(ApiError::bad_request("group title must be 1-80 bytes")); + } + + let chat_id = random_id("chat_"); + let now = unix_time(); + transaction.execute( + "INSERT INTO group_chats(chat_id, title, created_by_user_id, created_at) + VALUES (?1, ?2, ?3, ?4)", + params![chat_id, title, auth.user_id, now], + )?; + transaction.execute( + "INSERT INTO group_chat_members(chat_id, user_id, nickname, joined_at, left_at) + VALUES (?1, ?2, ?3, ?4, NULL)", + params![chat_id, auth.user_id, creator_nickname, now], + )?; + for (user_id, nickname) in resolved_members { + transaction.execute( + "INSERT INTO group_chat_members(chat_id, user_id, nickname, joined_at, left_at) + VALUES (?1, ?2, ?3, ?4, NULL)", + params![chat_id, user_id, nickname, now], + )?; + } + transaction.commit()?; + Ok(Json(load_group_chat_summary(&database, &chat_id)?)) +} + +async fn list_group_chats( + State(state): State, + headers: HeaderMap, + body: Bytes, +) -> Result, ApiError> { + let request: GroupChatsRequest = decode_json(&body)?; + let auth = authenticate(&state, &headers, "POST", "/v1/chats/list", &body, None)?; + require_identity(&auth, &request.user_id, &request.device_id)?; + let database = lock_database(&state)?; + let chat_ids = { + let mut statement = database.prepare( + "SELECT c.chat_id + FROM group_chats c + JOIN group_chat_members m ON m.chat_id = c.chat_id + WHERE m.user_id = ?1 AND m.left_at IS NULL + ORDER BY COALESCE( + (SELECT MAX(message.created_at) + FROM group_chat_messages message + WHERE message.chat_id = c.chat_id), + c.created_at + ) DESC, c.chat_id", + )?; + let chat_ids = statement + .query_map(params![auth.user_id], |row| row.get::<_, String>(0))? + .collect::, _>>()?; + chat_ids + }; + let chats = chat_ids + .iter() + .map(|chat_id| load_group_chat_summary(&database, chat_id)) + .collect::, _>>()?; + Ok(Json(GroupChatsResponse { chats })) +} + +async fn send_group_message( + State(state): State, + Path(chat_id): Path, + headers: HeaderMap, + body: Bytes, +) -> Result, ApiError> { + let request: SendGroupMessageRequest = decode_json(&body)?; + let path = format!("/v1/chats/{chat_id}/messages"); + let auth = authenticate(&state, &headers, "POST", &path, &body, None)?; + require_identity(&auth, &request.user_id, &request.device_id)?; + if request.client_message_id.len() < 8 + || request.client_message_id.len() > 64 + || !request.client_message_id.is_ascii() + { + return Err(ApiError::bad_request("invalid client message ID")); + } + let text = request.text.trim(); + let text_length = text.len().min(u16::MAX as usize) as u16; + + let mut database = lock_database(&state)?; + let transaction = database.transaction_with_behavior(TransactionBehavior::Immediate)?; + let active_member = active_group_member(&transaction, &chat_id, &auth.user_id)?; + if !group_chat::message_may_be_sent(active_member, true, text_length) { + return Err(if active_member { + ApiError::bad_request("message must be 1-4096 bytes") + } else { + ApiError::forbidden("device account is not a member of this group") + }); + } + let sender_nickname = transaction + .query_row( + "SELECT nickname FROM nicknames WHERE user_id = ?1", + params![auth.user_id], + |row| row.get::<_, String>(0), + ) + .optional()? + .unwrap_or_else(|| auth.display_name.clone()); + let now = unix_time(); + transaction.execute( + "INSERT OR IGNORE INTO group_chat_messages + (chat_id, sender_user_id, sender_device_id, sender_nickname, + client_message_id, text, created_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + params![ + chat_id, + auth.user_id, + auth.device_id, + sender_nickname, + request.client_message_id, + text, + now + ], + )?; + let message = transaction.query_row( + "SELECT message_id, chat_id, sender_user_id, sender_nickname, text, created_at + FROM group_chat_messages + WHERE chat_id = ?1 AND sender_device_id = ?2 AND client_message_id = ?3", + params![chat_id, auth.device_id, request.client_message_id], + group_chat_message_from_row, + )?; + transaction.commit()?; + Ok(Json(message)) +} + +async fn list_group_messages( + State(state): State, + Path(chat_id): Path, + headers: HeaderMap, + body: Bytes, +) -> Result, ApiError> { + let request: GroupMessagesRequest = decode_json(&body)?; + let path = format!("/v1/chats/{chat_id}/messages/list"); + let auth = authenticate(&state, &headers, "POST", &path, &body, None)?; + require_identity(&auth, &request.user_id, &request.device_id)?; + let database = lock_database(&state)?; + let active_member = active_group_member(&database, &chat_id, &auth.user_id)?; + if !group_chat::member_may_read(active_member, true) { + return Err(ApiError::forbidden( + "device account is not a member of this group", + )); + } + let after_message_id = request.after_message_id.max(0); + let limit = i64::from(group_chat::message_page_size(request.limit)); + let messages = { + let mut statement = database.prepare( + "SELECT message_id, chat_id, sender_user_id, sender_nickname, text, created_at + FROM group_chat_messages + WHERE chat_id = ?1 AND message_id > ?2 + ORDER BY message_id ASC LIMIT ?3", + )?; + let messages = statement + .query_map( + params![chat_id, after_message_id, limit], + group_chat_message_from_row, + )? + .collect::, _>>()?; + messages + }; + Ok(Json(GroupMessagesResponse { messages })) +} + +fn active_group_member( + database: &Connection, + chat_id: &str, + user_id: &str, +) -> Result { + Ok(database.query_row( + "SELECT EXISTS( + SELECT 1 FROM group_chat_members + WHERE chat_id = ?1 AND user_id = ?2 AND left_at IS NULL + )", + params![chat_id, user_id], + |row| row.get::<_, bool>(0), + )?) +} + +fn load_group_chat_summary( + database: &Connection, + chat_id: &str, +) -> Result { + let (title, created_at) = database + .query_row( + "SELECT title, created_at FROM group_chats WHERE chat_id = ?1", + params![chat_id], + |row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)), + ) + .optional()? + .ok_or_else(|| ApiError::not_found("group chat not found"))?; + let members = { + let mut statement = database.prepare( + "SELECT COALESCE( + (SELECT nickname FROM nicknames n WHERE n.user_id = m.user_id), + m.nickname + ) + FROM group_chat_members m + WHERE m.chat_id = ?1 AND m.left_at IS NULL + ORDER BY 1", + )?; + let members = statement + .query_map(params![chat_id], |row| row.get::<_, String>(0))? + .collect::, _>>()?; + members + }; + let last_message = database + .query_row( + "SELECT text, created_at FROM group_chat_messages + WHERE chat_id = ?1 ORDER BY message_id DESC LIMIT 1", + params![chat_id], + |row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)), + ) + .optional()?; + Ok(GroupChatSummary { + chat_id: chat_id.to_string(), + title, + members, + created_at, + last_message: last_message.as_ref().map(|message| message.0.clone()), + last_message_at: last_message.map(|message| message.1), + }) +} + +fn group_chat_message_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(GroupChatMessage { + message_id: row.get(0)?, + chat_id: row.get(1)?, + sender_user_id: row.get(2)?, + sender_nickname: row.get(3)?, + text: row.get(4)?, + created_at: row.get(5)?, + }) +} + +fn default_group_title(creator: &str, members: &[String]) -> String { + let mut title = format!("@{creator}"); + for member in members { + let fragment = format!(", @{member}"); + if title.len() + fragment.len() + > group_chat::MAX_GROUP_TITLE_BYTES as usize - "...".len() + { + title.push_str("..."); + break; + } + title.push_str(&fragment); + } + title +} + +fn authenticate( + state: &AppState, + headers: &HeaderMap, + method: &str, + path: &str, + body: &[u8], + bootstrap: Option<(&str, &str)>, +) -> Result { + verify_service_token(&state.configuration, headers)?; + let device_id = header(headers, "x-trinet-device-id")?; + let timestamp_text = header(headers, "x-trinet-timestamp")?; + let nonce = header(headers, "x-trinet-nonce")?; + let signature_text = header(headers, "x-trinet-signature")?; + let timestamp: i64 = timestamp_text + .parse() + .map_err(|_| ApiError::unauthorized("invalid request timestamp"))?; + let now = unix_time(); + if timestamp < 0 + || now < 0 + || !internet_call::request_signature_is_fresh(timestamp as u32, now as u32) + { + return Err(ApiError::unauthorized("request signature is stale")); + } + if nonce.len() < 16 || nonce.len() > 64 || !nonce.is_ascii() { + return Err(ApiError::unauthorized("invalid request nonce")); + } + + let mut database = lock_database(state)?; + let stored = database + .query_row( + "SELECT user_id, display_name, signing_public_key, capabilities, + key_fingerprint, revoked_at + FROM devices WHERE device_id = ?1", + params![device_id], + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, u8>(3)?, + row.get::<_, String>(4)?, + row.get::<_, Option>(5)?, + )) + }, + ) + .optional()?; + let (user_id, display_name, public_key, capabilities) = match stored { + Some(record) => { + if record.5.is_some() + || !account_identity::device_membership_is_valid( + stable_id(&record.0), + stable_id(device_id), + stable_id(&record.4), + account_identity::DEVICE_ACTIVE, + ) + { + return Err(ApiError::forbidden("device membership is revoked")); + } + (record.0, record.1, record.2, record.3) + } + None => { + let (bootstrap_user_id, bootstrap_public_key) = bootstrap + .ok_or_else(|| ApiError::unauthorized("device is not registered"))?; + ( + bootstrap_user_id.to_string(), + bootstrap_user_id.to_string(), + bootstrap_public_key.to_string(), + 0, + ) + } + }; + if let Some((bootstrap_user_id, _)) = bootstrap { + if user_id != bootstrap_user_id { + return Err(ApiError::forbidden("registered user ID cannot be changed")); + } + } + + let body_hash = lowercase_hex(&Sha256::digest(body)); + let canonical = format!( + "{}\n{}\n{}\n{}\n{}", + method.to_ascii_uppercase(), + path, + timestamp_text, + nonce, + body_hash + ); + verify_signature(&public_key, signature_text, canonical.as_bytes())?; + + let transaction = database.transaction_with_behavior(TransactionBehavior::Immediate)?; + transaction.execute( + "DELETE FROM request_nonces WHERE expires_at < ?1", + params![now], + )?; + let inserted = transaction.execute( + "INSERT OR IGNORE INTO request_nonces(device_id, nonce, expires_at) + VALUES (?1, ?2, ?3)", + params![ + device_id, + nonce, + now + i64::from(internet_call::REQUEST_SIGNATURE_TTL_SECONDS) + ], + )?; + if inserted != 1 { + return Err(ApiError::unauthorized("request nonce was already used")); + } + transaction.execute( + "UPDATE devices SET last_seen = ?1 WHERE device_id = ?2", + params![now, device_id], + )?; + transaction.commit()?; + Ok(AuthenticatedDevice { + user_id, + device_id: device_id.to_string(), + display_name, + capabilities, + }) +} + +fn verify_service_token( + configuration: &Configuration, + headers: &HeaderMap, +) -> Result<(), ApiError> { + let Some(expected) = &configuration.service_access_token else { + return Ok(()); + }; + let actual = headers + .get("authorization") + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.strip_prefix("Bearer ")) + .ok_or_else(|| ApiError::unauthorized("missing service access token"))?; + if actual.as_bytes() != expected.as_bytes() { + return Err(ApiError::unauthorized("invalid service access token")); + } + Ok(()) +} + +fn verify_signature(public_key: &str, signature: &str, message: &[u8]) -> Result<(), ApiError> { + let public_key = decode_public_key(public_key)?; + let verifying_key = VerifyingKey::from_sec1_bytes(&public_key) + .map_err(|_| ApiError::unauthorized("invalid device public key"))?; + let signature = general_purpose::STANDARD + .decode(signature) + .map_err(|_| ApiError::unauthorized("invalid signature encoding"))?; + let signature = Signature::from_der(&signature) + .map_err(|_| ApiError::unauthorized("invalid signature format"))?; + verifying_key + .verify(message, &signature) + .map_err(|_| ApiError::unauthorized("device signature verification failed")) +} + +fn session_for( + configuration: &Configuration, + call_id: &str, + room_id: &str, + device: &AuthenticatedDevice, + participant_name: &str, +) -> Result { + let token = livekit_token(configuration, room_id, &device.device_id, participant_name)?; + Ok(InternetCallSession { + call_id: call_id.to_string(), + room_id: room_id.to_string(), + livekit_url: configuration.livekit_url.clone(), + token, + media_key: None, + }) +} + +fn livekit_token( + configuration: &Configuration, + room: &str, + identity: &str, + name: &str, +) -> Result { + let now = unix_time(); + let header = general_purpose::URL_SAFE_NO_PAD.encode(br#"{"alg":"HS256","typ":"JWT"}"#); + let claims = LiveKitClaims { + iss: &configuration.livekit_api_key, + sub: identity, + name, + nbf: now - 5, + exp: now + i64::from(internet_call::TOKEN_TTL_SECONDS), + video: LiveKitVideoGrant { + room_join: true, + room, + can_publish: true, + can_subscribe: true, + can_publish_data: true, + }, + }; + let payload = serde_json::to_vec(&claims) + .map_err(|error| ApiError::internal(format!("token encoding failed: {error}")))?; + let payload = general_purpose::URL_SAFE_NO_PAD.encode(payload); + let signing_input = format!("{header}.{payload}"); + let mut signer = HmacSha256::new_from_slice(configuration.livekit_api_secret.as_bytes()) + .map_err(|_| ApiError::internal("invalid LiveKit API secret"))?; + signer.update(signing_input.as_bytes()); + let signature = general_purpose::URL_SAFE_NO_PAD.encode(signer.finalize().into_bytes()); + Ok(format!("{signing_input}.{signature}")) +} + +fn decode_json(body: &[u8]) -> Result { + serde_json::from_slice(body) + .map_err(|error| ApiError::bad_request(format!("invalid JSON body: {error}"))) +} + +fn header<'a>(headers: &'a HeaderMap, name: &str) -> Result<&'a str, ApiError> { + headers + .get(name) + .and_then(|value| value.to_str().ok()) + .ok_or_else(|| ApiError::unauthorized(format!("missing {name} header"))) +} + +fn lock_database(state: &AppState) -> Result, ApiError> { + state + .database + .lock() + .map_err(|_| ApiError::internal("database lock is poisoned")) +} + +fn require_identity( + auth: &AuthenticatedDevice, + user_id: &str, + device_id: &str, +) -> Result<(), ApiError> { + if auth.user_id != user_id || auth.device_id != device_id { + return Err(ApiError::forbidden("signed device does not match request body")); + } + Ok(()) +} + +fn capability_bits(capabilities: &[String]) -> u8 { + capabilities.iter().fold(0, |bits, capability| { + bits | match capability.as_str() { + "audio" => internet_call::CAP_AUDIO, + "video" => internet_call::CAP_VIDEO, + "mesh" => internet_call::CAP_MESH, + "webrtc" => internet_call::CAP_WEBRTC, + _ => 0, + } + }) +} + +fn decode_public_key(encoded: &str) -> Result, ApiError> { + general_purpose::STANDARD + .decode(encoded) + .map_err(|_| ApiError::bad_request("invalid public-key encoding")) +} + +fn fingerprint(public_key: &[u8]) -> String { + lowercase_hex(&Sha256::digest(public_key)[..12]) +} + +fn stable_id(value: &str) -> u64 { + let digest = Sha256::digest(value.as_bytes()); + let mut bytes = [0_u8; 8]; + bytes.copy_from_slice(&digest[..8]); + u64::from_be_bytes(bytes).max(1) +} + +fn unix_time() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs() as i64) + .unwrap_or(0) +} + +fn random_id(prefix: &str) -> String { + let mut bytes = [0_u8; 16]; + OsRng.fill_bytes(&mut bytes); + format!("{prefix}{}", lowercase_hex(&bytes)) +} + +fn lowercase_hex(bytes: &[u8]) -> String { + bytes.iter().map(|byte| format!("{byte:02x}")).collect() +} + +fn normalize_nickname(value: &str) -> String { + value + .trim() + .trim_start_matches('@') + .to_ascii_lowercase() +} + +fn device_is_online(last_seen: i64, now: i64) -> bool { + last_seen >= 0 + && now >= 0 + && internet_call::device_is_online(last_seen as u32, now as u32) +} + +fn nickname_shape_valid(nickname: &str) -> bool { + let starts_with_letter = nickname + .as_bytes() + .first() + .is_some_and(|byte| byte.is_ascii_lowercase()); + let invalid_characters = nickname + .bytes() + .filter(|byte| !byte.is_ascii_lowercase() && !byte.is_ascii_digit() && *byte != b'_') + .count() + .min(u8::MAX as usize) as u8; + nickname_directory::nickname_shape_is_valid( + nickname.len().min(u8::MAX as usize) as u8, + starts_with_letter, + invalid_characters, + ) +} + +fn nicknames_are_confusing(candidate: &str, existing: &str) -> bool { + let distance = edit_distance(candidate.as_bytes(), existing.as_bytes()).min(u8::MAX as usize) as u8; + let shared_prefix = candidate + .bytes() + .zip(existing.bytes()) + .take_while(|(left, right)| left == right) + .count() + .min(u8::MAX as usize) as u8; + nickname_directory::nickname_is_confusing( + candidate == existing, + distance, + shared_prefix, + ) +} + +fn edit_distance(left: &[u8], right: &[u8]) -> usize { + if left.is_empty() { + return right.len(); + } + if right.is_empty() { + return left.len(); + } + let mut previous: Vec = (0..=right.len()).collect(); + for (left_index, left_value) in left.iter().enumerate() { + let mut current = vec![left_index + 1]; + for (right_index, right_value) in right.iter().enumerate() { + current.push( + (current[right_index] + 1) + .min(previous[right_index + 1] + 1) + .min(previous[right_index] + usize::from(left_value != right_value)), + ); + } + previous = current; + } + previous[right.len()] +} + +fn nickname_suggestions<'a>( + candidate: &str, + seed: &str, + existing: impl Iterator, +) -> Vec { + let existing = existing.map(str::to_string).collect::>(); + let mut base = candidate + .bytes() + .filter(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || *byte == b'_') + .map(char::from) + .collect::(); + if !base + .as_bytes() + .first() + .is_some_and(|byte| byte.is_ascii_lowercase()) + { + base = format!("user_{base}"); + } + if base.len() < nickname_directory::NICKNAME_MIN_LENGTH as usize { + base.push_str("net"); + } + base.truncate(nickname_directory::NICKNAME_MAX_LENGTH as usize - 3); + let seed = stable_id(seed) % 1000; + let mut suggestions = Vec::new(); + for offset in 0..40_u64 { + let suffix = format!("{:03}", (seed + offset * 37) % 1000); + let mut proposal = base.clone(); + proposal.truncate(nickname_directory::NICKNAME_MAX_LENGTH as usize - suffix.len()); + proposal.push_str(&suffix); + if !existing + .iter() + .any(|nickname| nicknames_are_confusing(&proposal, nickname)) + { + suggestions.push(proposal); + if suggestions.len() == 3 { + break; + } + } + } + suggestions +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::{ + body::Body, + http::{Request, StatusCode}, + }; + use http_body_util::BodyExt; + use p256::ecdsa::{signature::Signer, SigningKey}; + use serde_json::{json, Value}; + use tower::ServiceExt; + + struct TestDevice { + user_id: String, + device_id: String, + display_name: String, + signing_key: SigningKey, + public_key: String, + fingerprint: String, + } + + impl TestDevice { + fn new(user_id: &str, device_id: &str, display_name: &str) -> Self { + let signing_key = SigningKey::random(&mut OsRng); + let public_key_bytes = signing_key.verifying_key().to_encoded_point(false); + Self { + user_id: user_id.to_string(), + device_id: device_id.to_string(), + display_name: display_name.to_string(), + public_key: general_purpose::STANDARD.encode(public_key_bytes.as_bytes()), + fingerprint: fingerprint(public_key_bytes.as_bytes()), + signing_key, + } + } + + fn registration(&self) -> Value { + json!({ + "user_id": self.user_id, + "device_id": self.device_id, + "display_name": self.display_name, + "signing_public_key": self.public_key, + "key_fingerprint": self.fingerprint, + "platform": "test", + "voip_push_token": null, + "capabilities": ["audio", "video", "mesh", "webrtc"] + }) + } + } + + fn test_state() -> AppState { + let connection = Connection::open_in_memory().unwrap(); + initialize_database(&connection).unwrap(); + AppState { + database: Arc::new(Mutex::new(connection)), + configuration: Arc::new(Configuration { + bind: "127.0.0.1:8080".parse().unwrap(), + livekit_url: "ws://127.0.0.1:7880".to_string(), + livekit_api_key: "devkey".to_string(), + livekit_api_secret: "secret".to_string(), + service_access_token: None, + }), + } + } + + async fn signed_post( + application: Router, + path: &str, + body: Value, + device: &TestDevice, + ) -> (StatusCode, Option) { + let body = serde_json::to_vec(&body).unwrap(); + let timestamp = unix_time().to_string(); + let nonce = random_id("nonce_"); + let body_hash = lowercase_hex(&Sha256::digest(&body)); + let canonical = format!("POST\n{path}\n{timestamp}\n{nonce}\n{body_hash}"); + let signature: p256::ecdsa::Signature = device.signing_key.sign(canonical.as_bytes()); + let request = Request::builder() + .method("POST") + .uri(path) + .header("content-type", "application/json") + .header("x-trinet-device-id", &device.device_id) + .header("x-trinet-timestamp", timestamp) + .header("x-trinet-nonce", nonce) + .header( + "x-trinet-signature", + general_purpose::STANDARD.encode(signature.to_der().as_bytes()), + ) + .body(Body::from(body)) + .unwrap(); + let response = application.oneshot(request).await.unwrap(); + let status = response.status(); + let bytes = response.into_body().collect().await.unwrap().to_bytes(); + let value = (!bytes.is_empty()) + .then(|| serde_json::from_slice(&bytes).ok()) + .flatten(); + (status, value) + } + + #[test] + fn adapter_similarity_matches_generated_policy() { + assert!(nicknames_are_confusing("alice", "alica")); + assert!(nicknames_are_confusing("alice", "alice2")); + assert!(!nicknames_are_confusing("alice", "bob_net")); + } + + #[test] + fn suggestions_are_valid_and_distinct() { + let existing = ["alice", "alice001"]; + let suggestions = nickname_suggestions("alice", "device", existing.into_iter()); + assert_eq!(suggestions.len(), 3); + assert!(suggestions.iter().all(|value| nickname_shape_valid(value))); + assert!(suggestions + .iter() + .all(|value| existing.iter().all(|item| !nicknames_are_confusing(value, item)))); + } + + #[test] + fn livekit_token_is_room_scoped() { + let configuration = Configuration { + bind: "127.0.0.1:8080".parse().unwrap(), + livekit_url: "ws://127.0.0.1:7880".to_string(), + livekit_api_key: "devkey".to_string(), + livekit_api_secret: "secret".to_string(), + service_access_token: None, + }; + let token = livekit_token(&configuration, "room_one", "device_one", "Alice").unwrap(); + let payload = token.split('.').nth(1).unwrap(); + let decoded = general_purpose::URL_SAFE_NO_PAD.decode(payload).unwrap(); + let value: serde_json::Value = serde_json::from_slice(&decoded).unwrap(); + assert_eq!(value["video"]["room"], "room_one"); + assert_eq!(value["sub"], "device_one"); + assert_eq!(value["video"]["roomJoin"], true); + } + + #[tokio::test] + async fn signed_nickname_to_call_flow_is_end_to_end() { + let state = test_state(); + let caller = TestDevice::new("user_alice", "device_alice", "Alice Phone"); + let callee = TestDevice::new("user_bob", "device_bob", "Bob Phone"); + + for device in [&caller, &callee] { + let (status, _) = signed_post( + application(state.clone()), + "/v1/devices/register", + device.registration(), + device, + ) + .await; + assert_eq!(status, StatusCode::NO_CONTENT); + } + + for (device, nickname) in [(&caller, "alice_net"), (&callee, "bob_net")] { + let (status, response) = signed_post( + application(state.clone()), + "/v1/directory/nicknames/claim", + json!({ + "nickname": nickname, + "user_id": device.user_id, + "device_id": device.device_id + }), + device, + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(response.unwrap()["claimed"], true); + } + + let (status, response) = signed_post( + application(state.clone()), + "/v1/directory/search", + json!({"query": "bob", "limit": 20}), + &caller, + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(response.unwrap()["results"][0]["nickname"], "bob_net"); + + let (status, response) = signed_post( + application(state.clone()), + "/v1/calls", + json!({ + "callee": "bob_net", + "caller_user_id": caller.user_id, + "caller_device_id": caller.device_id, + "audio": true, + "video": true + }), + &caller, + ) + .await; + assert_eq!(status, StatusCode::OK); + let created = response.unwrap(); + let call_id = created["call_id"].as_str().unwrap(); + let room_id = created["room_id"].as_str().unwrap(); + assert!(!created["token"].as_str().unwrap().is_empty()); + + let (status, response) = signed_post( + application(state.clone()), + "/v1/calls/incoming", + json!({"user_id": callee.user_id, "device_id": callee.device_id}), + &callee, + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(response.unwrap()["calls"][0]["call_id"], call_id); + + let join_path = format!("/v1/calls/{call_id}/join"); + let (status, _) = signed_post( + application(state.clone()), + &join_path, + json!({"user_id": caller.user_id, "device_id": caller.device_id}), + &caller, + ) + .await; + assert_eq!(status, StatusCode::FORBIDDEN); + + let (status, response) = signed_post( + application(state), + &join_path, + json!({"user_id": callee.user_id, "device_id": callee.device_id}), + &callee, + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(response.unwrap()["room_id"], room_id); + } + + #[tokio::test] + async fn linked_devices_share_nickname_and_first_answer_wins() { + let state = test_state(); + let caller = TestDevice::new("user_caller", "device_caller", "Caller Phone"); + let owner_phone = TestDevice::new("user_owner", "device_owner_phone", "Owner iPhone"); + let owner_mac = TestDevice::new("user_temporary", "device_owner_mac", "Owner Mac"); + + for device in [&caller, &owner_phone, &owner_mac] { + let (status, _) = signed_post( + application(state.clone()), + "/v1/devices/register", + device.registration(), + device, + ) + .await; + assert_eq!(status, StatusCode::NO_CONTENT); + } + + for (device, nickname) in [ + (&caller, "caller_net"), + (&owner_phone, "owner_net"), + (&owner_mac, "old_mac_net"), + ] { + let (status, response) = signed_post( + application(state.clone()), + "/v1/directory/nicknames/claim", + json!({ + "nickname": nickname, + "user_id": device.user_id, + "device_id": device.device_id + }), + device, + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(response.unwrap()["claimed"], true); + } + + let (status, response) = signed_post( + application(state.clone()), + "/v1/account/link-code", + json!({"user_id": owner_phone.user_id, "device_id": owner_phone.device_id}), + &owner_phone, + ) + .await; + assert_eq!(status, StatusCode::OK); + let link_code = response.unwrap()["link_code"].as_str().unwrap().to_string(); + + let (status, response) = signed_post( + application(state.clone()), + "/v1/account/link", + json!({ + "user_id": owner_mac.user_id, + "device_id": owner_mac.device_id, + "link_code": link_code + }), + &owner_mac, + ) + .await; + assert_eq!(status, StatusCode::OK); + let snapshot = response.unwrap(); + assert_eq!(snapshot["account_id"], owner_phone.user_id); + assert_eq!(snapshot["nickname"], "owner_net"); + assert_eq!(snapshot["devices"].as_array().unwrap().len(), 2); + + let (status, response) = signed_post( + application(state.clone()), + "/v1/calls", + json!({ + "callee": "owner_net", + "caller_user_id": caller.user_id, + "caller_device_id": caller.device_id, + "audio": true, + "video": true + }), + &caller, + ) + .await; + assert_eq!(status, StatusCode::OK); + let call_id = response.unwrap()["call_id"].as_str().unwrap().to_string(); + + for device in [&owner_phone, &owner_mac] { + let (status, response) = signed_post( + application(state.clone()), + "/v1/calls/incoming", + json!({"user_id": owner_phone.user_id, "device_id": device.device_id}), + device, + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(response.unwrap()["calls"][0]["call_id"], call_id); + } + + let join_path = format!("/v1/calls/{call_id}/join"); + let (status, _) = signed_post( + application(state.clone()), + &join_path, + json!({"user_id": owner_phone.user_id, "device_id": owner_mac.device_id}), + &owner_mac, + ) + .await; + assert_eq!(status, StatusCode::OK); + + let (status, _) = signed_post( + application(state), + &join_path, + json!({"user_id": owner_phone.user_id, "device_id": owner_phone.device_id}), + &owner_phone, + ) + .await; + assert_eq!(status, StatusCode::FORBIDDEN); + } + + #[tokio::test] + async fn group_chat_is_shared_by_member_accounts_and_messages_are_idempotent() { + let state = test_state(); + let alice = TestDevice::new("user_alice", "device_alice", "Alice Phone"); + let bob = TestDevice::new("user_bob", "device_bob", "Bob Phone"); + let carol = TestDevice::new("user_carol", "device_carol", "Carol Phone"); + let outsider = TestDevice::new("user_dave", "device_dave", "Dave Phone"); + + for (device, nickname) in [ + (&alice, "alice_net"), + (&bob, "bob_net"), + (&carol, "carol_net"), + (&outsider, "dave_net"), + ] { + let (status, _) = signed_post( + application(state.clone()), + "/v1/devices/register", + device.registration(), + device, + ) + .await; + assert_eq!(status, StatusCode::NO_CONTENT); + let (status, response) = signed_post( + application(state.clone()), + "/v1/directory/nicknames/claim", + json!({ + "nickname": nickname, + "user_id": device.user_id, + "device_id": device.device_id + }), + device, + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(response.unwrap()["claimed"], true); + } + + let (status, response) = signed_post( + application(state.clone()), + "/v1/chats", + json!({ + "creator_user_id": alice.user_id, + "creator_device_id": alice.device_id, + "title": "Field team", + "members": ["@bob_net", "carol_net"] + }), + &alice, + ) + .await; + assert_eq!(status, StatusCode::OK); + let created = response.unwrap(); + let chat_id = created["chat_id"].as_str().unwrap().to_string(); + assert_eq!(created["members"].as_array().unwrap().len(), 3); + + let (status, response) = signed_post( + application(state.clone()), + "/v1/chats/list", + json!({"user_id": bob.user_id, "device_id": bob.device_id}), + &bob, + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(response.unwrap()["chats"][0]["chat_id"], chat_id); + + let message_path = format!("/v1/chats/{chat_id}/messages"); + let message_body = json!({ + "user_id": alice.user_id, + "device_id": alice.device_id, + "client_message_id": "message-0001", + "text": "Meet at point three" + }); + let (status, response) = signed_post( + application(state.clone()), + &message_path, + message_body.clone(), + &alice, + ) + .await; + assert_eq!(status, StatusCode::OK); + let first_message_id = response.unwrap()["message_id"].as_i64().unwrap(); + let (status, response) = signed_post( + application(state.clone()), + &message_path, + message_body, + &alice, + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!( + response.unwrap()["message_id"].as_i64().unwrap(), + first_message_id + ); + + let list_path = format!("/v1/chats/{chat_id}/messages/list"); + let (status, response) = signed_post( + application(state.clone()), + &list_path, + json!({ + "user_id": carol.user_id, + "device_id": carol.device_id, + "after_message_id": 0, + "limit": 50 + }), + &carol, + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(response.unwrap()["messages"][0]["text"], "Meet at point three"); + + let (status, _) = signed_post( + application(state), + &list_path, + json!({ + "user_id": outsider.user_id, + "device_id": outsider.device_id, + "after_message_id": 0, + "limit": 50 + }), + &outsider, + ) + .await; + assert_eq!(status, StatusCode::FORBIDDEN); + } + + #[tokio::test] + async fn nickname_call_rejects_a_stale_destination() { + let state = test_state(); + let caller = TestDevice::new("user_online", "device_online", "Online Phone"); + let callee = TestDevice::new("user_stale", "device_stale", "Stale Phone"); + for (device, nickname) in [(&caller, "online_net"), (&callee, "stale_net")] { + let (status, _) = signed_post( + application(state.clone()), + "/v1/devices/register", + device.registration(), + device, + ) + .await; + assert_eq!(status, StatusCode::NO_CONTENT); + let (status, _) = signed_post( + application(state.clone()), + "/v1/directory/nicknames/claim", + json!({ + "nickname": nickname, + "user_id": device.user_id, + "device_id": device.device_id + }), + device, + ) + .await; + assert_eq!(status, StatusCode::OK); + } + state + .database + .lock() + .unwrap() + .execute( + "UPDATE devices SET last_seen = ?1 WHERE device_id = ?2", + params![ + unix_time() - i64::from(internet_call::PRESENCE_TTL_SECONDS) - 1, + callee.device_id + ], + ) + .unwrap(); + + let (status, _) = signed_post( + application(state), + "/v1/calls", + json!({ + "callee": "stale_net", + "caller_user_id": caller.user_id, + "caller_device_id": caller.device_id, + "audio": true, + "video": true + }), + &caller, + ) + .await; + assert_eq!(status, StatusCode::CONFLICT); + } +} diff --git a/smoke/loopback_call.sh b/smoke/loopback_call.sh index 241ead7e..8874eb1f 100755 --- a/smoke/loopback_call.sh +++ b/smoke/loopback_call.sh @@ -45,9 +45,9 @@ done # does not). python's single sendto() guarantees one datagram. Re-sent every ~4s (UDP is lossy). send_invite() { python3 - <<'PYEOF' -import socket, hashlib, hmac -# The INVITE is authenticated (see CallManager.inviteKey): [FD 11][HMAC:8][payload]. Derive the same -# PSK -> HKDF -> HMAC key so the app accepts this test packet; an unauthenticated one is now rejected. +import socket, hashlib, hmac, time +# The INVITE is authenticated + fresh (see CallManager.inviteKey): [FD 11][HMAC:8][name\nips\nROOM\nTS_MS]. +# Derive the same PSK -> HKDF -> HMAC key AND stamp a current timestamp; unauthenticated OR stale is rejected. def hkdf(ikm, salt, info, n=32): prk = hmac.new(salt, ikm, hashlib.sha256).digest() okm, t, i = b"", b"", 1 @@ -55,7 +55,8 @@ def hkdf(ikm, salt, info, n=32): t = hmac.new(prk, t + info + bytes([i]), hashlib.sha256).digest(); okm += t; i += 1 return okm[:n] key = hkdf(hashlib.sha256(b"tri-net-psk-v1").digest(), b"trios-mesh/v1/invite", b"invite-auth") -payload = b"LOOPBACK smoke\n127.0.0.1,192.168.1.250,192.168.1.251\n" +ts = str(int(time.time() * 1000)).encode() +payload = b"LOOPBACK smoke\n127.0.0.1,192.168.1.250,192.168.1.251\n\n" + ts mac = hmac.new(key, payload, hashlib.sha256).digest()[:8] s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) s.sendto(bytes([0xFD, 0x11]) + mac + payload, ("127.0.0.1", 7000)) diff --git a/specs/account_identity.t27 b/specs/account_identity.t27 new file mode 100644 index 00000000..5620cd33 --- /dev/null +++ b/specs/account_identity.t27 @@ -0,0 +1,79 @@ +// Multi-device account and trusted-device linking policy. +// Passkey ceremonies and storage adapters remain platform responsibilities. +// phi^2 + phi^-2 = 3 + +module AccountIdentity { + use base::types; + + const DEVICE_ACTIVE: u8 = 1; + const DEVICE_REVOKED: u8 = 2; + const LINK_CODE_TTL_SECONDS: u32 = 600; + const LINK_CODE_ENTROPY_BITS: u16 = 128; + + // An account is the stable owner identity. Every installation keeps its + // own device key and becomes a separately revocable account member. + fn device_membership_is_valid(account_id: u64, device_id: u64, key_fingerprint: u64, status: u8) -> bool { + return account_id != 0 && + device_id != 0 && + key_fingerprint != 0 && + status == DEVICE_ACTIVE; + } + + fn link_code_is_fresh(created_at: u32, now: u32) -> bool { + if (now < created_at) { + return false; + } + return (now - created_at) <= LINK_CODE_TTL_SECONDS; + } + + // Linking requires proof from an active account device and a single-use, + // high-entropy code. A single-device account may be merged; its old nick + // is relinquished so one device cannot silently move other installations. + fn may_adopt_account(code_matches: bool, code_unused: bool, code_fresh: bool, source_is_single_device: bool) -> bool { + return code_matches && code_unused && code_fresh && source_is_single_device; + } + + // Losing one device must not destroy the account. At least one active + // member must remain so that another device can approve future changes. + fn may_revoke_device(same_account: bool, target_active: bool, active_devices: u16) -> bool { + return same_account && target_active && active_devices > 1; + } + + test active_device_has_separate_key { + assert(device_membership_is_valid(10, 20, 30, DEVICE_ACTIVE) == true, "active member"); + assert(device_membership_is_valid(10, 20, 30, DEVICE_REVOKED) == false, "revoked member"); + assert(device_membership_is_valid(10, 20, 0, DEVICE_ACTIVE) == false, "missing key"); + } + + test link_code_is_short_lived { + assert(link_code_is_fresh(100, 700) == true, "ttl boundary"); + assert(link_code_is_fresh(100, 701) == false, "expired"); + assert(link_code_is_fresh(101, 100) == false, "future code"); + } + + test account_adoption_needs_all_proofs { + assert(may_adopt_account(true, true, true, true) == true, "trusted link"); + assert(may_adopt_account(false, true, true, true) == false, "wrong code"); + assert(may_adopt_account(true, false, true, true) == false, "replayed code"); + assert(may_adopt_account(true, true, true, false) == false, "multi-device source"); + } + + test last_device_cannot_be_revoked { + assert(may_revoke_device(true, true, 2) == true, "one owner remains"); + assert(may_revoke_device(true, true, 1) == false, "preserve last owner"); + assert(may_revoke_device(false, true, 2) == false, "different account"); + } + + invariant link_code_has_full_random_token + assert LINK_CODE_ENTROPY_BITS >= 128 + + invariant link_window_is_bounded + assert LINK_CODE_TTL_SECONDS <= 600 + + invariant device_states_are_distinct + assert DEVICE_ACTIVE != DEVICE_REVOKED + + bench account_membership_check_latency + measure: nanoseconds to device_membership_is_valid(10, 20, 30, DEVICE_ACTIVE) + target: < 1000ns +} diff --git a/specs/group_chat.t27 b/specs/group_chat.t27 new file mode 100644 index 00000000..76b8d6e0 --- /dev/null +++ b/specs/group_chat.t27 @@ -0,0 +1,90 @@ +// Persistent group chat membership and message policy. +// HTTP, SQLite, and UI adapters are outside this specification. +// phi^2 + phi^-2 = 3 + +module GroupChat { + use base::types; + + const MIN_GROUP_MEMBERS: u8 = 2; + const MAX_GROUP_MEMBERS: u8 = 32; + const MAX_GROUP_TITLE_BYTES: u16 = 80; + const MAX_MESSAGE_BYTES: u16 = 4096; + const MAX_MESSAGE_PAGE: u16 = 100; + + // requested/resolved/unique count only the invited accounts. The creator + // is always inserted separately and must own a verified nickname. + fn group_may_be_created(creator_valid: bool, requested: u8, resolved: u8, unique: u8) -> bool { + if (!creator_valid || requested == 0) { + return false; + } + if (requested != resolved || requested != unique) { + return false; + } + // The creator is the extra member, so at most MAX-1 invitations fit. + // Avoid `requested + 1`: requested is u8 and an attacker can send 255. + return requested < MAX_GROUP_MEMBERS; + } + + fn title_is_valid(byte_length: u16) -> bool { + return byte_length > 0 && byte_length <= MAX_GROUP_TITLE_BYTES; + } + + fn member_may_read(active_member: bool, device_valid: bool) -> bool { + return active_member && device_valid; + } + + fn message_may_be_sent(active_member: bool, device_valid: bool, byte_length: u16) -> bool { + return member_may_read(active_member, device_valid) && + byte_length > 0 && + byte_length <= MAX_MESSAGE_BYTES; + } + + fn message_page_size(requested: u16) -> u16 { + if (requested == 0) { + return 1; + } + if (requested > MAX_MESSAGE_PAGE) { + return MAX_MESSAGE_PAGE; + } + return requested; + } + + test complete_unique_roster_is_required { + assert(group_may_be_created(true, 2, 2, 2) == true, "creator plus two invitees"); + assert(group_may_be_created(true, 2, 1, 2) == false, "unresolved nickname"); + assert(group_may_be_created(true, 2, 2, 1) == false, "duplicate account"); + assert(group_may_be_created(false, 2, 2, 2) == false, "creator needs nickname"); + } + + test membership_gates_history { + assert(member_may_read(true, true) == true, "active account device"); + assert(member_may_read(false, true) == false, "not a member"); + assert(member_may_read(true, false) == false, "invalid device"); + } + + test messages_are_bounded { + assert(message_may_be_sent(true, true, 1) == true, "small message"); + assert(message_may_be_sent(true, true, 4096) == true, "boundary accepted"); + assert(message_may_be_sent(true, true, 4097) == false, "oversized rejected"); + assert(message_may_be_sent(false, true, 10) == false, "non-member rejected"); + } + + test page_size_is_clamped { + assert(message_page_size(0) == 1, "non-empty page"); + assert(message_page_size(50) == 50, "requested page"); + assert(message_page_size(500) == 100, "bounded page"); + } + + invariant member_bounds_are_ordered + assert MIN_GROUP_MEMBERS < MAX_GROUP_MEMBERS + + invariant message_limit_is_positive + assert MAX_MESSAGE_BYTES > 0 + + invariant page_fits_message_limit + assert MAX_MESSAGE_PAGE < MAX_MESSAGE_BYTES + + bench message_policy_latency + measure: nanoseconds to message_may_be_sent(true, true, 128) + target: < 1000ns +} diff --git a/specs/internet_call.t27 b/specs/internet_call.t27 new file mode 100644 index 00000000..b9e0cfa6 --- /dev/null +++ b/specs/internet_call.t27 @@ -0,0 +1,205 @@ +// Internet call policy and lifecycle. +// Network adapters, APNs delivery, and LiveKit token signing are thin wrappers. +// phi^2 + phi^-2 = 3 + +module InternetCall { + use base::types; + + const ROUTE_NONE: u8 = 0; + const ROUTE_MESH: u8 = 1; + const ROUTE_INTERNET: u8 = 2; + + const CALL_IDLE: u8 = 0; + const CALL_RINGING: u8 = 1; + const CALL_ACTIVE: u8 = 2; + const CALL_ENDED: u8 = 3; + + const CAP_AUDIO: u8 = 1; + const CAP_VIDEO: u8 = 2; + const CAP_MESH: u8 = 4; + const CAP_WEBRTC: u8 = 8; + + const INVITE_TTL_SECONDS: u32 = 30; + const TOKEN_TTL_SECONDS: u32 = 300; + const REQUEST_SIGNATURE_TTL_SECONDS: u32 = 60; + const PRESENCE_TTL_SECONDS: u32 = 90; + + // A routable device must have stable opaque identifiers and a public key. + // Display names and IP addresses are deliberately excluded from identity. + fn device_is_valid(user_id: u64, device_id: u64, key_fingerprint: u64, capabilities: u8) -> bool { + if (user_id == 0 || device_id == 0 || key_fingerprint == 0) { + return false; + } + return (capabilities & CAP_AUDIO) != 0; + } + + fn supports_internet_call(capabilities: u8) -> bool { + return ((capabilities & CAP_AUDIO) != 0) && ((capabilities & CAP_WEBRTC) != 0); + } + + fn supports_video_call(capabilities: u8) -> bool { + return supports_internet_call(capabilities) && ((capabilities & CAP_VIDEO) != 0); + } + + fn device_is_online(last_seen: u32, now: u32) -> bool { + if (now < last_seen) { + return false; + } + return (now - last_seen) <= PRESENCE_TTL_SECONDS; + } + + // Auto always prefers the sovereign local path when the peer is reachable. + fn select_route(mesh_reachable: bool, internet_reachable: bool) -> u8 { + if (mesh_reachable) { + return ROUTE_MESH; + } + if (internet_reachable) { + return ROUTE_INTERNET; + } + return ROUTE_NONE; + } + + fn invite_is_fresh(created_at: u32, now: u32) -> bool { + if (now < created_at) { + return false; + } + return (now - created_at) <= INVITE_TTL_SECONDS; + } + + fn token_is_fresh(issued_at: u32, now: u32) -> bool { + if (now < issued_at) { + return false; + } + return (now - issued_at) <= TOKEN_TTL_SECONDS; + } + + // Signed requests are short lived; the adapter must also reject reused nonces. + fn request_signature_is_fresh(signed_at: u32, now: u32) -> bool { + if (now < signed_at) { + return false; + } + return (now - signed_at) <= REQUEST_SIGNATURE_TTL_SECONDS; + } + + fn may_answer(status: u8, invite_fresh: bool, device_valid: bool) -> bool { + return status == CALL_RINGING && invite_fresh && device_valid; + } + + // A caller cannot call its own account. A nickname targets the other + // account and the adapter fans the invitation out to all active endpoints. + fn call_target_is_valid(caller_user_id: u64, caller_device_id: u64, callee_user_id: u64, callee_device_id: u64, callee_capabilities: u8) -> bool { + if (caller_user_id == 0 || caller_device_id == 0 || callee_user_id == 0 || callee_device_id == 0) { + return false; + } + if (caller_user_id == callee_user_id) { + return false; + } + return supports_internet_call(callee_capabilities); + } + + fn call_target_is_available(caller_user_id: u64, caller_device_id: u64, callee_user_id: u64, callee_device_id: u64, callee_capabilities: u8, online: bool) -> bool { + return online && call_target_is_valid(caller_user_id, caller_device_id, callee_user_id, callee_device_id, callee_capabilities); + } + + // Any active destination device selected by the fan-out may accept the + // short-lived invitation. The adapter atomically records the first answer. + fn join_is_authorized(request_user_id: u64, request_device_id: u64, callee_user_id: u64, callee_device_id: u64, status: u8, invite_fresh: bool, device_valid: bool) -> bool { + return request_user_id == callee_user_id && + request_device_id == callee_device_id && + may_answer(status, invite_fresh, device_valid); + } + + fn next_status(status: u8, accept: bool) -> u8 { + if (status == CALL_IDLE) { + return CALL_RINGING; + } + if (status == CALL_RINGING && accept) { + return CALL_ACTIVE; + } + if (status == CALL_RINGING && !accept) { + return CALL_ENDED; + } + if (status == CALL_ACTIVE && !accept) { + return CALL_ENDED; + } + return status; + } + + test stable_identity_requires_public_key { + assert(device_is_valid(10, 20, 30, CAP_AUDIO | CAP_WEBRTC) == true, "valid device"); + assert(device_is_valid(10, 20, 0, CAP_AUDIO | CAP_WEBRTC) == false, "missing key"); + } + + test display_address_is_not_identity { + assert(device_is_valid(10, 20, 30, CAP_AUDIO) == true, "no IP or name required"); + } + + test auto_prefers_mesh { + assert(select_route(true, true) == ROUTE_MESH, "mesh first"); + assert(select_route(false, true) == ROUTE_INTERNET, "internet fallback"); + assert(select_route(false, false) == ROUTE_NONE, "offline"); + } + + test expired_invite_cannot_be_answered { + assert(may_answer(CALL_RINGING, invite_is_fresh(100, 131), true) == false, "expired"); + } + + test call_lifecycle { + ringing = next_status(CALL_IDLE, true); + active = next_status(ringing, true); + ended = next_status(active, false); + assert(ringing == CALL_RINGING, "ringing"); + assert(active == CALL_ACTIVE, "active"); + assert(ended == CALL_ENDED, "ended"); + } + + test video_requires_webrtc { + assert(supports_video_call(CAP_AUDIO | CAP_VIDEO) == false, "no internet transport"); + assert(supports_video_call(CAP_AUDIO | CAP_VIDEO | CAP_WEBRTC) == true, "video enabled"); + } + + test stale_device_proof_is_rejected { + assert(request_signature_is_fresh(100, 160) == true, "boundary accepted"); + assert(request_signature_is_fresh(100, 161) == false, "stale proof"); + assert(request_signature_is_fresh(101, 100) == false, "future proof"); + } + + test internet_target_must_be_a_different_routable_device { + assert(call_target_is_valid(10, 20, 10, 20, CAP_AUDIO | CAP_WEBRTC) == false, "self call rejected"); + assert(call_target_is_valid(10, 20, 10, 21, CAP_AUDIO | CAP_WEBRTC) == false, "same account rejected"); + assert(call_target_is_valid(10, 20, 30, 40, CAP_AUDIO) == false, "no WebRTC"); + assert(call_target_is_valid(10, 20, 30, 40, CAP_AUDIO | CAP_WEBRTC) == true, "remote target"); + } + + test nickname_call_requires_online_device { + assert(device_is_online(100, 190) == true, "presence boundary"); + assert(device_is_online(100, 191) == false, "stale device"); + assert(call_target_is_available(10, 20, 30, 40, CAP_AUDIO | CAP_WEBRTC, true) == true, "online target"); + assert(call_target_is_available(10, 20, 30, 40, CAP_AUDIO | CAP_WEBRTC, false) == false, "offline target"); + } + + test only_fresh_destination_may_join { + assert(join_is_authorized(30, 40, 30, 40, CALL_RINGING, true, true) == true, "callee joins"); + assert(join_is_authorized(10, 20, 30, 40, CALL_RINGING, true, true) == false, "caller cannot answer"); + assert(join_is_authorized(30, 40, 30, 40, CALL_RINGING, false, true) == false, "expired invite"); + } + + invariant route_values_are_distinct + assert ROUTE_MESH != ROUTE_INTERNET + + invariant active_and_ended_are_distinct + assert CALL_ACTIVE != CALL_ENDED + + invariant access_token_outlives_invite + assert TOKEN_TTL_SECONDS > INVITE_TTL_SECONDS + + invariant device_proof_is_short_lived + assert REQUEST_SIGNATURE_TTL_SECONDS < TOKEN_TTL_SECONDS + + invariant presence_outlives_poll_interval + assert PRESENCE_TTL_SECONDS > INVITE_TTL_SECONDS + + bench route_selection_latency + measure: nanoseconds to select_route(true, true) + target: < 1000ns +} diff --git a/specs/mesh_call_signaling.t27 b/specs/mesh_call_signaling.t27 new file mode 100644 index 00000000..0cc8b046 --- /dev/null +++ b/specs/mesh_call_signaling.t27 @@ -0,0 +1,62 @@ +// Signed local call invitation policy. +// UDP sockets, JSON encoding, and UI prompts are adapter responsibilities. +// phi^2 + phi^-2 = 3 + +module MeshCallSignaling { + use base::types; + + const INVITE_VERSION: u8 = 1; + const MEDIA_PORT: u32 = 7000; + const SIGNALING_PORT: u32 = 7001; + const INVITE_TTL_SECONDS: u32 = 30; + + fn invite_is_fresh(created_at: u32, now: u32) -> bool { + if (now < created_at) { + return false; + } + return (now - created_at) <= INVITE_TTL_SECONDS; + } + + fn invite_may_ring( + version: u8, + media_port: u32, + signature_valid: bool, + identity_binding_valid: bool, + nonce_reused: bool, + fresh: bool + ) -> bool { + if (version != INVITE_VERSION || media_port != MEDIA_PORT) { + return false; + } + // The invitation carries the public key and fingerprint that bind the + // signed identity. Prior Bonjour discovery is not required: routed + // mesh peers must be able to ring each other by a known address. + return signature_valid && identity_binding_valid && !nonce_reused && fresh; + } + + test valid_invite_may_ring { + assert(invite_may_ring(1, 7000, true, true, false, true) == true, "self-contained signed invite"); + } + + test forged_or_replayed_invite_is_rejected { + assert(invite_may_ring(1, 7000, false, true, false, true) == false, "bad signature"); + assert(invite_may_ring(1, 7000, true, false, false, true) == false, "invalid identity binding"); + assert(invite_may_ring(1, 7000, true, true, true, true) == false, "replayed nonce"); + } + + test stale_invite_is_rejected { + assert(invite_is_fresh(100, 130) == true, "ttl boundary"); + assert(invite_is_fresh(100, 131) == false, "expired"); + assert(invite_is_fresh(101, 100) == false, "future timestamp"); + } + + invariant signaling_and_media_ports_are_distinct + assert SIGNALING_PORT != MEDIA_PORT + + invariant invite_ttl_is_short + assert INVITE_TTL_SECONDS <= 30 + + bench invite_policy_latency + measure: nanoseconds to invite_may_ring(1, 7000, true, true, false, true) + target: < 1000ns +} diff --git a/specs/nickname_directory.t27 b/specs/nickname_directory.t27 new file mode 100644 index 00000000..8dfaa542 --- /dev/null +++ b/specs/nickname_directory.t27 @@ -0,0 +1,126 @@ +// Nickname directory policy. +// String normalization and network storage are adapter responsibilities. +// phi^2 + phi^-2 = 3 + +module NicknameDirectory { + use base::types; + + const NICKNAME_MIN_LENGTH: u8 = 3; + const NICKNAME_MAX_LENGTH: u8 = 20; + const MAX_EDIT_DISTANCE: u8 = 1; + const MIN_CONFUSING_PREFIX: u8 = 4; + const MESH_ROUTE_CACHE_TTL_SECONDS: u32 = 604800; + + const CLAIM_REJECTED: u8 = 0; + const CLAIM_MESH_LOCAL: u8 = 1; + const CLAIM_VERIFIED: u8 = 2; + + // Adapters restrict normalized nicknames to lowercase ASCII letters, + // decimal digits, and underscore. The first character must be a letter. + fn nickname_shape_is_valid(length: u8, starts_with_letter: bool, invalid_characters: u8) -> bool { + if (length < NICKNAME_MIN_LENGTH || length > NICKNAME_MAX_LENGTH) { + return false; + } + return starts_with_letter && invalid_characters == 0; + } + + // Exact normalized collisions and near-copy names are rejected. Prefix + // checks only apply when at least four characters are shared. + fn nickname_is_confusing(exact_match: bool, edit_distance: u8, shared_prefix: u8) -> bool { + if (exact_match) { + return true; + } + if (edit_distance <= MAX_EDIT_DISTANCE) { + return true; + } + return shared_prefix >= MIN_CONFUSING_PREFIX && edit_distance == 2; + } + + // Global verification requires the authoritative registry. A connected + // mesh can issue only a provisional claim. + fn claim_status(shape_valid: bool, confusing: bool, registry_reachable: bool, registry_accepts: bool) -> u8 { + if (!shape_valid || confusing) { + return CLAIM_REJECTED; + } + if (registry_reachable && registry_accepts) { + return CLAIM_VERIFIED; + } + if (!registry_reachable) { + return CLAIM_MESH_LOCAL; + } + return CLAIM_REJECTED; + } + + fn may_route_by_nickname(claim: u8, signature_valid: bool) -> bool { + return (claim == CLAIM_MESH_LOCAL || claim == CLAIM_VERIFIED) && signature_valid; + } + + // A global nickname belongs to the account, not to one installation. Two + // separately keyed devices in the same account may advertise the same nick. + fn nickname_owner_matches(claim_account_id: u64, device_account_id: u64) -> bool { + return claim_account_id != 0 && claim_account_id == device_account_id; + } + + // A previously verified mesh address remains a usable fallback while the + // peer is temporarily suspended and cannot advertise over Bonjour. + fn cached_mesh_route_is_fresh(last_seen: u32, now: u32) -> bool { + if (now < last_seen) { + return false; + } + return (now - last_seen) <= MESH_ROUTE_CACHE_TTL_SECONDS; + } + + test nickname_shape_rules { + assert(nickname_shape_is_valid(3, true, 0) == true, "minimum valid"); + assert(nickname_shape_is_valid(2, true, 0) == false, "too short"); + assert(nickname_shape_is_valid(21, true, 0) == false, "too long"); + assert(nickname_shape_is_valid(8, false, 0) == false, "must start with letter"); + assert(nickname_shape_is_valid(8, true, 1) == false, "invalid character"); + } + + test exact_and_near_copy_are_confusing { + assert(nickname_is_confusing(true, 0, 8) == true, "exact collision"); + assert(nickname_is_confusing(false, 1, 0) == true, "near copy"); + assert(nickname_is_confusing(false, 3, 2) == false, "distinct name"); + } + + test registry_is_authoritative { + assert(claim_status(true, false, true, true) == CLAIM_VERIFIED, "global claim"); + assert(claim_status(true, false, false, false) == CLAIM_MESH_LOCAL, "offline claim"); + assert(claim_status(true, false, true, false) == CLAIM_REJECTED, "registry rejected"); + } + + test signed_claim_routes { + assert(may_route_by_nickname(CLAIM_VERIFIED, true) == true, "verified route"); + assert(may_route_by_nickname(CLAIM_MESH_LOCAL, true) == true, "mesh route"); + assert(may_route_by_nickname(CLAIM_VERIFIED, false) == false, "unsigned route"); + } + + test linked_devices_share_nickname_owner { + assert(nickname_owner_matches(10, 10) == true, "same account"); + assert(nickname_owner_matches(10, 20) == false, "different account"); + assert(nickname_owner_matches(0, 0) == false, "missing identity"); + } + + test cached_mesh_route_expiry { + assert(cached_mesh_route_is_fresh(100, 604900) == true, "ttl boundary"); + assert(cached_mesh_route_is_fresh(100, 604901) == false, "expired route"); + assert(cached_mesh_route_is_fresh(101, 100) == false, "future observation"); + } + + invariant nickname_length_bounds + assert NICKNAME_MIN_LENGTH < NICKNAME_MAX_LENGTH + + invariant verified_is_stronger_than_local + assert CLAIM_VERIFIED > CLAIM_MESH_LOCAL + + invariant confusing_distance_is_small + assert MAX_EDIT_DISTANCE < NICKNAME_MIN_LENGTH + + invariant mesh_route_cache_is_bounded + assert MESH_ROUTE_CACHE_TTL_SECONDS <= 604800 + + bench nickname_policy_latency + measure: nanoseconds to nickname_is_confusing(false, 1, 0) + target: < 1000ns +} diff --git a/src/bin/tri_rti.rs b/src/bin/tri_rti.rs index 78500ee8..4360858b 100644 --- a/src/bin/tri_rti.rs +++ b/src/bin/tri_rti.rs @@ -468,7 +468,7 @@ fn run_fusion() { let should = (cmd >> 14) & 1; let dir_name = ["CCW", "CW", "none"][dir as usize]; let action = if should == 1 { "SLEW" } else { "in view" }; - eprintln!(" {:>3} | ({:>4.0},{:>4.0}) | {:>5}° | {:>3}° | {:>9} | {}", + eprintln!(" {:>3} | ({:>4.0},{:>4.0}) | {:>5}° | {:>3}° | {:>9} | {}", i+1, bx, by, 0, slew, dir_name, action); } eprintln!("\nFusion: RTI finds objects → camera slews to confirm visually."); diff --git a/src/lib.rs b/src/lib.rs index 34c3cc2e..b25b3798 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -45,6 +45,17 @@ pub mod anomaly_detector; #[path = "../gen/rust/quarantine_manager.rs"] pub mod quarantine_manager; +#[path = "../gen/rust/internet_call.rs"] +pub mod internet_call; + +#[path = "../gen/rust/nickname_directory.rs"] +pub mod nickname_directory; +#[path = "../gen/rust/mesh_call_signaling.rs"] +pub mod mesh_call_signaling; + +#[path = "../gen/rust/group_chat.rs"] +pub mod group_chat; + // Types used across the crate pub type NodeId = u32;