diff --git a/2026-04-16-cloudflare-driver.md b/2026-04-16-cloudflare-driver.md new file mode 100644 index 0000000..cb3631d --- /dev/null +++ b/2026-04-16-cloudflare-driver.md @@ -0,0 +1,535 @@ +# Plan: Cloudflare Driver For `hyper-browser-simulator` + +## Recommendation + +The generic backend seam already exists in the current codebase: + +- `ParticipantDriverSession` in `browser/src/participant/shared/runtime.rs` is the real backend contract. +- `FrontendAutomation` in `browser/src/participant/local/frontend.rs` is now local-Chromium-only. + +So the Cloudflare work should build on the current `ParticipantDriverSession` runtime instead of reviving the older concrete `FrontendDriver` idea. + +Recommended shape: + +- Keep the actual driver implementation in `client-simulator-browser`, under `browser/src/participant/cloudflare/`. +- Add one new workspace crate for the generated worker client, for example `cloudflare-worker-client/`. +- Do not put the driver implementation itself in a separate crate. That would create a dependency cycle because `client-simulator-browser` owns the `ParticipantDriverSession` trait and also needs to construct the driver. +- Reuse the existing Rust auth flow for Hyper Core. The driver should obtain or reuse the `hyper_session` cookie locally and pass the raw cookie value to the worker during session creation. +- Treat the worker's `sessionId` as a private implementation detail of the driver. Nothing above `CloudflareSession` should know about it. +- Cache authoritative state from worker responses and use a low-frequency worker state poll to implement `wait_for_termination()`. Note that the cloudflare Browser Rendering instance will terminate after its keep-alive timeout passes without inactivity. The driver and cloudflare worker needs to connect regularly to the instance to keep it alive. + +## Related Project + +The counterpart worker project lives at: + +- `/Users/robert/projects/shuttle/simulator/cloudflare-browser-simulator` + +The most relevant files in that repo for this plan are: + +- worker/API entrypoint: `/Users/robert/projects/shuttle/simulator/cloudflare-browser-simulator/worker/src/index.ts` +- worker routes and schemas: `/Users/robert/projects/shuttle/simulator/cloudflare-browser-simulator/worker/src/api/` +- current worker automation logic: `/Users/robert/projects/shuttle/simulator/cloudflare-browser-simulator/worker/src/api/logic.ts` +- current generated-client example: `/Users/robert/projects/shuttle/simulator/cloudflare-browser-simulator/cli/` +- counterpart implementation plan: `/Users/robert/projects/shuttle/simulator/cloudflare-browser-simulator/plans/2026-04-16_hyper-browser-simulator-support.md` + +## Current State + +What is already in place: + +- The shared runtime and driver contract are implemented. +- `LocalChromiumSession` is the production local backend. +- `RemoteStubSession` is a simulated remote backend. +- `browser/src/participant/cloudflare/mod.rs` is an empty placeholder. +- `ParticipantBackendKind` only supports `local` and `remote-stub`. +- The TUI already has backend selection. + +What is missing for a real Cloudflare backend: + +- a generated Rust client for the worker API in this repo +- a `CloudflareSession` implementation of `ParticipantDriverSession` +- config for the worker base URL and request behavior +- spawn/store wiring for the new backend +- tests against a mock worker + +The cloudflare-browser-simulator repo already has a worker implementation following `/Users/robert/projects/shuttle/simulator/cloudflare-browser-simulator/plans/2026-04-16_hyper-browser-simulator-support.md`. You can run a local worker using the justfile commands at `/Users/robert/projects/shuttle/simulator/cloudflare-browser-simulator/justfile`. If the worker API is not sufficient or could be made more ergonomic for the driver, you can modify its implementation and OpenAPI spec as needed. + +## Design Decisions + +### Use a generated client crate, not hand-written HTTP + +The worker repo already treats its OpenAPI document as canonical. Mirror that pattern here. + +Recommended crate layout: + +- `cloudflare-worker-client/` + - `build.rs` + - `openapi/cloudflare-browser-simulator.json` + - `src/generated.rs` + - `src/lib.rs` + - `src/client.rs` + +Responsibilities: + +- own the committed OpenAPI copy used by this repo +- generate the Rust client with `progenitor` +- expose a thin ergonomic wrapper around the generated client +- keep `reqwest` transport, timeout, base URL, and error formatting out of `browser/` + +### Keep the Cloudflare driver in `browser/` + +Recommended files: + +- `browser/src/participant/cloudflare/mod.rs` +- `browser/src/participant/cloudflare/session.rs` +- `browser/src/participant/cloudflare/config.rs` +- `browser/src/participant/cloudflare/mapping.rs` + +Responsibilities: + +- map `ParticipantLaunchSpec` to worker create-session requests +- map `ParticipantMessage` to worker command requests +- translate worker state into `ParticipantState` +- forward worker log entries into `ParticipantLogMessage` +- own `sessionId`, cached state, and termination polling + +### Reuse the existing auth stack + +For Hyper Core: + +- use `HyperSessionCookieManger` exactly as the local backend does +- if a cookie is already available, reuse it +- otherwise fetch one with `fetch_new_cookie(base_url, username)` +- send only the cookie value to the worker + +The worker should set that cookie into the browser context before navigation. Do not duplicate the guest-auth flow inside the worker. + +### Keep backend-specific limitations at the driver boundary + +Cloudflare differs from local Chromium in a few important ways: + +- always headless +- no local user data dir +- no local fake-media files from the Rust host +- WebRTC-only in practice + +The driver should absorb those differences by: + +- logging when a setting is ignored or normalized +- exposing the actual applied state from the worker +- not changing the `Participant`, TUI command surface, or shared runtime just to model Cloudflare internals + +## Scope + +In scope for this implementation: + +- new `cloudflare` backend kind +- Hyper Core and Hyper Lite support through the worker +- full `ParticipantMessage` coverage +- accurate `ParticipantState` refreshes +- proper close and unexpected-termination handling +- generated OpenAPI client in this repo + +Explicit non-goals for v1: + +- headed Cloudflare sessions +- remote support for local fake-media files or URLs +- sharing DOM selector code across Rust and TypeScript through a new abstraction layer +- exposing worker-specific identifiers outside the driver + +## Progress Tracker + +Overall status: `phase 6 automated validation added; explicit Cloudflare keep-alive polling implemented; manual smoke validation pending` + +Cross-repo dependency: + +- This plan depends on the worker contract described in `/Users/robert/projects/shuttle/simulator/cloudflare-browser-simulator/plans/2026-04-16_hyper-browser-simulator-support.md`. + +Milestones: + +- [x] Phase 1: Freeze the worker contract and add the generated-client crate +- [x] Phase 2: Add Cloudflare backend config and spawn wiring +- [x] Phase 3: Implement `CloudflareSession` start and close +- [x] Phase 4: Implement command handling, cached state, and termination polling +- [x] Phase 5: Add TUI and UX handling for backend-specific limitations +- [ ] Phase 6: Validate with unit, integration, and manual tests + +Latest implementation note: + +- the Cloudflare driver now sends explicit `POST /sessions/{sessionId}/keep-alive` requests on its background poll loop and clamps that loop to a safe interval within the configured Browser Rendering inactivity window +- the worker-side `sessionTimeoutMs` is now treated as the Browser Rendering `keep_alive` inactivity timeout instead of a hard session lifetime, so active sessions can outlive 10 minutes when the simulator keeps sending commands + +## Detailed Plan + +### Phase 1: Freeze the worker contract and add the generated-client crate + +Goal: + +- make the worker API a typed dependency of this repo +- avoid hand-written request and response structs + +Changes: + +- add a new workspace member, for example `cloudflare-worker-client` +- add `progenitor`, `progenitor-client`, `openapiv3`, `prettyplease`, `syn`, and the runtime `reqwest` pieces needed for generated code +- copy the canonical worker spec into this repo under the new crate +- add `build.rs` to generate the client from the committed spec +- expose a small wrapper API for: + - constructing a client from base URL and timeouts + - formatting API errors into `eyre::Report` + - convenient methods for create, command, state, and close calls + +TDD steps: + +- [x] add a failing test for client construction and base URL normalization +- [x] add a failing test for worker error translation into actionable Rust errors +- [x] implement the wrapper until those tests pass + +Implemented in this phase: + +- added the `cloudflare-worker-client` workspace crate +- copied the canonical worker OpenAPI spec into `cloudflare-worker-client/openapi/cloudflare-browser-simulator.json` +- added `build.rs`-driven client generation with `progenitor` +- exposed a thin wrapper with base URL normalization, typed session helpers, and `eyre` error formatting +- added focused unit tests covering base URL normalization and actionable worker error translation + +Completion criteria: + +- `client-simulator-browser` can depend on the generated client crate without any direct OpenAPI or `reqwest` boilerplate +- the worker contract is represented by generated Rust types in this repo + +### Phase 2: Add Cloudflare backend config and spawn wiring + +Goal: + +- make Cloudflare a first-class backend selection + +Recommended config shape: + +```yaml +backend: cloudflare +cloudflare: + base_url: https://cloudflare-browser-simulator.hyper-video.workers.dev + request_timeout_seconds: 30 + session_timeout_ms: 600000 + navigation_timeout_ms: 45000 + selector_timeout_ms: 20000 + debug: false + health_poll_interval_ms: 5000 +``` + +Recommended code changes: + +- extend `ParticipantBackendKind` in `config/src/client_config.rs` with `Cloudflare` +- add a `CloudflareConfig` struct in `config/` +- update `config/src/lib.rs` and `config/src/default-config.yaml` +- update the TUI backend picker to include `cloudflare` +- replace separate `spawn_local()` and `spawn_remote_stub()` calls from the TUI with one backend-dispatching store method to stay DRY +- add `ParticipantStore::spawn(config)` or equivalent central dispatch +- add `Participant::spawn_cloudflare(...)` or equivalent internal constructor + +Recommended simplification: + +- do not add a large new TUI editor for every Cloudflare field in the first patch +- keep backend selection in the TUI +- keep advanced Cloudflare settings configurable through YAML first + +TDD steps: + +- [x] add failing config parsing tests for `backend: cloudflare` and the nested `cloudflare` block +- [x] add a failing store test proving backend dispatch reaches the Cloudflare constructor +- [x] implement the config and dispatch changes until the tests pass + +Implemented in this phase: + +- extended `ParticipantBackendKind` with `cloudflare` +- added `CloudflareConfig` in `config/` and threaded it through `Config` plus the default YAML +- updated config parsing tests to cover `backend: cloudflare` and the nested `cloudflare` block +- replaced the TUI's direct local vs remote-stub branching with `ParticipantStore::spawn(config)` backend dispatch +- added `Participant::spawn(config, ...)` and `Participant::spawn_cloudflare(...)` dispatch wiring +- added a minimal `CloudflareSession` placeholder so the Cloudflare backend path can be constructed and routed without implementing the real driver yet +- added a browser-store test proving Cloudflare backend dispatch reaches the Cloudflare constructor + +Notes: + +- the Cloudflare backend now parses and dispatches correctly, but the placeholder session still returns an explicit "not implemented yet" start error until Phase 3 + +Completion criteria: + +- the user can select `cloudflare` as a backend +- the simulator can construct a Cloudflare-backed participant session from config + +### Phase 3: Implement `CloudflareSession` start and close + +Goal: + +- create a real remote participant backend with correct lifecycle semantics + +Recommended `CloudflareSession` fields: + +- `launch_spec: ParticipantLaunchSpec` +- `cloudflare_config: CloudflareConfig` +- `log_sender: UnboundedSender` +- `session_id: Option` +- `cached_state: ParticipantState` +- auth dependencies needed to lazily obtain a cookie for Hyper Core + +`start()` should: + +- prepare the create-session request from `ParticipantLaunchSpec` +- fetch or reuse a `hyper_session` cookie for Hyper Core if needed +- call the worker create endpoint +- store `sessionId` +- initialize `cached_state` from the worker response +- forward worker log entries into the participant log channel + +`close()` should: + +- call the worker close endpoint if a session exists +- clear local session state +- be idempotent + +TDD steps: + +- [x] add a failing integration test against a mock worker for successful start +- [x] add a failing integration test for close-after-start +- [x] add a failing test for Hyper Core cookie injection into the create request +- [x] implement `start()` and `close()` until those tests pass + +Implemented in this phase: + +- added `cloudflare-worker-client` as a browser-crate dependency and used it from the Cloudflare driver lifecycle +- replaced the placeholder `CloudflareSession` start failure with real worker `create_session` and `close_session` calls +- reused an already-borrowed Hyper Core cookie when available and otherwise fetched one locally through `HyperSessionCookieManger` before sending only the raw cookie value to the worker +- mapped `ParticipantLaunchSpec` settings into the worker create request and mapped worker participant state back into Rust `ParticipantState` +- cached the initial worker state locally so the shared runtime can publish accurate state immediately after startup +- made `close()` idempotent when no worker session exists +- added a mock-HTTP lifecycle test covering guest-cookie fetch, worker create payload, initial state caching, and worker close dispatch + +Notes: + +- `handle_command()` still returns an explicit "not implemented yet" error for Cloudflare commands +- `wait_for_termination()` is still pending and will be implemented with worker state polling in Phase 4 + +Completion criteria: + +- a Cloudflare participant can be started and closed through the shared runtime +- no Cloudflare identifiers leak above the driver + +### Phase 4: Implement command handling, cached state, and termination polling + +Goal: + +- reach full runtime compatibility with the command/state contract + +Implementation notes: + +- map every `ParticipantMessage` variant to the worker command API +- update `cached_state` from worker command responses +- make `refresh_state()` return the cached authoritative state from the last worker response +- reserve explicit worker state calls for: + - termination polling + - recovery or debugging paths + +Recommended command semantics: + +- `Join` -> worker join command +- `Leave` -> worker leave command, keep the backend session alive +- `ToggleAudio` -> worker toggle audio command +- `ToggleVideo` -> worker toggle video command +- `ToggleScreenshare` -> worker toggle screenshare command +- `SetNoiseSuppression` -> worker set noise suppression command +- `SetWebcamResolutions` -> worker set webcam resolution command +- `ToggleBackgroundBlur` -> worker toggle blur command + +Termination handling: + +- spawn a background polling task after `start()` +- hit the worker state endpoint at a low frequency +- if the worker reports the session missing, closed, or failed, send a `DriverTermination` +- stop the poll cleanly during intentional close + +TDD steps: + +- [x] add one failing test per command mapping +- [x] add a failing test proving `refresh_state()` reflects command responses without extra network calls +- [x] add a failing test for unexpected termination on worker `404` or equivalent closed-session signal +- [x] implement command handling and polling until the tests pass + +Implemented in this phase: + +- mapped the full `ParticipantMessage` surface onto the worker command API and updated the Cloudflare cached state from each command response +- changed `refresh_state()` to return the in-memory cached worker state instead of making extra network calls +- added a background worker-state poller that refreshes cached state during health checks and reports unexpected worker-side session loss through `wait_for_termination()` +- stopped the poller cleanly during intentional close so the runtime can distinguish expected shutdown from backend loss +- expanded the Cloudflare driver tests to cover command payload mapping, cache-only refresh behavior, and termination reporting on worker-state failures + +Notes: + +- the driver now reaches command/state parity with the shared runtime contract +- Phase 5 is still the next unimplemented slice and will focus on backend-specific UX and limitation handling + +Completion criteria: + +- `CloudflareSession` supports the full `ParticipantMessage` surface +- runtime state remains accurate after start and every command +- unexpected worker-side session loss reaches `wait_for_termination()` + +### Phase 5: Add TUI and UX handling for backend-specific limitations + +Goal: + +- keep the UI understandable without overcomplicating it + +Recommended behavior: + +- allow all existing participant controls to remain visible +- when the backend cannot honor a local-only setting, surface that through logs and resulting state +- keep the TUI layout stable in the first iteration + +Specific decisions to encode: + +- `headless` is ignored for Cloudflare because the backend is always headless +- local fake-media file and URL selections are ignored for Cloudflare +- transport should normalize to `WebRTC`; if the user configured `WebTransport`, log the normalization and reflect `WebRTC` in state + +Optional follow-up, not required for the first implementation: + +- annotate unsupported fields in the TUI when `backend == cloudflare` + +TDD steps: + +- [x] add a failing test for transport normalization +- [x] add a failing test for ignored fake-media settings producing a log entry +- [x] implement the minimal UX behavior needed for those tests + +Implemented in this phase: + +- added Cloudflare-only launch-option handling so the driver can reason about headless and fake-media selections without introducing TUI-specific branching into the shared runtime +- normalized Cloudflare create-session requests to `WebRTC` when the user configured `WebTransport`, and emitted a warning log explaining the normalization +- added warning logs when the Cloudflare backend is asked to honor `headless: false` or a local fake-media file/URL selection that the worker cannot use +- kept the existing TUI layout and controls unchanged so backend-specific behavior stays visible through logs and resulting participant state instead of extra UI forks +- expanded the Cloudflare driver tests to cover transport normalization plus ignored-setting log emission + +Notes: + +- the simulator still exposes the existing controls, but Cloudflare-specific limitations are now surfaced predictably at session start +- Phase 6 remains the next open slice and is limited to broader automated and manual validation + +Completion criteria: + +- backend-specific behavior is visible and predictable +- the UI does not need backend-specific branching for every participant command + +### Phase 6: Validate with unit, integration, and manual tests + +Goal: + +- verify the backend works without a real Cloudflare dependency in automated tests + +Automated tests: + +- add unit tests close to the Cloudflare mapping and config code +- add integration tests in `browser/tests/` with a mock HTTP server such as `wiremock` +- exercise the shared runtime with a real `CloudflareSession` against mocked worker responses + +Suggested automated scenarios: + +- start success for Hyper Core +- start success for Hyper Lite +- command success for every `ParticipantMessage` variant +- command failure logging without crashing the runtime +- close after leave +- unexpected termination detection +- config parsing and backend dispatch + +Manual validation: + +- `just test` +- `just clippy` +- run the worker locally and point the simulator at it through the new Cloudflare config block +- join a Hyper Core room +- join a Hyper Lite room +- exercise audio, video, screenshare, noise suppression, resolution, blur, leave, and close + +Implemented in this phase: + +- added `browser/tests/cloudflare_driver.rs` to exercise the Cloudflare backend through the public `Participant` runtime instead of the driver internals +- covered a Hyper Lite end-to-end flow where mocked worker responses drive the shared participant state through start, join, audio toggle, video toggle, and close +- covered a command-failure path to prove the shared runtime keeps the participant alive after a worker command error and can still close cleanly +- covered unexpected termination handling by letting the worker state poll fail and asserting the public participant state transitions to stopped +- covered the Hyper Core auth path through the public backend spawn flow, including guest-cookie fetch, `/api/v1/auth/me/name`, and propagation of the fetched `hyper_session` cookie into the worker create request + +Notes: + +- existing unit coverage in `browser/src/participant/cloudflare/mod.rs` and config/runtime tests remain in place; this phase adds the missing browser-level integration layer on top +- manual smoke validation against a real local worker has not been executed yet, so this phase is not marked complete + +Completion criteria: + +- automated tests cover the driver contract +- manual smoke tests pass against a real worker + +## Recommended File Changes + +Rust workspace: + +- `Cargo.toml` +- `browser/Cargo.toml` +- `config/src/client_config.rs` +- `config/src/lib.rs` +- `config/src/default-config.yaml` +- `browser/src/participant/mod.rs` +- `browser/src/participant/shared/store.rs` +- `browser/src/participant/cloudflare/mod.rs` +- `tui/src/tui/components/browser_start.rs` + +New files and directories: + +- `cloudflare-worker-client/Cargo.toml` +- `cloudflare-worker-client/build.rs` +- `cloudflare-worker-client/openapi/cloudflare-browser-simulator.json` +- `cloudflare-worker-client/src/generated.rs` +- `cloudflare-worker-client/src/lib.rs` +- `cloudflare-worker-client/src/client.rs` +- `browser/tests/cloudflare_driver.rs` + +## Risks And Mitigations + +### Risk: a separate driver crate causes a dependency cycle + +Mitigation: + +- keep the generated client in a new crate +- keep the actual `CloudflareSession` in `client-simulator-browser` + +### Risk: double round-trips for every command + +Mitigation: + +- use create and command responses as authoritative state updates +- do not immediately re-fetch state after every command just because the runtime has a `refresh_state()` hook + +### Risk: Cloudflare session lifetime is shorter than local Chromium sessions + +Mitigation: + +- surface the configured keep-alive limits clearly in config and logs +- rely on the termination poller so the runtime reflects worker-side expiry promptly + +### Risk: fake-media parity becomes a scope trap + +Mitigation: + +- explicitly ship v1 with synthetic worker media only +- keep local fake-media handling local-only + +## Recommended First Patch Set + +The first implementation PR in this repo should do only this: + +1. add the generated-client crate +2. add Cloudflare config and backend selection +3. add the `CloudflareSession` skeleton with start and close only +4. add mock-worker integration tests for start and close + +That gives a reviewable base. Command parity and polish can land immediately after. diff --git a/Cargo.lock b/Cargo.lock index 421d176..e1a0bc3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -143,6 +143,28 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "aws-lc-rs" +version = "1.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec6fb3fe69024a75fa7e1bfb48aa6cf59706a101658ea01bfd33b2b248a038f" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f50037ee5e1e41e7b8f9d161680a725bd1626cb6f8c7e901f91f942850852fe7" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + [[package]] name = "backtrace" version = "0.3.76" @@ -215,9 +237,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.10.1" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" dependencies = [ "serde", ] @@ -244,15 +266,29 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e1354349954c6fc9cb0deab020f27f783cf0b604e8bb754dc4658ecf0d29c35f" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + [[package]] name = "cfg-if" version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "chromiumoxide" version = "0.7.0" @@ -269,7 +305,7 @@ dependencies = [ "futures", "futures-timer", "pin-project-lite", - "reqwest", + "reqwest 0.12.23", "serde", "serde_json", "thiserror 1.0.69", @@ -399,11 +435,12 @@ dependencies = [ "chromiumoxide", "chrono", "client-simulator-config", + "cloudflare-worker-client", "derive_more", "eyre", "futures", "maybe-backoff", - "reqwest", + "reqwest 0.12.23", "serde", "serde_json", "strum 0.27.2", @@ -425,7 +462,7 @@ dependencies = [ "eyre", "lazy_static", "random_name_generator", - "reqwest", + "reqwest 0.12.23", "serde", "serde_yml", "sha1", @@ -466,6 +503,33 @@ dependencies = [ "url", ] +[[package]] +name = "cloudflare-worker-client" +version = "0.1.0" +dependencies = [ + "chrono", + "eyre", + "openapiv3", + "prettyplease", + "progenitor", + "progenitor-client", + "reqwest 0.13.2", + "serde", + "serde_json", + "syn", + "tokio", + "url", +] + +[[package]] +name = "cmake" +version = "0.1.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7caa3f9de89ddbe2c607f4101924c5abec803763ae9534e4f4d7d8f84aa81f0" +dependencies = [ + "cc", +] + [[package]] name = "color-eyre" version = "0.6.5" @@ -499,6 +563,16 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + [[package]] name = "compact_str" version = "0.8.1" @@ -588,6 +662,16 @@ dependencies = [ "libc", ] +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -774,6 +858,12 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + [[package]] name = "either" version = "1.15.0" @@ -861,6 +951,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "foreign-types" version = "0.3.2" @@ -885,6 +981,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "futures" version = "0.3.31" @@ -912,9 +1014,9 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" [[package]] name = "futures-executor" @@ -997,8 +1099,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi 0.11.1+wasi-snapshot-preview1", + "wasm-bindgen", ] [[package]] @@ -1138,7 +1242,7 @@ checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.1.5", ] [[package]] @@ -1146,6 +1250,17 @@ name = "hashbrown" version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" [[package]] name = "hashlink" @@ -1179,12 +1294,11 @@ dependencies = [ [[package]] name = "http" -version = "1.3.1" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" dependencies = [ "bytes", - "fnv", "itoa", ] @@ -1458,12 +1572,14 @@ checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5" [[package]] name = "indexmap" -version = "2.11.4" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.16.0", + "hashbrown 0.17.0", + "serde", + "serde_core", ] [[package]] @@ -1533,6 +1649,60 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.3", + "libc", +] + [[package]] name = "joinery" version = "2.1.0" @@ -1541,10 +1711,12 @@ checksum = "72167d68f5fce3b8655487b8038691a3c9984ee769590f93f2a631f4ad64e4f5" [[package]] name = "js-sys" -version = "0.3.81" +version = "0.3.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec48937a97411dcb524a265206ccd4c90bb711fca92b2792c407f268825b9305" +checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" dependencies = [ + "cfg-if", + "futures-util", "once_cell", "wasm-bindgen", ] @@ -1617,9 +1789,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.28" +version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "lru" @@ -1630,6 +1802,12 @@ dependencies = [ "hashbrown 0.15.5", ] +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "matchers" version = "0.2.0" @@ -1693,10 +1871,10 @@ dependencies = [ "libc", "log", "openssl", - "openssl-probe", + "openssl-probe 0.1.6", "openssl-sys", "schannel", - "security-framework", + "security-framework 2.11.1", "security-framework-sys", "tempfile", ] @@ -1712,9 +1890,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.1.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" [[package]] name = "num-traits" @@ -1746,6 +1924,17 @@ version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" +[[package]] +name = "openapiv3" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c8d427828b22ae1fff2833a03d8486c2c881367f1c336349f307f321e7f4d05" +dependencies = [ + "indexmap", + "serde", + "serde_json", +] + [[package]] name = "openssl" version = "0.10.73" @@ -1778,6 +1967,12 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "openssl-sys" version = "0.9.109" @@ -1931,6 +2126,16 @@ dependencies = [ "yansi", ] +[[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 = "proc-macro-crate" version = "1.3.1" @@ -1943,13 +2148,79 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.101" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] +[[package]] +name = "progenitor" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d36315275b213c64c68dff684477ea7118a0f630832f737b550796a368f9962c" +dependencies = [ + "progenitor-client", + "progenitor-impl", + "progenitor-macro", +] + +[[package]] +name = "progenitor-client" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3999c302f5f2a42b7ca1cc39ad9e612c74cf2910ef6e58f869e45f3068b9659f" +dependencies = [ + "bytes", + "futures-core", + "percent-encoding", + "reqwest 0.13.2", + "serde", + "serde_json", + "serde_urlencoded", +] + +[[package]] +name = "progenitor-impl" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de362a0477182f45accdbad4d43cd89a95a1db0a518a7c1ddf3e525e6896f0f0" +dependencies = [ + "heck 0.5.0", + "http", + "indexmap", + "openapiv3", + "proc-macro2", + "quote", + "regex", + "schemars", + "serde", + "serde_json", + "syn", + "thiserror 2.0.18", + "typify", + "unicode-ident", +] + +[[package]] +name = "progenitor-macro" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c98aeaaab266bf848a602c78e039e7d62c80ba36303ae4092ec65f17e7fd0eaa" +dependencies = [ + "openapiv3", + "proc-macro2", + "progenitor-impl", + "quote", + "schemars", + "serde", + "serde_json", + "serde_tokenstream", + "serde_yaml", + "syn", +] + [[package]] name = "psl-types" version = "2.0.11" @@ -1975,11 +2246,67 @@ dependencies = [ "memchr", ] +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.3.3", + "lru-slab", + "rand 0.9.2", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.59.0", +] + [[package]] name = "quote" -version = "1.0.41" +version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" dependencies = [ "proc-macro2", ] @@ -2105,14 +2432,14 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.16", "libredox", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] name = "regex" -version = "1.11.3" +version = "1.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b5288124840bee7b386bc413c487869b360b2b4ec421ea56425128692f2a82c" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" dependencies = [ "aho-corasick", "memchr", @@ -2122,9 +2449,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.11" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "833eb9ce86d40ef33cb1306d8accf7bc8ec2bfea4355cbdebb3df68b40925cad" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" dependencies = [ "aho-corasick", "memchr", @@ -2137,6 +2464,16 @@ version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "caf4aa5b0f434c91fe5c7f1ecb6a5ece2130b02ad2a590589dda5146df959001" +[[package]] +name = "regress" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2057b2325e68a893284d1538021ab90279adac1139957ca2a74426c6f118fb48" +dependencies = [ + "hashbrown 0.16.0", + "memchr", +] + [[package]] name = "reqwest" version = "0.12.23" @@ -2181,6 +2518,47 @@ dependencies = [ "web-sys", ] +[[package]] +name = "reqwest" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" +dependencies = [ + "base64", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + [[package]] name = "ring" version = "0.17.14" @@ -2235,6 +2613,12 @@ version = "0.1.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + [[package]] name = "rustix" version = "0.38.44" @@ -2267,6 +2651,7 @@ version = "0.23.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd3c25631629d034ce7cd9940adc9d45762d46de2b0f57193c4443b92c6d4d40" dependencies = [ + "aws-lc-rs", "once_cell", "rustls-pki-types", "rustls-webpki", @@ -2274,21 +2659,62 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe 0.2.1", + "rustls-pki-types", + "schannel", + "security-framework 3.5.1", +] + [[package]] name = "rustls-pki-types" version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79" dependencies = [ + "web-time", "zeroize", ] +[[package]] +name = "rustls-platform-verifier" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework 3.5.1", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + [[package]] name = "rustls-webpki" -version = "0.103.6" +version = "0.103.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8572f3c2cb9934231157b45499fc41e1f58c589fdfb81a844ba873265e80f8eb" +checksum = "8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06" dependencies = [ + "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -2324,6 +2750,32 @@ dependencies = [ "windows-sys 0.61.1", ] +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "chrono", + "dyn-clone", + "schemars_derive", + "serde", + "serde_json", + "uuid", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -2337,7 +2789,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ "bitflags", - "core-foundation", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" +dependencies = [ + "bitflags", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -2353,6 +2818,16 @@ dependencies = [ "libc", ] +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + [[package]] name = "serde" version = "1.0.228" @@ -2383,17 +2858,28 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "serde_json" -version = "1.0.145" +version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ "itoa", "memchr", - "ryu", "serde", "serde_core", + "zmij", ] [[package]] @@ -2405,6 +2891,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_tokenstream" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c49585c52c01f13c5c2ebb333f14f6885d76daa768d8a037d28017ec538c69" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -2417,6 +2915,19 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "serde_yml" version = "0.0.12" @@ -2599,9 +3110,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "2.0.106" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", @@ -2635,7 +3146,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" dependencies = [ "bitflags", - "core-foundation", + "core-foundation 0.9.4", "system-configuration-sys", ] @@ -2679,11 +3190,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.17" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl 2.0.17", + "thiserror-impl 2.0.18", ] [[package]] @@ -2699,9 +3210,9 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.17" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", @@ -2719,30 +3230,30 @@ dependencies = [ [[package]] name = "time" -version = "0.3.44" +version = "0.3.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "itoa", "num-conv", "powerfmt", - "serde", + "serde_core", "time-core", "time-macros", ] [[package]] name = "time-core" -version = "0.1.6" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" [[package]] name = "time-macros" -version = "0.2.24" +version = "0.2.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" dependencies = [ "num-conv", "time-core", @@ -2758,6 +3269,21 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "titlecase" version = "2.2.1" @@ -2893,9 +3419,9 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.6" +version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ "bitflags", "bytes", @@ -3051,11 +3577,58 @@ version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" +[[package]] +name = "typify" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b715573a376585888b742ead9be5f4826105e622169180662e2c81bed4a149c3" +dependencies = [ + "typify-impl", + "typify-macro", +] + +[[package]] +name = "typify-impl" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2fd0d27608a466d063d23b97cf2d26c25d838f01b4f7d5ff406a7446f16b6e3" +dependencies = [ + "heck 0.5.0", + "log", + "proc-macro2", + "quote", + "regress", + "schemars", + "semver", + "serde", + "serde_json", + "syn", + "thiserror 2.0.18", + "unicode-ident", +] + +[[package]] +name = "typify-macro" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd04bb1207cd4e250941cc1641f4c4815f7eaa2145f45c09dd49cb0a3691710a" +dependencies = [ + "proc-macro2", + "quote", + "schemars", + "semver", + "serde", + "serde_json", + "serde_tokenstream", + "syn", + "typify-impl", +] + [[package]] name = "unicode-ident" -version = "1.0.19" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-segmentation" @@ -3092,6 +3665,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "untrusted" version = "0.9.0" @@ -3209,9 +3788,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.104" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1da10c01ae9f1ae40cbfac0bac3b1e724b320abfcf52229f80b547c0d250e2d" +checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" dependencies = [ "cfg-if", "once_cell", @@ -3220,38 +3799,21 @@ dependencies = [ "wasm-bindgen-shared", ] -[[package]] -name = "wasm-bindgen-backend" -version = "0.2.104" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "671c9a5a66f49d8a47345ab942e2cb93c7d1d0339065d4f8139c486121b43b19" -dependencies = [ - "bumpalo", - "log", - "proc-macro2", - "quote", - "syn", - "wasm-bindgen-shared", -] - [[package]] name = "wasm-bindgen-futures" -version = "0.4.54" +version = "0.4.68" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e038d41e478cc73bae0ff9b36c60cff1c98b8f38f8d7e8061e79ee63608ac5c" +checksum = "f371d383f2fb139252e0bfac3b81b265689bf45b6874af544ffa4c975ac1ebf8" dependencies = [ - "cfg-if", "js-sys", - "once_cell", "wasm-bindgen", - "web-sys", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.104" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ca60477e4c59f5f2986c50191cd972e3a50d8a95603bc9434501cf156a9a119" +checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -3259,31 +3821,44 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.104" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f07d2f20d4da7b26400c9f4a0511e6e0345b040694e8a75bd41d578fa4421d7" +checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" dependencies = [ + "bumpalo", "proc-macro2", "quote", "syn", - "wasm-bindgen-backend", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.104" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bad67dc8b2a1a6e5448428adec4c3e84c43e561d8c9ee8a9e5aabeb193ec41d1" +checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "web-sys" -version = "0.3.81" +version = "0.3.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9367c417a924a74cae129e6a2ae3b47fabb1f8995595ab474029da749a8be120" +checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d" dependencies = [ "js-sys", "wasm-bindgen", @@ -3299,6 +3874,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "which" version = "6.0.3" @@ -3447,6 +4031,15 @@ dependencies = [ "windows-link 0.2.0", ] +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + [[package]] name = "windows-sys" version = "0.48.0" @@ -3492,6 +4085,21 @@ dependencies = [ "windows-link 0.2.0", ] +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + [[package]] name = "windows-targets" version = "0.48.5" @@ -3540,6 +4148,12 @@ dependencies = [ "windows_x86_64_msvc 0.53.0", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.48.5" @@ -3558,6 +4172,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + [[package]] name = "windows_aarch64_msvc" version = "0.48.5" @@ -3576,6 +4196,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + [[package]] name = "windows_i686_gnu" version = "0.48.5" @@ -3606,6 +4232,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + [[package]] name = "windows_i686_msvc" version = "0.48.5" @@ -3624,6 +4256,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + [[package]] name = "windows_x86_64_gnu" version = "0.48.5" @@ -3642,6 +4280,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + [[package]] name = "windows_x86_64_gnullvm" version = "0.48.5" @@ -3660,6 +4304,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + [[package]] name = "windows_x86_64_msvc" version = "0.48.5" @@ -3844,3 +4494,9 @@ dependencies = [ "quote", "syn", ] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml index 4734c0d..afabef3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,7 @@ resolver = "2" members = [ ".", "browser", + "cloudflare-worker-client", "config", "tui", ] @@ -55,6 +56,7 @@ which = "8.0.0" client-simulator-browser = { path = "./browser" } client-simulator-config = { path = "./config" } client-simulator-tui = { path = "./tui" } +cloudflare-worker-client = { path = "./cloudflare-worker-client" } [workspace.lints.clippy] # Allows for simpler exports. diff --git a/browser/Cargo.toml b/browser/Cargo.toml index f3377ca..f3d13c3 100644 --- a/browser/Cargo.toml +++ b/browser/Cargo.toml @@ -22,7 +22,12 @@ url.workspace = true which.workspace = true client-simulator-config.workspace = true +cloudflare-worker-client.workspace = true maybe-backoff.workspace = true +[dev-dependencies] +serde_json.workspace = true +tokio = { workspace = true, features = ["io-util", "net", "time"] } + [lints] workspace = true diff --git a/browser/src/auth.rs b/browser/src/auth.rs index d2c81aa..c35a513 100644 --- a/browser/src/auth.rs +++ b/browser/src/auth.rs @@ -132,6 +132,10 @@ impl BorrowedCookie { pub fn username(&self) -> &str { &self.cookie.username } + + pub fn raw_value(&self) -> &str { + &self.cookie.cookie + } } // -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- diff --git a/browser/src/participant/cloudflare/mod.rs b/browser/src/participant/cloudflare/mod.rs index 4eca37c..4e7b273 100644 --- a/browser/src/participant/cloudflare/mod.rs +++ b/browser/src/participant/cloudflare/mod.rs @@ -1 +1,1654 @@ -//! Cloudflare-specific participant backends live here. +use crate::{ + auth::{ + BorrowedCookie, + HyperSessionCookieManger, + }, + participant::shared::{ + messages::{ + ParticipantLogMessage, + ParticipantMessage, + }, + DriverTermination, + ParticipantDriverSession, + ParticipantLaunchSpec, + ParticipantState, + ResolvedFrontendKind, + }, +}; +use client_simulator_config::{ + media::FakeMedia, + CloudflareConfig, + TransportMode, +}; +use cloudflare_worker_client::{ + types, + CloudflareWorkerClient, +}; +use eyre::{ + bail, + eyre, + Context as _, + Result, +}; +use futures::{ + future::BoxFuture, + FutureExt as _, +}; +use std::{ + sync::{ + Arc, + Mutex, + }, + time::Duration, +}; +use tokio::{ + sync::{ + mpsc::UnboundedSender, + oneshot, + watch, + }, + task::JoinHandle, + time::MissedTickBehavior, +}; + +enum CloudflareAuth { + HyperCore { + cookie: Option, + cookie_manager: HyperSessionCookieManger, + }, + HyperLite, +} + +#[derive(Debug, Clone, PartialEq)] +pub(super) struct CloudflareLaunchOptions { + headless: bool, + fake_media: FakeMedia, +} + +impl From<&client_simulator_config::Config> for CloudflareLaunchOptions { + fn from(config: &client_simulator_config::Config) -> Self { + Self { + headless: config.headless, + fake_media: config.fake_media(), + } + } +} + +pub(super) struct CloudflareSession { + launch_spec: ParticipantLaunchSpec, + launch_options: CloudflareLaunchOptions, + cloudflare_config: CloudflareConfig, + sender: UnboundedSender, + auth: CloudflareAuth, + session_id: Option, + cached_state: Arc>, + termination_tx: watch::Sender>, + termination_rx: watch::Receiver>, + poller_shutdown_tx: Option>, + poller_task: Option>, +} + +fn emit_log_message( + sender: &UnboundedSender, + participant_name: &str, + level: &str, + message: impl ToString, +) { + let log_message = ParticipantLogMessage::new(level, participant_name, message); + log_message.write(); + if let Err(err) = sender.send(log_message) { + trace!( + participant = %participant_name, + "Failed to send cloudflare driver log message: {err}" + ); + } +} + +fn forward_worker_entries( + sender: &UnboundedSender, + participant_name: &str, + entries: &[types::AutomationLogEntry], +) { + for entry in entries { + emit_log_message( + sender, + participant_name, + "debug", + format!("worker {} {}", entry.at.to_rfc3339(), entry.step), + ); + } +} + +fn store_cached_state(cached_state: &Arc>, state: &types::ParticipantState) { + *cached_state.lock().unwrap() = map_state(state); +} + +impl CloudflareSession { + pub(super) fn new( + launch_spec: ParticipantLaunchSpec, + launch_options: CloudflareLaunchOptions, + cloudflare_config: CloudflareConfig, + sender: UnboundedSender, + cookie: Option, + cookie_manager: HyperSessionCookieManger, + ) -> Self { + Self::build( + launch_spec, + launch_options, + cloudflare_config, + sender, + cookie, + cookie_manager, + true, + ) + } + + fn build( + launch_spec: ParticipantLaunchSpec, + launch_options: CloudflareLaunchOptions, + cloudflare_config: CloudflareConfig, + sender: UnboundedSender, + cookie: Option, + cookie_manager: HyperSessionCookieManger, + _track_spawn: bool, + ) -> Self { + #[cfg(test)] + { + if _track_spawn { + spawned_participants_for_test() + .lock() + .unwrap() + .push(launch_spec.username.clone()); + } + } + + let auth = match launch_spec.frontend_kind { + ResolvedFrontendKind::HyperCore => CloudflareAuth::HyperCore { cookie, cookie_manager }, + ResolvedFrontendKind::HyperLite => CloudflareAuth::HyperLite, + }; + let (termination_tx, termination_rx) = watch::channel(None); + + Self { + cached_state: Arc::new(Mutex::new(ParticipantState { + username: launch_spec.username.clone(), + ..Default::default() + })), + launch_spec, + launch_options, + cloudflare_config, + sender, + auth, + session_id: None, + termination_tx, + termination_rx, + poller_shutdown_tx: None, + poller_task: None, + } + } + + #[cfg(test)] + fn new_for_test( + launch_spec: ParticipantLaunchSpec, + launch_options: CloudflareLaunchOptions, + cloudflare_config: CloudflareConfig, + sender: UnboundedSender, + cookie: Option, + cookie_manager: HyperSessionCookieManger, + ) -> Self { + Self::build( + launch_spec, + launch_options, + cloudflare_config, + sender, + cookie, + cookie_manager, + false, + ) + } + + fn log_message(&self, level: &str, message: impl ToString) { + emit_log_message(&self.sender, &self.launch_spec.username, level, message); + } + + fn worker_client(&self) -> Result { + CloudflareWorkerClient::new( + self.cloudflare_config.base_url.as_ref(), + Duration::from_secs(self.cloudflare_config.request_timeout_seconds), + ) + .wrap_err("Failed to construct Cloudflare worker client") + } + + fn log_worker_entries(&self, entries: &[types::AutomationLogEntry]) { + forward_worker_entries(&self.sender, &self.launch_spec.username, entries); + } + + fn log_backend_limitations(&self) { + if !self.launch_options.headless { + self.log_message( + "warn", + "Cloudflare backend ignores headless=false because worker sessions are always headless", + ); + } + + if let FakeMedia::FileOrUrl(source) = &self.launch_options.fake_media { + self.log_message( + "warn", + format!( + "Cloudflare backend ignores local fake media source `{source}` and will use worker-provided media instead" + ), + ); + } + } + + fn normalized_settings(&self) -> crate::participant::shared::ParticipantSettings { + let mut settings = self.launch_spec.settings.clone(); + + if settings.transport != TransportMode::WebRTC { + self.log_message( + "warn", + format!( + "Cloudflare backend only supports WebRTC transport; normalizing configured {} transport to WebRTC", + settings.transport + ), + ); + settings.transport = TransportMode::WebRTC; + } + + settings + } + + async fn ensure_hyper_session_cookie(&mut self) -> Result> { + match &mut self.auth { + CloudflareAuth::HyperCore { cookie, cookie_manager } => { + if cookie.is_none() { + *cookie = Some( + cookie_manager + .give_or_fetch_cookie(self.launch_spec.base_url(), &self.launch_spec.username) + .await?, + ); + } + + Ok(cookie.as_ref().map(|cookie| cookie.raw_value().to_owned())) + } + CloudflareAuth::HyperLite => Ok(None), + } + } + + async fn build_create_request(&mut self) -> Result { + self.log_backend_limitations(); + let normalized_settings = self.normalized_settings(); + let hyper_session_cookie = self + .ensure_hyper_session_cookie() + .await? + .map(types::SessionCreateRequestHyperSessionCookie::try_from) + .transpose() + .map_err(|error| eyre!("Failed to encode Hyper Core session cookie for the worker: {error}"))?; + + Ok(types::SessionCreateRequest { + debug: Some(self.cloudflare_config.debug), + display_name: types::SessionCreateRequestDisplayName::try_from(self.launch_spec.username.clone()) + .map_err(|error| eyre!("Invalid Cloudflare display name: {error}"))?, + frontend_kind: map_frontend_kind(self.launch_spec.frontend_kind), + hyper_session_cookie, + navigation_timeout_ms: Some(self.cloudflare_config.navigation_timeout_ms as f64), + room_url: self.launch_spec.session_url.to_string(), + selector_timeout_ms: Some(self.cloudflare_config.selector_timeout_ms as f64), + session_timeout_ms: Some(self.cloudflare_config.session_timeout_ms as f64), + settings: map_settings(&normalized_settings), + }) + } + + fn command_request(message: ParticipantMessage) -> types::SessionCommandRequest { + match message { + ParticipantMessage::Join => types::SessionCommandRequest::Join, + ParticipantMessage::Leave => types::SessionCommandRequest::Leave, + ParticipantMessage::Close => types::SessionCommandRequest::Leave, + ParticipantMessage::ToggleAudio => types::SessionCommandRequest::ToggleAudio, + ParticipantMessage::ToggleVideo => types::SessionCommandRequest::ToggleVideo, + ParticipantMessage::ToggleScreenshare => types::SessionCommandRequest::ToggleScreenshare, + ParticipantMessage::ToggleAutoGainControl => types::SessionCommandRequest::ToggleAutoGainControl, + ParticipantMessage::SetNoiseSuppression(value) => types::SessionCommandRequest::SetNoiseSuppression { + noise_suppression: map_command_noise_suppression(value), + }, + ParticipantMessage::SetWebcamResolutions(value) => types::SessionCommandRequest::SetWebcamResolution { + webcam_resolution: map_command_webcam_resolution(value), + }, + ParticipantMessage::ToggleBackgroundBlur => types::SessionCommandRequest::ToggleBackgroundBlur, + } + } + + fn cached_state(&self) -> ParticipantState { + self.cached_state.lock().unwrap().clone() + } + + fn update_cached_state(&self, state: &types::ParticipantState) { + store_cached_state(&self.cached_state, state); + } + + fn effective_health_poll_interval(&self) -> Duration { + let configured_ms = self.cloudflare_config.health_poll_interval_ms.max(1); + let keep_alive_budget_ms = self.cloudflare_config.session_timeout_ms.saturating_div(2).max(1); + let effective_ms = configured_ms.min(keep_alive_budget_ms); + + if effective_ms != configured_ms { + self.log_message( + "warn", + format!( + "Cloudflare health poll interval {}ms exceeds the safe keep-alive window for a {}ms Browser Rendering keep_alive timeout; clamping to {}ms", + configured_ms, self.cloudflare_config.session_timeout_ms, effective_ms + ), + ); + } + + Duration::from_millis(effective_ms) + } + + async fn stop_termination_poller(&mut self) { + if let Some(shutdown_tx) = self.poller_shutdown_tx.take() { + let _ = shutdown_tx.send(()); + } + + if let Some(task) = self.poller_task.take() { + let _ = task.await; + } + } + + fn start_termination_poller(&mut self, session_id: String) -> Result<()> { + let client = self.worker_client()?; + let poll_interval = self.effective_health_poll_interval(); + let cached_state = Arc::clone(&self.cached_state); + let participant_name = self.launch_spec.username.clone(); + let sender = self.sender.clone(); + let termination_tx = self.termination_tx.clone(); + let (shutdown_tx, mut shutdown_rx) = oneshot::channel(); + + let task = tokio::spawn(async move { + let mut interval = tokio::time::interval(poll_interval); + interval.set_missed_tick_behavior(MissedTickBehavior::Delay); + interval.tick().await; + + loop { + tokio::select! { + _ = &mut shutdown_rx => break, + _ = interval.tick() => { + match client.keep_alive_session(&session_id).await { + Ok(response) => { + forward_worker_entries(&sender, &participant_name, &response.log); + + store_cached_state(&cached_state, &response.state); + + if !response.state.running { + let _ = termination_tx.send(Some(DriverTermination::new( + "warn", + format!( + "Cloudflare worker session {session_id} is no longer running" + ), + ))); + break; + } + } + Err(err) => { + let _ = termination_tx.send(Some(DriverTermination::new( + "warn", + format!( + "Cloudflare worker session {session_id} terminated unexpectedly: {err}" + ), + ))); + break; + } + } + } + } + } + }); + + self.poller_shutdown_tx = Some(shutdown_tx); + self.poller_task = Some(task); + + Ok(()) + } + + async fn start_inner(&mut self) -> Result<()> { + if self.session_id.is_some() { + bail!("Cloudflare session already started"); + } + + self.log_message( + "info", + format!( + "Creating Cloudflare worker session via {}", + self.cloudflare_config.base_url + ), + ); + + let request = self.build_create_request().await?; + let response = self.worker_client()?.create_session(&request).await?; + self.log_worker_entries(&response.log); + + self.termination_tx.send_replace(None); + self.update_cached_state(&response.state); + self.session_id = Some(response.session_id.clone()); + self.start_termination_poller(response.session_id.clone())?; + + self.log_message( + "info", + format!("Created Cloudflare worker session {}", response.session_id), + ); + + Ok(()) + } + + async fn close_inner(&mut self) -> Result<()> { + self.stop_termination_poller().await; + + let Some(session_id) = self.session_id.clone() else { + self.log_message("debug", "Cloudflare worker session already closed"); + return Ok(()); + }; + + self.log_message("info", format!("Closing Cloudflare worker session {session_id}")); + + let response = self.worker_client()?.close_session(&session_id).await?; + self.log_worker_entries(&response.log); + self.session_id = None; + self.termination_tx.send_replace(None); + { + let mut cached_state = self.cached_state.lock().unwrap(); + cached_state.running = false; + cached_state.joined = false; + cached_state.screenshare_activated = false; + } + + self.log_message("info", format!("Closed Cloudflare worker session {session_id}")); + + Ok(()) + } + + async fn handle_command_inner(&mut self, message: ParticipantMessage) -> Result<()> { + let session_id = self + .session_id + .clone() + .ok_or_else(|| eyre!("Cloudflare session is not started"))?; + let request = Self::command_request(message); + let response = self.worker_client()?.command_session(&session_id, &request).await?; + self.log_worker_entries(&response.log); + self.update_cached_state(&response.state); + Ok(()) + } + + async fn wait_for_termination_inner(&mut self) -> DriverTermination { + loop { + if let Some(termination) = self.termination_rx.borrow().clone() { + return termination; + } + + if self.termination_rx.changed().await.is_err() { + return DriverTermination::new("warn", "cloudflare driver termination channel closed"); + } + } + } +} + +impl ParticipantDriverSession for CloudflareSession { + fn participant_name(&self) -> &str { + &self.launch_spec.username + } + + fn start(&mut self) -> BoxFuture<'_, Result<()>> { + self.start_inner().boxed() + } + + fn handle_command(&mut self, message: ParticipantMessage) -> BoxFuture<'_, Result<()>> { + self.handle_command_inner(message).boxed() + } + + fn refresh_state(&mut self) -> BoxFuture<'_, Result> { + async move { Ok(self.cached_state()) }.boxed() + } + + fn close(&mut self) -> BoxFuture<'_, Result<()>> { + self.close_inner().boxed() + } + + fn wait_for_termination(&mut self) -> BoxFuture<'_, DriverTermination> { + self.wait_for_termination_inner().boxed() + } +} + +fn map_frontend_kind(frontend_kind: ResolvedFrontendKind) -> types::SessionCreateRequestFrontendKind { + match frontend_kind { + ResolvedFrontendKind::HyperCore => types::SessionCreateRequestFrontendKind::HyperCore, + ResolvedFrontendKind::HyperLite => types::SessionCreateRequestFrontendKind::HyperLite, + } +} + +fn map_settings(settings: &crate::participant::shared::ParticipantSettings) -> types::ParticipantSettings { + types::ParticipantSettings { + audio_enabled: settings.audio_enabled, + auto_gain_control: settings.auto_gain_control, + blur: settings.blur, + noise_suppression: match settings.noise_suppression { + client_simulator_config::NoiseSuppression::Disabled => types::ParticipantSettingsNoiseSuppression::None, + client_simulator_config::NoiseSuppression::Deepfilternet => { + types::ParticipantSettingsNoiseSuppression::Deepfilternet + } + client_simulator_config::NoiseSuppression::RNNoise => types::ParticipantSettingsNoiseSuppression::Rnnoise, + client_simulator_config::NoiseSuppression::IRISCarthy => { + types::ParticipantSettingsNoiseSuppression::IrisCarthy + } + client_simulator_config::NoiseSuppression::KrispHigh => { + types::ParticipantSettingsNoiseSuppression::KrispHigh + } + client_simulator_config::NoiseSuppression::KrispMedium => { + types::ParticipantSettingsNoiseSuppression::KrispMedium + } + client_simulator_config::NoiseSuppression::KrispLow => types::ParticipantSettingsNoiseSuppression::KrispLow, + client_simulator_config::NoiseSuppression::KrispHighWithBVC => { + types::ParticipantSettingsNoiseSuppression::KrispHighWithBvc + } + client_simulator_config::NoiseSuppression::KrispMediumWithBVC => { + types::ParticipantSettingsNoiseSuppression::KrispMediumWithBvc + } + client_simulator_config::NoiseSuppression::AiCousticsSparrowXxs => { + types::ParticipantSettingsNoiseSuppression::AiCousticsSparrowXxs + } + client_simulator_config::NoiseSuppression::AiCousticsSparrowXs => { + types::ParticipantSettingsNoiseSuppression::AiCousticsSparrowXs + } + client_simulator_config::NoiseSuppression::AiCousticsSparrowS => { + types::ParticipantSettingsNoiseSuppression::AiCousticsSparrowS + } + client_simulator_config::NoiseSuppression::AiCousticsSparrowL => { + types::ParticipantSettingsNoiseSuppression::AiCousticsSparrowL + } + }, + resolution: match settings.resolution { + client_simulator_config::WebcamResolution::Auto => types::ParticipantSettingsResolution::Auto, + client_simulator_config::WebcamResolution::P144 => types::ParticipantSettingsResolution::P144, + client_simulator_config::WebcamResolution::P240 => types::ParticipantSettingsResolution::P240, + client_simulator_config::WebcamResolution::P360 => types::ParticipantSettingsResolution::P360, + client_simulator_config::WebcamResolution::P480 => types::ParticipantSettingsResolution::P480, + client_simulator_config::WebcamResolution::P720 => types::ParticipantSettingsResolution::P720, + client_simulator_config::WebcamResolution::P1080 => types::ParticipantSettingsResolution::P1080, + client_simulator_config::WebcamResolution::P1440 => types::ParticipantSettingsResolution::P1440, + client_simulator_config::WebcamResolution::P2160 => types::ParticipantSettingsResolution::P2160, + client_simulator_config::WebcamResolution::P4320 => types::ParticipantSettingsResolution::P4320, + }, + screenshare_enabled: settings.screenshare_enabled, + transport: match settings.transport { + client_simulator_config::TransportMode::WebRTC => types::ParticipantSettingsTransport::Webrtc, + client_simulator_config::TransportMode::WebTransport => types::ParticipantSettingsTransport::Webtransport, + }, + video_enabled: settings.video_enabled, + } +} + +fn map_state(state: &types::ParticipantState) -> ParticipantState { + ParticipantState { + username: String::new(), + running: state.running, + joined: state.joined, + muted: state.muted, + video_activated: state.video_activated, + auto_gain_control: state.auto_gain_control, + noise_suppression: match state.noise_suppression { + types::ParticipantStateNoiseSuppression::None => client_simulator_config::NoiseSuppression::Disabled, + types::ParticipantStateNoiseSuppression::Deepfilternet => { + client_simulator_config::NoiseSuppression::Deepfilternet + } + types::ParticipantStateNoiseSuppression::Rnnoise => client_simulator_config::NoiseSuppression::RNNoise, + types::ParticipantStateNoiseSuppression::IrisCarthy => { + client_simulator_config::NoiseSuppression::IRISCarthy + } + types::ParticipantStateNoiseSuppression::KrispHigh => client_simulator_config::NoiseSuppression::KrispHigh, + types::ParticipantStateNoiseSuppression::KrispMedium => { + client_simulator_config::NoiseSuppression::KrispMedium + } + types::ParticipantStateNoiseSuppression::KrispLow => client_simulator_config::NoiseSuppression::KrispLow, + types::ParticipantStateNoiseSuppression::KrispHighWithBvc => { + client_simulator_config::NoiseSuppression::KrispHighWithBVC + } + types::ParticipantStateNoiseSuppression::KrispMediumWithBvc => { + client_simulator_config::NoiseSuppression::KrispMediumWithBVC + } + types::ParticipantStateNoiseSuppression::AiCousticsSparrowXxs => { + client_simulator_config::NoiseSuppression::AiCousticsSparrowXxs + } + types::ParticipantStateNoiseSuppression::AiCousticsSparrowXs => { + client_simulator_config::NoiseSuppression::AiCousticsSparrowXs + } + types::ParticipantStateNoiseSuppression::AiCousticsSparrowS => { + client_simulator_config::NoiseSuppression::AiCousticsSparrowS + } + types::ParticipantStateNoiseSuppression::AiCousticsSparrowL => { + client_simulator_config::NoiseSuppression::AiCousticsSparrowL + } + }, + transport_mode: match state.transport_mode { + types::ParticipantStateTransportMode::Webrtc => client_simulator_config::TransportMode::WebRTC, + types::ParticipantStateTransportMode::Webtransport => client_simulator_config::TransportMode::WebTransport, + }, + webcam_resolution: match state.webcam_resolution { + types::ParticipantStateWebcamResolution::Auto => client_simulator_config::WebcamResolution::Auto, + types::ParticipantStateWebcamResolution::P144 => client_simulator_config::WebcamResolution::P144, + types::ParticipantStateWebcamResolution::P240 => client_simulator_config::WebcamResolution::P240, + types::ParticipantStateWebcamResolution::P360 => client_simulator_config::WebcamResolution::P360, + types::ParticipantStateWebcamResolution::P480 => client_simulator_config::WebcamResolution::P480, + types::ParticipantStateWebcamResolution::P720 => client_simulator_config::WebcamResolution::P720, + types::ParticipantStateWebcamResolution::P1080 => client_simulator_config::WebcamResolution::P1080, + types::ParticipantStateWebcamResolution::P1440 => client_simulator_config::WebcamResolution::P1440, + types::ParticipantStateWebcamResolution::P2160 => client_simulator_config::WebcamResolution::P2160, + types::ParticipantStateWebcamResolution::P4320 => client_simulator_config::WebcamResolution::P4320, + }, + background_blur: state.background_blur, + screenshare_activated: state.screenshare_activated, + } +} + +fn map_command_noise_suppression( + noise_suppression: client_simulator_config::NoiseSuppression, +) -> types::SessionCommandRequestNoiseSuppression { + match noise_suppression { + client_simulator_config::NoiseSuppression::Disabled => types::SessionCommandRequestNoiseSuppression::None, + client_simulator_config::NoiseSuppression::Deepfilternet => { + types::SessionCommandRequestNoiseSuppression::Deepfilternet + } + client_simulator_config::NoiseSuppression::RNNoise => types::SessionCommandRequestNoiseSuppression::Rnnoise, + client_simulator_config::NoiseSuppression::IRISCarthy => { + types::SessionCommandRequestNoiseSuppression::IrisCarthy + } + client_simulator_config::NoiseSuppression::KrispHigh => types::SessionCommandRequestNoiseSuppression::KrispHigh, + client_simulator_config::NoiseSuppression::KrispMedium => { + types::SessionCommandRequestNoiseSuppression::KrispMedium + } + client_simulator_config::NoiseSuppression::KrispLow => types::SessionCommandRequestNoiseSuppression::KrispLow, + client_simulator_config::NoiseSuppression::KrispHighWithBVC => { + types::SessionCommandRequestNoiseSuppression::KrispHighWithBvc + } + client_simulator_config::NoiseSuppression::KrispMediumWithBVC => { + types::SessionCommandRequestNoiseSuppression::KrispMediumWithBvc + } + client_simulator_config::NoiseSuppression::AiCousticsSparrowXxs => { + types::SessionCommandRequestNoiseSuppression::AiCousticsSparrowXxs + } + client_simulator_config::NoiseSuppression::AiCousticsSparrowXs => { + types::SessionCommandRequestNoiseSuppression::AiCousticsSparrowXs + } + client_simulator_config::NoiseSuppression::AiCousticsSparrowS => { + types::SessionCommandRequestNoiseSuppression::AiCousticsSparrowS + } + client_simulator_config::NoiseSuppression::AiCousticsSparrowL => { + types::SessionCommandRequestNoiseSuppression::AiCousticsSparrowL + } + } +} + +fn map_command_webcam_resolution( + webcam_resolution: client_simulator_config::WebcamResolution, +) -> types::SessionCommandRequestWebcamResolution { + match webcam_resolution { + client_simulator_config::WebcamResolution::Auto => types::SessionCommandRequestWebcamResolution::Auto, + client_simulator_config::WebcamResolution::P144 => types::SessionCommandRequestWebcamResolution::P144, + client_simulator_config::WebcamResolution::P240 => types::SessionCommandRequestWebcamResolution::P240, + client_simulator_config::WebcamResolution::P360 => types::SessionCommandRequestWebcamResolution::P360, + client_simulator_config::WebcamResolution::P480 => types::SessionCommandRequestWebcamResolution::P480, + client_simulator_config::WebcamResolution::P720 => types::SessionCommandRequestWebcamResolution::P720, + client_simulator_config::WebcamResolution::P1080 => types::SessionCommandRequestWebcamResolution::P1080, + client_simulator_config::WebcamResolution::P1440 => types::SessionCommandRequestWebcamResolution::P1440, + client_simulator_config::WebcamResolution::P2160 => types::SessionCommandRequestWebcamResolution::P2160, + client_simulator_config::WebcamResolution::P4320 => types::SessionCommandRequestWebcamResolution::P4320, + } +} + +#[cfg(test)] +fn spawned_participants_for_test() -> &'static Mutex> { + static SPAWNED: Mutex> = Mutex::new(Vec::new()); + &SPAWNED +} + +#[cfg(test)] +pub(crate) fn take_spawned_participants_for_test() -> Vec { + std::mem::take(&mut *spawned_participants_for_test().lock().unwrap()) +} + +#[cfg(test)] +mod tests { + use super::{ + CloudflareLaunchOptions, + CloudflareSession, + }; + use crate::{ + auth::HyperSessionCookieManger, + participant::shared::{ + messages::ParticipantMessage, + ParticipantDriverSession, + ParticipantLaunchSpec, + ParticipantSettings, + ParticipantState, + ResolvedFrontendKind, + }, + }; + use chrono::Utc; + use client_simulator_config::{ + media::FakeMedia, + CloudflareConfig, + NoiseSuppression, + TransportMode, + WebcamResolution, + }; + use serde_json::{ + json, + Value, + }; + use std::{ + collections::VecDeque, + fs, + path::PathBuf, + sync::{ + Arc, + Mutex, + }, + time::{ + Duration, + SystemTime, + UNIX_EPOCH, + }, + }; + use tokio::{ + io::{ + AsyncReadExt as _, + AsyncWriteExt as _, + }, + net::TcpListener, + sync::mpsc::unbounded_channel, + }; + use url::Url; + + #[derive(Clone, Debug)] + struct CapturedRequest { + method: String, + path: String, + headers: Vec<(String, String)>, + body: String, + } + + #[tokio::test] + async fn start_fetches_cookie_creates_worker_session_and_close_tears_it_down() { + let responses = VecDeque::from(vec![ + MockResponse::new( + 200, + "Set-Cookie: hyper_session=fetched-cookie; Path=/; HttpOnly\r\n", + "", + ), + MockResponse::json(200, json!({ "ok": true })), + MockResponse::json( + 200, + json!({ + "ok": true, + "sessionId": "cf-session-123", + "state": { + "running": true, + "joined": true, + "muted": false, + "videoActivated": true, + "screenshareActivated": false, + "autoGainControl": true, + "noiseSuppression": "rnnoise", + "transportMode": "webrtc", + "webcamResolution": "p720", + "backgroundBlur": true + }, + "log": [ + { + "at": Utc::now().to_rfc3339(), + "step": "Joined the room" + } + ] + }), + ), + MockResponse::json( + 200, + json!({ + "ok": true, + "sessionId": "cf-session-123", + "log": [ + { + "at": Utc::now().to_rfc3339(), + "step": "Closed the browser" + } + ] + }), + ), + ]); + let (base_url, requests, server) = spawn_http_server(responses).await; + let cookie_manager = HyperSessionCookieManger::new(unique_temp_dir().join("cookies.json")); + let (log_sender, _log_receiver) = unbounded_channel(); + let mut session = CloudflareSession::new_for_test( + launch_spec(ResolvedFrontendKind::HyperCore, &format!("{base_url}/room/demo")), + launch_options(false, FakeMedia::None), + CloudflareConfig { + base_url: Url::parse(&base_url).unwrap(), + request_timeout_seconds: 5, + session_timeout_ms: 120_000, + navigation_timeout_ms: 30_000, + selector_timeout_ms: 10_000, + debug: true, + health_poll_interval_ms: 5_000, + }, + log_sender, + None, + cookie_manager, + ); + + session.start().await.unwrap(); + + let state = session.refresh_state().await.unwrap(); + assert!(state.running); + assert!(state.joined); + assert_eq!(state.noise_suppression, NoiseSuppression::RNNoise); + assert_eq!(state.transport_mode, TransportMode::WebRTC); + assert_eq!(state.webcam_resolution, WebcamResolution::P720); + assert!(state.auto_gain_control); + assert!(state.background_blur); + + session.close().await.unwrap(); + server.abort(); + + let requests = requests.lock().unwrap().clone(); + assert_eq!(requests.len(), 4); + + assert_eq!(requests[0].method, "POST"); + assert_eq!(requests[0].path, "/api/v1/auth/guest?username=guest"); + + assert_eq!(requests[1].method, "PUT"); + assert_eq!(requests[1].path, "/api/v1/auth/me/name"); + assert_eq!( + header_value(&requests[1], "cookie").as_deref(), + Some("hyper_session=fetched-cookie") + ); + assert_eq!( + serde_json::from_str::(&requests[1].body).unwrap(), + json!({ "name": "cloudflare-sim" }) + ); + + assert_eq!(requests[2].method, "POST"); + assert_eq!(requests[2].path, "/sessions"); + assert_eq!( + serde_json::from_str::(&requests[2].body).unwrap(), + json!({ + "debug": true, + "displayName": "cloudflare-sim", + "frontendKind": "hyper-core", + "hyperSessionCookie": "fetched-cookie", + "navigationTimeoutMs": 30000.0, + "roomUrl": format!("{base_url}/room/demo"), + "selectorTimeoutMs": 10000.0, + "sessionTimeoutMs": 120000.0, + "settings": { + "audioEnabled": true, + "autoGainControl": true, + "blur": true, + "noiseSuppression": "rnnoise", + "resolution": "p720", + "screenshareEnabled": false, + "transport": "webrtc", + "videoEnabled": true + } + }) + ); + + assert_eq!(requests[3].method, "POST"); + assert_eq!(requests[3].path, "/sessions/cf-session-123/close"); + } + + #[tokio::test] + async fn start_accepts_ai_coustics_noise_suppression_from_worker_state() { + let responses = VecDeque::from(vec![ + MockResponse::json( + 200, + json!({ + "ok": true, + "sessionId": "cf-session-ai-coustics", + "state": { + "running": true, + "joined": true, + "muted": false, + "videoActivated": true, + "screenshareActivated": false, + "autoGainControl": true, + "noiseSuppression": "ai-coustics-sparrow-s", + "transportMode": "webrtc", + "webcamResolution": "p720", + "backgroundBlur": true + }, + "log": [], + }), + ), + MockResponse::json( + 200, + json!({ + "ok": true, + "sessionId": "cf-session-ai-coustics", + "log": [], + }), + ), + ]); + let (base_url, _requests, server) = spawn_http_server(responses).await; + let cookie_manager = HyperSessionCookieManger::new(unique_temp_dir().join("cookies.json")); + let (log_sender, _log_receiver) = unbounded_channel(); + let mut session = CloudflareSession::new_for_test( + launch_spec(ResolvedFrontendKind::HyperLite, &format!("{base_url}/room/demo")), + launch_options(false, FakeMedia::None), + CloudflareConfig { + base_url: Url::parse(&base_url).unwrap(), + request_timeout_seconds: 5, + session_timeout_ms: 120_000, + navigation_timeout_ms: 30_000, + selector_timeout_ms: 10_000, + debug: false, + health_poll_interval_ms: 60_000, + }, + log_sender, + None, + cookie_manager, + ); + + session.start().await.unwrap(); + + let state = session.refresh_state().await.unwrap(); + assert!(state.auto_gain_control); + assert_eq!(state.noise_suppression, NoiseSuppression::AiCousticsSparrowS); + + session.close().await.unwrap(); + server.abort(); + } + + #[tokio::test] + async fn commands_map_to_worker_requests_and_refresh_state_uses_cache() { + let responses = VecDeque::from(vec![ + MockResponse::json( + 200, + json!({ + "ok": true, + "sessionId": "cf-session-commands", + "state": worker_state_json(false, false, false, false, true, "none", "auto", false), + "log": [], + }), + ), + MockResponse::json( + 200, + json!({ + "ok": true, + "sessionId": "cf-session-commands", + "state": worker_state_json(true, false, false, false, true, "none", "auto", false), + "log": [], + }), + ), + MockResponse::json( + 200, + json!({ + "ok": true, + "sessionId": "cf-session-commands", + "state": worker_state_json(true, true, false, false, true, "none", "auto", false), + "log": [], + }), + ), + MockResponse::json( + 200, + json!({ + "ok": true, + "sessionId": "cf-session-commands", + "state": worker_state_json(true, true, true, false, true, "none", "auto", false), + "log": [], + }), + ), + MockResponse::json( + 200, + json!({ + "ok": true, + "sessionId": "cf-session-commands", + "state": worker_state_json(true, true, true, true, true, "none", "auto", false), + "log": [], + }), + ), + MockResponse::json( + 200, + json!({ + "ok": true, + "sessionId": "cf-session-commands", + "state": worker_state_json(true, true, true, true, false, "none", "auto", false), + "log": [], + }), + ), + MockResponse::json( + 200, + json!({ + "ok": true, + "sessionId": "cf-session-commands", + "state": worker_state_json(true, true, true, true, false, "deepfilternet", "auto", false), + "log": [], + }), + ), + MockResponse::json( + 200, + json!({ + "ok": true, + "sessionId": "cf-session-commands", + "state": worker_state_json(true, true, true, true, false, "deepfilternet", "p1080", false), + "log": [], + }), + ), + MockResponse::json( + 200, + json!({ + "ok": true, + "sessionId": "cf-session-commands", + "state": worker_state_json(true, true, true, true, false, "deepfilternet", "p1080", true), + "log": [], + }), + ), + MockResponse::json( + 200, + json!({ + "ok": true, + "sessionId": "cf-session-commands", + "state": worker_state_json(false, true, true, false, false, "deepfilternet", "p1080", true), + "log": [], + }), + ), + MockResponse::json( + 200, + json!({ + "ok": true, + "sessionId": "cf-session-commands", + "log": [], + }), + ), + ]); + let (base_url, requests, server) = spawn_http_server(responses).await; + let cookie_manager = HyperSessionCookieManger::new(unique_temp_dir().join("cookies.json")); + let (log_sender, _log_receiver) = unbounded_channel(); + let mut session = CloudflareSession::new_for_test( + launch_spec(ResolvedFrontendKind::HyperLite, &format!("{base_url}/room/demo")), + launch_options(false, FakeMedia::None), + CloudflareConfig { + base_url: Url::parse(&base_url).unwrap(), + request_timeout_seconds: 5, + session_timeout_ms: 120_000, + navigation_timeout_ms: 30_000, + selector_timeout_ms: 10_000, + debug: false, + health_poll_interval_ms: 60_000, + }, + log_sender, + None, + cookie_manager, + ); + + session.start().await.unwrap(); + + let cases = vec![ + ( + ParticipantMessage::Join, + json!({ "type": "join" }), + expected_state( + true, + false, + false, + false, + true, + NoiseSuppression::Disabled, + WebcamResolution::Auto, + false, + ), + ), + ( + ParticipantMessage::ToggleAudio, + json!({ "type": "toggle-audio" }), + expected_state( + true, + true, + false, + false, + true, + NoiseSuppression::Disabled, + WebcamResolution::Auto, + false, + ), + ), + ( + ParticipantMessage::ToggleVideo, + json!({ "type": "toggle-video" }), + expected_state( + true, + true, + true, + false, + true, + NoiseSuppression::Disabled, + WebcamResolution::Auto, + false, + ), + ), + ( + ParticipantMessage::ToggleScreenshare, + json!({ "type": "toggle-screenshare" }), + expected_state( + true, + true, + true, + true, + true, + NoiseSuppression::Disabled, + WebcamResolution::Auto, + false, + ), + ), + ( + ParticipantMessage::ToggleAutoGainControl, + json!({ "type": "toggle-auto-gain-control" }), + expected_state( + true, + true, + true, + true, + false, + NoiseSuppression::Disabled, + WebcamResolution::Auto, + false, + ), + ), + ( + ParticipantMessage::SetNoiseSuppression(NoiseSuppression::Deepfilternet), + json!({ "type": "set-noise-suppression", "noiseSuppression": "deepfilternet" }), + expected_state( + true, + true, + true, + true, + false, + NoiseSuppression::Deepfilternet, + WebcamResolution::Auto, + false, + ), + ), + ( + ParticipantMessage::SetWebcamResolutions(WebcamResolution::P1080), + json!({ "type": "set-webcam-resolution", "webcamResolution": "p1080" }), + expected_state( + true, + true, + true, + true, + false, + NoiseSuppression::Deepfilternet, + WebcamResolution::P1080, + false, + ), + ), + ( + ParticipantMessage::ToggleBackgroundBlur, + json!({ "type": "toggle-background-blur" }), + expected_state( + true, + true, + true, + true, + false, + NoiseSuppression::Deepfilternet, + WebcamResolution::P1080, + true, + ), + ), + ( + ParticipantMessage::Leave, + json!({ "type": "leave" }), + expected_state( + false, + true, + true, + false, + false, + NoiseSuppression::Deepfilternet, + WebcamResolution::P1080, + true, + ), + ), + ]; + + for (index, (message, expected_body, expected_state)) in cases.into_iter().enumerate() { + session.handle_command(message).await.unwrap(); + let request_count_before_refresh = requests.lock().unwrap().len(); + let state = session.refresh_state().await.unwrap(); + assert_eq!( + requests.lock().unwrap().len(), + request_count_before_refresh, + "refresh_state unexpectedly triggered a network call for case {index}" + ); + assert_state_matches(&state, &expected_state); + let request = requests.lock().unwrap().get(index + 1).unwrap().clone(); + assert_eq!(request.method, "POST"); + assert_eq!(request.path, "/sessions/cf-session-commands/commands"); + assert_eq!(serde_json::from_str::(&request.body).unwrap(), expected_body); + } + + session.close().await.unwrap(); + server.abort(); + + let requests = requests.lock().unwrap().clone(); + assert_eq!(requests[0].method, "POST"); + assert_eq!(requests[0].path, "/sessions"); + assert_eq!(requests[10].method, "POST"); + assert_eq!(requests[10].path, "/sessions/cf-session-commands/close"); + } + + #[tokio::test] + async fn start_normalizes_webtransport_to_webrtc_for_cloudflare() { + let responses = VecDeque::from(vec![ + MockResponse::json( + 200, + json!({ + "ok": true, + "sessionId": "cf-session-webrtc", + "state": worker_state_json(false, false, false, false, true, "rnnoise", "p720", true), + "log": [], + }), + ), + MockResponse::json( + 200, + json!({ + "ok": true, + "sessionId": "cf-session-webrtc", + "log": [], + }), + ), + ]); + let (base_url, requests, server) = spawn_http_server(responses).await; + let cookie_manager = HyperSessionCookieManger::new(unique_temp_dir().join("cookies.json")); + let (log_sender, mut log_receiver) = unbounded_channel(); + let mut spec = launch_spec(ResolvedFrontendKind::HyperLite, &format!("{base_url}/room/demo")); + spec.settings.transport = TransportMode::WebTransport; + let mut session = CloudflareSession::new_for_test( + spec, + launch_options(false, FakeMedia::None), + CloudflareConfig { + base_url: Url::parse(&base_url).unwrap(), + request_timeout_seconds: 5, + session_timeout_ms: 120_000, + navigation_timeout_ms: 30_000, + selector_timeout_ms: 10_000, + debug: false, + health_poll_interval_ms: 60_000, + }, + log_sender, + None, + cookie_manager, + ); + + session.start().await.unwrap(); + + let state = session.refresh_state().await.unwrap(); + assert_eq!(state.transport_mode, TransportMode::WebRTC); + + session.close().await.unwrap(); + server.abort(); + + let requests = requests.lock().unwrap().clone(); + assert_eq!(requests[0].method, "POST"); + assert_eq!(requests[0].path, "/sessions"); + assert_eq!( + serde_json::from_str::(&requests[0].body).unwrap()["settings"]["transport"], + json!("webrtc") + ); + + let logs = drain_log_messages(&mut log_receiver); + assert!(logs.iter().any(|message| { + message.contains("only supports WebRTC transport") && message.contains("normalizing configured") + })); + } + + #[tokio::test] + async fn start_logs_ignored_headless_and_fake_media_settings_for_cloudflare() { + let responses = VecDeque::from(vec![ + MockResponse::json( + 200, + json!({ + "ok": true, + "sessionId": "cf-session-limitations", + "state": worker_state_json(false, false, false, false, true, "rnnoise", "p720", true), + "log": [], + }), + ), + MockResponse::json( + 200, + json!({ + "ok": true, + "sessionId": "cf-session-limitations", + "log": [], + }), + ), + ]); + let (base_url, _requests, server) = spawn_http_server(responses).await; + let cookie_manager = HyperSessionCookieManger::new(unique_temp_dir().join("cookies.json")); + let (log_sender, mut log_receiver) = unbounded_channel(); + let mut session = CloudflareSession::new_for_test( + launch_spec(ResolvedFrontendKind::HyperLite, &format!("{base_url}/room/demo")), + launch_options( + false, + FakeMedia::FileOrUrl("https://example.com/fake-media.mp4".to_owned()), + ), + CloudflareConfig { + base_url: Url::parse(&base_url).unwrap(), + request_timeout_seconds: 5, + session_timeout_ms: 120_000, + navigation_timeout_ms: 30_000, + selector_timeout_ms: 10_000, + debug: false, + health_poll_interval_ms: 60_000, + }, + log_sender, + None, + cookie_manager, + ); + + session.start().await.unwrap(); + session.close().await.unwrap(); + server.abort(); + + let logs = drain_log_messages(&mut log_receiver); + assert!(logs.iter().any(|message| message.contains("ignores headless=false"))); + assert!(logs.iter().any(|message| { + message.contains("ignores local fake media source") + && message.contains("https://example.com/fake-media.mp4") + })); + } + + #[tokio::test] + async fn termination_poller_reports_worker_state_failures() { + let responses = VecDeque::from(vec![ + MockResponse::json( + 200, + json!({ + "ok": true, + "sessionId": "cf-session-terminated", + "state": worker_state_json(true, false, false, false, true, "none", "auto", false), + "log": [], + }), + ), + MockResponse::json( + 500, + json!({ + "ok": false, + "sessionId": "cf-session-terminated", + "error": "Browser session missing", + "log": [], + }), + ), + ]); + let (base_url, requests, server) = spawn_http_server(responses).await; + let cookie_manager = HyperSessionCookieManger::new(unique_temp_dir().join("cookies.json")); + let (log_sender, _log_receiver) = unbounded_channel(); + let mut session = CloudflareSession::new_for_test( + launch_spec(ResolvedFrontendKind::HyperLite, &format!("{base_url}/room/demo")), + launch_options(false, FakeMedia::None), + CloudflareConfig { + base_url: Url::parse(&base_url).unwrap(), + request_timeout_seconds: 5, + session_timeout_ms: 120_000, + navigation_timeout_ms: 30_000, + selector_timeout_ms: 10_000, + debug: false, + health_poll_interval_ms: 5, + }, + log_sender, + None, + cookie_manager, + ); + + session.start().await.unwrap(); + + let termination = tokio::time::timeout(Duration::from_secs(1), session.wait_for_termination()) + .await + .expect("timed out waiting for Cloudflare termination"); + + assert_eq!(termination.level, "warn"); + assert!(termination.message.contains("cf-session-terminated")); + assert!(termination.message.contains("Browser session missing")); + + server.abort(); + + let requests = requests.lock().unwrap().clone(); + assert_eq!(requests.len(), 2); + assert_eq!(requests[1].method, "POST"); + assert_eq!(requests[1].path, "/sessions/cf-session-terminated/keep-alive"); + } + + fn launch_options(headless: bool, fake_media: FakeMedia) -> CloudflareLaunchOptions { + CloudflareLaunchOptions { headless, fake_media } + } + + fn launch_spec(frontend_kind: ResolvedFrontendKind, room_url: &str) -> ParticipantLaunchSpec { + ParticipantLaunchSpec { + username: "cloudflare-sim".to_owned(), + session_url: Url::parse(room_url).unwrap(), + frontend_kind, + settings: ParticipantSettings { + audio_enabled: true, + video_enabled: true, + screenshare_enabled: false, + auto_gain_control: true, + noise_suppression: NoiseSuppression::RNNoise, + transport: TransportMode::WebRTC, + resolution: WebcamResolution::P720, + blur: true, + }, + } + } + + fn worker_state_json( + joined: bool, + muted: bool, + video_activated: bool, + screenshare_activated: bool, + auto_gain_control: bool, + noise_suppression: &str, + webcam_resolution: &str, + background_blur: bool, + ) -> Value { + json!({ + "running": true, + "joined": joined, + "muted": muted, + "videoActivated": video_activated, + "screenshareActivated": screenshare_activated, + "autoGainControl": auto_gain_control, + "noiseSuppression": noise_suppression, + "transportMode": "webrtc", + "webcamResolution": webcam_resolution, + "backgroundBlur": background_blur, + }) + } + + fn expected_state( + joined: bool, + muted: bool, + video_activated: bool, + screenshare_activated: bool, + auto_gain_control: bool, + noise_suppression: NoiseSuppression, + webcam_resolution: WebcamResolution, + background_blur: bool, + ) -> ParticipantState { + ParticipantState { + username: String::new(), + running: true, + joined, + muted, + video_activated, + auto_gain_control, + noise_suppression, + transport_mode: TransportMode::WebRTC, + webcam_resolution, + background_blur, + screenshare_activated, + } + } + + fn assert_state_matches(actual: &ParticipantState, expected: &ParticipantState) { + assert_eq!(actual.username, expected.username); + assert_eq!(actual.running, expected.running); + assert_eq!(actual.joined, expected.joined); + assert_eq!(actual.muted, expected.muted); + assert_eq!(actual.video_activated, expected.video_activated); + assert_eq!(actual.auto_gain_control, expected.auto_gain_control); + assert_eq!(actual.noise_suppression, expected.noise_suppression); + assert_eq!(actual.transport_mode, expected.transport_mode); + assert_eq!(actual.webcam_resolution, expected.webcam_resolution); + assert_eq!(actual.background_blur, expected.background_blur); + assert_eq!(actual.screenshare_activated, expected.screenshare_activated); + } + + fn drain_log_messages( + log_receiver: &mut tokio::sync::mpsc::UnboundedReceiver< + crate::participant::shared::messages::ParticipantLogMessage, + >, + ) -> Vec { + let mut messages = Vec::new(); + while let Ok(message) = log_receiver.try_recv() { + messages.push(message.message); + } + messages + } + + async fn spawn_http_server( + responses: VecDeque, + ) -> (String, Arc>>, tokio::task::JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base_url = format!("http://{}", listener.local_addr().unwrap()); + let requests = Arc::new(Mutex::new(Vec::new())); + let requests_for_task = Arc::clone(&requests); + + let task = tokio::spawn(async move { + let mut responses = responses; + + while let Some(response) = responses.pop_front() { + let (mut stream, _) = listener.accept().await.unwrap(); + let request = read_request(&mut stream).await; + requests_for_task.lock().unwrap().push(request); + let reply = format!( + "HTTP/1.1 {} {}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n{}\r\n{}", + response.status, + status_text(response.status), + response.body.len(), + response.headers, + response.body, + ); + stream.write_all(reply.as_bytes()).await.unwrap(); + } + }); + + (base_url, requests, task) + } + + async fn read_request(stream: &mut tokio::net::TcpStream) -> CapturedRequest { + let mut buffer = Vec::new(); + let mut chunk = [0_u8; 4096]; + let header_end; + + loop { + let read = stream.read(&mut chunk).await.unwrap(); + assert!(read > 0, "unexpected EOF while reading request headers"); + buffer.extend_from_slice(&chunk[..read]); + + if let Some(end) = find_header_end(&buffer) { + header_end = end; + break; + } + } + + let headers_bytes = &buffer[..header_end]; + let headers_text = String::from_utf8(headers_bytes.to_vec()).unwrap(); + let mut lines = headers_text.split("\r\n"); + let request_line = lines.next().unwrap(); + let mut request_line = request_line.split_whitespace(); + let method = request_line.next().unwrap().to_owned(); + let path = request_line.next().unwrap().to_owned(); + + let mut headers = Vec::new(); + let mut content_length = 0_usize; + for line in lines.filter(|line| !line.is_empty()) { + let (name, value) = line.split_once(':').unwrap(); + let value = value.trim().to_owned(); + if name.eq_ignore_ascii_case("content-length") { + content_length = value.parse().unwrap(); + } + headers.push((name.to_ascii_lowercase(), value)); + } + + let body_start = header_end + 4; + let mut body = buffer[body_start..].to_vec(); + while body.len() < content_length { + let read = stream.read(&mut chunk).await.unwrap(); + assert!(read > 0, "unexpected EOF while reading request body"); + body.extend_from_slice(&chunk[..read]); + } + + CapturedRequest { + method, + path, + headers, + body: String::from_utf8(body).unwrap(), + } + } + + fn find_header_end(buffer: &[u8]) -> Option { + buffer.windows(4).position(|window| window == b"\r\n\r\n") + } + + fn header_value(request: &CapturedRequest, name: &str) -> Option { + request + .headers + .iter() + .find(|(header_name, _)| header_name == &name.to_ascii_lowercase()) + .map(|(_, value)| value.clone()) + } + + fn unique_temp_dir() -> PathBuf { + let nonce = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos(); + let dir = std::env::temp_dir().join(format!("hyper-browser-simulator-cloudflare-{nonce}")); + fs::create_dir_all(&dir).unwrap(); + dir + } + + fn status_text(status: u16) -> &'static str { + match status { + 200 => "OK", + 500 => "Internal Server Error", + _ => "OK", + } + } + + struct MockResponse { + status: u16, + headers: String, + body: String, + } + + impl MockResponse { + fn new(status: u16, headers: &str, body: &str) -> Self { + Self { + status, + headers: headers.to_owned(), + body: body.to_owned(), + } + } + + fn json(status: u16, body: Value) -> Self { + Self { + status, + headers: String::new(), + body: serde_json::to_string(&body).unwrap(), + } + } + } +} diff --git a/browser/src/participant/local/commands.rs b/browser/src/participant/local/commands.rs index 21a889b..2fb9bfa 100644 --- a/browser/src/participant/local/commands.rs +++ b/browser/src/participant/local/commands.rs @@ -89,6 +89,17 @@ create_eval_setter!( "function f(noiseSuppression) { hyper.settings.media.actions.setNoiseSuppression(noiseSuppression); }" ); +create_eval_getter!( + get_auto_gain_control, + "function f() { return hyper.settings.media.autoGainControl; }", + bool +); + +create_eval_setter!( + set_auto_gain_control, + "function f(value) { hyper.settings.media.actions.setAutoGainControl(value); }" +); + create_eval_getter!( get_background_blur, "function f() { return hyper.settings.media.backgroundBlur; }", diff --git a/browser/src/participant/local/core.rs b/browser/src/participant/local/core.rs index 92d137b..6c443fd 100644 --- a/browser/src/participant/local/core.rs +++ b/browser/src/participant/local/core.rs @@ -6,10 +6,12 @@ use super::{ ParticipantState, }, commands::{ + get_auto_gain_control, get_background_blur, get_force_webrtc, get_noise_suppression, get_outgoing_camera_resolution, + set_auto_gain_control, set_background_blur, set_force_webrtc, set_noise_suppression, @@ -143,6 +145,9 @@ impl ParticipantInner { async fn apply_all_settings(&self, in_lobby: bool) -> Result<()> { let settings = &self.context.launch_spec.settings; + set_auto_gain_control(&self.context.page, settings.auto_gain_control) + .await + .context("failed to set auto gain control")?; set_noise_suppression(&self.context.page, settings.noise_suppression) .await .context("failed to set noise suppression")?; @@ -244,6 +249,14 @@ impl ParticipantInner { Ok(()) } + async fn toggle_auto_gain_control_inner(&self) -> Result<()> { + let auto_gain_control = get_auto_gain_control(&self.context.page).await?; + set_auto_gain_control(&self.context.page, !auto_gain_control) + .await + .context("Failed to set auto gain control")?; + Ok(()) + } + async fn toggle_background_blur_inner(&self) -> Result<()> { let background_blur = get_background_blur(&self.context.page).await?; set_background_blur(&self.context.page, !background_blur) @@ -281,6 +294,10 @@ impl ParticipantInner { state.noise_suppression = value; } + if let Ok(value) = get_auto_gain_control(&self.context.page).await { + state.auto_gain_control = value; + } + if let Ok(mute_button) = self.mute_button().await { if let Some(value) = element_state(&mute_button).await { state.muted = !value; @@ -335,6 +352,7 @@ impl FrontendAutomation for ParticipantInner { ParticipantMessage::ToggleAudio => self.toggle_audio_inner().await, ParticipantMessage::ToggleVideo => self.toggle_video_inner().await, ParticipantMessage::ToggleScreenshare => self.toggle_screen_share_inner().await, + ParticipantMessage::ToggleAutoGainControl => self.toggle_auto_gain_control_inner().await, ParticipantMessage::SetWebcamResolutions(value) => self.set_webcam_resolutions_inner(value).await, ParticipantMessage::SetNoiseSuppression(value) => self.set_noise_suppression_inner(value).await, ParticipantMessage::ToggleBackgroundBlur => self.toggle_background_blur_inner().await, diff --git a/browser/src/participant/local/lite.rs b/browser/src/participant/local/lite.rs index 5dec1cf..331e9bc 100644 --- a/browser/src/participant/local/lite.rs +++ b/browser/src/participant/local/lite.rs @@ -5,6 +5,10 @@ use super::{ messages::ParticipantMessage, ParticipantState, }, + commands::{ + get_auto_gain_control, + set_auto_gain_control, + }, frontend::{ element_state, FrontendAutomation, @@ -90,6 +94,10 @@ impl ParticipantInnerLite { async fn apply_all_settings(&self) -> Result<()> { let settings = &self.context.launch_spec.settings; + set_auto_gain_control(&self.context.page, settings.auto_gain_control) + .await + .context("failed to set auto gain control")?; + if !settings.audio_enabled { self.toggle_audio_inner().await?; } @@ -150,6 +158,14 @@ impl ParticipantInnerLite { Ok(()) } + async fn toggle_auto_gain_control_inner(&self) -> Result<()> { + let auto_gain_control = get_auto_gain_control(&self.context.page).await?; + set_auto_gain_control(&self.context.page, !auto_gain_control) + .await + .context("Failed to set auto gain control")?; + Ok(()) + } + async fn set_webcam_resolutions_inner(&self, _value: WebcamResolution) -> Result<()> { debug!( participant = %self.participant_name(), @@ -196,12 +212,17 @@ impl ParticipantInnerLite { username: self.context.launch_spec.username.clone(), running: true, joined, + auto_gain_control: self.context.launch_spec.settings.auto_gain_control, transport_mode: TransportMode::default(), webcam_resolution: WebcamResolution::default(), noise_suppression: NoiseSuppression::default(), ..Default::default() }; + if let Ok(value) = get_auto_gain_control(&self.context.page).await { + state.auto_gain_control = value; + } + if let Ok(mute_button) = self.mute_button().await { if let Some(value) = element_state(&mute_button).await { state.muted = !value; @@ -241,6 +262,7 @@ impl FrontendAutomation for ParticipantInnerLite { ParticipantMessage::ToggleAudio => self.toggle_audio_inner().await, ParticipantMessage::ToggleVideo => self.toggle_video_inner().await, ParticipantMessage::ToggleScreenshare => self.toggle_screen_share_inner().await, + ParticipantMessage::ToggleAutoGainControl => self.toggle_auto_gain_control_inner().await, ParticipantMessage::SetWebcamResolutions(value) => self.set_webcam_resolutions_inner(value).await, ParticipantMessage::SetNoiseSuppression(value) => self.set_noise_suppression_inner(value).await, ParticipantMessage::ToggleBackgroundBlur => self.toggle_background_blur_inner().await, diff --git a/browser/src/participant/mod.rs b/browser/src/participant/mod.rs index 15024f1..c135f97 100644 --- a/browser/src/participant/mod.rs +++ b/browser/src/participant/mod.rs @@ -13,11 +13,13 @@ use crate::participant::{ run_participant_runtime, ParticipantDriverSession, ParticipantLaunchSpec, + ResolvedFrontendKind, }, }; use chrono::Utc; use client_simulator_config::{ Config, + ParticipantBackendKind, ParticipantConfig, }; use eyre::{ @@ -70,6 +72,14 @@ impl Participant { Ok(participant) } + pub fn spawn(config: &Config, cookie_manager: HyperSessionCookieManger) -> Result { + match config.backend { + ParticipantBackendKind::Local => Self::spawn_with_app_config(config, cookie_manager), + ParticipantBackendKind::Cloudflare => Self::spawn_cloudflare(config, cookie_manager), + ParticipantBackendKind::RemoteStub => Self::spawn_remote_stub(config, cookie_manager), + } + } + pub fn spawn_with_app_config_and_receiver( config: &Config, cookie_manager: HyperSessionCookieManger, @@ -140,6 +150,43 @@ impl Participant { sender, }) } + + pub fn spawn_cloudflare(config: &Config, cookie_manager: HyperSessionCookieManger) -> Result { + let session_url = config.url.clone().ok_or_eyre("No session URL provided in the config")?; + let frontend_kind = ResolvedFrontendKind::from_session_url(&session_url); + let base_url = session_url.origin().unicode_serialization(); + let cookie = matches!(frontend_kind, ResolvedFrontendKind::HyperCore) + .then(|| cookie_manager.give_cookie(&base_url)) + .flatten(); + let name = cookie.as_ref().map(BorrowedCookie::username); + let participant_config = ParticipantConfig::new(config, name)?; + let launch_spec = ParticipantLaunchSpec::from(participant_config); + let name = launch_spec.username.clone(); + + let (sender, receiver) = unbounded_channel::(); + let (log_sender, _log_receiver) = unbounded_channel::(); + let (state_receiver, task_guard) = spawn_session( + name.clone(), + receiver, + log_sender.clone(), + cloudflare::CloudflareSession::new( + launch_spec, + cloudflare::CloudflareLaunchOptions::from(config), + config.cloudflare.clone(), + log_sender, + cookie, + cookie_manager, + ), + ); + + Ok(Self { + name, + created: Utc::now(), + state: state_receiver, + _participant_task_guard: task_guard, + sender, + }) + } } fn spawn_session( @@ -255,6 +302,10 @@ impl Participant { self.toggle_screen_share(); } + pub fn toggle_auto_gain_control(&self) { + self.send_message(ParticipantMessage::ToggleAutoGainControl); + } + pub fn set_noise_suppression(&self, value: client_simulator_config::NoiseSuppression) { self.send_message(ParticipantMessage::SetNoiseSuppression(value)); } diff --git a/browser/src/participant/remote_stub.rs b/browser/src/participant/remote_stub.rs index b722a9b..00a7a63 100644 --- a/browser/src/participant/remote_stub.rs +++ b/browser/src/participant/remote_stub.rs @@ -59,6 +59,7 @@ impl ParticipantDriverSession for RemoteStubSession { joined: true, muted: !self.launch_spec.settings.audio_enabled, video_activated: self.launch_spec.settings.video_enabled, + auto_gain_control: self.launch_spec.settings.auto_gain_control, noise_suppression: self.launch_spec.settings.noise_suppression, transport_mode: self.launch_spec.settings.transport, webcam_resolution: self.launch_spec.settings.resolution, @@ -97,6 +98,10 @@ impl ParticipantDriverSession for RemoteStubSession { self.state.screenshare_activated = !self.state.screenshare_activated; self.log_message("debug", "remote stub toggled screenshare"); } + ParticipantMessage::ToggleAutoGainControl => { + self.state.auto_gain_control = !self.state.auto_gain_control; + self.log_message("debug", "remote stub toggled auto gain control"); + } ParticipantMessage::SetNoiseSuppression(value) => { self.state.noise_suppression = value; self.log_message("debug", format!("remote stub set noise suppression to {value}")); diff --git a/browser/src/participant/shared/messages.rs b/browser/src/participant/shared/messages.rs index 91ffa34..dd00abf 100644 --- a/browser/src/participant/shared/messages.rs +++ b/browser/src/participant/shared/messages.rs @@ -12,6 +12,7 @@ pub enum ParticipantMessage { ToggleAudio, ToggleVideo, ToggleScreenshare, + ToggleAutoGainControl, SetNoiseSuppression(NoiseSuppression), SetWebcamResolutions(WebcamResolution), ToggleBackgroundBlur, diff --git a/browser/src/participant/shared/mod.rs b/browser/src/participant/shared/mod.rs index 238c6df..a45056c 100644 --- a/browser/src/participant/shared/mod.rs +++ b/browser/src/participant/shared/mod.rs @@ -11,6 +11,7 @@ pub(in crate::participant) use runtime::{ }; pub(in crate::participant) use spec::{ ParticipantLaunchSpec, + ParticipantSettings, ResolvedFrontendKind, }; pub use state::ParticipantState; diff --git a/browser/src/participant/shared/runtime.rs b/browser/src/participant/shared/runtime.rs index f6f4d72..b9a3317 100644 --- a/browser/src/participant/shared/runtime.rs +++ b/browser/src/participant/shared/runtime.rs @@ -276,6 +276,7 @@ mod tests { ParticipantMessage::Close | ParticipantMessage::ToggleVideo | ParticipantMessage::ToggleScreenshare + | ParticipantMessage::ToggleAutoGainControl | ParticipantMessage::SetNoiseSuppression(_) | ParticipantMessage::SetWebcamResolutions(_) | ParticipantMessage::ToggleBackgroundBlur => {} diff --git a/browser/src/participant/shared/spec.rs b/browser/src/participant/shared/spec.rs index c61e5aa..79fcd64 100644 --- a/browser/src/participant/shared/spec.rs +++ b/browser/src/participant/shared/spec.rs @@ -28,6 +28,7 @@ pub(in crate::participant) struct ParticipantSettings { pub(in crate::participant) audio_enabled: bool, pub(in crate::participant) video_enabled: bool, pub(in crate::participant) screenshare_enabled: bool, + pub(in crate::participant) auto_gain_control: bool, pub(in crate::participant) noise_suppression: NoiseSuppression, pub(in crate::participant) transport: TransportMode, pub(in crate::participant) resolution: WebcamResolution, @@ -41,6 +42,7 @@ impl From<&ParticipantConfig> for ParticipantSettings { audio_enabled: app_config.audio_enabled, video_enabled: app_config.video_enabled, screenshare_enabled: app_config.screenshare_enabled, + auto_gain_control: app_config.auto_gain_control, noise_suppression: app_config.noise_suppression, transport: app_config.transport, resolution: app_config.resolution, @@ -100,6 +102,7 @@ mod tests { audio_enabled: true, video_enabled: false, screenshare_enabled: true, + auto_gain_control: true, noise_suppression: NoiseSuppression::RNNoise, transport: TransportMode::WebRTC, resolution: WebcamResolution::P720, @@ -116,6 +119,7 @@ mod tests { assert!(spec.settings.audio_enabled); assert!(!spec.settings.video_enabled); assert!(spec.settings.screenshare_enabled); + assert!(spec.settings.auto_gain_control); assert_eq!(spec.settings.noise_suppression, NoiseSuppression::RNNoise); assert_eq!(spec.settings.transport, TransportMode::WebRTC); assert_eq!(spec.settings.resolution, WebcamResolution::P720); diff --git a/browser/src/participant/shared/state.rs b/browser/src/participant/shared/state.rs index aa91f87..a1302c0 100644 --- a/browser/src/participant/shared/state.rs +++ b/browser/src/participant/shared/state.rs @@ -11,6 +11,7 @@ pub struct ParticipantState { pub joined: bool, pub muted: bool, pub video_activated: bool, + pub auto_gain_control: bool, pub noise_suppression: NoiseSuppression, pub transport_mode: TransportMode, pub webcam_resolution: WebcamResolution, diff --git a/browser/src/participant/shared/store.rs b/browser/src/participant/shared/store.rs index d77fa01..85bd87e 100644 --- a/browser/src/participant/shared/store.rs +++ b/browser/src/participant/shared/store.rs @@ -5,7 +5,10 @@ use crate::{ }, participant::Participant, }; -use client_simulator_config::Config; +use client_simulator_config::{ + Config, + ParticipantBackendKind, +}; use eyre::Result; use std::{ collections::HashMap, @@ -36,16 +39,22 @@ impl ParticipantStore { &self.cookies } - pub fn spawn_local(&self, config: &Config) -> Result<()> { - let participant = Participant::spawn_with_app_config(config, self.cookies.clone())?; + pub fn spawn(&self, config: &Config) -> Result<()> { + let participant = Participant::spawn(config, self.cookies.clone())?; self.add(participant); Ok(()) } + pub fn spawn_local(&self, config: &Config) -> Result<()> { + let mut config = config.clone(); + config.backend = ParticipantBackendKind::Local; + self.spawn(&config) + } + pub fn spawn_remote_stub(&self, config: &Config) -> Result<()> { - let participant = Participant::spawn_remote_stub(config, self.cookies.clone())?; - self.add(participant); - Ok(()) + let mut config = config.clone(); + config.backend = ParticipantBackendKind::RemoteStub; + self.spawn(&config) } pub fn len(&self) -> usize { @@ -58,7 +67,7 @@ impl ParticipantStore { fn sorted(&self) -> IntoIter { let mut participants = self.inner.lock().unwrap().values().cloned().collect::>(); - participants.sort_by(|a, b| a.created.cmp(&b.created)); + participants.sort_by_key(|a| a.created); participants.into_iter() } @@ -89,3 +98,52 @@ impl ParticipantStore { (index > 0).then(|| sorted[index - 1].name.clone()) } } + +#[cfg(test)] +mod tests { + use super::ParticipantStore; + use crate::participant::cloudflare::take_spawned_participants_for_test; + use client_simulator_config::{ + Config, + ParticipantBackendKind, + }; + use std::{ + fs, + path::PathBuf, + time::{ + SystemTime, + UNIX_EPOCH, + }, + }; + use url::Url; + + #[tokio::test] + async fn spawn_dispatches_cloudflare_backend_to_cloudflare_constructor() { + let _ = take_spawned_participants_for_test(); + + let data_dir = unique_test_data_dir(); + fs::create_dir_all(&data_dir).expect("create temp data dir"); + + let store = ParticipantStore::new(&data_dir); + let config = Config { + url: Some(Url::parse("https://example.com/space/demo").expect("valid url")), + backend: ParticipantBackendKind::Cloudflare, + ..Default::default() + }; + + store.spawn(&config).expect("spawn should dispatch"); + tokio::task::yield_now().await; + + let spawned = take_spawned_participants_for_test(); + assert_eq!(spawned.len(), 1); + assert_eq!(spawned, store.keys()); + } + + fn unique_test_data_dir() -> PathBuf { + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("current time") + .as_nanos(); + std::env::temp_dir().join(format!("hyper-browser-simulator-store-test-{timestamp}")) + } +} diff --git a/browser/tests/cloudflare_driver.rs b/browser/tests/cloudflare_driver.rs new file mode 100644 index 0000000..b937fdd --- /dev/null +++ b/browser/tests/cloudflare_driver.rs @@ -0,0 +1,520 @@ +use client_simulator_browser::{ + auth::HyperSessionCookieManger, + participant::{ + Participant, + ParticipantState, + }, +}; +use client_simulator_config::{ + CloudflareConfig, + Config, + ParticipantBackendKind, + WebcamResolution, +}; +use serde_json::{ + json, + Value, +}; +use std::{ + collections::VecDeque, + fs, + path::PathBuf, + sync::{ + Arc, + Mutex, + }, + time::{ + Duration, + SystemTime, + UNIX_EPOCH, + }, +}; +use tokio::{ + io::{ + AsyncReadExt as _, + AsyncWriteExt as _, + }, + net::TcpListener, + sync::watch, + time::{ + sleep, + timeout, + }, +}; + +#[tokio::test] +async fn cloudflare_runtime_updates_public_participant_state_from_worker_commands() { + let responses = VecDeque::from(vec![ + MockResponse::json( + 200, + json!({ + "ok": true, + "sessionId": "cf-runtime-commands", + "state": worker_state_json(false, false, false, "p720"), + "log": [], + }), + ), + MockResponse::json( + 200, + json!({ + "ok": true, + "sessionId": "cf-runtime-commands", + "state": worker_state_json(true, false, false, "p720"), + "log": [], + }), + ), + MockResponse::json( + 200, + json!({ + "ok": true, + "sessionId": "cf-runtime-commands", + "state": worker_state_json(true, true, false, "p720"), + "log": [], + }), + ), + MockResponse::json( + 200, + json!({ + "ok": true, + "sessionId": "cf-runtime-commands", + "state": worker_state_json(true, true, true, "p720"), + "log": [], + }), + ), + MockResponse::json( + 200, + json!({ + "ok": true, + "sessionId": "cf-runtime-commands", + "log": [], + }), + ), + ]); + let (base_url, requests, server) = spawn_http_server(responses).await; + let cookie_manager = HyperSessionCookieManger::new(unique_temp_dir().join("cookies.json")); + let participant = Participant::spawn( + &cloudflare_config(&format!("{base_url}/m/demo"), &base_url, 60_000), + cookie_manager, + ) + .expect("cloudflare participant should spawn"); + let state = participant.state.clone(); + + let started = wait_for_state(&state, |current| { + current.running && !current.joined && current.webcam_resolution == WebcamResolution::P720 + }) + .await; + assert!(started.running); + assert!(!started.joined); + + participant.join(); + let joined = wait_for_state(&state, |current| current.running && current.joined).await; + assert!(joined.joined); + assert!(!joined.muted); + + participant.toggle_audio(); + let muted = wait_for_state(&state, |current| current.running && current.joined && current.muted).await; + assert!(muted.muted); + + participant.toggle_video(); + let video_activated = wait_for_state(&state, |current| { + current.running && current.joined && current.video_activated + }) + .await; + assert!(video_activated.video_activated); + + participant.close().await; + assert!(!state.borrow().running); + + server.abort(); + + let requests = requests.lock().unwrap().clone(); + assert_eq!(requests.len(), 5); + assert_eq!(requests[0].method, "POST"); + assert_eq!(requests[0].path, "/sessions"); + assert_eq!(requests[1].path, "/sessions/cf-runtime-commands/commands"); + assert_eq!(request_json(&requests[1]), json!({ "type": "join" })); + assert_eq!(request_json(&requests[2]), json!({ "type": "toggle-audio" })); + assert_eq!(request_json(&requests[3]), json!({ "type": "toggle-video" })); + assert_eq!(requests[4].path, "/sessions/cf-runtime-commands/close"); +} + +#[tokio::test] +async fn cloudflare_runtime_survives_command_failures_and_can_still_close() { + let responses = VecDeque::from(vec![ + MockResponse::json( + 200, + json!({ + "ok": true, + "sessionId": "cf-runtime-errors", + "state": worker_state_json(false, false, false, "p720"), + "log": [], + }), + ), + MockResponse::json( + 500, + json!({ + "ok": false, + "error": "join exploded", + }), + ), + MockResponse::json( + 200, + json!({ + "ok": true, + "sessionId": "cf-runtime-errors", + "log": [], + }), + ), + ]); + let (base_url, requests, server) = spawn_http_server(responses).await; + let cookie_manager = HyperSessionCookieManger::new(unique_temp_dir().join("cookies.json")); + let participant = Participant::spawn( + &cloudflare_config(&format!("{base_url}/m/demo"), &base_url, 60_000), + cookie_manager, + ) + .expect("cloudflare participant should spawn"); + let state = participant.state.clone(); + + wait_for_state(&state, |current| { + current.running && !current.joined && current.webcam_resolution == WebcamResolution::P720 + }) + .await; + + participant.join(); + sleep(Duration::from_millis(50)).await; + + let after_failure = state.borrow().clone(); + assert!(after_failure.running); + assert!(!after_failure.joined); + + participant.close().await; + assert!(!state.borrow().running); + + server.abort(); + + let requests = requests.lock().unwrap().clone(); + assert_eq!(requests.len(), 3); + assert_eq!(requests[1].path, "/sessions/cf-runtime-errors/commands"); + assert_eq!(request_json(&requests[1]), json!({ "type": "join" })); + assert_eq!(requests[2].path, "/sessions/cf-runtime-errors/close"); +} + +#[tokio::test] +async fn cloudflare_runtime_marks_participant_stopped_when_worker_state_poll_fails() { + let responses = VecDeque::from(vec![ + MockResponse::json( + 200, + json!({ + "ok": true, + "sessionId": "cf-runtime-terminated", + "state": worker_state_json(true, false, false, "p720"), + "log": [], + }), + ), + MockResponse::json( + 500, + json!({ + "ok": false, + "error": "Browser session missing", + }), + ), + ]); + let (base_url, requests, server) = spawn_http_server(responses).await; + let cookie_manager = HyperSessionCookieManger::new(unique_temp_dir().join("cookies.json")); + let participant = Participant::spawn( + &cloudflare_config(&format!("{base_url}/m/demo"), &base_url, 5), + cookie_manager, + ) + .expect("cloudflare participant should spawn"); + let state = participant.state.clone(); + + wait_for_state(&state, |current| { + current.running && current.joined && current.webcam_resolution == WebcamResolution::P720 + }) + .await; + wait_for_state(&state, |current| !current.running).await; + + server.abort(); + + let requests = requests.lock().unwrap().clone(); + assert_eq!(requests.len(), 2); + assert_eq!(requests[0].path, "/sessions"); + assert_eq!(requests[1].method, "POST"); + assert_eq!(requests[1].path, "/sessions/cf-runtime-terminated/keep-alive"); +} + +#[tokio::test] +async fn cloudflare_runtime_fetches_hyper_core_cookie_before_creating_worker_session() { + let responses = VecDeque::from(vec![ + MockResponse::new( + 200, + "Set-Cookie: hyper_session=fetched-cookie; Path=/; HttpOnly\r\n", + "", + ), + MockResponse::json(200, json!({ "ok": true })), + MockResponse::json( + 200, + json!({ + "ok": true, + "sessionId": "cf-runtime-core", + "state": { + "running": true, + "joined": true, + "muted": false, + "videoActivated": true, + "screenshareActivated": false, + "autoGainControl": true, + "noiseSuppression": "ai-coustics-sparrow-s", + "transportMode": "webrtc", + "webcamResolution": "p1080", + "backgroundBlur": false, + }, + "log": [], + }), + ), + MockResponse::json( + 200, + json!({ + "ok": true, + "sessionId": "cf-runtime-core", + "log": [], + }), + ), + ]); + let (base_url, requests, server) = spawn_http_server(responses).await; + let cookie_manager = HyperSessionCookieManger::new(unique_temp_dir().join("cookies.json")); + let participant = Participant::spawn( + &cloudflare_config(&format!("{base_url}/room/demo"), &base_url, 60_000), + cookie_manager, + ) + .expect("cloudflare participant should spawn"); + let state = participant.state.clone(); + + let started = wait_for_state(&state, |current| { + current.running + && current.joined + && current.video_activated + && current.webcam_resolution == WebcamResolution::P1080 + }) + .await; + assert!(started.running); + assert!(started.joined); + assert_eq!( + started.noise_suppression, + client_simulator_config::NoiseSuppression::AiCousticsSparrowS + ); + + participant.close().await; + assert!(!state.borrow().running); + + server.abort(); + + let requests = requests.lock().unwrap().clone(); + assert_eq!(requests.len(), 4); + assert_eq!(requests[0].method, "POST"); + assert_eq!(requests[0].path, "/api/v1/auth/guest?username=guest"); + assert_eq!(requests[1].method, "PUT"); + assert_eq!(requests[1].path, "/api/v1/auth/me/name"); + assert_eq!( + header_value(&requests[1], "cookie").as_deref(), + Some("hyper_session=fetched-cookie") + ); + + let set_name_body = request_json(&requests[1]); + let display_name = request_json(&requests[2])["displayName"].clone(); + assert_eq!(requests[2].path, "/sessions"); + assert_eq!( + request_json(&requests[2])["hyperSessionCookie"], + json!("fetched-cookie") + ); + assert_eq!(display_name, set_name_body["name"]); + assert_eq!(requests[3].path, "/sessions/cf-runtime-core/close"); +} + +fn cloudflare_config(session_url: &str, base_url: &str, health_poll_interval_ms: u64) -> Config { + let mut config = Config::default(); + config.url = Some(session_url.parse().unwrap()); + config.backend = ParticipantBackendKind::Cloudflare; + config.headless = true; + config.cloudflare = CloudflareConfig { + base_url: base_url.parse().unwrap(), + request_timeout_seconds: 5, + session_timeout_ms: 120_000, + navigation_timeout_ms: 30_000, + selector_timeout_ms: 10_000, + debug: false, + health_poll_interval_ms, + }; + config +} + +fn worker_state_json(joined: bool, muted: bool, video_activated: bool, webcam_resolution: &str) -> Value { + json!({ + "running": true, + "joined": joined, + "muted": muted, + "videoActivated": video_activated, + "screenshareActivated": false, + "autoGainControl": true, + "noiseSuppression": "none", + "transportMode": "webrtc", + "webcamResolution": webcam_resolution, + "backgroundBlur": false, + }) +} + +async fn wait_for_state(state: &watch::Receiver, mut predicate: F) -> ParticipantState +where + F: FnMut(&ParticipantState) -> bool, +{ + let mut state = state.clone(); + timeout(Duration::from_secs(1), async move { + state.wait_for(|current| predicate(current)).await.unwrap().clone() + }) + .await + .expect("timed out waiting for participant state") +} + +#[derive(Clone, Debug)] +struct CapturedRequest { + method: String, + path: String, + headers: Vec<(String, String)>, + body: String, +} + +struct MockResponse { + status: u16, + headers: String, + body: String, +} + +impl MockResponse { + fn new(status: u16, headers: &str, body: &str) -> Self { + Self { + status, + headers: headers.to_owned(), + body: body.to_owned(), + } + } + + fn json(status: u16, body: Value) -> Self { + Self { + status, + headers: String::new(), + body: serde_json::to_string(&body).unwrap(), + } + } +} + +async fn spawn_http_server( + responses: VecDeque, +) -> (String, Arc>>, tokio::task::JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base_url = format!("http://{}", listener.local_addr().unwrap()); + let requests = Arc::new(Mutex::new(Vec::new())); + let requests_for_task = Arc::clone(&requests); + + let task = tokio::spawn(async move { + let mut responses = responses; + + while let Some(response) = responses.pop_front() { + let (mut stream, _) = listener.accept().await.unwrap(); + let request = read_request(&mut stream).await; + requests_for_task.lock().unwrap().push(request); + let reply = format!( + "HTTP/1.1 {} {}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n{}\r\n{}", + response.status, + status_text(response.status), + response.body.len(), + response.headers, + response.body, + ); + stream.write_all(reply.as_bytes()).await.unwrap(); + } + }); + + (base_url, requests, task) +} + +async fn read_request(stream: &mut tokio::net::TcpStream) -> CapturedRequest { + let mut buffer = Vec::new(); + let mut chunk = [0_u8; 4096]; + let header_end; + + loop { + let read = stream.read(&mut chunk).await.unwrap(); + assert!(read > 0, "unexpected EOF while reading request headers"); + buffer.extend_from_slice(&chunk[..read]); + + if let Some(end) = find_header_end(&buffer) { + header_end = end; + break; + } + } + + let headers_bytes = &buffer[..header_end]; + let headers_text = String::from_utf8(headers_bytes.to_vec()).unwrap(); + let mut lines = headers_text.split("\r\n"); + let request_line = lines.next().unwrap(); + let mut request_line = request_line.split_whitespace(); + let method = request_line.next().unwrap().to_owned(); + let path = request_line.next().unwrap().to_owned(); + + let mut headers = Vec::new(); + let mut content_length = 0_usize; + for line in lines.filter(|line| !line.is_empty()) { + let (name, value) = line.split_once(':').unwrap(); + let value = value.trim().to_owned(); + if name.eq_ignore_ascii_case("content-length") { + content_length = value.parse().unwrap(); + } + headers.push((name.to_ascii_lowercase(), value)); + } + + let body_start = header_end + 4; + let mut body = buffer[body_start..].to_vec(); + while body.len() < content_length { + let read = stream.read(&mut chunk).await.unwrap(); + assert!(read > 0, "unexpected EOF while reading request body"); + body.extend_from_slice(&chunk[..read]); + } + + CapturedRequest { + method, + path, + headers, + body: String::from_utf8(body).unwrap(), + } +} + +fn find_header_end(buffer: &[u8]) -> Option { + buffer.windows(4).position(|window| window == b"\r\n\r\n") +} + +fn header_value(request: &CapturedRequest, name: &str) -> Option { + request + .headers + .iter() + .find(|(header_name, _)| header_name == &name.to_ascii_lowercase()) + .map(|(_, value)| value.clone()) +} + +fn request_json(request: &CapturedRequest) -> Value { + serde_json::from_str(&request.body).unwrap() +} + +fn unique_temp_dir() -> PathBuf { + let nonce = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos(); + let dir = std::env::temp_dir().join(format!("hyper-browser-simulator-cloudflare-it-{nonce}")); + fs::create_dir_all(&dir).unwrap(); + dir +} + +fn status_text(status: u16) -> &'static str { + match status { + 200 => "OK", + 500 => "Internal Server Error", + _ => "OK", + } +} diff --git a/cloudflare-worker-client/Cargo.toml b/cloudflare-worker-client/Cargo.toml new file mode 100644 index 0000000..b787b89 --- /dev/null +++ b/cloudflare-worker-client/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "cloudflare-worker-client" +version.workspace = true +authors.workspace = true +edition.workspace = true +repository.workspace = true +build = "build.rs" + +[dependencies] +chrono.workspace = true +eyre.workspace = true +serde.workspace = true +serde_json.workspace = true +url.workspace = true + +progenitor-client = "0.13.0" +reqwest = { version = "0.13.2", default-features = false, features = ["json", "query", "rustls", "stream"] } + +[build-dependencies] +openapiv3 = "2.2.0" +prettyplease = "0.2.37" +progenitor = "0.13.0" +serde_json.workspace = true +syn = "2.0.106" + +[dev-dependencies] +tokio = { workspace = true, features = ["io-util", "net", "time"] } + +[lints] +workspace = true diff --git a/cloudflare-worker-client/build.rs b/cloudflare-worker-client/build.rs new file mode 100644 index 0000000..186ca9f --- /dev/null +++ b/cloudflare-worker-client/build.rs @@ -0,0 +1,26 @@ +use std::{ + env, + fs, + path::PathBuf, +}; + +fn main() { + let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("missing CARGO_MANIFEST_DIR")); + let spec_path = manifest_dir.join("openapi/cloudflare-browser-simulator.json"); + + println!("cargo:rerun-if-changed={}", spec_path.display()); + + let spec = + serde_json::from_slice::(&fs::read(&spec_path).expect("failed to read OpenAPI spec")) + .expect("failed to parse OpenAPI spec"); + + let mut generator = progenitor::Generator::default(); + let tokens = generator + .generate_tokens(&spec) + .expect("failed to generate Rust client from OpenAPI spec"); + let ast = syn::parse2(tokens).expect("failed to parse generated Rust client"); + let content = prettyplease::unparse(&ast); + + let out_path = PathBuf::from(env::var("OUT_DIR").expect("missing OUT_DIR")).join("worker_api.rs"); + fs::write(out_path, content).expect("failed to write generated Rust client"); +} diff --git a/cloudflare-worker-client/openapi/cloudflare-browser-simulator.json b/cloudflare-worker-client/openapi/cloudflare-browser-simulator.json new file mode 100644 index 0000000..53a07ef --- /dev/null +++ b/cloudflare-worker-client/openapi/cloudflare-browser-simulator.json @@ -0,0 +1,1314 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "cloudflare-browser-simulator", + "version": "0.3.0", + "description": "Cloudflare Worker that launches Browser Rendering sessions, joins Hyper rooms, and exposes its API contract from code." + }, + "components": { + "schemas": { + "HealthzResponse": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "enum": [ + true + ] + } + }, + "required": [ + "ok" + ] + }, + "SessionsResponse": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "enum": [ + true + ] + }, + "summary": { + "$ref": "#/components/schemas/SessionSummary" + }, + "sessions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Session" + } + } + }, + "required": [ + "ok", + "summary", + "sessions" + ] + }, + "SessionSummary": { + "type": "object", + "properties": { + "openSessions": { + "type": "number" + }, + "connectedSessions": { + "type": "number" + }, + "idleSessions": { + "type": "number" + }, + "activeSessions": { + "type": "number" + }, + "maxConcurrentSessions": { + "type": "number" + }, + "allowedBrowserAcquisitions": { + "type": "number" + }, + "timeUntilNextAllowedBrowserAcquisition": { + "type": "number" + } + }, + "required": [ + "openSessions", + "connectedSessions", + "idleSessions", + "activeSessions", + "maxConcurrentSessions", + "allowedBrowserAcquisitions", + "timeUntilNextAllowedBrowserAcquisition" + ] + }, + "Session": { + "type": "object", + "properties": { + "sessionId": { + "type": "string" + }, + "startTime": { + "type": "number" + }, + "connectionId": { + "type": "string" + }, + "connectionStartTime": { + "type": "number" + }, + "isActive": { + "type": "boolean" + } + }, + "required": [ + "sessionId", + "startTime", + "isActive" + ] + }, + "LimitsResponse": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "enum": [ + true + ] + }, + "limits": { + "$ref": "#/components/schemas/Limits" + }, + "docs": { + "$ref": "#/components/schemas/LimitsDocs" + } + }, + "required": [ + "ok", + "limits", + "docs" + ] + }, + "Limits": { + "type": "object", + "properties": { + "activeSessions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ActiveSessionRef" + } + }, + "maxConcurrentSessions": { + "type": "number" + }, + "allowedBrowserAcquisitions": { + "type": "number" + }, + "timeUntilNextAllowedBrowserAcquisition": { + "type": "number" + } + }, + "required": [ + "activeSessions", + "maxConcurrentSessions", + "allowedBrowserAcquisitions", + "timeUntilNextAllowedBrowserAcquisition" + ] + }, + "ActiveSessionRef": { + "type": "object", + "properties": { + "id": { + "type": "string" + } + }, + "required": [ + "id" + ] + }, + "LimitsDocs": { + "type": "object", + "properties": { + "activeSessions": { + "type": "string" + }, + "maxConcurrentSessions": { + "type": "string" + }, + "allowedBrowserAcquisitions": { + "type": "string" + }, + "timeUntilNextAllowedBrowserAcquisition": { + "type": "string" + } + }, + "required": [ + "activeSessions", + "maxConcurrentSessions", + "allowedBrowserAcquisitions", + "timeUntilNextAllowedBrowserAcquisition" + ] + }, + "SessionCreateResponse": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "enum": [ + true + ] + }, + "sessionId": { + "type": "string" + }, + "state": { + "$ref": "#/components/schemas/ParticipantState" + }, + "log": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AutomationLogEntry" + } + } + }, + "required": [ + "ok", + "sessionId", + "state", + "log" + ] + }, + "ParticipantState": { + "type": "object", + "properties": { + "running": { + "type": "boolean" + }, + "joined": { + "type": "boolean" + }, + "muted": { + "type": "boolean" + }, + "videoActivated": { + "type": "boolean" + }, + "screenshareActivated": { + "type": "boolean" + }, + "autoGainControl": { + "type": "boolean" + }, + "noiseSuppression": { + "type": "string", + "enum": [ + "none", + "deepfilternet", + "rnnoise", + "iris-carthy", + "krisp-high", + "krisp-medium", + "krisp-low", + "krisp-high-with-bvc", + "krisp-medium-with-bvc", + "ai-coustics-sparrow-xxs", + "ai-coustics-sparrow-xs", + "ai-coustics-sparrow-s", + "ai-coustics-sparrow-l" + ] + }, + "transportMode": { + "type": "string", + "enum": [ + "webrtc", + "webtransport" + ] + }, + "webcamResolution": { + "type": "string", + "enum": [ + "auto", + "p144", + "p240", + "p360", + "p480", + "p720", + "p1080", + "p1440", + "p2160", + "p4320" + ] + }, + "backgroundBlur": { + "type": "boolean" + } + }, + "required": [ + "running", + "joined", + "muted", + "videoActivated", + "screenshareActivated", + "autoGainControl", + "noiseSuppression", + "transportMode", + "webcamResolution", + "backgroundBlur" + ] + }, + "AutomationLogEntry": { + "type": "object", + "properties": { + "at": { + "type": "string", + "format": "date-time" + }, + "step": { + "type": "string" + } + }, + "required": [ + "at", + "step" + ] + }, + "ApiErrorResponse": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [ + false + ] + }, + "errors": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ValidationErrorItem" + } + }, + "ok": { + "type": "boolean", + "enum": [ + false + ] + }, + "sessionId": { + "type": "string" + }, + "error": { + "type": "string" + }, + "log": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AutomationLogEntry" + } + }, + "screenshot": { + "$ref": "#/components/schemas/Screenshot" + } + } + }, + "ValidationErrorItem": { + "type": "object", + "properties": { + "code": { + "type": "number" + }, + "message": { + "type": "string" + }, + "path": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "code", + "message", + "path" + ] + }, + "Screenshot": { + "type": "object", + "properties": { + "mimeType": { + "type": "string" + }, + "base64": { + "type": "string" + } + }, + "required": [ + "mimeType", + "base64" + ] + }, + "SessionCreateRequest": { + "type": "object", + "properties": { + "roomUrl": { + "type": "string", + "format": "uri", + "description": "Hyper room URL to join." + }, + "displayName": { + "type": "string", + "minLength": 1, + "description": "Display name shown in the room." + }, + "frontendKind": { + "type": "string", + "enum": [ + "hyper-core", + "hyper-lite" + ] + }, + "hyperSessionCookie": { + "type": "string", + "minLength": 1, + "description": "Optional Hyper Core `hyper_session` cookie value to inject before navigation." + }, + "settings": { + "$ref": "#/components/schemas/ParticipantSettings" + }, + "sessionTimeoutMs": { + "type": "number", + "description": "Desired Browser Rendering inactivity timeout (`keep_alive`) in milliseconds." + }, + "navigationTimeoutMs": { + "type": "number", + "description": "Navigation timeout in milliseconds." + }, + "selectorTimeoutMs": { + "type": "number", + "description": "Selector timeout in milliseconds." + }, + "debug": { + "type": "boolean", + "description": "Include a debug screenshot in 500 responses." + } + }, + "required": [ + "roomUrl", + "displayName", + "frontendKind", + "settings" + ] + }, + "ParticipantSettings": { + "type": "object", + "properties": { + "audioEnabled": { + "type": "boolean", + "description": "Whether the participant should join with audio enabled." + }, + "videoEnabled": { + "type": "boolean", + "description": "Whether the participant should join with video enabled." + }, + "screenshareEnabled": { + "type": "boolean", + "description": "Whether the participant should start screensharing after join." + }, + "autoGainControl": { + "type": "boolean", + "description": "Whether Hyper should automatically adjust microphone volume." + }, + "noiseSuppression": { + "type": "string", + "enum": [ + "none", + "deepfilternet", + "rnnoise", + "iris-carthy", + "krisp-high", + "krisp-medium", + "krisp-low", + "krisp-high-with-bvc", + "krisp-medium-with-bvc", + "ai-coustics-sparrow-xxs", + "ai-coustics-sparrow-xs", + "ai-coustics-sparrow-s", + "ai-coustics-sparrow-l" + ] + }, + "transport": { + "type": "string", + "enum": [ + "webrtc", + "webtransport" + ] + }, + "resolution": { + "type": "string", + "enum": [ + "auto", + "p144", + "p240", + "p360", + "p480", + "p720", + "p1080", + "p1440", + "p2160", + "p4320" + ] + }, + "blur": { + "type": "boolean", + "description": "Whether background blur should be enabled." + } + }, + "required": [ + "audioEnabled", + "videoEnabled", + "screenshareEnabled", + "autoGainControl", + "noiseSuppression", + "transport", + "resolution", + "blur" + ] + }, + "SessionBatchCloseResponse": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "enum": [ + true + ] + }, + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SessionBatchCloseResult" + } + }, + "summary": { + "$ref": "#/components/schemas/SessionBatchCloseSummary" + } + }, + "required": [ + "ok", + "results", + "summary" + ] + }, + "SessionBatchCloseResult": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "sessionId": { + "type": "string" + }, + "error": { + "type": "string" + }, + "log": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AutomationLogEntry" + } + } + }, + "required": [ + "ok", + "sessionId", + "log" + ] + }, + "SessionBatchCloseSummary": { + "type": "object", + "properties": { + "requestedSessions": { + "type": "number" + }, + "closedSessions": { + "type": "number" + }, + "failedSessions": { + "type": "number" + } + }, + "required": [ + "requestedSessions", + "closedSessions", + "failedSessions" + ] + }, + "SessionBatchCloseRequest": { + "type": "object", + "properties": { + "sessionIds": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "description": "Cloudflare Browser Rendering session ID." + }, + "minItems": 1, + "description": "Browser Rendering session IDs to close in a single request." + } + }, + "required": [ + "sessionIds" + ] + }, + "SessionCommandResponse": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "enum": [ + true + ] + }, + "sessionId": { + "type": "string" + }, + "state": { + "$ref": "#/components/schemas/ParticipantState" + }, + "log": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AutomationLogEntry" + } + } + }, + "required": [ + "ok", + "sessionId", + "state", + "log" + ] + }, + "SessionCommandRequest": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "join" + ] + } + }, + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "leave" + ] + } + }, + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "toggle-audio" + ] + } + }, + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "toggle-video" + ] + } + }, + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "toggle-screenshare" + ] + } + }, + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "toggle-auto-gain-control" + ] + } + }, + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "set-noise-suppression" + ] + }, + "noiseSuppression": { + "type": "string", + "enum": [ + "none", + "deepfilternet", + "rnnoise", + "iris-carthy", + "krisp-high", + "krisp-medium", + "krisp-low", + "krisp-high-with-bvc", + "krisp-medium-with-bvc", + "ai-coustics-sparrow-xxs", + "ai-coustics-sparrow-xs", + "ai-coustics-sparrow-s", + "ai-coustics-sparrow-l" + ] + } + }, + "required": [ + "type", + "noiseSuppression" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "set-webcam-resolution" + ] + }, + "webcamResolution": { + "type": "string", + "enum": [ + "auto", + "p144", + "p240", + "p360", + "p480", + "p720", + "p1080", + "p1440", + "p2160", + "p4320" + ] + } + }, + "required": [ + "type", + "webcamResolution" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "toggle-background-blur" + ] + } + }, + "required": [ + "type" + ] + } + ] + }, + "SessionStateResponse": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "enum": [ + true + ] + }, + "sessionId": { + "type": "string" + }, + "state": { + "$ref": "#/components/schemas/ParticipantState" + }, + "log": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AutomationLogEntry" + } + } + }, + "required": [ + "ok", + "sessionId", + "state" + ] + }, + "SessionKeepAliveResponse": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "enum": [ + true + ] + }, + "sessionId": { + "type": "string" + }, + "state": { + "$ref": "#/components/schemas/ParticipantState" + }, + "log": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AutomationLogEntry" + } + } + }, + "required": [ + "ok", + "sessionId", + "state", + "log" + ] + }, + "SessionCloseResponse": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "enum": [ + true + ] + }, + "sessionId": { + "type": "string" + }, + "log": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AutomationLogEntry" + } + } + }, + "required": [ + "ok", + "sessionId", + "log" + ] + } + }, + "parameters": {} + }, + "paths": { + "": { + "get": { + "operationId": "redirectToDocs", + "tags": [ + "meta" + ], + "summary": "Redirect to the interactive API docs", + "responses": { + "302": { + "description": "Redirects to /docs." + } + } + } + }, + "/healthz": { + "get": { + "operationId": "getHealthz", + "tags": [ + "meta" + ], + "summary": "Check worker health", + "responses": { + "200": { + "description": "Simple worker health response.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HealthzResponse" + } + } + } + } + } + } + }, + "/sessions": { + "get": { + "operationId": "listSessions", + "tags": [ + "sessions" + ], + "summary": "List active browser rendering sessions", + "responses": { + "200": { + "description": "Active Cloudflare Browser Rendering sessions and a summarized count.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionsResponse" + } + } + } + } + } + }, + "post": { + "operationId": "createSession", + "tags": [ + "sessions" + ], + "summary": "Create a participant browser session", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionCreateRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Participant session created.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionCreateResponse" + } + } + } + }, + "400": { + "description": "Input validation failed.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "500": { + "description": "Session creation failed.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "501": { + "description": "Requested create-session behavior is not implemented yet.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + } + } + }, + "/limits": { + "get": { + "operationId": "getLimits", + "tags": [ + "sessions" + ], + "summary": "Show browser rendering capacity and rate limits", + "responses": { + "200": { + "description": "Current browser rendering limits and field-level notes.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LimitsResponse" + } + } + } + } + } + } + }, + "/sessions/close": { + "post": { + "operationId": "closeSessions", + "tags": [ + "sessions" + ], + "summary": "Close multiple participant browser sessions", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionBatchCloseRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Participant sessions closed.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionBatchCloseResponse" + } + } + } + }, + "400": { + "description": "Input validation failed.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "500": { + "description": "Participant session close handler failed before processing results.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + } + } + }, + "/sessions/{sessionId}/commands": { + "post": { + "operationId": "commandSession", + "tags": [ + "sessions" + ], + "summary": "Apply a runtime participant command", + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1, + "description": "Cloudflare Browser Rendering session ID." + }, + "required": true, + "description": "Cloudflare Browser Rendering session ID.", + "name": "sessionId", + "in": "path" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionCommandRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Participant command applied.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionCommandResponse" + } + } + } + }, + "400": { + "description": "Input validation failed.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "500": { + "description": "Participant command failed.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "501": { + "description": "Participant commands are not implemented yet.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + } + } + }, + "/sessions/{sessionId}/state": { + "get": { + "operationId": "getSessionState", + "tags": [ + "sessions" + ], + "summary": "Fetch the latest participant state", + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1, + "description": "Cloudflare Browser Rendering session ID." + }, + "required": true, + "description": "Cloudflare Browser Rendering session ID.", + "name": "sessionId", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Participant state fetched.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionStateResponse" + } + } + } + }, + "400": { + "description": "Input validation failed.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "500": { + "description": "Participant state lookup failed.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "501": { + "description": "Participant state scraping is not implemented yet.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + } + } + }, + "/sessions/{sessionId}/keep-alive": { + "post": { + "operationId": "keepAliveSession", + "tags": [ + "sessions" + ], + "summary": "Send a keep-alive command to a participant browser session", + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1, + "description": "Cloudflare Browser Rendering session ID." + }, + "required": true, + "description": "Cloudflare Browser Rendering session ID.", + "name": "sessionId", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Keep-alive command sent and participant state refreshed.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionKeepAliveResponse" + } + } + } + }, + "400": { + "description": "Input validation failed.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "500": { + "description": "Keep-alive failed.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + } + } + }, + "/sessions/{sessionId}/close": { + "post": { + "operationId": "closeSession", + "tags": [ + "sessions" + ], + "summary": "Close a participant browser session", + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1, + "description": "Cloudflare Browser Rendering session ID." + }, + "required": true, + "description": "Cloudflare Browser Rendering session ID.", + "name": "sessionId", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Participant session closed.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionCloseResponse" + } + } + } + }, + "400": { + "description": "Input validation failed.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "500": { + "description": "Participant session close failed.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + } + } + } + } +} \ No newline at end of file diff --git a/cloudflare-worker-client/src/client.rs b/cloudflare-worker-client/src/client.rs new file mode 100644 index 0000000..5973d4d --- /dev/null +++ b/cloudflare-worker-client/src/client.rs @@ -0,0 +1,356 @@ +use crate::generated::{ + types::{ + self, + CloseSessionSessionId, + CommandSessionSessionId, + GetSessionStateSessionId, + KeepAliveSessionSessionId, + }, + Client as ApiClient, +}; +use eyre::{ + eyre, + Result, + WrapErr, +}; +use progenitor_client::Error as ApiError; +use reqwest::{ + Method, + StatusCode, +}; +use serde::Serialize; +use std::time::Duration; +use url::Url; + +pub const LOCAL_WORKER_URL: &str = "http://127.0.0.1:8787"; +pub const DEPLOYED_WORKER_URL: &str = "https://cloudflare-browser-simulator.hyper-video.workers.dev"; + +#[derive(Clone)] +pub struct CloudflareWorkerClient { + base_url: String, + api: ApiClient, +} + +impl std::fmt::Debug for CloudflareWorkerClient { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("CloudflareWorkerClient") + .field("base_url", &self.base_url) + .finish_non_exhaustive() + } +} + +impl CloudflareWorkerClient { + pub fn new(base_url: &str, request_timeout: Duration) -> Result { + let base_url = normalize_base_url(base_url)?; + let client = reqwest::Client::builder() + .timeout(request_timeout) + .build() + .wrap_err("Failed to construct Cloudflare worker HTTP client")?; + + Ok(Self { + api: ApiClient::new_with_client(&base_url, client), + base_url, + }) + } + + pub fn base_url(&self) -> &str { + &self.base_url + } + + pub fn api(&self) -> &ApiClient { + &self.api + } + + pub async fn list_sessions(&self) -> Result { + self.api + .list_sessions() + .await + .map(|response| response.into_inner()) + .map_err(|error| api_error(error, "list worker sessions", &self.base_url, &Method::GET, "sessions")) + } + + pub async fn get_limits(&self) -> Result { + self.api + .get_limits() + .await + .map(|response| response.into_inner()) + .map_err(|error| api_error(error, "fetch worker limits", &self.base_url, &Method::GET, "limits")) + } + + pub async fn create_session(&self, request: &types::SessionCreateRequest) -> Result { + self.api + .create_session(request) + .await + .map(|response| response.into_inner()) + .map_err(|error| { + api_error( + error, + "create worker session", + &self.base_url, + &Method::POST, + "sessions", + ) + }) + } + + pub async fn command_session( + &self, + session_id: &str, + request: &types::SessionCommandRequest, + ) -> Result { + let path = format!("sessions/{session_id}/commands"); + let session_id = CommandSessionSessionId::try_from(session_id) + .map_err(|error| eyre!("Invalid worker session ID `{session_id}`: {error}"))?; + + self.api + .command_session(&session_id, request) + .await + .map(|response| response.into_inner()) + .map_err(|error| api_error(error, "apply a worker command", &self.base_url, &Method::POST, &path)) + } + + pub async fn get_session_state(&self, session_id: &str) -> Result { + let path = format!("sessions/{session_id}/state"); + let session_id = GetSessionStateSessionId::try_from(session_id) + .map_err(|error| eyre!("Invalid worker session ID `{session_id}`: {error}"))?; + + self.api + .get_session_state(&session_id) + .await + .map(|response| response.into_inner()) + .map_err(|error| api_error(error, "fetch worker state", &self.base_url, &Method::GET, &path)) + } + + pub async fn keep_alive_session(&self, session_id: &str) -> Result { + let path = format!("sessions/{session_id}/keep-alive"); + let session_id = KeepAliveSessionSessionId::try_from(session_id) + .map_err(|error| eyre!("Invalid worker session ID `{session_id}`: {error}"))?; + + self.api + .keep_alive_session(&session_id) + .await + .map(|response| response.into_inner()) + .map_err(|error| api_error(error, "send a worker keep-alive", &self.base_url, &Method::POST, &path)) + } + + pub async fn close_session(&self, session_id: &str) -> Result { + let path = format!("sessions/{session_id}/close"); + let session_id = CloseSessionSessionId::try_from(session_id) + .map_err(|error| eyre!("Invalid worker session ID `{session_id}`: {error}"))?; + + self.api + .close_session(&session_id) + .await + .map(|response| response.into_inner()) + .map_err(|error| api_error(error, "close worker session", &self.base_url, &Method::POST, &path)) + } +} + +fn normalize_base_url(base_url: &str) -> Result { + let url = Url::parse(base_url).wrap_err_with(|| format!("Invalid Cloudflare worker base URL: {base_url}"))?; + + if url.query().is_some() || url.fragment().is_some() { + return Err(eyre!( + "Invalid Cloudflare worker base URL `{base_url}`: query parameters and fragments are not supported" + )); + } + + Ok(url.to_string().trim_end_matches('/').to_owned()) +} + +fn api_error(error: ApiError, action: &str, base_url: &str, method: &Method, path: &str) -> eyre::Report +where + E: Serialize + std::fmt::Debug + Send + Sync + 'static, +{ + match error { + ApiError::ErrorResponse(response) => { + let status = response.status(); + if status == StatusCode::NOT_FOUND { + return eyre!(not_found_message(method, base_url, path)); + } + + let body = pretty_json(response.as_ref()); + eyre!("Failed to {action} from {base_url}: HTTP {status}\n{body}") + } + ApiError::InvalidResponsePayload(bytes, source) => { + let body = String::from_utf8_lossy(&bytes); + eyre!( + "Failed to {action} from {base_url}: response did not match the generated API schema: {source}\n{}\n{body}", + schema_mismatch_hint(base_url) + ) + } + ApiError::UnexpectedResponse(response) => { + let status = response.status(); + if status == StatusCode::NOT_FOUND { + eyre!(not_found_message(method, base_url, path)) + } else { + eyre!("Failed to {action} from {base_url}: unexpected HTTP {status}") + } + } + other => eyre::Report::new(other).wrap_err(format!("Failed to {action} from {base_url}")), + } +} + +fn pretty_json(value: &T) -> String +where + T: Serialize, +{ + serde_json::to_string_pretty(value).unwrap_or_else(|_| "".to_owned()) +} + +fn not_found_message(method: &Method, base_url: &str, path: &str) -> String { + let mut message = format!("The worker at {base_url} does not expose {method} /{path}."); + if base_url == DEPLOYED_WORKER_URL { + message.push_str("\nUpdate the deployed worker or point the simulator at a newer local worker instance."); + } + message +} + +fn schema_mismatch_hint(base_url: &str) -> &'static str { + if base_url == DEPLOYED_WORKER_URL { + "The deployed worker schema likely changed since this simulator build. Rebuild hyper-browser-simulator from the current source tree or point it at a matching local worker instance." + } else { + "The worker response schema likely differs from this simulator build. Rebuild hyper-browser-simulator or point it at a worker instance with a matching API schema." + } +} + +#[cfg(test)] +mod tests { + use super::CloudflareWorkerClient; + use crate::generated::types::{ + ParticipantSettings, + ParticipantSettingsNoiseSuppression, + ParticipantSettingsResolution, + ParticipantSettingsTransport, + SessionCreateRequest, + SessionCreateRequestDisplayName, + SessionCreateRequestFrontendKind, + }; + use std::time::Duration; + use tokio::{ + io::{ + AsyncReadExt as _, + AsyncWriteExt as _, + }, + net::TcpListener, + }; + + #[tokio::test] + async fn normalizes_base_urls_before_building_requests() { + let response = concat!( + r#"{"ok":true,"limits":{"activeSessions":[],"maxConcurrentSessions":2,"allowedBrowserAcquisitions":1,"timeUntilNextAllowedBrowserAcquisition":0},"docs":{"activeSessions":"active","maxConcurrentSessions":"max","allowedBrowserAcquisitions":"allowed","timeUntilNextAllowedBrowserAcquisition":"wait"}}"# + ); + let (base_url, request_task) = spawn_json_server(200, response).await; + + let client = CloudflareWorkerClient::new(&format!("{base_url}///"), Duration::from_secs(5)).unwrap(); + + assert_eq!(client.base_url(), base_url); + let _ = client.get_limits().await.unwrap(); + + let request = request_task.await.unwrap(); + assert!(request.starts_with("GET /limits HTTP/1.1\r\n"), "{request}"); + } + + #[tokio::test] + async fn translates_worker_errors_into_actionable_reports() { + let response = r#"{"ok":false,"error":"worker exploded"}"#; + let (base_url, _request_task) = spawn_json_server(500, response).await; + let client = CloudflareWorkerClient::new(&base_url, Duration::from_secs(5)).unwrap(); + + let error = client.create_session(&create_session_request()).await.unwrap_err(); + let message = error.to_string(); + + assert!(message.contains("Failed to create worker session from"), "{message}"); + assert!(message.contains("HTTP 500 Internal Server Error"), "{message}"); + assert!(message.contains("worker exploded"), "{message}"); + assert!(message.contains(&base_url), "{message}"); + } + + #[tokio::test] + async fn translates_schema_mismatches_into_rebuild_hints() { + let response = concat!( + r#"{"ok":true,"sessionId":"cf-session-123","state":{"running":true,"joined":true,"muted":false,"videoActivated":true,"screenshareActivated":false,"noiseSuppression":"future-noise-model","transportMode":"webrtc","webcamResolution":"auto","backgroundBlur":false},"log":[]}"# + ); + let (base_url, _request_task) = spawn_json_server(200, response).await; + let client = CloudflareWorkerClient::new(&base_url, Duration::from_secs(5)).unwrap(); + + let error = client.create_session(&create_session_request()).await.unwrap_err(); + let message = error.to_string(); + + assert!( + message.contains("response did not match the generated API schema"), + "{message}" + ); + assert!(message.contains("Rebuild hyper-browser-simulator"), "{message}"); + assert!(message.contains("future-noise-model"), "{message}"); + } + + fn create_session_request() -> SessionCreateRequest { + SessionCreateRequest { + debug: Some(false), + display_name: SessionCreateRequestDisplayName::try_from("Cloudflare Simulator").unwrap(), + frontend_kind: SessionCreateRequestFrontendKind::HyperCore, + hyper_session_cookie: None, + navigation_timeout_ms: Some(45_000.0), + room_url: "https://example.com/room".to_owned(), + selector_timeout_ms: Some(20_000.0), + session_timeout_ms: Some(600_000.0), + settings: ParticipantSettings { + audio_enabled: true, + auto_gain_control: true, + blur: false, + noise_suppression: ParticipantSettingsNoiseSuppression::None, + resolution: ParticipantSettingsResolution::Auto, + screenshare_enabled: false, + transport: ParticipantSettingsTransport::Webrtc, + video_enabled: true, + }, + } + } + + async fn spawn_json_server(status: u16, body: &'static str) -> (String, tokio::task::JoinHandle) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let response = http_response(status, body); + + let task = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut buffer = [0_u8; 4096]; + let mut request = Vec::new(); + + loop { + let read = stream.read(&mut buffer).await.unwrap(); + if read == 0 { + break; + } + request.extend_from_slice(&buffer[..read]); + if request.windows(4).any(|window| window == b"\r\n\r\n") { + break; + } + } + + stream.write_all(response.as_bytes()).await.unwrap(); + stream.shutdown().await.unwrap(); + + String::from_utf8(request).unwrap() + }); + + (format!("http://{address}"), task) + } + + fn http_response(status: u16, body: &str) -> String { + format!( + "HTTP/1.1 {status} {}\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}", + reason_phrase(status), + body.len() + ) + } + + fn reason_phrase(status: u16) -> &'static str { + match status { + 200 => "OK", + 500 => "Internal Server Error", + _ => "Unknown", + } + } +} diff --git a/cloudflare-worker-client/src/generated.rs b/cloudflare-worker-client/src/generated.rs new file mode 100644 index 0000000..5c0e720 --- /dev/null +++ b/cloudflare-worker-client/src/generated.rs @@ -0,0 +1,4 @@ +#![allow(clippy::all)] +#![allow(dead_code)] + +include!(concat!(env!("OUT_DIR"), "/worker_api.rs")); diff --git a/cloudflare-worker-client/src/lib.rs b/cloudflare-worker-client/src/lib.rs new file mode 100644 index 0000000..e772941 --- /dev/null +++ b/cloudflare-worker-client/src/lib.rs @@ -0,0 +1,9 @@ +mod client; +pub mod generated; + +pub use client::{ + CloudflareWorkerClient, + DEPLOYED_WORKER_URL, + LOCAL_WORKER_URL, +}; +pub use generated::types; diff --git a/config/src/client_config.rs b/config/src/client_config.rs index fe46a68..e316a9f 100644 --- a/config/src/client_config.rs +++ b/config/src/client_config.rs @@ -64,6 +64,18 @@ pub enum NoiseSuppression { #[strum(to_string = "krisp-medium-with-bvc")] #[serde(rename = "krisp-medium-with-bvc")] KrispMediumWithBVC, + #[strum(to_string = "ai-coustics-sparrow-xxs")] + #[serde(rename = "ai-coustics-sparrow-xxs")] + AiCousticsSparrowXxs, + #[strum(to_string = "ai-coustics-sparrow-xs")] + #[serde(rename = "ai-coustics-sparrow-xs")] + AiCousticsSparrowXs, + #[strum(to_string = "ai-coustics-sparrow-s")] + #[serde(rename = "ai-coustics-sparrow-s")] + AiCousticsSparrowS, + #[strum(to_string = "ai-coustics-sparrow-l")] + #[serde(rename = "ai-coustics-sparrow-l")] + AiCousticsSparrowL, } #[derive(Debug, Default, Clone, Copy, Display, EnumIter, EnumString, Serialize, Deserialize, PartialEq, Eq)] @@ -72,6 +84,7 @@ pub enum NoiseSuppression { pub enum ParticipantBackendKind { #[default] Local, + Cloudflare, RemoteStub, } diff --git a/config/src/cloudflare_config.rs b/config/src/cloudflare_config.rs new file mode 100644 index 0000000..0a6545f --- /dev/null +++ b/config/src/cloudflare_config.rs @@ -0,0 +1,37 @@ +use serde::{ + Deserialize, + Serialize, +}; + +const DEFAULT_BASE_URL: &str = "https://cloudflare-browser-simulator.hyper-video.workers.dev"; + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct CloudflareConfig { + pub base_url: url::Url, + pub request_timeout_seconds: u64, + pub session_timeout_ms: u64, + pub navigation_timeout_ms: u64, + pub selector_timeout_ms: u64, + pub debug: bool, + pub health_poll_interval_ms: u64, +} + +impl CloudflareConfig { + pub fn is_default(&self) -> bool { + self == &Self::default() + } +} + +impl Default for CloudflareConfig { + fn default() -> Self { + Self { + base_url: url::Url::parse(DEFAULT_BASE_URL).expect("valid Cloudflare worker base URL"), + request_timeout_seconds: 30, + session_timeout_ms: 600_000, + navigation_timeout_ms: 45_000, + selector_timeout_ms: 20_000, + debug: false, + health_poll_interval_ms: 5_000, + } + } +} diff --git a/config/src/default-config.yaml b/config/src/default-config.yaml index b08a1bf..d6f7a54 100644 --- a/config/src/default-config.yaml +++ b/config/src/default-config.yaml @@ -25,8 +25,17 @@ fake_media_sources: fake_media: 'https://audio-samples.hyper.video/steve-jobs-british-radio-bg-50.mp3' headless: false backend: local +cloudflare: + base_url: https://cloudflare-browser-simulator.hyper-video.workers.dev + request_timeout_seconds: 30 + session_timeout_ms: 600000 + navigation_timeout_ms: 45000 + selector_timeout_ms: 20000 + debug: false + health_poll_interval_ms: 5000 audio_enabled: true video_enabled: true +auto_gain_control: true noise_suppression: none transport: webtransport resolution: auto diff --git a/config/src/lib.rs b/config/src/lib.rs index be4503c..408b5a8 100644 --- a/config/src/lib.rs +++ b/config/src/lib.rs @@ -5,6 +5,7 @@ mod app_config; mod args; mod browser_config; mod client_config; +mod cloudflare_config; pub mod media; mod participant_config; @@ -28,6 +29,7 @@ pub use client_config::{ WebcamResolution, WebcamResolutionIter, }; +pub use cloudflare_config::CloudflareConfig; use color_eyre::Result; use eyre::Context as _; pub use participant_config::{ @@ -57,12 +59,16 @@ pub struct Config { pub headless: bool, #[serde(default, skip_serializing_if = "ParticipantBackendKind::is_local")] pub backend: ParticipantBackendKind, + #[serde(default, skip_serializing_if = "CloudflareConfig::is_default")] + pub cloudflare: CloudflareConfig, #[serde(default)] pub audio_enabled: bool, #[serde(default)] pub video_enabled: bool, #[serde(default)] pub screenshare_enabled: bool, + #[serde(default = "default_auto_gain_control")] + pub auto_gain_control: bool, #[serde(default)] pub noise_suppression: NoiseSuppression, #[serde(default)] @@ -75,6 +81,10 @@ pub struct Config { const DEFAULT_CONFIG: &str = include_str!("default-config.yaml"); +const fn default_auto_gain_control() -> bool { + true +} + impl Default for Config { fn default() -> Self { serde_yml::from_str(DEFAULT_CONFIG).expect("Failed to parse default config") @@ -89,6 +99,36 @@ impl config::Source for Config { fn collect(&self) -> Result, config::ConfigError> { let mut cache = HashMap::::new(); cache.insert("backend".to_string(), self.backend.to_string().into()); + if !self.cloudflare.is_default() { + cache.insert( + "cloudflare".to_string(), + config::ValueKind::Table(HashMap::from_iter([ + ("base_url".to_string(), self.cloudflare.base_url.to_string().into()), + ( + "request_timeout_seconds".to_string(), + self.cloudflare.request_timeout_seconds.into(), + ), + ( + "session_timeout_ms".to_string(), + self.cloudflare.session_timeout_ms.into(), + ), + ( + "navigation_timeout_ms".to_string(), + self.cloudflare.navigation_timeout_ms.into(), + ), + ( + "selector_timeout_ms".to_string(), + self.cloudflare.selector_timeout_ms.into(), + ), + ("debug".to_string(), self.cloudflare.debug.into()), + ( + "health_poll_interval_ms".to_string(), + self.cloudflare.health_poll_interval_ms.into(), + ), + ])) + .into(), + ); + } if let Some(url) = &self.url { cache.insert("url".to_string(), url.to_string().into()); } @@ -96,6 +136,7 @@ impl config::Source for Config { cache.insert("audio_enabled".to_string(), self.audio_enabled.into()); cache.insert("video_enabled".to_string(), self.video_enabled.into()); cache.insert("screenshare_enabled".to_string(), self.screenshare_enabled.into()); + cache.insert("auto_gain_control".to_string(), self.auto_gain_control.into()); cache.insert( "noise_suppression".to_string(), self.noise_suppression.to_string().into(), @@ -268,4 +309,40 @@ remote_url_options: ); assert_eq!(config.backend, ParticipantBackendKind::Local); } + + #[test] + fn parses_cloudflare_backend_and_nested_cloudflare_config() { + let config: Config = config::Config::builder() + .add_source(Config::default()) + .add_source(config::File::from_str( + r#" +backend: cloudflare +cloudflare: + base_url: https://worker.example.com + request_timeout_seconds: 15 + session_timeout_ms: 120000 + navigation_timeout_ms: 30000 + selector_timeout_ms: 10000 + debug: true + health_poll_interval_ms: 2000 +"#, + config::FileFormat::Yaml, + )) + .build() + .expect("failed to build config") + .try_deserialize() + .expect("failed to deserialize config"); + + assert_eq!(config.backend, ParticipantBackendKind::Cloudflare); + assert_eq!( + config.cloudflare.base_url, + url::Url::parse("https://worker.example.com").expect("valid url") + ); + assert_eq!(config.cloudflare.request_timeout_seconds, 15); + assert_eq!(config.cloudflare.session_timeout_ms, 120_000); + assert_eq!(config.cloudflare.navigation_timeout_ms, 30_000); + assert_eq!(config.cloudflare.selector_timeout_ms, 10_000); + assert!(config.cloudflare.debug); + assert_eq!(config.cloudflare.health_poll_interval_ms, 2_000); + } } diff --git a/tui/examples/join-hyper-session.rs b/tui/examples/join-hyper-session.rs index 0d60c46..0af0dbf 100644 --- a/tui/examples/join-hyper-session.rs +++ b/tui/examples/join-hyper-session.rs @@ -31,6 +31,7 @@ async fn run(Args { url }: Args) -> Result<()> { session_url: url.clone(), app_config: Config { headless: false, + auto_gain_control: true, ..Default::default() }, }, diff --git a/tui/src/tui/components/browser_start.rs b/tui/src/tui/components/browser_start.rs index 632d80f..82cb2a7 100644 --- a/tui/src/tui/components/browser_start.rs +++ b/tui/src/tui/components/browser_start.rs @@ -44,6 +44,7 @@ enum SelectedField { Mute, VideoDisable, ScreenshareDisable, + AutoGainControl, NoiseSuppression, Transport, Resolution, @@ -63,6 +64,7 @@ impl SelectedField { SelectedField::Mute => " Mute audio? to toggle. ", SelectedField::VideoDisable => " Enable video? to toggle. ", SelectedField::ScreenshareDisable => " Enable screenshare? to toggle. ", + SelectedField::AutoGainControl => " Automatically adjust volume? to toggle. ", SelectedField::NoiseSuppression => " Enable noise suppression? to select noise suppression model. ", SelectedField::Transport => " Select transport protocol. to select. ", SelectedField::Resolution => " Select resolution for video (camera). to select. ", @@ -166,6 +168,7 @@ impl Component for BrowserStart { SelectedField::Mute | SelectedField::VideoDisable | SelectedField::ScreenshareDisable + | SelectedField::AutoGainControl | SelectedField::NoiseSuppression | SelectedField::Transport | SelectedField::Resolution @@ -360,6 +363,7 @@ impl Component for BrowserStart { KeyCode::Enter if self.selected == SelectedField::Mute => Some(BrowserStartAction::Toggle), KeyCode::Enter if self.selected == SelectedField::VideoDisable => Some(BrowserStartAction::Toggle), KeyCode::Enter if self.selected == SelectedField::ScreenshareDisable => Some(BrowserStartAction::Toggle), + KeyCode::Enter if self.selected == SelectedField::AutoGainControl => Some(BrowserStartAction::Toggle), KeyCode::Enter if self.selected == SelectedField::NoiseSuppression => { Some(BrowserStartAction::StartSelectNoiseSuppression) } @@ -441,7 +445,8 @@ impl Component for BrowserStart { SelectedField::Mute => SelectedField::FakeMedia, SelectedField::VideoDisable => SelectedField::Mute, SelectedField::ScreenshareDisable => SelectedField::VideoDisable, - SelectedField::NoiseSuppression => SelectedField::ScreenshareDisable, + SelectedField::AutoGainControl => SelectedField::ScreenshareDisable, + SelectedField::NoiseSuppression => SelectedField::AutoGainControl, SelectedField::Transport => SelectedField::NoiseSuppression, SelectedField::Resolution => SelectedField::Transport, SelectedField::BackgroundBlur => SelectedField::Resolution, @@ -457,7 +462,8 @@ impl Component for BrowserStart { SelectedField::FakeMedia => SelectedField::Mute, SelectedField::Mute => SelectedField::VideoDisable, SelectedField::VideoDisable => SelectedField::ScreenshareDisable, - SelectedField::ScreenshareDisable => SelectedField::NoiseSuppression, + SelectedField::ScreenshareDisable => SelectedField::AutoGainControl, + SelectedField::AutoGainControl => SelectedField::NoiseSuppression, SelectedField::NoiseSuppression => SelectedField::Transport, SelectedField::Transport => SelectedField::Resolution, SelectedField::Resolution => SelectedField::BackgroundBlur, @@ -579,6 +585,9 @@ impl Component for BrowserStart { SelectedField::ScreenshareDisable => { self.config.screenshare_enabled = !self.config.screenshare_enabled; } + SelectedField::AutoGainControl => { + self.config.auto_gain_control = !self.config.auto_gain_control; + } SelectedField::BackgroundBlur => { self.config.blur = !self.config.blur; } @@ -596,17 +605,8 @@ impl Component for BrowserStart { return Ok(None); } - match self.config.backend { - ParticipantBackendKind::Local => { - if let Err(e) = self.participant_store.spawn_local(&self.config) { - error!(?e, "Failed to spawn local participant"); - } - } - ParticipantBackendKind::RemoteStub => { - if let Err(e) = self.participant_store.spawn_remote_stub(&self.config) { - error!(?e, "Failed to spawn remote participant stub"); - } - } + if let Err(e) = self.participant_store.spawn(&self.config) { + error!(backend = %self.config.backend, ?e, "Failed to spawn participant"); } return Ok(Some(Action::ParticipantCountChanged(self.participant_store.len()))); @@ -647,6 +647,7 @@ impl Component for BrowserStart { Constraint::Length(1), // Muted checkbox Constraint::Length(1), // Video disabled checkbox Constraint::Length(1), // Screenshare disabled checkbox + Constraint::Length(1), // Auto gain control checkbox Constraint::Length(1), // Noise suppression checkbox Constraint::Length(1), // Transport Constraint::Length(1), // Resolution @@ -667,6 +668,7 @@ impl Component for BrowserStart { "Audio enabled:", "Video enabled:", "Screenshare enabled:", + "Auto gain control:", "Noise suppression:", "Transport:", "Resolution:", @@ -737,6 +739,17 @@ impl Component for BrowserStart { frame.render_widget(widget, rows[current_row_index]); current_row_index += 1; + // --- Auto gain control --- + let widget = widgets::label_and_bool( + form_labels[current_row_index], + self.config.auto_gain_control, + max_length, + self.focused && self.selected == SelectedField::AutoGainControl, + &theme, + ); + frame.render_widget(widget, rows[current_row_index]); + current_row_index += 1; + // --- Noise suppression --- let widget = widgets::label_and_text( form_labels[current_row_index], diff --git a/tui/src/tui/components/participants.rs b/tui/src/tui/components/participants.rs index 510b227..6808c8a 100644 --- a/tui/src/tui/components/participants.rs +++ b/tui/src/tui/components/participants.rs @@ -78,8 +78,35 @@ impl Participants { } } - fn render_tick(&mut self) -> Result<()> { - Ok(()) + fn render_tick(&mut self) -> Result> { + Ok(self.reconcile_selection()) + } + + fn reconcile_selection(&mut self) -> Option { + let keys = self.participants.keys(); + + if keys.is_empty() { + self.selected = None; + self.table_state.select(None); + + return self.focused.then_some(Action::Activate(ActivateAction::BrowserStart)); + } + + if self + .selected + .as_ref() + .is_none_or(|selected| !keys.iter().any(|key| key == selected)) + { + self.selected = keys.first().cloned(); + } + + let selected_index = self + .selected + .as_ref() + .and_then(|selected| keys.iter().position(|key| key == selected)); + self.table_state.select(selected_index); + + None } fn move_up(&mut self) { @@ -92,6 +119,8 @@ impl Participants { } else { self.selected = None; } + } else { + self.selected = None; } } else { self.selected = None; @@ -106,6 +135,8 @@ impl Participants { if index < keys.len() - 1 { self.selected = keys.get(index + 1).cloned(); } + } else { + self.selected = self.participants.keys().first().cloned(); } } else { self.selected = self.participants.keys().first().cloned(); @@ -147,7 +178,7 @@ impl Component for Participants { self.focused = false; self.visible = false; } - Action::Render => self.render_tick()?, + Action::Render => return Ok(self.render_tick()?), Action::ParticipantsAction(inner) => match inner { ParticipantsAction::MoveUp => { self.move_up(); @@ -283,6 +314,13 @@ impl Component for Participants { None } + (KeyCode::Char('g'), Some(selected)) => { + if let Some(participant) = self.participants.get(selected) { + participant.toggle_auto_gain_control(); + } + None + } + (KeyCode::Char('n'), Some(_)) => Some(Action::ParticipantsAction( ParticipantsAction::StartSelectNoiseSuppression, )), @@ -313,7 +351,7 @@ impl Component for Participants { let [_, _, area] = header_and_two_main_areas(area)?; let help = if self.selected.is_some() { - " to shutdown, oin, eave, ute, ideo, creenshare, oise suppression, esolutions, lur " + " to shutdown, oin, eave, ute, ideo, creenshare, auto ain, oise suppression, esolutions, lur " } else { "" }; @@ -337,7 +375,8 @@ impl Component for Participants { "Joined", "Muted", "Video active", - "Screenshare active", + "Screenshare", + "Autogain", "Noise Suppression", "Transport", "Resolution", @@ -366,6 +405,7 @@ impl Component for Participants { let muted = format_bool(state.muted); let video = format_bool(state.video_activated); let screenshare = format_bool(state.screenshare_activated); + let auto_gain_control = format_bool(state.auto_gain_control); let noise_suppression = state.noise_suppression.to_string(); let transport_mode = state.transport_mode.to_string(); let resolution = state.webcam_resolution.to_string(); @@ -378,6 +418,7 @@ impl Component for Participants { Cell::from(muted), Cell::from(video), Cell::from(screenshare), + Cell::from(auto_gain_control), Cell::from(noise_suppression), Cell::from(transport_mode), Cell::from(resolution), @@ -404,16 +445,17 @@ impl Component for Participants { ) .widths([ Constraint::Percentage(10), // Name - Constraint::Percentage(10), // Created - Constraint::Percentage(10), // Running - Constraint::Percentage(10), // Joined - Constraint::Percentage(10), // Muted - Constraint::Percentage(10), // Video active - Constraint::Percentage(10), // Screenshare active - Constraint::Percentage(10), // Noise suppressed - Constraint::Percentage(10), // Transport mode - Constraint::Percentage(10), // Resolution - Constraint::Percentage(10), // Blur + Constraint::Percentage(8), // Created + Constraint::Percentage(7), // Running + Constraint::Percentage(7), // Joined + Constraint::Percentage(7), // Muted + Constraint::Percentage(7), // Video active + Constraint::Percentage(9), // Screenshare active + Constraint::Percentage(8), // Auto gain + Constraint::Percentage(12), // Noise suppression + Constraint::Percentage(8), // Transport mode + Constraint::Percentage(8), // Resolution + Constraint::Percentage(9), // Blur ]) .column_spacing(1); @@ -431,6 +473,87 @@ impl Component for Participants { } } +#[cfg(test)] +mod tests { + use super::Participants; + use crate::tui::{ + Action, + ActivateAction, + Component, + }; + use client_simulator_browser::participant::ParticipantStore; + use client_simulator_config::Config; + use std::{ + fs, + path::PathBuf, + time::{ + SystemTime, + UNIX_EPOCH, + }, + }; + use url::Url; + + #[tokio::test] + async fn render_selects_first_remaining_participant_after_external_removal() { + let store = participant_store(); + spawn_remote_participant(&store); + spawn_remote_participant(&store); + + let keys = store.keys(); + assert_eq!(keys.len(), 2); + + let mut component = Participants::new(store.clone()); + component.selected = Some(keys[1].clone()); + component.focused = true; + + store.remove(&keys[1]); + + let action = component.update(Action::Render).expect("render update succeeds"); + + assert_eq!(action, None); + assert_eq!(component.selected, Some(keys[0].clone())); + } + + #[tokio::test] + async fn render_returns_browser_start_when_focused_list_becomes_empty() { + let store = participant_store(); + spawn_remote_participant(&store); + + let key = store.keys().into_iter().next().expect("participant exists"); + + let mut component = Participants::new(store.clone()); + component.selected = Some(key.clone()); + component.focused = true; + + store.remove(&key); + + let action = component.update(Action::Render).expect("render update succeeds"); + + assert_eq!(action, Some(Action::Activate(ActivateAction::BrowserStart))); + assert_eq!(component.selected, None); + } + + fn spawn_remote_participant(store: &ParticipantStore) { + let mut config = Config::default(); + config.url = Some(Url::parse("https://example.com/room/demo").expect("valid url")); + store.spawn_remote_stub(&config).expect("spawn remote stub participant"); + } + + fn participant_store() -> ParticipantStore { + let data_dir = unique_test_data_dir(); + fs::create_dir_all(&data_dir).expect("create temp data dir"); + ParticipantStore::new(&data_dir) + } + + fn unique_test_data_dir() -> PathBuf { + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("current time") + .as_nanos(); + std::env::temp_dir().join(format!("hyper-browser-simulator-participants-test-{timestamp}")) + } +} + // Helper function to format duration fn format_duration(value: TimeDelta) -> String { let seconds = value.as_seconds_f32().round() as i32; diff --git a/tui/src/tui/layout.rs b/tui/src/tui/layout.rs index b338c9e..cf42bdb 100644 --- a/tui/src/tui/layout.rs +++ b/tui/src/tui/layout.rs @@ -35,7 +35,8 @@ pub(crate) fn header_and_two_main_areas(area: Rect) -> Result<[Rect; 3]> { let [header, area] = header_and_main_area(area)?; let [a, b] = *Layout::default() .direction(Direction::Vertical) - .constraints([Constraint::Max(16), Constraint::Min(0)]) + // The browser controls pane currently needs 17 rows including borders. + .constraints([Constraint::Max(17), Constraint::Min(0)]) .split(area) else { bail!("Failed to split the area"); diff --git a/tui/src/tui/widgets/list_input.rs b/tui/src/tui/widgets/list_input.rs index 5589bb1..9b0c12a 100644 --- a/tui/src/tui/widgets/list_input.rs +++ b/tui/src/tui/widgets/list_input.rs @@ -6,6 +6,7 @@ use ratatui::{ self, Block, Borders, + Clear, }, }; @@ -50,7 +51,7 @@ impl ListInput { } } - pub(crate) fn draw(&mut self, frame: &mut ratatui::Frame<'_>, _area: ratatui::prelude::Rect) -> Result<()> { + pub(crate) fn draw(&mut self, frame: &mut ratatui::Frame<'_>, area: ratatui::prelude::Rect) -> Result<()> { // Iterate through all elements in the `items` and stylize them. let items: Vec = self .items @@ -69,7 +70,7 @@ impl ListInput { .highlight_symbol("> ") .highlight_spacing(widgets::HighlightSpacing::Always); - render_popup(list, frame, &mut self.list_state, line_count); + render_popup(list, frame, area, &mut self.list_state, line_count); Ok(()) } @@ -101,8 +102,15 @@ impl ListInput { } } -fn render_popup(popup: T, frame: &mut Frame, state: &mut T::State, line_count: u16) -> Rect { - let area = crate::tui::layout::center(frame.area(), Constraint::Max(120), Constraint::Length(line_count + 2)); +fn render_popup( + popup: T, + frame: &mut Frame, + area: Rect, + state: &mut T::State, + line_count: u16, +) -> Rect { + let area = crate::tui::layout::center(area, Constraint::Max(120), Constraint::Length(line_count + 2)); + frame.render_widget(Clear, area); frame.render_stateful_widget(popup, area, state); area } diff --git a/tui/src/tui/widgets/text_input.rs b/tui/src/tui/widgets/text_input.rs index 1d5ee0a..3e075fa 100644 --- a/tui/src/tui/widgets/text_input.rs +++ b/tui/src/tui/widgets/text_input.rs @@ -32,8 +32,8 @@ impl TextInput { Self { editor } } - pub(crate) fn draw(&mut self, frame: &mut ratatui::Frame<'_>, _area: ratatui::prelude::Rect) -> Result<()> { - render_popup(&self.editor, frame); + pub(crate) fn draw(&mut self, frame: &mut ratatui::Frame<'_>, area: ratatui::prelude::Rect) -> Result<()> { + render_popup(&self.editor, frame, area); Ok(()) } @@ -46,9 +46,9 @@ impl TextInput { } } -fn render_popup(popup: impl Widget, frame: &mut Frame) -> Rect { +fn render_popup(popup: impl Widget, frame: &mut Frame, area: Rect) -> Rect { let area = layout::center( - frame.area(), + area, Constraint::Max(120), Constraint::Length(3), // top and bottom border + content );