diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 52a0cd0..31c5641 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -28,7 +28,7 @@ jobs: # every newer glibc, so one artifact covers system OBS and Flatpak. runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 # clang/libclang is a build dependency now: ffmpeg-sys-next runs bindgen # over the bundled FFmpeg headers, and obs-sys does the same for its @@ -120,9 +120,10 @@ jobs: - name: Clippy run: cargo clippy --workspace --all-targets -- -D warnings - # Formatting and spelling are checked once, on Linux: they are - # platform-independent, so running them per platform only buys three - # copies of the same failure. `make style` fixes both locally. + # Formatting, spelling and the TLS provider are checked once, on Linux: + # they read the tree, not the platform, so running them per platform only + # buys three copies of the same failure. `make style` fixes the first two + # locally. - name: Format check run: make style-check @@ -131,6 +132,11 @@ jobs: pipx install codespell make spell-check + # Cargo.lock must keep resolving rustls onto ring: aws-lc-sys would put + # cmake, nasm, perl and go on every build machine. + - name: TLS provider + run: make tls-provider + # libobs is installed here (OBS PPA, see above), so the test binaries # that touch libobs symbols (obs, irl-source) link and run on this # runner only. @@ -145,7 +151,7 @@ jobs: run: ./scripts/verify-plugin.sh target/release/libobs_irl_source.so - name: Upload artifact - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@v6 with: name: obs-irl-source-linux-x64 path: target/release/libobs_irl_source.so @@ -153,17 +159,20 @@ jobs: windows-x64: runs-on: windows-2025-vs2026 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 + # The x64 MSVC environment, exported to every later step. Done by hand + # rather than through ilammy/msvc-dev-cmd, which is unmaintained and + # still targets Node 20: vswhere finds the newest toolset on the image, + # vcvarsall.bat configures it, and the shell's environment becomes the + # job's. - name: Setup MSVC - uses: ilammy/msvc-dev-cmd@0b201ec74fa43914dc39ae48a89fd1d8cb592756 # v1.13.0 - # msvc-dev-cmd has no Node 24 release yet (upstream ilammy/msvc-dev-cmd#105); - # run its node20 bundle on the node24 runtime. This restates the - # deprecation annotation ("forced to run on Node.js 24") rather than - # silencing it — the point is to exercise the runtime that survives the - # retirement, so a break surfaces now instead of when Node 20 is removed. - env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + shell: cmd + run: | + for /f "usebackq delims=" %%i in (`"%ProgramFiles(x86)%\Microsoft Visual Studio\Installer\vswhere.exe" -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath`) do set "VSINSTALL=%%i" + if not defined VSINSTALL ( echo No Visual Studio with the x64 C++ toolset found & exit /b 1 ) + call "%VSINSTALL%\VC\Auxiliary\Build\vcvarsall.bat" x64 || exit /b 1 + set >> "%GITHUB_ENV%" # librist builds with meson. It has to be the Windows-native meson (so it # detects cl.exe rather than looking for a POSIX toolchain), which @@ -262,7 +271,7 @@ jobs: if ($fail) { Write-Host $deps; Write-Host $exports; exit 1 } - name: Upload artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: obs-irl-source-windows-x64 path: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7eb9201..2b1f9bc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -34,12 +34,12 @@ jobs: # The changelog is built from the commit range since the previous tag, # so the job needs full history and every tag, not the default shallow # single-commit checkout. - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: fetch-depth: 0 - name: Download build artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v6 with: path: artifacts diff --git a/CLAUDE.md b/CLAUDE.md index 765bea2..cc29ed0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -68,7 +68,8 @@ make style-check make lint # cargo xlint make test # cargo xtest make spell-check # codespell -make check # style-check + lint + test + spell-check, what CI runs +make tls-provider # Cargo.lock still resolves rustls onto ring, not aws-lc-rs +make check # style-check + lint + test + spell-check + tls-provider, what CI runs make sim # the speed-controller simulation; not a CI target ``` @@ -88,6 +89,7 @@ One cdylib, five workspace crates. The rule that shapes the split: **all unsafe | `crates/obs` | Safe, plugin-agnostic libobs API: the `Source` trait and registration, `declare_module!`, `Data`/`Properties`/`CallData`/`ProcHandler`, `VideoFrame`/`AudioFrame` builders, scene transforms, the obs-websocket vendor helper, `panic::guard`. Knows nothing about IRL streaming. | | `crates/ffmpeg` | RAII over `ffmpeg-sys-next` (package `irl-ffmpeg`, lib name `ffmpeg`): `FormatContext`, `CodecContext`, `Frame`, `Packet`, `HwDeviceContext`, `FramePool`, `Resampler`, `Scaler`, `InterruptWatch`, and `log::route_to`, which hands the bundled FFmpeg's `av_log` to a caller-supplied sink. `build.rs` replays `irl-deps.env`. | | `crates/irl-core` | Everything that needs neither libobs nor FFmpeg: the jitter buffer, PTS repair, the speed controller, output-clock arithmetic, video pacing, demuxer options, config derivation, the stats table, every tuning constant. Plain data in, plain data out — and therefore the only crate with a real unit-test suite. | +| `crates/irl-provider` | The plugin side of `docs/provider-protocol.md`: discovery, OAuth code + PKCE over a loopback redirect, the per-provider state file, the key-free ingest list and the resolve call. `#![forbid(unsafe_code)]`, no libobs; the plugin hands it a state directory, a logger and a wake-the-dialogs callback through `init`. Pure parts are tested under `tests/` without a network. | | `crates/irl-source` | The plugin itself: module entry points, the source lifecycle and the three worker threads. | ### Data flow @@ -135,10 +137,11 @@ Buffer regulation happens through playback speed only, asymmetric like IRLToolki | file | ports | | --- | --- | -| `lib.rs` | `plugin.c`: `declare_module!`, load → the FFmpeg log route and `register_source::()`, post_load → `websocket::register()`, the deadlock poller under the feature. | +| `lib.rs` | `plugin.c`: `declare_module!`, load → the FFmpeg log route, `register_source::()` and `providers::init()`, post_load → `websocket::register()`, the deadlock poller under the feature. | | `log.rs` | `irl_info!` / `irl_warn!` / `irl_error!` / `irl_debug!`, which bind the `[irl-source]` prefix, plus the redaction (`redacted_input_url`, `redacted_log_line`) and the `[ffmpeg]` sink. | | `source.rs` | `irl-source.c`: create/update/tick/activate/deactivate/show/hide/Drop, the media callbacks and the `media_stopped` latch, `start_receiver`/`stop_receiver`, fit-to-canvas, the `get_stats` proc. | | `settings.rs` | `settings.c`: defaults and the properties dialog. | +| `providers.rs` | New in 2.x: the Provider dropdown, one ingest picker per provider and the sign-in buttons. Writes into `url` and nothing else; `tests/provider_seam.rs` pins that no file outside it, `settings.rs` and `lib.rs` mentions providers. Its module doc lists the four libobs dialog behaviours that dictate its shape. | | `config.rs` | `config_load` / `config_requires_restart` / `config_apply_hot`. | | `shared.rs` | The decomposition of the C `struct irl_source` into owners (see below). | | `receiver/{mod,stream,decode,audio_in}.rs` | `receiver.c`, `receiver-stream.c`, the audio half of `receiver-decode.c`, and the intake half of `receiver-audio.c`. | @@ -196,7 +199,7 @@ Everywhere else, tests live in `tests/`, never inside the lib. The link argument Note the sampling point in it. The jitter buffer's level oscillates by one whole chunk within every cycle, so *where* you read the fill decides what number you get: before the pump's read (what the controller regulates) it averages the target, and after it, a chunk lower. The stats line's `buf=` is a random sample of that oscillation, which is why it reads low as often as not. -`crates/irl-source/tests/locale_keys.rs` is the mechanical half of the "a new UI string belongs in two places" rule: it scans `settings.rs` and `source.rs` for `module_text` keys and fails if one has no `data/locale/en-US.ini` entry, or if the ini carries a string nothing uses. `module_text` falls back to returning the key, so without it a missing string is only noticed by opening the properties dialog. +`crates/irl-source/tests/locale_keys.rs` is the mechanical half of the "a new UI string belongs in two places" rule: it scans `settings.rs`, `source.rs` and `providers.rs` for `module_text` keys and fails if one has no `data/locale/en-US.ini` entry, or if the ini carries a string nothing uses. `module_text` falls back to returning the key, so without it a missing string is only noticed by opening the properties dialog. The speed controller has one more check that is not a test, because a controller that limit-cycles still passes every assertion you would think to write about one sample of it: @@ -252,6 +255,7 @@ This plugin was heavily built with LLM assistance, including the Rust port. The - **`THIRD_PARTY_NOTICES.md`** — Licenses for the statically linked stack and the Rust crates, shipped inside every release archive rather than only living in the repo, because LGPLv3 FFmpeg wants its notices conveyed with the object code. `deps/README.md` has the reasoning behind the license choices; this file is the artifact-facing copy. - **`docs/audio-pipeline.md`** — Deep dive on the buffered vs low-latency audio paths, jitter buffer, adaptive latency control, PTS repair tiers, and timestamp handling. - **`docs/viewer-quality-plan.md`** — The viewer-quality policy and the recovery/diagnostics behavior that implements it (what stats to watch and what healthy looks like). +- **`docs/provider-protocol.md`** — The contract a service implements to appear in the Provider dropdown: discovery document, OAuth sign-in, the key-free ingest list and the resolve call. The plugin side of it lives in `crates/irl-provider`. - **`docs/audio-timing-pitfalls.md`** — What was built wrong first in the audio timing path, and the media-clock estimator that was built, measured and deleted. Required reading before changing `crates/irl-core/src/speed.rs`; most of it is re-inventable. - **`Makefile`**, **`.config/`** — The quality gates and their explicit configs (`rustfmt.toml`, `codespellrc`), so `make check` gives the same answer everywhere. - **`AGENTS.md`**, **`GEMINI.md`** — Symlinks to this file (`CLAUDE.md`). diff --git a/Cargo.lock b/Cargo.lock index 7f4a363..f327226 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -41,6 +41,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "bindgen" version = "0.72.1" @@ -58,7 +64,7 @@ dependencies = [ "regex", "rustc-hash", "shlex 1.3.0", - "syn", + "syn 2.0.119", ] [[package]] @@ -67,6 +73,12 @@ version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + [[package]] name = "cc" version = "1.4.4" @@ -103,6 +115,61 @@ dependencies = [ "libloading", ] +[[package]] +name = "cookie" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "cookie_store" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206" +dependencies = [ + "cookie", + "document-features", + "idna", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "time", + "url", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "either" version = "1.18.0" @@ -141,6 +208,26 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" +[[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 = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + [[package]] name = "gimli" version = "0.32.3" @@ -165,6 +252,126 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -177,21 +384,34 @@ dependencies = [ [[package]] name = "irl-core" -version = "2.0.2" +version = "2.1.1" [[package]] name = "irl-ffmpeg" -version = "2.0.2" +version = "2.1.1" dependencies = [ "ffmpeg-sys-next", ] +[[package]] +name = "irl-provider" +version = "2.1.1" +dependencies = [ + "base64", + "parking_lot", + "ring", + "serde", + "serde_json", + "ureq", +] + [[package]] name = "irl-source" -version = "2.0.2" +version = "2.1.1" dependencies = [ "irl-core", "irl-ffmpeg", + "irl-provider", "obs", "parking_lot", ] @@ -205,6 +425,12 @@ dependencies = [ "either", ] +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + [[package]] name = "libc" version = "0.2.189" @@ -221,6 +447,18 @@ dependencies = [ "windows-link", ] +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + [[package]] name = "lock_api" version = "0.4.14" @@ -267,6 +505,12 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + [[package]] name = "num_cpus" version = "1.17.0" @@ -288,18 +532,24 @@ dependencies = [ [[package]] name = "obs" -version = "2.0.2" +version = "2.1.1" dependencies = [ "obs-sys", ] [[package]] name = "obs-sys" -version = "2.0.2" +version = "2.1.1" dependencies = [ "bindgen", ] +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + [[package]] name = "parking_lot" version = "0.12.5" @@ -325,6 +575,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + [[package]] name = "petgraph" version = "0.6.5" @@ -341,6 +597,21 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "prettyplease" version = "0.2.37" @@ -348,7 +619,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn", + "syn 2.0.119", ] [[package]] @@ -407,6 +678,20 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom", + "libc", + "untrusted", + "windows-sys", +] + [[package]] name = "rustc-demangle" version = "0.1.28" @@ -419,12 +704,90 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" +[[package]] +name = "rustls" +version = "0.23.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6725596c3f2c3a0aef021139e145d4eafe314a6623e4680ca83852b2c67ab2ba" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + [[package]] name = "shlex" version = "1.3.0" @@ -443,6 +806,18 @@ version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.119" @@ -454,20 +829,332 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "3.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af5546be8f5378d5414f83733f5c9a2526f4645829edbc1c41790aeef1b38e8b" +dependencies = [ + "base64", + "cookie_store", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "ureq-proto", + "utf8-zero", + "webpki-roots", +] + +[[package]] +name = "ureq-proto" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabc3e92916c89c95b20eef7b06b00b066bc217ef9ea3a4ac9bf1a7e35261e10" +dependencies = [ + "base64", + "http", + "httparse", + "log", +] + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "vcpkg" version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml index 94be458..bac16ec 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,13 +5,14 @@ members = [ "crates/obs", "crates/ffmpeg", "crates/irl-core", + "crates/irl-provider", "crates/irl-source", ] [workspace.package] # Single source of truth for the plugin version. release.yml verifies the # pushed tag against it and the Windows installer reads it from here. -version = "2.0.2" +version = "2.1.1" edition = "2024" rust-version = "1.97" license = "AGPL-3.0-or-later" @@ -23,6 +24,7 @@ obs-sys = { path = "crates/obs-sys" } obs = { path = "crates/obs" } irl-ffmpeg = { path = "crates/ffmpeg" } irl-core = { path = "crates/irl-core" } +irl-provider = { path = "crates/irl-provider" } # The bundled FFmpeg is located through FFMPEG_DIR (see .cargo/config.toml); # `static` makes the sys crate emit static link lines for the five libav* # archives. The transitive libraries (srt, rist, mbedtls, ...) are linked by diff --git a/Makefile b/Makefile index 997d785..feb2d87 100644 --- a/Makefile +++ b/Makefile @@ -6,14 +6,14 @@ CONFIG_DIR = .config CARGO = cargo -.PHONY: default build check style style-check lint test spell-check sim clean +.PHONY: default build check style style-check lint test spell-check tls-provider sim clean default: check build: $(CARGO) build --release --workspace -check: style-check lint test spell-check +check: style-check lint test spell-check tls-provider style: $(CARGO) fmt -- --config-path $(CONFIG_DIR)/rustfmt.toml @@ -30,6 +30,19 @@ test: spell-check: codespell --config $(CONFIG_DIR)/codespellrc +# crates/irl-provider asks ureq for the *ring* rustls provider, which ships +# pregenerated assembly and needs no cmake, nasm, perl or go on any runner. +# rustls's own default provider is aws-lc-rs, so one future dependency enabling +# rustls with default features would unify the feature and quietly add a cmake +# requirement to all three CI jobs. Cheaper to assert than to rediscover on a +# red build. +tls-provider: + @grep -q '^name = "ring"' Cargo.lock \ + || { echo 'Cargo.lock: expected the ring rustls provider'; exit 1; } + @! grep -q '^name = "aws-lc-sys"' Cargo.lock \ + || { echo 'Cargo.lock: aws-lc-sys pulled in; pin rustls back to ring'; exit 1; } + @echo " ok rustls provider is ring" + # The audio speed controller, run closed-loop against a simulated sender. # Deliberately not part of `check`: it is a design aid, not a gate. Read # docs/audio-timing-pitfalls.md before touching what it exercises. diff --git a/README.md b/README.md index 5ef1b19..5c82de9 100644 --- a/README.md +++ b/README.md @@ -73,8 +73,8 @@ The release binary bundles its own media stack and resolves libobs symbols from ## Usage -1. Add a new source: **IRL Source (irlserver.com)** -2. Enter your stream URL (for example `srt://your-server:4000?streamid=play/stream/key`) +1. Add a new source: **IRL Source** +2. Pick a provider and sign in, then choose an ingest from the list. Or leave Provider on **Manual URL** and enter your stream URL (for example `srt://your-server:4000?streamid=play/stream/key`). 3. Leave the rest alone unless you have a reason. The defaults are the tested path. A source you just added sizes itself to the canvas when its first frame arrives, same result as Edit > Transform > Fit to screen (aspect preserved, nothing cropped). This happens once. A source loaded from a saved scene collection is never touched, and once you move or resize it the plugin leaves it alone. @@ -83,6 +83,10 @@ A source you just added sizes itself to the canvas when its first frame arrives, | Setting | Default | What it does | | --- | --- | --- | +| Provider | Manual URL | Where the pull URL comes from. Manual URL means you type it. A provider lets you sign in and pick an ingest by name. Custom provider takes the base URL of any service that implements the [provider protocol](docs/provider-protocol.md) | +| Provider URL | | Only with Custom provider: the service's base URL, `https://…` | +| Ingest | | Your ingests at the selected provider, by name, with region and live status when the provider reports them. Picking one writes its pull URL into URL below and then resets itself, so it is an action rather than a setting | +| Sign in / Refresh ingests / Sign out | | Sign in opens your browser at the provider. Once signed in the same button re-reads the list, and Sign out ends the session. The sign-in survives an OBS restart | | URL | | Your pull URL. SRT, RTMP, or anything else FFmpeg can open | | Reconnect Delay | 2s | How long to wait between reconnect attempts | | Target Buffer | 120ms | How much audio cushion to hold, 20ms to 8s. This is your main latency knob: higher rides out a worse connection, lower is snappier and less forgiving. If the stats show `underruns` climbing, this is the setting to raise — an underrun means the cushion ran dry, and the concealment that covers it delays video by the same amount to keep lip sync. The whole target is paid as delay before the source starts, so raise it to what your connection actually needs rather than to the maximum. Memory cost is small and does not depend on resolution much: video is held compressed and only decoded just before it is shown | @@ -99,6 +103,14 @@ Target Buffer, Reconnect Delay, Adaptive Latency Control, Catch-Up Speed, Wait f Earlier versions exposed Min/Max Buffer, PTS gap thresholds, Network Buffer and Decoupled Audio. Those are now fixed or derived internally, so old scene collections keep working and ignore the stored values. +### Providers + +Signing in opens your browser at the provider and hands the session back to OBS over a loopback address, the same OAuth flow a native app uses. Nothing is typed into OBS. The session lands in a file under OBS's plugin config directory, owner-only on macOS and Linux, never in your scene collection, so exporting or sharing a collection never carries it with you. + +Signing in changes nothing about how a source streams. The Ingest list writes a plain URL into the URL field, and that field is all the receiver ever reads. A scene collection you saved months ago keeps working with an expired session, a provider that is down, or no sign-in at all. Only the list stops filling. When a session expires the plugin notices on the next refresh and the button goes back to Sign in. + +The ingest list never contains stream keys. Picking an entry asks the provider for that one URL, which goes straight into the URL field. Any service can be a provider by implementing [docs/provider-protocol.md](docs/provider-protocol.md); paste its base URL under Custom provider. + ### Buffered or low latency? `Low Latency Audio` does more than flip an OBS flag. It changes how the plugin buffers. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 925fc5b..f480b9b 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -124,6 +124,73 @@ synchronise on. Used under either license at the recipient's option. License texts: · +### ureq (and ureq-proto, http, httparse, percent-encoding, utf8-zero) — MIT OR Apache-2.0 + + + +The blocking HTTP client behind the Provider dropdown: discovery, the OAuth +token exchange and the ingest list. Blocking rather than async deliberately; +the plugin has no async runtime and none is wanted. Used under either license +at the recipient's option. + +License texts: · + + +### rustls (and rustls-pki-types) — Apache-2.0 OR ISC OR MIT + + + +The TLS implementation behind that client. Used in place of the bundled +Mbed TLS because the FFmpeg build runs with `tls_verify=0` (it ships no CA +store), which is acceptable for a stream URL and not for a bearer token. + +### ring — Apache-2.0 AND ISC + + + +rustls's cryptographic provider, and the SHA-256 and random source behind the +PKCE code challenge. Pinned in preference to aws-lc-rs because it ships +pregenerated assembly and so needs no cmake, nasm, perl or go on any build +machine; `make tls-provider` asserts the pin holds. + +Code sourced from BoringSSL is Apache-2.0 (`LICENSE-BoringSSL`); ring's own +code is ISC (`LICENSE-other-bits`). Despite BoringSSL's ancestry, the crate +carries no code under the historic OpenSSL license, whose advertising clause +would be incompatible with the AGPL. + +### rustls-webpki — ISC · untrusted — ISC + + · + +Certificate path validation and its input parser. + +### webpki-roots — CDLA-Permissive-2.0 + + + +Mozilla's CA root store, compiled in. A compiled-in bundle updates with +`cargo update` rather than with a packaging change on three platforms, and the +plugin only ever talks to providers the user chose. + +License text: + +### serde (and serde_core, serde_derive, serde_json, itoa, zmij) — MIT OR Apache-2.0 + + · + +Reads the provider documents and the ingest list, and reads and writes the +plugin's own per-provider state file. + +### base64 — MIT OR Apache-2.0 + + + +The base64url encoding of the PKCE verifier and challenge. + +The small helper crates these pull in (getrandom, once_cell, log, zeroize, +cfg-if, smallvec, scopeguard, memchr) are MIT OR Apache-2.0; subtle is +BSD-3-Clause. + ### The Rust standard library — MIT OR Apache-2.0 diff --git a/crates/irl-core/src/consts.rs b/crates/irl-core/src/consts.rs index 70e1712..c953562 100644 --- a/crates/irl-core/src/consts.rs +++ b/crates/irl-core/src/consts.rs @@ -335,6 +335,28 @@ pub const UDP_FIFO_DEFAULT_PACKETS: i64 = 7 * 4096; /// Interval of the periodic receiver stats log line. pub const STATS_LOG_INTERVAL_NS: u64 = 30_000_000_000; +// ── Providers ── + +/// A provider the Provider dropdown offers before any sign-in. See +/// `docs/provider-protocol.md`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct BuiltinProvider { + /// The dropdown label until the provider's own document has been read. + pub name: &'static str, + /// Where `/.well-known/irl-source-provider.json` is fetched from. + pub base_url: &'static str, +} + +/// The stock list. A build for one provider replaces this with itself and +/// turns [`ALLOW_CUSTOM_PROVIDER`] off; nothing else has to change. +pub const BUILTIN_PROVIDERS: &[BuiltinProvider] = &[BuiltinProvider { + name: "IRLServer", + base_url: "https://irlserver.com", +}]; + +/// Whether the dropdown offers a Custom entry with a base URL field. +pub const ALLOW_CUSTOM_PROVIDER: bool = true; + /// Ring capacity when the format is degenerate and `4 × max_ms` works out to /// nothing (`audio_buffer_init`'s `buf->capacity = 65536` fallback). pub const AUDIO_BUFFER_FALLBACK_CAPACITY: usize = 65536; diff --git a/crates/irl-provider/Cargo.toml b/crates/irl-provider/Cargo.toml new file mode 100644 index 0000000..b270c73 --- /dev/null +++ b/crates/irl-provider/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "irl-provider" +description = "The plugin side of the provider protocol: discovery, OAuth sign-in and the key-free ingest list" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true + +[lib] +doctest = false + +[dependencies] +base64 = "0.23.1" +parking_lot.workspace = true +# ring is already in the tree as rustls's crypto provider; PKCE needs SHA-256 +# and a CSPRNG, and taking them from ring adds no crate. +ring = "0.17.14" +serde = { version = "1.0.229", features = ["derive"] } +serde_json = "1.0.151" +# Blocking HTTP, no async runtime. default-features = false drops gzip; +# "rustls" resolves TLS onto ring, not aws-lc-rs, so no build machine needs +# cmake, nasm, perl or go. `make tls-provider` asserts that stays true. +ureq = { version = "3", default-features = false, features = ["rustls", "json"] } + +[dev-dependencies] +serde_json = "1.0.151" diff --git a/crates/irl-provider/src/api.rs b/crates/irl-provider/src/api.rs new file mode 100644 index 0000000..285023c --- /dev/null +++ b/crates/irl-provider/src/api.rs @@ -0,0 +1,197 @@ +//! The two provider endpoints, and the HTTP agent every request goes through. + +use std::fmt; +use std::sync::OnceLock; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; + +use crate::hooks; + +/// One entry of the list. `id` is opaque and secret-free by contract; it is +/// what the dropdown stores. +#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] +pub struct Ingest { + pub id: String, + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub detail: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub online: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub bitrate_kbps: Option, +} + +/// `^[A-Za-z0-9._:-]{1,128}$`: safe in a URL path and in a settings value. +#[must_use] +pub fn valid_ingest_id(id: &str) -> bool { + !id.is_empty() + && id.len() <= 128 + && id + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b':' | b'-')) +} + +#[derive(Debug)] +pub enum ApiError { + /// 401. The caller refreshes the token once and retries. + Unauthorized, + /// 403: visible but not pullable. + Forbidden(String), + /// 404: unknown or revoked id. + NotFound(String), + Http(String), + Json(String), +} + +impl fmt::Display for ApiError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Unauthorized => f.write_str("not signed in"), + Self::Forbidden(d) if d.is_empty() => f.write_str("not allowed to pull this ingest"), + Self::Forbidden(d) => write!(f, "not allowed to pull this ingest: {d}"), + Self::NotFound(d) if d.is_empty() => f.write_str("ingest not found"), + Self::NotFound(d) => write!(f, "ingest not found: {d}"), + Self::Http(e) => f.write_str(e), + Self::Json(e) => write!(f, "malformed response: {e}"), + } + } +} + +/// Hard ceilings so a dead provider is a short pause, never a hang. The +/// resolve call runs on the OBS UI thread inside a property callback, so this +/// is what stands between a blackholed DNS lookup and a frozen dialog. +/// +/// One agent for the process, so a sign-in and the ingest calls after it reuse +/// the connection instead of paying a TLS handshake each. Built on first use, +/// which is after [`crate::init`] has installed the hooks the user agent comes +/// from. +pub(crate) fn agent() -> &'static ureq::Agent { + static AGENT: OnceLock = OnceLock::new(); + AGENT.get_or_init(|| { + ureq::Agent::config_builder() + .timeout_connect(Some(Duration::from_secs(3))) + .timeout_global(Some(Duration::from_secs(5))) + // Inspect status codes ourselves rather than having them raise. + .http_status_as_error(false) + .user_agent(hooks::user_agent()) + .build() + .into() + }) +} + +/// An unauthenticated GET returning the body, for the discovery documents. +pub(crate) fn get_text(url: &str) -> Result { + let mut response = agent() + .get(url) + .header("Accept", "application/json") + .call() + .map_err(|e| e.to_string())?; + let status = response.status().as_u16(); + if status != 200 { + return Err(format!("HTTP {status} from {url}")); + } + response + .body_mut() + .read_to_string() + .map_err(|e| e.to_string()) +} + +#[derive(Deserialize, Default)] +struct OAuthErrorBody { + #[serde(default)] + error: String, + #[serde(default)] + error_description: String, +} + +/// `(error, error_description)` from an OAuth-shaped error body, falling back +/// to the status code when there is no usable body. +pub(crate) fn read_oauth_error( + response: &mut ureq::http::Response, +) -> (String, String) { + let status = response.status().as_u16(); + let body: OAuthErrorBody = response.body_mut().read_json().unwrap_or_default(); + if body.error.is_empty() { + (format!("HTTP {status}"), String::new()) + } else { + (body.error, body.error_description) + } +} + +fn authed_get( + url: &str, + access_token: &str, +) -> Result { + let mut response = agent() + .get(url) + .header("Accept", "application/json") + .header("Authorization", format!("Bearer {access_token}")) + .call() + .map_err(|e| ApiError::Http(e.to_string()))?; + match response.status().as_u16() { + 200 => response + .body_mut() + .read_json() + .map_err(|e| ApiError::Json(e.to_string())), + 401 => Err(ApiError::Unauthorized), + 403 => Err(ApiError::Forbidden(read_oauth_error(&mut response).1)), + 404 => Err(ApiError::NotFound(read_oauth_error(&mut response).1)), + other => { + let (error, description) = read_oauth_error(&mut response); + Err(ApiError::Http(if error.starts_with("HTTP ") { + format!("HTTP {other}") + } else { + format!("HTTP {other}: {error} {description}") + })) + } + } +} + +#[derive(Deserialize)] +struct IngestList { + #[serde(default)] + ingests: Vec, +} + +/// Entries with an id outside the contract are dropped rather than failing +/// the whole list; the provider is told nothing, the user sees the rest. +#[must_use] +pub fn keep_valid(ingests: Vec) -> Vec { + ingests + .into_iter() + .filter(|i| valid_ingest_id(&i.id) && !i.name.trim().is_empty()) + .collect() +} + +pub fn parse_ingest_list(raw: &str) -> Result, ApiError> { + let list: IngestList = serde_json::from_str(raw).map_err(|e| ApiError::Json(e.to_string()))?; + Ok(keep_valid(list.ingests)) +} + +pub(crate) fn list_ingests(endpoint: &str, access_token: &str) -> Result, ApiError> { + let list: IngestList = authed_get(endpoint, access_token)?; + Ok(keep_valid(list.ingests)) +} + +#[derive(Deserialize)] +struct Resolved { + url: String, +} + +#[must_use] +pub fn resolve_endpoint(ingests_endpoint: &str, id: &str) -> String { + format!("{}/{id}/url", ingests_endpoint.trim_end_matches('/')) +} + +pub(crate) fn resolve_url( + ingests_endpoint: &str, + id: &str, + access_token: &str, +) -> Result { + let resolved: Resolved = authed_get(&resolve_endpoint(ingests_endpoint, id), access_token)?; + if resolved.url.trim().is_empty() { + return Err(ApiError::Json("empty url".to_owned())); + } + Ok(resolved.url) +} diff --git a/crates/irl-provider/src/browser.rs b/crates/irl-provider/src/browser.rs new file mode 100644 index 0000000..33307e8 --- /dev/null +++ b/crates/irl-provider/src/browser.rs @@ -0,0 +1,65 @@ +//! Opening a URL in the user's browser, without a dependency. + +use std::process::{Command, Stdio}; + +/// Launch the system browser. Errors come back to the caller so they reach the +/// plugin's log through its own logger. +pub(crate) fn open(url: &str) -> std::io::Result<()> { + // The URL is built from provider-supplied endpoints. Nothing legitimate in + // one contains a quote, whitespace or a control character, and each of + // them is a way to break out of an argument on some platform. + if url + .chars() + .any(|c| c == '"' || c == '\'' || c.is_whitespace() || c.is_control()) + { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "refusing to open a URL containing a quote, whitespace or a control character", + )); + } + let mut command = platform_command(url); + // OBS's stdio belongs to OBS: a browser that inherits it can scribble over + // the log. + command + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + + let mut child = command.spawn()?; + // The launchers below all exit at once; reap on a throwaway thread so the + // launcher never becomes a zombie and the caller never blocks. + std::thread::spawn(move || { + let _ = child.wait(); + }); + Ok(()) +} + +#[cfg(target_os = "macos")] +fn platform_command(url: &str) -> Command { + let mut c = Command::new("open"); + c.arg(url); + c +} + +#[cfg(windows)] +fn platform_command(url: &str) -> Command { + use std::os::windows::process::CommandExt; + /// CREATE_NO_WINDOW: otherwise a console flashes over a fullscreen OBS. + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + // Not `cmd /C start`. cmd re-parses its command line, and an OAuth URL is + // full of `&` (a command separator) and `%XX` (variable expansion). + // rundll32 hands the argument to ShellExecute untouched. + let mut c = Command::new("rundll32"); + c.args(["url.dll,FileProtocolHandler", url]) + .creation_flags(CREATE_NO_WINDOW); + c +} + +#[cfg(all(unix, not(target_os = "macos")))] +fn platform_command(url: &str) -> Command { + // Inside the OBS Flatpak this is the portal-aware xdg-open shim, which is + // what makes the handoff work in the sandbox at all. + let mut c = Command::new("xdg-open"); + c.arg(url); + c +} diff --git a/crates/irl-provider/src/discovery.rs b/crates/irl-provider/src/discovery.rs new file mode 100644 index 0000000..0a4c63b --- /dev/null +++ b/crates/irl-provider/src/discovery.rs @@ -0,0 +1,187 @@ +//! The provider document and the OIDC configuration it points at. + +use std::fmt; +use std::net::IpAddr; + +use serde::{Deserialize, Serialize}; + +use crate::api; + +pub const PROTOCOL_VERSION: u32 = 1; +pub const WELL_KNOWN_PATH: &str = "/.well-known/irl-source-provider.json"; +pub const OIDC_PATH: &str = "/.well-known/openid-configuration"; + +/// `{base}/.well-known/irl-source-provider.json`, validated. +#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] +pub struct ProviderDoc { + pub protocol_version: u32, + pub id: String, + pub name: String, + pub issuer: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub client_id: Option, + #[serde(default = "default_scope")] + pub scope: String, + pub ingests_endpoint: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub min_plugin_version: Option, +} + +fn default_scope() -> String { + "openid".to_owned() +} + +/// The four entries of `{issuer}/.well-known/openid-configuration` the plugin +/// uses. Everything else in that document is ignored. +#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] +pub struct OidcEndpoints { + pub authorization_endpoint: String, + pub token_endpoint: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub registration_endpoint: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub revocation_endpoint: Option, +} + +#[derive(Debug)] +pub enum DiscoveryError { + Http(String), + Json(String), + UnsupportedVersion(u32), + Invalid(&'static str), +} + +impl fmt::Display for DiscoveryError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Http(e) => write!(f, "{e}"), + Self::Json(e) => write!(f, "malformed document: {e}"), + Self::UnsupportedVersion(v) => { + write!( + f, + "protocol version {v} is not supported (this plugin speaks {PROTOCOL_VERSION})" + ) + } + Self::Invalid(what) => write!(f, "document rejected: {what}"), + } + } +} + +/// Trim, drop a trailing slash, and require a URL the plugin is willing to +/// send a token to: `https://`, or plain `http://` on the loopback interface +/// for a provider under development. +#[must_use] +pub fn normalize_base_url(input: &str) -> Option { + let trimmed = input.trim().trim_end_matches('/'); + if trimmed.is_empty() || trimmed.contains(char::is_whitespace) { + return None; + } + if trimmed.starts_with("https://") || is_loopback_http(trimmed) { + Some(trimmed.to_owned()) + } else { + None + } +} + +fn is_loopback_http(url: &str) -> bool { + let Some(rest) = url.strip_prefix("http://") else { + return false; + }; + // A userinfo section is rejected rather than parsed past: the host in + // `http://127.0.0.1:1@attacker.example/` is the attacker's, and nothing + // the plugin talks to needs credentials in the URL. + let authority = rest.split(['/', '?', '#']).next().unwrap_or_default(); + if authority.contains('@') { + return false; + } + let Ok(uri) = url.parse::() else { + return false; + }; + let Some(host) = uri.host() else { + return false; + }; + // An IPv6 host keeps its brackets here. + let host = host + .strip_prefix('[') + .and_then(|h| h.strip_suffix(']')) + .unwrap_or(host); + host.eq_ignore_ascii_case("localhost") + || host.parse::().is_ok_and(|ip| ip.is_loopback()) +} + +#[must_use] +pub fn well_known_url(base_url: &str) -> String { + format!("{}{WELL_KNOWN_PATH}", base_url.trim_end_matches('/')) +} + +#[must_use] +pub fn oidc_url(issuer: &str) -> String { + format!("{}{OIDC_PATH}", issuer.trim_end_matches('/')) +} + +/// `^[a-z0-9-]{1,32}$`. The id names a file on disk and prefixes values in +/// the scene collection, so it is kept to characters that are safe in both. +#[must_use] +pub fn valid_provider_id(id: &str) -> bool { + !id.is_empty() + && id.len() <= 32 + && id + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') +} + +pub fn parse_doc(raw: &str) -> Result { + let doc: ProviderDoc = + serde_json::from_str(raw).map_err(|e| DiscoveryError::Json(e.to_string()))?; + if doc.protocol_version != PROTOCOL_VERSION { + return Err(DiscoveryError::UnsupportedVersion(doc.protocol_version)); + } + if !valid_provider_id(&doc.id) { + return Err(DiscoveryError::Invalid("id must match [a-z0-9-]{1,32}")); + } + if doc.name.trim().is_empty() { + return Err(DiscoveryError::Invalid("name is empty")); + } + if normalize_base_url(&doc.issuer).is_none() { + return Err(DiscoveryError::Invalid("issuer must be an https URL")); + } + if normalize_base_url(&doc.ingests_endpoint).is_none() { + return Err(DiscoveryError::Invalid( + "ingests_endpoint must be an https URL", + )); + } + if doc.scope.trim().is_empty() { + return Err(DiscoveryError::Invalid("scope is empty")); + } + Ok(doc) +} + +pub fn parse_oidc(raw: &str) -> Result { + let oidc: OidcEndpoints = + serde_json::from_str(raw).map_err(|e| DiscoveryError::Json(e.to_string()))?; + for (what, url) in [ + ("authorization_endpoint", Some(&oidc.authorization_endpoint)), + ("token_endpoint", Some(&oidc.token_endpoint)), + ("registration_endpoint", oidc.registration_endpoint.as_ref()), + ("revocation_endpoint", oidc.revocation_endpoint.as_ref()), + ] { + if let Some(url) = url + && normalize_base_url(url).is_none() + { + return Err(DiscoveryError::Invalid(match what { + "authorization_endpoint" => "authorization_endpoint must be an https URL", + "token_endpoint" => "token_endpoint must be an https URL", + "registration_endpoint" => "registration_endpoint must be an https URL", + _ => "revocation_endpoint must be an https URL", + })); + } + } + Ok(oidc) +} + +/// Both documents, over the network. +pub(crate) fn fetch(base_url: &str) -> Result<(ProviderDoc, OidcEndpoints), DiscoveryError> { + let doc = parse_doc(&api::get_text(&well_known_url(base_url)).map_err(DiscoveryError::Http)?)?; + let oidc = parse_oidc(&api::get_text(&oidc_url(&doc.issuer)).map_err(DiscoveryError::Http)?)?; + Ok((doc, oidc)) +} diff --git a/crates/irl-provider/src/hooks.rs b/crates/irl-provider/src/hooks.rs new file mode 100644 index 0000000..acfc692 --- /dev/null +++ b/crates/irl-provider/src/hooks.rs @@ -0,0 +1,66 @@ +//! What the plugin supplies: where state lives, how to log, how to wake the +//! properties dialogs. +//! +//! A struct of plain function pointers rather than a trait object so the +//! crate has nothing to hold on to and the plugin nothing to keep alive. + +use std::path::PathBuf; +use std::sync::OnceLock; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Level { + Info, + Warning, +} + +#[derive(Clone, Debug)] +pub struct Hooks { + /// Directory for the per-provider state files. `None` disables + /// persistence; sign-ins then last until the process exits. + pub state_dir: Option, + /// Goes into `User-Agent` and is compared against `min_plugin_version`. + pub plugin_version: &'static str, + pub log: fn(Level, &str), + /// Called from a worker thread after a sign-in, refresh or sign-out + /// changed what the properties dialog should show. + pub wake_dialogs: fn(), +} + +static HOOKS: OnceLock = OnceLock::new(); + +/// Install the hooks. A second call is ignored: the first caller owns them. +pub fn init(hooks: Hooks) { + let _ = HOOKS.set(hooks); +} + +fn noop_log(_: Level, _: &str) {} +fn noop_wake() {} + +static UNINITIALISED: Hooks = Hooks { + state_dir: None, + plugin_version: "0.0.0", + log: noop_log, + wake_dialogs: noop_wake, +}; + +pub(crate) fn hooks() -> &'static Hooks { + HOOKS.get().unwrap_or(&UNINITIALISED) +} + +pub(crate) fn user_agent() -> String { + format!("obs-irl-source/{}", hooks().plugin_version) +} + +macro_rules! log_info { + ($($arg:tt)*) => { + ($crate::hooks::hooks().log)($crate::hooks::Level::Info, &format!($($arg)*)) + }; +} + +macro_rules! log_warn { + ($($arg:tt)*) => { + ($crate::hooks::hooks().log)($crate::hooks::Level::Warning, &format!($($arg)*)) + }; +} + +pub(crate) use {log_info, log_warn}; diff --git a/crates/irl-provider/src/lib.rs b/crates/irl-provider/src/lib.rs new file mode 100644 index 0000000..9989cce --- /dev/null +++ b/crates/irl-provider/src/lib.rs @@ -0,0 +1,37 @@ +//! The plugin side of `docs/provider-protocol.md`. +//! +//! A provider is a base URL. This crate discovers it, signs the user in over +//! OAuth 2.0 with a loopback redirect, keeps the refresh token and the last +//! ingest list in a per-provider state file, and resolves one ingest id to a +//! pull URL on request. It knows nothing about libobs: the plugin hands it a +//! state directory, a logger and a "wake the dialogs" callback through +//! [`init`], and everything else is plain Rust over blocking HTTP. +//! +//! Two invariants shape the API: +//! +//! - Nothing here is on the streaming path. The receiver reads the `url` +//! setting and only that, so a provider that is down, a session that +//! expired or a plugin that was never signed in cannot stop a stream. +//! - No pull URL is ever written to disk. The list carries names and ids; a +//! URL exists only between [`resolve`] returning and the caller writing it +//! into the OBS setting. +//! +//! The pure parts (`discovery`, `oauth`, `loopback`, `store`, `version`) are +//! public so `tests/` can drive them without a network. + +#![forbid(unsafe_code)] + +pub mod api; +mod browser; +pub mod discovery; +mod hooks; +pub mod loopback; +pub mod oauth; +mod registry; +pub mod store; +pub mod version; + +pub use api::Ingest; +pub use discovery::normalize_base_url; +pub use hooks::{Hooks, Level, init}; +pub use registry::{ProviderView, ResolveError, provider_for, refresh, resolve, sign_in, sign_out}; diff --git a/crates/irl-provider/src/loopback.rs b/crates/irl-provider/src/loopback.rs new file mode 100644 index 0000000..67f5e2e --- /dev/null +++ b/crates/irl-provider/src/loopback.rs @@ -0,0 +1,205 @@ +//! The loopback half of the sign-in (RFC 8252 section 7.3). +//! +//! An OBS plugin is a shared library and cannot register a URL scheme, so the +//! authorization server redirects to `http://127.0.0.1:/callback`. The +//! port comes from a fixed list rather than an ephemeral one because most +//! authorization servers match redirect URIs exactly, so every port the plugin +//! might use has to be registered with the client up front. +//! +//! The deadline uses `std::time::Instant`. The plugin's clock rule exists so +//! media and OBS timestamps share one time base; nothing here is a timestamp. + +use std::collections::HashMap; +use std::io::{Read, Write}; +use std::net::{Ipv4Addr, SocketAddr, TcpListener, TcpStream}; +use std::ops::RangeInclusive; +use std::time::{Duration, Instant}; + +pub const PORTS: RangeInclusive = 47420..=47429; +pub const SIGN_IN_TIMEOUT: Duration = Duration::from_secs(120); + +#[must_use] +pub fn redirect_uri_for(port: u16) -> String { + format!("http://127.0.0.1:{port}/callback") +} + +/// Every redirect URI a client registration has to carry. +#[must_use] +pub fn all_redirect_uris() -> Vec { + PORTS.map(redirect_uri_for).collect() +} + +/// Percent-decoding that leaves `+` alone. Query values here are OAuth codes +/// and nonces, which may be base64url and are never form-encoded spaces. +#[must_use] +pub fn percent_decode(input: &str) -> String { + let bytes = input.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'%' + && i + 2 < bytes.len() + && let (Some(hi), Some(lo)) = (hex_val(bytes[i + 1]), hex_val(bytes[i + 2])) + { + out.push(hi << 4 | lo); + i += 3; + } else { + out.push(bytes[i]); + i += 1; + } + } + String::from_utf8_lossy(&out).into_owned() +} + +fn hex_val(b: u8) -> Option { + match b { + b'0'..=b'9' => Some(b - b'0'), + b'a'..=b'f' => Some(b - b'a' + 10), + b'A'..=b'F' => Some(b - b'A' + 10), + _ => None, + } +} + +/// The query parameters of an HTTP request line +/// (`GET /callback?code=…&state=… HTTP/1.1`). +#[must_use] +pub fn parse_query(request_line: &str) -> HashMap { + let mut out = HashMap::new(); + let Some(target) = request_line.split_whitespace().nth(1) else { + return out; + }; + let Some((_, query)) = target.split_once('?') else { + return out; + }; + for pair in query.split('&') { + if let Some((k, v)) = pair.split_once('=') { + out.insert(percent_decode(k), percent_decode(v)); + } + } + out +} + +/// How a redirect resolves. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Outcome { + Code(String), + /// The server sent `error=…`: the user declined, or the client is not + /// allowed. + Denied(String), + TimedOut, +} + +/// Classify one redirect's parameters against the expected `state`. +/// `None` means "not ours": answer the request and keep waiting. +#[must_use] +pub fn classify(params: &HashMap, state: &str) -> Option { + if params.get("state").is_none_or(|s| s != state) { + return None; + } + if let Some(error) = params.get("error") { + return Some(Outcome::Denied(error.clone())); + } + params + .get("code") + .filter(|c| !c.is_empty()) + .map(|c| Outcome::Code(c.clone())) +} + +pub struct Handoff { + listener: TcpListener, + state: String, +} + +impl Handoff { + /// Bind the first free port in [`PORTS`] on the loopback interface only. + pub fn bind(state: String) -> std::io::Result { + let mut last = None; + for port in PORTS { + match TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, port))) { + Ok(listener) => { + listener.set_nonblocking(true)?; + return Ok(Self { listener, state }); + } + Err(e) => last = Some(e), + } + } + Err(last.unwrap_or_else(|| std::io::Error::other("no loopback port to bind"))) + } + + #[must_use] + pub fn port(&self) -> u16 { + self.listener + .local_addr() + .map(|a| a.port()) + .unwrap_or_default() + } + + /// The `state` value the redirect must echo. + #[must_use] + pub fn state(&self) -> &str { + &self.state + } + + #[must_use] + pub fn redirect_uri(&self) -> String { + redirect_uri_for(self.port()) + } + + /// Accept connections until one carries the expected `state`, or the + /// deadline passes. Every caller gets an answer, so a stray probe does not + /// leave a browser tab hanging. + pub fn wait(&self) -> Outcome { + let deadline = Instant::now() + SIGN_IN_TIMEOUT; + while Instant::now() < deadline { + match self.listener.accept() { + Ok((stream, _)) => { + if let Some(outcome) = self.serve(stream) { + return outcome; + } + } + Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => { + std::thread::sleep(Duration::from_millis(100)); + } + Err(_) => return Outcome::TimedOut, + } + } + Outcome::TimedOut + } + + fn serve(&self, mut stream: TcpStream) -> Option { + stream.set_nonblocking(false).ok()?; + stream.set_read_timeout(Some(Duration::from_secs(2))).ok()?; + + let mut buf = [0u8; 8192]; + let read = stream.read(&mut buf).ok()?; + let request = String::from_utf8_lossy(&buf[..read]); + let line = request.lines().next().unwrap_or_default(); + let outcome = classify(&parse_query(line), &self.state); + + let body = match outcome { + Some(Outcome::Code(_)) => RESPONSE_OK, + _ => RESPONSE_BAD, + }; + let _ = stream.write_all( + format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\n\ + Content-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ) + .as_bytes(), + ); + let _ = stream.flush(); + outcome + } +} + +/// `history.replaceState` drops the code out of the address bar the moment +/// the page loads, so a screen capture does not carry it. +const RESPONSE_OK: &str = "Signed in\ +\ +\ +

