Compare commits

...
Author SHA1 Message Date
enricobuehler a2dc011200 release: 0.31.2 — version bump, notes, CHANGELOG, Play notes
apple / swift (pull_request) Successful in 2m17s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 4m59s
android / android (pull_request) Successful in 6m30s
nix / flake (pull_request) Successful in 6m38s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 8m5s
ci / docs-drift (pull_request) Successful in 29s
ci / bun-nix (pull_request) Successful in 32s
ci / web (pull_request) Successful in 1m6s
ci / docs-site (pull_request) Successful in 1m9s
ci / rust-arm64 (pull_request) Successful in 1m25s
ci / rust (pull_request) Successful in 16m10s
10 commits since v0.31.1 (6 non-merge). Cut from origin/main 48eeae75 (#368
merged).

THE NUMBER: a patch, and unlike the last cut the version table does not even
have to argue for it. Nothing versioned moved — WIRE_VERSION 2, C ABI 25 with
include/punktfunk_core.h showing NO diff at all against the v0.31.1 tag (not
even a #define, unlike the last two releases), driver protocol 6 / min 3 with
pf-driver-proto unchanged, gamepad channel 3, plugin index schema 1, host event
schema 1, gamescope +pfhdr8 with no new patch files, SDK 0.1.5 and plugin-kit
0.4.4 both untouched. No `!` commit, no feat, no route added or removed, no
breaking change of any kind. Every non-merge commit is fix/refactor/test.

The cycle has a shape: three of the six non-merge commits are the same class of
fault — the host using the wrong local address — reached from three directions.
The data socket bound 0.0.0.0:0 and let routing pick the video source, which the
client's connected socket then dropped in-kernel (#367). Host::detect() froze the
advertised address at process start, so a cold boot that beat the network pinned
127.0.0.1 for the life of the process and broke both mDNS adverts, the Moonlight
session URL, the WoL mac record and HostInfo together (#366). And the firewall
rules guarding the ports those addresses point at admitted any program on the
machine (#368). The fourth is an Android regression from v0.31.1 (#365); the
remaining two are the refactor and test supporting #366.

api/openapi.json changes in DOCUMENTATION ONLY this time — two description
strings on HostInfo, no route, schema, required field or type — plus the stamp.
Re-stamped here, not regenerated: punktfunk-host does not build on macOS, and
#366 regenerated the document itself on a runner where
openapi_document_is_complete_and_checked_in actually executes. "0.31.1" appears
nowhere in either copy afterwards, and the two copies are byte-identical.

That description change is load-bearing rather than cosmetic, so it is called out
as a behaviour change in the CHANGELOG beside the firewall one: HostInfo.local_ip
was a field snapshotted at detect() and is now a method that re-reads per
request, so a consumer that cached it at startup was caching a value that could
be 127.0.0.1 forever.

The other behaviour change is the externally visible one: Windows service install
now scopes all five fixed-port rules to the listening executable while keeping
their localport=, so 5353 is punktfunk's alone and anything else on the machine
that was reachable on mDNS through our any-program rule needs its own. Fallbacks
are asymmetric on purpose — a fixed-port rule that cannot resolve its exe falls
back to the old wide form (a looser rule still streams), while the data-plane
rule skips (it has no port to fall back to, so a program-less version would not
be looser, it would be open).

Also in this commit, because a cut is when docs freshness bites:
docs-site/content/docs/ports.mdx. Its "Video needs nothing opened" bullet has
been wrong for Windows since v0.31.1 added the data-plane rule — it now says so
and names why (no fixed rule can cover a per-session ephemeral port). And the
Windows line gains a Callout for the 5353 change above, since that is the one
thing on this page a reader may have to act on. Callout shape copied from the
proven usage in plugins.mdx (no `title` prop — node_modules is not installed here
and fumadocs' prop surface could not be verified offline).

Play notes are Android-only per whatsnew/TEMPLATE.txt, which this cycle means the
#365 regression alone. The three host-side fixes are deliberately NOT in there:
updating the app does not fix any of them, so listing them on the store page
would promise something the download does not deliver.

Gates: cargo fmt --all --check clean; cargo metadata --offline ok with the
Cargo.lock diff versions-only (36/36); cargo test -p punktfunk-core --lib 273
passed; the C ABI harness PASSED reporting abi_version=25 (needed `brew install
opus` on this Mac to link — the first run failed on the missing library, not on
the code); cbindgen regenerated include/punktfunk_core.h during that build and it
came out byte-identical to the checked-in file AND to the v0.31.1 tag, which is a
stronger check on the ABI row than diffing it; scripts/ci/check-docs-drift.sh
clean; scripts/ci/check-docs-links.sh clean; the android.yml Play notes gate run
verbatim, 357/500 characters and unique; both openapi copies cmp identical and
stamped 0.31.2; notes voice scan clean (one backticked term in the whole file,
the `punktfunk-host service install` command, and the only technical vocabulary
sits inside `## For developers`).

Not run here, and why: clippy and any punktfunk-host build (does not compile on
macOS — CI covers it), and the Android unit tests (:kit: and :app: were run on
#365 itself; nothing in this commit touches Kotlin).

One judgement call left for the tag: SECURITY.md promises to credit a reporter in
the release notes when the fix is public, and the #368 commit records only "a
user on 2026-08-21" with no name. The notes credit them unnamed. If they want
their name on it, that is a one-line edit to docs/releases/v0.31.2.md before the
tag is pushed.
2026-08-21 20:04:23 +02:00
enricobuehler 48eeae7527 Merge pull request 'The fixed-port firewall rules were open to every program on the machine' (#368) from worktree-firewall-program-scoped-rules into main
ci / bun-nix (push) Successful in 26s
ci / docs-drift (push) Successful in 21s
ci / rust-arm64 (push) Successful in 1m28s
ci / web (push) Successful in 1m41s
ci / docs-site (push) Successful in 1m55s
deb / build-publish-gamescope (push) Successful in 1m39s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 46s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 12s
deb / build-publish-client-arm64 (push) Successful in 2m34s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 13s
deb / build-publish (push) Successful in 4m3s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 19s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 27s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 15s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 18s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 21s
deb / build-publish-host (push) Successful in 4m28s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 47s
docker / builders-arm64cross (push) Successful in 8s
arch / build-publish (push) Successful in 7m26s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m58s
ci / rust (push) Successful in 8m7s
docker / deploy-docs (push) Successful in 37s
deb / smoke-install (push) Successful in 2m35s
android / android (push) Successful in 10m56s
windows-host / package (push) Successful in 13m48s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 25s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 17m25s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m39s
2026-08-21 14:14:10 +00:00
enricobuehler 0df4ca957f fix(host,windows): the fixed-port firewall rules were open to every program on the machine
ci / docs-drift (pull_request) Successful in 29s
ci / bun-nix (pull_request) Successful in 1m19s
ci / web (pull_request) Successful in 1m27s
ci / docs-site (pull_request) Successful in 1m26s
ci / rust-arm64 (pull_request) Successful in 1m40s
ci / rust (pull_request) Successful in 5m38s
android / android (pull_request) Successful in 6m1s
`service install` added `dir=in action=allow` rules carrying only `localport=`,
which admit ANY process on the machine on those ports — GameStream
(47984/47989/47998-48010/48010), the native plane (9777), mgmt (47990), mDNS
(5353), and the console pair (47992/47993). Binding a high port on Windows
needs no elevation, so an unprivileged program could take any of them and be
reachable from the LAN simply by binding first — silently, because our rule is
precisely what suppresses the "Allow this app to communicate on…" prompt that
would otherwise be the only way in.

Scope every rule to the executable that actually listens on it, keeping the
ports: program AND port is strictly tighter than either alone. The host rules
name the host exe (resolved once and shared with the data-plane rule, which
already worked this way and is the pattern the rest now follow); the console
rules name the bundled `<app>/bun/bun.exe` the supervisor spawns.

The old argument for leaving them unscoped — "an install whose recorded exe
path later moves still has its fixed ports open" — does not hold: `service
install` re-runs this whole remove-then-add on every upgrade, so the path is
refreshed rather than left stale.

Fallbacks are deliberate and asymmetric. A fixed-port rule whose program can't
be resolved falls back to the old any-program form, because a looser rule still
streams and no rule is a black screen. The data-plane rule instead skips: it
has no `localport=` to fall back to, so a program-less version of it would not
be a looser rule but an open host.

One externally visible change, called out in installer output: 5353 is ours
alone now, so anything else on the machine that answered mDNS through
punktfunk's rule needs its own.

Reported by a user on 2026-08-21, after the source-IP fix in #367 resolved
their black screen.
2026-08-21 16:05:00 +02:00
enricobuehler 13aa11355e Merge pull request 'Video egressed from whichever interface routing picked, not the one the client dialed' (#367) from worktree-blackscreen-data-plane-source-ip into main
ci / bun-nix (push) Successful in 30s
ci / web (push) Successful in 1m2s
ci / docs-drift (push) Successful in 32s
ci / docs-site (push) Successful in 1m13s
ci / rust-arm64 (push) Successful in 1m28s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 15s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 10s
deb / build-publish-gamescope (push) Successful in 49s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 20s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 18s
deb / build-publish-client-arm64 (push) Successful in 1m11s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 12s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 14s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 14s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 14s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 46s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m14s
docker / builders-arm64cross (push) Successful in 13s
docker / deploy-docs (push) Successful in 36s
deb / build-publish-host (push) Successful in 7m0s
ci / rust (push) Successful in 7m59s
arch / build-publish (push) Successful in 10m16s
android / android (push) Successful in 12m19s
windows-host / package (push) Successful in 13m16s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 26s
deb / build-publish (push) Failing after 13m45s
deb / smoke-install (push) Successful in 2m21s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m21s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 17m25s
2026-08-21 12:31:44 +00:00
enricobuehler e989d7457f fix(host): video egressed from whichever interface routing picked, not the one the client dialed
ci / web (pull_request) Successful in 1m29s
ci / rust-arm64 (pull_request) Successful in 1m48s
ci / bun-nix (pull_request) Successful in 39s
ci / docs-drift (pull_request) Successful in 30s
ci / docs-site (pull_request) Successful in 1m41s
android / android (pull_request) Successful in 6m26s
ci / rust (pull_request) Successful in 15m39s
`bind_data_socket` bound `0.0.0.0:0`, so the kernel chose the video source
address from the routing table, independently of the address the client's
control connection actually arrived on. The client's data socket is
`connect`ed to the host IP it dialed, so its kernel drops every datagram from
any other source — before userspace, where nothing counts it.

On a host with two live paths to the client (Ethernet and Wi-Fi both up on the
same LAN; a VPN/overlay adapter claiming the route) that is a permanent black
screen with every gauge green: the hole-punch still arrives so the host logs
`punched=true`, `loss_ppm` stays 0 because there are no packets to see gaps
in, and QUIC — which quinn pins to the right local address — carries control,
audio and input perfectly. `from_socket_punch` already documents the mirror of
this assumption for the *client's* source IP; the host side was never checked.

Bind the data socket to `Connection::local_ip()` instead (unmapping an
IPv4-mapped v6 address so the socket can still `connect` to a v4 peer), and
fall back to the wildcard, loudly, when it is unavailable.

Two diagnostics, because this session's log could not answer the question:
- the `data plane bound` line now carries the socket's post-`connect` `local=`
  address — the source the kernel will actually stamp — and WARNs when it
  differs from the address the control plane arrived on.
- the black-screen ERROR no longer asserts "This is a PATH problem, not
  decode" and no longer names `punched=false` as *the* fingerprint. It fired
  with `punched=true` in the field, which contradicts its own advice and sent
  an investigation at the firewall. It now branches on what the bring-up line
  says, and admits its counter is incremented after decrypt and replay checks,
  so a session whose every datagram failed to open reports the same zero.
2026-08-21 14:06:48 +02:00
enricobuehler b05bb1dd48 A cold-booted host advertised 127.0.0.1 and never recovered (#366)
ci / docs-drift (push) Successful in 38s
ci / bun-nix (push) Successful in 1m14s
ci / web (push) Successful in 1m25s
ci / docs-site (push) Successful in 1m34s
ci / rust-arm64 (push) Successful in 2m2s
deb / build-publish-gamescope (push) Successful in 45s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 11s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 25s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 14s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 14s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 13s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 12s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 12s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 12s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m17s
deb / build-publish-client-arm64 (push) Successful in 5m40s
ci / rust (push) Successful in 8m16s
arch / build-publish (push) Successful in 10m47s
android / android (push) Successful in 10m59s
deb / build-publish (push) Successful in 10m26s
deb / build-publish-host (push) Successful in 11m17s
docker / builders-arm64cross (push) Successful in 16s
docker / deploy-docs (push) Successful in 1m11s
deb / smoke-install (push) Successful in 3m54s
windows-host / package (push) Successful in 17m7s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 34s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 20m43s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 20m6s
`Host::detect()` snapshotted the LAN address once, at process start, and every
consumer read that frozen field for the life of the process. On a cold boot the
host outruns the network — the Windows service is `AutoStart` with no
dependencies — so the route probe failed with ENETUNREACH and the loopback
fallback stuck until someone restarted the host by hand.

Four surfaces broke off that one field: both mDNS adverts published 127.0.0.1 as
their A record, `session_url_xml()` handed Moonlight `rtsp://127.0.0.1:48010`,
`wol::wake_macs()` dropped the `mac` TXT record and silently disabled
Wake-on-LAN, and `HostInfo.local_ip` reported loopback to the console.

Fixed at the choke point: `primary_local_ip()` never returns loopback and falls
back to the first non-loopback interface address when no default route exists
yet; `Host::local_ip` re-reads instead of freezing; and a live mDNS advert
re-registers when the routed address changes. Also covers a changed DHCP lease
and a host moved between Wi-Fi and Ethernet.
2026-08-21 12:04:15 +00:00
enricobuehler 2898f6b049 test(host): cover the interface fallback the boot race actually takes
ci / bun-nix (pull_request) Successful in 42s
ci / docs-drift (pull_request) Successful in 43s
ci / web (pull_request) Successful in 1m1s
ci / docs-site (pull_request) Successful in 1m5s
ci / rust-arm64 (pull_request) Successful in 2m48s
ci / rust (pull_request) Successful in 5m30s
android / android (pull_request) Successful in 6m48s
The route probe needs a default route, which on a cold boot lands after the NIC
has its address; the fallback is what answers in between, and nothing exercised
it. Split it into `first_lan_ipv4` so a test can assert the one thing that
matters: it never hands back the loopback `get_if_addrs` also reports.
2026-08-21 13:34:44 +02:00
enricobuehler 4eb4e3465b refactor(host): end the mDNS re-announce loop with a channel, not a flag
std's mpsc doubles as the sleep and the stop signal: the loop times out every
IP_RECHECK to re-check the address, and the Advert dropping its sender wakes the
thread immediately instead of leaving it to notice a flag up to 10s later. Drops
the Arc<AtomicBool> and the Drop impl.
2026-08-21 13:32:08 +02:00
enricobuehler 44cd5bfd81 Merge pull request 'The button correction fired on pads that never needed it' (#365) from worktree-android-pad-mapping-regression into main
ci / bun-nix (push) Successful in 31s
ci / docs-drift (push) Successful in 44s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 14s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 12s
ci / web (push) Successful in 1m23s
ci / docs-site (push) Successful in 1m34s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 11s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 12s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 11s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 1m27s
ci / rust-arm64 (push) Successful in 2m31s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 59s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m18s
docker / builders-arm64cross (push) Successful in 39s
docker / deploy-docs (push) Successful in 1m5s
ci / rust (push) Successful in 7m35s
android / android (push) Successful in 8m29s
2026-08-21 11:24:03 +00:00
enricobuehler 8977228a4b fix(host): a cold-booted host advertised 127.0.0.1 and never recovered
`Host::detect()` snapshotted the LAN address once, at process start, and every
consumer read that frozen field forever. On a cold boot the host wins the race
against the network — the Windows service is registered `AutoStart` with no
dependencies — so `primary_local_ip()`'s route probe to 8.8.8.8 failed with
ENETUNREACH and the loopback fallback stuck for the life of the process.
Restarting the host re-ran `detect()` on a live network, which is the workaround
users found.

Four surfaces broke together, all off that one field:

  * both mDNS adverts (`_punktfunk._udp`, `_nvstream._tcp`) published `127.0.0.1`
    as their A record — the address a client lists and dials;
  * `session_url_xml()` handed Moonlight `rtsp://127.0.0.1:48010` after /launch,
    so even a manually-added host could not stream;
  * `wol::wake_macs()` found no interface for loopback and dropped the `mac` TXT
    record, silently disabling Wake-on-LAN;
  * `HostInfo.local_ip` reported loopback to the web console.

Fixed at the choke point rather than per-caller:

  * `primary_local_ip()` never returns loopback or the unspecified address. When
    the route probe fails it falls back to the first non-loopback interface
    address, which exists as soon as the NIC is configured even if the default
    route is not installed yet — the common shape of the boot race.
  * `Host::local_ip` becomes a method that re-reads instead of a field that
    freezes. A `connect(2)` on an unconnected UDP socket sends no packets and
    costs nothing beside the HTTP response it is serialized into.
  * mDNS records are pushed, not polled, so a live advert re-registers when the
    routed address changes (`discovery::advertise_live`, shared by both service
    types). It polls the routed address rather than subscribing to the daemon's
    IpAdd events because the boot race usually resolves without one: the NIC
    often has its address before we register and only the route lands late.

This also covers the sibling cases that were never reported — DHCP handing out a
different lease, and a host moved between Wi-Fi and Ethernet.
2026-08-21 13:21:52 +02:00
enricobuehler 6a82a602a1 fix(clients/android): the button correction fired on pads that never needed it
ci / docs-drift (pull_request) Successful in 50s
ci / web (pull_request) Successful in 1m12s
ci / bun-nix (pull_request) Successful in 1m13s
ci / docs-site (pull_request) Successful in 1m16s
ci / rust-arm64 (pull_request) Successful in 2m12s
ci / rust (pull_request) Successful in 5m27s
android / android (pull_request) Successful in 6m41s
Two field reports (2026-08-21), one shape: a GameSir G8+ and an Xbox Elite
Series 2 ("Xbox Wireless Controller" over Bluetooth) with X answering Y, Y
answering LB, and the two shoulders answering menu buttons — everything else
correct. That is not a stray mapping, it is exactly what `GENERIC_XBOX` does to
scancodes `0x133`/`0x134`/`0x136`/`0x137`, so the correction added yesterday was
firing on pads whose buttons were already where `Generic.kl` says they are.

It fired because it asked the wrong question. `hasKeys(BUTTON_C, BUTTON_Z)`
answers for what a device DECLARES, not what it reports: `hid-input` allocates
`BTN_A + n` straight through for every button in the descriptor, so BTN_C
(`0x132`) and BTN_Z (`0x135`) are set on ANY pad declaring six or more —
including a standard-layout pad that never presses either. The signal is
therefore identical on the pad that needs correcting and the pad that does not,
and no amount of tightening it could have separated them. It is the same pad
model in both reports: an Elite Series 2 needed the correction on a Fire TV and
another Elite Series 2 was broken by it here.

What does separate them is the axes. A HID gamepad describes its triggers either
as the Accelerator/Brake usages — which become `ABS_GAS`/`ABS_BRAKE`, names
Android has words for — or as two more generic axes on `ABS_Z`/`ABS_RZ`, which
it does not. A descriptor well-formed enough to name its triggers puts its
buttons at the standard positions too. It is also the firmware line on the pad in
the report: an Xbox Wireless Controller over Bluetooth reports GAS/BRAKE after
its firmware update and Z/Rz before it, and only the older one was ever wrong.

`padButtons` now takes `namedTriggers` and answers NATIVE whenever it is set —
no correction of any kind, on buttons or axes, for a pad Android already reads.
`padMap` computed that fact one line below and only ever spent it on the axes;
it now decides both. `hasKeys` stays for the narrower question it can answer —
WHICH straight-through order, once the axes have established there is one — where
a false positive costs nothing.

Moonlight decides it on the same fact (`ControllerHandler`, `gasRange == null`
beside the `"Xbox Wireless Controller"` name); yesterday's commit cited its
tables and then replaced its discriminator, which is where this came in.

Verified: `:kit:testDebugUnitTest` and `:app:testDebugUnitTest` green (16 cases
in PadButtonsTest, 3 new: the gate holds for every vendor/declaration
combination, the four reported buttons stay themselves, and the report-order
choice past the gate is unchanged), `:app:compileDebugKotlin` clean. The
DualSense report filed alongside these — Triangle dead in the client UI and in
the stream — is NOT explained by this and is not fixed here: a button that
reaches neither is one `buttonBit` maps to nothing, which no branch of the
correction produces for Triangle. The Controllers screen prints the raw scancode
and keycode of every press; that line off the reporter's pad will pin it.
2026-08-21 13:10:23 +02:00
enricobuehler fc3b2d0328 Merge pull request '0.31.1 notes: fold in #362, which is what makes the console's off switch work' (#364) from worktree-release-0311-foldin into main
ci / docs-site (push) Successful in 1m7s
ci / bun-nix (push) Successful in 18s
apple / swift (push) Successful in 2m9s
ci / docs-drift (push) Successful in 19s
android-screenshots / screenshots (push) Successful in 1m55s
ci / web (push) Successful in 8m14s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 4m27s
ci / rust-arm64 (push) Successful in 10m14s
deb / build-publish-gamescope (push) Successful in 1m2s
deb / build-publish (push) Successful in 4m33s
decky / build-publish (push) Successful in 42s
deb / build-publish-client-arm64 (push) Successful in 2m33s
ci / rust (push) Successful in 14m38s
arch / build-publish (push) Successful in 9m40s
apple / distribute (push) Successful in 13m35s
sbom / sbom (push) Successful in 25s
android / android (push) Successful in 11m19s
deb / build-publish-host (push) Successful in 9m2s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 8m42s
docker / builders-arm64cross (push) Successful in 13s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 11s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 10s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 9s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 9s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 11s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 13s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 11s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m22s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 35s
docker / deploy-docs (push) Successful in 20s
linux-client-screenshots / screenshots (push) Successful in 5m2s
deb / smoke-install (push) Successful in 2m40s
flatpak / build-publish (push) Successful in 6m39s
web-screenshots / screenshots (push) Successful in 6m25s
apple / screenshots (push) Successful in 9m53s
windows-host / package (push) Successful in 14m5s
windows-host / canary-manifest (push) Skipped
windows-host / winget-source (push) Successful in 25s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m49s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 19m38s
2026-08-20 20:37:56 +00:00
enricobuehler 1c1fd7d9bc release: 0.31.1 — fold in #362, which makes the console's off switch work
ci / bun-nix (pull_request) Successful in 30s
ci / docs-drift (pull_request) Successful in 28s
ci / web (pull_request) Successful in 1m13s
ci / docs-site (pull_request) Successful in 1m12s
ci / rust-arm64 (pull_request) Successful in 1m21s
ci / rust (pull_request) Successful in 14m33s
30 commits since v0.31.0 (19 non-merge). #362 merged onto main seconds
after the release merge (#363), so the notes as tagged would have been
wrong in the one way that matters: they lead `## New` with the console's
"Controller-optimized UI" switch, and cbd3d028 is the commit that makes
that switch do anything at all.

trust::Settings::extra is #[serde(flatten)], so the android.* keys are
TOP-LEVEL keys of the settings document. ConsoleJson wrote and read them
nested under an "extra" object, which serde filed under the literal key
"extra" — so no console row ever found android.gamepad_ui_enabled. Every
Android-only row rode the same dead path in 0.31.0: low latency, phone
rumble/gyro, SC2 and DualSense capture, and the console-UI mode picker.
That is a user-facing fix in its own right and is now in `## Fixed`, in
`## TL;DR`, and in the Play notes — it is the most useful 500 characters
an Android user gets from this release.

Also folded in: c9a76287, the focus halo and panel_highlight taking the
card's corner plus their own outset (a rect grown by d only stays
concentric if its radius grows by d too).

NOT folded in — deliberately: cargo audit is red on main with
RUSTSEC-2026-0258 (h2 0.4.15, unbounded empty DATA frames, fixed in
0.4.16, transitive via hyper). It predates this cut — the same job failed
on 669a1bc0 and on the v0.31.0 tag commit — so it is not a regression
here. `cargo update -p h2 --precise 0.4.16` bumps h2 in eleven lock lines
but ALSO rewrites several windows-sys references downward (0.61.2 ->
0.59.0/0.52.0) on the pinned 1.96.0 toolchain. Re-resolving the graph for
the Windows build inside a version bump that cannot be compiled for
Windows on this host is the wrong trade; it wants its own commit and its
own CI. Recorded in the CHANGELOG's verification section.

Cargo.lock is untouched by this commit.

Gates re-run: cargo fmt --all --check clean; scripts/ci/check-docs-drift.sh
clean; cargo test -p punktfunk-core --lib 273 passed; the Play notes gate
run verbatim, 440/500 characters and unique; both openapi copies cmp
identical and stamped 0.31.1; notes voice scan clean.
2026-08-20 22:37:01 +02:00
enricobuehler 1c60e641b3 Merge pull request 'The console's own settings never left the console, and the layer under a card missed its corners' (#362) from worktree-android-console-extra-keys into main
ci / rust-arm64 (push) Successful in 2m1s
ci / docs-site (push) Successful in 1m10s
ci / bun-nix (push) Successful in 21s
ci / web (push) Successful in 2m19s
ci / docs-drift (push) Successful in 19s
android / android (push) Successful in 11m5s
arch / build-publish (push) Successful in 11m27s
deb / build-publish-host (push) Failing after 6m8s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 12s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 8s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 9s
deb / build-publish-gamescope (push) Successful in 3m51s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
deb / build-publish-client-arm64 (push) Successful in 1m56s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 15s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 12s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 12s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 14s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 46s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m16s
deb / build-publish (push) Successful in 9m24s
docker / builders-arm64cross (push) Successful in 10s
docker / deploy-docs (push) Successful in 34s
flatpak / build-publish (push) Successful in 4m55s
deb / smoke-install (push) Successful in 5m3s
ci / rust (push) Successful in 22m4s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m56s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 8m50s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 20m14s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 20m37s
2026-08-20 20:03:42 +00:00
enricobuehler eda4b7ebd2 Merge pull request '0.31.1 — the video data port, the pad games could not see, and the buttons Android guessed wrong' (#363) from worktree-release-0311-prep into main
audit / cargo-audit (push) Failing after 2m1s
apple / swift (push) Successful in 2m5s
android / android (push) Canceled after 3m20s
audit / bun-audit (sdk) (push) Successful in 32s
arch / build-publish (push) Canceled after 3m1s
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
ci / bun-nix (push) Canceled after 0s
ci / docs-drift (push) Canceled after 0s
audit / pnpm-audit (push) Successful in 21s
audit / docs-site-audit (push) Successful in 25s
deb / build-publish (push) Canceled after 0s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish-gamescope (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
deb / smoke-install (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Canceled after 0s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
flatpak / build-publish (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 0s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 2m16s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 47s
audit / bun-audit (plugin-kit) (push) Successful in 15s
audit / license-gate (push) Successful in 6m31s
audit / miri (push) Successful in 6m13s
audit / c-abi-asan (push) Successful in 7m41s
apple / distribute (push) Successful in 11m54s
audit / bun-audit (web) (push) Failing after 11m38s
windows-host / package (push) Successful in 14m11s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 38s
apple / screenshots (push) Successful in 11m12s
nix / flake (push) Successful in 26m2s
2026-08-20 20:00:14 +00:00
enricobuehler 730ac43169 release: 0.31.1 — version bump, notes, CHANGELOG, Play notes
ci / bun-nix (pull_request) Successful in 30s
ci / docs-drift (pull_request) Successful in 27s
apple / swift (pull_request) Failing after 1m40s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m39s
ci / docs-site (pull_request) Successful in 1m39s
ci / rust-arm64 (pull_request) Successful in 5m33s
android / android (pull_request) Successful in 7m19s
nix / flake (pull_request) Successful in 8m20s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 3m6s
ci / rust (pull_request) Successful in 25m12s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m47s
27 commits since v0.31.0 (17 non-merge). Cut from origin/main 2be444b3
(#361 merged).

THE NUMBER: a patch, on the user's call, and the version table does not
argue with it. Nothing versioned moved — WIRE_VERSION 2, C ABI 25 with
include/punktfunk_core.h differing from the v0.31.0 tag by one `#define`,
driver protocol 6 / min 3 (pf-driver-proto has no diff against the tag),
gamepad channel 3, plugin index schema 1, host event schema 1, gamescope
+pfhdr8 with no new patch files, SDK 0.1.5 untouched. No `!` commit and
no breaking change of any kind.

Two surfaces grow, both by pure addition, and neither forces the minor:
the management API gains PUT /library/provider/{provider}/running (#361,
already regenerated into both openapi copies there — this commit moves
only the stamp), and plugin-kit goes 0.4.3 -> 0.4.4 to export
ProviderClient.reportRunning from the package root. plugin-kit-v0.4.4 is
tagged AND published — it is the registry's `latest`; 0.4.2 is still
skipped there. A plugin that adopts neither is unaffected, and a 404 from
an older host means "this host tracks games by scanning".

The cycle is otherwise fix-shaped: fifteen fixes and two small features
— the console's own switch for turning itself off (which exists because
0.31.0 made the console the only interface Android shows with a pad
attached) and the provider running-report above.

The new control message (DeliveryReport, type byte 0x0B) is a `#define`,
not an ABI step — the same shape PipelineGap (0x0A) had at v0.30.0, and
it takes a free byte in its block rather than lengthening LossReport,
which is length-checked exactly.

Two behaviour changes are called out in the CHANGELOG rather than left
to be discovered: Windows `service install` now provisions a
program-scoped inbound UDP rule for the host exe (the black-picture
fix — no `localport=` rule can ever cover an ephemeral data port), and
the GameStream plane's default virtual-Xbox backend flips from the XUSB
companion to the UMDF HID pad, which is the correction of a default that
was only ever a module-name coincidence. Both revert with
PUNKTFUNK_XBOX_BACKEND=xusb / by removing the rule.

Also in this commit, because a cut is when it bites:
packaging/gamescope/PKGBUILD moves pfhdr7 -> pfhdr8. The banner has said
+pfhdr8 since patch 0010 and the host probes it for >= 8, but pacman
compares pkgver-pkgrel, read 3.16.25.pfhdr7-1 on both v0.30.0 and
v0.31.0, and offered Arch/SteamOS hosts no upgrade at all — so the
keyboard-layout fix never reached them. deb and rpm derive the version
from the binary banner and moved by themselves; Arch is the only channel
that hardcodes it. The v0.31.0 table flagged this as pre-existing.

api/openapi.json is RE-STAMPED here, not regenerated: punktfunk-host
does not build on macOS. #361 regenerated the document itself, with the
new route and its three schemas, on a runner where
openapi_document_is_complete_and_checked_in actually executes. This
commit moves one line (info.version, which utoipa fills from
CARGO_PKG_VERSION), and "0.31.0" appears nowhere else in either copy.

Gates: cargo fmt --all --check clean; cargo metadata --offline ok with
the Cargo.lock diff versions-only (36/36); cargo test -p punktfunk-core
--lib 273 passed; the C ABI harness passed reporting abi_version=25 —
it did NOT run on the v0.31.0 cut, so this is the first cut since ABI 25
where a C compiler has built the generated header;
scripts/ci/check-docs-drift.sh clean; the android.yml Play notes gate
run verbatim, 442/500 characters and unique; both openapi copies cmp
identical and stamped 0.31.1; notes voice scan clean.
2026-08-20 21:58:39 +02:00
25 changed files with 1274 additions and 215 deletions
+465
View File
@@ -12,6 +12,471 @@ with the version table of the release you are moving to, then read **Breaking ch
---
## v0.31.2
10 commits since v0.31.1 (6 non-merge), counted at the tip this was cut from.
**Nothing versioned moves, and this time nothing versioned even changes shape.** `WIRE_VERSION`
stays **2**, the C ABI stays **25**, and so do the driver protocol, the gamepad channel, the plugin
index schema and the host event schema. `include/punktfunk_core.h` is **byte-identical to the
v0.31.1 tag** — unlike the last two releases, which each added a `#define` — and `pf-driver-proto`
shows no diff either. No route is added or removed, no `#[repr(C)]` struct moves, and neither
`@punktfunk/host` (0.1.5) nor `@punktfunk/plugin-kit` (0.4.4) is re-cut. An embedder can take this
release without recompiling anything, and a packager has one thing to notice: the Windows firewall
rules below.
`api/openapi.json` changes in **documentation only** — two `description` strings on `HostInfo`, no
route, schema, field or type — plus the `info.version` stamp. That documentation change is
load-bearing, though, because it records a behaviour change: `local_ip` is now read per request.
The release is entirely fix-shaped. Three of the six non-merge commits are the same class of fault —
the host using the wrong local address — reached from three directions: the data socket's source
address (#367), the advertised address after a cold boot (#366), and the firewall rules that
admitted anyone to the ports those addresses point at (#368). The fourth is an Android regression
from v0.31.1 (#365); the remaining two are a refactor and a test in support of #366.
### Versions
| | v0.31.1 | v0.31.2 | Notes |
|---|---|---|---|
| Wire protocol | 2 | **2** | unchanged. No message added, removed or re-shaped; `DeliveryReport` (`0x0B`) from v0.31.1 is the most recent addition and is untouched |
| C ABI | 25 | **25** | unchanged. `include/punktfunk_core.h` has **no diff at all** against the v0.31.1 tag — not even a constant |
| Rust edition | 2024 | **2024** | unchanged |
| MSRV (`rust-version`) | 1.85 | **1.85** | unchanged |
| Workspace crate dirs | 27 | **27** | unchanged (39 `[workspace] members`, also unchanged) |
| Virtual-display driver protocol | 6 | **6** | unchanged (minimum accepted still 3); `pf-driver-proto` shows no diff against the v0.31.1 tag |
| Windows virtual-gamepad channel | 3 | **3** | unchanged; no file under the gamepad backends is touched by this release |
| Plugin index schema | 1 | **1** | unchanged |
| Host event schema | 1 | **1** | unchanged (`punktfunk-host/src/events.rs`) |
| `api/openapi.json` | 0.31.1 | **0.31.2** | **description-only**, plus the stamp (`info.version` is `CARGO_PKG_VERSION`). The two `HostInfo` strings that change are quoted under **`Host::local_ip`** below; no route, schema, required-field or type differs. Re-stamped here, not regenerated — `punktfunk-host` does not build on macOS; the document itself was regenerated in #366 on a runner where `openapi_document_is_complete_and_checked_in` executes. `api/` and `docs-site/public/` are byte-identical to each other |
| gamescope patch level (`+pfhdrN`) | 8 | **8** | unchanged; no new patch files, and `packaging/gamescope/PKGBUILD` still declares `pfhdr8` after the v0.31.1 correction |
| `@punktfunk/host` (SDK) | 0.1.5 | **0.1.5** | unchanged; nothing under `sdk/` moved |
| `@punktfunk/plugin-kit` | 0.4.4 | **0.4.4** | unchanged; nothing under `plugin-kit/` moved. 0.4.4 remains the registry's `latest` |
### ⚠ Breaking changes
**None.** No wire change, no ABI change, no driver-protocol change, no plugin-contract change, no
API-surface change. Every 0.31.x host, client, driver and plugin keeps interoperating in both
directions with no re-pairing and no rebuild.
Two **behaviour** changes that break no build but change what a machine does:
- **Windows `service install` now scopes every inbound rule to a program.** The five fixed-port
rules gain `program=<exe>` while keeping their `localport=`. If you provision firewall rules
yourself rather than letting `service install` do it, the equivalent is `program=` on each; if you
do nothing, `service install` re-runs on every upgrade and rewrites them for you. **Externally
visible:** 5353 is punktfunk's alone now, so anything else on the machine that was reachable on
mDNS through punktfunk's any-program rule needs its own rule.
- **`HostInfo.local_ip` is no longer static for the life of the process.** It was a field
snapshotted at `Host::detect()`; it is a method that re-reads on every request. A consumer that
cached it once at startup was caching a value that could be `127.0.0.1` forever (see below) and
should poll instead. The two `description` strings in `api/openapi.json` say so.
### Windows: the fixed-port firewall rules admitted any program on the machine
`service install` added `dir=in action=allow` rules carrying only `localport=`. A rule of that shape
admits **any process** that binds the port — GameStream (47984/47989/47998-48010/48010), the native
plane (9777), mgmt (47990), mDNS (5353) and the console pair (47992/47993). Binding a high port on
Windows requires no elevation, so an unprivileged program could take any of them and become
LAN-reachable simply by binding first, and **silently**: our rule is precisely what suppresses the
"Allow this app to communicate on…" prompt that would otherwise be the only way in.
Every rule is now scoped to the executable that actually listens on it, keeping the ports — program
**and** port is strictly tighter than either alone. The host rules name the host exe (resolved once
via `current_exe()` and shared with the data-plane rule, which already worked this way and is the
pattern the rest now follow); the console rules name the bundled `<app>/bun/bun.exe` the supervisor
spawns. `fw_add_rule_args` is the new single constructor for the whole shape.
The old argument for leaving them unscoped — "an install whose recorded exe path later moves still
has its fixed ports open" — does not hold: `service install` re-runs the whole remove-then-add on
every upgrade, so the path is refreshed rather than left stale.
Fallbacks are deliberate and **asymmetric**. A fixed-port rule whose program cannot be resolved
falls back to the old any-program form, because a looser rule still streams and no rule is a black
screen. The data-plane rule instead **skips**: it has no `localport=` to fall back to, so a
program-less version of it would not be a looser rule but an open host. The installer prints the
5353 note only when the scoping actually happened — claiming it while the rules are still wide open
would be worse than saying nothing.
Reported by a user on 2026-08-21, immediately after the source-IP fix below cleared their black
screen.
### The data socket binds the address the control plane arrived on
`bind_data_socket` bound `0.0.0.0:0`, so the kernel chose the video source address from the routing
table, **independently of the address the client's control connection actually arrived on**. The
client's data socket is `connect`ed to the host IP it dialled, so its kernel drops every datagram
from any other source — in the kernel, before userspace, where nothing counts it.
On a host with two live paths to the client (Ethernet and Wi-Fi both up on the same LAN; a
VPN/overlay adapter claiming the route) that is a permanent black screen with every gauge green: the
hole-punch still arrives so the host logs `punched=true`, `loss_ppm` stays 0 because there are no
packets to see gaps in, and QUIC — which quinn pins to the right local address — carries control,
audio and input perfectly. `from_socket_punch` already documented the mirror of this assumption for
the *client's* source IP; the host side was never checked.
The socket now binds `Connection::local_ip()` (unmapping an IPv4-mapped v6 address so it can still
`connect` to a v4 peer), falling back to the wildcard **loudly** when that is unavailable.
Two diagnostics changed with it, because the field session's log could not answer the question:
- the `data plane bound` line carries the socket's post-`connect` `local=` address — the source the
kernel will actually stamp — and WARNs when it differs from the address the control plane arrived
on.
- the black-screen ERROR no longer asserts "This is a PATH problem, not decode" and no longer names
`punched=false` as *the* fingerprint. It fired with `punched=true` in the field, contradicting its
own advice and sending an investigation to the firewall. It now branches on what the bring-up line
says, and admits its counter is incremented after decrypt and replay checks, so a session whose
every datagram failed to open reports the same zero as one that received nothing.
### `Host::local_ip` re-reads, and mDNS adverts follow it
`Host::detect()` snapshotted the LAN address once at process start and every consumer read that
frozen field forever. On a cold boot the host wins the race against the network — the Windows
service is registered `AutoStart` with no dependencies — so `primary_local_ip()`'s route probe to
8.8.8.8 failed with `ENETUNREACH` and the loopback fallback stuck for the life of the process.
Restarting the host re-ran `detect()` on a live network, which is the workaround users found.
Four surfaces broke together off that one field: both mDNS adverts (`_punktfunk._udp`,
`_nvstream._tcp`) published `127.0.0.1` as their A record; `session_url_xml()` handed Moonlight
`rtsp://127.0.0.1:48010` after `/launch`; `wol::wake_macs()` found no interface for loopback and
dropped the `mac` TXT record, silently disabling Wake-on-LAN; and `HostInfo.local_ip` reported
loopback to the web console.
Fixed at the choke point rather than per caller:
- `primary_local_ip()` never returns loopback or the unspecified address. When the route probe
fails it falls back to `first_lan_ipv4` — the first non-loopback interface address, which exists
as soon as the NIC is configured even if the default route is not installed yet, the common shape
of the boot race. It is split out so a test can assert the one thing that matters: it never hands
back the loopback `get_if_addrs` also reports.
- `Host::local_ip` becomes a method that re-reads instead of a field that freezes. A `connect(2)` on
an unconnected UDP socket sends no packets and costs nothing beside the HTTP response it is
serialized into.
- mDNS records are **pushed, not polled**: a live advert re-registers when the routed address
changes (`discovery::advertise_live`, shared by both service types). It polls the routed address
rather than subscribing to the daemon's `IpAdd` events because the boot race usually resolves
without one — the NIC often has its address before we register, and only the route lands late.
This also covers the sibling cases that were never reported: DHCP handing out a different lease, and
a host moved between Wi-Fi and Ethernet.
The re-announce loop's stop signal is now the `mpsc` channel it already sleeps on, rather than an
`Arc<AtomicBool>` plus a `Drop` impl: the `Advert` dropping its sender wakes the thread immediately
instead of leaving it to notice a flag up to `IP_RECHECK` (10 s) later.
### Android: the button correction is gated on named triggers, not declared keys
v0.31.1 corrected button positions for pads Android has no key layout for, and gated it on
`hasKeys(BUTTON_C, BUTTON_Z)`. That gate answers for what a device **declares**, not what it
reports: `hid-input` allocates `BTN_A + n` straight through for every button in the descriptor, so
`BTN_C` (`0x132`) and `BTN_Z` (`0x135`) are set on **any** pad declaring six or more buttons —
including a standard-layout pad that never presses either. The signal was therefore identical on the
pad that needs correcting and the pad that does not, and no tightening of it could have separated
them.
Two field reports on 2026-08-21 (a GameSir G8+ and an "Xbox Wireless Controller" over Bluetooth) had
X answering Y, Y answering LB, and both shoulders answering menu buttons — exactly what
`GENERIC_XBOX` does to scancodes `0x133`/`0x134`/`0x136`/`0x137`. It is the same pad model on both
sides of the bug: an Elite Series 2 needed the correction on a Fire TV, and another was broken by it
here.
What separates them is the **axes**. A HID gamepad describes its triggers either as the
Accelerator/Brake usages — which become `ABS_GAS`/`ABS_BRAKE`, names Android has words for — or as
two generic axes on `ABS_Z`/`ABS_RZ`, which it does not. A descriptor well-formed enough to name its
triggers puts its buttons at the standard positions too. It is also the firmware line on the pad in
the report: an Xbox Wireless Controller reports GAS/BRAKE after its firmware update and Z/Rz before
it, and only the older one was ever wrong.
`padButtons` now takes `namedTriggers` and answers `NATIVE` whenever it is set — no correction of
any kind, on buttons or axes, for a pad Android already reads. `padMap` computed that fact one line
below and only ever spent it on the axes; it now decides both. `hasKeys` stays for the narrower
question it can answer — *which* straight-through order, once the axes have established there is
one — where a false positive costs nothing. This is the same discriminator Moonlight uses
(`ControllerHandler`, `gasRange == null` beside the `"Xbox Wireless Controller"` name); v0.31.1
cited its tables and then replaced its discriminator, which is where this came in.
`PadButtonsTest` is at 16 cases, 3 new: the gate holds for every vendor/declaration combination, the
four reported buttons stay themselves, and the report-order choice past the gate is unchanged.
**Not fixed here:** a DualSense report filed alongside these, with Triangle dead in both the client
UI and the stream. A button reaching neither is one `buttonBit` maps to nothing, which no branch of
the correction produces for Triangle.
---
## v0.31.1
30 commits since v0.31.0 (19 non-merge), counted at the tip this was cut from.
**No versioned surface moves.** `WIRE_VERSION` stays **2**, the C ABI stays **25**, and so do the
driver protocol, the gamepad channel, the plugin index schema and the host event schema. No C
function changed its signature, no `#[repr(C)]` struct grew a field, and `include/punktfunk_core.h`
gains exactly one line: a `#define` for a new control-message type byte. An embedder rebuilds
against the new header and is done; a packager has one thing to notice (the Windows firewall rule
below) and one thing to be glad of (the Arch compositor package finally declares the level it
builds).
Two surfaces grow, both by pure addition: the **management API** gains
`PUT /library/provider/{provider}/running` and its three schemas, and **`@punktfunk/plugin-kit`
goes 0.4.3 → 0.4.4** to export the client call for it. A plugin that adopts neither is unaffected,
and an older host answers the route with a 404 that means "this host tracks games by scanning".
One control message is **added** to the wire — `DeliveryReport`, type byte **`0x0B`** — which is a
`#define`, not an ABI step, exactly as `PipelineGap` (`0x0A`) was at v0.30.0. It takes a free byte
in its block rather than lengthening an existing message, deliberately: see below.
Two behaviour changes are worth reading before you package or embed this release: on Windows,
`service install` now provisions a **program-scoped inbound UDP rule** for the host executable, and
the **GameStream plane's default virtual-Xbox backend flips from the XUSB companion to the UMDF HID
pad**, matching what the native plane has done since 2026-08-09.
### Versions
| | v0.31.0 | v0.31.1 | Notes |
|---|---|---|---|
| Wire protocol | 2 | **2** | unchanged. One additive control message, `DeliveryReport` (`0x0B`), which an older peer does not parse and does not need — see **The wire** below |
| C ABI | 25 | **25** | unchanged. `include/punktfunk_core.h` differs from the v0.31.0 tag by one `#define` (`PUNKTFUNK_MSG_DELIVERY_REPORT = 11`, under `PUNKTFUNK_FEATURE_QUIC`), which is a constant, not a declaration. Rust-only addition in `punktfunk-core`: `client::NO_VIDEO_RETRY` is now public beside `client::FLUSH_COOLDOWN` |
| Rust edition | 2024 | **2024** | unchanged |
| MSRV (`rust-version`) | 1.85 | **1.85** | unchanged |
| Workspace crate dirs | 27 | **27** | unchanged (39 `[workspace] members`, also unchanged) |
| Virtual-display driver protocol | 6 | **6** | unchanged (minimum accepted still 3); `pf-driver-proto` shows no diff against the v0.31.0 tag |
| Windows virtual-gamepad channel | 3 | **3** | unchanged. What changed is which *backend* the GameStream plane picks, not the channel — see **Windows: the GameStream plane builds the pad games can see** |
| Plugin index schema | 1 | **1** | unchanged |
| Host event schema | 1 | **1** | unchanged (`punktfunk-host/src/events.rs`) |
| `api/openapi.json` | 0.31.0 | **0.31.1** | one route **added**`PUT /library/provider/{provider}/running` plus its three schemas — and the stamp moved with the crate (`info.version` is `CARGO_PKG_VERSION`). Regenerated in #361 and re-stamped here; nothing else in the document differs. `api/` and `docs-site/public/` are byte-identical to each other |
| gamescope patch level (`+pfhdrN`) | 8 | **8** | unchanged; no new patch files. ⚠ **`packaging/gamescope/PKGBUILD` is fixed here**: it declared `pfhdr7` while patch 0010 stamps `+pfhdr8` into the banner, so pacman saw no upgrade at all — see below |
| `@punktfunk/host` (SDK) | 0.1.5 | **0.1.5** | unchanged; nothing under `sdk/` moved |
| `@punktfunk/plugin-kit` | 0.4.3 | **0.4.4** | cut, for `ProviderClient.reportRunning` and its two types: they were reachable only through the deep `./reconcile.js` path, because `index.ts` re-exports an explicit list rather than a star, so no plugin could import them from the package root. Tagged `plugin-kit-v0.4.4` and **published** — the registry's `latest` (0.4.2 is still skipped there, as it has been since v0.30.0). The playnite plugin deliberately does *not* depend on it, calling the route through the untyped host seam so it was never gated on this publish |
### ⚠ Breaking changes
**None.** No wire change, no ABI change, no driver-protocol change, no plugin-contract change. Every
0.31.0 host, client, driver and plugin keeps interoperating in both directions with no re-pairing.
Two **behaviour** changes that break no build but change what a machine does:
- **Windows `service install` adds a second firewall rule.** `Punktfunk UDP (data plane)`
`dir=in action=allow protocol=UDP program=<host exe>`, on the same profile set the port rules use.
`service uninstall` deletes it by name. If you provision firewall rules yourself instead of
letting `service install` do it, you need the equivalent, or your hosts keep the black-picture
failure below. Program-scoped rather than port-scoped by design: the data plane binds `0.0.0.0:0`,
and a pinned port inside 47998-48010 would collide with Sunshine/Apollo.
- **`PUNKTFUNK_XBOX_BACKEND` now governs both planes on Windows, and the GameStream plane's default
moves to the HID pad.** `PUNKTFUNK_XBOX_BACKEND=xusb` reverts both planes together; it previously
reverted only the native one, because `windows_xbox_hid` was `pub(super)` and unreachable from
`gamestream/control.rs`. It is `pub(crate)` now, with one definition and one name.
### A provider plugin can report which of its titles are running
The host derives liveness by **scanning**, which needs something recognizable on disk. A
Playnite-launched emulated game, a manually added one, or a library plugin that records no install
directory has none — and its launch is a `playnite://` hand-off, so the host holds no process
either. The lease went `Untracked`: the exit was never noticed, `session_on_game_exit` could not
fire, and `POST /game/end` had nothing to aim at.
**`PUT /library/provider/{provider}/running`** takes a provider's *complete* running set (with the
pid where it knows one) — declarative and idempotent like the reconcile beside it, so a missed event
or a plugin restart self-corrects rather than drifting. `crate::runstate` holds it and **expires it
after 90 s unless restated**, which is what makes it safe for a live provider to hold a session open
for a game the host cannot see: a plugin that dies stops counting and the host falls back to
scanning. The route is the plugin lane's, like the reconcile, and carries **no new authority** — the
host maps `external_id` through the catalog, so a provider can only speak about entries it
published. An unknown id is *counted, not refused*, because a report legitimately races its own
reconcile and 400-ing the batch would discard the liveness of every other running title.
**`LeaseKind::Reported`** is the lease that follows. `open` reaches it when the spec is empty and a
provider speaks for the id, and — load-bearing on Windows, where every launch is a hand-off by
construction — the three shim reclassification paths now fall back to it where they fell to
`Untracked`. Phase 1 takes "running" as the game appearing; phase 2 takes "stopped" as the exit.
Unlike `procscan::running_hint`, which may only ever *delay* an exit (Steam's registry flag survives
an unclean one), a fresh report is decisive in both directions. A reported pid joins the termination
ladders on the same terms as a spawned one: re-resolved and start-time-pinned at the moment of use.
Client side, `ProviderClient.reportRunning` is exported from the plugin-kit root in 0.4.4 (see the
table). A **404 from an older host means "this host tracks games by scanning"** — it is not an error
a plugin should retry.
### The wire: `DeliveryReport` (`0x0B`)
`LossReport` carries `loss_ppm`, which is a ratio over the packets that **arrived** — so a flawless
link and a link delivering nothing both report `0`. A host reading total silence as perfection
decayed adaptive FEC to its floor and logged confident wording about the client's network.
Clients now also send `DeliveryReport`, carrying the session's received-packet count. It is a **new
type byte, not a field appended to `LossReport`**: that message is length-checked exactly, so
lengthening it would make every shipped host reject the loss reports its FEC runs on. Send policy is
deliberately sparse — every window while the count is zero, once when the first packets land, then
never — because an older host warns per unknown message type and must not be flooded across a good
session.
`client::NO_VIDEO_RETRY` (the client got nothing) and `client::FLUSH_COOLDOWN` (the client is
drowning) were both 2000 ms, so the host's cadence classifier could not tell two opposite faults
apart and named the wrong one out loud. `NO_VIDEO_RETRY` moves into `punktfunk-core` beside
`FLUSH_COOLDOWN` at **2600 ms**, and both sides now compare against the shared constant rather than
against a local copy.
### Windows: the data plane was never open, on any host
The firewall rules `service install` writes are `localport=`-scoped (47998-48010, 9777, 5353), and
the media data plane binds an **ephemeral** port per session. No such rule can cover it, so Windows
Firewall dropped the client's hole-punch on **every session on every Windows host**`punched=false`
on the "data plane bound" line, in all six sessions across two field logs, including sessions that
appeared to work. Video then fell back to blind-sending at the address the client *reported*; where
the path needed the flow opened client-first, the control plane stayed healthy and the picture never
arrived. One field host sent 1,919 frames into a black screen while blaming the client.
Diagnosis changed with it: it now leads with the delivery count (zero is an **error** naming the data
plane; a confirmed count keeps the old confident wording; a client that cannot answer gets a warning
that says so), and a punch that never arrives is its own warning rather than a debug field on an info
line.
### Windows: the GameStream plane builds the pad games can see
There are two virtual Xbox backends on Windows and they are not interchangeable to a game. The XUSB
companion registers only `GUID_DEVINTERFACE_XUSB` and exposes no HID collection (`pf_xusb.inx`:
"a non-HID UMDF2 driver", `Class = System`), so Steam's hidapi enumeration, SDL, RawInput,
DirectInput, `joy.cpl` and WGI/GameInput cannot see it at all — only classic `XInputGetState` can.
The native plane moved to the real HID pad as its default in `bd5735b8` for exactly that reason.
`gamestream/control.rs` had bound `crate::inject::gamepad` since the first gamepad commit, when that
name meant uinput and Windows had no second backend; Windows later gave the same name the XUSB
companion, so this plane inherited it by module-name coincidence rather than by decision. Every
Moonlight-compatible session since has presented a pad most games cannot enumerate. A `SessionPads`
enum is now the one place this plane picks a backend, reading the same knob the native plane reads.
The HID pad's rich-feedback plane is dropped rather than plumbed: an Xbox pad has no lightbar or
adaptive triggers, and GameStream's rumble message (`0x010B`) carries the two handle motors only.
### Android: buttons resolved from the scancode
Android names a pad's buttons through a **key layout file** matched on VID/PID; a pad with no
matching file falls back to AOSP's `Generic.kl`, which assigns keycodes by **scancode position**
(`0x130``BUTTON_A`, `0x131``BUTTON_B`, …). A HID gamepad with no kernel driver numbers its buttons
`1..n` in its own report order, so every keycode past the first divergence is somebody else's button.
AOSP ships no layout for the Elite Series 2 over Bluetooth (`045e:0b05`) on any version, and the
DualSense's (`054c:0ce6`) postdates Fire OS and requires `CONFIG_HID_PLAYSTATION`, which a Fire TV
kernel has not.
`Gamepad.padKeyCode(event)` is a drop-in for `event.keyCode` and **every** pad reader now goes
through it — the streaming branch, the Skia console shell's probe, the older Compose navigation, and
the Controllers tester. Two guards keep it off pads that already work: the correction applies only
where the delivered keycode is what `Generic.kl` would have said, and which report order to read is
decided from what the device *declares* (a pad numbering straight through claims `BUTTON_C` and
`BUTTON_Z`, keycodes no real controller has a button for) rather than from a model table. Axes get
the same treatment, with trigger rest position measured from the device's own range instead of
assumed. The Xbox Bluetooth product ids (One S, Elite Series 2 and its Core) join the identity table.
Also here: `pads()` filters on `looksLikeController` (the source claim **and** hardware behind it)
rather than on `isPad`, which kept the console UI pinned on for any device merely claiming the
gamepad source class; and the `ASurfaceControl` layer's destination rect is now read per-present from
a packed atomic on the session handle (new JNI symbol `nativeVideoSurfaceSize`, fed from every
`surfaceChanged`) rather than captured once at `surfaceCreated`, which is why the picture sat at the
origin once the bars and cutout grew the view.
### gamescope and the takeover
- **`packaging/gamescope/PKGBUILD` moves `pfhdr7``pfhdr8`.** The banner has said `+pfhdr8` since
patch 0010 (the seat's stub keyboard carrying the compiled `XKB_DEFAULT_*` keymap), and the host
probes the banner for `>= 8` on the keymap path — but pacman compares `pkgver-pkgrel`, read
`3.16.25.pfhdr7-1` on both v0.30.0 and v0.31.0, and **offered no upgrade at all**. deb and rpm
derive their version from the binary banner and moved by themselves; Arch is the only channel that
hardcodes it. This is the mismatch the v0.31.0 table flagged as pre-existing.
- **The in-stream session-select gate is armed again.** v0.31.0's takeover stopped stopping the
display manager and started idling the autologin session (`c2f5e91b`), which also deleted the two
lines the old path carried (`record_session_select_baseline()`, `STOPPED_DM = Some(dm)`);
`38a0f54b` then removed every remaining writer, leaving `honor_session_select_switch` unreachable.
Bazzite/SteamOS never noticed — their `os-session-select` writes no sentinel and
`is_steam_htpc_platform()` defaults the mid-stream watcher on. `ID=nobara` matches no HTPC default
and its ChimeraOS-layout `os-session-select` **does** write the sentinel, so on Nobara a mid-stream
"Switch to Desktop" went entirely unhandled. `takeover_idled()` now reads `IDLE_DROPIN_ARMED`, the
idle drop-in re-baselines the sentinel, and `STOPPED_DM` is documented as adoption-only state for a
takeover stranded by a pre-0.31.0 host. Both hand-back paths also restore the box's own Game Mode
unit, which neither did — a mid-stream switch is not a disconnect, so the disconnect sweep never
reached the `ExecStart=/usr/bin/sleep infinity` drop-in.
- **Nix shipped a wrapper with no target.** nixpkgs wraps this package: the real ELF is
`bin/.gamescope-wrapped` and `bin/gamescope` is a makeWrapper launcher. The prune
(`find $out/bin -mindepth 1 ! -name gamescope -delete`) deleted the compositor and kept the
launcher — measured at 16 KB. That single line explains the empty `--version` output and the
"missing `+pfhdr` marker", both of which had been attributed to the build sandbox and to upstream.
The prune keeps the target now and the guard asserts on the **wrapped ELF**. Separately,
`packaging/nix/gamescope.nix` now pins `src` to `5fb8dce4` like every other channel — it was the
only one patching whatever version nixpkgs happened to carry, which broke `host.gamescopeHdr`
(default true) builds outright when nixpkgs shipped 3.16.24.
### Everything else an integrator might notice
- **`pf-console-ui`:** `ConsoleOptions.fallback_ui` (new, threaded to `Ctx` beside `deck`) gates the
Android-only "Controller-optimized UI" row, written through `extra` under
`android.gamepad_ui_enabled`; it is true only for the Android touch shell. Down on the carousel
opens Settings (`▼` is a new hint glyph — the `▲` triangle inverted, not a second draw routine),
and the host options menu gains a Library row on the same terms `Y` offers it (saved **and**
paired), replacing the menu rather than stacking on it. Both exist because a TV remote emits only
Move/Confirm/Back.
- **`scripts/ci/docs-undocumented-env-baseline.txt`** gains `PUNKTFUNK_MSG_DELIVERY_REPORT`.
`check-docs-drift.sh` scans for `PUNKTFUNK_*` identifiers and cannot tell an operator knob from a
cbindgen-exported `#define`; every other `PUNKTFUNK_MSG_*` is already baselined beside it.
- **`clients/probe`** reads the new delivery counter.
- **`trust::Settings::extra` is `#[serde(flatten)]`**, so `android.*` keys are **top-level** keys of
the settings document, beside `width` and `codec`. `ConsoleJson` wrote and read them nested under
an `"extra"` object, which serde stored under the literal key `"extra"` — so no console row ever
found `android.gamepad_ui_enabled`, every Android-only row (low latency, phone rumble/gyro, SC2 and
DualSense capture, the console-UI mode picker) read its own default, and the value the console
saved came back to Kotlin unchanged, so `applySettings` raised no callback. Fixed in #362, which is
what makes the `feat` above work at all. A store written by the nesting build carries the dead
wrapper and drops it on the next write. The new test pins the shape from **both sides**: a
round-trip alone could not catch this, because both halves agreed on the same wrong nesting.
- **`pf-console-ui` focus halo / `panel_highlight` radii.** A rounded rect grown by `d` keeps its
corners concentric with the original only if its radius grows by `d` too; both helpers kept the
card's own radius, so the halo read as a squared-off outline at the four corners. `drop_shadow`
only offsets and was already right; the collections plate uses `RRect::with_outset`, which adjusts
radii itself. Every card path goes through the two fixed helpers.
### Verification status
Gates run on the release tree (this MacBook, rustc/rustfmt per `rust-toolchain.toml`):
`cargo fmt --all --check` clean; `cargo metadata --offline` ok with the `Cargo.lock` diff
versions-only (36/36 lines); `cargo test -p punktfunk-core --lib` **273 passed**; the C ABI harness
(`tests/c_abi.rs`) **passed**, reporting `abi_version=25` and four frames round-tripped byte-exact
through lossy loopback — it did **not** run on the v0.31.0 cut, so this is the first cut since ABI 25
where a C compiler has actually built the generated header; `scripts/ci/check-docs-drift.sh` clean;
the android.yml Play notes gate run verbatim — 442/500 characters and not byte-identical to any prior
release's; both openapi copies `cmp` identical, both stamped 0.31.1; notes voice scan clean.
**`api/openapi.json` was re-stamped here, not regenerated.** The document itself was regenerated
in #361 (with the new route and its three schemas) on a runner where
`openapi_document_is_complete_and_checked_in` actually executes; this commit moves only
`info.version`, which utoipa fills from `CARGO_PKG_VERSION`. `punktfunk-host` does not build on
macOS, so that test could not be re-run here — but `0.31.0` appears nowhere else in either copy, so
regeneration would produce this byte-for-byte. If it ever fails on this commit, regenerate with
`cargo run -p punktfunk-host -- openapi > api/openapi.json` and `cp` to `docs-site/public/`.
**Verified by reading only** — compiled nowhere available to the cutting host: everything under
`crates/punktfunk-host` (Windows and Linux arms alike), `packaging/nix/gamescope.nix`, and the
Android/Kotlin half. That includes `crate::runstate` and the new route; its own tests turned up a
collision on their first run in an environment that executes them (all three shared the provider id
`playnite` and cleared the process-global table between cases, so parallel scheduling flipped their
answers) — fixed in #361 by giving each test ids only it uses and retiring the blunt `reset()`. The Windows GameStream pad change was checked on `.133` when it landed
(`cargo check` and `cargo clippy -p punktfunk-host -- -D warnings` clean, both compiling arms), and
`windows-host.yml` has **no `pull_request` trigger**, so a PR will not re-check that arm.
**`audit.yml` is red on main and this release does not fix it.** `cargo audit` reports
**RUSTSEC-2026-0258** (`h2` 0.4.15, "unbounded empty DATA frames", published 2026-08-17, fixed in
0.4.16); `h2` is transitive through `hyper`. It **predates this cut** — the same job failed on
`669a1bc0` and on the v0.31.0 tag commit — so it is not a regression here, and it was deliberately
**not** bundled into the release commit: `cargo update -p h2 --precise 0.4.16` bumps h2 in eleven
lock lines but also rewrites several `windows-sys` references downward (0.61.2 → 0.59.0/0.52.0) on
the pinned 1.96.0 toolchain, and re-resolving the graph for the Windows build is not a change to
make inside a version bump that cannot be compiled for Windows on the cutting host. It wants its own
commit and its own CI.
**Not confirmed on glass:** the Android scancode remap (the reporter's Fire TV Stick 4K Max is the
test that settles it), the Windows data-plane firewall rule in a field session, and the
Moonlight-compatible HID pad — the log line to look for there is
`virtual Xbox pad created (Windows UMDF HID)` where it used to say
`virtual Xbox 360 created (Windows XUSB companion)`.
---
## v0.31.0
170 commits since v0.30.0 (113 non-merge), counted at the tip this was cut from.
Generated
+36 -36
View File
@@ -1090,7 +1090,7 @@ dependencies = [
[[package]]
name = "cursor-probe"
version = "0.31.0"
version = "0.31.2"
dependencies = [
"anyhow",
"pf-capture",
@@ -1222,7 +1222,7 @@ dependencies = [
[[package]]
name = "display-disturb"
version = "0.31.0"
version = "0.31.2"
dependencies = [
"pf-win-display",
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
@@ -2343,7 +2343,7 @@ dependencies = [
[[package]]
name = "latency-probe"
version = "0.31.0"
version = "0.31.2"
[[package]]
name = "lazy_static"
@@ -2446,7 +2446,7 @@ dependencies = [
[[package]]
name = "libvpl-sys"
version = "0.31.0"
version = "0.31.2"
dependencies = [
"bindgen",
"cmake",
@@ -2475,7 +2475,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "loss-harness"
version = "0.31.0"
version = "0.31.2"
dependencies = [
"punktfunk-core",
]
@@ -2967,7 +2967,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pf-bitstream"
version = "0.31.0"
version = "0.31.2"
dependencies = [
"cros-codecs",
"tracing",
@@ -2975,7 +2975,7 @@ dependencies = [
[[package]]
name = "pf-capture"
version = "0.31.0"
version = "0.31.2"
dependencies = [
"anyhow",
"ashpd",
@@ -2996,7 +2996,7 @@ dependencies = [
[[package]]
name = "pf-client-core"
version = "0.31.0"
version = "0.31.2"
dependencies = [
"anyhow",
"ash",
@@ -3032,7 +3032,7 @@ dependencies = [
[[package]]
name = "pf-clipboard"
version = "0.31.0"
version = "0.31.2"
dependencies = [
"anyhow",
"ashpd",
@@ -3050,7 +3050,7 @@ dependencies = [
[[package]]
name = "pf-console-ui"
version = "0.31.0"
version = "0.31.2"
dependencies = [
"anyhow",
"ash",
@@ -3073,7 +3073,7 @@ dependencies = [
[[package]]
name = "pf-dxvadec"
version = "0.31.0"
version = "0.31.2"
dependencies = [
"cros-codecs",
"pf-bitstream",
@@ -3083,7 +3083,7 @@ dependencies = [
[[package]]
name = "pf-encode"
version = "0.31.0"
version = "0.31.2"
dependencies = [
"anyhow",
"ash",
@@ -3109,7 +3109,7 @@ dependencies = [
[[package]]
name = "pf-frame"
version = "0.31.0"
version = "0.31.2"
dependencies = [
"anyhow",
"libc",
@@ -3122,7 +3122,7 @@ dependencies = [
[[package]]
name = "pf-gpu"
version = "0.31.0"
version = "0.31.2"
dependencies = [
"anyhow",
"pf-host-config",
@@ -3136,11 +3136,11 @@ dependencies = [
[[package]]
name = "pf-host-config"
version = "0.31.0"
version = "0.31.2"
[[package]]
name = "pf-inject"
version = "0.31.0"
version = "0.31.2"
dependencies = [
"anyhow",
"ashpd",
@@ -3169,14 +3169,14 @@ dependencies = [
[[package]]
name = "pf-paths"
version = "0.31.0"
version = "0.31.2"
dependencies = [
"tracing",
]
[[package]]
name = "pf-presenter"
version = "0.31.0"
version = "0.31.2"
dependencies = [
"anyhow",
"ash",
@@ -3191,7 +3191,7 @@ dependencies = [
[[package]]
name = "pf-update"
version = "0.31.0"
version = "0.31.2"
dependencies = [
"serde",
"serde_json",
@@ -3199,7 +3199,7 @@ dependencies = [
[[package]]
name = "pf-update-check"
version = "0.31.0"
version = "0.31.2"
dependencies = [
"anyhow",
"aws-lc-rs",
@@ -3211,7 +3211,7 @@ dependencies = [
[[package]]
name = "pf-vaadec"
version = "0.31.0"
version = "0.31.2"
dependencies = [
"cros-codecs",
"pf-bitstream",
@@ -3220,7 +3220,7 @@ dependencies = [
[[package]]
name = "pf-vdisplay"
version = "0.31.0"
version = "0.31.2"
dependencies = [
"anyhow",
"ashpd",
@@ -3253,7 +3253,7 @@ dependencies = [
[[package]]
name = "pf-vkdecode"
version = "0.31.0"
version = "0.31.2"
dependencies = [
"ash",
"cros-codecs",
@@ -3264,7 +3264,7 @@ dependencies = [
[[package]]
name = "pf-win-display"
version = "0.31.0"
version = "0.31.2"
dependencies = [
"pf-paths",
"punktfunk-core",
@@ -3275,7 +3275,7 @@ dependencies = [
[[package]]
name = "pf-zerocopy"
version = "0.31.0"
version = "0.31.2"
dependencies = [
"anyhow",
"ash",
@@ -3487,7 +3487,7 @@ dependencies = [
[[package]]
name = "punktfunk-cli"
version = "0.31.0"
version = "0.31.2"
dependencies = [
"pf-client-core",
"punktfunk-core",
@@ -3497,7 +3497,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-android"
version = "0.31.0"
version = "0.31.2"
dependencies = [
"android_logger",
"anyhow",
@@ -3521,7 +3521,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-linux"
version = "0.31.0"
version = "0.31.2"
dependencies = [
"anyhow",
"async-channel",
@@ -3538,7 +3538,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-session"
version = "0.31.0"
version = "0.31.2"
dependencies = [
"log",
"pf-client-core",
@@ -3554,7 +3554,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-windows"
version = "0.31.0"
version = "0.31.2"
dependencies = [
"async-channel",
"mdns-sd",
@@ -3572,7 +3572,7 @@ dependencies = [
[[package]]
name = "punktfunk-core"
version = "0.31.0"
version = "0.31.2"
dependencies = [
"aes-gcm",
"cbindgen",
@@ -3605,7 +3605,7 @@ dependencies = [
[[package]]
name = "punktfunk-encode-worker"
version = "0.31.0"
version = "0.31.2"
dependencies = [
"pf-encode",
"tracing",
@@ -3614,7 +3614,7 @@ dependencies = [
[[package]]
name = "punktfunk-host"
version = "0.31.0"
version = "0.31.2"
dependencies = [
"aes",
"aes-gcm",
@@ -3684,7 +3684,7 @@ dependencies = [
[[package]]
name = "punktfunk-probe"
version = "0.31.0"
version = "0.31.2"
dependencies = [
"anyhow",
"mdns-sd",
@@ -3698,7 +3698,7 @@ dependencies = [
[[package]]
name = "punktfunk-tray"
version = "0.31.0"
version = "0.31.2"
dependencies = [
"anyhow",
"ksni",
@@ -3722,7 +3722,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
[[package]]
name = "pyrowave-sys"
version = "0.31.0"
version = "0.31.2"
dependencies = [
"bindgen",
"cmake",
+1 -1
View File
@@ -65,7 +65,7 @@ exclude = [
ndk = { path = "clients/android/native/vendor/ndk" }
[workspace.package]
version = "0.31.0"
version = "0.31.2"
edition = "2024"
rust-version = "1.85"
license = "MIT OR Apache-2.0"
+3 -3
View File
@@ -10,7 +10,7 @@
"name": "MIT OR Apache-2.0",
"identifier": "MIT OR Apache-2.0"
},
"version": "0.31.0"
"version": "0.31.2"
},
"paths": {
"/api/v1/client-logs": {
@@ -6688,7 +6688,7 @@
},
"HostInfo": {
"type": "object",
"description": "Host identity and advertised capabilities (static for the life of the process).",
"description": "Host identity and advertised capabilities (static for the life of the process, except\n`local_ip`).",
"required": [
"hostname",
"uniqueid",
@@ -6734,7 +6734,7 @@
},
"local_ip": {
"type": "string",
"description": "Best-effort primary LAN IP."
"description": "Best-effort primary LAN IP, read fresh on every request — a host that started before its\nnetwork did (cold boot) reports `127.0.0.1` only until it actually has an address, and a\nhost that moves networks reports the new one. Poll it rather than caching it."
},
"os": {
"type": "string",
@@ -366,15 +366,26 @@ object Gamepad {
// is immune to the layout file — the same reason [Keymap.toVk] reads `scanCode` for keyboards.
// Two things keep it from breaking a pad that already works:
//
// 1. The correction is applied ONLY when the delivered keycode is what `Generic.kl` would
// have said ([genericKeyCode]). A different keycode means a device-specific layout IS in
// force and already knows this pad better than we do, so we leave it alone.
// 2. Which report order to read is decided from what the DEVICE declares, never a model
// table: a pad numbering straight through claims BUTTON_C and BUTTON_Z ([PadButtons]),
// keycodes no real controller has a button for.
// 1. Nothing is corrected on a pad that names its triggers ([padButtons]). A descriptor
// well-formed enough to call them Accelerator/Brake puts its buttons at the standard
// positions too, and that is the fact — not the model — that separates the two firmwares
// of the SAME Xbox pad, only the older of which needs any of this.
// 2. Past that gate the correction still applies ONLY where the delivered keycode is what
// `Generic.kl` would have said ([genericKeyCode]). A different keycode means a
// device-specific layout IS in force and knows this pad better than we do.
//
// Moonlight carries the same two tables (`ControllerHandler`'s `isNonStandardDualShock4` /
// `isNonStandardXboxBtController`), which is why both pads work there on the same box.
// Moonlight carries the same two tables AND the same gate (`ControllerHandler`'s
// `isNonStandardDualShock4` / `isNonStandardXboxBtController`, the latter on `gasRange == null`),
// which is why both pads work there on the same box.
//
// The first cut of this asked `hasKeys(BUTTON_C, BUTTON_Z)` on its own, on the reasoning that a
// pad numbering straight through reaches keycodes no controller has a button for. It does — but
// so does every pad that merely DECLARES six buttons, because `hid-input` allocates `BTN_A + n`
// straight through for the whole descriptor whether or not the pad ever presses them. That fired
// the correction on pads Android was already reading correctly (2026-08-21: an Xbox pad
// answering X with Y, Y with LB, and both shoulders with a menu button), and it could not have
// done otherwise: the signal is identical on the firmware that needs correcting and the one that
// does not. Declaration is not report order. Only the axes tell them apart.
/** [MotionEvent] axis id meaning "this pad has no such axis" — see [PadMap]. */
const val AXIS_NONE = -1
@@ -526,22 +537,42 @@ object Gamepad {
private val padMaps = ConcurrentHashMap<String, PadMap>()
/**
* Which report order [dev]'s buttons follow, asked of the device rather than a model table.
* Which report order [dev]'s buttons follow — [namedTriggers] is whether the pad reports its
* triggers under a name Android knows (see [padMap]), and [declaresCZ] whether it declares
* BUTTON_C and BUTTON_Z.
*
* A pad numbering its HID buttons straight through reaches BUTTON_C and BUTTON_Z, keycodes
* that exist only as `Generic.kl` positions — no controller has a physical C or Z button, and
* a pad with a kernel driver behind it emits the modern Linux gamepad codes, which skip both.
* Declaring the pair is therefore the signature of a pad Android is guessing at.
* `namedTriggers` decides it, and a pad that has them is [PadButtons.NATIVE] whatever else it
* says. A HID gamepad describes its triggers either as the Accelerator/Brake usages, which
* become `ABS_GAS`/`ABS_BRAKE` and axis names Android has words for, or as two more generic
* axes on `ABS_Z`/`ABS_RZ`, which it does not — and a report descriptor well-formed enough to
* name its triggers puts its buttons at the standard positions too, the ones `Generic.kl`
* already reads correctly. It is the same fact Moonlight decides this on (`gasRange == null`
* beside the `"Xbox Wireless Controller"` name), and it is the one that separates the two
* firmwares of the SAME pad: an Xbox Wireless Controller over Bluetooth reports GAS/BRAKE
* after its firmware update and Z/Rz before it, and only the older one needs correcting.
*
* `declaresCZ` cannot make that call and must never be asked to. `hasKeys` answers for what a
* device DECLARES, not what it reports: `hid-input` allocates `BTN_A + n` straight through for
* every button in the descriptor, so BTN_C (`0x132`) and BTN_Z (`0x135`) are set on any pad
* declaring six or more — a standard-layout pad that never presses either included. Read alone
* it fired the correction on pads whose buttons were already right, which is how an Xbox pad
* came to answer X with Y and Y with LB (field reports, 2026-08-21). It stays as the narrower
* question it can answer — WHICH straight-through order, once `namedTriggers` has established
* there is one — where a false positive costs nothing.
*/
fun padButtons(dev: InputDevice): PadButtons {
fun padButtons(dev: InputDevice, namedTriggers: Boolean): PadButtons {
val has = dev.hasKeys(KeyEvent.KEYCODE_BUTTON_C, KeyEvent.KEYCODE_BUTTON_Z, 0)
val straightThrough = has[0] && has[1]
return when {
straightThrough && dev.vendorId == VID_SONY -> PadButtons.GENERIC_SONY
straightThrough -> PadButtons.GENERIC_XBOX
dev.vendorId == VID_SONY -> PadButtons.SONY_MODERN
else -> PadButtons.NATIVE
}
return padButtons(namedTriggers, dev.vendorId == VID_SONY, declaresCZ = has[0] && has[1])
}
/** [padButtons]'s choice over plain facts — the seam its truth table is tested at (an
* [InputDevice] cannot be built off a device). */
fun padButtons(namedTriggers: Boolean, sony: Boolean, declaresCZ: Boolean): PadButtons = when {
namedTriggers -> PadButtons.NATIVE
declaresCZ && sony -> PadButtons.GENERIC_SONY
declaresCZ -> PadButtons.GENERIC_XBOX
sony -> PadButtons.SONY_MODERN
else -> PadButtons.NATIVE
}
/**
@@ -566,11 +597,11 @@ object Gamepad {
fun padMap(dev: InputDevice?): PadMap {
if (dev == null) return NATIVE_MAP
padMaps[dev.descriptor]?.let { return it }
val buttons = padButtons(dev)
fun has(a: Int) = axis(dev, a) != null
val named = (has(MotionEvent.AXIS_LTRIGGER) && has(MotionEvent.AXIS_RTRIGGER)) ||
(has(MotionEvent.AXIS_BRAKE) && has(MotionEvent.AXIS_GAS)) ||
(has(MotionEvent.AXIS_BRAKE) && has(MotionEvent.AXIS_THROTTLE))
val buttons = padButtons(dev, namedTriggers = named)
val rx = axis(dev, MotionEvent.AXIS_RX)
val hasRxRy = rx != null && has(MotionEvent.AXIS_RY)
// Whichever pair the fallback is about to pick, ask THAT one where it rests.
@@ -200,4 +200,55 @@ class PadButtonsTest {
assertEquals(generic, Gamepad.PadButtons.NATIVE.correct(scan, generic))
}
}
/**
* The regression that made this gate necessary (field reports, 2026-08-21): an Xbox Wireless
* Controller and a GameSir G8+, both with their buttons at the standard positions and both
* corrected anyway, because `hasKeys` says BUTTON_C and BUTTON_Z for any pad that DECLARES six
* buttons — `hid-input` allocates the whole descriptor `BTN_A + n` straight through whether the
* pad ever presses them or not. Naming the triggers is what tells the two apart.
*/
@Test
fun `a pad that names its triggers is never corrected, whatever it declares`() {
for (sony in listOf(false, true)) {
for (declaresCZ in listOf(false, true)) {
assertEquals(
Gamepad.PadButtons.NATIVE,
Gamepad.padButtons(namedTriggers = true, sony = sony, declaresCZ = declaresCZ),
)
}
}
}
/**
* The four buttons the field reports named, on a pad whose report order is already standard:
* X answering Y, Y answering LB, and both shoulders answering a menu button. NATIVE is what
* keeps them themselves — the correction tables are right for the pads they are for, and this
* is about not reaching one of them.
*/
@Test
fun `an Xbox pad at the standard positions keeps X, Y and its shoulders`() {
val native = Gamepad.PadButtons.NATIVE
assertEquals(KeyEvent.KEYCODE_BUTTON_X, native.correct(0x133, KeyEvent.KEYCODE_BUTTON_X))
assertEquals(KeyEvent.KEYCODE_BUTTON_Y, native.correct(0x134, KeyEvent.KEYCODE_BUTTON_Y))
assertEquals(KeyEvent.KEYCODE_BUTTON_L1, native.correct(0x136, KeyEvent.KEYCODE_BUTTON_L1))
assertEquals(KeyEvent.KEYCODE_BUTTON_R1, native.correct(0x137, KeyEvent.KEYCODE_BUTTON_R1))
// What the old heuristic did to each of them, kept here so the difference stays visible.
val wrong = Gamepad.PadButtons.GENERIC_XBOX
assertEquals(KeyEvent.KEYCODE_BUTTON_Y, wrong.correct(0x133, KeyEvent.KEYCODE_BUTTON_X))
assertEquals(KeyEvent.KEYCODE_BUTTON_L1, wrong.correct(0x134, KeyEvent.KEYCODE_BUTTON_Y))
assertEquals(KeyEvent.KEYCODE_BUTTON_SELECT, wrong.correct(0x136, KeyEvent.KEYCODE_BUTTON_L1))
assertEquals(KeyEvent.KEYCODE_BUTTON_START, wrong.correct(0x137, KeyEvent.KEYCODE_BUTTON_R1))
}
/** Past the gate, which straight-through order to read is still the question it always was. */
@Test
fun `an unnamed-trigger pad still resolves its report order`() {
fun order(sony: Boolean, declaresCZ: Boolean) =
Gamepad.padButtons(namedTriggers = false, sony = sony, declaresCZ = declaresCZ)
assertEquals(Gamepad.PadButtons.GENERIC_SONY, order(sony = true, declaresCZ = true))
assertEquals(Gamepad.PadButtons.GENERIC_XBOX, order(sony = false, declaresCZ = true))
assertEquals(Gamepad.PadButtons.SONY_MODERN, order(sony = true, declaresCZ = false))
assertEquals(Gamepad.PadButtons.NATIVE, order(sony = false, declaresCZ = false))
}
}
+104 -29
View File
@@ -25,7 +25,9 @@
use anyhow::{Context, Result};
use mdns_sd::{ServiceDaemon, ServiceInfo};
use std::collections::HashMap;
use std::net::IpAddr;
use std::net::{IpAddr, Ipv4Addr};
use std::sync::mpsc;
use std::time::Duration;
/// The native-protocol mDNS service type. Clients browse this to find punktfunk/1 hosts.
pub const NATIVE_SERVICE: &str = "_punktfunk._udp.local.";
@@ -81,9 +83,78 @@ pub(crate) fn dns_label(name: &str) -> String {
}
}
/// Holds the mDNS daemon; dropping it unregisters the service.
/// Holds the mDNS daemon; dropping it unregisters the service and stops the re-announce loop.
pub struct Advert {
_daemon: ServiceDaemon,
/// Never sent on. Dropping it disconnects the channel the re-announce thread waits on, which
/// wakes that thread immediately and ends it — so an `Advert` takes its loop with it instead
/// of leaving one behind polling for a service nobody advertises.
_stop: mpsc::Sender<()>,
}
/// How often a live advert re-checks the address it is announcing.
const IP_RECHECK: Duration = Duration::from_secs(10);
/// The address to advertise right now — loopback only while the machine still has none.
fn current_ip() -> IpAddr {
crate::gamestream::primary_local_ip().unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST))
}
/// Register `build(ip)` for the host's current address, and re-register it whenever that address
/// changes. Shared by both adverts ([`advertise_native`] and [`crate::gamestream::mdns`]).
///
/// mDNS records are PUSHED, not polled: whatever address was true at `register()` keeps being
/// announced until something registers a newer one. The host process comes up during boot, which
/// on a cold start is before the machine has an address — so the first registration could be
/// `127.0.0.1`, and it stayed that way until the host was restarted by hand. `mdns-sd` documents a
/// second `register()` of the same fullname as an update, so re-announcing is just calling it
/// again.
///
/// Polls the *routed* address rather than subscribing to the daemon's `IpAdd` events, because the
/// boot race usually resolves without one: the NIC often has its address before we register and
/// only the default route lands late, so no interface event ever fires.
pub(crate) fn advertise_live(
service: &'static str,
build: impl Fn(IpAddr) -> Result<ServiceInfo> + Send + 'static,
) -> Result<Advert> {
let daemon = ServiceDaemon::new().context("create mDNS daemon")?;
let registered = current_ip();
daemon
.register(build(registered)?)
.with_context(|| format!("register {service} mDNS service"))?;
let (stop_tx, stop_rx) = mpsc::channel::<()>();
let bg_daemon = daemon.clone();
std::thread::spawn(move || {
let mut announced = registered;
// Doubles as the sleep: times out every `IP_RECHECK` to re-check, and returns
// `Disconnected` the moment the `Advert` drops its sender, which ends the loop.
while matches!(
stop_rx.recv_timeout(IP_RECHECK),
Err(mpsc::RecvTimeoutError::Timeout)
) {
let now = current_ip();
if now == announced {
continue;
}
match build(now)
.and_then(|info| bg_daemon.register(info).context("re-register mDNS service"))
{
Ok(()) => {
tracing::info!(service, from = %announced, to = %now, "host address changed — re-announced");
announced = now;
}
// Leave the previous record standing and retry next tick rather than going dark.
Err(e) => {
tracing::warn!(service, error = %format!("{e:#}"), "mDNS re-announce failed");
}
}
}
});
Ok(Advert {
_daemon: daemon,
_stop: stop_tx,
})
}
/// Advertise the native host on the LAN. `fingerprint` is the host cert SHA-256 (lowercase hex);
@@ -95,7 +166,6 @@ pub struct Advert {
#[allow(clippy::too_many_arguments)]
pub fn advertise_native(
hostname: &str,
ip: IpAddr,
port: u16,
fingerprint: &str,
require_pairing: bool,
@@ -103,14 +173,17 @@ pub fn advertise_native(
mgmt_port: Option<u16>,
os_chain: &str,
) -> Result<Advert> {
let daemon = ServiceDaemon::new().context("create mDNS daemon")?;
// `hostname` is the DISPLAY name (the instance label clients read back); the A-record target
// has to be a legal DNS name, hence the separate sanitized label.
let host_name = format!("{}.local.", dns_label(hostname));
let mut props: HashMap<String, String> = HashMap::new();
props.insert("proto".into(), NATIVE_PROTO.into());
props.insert("fp".into(), fingerprint.to_string());
props.insert(
// Owned, because the record is rebuilt whenever the host's address changes — see
// [`advertise_live`]. Everything except the address (and the MACs derived from it) is fixed,
// so it is computed once here and moved into the builder.
let instance = hostname.to_string();
let mut fixed: HashMap<String, String> = HashMap::new();
fixed.insert("proto".into(), NATIVE_PROTO.into());
fixed.insert("fp".into(), fingerprint.to_string());
fixed.insert(
"pair".into(),
if require_pairing {
"required"
@@ -119,31 +192,14 @@ pub fn advertise_native(
}
.into(),
);
props.insert("id".into(), uniqueid.to_string());
fixed.insert("id".into(), uniqueid.to_string());
if let Some(mgmt) = mgmt_port {
props.insert("mgmt".into(), mgmt.to_string());
fixed.insert("mgmt".into(), mgmt.to_string());
}
// `os` — advisory OS-identity chain for the client's host-card icon (see module doc).
if !os_chain.is_empty() {
props.insert("os".into(), os_chain.to_string());
fixed.insert("os".into(), os_chain.to_string());
}
// `mac` — the host's wake-capable NIC MAC(s), comma-separated `aa:bb:cc:dd:ee:ff`, routed NIC
// first. A client persists these while the host is awake so it can send a Wake-on-LAN magic
// packet to wake it later (when it's asleep and no longer advertising). Unauthenticated like
// the rest of the advert, but a wrong MAC only makes a wake fail — the magic packet is inert
// and the cert fingerprint still gates the actual connection. Omitted when none can be read.
let macs = crate::wol::wake_macs(ip);
if !macs.is_empty() {
props.insert("mac".into(), macs.join(","));
}
// Detect & warn (never modifies) if the routed NIC isn't armed to wake — the usual reason WoL
// silently fails.
crate::wol::warn_if_not_armed(ip);
let service = ServiceInfo::new(NATIVE_SERVICE, hostname, &host_name, ip, port, props)
.context("build native mDNS ServiceInfo")?;
daemon
.register(service)
.context("register native mDNS service")?;
tracing::info!(
service = "_punktfunk._udp",
port,
@@ -151,7 +207,26 @@ pub fn advertise_native(
pair = if require_pairing { "required" } else { "optional" },
"native punktfunk/1 mDNS advertising"
);
Ok(Advert { _daemon: daemon })
advertise_live(NATIVE_SERVICE, move |ip| {
let mut props = fixed.clone();
// `mac` — the host's wake-capable NIC MAC(s), comma-separated `aa:bb:cc:dd:ee:ff`, routed
// NIC first. A client persists these while the host is awake so it can send a
// Wake-on-LAN magic packet to wake it later (when it's asleep and no longer advertising).
// Unauthenticated like the rest of the advert, but a wrong MAC only makes a wake fail —
// the magic packet is inert and the cert fingerprint still gates the actual connection.
// Omitted when none can be read, which is what a host that came up before its network did
// used to report forever.
let macs = crate::wol::wake_macs(ip);
if !macs.is_empty() {
props.insert("mac".into(), macs.join(","));
}
// Detect & warn (never modifies) if the routed NIC isn't armed to wake — the usual reason
// WoL silently fails. Re-checked on an address change because the routed NIC may be a
// different one now.
crate::wol::warn_if_not_armed(ip);
ServiceInfo::new(NATIVE_SERVICE, &instance, &host_name, ip, port, props)
.context("build native mDNS ServiceInfo")
})
}
#[cfg(test)]
+18 -21
View File
@@ -3,37 +3,34 @@
use super::Host;
use anyhow::{Context, Result};
use mdns_sd::{ServiceDaemon, ServiceInfo};
use mdns_sd::ServiceInfo;
use std::collections::HashMap;
/// Holds the mDNS daemon; dropping it unregisters the service.
pub struct Advert {
_daemon: ServiceDaemon,
}
// One `Advert` for both service types: holds the mDNS daemon plus the re-announce loop that
// keeps the record pointed at the host's current address.
use crate::discovery::Advert;
const SERVICE: &str = "_nvstream._tcp.local.";
pub fn advertise(host: &Host) -> Result<Advert> {
let daemon = ServiceDaemon::new().context("create mDNS daemon")?;
// Instance name = the display name (what Moonlight lists); A-record target = the sanitized
// DNS label, so a free-text `PUNKTFUNK_HOST_NAME` can't produce an illegal record.
let host_name = format!("{}.local.", crate::discovery::dns_label(&host.hostname));
// No TXT records are required for Moonlight discovery; it resolves the A record and then
// GETs /serverinfo for capabilities.
let props: HashMap<String, String> = HashMap::new();
let service = ServiceInfo::new(
"_nvstream._tcp.local.",
&host.hostname,
&host_name,
host.local_ip,
host.http_port,
props,
)
.context("build mDNS ServiceInfo")?;
daemon.register(service).context("register mDNS service")?;
let instance = host.hostname.clone();
let port = host.http_port;
tracing::info!(
service = "_nvstream._tcp",
port = host.http_port,
port,
host = %host_name,
"mDNS advertising"
);
Ok(Advert { _daemon: daemon })
// The advertised address is supplied per-registration so the record follows the host onto a
// network that only came up after boot — see [`crate::discovery::advertise_live`].
crate::discovery::advertise_live(SERVICE, move |ip| {
// No TXT records are required for Moonlight discovery; it resolves the A record and then
// GETs /serverinfo for capabilities.
let props: HashMap<String, String> = HashMap::new();
ServiceInfo::new(SERVICE, &instance, &host_name, ip, port, props)
.context("build mDNS ServiceInfo")
})
}
+97 -8
View File
@@ -138,7 +138,6 @@ pub struct Host {
pub hostname: String,
/// Stable per-host id (persisted), echoed in serverinfo + matched on pairing.
pub uniqueid: String,
pub local_ip: IpAddr,
pub http_port: u16,
pub https_port: u16,
/// OS identity chain (`windows` | `macos` | `linux[/<family>][/<id>]`), advertised in the
@@ -155,13 +154,25 @@ impl Host {
Ok(Host {
hostname: hostname_string(),
uniqueid: load_or_create_uniqueid()?,
local_ip: primary_local_ip().unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST)),
http_port: HTTP_PORT,
https_port: HTTPS_PORT,
os_chain: os.chain.clone(),
os_name: os.pretty.clone(),
})
}
/// Best-effort primary LAN IP, re-read on every call.
///
/// Deliberately NOT a field: [`Host::detect`] runs as the host process starts, which on a cold
/// boot is before the machine has an address at all, and a snapshot taken there used to stick
/// for the life of the process — the host then advertised itself over mDNS as `127.0.0.1`,
/// handed Moonlight an `rtsp://127.0.0.1` session URL, and dropped its Wake-on-LAN MAC record,
/// until someone restarted it by hand. Reading live costs a `connect(2)` on an unconnected UDP
/// socket (no packets are sent), which is nothing beside the HTTP responses it is serialized
/// into. Loopback here means "still no LAN address", not a stale one.
pub fn local_ip(&self) -> IpAddr {
primary_local_ip().unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST))
}
}
/// The stream parameters a client passes at `/launch`, shared with the RTSP + media stages.
@@ -432,7 +443,7 @@ pub fn serve(
tracing::info!(
hostname = %state.host.hostname,
uniqueid = %state.host.uniqueid,
ip = %state.host.local_ip,
ip = %state.host.local_ip(),
native_port = native.port,
require_pairing = native.require_pairing,
gamestream,
@@ -656,10 +667,43 @@ fn load_or_create_uniqueid() -> Result<String> {
/// Best-effort primary LAN IP: open a UDP socket "toward" a public address and read the
/// local address the OS would route through. No packets are actually sent.
fn primary_local_ip() -> Option<IpAddr> {
let sock = UdpSocket::bind("0.0.0.0:0").ok()?;
sock.connect("8.8.8.8:80").ok()?;
sock.local_addr().ok().map(|a| a.ip())
///
/// Returns `None` — never loopback — when the machine has no LAN address yet, so callers have to
/// decide what "unknown" means instead of silently inheriting `127.0.0.1`. During a cold boot the
/// route probe fails outright (the host outruns DHCP: the Windows service is `AutoStart` with no
/// network dependency), so it falls back to the first non-loopback interface address, which the
/// NIC has as soon as it is configured even if the default route is not installed yet.
pub(crate) fn primary_local_ip() -> Option<IpAddr> {
let routed = UdpSocket::bind("0.0.0.0:0")
.and_then(|sock| {
sock.connect("8.8.8.8:80")?;
sock.local_addr()
})
.ok()
.map(|a| a.ip())
.filter(|ip| usable_lan_ip(*ip));
routed.or_else(first_lan_ipv4)
}
/// First reachable IPv4 an interface holds, ignoring the routing table entirely.
///
/// Split out because this is the branch the boot race actually takes, and the one nothing would
/// otherwise exercise: the route probe above needs a default route, which lands *after* the NIC
/// has its address on a cold boot. Between those two moments the old code had no answer and fell
/// back to loopback for good.
fn first_lan_ipv4() -> Option<IpAddr> {
if_addrs::get_if_addrs()
.ok()?
.into_iter()
.map(|i| i.ip())
.find(|ip| ip.is_ipv4() && usable_lan_ip(*ip))
}
/// Is `ip` an address a client could actually reach this host on? Loopback and the unspecified
/// address are both "we don't know yet" dressed up as an answer, and advertising either is the
/// boot race that made a freshly-restarted host publish itself as `127.0.0.1`.
fn usable_lan_ip(ip: IpAddr) -> bool {
!ip.is_loopback() && !ip.is_unspecified()
}
/// Where the paired-client allow-list persists (survives host restarts, like Sunshine).
@@ -740,6 +784,52 @@ mod host_name_tests {
}
}
#[cfg(test)]
mod local_ip_tests {
use super::{first_lan_ipv4, primary_local_ip, usable_lan_ip};
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
#[test]
fn loopback_and_unspecified_are_never_advertisable() {
// The bug: a host that started before its network did advertised these as its address and
// kept doing so for the life of the process.
for unusable in [
IpAddr::V4(Ipv4Addr::LOCALHOST),
IpAddr::V4(Ipv4Addr::UNSPECIFIED),
IpAddr::V6(Ipv6Addr::LOCALHOST),
IpAddr::V6(Ipv6Addr::UNSPECIFIED),
] {
assert!(
!usable_lan_ip(unusable),
"{unusable} must not be advertised"
);
}
for usable in [
IpAddr::V4(Ipv4Addr::new(192, 168, 1, 173)),
IpAddr::V4(Ipv4Addr::new(10, 0, 0, 2)),
IpAddr::V6(Ipv6Addr::new(0xfd00, 0, 0, 0, 0, 0, 0, 1)),
] {
assert!(usable_lan_ip(usable), "{usable} is reachable and must pass");
}
}
#[test]
fn probe_reports_no_address_rather_than_loopback() {
// Holds on a networked box and on an isolated CI runner alike: either we found a real LAN
// address, or we admit we have none. `None` is what lets `Host::local_ip()` and the mDNS
// advert keep retrying instead of freezing a wrong answer in place.
assert!(primary_local_ip().is_none_or(usable_lan_ip));
}
#[test]
fn interface_fallback_never_offers_loopback() {
// The branch a cold boot takes, before the default route exists. It may legitimately find
// nothing (a machine with no NIC up, e.g. an isolated CI container) — what it must never
// do is hand back the loopback that `get_if_addrs` also reports.
assert!(first_lan_ipv4().is_none_or(usable_lan_ip));
}
}
#[cfg(test)]
mod session_tests {
use super::*;
@@ -748,7 +838,6 @@ mod session_tests {
let host = Host {
hostname: "test-host".into(),
uniqueid: "deadbeef".into(),
local_ip: IpAddr::V4(Ipv4Addr::LOCALHOST),
http_port: HTTP_PORT,
https_port: HTTPS_PORT,
os_chain: "linux".into(),
@@ -250,7 +250,7 @@ async fn h_launch(
fps = session.fps,
rikeyid = session.rikeyid,
"launch — session created; RTSP at rtsp://{}:{RTSP_PORT}",
st.host.local_ip
st.host.local_ip()
);
xml(session_url_xml(&st, "gamesession")).into_response()
}
@@ -405,7 +405,7 @@ fn gamestream_admission(
fn session_url_xml(st: &AppState, tag: &str) -> String {
format!(
"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<root status_code=\"200\">\n<sessionUrl0>rtsp://{}:{RTSP_PORT}</sessionUrl0>\n<{tag}>1</{tag}>\n</root>\n",
st.host.local_ip
st.host.local_ip()
)
}
@@ -485,13 +485,11 @@ fn error_xml() -> String {
#[cfg(test)]
mod tests {
use super::*;
use std::net::{IpAddr, Ipv4Addr};
fn test_state() -> Arc<AppState> {
let host = super::super::Host {
hostname: "t".into(),
uniqueid: "id".into(),
local_ip: IpAddr::V4(Ipv4Addr::LOCALHOST),
http_port: HTTP_PORT,
https_port: HTTPS_PORT,
os_chain: "linux".into(),
@@ -39,7 +39,7 @@ pub fn serverinfo_xml(host: &Host, https: bool, paired: bool) -> String {
uniqueid = host.uniqueid,
https_port = host.https_port,
http_port = host.http_port,
local_ip = host.local_ip,
local_ip = host.local_ip(),
)
}
@@ -205,7 +205,6 @@ mod tests {
let host = Host {
hostname: "test".into(),
uniqueid: "uid".into(),
local_ip: std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST),
http_port: 47989,
https_port: 47984,
os_chain: "linux".into(),
+6 -3
View File
@@ -23,13 +23,16 @@ pub(crate) struct Health {
abi_version: u32,
}
/// Host identity and advertised capabilities (static for the life of the process).
/// Host identity and advertised capabilities (static for the life of the process, except
/// `local_ip`).
#[derive(Serialize, ToSchema)]
pub(crate) struct HostInfo {
hostname: String,
/// Stable per-host id (persisted across restarts), matched on pairing.
uniqueid: String,
/// Best-effort primary LAN IP.
/// Best-effort primary LAN IP, read fresh on every request — a host that started before its
/// network did (cold boot) reports `127.0.0.1` only until it actually has an address, and a
/// host that moves networks reports the new one. Poll it rather than caching it.
local_ip: String,
/// `punktfunk-host` crate version.
version: String,
@@ -324,7 +327,7 @@ pub(crate) async fn get_host_info(State(st): State<Arc<MgmtState>>) -> Json<Host
Json(HostInfo {
hostname: h.hostname.clone(),
uniqueid: h.uniqueid.clone(),
local_ip: h.local_ip.to_string(),
local_ip: h.local_ip().to_string(),
version: env!("PUNKTFUNK_VERSION").into(),
abi_version: punktfunk_core::ABI_VERSION,
app_version: APP_VERSION.into(),
-2
View File
@@ -47,7 +47,6 @@ use axum::body::Body;
use axum::http::StatusCode;
use http_body_util::BodyExt;
use sha2::{Digest, Sha256};
use std::net::{IpAddr, Ipv4Addr};
use std::sync::atomic::Ordering;
use tower::ServiceExt;
@@ -73,7 +72,6 @@ fn test_state() -> Arc<AppState> {
let host = Host {
hostname: "test-host".into(),
uniqueid: "deadbeef".into(),
local_ip: IpAddr::V4(Ipv4Addr::LOCALHOST),
http_port: HTTP_PORT,
https_port: HTTPS_PORT,
os_chain: "linux/arch/steamos".into(),
+104 -7
View File
@@ -156,9 +156,33 @@ pub struct Punktfunk1Options {
/// the client's reported address, no hole-punch"; `false` (random port, or a busy fixed port) means
/// "hole-punch". The socket is held from the handshake through streaming — no drop-then-rebind
/// window in which a concurrent session could steal a fixed port.
fn bind_data_socket(data_port: Option<u16>) -> std::io::Result<(std::net::UdpSocket, bool)> {
///
/// `local_ip` is the address the client's QUIC connection was RECEIVED on (`Connection::local_ip`),
/// and binding to it is load-bearing on a multi-homed host. The client's data socket is
/// `connect`ed to the host IP it dialed, so its kernel accepts video only from THAT source
/// address; a wildcard bind here lets the routing table pick the egress interface independently of
/// the one the control plane arrived on, and the two differ whenever a host has two paths to the
/// client — Ethernet and Wi-Fi both up on the same LAN is the everyday case. Every video datagram
/// is then dropped by the client's kernel before userspace: nothing counts it, `loss_ppm` stays 0
/// (no packets, no gaps), the hole-punch still arrives so the host logs `punched=true`, and the
/// control plane — which quinn pins to the right local address — stays perfectly healthy. That is
/// the "connects fine, black screen forever" shape with every gauge green, and it is invisible on
/// both ends. `None` (platform can't report it) or a bind failure falls back to the wildcard.
fn bind_data_socket(
data_port: Option<u16>,
local_ip: Option<std::net::IpAddr>,
) -> std::io::Result<(std::net::UdpSocket, bool)> {
// An IPv4-mapped v6 local address (dual-stack endpoint) must be unmapped before it can bind a
// socket that will `connect` to a v4 peer — the families have to match.
let local_ip = local_ip.map(|ip| match ip {
std::net::IpAddr::V6(v6) => v6.to_ipv4_mapped().map_or(ip, std::net::IpAddr::V4),
v4 => v4,
});
let wildcard = |ip: Option<std::net::IpAddr>| {
ip.unwrap_or(std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED))
};
if let Some(p) = data_port.filter(|p| *p != 0) {
match std::net::UdpSocket::bind(("0.0.0.0", p)) {
match std::net::UdpSocket::bind((wildcard(local_ip), p)) {
Ok(sock) => return Ok((sock, true)),
Err(e) => tracing::warn!(
data_port = p,
@@ -168,7 +192,23 @@ fn bind_data_socket(data_port: Option<u16>) -> std::io::Result<(std::net::UdpSoc
),
}
}
Ok((std::net::UdpSocket::bind("0.0.0.0:0")?, false))
match std::net::UdpSocket::bind((wildcard(local_ip), 0)) {
Ok(sock) => Ok((sock, false)),
// The control plane arrived on this address moments ago, so a failure here means it just
// went away (an adapter dropped mid-handshake). The wildcard still reaches a client the
// routing table can route to — degraded, not dead — so take it and say why.
Err(e) if local_ip.is_some() => {
tracing::warn!(
local_ip = ?local_ip,
error = %e,
"could not bind the data plane to the address the control connection arrived on \
falling back to the wildcard. On a multi-homed host video may now egress from \
a different interface than the client dialed, which it silently drops."
);
Ok((std::net::UdpSocket::bind("0.0.0.0:0")?, false))
}
Err(e) => Err(e),
}
}
/// The native (punktfunk/1) trust store + on-demand arming PIN, shared with the management API.
@@ -365,7 +405,6 @@ pub(crate) async fn serve(
match crate::gamestream::Host::detect() {
Ok(h) => crate::discovery::advertise_native(
&h.hostname,
h.local_ip,
opts.port,
&fingerprint_hex(&fingerprint),
opts.require_pairing,
@@ -2057,6 +2096,10 @@ async fn serve_session(
// stages ride the same per-session trace; resizes write their totals into the shared slot.
let bringup_dp = bringup.clone();
let resize_ms_dp = resize_ms.clone();
// The address the control connection arrived on, for the data plane's source-address check
// below — the one comparison that distinguishes "the client is filtering our video" from
// "the video never left". Captured here because the send loop runs on a blocking thread.
let control_local_ip = conn.local_ip();
let result: Result<()> = async {
let stream_thread = tokio::task::spawn_blocking(move || -> Result<()> {
// Bring up the (already-bound) data-plane socket. Default: hole-punch — wait briefly
@@ -2091,15 +2134,44 @@ async fn serve_session(
}
};
bringup_dp.mark("punch_done");
// Post-`connect`, `local_addr` reports the source address the kernel will actually
// stamp on every video datagram — the number that has to match the host IP the client
// dialed, because its data socket is connected and its kernel drops anything else
// before userspace. Logged unconditionally: a black-screen report is unanswerable
// without it (this session's showed only the port).
let local = transport.local_addr().ok();
tracing::info!(
%client_udp,
udp_port,
direct,
punched,
local = ?local,
"data plane bound (direct=true → fixed --data-port, streaming to the reported \
address with no hole-punch; else punched=true the client's observed source, \
false no punch seen, the reported address)"
);
// A video source address that isn't the one the control plane arrived on means the
// client will discard every datagram we send, however healthy this end looks.
if let (Some(l), Some(c)) = (local.map(|a| a.ip()), control_local_ip) {
let c = match c {
std::net::IpAddr::V6(v6) => {
v6.to_ipv4_mapped().map_or(c, std::net::IpAddr::V4)
}
v4 => v4,
};
if !l.is_unspecified() && l != c {
tracing::warn!(
video_source_ip = %l,
control_local_ip = %c,
"the video data plane egresses from a DIFFERENT host address than the one \
this client connected to its data socket is connected to the address it \
dialed, so its kernel drops every video datagram before userspace: black \
screen, zero reported loss, healthy control plane. Usual cause is two \
live paths to the client (Ethernet and Wi-Fi both up on the same LAN, or \
a VPN/overlay adapter claiming the route)"
);
}
}
// A punch that never arrives is not a routine fallback — it is the fingerprint of a
// data port the client cannot reach INBOUND, and every client punches (5/s for the
// first three seconds, then every two). Video then goes to an address the client only
@@ -2515,7 +2587,7 @@ mod tests {
// No fixed port (and the explicit-0 alias) → a random ephemeral port, and NOT direct: the
// caller hole-punches.
for req in [None, Some(0)] {
let (sock, direct) = bind_data_socket(req).expect("bind random data socket");
let (sock, direct) = bind_data_socket(req, None).expect("bind random data socket");
assert!(!direct, "req={req:?} must hole-punch, not stream direct");
assert_ne!(sock.local_addr().unwrap().port(), 0);
}
@@ -2532,13 +2604,14 @@ mod tests {
.port();
// A free fixed port binds exactly it, in DIRECT mode (no hole-punch).
let (held, direct) = bind_data_socket(Some(free)).expect("bind fixed data socket");
let (held, direct) = bind_data_socket(Some(free), None).expect("bind fixed data socket");
assert!(direct, "a fixed --data-port must stream direct");
assert_eq!(held.local_addr().unwrap().port(), free);
// While it's held, a second session on the same fixed port can't bind it → it must fall
// back to a random port + hole-punch rather than fail (so concurrency never regresses).
let (fallback, direct2) = bind_data_socket(Some(free)).expect("busy fixed port falls back");
let (fallback, direct2) =
bind_data_socket(Some(free), None).expect("busy fixed port falls back");
assert!(!direct2, "a busy fixed port must fall back to hole-punch");
assert_ne!(
fallback.local_addr().unwrap().port(),
@@ -2547,6 +2620,30 @@ mod tests {
);
}
/// The multi-homed black screen: video must egress from the address the client's control
/// connection arrived on, because the client's data socket is connected to the host address it
/// dialed and its kernel drops every datagram from any other source — silently, before
/// userspace, so nothing on either end counts it. A wildcard bind here lets the routing table
/// choose a different interface whenever the host has two paths to the client.
#[test]
fn data_socket_binds_the_address_the_control_plane_arrived_on() {
let loopback = std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST);
let (sock, direct) =
bind_data_socket(None, Some(loopback)).expect("bind pinned data socket");
assert!(!direct);
assert_eq!(sock.local_addr().unwrap().ip(), loopback);
// An IPv4-mapped v6 local address (a dual-stack QUIC endpoint reports one) has to be
// unmapped, or the socket binds v6 and can never `connect` to the v4 client.
let mapped = std::net::IpAddr::V6(std::net::Ipv4Addr::LOCALHOST.to_ipv6_mapped());
let (sock, _) = bind_data_socket(None, Some(mapped)).expect("bind mapped data socket");
assert_eq!(sock.local_addr().unwrap().ip(), loopback);
// No reported local address (platform can't say) keeps the old wildcard behaviour.
let (sock, _) = bind_data_socket(None, None).expect("bind wildcard data socket");
assert!(sock.local_addr().unwrap().ip().is_unspecified());
}
/// Freeze the gamepad wire contract: every button bit + axis id pinned to its exact value in
/// `punktfunk_core::input::gamepad` — the single source both the punktfunk/1 native wire and the
/// GameStream/Limelight wire read from (they are one and the same). Renumbering a bit in core
@@ -780,7 +780,9 @@ pub(super) async fn negotiate(
// bind→read→drop→rebind window a concurrent session could race for a fixed port). A fixed
// `--data-port` yields `direct = true` (stream straight to the client's reported address,
// no punch-wait); otherwise a random ephemeral port + hole-punch.
let (data_sock, direct) = bind_data_socket(data_port)?;
// Bound to the address THIS connection arrived on, not the wildcard: the client only accepts
// video from the host IP it dialed (see `bind_data_socket`).
let (data_sock, direct) = bind_data_socket(data_port, conn.local_ip())?;
let udp_port = data_sock.local_addr()?.port();
// The session's video geometry (see the `shard_payload` field below). Resolved before the
+13 -5
View File
@@ -3034,11 +3034,19 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
counted here, so the picture is black and every keyframe we force is \
wasted. The control plane is healthy (this report arrived on it), so \
the session looks alive: audio, input and the library keep working. \
This is a PATH problem, not decode check that inbound UDP to this \
host's per-session data port is allowed (the 'data plane bound' line \
above shows `punched=false` when the client's hole-punch never \
arrived, which is the fingerprint), and that no other host or \
firewall is intercepting it"
READ THE 'data plane bound' LINE ABOVE it says which leg failed, \
and this line cannot. `punched=false`: the client's hole-punch never \
arrived, so inbound UDP to this host's per-session data port is \
blocked open it (the ports are ephemeral, so the rule must be \
program-scoped, not port-scoped). `punched=true`: inbound is FINE and \
the failure is on the return leg compare that line's `local=` \
source address against the host address this client dialed, because \
its data socket is connected and its kernel silently drops video from \
any other source. If those match, the datagrams left this host \
correctly and the client either never received them (a hop on the \
path) or received them and could not open them: this counter is \
incremented AFTER decrypt and replay checks, so a session whose every \
datagram failed to open reports exactly this same zero"
);
} else if matches_client_recovery_cooldown(period) {
if client_rx == u32::MAX {
+23 -15
View File
@@ -770,8 +770,24 @@ fn web_setup(args: &[String]) -> Result<()> {
// (security-review 2026-08-05 H-3). Same host, same certificate, different port — which is
// what makes it a different origin to the browser while staying same-site for the session
// cookie. Without this rule, plugin interfaces simply do not load from another device.
// Both rules are scoped to the bundled bun binary that actually listens on them, not left
// open to any program: a port-only `dir=in action=allow` rule admits whatever binds the port
// first, needs no elevation to do so, and suppresses the Windows prompt that would otherwise
// be the only way in (see `service::fw_add_rule_args`). The console child is
// `<app>/bun/bun.exe` — the same path `service.rs`'s supervisor spawns — so the rule follows
// it. If that binary isn't there, fall back to the port-only rule rather than leaving the
// console unreachable, and say which happened.
let fw_profile =
crate::service::firewall_profile_arg(crate::service::allow_public_network(args)?);
let bun = app_dir.join("bun").join("bun.exe");
let program = bun.exists().then_some(bun.as_path());
if program.is_none() {
eprintln!(
"warning: {} not found — the console firewall rules stay open to any program on those \
ports instead of only the console",
bun.display()
);
}
for (name, port) in [
("Punktfunk web console (TCP 47992)", "47992"),
("Punktfunk plugin UIs (TCP 47993)", "47993"),
@@ -786,21 +802,13 @@ fn web_setup(args: &[String]) -> Result<()> {
&format!("name={name}"),
],
);
if !run_quiet(
"netsh",
&[
"advfirewall",
"firewall",
"add",
"rule",
&format!("name={name}"),
"dir=in",
"action=allow",
"protocol=TCP",
&format!("localport={port}"),
fw_profile,
],
) {
if !crate::service::run_netsh(&crate::service::fw_add_rule_args(
name,
"TCP",
Some(port),
program,
fw_profile,
)) {
eprintln!("warning: could not add the firewall rule for TCP {port}");
}
}
+167 -49
View File
@@ -1550,14 +1550,79 @@ pub(crate) fn allow_public_network(args: &[String]) -> Result<bool> {
Ok(fw_public_marker().exists())
}
/// Build the `netsh advfirewall firewall add rule` argument vector for one inbound allow rule.
///
/// `program` is the whole point of this helper existing. A `dir=in action=allow` rule carrying only
/// `localport=` admits **any process on the machine** on those ports, and binding a high port on
/// Windows needs no elevation — so such a rule is a standing hole that any unprivileged program can
/// step into simply by binding first, and it does so *silently*, because our rule is exactly what
/// suppresses the "Allow this app to communicate on…" prompt Windows would otherwise raise (that
/// prompt is the UAC gate; without a matching rule there is no way in without one). Naming the
/// owning executable keeps the ports open for punktfunk and no one else. Reported by a user on
/// 2026-08-21, and correct: the fixed rules were the last any-program ones we shipped.
///
/// `ports` stays alongside it rather than being replaced by it — program AND port is strictly
/// tighter than either alone, and it is only ever dropped where the port genuinely cannot be known
/// in advance ([`add_data_plane_firewall_rule`], whose port is ephemeral per session).
///
/// `None` for `program` reproduces the old any-program rule, and every caller falls back to it
/// rather than skipping the rule when it cannot resolve its executable: a looser rule still streams,
/// no rule at all is a black screen.
pub(crate) fn fw_add_rule_args(
name: &str,
proto: &str,
ports: Option<&str>,
program: Option<&std::path::Path>,
profile: &str,
) -> Vec<String> {
let mut args: Vec<String> = ["advfirewall", "firewall", "add", "rule"]
.iter()
.map(|s| s.to_string())
.collect();
args.push(format!("name={name}"));
args.push("dir=in".into());
args.push("action=allow".into());
args.push(format!("protocol={proto}"));
if let Some(p) = ports {
args.push(format!("localport={p}"));
}
if let Some(exe) = program {
args.push(format!("program={}", exe.display()));
}
args.push(profile.to_string());
args
}
/// [`run_quiet`] for an arg vector built by [`fw_add_rule_args`].
pub(crate) fn run_netsh(args: &[String]) -> bool {
let borrowed: Vec<&str> = args.iter().map(String::as_str).collect();
run_quiet("netsh", &borrowed)
}
/// Inbound firewall rules for the streaming + mgmt ports (best-effort; logs but never fails the
/// install). Scoped by [`firewall_profile_arg`]: Domain + Private by default, all profiles when
/// `allow_public`. TCP 47990 is deliberate: `serve` binds the mgmt/library REST API to all interfaces
/// so paired clients can browse the game library over mTLS, and off-loopback `mgmt::require_auth`
/// exposes only the read-only status/library allowlist to a paired client cert — the bearer-token
/// admin surface stays loopback-only regardless of the bind — so opening it adds no admin exposure.
/// `allow_public`, and — since 2026-08-21 — to this host executable, so the ports below are open to
/// punktfunk rather than to anything on the machine that binds them first (see
/// [`fw_add_rule_args`]). TCP 47990 is deliberate: `serve` binds the mgmt/library REST API to all
/// interfaces so paired clients can browse the game library over mTLS, and off-loopback
/// `mgmt::require_auth` exposes only the read-only status/library allowlist to a paired client cert
/// — the bearer-token admin surface stays loopback-only regardless of the bind — so opening it adds
/// no admin exposure.
fn add_firewall_rules(allow_public: bool) {
let profile = firewall_profile_arg(allow_public);
// Resolved once and shared with the data-plane rule below. `service install` re-runs this whole
// remove-then-add on every upgrade, so a path recorded here cannot go stale behind a moved
// install — which is what previously argued for leaving these rules unscoped.
let exe = match std::env::current_exe() {
Ok(p) => Some(p),
Err(e) => {
eprintln!(
"warning: could not resolve the host executable path ({e}) — the rules below stay \
open to any program on those ports, and the per-session data-plane rule is skipped"
);
None
}
};
// (name suffix, protocol, ports). 47990 = mgmt/library (LAN = read-only, paired-cert only); the
// rest are the GameStream (47984/47989/48010, 47998-48010) + native (9777) + mDNS (5353) ports.
let rules = [
@@ -1566,28 +1631,35 @@ fn add_firewall_rules(allow_public: bool) {
];
for (suffix, proto, ports) in rules {
let name = format!("Punktfunk {suffix}");
let ok = run_quiet(
"netsh",
&[
"advfirewall",
"firewall",
"add",
"rule",
&format!("name={name}"),
"dir=in",
"action=allow",
&format!("protocol={proto}"),
&format!("localport={ports}"),
profile,
],
);
let ok = run_netsh(&fw_add_rule_args(
&name,
proto,
Some(ports),
exe.as_deref(),
profile,
));
if ok {
println!("Firewall rule added: {name} ({ports}) [{profile}]");
let scope = match &exe {
Some(p) => format!(" for {}", p.display()),
None => String::new(),
};
println!("Firewall rule added: {name} ({ports}{scope}) [{profile}]");
} else {
eprintln!("warning: could not add firewall rule '{name}' (add it manually if needed)");
}
}
add_data_plane_firewall_rule(profile);
add_data_plane_firewall_rule(profile, exe.as_deref());
// 5353 is now ours alone. Anything else on this machine that answered mDNS through the old
// any-program rule needs its own — say so, because it is the one externally visible change.
// Only when the scoping actually happened: with no exe path these rules are still wide open,
// and claiming otherwise in installer output is worse than saying nothing.
if exe.is_some() {
println!(
"Note: these rules are scoped to the punktfunk host executable, so they no longer open \
those ports to every program on this machine. Another mDNS/GameStream application \
that relied on punktfunk's rules to be reachable now needs a rule of its own."
);
}
if !allow_public {
println!(
"Note: streaming ports are open on Private/Domain networks only. On a network Windows \
@@ -1613,35 +1685,29 @@ const FW_DATA_PLANE_RULE: &str = "Punktfunk UDP (data plane)";
///
/// Program-scoped rather than a pinned port: it covers whatever port the session picks, needs no
/// second rule when the range moves, and cannot collide with another host (a pinned data port in
/// 47998-48010 would land on Sunshine/Apollo's GameStream range). The port rules above are kept as
/// they are — an install whose recorded exe path later moves still has its fixed ports open.
fn add_data_plane_firewall_rule(profile: &str) {
let exe = match std::env::current_exe() {
Ok(p) => p,
Err(e) => {
eprintln!(
"warning: could not resolve the host executable path ({e}) — skipping the \
data-plane firewall rule; streams may show a black picture behind a healthy \
connection on networks that need the client's hole-punch to open the path"
);
return;
}
/// 47998-48010 would land on Sunshine/Apollo's GameStream range). This rule is the pattern the
/// fixed-port rules above now follow too — it is only the `localport=` they keep and this one
/// cannot have.
///
/// `exe` is resolved once by the caller and shared; `None` means it could not be resolved, and this
/// rule is skipped rather than widened, because a program-less "any inbound UDP on any port" rule is
/// not a looser version of this — it is an open host.
fn add_data_plane_firewall_rule(profile: &str, exe: Option<&std::path::Path>) {
let Some(exe) = exe else {
eprintln!(
"warning: no host executable path — skipping the data-plane firewall rule; streams may \
show a black picture behind a healthy connection on networks that need the client's \
hole-punch to open the path"
);
return;
};
let ok = run_quiet(
"netsh",
&[
"advfirewall",
"firewall",
"add",
"rule",
&format!("name={FW_DATA_PLANE_RULE}"),
"dir=in",
"action=allow",
"protocol=UDP",
&format!("program={}", exe.to_string_lossy()),
profile,
],
);
let ok = run_netsh(&fw_add_rule_args(
FW_DATA_PLANE_RULE,
"UDP",
None,
Some(exe),
profile,
));
if ok {
println!(
"Firewall rule added: {FW_DATA_PLANE_RULE} (any UDP port for {}) [{profile}]",
@@ -1872,3 +1938,55 @@ fn maybe_boot_loop_rollback(restarts: u32, attempted: &mut bool) {
Err(e) => tracing::error!(error = %e, "failed to spawn the rollback installer"),
}
}
#[cfg(test)]
mod firewall_tests {
use super::*;
use std::path::Path;
/// Every fixed-port rule must carry BOTH `program=` and `localport=`. Dropping the program
/// scope is the regression that matters: the rule still works, streaming still works, and the
/// only visible difference is that any unprivileged process on the machine can bind those
/// ports and be reachable from the LAN without ever raising a Windows prompt.
#[test]
fn fixed_port_rules_are_scoped_to_the_program_and_the_ports() {
let exe = Path::new(r"C:\Program Files\Punktfunk\punktfunk-host.exe");
let args = fw_add_rule_args(
"Punktfunk UDP",
"UDP",
Some("47998-48010,9777,5353"),
Some(exe),
"profile=domain,private",
);
assert!(args.contains(&format!("program={}", exe.display())));
assert!(args.contains(&"localport=47998-48010,9777,5353".to_string()));
assert!(args.contains(&"dir=in".to_string()));
assert!(args.contains(&"action=allow".to_string()));
assert!(args.contains(&"profile=domain,private".to_string()));
assert_eq!(&args[..4], &["advfirewall", "firewall", "add", "rule"]);
}
/// The data plane is the one rule that legitimately has no port: its socket binds `0.0.0.0:0`
/// per session. It must therefore never lose its program scope — a program-less "any inbound
/// UDP on any port" rule is not a looser version of this rule, it is an open host.
#[test]
fn the_data_plane_rule_has_a_program_but_no_port() {
let exe = Path::new(r"C:\Program Files\Punktfunk\punktfunk-host.exe");
let args = fw_add_rule_args(FW_DATA_PLANE_RULE, "UDP", None, Some(exe), "profile=any");
assert!(args.contains(&format!("program={}", exe.display())));
assert!(
!args.iter().any(|a| a.starts_with("localport=")),
"the per-session data port is ephemeral — pinning one would close the others"
);
}
/// An unresolvable executable falls back to the old any-program rule rather than to no rule:
/// a looser rule still streams, a missing one is a black screen. Pinned so the fallback stays
/// deliberate rather than becoming an accident.
#[test]
fn a_missing_program_falls_back_to_the_port_only_rule() {
let args = fw_add_rule_args("Punktfunk TCP", "TCP", Some("47990"), None, "profile=any");
assert!(!args.iter().any(|a| a.starts_with("program=")));
assert!(args.contains(&"localport=47990".to_string()));
}
}
+15 -3
View File
@@ -17,9 +17,12 @@ list; the install guides quote the one or two lines that apply to each distro.
one](/docs/web-console#two-ports-not-one)).
- **`punktfunk-gamestream`** is needed only once you turn on Moonlight compat
(`PUNKTFUNK_GAMESTREAM=1` in `host.env` — [Moonlight](/docs/moonlight)).
- **Video needs nothing opened.** The data plane uses an ephemeral UDP port the *client* opens with a
hole-punch; the host streams back through the path the client opened, so only outbound UDP has to
be allowed (the default in both ufw and firewalld).
- **Video needs nothing opened on Linux.** The data plane uses an ephemeral UDP port the *client*
opens with a hole-punch; the host streams back through the path the client opened, so only
outbound UDP has to be allowed (the default in both ufw and firewalld). **Windows is the
exception** — it drops the client's hole-punch, which is why `service install` adds an inbound UDP
rule scoped to the host executable rather than to a port number (no fixed rule can cover a port
chosen fresh each session).
## Enabling the profiles
@@ -40,6 +43,15 @@ Stock Arch and Debian ship no firewall; Ubuntu installs ufw but leaves it inacti
and most Fedora-family spins run firewalld; CachyOS enables ufw. On **NixOS** the module's
`openFirewall = true` does all of this; on **Windows** the installer registers the rules.
<Callout type="warn">
**Windows: the rules are scoped to Punktfunk from 0.31.2 on.** Each rule names the executable that
listens on it as well as the port, so those ports are open to Punktfunk rather than to anything on
the machine that binds them first — before 0.31.2 they named only the port. The one thing this can
change for you is **5353**: if something else on that PC relied on Punktfunk's rule to answer
discovery, it now needs a rule of its own. `service install` re-applies the rules on every upgrade,
so a normal update is enough.
</Callout>
## Moving a port
Two are configurable, and both are how you share a machine with another streaming host — see
+3 -3
View File
@@ -10,7 +10,7 @@
"name": "MIT OR Apache-2.0",
"identifier": "MIT OR Apache-2.0"
},
"version": "0.31.0"
"version": "0.31.2"
},
"paths": {
"/api/v1/client-logs": {
@@ -6688,7 +6688,7 @@
},
"HostInfo": {
"type": "object",
"description": "Host identity and advertised capabilities (static for the life of the process).",
"description": "Host identity and advertised capabilities (static for the life of the process, except\n`local_ip`).",
"required": [
"hostname",
"uniqueid",
@@ -6734,7 +6734,7 @@
},
"local_ip": {
"type": "string",
"description": "Best-effort primary LAN IP."
"description": "Best-effort primary LAN IP, read fresh on every request — a host that started before its\nnetwork did (cold boot) reports `127.0.0.1` only until it actually has an address, and a\nhost that moves networks reports the new one. Poll it rather than caching it."
},
"os": {
"type": "string",
+55
View File
@@ -0,0 +1,55 @@
Wire-compatible with 0.31.x — everything you have already paired keeps working, and you can update one side at a time. Nothing here changes how a host and a client agree on what to send each other, so an old client on a new host, or the other way round, streams exactly as it does today.
This is a fix release, and the largest thing in it has been wrong on every Windows host since there have been Windows hosts: the port your video actually travels on was never opened in the firewall, so on any network that needs your client to knock first, the connection succeeded, every health signal stayed green, and the picture never arrived. Two more are about controllers that were connected, correctly identified, and doing the wrong thing anyway — Moonlight-compatible sessions on Windows were building the one kind of virtual controller Steam and most games cannot see, and on a Fire TV a DualSense's L2 was arriving as L1. The rest is Android: a console that could not be dismissed, a picture that sat in the top-left corner, and a television remote that could not reach Settings or your library. There is one new thing too — a library add-on can now tell your host which of its games are running, which is how a game your host cannot find on disk finally ends its own session.
## TL;DR
- **Windows: streams that connected and then stayed black.** The per-session video port was never covered by any firewall rule, on any Windows host, ever. Update with the installer and the rule is added for you — see *Before you update*.
- **Windows: controllers did nothing in Moonlight-compatible sessions.** Every client, every device, same result. Those sessions built a virtual controller most games cannot enumerate; they now build the same one ordinary Punktfunk sessions do.
- **Android: controllers Android has no layout for pressed the wrong buttons.** A DualSense or an Xbox Elite Series 2 over Bluetooth on a Fire TV had L2 arriving as L1, with Circle and R1 doing nothing at all.
- **Android: the controller console would not go away, and the picture sat in the corner.** Both fixed — and the console now carries its own switch for turning itself off.
- **A television remote can reach Settings and your library again.** Both were on controller face buttons a remote does not have.
- **Android: every setting that only exists in the controller console did nothing when you changed it.** Low latency, the phone's own rumble and motion, controller capture — all saved to somewhere nothing read back.
- **Nobara hosts: "Switch to Desktop" during a stream threw you straight back into the stream.** And on Steam Deck-style hosts, a mid-stream switch could leave Gaming Mode unable to start afterwards.
- **NixOS: the host installed a compositor that was not there.** The package shipped the small launcher and deleted the program it launches.
- **Games your host cannot see on disk can now end their own session.** A library add-on can tell the host which of its titles are running, which is the missing half for anything launched by handing off to another program — emulated titles, manually added ones.
## Before you update
- **Windows hosts: run the installer rather than replacing the program by hand.** The black-picture fix is a new firewall rule, and it is added by the host's own service-install step, which the installer runs for you on every update. If you run the host some other way, run `punktfunk-host service install` once as an administrator — or add an inbound rule for `punktfunk-host.exe` yourself. Uninstalling Punktfunk removes the rule again. Nothing on the client side needs doing.
- **NixOS hosts: expect a rebuild.** The compositor is now pinned to the exact version every other packaging channel ships, rather than to whatever the packages you happened to have carried, so its build inputs change. The binary cache added in 0.31.0 covers it if you have it configured.
- **Add-on authors: `@punktfunk/plugin-kit` 0.4.4 is what you depend on for the running-report above.** The call and its types were reachable only through a deep import path before, so nothing could reach them from the package root. Nothing else in the add-on contract moved.
- **Arch and SteamOS hosts: this release finally offers you a compositor upgrade you have been owed since 0.30.0.** The package's declared version had been left behind while the program inside it moved on, so your package manager saw nothing to do — which is why non-US keyboard layouts kept typing US characters on those machines even after the fix shipped.
## New
- **A library add-on can tell your host which of its games are running.** Your host normally works out whether a game is still going by looking for it on the machine — which needs something recognisable there to look for. A title that Playnite launches by handing off to another program has none: an emulated game, a manually added one, anything whose add-on records no folder. So the host never noticed those ending, your session stayed open on a game that had already exited, and anything set to happen when a game ends did not. The add-on knew the whole time, and can now say so. It is deliberately hard to get stuck on: a report expires unless it is repeated, so an add-on that crashes or is uninstalled stops speaking for your games within a minute and your host goes back to looking for itself. **This is the host's half.** It does something for you once an add-on sends those reports — the Playnite one is the first and updates separately, on its own schedule.
- **The controller console can switch itself off.** On an Android phone or tablet the console's own Settings now carries a Controller-optimized UI switch, so you can leave the console from inside it and land back on the touch interface immediately. Until now that switch existed only in the touch settings — which someone stuck inside the console had no way to reach, and since 0.31.0 the console is the only interface Android shows when a controller is attached. It appears only where switching off has somewhere to land: not on a television, and not on the desktop console, where turning it off would leave you with nothing.
## Improved
- **A host can tell a silent connection from a perfect one.** Packet loss is a proportion of what arrived, so a link delivering nothing and a flawless link both reported zero loss — and the host read the silence as perfection, complete with confident wording about the client's network in the log. Clients now also report how much they have actually received, so a host that is sending into a void says so, and names the video path rather than blaming the client. A client too old to answer gets a warning that says as much instead of a guess.
- **The Connected controllers page can be trusted again.** It read your button presses the same wrong way the stream did, so it agreed with the bug and confirmed a mistranslated pad as correct. It now shows what the controller actually reported alongside what Android made of it, and names the layout it resolved — which is also what makes an unrecognised pad fixable from a bug report, without the hardware in hand.
## Fixed
- **A Windows host could stream into a black screen with nothing anywhere saying why.** One field host sent 1,919 frames of video that were never seen while its own log blamed the client's network. The cause: the firewall rules Punktfunk installs cover fixed port numbers, but the video itself travels on a port chosen fresh for each session — a port no such rule can ever cover. So Windows dropped the client's opening knock on **every session on every Windows host**, including ones that worked; those worked only because nothing on the path needed the client to open the way first. When something did — many home routers, most mobile networks, anything doing address translation in front of the host — the control connection stayed perfectly healthy and the video went nowhere. The host now installs a rule that follows the program instead of the port number, so whichever port a session picks is covered. It is scoped to the program deliberately: pinning a port would have collided with Sunshine and Apollo on machines running both.
- **Controllers did nothing at all in Moonlight-compatible sessions on Windows**, reported across every client and every device someone tried, which is what showed it was not a client problem. Windows has two ways to present a virtual Xbox controller, and only one of them is visible to Steam, to games using the common input libraries, and to the Windows Game Controllers panel. Ordinary Punktfunk sessions moved to that one over a year of reports; Moonlight-compatible sessions kept the invisible one purely because both were reached by the same internal name. They now make the same choice, and switching them both back is still one setting if you need it.
- **Controllers pressed the wrong buttons on devices Android has no layout for.** Reported from a Fire TV Stick 4K Max with a DualSense and an Xbox Elite Series 2, both over Bluetooth, both identified by name and both wrong under the fingers: L2 arrived as L1, and Circle and R1 were dropped entirely. Android names a controller's buttons from a file matched to that exact model, and when there is no such file it falls back to guessing by the order the buttons appear in the controller's own report — which is only right if that order happens to match. Neither pad has a file on a Fire TV. Punktfunk now reads the button's position in the controller's report directly, which is what the controller means rather than what the guess made of it, and does so only where the guess was in play, so a device that already worked is untouched. Triggers get the same treatment: on a pad Android never mapped they sit on raw axes, which is why pulling a trigger could swing the right stick instead. Whether a trigger rests at zero or at the bottom of its range is now measured from the device rather than assumed. **Not yet confirmed on the reporter's own hardware** — the fix is proven against what those two controllers report, but a Fire TV is the test that settles it, so please say if yours still misbehaves. One thing stays broken and cannot be fixed here: the Xbox pad's Guide button is delivered by Android as the Home key, which it never passes to an app.
- **The controller console could not be dismissed on some Android phones.** Turning it off is a matter of no controller being attached, and the app decided that by asking whether a device claimed to be a gamepad — which is the right question for routing a button press and the wrong one for knowing a pad is in the room. Manufacturer game-mode overlays and gaming-phone shoulder triggers make that claim without being controllers, and one of them was enough to pin the console on forever, because a pad that was never there can never be unplugged. The app now also asks whether the hardware is there behind the claim — a stick, a directional pad or real face buttons — and the new switch above is the guaranteed way out either way.
- **Changing an Android-only setting inside the controller console did nothing.** Low latency, the phone's own rumble and motion controls, the controller capture switches and the console's own display mode could each be changed in the console, and each quietly went nowhere: the console filed those settings one level deeper than the settings file keeps them, so nothing ever read one back. The row showed its own default, your change came back as the value it had just been handed, and nothing downstream ever heard that anything had moved — which is also why the new off switch above needed this fixed before it could work at all. Settings written by the previous build carry a dead wrapper; it is discarded the next time anything is saved rather than followed around for the life of the install.
- **The glow behind a focused card was squarer than the card.** The halo grows the card by a few units on every side but kept the card's own corner radius, and a shape grown outward only stays parallel if its corners grow with it — so the two arcs stopped sharing a centre and the corners read as a badly drawn outline instead of light spilling out from behind. Every card in the console goes through that path: the home tiles, the library grid, the coverflow and the collections deck.
- **The picture sat in the top-left corner of an Android screen.** The video layer took the size of the view once, at the moment it was created — and the stream screen hides the system bars and expands into the display cutout a frame or two later, each of which grows the view underneath a layer that never hears about it. The size is now read fresh for every frame, which also means rotating the phone and multi-window both simply work.
- **Settings and your game library could not be reached with a television remote.** A remote has a directional pad, OK and Back, and the console had put Settings and the library shelf on controller face buttons it does not have — so on an Android TV neither could be opened at all. Pressing down on the home row now opens Settings, and the library has joined each machine's own options menu, which is where the documentation had been telling you to find it all along. The on-screen hints name whichever route the device in your hand actually has.
- **On Nobara, "Switch to Desktop" during a stream threw you straight back into the stream.** The switch takes the picture away, Punktfunk reads that as a problem and rebuilds the session, and the rebuild put Gaming Mode back over the desktop that was trying to start. 0.31.0 changed how the host takes Gaming Mode over and left nothing watching for the switch, which Steam Deck and Bazzite machines never noticed because they follow it another way — Nobara has neither. Also fixed on both: a mid-stream switch used to leave Gaming Mode replaced by a placeholder, so the machine's own "Return to Gaming Mode" afterwards started something that did nothing. Both routes now hand the machine back intact.
- **On NixOS the host installed a compositor consisting only of its launcher.** The packaging trims the installed programs down to the one that is needed and had been matching it by name — but the program with that name is a small launcher that sets things up and then runs the real compositor, which the trim deleted. What shipped was a launcher pointing at nothing. That is the true cause of the version banner printing nothing, the marker "missing from the binary", and every HDR-related NixOS failure chased alongside them. Separately, NixOS was the only channel not pinning which compositor version it patches, so a package update could and did break the build outright — and since HDR is on by default, that failure landed on anyone enabling the host at all.
## Thanks
Every fix above came from someone describing precisely what did not happen. The Windows black screen was found in two field logs from a host that looked healthy in every respect; the dead controllers in Moonlight-compatible sessions were reported with the detail that made them findable, that it reproduced on every client and device tried; the Fire TV report named which button arrived as which; and one Android user sent two reports in a day that turned out to be the same mistake made twice. Thank you — that is what makes a fault findable rather than merely believable.
## For developers
Protocol, ABI, driver and embedder detail — including the version table — is in [CHANGELOG.md](https://git.unom.io/unom/punktfunk/src/tag/v0.31.1/CHANGELOG.md).
The short version: the streaming protocol, the embedding interface, the driver protocol and the gamepad channel are all exactly where 0.31.0 left them, so nothing needs rebuilding, re-pairing or re-packaging in any direction. The management API and the add-on toolkit each gain one thing by pure addition — the running-report route above, and the toolkit call for it in 0.4.4 — and an add-on that ignores both keeps working unchanged. One control message is added to the wire — clients reporting how much they have received, which is what lets a host tell a dead video path from a clean one — but it takes a spare message number rather than changing an existing message, and an older host on the other end ignores it after one note in its log.
+42
View File
@@ -0,0 +1,42 @@
Wire-compatible with 0.31.x — everything you have already paired keeps working, and you can update one side at a time. Nothing here changes how a host and a client agree on what to send each other, so an old client on a new host, or the other way round, streams exactly as it does today.
This is a fix release, and most of it continues the hunt 0.31.1 started: a stream that connects, reports itself perfectly healthy at every gauge, and shows you a black screen. Two more causes end here, and both are about which of your host's addresses it used — video that left the host by whichever network connection the machine happened to prefer rather than the one your client actually dialled, and a host that started up faster than its own network and then spent the rest of the day telling everyone to connect to an address that only ever means "this machine". The other half is Windows security: the firewall rules Punktfunk installs named ports but not programs, which left those ports open to anything running on that PC — they name Punktfunk now, and that is the one change here that can affect another program on the same machine. On Android, the controller fix from 0.31.1 turned out to be firing on controllers that never needed it, breaking buttons that had been correct all along.
## TL;DR
- **A host with two ways to reach your client streamed into a black screen.** Ethernet and Wi-Fi both connected, or a VPN adapter installed, was enough: the video left by whichever one the machine preferred, and your client discarded every packet of it. Nothing else about the session was wrong, which is exactly why it was so hard to see.
- **A host that started before its network was ready never recovered.** It advertised an address meaning "this machine", so clients listed it and could not connect to it, Moonlight-compatible sessions could not stream, and Wake-on-LAN silently stopped working. Restarting the host was the only cure; now there is nothing to cure.
- **Windows: Punktfunk's firewall rules were open to every program on your PC.** They named only port numbers, so any program — with no administrator rights and no prompt — could take one of those ports and be reachable from your network through a rule meant for Punktfunk. **Read *Before you update*: one thing on that machine may now need a rule of its own.**
- **Android: 0.31.1's controller fix broke controllers that were already correct.** On a GameSir G8+ and an Xbox Elite Series 2 over Bluetooth, X answered Y, Y answered the left shoulder, and both shoulders answered menu buttons.
- **Your host now follows its own address when it changes** — a new lease from your router, or a machine moved between Wi-Fi and Ethernet — instead of announcing the address it had at startup forever.
## Before you update
- **Windows hosts: run the installer rather than replacing the program by hand.** The firewall fix rewrites the rules Punktfunk installs, and that happens in the host's own service-install step, which the installer runs for you on every update. If you run the host some other way, run `punktfunk-host service install` once as an administrator. Skipping it leaves the old wide-open rules in place; nothing on the client side needs doing.
- **Windows hosts: something else on that machine may need its own firewall rule now.** Punktfunk's rule for the discovery port (5353) used to be open to every program, so anything else on that PC that answers discovery — another streaming host, a media server, a printer or scanner utility — could have been reachable through Punktfunk's rule without ever having one of its own. That ends with this release. If something else on the machine stops being discoverable after you update, give it its own rule. The installer prints a note saying exactly this while it works.
## Improved
- **Your host keeps up with its own address instead of freezing it at startup.** The address a host publishes for clients to dial was worked out once, when the host process started, and then never looked at again. Now it is re-read as things change and the published address is updated to match — so a new lease from your router, or a laptop host carried from Wi-Fi to Ethernet, no longer leaves your clients dialling somewhere the host has not been for hours. This is the general form of the cold-boot fix below, and it covers the cases nobody had got round to reporting yet.
- **When a stream does go black, the host's log now tells you the truth about it.** The message it printed used to name a cause with real confidence — and was wrong often enough to send people to the wrong place entirely, including at least one investigation that went to the firewall while the actual fault was the network card. It no longer asserts a cause it cannot know, and it now records which network connection the video is actually leaving by, and says so plainly when that is not the one the client arrived on. That single line is what turns the black screen above from a mystery into something a log answers.
## Fixed
- **A host with more than one live path to your client sent the video down the wrong one, and you got a black screen with every indicator green.** Two network connections up on the same network — the very common Ethernet-and-Wi-Fi-both-on — or a VPN or overlay adapter that claims to know a better route, and the host let the machine choose which one the video left by. That choice was made with no reference at all to how your client had reached it. Your client only listens for video from the address it dialled, so it threw away everything arriving from the other one, in the part of the system that counts nothing and reports nothing. Meanwhile the connection was made, sound and controller input flowed perfectly, and the host's own loss figure sat at zero — because loss is measured over packets that arrived, and none did. The host now sends the video from the same address the client reached it on.
- **A host that started before its network was ready advertised itself as unreachable, and stayed that way until it was restarted.** Cold-booting a machine is a race, and the host wins it: it starts without waiting for the network, asks which address it should publish, gets no answer because there is no network yet, and falls back to the address that means "this machine and nothing else". Then it kept that answer for the entire life of the process. Everything that reads that address broke together — the host appeared in your client's list but could not be connected to, Moonlight-compatible sessions were handed the same useless address after launching a game, Wake-on-LAN quietly stopped working because the host could no longer identify the network hardware to record for it, and the web console displayed the wrong address to anyone who looked. Users found the workaround themselves, which was to restart the host once the machine had settled. The host now refuses that fallback answer entirely: if the usual method cannot say which address to use, it takes the first real network address it can find, which exists as soon as the network card is configured — well before the machine finishes working out how to route anything.
- **Windows: the firewall rules Punktfunk installs opened those ports to every program on the machine, not to Punktfunk.** Each rule named a port and nothing else, and a rule like that admits whatever is listening on that port — Punktfunk or otherwise. Nothing about it required administrator rights to exploit: taking a high-numbered port on Windows needs no privileges at all, so any program that started first could sit on one of Punktfunk's ports and be reachable from your whole network. Worse, it happened without any of the usual signs, because the pop-up asking whether to let a program communicate on your network is precisely what a matching rule suppresses — Punktfunk's rule was answering that question on another program's behalf. Every rule now names the program that is genuinely meant to be listening on it, and keeps the port restriction as well, so both must match. The affected ports were the streaming, discovery, management and console ports. If Punktfunk cannot work out its own location on disk it keeps the old broader rule rather than leaving you with no rule at all, since a rule that is too generous still streams and a missing one is a black screen.
- **Android: controllers that had always worked started pressing the wrong buttons.** This is a regression from 0.31.1, reported the same day on a GameSir G8+ and an Xbox Elite Series 2: X answered Y, Y answered the left shoulder, and the two shoulder buttons answered menu buttons, with everything else correct. 0.31.1 fixed controllers Android has no layout file for by reading each button's position in the controller's own report instead of trusting Android's guess — the right fix, applied to too many controllers. It decided which controllers needed it by asking what the device *claimed* to have, and that claim turns out to be true of any controller with six or more buttons, including every controller that was already perfectly correct. So it corrected pads that needed no correcting, and moved their buttons off the marks. It is now decided on the triggers instead: a controller that describes its triggers properly is one Android has a real layout for, and it is left completely alone — no correction to its buttons or its sticks. That is the same signal Moonlight uses for the same decision, and it matches the reports precisely, down to the fact that the very same model needed correcting on a Fire TV and was broken by it here: an Xbox Wireless Controller describes its triggers one way after a firmware update and the other way before it, and only the older one was ever wrong.
## Known issue
- **A DualSense with a dead Triangle button is not fixed here.** It was reported alongside the two controllers above and looks related, but it is not the same fault — Triangle reaching neither the stream nor Punktfunk's own controller display is a different failure from a button arriving as the wrong one, and nothing in the fix above produces it. The Connected controllers page prints exactly what each press reports; that line from an affected pad is what will pin it down.
## Thanks
Every fix in this release came from someone reporting what actually happened rather than what they assumed. Both black-screen causes were found in field logs from hosts that looked entirely healthy — and the firewall hole was reported by a user on the same day, immediately after the first of those fixes cleared their black screen and left them looking at the rules. The Android controller regression came back within a day of the release that caused it, from two people who named which button answered which, which is the difference between a report that can be fixed and one that can only be believed. Thank you.
## For developers
Protocol, ABI, driver and embedder detail — including the version table — is in [CHANGELOG.md](https://git.unom.io/unom/punktfunk/src/tag/v0.31.2/CHANGELOG.md).
The short version: nothing versioned moves at all. The streaming protocol, the embedding interface, the driver protocol, the gamepad channel and the add-on contract are exactly where 0.31.1 left them, no message or function changed shape, and no header, package or plugin needs rebuilding, re-pairing or re-publishing in any direction. Two things worth knowing: on Windows the firewall rules provisioned at install are now scoped to the executable that listens on each port, which is the one change here that can affect another program on the same machine; and the host's reported address in the management API is now read fresh on every request instead of being fixed for the life of the process, so poll it rather than caching it.
+4
View File
@@ -0,0 +1,4 @@
• Fixes every console-only setting doing nothing when you changed it — low latency, rumble, motion and controller capture all went nowhere.
• Fixes controllers pressing the wrong buttons on boxes Android has no layout for — on a Fire TV a DualSense's L2 arrived as L1.
• The controller console can now be switched off from inside it, and a TV remote can reach Settings and your library.
• The picture no longer sits in the top-left corner.
+2
View File
@@ -0,0 +1,2 @@
• Fixes controllers pressing the wrong buttons after the last update — on a GameSir G8+ and an Xbox Elite Series 2 over Bluetooth, X answered Y and both shoulders answered menu buttons.
• The button correction from the last release now applies only to controllers Android has no layout for, so a pad that worked before this update is left exactly as it was.
+6 -1
View File
@@ -19,7 +19,7 @@ pkgname=punktfunk-gamescope
# bump it with the marker so pacman sees a new version when only our patches moved.
_gsver=3.16.25
_gsrev=5fb8dce4a09d0a68d097b9faf9513782106bc843
pkgver="${_gsver}.pfhdr7"
pkgver="${_gsver}.pfhdr8"
# 2: patch 0006 (never destroy the Vulkan device/output at exit). No capability moved, so the
# `.pfhdrN` level deliberately stays put — see README.md.
# 3: pin moved 8c676c39 -> 5fb8dce4 (3.16.25-1 -> 3.16.25-11), which brings upstream's own
@@ -45,6 +45,11 @@ pkgver="${_gsver}.pfhdr7"
# racing steamcompmgr's vulkan_screenshot on the same device — a SIGSEGV precisely in the linger
# window, so a kept display was dead and reconnect lost the game session. No capability the host
# probes for, but "reconnect lost my game" triage has to read the difference off the banner.
#
# pfhdr8 / rel 1: patch 0010 gives the seat's stub keyboard the compiled `XKB_DEFAULT_*` keymap, so
# a session follows the box's configured layout instead of typing US characters. The host PROBES the
# banner for this one (`>= 8`), so the level HAS to move here too — leaving it at pfhdr7 made pacman
# see no upgrade at all, and every Arch/SteamOS host kept a compositor the probe rejects.
pkgrel=1
pkgdesc="gamescope with 10-bit BT.2020/PQ PipeWire capture, for punktfunk HDR streaming"
arch=('x86_64' 'aarch64')