diff --git a/CLAUDE.md b/CLAUDE.md index 99492065..90b67fd6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,10 +10,15 @@ Low-latency desktop streaming stack, Linux-first, with a shared Rust protocol co suite passes (FEC recovery, loopback-under-loss, proptests, a C ABI harness). It was put through an adversarial review and 13 verified findings were fixed + regression-tested (commit `a913042`). -- **The host backends are `#[cfg(target_os = "linux")]` stubs.** They compile everywhere - but `bail!` until implemented. This is the next work (**M0**, then **M2**) and needs a - real Linux GPU + Wayland stack — which is why this repo is being moved to the NVIDIA - Ubuntu VM. +- **M0 (the pipeline spike) is done and verified** on the NVIDIA box (Ubuntu 25.10, RTX + 5070 Ti, driver 595): `lumen-host m0` captures a headless wlroots output via the + ScreenCast portal + PipeWire, NVENC-encodes it, writes a playable H.265 file, and + round-trips every access unit through a `lumen_core` host→client session (0 mismatches). + See [`docs/linux-setup.md`](docs/linux-setup.md); the code is in + `crates/lumen-host/src/{m0,capture,encode}.rs` (+ `capture/linux.rs`, `encode/linux.rs`). +- **The remaining host backends are `#[cfg(target_os = "linux")]` stubs** — KWin/Mutter + virtual displays (`vdisplay.rs`), libei/uinput input (`inject.rs`), web/pairing + (`web.rs`). They compile everywhere but `bail!` until implemented. This is **M2**. ## Build / test / run @@ -56,19 +61,26 @@ include/lumen_core.h generated C header (`crypto.rs`); the ABI enforces `struct_size` and range-checks inputs. There are regression tests for these — keep them green. -## Next: M0 (the pipeline spike) on this VM +## Running the M0 spike on this box -**Start here on the NVIDIA Ubuntu VM:** [`docs/linux-setup.md`](docs/linux-setup.md), then -run `bash scripts/bootstrap-ubuntu.sh` (verifies NVIDIA/NVENC, installs the Rust/PipeWire/ -wlroots/FFmpeg-dev deps) and bring up headless Sway with `scripts/headless/`. +[`docs/linux-setup.md`](docs/linux-setup.md) is the reference. One-time: `bash +scripts/bootstrap-ubuntu.sh` (verifies NVIDIA/NVENC, installs deps incl. `libnvidia-gl`, +adds the `render`/`video` groups — re-login after). Then per run: `bash +scripts/headless/run-headless-sway.sh` (shell 1) and `bash +scripts/headless/prepare-session.sh` (shell 2), then `cargo run -p lumen-host -- m0 +--source portal --out /tmp/lumen-m0.h265`. `--source synthetic` needs no capture session. -Per plan §8/§12: drive a headless Sway/wlroots output → capture via PipeWire (ScreenCast -portal, `ashpd` 0.13 + `pipewire` 0.10) → encode with NVENC (`ffmpeg-next` 7.x, -`hevc_nvenc`) → write a playable H.265 file. Then wire that pipeline into a `lumen-core` -host `Session` (M2). The module seams exist in -`crates/lumen-host/src/{vdisplay,capture,encode,inject,pipeline}.rs`. **Budget for the -CPU-copy fallback first** — dmabuf→NVENC zero-copy import is unreliable across NVIDIA -driver versions (plan §9 risk); the setup doc covers it. +M0 uses the **CPU-copy capture path** (portal → PipeWire shm, packed `RGB` on wlroots → +NVENC `rgb0`); dmabuf→NVENC zero-copy is deferred (plan §9). Pinned crate facts (the setup +doc has the why): `ashpd` **0.13** (`screencast` feature, options-struct API, multi-thread +tokio runtime) + `pipewire` **0.9** (must match ashpd's; not 0.10) + `ffmpeg-next` **7.x**. + +## Next: M2 — P1 host to a stock Moonlight client + +Wire M0's capture→encode pipeline (`m0.rs` / `pipeline.rs`) into a streaming host: KWin +virtual output (`vdisplay.rs`, study KRdp), `serverinfo`/pairing/RTSP (`web.rs`) enough for +a real Moonlight client, input via reis/uinput (`inject.rs`). The module seams exist and +`bail!` today. ## Conventions diff --git a/Cargo.lock b/Cargo.lock index daec228d..072deda8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -57,6 +57,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "annotate-snippets" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "710e8eae58854cdc1790fcb56cca04d712a17be849eeb81da2a724bf4bae2bc4" +dependencies = [ + "anstyle", + "unicode-width", +] + [[package]] name = "anstream" version = "1.0.0" @@ -113,12 +123,260 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "arc-swap" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" +dependencies = [ + "rustversion", +] + +[[package]] +name = "ashpd" +version = "0.13.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "340e0f6bf7f9ee78549c61454f1460a3ed97c011902ee76b58301bbc6d502a32" +dependencies = [ + "enumflags2", + "futures-util", + "getrandom 0.4.2", + "serde", + "serde_repr", + "tokio", + "zbus", +] + +[[package]] +name = "asn1-rs" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom 7.1.3", + "num-traits", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + [[package]] name = "autocfg" version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "aws-lc-rs" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-server" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1ab4a3ec9ea8a657c72d99a03a824af695bd0fb5ec639ccbd9cd3543b41a5f9" +dependencies = [ + "arc-swap", + "bytes", + "fs-err", + "http", + "http-body", + "hyper", + "hyper-util", + "pin-project-lite", + "rustls", + "rustls-pemfile", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[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 = "bindgen" +version = "0.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f49d8fed880d473ea71efb9bf597651e77201bdd4893efe54c9e5d65ae04ce6f" +dependencies = [ + "bitflags 2.13.0", + "cexpr", + "clang-sys", + "itertools", + "proc-macro2", + "quote", + "regex", + "rustc-hash 1.1.0", + "shlex 1.3.0", + "syn", +] + +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "annotate-snippets", + "bitflags 2.13.0", + "cexpr", + "clang-sys", + "itertools", + "proc-macro2", + "quote", + "regex", + "rustc-hash 2.1.2", + "shlex 1.3.0", + "syn", +] + [[package]] name = "bit-set" version = "0.8.0" @@ -146,6 +404,15 @@ version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +[[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.20.3" @@ -174,7 +441,7 @@ dependencies = [ "serde_json", "syn", "tempfile", - "toml", + "toml 0.9.12+spec-1.1.0", ] [[package]] @@ -184,7 +451,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" dependencies = [ "find-msvc-tools", - "shlex", + "jobserver", + "libc", + "shlex 2.0.1", ] [[package]] @@ -193,6 +462,25 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom 7.1.3", +] + +[[package]] +name = "cfg-expr" +version = "0.20.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb693542bcafa528e198be0ebd9d3632ca5b7c93dbe7237460e199910835997c" +dependencies = [ + "smallvec", + "target-lexicon", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -215,6 +503,17 @@ dependencies = [ "inout", ] +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + [[package]] name = "clap" version = "4.6.1" @@ -242,6 +541,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + [[package]] name = "colorchoice" version = "1.0.5" @@ -258,6 +566,36 @@ dependencies = [ "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 = "convert_case" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baaaa0ecca5b51987b9423ccdc971514dd8b0bb7b4060b983d3664dad3f1f89f" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "cookie-factory" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9885fa71e26b8ab7855e2ec7cae6e9b380edff76cd052e07c683a0319d51b3a2" + [[package]] name = "core-foundation" version = "0.10.1" @@ -283,6 +621,12 @@ dependencies = [ "libc", ] +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + [[package]] name = "crypto-common" version = "0.1.7" @@ -303,6 +647,107 @@ dependencies = [ "cipher", ] +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "der-parser" +version = "9.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom 7.1.3", + "num-bigint", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +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", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +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 = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -319,6 +764,27 @@ dependencies = [ "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" @@ -327,7 +793,7 @@ checksum = "4e7f34442dbe69c60fe8eaf58a8cafff81a1f278816d8ab4db255b3bef4ac3c4" dependencies = [ "getrandom 0.3.4", "libm", - "rand", + "rand 0.9.4", "siphasher", ] @@ -337,6 +803,31 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "ffmpeg-next" +version = "7.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da02698288e0275e442a47fc12ca26d50daf0d48b15398ba5906f20ac2e2a9f9" +dependencies = [ + "bitflags 2.13.0", + "ffmpeg-sys-next", + "libc", +] + +[[package]] +name = "ffmpeg-sys-next" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9e9c75ebd4463de9d8998fb134ba26347fe5faee62fabf0a4b4d41bd500b4ad" +dependencies = [ + "bindgen 0.70.1", + "cc", + "libc", + "num_cpus", + "pkg-config", + "vcpkg", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -349,18 +840,105 @@ version = "0.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" +[[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 = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs-err" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73fde052dbfc920003cfd2c8e2c6e6d4cc7c1091538c3a24226cec0665ab08c0" +dependencies = [ + "autocfg", + "tokio", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + [[package]] name = "futures-core" version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[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.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + [[package]] name = "futures-task" version = "0.3.32" @@ -374,6 +952,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-core", + "futures-macro", "futures-task", "pin-project-lite", "slab", @@ -411,11 +990,24 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi", + "r-efi 5.3.0", "wasip2", "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + [[package]] name = "ghash" version = "0.5.1" @@ -426,6 +1018,31 @@ dependencies = [ "polyval", ] +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "h2" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -435,6 +1052,15 @@ dependencies = [ "ahash", ] +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + [[package]] name = "hashbrown" version = "0.17.1" @@ -447,6 +1073,115 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.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 = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "if-addrs" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0a05c691e1fae256cf7013d99dad472dc52d5543322761f83ec8d47eab40d2b" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -455,6 +1190,8 @@ checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", "hashbrown 0.17.1", + "serde", + "serde_core", ] [[package]] @@ -481,6 +1218,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -531,6 +1277,16 @@ dependencies = [ "syn", ] +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + [[package]] name = "js-sys" version = "0.3.100" @@ -551,6 +1307,15 @@ name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libc" @@ -558,12 +1323,50 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + [[package]] name = "libm" version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +[[package]] +name = "libspa" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6b8cfa2a7656627b4c92c6b9ef929433acd673d5ab3708cda1b18478ac00df4" +dependencies = [ + "bitflags 2.13.0", + "cc", + "convert_case", + "cookie-factory", + "libc", + "libspa-sys", + "nix", + "nom 8.0.0", + "system-deps", +] + +[[package]] +name = "libspa-sys" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901049455d2eb6decf9058235d745237952f4804bc584c5fcb41412e6adcc6e0" +dependencies = [ + "bindgen 0.72.1", + "cc", + "system-deps", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -625,7 +1428,7 @@ dependencies = [ "cbindgen", "proptest", "quinn", - "rand", + "rand 0.9.4", "reed-solomon-erasure", "reed-solomon-simd", "thiserror 2.0.18", @@ -639,10 +1442,27 @@ dependencies = [ name = "lumen-host" version = "0.0.1" dependencies = [ + "aes", "anyhow", + "ashpd", + "axum", + "axum-server", + "ffmpeg-next", + "hex", + "libc", "lumen-core", + "mdns-sd", + "pipewire", + "rand 0.8.6", + "rcgen", + "rsa", + "rustls", + "rustls-pemfile", + "sha2", + "tokio", "tracing", "tracing-subscriber", + "x509-parser", ] [[package]] @@ -654,12 +1474,54 @@ dependencies = [ "regex-automata", ] +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "mdns-sd" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "892f96f6d2ebe1ea641279f986ac52a2a6bac71e8f743bb258315cfe2bd7e88e" +dependencies = [ + "fastrand", + "flume", + "if-addrs", + "log", + "mio", + "socket-pktinfo", + "socket2", +] + [[package]] name = "memchr" version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" +[[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 = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "mio" version = "1.2.1" @@ -667,10 +1529,42 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.61.2", ] +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags 2.13.0", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[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 = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -680,6 +1574,58 @@ 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-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.6", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[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-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -687,6 +1633,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "oid-registry" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9" +dependencies = [ + "asn1-rs", ] [[package]] @@ -713,6 +1679,22 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[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.11.2" @@ -721,7 +1703,17 @@ checksum = "7d17b78036a60663b797adeaee46f5c9dfebb86948d1255007a1d6be0271ff99" dependencies = [ "instant", "lock_api", - "parking_lot_core", + "parking_lot_core 0.8.6", +] + +[[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 0.9.12", ] [[package]] @@ -733,17 +1725,110 @@ dependencies = [ "cfg-if", "instant", "libc", - "redox_syscall", + "redox_syscall 0.2.16", "smallvec", "winapi", ] +[[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 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "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-lite" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pipewire" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9688b89abf11d756499f7c6190711d6dbe5a3acdb30c8fbf001d6596d06a8d44" +dependencies = [ + "anyhow", + "bitflags 2.13.0", + "libc", + "libspa", + "libspa-sys", + "nix", + "once_cell", + "pipewire-sys", + "thiserror 2.0.18", +] + +[[package]] +name = "pipewire-sys" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb028afee0d6ca17020b090e3b8fa2d7de23305aef975c7e5192a5050246ea36" +dependencies = [ + "bindgen 0.72.1", + "libspa-sys", + "system-deps", +] + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + [[package]] name = "polyval" version = "0.6.2" @@ -756,6 +1841,12 @@ dependencies = [ "universal-hash", ] +[[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" @@ -765,6 +1856,25 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -784,8 +1894,8 @@ dependencies = [ "bit-vec", "bitflags 2.13.0", "num-traits", - "rand", - "rand_chacha", + "rand 0.9.4", + "rand_chacha 0.9.0", "rand_xorshift", "regex-syntax", "rusty-fork", @@ -810,7 +1920,7 @@ dependencies = [ "pin-project-lite", "quinn-proto", "quinn-udp", - "rustc-hash", + "rustc-hash 2.1.2", "rustls", "socket2", "thiserror 2.0.18", @@ -829,9 +1939,9 @@ dependencies = [ "fastbloom", "getrandom 0.3.4", "lru-slab", - "rand", + "rand 0.9.4", "ring", - "rustc-hash", + "rustc-hash 2.1.2", "rustls", "rustls-pki-types", "rustls-platform-verifier", @@ -871,16 +1981,43 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + [[package]] name = "rand" version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ - "rand_chacha", + "rand_chacha 0.9.0", "rand_core 0.9.5", ] +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + [[package]] name = "rand_chacha" version = "0.9.0" @@ -918,6 +2055,19 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rcgen" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2" +dependencies = [ + "aws-lc-rs", + "pem", + "rustls-pki-types", + "time", + "yasna", +] + [[package]] name = "readme-rustdocifier" version = "0.1.1" @@ -933,6 +2083,15 @@ dependencies = [ "bitflags 1.3.2", ] +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.0", +] + [[package]] name = "reed-solomon-erasure" version = "6.0.0" @@ -941,7 +2100,7 @@ checksum = "7263373d500d4d4f505d43a2a662d475a894aa94503a1ee28e9188b5f3960d4f" dependencies = [ "libm", "lru", - "parking_lot", + "parking_lot 0.11.2", "smallvec", "spin", ] @@ -958,6 +2117,18 @@ dependencies = [ "readme-rustdocifier", ] +[[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" @@ -989,12 +2160,47 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + [[package]] name = "rustc-hash" version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom 7.1.3", +] + [[package]] name = "rustix" version = "1.1.4" @@ -1014,6 +2220,8 @@ version = "0.23.40" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ + "aws-lc-rs", + "log", "once_cell", "ring", "rustls-pki-types", @@ -1034,6 +2242,15 @@ dependencies = [ "security-framework", ] +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "rustls-pki-types" version = "1.14.1" @@ -1077,6 +2294,7 @@ version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ + "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -1100,6 +2318,12 @@ dependencies = [ "wait-timeout", ] +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "same-file" version = "1.0.6" @@ -1147,6 +2371,12 @@ dependencies = [ "libc", ] +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.228" @@ -1190,6 +2420,28 @@ dependencies = [ "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", +] + [[package]] name = "serde_spanned" version = "1.1.1" @@ -1199,6 +2451,29 @@ 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 = "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" @@ -1208,12 +2483,38 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + [[package]] name = "shlex" version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + [[package]] name = "siphasher" version = "1.0.3" @@ -1232,6 +2533,17 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "socket-pktinfo" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "927136cc2ae6a1b0e66ac6b1210902b75c3f726db004a73bc18686dcd0dcd22f" +dependencies = [ + "libc", + "socket2", + "windows-sys 0.60.2", +] + [[package]] name = "socket2" version = "0.6.4" @@ -1247,6 +2559,19 @@ 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 = "strsim" @@ -1271,6 +2596,42 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[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 = "system-deps" +version = "7.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396a35feb67335377e0251fcbc1092fc85c484bd4e3a7a54319399da127796e7" +dependencies = [ + "cfg-expr", + "heck", + "pkg-config", + "toml 1.1.2+spec-1.1.0", + "version-compare", +] + +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + [[package]] name = "tempfile" version = "3.27.0" @@ -1278,7 +2639,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.2", "once_cell", "rustix", "windows-sys 0.61.2", @@ -1333,6 +2694,37 @@ 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 = "tinyvec" version = "1.11.0" @@ -1354,11 +2746,15 @@ version = "1.52.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ + "bytes", "libc", "mio", + "parking_lot 0.12.5", "pin-project-lite", + "signal-hook-registry", "socket2", "tokio-macros", + "tracing", "windows-sys 0.61.2", ] @@ -1373,6 +2769,29 @@ dependencies = [ "syn", ] +[[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-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 = "toml" version = "0.9.12+spec-1.1.0" @@ -1382,12 +2801,27 @@ dependencies = [ "indexmap", "serde_core", "serde_spanned", - "toml_datetime", + "toml_datetime 0.7.5+spec-1.1.0", "toml_parser", "toml_writer", "winnow 0.7.15", ] +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.3", +] + [[package]] name = "toml_datetime" version = "0.7.5+spec-1.1.0" @@ -1397,6 +2831,27 @@ dependencies = [ "serde_core", ] +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +dependencies = [ + "indexmap", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.3", +] + [[package]] name = "toml_parser" version = "1.1.2+spec-1.1.0" @@ -1412,6 +2867,34 @@ version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + [[package]] name = "tracing" version = "0.1.44" @@ -1480,6 +2963,17 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + [[package]] name = "unarray" version = "0.1.4" @@ -1492,6 +2986,24 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[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" @@ -1514,12 +3026,35 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7" +dependencies = [ + "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 = "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" @@ -1557,7 +3092,16 @@ version = "1.0.3+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", ] [[package]] @@ -1605,6 +3149,40 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.13.0", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + [[package]] name = "web-time" version = "1.1.0" @@ -1894,6 +3472,18 @@ name = "winnow" version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] [[package]] name = "wit-bindgen" @@ -1901,6 +3491,167 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.13.0", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "x509-parser" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom 7.1.3", + "oid-registry", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + +[[package]] +name = "yasna" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +dependencies = [ + "time", +] + +[[package]] +name = "zbus" +version = "5.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eee682d202a77e4a9f3b2c2bdf48a7b28af5c08c34ddf66f98c93e5e39464285" +dependencies = [ + "async-broadcast", + "async-recursion", + "async-trait", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tokio", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 1.0.3", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adf1bd45a81a103745b1757754762a26e8cd01e4532e4d6c8ec431624b80d1d6" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d" +dependencies = [ + "serde", + "winnow 1.0.3", + "zvariant", +] + [[package]] name = "zerocopy" version = "0.8.50" @@ -1932,3 +3683,43 @@ name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zvariant" +version = "5.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a192a0bde63360d77a7523c833d4b4ce6070a927e2c53246e4c540b1a3e27be0" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 1.0.3", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90bc6cde9c01c511074be97f7ccb6c19d0da89e3f8662e812e999dcfd4638737" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e8535915cfa75547e559d8c68e8139909a4aeee076831e4ef7fc59d8172c4d6" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn", + "winnow 1.0.3", +] diff --git a/README.md b/README.md index 7a7e7d8a..62346edc 100644 --- a/README.md +++ b/README.md @@ -13,16 +13,20 @@ negotiated extension. See [`docs/implementation-plan.md`](docs/implementation-pl | Milestone | State | |-----------|-------| | **M1 — `lumen-core` + C ABI** | ✅ done & tested (FEC, packetization, crypto, session, `lumen_core.h`) | -| M0 — pipeline spike (wlroots→PipeWire→encode→file) | ⬜ needs Linux GPU | -| M2 — P1 host → stock Moonlight | ⬜ scaffolded (`lumen-host`) | +| **M0 — pipeline spike** (wlroots→PipeWire→NVENC→file→`lumen-core`) | ✅ done & verified on NVIDIA (RTX 5070 Ti / driver 595) | +| M2 — P1 host → stock Moonlight | 🟡 capture+encode landed in M0; pairing/RTSP/vdisplay pending | | M3 — measurement harness | 🟡 `tools/loss-harness` runs; `latency-probe` scaffolded | | M4 — P2 transport + Rust client | 🟡 GF(2¹⁶) core done; `lumen-client-rs` scaffolded | | M5 — Apple client | ⬜ scaffolded (`clients/apple`) | `lumen-core` is complete and verified: it builds and its full test suite (FEC recovery, loopback round-trip under loss, property tests, and a **C ABI harness**) passes on -macOS/aarch64. The Linux host backends (PipeWire, VAAPI/NVENC, KWin, libei) are -`#[cfg(target_os = "linux")]` seams — defined and compiling, implementations pending. +macOS/aarch64. **M0 is done:** `lumen-host` captures a headless wlroots output via the +ScreenCast portal + PipeWire, encodes it with NVENC, writes a playable H.265 file, and +round-trips every access unit through a `lumen_core` host→client session (see +`docs/linux-setup.md`). The remaining Linux host backends (KWin/Mutter virtual displays, +libei input, web/pairing) are `#[cfg(target_os = "linux")]` seams — defined and compiling, +implementations pending (M2). ## Layout diff --git a/crates/lumen-host/Cargo.toml b/crates/lumen-host/Cargo.toml index 0d49c457..5ad6c8e5 100644 --- a/crates/lumen-host/Cargo.toml +++ b/crates/lumen-host/Cargo.toml @@ -13,15 +13,30 @@ lumen-core = { path = "../lumen-core" } anyhow = "1" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } +axum = "0.8" +mdns-sd = "0.20" +tokio = { version = "1", features = ["full"] } +rsa = "0.9" +sha2 = { version = "0.10", features = ["oid"] } +aes = "0.8" +rand = "0.8" +hex = "0.4" +rcgen = { version = "0.13", default-features = false, features = ["aws_lc_rs", "pem"] } +x509-parser = "0.16" +axum-server = { version = "0.7", features = ["tls-rustls"] } +rustls = "0.23" +rustls-pemfile = "2" -# Linux backends are wired in M0/M2. They live behind `#[cfg(target_os = "linux")]` -# so the workspace stays green on macOS; the dep list (per plan §4) is: -# -# [target.'cfg(target_os = "linux")'.dependencies] -# pipewire = "..." # ScreenCast portal stream -> dmabuf (capture) -# ashpd = "..." # xdg-desktop-portal: ScreenCast, RemoteDesktop -# zbus = "..." # DBus: KWin/Mutter virtual-output creation -# ffmpeg-next / rsmpeg # VAAPI / NVENC encode, dmabuf import -# reis = "..." # libei input injection (Wayland-native) -# input-linux = "..." # uinput fallback -# axum + tokio # web config / pairing API +[target.'cfg(target_os = "linux")'.dependencies] +# `screencast` gates the ScreenCast portal module; `tokio` is the default runtime. +# `open_pipe_wire_remote` is unconditional, so ashpd's own `pipewire` feature is not +# needed — we drive PipeWire with the `pipewire` crate below. +ashpd = { version = "0.13", features = ["screencast"] } +ffmpeg-next = "7" +libc = "0.2" +# Must match the pipewire crate ashpd 0.13 links (libspa/pipewire-sys `links` key is +# unique per build), i.e. 0.9 — NOT the 0.10 the setup doc mentions. +pipewire = "0.9" +# ashpd 0.13 uses the tokio runtime; a current-thread runtime drives the one-time +# portal handshake (control plane — never the per-frame path). +tokio = { version = "1", features = ["rt", "rt-multi-thread", "net", "time"] } diff --git a/crates/lumen-host/src/capture.rs b/crates/lumen-host/src/capture.rs index 41eee98d..dfa466c6 100644 --- a/crates/lumen-host/src/capture.rs +++ b/crates/lumen-host/src/capture.rs @@ -1,15 +1,48 @@ -//! Frame capture (plan §7). On Linux: a PipeWire ScreenCast portal stream delivering -//! dmabuf frames with no copy to the CPU. The encoder imports the dmabuf directly. +//! Frame capture (plan §7). On Linux: a PipeWire ScreenCast portal stream. M0 uses the +//! CPU-copy fallback (the portal delivers a CPU buffer; the encoder uploads it to the GPU +//! internally). Zero-copy dmabuf→NVENC import is deferred (plan §9 risk). use anyhow::Result; -/// A captured frame. For zero-copy the real type wraps a dmabuf fd + modifier; the CPU -/// buffer is only a fallback path (plan §9 risk: per-GPU dmabuf import quirks). +/// Packed pixel layout of a [`CapturedFrame`]. The ScreenCast portal negotiates the +/// format; on wlroots it is commonly packed `RGB` (3 bytes/pixel). The encoder maps these +/// to an NVENC-accepted input format (`rgb0`/`bgr0`/`rgba`/`bgra`), expanding 3→4 bytes +/// where needed — no host-side colour conversion. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PixelFormat { + /// `[B,G,R,x]`, 4 bpp. + Bgrx, + /// `[R,G,B,x]`, 4 bpp. + Rgbx, + /// `[B,G,R,A]`, 4 bpp. + Bgra, + /// `[R,G,B,A]`, 4 bpp. + Rgba, + /// `[R,G,B]`, 3 bpp. + Rgb, + /// `[B,G,R]`, 3 bpp. + Bgr, +} + +impl PixelFormat { + pub fn bytes_per_pixel(self) -> usize { + match self { + PixelFormat::Rgb | PixelFormat::Bgr => 3, + _ => 4, + } + } +} + +/// A captured frame. For zero-copy the real type would wrap a dmabuf fd + modifier; the +/// CPU buffer is the M0 fallback path (plan §9 risk: per-GPU dmabuf import quirks). pub struct CapturedFrame { pub width: u32, pub height: u32, pub pts_ns: u64, - /// Fallback CPU pixels (empty when a dmabuf is used). + /// Pixel layout of `cpu_bytes`. + pub format: PixelFormat, + /// Tightly-packed pixels in `format`, `width * height * format.bytes_per_pixel()` + /// bytes (no row padding). pub cpu_bytes: Vec, } @@ -19,14 +52,86 @@ pub trait Capturer: Send { fn next_frame(&mut self) -> Result; } -/// Open a capturer for a PipeWire node id (from the ScreenCast portal). -pub fn open_pipewire(_node_id: u32) -> Result> { - #[cfg(target_os = "linux")] - { - anyhow::bail!("pipewire capture not yet implemented (M0)") - } - #[cfg(not(target_os = "linux"))] - { - anyhow::bail!("capture requires Linux + PipeWire") +/// A deterministic moving test pattern (BGRx). Lets M0 exercise the encode → file → +/// `lumen_core` path with no live capture session, and produces obviously non-static +/// content (a sweeping bar + animated gradient) so the encoded output is verifiable. +pub struct SyntheticCapturer { + width: u32, + height: u32, + fps: u32, + frame_idx: u64, + buf: Vec, +} + +impl SyntheticCapturer { + const BPP: usize = 4; // emits BGRx + + pub fn new(width: u32, height: u32, fps: u32) -> Self { + assert!(width > 0 && height > 0 && fps > 0); + let buf = vec![0u8; width as usize * height as usize * Self::BPP]; + SyntheticCapturer { + width, + height, + fps, + frame_idx: 0, + buf, + } } } + +impl Capturer for SyntheticCapturer { + fn next_frame(&mut self) -> Result { + let w = self.width as usize; + let h = self.height as usize; + let bpp = Self::BPP; + let t = self.frame_idx; + // A vertical bar sweeps left→right once every ~2s; the background is a gradient + // whose phase advances each frame, so every pixel changes frame-to-frame. + let bar_x = ((t * w as u64) / (self.fps as u64 * 2)) % w as u64; + let phase = (t % 256) as usize; + for y in 0..h { + let row = y * w * bpp; + for x in 0..w { + let i = row + x * bpp; + let on_bar = (x as u64).abs_diff(bar_x) < 8; + // BGRx byte order: [B, G, R, x] + self.buf[i] = if on_bar { + 255 + } else { + ((x + phase) & 0xff) as u8 + }; + self.buf[i + 1] = if on_bar { + 255 + } else { + ((y + phase) & 0xff) as u8 + }; + self.buf[i + 2] = if on_bar { 255 } else { ((x + y) & 0xff) as u8 }; + self.buf[i + 3] = 0; + } + } + let pts_ns = self.frame_idx * 1_000_000_000 / self.fps as u64; + self.frame_idx += 1; + Ok(CapturedFrame { + width: self.width, + height: self.height, + pts_ns, + format: PixelFormat::Bgrx, + cpu_bytes: self.buf.clone(), + }) + } +} + +/// Open a live capturer for a client-sized monitor via the xdg ScreenCast portal +/// (`ashpd`) → PipeWire (`pipewire`). Implemented in the `linux` submodule. +#[cfg(target_os = "linux")] +pub fn open_portal_monitor() -> Result> { + linux::PortalCapturer::open().map(|c| Box::new(c) as Box) +} + +#[cfg(not(target_os = "linux"))] +pub fn open_portal_monitor() -> Result> { + anyhow::bail!("portal capture requires Linux (xdg-desktop-portal + PipeWire)") +} + +#[cfg(target_os = "linux")] +mod linux; diff --git a/crates/lumen-host/src/capture/linux.rs b/crates/lumen-host/src/capture/linux.rs new file mode 100644 index 00000000..5c12297b --- /dev/null +++ b/crates/lumen-host/src/capture/linux.rs @@ -0,0 +1,407 @@ +//! Live capture: xdg ScreenCast portal (`ashpd`) → PipeWire (`pipewire`), CPU-copy path. +//! +//! Two dedicated threads, because both stacks are tied to their thread: +//! * **portal thread** drives the async ashpd handshake on a multi-thread tokio runtime +//! (control plane — never the per-frame path), then parks on a pending future so the +//! `proxy` + its zbus connection stay alive (the cast is torn down when that connection +//! drops; ashpd's `Session` has no `Drop`); +//! * **pipewire thread** owns the (`!Send`) MainLoop/Stream and pumps frames. +//! +//! The portal hands the PipeWire remote fd + node id to the pipewire thread; decoded BGRx +//! frames leave the pipewire thread over a bounded channel. The authoritative frame size +//! comes from the negotiated PipeWire format, not the portal's size hint. +//! +//! Cleanup note (M0): the two threads are detached and torn down at process exit. A +//! graceful stop (pipewire `channel` quit + Session close) belongs with the M2 session +//! lifecycle. + +use super::{CapturedFrame, Capturer, PixelFormat}; +use anyhow::{anyhow, Context, Result}; +use std::os::fd::OwnedFd; +use std::sync::mpsc::{sync_channel, Receiver, RecvTimeoutError}; +use std::thread; +use std::time::Duration; + +/// Live monitor capturer backed by the portal + PipeWire threads. +pub struct PortalCapturer { + frames: Receiver, +} + +impl PortalCapturer { + pub fn open() -> Result { + // Portal handshake (async) on its own thread; hands back the PW fd + node id. + let (setup_tx, setup_rx) = std::sync::mpsc::channel::>(); + thread::Builder::new() + .name("lumen-portal".into()) + .spawn(move || portal_thread(setup_tx)) + .context("spawn portal thread")?; + + let (fd, node_id) = match setup_rx.recv_timeout(Duration::from_secs(20)) { + Ok(Ok(v)) => v, + Ok(Err(e)) => return Err(anyhow!("ScreenCast portal setup failed: {e}")), + Err(_) => return Err(anyhow!("timed out waiting for the ScreenCast portal")), + }; + tracing::info!( + node_id, + "ScreenCast portal session started; connecting PipeWire" + ); + + // Frames flow from the pipewire thread over a small bounded channel. + let (frame_tx, frame_rx) = sync_channel::(8); + thread::Builder::new() + .name("lumen-pipewire".into()) + .spawn(move || { + if let Err(e) = pipewire::pipewire_thread(fd, node_id, frame_tx) { + tracing::error!(error = %format!("{e:#}"), "pipewire capture thread failed"); + } + }) + .context("spawn pipewire thread")?; + + Ok(PortalCapturer { frames: frame_rx }) + } +} + +impl Capturer for PortalCapturer { + fn next_frame(&mut self) -> Result { + // First frame can lag behind format negotiation; later frames arrive at ~fps. + match self.frames.recv_timeout(Duration::from_secs(10)) { + Ok(frame) => Ok(frame), + Err(RecvTimeoutError::Timeout) => Err(anyhow!("no PipeWire frame within 10s")), + Err(RecvTimeoutError::Disconnected) => Err(anyhow!("PipeWire capture thread ended")), + } + } +} + +/// The portal handshake: connect ScreenCast, select a single monitor, start, open the +/// PipeWire remote, hand the fd + node id back, then keep the session alive. +fn portal_thread(setup_tx: std::sync::mpsc::Sender>) { + use ashpd::desktop::screencast::{CursorMode, Screencast, SelectSourcesOptions, SourceType}; + use ashpd::desktop::PersistMode; + use ashpd::enumflags2::BitFlags; + + // Multi-thread runtime: the zbus connection's background reader must be pumped + // continuously across the create_session → select_sources → start handshake, or the + // portal reports "Invalid session". (A current-thread runtime starves it.) + let rt = match tokio::runtime::Builder::new_multi_thread() + .worker_threads(2) + .enable_all() + .build() + { + Ok(rt) => rt, + Err(e) => { + let _ = setup_tx.send(Err(format!("build tokio runtime: {e}"))); + return; + } + }; + let err_tx = setup_tx.clone(); + + rt.block_on(async move { + let result: Result<()> = async { + let proxy = Screencast::new() + .await + .context("connect ScreenCast portal")?; + let session = proxy + .create_session(Default::default()) + .await + .context("create_session")?; + proxy + .select_sources( + &session, + SelectSourcesOptions::default() + .set_cursor_mode(CursorMode::Hidden) + // Only MONITOR is offered by the wlroots backend + // (AvailableSourceTypes=1); requesting unsupported types + // invalidates the session. + .set_sources(BitFlags::from_flag(SourceType::Monitor)) + .set_multiple(false) + .set_persist_mode(PersistMode::DoNot), + ) + .await + .context("select_sources")? + .response() + .context("select_sources rejected (unsupported source type / cursor mode?)")?; + let streams = proxy + .start(&session, None, Default::default()) + .await + .context("start cast")? + .response() + .context("start response (chooser cancelled? portal misconfigured?)")?; + let stream = streams + .streams() + .first() + .context("portal returned no streams")? + .clone(); + let node_id = stream.pipe_wire_node_id(); + let fd = proxy + .open_pipe_wire_remote(&session, Default::default()) + .await + .context("open_pipe_wire_remote")?; + + setup_tx + .send(Ok((fd, node_id))) + .map_err(|_| anyhow!("capturer dropped before setup completed"))?; + + // Keep `proxy` + `session` (and the underlying zbus connection) alive for the + // capture; the cast is torn down when the connection drops (ashpd's `Session` + // has no `Drop`), which here happens at process exit. + let _keep_alive = (&proxy, &session); + std::future::pending::<()>().await; + Ok(()) + } + .await; + + if let Err(e) = result { + let _ = err_tx.send(Err(format!("{e:#}"))); + } + }); +} + +mod pipewire { + //! The PipeWire consumer, confined to its own thread (the PW types are `!Send`). + + use super::{CapturedFrame, PixelFormat}; + use anyhow::{Context, Result}; + use pipewire as pw; + use pw::{properties::properties, spa}; + use std::os::fd::OwnedFd; + use std::sync::mpsc::SyncSender; + use std::time::{SystemTime, UNIX_EPOCH}; + + use spa::param::video::{VideoFormat, VideoInfoRaw}; + use spa::pod::Pod; + + /// Map a negotiated SPA video format to a layout the encoder can consume. Returns + /// `None` for formats we don't handle (the frame is then skipped). + fn map_format(f: VideoFormat) -> Option { + Some(match f { + VideoFormat::BGRx => PixelFormat::Bgrx, + VideoFormat::RGBx => PixelFormat::Rgbx, + VideoFormat::BGRA => PixelFormat::Bgra, + VideoFormat::RGBA => PixelFormat::Rgba, + VideoFormat::RGB => PixelFormat::Rgb, + VideoFormat::BGR => PixelFormat::Bgr, + _ => return None, + }) + } + + struct UserData { + info: VideoInfoRaw, + /// Negotiated layout (`None` until param_changed, or if unsupported). + format: Option, + tx: SyncSender, + } + + pub fn pipewire_thread(fd: OwnedFd, node_id: u32, tx: SyncSender) -> Result<()> { + pw::init(); + + let mainloop = pw::main_loop::MainLoopRc::new(None).context("pw MainLoop")?; + let context = pw::context::ContextRc::new(&mainloop, None).context("pw Context")?; + let core = context + .connect_fd_rc(fd, None) + .context("pw connect_fd (portal remote)")?; + + let data = UserData { + info: VideoInfoRaw::default(), + format: None, + tx, + }; + + let stream = pw::stream::StreamBox::new( + &core, + "lumen-screencast", + properties! { + *pw::keys::MEDIA_TYPE => "Video", + *pw::keys::MEDIA_CATEGORY => "Capture", + *pw::keys::MEDIA_ROLE => "Screen", + }, + ) + .context("pw Stream")?; + + let _listener = stream + .add_local_listener_with_user_data(data) + .state_changed(|_stream, _ud, old, new| { + tracing::info!(?old, ?new, "pipewire stream state"); + }) + .param_changed(|_stream, ud, id, param| { + let Some(param) = param else { return }; + if id != pw::spa::param::ParamType::Format.as_raw() { + return; + } + let Ok((media_type, media_subtype)) = + pw::spa::param::format_utils::parse_format(param) + else { + return; + }; + if media_type != pw::spa::param::format::MediaType::Video + || media_subtype != pw::spa::param::format::MediaSubtype::Raw + { + return; + } + if ud.info.parse(param).is_ok() { + let sz = ud.info.size(); + ud.format = map_format(ud.info.format()); + tracing::info!( + width = sz.width, + height = sz.height, + spa_format = ?ud.info.format(), + mapped = ?ud.format, + "pipewire format negotiated" + ); + if ud.format.is_none() { + tracing::error!( + spa_format = ?ud.info.format(), + "negotiated a pixel format the encoder cannot consume — frames will be skipped" + ); + } + } + }) + .process(|stream, ud| { + // PipeWire dispatches this from a C trampoline with no catch_unwind; a + // panic crossing that FFI boundary would abort the whole host. Contain it. + let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let Some(mut buffer) = stream.dequeue_buffer() else { + return; + }; + let datas = buffer.datas_mut(); + if datas.is_empty() { + return; + } + let sz = ud.info.size(); + let (w, h) = (sz.width as usize, sz.height as usize); + if w == 0 || h == 0 { + return; // format not negotiated yet + } + let d = &mut datas[0]; + let (size, offset, stride) = { + let c = d.chunk(); + ( + c.size() as usize, + c.offset() as usize, + c.stride().max(0) as usize, + ) + }; + let Some(fmt) = ud.format else { return }; // unsupported/not negotiated + let bpp = fmt.bytes_per_pixel(); + let row = w * bpp; + let stride = if stride == 0 { row } else { stride }; + let Some(buf) = d.data() else { return }; + // Need stride*(h-1)+row valid bytes within [offset, offset+size). + if stride < row || offset > buf.len() { + return; + } + let avail = buf.len() - offset; + let needed = stride * (h - 1) + row; + if needed > avail || needed > size { + return; + } + let region = &buf[offset..offset + size.min(avail)]; + // De-pad into a tightly-packed buffer (chunk stride may exceed w*bpp). + let mut tight = vec![0u8; row * h]; + for y in 0..h { + tight[y * row..y * row + row] + .copy_from_slice(®ion[y * stride..y * stride + row]); + } + let pts_ns = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos() as u64) + .unwrap_or(0); + let frame = CapturedFrame { + width: w as u32, + height: h as u32, + pts_ns, + format: fmt, + cpu_bytes: tight, + }; + // Drop if the encoder is behind — never block the pipewire loop. + let _ = ud.tx.try_send(frame); + })); + if outcome.is_err() { + tracing::error!("panic in pipewire process callback — frame dropped"); + } + }) + .register() + .context("register stream listener")?; + + // Request raw video in any encoder-mappable layout, any size/framerate. + let obj = pw::spa::pod::object!( + pw::spa::utils::SpaTypes::ObjectParamFormat, + pw::spa::param::ParamType::EnumFormat, + pw::spa::pod::property!( + pw::spa::param::format::FormatProperties::MediaType, + Id, + pw::spa::param::format::MediaType::Video + ), + pw::spa::pod::property!( + pw::spa::param::format::FormatProperties::MediaSubtype, + Id, + pw::spa::param::format::MediaSubtype::Raw + ), + // Offer the layouts the encoder can map to an NVENC input format. wlroots + // commonly fixates packed RGB (3 bpp); other compositors offer 4 bpp. Only + // these are requested, so negotiation fails loudly rather than handing us a + // format we'd misinterpret. + pw::spa::pod::property!( + pw::spa::param::format::FormatProperties::VideoFormat, + Choice, + Enum, + Id, + VideoFormat::RGB, + VideoFormat::RGB, + VideoFormat::BGR, + VideoFormat::RGBx, + VideoFormat::BGRx, + VideoFormat::RGBA, + VideoFormat::BGRA, + ), + pw::spa::pod::property!( + pw::spa::param::format::FormatProperties::VideoSize, + Choice, + Range, + Rectangle, + pw::spa::utils::Rectangle { + width: 1920, + height: 1080 + }, + pw::spa::utils::Rectangle { + width: 1, + height: 1 + }, + pw::spa::utils::Rectangle { + width: 8192, + height: 8192 + } + ), + pw::spa::pod::property!( + pw::spa::param::format::FormatProperties::VideoFramerate, + Choice, + Range, + Fraction, + pw::spa::utils::Fraction { num: 60, denom: 1 }, + pw::spa::utils::Fraction { num: 0, denom: 1 }, + pw::spa::utils::Fraction { num: 240, denom: 1 } + ), + ); + + let values: Vec = pw::spa::pod::serialize::PodSerializer::serialize( + std::io::Cursor::new(Vec::new()), + &pw::spa::pod::Value::Object(obj), + ) + .context("serialize format pod")? + .0 + .into_inner(); + let mut params = [Pod::from_bytes(&values).context("pod from bytes")?]; + + stream + .connect( + spa::utils::Direction::Input, + Some(node_id), + pw::stream::StreamFlags::AUTOCONNECT | pw::stream::StreamFlags::MAP_BUFFERS, + &mut params, + ) + .context("pw stream connect")?; + + // Blocks this thread, pumping frame callbacks until process exit. + mainloop.run(); + Ok(()) + } +} diff --git a/crates/lumen-host/src/encode.rs b/crates/lumen-host/src/encode.rs index 83a7bca9..a1208a1c 100644 --- a/crates/lumen-host/src/encode.rs +++ b/crates/lumen-host/src/encode.rs @@ -1,10 +1,15 @@ -//! Hardware video encode (plan §7). Binds FFmpeg (VAAPI / NVENC); never rewrites codecs. -//! Low-latency preset, lookahead off, dmabuf import for zero-copy from [`crate::capture`]. +//! Hardware video encode (plan §7). Binds FFmpeg (NVENC); never rewrites codecs. +//! Low-latency preset, B-frames off. M0 feeds BGRx CPU frames directly — `*_nvenc` +//! accepts `bgr0` input and converts to YUV on the GPU, so no host-side swscale is +//! needed (dmabuf zero-copy import is deferred; plan §9). -use crate::capture::CapturedFrame; +use crate::capture::{CapturedFrame, PixelFormat}; use anyhow::Result; /// An encoded access unit (one NAL/AU) to hand to `lumen_core` for FEC + packetization. +/// `data` is in-band Annex-B (the encoder is opened without a global header), so each +/// keyframe carries its own VPS/SPS/PPS — the bytes are both a playable elementary +/// stream and a self-contained AU for the wire. pub struct EncodedFrame { pub data: Vec, pub pts_ns: u64, @@ -20,21 +25,50 @@ pub enum Codec { Av1, } +impl Codec { + /// The FFmpeg NVENC encoder name (selected by name, not codec id — the latter would + /// pick the software encoder). + pub fn nvenc_name(self) -> &'static str { + match self { + Codec::H264 => "h264_nvenc", + Codec::H265 => "hevc_nvenc", + Codec::Av1 => "av1_nvenc", + } + } +} + /// A hardware encoder. One per session; runs on the encode thread. pub trait Encoder: Send { fn submit(&mut self, frame: &CapturedFrame) -> Result<()>; /// Pull the next encoded AU if one is ready. fn poll(&mut self) -> Result>; + /// Signal end-of-stream. After this, drain the remaining AUs with [`poll`](Self::poll) + /// until it returns `None` — NVENC buffers frames internally even at `delay=0`. + fn flush(&mut self) -> Result<()>; } -/// Open an encoder. `bitrate_bps` and `codec` come from session negotiation. -pub fn open(_codec: Codec, _bitrate_bps: u64) -> Result> { +/// Open an NVENC encoder for packed RGB/BGR CPU frames of the given `format` and mode. +/// `format`, `bitrate_bps`, `codec`, and the mode come from session negotiation; M0 takes +/// them from the first captured frame. +pub fn open_video( + codec: Codec, + format: PixelFormat, + width: u32, + height: u32, + fps: u32, + bitrate_bps: u64, +) -> Result> { #[cfg(target_os = "linux")] { - anyhow::bail!("VAAPI/NVENC encode not yet implemented (M0)") + let enc = linux::NvencEncoder::open(codec, format, width, height, fps, bitrate_bps)?; + Ok(Box::new(enc) as Box) } #[cfg(not(target_os = "linux"))] { - anyhow::bail!("encode requires Linux (VAAPI/NVENC via FFmpeg)") + let _ = (codec, format, width, height, fps, bitrate_bps); + anyhow::bail!("NVENC encode requires Linux (FFmpeg + NVIDIA driver)") } } + +#[cfg(target_os = "linux")] +mod linux; diff --git a/crates/lumen-host/src/encode/linux.rs b/crates/lumen-host/src/encode/linux.rs new file mode 100644 index 00000000..69fc77f9 --- /dev/null +++ b/crates/lumen-host/src/encode/linux.rs @@ -0,0 +1,184 @@ +//! NVENC encoder via `ffmpeg-next` (binds the system FFmpeg 7.x / libavcodec 61). +//! +//! Input is a packed RGB/BGR CPU frame; `*_nvenc` accepts `rgb0`/`bgr0`/`rgba`/`bgra` +//! directly and does the RGB→YUV conversion on the GPU, so the host stays off the +//! colour-conversion path. The portal commonly negotiates packed 24-bit `RGB`, which NVENC +//! does *not* accept — we expand it to `rgb0` (one padding byte/pixel, no colour math). +//! The encoder is opened *without* a global header so VPS/SPS/PPS are emitted in-band on +//! every IDR — the output is both a playable raw Annex-B stream and self-contained AUs. + +use super::{Codec, EncodedFrame, Encoder}; +use crate::capture::{CapturedFrame, PixelFormat}; +use anyhow::{anyhow, Context, Result}; +use ffmpeg::format::Pixel; +use ffmpeg::util::frame::Video as VideoFrame; +use ffmpeg::{codec, encoder, Dictionary, Packet, Rational}; +use ffmpeg_next as ffmpeg; + +/// Map a captured layout to the NVENC input pixel format, and whether a 3→4 byte expand is +/// needed (packed RGB/BGR have no padding byte; the NVENC `*0` formats do). +fn nvenc_input(format: PixelFormat) -> (Pixel, bool) { + match format { + PixelFormat::Bgrx => (Pixel::BGRZ, false), // bgr0 + PixelFormat::Rgbx => (Pixel::RGBZ, false), // rgb0 + PixelFormat::Bgra => (Pixel::BGRA, false), + PixelFormat::Rgba => (Pixel::RGBA, false), + PixelFormat::Rgb => (Pixel::RGBZ, true), // RGB -> rgb0 + PixelFormat::Bgr => (Pixel::BGRZ, true), // BGR -> bgr0 + } +} + +pub struct NvencEncoder { + enc: encoder::video::Encoder, + /// Reusable 4-bpp input frame in `nvenc_pixel` (its plane stride may exceed width*4). + /// Mutating it in place across frames is sound only because the encoder is opened with + /// `delay=0`/`bf=0`/`max_b_frames=0` and the caller drains `poll()` after each `submit`, + /// so libavcodec holds no reference to the previous frame's buffer when we overwrite it. + frame: VideoFrame, + src_format: PixelFormat, + expand: bool, + width: u32, + height: u32, + fps: u32, + /// Monotonic presentation index, in `1/fps` time-base units. + frame_idx: i64, +} + +impl NvencEncoder { + pub fn open( + codec: Codec, + format: PixelFormat, + width: u32, + height: u32, + fps: u32, + bitrate_bps: u64, + ) -> Result { + ffmpeg::init().context("ffmpeg init")?; + let name = codec.nvenc_name(); + let av_codec = encoder::find_by_name(name) + .ok_or_else(|| anyhow!("{name} not built into libavcodec"))?; + let (nvenc_pixel, expand) = nvenc_input(format); + + let mut video = codec::context::Context::new_with_codec(av_codec) + .encoder() + .video() + .context("alloc video encoder")?; + video.set_width(width); + video.set_height(height); + video.set_format(nvenc_pixel); // NVENC converts RGB→YUV internally + video.set_time_base(Rational(1, fps as i32)); + video.set_frame_rate(Some(Rational(fps as i32, 1))); + video.set_bit_rate(bitrate_bps as usize); + video.set_max_bit_rate(bitrate_bps as usize); + video.set_gop(fps.saturating_mul(2).max(1)); // ~2s keyframe interval + video.set_max_b_frames(0); + + // Low-latency NVENC tuning (plan §7 / linux-setup doc). + let mut opts = Dictionary::new(); + opts.set("preset", "p1"); // fastest + opts.set("tune", "ull"); // ultra-low-latency + opts.set("rc", "cbr"); + opts.set("bf", "0"); + opts.set("delay", "0"); + + let enc = video + .open_with(opts) + .with_context(|| format!("open {name} ({width}x{height}@{fps}, {bitrate_bps} bps)"))?; + + let frame = VideoFrame::new(nvenc_pixel, width, height); + Ok(NvencEncoder { + enc, + frame, + src_format: format, + expand, + width, + height, + fps, + frame_idx: 0, + }) + } +} + +impl Encoder for NvencEncoder { + fn submit(&mut self, captured: &CapturedFrame) -> Result<()> { + anyhow::ensure!( + captured.width == self.width && captured.height == self.height, + "captured frame {}x{} != encoder {}x{}", + captured.width, + captured.height, + self.width, + self.height + ); + anyhow::ensure!( + captured.format == self.src_format, + "captured format {:?} != encoder source {:?}", + captured.format, + self.src_format + ); + let w = self.width as usize; + let h = self.height as usize; + let src_bpp = self.src_format.bytes_per_pixel(); + let src_row = w * src_bpp; + anyhow::ensure!( + captured.cpu_bytes.len() >= src_row * h, + "captured buffer {} bytes < required {}", + captured.cpu_bytes.len(), + src_row * h + ); + + let stride = self.frame.stride(0); // dst is 4-bpp, aligned + let dst = self.frame.data_mut(0); + if self.expand { + // packed 3-bpp RGB/BGR → 4-bpp *0 (copy 3 bytes, zero the pad byte) + for y in 0..h { + let s = &captured.cpu_bytes[y * src_row..y * src_row + src_row]; + let drow = &mut dst[y * stride..y * stride + w * 4]; + for x in 0..w { + drow[x * 4..x * 4 + 3].copy_from_slice(&s[x * 3..x * 3 + 3]); + drow[x * 4 + 3] = 0; + } + } + } else { + // 4-bpp → 4-bpp, honoring the (possibly larger) dst stride + for y in 0..h { + dst[y * stride..y * stride + src_row] + .copy_from_slice(&captured.cpu_bytes[y * src_row..y * src_row + src_row]); + } + } + self.frame.set_pts(Some(self.frame_idx)); + self.frame_idx += 1; + self.enc.send_frame(&self.frame).context("send_frame")?; + Ok(()) + } + + fn poll(&mut self) -> Result> { + let mut pkt = Packet::empty(); + match self.enc.receive_packet(&mut pkt) { + Ok(()) => { + let data = pkt.data().map(|d| d.to_vec()).unwrap_or_default(); + let pts = pkt.pts().unwrap_or(0).max(0) as u64; + let pts_ns = pts * 1_000_000_000 / self.fps as u64; + Ok(Some(EncodedFrame { + data, + pts_ns, + keyframe: pkt.is_key(), + })) + } + // No packet ready yet (need another input frame). + Err(ffmpeg::Error::Other { errno }) + if errno == ffmpeg::util::error::EAGAIN + || errno == ffmpeg::util::error::EWOULDBLOCK => + { + Ok(None) + } + // Fully drained after flush(). + Err(ffmpeg::Error::Eof) => Ok(None), + Err(e) => Err(e).context("receive_packet"), + } + } + + fn flush(&mut self) -> Result<()> { + self.enc.send_eof().context("send_eof")?; + Ok(()) + } +} diff --git a/crates/lumen-host/src/gamestream/cert.rs b/crates/lumen-host/src/gamestream/cert.rs new file mode 100644 index 00000000..3ceb1ffb --- /dev/null +++ b/crates/lumen-host/src/gamestream/cert.rs @@ -0,0 +1,75 @@ +//! The host's self-signed RSA-2048 identity: the cert returned to clients as `plaincert` +//! during pairing AND presented as the TLS server cert on 47984 (Moonlight pins it). The +//! cert's own X.509 signature bytes are an input to the pairing hashes, so we extract them. + +use super::config_dir; +use anyhow::{anyhow, Context, Result}; +use rsa::pkcs1v15::SigningKey; +use rsa::pkcs8::DecodePrivateKey; +use rsa::RsaPrivateKey; +use sha2::Sha256; +use std::fs; + +pub struct ServerIdentity { + /// PEM of the cert (returned hex-encoded as `plaincert`; also the TLS server cert). + pub cert_pem: String, + /// PKCS#8 PEM of the private key (TLS server key). + pub key_pem: String, + /// The cert's X.509 `signatureValue` bytes — bound into the pairing challenge hashes. + pub signature: Vec, + /// RSA-PKCS1v15-SHA256 signer over the host key (the pairing `sign256`). + pub signing_key: SigningKey, +} + +impl ServerIdentity { + pub fn load_or_create() -> Result { + let dir = config_dir(); + let cert_path = dir.join("cert.pem"); + let key_path = dir.join("key.pem"); + let (cert_pem, key_pem) = match ( + fs::read_to_string(&cert_path), + fs::read_to_string(&key_path), + ) { + (Ok(c), Ok(k)) if !c.trim().is_empty() && !k.trim().is_empty() => (c, k), + _ => { + let (c, k) = generate()?; + fs::create_dir_all(&dir).ok(); + fs::write(&cert_path, &c) + .with_context(|| format!("write {}", cert_path.display()))?; + fs::write(&key_path, &k) + .with_context(|| format!("write {}", key_path.display()))?; + tracing::info!(path = %cert_path.display(), "generated lumen host certificate (RSA-2048)"); + (c, k) + } + }; + let priv_key = RsaPrivateKey::from_pkcs8_pem(&key_pem).context("parse host private key")?; + let signing_key = SigningKey::::new(priv_key); + let signature = cert_signature(&cert_pem)?; + Ok(ServerIdentity { + cert_pem, + key_pem, + signature, + signing_key, + }) + } +} + +fn generate() -> Result<(String, String)> { + let key = rcgen::KeyPair::generate_for(&rcgen::PKCS_RSA_SHA256).context("rcgen RSA keygen")?; + let mut params = rcgen::CertificateParams::new(Vec::::new()).context("cert params")?; + params + .distinguished_name + .push(rcgen::DnType::CommonName, "lumen"); + params.not_before = rcgen::date_time_ymd(2020, 1, 1); + params.not_after = rcgen::date_time_ymd(2040, 1, 1); + let cert = params.self_signed(&key).context("self-sign cert")?; + Ok((cert.pem(), key.serialize_pem())) +} + +/// Extract the X.509 `signatureValue` bytes from a cert PEM. +fn cert_signature(cert_pem: &str) -> Result> { + let (_, pem) = x509_parser::pem::parse_x509_pem(cert_pem.as_bytes()) + .map_err(|e| anyhow!("parse cert pem: {e}"))?; + let x509 = pem.parse_x509().context("parse x509")?; + Ok(x509.signature_value.data.to_vec()) +} diff --git a/crates/lumen-host/src/gamestream/crypto.rs b/crates/lumen-host/src/gamestream/crypto.rs new file mode 100644 index 00000000..23791f5c --- /dev/null +++ b/crates/lumen-host/src/gamestream/crypto.rs @@ -0,0 +1,60 @@ +//! Pairing crypto primitives (control plane only — distinct from `lumen_core`'s AES-GCM +//! data-plane sealing). GameStream pairing uses: AES-128-**ECB** with **no padding**, +//! SHA-256 (host appversion major ≥ 7), and RSA-PKCS1v15-SHA256 signatures. See the +//! `serverinfo + pairing` section of `docs/research/gamestream-protocol-research.json`. + +use aes::cipher::generic_array::GenericArray; +use aes::cipher::{BlockDecrypt, BlockEncrypt, KeyInit}; +use aes::Aes128; +use rand::RngCore; +use sha2::{Digest, Sha256}; + +/// `n` cryptographically-random bytes. +pub fn random() -> [u8; N] { + let mut b = [0u8; N]; + rand::thread_rng().fill_bytes(&mut b); + b +} + +/// SHA-256 over the concatenation of `parts`. +pub fn sha256(parts: &[&[u8]]) -> [u8; 32] { + let mut h = Sha256::new(); + for p in parts { + h.update(p); + } + h.finalize().into() +} + +/// The PIN-derived AES-128 key: `SHA-256(salt || pin)[..16]` (salt first, PIN as ASCII). +pub fn pin_key(salt: &[u8; 16], pin: &str) -> [u8; 16] { + let d = sha256(&[salt, pin.as_bytes()]); + let mut k = [0u8; 16]; + k.copy_from_slice(&d[..16]); + k +} + +/// AES-128-ECB encrypt, no padding: input is zero-extended to a 16-byte multiple. +pub fn ecb_encrypt(key: &[u8; 16], data: &[u8]) -> Vec { + let cipher = Aes128::new(GenericArray::from_slice(key)); + let mut out = data.to_vec(); + let rem = out.len() % 16; + if rem != 0 { + out.resize(out.len() + (16 - rem), 0); + } + for chunk in out.chunks_mut(16) { + cipher.encrypt_block(GenericArray::from_mut_slice(chunk)); + } + out +} + +/// AES-128-ECB decrypt, no padding: trailing bytes past the last whole block are ignored. +pub fn ecb_decrypt(key: &[u8; 16], data: &[u8]) -> Vec { + let cipher = Aes128::new(GenericArray::from_slice(key)); + let mut out = Vec::with_capacity(data.len()); + for chunk in data.chunks_exact(16) { + let mut block = *GenericArray::from_slice(chunk); + cipher.decrypt_block(&mut block); + out.extend_from_slice(&block); + } + out +} diff --git a/crates/lumen-host/src/gamestream/mdns.rs b/crates/lumen-host/src/gamestream/mdns.rs new file mode 100644 index 00000000..a361dd7c --- /dev/null +++ b/crates/lumen-host/src/gamestream/mdns.rs @@ -0,0 +1,37 @@ +//! mDNS advertisement of `_nvstream._tcp.local.` so Moonlight auto-discovers the host. +//! (Manual "add host by IP" also works as a fallback, which is what we test with first.) + +use super::Host; +use anyhow::{Context, Result}; +use mdns_sd::{ServiceDaemon, ServiceInfo}; +use std::collections::HashMap; + +/// Holds the mDNS daemon; dropping it unregisters the service. +pub struct Advert { + _daemon: ServiceDaemon, +} + +pub fn advertise(host: &Host) -> Result { + let daemon = ServiceDaemon::new().context("create mDNS daemon")?; + let host_name = format!("{}.local.", host.hostname); + // No TXT records are required for Moonlight discovery; it resolves the A record and then + // GETs /serverinfo for capabilities. + let props: HashMap = HashMap::new(); + let service = ServiceInfo::new( + "_nvstream._tcp.local.", + &host.hostname, + &host_name, + host.local_ip, + host.http_port, + props, + ) + .context("build mDNS ServiceInfo")?; + daemon.register(service).context("register mDNS service")?; + tracing::info!( + service = "_nvstream._tcp", + port = host.http_port, + host = %host_name, + "mDNS advertising" + ); + Ok(Advert { _daemon: daemon }) +} diff --git a/crates/lumen-host/src/gamestream/mod.rs b/crates/lumen-host/src/gamestream/mod.rs new file mode 100644 index 00000000..1fcbfcfd --- /dev/null +++ b/crates/lumen-host/src/gamestream/mod.rs @@ -0,0 +1,151 @@ +//! GameStream (P1) control plane — what a stock Moonlight/Artemis client talks to around +//! the media streams: mDNS discovery, the nvhttp serverinfo + pairing HTTP(S) API, RTSP, +//! and the ENet control stream. `tokio`/`axum` live here (control plane, I/O-bound — never +//! the per-frame hot path; that is `lumen_core`'s P1 wire codec). See `docs/m2-plan.md`. +//! +//! Status: P1.1 — mDNS `_nvstream._tcp` advertisement + `/serverinfo`. Pairing, RTSP, and +//! the media streams follow (see the M2 task list / plan). + +mod cert; +mod crypto; +mod mdns; +mod nvhttp; +mod pairing; +mod rtsp; +mod serverinfo; +mod tls; +mod video; + +use anyhow::{Context, Result}; +use std::net::{IpAddr, Ipv4Addr, UdpSocket}; +use std::path::PathBuf; +use std::sync::Arc; + +/// nvhttp ports (Moonlight derives all stream ports by offset from the HTTP base 47989). +pub const HTTP_PORT: u16 = 47989; +pub const HTTPS_PORT: u16 = 47984; +pub const RTSP_PORT: u16 = 48010; +pub const VIDEO_PORT: u16 = 47998; +pub const CONTROL_PORT: u16 = 47999; +pub const AUDIO_PORT: u16 = 48000; + +/// Advertised host version. Major ≥ 7 tells Moonlight to use SHA-256 for pairing. +pub const APP_VERSION: &str = "7.1.431.-1"; +pub const GFE_VERSION: &str = "3.23.0.74"; +/// Codec support bitmask: 3=H264, 259=+HEVC, 3843=+AV1 (we encode HEVC/H264/AV1 via NVENC). +pub const SERVER_CODEC_MODE_SUPPORT: u32 = 3843; + +/// Stable host identity + advertised capabilities, shared across control-plane handlers. +pub struct Host { + pub hostname: String, + /// Stable per-host id (persisted), echoed in serverinfo + matched on pairing. + pub uniqueid: String, + pub local_ip: IpAddr, + pub http_port: u16, + pub https_port: u16, + // Pairing state (server cert, paired client certs) lands in the next P1.1 slice. +} + +impl Host { + pub fn detect() -> Result { + Ok(Host { + hostname: hostname_string(), + uniqueid: load_or_create_uniqueid()?, + local_ip: primary_local_ip().unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST)), + http_port: HTTP_PORT, + https_port: HTTPS_PORT, + }) + } +} + +/// The stream parameters a client passes at `/launch`, shared with the RTSP + media stages. +#[derive(Clone, Copy, Debug)] +pub struct LaunchSession { + /// AES-128 key for the RTSP/control/video/audio planes (from `rikey`). + pub gcm_key: [u8; 16], + /// `rikeyid` — seeds the per-stream GCM IVs. + pub rikeyid: i32, + pub width: u32, + pub height: u32, + pub fps: u32, +} + +/// Shared control-plane state used as the axum app state. +pub struct AppState { + pub host: Host, + pub identity: cert::ServerIdentity, + pub pairing: pairing::Pairing, + /// Pinned (paired) client certificate DERs — the post-pair allow-list. + pub paired: std::sync::Mutex>>, + /// The active launch session (set by `/launch`, consumed by RTSP/media). + pub launch: std::sync::Mutex>, +} + +/// Run the GameStream control plane (blocks): mDNS advertisement + the nvhttp servers. +pub fn serve() -> Result<()> { + let host = Host::detect()?; + let identity = cert::ServerIdentity::load_or_create().context("host certificate")?; + let state = Arc::new(AppState { + host, + identity, + pairing: pairing::Pairing::new(), + paired: std::sync::Mutex::new(Vec::new()), + launch: std::sync::Mutex::new(None), + }); + tracing::info!( + hostname = %state.host.hostname, + uniqueid = %state.host.uniqueid, + ip = %state.host.local_ip, + "lumen GameStream host (P1.1: serverinfo + pairing + mDNS)" + ); + let rt = tokio::runtime::Runtime::new().context("build tokio runtime")?; + rt.block_on(async move { + // rustls needs a process-wide crypto provider before any TLS config is built. + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + let _advert = mdns::advertise(&state.host).context("mDNS advertise")?; + rtsp::spawn(state.clone()).context("start RTSP server")?; + nvhttp::run(state).await + }) +} + +/// `~/.config/lumen`, created on demand — host identity + (later) pairing state live here. +fn config_dir() -> PathBuf { + let base = std::env::var_os("XDG_CONFIG_HOME") + .map(PathBuf::from) + .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config"))) + .unwrap_or_else(|| PathBuf::from(".")); + base.join("lumen") +} + +fn hostname_string() -> String { + std::fs::read_to_string("/proc/sys/kernel/hostname") + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "lumen-host".to_string()) +} + +/// Load the persisted host uniqueid, or mint one (from the kernel UUID source) and store it. +fn load_or_create_uniqueid() -> Result { + let path = config_dir().join("uniqueid"); + if let Ok(s) = std::fs::read_to_string(&path) { + let t = s.trim(); + if !t.is_empty() { + return Ok(t.to_string()); + } + } + let id = std::fs::read_to_string("/proc/sys/kernel/random/uuid") + .map(|u| u.trim().replace('-', "")) + .unwrap_or_else(|_| format!("{:016x}{:016x}", std::process::id(), HTTP_PORT)); + std::fs::create_dir_all(config_dir()).ok(); + std::fs::write(&path, &id).with_context(|| format!("write {}", path.display()))?; + Ok(id) +} + +/// Best-effort primary LAN IP: open a UDP socket "toward" a public address and read the +/// local address the OS would route through. No packets are actually sent. +fn primary_local_ip() -> Option { + let sock = UdpSocket::bind("0.0.0.0:0").ok()?; + sock.connect("8.8.8.8:80").ok()?; + sock.local_addr().ok().map(|a| a.ip()) +} diff --git a/crates/lumen-host/src/gamestream/nvhttp.rs b/crates/lumen-host/src/gamestream/nvhttp.rs new file mode 100644 index 00000000..bca5afa7 --- /dev/null +++ b/crates/lumen-host/src/gamestream/nvhttp.rs @@ -0,0 +1,228 @@ +//! The nvhttp servers: plain HTTP on 47989 and mutual-TLS on 47984. Serves `/serverinfo`, +//! the `/pair` flow, `/applist`, and `/launch`/`/resume`/`/cancel`, plus a lumen-only +//! `/pin` endpoint to deliver the Moonlight-displayed PIN. Over HTTPS the client is +//! mutual-TLS-authenticated, so `/serverinfo` reports `PairStatus=1` there. + +use super::{serverinfo, AppState, LaunchSession, HTTPS_PORT, HTTP_PORT, RTSP_PORT}; +use anyhow::{anyhow, Context, Result}; +use axum::{ + extract::{Query, State}, + http::header, + response::IntoResponse, + routing::get, + Extension, Router, +}; +use std::collections::HashMap; +use std::net::SocketAddr; +use std::sync::Arc; + +/// Which listener a request arrived on — HTTPS means a mutual-TLS-authenticated client. +#[derive(Clone, Copy)] +struct Https(bool); + +pub async fn run(state: Arc) -> Result<()> { + // Mutual-TLS: request + verify the client cert (Moonlight presents one for the + // post-pairing pairchallenge + all post-pair endpoints). + let tls = axum_server::tls_rustls::RustlsConfig::from_config(super::tls::server_config( + &state.identity.cert_pem, + &state.identity.key_pem, + )?); + + let http_addr = SocketAddr::from(([0, 0, 0, 0], HTTP_PORT)); + let https_addr = SocketAddr::from(([0, 0, 0, 0], HTTPS_PORT)); + tracing::info!(%http_addr, %https_addr, "nvhttp listening (serverinfo + pair + launch)"); + + let http = axum_server::bind(http_addr).serve(router(state.clone(), false).into_make_service()); + let https = + axum_server::bind_rustls(https_addr, tls).serve(router(state, true).into_make_service()); + tokio::try_join!(async { http.await.context("nvhttp HTTP server") }, async { + https.await.context("nvhttp HTTPS server") + },)?; + Ok(()) +} + +fn router(state: Arc, https: bool) -> Router { + Router::new() + .route("/serverinfo", get(h_serverinfo)) + .route("/pair", get(h_pair)) + .route("/pin", get(h_pin)) + .route("/applist", get(h_applist)) + .route("/launch", get(h_launch)) + .route("/resume", get(h_resume)) + .route("/cancel", get(h_cancel)) + .layer(Extension(Https(https))) + .with_state(state) +} + +fn xml(body: String) -> impl IntoResponse { + ([(header::CONTENT_TYPE, "application/xml")], body) +} + +async fn h_serverinfo( + State(st): State>, + Extension(Https(https)): Extension, +) -> impl IntoResponse { + // Over the mutual-TLS port the peer is an authenticated (paired) client → PairStatus=1. + xml(serverinfo::serverinfo_xml(&st.host, https)) +} + +async fn h_pin( + State(st): State>, + Query(q): Query>, +) -> impl IntoResponse { + match q.get("pin").filter(|p| !p.is_empty()) { + Some(pin) => { + st.pairing.pin.submit(pin.clone()); + "PIN accepted\n".to_string() + } + None => "usage: GET /pin?pin=NNNN\n".to_string(), + } +} + +async fn h_applist(State(_st): State>) -> impl IntoResponse { + // One app for now: the headless desktop (the wlroots virtual output). + xml("\n\n\n0\nDesktop\n1\n\n\n".to_string()) +} + +async fn h_launch( + State(st): State>, + Query(q): Query>, +) -> impl IntoResponse { + match launch(&st, &q) { + Ok(session) => { + *st.launch.lock().unwrap() = Some(session); + tracing::info!( + w = session.width, + h = session.height, + fps = session.fps, + rikeyid = session.rikeyid, + "launch — session created; RTSP at rtsp://{}:{RTSP_PORT}", + st.host.local_ip + ); + xml(session_url_xml(&st, "gamesession")) + } + Err(e) => { + tracing::warn!(error = %format!("{e:#}"), "launch failed"); + xml(error_xml()) + } + } +} + +async fn h_resume(State(st): State>) -> impl IntoResponse { + if st.launch.lock().unwrap().is_some() { + xml(session_url_xml(&st, "resume")) + } else { + xml(error_xml()) + } +} + +async fn h_cancel(State(st): State>) -> impl IntoResponse { + *st.launch.lock().unwrap() = None; + tracing::info!("cancel — launch session cleared"); + xml("\n1\n".to_string()) +} + +/// Parse the `/launch` query (rikey/rikeyid/mode) into a [`LaunchSession`]. +fn launch(_st: &AppState, q: &HashMap) -> Result { + let rikey = q.get("rikey").ok_or_else(|| anyhow!("missing rikey"))?; + let key_bytes = hex::decode(rikey).context("rikey hex")?; + if key_bytes.len() < 16 { + return Err(anyhow!("rikey too short")); + } + let mut gcm_key = [0u8; 16]; + gcm_key.copy_from_slice(&key_bytes[..16]); + // rikeyid is a signed 32-bit int (negative values wrap to a big-endian u32 IV later). + let rikeyid: i32 = q.get("rikeyid").and_then(|s| s.parse().ok()).unwrap_or(0); + let (width, height, fps) = q + .get("mode") + .and_then(|m| parse_mode(m)) + .unwrap_or((1920, 1080, 60)); + Ok(LaunchSession { + gcm_key, + rikeyid, + width, + height, + fps, + }) +} + +/// `"1920x1080x60"` → `(1920, 1080, 60)`. +fn parse_mode(mode: &str) -> Option<(u32, u32, u32)> { + let mut it = mode.split('x'); + let w = it.next()?.parse().ok()?; + let h = it.next()?.parse().ok()?; + let fps = it.next()?.parse().ok()?; + Some((w, h, fps)) +} + +fn session_url_xml(st: &AppState, tag: &str) -> String { + format!( + "\n\nrtsp://{}:{RTSP_PORT}\n<{tag}>1\n\n", + st.host.local_ip + ) +} + +async fn h_pair( + State(st): State>, + Query(q): Query>, +) -> impl IntoResponse { + let uniqueid = q.get("uniqueid").cloned().unwrap_or_default(); + let phrase = q.get("phrase").map(String::as_str); + + let step = phrase + .filter(|p| *p == "getservercert" || *p == "pairchallenge") + .or_else(|| { + [ + "clientchallenge", + "serverchallengeresp", + "clientpairingsecret", + ] + .into_iter() + .find(|k| q.contains_key(*k)) + }) + .unwrap_or("?"); + tracing::info!(uniqueid, step, "pair request"); + + let result = if phrase == Some("getservercert") { + match (q.get("salt"), q.get("clientcert")) { + (Some(salt), Some(cc)) => { + st.pairing + .getservercert(&st.identity, &uniqueid, salt, cc) + .await + } + _ => Ok(pair_error_xml()), + } + } else if phrase == Some("pairchallenge") { + // Reached only over the TLS port with the pinned host cert; the handshake is the + // proof, so acknowledge success. + Ok(paired_ok_xml()) + } else if let Some(v) = q.get("clientchallenge") { + st.pairing.clientchallenge(&st.identity, &uniqueid, v) + } else if let Some(v) = q.get("serverchallengeresp") { + st.pairing.serverchallengeresp(&st.identity, &uniqueid, v) + } else if let Some(v) = q.get("clientpairingsecret") { + st.pairing.clientpairingsecret(&uniqueid, v, &st.paired) + } else { + Ok(pair_error_xml()) + }; + + let body = result.unwrap_or_else(|e| { + tracing::warn!(error = %format!("{e:#}"), uniqueid, "pair handler error"); + pair_error_xml() + }); + xml(body) +} + +fn paired_ok_xml() -> String { + "\n1\n" + .to_string() +} + +fn pair_error_xml() -> String { + "\n0\n" + .to_string() +} + +fn error_xml() -> String { + "\n\n".to_string() +} diff --git a/crates/lumen-host/src/gamestream/pairing.rs b/crates/lumen-host/src/gamestream/pairing.rs new file mode 100644 index 00000000..304f2b8e --- /dev/null +++ b/crates/lumen-host/src/gamestream/pairing.rs @@ -0,0 +1,251 @@ +//! The 4-phase GameStream pairing state machine (over HTTP), keyed by `uniqueid`. Proves +//! both sides know the PIN (via the SHA-256(salt||pin) AES-ECB key) and own their certs +//! (RSA signatures), then pins the client cert. The final `pairchallenge` happens over +//! HTTPS (handled in `nvhttp`). Byte-exact spec: `docs/research/…-research.json`. + +use super::cert::ServerIdentity; +use super::crypto; +use anyhow::{anyhow, bail, Context, Result}; +use rsa::pkcs1v15::{Signature, VerifyingKey}; +use rsa::pkcs8::DecodePublicKey; +use rsa::signature::{SignatureEncoding, Signer, Verifier}; +use rsa::RsaPublicKey; +use sha2::Sha256; +use std::collections::HashMap; +use std::sync::Mutex; +use std::time::Duration; +use tokio::sync::Notify; + +/// Out-of-band PIN delivery. Moonlight generates + displays a PIN; the user submits it +/// (here via `GET /pin?pin=NNNN`). `getservercert` parks until a PIN arrives. +pub struct PinGate { + pin: Mutex>, + notify: Notify, +} + +impl PinGate { + fn new() -> Self { + PinGate { + pin: Mutex::new(None), + notify: Notify::new(), + } + } + + pub fn submit(&self, pin: String) { + *self.pin.lock().unwrap() = Some(pin); + self.notify.notify_waiters(); + } + + async fn take(&self, timeout: Duration) -> Option { + let deadline = tokio::time::Instant::now() + timeout; + loop { + if let Some(p) = self.pin.lock().unwrap().take() { + return Some(p); + } + if tokio::time::timeout_at(deadline, self.notify.notified()) + .await + .is_err() + { + return None; + } + } + } +} + +/// Per-client pairing session carried across the 4 separate HTTP GETs. +struct Session { + aes_key: [u8; 16], + client_cert_der: Vec, + client_cert_sig: Vec, + client_pubkey: RsaPublicKey, + serversecret: [u8; 16], + server_challenge: [u8; 16], + /// The client's phase-3 hash, recomputed + checked in phase 4. + client_hash: Vec, +} + +pub struct Pairing { + sessions: Mutex>, + pub pin: PinGate, +} + +impl Pairing { + pub fn new() -> Self { + Pairing { + sessions: Mutex::new(HashMap::new()), + pin: PinGate::new(), + } + } + + /// Phase 1: store the client cert, await the PIN, derive the AES key, return our cert. + pub async fn getservercert( + &self, + id: &ServerIdentity, + uniqueid: &str, + salt_hex: &str, + clientcert_hex: &str, + ) -> Result { + let salt_bytes = hex::decode(salt_hex).context("salt hex")?; + if salt_bytes.len() < 16 { + bail!("salt too short"); + } + let mut salt = [0u8; 16]; + salt.copy_from_slice(&salt_bytes[..16]); + let pem_bytes = hex::decode(clientcert_hex).context("clientcert hex")?; + let (der, sig, pubkey) = parse_client_cert(&pem_bytes)?; + + tracing::info!( + uniqueid, + "pairing phase 1 (getservercert) — awaiting PIN: submit `GET /pin?pin=NNNN`" + ); + let pin = self + .pin + .take(Duration::from_secs(300)) + .await + .ok_or_else(|| anyhow!("no PIN submitted within 300s"))?; + let aes_key = crypto::pin_key(&salt, &pin); + + self.sessions.lock().unwrap().insert( + uniqueid.to_string(), + Session { + aes_key, + client_cert_der: der, + client_cert_sig: sig, + client_pubkey: pubkey, + serversecret: [0; 16], + server_challenge: [0; 16], + client_hash: Vec::new(), + }, + ); + tracing::info!( + uniqueid, + "pairing phase 1 — PIN accepted, returning host cert" + ); + let inner = format!( + "{}", + hex::encode(id.cert_pem.as_bytes()) + ); + Ok(paired_xml(&inner, true)) + } + + /// Phase 2: decrypt the client challenge, return our hash + server challenge. + pub fn clientchallenge( + &self, + id: &ServerIdentity, + uniqueid: &str, + hexv: &str, + ) -> Result { + let mut map = self.sessions.lock().unwrap(); + let s = map + .get_mut(uniqueid) + .ok_or_else(|| anyhow!("no pairing session"))?; + let enc = hex::decode(hexv).context("clientchallenge hex")?; + let client_challenge = crypto::ecb_decrypt(&s.aes_key, &enc); + if client_challenge.len() < 16 { + bail!("short client challenge"); + } + s.serversecret = crypto::random(); + s.server_challenge = crypto::random(); + let server_hash = + crypto::sha256(&[&client_challenge[..16], &id.signature, &s.serversecret]); + let mut plain = Vec::with_capacity(48); + plain.extend_from_slice(&server_hash); + plain.extend_from_slice(&s.server_challenge); + let resp = crypto::ecb_encrypt(&s.aes_key, &plain); + let inner = format!( + "{}", + hex::encode(resp) + ); + Ok(paired_xml(&inner, true)) + } + + /// Phase 3: store the client's hash, return our RSA-signed serversecret. + pub fn serverchallengeresp( + &self, + id: &ServerIdentity, + uniqueid: &str, + hexv: &str, + ) -> Result { + let mut map = self.sessions.lock().unwrap(); + let s = map + .get_mut(uniqueid) + .ok_or_else(|| anyhow!("no pairing session"))?; + let enc = hex::decode(hexv).context("serverchallengeresp hex")?; + let client_hash = crypto::ecb_decrypt(&s.aes_key, &enc); + if client_hash.len() < 32 { + bail!("short challenge response"); + } + s.client_hash = client_hash[..32].to_vec(); + let sig: Signature = id.signing_key.sign(&s.serversecret); + let mut secret = Vec::with_capacity(16 + 256); + secret.extend_from_slice(&s.serversecret); + secret.extend_from_slice(&sig.to_vec()); + let inner = format!("{}", hex::encode(secret)); + Ok(paired_xml(&inner, true)) + } + + /// Phase 4: verify the client knew the PIN (hash match) and owns its cert (RSA verify); + /// on success, pin the client cert. + pub fn clientpairingsecret( + &self, + uniqueid: &str, + hexv: &str, + paired_store: &Mutex>>, + ) -> Result { + let mut map = self.sessions.lock().unwrap(); + let s = map + .get_mut(uniqueid) + .ok_or_else(|| anyhow!("no pairing session"))?; + let data = hex::decode(hexv).context("clientpairingsecret hex")?; + if data.len() < 16 { + bail!("short pairing secret"); + } + let client_secret = &data[..16]; + let client_sig = &data[16..]; + let expected = crypto::sha256(&[&s.server_challenge, &s.client_cert_sig, client_secret]); + let hash_ok = expected[..] == s.client_hash[..]; + let sig_ok = verify256(&s.client_pubkey, client_secret, client_sig).is_ok(); + if hash_ok && sig_ok { + paired_store.lock().unwrap().push(s.client_cert_der.clone()); + tracing::info!(uniqueid, "pairing phase 4 — SUCCESS, client cert pinned"); + Ok(paired_xml("", true)) + } else { + tracing::warn!( + uniqueid, + hash_ok, + sig_ok, + "pairing phase 4 — FAILED (PIN/cert)" + ); + map.remove(uniqueid); + Ok(paired_xml("", false)) + } + } +} + +fn verify256(pubkey: &RsaPublicKey, msg: &[u8], sig: &[u8]) -> Result<()> { + let vk = VerifyingKey::::new(pubkey.clone()); + let signature = Signature::try_from(sig).context("parse client signature")?; + vk.verify(msg, &signature) + .context("verify client signature")?; + Ok(()) +} + +fn parse_client_cert(pem_bytes: &[u8]) -> Result<(Vec, Vec, RsaPublicKey)> { + let (_, pem) = + x509_parser::pem::parse_x509_pem(pem_bytes).map_err(|e| anyhow!("client cert pem: {e}"))?; + let der = pem.contents.clone(); + let x509 = pem.parse_x509().context("parse client x509")?; + let sig = x509.signature_value.data.to_vec(); + let pubkey = + RsaPublicKey::from_public_key_der(x509.public_key().raw).context("client rsa pubkey")?; + Ok((der, sig, pubkey)) +} + +/// `0|1 inner `. +fn paired_xml(inner: &str, paired: bool) -> String { + format!( + "\n\n{}\n{}\n", + u8::from(paired), + inner + ) +} diff --git a/crates/lumen-host/src/gamestream/rtsp.rs b/crates/lumen-host/src/gamestream/rtsp.rs new file mode 100644 index 00000000..10d69617 --- /dev/null +++ b/crates/lumen-host/src/gamestream/rtsp.rs @@ -0,0 +1,266 @@ +//! The GameStream RTSP handshake (TCP 48010). Hand-rolled because GameStream's RTSP is +//! non-standard (streamid= targets, the literal `DEADBEEFCAFE` session, the X-SS-* headers) +//! and off-the-shelf RTSP crates assume standard semantics. Sequence Moonlight drives: +//! OPTIONS → DESCRIBE → SETUP(audio/video/control) → ANNOUNCE → PLAY. ANNOUNCE carries the +//! negotiated stream config; PLAY is where the media stages start (P1.3+). +//! +//! Runs on its own native thread (control-plane setup, not the per-frame hot path), one +//! thread per connection. Plaintext only for now (encryption is negotiated; P1.5). + +use super::{AppState, AUDIO_PORT, CONTROL_PORT, RTSP_PORT, VIDEO_PORT}; +use anyhow::{Context, Result}; +use std::collections::HashMap; +use std::io::{Read, Write}; +use std::net::{TcpListener, TcpStream}; +use std::sync::Arc; + +/// Opaque per-session payload the client echoes as its first UDP datagram (port-learning). +const PING_PAYLOAD: &str = "0011223344556677"; + +/// Bind 48010 and accept RTSP connections on a dedicated thread. +pub fn spawn(state: Arc) -> Result<()> { + let listener = TcpListener::bind(("0.0.0.0", RTSP_PORT)) + .with_context(|| format!("bind RTSP {RTSP_PORT}"))?; + tracing::info!(port = RTSP_PORT, "RTSP listening"); + std::thread::Builder::new() + .name("lumen-rtsp".into()) + .spawn(move || { + for conn in listener.incoming() { + match conn { + Ok(stream) => { + let st = state.clone(); + std::thread::spawn(move || { + if let Err(e) = handle_conn(stream, st) { + tracing::warn!(error = %format!("{e:#}"), "RTSP connection ended"); + } + }); + } + Err(e) => tracing::warn!(error = %e, "RTSP accept failed"), + } + } + }) + .context("spawn RTSP thread")?; + Ok(()) +} + +struct Request { + method: String, + uri: String, + cseq: String, + head: String, + body: String, +} + +fn handle_conn(mut stream: TcpStream, state: Arc) -> Result<()> { + let peer = stream.peer_addr().ok(); + let mut buf: Vec = Vec::new(); + // GameStream RTSP is one request per TCP connection: moonlight-common-c reads the + // response until EOF, so we answer one message and close the connection (which signals + // the end of the response). Session state lives in `AppState`, not the connection. + if let Some(req) = read_message(&mut stream, &mut buf)? { + tracing::info!( + method = %req.method, cseq = %req.cseq, + "RTSP {} | {}", req.head.replace("\r\n", " | "), + if req.body.is_empty() { String::new() } else { format!("body: {}", req.body.replace("\r\n", " | ")) } + ); + let resp = handle_request(&req, &state); + stream.write_all(resp.as_bytes()).context("RTSP write")?; + stream.flush().ok(); + // Close (FIN after the flushed response) so the client detects end-of-response. + let _ = stream.shutdown(std::net::Shutdown::Both); + } + let _ = peer; + Ok(()) +} + +/// Read one complete RTSP message (headers + any Content-Length body) from the stream, +/// buffering across reads and leaving any pipelined remainder in `buf`. +fn read_message(stream: &mut TcpStream, buf: &mut Vec) -> Result> { + loop { + if let Some(end) = find_subslice(buf, b"\r\n\r\n") { + let head = std::str::from_utf8(&buf[..end]).context("RTSP header utf8")?; + let content_len = header_value(head, "content-length") + .and_then(|v| v.trim().parse::().ok()) + .unwrap_or(0); + let total = end + 4 + content_len; + if buf.len() < total { + // headers complete but body still arriving — read more + } else { + let head = head.to_string(); + let body = String::from_utf8_lossy(&buf[end + 4..total]).into_owned(); + buf.drain(..total); + return Ok(Some(parse_request(&head, body))); + } + } + let mut tmp = [0u8; 8192]; + let n = stream.read(&mut tmp).context("RTSP read")?; + if n == 0 { + return Ok(None); // peer closed + } + buf.extend_from_slice(&tmp[..n]); + } +} + +fn parse_request(head: &str, body: String) -> Request { + let mut lines = head.split("\r\n"); + let request_line = lines.next().unwrap_or(""); + let mut parts = request_line.split_whitespace(); + let method = parts.next().unwrap_or("").to_string(); + let uri = parts.next().unwrap_or("").to_string(); + let cseq = header_value(head, "cseq").unwrap_or("0").trim().to_string(); + Request { + method, + uri, + cseq, + head: head.to_string(), + body, + } +} + +fn handle_request(req: &Request, state: &AppState) -> String { + match req.method.as_str() { + "OPTIONS" => response( + &req.cseq, + &[("Public", "OPTIONS DESCRIBE SETUP ANNOUNCE PLAY TEARDOWN")], + None, + ), + "DESCRIBE" => response( + &req.cseq, + &[("Content-Type", "application/sdp")], + Some(&describe_sdp()), + ), + "SETUP" => { + let (port, extra_key) = match stream_type(&req.uri) { + Some("audio") => (AUDIO_PORT, "X-SS-Ping-Payload"), + Some("video") => (VIDEO_PORT, "X-SS-Ping-Payload"), + Some("control") => (CONTROL_PORT, "X-SS-Connect-Data"), + _ => return response_status("404 Not Found", &req.cseq, &[], None), + }; + let transport = format!("server_port={port}"); + response( + &req.cseq, + &[ + ("Session", "DEADBEEFCAFE;timeout = 90"), + ("Transport", &transport), + (extra_key, PING_PAYLOAD), + ], + None, + ) + } + "ANNOUNCE" => { + let cfg = parse_announce(&req.body); + tracing::info!( + width = cfg + .get("x-nv-video[0].clientViewportWd") + .map(String::as_str) + .unwrap_or("?"), + height = cfg + .get("x-nv-video[0].clientViewportHt") + .map(String::as_str) + .unwrap_or("?"), + fps = cfg + .get("x-nv-video[0].maxFPS") + .map(String::as_str) + .unwrap_or("?"), + bitrate_kbps = cfg + .get("x-nv-vqos[0].bw.maximumBitrateKbps") + .map(String::as_str) + .unwrap_or("?"), + packet_size = cfg + .get("x-nv-video[0].packetSize") + .map(String::as_str) + .unwrap_or("?"), + codec = cfg + .get("x-nv-vqos[0].bitStreamFormat") + .map(String::as_str) + .unwrap_or("?"), + fec_pct = cfg + .get("x-nv-vqos[0].fec.repairPercent") + .map(String::as_str) + .unwrap_or("?"), + "RTSP ANNOUNCE — negotiated stream config" + ); + // TODO(P1.3): map `cfg` → lumen_core::Config and stash it for the media stages. + let _ = state; + response(&req.cseq, &[], None) + } + "PLAY" => { + tracing::info!("RTSP PLAY — client ready; media streams would start here (P1.3)"); + response(&req.cseq, &[("Session", "DEADBEEFCAFE;timeout = 90")], None) + } + "TEARDOWN" => response(&req.cseq, &[], None), + other => { + tracing::warn!(method = other, "RTSP unsupported method"); + response_status("501 Not Implemented", &req.cseq, &[], None) + } + } +} + +/// Host capability SDP returned by DESCRIBE. Advertises HEVC + AV1 and no encryption +/// (plaintext streams for now; P1.5 adds the negotiated AES paths). +fn describe_sdp() -> String { + // Line-oriented a=key:value, matching what moonlight-common-c scans for. + [ + "a=x-ss-general.featureFlags:0", + "a=x-ss-general.encryptionSupported:0", + "a=x-ss-general.encryptionRequested:0", + "sprop-parameter-sets=AAAAAU", // HEVC capability indicator + "a=rtpmap:98 AV1/90000", // AV1 capability indicator + "", + ] + .join("\r\n") +} + +/// Parse an ANNOUNCE SDP body's `a=key:value` lines into a map. +fn parse_announce(body: &str) -> HashMap { + let mut map = HashMap::new(); + for line in body.lines() { + if let Some(rest) = line.strip_prefix("a=") { + if let Some((k, v)) = rest.split_once(':') { + map.insert(k.to_string(), v.to_string()); + } + } + } + map +} + +/// Extract the stream type from a SETUP URI like `…/streamid=video/0/0`. +fn stream_type(uri: &str) -> Option<&str> { + let after = uri.split("streamid=").nth(1)?; + let token = after.split('/').next()?; + match token { + "audio" | "video" | "control" => Some(token), + _ => None, + } +} + +fn response(cseq: &str, headers: &[(&str, &str)], body: Option<&str>) -> String { + response_status("200 OK", cseq, headers, body) +} + +fn response_status( + status: &str, + cseq: &str, + headers: &[(&str, &str)], + body: Option<&str>, +) -> String { + let body = body.unwrap_or(""); + let mut out = format!("RTSP/1.0 {status}\r\nCSeq: {cseq}\r\n"); + for (k, v) in headers { + out.push_str(&format!("{k}: {v}\r\n")); + } + out.push_str(&format!("Content-Length: {}\r\n\r\n", body.len())); + out.push_str(body); + out +} + +fn find_subslice(hay: &[u8], needle: &[u8]) -> Option { + hay.windows(needle.len()).position(|w| w == needle) +} + +fn header_value<'a>(head: &'a str, key_lower: &str) -> Option<&'a str> { + head.split("\r\n").find_map(|line| { + let (k, v) = line.split_once(':')?; + (k.trim().eq_ignore_ascii_case(key_lower)).then(|| v.trim_start()) + }) +} diff --git a/crates/lumen-host/src/gamestream/serverinfo.rs b/crates/lumen-host/src/gamestream/serverinfo.rs new file mode 100644 index 00000000..eda813e4 --- /dev/null +++ b/crates/lumen-host/src/gamestream/serverinfo.rs @@ -0,0 +1,42 @@ +//! The `/serverinfo` capability/status XML Moonlight GETs before pairing and each launch. + +use super::{Host, APP_VERSION, GFE_VERSION, SERVER_CODEC_MODE_SUPPORT}; + +/// Build the `` serverinfo document. `https` selects the +/// paired-HTTPS variant (real MAC). Element names are case-sensitive and match what +/// moonlight-common-c parses. +pub fn serverinfo_xml(host: &Host, https: bool) -> String { + // MAC is hidden over plain HTTP; PairStatus reflects the pairing store once the HTTPS + // path carries per-client identity (a hardening follow-up — 0 for now). + let mac = if https { + "01:02:03:04:05:06" + } else { + "00:00:00:00:00:00" + }; + // Over the mutual-TLS HTTPS port the peer is an authenticated (paired) client. + let pair_status = u8::from(https); + format!( + r#" + +{hostname} +{APP_VERSION} +{GFE_VERSION} +{uniqueid} +{https_port} +{http_port} +1869449984 +{mac} +{local_ip} +{SERVER_CODEC_MODE_SUPPORT} +{pair_status} +0 +SUNSHINE_SERVER_FREE + +"#, + hostname = host.hostname, + uniqueid = host.uniqueid, + https_port = host.https_port, + http_port = host.http_port, + local_ip = host.local_ip, + ) +} diff --git a/crates/lumen-host/src/gamestream/tls.rs b/crates/lumen-host/src/gamestream/tls.rs new file mode 100644 index 00000000..13d151da --- /dev/null +++ b/crates/lumen-host/src/gamestream/tls.rs @@ -0,0 +1,99 @@ +//! TLS for the HTTPS nvhttp port (47984). Moonlight does **mutual TLS** — it presents its +//! client cert and expects the server to request one — so a plain server-auth config makes +//! the post-pairing `pairchallenge` fail. This config requests the client cert and verifies +//! the client owns its key, but (for now) accepts any well-formed cert; enforcing the +//! paired allow-list (rejecting unpaired clients on /launch) is a follow-up hardening step. + +use anyhow::{anyhow, Context, Result}; +use rustls::client::danger::HandshakeSignatureValid; +use rustls::crypto::{verify_tls12_signature, verify_tls13_signature, CryptoProvider}; +use rustls::pki_types::{CertificateDer, UnixTime}; +use rustls::server::danger::{ClientCertVerified, ClientCertVerifier}; +use rustls::{DigitallySignedStruct, DistinguishedName, ServerConfig, SignatureScheme}; +use std::sync::Arc; + +/// Requests + signature-checks the client cert but accepts any (the pairing handshake is +/// the real proof). Pinning to the paired set is a hardening follow-up. +#[derive(Debug)] +struct AcceptAnyClientCert { + provider: Arc, +} + +impl ClientCertVerifier for AcceptAnyClientCert { + fn offer_client_auth(&self) -> bool { + true + } + + fn client_auth_mandatory(&self) -> bool { + true + } + + fn root_hint_subjects(&self) -> &[DistinguishedName] { + &[] + } + + fn verify_client_cert( + &self, + _end_entity: &CertificateDer, + _intermediates: &[CertificateDer], + _now: UnixTime, + ) -> Result { + Ok(ClientCertVerified::assertion()) + } + + fn verify_tls12_signature( + &self, + message: &[u8], + cert: &CertificateDer, + dss: &DigitallySignedStruct, + ) -> Result { + verify_tls12_signature( + message, + cert, + dss, + &self.provider.signature_verification_algorithms, + ) + } + + fn verify_tls13_signature( + &self, + message: &[u8], + cert: &CertificateDer, + dss: &DigitallySignedStruct, + ) -> Result { + verify_tls13_signature( + message, + cert, + dss, + &self.provider.signature_verification_algorithms, + ) + } + + fn supported_verify_schemes(&self) -> Vec { + self.provider + .signature_verification_algorithms + .supported_schemes() + } +} + +/// Build a mutual-TLS `ServerConfig` presenting the host cert/key. +pub fn server_config(cert_pem: &str, key_pem: &str) -> Result> { + let provider = Arc::new(rustls::crypto::aws_lc_rs::default_provider()); + let certs = rustls_pemfile::certs(&mut cert_pem.as_bytes()) + .collect::, _>>() + .context("parse host cert PEM")?; + let key = rustls_pemfile::private_key(&mut key_pem.as_bytes()) + .context("parse host key PEM")? + .ok_or_else(|| anyhow!("no private key in host key PEM"))?; + + let verifier = Arc::new(AcceptAnyClientCert { + provider: provider.clone(), + }); + let config = ServerConfig::builder_with_provider(provider) + .with_safe_default_protocol_versions() + .context("rustls protocol versions")? + .with_client_cert_verifier(verifier) + .with_single_cert(certs, key) + .context("rustls server cert")?; + Ok(Arc::new(config)) +} diff --git a/crates/lumen-host/src/gamestream/video.rs b/crates/lumen-host/src/gamestream/video.rs new file mode 100644 index 00000000..f43dc829 --- /dev/null +++ b/crates/lumen-host/src/gamestream/video.rs @@ -0,0 +1,216 @@ +//! GameStream video wire packetization: an encoded access unit → UDP datagrams a stock +//! Moonlight client decodes. Each datagram is +//! `RTP_PACKET(12, big-endian) + reserved[4] + NV_VIDEO_PACKET(16, little-endian) + payload` +//! and the frame's bitstream is prefixed with an 8-byte `video_short_frame_header_t`, then +//! striped into ≤4 FEC blocks of ≤255 data shards. Byte-exact spec: +//! `docs/research/gamestream-protocol-research.json` (video plane). +//! +//! P1.3 sends **data shards only** (`fecPercentage = 0`): on a clean LAN the client has +//! every data shard and never runs Reed–Solomon recovery, so we get a decodable frame +//! without matching Moonlight's `nanors` parity matrix (that interop work is P1.5). Plaintext +//! only (encryption negotiated off for now). This lives in lumen-host for fast iteration; +//! the wire codec moves into lumen-core (the P1 wire mode) once proven. + +/// RTP `header` byte: version 2 (0x80) | extension (0x10) — Moonlight keys on the extension. +const RTP_HEADER_BYTE: u8 = 0x80 | 0x10; +const FLAG_PIC: u8 = 0x1; +const FLAG_EOF: u8 = 0x2; +const FLAG_SOF: u8 = 0x4; +const MULTI_FEC_FLAGS: u8 = 0x10; +const MAX_DATA_SHARDS_PER_BLOCK: usize = 255; +const MAX_FEC_BLOCKS: usize = 4; +/// Per-shard header: RTP(12) + reserved(4) + NV_VIDEO_PACKET(16). +const SHARD_HEADER: usize = 32; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum FrameType { + Idr, + P, +} + +/// Splits encoded access units into GameStream video datagrams. +pub struct VideoPacketizer { + /// Negotiated `packetSize` (ANNOUNCE `x-nv-video[0].packetSize`). + packet_size: usize, + /// Per-shard payload bytes = `blocksize - SHARD_HEADER`, `blocksize = packetSize + 16`. + payload_per_shard: usize, + frame_index: u32, + /// Monotonic per-stream packet counter (the RTP sequence / streamPacketIndex source). + seq: u32, +} + +impl VideoPacketizer { + pub fn new(packet_size: usize) -> Self { + VideoPacketizer { + packet_size, + payload_per_shard: packet_size + 16 - SHARD_HEADER, + frame_index: 0, + seq: 0, + } + } + + /// Packetize one encoded AU into wire datagrams (ready for UDP send). + pub fn packetize( + &mut self, + au: &[u8], + frame_type: FrameType, + timestamp_90k: u32, + ) -> Vec> { + let frame_index = self.frame_index; + self.frame_index = self.frame_index.wrapping_add(1); + let pps = self.payload_per_shard; + + // frame payload = 8-byte short frame header + the AU bitstream. + let total_len = 8 + au.len(); + let last_payload_len = match total_len % pps { + 0 => pps, + r => r, + }; + let mut fp = Vec::with_capacity(total_len); + fp.extend_from_slice(&short_frame_header(frame_type, last_payload_len as u16)); + fp.extend_from_slice(au); + + let total_data = total_len.div_ceil(pps).max(1); + let n_blocks = total_data + .div_ceil(MAX_DATA_SHARDS_PER_BLOCK) + .clamp(1, MAX_FEC_BLOCKS); + let per_block = total_data.div_ceil(n_blocks); + + let mut packets = Vec::with_capacity(total_data); + for b in 0..n_blocks { + let first = b * per_block; + let last = ((b + 1) * per_block).min(total_data); + if first >= last { + break; + } + let block_data_count = last - first; + for (fec_index, shard) in (first..last).enumerate() { + let start = shard * pps; + let end = (start + pps).min(fp.len()); + let mut payload = vec![0u8; pps]; // last shard zero-padded + payload[..end - start].copy_from_slice(&fp[start..end]); + + let mut flags = FLAG_PIC; + if shard == 0 { + flags |= FLAG_SOF; + } + if shard == total_data - 1 { + flags |= FLAG_EOF; + } + let multi_fec_blocks = ((b as u8) << 4) | (((n_blocks - 1) as u8) << 6); + // fecInfo: dataShards<<22 | fecIndex<<12 | fecPercentage<<4 (pct = 0). + let fec_info: u32 = ((block_data_count as u32) << 22) | ((fec_index as u32) << 12); + let seq = self.seq; + self.seq = self.seq.wrapping_add(1); + + packets.push(build_packet( + seq, + timestamp_90k, + frame_index, + flags, + multi_fec_blocks, + fec_info, + &payload, + )); + } + } + packets + } +} + +/// 8-byte `video_short_frame_header_t` (little-endian), prefixed to the AU bitstream. +fn short_frame_header(frame_type: FrameType, last_payload_len: u16) -> [u8; 8] { + let mut h = [0u8; 8]; + h[0] = 0x01; // headerType + h[1..3].copy_from_slice(&0u16.to_le_bytes()); // frame_processing_latency + h[3] = match frame_type { + FrameType::Idr => 2, + FrameType::P => 1, + }; + h[4..6].copy_from_slice(&last_payload_len.to_le_bytes()); + // h[6..8] unknown = 0 + h +} + +/// Build one wire datagram: RTP(BE) + reserved + NV_VIDEO_PACKET(LE) + payload. +fn build_packet( + seq: u32, + timestamp_90k: u32, + frame_index: u32, + flags: u8, + multi_fec_blocks: u8, + fec_info: u32, + payload: &[u8], +) -> Vec { + let mut p = Vec::with_capacity(SHARD_HEADER + payload.len()); + // --- RTP_PACKET (12 bytes, big-endian) --- + p.push(RTP_HEADER_BYTE); // header + p.push(0); // packetType (unused for video) + p.extend_from_slice(&(seq as u16).to_be_bytes()); // sequenceNumber + p.extend_from_slice(×tamp_90k.to_be_bytes()); // timestamp (90 kHz) + p.extend_from_slice(&0u32.to_be_bytes()); // ssrc + // --- reserved[4] --- + p.extend_from_slice(&[0u8; 4]); + // --- NV_VIDEO_PACKET (16 bytes, little-endian) --- + p.extend_from_slice(&(seq << 8).to_le_bytes()); // streamPacketIndex (low byte 0) + p.extend_from_slice(&frame_index.to_le_bytes()); // frameIndex + p.push(flags); + p.push(0); // extraFlags + p.push(MULTI_FEC_FLAGS); + p.push(multi_fec_blocks); + p.extend_from_slice(&fec_info.to_le_bytes()); // fecInfo + // --- payload --- + p.extend_from_slice(payload); + p +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn single_block_layout() { + let mut pk = VideoPacketizer::new(1392); // payload_per_shard = 1392+16-32 = 1376 + assert_eq!(pk.payload_per_shard, 1376); + let au = vec![0xABu8; 4000]; // 8+4000 = 4008 → ceil(4008/1376) = 3 data shards + let pkts = pk.packetize(&au, FrameType::Idr, 90_000); + assert_eq!(pkts.len(), 3); + // Every datagram is SHARD_HEADER + payload_per_shard. + for p in &pkts { + assert_eq!(p.len(), SHARD_HEADER + 1376); + assert_eq!(p[0], 0x90); // RTP header byte + } + // First packet: SOF set, fecIndex 0, frameIndex 0. + let first = &pkts[0]; + assert_eq!(first[24] & FLAG_SOF, FLAG_SOF); + assert_eq!(first[24] & FLAG_PIC, FLAG_PIC); + let frame_index = u32::from_le_bytes(first[20..24].try_into().unwrap()); + assert_eq!(frame_index, 0); + let fec_info = u32::from_le_bytes(first[28..32].try_into().unwrap()); + assert_eq!(fec_info >> 22, 3); // dataShards = 3 + assert_eq!((fec_info >> 12) & 0x3ff, 0); // fecIndex 0 + // Last packet: EOF set, fecIndex 2. + let last = &pkts[2]; + assert_eq!(last[24] & FLAG_EOF, FLAG_EOF); + let fec_info_last = u32::from_le_bytes(last[28..32].try_into().unwrap()); + assert_eq!((fec_info_last >> 12) & 0x3ff, 2); + // RTP sequence numbers are 0,1,2. + for (i, p) in pkts.iter().enumerate() { + assert_eq!(u16::from_be_bytes(p[2..4].try_into().unwrap()), i as u16); + } + } + + #[test] + fn multi_block_split() { + let mut pk = VideoPacketizer::new(1392); + // Need > 255 data shards → multi-block. 255*1376 ≈ 351 KB; use 600 KB. + let au = vec![0u8; 600_000]; + let pkts = pk.packetize(&au, FrameType::P, 0); + let total = (8 + au.len()).div_ceil(1376); + assert_eq!(pkts.len(), total); + // n_blocks = ceil(total/255), clamped to 4; check multiFecBlocks lastBlock nibble. + let n_blocks = total.div_ceil(255).clamp(1, 4); + let last_block = ((pkts.last().unwrap()[27]) >> 6) & 0x3; + assert_eq!(last_block as usize, n_blocks - 1); + } +} diff --git a/crates/lumen-host/src/m0.rs b/crates/lumen-host/src/m0.rs new file mode 100644 index 00000000..6f68d140 --- /dev/null +++ b/crates/lumen-host/src/m0.rs @@ -0,0 +1,233 @@ +//! M0 — the pipeline spike (plan §8): capture → NVENC encode → playable file, with the +//! encoded access units also fed through a `lumen_core` host→client `Session` over an +//! in-process loopback to prove the core's FEC + packetize + reassemble path on real +//! encoder output. +//! +//! This is the spike runner, not the M2 hot path: it drives the stages on one thread (the +//! per-stage-thread pipeline with bounded channels is [`crate::pipeline`]). Source is +//! either a synthetic BGRx test pattern (no capture session needed) or the live xdg +//! ScreenCast portal monitor. + +use crate::capture::{self, Capturer, SyntheticCapturer}; +use crate::encode::{self, Codec, EncodedFrame, Encoder}; +use anyhow::{anyhow, Context, Result}; +use lumen_core::packet::{FLAG_PIC, FLAG_SOF}; +use lumen_core::{Config, Role, Session}; +use std::fs::File; +use std::io::{BufWriter, Write}; +use std::path::PathBuf; +use std::time::Instant; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Source { + /// Deterministic moving BGRx test pattern — no capture session required. + Synthetic, + /// Live monitor via the xdg ScreenCast portal + PipeWire. + Portal, +} + +#[derive(Clone, Debug)] +pub struct Options { + pub source: Source, + /// Synthetic-only; the portal source uses the PipeWire-negotiated size. + pub width: u32, + pub height: u32, + pub fps: u32, + pub seconds: u32, + pub codec: Codec, + pub bitrate_bps: u64, + /// Raw Annex-B elementary-stream sink (`.h265`/`.h264`/`.ivf-less .obu`); playable. + pub out: PathBuf, + /// Also round-trip every AU through a `lumen_core` host→client loopback and verify. + pub loopback: bool, +} + +pub fn run(opts: Options) -> Result<()> { + let mut capturer: Box = match opts.source { + Source::Synthetic => { + tracing::info!( + width = opts.width, + height = opts.height, + fps = opts.fps, + "M0 source: synthetic BGRx test pattern" + ); + Box::new(SyntheticCapturer::new(opts.width, opts.height, opts.fps)) + } + Source::Portal => { + tracing::info!("M0 source: xdg ScreenCast portal (live monitor)"); + capture::open_portal_monitor().context("open portal capturer")? + } + }; + + // The first frame establishes the authoritative dimensions (the portal's negotiated + // size, or the synthetic size) used to configure the encoder. + let first = capturer.next_frame().context("capture first frame")?; + let (w, h) = (first.width, first.height); + tracing::info!( + width = w, + height = h, + format = ?first.format, + codec = ?opts.codec, + bitrate_bps = opts.bitrate_bps, + "opening NVENC encoder" + ); + let mut encoder = + encode::open_video(opts.codec, first.format, w, h, opts.fps, opts.bitrate_bps) + .context("open encoder")?; + + let mut sink = BufWriter::new( + File::create(&opts.out).with_context(|| format!("create {}", opts.out.display()))?, + ); + + let mut lb = if opts.loopback { + Some(Loopback::new().context("build lumen-core loopback")?) + } else { + None + }; + + let target_frames = (opts.seconds as u64) * (opts.fps as u64); + let started = Instant::now(); + let mut stats = Stats::default(); + + let mut frame = first; + loop { + encoder.submit(&frame).context("encoder submit")?; + stats.submitted += 1; + drain_encoder(encoder.as_mut(), &mut sink, lb.as_mut(), &mut stats)?; + if stats.submitted >= target_frames { + break; + } + frame = capturer.next_frame().context("capture frame")?; + } + + // NVENC buffers frames internally even at delay=0 — flush and drain the tail. + encoder.flush().context("encoder flush")?; + drain_encoder(encoder.as_mut(), &mut sink, lb.as_mut(), &mut stats)?; + sink.flush().context("flush output file")?; + + let elapsed = started.elapsed().as_secs_f64(); + tracing::info!( + submitted = stats.submitted, + encoded = stats.encoded, + keyframes = stats.keyframes, + bytes_out = stats.bytes_out, + out = %opts.out.display(), + elapsed_s = format!("{elapsed:.2}"), + encode_fps = format!("{:.1}", stats.encoded as f64 / elapsed.max(1e-9)), + "M0 capture→encode→file complete" + ); + + if let Some(lb) = lb { + lb.report(); + if lb.mismatches > 0 || lb.recovered != lb.submitted { + return Err(anyhow!( + "lumen-core loopback verification FAILED: {} mismatches, {}/{} AUs recovered", + lb.mismatches, + lb.recovered, + lb.submitted + )); + } + } + Ok(()) +} + +#[derive(Default)] +struct Stats { + submitted: u64, + encoded: u64, + keyframes: u64, + bytes_out: u64, +} + +fn drain_encoder( + encoder: &mut dyn Encoder, + sink: &mut impl Write, + mut lb: Option<&mut Loopback>, + stats: &mut Stats, +) -> Result<()> { + while let Some(au) = encoder.poll().context("encoder poll")? { + sink.write_all(&au.data).context("write AU to file")?; + stats.encoded += 1; + stats.bytes_out += au.data.len() as u64; + if au.keyframe { + stats.keyframes += 1; + } + if let Some(lb) = lb.as_deref_mut() { + lb.submit(&au)?; + } + } + Ok(()) +} + +/// A host↔client `lumen_core` pair over a lossless in-process loopback. Each encoded AU is +/// FEC-protected, packetized, sent, then reassembled on the client and byte-compared to the +/// original — exercising the core on real encoder output (the M0 "feed into a Session" goal). +struct Loopback { + host: Session, + client: Session, + submitted: u64, + recovered: u64, + mismatches: u64, + bytes: u64, +} + +impl Loopback { + fn new() -> Result { + let (host_tx, client_tx) = lumen_core::transport::loopback_pair(0, 0); + let host = Session::new(Config::p1_defaults(Role::Host), Box::new(host_tx)) + .map_err(|e| anyhow!("host session: {e:?}"))?; + let client = Session::new(Config::p1_defaults(Role::Client), Box::new(client_tx)) + .map_err(|e| anyhow!("client session: {e:?}"))?; + Ok(Loopback { + host, + client, + submitted: 0, + recovered: 0, + mismatches: 0, + bytes: 0, + }) + } + + fn submit(&mut self, au: &EncodedFrame) -> Result<()> { + let mut flags = FLAG_PIC as u32; + if au.keyframe { + flags |= FLAG_SOF as u32; + } + self.host + .submit_frame(&au.data, au.pts_ns, flags) + .map_err(|e| anyhow!("host submit_frame: {e:?}"))?; + self.submitted += 1; + self.bytes += au.data.len() as u64; + + // Lossless + in-order loopback: each submit yields exactly the AU just sent. + loop { + match self.client.poll_frame() { + Ok(frame) => { + self.recovered += 1; + if frame.data != au.data { + self.mismatches += 1; + tracing::warn!( + recovered = self.recovered, + got = frame.data.len(), + expected = au.data.len(), + "loopback AU mismatch" + ); + } + } + Err(lumen_core::LumenError::NoFrame) => break, + Err(e) => return Err(anyhow!("client poll_frame: {e:?}")), + } + } + Ok(()) + } + + fn report(&self) { + tracing::info!( + submitted = self.submitted, + recovered = self.recovered, + mismatches = self.mismatches, + bytes = self.bytes, + "lumen-core loopback: AUs FEC-packetized → sent → reassembled & verified" + ); + } +} diff --git a/crates/lumen-host/src/main.rs b/crates/lumen-host/src/main.rs index 7451fac8..5d379f20 100644 --- a/crates/lumen-host/src/main.rs +++ b/crates/lumen-host/src/main.rs @@ -6,20 +6,26 @@ //! `#[cfg(target_os = "linux")]`; the crate compiles everywhere so the workspace builds //! on non-Linux dev machines — it just can't run the pipeline there. //! -//! Status: scaffold. M0 wires capture→encode→file; M2 wires the full P1 host that a -//! stock Moonlight client connects to. +//! Status: M0. The `m0` subcommand runs the capture→encode→file pipeline spike and feeds +//! the encoded AUs through a `lumen_core` loopback. M2 wires the full P1 host that a stock +//! Moonlight client connects to. // Scaffold: trait methods and config paths are defined ahead of their backends. #![allow(dead_code)] mod capture; mod encode; +mod gamestream; mod inject; +mod m0; mod pipeline; mod vdisplay; mod web; -use vdisplay::{Compositor, Mode}; +use anyhow::{bail, Result}; +use encode::Codec; +use m0::{Options, Source}; +use std::path::PathBuf; fn main() { tracing_subscriber::fmt() @@ -28,34 +34,147 @@ fn main() { ) .init(); - tracing::info!( - "lumen-host scaffold (lumen_core ABI v{})", - lumen_core::ABI_VERSION - ); - - // The intended startup sequence (each step is a separate, pluggable subsystem): - // 1. negotiate mode + codec + FEC scheme over the control plane (web::WebConfig) - // 2. vdisplay::open(compositor).create(mode) -> client-sized virtual output - // 3. capture::open_pipewire(node) ; encode::open(codec, bitrate) - // 4. build a lumen_core::Session (host role) over a UDP transport to the client - // 5. loop pipeline::pump_once(..) until disconnect, then destroy the output - let target_mode = Mode { - width: 2560, - height: 1440, - refresh_hz: 240, - }; - let compositor = Compositor::Kwin; // MVP target - - if cfg!(target_os = "linux") { - tracing::info!( - ?compositor, - ?target_mode, - "would create a virtual output and start streaming (backends pending M0/M2)" - ); - } else { - tracing::warn!( - "this is a Linux host; on {} only the shared lumen_core builds and is testable", - std::env::consts::OS - ); + if let Err(e) = real_main() { + tracing::error!("{e:#}"); + std::process::exit(1); } } + +fn real_main() -> Result<()> { + tracing::info!("lumen-host (lumen_core ABI v{})", lumen_core::ABI_VERSION); + + let args: Vec = std::env::args().skip(1).collect(); + match args.first().map(String::as_str) { + // M2 GameStream host control plane (P1.1: mDNS + serverinfo). + Some("serve") => gamestream::serve(), + // M0 pipeline spike. + Some("m0") => m0::run(parse_m0(&args[1..])?), + Some("-h") | Some("--help") | Some("help") | None => { + print_usage(); + Ok(()) + } + // Bare flags (no subcommand) default to the m0 spike for back-compat. + Some(_) => m0::run(parse_m0(&args)?), + } +} + +fn parse_m0(args: &[String]) -> Result { + let mut source = Source::Portal; + let mut width = 1920u32; + let mut height = 1080u32; + let mut fps = 60u32; + let mut seconds = 5u32; + let mut codec = Codec::H265; + let mut bitrate_mbps = 20u64; + let mut out: Option = None; + let mut loopback = true; + + let mut i = 0; + while i < args.len() { + let arg = args[i].as_str(); + let mut next = || { + i += 1; + args.get(i) + .cloned() + .ok_or_else(|| anyhow::anyhow!("missing value for {arg}")) + }; + match arg { + "--source" => { + source = match next()?.as_str() { + "synthetic" => Source::Synthetic, + "portal" => Source::Portal, + other => bail!("unknown --source '{other}' (synthetic|portal)"), + } + } + "--width" => { + width = next()? + .parse() + .map_err(|_| anyhow::anyhow!("bad --width"))? + } + "--height" => { + height = next()? + .parse() + .map_err(|_| anyhow::anyhow!("bad --height"))? + } + "--fps" => fps = next()?.parse().map_err(|_| anyhow::anyhow!("bad --fps"))?, + "--seconds" => { + seconds = next()? + .parse() + .map_err(|_| anyhow::anyhow!("bad --seconds"))? + } + "--codec" => { + codec = match next()?.as_str() { + "h264" => Codec::H264, + "h265" | "hevc" => Codec::H265, + "av1" => Codec::Av1, + other => bail!("unknown --codec '{other}' (h264|h265|av1)"), + } + } + "--bitrate" => { + bitrate_mbps = next()? + .parse() + .map_err(|_| anyhow::anyhow!("bad --bitrate (Mbps)"))? + } + "--out" => out = Some(PathBuf::from(next()?)), + "--no-loopback" => loopback = false, + "-h" | "--help" => { + print_usage(); + std::process::exit(0); + } + other => bail!("unknown argument '{other}' (try --help)"), + } + i += 1; + } + + if fps == 0 || width == 0 || height == 0 || seconds == 0 { + bail!("--fps/--width/--height/--seconds must be > 0"); + } + + let out = out.unwrap_or_else(|| { + let ext = match codec { + Codec::H264 => "h264", + Codec::H265 => "h265", + Codec::Av1 => "obu", + }; + PathBuf::from(format!("/tmp/lumen-m0.{ext}")) + }); + + Ok(Options { + source, + width, + height, + fps, + seconds, + codec, + bitrate_bps: bitrate_mbps.saturating_mul(1_000_000), + out, + loopback, + }) +} + +fn print_usage() { + eprintln!( + "lumen-host — Linux streaming host + +USAGE: + lumen-host serve GameStream host control plane (M2: mDNS + serverinfo …) + lumen-host m0 [OPTIONS] M0 capture→encode→file pipeline spike + +OPTIONS: + --source frame source (default: portal) + --seconds capture duration in seconds (default: 5) + --fps target frame rate (default: 60) + --codec NVENC codec (default: h265) + --bitrate target bitrate in Mbps (default: 20) + --width --height synthetic source size (default: 1920x1080) + --out raw Annex-B output (default: /tmp/lumen-m0.) + --no-loopback skip the lumen_core round-trip verification + -h, --help this help + +NOTES: + 'portal' needs headless Sway + xdg-desktop-portal-wlr running in this session + (see docs/linux-setup.md). 'synthetic' needs no capture session and always runs. + Encoded AUs are written to a playable file AND (unless --no-loopback) fed through a + lumen_core host→client loopback that reassembles and byte-verifies each one." + ); +} diff --git a/scripts/bootstrap-ubuntu.sh b/scripts/bootstrap-ubuntu.sh index 14218097..02b7ac43 100755 --- a/scripts/bootstrap-ubuntu.sh +++ b/scripts/bootstrap-ubuntu.sh @@ -25,9 +25,10 @@ fi CODENAME="$(. /etc/os-release 2>/dev/null && echo "${VERSION_CODENAME:-unknown}")" case "$CODENAME" in noble) ok "Ubuntu 24.04 (noble) — the recommended target" ;; + questing) ok "Ubuntu 25.10 (questing) — newer than the tested 24.04; M0 verified here (Sway 1.10, FFmpeg 7.1)" ;; jammy) warn "Ubuntu 22.04 (jammy): Sway 1.7 / FFmpeg 4.4 are too old for the M0 path. \ -Strongly prefer 24.04, or build Sway/wlroots + FFmpeg 7.x from source here." ;; - *) warn "Unrecognized release '$CODENAME' — proceeding, but package names are tuned for noble." ;; +Strongly prefer 24.04+, or build Sway/wlroots + FFmpeg 7.x from source here." ;; + *) warn "Unrecognized release '$CODENAME' — proceeding, but package names are tuned for noble/questing." ;; esac SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo" @@ -92,7 +93,50 @@ apt_install "Wayland dev" libwayland-dev wayland-protocols wayland-utils apt_install "DRM/EGL/GBM/VA" libdrm-dev libgbm-dev libgbm1 libegl-dev libegl1 libgles-dev mesa-common-dev libva-dev apt_install "capture + dbus" wf-recorder grim dbus-user-session drm-info mesa-utils apt_try "NVIDIA EGL platform (multiverse)" libnvidia-egl-wayland1 libnvidia-egl-gbm1 -apt_try "libei (noble only; reis is pure-Rust so optional)" libei-dev +apt_try "libei (reis is pure-Rust so optional)" libei-dev + +# --------------------------------------------------------------------------- +log "NVIDIA GL/EGL userspace (headless GPU Wayland needs this — nvidia-utils alone is NOT enough)" +# --------------------------------------------------------------------------- +# nvidia-utils-NNN ships nvidia-smi + NVENC (libnvidia-encode) but NOT the GL/EGL libs. +# Without libnvidia-gl-NNN there is no libEGL_nvidia.so.0 and no GLVND vendor JSON +# (/usr/share/glvnd/egl_vendor.d/10_nvidia.json), so libglvnd falls back to Mesa, wlroots +# can't init EGL on the NVIDIA GPU, Sway is forced to the pixman software renderer, and the +# ScreenCast portal then can't negotiate a dmabuf buffer format (capture fails). Install the +# GL package matching the running driver. +if have nvidia-smi; then + if [ -e /usr/share/glvnd/egl_vendor.d/10_nvidia.json ] && ldconfig -p 2>/dev/null | grep -qi 'libEGL_nvidia'; then + ok "NVIDIA GL/EGL userspace present (libEGL_nvidia + 10_nvidia.json)" + elif [ -n "${DRV:-}" ]; then + apt_try "NVIDIA GL/EGL userspace (libnvidia-gl-$DRV)" "libnvidia-gl-$DRV" + [ -e /usr/share/glvnd/egl_vendor.d/10_nvidia.json ] \ + && ok "10_nvidia.json now present — GPU EGL should work" \ + || warn "10_nvidia.json still missing — install the libnvidia-gl package matching driver $DRV by hand." + else + warn "Couldn't determine the driver branch; install libnvidia-gl- matching 'nvidia-smi' by hand." + fi +fi + +# --------------------------------------------------------------------------- +log "GPU device group membership (render + video) — required to open /dev/dri/*" +# --------------------------------------------------------------------------- +# wlroots opens the render node (/dev/dri/renderD128, group 'render') and the DRM card +# (/dev/dri/card*, group 'video'); both are 0660. Without membership Sway aborts with +# 'Permission denied' on the render node. +TARGET_USER="${SUDO_USER:-$USER}" +NEED_GROUPS="" +for g in render video; do + id -nG "$TARGET_USER" 2>/dev/null | tr ' ' '\n' | grep -qx "$g" || NEED_GROUPS="$NEED_GROUPS $g" +done +if [ -n "$NEED_GROUPS" ]; then + warn "$TARGET_USER is not in:$NEED_GROUPS — adding (takes effect on next LOGIN; re-login or reboot):" + for g in $NEED_GROUPS; do echo " $SUDO usermod -aG $g $TARGET_USER"; done + $SUDO usermod -aG "$(echo "$NEED_GROUPS" | tr ' ' ',' | sed 's/^,//')" "$TARGET_USER" 2>/dev/null \ + && ok "added; LOG OUT AND BACK IN (or reboot) for it to apply" \ + || warn "could not usermod automatically — run the commands above." +else + ok "$TARGET_USER already in render + video" +fi # --------------------------------------------------------------------------- log "FFmpeg dev headers (gated — must NOT clobber your custom NVENC build)" diff --git a/scripts/headless/prepare-session.sh b/scripts/headless/prepare-session.sh new file mode 100755 index 00000000..434c9a00 --- /dev/null +++ b/scripts/headless/prepare-session.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# Run AFTER headless Sway is up (run-headless-sway.sh), from a second shell on the same +# user. It: (1) points this shell at the running Sway, (2) gives HEADLESS-1 a real refresh +# clock (an idle/0 mHz output produces no frames), (3) imports the env the ScreenCast portal +# needs to find Sway and pick the wlr backend, and (4) writes /tmp/lumen-sway-env.sh so +# other shells (e.g. `cargo run -p lumen-host`) can `source` it. +# +# Usage: bash scripts/headless/prepare-session.sh [WxH@RHz] (default 1920x1080@60Hz) +set -euo pipefail + +export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}" +export WAYLAND_DISPLAY="${WAYLAND_DISPLAY:-wayland-1}" +export XDG_CURRENT_DESKTOP=sway +export DBUS_SESSION_BUS_ADDRESS="${DBUS_SESSION_BUS_ADDRESS:-unix:path=$XDG_RUNTIME_DIR/bus}" +SWAYSOCK="$(ls -t "$XDG_RUNTIME_DIR"/sway-ipc.*.sock 2>/dev/null | head -1)" +export SWAYSOCK +MODE="${1:-1920x1080@60Hz}" + +if ! swaymsg -t get_outputs >/dev/null 2>&1; then + echo "Sway IPC not reachable ($SWAYSOCK) — is run-headless-sway.sh running?" >&2 + exit 1 +fi + +# A real refresh clock so the compositor actually drives frames (and screencopy/PipeWire +# get content). Without on-screen motion the screen is static; launch something animated +# (e.g. `swaymsg exec foot`) to exercise the encoder. +swaymsg output HEADLESS-1 mode "$MODE" >/dev/null +echo "HEADLESS-1 set to $MODE" + +# The portal (xdg-desktop-portal-wlr) is D-Bus/systemd activated and must inherit these to +# find the compositor and select the wlr ScreenCast backend. +systemctl --user import-environment WAYLAND_DISPLAY XDG_CURRENT_DESKTOP SWAYSOCK XDG_RUNTIME_DIR 2>/dev/null || true +dbus-update-activation-environment --systemd WAYLAND_DISPLAY XDG_CURRENT_DESKTOP SWAYSOCK 2>/dev/null || true + +cat > /tmp/lumen-sway-env.sh </dev/null || true chmod 700 "$XDG_RUNTIME_DIR" 2>/dev/null || true -echo "starting headless Sway (renderer=$WLR_RENDERER). From another shell on this user:" -echo " export XDG_RUNTIME_DIR=$XDG_RUNTIME_DIR WAYLAND_DISPLAY=wayland-1" -echo " swaymsg -t get_outputs # expect HEADLESS-1" +echo "starting headless Sway (renderer=$WLR_RENDERER) on bus $DBUS_SESSION_BUS_ADDRESS" +echo "from another shell on this user: bash $HERE/prepare-session.sh" echo -# --unsupported-gpu is mandatory on Sway 1.9 with the proprietary NVIDIA driver. -exec dbus-run-session -- sway --unsupported-gpu +# wlroots opens the render node (/dev/dri/renderD128, group 'render'); NVIDIA's EGL uses it. +# If this login predates `usermod -aG render,video` (groups not yet active), bridge with +# `sg render` for this launch. The clean fix is to re-login so the groups apply natively. +if id -nG | tr ' ' '\n' | grep -qx render; then + exec sway --unsupported-gpu +else + echo "note: 'render' group not active in this login — bridging via 'sg render' (re-login to avoid)" + exec sg render -c 'sway --unsupported-gpu' +fi