commit 897e556bea5a8081f3bb5a42944d7e498db82266 Author: every.channel Date: Sun Feb 15 16:17:27 2026 -0500 every.channel: sanitized baseline diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..95c114d --- /dev/null +++ b/.envrc @@ -0,0 +1,2 @@ +use flake +export EVERY_CHANNEL_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" diff --git a/.forgejo/workflows/deploy-cloudflare.yml b/.forgejo/workflows/deploy-cloudflare.yml new file mode 100644 index 0000000..50ac6aa --- /dev/null +++ b/.forgejo/workflows/deploy-cloudflare.yml @@ -0,0 +1,40 @@ +name: deploy-cloudflare + +on: + push: + branches: [main] + workflow_dispatch: {} + +concurrency: + group: cloudflare-deploy-${{ forgejo.ref }} + cancel-in-progress: true + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "20" + + - uses: dtolnay/rust-toolchain@stable + with: + targets: wasm32-unknown-unknown + + - name: Build Web App (Trunk) + run: | + set -euo pipefail + cargo install trunk --locked + cd apps/tauri/ui + trunk build --release --public-url / + + - name: Deploy Worker (Wrangler) + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + run: | + set -euo pipefail + cd deploy/cloudflare-worker + npm ci + npm run deploy diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..414b9bb --- /dev/null +++ b/.gitignore @@ -0,0 +1,28 @@ +apps/tauri/resources/yt-dlp/*/venv/ +tmp/ +target/ +apps/tauri/ui/target/ +apps/tauri/ui/apps/ +apps/tauri/dist/ +apps/tauri/gen/ +.direnv/ +result +.wrangler/ +# third_party is managed as submodules for build-critical deps (iroh-live, iroh-gossip). +# Everything else under third_party is treated as local scratch space. +third_party/* +!third_party/iroh-live +!third_party/iroh-org +third_party/iroh-org/* +!third_party/iroh-org/iroh-gossip + +# Cloudflare worker local deps / builds +deploy/cloudflare-worker/node_modules/ + +# NEVER commit private keys +every_channel_ed25519 +*.pem +*.key +*.p12 +*.pfx +**/.env diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..7c6e5f7 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,24 @@ +# Agent Instructions + +This repo runs on explicit governance. Agents should operate autonomously and record decisions as ECPs. + +## Principles + +- The constitution states enduring principles, not specific technical choices. +- Technical decisions belong in ECPs. +- Use ECPs as the primary review surface for the founder. + +## Workflow + +- For any non-trivial change, draft an ECP in `evolution/proposals/` before or alongside implementation. +- Keep ECPs short, decisive, and reversible where possible. +- Prefer incremental commits; document rationale in ECPs rather than inline comments. + +## Identity and signing + +- Commits must be signed with SSH or age identities (minimum: SSH-signed commits). +- If unsure about signing configuration, pause and ask. + +## Autonomy + +- Proceed independently; ask for input only when blocked by ambiguous design or missing constraints. diff --git a/CONSTITUTION.md b/CONSTITUTION.md new file mode 100644 index 0000000..8c69107 --- /dev/null +++ b/CONSTITUTION.md @@ -0,0 +1,42 @@ +# every.channel Constitution + +1. Mission + +Make broadcast television universally reachable. + +Build a global, disaggregated network of relays that lets anyone, anywhere, watch every.channel on any device, for free. + +2. Principles + +These are non-negotiable. Amendments require explicit constitutional process. + +- **Free access.** No paywalls or tiers for viewing or participation. Donations and grants are welcome. +- **Public-first.** Broadcast spectrum is public. The network exists to expand public access and reduce artificial scarcity. +- **User sovereignty.** Nodes are user-run, user-owned, and programmable. Leaving the network must be as easy as joining it. +- **Resilient by design.** The system must tolerate takedowns, failures, and hostile pressure without losing the whole. +- **Transparent operation.** Source, protocols, and governance are public. Hidden control planes are not acceptable. +- **Composable layers.** The system is built from separable components so multiple implementations can coexist. + +3. Infrastructure + +**The project controls its own infrastructure.** CI, deployment, and secrets are defined in this repository. + +External services may be used when practical but must not create dependencies that prevent independent operation. + +4. Contributor Conduct + +- Non-trivial changes require a written proposal in `evolution/proposals/` referencing this constitution. +- Capture decisions and rationale in the repository. If it is not written down, it did not happen. +- When tradeoffs appear, prefer choices that maximize user control and network resiliency. +- Security-sensitive changes require senior contributor review. + +5. Governance + +- ECP (every.channel proposals) is the legislative process. +- Senior contributors are named in `CONTRIBUTORS.md`. +- All changes merge through pull requests. +- Constitutional amendments require a dedicated ECP quoting the affected section with explicit rationale. + +6. Origin + +This constitution implements the intent of the every.channel genesis documents. diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md new file mode 100644 index 0000000..6f35733 --- /dev/null +++ b/CONTRIBUTORS.md @@ -0,0 +1,3 @@ +# Contributors + +- Founder: founder@every.channel diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..da401d9 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,9029 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ac-ffmpeg" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd5735fde11540ea2a6e5cc93fea7fe8d8ff0f39f1630cbd21c74155d5ae6710" +dependencies = [ + "ac-ffmpeg-build", + "ac-ffmpeg-features", + "cc", + "lazy_static", + "rustc_version", +] + +[[package]] +name = "ac-ffmpeg-build" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd4d05f90fca97e6f5960cbca5154985db2b3e91592d874f0f22a8a34f53aa9" +dependencies = [ + "cfg-if", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "ac-ffmpeg-features" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6bdd0b60d57a35283db1b94d786f47857b412195943b38be68a9dbb481adb9c" +dependencies = [ + "ac-ffmpeg-build", + "cc", +] + +[[package]] +name = "acto" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "148541f13c28e3e840354ee4d6c99046c10be2c81068bbd23b9e3a38f95a917e" +dependencies = [ + "parking_lot", + "pin-project-lite", + "rustc_version", + "smol_str 0.1.24", + "sync_wrapper", + "tokio", + "tracing", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.7", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" + +[[package]] +name = "arc-swap" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ded5f9a03ac8f24d1b8a25101ee812cd32cdc8c50a4c50237de2c4915850e73" +dependencies = [ + "rustversion", +] + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "asn1-rs" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6fd5ddaf0351dff5b8da21b2fb4ff8e08ddd02857f0bf69c47639106c0fff0" +dependencies = [ + "asn1-rs-derive 0.4.0", + "asn1-rs-impl 0.1.0", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 1.0.69", +] + +[[package]] +name = "asn1-rs" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048" +dependencies = [ + "asn1-rs-derive 0.5.1", + "asn1-rs-impl 0.2.0", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "726535892e8eae7e70657b4c8ea93d26b8553afb1ce617caee529ef96d7dee6c" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", + "synstructure 0.12.6", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", + "synstructure 0.13.2", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2777730b2039ac0f95f093556e61b6d26cebed5393ca6f152717777cec3a42ed" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-compat" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1ba85bc55464dcbf728b56d97e119d673f4cf9062be330a9a26f3acf504a590" +dependencies = [ + "futures-core", + "futures-io", + "once_cell", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "async_cell" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "447ab28afbb345f5408b120702a44e5529ebf90b1796ec76e9528df8e288e6c2" +dependencies = [ + "loom", +] + +[[package]] +name = "async_io_stream" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d7b9decdf35d8908a7e3ef02f64c5e9b1695e230154c0e8de3969142d9b94c" +dependencies = [ + "futures", + "pharos", + "rustc_version", +] + +[[package]] +name = "atk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" +dependencies = [ + "atk-sys", + "glib", + "libc", +] + +[[package]] +name = "atk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "atomic-polyfill" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4" +dependencies = [ + "critical-section", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "attohttpc" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16e2cdb6d5ed835199484bb92bb8b3edd526effe995c61732580439c1a67e2e9" +dependencies = [ + "base64 0.22.1", + "http", + "log", + "url", +] + +[[package]] +name = "auto_generate_cdp" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "359220d0b9360b79d17d648d0a3ba1e792ec36bdbc227c8fd0351df3a0415704" +dependencies = [ + "convert_case 0.8.0", + "proc-macro2", + "quote", + "serde", + "serde_json", + "ureq 3.2.0", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "backon" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef" +dependencies = [ + "fastrand", + "gloo-timers", + "tokio", +] + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base32" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "022dfe9eb35f19ebbcb51e0b40a5ab759f46ad60cadf7297e0bd085afb50e076" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +dependencies = [ + "serde_core", +] + +[[package]] +name = "blake3" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2468ef7d57b3fb7e16b576e8377cdbde2320c60e1491e961d11da40fc4f02a2d" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96eb4cdd6cf1b31d671e9efe75c5d1ec614776856cefbe109ca373554a6d514f" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "brotli" +version = "8.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bumpalo" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" +dependencies = [ + "serde", +] + +[[package]] +name = "cairo-rs" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +dependencies = [ + "bitflags 2.10.0", + "cairo-sys-rs", + "glib", + "libc", + "once_cell", + "thiserror 1.0.69", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "camino" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "cargo_toml" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77" +dependencies = [ + "serde", + "toml 0.9.11+spec-1.1.0", +] + +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + +[[package]] +name = "cc" +version = "1.2.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "ccm" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae3c82e4355234767756212c570e29833699ab63e6ffd161887314cc5b43847" +dependencies = [ + "aead", + "cipher", + "ctr", + "subtle", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfb" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +dependencies = [ + "byteorder", + "fnv", + "uuid", +] + +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20", + "cipher", + "poly1305", + "zeroize", +] + +[[package]] +name = "chrono" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link 0.2.1", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout", + "zeroize", +] + +[[package]] +name = "clap" +version = "4.5.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75ca66430e33a14957acc24c5077b503e7d374151b2b4b3a10c83b4ceb4be0e" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "793207c7fa6300a0608d1080b858e5fdbe713cdc1c8db9fb17777d8a13e63df0" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "clap_lex" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" + +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror 2.0.18", +] + +[[package]] +name = "colorchoice" +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 = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "convert_case" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" + +[[package]] +name = "convert_case" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baaaa0ecca5b51987b9423ccdc971514dd8b0bb7b4060b983d3664dad3f1f89f" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "time", + "version_check", +] + +[[package]] +name = "cordyceps" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "688d7fbb8092b8de775ef2536f36c8c31f2bc4006ece2e8d8ad2d17d00ce0a2a" +dependencies = [ + "loom", + "tracing", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1" +dependencies = [ + "bitflags 2.10.0", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.10.0", + "core-foundation", + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.0-rc.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8235645834fbc6832939736ce2f2d08192652269e11010a6240f61b908a1c6" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "cssparser" +version = "0.29.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f93d03419cb5950ccfd3daf3ff1c7a36ace64609a1a8746d493df1ca0afde0fa" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "matches", + "phf 0.10.1", + "proc-macro2", + "quote", + "smallvec", + "syn 1.0.109", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.114", +] + +[[package]] +name = "ctor" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" +dependencies = [ + "quote", + "syn 2.0.114", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest 0.10.7", + "fiat-crypto 0.2.9", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek" +version = "5.0.0-pre.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f9200d1d13637f15a6acb71e758f64624048d85b31a5fdbfd8eca1e2687d0b7" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest 0.11.0-rc.3", + "fiat-crypto 0.3.0", + "rand_core 0.9.5", + "rustc_version", + "serde", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core 0.21.3", + "darling_macro 0.21.3", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.114", +] + +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.114", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core 0.20.11", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core 0.21.3", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "pem-rfc7468 0.7.0", + "zeroize", +] + +[[package]] +name = "der" +version = "0.8.0-rc.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02c1d73e9668ea6b6a28172aa55f3ebec38507131ce179051c8033b5c6037653" +dependencies = [ + "const-oid 0.10.2", + "pem-rfc7468 1.0.0", + "zeroize", +] + +[[package]] +name = "der-parser" +version = "8.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbd676fbbab537128ef0278adb5576cf363cff6aa22a7b24effe97347cfab61e" +dependencies = [ + "asn1-rs 0.5.2", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "der-parser" +version = "9.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553" +dependencies = [ + "asn1-rs 0.6.2", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "deranged" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +dependencies = [ + "powerfmt", + "serde_core", +] + +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling 0.20.11", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn 2.0.114", +] + +[[package]] +name = "derive_more" +version = "0.99.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" +dependencies = [ + "convert_case 0.4.0", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.114", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case 0.10.0", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.114", + "unicode-xid", +] + +[[package]] +name = "diatomic-waker" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab03c107fafeb3ee9f5925686dbb7a73bc76e3932abb0d2b365cb64b169cf04c" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "const-oid 0.9.6", + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.0-rc.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dac89f8a64533a9b0eaa73a68e424db0fb1fd6271c74cc0125336a05f090568d" +dependencies = [ + "block-buffer 0.11.0", + "crypto-common 0.2.0-rc.4", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + +[[package]] +name = "dispatch2" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" +dependencies = [ + "bitflags 2.10.0", + "block2", + "libc", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "dlopen2" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09b4f5f101177ff01b8ec4ecc81eead416a8aa42819a2869311b3420fa114ffa" +dependencies = [ + "libc", + "once_cell", + "winapi", +] + +[[package]] +name = "dlopen2" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" +dependencies = [ + "dlopen2_derive", + "libc", + "once_cell", + "winapi", +] + +[[package]] +name = "dlopen2_derive" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +dependencies = [ + "serde", +] + +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "ec-chopper" +version = "0.0.0" +dependencies = [ + "ac-ffmpeg", + "anyhow", + "blake3", + "ec-core", + "ec-ts", + "serde", +] + +[[package]] +name = "ec-cli" +version = "0.0.0" +dependencies = [ + "anyhow", + "blake3", + "clap", + "ec-chopper", + "ec-core", + "ec-hdhomerun", + "ec-linux-iptv", + "serde_json", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "ec-core" +version = "0.0.0" +dependencies = [ + "blake3", + "serde", + "serde_json", +] + +[[package]] +name = "ec-crypto" +version = "0.0.0" +dependencies = [ + "blake3", + "chacha20poly1305", + "ec-core", + "ed25519-dalek 2.2.0", + "hex", +] + +[[package]] +name = "ec-direct" +version = "0.0.0" +dependencies = [ + "anyhow", + "base64 0.22.1", + "bytes", + "just-webrtc", + "serde", + "serde_json", + "tokio", +] + +[[package]] +name = "ec-hdhomerun" +version = "0.0.0" +dependencies = [ + "anyhow", + "crc32fast", + "ec-core", + "hex", + "serde", + "serde_json", + "ureq 2.12.1", +] + +[[package]] +name = "ec-iroh" +version = "0.0.0" +dependencies = [ + "anyhow", + "blake3", + "bytes", + "ec-core", + "futures-lite", + "iroh", + "iroh-gossip", + "serde_json", + "tokio", +] + +[[package]] +name = "ec-linux-iptv" +version = "0.0.0" +dependencies = [ + "anyhow", + "serde", +] + +[[package]] +name = "ec-moq" +version = "0.0.0" +dependencies = [ + "anyhow", + "blake3", + "bytes", + "ec-core", + "ec-crypto", + "ec-iroh", + "hex", + "iroh", + "iroh-moq", + "moq-lite", + "serde", + "serde_json", + "tokio", + "tracing", +] + +[[package]] +name = "ec-node" +version = "0.0.0" +dependencies = [ + "anyhow", + "blake3", + "bytes", + "clap", + "ec-chopper", + "ec-core", + "ec-crypto", + "ec-direct", + "ec-hdhomerun", + "ec-iroh", + "ec-linux-iptv", + "ec-moq", + "futures-util", + "headless_chrome", + "hex", + "iroh", + "just-webrtc", + "reqwest", + "serde", + "serde_json", + "tokio", + "tokio-tungstenite", + "tracing", + "tracing-subscriber", + "urlencoding", + "which 6.0.3", +] + +[[package]] +name = "ec-tauri" +version = "0.0.0" +dependencies = [ + "anyhow", + "axum", + "blake3", + "ec-chopper", + "ec-core", + "ec-crypto", + "ec-hdhomerun", + "ec-iroh", + "ec-linux-iptv", + "ec-moq", + "hex", + "iroh", + "reqwest", + "serde", + "serde_json", + "tauri", + "tauri-build", + "tokio", + "tower-http 0.5.2", + "tracing", +] + +[[package]] +name = "ec-ts" +version = "0.0.0" +dependencies = [ + "anyhow", + "serde", + "serde-big-array", +] + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der 0.7.10", + "digest 0.10.7", + "elliptic-curve", + "rfc6979", + "signature 2.2.0", + "spki 0.7.3", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8 0.10.2", + "signature 2.2.0", +] + +[[package]] +name = "ed25519" +version = "3.0.0-rc.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d058004dae83c9cf58f3d81612d0296bbf0a52dd7d7b6afa30ab7228bb6119f" +dependencies = [ + "pkcs8 0.11.0-rc.10", + "serde", + "signature 3.0.0-rc.9", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek 4.1.3", + "ed25519 2.2.3", + "serde", + "sha2 0.10.9", + "subtle", + "zeroize", +] + +[[package]] +name = "ed25519-dalek" +version = "3.0.0-pre.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad207ed88a133091f83224265eac21109930db09bedcad05d5252f2af2de20a1" +dependencies = [ + "curve25519-dalek 5.0.0-pre.1", + "ed25519 3.0.0-rc.3", + "rand_core 0.9.5", + "serde", + "sha2 0.11.0-rc.2", + "signature 3.0.0-rc.9", + "subtle", + "zeroize", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest 0.10.7", + "ff", + "generic-array", + "group", + "hkdf", + "pem-rfc7468 0.7.0", + "pkcs8 0.10.2", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "embed-resource" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55a075fc573c64510038d7ee9abc7990635863992f83ebc52c8b433b8411a02e" +dependencies = [ + "cc", + "memchr", + "rustc_version", + "toml 0.9.11+spec-1.1.0", + "vswhom", + "winreg 0.55.0", +] + +[[package]] +name = "embed_plist" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" + +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + +[[package]] +name = "enum-as-inner" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "enum-assoc" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed8956bd5c1f0415200516e78ff07ec9e16415ade83c056c230d7b7ea0d55b7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "env_home" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89e8918065695684b2b0702da20382d5ae6065cf3327bc2d6436bd49a71ce9f3" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fastbloom" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7f34442dbe69c60fe8eaf58a8cafff81a1f278816d8ab4db255b3bef4ac3c4" +dependencies = [ + "getrandom 0.3.4", + "libm", + "rand 0.9.2", + "siphasher 1.0.2", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "fiat-crypto" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset 0.9.1", + "rustc_version", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "flate2" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b375d6465b98090a5f25b1c7703f3859783755aa9a80433b36e0379a3ec2f369" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin 0.9.8", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" +dependencies = [ + "mac", + "new_debug_unreachable", +] + +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-buffered" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8e0e1f38ec07ba4abbde21eed377082f17ccb988be9d988a5adbf4bafc118fd" +dependencies = [ + "cordyceps", + "diatomic-waker", + "futures-core", + "pin-project-lite", + "spin 0.10.0", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-concurrency" +version = "7.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175cd8cca9e1d45b87f18ffa75088f2099e3c4fe5e2f83e42de112560bea8ea6" +dependencies = [ + "fixedbitset", + "futures-core", + "futures-lite", + "pin-project", + "smallvec", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "fxhash" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" +dependencies = [ + "byteorder", +] + +[[package]] +name = "gdk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", + "once_cell", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkwayland-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69" +dependencies = [ + "gdk-sys", + "glib-sys", + "gobject-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkx11" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe" +dependencies = [ + "gdk", + "gdkx11-sys", + "gio", + "glib", + "libc", + "x11", +] + +[[package]] +name = "gdkx11-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" +dependencies = [ + "gdk-sys", + "glib-sys", + "libc", + "system-deps", + "x11", +] + +[[package]] +name = "generator" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f04ae4152da20c76fe800fa48659201d5cf627c5149ca0b707b69d7eef6cf9" +dependencies = [ + "cc", + "cfg-if", + "libc", + "log", + "rustversion", + "windows-link 0.2.1", + "windows-result 0.4.1", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[package]] +name = "getrandom" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.9.0+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] +name = "gio" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "once_cell", + "pin-project-lite", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "gio-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "winapi", +] + +[[package]] +name = "glib" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" +dependencies = [ + "bitflags 2.10.0", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "once_cell", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "glib-macros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" +dependencies = [ + "heck 0.4.1", + "proc-macro-crate 2.0.2", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "glib-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "gobject-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "gtk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" +dependencies = [ + "atk", + "cairo-rs", + "field-offset", + "futures-channel", + "gdk", + "gdk-pixbuf", + "gio", + "glib", + "gtk-sys", + "gtk3-macros", + "libc", + "pango", + "pkg-config", +] + +[[package]] +name = "gtk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" +dependencies = [ + "atk-sys", + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk3-macros" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.13.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hash32" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "headless_chrome" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "333344ecb4b6a91ddd2e6a3c4fdb54aaddfbd2c82847f9c58fe42dd88afcf08e" +dependencies = [ + "anyhow", + "auto_generate_cdp", + "base64 0.22.1", + "derive_builder", + "log", + "rand 0.9.2", + "regex", + "serde", + "serde_json", + "tempfile", + "thiserror 2.0.18", + "tungstenite 0.28.0", + "url", + "which 8.0.0", + "winreg 0.55.0", +] + +[[package]] +name = "heapless" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f" +dependencies = [ + "atomic-polyfill", + "hash32", + "rustc_version", + "serde", + "spin 0.9.8", + "stable_deref_trait", +] + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hickory-proto" +version = "0.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8a6fe56c0038198998a6f217ca4e7ef3a5e51f46163bd6dd60b5c71ca6c6502" +dependencies = [ + "async-trait", + "bytes", + "cfg-if", + "data-encoding", + "enum-as-inner", + "futures-channel", + "futures-io", + "futures-util", + "h2", + "http", + "idna", + "ipnet", + "once_cell", + "rand 0.9.2", + "ring", + "rustls", + "thiserror 2.0.18", + "tinyvec", + "tokio", + "tokio-rustls", + "tracing", + "url", +] + +[[package]] +name = "hickory-resolver" +version = "0.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc62a9a99b0bfb44d2ab95a7208ac952d31060efc16241c87eaf36406fecf87a" +dependencies = [ + "cfg-if", + "futures-util", + "hickory-proto", + "ipconfig", + "moka", + "once_cell", + "parking_lot", + "rand 0.9.2", + "resolv-conf", + "rustls", + "smallvec", + "thiserror 2.0.18", + "tokio", + "tokio-rustls", + "tracing", +] + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "html5ever" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b7410cae13cbc75623c98ac4cbfd1f0bedddf3227afc24f370cf0f50a44a11c" +dependencies = [ + "log", + "mac", + "markup5ever", + "match_token", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hybrid-array" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b41fb3dc24fe72c2e3a4685eed55917c2fb228851257f4a8f2d985da9443c3e5" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots 1.0.5", +] + +[[package]] +name = "hyper-util" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2 0.6.2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ico" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc50b891e4acf8fe0e71ef88ec43ad82ee07b3810ad09de10f1d01f072ed4b98" +dependencies = [ + "byteorder", + "png", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "identity-hash" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfdd7caa900436d8f13b2346fe10257e0c05c1f1f9e351f4f5d57c03bd5f45da" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "igd-next" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "516893339c97f6011282d5825ac94fc1c7aad5cad26bdc2d0cee068c0bf97f97" +dependencies = [ + "async-trait", + "attohttpc", + "bytes", + "futures", + "http", + "http-body-util", + "hyper", + "hyper-util", + "log", + "rand 0.9.2", + "tokio", + "url", + "xmltree", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "infer" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" +dependencies = [ + "cfb", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + +[[package]] +name = "interceptor" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4705c00485029e738bea8c9505b5ddb1486a8f3627a953e1e77e6abdf5eef90c" +dependencies = [ + "async-trait", + "bytes", + "log", + "portable-atomic", + "rand 0.8.5", + "rtcp", + "rtp", + "thiserror 1.0.69", + "tokio", + "waitgroup", + "webrtc-srtp", + "webrtc-util", +] + +[[package]] +name = "ipconfig" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b58db92f96b720de98181bbbe63c831e87005ab460c1bf306eb2622b4707997f" +dependencies = [ + "socket2 0.5.10", + "widestring", + "windows-sys 0.48.0", + "winreg 0.50.0", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "iri-string" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "iroh" +version = "0.96.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3790cc3a5ef6a89a1e30b64de54de31e692958e2dc8a37cf2831d52c76805de9" +dependencies = [ + "backon", + "bytes", + "cfg_aliases", + "data-encoding", + "derive_more 2.1.1", + "ed25519-dalek 3.0.0-pre.1", + "futures-util", + "getrandom 0.3.4", + "hickory-resolver", + "http", + "igd-next", + "iroh-base", + "iroh-metrics", + "iroh-quinn", + "iroh-quinn-proto", + "iroh-quinn-udp 0.8.0", + "iroh-relay", + "n0-error", + "n0-future", + "n0-watcher", + "netdev", + "netwatch 0.14.0", + "papaya", + "pin-project", + "pkarr", + "pkcs8 0.11.0-rc.10", + "portmapper", + "rand 0.9.2", + "reqwest", + "rustc-hash", + "rustls", + "rustls-pki-types", + "rustls-webpki", + "serde", + "smallvec", + "strum", + "swarm-discovery", + "sync_wrapper", + "time", + "tokio", + "tokio-stream", + "tokio-util", + "tracing", + "url", + "wasm-bindgen-futures", + "webpki-roots 1.0.5", +] + +[[package]] +name = "iroh-base" +version = "0.96.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4c3fc0440c8775bf2677a58550fcef7e544346add01bf1b163f9fc0cedd436e" +dependencies = [ + "curve25519-dalek 5.0.0-pre.1", + "data-encoding", + "derive_more 2.1.1", + "ed25519-dalek 3.0.0-pre.1", + "n0-error", + "rand_core 0.9.5", + "serde", + "url", + "zeroize", + "zeroize_derive", +] + +[[package]] +name = "iroh-gossip" +version = "0.96.0" +dependencies = [ + "blake3", + "bytes", + "data-encoding", + "derive_more 2.1.1", + "ed25519-dalek 3.0.0-pre.1", + "futures-concurrency", + "futures-lite", + "futures-util", + "hex", + "indexmap 2.13.0", + "iroh", + "iroh-base", + "iroh-metrics", + "irpc", + "n0-error", + "n0-future", + "postcard", + "rand 0.9.2", + "serde", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "iroh-metrics" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c946095f060e6e59b9ff30cc26c75cdb758e7fb0cde8312c89e2144654989fcb" +dependencies = [ + "iroh-metrics-derive", + "itoa", + "n0-error", + "postcard", + "ryu", + "serde", + "tracing", +] + +[[package]] +name = "iroh-metrics-derive" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab063c2bfd6c3d5a33a913d4fdb5252f140db29ec67c704f20f3da7e8f92dbf" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "iroh-moq" +version = "0.1.0" +dependencies = [ + "iroh", + "moq-lite", + "n0-error", + "n0-future", + "tokio", + "tokio-util", + "tracing", + "url", + "web-transport-iroh", +] + +[[package]] +name = "iroh-quinn" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "034ed21f34c657a123d39525d948c885aacba59508805e4dd67d71f022e7151b" +dependencies = [ + "bytes", + "cfg_aliases", + "iroh-quinn-proto", + "iroh-quinn-udp 0.8.0", + "pin-project-lite", + "rustc-hash", + "rustls", + "socket2 0.6.2", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tracing", + "web-time", +] + +[[package]] +name = "iroh-quinn-proto" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de99ad8adc878ee0e68509ad256152ce23b8bbe45f5539d04e179630aca40a9" +dependencies = [ + "bytes", + "derive_more 2.1.1", + "enum-assoc", + "fastbloom", + "getrandom 0.3.4", + "identity-hash", + "lru-slab", + "rand 0.9.2", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "slab", + "sorted-index-buffer", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "iroh-quinn-udp" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a91fe9ec3db6615d7ab1b303717f3b98fc40b96955a4ea25b113b1b879f7481f" +dependencies = [ + "cfg_aliases", + "libc", + "socket2 0.6.2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "iroh-quinn-udp" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f981dadd5a072a9e0efcd24bdcc388e570073f7e51b33505ceb1ef4668c80c86" +dependencies = [ + "cfg_aliases", + "libc", + "socket2 0.6.2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "iroh-relay" +version = "0.96.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "236c6f131ce774f7cc7548f467890c313b09f7849b8d703360d6602bc8c5184c" +dependencies = [ + "blake3", + "bytes", + "cfg_aliases", + "data-encoding", + "derive_more 2.1.1", + "getrandom 0.3.4", + "hickory-resolver", + "http", + "http-body-util", + "hyper", + "hyper-util", + "iroh-base", + "iroh-metrics", + "iroh-quinn", + "iroh-quinn-proto", + "lru", + "n0-error", + "n0-future", + "num_enum", + "pin-project", + "pkarr", + "postcard", + "rand 0.9.2", + "reqwest", + "rustls", + "rustls-pki-types", + "serde", + "serde_bytes", + "strum", + "tokio", + "tokio-rustls", + "tokio-util", + "tokio-websockets", + "tracing", + "url", + "vergen-gitcl", + "webpki-roots 1.0.5", + "ws_stream_wasm", + "z32", +] + +[[package]] +name = "irpc" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1bbc84aaeab13a6d7502bae4f40f2517b643924842e0230ea0bf807477cc208" +dependencies = [ + "futures-util", + "irpc-derive", + "n0-error", + "n0-future", + "serde", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "irpc-derive" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58148196d2230183c9679431ac99b57e172000326d664e8456fa2cd27af6505a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "javascriptcore-rs" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" +dependencies = [ + "bitflags 1.3.2", + "glib", + "javascriptcore-rs-sys", +] + +[[package]] +name = "javascriptcore-rs-sys" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" + +[[package]] +name = "js-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "json-patch" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863726d7afb6bc2590eeff7135d923545e5e964f004c2ccf8716c25e70a86f08" +dependencies = [ + "jsonptr", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "jsonptr" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dea2b27dd239b2556ed7a25ba842fe47fd602e7fc7433c2a8d6106d4d9edd70" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "just-webrtc" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb1cb0e36a34b7c6a147c374b68be2fb4ab279888e8637487da157da5f4b8a0b" +dependencies = [ + "async_cell", + "bytes", + "flume", + "js-sys", + "log", + "serde", + "serde-wasm-bindgen", + "thiserror 1.0.69", + "trait-variant", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webrtc", +] + +[[package]] +name = "keyboard-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +dependencies = [ + "bitflags 2.10.0", + "serde", + "unicode-segmentation", +] + +[[package]] +name = "kuchikiki" +version = "0.8.8-speedreader" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02cb977175687f33fa4afa0c95c112b987ea1443e5a51c8f8ff27dc618270cc2" +dependencies = [ + "cssparser", + "html5ever", + "indexmap 2.13.0", + "selectors", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libappindicator" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" +dependencies = [ + "glib", + "gtk", + "gtk-sys", + "libappindicator-sys", + "log", +] + +[[package]] +name = "libappindicator-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" +dependencies = [ + "gtk-sys", + "libloading", + "once_cell", +] + +[[package]] +name = "libc" +version = "0.2.180" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" + +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +dependencies = [ + "bitflags 2.10.0", + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "loom" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" +dependencies = [ + "cfg-if", + "generator", + "scoped-tls", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "lru" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dc47f592c06f33f8e3aea9591776ec7c9f9e4124778ff8a3c3b87159f7e593" +dependencies = [ + "hashbrown 0.16.1", +] + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "mac" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" + +[[package]] +name = "mac-addr" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3d25b0e0b648a86960ac23b7ad4abb9717601dec6f66c165f5b037f3f03065f" + +[[package]] +name = "mainline" +version = "6.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ff27d378ca495eaf3be8616d5d7319c1c18e93fd60e13698fcdc7e19448f1a4" +dependencies = [ + "crc", + "document-features", + "dyn-clone", + "ed25519-dalek 3.0.0-pre.1", + "flume", + "futures-lite", + "getrandom 0.3.4", + "lru", + "serde", + "serde_bencode", + "serde_bytes", + "sha1_smol", + "thiserror 2.0.18", + "tracing", +] + +[[package]] +name = "markup5ever" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a7213d12e1864c0f002f52c2923d4556935a43dec5e71355c2760e0f6e7a18" +dependencies = [ + "log", + "phf 0.11.3", + "phf_codegen 0.11.3", + "string_cache", + "string_cache_codegen", + "tendril", +] + +[[package]] +name = "match_token" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88a9689d8d44bf9964484516275f5cd4c9b59457a6940c1d5d0ecbb94510a36b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matches" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" + +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest 0.10.7", +] + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "memoffset" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5de893c32cde5f383baa4c04c5d6dbdd735cfd4a794b0debdb2bb1b421da5ff4" +dependencies = [ + "autocfg", +] + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.61.2", +] + +[[package]] +name = "moka" +version = "0.12.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac832c50ced444ef6be0767a008b02c106a909ba79d1d830501e94b96f6b7e" +dependencies = [ + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "equivalent", + "parking_lot", + "portable-atomic", + "smallvec", + "tagptr", + "uuid", +] + +[[package]] +name = "moq-lite" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c2258f990ddd8465f8dcf343bd00714927cd8b8b81784b153020ca24e47898f" +dependencies = [ + "async-channel", + "bytes", + "futures", + "hex", + "num_enum", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-async", + "web-transport-trait", +] + +[[package]] +name = "muda" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c1738382f66ed56b3b9c8119e794a2e23148ac8ea214eda86622d4cb9d415a" +dependencies = [ + "crossbeam-channel", + "dpi", + "gtk", + "keyboard-types", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "once_cell", + "png", + "serde", + "thiserror 2.0.18", + "windows-sys 0.60.2", +] + +[[package]] +name = "n0-error" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af4782b4baf92d686d161c15460c83d16ebcfd215918763903e9619842665cae" +dependencies = [ + "anyhow", + "n0-error-macros", + "spez", +] + +[[package]] +name = "n0-error-macros" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03755949235714b2b307e5ae89dd8c1c2531fb127d9b8b7b4adf9c876cd3ed18" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "n0-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2ab99dfb861450e68853d34ae665243a88b8c493d01ba957321a1e9b2312bbe" +dependencies = [ + "cfg_aliases", + "derive_more 2.1.1", + "futures-buffered", + "futures-lite", + "futures-util", + "js-sys", + "pin-project", + "send_wrapper", + "tokio", + "tokio-util", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-time", +] + +[[package]] +name = "n0-watcher" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba717c22ceec021ace0ff7674bf8fd60c9394605740a8201678fc1cb3a7398f6" +dependencies = [ + "derive_more 2.1.1", + "n0-error", + "n0-future", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.10.0", + "jni-sys", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys", +] + +[[package]] +name = "netdev" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc9815643a243856e7bd84524e1ff739e901e846cfb06ad9627cd2b6d59bd737" +dependencies = [ + "block2", + "dispatch2", + "dlopen2 0.5.0", + "ipnet", + "libc", + "mac-addr", + "netlink-packet-core", + "netlink-packet-route 0.25.1", + "netlink-sys", + "objc2-core-foundation", + "objc2-system-configuration", + "once_cell", + "plist", + "windows-sys 0.59.0", +] + +[[package]] +name = "netlink-packet-core" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3463cbb78394cb0141e2c926b93fc2197e473394b761986eca3b9da2c63ae0f4" +dependencies = [ + "paste", +] + +[[package]] +name = "netlink-packet-route" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ec2f5b6839be2a19d7fa5aab5bc444380f6311c2b693551cb80f45caaa7b5ef" +dependencies = [ + "bitflags 2.10.0", + "libc", + "log", + "netlink-packet-core", +] + +[[package]] +name = "netlink-packet-route" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ce3636fa715e988114552619582b530481fd5ef176a1e5c1bf024077c2c9445" +dependencies = [ + "bitflags 2.10.0", + "libc", + "log", + "netlink-packet-core", +] + +[[package]] +name = "netlink-proto" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b65d130ee111430e47eed7896ea43ca693c387f097dd97376bffafbf25812128" +dependencies = [ + "bytes", + "futures", + "log", + "netlink-packet-core", + "netlink-sys", + "thiserror 2.0.18", +] + +[[package]] +name = "netlink-sys" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd6c30ed10fa69cc491d491b85cc971f6bdeb8e7367b7cde2ee6cc878d583fae" +dependencies = [ + "bytes", + "futures-util", + "libc", + "log", + "tokio", +] + +[[package]] +name = "netwatch" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "970729c08dbe7987d698f996c6b4945cbfdcdd6ee627df6de51d5469cec13b99" +dependencies = [ + "atomic-waker", + "bytes", + "cfg_aliases", + "derive_more 2.1.1", + "iroh-quinn-udp 0.7.0", + "js-sys", + "libc", + "n0-error", + "n0-future", + "n0-watcher", + "netdev", + "netlink-packet-core", + "netlink-packet-route 0.28.0", + "netlink-proto", + "netlink-sys", + "objc2-core-foundation", + "objc2-system-configuration", + "pin-project-lite", + "serde", + "socket2 0.6.2", + "time", + "tokio", + "tokio-util", + "tracing", + "web-sys", + "windows 0.62.2", + "windows-result 0.4.1", + "wmi", +] + +[[package]] +name = "netwatch" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "454b8c0759b2097581f25ed5180b4a1d14c324fde6d0734932a288e044d06232" +dependencies = [ + "atomic-waker", + "bytes", + "cfg_aliases", + "derive_more 2.1.1", + "iroh-quinn-udp 0.8.0", + "js-sys", + "libc", + "n0-error", + "n0-future", + "n0-watcher", + "netdev", + "netlink-packet-core", + "netlink-packet-route 0.28.0", + "netlink-proto", + "netlink-sys", + "objc2-core-foundation", + "objc2-system-configuration", + "pin-project-lite", + "serde", + "socket2 0.6.2", + "time", + "tokio", + "tokio-util", + "tracing", + "web-sys", + "windows 0.62.2", + "windows-result 0.4.1", + "wmi", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nix" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b" +dependencies = [ + "bitflags 1.3.2", + "cfg-if", + "libc", + "memoffset 0.7.1", + "pin-utils", +] + +[[package]] +name = "nodrop" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "ntimestamp" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c50f94c405726d3e0095e89e72f75ce7f6587b94a8bd8dc8054b73f65c0fd68c" +dependencies = [ + "base32", + "document-features", + "getrandom 0.2.17", + "httpdate", + "js-sys", + "once_cell", + "serde", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1207a7e20ad57b847bbddc6776b968420d38292bbfe2089accff5e19e82454c" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" +dependencies = [ + "proc-macro-crate 3.4.0", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + +[[package]] +name = "objc2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05" +dependencies = [ + "objc2-encode", + "objc2-exception-helper", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.10.0", + "block2", + "libc", + "objc2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-text", + "objc2-core-video", + "objc2-foundation", + "objc2-quartz-core", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.10.0", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "bitflags 2.10.0", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.10.0", + "block2", + "dispatch2", + "libc", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.10.0", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.10.0", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-core-video" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" +dependencies = [ + "bitflags 2.10.0", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-io-surface", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-exception-helper" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a" +dependencies = [ + "cc", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.10.0", + "block2", + "libc", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.10.0", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-javascript-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a1e6550c4caed348956ce3370c9ffeca70bb1dbed4fa96112e7c6170e074586" +dependencies = [ + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.10.0", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-security" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a" +dependencies = [ + "bitflags 2.10.0", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-system-configuration" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" +dependencies = [ + "bitflags 2.10.0", + "dispatch2", + "libc", + "objc2", + "objc2-core-foundation", + "objc2-security", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.10.0", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-web-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" +dependencies = [ + "bitflags 2.10.0", + "block2", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "objc2-javascript-core", + "objc2-security", +] + +[[package]] +name = "oid-registry" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9" +dependencies = [ + "asn1-rs 0.6.2", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +dependencies = [ + "critical-section", + "portable-atomic", +] + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + +[[package]] +name = "pango" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" +dependencies = [ + "gio", + "glib", + "libc", + "once_cell", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "papaya" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f92dd0b07c53a0a0c764db2ace8c541dc47320dad97c2200c2a637ab9dd2328f" +dependencies = [ + "equivalent", + "seize", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link 0.2.1", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "pem-rfc7468" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pharos" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9567389417feee6ce15dd6527a8a1ecac205ef62c2932bcf3d9f6fc5b78b414" +dependencies = [ + "futures", + "rustc_version", +] + +[[package]] +name = "phf" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12" +dependencies = [ + "phf_shared 0.8.0", +] + +[[package]] +name = "phf" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259" +dependencies = [ + "phf_macros 0.10.0", + "phf_shared 0.10.0", + "proc-macro-hack", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros 0.11.3", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf_codegen" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbffee61585b0411840d3ece935cce9cb6321f01c45477d30066498cd5e1a815" +dependencies = [ + "phf_generator 0.8.0", + "phf_shared 0.8.0", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf_generator" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17367f0cc86f2d25802b2c26ee58a7b23faeccf78a396094c13dced0d0182526" +dependencies = [ + "phf_shared 0.8.0", + "rand 0.7.3", +] + +[[package]] +name = "phf_generator" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6" +dependencies = [ + "phf_shared 0.10.0", + "rand 0.8.5", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared 0.11.3", + "rand 0.8.5", +] + +[[package]] +name = "phf_macros" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58fdf3184dd560f160dd73922bea2d5cd6e8f064bf4b13110abd81b03697b4e0" +dependencies = [ + "phf_generator 0.10.0", + "phf_shared 0.10.0", + "proc-macro-hack", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "phf_shared" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c00cf8b9eafe68dde5e9eaa2cef8ee84a9336a47d566ec55ca16589633b65af7" +dependencies = [ + "siphasher 0.3.11", +] + +[[package]] +name = "phf_shared" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6796ad771acdc0123d2a88dc428b5e38ef24456743ddb1744ed628f9815c096" +dependencies = [ + "siphasher 0.3.11", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher 1.0.2", +] + +[[package]] +name = "pin-project" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkarr" +version = "5.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d346b545765a0ef58b6a7e160e17ddaa7427f439b7b9a287df6c88c9e04bf2" +dependencies = [ + "async-compat", + "base32", + "bytes", + "cfg_aliases", + "document-features", + "dyn-clone", + "ed25519-dalek 3.0.0-pre.1", + "futures-buffered", + "futures-lite", + "getrandom 0.3.4", + "log", + "lru", + "mainline", + "ntimestamp", + "reqwest", + "self_cell", + "serde", + "sha1_smol", + "simple-dns", + "thiserror 2.0.18", + "tokio", + "tracing", + "url", + "wasm-bindgen-futures", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der 0.7.10", + "spki 0.7.3", +] + +[[package]] +name = "pkcs8" +version = "0.11.0-rc.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b226d2cc389763951db8869584fd800cbbe2962bf454e2edeb5172b31ee99774" +dependencies = [ + "der 0.8.0-rc.10", + "spki 0.8.0-rc.4", +] + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "plist" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" +dependencies = [ + "base64 0.22.1", + "indexmap 2.13.0", + "quick-xml", + "serde", + "time", +] + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "portmapper" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f29fb522a166045a35b507dea30e3eb69bca1c5a53669d252744d5a0d8474ffa" +dependencies = [ + "base64 0.22.1", + "bytes", + "derive_more 2.1.1", + "futures-lite", + "futures-util", + "hyper-util", + "igd-next", + "iroh-metrics", + "libc", + "n0-error", + "netwatch 0.13.0", + "num_enum", + "rand 0.9.2", + "serde", + "smallvec", + "socket2 0.6.2", + "time", + "tokio", + "tokio-util", + "tower-layer", + "tracing", + "url", +] + +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "heapless", + "postcard-derive", + "serde", +] + +[[package]] +name = "postcard-derive" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0232bd009a197ceec9cc881ba46f727fcd8060a2d8d6a9dde7a69030a6fe2bb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro-crate" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" +dependencies = [ + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "proc-macro-crate" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +dependencies = [ + "toml_edit 0.23.10+spec-1.0.0", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro-hack" +version = "0.5.20+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quick-xml" +version = "0.38.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" +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 0.6.2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "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 0.6.2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03" +dependencies = [ + "getrandom 0.1.16", + "libc", + "rand_chacha 0.2.2", + "rand_core 0.5.1", + "rand_hc", + "rand_pcg", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402" +dependencies = [ + "ppv-lite86", + "rand_core 0.5.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" +dependencies = [ + "getrandom 0.1.16", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_hc" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c" +dependencies = [ + "rand_core 0.5.1", +] + +[[package]] +name = "rand_pcg" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16abd0c1b639e9eb4d7c50c0b8100b0d0f849be2349829c740fe8e6eb4816429" +dependencies = [ + "rand_core 0.5.1", +] + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "rcgen" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "x509-parser", + "yasna", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.10.0", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "regex" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "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", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http 0.6.8", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "webpki-roots 1.0.5", +] + +[[package]] +name = "resolv-conf" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rtcp" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc9f775ff89c5fe7f0cc0abafb7c57688ae25ce688f1a52dd88e277616c76ab2" +dependencies = [ + "bytes", + "thiserror 1.0.69", + "webrtc-util", +] + +[[package]] +name = "rtp" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6870f09b5db96f8b9e7290324673259fd15519ebb7d55acf8e7eb044a9ead6af" +dependencies = [ + "bytes", + "portable-atomic", + "rand 0.8.5", + "serde", + "thiserror 1.0.69", + "webrtc-util", +] + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.10.0", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +dependencies = [ + "bitflags 2.10.0", + "errno", + "libc", + "linux-raw-sys 0.11.0", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "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", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +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", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[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.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "indexmap 1.9.3", + "schemars_derive", + "serde", + "serde_json", + "url", + "uuid", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54e910108742c57a770f492731f99be216a52fadd361b06c8fb59d74ccc267d2" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.114", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sdp" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13254db766b17451aced321e7397ebf0a446ef0c8d2942b6e67a95815421093f" +dependencies = [ + "rand 0.8.5", + "substring", + "thiserror 1.0.69", + "url", +] + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der 0.7.10", + "generic-array", + "pkcs8 0.10.2", + "subtle", + "zeroize", +] + +[[package]] +name = "security-framework" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" +dependencies = [ + "bitflags 2.10.0", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "seize" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b55fb86dfd3a2f5f76ea78310a88f96c4ea21a3031f8d212443d56123fd0521" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "selectors" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c37578180969d00692904465fb7f6b3d50b9a2b952b87c23d0e2e5cb5013416" +dependencies = [ + "bitflags 1.3.2", + "cssparser", + "derive_more 0.99.20", + "fxhash", + "log", + "phf 0.8.0", + "phf_codegen 0.8.0", + "precomputed-hash", + "servo_arc", + "smallvec", +] + +[[package]] +name = "self_cell" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89" + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-big-array" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11fc7cc2c76d73e0f27ee52abbd64eec84d46f370c88371120433196934e4b7f" +dependencies = [ + "serde", +] + +[[package]] +name = "serde-untagged" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058" +dependencies = [ + "erased-serde", + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "serde-wasm-bindgen" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b" +dependencies = [ + "js-sys", + "serde", + "wasm-bindgen", +] + +[[package]] +name = "serde_bencode" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70dfc7b7438b99896e7f8992363ab8e2c4ba26aa5ec675d32d1c3c2c33d413e" +dependencies = [ + "serde", + "serde_bytes", +] + +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_with" +version = "3.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fa237f2807440d238e0364a218270b98f767a00d3dada77b1c53ae88940e2e7" +dependencies = [ + "base64 0.22.1", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.13.0", + "schemars 0.9.0", + "schemars 1.2.0", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52a8e3ca0ca629121f70ab50f95249e5a6f925cc0f6ffe8256c45b728875706c" +dependencies = [ + "darling 0.21.3", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "serialize-to-javascript" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04f3666a07a197cdb77cdf306c32be9b7f598d7060d50cfd4d5aa04bfd92f6c5" +dependencies = [ + "serde", + "serde_json", + "serialize-to-javascript-impl", +] + +[[package]] +name = "serialize-to-javascript-impl" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "servo_arc" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d52aa42f8fdf0fed91e5ce7f23d8138441002fa31dca008acf47e6fd4721f741" +dependencies = [ + "nodrop", + "stable_deref_trait", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest 0.10.7", +] + +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0-rc.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1e3878ab0f98e35b2df35fe53201d088299b41a6bb63e3e34dada2ac4abd924" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest 0.11.0-rc.3", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest 0.10.7", + "rand_core 0.6.4", +] + +[[package]] +name = "signature" +version = "3.0.0-rc.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ad0ce3b3f8efd7406f22e2ca5d02be21cdf3b3d1d53ab141f784de8965c7c7e" + +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "simple-dns" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dee851d0e5e7af3721faea1843e8015e820a234f81fda3dea9247e15bac9a86a" +dependencies = [ + "bitflags 2.10.0", +] + +[[package]] +name = "siphasher" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" + +[[package]] +name = "siphasher" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "smol_str" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fad6c857cbab2627dcf01ec85a623ca4e7dcb5691cbaa3d7fb7653671f0d09c9" + +[[package]] +name = "smol_str" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "socks" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0c3dbbd9ae980613c6dd8e28a9407b50509d3803b57624d5dfe8315218cd58b" +dependencies = [ + "byteorder", + "libc", + "winapi", +] + +[[package]] +name = "softbuffer" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" +dependencies = [ + "bytemuck", + "js-sys", + "ndk", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "objc2-quartz-core", + "raw-window-handle", + "redox_syscall", + "tracing", + "wasm-bindgen", + "web-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "sorted-index-buffer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea06cc588e43c632923a55450401b8f25e628131571d4e1baea1bdfdb2b5ed06" + +[[package]] +name = "soup3" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f" +dependencies = [ + "futures-channel", + "gio", + "glib", + "libc", + "soup3-sys", +] + +[[package]] +name = "soup3-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "spez" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c87e960f4dca2788eeb86bbdde8dd246be8948790b7618d656e68f9b720a86e8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spin" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der 0.7.10", +] + +[[package]] +name = "spki" +version = "0.8.0-rc.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8baeff88f34ed0691978ec34440140e1572b68c7dd4a495fd14a3dc1944daa80" +dependencies = [ + "base64ct", + "der 0.8.0-rc.10", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "string_cache" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared 0.11.3", + "precomputed-hash", + "serde", +] + +[[package]] +name = "string_cache_codegen" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "stun" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28fad383a1cc63ae141e84e48eaef44a1063e9d9e55bcb8f51a99b886486e01b" +dependencies = [ + "base64 0.21.7", + "crc", + "lazy_static", + "md-5", + "rand 0.8.5", + "ring", + "subtle", + "thiserror 1.0.69", + "tokio", + "url", + "webrtc-util", +] + +[[package]] +name = "substring" +version = "1.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42ee6433ecef213b2e72f587ef64a2f5943e7cd16fbd82dbe8bc07486c534c86" +dependencies = [ + "autocfg", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "swarm-discovery" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5ab62937edac8b23fa40e55a358ea1924245b17fc1eb20d14929c8f11be98d" +dependencies = [ + "acto", + "hickory-proto", + "rand 0.9.2", + "socket2 0.6.2", + "thiserror 2.0.18", + "tokio", + "tracing", +] + +[[package]] +name = "swift-rs" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4057c98e2e852d51fdcfca832aac7b571f6b351ad159f9eda5db1655f8d0c4d7" +dependencies = [ + "base64 0.21.7", + "serde", + "serde_json", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f36bdaa60a83aca3921b5259d5400cbf5e90fc51931376a9bd4a0eb79aa7210f" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", + "unicode-xid", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck 0.5.0", + "pkg-config", + "toml 0.8.2", + "version-compare", +] + +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + +[[package]] +name = "tao" +version = "0.34.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a753bdc39c07b192151523a3f77cd0394aa75413802c883a0f6f6a0e5ee2e7" +dependencies = [ + "bitflags 2.10.0", + "block2", + "core-foundation", + "core-graphics", + "crossbeam-channel", + "dispatch", + "dlopen2 0.8.2", + "dpi", + "gdkwayland-sys", + "gdkx11-sys", + "gtk", + "jni", + "lazy_static", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "once_cell", + "parking_lot", + "raw-window-handle", + "scopeguard", + "tao-macros", + "unicode-segmentation", + "url", + "windows 0.61.3", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "tao-macros" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "tauri" +version = "2.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a3868da5508446a7cd08956d523ac3edf0a8bc20bf7e4038f9a95c2800d2033" +dependencies = [ + "anyhow", + "bytes", + "cookie", + "dirs", + "dunce", + "embed_plist", + "getrandom 0.3.4", + "glob", + "gtk", + "heck 0.5.0", + "http", + "jni", + "libc", + "log", + "mime", + "muda", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "percent-encoding", + "plist", + "raw-window-handle", + "reqwest", + "serde", + "serde_json", + "serde_repr", + "serialize-to-javascript", + "swift-rs", + "tauri-build", + "tauri-macros", + "tauri-runtime", + "tauri-runtime-wry", + "tauri-utils", + "thiserror 2.0.18", + "tokio", + "tray-icon", + "url", + "webkit2gtk", + "webview2-com", + "window-vibrancy", + "windows 0.61.3", +] + +[[package]] +name = "tauri-build" +version = "2.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17fcb8819fd16463512a12f531d44826ce566f486d7ccd211c9c8cebdaec4e08" +dependencies = [ + "anyhow", + "cargo_toml", + "dirs", + "glob", + "heck 0.5.0", + "json-patch", + "schemars 0.8.22", + "semver", + "serde", + "serde_json", + "tauri-utils", + "tauri-winres", + "toml 0.9.11+spec-1.1.0", + "walkdir", +] + +[[package]] +name = "tauri-codegen" +version = "2.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa9844cefcf99554a16e0a278156ae73b0d8680bbc0e2ad1e4287aadd8489cf" +dependencies = [ + "base64 0.22.1", + "brotli", + "ico", + "json-patch", + "plist", + "png", + "proc-macro2", + "quote", + "semver", + "serde", + "serde_json", + "sha2 0.10.9", + "syn 2.0.114", + "tauri-utils", + "thiserror 2.0.18", + "time", + "url", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-macros" +version = "2.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3764a12f886d8245e66b7ee9b43ccc47883399be2019a61d80cf0f4117446fde" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.114", + "tauri-codegen", + "tauri-utils", +] + +[[package]] +name = "tauri-runtime" +version = "2.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87f766fe9f3d1efc4b59b17e7a891ad5ed195fa8d23582abb02e6c9a01137892" +dependencies = [ + "cookie", + "dpi", + "gtk", + "http", + "jni", + "objc2", + "objc2-ui-kit", + "objc2-web-kit", + "raw-window-handle", + "serde", + "serde_json", + "tauri-utils", + "thiserror 2.0.18", + "url", + "webkit2gtk", + "webview2-com", + "windows 0.61.3", +] + +[[package]] +name = "tauri-runtime-wry" +version = "2.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "187a3f26f681bdf028f796ccf57cf478c1ee422c50128e5a0a6ebeb3f5910065" +dependencies = [ + "gtk", + "http", + "jni", + "log", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "once_cell", + "percent-encoding", + "raw-window-handle", + "softbuffer", + "tao", + "tauri-runtime", + "tauri-utils", + "url", + "webkit2gtk", + "webview2-com", + "windows 0.61.3", + "wry", +] + +[[package]] +name = "tauri-utils" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a423c51176eb3616ee9b516a9fa67fed5f0e78baaba680e44eb5dd2cc37490" +dependencies = [ + "anyhow", + "brotli", + "cargo_metadata", + "ctor", + "dunce", + "glob", + "html5ever", + "http", + "infer", + "json-patch", + "kuchikiki", + "log", + "memchr", + "phf 0.11.3", + "proc-macro2", + "quote", + "regex", + "schemars 0.8.22", + "semver", + "serde", + "serde-untagged", + "serde_json", + "serde_with", + "swift-rs", + "thiserror 2.0.18", + "toml 0.9.11+spec-1.1.0", + "url", + "urlpattern", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-winres" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1087b111fe2b005e42dbdc1990fc18593234238d47453b0c99b7de1c9ab2c1e0" +dependencies = [ + "dunce", + "embed-resource", + "toml 0.9.11+spec-1.1.0", +] + +[[package]] +name = "tempfile" +version = "3.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix 1.1.3", + "windows-sys 0.61.2", +] + +[[package]] +name = "tendril" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" +dependencies = [ + "futf", + "mac", + "utf-8", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9da98b7d9b7dad93488a84b8248efc35352b0b2657397d4167e7ad67e5d535e5" +dependencies = [ + "deranged", + "itoa", + "js-sys", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78cc610bac2dcee56805c99642447d4c5dbde4d01f752ffea0199aee1f601dc4" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.6.2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", + "tokio-util", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" +dependencies = [ + "futures-util", + "log", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tungstenite 0.24.0", + "webpki-roots 0.26.11", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "futures-util", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-websockets" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1b6348ebfaaecd771cecb69e832961d277f59845d4220a584701f72728152b7" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-sink", + "getrandom 0.3.4", + "http", + "httparse", + "rand 0.9.2", + "ring", + "rustls-pki-types", + "simdutf8", + "tokio", + "tokio-rustls", + "tokio-util", +] + +[[package]] +name = "toml" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "toml" +version = "0.9.11+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3afc9a848309fe1aaffaed6e1546a7a14de1f935dc9d89d32afd9a44bab7c46" +dependencies = [ + "indexmap 2.13.0", + "serde_core", + "serde_spanned 1.0.4", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.14", +] + +[[package]] +name = "toml_datetime" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap 2.13.0", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" +dependencies = [ + "indexmap 2.13.0", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.23.10+spec-1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" +dependencies = [ + "indexmap 2.13.0", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "winnow 0.7.14", +] + +[[package]] +name = "toml_parser" +version = "1.0.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44" +dependencies = [ + "winnow 0.7.14", +] + +[[package]] +name = "toml_writer" +version = "1.0.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5" +dependencies = [ + "bitflags 2.10.0", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "http-range-header", + "httpdate", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags 2.10.0", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "trait-variant" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70977707304198400eb4835a78f6a9f928bf41bba420deb8fdb175cd965d77a7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "tray-icon" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e85aa143ceb072062fc4d6356c1b520a51d636e7bc8e77ec94be3608e5e80c" +dependencies = [ + "crossbeam-channel", + "dirs", + "libappindicator", + "muda", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "once_cell", + "png", + "serde", + "thiserror 2.0.18", + "windows-sys 0.60.2", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.8.5", + "rustls", + "rustls-pki-types", + "sha1", + "thiserror 1.0.69", + "utf-8", +] + +[[package]] +name = "tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.2", + "sha1", + "thiserror 2.0.18", + "utf-8", +] + +[[package]] +name = "turn" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b000cebd930420ac1ed842c8128e3b3412512dfd5b82657eab035a3f5126acc" +dependencies = [ + "async-trait", + "base64 0.21.7", + "futures", + "log", + "md-5", + "portable-atomic", + "rand 0.8.5", + "ring", + "stun", + "thiserror 1.0.69", + "tokio", + "tokio-util", + "webrtc-util", +] + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unic-char-property" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" +dependencies = [ + "unic-char-range", +] + +[[package]] +name = "unic-char-range" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" + +[[package]] +name = "unic-common" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" + +[[package]] +name = "unic-ucd-ident" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987" +dependencies = [ + "unic-char-property", + "unic-char-range", + "unic-ucd-version", +] + +[[package]] +name = "unic-ucd-version" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" +dependencies = [ + "unic-common", +] + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +dependencies = [ + "base64 0.22.1", + "flate2", + "log", + "once_cell", + "rustls", + "rustls-pki-types", + "url", + "webpki-roots 0.26.11", +] + +[[package]] +name = "ureq" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdc97a28575b85cfedf2a7e7d3cc64b3e11bd8ac766666318003abbacc7a21fc" +dependencies = [ + "base64 0.22.1", + "flate2", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "socks", + "ureq-proto", + "utf-8", + "webpki-roots 1.0.5", +] + +[[package]] +name = "ureq-proto" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d81f9efa9df032be5934a46a068815a10a042b494b6a58cb0a1a97bb5467ed6f" +dependencies = [ + "base64 0.22.1", + "http", + "httparse", + "log", +] + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "urlpattern" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70acd30e3aa1450bc2eece896ce2ad0d178e9c079493819301573dae3c37ba6d" +dependencies = [ + "regex", + "serde", + "unic-ucd-ident", + "url", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee48d38b119b0cd71fe4141b30f5ba9c7c5d9f4e7a3a8b4a674e4b6ef789976f" +dependencies = [ + "getrandom 0.3.4", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "vergen" +version = "9.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b849a1f6d8639e8de261e81ee0fc881e3e3620db1af9f2e0da015d4382ceaf75" +dependencies = [ + "anyhow", + "derive_builder", + "rustversion", + "vergen-lib 9.1.0", +] + +[[package]] +name = "vergen-gitcl" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9dfc1de6eb2e08a4ddf152f1b179529638bedc0ea95e6d667c014506377aefe" +dependencies = [ + "anyhow", + "derive_builder", + "rustversion", + "time", + "vergen", + "vergen-lib 0.1.6", +] + +[[package]] +name = "vergen-lib" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b07e6010c0f3e59fcb164e0163834597da68d1f864e2b8ca49f74de01e9c166" +dependencies = [ + "anyhow", + "derive_builder", + "rustversion", +] + +[[package]] +name = "vergen-lib" +version = "9.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b34a29ba7e9c59e62f229ae1932fb1b8fb8a6fdcc99215a641913f5f5a59a569" +dependencies = [ + "anyhow", + "derive_builder", + "rustversion", +] + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vswhom" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" +dependencies = [ + "libc", + "vswhom-sys", +] + +[[package]] +name = "vswhom-sys" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "waitgroup" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1f50000a783467e6c0200f9d10642f4bc424e39efc1b770203e88b488f79292" +dependencies = [ + "atomic-waker", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.9.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" +dependencies = [ + "cfg-if", + "futures-util", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.114", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-async" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b2260b739b0e95cf9b78f22a64704af7ed9760ea12baa3745b4b97899dc89a" +dependencies = [ + "tokio", + "tracing", + "wasm-bindgen-futures", +] + +[[package]] +name = "web-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-transport-iroh" +version = "0.1.1" +dependencies = [ + "bytes", + "http", + "iroh", + "iroh-quinn", + "n0-error", + "n0-future", + "thiserror 2.0.18", + "tokio", + "tracing", + "url", + "web-transport-proto", + "web-transport-trait", +] + +[[package]] +name = "web-transport-proto" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "660175a6d1643adb93b71c4f853d4f20f0fce47f53ae579afe9f7711fe84870d" +dependencies = [ + "bytes", + "http", + "thiserror 2.0.18", + "tokio", + "url", +] + +[[package]] +name = "web-transport-trait" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae5c857e6b426610648b39c6b48f9e66ae97b27b166d7c2f1ec369596548271" +dependencies = [ + "bytes", +] + +[[package]] +name = "webkit2gtk" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76b1bc1e54c581da1e9f179d0b38512ba358fb1af2d634a1affe42e37172361a" +dependencies = [ + "bitflags 1.3.2", + "cairo-rs", + "gdk", + "gdk-sys", + "gio", + "gio-sys", + "glib", + "glib-sys", + "gobject-sys", + "gtk", + "gtk-sys", + "javascriptcore-rs", + "libc", + "once_cell", + "soup3", + "webkit2gtk-sys", +] + +[[package]] +name = "webkit2gtk-sys" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62daa38afc514d1f8f12b8693d30d5993ff77ced33ce30cd04deebc267a6d57c" +dependencies = [ + "bitflags 1.3.2", + "cairo-sys-rs", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk-sys", + "javascriptcore-rs-sys", + "libc", + "pkg-config", + "soup3-sys", + "system-deps", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36a29fc0408b113f68cf32637857ab740edfafdf460c326cd2afaa2d84cc05dc" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.5", +] + +[[package]] +name = "webpki-roots" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12bed680863276c63889429bfd6cab3b99943659923822de1c8a39c49e4d722c" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webrtc" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b3a840e31c969844714f93b5a87e73ee49f3bc2a4094ab9132c69497eb31db" +dependencies = [ + "arc-swap", + "async-trait", + "bytes", + "cfg-if", + "hex", + "interceptor", + "lazy_static", + "log", + "portable-atomic", + "rand 0.8.5", + "rcgen", + "regex", + "ring", + "rtcp", + "rtp", + "rustls", + "sdp", + "serde", + "serde_json", + "sha2 0.10.9", + "smol_str 0.2.2", + "stun", + "thiserror 1.0.69", + "time", + "tokio", + "turn", + "url", + "waitgroup", + "webrtc-data", + "webrtc-dtls", + "webrtc-ice", + "webrtc-mdns", + "webrtc-media", + "webrtc-sctp", + "webrtc-srtp", + "webrtc-util", +] + +[[package]] +name = "webrtc-data" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8b7c550f8d35867b72d511640adf5159729b9692899826fe00ba7fa74f0bf70" +dependencies = [ + "bytes", + "log", + "portable-atomic", + "thiserror 1.0.69", + "tokio", + "webrtc-sctp", + "webrtc-util", +] + +[[package]] +name = "webrtc-dtls" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86e5eedbb0375aa04da93fc3a189b49ed3ed9ee844b6997d5aade14fc3e2c26e" +dependencies = [ + "aes", + "aes-gcm", + "async-trait", + "bincode", + "byteorder", + "cbc", + "ccm", + "der-parser 8.2.0", + "hkdf", + "hmac", + "log", + "p256", + "p384", + "portable-atomic", + "rand 0.8.5", + "rand_core 0.6.4", + "rcgen", + "ring", + "rustls", + "sec1", + "serde", + "sha1", + "sha2 0.10.9", + "subtle", + "thiserror 1.0.69", + "tokio", + "webrtc-util", + "x25519-dalek", + "x509-parser", +] + +[[package]] +name = "webrtc-ice" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d4f0ca6d4df8d1bdd34eece61b51b62540840b7a000397bcfb53a7bfcf347c8" +dependencies = [ + "arc-swap", + "async-trait", + "crc", + "log", + "portable-atomic", + "rand 0.8.5", + "serde", + "serde_json", + "stun", + "thiserror 1.0.69", + "tokio", + "turn", + "url", + "uuid", + "waitgroup", + "webrtc-mdns", + "webrtc-util", +] + +[[package]] +name = "webrtc-mdns" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0804694f3b2acfdff48f6df217979b13cb0a00377c63b5effd111daaee7e8c4" +dependencies = [ + "log", + "socket2 0.5.10", + "thiserror 1.0.69", + "tokio", + "webrtc-util", +] + +[[package]] +name = "webrtc-media" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c15b20e98167b22949abc1c20eca7c6d814307d187068fe7a48f0b87a4f6d46" +dependencies = [ + "byteorder", + "bytes", + "rand 0.8.5", + "rtp", + "thiserror 1.0.69", +] + +[[package]] +name = "webrtc-sctp" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d850daa68639b9d7bb16400676e97525d1e52b15b4928240ae2ba0e849817a5" +dependencies = [ + "arc-swap", + "async-trait", + "bytes", + "crc", + "log", + "portable-atomic", + "rand 0.8.5", + "thiserror 1.0.69", + "tokio", + "webrtc-util", +] + +[[package]] +name = "webrtc-srtp" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbec5da43a62c228d321d93fb12cc9b4d9c03c9b736b0c215be89d8bd0774cfe" +dependencies = [ + "aead", + "aes", + "aes-gcm", + "byteorder", + "bytes", + "ctr", + "hmac", + "log", + "rtcp", + "rtp", + "sha1", + "subtle", + "thiserror 1.0.69", + "tokio", + "webrtc-util", +] + +[[package]] +name = "webrtc-util" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc8d9bc631768958ed97b8d68b5d301e63054ae90b09083d43e2fefb939fd77e" +dependencies = [ + "async-trait", + "bitflags 1.3.2", + "bytes", + "ipnet", + "lazy_static", + "libc", + "log", + "nix", + "portable-atomic", + "rand 0.8.5", + "thiserror 1.0.69", + "tokio", + "winapi", +] + +[[package]] +name = "webview2-com" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" +dependencies = [ + "webview2-com-macros", + "webview2-com-sys", + "windows 0.61.3", + "windows-core 0.61.2", + "windows-implement", + "windows-interface", +] + +[[package]] +name = "webview2-com-macros" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "webview2-com-sys" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" +dependencies = [ + "thiserror 2.0.18", + "windows 0.61.3", + "windows-core 0.61.2", +] + +[[package]] +name = "which" +version = "6.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ee928febd44d98f2f459a4a79bd4d928591333a494a10a868418ac1b39cf1f" +dependencies = [ + "either", + "home", + "rustix 0.38.44", + "winsafe", +] + +[[package]] +name = "which" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fabb953106c3c8eea8306e4393700d7657561cb43122571b172bbfb7c7ba1d" +dependencies = [ + "env_home", + "rustix 1.1.3", + "winsafe", +] + +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "window-vibrancy" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c" +dependencies = [ + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "windows-sys 0.59.0", + "windows-version", +] + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections 0.2.0", + "windows-core 0.61.2", + "windows-future 0.2.1", + "windows-link 0.1.3", + "windows-numerics 0.2.0", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections 0.3.2", + "windows-core 0.62.2", + "windows-future 0.3.2", + "windows-numerics 0.3.1", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core 0.62.2", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading 0.1.0", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", + "windows-threading 0.2.1", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-version" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "0.7.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + +[[package]] +name = "winreg" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + +[[package]] +name = "winsafe" +version = "0.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" + +[[package]] +name = "wmi" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "746791db82f029aaefc774ccbb8e61306edba18ef2c8998337cadccc0b8067f7" +dependencies = [ + "chrono", + "futures", + "log", + "serde", + "thiserror 2.0.18", + "windows 0.62.2", + "windows-core 0.62.2", +] + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "wry" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728b7d4c8ec8d81cab295e0b5b8a4c263c0d41a785fb8f8c4df284e5411140a2" +dependencies = [ + "base64 0.22.1", + "block2", + "cookie", + "crossbeam-channel", + "dirs", + "dpi", + "dunce", + "gdkx11", + "gtk", + "html5ever", + "http", + "javascriptcore-rs", + "jni", + "kuchikiki", + "libc", + "ndk", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "sha2 0.10.9", + "soup3", + "tao-macros", + "thiserror 2.0.18", + "url", + "webkit2gtk", + "webkit2gtk-sys", + "webview2-com", + "windows 0.61.3", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "ws_stream_wasm" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c173014acad22e83f16403ee360115b38846fe754e735c5d9d3803fe70c6abc" +dependencies = [ + "async_io_stream", + "futures", + "js-sys", + "log", + "pharos", + "rustc_version", + "send_wrapper", + "thiserror 2.0.18", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek 4.1.3", + "rand_core 0.6.4", + "serde", + "zeroize", +] + +[[package]] +name = "x509-parser" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69" +dependencies = [ + "asn1-rs 0.6.2", + "data-encoding", + "der-parser 9.0.0", + "lazy_static", + "nom", + "oid-registry", + "ring", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + +[[package]] +name = "xml-rs" +version = "0.8.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" + +[[package]] +name = "xmltree" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7d8a75eaf6557bb84a65ace8609883db44a29951042ada9b393151532e41fcb" +dependencies = [ + "xml-rs", +] + +[[package]] +name = "yasna" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +dependencies = [ + "time", +] + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", + "synstructure 0.13.2", +] + +[[package]] +name = "z32" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2164e798d9e3d84ee2c91139ace54638059a3b23e361f5c11781c2c6459bde0f" + +[[package]] +name = "zerocopy" +version = "0.8.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7456cf00f0685ad319c5b1693f291a650eaf345e941d082fc4e03df8a03996ac" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1328722bbf2115db7e19d69ebcc15e795719e2d66b60827c6a69a117365e37a0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", + "synstructure 0.13.2", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "zmij" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1966f8ac2c1f76987d69a74d0e0f929241c10e78136434e3be70ff7f58f64214" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..a1d7c77 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,36 @@ +[workspace] +resolver = "2" +members = [ + "crates/ec-core", + "crates/ec-moq", + "crates/ec-direct", + "crates/ec-hdhomerun", + "crates/ec-linux-iptv", + "crates/ec-iroh", + "crates/ec-crypto", + "crates/ec-ts", + "crates/ec-chopper", + "crates/ec-node", + "crates/ec-cli", + "apps/tauri", +] +exclude = [ + # Vendored upstream crates; we build them as dependencies but do not treat them + # as first-class workspace members (their upstream tests are timing-sensitive). + "third_party/iroh-org/iroh-gossip", + "third_party/iroh-live/iroh-moq", + "third_party/iroh-live/web-transport-iroh", +] + +[workspace.package] +edition = "2021" +license = "AGPL-3.0-only" + +[workspace.dependencies] +anyhow = "1" +blake3 = "1" +clap = { version = "4", features = ["derive"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tracing = "0.1" +tracing-subscriber = "0.3" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..012c074 --- /dev/null +++ b/LICENSE @@ -0,0 +1,644 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +if it includes a convenient and prominently visible feature that +(1) displays an appropriate copyright notice, and (2) tells the user +that there is no warranty for the work (except to the extent that +warranties are provided), that licensees may convey the work under +this License, and how to view a copy of this License. If the +interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any + tangible personal property which is normally used for personal, family, + or household purposes, or (2) anything designed or sold for incorporation + into a dwelling. In determining whether a product is a consumer product, + doubtful cases shall be resolved in favor of coverage. For a particular + product received by a particular user, "normally used" refers to a + typical or common use of that class of product, regardless of the status + of the particular user or of the way in which the particular user + actually uses, or expects or is expected to use, the product. A product + is a consumer product regardless of whether the product has substantial + commercial, industrial or non-consumer uses, unless such uses represent + the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, + procedures, authorization keys, or other information required to install + and execute modified versions of a covered work in that User Product from + a modified version of its Corresponding Source. The information must + suffice to ensure that the continued functioning of the modified object + code is in no case prevented or interfered with solely because + modification has been made. + + If you convey an object code work under this section in, or with, or + specifically for use in, a User Product, and the conveying occurs as + part of a transaction in which the right of possession and use of the + User Product is transferred to the recipient in perpetuity or for a + fixed term (regardless of how the transaction is characterized), the + Corresponding Source conveyed under this section must be accompanied + by the Installation Information. But this requirement does not apply + if neither you nor any third party retains the ability to install + modified object code on the User Product (for example, the work has + been installed in ROM). + + The requirement to provide Installation Information does not include a + requirement to continue to provide support service, warranty, or updates + for a work that has been modified or installed by the recipient, or for + the User Product in which it has been modified or installed. Access to a + network may be denied when the modification itself materially and + adversely affects the operation of the network or violates the rules and + protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, + in accord with this section must be in a format that is publicly + documented (and with an implementation available to the public in + source code form), and must require no special password or key for + unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this + License by making exceptions from one or more of its conditions. + Additional permissions that are applicable to the entire Program shall + be treated as though they were included in this License, to the extent + that they are valid under applicable law. If additional permissions + apply only to part of the Program, that part may be used separately + under those permissions, but the entire Program remains governed by + this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option + remove any additional permissions from that copy, or from any part of + it. (Additional permissions may be written to require their own + removal in certain cases when you modify the work.) You may place + additional permissions on material, added by you to a covered work, + for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you + add to a covered work, you may (if authorized by the copyright holders + of that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further + restrictions" within the meaning of section 10. If the Program as you + received it, or any part of it, contains a notice stating that it is + governed by this License along with a term that is a further + restriction, you may remove that term. If a license document contains + a further restriction but permits relicensing or conveying under this + License, you may add to a covered work material governed by the terms + of that license document, provided that the further restriction does + not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you + must place, in the relevant source files, a statement of the + additional terms that apply to those files, or a notice indicating + where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the + form of a separately written license, or stated as exceptions; + the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly + provided under this License. Any attempt otherwise to propagate or + modify it is void, and will automatically terminate your rights under + this License (including any patent licenses granted under the third + paragraph of section 11). + + However, if you cease all violation of this License, then your + license from a particular copyright holder is reinstated (a) + provisionally, unless and until the copyright holder explicitly and + finally terminates your license, and (b) permanently, if the copyright + holder fails to notify you of the violation by some reasonable means + prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is + reinstated permanently if the copyright holder notifies you of the + violation by some reasonable means, this is the first time you have + received notice of violation of this License (for any work) from that + copyright holder, and you cure the violation prior to 30 days after + your receipt of the notice. + + Termination of your rights under this section does not terminate the + licenses of parties who have received copies or rights from you under + this License. If your rights have been terminated and not permanently + reinstated, you do not qualify to receive new licenses for the same + material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or + run a copy of the Program. Ancillary propagation of a covered work + occurring solely as a consequence of using peer-to-peer transmission + to receive a copy likewise does not require acceptance. However, + nothing other than this License grants you permission to propagate or + modify any covered work. These actions infringe copyright if you do + not accept this License. Therefore, by modifying or propagating a + covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically + receives a license from the original licensors, to run, modify and + propagate that work, subject to this License. You are not responsible + for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an + organization, or substantially all assets of one, or subdividing an + organization, or merging organizations. If propagation of a covered + work results from an entity transaction, each party to that + transaction who receives a copy of the work also receives whatever + licenses to the work the party's predecessor in interest had or could + give under the previous paragraph, plus a right to possession of the + Corresponding Source of the work from the predecessor in interest, if + the predecessor was required to provide the Corresponding Source. + + You may not impose any further restrictions on the exercise of the + rights granted or affirmed under this License. For example, you may + not impose a license fee, royalty, or other charge for exercise of + rights granted under this License, and you may not initiate litigation + (including a cross-claim or counterclaim in a lawsuit) alleging that + any patent claim is infringed by making, using, selling, offering for + sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this + License of the Program or a work on which the Program is based. The + work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims + owned or controlled by the contributor, whether already acquired or + hereafter acquired, that would be infringed by some manner, permitted + by this License, of making, using, or selling its contributor version, + but do not include claims that would be infringed only as a + consequence of further modification of the contributor version. + + Each contributor grants you a non-exclusive, worldwide, royalty-free + patent license under the contributor's essential patent claims, to + make, use, sell, offer for sale, import and otherwise run, modify and + propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express + agreement or commitment, however denominated, not to enforce a patent + (such as an express permission to practice a patent or covenant not to + sue for patent infringement). To "grant" such a patent license to a + party means to make such an agreement or commitment not to enforce a + patent against the party. + + If you convey a covered work, knowingly relying on a patent license, + and the Corresponding Source of the work is not available for anyone + to copy, free of charge and under the terms of this License, through a + publicly available network server or other readily accessible means, + then you must either (1) cause the Corresponding Source to be so + available, or (2) arrange to deprive yourself of the benefit of the + patent license for this particular work, or (3) arrange, in a manner + consistent with the requirements of this License, to extend the patent + license to downstream recipients. "Knowingly relying" means you have + actual knowledge that, but for the patent license, your conveying the + covered work in a country, or your recipient's use of the covered work + in a country, would infringe one or more identifiable patents in that + country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or + arrangement, you convey, or propagate by procuring conveyance of, a + covered work, and grant a patent license to some of the parties + receiving the covered work authorizing them to use, propagate, modify + or convey a specific copy of the covered work, then the patent license + you grant is automatically extended to all recipients of the covered + work and works based on it. + + A patent license is "discriminatory" if it does not include within + the scope of its coverage, prohibits the exercise of, or is + conditioned on the non-exercise of one or more of the rights that are + specifically granted under this License. You may not convey a covered + work if you are a party to an arrangement with a third party that is + in the business of distributing software, under which you make payment + to the third party based on the extent of your activity of conveying + the work, and under which the third party grants, to any of the + parties who would receive the covered work from you, a discriminatory + patent license (a) in connection with copies of the covered work + conveyed by you (or copies made from those copies), or (b) primarily + for and in connection with specific products or compilations that + contain the covered work, unless you entered into that arrangement, + or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting + any implied license or other defenses to infringement that may + otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or + otherwise) that contradict the conditions of this License, they do not + excuse you from the conditions of this License. If you cannot convey a + covered work so as to satisfy simultaneously your obligations under + this License and any other pertinent obligations, then as a consequence + you may not convey it at all. For example, if you agree to terms that + obligate you to collect a royalty for further conveying from those to + whom you convey the Program, the only way you could satisfy both those + terms and this License would be to refrain entirely from conveying the + Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the + Program, your modified version must prominently offer all users + interacting with it remotely through a computer network (if your version + supports such interaction) an opportunity to receive the Corresponding + Source of your version by providing access to the Corresponding Source + from a network server at no charge, through some standard or customary + means of facilitating copying of software. This Corresponding Source + shall include the Corresponding Source for any work covered by version 3 + of the GNU General Public License that is incorporated pursuant to the + following paragraph. + + Notwithstanding any other provision of this License, you have + permission to link or combine any covered work with a work licensed + under version 3 of the GNU General Public License into a single + combined work, and to convey the resulting work. The terms of this + License will continue to apply to the part which is the covered work, + but the work with which it is combined will remain governed by version + 3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of + the GNU Affero General Public License from time to time. Such new + versions will be similar in spirit to the present version, but may + differ in detail to address new problems or concerns. + + Each version is given a distinguishing version number. If the + Program specifies that a certain numbered version of the GNU Affero + General Public License "or any later version" applies to it, you have + the option of following the terms and conditions either of that + numbered version or of any later version published by the Free + Software Foundation. If the Program does not specify a version number + of the GNU Affero General Public License, you may choose any version + ever published by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future + versions of the GNU Affero General Public License can be used, that + proxy's public statement of acceptance of a version permanently + authorizes you to choose that version for the Program. + + Later license versions may give you additional or different + permissions. However, no additional obligations are imposed on any + author or copyright holder as a result of your choosing to follow a + later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY + APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT + HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY + OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, + THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR + PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM + IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF + ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING + WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS + THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY + GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE + USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF + DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD + PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), + EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF + SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided + above cannot be given local legal effect according to their terms, + reviewing courts shall apply local law that most closely approximates + an absolute waiver of all civil liability in connection with the + Program, unless a warranty or assumption of liability accompanies a + copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . diff --git a/README.md b/README.md new file mode 100644 index 0000000..c0fa768 --- /dev/null +++ b/README.md @@ -0,0 +1,94 @@ +# every.channel + +A global, disaggregated mesh of relays that turns local ATSC antennas into a coherent, worldwide stream. The stack is Rust-first, MoQ-native, and designed for deterministic chunking so identical broadcasts yield identical data. + +## Goals + +- Free, global access to broadcast TV through user-run relays. +- Deterministic encoding and chunking to make availability a coordination problem. +- Clean layering: capture -> transcode -> MoQ publish -> relay -> client playback. +- Cross-platform clients: Tauri app, CLI, and a static web UI. + +## Repository layout + +- `crates/ec-core`: shared types and determinism profiles. +- `crates/ec-hdhomerun`: HDHomeRun discovery and lineup scaffolding. +- `crates/ec-linux-iptv`: Linux DVB ingest scaffolding. +- `crates/ec-iroh`: iroh transport scaffolding. +- `crates/ec-crypto`: stream key derivation helpers. +- `crates/ec-ts`: MPEG-TS timing and table parsing. +- `crates/ec-chopper`: deterministic ffmpeg chunking scaffolding. +- `crates/ec-moq`: MoQ data model and relay scaffolding. +- `crates/ec-node`: node runner (ingest + publish). +- `crates/ec-cli`: CLI for discovery and node control. +- `apps/tauri`: desktop client shell. +- `apps/tauri/ui`: Dioxus web frontend embedded in the Tauri app. +- `docs/USAGE.md`: runbook for viewer and ingest pipelines. +- `docs/IROH_EXAMPLES.md`: summary of iroh repos/examples used for design. +- `docs/`: architecture, roadmap, and MoQ notes. + +## Development + +Nix: + +```sh +nix develop +``` + +Rust: + +```sh +cargo build +``` + +Runbook: + +```sh +cat docs/USAGE.md +``` + +Coverage: + +```sh +./scripts/coverage.sh +``` + +Build static web: + +```sh +./scripts/build-web.sh +``` + +Deploy to Cloudflare Workers (static site): + +```sh +./scripts/deploy-workers.sh +``` + +Remote website E2E (local publisher -> deployed every.channel web): + +```sh +./scripts/e2e-remote-website-direct.sh +``` + +Remote website E2E (public list/signaling -> website selects stream automatically): + +```sh +./scripts/e2e-remote-website-directory.sh +``` + +Tauri viewer (Dioxus + Trunk): + +```sh +cd apps/tauri/ui +trunk serve --port 1420 --public-url / +``` + +```sh +cd ../ +cargo run +``` + +## Status + +This repository is intentionally minimal. It captures the initial architecture and scaffold for a MoQ-first network and will expand as proposals are accepted. diff --git a/apps/tauri/Cargo.toml b/apps/tauri/Cargo.toml new file mode 100644 index 0000000..53b8928 --- /dev/null +++ b/apps/tauri/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "ec-tauri" +version = "0.0.0" +edition.workspace = true +license.workspace = true + +[dependencies] +anyhow.workspace = true +axum = "0.7" +blake3.workspace = true +ec-crypto = { path = "../../crates/ec-crypto" } +ec-core = { path = "../../crates/ec-core" } +ec-chopper = { path = "../../crates/ec-chopper" } +ec-hdhomerun = { path = "../../crates/ec-hdhomerun" } +ec-linux-iptv = { path = "../../crates/ec-linux-iptv" } +ec-iroh = { path = "../../crates/ec-iroh" } +ec-moq = { path = "../../crates/ec-moq" } +hex = "0.4" +iroh = "0.96" +reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls"] } +serde.workspace = true +serde_json = "1" +tauri = { version = "2", features = [] } +tokio = { version = "1", features = ["rt-multi-thread", "macros"] } +tower-http = { version = "0.5", features = ["fs"] } +tracing.workspace = true + +[build-dependencies] +tauri-build = { version = "2", features = [] } diff --git a/apps/tauri/build.rs b/apps/tauri/build.rs new file mode 100644 index 0000000..261851f --- /dev/null +++ b/apps/tauri/build.rs @@ -0,0 +1,3 @@ +fn main() { + tauri_build::build(); +} diff --git a/apps/tauri/icons/icon.png b/apps/tauri/icons/icon.png new file mode 100644 index 0000000..8ef45ce Binary files /dev/null and b/apps/tauri/icons/icon.png differ diff --git a/apps/tauri/resources/yt-dlp/README.md b/apps/tauri/resources/yt-dlp/README.md new file mode 100644 index 0000000..2c03208 --- /dev/null +++ b/apps/tauri/resources/yt-dlp/README.md @@ -0,0 +1,8 @@ +Bundled yt-dlp runtime lives under platform-specific folders. + +Use `scripts/vendor-yt-dlp.sh` to populate: +- macos/venv +- linux/venv +- windows/venv + +These directories are intentionally empty in git. diff --git a/apps/tauri/src/main.rs b/apps/tauri/src/main.rs new file mode 100644 index 0000000..6a4fd97 --- /dev/null +++ b/apps/tauri/src/main.rs @@ -0,0 +1,3452 @@ +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +use anyhow::{anyhow, Context, Result}; +use axum::Router; +use blake3; +use ec_core::{ + merkle_root_from_hashes, Manifest, ManifestVariant, MoqStreamDescriptor, SourceId, + StreamCatalogEntry, StreamDescriptor, StreamEncryptionInfo, StreamId, StreamKey, + StreamMetadata, +}; +use ec_crypto::{decrypt_stream_data, encrypt_stream_data, ENCRYPTION_ALG}; +use ec_hdhomerun::{HdhomerunDevice, LineupEntry}; +use ec_iroh; +use ec_linux_iptv::LinuxDvbConfig; +use ec_moq::{ + chunk_duration_secs, GroupId, HlsWriter, MoqNode, ObjectMeta, ObjectPayload, TimingMeta, + DEFAULT_MANIFEST_TRACK_NAME, DEFAULT_TRACK_NAME, +}; +use reqwest::blocking as reqwest_blocking; +use reqwest::Url; +use serde::{Deserialize, Serialize}; +use std::collections::{HashMap, HashSet}; +use std::fs; +use std::io::Read; +use std::net::IpAddr; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::sync::Arc; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; +use tauri::path::BaseDirectory; +use tauri::{AppHandle, Manager, State}; +use tokio::sync::Mutex; +use tower_http::services::ServeDir; + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct PlaybackInfo { + stream_id: String, + url: String, +} + +#[derive(Debug, Clone)] +struct StreamSource { + stream_url: String, + title: String, + number: Option, + metadata: Vec, +} + +struct StreamProcess { + _child: Child, + _output_dir: PathBuf, +} + +struct MoqStreamProcess { + _task: tauri::async_runtime::JoinHandle<()>, + _node: MoqNode, + _output_dir: PathBuf, + _mdns: Option, +} + +struct MoqPublishProcess { + _task: tauri::async_runtime::JoinHandle<()>, + _node: MoqNode, + _mdns: Option, + share: ShareInfo, +} + +struct CatalogProcess { + _task: tauri::async_runtime::JoinHandle<()>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct SourceDescriptor { + id: String, + kind: String, + name: String, + ip: Option, + tuner_count: Option, + status: String, +} + +struct StreamManager { + port: u16, + output_root: PathBuf, + streams: Vec, + manual_streams: Vec, + sources: HashMap, + manual_sources: HashMap, + manual_source_descriptors: HashMap, + manual_devices: HashMap, + manual_entries: Vec, + manual_entries_loaded: bool, + processes: HashMap, + moq_processes: HashMap, + moq_publishes: HashMap, + catalog_streams: HashMap, + catalog_process: Option, +} + +impl StreamManager { + fn new(port: u16, output_root: PathBuf) -> Self { + Self { + port, + output_root, + streams: Vec::new(), + manual_streams: Vec::new(), + sources: HashMap::new(), + manual_sources: HashMap::new(), + manual_source_descriptors: HashMap::new(), + manual_devices: HashMap::new(), + manual_entries: Vec::new(), + manual_entries_loaded: false, + processes: HashMap::new(), + moq_processes: HashMap::new(), + moq_publishes: HashMap::new(), + catalog_streams: HashMap::new(), + catalog_process: None, + } + } +} + +#[tauri::command] +async fn list_streams( + state: State<'_, Arc>>, +) -> Result, String> { + let needs_refresh = { + let manager = state.lock().await; + manager.streams.is_empty() + }; + + if needs_refresh { + let (streams, sources) = tokio::task::spawn_blocking(discover_streams) + .await + .map_err(|err| err.to_string()) + .and_then(|res| res.map_err(|err| err.to_string()))?; + + let mut manager = state.lock().await; + manager.streams = streams; + manager.sources = sources; + } + + let manager = state.lock().await; + let local = merge_local_streams(&manager.streams, &manager.manual_streams); + Ok(merge_streams(&local, &manager.catalog_streams)) +} + +#[tauri::command] +async fn refresh_streams( + state: State<'_, Arc>>, +) -> Result, String> { + let (streams, sources) = tokio::task::spawn_blocking(discover_streams) + .await + .map_err(|err| err.to_string()) + .and_then(|res| res.map_err(|err| err.to_string()))?; + + let mut manager = state.lock().await; + manager.streams = streams; + manager.sources = sources; + let local = merge_local_streams(&manager.streams, &manager.manual_streams); + Ok(merge_streams(&local, &manager.catalog_streams)) +} + +#[tauri::command] +async fn list_sources( + state: State<'_, Arc>>, +) -> Result, String> { + let sources = tokio::task::spawn_blocking(discover_sources) + .await + .map_err(|err| err.to_string()) + .and_then(|res| res.map_err(|err| err.to_string()))?; + let manager = state.lock().await; + let mut merged = merge_source_descriptors(sources, manager.manual_devices.iter()); + let mut seen: HashSet = merged.iter().map(|source| source.id.clone()).collect(); + for source in manager.manual_source_descriptors.values() { + if seen.insert(source.id.clone()) { + merged.push(source.clone()); + } + } + Ok(dedupe_source_descriptors(merged)) +} + +fn dedupe_source_descriptors(sources: Vec) -> Vec { + // Defensive dedupe: callers occasionally merge the same physical device via multiple paths + // (mDNS, manual add, persisted aliases). Prefer "online" when duplicates exist. + use std::collections::BTreeMap; + + fn key(s: &SourceDescriptor) -> (String, String, String) { + ( + s.kind.clone(), + s.id.clone(), + s.ip.clone().unwrap_or_default(), + ) + } + + let mut by_key: BTreeMap<(String, String, String), SourceDescriptor> = BTreeMap::new(); + for source in sources { + let k = key(&source); + match by_key.get(&k) { + None => { + by_key.insert(k, source); + } + Some(existing) => { + if existing.status != "online" && source.status == "online" { + by_key.insert(k, source); + } + } + } + } + + by_key.into_values().collect() +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct AddHdhrArgs { + host: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct AddYtdlpArgs { + url: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +#[serde(rename_all = "camelCase")] +struct ManualSourceOptions { + ytdlp_format: Option, + ytdlp_live_from_start: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +#[serde(rename_all = "camelCase")] +struct ManualSourceEntry { + kind: String, + input: String, + options: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct AddStreamArgs { + input: String, + options: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ProbeStreamArgs { + input: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct LinuxDvbAdapterInfo { + adapter: u32, + dvrs: Vec, + frontends: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct LinuxDvbChannelsInfo { + channels_conf: Option, + channels: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct LinuxDvbBuildUrlArgs { + adapter: u32, + dvr: u32, + channel: Option, + channels_conf: Option, + tune_wait_ms: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct LinuxDvbListChannelsArgs { + channels_conf: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct YtdlpFormatOption { + format_id: String, + label: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct YtdlpProbe { + title: Option, + formats: Vec, + default_format: Option, + supports_live_from_start: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct StreamProbe { + kind: String, + live: bool, + requires_options: bool, + message: Option, + ytdlp: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct AddStreamResult { + kind: String, + added: usize, + title: Option, +} + +#[tauri::command] +async fn add_hdhr_source( + args: AddHdhrArgs, + app: AppHandle, + state: State<'_, Arc>>, +) -> Result { + let host = args.host.trim(); + if host.is_empty() { + return Err("host is required".to_string()); + } + + let host_string = normalize_host(host); + let host_for_task = host_string.clone(); + let (device, streams, sources) = + tokio::task::spawn_blocking(move || hydrate_hdhr_host(&host_for_task)) + .await + .map_err(|err| err.to_string()) + .and_then(|res| res.map_err(|err| err.to_string()))?; + + let mut manager = state.lock().await; + add_manual_entries( + &mut manager, + host_string.clone(), + device.clone(), + streams, + sources, + ); + remember_manual_entry( + &mut manager, + &app, + ManualSourceEntry { + kind: "hdhr".to_string(), + input: host_string.clone(), + options: None, + }, + ); + Ok(source_descriptor_for_device_with_id( + &device, + "manual", + manual_source_id(&host_string, &device), + )) +} + +#[tauri::command] +async fn add_ytdlp_source( + args: AddYtdlpArgs, + app: AppHandle, + state: State<'_, Arc>>, +) -> Result { + let url = args.url.trim(); + if url.is_empty() { + return Err("url is required".to_string()); + } + + let url_string = url.to_string(); + let url_for_task = url_string.clone(); + let app_clone = app.clone(); + let resolved = + tokio::task::spawn_blocking(move || resolve_ytdlp_stream(&app_clone, &url_for_task, None)) + .await + .map_err(|err| err.to_string()) + .and_then(|res| res.map_err(|err| err.to_string()))?; + + let mut manager = state.lock().await; + if !manager + .manual_streams + .iter() + .any(|existing| existing.id == resolved.descriptor.id) + { + manager.manual_streams.push(resolved.descriptor.clone()); + } + manager + .manual_sources + .insert(resolved.descriptor.id.0.clone(), resolved.source); + manager.manual_source_descriptors.insert( + resolved.source_descriptor.id.clone(), + resolved.source_descriptor, + ); + remember_manual_entry( + &mut manager, + &app, + ManualSourceEntry { + kind: "ytdlp".to_string(), + input: url_string, + options: None, + }, + ); + Ok(resolved.descriptor) +} + +#[tauri::command] +async fn probe_stream(args: ProbeStreamArgs, app: AppHandle) -> Result { + let input = args.input.trim().to_string(); + if input.is_empty() { + return Err("input is required".to_string()); + } + tokio::task::spawn_blocking(move || probe_stream_input(&app, &input)) + .await + .map_err(|err| err.to_string()) + .and_then(|res| res.map_err(|err| err.to_string())) +} + +#[tauri::command] +async fn linux_dvb_list_adapters() -> Result, String> { + tokio::task::spawn_blocking(move || -> Result> { + let adapters = ec_linux_iptv::list_adapters()?; + Ok(adapters + .into_iter() + .map(|info| LinuxDvbAdapterInfo { + adapter: info.adapter, + dvrs: info.dvrs, + frontends: info.frontends, + }) + .collect()) + }) + .await + .map_err(|err| err.to_string()) + .and_then(|res| res.map_err(|err| err.to_string())) +} + +#[tauri::command] +async fn linux_dvb_list_channels( + args: LinuxDvbListChannelsArgs, +) -> Result { + tokio::task::spawn_blocking(move || -> Result { + let conf = args + .channels_conf + .as_deref() + .and_then(|value| { + let value = value.trim(); + if value.is_empty() { + None + } else { + Some(PathBuf::from(value)) + } + }) + .or_else(ec_linux_iptv::find_channels_conf); + let channels = if let Some(path) = conf.as_deref() { + ec_linux_iptv::parse_channels_conf(path)? + } else { + Vec::new() + }; + Ok(LinuxDvbChannelsInfo { + channels_conf: conf.map(|p| p.display().to_string()), + channels, + }) + }) + .await + .map_err(|err| err.to_string()) + .and_then(|res| res.map_err(|err| err.to_string())) +} + +#[tauri::command] +async fn linux_dvb_build_url(args: LinuxDvbBuildUrlArgs) -> Result { + tokio::task::spawn_blocking(move || -> Result { + let adapter = args.adapter; + let dvr = args.dvr; + let mut tune_cmd = Vec::new(); + if let (Some(conf), Some(channel)) = + (args.channels_conf.as_deref(), args.channel.as_deref()) + { + let conf_path = PathBuf::from(conf); + tune_cmd = ec_linux_iptv::default_zap_tune_command(adapter, &conf_path, channel); + } + let config = LinuxDvbConfig { + adapter, + frontend: 0, + dvr, + tune_command: if tune_cmd.is_empty() { + None + } else { + Some(tune_cmd) + }, + tune_timeout_ms: args.tune_wait_ms, + }; + Ok(linux_dvb_url(&config)) + }) + .await + .map_err(|err| err.to_string()) + .and_then(|res| res.map_err(|err| err.to_string())) +} + +#[tauri::command] +async fn add_stream( + args: AddStreamArgs, + app: AppHandle, + state: State<'_, Arc>>, +) -> Result { + let input = args.input.trim().to_string(); + if input.is_empty() { + return Err("input is required".to_string()); + } + let options = args.options.clone(); + let app_clone = app.clone(); + let input_clone = input.clone(); + let resolved = tokio::task::spawn_blocking(move || { + resolve_stream_input(&app_clone, &input_clone, options) + }) + .await + .map_err(|err| err.to_string()) + .and_then(|res| res.map_err(|err| err.to_string()))?; + + let mut manager = state.lock().await; + let mut added = 0usize; + let mut title = None; + let kind = resolved.kind_name(); + match resolved { + ResolvedStream::Hdhr { + host, + device, + streams, + sources, + } => { + let before = manager.manual_streams.len(); + add_manual_entries(&mut manager, host.clone(), device.clone(), streams, sources); + remember_manual_entry( + &mut manager, + &app, + ManualSourceEntry { + kind: "hdhr".to_string(), + input: host, + options: None, + }, + ); + let after = manager.manual_streams.len(); + added = after.saturating_sub(before); + } + ResolvedStream::Direct { entry } => { + title = Some(entry.descriptor.title.clone()); + if !manager + .manual_streams + .iter() + .any(|existing| existing.id == entry.descriptor.id) + { + manager.manual_streams.push(entry.descriptor.clone()); + added = 1; + } + manager + .manual_sources + .insert(entry.descriptor.id.0.clone(), entry.source); + manager + .manual_source_descriptors + .insert(entry.source_descriptor.id.clone(), entry.source_descriptor); + remember_manual_entry(&mut manager, &app, entry.manual_entry); + } + } + + Ok(AddStreamResult { kind, added, title }) +} + +#[tauri::command] +async fn start_stream( + stream_id: String, + state: State<'_, Arc>>, +) -> Result { + let (stream_url, output_dir, port) = { + let mut manager = state.lock().await; + if let Some(_process) = manager.processes.get(&stream_id) { + let url = playback_url(manager.port, &stream_id); + return Ok(PlaybackInfo { stream_id, url }); + } + + let source = manager + .sources + .get(&stream_id) + .or_else(|| manager.manual_sources.get(&stream_id)) + .ok_or_else(|| format!("unknown stream {stream_id}")) + .cloned()?; + + let output_dir = manager.output_root.join(stream_dir_name(&stream_id)); + (source.stream_url, output_dir, manager.port) + }; + + let stream_id_clone = stream_id.clone(); + let output_dir_clone = output_dir.clone(); + let process = tokio::task::spawn_blocking(move || { + spawn_ffmpeg_cmaf_ladder(&stream_url, &output_dir_clone, DEFAULT_SEGMENT_MS, 6, true) + }) + .await + .map_err(|err| err.to_string()) + .and_then(|res| res.map_err(|err| err.to_string()))?; + + let mut manager = state.lock().await; + manager.processes.insert( + stream_id.clone(), + StreamProcess { + _child: process, + _output_dir: output_dir, + }, + ); + + Ok(PlaybackInfo { + stream_id: stream_id_clone.clone(), + url: playback_url(port, &stream_id_clone), + }) +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct MoqStartArgs { + remote: String, + broadcast_name: String, + stream_id: Option, + track_name: Option, + /// When true, subscribe to all known variants and write a master playlist so the player can + /// auto-pick quality. This increases inbound bandwidth. + auto_quality: Option, + /// When set, subscribe to a specific variant id (e.g. "720p"). + variant: Option, + network_secret: Option, + discovery: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct CatalogWatchArgs { + peers: Vec, + discovery: Option, +} + +#[tauri::command] +async fn start_catalog_watch( + args: CatalogWatchArgs, + state: State<'_, Arc>>, +) -> Result<(), String> { + { + let manager = state.lock().await; + if manager.catalog_process.is_some() { + return Ok(()); + } + } + + let state = state.inner().clone(); + let state_for_task = state.clone(); + let peers = args.peers.clone(); + let discovery = parse_discovery(args.discovery.as_deref())?; + let task = tauri::async_runtime::spawn(async move { + let node = match MoqNode::bind_with_discovery(None, discovery).await { + Ok(node) => node, + Err(err) => { + tracing::error!("catalog gossip failed to start: {err:#}"); + return; + } + }; + + let mdns = if discovery.mdns { + match ec_iroh::MdnsDiscovery::start( + node.endpoint(), + Some(ec_iroh::MDNS_USER_DATA), + true, + ) + .await + { + Ok(mdns) => Some(mdns), + Err(err) => { + tracing::warn!("mdns discovery unavailable: {err:#}"); + None + } + } + } else { + None + }; + + let mut peer_list = parse_gossip_peers(peers); + if let Some(mdns) = mdns.as_ref() { + match mdns.discover_peers(Duration::from_secs(2)).await { + Ok(found) => { + for addr in found { + if let Ok(encoded) = serde_json::to_string(&addr) { + peer_list.push(encoded); + } + } + } + Err(err) => { + tracing::warn!("mdns peer discovery failed: {err:#}"); + } + } + } + let peer_list = merge_peer_strings(peer_list); + + let mut gossip = + match ec_iroh::CatalogGossip::join(node.endpoint().clone(), &peer_list).await { + Ok(gossip) => gossip, + Err(err) => { + tracing::error!("catalog gossip join failed: {err:#}"); + return; + } + }; + + // Keep adding newly discovered peers over time so "nearby directory" can + // come online without manual contact entry. This is intentionally best-effort. + let mdns_for_loop = mdns.clone(); + let mut last_refresh = Instant::now() - Duration::from_secs(10); + + loop { + if let Some(mdns) = mdns_for_loop.as_ref() { + if last_refresh.elapsed() >= Duration::from_secs(5) { + last_refresh = Instant::now(); + match mdns.discover_peers(Duration::from_millis(800)).await { + Ok(found) => gossip.add_peers(found), + Err(err) => tracing::debug!("mdns peer refresh failed: {err:#}"), + } + } + } + + match gossip.next_entry().await { + Ok(Some(entry)) => { + let descriptor = catalog_entry_to_descriptor(entry); + let mut manager = state_for_task.lock().await; + manager + .catalog_streams + .insert(descriptor.id.0.clone(), descriptor); + } + Ok(None) => break, + Err(err) => { + tracing::warn!("catalog gossip error: {err:#}"); + tokio::time::sleep(Duration::from_secs(1)).await; + } + } + } + + let _mdns = mdns; + }); + + let mut manager = state.lock().await; + manager.catalog_process = Some(CatalogProcess { _task: task }); + Ok(()) +} + +#[tauri::command] +async fn start_moq_stream( + args: MoqStartArgs, + state: State<'_, Arc>>, +) -> Result { + let stream_id = args + .stream_id + .clone() + .unwrap_or_else(|| args.broadcast_name.clone()); + + let (output_dir, port) = { + let mut manager = state.lock().await; + if manager.moq_processes.contains_key(&stream_id) { + let url = playback_url(manager.port, &stream_id); + return Ok(PlaybackInfo { stream_id, url }); + } + let output_dir = manager.output_root.join(stream_dir_name(&stream_id)); + (output_dir, manager.port) + }; + + let output_dir_clone = output_dir.clone(); + let broadcast_name = args.broadcast_name.clone(); + let base_track_name = args + .track_name + .clone() + .unwrap_or_else(|| DEFAULT_TRACK_NAME.to_string()); + let auto_quality = args.auto_quality.unwrap_or(false); + let variant = args.variant.clone().and_then(|v| { + let t = v.trim().to_string(); + if t.is_empty() { + None + } else { + Some(t) + } + }); + let remote = ec_iroh::parse_endpoint_addr(&args.remote).map_err(|err| err.to_string())?; + let network_secret = + parse_network_secret(args.network_secret).map_err(|err| err.to_string())?; + let stream_id_for_key = args.stream_id.clone(); + + let discovery = parse_discovery(args.discovery.as_deref())?; + let node = MoqNode::bind_with_discovery(None, discovery) + .await + .map_err(|err| err.to_string())?; + let mdns = if discovery.mdns { + match ec_iroh::MdnsDiscovery::start(node.endpoint(), Some(ec_iroh::MDNS_USER_DATA), true) + .await + { + Ok(mdns) => Some(mdns), + Err(err) => { + tracing::warn!("mdns discovery unavailable: {err:#}"); + None + } + } + } else { + None + }; + + struct VariantSub { + id: String, + stream: ec_moq::MoqObjectStream, + init_stream: Option, + output_dir: PathBuf, + } + + let variants = if auto_quality { + let variants = default_cmaf_variants(); + let base = base_track_name + .split('/') + .next() + .unwrap_or(DEFAULT_TRACK_NAME) + .to_string(); + let mut subs = Vec::new(); + for v in &variants { + let chunk_track = format!("{}/{}", base, v.id); + let init_track = format!("init/{}", v.id); + let init_stream = node + .subscribe_objects(remote.clone(), &broadcast_name, &init_track) + .await + .ok(); + let stream = match node + .subscribe_objects(remote.clone(), &broadcast_name, &chunk_track) + .await + { + Ok(s) => s, + Err(err) => { + tracing::warn!("variant {} subscribe failed: {err:#}", v.id); + continue; + } + }; + subs.push(VariantSub { + id: v.id.to_string(), + stream, + init_stream, + output_dir: output_dir_clone.join(v.id), + }); + } + Some((variants, subs)) + } else { + None + }; + + let (single_stream, single_init_stream) = if !auto_quality { + let base = base_track_name + .split('/') + .next() + .unwrap_or(DEFAULT_TRACK_NAME) + .to_string(); + let (track_name, init_track) = if let Some(v) = variant.as_deref() { + (format!("{base}/{v}"), format!("init/{v}")) + } else { + let track = base_track_name.clone(); + let init = if let Some(suffix) = track.split('/').last() { + if track.contains('/') && !suffix.is_empty() { + format!("init/{suffix}") + } else { + "init".to_string() + } + } else { + "init".to_string() + }; + (track, init) + }; + let init_stream = node + .subscribe_objects(remote.clone(), &broadcast_name, &init_track) + .await + .ok(); + let stream = node + .subscribe_objects(remote.clone(), &broadcast_name, &track_name) + .await + .map_err(|err| err.to_string())?; + (Some(stream), init_stream) + } else { + (None, None) + }; + + let task = tauri::async_runtime::spawn(async move { + async fn run_variant( + mut stream: ec_moq::MoqObjectStream, + mut init_stream: Option, + output_dir: PathBuf, + broadcast_name: String, + stream_id_for_key: Option, + network_secret: Option>, + ) { + let mut hls = match HlsWriter::new_cmaf(&output_dir, 2.0, 6) { + Ok(hls) => hls, + Err(err) => { + tracing::error!("failed to create hls writer: {err:#}"); + return; + } + }; + let fallback = Duration::from_millis(2000); + let mut fallback_index = 0u64; + let mut init_ready = false; + let mut buffered: Vec<(u64, f64, Vec)> = Vec::new(); + + loop { + tokio::select! { + biased; + init_obj = async { if let Some(s) = init_stream.as_mut() { s.recv().await } else { None } }, if !init_ready && init_stream.is_some() => { + let Some(object) = init_obj else { + init_stream = None; + continue; + }; + let index = object.meta.timing.as_ref().map(|t| t.chunk_index).unwrap_or(0); + let key_id = object.meta.encryption.as_ref().map(|enc| enc.key_id.as_str()).unwrap_or(&broadcast_name); + let init = if let Some(enc) = &object.meta.encryption { + if enc.alg != ENCRYPTION_ALG { + tracing::warn!("init: unsupported encryption {}", enc.alg); + continue; + } + match decrypt_stream_data(key_id, index, &object.data, network_secret.as_deref()) { + Some(plaintext) => plaintext, + None => { + tracing::warn!("init: decryption failed"); + continue; + } + } + } else { + object.data + }; + if let Err(err) = hls.write_init_segment(&init) { + tracing::warn!("failed to write init segment: {err:#}"); + continue; + } + init_ready = true; + buffered.sort_by_key(|(idx, _, _)| *idx); + for (idx, dur, bytes) in buffered.drain(..) { + if let Err(err) = hls.write_segment(idx, dur, &bytes) { + tracing::warn!("failed to write buffered segment: {err:#}"); + } + } + continue; + } + obj = stream.recv() => { + let Some(object) = obj else { break; }; + let index = object + .meta + .timing + .as_ref() + .map(|t| t.chunk_index) + .unwrap_or_else(|| { + let current = fallback_index; + fallback_index += 1; + current + }); + + let stream_id = stream_id_for_key + .as_deref() + .or_else(|| object.meta.encryption.as_ref().map(|enc| enc.key_id.as_str())) + .unwrap_or(&broadcast_name); + + let data = if let Some(enc) = &object.meta.encryption { + if enc.alg != ENCRYPTION_ALG { + tracing::warn!("unsupported encryption {}", enc.alg); + continue; + } + match decrypt_stream_data(stream_id, index, &object.data, network_secret.as_deref()) { + Some(plaintext) => plaintext, + None => { + tracing::warn!("decryption failed for chunk {}", index); + continue; + } + } + } else { + object.data + }; + + let duration = chunk_duration_secs(&object.meta, fallback); + if !init_ready { + buffered.push((index, duration, data)); + continue; + } + if let Err(err) = hls.write_segment(index, duration, &data) { + tracing::warn!("failed to write hls segment: {err:#}"); + } + } + } + } + } + + if auto_quality { + let Some((variants, subs)) = variants else { + tracing::warn!("auto quality enabled, but no variant subscriptions were created"); + return; + }; + if let Err(err) = write_hls_master_playlist(&output_dir_clone, &variants, 128_000) { + tracing::warn!("failed to write master playlist: {err:#}"); + } + let mut handles = Vec::new(); + for sub in subs { + let out = sub.output_dir; + let b = broadcast_name.clone(); + let sid = stream_id_for_key.clone(); + let secret = network_secret.clone(); + handles.push(tokio::spawn(async move { + run_variant(sub.stream, sub.init_stream, out, b, sid, secret).await + })); + } + for h in handles { + let _ = h.await; + } + return; + } + + let Some(stream) = single_stream else { + return; + }; + run_variant( + stream, + single_init_stream, + output_dir_clone, + broadcast_name, + stream_id_for_key, + network_secret, + ) + .await; + }); + + let mut manager = state.lock().await; + manager.moq_processes.insert( + stream_id.clone(), + MoqStreamProcess { + _task: task, + _node: node, + _output_dir: output_dir, + _mdns: mdns, + }, + ); + + Ok(PlaybackInfo { + stream_id: stream_id.clone(), + url: playback_url(port, &stream_id), + }) +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct MoqPublishArgs { + stream_id: String, + network_secret: Option, + chunk_ms: Option, + announce: bool, + gossip_peers: Vec, + discovery: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ShareInfo { + stream_id: String, + endpoint_addr: String, + endpoint_id: String, + broadcast_name: String, + track_name: String, + discovery: Option, + announce_status: Option, +} + +#[tauri::command] +async fn start_moq_publish( + args: MoqPublishArgs, + state: State<'_, Arc>>, +) -> Result { + let (stream_url, output_dir, stream_id, chunk_ms, descriptor) = { + let mut manager = state.lock().await; + if let Some(existing) = manager.moq_publishes.get(&args.stream_id) { + return Ok(existing.share.clone()); + } + + let source = manager + .sources + .get(&args.stream_id) + .or_else(|| manager.manual_sources.get(&args.stream_id)) + .ok_or_else(|| format!("unknown stream {}", args.stream_id)) + .cloned()?; + let descriptor = manager + .streams + .iter() + .chain(manager.manual_streams.iter()) + .find(|stream| stream.id.0 == args.stream_id) + .cloned() + .unwrap_or_else(|| StreamDescriptor { + id: StreamId(args.stream_id.clone()), + title: source.title.clone(), + number: source.number.clone(), + source: "source".to_string(), + metadata: source.metadata.clone(), + }); + let output_dir = manager + .output_root + .join("publish") + .join(stream_dir_name(&args.stream_id)); + ( + source.stream_url, + output_dir, + args.stream_id.clone(), + args.chunk_ms.unwrap_or(DEFAULT_SEGMENT_MS), + descriptor, + ) + }; + + fs::create_dir_all(&output_dir) + .with_context(|| format!("failed to create {}", output_dir.display())) + .map_err(|err| err.to_string())?; + + let variants = default_cmaf_variants(); + let init_track_prefix = "init".to_string(); + let chunks_track_prefix = DEFAULT_TRACK_NAME.to_string(); + let manifest_track_name = DEFAULT_MANIFEST_TRACK_NAME.to_string(); + + let discovery = parse_discovery(args.discovery.as_deref())?; + let node = MoqNode::bind_with_discovery(None, discovery) + .await + .map_err(|err| err.to_string())?; + let mdns = if discovery.mdns { + match ec_iroh::MdnsDiscovery::start(node.endpoint(), Some(ec_iroh::MDNS_USER_DATA), true) + .await + { + Ok(mdns) => Some(mdns), + Err(err) => { + tracing::warn!("mdns discovery unavailable: {err:#}"); + None + } + } + } else { + None + }; + let endpoint = node.endpoint().clone(); + let endpoint_id = node.endpoint().id().to_string(); + let endpoint_addr = serde_json::to_string(&node.endpoint_addr()) + .unwrap_or_else(|_| node.endpoint().id().to_string()); + let broadcast_name = stream_id.clone(); + let track_name = chunks_track_prefix.clone(); + + let mut object_tracks = Vec::new(); + // Back-compat: also publish a single default variant on the base tracks so simple links + // (track=chunks) still work. + object_tracks.push(chunks_track_prefix.clone()); + object_tracks.push(init_track_prefix.clone()); + for variant in &variants { + object_tracks.push(format!("{}/{}", chunks_track_prefix, variant.id)); + object_tracks.push(format!("{}/{}", init_track_prefix, variant.id)); + } + let mut publish_set = node + .publish_track_set( + &broadcast_name, + object_tracks, + vec![manifest_track_name.clone()], + ) + .await + .map_err(|err| err.to_string())?; + + let network_secret = + parse_network_secret(args.network_secret).map_err(|err| err.to_string())?; + + let share = ShareInfo { + stream_id: stream_id.clone(), + endpoint_addr: endpoint_addr.clone(), + endpoint_id, + broadcast_name: broadcast_name.clone(), + track_name: track_name.clone(), + discovery: args.discovery.clone(), + announce_status: None, + }; + + let stream_id_for_key = stream_id.clone(); + let share_for_task = share.clone(); + let task = tauri::async_runtime::spawn_blocking(move || { + let result: Result<(), String> = (|| { + // Spawn FFmpeg ladder segmenter and publish init+segments as encrypted objects. + let mut child = spawn_ffmpeg_cmaf_ladder(&stream_url, &output_dir, chunk_ms, 0, false) + .map_err(|err| err.to_string())?; + + for variant in &variants { + let init_path = output_dir.join(variant.id).join("init.mp4"); + wait_for_stable_file(&init_path, Duration::from_secs(20)) + .map_err(|err| err.to_string())?; + let data = fs::read(&init_path).map_err(|err| err.to_string())?; + let key_id = format!( + "{}/init", + derive_variant_stream_id(&stream_id_for_key, variant.id) + ); + let object = build_object_bytes( + &key_id, + 0, + 0, + "init", + data, + network_secret.as_deref(), + "video/mp4", + None, + ) + .map_err(|err| err.to_string())?; + let base_copy = object.clone(); + publish_set + .publish_object( + &format!("{}/{}", init_track_prefix, variant.id), + GroupId(0), + object, + ) + .map_err(|err| err.to_string())?; + if variant.id == "720p" { + publish_set + .publish_object(&init_track_prefix, GroupId(0), base_copy) + .map_err(|err| err.to_string())?; + } + } + + let mut manifest_seq: u64 = 0; + let mut index: u64 = 0; + loop { + let mut per_variant_hash = Vec::new(); + let mut per_variant_data = Vec::new(); + for variant in &variants { + let seg_path = output_dir + .join(variant.id) + .join(format!("segment_{index:06}.m4s")); + match wait_for_stable_file(&seg_path, Duration::from_secs(30)) { + Ok(()) => {} + Err(err) => { + if let Ok(Some(status)) = child.try_wait() { + if status.success() { + return Ok(()); + } + return Err(format!("ffmpeg exited with {status} ({err:#})")); + } + return Err(err.to_string()); + } + } + let data = fs::read(&seg_path).map_err(|err| err.to_string())?; + let hash = blake3::hash(&data).to_hex().to_string(); + per_variant_hash.push((variant.id.to_string(), hash)); + per_variant_data.push((variant, data)); + } + + let manifest = build_multi_variant_manifest( + &stream_id_for_key, + chunk_ms, + index, + &variants, + &per_variant_hash, + ) + .map_err(|err| err.to_string())?; + + publish_set + .publish_manifest(&manifest_track_name, manifest_seq, &manifest) + .map_err(|err| err.to_string())?; + manifest_seq += 1; + + for (variant, data) in per_variant_data { + let key_id = derive_variant_stream_id(&stream_id_for_key, variant.id); + let object = build_object_bytes( + &key_id, + index, + chunk_ms * 27_000, + "cmaf", + data, + network_secret.as_deref(), + "video/iso.segment", + Some(&manifest.manifest_id), + ) + .map_err(|err| err.to_string())?; + let base_copy = object.clone(); + publish_set + .publish_object( + &format!("{}/{}", chunks_track_prefix, variant.id), + GroupId(index + 1), + object, + ) + .map_err(|err| err.to_string())?; + if variant.id == "720p" { + publish_set + .publish_object(&chunks_track_prefix, GroupId(index + 1), base_copy) + .map_err(|err| err.to_string())?; + } + } + + index += 1; + } + })(); + + if let Err(err) = result { + tracing::warn!("moq publish task ended: {err}"); + } + }); + + let mut manager = state.lock().await; + manager.moq_publishes.insert( + stream_id.clone(), + MoqPublishProcess { + _task: task, + _node: node, + _mdns: mdns.clone(), + share: share_for_task, + }, + ); + + let mut share = share; + if args.announce { + let mut peers = parse_gossip_peers(args.gossip_peers); + if let Some(mdns) = mdns.as_ref() { + match mdns.discover_peers(Duration::from_secs(2)).await { + Ok(found) => { + for addr in found { + if let Ok(encoded) = serde_json::to_string(&addr) { + peers.push(encoded); + } + } + } + Err(err) => { + tracing::warn!("mdns peer discovery failed: {err:#}"); + } + } + } + let peers = merge_peer_strings(peers); + if peers.is_empty() { + share.announce_status = Some("no gossip peers configured".to_string()); + return Ok(share); + } + let entry = build_catalog_entry(&descriptor, &endpoint_addr, &broadcast_name, &track_name); + match ec_iroh::CatalogGossip::join(endpoint.clone(), &peers).await { + Ok(mut gossip) => match gossip.announce(entry).await { + Ok(_) => share.announce_status = Some("announced".to_string()), + Err(err) => share.announce_status = Some(format!("announce failed: {err}")), + }, + Err(err) => { + share.announce_status = Some(format!("gossip join failed: {err}")); + } + } + } + + Ok(share) +} + +async fn load_persisted_manual_sources( + app: AppHandle, + state: Arc>, +) -> Result<()> { + let entries = load_manual_sources(&app)?; + if entries.is_empty() { + let mut manager = state.lock().await; + manager.manual_entries_loaded = true; + return Ok(()); + } + + { + let mut manager = state.lock().await; + if manager.manual_entries_loaded { + return Ok(()); + } + manager.manual_entries_loaded = true; + manager.manual_entries = entries.clone(); + } + + for entry in entries { + match entry.kind.as_str() { + "hdhr" => { + let host = entry.input.clone(); + let result = tokio::task::spawn_blocking(move || hydrate_hdhr_host(&host)) + .await + .map_err(|err| anyhow!("manual host task failed: {err}"))?; + match result { + Ok((device, streams, sources)) => { + let mut manager = state.lock().await; + add_manual_entries(&mut manager, entry.input, device, streams, sources); + } + Err(err) => { + tracing::warn!("failed to load manual HDHomeRun {}: {err:#}", entry.input); + } + } + } + "ytdlp" => { + let app_clone = app.clone(); + let entry_clone = entry.clone(); + let result = tokio::task::spawn_blocking(move || { + resolve_ytdlp_stream( + &app_clone, + &entry_clone.input, + entry_clone.options.clone(), + ) + }) + .await + .map_err(|err| anyhow!("manual yt-dlp task failed: {err}"))?; + match result { + Ok(resolved) => { + let mut manager = state.lock().await; + if !manager + .manual_streams + .iter() + .any(|existing| existing.id == resolved.descriptor.id) + { + manager.manual_streams.push(resolved.descriptor.clone()); + } + manager + .manual_sources + .insert(resolved.descriptor.id.0.clone(), resolved.source); + manager.manual_source_descriptors.insert( + resolved.source_descriptor.id.clone(), + resolved.source_descriptor, + ); + } + Err(err) => { + tracing::warn!("failed to load yt-dlp source {}: {err:#}", entry.input); + } + } + } + "hls" => { + let entry_clone = entry.clone(); + let result = + tokio::task::spawn_blocking(move || resolve_hls_stream(&entry_clone.input)) + .await + .map_err(|err| anyhow!("manual hls task failed: {err}"))?; + match result { + Ok(resolved) => { + let mut manager = state.lock().await; + if !manager + .manual_streams + .iter() + .any(|existing| existing.id == resolved.descriptor.id) + { + manager.manual_streams.push(resolved.descriptor.clone()); + } + manager + .manual_sources + .insert(resolved.descriptor.id.0.clone(), resolved.source); + manager.manual_source_descriptors.insert( + resolved.source_descriptor.id.clone(), + resolved.source_descriptor, + ); + } + Err(err) => { + tracing::warn!("failed to load hls source {}: {err:#}", entry.input); + } + } + } + "linux-dvb" => { + let entry_clone = entry.clone(); + let result = tokio::task::spawn_blocking(move || { + let url = Url::parse(&entry_clone.input).context("invalid linux-dvb url")?; + resolve_linux_dvb_stream(&url) + }) + .await + .map_err(|err| anyhow!("manual linux-dvb task failed: {err}"))?; + match result { + Ok(resolved) => { + let mut manager = state.lock().await; + if !manager + .manual_streams + .iter() + .any(|existing| existing.id == resolved.descriptor.id) + { + manager.manual_streams.push(resolved.descriptor.clone()); + } + manager + .manual_sources + .insert(resolved.descriptor.id.0.clone(), resolved.source); + manager.manual_source_descriptors.insert( + resolved.source_descriptor.id.clone(), + resolved.source_descriptor, + ); + } + Err(err) => { + tracing::warn!("failed to load linux-dvb source {}: {err:#}", entry.input); + } + } + } + other => { + tracing::warn!("unknown manual source kind {other}"); + } + } + } + + Ok(()) +} + +fn main() -> Result<()> { + let output_root = std::env::temp_dir().join("every.channel").join("streams"); + fs::create_dir_all(&output_root)?; + + let port = tauri::async_runtime::block_on(start_http_server(output_root.clone()))?; + let manager = StreamManager::new(port, output_root); + + tauri::Builder::default() + .manage(Arc::new(Mutex::new(manager))) + .setup(|app| { + let app_handle = app.handle().clone(); + let state = app.state::>>().inner().clone(); + tauri::async_runtime::spawn(async move { + if let Err(err) = load_persisted_manual_sources(app_handle, state).await { + tracing::warn!("manual sources load failed: {err:#}"); + } + }); + Ok(()) + }) + .invoke_handler(tauri::generate_handler![ + list_streams, + refresh_streams, + list_sources, + add_hdhr_source, + add_ytdlp_source, + probe_stream, + linux_dvb_list_adapters, + linux_dvb_list_channels, + linux_dvb_build_url, + add_stream, + start_stream, + start_moq_stream, + start_moq_publish, + start_catalog_watch + ]) + .run(tauri::generate_context!()) + .expect("tauri runtime error"); + + Ok(()) +} + +async fn start_http_server(output_root: PathBuf) -> Result { + let router = Router::new().nest_service("/streams", ServeDir::new(output_root)); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?; + let port = listener.local_addr()?.port(); + + tauri::async_runtime::spawn(async move { + let _ = axum::serve(listener, router).await; + }); + + Ok(port) +} + +fn playback_url(port: u16, stream_id: &str) -> String { + let dir = stream_dir_name(stream_id); + format!("http://127.0.0.1:{port}/streams/{dir}/index.m3u8") +} + +fn stream_dir_name(stream_id: &str) -> String { + stream_id + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }) + .collect::() +} + +fn discover_streams() -> Result<(Vec, HashMap)> { + let devices = ec_hdhomerun::discover()?; + let mut streams = Vec::new(); + let mut sources = HashMap::new(); + let mut seen_devices = HashSet::new(); + let mut seen_streams = HashSet::new(); + + for device in devices { + let device_key = if !device.id.0.is_empty() && device.id.0 != "unknown" { + format!("id:{}", device.id.0) + } else { + format!("ip:{}", device.ip) + }; + if !seen_devices.insert(device_key) { + continue; + } + let lineup = ec_hdhomerun::fetch_lineup(&device)?; + for entry in lineup { + let (descriptor, source) = descriptor_from_lineup(&device, &entry); + let id = descriptor.id.0.clone(); + if !seen_streams.insert(id.clone()) { + continue; + } + streams.push(descriptor); + sources.insert(id, source); + } + } + + // Linux DVB: if adapters exist and we can find a channels.conf, expose each channel as a + // stream without requiring manual "add stream" input. Actual scanning is out of scope here; + // we only consume an existing channels.conf. + if let (Ok(adapters), Some(conf_path)) = ( + ec_linux_iptv::list_adapters(), + ec_linux_iptv::find_channels_conf(), + ) { + if let Ok(channels) = ec_linux_iptv::parse_channels_conf(&conf_path) { + for adapter in adapters { + let dvr = adapter.dvrs.first().copied().unwrap_or(0); + for channel in channels.iter() { + let tune_cmd = ec_linux_iptv::default_zap_tune_command( + adapter.adapter, + &conf_path, + channel, + ); + let config = LinuxDvbConfig { + adapter: adapter.adapter, + frontend: 0, + dvr, + tune_command: Some(tune_cmd), + tune_timeout_ms: Some(800), + }; + let stream_url = linux_dvb_url(&config); + + let stream_id = StreamKey { + version: 1, + broadcast: None, + source: Some(SourceId { + kind: "linux-dvb".to_string(), + device_id: Some(format!("adapter{}:dvr{}", adapter.adapter, dvr)), + channel: Some(channel.clone()), + }), + profile: None, + variant: None, + } + .to_stream_id(); + + let id = stream_id.0.clone(); + if !seen_streams.insert(id.clone()) { + continue; + } + + let mut metadata = Vec::new(); + metadata.push(StreamMetadata { + key: "adapter".to_string(), + value: adapter.adapter.to_string(), + }); + metadata.push(StreamMetadata { + key: "dvr".to_string(), + value: dvr.to_string(), + }); + metadata.push(StreamMetadata { + key: "channel".to_string(), + value: channel.clone(), + }); + metadata.push(StreamMetadata { + key: "channels_conf".to_string(), + value: conf_path.display().to_string(), + }); + + let descriptor = StreamDescriptor { + id: stream_id, + title: channel.clone(), + number: None, + source: "linux-dvb".to_string(), + metadata: metadata.clone(), + }; + let source = StreamSource { + stream_url, + title: descriptor.title.clone(), + number: None, + metadata, + }; + streams.push(descriptor); + sources.insert(id, source); + } + } + } + } + + Ok((streams, sources)) +} + +fn descriptor_from_lineup( + device: &HdhomerunDevice, + entry: &LineupEntry, +) -> (StreamDescriptor, StreamSource) { + let stream_id = StreamKey { + version: 1, + broadcast: None, + source: Some(SourceId { + kind: "hdhr".to_string(), + device_id: Some(device.id.0.clone()), + channel: entry + .channel + .number + .clone() + .or_else(|| Some(entry.channel.id.0.clone())), + }), + profile: None, + variant: None, + } + .to_stream_id(); + + let mut metadata = Vec::new(); + metadata.push(StreamMetadata { + key: "device_id".to_string(), + value: device.id.0.clone(), + }); + metadata.push(StreamMetadata { + key: "device_ip".to_string(), + value: device.ip.clone(), + }); + + for channel_meta in &entry.channel.metadata { + match channel_meta { + ec_core::ChannelMetadata::Callsign(value) => metadata.push(StreamMetadata { + key: "callsign".to_string(), + value: value.clone(), + }), + ec_core::ChannelMetadata::Network(value) => metadata.push(StreamMetadata { + key: "network".to_string(), + value: value.clone(), + }), + ec_core::ChannelMetadata::Region(value) => metadata.push(StreamMetadata { + key: "region".to_string(), + value: value.clone(), + }), + ec_core::ChannelMetadata::Frequency(value) => metadata.push(StreamMetadata { + key: "frequency".to_string(), + value: value.clone(), + }), + ec_core::ChannelMetadata::Extra(key, value) => metadata.push(StreamMetadata { + key: key.clone(), + value: value.clone(), + }), + } + } + + if is_drm_entry(entry) { + metadata.push(StreamMetadata { + key: "drm".to_string(), + value: "likely".to_string(), + }); + } + + let title = entry.channel.name.clone(); + + let descriptor = StreamDescriptor { + id: stream_id.clone(), + title: title.clone(), + number: entry.channel.number.clone(), + source: "hdhr".to_string(), + metadata: metadata.clone(), + }; + + let source = StreamSource { + stream_url: entry.stream_url.clone(), + title, + number: entry.channel.number.clone(), + metadata, + }; + + (descriptor, source) +} + +fn is_drm_entry(entry: &LineupEntry) -> bool { + fn looks_drm(value: &str) -> bool { + let value = value.to_lowercase(); + value.contains("drm") + || value.contains("encrypted") + || value.contains("protected") + || value.contains("copy") + || value.contains("widevine") + } + + if entry.tags.iter().any(|tag| looks_drm(tag)) { + return true; + } + + if let Some(obj) = entry.raw.as_object() { + for (key, value) in obj.iter() { + if looks_drm(key) || looks_drm(&value.to_string()) { + return true; + } + } + } + + false +} + +fn discover_sources() -> Result> { + let devices = ec_hdhomerun::discover()?; + let mut by_key: HashMap = HashMap::new(); + for device in devices { + let key = device_key(&device); + by_key.entry(key).or_insert(device); + } + let mut sources = by_key + .into_values() + .map(|device| source_descriptor_for_device(&device, "online")) + .collect::>(); + + if let Ok(adapters) = ec_linux_iptv::list_adapters() { + for info in adapters { + sources.push(SourceDescriptor { + id: format!("linux-dvb:adapter{}", info.adapter), + kind: "linux-dvb".to_string(), + name: format!("Linux DVB adapter{}", info.adapter), + ip: None, + tuner_count: Some(info.frontends.len().min(u8::MAX as usize) as u8), + status: "online".to_string(), + }); + } + } + + Ok(sources) +} + +fn merge_source_descriptors<'a, I>( + mut sources: Vec, + devices: I, +) -> Vec +where + I: IntoIterator, +{ + let mut seen: HashSet = sources + .iter() + .map(|source| { + if !source.id.is_empty() && source.id != "unknown" { + format!("id:{}", source.id) + } else { + format!("ip:{}", source.ip.clone().unwrap_or_default()) + } + }) + .collect(); + + for (host, device) in devices { + // Manual HDHomeRun entries are aliases, not distinct devices. Deduplicate by device id when + // possible so the Sources panel does not show the same tuner multiple times. + let key = device_key(device); + if key.starts_with("id:") { + if !seen.insert(key) { + continue; + } + sources.push(source_descriptor_for_device_with_id( + device, + "manual", + device.id.0.clone(), + )); + continue; + } + + let key = manual_source_id(host, device); + if seen.insert(format!("id:{key}")) { + sources.push(source_descriptor_for_device_with_id(device, "manual", key)); + } + } + + sources +} + +fn device_key(device: &HdhomerunDevice) -> String { + if !device.id.0.is_empty() && device.id.0 != "unknown" { + format!("id:{}", device.id.0) + } else { + format!("ip:{}", device.ip) + } +} + +fn source_descriptor_for_device(device: &HdhomerunDevice, status: &str) -> SourceDescriptor { + source_descriptor_for_device_with_id(device, status, device.id.0.clone()) +} + +fn source_descriptor_for_device_with_id( + device: &HdhomerunDevice, + status: &str, + id: String, +) -> SourceDescriptor { + SourceDescriptor { + id, + kind: "hdhr".to_string(), + name: device.friendly_name.clone().unwrap_or_else(|| { + device + .model_number + .clone() + .unwrap_or_else(|| "HDHomeRun".to_string()) + }), + ip: Some(device.ip.clone()), + tuner_count: Some(device.tuner_count), + status: status.to_string(), + } +} + +fn manual_source_id(host: &str, device: &HdhomerunDevice) -> String { + if !device.id.0.is_empty() && device.id.0 != "unknown" { + format!("{}@{}", device.id.0, host) + } else { + host.to_string() + } +} + +fn normalize_host(host: &str) -> String { + let trimmed = host.trim(); + let stripped = trimmed + .strip_prefix("http://") + .or_else(|| trimmed.strip_prefix("https://")) + .unwrap_or(trimmed); + let stripped = stripped.trim_end_matches('/'); + stripped + .split('/') + .next() + .unwrap_or(stripped) + .trim() + .to_string() +} + +fn remember_manual_entry(manager: &mut StreamManager, app: &AppHandle, entry: ManualSourceEntry) { + let Some(entry) = normalize_manual_entry(entry) else { + return; + }; + if !manager + .manual_entries + .iter() + .any(|existing| existing == &entry) + { + manager.manual_entries.push(entry); + if let Err(err) = save_manual_sources(app, &manager.manual_entries) { + tracing::warn!("failed to persist manual sources: {err:#}"); + } + } +} + +#[derive(Debug, Clone, Deserialize)] +struct YtDlpFormat { + format_id: Option, + format: Option, + format_note: Option, + url: Option, + protocol: Option, + tbr: Option, + height: Option, + width: Option, + fps: Option, + ext: Option, + vcodec: Option, + acodec: Option, +} + +#[derive(Debug, Clone, Deserialize)] +struct YtDlpInfo { + id: Option, + title: Option, + webpage_url: Option, + is_live: Option, + live_status: Option, + extractor: Option, + extractor_key: Option, + formats: Option>, + url: Option, +} + +struct ResolvedDirectStream { + descriptor: StreamDescriptor, + source: StreamSource, + source_descriptor: SourceDescriptor, + manual_entry: ManualSourceEntry, +} + +enum ResolvedStream { + Hdhr { + host: String, + device: HdhomerunDevice, + streams: Vec, + sources: HashMap, + }, + Direct { + entry: ResolvedDirectStream, + }, +} + +impl ResolvedStream { + fn kind_name(&self) -> String { + match self { + ResolvedStream::Hdhr { .. } => "hdhr".to_string(), + ResolvedStream::Direct { entry } => entry.manual_entry.kind.clone(), + } + } +} + +fn resolve_ytdlp_stream( + app: &AppHandle, + url: &str, + options: Option, +) -> Result { + let info = run_ytdlp_json(app, url, options.as_ref())?; + if !is_ytdlp_live(&info) { + return Err(anyhow!("yt-dlp stream is not live")); + } + let stream_url = pick_ytdlp_stream_url(&info) + .ok_or_else(|| anyhow!("yt-dlp did not return a usable stream url"))?; + let stream_id = StreamKey { + version: 1, + broadcast: None, + source: Some(SourceId { + kind: "ytdlp".to_string(), + device_id: info.id.clone(), + channel: Some(info.webpage_url.clone().unwrap_or_else(|| url.to_string())), + }), + profile: Some("hls".to_string()), + variant: None, + } + .to_stream_id(); + + let title = info + .title + .clone() + .unwrap_or_else(|| "yt-dlp stream".to_string()); + let mut metadata = Vec::new(); + metadata.push(StreamMetadata { + key: "source_kind".to_string(), + value: "ytdlp".to_string(), + }); + metadata.push(StreamMetadata { + key: "origin_url".to_string(), + value: info.webpage_url.clone().unwrap_or_else(|| url.to_string()), + }); + if let Some(id) = info.id.clone() { + metadata.push(StreamMetadata { + key: "ytdlp_id".to_string(), + value: id, + }); + } + if let Some(live) = info.is_live { + metadata.push(StreamMetadata { + key: "is_live".to_string(), + value: live.to_string(), + }); + } + + let descriptor = StreamDescriptor { + id: stream_id.clone(), + title: title.clone(), + number: None, + source: "ytdlp".to_string(), + metadata: metadata.clone(), + }; + + let source = StreamSource { + stream_url: stream_url.clone(), + title: title.clone(), + number: None, + metadata: metadata.clone(), + }; + + let source_id = info + .id + .clone() + .map(|id| format!("ytdlp:{id}")) + .unwrap_or_else(|| format!("ytdlp:{}", stream_id.0)); + + let source_descriptor = SourceDescriptor { + id: source_id, + kind: "ytdlp".to_string(), + name: title, + ip: None, + tuner_count: None, + status: if info.is_live.unwrap_or(false) { + "live".to_string() + } else { + "ready".to_string() + }, + }; + + Ok(ResolvedDirectStream { + descriptor, + source, + source_descriptor, + manual_entry: ManualSourceEntry { + kind: "ytdlp".to_string(), + input: url.to_string(), + options, + }, + }) +} + +fn run_ytdlp_json( + app: &AppHandle, + url: &str, + options: Option<&ManualSourceOptions>, +) -> Result { + let python = resolve_ytdlp_python(app)?; + let mut cmd = Command::new(python); + cmd.arg("-m") + .arg("yt_dlp") + .arg("-J") + .arg("--no-playlist") + .arg("--no-warnings") + .arg("--no-progress"); + if let Some(options) = options { + if let Some(format_id) = options.ytdlp_format.as_ref() { + if !format_id.trim().is_empty() { + cmd.arg("-f").arg(format_id); + } + } + if options.ytdlp_live_from_start { + cmd.arg("--live-from-start"); + } + } + let output = cmd + .arg(url) + .env("PYTHONNOUSERSITE", "1") + .output() + .context("failed to run yt-dlp")?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(anyhow!("yt-dlp failed: {stderr}")); + } + parse_ytdlp_json(&output.stdout) +} + +fn parse_ytdlp_json(output: &[u8]) -> Result { + let text = String::from_utf8_lossy(output); + if let Ok(info) = serde_json::from_str::(text.trim()) { + return Ok(info); + } + for line in text.lines().rev() { + if let Ok(info) = serde_json::from_str::(line.trim()) { + return Ok(info); + } + } + Err(anyhow!("failed to parse yt-dlp json output")) +} + +fn pick_ytdlp_stream_url(info: &YtDlpInfo) -> Option { + let mut best: Option<(f64, String)> = None; + if let Some(formats) = info.formats.as_ref() { + for format in formats { + let url = match format.url.as_ref() { + Some(url) => url, + None => continue, + }; + let mut score = 0.0; + if let Some(protocol) = format.protocol.as_ref() { + let protocol = protocol.to_lowercase(); + if protocol.contains("m3u8") { + score += 1000.0; + } + } + if let Some(ext) = format.ext.as_ref() { + if ext.eq_ignore_ascii_case("mp4") { + score += 10.0; + } + } + if let Some(height) = format.height { + score += height as f64; + } + if let Some(tbr) = format.tbr { + score += tbr; + } + match best { + Some((best_score, _)) if best_score >= score => {} + _ => best = Some((score, url.clone())), + } + } + } + if let Some((_, url)) = best { + return Some(url); + } + info.url.clone() +} + +fn resolve_ytdlp_python(app: &AppHandle) -> Result { + if let Ok(path) = std::env::var("EVERY_CHANNEL_YTDLP_PYTHON") { + return Ok(PathBuf::from(path)); + } + let target = match std::env::consts::OS { + "macos" => "macos", + "linux" => "linux", + "windows" => "windows", + other => other, + }; + let base = app + .path() + .resolve(format!("yt-dlp/{target}/venv"), BaseDirectory::Resource) + .context("failed to resolve yt-dlp resource path")?; + let python = if cfg!(windows) { + base.join("Scripts").join("python.exe") + } else { + base.join("bin").join("python") + }; + if python.exists() { + Ok(python) + } else { + Err(anyhow!( + "yt-dlp runtime not bundled; run scripts/vendor-yt-dlp.sh" + )) + } +} + +fn probe_stream_input(app: &AppHandle, input: &str) -> Result { + let trimmed = input.trim(); + if trimmed.eq_ignore_ascii_case("linux-dvb") || trimmed.eq_ignore_ascii_case("dvb") { + return Ok(StreamProbe { + kind: "linux-dvb".to_string(), + live: true, + requires_options: true, + message: Some("Select adapter + channel".to_string()), + ytdlp: None, + }); + } + if !input.contains("://") { + let host = normalize_host(input); + if host.is_empty() { + return Err(anyhow!("input is required")); + } + if ec_hdhomerun::discover_from_host(&host).is_ok() { + return Ok(StreamProbe { + kind: "hdhr".to_string(), + live: true, + requires_options: false, + message: None, + ytdlp: None, + }); + } + return Err(anyhow!("input is not a valid URL or HDHomeRun host")); + } + + let url = Url::parse(input).context("invalid url")?; + if is_linux_dvb_scheme(&url) { + let _config = parse_linux_dvb_url(&url)?; + return Ok(StreamProbe { + kind: "linux-dvb".to_string(), + live: true, + requires_options: false, + message: None, + ytdlp: None, + }); + } + if let Some(host) = url.host_str() { + if is_likely_local_host(host) && ec_hdhomerun::discover_from_host(host).is_ok() { + return Ok(StreamProbe { + kind: "hdhr".to_string(), + live: true, + requires_options: false, + message: None, + ytdlp: None, + }); + } + } + + if url.as_str().contains(".m3u8") { + let _resolved = probe_hls_live(url.as_str())?; + return Ok(StreamProbe { + kind: "hls".to_string(), + live: true, + requires_options: false, + message: None, + ytdlp: None, + }); + } + + let info = run_ytdlp_json(app, input, None)?; + if !is_ytdlp_live(&info) { + return Err(anyhow!("yt-dlp stream is not live")); + } + let probe = build_ytdlp_probe(&info); + Ok(StreamProbe { + kind: "ytdlp".to_string(), + live: true, + requires_options: !probe.formats.is_empty(), + message: None, + ytdlp: Some(probe), + }) +} + +fn resolve_stream_input( + app: &AppHandle, + input: &str, + options: Option, +) -> Result { + let trimmed = input.trim(); + if trimmed.eq_ignore_ascii_case("linux-dvb") || trimmed.eq_ignore_ascii_case("dvb") { + return Err(anyhow!( + "linux-dvb requires options; use the linux DVB picker" + )); + } + if !input.contains("://") { + let host = normalize_host(input); + let (device, streams, sources) = hydrate_hdhr_host(&host)?; + return Ok(ResolvedStream::Hdhr { + host, + device, + streams, + sources, + }); + } + + let url = Url::parse(input).context("invalid url")?; + if is_linux_dvb_scheme(&url) { + let resolved = resolve_linux_dvb_stream(&url)?; + return Ok(ResolvedStream::Direct { entry: resolved }); + } + if let Some(host) = url.host_str() { + if is_likely_local_host(host) { + let (device, streams, sources) = hydrate_hdhr_host(host)?; + return Ok(ResolvedStream::Hdhr { + host: host.to_string(), + device, + streams, + sources, + }); + } + } + + if url.as_str().contains(".m3u8") { + let resolved = resolve_hls_stream(input)?; + return Ok(ResolvedStream::Direct { entry: resolved }); + } + + let resolved = resolve_ytdlp_stream(app, input, options)?; + Ok(ResolvedStream::Direct { entry: resolved }) +} + +fn is_linux_dvb_scheme(url: &Url) -> bool { + matches!(url.scheme(), "dvb" | "linux-dvb") +} + +fn resolve_linux_dvb_stream(url: &Url) -> Result { + let config = parse_linux_dvb_url(url)?; + let stream_id = StreamKey { + version: 1, + broadcast: None, + source: Some(SourceId { + kind: "linux-dvb".to_string(), + device_id: Some(format!("adapter{}:dvr{}", config.adapter, config.dvr)), + channel: None, + }), + profile: None, + variant: None, + } + .to_stream_id(); + + let title = format!("Linux DVB adapter{} dvr{}", config.adapter, config.dvr); + let descriptor = StreamDescriptor { + id: stream_id.clone(), + title: title.clone(), + number: None, + source: "linux-dvb".to_string(), + metadata: vec![ + StreamMetadata { + key: "adapter".to_string(), + value: config.adapter.to_string(), + }, + StreamMetadata { + key: "dvr".to_string(), + value: config.dvr.to_string(), + }, + ], + }; + + let stream_url = linux_dvb_url(&config); + let source = StreamSource { + stream_url: stream_url.clone(), + title, + number: None, + metadata: descriptor.metadata.clone(), + }; + + let source_descriptor = SourceDescriptor { + id: format!("linux-dvb:adapter{}:dvr{}", config.adapter, config.dvr), + kind: "linux-dvb".to_string(), + name: format!("Linux DVB adapter{} dvr{}", config.adapter, config.dvr), + ip: None, + tuner_count: None, + status: "manual".to_string(), + }; + + Ok(ResolvedDirectStream { + descriptor, + source, + source_descriptor, + manual_entry: ManualSourceEntry { + kind: "linux-dvb".to_string(), + input: stream_url, + options: None, + }, + }) +} + +fn parse_linux_dvb_url(url: &Url) -> Result { + let mut adapter = None; + let mut dvr = None; + let mut tune_cmd = Vec::new(); + let mut tune_wait_ms = None; + + for (key, value) in url.query_pairs() { + match key.as_ref() { + "adapter" => adapter = value.parse::().ok(), + "dvr" => dvr = value.parse::().ok(), + "tune" | "tune_cmd" => tune_cmd.push(value.to_string()), + "tune_wait_ms" => tune_wait_ms = value.parse::().ok(), + _ => {} + } + } + + if adapter.is_none() || dvr.is_none() { + let segments = url + .path_segments() + .map(|segments| segments.collect::>()) + .unwrap_or_default(); + if segments.len() >= 2 && segments[0].starts_with("adapter") { + adapter = segments[0] + .trim_start_matches("adapter") + .parse::() + .ok(); + } + if segments.len() >= 3 && segments[1] == "dvr" { + dvr = segments[2].parse::().ok(); + } else if segments.len() >= 2 && segments[1].starts_with("dvr") { + dvr = segments[1].trim_start_matches("dvr").parse::().ok(); + } + } + + let adapter = adapter.unwrap_or(0); + let dvr = dvr.unwrap_or(0); + + Ok(LinuxDvbConfig { + adapter, + frontend: 0, + dvr, + tune_command: if tune_cmd.is_empty() { + None + } else { + Some(tune_cmd) + }, + tune_timeout_ms: tune_wait_ms, + }) +} + +fn linux_dvb_url(config: &LinuxDvbConfig) -> String { + let mut url = Url::parse("linux-dvb://localhost").expect("static url"); + { + let mut pairs = url.query_pairs_mut(); + pairs.append_pair("adapter", &config.adapter.to_string()); + pairs.append_pair("dvr", &config.dvr.to_string()); + if let Some(cmd) = &config.tune_command { + for part in cmd { + pairs.append_pair("tune", part); + } + } + if let Some(wait) = config.tune_timeout_ms { + pairs.append_pair("tune_wait_ms", &wait.to_string()); + } + } + url.to_string() +} + +fn is_likely_local_host(host: &str) -> bool { + if host.eq_ignore_ascii_case("localhost") || host.ends_with(".local") { + return true; + } + is_private_ip(host) +} + +fn is_private_ip(host: &str) -> bool { + let Ok(ip) = host.parse::() else { + return false; + }; + match ip { + IpAddr::V4(addr) => { + let octets = addr.octets(); + match octets[0] { + 10 => true, + 172 => (16..=31).contains(&octets[1]), + 192 => octets[1] == 168, + 127 => true, + _ => false, + } + } + IpAddr::V6(addr) => addr.is_loopback() || addr.is_unique_local(), + } +} + +fn build_ytdlp_probe(info: &YtDlpInfo) -> YtdlpProbe { + let mut formats = Vec::new(); + if let Some(list) = info.formats.as_ref() { + for format in list { + let format_id = match format.format_id.as_ref() { + Some(id) => id.clone(), + None => continue, + }; + let mut parts = Vec::new(); + if let Some(height) = format.height { + if let Some(width) = format.width { + parts.push(format!("{width}x{height}")); + } else { + parts.push(format!("{height}p")); + } + } + if let Some(tbr) = format.tbr { + parts.push(format!("{tbr:.0} kbps")); + } + if let Some(protocol) = format.protocol.as_ref() { + parts.push(protocol.to_string()); + } + if let Some(ext) = format.ext.as_ref() { + parts.push(ext.to_string()); + } + if let Some(note) = format.format_note.as_ref() { + parts.push(note.to_string()); + } + if let Some(vcodec) = format.vcodec.as_ref() { + if !vcodec.eq_ignore_ascii_case("none") { + parts.push(vcodec.to_string()); + } + } + if let Some(acodec) = format.acodec.as_ref() { + if !acodec.eq_ignore_ascii_case("none") { + parts.push(acodec.to_string()); + } + } + let label = if let Some(format_label) = format.format.as_ref() { + format_label.clone() + } else if parts.is_empty() { + format_id.clone() + } else { + parts.join(" • ") + }; + formats.push(YtdlpFormatOption { format_id, label }); + } + } + let default_format = formats.first().map(|f| f.format_id.clone()); + YtdlpProbe { + title: info.title.clone(), + formats, + default_format, + supports_live_from_start: supports_live_from_start(info), + } +} + +fn supports_live_from_start(info: &YtDlpInfo) -> bool { + let mut key = String::new(); + if let Some(value) = info.extractor_key.as_ref() { + key.push_str(value); + } else if let Some(value) = info.extractor.as_ref() { + key.push_str(value); + } + let key = key.to_lowercase(); + key.contains("youtube") || key.contains("twitch") +} + +fn is_ytdlp_live(info: &YtDlpInfo) -> bool { + if info.is_live == Some(true) { + return true; + } + if let Some(status) = info.live_status.as_ref() { + return status.eq_ignore_ascii_case("is_live"); + } + false +} + +fn resolve_hls_stream(url: &str) -> Result { + let resolved_url = probe_hls_live(url)?; + let parsed = Url::parse(&resolved_url).context("invalid hls url")?; + let host = parsed.host_str().unwrap_or("HLS").to_string(); + let title = format!("HLS {host}"); + let stream_id = StreamKey { + version: 1, + broadcast: None, + source: Some(SourceId { + kind: "hls".to_string(), + device_id: None, + channel: Some(url.to_string()), + }), + profile: Some("hls".to_string()), + variant: None, + } + .to_stream_id(); + let metadata = vec![ + StreamMetadata { + key: "source_kind".to_string(), + value: "hls".to_string(), + }, + StreamMetadata { + key: "origin_url".to_string(), + value: url.to_string(), + }, + StreamMetadata { + key: "resolved_url".to_string(), + value: resolved_url.clone(), + }, + ]; + let descriptor = StreamDescriptor { + id: stream_id.clone(), + title: title.clone(), + number: None, + source: "hls".to_string(), + metadata: metadata.clone(), + }; + let source = StreamSource { + stream_url: resolved_url, + title: title.clone(), + number: None, + metadata: metadata.clone(), + }; + let source_descriptor = SourceDescriptor { + id: format!("hls:{}", stream_id.0), + kind: "hls".to_string(), + name: title, + ip: None, + tuner_count: None, + status: "live".to_string(), + }; + Ok(ResolvedDirectStream { + descriptor, + source, + source_descriptor, + manual_entry: ManualSourceEntry { + kind: "hls".to_string(), + input: url.to_string(), + options: None, + }, + }) +} + +fn probe_hls_live(url: &str) -> Result { + let text = fetch_hls_text(url)?; + if text.contains("#EXT-X-STREAM-INF") { + let base = Url::parse(url).context("invalid hls url")?; + let mut lines = text.lines(); + while let Some(line) = lines.next() { + if line.trim().starts_with("#EXT-X-STREAM-INF") { + for candidate in lines.by_ref() { + let candidate = candidate.trim(); + if candidate.is_empty() || candidate.starts_with('#') { + continue; + } + let resolved = base.join(candidate).context("invalid hls variant url")?; + return probe_hls_live(resolved.as_str()); + } + } + } + } + if text.contains("#EXT-X-ENDLIST") || text.contains("#EXT-X-PLAYLIST-TYPE:VOD") { + return Err(anyhow!("HLS playlist is not live")); + } + Ok(url.to_string()) +} + +fn fetch_hls_text(url: &str) -> Result { + let resp = reqwest_blocking::get(url).context("failed to fetch hls url")?; + if !resp.status().is_success() { + return Err(anyhow!("hls request failed with {}", resp.status())); + } + Ok(resp.text().context("failed to read hls response")?) +} + +fn manual_sources_path(app: &AppHandle) -> Result { + app.path() + .resolve("manual_sources.json", BaseDirectory::AppConfig) + .context("failed to resolve app config path") +} + +fn legacy_manual_hosts_path(app: &AppHandle) -> Result { + app.path() + .resolve("manual_hdhomerun.json", BaseDirectory::AppConfig) + .context("failed to resolve app config path") +} + +fn normalize_manual_entry(mut entry: ManualSourceEntry) -> Option { + entry.input = match entry.kind.as_str() { + "hdhr" => normalize_host(&entry.input), + "linux-dvb" => { + if let Ok(url) = Url::parse(&entry.input) { + if is_linux_dvb_scheme(&url) { + if let Ok(config) = parse_linux_dvb_url(&url) { + linux_dvb_url(&config) + } else { + entry.input.trim().to_string() + } + } else { + entry.input.trim().to_string() + } + } else { + entry.input.trim().to_string() + } + } + _ => entry.input.trim().to_string(), + }; + if entry.input.is_empty() { + None + } else { + Some(entry) + } +} + +fn load_manual_sources(app: &AppHandle) -> Result> { + let path = manual_sources_path(app)?; + let entries: Vec = if path.exists() { + let bytes = + fs::read(&path).with_context(|| format!("failed to read {}", path.display()))?; + serde_json::from_slice(&bytes).context("invalid manual_sources.json")? + } else { + let legacy_path = legacy_manual_hosts_path(app)?; + if !legacy_path.exists() { + Vec::new() + } else { + let bytes = fs::read(&legacy_path) + .with_context(|| format!("failed to read {}", legacy_path.display()))?; + let hosts: Vec = + serde_json::from_slice(&bytes).context("invalid manual_hdhomerun.json")?; + hosts + .into_iter() + .map(|host| ManualSourceEntry { + kind: "hdhr".to_string(), + input: host, + options: None, + }) + .collect() + } + }; + + let mut seen = HashSet::new(); + let mut normalized = Vec::new(); + for entry in entries.into_iter() { + let Some(entry) = normalize_manual_entry(entry) else { + continue; + }; + if seen.insert(entry.clone()) { + normalized.push(entry); + } + } + Ok(normalized) +} + +fn save_manual_sources(app: &AppHandle, entries: &[ManualSourceEntry]) -> Result<()> { + let path = manual_sources_path(app)?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("failed to create {}", parent.display()))?; + } + let data = serde_json::to_vec_pretty(entries)?; + fs::write(&path, data).with_context(|| format!("failed to write {}", path.display()))?; + Ok(()) +} + +fn hydrate_hdhr_host( + host: &str, +) -> Result<( + HdhomerunDevice, + Vec, + HashMap, +)> { + let device = ec_hdhomerun::discover_from_host(host)?; + let lineup = ec_hdhomerun::fetch_lineup(&device)?; + let mut streams = Vec::new(); + let mut sources = HashMap::new(); + let mut seen = HashSet::new(); + for entry in lineup { + let (descriptor, source) = descriptor_from_lineup(&device, &entry); + let id = descriptor.id.0.clone(); + if !seen.insert(id.clone()) { + continue; + } + streams.push(descriptor); + sources.insert(id, source); + } + Ok((device, streams, sources)) +} + +fn add_manual_entries( + manager: &mut StreamManager, + host: String, + device: HdhomerunDevice, + streams: Vec, + sources: HashMap, +) { + manager.manual_devices.insert(host, device); + for stream in streams { + if !manager + .manual_streams + .iter() + .any(|existing| existing.id == stream.id) + { + manager.manual_streams.push(stream); + } + } + for (id, source) in sources { + manager.manual_sources.insert(id, source); + } +} + +fn merge_local_streams( + local: &[StreamDescriptor], + manual: &[StreamDescriptor], +) -> Vec { + let mut merged = local.to_vec(); + let mut seen: HashSet<_> = local.iter().map(|stream| stream.id.0.clone()).collect(); + for stream in manual { + if seen.insert(stream.id.0.clone()) { + merged.push(stream.clone()); + } + } + merged +} + +fn merge_streams( + local: &[StreamDescriptor], + catalog: &HashMap, +) -> Vec { + let mut merged = local.to_vec(); + let existing: HashSet<_> = local.iter().map(|s| s.id.0.clone()).collect(); + for (id, entry) in catalog.iter() { + if !existing.contains(id) { + merged.push(entry.clone()); + } + } + merged +} + +fn catalog_entry_to_descriptor(entry: StreamCatalogEntry) -> StreamDescriptor { + let mut descriptor = entry.stream; + if let Some(moq) = entry.moq { + descriptor.source = "moq".to_string(); + descriptor.metadata.push(StreamMetadata { + key: "moq_endpoint".to_string(), + value: moq.endpoint, + }); + descriptor.metadata.push(StreamMetadata { + key: "moq_broadcast".to_string(), + value: moq.broadcast_name, + }); + descriptor.metadata.push(StreamMetadata { + key: "moq_track".to_string(), + value: moq.track_name, + }); + if let Some(enc) = moq.encryption { + descriptor.metadata.push(StreamMetadata { + key: "moq_enc_alg".to_string(), + value: enc.alg, + }); + descriptor.metadata.push(StreamMetadata { + key: "moq_key_id".to_string(), + value: enc.key_id, + }); + } + } + descriptor +} + +#[derive(Debug, Clone, Copy)] +struct CmafVariantSpec { + id: &'static str, + width: u32, + height: u32, + video_bitrate_kbps: u32, +} + +fn default_cmaf_variants() -> Vec { + vec![ + CmafVariantSpec { + id: "1080p", + width: 1920, + height: 1080, + video_bitrate_kbps: 6000, + }, + CmafVariantSpec { + id: "720p", + width: 1280, + height: 720, + video_bitrate_kbps: 3000, + }, + CmafVariantSpec { + id: "480p", + width: 854, + height: 480, + video_bitrate_kbps: 1200, + }, + ] +} + +fn write_hls_master_playlist( + output_dir: &Path, + variants: &[CmafVariantSpec], + audio_bitrate_bps: u32, +) -> Result<()> { + let mut text = String::new(); + text.push_str("#EXTM3U\n#EXT-X-VERSION:7\n"); + for v in variants { + let bandwidth = (v.video_bitrate_kbps * 1000).saturating_add(audio_bitrate_bps); + text.push_str(&format!( + "#EXT-X-STREAM-INF:BANDWIDTH={bandwidth},RESOLUTION={}x{}\n{}/index.m3u8\n", + v.width, v.height, v.id + )); + } + fs::create_dir_all(output_dir) + .with_context(|| format!("failed to create {}", output_dir.display()))?; + fs::write(output_dir.join("index.m3u8"), text.as_bytes()) + .with_context(|| format!("failed to write {}", output_dir.display()))?; + Ok(()) +} + +fn wait_for_stable_file(path: &Path, timeout: Duration) -> Result<()> { + let start = Instant::now(); + let mut last_len: Option = None; + let mut stable_ms: u64 = 0; + + while start.elapsed() < timeout { + if let Ok(meta) = fs::metadata(path) { + let len = meta.len(); + if len > 0 { + if Some(len) == last_len { + stable_ms += 100; + if stable_ms >= 300 { + return Ok(()); + } + } else { + last_len = Some(len); + stable_ms = 0; + } + } + } + std::thread::sleep(Duration::from_millis(100)); + } + + Err(anyhow!( + "timed out waiting for stable file {} after {:?}", + path.display(), + timeout + )) +} + +fn spawn_ffmpeg_cmaf_ladder( + stream_url: &str, + output_dir: &Path, + chunk_ms: u64, + hls_list_size: usize, + delete_segments: bool, +) -> Result { + let variants = default_cmaf_variants(); + let segment_time = format!("{:.3}", chunk_ms as f64 / 1000.0); + + let _ = fs::remove_dir_all(output_dir); + fs::create_dir_all(output_dir) + .with_context(|| format!("failed to create {}", output_dir.display()))?; + for v in &variants { + fs::create_dir_all(output_dir.join(v.id))?; + } + // Keep playback URL stable: /index.m3u8 is always present. For multi-variant this is a master. + write_hls_master_playlist(output_dir, &variants, 128_000)?; + + if stream_url.starts_with("linux-dvb://") || stream_url.starts_with("dvb://") { + let url = Url::parse(stream_url).context("invalid linux-dvb url")?; + let config = parse_linux_dvb_url(&url)?; + let reader = + ec_linux_iptv::open_stream(&config).context("failed to open linux dvb stream")?; + return spawn_ffmpeg_cmaf_ladder_from_reader( + reader, + output_dir, + &segment_time, + &variants, + hls_list_size, + delete_segments, + ); + } + + spawn_ffmpeg_cmaf_ladder_with_input( + vec!["-i".to_string(), stream_url.to_string()], + None, + output_dir, + &segment_time, + &variants, + hls_list_size, + delete_segments, + ) +} + +fn sanitize_component(value: &str) -> String { + value + .chars() + .map(|c| match c { + 'a'..='z' | '0'..='9' | '-' | '_' | '/' => c, + 'A'..='Z' => c.to_ascii_lowercase(), + _ => '_', + }) + .collect() +} + +fn derive_variant_stream_id(base_stream_id: &str, variant_id: &str) -> String { + let v = sanitize_component(variant_id); + format!("{}/variant-{}", base_stream_id.trim_end_matches('/'), v) +} + +fn build_multi_variant_manifest( + base_stream_id: &str, + chunk_ms: u64, + chunk_index: u64, + variants: &[CmafVariantSpec], + per_variant_hash: &[(String, String)], +) -> Result { + let created_unix_ms = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; + let epoch_id = format!("epoch-{created_unix_ms}"); + + let mut entries = Vec::with_capacity(variants.len()); + for v in variants { + let Some((_, hash)) = per_variant_hash.iter().find(|(id, _)| id == v.id) else { + return Err(anyhow!("missing hash for variant {}", v.id)); + }; + let chunk_hashes = vec![hash.clone()]; + let merkle_root = merkle_root_from_hashes(&chunk_hashes).map_err(|err| anyhow!("{err}"))?; + entries.push(ManifestVariant { + variant_id: v.id.to_string(), + stream_id: StreamId(derive_variant_stream_id(base_stream_id, v.id)), + chunk_start_index: chunk_index, + total_chunks: 1, + merkle_root, + chunk_hashes, + metadata: vec![ + StreamMetadata { + key: "width".to_string(), + value: v.width.to_string(), + }, + StreamMetadata { + key: "height".to_string(), + value: v.height.to_string(), + }, + StreamMetadata { + key: "video_bitrate_kbps".to_string(), + value: v.video_bitrate_kbps.to_string(), + }, + ], + }); + } + entries.sort_by(|a, b| a.variant_id.cmp(&b.variant_id)); + let roots = entries + .iter() + .map(|v| v.merkle_root.clone()) + .collect::>(); + let body_root = merkle_root_from_hashes(&roots).map_err(|err| anyhow!("{err}"))?; + + let body = ec_core::ManifestBody { + stream_id: StreamId(base_stream_id.to_string()), + epoch_id, + chunk_duration_ms: chunk_ms, + total_chunks: 1, + chunk_start_index: chunk_index, + encoder_profile_id: "deterministic-h264-aac".to_string(), + merkle_root: body_root, + created_unix_ms, + metadata: vec![], + chunk_hashes: vec![], + variants: Some(entries), + }; + + let manifest_id = body.manifest_id()?; + let mut signatures = Vec::new(); + if let Some(keypair) = + ec_crypto::load_manifest_keypair_from_env().map_err(|err| anyhow!(err))? + { + signatures.push(ec_crypto::sign_manifest_id(&manifest_id, &keypair)); + } + + Ok(Manifest { + body, + manifest_id, + signatures, + }) +} + +fn spawn_ffmpeg_cmaf_ladder_from_reader( + reader: R, + output_dir: &Path, + segment_time: &str, + variants: &[CmafVariantSpec], + hls_list_size: usize, + delete_segments: bool, +) -> Result { + spawn_ffmpeg_cmaf_ladder_with_input( + vec!["-i".to_string(), "pipe:0".to_string()], + Some(Box::new(reader)), + output_dir, + segment_time, + variants, + hls_list_size, + delete_segments, + ) +} + +fn spawn_ffmpeg_cmaf_ladder_with_input( + input_args: Vec, + reader: Option>, + output_dir: &Path, + segment_time: &str, + variants: &[CmafVariantSpec], + hls_list_size: usize, + delete_segments: bool, +) -> Result { + let mut cmd = Command::new("ffmpeg"); + cmd.current_dir(output_dir); + cmd.arg("-hide_banner") + .arg("-loglevel") + .arg("error") + .arg("-nostdin") + .arg("-y"); + + for arg in input_args { + cmd.arg(arg); + } + + // Reduce opportunities for non-deterministic scheduling in filters/decoders. + cmd.arg("-filter_threads") + .arg("1") + .arg("-filter_complex_threads") + .arg("1") + .arg("-threads") + .arg("1") + // Keep only a simple A/V set (ignore subs/data, drop metadata). + .arg("-map") + .arg("0:v:0") + .arg("-map") + .arg("0:a:0?") + .arg("-sn") + .arg("-dn") + .arg("-map_metadata") + .arg("-1"); + + // Filter graph: split and scale into N variants. + let mut filter = String::new(); + filter.push_str(&format!("[0:v]split={}", variants.len())); + for i in 0..variants.len() { + filter.push_str(&format!("[v{i}]")); + } + filter.push(';'); + for (i, v) in variants.iter().enumerate() { + filter.push_str(&format!( + "[v{i}]scale=w={}:h={}:flags=bicubic[v{i}o];", + v.width, v.height + )); + } + cmd.arg("-filter_complex").arg(filter); + + for (i, v) in variants.iter().enumerate() { + let out_variant_dir = output_dir.join(v.id); + let seg_template = out_variant_dir.join("segment_%06d.m4s"); + let seg_template = seg_template + .to_str() + .ok_or_else(|| anyhow!("invalid segment template path"))? + .to_string(); + + let v_bitrate = format!("{}k", v.video_bitrate_kbps); + let bufsize = format!("{}k", v.video_bitrate_kbps.saturating_mul(2)); + + cmd.arg("-map") + .arg(format!("[v{i}o]")) + .arg("-map") + .arg("0:a:0?") + .arg("-c:v") + .arg("libx264") + .arg("-b:v") + .arg(v_bitrate) + .arg("-maxrate") + .arg(format!("{}k", v.video_bitrate_kbps)) + .arg("-bufsize") + .arg(bufsize); + + for arg in default_encoder_args() { + cmd.arg(arg); + } + + cmd.arg("-f") + .arg("hls") + .arg("-hls_time") + .arg(segment_time) + .arg("-hls_list_size") + .arg(hls_list_size.to_string()) + .arg("-hls_flags") + .arg(if delete_segments { + "delete_segments+append_list+independent_segments" + } else { + "append_list+independent_segments" + }) + .arg("-hls_segment_type") + .arg("fmp4") + .arg("-hls_fmp4_init_filename") + .arg("init.mp4") + .arg("-hls_segment_filename") + .arg(seg_template) + .arg(out_variant_dir.join("index.m3u8")); + } + + if reader.is_some() { + cmd.stdin(Stdio::piped()); + } else { + cmd.stdin(Stdio::null()); + } + cmd.stdout(Stdio::null()).stderr(Stdio::inherit()); + + let mut child = cmd + .spawn() + .with_context(|| "failed to spawn ffmpeg".to_string())?; + if let Some(reader) = reader { + let mut stdin = child + .stdin + .take() + .ok_or_else(|| anyhow!("ffmpeg stdin unavailable"))?; + std::thread::spawn(move || { + let mut reader = reader; + let _ = std::io::copy(&mut reader, &mut stdin); + }); + } + Ok(child) +} + +fn parse_network_secret(value: Option) -> Result>> { + let value = value.or_else(|| std::env::var("EVERY_CHANNEL_NETWORK_SECRET").ok()); + let Some(value) = value else { return Ok(None) }; + let bytes = hex::decode(value).context("network secret must be hex")?; + Ok(Some(bytes)) +} + +fn parse_discovery(value: Option<&str>) -> Result { + if let Some(value) = value { + let trimmed = value.trim(); + if !trimmed.is_empty() { + return ec_iroh::DiscoveryConfig::from_list(trimmed).map_err(|err| err.to_string()); + } + } + ec_iroh::DiscoveryConfig::from_env().map_err(|err| err.to_string()) +} + +const DEFAULT_SEGMENT_MS: u64 = 2000; + +fn parse_gossip_peers(mut peers: Vec) -> Vec { + if peers.is_empty() { + if let Ok(env_peers) = std::env::var("EVERY_CHANNEL_GOSSIP_PEERS") { + peers = env_peers + .split(',') + .map(|peer| peer.trim().to_string()) + .filter(|peer| !peer.is_empty()) + .collect(); + } + } + peers +} + +fn merge_peer_strings(peers: Vec) -> Vec { + let mut seen = HashSet::new(); + let mut merged = Vec::new(); + for peer in peers { + let trimmed = peer.trim(); + if trimmed.is_empty() { + continue; + } + if seen.insert(trimmed.to_string()) { + merged.push(trimmed.to_string()); + } + } + merged +} + +fn build_object_bytes( + key_id: &str, + chunk_index: u64, + chunk_duration_27mhz: u64, + sync_status: &str, + plaintext: Vec, + network_secret: Option<&[u8]>, + content_type: &str, + manifest_id: Option<&str>, +) -> Result { + let chunk_hash = blake3::hash(&plaintext).to_hex().to_string(); + let created_unix_ms = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; + + let timing = TimingMeta { + chunk_index, + chunk_start_27mhz: 0, + chunk_duration_27mhz, + utc_start_unix: None, + sync_status: sync_status.to_string(), + }; + + let encrypted = encrypt_stream_data(key_id, chunk_index, &plaintext, network_secret); + let meta = ObjectMeta { + created_unix_ms, + content_type: content_type.to_string(), + size_bytes: encrypted.ciphertext.len() as u64, + timing: Some(timing), + encryption: Some(ec_moq::EncryptionMeta { + alg: encrypted.alg.to_string(), + key_id: key_id.to_string(), + nonce_hex: hex::encode(encrypted.nonce), + }), + chunk_hash: Some(chunk_hash), + chunk_hash_alg: Some("blake3".to_string()), + chunk_proof: None, + chunk_proof_alg: None, + manifest_id: manifest_id.map(|s| s.to_string()), + }; + + Ok(ObjectPayload { + meta, + data: encrypted.ciphertext, + }) +} + +fn build_catalog_entry( + descriptor: &StreamDescriptor, + endpoint_addr: &str, + broadcast_name: &str, + track_name: &str, +) -> StreamCatalogEntry { + let encryption = StreamEncryptionInfo { + alg: ENCRYPTION_ALG.to_string(), + key_id: descriptor.id.0.clone(), + nonce_scheme: "blake3(stream-id,chunk-index)".to_string(), + }; + + let moq = MoqStreamDescriptor { + endpoint: endpoint_addr.to_string(), + broadcast_name: broadcast_name.to_string(), + track_name: track_name.to_string(), + encryption: Some(encryption), + }; + + StreamCatalogEntry { + stream: descriptor.clone(), + moq: Some(moq), + manifest: None, + updated_unix_ms: SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64, + } +} + +fn default_encoder_args() -> Vec<&'static str> { + vec![ + "-c:a", + "aac", + "-b:a", + "128k", + "-ac", + "2", + "-ar", + "48000", + "-pix_fmt", + "yuv420p", + "-g", + "60", + "-keyint_min", + "60", + "-sc_threshold", + "0", + "-bf", + "0", + "-threads", + "1", + "-fflags", + "+bitexact", + "-flags:v", + "+bitexact", + "-flags:a", + "+bitexact", + ] +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalize_host_strips_scheme_path_and_slash() { + // Use documentation IPs (RFC 5737) in tests. + assert_eq!(normalize_host("http://192.0.2.1/"), "192.0.2.1"); + assert_eq!( + normalize_host("https://example.local/foo/bar"), + "example.local" + ); + assert_eq!(normalize_host(" 192.0.2.3 "), "192.0.2.3"); + } + + #[test] + fn linux_dvb_url_roundtrips_parse() { + let config = LinuxDvbConfig { + adapter: 1, + frontend: 0, + dvr: 2, + tune_command: Some(vec![ + "dvbv5-zap".to_string(), + "-r".to_string(), + "Channel Name".to_string(), + ]), + tune_timeout_ms: Some(800), + }; + let url = linux_dvb_url(&config); + let parsed = Url::parse(&url).unwrap(); + let out = parse_linux_dvb_url(&parsed).unwrap(); + assert_eq!(out.adapter, 1); + assert_eq!(out.dvr, 2); + assert_eq!(out.tune_timeout_ms, Some(800)); + assert_eq!(out.tune_command.unwrap()[0], "dvbv5-zap"); + } + + #[test] + fn stream_dir_name_sanitizes_non_alnum() { + assert_eq!( + stream_dir_name("ec/stream/v1/source/hdhr"), + "ec_stream_v1_source_hdhr" + ); + assert_eq!(stream_dir_name("a b+c"), "a_b_c"); + } + + #[test] + fn merge_source_descriptors_dedupes_manual_hdhr_by_device_id() { + let device = HdhomerunDevice { + id: ec_core::DeviceId("ABCDEF01".to_string()), + ip: "10.0.0.1".to_string(), + tuner_count: 4, + lineup_url: None, + discover_url: None, + base_url: None, + device_auth: None, + friendly_name: Some("HDHR".to_string()), + model_number: None, + firmware_name: None, + firmware_version: None, + device_type: None, + discovery_tags: Vec::new(), + raw_discover_json: None, + }; + + let sources = vec![source_descriptor_for_device(&device, "online")]; + let merged = merge_source_descriptors(sources, [(&"host".to_string(), &device)]); + let count = merged.iter().filter(|s| s.kind == "hdhr").count(); + assert_eq!(count, 1); + } + + #[test] + fn write_hls_master_playlist_includes_variants() { + let dir = std::env::temp_dir().join(format!( + "ec-tauri-master-{}", + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + )); + let _ = fs::remove_dir_all(&dir); + let variants = default_cmaf_variants(); + write_hls_master_playlist(&dir, &variants, 128_000).unwrap(); + let text = fs::read_to_string(dir.join("index.m3u8")).unwrap(); + assert!(text.contains("#EXT-X-STREAM-INF")); + assert!(text.contains("1080p/index.m3u8")); + assert!(text.contains("720p/index.m3u8")); + assert!(text.contains("480p/index.m3u8")); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn derive_variant_stream_id_is_stable() { + assert_eq!( + derive_variant_stream_id("every.channel/x", "720p"), + "every.channel/x/variant-720p" + ); + assert_eq!( + derive_variant_stream_id("every.channel/x/", "A B"), + "every.channel/x/variant-a_b" + ); + } +} diff --git a/apps/tauri/tauri.conf.json b/apps/tauri/tauri.conf.json new file mode 100644 index 0000000..58a4d2e --- /dev/null +++ b/apps/tauri/tauri.conf.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "every.channel", + "version": "0.0.0", + "identifier": "channel.every.app", + "build": { + "beforeBuildCommand": "cd ui && trunk build --release", + "beforeDevCommand": "cd ui && trunk serve --port 1420 --public-url /", + "devUrl": "http://localhost:1420", + "frontendDist": "dist" + }, + "app": { + "withGlobalTauri": true, + "windows": [ + { + "title": "every.channel", + "width": 1280, + "height": 820, + "resizable": true + } + ], + "security": { + "csp": null + } + }, + "bundle": { + "resources": [ + "resources/**/*" + ] + } +} diff --git a/apps/tauri/ui/Cargo.lock b/apps/tauri/ui/Cargo.lock new file mode 100644 index 0000000..dde1ab3 --- /dev/null +++ b/apps/tauri/ui/Cargo.lock @@ -0,0 +1,3619 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anyhow" +version = "1.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" + +[[package]] +name = "arc-swap" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ded5f9a03ac8f24d1b8a25101ee812cd32cdc8c50a4c50237de2c4915850e73" +dependencies = [ + "rustversion", +] + +[[package]] +name = "asn1-rs" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6fd5ddaf0351dff5b8da21b2fb4ff8e08ddd02857f0bf69c47639106c0fff0" +dependencies = [ + "asn1-rs-derive 0.4.0", + "asn1-rs-impl 0.1.0", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror", +] + +[[package]] +name = "asn1-rs" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048" +dependencies = [ + "asn1-rs-derive 0.5.1", + "asn1-rs-impl 0.2.0", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "726535892e8eae7e70657b4c8ea93d26b8553afb1ce617caee529ef96d7dee6c" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", + "synstructure 0.12.6", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", + "synstructure 0.13.2", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2777730b2039ac0f95f093556e61b6d26cebed5393ca6f152717777cec3a42ed" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "async_cell" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "447ab28afbb345f5408b120702a44e5529ebf90b1796ec76e9528df8e288e6c2" +dependencies = [ + "loom", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" + +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + +[[package]] +name = "cc" +version = "1.2.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "ccm" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae3c82e4355234767756212c570e29833699ab63e6ffd161887314cc5b43847" +dependencies = [ + "aead", + "cipher", + "ctr", + "subtle", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "console_error_panic_hook" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" +dependencies = [ + "cfg-if", + "wasm-bindgen", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "const-serialize" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08259976d62c715c4826cb4a3d64a3a9e5c5f68f964ff6087319857f569f93a6" +dependencies = [ + "const-serialize-macro", + "serde", +] + +[[package]] +name = "const-serialize-macro" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04382d0d9df7434af6b1b49ea1a026ef39df1b0738b1cc373368cf175354f6eb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "const_format" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7faa7469a93a566e9ccc1c73fe783b4a65c274c5ace346038dca9c39fe0030ad" +dependencies = [ + "const_format_proc_macros", +] + +[[package]] +name = "const_format_proc_macros" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + +[[package]] +name = "convert_case" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core", + "typenum", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "dashmap" +version = "5.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856" +dependencies = [ + "cfg-if", + "hashbrown", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "der-parser" +version = "8.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbd676fbbab537128ef0278adb5576cf363cff6aa22a7b24effe97347cfab61e" +dependencies = [ + "asn1-rs 0.5.2", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "der-parser" +version = "9.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553" +dependencies = [ + "asn1-rs 0.6.2", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "deranged" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "dioxus" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a247114500f1a78e87022defa8173de847accfada8e8809dfae23a118a580c" +dependencies = [ + "dioxus-cli-config", + "dioxus-config-macro", + "dioxus-core", + "dioxus-core-macro", + "dioxus-devtools", + "dioxus-document", + "dioxus-fullstack", + "dioxus-history", + "dioxus-hooks", + "dioxus-html", + "dioxus-logger", + "dioxus-signals", + "dioxus-web", + "manganis", + "warnings", +] + +[[package]] +name = "dioxus-cli-config" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdd16948f1ffdb068dd9a64812158073a4250e2af4e98ea31fdac0312e6bce86" +dependencies = [ + "wasm-bindgen", +] + +[[package]] +name = "dioxus-config-macro" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75cbf582fbb1c32d34a1042ea675469065574109c95154468710a4d73ee98b49" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "dioxus-core" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c03f451a119e47433c16e2d8eb5b15bf7d6e6734eb1a4c47574e6711dadff8d" +dependencies = [ + "const_format", + "dioxus-core-types", + "futures-channel", + "futures-util", + "generational-box", + "longest-increasing-subsequence", + "rustc-hash", + "rustversion", + "serde", + "slab", + "slotmap", + "tracing", + "warnings", +] + +[[package]] +name = "dioxus-core-macro" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "105c954caaaedf8cd10f3d1ba576b01e18aa8d33ad435182125eefe488cf0064" +dependencies = [ + "convert_case", + "dioxus-rsx", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "dioxus-core-types" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91a82fccfa48574eb7aa183e297769540904694844598433a9eb55896ad9f93b" +dependencies = [ + "once_cell", +] + +[[package]] +name = "dioxus-devtools" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712a7300f1e8181218187b03502044157eef04e0a25b518117c5ef9ae1096880" +dependencies = [ + "dioxus-core", + "dioxus-devtools-types", + "dioxus-signals", + "serde", + "serde_json", + "tracing", + "tungstenite", + "warnings", +] + +[[package]] +name = "dioxus-devtools-types" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f62434973c0c9c5a3bc42e9cd5e7070401c2062a437fb5528f318c3e42ebf4ff" +dependencies = [ + "dioxus-core", + "serde", +] + +[[package]] +name = "dioxus-document" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "802a2014d1662b6615eec0a275745822ee4fc66aacd9d0f2fb33d6c8da79b8f2" +dependencies = [ + "dioxus-core", + "dioxus-core-macro", + "dioxus-core-types", + "dioxus-html", + "futures-channel", + "futures-util", + "generational-box", + "lazy-js-bundle", + "serde", + "serde_json", + "tracing", +] + +[[package]] +name = "dioxus-fullstack" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe99b48a1348eec385b5c4bd3e80fd863b0d3b47257d34e2ddc58754dec5d128" +dependencies = [ + "base64 0.22.1", + "bytes", + "ciborium", + "dioxus-devtools", + "dioxus-history", + "dioxus-lib", + "dioxus-web", + "dioxus_server_macro", + "futures-channel", + "futures-util", + "generational-box", + "once_cell", + "serde", + "server_fn", + "tracing", + "web-sys", +] + +[[package]] +name = "dioxus-history" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ae4e22616c698f35b60727313134955d885de2d32e83689258e586ebc9b7909" +dependencies = [ + "dioxus-core", + "tracing", +] + +[[package]] +name = "dioxus-hooks" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "948e2b3f20d9d4b2c300aaa60281b1755f3298684448920b27106da5841896d0" +dependencies = [ + "dioxus-core", + "dioxus-signals", + "futures-channel", + "futures-util", + "generational-box", + "rustversion", + "slab", + "tracing", + "warnings", +] + +[[package]] +name = "dioxus-html" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59c9a40e6fee20ce7990095492dedb6a753eebe05e67d28271a249de74dc796d" +dependencies = [ + "async-trait", + "dioxus-core", + "dioxus-core-macro", + "dioxus-core-types", + "dioxus-hooks", + "dioxus-html-internal-macro", + "enumset", + "euclid", + "futures-channel", + "generational-box", + "keyboard-types", + "lazy-js-bundle", + "rustversion", + "tracing", +] + +[[package]] +name = "dioxus-html-internal-macro" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43ba87b53688a2c9f619ecdf4b3b955bc1f08bd0570a80a0d626c405f6d14a76" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "dioxus-interpreter-js" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330707b10ca75cb0eb05f9e5f8d80217cd0d7e62116a8277ae363c1a09b57a22" +dependencies = [ + "js-sys", + "lazy-js-bundle", + "rustc-hash", + "sledgehammer_bindgen", + "sledgehammer_utils", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "dioxus-lib" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5405b71aa9b8b0c3e0d22728f12f34217ca5277792bd315878cc6ecab7301b72" +dependencies = [ + "dioxus-config-macro", + "dioxus-core", + "dioxus-core-macro", + "dioxus-document", + "dioxus-history", + "dioxus-hooks", + "dioxus-html", + "dioxus-rsx", + "dioxus-signals", + "warnings", +] + +[[package]] +name = "dioxus-logger" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "545961e752f6c8bf59c274951b3c8b18a106db6ad2f9e2035b29e1f2a3e899b1" +dependencies = [ + "console_error_panic_hook", + "dioxus-cli-config", + "tracing", + "tracing-subscriber", + "tracing-wasm", +] + +[[package]] +name = "dioxus-rsx" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eb588e05800b5a7eb90b2f40fca5bbd7626e823fb5e1ba21e011de649b45aa1" +dependencies = [ + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "dioxus-signals" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10e032dbb3a2c0386ec8b8ee59bc20b5aeb67038147c855801237b45b13d72ac" +dependencies = [ + "dioxus-core", + "futures-channel", + "futures-util", + "generational-box", + "once_cell", + "parking_lot", + "rustc-hash", + "tracing", + "warnings", +] + +[[package]] +name = "dioxus-web" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7c12475c3d360058b8afe1b68eb6dfc9cbb7dcd760aed37c5f85c561c83ed1" +dependencies = [ + "async-trait", + "ciborium", + "dioxus-cli-config", + "dioxus-core", + "dioxus-core-types", + "dioxus-devtools", + "dioxus-document", + "dioxus-history", + "dioxus-html", + "dioxus-interpreter-js", + "dioxus-signals", + "futures-channel", + "futures-util", + "generational-box", + "js-sys", + "lazy-js-bundle", + "rustc-hash", + "serde", + "serde-wasm-bindgen 0.5.0", + "serde_json", + "tracing", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "dioxus_server_macro" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "371a5b21989a06b53c5092e977b3f75d0e60a65a4c15a2aa1d07014c3b2dda97" +dependencies = [ + "proc-macro2", + "quote", + "server_fn_macro", + "syn 2.0.114", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "ec-direct" +version = "0.0.0" +dependencies = [ + "anyhow", + "base64 0.22.1", + "just-webrtc", + "serde", + "serde_json", +] + +[[package]] +name = "ec-tauri-ui" +version = "0.0.0" +dependencies = [ + "bytes", + "dioxus", + "ec-direct", + "futures-util", + "gloo-net", + "gloo-timers", + "js-sys", + "just-webrtc", + "serde", + "serde-wasm-bindgen 0.6.5", + "serde_json", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "hkdf", + "pem-rfc7468", + "pkcs8", + "rand_core", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "enumset" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25b07a8dfbbbfc0064c0a6bdf9edcf966de6b1c33ce344bdeca3b41615452634" +dependencies = [ + "enumset_derive", +] + +[[package]] +name = "enumset_derive" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f43e744e4ea338060faee68ed933e46e722fb7f3617e722a5772d7e856d8b3ce" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "euclid" +version = "0.22.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df61bf483e837f88d5c2291dcf55c67be7e676b3a51acc48db3a7b163b91ed63" +dependencies = [ + "num-traits", +] + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "generational-box" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a673cf4fb0ea6a91aa86c08695756dfe875277a912cdbf33db9a9f62d47ed82b" +dependencies = [ + "parking_lot", + "tracing", +] + +[[package]] +name = "generator" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f04ae4152da20c76fe800fa48659201d5cf627c5149ca0b707b69d7eef6cf9" +dependencies = [ + "cc", + "cfg-if", + "libc", + "log", + "rustversion", + "windows-link", + "windows-result", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] +name = "gloo-net" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c06f627b1a58ca3d42b45d6104bf1e1a03799df472df00988b6ba21accc10580" +dependencies = [ + "futures-channel", + "futures-core", + "futures-sink", + "gloo-utils", + "http", + "js-sys", + "pin-project", + "serde", + "serde_json", + "thiserror", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "gloo-utils" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5555354113b18c547c1d3a98fbf7fb32a9ff4f6fa112ce823a21641a0ba3aa" +dependencies = [ + "js-sys", + "serde", + "serde_json", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core", + "subtle", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + +[[package]] +name = "interceptor" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4705c00485029e738bea8c9505b5ddb1486a8f3627a953e1e77e6abdf5eef90c" +dependencies = [ + "async-trait", + "bytes", + "log", + "portable-atomic", + "rand", + "rtcp", + "rtp", + "thiserror", + "tokio", + "waitgroup", + "webrtc-srtp", + "webrtc-util", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "js-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "just-webrtc" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb1cb0e36a34b7c6a147c374b68be2fb4ab279888e8637487da157da5f4b8a0b" +dependencies = [ + "async_cell", + "bytes", + "flume", + "js-sys", + "log", + "serde", + "serde-wasm-bindgen 0.6.5", + "thiserror", + "trait-variant", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webrtc", +] + +[[package]] +name = "keyboard-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +dependencies = [ + "bitflags 2.10.0", +] + +[[package]] +name = "lazy-js-bundle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e49596223b9d9d4947a14a25c142a6e7d8ab3f27eb3ade269d238bb8b5c267e2" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.180" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "longest-increasing-subsequence" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3bd0dd2cd90571056fdb71f6275fada10131182f84899f4b2a916e565d81d86" + +[[package]] +name = "loom" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" +dependencies = [ + "cfg-if", + "generator", + "scoped-tls", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "manganis" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "317af44b15e7605b85f04525449a3bb631753040156c9b318e6cba8a3ea4ef73" +dependencies = [ + "const-serialize", + "manganis-core", + "manganis-macro", +] + +[[package]] +name = "manganis-core" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c38bee65cc725b2bba23b5dbb290f57c8be8fadbe2043fb7e2ce73022ea06519" +dependencies = [ + "const-serialize", + "dioxus-cli-config", + "dioxus-core-types", + "serde", +] + +[[package]] +name = "manganis-macro" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f4f71310913c40174d9f0cfcbcb127dad0329ecdb3945678a120db22d3d065" +dependencies = [ + "dunce", + "manganis-core", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "memoffset" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5de893c32cde5f383baa4c04c5d6dbdd735cfd4a794b0debdb2bb1b421da5ff4" +dependencies = [ + "autocfg", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nix" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b" +dependencies = [ + "bitflags 1.3.2", + "cfg-if", + "libc", + "memoffset", + "pin-utils", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "oid-registry" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9" +dependencies = [ + "asn1-rs 0.6.2", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proc-macro2-diagnostics" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", + "version_check", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rcgen" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "x509-parser", + "yasna", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.10.0", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rtcp" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc9f775ff89c5fe7f0cc0abafb7c57688ae25ce688f1a52dd88e277616c76ab2" +dependencies = [ + "bytes", + "thiserror", + "webrtc-util", +] + +[[package]] +name = "rtp" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6870f09b5db96f8b9e7290324673259fd15519ebb7d55acf8e7eb044a9ead6af" +dependencies = [ + "bytes", + "portable-atomic", + "rand", + "serde", + "thiserror", + "webrtc-util", +] + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + +[[package]] +name = "rustls" +version = "0.23.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sdp" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13254db766b17451aced321e7397ebf0a446ef0c8d2942b6e67a95815421093f" +dependencies = [ + "rand", + "substring", + "thiserror", + "url", +] + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" +dependencies = [ + "futures-core", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-wasm-bindgen" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3b143e2833c57ab9ad3ea280d21fd34e285a42837aeb0ee301f4f41890fa00e" +dependencies = [ + "js-sys", + "serde", + "wasm-bindgen", +] + +[[package]] +name = "serde-wasm-bindgen" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b" +dependencies = [ + "js-sys", + "serde", + "wasm-bindgen", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_qs" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0431a35568651e363364210c91983c1da5eb29404d9f0928b67d4ebcfa7d330c" +dependencies = [ + "percent-encoding", + "serde", + "thiserror", +] + +[[package]] +name = "server_fn" +version = "0.6.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fae7a3038a32e5a34ba32c6c45eb4852f8affaf8b794ebfcd4b1099e2d62ebe" +dependencies = [ + "bytes", + "const_format", + "dashmap", + "futures", + "gloo-net", + "http", + "js-sys", + "once_cell", + "send_wrapper", + "serde", + "serde_json", + "serde_qs", + "server_fn_macro_default", + "thiserror", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "xxhash-rust", +] + +[[package]] +name = "server_fn_macro" +version = "0.6.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faaaf648c6967aef78177c0610478abb5a3455811f401f3c62d10ae9bd3901a1" +dependencies = [ + "const_format", + "convert_case", + "proc-macro2", + "quote", + "syn 2.0.114", + "xxhash-rust", +] + +[[package]] +name = "server_fn_macro_default" +version = "0.6.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f2aa8119b558a17992e0ac1fd07f080099564f24532858811ce04f742542440" +dependencies = [ + "server_fn_macro", + "syn 2.0.114", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "sledgehammer_bindgen" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49e83e178d176459c92bc129cfd0958afac3ced925471b889b3a75546cfc4133" +dependencies = [ + "sledgehammer_bindgen_macro", + "wasm-bindgen", +] + +[[package]] +name = "sledgehammer_bindgen_macro" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb251b407f50028476a600541542b605bb864d35d9ee1de4f6cab45d88475e6d" +dependencies = [ + "quote", + "syn 2.0.114", +] + +[[package]] +name = "sledgehammer_utils" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "debdd4b83524961983cea3c55383b3910fd2f24fd13a188f5b091d2d504a61ae" +dependencies = [ + "rustc-hash", +] + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "serde", + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "smol_str" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stun" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28fad383a1cc63ae141e84e48eaef44a1063e9d9e55bcb8f51a99b886486e01b" +dependencies = [ + "base64 0.21.7", + "crc", + "lazy_static", + "md-5", + "rand", + "ring", + "subtle", + "thiserror", + "tokio", + "url", + "webrtc-util", +] + +[[package]] +name = "substring" +version = "1.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42ee6433ecef213b2e72f587ef64a2f5943e7cd16fbd82dbe8bc07486c534c86" +dependencies = [ + "autocfg", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f36bdaa60a83aca3921b5259d5400cbf5e90fc51931376a9bd4a0eb79aa7210f" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", + "unicode-xid", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "socket2 0.6.2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "tracing-wasm" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4575c663a174420fa2d78f4108ff68f65bf2fbb7dd89f33749b6e826b3626e07" +dependencies = [ + "tracing", + "tracing-subscriber", + "wasm-bindgen", +] + +[[package]] +name = "trait-variant" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70977707304198400eb4835a78f6a9f928bf41bba420deb8fdb175cd965d77a7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "tungstenite" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e2ce1e47ed2994fd43b04c8f618008d4cabdd5ee34027cf14f9d918edd9c8" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand", + "sha1", + "thiserror", + "utf-8", +] + +[[package]] +name = "turn" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b000cebd930420ac1ed842c8128e3b3412512dfd5b82657eab035a3f5126acc" +dependencies = [ + "async-trait", + "base64 0.21.7", + "futures", + "log", + "md-5", + "portable-atomic", + "rand", + "ring", + "stun", + "thiserror", + "tokio", + "tokio-util", + "webrtc-util", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee48d38b119b0cd71fe4141b30f5ba9c7c5d9f4e7a3a8b4a674e4b6ef789976f" +dependencies = [ + "getrandom 0.3.4", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "waitgroup" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1f50000a783467e6c0200f9d10642f4bc424e39efc1b770203e88b488f79292" +dependencies = [ + "atomic-waker", +] + +[[package]] +name = "warnings" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64f68998838dab65727c9b30465595c6f7c953313559371ca8bf31759b3680ad" +dependencies = [ + "pin-project", + "tracing", + "warnings-macro", +] + +[[package]] +name = "warnings-macro" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59195a1db0e95b920366d949ba5e0d3fc0e70b67c09be15ce5abb790106b0571" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" +dependencies = [ + "cfg-if", + "futures-util", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.114", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webrtc" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b3a840e31c969844714f93b5a87e73ee49f3bc2a4094ab9132c69497eb31db" +dependencies = [ + "arc-swap", + "async-trait", + "bytes", + "cfg-if", + "hex", + "interceptor", + "lazy_static", + "log", + "portable-atomic", + "rand", + "rcgen", + "regex", + "ring", + "rtcp", + "rtp", + "rustls", + "sdp", + "serde", + "serde_json", + "sha2", + "smol_str", + "stun", + "thiserror", + "time", + "tokio", + "turn", + "url", + "waitgroup", + "webrtc-data", + "webrtc-dtls", + "webrtc-ice", + "webrtc-mdns", + "webrtc-media", + "webrtc-sctp", + "webrtc-srtp", + "webrtc-util", +] + +[[package]] +name = "webrtc-data" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8b7c550f8d35867b72d511640adf5159729b9692899826fe00ba7fa74f0bf70" +dependencies = [ + "bytes", + "log", + "portable-atomic", + "thiserror", + "tokio", + "webrtc-sctp", + "webrtc-util", +] + +[[package]] +name = "webrtc-dtls" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86e5eedbb0375aa04da93fc3a189b49ed3ed9ee844b6997d5aade14fc3e2c26e" +dependencies = [ + "aes", + "aes-gcm", + "async-trait", + "bincode", + "byteorder", + "cbc", + "ccm", + "der-parser 8.2.0", + "hkdf", + "hmac", + "log", + "p256", + "p384", + "portable-atomic", + "rand", + "rand_core", + "rcgen", + "ring", + "rustls", + "sec1", + "serde", + "sha1", + "sha2", + "subtle", + "thiserror", + "tokio", + "webrtc-util", + "x25519-dalek", + "x509-parser", +] + +[[package]] +name = "webrtc-ice" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d4f0ca6d4df8d1bdd34eece61b51b62540840b7a000397bcfb53a7bfcf347c8" +dependencies = [ + "arc-swap", + "async-trait", + "crc", + "log", + "portable-atomic", + "rand", + "serde", + "serde_json", + "stun", + "thiserror", + "tokio", + "turn", + "url", + "uuid", + "waitgroup", + "webrtc-mdns", + "webrtc-util", +] + +[[package]] +name = "webrtc-mdns" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0804694f3b2acfdff48f6df217979b13cb0a00377c63b5effd111daaee7e8c4" +dependencies = [ + "log", + "socket2 0.5.10", + "thiserror", + "tokio", + "webrtc-util", +] + +[[package]] +name = "webrtc-media" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c15b20e98167b22949abc1c20eca7c6d814307d187068fe7a48f0b87a4f6d46" +dependencies = [ + "byteorder", + "bytes", + "rand", + "rtp", + "thiserror", +] + +[[package]] +name = "webrtc-sctp" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d850daa68639b9d7bb16400676e97525d1e52b15b4928240ae2ba0e849817a5" +dependencies = [ + "arc-swap", + "async-trait", + "bytes", + "crc", + "log", + "portable-atomic", + "rand", + "thiserror", + "tokio", + "webrtc-util", +] + +[[package]] +name = "webrtc-srtp" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbec5da43a62c228d321d93fb12cc9b4d9c03c9b736b0c215be89d8bd0774cfe" +dependencies = [ + "aead", + "aes", + "aes-gcm", + "byteorder", + "bytes", + "ctr", + "hmac", + "log", + "rtcp", + "rtp", + "sha1", + "subtle", + "thiserror", + "tokio", + "webrtc-util", +] + +[[package]] +name = "webrtc-util" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc8d9bc631768958ed97b8d68b5d301e63054ae90b09083d43e2fefb939fd77e" +dependencies = [ + "async-trait", + "bitflags 1.3.2", + "bytes", + "ipnet", + "lazy_static", + "libc", + "log", + "nix", + "portable-atomic", + "rand", + "thiserror", + "tokio", + "winapi", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek", + "rand_core", + "serde", + "zeroize", +] + +[[package]] +name = "x509-parser" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69" +dependencies = [ + "asn1-rs 0.6.2", + "data-encoding", + "der-parser 9.0.0", + "lazy_static", + "nom", + "oid-registry", + "ring", + "rusticata-macros", + "thiserror", + "time", +] + +[[package]] +name = "xxhash-rust" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" + +[[package]] +name = "yasna" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +dependencies = [ + "time", +] + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", + "synstructure 0.13.2", +] + +[[package]] +name = "zerocopy" +version = "0.8.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7456cf00f0685ad319c5b1693f291a650eaf345e941d082fc4e03df8a03996ac" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1328722bbf2115db7e19d69ebcc15e795719e2d66b60827c6a69a117365e37a0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", + "synstructure 0.13.2", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "zmij" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff05f8caa9038894637571ae6b9e29466c1f4f829d26c9b28f869a29cbe3445" diff --git a/apps/tauri/ui/Cargo.toml b/apps/tauri/ui/Cargo.toml new file mode 100644 index 0000000..fc18cdc --- /dev/null +++ b/apps/tauri/ui/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "ec-tauri-ui" +version = "0.0.0" +edition = "2021" + +[dependencies] +dioxus = { version = "0.6", features = ["web"] } +ec-direct = { path = "../../../crates/ec-direct" } +bytes = "1" +futures-util = { version = "0.3", features = ["sink"] } +gloo-timers = { version = "0.3", features = ["futures"] } +gloo-net = { version = "0.6", features = ["websocket"] } +js-sys = "0.3" +just-webrtc = "0.2" +serde = { version = "1", features = ["derive"] } +serde-wasm-bindgen = "0.6" +serde_json = "1" +wasm-bindgen = "0.2" +wasm-bindgen-futures = "0.4" +web-sys = { version = "0.3", features = ["Window", "Navigator", "Clipboard", "MediaSource", "SourceBuffer", "Url", "HtmlVideoElement", "HtmlMediaElement", "EventTarget", "Event", "Blob", "Request", "RequestInit", "Response", "Headers", "Location"] } + +[workspace] diff --git a/apps/tauri/ui/Trunk.toml b/apps/tauri/ui/Trunk.toml new file mode 100644 index 0000000..788fec6 --- /dev/null +++ b/apps/tauri/ui/Trunk.toml @@ -0,0 +1,3 @@ +[build] +dist = "../dist" +public_url = "/" diff --git a/apps/tauri/ui/icons/apple-touch-icon.png b/apps/tauri/ui/icons/apple-touch-icon.png new file mode 100644 index 0000000..75e3330 Binary files /dev/null and b/apps/tauri/ui/icons/apple-touch-icon.png differ diff --git a/apps/tauri/ui/icons/icon-192.png b/apps/tauri/ui/icons/icon-192.png new file mode 100644 index 0000000..21a0536 Binary files /dev/null and b/apps/tauri/ui/icons/icon-192.png differ diff --git a/apps/tauri/ui/icons/icon-512.png b/apps/tauri/ui/icons/icon-512.png new file mode 100644 index 0000000..f0ced44 Binary files /dev/null and b/apps/tauri/ui/icons/icon-512.png differ diff --git a/apps/tauri/ui/index.html b/apps/tauri/ui/index.html new file mode 100644 index 0000000..556afec --- /dev/null +++ b/apps/tauri/ui/index.html @@ -0,0 +1,34 @@ + + + + + + + + + + every.channel + + + + + + + + + + + + +
+ + + + diff --git a/apps/tauri/ui/manifest.webmanifest b/apps/tauri/ui/manifest.webmanifest new file mode 100644 index 0000000..1c2f367 --- /dev/null +++ b/apps/tauri/ui/manifest.webmanifest @@ -0,0 +1,29 @@ +{ + "name": "every.channel", + "short_name": "every.channel", + "description": "every.channel viewer", + "start_url": "/", + "scope": "/", + "display": "standalone", + "background_color": "#f7f4ef", + "theme_color": "#f7f4ef", + "icons": [ + { + "src": "icons/icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} + diff --git a/apps/tauri/ui/src/main.rs b/apps/tauri/ui/src/main.rs new file mode 100644 index 0000000..9095a27 --- /dev/null +++ b/apps/tauri/ui/src/main.rs @@ -0,0 +1,2329 @@ +use dioxus::prelude::*; +use js_sys::{decode_uri_component, encode_uri_component, Function, Reflect, Promise}; +use serde::{Deserialize, Serialize}; +use std::cell::RefCell; +use std::collections::VecDeque; +use std::rc::Rc; +use wasm_bindgen::closure::Closure; +use wasm_bindgen::{JsCast, JsValue}; +use wasm_bindgen_futures::JsFuture; +use web_sys::{Event, MediaSource, Request, RequestInit, Response, SourceBuffer, Url}; + +use bytes::Bytes; +use ec_direct::{decode_direct_link, encode_direct_link, DirectCodeV1}; +use just_webrtc::types::{ICEServer, PeerConfiguration}; +use just_webrtc::{DataChannelExt, PeerConnectionBuilder, PeerConnectionExt}; + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +struct StreamDescriptor { + id: String, + title: String, + number: Option, + source: String, + metadata: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +struct StreamMetadata { + key: String, + value: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +struct PlaybackInfo { + stream_id: String, + url: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +struct EmptyArgs {} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct StartArgs { + stream_id: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct MoqStartArgs { + remote: String, + broadcast_name: String, + stream_id: Option, + track_name: Option, + auto_quality: Option, + variant: Option, + network_secret: Option, + discovery: Option, +} + +#[derive(Clone, Debug)] +struct DirectSession { + reply_link: String, +} + +#[derive(Clone, Debug, Deserialize)] +struct DirectoryList { + now_ms: u64, + entries: Vec, +} + +#[derive(Clone, Debug, Deserialize, PartialEq)] +struct DirectoryEntry { + stream_id: String, + title: String, + offer: String, + updated_ms: u64, + expires_ms: u64, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ShareArgs { + stream_id: String, + network_secret: Option, + chunk_ms: Option, + announce: bool, + gossip_peers: Vec, + discovery: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +struct ShareInfo { + stream_id: String, + endpoint_addr: String, + endpoint_id: Option, + broadcast_name: String, + track_name: String, + discovery: Option, + announce_status: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +struct SourceDescriptor { + id: String, + kind: String, + name: String, + ip: Option, + tuner_count: Option, + status: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct CatalogWatchArgs { + peers: Vec, + discovery: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct AddStreamArgs { + input: String, + options: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ProbeStreamArgs { + input: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +struct ManualSourceOptions { + ytdlp_format: Option, + ytdlp_live_from_start: bool, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct YtdlpFormatOption { + format_id: String, + label: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct YtdlpProbe { + title: Option, + formats: Vec, + default_format: Option, + supports_live_from_start: bool, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct StreamProbe { + kind: String, + live: bool, + requires_options: bool, + message: Option, + ytdlp: Option, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct LinuxDvbAdapterInfo { + adapter: u32, + dvrs: Vec, + frontends: Vec, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct LinuxDvbChannelsInfo { + channels_conf: Option, + channels: Vec, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct LinuxDvbBuildUrlArgs { + adapter: u32, + dvr: u32, + channel: Option, + channels_conf: Option, + tune_wait_ms: Option, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct LinuxDvbListChannelsArgs { + channels_conf: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct AddStreamResult { + kind: String, + added: usize, + title: Option, +} + +fn discovery_string(dht: bool, mdns: bool, dns: bool) -> Option { + let mut modes = Vec::new(); + if dht { + modes.push("dht"); + } + if mdns { + modes.push("mdns"); + } + if dns { + modes.push("dns"); + } + if modes.is_empty() { + None + } else { + Some(modes.join(",")) + } +} + +#[derive(Clone, Debug)] +struct ParsedWatchLink { + remote: String, + broadcast: String, + track: Option, + stream_id: Option, + network_secret: Option, + discovery: Option, +} + +fn encode_component(value: &str) -> String { + encode_uri_component(value).as_string().unwrap_or_else(|| value.to_string()) +} + +fn decode_component(value: &str) -> Option { + decode_uri_component(value).ok()?.as_string() +} + +fn build_watch_link( + remote: &str, + broadcast: &str, + track: Option<&str>, + stream_id: Option<&str>, + network_secret: Option<&str>, + discovery: Option<&str>, +) -> String { + let mut parts = Vec::new(); + parts.push(format!("remote={}", encode_component(remote))); + parts.push(format!("broadcast={}", encode_component(broadcast))); + if let Some(track) = track { + if !track.trim().is_empty() { + parts.push(format!("track={}", encode_component(track))); + } + } + if let Some(stream_id) = stream_id { + if !stream_id.trim().is_empty() { + parts.push(format!("stream_id={}", encode_component(stream_id))); + } + } + if let Some(secret) = network_secret { + if !secret.trim().is_empty() { + parts.push(format!("secret={}", encode_component(secret))); + } + } + if let Some(discovery) = discovery { + if !discovery.trim().is_empty() { + parts.push(format!("discovery={}", encode_component(discovery))); + } + } + format!("every.channel://watch?{}", parts.join("&")) +} + +fn parse_watch_link(link: &str) -> Option { + let link = link.trim(); + let prefix = "every.channel://"; + if !link.starts_with(prefix) { + return None; + } + let rest = &link[prefix.len()..]; + let (path, query) = rest.split_once('?')?; + if !path.eq_ignore_ascii_case("watch") { + return None; + } + + let mut remote = None::; + let mut broadcast = None::; + let mut track = None::; + let mut stream_id = None::; + let mut secret = None::; + let mut discovery = None::; + + for pair in query.split('&') { + let pair = pair.trim(); + if pair.is_empty() { + continue; + } + let (k, v) = pair.split_once('=').unwrap_or((pair, "")); + let k = decode_component(k).unwrap_or_else(|| k.to_string()); + let v = decode_component(v).unwrap_or_else(|| v.to_string()); + match k.as_str() { + "remote" => remote = Some(v), + "broadcast" => broadcast = Some(v), + "track" => track = Some(v), + "stream_id" => stream_id = Some(v), + "secret" => secret = Some(v), + "discovery" => discovery = Some(v), + _ => {} + } + } + + Some(ParsedWatchLink { + remote: remote?, + broadcast: broadcast?, + track, + stream_id, + network_secret: secret, + discovery, + }) +} + +fn parse_direct_link(link: &str) -> Option { + decode_direct_link(link).ok() +} + +#[derive(Clone, Debug, Deserialize)] +struct ObjectMeta { + created_unix_ms: u64, + content_type: String, + size_bytes: u64, + timing: Option, + #[allow(dead_code)] + encryption: Option, + #[allow(dead_code)] + chunk_hash: Option, + #[allow(dead_code)] + chunk_hash_alg: Option, + #[allow(dead_code)] + chunk_proof: Option>, + #[allow(dead_code)] + chunk_proof_alg: Option, + #[allow(dead_code)] + manifest_id: Option, +} + +#[derive(Clone, Debug, Deserialize)] +struct TimingMeta { + chunk_index: u64, + chunk_start_27mhz: u64, + chunk_duration_27mhz: u64, + utc_start_unix: Option, + sync_status: String, +} + +#[derive(Clone, Debug, Deserialize)] +struct EncryptionMeta { + alg: String, + key_id: String, + nonce_hex: String, +} + +const DIRECT_WIRE_TAG_FRAME: u8 = 0x00; +const DIRECT_WIRE_TAG_STREAM: u8 = 0x01; +const DIRECT_WIRE_TAG_PING: u8 = 0x02; + +#[derive(Default)] +struct DirectWireDecoder { + buf: Vec, + pos: usize, + want: Option, +} + +impl DirectWireDecoder { + fn push(&mut self, msg: &[u8]) -> Vec> { + if msg.is_empty() { + return Vec::new(); + } + + match msg[0] { + DIRECT_WIRE_TAG_FRAME => return vec![msg[1..].to_vec()], + DIRECT_WIRE_TAG_STREAM => { + self.buf.extend_from_slice(&msg[1..]); + } + DIRECT_WIRE_TAG_PING => { + // Control message; ignore. + return Vec::new(); + } + _ => { + // Unknown tag: assume legacy (whole frame per message). + return vec![msg.to_vec()]; + } + } + + let mut out = Vec::new(); + loop { + if self.want.is_none() { + if self.buf.len().saturating_sub(self.pos) < 4 { + break; + } + let start = self.pos; + let len = u32::from_be_bytes([ + self.buf[start], + self.buf[start + 1], + self.buf[start + 2], + self.buf[start + 3], + ]) as usize; + self.pos += 4; + self.want = Some(len); + } + + let Some(want) = self.want else { break }; + if self.buf.len().saturating_sub(self.pos) < want { + break; + } + let start = self.pos; + let end = start + want; + out.push(self.buf[start..end].to_vec()); + self.pos = end; + self.want = None; + + if self.pos > 64 * 1024 { + self.buf.drain(0..self.pos); + self.pos = 0; + } + } + + out + } +} + +fn decode_object_frame(bytes: &[u8]) -> Option<(ObjectMeta, Vec)> { + if bytes.len() < 4 { + return None; + } + let meta_len = u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]) as usize; + if bytes.len() < 4 + meta_len { + return None; + } + let meta: ObjectMeta = serde_json::from_slice(&bytes[4..4 + meta_len]).ok()?; + let data = bytes[4 + meta_len..].to_vec(); + Some((meta, data)) +} + +fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option { + haystack + .windows(needle.len()) + .position(|window| window == needle) +} + +fn codec_string_from_init_mp4(init: &[u8]) -> Option { + // Minimal: locate avcC and read AVCProfileIndication/profile_compatibility/AVCLevelIndication. + let pos = find_subslice(init, b"avcC")?; + let start = pos + 4; + if init.len() < start + 4 { + return None; + } + let config_version = init[start]; + if config_version != 1 { + return None; + } + let profile = init[start + 1]; + let compat = init[start + 2]; + let level = init[start + 3]; + Some(format!("avc1.{profile:02x}{compat:02x}{level:02x}")) +} + +fn ws_url_for_path(path_and_query: &str) -> Result { + let window = web_sys::window().ok_or_else(|| "missing window".to_string())?; + let loc = window.location(); + let origin = loc.origin().map_err(|_| "location.origin failed".to_string())?; + let ws_origin = if origin.starts_with("https://") { + origin.replacen("https://", "wss://", 1) + } else if origin.starts_with("http://") { + origin.replacen("http://", "ws://", 1) + } else { + return Err("unknown origin scheme".to_string()); + }; + Ok(format!("{ws_origin}{path_and_query}")) +} + +struct MseSink { + ms: MediaSource, + sb: Option, + is_open: bool, + updateend_installed: bool, + queue: VecDeque>, +} + +fn create_mse_url() -> Result<(MediaSource, String), String> { + let ms = MediaSource::new().map_err(|_| "MediaSource unsupported".to_string())?; + let url = Url::create_object_url_with_source(&ms).map_err(|_| "URL create failed".to_string())?; + Ok((ms, url)) +} + +fn install_sourceopen(ms: &MediaSource, state: Rc>) { + let ms_for_listener = ms.clone(); + let cb = Closure::::new(move |_evt: Event| { + let mut st = state.borrow_mut(); + st.is_open = true; + // Keep MediaSource alive for the listener lifetime. + let _ = &ms_for_listener; + }); + let _ = ms.add_event_listener_with_callback("sourceopen", cb.as_ref().unchecked_ref()); + cb.forget(); +} + +fn try_create_source_buffer(state: Rc>, init: &[u8]) -> Result<(), JsValue> { + let mut st = state.borrow_mut(); + if st.sb.is_some() || !st.is_open { + return Ok(()); + } + let v = codec_string_from_init_mp4(init).unwrap_or_else(|| "avc1.42e01e".to_string()); + let with_audio = format!("video/mp4; codecs=\"{v}, mp4a.40.2\""); + let sb = st + .ms + .add_source_buffer(&with_audio) + .or_else(|_| st.ms.add_source_buffer(&format!("video/mp4; codecs=\"{v}\"")))?; + st.sb = Some(sb); + if !st.updateend_installed { + if let Some(sb) = st.sb.as_ref() { + let state2 = state.clone(); + let cb = Closure::::new(move |_evt: Event| { + let mut st = state2.borrow_mut(); + flush_queue(&mut st); + }); + let _ = sb.add_event_listener_with_callback("updateend", cb.as_ref().unchecked_ref()); + cb.forget(); + st.updateend_installed = true; + } + } + Ok(()) +} + +fn append_bytes(st: &mut MseSink, bytes: Vec) { + st.queue.push_back(bytes); + flush_queue(st); +} + +fn flush_queue(st: &mut MseSink) { + let Some(sb) = st.sb.as_ref() else { return; }; + if sb.updating() { + return; + } + let Some(mut next) = st.queue.pop_front() else { return; }; + let _ = sb.append_buffer_with_u8_array(&mut next); +} + +async fn copy_to_clipboard(text: String) -> Result<(), String> { + let window = web_sys::window().ok_or_else(|| "window unavailable".to_string())?; + let clipboard = window.navigator().clipboard(); + let promise = clipboard.write_text(&text); + JsFuture::from(promise) + .await + .map_err(|err| format!("clipboard write rejected: {err:?}"))?; + Ok(()) +} + +async fn fetch_json Deserialize<'de>>(url: &str) -> Result { + let window = web_sys::window().ok_or_else(|| "window unavailable".to_string())?; + let p = window.fetch_with_str(url); + let resp = JsFuture::from(p) + .await + .map_err(|err| format!("fetch rejected: {err:?}"))?; + let resp: Response = resp + .dyn_into() + .map_err(|_| "fetch response type mismatch".to_string())?; + let p = resp.json().map_err(|_| "response.json failed".to_string())?; + let value = JsFuture::from(p) + .await + .map_err(|err| format!("response.json rejected: {err:?}"))?; + serde_wasm_bindgen::from_value(value).map_err(|err| format!("invalid json: {err}")) +} + +async fn post_json(url: &str, body: &T) -> Result<(), String> { + let window = web_sys::window().ok_or_else(|| "window unavailable".to_string())?; + let mut init = RequestInit::new(); + init.method("POST"); + let json = serde_json::to_string(body).map_err(|err| err.to_string())?; + init.body(Some(&JsValue::from_str(&json))); + let req = Request::new_with_str_and_init(url, &init) + .map_err(|_| "request build failed".to_string())?; + req.headers() + .set("content-type", "application/json") + .map_err(|_| "header set failed".to_string())?; + let p = window.fetch_with_request(&req); + let resp = JsFuture::from(p) + .await + .map_err(|err| format!("fetch rejected: {err:?}"))?; + let resp: Response = resp + .dyn_into() + .map_err(|_| "fetch response type mismatch".to_string())?; + if !resp.ok() { + return Err(format!("server error: {}", resp.status())); + } + Ok(()) +} + +#[derive(Deserialize)] +struct TurnResp { + ice_servers: Vec, +} + +async fn fetch_turn_peer_config() -> PeerConfiguration { + let mut cfg = PeerConfiguration::default(); + if let Ok(resp) = fetch_json::("/api/turn").await { + // Prefer server-provided ICE servers when present. + if !resp.ice_servers.is_empty() { + cfg.ice_servers = resp.ice_servers; + } + } + cfg +} + +fn main() { + dioxus::launch(App); +} + +fn tauri_available() -> bool { + find_tauri_invoke().is_some() +} + +fn find_tauri_invoke() -> Option<(JsValue, Function)> { + let window = web_sys::window()?; + let tauri = Reflect::get(&window, &JsValue::from_str("__TAURI__")).ok(); + if let Some(tauri) = tauri { + if let Ok(invoke) = Reflect::get(&tauri, &JsValue::from_str("invoke")) { + if let Ok(func) = invoke.dyn_into::() { + return Some((tauri, func)); + } + } + if let Ok(core) = Reflect::get(&tauri, &JsValue::from_str("core")) { + if let Ok(invoke) = Reflect::get(&core, &JsValue::from_str("invoke")) { + if let Ok(func) = invoke.dyn_into::() { + return Some((core, func)); + } + } + } + } + if let Ok(internals) = Reflect::get(&window, &JsValue::from_str("__TAURI_INTERNALS__")) { + if let Ok(invoke) = Reflect::get(&internals, &JsValue::from_str("invoke")) { + if let Ok(func) = invoke.dyn_into::() { + return Some((internals, func)); + } + } + } + None +} + +#[component] +fn App() -> Element { + let mut streams = use_signal(Vec::::new); + let mut selected = use_signal(|| None::); + let mut playback = use_signal(|| None::); + let mut status = use_signal(|| "Discovering local streams".to_string()); + let mut share_info = use_signal(|| None::); + let mut share_announce = use_signal(|| true); + let mut share_peers = use_signal(|| "".to_string()); + let mut share_secret = use_signal(|| "".to_string()); + let mut sources = use_signal(Vec::::new); + let mut source_status = use_signal(|| "Idle".to_string()); + let mut catalog_peers = use_signal(|| "".to_string()); + let mut source_menu_open = use_signal(|| false); + let mut source_loading = use_signal(|| false); + let mut moq_link = use_signal(|| "".to_string()); + let mut moq_link_advanced = use_signal(|| false); + let mut moq_remote = use_signal(|| "".to_string()); + let mut moq_broadcast = use_signal(|| "".to_string()); + let mut moq_stream_id = use_signal(|| "".to_string()); + let mut moq_track = use_signal(|| "chunks".to_string()); + let mut moq_secret = use_signal(|| "".to_string()); + let mut moq_auto_quality = use_signal(|| true); + let mut moq_quality = use_signal(|| "720p".to_string()); + let mut direct_offer = use_signal(|| None::); + let mut direct_reply = use_signal(|| "".to_string()); + let mut direct_answer_stream_id = use_signal(|| None::); + let mut discovery_dht = use_signal(|| false); + let mut discovery_mdns = use_signal(|| false); + let mut discovery_dns = use_signal(|| false); + let mut add_input = use_signal(|| "".to_string()); + let mut add_probe = use_signal(|| None::); + let mut add_probe_input = use_signal(|| "".to_string()); + let mut add_format = use_signal(|| "".to_string()); + let mut add_live_from_start = use_signal(|| false); + let mut linux_adapters = use_signal(Vec::::new); + let mut linux_adapter = use_signal(|| "0".to_string()); + let mut linux_dvr = use_signal(|| "0".to_string()); + let mut linux_channels_conf = use_signal(|| "".to_string()); + let mut linux_channels = use_signal(Vec::::new); + let mut linux_channel = use_signal(|| "".to_string()); + let mut linux_tune_wait = use_signal(|| "800".to_string()); + let mut stream_filter = use_signal(|| "all".to_string()); + let mut page = use_signal(|| 0usize); + let mut global_directory = use_signal(Vec::::new); + let mut global_directory_status = use_signal(|| "Loading...".to_string()); + let mut global_directory_loading = use_signal(|| false); + let page_size = 14usize; + + let _loader = use_resource(move || { + let mut streams = streams.clone(); + let mut status = status.clone(); + let mut sources = sources.clone(); + let mut source_status = source_status.clone(); + let mut global_directory = global_directory.clone(); + let mut global_directory_status = global_directory_status.clone(); + let mut global_directory_loading = global_directory_loading.clone(); + async move { + if !tauri_available() { + status.set("Web mode: browse live channels".to_string()); + source_status.set("Web mode".to_string()); + global_directory_loading.set(true); + match fetch_json::("/api/directory").await { + Ok(list) => { + global_directory_status.set(format!("{} live", list.entries.len())); + global_directory.set(list.entries); + } + Err(err) => { + global_directory_status.set(format!("Live list error: {err}")); + } + } + global_directory_loading.set(false); + return; + } + match tauri_invoke::, _>("list_streams", &EmptyArgs {}) + .await + { + Ok(list) => { + status.set(format!("{} streams ready", list.len())); + streams.set(list); + } + Err(err) => { + status.set(format!("Discovery error: {err}")); + } + } + match tauri_invoke::, _>("list_sources", &EmptyArgs {}) + .await + { + Ok(list) => { + source_status.set(format!("{} sources online", list.len())); + sources.set(list); + } + Err(err) => { + source_status.set(format!("Source error: {err}")); + } + } + } + }); + + let stream_list = streams.read().clone(); + let filter_value = stream_filter.read().clone(); + let filtered_streams = if filter_value == "all" { + stream_list + } else { + stream_list + .into_iter() + .filter(|stream| stream_source_kind(stream) == filter_value) + .collect::>() + }; + let total_streams = filtered_streams.len(); + let page_index = *page.read(); + let page_count = if total_streams == 0 { + 1 + } else { + (total_streams + page_size - 1) / page_size + }; + let clamped_page = if page_index >= page_count { 0 } else { page_index }; + let start = clamped_page * page_size; + let paged_streams = filtered_streams + .iter() + .skip(start) + .take(page_size) + .cloned() + .collect::>(); + if clamped_page != page_index { + page.set(clamped_page); + } + let active_id = selected.read().as_ref().map(|s| s.id.clone()); + let playback_url = playback.read().as_ref().map(|p| p.url.clone()); + let now_playing = selected.read().clone(); + let current_share = share_info.read().clone(); + let source_list = sources.read().clone(); + + let mut refresh_sources = { + let mut sources = sources.clone(); + let mut source_status = source_status.clone(); + let mut source_loading = source_loading.clone(); + move || { + if !tauri_available() { + source_status.set("Tauri backend not available".to_string()); + return; + } + source_loading.set(true); + let mut sources = sources.clone(); + let mut source_status = source_status.clone(); + let mut source_loading = source_loading.clone(); + spawn(async move { + match tauri_invoke::, _>("list_sources", &EmptyArgs {}) + .await + { + Ok(list) => { + source_status.set(format!("{} sources online", list.len())); + sources.set(list); + } + Err(err) => { + source_status.set(format!("Source error: {err}")); + } + } + source_loading.set(false); + }); + } + }; + + let mut refresh_streams = { + let mut streams = streams.clone(); + let mut status = status.clone(); + move || { + if !tauri_available() { + status.set("Tauri backend not available (open the Tauri app)".to_string()); + return; + } + let mut streams = streams.clone(); + let mut status = status.clone(); + spawn(async move { + match tauri_invoke::, _>("refresh_streams", &EmptyArgs {}) + .await + { + Ok(list) => { + status.set(format!("{} streams ready", list.len())); + streams.set(list); + } + Err(err) => { + status.set(format!("Discovery error: {err}")); + } + } + }); + } + }; + + let mut refresh_global_directory = { + let mut global_directory = global_directory.clone(); + let mut global_directory_status = global_directory_status.clone(); + let mut global_directory_loading = global_directory_loading.clone(); + move || { + global_directory_loading.set(true); + let mut global_directory = global_directory.clone(); + let mut global_directory_status = global_directory_status.clone(); + let mut global_directory_loading = global_directory_loading.clone(); + spawn(async move { + match fetch_json::("/api/directory").await { + Ok(list) => { + global_directory_status.set(format!("{} live", list.entries.len())); + global_directory.set(list.entries); + } + Err(err) => { + global_directory_status.set(format!("Live list error: {err}")); + } + } + global_directory_loading.set(false); + }); + } + }; + + rsx! { + div { class: "app", + header { class: "topbar", + div { class: "brand", + div { class: "brand-title", "every.channel" } + div { class: "brand-subtitle", "Your signal, everywhere." } + } + div { class: "topbar-actions", + button { + class: "add-source", + onclick: move |_| { + let open = *source_menu_open.read(); + source_menu_open.set(!open); + if !open { + refresh_sources(); + } + }, + if *source_loading.read() { "Finding..." } else { "+ Add" } + } + if *source_menu_open.read() { + div { class: "source-menu", + div { class: "source-menu-title", "Devices" } + div { class: "source-menu-status", "{source_status.read()}" } + for source in source_list.clone() { + div { class: "source-menu-item", + span { "{source.name}" } + span { "{source.ip.clone().unwrap_or_default()}" } + } + } + button { + class: "source-menu-action", + onclick: move |_| refresh_sources(), + "Refresh" + } + div { class: "source-menu-divider" } + div { class: "source-menu-section", + div { class: "source-menu-title", "Add stream" } + label { class: "source-menu-label", "Paste a link or device address" } + input { + class: "source-menu-input", + placeholder: "HDHomeRun IP, https://…, or linux-dvb", + value: "{add_input.read()}", + oninput: move |evt| { + add_input.set(evt.value()); + add_probe.set(None); + add_probe_input.set("".to_string()); + }, + } + if let Some(probe) = add_probe.read().as_ref() { + if probe.kind == "ytdlp" { + div { class: "source-menu-subsection", + label { class: "source-menu-label", "Format" } + select { + class: "source-menu-input", + onchange: move |evt| add_format.set(evt.value()), + { + if let Some(ytdlp) = probe.ytdlp.as_ref() { + if ytdlp.formats.is_empty() { + rsx! { option { value: "", "Default" } } + } else { + rsx!({ + ytdlp.formats.iter().map(|format| { + let selected = add_format.read().as_str() == format.format_id; + rsx! { + option { + value: "{format.format_id}", + selected: selected, + "{format.label}" + } + } + }) + }) + } + } else { + rsx! { option { value: "", "Default" } } + } + } + } + if probe + .ytdlp + .as_ref() + .map(|ytdlp| ytdlp.supports_live_from_start) + .unwrap_or(false) + { + label { class: "source-menu-toggle", + input { + r#type: "checkbox", + checked: *add_live_from_start.read(), + onchange: move |_| { + let current = *add_live_from_start.read(); + add_live_from_start.set(!current); + } + } + span { "Live from start (when supported)" } + } + } + } + } + if probe.kind == "linux-dvb" { + div { class: "source-menu-subsection", + if linux_adapters.read().is_empty() { + div { class: "source-menu-status", "No Linux DVB adapters found on this machine" } + } + label { class: "source-menu-label", "Adapter" } + select { + class: "source-menu-input", + onchange: move |evt| { + linux_adapter.set(evt.value()); + linux_dvr.set("0".to_string()); + }, + { + linux_adapters.read().iter().map(|info| { + let value = info.adapter.to_string(); + let selected = linux_adapter.read().as_str() == value; + rsx! { + option { value: "{value}", selected: selected, "adapter{info.adapter}" } + } + }) + } + } + label { class: "source-menu-label", "DVR" } + select { + class: "source-menu-input", + onchange: move |evt| linux_dvr.set(evt.value()), + { + let current_adapter = linux_adapter.read().parse::().unwrap_or(0); + if let Some(info) = linux_adapters.read().iter().find(|info| info.adapter == current_adapter) { + rsx!({ + info.dvrs.iter().map(|dvr| { + let value = dvr.to_string(); + let selected = linux_dvr.read().as_str() == value; + rsx! { option { value: "{value}", selected: selected, "dvr{dvr}" } } + }) + }) + } else { + rsx! { option { value: "0", "dvr0" } } + } + } + } + label { class: "source-menu-label", "channels.conf (optional)" } + input { + class: "source-menu-input", + placeholder: "/path/to/channels.conf", + value: "{linux_channels_conf.read()}", + oninput: move |evt| linux_channels_conf.set(evt.value()), + } + button { + class: "source-menu-action", + onclick: move |_| { + if !tauri_available() { + status.set("Tauri backend not available (open the Tauri app)".to_string()); + return; + } + let conf = linux_channels_conf.read().trim().to_string(); + let args = LinuxDvbListChannelsArgs { + channels_conf: if conf.is_empty() { None } else { Some(conf) }, + }; + let mut status = status.clone(); + let mut linux_channels = linux_channels.clone(); + let mut linux_channel = linux_channel.clone(); + let mut linux_channels_conf = linux_channels_conf.clone(); + spawn(async move { + match tauri_invoke::("linux_dvb_list_channels", &args).await { + Ok(info) => { + if let Some(path) = info.channels_conf.clone() { + if linux_channels_conf.read().trim().is_empty() { + linux_channels_conf.set(path); + } + } + if linux_channel.read().trim().is_empty() && !info.channels.is_empty() { + linux_channel.set(info.channels[0].clone()); + } + linux_channels.set(info.channels); + status.set("Linux DVB channels loaded".to_string()); + } + Err(err) => { + status.set(format!("Linux DVB channels error: {err}")); + } + } + }); + }, + "Load channels" + } + if !linux_channels.read().is_empty() { + label { class: "source-menu-label", "Channel" } + select { + class: "source-menu-input", + onchange: move |evt| linux_channel.set(evt.value()), + { + linux_channels.read().iter().map(|channel| { + let selected = linux_channel.read().as_str() == channel.as_str(); + rsx! { option { value: "{channel}", selected: selected, "{channel}" } } + }) + } + } + } + label { class: "source-menu-label", "Tune wait (ms)" } + input { + class: "source-menu-input", + placeholder: "800", + value: "{linux_tune_wait.read()}", + oninput: move |evt| linux_tune_wait.set(evt.value()), + } + } + } + } + button { + class: "source-menu-button", + onclick: move |_| { + let input = add_input.read().trim().to_string(); + if input.is_empty() { + status.set("Stream input is required".to_string()); + return; + } + if !tauri_available() { + status.set("Tauri backend not available (open the Tauri app)".to_string()); + return; + } + let mut status = status.clone(); + let mut streams = streams.clone(); + let mut sources = sources.clone(); + let mut source_status = source_status.clone(); + let mut add_input = add_input.clone(); + let mut add_probe = add_probe.clone(); + let mut add_probe_input = add_probe_input.clone(); + let mut add_format = add_format.clone(); + let mut add_live_from_start = add_live_from_start.clone(); + let mut linux_adapters = linux_adapters.clone(); + let mut linux_adapter = linux_adapter.clone(); + let mut linux_dvr = linux_dvr.clone(); + let mut linux_channels_conf = linux_channels_conf.clone(); + let mut linux_channels = linux_channels.clone(); + let mut linux_channel = linux_channel.clone(); + let mut linux_tune_wait = linux_tune_wait.clone(); + let input_clone = input.clone(); + spawn(async move { + let existing_probe = add_probe.read().clone(); + if let Some(probe) = existing_probe { + if probe.kind == "ytdlp" + && probe.requires_options + && add_probe_input.read().as_str() == input_clone + { + let format_value = add_format.read().trim().to_string(); + let options = ManualSourceOptions { + ytdlp_format: if format_value.is_empty() { None } else { Some(format_value) }, + ytdlp_live_from_start: *add_live_from_start.read(), + }; + status.set("Adding stream".to_string()); + let args = AddStreamArgs { + input: input_clone.clone(), + options: Some(options), + }; + match tauri_invoke::("add_stream", &args).await { + Ok(result) => { + status.set(format!("Added {} ({})", result.added, result.kind)); + add_input.set("".to_string()); + add_probe.set(None); + add_probe_input.set("".to_string()); + add_format.set("".to_string()); + add_live_from_start.set(false); + if let Ok(list) = tauri_invoke::, _>("list_streams", &EmptyArgs {}).await { + streams.set(list); + } + if let Ok(list) = tauri_invoke::, _>("list_sources", &EmptyArgs {}).await { + source_status.set(format!("{} sources online", list.len())); + sources.set(list); + } + } + Err(err) => { + status.set(format!("Add stream error: {err}")); + } + } + return; + } + if probe.kind == "linux-dvb" + && probe.requires_options + && add_probe_input.read().as_str() == input_clone + { + let adapter = linux_adapter + .read() + .trim() + .parse::() + .unwrap_or(0); + let dvr = linux_dvr + .read() + .trim() + .parse::() + .unwrap_or(0); + let conf = linux_channels_conf.read().trim().to_string(); + let channel = linux_channel.read().trim().to_string(); + let tune_wait_ms = linux_tune_wait + .read() + .trim() + .parse::() + .ok(); + let build_args = LinuxDvbBuildUrlArgs { + adapter, + dvr, + channel: if channel.is_empty() { None } else { Some(channel) }, + channels_conf: if conf.is_empty() { None } else { Some(conf) }, + tune_wait_ms, + }; + status.set("Building Linux DVB stream".to_string()); + let url = match tauri_invoke::("linux_dvb_build_url", &build_args).await { + Ok(url) => url, + Err(err) => { + status.set(format!("Linux DVB build error: {err}")); + return; + } + }; + status.set("Adding stream".to_string()); + let args = AddStreamArgs { + input: url, + options: None, + }; + match tauri_invoke::("add_stream", &args).await { + Ok(result) => { + status.set(format!("Added {} ({})", result.added, result.kind)); + add_input.set("".to_string()); + add_probe.set(None); + add_probe_input.set("".to_string()); + add_format.set("".to_string()); + add_live_from_start.set(false); + if let Ok(list) = tauri_invoke::, _>("list_streams", &EmptyArgs {}).await { + streams.set(list); + } + if let Ok(list) = tauri_invoke::, _>("list_sources", &EmptyArgs {}).await { + source_status.set(format!("{} sources online", list.len())); + sources.set(list); + } + } + Err(err) => { + status.set(format!("Add stream error: {err}")); + } + } + return; + } + } + status.set("Checking stream".to_string()); + let probe_args = ProbeStreamArgs { input: input_clone.clone() }; + match tauri_invoke::("probe_stream", &probe_args).await { + Ok(probe) => { + if probe.kind == "ytdlp" && probe.requires_options { + if let Some(ytdlp) = probe.ytdlp.as_ref() { + if let Some(default_format) = ytdlp.default_format.as_ref() { + add_format.set(default_format.clone()); + } else { + add_format.set("".to_string()); + } + add_live_from_start.set(false); + } + add_probe_input.set(input_clone); + add_probe.set(Some(probe)); + status.set("Select yt-dlp options".to_string()); + return; + } + if probe.kind == "linux-dvb" && probe.requires_options { + status.set("Loading Linux DVB devices".to_string()); + match tauri_invoke::, _>("linux_dvb_list_adapters", &EmptyArgs {}).await { + Ok(list) => { + linux_adapters.set(list.clone()); + if let Some(first) = list.first() { + linux_adapter.set(first.adapter.to_string()); + if let Some(dvr) = first.dvrs.first() { + linux_dvr.set(dvr.to_string()); + } else { + linux_dvr.set("0".to_string()); + } + } + } + Err(err) => { + status.set(format!("Linux DVB devices error: {err}")); + } + } + let conf_args = LinuxDvbListChannelsArgs { channels_conf: None }; + match tauri_invoke::("linux_dvb_list_channels", &conf_args).await { + Ok(info) => { + if let Some(path) = info.channels_conf.clone() { + linux_channels_conf.set(path); + } + if !info.channels.is_empty() { + linux_channel.set(info.channels[0].clone()); + } + linux_channels.set(info.channels); + } + Err(err) => { + status.set(format!("Linux DVB channels error: {err}")); + } + } + add_probe_input.set(input_clone); + add_probe.set(Some(probe)); + status.set("Select Linux DVB options".to_string()); + return; + } + + let args = AddStreamArgs { + input: input_clone.clone(), + options: None, + }; + match tauri_invoke::("add_stream", &args).await { + Ok(result) => { + status.set(format!("Added {} ({})", result.added, result.kind)); + add_input.set("".to_string()); + add_probe.set(None); + add_probe_input.set("".to_string()); + add_format.set("".to_string()); + add_live_from_start.set(false); + if let Ok(list) = tauri_invoke::, _>("list_streams", &EmptyArgs {}).await { + streams.set(list); + } + if let Ok(list) = tauri_invoke::, _>("list_sources", &EmptyArgs {}).await { + source_status.set(format!("{} sources online", list.len())); + sources.set(list); + } + } + Err(err) => { + status.set(format!("Add stream error: {err}")); + } + } + } + Err(err) => { + status.set(format!("Probe error: {err}")); + add_probe.set(None); + add_probe_input.set("".to_string()); + } + } + }); + }, + if add_probe.read().is_some() { "Start" } else { "Add" } + } + } + div { class: "source-menu-divider" } + div { class: "source-menu-section", + div { class: "source-menu-title", "Watch a link" } + label { class: "source-menu-label", "Link" } + input { + class: "source-menu-input", + placeholder: "every.channel://watch?...", + value: "{moq_link.read()}", + oninput: move |evt| moq_link.set(evt.value()), + } + button { + class: "source-menu-action", + onclick: move |_| { + let link = moq_link.read().trim().to_string(); + direct_offer.set(None); + direct_reply.set("".to_string()); + direct_answer_stream_id.set(None); + if let Some(parsed) = parse_watch_link(&link) { + moq_remote.set(parsed.remote); + moq_broadcast.set(parsed.broadcast); + if let Some(track) = parsed.track { + moq_track.set(track); + } + if let Some(stream_id) = parsed.stream_id { + moq_stream_id.set(stream_id); + } + if let Some(secret) = parsed.network_secret { + moq_secret.set(secret); + } + if let Some(discovery) = parsed.discovery { + let d = discovery.to_ascii_lowercase(); + discovery_dht.set(d.contains("dht")); + discovery_mdns.set(d.contains("mdns")); + discovery_dns.set(d.contains("dns")); + } + status.set("Link parsed (ready to watch)".to_string()); + return; + } + if let Some(offer) = parse_direct_link(&link) { + direct_offer.set(Some(offer)); + status.set("Link parsed (reply required)".to_string()); + return; + } + status.set("That link does not look valid".to_string()); + }, + "Parse link" + } + label { class: "toggle", + input { + r#type: "checkbox", + checked: {*moq_link_advanced.read()}, + onclick: move |_| { + let next = !*moq_link_advanced.read(); + moq_link_advanced.set(next); + }, + } + span { "Show details" } + } + if *moq_link_advanced.read() { + label { class: "source-menu-label", "Address" } + input { + class: "source-menu-input", + placeholder: "auto-filled from link", + value: "{moq_remote.read()}", + oninput: move |evt| moq_remote.set(evt.value()), + } + label { class: "source-menu-label", "Channel" } + input { + class: "source-menu-input", + placeholder: "auto-filled from link", + value: "{moq_broadcast.read()}", + oninput: move |evt| moq_broadcast.set(evt.value()), + } + label { class: "source-menu-label", "Channel ID (optional)" } + input { + class: "source-menu-input", + placeholder: "optional", + value: "{moq_stream_id.read()}", + oninput: move |evt| moq_stream_id.set(evt.value()), + } + label { class: "source-menu-label", "Segment (optional)" } + input { + class: "source-menu-input", + placeholder: "optional", + value: "{moq_track.read()}", + oninput: move |evt| moq_track.set(evt.value()), + } + label { class: "source-menu-label", "Sharing key (optional)" } + input { + class: "source-menu-input", + placeholder: "optional", + value: "{moq_secret.read()}", + oninput: move |evt| moq_secret.set(evt.value()), + } + } + label { class: "source-menu-label", "Auto quality" } + label { class: "toggle", + input { + r#type: "checkbox", + checked: {*moq_auto_quality.read()}, + onclick: move |_| moq_auto_quality.toggle(), + } + span { "Let the player pick quality" } + } + label { class: "source-menu-label", "Quality" } + select { + class: "source-menu-input", + value: "{moq_quality.read()}", + disabled: {*moq_auto_quality.read()}, + onchange: move |evt| moq_quality.set(evt.value()), + option { value: "1080p", "1080p" } + option { value: "720p", "720p" } + option { value: "480p", "480p" } + } + button { + class: "source-menu-button", + onclick: move |_| { + let remote = moq_remote.read().clone(); + let broadcast = moq_broadcast.read().clone(); + // Direct connect does not require these fields. + if direct_offer.read().is_none() && (remote.is_empty() || broadcast.is_empty()) { + status.set("Missing info: paste a link first".to_string()); + return; + } + let stream_override = moq_stream_id.read().clone(); + let track = moq_track.read().clone(); + let auto_quality = *moq_auto_quality.read(); + let quality = moq_quality.read().clone(); + let secret = moq_secret.read().clone(); + let discovery = discovery_string( + *discovery_dht.read(), + *discovery_mdns.read(), + *discovery_dns.read(), + ); + let mut selected = selected.clone(); + let mut playback = playback.clone(); + let mut status = status.clone(); + let mut direct_reply = direct_reply.clone(); + let direct_answer_stream_id = direct_answer_stream_id.read().clone(); + let direct_offer = direct_offer.read().clone(); + spawn(async move { + if let Some(offer) = direct_offer { + status.set("Preparing reply".to_string()); + let cfg = fetch_turn_peer_config().await; + let pc = match PeerConnectionBuilder::new() + .set_config(cfg) + .with_remote_offer(Some(offer.desc.clone())) + { + Ok(b) => match b.build().await { + Ok(pc) => pc, + Err(err) => { + status.set(format!("Error: {err}")); + return; + } + }, + Err(err) => { + status.set(format!("Error: {err}")); + return; + } + }; + if let Err(err) = pc.add_ice_candidates(offer.candidates.clone()).await { + status.set(format!("Error: {err}")); + return; + } + let desc = match pc.get_local_description().await { + Some(d) => d, + None => { + status.set("Error: no reply description".to_string()); + return; + } + }; + let candidates = match pc.collect_ice_candidates().await { + Ok(c) => c, + Err(err) => { + status.set(format!("Error: {err}")); + return; + } + }; + let reply = match encode_direct_link(&DirectCodeV1 { v: 1, desc, candidates, label: Some("every.channel0".to_string()) }) { + Ok(v) => v, + Err(err) => { + status.set(format!("Error: {err}")); + return; + } + }; + direct_reply.set(reply); + if let Some(stream_id) = direct_answer_stream_id.as_ref() { + // Send answer back to the directory/signaling API so the publisher can connect. + let payload = serde_json::json!({ + "stream_id": stream_id, + "answer": direct_reply.read().clone(), + }); + if let Err(err) = post_json("/api/answer", &payload).await { + status.set(format!("Reply send failed: {err}")); + return; + } + } + + status.set("Waiting for video".to_string()); + let ch = match pc.receive_channel().await { + Ok(ch) => ch, + Err(err) => { + status.set(format!("Error: {err}")); + return; + } + }; + ch.wait_ready().await; + + let (ms, url) = match create_mse_url() { + Ok(v) => v, + Err(err) => { + status.set(format!("Error: {err}")); + return; + } + }; + let sink = Rc::new(RefCell::new(MseSink { + ms: ms.clone(), + sb: None, + is_open: false, + updateend_installed: false, + queue: VecDeque::new(), + })); + install_sourceopen(&ms, sink.clone()); + + playback.set(Some(PlaybackInfo { stream_id: "direct".to_string(), url: url.clone() })); + selected.set(Some(StreamDescriptor { + id: "direct".to_string(), + title: "Direct".to_string(), + number: None, + source: "direct".to_string(), + metadata: vec![], + })); + + // Receive init + segments over the data channel and append to MSE. + let mut ch = ch; + spawn(async move { + use futures_util::future::FutureExt; + use gloo_timers::future::TimeoutFuture; + + let mut decoder = DirectWireDecoder::default(); + let mut next_ping = TimeoutFuture::new(1000).fuse(); + loop { + let msg: Bytes = futures_util::select! { + _ = next_ping => { + let _ = ch.send(&Bytes::from(vec![DIRECT_WIRE_TAG_PING])).await; + next_ping = TimeoutFuture::new(1000).fuse(); + continue; + } + msg = ch.receive().fuse() => { + match msg { + Ok(b) => b, + Err(_) => break, + } + } + }; + for frame in decoder.push(&msg) { + let Some((meta, data)) = + decode_object_frame(&frame) + else { + continue; + }; + if meta.content_type.starts_with("video/mp4") { + let _ = try_create_source_buffer(sink.clone(), &data); + let mut st = sink.borrow_mut(); + append_bytes(&mut st, data); + } else if meta.content_type.contains("iso.segment") { + let mut st = sink.borrow_mut(); + append_bytes(&mut st, data); + } + } + } + }); + + status.set("Live".to_string()); + return; + } + + if !tauri_available() { + status.set("Tauri backend not available (open the Tauri app)".to_string()); + return; + } + + status.set("Connecting".to_string()); + let remote_meta = remote.clone(); + let args = MoqStartArgs { + remote: remote.clone(), + broadcast_name: broadcast.clone(), + stream_id: if stream_override.is_empty() { None } else { Some(stream_override.clone()) }, + track_name: if track.is_empty() { None } else { Some(track.clone()) }, + auto_quality: Some(auto_quality), + variant: if auto_quality { None } else { Some(quality.clone()) }, + network_secret: if secret.is_empty() { None } else { Some(secret.clone()) }, + discovery, + }; + match tauri_invoke::("start_moq_stream", &args).await { + Ok(info) => { + status.set("Live".to_string()); + playback.set(Some(info)); + let stream_id = if stream_override.is_empty() { + broadcast.clone() + } else { + stream_override.clone() + }; + let descriptor = StreamDescriptor { + id: stream_id.clone(), + title: stream_id.clone(), + number: None, + source: "link".to_string(), + metadata: vec![ + StreamMetadata { key: "broadcast".to_string(), value: broadcast.clone() }, + StreamMetadata { key: "remote".to_string(), value: remote_meta.clone() }, + StreamMetadata { key: "track".to_string(), value: track.clone() }, + ], + }; + selected.set(Some(descriptor)); + } + Err(err) => { + status.set(format!("Error: {err}")); + } + } + }); + }, + "Tune in" + } + if !direct_reply.read().is_empty() { + div { class: "source-menu-item", + span { "Reply" } + span { "Send this back to the streamer" } + } + div { class: "source-menu-inline", + input { + class: "source-menu-input", + value: "{direct_reply.read()}", + readonly: true, + } + button { + class: "source-menu-action small", + onclick: move |_| { + let value = direct_reply.read().clone(); + let mut status = status.clone(); + spawn(async move { + match copy_to_clipboard(value).await { + Ok(_) => status.set("Copied reply".to_string()), + Err(err) => status.set(format!("Copy failed: {err}")), + } + }); + }, + "Copy" + } + } + } + } + div { class: "source-menu-divider" } + div { class: "source-menu-section", + div { class: "source-menu-title", "Reach" } + div { class: "source-menu-status", "How others can find you" } + label { class: "toggle", + input { + r#type: "checkbox", + checked: {*discovery_dht.read()}, + onclick: move |_| { + let next = !*discovery_dht.read(); + discovery_dht.set(next); + }, + } + span { "Public (internet)" } + } + label { class: "toggle", + input { + r#type: "checkbox", + checked: {*discovery_mdns.read()}, + onclick: move |_| { + let next = !*discovery_mdns.read(); + discovery_mdns.set(next); + }, + } + span { "Nearby (same Wi-Fi)" } + } + label { class: "toggle", + input { + r#type: "checkbox", + checked: {*discovery_dns.read()}, + onclick: move |_| { + let next = !*discovery_dns.read(); + discovery_dns.set(next); + }, + } + span { "Experimental DNS" } + } + } + } + } + div { class: "status-pill", + div { class: "status-dot" } + span { "{status.read()}" } + } + } + } + main { class: "grid", + div { class: "left-column", + section { class: "panel", + div { class: "panel-header", + div { class: "panel-title", "Channels" } + div { class: "panel-actions", + select { + class: "panel-select", + value: "{stream_filter.read()}", + onchange: move |evt| { + stream_filter.set(evt.value()); + page.set(0); + }, + option { value: "all", "All" } + option { value: "hdhr", "HDHR" } + option { value: "linux-dvb", "Linux DVB" } + option { value: "hls", "HLS" } + option { value: "ytdlp", "yt-dlp" } + option { value: "moq", "Link" } + } + button { + class: "panel-button", + onclick: move |_| refresh_streams(), + "Refresh" + } + div { class: "pager", + button { + class: "pager-button", + onclick: move |_| { + if clamped_page > 0 { + page.set(clamped_page - 1); + } + }, + "Prev" + } + span { class: "pager-label", "{clamped_page + 1} / {page_count}" } + button { + class: "pager-button", + onclick: move |_| { + if clamped_page + 1 < page_count { + page.set(clamped_page + 1); + } + }, + "Next" + } + } + } + } + div { class: "channel-list", + { + let active_id = active_id.clone(); + let mut selected = selected.clone(); + let mut playback = playback.clone(); + let mut status = status.clone(); + let mut share_info = share_info.clone(); + let discovery_dht = discovery_dht.clone(); + let discovery_mdns = discovery_mdns.clone(); + let discovery_dns = discovery_dns.clone(); + paged_streams.iter().map(move |stream| { + let is_active = active_id + .as_ref() + .map(|id| id == &stream.id) + .unwrap_or(false); + let card_class = if is_active { "channel-card active" } else { "channel-card" }; + let moq_endpoint = stream + .metadata + .iter() + .find(|m| m.key == "moq_endpoint") + .map(|m| m.value.clone()); + let moq_broadcast = stream + .metadata + .iter() + .find(|m| m.key == "moq_broadcast") + .map(|m| m.value.clone()); + let moq_track = stream + .metadata + .iter() + .find(|m| m.key == "moq_track") + .map(|m| m.value.clone()); + let moq_key_id = stream + .metadata + .iter() + .find(|m| m.key == "moq_key_id") + .map(|m| m.value.clone()); + let stream_clone = stream.clone(); + let mut selected = selected.clone(); + let mut playback = playback.clone(); + let mut status = status.clone(); + let mut share_info = share_info.clone(); + let has_drm = stream_has_drm(&stream.metadata); + rsx! { + div { + class: "{card_class}", + onclick: move |_| { + selected.set(Some(stream_clone.clone())); + let stream_id = stream_clone.id.clone(); + let stream_title = stream_clone.title.clone(); + status.set(format!("Starting {}", stream_title)); + share_info.set(None); + let mut playback = playback.clone(); + let mut status = status.clone(); + let moq_endpoint = moq_endpoint.clone(); + let moq_broadcast = moq_broadcast.clone(); + let moq_track = moq_track.clone(); + let moq_key_id = moq_key_id.clone(); + spawn(async move { + if let (Some(endpoint), Some(broadcast)) = (moq_endpoint, moq_broadcast) { + let discovery = discovery_string( + *discovery_dht.read(), + *discovery_mdns.read(), + *discovery_dns.read(), + ); + let args = MoqStartArgs { + remote: endpoint.clone(), + broadcast_name: broadcast.clone(), + stream_id: moq_key_id.or(Some(stream_id.clone())), + track_name: moq_track, + auto_quality: Some(true), + variant: None, + network_secret: None, + discovery, + }; + match tauri_invoke::("start_moq_stream", &args).await { + Ok(info) => { + status.set(format!("Live: {}", stream_title)); + playback.set(Some(info)); + } + Err(err) => { + status.set(format!("Link error: {err}")); + } + } + } else { + let args = StartArgs { stream_id: stream_id.clone() }; + match tauri_invoke::("start_stream", &args).await { + Ok(info) => { + status.set(format!("Live: {}", stream_title)); + playback.set(Some(info)); + } + Err(err) => { + status.set(format!("Stream error: {err}")); + } + } + } + }); + }, + div { class: "channel-title", "{stream.title}" } + div { class: "channel-meta", + {stream.number.clone().unwrap_or_default()} + } + if !stream.source.is_empty() { + div { class: "channel-badge source", "{stream.source}" } + } + if has_drm { + div { class: "channel-badge drm", "Protected" } + } + } + } + }) + } + } + } + section { class: "panel", + div { class: "panel-title", "Devices" } + div { class: "source-status", "{source_status.read()}" } + div { class: "source-list", + { + source_list.iter().map(|source| { + let status_label = format!( + "{} - {}", + source.kind.to_uppercase(), + source.status + ); + let ip_label = source + .ip + .clone() + .unwrap_or_else(|| "Unknown IP".to_string()); + let tuner_label = source + .tuner_count + .map(|tuners| format!("{tuners} tuners")) + .unwrap_or_else(|| "Tuners unknown".to_string()); + rsx! { + div { class: "source-card", + div { class: "source-name", "{source.name}" } + div { class: "source-meta", + "{status_label}" + } + div { class: "source-meta", + "{ip_label}" + } + div { class: "source-meta", + "{tuner_label}" + } + } + } + }) + } + } + div { class: "catalog-panel", + div { class: "moq-title", "Live channels" } + if tauri_available() { + label { class: "moq-label", "Friends (optional, comma separated)" } + input { + class: "moq-input", + placeholder: "optional", + value: "{catalog_peers.read()}", + oninput: move |evt| catalog_peers.set(evt.value()), + } + button { + class: "moq-button", + onclick: move |_| { + let peers = catalog_peers + .read() + .split(',') + .map(|p| p.trim().to_string()) + .filter(|p| !p.is_empty()) + .collect::>(); + let discovery = discovery_string( + *discovery_dht.read(), + *discovery_mdns.read(), + *discovery_dns.read(), + ); + let mut status = status.clone(); + spawn(async move { + let args = CatalogWatchArgs { peers, discovery }; + match tauri_invoke::<(), _>("start_catalog_watch", &args).await { + Ok(_) => status.set("Searching for live channels...".to_string()), + Err(err) => status.set(format!("Search error: {err}")), + } + }); + }, + "Search" + } + } else { + div { class: "source-status", "{global_directory_status.read()}" } + button { + class: "moq-button", + "data-testid": "global-refresh", + onclick: move |_| refresh_global_directory(), + if *global_directory_loading.read() { "Refreshing..." } else { "Refresh" } + } + div { class: "source-list", + {global_directory.read().iter().map(|entry| { + let stream_id = entry.stream_id.clone(); + let title = entry.title.clone(); + let mut status = status.clone(); + let mut playback = playback.clone(); + let mut selected = selected.clone(); + rsx! { + div { class: "source-card", + div { class: "source-name", "{title}" } + div { class: "source-meta", "{stream_id}" } + button { + class: "panel-button", + "data-stream-id": "{stream_id}", + "data-testid": "global-watch", + onclick: move |_| { + let mut status = status.clone(); + let mut playback = playback.clone(); + let mut selected = selected.clone(); + let sid = stream_id.clone(); + let stitle = title.clone(); + spawn(async move { + status.set("Connecting".to_string()); + + let (ms, url) = match create_mse_url() { + Ok(v) => v, + Err(err) => { + status.set(format!("Error: {err}")); + return; + } + }; + let sink = Rc::new(RefCell::new(MseSink { + ms: ms.clone(), + sb: None, + is_open: false, + updateend_installed: false, + queue: VecDeque::new(), + })); + install_sourceopen(&ms, sink.clone()); + + playback.set(Some(PlaybackInfo { stream_id: sid.clone(), url: url.clone() })); + selected.set(Some(StreamDescriptor { + id: sid.clone(), + title: stitle, + number: None, + source: "live".to_string(), + metadata: vec![], + })); + + spawn(async move { + use futures_util::future::FutureExt; + use futures_util::{SinkExt, StreamExt}; + use gloo_timers::future::TimeoutFuture; + use gloo_net::websocket::{futures::WebSocket, Message}; + + let sid_enc = js_sys::encode_uri_component(&sid) + .as_string() + .unwrap_or_else(|| sid.clone()); + let ws_url = match ws_url_for_path(&format!( + "/api/stream/ws?stream_id={}&role=sub", + sid_enc + )) { + Ok(u) => u, + Err(err) => { + status.set(format!("WebSocket URL error: {err}")); + return; + } + }; + let mut ws = match WebSocket::open(&ws_url) { + Ok(ws) => ws, + Err(_) => { + status.set("WebSocket connect failed".to_string()); + return; + } + }; + + let mut decoder = DirectWireDecoder::default(); + let mut next_ping = TimeoutFuture::new(1000).fuse(); + loop { + let msg: Vec = futures_util::select! { + _ = next_ping => { + // Keep the relay connection alive (optional). + let _ = ws.send(Message::Bytes(vec![DIRECT_WIRE_TAG_PING])).await; + next_ping = TimeoutFuture::new(1000).fuse(); + continue; + } + msg = ws.next().fuse() => { + match msg { + Some(Ok(Message::Bytes(b))) => b, + Some(Ok(_)) => continue, + Some(Err(_)) => break, + None => break, + } + } + }; + for frame in decoder.push(&msg) { + let Some((meta, data)) = + decode_object_frame(&frame) + else { + continue; + }; + if meta.content_type.starts_with("video/mp4") { + let _ = try_create_source_buffer(sink.clone(), &data); + let mut st = sink.borrow_mut(); + append_bytes(&mut st, data); + } else if meta.content_type.contains("iso.segment") { + let mut st = sink.borrow_mut(); + append_bytes(&mut st, data); + } + } + } + }); + + status.set("Live".to_string()); + }); + }, + "Watch" + } + } + } + })} + } + } + } + } + } + section { class: "panel", + div { class: "panel-header", + div { class: "panel-title", "Now Playing" } + if let Some(stream) = now_playing.clone() { + button { + class: "panel-button", + onclick: move |_| { + if !tauri_available() { + status.set("Tauri backend not available (open the Tauri app)".to_string()); + return; + } + let discovery = discovery_string( + *discovery_dht.read(), + *discovery_mdns.read(), + *discovery_dns.read(), + ); + let peers = share_peers + .read() + .split(',') + .map(|peer| peer.trim().to_string()) + .filter(|peer| !peer.is_empty()) + .collect::>(); + let secret = share_secret.read().trim().to_string(); + let args = ShareArgs { + stream_id: stream.id.clone(), + network_secret: if secret.is_empty() { None } else { Some(secret) }, + chunk_ms: None, + announce: *share_announce.read(), + gossip_peers: peers, + discovery, + }; + let mut share_info = share_info.clone(); + let mut status = status.clone(); + spawn(async move { + status.set("Preparing link".to_string()); + match tauri_invoke::("start_moq_publish", &args).await { + Ok(info) => { + if let Some(announce) = info.announce_status.as_ref() { + status.set(format!("Ready ({announce})")); + } else { + status.set("Ready".to_string()); + } + share_info.set(Some(info)); + } + Err(err) => { + status.set(format!("Error: {err}")); + } + } + }); + }, + "Share this channel" + } + } + } + div { class: "player-shell", + div { class: "video-frame", + if let Some(url) = playback_url { + video { + src: "{url}", + controls: true, + autoplay: true, + playsinline: true, + } + } else { + div { class: "placeholder", "Select a channel to start playback" } + } + } + if let Some(stream) = now_playing { + div { class: "meta-grid", + div { class: "meta-card", + strong { "Channel" } + span { "{stream.title}" } + } + div { class: "meta-card", + strong { "Number" } + span { {stream.number.clone().unwrap_or_else(|| "-".to_string())} } + } + if stream_has_drm(&stream.metadata) { + div { class: "meta-card drm", + strong { "Protected" } + span { "Likely protected" } + } + } + div { class: "meta-card", + strong { "Sharing" } + label { class: "toggle", + input { + r#type: "checkbox", + checked: {*share_announce.read()}, + onclick: move |_| { + let next = !*share_announce.read(); + share_announce.set(next); + }, + } + span { "Show in directory" } + } + label { class: "toggle", + input { + r#type: "checkbox", + checked: {*discovery_mdns.read()}, + onclick: move |_| { + let next = !*discovery_mdns.read(); + discovery_mdns.set(next); + }, + } + span { "Nearby (same Wi-Fi)" } + } + label { class: "toggle", + input { + r#type: "checkbox", + checked: {*discovery_dht.read()}, + onclick: move |_| { + let next = !*discovery_dht.read(); + discovery_dht.set(next); + }, + } + span { "Public (internet)" } + } + input { + class: "share-input", + placeholder: "contacts (comma separated, optional)", + value: "{share_peers.read()}", + oninput: move |evt| share_peers.set(evt.value()), + } + input { + class: "share-input", + placeholder: "sharing key (optional)", + value: "{share_secret.read()}", + oninput: move |evt| share_secret.set(evt.value()), + } + } + for meta in stream.metadata.iter().take(6) { + div { class: "meta-card", + strong { "{meta.key.as_str()}" } + span { "{meta.value.as_str()}" } + } + } + } + } + if let Some(share) = current_share { + div { class: "share-card", + div { class: "share-title", "Share" } + div { class: "share-row", + span { class: "share-label", "Link" } + div { class: "share-link-row", + input { + class: "share-link", + readonly: true, + value: { + let secret_for_link = share_secret.read().trim().to_string(); + build_watch_link( + &share.endpoint_addr, + &share.broadcast_name, + Some(&share.track_name), + None, + if secret_for_link.is_empty() { None } else { Some(secret_for_link.as_str()) }, + share.discovery.as_deref(), + ) + }, + } + button { + class: "share-copy", + onclick: move |_| { + let secret_for_link = share_secret.read().trim().to_string(); + let link = build_watch_link( + &share.endpoint_addr, + &share.broadcast_name, + Some(&share.track_name), + None, + if secret_for_link.is_empty() { None } else { Some(secret_for_link.as_str()) }, + share.discovery.as_deref(), + ); + let mut status = status.clone(); + spawn(async move { + match copy_to_clipboard(link).await { + Ok(_) => status.set("Copied link".to_string()), + Err(err) => status.set(format!("Copy failed: {err}")), + } + }); + }, + "Copy" + } + } + } + if let Some(endpoint_id) = share.endpoint_id.clone() { + div { class: "share-row", + span { class: "share-label", "Your ID" } + div { class: "share-link-row", + input { + class: "share-link", + readonly: true, + value: "{endpoint_id}", + } + button { + class: "share-copy", + onclick: move |_| { + let value = endpoint_id.clone(); + let mut status = status.clone(); + spawn(async move { + match copy_to_clipboard(value).await { + Ok(_) => status.set("Copied ID".to_string()), + Err(err) => status.set(format!("Copy failed: {err}")), + } + }); + }, + "Copy" + } + } + } + } + if let Some(announce) = share.announce_status { + div { class: "share-row", + span { class: "share-label", "Status" } + span { class: "share-value", "{announce}" } + } + } + } + } + } + } + } + } + } +} + +fn stream_has_drm(metadata: &[StreamMetadata]) -> bool { + metadata.iter().any(|meta| { + let key = meta.key.to_lowercase(); + let value = meta.value.to_lowercase(); + key == "drm" + || key.contains("copy") + || key.contains("protected") + || value.contains("drm") + || value.contains("encrypted") + || value.contains("widevine") + }) +} + +fn stream_source_kind(stream: &StreamDescriptor) -> String { + let source = stream.source.trim(); + if !source.is_empty() { + return source.to_ascii_lowercase(); + } + if let Some(kind) = stream + .metadata + .iter() + .find(|entry| entry.key == "source_kind") + .map(|entry| entry.value.clone()) + { + return kind.to_ascii_lowercase(); + } + "unknown".to_string() +} + +async fn tauri_invoke(command: &str, args: &A) -> Result +where + T: for<'de> Deserialize<'de>, + A: Serialize, +{ + let (tauri, invoke_fn) = find_tauri_invoke().ok_or_else(|| "tauri invoke not available".to_string())?; + + let args = serde_wasm_bindgen::to_value(args).map_err(|err| err.to_string())?; + let promise = invoke_fn + .call2(&tauri, &JsValue::from_str(command), &args) + .map_err(|err| format!("invoke failed: {err:?}"))?; + let promise = Promise::from(promise); + let result = JsFuture::from(promise) + .await + .map_err(|err| format!("invoke rejected: {err:?}"))?; + + serde_wasm_bindgen::from_value(result).map_err(|err| err.to_string()) +} diff --git a/apps/tauri/ui/style.css b/apps/tauri/ui/style.css new file mode 100644 index 0000000..9798054 --- /dev/null +++ b/apps/tauri/ui/style.css @@ -0,0 +1,671 @@ +@import url("https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500&display=swap"); + +:root { + color-scheme: light; + --bg: #f7f4ef; + --bg-ink: #151410; + --bg-muted: #f1ede6; + --bg-card: #ffffff; + --accent: #18a89b; + --accent-strong: #0c6f68; + --accent-warm: #d4915a; + --ink: #151410; + --ink-muted: #5a564c; + --border: rgba(21, 20, 16, 0.12); + --shadow: 0 24px 50px rgba(21, 20, 16, 0.15); + font-family: "Space Grotesk", "IBM Plex Sans", "Segoe UI", sans-serif; +} + +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + background: radial-gradient(circle at top left, #fff7ec 0%, #f7f4ef 42%, #eef5f3 100%); + color: var(--ink); + min-height: 100vh; +} + +body::before { + content: ""; + position: fixed; + inset: 0; + background-image: radial-gradient(circle at 20% 20%, rgba(24, 168, 155, 0.15), transparent 45%), + radial-gradient(circle at 85% 12%, rgba(255, 155, 82, 0.18), transparent 40%), + radial-gradient(circle at 40% 80%, rgba(24, 168, 155, 0.1), transparent 50%); + pointer-events: none; + z-index: -1; +} + +#main { + min-height: 100vh; +} + +.app { + display: flex; + flex-direction: column; + gap: 20px; + padding: 24px clamp(16px, 4vw, 40px) 36px; + animation: fadeIn 0.6s ease-out; +} + +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +.topbar-actions { + display: flex; + align-items: center; + gap: 12px; + position: relative; +} + +.add-source { + border: none; + border-radius: 14px; + background: var(--accent-strong); + color: white; + padding: 8px 14px; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.add-source:hover { + transform: translateY(-1px); + box-shadow: 0 12px 20px rgba(12, 111, 104, 0.25); +} + +.source-menu { + position: absolute; + top: 48px; + right: 0; + width: 280px; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 16px; + padding: 12px; + box-shadow: var(--shadow); + display: flex; + flex-direction: column; + gap: 8px; + max-height: 520px; + overflow-y: auto; + z-index: 10; +} + +.source-menu-title { + font-size: 13px; + font-weight: 500; +} + +.source-menu-status { + font-size: 12px; + color: var(--ink-muted); +} + +.source-menu-item { + display: flex; + justify-content: space-between; + font-size: 12px; + color: var(--ink-muted); +} + +.source-menu-action { + border: none; + border-radius: 12px; + background: var(--bg-muted); + padding: 8px 10px; + font-size: 12px; + cursor: pointer; +} + +.source-menu-action.small { + padding: 6px 10px; + border-radius: 10px; +} + +.source-menu-inline { + display: flex; + gap: 8px; + align-items: center; +} + +.source-menu-inline .source-menu-input { + flex: 1; +} + +.source-menu-divider { + height: 1px; + background: var(--border); + margin: 6px 0; +} + +.source-menu-section { + display: flex; + flex-direction: column; + gap: 6px; +} + +.source-menu-subsection { + display: flex; + flex-direction: column; + gap: 8px; +} + +.source-menu-label { + font-size: 11px; + color: var(--ink-muted); + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.source-menu-input { + border: 1px solid var(--border); + border-radius: 10px; + padding: 6px 8px; + font-size: 12px; + background: var(--bg-muted); +} + +.source-menu-button { + border: none; + border-radius: 12px; + background: var(--accent); + color: #fff; + padding: 8px 10px; + font-size: 12px; + cursor: pointer; +} + +.source-menu-toggle { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + color: var(--ink-muted); +} + +.brand { + display: flex; + flex-direction: column; + gap: 6px; +} + +.brand-title { + font-size: clamp(24px, 2.6vw, 30px); + font-weight: 600; + letter-spacing: -0.03em; +} + +.brand-subtitle { + font-size: 12px; + color: var(--ink-muted); +} + +.status-pill { + display: inline-flex; + align-items: center; + gap: 10px; + padding: 8px 14px; + border-radius: 999px; + border: 1px solid var(--border); + background: var(--bg-card); + font-size: 13px; + color: var(--ink-muted); + box-shadow: 0 10px 20px rgba(21, 20, 16, 0.08); +} + +.status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--accent); + box-shadow: 0 0 0 4px rgba(24, 168, 155, 0.2); + animation: pulse 1.8s ease-in-out infinite; +} + +@keyframes pulse { + 0% { + box-shadow: 0 0 0 4px rgba(24, 168, 155, 0.2); + } + 50% { + box-shadow: 0 0 0 7px rgba(24, 168, 155, 0.08); + } + 100% { + box-shadow: 0 0 0 4px rgba(24, 168, 155, 0.2); + } +} + +.grid { + display: grid; + grid-template-columns: minmax(260px, 1fr) minmax(320px, 2fr); + gap: 20px; +} + +.left-column { + display: flex; + flex-direction: column; + gap: 24px; +} + +.panel { + background: var(--bg-card); + border-radius: 18px; + border: 1px solid var(--border); + box-shadow: var(--shadow); + padding: 20px; +} + +.panel-title { + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.14em; + color: var(--ink-muted); + margin-bottom: 12px; +} + +.panel-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; +} + +.panel-header .panel-title { + margin-bottom: 0; +} + +.panel-actions { + display: flex; + align-items: center; + gap: 10px; +} + +.panel-select { + border: 1px solid var(--border); + background: var(--bg-muted); + border-radius: 10px; + padding: 6px 10px; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.1em; +} + +.panel-button { + border: none; + border-radius: 12px; + background: var(--bg-muted); + padding: 8px 12px; + font-size: 12px; + cursor: pointer; +} + +.pager { + display: flex; + align-items: center; + gap: 6px; +} + +.pager-button { + border: none; + border-radius: 10px; + background: var(--bg-muted); + padding: 6px 10px; + font-size: 11px; + cursor: pointer; +} + +.pager-label { + font-size: 12px; + color: var(--ink-muted); +} + +.channel-list { + display: flex; + flex-direction: column; + gap: 10px; + max-height: 480px; + overflow: auto; + padding-right: 4px; +} + +.channel-card { + border-radius: 14px; + border: 1px solid transparent; + background: var(--bg-muted); + padding: 12px 14px; + display: flex; + flex-direction: column; + gap: 4px; + cursor: pointer; + transition: transform 0.2s ease, border 0.2s ease, box-shadow 0.2s ease; +} + +.channel-badge { + align-self: flex-start; + border-radius: 999px; + padding: 4px 10px; + font-size: 10px; + letter-spacing: 0.12em; + text-transform: uppercase; + font-weight: 600; + background: rgba(21, 20, 16, 0.08); + color: var(--ink-muted); +} + +.channel-badge.drm { + background: rgba(255, 155, 82, 0.2); + color: #a14d00; +} + +.channel-badge.source { + background: rgba(24, 168, 155, 0.16); + color: #0f6c63; +} + +.channel-card:hover { + transform: translateY(-2px); + border: 1px solid rgba(24, 168, 155, 0.4); + box-shadow: 0 12px 24px rgba(21, 20, 16, 0.1); +} + +.channel-card.active { + border: 1px solid var(--accent); + background: rgba(24, 168, 155, 0.1); +} + +.channel-title { + font-size: 15px; + font-weight: 600; +} + +.channel-meta { + font-size: 13px; + color: var(--ink-muted); +} + +.source-status { + font-size: 13px; + color: var(--ink-muted); + margin-bottom: 12px; +} + +.source-list { + display: flex; + flex-direction: column; + gap: 12px; + margin-bottom: 20px; +} + +.source-card { + border-radius: 16px; + border: 1px solid var(--border); + background: #fbf9f6; + padding: 12px 14px; +} + +.source-name { + font-weight: 600; + margin-bottom: 6px; +} + +.source-meta { + font-size: 12px; + color: var(--ink-muted); +} + +.catalog-panel { + margin-bottom: 18px; +} + +.player-shell { + display: flex; + flex-direction: column; + gap: 16px; +} + +.video-frame { + width: 100%; + aspect-ratio: 16 / 9; + border-radius: 14px; + overflow: hidden; + background: #0f0f0f; + border: 1px solid rgba(21, 20, 16, 0.2); + position: relative; +} + +.video-frame::after { + content: ""; + position: absolute; + inset: 0; + pointer-events: none; + opacity: 0.11; + mix-blend-mode: overlay; + background: + linear-gradient( + to bottom, + rgba(255, 255, 255, 0.045), + rgba(0, 0, 0, 0.045) + ), + repeating-linear-gradient( + to bottom, + rgba(255, 255, 255, 0.04) 0px, + rgba(255, 255, 255, 0.04) 1px, + rgba(0, 0, 0, 0) 2px, + rgba(0, 0, 0, 0) 4px + ); +} + +video { + width: 100%; + height: 100%; + object-fit: cover; +} + +.placeholder { + display: grid; + place-items: center; + height: 100%; + color: rgba(255, 255, 255, 0.7); + font-size: 14px; +} + +.meta-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 12px; +} + +.meta-card { + border-radius: 12px; + background: #fdfbf8; + border: 1px solid var(--border); + padding: 12px 14px; + font-size: 13px; + color: var(--ink-muted); +} + +.meta-card.drm { + border-color: rgba(255, 155, 82, 0.5); + background: rgba(255, 155, 82, 0.12); +} + +.meta-card strong { + display: block; + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.12em; + color: var(--ink-muted); + margin-bottom: 4px; +} + +.share-card { + margin-top: 16px; + border-radius: 14px; + border: 1px solid var(--border); + background: rgba(255, 255, 255, 0.7); + padding: 14px 16px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.share-title { + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.2em; + color: var(--ink-muted); +} + +.share-row { + display: flex; + flex-direction: column; + gap: 4px; +} + +.share-label { + font-size: 11px; + color: var(--ink-muted); + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.share-value { + font-size: 12px; + color: var(--ink); + word-break: break-all; +} + +.share-link-row { + display: grid; + grid-template-columns: 1fr auto; + gap: 10px; + align-items: center; +} + +.share-link { + border-radius: 10px; + border: 1px solid var(--border); + padding: 8px 10px; + font-size: 12px; + background: #fbf9f6; + color: var(--ink); +} + +.share-copy { + border-radius: 10px; + border: 1px solid var(--border); + background: rgba(255, 255, 255, 0.8); + padding: 8px 12px; + font-size: 12px; + color: var(--ink); + cursor: pointer; + transition: transform 140ms ease, box-shadow 140ms ease, background 140ms ease; +} + +.share-copy:hover { + transform: translateY(-1px); + box-shadow: 0 10px 24px rgba(30, 23, 17, 0.08); +} + +.toggle { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + color: var(--ink); +} + +.toggle input { + accent-color: var(--accent); +} + +.share-input { + margin-top: 8px; + border-radius: 10px; + border: 1px solid var(--border); + padding: 8px 10px; + font-size: 12px; + background: #fbf9f6; + color: var(--ink); +} + +.moq-panel { + margin-top: 24px; + padding-top: 20px; + border-top: 1px dashed var(--border); + display: flex; + flex-direction: column; + gap: 10px; +} + +.moq-title { + font-size: 13px; + font-weight: 600; + color: var(--ink); +} + +.moq-label { + font-size: 12px; + color: var(--ink-muted); + text-transform: uppercase; + letter-spacing: 0.12em; +} + +.moq-input { + border-radius: 10px; + border: 1px solid var(--border); + padding: 10px 12px; + font-size: 13px; + background: #fbf9f6; + color: var(--ink); +} + +.moq-input:focus { + outline: none; + border-color: rgba(24, 168, 155, 0.6); + box-shadow: 0 0 0 3px rgba(24, 168, 155, 0.15); +} + +.moq-button { + margin-top: 6px; + border: none; + border-radius: 12px; + background: var(--accent); + color: white; + padding: 10px 14px; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.moq-button:hover { + transform: translateY(-1px); + box-shadow: 0 12px 20px rgba(24, 168, 155, 0.25); +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(12px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@media (max-width: 900px) { + .grid { + grid-template-columns: 1fr; + } +} diff --git a/apps/tauri/ui/sw.js b/apps/tauri/ui/sw.js new file mode 100644 index 0000000..5f78660 --- /dev/null +++ b/apps/tauri/ui/sw.js @@ -0,0 +1,103 @@ +/* every.channel PWA service worker + * + * Goal: cache the app shell so it can be installed and load offline. + * Do not interfere with media fetching/streaming: always network-pass-through + * for non-GET requests and for large binary media responses. + */ + +const CACHE_NAME = "every.channel-shell-v1"; +const SHELL = [ + "./", + "./index.html", + "./style.css", + "./manifest.webmanifest", + "./icons/icon-192.png", + "./icons/icon-512.png", + "./icons/apple-touch-icon.png", +]; + +self.addEventListener("install", (event) => { + event.waitUntil( + caches + .open(CACHE_NAME) + .then((cache) => cache.addAll(SHELL)) + .then(() => self.skipWaiting()) + ); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil( + caches + .keys() + .then((keys) => + Promise.all( + keys.map((key) => { + if (key !== CACHE_NAME) return caches.delete(key); + return Promise.resolve(); + }) + ) + ) + .then(() => self.clients.claim()) + ); +}); + +function isNavigationRequest(request) { + return request.mode === "navigate"; +} + +function isMediaRequest(request) { + const url = new URL(request.url); + const path = url.pathname.toLowerCase(); + return ( + path.endsWith(".m3u8") || + path.endsWith(".m4s") || + path.endsWith(".mp4") || + path.endsWith(".ts") + ); +} + +self.addEventListener("fetch", (event) => { + const { request } = event; + if (request.method !== "GET") return; + + // Don't cache/modify streaming media requests. + if (isMediaRequest(request)) { + event.respondWith(fetch(request)); + return; + } + + // For navigations, prefer network but fall back to cached shell. + if (isNavigationRequest(request)) { + event.respondWith( + fetch(request).catch(() => caches.match("./index.html").then((r) => r || Response.error())) + ); + return; + } + + // Cache-first for same-origin static assets; network fallback. + const url = new URL(request.url); + if (url.origin === self.location.origin) { + event.respondWith( + caches.match(request).then((cached) => { + if (cached) return cached; + return fetch(request) + .then((resp) => { + // Avoid caching huge binary responses. + const len = resp.headers.get("content-length"); + const tooBig = len && Number(len) > 5_000_000; + if (resp.ok && !tooBig) { + const clone = resp.clone(); + caches.open(CACHE_NAME).then((cache) => cache.put(request, clone)).catch(() => {}); + } + return resp; + }) + .catch(() => cached || Response.error()); + }) + ); + return; + } + + // Default: network. + event.respondWith(fetch(request)); +}); + diff --git a/apps/web/Cargo.lock b/apps/web/Cargo.lock new file mode 100644 index 0000000..7ca771a --- /dev/null +++ b/apps/web/Cargo.lock @@ -0,0 +1,1857 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + +[[package]] +name = "console_error_panic_hook" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" +dependencies = [ + "cfg-if", + "wasm-bindgen", +] + +[[package]] +name = "const-serialize" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08259976d62c715c4826cb4a3d64a3a9e5c5f68f964ff6087319857f569f93a6" +dependencies = [ + "const-serialize-macro", + "serde", +] + +[[package]] +name = "const-serialize-macro" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04382d0d9df7434af6b1b49ea1a026ef39df1b0738b1cc373368cf175354f6eb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "const_format" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7faa7469a93a566e9ccc1c73fe783b4a65c274c5ace346038dca9c39fe0030ad" +dependencies = [ + "const_format_proc_macros", +] + +[[package]] +name = "const_format_proc_macros" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + +[[package]] +name = "convert_case" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "dashmap" +version = "5.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856" +dependencies = [ + "cfg-if", + "hashbrown", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dioxus" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a247114500f1a78e87022defa8173de847accfada8e8809dfae23a118a580c" +dependencies = [ + "dioxus-cli-config", + "dioxus-config-macro", + "dioxus-core", + "dioxus-core-macro", + "dioxus-devtools", + "dioxus-document", + "dioxus-fullstack", + "dioxus-history", + "dioxus-hooks", + "dioxus-html", + "dioxus-logger", + "dioxus-signals", + "dioxus-web", + "manganis", + "warnings", +] + +[[package]] +name = "dioxus-cli-config" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdd16948f1ffdb068dd9a64812158073a4250e2af4e98ea31fdac0312e6bce86" +dependencies = [ + "wasm-bindgen", +] + +[[package]] +name = "dioxus-config-macro" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75cbf582fbb1c32d34a1042ea675469065574109c95154468710a4d73ee98b49" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "dioxus-core" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c03f451a119e47433c16e2d8eb5b15bf7d6e6734eb1a4c47574e6711dadff8d" +dependencies = [ + "const_format", + "dioxus-core-types", + "futures-channel", + "futures-util", + "generational-box", + "longest-increasing-subsequence", + "rustc-hash", + "rustversion", + "serde", + "slab", + "slotmap", + "tracing", + "warnings", +] + +[[package]] +name = "dioxus-core-macro" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "105c954caaaedf8cd10f3d1ba576b01e18aa8d33ad435182125eefe488cf0064" +dependencies = [ + "convert_case", + "dioxus-rsx", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dioxus-core-types" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91a82fccfa48574eb7aa183e297769540904694844598433a9eb55896ad9f93b" +dependencies = [ + "once_cell", +] + +[[package]] +name = "dioxus-devtools" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712a7300f1e8181218187b03502044157eef04e0a25b518117c5ef9ae1096880" +dependencies = [ + "dioxus-core", + "dioxus-devtools-types", + "dioxus-signals", + "serde", + "serde_json", + "tracing", + "tungstenite", + "warnings", +] + +[[package]] +name = "dioxus-devtools-types" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f62434973c0c9c5a3bc42e9cd5e7070401c2062a437fb5528f318c3e42ebf4ff" +dependencies = [ + "dioxus-core", + "serde", +] + +[[package]] +name = "dioxus-document" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "802a2014d1662b6615eec0a275745822ee4fc66aacd9d0f2fb33d6c8da79b8f2" +dependencies = [ + "dioxus-core", + "dioxus-core-macro", + "dioxus-core-types", + "dioxus-html", + "futures-channel", + "futures-util", + "generational-box", + "lazy-js-bundle", + "serde", + "serde_json", + "tracing", +] + +[[package]] +name = "dioxus-fullstack" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe99b48a1348eec385b5c4bd3e80fd863b0d3b47257d34e2ddc58754dec5d128" +dependencies = [ + "base64", + "bytes", + "ciborium", + "dioxus-devtools", + "dioxus-history", + "dioxus-lib", + "dioxus-web", + "dioxus_server_macro", + "futures-channel", + "futures-util", + "generational-box", + "once_cell", + "serde", + "server_fn", + "tracing", + "web-sys", +] + +[[package]] +name = "dioxus-history" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ae4e22616c698f35b60727313134955d885de2d32e83689258e586ebc9b7909" +dependencies = [ + "dioxus-core", + "tracing", +] + +[[package]] +name = "dioxus-hooks" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "948e2b3f20d9d4b2c300aaa60281b1755f3298684448920b27106da5841896d0" +dependencies = [ + "dioxus-core", + "dioxus-signals", + "futures-channel", + "futures-util", + "generational-box", + "rustversion", + "slab", + "tracing", + "warnings", +] + +[[package]] +name = "dioxus-html" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59c9a40e6fee20ce7990095492dedb6a753eebe05e67d28271a249de74dc796d" +dependencies = [ + "async-trait", + "dioxus-core", + "dioxus-core-macro", + "dioxus-core-types", + "dioxus-hooks", + "dioxus-html-internal-macro", + "enumset", + "euclid", + "futures-channel", + "generational-box", + "keyboard-types", + "lazy-js-bundle", + "rustversion", + "tracing", +] + +[[package]] +name = "dioxus-html-internal-macro" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43ba87b53688a2c9f619ecdf4b3b955bc1f08bd0570a80a0d626c405f6d14a76" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dioxus-interpreter-js" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330707b10ca75cb0eb05f9e5f8d80217cd0d7e62116a8277ae363c1a09b57a22" +dependencies = [ + "js-sys", + "lazy-js-bundle", + "rustc-hash", + "sledgehammer_bindgen", + "sledgehammer_utils", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "dioxus-lib" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5405b71aa9b8b0c3e0d22728f12f34217ca5277792bd315878cc6ecab7301b72" +dependencies = [ + "dioxus-config-macro", + "dioxus-core", + "dioxus-core-macro", + "dioxus-document", + "dioxus-history", + "dioxus-hooks", + "dioxus-html", + "dioxus-rsx", + "dioxus-signals", + "warnings", +] + +[[package]] +name = "dioxus-logger" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "545961e752f6c8bf59c274951b3c8b18a106db6ad2f9e2035b29e1f2a3e899b1" +dependencies = [ + "console_error_panic_hook", + "dioxus-cli-config", + "tracing", + "tracing-subscriber", + "tracing-wasm", +] + +[[package]] +name = "dioxus-rsx" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eb588e05800b5a7eb90b2f40fca5bbd7626e823fb5e1ba21e011de649b45aa1" +dependencies = [ + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "syn", +] + +[[package]] +name = "dioxus-signals" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10e032dbb3a2c0386ec8b8ee59bc20b5aeb67038147c855801237b45b13d72ac" +dependencies = [ + "dioxus-core", + "futures-channel", + "futures-util", + "generational-box", + "once_cell", + "parking_lot", + "rustc-hash", + "tracing", + "warnings", +] + +[[package]] +name = "dioxus-web" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7c12475c3d360058b8afe1b68eb6dfc9cbb7dcd760aed37c5f85c561c83ed1" +dependencies = [ + "async-trait", + "ciborium", + "dioxus-cli-config", + "dioxus-core", + "dioxus-core-types", + "dioxus-devtools", + "dioxus-document", + "dioxus-history", + "dioxus-html", + "dioxus-interpreter-js", + "dioxus-signals", + "futures-channel", + "futures-util", + "generational-box", + "js-sys", + "lazy-js-bundle", + "rustc-hash", + "serde", + "serde-wasm-bindgen", + "serde_json", + "tracing", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "dioxus_server_macro" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "371a5b21989a06b53c5092e977b3f75d0e60a65a4c15a2aa1d07014c3b2dda97" +dependencies = [ + "proc-macro2", + "quote", + "server_fn_macro", + "syn", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "ec-web" +version = "0.0.0" +dependencies = [ + "dioxus", + "js-sys", + "serde", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "enumset" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25b07a8dfbbbfc0064c0a6bdf9edcf966de6b1c33ce344bdeca3b41615452634" +dependencies = [ + "enumset_derive", +] + +[[package]] +name = "enumset_derive" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f43e744e4ea338060faee68ed933e46e722fb7f3617e722a5772d7e856d8b3ce" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "euclid" +version = "0.22.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df61bf483e837f88d5c2291dcf55c67be7e676b3a51acc48db3a7b163b91ed63" +dependencies = [ + "num-traits", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "generational-box" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a673cf4fb0ea6a91aa86c08695756dfe875277a912cdbf33db9a9f62d47ed82b" +dependencies = [ + "parking_lot", + "tracing", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "gloo-net" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c06f627b1a58ca3d42b45d6104bf1e1a03799df472df00988b6ba21accc10580" +dependencies = [ + "futures-channel", + "futures-core", + "futures-sink", + "gloo-utils", + "http", + "js-sys", + "pin-project", + "serde", + "serde_json", + "thiserror", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "gloo-utils" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5555354113b18c547c1d3a98fbf7fb32a9ff4f6fa112ce823a21641a0ba3aa" +dependencies = [ + "js-sys", + "serde", + "serde_json", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "js-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "keyboard-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +dependencies = [ + "bitflags", +] + +[[package]] +name = "lazy-js-bundle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e49596223b9d9d4947a14a25c142a6e7d8ab3f27eb3ade269d238bb8b5c267e2" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.180" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "longest-increasing-subsequence" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3bd0dd2cd90571056fdb71f6275fada10131182f84899f4b2a916e565d81d86" + +[[package]] +name = "manganis" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "317af44b15e7605b85f04525449a3bb631753040156c9b318e6cba8a3ea4ef73" +dependencies = [ + "const-serialize", + "manganis-core", + "manganis-macro", +] + +[[package]] +name = "manganis-core" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c38bee65cc725b2bba23b5dbb290f57c8be8fadbe2043fb7e2ce73022ea06519" +dependencies = [ + "const-serialize", + "dioxus-cli-config", + "dioxus-core-types", + "serde", +] + +[[package]] +name = "manganis-macro" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f4f71310913c40174d9f0cfcbcb127dad0329ecdb3945678a120db22d3d065" +dependencies = [ + "dunce", + "manganis-core", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proc-macro2-diagnostics" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "version_check", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" +dependencies = [ + "futures-core", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-wasm-bindgen" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3b143e2833c57ab9ad3ea280d21fd34e285a42837aeb0ee301f4f41890fa00e" +dependencies = [ + "js-sys", + "serde", + "wasm-bindgen", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_qs" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0431a35568651e363364210c91983c1da5eb29404d9f0928b67d4ebcfa7d330c" +dependencies = [ + "percent-encoding", + "serde", + "thiserror", +] + +[[package]] +name = "server_fn" +version = "0.6.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fae7a3038a32e5a34ba32c6c45eb4852f8affaf8b794ebfcd4b1099e2d62ebe" +dependencies = [ + "bytes", + "const_format", + "dashmap", + "futures", + "gloo-net", + "http", + "js-sys", + "once_cell", + "send_wrapper", + "serde", + "serde_json", + "serde_qs", + "server_fn_macro_default", + "thiserror", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "xxhash-rust", +] + +[[package]] +name = "server_fn_macro" +version = "0.6.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faaaf648c6967aef78177c0610478abb5a3455811f401f3c62d10ae9bd3901a1" +dependencies = [ + "const_format", + "convert_case", + "proc-macro2", + "quote", + "syn", + "xxhash-rust", +] + +[[package]] +name = "server_fn_macro_default" +version = "0.6.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f2aa8119b558a17992e0ac1fd07f080099564f24532858811ce04f742542440" +dependencies = [ + "server_fn_macro", + "syn", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "sledgehammer_bindgen" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49e83e178d176459c92bc129cfd0958afac3ced925471b889b3a75546cfc4133" +dependencies = [ + "sledgehammer_bindgen_macro", + "wasm-bindgen", +] + +[[package]] +name = "sledgehammer_bindgen_macro" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb251b407f50028476a600541542b605bb864d35d9ee1de4f6cab45d88475e6d" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "sledgehammer_utils" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "debdd4b83524961983cea3c55383b3910fd2f24fd13a188f5b091d2d504a61ae" +dependencies = [ + "rustc-hash", +] + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "serde", + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "syn" +version = "2.0.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +dependencies = [ + "sharded-slab", + "thread_local", + "tracing-core", +] + +[[package]] +name = "tracing-wasm" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4575c663a174420fa2d78f4108ff68f65bf2fbb7dd89f33749b6e826b3626e07" +dependencies = [ + "tracing", + "tracing-subscriber", + "wasm-bindgen", +] + +[[package]] +name = "tungstenite" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e2ce1e47ed2994fd43b04c8f618008d4cabdd5ee34027cf14f9d918edd9c8" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand", + "sha1", + "thiserror", + "utf-8", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "warnings" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64f68998838dab65727c9b30465595c6f7c953313559371ca8bf31759b3680ad" +dependencies = [ + "pin-project", + "tracing", + "warnings-macro", +] + +[[package]] +name = "warnings-macro" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59195a1db0e95b920366d949ba5e0d3fc0e70b67c09be15ce5abb790106b0571" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" +dependencies = [ + "cfg-if", + "futures-util", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "xxhash-rust" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff05f8caa9038894637571ae6b9e29466c1f4f829d26c9b28f869a29cbe3445" diff --git a/apps/web/Cargo.toml b/apps/web/Cargo.toml new file mode 100644 index 0000000..c6d714e --- /dev/null +++ b/apps/web/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "ec-web" +version = "0.0.0" +edition = "2021" + +[dependencies] +dioxus = { version = "0.6", features = ["web"] } +js-sys = "0.3" +serde = { version = "1", features = ["derive"] } +wasm-bindgen = "0.2" +wasm-bindgen-futures = "0.4" +web-sys = { version = "0.3", features = ["Window", "Navigator", "Clipboard"] } + +[workspace] diff --git a/apps/web/README.md b/apps/web/README.md new file mode 100644 index 0000000..7aa20ec --- /dev/null +++ b/apps/web/README.md @@ -0,0 +1,18 @@ +# every.channel web site (static) + +This is a static web site built in Rust with Dioxus and compiled to WASM. + +## Dev + +From repo root: + +```bash +nix develop -c bash -lc 'cd apps/web && trunk serve --port 1421 --public-url /' +``` + +## Build + +```bash +nix develop -c bash -lc 'cd apps/web && trunk build --release --public-url /' +``` + diff --git a/apps/web/Trunk.toml b/apps/web/Trunk.toml new file mode 100644 index 0000000..3645c7e --- /dev/null +++ b/apps/web/Trunk.toml @@ -0,0 +1,5 @@ +[build] +target = "index.html" +dist = "dist" +public_url = "/" + diff --git a/apps/web/index.html b/apps/web/index.html new file mode 100644 index 0000000..9ccd9ae --- /dev/null +++ b/apps/web/index.html @@ -0,0 +1,17 @@ + + + + + + every.channel + + + + + +
+ + diff --git a/apps/web/src/main.rs b/apps/web/src/main.rs new file mode 100644 index 0000000..cab97d4 --- /dev/null +++ b/apps/web/src/main.rs @@ -0,0 +1,125 @@ +use dioxus::prelude::*; +use wasm_bindgen_futures::JsFuture; + +fn main() { + dioxus::launch(App); +} + +#[component] +fn App() -> Element { + let mut link = use_signal(|| "".to_string()); + let mut status = use_signal(|| "".to_string()); + + rsx! { + div { class: "page", + header { class: "top", + div { class: "brand", + div { class: "brand-title", "every.channel" } + div { class: "brand-subtitle", + "Watch and share free over-the-air TV. Local first, global when you want." + } + } + nav { class: "nav", + a { href: "#watch", "Watch" } + a { href: "#directory", "Directory" } + a { href: "#join", "Join" } + a { href: "#about", "Info" } + } + } + + div { class: "grid", + section { class: "card section", id: "watch", + div { class: "card-title", "Watch" } + div { class: "h1", "Watch a link" } + div { class: "p", + "Got a link from a friend? Paste it here to copy, then open the desktop app." + } + div { class: "row", + input { + class: "input", + placeholder: "every.channel://watch?...", + value: "{link.read()}", + oninput: move |evt| link.set(evt.value()), + } + button { + class: "btn primary", + onclick: move |_| { + let value = link.read().trim().to_string(); + if value.is_empty() { + status.set("Paste a link first".to_string()); + return; + } + let mut status = status.clone(); + spawn(async move { + match copy_to_clipboard(value).await { + Ok(_) => status.set("Copied! Open the app and paste under Watch a Link.".to_string()), + Err(err) => status.set(format!("Copy failed: {err}")), + } + }); + }, + "Copy" + } + } + if !status.read().is_empty() { + div { class: "kicker", + span { class: "dot" } + span { "{status.read()}" } + } + } + } + + section { class: "card section", id: "directory", + div { class: "card-title", "Directory" } + div { class: "h1", "Find channels from people you trust" } + div { class: "p", + "The directory is opt-in. You choose what to share and who to connect with." + } + div { class: "kicker", + span { class: "dot" } + span { "Enable Nearby or Public reach in the app to find others." } + } + } + + section { class: "card section", id: "join", + div { class: "card-title", "Join" } + div { class: "h1", "Run your own" } + div { class: "p", + "Anyone can watch, share, and relay. Works with HDHomeRun, Linux TV tuners, and live streams." + } + div { class: "kicker", + span { class: "dot" } + span { "Desktop app and CLI available now." } + } + } + + section { class: "card section", id: "about", + div { class: "card-title", "About" } + div { class: "h1", "A small promise" } + div { class: "p", + "TV signals are just waves in the air. This project makes it easier to pick them up and share them with others." + } + div { class: "kicker", + span { class: "dot" } + span { "Open source. No central server." } + } + } + } + + footer { class: "footer", + span { "AGPLv3" } + span { "every.channel" } + a { href: "https://every.channel", "every.channel" } + } + } + } +} + +async fn copy_to_clipboard(text: String) -> Result<(), String> { + let window = web_sys::window().ok_or_else(|| "window unavailable".to_string())?; + let clipboard = window.navigator().clipboard(); + let promise = clipboard.write_text(&text); + JsFuture::from(promise) + .await + .map_err(|err| format!("clipboard write rejected: {err:?}"))?; + Ok(()) +} diff --git a/apps/web/style.css b/apps/web/style.css new file mode 100644 index 0000000..919fae1 --- /dev/null +++ b/apps/web/style.css @@ -0,0 +1,285 @@ +@import url("https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500&display=swap"); + +:root { + color-scheme: light; + --bg: #f8f5f0; + --bg-muted: #f2ede5; + --card: rgba(255, 255, 255, 0.82); + --ink: #1a1814; + --ink-muted: #5c574d; + --border: rgba(26, 24, 20, 0.10); + --shadow: 0 16px 40px rgba(26, 24, 20, 0.10); + --accent: #18a89b; + --accent-ink: #0c6f68; + --warm: #d4915a; + --warm-muted: rgba(232, 160, 92, 0.12); + font-family: "Space Grotesk", "IBM Plex Sans", "Segoe UI", system-ui, sans-serif; + font-feature-settings: "ss01" 1; +} + +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + min-height: 100vh; + color: var(--ink); + background: linear-gradient(168deg, #fdfaf5 0%, #f8f5f0 35%, #f4f1ec 70%, #f0ece6 100%); +} + +/* Subtle "old TV" nod: soft phosphor glow and faint scanlines */ +body::before { + content: ""; + position: fixed; + inset: 0; + background-image: + radial-gradient(ellipse 80% 60% at 15% 10%, rgba(232, 160, 92, 0.08), transparent 50%), + radial-gradient(ellipse 60% 50% at 85% 15%, rgba(24, 168, 155, 0.06), transparent 45%), + radial-gradient(ellipse 70% 40% at 50% 90%, rgba(232, 160, 92, 0.05), transparent 50%), + repeating-linear-gradient( + 0deg, + transparent 0px, + transparent 2px, + rgba(26, 24, 20, 0.012) 2px, + rgba(26, 24, 20, 0.012) 4px + ); + pointer-events: none; + z-index: -1; +} + +#main { + min-height: 100vh; +} + +.page { + max-width: 1120px; + margin: 0 auto; + padding: 24px clamp(16px, 4vw, 40px) 40px; + display: flex; + flex-direction: column; + gap: 20px; + animation: fadeIn 0.5s ease-out; +} + +.top { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; +} + +.brand { + display: flex; + flex-direction: column; + gap: 4px; +} + +.brand-title { + font-size: clamp(24px, 2.6vw, 30px); + font-weight: 600; + letter-spacing: -0.025em; + color: var(--ink); +} + +.brand-subtitle { + font-size: 12px; + color: var(--ink-muted); + max-width: 44ch; + line-height: 1.4; +} + +.nav { + display: flex; + gap: 5px; + flex-wrap: wrap; + justify-content: flex-end; +} + +.nav a { + text-decoration: none; + color: var(--ink-muted); + font-size: 12px; + font-weight: 500; + padding: 6px 10px; + border-radius: 999px; + border: 1px solid var(--border); + background: rgba(255, 255, 255, 0.65); + transition: transform 120ms ease, box-shadow 120ms ease, background 120ms ease, color 120ms ease; +} + +.nav a:hover { + transform: translateY(-1px); + background: rgba(255, 255, 255, 0.9); + color: var(--ink); + box-shadow: 0 8px 20px rgba(26, 24, 20, 0.06); +} + +.grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 14px; +} + +@media (max-width: 720px) { + .grid { + grid-template-columns: 1fr; + } +} + +.card { + border-radius: 16px; + border: 1px solid var(--border); + background: var(--card); + box-shadow: var(--shadow); + padding: 18px; + backdrop-filter: blur(12px); +} + +.card-title { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.12em; + color: var(--ink-muted); + margin-bottom: 8px; +} + +.h1 { + font-size: clamp(16px, 1.8vw, 19px); + font-weight: 600; + letter-spacing: -0.015em; + margin-bottom: 8px; + line-height: 1.3; +} + +.p { + font-size: 12px; + line-height: 1.5; + color: var(--ink-muted); +} + +.row { + display: grid; + grid-template-columns: 1fr auto; + gap: 6px; + margin-top: 10px; +} + +.input { + border: 1px solid var(--border); + border-radius: 10px; + padding: 8px 10px; + font-size: 11px; + background: rgba(242, 237, 229, 0.6); + color: var(--ink); +} + +.input:focus { + outline: none; + border-color: rgba(24, 168, 155, 0.4); + box-shadow: 0 0 0 3px rgba(24, 168, 155, 0.08); +} + +.btn { + border: 1px solid var(--border); + border-radius: 10px; + padding: 8px 12px; + font-size: 11px; + font-weight: 500; + cursor: pointer; + background: rgba(255, 255, 255, 0.85); + color: var(--ink); + transition: transform 120ms ease, box-shadow 120ms ease, background 120ms ease; +} + +.btn:hover { + transform: translateY(-1px); + box-shadow: 0 6px 16px rgba(26, 24, 20, 0.06); +} + +.btn.primary { + background: var(--accent-ink); + color: #fff; + border-color: rgba(12, 111, 104, 0.3); +} + +.btn.primary:hover { + box-shadow: 0 6px 16px rgba(12, 111, 104, 0.2); +} + +.kicker { + margin-top: 8px; + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 10px; + border-radius: 999px; + border: 1px solid var(--border); + background: var(--warm-muted); + color: var(--ink-muted); + font-size: 11px; +} + +.dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--warm); + box-shadow: 0 0 0 3px rgba(232, 160, 92, 0.2); +} + +.section { + scroll-margin-top: 12px; +} + +code { + font-family: "IBM Plex Mono", ui-monospace, monospace; + font-size: 0.9em; + background: rgba(26, 24, 20, 0.05); + padding: 2px 5px; + border-radius: 4px; +} + +.footer { + margin-top: 6px; + padding-top: 12px; + border-top: 1px solid var(--border); + font-size: 10px; + color: var(--ink-muted); + display: flex; + flex-wrap: wrap; + gap: 10px; + justify-content: space-between; +} + +.footer a { + color: var(--ink-muted); + text-decoration: none; +} + +.footer a:hover { + color: var(--ink); +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(4px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@media (max-width: 720px) { + .top { + flex-direction: column; + align-items: stretch; + } + .nav { + justify-content: flex-start; + } +} diff --git a/crates/ec-chopper/Cargo.toml b/crates/ec-chopper/Cargo.toml new file mode 100644 index 0000000..fba74b7 --- /dev/null +++ b/crates/ec-chopper/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "ec-chopper" +version = "0.0.0" +edition.workspace = true +license.workspace = true + +[dependencies] +ac-ffmpeg = "0.19.0" +anyhow.workspace = true +blake3.workspace = true +ec-core = { path = "../ec-core" } +ec-ts = { path = "../ec-ts" } +serde.workspace = true diff --git a/crates/ec-chopper/src/lib.rs b/crates/ec-chopper/src/lib.rs new file mode 100644 index 0000000..790bfbe --- /dev/null +++ b/crates/ec-chopper/src/lib.rs @@ -0,0 +1,899 @@ +//! Deterministic chunking and transcode scaffolding. + +use ac_ffmpeg::format::{ + demuxer::Demuxer, + io::IO, + muxer::{Muxer, OutputFormat}, +}; +use anyhow::{anyhow, Context, Result}; +use ec_core::{ + merkle_root_from_hashes, DeterminismProfile, ManifestBody, StreamId, StreamMetadata, +}; +use ec_ts::{SectionAssembler, TimeSyncEngine, TimeSyncUpdate, TsReader}; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::time::Duration; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StreamProbe { + pub index: usize, + pub kind: String, + pub decoder: Option, + pub width: Option, + pub height: Option, + pub sample_rate: Option, + pub channels: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum ChunkFormat { + Fmp4, + MpegTs, + Matroska, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChunkerConfig { + pub output_dir: PathBuf, + pub segment_duration_ms: u64, + pub segment_template: String, + pub format: ChunkFormat, + pub profile: DeterminismProfile, +} + +impl ChunkerConfig { + pub fn default_segment_template() -> String { + "segment_%06d.m4s".to_string() + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChunkSegment { + pub index: usize, + pub path: PathBuf, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChunkManifest { + pub output_dir: PathBuf, + pub segments: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TsChunk { + pub index: u64, + pub path: PathBuf, + pub timing: ChunkTiming, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HashedTsChunk { + pub index: u64, + pub path: PathBuf, + pub timing: ChunkTiming, + pub hash: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HashedTsChunkManifest { + pub output_dir: PathBuf, + pub chunks: Vec, + pub merkle_root: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChunkTiming { + pub chunk_index: u64, + pub chunk_start_27mhz: Option, + pub chunk_duration_27mhz: u64, + pub utc_start_unix: Option, + pub sync_status: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TsChunkManifest { + pub output_dir: PathBuf, + pub chunks: Vec, +} + +#[derive(Debug, Clone)] +pub enum ChunkerInput { + Url(String), + File(PathBuf), +} + +#[derive(Debug)] +pub struct SegmenterProcess { + pub child: Child, + pub output_dir: PathBuf, +} + +#[derive(Debug, Clone)] +pub struct FfmpegCliSegmenter { + pub ffmpeg_bin: PathBuf, +} + +impl Default for FfmpegCliSegmenter { + fn default() -> Self { + Self { + ffmpeg_bin: PathBuf::from("ffmpeg"), + } + } +} + +impl FfmpegCliSegmenter { + pub fn spawn(&self, input: ChunkerInput, config: &ChunkerConfig) -> Result { + fs::create_dir_all(&config.output_dir) + .with_context(|| format!("failed to create {}", config.output_dir.display()))?; + + let input_arg = match input { + ChunkerInput::Url(url) => url, + ChunkerInput::File(path) => path + .to_str() + .ok_or_else(|| anyhow!("invalid input path"))? + .to_string(), + }; + + let segment_time = format!("{:.3}", config.segment_duration_ms as f64 / 1000.0); + let output_template = config.output_dir.join(&config.segment_template); + let output_template = output_template + .to_str() + .ok_or_else(|| anyhow!("invalid output template path"))? + .to_string(); + + let mut cmd = Command::new(&self.ffmpeg_bin); + cmd.arg("-hide_banner") + .arg("-loglevel") + .arg("error") + .arg("-nostdin") + .arg("-y") + .arg("-i") + .arg(&input_arg); + + for arg in ffmpeg_profile_args(&config.profile) { + cmd.arg(arg); + } + + cmd.arg("-f") + .arg("segment") + .arg("-segment_time") + .arg(segment_time) + .arg("-reset_timestamps") + .arg("1") + .arg("-segment_format") + .arg(segment_format_arg(&config.format)) + .arg(&output_template) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::inherit()); + + let child = cmd + .spawn() + .with_context(|| "failed to spawn ffmpeg".to_string())?; + + Ok(SegmenterProcess { + child, + output_dir: config.output_dir.clone(), + }) + } +} + +pub fn collect_segments(output_dir: &Path) -> Result { + let mut entries = fs::read_dir(output_dir)? + .filter_map(Result::ok) + .filter(|entry| entry.file_type().map(|t| t.is_file()).unwrap_or(false)) + .map(|entry| entry.path()) + .collect::>(); + + entries.sort(); + + let segments = entries + .into_iter() + .enumerate() + .map(|(index, path)| ChunkSegment { index, path }) + .collect(); + + Ok(ChunkManifest { + output_dir: output_dir.to_path_buf(), + segments, + }) +} + +pub fn probe_read_stream(stream: T) -> Result> { + let io = IO::from_read_stream(stream); + let demuxer = Demuxer::builder() + .build(io) + .map_err(|err| anyhow!(err.to_string()))?; + let demuxer = demuxer + .find_stream_info(Some(Duration::from_secs(2))) + .map_err(|(_, err)| anyhow!(err.to_string()))?; + + let mut probes = Vec::new(); + for (index, stream) in demuxer.streams().iter().enumerate() { + let params = stream.codec_parameters(); + let mut probe = StreamProbe { + index, + kind: if params.is_video_codec() { + "video".to_string() + } else if params.is_audio_codec() { + "audio".to_string() + } else if params.is_subtitle_codec() { + "subtitle".to_string() + } else { + "data".to_string() + }, + decoder: params.decoder_name().map(|name| name.to_string()), + width: None, + height: None, + sample_rate: None, + channels: None, + }; + + if let Some(video) = params.as_video_codec_parameters() { + probe.width = Some(video.width()); + probe.height = Some(video.height()); + } + + if let Some(audio) = params.as_audio_codec_parameters() { + probe.sample_rate = Some(audio.sample_rate()); + probe.channels = Some(audio.channel_layout().channels()); + } + + probes.push(probe); + } + + Ok(probes) +} + +pub fn analyze_ts_time( + stream: T, + chunk_duration_ms: u64, + max_events: usize, +) -> Result> { + let mut reader = TsReader::new(stream); + let mut assembler = SectionAssembler::default(); + let mut engine = TimeSyncEngine::new(chunk_duration_ms); + let mut events = Vec::new(); + + while let Some(packet) = reader.read_packet()? { + for update in engine.ingest_packet(&packet, &mut assembler) { + events.push(update); + if events.len() >= max_events { + return Ok(events); + } + } + } + + Ok(events) +} + +pub fn chunk_ts_stream( + stream: T, + output_dir: &Path, + chunk_duration_ms: u64, + max_chunks: Option, +) -> Result { + let mut chunks = Vec::new(); + chunk_ts_stream_live(stream, output_dir, chunk_duration_ms, max_chunks, |chunk| { + chunks.push(chunk); + Ok(()) + })?; + Ok(TsChunkManifest { + output_dir: output_dir.to_path_buf(), + chunks, + }) +} + +pub fn chunk_ts_stream_live Result<()>>( + stream: T, + output_dir: &Path, + chunk_duration_ms: u64, + max_chunks: Option, + mut on_chunk: F, +) -> Result<()> { + fs::create_dir_all(output_dir) + .with_context(|| format!("failed to create {}", output_dir.display()))?; + + let mut reader = TsReader::new(stream); + let mut assembler = SectionAssembler::default(); + let mut engine = TimeSyncEngine::new(chunk_duration_ms); + + let mut current_index: Option = None; + let mut current_file: Option = None; + let mut current_timing: Option = None; + let mut emitted = 0usize; + + let mut close_and_emit = + |index: u64, timing: ChunkTiming, file: std::fs::File| -> Result { + drop(file); + let path = chunk_path(output_dir, index); + on_chunk(TsChunk { + index, + path, + timing, + })?; + emitted += 1; + Ok(max_chunks.map(|limit| emitted >= limit).unwrap_or(false)) + }; + + while let Some(packet) = reader.read_packet()? { + let updates = engine.ingest_packet(&packet, &mut assembler); + for update in updates { + if update.discontinuity { + if let (Some(index), Some(timing), Some(file)) = ( + current_index.take(), + current_timing.take(), + current_file.take(), + ) { + if close_and_emit(index, timing, file)? { + return Ok(()); + } + } + } + + if let Some(index) = update.chunk_index { + if current_index != Some(index) { + if let (Some(prev_index), Some(timing), Some(file)) = ( + current_index.take(), + current_timing.take(), + current_file.take(), + ) { + if close_and_emit(prev_index, timing, file)? { + return Ok(()); + } + } + + let path = chunk_path(output_dir, index); + let file = std::fs::File::create(&path) + .with_context(|| format!("failed to create {}", path.display()))?; + current_file = Some(file); + current_index = Some(index); + current_timing = Some(ChunkTiming { + chunk_index: index, + chunk_start_27mhz: update.chunk_start_27mhz, + chunk_duration_27mhz: chunk_duration_ms * 27_000, + utc_start_unix: update.utc_start_unix, + sync_status: if update.synced { + "synced".to_string() + } else { + "unsynced".to_string() + }, + }); + } + } + } + + if let Some(file) = current_file.as_mut() { + file.write_all(packet.as_bytes())?; + } + } + + if let (Some(index), Some(timing), Some(file)) = ( + current_index.take(), + current_timing.take(), + current_file.take(), + ) { + let _ = close_and_emit(index, timing, file); + } + + Ok(()) +} + +fn chunk_path(output_dir: &Path, index: u64) -> PathBuf { + output_dir.join(format!("chunk_{index:010}.ts")) +} + +pub fn hash_file_blake3(path: &Path) -> Result { + let mut file = + fs::File::open(path).with_context(|| format!("failed to open {}", path.display()))?; + let mut hasher = blake3::Hasher::new(); + let mut buffer = [0u8; 8192]; + loop { + let read = file.read(&mut buffer)?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + } + Ok(hasher.finalize().to_hex().to_string()) +} + +pub fn chunk_stream_ffmpeg( + stream: T, + output_dir: &Path, + chunk_duration_ms: u64, + max_chunks: Option, +) -> Result { + fs::create_dir_all(output_dir) + .with_context(|| format!("failed to create {}", output_dir.display()))?; + + let io = IO::from_read_stream(stream); + let demuxer = Demuxer::builder() + .build(io) + .map_err(|err| anyhow!(err.to_string()))?; + let demuxer = demuxer + .find_stream_info(Some(Duration::from_secs(2))) + .map_err(|(_, err)| anyhow!(err.to_string()))?; + + let stream_info = demuxer + .streams() + .iter() + .map(|stream| (stream.codec_parameters(), stream.time_base())) + .collect::>(); + + let mut demuxer = demuxer.into_demuxer(); + let chunk_duration_micros = chunk_duration_ms as i64 * 1000; + + let mut chunks = Vec::new(); + let mut current_index: Option = None; + let mut current_muxer: Option> = None; + let mut current_timing: Option = None; + + loop { + let Some(packet) = demuxer.take().map_err(|err| anyhow!(err.to_string()))? else { + break; + }; + + let ts = packet + .pts() + .as_micros() + .or_else(|| packet.dts().as_micros()); + + let chunk_index = ts + .and_then(|micros| { + if micros < 0 { + None + } else { + Some((micros / chunk_duration_micros) as u64) + } + }) + .or(current_index); + + if let Some(index) = chunk_index { + if current_index != Some(index) { + if let Some(mut muxer) = current_muxer.take() { + muxer.flush().map_err(|err| anyhow!(err.to_string()))?; + let _ = muxer.close(); + } + if let (Some(prev_index), Some(timing)) = + (current_index.take(), current_timing.take()) + { + chunks.push(TsChunk { + index: prev_index, + path: chunk_path(output_dir, prev_index), + timing, + }); + } + + let path = chunk_path(output_dir, index); + let file = std::fs::File::create(&path) + .with_context(|| format!("failed to create {}", path.display()))?; + let io = IO::from_write_stream(file); + let mut builder = Muxer::builder(); + for (params, _) in &stream_info { + builder + .add_stream(params) + .map_err(|err| anyhow!(err.to_string()))?; + } + for (stream, (_, tb)) in builder.streams_mut().iter_mut().zip(stream_info.iter()) { + stream.set_time_base(*tb); + } + let format = OutputFormat::find_by_name("mpegts") + .ok_or_else(|| anyhow!("mpegts format not found"))?; + let muxer = builder + .interleaved(true) + .build(io, format) + .map_err(|err| anyhow!(err.to_string()))?; + + current_muxer = Some(muxer); + current_index = Some(index); + current_timing = Some(ChunkTiming { + chunk_index: index, + chunk_start_27mhz: ts.map(|micros| (micros as u64) * 27), + chunk_duration_27mhz: chunk_duration_ms * 27_000, + utc_start_unix: None, + sync_status: "pts".to_string(), + }); + + if let Some(limit) = max_chunks { + if chunks.len() >= limit { + break; + } + } + } + } + + if let Some(muxer) = current_muxer.as_mut() { + let packet = packet.with_time_base(ac_ffmpeg::time::TimeBase::MICROSECONDS); + muxer.push(packet).map_err(|err| anyhow!(err.to_string()))?; + } + } + + if let Some(mut muxer) = current_muxer.take() { + let _ = muxer.flush(); + let _ = muxer.close(); + } + if let (Some(index), Some(timing)) = (current_index.take(), current_timing.take()) { + chunks.push(TsChunk { + index, + path: chunk_path(output_dir, index), + timing, + }); + } + + Ok(TsChunkManifest { + output_dir: output_dir.to_path_buf(), + chunks, + }) +} + +pub fn hash_ts_chunks(manifest: &TsChunkManifest) -> Result { + let mut ordered = manifest.chunks.clone(); + ordered.sort_by_key(|chunk| chunk.index); + + let mut hashes = Vec::with_capacity(ordered.len()); + let mut chunks = Vec::with_capacity(ordered.len()); + for chunk in ordered { + let hash = hash_file_blake3(&chunk.path)?; + hashes.push(hash.clone()); + chunks.push(HashedTsChunk { + index: chunk.index, + path: chunk.path.clone(), + timing: chunk.timing.clone(), + hash, + }); + } + + let merkle_root = merkle_root_from_hashes(&hashes)?; + Ok(HashedTsChunkManifest { + output_dir: manifest.output_dir.clone(), + chunks, + merkle_root, + }) +} + +pub fn build_manifest_body_for_chunks( + stream_id: StreamId, + epoch_id: impl Into, + chunk_duration_ms: u64, + chunk_start_index: u64, + encoder_profile_id: impl Into, + created_unix_ms: u64, + metadata: Vec, + chunk_hashes: &[String], +) -> Result { + let merkle_root = merkle_root_from_hashes(chunk_hashes)?; + Ok(ManifestBody { + stream_id, + epoch_id: epoch_id.into(), + chunk_duration_ms, + total_chunks: chunk_hashes.len() as u64, + chunk_start_index, + encoder_profile_id: encoder_profile_id.into(), + merkle_root, + created_unix_ms, + metadata, + chunk_hashes: chunk_hashes.to_vec(), + variants: None, + }) +} + +pub fn manifest_for_ts_chunks( + stream_id: StreamId, + epoch_id: impl Into, + chunk_duration_ms: u64, + chunk_start_index: u64, + encoder_profile_id: impl Into, + created_unix_ms: u64, + metadata: Vec, + manifest: &TsChunkManifest, +) -> Result<(ManifestBody, HashedTsChunkManifest)> { + let hashed = hash_ts_chunks(manifest)?; + let chunk_hashes = hashed + .chunks + .iter() + .map(|chunk| chunk.hash.clone()) + .collect::>(); + let body = build_manifest_body_for_chunks( + stream_id, + epoch_id, + chunk_duration_ms, + chunk_start_index, + encoder_profile_id, + created_unix_ms, + metadata, + &chunk_hashes, + )?; + Ok((body, hashed)) +} + +pub fn chunk_stream_ffmpeg_live Result<()>>( + stream: T, + output_dir: &Path, + chunk_duration_ms: u64, + max_chunks: Option, + mut on_chunk: F, +) -> Result<()> { + fs::create_dir_all(output_dir) + .with_context(|| format!("failed to create {}", output_dir.display()))?; + + let io = IO::from_read_stream(stream); + let demuxer = Demuxer::builder() + .build(io) + .map_err(|err| anyhow!(err.to_string()))?; + let demuxer = demuxer + .find_stream_info(Some(Duration::from_secs(2))) + .map_err(|(_, err)| anyhow!(err.to_string()))?; + + let stream_info = demuxer + .streams() + .iter() + .map(|stream| (stream.codec_parameters(), stream.time_base())) + .collect::>(); + + let mut demuxer = demuxer.into_demuxer(); + let chunk_duration_micros = chunk_duration_ms as i64 * 1000; + + let mut current_index: Option = None; + let mut current_muxer: Option> = None; + let mut current_timing: Option = None; + let mut emitted = 0usize; + + loop { + let Some(packet) = demuxer.take().map_err(|err| anyhow!(err.to_string()))? else { + break; + }; + + let ts = packet + .pts() + .as_micros() + .or_else(|| packet.dts().as_micros()); + + let chunk_index = ts + .and_then(|micros| { + if micros < 0 { + None + } else { + Some((micros / chunk_duration_micros) as u64) + } + }) + .or(current_index); + + if let Some(index) = chunk_index { + if current_index != Some(index) { + if let Some(mut muxer) = current_muxer.take() { + muxer.flush().map_err(|err| anyhow!(err.to_string()))?; + let _ = muxer.close(); + } + if let (Some(prev_index), Some(timing)) = + (current_index.take(), current_timing.take()) + { + let chunk = TsChunk { + index: prev_index, + path: chunk_path(output_dir, prev_index), + timing, + }; + on_chunk(chunk)?; + emitted += 1; + if let Some(limit) = max_chunks { + if emitted >= limit { + return Ok(()); + } + } + } + + let path = chunk_path(output_dir, index); + let file = std::fs::File::create(&path) + .with_context(|| format!("failed to create {}", path.display()))?; + let io = IO::from_write_stream(file); + let mut builder = Muxer::builder(); + for (params, _) in &stream_info { + builder + .add_stream(params) + .map_err(|err| anyhow!(err.to_string()))?; + } + for (stream, (_, tb)) in builder.streams_mut().iter_mut().zip(stream_info.iter()) { + stream.set_time_base(*tb); + } + let format = OutputFormat::find_by_name("mpegts") + .ok_or_else(|| anyhow!("mpegts format not found"))?; + let muxer = builder + .interleaved(true) + .build(io, format) + .map_err(|err| anyhow!(err.to_string()))?; + + current_muxer = Some(muxer); + current_index = Some(index); + current_timing = Some(ChunkTiming { + chunk_index: index, + chunk_start_27mhz: ts.map(|micros| (micros as u64) * 27), + chunk_duration_27mhz: chunk_duration_ms * 27_000, + utc_start_unix: None, + sync_status: "pts".to_string(), + }); + } + } + + if let Some(muxer) = current_muxer.as_mut() { + let packet = packet.with_time_base(ac_ffmpeg::time::TimeBase::MICROSECONDS); + muxer.push(packet).map_err(|err| anyhow!(err.to_string()))?; + } + } + + if let Some(mut muxer) = current_muxer.take() { + let _ = muxer.flush(); + let _ = muxer.close(); + } + if let (Some(index), Some(timing)) = (current_index.take(), current_timing.take()) { + let chunk = TsChunk { + index, + path: chunk_path(output_dir, index), + timing, + }; + on_chunk(chunk)?; + } + + Ok(()) +} + +fn segment_format_arg(format: &ChunkFormat) -> &'static str { + match format { + ChunkFormat::Fmp4 => "mp4", + ChunkFormat::MpegTs => "mpegts", + ChunkFormat::Matroska => "matroska", + } +} + +pub fn ffmpeg_profile_args(profile: &DeterminismProfile) -> Vec { + let mut args = Vec::new(); + if !profile.encoder.is_empty() { + args.push("-c:v".to_string()); + args.push(profile.encoder.clone()); + } + for arg in &profile.encoder_args { + args.push(arg.clone()); + } + args +} + +pub fn deterministic_h264_profile() -> DeterminismProfile { + DeterminismProfile { + name: "deterministic-h264-aac".to_string(), + description: "Single-threaded H.264 + AAC with fixed GOP and bitexact flags".to_string(), + encoder: "libx264".to_string(), + encoder_args: vec![ + "-c:a".to_string(), + "aac".to_string(), + "-b:a".to_string(), + "128k".to_string(), + "-ac".to_string(), + "2".to_string(), + "-ar".to_string(), + "48000".to_string(), + "-pix_fmt".to_string(), + "yuv420p".to_string(), + "-g".to_string(), + "60".to_string(), + "-keyint_min".to_string(), + "60".to_string(), + "-sc_threshold".to_string(), + "0".to_string(), + "-bf".to_string(), + "0".to_string(), + "-threads".to_string(), + "1".to_string(), + "-fflags".to_string(), + "+bitexact".to_string(), + "-flags:v".to_string(), + "+bitexact".to_string(), + "-flags:a".to_string(), + "+bitexact".to_string(), + ], + chunk_duration_ms: 2000, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Cursor; + + fn ts_packet_with_pcr(pid: u16, cc: u8, pcr_27mhz: u64) -> [u8; ec_ts::TS_PACKET_SIZE] { + // Match ec_ts parser expectations. + let base = pcr_27mhz / 300; + let ext = pcr_27mhz % 300; + let mut pcr = [0u8; 6]; + pcr[0] = ((base >> 25) & 0xFF) as u8; + pcr[1] = ((base >> 17) & 0xFF) as u8; + pcr[2] = ((base >> 9) & 0xFF) as u8; + pcr[3] = ((base >> 1) & 0xFF) as u8; + pcr[4] = (((base & 0x1) << 7) as u8) | 0x7E | (((ext >> 8) & 0x1) as u8); + pcr[5] = (ext & 0xFF) as u8; + + let mut data = [0u8; ec_ts::TS_PACKET_SIZE]; + data[0] = 0x47; + data[1] = ((pid >> 8) as u8) & 0x1F; + data[2] = (pid & 0xFF) as u8; + data[3] = (2 << 4) | (cc & 0x0F); // adaptation only + data[4] = 7; + data[5] = 0x10; + data[6..12].copy_from_slice(&pcr); + data + } + + #[test] + fn segment_format_mapping_is_correct() { + assert_eq!(segment_format_arg(&ChunkFormat::Fmp4), "mp4"); + assert_eq!(segment_format_arg(&ChunkFormat::MpegTs), "mpegts"); + assert_eq!(segment_format_arg(&ChunkFormat::Matroska), "matroska"); + } + + #[test] + fn deterministic_profile_args_are_single_threaded_and_bitexact() { + let profile = deterministic_h264_profile(); + let args = ffmpeg_profile_args(&profile); + assert!(args.iter().any(|a| a == "-threads")); + assert!(args.iter().any(|a| a == "1")); + assert!(args.iter().any(|a| a == "+bitexact")); + assert!(args.iter().any(|a| a == "libx264")); + } + + #[test] + fn hash_file_blake3_matches_direct_hash() { + let dir = std::env::temp_dir().join(format!("ec-chopper-hash-{}", std::process::id())); + let _ = fs::create_dir_all(&dir); + let path = dir.join("x.bin"); + fs::write(&path, b"hello").unwrap(); + let h = hash_file_blake3(&path).unwrap(); + assert_eq!(h, blake3::hash(b"hello").to_hex().to_string()); + let _ = fs::remove_file(&path); + } + + #[test] + fn chunk_ts_stream_emits_expected_chunk_indices() { + let chunk_ms = 1000u64; + let dir = std::env::temp_dir().join(format!("ec-chopper-chunks-{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + + let mut bytes = Vec::new(); + bytes.extend_from_slice(&ts_packet_with_pcr(0x0100, 0, 0)); + bytes.extend_from_slice(&ts_packet_with_pcr(0x0100, 1, 27_000_000)); + bytes.extend_from_slice(&ts_packet_with_pcr(0x0100, 2, 54_000_000)); + + let manifest = chunk_ts_stream(Cursor::new(bytes), &dir, chunk_ms, None).unwrap(); + let indices = manifest.chunks.iter().map(|c| c.index).collect::>(); + assert_eq!(indices, vec![0, 1, 2]); + for chunk in &manifest.chunks { + let data = fs::read(&chunk.path).unwrap(); + assert_eq!(data.len() % ec_ts::TS_PACKET_SIZE, 0); + } + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn hashed_manifest_merkle_root_matches_core() { + let dir = std::env::temp_dir().join(format!("ec-chopper-merkle-{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + + let mut bytes = Vec::new(); + bytes.extend_from_slice(&ts_packet_with_pcr(0x0100, 0, 0)); + bytes.extend_from_slice(&ts_packet_with_pcr(0x0100, 1, 27_000_000)); + let manifest = chunk_ts_stream(Cursor::new(bytes), &dir, 1000, None).unwrap(); + let hashed = hash_ts_chunks(&manifest).unwrap(); + let hashes = hashed + .chunks + .iter() + .map(|c| c.hash.clone()) + .collect::>(); + let expected = ec_core::merkle_root_from_hashes(&hashes).unwrap(); + assert_eq!(hashed.merkle_root, expected); + + let _ = fs::remove_dir_all(&dir); + } +} diff --git a/crates/ec-cli/Cargo.toml b/crates/ec-cli/Cargo.toml new file mode 100644 index 0000000..669048b --- /dev/null +++ b/crates/ec-cli/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "ec-cli" +version = "0.0.0" +edition.workspace = true +license.workspace = true + +[dependencies] +anyhow.workspace = true +blake3.workspace = true +clap.workspace = true +ec-chopper = { path = "../ec-chopper" } +ec-core = { path = "../ec-core" } +ec-hdhomerun = { path = "../ec-hdhomerun" } +ec-linux-iptv = { path = "../ec-linux-iptv" } +serde_json.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true diff --git a/crates/ec-cli/src/main.rs b/crates/ec-cli/src/main.rs new file mode 100644 index 0000000..9622919 --- /dev/null +++ b/crates/ec-cli/src/main.rs @@ -0,0 +1,379 @@ +use anyhow::{anyhow, Context, Result}; +use blake3; +use clap::{Parser, Subcommand}; +use std::fs::{self, File}; +use std::io::{Read, Write}; +use std::path::PathBuf; + +#[derive(Parser, Debug)] +#[command(name = "every.channel")] +#[command(about = "CLI for the every.channel mesh", long_about = None)] +struct Cli { + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand, Debug)] +enum Commands { + /// Discover HDHomeRun devices on the network. + Discover, + /// Fetch channel lineup for a device. + Lineup { + /// Hostname or IP (e.g. 192.168.1.10 or hdhomerun.local). + #[arg(long)] + host: Option, + /// Device ID (used as .local). + #[arg(long)] + device_id: Option, + }, + /// Parse lineup JSON from a file on disk. + LineupFile { path: String }, + /// Open an HDHomeRun stream and dump MPEG-TS to a file. + StreamDump { + /// Hostname or IP (e.g. 192.168.1.10). + #[arg(long)] + host: Option, + /// Device ID (used as .local). + #[arg(long)] + device_id: Option, + /// Guide number (e.g. 8.1). + #[arg(long)] + channel: Option, + /// Guide name (e.g. KQED). + #[arg(long)] + name: Option, + /// Optional duration in seconds (if supported by the tuner URL). + #[arg(long)] + duration: Option, + /// Output path for the transport stream. + #[arg(long, default_value = "stream.ts")] + output: PathBuf, + }, + /// Chunk an input stream using ffmpeg. + Chunk { + /// Input URL or file path. + input: String, + /// Output directory for segments. + output_dir: PathBuf, + }, + /// Probe a media file using ac-ffmpeg. + Probe { + /// Input file path. + input: String, + }, + /// Analyze TS timing and chunk boundaries. + TsSync { + /// Input TS file. + input: String, + /// Chunk duration in ms. + #[arg(long, default_value_t = 2000)] + chunk_ms: u64, + /// Maximum number of events to print. + #[arg(long, default_value_t = 50)] + max_events: usize, + }, + /// Re-encode the same input multiple times and compare segment hashes. + DeterminismTest { + /// Input file path (TS or other supported by ffmpeg). + input: String, + /// Output directory root (runs will be placed under run-*/). + output_dir: PathBuf, + /// Number of runs to compare. + #[arg(long, default_value_t = 2)] + runs: usize, + }, + /// Open a Linux DVB DVR device and dump MPEG-TS to a file. + LinuxDvbDump { + /// DVB adapter index. + #[arg(long, default_value_t = 0)] + adapter: u32, + /// DVR device index. + #[arg(long, default_value_t = 0)] + dvr: u32, + /// Optional tune command (repeat for each arg). + #[arg(long, allow_hyphen_values = true)] + tune_cmd: Vec, + /// Optional tune wait (ms). + #[arg(long)] + tune_wait_ms: Option, + /// Output path for the transport stream. + #[arg(long, default_value = "linux-dvb.ts")] + output: PathBuf, + }, +} + +fn main() -> Result<()> { + tracing_subscriber::fmt().init(); + let cli = Cli::parse(); + + match cli.command { + Commands::Discover => { + let devices = ec_hdhomerun::discover()?; + println!("{}", serde_json::to_string_pretty(&devices)?); + } + Commands::Lineup { host, device_id } => { + let device = resolve_device(host, device_id)?; + let lineup = ec_hdhomerun::fetch_lineup(&device)?; + println!("{}", serde_json::to_string_pretty(&lineup)?); + } + Commands::LineupFile { path } => { + let bytes = fs::read(&path)?; + let lineup = ec_hdhomerun::lineup_from_json_bytes(&bytes, None)?; + println!("{}", serde_json::to_string_pretty(&lineup)?); + } + Commands::StreamDump { + host, + device_id, + channel, + name, + duration, + output, + } => { + let device = resolve_device(host, device_id)?; + let lineup = ec_hdhomerun::fetch_lineup(&device)?; + let entry = if let Some(channel) = channel { + ec_hdhomerun::find_lineup_entry_by_number(&lineup, &channel) + .or_else(|| ec_hdhomerun::find_lineup_entry_by_name(&lineup, &channel)) + .ok_or_else(|| anyhow!("channel not found: {channel}"))? + } else if let Some(name) = name { + ec_hdhomerun::find_lineup_entry_by_name(&lineup, &name) + .ok_or_else(|| anyhow!("channel not found: {name}"))? + } else { + return Err(anyhow!("--channel or --name required")); + }; + + let mut stream = ec_hdhomerun::open_stream_entry(entry, duration)?; + let mut file = File::create(&output) + .with_context(|| format!("failed to create {}", output.display()))?; + + let mut buf = [0u8; 8192]; + loop { + let read = stream.read(&mut buf)?; + if read == 0 { + break; + } + file.write_all(&buf[..read])?; + } + } + Commands::Chunk { input, output_dir } => { + let profile = ec_chopper::deterministic_h264_profile(); + let config = ec_chopper::ChunkerConfig { + output_dir, + segment_duration_ms: profile.chunk_duration_ms, + segment_template: ec_chopper::ChunkerConfig::default_segment_template(), + format: ec_chopper::ChunkFormat::Fmp4, + profile, + }; + + let input = if input.starts_with("http://") || input.starts_with("https://") { + ec_chopper::ChunkerInput::Url(input) + } else { + ec_chopper::ChunkerInput::File(PathBuf::from(input)) + }; + + let segmenter = ec_chopper::FfmpegCliSegmenter::default(); + let mut process = segmenter.spawn(input, &config)?; + let status = process.child.wait()?; + if !status.success() { + return Err(anyhow!("ffmpeg exited with status {status}")); + } + + let manifest = ec_chopper::collect_segments(&process.output_dir)?; + println!("{}", serde_json::to_string_pretty(&manifest)?); + } + Commands::Probe { input } => { + let file = File::open(&input).with_context(|| format!("failed to open {}", input))?; + let probes = ec_chopper::probe_read_stream(file)?; + println!("{}", serde_json::to_string_pretty(&probes)?); + } + Commands::TsSync { + input, + chunk_ms, + max_events, + } => { + let file = File::open(&input).with_context(|| format!("failed to open {}", input))?; + let events = ec_chopper::analyze_ts_time(file, chunk_ms, max_events)?; + println!("{}", serde_json::to_string_pretty(&events)?); + } + Commands::DeterminismTest { + input, + output_dir, + runs, + } => { + if runs < 1 { + return Err(anyhow!("runs must be >= 1")); + } + + let profile = ec_chopper::deterministic_h264_profile(); + let format = ec_chopper::ChunkFormat::Fmp4; + let template = ec_chopper::ChunkerConfig::default_segment_template(); + + let mut baseline: Option> = None; + for run in 0..runs { + let run_dir = output_dir.join(format!("run-{}", run + 1)); + let _ = fs::remove_dir_all(&run_dir); + + let config = ec_chopper::ChunkerConfig { + output_dir: run_dir.clone(), + segment_duration_ms: profile.chunk_duration_ms, + segment_template: template.clone(), + format: format.clone(), + profile: profile.clone(), + }; + + let input_spec = if input.starts_with("http://") || input.starts_with("https://") { + ec_chopper::ChunkerInput::Url(input.clone()) + } else { + ec_chopper::ChunkerInput::File(PathBuf::from(&input)) + }; + + let segmenter = ec_chopper::FfmpegCliSegmenter::default(); + let mut process = segmenter.spawn(input_spec, &config)?; + let status = process.child.wait()?; + if !status.success() { + return Err(anyhow!("ffmpeg exited with status {status}")); + } + + let hashes = hash_segments(&process.output_dir)?; + match baseline.as_ref() { + None => { + baseline = Some(hashes); + println!( + "run {}: baseline ({}) segments", + run + 1, + baseline.as_ref().unwrap().len() + ); + } + Some(base) => { + let mismatches = compare_hashes(base, &hashes); + if mismatches > 0 { + return Err(anyhow!( + "determinism mismatch on run {} ({} mismatches)", + run + 1, + mismatches + )); + } + println!("run {}: matched baseline", run + 1); + } + } + } + } + Commands::LinuxDvbDump { + adapter, + dvr, + tune_cmd, + tune_wait_ms, + output, + } => { + let config = ec_linux_iptv::LinuxDvbConfig { + adapter, + frontend: 0, + dvr, + tune_command: if tune_cmd.is_empty() { + None + } else { + Some(tune_cmd) + }, + tune_timeout_ms: tune_wait_ms, + }; + + let mut stream = ec_linux_iptv::open_stream(&config)?; + let mut file = File::create(&output) + .with_context(|| format!("failed to create {}", output.display()))?; + + let mut buf = [0u8; 8192]; + loop { + let read = stream.read(&mut buf)?; + if read == 0 { + break; + } + file.write_all(&buf[..read])?; + } + } + } + + Ok(()) +} + +fn hash_segments(output_dir: &PathBuf) -> Result> { + let manifest = ec_chopper::collect_segments(output_dir)?; + let mut hashes = Vec::new(); + for segment in manifest.segments { + let bytes = fs::read(&segment.path) + .with_context(|| format!("failed to read {}", segment.path.display()))?; + let hash = blake3::hash(&bytes); + hashes.push(hash.to_hex().to_string()); + } + Ok(hashes) +} + +fn compare_hashes(base: &[String], candidate: &[String]) -> usize { + let mut mismatches = 0usize; + let max_len = base.len().max(candidate.len()); + for idx in 0..max_len { + let base_hash = base.get(idx); + let candidate_hash = candidate.get(idx); + if base_hash != candidate_hash { + mismatches += 1; + } + } + mismatches +} + +fn resolve_device( + host: Option, + device_id: Option, +) -> Result { + if let Some(host) = host { + ec_hdhomerun::discover_from_host(&host) + } else if let Some(device_id) = device_id { + let host = format!("{device_id}.local"); + ec_hdhomerun::discover_from_host(&host) + } else { + let mut devices = ec_hdhomerun::discover()?; + devices + .pop() + .ok_or_else(|| anyhow!("no HDHomeRun devices found")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use clap::Parser; + + #[test] + fn clap_parses_common_subcommands() { + let cli = Cli::try_parse_from(["every.channel", "discover"]).unwrap(); + matches!(cli.command, Commands::Discover); + + let cli = Cli::try_parse_from([ + "every.channel", + "ts-sync", + "input.ts", + "--chunk-ms", + "1000", + "--max-events", + "5", + ]) + .unwrap(); + matches!(cli.command, Commands::TsSync { .. }); + + let cli = Cli::try_parse_from([ + "every.channel", + "linux-dvb-dump", + "--adapter", + "0", + "--dvr", + "0", + "--tune-cmd", + "dvbv5-zap", + "--tune-cmd", + "-r", + "--tune-cmd", + "KQED", + ]) + .unwrap(); + matches!(cli.command, Commands::LinuxDvbDump { .. }); + } +} diff --git a/crates/ec-core/Cargo.toml b/crates/ec-core/Cargo.toml new file mode 100644 index 0000000..df0eef2 --- /dev/null +++ b/crates/ec-core/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "ec-core" +version = "0.0.0" +edition.workspace = true +license.workspace = true + +[dependencies] +serde.workspace = true +blake3.workspace = true +serde_json.workspace = true diff --git a/crates/ec-core/src/lib.rs b/crates/ec-core/src/lib.rs new file mode 100644 index 0000000..0b7c34b --- /dev/null +++ b/crates/ec-core/src/lib.rs @@ -0,0 +1,463 @@ +//! Core types shared across every.channel. + +use serde::{Deserialize, Serialize}; +use std::fmt; + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct ChannelId(pub String); + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct DeviceId(pub String); + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct StreamId(pub String); + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StreamDescriptor { + pub id: StreamId, + pub title: String, + pub number: Option, + pub source: String, + pub metadata: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StreamMetadata { + pub key: String, + pub value: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BroadcastId { + pub standard: String, + pub transport_stream_id: Option, + pub program_number: Option, + pub callsign: Option, + pub region: Option, + pub frequency: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SourceId { + pub kind: String, + pub device_id: Option, + pub channel: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StreamKey { + pub version: u16, + pub broadcast: Option, + pub source: Option, + pub profile: Option, + pub variant: Option, +} + +impl StreamKey { + pub fn to_stream_id(&self) -> StreamId { + let mut parts = vec![ + "ec".to_string(), + "stream".to_string(), + format!("v{}", self.version), + ]; + + if let Some(broadcast) = &self.broadcast { + parts.push("broadcast".to_string()); + parts.push(sanitize(&broadcast.standard)); + if let Some(tsid) = broadcast.transport_stream_id { + parts.push(format!("tsid-{tsid}")); + } + if let Some(program) = broadcast.program_number { + parts.push(format!("program-{program}")); + } + if let Some(callsign) = &broadcast.callsign { + parts.push(format!("callsign-{}", sanitize(callsign))); + } + if let Some(region) = &broadcast.region { + parts.push(format!("region-{}", sanitize(region))); + } + if let Some(freq) = &broadcast.frequency { + parts.push(format!("freq-{}", sanitize(freq))); + } + } else if let Some(source) = &self.source { + parts.push("source".to_string()); + parts.push(sanitize(&source.kind)); + if let Some(device) = &source.device_id { + parts.push(format!("device-{}", sanitize(device))); + } + if let Some(channel) = &source.channel { + parts.push(format!("channel-{}", sanitize(channel))); + } + } else { + parts.push("unknown".to_string()); + } + + if let Some(profile) = &self.profile { + parts.push(format!("profile-{}", sanitize(profile))); + } + if let Some(variant) = &self.variant { + parts.push(format!("variant-{}", sanitize(variant))); + } + + StreamId(parts.join("/")) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Channel { + pub id: ChannelId, + pub name: String, + pub number: Option, + pub program_id: Option, + pub metadata: Vec, +} + +fn sanitize(value: &str) -> String { + value + .chars() + .map(|c| match c { + 'a'..='z' | '0'..='9' | '-' | '_' => c, + 'A'..='Z' => c.to_ascii_lowercase(), + _ => '_', + }) + .collect() +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum ChannelMetadata { + Callsign(String), + Network(String), + Region(String), + Frequency(String), + Extra(String, String), +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PacketDigest { + pub algorithm: String, + pub hex: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DeterminismProfile { + pub name: String, + pub description: String, + pub encoder: String, + pub encoder_args: Vec, + pub chunk_duration_ms: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NodeDescriptor { + pub node_id: String, + pub human_name: String, + pub location_hint: Option, + pub capabilities: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StreamEncryptionInfo { + pub alg: String, + pub key_id: String, + pub nonce_scheme: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MoqStreamDescriptor { + pub endpoint: String, + pub broadcast_name: String, + pub track_name: String, + pub encryption: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StreamCatalogEntry { + pub stream: StreamDescriptor, + pub moq: Option, + pub manifest: Option, + pub updated_unix_ms: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StreamCatalog { + pub entries: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ManifestSummary { + pub manifest_id: String, + pub merkle_root: String, + pub epoch_id: String, + pub total_chunks: u64, + pub chunk_start_index: u64, + pub encoder_profile_id: String, + pub signed_by: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChunkId { + pub stream_id: StreamId, + pub epoch_id: String, + pub chunk_index: u64, + pub chunk_hash: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ManifestVariant { + pub variant_id: String, + pub stream_id: StreamId, + pub chunk_start_index: u64, + pub total_chunks: u64, + pub merkle_root: String, + pub chunk_hashes: Vec, + #[serde(default)] + pub metadata: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ManifestBody { + pub stream_id: StreamId, + pub epoch_id: String, + pub chunk_duration_ms: u64, + pub total_chunks: u64, + pub chunk_start_index: u64, + pub encoder_profile_id: String, + pub merkle_root: String, + pub created_unix_ms: u64, + pub metadata: Vec, + pub chunk_hashes: Vec, + #[serde(default)] + pub variants: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ManifestSignature { + pub signer_id: String, + pub alg: String, + pub signature: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Manifest { + pub body: ManifestBody, + pub manifest_id: String, + pub signatures: Vec, +} + +impl Manifest { + pub fn summary(&self) -> ManifestSummary { + ManifestSummary { + manifest_id: self.manifest_id.clone(), + merkle_root: self.body.merkle_root.clone(), + epoch_id: self.body.epoch_id.clone(), + total_chunks: self.body.total_chunks, + chunk_start_index: self.body.chunk_start_index, + encoder_profile_id: self.body.encoder_profile_id.clone(), + signed_by: self + .signatures + .iter() + .map(|sig| sig.signer_id.clone()) + .collect(), + } + } +} + +#[derive(Debug, Clone)] +pub enum ManifestError { + Empty, + InvalidHash(String), +} + +impl fmt::Display for ManifestError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ManifestError::Empty => write!(f, "no chunk hashes supplied"), + ManifestError::InvalidHash(value) => write!(f, "invalid chunk hash: {value}"), + } + } +} + +impl std::error::Error for ManifestError {} + +impl ManifestBody { + pub fn manifest_id(&self) -> Result { + let bytes = serde_json::to_vec(self)?; + Ok(blake3::hash(&bytes).to_hex().to_string()) + } +} + +pub fn merkle_root_from_hashes(hashes: &[String]) -> Result { + if hashes.is_empty() { + return Err(ManifestError::Empty); + } + let mut nodes: Vec = Vec::with_capacity(hashes.len()); + for hash in hashes { + let parsed = blake3::Hash::from_hex(hash.as_bytes()) + .map_err(|_| ManifestError::InvalidHash(hash.clone()))?; + nodes.push(parsed); + } + while nodes.len() > 1 { + if nodes.len() % 2 == 1 { + if let Some(last) = nodes.last().cloned() { + nodes.push(last); + } + } + let mut parents = Vec::with_capacity(nodes.len() / 2); + for pair in nodes.chunks(2) { + let left = pair[0].as_bytes(); + let right = pair[1].as_bytes(); + let mut merged = [0u8; 64]; + merged[..32].copy_from_slice(left); + merged[32..].copy_from_slice(right); + parents.push(blake3::hash(&merged)); + } + nodes = parents; + } + Ok(nodes[0].to_hex().to_string()) +} + +pub fn merkle_proof_for_index( + hashes: &[String], + index: usize, +) -> Result, ManifestError> { + if hashes.is_empty() { + return Err(ManifestError::Empty); + } + if index >= hashes.len() { + return Err(ManifestError::InvalidHash(format!( + "index {index} out of bounds" + ))); + } + + let mut nodes: Vec = Vec::with_capacity(hashes.len()); + for hash in hashes { + let parsed = blake3::Hash::from_hex(hash.as_bytes()) + .map_err(|_| ManifestError::InvalidHash(hash.clone()))?; + nodes.push(parsed); + } + + let mut proof = Vec::new(); + let mut pos = index; + while nodes.len() > 1 { + if nodes.len() % 2 == 1 { + if let Some(last) = nodes.last().cloned() { + nodes.push(last); + } + } + + let sibling_index = if pos % 2 == 0 { pos + 1 } else { pos - 1 }; + let sibling = nodes + .get(sibling_index) + .ok_or_else(|| ManifestError::InvalidHash("missing sibling".to_string()))?; + proof.push(sibling.to_hex().to_string()); + + let mut parents = Vec::with_capacity(nodes.len() / 2); + for pair in nodes.chunks(2) { + let left = pair[0].as_bytes(); + let right = pair[1].as_bytes(); + let mut merged = [0u8; 64]; + merged[..32].copy_from_slice(left); + merged[32..].copy_from_slice(right); + parents.push(blake3::hash(&merged)); + } + nodes = parents; + pos /= 2; + } + + Ok(proof) +} + +pub fn verify_merkle_proof( + leaf_hash: &str, + mut index: usize, + branch: &[String], + expected_root: &str, +) -> bool { + let Ok(mut acc) = blake3::Hash::from_hex(leaf_hash.as_bytes()) else { + return false; + }; + for sibling_hex in branch { + let Ok(sibling) = blake3::Hash::from_hex(sibling_hex.as_bytes()) else { + return false; + }; + let (left, right) = if index % 2 == 0 { + (acc, sibling) + } else { + (sibling, acc) + }; + let mut merged = [0u8; 64]; + merged[..32].copy_from_slice(left.as_bytes()); + merged[32..].copy_from_slice(right.as_bytes()); + acc = blake3::hash(&merged); + index /= 2; + } + acc.to_hex().to_string() == expected_root +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn manifest_id_changes_with_body() { + let body = ManifestBody { + stream_id: StreamId("s".to_string()), + epoch_id: "e".to_string(), + chunk_duration_ms: 2000, + total_chunks: 1, + chunk_start_index: 0, + encoder_profile_id: "p".to_string(), + merkle_root: "00".repeat(32), + created_unix_ms: 1, + metadata: Vec::new(), + chunk_hashes: vec!["11".repeat(32)], + variants: None, + }; + let id1 = body.manifest_id().unwrap(); + let mut body2 = body.clone(); + body2.created_unix_ms = 2; + let id2 = body2.manifest_id().unwrap(); + assert_ne!(id1, id2); + } + + #[test] + fn merkle_root_single_is_leaf() { + let leaf = blake3::hash(b"leaf").to_hex().to_string(); + let root = merkle_root_from_hashes(&[leaf.clone()]).unwrap(); + assert_eq!(root, leaf); + } + + #[test] + fn merkle_root_rejects_invalid_hash() { + let err = merkle_root_from_hashes(&["not-hex".to_string()]).unwrap_err(); + assert!(matches!(err, ManifestError::InvalidHash(_))); + } + + #[test] + fn merkle_proof_roundtrip_small_sets() { + for size in 1..=9usize { + let leaves = (0..size) + .map(|i| blake3::hash(&[i as u8]).to_hex().to_string()) + .collect::>(); + let root = merkle_root_from_hashes(&leaves).unwrap(); + for idx in 0..size { + let proof = merkle_proof_for_index(&leaves, idx).unwrap(); + assert!( + verify_merkle_proof(&leaves[idx], idx, &proof, &root), + "size {size} idx {idx} failed" + ); + } + } + } + + #[test] + fn merkle_proof_detects_tampering() { + let leaves = (0..4usize) + .map(|i| blake3::hash(&[i as u8]).to_hex().to_string()) + .collect::>(); + let root = merkle_root_from_hashes(&leaves).unwrap(); + let mut proof = merkle_proof_for_index(&leaves, 2).unwrap(); + proof[0] = blake3::hash(b"evil").to_hex().to_string(); + assert!(!verify_merkle_proof(&leaves[2], 2, &proof, &root)); + } +} diff --git a/crates/ec-crypto/Cargo.toml b/crates/ec-crypto/Cargo.toml new file mode 100644 index 0000000..7eff59a --- /dev/null +++ b/crates/ec-crypto/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "ec-crypto" +version = "0.0.0" +edition.workspace = true +license.workspace = true + +[dependencies] +blake3 = "1" +chacha20poly1305 = "0.10" +ed25519-dalek = { version = "2", features = ["pkcs8"] } +hex = "0.4" +ec-core = { path = "../ec-core" } diff --git a/crates/ec-crypto/src/lib.rs b/crates/ec-crypto/src/lib.rs new file mode 100644 index 0000000..f29cbeb --- /dev/null +++ b/crates/ec-crypto/src/lib.rs @@ -0,0 +1,227 @@ +//! Cryptographic helpers for every.channel. + +use chacha20poly1305::{aead::Aead, KeyInit, XChaCha20Poly1305, XNonce}; +use ec_core::ManifestSignature; +use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; +use std::env; +use std::fs; + +pub const MANIFEST_SIG_ALG: &str = "ed25519"; + +pub const ENCRYPTION_ALG: &str = "xchacha20poly1305"; + +/// Derive a stream encryption key from a stream id and optional network secret. +/// +/// This is deterministic: identical stream ids produce identical keys. +pub fn derive_stream_key(stream_id: &str, network_secret: Option<&[u8]>) -> [u8; 32] { + let mut input = Vec::new(); + if let Some(secret) = network_secret { + input.extend_from_slice(secret); + input.push(0); + } + input.extend_from_slice(stream_id.as_bytes()); + + blake3::derive_key("every.channel stream key v1", &input) +} + +/// Derive a deterministic nonce for a stream chunk. +pub fn derive_stream_nonce(stream_id: &str, chunk_index: u64) -> [u8; 24] { + let mut hasher = blake3::Hasher::new(); + hasher.update(b"every.channel stream nonce v1"); + hasher.update(stream_id.as_bytes()); + hasher.update(&chunk_index.to_be_bytes()); + let hash = hasher.finalize(); + let mut nonce = [0u8; 24]; + nonce.copy_from_slice(&hash.as_bytes()[..24]); + nonce +} + +#[derive(Debug, Clone)] +pub struct EncryptedPayload { + pub ciphertext: Vec, + pub nonce: [u8; 24], + pub alg: &'static str, +} + +pub fn encrypt_stream_data( + stream_id: &str, + chunk_index: u64, + plaintext: &[u8], + network_secret: Option<&[u8]>, +) -> EncryptedPayload { + let key_bytes = derive_stream_key(stream_id, network_secret); + let cipher = XChaCha20Poly1305::new_from_slice(&key_bytes).expect("key size"); + let nonce_bytes = derive_stream_nonce(stream_id, chunk_index); + let nonce = XNonce::from_slice(&nonce_bytes); + let ciphertext = cipher + .encrypt(nonce, plaintext) + .expect("encryption failure"); + + EncryptedPayload { + ciphertext, + nonce: nonce_bytes, + alg: ENCRYPTION_ALG, + } +} + +pub fn decrypt_stream_data( + stream_id: &str, + chunk_index: u64, + ciphertext: &[u8], + network_secret: Option<&[u8]>, +) -> Option> { + let key_bytes = derive_stream_key(stream_id, network_secret); + let cipher = XChaCha20Poly1305::new_from_slice(&key_bytes).expect("key size"); + let nonce_bytes = derive_stream_nonce(stream_id, chunk_index); + let nonce = XNonce::from_slice(&nonce_bytes); + cipher.decrypt(nonce, ciphertext).ok() +} + +#[derive(Debug, Clone)] +pub struct ManifestKeypair { + pub signing_key: SigningKey, + pub verifying_key: VerifyingKey, +} + +pub fn load_manifest_keypair_from_env() -> Result, String> { + let value = match env::var("EVERY_CHANNEL_MANIFEST_SIGNING_KEY") { + Ok(value) => value, + Err(env::VarError::NotPresent) => return Ok(None), + Err(err) => return Err(err.to_string()), + }; + let trimmed = value.trim(); + let key_bytes = if std::path::Path::new(trimmed).exists() { + let text = fs::read_to_string(trimmed).map_err(|err| err.to_string())?; + hex::decode(text.trim()).map_err(|err| err.to_string())? + } else { + hex::decode(trimmed).map_err(|err| err.to_string())? + }; + let bytes = if key_bytes.len() == 32 { + key_bytes + } else if key_bytes.len() == 64 { + key_bytes[..32].to_vec() + } else { + return Err("manifest signing key must be 32 or 64 hex bytes".to_string()); + }; + let mut secret = [0u8; 32]; + secret.copy_from_slice(&bytes[..32]); + let signing_key = SigningKey::from_bytes(&secret); + let verifying_key = signing_key.verifying_key(); + Ok(Some(ManifestKeypair { + signing_key, + verifying_key, + })) +} + +pub fn signer_id_from_key(key: &VerifyingKey) -> String { + format!("ed25519:{}", hex::encode(key.to_bytes())) +} + +pub fn sign_manifest_id(manifest_id: &str, keypair: &ManifestKeypair) -> ManifestSignature { + let signature: Signature = keypair.signing_key.sign(manifest_id.as_bytes()); + ManifestSignature { + signer_id: signer_id_from_key(&keypair.verifying_key), + alg: MANIFEST_SIG_ALG.to_string(), + signature: hex::encode(signature.to_bytes()), + } +} + +pub fn verify_manifest_signature(manifest_id: &str, sig: &ManifestSignature) -> bool { + if sig.alg != MANIFEST_SIG_ALG { + return false; + } + let signer_id = sig + .signer_id + .strip_prefix("ed25519:") + .unwrap_or(&sig.signer_id); + let Ok(pk_bytes) = hex::decode(signer_id) else { + return false; + }; + if pk_bytes.len() != 32 { + return false; + } + let mut pk = [0u8; 32]; + pk.copy_from_slice(&pk_bytes); + let Ok(verifying_key) = VerifyingKey::from_bytes(&pk) else { + return false; + }; + let Ok(sig_bytes) = hex::decode(&sig.signature) else { + return false; + }; + let Ok(signature) = Signature::from_slice(&sig_bytes) else { + return false; + }; + verifying_key + .verify(manifest_id.as_bytes(), &signature) + .is_ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn stream_key_is_deterministic_and_secret_sensitive() { + let k1 = derive_stream_key("s1", None); + let k2 = derive_stream_key("s1", None); + assert_eq!(k1, k2); + let k3 = derive_stream_key("s2", None); + assert_ne!(k1, k3); + + let secret = [7u8; 32]; + let ks1 = derive_stream_key("s1", Some(&secret)); + assert_ne!(k1, ks1); + let ks2 = derive_stream_key("s1", Some(&secret)); + assert_eq!(ks1, ks2); + } + + #[test] + fn nonce_changes_per_chunk_index() { + let n1 = derive_stream_nonce("s", 1); + let n2 = derive_stream_nonce("s", 2); + assert_ne!(n1, n2); + } + + #[test] + fn encrypt_decrypt_roundtrip() { + let plaintext = b"hello world"; + let enc = encrypt_stream_data("s", 42, plaintext, None); + assert_ne!(enc.ciphertext, plaintext); + let out = decrypt_stream_data("s", 42, &enc.ciphertext, None).unwrap(); + assert_eq!(out, plaintext); + } + + #[test] + fn decrypt_fails_with_wrong_index() { + let plaintext = b"hello world"; + let enc = encrypt_stream_data("s", 42, plaintext, None); + assert!(decrypt_stream_data("s", 43, &enc.ciphertext, None).is_none()); + } + + #[test] + fn manifest_sign_verify_roundtrip() { + let secret = [1u8; 32]; + let signing_key = SigningKey::from_bytes(&secret); + let verifying_key = signing_key.verifying_key(); + let keypair = ManifestKeypair { + signing_key, + verifying_key, + }; + let sig = sign_manifest_id("m", &keypair); + assert!(verify_manifest_signature("m", &sig)); + assert!(!verify_manifest_signature("evil", &sig)); + } + + #[test] + fn load_keypair_from_env_hex() { + let prev = env::var("EVERY_CHANNEL_MANIFEST_SIGNING_KEY").ok(); + env::set_var("EVERY_CHANNEL_MANIFEST_SIGNING_KEY", "00".repeat(32)); + let loaded = load_manifest_keypair_from_env().unwrap().unwrap(); + let id = signer_id_from_key(&loaded.verifying_key); + assert!(id.starts_with("ed25519:")); + match prev { + Some(value) => env::set_var("EVERY_CHANNEL_MANIFEST_SIGNING_KEY", value), + None => env::remove_var("EVERY_CHANNEL_MANIFEST_SIGNING_KEY"), + } + } +} diff --git a/crates/ec-direct/Cargo.toml b/crates/ec-direct/Cargo.toml new file mode 100644 index 0000000..53a0b50 --- /dev/null +++ b/crates/ec-direct/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "ec-direct" +version = "0.0.0" +edition.workspace = true +license.workspace = true + +[dependencies] +anyhow.workspace = true +base64 = "0.22" +just-webrtc = { version = "0.2", default-features = true } +serde.workspace = true +serde_json.workspace = true + +[dev-dependencies] +bytes = "1" +tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] } diff --git a/crates/ec-direct/src/lib.rs b/crates/ec-direct/src/lib.rs new file mode 100644 index 0000000..476047a --- /dev/null +++ b/crates/ec-direct/src/lib.rs @@ -0,0 +1,94 @@ +use anyhow::{anyhow, Context, Result}; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine; +use just_webrtc::types::{ICECandidate, SessionDescription}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct DirectCodeV1 { + pub v: u8, + pub desc: SessionDescription, + pub candidates: Vec, + #[serde(default)] + pub label: Option, +} + +const PREFIX: &str = "every.channel://"; + +pub fn encode_code(code: &DirectCodeV1) -> Result { + let json = serde_json::to_vec(code)?; + Ok(URL_SAFE_NO_PAD.encode(json)) +} + +pub fn decode_code(code: &str) -> Result { + let bytes = URL_SAFE_NO_PAD + .decode(code.trim()) + .context("invalid base64url code")?; + let parsed: DirectCodeV1 = serde_json::from_slice(&bytes).context("invalid code json")?; + if parsed.v != 1 { + return Err(anyhow!("unsupported direct code version {}", parsed.v)); + } + Ok(parsed) +} + +pub fn build_direct_link(code_b64: &str) -> String { + format!("every.channel://direct?c={code_b64}") +} + +pub fn encode_direct_link(code: &DirectCodeV1) -> Result { + let b64 = encode_code(code)?; + Ok(build_direct_link(&b64)) +} + +pub fn decode_direct_link(link_or_code: &str) -> Result { + let s = link_or_code.trim(); + if !s.starts_with(PREFIX) { + return decode_code(s); + } + let rest = &s[PREFIX.len()..]; + let (path, query) = rest.split_once('?').ok_or_else(|| anyhow!("missing '?'"))?; + if !path.eq_ignore_ascii_case("direct") { + return Err(anyhow!("not a direct link")); + } + for pair in query.split('&') { + let pair = pair.trim(); + if pair.is_empty() { + continue; + } + let (k, v) = pair.split_once('=').unwrap_or((pair, "")); + if k.eq_ignore_ascii_case("c") { + return decode_code(v); + } + } + Err(anyhow!("missing code parameter")) +} + +#[cfg(test)] +mod tests { + use super::*; + use just_webrtc::types::SDPType; + + #[test] + fn code_roundtrips() { + let code = DirectCodeV1 { + v: 1, + desc: SessionDescription { + sdp_type: SDPType::Offer, + sdp: "x".to_string(), + }, + candidates: vec![ICECandidate { + candidate: "c".to_string(), + sdp_mid: Some("0".to_string()), + sdp_mline_index: Some(0), + username_fragment: None, + }], + label: Some("ec".to_string()), + }; + let enc = encode_code(&code).unwrap(); + let dec = decode_code(&enc).unwrap(); + assert_eq!(dec, code); + let link = encode_direct_link(&code).unwrap(); + let dec2 = decode_direct_link(&link).unwrap(); + assert_eq!(dec2, code); + } +} diff --git a/crates/ec-direct/tests/e2e_loopback.rs b/crates/ec-direct/tests/e2e_loopback.rs new file mode 100644 index 0000000..4e34c71 --- /dev/null +++ b/crates/ec-direct/tests/e2e_loopback.rs @@ -0,0 +1,134 @@ +use anyhow::{anyhow, Result}; +use bytes::Bytes; +use ec_direct::{decode_direct_link, encode_direct_link, DirectCodeV1}; +use just_webrtc::types::{ + DataChannelOptions, PeerConfiguration, PeerConnectionState, SessionDescription, +}; +use just_webrtc::{DataChannelExt, PeerConnectionBuilder, PeerConnectionExt}; + +async fn wait_connected(pc: &impl PeerConnectionExt) -> Result<()> { + tokio::time::timeout(std::time::Duration::from_secs(20), async { + loop { + match pc.state_change().await { + PeerConnectionState::Connected => break Ok(()), + PeerConnectionState::Failed => break Err(anyhow!("peer connection failed")), + PeerConnectionState::Closed => break Err(anyhow!("peer connection closed")), + _ => {} + } + } + }) + .await + .map_err(|_| anyhow!("timed out waiting for peer connection"))? +} + +// Ignored by default: WebRTC can be timing-sensitive on some hosts. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore] +async fn e2e_direct_connect_loopback_sends_bytes() -> Result<()> { + // Avoid depending on external STUN servers in tests: use host candidates only. + let cfg = PeerConfiguration { + ice_servers: vec![], + ..Default::default() + }; + + let offerer = PeerConnectionBuilder::new() + .set_config(cfg.clone()) + .with_channel_options(vec![( + "simple_channel_".to_string(), + DataChannelOptions::default(), + )]) + .map_err(|e| anyhow!("{e:#}"))? + .build() + .await + .map_err(|e| anyhow!("{e:#}"))?; + + let offer_desc: SessionDescription = offerer + .get_local_description() + .await + .ok_or_else(|| anyhow!("missing offer local description"))?; + let offer_candidates = offerer + .collect_ice_candidates() + .await + .map_err(|e| anyhow!("{e:#}"))?; + let offer_link = encode_direct_link(&DirectCodeV1 { + v: 1, + desc: offer_desc, + candidates: offer_candidates, + label: Some("every.channel0".to_string()), + })?; + + let offer_code = decode_direct_link(&offer_link)?; + let answerer = PeerConnectionBuilder::new() + .set_config(cfg.clone()) + .with_remote_offer(Some(offer_code.desc.clone())) + .map_err(|e| anyhow!("{e:#}"))? + .build() + .await + .map_err(|e| anyhow!("{e:#}"))?; + answerer + .add_ice_candidates(offer_code.candidates.clone()) + .await + .map_err(|e| anyhow!("{e:#}"))?; + let answer_desc = answerer + .get_local_description() + .await + .ok_or_else(|| anyhow!("missing answer local description"))?; + let answer_candidates = answerer + .collect_ice_candidates() + .await + .map_err(|e| anyhow!("{e:#}"))?; + let answer_link = encode_direct_link(&DirectCodeV1 { + v: 1, + desc: answer_desc, + candidates: answer_candidates, + label: Some("every.channel0".to_string()), + })?; + + let answer_code = decode_direct_link(&answer_link)?; + offerer + .set_remote_description(answer_code.desc.clone()) + .await + .map_err(|e| anyhow!("{e:#}"))?; + offerer + .add_ice_candidates(answer_code.candidates.clone()) + .await + .map_err(|e| anyhow!("{e:#}"))?; + + // Wait for both peers to report a full connection before waiting for the data channel. + wait_connected(&offerer).await?; + wait_connected(&answerer).await?; + + let offerer_ch = offerer + .receive_channel() + .await + .map_err(|e| anyhow!("{e:#}"))?; + let answerer_ch = answerer + .receive_channel() + .await + .map_err(|e| anyhow!("{e:#}"))?; + offerer_ch.wait_ready().await; + answerer_ch.wait_ready().await; + + let payload = Bytes::from_static(b"hello"); + offerer_ch + .send(&payload) + .await + .map_err(|e| anyhow!("{e:#}"))?; + let got = tokio::time::timeout(std::time::Duration::from_secs(10), answerer_ch.receive()) + .await + .map_err(|_| anyhow!("timed out waiting for receive"))? + .map_err(|e| anyhow!("{e:#}"))?; + assert_eq!(&got[..], b"hello"); + + // Confirm the reverse direction works too (this also guards against one-way readiness bugs). + answerer_ch + .send(&Bytes::from_static(b"world")) + .await + .map_err(|e| anyhow!("{e:#}"))?; + let got = tokio::time::timeout(std::time::Duration::from_secs(10), offerer_ch.receive()) + .await + .map_err(|_| anyhow!("timed out waiting for receive"))? + .map_err(|e| anyhow!("{e:#}"))?; + assert_eq!(&got[..], b"world"); + Ok(()) +} diff --git a/crates/ec-hdhomerun/Cargo.toml b/crates/ec-hdhomerun/Cargo.toml new file mode 100644 index 0000000..7d4df40 --- /dev/null +++ b/crates/ec-hdhomerun/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "ec-hdhomerun" +version = "0.0.0" +edition.workspace = true +license.workspace = true + +[dependencies] +anyhow.workspace = true +ec-core = { path = "../ec-core" } +crc32fast = "1" +hex = "0.4" +serde.workspace = true +serde_json.workspace = true +ureq = { version = "2", default-features = true, features = ["tls"] } diff --git a/crates/ec-hdhomerun/src/lib.rs b/crates/ec-hdhomerun/src/lib.rs new file mode 100644 index 0000000..73e87e9 --- /dev/null +++ b/crates/ec-hdhomerun/src/lib.rs @@ -0,0 +1,676 @@ +//! HDHomeRun discovery, lineup ingest, and stream scaffolding. + +use anyhow::{anyhow, Context, Result}; +use ec_core::{Channel, ChannelId, ChannelMetadata, DeviceId}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::io::Read; +use std::net::{Ipv4Addr, SocketAddrV4, UdpSocket}; +use std::time::{Duration, Instant}; + +const DISCOVER_UDP_PORT: u16 = 65001; +const TYPE_DISCOVER_REQ: u16 = 0x0002; +const TYPE_DISCOVER_RPY: u16 = 0x0003; + +const TAG_DEVICE_TYPE: u8 = 0x01; +const TAG_DEVICE_ID: u8 = 0x02; +const TAG_TUNER_COUNT: u8 = 0x10; +const TAG_DEVICE_AUTH_BIN: u8 = 0x29; +const TAG_BASE_URL: u8 = 0x2A; +const TAG_DEVICE_AUTH_STR: u8 = 0x2B; + +const DEVICE_TYPE_TUNER: u32 = 0x00000001; +const DEVICE_ID_WILDCARD: u32 = 0xFFFFFFFF; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DeviceField { + pub key: String, + pub value: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HdhomerunDevice { + pub id: DeviceId, + pub ip: String, + pub tuner_count: u8, + pub lineup_url: Option, + pub discover_url: Option, + pub base_url: Option, + pub device_auth: Option, + pub friendly_name: Option, + pub model_number: Option, + pub firmware_name: Option, + pub firmware_version: Option, + pub device_type: Option, + pub discovery_tags: Vec, + pub raw_discover_json: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LineupEntry { + pub channel: Channel, + pub stream_url: String, + pub tags: Vec, + pub raw: Value, +} + +pub struct HdhomerunStream { + pub url: String, + reader: Box, +} + +impl std::fmt::Debug for HdhomerunStream { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("HdhomerunStream") + .field("url", &self.url) + .finish_non_exhaustive() + } +} + +impl Read for HdhomerunStream { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + self.reader.read(buf) + } +} + +#[derive(Debug, Clone, Deserialize)] +struct DiscoverJson { + #[serde(rename = "DeviceID")] + device_id: Option, + #[serde(rename = "DeviceAuth")] + device_auth: Option, + #[serde(rename = "BaseURL")] + base_url: Option, + #[serde(rename = "LineupURL")] + lineup_url: Option, + #[serde(rename = "DiscoverURL")] + discover_url: Option, + #[serde(rename = "FriendlyName")] + friendly_name: Option, + #[serde(rename = "ModelNumber")] + model_number: Option, + #[serde(rename = "FirmwareName")] + firmware_name: Option, + #[serde(rename = "FirmwareVersion")] + firmware_version: Option, + #[serde(rename = "DeviceType")] + device_type: Option, + #[serde(rename = "TunerCount")] + tuner_count: Option, +} + +#[derive(Debug, Clone, Deserialize)] +struct LineupJsonEntry { + #[serde(rename = "GuideNumber")] + guide_number: Option, + #[serde(rename = "GuideName")] + guide_name: Option, + #[serde(rename = "Tags")] + tags: Option, + #[serde(rename = "URL")] + url: Option, +} + +/// Discover devices using UDP broadcast, then hydrate with /discover.json when possible. +pub fn discover() -> Result> { + let mut devices = discover_udp(Duration::from_millis(400))?; + + if devices.is_empty() { + if let Ok(device) = discover_from_host("hdhomerun.local") { + devices.push(device); + } + } + + Ok(devices) +} + +/// Discover a device by hostname or IP using the HTTP discover.json endpoint. +pub fn discover_from_host(host: &str) -> Result { + let base_url = format!("http://{host}"); + let discover_url = format!("{base_url}/discover.json"); + let json = fetch_json(&discover_url)?; + let discover: DiscoverJson = serde_json::from_value(json.clone()) + .with_context(|| format!("invalid discover.json from {discover_url}"))?; + + let device = HdhomerunDevice { + id: DeviceId( + discover + .device_id + .clone() + .unwrap_or_else(|| "unknown".to_string()), + ), + ip: host.to_string(), + tuner_count: discover.tuner_count.unwrap_or(0), + lineup_url: discover.lineup_url.clone(), + discover_url: discover.discover_url.clone().or(Some(discover_url)), + base_url: discover.base_url.clone().or(Some(base_url)), + device_auth: discover.device_auth.clone(), + friendly_name: discover.friendly_name.clone(), + model_number: discover.model_number.clone(), + firmware_name: discover.firmware_name.clone(), + firmware_version: discover.firmware_version.clone(), + device_type: discover.device_type.clone(), + discovery_tags: Vec::new(), + raw_discover_json: Some(json), + }; + + Ok(device) +} + +/// Fetch and normalize lineup information for a device. +pub fn fetch_lineup(device: &HdhomerunDevice) -> Result> { + let lineup_url = resolve_lineup_url(device)?; + let json = fetch_json(&lineup_url)?; + lineup_from_json_value(&json, Some(&device.id)) + .with_context(|| format!("invalid lineup.json from {lineup_url}")) +} + +/// Parse a lineup.json file already loaded into memory. +pub fn lineup_from_json_bytes( + bytes: &[u8], + device_id: Option<&DeviceId>, +) -> Result> { + let json: Value = serde_json::from_slice(bytes)?; + lineup_from_json_value(&json, device_id) +} + +/// Open a raw MPEG-TS stream by channel ID (lineup lookup required). +pub fn open_stream(device: &HdhomerunDevice, channel: &ChannelId) -> Result { + let lineup = fetch_lineup(device)?; + let entry = lineup + .into_iter() + .find(|entry| entry.channel.id == *channel) + .ok_or_else(|| anyhow!("channel {} not found in lineup", channel.0))?; + open_stream_entry(&entry, None) +} + +/// Open a raw MPEG-TS stream from a lineup entry. +pub fn open_stream_entry( + entry: &LineupEntry, + duration_secs: Option, +) -> Result { + open_stream_url(&entry.stream_url, duration_secs) +} + +/// Open a raw MPEG-TS stream by URL. +pub fn open_stream_url(url: &str, duration_secs: Option) -> Result { + let url = if let Some(duration) = duration_secs { + append_query_param(url, "duration", &duration.to_string()) + } else { + url.to_string() + }; + + // Streams can be long-lived. Only apply read timeout when the caller requests + // `duration=...` (useful for tests and short captures). + let mut agent_builder = ureq::AgentBuilder::new().timeout_connect(Duration::from_secs(3)); + if let Some(duration) = duration_secs { + agent_builder = agent_builder.timeout_read(Duration::from_secs(duration as u64 + 10)); + } + let agent = agent_builder.build(); + + let response = agent + .get(&url) + .call() + .with_context(|| format!("failed to open stream {url}"))?; + if response.status() < 200 || response.status() >= 300 { + return Err(anyhow!( + "stream returned http {} for {}", + response.status(), + url + )); + } + + Ok(HdhomerunStream { + url, + reader: response.into_reader(), + }) +} + +pub fn find_lineup_entry_by_number<'a>( + lineup: &'a [LineupEntry], + guide_number: &str, +) -> Option<&'a LineupEntry> { + lineup + .iter() + .find(|entry| entry.channel.number.as_deref() == Some(guide_number)) +} + +pub fn find_lineup_entry_by_name<'a>( + lineup: &'a [LineupEntry], + guide_name: &str, +) -> Option<&'a LineupEntry> { + lineup.iter().find(|entry| entry.channel.name == guide_name) +} + +fn discover_udp(timeout: Duration) -> Result> { + let socket = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0))?; + socket.set_broadcast(true)?; + socket.set_read_timeout(Some(Duration::from_millis(100)))?; + + let packet = build_discover_packet()?; + let broadcast_addr = SocketAddrV4::new(Ipv4Addr::BROADCAST, DISCOVER_UDP_PORT); + socket.send_to(&packet, broadcast_addr)?; + + let mut devices = Vec::new(); + let start = Instant::now(); + let mut buf = [0u8; 2048]; + + while start.elapsed() < timeout { + match socket.recv_from(&mut buf) { + Ok((len, addr)) => { + if let Ok(device) = parse_discover_response(&buf[..len], addr.ip().to_string()) { + devices.push(device); + } + } + Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => continue, + Err(err) if err.kind() == std::io::ErrorKind::TimedOut => continue, + Err(err) => return Err(err.into()), + } + } + + for device in devices.iter_mut() { + if let Ok(json) = try_fetch_discover_json(&device.ip) { + apply_discover_json(device, json); + } + } + + Ok(devices) +} + +fn build_discover_packet() -> Result> { + let mut payload = Vec::new(); + payload.extend(tlv(TAG_DEVICE_TYPE, &DEVICE_TYPE_TUNER.to_be_bytes())); + payload.extend(tlv(TAG_DEVICE_ID, &DEVICE_ID_WILDCARD.to_be_bytes())); + + let mut packet = Vec::with_capacity(4 + payload.len() + 4); + packet.extend(TYPE_DISCOVER_REQ.to_be_bytes()); + packet.extend((payload.len() as u16).to_be_bytes()); + packet.extend(payload); + + let crc = crc32fast::hash(&packet); + packet.extend(crc.to_le_bytes()); + Ok(packet) +} + +fn parse_discover_response(bytes: &[u8], ip: String) -> Result { + if bytes.len() < 8 { + return Err(anyhow!("discover reply too short")); + } + + let packet_type = u16::from_be_bytes([bytes[0], bytes[1]]); + if packet_type != TYPE_DISCOVER_RPY { + return Err(anyhow!("unexpected packet type")); + } + + let payload_len = u16::from_be_bytes([bytes[2], bytes[3]]) as usize; + if bytes.len() < 4 + payload_len + 4 { + return Err(anyhow!("truncated discover reply")); + } + + let payload = &bytes[4..4 + payload_len]; + let expected_crc = u32::from_le_bytes([ + bytes[4 + payload_len], + bytes[4 + payload_len + 1], + bytes[4 + payload_len + 2], + bytes[4 + payload_len + 3], + ]); + let actual_crc = crc32fast::hash(&bytes[..4 + payload_len]); + if expected_crc != actual_crc { + return Err(anyhow!("bad crc")); + } + + let mut cursor = 0usize; + let mut device_id: Option = None; + let mut tuner_count: Option = None; + let mut base_url: Option = None; + let mut device_auth: Option = None; + let mut tags: Vec = Vec::new(); + + while cursor < payload.len() { + let tag = payload[cursor]; + cursor += 1; + + let (length, consumed) = read_varlen(&payload[cursor..])?; + cursor += consumed; + + if cursor + length > payload.len() { + return Err(anyhow!("discover TLV length overflow")); + } + + let value = &payload[cursor..cursor + length]; + cursor += length; + + match tag { + TAG_DEVICE_ID => { + if value.len() == 4 { + let id = u32::from_be_bytes([value[0], value[1], value[2], value[3]]); + device_id = Some(format!("{id:08X}")); + } + } + TAG_TUNER_COUNT => { + if let Some(first) = value.first() { + tuner_count = Some(*first); + } + } + TAG_BASE_URL => { + if let Ok(text) = std::str::from_utf8(value) { + base_url = Some(text.trim_end_matches('\0').to_string()); + } + } + TAG_DEVICE_AUTH_STR => { + if let Ok(text) = std::str::from_utf8(value) { + device_auth = Some(text.trim_end_matches('\0').to_string()); + } + } + TAG_DEVICE_AUTH_BIN => { + tags.push(DeviceField { + key: "device_auth_bin".to_string(), + value: hex::encode(value), + }); + } + TAG_DEVICE_TYPE => { + tags.push(DeviceField { + key: "device_type".to_string(), + value: hex::encode(value), + }); + } + other => { + tags.push(DeviceField { + key: format!("tag_{other:02X}"), + value: hex::encode(value), + }); + } + } + } + + let id = device_id.unwrap_or_else(|| "unknown".to_string()); + let device = HdhomerunDevice { + id: DeviceId(id), + ip, + tuner_count: tuner_count.unwrap_or(0), + lineup_url: None, + discover_url: None, + base_url, + device_auth, + friendly_name: None, + model_number: None, + firmware_name: None, + firmware_version: None, + device_type: None, + discovery_tags: tags, + raw_discover_json: None, + }; + + Ok(device) +} + +fn read_varlen(buf: &[u8]) -> Result<(usize, usize)> { + if buf.is_empty() { + return Err(anyhow!("missing varlen")); + } + + let first = buf[0]; + if first & 0x80 == 0 { + Ok((first as usize, 1)) + } else { + if buf.len() < 2 { + return Err(anyhow!("missing varlen second byte")); + } + let len = ((first & 0x7F) as usize) | ((buf[1] as usize) << 7); + Ok((len, 2)) + } +} + +fn tlv(tag: u8, value: &[u8]) -> Vec { + let mut out = Vec::with_capacity(2 + value.len()); + out.push(tag); + out.extend(encode_varlen(value.len())); + out.extend(value); + out +} + +fn encode_varlen(len: usize) -> Vec { + if len <= 0x7F { + vec![len as u8] + } else { + vec![((len & 0x7F) as u8) | 0x80, (len >> 7) as u8] + } +} + +fn fetch_json(url: &str) -> Result { + let agent = ureq::AgentBuilder::new() + .timeout_connect(Duration::from_secs(3)) + .timeout_read(Duration::from_secs(6)) + .build(); + let response = agent + .get(url) + .call() + .with_context(|| format!("request failed for {url}"))?; + if response.status() < 200 || response.status() >= 300 { + return Err(anyhow!("http {} for {url}", response.status())); + } + let mut body = String::new(); + response + .into_reader() + .read_to_string(&mut body) + .with_context(|| format!("failed to read response body for {url}"))?; + Ok(serde_json::from_str::(&body) + .with_context(|| format!("invalid json body for {url}"))?) +} + +fn try_fetch_discover_json(host: &str) -> Result { + let url = format!("http://{host}/discover.json"); + fetch_json(&url) +} + +fn apply_discover_json(device: &mut HdhomerunDevice, json: Value) { + if let Ok(discover) = serde_json::from_value::(json.clone()) { + if let Some(device_id) = discover.device_id { + device.id = DeviceId(device_id); + } + if let Some(tuner_count) = discover.tuner_count { + device.tuner_count = tuner_count; + } + device.lineup_url = discover.lineup_url.or(device.lineup_url.take()); + device.discover_url = discover.discover_url.or(device.discover_url.take()); + device.base_url = discover.base_url.or(device.base_url.take()); + device.device_auth = discover.device_auth.or(device.device_auth.take()); + device.friendly_name = discover.friendly_name.or(device.friendly_name.take()); + device.model_number = discover.model_number.or(device.model_number.take()); + device.firmware_name = discover.firmware_name.or(device.firmware_name.take()); + device.firmware_version = discover.firmware_version.or(device.firmware_version.take()); + device.device_type = discover.device_type.or(device.device_type.take()); + } + + device.raw_discover_json = Some(json); +} + +fn resolve_lineup_url(device: &HdhomerunDevice) -> Result { + if let Some(lineup_url) = device.lineup_url.as_ref() { + return Ok(lineup_url.clone()); + } + + if let Some(base_url) = device.base_url.as_ref() { + return Ok(format!("{base_url}/lineup.json")); + } + + if !device.ip.is_empty() { + return Ok(format!("http://{}/lineup.json", device.ip)); + } + + Err(anyhow!("no lineup URL available")) +} + +fn append_query_param(url: &str, key: &str, value: &str) -> String { + if url.contains('?') { + format!("{url}&{key}={value}") + } else { + format!("{url}?{key}={value}") + } +} + +fn lineup_from_json_value(json: &Value, device_id: Option<&DeviceId>) -> Result> { + let entries = json + .as_array() + .ok_or_else(|| anyhow!("lineup json is not an array"))?; + + let mut output = Vec::with_capacity(entries.len()); + + for (index, entry) in entries.iter().enumerate() { + let parsed: LineupJsonEntry = serde_json::from_value(entry.clone()) + .with_context(|| format!("invalid lineup entry at index {index}"))?; + + let guide_number = parsed.guide_number.clone(); + let guide_name = parsed + .guide_name + .clone() + .or_else(|| guide_number.clone()) + .unwrap_or_else(|| format!("Channel {index}")); + let tags = parsed + .tags + .unwrap_or_default() + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect::>(); + let url = parsed.url.clone().unwrap_or_else(|| "".to_string()); + + let id = match (device_id, guide_number.as_ref()) { + (Some(device_id), Some(guide_number)) => { + ChannelId(format!("hdhr:{}:{}", device_id.0, guide_number)) + } + (_, Some(guide_number)) => ChannelId(guide_number.clone()), + (_, None) => ChannelId(format!("hdhr:unknown:{index}")), + }; + + let mut metadata = Vec::new(); + for tag in &tags { + metadata.push(ChannelMetadata::Extra("tag".to_string(), tag.clone())); + } + + if let Some(guide_number) = guide_number.clone() { + metadata.push(ChannelMetadata::Extra( + "guide_number".to_string(), + guide_number, + )); + } + + if let Some(obj) = entry.as_object() { + for (key, value) in obj.iter() { + if key == "GuideNumber" || key == "GuideName" || key == "Tags" || key == "URL" { + continue; + } + metadata.push(ChannelMetadata::Extra(key.clone(), value.to_string())); + } + } + + let channel = Channel { + id, + name: guide_name, + number: parsed.guide_number, + program_id: None, + metadata, + }; + + output.push(LineupEntry { + channel, + stream_url: url, + tags, + raw: entry.clone(), + }); + } + + Ok(output) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn varlen_roundtrip_small_and_large() { + for len in [0usize, 1, 10, 127, 128, 200, 1024] { + let enc = encode_varlen(len); + let (decoded, consumed) = read_varlen(&enc).unwrap(); + assert_eq!(decoded, len); + assert_eq!(consumed, enc.len()); + } + } + + #[test] + fn parse_discover_response_happy_path() { + let device_id = 0x10ACEBB9u32; + let ip = "192.0.2.10"; // RFC 5737 TEST-NET-1 + let mut payload = Vec::new(); + payload.extend(tlv(TAG_DEVICE_ID, &device_id.to_be_bytes())); + payload.extend(tlv(TAG_TUNER_COUNT, &[4u8])); + payload.extend(tlv(TAG_BASE_URL, b"http://192.0.2.10\0")); + payload.extend(tlv(TAG_DEVICE_AUTH_STR, b"auth-token\0")); + payload.extend(tlv(0x99, b"unknown")); + + let mut packet = Vec::new(); + packet.extend(TYPE_DISCOVER_RPY.to_be_bytes()); + packet.extend((payload.len() as u16).to_be_bytes()); + packet.extend(&payload); + let crc = crc32fast::hash(&packet); + packet.extend(crc.to_le_bytes()); + + let dev = parse_discover_response(&packet, ip.to_string()).unwrap(); + assert_eq!(dev.id.0, "10ACEBB9"); + assert_eq!(dev.ip, ip); + assert_eq!(dev.tuner_count, 4); + assert_eq!(dev.base_url.as_deref(), Some("http://192.0.2.10")); + assert_eq!(dev.device_auth.as_deref(), Some("auth-token")); + assert!(dev.discovery_tags.iter().any(|t| t.key == "tag_99")); + } + + #[test] + fn parse_discover_response_rejects_bad_crc() { + let mut payload = Vec::new(); + payload.extend(tlv(TAG_TUNER_COUNT, &[2u8])); + let mut packet = Vec::new(); + packet.extend(TYPE_DISCOVER_RPY.to_be_bytes()); + packet.extend((payload.len() as u16).to_be_bytes()); + packet.extend(&payload); + let crc = crc32fast::hash(&packet); + packet.extend(crc.to_le_bytes()); + // corrupt the last byte + *packet.last_mut().unwrap() ^= 0xFF; + assert!(parse_discover_response(&packet, "1.2.3.4".to_string()).is_err()); + } + + #[test] + fn lineup_parsing_generates_channel_ids_and_metadata() { + let device_id = DeviceId("ABCDEF01".to_string()); + let json = serde_json::json!([ + { + "GuideNumber": "2.1", + "GuideName": "KCBS-HD", + "Tags": "drm,encrypted,", + "URL": "http://hdhr/auto/v2.1", + "Foo": "Bar" + }, + { + "GuideNumber": "2.2", + "GuideName": "StartTV", + "Tags": "", + "URL": "http://hdhr/auto/v2.2" + } + ]); + let entries = lineup_from_json_value(&json, Some(&device_id)).unwrap(); + assert_eq!(entries.len(), 2); + assert_eq!(entries[0].channel.id.0, "hdhr:ABCDEF01:2.1"); + assert_eq!(entries[0].channel.name, "KCBS-HD"); + assert_eq!(entries[0].channel.number.as_deref(), Some("2.1")); + assert_eq!(entries[0].stream_url, "http://hdhr/auto/v2.1"); + assert!(entries[0].tags.iter().any(|t| t == "drm")); + assert!(entries[0].channel.metadata.iter().any(|m| match m { + ChannelMetadata::Extra(key, value) => key == "guide_number" && value == "2.1", + _ => false, + })); + assert!(entries[0].channel.metadata.iter().any(|m| match m { + ChannelMetadata::Extra(key, _) => key == "Foo", + _ => false, + })); + } +} diff --git a/crates/ec-iroh/Cargo.toml b/crates/ec-iroh/Cargo.toml new file mode 100644 index 0000000..6b76d63 --- /dev/null +++ b/crates/ec-iroh/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "ec-iroh" +version = "0.0.0" +edition.workspace = true +license.workspace = true + +[dependencies] +anyhow.workspace = true +blake3 = "1" +bytes = "1" +ec-core = { path = "../ec-core" } +futures-lite = "2" +iroh = { version = "0.96", features = ["address-lookup-mdns", "address-lookup-pkarr-dht"] } +iroh-gossip = { path = "../../third_party/iroh-org/iroh-gossip", features = ["net"] } +serde_json.workspace = true +tokio = { version = "1", features = ["time"] } diff --git a/crates/ec-iroh/src/lib.rs b/crates/ec-iroh/src/lib.rs new file mode 100644 index 0000000..1c8dddd --- /dev/null +++ b/crates/ec-iroh/src/lib.rs @@ -0,0 +1,328 @@ +//! iroh transport scaffolding for every.channel. + +use anyhow::{Context, Result}; +use bytes::Bytes; +use ec_core::StreamCatalogEntry; +use futures_lite::StreamExt; +use iroh::address_lookup::{ + DhtAddressLookup, DiscoveryEvent, DnsAddressLookup, MdnsAddressLookup, PkarrPublisher, UserData, +}; +use iroh::endpoint::RelayMode; +use iroh::{ + address_lookup::memory::MemoryLookup, protocol::Router, Endpoint, EndpointAddr, PublicKey, + SecretKey, +}; +use iroh_gossip::{ + api::{Event, GossipReceiver, GossipSender}, + net::{Gossip, GOSSIP_ALPN}, + proto::TopicId, +}; +use std::collections::BTreeMap; +use std::env; +use std::time::{Duration, Instant}; + +pub const ALPN_MOQ: &[u8] = b"every.channel/moq/0"; +pub const DEFAULT_CATALOG_TOPIC: &str = "every.channel/catalog/v1"; +pub const MDNS_USER_DATA: &str = "every.channel"; + +#[derive(Debug, Clone)] +pub struct TokenBucket { + capacity: u64, + tokens: f64, + refill_per_sec: f64, + last_refill: Instant, +} + +impl TokenBucket { + pub fn new(capacity: u64, refill_per_sec: u64) -> Self { + let capacity = capacity.max(1); + let refill_per_sec = refill_per_sec.max(1) as f64; + Self { + capacity, + tokens: capacity as f64, + refill_per_sec, + last_refill: Instant::now(), + } + } + + pub fn allow(&mut self, amount: u64) -> bool { + self.refill(); + let amount = amount as f64; + if amount <= self.tokens { + self.tokens -= amount; + true + } else { + false + } + } + + fn refill(&mut self) { + let now = Instant::now(); + let elapsed = now.duration_since(self.last_refill).as_secs_f64(); + if elapsed <= 0.0 { + return; + } + self.tokens = (self.tokens + elapsed * self.refill_per_sec).min(self.capacity as f64); + self.last_refill = now; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn token_bucket_allows_and_refills() { + let mut bucket = TokenBucket::new(10, 10); + assert!(bucket.allow(7)); + assert!(bucket.allow(3)); + assert!(!bucket.allow(1)); + + // Force a refill without sleeping. + bucket.last_refill = Instant::now() - Duration::from_secs(1); + assert!(bucket.allow(1)); + } +} + +#[derive(Debug, Clone, Copy, Default)] +pub struct DiscoveryConfig { + pub dht: bool, + pub mdns: bool, + pub dns: bool, +} + +impl DiscoveryConfig { + pub fn from_env() -> Result { + match env::var("EVERY_CHANNEL_IROH_DISCOVERY") { + Ok(value) => Self::from_list(&value), + Err(env::VarError::NotPresent) => Ok(Self::default()), + Err(err) => Err(err.into()), + } + } + + pub fn from_list(value: &str) -> Result { + let mut config = DiscoveryConfig::default(); + for raw in value.split(|c: char| c == ',' || c == ';' || c.is_whitespace()) { + let token = raw.trim().to_ascii_lowercase(); + if token.is_empty() { + continue; + } + match token.as_str() { + "dht" => config.dht = true, + "mdns" => config.mdns = true, + "dns" => config.dns = true, + "all" => { + config.dht = true; + config.mdns = true; + config.dns = true; + } + "none" | "off" => { + config = DiscoveryConfig::default(); + } + _ => { + return Err(anyhow::anyhow!("unknown discovery mode: {token}")); + } + } + } + Ok(config) + } +} + +pub async fn build_endpoint( + secret: Option, + discovery: DiscoveryConfig, +) -> Result { + let relay_mode = relay_mode_from_env().unwrap_or(RelayMode::Default); + let mut builder = Endpoint::empty_builder(relay_mode); + if let Some(secret) = secret { + builder = builder.secret_key(secret); + } + if discovery.dns { + builder = builder + .address_lookup(PkarrPublisher::n0_dns()) + .address_lookup(DnsAddressLookup::n0_dns()); + } + if discovery.dht { + builder = builder.address_lookup(DhtAddressLookup::builder()); + } + if discovery.mdns { + builder = builder.address_lookup(MdnsAddressLookup::builder()); + } + let endpoint = builder.bind().await?; + endpoint.set_alpns(vec![ALPN_MOQ.to_vec()]); + Ok(endpoint) +} + +fn relay_mode_from_env() -> Result { + let value = match env::var("EVERY_CHANNEL_IROH_RELAY") { + Ok(value) => value, + Err(env::VarError::NotPresent) => return Ok(RelayMode::Default), + Err(err) => return Err(err.into()), + }; + match value.trim().to_ascii_lowercase().as_str() { + "" | "default" => Ok(RelayMode::Default), + "disabled" | "off" => Ok(RelayMode::Disabled), + other => Err(anyhow::anyhow!("unknown relay mode: {other}")), + } +} + +pub async fn start_endpoint() -> Result { + let discovery = DiscoveryConfig::from_env()?; + build_endpoint(None, discovery).await +} + +pub fn catalog_topic() -> TopicId { + let hash = blake3::hash(DEFAULT_CATALOG_TOPIC.as_bytes()); + TopicId::from_bytes(*hash.as_bytes()) +} + +pub fn parse_endpoint_addr(value: &str) -> Result { + let value = value.trim(); + if value.starts_with('{') { + let addr = + serde_json::from_str::(value).context("invalid EndpointAddr json")?; + return Ok(addr); + } + let id = value.parse::().context("invalid endpoint id")?; + Ok(EndpointAddr::new(id)) +} + +#[derive(Debug, Clone)] +pub struct MdnsDiscovery { + mdns: MdnsAddressLookup, + endpoint_id: PublicKey, + user_data: Option, +} + +impl MdnsDiscovery { + pub async fn start( + endpoint: &Endpoint, + user_data: Option<&str>, + advertise: bool, + ) -> Result { + let mdns = MdnsAddressLookup::builder() + .advertise(advertise) + .build(endpoint.id()) + .context("mdns address lookup failed")?; + endpoint.address_lookup().add(mdns.clone()); + + let user_data = if let Some(value) = user_data { + let data = UserData::try_from(value.to_string()).context("invalid mdns user data")?; + endpoint.set_user_data_for_address_lookup(Some(data.clone())); + Some(data) + } else { + None + }; + + Ok(Self { + mdns, + endpoint_id: endpoint.id(), + user_data, + }) + } + + pub async fn discover_peers(&self, timeout: Duration) -> Result> { + let mut stream = self.mdns.subscribe().await; + let deadline = Instant::now() + timeout; + let mut peers: BTreeMap = BTreeMap::new(); + + loop { + let now = Instant::now(); + if now >= deadline { + break; + } + let remaining = deadline - now; + match tokio::time::timeout(remaining, stream.next()).await { + Ok(Some(DiscoveryEvent::Discovered { endpoint_info, .. })) => { + if endpoint_info.endpoint_id == self.endpoint_id { + continue; + } + if let Some(expected) = self.user_data.as_ref() { + if endpoint_info.data.user_data() != Some(expected) { + continue; + } + } + let addr = EndpointAddr::from(endpoint_info); + peers.insert(addr.id, addr); + } + Ok(Some(DiscoveryEvent::Expired { .. })) => {} + Ok(None) => break, + Err(_) => break, + } + } + + Ok(peers.into_values().collect()) + } +} + +#[derive(Debug)] +pub struct CatalogGossip { + sender: GossipSender, + receiver: GossipReceiver, + _router: Router, + _gossip: Gossip, + _memory_lookup: MemoryLookup, +} + +impl CatalogGossip { + pub async fn join(endpoint: Endpoint, peers: &[String]) -> Result { + let memory_lookup = MemoryLookup::new(); + endpoint.address_lookup().add(memory_lookup.clone()); + + let gossip = Gossip::builder().spawn(endpoint.clone()); + let router = Router::builder(endpoint.clone()) + .accept(GOSSIP_ALPN, gossip.clone()) + .spawn(); + + let peer_addrs = peers + .iter() + .map(|peer| parse_endpoint_addr(peer)) + .collect::, _>>() + .context("failed to parse gossip peer addr")?; + for peer in &peer_addrs { + memory_lookup.add_endpoint_info(peer.clone()); + } + let peer_ids = peer_addrs + .iter() + .map(|addr| addr.id) + .collect::>(); + + let (sender, receiver) = gossip + .subscribe_and_join(catalog_topic(), peer_ids) + .await? + .split(); + + Ok(Self { + sender, + receiver, + _router: router, + _gossip: gossip, + _memory_lookup: memory_lookup, + }) + } + + pub async fn announce(&mut self, entry: StreamCatalogEntry) -> Result<()> { + let bytes = serde_json::to_vec(&entry)?; + self.sender.broadcast(Bytes::from(bytes)).await?; + Ok(()) + } + + pub async fn next_entry(&mut self) -> Result> { + while let Some(event) = self.receiver.try_next().await? { + if let Event::Received(msg) = event { + if let Ok(entry) = serde_json::from_slice::(&msg.content) { + return Ok(Some(entry)); + } + } + } + Ok(None) + } + + /// Add peers after the gossip topic has already been joined. This enables + /// "nearby" discovery to continuously contribute new peers over time. + pub fn add_peers(&self, peers: Vec) { + for peer in peers { + self._memory_lookup.add_endpoint_info(peer); + } + } +} diff --git a/crates/ec-linux-iptv/Cargo.toml b/crates/ec-linux-iptv/Cargo.toml new file mode 100644 index 0000000..c5b1b54 --- /dev/null +++ b/crates/ec-linux-iptv/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "ec-linux-iptv" +version = "0.0.0" +edition.workspace = true +license.workspace = true + +[dependencies] +anyhow.workspace = true +serde.workspace = true diff --git a/crates/ec-linux-iptv/src/lib.rs b/crates/ec-linux-iptv/src/lib.rs new file mode 100644 index 0000000..0180641 --- /dev/null +++ b/crates/ec-linux-iptv/src/lib.rs @@ -0,0 +1,292 @@ +//! Linux IPTV (LinuxDVB) ingest scaffolding. + +use anyhow::{anyhow, Result}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeSet; +use std::fs; +use std::fs::File; +use std::io::Read; +use std::path::{Path, PathBuf}; +use std::process::Child; + +#[cfg(target_os = "linux")] +use std::{process::Command, time::Duration}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LinuxDvbConfig { + pub adapter: u32, + pub frontend: u32, + pub dvr: u32, + pub tune_command: Option>, + pub tune_timeout_ms: Option, +} + +#[derive(Debug)] +pub struct LinuxDvbStream { + file: File, + _tuner: Option, + pub path: PathBuf, +} + +impl Read for LinuxDvbStream { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + self.file.read(buf) + } +} + +/// Open the Linux DVB DVR device. Optionally spawns a tune command (like dvbv5-zap). +#[cfg(target_os = "linux")] +pub fn open_stream(config: &LinuxDvbConfig) -> Result { + let tuner = if let Some(cmd) = config.tune_command.clone() { + spawn_tune_command(cmd, config.tune_timeout_ms)? + } else { + None + }; + + let path = dvb_path(config.adapter, config.dvr); + let file = + File::open(&path).map_err(|err| anyhow!("failed to open {}: {err}", path.display()))?; + + Ok(LinuxDvbStream { + file, + _tuner: tuner, + path, + }) +} + +#[cfg(not(target_os = "linux"))] +pub fn open_stream(_config: &LinuxDvbConfig) -> Result { + Err(anyhow!("Linux DVB support requires Linux")) +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LinuxDvbAdapterInfo { + pub adapter: u32, + pub dvrs: Vec, + pub frontends: Vec, +} + +pub fn list_adapters() -> Result> { + list_adapters_in(Path::new("/dev/dvb")) +} + +fn list_adapters_in(root: &Path) -> Result> { + if !root.exists() { + return Ok(Vec::new()); + } + + let mut adapters = Vec::new(); + for entry in fs::read_dir(root)? { + let entry = entry?; + if !entry.file_type()?.is_dir() { + continue; + } + let name = entry.file_name(); + let name = name.to_string_lossy(); + if !name.starts_with("adapter") { + continue; + } + let Ok(adapter) = name.trim_start_matches("adapter").parse::() else { + continue; + }; + let path = entry.path(); + let mut dvrs = BTreeSet::new(); + let mut frontends = BTreeSet::new(); + for dev in fs::read_dir(&path)? { + let dev = dev?; + let dev_name = dev.file_name().to_string_lossy().to_string(); + if dev_name.starts_with("dvr") { + if let Ok(idx) = dev_name.trim_start_matches("dvr").parse::() { + dvrs.insert(idx); + } + } else if dev_name.starts_with("frontend") { + if let Ok(idx) = dev_name.trim_start_matches("frontend").parse::() { + frontends.insert(idx); + } + } + } + adapters.push(LinuxDvbAdapterInfo { + adapter, + dvrs: dvrs.into_iter().collect(), + frontends: frontends.into_iter().collect(), + }); + } + + adapters.sort_by_key(|info| info.adapter); + Ok(adapters) +} + +pub fn channels_conf_candidates() -> Vec { + // Prefer an explicit path for determinism and testability. + if let Ok(value) = std::env::var("EVERY_CHANNEL_DVB_CHANNELS_CONF") { + let value = value.trim(); + if !value.is_empty() { + return vec![PathBuf::from(value)]; + } + } + + let home = std::env::var("HOME").ok().map(PathBuf::from); + let mut out = Vec::new(); + if let Some(home) = home { + out.push(home.join(".dvb").join("channels.conf")); + out.push(home.join(".config").join("dvb").join("channels.conf")); + } + out.push(PathBuf::from("/etc/dvb/channels.conf")); + out +} + +pub fn find_channels_conf() -> Option { + for candidate in channels_conf_candidates() { + if candidate.exists() { + return Some(candidate); + } + } + None +} + +pub fn parse_channels_conf(path: &Path) -> Result> { + let text = fs::read_to_string(path) + .map_err(|err| anyhow!("failed to read {}: {err}", path.display()))?; + let mut channels = BTreeSet::new(); + for line in text.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + if let Some((name, _)) = line.split_once(':') { + let name = name.trim(); + if !name.is_empty() { + channels.insert(name.to_string()); + } + } + } + Ok(channels.into_iter().collect()) +} + +pub fn default_zap_tune_command(adapter: u32, channels_conf: &Path, channel: &str) -> Vec { + vec![ + "dvbv5-zap".to_string(), + "-a".to_string(), + adapter.to_string(), + "-c".to_string(), + channels_conf.display().to_string(), + "-r".to_string(), + channel.to_string(), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_channels_conf_extracts_names() { + let dir = std::env::temp_dir().join(format!("ec-channels-{}", std::process::id())); + let _ = fs::create_dir_all(&dir); + let path = dir.join("channels.conf"); + fs::write( + &path, + "\ +# comment +KQED:foo +KQED:duplicate + +KCBS-HD:bar +", + ) + .unwrap(); + + let channels = parse_channels_conf(&path).unwrap(); + assert_eq!(channels, vec!["KCBS-HD".to_string(), "KQED".to_string()]); + let _ = fs::remove_file(&path); + } + + #[test] + fn default_zap_command_contains_adapter_and_channel() { + let conf = Path::new("/tmp/channels.conf"); + let cmd = default_zap_tune_command(2, conf, "KQED"); + assert_eq!(cmd[0], "dvbv5-zap"); + assert!(cmd.iter().any(|arg| arg == "2")); + assert!(cmd.iter().any(|arg| arg == "KQED")); + } + + #[test] + fn find_channels_conf_prefers_env_override() { + let dir = std::env::temp_dir().join(format!("ec-channels-env-{}", std::process::id())); + let _ = fs::create_dir_all(&dir); + let path = dir.join("channels.conf"); + fs::write(&path, "KQED:foo\n").unwrap(); + + let prev = std::env::var("EVERY_CHANNEL_DVB_CHANNELS_CONF").ok(); + std::env::set_var( + "EVERY_CHANNEL_DVB_CHANNELS_CONF", + path.display().to_string(), + ); + let found = find_channels_conf().unwrap(); + assert_eq!(found, path); + + match prev { + Some(value) => std::env::set_var("EVERY_CHANNEL_DVB_CHANNELS_CONF", value), + None => std::env::remove_var("EVERY_CHANNEL_DVB_CHANNELS_CONF"), + } + let _ = fs::remove_file(&path); + } + + #[test] + fn list_adapters_parses_fake_dev_tree() { + let root = std::env::temp_dir().join(format!("ec-dvb-root-{}", std::process::id())); + let _ = fs::remove_dir_all(&root); + fs::create_dir_all(root.join("adapter1")).unwrap(); + fs::create_dir_all(root.join("adapter0")).unwrap(); + fs::write(root.join("adapter0").join("dvr0"), "").unwrap(); + fs::write(root.join("adapter0").join("frontend0"), "").unwrap(); + fs::write(root.join("adapter1").join("dvr2"), "").unwrap(); + fs::write(root.join("adapter1").join("frontend0"), "").unwrap(); + fs::write(root.join("adapter1").join("frontend1"), "").unwrap(); + + let list = list_adapters_in(&root).unwrap(); + assert_eq!(list.len(), 2); + assert_eq!(list[0].adapter, 0); + assert_eq!(list[0].dvrs, vec![0]); + assert_eq!(list[0].frontends, vec![0]); + assert_eq!(list[1].adapter, 1); + assert_eq!(list[1].dvrs, vec![2]); + assert_eq!(list[1].frontends, vec![0, 1]); + + let _ = fs::remove_dir_all(&root); + } +} + +#[cfg(target_os = "linux")] +fn spawn_tune_command(command: Vec, tune_timeout_ms: Option) -> Result> { + if command.is_empty() { + return Ok(None); + } + + let mut cmd = Command::new(&command[0]); + if command.len() > 1 { + cmd.args(&command[1..]); + } + + let child = cmd.spawn()?; + + if let Some(timeout_ms) = tune_timeout_ms { + std::thread::sleep(Duration::from_millis(timeout_ms)); + } + + Ok(Some(child)) +} + +#[cfg(not(target_os = "linux"))] +fn spawn_tune_command( + _command: Vec, + _tune_timeout_ms: Option, +) -> Result> { + Ok(None) +} + +fn dvb_path(adapter: u32, dvr: u32) -> PathBuf { + Path::new("/dev/dvb") + .join(format!("adapter{adapter}")) + .join(format!("dvr{dvr}")) +} diff --git a/crates/ec-moq/Cargo.toml b/crates/ec-moq/Cargo.toml new file mode 100644 index 0000000..a7f4c8c --- /dev/null +++ b/crates/ec-moq/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "ec-moq" +version = "0.0.0" +edition.workspace = true +license.workspace = true + +[dependencies] +anyhow.workspace = true +bytes = "1" +ec-core = { path = "../ec-core" } +ec-iroh = { path = "../ec-iroh" } +iroh = "0.96" +iroh-moq = { path = "../../third_party/iroh-live/iroh-moq" } +moq-lite = "0.10.1" +serde.workspace = true +serde_json.workspace = true +tokio = { version = "1", features = ["sync", "rt", "macros"] } +tracing.workspace = true + +[dev-dependencies] +blake3.workspace = true +ec-crypto = { path = "../ec-crypto" } +hex = "0.4" diff --git a/crates/ec-moq/src/lib.rs b/crates/ec-moq/src/lib.rs new file mode 100644 index 0000000..e198bb6 --- /dev/null +++ b/crates/ec-moq/src/lib.rs @@ -0,0 +1,832 @@ +//! Media over QUIC (MoQ) scaffolding. + +use anyhow::{anyhow, Context, Result}; +use bytes::Bytes; +use ec_core::Manifest; +use ec_iroh::DiscoveryConfig; +use iroh::{protocol::Router, Endpoint, EndpointAddr, SecretKey}; +use moq_lite::{BroadcastConsumer, BroadcastProducer, Group, Track}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::fs; +use std::path::PathBuf; +use std::time::Duration; +use tokio::sync::mpsc; +use tokio::task::JoinHandle; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TrackName { + pub namespace: String, + pub name: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GroupId(pub u64); + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ObjectId(pub u64); + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ObjectMeta { + pub created_unix_ms: u64, + pub content_type: String, + pub size_bytes: u64, + pub timing: Option, + pub encryption: Option, + pub chunk_hash: Option, + pub chunk_hash_alg: Option, + pub chunk_proof: Option>, + pub chunk_proof_alg: Option, + pub manifest_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ObjectPayload { + pub meta: ObjectMeta, + pub data: Vec, +} + +pub const DEFAULT_TRACK_NAME: &str = "chunks"; +pub const DEFAULT_MANIFEST_TRACK_NAME: &str = "manifests"; + +pub trait Publisher { + fn publish_object( + &self, + track: &TrackName, + group: GroupId, + object: ObjectPayload, + ) -> Result<()>; +} + +pub trait Subscriber { + fn subscribe_track(&self, track: &TrackName) -> Result<()>; +} + +pub trait Relay { + fn announce_track(&self, track: &TrackName) -> Result<()>; + fn cache_object(&self, track: &TrackName, group: GroupId, object: ObjectPayload) -> Result<()>; +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TimingMeta { + pub chunk_index: u64, + pub chunk_start_27mhz: u64, + pub chunk_duration_27mhz: u64, + pub utc_start_unix: Option, + pub sync_status: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EncryptionMeta { + pub alg: String, + pub key_id: String, + pub nonce_hex: String, +} + +#[derive(Debug, Clone)] +pub struct FileRelay { + root: PathBuf, +} + +impl FileRelay { + pub fn new(root: impl Into) -> Self { + Self { root: root.into() } + } + + pub fn write_object( + &self, + track: &TrackName, + group: GroupId, + object_id: ObjectId, + object: &ObjectPayload, + ) -> Result<()> { + let base = self.object_dir(track, group, object_id); + fs::create_dir_all(&base) + .with_context(|| format!("failed to create {}", base.display()))?; + + let data_path = base.join("data.bin"); + let meta_path = base.join("meta.json"); + + fs::write(&data_path, &object.data) + .with_context(|| format!("failed to write {}", data_path.display()))?; + fs::write(&meta_path, serde_json::to_vec_pretty(&object.meta)?) + .with_context(|| format!("failed to write {}", meta_path.display()))?; + + Ok(()) + } + + fn object_dir(&self, track: &TrackName, group: GroupId, object_id: ObjectId) -> PathBuf { + let namespace = sanitize_component(&track.namespace); + let name = sanitize_component(&track.name); + self.root + .join(namespace) + .join(name) + .join(format!("group-{}", group.0)) + .join(format!("object-{}", object_id.0)) + } +} + +impl Relay for FileRelay { + fn announce_track(&self, _track: &TrackName) -> Result<()> { + Ok(()) + } + + fn cache_object(&self, track: &TrackName, group: GroupId, object: ObjectPayload) -> Result<()> { + self.write_object(track, group, ObjectId(0), &object) + } +} + +fn sanitize_component(value: &str) -> String { + value + .chars() + .map(|c| match c { + 'a'..='z' | '0'..='9' | '-' | '_' => c, + 'A'..='Z' => c.to_ascii_lowercase(), + _ => '_', + }) + .collect() +} + +pub fn encode_object_frame(meta: &ObjectMeta, data: &[u8]) -> Result> { + let meta_bytes = serde_json::to_vec(meta)?; + let meta_len = u32::try_from(meta_bytes.len()).map_err(|_| anyhow!("object meta too large"))?; + let mut out = Vec::with_capacity(4 + meta_bytes.len() + data.len()); + out.extend_from_slice(&meta_len.to_be_bytes()); + out.extend_from_slice(&meta_bytes); + out.extend_from_slice(data); + Ok(out) +} + +pub fn decode_object_frame(bytes: &[u8]) -> Result { + if bytes.len() < 4 { + return Err(anyhow!("object frame too short")); + } + let meta_len = u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]) as usize; + if bytes.len() < 4 + meta_len { + return Err(anyhow!("object frame missing metadata bytes")); + } + let meta = serde_json::from_slice(&bytes[4..4 + meta_len])?; + let data = bytes[4 + meta_len..].to_vec(); + Ok(ObjectPayload { meta, data }) +} + +pub fn encode_manifest_frame(manifest: &Manifest) -> Result> { + Ok(serde_json::to_vec(manifest)?) +} + +pub fn decode_manifest_frame(bytes: &[u8]) -> Result { + Ok(serde_json::from_slice(bytes)?) +} + +#[derive(Debug)] +pub struct MoqNode { + endpoint: Endpoint, + router: Router, + moq: iroh_moq::Moq, +} + +impl MoqNode { + pub async fn bind(secret: Option) -> Result { + let discovery = DiscoveryConfig::from_env()?; + Self::bind_with_discovery(secret, discovery).await + } + + pub async fn bind_with_discovery( + secret: Option, + discovery: DiscoveryConfig, + ) -> Result { + let endpoint = ec_iroh::build_endpoint(secret, discovery).await?; + let moq = iroh_moq::Moq::new(endpoint.clone()); + let router = Router::builder(endpoint.clone()) + .accept(iroh_moq::ALPN, moq.protocol_handler()) + .spawn(); + Ok(Self { + endpoint, + router, + moq, + }) + } + + pub fn endpoint(&self) -> &Endpoint { + &self.endpoint + } + + pub fn endpoint_addr(&self) -> EndpointAddr { + self.router.endpoint().addr() + } + + pub async fn publish_objects( + &self, + broadcast_name: impl Into, + track_name: impl Into, + ) -> Result { + let broadcast_name = broadcast_name.into(); + let track_name = track_name.into(); + let mut broadcast = BroadcastProducer::default(); + let track = broadcast.create_track(Track { + name: track_name.clone(), + priority: 0, + }); + self.moq + .publish(broadcast_name.clone(), broadcast.clone()) + .await?; + Ok(MoqPublisher { + broadcast_name, + track_name, + broadcast, + track, + }) + } + + /// Publish a broadcast containing multiple tracks, all created before publishing. + /// + /// This avoids subtle issues in some MoQ implementations where tracks added after the + /// initial publish are not reliably deliverable to subscribers. + pub async fn publish_track_set( + &self, + broadcast_name: impl Into, + object_tracks: Vec, + manifest_tracks: Vec, + ) -> Result { + let broadcast_name = broadcast_name.into(); + let mut broadcast = BroadcastProducer::default(); + + let mut object = HashMap::new(); + for name in object_tracks { + let track = broadcast.create_track(Track { + name: name.clone(), + priority: 0, + }); + object.insert(name, track); + } + + let mut manifests = HashMap::new(); + for name in manifest_tracks { + let track = broadcast.create_track(Track { + name: name.clone(), + priority: 0, + }); + manifests.insert(name, track); + } + + self.moq.publish(broadcast_name.clone(), broadcast).await?; + Ok(MoqPublishSet { + broadcast_name, + object, + manifests, + }) + } + + pub async fn subscribe_objects( + &self, + remote: EndpointAddr, + broadcast_name: impl Into, + track_name: impl Into, + ) -> Result { + let broadcast_name = broadcast_name.into(); + let track_name = track_name.into(); + let mut session = self.moq.connect(remote).await?; + let broadcast = session.subscribe(&broadcast_name).await?; + let track = subscribe_track(&broadcast, &track_name)?; + MoqObjectStream::spawn(session, track) + } + + pub async fn subscribe_manifests( + &self, + remote: EndpointAddr, + broadcast_name: impl Into, + track_name: impl Into, + ) -> Result { + let broadcast_name = broadcast_name.into(); + let track_name = track_name.into(); + let mut session = self.moq.connect(remote).await?; + let broadcast = session.subscribe(&broadcast_name).await?; + let track = subscribe_track(&broadcast, &track_name)?; + MoqManifestStream::spawn(session, track) + } +} + +pub struct MoqPublishSet { + broadcast_name: String, + object: HashMap, + manifests: HashMap, +} + +impl MoqPublishSet { + pub fn publish_object( + &mut self, + track_name: &str, + group: GroupId, + object: ObjectPayload, + ) -> Result<()> { + let Some(track) = self.object.get_mut(track_name) else { + return Err(anyhow!("unknown object track {}", track_name)); + }; + let Some(mut group_writer) = track.create_group(Group { sequence: group.0 }) else { + return Err(anyhow!("group {} already published", group.0)); + }; + let frame = encode_object_frame(&object.meta, &object.data)?; + group_writer.write_frame(Bytes::from(frame)); + group_writer.close(); + Ok(()) + } + + pub fn publish_manifest( + &mut self, + track_name: &str, + sequence: u64, + manifest: &Manifest, + ) -> Result<()> { + let Some(track) = self.manifests.get_mut(track_name) else { + return Err(anyhow!("unknown manifest track {}", track_name)); + }; + let Some(mut group_writer) = track.create_group(Group { sequence }) else { + return Err(anyhow!("manifest group {} already published", sequence)); + }; + let frame = encode_manifest_frame(manifest)?; + group_writer.write_frame(Bytes::from(frame)); + group_writer.close(); + Ok(()) + } + + pub fn broadcast_name(&self) -> &str { + &self.broadcast_name + } +} + +pub struct MoqPublisher { + broadcast_name: String, + track_name: String, + broadcast: BroadcastProducer, + track: moq_lite::TrackProducer, +} + +impl MoqPublisher { + pub fn publish_object(&mut self, group: GroupId, object: ObjectPayload) -> Result<()> { + let Some(mut group_writer) = self.track.create_group(Group { sequence: group.0 }) else { + return Err(anyhow!("group {} already published", group.0)); + }; + let frame = encode_object_frame(&object.meta, &object.data)?; + group_writer.write_frame(Bytes::from(frame)); + group_writer.close(); + Ok(()) + } + + pub fn create_side_track(&mut self, track_name: impl Into) -> Result { + let track_name = track_name.into(); + let track = self.broadcast.create_track(Track { + name: track_name.clone(), + priority: 0, + }); + Ok(MoqSidePublisher { track_name, track }) + } + + pub fn create_manifest_track( + &mut self, + track_name: impl Into, + ) -> Result { + let track_name = track_name.into(); + let track = self.broadcast.create_track(Track { + name: track_name.clone(), + priority: 0, + }); + Ok(MoqManifestPublisher { track_name, track }) + } + + pub fn broadcast_name(&self) -> &str { + &self.broadcast_name + } + + pub fn track_name(&self) -> &str { + &self.track_name + } +} + +pub struct MoqSidePublisher { + track_name: String, + track: moq_lite::TrackProducer, +} + +impl MoqSidePublisher { + pub fn publish_object(&mut self, group: GroupId, object: ObjectPayload) -> Result<()> { + let Some(mut group_writer) = self.track.create_group(Group { sequence: group.0 }) else { + return Err(anyhow!("group {} already published", group.0)); + }; + let frame = encode_object_frame(&object.meta, &object.data)?; + group_writer.write_frame(Bytes::from(frame)); + group_writer.close(); + Ok(()) + } + + pub fn track_name(&self) -> &str { + &self.track_name + } +} + +pub struct MoqManifestPublisher { + track_name: String, + track: moq_lite::TrackProducer, +} + +impl MoqManifestPublisher { + pub fn publish_manifest(&mut self, sequence: u64, manifest: &Manifest) -> Result<()> { + let Some(mut group_writer) = self.track.create_group(Group { sequence }) else { + return Err(anyhow!("manifest group {} already published", sequence)); + }; + let frame = encode_manifest_frame(manifest)?; + group_writer.write_frame(Bytes::from(frame)); + group_writer.close(); + Ok(()) + } + + pub fn track_name(&self) -> &str { + &self.track_name + } +} + +pub struct MoqObjectStream { + receiver: mpsc::Receiver, + _task: JoinHandle<()>, + _session: iroh_moq::MoqSession, +} + +impl MoqObjectStream { + fn spawn(session: iroh_moq::MoqSession, mut track: moq_lite::TrackConsumer) -> Result { + let (tx, rx) = mpsc::channel(32); + let task = tokio::spawn(async move { + loop { + let next_group = track.next_group().await; + let Some(mut group) = (match next_group { + Ok(group) => group, + Err(err) => { + tracing::warn!("moq track error: {err:#}"); + break; + } + }) else { + break; + }; + + let mut buffer = Vec::new(); + loop { + match group.read_frame().await { + Ok(Some(frame)) => buffer.extend_from_slice(&frame), + Ok(None) => break, + Err(err) => { + tracing::warn!("moq group error: {err:#}"); + break; + } + } + } + + if buffer.is_empty() { + continue; + } + match decode_object_frame(&buffer) { + Ok(object) => { + if tx.send(object).await.is_err() { + break; + } + } + Err(err) => { + tracing::warn!("failed to decode object frame: {err:#}"); + } + } + } + }); + Ok(Self { + receiver: rx, + _task: task, + _session: session, + }) + } + + pub async fn recv(&mut self) -> Option { + self.receiver.recv().await + } +} + +pub struct MoqManifestStream { + receiver: mpsc::Receiver, + _task: JoinHandle<()>, + _session: iroh_moq::MoqSession, +} + +impl MoqManifestStream { + fn spawn(session: iroh_moq::MoqSession, mut track: moq_lite::TrackConsumer) -> Result { + let (tx, rx) = mpsc::channel(8); + let task = tokio::spawn(async move { + loop { + let next_group = track.next_group().await; + let Some(mut group) = (match next_group { + Ok(group) => group, + Err(err) => { + tracing::warn!("moq manifest track error: {err:#}"); + break; + } + }) else { + break; + }; + + let mut buffer = Vec::new(); + loop { + match group.read_frame().await { + Ok(Some(frame)) => buffer.extend_from_slice(&frame), + Ok(None) => break, + Err(err) => { + tracing::warn!("moq manifest group error: {err:#}"); + break; + } + } + } + + if buffer.is_empty() { + continue; + } + match decode_manifest_frame(&buffer) { + Ok(manifest) => { + if tx.send(manifest).await.is_err() { + break; + } + } + Err(err) => { + tracing::warn!("failed to decode manifest frame: {err:#}"); + } + } + } + }); + Ok(Self { + receiver: rx, + _task: task, + _session: session, + }) + } + + pub async fn recv(&mut self) -> Option { + self.receiver.recv().await + } +} + +fn subscribe_track(broadcast: &BroadcastConsumer, name: &str) -> Result { + let track = broadcast.subscribe_track(&Track::new(name)); + Ok(track) +} + +#[derive(Debug, Clone)] +pub struct HlsWriter { + output_dir: PathBuf, + window: usize, + target_duration: f64, + init_filename: String, + segments: std::collections::VecDeque, +} + +#[derive(Debug, Clone)] +struct HlsSegment { + index: u64, + duration: f64, + filename: String, +} + +impl HlsWriter { + pub fn new_cmaf( + output_dir: impl Into, + target_duration: f64, + window: usize, + ) -> Result { + // CMAF-only writer: init.mp4 + segment_*.m4s + HLS playlist as a local compatibility artifact. + let output_dir = output_dir.into(); + fs::create_dir_all(&output_dir) + .with_context(|| format!("failed to create {}", output_dir.display()))?; + Ok(Self { + output_dir, + window: window.max(1), + target_duration, + init_filename: "init.mp4".to_string(), + segments: std::collections::VecDeque::new(), + }) + } + + pub fn write_init_segment(&mut self, data: &[u8]) -> Result { + let path = self.output_dir.join(&self.init_filename); + fs::write(&path, data).with_context(|| format!("failed to write {}", path.display()))?; + self.write_playlist()?; + Ok(path) + } + + pub fn write_segment(&mut self, index: u64, duration: f64, data: &[u8]) -> Result { + let filename = format!("segment_{index:06}.m4s"); + let path = self.output_dir.join(&filename); + fs::write(&path, data).with_context(|| format!("failed to write {}", path.display()))?; + + self.segments.push_back(HlsSegment { + index, + duration, + filename, + }); + while self.segments.len() > self.window { + self.segments.pop_front(); + } + + self.write_playlist()?; + Ok(path) + } + + fn write_playlist(&self) -> Result<()> { + let mut lines = Vec::new(); + lines.push("#EXTM3U".to_string()); + lines.push("#EXT-X-VERSION:7".to_string()); + lines.push("#EXT-X-INDEPENDENT-SEGMENTS".to_string()); + lines.push(format!("#EXT-X-MAP:URI=\"{}\"", self.init_filename)); + let target = self.target_duration.ceil().max(1.0) as u64; + lines.push(format!("#EXT-X-TARGETDURATION:{target}")); + if let Some(first) = self.segments.front() { + lines.push(format!("#EXT-X-MEDIA-SEQUENCE:{}", first.index)); + } + for seg in &self.segments { + lines.push(format!("#EXTINF:{:.3},", seg.duration)); + lines.push(seg.filename.clone()); + } + let playlist_path = self.output_dir.join("index.m3u8"); + fs::write(&playlist_path, lines.join("\n") + "\n") + .with_context(|| format!("failed to write {}", playlist_path.display()))?; + Ok(()) + } +} + +pub fn chunk_duration_secs(meta: &ObjectMeta, fallback: Duration) -> f64 { + if let Some(timing) = &meta.timing { + let secs = timing.chunk_duration_27mhz as f64 / 27_000_000.0; + if secs > 0.0 { + return secs; + } + } + fallback.as_secs_f64() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::env; + + #[test] + fn sanitize_component_is_stable() { + assert_eq!(sanitize_component("Hello World!"), "hello_world_"); + assert_eq!(sanitize_component("a-b_C9"), "a-b_c9"); + } + + #[test] + fn object_frame_roundtrip() { + let meta = ObjectMeta { + created_unix_ms: 1, + content_type: "application/octet-stream".to_string(), + size_bytes: 3, + timing: Some(TimingMeta { + chunk_index: 7, + chunk_start_27mhz: 0, + chunk_duration_27mhz: 54_000_000, + utc_start_unix: None, + sync_status: "synthetic".to_string(), + }), + encryption: None, + chunk_hash: Some("00".repeat(32)), + chunk_hash_alg: Some("blake3".to_string()), + chunk_proof: Some(vec!["00".repeat(32)]), + chunk_proof_alg: Some("merkle+blake3".to_string()), + manifest_id: Some("m".to_string()), + }; + let data = b"abc".to_vec(); + let frame = encode_object_frame(&meta, &data).unwrap(); + let decoded = decode_object_frame(&frame).unwrap(); + assert_eq!(decoded.data, data); + assert_eq!(decoded.meta.created_unix_ms, meta.created_unix_ms); + assert_eq!( + decoded.meta.timing.as_ref().unwrap().chunk_index, + meta.timing.as_ref().unwrap().chunk_index + ); + assert_eq!(decoded.meta.manifest_id, meta.manifest_id); + } + + #[test] + fn decode_rejects_short_frame() { + assert!(decode_object_frame(&[]).is_err()); + assert!(decode_object_frame(&[0, 0, 0]).is_err()); + } + + #[test] + fn manifest_frame_roundtrip() { + let manifest = ec_core::Manifest { + body: ec_core::ManifestBody { + stream_id: ec_core::StreamId("s".to_string()), + epoch_id: "e".to_string(), + chunk_duration_ms: 2000, + total_chunks: 1, + chunk_start_index: 0, + encoder_profile_id: "p".to_string(), + merkle_root: "00".repeat(32), + created_unix_ms: 1, + metadata: Vec::new(), + chunk_hashes: vec!["11".repeat(32)], + variants: None, + }, + manifest_id: "m".to_string(), + signatures: Vec::new(), + }; + let bytes = encode_manifest_frame(&manifest).unwrap(); + let decoded = decode_manifest_frame(&bytes).unwrap(); + assert_eq!(decoded.manifest_id, "m"); + assert_eq!(decoded.body.epoch_id, "e"); + } + + #[test] + fn manifest_frame_signed_roundtrip_verifies() { + let prev = env::var("EVERY_CHANNEL_MANIFEST_SIGNING_KEY").ok(); + env::set_var("EVERY_CHANNEL_MANIFEST_SIGNING_KEY", "11".repeat(32)); + let keypair = ec_crypto::load_manifest_keypair_from_env() + .expect("load should not error") + .expect("keypair should exist"); + + let chunk_hashes = vec![blake3::hash(b"chunk0").to_hex().to_string()]; + let merkle_root = ec_core::merkle_root_from_hashes(&chunk_hashes).unwrap(); + let body = ec_core::ManifestBody { + stream_id: ec_core::StreamId("s".to_string()), + epoch_id: "e".to_string(), + chunk_duration_ms: 2000, + total_chunks: 1, + chunk_start_index: 0, + encoder_profile_id: "p".to_string(), + merkle_root, + created_unix_ms: 1, + metadata: Vec::new(), + chunk_hashes, + variants: None, + }; + let manifest_id = body.manifest_id().unwrap(); + let sig = ec_crypto::sign_manifest_id(&manifest_id, &keypair); + assert!(ec_crypto::verify_manifest_signature(&manifest_id, &sig)); + + let manifest = ec_core::Manifest { + body, + manifest_id: manifest_id.clone(), + signatures: vec![sig], + }; + let bytes = encode_manifest_frame(&manifest).unwrap(); + let decoded = decode_manifest_frame(&bytes).unwrap(); + assert_eq!(decoded.manifest_id, manifest_id); + assert_eq!(decoded.signatures.len(), 1); + assert!(ec_crypto::verify_manifest_signature( + &decoded.manifest_id, + &decoded.signatures[0] + )); + + match prev { + Some(value) => env::set_var("EVERY_CHANNEL_MANIFEST_SIGNING_KEY", value), + None => env::remove_var("EVERY_CHANNEL_MANIFEST_SIGNING_KEY"), + } + } + + #[test] + fn object_frame_encrypt_decrypt_roundtrip_and_hash_matches_plaintext() { + let stream_id = "ec/stream/v1/source/test/device-a/channel-b"; + let chunk_index = 7u64; + let plaintext = b"hello every.channel"; + let expected_hash = blake3::hash(plaintext).to_hex().to_string(); + let enc = ec_crypto::encrypt_stream_data(stream_id, chunk_index, plaintext, None); + + let meta = ObjectMeta { + created_unix_ms: 1, + content_type: "application/octet-stream".to_string(), + size_bytes: enc.ciphertext.len() as u64, + timing: Some(TimingMeta { + chunk_index, + chunk_start_27mhz: 0, + chunk_duration_27mhz: 54_000_000, + utc_start_unix: None, + sync_status: "synthetic".to_string(), + }), + encryption: Some(EncryptionMeta { + alg: enc.alg.to_string(), + key_id: stream_id.to_string(), + nonce_hex: hex::encode(enc.nonce), + }), + chunk_hash: Some(expected_hash.clone()), + chunk_hash_alg: Some("blake3".to_string()), + chunk_proof: None, + chunk_proof_alg: None, + manifest_id: None, + }; + + let frame = encode_object_frame(&meta, &enc.ciphertext).unwrap(); + let decoded = decode_object_frame(&frame).unwrap(); + let out = ec_crypto::decrypt_stream_data(stream_id, chunk_index, &decoded.data, None) + .expect("decrypt should succeed"); + assert_eq!(out, plaintext); + assert_eq!( + decoded.meta.chunk_hash.as_deref(), + Some(expected_hash.as_str()) + ); + assert_eq!( + blake3::hash(&out).to_hex().to_string(), + decoded.meta.chunk_hash.unwrap() + ); + } +} diff --git a/crates/ec-node/Cargo.toml b/crates/ec-node/Cargo.toml new file mode 100644 index 0000000..5f89eae --- /dev/null +++ b/crates/ec-node/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "ec-node" +version = "0.0.0" +edition.workspace = true +license.workspace = true + +[dependencies] +anyhow.workspace = true +blake3.workspace = true +clap.workspace = true +ec-core = { path = "../ec-core" } +ec-crypto = { path = "../ec-crypto" } +ec-direct = { path = "../ec-direct" } +ec-moq = { path = "../ec-moq" } +ec-chopper = { path = "../ec-chopper" } +ec-hdhomerun = { path = "../ec-hdhomerun" } +ec-iroh = { path = "../ec-iroh" } +ec-linux-iptv = { path = "../ec-linux-iptv" } +hex = "0.4" +iroh = "0.96" +just-webrtc = "0.2" +bytes = "1" +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +urlencoding = "2" +serde.workspace = true +serde_json.workspace = true +tokio = { version = "1", features = ["rt-multi-thread", "macros"] } +tokio-tungstenite = { version = "0.24", default-features = false, features = ["connect", "rustls-tls-webpki-roots"] } +futures-util = "0.3" +tracing.workspace = true +tracing-subscriber.workspace = true + +[dev-dependencies] +headless_chrome = "1" +which = "6" diff --git a/crates/ec-node/src/main.rs b/crates/ec-node/src/main.rs new file mode 100644 index 0000000..a5d2342 --- /dev/null +++ b/crates/ec-node/src/main.rs @@ -0,0 +1,4193 @@ +//! Node runner: orchestrates ingest, chunking, and MoQ publication. + +mod source; + +use anyhow::{anyhow, Context, Result}; +use blake3; +use clap::ValueEnum; +use clap::{Parser, Subcommand}; +use ec_chopper::{build_manifest_body_for_chunks, TsChunk}; +use ec_core::{ + merkle_proof_for_index, verify_merkle_proof, Manifest, ManifestSummary, ManifestVariant, + MoqStreamDescriptor, StreamCatalogEntry, StreamDescriptor, StreamEncryptionInfo, StreamId, + StreamKey, StreamMetadata, +}; +use ec_crypto::{ + decrypt_stream_data, encrypt_stream_data, load_manifest_keypair_from_env, sign_manifest_id, + verify_manifest_signature, ENCRYPTION_ALG, +}; +use ec_direct::{decode_direct_link, encode_direct_link, DirectCodeV1}; +use ec_iroh::DiscoveryConfig; +use ec_moq::{ + chunk_duration_secs, decode_object_frame, encode_object_frame, FileRelay, GroupId, HlsWriter, + MoqNode, MoqPublishSet, ObjectId, ObjectMeta, ObjectPayload, TimingMeta, TrackName, + DEFAULT_MANIFEST_TRACK_NAME, DEFAULT_TRACK_NAME, +}; +use iroh::Watcher; +use just_webrtc::types::{DataChannelOptions, ICEServer, PeerConfiguration, PeerConnectionState}; +use just_webrtc::{DataChannelExt, PeerConnectionBuilder, PeerConnectionExt}; +use source::{HdhrSource, HlsMode, HlsSource, LinuxDvbSource, StreamSource, TsSource}; +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::fs; +use std::fs::File; +use std::future::Future; +use std::io::Read; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::str::FromStr; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; +use tokio_tungstenite::tungstenite::Message as WsMessage; +use futures_util::{SinkExt, StreamExt}; + +const DIRECT_WIRE_TAG_FRAME: u8 = 0x00; +const DIRECT_WIRE_TAG_STREAM: u8 = 0x01; +const DIRECT_WIRE_TAG_PING: u8 = 0x02; +const DIRECT_HEARTBEAT_TIMEOUT: Duration = Duration::from_secs(8); +// Conservatively under typical SCTP data channel max message sizes. +const DIRECT_WIRE_CHUNK_BYTES: usize = 16 * 1024; +use tokio::sync::mpsc; +use tokio::sync::RwLock; + +#[derive(Parser, Debug)] +#[command(name = "ec-node")] +#[command(about = "every.channel node runner", long_about = None)] +struct Cli { + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand, Debug)] +enum Commands { + /// Ingest a source and publish MoQ objects into a local relay directory. + Ingest(IngestArgs), + /// Ingest a source and publish via MoQ over iroh. + MoqPublish(MoqPublishArgs), + /// Subscribe to a MoQ stream and write HLS segments locally. + MoqSubscribe(MoqSubscribeArgs), + /// Publish and subscribe to a MoQ stream locally, verifying chunk hashes. + MoqSelftest(MoqSelftestArgs), + /// Publish a stream over a direct WebRTC data channel (manual copy/paste connect code). + DirectPublish(DirectPublishArgs), + /// Subscribe to a direct WebRTC stream (directory or offer link) and optionally capture an .mp4 proof. + DirectSubscribe(DirectSubscribeArgs), + /// Publish a stream to the global one-to-many relay (`/api/stream/ws`) and keep the directory entry live. + WsPublish(WsPublishArgs), + /// Subscribe to the global one-to-many relay (`/api/stream/ws`) and capture CMAF fragments + an mp4 proof. + WsSubscribe(WsSubscribeArgs), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +enum EncodeMode { + /// Publish/subscribe CMAF-style fragmented MP4 segments (HLS fMP4) encoded with x264/AAC. + Cmaf, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +enum CmafLadderPreset { + /// 3-rung ladder: 1080p@6000k, 720p@3000k, 480p@1200k (CBR-ish). + Hd3, +} + +#[derive(Parser, Debug)] +struct IngestArgs { + /// Output directory for temporary chunks. + #[arg(long, default_value = "./tmp/chunks")] + chunk_dir: PathBuf, + /// Relay directory to write MoQ objects. + #[arg(long, default_value = "./tmp/relay")] + relay_dir: PathBuf, + /// Chunk duration in ms. + #[arg(long, default_value_t = 2000)] + chunk_ms: u64, + /// Maximum number of chunks to write. + #[arg(long)] + max_chunks: Option, + /// Optional stream id override. + #[arg(long)] + stream_id: Option, + /// Optional network secret (hex) for stream encryption. + #[arg(long)] + network_secret: Option, + /// Enable deterministic transcode before chunking. + #[arg(long)] + deterministic: bool, + #[command(subcommand)] + source: IngestSource, +} + +#[derive(Parser, Debug)] +struct MoqPublishArgs { + /// Output directory for temporary chunks. + #[arg(long, default_value = "./tmp/chunks")] + chunk_dir: PathBuf, + /// Chunk duration in ms. + #[arg(long, default_value_t = 2000)] + chunk_ms: u64, + /// Maximum number of chunks to write. + #[arg(long)] + max_chunks: Option, + /// Optional stream id override. + #[arg(long)] + stream_id: Option, + /// Optional network secret (hex) for stream encryption. + #[arg(long)] + network_secret: Option, + /// Enable deterministic transcode before chunking. + #[arg(long)] + deterministic: bool, + /// Broadcast name override (defaults to stream id). + #[arg(long)] + broadcast_name: Option, + /// Track name override. + #[arg(long, default_value = DEFAULT_TRACK_NAME)] + track_name: String, + /// Publish chunk objects on the main track. + #[arg(long, default_value_t = true, action = clap::ArgAction::Set)] + publish_chunks: bool, + /// Publish manifests alongside chunks. + #[arg(long)] + publish_manifests: bool, + /// Track name for manifest frames. + #[arg(long, default_value = DEFAULT_MANIFEST_TRACK_NAME)] + manifest_track: String, + /// Number of chunks per manifest epoch. + #[arg(long, default_value_t = 1)] + epoch_chunks: usize, + /// Optional iroh secret key (hex). + #[arg(long)] + iroh_secret: Option, + /// Discovery modes to enable (comma-separated: dht, mdns, dns). + #[arg(long)] + discovery: Option, + /// Announce catalog entries over iroh-gossip (requires peers). + #[arg(long)] + announce: bool, + /// Gossip peers to connect to (repeatable). + #[arg(long)] + gossip_peer: Vec, + /// Optional startup delay (ms) after binding/publishing tracks, before ingest begins. + /// Useful for E2E tests that need time to connect subscribers. + #[arg(long)] + startup_delay_ms: Option, + /// Encoding/container mode. + #[arg(long, value_enum, default_value_t = EncodeMode::Cmaf)] + encode: EncodeMode, + /// Track name for CMAF init segment objects. + #[arg(long, default_value = "init")] + init_track: String, + /// Publish a CMAF ladder (multiple quality variants) using x264/AAC. + #[arg(long, value_enum)] + cmaf_ladder: Option, + #[command(subcommand)] + source: IngestSource, +} + +#[derive(Parser, Debug)] +struct MoqSubscribeArgs { + /// Output directory for HLS segments. + #[arg(long, default_value = "./tmp/moq-hls")] + output_dir: PathBuf, + /// Fallback chunk duration in ms. + #[arg(long, default_value_t = 2000)] + chunk_ms: u64, + /// HLS window size. + #[arg(long, default_value_t = 6)] + window: usize, + /// Optional stream id override (for decryption). + #[arg(long)] + stream_id: Option, + /// Optional network secret (hex) for stream decryption. + #[arg(long)] + network_secret: Option, + /// Remote endpoint address (iroh EndpointAddr). + #[arg(long)] + remote: String, + /// Optional remote endpoint address to fetch manifests from. + /// Defaults to `--remote` when not provided. + #[arg(long)] + remote_manifests: Option, + /// Broadcast name to subscribe to. + #[arg(long)] + broadcast_name: String, + /// Track name to subscribe to. + #[arg(long, default_value = DEFAULT_TRACK_NAME)] + track_name: String, + /// Subscribe to manifest frames. + #[arg(long)] + subscribe_manifests: bool, + /// Require a manifest to accept chunk data. + #[arg(long)] + require_manifest: bool, + /// Track name for manifest frames. + #[arg(long, default_value = DEFAULT_MANIFEST_TRACK_NAME)] + manifest_track: String, + /// Allowed manifest signer ids (comma-separated). + #[arg(long)] + manifest_signers: Option, + /// Maximum bytes per second to accept (anti-junk). + #[arg(long)] + max_bytes_per_sec: Option, + /// Maximum burst bytes before throttling. + #[arg(long)] + max_bytes_burst: Option, + /// Maximum invalid chunks before disconnect. + #[arg(long, default_value_t = 1)] + max_invalid_chunks: u32, + /// Stop after writing this many segments (useful for E2E tests). + #[arg(long)] + stop_after: Option, + /// Optional iroh secret key (hex). + #[arg(long)] + iroh_secret: Option, + /// Discovery modes to enable (comma-separated: dht, mdns, dns). + #[arg(long)] + discovery: Option, + /// Container mode for local HLS output. + #[arg(long, value_enum, default_value_t = EncodeMode::Cmaf)] + container: EncodeMode, + /// Track name to subscribe to for CMAF init segment objects. + #[arg(long, default_value = "init")] + init_track: String, + /// Subscribe to the init segment track (CMAF). + #[arg(long)] + subscribe_init: bool, + /// Write raw CMAF init+segments (no HLS playlist) to `--output-dir`. + #[arg(long)] + raw_cmaf: bool, +} + +#[derive(Parser, Debug)] +struct MoqSelftestArgs { + /// Input TS file or URL (e.g. http://HDHR_HOST/auto/v8.1). + input: String, + /// Output directory for temporary chunks. + #[arg(long, default_value = "./tmp/moq-selftest")] + chunk_dir: PathBuf, + /// Chunk duration in ms. + #[arg(long, default_value_t = 2000)] + chunk_ms: u64, + /// Maximum number of chunks to publish/verify. + #[arg(long, default_value_t = 8)] + max_chunks: usize, + /// Optional stream id override. + #[arg(long)] + stream_id: Option, + /// Track name override. + #[arg(long, default_value = DEFAULT_TRACK_NAME)] + track_name: String, + /// Discovery modes to enable (comma-separated: dht, mdns, dns). + #[arg(long)] + discovery: Option, +} + +#[derive(Parser, Debug)] +struct DirectPublishArgs { + /// Global stream id (used for directory/signaling). If omitted, a fresh id is generated. + #[arg(long)] + stream_id: Option, + /// Human-friendly title (used for directory listing). + #[arg(long, default_value = "Live channel")] + title: String, + /// Optional directory base URL (e.g. https://every.channel). When set, the publisher + /// announces the offer to `/api/announce` and polls `/api/answer`. + #[arg(long)] + directory_url: Option, + /// Offer TTL (ms) when announcing to the directory. + #[arg(long, default_value_t = 20000)] + announce_ttl_ms: u64, + /// Output directory for temporary chunks. + #[arg(long, default_value = "./tmp/direct-chunks")] + chunk_dir: PathBuf, + /// Chunk duration in ms. + #[arg(long, default_value_t = 2000)] + chunk_ms: u64, + /// Maximum number of media segments to send. + #[arg(long, default_value_t = 30)] + max_segments: usize, + /// Optional answer code/link to avoid interactive stdin. + #[arg(long)] + answer: Option, + /// How long to wait for a browser answer when using `--directory-url` or stdin (seconds). + /// Set to 0 to wait indefinitely. + #[arg(long, default_value_t = 900)] + answer_timeout_secs: u64, + /// Ingest source. + #[command(subcommand)] + source: IngestSource, +} + +#[derive(Parser, Debug)] +struct DirectSubscribeArgs { + /// Directory base URL (e.g. https://every.channel). Used to locate offers by stream_id + /// and to POST answers back to the publisher. + #[arg(long, default_value = "https://every.channel")] + directory_url: String, + /// Stream id to locate in the directory. + #[arg(long)] + stream_id: Option, + /// Direct offer link/code to use instead of looking up a stream in the directory. + #[arg(long)] + offer: Option, + /// Output directory for captured CMAF fragments (init+segments) and index.m3u8. + #[arg(long, default_value = "./tmp/direct-subscribe")] + out_dir: PathBuf, + /// Maximum number of media segments to capture (init not counted). + #[arg(long, default_value_t = 12)] + max_segments: usize, + /// Optional time limit (seconds). When set, capture stops after this duration even if `--max-segments` is not reached. + #[arg(long)] + duration_secs: Option, + /// If set, remux the captured playlist to this mp4 path (best-effort, `ffmpeg -c copy`). + #[arg(long)] + mp4: Option, +} + +#[derive(Parser, Debug)] +struct WsPublishArgs { + /// Global stream id (used for directory listing + relay instance key). If omitted, a fresh id is generated. + #[arg(long)] + stream_id: Option, + /// Human-friendly title (used for directory listing). + #[arg(long, default_value = "Live channel")] + title: String, + /// Directory base URL (e.g. https://every.channel). Used for listing at `/api/announce`, + /// and as the base for relay websocket URL (`/api/stream/ws`). + #[arg(long, default_value = "https://every.channel")] + directory_url: String, + /// Offer TTL (ms) when announcing to the directory. + #[arg(long, default_value_t = 20000)] + announce_ttl_ms: u64, + /// Output directory for temporary chunks. + #[arg(long, default_value = "./tmp/ws-chunks")] + chunk_dir: PathBuf, + /// Chunk duration in ms. + #[arg(long, default_value_t = 2000)] + chunk_ms: u64, + /// Maximum number of media segments to send (init not counted). Set high for "run forever" behavior. + #[arg(long, default_value_t = 1_000_000)] + max_segments: usize, + /// Ingest source. + #[command(subcommand)] + source: IngestSource, +} + +#[derive(Parser, Debug)] +struct WsSubscribeArgs { + /// Directory base URL (e.g. https://every.channel). Used for relay websocket URL (`/api/stream/ws`). + #[arg(long, default_value = "https://every.channel")] + directory_url: String, + /// Stream id to subscribe to. + #[arg(long)] + stream_id: String, + /// Output directory for captured CMAF fragments (init+segments) and index.m3u8. + #[arg(long, default_value = "./tmp/ws-subscribe")] + out_dir: PathBuf, + /// Maximum number of media segments to capture (init not counted). + #[arg(long, default_value_t = 12)] + max_segments: usize, + /// Optional time limit (seconds). When set, capture stops after this duration even if `--max-segments` is not reached. + #[arg(long)] + duration_secs: Option, + /// If set, remux the captured playlist to this mp4 path (best-effort, `ffmpeg -c copy`). + #[arg(long)] + mp4: Option, +} + +#[derive(Subcommand, Debug)] +enum IngestSource { + /// Ingest from an HDHomeRun device. + Hdhr { + /// Hostname or IP (e.g. 192.168.1.10). If omitted, auto-discover. + #[arg(long)] + host: Option, + /// Device ID (uses .local when host is omitted). + #[arg(long)] + device_id: Option, + /// Channel number (e.g. 8.1). + #[arg(long)] + channel: Option, + /// Channel name (e.g. KQED). + #[arg(long)] + name: Option, + /// Prefer mDNS (hdhomerun.local) before UDP discovery. + #[arg(long)] + prefer_mdns: bool, + }, + /// Ingest from an HLS playlist URL. + Hls { + /// HLS playlist URL. + url: String, + /// Ingest mode (passthrough, remux, transcode). + #[arg(long, value_enum, default_value_t = HlsMode::Passthrough)] + mode: HlsMode, + }, + /// Ingest from a Linux DVB device. + LinuxDvb { + /// DVB adapter index. + #[arg(long, default_value_t = 0)] + adapter: u32, + /// DVR device index. + #[arg(long, default_value_t = 0)] + dvr: u32, + /// Optional tune command (repeat for each arg). + #[arg(long)] + tune_cmd: Vec, + /// Optional tune wait (ms). + #[arg(long)] + tune_wait_ms: Option, + }, + /// Ingest from a raw TS file or URL. + Ts { + /// Input TS file or URL. + input: String, + }, +} + +fn main() -> Result<()> { + // Keep stdout reserved for machine-readable output (endpoint addr, etc). + tracing_subscriber::fmt() + .with_writer(std::io::stderr) + .init(); + let cli = Cli::parse(); + + match cli.command { + Commands::Ingest(args) => ingest(args)?, + Commands::MoqPublish(args) => run_async(moq_publish(args))?, + Commands::MoqSubscribe(args) => run_async(moq_subscribe(args))?, + Commands::MoqSelftest(args) => run_async(moq_selftest(args))?, + Commands::DirectPublish(args) => run_async(direct_publish(args))?, + Commands::DirectSubscribe(args) => run_async(direct_subscribe(args))?, + Commands::WsPublish(args) => run_async(ws_publish(args))?, + Commands::WsSubscribe(args) => run_async(ws_subscribe(args))?, + } + + Ok(()) +} + +fn run_async(future: F) -> Result<()> +where + F: Future>, +{ + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build()?; + runtime.block_on(future) +} + +fn ingest(args: IngestArgs) -> Result<()> { + fs::create_dir_all(&args.chunk_dir) + .with_context(|| format!("failed to create {}", args.chunk_dir.display()))?; + + let deterministic = deterministic_enabled(args.deterministic); + let (source, _needs_transcode): (Box, bool) = match args.source { + IngestSource::Hls { url, mut mode } => { + if deterministic { + mode = HlsMode::Transcode; + } + (Box::new(HlsSource { url, mode }), false) + } + IngestSource::Hdhr { + host, + device_id, + channel, + name, + prefer_mdns, + } => ( + Box::new(HdhrSource { + host, + device_id, + channel, + name, + prefer_mdns, + }), + deterministic, + ), + IngestSource::LinuxDvb { + adapter, + dvr, + tune_cmd, + tune_wait_ms, + } => ( + Box::new(LinuxDvbSource { + adapter, + dvr, + tune_cmd, + tune_wait_ms, + }), + deterministic, + ), + IngestSource::Ts { input } => (Box::new(TsSource { input }), deterministic), + }; + + let source_id = source.source_id(); + let source_id_for_stream = source_id.clone(); + let reader = source.open_stream()?; + let encoder_profile_id = if deterministic { + "deterministic-h264-aac".to_string() + } else { + // NOTE: We still normalize into CMAF for interoperability, even when determinism is off. + "h264-aac".to_string() + }; + + // CMAF-only: segment into init.mp4 + segment_*.m4s via ffmpeg. + let out_dir = args.chunk_dir.join("cmaf"); + let (init_path, segments) = chunk_stream_cmaf_ffmpeg( + reader, + &out_dir, + args.chunk_ms, + args.max_chunks.unwrap_or(usize::MAX), + deterministic, + )?; + let mut chunk_hashes = Vec::with_capacity(1 + segments.len()); + // Chunk index 0 is always init.mp4; segments are 1..N. + let (init_bytes, init_hash) = read_chunk_bytes_and_hash(&init_path)?; + chunk_hashes.push(init_hash); + let mut segment_meta = Vec::with_capacity(segments.len()); + for seg_path in &segments { + let (bytes, hash) = read_chunk_bytes_and_hash(seg_path)?; + chunk_hashes.push(hash.clone()); + segment_meta.push((seg_path.clone(), bytes, hash)); + } + let chunk_start_index = 0u64; + let created_unix_ms = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; + let relay = FileRelay::new(args.relay_dir); + let track = TrackName { + namespace: "every.channel".to_string(), + name: args.stream_id.unwrap_or_else(|| { + StreamKey { + version: 1, + broadcast: None, + source: Some(source_id_for_stream), + profile: Some(format!("chunk-{}ms", args.chunk_ms)), + variant: None, + } + .to_stream_id() + .0 + }), + }; + let stream_id = StreamId(track.name.clone()); + let manifest_payload = build_manifest( + stream_id, + format!("epoch-{created_unix_ms}"), + args.chunk_ms, + chunk_start_index, + encoder_profile_id, + created_unix_ms, + vec![StreamMetadata { + key: "source_kind".to_string(), + value: source_id.kind.clone(), + }], + chunk_hashes, + )?; + let manifest_id = manifest_payload.manifest_id.clone(); + let manifest_path = args.chunk_dir.join("manifest.json"); + fs::write( + &manifest_path, + serde_json::to_vec_pretty(&manifest_payload)?, + )?; + + let network_secret = parse_network_secret(args.network_secret)?; + + // Publish init at chunk_index 0 to avoid colliding with segment_000000. + publish_chunk_file( + &relay, + &track, + TsChunk { + index: 0, + path: init_path, + timing: ec_chopper::ChunkTiming { + chunk_index: 0, + chunk_start_27mhz: None, + chunk_duration_27mhz: 0, + utc_start_unix: None, + sync_status: "init".to_string(), + }, + }, + "video/mp4", + Some(init_bytes), + network_secret.as_deref(), + Some(&manifest_id), + )?; + for (i, (seg_path, bytes, _hash)) in segment_meta.into_iter().enumerate() { + let chunk_index = (i as u64) + 1; + publish_chunk_file( + &relay, + &track, + TsChunk { + index: chunk_index, + path: seg_path, + timing: ec_chopper::ChunkTiming { + chunk_index, + chunk_start_27mhz: None, + chunk_duration_27mhz: args.chunk_ms * 27_000, + utc_start_unix: None, + sync_status: "cmaf".to_string(), + }, + }, + "video/iso.segment", + Some(bytes), + network_secret.as_deref(), + Some(&manifest_id), + )?; + } + + Ok(()) +} + +fn publish_chunk_file( + relay: &FileRelay, + track: &TrackName, + chunk: TsChunk, + content_type: &str, + data_override: Option>, + network_secret: Option<&[u8]>, + manifest_id: Option<&str>, +) -> Result<()> { + let (data, chunk_hash) = match data_override { + Some(bytes) => { + let hash = blake3::hash(&bytes).to_hex().to_string(); + (bytes, hash) + } + None => read_chunk_bytes_and_hash(&chunk.path)?, + }; + let object = build_object( + chunk, + data, + chunk_hash, + None, + network_secret, + manifest_id, + content_type, + &track.name, + )?; + relay.write_object( + track, + GroupId( + object + .meta + .timing + .as_ref() + .map(|t| t.chunk_index) + .unwrap_or(0), + ), + ObjectId( + object + .meta + .timing + .as_ref() + .map(|t| t.chunk_index) + .unwrap_or(0), + ), + &object, + ) +} + +fn chunk_stream_cmaf_ffmpeg( + mut reader: Box, + out_dir: &std::path::Path, + chunk_ms: u64, + max_segments: usize, + deterministic: bool, +) -> Result<(std::path::PathBuf, Vec)> { + let _ = fs::remove_dir_all(out_dir); + fs::create_dir_all(out_dir) + .with_context(|| format!("failed to create {}", out_dir.display()))?; + + let profile = if deterministic { + Some(ec_chopper::deterministic_h264_profile()) + } else { + // For now, keep encoder args stable even when determinism is off. + Some(ec_chopper::deterministic_h264_profile()) + }; + + let mut cmd = Command::new("ffmpeg"); + cmd.current_dir(out_dir); + cmd.arg("-hide_banner") + .arg("-loglevel") + .arg("error") + .arg("-nostdin") + .arg("-y") + .arg("-i") + .arg("pipe:0") + // Keep stream mapping predictable. + .arg("-map") + .arg("0:v:0") + .arg("-map") + .arg("0:a:0?") + .arg("-sn") + .arg("-dn") + .arg("-map_metadata") + .arg("-1") + // Reduce opportunities for non-deterministic scheduling in filters/decoders. + .arg("-filter_threads") + .arg("1") + .arg("-filter_complex_threads") + .arg("1") + .arg("-threads") + .arg("1"); + + if let Some(profile) = profile { + for arg in ec_chopper::ffmpeg_profile_args(&profile) { + cmd.arg(arg); + } + } + + let seg_time = format!("{:.3}", chunk_ms as f64 / 1000.0); + cmd.arg("-f") + .arg("hls") + .arg("-hls_time") + .arg(seg_time) + .arg("-hls_list_size") + .arg("0") + .arg("-hls_segment_type") + .arg("fmp4") + .arg("-hls_flags") + .arg("independent_segments") + .arg("-hls_fmp4_init_filename") + .arg("init.mp4") + .arg("-hls_segment_filename") + .arg("segment_%06d.m4s") + .arg("index.m3u8") + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::inherit()); + + let mut child = cmd.spawn().with_context(|| "failed to spawn ffmpeg")?; + let mut stdin = child + .stdin + .take() + .ok_or_else(|| anyhow!("ffmpeg stdin unavailable"))?; + let writer = std::thread::spawn(move || -> Result<()> { + std::io::copy(&mut reader, &mut stdin)?; + Ok(()) + }); + + let init_path = out_dir.join("init.mp4"); + wait_for_stable_file(&init_path, Duration::from_secs(20))?; + + let mut segments = Vec::new(); + for i in 0..max_segments { + let seg_path = out_dir.join(format!("segment_{i:06}.m4s")); + match wait_for_stable_file(&seg_path, Duration::from_secs(30)) { + Ok(()) => segments.push(seg_path), + Err(err) => { + // If ffmpeg ended cleanly, stop; otherwise bubble the error. + if let Ok(Some(status)) = child.try_wait() { + if status.success() { + break; + } + return Err(anyhow!("ffmpeg exited with {status} ({err:#})")); + } + return Err(err); + } + } + } + + let _ = child.kill(); + let _ = child.wait(); + let _ = writer.join(); + + Ok((init_path, segments)) +} + +fn parse_network_secret(value: Option) -> Result>> { + let value = value.or_else(|| std::env::var("EVERY_CHANNEL_NETWORK_SECRET").ok()); + let Some(value) = value else { return Ok(None) }; + let bytes = hex::decode(value).context("network secret must be hex")?; + Ok(Some(bytes)) +} + +fn deterministic_enabled(flag: bool) -> bool { + if flag { + return true; + } + std::env::var("EVERY_CHANNEL_DETERMINISTIC") + .ok() + .map(|value| { + let value = value.trim().to_ascii_lowercase(); + value == "1" || value == "true" || value == "yes" || value == "on" + }) + .unwrap_or(false) +} + +fn read_chunk_bytes_and_hash(path: &std::path::Path) -> Result<(Vec, String)> { + let data = fs::read(path).with_context(|| format!("failed to read {}", path.display()))?; + let hash = blake3::hash(&data).to_hex().to_string(); + Ok((data, hash)) +} + +fn build_manifest( + stream_id: StreamId, + epoch_id: impl Into, + chunk_duration_ms: u64, + chunk_start_index: u64, + encoder_profile_id: impl Into, + created_unix_ms: u64, + metadata: Vec, + chunk_hashes: Vec, +) -> Result { + let body = build_manifest_body_for_chunks( + stream_id, + epoch_id, + chunk_duration_ms, + chunk_start_index, + encoder_profile_id, + created_unix_ms, + metadata, + &chunk_hashes, + )?; + let manifest_id = body.manifest_id()?; + let mut signatures = Vec::new(); + if let Some(keypair) = load_manifest_keypair_from_env().map_err(|err| anyhow!(err))? { + signatures.push(sign_manifest_id(&manifest_id, &keypair)); + } + Ok(Manifest { + body, + manifest_id, + signatures, + }) +} + +#[derive(Debug, Clone)] +struct CmafVariantSpec { + id: String, + width: u32, + height: u32, + video_bitrate_kbps: u32, +} + +fn cmaf_ladder_variants(preset: CmafLadderPreset) -> Vec { + match preset { + CmafLadderPreset::Hd3 => vec![ + CmafVariantSpec { + id: "1080p".to_string(), + width: 1920, + height: 1080, + video_bitrate_kbps: 6000, + }, + CmafVariantSpec { + id: "720p".to_string(), + width: 1280, + height: 720, + video_bitrate_kbps: 3000, + }, + CmafVariantSpec { + id: "480p".to_string(), + width: 854, + height: 480, + video_bitrate_kbps: 1200, + }, + ], + } +} + +fn sanitize_component(value: &str) -> String { + value + .chars() + .map(|c| match c { + 'a'..='z' | '0'..='9' | '-' | '_' | '/' => c, + 'A'..='Z' => c.to_ascii_lowercase(), + _ => '_', + }) + .collect() +} + +fn derive_variant_stream_id(base_stream_id: &str, variant_id: &str) -> String { + // Match StreamKey encoding style (`variant-...`) without requiring we reconstruct StreamKey. + let v = sanitize_component(variant_id); + format!("{}/variant-{}", base_stream_id.trim_end_matches('/'), v) +} + +fn build_multi_variant_manifest( + base_stream_id: StreamId, + epoch_id: String, + chunk_duration_ms: u64, + chunk_start_index: u64, + encoder_profile_id: String, + created_unix_ms: u64, + metadata: Vec, + variants: &[CmafVariantSpec], + variant_chunk_start_index: u64, + per_variant_hash: Vec<(String, String)>, +) -> Result { + let mut entries = Vec::new(); + for variant in variants { + let Some((_, hash)) = per_variant_hash.iter().find(|(id, _)| id == &variant.id) else { + return Err(anyhow!("missing hash for variant {}", variant.id)); + }; + let chunk_hashes = vec![hash.clone()]; + let merkle_root = + ec_core::merkle_root_from_hashes(&chunk_hashes).map_err(|err| anyhow!("{err}"))?; + let stream_id = StreamId(derive_variant_stream_id(&base_stream_id.0, &variant.id)); + entries.push(ManifestVariant { + variant_id: variant.id.clone(), + stream_id, + chunk_start_index: variant_chunk_start_index, + total_chunks: 1, + merkle_root, + chunk_hashes, + metadata: vec![ + StreamMetadata { + key: "width".to_string(), + value: variant.width.to_string(), + }, + StreamMetadata { + key: "height".to_string(), + value: variant.height.to_string(), + }, + StreamMetadata { + key: "video_bitrate_kbps".to_string(), + value: variant.video_bitrate_kbps.to_string(), + }, + ], + }); + } + entries.sort_by(|a, b| a.variant_id.cmp(&b.variant_id)); + let roots = entries + .iter() + .map(|v| v.merkle_root.clone()) + .collect::>(); + let body_root = ec_core::merkle_root_from_hashes(&roots).map_err(|err| anyhow!("{err}"))?; + let body = ec_core::ManifestBody { + stream_id: base_stream_id, + epoch_id, + chunk_duration_ms, + total_chunks: 1, + chunk_start_index, + encoder_profile_id, + merkle_root: body_root, + created_unix_ms, + metadata, + chunk_hashes: Vec::new(), + variants: Some(entries), + }; + let manifest_id = body.manifest_id()?; + let mut signatures = Vec::new(); + if let Some(keypair) = load_manifest_keypair_from_env().map_err(|err| anyhow!(err))? { + signatures.push(sign_manifest_id(&manifest_id, &keypair)); + } + Ok(Manifest { + body, + manifest_id, + signatures, + }) +} + +struct EpochBuffer { + capacity: usize, + chunks: Vec, + data: Vec>>, + hashes: Vec, + start_index: Option, +} + +impl EpochBuffer { + fn new(capacity: usize) -> Self { + Self { + capacity: capacity.max(1), + chunks: Vec::new(), + data: Vec::new(), + hashes: Vec::new(), + start_index: None, + } + } + + fn push(&mut self, chunk: TsChunk, data: Option>, hash: String) { + if self.start_index.is_none() { + self.start_index = Some(chunk.timing.chunk_index); + } + self.chunks.push(chunk); + self.data.push(data); + self.hashes.push(hash); + } + + fn is_full(&self) -> bool { + self.chunks.len() >= self.capacity + } + + fn is_empty(&self) -> bool { + self.chunks.is_empty() + } + + fn start_index(&self) -> u64 { + self.start_index.unwrap_or(0) + } + + fn take(&mut self) -> (Vec, Vec>>, Vec) { + self.start_index = None; + let chunks = std::mem::take(&mut self.chunks); + let data = std::mem::take(&mut self.data); + let hashes = std::mem::take(&mut self.hashes); + (chunks, data, hashes) + } +} + +fn parse_manifest_allowlist(value: Option<&str>) -> Option> { + let value = value?.trim(); + if value.is_empty() { + return None; + } + let set = value + .split(|c: char| c == ',' || c == ';' || c.is_whitespace()) + .filter_map(|token| { + let trimmed = token.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + }) + .collect::>(); + if set.is_empty() { + None + } else { + Some(set) + } +} + +fn validate_manifest(manifest: &Manifest, allowlist: Option<&HashSet>) -> bool { + let body_id = match manifest.body.manifest_id() { + Ok(id) => id, + Err(_) => return false, + }; + if body_id != manifest.manifest_id { + return false; + } + if let Some(variants) = manifest.body.variants.as_ref().filter(|v| !v.is_empty()) { + // Multi-variant: validate each variant and ensure body merkle_root commits to per-variant roots. + let mut roots = Vec::with_capacity(variants.len()); + for variant in variants { + if variant.chunk_hashes.len() != variant.total_chunks as usize { + return false; + } + match ec_core::merkle_root_from_hashes(&variant.chunk_hashes) { + Ok(root) if root == variant.merkle_root => {} + _ => return false, + } + roots.push((variant.variant_id.clone(), variant.merkle_root.clone())); + } + roots.sort_by(|a, b| a.0.cmp(&b.0)); + let ordered = roots.into_iter().map(|(_, root)| root).collect::>(); + match ec_core::merkle_root_from_hashes(&ordered) { + Ok(root) if root == manifest.body.merkle_root => {} + _ => return false, + } + } else if !manifest.body.chunk_hashes.is_empty() { + if manifest.body.chunk_hashes.len() != manifest.body.total_chunks as usize { + return false; + } + match ec_core::merkle_root_from_hashes(&manifest.body.chunk_hashes) { + Ok(root) if root == manifest.body.merkle_root => {} + _ => return false, + } + } + + if let Some(allowlist) = allowlist { + return manifest.signatures.iter().any(|sig| { + verify_manifest_signature(&manifest.manifest_id, sig) + && allowlist.contains(&sig.signer_id) + }); + } + + if manifest.signatures.is_empty() { + // Unsigned manifests are only acceptable when they include full hashes for verification. + return !manifest.body.chunk_hashes.is_empty() + || manifest + .body + .variants + .as_ref() + .is_some_and(|v| !v.is_empty()); + } + manifest + .signatures + .iter() + .any(|sig| verify_manifest_signature(&manifest.manifest_id, sig)) +} + +fn strip_init_suffix(key_id: &str) -> &str { + key_id.strip_suffix("/init").unwrap_or(key_id) +} + +fn manifest_hash_for_chunk( + manifest: &Manifest, + stream_id: &str, + chunk_index: u64, +) -> Option { + if let Some(variants) = manifest.body.variants.as_ref() { + let variant = variants.iter().find(|v| v.stream_id.0 == stream_id)?; + if chunk_index < variant.chunk_start_index { + return None; + } + let offset = (chunk_index - variant.chunk_start_index) as usize; + return variant.chunk_hashes.get(offset).cloned(); + } + + // Legacy single-variant manifests. + if manifest.body.stream_id.0 != stream_id { + return None; + } + if chunk_index < manifest.body.chunk_start_index { + return None; + } + let offset = (chunk_index - manifest.body.chunk_start_index) as usize; + manifest.body.chunk_hashes.get(offset).cloned() +} + +fn manifest_covers_stream_index(manifest: &Manifest, stream_id: &str, chunk_index: u64) -> bool { + if let Some(variants) = manifest.body.variants.as_ref() { + let Some(variant) = variants.iter().find(|v| v.stream_id.0 == stream_id) else { + return false; + }; + if chunk_index < variant.chunk_start_index { + return false; + } + let end = variant + .chunk_start_index + .saturating_add(variant.total_chunks as u64); + return chunk_index < end; + } + + if manifest.body.stream_id.0 != stream_id { + return false; + } + if chunk_index < manifest.body.chunk_start_index { + return false; + } + let end = manifest + .body + .chunk_start_index + .saturating_add(manifest.body.total_chunks as u64); + chunk_index < end +} + +fn find_manifest_for_stream_index( + store: &HashMap, + stream_id: &str, + chunk_index: u64, +) -> Option { + // Prefer the latest manifest whose range covers the index for this stream. + let mut best: Option<&Manifest> = None; + for manifest in store.values() { + if !manifest_covers_stream_index(manifest, stream_id, chunk_index) { + continue; + } + match best { + None => best = Some(manifest), + Some(current) => { + if manifest.body.chunk_start_index >= current.body.chunk_start_index { + best = Some(manifest); + } + } + } + } + best.cloned() +} + +fn build_object( + chunk: TsChunk, + data: Vec, + chunk_hash: String, + chunk_proof: Option>, + network_secret: Option<&[u8]>, + manifest_id: Option<&str>, + content_type: &str, + key_id: &str, +) -> Result { + let created_unix_ms = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; + + let timing = TimingMeta { + chunk_index: chunk.timing.chunk_index, + chunk_start_27mhz: chunk.timing.chunk_start_27mhz.unwrap_or(0), + chunk_duration_27mhz: chunk.timing.chunk_duration_27mhz, + utc_start_unix: chunk.timing.utc_start_unix, + sync_status: chunk.timing.sync_status.clone(), + }; + + let encrypted = encrypt_stream_data(key_id, chunk.timing.chunk_index, &data, network_secret); + let meta = ObjectMeta { + created_unix_ms, + content_type: content_type.to_string(), + size_bytes: encrypted.ciphertext.len() as u64, + timing: Some(timing), + encryption: Some(ec_moq::EncryptionMeta { + alg: encrypted.alg.to_string(), + key_id: key_id.to_string(), + nonce_hex: hex::encode(encrypted.nonce), + }), + chunk_hash: Some(chunk_hash), + chunk_hash_alg: Some("blake3".to_string()), + chunk_proof, + chunk_proof_alg: Some("merkle+blake3".to_string()), + manifest_id: manifest_id.map(|value| value.to_string()), + }; + + Ok(ObjectPayload { + meta, + data: encrypted.ciphertext, + }) +} + +fn flush_epoch_publish( + publish_set: &mut MoqPublishSet, + object_track_name: &str, + manifest_track_name: &str, + publish_chunks: bool, + publish_manifests: bool, + epoch_buffer: &mut EpochBuffer, + stream_id_value: &StreamId, + chunk_ms: u64, + encoder_profile_id: &str, + source_kind: &str, + network_secret: Option<&[u8]>, + segment_content_type: &str, + key_id: &str, + object_sequence: &mut u64, + manifest_sequence: &mut u64, + announce_tx: Option<&tokio::sync::mpsc::UnboundedSender>, +) -> Result<()> { + if epoch_buffer.is_empty() { + return Ok(()); + } + + let (chunks, datas, hashes) = epoch_buffer.take(); + let start_index = chunks + .first() + .map(|chunk| chunk.timing.chunk_index) + .unwrap_or_else(|| epoch_buffer.start_index()); + let created_unix_ms = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; + + let mut manifest_id = None; + if publish_manifests { + let manifest = build_manifest( + stream_id_value.clone(), + format!("epoch-{start_index}"), + chunk_ms, + start_index, + encoder_profile_id.to_string(), + created_unix_ms, + vec![StreamMetadata { + key: "source_kind".to_string(), + value: source_kind.to_string(), + }], + hashes.clone(), + )?; + manifest_id = Some(manifest.manifest_id.clone()); + publish_set.publish_manifest(manifest_track_name, *manifest_sequence, &manifest)?; + *manifest_sequence += 1; + if let Some(tx) = announce_tx { + let _ = tx.send(manifest.summary()); + } + } + + // Compute per-chunk Merkle proofs so subscribers can validate membership + // even if future manifests omit the full chunk hash list. + let mut proofs = Vec::with_capacity(hashes.len()); + for (offset, _) in hashes.iter().enumerate() { + proofs.push(merkle_proof_for_index(&hashes, offset)?); + } + + if publish_chunks { + for (((chunk, data), hash), proof) in chunks.into_iter().zip(datas).zip(hashes).zip(proofs) + { + let Some(data) = data else { + return Err(anyhow!("missing chunk data for publish")); + }; + let object = build_object( + chunk, + data, + hash, + Some(proof), + network_secret, + manifest_id.as_deref(), + segment_content_type, + key_id, + )?; + tracing::info!( + "publish segment chunk_index={} bytes={} content_type={}", + object + .meta + .timing + .as_ref() + .map(|t| t.chunk_index) + .unwrap_or(0), + object.data.len(), + object.meta.content_type + ); + publish_set.publish_object(object_track_name, GroupId(*object_sequence), object)?; + *object_sequence += 1; + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_manifest_allowlist_splits_and_trims() { + let set = parse_manifest_allowlist(Some(" a,b ; c\t d ")).unwrap(); + assert!(set.contains("a")); + assert!(set.contains("b")); + assert!(set.contains("c")); + assert!(set.contains("d")); + } + + #[test] + fn deterministic_enabled_reads_env() { + let prev = std::env::var("EVERY_CHANNEL_DETERMINISTIC").ok(); + std::env::set_var("EVERY_CHANNEL_DETERMINISTIC", "true"); + assert!(deterministic_enabled(false)); + std::env::set_var("EVERY_CHANNEL_DETERMINISTIC", "0"); + assert!(!deterministic_enabled(false)); + match prev { + Some(value) => std::env::set_var("EVERY_CHANNEL_DETERMINISTIC", value), + None => std::env::remove_var("EVERY_CHANNEL_DETERMINISTIC"), + } + } + + #[test] + fn parse_network_secret_accepts_hex_and_rejects_invalid() { + let out = parse_network_secret(Some("00".repeat(8))).unwrap().unwrap(); + assert_eq!(out.len(), 8); + assert!(parse_network_secret(Some("not-hex".to_string())).is_err()); + } + + fn build_valid_manifest(unsigned: bool) -> Manifest { + let chunk_hashes = vec![ + blake3::hash(b"c0").to_hex().to_string(), + blake3::hash(b"c1").to_hex().to_string(), + ]; + let body = build_manifest_body_for_chunks( + StreamId("s".to_string()), + "epoch-1", + 2000, + 10, + "p", + 1, + Vec::new(), + &chunk_hashes, + ) + .unwrap(); + let manifest_id = body.manifest_id().unwrap(); + let signatures = if unsigned { + Vec::new() + } else { + let prev = std::env::var("EVERY_CHANNEL_MANIFEST_SIGNING_KEY").ok(); + std::env::set_var("EVERY_CHANNEL_MANIFEST_SIGNING_KEY", "11".repeat(32)); + let keypair = load_manifest_keypair_from_env().unwrap().unwrap(); + let sig = sign_manifest_id(&manifest_id, &keypair); + match prev { + Some(value) => std::env::set_var("EVERY_CHANNEL_MANIFEST_SIGNING_KEY", value), + None => std::env::remove_var("EVERY_CHANNEL_MANIFEST_SIGNING_KEY"), + } + vec![sig] + }; + + Manifest { + body, + manifest_id, + signatures, + } + } + + #[test] + fn validate_manifest_accepts_unsigned_only_with_hashes() { + let mut manifest = build_valid_manifest(true); + assert!(validate_manifest(&manifest, None)); + + // Remove hashes: unsigned should be rejected. + manifest.body.chunk_hashes.clear(); + manifest.body.total_chunks = 0; + manifest.body.merkle_root = "00".repeat(32); + manifest.manifest_id = manifest.body.manifest_id().unwrap(); + assert!(!validate_manifest(&manifest, None)); + } + + #[test] + fn validate_manifest_accepts_signed_and_obeys_allowlist() { + let manifest = build_valid_manifest(false); + assert!(validate_manifest(&manifest, None)); + let signer = manifest.signatures[0].signer_id.clone(); + let allow = HashSet::from([signer.clone()]); + assert!(validate_manifest(&manifest, Some(&allow))); + let deny = HashSet::from(["other".to_string()]); + assert!(!validate_manifest(&manifest, Some(&deny))); + } + + #[test] + fn manifest_hash_for_chunk_indexes_into_hash_list() { + let manifest = build_valid_manifest(true); + let sid = manifest.body.stream_id.0.as_str(); + assert!(manifest_hash_for_chunk(&manifest, sid, 9).is_none()); + assert_eq!( + manifest_hash_for_chunk(&manifest, sid, 10).as_deref(), + Some(manifest.body.chunk_hashes[0].as_str()) + ); + assert_eq!( + manifest_hash_for_chunk(&manifest, sid, 11).as_deref(), + Some(manifest.body.chunk_hashes[1].as_str()) + ); + assert!(manifest_hash_for_chunk(&manifest, sid, 12).is_none()); + } +} + +async fn moq_publish(args: MoqPublishArgs) -> Result<()> { + fs::create_dir_all(&args.chunk_dir) + .with_context(|| format!("failed to create {}", args.chunk_dir.display()))?; + + let deterministic = deterministic_enabled(args.deterministic); + let (source, _needs_transcode): (Box, bool) = match args.source { + IngestSource::Hls { url, mut mode } => { + if deterministic { + mode = HlsMode::Transcode; + } + (Box::new(HlsSource { url, mode }), false) + } + IngestSource::Hdhr { + host, + device_id, + channel, + name, + prefer_mdns, + } => ( + Box::new(HdhrSource { + host, + device_id, + channel, + name, + prefer_mdns, + }), + deterministic, + ), + IngestSource::LinuxDvb { + adapter, + dvr, + tune_cmd, + tune_wait_ms, + } => ( + Box::new(LinuxDvbSource { + adapter, + dvr, + tune_cmd, + tune_wait_ms, + }), + deterministic, + ), + IngestSource::Ts { input } => (Box::new(TsSource { input }), deterministic), + }; + + let source_id = source.source_id(); + let source_id_for_stream = source_id.clone(); + + let stream_id = args.stream_id.unwrap_or_else(|| { + StreamKey { + version: 1, + broadcast: None, + source: Some(source_id_for_stream), + profile: Some(format!("chunk-{}ms", args.chunk_ms)), + variant: None, + } + .to_stream_id() + .0 + }); + + let broadcast_name = args.broadcast_name.unwrap_or_else(|| stream_id.clone()); + let track_name = args.track_name.clone(); + + let secret = parse_iroh_secret(args.iroh_secret)?; + let discovery = parse_discovery(args.discovery.as_deref())?; + let node = MoqNode::bind_with_discovery(secret, discovery).await?; + + // Wait briefly for direct addresses so "remote" can connect without needing discovery. + let mut endpoint_addr = node.endpoint_addr(); + if endpoint_addr.addrs.is_empty() { + let mut watcher = node.endpoint().watch_addr(); + let start = Instant::now(); + while endpoint_addr.addrs.is_empty() && start.elapsed() < Duration::from_secs(3) { + tokio::time::sleep(Duration::from_millis(200)).await; + endpoint_addr = watcher.get(); + } + } + + println!("moq endpoint id: {}", node.endpoint().id()); + if let Ok(addr_json) = serde_json::to_string(&endpoint_addr) { + println!("moq endpoint addr: {}", addr_json); + } + println!("moq broadcast: {}", broadcast_name); + println!("moq track: {}", track_name); + + let publish_chunks = args.publish_chunks; + let ladder = args.cmaf_ladder; + let ladder_variants = ladder.map(cmaf_ladder_variants); + + let mut object_tracks = Vec::new(); + if let Some(variants) = ladder_variants.as_ref() { + if !publish_chunks { + return Err(anyhow!("--cmaf-ladder requires --publish-chunks true")); + } + for variant in variants { + object_tracks.push(format!("{}/{}", track_name, variant.id)); + object_tracks.push(format!("{}/{}", args.init_track, variant.id)); + } + } else { + object_tracks.push(track_name.clone()); + if publish_chunks { + object_tracks.push(args.init_track.clone()); + } + } + let mut manifest_tracks = Vec::new(); + if args.publish_manifests { + manifest_tracks.push(args.manifest_track.clone()); + } + let mut publish_set = node + .publish_track_set(&broadcast_name, object_tracks, manifest_tracks) + .await?; + + if let Some(ms) = args.startup_delay_ms { + tokio::time::sleep(Duration::from_millis(ms)).await; + } + + let network_secret = parse_network_secret(args.network_secret)?; + + let track = TrackName { + namespace: "every.channel".to_string(), + name: stream_id.clone(), + }; + let stream_id_value = StreamId(track.name.clone()); + let source_kind = source_id.kind.clone(); + let encoder_profile_id = "deterministic-h264-aac".to_string(); + + if let Some(variants) = ladder_variants { + if args.epoch_chunks != 1 { + return Err(anyhow!("--cmaf-ladder currently requires --epoch-chunks 1")); + } + if !args.publish_manifests { + return Err(anyhow!( + "--cmaf-ladder currently requires --publish-manifests" + )); + } + + #[derive(Debug)] + enum PendingPublish { + Object { + track: String, + group: u64, + object: ObjectPayload, + }, + Manifest { + track: String, + sequence: u64, + manifest: Manifest, + }, + } + + let (tx, mut rx) = mpsc::channel::(16); + let chunk_ms = args.chunk_ms; + let max_chunks = args.max_chunks.unwrap_or(usize::MAX); + let out_dir = args.chunk_dir.join("cmaf-ladder"); + let init_track_prefix = args.init_track.clone(); + let chunk_track_prefix = track_name.clone(); + let manifest_track = args.manifest_track.clone(); + let publish_manifests = args.publish_manifests; + let source_kind = source_id.kind.clone(); + let base_stream_id = stream_id.clone(); + let network_secret_bytes = network_secret.clone(); + let startup_delay_ms = args.startup_delay_ms; + + let chunk_task = tokio::task::spawn_blocking(move || -> Result<()> { + let _ = fs::remove_dir_all(&out_dir); + fs::create_dir_all(&out_dir) + .with_context(|| format!("failed to create {}", out_dir.display()))?; + for variant in &variants { + fs::create_dir_all(out_dir.join(&variant.id))?; + } + + let mut cmd = Command::new("ffmpeg"); + cmd.current_dir(&out_dir); + cmd.arg("-hide_banner") + .arg("-loglevel") + .arg("error") + .arg("-nostdin") + .arg("-y") + .arg("-i") + .arg("pipe:0") + // Reduce opportunities for non-deterministic scheduling in filters/decoders. + .arg("-filter_threads") + .arg("1") + .arg("-filter_complex_threads") + .arg("1") + .arg("-threads") + .arg("1") + .arg("-map") + .arg("0:v:0") + .arg("-map") + .arg("0:a:0?") + .arg("-sn") + .arg("-dn") + .arg("-map_metadata") + .arg("-1"); + + // Build a split+scale filter graph for all variants. + // NOTE: We keep it simple: split video N ways, then scale each output. + let mut filter = String::new(); + filter.push_str(&format!("[0:v]split={}", variants.len())); + for (i, _) in variants.iter().enumerate() { + filter.push_str(&format!("[v{i}]")); + } + filter.push(';'); + for (i, variant) in variants.iter().enumerate() { + // Scale flags influence quality but should be deterministic. + filter.push_str(&format!( + "[v{i}]scale=w={}:h={}:flags=bicubic[v{i}o];", + variant.width, variant.height + )); + } + cmd.arg("-filter_complex").arg(filter); + + let seg_time = format!("{:.3}", chunk_ms as f64 / 1000.0); + for (i, variant) in variants.iter().enumerate() { + let v_bitrate = format!("{}k", variant.video_bitrate_kbps); + let bufsize = format!("{}k", variant.video_bitrate_kbps.saturating_mul(2)); + let out_variant_dir = out_dir.join(&variant.id); + let seg_template = out_variant_dir.join("segment_%06d.m4s"); + let seg_template = seg_template + .to_str() + .ok_or_else(|| anyhow!("invalid segment template path"))? + .to_string(); + + cmd.arg("-map") + .arg(format!("[v{i}o]")) + .arg("-map") + .arg("0:a:0?") + .arg("-c:v") + .arg("libx264") + // Force keyframes aligned to segment boundaries (chunk_ms). + .arg("-force_key_frames") + .arg(format!( + "expr:gte(t,n_forced*{:.3})", + chunk_ms as f64 / 1000.0 + )) + .arg("-b:v") + .arg(&v_bitrate) + .arg("-minrate") + .arg(&v_bitrate) + .arg("-maxrate") + .arg(&v_bitrate) + .arg("-bufsize") + .arg(&bufsize) + .arg("-c:a") + .arg("aac") + .arg("-b:a") + .arg("128k") + .arg("-ac") + .arg("2") + .arg("-ar") + .arg("48000") + .arg("-pix_fmt") + .arg("yuv420p") + .arg("-g") + .arg("60") + .arg("-keyint_min") + .arg("60") + .arg("-sc_threshold") + .arg("0") + .arg("-bf") + .arg("0") + .arg("-threads") + .arg("1") + .arg("-fflags") + .arg("+bitexact") + .arg("-flags:v") + .arg("+bitexact") + .arg("-flags:a") + .arg("+bitexact") + .arg("-f") + .arg("hls") + .arg("-hls_time") + .arg(&seg_time) + .arg("-hls_list_size") + .arg("0") + .arg("-hls_segment_type") + .arg("fmp4") + .arg("-hls_flags") + .arg("independent_segments") + .arg("-hls_fmp4_init_filename") + .arg("init.mp4") + .arg("-hls_segment_filename") + .arg(seg_template) + .arg(out_variant_dir.join("index.m3u8")); + } + + cmd.stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::inherit()); + + let mut child = cmd.spawn().with_context(|| "failed to spawn ffmpeg")?; + let mut stdin = child + .stdin + .take() + .ok_or_else(|| anyhow!("ffmpeg stdin unavailable"))?; + let mut reader = source.open_stream()?; + let writer = std::thread::spawn(move || -> Result<()> { + std::io::copy(&mut reader, &mut stdin)?; + Ok(()) + }); + + // Optional startup delay to allow subscribers to connect. + if let Some(ms) = startup_delay_ms { + std::thread::sleep(Duration::from_millis(ms)); + } + + // Publish init per variant as soon as they exist. + for variant in &variants { + let init_path = out_dir.join(&variant.id).join("init.mp4"); + wait_for_stable_file(&init_path, Duration::from_secs(20))?; + let data = fs::read(&init_path)?; + let hash = blake3::hash(&data).to_hex().to_string(); + let key_id = format!( + "{}/init", + derive_variant_stream_id(&base_stream_id, &variant.id) + ); + let object = build_object( + TsChunk { + index: 0, + path: init_path, + timing: ec_chopper::ChunkTiming { + chunk_index: 0, + chunk_start_27mhz: None, + chunk_duration_27mhz: 0, + utc_start_unix: None, + sync_status: "init".to_string(), + }, + }, + data, + hash, + None, + network_secret_bytes.as_deref(), + None, + "video/mp4", + &key_id, + )?; + tx.blocking_send(PendingPublish::Object { + track: format!("{}/{}", init_track_prefix, variant.id), + group: 0, + object, + }) + .map_err(|_| anyhow!("publish channel closed"))?; + } + + let mut manifest_seq: u64 = 0; + for index in 0..max_chunks { + // Wait for each variant's segment for this index. + let mut per_variant_segments = Vec::with_capacity(variants.len()); + let mut per_variant_hashes = Vec::with_capacity(variants.len()); + for variant in &variants { + let seg_path = out_dir + .join(&variant.id) + .join(format!("segment_{index:06}.m4s")); + wait_for_stable_file(&seg_path, Duration::from_secs(30))?; + let data = fs::read(&seg_path)?; + let hash = blake3::hash(&data).to_hex().to_string(); + per_variant_segments.push((variant, seg_path, data, hash.clone())); + per_variant_hashes.push((variant, hash)); + } + + let created_unix_ms = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; + let epoch_id = format!("epoch-{created_unix_ms}"); + + let manifest = if publish_manifests { + build_multi_variant_manifest( + StreamId(base_stream_id.clone()), + epoch_id, + chunk_ms, + index as u64, + encoder_profile_id.clone(), + created_unix_ms, + vec![StreamMetadata { + key: "source_kind".to_string(), + value: source_kind.clone(), + }], + &variants, + index as u64, + per_variant_hashes + .iter() + .map(|(v, h)| (v.id.clone(), h.clone())) + .collect(), + )? + } else { + // Still build an unsigned manifest for internal linkage if desired. + build_multi_variant_manifest( + StreamId(base_stream_id.clone()), + epoch_id, + chunk_ms, + index as u64, + encoder_profile_id.clone(), + created_unix_ms, + vec![StreamMetadata { + key: "source_kind".to_string(), + value: source_kind.clone(), + }], + &variants, + index as u64, + per_variant_hashes + .iter() + .map(|(v, h)| (v.id.clone(), h.clone())) + .collect(), + )? + }; + + tx.blocking_send(PendingPublish::Manifest { + track: manifest_track.clone(), + sequence: manifest_seq, + manifest: manifest.clone(), + }) + .map_err(|_| anyhow!("publish channel closed"))?; + manifest_seq += 1; + + // Publish segment objects for each variant, linked to the manifest. + for (variant, seg_path, data, hash) in per_variant_segments { + let key_id = derive_variant_stream_id(&base_stream_id, &variant.id); + let chunk = TsChunk { + index: index as u64, + path: seg_path, + timing: ec_chopper::ChunkTiming { + chunk_index: index as u64, + chunk_start_27mhz: None, + chunk_duration_27mhz: chunk_ms * 27_000, + utc_start_unix: None, + sync_status: "cmaf".to_string(), + }, + }; + let object = build_object( + chunk, + data, + hash, + None, + network_secret_bytes.as_deref(), + Some(&manifest.manifest_id), + "video/iso.segment", + &key_id, + )?; + tx.blocking_send(PendingPublish::Object { + track: format!("{}/{}", chunk_track_prefix, variant.id), + group: (index as u64) + 1, + object, + }) + .map_err(|_| anyhow!("publish channel closed"))?; + } + } + + let _ = child.kill(); + let _ = child.wait(); + let _ = writer.join(); + Ok(()) + }); + + while let Some(item) = rx.recv().await { + match item { + PendingPublish::Object { + track, + group, + object, + } => { + publish_set.publish_object(&track, GroupId(group), object)?; + } + PendingPublish::Manifest { + track, + sequence, + manifest, + } => { + publish_set.publish_manifest(&track, sequence, &manifest)?; + } + } + } + + chunk_task + .await + .map_err(|err| anyhow!("chunk task join error: {err}"))??; + + return Ok(()); + } + let needs_init_track = publish_chunks; + let mut epoch_buffer = EpochBuffer::new(args.epoch_chunks); + // Some early MoQ implementations have surprising assumptions around group sequence ids. + // In CMAF mode we reserve group 0 for the init segment on the init track and start + // segment groups at 1 to avoid any potential cross-track collisions. + let mut object_sequence: u64 = if needs_init_track { 1 } else { 0 }; + let mut manifest_sequence: u64 = 0; + + let announce_tx = if args.announce { + Some( + spawn_catalog_announcer( + &node, + &track, + &broadcast_name, + &track_name, + args.gossip_peer.clone(), + ) + .await?, + ) + } else { + None + }; + + #[derive(Debug)] + enum PendingKind { + Init, + Segment, + } + + #[derive(Debug)] + struct PendingChunk { + kind: PendingKind, + chunk: TsChunk, + data: Option>, + hash: String, + } + + let segment_content_type = "video/iso.segment"; + + // Chunking is CPU and IO heavy and must not block the async runtime. + // We do ingest + chunking on a blocking thread and feed finalized chunks + // to the async publisher over a channel. + let (tx, mut rx) = mpsc::channel::(8); + let chunk_dir = args.chunk_dir.clone(); + let chunk_ms = args.chunk_ms; + let max_chunks = args.max_chunks; + let chunk_task = tokio::task::spawn_blocking(move || -> Result<()> { + let out_dir = chunk_dir.join("cmaf"); + let _ = fs::remove_dir_all(&out_dir); + fs::create_dir_all(&out_dir) + .with_context(|| format!("failed to create {}", out_dir.display()))?; + + let profile = ec_chopper::deterministic_h264_profile(); + let mut cmd = std::process::Command::new("ffmpeg"); + cmd.current_dir(&out_dir); + cmd.arg("-hide_banner") + .arg("-loglevel") + .arg("error") + .arg("-nostdin") + .arg("-y") + .arg("-i") + .arg("pipe:0"); + + // Reduce non-determinism and "surprise streams" (data/subtitles, extra audio). + // We intentionally pick the first video stream and (optionally) the first audio stream. + cmd.arg("-map").arg("0:v:0"); + cmd.arg("-map").arg("0:a:0?"); + cmd.arg("-sn").arg("-dn"); + cmd.arg("-map_metadata").arg("-1"); + + for arg in ec_chopper::ffmpeg_profile_args(&profile) { + cmd.arg(arg); + } + let seg_time = format!("{:.3}", chunk_ms as f64 / 1000.0); + let seg_template = "segment_%06d.m4s".to_string(); + let init_filename = "init.mp4".to_string(); + let playlist = "index.m3u8".to_string(); + + cmd.arg("-f") + .arg("hls") + .arg("-hls_time") + .arg(seg_time) + .arg("-hls_list_size") + .arg("0") + .arg("-hls_segment_type") + .arg("fmp4") + .arg("-hls_flags") + .arg("independent_segments") + .arg("-hls_fmp4_init_filename") + .arg(init_filename) + .arg("-hls_segment_filename") + .arg(seg_template) + .arg(playlist) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::inherit()); + + let mut child = cmd + .spawn() + .with_context(|| "failed to spawn ffmpeg".to_string())?; + let mut stdin = child + .stdin + .take() + .ok_or_else(|| anyhow!("ffmpeg stdin unavailable"))?; + let mut reader = source.open_stream()?; + let writer = std::thread::spawn(move || -> Result<()> { + std::io::copy(&mut reader, &mut stdin)?; + Ok(()) + }); + + let init_path = out_dir.join("init.mp4"); + if publish_chunks { + wait_for_stable_file(&init_path, Duration::from_secs(10))?; + let data = fs::read(&init_path)?; + let hash = blake3::hash(&data).to_hex().to_string(); + let chunk = TsChunk { + index: 0, + path: init_path.clone(), + timing: ec_chopper::ChunkTiming { + chunk_index: 0, + chunk_start_27mhz: None, + chunk_duration_27mhz: 0, + utc_start_unix: None, + sync_status: "init".to_string(), + }, + }; + tx.blocking_send(PendingChunk { + kind: PendingKind::Init, + chunk, + data: Some(data), + hash, + }) + .map_err(|_| anyhow!("publish channel closed"))?; + } + + let limit = max_chunks.unwrap_or(usize::MAX); + for index in 0..limit { + let seg_path = out_dir.join(format!("segment_{index:06}.m4s")); + // If the segment never appears and ffmpeg exited, we are done (short input). + // If the segment never appears and ffmpeg is still running, treat as error. + match wait_for_stable_file(&seg_path, Duration::from_secs(20)) { + Ok(()) => {} + Err(err) => { + if let Ok(Some(status)) = child.try_wait() { + if status.success() { + break; + } + return Err(anyhow!("ffmpeg exited with {status} ({err:#})")); + } + return Err(err); + } + } + let data = if publish_chunks { + Some(fs::read(&seg_path)?) + } else { + None + }; + let hash = if let Some(ref bytes) = data { + blake3::hash(bytes).to_hex().to_string() + } else { + ec_chopper::hash_file_blake3(&seg_path)? + }; + + let chunk = TsChunk { + index: index as u64, + path: seg_path.clone(), + timing: ec_chopper::ChunkTiming { + chunk_index: index as u64, + chunk_start_27mhz: None, + chunk_duration_27mhz: chunk_ms * 27_000, + utc_start_unix: None, + sync_status: "cmaf".to_string(), + }, + }; + + tx.blocking_send(PendingChunk { + kind: PendingKind::Segment, + chunk, + data, + hash, + }) + .map_err(|_| anyhow!("publish channel closed"))?; + } + + let _ = child.kill(); + let _ = child.wait(); + let _ = writer.join(); + Ok(()) + }); + + while let Some(pending) = rx.recv().await { + match pending.kind { + PendingKind::Init => { + let Some(data) = pending.data else { + continue; + }; + let key_id = format!("{}/init", track.name); + let object = build_object( + pending.chunk, + data, + pending.hash, + None, + network_secret.as_deref(), + None, + "video/mp4", + &key_id, + )?; + tracing::info!( + "publish init bytes={} track={}", + object.data.len(), + args.init_track + ); + publish_set.publish_object(&args.init_track, GroupId(0), object)?; + } + PendingKind::Segment => { + epoch_buffer.push(pending.chunk, pending.data, pending.hash); + if epoch_buffer.is_full() { + flush_epoch_publish( + &mut publish_set, + &track_name, + &args.manifest_track, + publish_chunks, + args.publish_manifests, + &mut epoch_buffer, + &stream_id_value, + args.chunk_ms, + &encoder_profile_id, + &source_kind, + network_secret.as_deref(), + segment_content_type, + &track.name, + &mut object_sequence, + &mut manifest_sequence, + announce_tx.as_ref(), + )?; + } + } + } + } + + // Ensure chunking completed successfully. + chunk_task + .await + .map_err(|err| anyhow!("chunk task join error: {err}"))??; + + flush_epoch_publish( + &mut publish_set, + &track_name, + &args.manifest_track, + publish_chunks, + args.publish_manifests, + &mut epoch_buffer, + &stream_id_value, + args.chunk_ms, + &encoder_profile_id, + &source_kind, + network_secret.as_deref(), + segment_content_type, + &track.name, + &mut object_sequence, + &mut manifest_sequence, + announce_tx.as_ref(), + )?; + + Ok(()) +} + +async fn moq_subscribe(args: MoqSubscribeArgs) -> Result<()> { + if args.require_manifest && !args.subscribe_manifests { + return Err(anyhow!("--require-manifest requires --subscribe-manifests")); + } + let secret = parse_iroh_secret(args.iroh_secret)?; + let discovery = parse_discovery(args.discovery.as_deref())?; + let node = MoqNode::bind_with_discovery(secret, discovery).await?; + let remote = ec_iroh::parse_endpoint_addr(&args.remote)?; + let remote_manifests = if let Some(value) = args.remote_manifests.as_deref() { + ec_iroh::parse_endpoint_addr(value)? + } else { + remote.clone() + }; + let mut stream = node + .subscribe_objects(remote.clone(), &args.broadcast_name, &args.track_name) + .await?; + + let manifest_allowlist = parse_manifest_allowlist(args.manifest_signers.as_deref()); + let manifest_store = if args.subscribe_manifests || args.require_manifest { + let mut manifest_stream = node + .subscribe_manifests(remote_manifests, &args.broadcast_name, &args.manifest_track) + .await?; + let store = Arc::new(RwLock::new(HashMap::new())); + let store_clone = Arc::clone(&store); + let allowlist = manifest_allowlist.clone(); + tokio::spawn(async move { + while let Some(manifest) = manifest_stream.recv().await { + if !validate_manifest(&manifest, allowlist.as_ref()) { + tracing::warn!("rejected manifest {}", manifest.manifest_id); + continue; + } + let manifest_id = manifest.manifest_id.clone(); + store_clone.write().await.insert(manifest_id, manifest); + } + }); + Some(store) + } else { + None + }; + + let network_secret = parse_network_secret(args.network_secret)?; + + let mut hls = + HlsWriter::new_cmaf(&args.output_dir, args.chunk_ms as f64 / 1000.0, args.window)?; + + let needs_init = args.subscribe_init; + let mut init_ready = !needs_init; + let mut buffered_segments: Vec<(u64, f64, Vec)> = Vec::new(); + + let mut init_rx = if needs_init { + let (tx, rx) = tokio::sync::oneshot::channel::>>(); + let mut init_stream = node + .subscribe_objects(remote.clone(), &args.broadcast_name, &args.init_track) + .await?; + let remote_str = args.remote.clone(); + let init_track = args.init_track.clone(); + let secret = network_secret.clone(); + tokio::spawn(async move { + let deadline = Instant::now() + Duration::from_secs(60); + while Instant::now() < deadline { + let remaining = deadline.saturating_duration_since(Instant::now()); + let recv = + tokio::time::timeout(remaining.min(Duration::from_secs(2)), init_stream.recv()) + .await; + let Ok(Some(object)) = recv else { continue }; + + let init_index = object + .meta + .timing + .as_ref() + .map(|t| t.chunk_index) + .unwrap_or(0); + + let data = if let Some(enc) = &object.meta.encryption { + if enc.alg != ENCRYPTION_ALG { + tracing::warn!("init: unsupported encryption {}", enc.alg); + continue; + } + tracing::info!( + "init: received encrypted object bytes={} key_id={} chunk_index={}", + object.data.len(), + enc.key_id, + init_index + ); + match decrypt_stream_data( + &enc.key_id, + init_index, + &object.data, + secret.as_deref(), + ) { + Some(plaintext) => plaintext, + None => { + tracing::warn!( + "init: decryption failed key_id={} chunk_index={}", + enc.key_id, + init_index + ); + continue; + } + } + } else { + tracing::info!( + "init: received plaintext object bytes={} chunk_index={}", + object.data.len(), + init_index + ); + object.data + }; + let _ = tx.send(Ok(data)); + return; + } + let _ = tx.send(Err(anyhow!( + "timed out waiting for CMAF init segment on track '{}' from {}", + init_track, + remote_str + ))); + }); + Some(rx) + } else { + None + }; + + let fallback = Duration::from_millis(args.chunk_ms); + let mut fallback_index = 0u64; + let mut invalid_chunks = 0u32; + let mut written_chunks = 0u64; + let mut quota = args.max_bytes_per_sec.map(|rate| { + let burst = args.max_bytes_burst.unwrap_or(rate.saturating_mul(2)); + ec_iroh::TokenBucket::new(burst, rate) + }); + + loop { + tokio::select! { + biased; + init_res = async { if let Some(rx) = init_rx.as_mut() { Some(rx.await) } else { None } }, if init_rx.is_some() => { + let Some(init_res) = init_res else { continue }; + let init = match init_res { + Ok(inner) => inner?, + Err(_) => return Err(anyhow!("init receiver task cancelled")), + }; + if args.raw_cmaf { + fs::create_dir_all(&args.output_dir)?; + fs::write(args.output_dir.join("init.mp4"), &init)?; + } else { + let _ = hls.write_init_segment(&init)?; + } + init_ready = true; + init_rx = None; + + // Flush any segments we buffered while waiting for init. + buffered_segments.sort_by_key(|(idx, _, _)| *idx); + for (idx, dur, bytes) in buffered_segments.drain(..) { + if args.raw_cmaf { + fs::create_dir_all(&args.output_dir)?; + fs::write(args.output_dir.join(format!("segment_{idx:06}.m4s")), &bytes)?; + } else { + let _ = hls.write_segment(idx, dur, &bytes)?; + } + written_chunks += 1; + if let Some(limit) = args.stop_after { + if written_chunks >= limit { + return Ok(()); + } + } + } + continue; + } + object = stream.recv() => { + let Some(object) = object else { break }; + if let Some(bucket) = quota.as_mut() { + if !bucket.allow(object.data.len() as u64) { + tracing::warn!("quota exceeded; dropping chunk"); + continue; + } + } + let stream_id_for_manifest = object + .meta + .encryption + .as_ref() + .map(|enc| strip_init_suffix(enc.key_id.as_str()).to_string()); + let index = object + .meta + .timing + .as_ref() + .map(|t| t.chunk_index) + .unwrap_or_else(|| { + let current = fallback_index; + fallback_index += 1; + current + }); + let stream_id = args + .stream_id + .as_deref() + .or_else(|| object.meta.encryption.as_ref().map(|enc| enc.key_id.as_str())); + + let data = if let Some(enc) = &object.meta.encryption { + if enc.alg != ENCRYPTION_ALG { + tracing::warn!("unsupported encryption {}", enc.alg); + continue; + } + let Some(stream_id) = stream_id else { + tracing::warn!("missing stream id for decryption"); + continue; + }; + match decrypt_stream_data(stream_id, index, &object.data, network_secret.as_deref()) { + Some(plaintext) => plaintext, + None => { + tracing::warn!("decryption failed for chunk {}", index); + continue; + } + } + } else { + object.data + }; + + if let Some(store) = manifest_store.as_ref() { + let manifest = { + let store = store.read().await; + if let Some(manifest_id) = object.meta.manifest_id.as_ref() { + store.get(manifest_id).cloned() + } else { + if let Some(stream_id) = stream_id_for_manifest.as_deref() { + find_manifest_for_stream_index(&store, stream_id, index) + } else { + None + } + } + }; + + if let Some(manifest) = manifest { + let expected = stream_id_for_manifest + .as_deref() + .and_then(|sid| manifest_hash_for_chunk(&manifest, sid, index)); + if let Some(expected) = expected { + if let Some(meta_hash) = object.meta.chunk_hash.as_ref() { + if expected != *meta_hash { + tracing::warn!( + "manifest mismatch for chunk {} (expected {}, got {})", + index, + expected, + meta_hash + ); + invalid_chunks += 1; + if invalid_chunks > args.max_invalid_chunks { + tracing::warn!("too many invalid chunks; closing"); + break; + } + continue; + } + } else if args.require_manifest { + tracing::warn!("missing chunk hash for manifest"); + invalid_chunks += 1; + if invalid_chunks > args.max_invalid_chunks { + tracing::warn!("too many invalid chunks; closing"); + break; + } + continue; + } + } else { + // If the manifest doesn't include hash lists, fall back to Merkle proof. + if let (Some(meta_hash), Some(proof)) = + (object.meta.chunk_hash.as_ref(), object.meta.chunk_proof.as_ref()) + { + let offset = (index - manifest.body.chunk_start_index) as usize; + if !verify_merkle_proof( + meta_hash, + offset, + proof, + &manifest.body.merkle_root, + ) { + tracing::warn!("chunk {} proof invalid for manifest", index); + invalid_chunks += 1; + if invalid_chunks > args.max_invalid_chunks { + tracing::warn!("too many invalid chunks; closing"); + break; + } + continue; + } + } else if args.require_manifest { + tracing::warn!("chunk {} outside manifest", index); + invalid_chunks += 1; + if invalid_chunks > args.max_invalid_chunks { + tracing::warn!("too many invalid chunks; closing"); + break; + } + continue; + } + } + } else if args.require_manifest { + tracing::warn!("missing manifest covering chunk {}", index); + continue; + } + } + + if let Some(expected) = object.meta.chunk_hash.as_ref() { + let actual = blake3::hash(&data).to_hex().to_string(); + if &actual != expected { + tracing::warn!( + "chunk {} hash mismatch (expected {}, got {})", + index, + expected, + actual + ); + invalid_chunks += 1; + if invalid_chunks > args.max_invalid_chunks { + tracing::warn!("too many invalid chunks; closing"); + break; + } + continue; + } + } + + let duration = chunk_duration_secs(&object.meta, fallback); + if !init_ready { + // Keep draining the MoQ track to avoid flow-control stalls while init is pending. + buffered_segments.push((index, duration, data)); + } else { + if args.raw_cmaf { + fs::create_dir_all(&args.output_dir)?; + fs::write(args.output_dir.join(format!("segment_{index:06}.m4s")), &data)?; + } else { + let _ = hls.write_segment(index, duration, &data)?; + } + written_chunks += 1; + } + if let Some(limit) = args.stop_after { + if written_chunks >= limit { + break; + } + } + } + } + } + + if needs_init && !init_ready { + return Err(anyhow!( + "stream ended before receiving CMAF init segment on track '{}' from {}", + args.init_track, + args.remote + )); + } + + Ok(()) +} + +async fn moq_selftest(args: MoqSelftestArgs) -> Result<()> { + let discovery = parse_discovery(args.discovery.as_deref())?; + let publisher_node = MoqNode::bind_with_discovery(None, discovery).await?; + let subscriber_node = MoqNode::bind_with_discovery(None, discovery).await?; + + publisher_node.endpoint().online().await; + subscriber_node.endpoint().online().await; + + let stream_id = args.stream_id.unwrap_or_else(|| { + let ts = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis(); + format!("selftest-{ts}") + }); + let broadcast_name = stream_id.clone(); + let track_name = args.track_name.clone(); + + let mut publisher = publisher_node + .publish_objects(&broadcast_name, &track_name) + .await?; + let mut endpoint_addr = publisher_node.endpoint().addr(); + if endpoint_addr.addrs.is_empty() { + let mut watcher = publisher_node.endpoint().watch_addr(); + let start = Instant::now(); + while endpoint_addr.addrs.is_empty() && start.elapsed() < Duration::from_secs(3) { + tokio::time::sleep(Duration::from_millis(200)).await; + endpoint_addr = watcher.get(); + } + } + if endpoint_addr.addrs.is_empty() { + tracing::warn!( + "publisher endpoint has no direct addrs; selftest may rely on discovery/relays" + ); + } + + let mut stream = subscriber_node + .subscribe_objects(endpoint_addr, &broadcast_name, &track_name) + .await?; + + let expected: Arc>> = Arc::new(Mutex::new(BTreeMap::new())); + let (done_tx, mut done_rx) = tokio::sync::watch::channel(0usize); + let (progress_tx, mut progress_rx) = tokio::sync::mpsc::channel::(8); + + let subscriber_task = tokio::spawn(async move { + let mut received: BTreeMap = BTreeMap::new(); + loop { + tokio::select! { + item = stream.recv() => { + match item { + Some(object) => { + let index = object + .meta + .timing + .as_ref() + .map(|timing| timing.chunk_index) + .unwrap_or(0); + let hash = blake3::hash(&object.data); + received.insert(index, hash); + let _ = progress_tx.send(index).await; + let expected_total = *done_rx.borrow(); + if expected_total > 0 && received.len() >= expected_total { + break; + } + } + None => break, + } + } + _ = done_rx.changed() => { + let expected_total = *done_rx.borrow(); + if expected_total > 0 && received.len() >= expected_total { + break; + } + } + } + } + Ok::<_, anyhow::Error>(received) + }); + + let chunk_dir = args.chunk_dir.clone(); + let input = args.input.clone(); + let max_chunks = args.max_chunks; + let track = TrackName { + namespace: "every.channel".to_string(), + name: stream_id.clone(), + }; + + let objects = + tokio::task::spawn_blocking(move || -> Result> { + let _ = fs::remove_dir_all(&chunk_dir); + fs::create_dir_all(&chunk_dir) + .with_context(|| format!("failed to create {}", chunk_dir.display()))?; + + let reader: Box = if input.starts_with("http://") + || input.starts_with("https://") + { + Box::new(ec_hdhomerun::open_stream_url(&input, None)?) + } else { + Box::new(File::open(&input).with_context(|| format!("failed to open {}", input))?) + }; + + let mut objects = Vec::new(); + let out_dir = chunk_dir.join("cmaf"); + let (init_path, segments) = + chunk_stream_cmaf_ffmpeg(reader, &out_dir, args.chunk_ms, max_chunks, true)?; + + // Index 0: init.mp4 + let (init_bytes, init_hash) = read_chunk_bytes_and_hash(&init_path)?; + let init_chunk = TsChunk { + index: 0, + path: init_path, + timing: ec_chopper::ChunkTiming { + chunk_index: 0, + chunk_start_27mhz: None, + chunk_duration_27mhz: 0, + utc_start_unix: None, + sync_status: "init".to_string(), + }, + }; + let object = build_object( + init_chunk, + init_bytes, + init_hash, + None, + None, + None, + "video/mp4", + &track.name, + )?; + let hash = blake3::hash(&object.data); + objects.push((0, object, hash)); + + // Index 1..N: segments + for (i, seg_path) in segments.into_iter().enumerate() { + let index = (i as u64) + 1; + let (bytes, hash_hex) = read_chunk_bytes_and_hash(&seg_path)?; + let chunk = TsChunk { + index, + path: seg_path, + timing: ec_chopper::ChunkTiming { + chunk_index: index, + chunk_start_27mhz: None, + chunk_duration_27mhz: args.chunk_ms * 27_000, + utc_start_unix: None, + sync_status: "cmaf".to_string(), + }, + }; + let object = build_object( + chunk, + bytes, + hash_hex, + None, + None, + None, + "video/iso.segment", + &track.name, + )?; + let hash = blake3::hash(&object.data); + objects.push((index, object, hash)); + } + + Ok(objects) + }) + .await + .map_err(|err| anyhow!("chunking task failed: {err}"))??; + + { + let mut map = expected.lock().expect("mutex poisoned"); + for (index, _, hash) in &objects { + map.insert(*index, *hash); + } + let _ = done_tx.send(map.len()); + } + + tokio::time::sleep(Duration::from_millis(250)).await; + + for (index, object, _) in objects { + publisher.publish_object(GroupId(index), object)?; + match tokio::time::timeout(Duration::from_secs(2), progress_rx.recv()).await { + Ok(Some(received)) if received == index => {} + Ok(Some(received)) => { + tracing::warn!("selftest received chunk {received} while waiting for {index}"); + } + Ok(None) => { + return Err(anyhow!("selftest subscriber closed before chunk {index}")); + } + Err(_) => { + return Err(anyhow!("selftest timed out waiting for chunk {index}")); + } + } + } + + let received = tokio::time::timeout(Duration::from_secs(20), subscriber_task) + .await + .map_err(|_| anyhow!("selftest timed out waiting for subscriber"))? + .map_err(|err| anyhow!("subscriber task failed: {err}"))??; + + let expected_map = expected.lock().expect("mutex poisoned"); + let mut mismatches = 0usize; + + for (index, expected_hash) in expected_map.iter() { + match received.get(index) { + Some(actual) if actual == expected_hash => {} + Some(_) => { + mismatches += 1; + tracing::warn!("hash mismatch at chunk {index}"); + } + None => { + mismatches += 1; + tracing::warn!("missing chunk {index}"); + } + } + } + + if received.len() > expected_map.len() { + mismatches += received.len() - expected_map.len(); + tracing::warn!( + "received extra chunks ({})", + received.len() - expected_map.len() + ); + } + + if mismatches > 0 { + return Err(anyhow!("moq selftest failed with {mismatches} mismatches")); + } + + println!( + "moq selftest ok: {} chunks verified (broadcast {}, track {})", + expected_map.len(), + broadcast_name, + track_name + ); + + Ok(()) +} + +#[derive(serde::Deserialize)] +struct TurnResp { + ice_servers: Vec, +} + +async fn fetch_turn_ice_servers(client: &reqwest::Client, dir: &str) -> Option> { + let base = dir.trim_end_matches('/'); + let url = format!("{base}/api/turn"); + let res = client.get(url).send().await.ok()?; + if !res.status().is_success() { + return None; + } + let body: TurnResp = res.json().await.ok()?; + if body.ice_servers.is_empty() { + return None; + } + Some(body.ice_servers) +} + +async fn direct_publish(args: DirectPublishArgs) -> Result<()> { + fs::create_dir_all(&args.chunk_dir) + .with_context(|| format!("failed to create {}", args.chunk_dir.display()))?; + + // For browser interop, we currently always normalize into deterministic H.264/AAC CMAF. + // This also keeps the codec stable for MSE playback. + let deterministic = true; + + let source: Box = match args.source { + IngestSource::Hls { url, .. } => Box::new(HlsSource { + url, + mode: HlsMode::Transcode, + }), + IngestSource::Hdhr { + host, + device_id, + channel, + name, + prefer_mdns, + } => Box::new(HdhrSource { + host, + device_id, + channel, + name, + prefer_mdns, + }), + IngestSource::LinuxDvb { + adapter, + dvr, + tune_cmd, + tune_wait_ms, + } => Box::new(LinuxDvbSource { + adapter, + dvr, + tune_cmd, + tune_wait_ms, + }), + IngestSource::Ts { input } => Box::new(TsSource { input }), + }; + + fn normalize_base(url: &str) -> String { + url.trim_end_matches('/').to_string() + } + + let directory_url = args.directory_url.as_deref().map(normalize_base); + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(8)) + .build() + .with_context(|| "failed to build http client")?; + + let mut cfg = PeerConfiguration::default(); + if let Some(dir) = directory_url.as_deref() { + if let Some(ice) = fetch_turn_ice_servers(&client, dir).await { + cfg.ice_servers = ice; + } + } + + let stream_id = args.stream_id.clone().unwrap_or_else(|| { + let ts = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis(); + format!("every.channel/direct/{ts}") + }); + + #[derive(serde::Serialize)] + struct AnnounceReq<'a> { + stream_id: &'a str, + title: &'a str, + offer: &'a str, + expires_ms: u64, + } + + #[derive(serde::Deserialize)] + struct AnswerResp { + answer: String, + } + let title = args.title.clone(); + let out_dir = args.chunk_dir.join("cmaf"); + let chunk_ms = args.chunk_ms; + let max_segments = args.max_segments; + let answer_override = args.answer.clone(); + + loop { + // New offerer per session so viewers can reconnect (or a new viewer can take over). + let offerer = PeerConnectionBuilder::new() + .set_config(cfg.clone()) + .with_channel_options(vec![( + "simple_channel_".to_string(), + DataChannelOptions { + ordered: Some(true), + ..Default::default() + }, + )]) + .map_err(|e| anyhow!("{e:#}"))? + .build() + .await + .map_err(|e| anyhow!("{e:#}"))?; + + let offer_desc = offerer + .get_local_description() + .await + .ok_or_else(|| anyhow!("missing local offer description"))?; + let offer_candidates = offerer + .collect_ice_candidates() + .await + .map_err(|e| anyhow!("{e:#}"))?; + let offer_link = encode_direct_link(&DirectCodeV1 { + v: 1, + desc: offer_desc, + candidates: offer_candidates, + label: Some("every.channel0".to_string()), + })?; + + println!("{offer_link}"); + + let stop_refresh = tokio::sync::watch::channel(false); + if let Some(dir) = directory_url.as_deref() { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; + let req = AnnounceReq { + stream_id: &stream_id, + title: &title, + offer: &offer_link, + expires_ms: now.saturating_add(args.announce_ttl_ms), + }; + let url = format!("{dir}/api/announce"); + let res = client.post(url).json(&req).send().await?; + if !res.status().is_success() { + tracing::warn!("directory announce failed: {}", res.status()); + } else { + tracing::info!("announced offer for {}", stream_id); + } + + // Best-effort refresh while waiting for an answer (stops when the session starts). + let mut stop_rx = stop_refresh.1.clone(); + let client2 = client.clone(); + let dir2 = dir.to_string(); + let stream_id2 = stream_id.clone(); + let title2 = title.clone(); + let offer2 = offer_link.clone(); + let ttl = args.announce_ttl_ms; + tokio::spawn(async move { + loop { + if *stop_rx.borrow() { + break; + } + tokio::time::sleep(Duration::from_millis(ttl.saturating_mul(3) / 4)).await; + if *stop_rx.borrow() { + break; + } + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; + let req = AnnounceReq { + stream_id: &stream_id2, + title: &title2, + offer: &offer2, + expires_ms: now.saturating_add(ttl), + }; + let url = format!("{dir2}/api/announce"); + let fut = client2.post(url).json(&req).send(); + let _ = tokio::time::timeout(Duration::from_secs(5), fut).await; + } + }); + } + + let answer = if let Some(answer) = answer_override.clone() { + answer + } else if let Some(dir) = directory_url.as_deref() { + eprintln!("waiting for browser answer via {dir}/api/answer?stream_id=..."); + let url = format!( + "{dir}/api/answer?stream_id={}", + urlencoding::encode(&stream_id) + ); + let deadline = if args.answer_timeout_secs == 0 { + None + } else { + Some(Instant::now() + Duration::from_secs(args.answer_timeout_secs)) + }; + loop { + if deadline.is_some_and(|d| Instant::now() > d) { + return Err(anyhow!( + "timed out waiting for answer for stream_id {stream_id}" + )); + } + match client.get(&url).send().await { + Ok(res) if res.status().is_success() => { + let body: AnswerResp = res.json().await?; + break body.answer; + } + _ => { + tokio::time::sleep(Duration::from_millis(300)).await; + } + } + } + } else { + eprintln!("paste direct answer link/code, then press enter:"); + let mut line = String::new(); + std::io::stdin().read_line(&mut line)?; + line + }; + + // Stop refreshing announcements. If we keep the listing live while connected, people + // will try to join and silently fail (this direct path is 1:1 today). + let _ = stop_refresh.0.send(true); + + let answer = decode_direct_link(&answer)?; + offerer + .set_remote_description(answer.desc) + .await + .map_err(|e| anyhow!("{e:#}"))?; + offerer + .add_ice_candidates(answer.candidates) + .await + .map_err(|e| anyhow!("{e:#}"))?; + + let channel = offerer + .receive_channel() + .await + .map_err(|e| anyhow!("{e:#}"))?; + channel.wait_ready().await; + eprintln!("direct channel open: {}", channel.label()); + + // Detect viewer disconnect promptly. Relying only on `channel.send` errors can lag + // (depending on buffering), leaving the directory entry effectively "stuck". + let (pc_dead_tx, mut pc_dead_rx) = tokio::sync::oneshot::channel::(); + let (pc_stop_tx, mut pc_stop_rx) = tokio::sync::watch::channel(false); + tokio::spawn(async move { + loop { + tokio::select! { + _ = pc_stop_rx.changed() => { + if *pc_stop_rx.borrow() { + break; + } + } + st = offerer.state_change() => { + if matches!( + st, + PeerConnectionState::Disconnected | PeerConnectionState::Failed | PeerConnectionState::Closed + ) { + let _ = pc_dead_tx.send(st); + break; + } + } + } + } + }); + + // Expire the listing quickly now that we have a live session. + if let Some(dir) = directory_url.as_deref() { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; + let req = AnnounceReq { + stream_id: &stream_id, + title: &title, + offer: &offer_link, + expires_ms: now.saturating_add(1500), + }; + let url = format!("{dir}/api/announce"); + let _ = client.post(url).json(&req).send().await; + } + + enum ChunkItem { + Init(Vec), + Segment { index: u64, bytes: Vec }, + } + + let (tx, mut rx) = mpsc::channel::(8); + let reader = source.open_stream()?; + let out_dir2 = out_dir.clone(); + let chunk_task = tokio::task::spawn_blocking(move || -> Result<()> { + let _ = fs::remove_dir_all(&out_dir2); + fs::create_dir_all(&out_dir2) + .with_context(|| format!("failed to create {}", out_dir2.display()))?; + + let profile = if deterministic { + ec_chopper::deterministic_h264_profile() + } else { + ec_chopper::deterministic_h264_profile() + }; + + let mut cmd = Command::new("ffmpeg"); + cmd.current_dir(&out_dir2); + cmd.arg("-hide_banner") + .arg("-loglevel") + .arg("error") + .arg("-nostdin") + .arg("-y") + .arg("-i") + .arg("pipe:0") + // predictable mapping + .arg("-map") + .arg("0:v:0") + .arg("-map") + .arg("0:a:0?") + .arg("-sn") + .arg("-dn") + .arg("-map_metadata") + .arg("-1") + // reduce scheduling nondeterminism + .arg("-filter_threads") + .arg("1") + .arg("-filter_complex_threads") + .arg("1") + .arg("-threads") + .arg("1"); + + for arg in ec_chopper::ffmpeg_profile_args(&profile) { + cmd.arg(arg); + } + + let seg_time = format!("{:.3}", chunk_ms as f64 / 1000.0); + cmd.arg("-f") + .arg("hls") + .arg("-hls_time") + .arg(seg_time) + .arg("-hls_list_size") + .arg("0") + .arg("-hls_segment_type") + .arg("fmp4") + .arg("-hls_flags") + .arg("independent_segments") + .arg("-hls_fmp4_init_filename") + .arg("init.mp4") + .arg("-hls_segment_filename") + .arg("segment_%06d.m4s") + .arg("index.m3u8") + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::inherit()); + + let mut child = cmd.spawn().with_context(|| "failed to spawn ffmpeg")?; + let mut stdin = child + .stdin + .take() + .ok_or_else(|| anyhow!("ffmpeg stdin unavailable"))?; + let mut reader = reader; + let writer = std::thread::spawn(move || -> Result<()> { + std::io::copy(&mut reader, &mut stdin)?; + Ok(()) + }); + + let init_path = out_dir2.join("init.mp4"); + wait_for_stable_file(&init_path, Duration::from_secs(20))?; + let init = fs::read(&init_path)?; + tx.blocking_send(ChunkItem::Init(init)) + .map_err(|_| anyhow!("receiver closed"))?; + + for i in 0..max_segments { + let seg_path = out_dir2.join(format!("segment_{i:06}.m4s")); + match wait_for_stable_file(&seg_path, Duration::from_secs(30)) { + Ok(()) => {} + Err(err) => { + if let Ok(Some(status)) = child.try_wait() { + if status.success() { + break; + } + return Err(anyhow!("ffmpeg exited with {status} ({err:#})")); + } + return Err(err); + } + } + let bytes = fs::read(&seg_path)?; + tx.blocking_send(ChunkItem::Segment { + index: (i as u64) + 1, + bytes, + }) + .map_err(|_| anyhow!("receiver closed"))?; + } + + let _ = child.kill(); + let _ = child.wait(); + let _ = writer.join(); + Ok(()) + }); + + let mut send_failed = false; + let mut have_heartbeat = false; + let mut last_heartbeat = Instant::now(); + let mut heartbeat_check = tokio::time::interval(Duration::from_secs(1)); + heartbeat_check.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + loop { + tokio::select! { + biased; + st = (&mut pc_dead_rx) => { + match st { + Ok(st) => tracing::info!("peer connection ended ({st:?}); restarting session"), + Err(_) => tracing::info!("peer connection ended; restarting session"), + } + send_failed = true; + break; + } + _ = heartbeat_check.tick() => { + if have_heartbeat && last_heartbeat.elapsed() > DIRECT_HEARTBEAT_TIMEOUT { + tracing::info!("direct session heartbeat timed out; restarting session"); + send_failed = true; + break; + } + } + msg = channel.receive() => { + match msg { + Ok(b) => { + // Any message from the subscriber counts as a heartbeat today. + // (We reserve DIRECT_WIRE_TAG_PING for explicit pings, but don't require it.) + have_heartbeat = true; + last_heartbeat = Instant::now(); + if !b.is_empty() && b[0] == DIRECT_WIRE_TAG_PING { + // ignore + } + } + Err(_) => { + send_failed = true; + break; + } + } + } + item = rx.recv() => { + let Some(item) = item else { break }; + let (index, content_type, bytes, duration_27mhz, sync_status) = match item { + ChunkItem::Init(bytes) => (0u64, "video/mp4", bytes, 0u64, "init"), + ChunkItem::Segment { index, bytes } => { + (index, "video/iso.segment", bytes, chunk_ms * 27_000, "cmaf") + } + }; + if index == 0 { + tracing::info!("direct send: init.mp4 ({} bytes)", bytes.len()); + } else if index % 5 == 0 { + tracing::info!("direct send: segment {index} ({} bytes)", bytes.len()); + } + let hash = blake3::hash(&bytes).to_hex().to_string(); + let meta = ObjectMeta { + created_unix_ms: SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64, + content_type: content_type.to_string(), + size_bytes: bytes.len() as u64, + timing: Some(TimingMeta { + chunk_index: index, + chunk_start_27mhz: 0, + chunk_duration_27mhz: duration_27mhz, + utc_start_unix: None, + sync_status: sync_status.to_string(), + }), + encryption: None, + chunk_hash: Some(hash), + chunk_hash_alg: Some("blake3".to_string()), + chunk_proof: None, + chunk_proof_alg: None, + manifest_id: None, + }; + let frame = encode_object_frame(&meta, &bytes)?; + match tokio::time::timeout( + Duration::from_secs(5), + direct_wire_send_frame(&channel, &frame), + ) + .await + { + Ok(Ok(())) => {} + Ok(Err(err)) => { + tracing::warn!("direct send failed (restarting session): {err:#}"); + send_failed = true; + break; + } + Err(_) => { + tracing::warn!("direct send timed out (restarting session)"); + send_failed = true; + break; + } + } + } + } + } + + // Ensure the peer connection is dropped if we restart for non-ICE reasons (e.g. ffmpeg end). + let _ = pc_stop_tx.send(true); + + drop(rx); + match chunk_task.await { + Ok(Ok(())) => {} + Ok(Err(err)) => { + // Common when the viewer disconnects: the receiver is dropped and the + // blocking sender errors out. Treat as a reconnect. + tracing::debug!("chunk task ended: {err:#}"); + } + Err(err) => tracing::debug!("chunk task join error: {err}"), + } + + if answer_override.is_some() { + // For manual mode, we can't reasonably loop. + break; + } + if !send_failed { + // ffmpeg ended or source ended; try again. + tokio::time::sleep(Duration::from_millis(500)).await; + } + } + + Ok(()) +} + +async fn direct_wire_send_frame(channel: &impl DataChannelExt, frame: &[u8]) -> Result<()> { + let len = u32::try_from(frame.len()).map_err(|_| anyhow!("frame too large"))?; + let mut stream = Vec::with_capacity(4 + frame.len()); + stream.extend_from_slice(&len.to_be_bytes()); + stream.extend_from_slice(frame); + + for chunk in stream.chunks(DIRECT_WIRE_CHUNK_BYTES) { + let mut msg = Vec::with_capacity(1 + chunk.len()); + msg.push(DIRECT_WIRE_TAG_STREAM); + msg.extend_from_slice(chunk); + channel + .send(&bytes::Bytes::from(msg)) + .await + .map_err(|e| anyhow!("{e:#}"))?; + } + Ok(()) +} + +#[derive(Debug, Default)] +struct DirectWireDecoder { + buf: Vec, + pos: usize, + want: Option, +} + +impl DirectWireDecoder { + fn push(&mut self, msg: &[u8]) -> Result>> { + if msg.is_empty() { + return Ok(Vec::new()); + } + + match msg[0] { + DIRECT_WIRE_TAG_FRAME => return Ok(vec![msg[1..].to_vec()]), + DIRECT_WIRE_TAG_STREAM => { + self.buf.extend_from_slice(&msg[1..]); + } + DIRECT_WIRE_TAG_PING => { + // Control message; ignore. + return Ok(Vec::new()); + } + _ => { + // Unknown tag: treat as legacy "whole frame per message". + return Ok(vec![msg.to_vec()]); + } + } + + let mut out = Vec::new(); + loop { + if self.want.is_none() { + if self.buf.len().saturating_sub(self.pos) < 4 { + break; + } + let start = self.pos; + let meta = &self.buf[start..start + 4]; + let len = u32::from_be_bytes([meta[0], meta[1], meta[2], meta[3]]) as usize; + self.pos += 4; + self.want = Some(len); + } + + let Some(want) = self.want else { break }; + if self.buf.len().saturating_sub(self.pos) < want { + break; + } + let start = self.pos; + let end = start + want; + out.push(self.buf[start..end].to_vec()); + self.pos = end; + self.want = None; + + // Avoid unbounded growth: occasionally compact the buffer. + if self.pos > 64 * 1024 { + self.buf.drain(0..self.pos); + self.pos = 0; + } + } + Ok(out) + } +} + +async fn direct_subscribe(args: DirectSubscribeArgs) -> Result<()> { + fs::create_dir_all(&args.out_dir) + .with_context(|| format!("failed to create {}", args.out_dir.display()))?; + + #[derive(serde::Deserialize)] + struct DirectoryResp { + entries: Vec, + } + + #[derive(serde::Deserialize)] + struct DirectoryEntry { + stream_id: String, + title: String, + offer: String, + #[allow(dead_code)] + expires_ms: Option, + } + + fn normalize_base(url: &str) -> String { + url.trim_end_matches('/').to_string() + } + + let dir = normalize_base(&args.directory_url); + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(8)) + .build() + .with_context(|| "failed to build http client")?; + + let (stream_id, offer_link) = match (args.stream_id.clone(), args.offer.clone()) { + (_, Some(offer)) => (args.stream_id.clone(), offer), + (Some(stream_id), None) => { + let url = format!("{dir}/api/directory"); + let res = client.get(&url).send().await?; + if !res.status().is_success() { + return Err(anyhow!("directory GET failed: {}", res.status())); + } + let body: DirectoryResp = res.json().await?; + let entry = body + .entries + .into_iter() + .find(|e| e.stream_id == stream_id) + .ok_or_else(|| anyhow!("stream_id not found in directory: {stream_id}"))?; + (Some(stream_id), entry.offer) + } + (None, None) => { + return Err(anyhow!("either --offer or --stream-id is required")); + } + }; + + let offer = decode_direct_link(&offer_link)?; + let mut cfg = PeerConfiguration::default(); + if let Some(ice) = fetch_turn_ice_servers(&client, &dir).await { + cfg.ice_servers = ice; + } + let pc = PeerConnectionBuilder::new() + .set_config(cfg) + .with_remote_offer(Some(offer.desc)) + .map_err(|e| anyhow!("{e:#}"))? + .build() + .await + .map_err(|e| anyhow!("{e:#}"))?; + pc.add_ice_candidates(offer.candidates) + .await + .map_err(|e| anyhow!("{e:#}"))?; + + // Build our answer link (used both for directory POST and manual copy/paste). + let desc = pc + .get_local_description() + .await + .ok_or_else(|| anyhow!("missing local answer description"))?; + let candidates = pc + .collect_ice_candidates() + .await + .map_err(|e| anyhow!("{e:#}"))?; + let answer_link = encode_direct_link(&DirectCodeV1 { + v: 1, + desc, + candidates, + label: Some("every.channel0".to_string()), + })?; + + if let Some(stream_id) = stream_id.as_deref() { + #[derive(serde::Serialize)] + struct AnswerReq<'a> { + stream_id: &'a str, + answer: &'a str, + } + let url = format!("{dir}/api/answer"); + let res = client + .post(url) + .json(&AnswerReq { + stream_id, + answer: &answer_link, + }) + .send() + .await?; + if !res.status().is_success() { + return Err(anyhow!( + "directory POST /api/answer failed: {}", + res.status() + )); + } + } else { + eprintln!("answer link (paste into publisher):\n{answer_link}"); + } + + let ch = pc.receive_channel().await.map_err(|e| anyhow!("{e:#}"))?; + ch.wait_ready().await; + eprintln!("direct channel open: {}", ch.label()); + + let cmaf_dir = args.out_dir.join("cmaf"); + fs::create_dir_all(&cmaf_dir) + .with_context(|| format!("failed to create {}", cmaf_dir.display()))?; + + let mut init_written = false; + let mut durations = Vec::::new(); + let mut captured_segments = 0usize; + let mut decoder = DirectWireDecoder::default(); + + let mut ping = tokio::time::interval(Duration::from_secs(1)); + ping.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + + let deadline = args + .duration_secs + .map(|s| Instant::now() + Duration::from_secs(s)); + while captured_segments < args.max_segments { + if deadline.is_some_and(|d| Instant::now() > d) { + break; + } + let msg = tokio::select! { + _ = ping.tick() => { + // Heartbeat to help the publisher detect disconnects promptly. + let _ = ch.send(&bytes::Bytes::from(vec![DIRECT_WIRE_TAG_PING])).await; + continue; + } + msg = ch.receive() => msg.map_err(|e| anyhow!("{e:#}"))?, + }; + for frame in decoder.push(&msg)? { + let payload = decode_object_frame(&frame)?; + let idx = payload + .meta + .timing + .as_ref() + .map(|t| t.chunk_index) + .unwrap_or(0); + + if idx == 0 { + tracing::info!("direct recv: init.mp4 ({} bytes)", payload.data.len()); + let path = cmaf_dir.join("init.mp4"); + fs::write(&path, &payload.data) + .with_context(|| format!("failed to write {}", path.display()))?; + init_written = true; + continue; + } + + if !init_written { + // Ignore segments until init arrives. + continue; + } + + // Publisher indexes segments starting at 1; our filenames start at 0. + let seg = idx.saturating_sub(1); + let path = cmaf_dir.join(format!("segment_{seg:06}.m4s")); + fs::write(&path, &payload.data) + .with_context(|| format!("failed to write {}", path.display()))?; + tracing::info!("direct recv: segment {idx} ({} bytes)", payload.data.len()); + + let dur = payload + .meta + .timing + .as_ref() + .map(|t| t.chunk_duration_27mhz as f64 / 27_000_000.0) + .unwrap_or(2.0); + durations.push(dur); + captured_segments += 1; + if captured_segments >= args.max_segments { + break; + } + } + } + + if !init_written || durations.is_empty() { + return Err(anyhow!("no media captured (missing init or segments)")); + } + + // Write a VOD playlist so ffmpeg can remux the fragments. + let target = durations + .iter() + .copied() + .fold(0.0_f64, f64::max) + .ceil() + .max(1.0) as u64; + let mut m3u8 = String::new(); + m3u8.push_str("#EXTM3U\n"); + m3u8.push_str("#EXT-X-VERSION:7\n"); + m3u8.push_str(&format!("#EXT-X-TARGETDURATION:{target}\n")); + m3u8.push_str("#EXT-X-PLAYLIST-TYPE:VOD\n"); + m3u8.push_str("#EXT-X-INDEPENDENT-SEGMENTS\n"); + m3u8.push_str("#EXT-X-MAP:URI=\"init.mp4\"\n"); + for (i, dur) in durations.iter().enumerate() { + m3u8.push_str(&format!("#EXTINF:{:.3},\n", dur)); + m3u8.push_str(&format!("segment_{i:06}.m4s\n")); + } + m3u8.push_str("#EXT-X-ENDLIST\n"); + let playlist_path = cmaf_dir.join("index.m3u8"); + fs::write(&playlist_path, m3u8) + .with_context(|| format!("failed to write {}", playlist_path.display()))?; + + let mp4_path = args + .mp4 + .clone() + .unwrap_or_else(|| args.out_dir.join("capture.mp4")); + let status = Command::new("ffmpeg") + .arg("-hide_banner") + .arg("-loglevel") + .arg("error") + .arg("-nostdin") + .arg("-y") + .arg("-protocol_whitelist") + .arg("file,crypto,data") + .arg("-i") + .arg(&playlist_path) + .arg("-c") + .arg("copy") + .arg(&mp4_path) + .status(); + match status { + Ok(s) if s.success() => { + println!("{}", mp4_path.display()); + } + Ok(s) => { + eprintln!("ffmpeg remux failed: {s}"); + println!("{}", playlist_path.display()); + } + Err(err) => { + eprintln!("failed to run ffmpeg: {err}"); + println!("{}", playlist_path.display()); + } + } + + Ok(()) +} + +fn ws_url_for(base: &str, stream_id: &str, role: &str) -> String { + let base = base.trim_end_matches('/'); + let ws_base = if let Some(rest) = base.strip_prefix("https://") { + format!("wss://{rest}") + } else if let Some(rest) = base.strip_prefix("http://") { + format!("ws://{rest}") + } else if base.starts_with("ws://") || base.starts_with("wss://") { + base.to_string() + } else { + format!("wss://{base}") + }; + format!( + "{ws_base}/api/stream/ws?stream_id={}&role={role}", + urlencoding::encode(stream_id) + ) +} + +async fn ws_send_frame( + ws: &mut tokio_tungstenite::WebSocketStream>, + frame: &[u8], +) -> Result<()> { + let len = u32::try_from(frame.len()).map_err(|_| anyhow!("frame too large"))?; + let mut stream = Vec::with_capacity(4 + frame.len()); + stream.extend_from_slice(&len.to_be_bytes()); + stream.extend_from_slice(frame); + for chunk in stream.chunks(DIRECT_WIRE_CHUNK_BYTES) { + let mut msg = Vec::with_capacity(1 + chunk.len()); + msg.push(DIRECT_WIRE_TAG_STREAM); + msg.extend_from_slice(chunk); + ws.send(WsMessage::Binary(msg)).await?; + } + Ok(()) +} + +async fn ws_publish(args: WsPublishArgs) -> Result<()> { + fs::create_dir_all(&args.chunk_dir) + .with_context(|| format!("failed to create {}", args.chunk_dir.display()))?; + + // For browser interop, we currently always normalize into deterministic H.264/AAC CMAF. + let deterministic = true; + + let source: Box = match args.source { + IngestSource::Hls { url, .. } => Box::new(HlsSource { + url, + mode: HlsMode::Transcode, + }), + IngestSource::Hdhr { + host, + device_id, + channel, + name, + prefer_mdns, + } => Box::new(HdhrSource { + host, + device_id, + channel, + name, + prefer_mdns, + }), + IngestSource::LinuxDvb { + adapter, + dvr, + tune_cmd, + tune_wait_ms, + } => Box::new(LinuxDvbSource { + adapter, + dvr, + tune_cmd, + tune_wait_ms, + }), + IngestSource::Ts { input } => Box::new(TsSource { input }), + }; + + fn normalize_base(url: &str) -> String { + url.trim_end_matches('/').to_string() + } + + let directory_url = normalize_base(&args.directory_url); + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(8)) + .build() + .with_context(|| "failed to build http client")?; + + let stream_id = args.stream_id.clone().unwrap_or_else(|| { + let ts = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis(); + format!("every.channel/ws/{ts}") + }); + let title = args.title.clone(); + + #[derive(serde::Serialize)] + struct AnnounceReq<'a> { + stream_id: &'a str, + title: &'a str, + offer: &'a str, + expires_ms: u64, + } + + // Directory listing is still "offer"-shaped today; keep a non-empty placeholder for legacy clients. + let offer_placeholder = format!("every.channel://watch?stream_id={}", stream_id); + + // Refresh listing forever while publishing (one-to-many relay supports multiple viewers). + let ttl = args.announce_ttl_ms; + let client2 = client.clone(); + let dir2 = directory_url.clone(); + let stream_id2 = stream_id.clone(); + let title2 = title.clone(); + let offer2 = offer_placeholder.clone(); + tokio::spawn(async move { + loop { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; + let req = AnnounceReq { + stream_id: &stream_id2, + title: &title2, + offer: &offer2, + expires_ms: now.saturating_add(ttl), + }; + let url = format!("{dir2}/api/announce"); + let _ = tokio::time::timeout(Duration::from_secs(5), client2.post(url).json(&req).send()).await; + tokio::time::sleep(Duration::from_millis(ttl.saturating_mul(3) / 4)).await; + } + }); + + // Connect to relay websocket as publisher. + let ws_url = ws_url_for(&directory_url, &stream_id, "pub"); + eprintln!("ws publish: {stream_id}"); + eprintln!("ws url: {ws_url}"); + let (mut ws, _resp) = tokio_tungstenite::connect_async(&ws_url).await?; + + enum ChunkItem { + Init(Vec), + Segment { index: u64, bytes: Vec }, + } + let out_dir = args.chunk_dir.join("cmaf"); + let chunk_ms = args.chunk_ms; + let max_segments = args.max_segments; + + let (tx, mut rx) = mpsc::channel::(8); + let reader = source.open_stream()?; + let out_dir2 = out_dir.clone(); + let chunk_task = tokio::task::spawn_blocking(move || -> Result<()> { + let _ = fs::remove_dir_all(&out_dir2); + fs::create_dir_all(&out_dir2) + .with_context(|| format!("failed to create {}", out_dir2.display()))?; + + let profile = if deterministic { + ec_chopper::deterministic_h264_profile() + } else { + ec_chopper::deterministic_h264_profile() + }; + + let mut cmd = Command::new("ffmpeg"); + cmd.current_dir(&out_dir2); + cmd.arg("-hide_banner") + .arg("-loglevel") + .arg("error") + .arg("-nostdin") + .arg("-y") + .arg("-i") + .arg("pipe:0") + // predictable mapping + .arg("-map") + .arg("0:v:0") + .arg("-map") + .arg("0:a:0?") + .arg("-sn") + .arg("-dn") + .arg("-map_metadata") + .arg("-1") + // reduce scheduling nondeterminism + .arg("-filter_threads") + .arg("1") + .arg("-filter_complex_threads") + .arg("1") + .arg("-threads") + .arg("1"); + + for arg in ec_chopper::ffmpeg_profile_args(&profile) { + cmd.arg(arg); + } + + let seg_time = format!("{:.3}", chunk_ms as f64 / 1000.0); + cmd.arg("-f") + .arg("hls") + .arg("-hls_time") + .arg(seg_time) + .arg("-hls_list_size") + .arg("0") + .arg("-hls_segment_type") + .arg("fmp4") + .arg("-hls_flags") + .arg("independent_segments") + .arg("-hls_fmp4_init_filename") + .arg("init.mp4") + .arg("-hls_segment_filename") + .arg("segment_%06d.m4s") + .arg("index.m3u8") + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::inherit()); + + let mut child = cmd.spawn().with_context(|| "failed to spawn ffmpeg")?; + let mut stdin = child + .stdin + .take() + .ok_or_else(|| anyhow!("ffmpeg stdin unavailable"))?; + let mut reader = reader; + let writer = std::thread::spawn(move || -> Result<()> { + std::io::copy(&mut reader, &mut stdin)?; + Ok(()) + }); + + let init_path = out_dir2.join("init.mp4"); + wait_for_stable_file(&init_path, Duration::from_secs(20))?; + let init = fs::read(&init_path)?; + tx.blocking_send(ChunkItem::Init(init)) + .map_err(|_| anyhow!("receiver closed"))?; + + for i in 0..max_segments { + let seg_path = out_dir2.join(format!("segment_{i:06}.m4s")); + match wait_for_stable_file(&seg_path, Duration::from_secs(30)) { + Ok(()) => {} + Err(err) => { + if let Ok(Some(status)) = child.try_wait() { + if status.success() { + break; + } + return Err(anyhow!("ffmpeg exited with {status} ({err:#})")); + } + return Err(err); + } + } + let bytes = fs::read(&seg_path)?; + tx.blocking_send(ChunkItem::Segment { + index: (i as u64) + 1, + bytes, + }) + .map_err(|_| anyhow!("receiver closed"))?; + } + + let _ = child.kill(); + let _ = child.wait(); + let _ = writer.join(); + Ok(()) + }); + + while let Some(item) = rx.recv().await { + let (index, content_type, bytes, duration_27mhz, sync_status) = match item { + ChunkItem::Init(bytes) => (0u64, "video/mp4", bytes, 0u64, "init"), + ChunkItem::Segment { index, bytes } => { + (index, "video/iso.segment", bytes, chunk_ms * 27_000, "cmaf") + } + }; + if index == 0 { + tracing::info!("ws send: init.mp4 ({} bytes)", bytes.len()); + } else if index % 10 == 0 { + tracing::info!("ws send: segment {index} ({} bytes)", bytes.len()); + } + + let hash = blake3::hash(&bytes).to_hex().to_string(); + let meta = ObjectMeta { + created_unix_ms: SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64, + content_type: content_type.to_string(), + size_bytes: bytes.len() as u64, + timing: Some(TimingMeta { + chunk_index: index, + chunk_start_27mhz: 0, + chunk_duration_27mhz: duration_27mhz, + utc_start_unix: None, + sync_status: sync_status.to_string(), + }), + encryption: None, + chunk_hash: Some(hash), + chunk_hash_alg: Some("blake3".to_string()), + chunk_proof: None, + chunk_proof_alg: None, + manifest_id: None, + }; + let frame = encode_object_frame(&meta, &bytes)?; + tokio::time::timeout(Duration::from_secs(5), ws_send_frame(&mut ws, &frame)).await??; + } + + match chunk_task.await { + Ok(Ok(())) => Ok(()), + Ok(Err(err)) => Err(err), + Err(err) => Err(anyhow!("chunk task join error: {err}")), + } +} + +async fn ws_subscribe(args: WsSubscribeArgs) -> Result<()> { + fs::create_dir_all(&args.out_dir) + .with_context(|| format!("failed to create {}", args.out_dir.display()))?; + + let ws_url = ws_url_for(&args.directory_url, &args.stream_id, "sub"); + eprintln!("ws subscribe: {} ({ws_url})", args.stream_id); + let (mut ws, _resp) = tokio_tungstenite::connect_async(&ws_url).await?; + + let cmaf_dir = args.out_dir.join("cmaf"); + fs::create_dir_all(&cmaf_dir) + .with_context(|| format!("failed to create {}", cmaf_dir.display()))?; + + let mut init_written = false; + let mut durations = Vec::::new(); + let mut captured_segments = 0usize; + let mut decoder = DirectWireDecoder::default(); + + let deadline = args + .duration_secs + .map(|s| Instant::now() + Duration::from_secs(s)); + + while captured_segments < args.max_segments { + if deadline.is_some_and(|d| Instant::now() > d) { + break; + } + let next = ws.next().await.ok_or_else(|| anyhow!("websocket closed"))??; + let bytes = match next { + WsMessage::Binary(b) => b, + WsMessage::Close(_) => break, + _ => continue, + }; + for frame in decoder.push(&bytes)? { + let payload = decode_object_frame(&frame)?; + let idx = payload + .meta + .timing + .as_ref() + .map(|t| t.chunk_index) + .unwrap_or(0); + + if idx == 0 { + tracing::info!("ws recv: init.mp4 ({} bytes)", payload.data.len()); + let path = cmaf_dir.join("init.mp4"); + fs::write(&path, &payload.data) + .with_context(|| format!("failed to write {}", path.display()))?; + init_written = true; + continue; + } + + if !init_written { + continue; + } + + let seg = idx.saturating_sub(1); + let path = cmaf_dir.join(format!("segment_{seg:06}.m4s")); + fs::write(&path, &payload.data) + .with_context(|| format!("failed to write {}", path.display()))?; + if idx % 10 == 0 { + tracing::info!("ws recv: segment {idx} ({} bytes)", payload.data.len()); + } + + let dur = payload + .meta + .timing + .as_ref() + .map(|t| t.chunk_duration_27mhz as f64 / 27_000_000.0) + .unwrap_or(2.0); + durations.push(dur); + captured_segments += 1; + if captured_segments >= args.max_segments { + break; + } + } + } + + if !init_written || durations.is_empty() { + return Err(anyhow!("no media captured (missing init or segments)")); + } + + let target = durations + .iter() + .copied() + .fold(0.0_f64, f64::max) + .ceil() + .max(1.0) as u64; + let mut m3u8 = String::new(); + m3u8.push_str("#EXTM3U\n"); + m3u8.push_str("#EXT-X-VERSION:7\n"); + m3u8.push_str(&format!("#EXT-X-TARGETDURATION:{target}\n")); + m3u8.push_str("#EXT-X-PLAYLIST-TYPE:VOD\n"); + m3u8.push_str("#EXT-X-INDEPENDENT-SEGMENTS\n"); + m3u8.push_str("#EXT-X-MAP:URI=\"init.mp4\"\n"); + for (i, dur) in durations.iter().enumerate() { + m3u8.push_str(&format!("#EXTINF:{:.3},\n", dur)); + m3u8.push_str(&format!("segment_{i:06}.m4s\n")); + } + m3u8.push_str("#EXT-X-ENDLIST\n"); + let playlist_path = cmaf_dir.join("index.m3u8"); + fs::write(&playlist_path, m3u8) + .with_context(|| format!("failed to write {}", playlist_path.display()))?; + + let mp4_path = args + .mp4 + .clone() + .unwrap_or_else(|| args.out_dir.join("capture.mp4")); + let status = Command::new("ffmpeg") + .arg("-hide_banner") + .arg("-loglevel") + .arg("error") + .arg("-nostdin") + .arg("-y") + .arg("-protocol_whitelist") + .arg("file,crypto,data") + .arg("-i") + .arg(&playlist_path) + .arg("-c") + .arg("copy") + .arg(&mp4_path) + .status(); + match status { + Ok(s) if s.success() => { + println!("{}", mp4_path.display()); + } + Ok(s) => { + eprintln!("ffmpeg remux failed: {s}"); + println!("{}", playlist_path.display()); + } + Err(err) => { + eprintln!("failed to run ffmpeg: {err}"); + println!("{}", playlist_path.display()); + } + } + + Ok(()) +} + +fn parse_iroh_secret(value: Option) -> Result> { + let value = value.or_else(|| std::env::var("IROH_SECRET").ok()); + let Some(value) = value else { return Ok(None) }; + let secret = + iroh::SecretKey::from_str(&value).with_context(|| "failed to parse IROH_SECRET")?; + Ok(Some(secret)) +} + +fn parse_discovery(value: Option<&str>) -> Result { + match value { + Some(value) => DiscoveryConfig::from_list(value), + None => DiscoveryConfig::from_env(), + } +} + +async fn spawn_catalog_announcer( + node: &MoqNode, + track: &TrackName, + broadcast_name: &str, + track_name: &str, + peers: Vec, +) -> Result> { + let endpoint = serde_json::to_string(&node.endpoint_addr()) + .unwrap_or_else(|_| node.endpoint().id().to_string()); + let track = track.clone(); + let broadcast_name = broadcast_name.to_string(); + let track_name = track_name.to_string(); + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::(); + let endpoint_clone = endpoint.clone(); + let node_endpoint = node.endpoint().clone(); + + tokio::spawn(async move { + let mut gossip = match ec_iroh::CatalogGossip::join(node_endpoint, &peers).await { + Ok(gossip) => gossip, + Err(err) => { + tracing::warn!("catalog gossip join failed: {err:#}"); + return; + } + }; + let entry = build_catalog_entry( + endpoint_clone.clone(), + &track, + &broadcast_name, + &track_name, + None, + ); + if let Err(err) = gossip.announce(entry).await { + tracing::warn!("catalog announce failed: {err:#}"); + } + while let Some(summary) = rx.recv().await { + let entry = build_catalog_entry( + endpoint_clone.clone(), + &track, + &broadcast_name, + &track_name, + Some(summary), + ); + if let Err(err) = gossip.announce(entry).await { + tracing::warn!("catalog update failed: {err:#}"); + } + } + }); + + Ok(tx) +} + +fn build_catalog_entry( + endpoint: String, + track: &TrackName, + broadcast_name: &str, + track_name: &str, + manifest: Option, +) -> StreamCatalogEntry { + let stream = StreamDescriptor { + id: StreamId(track.name.clone()), + title: track.name.clone(), + number: None, + source: "moq".to_string(), + metadata: vec![StreamMetadata { + key: "broadcast".to_string(), + value: broadcast_name.to_string(), + }], + }; + + let encryption = StreamEncryptionInfo { + alg: ENCRYPTION_ALG.to_string(), + key_id: track.name.clone(), + nonce_scheme: "stream-id+chunk-index".to_string(), + }; + + let moq = MoqStreamDescriptor { + endpoint, + broadcast_name: broadcast_name.to_string(), + track_name: track_name.to_string(), + encryption: Some(encryption), + }; + + StreamCatalogEntry { + stream, + moq: Some(moq), + manifest, + updated_unix_ms: SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64, + } +} + +fn wait_for_stable_file(path: &Path, timeout: Duration) -> Result<()> { + let start = Instant::now(); + let mut last_len: Option = None; + let mut stable_ms: u64 = 0; + + // We want "write is done" behavior. File size staying constant for a few polls + // is a pragmatic signal that ffmpeg is done producing the segment. + while start.elapsed() < timeout { + if let Ok(meta) = fs::metadata(path) { + let len = meta.len(); + if len > 0 { + if Some(len) == last_len { + stable_ms += 100; + if stable_ms >= 300 { + return Ok(()); + } + } else { + last_len = Some(len); + stable_ms = 0; + } + } + } + std::thread::sleep(Duration::from_millis(100)); + } + + Err(anyhow!( + "timed out waiting for stable file {} after {:?}", + path.display(), + timeout + )) +} diff --git a/crates/ec-node/src/source.rs b/crates/ec-node/src/source.rs new file mode 100644 index 0000000..3482c93 --- /dev/null +++ b/crates/ec-node/src/source.rs @@ -0,0 +1,283 @@ +use anyhow::{anyhow, Result}; +use clap::ValueEnum; +use ec_chopper::{deterministic_h264_profile, ffmpeg_profile_args}; +use ec_core::SourceId; +use ec_hdhomerun::{find_lineup_entry_by_name, find_lineup_entry_by_number}; +use ec_linux_iptv::LinuxDvbConfig; +use std::io::Read; +use std::process::{Child, Command, Stdio}; +use std::thread; + +pub trait StreamSource: Send { + fn open_stream(&self) -> Result>; + fn source_id(&self) -> SourceId; +} + +#[derive(Debug, Clone)] +pub struct HdhrSource { + pub host: Option, + pub device_id: Option, + pub channel: Option, + pub name: Option, + pub prefer_mdns: bool, +} + +impl StreamSource for HdhrSource { + fn open_stream(&self) -> Result> { + let device = resolve_hdhr_device(self)?; + let lineup = ec_hdhomerun::fetch_lineup(&device)?; + let entry = if let Some(channel) = &self.channel { + find_lineup_entry_by_number(&lineup, channel) + .or_else(|| find_lineup_entry_by_name(&lineup, channel)) + .ok_or_else(|| anyhow!("channel not found: {channel}"))? + } else if let Some(name) = &self.name { + find_lineup_entry_by_name(&lineup, name) + .ok_or_else(|| anyhow!("channel not found: {name}"))? + } else { + return Err(anyhow!("--channel or --name required for hdhr")); + }; + + Ok(Box::new(ec_hdhomerun::open_stream_entry(entry, None)?)) + } + + fn source_id(&self) -> SourceId { + let device_id = self.device_id.clone().or_else(|| self.host.clone()); + SourceId { + kind: "hdhr".to_string(), + device_id, + channel: self.channel.clone().or_else(|| self.name.clone()), + } + } +} + +fn resolve_hdhr_device(source: &HdhrSource) -> Result { + if let Some(host) = &source.host { + return ec_hdhomerun::discover_from_host(host); + } + + if let Some(device_id) = &source.device_id { + let host = format!("{device_id}.local"); + return ec_hdhomerun::discover_from_host(&host); + } + + if source.prefer_mdns { + if let Ok(device) = ec_hdhomerun::discover_from_host("hdhomerun.local") { + return Ok(device); + } + } + + let mut devices = ec_hdhomerun::discover()?; + devices + .pop() + .ok_or_else(|| anyhow!("no HDHomeRun devices found")) +} + +#[derive(Debug, Clone)] +pub struct LinuxDvbSource { + pub adapter: u32, + pub dvr: u32, + pub tune_cmd: Vec, + pub tune_wait_ms: Option, +} + +impl StreamSource for LinuxDvbSource { + fn open_stream(&self) -> Result> { + let config = LinuxDvbConfig { + adapter: self.adapter, + frontend: 0, + dvr: self.dvr, + tune_command: if self.tune_cmd.is_empty() { + None + } else { + Some(self.tune_cmd.clone()) + }, + tune_timeout_ms: self.tune_wait_ms, + }; + Ok(Box::new(ec_linux_iptv::open_stream(&config)?)) + } + + fn source_id(&self) -> SourceId { + SourceId { + kind: "linux-dvb".to_string(), + device_id: Some(format!("adapter{}:dvr{}", self.adapter, self.dvr)), + channel: None, + } + } +} + +#[derive(Debug, Clone)] +pub struct TsSource { + pub input: String, +} + +impl StreamSource for TsSource { + fn open_stream(&self) -> Result> { + if self.input.starts_with("http://") || self.input.starts_with("https://") { + Ok(Box::new(ec_hdhomerun::open_stream_url(&self.input, None)?)) + } else { + Ok(Box::new(std::fs::File::open(&self.input)?)) + } + } + + fn source_id(&self) -> SourceId { + SourceId { + kind: "ts".to_string(), + device_id: None, + channel: None, + } + } +} + +#[derive(Debug, Clone, Copy, ValueEnum)] +pub enum HlsMode { + Passthrough, + Remux, + Transcode, +} + +impl Default for HlsMode { + fn default() -> Self { + HlsMode::Passthrough + } +} + +#[derive(Debug, Clone)] +pub struct HlsSource { + pub url: String, + pub mode: HlsMode, +} + +impl StreamSource for HlsSource { + fn open_stream(&self) -> Result> { + let mut cmd = Command::new("ffmpeg"); + cmd.arg("-hide_banner") + .arg("-loglevel") + .arg("error") + .arg("-nostdin") + .arg("-i") + .arg(&self.url); + + match self.mode { + HlsMode::Passthrough => { + cmd.arg("-c").arg("copy"); + } + HlsMode::Remux => { + cmd.arg("-fflags").arg("+genpts").arg("-c").arg("copy"); + } + HlsMode::Transcode => { + let profile = deterministic_h264_profile(); + for arg in ffmpeg_profile_args(&profile) { + cmd.arg(arg); + } + } + } + + cmd.arg("-f") + .arg("mpegts") + .arg("pipe:1") + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()); + + let mut child = cmd + .spawn() + .map_err(|err| anyhow!("failed to spawn ffmpeg: {err}"))?; + let stdout = child + .stdout + .take() + .ok_or_else(|| anyhow!("ffmpeg stdout unavailable"))?; + Ok(Box::new(FfmpegChildStream { child, stdout })) + } + + fn source_id(&self) -> SourceId { + SourceId { + kind: "hls".to_string(), + device_id: None, + channel: Some(self.url.clone()), + } + } +} + +struct FfmpegChildStream { + child: Child, + stdout: std::process::ChildStdout, +} + +impl Read for FfmpegChildStream { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + self.stdout.read(buf) + } +} + +impl Drop for FfmpegChildStream { + fn drop(&mut self) { + let _ = self.child.kill(); + } +} + +pub fn deterministic_transcode_stream( + reader: Box, +) -> Result> { + let profile = deterministic_h264_profile(); + let mut cmd = Command::new("ffmpeg"); + cmd.arg("-hide_banner") + .arg("-loglevel") + .arg("error") + .arg("-nostdin") + .arg("-i") + .arg("pipe:0"); + + for arg in ffmpeg_profile_args(&profile) { + cmd.arg(arg); + } + + cmd.arg("-f") + .arg("mpegts") + .arg("pipe:1") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()); + + let mut child = cmd + .spawn() + .map_err(|err| anyhow!("failed to spawn ffmpeg: {err}"))?; + let mut stdin = child + .stdin + .take() + .ok_or_else(|| anyhow!("ffmpeg stdin unavailable"))?; + let stdout = child + .stdout + .take() + .ok_or_else(|| anyhow!("ffmpeg stdout unavailable"))?; + + let writer = thread::spawn(move || { + let mut reader = reader; + let _ = std::io::copy(&mut reader, &mut stdin); + }); + + Ok(Box::new(FfmpegTranscodeStream { + child, + stdout, + writer: Some(writer), + })) +} + +struct FfmpegTranscodeStream { + child: Child, + stdout: std::process::ChildStdout, + writer: Option>, +} + +impl Read for FfmpegTranscodeStream { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + self.stdout.read(buf) + } +} + +impl Drop for FfmpegTranscodeStream { + fn drop(&mut self) { + let _ = self.child.kill(); + if let Some(writer) = self.writer.take() { + let _ = writer.join(); + } + } +} diff --git a/crates/ec-node/tests/determinism_cmaf_ladder.rs b/crates/ec-node/tests/determinism_cmaf_ladder.rs new file mode 100644 index 0000000..69c7669 --- /dev/null +++ b/crates/ec-node/tests/determinism_cmaf_ladder.rs @@ -0,0 +1,308 @@ +use std::io::{BufRead, BufReader}; +use std::path::Path; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +fn ec_node_path() -> std::path::PathBuf { + if let Ok(value) = std::env::var("EC_NODE_BIN") { + return value.into(); + } + if let Ok(value) = std::env::var("CARGO_BIN_EXE_ec_node") { + return value.into(); + } + if let Ok(value) = std::env::var("CARGO_BIN_EXE_ec-node") { + return value.into(); + } + let exe = std::env::current_exe().expect("current_exe"); + let debug_dir = exe + .parent() + .and_then(|p| p.parent()) + .expect("expected target/debug/deps"); + debug_dir.join("ec-node") +} + +fn wait_for_line_prefix( + lines: &mut dyn Iterator>, + prefix: &str, + timeout: Duration, +) -> Option { + let deadline = Instant::now() + timeout; + while Instant::now() < deadline { + match lines.next() { + Some(Ok(line)) => { + if let Some(rest) = line.strip_prefix(prefix) { + return Some(rest.trim().to_string()); + } + } + Some(Err(_)) => continue, + None => break, + } + } + None +} + +fn blake3_hex(path: &Path) -> anyhow::Result { + let bytes = std::fs::read(path)?; + Ok(blake3::hash(&bytes).to_hex().to_string()) +} + +fn concat_init_and_segment(init: &Path, seg: &Path, out: &Path) -> anyhow::Result<()> { + let init_bytes = std::fs::read(init)?; + let seg_bytes = std::fs::read(seg)?; + let mut bytes = Vec::with_capacity(init_bytes.len() + seg_bytes.len()); + bytes.extend_from_slice(&init_bytes); + bytes.extend_from_slice(&seg_bytes); + std::fs::write(out, bytes)?; + Ok(()) +} + +fn first_video_frame_keyframe_flag(mp4: &Path) -> anyhow::Result { + if Command::new("ffprobe") + .arg("-version") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .is_err() + { + // Cross-OS environments might not have ffprobe installed; treat as skip. + return Ok(1); + } + // Read only the first decoded frame record. For fMP4 this works reliably if we concat init+seg. + let out = Command::new("ffprobe") + .arg("-v") + .arg("error") + .arg("-select_streams") + .arg("v:0") + .arg("-show_frames") + .arg("-read_intervals") + .arg("%+#1") + .arg("-show_entries") + .arg("frame=key_frame") + .arg("-of") + .arg("csv=p=0") + .arg(mp4) + .output()?; + if !out.status.success() { + anyhow::bail!("ffprobe failed: {}", String::from_utf8_lossy(&out.stderr)); + } + let s = String::from_utf8_lossy(&out.stdout); + let first = s.lines().next().unwrap_or("").trim(); + // Some ffprobe builds may append extra columns (e.g. side data) even with restricted + // `-show_entries`. We only care about the first token. + let token = first.split(',').next().unwrap_or("").trim(); + let flag: u32 = token + .parse() + .map_err(|_| anyhow::anyhow!("unexpected ffprobe output: {first:?}"))?; + Ok(flag) +} + +fn write_deterministic_ts(out_path: &Path) -> anyhow::Result<()> { + // Deterministic synthetic A/V source: 30fps CFR with a fixed sine audio tone. + // Output: MPEG-TS, constrained to a stable keyframe cadence (g=60 -> 2s GOP). + let status = Command::new("ffmpeg") + .arg("-hide_banner") + .arg("-loglevel") + .arg("error") + .arg("-nostdin") + .arg("-y") + .arg("-f") + .arg("lavfi") + .arg("-i") + .arg("testsrc2=size=1280x720:rate=30") + .arg("-f") + .arg("lavfi") + .arg("-i") + .arg("sine=frequency=1000:sample_rate=48000") + .arg("-t") + .arg("10") + .arg("-map") + .arg("0:v:0") + .arg("-map") + .arg("1:a:0") + .arg("-c:v") + .arg("libx264") + .arg("-pix_fmt") + .arg("yuv420p") + .arg("-g") + .arg("60") + .arg("-keyint_min") + .arg("60") + .arg("-sc_threshold") + .arg("0") + .arg("-bf") + .arg("0") + .arg("-threads") + .arg("1") + .arg("-fflags") + .arg("+bitexact") + .arg("-flags:v") + .arg("+bitexact") + .arg("-c:a") + .arg("aac") + .arg("-b:a") + .arg("128k") + .arg("-ac") + .arg("2") + .arg("-ar") + .arg("48000") + .arg("-flags:a") + .arg("+bitexact") + .arg("-f") + .arg("mpegts") + .arg(out_path) + .status()?; + if !status.success() { + anyhow::bail!("ffmpeg synthetic TS generation failed with {status}"); + } + Ok(()) +} + +fn run_ladder(ec_node: &Path, input_ts: &Path, out_dir: &Path) -> anyhow::Result<()> { + let signing_key = "11".repeat(32); + let network_secret = "22".repeat(32); + let stream_id = "every.channel/determinism/cmaf-ladder"; + let broadcast_name = "every.channel/determinism/cmaf-ladder"; + + let mut cmd = Command::new(ec_node); + cmd.env("EVERY_CHANNEL_MANIFEST_SIGNING_KEY", &signing_key) + .arg("moq-publish") + .arg("--publish-manifests") + .arg("--encode") + .arg("cmaf") + .arg("--cmaf-ladder") + .arg("hd3") + .arg("--epoch-chunks") + .arg("1") + .arg("--max-chunks") + .arg("3") + .arg("--chunk-ms") + .arg("2000") + .arg("--stream-id") + .arg(stream_id) + .arg("--broadcast-name") + .arg(broadcast_name) + .arg("--track-name") + .arg("chunks") + .arg("--init-track") + .arg("init") + .arg("--network-secret") + .arg(&network_secret) + .arg("--chunk-dir") + .arg(out_dir) + .arg("--startup-delay-ms") + .arg("0") + .arg("ts") + .arg(input_ts) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()); + + // This will run until --max-chunks is reached, then exit. + let mut child = cmd.spawn()?; + let stdout = child.stdout.take().expect("publisher stdout missing"); + let mut lines = BufReader::new(stdout).lines(); + let _remote = wait_for_line_prefix(&mut lines, "moq endpoint addr: ", Duration::from_secs(10)) + .ok_or_else(|| anyhow::anyhow!("publisher did not print endpoint addr"))?; + + let status = child.wait()?; + if !status.success() { + anyhow::bail!("publisher failed: {status}"); + } + Ok(()) +} + +#[test] +#[ignore] +fn deterministic_cmaf_ladder_outputs_match_across_runs() { + let ec_node = ec_node_path(); + + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis(); + let tmp = std::env::temp_dir().join(format!("ec-determinism-cmaf-ladder-{ts}")); + let _ = std::fs::create_dir_all(&tmp); + + let input_ts = tmp.join("input.ts"); + write_deterministic_ts(&input_ts).expect("write deterministic TS"); + + let run1 = tmp.join("run1"); + let run2 = tmp.join("run2"); + let _ = std::fs::remove_dir_all(&run1); + let _ = std::fs::remove_dir_all(&run2); + std::fs::create_dir_all(&run1).unwrap(); + std::fs::create_dir_all(&run2).unwrap(); + + run_ladder(&ec_node, &input_ts, &run1).expect("run ladder 1"); + run_ladder(&ec_node, &input_ts, &run2).expect("run ladder 2"); + + for variant in ["1080p", "720p", "480p"] { + let v1 = run1.join("cmaf-ladder").join(variant); + let v2 = run2.join("cmaf-ladder").join(variant); + + let init1 = v1.join("init.mp4"); + let init2 = v2.join("init.mp4"); + assert!( + init1.exists() && init2.exists(), + "missing init for {variant}" + ); + assert_eq!( + blake3_hex(&init1).unwrap(), + blake3_hex(&init2).unwrap(), + "init differs for {variant}" + ); + + for idx in 0..3 { + let s1 = v1.join(format!("segment_{idx:06}.m4s")); + let s2 = v2.join(format!("segment_{idx:06}.m4s")); + assert!( + s1.exists() && s2.exists(), + "missing segment {idx} for {variant}" + ); + assert_eq!( + blake3_hex(&s1).unwrap(), + blake3_hex(&s2).unwrap(), + "segment {idx} differs for {variant}" + ); + } + } +} + +#[test] +#[ignore] +fn cmaf_ladder_segments_start_with_keyframes() { + let ec_node = ec_node_path(); + + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis(); + let tmp = std::env::temp_dir().join(format!("ec-determinism-cmaf-ladder-kf-{ts}")); + let _ = std::fs::create_dir_all(&tmp); + + let input_ts = tmp.join("input.ts"); + write_deterministic_ts(&input_ts).expect("write deterministic TS"); + + let run = tmp.join("run"); + let _ = std::fs::remove_dir_all(&run); + std::fs::create_dir_all(&run).unwrap(); + run_ladder(&ec_node, &input_ts, &run).expect("run ladder"); + + for variant in ["1080p", "720p", "480p"] { + let v = run.join("cmaf-ladder").join(variant); + let init = v.join("init.mp4"); + assert!(init.exists(), "missing init for {variant}"); + + for idx in 0..3 { + let seg = v.join(format!("segment_{idx:06}.m4s")); + assert!(seg.exists(), "missing segment {idx} for {variant}"); + + let stitched = tmp.join(format!("stitched-{variant}-{idx:06}.mp4")); + concat_init_and_segment(&init, &seg, &stitched).unwrap(); + let keyflag = first_video_frame_keyframe_flag(&stitched).unwrap(); + assert_eq!( + keyflag, 1, + "segment {idx} not keyframe-aligned for {variant}" + ); + } + } +} diff --git a/crates/ec-node/tests/e2e_cmaf_ladder.rs b/crates/ec-node/tests/e2e_cmaf_ladder.rs new file mode 100644 index 0000000..6970c70 --- /dev/null +++ b/crates/ec-node/tests/e2e_cmaf_ladder.rs @@ -0,0 +1,231 @@ +use std::io::{BufRead, BufReader, Read}; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +const TS_PACKET_SIZE: usize = 188; + +fn env_required(key: &str) -> Option { + std::env::var(key) + .ok() + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) +} + +fn ec_node_path() -> std::path::PathBuf { + if let Ok(value) = std::env::var("EC_NODE_BIN") { + return value.into(); + } + if let Ok(value) = std::env::var("CARGO_BIN_EXE_ec_node") { + return value.into(); + } + if let Ok(value) = std::env::var("CARGO_BIN_EXE_ec-node") { + return value.into(); + } + let exe = std::env::current_exe().expect("current_exe"); + let debug_dir = exe + .parent() + .and_then(|p| p.parent()) + .expect("expected target/debug/deps"); + debug_dir.join("ec-node") +} + +fn wait_for_line_prefix( + lines: &mut dyn Iterator>, + prefix: &str, + timeout: Duration, +) -> Option { + let deadline = Instant::now() + timeout; + while Instant::now() < deadline { + match lines.next() { + Some(Ok(line)) => { + if let Some(rest) = line.strip_prefix(prefix) { + return Some(rest.trim().to_string()); + } + } + Some(Err(_)) => continue, + None => break, + } + } + None +} + +fn write_short_ts_recording( + host: &str, + channel: &str, + out_path: &std::path::Path, +) -> anyhow::Result<()> { + // Use lineup to resolve name -> number, but capture from the provided host. + // (OrbStack/Linux may not resolve the lineup URL's mDNS hostname.) + let device = ec_hdhomerun::discover_from_host(host)?; + let lineup = ec_hdhomerun::fetch_lineup(&device)?; + let entry = ec_hdhomerun::find_lineup_entry_by_number(&lineup, channel) + .or_else(|| ec_hdhomerun::find_lineup_entry_by_name(&lineup, channel)) + .ok_or_else(|| anyhow::anyhow!("channel not found in lineup: {channel}"))?; + + let guide_number = entry.channel.number.as_deref().unwrap_or(channel); + let capture_url = format!("http://{host}:5004/auto/v{guide_number}"); + + // Capture a short TS sample directly from the HDHR. + // Retry a few times to handle "no tuner available" 5xx responses. + let mut last_err: Option = None; + for attempt in 0..10 { + match ec_hdhomerun::open_stream_url(&capture_url, Some(14)) { + Ok(mut stream) => { + let mut file = std::fs::File::create(out_path)?; + std::io::copy(&mut stream, &mut file)?; + last_err = None; + break; + } + Err(err) => { + last_err = Some(err); + std::thread::sleep(Duration::from_millis(400 * (attempt + 1) as u64)); + continue; + } + } + } + if let Some(err) = last_err { + return Err(err); + } + + let mut file = std::fs::File::open(out_path)?; + let mut bytes = Vec::new(); + file.read_to_end(&mut bytes)?; + let mut len = bytes.len(); + let rem = len % TS_PACKET_SIZE; + if rem != 0 { + len -= rem; + std::fs::write(out_path, &bytes[..len])?; + } + if len < 188 * 200 { + anyhow::bail!("recorded TS too small ({} bytes) from HDHR {}", len, host); + } + Ok(()) +} + +#[test] +#[ignore] +fn e2e_cmaf_ladder_one_publisher_three_subscribers_verify_manifests() { + let host = match env_required("EVERY_CHANNEL_E2E_HDHR_HOST") { + Some(v) => v, + None => return, // skip + }; + let channel = match env_required("EVERY_CHANNEL_E2E_HDHR_CHANNEL") { + Some(v) => v, + None => return, // skip + }; + + let ec_node = ec_node_path(); + + // Keep secrets deterministic for reproducibility. + let signing_key = "11".repeat(32); + let network_secret = "22".repeat(32); + + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis(); + let stream_id = format!("every.channel/e2e/cmaf-ladder/{ts}"); + let broadcast_name = stream_id.clone(); + + let tmp = std::env::temp_dir().join(format!("ec-e2e-cmaf-ladder-{ts}")); + let _ = std::fs::create_dir_all(&tmp); + let input_ts = tmp.join("input.ts"); + + write_short_ts_recording(&host, &channel, &input_ts).expect("failed to record TS from HDHR"); + + let mut publisher = Command::new(&ec_node); + publisher + .env("EVERY_CHANNEL_MANIFEST_SIGNING_KEY", &signing_key) + .arg("moq-publish") + .arg("--publish-manifests") + .arg("--encode") + .arg("cmaf") + .arg("--cmaf-ladder") + .arg("hd3") + .arg("--epoch-chunks") + .arg("1") + .arg("--max-chunks") + .arg("3") + .arg("--chunk-ms") + .arg("2000") + .arg("--stream-id") + .arg(&stream_id) + .arg("--broadcast-name") + .arg(&broadcast_name) + .arg("--track-name") + .arg("chunks") + .arg("--init-track") + .arg("init") + .arg("--manifest-track") + .arg("manifests") + .arg("--network-secret") + .arg(&network_secret) + .arg("--chunk-dir") + .arg(tmp.join("pub-chunks")) + .arg("--startup-delay-ms") + .arg("4000") + .arg("ts") + .arg(input_ts.to_string_lossy().to_string()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()); + + let mut pub_child = publisher.spawn().expect("spawn publisher"); + let pub_stdout = pub_child.stdout.take().expect("publisher stdout missing"); + let mut pub_lines = BufReader::new(pub_stdout).lines(); + let remote = wait_for_line_prefix( + &mut pub_lines, + "moq endpoint addr: ", + Duration::from_secs(10), + ) + .expect("publisher did not print endpoint addr"); + + let variants = ["1080p", "720p", "480p"]; + let mut subscribers = Vec::new(); + for variant in variants { + let out_dir = tmp.join(format!("sub-{variant}")); + let mut sub = Command::new(&ec_node); + sub.arg("moq-subscribe") + .arg("--remote") + .arg(&remote) + .arg("--remote-manifests") + .arg(&remote) + .arg("--broadcast-name") + .arg(&broadcast_name) + .arg("--track-name") + .arg(format!("chunks/{variant}")) + .arg("--subscribe-manifests") + .arg("--require-manifest") + .arg("--manifest-track") + .arg("manifests") + .arg("--container") + .arg("cmaf") + .arg("--subscribe-init") + .arg("--init-track") + .arg(format!("init/{variant}")) + .arg("--raw-cmaf") + .arg("--stop-after") + .arg("2") + .arg("--network-secret") + .arg(&network_secret) + .arg("--output-dir") + .arg(&out_dir) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()); + subscribers.push(( + variant.to_string(), + out_dir, + sub.spawn().expect("spawn subscriber"), + )); + } + + for (variant, out_dir, mut child) in subscribers { + let status = child.wait().expect("wait subscriber"); + assert!(status.success(), "subscriber {variant} failed: {status}"); + let init = out_dir.join("init.mp4"); + assert!(init.exists(), "subscriber {variant} missing init.mp4"); + let seg0 = out_dir.join("segment_000000.m4s"); + assert!(seg0.exists(), "subscriber {variant} missing first segment"); + } + + let _ = pub_child.kill(); +} diff --git a/crates/ec-node/tests/e2e_hdhr.rs b/crates/ec-node/tests/e2e_hdhr.rs new file mode 100644 index 0000000..f540bf5 --- /dev/null +++ b/crates/ec-node/tests/e2e_hdhr.rs @@ -0,0 +1,211 @@ +use std::io::{BufRead, BufReader}; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +fn env_required(key: &str) -> Option { + std::env::var(key) + .ok() + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) +} + +fn looks_drm(value: &str) -> bool { + let value = value.to_lowercase(); + value.contains("drm") + || value.contains("encrypted") + || value.contains("protected") + || value.contains("copy") + || value.contains("widevine") +} + +fn autodiscover_hdhr_host_and_channel() -> Option<(String, String)> { + let devices = ec_hdhomerun::discover().ok()?; + let device = devices.into_iter().next()?; + let lineup = ec_hdhomerun::fetch_lineup(&device).ok()?; + let entry = lineup.iter().find(|e| { + let tag_drm = e.tags.iter().any(|t| looks_drm(t)); + let raw_drm = e + .raw + .as_object() + .map(|obj| { + obj.iter() + .any(|(k, v)| looks_drm(k) || looks_drm(&v.to_string())) + }) + .unwrap_or(false); + !tag_drm && !raw_drm && e.channel.number.as_deref().unwrap_or("").trim() != "" + })?; + let host = device.ip.clone(); + let channel = entry + .channel + .number + .clone() + .or_else(|| Some(entry.channel.name.clone())) + .unwrap_or_else(|| "2.1".to_string()); + Some((host, channel)) +} + +fn ec_node_path() -> std::path::PathBuf { + if let Ok(value) = std::env::var("EC_NODE_BIN") { + return value.into(); + } + if let Ok(value) = std::env::var("CARGO_BIN_EXE_ec_node") { + return value.into(); + } + if let Ok(value) = std::env::var("CARGO_BIN_EXE_ec-node") { + return value.into(); + } + // Fallback: assume a standard cargo target layout. + let exe = std::env::current_exe().expect("current_exe"); + let debug_dir = exe + .parent() + .and_then(|p| p.parent()) + .expect("expected target/debug/deps"); + let bin = debug_dir.join("ec-node"); + bin +} + +#[test] +#[ignore] +fn e2e_hdhr_publish_then_subscribe_with_manifest_and_encryption() { + let host = env_required("EVERY_CHANNEL_E2E_HDHR_HOST"); + let channel = env_required("EVERY_CHANNEL_E2E_HDHR_CHANNEL"); + let (host, channel) = match (host, channel) { + (Some(host), Some(channel)) => (host, channel), + _ => match autodiscover_hdhr_host_and_channel() { + Some(v) => v, + None => return, // skip + }, + }; + + let ec_node = ec_node_path(); + + // Keep secrets deterministic for reproducibility. + let signing_key = "11".repeat(32); + let network_secret = "22".repeat(32); + + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis(); + let broadcast_name = format!("every.channel/e2e/{ts}"); + + let tmp = std::env::temp_dir().join(format!("ec-e2e-hdhr-{ts}")); + let publish_chunks = tmp.join("publish-chunks"); + let subscribe_out = tmp.join("subscribe-out"); + + let mut publisher = Command::new(&ec_node); + publisher + .env("EVERY_CHANNEL_MANIFEST_SIGNING_KEY", &signing_key) + .arg("moq-publish") + .arg("--publish-manifests") + .arg("--epoch-chunks") + .arg("1") + .arg("--max-chunks") + .arg("8") + .arg("--chunk-ms") + .arg("2000") + .arg("--broadcast-name") + .arg(&broadcast_name) + .arg("--network-secret") + .arg(&network_secret) + .arg("--chunk-dir") + .arg(&publish_chunks) + .arg("hdhr") + .arg("--host") + .arg(&host) + .arg("--channel") + .arg(&channel) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()); + + let mut child = publisher.spawn().expect("failed to spawn publisher"); + let stdout = child.stdout.take().expect("publisher stdout missing"); + let mut lines = BufReader::new(stdout).lines(); + + let mut remote: Option = None; + let mut track: Option = None; + let deadline = Instant::now() + Duration::from_secs(10); + while Instant::now() < deadline { + let line = match lines.next() { + Some(Ok(line)) => line, + Some(Err(_)) => continue, + None => break, + }; + if let Some(rest) = line.strip_prefix("moq endpoint addr: ") { + remote = Some(rest.trim().to_string()); + } else if let Some(rest) = line.strip_prefix("moq track: ") { + track = Some(rest.trim().to_string()); + } + if remote.is_some() && track.is_some() { + break; + } + } + + let remote = remote.expect("publisher did not print endpoint addr in time"); + let track = track.expect("publisher did not print track in time"); + + let mut subscriber = Command::new(&ec_node); + subscriber + .arg("moq-subscribe") + .arg("--remote") + .arg(&remote) + .arg("--broadcast-name") + .arg(&broadcast_name) + .arg("--track-name") + .arg(&track) + .arg("--subscribe-manifests") + .arg("--require-manifest") + .arg("--max-invalid-chunks") + .arg("0") + .arg("--stop-after") + .arg("3") + .arg("--output-dir") + .arg(&subscribe_out) + .arg("--chunk-ms") + .arg("2000") + .arg("--network-secret") + .arg(&network_secret) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()); + + let mut sub_child = subscriber.spawn().expect("failed to spawn subscriber"); + let start = Instant::now(); + loop { + if let Ok(Some(status)) = sub_child.try_wait() { + assert!(status.success(), "subscriber exited with {status}"); + break; + } + if start.elapsed() > Duration::from_secs(30) { + let _ = sub_child.kill(); + panic!("subscriber timed out"); + } + std::thread::sleep(Duration::from_millis(200)); + } + + // Publisher should exit after max chunks; don't hang forever. + let start = Instant::now(); + loop { + if let Ok(Some(status)) = child.try_wait() { + assert!(status.success(), "publisher exited with {status}"); + break; + } + if start.elapsed() > Duration::from_secs(30) { + let _ = child.kill(); + panic!("publisher timed out"); + } + std::thread::sleep(Duration::from_millis(200)); + } + + let playlist = subscribe_out.join("index.m3u8"); + assert!( + playlist.exists(), + "missing playlist at {}", + playlist.display() + ); + let segments = std::fs::read_dir(&subscribe_out) + .unwrap() + .filter_map(|e| e.ok()) + .filter(|e| e.file_name().to_string_lossy().starts_with("segment_")) + .count(); + assert!(segments >= 1, "expected at least one segment"); +} diff --git a/crates/ec-node/tests/e2e_mesh_split.rs b/crates/ec-node/tests/e2e_mesh_split.rs new file mode 100644 index 0000000..9b80764 --- /dev/null +++ b/crates/ec-node/tests/e2e_mesh_split.rs @@ -0,0 +1,305 @@ +use std::io::{BufRead, BufReader, Read, Write}; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +const TS_PACKET_SIZE: usize = 188; + +fn env_required(key: &str) -> Option { + std::env::var(key) + .ok() + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) +} + +fn ec_node_path() -> std::path::PathBuf { + if let Ok(value) = std::env::var("EC_NODE_BIN") { + return value.into(); + } + if let Ok(value) = std::env::var("CARGO_BIN_EXE_ec_node") { + return value.into(); + } + if let Ok(value) = std::env::var("CARGO_BIN_EXE_ec-node") { + return value.into(); + } + let exe = std::env::current_exe().expect("current_exe"); + let debug_dir = exe + .parent() + .and_then(|p| p.parent()) + .expect("expected target/debug/deps"); + debug_dir.join("ec-node") +} + +fn wait_for_line_prefix( + lines: &mut dyn Iterator>, + prefix: &str, + timeout: Duration, +) -> Option { + let deadline = Instant::now() + timeout; + while Instant::now() < deadline { + match lines.next() { + Some(Ok(line)) => { + if let Some(rest) = line.strip_prefix(prefix) { + return Some(rest.trim().to_string()); + } + } + Some(Err(_)) => continue, + None => break, + } + } + None +} + +fn write_short_ts_recording( + host: &str, + channel: &str, + out_path: &std::path::Path, +) -> anyhow::Result<()> { + // Use the lineup's stream URL so we get the correct host/port (often :5004). + // HDHomeRun supports `duration=...` on the stream URL on many models. + // We also cap by time/bytes to avoid hanging if duration is ignored. + let device = ec_hdhomerun::discover_from_host(host)?; + let lineup = ec_hdhomerun::fetch_lineup(&device)?; + let entry = ec_hdhomerun::find_lineup_entry_by_number(&lineup, channel) + .or_else(|| ec_hdhomerun::find_lineup_entry_by_name(&lineup, channel)) + .ok_or_else(|| anyhow::anyhow!("channel not found in lineup: {channel}"))?; + + // Tuner allocation can transiently fail (503) if another client is using all tuners. + // Retry briefly; we only need a short capture. + let mut last_err: Option = None; + let mut stream = loop { + match ec_hdhomerun::open_stream_entry(entry, Some(8)) { + Ok(stream) => break stream, + Err(err) => { + let msg = format!("{err:#}"); + last_err = Some(err); + if msg.contains("503") { + std::thread::sleep(Duration::from_millis(500)); + continue; + } + return Err(last_err.unwrap()); + } + } + }; + + let mut file = std::fs::File::create(out_path)?; + let start = Instant::now(); + let mut bytes = 0usize; + let mut buf = [0u8; 64 * 1024]; + loop { + let n = stream.read(&mut buf)?; + if n == 0 { + break; + } + file.write_all(&buf[..n])?; + bytes += n; + if bytes >= 8 * 1024 * 1024 { + break; + } + if start.elapsed() > Duration::from_secs(6) { + break; + } + } + file.flush()?; + // Ensure the TS file ends on a packet boundary. + let len = file.metadata()?.len(); + let rem = (len as usize) % TS_PACKET_SIZE; + if rem != 0 { + file.set_len(len - rem as u64)?; + bytes = (len as usize) - rem; + } + if bytes < 188 * 20 { + anyhow::bail!("recorded TS too small ({} bytes) from HDHR {}", bytes, host); + } + Ok(()) +} + +#[test] +#[ignore] +fn e2e_split_sources_manifests_from_one_peer_objects_from_another() { + let host = match env_required("EVERY_CHANNEL_E2E_HDHR_HOST") { + Some(v) => v, + None => return, // skip + }; + let channel = match env_required("EVERY_CHANNEL_E2E_HDHR_CHANNEL") { + Some(v) => v, + None => return, // skip + }; + + let ec_node = ec_node_path(); + + // Keep secrets deterministic for reproducibility. + let signing_key = "11".repeat(32); + let network_secret = "22".repeat(32); + + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis(); + let stream_id = format!("every.channel/e2e/mesh/{ts}"); + let broadcast_name = stream_id.clone(); + + let tmp = std::env::temp_dir().join(format!("ec-e2e-mesh-split-{ts}")); + let _ = std::fs::create_dir_all(&tmp); + let input_ts = tmp.join("input.ts"); + let manifest_chunks = tmp.join("chunks-manifests"); + let object_chunks = tmp.join("chunks-objects"); + let subscribe_out = tmp.join("subscribe-out"); + + write_short_ts_recording(&host, &channel, &input_ts).expect("failed to record TS from HDHR"); + + // Publisher A: leader/signer, publishes manifests only. + // Give subscribers time to connect before ingest starts. + let mut pub_manifests = Command::new(&ec_node); + pub_manifests + .env("EVERY_CHANNEL_MANIFEST_SIGNING_KEY", &signing_key) + .arg("moq-publish") + .arg("--publish-manifests") + .arg("--publish-chunks") + .arg("false") + .arg("--epoch-chunks") + .arg("1") + .arg("--max-chunks") + .arg("6") + .arg("--chunk-ms") + .arg("2000") + .arg("--stream-id") + .arg(&stream_id) + .arg("--broadcast-name") + .arg(&broadcast_name) + .arg("--track-name") + .arg("noop") + .arg("--manifest-track") + .arg("manifests") + .arg("--network-secret") + .arg(&network_secret) + .arg("--chunk-dir") + .arg(&manifest_chunks) + .arg("--startup-delay-ms") + .arg("5000") + .arg("ts") + .arg(input_ts.to_string_lossy().to_string()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()); + + let mut pub_a = pub_manifests.spawn().expect("spawn manifest publisher"); + let a_stdout = pub_a + .stdout + .take() + .expect("manifest publisher stdout missing"); + let mut a_lines = BufReader::new(a_stdout).lines(); + let remote_manifests = + wait_for_line_prefix(&mut a_lines, "moq endpoint addr: ", Duration::from_secs(10)) + .expect("manifest publisher did not print endpoint addr"); + + // Publisher B: relay/data, publishes chunk objects only. + // Delay longer than the manifest publisher so the subscriber can receive manifests first. + let mut pub_objects = Command::new(&ec_node); + pub_objects + .arg("moq-publish") + .arg("--publish-chunks") + .arg("true") + .arg("--max-chunks") + .arg("6") + .arg("--chunk-ms") + .arg("2000") + .arg("--stream-id") + .arg(&stream_id) + .arg("--broadcast-name") + .arg(&broadcast_name) + .arg("--track-name") + .arg("objects") + .arg("--network-secret") + .arg(&network_secret) + .arg("--chunk-dir") + .arg(&object_chunks) + .arg("--startup-delay-ms") + .arg("9000") + .arg("ts") + .arg(input_ts.to_string_lossy().to_string()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()); + + let mut pub_b = pub_objects.spawn().expect("spawn object publisher"); + let b_stdout = pub_b + .stdout + .take() + .expect("object publisher stdout missing"); + let mut b_lines = BufReader::new(b_stdout).lines(); + let remote_objects = + wait_for_line_prefix(&mut b_lines, "moq endpoint addr: ", Duration::from_secs(10)) + .expect("object publisher did not print endpoint addr"); + + // Subscriber: stitch objects from B with manifests from A. + let mut subscriber = Command::new(&ec_node); + subscriber + .arg("moq-subscribe") + .arg("--remote") + .arg(&remote_objects) + .arg("--remote-manifests") + .arg(&remote_manifests) + .arg("--broadcast-name") + .arg(&broadcast_name) + .arg("--track-name") + .arg("objects") + .arg("--manifest-track") + .arg("manifests") + .arg("--subscribe-manifests") + .arg("--require-manifest") + .arg("--max-invalid-chunks") + .arg("0") + .arg("--stop-after") + .arg("2") + .arg("--output-dir") + .arg(&subscribe_out) + .arg("--chunk-ms") + .arg("2000") + .arg("--stream-id") + .arg(&stream_id) + .arg("--network-secret") + .arg(&network_secret) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()); + + let mut sub_child = subscriber.spawn().expect("failed to spawn subscriber"); + let start = Instant::now(); + loop { + if let Ok(Some(status)) = sub_child.try_wait() { + assert!(status.success(), "subscriber exited with {status}"); + break; + } + if start.elapsed() > Duration::from_secs(30) { + let _ = sub_child.kill(); + panic!("subscriber timed out"); + } + std::thread::sleep(Duration::from_millis(200)); + } + + // Ensure publishers exit after max chunks. + for child in [&mut pub_a, &mut pub_b] { + let start = Instant::now(); + loop { + if let Ok(Some(status)) = child.try_wait() { + assert!(status.success(), "publisher exited with {status}"); + break; + } + if start.elapsed() > Duration::from_secs(30) { + let _ = child.kill(); + panic!("publisher timed out"); + } + std::thread::sleep(Duration::from_millis(200)); + } + } + + let playlist = subscribe_out.join("index.m3u8"); + assert!( + playlist.exists(), + "missing playlist at {}", + playlist.display() + ); + let segments = std::fs::read_dir(&subscribe_out) + .unwrap() + .filter_map(|e| e.ok()) + .filter(|e| e.file_name().to_string_lossy().starts_with("segment_")) + .count(); + assert!(segments >= 1, "expected at least one segment"); +} diff --git a/crates/ec-node/tests/e2e_mesh_split_cmaf.rs b/crates/ec-node/tests/e2e_mesh_split_cmaf.rs new file mode 100644 index 0000000..77f000f --- /dev/null +++ b/crates/ec-node/tests/e2e_mesh_split_cmaf.rs @@ -0,0 +1,345 @@ +use std::io::{BufRead, BufReader, Read}; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +const TS_PACKET_SIZE: usize = 188; + +fn env_required(key: &str) -> Option { + std::env::var(key) + .ok() + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) +} + +fn looks_drm(value: &str) -> bool { + let value = value.to_lowercase(); + value.contains("drm") + || value.contains("encrypted") + || value.contains("protected") + || value.contains("copy") + || value.contains("widevine") +} + +fn autodiscover_hdhr_host_and_channel() -> Option<(String, String)> { + let devices = ec_hdhomerun::discover().ok()?; + let device = devices.into_iter().next()?; + let lineup = ec_hdhomerun::fetch_lineup(&device).ok()?; + let entry = lineup.iter().find(|e| { + // Prefer a likely-clear channel to avoid false negatives in E2E. + let tag_drm = e.tags.iter().any(|t| looks_drm(t)); + let raw_drm = e + .raw + .as_object() + .map(|obj| { + obj.iter() + .any(|(k, v)| looks_drm(k) || looks_drm(&v.to_string())) + }) + .unwrap_or(false); + !tag_drm && !raw_drm && e.channel.number.as_deref().unwrap_or("").trim() != "" + })?; + let host = device.ip.clone(); + let channel = entry + .channel + .number + .clone() + .or_else(|| Some(entry.channel.name.clone())) + .unwrap_or_else(|| "2.1".to_string()); + Some((host, channel)) +} + +fn ec_node_path() -> std::path::PathBuf { + if let Ok(value) = std::env::var("EC_NODE_BIN") { + return value.into(); + } + if let Ok(value) = std::env::var("CARGO_BIN_EXE_ec_node") { + return value.into(); + } + if let Ok(value) = std::env::var("CARGO_BIN_EXE_ec-node") { + return value.into(); + } + let exe = std::env::current_exe().expect("current_exe"); + let debug_dir = exe + .parent() + .and_then(|p| p.parent()) + .expect("expected target/debug/deps"); + debug_dir.join("ec-node") +} + +fn wait_for_line_prefix( + lines: &mut dyn Iterator>, + prefix: &str, + timeout: Duration, +) -> Option { + let deadline = Instant::now() + timeout; + while Instant::now() < deadline { + match lines.next() { + Some(Ok(line)) => { + if let Some(rest) = line.strip_prefix(prefix) { + return Some(rest.trim().to_string()); + } + } + Some(Err(_)) => continue, + None => break, + } + } + None +} + +fn write_short_ts_recording( + host: &str, + channel: &str, + out_path: &std::path::Path, +) -> anyhow::Result<()> { + // Use lineup to resolve name -> number, but capture from the provided host. + // (OrbStack/Linux may not resolve the lineup URL's mDNS hostname.) + let device = ec_hdhomerun::discover_from_host(host)?; + let lineup = ec_hdhomerun::fetch_lineup(&device)?; + let entry = ec_hdhomerun::find_lineup_entry_by_number(&lineup, channel) + .or_else(|| ec_hdhomerun::find_lineup_entry_by_name(&lineup, channel)) + .ok_or_else(|| anyhow::anyhow!("channel not found in lineup: {channel}"))?; + + let guide_number = entry.channel.number.as_deref().unwrap_or(channel); + let capture_url = format!("http://{host}:5004/auto/v{guide_number}"); + + // Capture a short TS sample directly from the HDHR. + // Retry a few times to handle "no tuner available" 5xx responses. + let mut last_err: Option = None; + for attempt in 0..10 { + match ec_hdhomerun::open_stream_url(&capture_url, Some(12)) { + Ok(mut stream) => { + let mut file = std::fs::File::create(out_path)?; + std::io::copy(&mut stream, &mut file)?; + last_err = None; + break; + } + Err(err) => { + last_err = Some(err); + std::thread::sleep(Duration::from_millis(400 * (attempt + 1) as u64)); + continue; + } + } + } + if let Some(err) = last_err { + return Err(err); + } + + let mut file = std::fs::File::open(out_path)?; + let mut bytes = Vec::new(); + file.read_to_end(&mut bytes)?; + let mut len = bytes.len(); + // Ensure the TS file ends on a packet boundary. + let rem = len % TS_PACKET_SIZE; + if rem != 0 { + len -= rem; + std::fs::write(out_path, &bytes[..len])?; + } + if len < 188 * 200 { + anyhow::bail!("recorded TS too small ({} bytes) from HDHR {}", len, host); + } + Ok(()) +} + +#[test] +#[ignore] +fn e2e_split_sources_cmaf_init_from_objects_peer_segments_verified_by_manifests_peer() { + let host = env_required("EVERY_CHANNEL_E2E_HDHR_HOST"); + let channel = env_required("EVERY_CHANNEL_E2E_HDHR_CHANNEL"); + let (host, channel) = match (host, channel) { + (Some(host), Some(channel)) => (host, channel), + _ => match autodiscover_hdhr_host_and_channel() { + Some(v) => v, + None => return, // skip + }, + }; + + let ec_node = ec_node_path(); + + // Keep secrets deterministic for reproducibility. + let signing_key = "11".repeat(32); + let network_secret = "22".repeat(32); + + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis(); + let stream_id = format!("every.channel/e2e/mesh-cmaf/{ts}"); + let broadcast_name = stream_id.clone(); + + let tmp = std::env::temp_dir().join(format!("ec-e2e-mesh-split-cmaf-{ts}")); + let _ = std::fs::create_dir_all(&tmp); + let input_ts = tmp.join("input.ts"); + let manifest_chunks = tmp.join("chunks-manifests"); + let object_chunks = tmp.join("chunks-objects"); + let subscribe_out = tmp.join("subscribe-out"); + + write_short_ts_recording(&host, &channel, &input_ts).expect("failed to record TS from HDHR"); + + // Publisher A: leader/signer, publishes manifests only (for CMAF segments). + let mut pub_manifests = Command::new(&ec_node); + pub_manifests + .env("EVERY_CHANNEL_MANIFEST_SIGNING_KEY", &signing_key) + .arg("moq-publish") + .arg("--publish-manifests") + .arg("--publish-chunks") + .arg("false") + .arg("--encode") + .arg("cmaf") + .arg("--epoch-chunks") + .arg("1") + .arg("--max-chunks") + .arg("4") + .arg("--chunk-ms") + .arg("2000") + .arg("--stream-id") + .arg(&stream_id) + .arg("--broadcast-name") + .arg(&broadcast_name) + .arg("--track-name") + .arg("noop") + .arg("--manifest-track") + .arg("manifests") + .arg("--network-secret") + .arg(&network_secret) + .arg("--chunk-dir") + .arg(&manifest_chunks) + .arg("--startup-delay-ms") + .arg("6000") + .arg("ts") + .arg(input_ts.to_string_lossy().to_string()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()); + + let mut pub_a = pub_manifests.spawn().expect("spawn manifest publisher"); + let a_stdout = pub_a + .stdout + .take() + .expect("manifest publisher stdout missing"); + let mut a_lines = BufReader::new(a_stdout).lines(); + let remote_manifests = + wait_for_line_prefix(&mut a_lines, "moq endpoint addr: ", Duration::from_secs(10)) + .expect("manifest publisher did not print endpoint addr"); + + // Publisher B: publishes init + segments as objects only. + let mut pub_objects = Command::new(&ec_node); + pub_objects + .arg("moq-publish") + .arg("--publish-chunks") + .arg("true") + .arg("--encode") + .arg("cmaf") + .arg("--init-track") + .arg("init") + .arg("--max-chunks") + .arg("4") + .arg("--chunk-ms") + .arg("2000") + .arg("--stream-id") + .arg(&stream_id) + .arg("--broadcast-name") + .arg(&broadcast_name) + .arg("--track-name") + .arg("objects") + .arg("--network-secret") + .arg(&network_secret) + .arg("--chunk-dir") + .arg(&object_chunks) + .arg("--startup-delay-ms") + .arg("10000") + .arg("ts") + .arg(input_ts.to_string_lossy().to_string()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()); + + let mut pub_b = pub_objects.spawn().expect("spawn object publisher"); + let b_stdout = pub_b + .stdout + .take() + .expect("object publisher stdout missing"); + let mut b_lines = BufReader::new(b_stdout).lines(); + let remote_objects = + wait_for_line_prefix(&mut b_lines, "moq endpoint addr: ", Duration::from_secs(10)) + .expect("object publisher did not print endpoint addr"); + + // Subscriber: init+segments from B, manifests from A. + let mut subscriber = Command::new(&ec_node); + subscriber + .arg("moq-subscribe") + .arg("--remote") + .arg(&remote_objects) + .arg("--remote-manifests") + .arg(&remote_manifests) + .arg("--broadcast-name") + .arg(&broadcast_name) + .arg("--track-name") + .arg("objects") + .arg("--manifest-track") + .arg("manifests") + .arg("--subscribe-manifests") + .arg("--require-manifest") + .arg("--max-invalid-chunks") + .arg("0") + .arg("--container") + .arg("cmaf") + .arg("--subscribe-init") + .arg("--init-track") + .arg("init") + .arg("--stop-after") + .arg("2") + .arg("--output-dir") + .arg(&subscribe_out) + .arg("--chunk-ms") + .arg("2000") + .arg("--stream-id") + .arg(&stream_id) + .arg("--network-secret") + .arg(&network_secret) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()); + + let mut sub_child = subscriber.spawn().expect("failed to spawn subscriber"); + let start = Instant::now(); + loop { + if let Ok(Some(status)) = sub_child.try_wait() { + assert!(status.success(), "subscriber exited with {status}"); + break; + } + if start.elapsed() > Duration::from_secs(60) { + let _ = sub_child.kill(); + panic!("subscriber timed out"); + } + std::thread::sleep(Duration::from_millis(200)); + } + + // Ensure publishers exit after max chunks. + for child in [&mut pub_a, &mut pub_b] { + let start = Instant::now(); + loop { + if let Ok(Some(status)) = child.try_wait() { + assert!(status.success(), "publisher exited with {status}"); + break; + } + if start.elapsed() > Duration::from_secs(90) { + let _ = child.kill(); + panic!("publisher timed out"); + } + std::thread::sleep(Duration::from_millis(200)); + } + } + + let playlist = subscribe_out.join("index.m3u8"); + assert!( + playlist.exists(), + "missing playlist at {}", + playlist.display() + ); + + let init = subscribe_out.join("init.mp4"); + assert!(init.exists(), "missing init segment at {}", init.display()); + + let segments = std::fs::read_dir(&subscribe_out) + .unwrap() + .filter_map(|e| e.ok()) + .filter(|e| e.file_name().to_string_lossy().ends_with(".m4s")) + .count(); + assert!(segments >= 1, "expected at least one .m4s segment"); +} diff --git a/crates/ec-node/tests/e2e_remote_website_direct.rs b/crates/ec-node/tests/e2e_remote_website_direct.rs new file mode 100644 index 0000000..19c3924 --- /dev/null +++ b/crates/ec-node/tests/e2e_remote_website_direct.rs @@ -0,0 +1,314 @@ +use std::ffi::OsStr; +use std::io::{BufRead, BufReader, Write}; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +fn which(cmd: &str) -> Option { + if let Ok(path) = which::which(cmd) { + return Some(path); + } + None +} + +fn chrome_path() -> Option { + // Prefer the standard macOS Chrome app bundle. + let mac = + std::path::PathBuf::from("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"); + if mac.exists() { + return Some(mac); + } + which("google-chrome") + .or_else(|| which("google-chrome-stable")) + .or_else(|| which("chromium")) +} + +fn ec_node_path() -> std::path::PathBuf { + if let Ok(value) = std::env::var("EC_NODE_BIN") { + return value.into(); + } + if let Ok(value) = std::env::var("CARGO_BIN_EXE_ec_node") { + return value.into(); + } + if let Ok(value) = std::env::var("CARGO_BIN_EXE_ec-node") { + return value.into(); + } + let exe = std::env::current_exe().expect("current_exe"); + let debug_dir = exe + .parent() + .and_then(|p| p.parent()) + .expect("expected target/debug/deps"); + debug_dir.join("ec-node") +} + +fn read_line_with_timeout( + lines: &mut dyn Iterator>, + timeout: Duration, +) -> Option { + let deadline = Instant::now() + timeout; + while Instant::now() < deadline { + match lines.next() { + Some(Ok(line)) => { + let line = line.trim().to_string(); + if !line.is_empty() { + return Some(line); + } + } + Some(Err(_)) => continue, + None => break, + } + } + None +} + +fn generate_ts_fixture(out: &std::path::Path) -> anyhow::Result<()> { + // Deterministic-ish fixture: single-threaded x264, fixed GOP, sine audio. + let status = Command::new("ffmpeg") + .arg("-hide_banner") + .arg("-loglevel") + .arg("error") + .arg("-nostdin") + .arg("-y") + .arg("-f") + .arg("lavfi") + .arg("-i") + .arg("testsrc2=size=1280x720:rate=30") + .arg("-f") + .arg("lavfi") + .arg("-i") + .arg("sine=frequency=1000:sample_rate=48000") + .arg("-t") + .arg("12") + .arg("-map") + .arg("0:v:0") + .arg("-map") + .arg("1:a:0") + .arg("-c:v") + .arg("libx264") + .arg("-pix_fmt") + .arg("yuv420p") + .arg("-g") + .arg("60") + .arg("-keyint_min") + .arg("60") + .arg("-sc_threshold") + .arg("0") + .arg("-bf") + .arg("0") + .arg("-threads") + .arg("1") + .arg("-c:a") + .arg("aac") + .arg("-b:a") + .arg("128k") + .arg("-ac") + .arg("2") + .arg("-ar") + .arg("48000") + .arg("-f") + .arg("mpegts") + .arg(out) + .status()?; + if !status.success() { + anyhow::bail!("ffmpeg fixture generation failed with {status}"); + } + Ok(()) +} + +fn click_button_by_text(tab: &headless_chrome::Tab, text: &str) -> anyhow::Result<()> { + let js = format!( + r#"(function() {{ + let btns = Array.from(document.querySelectorAll('button')); + let btn = btns.find(b => (b.innerText || '').trim() === {t}); + if (!btn) return false; + btn.click(); + return true; +}})();"#, + t = serde_json::to_string(text).unwrap() + ); + let v = tab.evaluate(&js, false)?; + let ok = v.value.and_then(|v| v.as_bool()).unwrap_or(false); + if !ok { + anyhow::bail!("button not found: {text}"); + } + Ok(()) +} + +fn fill_input_by_placeholder( + tab: &headless_chrome::Tab, + placeholder: &str, + value: &str, +) -> anyhow::Result<()> { + let js = format!( + r#"(function() {{ + let input = document.querySelector('input[placeholder={p}]'); + if (!input) return false; + input.focus(); + input.value = {v}; + input.dispatchEvent(new Event('input', {{ bubbles: true }})); + input.dispatchEvent(new Event('change', {{ bubbles: true }})); + return true; +}})();"#, + p = serde_json::to_string(placeholder).unwrap(), + v = serde_json::to_string(value).unwrap() + ); + let v = tab.evaluate(&js, false)?; + let ok = v.value.and_then(|v| v.as_bool()).unwrap_or(false); + if !ok { + anyhow::bail!("input not found for placeholder: {placeholder}"); + } + Ok(()) +} + +fn get_reply_link(tab: &headless_chrome::Tab) -> anyhow::Result> { + // Read the last readonly input inside the add menu; this is where we render the reply code. + let js = r#"(function() { + let menu = document.querySelector('.source-menu'); + if (!menu) return null; + let inputs = Array.from(menu.querySelectorAll('input.source-menu-input[readonly]')); + if (!inputs.length) return null; + return inputs[inputs.length - 1].value || null; +})();"#; + let v = tab.evaluate(js, false)?; + Ok(v.value.and_then(|v| v.as_str().map(|s| s.to_string()))) +} + +fn wait_for_text( + tab: &headless_chrome::Tab, + needle: &str, + timeout: Duration, +) -> anyhow::Result<()> { + let deadline = Instant::now() + timeout; + while Instant::now() < deadline { + let js = format!( + r#"(function() {{ + return document.body && (document.body.innerText || '').includes({n}); +}})();"#, + n = serde_json::to_string(needle).unwrap() + ); + let v = tab.evaluate(&js, false)?; + if v.value.and_then(|v| v.as_bool()).unwrap_or(false) { + return Ok(()); + } + std::thread::sleep(Duration::from_millis(200)); + } + anyhow::bail!("timed out waiting for text: {needle}"); +} + +fn wait_for_blob_video(tab: &headless_chrome::Tab, timeout: Duration) -> anyhow::Result<()> { + let deadline = Instant::now() + timeout; + while Instant::now() < deadline { + let js = r#"(function() { + let v = document.querySelector('video'); + if (!v) return false; + if (typeof v.src !== 'string') return false; + return v.src.startsWith('blob:'); +})();"#; + let v = tab.evaluate(js, false)?; + if v.value.and_then(|v| v.as_bool()).unwrap_or(false) { + return Ok(()); + } + std::thread::sleep(Duration::from_millis(200)); + } + anyhow::bail!("timed out waiting for video blob src"); +} + +#[test] +#[ignore] +fn e2e_remote_website_connects_to_local_direct_publisher() -> anyhow::Result<()> { + if which("ffmpeg").is_none() { + return Ok(()); // skip + } + let chrome = match chrome_path() { + Some(p) => p, + None => return Ok(()), // skip + }; + + let site_url = std::env::var("EVERY_CHANNEL_SITE_URL") + .unwrap_or_else(|_| "https://every.channel/".to_string()); + + let ec_node = ec_node_path(); + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis(); + let tmp = std::env::temp_dir().join(format!("ec-e2e-remote-website-direct-{ts}")); + let _ = std::fs::create_dir_all(&tmp); + let input_ts = tmp.join("input.ts"); + let chunk_dir = tmp.join("chunks"); + generate_ts_fixture(&input_ts)?; + + let mut pub_child = Command::new(&ec_node) + .arg("direct-publish") + .arg("--chunk-dir") + .arg(&chunk_dir) + .arg("--chunk-ms") + .arg("2000") + .arg("--max-segments") + .arg("6") + .arg("ts") + .arg(&input_ts) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn()?; + + let stdout = pub_child.stdout.take().expect("publisher stdout missing"); + let mut lines = BufReader::new(stdout).lines(); + let offer = read_line_with_timeout(&mut lines, Duration::from_secs(60)) + .ok_or_else(|| anyhow::anyhow!("publisher did not print offer link in time"))?; + if !offer.starts_with("every.channel://direct?c=") { + anyhow::bail!("unexpected offer link: {offer}"); + } + + let launch_options = headless_chrome::LaunchOptionsBuilder::default() + .path(Some(chrome)) + .headless(true) + .args(vec![ + OsStr::new("--autoplay-policy=no-user-gesture-required"), + OsStr::new("--mute-audio"), + ]) + .build() + .unwrap(); + let browser = headless_chrome::Browser::new(launch_options)?; + let tab = browser.new_tab()?; + tab.navigate_to(&site_url)?; + tab.wait_until_navigated()?; + + // Open the add menu via class selector (stable). + tab.wait_for_element("button.add-source")?.click()?; + tab.wait_for_element(".source-menu")?; + + // Use Watch a link flow. + fill_input_by_placeholder(&tab, "every.channel://watch?...", &offer)?; + click_button_by_text(&tab, "Parse link")?; + click_button_by_text(&tab, "Tune in")?; + + // Poll for reply link. + let deadline = Instant::now() + Duration::from_secs(60); + let reply = loop { + if let Some(v) = get_reply_link(&tab)? { + if v.starts_with("every.channel://direct?c=") { + break v; + } + } + if Instant::now() > deadline { + anyhow::bail!("timed out waiting for reply link in UI"); + } + std::thread::sleep(Duration::from_millis(200)); + }; + + // Feed reply back to publisher. + let stdin = pub_child.stdin.as_mut().expect("publisher stdin missing"); + writeln!(stdin, "{reply}")?; + stdin.flush()?; + + // Website should go Live and show a blob video source. + wait_for_text(&tab, "Live", Duration::from_secs(60))?; + wait_for_blob_video(&tab, Duration::from_secs(60))?; + + // Cleanup. + let _ = pub_child.kill(); + let _ = pub_child.wait(); + let _ = std::fs::remove_dir_all(&tmp); + Ok(()) +} diff --git a/crates/ec-node/tests/e2e_remote_website_directory.rs b/crates/ec-node/tests/e2e_remote_website_directory.rs new file mode 100644 index 0000000..77af8c8 --- /dev/null +++ b/crates/ec-node/tests/e2e_remote_website_directory.rs @@ -0,0 +1,243 @@ +use std::ffi::OsStr; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +fn which(cmd: &str) -> Option { + which::which(cmd).ok() +} + +fn chrome_path() -> Option { + // Prefer the standard macOS Chrome app bundle. + let mac = + std::path::PathBuf::from("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"); + if mac.exists() { + return Some(mac); + } + which("google-chrome") + .or_else(|| which("google-chrome-stable")) + .or_else(|| which("chromium")) +} + +fn ec_node_path() -> std::path::PathBuf { + if let Ok(value) = std::env::var("EC_NODE_BIN") { + return value.into(); + } + if let Ok(value) = std::env::var("CARGO_BIN_EXE_ec_node") { + return value.into(); + } + if let Ok(value) = std::env::var("CARGO_BIN_EXE_ec-node") { + return value.into(); + } + let exe = std::env::current_exe().expect("current_exe"); + let debug_dir = exe + .parent() + .and_then(|p| p.parent()) + .expect("expected target/debug/deps"); + debug_dir.join("ec-node") +} + +fn generate_ts_fixture(out: &std::path::Path) -> anyhow::Result<()> { + // Deterministic-ish fixture: single-threaded x264, fixed GOP, sine audio. + let status = Command::new("ffmpeg") + .arg("-hide_banner") + .arg("-loglevel") + .arg("error") + .arg("-nostdin") + .arg("-y") + .arg("-f") + .arg("lavfi") + .arg("-i") + .arg("testsrc2=size=1280x720:rate=30") + .arg("-f") + .arg("lavfi") + .arg("-i") + .arg("sine=frequency=1000:sample_rate=48000") + .arg("-t") + .arg("12") + .arg("-map") + .arg("0:v:0") + .arg("-map") + .arg("1:a:0") + .arg("-c:v") + .arg("libx264") + .arg("-pix_fmt") + .arg("yuv420p") + .arg("-g") + .arg("60") + .arg("-keyint_min") + .arg("60") + .arg("-sc_threshold") + .arg("0") + .arg("-bf") + .arg("0") + .arg("-threads") + .arg("1") + .arg("-c:a") + .arg("aac") + .arg("-b:a") + .arg("128k") + .arg("-ac") + .arg("2") + .arg("-ar") + .arg("48000") + .arg("-f") + .arg("mpegts") + .arg(out) + .status()?; + if !status.success() { + anyhow::bail!("ffmpeg fixture generation failed with {status}"); + } + Ok(()) +} + +fn click_css(tab: &headless_chrome::Tab, css: &str) -> anyhow::Result<()> { + tab.wait_for_element(css)?.click()?; + Ok(()) +} + +fn wait_for_text( + tab: &headless_chrome::Tab, + needle: &str, + timeout: Duration, +) -> anyhow::Result<()> { + let deadline = Instant::now() + timeout; + while Instant::now() < deadline { + let js = format!( + r#"(function() {{ + return document.body && (document.body.innerText || '').includes({n}); +}})();"#, + n = serde_json::to_string(needle).unwrap() + ); + let v = tab.evaluate(&js, false)?; + if v.value.and_then(|v| v.as_bool()).unwrap_or(false) { + return Ok(()); + } + std::thread::sleep(Duration::from_millis(200)); + } + anyhow::bail!("timed out waiting for text: {needle}"); +} + +fn wait_for_blob_video(tab: &headless_chrome::Tab, timeout: Duration) -> anyhow::Result<()> { + let deadline = Instant::now() + timeout; + while Instant::now() < deadline { + let js = r#"(function() { + let v = document.querySelector('video'); + if (!v) return false; + if (typeof v.src !== 'string') return false; + return v.src.startsWith('blob:'); +})();"#; + let v = tab.evaluate(js, false)?; + if v.value.and_then(|v| v.as_bool()).unwrap_or(false) { + return Ok(()); + } + std::thread::sleep(Duration::from_millis(200)); + } + anyhow::bail!("timed out waiting for video blob src"); +} + +fn click_global_watch(tab: &headless_chrome::Tab, stream_id: &str) -> anyhow::Result { + let js = format!( + r#"(function() {{ + let target = {sid}; + let btn = document.querySelector(`button[data-stream-id="${{target}}"]`) + || document.querySelector(`button[data_stream_id="${{target}}"]`); + if (!btn) return false; + btn.click(); + return true; +}})();"#, + sid = serde_json::to_string(stream_id).unwrap() + ); + let v = tab.evaluate(&js, false)?; + Ok(v.value.and_then(|v| v.as_bool()).unwrap_or(false)) +} + +#[test] +#[ignore] +fn e2e_remote_website_directory_connects_to_local_direct_publisher() -> anyhow::Result<()> { + if which("ffmpeg").is_none() { + return Ok(()); // skip + } + let chrome = match chrome_path() { + Some(p) => p, + None => return Ok(()), // skip + }; + + let site_url = std::env::var("EVERY_CHANNEL_SITE_URL") + .unwrap_or_else(|_| "https://every.channel/".to_string()); + let directory_url = std::env::var("EVERY_CHANNEL_DIRECTORY_URL") + .unwrap_or_else(|_| "https://every.channel".to_string()); + + let ec_node = ec_node_path(); + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis(); + let stream_id = format!("every.channel/e2e/{ts}"); + let title = format!("E2E {ts}"); + + let tmp = std::env::temp_dir().join(format!("ec-e2e-remote-website-directory-{ts}")); + let _ = std::fs::create_dir_all(&tmp); + let input_ts = tmp.join("input.ts"); + let chunk_dir = tmp.join("chunks"); + generate_ts_fixture(&input_ts)?; + + let mut pub_child = Command::new(&ec_node) + .arg("direct-publish") + .arg("--directory-url") + .arg(&directory_url) + .arg("--stream-id") + .arg(&stream_id) + .arg("--title") + .arg(&title) + .arg("--chunk-dir") + .arg(&chunk_dir) + .arg("--chunk-ms") + .arg("2000") + .arg("--max-segments") + .arg("6") + .arg("ts") + .arg(&input_ts) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::inherit()) + .spawn()?; + + let launch_options = headless_chrome::LaunchOptionsBuilder::default() + .path(Some(chrome)) + .headless(true) + .args(vec![ + OsStr::new("--autoplay-policy=no-user-gesture-required"), + OsStr::new("--mute-audio"), + ]) + .build() + .unwrap(); + let browser = headless_chrome::Browser::new(launch_options)?; + let tab = browser.new_tab()?; + tab.navigate_to(&site_url)?; + tab.wait_until_navigated()?; + + // Refresh public list and watch our stream_id. + click_css(&tab, "button[data-testid='global-refresh']")?; + + let deadline = Instant::now() + Duration::from_secs(60); + loop { + if click_global_watch(&tab, &stream_id)? { + break; + } + if Instant::now() > deadline { + anyhow::bail!("timed out waiting for stream_id to appear in global list"); + } + std::thread::sleep(Duration::from_millis(250)); + let _ = click_global_watch(&tab, &stream_id)?; + } + + // Website should go Live and show a blob video source. + wait_for_text(&tab, "Live", Duration::from_secs(60))?; + wait_for_blob_video(&tab, Duration::from_secs(60))?; + + // Cleanup. + let _ = pub_child.kill(); + let _ = pub_child.wait(); + let _ = std::fs::remove_dir_all(&tmp); + Ok(()) +} diff --git a/crates/ec-node/tests/e2e_remote_website_watch_existing.rs b/crates/ec-node/tests/e2e_remote_website_watch_existing.rs new file mode 100644 index 0000000..668e7b6 --- /dev/null +++ b/crates/ec-node/tests/e2e_remote_website_watch_existing.rs @@ -0,0 +1,174 @@ +use std::ffi::OsStr; +use std::time::{Duration, Instant}; + +fn which(cmd: &str) -> Option { + which::which(cmd).ok() +} + +fn chrome_path() -> Option { + let mac = + std::path::PathBuf::from("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"); + if mac.exists() { + return Some(mac); + } + which("google-chrome") + .or_else(|| which("google-chrome-stable")) + .or_else(|| which("chromium")) +} + +fn click_css(tab: &headless_chrome::Tab, css: &str) -> anyhow::Result<()> { + tab.wait_for_element(css)?.click()?; + Ok(()) +} + +fn wait_for_text( + tab: &headless_chrome::Tab, + needle: &str, + timeout: Duration, +) -> anyhow::Result<()> { + let deadline = Instant::now() + timeout; + while Instant::now() < deadline { + let js = format!( + r#"(function() {{ + return document.body && (document.body.innerText || '').includes({n}); +}})();"#, + n = serde_json::to_string(needle).unwrap() + ); + let v = tab.evaluate(&js, false)?; + if v.value.and_then(|v| v.as_bool()).unwrap_or(false) { + return Ok(()); + } + std::thread::sleep(Duration::from_millis(200)); + } + anyhow::bail!("timed out waiting for text: {needle}"); +} + +fn wait_for_blob_video(tab: &headless_chrome::Tab, timeout: Duration) -> anyhow::Result<()> { + let deadline = Instant::now() + timeout; + while Instant::now() < deadline { + let js = r#"(function() { + let v = document.querySelector('video'); + if (!v) return false; + if (typeof v.src !== 'string') return false; + return v.src.startsWith('blob:'); +})();"#; + let v = tab.evaluate(js, false)?; + if v.value.and_then(|v| v.as_bool()).unwrap_or(false) { + return Ok(()); + } + std::thread::sleep(Duration::from_millis(200)); + } + anyhow::bail!("timed out waiting for video blob src"); +} + +fn wait_for_video_element(tab: &headless_chrome::Tab, timeout: Duration) -> anyhow::Result<()> { + let deadline = Instant::now() + timeout; + while Instant::now() < deadline { + let js = r#"(function() { + return !!document.querySelector('video'); +})();"#; + let v = tab.evaluate(js, false)?; + if v.value.and_then(|v| v.as_bool()).unwrap_or(false) { + return Ok(()); + } + std::thread::sleep(Duration::from_millis(200)); + } + anyhow::bail!("timed out waiting for