From 261fdbb8e12485e9f6676a4b89257fc68cc37b36 Mon Sep 17 00:00:00 2001 From: MrBeldum <157205701+MrBeldum@users.noreply.github.com> Date: Wed, 30 Sep 2026 01:01:25 -0700 Subject: [PATCH 01/38] Keep test runs out of the real BroLink data directory The core config tests saved and deleted files in the user's real ~/Library/Application Support/dev.brolink.BroLink, and every test that built a Service, a HostApp or a ClientApp read that install's host.toml and client.toml (and would have rewritten them had a test changed a setting). data_dir() now honours BROLINK_DATA_DIR, and the workspace's .cargo/config.toml sets it to target/brolink-data for cargo test and cargo run. A shipped binary never sees the variable. Co-Authored-By: Claude Opus 5.5 (1M context) --- .cargo/config.toml | 7 ++++++- crates/core/src/config.rs | 30 ++++++++++++++++++++++++++---- 2 files changed, 32 insertions(+), 5 deletions(-) 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/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()); From 9caed05027319c9356c01493c07b8714701b11b9 Mon Sep 17 00:00:00 2001 From: MrBeldum <157205701+MrBeldum@users.noreply.github.com> Date: Wed, 30 Sep 2026 01:02:59 -0700 Subject: [PATCH 02/38] Answer only the same Tailscale account on the control API again a2f2ebe loosened the rule from "whois says the same user" to "whois knows the peer at all", so the relay VPS (a tagged node, whose own user is tagged-devices) would answer the Mac. That also admitted every machine shared in from another tailnet: it could pair with Sunshine through /v1/pin, sleep or restart the PC, read and write its clipboard, and POST /v1/update with any executable whose SHA-256 it sent alongside. The service now admits the account it is signed in as. A tagged node has no account, so it admits members of its own tailnet: the login that names a personal tailnet, or the users of an organisation's domain. Refusals are cached like approvals, and logged as before 3.x. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 10 ++ crates/core/src/api.rs | 6 + crates/core/src/tailscale.rs | 20 ++++ crates/host/src/service.rs | 209 ++++++++++++++++++++++++++++------- 4 files changed, 203 insertions(+), 42 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b8f0f2..f2ce660 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ 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 3.x 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. + ## 4.0.2 (2026-09-22) - `panel.log` and `service.log` no longer grow without end. Both were 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/tailscale.rs b/crates/core/src/tailscale.rs index 8af6501..10e9279 100644 --- a/crates/core/src/tailscale.rs +++ b/crates/core/src/tailscale.rs @@ -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)] @@ -714,6 +724,16 @@ mod tests { assert_eq!(output.len(), 1048576); } + #[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/host/src/service.rs b/crates/host/src/service.rs index 4c0a2b0..5f73566 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. //! //! 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; @@ -43,7 +48,8 @@ pub struct Service { install: Mutex>, streamer: Mutex, log: Mutex>, - auth: Mutex)>>, + /// `whois` answers for recent peers: when, and whether they may ask. + auth: Mutex>, /// This PC's side of the NAT story, from `tailscale netcheck`. nat: Mutex>, nat_running: AtomicBool, @@ -469,10 +475,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 +485,54 @@ 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); + .map(|(_, allowed)| *allowed); + if let Some(allowed) = cached { + 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); + 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)); - } - u + )); + allowed + } + Err(e) => { + self.log(format!("whois {ip}: {e}")); + return false; } }; - user.is_some() + 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); + } + } + cache.insert(ip, (Instant::now(), allowed)); + allowed } fn handle(&self, peer: SocketAddr, req: &Request) -> Response { @@ -781,6 +790,56 @@ 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)) + } +} + /// True when a service answers on loopback. pub fn service_alive() -> bool { http::request( @@ -882,6 +941,72 @@ mod tests { assert!(!is_tailnet_ip("2001:4860:4860::8888".parse().unwrap())); } + 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 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] fn browsers_and_rebound_hostnames_cannot_read_or_write_the_api() { let svc = Service::new(); From bb18995bd17da425a216e17839a08d1e3bd95551 Mon Sep 17 00:00:00 2001 From: MrBeldum <157205701+MrBeldum@users.noreply.github.com> Date: Wed, 30 Sep 2026 01:03:49 -0700 Subject: [PATCH 03/38] Carry copies from Mac and Linux hosts to the viewer The viewer tells a new copy on the host from an old one by the clipboard's sequence number, and only Windows has one: elsewhere read() reported 0 every time, so after the first poll no copy on a Mac or on the Linux VPS desktop ever reached the viewer. The sequence number is now derived from the text itself. The host also opened and dropped an arboard::Clipboard for every read and write. On X11 the selection belongs to that handle, and dropping the last one discards the text unless a clipboard manager takes it within 100 ms, so text pasted in from the viewer could be gone before the desktop pasted it. One handle now lives for the whole process. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 8 +++++- crates/host/src/clipboard.rs | 55 ++++++++++++++++++++++++++++++++---- 2 files changed, 56 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f2ce660..54f7e42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,12 +7,18 @@ GitHub release notes, so each version gets a heading of the form ## Unreleased - The control service again answers only machines signed in to the same - Tailscale account. Since 3.x it answered any machine Tailscale could + 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. ## 4.0.2 (2026-09-22) diff --git a/crates/host/src/clipboard.rs b/crates/host/src/clipboard.rs index e138cca..abc52c1 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_attr(windows, allow(dead_code))] +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(" ")); + } } From 928a33b80c596ca4e76bce416e080cba96f27103 Mon Sep 17 00:00:00 2001 From: MrBeldum <157205701+MrBeldum@users.noreply.github.com> Date: Wed, 30 Sep 2026 01:04:23 -0700 Subject: [PATCH 04/38] Keep the old host when a pushed update dies on start apply() only rolled back when Windows refused to create the new process at all. A replacement that started and then crashed (panic = "abort", a missing DLL) left the PC with the new file in place and no service: the old one exits right after apply() returns, and nothing starts the new one again until the next logon, which on an unattended PC is never. The new copy waits for the old one's port, so a healthy one is still running a moment after launch. apply() now waits two seconds and, if the child has already exited, moves it aside and restores the old executable while the old service keeps serving. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 5 ++++ crates/host/src/update.rs | 53 ++++++++++++++++++++++++++++++++++----- 2 files changed, 52 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 54f7e42..0fb3414 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,11 @@ GitHub release notes, so each version gets a heading of the form 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. ## 4.0.2 (2026-09-22) 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); From cdd9842e52d136c0702f3014f3f2b42c83f0fae7 Mon Sep 17 00:00:00 2001 From: MrBeldum <157205701+MrBeldum@users.noreply.github.com> Date: Wed, 30 Sep 2026 01:05:03 -0700 Subject: [PATCH 05/38] Try each resolved address when connecting, not only the first http::request, request_with and the GitHub fetch all connected to the first address a name resolved to and gave up if it failed. GitHub's download hosts resolve to several addresses, and a network with an IPv6 address but no IPv6 route lists an unusable one first. A shared connect() now walks the list. request() also goes through request_with() instead of duplicating it, and takes its Host header from the connected peer. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 3 +++ crates/core/src/http.rs | 56 +++++++++++++++++++++++++++------------ crates/core/src/update.rs | 10 ++----- 3 files changed, 44 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0fb3414..7f1dfc8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,9 @@ GitHub release notes, so each version gets a heading of the form 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. ## 4.0.2 (2026-09-22) diff --git a/crates/core/src/http.rs b/crates/core/src/http.rs index 6048b13..436eb3a 100644 --- a/crates/core/src/http.rs +++ b/crates/core/src/http.rs @@ -267,6 +267,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 +289,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 +308,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 +674,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/update.rs b/crates/core/src/update.rs index 023c85d..8899a1b 100644 --- a/crates/core/src/update.rs +++ b/crates/core/src/update.rs @@ -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)?; From 5784ac1b1f2aaa15326c816d1214a74a5694e7b3 Mon Sep 17 00:00:00 2001 From: MrBeldum <157205701+MrBeldum@users.noreply.github.com> Date: Wed, 30 Sep 2026 01:05:12 -0700 Subject: [PATCH 06/38] Name the right licence in the Windows executable's version block build.rs wrote "MIT license" into brolink-host.exe's LegalCopyright string. BroLink is GPL-3.0-or-later, as is the moonlight-common-c it links, and that string is what Explorer, Task Manager and installers show. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 3 +++ crates/host/build.rs | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f1dfc8..c626557 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,9 @@ GitHub release notes, so each version gets a heading of the form - 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. ## 4.0.2 (2026-09-22) 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() { From f907166aecf270ee709dd27f5ce042f5b4b494b7 Mon Sep 17 00:00:00 2001 From: MrBeldum <157205701+MrBeldum@users.noreply.github.com> Date: Wed, 30 Sep 2026 01:05:42 -0700 Subject: [PATCH 07/38] Stop running a program for every status request on Mac and Linux MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Service::status() asked machine_name() for the name on every request, and off Windows that runs `scutil --get ComputerName` or `hostname`. The window polls /v1/status every second and every other machine's lobby every three, so the service spawned a process one to two times a second for a name that almost never changes. It is now read at start and then once a minute with the other slow probes. status() went from 3.4 ms to 0.65 µs a call on this Mac (200 calls, test build). Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/host/src/service.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/host/src/service.rs b/crates/host/src/service.rs index 5f73566..4d0d8d3 100644 --- a/crates/host/src/service.rs +++ b/crates/host/src/service.rs @@ -38,6 +38,9 @@ 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. @@ -60,6 +63,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), @@ -330,6 +334,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 { @@ -430,7 +435,7 @@ impl Service { 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() From 8abc71bbff6a64f5ab80ae445df448d5f56a0006 Mon Sep 17 00:00:00 2001 From: MrBeldum <157205701+MrBeldum@users.noreply.github.com> Date: Wed, 30 Sep 2026 01:06:50 -0700 Subject: [PATCH 08/38] Make VideoToolbox's frame callback unable to outlive its target decode() hands VideoToolbox a pointer to a Decoded on its own stack as the frame refcon, and the output callback writes the picture through it. That is only sound while every callback for the frame runs before decode() returns. Synchronous decode does call it before returning, but a decoder is allowed to hold a frame back and emit it during a later call, which would write through a stack frame that no longer exists. decode() now calls VTDecompressionSessionWaitForAsynchronousFrames after a successful submit, as FFmpeg's VideoToolbox hwaccel does. Also drops what was never used: VideoToolbox::full_range (always false), the width and height every decoder constructor ignored, and OpenH264's intermediate chroma buffer, which cost a copy of the UV plane per frame on Windows and Linux. nal_units and interleave_uv are private to the platform that uses them. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/stream/src/session.rs | 2 +- crates/stream/src/video.rs | 13 ++++++------ crates/stream/src/video/openh264.rs | 6 +----- crates/stream/src/video/videotoolbox.rs | 28 +++++++++++++++---------- 4 files changed, 25 insertions(+), 24 deletions(-) diff --git a/crates/stream/src/session.rs b/crates/stream/src/session.rs index ff45363..dd7b601 100644 --- a/crates/stream/src/session.rs +++ b/crates/stream/src/session.rs @@ -546,7 +546,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; diff --git a/crates/stream/src/video.rs b/crates/stream/src/video.rs index f4c85b9..ef44e2b 100644 --- a/crates/stream/src/video.rs +++ b/crates/stream/src/video.rs @@ -170,22 +170,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 +209,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() From 5a1c340d9a4477c9cdd8545d0df84c74beef706e Mon Sep 17 00:00:00 2001 From: MrBeldum <157205701+MrBeldum@users.noreply.github.com> Date: Wed, 30 Sep 2026 01:07:35 -0700 Subject: [PATCH 09/38] Stop rewriting the engine helpers every 30 seconds on a silent PC While Sunshine reports no audio device, the service calls take_over_engine() on every sixth tick, and install_helpers() rewrote take-over-engine.ps1, .vbs and .cmd and ran `schtasks /Create /F` each time. The files are now written only when they differ from what this build carries, and the logon task is registered once per process. brand.rs also read VerQueryValue's UTF-16 strings and translation pair by turning a pointer into a Vec into a &[u16], which assumes an alignment a byte buffer does not promise. They are read unaligned now. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/host/src/audio.rs | 48 +++++++++++++++++++++++----------------- crates/host/src/brand.rs | 18 ++++++++++++--- 2 files changed, 43 insertions(+), 23 deletions(-) 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() { From 43846ab0d477d357ee513427dc10b7a69df585b5 Mon Sep 17 00:00:00 2001 From: MrBeldum <157205701+MrBeldum@users.noreply.github.com> Date: Wed, 30 Sep 2026 01:08:46 -0700 Subject: [PATCH 10/38] Let Cancel stop the pairing retries, and name the codec actually used connect() retried server_info() eight times, 700 ms apart and up to 6 s each, without looking at the cancel flag, so a Cancel while the PC was not answering waited out the whole retry. retry() now checks the attempt between tries. The Live stream's codec label said HEVC whenever the PC offered it and the setting allowed it, but Windows and Linux mask HEVC out of the formats they ask for, so they streamed H.264 under an HEVC label. The choice now also asks whether this machine decodes HEVC. Removes what never had an effect: stream::Settings::remote (every stream is flagged local, see the packet-size note in session.rs), with its BROLINK_TEST_REMOTE hook, lan_like_stream() and tailscale::overlay_or_lan(), and fixes two doc comments that sat on the wrong items. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 6 +++ crates/client/src/app.rs | 1 - crates/client/src/config.rs | 4 +- crates/client/src/session.rs | 80 +++++++++++++++++++++++++++++++----- crates/client/src/stream.rs | 1 - crates/core/src/tailscale.rs | 17 -------- crates/stream/src/session.rs | 11 +---- 7 files changed, 78 insertions(+), 42 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c626557..2c50a29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,12 @@ GitHub release notes, so each version gets a heading of the form - `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. +- 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. ## 4.0.2 (2026-09-22) diff --git a/crates/client/src/app.rs b/crates/client/src/app.rs index a3192b6..d044003 100644 --- a/crates/client/src/app.rs +++ b/crates/client/src/app.rs @@ -2124,7 +2124,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/session.rs b/crates/client/src/session.rs index 56a3d0f..a048350 100644 --- a/crates/client/src/session.rs +++ b/crates/client/src/session.rs @@ -641,9 +641,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); @@ -727,7 +727,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 +745,6 @@ fn run(c: &Connect, generation: u64) -> Result<()> { fps, bitrate_kbps: settings.bitrate_kbps, hevc, - remote: false, }, ri_key, ri_iv, @@ -778,16 +777,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 +889,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 +915,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 +1001,44 @@ 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 progress_tracks_step_changes() { let mut p = Progress::default(); diff --git a/crates/client/src/stream.rs b/crates/client/src/stream.rs index 49de0a4..aac08a2 100644 --- a/crates/client/src/stream.rs +++ b/crates/client/src/stream.rs @@ -1257,7 +1257,6 @@ mod tests { fps: 30, bitrate_kbps: 4000, hevc: true, - remote: true, }, [0; 16], [0; 16], diff --git a/crates/core/src/tailscale.rs b/crates/core/src/tailscale.rs index 10e9279..41c3376 100644 --- a/crates/core/src/tailscale.rs +++ b/crates/core/src/tailscale.rs @@ -340,19 +340,6 @@ pub fn netcheck() -> Result { } /// 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. - let o = ip.octets(); - o[0] == 100 && o[1] >= 64 && o[1] <= 127 -} - pub fn derp_code(region: i32) -> &'static str { match region { 1 => "nyc", @@ -540,10 +527,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()), diff --git a/crates/stream/src/session.rs b/crates/stream/src/session.rs index dd7b601..b5e9087 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`. @@ -450,11 +448,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() { @@ -969,9 +962,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 +1035,6 @@ mod real { fps, bitrate_kbps, hevc: std::env::var_os("BROLINK_TEST_HEVC").is_some(), - remote, }, ri_key, ri_iv, From b310ee55b4175fe69f7f3039a948206f5c2643bc Mon Sep 17 00:00:00 2001 From: MrBeldum <157205701+MrBeldum@users.noreply.github.com> Date: Wed, 30 Sep 2026 01:10:33 -0700 Subject: [PATCH 11/38] Remove API that nothing outside the tests called - Session's own mouse, key, text and scroll methods duplicated Input's; every caller goes through Session::input(). - tailscale::Status::windows_peers, superseded by machine_peers since every OS can share. - http::serve (serve_with_peer_check is the one the service uses) and nvhttp::Client::pair (the app pairs through pair_cancellable) are now test-only helpers. - ffi declarations for constants and moonlight functions BroLink never uses (STREAM_CFG_REMOTE/AUTO, COLOR_RANGE_FULL, the AVC reference invalidation flag, CONN_STATUS_OKAY, LiGetPendingVideoFrames, LiRequestIdrFrame), and the module-wide allow(dead_code) that hid them. - audio::Player's decoded counter and its packets()/decoded() accessors, which only the tests read; the test checks the queue instead. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/core/src/http.rs | 12 +++++++----- crates/core/src/tailscale.rs | 25 +++++++++++-------------- crates/stream/src/audio.rs | 21 ++++----------------- crates/stream/src/ffi.rs | 9 --------- crates/stream/src/nvhttp.rs | 12 +++++++----- crates/stream/src/session.rs | 31 +++---------------------------- 6 files changed, 32 insertions(+), 78 deletions(-) diff --git a/crates/core/src/http.rs b/crates/core/src/http.rs index 436eb3a..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, diff --git a/crates/core/src/tailscale.rs b/crates/core/src/tailscale.rs index 41c3376..19ff41c 100644 --- a/crates/core/src/tailscale.rs +++ b/crates/core/src/tailscale.rs @@ -192,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> { @@ -487,6 +476,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", @@ -511,7 +508,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"] @@ -580,7 +577,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); } @@ -634,7 +631,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"], diff --git a/crates/stream/src/audio.rs b/crates/stream/src/audio.rs index fd13f77..adcd454 100644 --- a/crates/stream/src/audio.rs +++ b/crates/stream/src/audio.rs @@ -48,7 +48,6 @@ pub struct Player { keep: usize, packets: u64, lost: u64, - decoded: u64, undecodable: u64, } @@ -134,7 +133,6 @@ impl Player { keep, packets: 0, lost: 0, - decoded: 0, undecodable: 0, }) } @@ -173,7 +171,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 +184,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) @@ -547,12 +534,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 b5e9087..349248c 100644 --- a/crates/stream/src/session.rs +++ b/crates/stream/src/session.rs @@ -360,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 { @@ -1060,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 { From c46bc31d4d3f9eccae1d5021e6bfee7d5ca502a2 Mon Sep 17 00:00:00 2001 From: MrBeldum <157205701+MrBeldum@users.noreply.github.com> Date: Wed, 30 Sep 2026 01:11:06 -0700 Subject: [PATCH 12/38] Drop the path argument that Auto quality never read path::effective took the measured path and ignored it: since 3.3 Auto asks for the same quality whatever the route. The parameter made every caller build a Path it did not need, and the test looped over paths that could not change the answer. Also removes a doc line left over from a deleted function in stream.rs. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/client/src/app.rs | 4 +-- crates/client/src/path.rs | 48 ++++++++++++++--------------------- crates/client/src/session.rs | 2 +- crates/client/src/settings.rs | 2 +- crates/client/src/stream.rs | 1 - 5 files changed, 23 insertions(+), 34 deletions(-) diff --git a/crates/client/src/app.rs b/crates/client/src/app.rs index d044003..3995713 100644 --- a/crates/client/src/app.rs +++ b/crates/client/src/app.rs @@ -611,7 +611,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 +2109,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(), 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 a048350..1bf8b18 100644 --- a/crates/client/src/session.rs +++ b/crates/client/src/session.rs @@ -666,7 +666,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, 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 aac08a2..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( From 02a0fba02101eb6fe34404787e9abea7b6add8e1 Mon Sep 17 00:00:00 2001 From: MrBeldum <157205701+MrBeldum@users.noreply.github.com> Date: Wed, 30 Sep 2026 01:13:47 -0700 Subject: [PATCH 13/38] Keep the adapter name out of double-quoted strings in setup The Wake-on-LAN step printed `Step "Enabling Wake-on-LAN on ''"`. The name was escaped for a single-quoted literal, but this string is double-quoted: a `"` in the adapter's name ended it, a syntax error that fails the entire elevated script, and `$x` or `$(...)` in the name was expanded or run. Both names are now assigned once as single-quoted literals and used through variables. Also drops a second, undocumented BROLINK_DUMP_SETUP hook that did what BROLINK_DUMP_SETUP_SCRIPT does, fixes two test helpers whose doc comments were swapped, and a stale task reference in migrate.rs. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 4 +++ crates/host/src/migrate.rs | 2 +- crates/host/src/setup.rs | 50 ++++++++++++++++++++++++++------------ 3 files changed, 39 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c50a29..c98ecc2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,10 @@ GitHub release notes, so each version gets a heading of the form - `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. - 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. 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/setup.rs b/crates/host/src/setup.rs index 189b4aa..acf5320 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) @@ -815,8 +819,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 +826,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 +860,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 +1041,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 +1424,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 { From dc5ee5451e30f08ec1d47726f89cf169b1c1679e Mon Sep 17 00:00:00 2001 From: MrBeldum <157205701+MrBeldum@users.noreply.github.com> Date: Wed, 30 Sep 2026 01:13:47 -0700 Subject: [PATCH 14/38] Parse every PowerShell script in CI, and test the whole workspace The Windows setup script is generated, so a quoting slip in Rust shows up only on a real PC. The Windows job now dumps it for every plan shape (fresh install, migration, each with and without dry run), with an exe path and adapter names that need quoting, and runs it, install-host.ps1 and take-over-engine.ps1 through Windows PowerShell's own parser. The macOS and Linux jobs and the release workflow listed four crates by name and so never ran brolink-ui's tests; they use --workspace now. The Linux job and the node Dockerfile stop installing libssl-dev and libpulse-dev, which nothing links (there is no openssl or libpulse crate in Cargo.lock). Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/ci.yml | 38 ++++++++++++++++++++++++++++++----- .github/workflows/release.yml | 2 +- deploy/node/Dockerfile | 4 ++-- 3 files changed, 36 insertions(+), 8 deletions(-) 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/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 \ From e4f71efaf5d03cf90437a49a1ee99201ec63565a Mon Sep 17 00:00:00 2001 From: MrBeldum <157205701+MrBeldum@users.noreply.github.com> Date: Wed, 30 Sep 2026 01:15:58 -0700 Subject: [PATCH 15/38] Compile only the moonlight-common-c sources BroLink uses, and say why build.rs globbed every .c file under the vendored src/ and enet/, so it also built four files nothing links: ConnectionTester.c and SimpleStun.c (public helpers BroLink never calls), RecorderCallbacks.c (only for LC_DEBUG_RECORD_MODE) and ENet's compress.c (moonlight never enables compression). They are gone, and build.rs lists the sources it compiles, so a re-vendor has to decide about each new file. VERSION now records the upstream commits of moonlight-common-c and its ENet and nanors submodules, everything trimmed, and the two local patches with their reasons (the ENet control-channel timeout for relayed streams, and the 150 Mbps bitrate latch), plus how to re-vendor. Co-Authored-By: Claude Opus 5.5 (1M context) --- NOTICE | 4 +- crates/stream/build.rs | 60 +- third_party/moonlight-common-c/VERSION | 47 ++ .../moonlight-common-c/enet/compress.c | 654 ------------------ .../moonlight-common-c/src/ConnectionTester.c | 308 --------- .../src/RecorderCallbacks.c | 102 --- .../moonlight-common-c/src/SimpleStun.c | 194 ------ 7 files changed, 88 insertions(+), 1281 deletions(-) delete mode 100644 third_party/moonlight-common-c/enet/compress.c delete mode 100644 third_party/moonlight-common-c/src/ConnectionTester.c delete mode 100644 third_party/moonlight-common-c/src/RecorderCallbacks.c delete mode 100644 third_party/moonlight-common-c/src/SimpleStun.c 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/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/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; -} From c19a220111cb2971cdccb94cdf1a4c89c23c9a56 Mon Sep 17 00:00:00 2001 From: MrBeldum <157205701+MrBeldum@users.noreply.github.com> Date: Wed, 30 Sep 2026 01:17:07 -0700 Subject: [PATCH 16/38] Drop the stream crate's unused serde dependency brolink-stream declared serde but serialises nothing; its only JSON lives in brolink-core. Co-Authored-By: Claude Opus 5.5 (1M context) --- Cargo.lock | 1 - crates/stream/Cargo.toml | 1 - 2 files changed, 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ff76c22..2ae5ffe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -774,7 +774,6 @@ dependencies = [ "rcgen", "rsa", "rustls", - "serde", "sha2", "tracing", "x509-parser", 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. From af6933ee8df132b34caf6b398d1cb1a189d87d25 Mon Sep 17 00:00:00 2001 From: MrBeldum <157205701+MrBeldum@users.noreply.github.com> Date: Wed, 30 Sep 2026 01:24:56 -0700 Subject: [PATCH 17/38] End the audio output thread when the player goes, not on a timer The thread that owns the cpal stream woke every 50 ms to look at a stop flag for the whole of every session, and kept the device open up to 50 ms after the player was dropped. It now blocks on a channel whose sender the player holds, so it wakes exactly once, at the end. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/stream/src/audio.rs | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/crates/stream/src/audio.rs b/crates/stream/src/audio.rs index adcd454..12e68fd 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. @@ -104,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(), ); @@ -126,7 +129,7 @@ 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, @@ -219,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) }; } } @@ -233,7 +235,7 @@ fn output_thread( source_rate: u32, source_channels: u16, queue: Arc>>, - stop: Arc, + stop: mpsc::Receiver<()>, output: Arc>, pulled: Arc, ) { @@ -352,9 +354,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"); } From f158641b6b6091b8073a30ae73cb715167500af4 Mon Sep 17 00:00:00 2001 From: MrBeldum <157205701+MrBeldum@users.noreply.github.com> Date: Wed, 30 Sep 2026 01:24:56 -0700 Subject: [PATCH 18/38] Redraw an idle window once a second, and only on news from the service The lobby asked egui for a new frame every 400 ms, and the unified app's service poller asked for another after every one-second poll, whether or not anything had changed. Workers already repaint when discovery, a connection attempt or an update moves, so the lobby's own timer only has to age notices: it is now one second, and the poller repaints only when the status, error, paired clients or gamepad state it read differ from before. Measured on this Mac with the release viewer (brolink-client, lobby open and idle, 60 s after a 12 s warm-up): 0.58 s of CPU at 400 ms, 0.39 s at 1 s, from about 1.0% to 0.65% of a core. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/client/src/app.rs | 3 ++- crates/host/src/app.rs | 14 +++++++++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/crates/client/src/app.rs b/crates/client/src/app.rs index 3995713..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(); 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)); } From 9dd9da773c7a1ff8bad48dc3a7d720ba99992428 Mon Sep 17 00:00:00 2001 From: MrBeldum <157205701+MrBeldum@users.noreply.github.com> Date: Wed, 30 Sep 2026 01:26:32 -0700 Subject: [PATCH 19/38] Bring the docs up to date with what has shipped ROADMAP still listed clipboard sync under Later; it shipped in 3.1, and 3.1 to 4.0 had no section at all. README called the stream "BroLink's stream protocol" (it is GameStream, through moonlight-common-c and Sunshine), left out deploy/native, and now states the control API's account rule including tagged servers. MACOS.md still described a Windows-only machine list; WINDOWS.md's route table lacked /v1/display. CONTRIBUTING explains BROLINK_DATA_DIR, where the vendored library is described, and the stream_real variables for streaming from another machine. Co-Authored-By: Claude Opus 5.5 (1M context) --- CONTRIBUTING.md | 20 ++++++++++++++++++-- README.md | 14 ++++++++++---- docs/MACOS.md | 6 +++--- docs/ROADMAP.md | 17 ++++++++++++++++- docs/WINDOWS.md | 5 ++++- 5 files changed, 51 insertions(+), 11 deletions(-) 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/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/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 From 949a670de2869b353bafcb6d21e59a8ad7ae5624 Mon Sep 17 00:00:00 2001 From: MrBeldum <157205701+MrBeldum@users.noreply.github.com> Date: Wed, 30 Sep 2026 01:26:58 -0700 Subject: [PATCH 20/38] Restart the Mac's background service when a script installs the app install-macos.sh and install-macos-release.sh replaced /Applications/BroLink.app but left the launchd job running the old binary: replacing a bundle does not stop a process, and the agent's KeepAlive restarts only after a failure. The Mac's sharing service then answered on the previous version until the next login, and every manual install needed a `launchctl kickstart`. Both scripts now kickstart the agent when launchd has it loaded. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 4 ++++ scripts/install-macos-release.sh | 7 +++++++ scripts/install-macos.sh | 7 +++++++ 3 files changed, 18 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c98ecc2..9446d5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,10 @@ GitHub release notes, so each version gets a heading of the form `"` 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. - 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. 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." From afc685736e744247aa368d84df6fece614f91e24 Mon Sep 17 00:00:00 2001 From: MrBeldum <157205701+MrBeldum@users.noreply.github.com> Date: Wed, 30 Sep 2026 01:28:31 -0700 Subject: [PATCH 21/38] Check for a black picture in vector-sized steps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every decoded frame is checked for being entirely black (the stream's black-picture diagnosis). The check walked the planes byte by byte with a short-circuiting all(), which cannot be vectorised, so a frame that really was black, the case the check exists for, cost the decoder thread 2.5 ms at 3024x1964: 15% of a core at 60 fps, on the latency path. Each row is now reduced to its maximum (and minimum, for chroma), which the compiler vectorises, still stopping at the first row with a visible pixel. Release build, 3024x1964 NV12, 200 runs: a black frame 2.47 ms -> 143 µs, a frame with a picture near the top 8.1 µs -> 0.34 µs. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/stream/src/video.rs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/crates/stream/src/video.rs b/crates/stream/src/video.rs index ef44e2b..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 }) } } From 356f458e1bbf0463befa5b3a694596217eb4eeba Mon Sep 17 00:00:00 2001 From: MrBeldum <157205701+MrBeldum@users.noreply.github.com> Date: Wed, 30 Sep 2026 01:29:37 -0700 Subject: [PATCH 22/38] Render each icon size once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The unified window renders the 64-pixel logo for the client's header, again for the host panel's, and on Windows and Linux once more for the window icon, all on the UI thread before the first frame. Each render is a Lanczos resize of the 1024-pixel logo, about 6.6 ms in a release build. Rendered sizes are now kept, so every render after the first of a size costs a 16 KB copy (3.5 µs). Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/core/src/icon.rs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) 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(); } From 107f486e7424983b6949131124dd0f6322f44483 Mon Sep 17 00:00:00 2001 From: MrBeldum <157205701+MrBeldum@users.noreply.github.com> Date: Wed, 30 Sep 2026 01:30:52 -0700 Subject: [PATCH 23/38] Bundle only the unified app bundle-macos.sh fell back to the viewer-only brolink-client binary when no brolink-host was built, which produced a BroLink.app that could not share its machine. Nothing builds the bundle that way; it now asks for the host build instead. Co-Authored-By: Claude Opus 5.5 (1M context) --- scripts/bundle-macos.sh | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) 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 From 66d4aa88b87b687204982da6e2c55a6a665ca922 Mon Sep 17 00:00:00 2001 From: MrBeldum <157205701+MrBeldum@users.noreply.github.com> Date: Wed, 30 Sep 2026 01:31:17 -0700 Subject: [PATCH 24/38] Compile the clipboard sequence helper only where it is used Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/host/src/clipboard.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/host/src/clipboard.rs b/crates/host/src/clipboard.rs index abc52c1..8e1cb1a 100644 --- a/crates/host/src/clipboard.rs +++ b/crates/host/src/clipboard.rs @@ -153,7 +153,7 @@ pub fn write(text: &str) -> Result<()> { /// 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_attr(windows, allow(dead_code))] +#[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(); From d35496fac64e66ebca37ed258d1e99e4460c4da3 Mon Sep 17 00:00:00 2001 From: MrBeldum <157205701+MrBeldum@users.noreply.github.com> Date: Wed, 30 Sep 2026 01:33:09 -0700 Subject: [PATCH 25/38] Keep one copy of the tailnet check, the PowerShell probe and window setup - The 100.64.0.0/10 test lived in core's wake.rs and again in the host service, with the IPv6 variant only in the service. Both are now tailscale::is_tailnet and is_tailnet_ip. - wake.rs and virtual_display.rs each carried the same powershell() runner; setup.rs, with the other PowerShell helpers, has the one. - brolink-host and the development viewer each built their own window options, and only the viewer's explained the vsync-off choice they share. brolink_client::native_options() is used by both, and the host no longer depends on egui and egui-wgpu directly. Co-Authored-By: Claude Opus 5.5 (1M context) --- Cargo.lock | 2 -- crates/client/src/lib.rs | 37 +++++++++++++++++++++++++++++ crates/client/src/main.rs | 38 +++--------------------------- crates/core/src/tailscale.rs | 28 +++++++++++++++++++++- crates/core/src/wake.rs | 9 +++---- crates/host/Cargo.toml | 2 -- crates/host/src/main.rs | 26 +------------------- crates/host/src/service.rs | 30 ++--------------------- crates/host/src/setup.rs | 18 ++++++++++++++ crates/host/src/virtual_display.rs | 20 +--------------- crates/host/src/wake.rs | 19 +-------------- 11 files changed, 93 insertions(+), 136 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2ae5ffe..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", 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/core/src/tailscale.rs b/crates/core/src/tailscale.rs index 19ff41c..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; @@ -328,6 +328,20 @@ pub fn netcheck() -> Result { parse_netcheck(&out) } +/// 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 && (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 { @@ -704,6 +718,18 @@ 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( 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/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/service.rs b/crates/host/src/service.rs index 4d0d8d3..65c62ff 100644 --- a/crates/host/src/service.rs +++ b/crates/host/src/service.rs @@ -20,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, is_tailnet_ip}; +use brolink_core::CONTROL_PORT; use parking_lot::Mutex; use std::collections::{HashMap, VecDeque}; use std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpListener}; @@ -915,37 +916,10 @@ fn control_host(host: &str) -> bool { }) } -/// 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::*; - #[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 whois(id: u64, login: &str) -> tailscale::WhoIs { tailscale::WhoIs { user_profile: tailscale::User { diff --git a/crates/host/src/setup.rs b/crates/host/src/setup.rs index acf5320..ba27ff5 100644 --- a/crates/host/src/setup.rs +++ b/crates/host/src/setup.rs @@ -599,6 +599,24 @@ 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()) +} + // 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 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::*; From 07156791448dc85448ec14c3aefc121154b6e034 Mon Sep 17 00:00:00 2001 From: MrBeldum <157205701+MrBeldum@users.noreply.github.com> Date: Wed, 30 Sep 2026 01:33:57 -0700 Subject: [PATCH 26/38] Run the release updater only on the Mac Since 4.0 the unified window runs on every OS, and with it update::spawn. The updater assumes a Mac throughout: it swaps an app bundle and pulls brolink-host.exe out of the Windows zip with /usr/bin/unzip. On a Windows PC with another Windows PC in the tailnet it would fetch the release and then fail to push, again every three minutes, racing the Mac that does the same job. Off macOS spawn() now only says where updates come from. Also corrects the module docs that still called the repository private. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 5 +++++ crates/client/src/update.rs | 17 +++++++++++++++-- crates/core/src/update.rs | 8 ++++---- 3 files changed, 24 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9446d5c..5b7187a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,11 @@ GitHub release notes, so each version gets a heading of the form 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. - 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. diff --git a/crates/client/src/update.rs b/crates/client/src/update.rs index 0cbf6cd..38a4f7d 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 @@ -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; diff --git a/crates/core/src/update.rs b/crates/core/src/update.rs index 8899a1b..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 From 0212d9a7bf08fd6234353da991f6cf6147357f04 Mon Sep 17 00:00:00 2001 From: MrBeldum <157205701+MrBeldum@users.noreply.github.com> Date: Wed, 30 Sep 2026 01:38:32 -0700 Subject: [PATCH 27/38] Accept a Tailscale IPv6 address in the control API's Host header The rebinding guard allowed loopback and Tailscale IPv4 in the Host header while the peer check itself admits Tailscale IPv6 too, so a client reaching the service over IPv6 was told browser access is disabled. Both now use tailscale::is_tailnet_ip. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/host/src/service.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/crates/host/src/service.rs b/crates/host/src/service.rs index 65c62ff..1276685 100644 --- a/crates/host/src/service.rs +++ b/crates/host/src/service.rs @@ -20,7 +20,7 @@ use brolink_core::api::{ CLIPBOARD_PATH, UPDATE_PATH, }; use brolink_core::http::{self, Request, Response}; -use brolink_core::tailscale::{self, is_tailnet, is_tailnet_ip}; +use brolink_core::tailscale::{self, is_tailnet_ip}; use brolink_core::CONTROL_PORT; use parking_lot::Mutex; use std::collections::{HashMap, VecDeque}; @@ -911,8 +911,7 @@ 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())) }) } @@ -1009,9 +1008,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() From beef87842490a0966c68a89216002d4c83f4afa3 Mon Sep 17 00:00:00 2001 From: MrBeldum <157205701+MrBeldum@users.noreply.github.com> Date: Wed, 30 Sep 2026 01:40:15 -0700 Subject: [PATCH 28/38] Serve the handover download from a blocking socket The handover server polls a non-blocking listener so it can notice a stop. On macOS a socket accepted from a non-blocking listener inherits O_NONBLOCK, so the timeouts set on it did nothing: a read before the PC's request arrived failed at once, and writing the ~20 MB executable failed with WouldBlock as soon as the send buffer filled. The PC then got a truncated file, the script's SHA-256 check refused it, and the install through the stream could not complete from a Mac. Accepted connections are now switched back to blocking. The new test writes 16 MB through a connection accepted this way; it fails without the change. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 4 +++ crates/client/src/handover.rs | 56 +++++++++++++++++++++++++++++++---- 2 files changed, 54 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b7187a..29c1ddd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,10 @@ GitHub release notes, so each version gets a heading of the form 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. - 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. 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 { From d6433d6824af4cc1608e1d02ca3bbf6bb8c50759 Mon Sep 17 00:00:00 2001 From: MrBeldum <157205701+MrBeldum@users.noreply.github.com> Date: Wed, 30 Sep 2026 01:41:11 -0700 Subject: [PATCH 29/38] Pass the user's data folder to elevated setup as one argument setup::run starts `brolink-host --setup-elevated --local-app-data ` through `Start-Process -ArgumentList @(...)`. Start-Process joins that array with spaces and quotes nothing (as its documentation says), so for a Windows user whose profile folder contains a space the elevated process got `--local-app-data C:\Users\Ada` plus a stray `Lovelace\...` argument, which clap rejects, and setup failed. The folder now carries its own double quotes, without a trailing backslash that would escape the closing one. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 5 +++++ crates/host/src/setup.rs | 45 ++++++++++++++++++++++++++++++++-------- 2 files changed, 41 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 29c1ddd..2aa0133 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,11 @@ GitHub release notes, so each version gets a heading of the form 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. - 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. diff --git a/crates/host/src/setup.rs b/crates/host/src/setup.rs index ba27ff5..0eaa73a 100644 --- a/crates/host/src/setup.rs +++ b/crates/host/src/setup.rs @@ -573,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" ); @@ -617,6 +614,22 @@ pub(crate) fn powershell(script: &str) -> Result { 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 @@ -828,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"); From 6a7ff9bdcb2b556f810be6af23983a696fb4525b Mon Sep 17 00:00:00 2001 From: MrBeldum <157205701+MrBeldum@users.noreply.github.com> Date: Wed, 30 Sep 2026 01:42:11 -0700 Subject: [PATCH 30/38] Log the NAT when it changes, not when the nearest relay does tailscale netcheck's preferred DERP region moves between runs on the same network (this Mac's service.log: Paris, Chicago, Paris within an hour), and every move logged a full network line. The window keeps the last 80 lines, so the flips pushed out what mattered. The line is now logged when UDP, the address families, the NAT's hardness or port mapping change; the report itself still carries the current region. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/host/src/service.rs | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/crates/host/src/service.rs b/crates/host/src/service.rs index 1276685..80dc47a 100644 --- a/crates/host/src/service.rs +++ b/crates/host/src/service.rs @@ -374,7 +374,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); @@ -846,6 +846,15 @@ impl Owner { } } +/// 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( @@ -1184,6 +1193,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 { From 4488f53200216800c5bf58765431a7153e74f8fc Mon Sep 17 00:00:00 2001 From: MrBeldum <157205701+MrBeldum@users.noreply.github.com> Date: Wed, 30 Sep 2026 01:42:56 -0700 Subject: [PATCH 31/38] Log a peer once, not every time its whois answer is refreshed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The control service caches a whois answer for a minute and logged the peer on every refresh. A machine polling /v1/status every few seconds therefore wrote "hermes (…) asked" once a minute for as long as it was up: 2,707 of 2,810 lines in this Mac's service.log, and the whole of the 80 lines the window shows. Answers are now remembered for an hour, and a peer is logged when it is first seen, when its answer changes, or after an hour without asking. The one-minute trust in an answer is unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 6 ++++++ crates/host/src/service.rs | 42 +++++++++++++++++++++----------------- 2 files changed, 29 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2aa0133..76fa43d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,12 @@ GitHub release notes, so each version gets a heading of the form 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. - 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. diff --git a/crates/host/src/service.rs b/crates/host/src/service.rs index 80dc47a..169af01 100644 --- a/crates/host/src/service.rs +++ b/crates/host/src/service.rs @@ -32,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. @@ -492,14 +494,11 @@ impl Service { return false; } let now = Instant::now(); - let cached = self - .auth - .lock() - .get(&ip) - .filter(|(at, _)| now - *at < AUTH_TTL) - .map(|(_, allowed)| *allowed); - if let Some(allowed) = cached { - return allowed; + 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() { @@ -509,16 +508,21 @@ impl Service { let allowed = match tailscale::whois(ip) { Ok(w) => { let allowed = owner.admits(&w); - self.log(format!( - "{} ({}) asked{}", - w.node.computed_name, - w.user_profile.login_name, - if allowed { - "" - } else { - ": not this account, refused" - } - )); + // 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" + } + )); + } allowed } Err(e) => { @@ -527,7 +531,7 @@ impl Service { } }; let mut cache = self.auth.lock(); - cache.retain(|_, (at, _)| at.elapsed() < AUTH_TTL); + cache.retain(|_, (at, _)| at.elapsed() < AUTH_REMEMBER); if cache.len() >= AUTH_CACHE_LIMIT { if let Some(oldest) = cache .iter() From 53daa018225044b241de163aab177ce2e0529f0c Mon Sep 17 00:00:00 2001 From: MrBeldum <157205701+MrBeldum@users.noreply.github.com> Date: Wed, 30 Sep 2026 01:43:51 -0700 Subject: [PATCH 32/38] Name the output device sound actually moved to MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the Mac's default output changes during a stream, cpal 0.18's CoreAudio backend reroutes the default-device stream itself and reports ErrorKind::DeviceChanged through the error callback. BroLink logged that as a warning (28 of them in this Mac's panel.log) and kept the old device's name in Output::Playing, so the stats said "audio → WH-1000XM4" while the sound played on AirPods. The callback now takes the new default's name for a DeviceChanged and still warns about anything else. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 3 +++ crates/stream/src/audio.rs | 35 +++++++++++++++++++++++++++++------ 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 76fa43d..9ffbd10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,6 +58,9 @@ GitHub release notes, so each version gets a heading of the form 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. - 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. diff --git a/crates/stream/src/audio.rs b/crates/stream/src/audio.rs index 12e68fd..bcad24b 100644 --- a/crates/stream/src/audio.rs +++ b/crates/stream/src/audio.rs @@ -283,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, ) } @@ -299,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, ) } @@ -308,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, ) } @@ -318,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), @@ -328,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, ) } From a84dcec51626a64c3a6347dc8edc6728270d8efe Mon Sep 17 00:00:00 2001 From: MrBeldum <157205701+MrBeldum@users.noreply.github.com> Date: Wed, 30 Sep 2026 01:44:40 -0700 Subject: [PATCH 33/38] Count a pushed host that is already on the release as updated This Mac's panel.log has "host update attempt 1/3: connection closed" followed by "host update for Hermes: this PC already runs BroLink Host 4.0.2": the first POST landed, Hermes answered and handed over to the new executable, the reply was lost with the old process, and the retry got 409 NotNewer, which the lobby showed as "Could not update Hermes". After a dropped connection or on a retry, push_host now asks the host's /v1/status (for up to 15 s, while it restarts) and treats a version at or past the release as success. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 5 +++ crates/client/src/update.rs | 75 +++++++++++++++++++++++++++++++++++-- 2 files changed, 76 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ffbd10..9ab64d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,6 +61,11 @@ GitHub release notes, so each version gets a heading of the form - 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. diff --git a/crates/client/src/update.rs b/crates/client/src/update.rs index 38a4f7d..bbdd9b2 100644 --- a/crates/client/src/update.rs +++ b/crates/client/src/update.rs @@ -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}; @@ -506,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), @@ -663,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"))); From ed251f65e130a9710d11434a9e310eef0d057ba6 Mon Sep 17 00:00:00 2001 From: MrBeldum <157205701+MrBeldum@users.noreply.github.com> Date: Wed, 30 Sep 2026 02:00:22 -0700 Subject: [PATCH 34/38] Log a failing whois once, not at every request A failed whois is not cached, so a peer polling every three seconds logged the same failure twenty times a minute and emptied the window's 80-line log. The same failure is now logged once until another one takes its place. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/host/src/service.rs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/crates/host/src/service.rs b/crates/host/src/service.rs index 169af01..9bbcdf8 100644 --- a/crates/host/src/service.rs +++ b/crates/host/src/service.rs @@ -56,6 +56,8 @@ pub struct Service { log: 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, @@ -75,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)), @@ -526,7 +529,14 @@ impl Service { allowed } Err(e) => { - self.log(format!("whois {ip}: {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); + } return false; } }; From b68c802235d9cdadb22fad8cbda79985c50e9c22 Mon Sep 17 00:00:00 2001 From: MrBeldum <157205701+MrBeldum@users.noreply.github.com> Date: Wed, 30 Sep 2026 02:00:36 -0700 Subject: [PATCH 35/38] Give the PC's display report a minute to arrive The black-picture report runs PowerShell with WMI queries and a screen sample on the PC, then asks the engine's web API for its config and log (curl, up to 8 s each). The Mac gave the whole thing 25 s and this Mac's panel.log has three "display report: timed out", after which the notice never says why the picture is black. The report now gets 60 s; it is asked for once per stream, off the UI thread. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/client/src/display.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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. From 5191f9294b418a444616b7fac2503e9daa2f8c80 Mon Sep 17 00:00:00 2001 From: MrBeldum <157205701+MrBeldum@users.noreply.github.com> Date: Wed, 30 Sep 2026 02:01:41 -0700 Subject: [PATCH 36/38] Leave the PC as found after the live stream test, and note the speed-ups stream_real now ends the app it launched (not one it resumed) before it asserts, so a live run does not leave a Desktop session open on the PC. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 9 +++++++++ crates/stream/src/session.rs | 13 +++++++++---- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ab64d1..b037b03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -72,6 +72,15 @@ GitHub release notes, so each version gets a heading of the form - 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) diff --git a/crates/stream/src/session.rs b/crates/stream/src/session.rs index 349248c..ee1a796 100644 --- a/crates/stream/src/session.rs +++ b/crates/stream/src/session.rs @@ -1056,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"); } } From 30f2e4af283b5084715006ddd7f3308f0e408496 Mon Sep 17 00:00:00 2001 From: MrBeldum <157205701+MrBeldum@users.noreply.github.com> Date: Wed, 30 Sep 2026 02:02:57 -0700 Subject: [PATCH 37/38] Word the service's log for any viewer, and name who asked Since 4.0 a Windows or Linux machine is as likely as a Mac to be the one asking, but the service still logged "the Mac sent the new BroLink Host", "sleep requested from the Mac", "the Mac turned the HDR desktop off" and "a Mac can only reach this PC through a relay". Those lines are now OS-neutral, and a power, update or display change names the tailnet address that asked for it. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/host/src/service.rs | 42 ++++++++++++++++++++------------------ 1 file changed, 22 insertions(+), 20 deletions(-) diff --git a/crates/host/src/service.rs b/crates/host/src/service.rs index 9bbcdf8..de3eddf 100644 --- a/crates/host/src/service.rs +++ b/crates/host/src/service.rs @@ -1,4 +1,4 @@ -//! 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 machine that @@ -48,7 +48,7 @@ pub struct Service { 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>, @@ -348,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; } @@ -357,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; @@ -435,7 +435,7 @@ 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 { @@ -590,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}"))), @@ -619,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 @@ -651,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 || { @@ -732,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) @@ -748,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, @@ -770,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. @@ -795,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() @@ -1051,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)); } @@ -1147,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())] } From 92eaaa6a9a69cac9c9f7e8de691996ab837c6376 Mon Sep 17 00:00:00 2001 From: MrBeldum <157205701+MrBeldum@users.noreply.github.com> Date: Wed, 30 Sep 2026 02:03:30 -0700 Subject: [PATCH 38/38] Redraw the window from discovery only when the listing changes The discovery thread asked for a frame after every three-second scan, and the netcheck and peer-relay probes asked for one after each of their runs, whether or not anything had changed. The probes now only fill their slots; the scan that picks their results up redraws the window when the machines, their status, the relays or this machine's network differ from the last scan. The time of the scan alone does not count. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/client/src/session.rs | 73 +++++++++++++++++++++++++++--------- 1 file changed, 56 insertions(+), 17 deletions(-) diff --git a/crates/client/src/session.rs b/crates/client/src/session.rs index 1bf8b18..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); }); } @@ -1039,6 +1050,34 @@ mod tests { 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();