diff --git a/.cargo/config.toml b/.cargo/config.toml index d9d33e9..77c4490 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,6 +1,11 @@ -# audiopus_sys ships a libopus whose CMake files predate CMake 4. [env] +# audiopus_sys ships a libopus whose CMake files predate CMake 4. CMAKE_POLICY_VERSION_MINIMUM = "3.5" +# `cargo test` and `cargo run` keep settings, identities and logs under +# target/ rather than in the real per-user data directory, so a test run +# never reads or rewrites an installed BroLink's files. A shipped binary +# does not see this; see `brolink_core::config::data_dir`. +BROLINK_DATA_DIR = { value = "target/brolink-data", relative = true } # The host is a standalone exe. Without this it needs vcruntime140.dll, # which is not inbox on a fresh Windows install. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5572d4d..301e77e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,6 +24,34 @@ jobs: run: cargo clippy --workspace --all-targets -- -D warnings - name: test run: cargo test --workspace + # The setup script is generated, so only a dump of it can be parsed. + # Every plan shape, with names that need quoting, through the parser + # of the Windows PowerShell that runs it; plus the shipped scripts. + - name: PowerShell scripts parse + shell: powershell + run: | + $ErrorActionPreference = 'Stop' + $env:BROLINK_DUMP_EXE = "C:\Users\O'Brien `$lab\AppData\Local\BroLink\brolink-host.exe" + $env:BROLINK_DUMP_ADAPTER = "Ethernet 'Realtek' `"2`" `$(x)" + $env:BROLINK_DUMP_ADAPTER_DESC = "Realtek PCIe GbE (`"x`")" + $files = @('scripts\install-host.ps1', 'crates\host\windows\take-over-engine.ps1') + foreach ($migrate in '0', '1') { + foreach ($dry in '0', '1') { + $out = Join-Path $env:RUNNER_TEMP "setup-$migrate$dry.ps1" + $env:BROLINK_DUMP_SETUP_SCRIPT = $out + $env:BROLINK_DUMP_MIGRATE = $migrate + $env:BROLINK_DUMP_DRY_RUN = $dry + cargo test -q -p brolink-host --lib the_generated_script_can_be_dumped_for_a_syntax_check + if ($LASTEXITCODE -ne 0) { throw "dumping the setup script failed" } + $files += $out + } + } + foreach ($f in $files) { + $errors = $null + [System.Management.Automation.Language.Parser]::ParseFile((Resolve-Path $f), [ref]$null, [ref]$errors) | Out-Null + if ($errors.Count -gt 0) { $errors | Format-List | Out-String | Write-Host; throw "$f does not parse" } + Write-Host "parses: $f" + } - name: release build run: cargo build --workspace --release - uses: actions/upload-artifact@v4 @@ -42,9 +70,9 @@ jobs: components: clippy - uses: Swatinem/rust-cache@v2 - name: clippy - run: cargo clippy -p brolink-core -p brolink-stream -p brolink-client -p brolink-host --all-targets -- -D warnings + run: cargo clippy --workspace --all-targets -- -D warnings - name: test - run: cargo test -p brolink-core -p brolink-stream -p brolink-client -p brolink-host + run: cargo test --workspace - name: release app run: cargo build --release -p brolink-host --target aarch64-apple-darwin - name: bundle @@ -59,11 +87,11 @@ jobs: components: clippy - uses: Swatinem/rust-cache@v2 - name: native deps - run: sudo apt-get update && sudo apt-get install -y --no-install-recommends cmake clang nasm pkg-config libssl-dev libasound2-dev libpulse-dev libxkbcommon-dev libwayland-dev libx11-dev libxcursor-dev libxrandr-dev libxi-dev libx11-xcb-dev libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev + run: sudo apt-get update && sudo apt-get install -y --no-install-recommends cmake clang nasm pkg-config libasound2-dev libxkbcommon-dev libwayland-dev libx11-dev libxcursor-dev libxrandr-dev libxi-dev libx11-xcb-dev libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev - name: clippy - run: cargo clippy -p brolink-core -p brolink-stream -p brolink-client -p brolink-host --all-targets -- -D warnings + run: cargo clippy --workspace --all-targets -- -D warnings - name: test - run: cargo test -p brolink-core -p brolink-stream -p brolink-client -p brolink-host + run: cargo test --workspace - name: release host run: cargo build --release -p brolink-host diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 08e80f6..68ba400 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,7 +28,7 @@ jobs: - uses: Swatinem/rust-cache@v2 # Never ship a binary whose tests were not run. - name: test - run: cargo test -p brolink-core -p brolink-stream -p brolink-client -p brolink-host + run: cargo test --workspace - name: build app run: cargo build --release -p brolink-host --target aarch64-apple-darwin # bundle-macos.sh ad-hoc signs and verifies the bundle itself. diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b8f0f2..b037b03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,84 @@ The release workflow publishes the section that matches the tag as the GitHub release notes, so each version gets a heading of the form `## X.Y.Z (date)`. +## Unreleased + +- The control service again answers only machines signed in to the same + Tailscale account. Since 4.0.0 it answered any machine Tailscale could + name, so a machine shared in from someone else's tailnet could pair with + the PC, sleep or restart it, read and write its clipboard, and push it a + new `brolink-host.exe`. A tagged server such as the relay VPS belongs to + no account, so it answers the members of its own tailnet, which keeps a + Mac able to open the VPS desktop. +- Text copied on a Mac or Linux machine you are streaming from now reaches + the machine you are watching on. Only Windows reported a clipboard + sequence number; everywhere else it was always 0, so the viewer took + every copy for the one it had already seen. On a Linux desktop, text + pasted in from the viewer also stays on the clipboard now instead of + vanishing when no clipboard manager picks it up. +- A Windows PC whose pushed update crashes on start keeps the version it + had. The service used to exit as soon as the new executable had been + launched, so a replacement that died straight away left the PC with no + BroLink service until someone logged in again. It now waits two seconds, + and puts the previous executable back if the new one has already exited. +- Update downloads try each of a server's addresses in turn. Only the first + one the name resolved to was ever tried, so one unreachable address (an + IPv6 one on a network without IPv6, say) failed the whole update check. +- `brolink-host.exe`'s version details (Explorer's Details tab) named the + wrong licence, MIT; they now say GPL-3.0-or-later, the licence BroLink + and moonlight-common-c are under. +- Windows setup no longer breaks on a network adapter whose name holds a + `"` or a `$`. The name was written into a double-quoted PowerShell + string, where a `"` ends the string (a syntax error that stops the whole + setup) and a `$` expands. It is now only ever a single-quoted literal. +- The macOS install scripts restart the background service after putting + the new app in place. It kept running the old version until the next + login, so a Mac that shares its desktop served other machines from the + previous release. +- A Windows machine no longer runs the Mac's updater. Since 4.0 every + machine runs the same window, so a Windows PC also checked GitHub and + would try to send other Windows PCs their update with a Mac-only unzip, + failing every few minutes. Its settings now say that updates arrive + from the Mac. +- Installing BroLink Host through the stream (for a PC on 3.0) can finish + on a Mac. The Mac served the file from a socket that macOS left + non-blocking, so sending the executable stopped as soon as the network + buffer filled and the PC rejected the truncated download. +- **Share this machine** works on a Windows account whose folder has a + space in it ("C:\Users\Ada Lovelace"). The elevated setup was handed + that folder through PowerShell's Start-Process, which splits unquoted + arguments at spaces, so it received half a path and an unexpected + extra argument and failed. +- The service log keeps what matters. Every machine that polled the + service was logged again each minute ("hermes (…) asked" was 2,707 of + the 2,810 lines in this Mac's `service.log`), and every change of the + nearest Tailscale relay region logged a network line. Each peer is now + logged when first seen, when its answer changes or after an hour away, + and the network line only when the NAT itself changes. +- When the Mac's sound output changes mid-stream (AirPods connect, say), + the stream's stats name the device the sound moved to. macOS already + moved it; BroLink kept naming the old one and logged a warning. +- A host update that lands is no longer reported as a failure. A PC can + take the new executable and restart before its reply reaches the Mac; + the Mac then retried, heard "this PC already runs BroLink Host 4.0.2", + and showed "Could not update" in red. It now asks the PC which version + it runs before calling a push failed. +- Cancel stops a connection attempt straight away while the PC is not + answering. The pairing check retried eight times regardless, which could + hold the window on "Checking pairing…" for most of a minute after Cancel. +- A Windows or Linux viewer no longer calls its stream HEVC: those decode + H.264 only, and the toolbar named the codec the PC could offer instead + of the one in use. +- The check that notices a black picture costs the decoder 0.14 ms a frame + instead of 2.5 ms at 3024×1964 when the picture really is black (15% of + a core at 60 fps, on the path every frame takes to the screen). +- An idle window redraws about once a second instead of two or three + times, about a third less CPU while the lobby sits open. +- A Mac or Linux machine that shares its desktop no longer runs a program + (`scutil` or `hostname`) for every status request it answers. +- When the picture is black, the PC's explanation gets a minute to arrive + instead of 25 seconds, which its slower checks sometimes needed. + ## 4.0.2 (2026-09-22) - `panel.log` and `service.log` no longer grow without end. Both were diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 49970ed..00d4542 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -16,12 +16,21 @@ cargo test --workspace example `BROLINK_SKIP_C=1 cargo check -p brolink-client --target aarch64-apple-darwin` from Windows after `rustup target add`. +`cargo test` and `cargo run` keep BroLink's settings, pairing identity and +logs in `target/brolink-data` (`.cargo/config.toml` sets +`BROLINK_DATA_DIR`), so neither touches an installed BroLink's files. To +point a development build at an installed one's data on purpose, set +`BROLINK_DATA_DIR` yourself, for example to `%LOCALAPPDATA%\BroLink` on +the development PC before `cargo run -p brolink-host -- --background`. + ## Where things live - `crates/stream`: everything that talks to Sunshine. `nvhttp` is the pairing and launch API, `session` drives moonlight-common-c through the C shim in `csrc/`, `video` and `audio` decode. It knows nothing about - windows or Tailscale. + windows or Tailscale. The vendored moonlight-common-c, what was trimmed + from it and its two local patches are described in + `third_party/moonlight-common-c/VERSION`. - `crates/client`: the viewer UI (a library plus `brolink-client`). `session.rs` is the path from a listed machine to a live stream; `stream.rs` is the toolbar and input while streaming; `video.rs` draws @@ -50,7 +59,8 @@ renders every screen to `target/ui-snapshots/*.png`. ## Tests against a real Sunshine With Sunshine running on the development PC and BroLink Host's service -started (`cargo run -p brolink-host -- --background`): +started (`cargo run -p brolink-host -- --background`, pointed at the +installed data directory as above so it has the engine login): ```bash cargo test -p brolink-stream pair_real -- --ignored --nocapture # five-phase pairing @@ -74,6 +84,12 @@ BROLINK_TEST_PC='Gaming-PC' cargo test -p brolink-client stream_snapshot -- --ig cargo test -p brolink-client decoded_frame_reaches -- --ignored ``` +`stream_real` streams from another machine too: `BROLINK_TEST_IP` is its +Tailscale address, `BROLINK_TEST_DIR` a directory holding copies of the files in the +Mac's `identity/` directory and the PC's certificate as `server.der`, and +`BROLINK_TEST_WIDTH`, `_HEIGHT`, `_FPS`, `_BITRATE_KBPS`, `_SECONDS` and +`BROLINK_TEST_HEVC=1` shape the stream. + `BROLINK_TEST_CODEC=h264` or `hevc` selects the codec. A black capture fails the live test by default; `BROLINK_TEST_EXPECT_BLACK=1` instead verifies the persistent black-picture diagnosis when testing a PC without an active diff --git a/Cargo.lock b/Cargo.lock index ff76c22..c4f01d0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -740,8 +740,6 @@ dependencies = [ "brolink-ui", "clap", "eframe", - "egui", - "egui-wgpu", "egui_kittest", "image", "parking_lot", @@ -774,7 +772,6 @@ dependencies = [ "rcgen", "rsa", "rustls", - "serde", "sha2", "tracing", "x509-parser", diff --git a/NOTICE b/NOTICE index 74d9482..69ddc22 100644 --- a/NOTICE +++ b/NOTICE @@ -10,8 +10,8 @@ Compiled into the client (crates/stream): Includes ENet (MIT, Lee Salzman) and nanors (MIT, sleepybishop). Opus, BSD-3-Clause, Xiph.Org Foundation; statically linked through the audiopus_sys crate. https://opus-codec.org - OpenH264, BSD-2-Clause, Cisco Systems. Only the Windows development - client uses it, as a software H.264 decoder; the Mac uses VideoToolbox. + OpenH264, BSD-2-Clause, Cisco Systems. Only a Windows or Linux viewer + uses it, as a software H.264 decoder; the Mac uses VideoToolbox. It is built from source, so Cisco's binary patent grant does not cover it. https://github.com/cisco/openh264 diff --git a/README.md b/README.md index 7e5b597..02bf0a4 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,8 @@ cp deploy/node/env.example deploy/node/.env docker compose -f deploy/node/docker-compose.yml up -d --build ``` +A desktop can also run natively under systemd on the VPS, beside the +relay, as this project's own VPS does: [deploy/native/README.md](deploy/native/README.md). The packet relay (when two NATs cannot punch through) is a separate kit: [deploy/relay/RELAY.md](deploy/relay/RELAY.md). @@ -155,9 +157,12 @@ Ethernet is strongly preferred; most Wi-Fi adapters cannot wake a PC. - The host's control service listens on TCP 47850 and answers only loopback and Tailscale addresses that `tailscale whois` attributes to the - account the PC is signed in as. Everyone else gets a 403. The firewall - rule setup adds is scoped to `100.64.0.0/10`. -- The stream is BroLink's stream protocol over Tailscale (WireGuard), with + account the PC is signed in as. A tagged server (the relay VPS) belongs + to no account and answers the members of its own tailnet. Everyone else, + including machines shared in from other tailnets, gets a 403. The + firewall rule setup adds is scoped to `100.64.0.0/10`. +- The stream is the GameStream protocol (moonlight-common-c on the viewer, + Sunshine on the host), encrypted, inside Tailscale (WireGuard), with certificate pairing on top; BroLink pins the PC's certificate after the first pairing. - No BroLink account or password exists. The engine login BroLink @@ -194,8 +199,9 @@ crates/host node: control service, engine setup, unified window (view + shar crates/client viewer UI: machine list, wake, pair, stream window and toolbar crates/ui theme and widgets shared by both windows deploy/node/ Docker kit: virtual desktop + engine + BroLink on a VPS +deploy/native/ systemd user units for the same desktop without Docker deploy/relay/ Docker kit: Tailscale peer relay -third_party/ moonlight-common-c (GPL-3.0), vendored +third_party/ moonlight-common-c (GPL-3.0), vendored; VERSION says what and why docs/ platform notes scripts/ installers and the macOS bundle ``` diff --git a/crates/client/src/app.rs b/crates/client/src/app.rs index a3192b6..bdad3a4 100644 --- a/crates/client/src/app.rs +++ b/crates/client/src/app.rs @@ -502,7 +502,8 @@ impl eframe::App for ClientApp { return; } - ctx.request_repaint_after(Duration::from_millis(400)); + // Workers repaint when something changes; this only ages notices. + ctx.request_repaint_after(Duration::from_secs(1)); if self.tailscale_checked.elapsed() > Duration::from_secs(2) { self.tailscale_checked = Instant::now(); self.tailscale_ok = tailscale::cli().is_some(); @@ -611,7 +612,7 @@ impl eframe::App for ClientApp { ui.add_space(14.0); self.pcs_card(ui, ctx, &disc, &prog); ui::titled_card(ui, "Next session", None, |ui| { - let settings = path::effective(&self.cfg.stream, &path::Path::default()); + let settings = path::effective(&self.cfg.stream); let (w, h) = settings.resolution.pixels(Self::native_pixels(ctx)); ui.horizontal_wrapped(|ui| { ui::status_pill(ui, &format!("{w} × {h}"), Tone::Info); @@ -2109,7 +2110,7 @@ mod snapshots { rtt_ms: Some(210), ..Default::default() }; - let settings = crate::path::effective(&crate::config::StreamSettings::default(), &path); + let settings = crate::path::effective(&crate::config::StreamSettings::default()); let session = brolink_stream::Session::start( brolink_stream::session::Server { address: "10.255.255.1".into(), @@ -2124,7 +2125,6 @@ mod snapshots { fps: 30, bitrate_kbps: 4000, hevc: true, - remote: true, }, [0; 16], [0; 16], diff --git a/crates/client/src/config.rs b/crates/client/src/config.rs index b4b476a..ddc0e1f 100644 --- a/crates/client/src/config.rs +++ b/crates/client/src/config.rs @@ -223,8 +223,8 @@ impl ClientConfig { c } - /// Keep a hand-edited or older file within what the UI offers. The - /// bitrate floor is the slider's 2 Mbps, under the Smooth preset's 4. + /// Keep a hand-edited or older file within what the UI offers: the + /// frame rates it lists and the bitrate slider's 2 to 150 Mbps. fn normalise(&mut self) { self.stream.fps = self.stream.fps.clamp(30, 240); self.stream.bitrate_kbps = self.stream.bitrate_kbps.clamp(2_000, 150_000); diff --git a/crates/client/src/display.rs b/crates/client/src/display.rs index 7355814..1b6aaaf 100644 --- a/crates/client/src/display.rs +++ b/crates/client/src/display.rs @@ -12,7 +12,11 @@ use serde_json::Value; use std::net::Ipv4Addr; use std::time::Duration; +/// Turning HDR off answers within seconds. const TIMEOUT: Duration = Duration::from_secs(25); +/// The report is slower: PowerShell with WMI and a screen sample, then two +/// calls to the engine's web API of up to 8 s each. +const REPORT_TIMEOUT: Duration = Duration::from_secs(60); /// What the PC said, in a form the stream's notice can show. #[derive(Debug, Clone, Default, PartialEq, Eq)] @@ -30,7 +34,7 @@ pub struct Help { } pub fn ask(ip: Ipv4Addr) -> Result { - http::get_json::((ip, CONTROL_PORT), "/v1/display", TIMEOUT) + http::get_json::((ip, CONTROL_PORT), "/v1/display", REPORT_TIMEOUT) } /// Turn the PC's HDR desktop off (or back on) and return its new state. diff --git a/crates/client/src/handover.rs b/crates/client/src/handover.rs index aff28ed..4737612 100644 --- a/crates/client/src/handover.rs +++ b/crates/client/src/handover.rs @@ -19,7 +19,7 @@ use brolink_core::HANDOVER_PORT; use brolink_stream::Input; use parking_lot::Mutex; use semver::Version; -use std::net::{Ipv4Addr, SocketAddr, TcpListener}; +use std::net::{Ipv4Addr, SocketAddr, TcpListener, TcpStream}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -254,8 +254,8 @@ fn run( if fetched_at.is_some_and(|t| t.elapsed() > AFTER_FETCH) { return Ok(()); } - match listener.accept() { - Ok((mut stream, peer)) => { + match accept(&listener) { + Ok(Some((mut stream, peer))) => { let _ = stream.set_read_timeout(Some(Duration::from_secs(10))); let _ = stream.set_write_timeout(Some(Duration::from_secs(180))); if peer.ip() != pc_ip { @@ -296,14 +296,28 @@ fn run( } } } - Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => { - std::thread::sleep(Duration::from_millis(150)); - } + Ok(None) => std::thread::sleep(Duration::from_millis(150)), Err(e) => return Err(anyhow!("accept: {e}")), } } } +/// The next connection, if one is waiting. The listener is non-blocking so +/// the loop can notice a stop, and on macOS a socket accepted from it +/// inherits that: the first read found nothing yet, and writing the 20 MB +/// executable failed the moment the send buffer filled, so the PC got a +/// truncated file. The connection itself blocks, within its timeouts. +fn accept(listener: &TcpListener) -> std::io::Result> { + match listener.accept() { + Ok((stream, peer)) => { + stream.set_nonblocking(false)?; + Ok(Some((stream, peer))) + } + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => Ok(None), + Err(e) => Err(e), + } +} + #[cfg(test)] mod tests { use super::*; @@ -326,6 +340,36 @@ mod tests { assert!(!s.contains("{{"), "no leftover braces: {s}"); } + #[test] + fn a_large_reply_goes_out_whole_from_the_non_blocking_listener() { + use std::io::{Read, Write}; + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + listener.set_nonblocking(true).unwrap(); + assert!(accept(&listener).unwrap().is_none(), "nothing waiting yet"); + let addr = listener.local_addr().unwrap(); + let body = vec![7u8; 16 * 1024 * 1024]; + let reader = std::thread::spawn(move || { + let mut c = TcpStream::connect(addr).unwrap(); + // Let the sender fill the socket buffer before anything is read. + std::thread::sleep(Duration::from_millis(200)); + let mut got = Vec::new(); + c.read_to_end(&mut got).unwrap(); + got.len() + }); + let (mut stream, _) = loop { + if let Some(conn) = accept(&listener).unwrap() { + break conn; + } + std::thread::sleep(Duration::from_millis(10)); + }; + stream + .set_write_timeout(Some(Duration::from_secs(10))) + .unwrap(); + stream.write_all(&body).unwrap(); + drop(stream); + assert_eq!(reader.join().unwrap(), body.len()); + } + #[test] fn only_a_release_with_an_update_route_is_worth_typing() { let rel = |v: &str| Release { diff --git a/crates/client/src/lib.rs b/crates/client/src/lib.rs index e3bec19..283fd84 100644 --- a/crates/client/src/lib.rs +++ b/crates/client/src/lib.rs @@ -13,3 +13,40 @@ pub mod share; pub mod stream; pub mod update; pub mod video; + +/// How a BroLink window opens: the brolink-host app and the development +/// viewer alike. +pub fn native_options() -> eframe::NativeOptions { + let icon = if cfg!(target_os = "macos") { + // eframe calls NSApplication.setApplicationIconImage with this + // bitmap, which replaces BroLink.icns in the Dock with an unmasked + // square. An empty icon leaves the bundle icon in place. + egui::IconData::default() + } else { + egui::IconData { + rgba: brolink_core::icon::render(64), + width: 64, + height: 64, + } + }; + eframe::NativeOptions { + viewport: egui::ViewportBuilder::default() + .with_inner_size([1100.0, 720.0]) + .with_min_inner_size([640.0, 420.0]) + .with_title("BroLink") + .with_icon(icon), + renderer: eframe::Renderer::Wgpu, + // A decoded frame goes to the screen as soon as it is drawn rather + // than waiting for the display's next refresh: up to a whole + // refresh interval less between the PC's picture and the eye. The + // picture only changes when a frame arrives, so there is nothing + // to tear. + vsync: false, + wgpu_options: egui_wgpu::WgpuConfiguration { + present_mode: egui_wgpu::wgpu::PresentMode::AutoNoVsync, + desired_maximum_frame_latency: Some(1), + ..Default::default() + }, + ..Default::default() + } +} diff --git a/crates/client/src/main.rs b/crates/client/src/main.rs index a4a1108..ccd9771 100644 --- a/crates/client/src/main.rs +++ b/crates/client/src/main.rs @@ -1,19 +1,7 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] -fn viewport_icon() -> egui::IconData { - if cfg!(target_os = "macos") { - // eframe calls NSApplication.setApplicationIconImage with this - // bitmap, which replaces BroLink.icns in the Dock with an unmasked - // square. An empty icon leaves the bundle icon in place. - egui::IconData::default() - } else { - egui::IconData { - rgba: brolink_core::icon::render(64), - width: 64, - height: 64, - } - } -} +//! The viewer on its own, for development: `cargo run -p brolink-client`. +//! What ships is `brolink-host`, which is this window plus sharing. fn main() -> anyhow::Result<()> { tracing_subscriber::fmt() @@ -23,29 +11,9 @@ fn main() -> anyhow::Result<()> { ) .with_target(false) .init(); - let native = eframe::NativeOptions { - viewport: egui::ViewportBuilder::default() - .with_inner_size([1100.0, 720.0]) - .with_min_inner_size([640.0, 420.0]) - .with_title("BroLink") - .with_icon(viewport_icon()), - renderer: eframe::Renderer::Wgpu, - // A decoded frame goes to the screen as soon as it is drawn rather - // than waiting for the display's next refresh: up to a whole - // refresh interval less between the PC's picture and the eye. The - // picture only changes when a frame arrives, so there is nothing - // to tear. - vsync: false, - wgpu_options: egui_wgpu::WgpuConfiguration { - present_mode: egui_wgpu::wgpu::PresentMode::AutoNoVsync, - desired_maximum_frame_latency: Some(1), - ..Default::default() - }, - ..Default::default() - }; eframe::run_native( "BroLink", - native, + brolink_client::native_options(), Box::new(|cc| Ok(Box::new(brolink_client::app::ClientApp::new(cc)))), ) .map_err(|e| anyhow::anyhow!("{e}")) diff --git a/crates/client/src/path.rs b/crates/client/src/path.rs index eba5cfe..89902ac 100644 --- a/crates/client/src/path.rs +++ b/crates/client/src/path.rs @@ -87,7 +87,7 @@ impl Path { /// Recommended starting quality. RTT measures delay, not throughput: neither /// a VPS relay nor a distant direct connection implies a bandwidth cap. -pub fn effective(settings: &StreamSettings, _path: &Path) -> StreamSettings { +pub fn effective(settings: &StreamSettings) -> StreamSettings { let mut s = settings.clone(); if s.quality == Quality::Auto { s.resolution = Resolution::Native; @@ -278,35 +278,25 @@ mod tests { ); } + /// The path is not an input at all: latency and relays never lower + /// what Auto asks for. #[test] - fn latency_and_relay_type_never_throttle_quality() { - for direct in [None, Some(true), Some(false)] { - for rtt_ms in [None, Some(2), Some(80), Some(200), Some(400)] { - for peer_relay in ["", "100.64.0.40:40000:vni:17"] { - let path = Path { - direct, - rtt_ms, - peer_relay: peer_relay.into(), - ..Default::default() - }; - let auto = StreamSettings { - fps: 120, - ..Default::default() - }; - let s = effective(&auto, &path); - assert_eq!( - (s.resolution, s.fps, s.bitrate_kbps), - (Resolution::Native, 120, 50_000) - ); - let custom = StreamSettings { - quality: Quality::Custom, - bitrate_kbps: 150_000, - ..auto - }; - assert_eq!(effective(&custom, &path), custom); - } - } - } + fn auto_is_the_recommended_quality_and_custom_is_left_alone() { + let auto = StreamSettings { + fps: 120, + ..Default::default() + }; + let s = effective(&auto); + assert_eq!( + (s.resolution, s.fps, s.bitrate_kbps), + (Resolution::Native, 120, 50_000) + ); + let custom = StreamSettings { + quality: Quality::Custom, + bitrate_kbps: 150_000, + ..auto + }; + assert_eq!(effective(&custom), custom); } #[test] diff --git a/crates/client/src/session.rs b/crates/client/src/session.rs index 56a3d0f..1547e4f 100644 --- a/crates/client/src/session.rs +++ b/crates/client/src/session.rs @@ -30,7 +30,7 @@ const NETCHECK_EVERY: Duration = Duration::from_secs(15 * 60); const PEER_RELAY_EVERY: Duration = Duration::from_secs(30); /// A machine on the tailnet BroLink can open, as far as this node can tell. -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone, Default, PartialEq)] pub struct Pc { pub node_id: String, pub name: String, @@ -66,7 +66,7 @@ impl Pc { } /// A tailnet node tagged `tag:relay`: a candidate peer relay, never a PC. -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone, Default, PartialEq)] pub struct Relay { pub name: String, pub ip: Option, @@ -86,7 +86,7 @@ pub enum PeerRelayServers { Known(Vec), } -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone, Default, PartialEq)] pub struct Discovery { /// Why nothing can be probed, when Tailscale is down. The list then /// holds what was remembered. @@ -110,9 +110,9 @@ pub struct Discovery { /// be woken. pub fn spawn_discovery(shared: Arc>, ctx: egui::Context) { let nat: Arc>> = Arc::default(); - spawn_netcheck(nat.clone(), ctx.clone()); + spawn_netcheck(nat.clone()); let servers: Arc> = Arc::default(); - spawn_peer_relay_probe(servers.clone(), ctx.clone()); + spawn_peer_relay_probe(servers.clone()); std::thread::spawn(move || { // Round trips per PC, newest last; the smallest of the last few is // the path's real round trip (a first connect also pays for the @@ -134,21 +134,33 @@ pub fn spawn_discovery(shared: Arc>, ctx: egui::Context) { scan.self_nat = nat.lock().clone(); scan.peer_relay_servers = servers.lock().clone(); learn(&scan); + let changed = listing_changed(&shared.lock(), &scan); *shared.lock() = scan; - ctx.request_repaint(); + if changed { + ctx.request_repaint(); + } std::thread::sleep(Duration::from_secs(3)); } }); } -/// `tailscale netcheck` now and then, for [`Discovery::self_nat`]. -fn spawn_netcheck(slot: Arc>>, ctx: egui::Context) { +/// Whether a new scan shows the window anything new. Every scan has a new +/// time; that alone is no reason to draw the window again. +fn listing_changed(old: &Discovery, new: &Discovery) -> bool { + old.refreshed.is_none() != new.refreshed.is_none() + || *old + != (Discovery { + refreshed: old.refreshed, + ..new.clone() + }) +} + +/// `tailscale netcheck` now and then, for [`Discovery::self_nat`]. The next +/// scan carries it to the window. +fn spawn_netcheck(slot: Arc>>) { std::thread::spawn(move || loop { match tailscale::netcheck() { - Ok(n) => { - *slot.lock() = Some(n.report()); - ctx.request_repaint(); - } + Ok(n) => *slot.lock() = Some(n.report()), Err(e) => tracing::info!("netcheck: {e}"), } std::thread::sleep(NETCHECK_EVERY); @@ -158,15 +170,14 @@ fn spawn_netcheck(slot: Arc>>, ctx: egui::Context) { /// `tailscale debug peer-relay-servers`, at most once every 30 s, on its own /// thread so the scan loop and UI never wait on it. The command itself is /// bounded (see `tailscale::PEER_RELAY_SERVERS_TIMEOUT`). Any failure leaves -/// the result `Unknown`, never a denial. -fn spawn_peer_relay_probe(slot: Arc>, ctx: egui::Context) { +/// the result `Unknown`, never a denial. The next scan carries it to the +/// window. +fn spawn_peer_relay_probe(slot: Arc>) { std::thread::spawn(move || loop { - let result = match tailscale::peer_relay_servers() { + *slot.lock() = match tailscale::peer_relay_servers() { Ok(servers) => PeerRelayServers::Known(servers), Err(_) => PeerRelayServers::Unknown, }; - *slot.lock() = result; - ctx.request_repaint(); std::thread::sleep(PEER_RELAY_EVERY); }); } @@ -641,9 +652,9 @@ fn run(c: &Connect, generation: u64) -> Result<()> { tracing::warn!("could not forget the PC's old certificate: {save_error:#}"); } client = Client::new(&identity, IpAddr::V4(t.ip), None)?; - retry(8, || client.server_info())? + retry(8, &c.progress, generation, || client.server_info())? } - Err(_) => retry(8, || client.server_info())?, + Err(_) => retry(8, &c.progress, generation, || client.server_info())?, }; if !info.paired || client.server_cert().is_none() { let pin = format!("{:04}", rand::random::() % 10_000); @@ -666,7 +677,7 @@ fn run(c: &Connect, generation: u64) -> Result<()> { // carry. report(Step::Launching, "Measuring the path…".into()); let path = measure_path(&t.node_id, t.ip, &t.path); - let settings = path::effective(&c.settings, &path); + let settings = path::effective(&c.settings); tracing::info!( "path to {}: {} · {}", t.name, @@ -727,7 +738,7 @@ fn run(c: &Connect, generation: u64) -> Result<()> { let _ = client.quit(); bail!("cancelled"); } - let hevc = settings.codec != Codec::H264 && info.codec_mode_support & 0x0F00 != 0; + let hevc = wants_hevc(settings.codec, info.codec_mode_support); let frames = Arc::new(FrameSlot::default()); let (tx, rx) = std::sync::mpsc::channel(); let ctx = c.ctx.clone(); @@ -745,7 +756,6 @@ fn run(c: &Connect, generation: u64) -> Result<()> { fps, bitrate_kbps: settings.bitrate_kbps, hevc, - remote: false, }, ri_key, ri_iv, @@ -778,16 +788,37 @@ fn run(c: &Connect, generation: u64) -> Result<()> { Ok(()) } -fn retry(times: u32, mut f: impl FnMut() -> Result) -> Result { - let mut last = None; - for _ in 0..times { +/// HEVC when the settings allow it, the PC can encode it (Sunshine's +/// `ServerCodecModeSupport` HEVC bits) and this machine can decode it. +/// Windows and Linux decode H.264 only, whatever the PC offers. +fn wants_hevc(codec: Codec, server_codecs: i32) -> bool { + const SCM_HEVC_MASK: i32 = 0x0F00; + codec != Codec::H264 + && server_codecs & SCM_HEVC_MASK != 0 + && brolink_stream::video::supported_formats() & brolink_stream::ffi::VIDEO_FORMAT_MASK_H265 + != 0 +} + +/// `f` until it succeeds, `times` tries at most, giving up early once the +/// attempt is cancelled. +fn retry( + times: u32, + progress: &Mutex, + generation: u64, + mut f: impl FnMut() -> Result, +) -> Result { + let mut last = anyhow!("not tried"); + for attempt in 1..=times { match f() { Ok(v) => return Ok(v), - Err(e) => last = Some(e), + Err(e) => last = e, + } + if attempt == times || stale(progress, generation) { + break; } std::thread::sleep(Duration::from_millis(700)); } - Err(last.unwrap()) + Err(last) } /// Hand the PIN to BroLink Host on the PC, which types it into Sunshine. If @@ -869,8 +900,6 @@ pub fn wake_only(pc: &Pc) -> Result { mod tests { use super::*; - /// Against BroLink Host running on this machine: the packet goes out on - /// the LAN and the host reports it. `cargo test -p brolink-client wake_test_real -- --ignored` #[test] fn update_reservation_blocks_connect_in_every_idle_state() { for step in [Step::Idle, Step::Ended { error: None }] { @@ -897,6 +926,8 @@ mod tests { } } + /// Against BroLink Host running on this machine: the packet goes out on + /// the LAN and the host reports it. `cargo test -p brolink-client wake_test_real -- --ignored` #[test] #[ignore = "needs BroLink Host running on this machine"] fn wake_test_real() { @@ -981,6 +1012,72 @@ mod tests { assert!(d.error.is_some() || !d.login.is_empty()); } + #[test] + fn hevc_needs_the_setting_the_pc_and_this_decoder() { + let hevc_pc = 0x0101; + assert!(!wants_hevc(Codec::H264, hevc_pc)); + assert!(!wants_hevc(Codec::Auto, 0x0001), "an H.264-only PC"); + assert_eq!( + wants_hevc(Codec::Auto, hevc_pc), + cfg!(target_os = "macos"), + "only VideoToolbox decodes HEVC here" + ); + } + + #[test] + fn a_cancelled_retry_stops_at_once() { + let progress = Mutex::new(Progress { + generation: 3, + ..Default::default() + }); + let mut tries = 0; + let r: Result<()> = retry(8, &progress, 3, || { + tries += 1; + progress.lock().cancel = true; + bail!("the PC is not answering") + }); + assert_eq!(tries, 1); + assert_eq!(r.unwrap_err().to_string(), "the PC is not answering"); + progress.lock().cancel = false; + let mut tries = 0; + let r = retry(3, &progress, 3, || { + tries += 1; + if tries < 2 { + bail!("not yet") + } + Ok(tries) + }); + assert_eq!(r.unwrap(), 2); + } + + #[test] + fn only_a_scan_that_changes_something_redraws() { + let first = Discovery { + refreshed: Some(Instant::now()), + pcs: vec![Pc { + name: "Gaming-PC".into(), + online: true, + ..Default::default() + }], + ..Default::default() + }; + assert!( + listing_changed(&Discovery::default(), &first), + "the first scan" + ); + let again = Discovery { + refreshed: Some(Instant::now() + Duration::from_secs(3)), + ..first.clone() + }; + assert!(!listing_changed(&first, &again), "only the time moved"); + let mut asleep = again.clone(); + asleep.pcs[0].online = false; + assert!(listing_changed(&first, &asleep)); + let mut relay = again; + relay.peer_relay_servers = PeerRelayServers::Known(vec!["100.64.0.40".into()]); + assert!(listing_changed(&first, &relay)); + } + #[test] fn progress_tracks_step_changes() { let mut p = Progress::default(); diff --git a/crates/client/src/settings.rs b/crates/client/src/settings.rs index 5523e2c..9c73fd9 100644 --- a/crates/client/src/settings.rs +++ b/crates/client/src/settings.rs @@ -65,7 +65,7 @@ pub fn stream_controls(ui: &mut egui::Ui, s: &mut StreamSettings, native: (u32, ui::setting_row(ui, "Video format", Some("HEVC gives sharper detail per megabit. Auto uses HEVC when supported; H.264 improves compatibility."), |ui| { ui::segmented(ui, &[(Codec::Auto, "Auto"), (Codec::Hevc, "HEVC"), (Codec::H264, "H.264")], &mut s.codec); }); - let effective = crate::path::effective(s, &crate::path::Path::default()); + let effective = crate::path::effective(s); let (w, h) = effective.resolution.pixels(native); ui.add_space(12.0); ui::status_pill( diff --git a/crates/client/src/stream.rs b/crates/client/src/stream.rs index 49de0a4..ce33723 100644 --- a/crates/client/src/stream.rs +++ b/crates/client/src/stream.rs @@ -1133,7 +1133,6 @@ fn pointer_on_stream( in_video && !over_bar && !popup && !overlay && !over_overlay } -/// "Auto · Smooth" → "Auto", "Custom · Sharp" → "Sharp", else "Custom". /// Whether `host_version` is too old for `/v1/update`, with the release /// that an install through the stream would put on it. pub fn old_host( @@ -1257,7 +1256,6 @@ mod tests { fps: 30, bitrate_kbps: 4000, hevc: true, - remote: true, }, [0; 16], [0; 16], diff --git a/crates/client/src/update.rs b/crates/client/src/update.rs index 0cbf6cd..bbdd9b2 100644 --- a/crates/client/src/update.rs +++ b/crates/client/src/update.rs @@ -1,7 +1,7 @@ //! Keeping this app and every BroLink Host it can see on the newest release. //! -//! The Mac is the one machine with a GitHub login (the repository is -//! private), so it does the fetching for everyone. Every few hours it asks +//! The Mac does the fetching for everyone (with a GitHub login if the +//! repository is private; see `brolink_core::update`). Every few hours it asks //! GitHub for the latest release. A newer app is downloaded, verified against //! GitHub's digest and its own code signature, and swapped into place once no //! stream is running; the app then relaunches itself. A newer host is @@ -16,14 +16,14 @@ use crate::config::ClientConfig; use crate::session::{Discovery, Live, Progress}; use anyhow::{anyhow, bail, Context, Result}; -use brolink_core::api::{Ack, UPDATE_PATH, UPDATE_SHA256_HEADER, UPDATE_VERSION_HEADER}; +use brolink_core::api::{Ack, Status, UPDATE_PATH, UPDATE_SHA256_HEADER, UPDATE_VERSION_HEADER}; use brolink_core::update::{self, Release, HOST_EXE, MAC_ASSET, WINDOWS_ASSET}; use brolink_core::{http, CONTROL_PORT}; use brolink_ui::Tone; use parking_lot::Mutex; use semver::Version; use std::collections::{BTreeMap, BTreeSet}; -use std::net::Ipv4Addr; +use std::net::{Ipv4Addr, SocketAddr}; use std::path::PathBuf; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -64,6 +64,19 @@ pub fn spawn( progress: Arc>, ctx: egui::Context, ) { + if !cfg!(target_os = "macos") { + // Everything below assumes a Mac: it replaces an app bundle and + // unpacks the Windows zip with /usr/bin/unzip. A Windows PC gets + // its new host pushed from the Mac, and a Linux node is rebuilt, so + // there is nothing here for either to fetch or send. + let _ = (discovery, live, progress, ctx); + state.lock().message = if cfg!(windows) { + "New versions arrive from the Mac on your Tailscale account.".into() + } else { + "BroLink updates itself on a Mac; install new releases here by hand.".into() + }; + return; + } std::thread::spawn(move || { let mut next = Instant::now() + FIRST_CHECK; let mut release: Option = None; @@ -493,21 +506,51 @@ fn push_host(rel: &Release, token: Option<&str>, ip: Ipv4Addr) -> Result<()> { let exe = host_exe(rel, token)?; let sha = update::sha256_hex(&exe); let version = rel.version.to_string(); + let host = SocketAddr::from((ip, CONTROL_PORT)); let mut last = None; for attempt in 1..=3 { match send_host(ip, &version, &sha, &exe) { Ok(()) => return Ok(()), - Err(e) if attempt < 3 && is_transient(&e) => { + Err(e) => { + // A host can take the file and restart before its reply + // gets out, so the Mac sees a dropped connection and the + // retry hears "already runs". Ask the host what it runs + // before calling either a failure. + if (attempt > 1 || is_transient(&e)) + && runs_at_least(host, &rel.version, Duration::from_secs(15)) + { + return Ok(()); + } + if attempt == 3 || !is_transient(&e) { + return Err(e); + } tracing::warn!("host update attempt {attempt}/3: {e:#}"); std::thread::sleep(Duration::from_secs(2 * attempt as u64)); last = Some(e); } - Err(e) => return Err(e), } } Err(last.unwrap_or_else(|| anyhow!("update failed"))) } +/// Whether the host at `addr` reports `want` or newer within `within`: it +/// may still be handing over to the new executable when first asked. +fn runs_at_least(addr: SocketAddr, want: &Version, within: Duration) -> bool { + let deadline = Instant::now() + within; + loop { + let running = http::get_json::(addr, "/v1/status", Duration::from_secs(2)) + .ok() + .and_then(|s| Version::parse(&s.version).ok()); + if running.is_some_and(|v| v >= *want) { + return true; + } + if Instant::now() >= deadline { + return false; + } + std::thread::sleep(Duration::from_secs(1)); + } +} + fn send_host(ip: Ipv4Addr, version: &str, sha: &str, exe: &[u8]) -> Result<()> { let r = http::request_with( (ip, CONTROL_PORT), @@ -650,6 +693,43 @@ mod tests { assert!(!msg.contains("os error"), "{msg}"); } + /// A one-reply HTTP server saying the host runs `version`. + fn status_server(version: &'static str) -> SocketAddr { + use std::io::{Read, Write}; + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + std::thread::spawn(move || { + for mut s in listener.incoming().flatten() { + let mut buf = [0u8; 1024]; + let _ = s.read(&mut buf); + let body = format!(r#"{{"app":"brolink","version":"{version}"}}"#); + let _ = write!( + s, + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + } + }); + addr + } + + #[test] + fn a_host_already_on_the_release_counts_as_updated() { + let want = Version::new(4, 0, 3); + assert!(runs_at_least(status_server("4.0.3"), &want, Duration::ZERO)); + assert!(runs_at_least(status_server("4.1.0"), &want, Duration::ZERO)); + assert!(!runs_at_least( + status_server("4.0.2"), + &want, + Duration::ZERO + )); + let closed = std::net::TcpListener::bind("127.0.0.1:0") + .unwrap() + .local_addr() + .unwrap(); + assert!(!runs_at_least(closed, &want, Duration::ZERO)); + } + #[test] fn pipe_and_eagain_are_retried() { assert!(is_transient(&anyhow!("connection closed"))); diff --git a/crates/core/src/api.rs b/crates/core/src/api.rs index 678e7d7..a071f37 100644 --- a/crates/core/src/api.rs +++ b/crates/core/src/api.rs @@ -13,6 +13,12 @@ //! | POST | /v1/update | `brolink-host.exe` bytes | [`Ack`] | //! | GET | /v1/clipboard | | [`Clipboard`] | //! | POST | /v1/clipboard | [`Clipboard`] | [`Ack`] | +//! | GET | /v1/display | | display report (JSON) | +//! | POST | /v1/display | [`DisplayRequest`] | display report (JSON) | +//! +//! Only machines signed in to the same Tailscale account get an answer +//! (a tagged server answers its tailnet's members); see the host's +//! `service` module. //! //! `/v1/update` carries the new executable itself, with its version in //! [`UPDATE_VERSION_HEADER`] and its SHA-256 in [`UPDATE_SHA256_HEADER`]. A diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs index 95124cf..3999223 100644 --- a/crates/core/src/config.rs +++ b/crates/core/src/config.rs @@ -1,6 +1,6 @@ //! Where each app keeps its few settings: one TOML file in the per-user data //! directory (`%LOCALAPPDATA%\BroLink` on Windows, `~/Library/Application -//! Support/BroLink` on macOS). +//! Support/dev.brolink.BroLink` on macOS, `~/.local/share/brolink` on Linux). use anyhow::{Context, Result}; use serde::{de::DeserializeOwned, Serialize}; @@ -14,8 +14,16 @@ use std::sync::{ static CONFIG_LOCK: Mutex<()> = Mutex::new(()); static TEMP_ID: AtomicU64 = AtomicU64::new(0); +/// The per-user data directory, created private to this user. +/// +/// `BROLINK_DATA_DIR` replaces it when set. The workspace's +/// `.cargo/config.toml` sets it for `cargo test` and `cargo run`, so a test +/// run on a machine with BroLink installed leaves that install's settings, +/// pairing identity and logs alone. pub fn data_dir() -> Result { - let dir = if cfg!(windows) { + let dir = if let Some(dir) = std::env::var_os("BROLINK_DATA_DIR").filter(|d| !d.is_empty()) { + Some(PathBuf::from(dir)) + } else if cfg!(windows) { std::env::var_os("LOCALAPPDATA") .map(PathBuf::from) .map(|p| p.join(crate::APP_NAME)) @@ -164,8 +172,7 @@ mod tests { assert_eq!(load::(&name), Cfg { n: 7 }); std::fs::write(dir.join(&name), "not = [toml").unwrap(); assert_eq!(load::(&name), Cfg::default()); - // Loading junk moves it aside as `.bad-*`. This runs against the - // user's real data directory, so remove what the test left behind. + // Loading junk moves it aside as `.bad-*`, exactly once. let prefix = format!("{stem}.bad-"); let preserved: Vec = std::fs::read_dir(&dir) .unwrap() @@ -216,6 +223,21 @@ mod tests { std::fs::remove_file(path).unwrap(); } + /// `.cargo/config.toml` points `cargo test` at `target/brolink-data`. + /// Without it every test that loads or saves settings would read and + /// rewrite the real install's files on the machine running the tests. + #[test] + fn tests_run_against_a_data_directory_under_target() { + let want = std::env::var_os("BROLINK_DATA_DIR").expect("cargo test sets BROLINK_DATA_DIR"); + let dir = data_dir().unwrap(); + assert_eq!(dir, PathBuf::from(want)); + assert!( + dir.components().any(|c| c.as_os_str() == "target"), + "{}", + dir.display() + ); + } + #[test] fn machine_name_is_never_empty() { assert!(!machine_name().is_empty()); diff --git a/crates/core/src/http.rs b/crates/core/src/http.rs index 6048b13..9b3e0c0 100644 --- a/crates/core/src/http.rs +++ b/crates/core/src/http.rs @@ -191,17 +191,19 @@ pub fn write_response(stream: &mut TcpStream, resp: &Response) -> Result<()> { ) } -/// Accept forever, with a bounded number of active connections. `handler` sees the peer -/// address so it can decide who is allowed to ask. -pub fn serve(listener: TcpListener, handler: F) +/// [`serve_with_peer_check`] for everyone, for the tests. +#[cfg(test)] +fn serve(listener: TcpListener, handler: F) where F: Fn(SocketAddr, &Request) -> Response + Send + Sync + 'static, { serve_with_peer_check(listener, |_| true, handler); } -/// Authorize the peer before reading headers or allocating a request body. -/// The check runs in a bounded connection worker, so it may perform I/O. +/// Accept forever, with a bounded number of active connections. `authorize` +/// sees the peer before its headers are read or a body is allocated; it +/// runs in the connection's worker, so it may do I/O. `handler` sees the +/// peer too, so it can decide per route. pub fn serve_with_peer_check(listener: TcpListener, authorize: A, handler: F) where A: Fn(SocketAddr) -> bool + Send + Sync + 'static, @@ -267,6 +269,20 @@ impl Drop for ConnectionSlot { } } +/// A connection to the first of `addr`'s addresses that accepts one, each +/// given `timeout`. A name can resolve to an IPv6 address first that this +/// network has no route to, ahead of an IPv4 one that works. +pub(crate) fn connect(addr: impl ToSocketAddrs, timeout: Duration) -> Result { + let mut last = None; + for a in addr.to_socket_addrs()? { + match TcpStream::connect_timeout(&a, timeout) { + Ok(s) => return Ok(s), + Err(e) => last = Some(e), + } + } + Err(last.map(io_err).unwrap_or_else(|| anyhow!("no address"))) +} + /// One request, one reply, within `timeout` for connect and for the read. pub fn request( addr: impl ToSocketAddrs, @@ -275,19 +291,13 @@ pub fn request( body: Option<&str>, timeout: Duration, ) -> Result { - let addr = addr - .to_socket_addrs()? - .next() - .ok_or_else(|| anyhow!("no address"))?; - let mut stream = TcpStream::connect_timeout(&addr, timeout).map_err(io_err)?; - stream.set_read_timeout(Some(timeout)).map_err(io_err)?; - stream.set_write_timeout(Some(timeout)).map_err(io_err)?; - exchange( - &mut stream, + request_with( + addr, method, path, - &addr.to_string(), - body.unwrap_or(""), + &[("Content-Type", "application/json")], + body.unwrap_or("").as_bytes(), + timeout, ) } @@ -300,14 +310,11 @@ pub fn request_with( body: &[u8], timeout: Duration, ) -> Result { - let addr = addr - .to_socket_addrs()? - .next() - .ok_or_else(|| anyhow!("no address"))?; - let mut stream = TcpStream::connect_timeout(&addr, timeout).map_err(io_err)?; + let mut stream = connect(addr, timeout)?; stream.set_read_timeout(Some(timeout)).map_err(io_err)?; stream.set_write_timeout(Some(timeout)).map_err(io_err)?; - exchange_with(&mut stream, method, path, &addr.to_string(), headers, body) + let host = stream.peer_addr().map_err(io_err)?.to_string(); + exchange_with(&mut stream, method, path, &host, headers, body) } /// One HTTP/1.1 request with a JSON body and its reply over any stream (TCP, @@ -669,6 +676,23 @@ mod tests { } } + #[test] + fn an_address_that_refuses_is_skipped_for_the_next_one() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let open = listener.local_addr().unwrap(); + let closed = TcpListener::bind("127.0.0.1:0") + .unwrap() + .local_addr() + .unwrap(); + std::thread::spawn(move || serve(listener, |_, _| Response::json(200, &Ack::ok()))); + let t = Duration::from_secs(2); + let r = request(&[closed, open][..], "GET", "/", None, t).unwrap(); + assert_eq!(r.status, 200, "{}", r.body); + let e = request(closed, "GET", "/", None, t).unwrap_err(); + assert!(!e.to_string().is_empty()); + assert!(connect(&[][..] as &[SocketAddr], t).is_err()); + } + #[test] fn garbage_is_refused_not_panicked() { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); diff --git a/crates/core/src/icon.rs b/crates/core/src/icon.rs index 4fe8121..ceefe7b 100644 --- a/crates/core/src/icon.rs +++ b/crates/core/src/icon.rs @@ -3,7 +3,8 @@ //! build script, which includes this file by path, so it must not refer to //! anything else in this crate. -use std::sync::OnceLock; +use std::collections::BTreeMap; +use std::sync::{Mutex, OnceLock}; use image::{Rgba, RgbaImage}; @@ -64,6 +65,14 @@ fn tile_bounds(im: &RgbaImage) -> (u32, u32, u32, u32) { /// square and applies the rounded app-icon shape itself. Pre-padding left /// a square plate around the mark. pub fn render(size: u32) -> Vec { + // Each window's header and the window icon ask for the same size at + // startup, before the first frame; the resize is the slow part. + static RENDERED: Mutex>> = Mutex::new(BTreeMap::new()); + let mut rendered = RENDERED.lock().unwrap_or_else(|e| e.into_inner()); + rendered.entry(size).or_insert_with(|| draw(size)).clone() +} + +fn draw(size: u32) -> Vec { if size == 0 { return Vec::new(); } diff --git a/crates/core/src/tailscale.rs b/crates/core/src/tailscale.rs index 8af6501..843ec78 100644 --- a/crates/core/src/tailscale.rs +++ b/crates/core/src/tailscale.rs @@ -5,7 +5,7 @@ use anyhow::{bail, Context, Result}; use serde::Deserialize; use std::collections::BTreeMap; use std::io::Read; -use std::net::Ipv4Addr; +use std::net::{IpAddr, Ipv4Addr}; use std::path::PathBuf; use std::process::{Command, Stdio}; use std::sync::OnceLock; @@ -68,6 +68,16 @@ pub struct Status { pub self_node: Node, pub peer: BTreeMap, pub user: BTreeMap, + /// The tailnet this machine is in. Absent before Tailscale 1.26. + pub current_tailnet: Option, +} + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(default, rename_all = "PascalCase")] +pub struct Tailnet { + /// The owner's login for a personal tailnet ("ada@example.com"), the + /// domain for an organisation's ("example.com"). + pub name: String, } #[derive(Debug, Clone, Default, Deserialize, PartialEq)] @@ -182,17 +192,6 @@ impl Status { .get(&self.self_node.user_id.to_string()) .map(|u| u.login_name.as_str()) } - /// Peers running Windows. Relay-tagged nodes are omitted even when they - /// report Windows. Prefer [`machine_peers`] for the streamable list. - pub fn windows_peers(&self) -> Vec<&Node> { - let mut v: Vec<&Node> = self - .peer - .values() - .filter(|n| n.is_windows() && !n.is_relay()) - .collect(); - v.sort_by(|a, b| a.host_name.cmp(&b.host_name)); - v - } /// Every tailnet peer that can share a desktop: any OS, minus `tag:relay` /// nodes that exist only to carry packets. Sorted by hostname. pub fn machine_peers(&self) -> Vec<&Node> { @@ -329,20 +328,21 @@ pub fn netcheck() -> Result { parse_netcheck(&out) } -/// The three-letter code of a DERP region, as `tailscale status` prints it. -/// True for loopback, RFC 1918, or Tailscale's CGNAT range (`100.64/10`). -/// BroLink never rides the raw internet, so these addresses are a LAN as -/// far as the stream protocol is concerned. -pub fn overlay_or_lan(ip: Ipv4Addr) -> bool { - if ip.is_loopback() || ip.is_private() { - return true; - } - // 100.64.0.0/10 (shared address space). `Ipv4Addr::is_shared` is not - // stable on the toolchain BroLink pins. +/// An address Tailscale hands out: IPv4 from 100.64.0.0/10. +pub fn is_tailnet(ip: Ipv4Addr) -> bool { let o = ip.octets(); - o[0] == 100 && o[1] >= 64 && o[1] <= 127 + o[0] == 100 && (64..128).contains(&o[1]) +} + +/// [`is_tailnet`], or IPv6 unique-local (Tailscale uses fd7a:115c:a1e0::/48). +pub fn is_tailnet_ip(ip: IpAddr) -> bool { + match ip { + IpAddr::V4(v4) => is_tailnet(v4), + IpAddr::V6(v6) => v6.octets()[0] & 0xfe == 0xfc, + } } +/// The three-letter code of a DERP region, as `tailscale status` prints it. pub fn derp_code(region: i32) -> &'static str { match region { 1 => "nyc", @@ -490,6 +490,14 @@ fn run_limited(cmd: &mut Command, limit: Duration) -> Result { mod tests { use super::*; + /// The Windows machines in the list, as the tests below look at them. + fn windows_peers(st: &Status) -> Vec<&Node> { + st.machine_peers() + .into_iter() + .filter(|n| n.is_windows()) + .collect() + } + const SAMPLE: &str = r#"{ "Version": "1.102.3", "BackendState": "Running", "Self": {"ID": "nSELF", "HostName": "Gaming-PC", "DNSName": "gaming-pc.example.ts.net.", "OS": "windows", @@ -514,7 +522,7 @@ mod tests { assert!(st.running()); assert_eq!(st.self_node.ipv4(), Some("100.64.0.10".parse().unwrap())); assert_eq!(st.self_login(), Some("user@example.com")); - let pcs = st.windows_peers(); + let pcs = windows_peers(&st); assert_eq!( pcs.iter().map(|n| n.host_name.as_str()).collect::>(), ["Den", "Gaming-PC-2", "Office"] @@ -530,10 +538,6 @@ mod tests { ); assert_eq!(st.peer["nodekey:a"].os_label(), "macOS"); assert_eq!(st.peer["nodekey:b"].os_label(), "Windows"); - assert!(overlay_or_lan("100.64.0.10".parse().unwrap())); - assert!(overlay_or_lan("192.168.1.10".parse().unwrap())); - assert!(overlay_or_lan("127.0.0.1".parse().unwrap())); - assert!(!overlay_or_lan("8.8.8.8".parse().unwrap())); assert_eq!( pcs[2].ipv4(), Some("100.64.0.30".parse().unwrap()), @@ -587,7 +591,7 @@ mod tests { fn a_stopped_daemon_is_not_running() { let st = parse_status(r#"{"BackendState":"Stopped"}"#).unwrap(); assert!(!st.running()); - assert!(st.windows_peers().is_empty()); + assert!(windows_peers(&st).is_empty()); assert_eq!(st.self_login(), None); } @@ -641,7 +645,7 @@ mod tests { ) .unwrap(); - let pcs = st.windows_peers(); + let pcs = windows_peers(&st); assert_eq!( pcs.iter().map(|n| n.host_name.as_str()).collect::>(), ["Gaming-PC", "tagged-pc"], @@ -714,6 +718,28 @@ mod tests { assert_eq!(output.len(), 1048576); } + #[test] + fn tailnet_range_is_recognised() { + assert!(is_tailnet("100.64.0.1".parse().unwrap())); + assert!(is_tailnet("100.127.255.254".parse().unwrap())); + assert!(!is_tailnet("100.128.0.1".parse().unwrap())); + assert!(!is_tailnet("192.168.1.2".parse().unwrap())); + assert!(is_tailnet_ip("100.111.100.57".parse().unwrap())); + assert!(is_tailnet_ip("fd7a:115c:a1e0::9e2a:381c".parse().unwrap())); + assert!(!is_tailnet_ip("8.8.8.8".parse().unwrap())); + assert!(!is_tailnet_ip("2001:4860:4860::8888".parse().unwrap())); + } + + #[test] + fn the_tailnet_name_is_read_when_present() { + let st = parse_status( + r#"{"BackendState":"Running","CurrentTailnet":{"Name":"user@example.com","MagicDNSSuffix":"tail0.ts.net"}}"#, + ) + .unwrap(); + assert_eq!(st.current_tailnet.unwrap().name, "user@example.com"); + assert!(parse_status(SAMPLE).unwrap().current_tailnet.is_none()); + } + #[test] fn whois_is_parsed() { let w: WhoIs = serde_json::from_str( diff --git a/crates/core/src/update.rs b/crates/core/src/update.rs index 023c85d..724cfe9 100644 --- a/crates/core/src/update.rs +++ b/crates/core/src/update.rs @@ -1,9 +1,9 @@ //! Finding and fetching BroLink releases on GitHub, for both apps' updaters. //! -//! The repository is private, so the calls need a token: the caller's -//! setting, `BROLINK_GITHUB_TOKEN`, or on macOS whatever git has stored for -//! github.com, which is the login the install script uses too. Without one -//! the requests still go out, so a public repository works unchanged. +//! A public repository needs no login. For a private one the calls carry a +//! token: the caller's setting, `BROLINK_GITHUB_TOKEN`, or on macOS whatever +//! git has stored for github.com, which is the login the install script +//! uses too. Without one the requests still go out. //! //! Only the Mac talks to GitHub. It replaces its own bundle and sends each //! PC the new `brolink-host.exe` over the control API; see @@ -14,7 +14,7 @@ use semver::Version; use serde::Deserialize; use sha2::{Digest, Sha256}; use std::io::{BufReader, Write}; -use std::net::{TcpStream, ToSocketAddrs}; +use std::net::TcpStream; use std::path::Path; use std::sync::Arc; use std::time::Duration; @@ -370,13 +370,7 @@ fn split_url(url: &str) -> Result<(String, String)> { } fn connect(host: &str) -> Result> { - let addr = (host, 443u16) - .to_socket_addrs() - .with_context(|| format!("resolve {host}"))? - .next() - .ok_or_else(|| anyhow!("{host} has no address"))?; - let tcp = TcpStream::connect_timeout(&addr, TIMEOUT) - .map_err(crate::http::io_err) + let tcp = crate::http::connect((host, 443u16), TIMEOUT) .with_context(|| format!("connect to {host}"))?; tcp.set_read_timeout(Some(TIMEOUT)) .map_err(crate::http::io_err)?; diff --git a/crates/core/src/wake.rs b/crates/core/src/wake.rs index a51dfbd..1b44dd3 100644 --- a/crates/core/src/wake.rs +++ b/crates/core/src/wake.rs @@ -84,7 +84,9 @@ pub fn plan( .unwrap_or_default() .into_iter() .filter_map(|i| match i.addr { - if_addrs::IfAddr::V4(a) if !a.ip.is_loopback() && !is_tailnet(a.ip) => { + if_addrs::IfAddr::V4(a) + if !a.ip.is_loopback() && !crate::tailscale::is_tailnet(a.ip) => + { Some((a.ip, a.broadcast)) } _ => None, @@ -111,11 +113,6 @@ pub fn plan( out } -fn is_tailnet(ip: Ipv4Addr) -> bool { - let o = ip.octets(); - o[0] == 100 && (64..128).contains(&o[1]) -} - /// Fire the packet everywhere in [`plan`]. Returns how many sends the OS /// accepted; unroutable targets are normal and only logged. pub fn send(mac: MacAddr, lan_ip: Option, public_ip: Option) -> usize { diff --git a/crates/host/Cargo.toml b/crates/host/Cargo.toml index 2af4207..40e3c53 100644 --- a/crates/host/Cargo.toml +++ b/crates/host/Cargo.toml @@ -19,7 +19,6 @@ path = "src/main.rs" brolink-core.workspace = true brolink-client.workspace = true brolink-ui.workspace = true -egui-wgpu.workspace = true anyhow.workspace = true serde = { workspace = true } serde_json.workspace = true @@ -30,7 +29,6 @@ rand.workspace = true clap.workspace = true semver = "1" eframe.workspace = true -egui.workspace = true [target.'cfg(not(windows))'.dependencies] arboard = "3" diff --git a/crates/host/build.rs b/crates/host/build.rs index a19949d..4ccbd10 100644 --- a/crates/host/build.rs +++ b/crates/host/build.rs @@ -23,7 +23,7 @@ fn main() { .set("CompanyName", "BroLink Contributors") .set( "LegalCopyright", - "Copyright (c) 2026 BroLink Contributors, MIT license", + "Copyright (c) 2026 BroLink Contributors. GPL-3.0-or-later", ) .set("OriginalFilename", "brolink-host.exe"); if let Err(e) = res.compile() { diff --git a/crates/host/src/app.rs b/crates/host/src/app.rs index 21820de..d0927b0 100644 --- a/crates/host/src/app.rs +++ b/crates/host/src/app.rs @@ -697,6 +697,15 @@ fn spawn_poller(shared: Arc>, ctx: egui::Context) { "/v1/status", Duration::from_millis(800), ); + let seen = |s: &Shared| { + ( + s.status.clone(), + s.service_error.clone(), + s.clients.clone(), + s.gamepad_driver, + ) + }; + let before = seen(&shared.lock()); match r { Ok(st) => { failures = 0; @@ -760,7 +769,10 @@ fn spawn_poller(shared: Arc>, ctx: egui::Context) { } } } - ctx.request_repaint(); + // The window is drawn again only when there is something new. + if seen(&shared.lock()) != before { + ctx.request_repaint(); + } tick += 1; std::thread::sleep(Duration::from_secs(1)); } diff --git a/crates/host/src/audio.rs b/crates/host/src/audio.rs index 3fc5104..9264a4d 100644 --- a/crates/host/src/audio.rs +++ b/crates/host/src/audio.rs @@ -7,43 +7,51 @@ mod win { use anyhow::{bail, Context, Result}; use std::os::windows::process::CommandExt; - use std::path::PathBuf; + use std::path::{Path, PathBuf}; use std::process::Command; + use std::sync::atomic::{AtomicBool, Ordering}; const TASK: &str = "BroLinkEngineUser"; const CREATE_NO_WINDOW: u32 = 0x0800_0000; - fn helper_dir() -> Result { - let dir = brolink_core::config::data_dir()?; - std::fs::create_dir_all(&dir).with_context(|| dir.display().to_string())?; - Ok(dir) - } - + /// The helper scripts, written into the data directory when they are + /// not there as this build has them, and the logon task that runs + /// them, registered once per process. The service calls this every 30 + /// seconds while the engine has no sound, and it used to rewrite all + /// three files and re-register the task each time. pub fn install_helpers() -> Result { - let dir = helper_dir()?; - std::fs::write( - dir.join("take-over-engine.ps1"), - include_str!("../windows/take-over-engine.ps1"), - )?; + static TASK_REGISTERED: AtomicBool = AtomicBool::new(false); + let dir = brolink_core::config::data_dir()?; + let script = dir.join("take-over-engine.ps1"); + write_if_changed(&script, include_str!("../windows/take-over-engine.ps1"))?; let launcher = dir.join("take-over-engine.vbs"); - std::fs::write(&launcher, include_str!("../windows/take-over-engine.vbs"))?; + write_if_changed(&launcher, include_str!("../windows/take-over-engine.vbs"))?; // Earlier versions pointed the task at a .cmd, which kept a terminal // on the desktop for as long as the helper ran. A task an // administrator registered cannot be re-pointed from here, so the // .cmd stays, reduced to handing off to the windowless launcher. - std::fs::write( - dir.join("take-over-engine.cmd"), + write_if_changed( + &dir.join("take-over-engine.cmd"), include_str!("../windows/take-over-engine.cmd"), )?; - let script = dir.join("take-over-engine.ps1"); - let tr = format!("wscript.exe //B //Nologo \"{}\"", launcher.display()); - let ok = create_logon_task(&tr); - if !ok { - tracing::info!("could not register {TASK}; the host will start the engine itself"); + if !TASK_REGISTERED.load(Ordering::Relaxed) { + let tr = format!("wscript.exe //B //Nologo \"{}\"", launcher.display()); + if create_logon_task(&tr) { + TASK_REGISTERED.store(true, Ordering::Relaxed); + } else { + tracing::info!("could not register {TASK}; the host will start the engine itself"); + } } Ok(script) } + fn write_if_changed(path: &Path, text: &str) -> Result<()> { + if std::fs::read(path).is_ok_and(|have| have == text.as_bytes()) { + return Ok(()); + } + std::fs::write(path, text).with_context(|| path.display().to_string()) + } + fn create_logon_task(tr: &str) -> bool { // These helpers live in the user's profile and must never gain an // elevated token through a scheduled task, including on upgrades. diff --git a/crates/host/src/brand.rs b/crates/host/src/brand.rs index 490ed24..5126809 100644 --- a/crates/host/src/brand.rs +++ b/crates/host/src/brand.rs @@ -256,6 +256,18 @@ mod win { (ok.as_bool() && !p.is_null() && len > 0).then_some((p as *const c_void, len)) } + /// `n` UTF-16 units at `p`, which points into a `Vec` and so has no + /// alignment a `&[u16]` could rely on. + /// + /// # Safety + /// `p` must point to `n` readable `u16`s. + unsafe fn read_u16s(p: *const c_void, n: usize) -> Vec { + let p = p as *const u16; + (0..n) + .map(|i| unsafe { p.add(i).read_unaligned() }) + .collect() + } + /// The fixed info, the strings and the language of `exe`'s version /// block; `None` when it has no block at all. #[allow(clippy::type_complexity)] @@ -285,7 +297,7 @@ mod win { } let (lang, cp) = match query(&buf, "\\VarFileInfo\\Translation") { Some((p, len)) if len >= 4 => { - let t = unsafe { std::slice::from_raw_parts(p as *const u16, 2) }; + let t = unsafe { read_u16s(p, 2) }; (t[0], t[1]) } _ => (verinfo::LANG, verinfo::CODEPAGE), @@ -296,8 +308,8 @@ mod win { // the usual one; look in both. for table in [format!("{lang:04X}{cp:04X}"), "040904B0".to_string()] { if let Some((p, len)) = query(&buf, &format!("\\StringFileInfo\\{table}\\{key}")) { - let u = unsafe { std::slice::from_raw_parts(p as *const u16, len as usize) }; - let s = String::from_utf16_lossy(u) + let u = unsafe { read_u16s(p, len as usize) }; + let s = String::from_utf16_lossy(&u) .trim_end_matches('\0') .to_string(); if !s.is_empty() { diff --git a/crates/host/src/clipboard.rs b/crates/host/src/clipboard.rs index e138cca..8e1cb1a 100644 --- a/crates/host/src/clipboard.rs +++ b/crates/host/src/clipboard.rs @@ -111,18 +111,54 @@ unsafe fn open() -> Result<()> { ) } +/// One clipboard handle for the life of the process. On X11 the text +/// belongs to the `arboard::Clipboard` that set it: when the last one is +/// dropped the text goes to a clipboard manager if one takes it within +/// 100 ms, and is otherwise gone before anything on the desktop can paste +/// it. A handle whose connection failed is replaced on the next call. +#[cfg(not(windows))] +fn with_clipboard( + f: impl FnOnce(&mut arboard::Clipboard) -> Result, +) -> Result { + static CLIPBOARD: parking_lot::Mutex> = + parking_lot::Mutex::new(None); + let mut slot = CLIPBOARD.lock(); + let clipboard = match slot.as_mut() { + Some(c) => c, + None => slot.insert(arboard::Clipboard::new()?), + }; + let result = f(clipboard); + if result.is_err() { + *slot = None; + } + Ok(result?) +} + #[cfg(not(windows))] pub fn read() -> Result { - let mut clip = arboard::Clipboard::new()?; - let text = clip.get_text().unwrap_or_default(); - Ok(Clipboard::fit(&text, 0)) + let text = with_clipboard(|c| match c.get_text() { + // No text on the clipboard (an image, or nothing) reads as empty. + Err(arboard::Error::ContentNotAvailable) => Ok(String::new()), + other => other, + })?; + Ok(Clipboard::fit(&text, text_seq(&text))) } #[cfg(not(windows))] pub fn write(text: &str) -> Result<()> { - let mut clip = arboard::Clipboard::new()?; - clip.set_text(text.to_string())?; - Ok(()) + with_clipboard(|c| c.set_text(text.to_string())) +} + +/// macOS and X11 have no sequence number a program can read, so the text +/// stands in for one: it changes whenever different text is copied, which +/// is all a reader needs to tell a new copy from one it has seen. A fixed +/// `seq` made every copy on a Mac or Linux desktop look like the last. +#[cfg(any(not(windows), test))] +fn text_seq(text: &str) -> u64 { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + text.hash(&mut h); + h.finish() } #[cfg(test)] @@ -137,4 +173,11 @@ mod tests { assert_eq!(from_windows(&to_windows("x\ny\n")), "x\ny\n"); assert_eq!(to_windows(""), ""); } + + #[test] + fn a_new_copy_gets_a_new_sequence_number() { + assert_eq!(text_seq("hello"), text_seq("hello")); + assert_ne!(text_seq("hello"), text_seq("hello!")); + assert_ne!(text_seq(""), text_seq(" ")); + } } diff --git a/crates/host/src/main.rs b/crates/host/src/main.rs index 21605fc..f25e85c 100644 --- a/crates/host/src/main.rs +++ b/crates/host/src/main.rs @@ -79,33 +79,9 @@ fn main() -> Result<()> { }); } service::ensure_service_running(); - let icon = if cfg!(target_os = "macos") { - eframe::egui::IconData::default() - } else { - eframe::egui::IconData { - rgba: brolink_core::icon::render(64), - width: 64, - height: 64, - } - }; - let native = eframe::NativeOptions { - viewport: eframe::egui::ViewportBuilder::default() - .with_inner_size([1100.0, 720.0]) - .with_min_inner_size([640.0, 420.0]) - .with_title("BroLink") - .with_icon(icon), - renderer: eframe::Renderer::Wgpu, - vsync: false, - wgpu_options: egui_wgpu::WgpuConfiguration { - present_mode: egui_wgpu::wgpu::PresentMode::AutoNoVsync, - desired_maximum_frame_latency: Some(1), - ..Default::default() - }, - ..Default::default() - }; eframe::run_native( "BroLink", - native, + brolink_client::native_options(), Box::new(|cc| Ok(Box::new(NodeApp::new(cc)))), ) .map_err(|e| anyhow::anyhow!("{e}")) diff --git a/crates/host/src/migrate.rs b/crates/host/src/migrate.rs index 83eec72..8ee5047 100644 --- a/crates/host/src/migrate.rs +++ b/crates/host/src/migrate.rs @@ -2,7 +2,7 @@ //! engine is migrated: copy → verify → start → prove → uninstall, never //! uninstall-first. Elevation is why this is script, not Rust. //! -//! Named list is the live Hermes inventory (task 14): required state is +//! What is copied is what a live MSI install held: required state is //! `sunshine_state.json` plus `credentials/cacert.pem` and `cakey.pem`. //! `apps.json` and `sunshine.conf` are best-effort. The log is regenerated. diff --git a/crates/host/src/service.rs b/crates/host/src/service.rs index 4c0a2b0..de3eddf 100644 --- a/crates/host/src/service.rs +++ b/crates/host/src/service.rs @@ -1,8 +1,13 @@ -//! The background control service: what a Mac on the tailnet talks to. +//! The background control service: what the other machines on the tailnet talk to. //! //! One TCP listener on every interface, one rule for who gets an answer: -//! loopback (the host's own control panel) or a tailnet address that -//! `tailscale whois` recognises. Everyone else gets a 403 and nothing more. +//! loopback (the host's own control panel), or a tailnet machine that +//! `tailscale whois` says belongs to the account this one is signed in as. +//! A tagged machine (the relay VPS) belongs to no account, so it answers +//! the members of its own tailnet. Everyone else gets a 403 and nothing +//! more: a machine shared in from someone else's tailnet can reach the +//! port, but may not pair, sleep the PC, read its clipboard or push it an +//! executable. use crate::clipboard; use crate::config::HostConfig; @@ -15,7 +20,8 @@ use brolink_core::api::{ CLIPBOARD_PATH, UPDATE_PATH, }; use brolink_core::http::{self, Request, Response}; -use brolink_core::{tailscale, CONTROL_PORT}; +use brolink_core::tailscale::{self, is_tailnet_ip}; +use brolink_core::CONTROL_PORT; use parking_lot::Mutex; use std::collections::{HashMap, VecDeque}; use std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpListener}; @@ -26,6 +32,8 @@ use std::time::{Duration, Instant}; const LOG_LINES: usize = 80; /// How long a `whois` answer is trusted before asking again. const AUTH_TTL: Duration = Duration::from_secs(60); +/// How long an answer is remembered, so the same peer is not logged again. +const AUTH_REMEMBER: Duration = Duration::from_secs(3600); const AUTH_CACHE_LIMIT: usize = 256; /// Ticks (of 5 s) between runs of `tailscale netcheck`: it takes seconds /// and the network does not move often. @@ -33,17 +41,23 @@ const NETCHECK_TICKS: u64 = 120; pub struct Service { cfg: Mutex, + /// This machine's name, re-read now and then: asking macOS or Linux + /// runs a program, which every status request used to do. + name: Mutex, tailscale: Mutex>, wake: Mutex, /// When a magic packet for this PC last arrived. wake_seen: Mutex>, - /// Whether the virtual display lists every size a Mac can ask for; + /// Whether the virtual display lists every screen size a viewer can ask for; /// `None` on a PC without one. See `virtual_display`. virtual_display: Mutex>, install: Mutex>, streamer: Mutex, log: Mutex>, - auth: Mutex)>>, + /// `whois` answers for recent peers: when, and whether they may ask. + auth: Mutex>, + /// The last `whois` failure logged. + whois_failed: Mutex>, /// This PC's side of the NAT story, from `tailscale netcheck`. nat: Mutex>, nat_running: AtomicBool, @@ -54,6 +68,7 @@ impl Service { pub fn new() -> Self { Self { cfg: Mutex::new(HostConfig::load()), + name: Mutex::new(brolink_core::config::machine_name()), tailscale: Mutex::new(Err("not checked yet".into())), wake: Mutex::new(WakeInfo::default()), wake_seen: Mutex::new(None), @@ -62,6 +77,7 @@ impl Service { streamer: Mutex::new(Streamer::default()), log: Mutex::new(VecDeque::new()), auth: Mutex::new(HashMap::new()), + whois_failed: Mutex::new(None), nat: Mutex::new(None), nat_running: AtomicBool::new(false), update_running: Arc::new(AtomicBool::new(false)), @@ -324,6 +340,7 @@ impl Service { } if tick.is_multiple_of(12) { + *self.name.lock() = brolink_core::config::machine_name(); let w = wake::probe(); let mut cur = self.wake.lock(); if *cur != w { @@ -331,7 +348,7 @@ impl Service { (Some(mac), Some(true)) => self.log(format!("Wake-on-LAN ready on {} ({mac})", w.adapter)), (Some(_), Some(false)) => self.log(format!("Wake-on-LAN is off on {}; run setup", w.adapter)), (Some(_), None) => self.log(format!("Wake-on-LAN state on {} is unknown", w.adapter)), - (None, _) => self.log("no wired adapter with a MAC found; the Mac will not be able to wake this PC"), + (None, _) => self.log("no wired adapter with a MAC found; other machines will not be able to wake this one"), } *cur = w; } @@ -340,10 +357,10 @@ impl Service { if *cur != v { match v { Some(true) => { - self.log("the virtual display lists every size a Mac can ask for") + self.log("the virtual display lists every screen size a viewer can ask for") } Some(false) => self - .log("the virtual display is missing sizes a Mac can ask for; run setup"), + .log("the virtual display is missing screen sizes a viewer can ask for; run setup"), None => {} } *cur = v; @@ -362,7 +379,7 @@ impl Service { Ok(n) => { let report = n.report(); let mut cur = svc.nat.lock(); - if cur.as_ref() != Some(&report) { + if cur.as_ref().is_none_or(|c| nat_changed(c, &report)) { svc.log(describe_nat(&report)); } *cur = Some(report); @@ -418,13 +435,13 @@ impl Service { } if *self.virtual_display.lock() == Some(false) { setup.push( - "The virtual display does not list every screen size a Mac can ask for.".into(), + "The virtual display does not list every screen size a viewer can ask for.".into(), ); } Status { app: "brolink".into(), version: env!("CARGO_PKG_VERSION").into(), - name: brolink_core::config::machine_name(), + name: self.name.lock().clone(), os: ts .as_ref() .ok() @@ -469,10 +486,9 @@ impl Service { } } - /// Loopback is the control panel. A tailnet peer is anyone `whois` - /// recognises: user machines and `tag:relay` nodes share a tailnet but - /// not a Tailscale user id, and a VPS desktop on the relay box has to - /// accept the Macs that found it. + /// Loopback is the control panel; a tailnet peer must pass + /// [`Owner::admits`]. A `whois` answer is remembered for [`AUTH_TTL`], + /// a refusal included; a failed `whois` is not remembered. fn authorized(&self, ip: IpAddr) -> bool { if ip.is_loopback() { return true; @@ -480,50 +496,63 @@ impl Service { if !is_tailnet_ip(ip) { return false; } - if self.tailscale.lock().is_err() { - return false; - } let now = Instant::now(); - let cached = self - .auth - .lock() - .get(&ip) - .filter(|(at, _)| now - *at < AUTH_TTL) - .map(|(_, u)| *u); - let user = match cached { - Some(u) => u, - None => { - let u = match tailscale::whois(ip) { - Ok(w) => { - self.log(format!( - "{} ({}) asked", - w.node.computed_name, w.user_profile.login_name - )); - Some(w.user_profile.id) - } - Err(e) => { - self.log(format!("whois {ip}: {e}")); - None - } - }; - let mut cache = self.auth.lock(); - cache.retain(|_, (at, _)| at.elapsed() < AUTH_TTL); - if cache.len() >= AUTH_CACHE_LIMIT { - if let Some(oldest) = cache - .iter() - .min_by_key(|(_, (at, _))| *at) - .map(|(ip, _)| *ip) - { - cache.remove(&oldest); - } + let previous = self.auth.lock().get(&ip).copied(); + if let Some((at, allowed)) = previous { + if now - at < AUTH_TTL { + return allowed; + } + } + // Copied out so the lock is not held across the `whois` call. + let owner = match &*self.tailscale.lock() { + Ok(st) => Owner::of(st), + Err(_) => return false, + }; + let allowed = match tailscale::whois(ip) { + Ok(w) => { + let allowed = owner.admits(&w); + // Once per peer, and again if the answer changes or it has + // been away an hour: a Mac polling every few seconds used + // to put a line here every minute, 96% of a Mac's log. + if previous.map(|(_, a)| a) != Some(allowed) { + self.log(format!( + "{} ({}) asked{}", + w.node.computed_name, + w.user_profile.login_name, + if allowed { + "" + } else { + ": not this account, refused" + } + )); } - if u.is_some() { - cache.insert(ip, (Instant::now(), u)); + allowed + } + Err(e) => { + // Not remembered, so asked again on the next request; say + // so once rather than at every poll. + let line = format!("whois {ip}: {e}"); + let mut last = self.whois_failed.lock(); + if last.as_deref() != Some(line.as_str()) { + self.log(line.clone()); + *last = Some(line); } - u + return false; } }; - user.is_some() + let mut cache = self.auth.lock(); + cache.retain(|_, (at, _)| at.elapsed() < AUTH_REMEMBER); + if cache.len() >= AUTH_CACHE_LIMIT { + if let Some(oldest) = cache + .iter() + .min_by_key(|(_, (at, _))| *at) + .map(|(ip, _)| *ip) + { + cache.remove(&oldest); + } + } + cache.insert(ip, (Instant::now(), allowed)); + allowed } fn handle(&self, peer: SocketAddr, req: &Request) -> Response { @@ -561,10 +590,10 @@ impl Service { match (req.method.as_str(), req.path.as_str()) { ("GET", "/v1/status") => Response::json(200, &self.status(local)), ("GET", "/v1/display") => Response::json(200, &self.display()), - ("POST", "/v1/display") => self.set_display(req), + ("POST", "/v1/display") => self.set_display(peer.ip(), req), ("POST", "/v1/pin") => self.pin(req), - ("POST", "/v1/power") => self.power(req), - ("POST", p) if p == UPDATE_PATH => self.update(req), + ("POST", "/v1/power") => self.power(peer.ip(), req), + ("POST", p) if p == UPDATE_PATH => self.update(peer.ip(), req), ("GET", p) if p == CLIPBOARD_PATH => match clipboard::read() { Ok(c) => Response::json(200, &c), Err(e) => Response::json(500, &Ack::err(format!("clipboard: {e}"))), @@ -590,7 +619,7 @@ impl Service { /// A newer `brolink-host.exe` from the Mac: stage it, answer, then swap /// it in and hand over. See [`crate::update`]. - fn update(&self, req: &Request) -> Response { + fn update(&self, from: IpAddr, req: &Request) -> Response { if !cfg!(windows) { // A Mac replaces its own app from GitHub and a container is // rebuilt; swapping a Windows executable in here would only @@ -622,7 +651,7 @@ impl Service { match update::stage(req, &exe) { Ok(version) => { self.log(format!( - "updating to {version}: the Mac sent the new BroLink Host" + "updating to {version}: {from} sent the new BroLink Host" )); let running = self.update_running.clone(); std::thread::spawn(move || { @@ -703,14 +732,14 @@ impl Service { /// setting the Mac can change: an HDR desktop on a PC with no monitor /// captures as black, and nobody can reach the PC's settings to fix it /// when the picture is the thing that is broken. - fn set_display(&self, req: &Request) -> Response { + fn set_display(&self, from: IpAddr, req: &Request) -> Response { let Ok(want) = req.json::() else { return Response::json(400, &Ack::err("expected {\"advanced_color\": true|false}")); }; match crate::display::set_advanced_color(want.advanced_color) { Ok(state) => { self.log(format!( - "the Mac turned the HDR desktop {}", + "{from} turned the HDR desktop {}", if want.advanced_color { "on" } else { "off" } )); Response::json(200, &state) @@ -719,7 +748,7 @@ impl Service { } } - fn power(&self, req: &Request) -> Response { + fn power(&self, from: IpAddr, req: &Request) -> Response { let Ok(p) = req.json::() else { return Response::json( 400, @@ -741,7 +770,7 @@ impl Service { .close_app(); } self.log(format!( - "{} requested from the Mac", + "{} requested by {from}", p.action.label().to_lowercase() )); // Reply first: sleep can suspend the machine before the bytes leave. @@ -766,11 +795,11 @@ impl Default for Service { pub fn describe_nat(n: &NatReport) -> String { let city = tailscale::derp_city(&n.derp); if !n.udp { - "network: UDP is blocked here, so a Mac can only reach this PC through a Tailscale relay" + "network: UDP is blocked here, so other machines reach this one only through a Tailscale relay" .into() } else if n.hard == Some(true) && !n.portmap { format!( - "network: hard NAT with no UPnP; a Mac on another network reaches this PC through the {city} relay unless the router gets UPnP or a forwarded UDP port" + "network: hard NAT with no UPnP; a machine on another network reaches this one through the {city} relay unless the router gets UPnP or a forwarded UDP port" ) } else if n.hard == Some(true) { "network: hard NAT, but the router maps ports; direct connections should work".into() @@ -781,6 +810,65 @@ pub fn describe_nat(n: &NatReport) -> String { } } +/// Whose machine this is, for deciding who may use the control API. +#[derive(Debug, Clone, PartialEq)] +struct Owner { + /// The Tailscale user this machine is signed in as; for a tagged + /// machine, Tailscale's shared "tagged-devices" user. + user: u64, + /// The machine carries ACL tags, so no person owns it. + tagged: bool, + /// `CurrentTailnet.Name`: the owner's login for a personal tailnet, + /// the domain for an organisation's. + tailnet: String, +} + +impl Owner { + fn of(st: &tailscale::Status) -> Self { + Self { + user: st.self_node.user_id, + tagged: !st.self_node.tags.is_empty(), + tailnet: st + .current_tailnet + .as_ref() + .map(|t| t.name.clone()) + .unwrap_or_default(), + } + } + + /// The same account may ask. A tagged machine has no account of its + /// own, so it takes the members of its tailnet: the owner of a + /// personal tailnet, the users of an organisation's domain. A machine + /// shared in from another tailnet is neither, even though `whois` + /// knows it and the tailnet's rules let it reach this port. + fn admits(&self, peer: &tailscale::WhoIs) -> bool { + let user = peer.user_profile.id; + if user == 0 { + return false; + } + if user == self.user { + return true; + } + if !self.tagged || self.tailnet.is_empty() { + return false; + } + let login = peer.user_profile.login_name.as_str(); + login.eq_ignore_ascii_case(&self.tailnet) + || login + .rsplit_once('@') + .is_some_and(|(_, domain)| domain.eq_ignore_ascii_case(&self.tailnet)) + } +} + +/// Whether the NAT itself changed. The nearest relay region moves between +/// runs on its own (Paris, then Chicago, then Paris again on the same +/// network), and logging each move pushed useful lines out of the 80 the +/// window shows. +fn nat_changed(old: &NatReport, new: &NatReport) -> bool { + (old.udp, old.ipv4, old.ipv6, old.hard, old.portmap) + != (new.udp, new.ipv4, new.ipv6, new.hard, new.portmap) +} + /// True when a service answers on loopback. pub fn service_alive() -> bool { http::request( @@ -846,40 +934,78 @@ fn control_host(host: &str) -> bool { return true; } host.parse::().is_ok_and(|addr| { - addr.port() == CONTROL_PORT - && (addr.ip().is_loopback() || matches!(addr.ip(), IpAddr::V4(ip) if is_tailnet(ip))) + addr.port() == CONTROL_PORT && (addr.ip().is_loopback() || is_tailnet_ip(addr.ip())) }) } -/// Tailscale hands out addresses from 100.64.0.0/10. -fn is_tailnet(ip: Ipv4Addr) -> bool { - let o = ip.octets(); - o[0] == 100 && (64..128).contains(&o[1]) -} - -/// IPv4 CGNAT overlay, or IPv6 unique-local (Tailscale uses fd7a:115c:a1e0::/48). -fn is_tailnet_ip(ip: IpAddr) -> bool { - match ip { - IpAddr::V4(v4) => is_tailnet(v4), - IpAddr::V6(v6) => v6.octets()[0] & 0xfe == 0xfc, - } -} - #[cfg(test)] mod tests { use super::*; + fn whois(id: u64, login: &str) -> tailscale::WhoIs { + tailscale::WhoIs { + user_profile: tailscale::User { + id, + login_name: login.into(), + ..Default::default() + }, + ..Default::default() + } + } + + /// The shapes of a real tailnet: a PC and a Mac signed in as one + /// person, a relay VPS tagged `tag:relay`, and a friend's machine + /// shared in from their own tailnet. #[test] - fn tailnet_range_is_recognised() { - assert!(is_tailnet("100.64.0.1".parse().unwrap())); - assert!(is_tailnet("100.127.255.254".parse().unwrap())); - assert!(is_tailnet("100.64.0.10".parse().unwrap())); - assert!(!is_tailnet("100.128.0.1".parse().unwrap())); - assert!(!is_tailnet("192.168.1.2".parse().unwrap())); - assert!(is_tailnet_ip("100.111.100.57".parse().unwrap())); - assert!(is_tailnet_ip("fd7a:115c:a1e0::9e2a:381c".parse().unwrap())); - assert!(!is_tailnet_ip("8.8.8.8".parse().unwrap())); - assert!(!is_tailnet_ip("2001:4860:4860::8888".parse().unwrap())); + fn only_the_account_or_for_a_tagged_machine_its_tailnet_may_ask() { + const ADA: u64 = 21; + const TAGGED: u64 = 18; + let status = |user: u64, tags: &str| { + tailscale::parse_status(&format!( + r#"{{"BackendState":"Running","Self":{{"UserID":{user},"Tags":[{tags}]}}, + "CurrentTailnet":{{"Name":"ada@example.com"}}}}"# + )) + .unwrap() + }; + let mac = whois(ADA, "ada@example.com"); + let relay = whois(TAGGED, "tagged-devices"); + let friend = whois(77, "bob@example.net"); + + let pc = Owner::of(&status(ADA, "")); + assert!(!pc.tagged); + assert!(pc.admits(&mac), "the owner's Mac"); + assert!( + !pc.admits(&friend), + "a shared-in machine may not pair, sleep or update the PC" + ); + assert!( + !pc.admits(&relay), + "an internet-facing relay may not push the PC an executable" + ); + assert!(!pc.admits(&whois(0, "")), "whois without a user"); + + let vps = Owner::of(&status(TAGGED, r#""tag:relay""#)); + assert!(vps.tagged); + assert!(vps.admits(&mac), "a tagged VPS serves its tailnet's owner"); + assert!( + vps.admits(&relay), + "and machines tagged in the same tailnet" + ); + assert!(!vps.admits(&friend)); + + // An organisation's tailnet is named after its domain. + let org = Owner { + tailnet: "example.com".into(), + ..vps.clone() + }; + assert!(org.admits(&whois(5, "ada@EXAMPLE.com"))); + assert!(!org.admits(&whois(6, "eve@example.com.evil"))); + // Without a tailnet name a tagged machine takes nobody but its kind. + let unnamed = Owner { + tailnet: String::new(), + ..vps + }; + assert!(!unnamed.admits(&mac)); } #[test] @@ -905,9 +1031,13 @@ mod tests { "localhost:47850", "100.64.0.10:47850", "[::1]:47850", + "[fd7a:115c:a1e0::1]:47850", ] { assert!(control_host(host), "{host}"); } + for host in ["8.8.8.8:47850", "[2001:db8::1]:47850", "100.64.0.10:80"] { + assert!(!control_host(host), "{host}"); + } let req = Request { headers: vec![("content-type".into(), "application/json-not-really".into())], ..Default::default() @@ -921,15 +1051,15 @@ mod tests { let req = Request::default(); svc.update_running.store(true, Ordering::Release); if cfg!(windows) { - assert_eq!(svc.update(&req).status, 409); + assert_eq!(svc.update(LOOPBACK, &req).status, 409); } else { // Not a Windows PC: refused before the slot is even looked at. - let r = svc.update(&req); + let r = svc.update(LOOPBACK, &req); assert_eq!(r.status, 400); assert!(r.body.contains("Windows PC"), "{}", r.body); } svc.update_running.store(false, Ordering::Release); - assert_eq!(svc.update(&req).status, 400); + assert_eq!(svc.update(LOOPBACK, &req).status, 400); assert!(!svc.update_running.load(Ordering::Acquire)); } @@ -1017,6 +1147,8 @@ mod tests { ); } + const LOOPBACK: IpAddr = IpAddr::V4(Ipv4Addr::LOCALHOST); + fn json_header() -> Vec<(String, String)> { vec![("content-type".into(), "application/json".into())] } @@ -1077,6 +1209,26 @@ mod tests { assert_eq!(r.status, 200); } + #[test] + fn a_new_nearest_relay_is_not_a_new_nat() { + let paris = NatReport { + udp: true, + hard: Some(true), + derp: "par".into(), + ..Default::default() + }; + let chicago = NatReport { + derp: "ord".into(), + ..paris.clone() + }; + assert!(!nat_changed(&paris, &chicago)); + let mapped = NatReport { + portmap: true, + ..paris.clone() + }; + assert!(nat_changed(&paris, &mapped)); + } + #[test] fn the_nat_line_names_the_problem() { let hard = NatReport { diff --git a/crates/host/src/setup.rs b/crates/host/src/setup.rs index 189b4aa..0eaa73a 100644 --- a/crates/host/src/setup.rs +++ b/crates/host/src/setup.rs @@ -382,15 +382,19 @@ if ($needFiles -or -not $svcUp) {{ "Step \"Wake-on-LAN: adapter unknown, skipped\"\n".to_string() } else { format!( - r#"Step "Enabling Wake-on-LAN on '{adapter}'" + r#"# The names are single-quoted literals held in variables. Spliced into +# a double-quoted string, a '$' in a name would expand and a '"' end it. +$wakeAdapter = '{adapter}' +$wakeDevice = '{desc}' +Step "Enabling Wake-on-LAN on '$wakeAdapter'" try {{ - Set-NetAdapterPowerManagement -Name '{adapter}' -WakeOnMagicPacket Enabled -ErrorAction Stop + Set-NetAdapterPowerManagement -Name $wakeAdapter -WakeOnMagicPacket Enabled -ErrorAction Stop }} catch {{ Write-Output " cmdlet failed ($_); the driver keywords below still apply" }} # The NDIS keywords are what the driver reads: magic packet from sleep, from # modern standby, and (Realtek's own keyword) from a full shutdown. ARP and # NS offload keep the card answering for the PC's address while it sleeps, # which is what lets a unicast wake packet reach it through a router. -$g = (Get-NetAdapter -Name '{adapter}' -ErrorAction SilentlyContinue).InterfaceGuid +$g = (Get-NetAdapter -Name $wakeAdapter -ErrorAction SilentlyContinue).InterfaceGuid $k = Get-ChildItem 'HKLM:\SYSTEM\CurrentControlSet\Control\Class\{{4d36e972-e325-11ce-bfc1-08002be10318}}' -ErrorAction SilentlyContinue | Where-Object {{ (Get-ItemProperty $_.PSPath -Name NetCfgInstanceId -ErrorAction SilentlyContinue).NetCfgInstanceId -eq $g }} | Select-Object -First 1 if ($k) {{ $changed = $false @@ -400,9 +404,9 @@ if ($k) {{ $changed = $true }} }} - if ($changed) {{ Restart-NetAdapter -Name '{adapter}' -ErrorAction SilentlyContinue }} + if ($changed) {{ Restart-NetAdapter -Name $wakeAdapter -ErrorAction SilentlyContinue }} }} else {{ Write-Output " no class key for the adapter; keywords unchanged" }} -try {{ powercfg /deviceenablewake '{desc}' | Out-Null }} catch {{ Write-Output " powercfg: $_" }} +try {{ powercfg /deviceenablewake $wakeDevice | Out-Null }} catch {{ Write-Output " powercfg: $_" }} "#, adapter = q(p.adapter), desc = q(p.adapter_description) @@ -569,15 +573,12 @@ pub fn run(p: &Plan<'_>) -> Result<()> { // UAC may run the helper as another administrator, whose profile // holds no host.toml: pass this user's folder so the engine login // and setup.log stay with the account that shares the machine. - let args = std::env::var_os("LOCALAPPDATA") - .filter(|dir| !dir.is_empty()) - .map(|dir| { - format!( - "'--setup-elevated', '--local-app-data', '{}'", - q(&dir.to_string_lossy()) - ) - }) - .unwrap_or_else(|| "'--setup-elevated'".into()); + let args = elevated_args( + std::env::var_os("LOCALAPPDATA") + .filter(|dir| !dir.is_empty()) + .map(|dir| dir.to_string_lossy().into_owned()) + .as_deref(), + ); let launch = format!( "$p = Start-Process -FilePath '{exe}' -Verb RunAs -Wait -PassThru -WindowStyle Hidden -ArgumentList @({args}); if ($null -eq $p) {{ exit 1 }}; exit $p.ExitCode" ); @@ -595,6 +596,40 @@ pub fn run(p: &Plan<'_>) -> Result<()> { } } +/// Run a read-only PowerShell probe and return what it printed. +#[cfg(windows)] +pub(crate) fn powershell(script: &str) -> Result { + use std::os::windows::process::CommandExt; + let out = std::process::Command::new("powershell") + .args(["-NoProfile", "-NonInteractive", "-Command", script]) + .creation_flags(0x0800_0000) + .output() + .context("run powershell")?; + anyhow::ensure!( + out.status.success(), + "powershell exited with {}: {}", + out.status, + String::from_utf8_lossy(&out.stderr).trim() + ); + Ok(String::from_utf8_lossy(&out.stdout).into_owned()) +} + +/// The `-ArgumentList` array for the elevated `--setup-elevated` run. +/// Start-Process joins the elements with spaces and quotes none of them, +/// so a folder with a space in it ("C:\Users\Ada Lovelace\...") has to +/// carry its own double quotes or it arrives as two arguments. A trailing +/// backslash would escape the closing quote, and a folder never needs one. +#[cfg(any(windows, test))] +fn elevated_args(local_app_data: Option<&str>) -> String { + match local_app_data { + Some(dir) => format!( + "'--setup-elevated', '--local-app-data', '\"{}\"'", + q(dir.trim_end_matches('\\')) + ), + None => "'--setup-elevated'".into(), + } +} + // Only the Windows setup path is compiled in a release build; tests use it everywhere. #[cfg_attr(not(windows), allow(dead_code))] /// A PowerShell single-quoted literal: only `'` needs escaping, nothing is @@ -806,6 +841,20 @@ mod tests { .all(|b| b.is_ascii_alphanumeric() || b == b'+' || b == b'/' || b == b'=')); } + #[test] + fn the_elevated_run_gets_the_whole_folder_as_one_argument() { + assert_eq!(elevated_args(None), "'--setup-elevated'"); + assert_eq!( + elevated_args(Some(r"C:\Users\Ada Lovelace\AppData\Local")), + r#"'--setup-elevated', '--local-app-data', '"C:\Users\Ada Lovelace\AppData\Local"'"# + ); + assert_eq!( + elevated_args(Some(r"C:\Users\O'Brien\AppData\Local\")), + r#"'--setup-elevated', '--local-app-data', '"C:\Users\O''Brien\AppData\Local"'"#, + "quote escaped for PowerShell, no trailing backslash before the quote" + ); + } + #[test] fn run_value_quotes_the_path_and_asks_for_background() { let p = PathBuf::from(r"C:\Users\Ada\AppData\Local\BroLink\brolink-host.exe"); @@ -815,8 +864,6 @@ mod tests { ); } - /// The script with its comments stripped: a "must not appear" check has - /// to be about what the script does, not about what it explains. /// Position of `needle` after the preamble's function definitions, so /// an ordering test reads the steps as they run, not the helpers. fn find_in_body(s: &str, needle: &str) -> Option { @@ -824,6 +871,8 @@ mod tests { s[at..].find(needle).map(|i| i + at) } + /// The script with its comments stripped: a "must not appear" check has + /// to be about what the script does, not about what it explains. fn code(s: &str) -> String { s.lines() .filter(|l| !l.trim_start().starts_with('#')) @@ -856,14 +905,29 @@ mod tests { }); assert!(s.contains("--creds 'brolink' 'p''w'"), "{s}"); assert!(!s.contains("Downloading the streaming engine")); - assert!(s.contains("Set-NetAdapterPowerManagement -Name 'Ethernet'")); - assert!(s.contains("Restart-NetAdapter -Name 'Ethernet'")); + assert!(s.contains("$wakeAdapter = 'Ethernet'"), "{s}"); + assert!(s.contains("Set-NetAdapterPowerManagement -Name $wakeAdapter")); + assert!(s.contains("Restart-NetAdapter -Name $wakeAdapter")); assert!(s.contains("'S5WakeOnLan'")); assert!(s.contains("HiberbootEnabled -Value 0")); assert!(s.contains("powercfg /change standby-timeout-ac 0")); assert!(s.contains("powercfg /change hibernate-timeout-ac 0")); assert!(s.contains("protocol=UDP localport=9 program='C:\\x\\brolink-host.exe'")); - assert!(s.contains("powercfg /deviceenablewake 'Realtek PCIe GbE'")); + assert!(s.contains("$wakeDevice = 'Realtek PCIe GbE'")); + assert!(s.contains("powercfg /deviceenablewake $wakeDevice")); + // A name only ever appears as a single-quoted literal: in a + // double-quoted string `$(...)` would run and a `"` end the string. + let named = script(&Plan { + adapter: r#"Eth "$(Stop-Computer)" 'x'"#, + adapter_description: "NIC $env:TEMP", + ..plan(&exe, false) + }); + assert!( + named.contains(r#"$wakeAdapter = 'Eth "$(Stop-Computer)" ''x'''"#), + "{named}" + ); + assert!(named.contains("$wakeDevice = 'NIC $env:TEMP'"), "{named}"); + assert_eq!(named.matches("Stop-Computer").count(), 1, "{named}"); assert!( s.contains("localport=47850 remoteip=100.64.0.0/10 program='C:\\x\\brolink-host.exe'") ); @@ -1022,10 +1086,6 @@ mod tests { fn the_virtual_display_learns_every_size_a_mac_can_ask_for() { let exe = PathBuf::from(r"C:\x\brolink-host.exe"); let s = script(&plan(&exe, false)); - // A copy to try on a real PC: BROLINK_DUMP_SETUP=/tmp/setup.ps1. - if let Ok(path) = std::env::var("BROLINK_DUMP_SETUP") { - std::fs::write(path, &s).expect("dump the script"); - } let body = code(&s); let step = find_in_body(&body, "Listing the sizes a Mac can ask for").expect("step"); let fast = find_in_body(&body, "Turning Fast Startup off").expect("fast startup"); @@ -1409,8 +1469,11 @@ system_tray = enabled assert!(wet.contains("Brand-Engine $dir"), "{wet}"); } - /// The seam the PowerShell syntax gate runs through: the generated script - /// is the real artefact, so it has to be obtainable off Windows. + /// The seam CI's PowerShell syntax check runs through (see + /// `.github/workflows/ci.yml`): the generated script is the real + /// artefact, so it has to be obtainable without running setup. + /// `BROLINK_DUMP_SETUP_SCRIPT=/tmp/setup.ps1` writes one; the other + /// `BROLINK_DUMP_*` variables pick the plan. #[test] fn the_generated_script_can_be_dumped_for_a_syntax_check() { let Ok(out) = std::env::var("BROLINK_DUMP_SETUP_SCRIPT") else { diff --git a/crates/host/src/update.rs b/crates/host/src/update.rs index 6ff6912..d113cd7 100644 --- a/crates/host/src/update.rs +++ b/crates/host/src/update.rs @@ -8,6 +8,8 @@ //! Windows lets a running executable be renamed but not overwritten. The new //! exe is started with `--replaces `, which makes it wait for the old //! service to let go of the port, and the old one exits after answering. +//! A new exe that cannot start, or exits at once, is moved aside and the +//! old one put back, still serving. use anyhow::{Context, Result}; use brolink_core::api::{UPDATE_MAX_BYTES, UPDATE_SHA256_HEADER, UPDATE_VERSION_HEADER}; @@ -20,6 +22,10 @@ use std::time::Duration; /// Anything smaller is not the host. const MIN_SIZE: usize = 1024 * 1024; +/// How long a new copy must stay up before the old one lets go. It waits +/// for the old one's port, so it is still running after this unless it +/// could not start at all. +const STARTUP_GRACE: Duration = Duration::from_secs(2); /// Why an upload was not staged, with the HTTP status it earns. #[derive(Debug, PartialEq, Eq)] @@ -134,6 +140,10 @@ fn is_host_executable(bytes: &[u8]) -> bool { /// Swap the staged executable in and start it. The caller exits afterwards; /// the new process waits for that before it takes the port. pub fn apply(exe: &Path) -> Result<()> { + apply_within(exe, STARTUP_GRACE) +} + +fn apply_within(exe: &Path, grace: Duration) -> Result<()> { let new = staged(exe); anyhow::ensure!(new.exists(), "nothing is staged at {}", new.display()); let old = { @@ -152,6 +162,14 @@ pub fn apply(exe: &Path) -> Result<()> { rename_retry(&old, exe).context("restore the previous executable after a failed swap")?; return Err(e).context("move the new executable in"); } + // Keep the old service installed if the replacement cannot run: this + // process is still serving requests, and a PC nobody can reach in + // person must not be left with a service that never starts. + let restore = |why: anyhow::Error| -> Result<()> { + rename_retry(exe, &new).context("move the failed replacement aside")?; + rename_retry(&old, exe).context("restore the previous executable")?; + Err(why.context("the previous executable is back in place")) + }; let mut c = std::process::Command::new(exe); c.arg("--background") .arg("--replaces") @@ -164,12 +182,15 @@ pub fn apply(exe: &Path) -> Result<()> { use std::os::windows::process::CommandExt; c.creation_flags(0x0000_0008 | 0x0800_0000); // DETACHED_PROCESS | CREATE_NO_WINDOW } - if let Err(e) = c.spawn() { - // Keep the old service installed if Windows cannot start the - // replacement. The current process is still serving requests. - rename_retry(exe, &new).context("move failed replacement aside")?; - rename_retry(&old, exe).context("restore previous executable after launch failure")?; - return Err(e).context("start the new service (previous executable restored)"); + let mut child = match c.spawn() { + Ok(child) => child, + Err(e) => return restore(anyhow::Error::new(e).context("start the new service")), + }; + // One that has already exited could not start (a crash, a missing + // library): it would leave the PC with no service after this one quits. + std::thread::sleep(grace); + if let Ok(Some(status)) = child.try_wait() { + return restore(anyhow::anyhow!("the new service exited at once ({status})")); } Ok(()) } @@ -365,6 +386,26 @@ mod tests { assert!(apply(&exe).is_err()); assert_eq!(std::fs::read(&exe).unwrap(), b"old"); assert_eq!(std::fs::read(staged(&exe)).unwrap(), b"new"); + // A replacement that starts and dies at once is put back too. + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let script = |body: &str| { + std::fs::write(staged(&exe), body).unwrap(); + std::fs::set_permissions(staged(&exe), std::fs::Permissions::from_mode(0o755)) + .unwrap(); + }; + script("#!/bin/sh\nexit 3\n"); + let e = apply_within(&exe, Duration::from_millis(500)).unwrap_err(); + assert!(format!("{e:#}").contains("exited at once"), "{e:#}"); + assert_eq!(std::fs::read(&exe).unwrap(), b"old"); + assert!(staged(&exe).exists()); + // One that keeps running is the new service. + script("#!/bin/sh\nexec sleep 2\n"); + apply_within(&exe, Duration::from_millis(300)).unwrap(); + assert!(std::fs::read(&exe).unwrap().starts_with(b"#!/bin/sh")); + assert_eq!(std::fs::read(retired(&exe)).unwrap(), b"old"); + } std::fs::write(retired(&exe), b"retired").unwrap(); std::fs::write(dir.join("brolink-host.exe.old-notes"), b"keep").unwrap(); tidy(&exe); diff --git a/crates/host/src/virtual_display.rs b/crates/host/src/virtual_display.rs index 6fdfb05..cb528b4 100644 --- a/crates/host/src/virtual_display.rs +++ b/crates/host/src/virtual_display.rs @@ -119,7 +119,7 @@ pub fn probe() -> Option { "{}if ($vddDevices.Count -eq 0) {{ 'none' }} elseif ($vddMissing.Count -eq 0) {{ 'ok' }} else {{ 'missing' }}", locate_ps() ); - match powershell(&script) { + match crate::setup::powershell(&script) { Ok(o) => parse_probe(&o), Err(e) => { tracing::debug!("virtual display probe: {e:#}"); @@ -140,24 +140,6 @@ fn parse_probe(out: &str) -> Option { } } -#[cfg(windows)] -fn powershell(script: &str) -> anyhow::Result { - use anyhow::Context; - use std::os::windows::process::CommandExt; - let out = std::process::Command::new("powershell") - .args(["-NoProfile", "-NonInteractive", "-Command", script]) - .creation_flags(0x0800_0000) - .output() - .context("run powershell")?; - anyhow::ensure!( - out.status.success(), - "powershell exited with {}: {}", - out.status, - String::from_utf8_lossy(&out.stderr).trim() - ); - Ok(String::from_utf8_lossy(&out.stdout).into_owned()) -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/host/src/wake.rs b/crates/host/src/wake.rs index 718f660..3115a48 100644 --- a/crates/host/src/wake.rs +++ b/crates/host/src/wake.rs @@ -57,7 +57,7 @@ pub fn probe() -> WakeInfo { $armed = @(powercfg /devicequery wake_armed) -contains $n.InterfaceDescription; \ $fs = (Get-ItemProperty 'HKLM:\\SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Power' -ErrorAction SilentlyContinue).HiberbootEnabled; \ Write-Output \"$($n.MacAddress)|$($n.Name)|$($n.InterfaceDescription)|$ip|$wol|$armed|$fs\""; - match powershell(SCRIPT) { + match crate::setup::powershell(SCRIPT) { Ok(o) => parse_probe(&o), Err(e) => { tracing::debug!("wake probe: {e:#}"); @@ -103,23 +103,6 @@ fn parse_probe(line: &str) -> WakeInfo { } } -#[cfg(windows)] -fn powershell(script: &str) -> Result { - use std::os::windows::process::CommandExt; - let out = std::process::Command::new("powershell") - .args(["-NoProfile", "-NonInteractive", "-Command", script]) - .creation_flags(0x0800_0000) - .output() - .context("run powershell")?; - anyhow::ensure!( - out.status.success(), - "powershell exited with {}: {}", - out.status, - String::from_utf8_lossy(&out.stderr).trim() - ); - Ok(String::from_utf8_lossy(&out.stdout).into_owned()) -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/stream/Cargo.toml b/crates/stream/Cargo.toml index d890bd5..e8404e9 100644 --- a/crates/stream/Cargo.toml +++ b/crates/stream/Cargo.toml @@ -14,7 +14,6 @@ anyhow.workspace = true tracing.workspace = true rand.workspace = true parking_lot.workspace = true -serde.workspace = true # Pairing: the client identity is an RSA key and a self-signed certificate, # the PIN exchange is AES-128-ECB + SHA-256, and the paired HTTPS channel # presents that certificate to Sunshine. diff --git a/crates/stream/build.rs b/crates/stream/build.rs index d3a8da2..e6dbed3 100644 --- a/crates/stream/build.rs +++ b/crates/stream/build.rs @@ -1,10 +1,46 @@ //! Compiles moonlight-common-c (with its ENet and Reed-Solomon dependencies) //! and the small C shim that adapts its callback structs to plain function //! pointers. Crypto is not compiled from C: `src/crypto.rs` provides the -//! `Plt*` functions the library expects. +//! `Plt*` functions the library expects. What is vendored, and why, is in +//! `third_party/moonlight-common-c/VERSION`. use std::path::Path; +/// Every C file the library needs, relative to the vendored root. Listed +/// rather than globbed, so a re-vendor that brings new files in has to +/// decide about each one. +const SOURCES: &[&str] = &[ + "src/AudioStream.c", + "src/ByteBuffer.c", + "src/Connection.c", + "src/ControlStream.c", + "src/FakeCallbacks.c", + "src/InputStream.c", + "src/LinkedBlockingQueue.c", + "src/Misc.c", + "src/Platform.c", + "src/PlatformSockets.c", + "src/RtpAudioQueue.c", + "src/RtpVideoQueue.c", + "src/RtspConnection.c", + "src/RtspParser.c", + "src/SdpGenerator.c", + "src/VideoDepacketizer.c", + "src/VideoStream.c", + "enet/callbacks.c", + "enet/host.c", + "enet/list.c", + "enet/packet.c", + "enet/peer.c", + "enet/protocol.c", + // Each is empty on the other platform. + "enet/unix.c", + "enet/win32.c", + "nanors/rs.c", + "nanors/deps/obl/oblas_common.c", + "nanors/deps/obl/oblas_lite.c", +]; + fn main() { let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../third_party/moonlight-common-c"); println!("cargo:rerun-if-changed=csrc"); @@ -27,16 +63,8 @@ fn main() { .define("NDEBUG", None) .define("HAS_SOCKLEN_T", "1") .warnings(false) - .opt_level(2); - for f in list(&root.join("src")) { - b.file(f); - } - for f in list(&root.join("enet")) { - b.file(f); - } - b.file(root.join("nanors/rs.c")) - .file(root.join("nanors/deps/obl/oblas_common.c")) - .file(root.join("nanors/deps/obl/oblas_lite.c")) + .opt_level(2) + .files(SOURCES.iter().map(|f| root.join(f))) .file("csrc/shim.c"); if target_os == "windows" { b.define("_CRT_SECURE_NO_WARNINGS", None); @@ -52,13 +80,3 @@ fn main() { } b.compile("moonlight-common-c"); } - -fn list(dir: &Path) -> Vec { - let mut v: Vec<_> = std::fs::read_dir(dir) - .unwrap_or_else(|e| panic!("{}: {e}", dir.display())) - .filter_map(|e| e.ok().map(|e| e.path())) - .filter(|p| p.extension().is_some_and(|x| x == "c")) - .collect(); - v.sort(); - v -} diff --git a/crates/stream/src/audio.rs b/crates/stream/src/audio.rs index fd13f77..bcad24b 100644 --- a/crates/stream/src/audio.rs +++ b/crates/stream/src/audio.rs @@ -9,7 +9,8 @@ use anyhow::{bail, Result}; use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; use parking_lot::Mutex; use std::collections::VecDeque; -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::mpsc; use std::sync::Arc; /// Queued sound beyond this is cut back to [`KEEP_MS`]. @@ -39,7 +40,9 @@ pub struct Player { pcm: Vec, /// Interleaved samples at the stream's rate and channel count. queue: Arc>>, - stop: Arc, + /// Dropped with the player, which ends the output thread and with it + /// the device stream. + _output_thread: mpsc::Sender<()>, output: Arc>, /// Frames the output device has pulled, silence included: proof that /// sound is leaving. @@ -48,7 +51,6 @@ pub struct Player { keep: usize, packets: u64, lost: u64, - decoded: u64, undecodable: u64, } @@ -105,14 +107,14 @@ impl Player { bail!("Opus decoder failed ({err})"); } let queue: Arc>> = Arc::default(); - let stop = Arc::new(AtomicBool::new(false)); + let (stop, stopped) = mpsc::channel(); let output = Arc::new(Mutex::new(Output::Opening)); let pulled = Arc::new(AtomicU64::new(0)); output_thread( sample_rate, channels as u16, queue.clone(), - stop.clone(), + stopped, output.clone(), pulled.clone(), ); @@ -127,14 +129,13 @@ impl Player { // negotiated frame would refuse a longer packet outright. pcm: vec![0.0; max_frame * channels], queue, - stop, + _output_thread: stop, output, pulled, max_queued: per_ms * MAX_QUEUED_MS, keep, packets: 0, lost: 0, - decoded: 0, undecodable: 0, }) } @@ -173,7 +174,6 @@ impl Player { } return; } - self.decoded += n as u64; let samples = &self.pcm[..n as usize * self.channels]; let mut q = self.queue.lock(); q.extend(samples); @@ -187,16 +187,6 @@ impl Player { self.output.lock().clone() } - /// Packets received from the PC, lost ones not counted. - pub fn packets(&self) -> u64 { - self.packets - } - - /// Samples per channel decoded so far. - pub fn decoded(&self) -> u64 { - self.decoded - } - /// Frames the output device has taken so far. pub fn pulled(&self) -> u64 { self.pulled.load(Ordering::Relaxed) @@ -232,7 +222,6 @@ fn phrase(output: &Output, packets: u64, lost: u64, pulled: u64) -> String { impl Drop for Player { fn drop(&mut self) { - self.stop.store(true, Ordering::Relaxed); unsafe { audiopus_sys::opus_multistream_decoder_destroy(self.decoder) }; } } @@ -246,7 +235,7 @@ fn output_thread( source_rate: u32, source_channels: u16, queue: Arc>>, - stop: Arc, + stop: mpsc::Receiver<()>, output: Arc>, pulled: Arc, ) { @@ -294,14 +283,37 @@ fn output_thread( source_rate as usize * source_channels as usize * 40 / 1000, ) }; - let err_fn = |e| tracing::warn!("audio output: {e}"); + // CoreAudio moves a default-device stream to the new default by + // itself and reports it as an error; only the name shown needs + // to follow. It used to be logged as a warning and the stats kept + // naming the device sound had left. + let err_fn = { + let output = output.clone(); + move |e: cpal::Error| { + if e.kind() != cpal::ErrorKind::DeviceChanged { + tracing::warn!("audio output: {e}"); + return; + } + let Some(now) = cpal::default_host() + .default_output_device() + .map(|d| d.to_string()) + .filter(|n| !n.is_empty()) + else { + return; + }; + tracing::info!("audio: now playing on {now}"); + if let Output::Playing { device, .. } = &mut *output.lock() { + *device = now; + } + } + }; let stream = match supported.sample_format() { cpal::SampleFormat::F32 => { let mut m = mixer(); device.build_output_stream( config, move |out: &mut [f32], _| m.fill(out), - err_fn, + err_fn.clone(), None, ) } @@ -310,7 +322,7 @@ fn output_thread( device.build_output_stream( config, move |out: &mut [i16], _| m.fill_i16(out), - err_fn, + err_fn.clone(), None, ) } @@ -319,7 +331,7 @@ fn output_thread( device.build_output_stream( config, move |out: &mut [i32], _| m.fill_i32(out), - err_fn, + err_fn.clone(), None, ) } @@ -329,7 +341,7 @@ fn output_thread( match device.build_output_stream( config, move |out: &mut [f32], _| m.fill(out), - err_fn, + err_fn.clone(), None, ) { Ok(s) => Ok(s), @@ -339,7 +351,7 @@ fn output_thread( device.build_output_stream( config, move |out: &mut [i16], _| m.fill_i16(out), - err_fn, + err_fn.clone(), None, ) } @@ -365,9 +377,8 @@ fn output_thread( rate, channels, }; - while !stop.load(Ordering::Relaxed) { - std::thread::sleep(std::time::Duration::from_millis(50)); - } + // Returns when the player, and with it the sender, is dropped. + let _ = stop.recv(); }) .expect("spawn audio thread"); } @@ -547,12 +558,12 @@ mod tests { for _ in 0..10 { p.push(&packet); } - assert_eq!(p.packets(), 10); - assert_eq!(p.decoded(), 10 * 480); + assert_eq!(p.packets, 10); + assert_eq!(p.queue.lock().len(), 10 * 480 * 2, "480 stereo frames each"); // A lost packet is concealed at the stream's frame size. p.push(&[]); - assert_eq!(p.decoded(), 11 * 480); - assert_eq!(p.undecodable, 0); + assert_eq!(p.queue.lock().len(), 11 * 480 * 2); + assert_eq!((p.lost, p.undecodable), (1, 0)); // The queue never grows past the cap, and never by a partial frame. for _ in 0..100 { p.push(&packet); diff --git a/crates/stream/src/ffi.rs b/crates/stream/src/ffi.rs index d2e0144..423cfc1 100644 --- a/crates/stream/src/ffi.rs +++ b/crates/stream/src/ffi.rs @@ -1,8 +1,6 @@ //! Bindings to the C shim (`csrc/shim.h`) and to the moonlight-common-c input //! functions, which take only primitive arguments and need no shim. -#![allow(non_camel_case_types, dead_code)] - use std::os::raw::{c_char, c_int, c_uint, c_void}; #[repr(C)] @@ -50,11 +48,8 @@ pub struct ServerInfo { } pub const STREAM_CFG_LOCAL: c_int = 0; -pub const STREAM_CFG_REMOTE: c_int = 1; -pub const STREAM_CFG_AUTO: c_int = 2; pub const COLORSPACE_REC_709: c_int = 1; pub const COLOR_RANGE_LIMITED: c_int = 0; -pub const COLOR_RANGE_FULL: c_int = 1; pub const ENCFLG_ALL: c_int = -1; pub const VIDEO_FORMAT_H264: c_int = 0x0001; @@ -65,7 +60,6 @@ pub const VIDEO_FORMAT_MASK_H265: c_int = 0x0F00; pub const CAPABILITY_DIRECT_SUBMIT: c_int = 0x1; /// The decoder copes with a frame that references one older than the last /// (the host then repairs a lost frame without a whole new keyframe). -pub const CAPABILITY_REFERENCE_FRAME_INVALIDATION_AVC: c_int = 0x2; pub const CAPABILITY_REFERENCE_FRAME_INVALIDATION_HEVC: c_int = 0x4; pub const CAPABILITY_SUPPORTS_ARBITRARY_AUDIO_DURATION: c_int = 0x10; @@ -80,7 +74,6 @@ pub const ML_ERROR_UNEXPECTED_EARLY_TERMINATION: c_int = -102; pub const ML_ERROR_PROTECTED_CONTENT: c_int = -103; pub const ML_ERROR_FRAME_CONVERSION: c_int = -104; -pub const CONN_STATUS_OKAY: c_int = 0; pub const CONN_STATUS_POOR: c_int = 1; pub const BUTTON_ACTION_PRESS: c_char = 0x07; @@ -131,9 +124,7 @@ extern "C" { pub fn LiSendHighResHScrollEvent(amount: i16) -> c_int; pub fn LiGetEstimatedRttInfo(rtt: *mut u32, variance: *mut u32) -> bool; pub fn LiGetMicroseconds() -> u64; - pub fn LiGetPendingVideoFrames() -> c_int; pub fn LiGetRTPVideoStats() -> *const RtpVideoStats; - pub fn LiRequestIdrFrame(); } pub fn stage_name(stage: c_int) -> String { diff --git a/crates/stream/src/nvhttp.rs b/crates/stream/src/nvhttp.rs index 1c0c5b3..36d5ee4 100644 --- a/crates/stream/src/nvhttp.rs +++ b/crates/stream/src/nvhttp.rs @@ -208,10 +208,8 @@ impl<'a> Client<'a> { }) } - /// The Moonlight pairing handshake. `pending` is called once the host is - /// holding our first request and waiting for someone to enter `pin` on - /// its side. On success the host certificate is pinned for the later - /// HTTPS calls and returned. + /// [`Client::pair_cancellable`] without the cancel, for the live tests. + #[cfg(test)] pub fn pair( &mut self, pin: &str, @@ -224,7 +222,11 @@ impl<'a> Client<'a> { }) } - /// Like `pair`, but the PIN callback can abort the outstanding socket read. + /// The Moonlight pairing handshake. `pending` is called once the host is + /// holding our first request and waiting for someone to enter `pin` on + /// its side; it returns `true` to cancel, which aborts that request. On + /// success the host certificate is pinned for the later HTTPS calls and + /// returned. pub fn pair_cancellable( &mut self, pin: &str, diff --git a/crates/stream/src/session.rs b/crates/stream/src/session.rs index ff45363..ee1a796 100644 --- a/crates/stream/src/session.rs +++ b/crates/stream/src/session.rs @@ -63,8 +63,6 @@ pub struct Settings { pub bitrate_kbps: u32, /// Ask for HEVC when both sides can; otherwise H.264. pub hevc: bool, - /// The PC is not on this LAN: smaller packets, remote pacing. - pub remote: bool, } /// What `Session::start` needs from `/serverinfo` and `/launch`. @@ -362,33 +360,6 @@ impl Session { inner: self.inner.clone(), } } - - pub fn mouse_move(&self, dx: i16, dy: i16) { - self.input().mouse_move(dx, dy); - } - - pub fn mouse_position(&self, x: i16, y: i16, width: i16, height: i16) { - self.input().mouse_position(x, y, width, height); - } - - /// `button` is one of `ffi::BUTTON_*`. - pub fn mouse_button(&self, button: c_int, down: bool) { - self.input().mouse_button(button, down); - } - - /// `vk` is a Windows virtual-key code; `modifiers` a mask of `ffi::MODIFIER_*`. - pub fn key(&self, vk: i16, down: bool, modifiers: c_char) { - self.input().key(vk, down, modifiers); - } - - pub fn text(&self, text: &str) { - self.input().text(text); - } - - /// Vertical and horizontal scroll in 1/120ths of a wheel click. - pub fn scroll(&self, vertical: i16, horizontal: i16) { - self.input().scroll(vertical, horizontal); - } } impl Drop for Session { @@ -450,11 +421,6 @@ fn sunshine_encoder_kbps(request: u64) -> u64 { kbps } -/// Tailscale (and any RFC 1918 path) is a LAN to the stream protocol. -pub fn lan_like_stream(ip: std::net::Ipv4Addr) -> bool { - brolink_core::tailscale::overlay_or_lan(ip) -} - fn run(inner: Arc, server: Server, s: Settings, ri_key: [u8; 16], ri_iv: [u8; 16]) { let _one_at_a_time = CONNECTION.lock(); if *inner.done.lock() { @@ -546,7 +512,7 @@ unsafe extern "C" fn video_setup( _fps: c_int, ) -> c_int { let Some(inner) = current() else { return -1 }; - match video::new_decoder(format, w as u32, h as u32) { + match video::new_decoder(format) { Ok(d) => { let mut st = inner.stats.lock(); st.width = w as u32; @@ -969,9 +935,8 @@ mod real { let fps = number("BROLINK_TEST_FPS", 60); let bitrate_kbps = number("BROLINK_TEST_BITRATE_KBPS", 10_000); let seconds = number("BROLINK_TEST_SECONDS", 12); - let remote = std::env::var_os("BROLINK_TEST_REMOTE").is_some(); assert!(width > 0 && height > 0 && fps > 0 && bitrate_kbps > 0 && seconds >= 12); - eprintln!("requested: {width}x{height} {fps} fps {bitrate_kbps} kbps remote={remote} duration={seconds}s"); + eprintln!("requested: {width}x{height} {fps} fps {bitrate_kbps} kbps duration={seconds}s"); let dir = std::env::var_os("BROLINK_TEST_DIR") .map(std::path::PathBuf::from) .unwrap_or_else(|| std::env::temp_dir().join("brolink-pair-test")); @@ -1043,7 +1008,6 @@ mod real { fps, bitrate_kbps, hevc: std::env::var_os("BROLINK_TEST_HEVC").is_some(), - remote, }, ri_key, ri_iv, @@ -1069,7 +1033,9 @@ mod real { // raw input sees continuous relative motion. Watch the PC's // cursor (GetCursorPos) to confirm the relative injection lands. let phase = (start.elapsed().as_millis() / 400) % 2; - session.mouse_move(if phase == 0 { 40 } else { -40 }, 0); + session + .input() + .mouse_move(if phase == 0 { 40 } else { -40 }, 0); } if let Some(f) = frames.take() { if frames.seq() % 60 == 1 { @@ -1090,15 +1056,20 @@ mod real { std::thread::sleep(Duration::from_millis(5)); } let stats = session.stats(); - eprintln!("stats: {stats:?}, frames published: {}", frames.seq()); - assert!(connected, "never connected"); - assert!(frames.seq() > 60, "too few frames: {}", frames.seq()); + let published = frames.seq(); + eprintln!("stats: {stats:?}, frames published: {published}"); session.stop(); let t = Instant::now(); while !session.finished() && t.elapsed() < Duration::from_secs(10) { std::thread::sleep(Duration::from_millis(20)); } - assert!(session.finished(), "session did not finish"); eprintln!("stopped in {:?}", t.elapsed()); + // Leave the PC as it was found: end the app this test started. + if !resume { + let _ = client.quit(); + } + assert!(connected, "never connected"); + assert!(published > 60, "too few frames: {published}"); + assert!(session.finished(), "session did not finish"); } } diff --git a/crates/stream/src/video.rs b/crates/stream/src/video.rs index f4c85b9..1cc57dd 100644 --- a/crates/stream/src/video.rs +++ b/crates/stream/src/video.rs @@ -66,18 +66,23 @@ impl Frame { return false; } let black = if self.full_range { 1 } else { 17 }; + // Whole-row reductions rather than a short-circuiting `all`: the + // compiler turns a max or min over a row into vector instructions, + // and the scan still stops at the first row with a visible pixel. + // A black frame is every row, sixty times a second. + let max = |row: &[u8]| row.iter().fold(0, |m, &v| m.max(v)); + let min = |row: &[u8]| row.iter().fold(u8::MAX, |m, &v| m.min(v)); self.y .chunks(self.y_stride) .take(height) - .all(|row| row[..width].iter().all(|&v| v <= black)) + .all(|row| max(&row[..width]) <= black) && self .uv .chunks(self.uv_stride) .take(chroma_height) .all(|row| { - row[..chroma_width] - .iter() - .all(|&v| (127..=129).contains(&v)) + let row = &row[..chroma_width]; + min(row) >= 127 && max(row) <= 129 }) } } @@ -170,22 +175,20 @@ pub fn capabilities() -> i32 { } } -pub fn new_decoder(format: i32, width: u32, height: u32) -> Result> { +pub fn new_decoder(format: i32) -> Result> { #[cfg(target_os = "macos")] { - Ok(Box::new(videotoolbox::VideoToolbox::new( - format, width, height, - )?)) + Ok(Box::new(videotoolbox::VideoToolbox::new(format)?)) } #[cfg(not(target_os = "macos"))] { - let _ = (width, height); Ok(Box::new(openh264::OpenH264::new(format)?)) } } /// Split an Annex B byte stream into NAL units without their start codes. -pub fn nal_units(data: &[u8]) -> Vec<&[u8]> { +#[cfg(any(target_os = "macos", test))] +fn nal_units(data: &[u8]) -> Vec<&[u8]> { let mut starts = Vec::new(); let mut i = 0; while i + 3 <= data.len() { @@ -211,7 +214,8 @@ pub fn nal_units(data: &[u8]) -> Vec<&[u8]> { } /// Interleave I420 chroma planes into one NV12 UV plane. -pub fn interleave_uv( +#[cfg(any(not(target_os = "macos"), test))] +fn interleave_uv( u: &[u8], v: &[u8], stride: usize, diff --git a/crates/stream/src/video/openh264.rs b/crates/stream/src/video/openh264.rs index 10a4ccb..c5941a5 100644 --- a/crates/stream/src/video/openh264.rs +++ b/crates/stream/src/video/openh264.rs @@ -7,7 +7,6 @@ use openh264::formats::YUVSource; pub struct OpenH264 { dec: openh264::decoder::Decoder, - uv: Vec, } impl OpenH264 { @@ -17,7 +16,6 @@ impl OpenH264 { } Ok(Self { dec: openh264::decoder::Decoder::new()?, - uv: Vec::new(), }) } } @@ -30,7 +28,6 @@ impl Decoder for OpenH264 { let (w, h) = yuv.dimensions(); let (ys, us, _) = yuv.strides(); let (cw, ch) = (w.div_ceil(2), h.div_ceil(2)); - interleave_uv(yuv.u(), yuv.v(), us, cw, ch, &mut self.uv); let mut frame = spare.unwrap_or_default(); frame.width = w as u32; frame.height = h as u32; @@ -41,8 +38,7 @@ impl Decoder for OpenH264 { for row in yuv.y().chunks(ys).take(h) { frame.y.extend_from_slice(&row[..w]); } - frame.uv.clear(); - frame.uv.extend_from_slice(&self.uv); + interleave_uv(yuv.u(), yuv.v(), us, cw, ch, &mut frame.uv); Ok(Some(frame)) } diff --git a/crates/stream/src/video/videotoolbox.rs b/crates/stream/src/video/videotoolbox.rs index 60d68d9..0e7934b 100644 --- a/crates/stream/src/video/videotoolbox.rs +++ b/crates/stream/src/video/videotoolbox.rs @@ -3,8 +3,9 @@ //! //! Each access unit arrives in Annex B. Parameter sets go into a format //! description; the slices are re-framed with 4-byte lengths (AVCC/HVCC) into -//! one sample buffer and decoded synchronously, so the output callback runs -//! on this thread before `decode` returns. +//! one sample buffer and decoded synchronously; `decode` also waits out any +//! frame the decoder held back, so the output callback has always run, on +//! this thread, before `decode` returns. use super::{nal_units, Decoder, Frame}; use anyhow::{anyhow, bail, Result}; @@ -139,6 +140,9 @@ extern "C" { frame_refcon: *mut c_void, info_flags_out: *mut u32, ) -> OSStatus; + fn VTDecompressionSessionWaitForAsynchronousFrames( + session: VTDecompressionSessionRef, + ) -> OSStatus; fn VTDecompressionSessionInvalidate(session: VTDecompressionSessionRef); fn VTDecompressionSessionCanAcceptFormatDescription( session: VTDecompressionSessionRef, @@ -157,7 +161,6 @@ extern "C" { pub struct VideoToolbox { hevc: bool, - full_range: bool, hardware: bool, format: CMFormatDescriptionRef, session: VTDecompressionSessionRef, @@ -169,14 +172,13 @@ pub struct VideoToolbox { unsafe impl Send for VideoToolbox {} impl VideoToolbox { - pub fn new(format: i32, _width: u32, _height: u32) -> Result { + pub fn new(format: i32) -> Result { let hevc = format & crate::ffi::VIDEO_FORMAT_MASK_H265 != 0; if !hevc && format & crate::ffi::VIDEO_FORMAT_MASK_H264 == 0 { bail!("unsupported video format {format:#x}"); } Ok(Self { hevc, - full_range: false, hardware: false, format: ptr::null(), session: ptr::null(), @@ -263,11 +265,8 @@ impl VideoToolbox { fn create_session(&mut self) -> Result<()> { unsafe { - let pixel_format: i32 = if self.full_range { - K_CV_PIXEL_FORMAT_420F - } else { - K_CV_PIXEL_FORMAT_420V - } as i32; + // The stream is limited range (`COLOR_RANGE_LIMITED`). + let pixel_format = K_CV_PIXEL_FORMAT_420V as i32; let number = CFNumberCreate( ptr::null(), K_CF_NUMBER_SINT32, @@ -495,6 +494,13 @@ impl Decoder for VideoToolbox { ptr::null_mut(), ); CFRelease(sample); + // `decoded` lives on this stack frame and the callback writes + // through it. Synchronous decode calls it before returning, but + // a decoder may still hold a frame back; wait it out here so + // the callback can never run after this frame is gone. + if status == 0 { + VTDecompressionSessionWaitForAsynchronousFrames(self.session); + } let status = if status != 0 { status } else { decoded.status }; if status != 0 { // -12903 kVTInvalidSessionErr, -12911 kVTVideoDecoderMalfunctionErr @@ -531,7 +537,7 @@ mod tests { fn decodes_visible_pixels_from_h264() { // 64x64, white top half and black bottom half, generated with OpenH264. let encoded = include_bytes!("../../tests/fixtures/gray-bars.h264"); - let mut decoder = VideoToolbox::new(crate::ffi::VIDEO_FORMAT_H264, 64, 64).unwrap(); + let mut decoder = VideoToolbox::new(crate::ffi::VIDEO_FORMAT_H264).unwrap(); let frame = decoder .decode(encoded, true, None) .unwrap() diff --git a/deploy/node/Dockerfile b/deploy/node/Dockerfile index 483d863..24d7c72 100644 --- a/deploy/node/Dockerfile +++ b/deploy/node/Dockerfile @@ -8,8 +8,8 @@ FROM rust:1-bookworm AS build RUN apt-get update && apt-get install -y --no-install-recommends \ - build-essential cmake clang nasm pkg-config libssl-dev \ - libasound2-dev libpulse-dev \ + build-essential cmake clang nasm pkg-config \ + libasound2-dev \ libxkbcommon-dev libwayland-dev libx11-dev libxcursor-dev \ libxrandr-dev libxi-dev libx11-xcb-dev libxcb-render0-dev \ libxcb-shape0-dev libxcb-xfixes0-dev \ diff --git a/docs/MACOS.md b/docs/MACOS.md index e03a36c..18439ff 100644 --- a/docs/MACOS.md +++ b/docs/MACOS.md @@ -69,9 +69,9 @@ xcrun stapler staple dist/BroLink.app 1. If Tailscale is missing or signed out, the top card says so and **Get Tailscale** opens the download page. -2. **Your PCs** lists the Windows machines on your Tailscale account. Each - line says whether it is ready, asleep, or missing something on the PC - side. +2. **Your machines** lists every other machine on your Tailscale account + (Windows, macOS and Linux alike). Each line says whether it is ready, + asleep, or missing something on its side. 3. Click **Connect**. The first time with a PC, BroLink pairs with it: it picks a random PIN, sends it to BroLink Host over Tailscale, and the host enters it for you. You see the PIN but never need it. If there is no diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 11f7677..311e8d0 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -25,12 +25,27 @@ Moonlight and launched it; BroLink itself did wake, pairing and power. - [x] Both Sunshine PIN APIs (with and without pairing ids) - [x] GPL-3.0-or-later +## v3.1 – v3.3 + +- [x] Clipboard sync both ways while streaming (text, up to 32 KB) +- [x] Automatic updates: the Mac fetches releases from GitHub and pushes + the new host to every PC over the control API +- [x] Direct or relayed path shown per PC, with the reason for a relay +- [x] A peer relay you run yourself (`deploy/relay/`) +- [x] Match screen: the stream is the viewing machine's own resolution + +## v4.0 + +- [x] One app on every OS: each machine lists the others and can share + its own desktop (Windows, macOS, Linux, a VPS) +- [x] `deploy/node/` Docker kit and `deploy/native/` units for a VPS desktop +- [x] The chosen bitrate is the one the PC's encoder targets + ## Later - [ ] AV1 decode on M3 and newer (moonlight-common-c negotiates it; the VideoToolbox path is HEVC and H.264 today) - [ ] Gamepads on the Mac (GameController framework to Sunshine's virtual pad) -- [ ] Clipboard sync between Mac and PC - [ ] Signed Windows and macOS binaries (Authenticode, Developer ID and notarization) - [ ] Menu-bar presence on the Mac, tray icon on Windows diff --git a/docs/WINDOWS.md b/docs/WINDOWS.md index a0c51cb..97f4cff 100644 --- a/docs/WINDOWS.md +++ b/docs/WINDOWS.md @@ -84,10 +84,13 @@ requests, all JSON: | `GET /v1/clipboard` | The clipboard as text, with Windows' clipboard sequence number | | `POST /v1/clipboard {"text"}` | Replaces the clipboard, so a ⌘V on the Mac pastes the Mac's text | | `POST /v1/update` | A new `brolink-host.exe`; see Updates | +| `GET /v1/display` | Why a capture may be black: monitors, the desktop's sampled brightness, HDR state, the engine's capture settings | +| `POST /v1/display {"advanced_color"}` | Turns the HDR (advanced colour) desktop off or on | A request is answered only if it comes from loopback or from a Tailscale address that `tailscale whois` attributes to the account this PC is signed -in as. Anything else is refused with a 403 before any action. +in as; a machine shared in from another tailnet is refused too. Anything +else gets a 403 before any action. The service also listens on UDP 9. A magic packet for this PC's MAC that arrives while it is awake is logged and reported in the status, which is diff --git a/scripts/bundle-macos.sh b/scripts/bundle-macos.sh index 69388cc..187a097 100755 --- a/scripts/bundle-macos.sh +++ b/scripts/bundle-macos.sh @@ -1,9 +1,8 @@ #!/usr/bin/env bash set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" -# Unified node binary first (views and shares); viewer-only as a fallback. -# When both a cross-compiled and a native build exist, the newer one is the -# one that was just built. +# The unified node binary (views and shares). When both a cross-compiled +# and a native build exist, the newer one is the one that was just built. newest() { local pick="" for f in "$@"; do @@ -16,12 +15,6 @@ newest() { } BIN="$(newest "$ROOT/target/aarch64-apple-darwin/release/brolink-host" "$ROOT/target/release/brolink-host")" if [[ -z "$BIN" ]]; then - BIN="$(newest "$ROOT/target/aarch64-apple-darwin/release/brolink-client" "$ROOT/target/release/brolink-client")" -fi -if [[ -z "$BIN" ]]; then - BIN="$ROOT/target/release/brolink-host" -fi -if [[ ! -f "$BIN" ]]; then echo "build the app first: cargo build --release -p brolink-host" >&2 exit 1 fi diff --git a/scripts/install-macos-release.sh b/scripts/install-macos-release.sh index 35c86b1..be21274 100644 --- a/scripts/install-macos-release.sh +++ b/scripts/install-macos-release.sh @@ -82,5 +82,12 @@ if [[ -d "$DEST" ]]; then else mv "$STAGE" "$DEST" fi +# The background service keeps running the old binary until it restarts: +# replacing the bundle does not stop it, and launchd restarts it only after +# a failure. Restart it through launchd so it runs the new one now. +AGENT="gui/$(id -u)/dev.brolink.node" +if launchctl print "$AGENT" >/dev/null 2>&1; then + launchctl kickstart -k "$AGENT" && echo "restarted the BroLink background service" +fi echo "installed $DEST" open "$DEST" diff --git a/scripts/install-macos.sh b/scripts/install-macos.sh index 36f27b6..ccb3d0c 100755 --- a/scripts/install-macos.sh +++ b/scripts/install-macos.sh @@ -21,5 +21,12 @@ LSREGISTER="/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchS "$LSREGISTER" -u "$APP" 2>/dev/null || true "$LSREGISTER" -f /Applications/BroLink.app 2>/dev/null || true rm -rf "$APP" +# The background service keeps running the old binary until it restarts: +# replacing the bundle does not stop it, and launchd restarts it only after +# a failure. Restart it through launchd so it runs the new one now. +AGENT="gui/$(id -u)/dev.brolink.node" +if launchctl print "$AGENT" >/dev/null 2>&1; then + launchctl kickstart -k "$AGENT" && echo "restarted the BroLink background service" +fi echo "installed /Applications/BroLink.app" echo "open it; it lists every machine on your Tailscale account, and can share this Mac." diff --git a/third_party/moonlight-common-c/VERSION b/third_party/moonlight-common-c/VERSION index 5819478..f8cf32b 100644 --- a/third_party/moonlight-common-c/VERSION +++ b/third_party/moonlight-common-c/VERSION @@ -1 +1,48 @@ 874ac9548f1bd6f095ef2b435c42cdde460e7821 2026-08-18 + +moonlight-common-c: the GameStream protocol engine BroLink's viewer links +(RTSP handshake, control and input channels, RTP video/audio reassembly with +FEC). crates/stream/build.rs compiles the files listed in its SOURCES array +and nothing else. GPL-3.0-or-later (LICENSE.txt); ENet and nanors keep their +own MIT licences (enet/LICENSE, nanors/LICENSE). + +Upstream + https://github.com/moonlight-stream/moonlight-common-c + 874ac9548f1bd6f095ef2b435c42cdde460e7821 (2026-08-18, + "Add LI_CTYPE_STEAM controller type") + enet submodule https://github.com/cgutman/enet + aca87840b57f045a1f7f9299e4b1b9b8e2a5e2f1 (v1.3.15-118-gaca8784) + nanors submodule https://github.com/sleepybishop/nanors + b1e3c22ca0cdc0bb83e3cd6ed1a2fc77869ed99a + +Trimmed (not present here) + - CMakeLists.txt, cmake/, README.md, and the submodules' build systems, + docs, tests and CI files: build.rs compiles the sources directly. + - src/PlatformCrypto.c: crates/stream/src/crypto.rs implements the same + Plt* functions in Rust, so no OpenSSL or mbedTLS is linked. + - src/ConnectionTester.c (LiTestClientConnectivity) and src/SimpleStun.c + (LiFindExternalAddressIP4): public helpers BroLink never calls. + Limelight.h still declares them; nothing may call them. + - src/RecorderCallbacks.c: only used when LC_DEBUG_RECORD_MODE is defined. + - enet/compress.c: ENet's range coder; moonlight never enables compression. + - nanors rs16*, oblas16*: the 16-bit field code; moonlight uses GF(2^8). + Everything else is byte-for-byte upstream except the two patches below. + +Local patches + 1. src/ControlStream.c: enet_peer_timeout(peer, 2, 10000, 10000) becomes + enet_peer_timeout(peer, 3, 15000, 30000). BroLink streams often cross + a Tailscale peer relay on a long path, where a burst of loss silences + the reliable control channel for seconds; upstream's 10 s limit then + declared the PC dead and ended long sessions with "Connection lost + (code -1)". A dead PC is still noticed within 30 s. + 2. src/SdpGenerator.c: the video bitrate latch is capped at 150000 kbps + instead of GFE's 100000. Sunshine has no 100 Mbps limit and BroLink's + bitrate setting goes to 150 Mbps. + +Re-vendoring + Check out the new upstream commit with submodules, copy src/, enet/*.c, + enet/include/, enet/LICENSE, nanors/rs.[ch], nanors/deps/obl/{oblas_common, + oblas_lite}.[ch], nanors/deps/obl/gf2_8_*.h and nanors/LICENSE, drop the + files listed above, re-apply both patches, update SOURCES in build.rs if + upstream added or removed files, and update the commits here. Then run + `cargo test -p brolink-stream` and a live stream_real test. diff --git a/third_party/moonlight-common-c/enet/compress.c b/third_party/moonlight-common-c/enet/compress.c deleted file mode 100644 index 784489a..0000000 --- a/third_party/moonlight-common-c/enet/compress.c +++ /dev/null @@ -1,654 +0,0 @@ -/** - @file compress.c - @brief An adaptive order-2 PPM range coder -*/ -#define ENET_BUILDING_LIB 1 -#include -#include "enet/enet.h" - -typedef struct _ENetSymbol -{ - /* binary indexed tree of symbols */ - enet_uint8 value; - enet_uint8 count; - enet_uint16 under; - enet_uint16 left, right; - - /* context defined by this symbol */ - enet_uint16 symbols; - enet_uint16 escapes; - enet_uint16 total; - enet_uint16 parent; -} ENetSymbol; - -/* adaptation constants tuned aggressively for small packet sizes rather than large file compression */ -enum -{ - ENET_RANGE_CODER_TOP = 1<<24, - ENET_RANGE_CODER_BOTTOM = 1<<16, - - ENET_CONTEXT_SYMBOL_DELTA = 3, - ENET_CONTEXT_SYMBOL_MINIMUM = 1, - ENET_CONTEXT_ESCAPE_MINIMUM = 1, - - ENET_SUBCONTEXT_ORDER = 2, - ENET_SUBCONTEXT_SYMBOL_DELTA = 2, - ENET_SUBCONTEXT_ESCAPE_DELTA = 5 -}; - -/* context exclusion roughly halves compression speed, so disable for now */ -#undef ENET_CONTEXT_EXCLUSION - -typedef struct _ENetRangeCoder -{ - /* only allocate enough symbols for reasonable MTUs, would need to be larger for large file compression */ - ENetSymbol symbols[4096]; -} ENetRangeCoder; - -void * -enet_range_coder_create (void) -{ - ENetRangeCoder * rangeCoder = (ENetRangeCoder *) enet_malloc (sizeof (ENetRangeCoder)); - if (rangeCoder == NULL) - return NULL; - - return rangeCoder; -} - -void -enet_range_coder_destroy (void * context) -{ - ENetRangeCoder * rangeCoder = (ENetRangeCoder *) context; - if (rangeCoder == NULL) - return; - - enet_free (rangeCoder); -} - -#define ENET_SYMBOL_CREATE(symbol, value_, count_) \ -{ \ - symbol = & rangeCoder -> symbols [nextSymbol ++]; \ - symbol -> value = value_; \ - symbol -> count = count_; \ - symbol -> under = count_; \ - symbol -> left = 0; \ - symbol -> right = 0; \ - symbol -> symbols = 0; \ - symbol -> escapes = 0; \ - symbol -> total = 0; \ - symbol -> parent = 0; \ -} - -#define ENET_CONTEXT_CREATE(context, escapes_, minimum) \ -{ \ - ENET_SYMBOL_CREATE (context, 0, 0); \ - (context) -> escapes = escapes_; \ - (context) -> total = escapes_ + 256*minimum; \ - (context) -> symbols = 0; \ -} - -static enet_uint16 -enet_symbol_rescale (ENetSymbol * symbol) -{ - enet_uint16 total = 0; - for (;;) - { - symbol -> count -= symbol->count >> 1; - symbol -> under = symbol -> count; - if (symbol -> left) - symbol -> under += enet_symbol_rescale (symbol + symbol -> left); - total += symbol -> under; - if (! symbol -> right) break; - symbol += symbol -> right; - } - return total; -} - -#define ENET_CONTEXT_RESCALE(context, minimum) \ -{ \ - (context) -> total = (context) -> symbols ? enet_symbol_rescale ((context) + (context) -> symbols) : 0; \ - (context) -> escapes -= (context) -> escapes >> 1; \ - (context) -> total += (context) -> escapes + 256*minimum; \ -} - -#define ENET_RANGE_CODER_OUTPUT(value) \ -{ \ - if (outData >= outEnd) \ - return 0; \ - * outData ++ = value; \ -} - -#define ENET_RANGE_CODER_ENCODE(under, count, total) \ -{ \ - encodeRange /= (total); \ - encodeLow += (under) * encodeRange; \ - encodeRange *= (count); \ - for (;;) \ - { \ - if((encodeLow ^ (encodeLow + encodeRange)) >= ENET_RANGE_CODER_TOP) \ - { \ - if(encodeRange >= ENET_RANGE_CODER_BOTTOM) break; \ - encodeRange = -encodeLow & (ENET_RANGE_CODER_BOTTOM - 1); \ - } \ - ENET_RANGE_CODER_OUTPUT (encodeLow >> 24); \ - encodeRange <<= 8; \ - encodeLow <<= 8; \ - } \ -} - -#define ENET_RANGE_CODER_FLUSH \ -{ \ - while (encodeLow) \ - { \ - ENET_RANGE_CODER_OUTPUT (encodeLow >> 24); \ - encodeLow <<= 8; \ - } \ -} - -#define ENET_RANGE_CODER_FREE_SYMBOLS \ -{ \ - if (nextSymbol >= sizeof (rangeCoder -> symbols) / sizeof (ENetSymbol) - ENET_SUBCONTEXT_ORDER ) \ - { \ - nextSymbol = 0; \ - ENET_CONTEXT_CREATE (root, ENET_CONTEXT_ESCAPE_MINIMUM, ENET_CONTEXT_SYMBOL_MINIMUM); \ - predicted = 0; \ - order = 0; \ - } \ -} - -#define ENET_CONTEXT_ENCODE(context, symbol_, value_, under_, count_, update, minimum) \ -{ \ - under_ = value*minimum; \ - count_ = minimum; \ - if (! (context) -> symbols) \ - { \ - ENET_SYMBOL_CREATE (symbol_, value_, update); \ - (context) -> symbols = symbol_ - (context); \ - } \ - else \ - { \ - ENetSymbol * node = (context) + (context) -> symbols; \ - for (;;) \ - { \ - if (value_ < node -> value) \ - { \ - node -> under += update; \ - if (node -> left) { node += node -> left; continue; } \ - ENET_SYMBOL_CREATE (symbol_, value_, update); \ - node -> left = symbol_ - node; \ - } \ - else \ - if (value_ > node -> value) \ - { \ - under_ += node -> under; \ - if (node -> right) { node += node -> right; continue; } \ - ENET_SYMBOL_CREATE (symbol_, value_, update); \ - node -> right = symbol_ - node; \ - } \ - else \ - { \ - count_ += node -> count; \ - under_ += node -> under - node -> count; \ - node -> under += update; \ - node -> count += update; \ - symbol_ = node; \ - } \ - break; \ - } \ - } \ -} - -#ifdef ENET_CONTEXT_EXCLUSION -static const ENetSymbol emptyContext = { 0, 0, 0, 0, 0, 0, 0, 0, 0 }; - -#define ENET_CONTEXT_WALK(context, body) \ -{ \ - const ENetSymbol * node = (context) + (context) -> symbols; \ - const ENetSymbol * stack [256]; \ - size_t stackSize = 0; \ - while (node -> left) \ - { \ - stack [stackSize ++] = node; \ - node += node -> left; \ - } \ - for (;;) \ - { \ - body; \ - if (node -> right) \ - { \ - node += node -> right; \ - while (node -> left) \ - { \ - stack [stackSize ++] = node; \ - node += node -> left; \ - } \ - } \ - else \ - if (stackSize <= 0) \ - break; \ - else \ - node = stack [-- stackSize]; \ - } \ -} - -#define ENET_CONTEXT_ENCODE_EXCLUDE(context, value_, under, total, minimum) \ -ENET_CONTEXT_WALK(context, { \ - if (node -> value != value_) \ - { \ - enet_uint16 parentCount = rangeCoder -> symbols [node -> parent].count + minimum; \ - if (node -> value < value_) \ - under -= parentCount; \ - total -= parentCount; \ - } \ -}) -#endif - -size_t -enet_range_coder_compress (void * context, const ENetBuffer * inBuffers, size_t inBufferCount, size_t inLimit, enet_uint8 * outData, size_t outLimit) -{ - ENetRangeCoder * rangeCoder = (ENetRangeCoder *) context; - enet_uint8 * outStart = outData, * outEnd = & outData [outLimit]; - const enet_uint8 * inData, * inEnd; - enet_uint32 encodeLow = 0, encodeRange = ~0; - ENetSymbol * root; - enet_uint16 predicted = 0; - size_t order = 0, nextSymbol = 0; - - if (rangeCoder == NULL || inBufferCount <= 0 || inLimit <= 0) - return 0; - - inData = (const enet_uint8 *) inBuffers -> data; - inEnd = & inData [inBuffers -> dataLength]; - inBuffers ++; - inBufferCount --; - - ENET_CONTEXT_CREATE (root, ENET_CONTEXT_ESCAPE_MINIMUM, ENET_CONTEXT_SYMBOL_MINIMUM); - - for (;;) - { - ENetSymbol * subcontext, * symbol; -#ifdef ENET_CONTEXT_EXCLUSION - const ENetSymbol * childContext = & emptyContext; -#endif - enet_uint8 value; - enet_uint16 count, under, * parent = & predicted, total; - if (inData >= inEnd) - { - if (inBufferCount <= 0) - break; - inData = (const enet_uint8 *) inBuffers -> data; - inEnd = & inData [inBuffers -> dataLength]; - inBuffers ++; - inBufferCount --; - } - value = * inData ++; - - for (subcontext = & rangeCoder -> symbols [predicted]; - subcontext != root; -#ifdef ENET_CONTEXT_EXCLUSION - childContext = subcontext, -#endif - subcontext = & rangeCoder -> symbols [subcontext -> parent]) - { - ENET_CONTEXT_ENCODE (subcontext, symbol, value, under, count, ENET_SUBCONTEXT_SYMBOL_DELTA, 0); - * parent = symbol - rangeCoder -> symbols; - parent = & symbol -> parent; - total = subcontext -> total; -#ifdef ENET_CONTEXT_EXCLUSION - if (childContext -> total > ENET_SUBCONTEXT_SYMBOL_DELTA + ENET_SUBCONTEXT_ESCAPE_DELTA) - ENET_CONTEXT_ENCODE_EXCLUDE (childContext, value, under, total, 0); -#endif - if (count > 0) - { - ENET_RANGE_CODER_ENCODE (subcontext -> escapes + under, count, total); - } - else - { - if (subcontext -> escapes > 0 && subcontext -> escapes < total) - ENET_RANGE_CODER_ENCODE (0, subcontext -> escapes, total); - subcontext -> escapes += ENET_SUBCONTEXT_ESCAPE_DELTA; - subcontext -> total += ENET_SUBCONTEXT_ESCAPE_DELTA; - } - subcontext -> total += ENET_SUBCONTEXT_SYMBOL_DELTA; - if (count > 0xFF - 2*ENET_SUBCONTEXT_SYMBOL_DELTA || subcontext -> total > ENET_RANGE_CODER_BOTTOM - 0x100) - ENET_CONTEXT_RESCALE (subcontext, 0); - if (count > 0) goto nextInput; - } - - ENET_CONTEXT_ENCODE (root, symbol, value, under, count, ENET_CONTEXT_SYMBOL_DELTA, ENET_CONTEXT_SYMBOL_MINIMUM); - * parent = symbol - rangeCoder -> symbols; - parent = & symbol -> parent; - total = root -> total; -#ifdef ENET_CONTEXT_EXCLUSION - if (childContext -> total > ENET_SUBCONTEXT_SYMBOL_DELTA + ENET_SUBCONTEXT_ESCAPE_DELTA) - ENET_CONTEXT_ENCODE_EXCLUDE (childContext, value, under, total, ENET_CONTEXT_SYMBOL_MINIMUM); -#endif - ENET_RANGE_CODER_ENCODE (root -> escapes + under, count, total); - root -> total += ENET_CONTEXT_SYMBOL_DELTA; - if (count > 0xFF - 2*ENET_CONTEXT_SYMBOL_DELTA + ENET_CONTEXT_SYMBOL_MINIMUM || root -> total > ENET_RANGE_CODER_BOTTOM - 0x100) - ENET_CONTEXT_RESCALE (root, ENET_CONTEXT_SYMBOL_MINIMUM); - - nextInput: - if (order >= ENET_SUBCONTEXT_ORDER) - predicted = rangeCoder -> symbols [predicted].parent; - else - order ++; - ENET_RANGE_CODER_FREE_SYMBOLS; - } - - ENET_RANGE_CODER_FLUSH; - - return (size_t) (outData - outStart); -} - -#define ENET_RANGE_CODER_SEED \ -{ \ - if (inData < inEnd) decodeCode |= * inData ++ << 24; \ - if (inData < inEnd) decodeCode |= * inData ++ << 16; \ - if (inData < inEnd) decodeCode |= * inData ++ << 8; \ - if (inData < inEnd) decodeCode |= * inData ++; \ -} - -#define ENET_RANGE_CODER_READ(total) ((decodeCode - decodeLow) / (decodeRange /= (total))) - -#define ENET_RANGE_CODER_DECODE(under, count, total) \ -{ \ - decodeLow += (under) * decodeRange; \ - decodeRange *= (count); \ - for (;;) \ - { \ - if((decodeLow ^ (decodeLow + decodeRange)) >= ENET_RANGE_CODER_TOP) \ - { \ - if(decodeRange >= ENET_RANGE_CODER_BOTTOM) break; \ - decodeRange = -decodeLow & (ENET_RANGE_CODER_BOTTOM - 1); \ - } \ - decodeCode <<= 8; \ - if (inData < inEnd) \ - decodeCode |= * inData ++; \ - decodeRange <<= 8; \ - decodeLow <<= 8; \ - } \ -} - -#define ENET_CONTEXT_DECODE(context, symbol_, code, value_, under_, count_, update, minimum, createRoot, visitNode, createRight, createLeft) \ -{ \ - under_ = 0; \ - count_ = minimum; \ - if (! (context) -> symbols) \ - { \ - createRoot; \ - } \ - else \ - { \ - ENetSymbol * node = (context) + (context) -> symbols; \ - for (;;) \ - { \ - enet_uint16 after = under_ + node -> under + (node -> value + 1)*minimum, before = node -> count + minimum; \ - visitNode; \ - if (code >= after) \ - { \ - under_ += node -> under; \ - if (node -> right) { node += node -> right; continue; } \ - createRight; \ - } \ - else \ - if (code < after - before) \ - { \ - node -> under += update; \ - if (node -> left) { node += node -> left; continue; } \ - createLeft; \ - } \ - else \ - { \ - value_ = node -> value; \ - count_ += node -> count; \ - under_ = after - before; \ - node -> under += update; \ - node -> count += update; \ - symbol_ = node; \ - } \ - break; \ - } \ - } \ -} - -#define ENET_CONTEXT_TRY_DECODE(context, symbol_, code, value_, under_, count_, update, minimum, exclude) \ -ENET_CONTEXT_DECODE (context, symbol_, code, value_, under_, count_, update, minimum, return 0, exclude (node -> value, after, before), return 0, return 0) - -#define ENET_CONTEXT_ROOT_DECODE(context, symbol_, code, value_, under_, count_, update, minimum, exclude) \ -ENET_CONTEXT_DECODE (context, symbol_, code, value_, under_, count_, update, minimum, \ - { \ - value_ = code / minimum; \ - under_ = code - code%minimum; \ - ENET_SYMBOL_CREATE (symbol_, value_, update); \ - (context) -> symbols = symbol_ - (context); \ - }, \ - exclude (node -> value, after, before), \ - { \ - value_ = node->value + 1 + (code - after)/minimum; \ - under_ = code - (code - after)%minimum; \ - ENET_SYMBOL_CREATE (symbol_, value_, update); \ - node -> right = symbol_ - node; \ - }, \ - { \ - value_ = node->value - 1 - (after - before - code - 1)/minimum; \ - under_ = code - (after - before - code - 1)%minimum; \ - ENET_SYMBOL_CREATE (symbol_, value_, update); \ - node -> left = symbol_ - node; \ - }) \ - -#ifdef ENET_CONTEXT_EXCLUSION -typedef struct _ENetExclude -{ - enet_uint8 value; - enet_uint16 under; -} ENetExclude; - -#define ENET_CONTEXT_DECODE_EXCLUDE(context, total, minimum) \ -{ \ - enet_uint16 under = 0; \ - nextExclude = excludes; \ - ENET_CONTEXT_WALK (context, { \ - under += rangeCoder -> symbols [node -> parent].count + minimum; \ - nextExclude -> value = node -> value; \ - nextExclude -> under = under; \ - nextExclude ++; \ - }); \ - total -= under; \ -} - -#define ENET_CONTEXT_EXCLUDED(value_, after, before) \ -{ \ - size_t low = 0, high = nextExclude - excludes; \ - for(;;) \ - { \ - size_t mid = (low + high) >> 1; \ - const ENetExclude * exclude = & excludes [mid]; \ - if (value_ < exclude -> value) \ - { \ - if (low + 1 < high) \ - { \ - high = mid; \ - continue; \ - } \ - if (exclude > excludes) \ - after -= exclude [-1].under; \ - } \ - else \ - { \ - if (value_ > exclude -> value) \ - { \ - if (low + 1 < high) \ - { \ - low = mid; \ - continue; \ - } \ - } \ - else \ - before = 0; \ - after -= exclude -> under; \ - } \ - break; \ - } \ -} -#endif - -#define ENET_CONTEXT_NOT_EXCLUDED(value_, after, before) - -size_t -enet_range_coder_decompress (void * context, const enet_uint8 * inData, size_t inLimit, enet_uint8 * outData, size_t outLimit) -{ - ENetRangeCoder * rangeCoder = (ENetRangeCoder *) context; - enet_uint8 * outStart = outData, * outEnd = & outData [outLimit]; - const enet_uint8 * inEnd = & inData [inLimit]; - enet_uint32 decodeLow = 0, decodeCode = 0, decodeRange = ~0; - ENetSymbol * root; - enet_uint16 predicted = 0; - size_t order = 0, nextSymbol = 0; -#ifdef ENET_CONTEXT_EXCLUSION - ENetExclude excludes [256]; - ENetExclude * nextExclude = excludes; -#endif - - if (rangeCoder == NULL || inLimit <= 0) - return 0; - - ENET_CONTEXT_CREATE (root, ENET_CONTEXT_ESCAPE_MINIMUM, ENET_CONTEXT_SYMBOL_MINIMUM); - - ENET_RANGE_CODER_SEED; - - for (;;) - { - ENetSymbol * subcontext, * symbol, * patch; -#ifdef ENET_CONTEXT_EXCLUSION - const ENetSymbol * childContext = & emptyContext; -#endif - enet_uint8 value = 0; - enet_uint16 code, under, count, bottom, * parent = & predicted, total; - - for (subcontext = & rangeCoder -> symbols [predicted]; - subcontext != root; -#ifdef ENET_CONTEXT_EXCLUSION - childContext = subcontext, -#endif - subcontext = & rangeCoder -> symbols [subcontext -> parent]) - { - if (subcontext -> escapes <= 0) - continue; - total = subcontext -> total; -#ifdef ENET_CONTEXT_EXCLUSION - if (childContext -> total > 0) - ENET_CONTEXT_DECODE_EXCLUDE (childContext, total, 0); -#endif - if (subcontext -> escapes >= total) - continue; - code = ENET_RANGE_CODER_READ (total); - if (code < subcontext -> escapes) - { - ENET_RANGE_CODER_DECODE (0, subcontext -> escapes, total); - continue; - } - code -= subcontext -> escapes; -#ifdef ENET_CONTEXT_EXCLUSION - if (childContext -> total > 0) - { - ENET_CONTEXT_TRY_DECODE (subcontext, symbol, code, value, under, count, ENET_SUBCONTEXT_SYMBOL_DELTA, 0, ENET_CONTEXT_EXCLUDED); - } - else -#endif - { - ENET_CONTEXT_TRY_DECODE (subcontext, symbol, code, value, under, count, ENET_SUBCONTEXT_SYMBOL_DELTA, 0, ENET_CONTEXT_NOT_EXCLUDED); - } - bottom = symbol - rangeCoder -> symbols; - ENET_RANGE_CODER_DECODE (subcontext -> escapes + under, count, total); - subcontext -> total += ENET_SUBCONTEXT_SYMBOL_DELTA; - if (count > 0xFF - 2*ENET_SUBCONTEXT_SYMBOL_DELTA || subcontext -> total > ENET_RANGE_CODER_BOTTOM - 0x100) - ENET_CONTEXT_RESCALE (subcontext, 0); - goto patchContexts; - } - - total = root -> total; -#ifdef ENET_CONTEXT_EXCLUSION - if (childContext -> total > 0) - ENET_CONTEXT_DECODE_EXCLUDE (childContext, total, ENET_CONTEXT_SYMBOL_MINIMUM); -#endif - code = ENET_RANGE_CODER_READ (total); - if (code < root -> escapes) - { - ENET_RANGE_CODER_DECODE (0, root -> escapes, total); - break; - } - code -= root -> escapes; -#ifdef ENET_CONTEXT_EXCLUSION - if (childContext -> total > 0) - { - ENET_CONTEXT_ROOT_DECODE (root, symbol, code, value, under, count, ENET_CONTEXT_SYMBOL_DELTA, ENET_CONTEXT_SYMBOL_MINIMUM, ENET_CONTEXT_EXCLUDED); - } - else -#endif - { - ENET_CONTEXT_ROOT_DECODE (root, symbol, code, value, under, count, ENET_CONTEXT_SYMBOL_DELTA, ENET_CONTEXT_SYMBOL_MINIMUM, ENET_CONTEXT_NOT_EXCLUDED); - } - bottom = symbol - rangeCoder -> symbols; - ENET_RANGE_CODER_DECODE (root -> escapes + under, count, total); - root -> total += ENET_CONTEXT_SYMBOL_DELTA; - if (count > 0xFF - 2*ENET_CONTEXT_SYMBOL_DELTA + ENET_CONTEXT_SYMBOL_MINIMUM || root -> total > ENET_RANGE_CODER_BOTTOM - 0x100) - ENET_CONTEXT_RESCALE (root, ENET_CONTEXT_SYMBOL_MINIMUM); - - patchContexts: - for (patch = & rangeCoder -> symbols [predicted]; - patch != subcontext; - patch = & rangeCoder -> symbols [patch -> parent]) - { - ENET_CONTEXT_ENCODE (patch, symbol, value, under, count, ENET_SUBCONTEXT_SYMBOL_DELTA, 0); - * parent = symbol - rangeCoder -> symbols; - parent = & symbol -> parent; - if (count <= 0) - { - patch -> escapes += ENET_SUBCONTEXT_ESCAPE_DELTA; - patch -> total += ENET_SUBCONTEXT_ESCAPE_DELTA; - } - patch -> total += ENET_SUBCONTEXT_SYMBOL_DELTA; - if (count > 0xFF - 2*ENET_SUBCONTEXT_SYMBOL_DELTA || patch -> total > ENET_RANGE_CODER_BOTTOM - 0x100) - ENET_CONTEXT_RESCALE (patch, 0); - } - * parent = bottom; - - ENET_RANGE_CODER_OUTPUT (value); - - if (order >= ENET_SUBCONTEXT_ORDER) - predicted = rangeCoder -> symbols [predicted].parent; - else - order ++; - ENET_RANGE_CODER_FREE_SYMBOLS; - } - - return (size_t) (outData - outStart); -} - -/** @defgroup host ENet host functions - @{ -*/ - -/** Sets the packet compressor the host should use to the default range coder. - @param host host to enable the range coder for - @returns 0 on success, < 0 on failure -*/ -int -enet_host_compress_with_range_coder (ENetHost * host) -{ - ENetCompressor compressor; - memset (& compressor, 0, sizeof (compressor)); - compressor.context = enet_range_coder_create(); - if (compressor.context == NULL) - return -1; - compressor.compress = enet_range_coder_compress; - compressor.decompress = enet_range_coder_decompress; - compressor.destroy = enet_range_coder_destroy; - enet_host_compress (host, & compressor); - return 0; -} - -/** @} */ - - diff --git a/third_party/moonlight-common-c/src/ConnectionTester.c b/third_party/moonlight-common-c/src/ConnectionTester.c deleted file mode 100644 index d993834..0000000 --- a/third_party/moonlight-common-c/src/ConnectionTester.c +++ /dev/null @@ -1,308 +0,0 @@ -#include "Limelight-internal.h" - -#define TEST_PORT_TIMEOUT_SEC 3 - -#define VALID_PORT_FLAG_MASK (ML_PORT_FLAG_TCP_47984 | \ - ML_PORT_FLAG_TCP_47989 | \ - ML_PORT_FLAG_TCP_48010 | \ - ML_PORT_FLAG_UDP_47998 | \ - ML_PORT_FLAG_UDP_47999 | \ - ML_PORT_FLAG_UDP_48000 | \ - ML_PORT_FLAG_UDP_48010) - -#define PORT_FLAGS_MAX_COUNT 32 - -#define MTU_TEST_SIZE 1040 - -unsigned int LiGetPortFlagsFromStage(int stage) -{ - switch (stage) - { - case STAGE_RTSP_HANDSHAKE: - // GFE 3.22 requires a successful ping on 48000 to complete RTSP handshake - return ML_PORT_FLAG_TCP_48010 | ML_PORT_FLAG_UDP_48010 | ML_PORT_FLAG_UDP_48000; - - case STAGE_CONTROL_STREAM_START: - return ML_PORT_FLAG_UDP_47999; - - default: - return 0; - } -} - -unsigned int LiGetPortFlagsFromTerminationErrorCode(int errorCode) -{ - switch (errorCode) - { - case ML_ERROR_NO_VIDEO_TRAFFIC: - // Video is UDP 47998, but we'll also test UDP 48000 because - // we don't have an equivalent audio traffic error. - return ML_PORT_FLAG_UDP_47998 | ML_PORT_FLAG_UDP_48000; - - default: - return 0; - } -} - -int LiGetProtocolFromPortFlagIndex(int portFlagIndex) -{ - // The lower byte is reserved for TCP - return (portFlagIndex >= 8) ? IPPROTO_UDP : IPPROTO_TCP; -} - -unsigned short LiGetPortFromPortFlagIndex(int portFlagIndex) -{ - switch (portFlagIndex) - { - // TCP ports - case ML_PORT_INDEX_TCP_47984: - return 47984; - case ML_PORT_INDEX_TCP_47989: - return 47989; - case ML_PORT_INDEX_TCP_48010: - return 48010; - - // UDP ports - case ML_PORT_INDEX_UDP_47998: - return 47998; - case ML_PORT_INDEX_UDP_47999: - return 47999; - case ML_PORT_INDEX_UDP_48000: - return 48000; - case ML_PORT_INDEX_UDP_48010: - return 48010; - - default: - LC_ASSERT(false); - return 0; - } -} - -void LiStringifyPortFlags(unsigned int portFlags, const char* separator, char* outputBuffer, int outputBufferLength) -{ - // Initialize the output buffer to an empty string - outputBuffer[0] = 0; - - // If there is no separator specified, use an empty string - if (separator == NULL) { - separator = ""; - } - - int offset = 0; - for (int i = 0; i < PORT_FLAGS_MAX_COUNT; i++) { - if (portFlags & (1U << i)) { - const char* protoStr = LiGetProtocolFromPortFlagIndex(i) == IPPROTO_UDP ? "UDP" : "TCP"; - offset += snprintf(&outputBuffer[offset], outputBufferLength - offset, "%s%s %u", - offset != 0 ? separator : "", - protoStr, - LiGetPortFromPortFlagIndex(i)); - if (outputBufferLength - offset <= 0) { - // snprintf() will return the desired length if the buffer is too small, - // so it is possible for this calculation to be negative. - break; - } - } - } -} - -unsigned int LiTestClientConnectivity(const char* testServer, unsigned short referencePort, unsigned int testPortFlags) -{ - unsigned int failingPortFlags; - struct sockaddr_storage address; - SOCKADDR_LEN address_length; - int i; - int err; - SOCKET sockets[PORT_FLAGS_MAX_COUNT]; - - // Mask out invalid ports from the port flags - testPortFlags &= VALID_PORT_FLAG_MASK; - failingPortFlags = testPortFlags; - - // If no ports were specified, just return 0 - if (testPortFlags == 0) { - return 0; - } - - // Initialize sockets array to -1 - memset(sockets, 0xFF, sizeof(sockets)); - - err = initializePlatformSockets(); - if (err != 0) { - Limelog("Failed to initialize sockets: %d\n", err); - return ML_TEST_RESULT_INCONCLUSIVE; - } - - err = resolveHostName(testServer, AF_UNSPEC, TCP_PORT_FLAG_ALWAYS_TEST | referencePort, &address, &address_length); - if (err != 0) { - failingPortFlags = ML_TEST_RESULT_INCONCLUSIVE; - goto Exit; - } - - for (i = 0; i < PORT_FLAGS_MAX_COUNT; i++) { - if (testPortFlags & (1U << i)) { - sockets[i] = createSocket(address.ss_family, - LiGetProtocolFromPortFlagIndex(i) == IPPROTO_UDP ? SOCK_DGRAM : SOCK_STREAM, - LiGetProtocolFromPortFlagIndex(i), - true); - if (sockets[i] == INVALID_SOCKET) { - err = LastSocketFail(); - Limelog("Failed to create socket: %d\n", err); - failingPortFlags = ML_TEST_RESULT_INCONCLUSIVE; - goto Exit; - } - - SET_PORT((LC_SOCKADDR*)&address, LiGetPortFromPortFlagIndex(i)); - if (LiGetProtocolFromPortFlagIndex(i) == IPPROTO_TCP) { - // Initiate an asynchronous connection - err = connect(sockets[i], (struct sockaddr*)&address, address_length); - if (err < 0) { - err = (int)LastSocketError(); - if (err != EWOULDBLOCK && err != EAGAIN && err != EINPROGRESS) { - Limelog("Failed to start async connect to TCP %u: %d\n", LiGetPortFromPortFlagIndex(i), err); - - // Mask off this bit so we don't try to include it in pollSockets() below - testPortFlags &= ~(1U << i); - } - } - } - else { - const char buf[MTU_TEST_SIZE] = "moonlight-ctest"; - int j; - - // Send a few packets since UDP is unreliable - for (j = 0; j < 3; j++) { - err = sendto(sockets[i], buf, sizeof(buf), 0, (struct sockaddr*)&address, address_length); - if (err < 0) { - err = (int)LastSocketError(); - Limelog("Failed to send test packet to UDP %u: %d\n", LiGetPortFromPortFlagIndex(i), err); - - // Mask off this bit so we don't try to include it in pollSockets() below - testPortFlags &= ~(1U << i); - - break; - } - - PltSleepMs(50); - } - } - } - } - - // Continue to call pollSockets() until we have no more sockets to wait for, - // or our pollSockets() call times out. - while (testPortFlags != 0) { - int nfds; - struct pollfd pfds[PORT_FLAGS_MAX_COUNT]; - - nfds = 0; - - // Fill out our FD sets - for (i = 0; i < PORT_FLAGS_MAX_COUNT; i++) { - if (testPortFlags & (1U << i)) { - pfds[nfds].fd = sockets[i]; - - if (LiGetProtocolFromPortFlagIndex(i) == IPPROTO_UDP) { - // Watch for readability on UDP sockets - pfds[nfds].events = POLLIN; - } - else { - // Watch for writeability on TCP sockets - pfds[nfds].events = POLLOUT; - } - - nfds++; - } - } - - // Wait for the to complete or the timeout to elapse. - // NB: The timeout resets each time we get a valid response on a port, - // but that's probably fine. - err = pollSockets(pfds, nfds, TEST_PORT_TIMEOUT_SEC * 1000); - if (err < 0) { - // pollSockets() failed - err = LastSocketError(); - Limelog("pollSockets() failed: %d\n", err); - failingPortFlags = ML_TEST_RESULT_INCONCLUSIVE; - goto Exit; - } - else if (err == 0) { - // pollSockets() timed out - Limelog("Connection timed out after %d seconds\n", TEST_PORT_TIMEOUT_SEC); - break; - } - - // We know something was signalled. Now we just need to find out what. - for (i = 0; i < nfds; i++) { - if (pfds[i].revents != 0) { - int portIndex; - - // This socket was signalled. Figure out what port it was. - for (portIndex = 0; portIndex < PORT_FLAGS_MAX_COUNT; portIndex++) { - if (sockets[portIndex] == pfds[i].fd) { - LC_ASSERT(testPortFlags & (1U << portIndex)); - break; - } - } - - LC_ASSERT(portIndex != PORT_FLAGS_MAX_COUNT); - - if (LiGetProtocolFromPortFlagIndex(portIndex) == IPPROTO_UDP) { - char buf[MTU_TEST_SIZE]; - - // A UDP socket was signalled. This could be because we got - // a packet from the test server, or it could be because we - // received an ICMP error which will be given to us from - // recvfrom(). - testPortFlags &= ~(1U << portIndex); - - // Check if the socket can be successfully read now - err = recvfrom(sockets[portIndex], buf, sizeof(buf), 0, NULL, NULL); - if (err >= 0) { - // The UDP test was a success. - failingPortFlags &= ~(1U << portIndex); - - Limelog("UDP port %u test successful\n", LiGetPortFromPortFlagIndex(portIndex)); - } - else { - err = LastSocketError(); - Limelog("UDP port %u test failed: %d\n", LiGetPortFromPortFlagIndex(portIndex), err); - } - } - else { - // A TCP socket was signalled - SOCKADDR_LEN len = sizeof(err); - getsockopt(sockets[portIndex], SOL_SOCKET, SO_ERROR, (char*)&err, &len); - if (err != 0 || (pfds[i].revents & POLLERR)) { - // Get the error code - err = (err != 0) ? err : LastSocketFail(); - } - - // The TCP test has completed for this port - testPortFlags &= ~(1U << portIndex); - if (err == 0) { - // The TCP test was a success - failingPortFlags &= ~(1U << portIndex); - - Limelog("TCP port %u test successful\n", LiGetPortFromPortFlagIndex(portIndex)); - } - else { - Limelog("TCP port %u test failed: %d\n", LiGetPortFromPortFlagIndex(portIndex), err); - } - } - } - } - - // Next iteration, we'll remove the matching sockets from our FD set and - // call select() again to wait on the remaining sockets. - } - -Exit: - for (i = 0; i < PORT_FLAGS_MAX_COUNT; i++) { - if (sockets[i] != INVALID_SOCKET) { - closeSocket(sockets[i]); - } - } - - cleanupPlatformSockets(); - return failingPortFlags; -} diff --git a/third_party/moonlight-common-c/src/RecorderCallbacks.c b/third_party/moonlight-common-c/src/RecorderCallbacks.c deleted file mode 100644 index 840ac19..0000000 --- a/third_party/moonlight-common-c/src/RecorderCallbacks.c +++ /dev/null @@ -1,102 +0,0 @@ -#ifdef _WIN32 -// Don't warn for fopen() usage -#define _CRT_SECURE_NO_WARNINGS 1 -#endif - -#include "Limelight-internal.h" - -static FILE* videoFile; -static FILE* audioFile; - -static DECODER_RENDERER_CALLBACKS realDrCallbacks; -static AUDIO_RENDERER_CALLBACKS realArCallbacks; - -static int recDrSetup(int videoFormat, int width, int height, int redrawRate, void* context, int drFlags) -{ - const char* path = context; - - if (path != NULL) { - videoFile = fopen(path, "wb"); - if (videoFile == NULL) { - return -1; - } - } - else { - Limelog("Video recording will not be enabled - file path not specified in drContext!\n"); - } - - return realDrCallbacks.setup(videoFormat, width, height, redrawRate, NULL, drFlags); -} - -static void recDrCleanup(void) -{ - if (videoFile != NULL) { - fclose(videoFile); - videoFile = NULL; - } - - realDrCallbacks.cleanup(); -} - -static int recDrSubmitDecodeUnit(PDECODE_UNIT decodeUnit) -{ - if (videoFile != NULL) { - PLENTRY entry = decodeUnit->bufferList; - while (entry != NULL) { - fwrite(entry->data, 1, entry->length, videoFile); - entry = entry->next; - } - } - - return realDrCallbacks.submitDecodeUnit(decodeUnit); -} - -static int recArInit(int audioConfiguration, POPUS_MULTISTREAM_CONFIGURATION opusConfig, void* context, int arFlags) -{ - const char* path = context; - - if (path != NULL) { - audioFile = fopen(path, "wb"); - if (audioFile == NULL) { - return -1; - } - } - else { - Limelog("Audio recording will not be enabled - file path not specified in arContext!\n"); - } - - return realArCallbacks.init(audioConfiguration, opusConfig, NULL, arFlags); -} - -static void recArCleanup(void) -{ - if (audioFile != NULL) { - fclose(audioFile); - audioFile = NULL; - } - - realArCallbacks.cleanup(); -} - -static void recArDecodeAndPlaySample(char* sampleData, int sampleLength) -{ - if (audioFile != NULL) { - fwrite(sampleData, 1, sampleLength, audioFile); - } - - realArCallbacks.decodeAndPlaySample(sampleData, sampleLength); -} - -void setRecorderCallbacks(PDECODER_RENDERER_CALLBACKS drCallbacks, PAUDIO_RENDERER_CALLBACKS arCallbacks) -{ - realDrCallbacks = *drCallbacks; - realArCallbacks = *arCallbacks; - - drCallbacks->setup = recDrSetup; - drCallbacks->cleanup = recDrCleanup; - drCallbacks->submitDecodeUnit = recDrSubmitDecodeUnit; - - arCallbacks->init = recArInit; - arCallbacks->cleanup = recArCleanup; - arCallbacks->decodeAndPlaySample = recArDecodeAndPlaySample; -} diff --git a/third_party/moonlight-common-c/src/SimpleStun.c b/third_party/moonlight-common-c/src/SimpleStun.c deleted file mode 100644 index 64a93ef..0000000 --- a/third_party/moonlight-common-c/src/SimpleStun.c +++ /dev/null @@ -1,194 +0,0 @@ -#include "Limelight-internal.h" - -#define STUN_RECV_TIMEOUT_SEC 3 - -#define STUN_MESSAGE_BINDING_REQUEST 0x0001 -#define STUN_MESSAGE_BINDING_SUCCESS 0x0101 -#define STUN_MESSAGE_COOKIE 0x2112a442 - -#define STUN_ATTRIBUTE_MAPPED_ADDRESS 0x0001 -#define STUN_ATTRIBUTE_XOR_MAPPED_ADDRESS 0x0020 - -#pragma pack(push, 1) - -typedef struct _STUN_ATTRIBUTE_HEADER { - unsigned short type; - unsigned short length; -} STUN_ATTRIBUTE_HEADER, *PSTUN_ATTRIBUTE_HEADER; - -typedef struct _STUN_MAPPED_IPV4_ADDRESS_ATTRIBUTE { - STUN_ATTRIBUTE_HEADER hdr; - unsigned char reserved; - unsigned char addressFamily; - unsigned short port; - unsigned int address; -} STUN_MAPPED_IPV4_ADDRESS_ATTRIBUTE, *PSTUN_MAPPED_IPV4_ADDRESS_ATTRIBUTE; - -typedef struct _STUN_MESSAGE { - unsigned short messageType; - unsigned short messageLength; - unsigned int magicCookie; - unsigned char transactionId[12]; -} STUN_MESSAGE, *PSTUN_MESSAGE; - -#pragma pack(pop) - -// This is extremely rudamentary STUN code simply for deriving the WAN IPv4 address when behind a NAT. -int LiFindExternalAddressIP4(const char* stunServer, unsigned short stunPort, unsigned int* wanAddr) -{ - SOCKET sock; - struct addrinfo* stunAddrs; - struct addrinfo hints; - char stunPortStr[6]; - int err; - STUN_MESSAGE reqMsg; - int i; - int bytesRead; - PSTUN_ATTRIBUTE_HEADER attribute; - PSTUN_MAPPED_IPV4_ADDRESS_ATTRIBUTE ipv4Attrib; - union { - STUN_MESSAGE hdr; - char buf[1024]; - } resp; - - sock = INVALID_SOCKET; - - err = initializePlatformSockets(); - if (err != 0) { - Limelog("Failed to initialize sockets: %d\n", err); - return err; - } - - memset(&hints, 0, sizeof(hints)); - hints.ai_family = AF_INET; - hints.ai_socktype = SOCK_DGRAM; - hints.ai_protocol = IPPROTO_UDP; - hints.ai_flags = AI_ADDRCONFIG; - - snprintf(stunPortStr, sizeof(stunPortStr), "%u", stunPort); - err = getaddrinfo(stunServer, stunPortStr, &hints, &stunAddrs); - if (err != 0 || stunAddrs == NULL) { - Limelog("Failed to resolve STUN server: %d\n", err); - stunAddrs = NULL; - goto Exit; - } - - sock = bindUdpSocket(hints.ai_family, NULL, 0, 0, SOCK_QOS_TYPE_BEST_EFFORT); - if (sock == INVALID_SOCKET) { - err = LastSocketFail(); - Limelog("Failed to connect to STUN server: %d\n", err); - goto Exit; - } - - reqMsg.messageType = htons(STUN_MESSAGE_BINDING_REQUEST); - reqMsg.messageLength = 0; - reqMsg.magicCookie = htonl(STUN_MESSAGE_COOKIE); - PltGenerateRandomData(reqMsg.transactionId, sizeof(reqMsg.transactionId)); - - bytesRead = SOCKET_ERROR; - for (i = 0; i < STUN_RECV_TIMEOUT_SEC * 1000 / UDP_RECV_POLL_TIMEOUT_MS && bytesRead <= 0; i++) { - // Retransmit the request every second until the timeout elapses or we get a response - if (i % (1000 / UDP_RECV_POLL_TIMEOUT_MS) == 0) { - struct addrinfo *current; - - // Send a request to each resolved address but stop if we get a response - for (current = stunAddrs; current != NULL && bytesRead <= 0; current = current->ai_next) { - err = (int)sendto(sock, (char *)&reqMsg, sizeof(reqMsg), 0, current->ai_addr, (SOCKADDR_LEN)current->ai_addrlen); - if (err == SOCKET_ERROR) { - err = LastSocketFail(); - Limelog("Failed to send STUN binding request: %d\n", err); - continue; - } - - // Wait UDP_RECV_POLL_TIMEOUT_MS before moving on to the next server to - // avoid having to spam the other STUN servers if we find a working one. - bytesRead = recvUdpSocket(sock, resp.buf, sizeof(resp.buf), true); - } - } - else { - // This waits in UDP_RECV_POLL_TIMEOUT_MS increments - bytesRead = recvUdpSocket(sock, resp.buf, sizeof(resp.buf), true); - } - } - - if (bytesRead == 0) { - Limelog("No response from STUN server\n"); - err = -2; - goto Exit; - } - else if (bytesRead == SOCKET_ERROR) { - err = LastSocketFail(); - Limelog("Failed to read STUN binding response: %d\n", err); - goto Exit; - } - else if (bytesRead < (int)sizeof(resp.hdr)) { - Limelog("STUN message truncated: %d\n", bytesRead); - err = -3; - goto Exit; - } - else if (htonl(resp.hdr.magicCookie) != STUN_MESSAGE_COOKIE) { - Limelog("Bad STUN cookie value: %x\n", htonl(resp.hdr.magicCookie)); - err = -3; - goto Exit; - } - else if (memcmp(reqMsg.transactionId, resp.hdr.transactionId, sizeof(reqMsg.transactionId))) { - Limelog("STUN transaction ID mismatch\n"); - err = -3; - goto Exit; - } - else if (htons(resp.hdr.messageType) != STUN_MESSAGE_BINDING_SUCCESS) { - Limelog("STUN message type mismatch: %x\n", htons(resp.hdr.messageType)); - err = -4; - goto Exit; - } - - attribute = (PSTUN_ATTRIBUTE_HEADER)(&resp.hdr + 1); - bytesRead -= sizeof(resp.hdr); - while (bytesRead > (int)sizeof(*attribute)) { - if (bytesRead < (int)(sizeof(*attribute) + htons(attribute->length))) { - Limelog("STUN attribute out of bounds: %d\n", htons(attribute->length)); - err = -5; - goto Exit; - } - // Mask off the comprehension bit - else if ((htons(attribute->type) & 0x7FFF) != STUN_ATTRIBUTE_XOR_MAPPED_ADDRESS) { - // Continue searching if this wasn't our address - bytesRead -= sizeof(*attribute) + htons(attribute->length); - attribute = (PSTUN_ATTRIBUTE_HEADER)(((char*)attribute) + sizeof(*attribute) + htons(attribute->length)); - continue; - } - - ipv4Attrib = (PSTUN_MAPPED_IPV4_ADDRESS_ATTRIBUTE)attribute; - if (htons(ipv4Attrib->hdr.length) != 8) { - Limelog("STUN address length mismatch: %d\n", htons(ipv4Attrib->hdr.length)); - err = -5; - goto Exit; - } - else if (ipv4Attrib->addressFamily != 1) { - Limelog("STUN address family mismatch: %x\n", ipv4Attrib->addressFamily); - err = -5; - goto Exit; - } - - // The address is XORed with the cookie - *wanAddr = ipv4Attrib->address ^ resp.hdr.magicCookie; - - err = 0; - goto Exit; - } - - Limelog("No XOR mapped address found in STUN response!\n"); - err = -6; - -Exit: - if (sock != INVALID_SOCKET) { - closeSocket(sock); - } - - if (stunAddrs != NULL) { - freeaddrinfo(stunAddrs); - } - - cleanupPlatformSockets(); - return err; -}