From 897e556bea5a8081f3bb5a42944d7e498db82266 Mon Sep 17 00:00:00 2001 From: "every.channel" Date: Sun, 15 Feb 2026 16:17:27 -0500 Subject: [PATCH] every.channel: sanitized baseline --- .envrc | 2 + .forgejo/workflows/deploy-cloudflare.yml | 40 + .gitignore | 28 + AGENTS.md | 24 + CONSTITUTION.md | 42 + CONTRIBUTORS.md | 3 + Cargo.lock | 9029 +++++++++++++++ Cargo.toml | 36 + LICENSE | 644 ++ README.md | 94 + apps/tauri/Cargo.toml | 29 + apps/tauri/build.rs | 3 + apps/tauri/icons/icon.png | Bin 0 -> 70 bytes apps/tauri/resources/yt-dlp/README.md | 8 + apps/tauri/src/main.rs | 3452 ++++++ apps/tauri/tauri.conf.json | 31 + apps/tauri/ui/Cargo.lock | 3619 ++++++ apps/tauri/ui/Cargo.toml | 22 + apps/tauri/ui/Trunk.toml | 3 + apps/tauri/ui/icons/apple-touch-icon.png | Bin 0 -> 984 bytes apps/tauri/ui/icons/icon-192.png | Bin 0 -> 1096 bytes apps/tauri/ui/icons/icon-512.png | Bin 0 -> 3796 bytes apps/tauri/ui/index.html | 34 + apps/tauri/ui/manifest.webmanifest | 29 + apps/tauri/ui/src/main.rs | 2329 ++++ apps/tauri/ui/style.css | 671 ++ apps/tauri/ui/sw.js | 103 + apps/web/Cargo.lock | 1857 ++++ apps/web/Cargo.toml | 14 + apps/web/README.md | 18 + apps/web/Trunk.toml | 5 + apps/web/index.html | 17 + apps/web/src/main.rs | 125 + apps/web/style.css | 285 + crates/ec-chopper/Cargo.toml | 13 + crates/ec-chopper/src/lib.rs | 899 ++ crates/ec-cli/Cargo.toml | 17 + crates/ec-cli/src/main.rs | 379 + crates/ec-core/Cargo.toml | 10 + crates/ec-core/src/lib.rs | 463 + crates/ec-crypto/Cargo.toml | 12 + crates/ec-crypto/src/lib.rs | 227 + crates/ec-direct/Cargo.toml | 16 + crates/ec-direct/src/lib.rs | 94 + crates/ec-direct/tests/e2e_loopback.rs | 134 + crates/ec-hdhomerun/Cargo.toml | 14 + crates/ec-hdhomerun/src/lib.rs | 676 ++ crates/ec-iroh/Cargo.toml | 16 + crates/ec-iroh/src/lib.rs | 328 + crates/ec-linux-iptv/Cargo.toml | 9 + crates/ec-linux-iptv/src/lib.rs | 292 + crates/ec-moq/Cargo.toml | 23 + crates/ec-moq/src/lib.rs | 832 ++ crates/ec-node/Cargo.toml | 35 + crates/ec-node/src/main.rs | 4193 +++++++ crates/ec-node/src/source.rs | 283 + .../ec-node/tests/determinism_cmaf_ladder.rs | 308 + crates/ec-node/tests/e2e_cmaf_ladder.rs | 231 + crates/ec-node/tests/e2e_hdhr.rs | 211 + crates/ec-node/tests/e2e_mesh_split.rs | 305 + crates/ec-node/tests/e2e_mesh_split_cmaf.rs | 345 + .../tests/e2e_remote_website_direct.rs | 314 + .../tests/e2e_remote_website_directory.rs | 243 + .../e2e_remote_website_watch_existing.rs | 174 + crates/ec-ts/Cargo.toml | 10 + crates/ec-ts/src/lib.rs | 648 ++ deploy/cloudflare-worker/.dockerignore | 5 + .../containers/ec-api/Cargo.toml | 19 + .../containers/ec-api/Dockerfile | 18 + .../containers/ec-api/src/main.rs | 252 + deploy/cloudflare-worker/package-lock.json | 1519 +++ deploy/cloudflare-worker/package.json | 15 + deploy/cloudflare-worker/src/index.ts | 575 + deploy/cloudflare-worker/wrangler.toml | 37 + docs/ARCHITECTURE.md | 57 + docs/BABY_STEPS.md | 57 + docs/CLAUDE_CODE_DESIGN_PROMPT.md | 45 + docs/COVERAGE.md | 31 + docs/DEPLOY_CLOUDFLARE.md | 22 + docs/IROH_EXAMPLES.md | 46 + docs/IROH_NOTES.md | 13 + docs/MOQ_IMPLEMENTATIONS.md | 16 + docs/MOQ_NOTES.md | 16 + docs/USAGE.md | 279 + docs/allowed_signers | 1 + evolution/proposals/ECP-0001-ecp-process.md | 56 + .../ECP-0002-initial-technical-direction.md | 30 + .../proposals/ECP-0003-hdhomerun-ingest.md | 29 + .../ECP-0004-global-stream-identifiers.md | 54 + ...CP-0005-deterministic-chunking-pipeline.md | 30 + .../proposals/ECP-0006-linux-iptv-ingest.md | 27 + .../proposals/ECP-0007-tauri-dioxus-viewer.md | 30 + .../ECP-0008-iroh-discovery-transport.md | 33 + .../ECP-0009-moq-implementation-selection.md | 36 + .../ECP-0010-time-synchronized-chunking.md | 46 + .../proposals/ECP-0011-stream-encryption.md | 33 + .../ECP-0012-moq-object-wire-format.md | 42 + .../ECP-0013-stream-catalog-gossip.md | 30 + .../proposals/ECP-0014-in-app-sharing.md | 37 + .../ECP-0015-gossip-announce-toggle.md | 23 + .../proposals/ECP-0016-mdns-peer-discovery.md | 24 + .../proposals/ECP-0017-dht-mdns-discovery.md | 25 + .../ECP-0018-ui-discovery-toggles.md | 20 + .../proposals/ECP-0019-hdhr-by-host-ui.md | 20 + .../ECP-0020-determinism-moq-selftest.md | 20 + .../proposals/ECP-0021-relay-mode-toggle.md | 19 + .../ECP-0022-swarm-availability-anti-junk.md | 114 + evolution/proposals/ECP-0023-hls-ingest.md | 54 + .../ECP-0024-zktls-manifest-provenance.md | 55 + .../ECP-0025-ytdlp-adapter-bundling.md | 45 + .../proposals/ECP-0026-agplv3-license.md | 28 + .../ECP-0027-unified-add-stream-flow.md | 60 + .../ECP-0028-testing-and-coverage.md | 86 + .../ECP-0029-linux-dvb-auto-discovery.md | 33 + .../ECP-0030-workspace-coverage-reporting.md | 36 + evolution/proposals/ECP-0031-hdhr-e2e-test.md | 39 + .../ECP-0032-split-source-moq-mesh.md | 56 + .../proposals/ECP-0033-x264-cmaf-fmp4.md | 65 + .../proposals/ECP-0034-raw-cmaf-output.md | 46 + .../ECP-0035-multi-variant-streams.md | 65 + .../ECP-0036-multi-variant-manifests.md | 72 + .../ECP-0037-cross-os-determinism.md | 50 + evolution/proposals/ECP-0038-share-links.md | 58 + evolution/proposals/ECP-0039-web-ia.md | 48 + evolution/proposals/ECP-0040-ui-ia-polish.md | 23 + evolution/proposals/ECP-0041-cmaf-only.md | 22 + .../proposals/ECP-0042-variants-and-auto.md | 24 + .../ECP-0043-browser-webrtc-bridge.md | 31 + ...ECP-0044-cloudflare-workers-static-site.md | 34 + .../ECP-0045-remote-website-e2e-direct.md | 35 + .../ECP-0046-web-directory-and-signaling.md | 49 + .../proposals/ECP-0047-coverage-reporting.md | 25 + .../ECP-0048-bootstrap-api-via-containers.md | 36 + .../ECP-0049-ts-ingest-cmaf-canonical.md | 28 + .../proposals/ECP-0050-delete-ts-chunk-cli.md | 22 + .../ECP-0051-direct-publish-answer-timeout.md | 17 + .../ECP-0052-direct-subscribe-cli.md | 47 + .../proposals/ECP-0053-direct-wire-framing.md | 49 + .../ECP-0054-remote-website-watch-e2e.md | 23 + .../proposals/ECP-0055-directory-timeouts.md | 17 + .../proposals/ECP-0056-turn-ice-bootstrap.md | 38 + ...P-0057-direct-publish-session-reconnect.md | 34 + ...ECP-0058-ws-relay-one-to-many-bootstrap.md | 76 + .../ECP-0059-bootstrap-api-durable-object.md | 51 + ...P-0060-repo-sanitization-and-authorship.md | 40 + evolution/proposals/README.md | 9 + flake.lock | 96 + flake.nix | 87 + justfile | 48 + scripts/build-web.sh | 10 + scripts/coverage-summary.sh | 23 + scripts/coverage.sh | 17 + scripts/deploy-workers.sh | 30 + scripts/determinism-cmaf.sh | 108 + scripts/e2e-hdhr.sh | 53 + scripts/e2e-mesh-split-cmaf.sh | 57 + scripts/e2e-mesh-split.sh | 56 + scripts/e2e-remote-website-direct.sh | 11 + scripts/e2e-remote-website-directory.sh | 11 + scripts/vendor-yt-dlp.sh | 34 + third_party/iroh-live/.cargo/config.toml | 7 + third_party/iroh-live/.gitignore | 3 + third_party/iroh-live/Cargo.lock | 9665 +++++++++++++++++ third_party/iroh-live/Cargo.toml | 11 + third_party/iroh-live/LICENSE-APACHE | 201 + third_party/iroh-live/LICENSE-MIT | 201 + third_party/iroh-live/Makefile.toml | 28 + third_party/iroh-live/README.md | 72 + third_party/iroh-live/iroh-live/Cargo.toml | 94 + third_party/iroh-live/iroh-live/README.md | 3 + .../iroh-live/examples/common/import.rs | 143 + .../iroh-live/examples/common/mod.rs | 1 + .../iroh-live/iroh-live/examples/publish.rs | 95 + .../iroh-live/iroh-live/examples/push.rs | 71 + .../iroh-live/examples/room-publish-file.rs | 75 + .../iroh-live/iroh-live/examples/rooms.rs | 428 + .../iroh-live/iroh-live/examples/watch.rs | 225 + third_party/iroh-live/iroh-live/src/lib.rs | 15 + third_party/iroh-live/iroh-live/src/live.rs | 62 + third_party/iroh-live/iroh-live/src/node.rs | 72 + third_party/iroh-live/iroh-live/src/rooms.rs | 391 + .../iroh-live/src/rooms/publisher.rs | 199 + third_party/iroh-live/iroh-live/src/ticket.rs | 61 + third_party/iroh-live/iroh-live/src/util.rs | 84 + third_party/iroh-live/iroh-moq/Cargo.toml | 19 + third_party/iroh-live/iroh-moq/src/lib.rs | 446 + third_party/iroh-live/moq-media/Cargo.toml | 88 + third_party/iroh-live/moq-media/src/audio.rs | 527 + .../iroh-live/moq-media/src/audio/aec.rs | 452 + third_party/iroh-live/moq-media/src/av.rs | 265 + .../iroh-live/moq-media/src/capture.rs | 233 + .../moq-media/src/ffmpeg/audio/decoder.rs | 93 + .../moq-media/src/ffmpeg/audio/encoder.rs | 153 + .../iroh-live/moq-media/src/ffmpeg/mod.rs | 108 + .../moq-media/src/ffmpeg/video/decoder.rs | 139 + .../moq-media/src/ffmpeg/video/encoder.rs | 568 + .../moq-media/src/ffmpeg/video/util.rs | 138 + .../src/ffmpeg/video/util/mjpg_decoder.rs | 74 + .../src/ffmpeg/video/util/rescaler.rs | 77 + third_party/iroh-live/moq-media/src/lib.rs | 9 + .../iroh-live/moq-media/src/publish.rs | 594 + .../iroh-live/moq-media/src/subscribe.rs | 712 ++ third_party/iroh-live/moq-media/src/util.rs | 12 + .../iroh-live/web-transport-iroh/Cargo.toml | 35 + .../iroh-live/web-transport-iroh/README.md | 9 + .../web-transport-iroh/src/client.rs | 71 + .../web-transport-iroh/src/connect.rs | 125 + .../iroh-live/web-transport-iroh/src/error.rs | 256 + .../iroh-live/web-transport-iroh/src/lib.rs | 56 + .../iroh-live/web-transport-iroh/src/recv.rs | 111 + .../iroh-live/web-transport-iroh/src/send.rs | 142 + .../web-transport-iroh/src/server.rs | 76 + .../web-transport-iroh/src/session.rs | 540 + .../web-transport-iroh/src/settings.rs | 69 + .../iroh-live/web-transport-iroh/src/tests.rs | 128 + .../iroh-org/iroh-gossip/.cargo/config.toml | 3 + .../iroh-org/iroh-gossip/.config/nextest.toml | 10 + .../iroh-gossip/.github/dependabot.yaml | 13 + .../.github/pull_request_template.md | 18 + .../iroh-gossip/.github/workflows/beta.yaml | 43 + .../iroh-gossip/.github/workflows/ci.yaml | 305 + .../.github/workflows/cleanup.yaml | 45 + .../iroh-gossip/.github/workflows/commit.yaml | 19 + .../iroh-gossip/.github/workflows/docs.yaml | 73 + .../iroh-gossip/.github/workflows/flaky.yaml | 99 + .../.github/workflows/release.yaml | 50 + .../.github/workflows/simulation.yaml | 53 + .../iroh-gossip/.github/workflows/tests.yaml | 229 + third_party/iroh-org/iroh-gossip/.gitignore | 1 + third_party/iroh-org/iroh-gossip/CHANGELOG.md | 250 + third_party/iroh-org/iroh-gossip/Cargo.lock | 4965 +++++++++ third_party/iroh-org/iroh-gossip/Cargo.toml | 172 + .../iroh-org/iroh-gossip/LICENSE-APACHE | 201 + third_party/iroh-org/iroh-gossip/LICENSE-MIT | 25 + .../iroh-org/iroh-gossip/Makefile.toml | 28 + third_party/iroh-org/iroh-gossip/README.md | 93 + third_party/iroh-org/iroh-gossip/cliff.toml | 64 + .../iroh-org/iroh-gossip/code_of_conduct.md | 13 + third_party/iroh-org/iroh-gossip/deny.toml | 41 + .../iroh-org/iroh-gossip/examples/chat.rs | 319 + .../iroh-org/iroh-gossip/examples/setup.rs | 21 + third_party/iroh-org/iroh-gossip/release.toml | 1 + .../iroh-org/iroh-gossip/simulations/all.toml | 35 + third_party/iroh-org/iroh-gossip/src/api.rs | 535 + .../iroh-org/iroh-gossip/src/bin/sim.rs | 418 + third_party/iroh-org/iroh-gossip/src/lib.rs | 25 + .../iroh-org/iroh-gossip/src/metrics.rs | 45 + third_party/iroh-org/iroh-gossip/src/net.rs | 1958 ++++ .../iroh-gossip/src/net/address_lookup.rs | 175 + .../iroh-org/iroh-gossip/src/net/util.rs | 435 + third_party/iroh-org/iroh-gossip/src/proto.rs | 344 + .../iroh-gossip/src/proto/hyparview.rs | 764 ++ .../iroh-gossip/src/proto/plumtree.rs | 909 ++ .../iroh-org/iroh-gossip/src/proto/sim.rs | 1141 ++ .../iroh-org/iroh-gossip/src/proto/state.rs | 381 + .../iroh-org/iroh-gossip/src/proto/topic.rs | 363 + .../iroh-org/iroh-gossip/src/proto/util.rs | 532 + third_party/iroh-org/iroh-gossip/tests/sim.rs | 134 + 258 files changed, 74298 insertions(+) create mode 100644 .envrc create mode 100644 .forgejo/workflows/deploy-cloudflare.yml create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 CONSTITUTION.md create mode 100644 CONTRIBUTORS.md create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 LICENSE create mode 100644 README.md create mode 100644 apps/tauri/Cargo.toml create mode 100644 apps/tauri/build.rs create mode 100644 apps/tauri/icons/icon.png create mode 100644 apps/tauri/resources/yt-dlp/README.md create mode 100644 apps/tauri/src/main.rs create mode 100644 apps/tauri/tauri.conf.json create mode 100644 apps/tauri/ui/Cargo.lock create mode 100644 apps/tauri/ui/Cargo.toml create mode 100644 apps/tauri/ui/Trunk.toml create mode 100644 apps/tauri/ui/icons/apple-touch-icon.png create mode 100644 apps/tauri/ui/icons/icon-192.png create mode 100644 apps/tauri/ui/icons/icon-512.png create mode 100644 apps/tauri/ui/index.html create mode 100644 apps/tauri/ui/manifest.webmanifest create mode 100644 apps/tauri/ui/src/main.rs create mode 100644 apps/tauri/ui/style.css create mode 100644 apps/tauri/ui/sw.js create mode 100644 apps/web/Cargo.lock create mode 100644 apps/web/Cargo.toml create mode 100644 apps/web/README.md create mode 100644 apps/web/Trunk.toml create mode 100644 apps/web/index.html create mode 100644 apps/web/src/main.rs create mode 100644 apps/web/style.css create mode 100644 crates/ec-chopper/Cargo.toml create mode 100644 crates/ec-chopper/src/lib.rs create mode 100644 crates/ec-cli/Cargo.toml create mode 100644 crates/ec-cli/src/main.rs create mode 100644 crates/ec-core/Cargo.toml create mode 100644 crates/ec-core/src/lib.rs create mode 100644 crates/ec-crypto/Cargo.toml create mode 100644 crates/ec-crypto/src/lib.rs create mode 100644 crates/ec-direct/Cargo.toml create mode 100644 crates/ec-direct/src/lib.rs create mode 100644 crates/ec-direct/tests/e2e_loopback.rs create mode 100644 crates/ec-hdhomerun/Cargo.toml create mode 100644 crates/ec-hdhomerun/src/lib.rs create mode 100644 crates/ec-iroh/Cargo.toml create mode 100644 crates/ec-iroh/src/lib.rs create mode 100644 crates/ec-linux-iptv/Cargo.toml create mode 100644 crates/ec-linux-iptv/src/lib.rs create mode 100644 crates/ec-moq/Cargo.toml create mode 100644 crates/ec-moq/src/lib.rs create mode 100644 crates/ec-node/Cargo.toml create mode 100644 crates/ec-node/src/main.rs create mode 100644 crates/ec-node/src/source.rs create mode 100644 crates/ec-node/tests/determinism_cmaf_ladder.rs create mode 100644 crates/ec-node/tests/e2e_cmaf_ladder.rs create mode 100644 crates/ec-node/tests/e2e_hdhr.rs create mode 100644 crates/ec-node/tests/e2e_mesh_split.rs create mode 100644 crates/ec-node/tests/e2e_mesh_split_cmaf.rs create mode 100644 crates/ec-node/tests/e2e_remote_website_direct.rs create mode 100644 crates/ec-node/tests/e2e_remote_website_directory.rs create mode 100644 crates/ec-node/tests/e2e_remote_website_watch_existing.rs create mode 100644 crates/ec-ts/Cargo.toml create mode 100644 crates/ec-ts/src/lib.rs create mode 100644 deploy/cloudflare-worker/.dockerignore create mode 100644 deploy/cloudflare-worker/containers/ec-api/Cargo.toml create mode 100644 deploy/cloudflare-worker/containers/ec-api/Dockerfile create mode 100644 deploy/cloudflare-worker/containers/ec-api/src/main.rs create mode 100644 deploy/cloudflare-worker/package-lock.json create mode 100644 deploy/cloudflare-worker/package.json create mode 100644 deploy/cloudflare-worker/src/index.ts create mode 100644 deploy/cloudflare-worker/wrangler.toml create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/BABY_STEPS.md create mode 100644 docs/CLAUDE_CODE_DESIGN_PROMPT.md create mode 100644 docs/COVERAGE.md create mode 100644 docs/DEPLOY_CLOUDFLARE.md create mode 100644 docs/IROH_EXAMPLES.md create mode 100644 docs/IROH_NOTES.md create mode 100644 docs/MOQ_IMPLEMENTATIONS.md create mode 100644 docs/MOQ_NOTES.md create mode 100644 docs/USAGE.md create mode 100644 docs/allowed_signers create mode 100644 evolution/proposals/ECP-0001-ecp-process.md create mode 100644 evolution/proposals/ECP-0002-initial-technical-direction.md create mode 100644 evolution/proposals/ECP-0003-hdhomerun-ingest.md create mode 100644 evolution/proposals/ECP-0004-global-stream-identifiers.md create mode 100644 evolution/proposals/ECP-0005-deterministic-chunking-pipeline.md create mode 100644 evolution/proposals/ECP-0006-linux-iptv-ingest.md create mode 100644 evolution/proposals/ECP-0007-tauri-dioxus-viewer.md create mode 100644 evolution/proposals/ECP-0008-iroh-discovery-transport.md create mode 100644 evolution/proposals/ECP-0009-moq-implementation-selection.md create mode 100644 evolution/proposals/ECP-0010-time-synchronized-chunking.md create mode 100644 evolution/proposals/ECP-0011-stream-encryption.md create mode 100644 evolution/proposals/ECP-0012-moq-object-wire-format.md create mode 100644 evolution/proposals/ECP-0013-stream-catalog-gossip.md create mode 100644 evolution/proposals/ECP-0014-in-app-sharing.md create mode 100644 evolution/proposals/ECP-0015-gossip-announce-toggle.md create mode 100644 evolution/proposals/ECP-0016-mdns-peer-discovery.md create mode 100644 evolution/proposals/ECP-0017-dht-mdns-discovery.md create mode 100644 evolution/proposals/ECP-0018-ui-discovery-toggles.md create mode 100644 evolution/proposals/ECP-0019-hdhr-by-host-ui.md create mode 100644 evolution/proposals/ECP-0020-determinism-moq-selftest.md create mode 100644 evolution/proposals/ECP-0021-relay-mode-toggle.md create mode 100644 evolution/proposals/ECP-0022-swarm-availability-anti-junk.md create mode 100644 evolution/proposals/ECP-0023-hls-ingest.md create mode 100644 evolution/proposals/ECP-0024-zktls-manifest-provenance.md create mode 100644 evolution/proposals/ECP-0025-ytdlp-adapter-bundling.md create mode 100644 evolution/proposals/ECP-0026-agplv3-license.md create mode 100644 evolution/proposals/ECP-0027-unified-add-stream-flow.md create mode 100644 evolution/proposals/ECP-0028-testing-and-coverage.md create mode 100644 evolution/proposals/ECP-0029-linux-dvb-auto-discovery.md create mode 100644 evolution/proposals/ECP-0030-workspace-coverage-reporting.md create mode 100644 evolution/proposals/ECP-0031-hdhr-e2e-test.md create mode 100644 evolution/proposals/ECP-0032-split-source-moq-mesh.md create mode 100644 evolution/proposals/ECP-0033-x264-cmaf-fmp4.md create mode 100644 evolution/proposals/ECP-0034-raw-cmaf-output.md create mode 100644 evolution/proposals/ECP-0035-multi-variant-streams.md create mode 100644 evolution/proposals/ECP-0036-multi-variant-manifests.md create mode 100644 evolution/proposals/ECP-0037-cross-os-determinism.md create mode 100644 evolution/proposals/ECP-0038-share-links.md create mode 100644 evolution/proposals/ECP-0039-web-ia.md create mode 100644 evolution/proposals/ECP-0040-ui-ia-polish.md create mode 100644 evolution/proposals/ECP-0041-cmaf-only.md create mode 100644 evolution/proposals/ECP-0042-variants-and-auto.md create mode 100644 evolution/proposals/ECP-0043-browser-webrtc-bridge.md create mode 100644 evolution/proposals/ECP-0044-cloudflare-workers-static-site.md create mode 100644 evolution/proposals/ECP-0045-remote-website-e2e-direct.md create mode 100644 evolution/proposals/ECP-0046-web-directory-and-signaling.md create mode 100644 evolution/proposals/ECP-0047-coverage-reporting.md create mode 100644 evolution/proposals/ECP-0048-bootstrap-api-via-containers.md create mode 100644 evolution/proposals/ECP-0049-ts-ingest-cmaf-canonical.md create mode 100644 evolution/proposals/ECP-0050-delete-ts-chunk-cli.md create mode 100644 evolution/proposals/ECP-0051-direct-publish-answer-timeout.md create mode 100644 evolution/proposals/ECP-0052-direct-subscribe-cli.md create mode 100644 evolution/proposals/ECP-0053-direct-wire-framing.md create mode 100644 evolution/proposals/ECP-0054-remote-website-watch-e2e.md create mode 100644 evolution/proposals/ECP-0055-directory-timeouts.md create mode 100644 evolution/proposals/ECP-0056-turn-ice-bootstrap.md create mode 100644 evolution/proposals/ECP-0057-direct-publish-session-reconnect.md create mode 100644 evolution/proposals/ECP-0058-ws-relay-one-to-many-bootstrap.md create mode 100644 evolution/proposals/ECP-0059-bootstrap-api-durable-object.md create mode 100644 evolution/proposals/ECP-0060-repo-sanitization-and-authorship.md create mode 100644 evolution/proposals/README.md create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 justfile create mode 100755 scripts/build-web.sh create mode 100755 scripts/coverage-summary.sh create mode 100755 scripts/coverage.sh create mode 100755 scripts/deploy-workers.sh create mode 100755 scripts/determinism-cmaf.sh create mode 100755 scripts/e2e-hdhr.sh create mode 100755 scripts/e2e-mesh-split-cmaf.sh create mode 100755 scripts/e2e-mesh-split.sh create mode 100755 scripts/e2e-remote-website-direct.sh create mode 100755 scripts/e2e-remote-website-directory.sh create mode 100755 scripts/vendor-yt-dlp.sh create mode 100644 third_party/iroh-live/.cargo/config.toml create mode 100644 third_party/iroh-live/.gitignore create mode 100644 third_party/iroh-live/Cargo.lock create mode 100644 third_party/iroh-live/Cargo.toml create mode 100644 third_party/iroh-live/LICENSE-APACHE create mode 100644 third_party/iroh-live/LICENSE-MIT create mode 100644 third_party/iroh-live/Makefile.toml create mode 100644 third_party/iroh-live/README.md create mode 100644 third_party/iroh-live/iroh-live/Cargo.toml create mode 100644 third_party/iroh-live/iroh-live/README.md create mode 100644 third_party/iroh-live/iroh-live/examples/common/import.rs create mode 100644 third_party/iroh-live/iroh-live/examples/common/mod.rs create mode 100644 third_party/iroh-live/iroh-live/examples/publish.rs create mode 100644 third_party/iroh-live/iroh-live/examples/push.rs create mode 100644 third_party/iroh-live/iroh-live/examples/room-publish-file.rs create mode 100644 third_party/iroh-live/iroh-live/examples/rooms.rs create mode 100644 third_party/iroh-live/iroh-live/examples/watch.rs create mode 100644 third_party/iroh-live/iroh-live/src/lib.rs create mode 100644 third_party/iroh-live/iroh-live/src/live.rs create mode 100644 third_party/iroh-live/iroh-live/src/node.rs create mode 100644 third_party/iroh-live/iroh-live/src/rooms.rs create mode 100644 third_party/iroh-live/iroh-live/src/rooms/publisher.rs create mode 100644 third_party/iroh-live/iroh-live/src/ticket.rs create mode 100644 third_party/iroh-live/iroh-live/src/util.rs create mode 100644 third_party/iroh-live/iroh-moq/Cargo.toml create mode 100644 third_party/iroh-live/iroh-moq/src/lib.rs create mode 100644 third_party/iroh-live/moq-media/Cargo.toml create mode 100644 third_party/iroh-live/moq-media/src/audio.rs create mode 100644 third_party/iroh-live/moq-media/src/audio/aec.rs create mode 100644 third_party/iroh-live/moq-media/src/av.rs create mode 100644 third_party/iroh-live/moq-media/src/capture.rs create mode 100644 third_party/iroh-live/moq-media/src/ffmpeg/audio/decoder.rs create mode 100644 third_party/iroh-live/moq-media/src/ffmpeg/audio/encoder.rs create mode 100644 third_party/iroh-live/moq-media/src/ffmpeg/mod.rs create mode 100644 third_party/iroh-live/moq-media/src/ffmpeg/video/decoder.rs create mode 100644 third_party/iroh-live/moq-media/src/ffmpeg/video/encoder.rs create mode 100644 third_party/iroh-live/moq-media/src/ffmpeg/video/util.rs create mode 100644 third_party/iroh-live/moq-media/src/ffmpeg/video/util/mjpg_decoder.rs create mode 100644 third_party/iroh-live/moq-media/src/ffmpeg/video/util/rescaler.rs create mode 100644 third_party/iroh-live/moq-media/src/lib.rs create mode 100644 third_party/iroh-live/moq-media/src/publish.rs create mode 100644 third_party/iroh-live/moq-media/src/subscribe.rs create mode 100644 third_party/iroh-live/moq-media/src/util.rs create mode 100644 third_party/iroh-live/web-transport-iroh/Cargo.toml create mode 100644 third_party/iroh-live/web-transport-iroh/README.md create mode 100644 third_party/iroh-live/web-transport-iroh/src/client.rs create mode 100644 third_party/iroh-live/web-transport-iroh/src/connect.rs create mode 100644 third_party/iroh-live/web-transport-iroh/src/error.rs create mode 100644 third_party/iroh-live/web-transport-iroh/src/lib.rs create mode 100644 third_party/iroh-live/web-transport-iroh/src/recv.rs create mode 100644 third_party/iroh-live/web-transport-iroh/src/send.rs create mode 100644 third_party/iroh-live/web-transport-iroh/src/server.rs create mode 100644 third_party/iroh-live/web-transport-iroh/src/session.rs create mode 100644 third_party/iroh-live/web-transport-iroh/src/settings.rs create mode 100644 third_party/iroh-live/web-transport-iroh/src/tests.rs create mode 100644 third_party/iroh-org/iroh-gossip/.cargo/config.toml create mode 100644 third_party/iroh-org/iroh-gossip/.config/nextest.toml create mode 100644 third_party/iroh-org/iroh-gossip/.github/dependabot.yaml create mode 100644 third_party/iroh-org/iroh-gossip/.github/pull_request_template.md create mode 100644 third_party/iroh-org/iroh-gossip/.github/workflows/beta.yaml create mode 100644 third_party/iroh-org/iroh-gossip/.github/workflows/ci.yaml create mode 100644 third_party/iroh-org/iroh-gossip/.github/workflows/cleanup.yaml create mode 100644 third_party/iroh-org/iroh-gossip/.github/workflows/commit.yaml create mode 100644 third_party/iroh-org/iroh-gossip/.github/workflows/docs.yaml create mode 100644 third_party/iroh-org/iroh-gossip/.github/workflows/flaky.yaml create mode 100644 third_party/iroh-org/iroh-gossip/.github/workflows/release.yaml create mode 100644 third_party/iroh-org/iroh-gossip/.github/workflows/simulation.yaml create mode 100644 third_party/iroh-org/iroh-gossip/.github/workflows/tests.yaml create mode 100644 third_party/iroh-org/iroh-gossip/.gitignore create mode 100644 third_party/iroh-org/iroh-gossip/CHANGELOG.md create mode 100644 third_party/iroh-org/iroh-gossip/Cargo.lock create mode 100644 third_party/iroh-org/iroh-gossip/Cargo.toml create mode 100644 third_party/iroh-org/iroh-gossip/LICENSE-APACHE create mode 100644 third_party/iroh-org/iroh-gossip/LICENSE-MIT create mode 100644 third_party/iroh-org/iroh-gossip/Makefile.toml create mode 100644 third_party/iroh-org/iroh-gossip/README.md create mode 100644 third_party/iroh-org/iroh-gossip/cliff.toml create mode 100644 third_party/iroh-org/iroh-gossip/code_of_conduct.md create mode 100644 third_party/iroh-org/iroh-gossip/deny.toml create mode 100644 third_party/iroh-org/iroh-gossip/examples/chat.rs create mode 100644 third_party/iroh-org/iroh-gossip/examples/setup.rs create mode 100644 third_party/iroh-org/iroh-gossip/release.toml create mode 100644 third_party/iroh-org/iroh-gossip/simulations/all.toml create mode 100644 third_party/iroh-org/iroh-gossip/src/api.rs create mode 100644 third_party/iroh-org/iroh-gossip/src/bin/sim.rs create mode 100644 third_party/iroh-org/iroh-gossip/src/lib.rs create mode 100644 third_party/iroh-org/iroh-gossip/src/metrics.rs create mode 100644 third_party/iroh-org/iroh-gossip/src/net.rs create mode 100644 third_party/iroh-org/iroh-gossip/src/net/address_lookup.rs create mode 100644 third_party/iroh-org/iroh-gossip/src/net/util.rs create mode 100644 third_party/iroh-org/iroh-gossip/src/proto.rs create mode 100644 third_party/iroh-org/iroh-gossip/src/proto/hyparview.rs create mode 100644 third_party/iroh-org/iroh-gossip/src/proto/plumtree.rs create mode 100644 third_party/iroh-org/iroh-gossip/src/proto/sim.rs create mode 100644 third_party/iroh-org/iroh-gossip/src/proto/state.rs create mode 100644 third_party/iroh-org/iroh-gossip/src/proto/topic.rs create mode 100644 third_party/iroh-org/iroh-gossip/src/proto/util.rs create mode 100644 third_party/iroh-org/iroh-gossip/tests/sim.rs 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 0000000000000000000000000000000000000000..8ef45ceaba2fe131dadaa6ba1983d8211a6b6d2d GIT binary patch literal 70 zcmeAS@N?(olHy`uVBq!ia0y~yU|FS$q5MwKlm9KSr{4O UpC0yRU|?YIboFyt=akR{0Ha_Ih5!Hn literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..75e33308dec06b2315c39a267647cdeed910d8c4 GIT binary patch literal 984 zcmeAS@N?(olHy`uVBq!ia0y~yVAuk}9Bd2>47O+4j2IZ0=X<(1hE&{od)GTp#8H4H zK)#XHV@_}M#%D}29n~8glQOSya2os68Y#znY%HyRWx3nta?^BiK~ykt`{&QczlWDQ zdNJRPEswnQs5Qoofi32d;9=d39S_@nlt!(!I+4H)L(OXv4)bhn;MsP7$Mgi3*%>a= zGhC)861dL<99{!MJRmWM42T2?Gp}_3%P$U$4q=UIY>Qmj7P*ius=aYrg;evcMVTU5 z+~2DA*ZzBASHfy5_V3)C4b!Ic#rY+6uz0~wkyp4H#2j^ z@#cFUBWx`l{bg2PzLsqA;wpFP=gXaUkH2raRbtoj^GWGk7uzx~|D!dZrIs&}W3PJp zc(c~C*yq<1Uo(D5IwLB7cy}N(ukn>w$K}4y`(}jC|NTOf*Z7KqI7=eZB4Q zzn=$6_I=&@ZLOy4?*4h(?(X=x@A&59emBcp@+`xe-Wkj-_sv|;-DJ0Q`kD`F_tt)9 zx61h+^YrQ^t=oJH8teXk_;**HRqBP%g7Ad6R;i2y{qOAmxBaXS2>)?d;Ki{8^JnJ# zZ~7T{#eANL12eD51ylQGwq57aFaEo}`t)7Ks-i|Vsf-op7njZd6P#eU;;#IHEWD<1#vxk< zA}7TzmDPKQXUnn1wy4Dh%xeo#q_0S|OJ?*k^KNxZY>Rpfk#4gz5))5bAqp~Wg(!%z zBC#zBhNRLOrNClfnGKF&U`e)U$2O@nM(LDBFu6fAWrL#_1np3qW^gAg@YdsPQJ184 rT%EA(#NAZQzuXV>I#9ry6Z-FTGk*Ws(UZi$z`)??>gTe~DWM4fTG78} literal 0 HcmV?d00001 diff --git a/apps/tauri/ui/icons/icon-192.png b/apps/tauri/ui/icons/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..21a0536a248f28fc4a4773f957bdf39ed7b78fad GIT binary patch literal 1096 zcmeAS@N?(olHy`uVBq!ia0y~yU^oE69Bd2>3_*8t*cljD_&r@5Ln>~)y?ZuS+Ec_e zvF$-Ci-lS0(yhV)4(FVunWJtocCdA%{oPab-TFXmU;7)YD5C@JjY zZ2Y6f^k}xgiF_r6{hbXz)L1^v=74hks4?N@FwS5=D1&nx7}OXbdI}oEq#F+J-hcmn z{{OkJFKaMO>YvMSLSA)`0$*Rlm!u9}5YgB0BRN&%kR#)g?)krFxm?g`2xjr-Y5c>w zH!G(GvQ-{9E(UV3$U z9QP;rf(Q2Yb+Pj}y4)T9Z9Mdz>0S2LEt8cM{vT_O)%^QL0%XrjcB|lP za~0=EH8RNAoSFLReZ`(tHFsGGcKi{o%i8>^bdDI~PxbnqaDn=#*;XF|Js2jxi@SOx z{_8K6f&_-lPu&g-r=PB`5%=kHU|{Bd`A z*VX4Y%eAsRcvm0!Z}tBke?~@|S^?#!Qo8TX2+aGtmF2>ZkINr@Z*}|sYp#5A64Q*2 zKZ1XrF50a8;**3*gHrkbSNm69{c5OT)}U4XKX(7_=!d}yN)3GH|0}bs`r)orUe9&p zPkF=f`RCu>ms};`(8{4C*XYJxFzb)8vcQ2x#x-@{)_%#oc=x?4L*n`WSNG}d{r#82 zfq_ZDA&(*a{^8fB`}1#qlu&8-zz}x-@B7aGIULrD#T+j9H?Rc!S9fI6kU60IDZUe= zp@D&AgH?lMzx`slW|jy4xx?~|nGNbKr!ky(pWpsYpz*Z|1LF*aOHKu`ED351n?Esj z99Z;Jyn*vTt2yJ0RyGBu?rHH1Y7FcNC*d-V5k(A28!~>fCNLE!Op|Hg(21&JjHrQ% zeT1qz$_~*hCXZb~)O9rc5n%l3DEFzm!R3iKSsh%5Rw^j*giEe%DilS|#s&@b X^D;uJlzVPH0SSA$`njxgN@xNAPgBmH literal 0 HcmV?d00001 diff --git a/apps/tauri/ui/icons/icon-512.png b/apps/tauri/ui/icons/icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..f0ced44e07d489b0cdd0682084ac331750ac8d20 GIT binary patch literal 3796 zcmeAS@N?(olHy`uVBq!ia0y~yU}6Aa4mJh`hA$OYelalc&GvM045_&F_U=L7*HIF# ziCzf|LR&A2CYw7rewoNA^*U5<6xMi!U_tkAQlb=qvQSCug`v21vVn_v7V5JV8S0yXMG+IK?MenWPL6T zkZYUuIW(9Q7*3e7-Jjnn!eH=^N4B=U3}k^p&5syqLkC8N$LE_8i|@vmcQJ4oY+?z?%GI)=wUgF&QN{vI!K3<$gevfn`W9R ztebj%k_aPH!?7m2Xf6hcdMWl_Yd`^MQ6&(gKHplCfrWvW;V%0U27`Yk4=&d_b}=v> zxY)iY+vPh4Ys1Io&U4>Me&=y*U|7&R=bh|#KGp_?1&3$9Q~fRkl4YL#P7~y9t0gbx z7Be(_e14IA^K0kt(jfJRXO!FzWZ+<^lJ(xjuF242U;A6>Za>H=e9@O`JsA}kUQAHE zJ70w1*!(=q!T@8}k7dr7>G#3M-L%YVye@j7*s+jaHxeFwq_Ofmng8)NG#H4rG zTnvx>>#Jv#^arzWFjz@=ezyWSjJa@@{`=ckclEui1Sw!od~u?i`MVv+HOv=obTgf2 z0I6y||A3q6JQIk?3}&*hHhg@(wSDgEA}Px?Ne7A@1r-=x%s$7+{ObIZn+rfz9uVc( zZ@)Udg~@^8!oe_uwsi-eMG7)99r$s%@tR@Py6)GHW(qP2Fz{`f)L6WJN7y`BMh3IAZIFg(tm@uI4F84CkT zL-@4LC?0ffr{BBwGPlJhtA^5-B zribE8^BlPt7!Pd9mp>NIQo#pG2fh10iZjl02MIr6KK`S-;pP183<3;H4SchudmGR1 zG-t7y4vMa0^OX*K_7qfLm{D>-qF(JDH6uH-DTb$e`rFaN&n^eHVkE0s{*} zHJ`xa^WUmHz^2^)*K6+ou~p6O9Ml~v&O_f`n&D^ zO+Rt-`!ul2#pNa9u0G@6Yw+S%XhX3dgG9Ys{crPMvn2}VS8^*bu`m?#cU?=gD*6)p zIkG;F5oB-eZ}VUKy<0K?<`f2IG`u4@&TQF|Y3rNme9)=TW^PdDcBHBQ;i zaLyLwpWFW*JpBH7*Z)1Npa5W6kn-p6pLh0P1GVFM)%v_{6_~X+II&p#XK7$C__vr@ zq4v+;n|HeJF@XZ<{=aUvO;7brs<;|9;*P`yE;JOp}47;d^5Nd?EI` zUq8FfAi}`p!0_XF=&}1>z4xSpEn_;sQPKNWs)}2Ifu-T2I0IkM9_9;8VoEA31L80pU;q_Q zj0y}I41eY*9oVY`vS`8m|GzEIfBO0IqyJvk58pvT0t`$C93@2<{`da7B2lm(>~;w$ zhPan<#(yR|9k5jfDO$krAvdr6W4tZXvVE)tZXk#9Z)b?8`xD*=N~SCg3=0?xEUf$f zuL7lEP?UY~XZYJ=-T!|IN5TCc%Nv*+SPCvQzny)j=VQDf(;iD9hGX$y|API+AiyBP zU~B)__IUkPj)M1KH$VagiT%Nt3u+g~U&(%n|5KF??1efGQJZ2r#ICl({fyGWFPJ zg0-+PfQkSmVAULKw>gFOcDXBeaw%+ zWKi*BWDsgc zWAfnE5Xc0)Oh_2BOi<~y2ZyEuI@1La+!#z;YL_s8;!djGfn~yykL)fCo>KJ=3=?3Y z;LwBwH-rfeZX_lozGWpj4Zd*c_915N=Q9MizhDjpe7=6qS32sJ}%s-5R z4wH__3lL;7sbv1ZsrJ$jW&uW(UV8>X2N07xM;1B5A6 z$Dq`(xolRpd!EFOnZm`V*M|1d?98ccq{ zMcd>V6j(BTFft@HxZz~7Wqg4tfG`idU}jL@;IU(1Xgcr!mC4m(%fN8xzy)Ro1->2| r28NG~4=yt?Bz#;^AWFM_V1cB){nEHa{>FD7f-LcL^>bP0l+XkK<%hrj literal 0 HcmV?d00001 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