Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
261fdbb
Keep test runs out of the real BroLink data directory
MrBeldum Sep 30, 2026
9caed05
Answer only the same Tailscale account on the control API again
MrBeldum Sep 30, 2026
bb18995
Carry copies from Mac and Linux hosts to the viewer
MrBeldum Sep 30, 2026
928a33b
Keep the old host when a pushed update dies on start
MrBeldum Sep 30, 2026
cdd9842
Try each resolved address when connecting, not only the first
MrBeldum Sep 30, 2026
5784ac1
Name the right licence in the Windows executable's version block
MrBeldum Sep 30, 2026
f907166
Stop running a program for every status request on Mac and Linux
MrBeldum Sep 30, 2026
8abc71b
Make VideoToolbox's frame callback unable to outlive its target
MrBeldum Sep 30, 2026
5a1c340
Stop rewriting the engine helpers every 30 seconds on a silent PC
MrBeldum Sep 30, 2026
43846ab
Let Cancel stop the pairing retries, and name the codec actually used
MrBeldum Sep 30, 2026
b310ee5
Remove API that nothing outside the tests called
MrBeldum Sep 30, 2026
c46bc31
Drop the path argument that Auto quality never read
MrBeldum Sep 30, 2026
02a0fba
Keep the adapter name out of double-quoted strings in setup
MrBeldum Sep 30, 2026
dc5ee54
Parse every PowerShell script in CI, and test the whole workspace
MrBeldum Sep 30, 2026
e4f71ef
Compile only the moonlight-common-c sources BroLink uses, and say why
MrBeldum Sep 30, 2026
c19a220
Drop the stream crate's unused serde dependency
MrBeldum Sep 30, 2026
af6933e
End the audio output thread when the player goes, not on a timer
MrBeldum Sep 30, 2026
f158641
Redraw an idle window once a second, and only on news from the service
MrBeldum Sep 30, 2026
9dd9da7
Bring the docs up to date with what has shipped
MrBeldum Sep 30, 2026
949a670
Restart the Mac's background service when a script installs the app
MrBeldum Sep 30, 2026
afc6857
Check for a black picture in vector-sized steps
MrBeldum Sep 30, 2026
356f458
Render each icon size once
MrBeldum Sep 30, 2026
107f486
Bundle only the unified app
MrBeldum Sep 30, 2026
66d4aa8
Compile the clipboard sequence helper only where it is used
MrBeldum Sep 30, 2026
d35496f
Keep one copy of the tailnet check, the PowerShell probe and window s…
MrBeldum Sep 30, 2026
0715679
Run the release updater only on the Mac
MrBeldum Sep 30, 2026
0212d9a
Accept a Tailscale IPv6 address in the control API's Host header
MrBeldum Sep 30, 2026
beef878
Serve the handover download from a blocking socket
MrBeldum Sep 30, 2026
d6433d6
Pass the user's data folder to elevated setup as one argument
MrBeldum Sep 30, 2026
6a7ff9b
Log the NAT when it changes, not when the nearest relay does
MrBeldum Sep 30, 2026
4488f53
Log a peer once, not every time its whois answer is refreshed
MrBeldum Sep 30, 2026
53daa01
Name the output device sound actually moved to
MrBeldum Sep 30, 2026
a84dcec
Count a pushed host that is already on the release as updated
MrBeldum Sep 30, 2026
ed251f6
Log a failing whois once, not at every request
MrBeldum Sep 30, 2026
b68c802
Give the PC's display report a minute to arrive
MrBeldum Sep 30, 2026
5191f92
Leave the PC as found after the live stream test, and note the speed-ups
MrBeldum Sep 30, 2026
30f2e4a
Word the service's log for any viewer, and name who asked
MrBeldum Sep 30, 2026
92eaaa6
Redraw the window from discovery only when the listing changes
MrBeldum Sep 30, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .cargo/config.toml
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
38 changes: 33 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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

Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
78 changes: 78 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,84 @@ The release workflow publishes the section that matches the tag as the
GitHub release notes, so each version gets a heading of the form
`## X.Y.Z (date)`.

## Unreleased