Signed in

You can close this tab and go back to OBS.

"; + +const RESPONSE_BAD: &str = "Sign-in failed\ +\ +

Sign-in failed

Start the sign-in again from OBS.

"; diff --git a/crates/irl-provider/src/oauth.rs b/crates/irl-provider/src/oauth.rs new file mode 100644 index 0000000..9c41915 --- /dev/null +++ b/crates/irl-provider/src/oauth.rs @@ -0,0 +1,256 @@ +//! OAuth 2.0 authorization code with PKCE (RFC 7636), refresh, revocation +//! (RFC 7009) and dynamic client registration (RFC 7591). +//! +//! The plugin is a public client: there is no secret to keep, and PKCE is what +//! stops another process on the machine from redeeming a code it intercepted +//! on the loopback redirect. + +use std::fmt; + +use base64::Engine; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use ring::digest; +use ring::rand::{SecureRandom, SystemRandom}; +use serde::Deserialize; + +use crate::api; + +pub const CLIENT_NAME: &str = "OBS IRL Source"; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Pkce { + pub verifier: String, + pub challenge: String, +} + +/// 32 random bytes, base64url: a 43 character verifier, the RFC's minimum. +#[must_use] +pub fn new_pkce() -> Pkce { + let verifier = URL_SAFE_NO_PAD.encode(random_bytes::<32>()); + let challenge = challenge_for(&verifier); + Pkce { + verifier, + challenge, + } +} + +/// `BASE64URL(SHA256(verifier))`, RFC 7636 section 4.2. +#[must_use] +pub fn challenge_for(verifier: &str) -> String { + URL_SAFE_NO_PAD.encode(digest::digest(&digest::SHA256, verifier.as_bytes())) +} + +/// 128 bits, hex. Only ever compared for equality. +#[must_use] +pub fn nonce() -> String { + random_bytes::<16>() + .iter() + .map(|b| format!("{b:02x}")) + .collect() +} + +fn random_bytes() -> [u8; N] { + let mut out = [0u8; N]; + // The OS CSPRNG failing to produce 32 bytes is not a condition the plugin + // can do anything about; a sign-in without a random verifier must not + // start. + SystemRandom::new() + .fill(&mut out) + .expect("the system random source is unavailable"); + out +} + +/// RFC 3986 unreserved characters pass; everything else is `%XX`. +#[must_use] +pub fn percent_encode(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for b in s.bytes() { + match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => { + out.push(b as char); + } + _ => out.push_str(&format!("%{b:02X}")), + } + } + out +} + +pub struct AuthorizeRequest<'a> { + pub endpoint: &'a str, + pub client_id: &'a str, + pub redirect_uri: &'a str, + pub scope: &'a str, + pub state: &'a str, + pub code_challenge: &'a str, +} + +impl AuthorizeRequest<'_> { + /// The URL the browser is opened at. Appends to an endpoint that may + /// already carry a query string. + #[must_use] + pub fn url(&self) -> String { + let sep = if self.endpoint.contains('?') { + '&' + } else { + '?' + }; + let params = [ + ("response_type", "code"), + ("client_id", self.client_id), + ("redirect_uri", self.redirect_uri), + ("scope", self.scope), + ("state", self.state), + ("code_challenge", self.code_challenge), + ("code_challenge_method", "S256"), + ]; + let query: Vec = params + .iter() + .map(|(k, v)| format!("{k}={}", percent_encode(v))) + .collect(); + format!("{}{sep}{}", self.endpoint, query.join("&")) + } +} + +#[derive(Clone, Debug, Deserialize)] +pub struct Tokens { + pub access_token: String, + #[serde(default)] + pub refresh_token: Option, +} + +#[derive(Debug)] +pub enum OAuthError { + Http(String), + /// The server answered with an OAuth error body. `invalid_grant` on a + /// refresh means the session is gone. + Rejected { + error: String, + description: String, + }, + Json(String), +} + +impl OAuthError { + #[must_use] + pub fn is_invalid_grant(&self) -> bool { + matches!(self, Self::Rejected { error, .. } if error == "invalid_grant") + } +} + +impl fmt::Display for OAuthError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Http(e) => write!(f, "{e}"), + Self::Rejected { error, description } if description.is_empty() => write!(f, "{error}"), + Self::Rejected { error, description } => write!(f, "{error}: {description}"), + Self::Json(e) => write!(f, "malformed token response: {e}"), + } + } +} + +fn token_request(token_endpoint: &str, form: &[(&str, &str)]) -> Result { + let mut response = api::agent() + .post(token_endpoint) + .header("Accept", "application/json") + .send_form(form.iter().copied()) + .map_err(|e| OAuthError::Http(e.to_string()))?; + if response.status().as_u16() != 200 { + let (error, description) = api::read_oauth_error(&mut response); + return Err(OAuthError::Rejected { error, description }); + } + response + .body_mut() + .read_json() + .map_err(|e| OAuthError::Json(e.to_string())) +} + +pub(crate) fn exchange_code( + token_endpoint: &str, + client_id: &str, + code: &str, + redirect_uri: &str, + code_verifier: &str, +) -> Result { + token_request( + token_endpoint, + &[ + ("grant_type", "authorization_code"), + ("code", code), + ("redirect_uri", redirect_uri), + ("client_id", client_id), + ("code_verifier", code_verifier), + ], + ) +} + +pub(crate) fn refresh( + token_endpoint: &str, + client_id: &str, + refresh_token: &str, +) -> Result { + token_request( + token_endpoint, + &[ + ("grant_type", "refresh_token"), + ("refresh_token", refresh_token), + ("client_id", client_id), + ], + ) +} + +/// Best effort: the local state is deleted whether or not this reaches the +/// server. +pub(crate) fn revoke(revocation_endpoint: &str, client_id: &str, token: &str) { + let _ = api::agent().post(revocation_endpoint).send_form([ + ("token", token), + ("token_type_hint", "refresh_token"), + ("client_id", client_id), + ]); +} + +#[derive(serde::Serialize)] +struct Registration<'a> { + client_name: &'static str, + redirect_uris: &'a [String], + token_endpoint_auth_method: &'static str, + grant_types: [&'static str; 2], + response_types: [&'static str; 1], + scope: &'a str, +} + +#[derive(Deserialize)] +struct Registered { + client_id: String, +} + +/// RFC 7591, for a provider whose document carries no `client_id`. +pub(crate) fn register_client( + registration_endpoint: &str, + redirect_uris: &[String], + scope: &str, +) -> Result { + let mut response = api::agent() + .post(registration_endpoint) + .header("Accept", "application/json") + .send_json(Registration { + client_name: CLIENT_NAME, + redirect_uris, + token_endpoint_auth_method: "none", + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + scope, + }) + .map_err(|e| OAuthError::Http(e.to_string()))?; + match response.status().as_u16() { + 200 | 201 => {} + _ => { + let (error, description) = api::read_oauth_error(&mut response); + return Err(OAuthError::Rejected { error, description }); + } + } + let registered: Registered = response + .body_mut() + .read_json() + .map_err(|e| OAuthError::Json(e.to_string()))?; + Ok(registered.client_id) +} diff --git a/crates/irl-provider/src/registry.rs b/crates/irl-provider/src/registry.rs new file mode 100644 index 0000000..04b6910 --- /dev/null +++ b/crates/irl-provider/src/registry.rs @@ -0,0 +1,518 @@ +//! Process-wide provider state, and the workers that change it. +//! +//! Lock discipline: the registry mutex is a leaf. Nothing does I/O or calls a +//! hook while holding it, and it is never taken while a plugin lock is held, +//! so it cannot join the receiver's lock order. Readers clone what they need +//! and drop the guard. +//! +//! Everything that talks to the network runs on a throwaway thread, except +//! [`resolve`], which the properties dialog needs an answer from before its +//! callback returns. The threads are not the receiver's workers: a sign-in +//! outlives any one source, and touches the plugin only through the +//! `wake_dialogs` hook after the state file is written. + +use std::collections::{HashMap, HashSet}; +use std::fmt; +use std::sync::OnceLock; + +use parking_lot::Mutex; + +use crate::api::{self, ApiError, Ingest, valid_ingest_id}; +use crate::discovery::{self, normalize_base_url}; +use crate::hooks::{self, log_info, log_warn}; +use crate::loopback::{self, Handoff, Outcome}; +use crate::store::{self, Stored}; +use crate::{browser, oauth, version}; + +struct Entry { + stored: Stored, + /// In memory only. A restart costs one refresh call. + access_token: Option, +} + +#[derive(Default)] +struct Registry { + by_id: HashMap, + /// Base URLs with a browser round-trip in flight, so a second click does + /// not bind a second port and open a second tab. + signing_in: HashSet, +} + +static REGISTRY: OnceLock> = OnceLock::new(); + +fn registry() -> &'static Mutex { + REGISTRY.get_or_init(|| { + let mut reg = Registry::default(); + if let Some(dir) = &hooks::hooks().state_dir { + for stored in store::load_all(dir) { + reg.by_id.insert( + stored.doc.id.clone(), + Entry { + stored, + access_token: None, + }, + ); + } + } + Mutex::new(reg) + }) +} + +fn id_for(reg: &Registry, base_url: &str) -> Option { + reg.by_id + .values() + .find(|e| e.stored.base_url == base_url) + .map(|e| e.stored.doc.id.clone()) +} + +fn persist(stored: &Stored) { + if let Some(dir) = &hooks::hooks().state_dir + && let Err(e) = store::save(dir, stored) + { + log_warn!( + "Could not save the sign-in state for {}: {e}", + stored.doc.name + ); + } +} + +/// What the properties dialog shows for one provider. Read from cached state; +/// building a dialog never touches the network. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ProviderView { + pub id: String, + pub name: String, + pub signed_in: bool, + pub ingests: Vec, + /// The provider's `min_plugin_version`, when this plugin is below it. + pub unsupported: Option, +} + +/// `None` until the provider at `base_url` has been discovered once. +#[must_use] +pub fn provider_for(base_url: &str) -> Option { + let base_url = normalize_base_url(base_url)?; + let reg = registry().lock(); + let entry = reg.by_id.get(&id_for(®, &base_url)?)?; + let doc = &entry.stored.doc; + let unsupported = doc + .min_plugin_version + .as_deref() + .filter(|min| !version::at_least(hooks::hooks().plugin_version, min)) + .map(str::to_owned); + Some(ProviderView { + id: doc.id.clone(), + name: doc.name.clone(), + signed_in: entry.stored.refresh_token.is_some() || entry.access_token.is_some(), + ingests: entry.stored.ingests.clone(), + unsupported, + }) +} + +/// Releases the sign-in slot however the worker ends, panic included; +/// otherwise one panic would wedge the button for the life of the process. +struct SignInSlot(String); + +impl Drop for SignInSlot { + fn drop(&mut self) { + registry().lock().signing_in.remove(&self.0); + } +} + +fn spawn(name: &'static str, f: impl FnOnce() + Send + 'static) { + if let Err(e) = std::thread::Builder::new().name(name.into()).spawn(f) { + log_warn!("Could not start the {name} thread: {e}"); + } +} + +/// Discover the provider, open the browser, wait for the redirect, exchange +/// the code, load the list. Returns at once; the work is on a thread. +pub fn sign_in(base_url: &str) { + let Some(base_url) = normalize_base_url(base_url) else { + log_warn!("The provider URL must start with https://"); + return; + }; + if !registry().lock().signing_in.insert(base_url.clone()) { + log_info!("A sign-in to {base_url} is already in progress; finish it in the browser"); + return; + } + let slot = SignInSlot(base_url.clone()); + spawn("irl-provider-signin", move || { + let _slot = slot; + run_sign_in(&base_url); + (hooks::hooks().wake_dialogs)(); + }); +} + +fn run_sign_in(base_url: &str) { + let (doc, oidc) = match discovery::fetch(base_url) { + Ok(d) => d, + Err(e) => { + log_warn!("Could not read the provider document at {base_url}: {e}"); + return; + } + }; + let name = doc.name.clone(); + + // Remember the documents whatever happens next, so the dialog can name + // the provider and show a version message without a network call. + let client_id = { + let mut reg = registry().lock(); + // A provider that changed its id leaves a file under the old one. + let stale: Vec = reg + .by_id + .values() + .filter(|e| e.stored.base_url == base_url && e.stored.doc.id != doc.id) + .map(|e| e.stored.doc.id.clone()) + .collect(); + for id in stale { + reg.by_id.remove(&id); + if let Some(dir) = &hooks::hooks().state_dir { + store::remove(dir, &id); + } + } + let previous = reg.by_id.remove(&doc.id); + let client_id = doc + .client_id + .clone() + .or_else(|| previous.as_ref().and_then(|e| e.stored.client_id.clone())); + // The session survives the refreshed documents. Every path below can + // return early (the version gate, a refused registration, a browser + // that does not open, a sign-in the user abandons), and clearing it + // here would sign the user out for pressing Sign in a second time. + // A provider reached at another base URL is another origin: its + // refresh token must not be replayed to this one. + let session = previous.filter(|e| e.stored.base_url == base_url); + reg.by_id.insert( + doc.id.clone(), + Entry { + stored: Stored { + base_url: base_url.to_owned(), + doc: doc.clone(), + oidc: oidc.clone(), + client_id: client_id.clone(), + refresh_token: session + .as_ref() + .and_then(|e| e.stored.refresh_token.clone()), + ingests: session + .as_ref() + .map(|e| e.stored.ingests.clone()) + .unwrap_or_default(), + }, + access_token: session.and_then(|e| e.access_token), + }, + ); + client_id + }; + + if let Some(min) = &doc.min_plugin_version + && !version::at_least(hooks::hooks().plugin_version, min) + { + log_warn!( + "{name} needs plugin version {min} or newer; this is {}", + hooks::hooks().plugin_version + ); + persist_entry(&doc.id); + return; + } + + let client_id = match client_id { + Some(id) => id, + None => match &oidc.registration_endpoint { + Some(endpoint) => { + match oauth::register_client(endpoint, &loopback::all_redirect_uris(), &doc.scope) { + Ok(id) => id, + Err(e) => { + log_warn!("{name} refused to register the plugin as a client: {e}"); + return; + } + } + } + None => { + log_warn!("{name} publishes neither a client_id nor a registration endpoint"); + return; + } + }, + }; + set_client_id(&doc.id, &client_id); + persist_entry(&doc.id); + + let handoff = match Handoff::bind(oauth::nonce()) { + Ok(h) => h, + Err(e) => { + log_warn!("Could not listen for the {name} sign-in on a loopback port: {e}"); + return; + } + }; + let pkce = oauth::new_pkce(); + let redirect_uri = handoff.redirect_uri(); + let url = oauth::AuthorizeRequest { + endpoint: &oidc.authorization_endpoint, + client_id: &client_id, + redirect_uri: &redirect_uri, + scope: &doc.scope, + state: handoff.state(), + code_challenge: &pkce.challenge, + } + .url(); + if let Err(e) = browser::open(&url) { + log_warn!("Could not open the browser for the {name} sign-in: {e}"); + return; + } + log_info!("Opened the {name} sign-in in your browser"); + + let code = match handoff.wait() { + Outcome::Code(code) => code, + Outcome::Denied(error) => { + log_warn!("{name} did not complete the sign-in: {error}"); + return; + } + Outcome::TimedOut => { + log_warn!("The {name} sign-in was not completed"); + return; + } + }; + + let tokens = match oauth::exchange_code( + &oidc.token_endpoint, + &client_id, + &code, + &redirect_uri, + &pkce.verifier, + ) { + Ok(t) => t, + Err(e) => { + log_warn!("{name} rejected the sign-in code: {e}"); + return; + } + }; + if tokens.refresh_token.is_none() { + log_warn!("{name} issued no refresh token; the sign-in lasts until OBS closes"); + } + + let ingests = match api::list_ingests(&doc.ingests_endpoint, &tokens.access_token) { + Ok(list) => list, + Err(ApiError::Unauthorized) => { + log_warn!("{name} rejected the new session"); + return; + } + Err(e) => { + // The session is good, only this call failed. Refresh picks the + // list up. + log_warn!("Signed in to {name}, but could not load the ingest list: {e}"); + Vec::new() + } + }; + + log_info!("Signed in to {name}; {} ingest(s) available", ingests.len()); + { + let mut reg = registry().lock(); + if let Some(entry) = reg.by_id.get_mut(&doc.id) { + entry.stored.refresh_token = tokens.refresh_token; + entry.stored.ingests = ingests; + entry.access_token = Some(tokens.access_token); + } + } + persist_entry(&doc.id); +} + +fn set_client_id(id: &str, client_id: &str) { + if let Some(entry) = registry().lock().by_id.get_mut(id) { + entry.stored.client_id = Some(client_id.to_owned()); + } +} + +fn persist_entry(id: &str) { + let snapshot = registry().lock().by_id.get(id).map(|e| e.stored.clone()); + if let Some(stored) = snapshot { + persist(&stored); + } +} + +/// Drop the session but keep the documents and the client id, so the next +/// sign-in needs no discovery and no registration. +fn forget_session(id: &str) { + { + let mut reg = registry().lock(); + if let Some(entry) = reg.by_id.get_mut(id) { + entry.access_token = None; + entry.stored.refresh_token = None; + entry.stored.ingests.clear(); + } + } + persist_entry(id); +} + +/// A fresh access token, or `Unauthorized` after the session was forgotten. +fn refresh_access_token(id: &str) -> Result { + let grant = { + let reg = registry().lock(); + reg.by_id.get(id).and_then(|e| { + Some(( + e.stored.oidc.token_endpoint.clone(), + e.stored.client_id.clone()?, + e.stored.refresh_token.clone()?, + )) + }) + }; + let Some((token_endpoint, client_id, refresh_token)) = grant else { + forget_session(id); + return Err(ApiError::Unauthorized); + }; + match oauth::refresh(&token_endpoint, &client_id, &refresh_token) { + Ok(tokens) => { + { + let mut reg = registry().lock(); + if let Some(entry) = reg.by_id.get_mut(id) { + entry.access_token = Some(tokens.access_token.clone()); + if tokens.refresh_token.is_some() { + entry.stored.refresh_token = tokens.refresh_token; + } + } + } + persist_entry(id); + Ok(tokens.access_token) + } + Err(e) if e.is_invalid_grant() => { + forget_session(id); + Err(ApiError::Unauthorized) + } + // A provider that is briefly unreachable is not a lost session. + Err(e) => Err(ApiError::Http(e.to_string())), + } +} + +/// Run `f` with a valid access token, refreshing once on a 401. A 401 with a +/// freshly refreshed token means the session is gone. +fn with_token(id: &str, f: impl Fn(&str) -> Result) -> Result { + let current = registry() + .lock() + .by_id + .get(id) + .and_then(|e| e.access_token.clone()); + let token = match current { + Some(t) => t, + None => refresh_access_token(id)?, + }; + match f(&token) { + Err(ApiError::Unauthorized) => { + let fresh = refresh_access_token(id)?; + match f(&fresh) { + Err(ApiError::Unauthorized) => { + forget_session(id); + Err(ApiError::Unauthorized) + } + other => other, + } + } + other => other, + } +} + +/// Re-read the ingest list on a thread, then wake the dialogs. +pub fn refresh(base_url: &str) { + let Some(base_url) = normalize_base_url(base_url) else { + return; + }; + spawn("irl-provider-refresh", move || { + let target = { + let reg = registry().lock(); + id_for(®, &base_url).and_then(|id| { + let e = reg.by_id.get(&id)?; + Some(( + id, + e.stored.doc.name.clone(), + e.stored.doc.ingests_endpoint.clone(), + )) + }) + }; + let Some((id, name, endpoint)) = target else { + return; + }; + match with_token(&id, |token| api::list_ingests(&endpoint, token)) { + Ok(ingests) => { + log_info!("Loaded {} ingest(s) from {name}", ingests.len()); + if let Some(entry) = registry().lock().by_id.get_mut(&id) { + entry.stored.ingests = ingests; + } + persist_entry(&id); + } + Err(ApiError::Unauthorized) => { + log_warn!("The {name} session has expired; sign in again") + } + Err(e) => log_warn!("Could not load the ingest list from {name}: {e}"), + } + (hooks::hooks().wake_dialogs)(); + }); +} + +/// Revoke the refresh token (best effort) and forget the session. +pub fn sign_out(base_url: &str) { + let Some(base_url) = normalize_base_url(base_url) else { + return; + }; + spawn("irl-provider-signout", move || { + let target = { + let reg = registry().lock(); + id_for(®, &base_url).and_then(|id| { + let e = reg.by_id.get(&id)?; + Some(( + id, + e.stored.doc.name.clone(), + e.stored.oidc.revocation_endpoint.clone(), + e.stored.client_id.clone(), + e.stored.refresh_token.clone(), + )) + }) + }; + let Some((id, name, revocation, client_id, refresh_token)) = target else { + return; + }; + if let (Some(endpoint), Some(client_id), Some(token)) = + (revocation, client_id, refresh_token) + { + oauth::revoke(&endpoint, &client_id, &token); + } + forget_session(&id); + log_info!("Signed out of {name}"); + (hooks::hooks().wake_dialogs)(); + }); +} + +#[derive(Debug)] +pub enum ResolveError { + NotSignedIn, + Api(ApiError), +} + +impl fmt::Display for ResolveError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::NotSignedIn => f.write_str("not signed in"), + Self::Api(e) => write!(f, "{e}"), + } + } +} + +/// The pull URL for one ingest id. Synchronous: it runs inside the dialog's +/// modified callback, which has to write the URL into the settings it was +/// handed before it returns. Bounded by the agent's timeouts, so a dead +/// provider is a pause of a few seconds, and a refresh on the way adds one +/// more. +pub fn resolve(base_url: &str, ingest_id: &str) -> Result { + let base_url = normalize_base_url(base_url).ok_or(ResolveError::NotSignedIn)?; + if !valid_ingest_id(ingest_id) { + return Err(ResolveError::Api(ApiError::NotFound(String::new()))); + } + let target = { + let reg = registry().lock(); + id_for(®, &base_url).and_then(|id| { + let e = reg.by_id.get(&id)?; + let signed_in = e.stored.refresh_token.is_some() || e.access_token.is_some(); + signed_in.then(|| (id, e.stored.doc.ingests_endpoint.clone())) + }) + }; + let (id, endpoint) = target.ok_or(ResolveError::NotSignedIn)?; + with_token(&id, |token| api::resolve_url(&endpoint, ingest_id, token)) + .map_err(ResolveError::Api) +} diff --git a/crates/irl-provider/src/store.rs b/crates/irl-provider/src/store.rs new file mode 100644 index 0000000..93058aa --- /dev/null +++ b/crates/irl-provider/src/store.rs @@ -0,0 +1,137 @@ +//! Persisted per-provider state: `/.json`. +//! +//! Holds the discovery documents (so a dialog opens without a network call), +//! the client id, the refresh token and the last ingest list. Never a pull +//! URL: the list is names and ids by contract, and the URL the user picked +//! lives only in the OBS setting. +//! +//! Not the OS keyring. `keyring` needs a Secret Service on Linux, which the +//! OBS Flatpak cannot talk to; on macOS a keychain item owned by a plugin +//! inside obs64 prompts for the login password whenever OBS's signature +//! changes, a modal password box mid-stream. A 0600 file is the same trust +//! boundary OBS itself uses for stream keys in `service.json`. + +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +use serde::{Deserialize, Serialize}; + +use crate::api::Ingest; +use crate::discovery::{OidcEndpoints, ProviderDoc, valid_provider_id}; + +#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] +pub struct Stored { + pub base_url: String, + pub doc: ProviderDoc, + pub oidc: OidcEndpoints, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub client_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub refresh_token: Option, + #[serde(default)] + pub ingests: Vec, +} + +fn path_for(dir: &Path, id: &str) -> PathBuf { + dir.join(format!("{id}.json")) +} + +/// A temporary name no other [`save`] can be holding open: two threads can +/// persist the same provider at once (a refresh landing while a sign-out +/// runs), and one fixed name would let one truncate the other's file and +/// rename half a token into place. +fn tmp_path_for(dir: &Path, id: &str) -> PathBuf { + static NEXT: AtomicU64 = AtomicU64::new(0); + let n = NEXT.fetch_add(1, Ordering::Relaxed); + dir.join(format!("{id}.json.{}-{n}{TMP_SUFFIX}", std::process::id())) +} + +/// What marks a file as a half-written state file. [`load_all`] skips it and +/// [`remove`] deletes every one of them. +const TMP_SUFFIX: &str = ".tmp"; + +fn is_tmp_for(name: &str, id: &str) -> bool { + name.starts_with(&format!("{id}.json.")) && name.ends_with(TMP_SUFFIX) +} + +/// Any failure (truncated, half-written by a crash, hand-edited) yields +/// `None` and the provider looks never signed in. Never panics: it runs +/// inside a properties callback, and a panic there unwinds into libobs. +#[must_use] +pub fn parse(raw: &str) -> Option { + let stored: Stored = serde_json::from_str(raw).ok()?; + valid_provider_id(&stored.doc.id).then_some(stored) +} + +/// Every readable state file in `dir`. A file whose name disagrees with the +/// id inside it is skipped: it was not written by [`save`]. +pub fn load_all(dir: &Path) -> Vec { + let Ok(entries) = fs::read_dir(dir) else { + return Vec::new(); + }; + entries + .filter_map(Result::ok) + .filter_map(|entry| { + let path = entry.path(); + let stem = path.file_stem()?.to_str()?.to_owned(); + if path.extension()? != "json" { + return None; + } + let stored = parse(&fs::read_to_string(&path).ok()?)?; + (stored.doc.id == stem).then_some(stored) + }) + .collect() +} + +/// Write atomically: a crash mid-write must not leave a file that reads as +/// "signed in with a truncated token". +pub fn save(dir: &Path, stored: &Stored) -> std::io::Result<()> { + fs::create_dir_all(dir)?; + let path = path_for(dir, &stored.doc.id); + let json = serde_json::to_string_pretty(stored).map_err(std::io::Error::other)?; + + let tmp = tmp_path_for(dir, &stored.doc.id); + let mut file = create_private(&tmp)?; + file.write_all(json.as_bytes())?; + file.sync_all()?; + drop(file); + fs::rename(&tmp, &path) +} + +pub fn remove(dir: &Path, id: &str) { + // The temp files too. A `save` that failed at `rename` left a complete + // token in one, and sign-out exists so the token is gone from the machine. + if let Ok(entries) = fs::read_dir(dir) { + for entry in entries.filter_map(Result::ok) { + let path = entry.path(); + if path + .file_name() + .and_then(|n| n.to_str()) + .is_some_and(|n| is_tmp_for(n, id)) + { + let _ = fs::remove_file(&path); + } + } + } + let _ = fs::remove_file(path_for(dir, id)); +} + +#[cfg(unix)] +fn create_private(path: &Path) -> std::io::Result { + use std::os::unix::fs::OpenOptionsExt; + fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o600) + .open(path) +} + +#[cfg(not(unix))] +fn create_private(path: &Path) -> std::io::Result { + // Windows inherits the parent directory's ACL, and the OBS plugin_config + // directory is already under the user's profile. + fs::File::create(path) +} diff --git a/crates/irl-provider/src/version.rs b/crates/irl-provider/src/version.rs new file mode 100644 index 0000000..1c9a402 --- /dev/null +++ b/crates/irl-provider/src/version.rs @@ -0,0 +1,35 @@ +//! The `min_plugin_version` comparison. +//! +//! Only `major.minor.patch` is compared. A pre-release or build suffix is +//! ignored, so `2.1.0-rc.1` counts as `2.1.0`: a provider that wants to shut +//! out release candidates raises the floor to the next patch instead. + +/// `major.minor.patch` from the front of `s`; `None` if that is not what it +/// starts with. +#[must_use] +pub fn parse(s: &str) -> Option<(u64, u64, u64)> { + let core = s.trim().trim_start_matches('v').split(['-', '+']).next()?; + let mut parts = core.split('.').map(|p| p.parse::().ok()); + let major = parts.next()??; + let minor = parts.next()??; + let patch = parts.next()??; + if parts.next().is_some() { + return None; + } + Some((major, minor, patch)) +} + +/// Whether plugin version `have` satisfies a provider's `need`. +/// +/// A `need` the plugin cannot parse is ignored (`true`): a provider that +/// mistypes its floor should not lock every user out. A `have` that does not +/// parse is treated as too old (`false`), which can only happen to a +/// development build. +#[must_use] +pub fn at_least(have: &str, need: &str) -> bool { + match (parse(have), parse(need)) { + (_, None) => true, + (None, Some(_)) => false, + (Some(h), Some(n)) => h >= n, + } +} diff --git a/crates/irl-provider/tests/api.rs b/crates/irl-provider/tests/api.rs new file mode 100644 index 0000000..e86182f --- /dev/null +++ b/crates/irl-provider/tests/api.rs @@ -0,0 +1,66 @@ +//! The list response as the plugin reads it. + +use irl_provider::api::{parse_ingest_list, resolve_endpoint, valid_ingest_id}; + +#[test] +fn ingest_ids_are_url_and_settings_safe() { + assert!(valid_ingest_id("a1b2:eu")); + assert!(valid_ingest_id("ckx1abc.FIN-2_x")); + assert!(!valid_ingest_id("")); + assert!(!valid_ingest_id("a/b")); + assert!(!valid_ingest_id("srt://host?streamid=play/x")); + assert!(!valid_ingest_id("a b")); + assert!(!valid_ingest_id(&"a".repeat(129))); +} + +#[test] +fn the_list_parses_and_optional_fields_default() { + let list = parse_ingest_list( + r#"{"ingests":[ + {"id":"a1:eu","name":"Main phone","detail":"Europe","online":true,"bitrate_kbps":4200}, + {"id":"c3:eu","name":"Backup phone"} + ]}"#, + ) + .unwrap(); + assert_eq!(list.len(), 2); + assert_eq!(list[0].detail.as_deref(), Some("Europe")); + assert_eq!(list[0].online, Some(true)); + assert_eq!(list[0].bitrate_kbps, Some(4200)); + assert_eq!(list[1].detail, None); + assert_eq!(list[1].online, None); + assert_eq!(list[1].bitrate_kbps, None); +} + +#[test] +fn entries_outside_the_contract_are_dropped_not_fatal() { + let list = parse_ingest_list( + r#"{"ingests":[ + {"id":"ok","name":"Fine"}, + {"id":"has/slash","name":"Bad id"}, + {"id":"blank","name":" "}, + {"id":"also-ok","name":"Also fine","extra":"ignored"} + ]}"#, + ) + .unwrap(); + let ids: Vec<&str> = list.iter().map(|i| i.id.as_str()).collect(); + assert_eq!(ids, ["ok", "also-ok"]); +} + +#[test] +fn an_empty_or_missing_list_is_empty() { + assert!(parse_ingest_list(r#"{"ingests":[]}"#).unwrap().is_empty()); + assert!(parse_ingest_list("{}").unwrap().is_empty()); + assert!(parse_ingest_list("null").is_err()); +} + +#[test] +fn the_resolve_endpoint_hangs_off_the_list_endpoint() { + assert_eq!( + resolve_endpoint("https://api.provider.example/irl-source/ingests", "a1:eu"), + "https://api.provider.example/irl-source/ingests/a1:eu/url" + ); + assert_eq!( + resolve_endpoint("https://api.provider.example/ingests/", "x"), + "https://api.provider.example/ingests/x/url" + ); +} diff --git a/crates/irl-provider/tests/discovery.rs b/crates/irl-provider/tests/discovery.rs new file mode 100644 index 0000000..94b669e --- /dev/null +++ b/crates/irl-provider/tests/discovery.rs @@ -0,0 +1,147 @@ +//! The provider document is the one thing a provider writes by hand, so the +//! validation is where their typos land. + +use irl_provider::discovery::{ + DiscoveryError, normalize_base_url, oidc_url, parse_doc, parse_oidc, valid_provider_id, + well_known_url, +}; + +const DOC: &str = r#"{ + "protocol_version": 1, + "id": "example", + "name": "Example Relays", + "issuer": "https://auth.provider.example", + "client_id": "obs-irl-source", + "scope": "openid", + "ingests_endpoint": "https://api.provider.example/irl-source/ingests", + "min_plugin_version": "2.1.0" +}"#; + +#[test] +fn a_complete_document_parses() { + let doc = parse_doc(DOC).unwrap(); + assert_eq!(doc.id, "example"); + assert_eq!(doc.name, "Example Relays"); + assert_eq!(doc.client_id.as_deref(), Some("obs-irl-source")); + assert_eq!(doc.min_plugin_version.as_deref(), Some("2.1.0")); +} + +#[test] +fn optional_fields_default() { + let doc = parse_doc( + r#"{"protocol_version":1,"id":"x","name":"X","issuer":"https://a.example","ingests_endpoint":"https://b.example/i"}"#, + ) + .unwrap(); + assert_eq!(doc.client_id, None); + assert_eq!(doc.scope, "openid"); + assert_eq!(doc.min_plugin_version, None); +} + +#[test] +fn an_unknown_protocol_version_is_refused() { + let err = + parse_doc(&DOC.replace("\"protocol_version\": 1", "\"protocol_version\": 2")).unwrap_err(); + assert!(matches!(err, DiscoveryError::UnsupportedVersion(2))); +} + +#[test] +fn ids_are_file_and_settings_safe() { + assert!(valid_provider_id("irlserver")); + assert!(valid_provider_id("go-irl-2")); + assert!(!valid_provider_id("")); + assert!(!valid_provider_id("IRLServer")); + assert!(!valid_provider_id("../etc")); + assert!(!valid_provider_id("a b")); + assert!(!valid_provider_id(&"a".repeat(33))); + assert!(matches!( + parse_doc(&DOC.replace("\"id\": \"example\"", "\"id\": \"Bad Id\"")).unwrap_err(), + DiscoveryError::Invalid(_) + )); +} + +#[test] +fn endpoints_must_be_https() { + let plain = DOC.replace( + "https://api.provider.example", + "http://api.provider.example", + ); + assert!(matches!( + parse_doc(&plain).unwrap_err(), + DiscoveryError::Invalid(_) + )); + let plain_issuer = DOC.replace( + "https://auth.provider.example", + "http://auth.provider.example", + ); + assert!(matches!( + parse_doc(&plain_issuer).unwrap_err(), + DiscoveryError::Invalid(_) + )); +} + +#[test] +fn base_urls_are_normalized_and_gated() { + assert_eq!( + normalize_base_url(" https://provider.example/ "), + Some("https://provider.example".to_owned()) + ); + assert_eq!( + normalize_base_url("https://provider.example/dash//"), + Some("https://provider.example/dash".to_owned()) + ); + // A provider under development may run plain http on the loopback + // interface, and nowhere else. + assert!(normalize_base_url("http://127.0.0.1:3000").is_some()); + assert!(normalize_base_url("http://localhost:3000/api").is_some()); + assert!(normalize_base_url("http://provider.example").is_none()); + assert!(normalize_base_url("http://127.0.0.1.evil.example").is_none()); + assert!(normalize_base_url("provider.example").is_none()); + assert!(normalize_base_url("https://pro vider.example").is_none()); + assert!(normalize_base_url("").is_none()); +} + +#[test] +fn loopback_http_reads_the_real_host() { + // Userinfo that looks like a loopback authority; the host is the one + // after the `@`. + assert!(normalize_base_url("http://127.0.0.1:1@attacker.example/").is_none()); + assert!(normalize_base_url("http://localhost@attacker.example/").is_none()); + // IPv6 loopback, with and without a port. + assert_eq!( + normalize_base_url("http://[::1]/"), + Some("http://[::1]".to_owned()) + ); + assert!(normalize_base_url("http://[::1]:3000/api").is_some()); + assert!(normalize_base_url("http://127.1.2.3:3000").is_some()); + assert!(normalize_base_url("http://[2001:db8::1]:3000").is_none()); +} + +#[test] +fn well_known_paths_tolerate_a_trailing_slash() { + assert_eq!( + well_known_url("https://provider.example/"), + "https://provider.example/.well-known/irl-source-provider.json" + ); + assert_eq!( + oidc_url("https://auth.provider.example/api/auth"), + "https://auth.provider.example/api/auth/.well-known/openid-configuration" + ); +} + +#[test] +fn oidc_configuration_needs_only_two_endpoints() { + let oidc = parse_oidc( + r#"{"issuer":"https://a.example","authorization_endpoint":"https://a.example/authorize","token_endpoint":"https://a.example/token","jwks_uri":"https://a.example/jwks"}"#, + ) + .unwrap(); + assert_eq!(oidc.registration_endpoint, None); + assert_eq!(oidc.revocation_endpoint, None); + assert!(parse_oidc(r#"{"authorization_endpoint":"https://a.example/authorize"}"#).is_err()); + assert!(matches!( + parse_oidc( + r#"{"authorization_endpoint":"https://a.example/authorize","token_endpoint":"http://a.example/token"}"# + ) + .unwrap_err(), + DiscoveryError::Invalid(_) + )); +} diff --git a/crates/irl-provider/tests/loopback.rs b/crates/irl-provider/tests/loopback.rs new file mode 100644 index 0000000..cd7c859 --- /dev/null +++ b/crates/irl-provider/tests/loopback.rs @@ -0,0 +1,101 @@ +//! The loopback redirect: parsing, the state check, and one real round-trip +//! over a socket. + +use std::collections::HashMap; +use std::io::{Read, Write}; +use std::net::TcpStream; + +use irl_provider::loopback::{ + Handoff, Outcome, PORTS, all_redirect_uris, classify, parse_query, percent_decode, + redirect_uri_for, +}; + +fn params(pairs: &[(&str, &str)]) -> HashMap { + pairs + .iter() + .map(|(k, v)| ((*k).to_owned(), (*v).to_owned())) + .collect() +} + +#[test] +fn percent_decoding_leaves_plus_alone() { + // An authorization code may be base64, and base64 contains `+`. A form + // decoder would turn it into a space and the code exchange would fail. + assert_eq!(percent_decode("ab%2Bcd+ef"), "ab+cd+ef"); + assert_eq!(percent_decode("%41%42%43"), "ABC"); + assert_eq!(percent_decode("%4"), "%4"); + assert_eq!(percent_decode("%zz"), "%zz"); +} + +#[test] +fn query_parsing_reads_the_request_line() { + let q = parse_query("GET /callback?code=abc%2B1&state=xyz HTTP/1.1"); + assert_eq!(q.get("code").map(String::as_str), Some("abc+1")); + assert_eq!(q.get("state").map(String::as_str), Some("xyz")); + assert!(parse_query("GET /callback HTTP/1.1").is_empty()); + assert!(parse_query("").is_empty()); +} + +#[test] +fn a_redirect_is_only_ours_if_the_state_matches() { + assert_eq!(classify(¶ms(&[("code", "c")]), "s"), None); + assert_eq!( + classify(¶ms(&[("code", "c"), ("state", "other")]), "s"), + None + ); + assert_eq!( + classify(¶ms(&[("code", "c"), ("state", "s")]), "s"), + Some(Outcome::Code("c".to_owned())) + ); + assert_eq!( + classify(¶ms(&[("error", "access_denied"), ("state", "s")]), "s"), + Some(Outcome::Denied("access_denied".to_owned())) + ); + // A matching state with neither code nor error is a malformed redirect, + // not a success. + assert_eq!(classify(¶ms(&[("state", "s")]), "s"), None); + assert_eq!( + classify(¶ms(&[("code", ""), ("state", "s")]), "s"), + None + ); +} + +#[test] +fn every_port_has_a_registered_redirect_uri() { + let uris = all_redirect_uris(); + assert_eq!(uris.len(), PORTS.count()); + assert_eq!(uris[0], "http://127.0.0.1:47420/callback"); + assert_eq!(redirect_uri_for(47429), "http://127.0.0.1:47429/callback"); +} + +#[test] +fn a_real_redirect_is_answered_and_returns_the_code() { + let handoff = Handoff::bind("nonce-1".to_owned()).expect("a loopback port is free"); + let port = handoff.port(); + assert!(PORTS.contains(&port)); + assert_eq!(handoff.redirect_uri(), redirect_uri_for(port)); + + let client = std::thread::spawn(move || { + let mut bodies = Vec::new(); + // First a redirect with the wrong state, which must be answered and + // ignored, then the right one. + for target in [ + "/callback?code=stolen&state=someone-else", + "/callback?code=the-code&state=nonce-1", + ] { + let mut s = TcpStream::connect(("127.0.0.1", port)).unwrap(); + write!(s, "GET {target} HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n").unwrap(); + let mut body = String::new(); + s.read_to_string(&mut body).unwrap(); + bodies.push(body); + } + bodies + }); + + assert_eq!(handoff.wait(), Outcome::Code("the-code".to_owned())); + let bodies = client.join().unwrap(); + assert!(bodies[0].starts_with("HTTP/1.1 200 OK")); + assert!(bodies[0].contains("Sign-in failed")); + assert!(bodies[1].contains("Signed in")); + assert!(bodies[1].contains("history.replaceState")); +} diff --git a/crates/irl-provider/tests/oauth.rs b/crates/irl-provider/tests/oauth.rs new file mode 100644 index 0000000..483266e --- /dev/null +++ b/crates/irl-provider/tests/oauth.rs @@ -0,0 +1,87 @@ +//! PKCE and the authorization URL. The code challenge is what stops another +//! process on the machine from redeeming an intercepted code, so it is pinned +//! to the RFC's own test vector rather than to our own output. + +use irl_provider::oauth::{AuthorizeRequest, challenge_for, new_pkce, nonce, percent_encode}; + +#[test] +fn challenge_matches_rfc_7636_appendix_b() { + assert_eq!( + challenge_for("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"), + "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" + ); +} + +#[test] +fn a_fresh_pkce_pair_is_well_formed_and_unique() { + let a = new_pkce(); + let b = new_pkce(); + // 32 bytes base64url without padding is exactly 43 characters, the RFC's + // minimum verifier length. + assert_eq!(a.verifier.len(), 43); + assert!( + a.verifier + .bytes() + .all(|c| c.is_ascii_alphanumeric() || c == b'-' || c == b'_') + ); + assert_eq!(a.challenge, challenge_for(&a.verifier)); + assert_ne!(a.verifier, b.verifier); +} + +#[test] +fn nonce_is_128_bits_of_hex() { + let n = nonce(); + assert_eq!(n.len(), 32); + assert!(n.bytes().all(|c| c.is_ascii_hexdigit())); + assert_ne!(n, nonce()); +} + +#[test] +fn percent_encoding_keeps_only_unreserved_characters() { + assert_eq!(percent_encode("abcXYZ019-._~"), "abcXYZ019-._~"); + assert_eq!( + percent_encode("http://127.0.0.1:47420/callback"), + "http%3A%2F%2F127.0.0.1%3A47420%2Fcallback" + ); + assert_eq!(percent_encode("openid profile"), "openid%20profile"); + assert_eq!(percent_encode("a&b=c"), "a%26b%3Dc"); +} + +#[test] +fn authorize_url_carries_every_pkce_parameter_encoded() { + let url = AuthorizeRequest { + endpoint: "https://auth.provider.example/oauth2/authorize", + client_id: "obs-irl-source", + redirect_uri: "http://127.0.0.1:47420/callback", + scope: "openid profile", + state: "abc123", + code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", + } + .url(); + assert_eq!( + url, + "https://auth.provider.example/oauth2/authorize?response_type=code\ + &client_id=obs-irl-source\ + &redirect_uri=http%3A%2F%2F127.0.0.1%3A47420%2Fcallback\ + &scope=openid%20profile\ + &state=abc123\ + &code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM\ + &code_challenge_method=S256" + ); +} + +#[test] +fn authorize_url_appends_to_an_existing_query() { + let url = AuthorizeRequest { + endpoint: "https://auth.provider.example/authorize?tenant=x", + client_id: "c", + redirect_uri: "http://127.0.0.1:47420/callback", + scope: "openid", + state: "s", + code_challenge: "ch", + } + .url(); + assert!( + url.starts_with("https://auth.provider.example/authorize?tenant=x&response_type=code&") + ); +} diff --git a/crates/irl-provider/tests/store.rs b/crates/irl-provider/tests/store.rs new file mode 100644 index 0000000..351f7e3 --- /dev/null +++ b/crates/irl-provider/tests/store.rs @@ -0,0 +1,113 @@ +//! The state file: recovery from damage, the round trip, and the promise +//! that no pull URL is in it. + +use std::fs; +use std::path::PathBuf; + +use irl_provider::Ingest; +use irl_provider::discovery::{OidcEndpoints, ProviderDoc}; +use irl_provider::store::{Stored, load_all, parse, remove, save}; + +fn stored(id: &str) -> Stored { + Stored { + base_url: "https://provider.example".to_owned(), + doc: ProviderDoc { + protocol_version: 1, + id: id.to_owned(), + name: "Example".to_owned(), + issuer: "https://auth.provider.example".to_owned(), + client_id: None, + scope: "openid".to_owned(), + ingests_endpoint: "https://api.provider.example/ingests".to_owned(), + min_plugin_version: None, + }, + oidc: OidcEndpoints { + authorization_endpoint: "https://auth.provider.example/authorize".to_owned(), + token_endpoint: "https://auth.provider.example/token".to_owned(), + registration_endpoint: None, + revocation_endpoint: None, + }, + client_id: Some("client".to_owned()), + refresh_token: Some("refresh-secret".to_owned()), + ingests: vec![Ingest { + id: "a1:eu".to_owned(), + name: "Main phone".to_owned(), + detail: Some("Europe".to_owned()), + online: Some(true), + bitrate_kbps: Some(4200), + }], + } +} + +fn temp_dir(tag: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "irl-provider-store-{tag}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let _ = fs::remove_dir_all(&dir); + dir +} + +#[test] +fn damaged_files_read_as_signed_out() { + assert!(parse("").is_none()); + assert!(parse("{").is_none()); + assert!(parse(r#"{"base_url":"https://x","refresh_token":"t"}"#).is_none()); + let bad_id = serde_json::to_string(&stored("Bad Id")).unwrap(); + assert!(parse(&bad_id).is_none()); +} + +#[test] +fn save_and_load_round_trip() { + let dir = temp_dir("roundtrip"); + let a = stored("alpha"); + let b = stored("beta"); + save(&dir, &a).unwrap(); + save(&dir, &b).unwrap(); + + // A file whose name disagrees with the id inside it was not written by + // `save` and is ignored. + fs::write( + dir.join("gamma.json"), + serde_json::to_string(&stored("delta")).unwrap(), + ) + .unwrap(); + fs::write(dir.join("notes.txt"), "not json").unwrap(); + + let mut loaded = load_all(&dir); + loaded.sort_by(|x, y| x.doc.id.cmp(&y.doc.id)); + assert_eq!(loaded, vec![a.clone(), b.clone()]); + + remove(&dir, "alpha"); + assert_eq!(load_all(&dir), vec![b]); + let _ = fs::remove_dir_all(&dir); +} + +#[cfg(unix)] +#[test] +fn the_file_is_owner_only() { + use std::os::unix::fs::PermissionsExt; + let dir = temp_dir("mode"); + save(&dir, &stored("alpha")).unwrap(); + let mode = fs::metadata(dir.join("alpha.json")) + .unwrap() + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o600); + let _ = fs::remove_dir_all(&dir); +} + +#[test] +fn no_pull_url_is_ever_serialized() { + // `Ingest` has no url field, so this is a type-level guarantee; the test + // pins the wire shape so a field added later is a conscious change. + let json = serde_json::to_string(&stored("alpha")).unwrap(); + assert!(!json.contains("srt://")); + assert!(!json.contains("\"url\"")); + assert!(json.contains("refresh-secret")); +} diff --git a/crates/irl-provider/tests/version.rs b/crates/irl-provider/tests/version.rs new file mode 100644 index 0000000..79c1bb2 --- /dev/null +++ b/crates/irl-provider/tests/version.rs @@ -0,0 +1,34 @@ +//! `min_plugin_version`: a provider's floor must never lock users out because +//! it was mistyped, and must not be dodged by a pre-release suffix. + +use irl_provider::version::{at_least, parse}; + +#[test] +fn parses_the_numeric_core_only() { + assert_eq!(parse("2.1.0"), Some((2, 1, 0))); + assert_eq!(parse("v2.1.0"), Some((2, 1, 0))); + assert_eq!(parse("2.1.0-rc.1"), Some((2, 1, 0))); + assert_eq!(parse("2.1.0+build.7"), Some((2, 1, 0))); + assert_eq!(parse(" 10.20.30 "), Some((10, 20, 30))); + assert_eq!(parse("2.1"), None); + assert_eq!(parse("2.1.0.4"), None); + assert_eq!(parse("two"), None); + assert_eq!(parse(""), None); +} + +#[test] +fn comparison_is_numeric_per_component() { + assert!(at_least("2.1.0", "2.1.0")); + assert!(at_least("2.10.0", "2.9.9")); + assert!(at_least("3.0.0", "2.99.99")); + assert!(!at_least("2.0.2", "2.1.0")); + assert!(!at_least("2.1.0-rc.1", "2.1.1")); + assert!(at_least("2.1.0-rc.1", "2.1.0")); +} + +#[test] +fn an_unparseable_floor_is_ignored_and_an_unparseable_plugin_is_too_old() { + assert!(at_least("2.1.0", "soon")); + assert!(at_least("2.1.0", "")); + assert!(!at_least("dev", "2.1.0")); +} diff --git a/crates/irl-source/Cargo.toml b/crates/irl-source/Cargo.toml index 889ec3d..0e542f6 100644 --- a/crates/irl-source/Cargo.toml +++ b/crates/irl-source/Cargo.toml @@ -30,3 +30,4 @@ obs = { workspace = true } irl-ffmpeg = { workspace = true } irl-core = { workspace = true } parking_lot = { workspace = true } +irl-provider = { workspace = true } diff --git a/crates/irl-source/src/lib.rs b/crates/irl-source/src/lib.rs index 898344c..d72c30b 100644 --- a/crates/irl-source/src/lib.rs +++ b/crates/irl-source/src/lib.rs @@ -8,6 +8,7 @@ #[macro_use] pub mod log; +mod providers; mod settings; // Public only so the integration tests under `tests/` (a separate crate) can @@ -45,6 +46,7 @@ fn module_load() -> bool { #[cfg(feature = "deadlocks")] spawn_deadlock_poller(); obs::register_source::(); + providers::init(); irl_info!("IRL Source plugin loaded (version {})", PLUGIN_VERSION); true } diff --git a/crates/irl-source/src/providers.rs b/crates/irl-source/src/providers.rs new file mode 100644 index 0000000..9fcf484 --- /dev/null +++ b/crates/irl-source/src/providers.rs @@ -0,0 +1,380 @@ +//! The Provider dropdown, one ingest picker per provider, and the sign-in +//! buttons: the dialog half of `docs/provider-protocol.md`. The protocol +//! itself is `irl_provider`; this file only builds widgets and forwards +//! clicks. +//! +//! Four libobs behaviours dictate the shape, all read out of +//! `shared/properties-view/properties-view.cpp` and `libobs/obs-source.c`: +//! +//! 1. A non-editable list stores the item's *value* while showing its name. +//! That is what lets an entry read "Main phone · Europe" and store an +//! ingest id. An editable list would store the displayed text. +//! 2. A non-editable list whose saved value matches no item writes item 0 +//! back into settings on dialog open. Every list here therefore has an +//! empty-valued item 0, so a stale value cannot clobber anything. +//! 3. Modified callbacks fire on dialog open, not only on a user change. The +//! ingest picker resets itself to item 0 after writing `url`, so it is a +//! momentary action rather than a second source of truth. +//! 4. Returning `true` from a callback re-creates the widgets from the +//! `obs_properties_t` the dialog already holds; only the `update_properties` +//! signal re-runs the builder, and it has to come from another thread. +//! Sign-in, refresh and sign-out therefore run on a thread and wake the +//! dialogs when done ([`wake_dialogs`]). +//! +//! `url` stays the single source of truth. Nothing on the streaming path knows +//! these settings exist; `tests/provider_seam.rs` pins that. + +use std::ffi::{CStr, CString}; + +use irl_core::consts::{ALLOW_CUSTOM_PROVIDER, BUILTIN_PROVIDERS, SOURCE_ID}; +use irl_provider::{Hooks, Ingest, Level}; +use obs::{ClickAction, ComboType, Data, ModifiedAction, Properties, PropertiesRef, TextType}; +use parking_lot::Mutex; + +use crate::module_text; +use crate::source::IrlSource; + +const KEY_PROVIDER: &CStr = c"provider"; +const KEY_PROVIDER_URL: &CStr = c"provider_url"; +const KEY_URL: &CStr = c"url"; +/// `provider`'s value for the Custom entry. Built-in entries store their base +/// URL instead, so a fork that reorders the list breaks no scene collection. +const VALUE_CUSTOM: &str = "custom"; + +const PREFIX_INGEST: &str = "provider_ingest"; +const PREFIX_SIGN_IN: &str = "provider_sign_in"; +const PREFIX_SIGN_OUT: &str = "provider_sign_out"; +const PREFIX_STATUS: &str = "provider_status"; + +/// What is in the Custom field right now. A button callback gets no settings, +/// and with `OBS_PROPERTIES_DEFER_UPDATE` the typed URL is not saved until OK, +/// so the field's modified callback mirrors it here. Seeded on dialog open, +/// because that is when modified callbacks first fire. +static CUSTOM_URL: Mutex = Mutex::new(String::new()); + +pub fn init() { + irl_provider::init(Hooks { + state_dir: obs::module::config_path(c"providers"), + plugin_version: crate::PLUGIN_VERSION, + log, + wake_dialogs, + }); +} + +fn log(level: Level, msg: &str) { + match level { + Level::Info => irl_info!("{msg}"), + Level::Warning => irl_warn!("{msg}"), + } +} + +/// Ask the frontend to reload any open properties dialog on one of our +/// sources. Handles are turned into owned references before anything is +/// signalled: `obs_enum_sources` holds libobs's source list while the callback +/// runs, and re-entering it is not worth the risk for a cosmetic refresh. +fn wake_dialogs() { + let mut sources = Vec::new(); + obs::source::enum_sources(&mut |source| { + if source.unversioned_id() == SOURCE_ID + && let Some(owned) = source.get_ref() + { + sources.push(owned); + } + true + }); + for source in &sources { + source.handle().update_properties(); + } +} + +pub fn defaults(settings: &Data<'_>) { + settings.set_default_str(KEY_PROVIDER, c""); + settings.set_default_str(KEY_PROVIDER_URL, c""); +} + +/// One entry of the Provider dropdown that has ingests: a built-in provider +/// or the Custom field. Manual URL is the absence of a slot. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Slot { + Builtin(usize), + Custom, +} + +impl Slot { + fn all() -> impl Iterator { + (0..BUILTIN_PROVIDERS.len()) + .map(Slot::Builtin) + .chain(ALLOW_CUSTOM_PROVIDER.then_some(Slot::Custom)) + } + + /// The property id for this slot under `prefix`. + fn key(self, prefix: &str) -> CString { + let key = match self { + Slot::Builtin(i) => format!("{prefix}_{i}"), + Slot::Custom => format!("{prefix}_{VALUE_CUSTOM}"), + }; + CString::new(key).expect("no NUL in a property id") + } + + /// The inverse of [`Slot::key`]. + fn parse(name: &CStr, prefix: &str) -> Option { + let rest = name + .to_str() + .ok()? + .strip_prefix(prefix)? + .strip_prefix('_')?; + if rest == VALUE_CUSTOM { + return ALLOW_CUSTOM_PROVIDER.then_some(Slot::Custom); + } + let i: usize = rest.parse().ok()?; + (i < BUILTIN_PROVIDERS.len()).then_some(Slot::Builtin(i)) + } + + /// What `provider` holds when this slot is selected. + fn value(self) -> &'static str { + match self { + Slot::Builtin(i) => BUILTIN_PROVIDERS[i].base_url, + Slot::Custom => VALUE_CUSTOM, + } + } + + /// The provider base URL, from the settings when the caller has them and + /// from the mirrored Custom field otherwise. + fn base_url(self, settings: Option<&Data<'_>>) -> Option { + match self { + Slot::Builtin(i) => Some(BUILTIN_PROVIDERS[i].base_url.to_owned()), + Slot::Custom => { + let typed = match settings { + Some(s) => s.get_str(KEY_PROVIDER_URL).unwrap_or_default(), + None => CUSTOM_URL.lock().clone(), + }; + irl_provider::normalize_base_url(&typed) + } + } + } +} + +fn cstring(s: &str) -> CString { + CString::new(s.replace('\0', "")).expect("NULs removed") +} + +/// Build the dropdown, the pickers and the buttons. Reads only cached state: +/// this runs on the OBS UI thread, and is also reachable from obs-websocket's +/// `GetInputPropertiesListPropertyItems`. +pub fn add_properties(props: &Properties, instance: Option<&IrlSource>) { + let saved = instance.map(|i| i.handle().settings()); + let saved = saved.as_ref().map(|d| d.data()); + + let list = props.add_string_list(KEY_PROVIDER, module_text(c"Provider"), ComboType::List); + list.add(module_text(c"Provider.Manual"), c""); + for (i, provider) in BUILTIN_PROVIDERS.iter().enumerate() { + list.add(&cstring(provider.name), &cstring(Slot::Builtin(i).value())); + } + if ALLOW_CUSTOM_PROVIDER { + list.add(module_text(c"Provider.Custom"), &cstring(VALUE_CUSTOM)); + } + list.on_modified::(); + props.add_text( + c"provider_help", + module_text(c"ProviderHelp"), + TextType::Info, + ); + if ALLOW_CUSTOM_PROVIDER { + props + .add_text( + KEY_PROVIDER_URL, + module_text(c"ProviderUrl"), + TextType::Default, + ) + .on_modified::(); + } + + for slot in Slot::all() { + // The saved Custom URL wins over the mirror here: on a fresh dialog + // the mirror is empty until the field's callback fires, which is after + // this builder has run. + let base_url = match slot { + Slot::Custom if saved.is_some() => slot.base_url(saved.as_ref()), + _ => slot.base_url(None), + }; + let view = base_url.and_then(|u| irl_provider::provider_for(&u)); + let signed_in = view.as_ref().is_some_and(|v| v.signed_in); + let unsupported = view.as_ref().and_then(|v| v.unsupported.clone()); + + if let Some(min) = &unsupported { + let text = module_text(c"Provider.TooOld") + .to_string_lossy() + .replace("%1", min); + props.add_text(&slot.key(PREFIX_STATUS), &cstring(&text), TextType::Info); + continue; + } + if signed_in { + let picker = props.add_string_list( + &slot.key(PREFIX_INGEST), + module_text(c"Ingest"), + ComboType::List, + ); + picker.add(module_text(c"Ingest.Pick"), c""); + for ingest in view.iter().flat_map(|v| &v.ingests) { + picker.add(&cstring(&label(ingest)), &cstring(&ingest.id)); + } + picker.on_modified::(); + } + props.add_button::( + &slot.key(PREFIX_SIGN_IN), + if signed_in { + module_text(c"Ingest.Refresh") + } else { + module_text(c"Ingest.SignIn") + }, + ); + if signed_in { + props.add_button::( + &slot.key(PREFIX_SIGN_OUT), + module_text(c"Ingest.SignOut"), + ); + } + } + + let selected = saved + .and_then(|d| d.get_str(KEY_PROVIDER)) + .unwrap_or_default(); + apply_visibility(&props.view(), &selected); +} + +/// `name · detail · live 4.2 Mbps`. Only the pieces the provider sent. +fn label(ingest: &Ingest) -> String { + let mut out = ingest.name.clone(); + if let Some(detail) = ingest.detail.as_deref().filter(|d| !d.trim().is_empty()) { + out.push_str(" · "); + out.push_str(detail); + } + match (ingest.online, ingest.bitrate_kbps) { + (Some(true), Some(kbps)) => { + out.push_str(" · "); + out.push_str(&module_text(c"Ingest.Live").to_string_lossy()); + out.push_str(&format!(" {:.1} Mbps", kbps as f64 / 1000.0)); + } + (Some(true), None) => { + out.push_str(" · "); + out.push_str(&module_text(c"Ingest.Live").to_string_lossy()); + } + (Some(false), _) => { + out.push_str(" · "); + out.push_str(&module_text(c"Ingest.Offline").to_string_lossy()); + } + (None, _) => {} + } + out +} + +/// Show the selected slot's widgets and hide every other slot's. +fn apply_visibility(props: &PropertiesRef<'_>, selected: &str) { + if let Some(url) = props.get(KEY_PROVIDER_URL) { + url.set_visible(selected == VALUE_CUSTOM); + } + for slot in Slot::all() { + let visible = slot.value() == selected; + for prefix in [ + PREFIX_STATUS, + PREFIX_INGEST, + PREFIX_SIGN_IN, + PREFIX_SIGN_OUT, + ] { + if let Some(property) = props.get(&slot.key(prefix)) { + property.set_visible(visible); + } + } + } +} + +struct ProviderChanged; + +impl ModifiedAction for ProviderChanged { + fn modified(_: &CStr, props: &PropertiesRef<'_>, settings: &Data<'_>) -> bool { + let selected = settings.get_str(KEY_PROVIDER).unwrap_or_default(); + apply_visibility(props, &selected); + true + } +} + +struct ProviderUrlEdited; + +impl ModifiedAction for ProviderUrlEdited { + fn modified(_: &CStr, _: &PropertiesRef<'_>, settings: &Data<'_>) -> bool { + *CUSTOM_URL.lock() = settings.get_str(KEY_PROVIDER_URL).unwrap_or_default(); + false + } +} + +/// Picking an ingest resolves its URL, writes it into `url`, and resets the +/// picker. Synchronous, on the UI thread: the settings object is only ours +/// for the duration of the callback, and the provider client's timeouts +/// bound the wait. +struct IngestPicked; + +impl ModifiedAction for IngestPicked { + fn modified(name: &CStr, _: &PropertiesRef<'_>, settings: &Data<'_>) -> bool { + let Some(slot) = Slot::parse(name, PREFIX_INGEST) else { + return false; + }; + // `get_str` is `None` for the empty string, so item 0 and the reset + // below both land here and do nothing. That is also what stops the + // rebuild this returns `true` for from looping. + let Some(pick) = settings.get_str(name) else { + return false; + }; + if let Some(base_url) = slot.base_url(Some(settings)) { + match irl_provider::resolve(&base_url, &pick) { + Ok(url) => match CString::new(url) { + Ok(url) => { + crate::log::log_input_url("Provider ingest selected", &url); + settings.set_str(KEY_URL, &url); + } + Err(_) => irl_warn!("The provider returned a URL with a NUL in it"), + }, + Err(e) => irl_warn!("Could not resolve the selected ingest: {e}"), + } + } + settings.set_str(name, c""); + true + } +} + +/// Sign in, or refresh the list once signed in. Returns `false` and does the +/// work on a thread: `true` would only rebuild the widgets from the list the +/// dialog was opened with, and the wake-up that re-runs the builder has to be +/// raised off the UI thread (see the module doc). +struct SignInClicked; + +impl ClickAction for SignInClicked { + fn clicked(name: &CStr) -> bool { + let Some(slot) = Slot::parse(name, PREFIX_SIGN_IN) else { + return false; + }; + let Some(base_url) = slot.base_url(None) else { + irl_warn!("Enter the provider URL (https://…) before signing in"); + return false; + }; + let signed_in = irl_provider::provider_for(&base_url).is_some_and(|v| v.signed_in); + if signed_in { + irl_provider::refresh(&base_url); + } else { + irl_provider::sign_in(&base_url); + } + false + } +} + +struct SignOutClicked; + +impl ClickAction for SignOutClicked { + fn clicked(name: &CStr) -> bool { + if let Some(slot) = Slot::parse(name, PREFIX_SIGN_OUT) + && let Some(base_url) = slot.base_url(None) + { + irl_provider::sign_out(&base_url); + } + false + } +} diff --git a/crates/irl-source/src/settings.rs b/crates/irl-source/src/settings.rs index 4c12f07..39a2297 100644 --- a/crates/irl-source/src/settings.rs +++ b/crates/irl-source/src/settings.rs @@ -14,6 +14,7 @@ use crate::source::IrlSource; /// demuxer options use the constant), so it was removed rather than ported. pub fn defaults(settings: &Data<'_>) { settings.set_default_str(c"url", c""); + crate::providers::defaults(settings); settings.set_default_i64(c"reconnect_delay", consts::DEFAULT_RECONNECT_DELAY_S); settings.set_default_i64(c"buffer_target_ms", consts::DEFAULT_BUFFER_TARGET_MS); @@ -29,7 +30,7 @@ pub fn defaults(settings: &Data<'_>) { } /// `irl_source_get_properties`. -pub fn properties(_instance: Option<&IrlSource>) -> Properties { +pub fn properties(instance: Option<&IrlSource>) -> Properties { let props = Properties::new(); // Without this, the dialog calls update() on every keystroke, so typing a @@ -37,6 +38,10 @@ pub fn properties(_instance: Option<&IrlSource>) -> Properties { props.set_flags(obs::sys::OBS_PROPERTIES_DEFER_UPDATE); // ── General ── + // + // The Provider dropdown and its pickers write into `url`; `url` itself is + // what everything downstream reads. + crate::providers::add_properties(&props, instance); props.add_text(c"url", module_text(c"URL"), TextType::Default); props.add_int( c"reconnect_delay", diff --git a/crates/irl-source/src/source.rs b/crates/irl-source/src/source.rs index b1db880..7f7da04 100644 --- a/crates/irl-source/src/source.rs +++ b/crates/irl-source/src/source.rs @@ -49,6 +49,12 @@ pub struct IrlSource { obs_state: Arc>, } +impl IrlSource { + pub(crate) fn handle(&self) -> SourceHandle { + self.source + } +} + impl Source for IrlSource { const ID: &'static CStr = c"irl_source"; const OUTPUT_FLAGS: u32 = obs::sys::OBS_SOURCE_ASYNC_VIDEO diff --git a/crates/irl-source/tests/locale_keys.rs b/crates/irl-source/tests/locale_keys.rs index fd4062e..2a51611 100644 --- a/crates/irl-source/tests/locale_keys.rs +++ b/crates/irl-source/tests/locale_keys.rs @@ -9,9 +9,10 @@ //! calls into libobs. const LOCALE: &str = include_str!("../../../data/locale/en-US.ini"); -const SOURCES: [(&str, &str); 2] = [ +const SOURCES: [(&str, &str); 3] = [ ("settings.rs", include_str!("../src/settings.rs")), ("source.rs", include_str!("../src/source.rs")), + ("providers.rs", include_str!("../src/providers.rs")), ]; /// Every `module_text(c"…")` argument in a source file. @@ -71,4 +72,5 @@ fn the_scan_finds_the_keys_it_should() { assert!(found.contains(&"CatchUpSpeed")); assert!(found.contains(&"TargetBuffer")); assert!(keys(SOURCES[1].1).contains(&"SourceName")); + assert!(keys(SOURCES[2].1).contains(&"Provider")); } diff --git a/crates/irl-source/tests/provider_seam.rs b/crates/irl-source/tests/provider_seam.rs new file mode 100644 index 0000000..0abd59f --- /dev/null +++ b/crates/irl-source/tests/provider_seam.rs @@ -0,0 +1,116 @@ +//! Providers may feed the properties dialog, and nothing else. +//! +//! The feature is worth nothing if a signed-out user, an expired session or an +//! unreachable provider can stop an already-configured source from streaming. +//! The design keeps that true by making the picker write into the existing +//! `url` setting and touch nothing else, so `url` stays the single source of +//! truth and the receiver never learns providers exist. +//! +//! Two tests: one on the configuration itself, and one structural, in the +//! style of `locale_keys.rs`, because the way this invariant would +//! realistically be lost is someone teaching `Config::load` to read a +//! provider key. + +use std::ffi::CString; +use std::fs; +use std::path::Path; + +use irl_core::{HwDecode, Watermarks, consts}; +use obs_irl_source::config::Config; +use obs_irl_source::shared::{HotValues, StreamConfig}; + +fn url_only(url: &str) -> Config { + Config { + stream: StreamConfig { + url: CString::new(url).unwrap(), + ffmpeg_options: None, + hw_decode: HwDecode::Auto, + low_latency_audio: false, + small_gap_ms: consts::SMALL_GAP_MS, + large_gap_ms: consts::LARGE_GAP_MS, + }, + hot: HotValues { + reconnect_delay_s: consts::DEFAULT_RECONNECT_DELAY_S as i32, + adaptive_speed: consts::DEFAULT_ADAPTIVE_SPEED, + catchup_percent: consts::DEFAULT_CATCHUP_PERCENT as i32, + wait_for_keyframe: consts::DEFAULT_WAIT_FOR_KEYFRAME, + clear_on_disconnect: consts::DEFAULT_CLEAR_ON_DISCONNECT, + watermarks: Watermarks::derive(consts::DEFAULT_BUFFER_TARGET_MS as i32), + }, + close_when_inactive: consts::DEFAULT_CLOSE_WHEN_INACTIVE, + } +} + +#[test] +fn a_url_alone_is_a_runnable_config() { + // Nothing else is consulted: no token, no cached list, no provider. This + // is the scene collection that was saved months ago and still works. + let config = url_only("srt://relay.example:4000?streamid=play/stream/abc"); + assert!(config.url().is_some()); + assert!(!config.requires_restart(&url_only( + "srt://relay.example:4000?streamid=play/stream/abc" + ))); + assert!(config.requires_restart(&url_only( + "srt://relay.example:4000?streamid=play/stream/def" + ))); +} + +/// Every `.rs` under `src/`, relative path and content. +fn sources() -> Vec<(String, String)> { + fn walk(dir: &Path, root: &Path, out: &mut Vec<(String, String)>) { + for entry in fs::read_dir(dir).unwrap().flatten() { + let path = entry.path(); + if path.is_dir() { + walk(&path, root, out); + } else if path.extension().is_some_and(|e| e == "rs") { + let rel = path + .strip_prefix(root) + .unwrap() + .to_string_lossy() + .into_owned(); + out.push((rel, fs::read_to_string(&path).unwrap())); + } + } + } + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + let mut out = Vec::new(); + walk(&root, &root, &mut out); + out +} + +#[test] +fn the_streaming_path_does_not_know_about_providers() { + // If a provider key ever reaches Config, the receiver starts depending on + // sign-in and an expired session takes a stream off the air. The dialog + // (`providers.rs`, `settings.rs`) and the module entry point (`lib.rs`, + // which installs the hooks) are the only places providers may appear. + let allowed = ["providers.rs", "settings.rs", "lib.rs"]; + let mut leaks = Vec::new(); + for (file, src) in sources() { + if allowed.contains(&file.as_str()) { + continue; + } + if src.contains("irl_provider") || src.contains("providers::") { + leaks.push(file); + } + } + assert!( + leaks.is_empty(), + "{leaks:?} reference the provider system; the streaming path must stay independent of sign-in" + ); + + let settings = include_str!("../src/settings.rs"); + assert!( + settings.contains("providers::add_properties"), + "the properties dialog no longer builds the provider widgets" + ); + let config = include_str!("../src/config.rs"); + assert!( + config.contains("get_str(c\"url\")"), + "Config::load no longer reads the `url` key the picker writes into" + ); + assert!( + !config.contains("provider"), + "Config::load reads a provider key; the streaming path must stay independent of sign-in" + ); +} diff --git a/crates/obs-sys/src/lib.rs b/crates/obs-sys/src/lib.rs index 913fcfe..7fdd5ba 100644 --- a/crates/obs-sys/src/lib.rs +++ b/crates/obs-sys/src/lib.rs @@ -606,6 +606,8 @@ unsafe extern "C" { pub fn obs_source_set_async_unbuffered(source: *mut obs_source_t, unbuffered: bool); pub fn obs_source_set_async_decoupled(source: *mut obs_source_t, decouple: bool); pub fn obs_source_media_started(source: *mut obs_source_t); + /// Returns a new reference; release it with [`obs_data_release`]. + pub fn obs_source_get_settings(source: *const obs_source_t) -> *mut obs_data_t; /// Raises `update_properties` on the source; the frontend reloads any open /// properties dialog for it. Only signals, so safe from a worker thread — /// the Qt side hops to the UI thread through a queued connection. @@ -716,6 +718,12 @@ unsafe extern "C" { p: *mut obs_property_t, modified: obs_property_modified_t, ); + pub fn obs_properties_get( + props: *mut obs_properties_t, + property: *const c_char, + ) -> *mut obs_property_t; + pub fn obs_property_set_visible(p: *mut obs_property_t, visible: bool); + pub fn obs_property_name(p: *mut obs_property_t) -> *const c_char; pub fn obs_properties_add_button( props: *mut obs_properties_t, name: *const c_char, diff --git a/crates/obs/src/lib.rs b/crates/obs/src/lib.rs index 7aa47d5..d0c044c 100644 --- a/crates/obs/src/lib.rs +++ b/crates/obs/src/lib.rs @@ -33,7 +33,7 @@ pub use data::{Data, DataArray, OwnedData}; pub use proc::{CallData, ProcCallback, ProcHandler}; pub use properties::{ ClickAction, ComboFormat, ComboType, IntList, IntProperty, ModifiedAction, Properties, - StringList, TextType, + PropertiesRef, Property, StringList, TextType, }; pub use scene::{BoundsType, Scene, SceneItem, TransformInfo, VideoInfo}; pub use source::{ diff --git a/crates/obs/src/properties.rs b/crates/obs/src/properties.rs index c071ab1..ac4a774 100644 --- a/crates/obs/src/properties.rs +++ b/crates/obs/src/properties.rs @@ -84,14 +84,16 @@ impl ComboType { /// *private data*, a plugin-defined type this crate cannot name; a caller that /// needs the source finds it with [`crate::source::enum_sources`]. pub trait ClickAction { - /// Return `true` to re-create the dialog's widgets. + /// `property` is the id the button was added under, so one marker type + /// can serve several buttons. Return `true` to re-create the dialog's + /// widgets. /// /// Only the *widgets*, and only from the `obs_properties_t` that already /// exists; the `get_properties` builder is not re-run. So `true` is right /// when the click changed `settings`, and useless when it changed something /// only the builder reads. For the latter, return `false` and raise /// [`crate::source::SourceHandle::update_properties`] from another thread. - fn clicked() -> bool; + fn clicked(property: &CStr) -> bool; } /// What a property's value change does. Implement on a marker type. @@ -101,36 +103,98 @@ pub trait ClickAction { /// that writes into another property must therefore be idempotent on the /// values it leaves behind. pub trait ModifiedAction { - /// `settings` is live and writable: a modified callback is the one place a - /// property may set another property's value. Return `true` to make the - /// frontend rebuild the widgets from the (possibly mutated) settings. - fn modified(settings: &Data<'_>) -> bool; + /// `property` is the id of the property that changed. `settings` is live + /// and writable: a modified callback is the one place a property may set + /// another property's value. `props` is the dialog being shown, for + /// toggling other properties' visibility. Return `true` to make the + /// frontend rebuild the widgets from the (possibly mutated) settings and + /// visibility. + fn modified(property: &CStr, props: &PropertiesRef<'_>, settings: &Data<'_>) -> bool; +} + +/// The id a property was added under, or `""` for a null property. +fn property_name(property: *mut obs_sys::obs_property_t) -> &'static CStr { + if property.is_null() { + return c""; + } + // SAFETY: live property; libobs returns the NUL-terminated name it owns, + // which lives as long as the properties object the callback runs inside. + let raw = unsafe { obs_sys::obs_property_name(property) }; + if raw.is_null() { + return c""; + } + // SAFETY: non-null and NUL-terminated, see above. + unsafe { CStr::from_ptr(raw) } } unsafe extern "C" fn click_trampoline( _props: *mut obs_sys::obs_properties_t, - _property: *mut obs_sys::obs_property_t, + property: *mut obs_sys::obs_property_t, _data: *mut c_void, ) -> bool { - guard("property button", false, A::clicked) + guard("property button", false, || { + A::clicked(property_name(property)) + }) } unsafe extern "C" fn modified_trampoline( - _props: *mut obs_sys::obs_properties_t, - _property: *mut obs_sys::obs_property_t, + props: *mut obs_sys::obs_properties_t, + property: *mut obs_sys::obs_property_t, settings: *mut obs_sys::obs_data_t, ) -> bool { guard("property modified", false, || { - let Some(settings) = NonNull::new(settings) else { + let (Some(props), Some(settings)) = (NonNull::new(props), NonNull::new(settings)) else { return false; }; - // SAFETY: non-null, and libobs keeps it alive for the duration of the - // callback. + // SAFETY: both non-null, and libobs keeps both alive for the duration + // of the callback. + let props = PropertiesRef(props, PhantomData); let settings = unsafe { Data::from_raw(settings) }; - M::modified(&settings) + M::modified(property_name(property), &props, &settings) }) } +/// A non-owning view of an `obs_properties_t` the frontend holds, as handed +/// to a modified callback. +#[derive(Debug)] +pub struct PropertiesRef<'a>(NonNull, PhantomData<&'a ()>); + +impl PropertiesRef<'_> { + /// `obs_properties_get`: the property named `id`, if the builder added + /// one. + pub fn get(&self, id: &CStr) -> Option> { + // SAFETY: live properties object for `'a`; `id` is NUL-terminated. + let ptr = unsafe { obs_sys::obs_properties_get(self.0.as_ptr(), id.as_ptr()) }; + NonNull::new(ptr).map(|p| Property(p, PhantomData)) + } +} + +/// One property inside a properties object, borrowed from it. libobs owns the +/// property itself. +#[derive(Debug)] +pub struct Property<'p>(NonNull, PhantomData<&'p ()>); + +impl Property<'_> { + /// `obs_property_set_visible`. Takes effect when the widgets are next + /// built: at dialog open, or after a callback returns `true`. + pub fn set_visible(&self, visible: bool) { + // SAFETY: live property owned by the borrowed properties object. + unsafe { obs_sys::obs_property_set_visible(self.0.as_ptr(), visible) }; + } + + /// `obs_property_set_modified_callback`, calling `M::modified` whenever + /// the value changes. + pub fn on_modified(&self) { + // SAFETY: live property; the trampoline is a `'static` fn item. + unsafe { + obs_sys::obs_property_set_modified_callback( + self.0.as_ptr(), + Some(modified_trampoline::), + ); + } + } +} + /// `obs_properties_t` being built. Ownership passes to libobs when the /// `get_properties` shim returns [`Properties::into_raw`]. #[derive(Debug)] @@ -150,10 +214,11 @@ impl Properties { unsafe { obs_sys::obs_properties_set_flags(self.0.as_ptr(), flags) }; } - pub fn add_text(&self, id: &CStr, description: &CStr, kind: TextType) { + pub fn add_text(&self, id: &CStr, description: &CStr, kind: TextType) -> Property<'_> { // SAFETY: live handle; libobs copies both strings and owns the - // returned obs_property_t, which stays inside the properties object. - unsafe { + // returned obs_property_t, which stays inside the properties object + // that the returned handle borrows. + let ptr = unsafe { obs_sys::obs_properties_add_text( self.0.as_ptr(), id.as_ptr(), @@ -161,6 +226,16 @@ impl Properties { kind.to_sys(), ) }; + Property( + NonNull::new(ptr).expect("obs_properties_add_text returned NULL"), + PhantomData, + ) + } + + /// The same object as a non-owning view, so code that runs both at build + /// time and inside a modified callback can take one type. + pub fn view(&self) -> PropertiesRef<'_> { + PropertiesRef(self.0, PhantomData) } pub fn add_int(&self, id: &CStr, description: &CStr, min: i32, max: i32, step: i32) { @@ -256,6 +331,14 @@ impl Properties { ) } + /// `obs_properties_get`: a property added earlier in this build, for + /// setting its initial visibility. + pub fn get(&self, id: &CStr) -> Option> { + // SAFETY: live handle owned by `self`; `id` is NUL-terminated. + let ptr = unsafe { obs_sys::obs_properties_get(self.0.as_ptr(), id.as_ptr()) }; + NonNull::new(ptr).map(|p| Property(p, PhantomData)) + } + /// `obs_properties_add_button`, calling `A::clicked` on press. pub fn add_button(&self, id: &CStr, text: &CStr) { // SAFETY: as above; the trampoline is a `'static` fn item with no diff --git a/crates/obs/src/source.rs b/crates/obs/src/source.rs index 5b63630..e1c2e98 100644 --- a/crates/obs/src/source.rs +++ b/crates/obs/src/source.rs @@ -4,7 +4,7 @@ use core::ffi::{CStr, c_void}; use core::ptr::NonNull; use crate::audio::AudioFrame; -use crate::data::Data; +use crate::data::{Data, OwnedData}; use crate::panic::{guard, guard_unit}; use crate::proc::ProcHandler; use crate::properties::Properties; @@ -366,6 +366,17 @@ impl SourceHandle { cstr_to_string(raw) } + /// `obs_source_get_settings`: the source's saved settings, as a new + /// reference. What a properties dialog with `OBS_PROPERTIES_DEFER_UPDATE` + /// has been typed into is not in here until OK or Apply. + pub fn settings(&self) -> OwnedData { + // SAFETY: live handle; libobs returns a reference this value owns. + let ptr = unsafe { obs_sys::obs_source_get_settings(self.as_ptr()) }; + let ptr = NonNull::new(ptr).expect("obs_source_get_settings returned NULL"); + // SAFETY: a fresh reference, released by `OwnedData::drop`. + unsafe { OwnedData::from_raw(ptr) } + } + /// `obs_source_update_properties`: ask the frontend to reload any open /// properties dialog for this source, re-running the `get_properties` /// builder. diff --git a/data/locale/en-US.ini b/data/locale/en-US.ini index 860d254..d8117aa 100644 --- a/data/locale/en-US.ini +++ b/data/locale/en-US.ini @@ -1,5 +1,18 @@ -SourceName="IRL Source (irlserver.com)" +SourceName="IRL Source" +Provider="Provider" +Provider.Manual="Manual URL" +Provider.Custom="Custom provider" +Provider.TooOld="This provider needs plugin version %1 or newer." +ProviderHelp="Sign in to a provider to pick an ingest by name. Picking one writes its pull URL into URL below. Leave Provider on Manual URL to type the URL yourself." +ProviderUrl="Provider URL" +Ingest="Ingest" +Ingest.Pick="Pick an ingest" +Ingest.SignIn="Sign in" +Ingest.Refresh="Refresh ingests" +Ingest.SignOut="Sign out" +Ingest.Live="live" +Ingest.Offline="offline" URL="URL" ReconnectDelay="Reconnect Delay (s)" diff --git a/deps/build-deps.sh b/deps/build-deps.sh index aeb50d4..03f3a27 100755 --- a/deps/build-deps.sh +++ b/deps/build-deps.sh @@ -70,7 +70,7 @@ if [[ ${host} == windows ]]; then # that directory first fixes it without modifying the MSYS2 install. if ! command -v cl >/dev/null 2>&1; then echo "cl.exe is not on PATH. Run this from an MSVC environment" >&2 - echo "(the CI job uses ilammy/msvc-dev-cmd plus msys2 path-type: inherit)." >&2 + echo "(the CI job runs vcvarsall.bat x64 first, then msys2 with path-type: inherit)." >&2 exit 1 fi PATH="$(dirname "$(command -v cl)"):${PATH}" diff --git a/docs/provider-protocol.md b/docs/provider-protocol.md new file mode 100644 index 0000000..844e563 --- /dev/null +++ b/docs/provider-protocol.md @@ -0,0 +1,125 @@ +# Provider protocol + +A provider is a service that hosts ingests for the plugin's users. The properties dialog signs in to it, lists the user's ingests by name, and writes the chosen pull URL into the URL field. The plugin ships no provider specific code. A service becomes a provider by publishing one JSON document and two HTTP endpoints, and by being an OAuth 2.0 authorization server. + +This document is the contract, version 1. A provider implements it against a host of its own; every URL below is an example. + +## Design constraints + +**The plugin is dumb.** It knows nothing about regions, protocols, stream keys, sharing, or entitlements. Every such decision is made server side and surfaced as a flat list of named entries. If a choice has to be made (which region to pull from), the server either makes it or expands it into separate entries. + +**No keys leave the server until the user picks one entry.** The list response carries names and opaque ids only. The pull URL, which embeds the stream key, is returned by a second call for one id at a time, and the plugin writes it straight into the OBS setting. The plugin never stores a URL in its own state file. + +**Streaming never depends on sign in.** The receiver reads only the URL setting. An expired session, an unreachable provider, or a signed out plugin only stops the dropdown from filling. A scene collection saved months ago keeps working. `crates/irl-source/tests/provider_seam.rs` pins this. + +**The plugin identifies itself.** Every request carries `User-Agent: obs-irl-source/`. The provider decides who gets to use it through the `client_id` it issues and the `min_plugin_version` it publishes. + +## What the user sees + +The dialog starts with a Provider dropdown. The stock build lists the built in providers plus a Custom entry, which reveals a text field for a provider base URL. Below it sit the ingest list and the sign in, refresh and sign out buttons for the selected provider, then the plain URL field. Picking an ingest writes its URL into that field and resets the ingest list to its blank entry, so the pick is an action, not a second source of truth. + +The built in list and the Custom entry are constants in `irl-core`. A fork that ships a build for one provider pins the list to itself and drops Custom; nothing else changes. + +The provider choice and the picked id are OBS settings, so they land in the scene collection. That is why the contract forbids secrets in ids. + +## Discovery + +The plugin fetches, without credentials: + +``` +GET {base}/.well-known/irl-source-provider.json +``` + +```json +{ + "protocol_version": 1, + "id": "example", + "name": "Example Relays", + "issuer": "https://auth.provider.example", + "client_id": "obs-irl-source", + "scope": "openid", + "ingests_endpoint": "https://api.provider.example/irl-source/ingests", + "min_plugin_version": "2.1.0" +} +``` + +- `protocol_version`: the plugin refuses a version it does not know. +- `id`: stable slug, `^[a-z0-9-]{1,32}$`. The plugin names the provider's state file after it and prefixes the ids it stores in OBS settings with it. Changing it signs every user out. +- `name`: the label of this provider in the Provider dropdown. +- `issuer`: the OAuth 2.0 issuer. The plugin loads `{issuer}/.well-known/openid-configuration` and reads `authorization_endpoint`, `token_endpoint`, `registration_endpoint` and `revocation_endpoint` from it. Nothing else in that document is used. +- `client_id`: optional. A pre-registered public client the plugin uses as is. When absent, the plugin registers itself through `registration_endpoint` (RFC 7591) on first sign in and caches the returned id. Pre-registering is preferred: it gives the provider one row to allowlist or revoke. +- `scope`: the scope string the plugin requests, verbatim. +- `ingests_endpoint`: absolute URL of the list endpoint. The resolve endpoint is derived from it. +- `min_plugin_version`: optional semver. An older plugin shows a message instead of a sign in button. + +The document must be served as `application/json`. CORS does not matter; the plugin is not a browser. + +## Sign in + +OAuth 2.0 authorization code with PKCE (`S256`), public client (`token_endpoint_auth_method: none`), loopback redirect per RFC 8252 section 7.3. + +1. The plugin binds `127.0.0.1` on the first free port from `47420` to `47429`. +2. It opens the system browser at `authorization_endpoint` with `response_type=code`, `client_id`, `redirect_uri=http://127.0.0.1:{port}/callback`, `scope`, `state` (a 128 bit nonce), `code_challenge` and `code_challenge_method=S256`. +3. The browser lands on `redirect_uri` with `code` and `state`. The plugin answers with a small "you can close this tab" page and stops listening. +4. The plugin exchanges the code at `token_endpoint` with `grant_type=authorization_code`, `code_verifier`, `redirect_uri` and `client_id`. +5. It stores the refresh token in the plugin's config directory (mode 0600 on macOS and Linux), never in the scene collection. The access token stays in memory. + +A sign in the user does not finish times out after two minutes and the port is released. + +The authorization server must accept all ten loopback redirect URIs for the client. RFC 8252 asks servers to accept any port on a loopback redirect, but many match registered URIs exactly, so the client is registered with all ten. A dynamically registered client sends all ten in `redirect_uris`. + +Refresh: on a 401 from either endpoint below, the plugin calls `token_endpoint` with `grant_type=refresh_token` once and retries. If the refresh fails, the plugin signs out and the dropdown empties. + +Sign out: the plugin posts the refresh token to `revocation_endpoint` (RFC 7009), ignores the result, and deletes its state file. Deleting the state file always happens, so the token is gone from the machine either way. Revocation is best effort: if the call fails, or the provider publishes no revocation endpoint, the token stays valid on the provider until it expires. + +## List ingests + +``` +GET {ingests_endpoint} +Authorization: Bearer {access_token} +Accept: application/json +``` + +```json +{ + "ingests": [ + { "id": "a1b2:eu", "name": "Main phone", "detail": "Europe", "online": true, "bitrate_kbps": 4200 }, + { "id": "a1b2:us", "name": "Main phone", "detail": "US East", "online": true, "bitrate_kbps": 4200 }, + { "id": "c3d4:eu", "name": "Backup phone", "detail": "Europe", "online": false } + ] +} +``` + +- `id`: opaque to the plugin, `^[A-Za-z0-9._:-]{1,128}$`, stable across calls for the same entry. It is what the dropdown stores, so it must not contain a secret. +- `name`: required. Primary text. +- `detail`: optional secondary text. Region, owner of a shared ingest, anything the server wants the user to see. +- `online`, `bitrate_kbps`: optional. Absent means unknown and the plugin shows nothing for it. Serve status from a cache; the plugin gives the call five seconds. +- Order is display order. Put the likely pick first. + +One entry per thing the user can pull. If an ingest is reachable from several regions, the server returns one entry per region and says which in `detail`. The plugin does not group, sort, or dedupe. + +Responses other than 200 use the OAuth error shape: `{ "error": "…", "error_description": "…" }`. 401 triggers the refresh above. Anything else is logged and the cached list stays. + +## Resolve one ingest + +``` +GET {ingests_endpoint}/{id}/url +Authorization: Bearer {access_token} +``` + +```json +{ "url": "srt://relay.provider.example:4000?streamid=play/stream/abc123" } +``` + +The plugin writes `url` into its URL setting and forgets it. The server may mint, rotate, or scope the key inside the URL however it likes; nothing in the plugin depends on the URL's shape beyond FFmpeg being able to open it. + +- 404: the id is unknown or the user no longer has access to it. The plugin leaves the URL setting alone and refreshes the list. +- 403: the user may see this ingest but not pull it. Same handling as 404, with `error_description` in the OBS log. + +## Timeouts and identification + +Every request has a 3 second connect timeout and a 5 second total timeout, and carries `User-Agent: obs-irl-source/`. + +## Requirements on the authorization server + +OIDC discovery, authorization code grant with PKCE, refresh tokens, public clients, and either RFC 7591 registration or a pre-registered `client_id`. A custom redirect that hands a session token to the loopback listener directly is not supported; the plugin speaks OAuth only. diff --git a/scripts/verify-plugin.sh b/scripts/verify-plugin.sh index e3e429f..785b3e9 100755 --- a/scripts/verify-plugin.sh +++ b/scripts/verify-plugin.sh @@ -37,7 +37,7 @@ echo "verifying ${module}" # crates/ffmpeg; the compiler enforces #![forbid(unsafe_code)] where present, # this only catches the attribute being deleted. repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -for f in crates/irl-core/src/lib.rs crates/irl-source/src/lib.rs; do +for f in crates/irl-core/src/lib.rs crates/irl-provider/src/lib.rs crates/irl-source/src/lib.rs; do if [[ -f ${repo_root}/${f} ]]; then grep -q '^#!\[forbid(unsafe_code)\]' "${repo_root}/${f}" && r=0 || r=1 check ${r} "forbid(unsafe_code) present in ${f}"