diff --git a/.github/workflows/simdeck-provider.yml b/.github/workflows/simdeck-provider.yml new file mode 100644 index 00000000..e240bce5 --- /dev/null +++ b/.github/workflows/simdeck-provider.yml @@ -0,0 +1,202 @@ +name: SimDeck Provider + +on: + workflow_dispatch: + inputs: + preview_id: + description: SimDeck Cloud preview deployment id + required: true + provider_token: + description: One-time provider registration token + required: true + simdeck_cloud_url: + description: SimDeck Cloud control plane URL + required: true + artifact_id: + description: GitHub Actions artifact id containing a simulator .app + required: false + source_run_id: + description: Source workflow run id that uploaded the simulator artifact + required: false + bundle_id: + description: iOS app bundle id to launch after install + required: false + simdeck_artifact_url: + description: Optional URL to a prebuilt simdeck binary tarball + required: false + +permissions: + actions: read + contents: read + +jobs: + provider: + runs-on: macos-latest + timeout-minutes: 360 + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Download simulator artifact + if: ${{ inputs.artifact_id != '' }} + uses: actions/download-artifact@v4 + with: + artifact-ids: ${{ inputs.artifact_id }} + run-id: ${{ inputs.source_run_id || github.run_id }} + github-token: ${{ github.token }} + path: simdeck-artifact + + - name: Install cloudflared + shell: bash + run: | + set -euo pipefail + brew install cloudflare/cloudflare/cloudflared + + - name: Register provider workflow + shell: bash + env: + SIMDECK_CLOUD_URL: ${{ inputs.simdeck_cloud_url }} + PREVIEW_ID: ${{ inputs.preview_id }} + PROVIDER_TOKEN: ${{ inputs.provider_token }} + run: | + set -euo pipefail + curl -fsS \ + -H 'content-type: application/json' \ + -d "{ + \"previewId\":\"$PREVIEW_ID\", + \"providerToken\":\"$PROVIDER_TOKEN\", + \"providerRunId\":\"$GITHUB_RUN_ID\", + \"providerRunUrl\":\"$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID\", + \"status\":\"running\", + \"simulatorName\":\"GitHub Actions macOS runner\" + }" \ + "$SIMDECK_CLOUD_URL/api/actions/providers/register" + + - name: Build or download SimDeck + shell: bash + env: + SIMDECK_ARTIFACT_URL: ${{ inputs.simdeck_artifact_url }} + run: | + set -euo pipefail + if [[ -n "${SIMDECK_ARTIFACT_URL:-}" ]]; then + mkdir -p build + curl -fsSL "$SIMDECK_ARTIFACT_URL" | tar -xz -C build + chmod +x build/simdeck + elif [[ -x ./scripts/build-client.sh && -x ./scripts/build-cli.sh ]]; then + ./scripts/build-client.sh + ./scripts/build-cli.sh + else + git clone --depth 1 https://github.com/NativeScript/SimDeck.git simdeck-src + ./simdeck-src/scripts/build-client.sh + ./simdeck-src/scripts/build-cli.sh + mkdir -p build + cp simdeck-src/build/simdeck build/simdeck + chmod +x build/simdeck + fi + + - name: Start simulator provider + shell: bash + env: + SIMDECK_CLOUD_URL: ${{ inputs.simdeck_cloud_url }} + PREVIEW_ID: ${{ inputs.preview_id }} + PROVIDER_TOKEN: ${{ inputs.provider_token }} + BUNDLE_ID: ${{ inputs.bundle_id }} + SIMDECK_ALLOWED_ORIGINS: ${{ inputs.simdeck_cloud_url }} + run: | + set -euo pipefail + + ios_udid="$(xcrun simctl list devices available --json | python3 -c ' + import json, sys + data = json.load(sys.stdin) + for runtime, devices in data.get("devices", {}).items(): + if "iOS" not in runtime: + continue + for device in devices: + if device.get("isAvailable"): + print(device["udid"]) + raise SystemExit(0) + raise SystemExit("No available iOS simulator") + ')" + + xcrun simctl boot "$ios_udid" || true + xcrun simctl bootstatus "$ios_udid" -b + + app_path="" + if [[ -d simdeck-artifact ]]; then + app_path="$(find simdeck-artifact -maxdepth 6 -type d -name '*.app' | head -n 1 || true)" + fi + if [[ -n "$app_path" ]]; then + xcrun simctl install "$ios_udid" "$app_path" + fi + if [[ -n "${BUNDLE_ID:-}" ]]; then + xcrun simctl launch "$ios_udid" "$BUNDLE_ID" || true + fi + + ./build/simdeck serve \ + --port 4310 \ + --bind 127.0.0.1 \ + --access-token "$PROVIDER_TOKEN" \ + --video-codec h264-software \ + > simdeck.log 2>&1 & + simdeck_pid="$!" + + for _ in {1..120}; do + if curl -fsS http://127.0.0.1:4310/api/health >/dev/null; then + break + fi + sleep 1 + done + + cloudflared tunnel --url http://127.0.0.1:4310 \ + > cloudflared.log 2>&1 & + cloudflared_pid="$!" + + tunnel_url="" + for _ in {1..120}; do + tunnel_url="$(grep -Eo 'https://[-a-zA-Z0-9.]+\.trycloudflare\.com' cloudflared.log | head -n 1 || true)" + if [[ -n "$tunnel_url" ]]; then + break + fi + sleep 1 + done + + if [[ -z "$tunnel_url" ]]; then + echo "cloudflared did not print a trycloudflare.com URL" + cat cloudflared.log || true + exit 1 + fi + + register() { + curl -fsS \ + -H 'content-type: application/json' \ + -d "{ + \"previewId\":\"$PREVIEW_ID\", + \"providerToken\":\"$PROVIDER_TOKEN\", + \"providerRunId\":\"$GITHUB_RUN_ID\", + \"providerRunUrl\":\"$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID\", + \"baseUrl\":\"$tunnel_url/?simdeckToken=$PROVIDER_TOKEN&transport=webrtc&device=$ios_udid\", + \"simulatorUdid\":\"$ios_udid\", + \"simulatorName\":\"GitHub Actions macOS runner\", + \"runtimeName\":\"$(xcrun simctl getenv "$ios_udid" SIMULATOR_RUNTIME_VERSION 2>/dev/null || true)\", + \"status\":\"ready\", + \"simulatorCount\":1 + }" \ + "$SIMDECK_CLOUD_URL/api/actions/providers/register" + } + + register + echo "SimDeck tunnel: $tunnel_url" + while true; do + sleep 30 + if ! kill -0 "$simdeck_pid" 2>/dev/null; then + echo "simdeck exited" + cat simdeck.log || true + exit 1 + fi + if ! kill -0 "$cloudflared_pid" 2>/dev/null; then + echo "cloudflared exited" + cat cloudflared.log || true + exit 1 + fi + register + done diff --git a/.gitignore b/.gitignore index dcf38f22..847fd936 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ packages/nativescript-inspector/dist/ packages/react-native-inspector/dist/ docs/.vitepress/dist/ docs/.vitepress/cache/ +cloud/ .playwright-mcp/ simdeck-snapshot.md diff --git a/AGENTS.md b/AGENTS.md index a16ae1fb..42251385 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,6 +20,8 @@ The native side should own anything that depends on macOS frameworks, `xcrun sim Defines REST routes for simulator control, health, metrics, and chrome assets. - `server/src/transport/webtransport.rs` Exposes one WebTransport session path per simulator and streams binary video packets. +- `server/src/transport/webrtc.rs` + Exposes an experimental H.264 WebRTC offer/answer endpoint for browser-to-runner preview tunnels. - `server/src/simulators/registry.rs` Tracks Rust-side simulator session state and lazy native attachment by UDID. - `cli/XCWSimctl.*` @@ -140,7 +142,7 @@ Useful direct commands: - If you change a CLI flag, REST route, packet format, or inspector method, update the matching page under `docs/` in the same pass. - If you expand the private framework bridge, document the Xcode/runtime assumptions here. - If a feature depends on a booted simulator, fail with a clear JSON error instead of silently returning an empty asset. -- Do not reintroduce legacy `/stream.h264` handling. The supported live path is Rust-managed WebTransport. +- Do not reintroduce legacy `/stream.h264` handling. The supported live paths are Rust-managed WebTransport and the experimental WebRTC offer endpoint. ## Near-Term Roadmap diff --git a/README.md b/README.md index a2f5f078..e6d1b259 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ npm i -g simdeck@latest ## Features -- WebTransport based streaming server in Rust, hardware encoded HVEC/H264 video stream +- WebTransport streaming server in Rust, plus experimental WebRTC for runner previews, using hardware encoded HEVC/H.264 video - Simulator control & inspection using private accessibility APIs - CoreSimulator chrome asset rendering for device bezels - NativeScript and React Native runtime inspector plugins, plus a native UIKit inspector framework for other apps @@ -55,6 +55,10 @@ simdeck ui --open This starts or reuses the project daemon, enables the browser UI, and opens the authenticated local URL. To focus a specific simulator, add `?device=UDID` to the opened URL. +SimDeck Cloud uses the same server binary as its GitHub Actions provider. The +provider workflow starts `simdeck serve` on the runner, exposes it through a +tunnel, and lets the hosted control plane connect to the simulator with a +one-time access token. The daemon exposes HTTP on the requested port and WebTransport on `port + 1`. The browser bootstrap comes from `GET /api/health`, which returns the WebTransport URL template, diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 941c7862..f78538bc 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -1,12 +1,29 @@ import { API_ROOT } from "../shared/constants"; +export function accessTokenFromLocation(): string { + if (typeof window === "undefined") { + return ""; + } + return new URLSearchParams(window.location.search).get("simdeckToken") ?? ""; +} + +export function apiHeaders(headers: HeadersInit = {}): HeadersInit { + const token = accessTokenFromLocation(); + return { + "Content-Type": "application/json", + ...(token ? { "X-SimDeck-Token": token } : {}), + ...headers, + }; +} + export async function apiRequest( path: string, options: RequestInit = {}, ): Promise { + const { headers, ...rest } = options; const response = await fetch(`${API_ROOT}${path}`, { - headers: { "Content-Type": "application/json" }, - ...options, + ...rest, + headers: apiHeaders(headers), }); const contentType = response.headers.get("content-type") ?? ""; diff --git a/client/src/features/stream/streamWorkerClient.ts b/client/src/features/stream/streamWorkerClient.ts index c4768994..4327ac48 100644 --- a/client/src/features/stream/streamWorkerClient.ts +++ b/client/src/features/stream/streamWorkerClient.ts @@ -1,4 +1,10 @@ -import type { StreamConnectTarget, WorkerToMainMessage } from "./streamTypes"; +import { accessTokenFromLocation, apiHeaders } from "../../api/client"; +import { createEmptyStreamStats } from "./stats"; +import type { + StreamConnectTarget, + StreamStats, + WorkerToMainMessage, +} from "./streamTypes"; export function buildStreamTarget(udid: string): StreamConnectTarget { return { udid }; @@ -7,7 +13,7 @@ export function buildStreamTarget(udid: string): StreamConnectTarget { interface StreamClientBackend { attachCanvas(canvasElement: HTMLCanvasElement): void; clear(): void; - connect(target: StreamConnectTarget): void; + connect(target: StreamConnectTarget): void | Promise; destroy(): void; disconnect(): void; } @@ -52,6 +58,208 @@ class WorkerStreamClient implements StreamClientBackend { } } +class WebRtcStreamClient implements StreamClientBackend { + private animationFrame = 0; + private canvas: HTMLCanvasElement | null = null; + private context: CanvasRenderingContext2D | null = null; + private peerConnection: RTCPeerConnection | null = null; + private stats: StreamStats = createEmptyStreamStats(); + private video: HTMLVideoElement | null = null; + + constructor( + private readonly onMessage: (message: WorkerToMainMessage) => void, + ) {} + + attachCanvas(canvasElement: HTMLCanvasElement) { + this.canvas = canvasElement; + this.context = canvasElement.getContext("2d", { + alpha: false, + desynchronized: true, + } as CanvasRenderingContext2DSettings & { desynchronized: boolean }); + if (!this.context) { + throw new Error("Unable to create a 2D canvas renderer for WebRTC."); + } + } + + clear() { + if (!this.canvas || !this.context) { + return; + } + this.context.clearRect(0, 0, this.canvas.width, this.canvas.height); + } + + async connect(target: StreamConnectTarget) { + this.disconnect(); + if (!this.canvas || !this.context) { + return; + } + this.stats = createEmptyStreamStats(); + this.onMessage({ + type: "status", + status: { detail: "Creating WebRTC offer", state: "connecting" }, + }); + + const peerConnection = new RTCPeerConnection({ + iceServers: iceServers(), + }); + this.peerConnection = peerConnection; + peerConnection.addTransceiver("video", { direction: "recvonly" }); + + peerConnection.ontrack = (event) => { + const [stream] = event.streams; + if (!stream) { + return; + } + const video = document.createElement("video"); + video.autoplay = true; + video.muted = true; + video.playsInline = true; + video.srcObject = stream; + this.video = video; + video.onloadedmetadata = () => { + void video.play(); + this.syncCanvasSize(video.videoWidth, video.videoHeight); + this.onMessage({ + type: "video-config", + size: { height: video.videoHeight, width: video.videoWidth }, + }); + this.onMessage({ + type: "status", + status: { detail: "WebRTC media connected", state: "streaming" }, + }); + this.drawVideoFrame(); + }; + }; + + peerConnection.onconnectionstatechange = () => { + if (peerConnection.connectionState === "failed") { + this.onMessage({ + type: "status", + status: { + error: "WebRTC connection failed.", + state: "error", + }, + }); + } + }; + + const offer = await peerConnection.createOffer(); + await peerConnection.setLocalDescription(offer); + await waitForIceGathering(peerConnection); + const localDescription = peerConnection.localDescription; + if (!localDescription) { + throw new Error("WebRTC local offer was not created."); + } + + const response = await fetch( + `/api/simulators/${encodeURIComponent(target.udid)}/webrtc/offer`, + { + body: JSON.stringify({ + sdp: localDescription.sdp, + type: localDescription.type, + }), + headers: apiHeaders(), + method: "POST", + }, + ); + if (!response.ok) { + throw new Error(await response.text()); + } + const answer = (await response.json()) as RTCSessionDescriptionInit; + await peerConnection.setRemoteDescription(answer); + } + + disconnect() { + window.cancelAnimationFrame(this.animationFrame); + this.animationFrame = 0; + this.video?.pause(); + this.video = null; + this.peerConnection?.close(); + this.peerConnection = null; + this.onMessage({ type: "status", status: { state: "idle" } }); + } + + destroy() { + this.disconnect(); + } + + private drawVideoFrame = () => { + if (!this.canvas || !this.context || !this.video) { + return; + } + if (this.video.videoWidth > 0 && this.video.videoHeight > 0) { + this.syncCanvasSize(this.video.videoWidth, this.video.videoHeight); + this.context.drawImage( + this.video, + 0, + 0, + this.canvas.width, + this.canvas.height, + ); + this.stats.decodedFrames += 1; + this.stats.renderedFrames += 1; + this.stats.receivedPackets += 1; + this.stats.width = this.canvas.width; + this.stats.height = this.canvas.height; + this.stats.codec = "webrtc"; + this.onMessage({ type: "stats", stats: { ...this.stats } }); + } + this.animationFrame = window.requestAnimationFrame(this.drawVideoFrame); + }; + + private syncCanvasSize(width: number, height: number) { + if (!this.canvas) { + return; + } + const nextWidth = Math.max(1, Math.round(width)); + const nextHeight = Math.max(1, Math.round(height)); + if (this.canvas.width !== nextWidth) { + this.canvas.width = nextWidth; + } + if (this.canvas.height !== nextHeight) { + this.canvas.height = nextHeight; + } + } +} + +function streamTransportMode(): string { + if (typeof window === "undefined") { + return "webtransport"; + } + return ( + new URLSearchParams(window.location.search).get("transport") ?? + "webtransport" + ); +} + +function iceServers(): RTCIceServer[] { + const params = new URLSearchParams(window.location.search); + const raw = params.get("iceServers") ?? "stun:stun.l.google.com:19302"; + return [ + { + urls: raw + .split(",") + .map((value) => value.trim()) + .filter(Boolean), + }, + ]; +} + +function waitForIceGathering(peerConnection: RTCPeerConnection) { + if (peerConnection.iceGatheringState === "complete") { + return Promise.resolve(); + } + return new Promise((resolve) => { + const timeout = window.setTimeout(resolve, 3000); + peerConnection.addEventListener("icegatheringstatechange", () => { + if (peerConnection.iceGatheringState === "complete") { + window.clearTimeout(timeout); + resolve(); + } + }); + }); +} + export class StreamWorkerClient { private readonly onMessage: (message: WorkerToMainMessage) => void; private backend: StreamClientBackend | null = null; @@ -73,7 +281,28 @@ export class StreamWorkerClient { } connect(target: StreamConnectTarget) { - this.backend?.connect(target); + try { + const result = this.backend?.connect(target); + if (result && typeof result.catch === "function") { + result.catch((error: unknown) => { + this.onMessage({ + type: "status", + status: { + error: error instanceof Error ? error.message : String(error), + state: "error", + }, + }); + }); + } + } catch (error) { + this.onMessage({ + type: "status", + status: { + error: error instanceof Error ? error.message : String(error), + state: "error", + }, + }); + } } disconnect() { @@ -94,6 +323,9 @@ export class StreamWorkerClient { } private createBackend(canvasElement: HTMLCanvasElement): StreamClientBackend { + if (streamTransportMode() === "webrtc" && accessTokenFromLocation()) { + return new WebRtcStreamClient(this.onMessage); + } void canvasElement; return new WorkerStreamClient(this.onMessage); } diff --git a/docs/api/rest.md b/docs/api/rest.md index 0a7c8a4e..ab4a9142 100644 --- a/docs/api/rest.md +++ b/docs/api/rest.md @@ -130,6 +130,30 @@ Forces the encoder to emit a fresh keyframe. Useful after a discontinuity or whe { "ok": true } ``` +### `POST /api/simulators/{udid}/webrtc/offer` + +Experimental WebRTC transport for runner-hosted previews. The browser sends an +SDP offer and the server responds with an SDP answer for a receive-only H.264 +video track: + +```json +{ + "sdp": "v=0\r\n...", + "type": "offer" +} +``` + +```json +{ + "sdp": "v=0\r\n...", + "type": "answer" +} +``` + +This endpoint requires the simulator stream to be H.264. For GitHub Actions +provider runs, start the runner host with `--video-codec h264-software` and pass +the one-time provider token as `--access-token`. + ### `POST /api/simulators/{udid}/open-url` Opens a URL inside the simulator: diff --git a/docs/contributing.md b/docs/contributing.md index 30eb247e..dcb9009e 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -45,18 +45,18 @@ To run only the production server: ## Layout -| Folder | What lives here | -| ---------------------------------- | --------------------------------------------------------------------------------------------------------- | -| `server/` | CLI entrypoint, project daemon, Rust HTTP server, WebTransport hub, inspector hub, registry, and metrics. | -| `cli/` | Objective-C native bridge for private CoreSimulator and SimulatorKit APIs. | -| `client/` | React UI served at `/`. | -| `packages/nativescript-inspector/` | TypeScript runtime for the NativeScript inspector. | -| `packages/inspector-agent/` | Swift Package for the Swift in-app inspector agent. | -| `packages/simdeck-test/` | JS/TS testing API for daemon-backed simulator automation. | -| `packages/vscode-extension/` | VS Code extension that opens the simulator inside an editor panel. | -| `scripts/` | Repeatable build entrypoints used by both local dev and CI. | -| `bin/` | Node launcher that locates and runs the compiled binary. | -| `docs/` | This documentation site (VitePress). | +| Folder | What lives here | +| ---------------------------------- | ---------------------------------------------------------------------------------------------------------- | +| `server/` | CLI entrypoint, project daemon, Rust HTTP server, stream transports, inspector hub, registry, and metrics. | +| `cli/` | Objective-C native bridge for private CoreSimulator and SimulatorKit APIs. | +| `client/` | React UI served at `/`. | +| `packages/nativescript-inspector/` | TypeScript runtime for the NativeScript inspector. | +| `packages/inspector-agent/` | Swift Package for the Swift in-app inspector agent. | +| `packages/simdeck-test/` | JS/TS testing API for daemon-backed simulator automation. | +| `packages/vscode-extension/` | VS Code extension that opens the simulator inside an editor panel. | +| `scripts/` | Repeatable build entrypoints used by both local dev and CI. | +| `bin/` | Node launcher that locates and runs the compiled binary. | +| `docs/` | This documentation site (VitePress). | ## Working rules @@ -70,7 +70,7 @@ If you contribute, keep these invariants in mind. They are also enforced by the - Don't add a Node or Swift dependency to solve work that already fits in Foundation/AppKit. - When touching private API usage, keep the adaptation small and explicit and document any simulator/runtime assumptions in `AGENTS.md`. - Prefer stable CLI subcommands over hidden environment variables. -- The supported live video path is WebTransport-only. Do not bring back legacy `/stream.h264` handling. +- The supported live video paths are WebTransport and the experimental WebRTC offer endpoint. Do not bring back legacy `/stream.h264` handling. - If a feature depends on a booted simulator, fail with a clear JSON error instead of silently returning an empty asset. ## Linting and formatting diff --git a/docs/guide/architecture.md b/docs/guide/architecture.md index c4ae0c6f..823fab9d 100644 --- a/docs/guide/architecture.md +++ b/docs/guide/architecture.md @@ -6,17 +6,17 @@ SimDeck is intentionally split into a small number of clearly-scoped layers. Eve SimDeck has three layers stacked between the browser and the iOS Simulator: -1. **Browser / VS Code** runs the React client from `client/`. It speaks HTTP for control and WebTransport for live video, both served by the Rust server. -2. **The Rust server** (`server/`, built on `axum` + `tokio`) owns the CLI entrypoint, project daemon lifecycle, REST routes (`api/`), the WebTransport hub and packet codec (`transport/`), the inspector WebSocket hub (`inspector.rs`), the per-UDID session registry (`simulators/`), metrics, and log streaming. +1. **Browser / VS Code** runs the React client from `client/`. It speaks HTTP for control and WebTransport or WebRTC for live video, served by the Rust server. +2. **The Rust server** (`server/`, built on `axum` + `tokio`) owns the CLI entrypoint, project daemon lifecycle, REST routes (`api/`), the stream transports (`transport/`), the inspector WebSocket hub (`inspector.rs`), the per-UDID session registry (`simulators/`), metrics, and log streaming. 3. **The Objective-C bridge** (`cli/`) is reached through a narrow C ABI in `cli/native/XCWNativeBridge.*`. It wraps `xcrun simctl`, the private `CoreSimulator` direct-boot path, the per-session HEVC/H.264 encoder, the headless display bridge that produces frames and accepts HID input, and the device-chrome renderer. Underneath all of that is the iOS Simulator itself — `CoreSimulator` for lifecycle, `SimulatorKit` for chrome assets. ## Layer responsibilities -### `server/` — Rust HTTP and WebTransport +### `server/` — Rust HTTP and stream transports -Owns the public CLI shape (`simdeck ui`, `daemon`, `boot`, `shutdown`, …), daemon metadata, the HTTP API, the WebTransport hub, the inspector hub, log streaming, and metrics. +Owns the public CLI shape (`simdeck ui`, `daemon`, `boot`, `shutdown`, …), daemon metadata, the HTTP API, WebTransport/WebRTC streaming, the inspector hub, log streaming, and metrics. Key modules: @@ -25,6 +25,7 @@ Key modules: | `server/src/main.rs` | CLI entrypoint, project daemon management, AppKit main-thread shim, tokio runtime bootstrap. | | `server/src/api/routes.rs` | Every `/api/*` route, including simulator control, accessibility, and inspector proxy. | | `server/src/transport/webtransport.rs` | WebTransport server, per-session frame fanout, keyframe handshake. | +| `server/src/transport/webrtc.rs` | Experimental WebRTC offer/answer endpoint for H.264 runner previews. | | `server/src/transport/packet.rs` | Binary video packet header (`PACKET_VERSION`, flags, layout). | | `server/src/inspector.rs` | WebSocket hub for the NativeScript runtime inspector. | | `server/src/simulators/registry.rs` | Per-UDID session registry with lazy attachment to the native bridge. | @@ -53,13 +54,13 @@ Inside the bridge: ### `client/` — React browser UI -The React app served at `/` is a thin shell that calls the REST API and consumes binary video over WebTransport. +The React app served at `/` is a thin shell that calls the REST API and consumes live video over WebTransport by default. Runner preview URLs can opt into WebRTC with `?transport=webrtc`. Layout under `client/src/`: - `app/AppShell.tsx` — top-level shell. - `api/` — typed wrappers around `/api/*` (`client.ts`, `controls.ts`, `simulators.ts`, `types.ts`). -- `features/stream/` — WebTransport reader, decoder workers, frame renderer. +- `features/stream/` — WebTransport reader, WebRTC client, decoder workers, frame renderer. - `features/viewport/` — frame canvas, hit testing, chrome compositing. - `features/input/` — touch/keyboard/hardware button affordances. - `features/accessibility/` — accessibility tree pane and source switcher. @@ -87,7 +88,9 @@ Most control endpoints follow the same path: a typed Rust handler in `server/src The browser opens a WebTransport session at `https://host:4311/wt/simulators/{udid}`. The handler in `transport::webtransport::handle_session` ensures the per-UDID `SimulatorSession` is started, waits up to ~3 s for the first keyframe, then opens two unidirectional streams to the client: a control stream that carries a single JSON `ControlHello` describing the codec, and a video stream that carries binary frame packets fanned out from `SimulatorSession.subscribe()`. -Each binary packet has a fixed-size 36-byte header followed by an optional codec configuration (description) blob and the encoded video data. See [WebTransport](/api/webtransport) and [Packet Format](/api/packet-format) for the wire layout. +Each WebTransport binary packet has a fixed-size 36-byte header followed by an optional codec configuration (description) blob and the encoded video data. See [WebTransport](/api/webtransport) and [Packet Format](/api/packet-format) for the wire layout. + +For GitHub Actions preview tunnels, the browser can instead POST an SDP offer to `/api/simulators/{udid}/webrtc/offer`. That path requires H.264 and sends the same simulator frame source over a WebRTC video track. ### Input @@ -108,7 +111,7 @@ The server discovers which inspectors are reachable for a given Simulator and su SimDeck stays in one OS process. The Rust binary: 1. Calls `xcw_native_initialize_app()` so AppKit creates an `NSApplication` on the main thread. -2. Spawns a tokio runtime on a worker thread that owns the HTTP server, WebTransport server, inspector hub, and registry. +2. Spawns a tokio runtime on a worker thread that owns the HTTP server, stream transports, inspector hub, and registry. 3. Spins the AppKit main loop in 50 ms slices on the main thread to dispatch display and HID callbacks. Normal CLI commands may spawn `simdeck daemon run` in the background for the current project. The daemon writes metadata under the system temp directory, and later commands reuse it while `/api/health` stays healthy. @@ -122,4 +125,4 @@ If you contribute, keep the following invariants in mind: - Browser-only presentation logic stays in `client/`. - NativeScript app runtime inspection logic stays in `packages/nativescript-inspector/`. - Add a server endpoint before adding client-only assumptions. -- The supported live video path is WebTransport-only — do not bring back legacy `/stream.h264` handling. +- The supported live video paths are WebTransport and the experimental WebRTC offer endpoint. Do not bring back legacy `/stream.h264` handling. diff --git a/server/Cargo.lock b/server/Cargo.lock index 893fd973..2c514411 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -2,6 +2,41 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -67,22 +102,59 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "arc-swap" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" +dependencies = [ + "rustversion", +] + +[[package]] +name = "asn1-rs" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048" +dependencies = [ + "asn1-rs-derive 0.5.1", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + [[package]] name = "asn1-rs" version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56624a96882bb8c26d61312ae18cb45868e5a9992ea73c58e45c3101e56a1e60" dependencies = [ - "asn1-rs-derive", + "asn1-rs-derive 0.6.0", "asn1-rs-impl", "displaydoc", "nom", "num-traits", "rusticata-macros", - "thiserror", + "thiserror 2.0.18", "time", ] +[[package]] +name = "asn1-rs-derive" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + [[package]] name = "asn1-rs-derive" version = "0.6.0" @@ -106,6 +178,17 @@ dependencies = [ "syn", ] +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -173,12 +256,39 @@ dependencies = [ "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 = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.11.1" @@ -194,18 +304,42 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + [[package]] name = "bumpalo" version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + [[package]] name = "cc" version = "1.2.60" @@ -216,6 +350,18 @@ dependencies = [ "shlex", ] +[[package]] +name = "ccm" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae3c82e4355234767756212c570e29833699ab63e6ffd161887314cc5b43847" +dependencies = [ + "aead", + "cipher", + "ctr", + "subtle", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -228,6 +374,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "clap" version = "4.6.1" @@ -274,6 +430,12 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "core-foundation" version = "0.10.1" @@ -299,6 +461,33 @@ dependencies = [ "libc", ] +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[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 0.6.4", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -306,22 +495,83 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "data-encoding" version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" +[[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 = "der-parser" +version = "9.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553" +dependencies = [ + "asn1-rs 0.6.2", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + [[package]] name = "der-parser" version = "10.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" dependencies = [ - "asn1-rs", + "asn1-rs 0.7.1", "displaydoc", "nom", "num-bigint", @@ -345,7 +595,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", + "const-oid", "crypto-common", + "subtle", ] [[package]] @@ -359,6 +611,47 @@ dependencies = [ "syn", ] +[[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", + "hkdf", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + [[package]] name = "errno" version = "0.3.14" @@ -369,12 +662,34 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "find-msvc-tools" version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -480,6 +795,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -504,11 +820,60 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi", + "r-efi 5.3.0", "wasip2", "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + [[package]] name = "heck" version = "0.5.0" @@ -521,6 +886,24 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "httlib-huffman" version = "0.3.4" @@ -695,6 +1078,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "idna" version = "1.1.0" @@ -716,6 +1105,54 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.0", + "serde", + "serde_core", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + +[[package]] +name = "interceptor" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5ab04c530fd82e414e40394cabe5f0ebfe30d119f10fe29d6e3561926af412e" +dependencies = [ + "async-trait", + "bytes", + "log", + "portable-atomic", + "rand 0.8.6", + "rtcp", + "rtp", + "thiserror 1.0.69", + "tokio", + "waitgroup", + "webrtc-srtp", + "webrtc-util", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -744,6 +1181,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libc" version = "0.2.185" @@ -756,6 +1199,15 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + [[package]] name = "log" version = "0.4.29" @@ -783,12 +1235,31 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + [[package]] name = "memchr" version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "memoffset" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5de893c32cde5f383baa4c04c5d6dbdd735cfd4a794b0debdb2bb1b421da5ff4" +dependencies = [ + "autocfg", +] + [[package]] name = "mime" version = "0.3.17" @@ -822,6 +1293,19 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "nix" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b" +dependencies = [ + "bitflags 1.3.2", + "cfg-if", + "libc", + "memoffset", + "pin-utils", +] + [[package]] name = "nom" version = "7.1.3" @@ -881,13 +1365,22 @@ version = "0.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8311fa8ab7a57759b4ff1f851a3048d9ef0effaa0130726426b742d26d8a88e7" +[[package]] +name = "oid-registry" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9" +dependencies = [ + "asn1-rs 0.6.2", +] + [[package]] name = "oid-registry" version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" dependencies = [ - "asn1-rs", + "asn1-rs 0.7.1", ] [[package]] @@ -902,12 +1395,65 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "openssl-probe" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[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 = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + [[package]] name = "pem" version = "3.0.6" @@ -918,6 +1464,15 @@ dependencies = [ "serde_core", ] +[[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" @@ -930,6 +1485,40 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + [[package]] name = "potential_utf" version = "0.1.5" @@ -954,6 +1543,25 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[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.106" @@ -977,7 +1585,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2 0.6.3", - "thiserror", + "thiserror 2.0.18", "tokio", "tracing", "web-time", @@ -992,13 +1600,13 @@ dependencies = [ "bytes", "getrandom 0.3.4", "lru-slab", - "rand", + "rand 0.9.4", "ring", "rustc-hash", "rustls", "rustls-pki-types", "slab", - "thiserror", + "thiserror 2.0.18", "tinyvec", "tracing", "web-time", @@ -1033,14 +1641,41 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + [[package]] name = "rand" version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ - "rand_chacha", - "rand_core", + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", ] [[package]] @@ -1050,7 +1685,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", ] [[package]] @@ -1072,6 +1716,7 @@ dependencies = [ "ring", "rustls-pki-types", "time", + "x509-parser 0.16.0", "yasna", ] @@ -1088,6 +1733,27 @@ dependencies = [ "yasna", ] +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.11.1", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + [[package]] name = "regex-automata" version = "0.4.14" @@ -1105,6 +1771,16 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + [[package]] name = "ring" version = "0.17.14" @@ -1119,12 +1795,47 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rtcp" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8306430fb118b7834bbee50e744dc34826eca1da2158657a3d6cbc70e24c2096" +dependencies = [ + "bytes", + "thiserror 1.0.69", + "webrtc-util", +] + +[[package]] +name = "rtp" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e68baca5b6cb4980678713f0d06ef3a432aa642baefcbfd0f4dd2ef9eb5ab550" +dependencies = [ + "bytes", + "memchr", + "portable-atomic", + "rand 0.8.6", + "serde", + "thiserror 1.0.69", + "webrtc-util", +] + [[package]] name = "rustc-hash" version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rusticata-macros" version = "4.1.0" @@ -1202,13 +1913,45 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sdp" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02a526161f474ae94b966ba622379d939a8fe46c930eebbadb73e339622599d5" +dependencies = [ + "rand 0.8.6", + "substring", + "thiserror 1.0.69", + "url", +] + +[[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 = "security-framework" version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags", + "bitflags 2.11.1", "core-foundation", "core-foundation-sys", "libc", @@ -1225,6 +1968,12 @@ dependencies = [ "libc", ] +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.228" @@ -1338,6 +2087,16 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + [[package]] name = "simdeck-server" version = "0.1.0" @@ -1355,12 +2114,13 @@ dependencies = [ "serde", "serde_json", "sha2", - "thiserror", + "thiserror 2.0.18", "tokio", "tokio-stream", "tower-http", "tracing", "tracing-subscriber", + "webrtc", "wtransport", ] @@ -1376,6 +2136,15 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "smol_str" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" +dependencies = [ + "serde", +] + [[package]] name = "socket2" version = "0.5.10" @@ -1396,6 +2165,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -1408,6 +2187,34 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "stun" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea256fb46a13f9204e9dee9982997b2c3097db175a9fddaa8350310d03c4d5a3" +dependencies = [ + "base64", + "crc", + "lazy_static", + "md-5", + "rand 0.8.6", + "ring", + "subtle", + "thiserror 1.0.69", + "tokio", + "url", + "webrtc-util", +] + +[[package]] +name = "substring" +version = "1.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42ee6433ecef213b2e72f587ef64a2f5943e7cd16fbd82dbe8bc07486c534c86" +dependencies = [ + "autocfg", +] + [[package]] name = "subtle" version = "2.6.1" @@ -1442,13 +2249,33 @@ dependencies = [ "syn", ] +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -1536,6 +2363,7 @@ dependencies = [ "bytes", "libc", "mio", + "parking_lot", "pin-project-lite", "signal-hook-registry", "socket2 0.6.3", @@ -1612,7 +2440,7 @@ version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ - "bitflags", + "bitflags 2.11.1", "bytes", "futures-core", "futures-util", @@ -1717,9 +2545,30 @@ dependencies = [ "http", "httparse", "log", - "rand", + "rand 0.9.4", "sha1", - "thiserror", + "thiserror 2.0.18", +] + +[[package]] +name = "turn" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0044fdae001dd8a1e247ea6289abf12f4fcea1331a2364da512f9cd680bbd8cb" +dependencies = [ + "async-trait", + "base64", + "futures", + "log", + "md-5", + "portable-atomic", + "rand 0.8.6", + "ring", + "stun", + "thiserror 1.0.69", + "tokio", + "tokio-util", + "webrtc-util", ] [[package]] @@ -1740,6 +2589,22 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + [[package]] name = "untrusted" version = "0.9.0" @@ -1770,6 +2635,17 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "valuable" version = "0.1.1" @@ -1782,6 +2658,15 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "waitgroup" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1f50000a783467e6c0200f9d10642f4bc424e39efc1b770203e88b488f79292" +dependencies = [ + "atomic-waker", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -1794,7 +2679,16 @@ version = "1.0.3+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", ] [[package]] @@ -1842,6 +2736,40 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.11.1", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + [[package]] name = "web-time" version = "1.1.0" @@ -1852,6 +2780,237 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webrtc" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30367074d9f18231d28a74fab0120856b2b665da108d71a12beab7185a36f97b" +dependencies = [ + "arc-swap", + "async-trait", + "bytes", + "cfg-if", + "hex", + "interceptor", + "lazy_static", + "log", + "portable-atomic", + "rand 0.8.6", + "rcgen 0.13.2", + "regex", + "ring", + "rtcp", + "rtp", + "rustls", + "sdp", + "serde", + "serde_json", + "sha2", + "smol_str", + "stun", + "thiserror 1.0.69", + "time", + "tokio", + "turn", + "url", + "waitgroup", + "webrtc-data", + "webrtc-dtls", + "webrtc-ice", + "webrtc-mdns", + "webrtc-media", + "webrtc-sctp", + "webrtc-srtp", + "webrtc-util", +] + +[[package]] +name = "webrtc-data" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec93b991efcd01b73c5b3503fa8adba159d069abe5785c988ebe14fcf8f05d1" +dependencies = [ + "bytes", + "log", + "portable-atomic", + "thiserror 1.0.69", + "tokio", + "webrtc-sctp", + "webrtc-util", +] + +[[package]] +name = "webrtc-dtls" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c9b89fc909f9da0499283b1112cd98f72fec28e55a54a9e352525ca65cd95c" +dependencies = [ + "aes", + "aes-gcm", + "async-trait", + "bincode", + "byteorder", + "cbc", + "ccm", + "der-parser 9.0.0", + "hkdf", + "hmac", + "log", + "p256", + "p384", + "portable-atomic", + "rand 0.8.6", + "rand_core 0.6.4", + "rcgen 0.13.2", + "ring", + "rustls", + "sec1", + "serde", + "sha1", + "sha2", + "subtle", + "thiserror 1.0.69", + "tokio", + "webrtc-util", + "x25519-dalek", + "x509-parser 0.16.0", +] + +[[package]] +name = "webrtc-ice" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348b28b593f7709ac98d872beb58c0009523df652c78e01b950ab9c537ff17d" +dependencies = [ + "arc-swap", + "async-trait", + "crc", + "log", + "portable-atomic", + "rand 0.8.6", + "serde", + "serde_json", + "stun", + "thiserror 1.0.69", + "tokio", + "turn", + "url", + "uuid", + "waitgroup", + "webrtc-mdns", + "webrtc-util", +] + +[[package]] +name = "webrtc-mdns" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6dfe9686c6c9c51428da4de415cb6ca2dc0591ce2b63212e23fd9cccf0e316b" +dependencies = [ + "log", + "socket2 0.5.10", + "thiserror 1.0.69", + "tokio", + "webrtc-util", +] + +[[package]] +name = "webrtc-media" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e153be16b8650021ad3e9e49ab6e5fa9fb7f6d1c23c213fd8bbd1a1135a4c704" +dependencies = [ + "byteorder", + "bytes", + "rand 0.8.6", + "rtp", + "thiserror 1.0.69", +] + +[[package]] +name = "webrtc-sctp" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5faf3846ec4b7e64b56338d62cbafe084aa79806b0379dff5cc74a8b7a2b3063" +dependencies = [ + "arc-swap", + "async-trait", + "bytes", + "crc", + "log", + "portable-atomic", + "rand 0.8.6", + "thiserror 1.0.69", + "tokio", + "webrtc-util", +] + +[[package]] +name = "webrtc-srtp" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "771db9993712a8fb3886d5be4613ebf27250ef422bd4071988bf55f1ed1a64fa" +dependencies = [ + "aead", + "aes", + "aes-gcm", + "byteorder", + "bytes", + "ctr", + "hmac", + "log", + "rtcp", + "rtp", + "sha1", + "subtle", + "thiserror 1.0.69", + "tokio", + "webrtc-util", +] + +[[package]] +name = "webrtc-util" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1438a8fd0d69c5775afb4a71470af92242dbd04059c61895163aa3c1ef933375" +dependencies = [ + "async-trait", + "bitflags 1.3.2", + "bytes", + "ipnet", + "lazy_static", + "libc", + "log", + "nix", + "portable-atomic", + "rand 0.8.6", + "thiserror 1.0.69", + "tokio", + "winapi", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-link" version = "0.2.1" @@ -2014,12 +3173,100 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + [[package]] name = "wit-bindgen" version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.11.1", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + [[package]] name = "writeable" version = "0.6.3" @@ -2041,7 +3288,7 @@ dependencies = [ "rustls-pki-types", "sha2", "socket2 0.5.10", - "thiserror", + "thiserror 2.0.18", "time", "tokio", "tracing", @@ -2058,24 +3305,54 @@ checksum = "1627c5b59450278e9771aab35275d72bfa2788128c197c5af1e4a820c8737ef4" dependencies = [ "httlib-huffman", "octets", - "thiserror", + "thiserror 2.0.18", "url", ] +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek", + "rand_core 0.6.4", + "serde", + "zeroize", +] + +[[package]] +name = "x509-parser" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69" +dependencies = [ + "asn1-rs 0.6.2", + "data-encoding", + "der-parser 9.0.0", + "lazy_static", + "nom", + "oid-registry 0.7.1", + "ring", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + [[package]] name = "x509-parser" version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4569f339c0c402346d4a75a9e39cf8dad310e287eef1ff56d4c68e5067f53460" dependencies = [ - "asn1-rs", + "asn1-rs 0.7.1", "data-encoding", - "der-parser", + "der-parser 10.0.0", "lazy_static", "nom", - "oid-registry", + "oid-registry 0.8.1", "rusticata-macros", - "thiserror", + "thiserror 2.0.18", "time", ] @@ -2085,15 +3362,15 @@ version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" dependencies = [ - "asn1-rs", + "asn1-rs 0.7.1", "data-encoding", - "der-parser", + "der-parser 10.0.0", "lazy_static", "nom", - "oid-registry", + "oid-registry 0.8.1", "ring", "rusticata-macros", - "thiserror", + "thiserror 2.0.18", "time", ] @@ -2175,6 +3452,20 @@ name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] name = "zerotrie" diff --git a/server/Cargo.toml b/server/Cargo.toml index 02ca4d9b..d84fd71d 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -23,6 +23,7 @@ tokio-stream = "0.1" tower-http = { version = "0.6", features = ["cors", "fs", "trace"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } +webrtc = "0.12" wtransport = "0.7" [build-dependencies] diff --git a/server/src/api/routes.rs b/server/src/api/routes.rs index 0779b69b..27d76732 100644 --- a/server/src/api/routes.rs +++ b/server/src/api/routes.rs @@ -354,6 +354,7 @@ pub fn router(state: AppState) -> Router { .route("/api/simulators/{udid}/batch", post(run_batch)) .route("/api/simulators/{udid}/touch", post(send_touch)) .route("/api/simulators/{udid}/control", get(control_socket)) + .route("/api/simulators/{udid}/webrtc/offer", post(webrtc_offer)) .route( "/api/simulators/{udid}/touch-sequence", post(send_touch_sequence), @@ -890,6 +891,16 @@ async fn control_socket( websocket.on_upgrade(move |socket| handle_control_socket(state, udid, socket)) } +async fn webrtc_offer( + State(state): State, + Path(udid): Path, + Json(payload): Json, +) -> Result, AppError> { + crate::transport::webrtc::create_answer(state, udid, payload) + .await + .map(Json) +} + async fn handle_control_socket(state: AppState, udid: String, socket: WebSocket) { let session = match state.registry.get_or_create_async(&udid).await { Ok(session) => session, diff --git a/server/src/auth.rs b/server/src/auth.rs index b733813d..afcb7cec 100644 --- a/server/src/auth.rs +++ b/server/src/auth.rs @@ -193,12 +193,27 @@ fn origin_is_allowed(config: &Config, origin: &str) -> bool { format!("http://[::1]:{}", config.http_port), ]; allowed.iter().any(|value| value == origin) + || extra_allowed_origins().any(|value| value == origin) } fn origin_is_cors_allowed(config: &Config, origin: &str) -> bool { origin == "null" || origin_is_allowed(config, origin) } +fn extra_allowed_origins() -> impl Iterator { + std::env::var("SIMDECK_ALLOWED_ORIGINS") + .ok() + .into_iter() + .flat_map(|value| { + value + .split(',') + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .collect::>() + }) +} + fn chrono_free_now_nanos() -> u128 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) diff --git a/server/src/error.rs b/server/src/error.rs index ad1716f5..e9fda2d2 100644 --- a/server/src/error.rs +++ b/server/src/error.rs @@ -41,7 +41,7 @@ impl AppError { impl IntoResponse for AppError { fn into_response(self) -> Response { - let status = match self { + let status = match &self { Self::BadRequest(_) => StatusCode::BAD_REQUEST, Self::NotFound(_) => StatusCode::NOT_FOUND, Self::Native(_) | Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR, diff --git a/server/src/transport/mod.rs b/server/src/transport/mod.rs index 5de8c12a..864e285b 100644 --- a/server/src/transport/mod.rs +++ b/server/src/transport/mod.rs @@ -1,2 +1,3 @@ pub mod packet; +pub mod webrtc; pub mod webtransport; diff --git a/server/src/transport/webrtc.rs b/server/src/transport/webrtc.rs new file mode 100644 index 00000000..0fe84d62 --- /dev/null +++ b/server/src/transport/webrtc.rs @@ -0,0 +1,255 @@ +use crate::api::routes::AppState; +use crate::error::AppError; +use bytes::Bytes; +use serde::{Deserialize, Serialize}; +use std::sync::atomic::Ordering; +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::broadcast; +use tracing::warn; +use webrtc::api::interceptor_registry::register_default_interceptors; +use webrtc::api::media_engine::{MediaEngine, MIME_TYPE_H264}; +use webrtc::api::APIBuilder; +use webrtc::ice_transport::ice_server::RTCIceServer; +use webrtc::interceptor::registry::Registry; +use webrtc::media::Sample; +use webrtc::peer_connection::configuration::RTCConfiguration; +use webrtc::peer_connection::sdp::session_description::RTCSessionDescription; +use webrtc::rtp_transceiver::rtp_codec::RTCRtpCodecCapability; +use webrtc::track::track_local::track_local_static_sample::TrackLocalStaticSample; +use webrtc::track::track_local::TrackLocal; + +const DEFAULT_STUN_URL: &str = "stun:stun.l.google.com:19302"; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WebRtcOfferPayload { + pub sdp: String, + #[serde(rename = "type")] + pub kind: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct WebRtcAnswerPayload { + pub sdp: String, + #[serde(rename = "type")] + pub kind: String, +} + +pub async fn create_answer( + state: AppState, + udid: String, + payload: WebRtcOfferPayload, +) -> Result { + if payload.kind != "offer" { + return Err(AppError::bad_request( + "WebRTC payload must include type `offer`.", + )); + } + + let session = state.registry.get_or_create_async(&udid).await?; + if let Err(error) = session.ensure_started_async().await { + state.registry.remove(&udid); + return Err(error); + } + session.request_refresh(); + + let first_frame = session + .wait_for_keyframe(Duration::from_secs(3)) + .await + .ok_or_else(|| AppError::native("Timed out waiting for a simulator keyframe."))?; + let codec = first_frame + .codec + .as_deref() + .unwrap_or_default() + .to_lowercase(); + if !codec.contains("h264") { + return Err(AppError::bad_request( + "WebRTC preview requires H.264. Restart SimDeck with `--video-codec h264-software`.", + )); + } + + let mut media_engine = MediaEngine::default(); + media_engine + .register_default_codecs() + .map_err(|error| AppError::internal(format!("register WebRTC codecs: {error}")))?; + let mut registry = Registry::new(); + registry = register_default_interceptors(registry, &mut media_engine) + .map_err(|error| AppError::internal(format!("register WebRTC interceptors: {error}")))?; + let api = APIBuilder::new() + .with_media_engine(media_engine) + .with_interceptor_registry(registry) + .build(); + + let peer_connection = Arc::new( + api.new_peer_connection(RTCConfiguration { + ice_servers: ice_servers(), + ..Default::default() + }) + .await + .map_err(|error| AppError::internal(format!("create WebRTC peer connection: {error}")))?, + ); + + let video_track = Arc::new(TrackLocalStaticSample::new( + RTCRtpCodecCapability { + mime_type: MIME_TYPE_H264.to_owned(), + clock_rate: 90_000, + channels: 0, + sdp_fmtp_line: "level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f" + .to_owned(), + rtcp_feedback: vec![], + }, + "simdeck-video".to_owned(), + "simdeck".to_owned(), + )); + + let rtp_sender = peer_connection + .add_track(video_track.clone() as Arc) + .await + .map_err(|error| AppError::internal(format!("add WebRTC video track: {error}")))?; + tokio::spawn(async move { + let mut buffer = vec![0u8; 1500]; + while rtp_sender.read(&mut buffer).await.is_ok() {} + }); + + let offer = RTCSessionDescription::offer(payload.sdp) + .map_err(|error| AppError::bad_request(format!("invalid WebRTC offer: {error}")))?; + peer_connection + .set_remote_description(offer) + .await + .map_err(|error| AppError::bad_request(format!("set remote WebRTC offer: {error}")))?; + + let answer = peer_connection + .create_answer(None) + .await + .map_err(|error| AppError::internal(format!("create WebRTC answer: {error}")))?; + let mut gather_complete = peer_connection.gathering_complete_promise().await; + peer_connection + .set_local_description(answer) + .await + .map_err(|error| AppError::internal(format!("set WebRTC answer: {error}")))?; + let _ = gather_complete.recv().await; + let local_description = peer_connection + .local_description() + .await + .ok_or_else(|| AppError::internal("WebRTC local description was not set."))?; + + tokio::spawn(stream_h264_frames( + state, + udid, + session, + first_frame, + peer_connection, + video_track, + )); + + Ok(WebRtcAnswerPayload { + sdp: local_description.sdp, + kind: "answer".to_owned(), + }) +} + +fn ice_servers() -> Vec { + let mut urls = std::env::var("SIMDECK_WEBRTC_ICE_SERVERS") + .ok() + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| DEFAULT_STUN_URL.to_owned()) + .split(',') + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .collect::>(); + if urls.is_empty() { + urls.push(DEFAULT_STUN_URL.to_owned()); + } + vec![RTCIceServer { + urls, + ..Default::default() + }] +} + +async fn stream_h264_frames( + state: AppState, + udid: String, + session: crate::simulators::session::SimulatorSession, + first_frame: crate::transport::packet::SharedFrame, + peer_connection: Arc, + video_track: Arc, +) { + let mut rx = session.subscribe(); + let _guard = WebRtcMetricsGuard::new(state.metrics.clone()); + if let Err(error) = write_frame_sample(&video_track, &first_frame).await { + warn!("failed to write initial WebRTC frame for {udid}: {error}"); + } + + loop { + let frame = match rx.recv().await { + Ok(frame) => frame, + Err(broadcast::error::RecvError::Lagged(skipped)) => { + state + .metrics + .frames_dropped_server + .fetch_add(skipped, Ordering::Relaxed); + session.request_refresh(); + continue; + } + Err(broadcast::error::RecvError::Closed) => break, + }; + if let Err(error) = write_frame_sample(&video_track, &frame).await { + warn!("WebRTC frame write failed for {udid}: {error}"); + break; + } + state.metrics.frames_sent.fetch_add(1, Ordering::Relaxed); + } + + let _ = peer_connection.close().await; +} + +async fn write_frame_sample( + video_track: &TrackLocalStaticSample, + frame: &crate::transport::packet::SharedFrame, +) -> anyhow::Result<()> { + let mut data = Vec::new(); + if frame.is_keyframe { + if let Some(description) = frame.description.as_ref() { + data.extend_from_slice(description.as_slice()); + } + } + data.extend_from_slice(frame.data.as_slice()); + video_track + .write_sample(&Sample { + data: Bytes::from(data), + duration: Duration::from_millis(16), + ..Default::default() + }) + .await?; + Ok(()) +} + +struct WebRtcMetricsGuard { + metrics: Arc, +} + +impl WebRtcMetricsGuard { + fn new(metrics: Arc) -> Self { + metrics + .subscribers_connected + .fetch_add(1, Ordering::Relaxed); + metrics.active_streams.fetch_add(1, Ordering::Relaxed); + Self { metrics } + } +} + +impl Drop for WebRtcMetricsGuard { + fn drop(&mut self) { + self.metrics + .subscribers_disconnected + .fetch_add(1, Ordering::Relaxed); + let _ = self.metrics.active_streams.fetch_update( + Ordering::Relaxed, + Ordering::Relaxed, + |current| Some(current.saturating_sub(1)), + ); + } +}