- The control service again answers only machines signed in to the same
Tailscale account. Since 4.0.0 it answered any machine Tailscale could
name, so a machine shared in from someone else's tailnet could pair with
the PC, sleep or restart it, read and write its clipboard, and push it a
new `brolink-host.exe`. A tagged server such as the relay VPS belongs to
no account, so it answers the members of its own tailnet, which keeps a
Mac able to open the VPS desktop.
- Text copied on a Mac or Linux machine you are streaming from now reaches
the machine you are watching on. Only Windows reported a clipboard
sequence number; everywhere else it was always 0, so the viewer took
every copy for the one it had already seen. On a Linux desktop, text
pasted in from the viewer also stays on the clipboard now instead of
vanishing when no clipboard manager picks it up.
- A Windows PC whose pushed update crashes on start keeps the version it
had. The service used to exit as soon as the new executable had been
launched, so a replacement that died straight away left the PC with no
BroLink service until someone logged in again. It now waits two seconds,
and puts the previous executable back if the new one has already exited.
- Update downloads try each of a server's addresses in turn. Only the first
one the name resolved to was ever tried, so one unreachable address (an
IPv6 one on a network without IPv6, say) failed the whole update check.
- `brolink-host.exe`'s version details (Explorer's Details tab) named the
wrong licence, MIT; they now say GPL-3.0-or-later, the licence BroLink
and moonlight-common-c are under.
- Windows setup no longer breaks on a network adapter whose name holds a
`"` or a `$`. The name was written into a double-quoted PowerShell
string, where a `"` ends the string (a syntax error that stops the whole
setup) and a `$` expands. It is now only ever a single-quoted literal.
- The macOS install scripts restart the background service after putting
the new app in place. It kept running the old version until the next
login, so a Mac that shares its desktop served other machines from the
previous release.
- A Windows machine no longer runs the Mac's updater. Since 4.0 every
machine runs the same window, so a Windows PC also checked GitHub and
would try to send other Windows PCs their update with a Mac-only unzip,
failing every few minutes. Its settings now say that updates arrive
from the Mac.
- Installing BroLink Host through the stream (for a PC on 3.0) can finish
on a Mac. The Mac served the file from a socket that macOS left
non-blocking, so sending the executable stopped as soon as the network
buffer filled and the PC rejected the truncated download.
- **Share this machine** works on a Windows account whose folder has a
space in it ("C:\Users\Ada Lovelace"). The elevated setup was handed
that folder through PowerShell's Start-Process, which splits unquoted
arguments at spaces, so it received half a path and an unexpected
extra argument and failed.
- The service log keeps what matters. Every machine that polled the
service was logged again each minute ("hermes (…) asked" was 2,707 of
the 2,810 lines in this Mac's `service.log`), and every change of the
nearest Tailscale relay region logged a network line. Each peer is now
logged when first seen, when its answer changes or after an hour away,
and the network line only when the NAT itself changes.
- When the Mac's sound output changes mid-stream (AirPods connect, say),
the stream's stats name the device the sound moved to. macOS already
moved it; BroLink kept naming the old one and logged a warning.
- A host update that lands is no longer reported as a failure. A PC can
take the new executable and restart before its reply reaches the Mac;
the Mac then retried, heard "this PC already runs BroLink Host 4.0.2",
and showed "Could not update" in red. It now asks the PC which version
it runs before calling a push failed.
- Cancel stops a connection attempt straight away while the PC is not
answering. The pairing check retried eight times regardless, which could
hold the window on "Checking pairing…" for most of a minute after Cancel.
- A Windows or Linux viewer no longer calls its stream HEVC: those decode
H.264 only, and the toolbar named the codec the PC could offer instead
of the one in use.
- The check that notices a black picture costs the decoder 0.14 ms a frame
instead of 2.5 ms at 3024×1964 when the picture really is black (15% of
a core at 60 fps, on the path every frame takes to the screen).
- An idle window redraws about once a second instead of two or three
times, about a third less CPU while the lobby sits open.
- A Mac or Linux machine that shares its desktop no longer runs a program
(`scutil` or `hostname`) for every status request it answers.
- When the picture is black, the PC's explanation gets a minute to arrive
instead of 25 seconds, which its slower checks sometimes needed.

## 4.0.2 (2026-09-22)

- `panel.log` and `service.log` no longer grow without end. Both were
Expand Down
20 changes: 18 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
3 changes: 0 additions & 3 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions NOTICE
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
14 changes: 10 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
```
Expand Down
8 changes: 4 additions & 4 deletions crates/client/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -611,7 +612,7 @@ impl eframe::App for ClientApp {
ui.add_space(14.0);
self.pcs_card(ui, ctx, &disc, &prog);
ui::titled_card(ui, "Next session", None, |ui| {
let settings = path::effective(&self.cfg.stream, &path::Path::default());
let settings = path::effective(&self.cfg.stream);
let (w, h) = settings.resolution.pixels(Self::native_pixels(ctx));
ui.horizontal_wrapped(|ui| {
ui::status_pill(ui, &format!("{w} × {h}"), Tone::Info);
Expand Down Expand Up @@ -2109,7 +2110,7 @@ mod snapshots {
rtt_ms: Some(210),
..Default::default()
};
let settings = crate::path::effective(&crate::config::StreamSettings::default(), &path);
let settings = crate::path::effective(&crate::config::StreamSettings::default());
let session = brolink_stream::Session::start(
brolink_stream::session::Server {
address: "10.255.255.1".into(),
Expand All @@ -2124,7 +2125,6 @@ mod snapshots {
fps: 30,
bitrate_kbps: 4000,
hevc: true,
remote: true,
},
[0; 16],
[0; 16],
Expand Down
4 changes: 2 additions & 2 deletions crates/client/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
6 changes: 5 additions & 1 deletion crates/client/src/display.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand All @@ -30,7 +34,7 @@ pub struct Help {
}

pub fn ask(ip: Ipv4Addr) -> Result<Value> {
http::get_json::<Value>((ip, CONTROL_PORT), "/v1/display", TIMEOUT)
http::get_json::<Value>((ip, CONTROL_PORT), "/v1/display", REPORT_TIMEOUT)
}

/// Turn the PC's HDR desktop off (or back on) and return its new state.
Expand Down
Loading
Loading