Compare commits

...
Author SHA1 Message Date
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 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 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
enricobuehler c9a76287d8 fix(console-ui): the layer under a card takes the card's corner plus its own outset
ci / bun-nix (pull_request) Successful in 24s
ci / docs-drift (pull_request) Successful in 42s
ci / web (pull_request) Successful in 1m6s
ci / docs-site (pull_request) Successful in 1m15s
ci / rust-arm64 (pull_request) Successful in 2m3s
android / android (pull_request) Successful in 5m34s
ci / rust (pull_request) Successful in 5m45s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m48s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 3m9s
The focus halo grows the card's rect by 4 design units on every side but drew
it with the card's own corner radius. A shape grown by `d` keeps its corners
parallel to the original's only if its radius grows by `d` too — otherwise the
two arcs stop sharing a centre. So the halo came out squarer than the card it
sits under: clean along the edges, visibly misaligned at the four corners,
where it read as a badly drawn outline rather than as light spilling out.

Same rule applied to `panel_highlight`, which pulls in half a unit and kept the
full radius. `drop_shadow` only offsets, so its geometry was already right, and
the collections plate uses `RRect::with_outset`, which adjusts the radii itself.

Every card in the console goes through these two helpers — the home tiles, the
library grid, the coverflow, the collections deck.
2026-08-20 21:44:55 +02:00
enricobuehler cbd3d02817 fix(clients/android): the console's Android rows were nested where serde flattens them
Turning "Controller-optimized UI" off in the console did nothing: the console
stayed up, because the setting never left the console.

`trust::Settings::extra` is `#[serde(flatten)]`, so the `android.*` keys are
TOP-LEVEL keys of the settings document, beside `width` and `codec`.
`ConsoleJson` wrote and read them nested under an `"extra"` object instead.
Serde put that whole object into the map under the literal key `"extra"`, so
no console row ever found `android.gamepad_ui_enabled` — the row showed its
own default, and the value the console saved came back to Kotlin as the one
Kotlin had just sent. `applySettings` then saw no change, raised no callback,
and `App` never recomputed `gamepadUiActive`.

Every Android-only row rode the same broken path: low latency, phone
rumble/gyro, SC2 and DualSense capture, and the console-UI mode picker.

A store written by the nesting build carries the dead wrapper; it is dropped
on the next write rather than echoed for the life of the install.

The new test pins the shape from both sides. A round-trip alone could not have
caught this — both halves agreed on the same wrong nesting, which is exactly
how it survived review.
2026-08-20 21:44:46 +02:00
enricobuehler 2be444b329 Merge pull request 'A provider plugin can report which of its titles are running' (#361) from worktree-playnite-runstate into main
ci / docs-site (push) Successful in 1m14s
ci / web (push) Successful in 1m19s
ci / bun-nix (push) Successful in 1m24s
ci / docs-drift (push) Successful in 27s
deb / build-publish-gamescope (push) Successful in 43s
ci / rust-arm64 (push) Successful in 3m26s
deb / build-publish-client-arm64 (push) Successful in 1m20s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 13s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 1m7s
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 15s
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 15s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 15s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 14s
deb / build-publish (push) Successful in 4m16s
nix / flake (push) Successful in 20m48s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 57s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m59s
ci / rust (push) Successful in 8m24s
arch / build-publish (push) Successful in 8m31s
deb / build-publish-host (push) Successful in 7m6s
docker / builders-arm64cross (push) Successful in 15s
docker / deploy-docs (push) Successful in 39s
plugin-kit-publish / publish (push) Successful in 41s
android / android (push) Successful in 12m41s
windows-host / package (push) Successful in 13m2s
windows-host / winget-source (push) Skipped
deb / smoke-install (push) Successful in 4m17s
windows-host / canary-manifest (push) Successful in 31s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m1s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m45s
Reviewed-on: #361
2026-08-20 19:22:10 +00:00
enricobuehler 669a1bc0ce The video data port was never open, and nothing could tell us (#360)
ci / docs-drift (push) Successful in 29s
ci / bun-nix (push) Successful in 53s
ci / docs-site (push) Successful in 1m13s
ci / web (push) Successful in 1m17s
apple / swift (push) Failing after 1m34s
apple / distribute (push) Skipped
apple / screenshots (push) Skipped
ci / rust-arm64 (push) Successful in 1m52s
deb / build-publish-gamescope (push) Successful in 1m9s
decky / build-publish (push) Successful in 1m5s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 22s
deb / build-publish-client-arm64 (push) Successful in 1m55s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 13s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 16s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 13s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 44s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 27s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m59s
deb / build-publish-host (push) Successful in 6m4s
ci / rust (push) Successful in 7m23s
arch / build-publish (push) Successful in 8m11s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 16s
docker / builders-arm64cross (push) Successful in 14s
docker / deploy-docs (push) Successful in 39s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 8m14s
android / android (push) Successful in 10m46s
flatpak / build-publish (push) Successful in 7m15s
deb / build-publish (push) Successful in 5m6s
deb / smoke-install (push) Successful in 3m49s
windows-host / package (push) Successful in 13m18s
windows-host / winget-source (push) Skipped
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m56s
windows-host / canary-manifest (push) Successful in 27s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m4s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m54s
Merged with `apple / swift` red: the Mac runner's volume is out of space (594 "No space left on device" lines; the only distinct error is "You can't save the file because the volume 'Macintosh HD' is out of space"). It passed on the parent commit 1280f697 and the only delta since was one line in scripts/ci/docs-undocumented-env-baseline.txt — Swift compilation of the Apple client cannot be affected by a Rust host firewall rule, a core control message, or a CI text baseline. A rerun hit the same wall, so the mac-mini-1 disk needs the owed cleanup.

Every check that can validate this change is green: ci/rust (480 core tests), ci/rust-arm64, android, docs-drift, web, docs-site, bun-nix, and both windows-client legs. The Windows HOST build — which the PR checks never run, and which is the only thing that compiles the cfg(windows) firewall function — was dispatched separately on the branch and passed, including "Clippy (host + tray, Windows)".
2026-08-20 18:48:28 +00:00
enricobuehler 8ff6fe6093 fix(host): regenerate the API spec, and stop the runstate tests colliding
ci / bun-nix (pull_request) Successful in 23s
ci / web (pull_request) Successful in 1m9s
ci / docs-site (pull_request) Successful in 1m13s
ci / docs-drift (pull_request) Successful in 1m5s
ci / rust-arm64 (pull_request) Successful in 2m54s
android / android (pull_request) Successful in 5m23s
ci / rust (pull_request) Successful in 7m17s
nix / flake (pull_request) Successful in 7m49s
`api/openapi.json` (and its docs-site copy) gain exactly the new route and its
three schemas — nothing else moved, which is the check worth doing on a
regenerated spec.

The test fix is one the tests found themselves, on the first run in an
environment that actually executes them: all three shared the provider id
`playnite` and cleared the whole process-global table between cases, so under
parallel scheduling they flipped each other's answers — `omitted_is_not_running`
read `None` for a title another test had just wiped. Each now takes ids only it
uses and forgets only its own row, which also retires the blunt `reset()` that
made the collision possible.
2026-08-20 20:38:08 +02:00
enricobuehler 1758266bda chore(plugin-kit): export the running-report surface, and bump to 0.4.4
`reportRunning` and its two types were reachable only through the deep
`./reconcile.js` path — `index.ts` re-exports an explicit list, not a star — so
no plugin could import them from the package root the way it imports every
other provider symbol.

Version bumped because it is a published package and the addition is what a
consumer would depend on; the playnite plugin deliberately does NOT, calling the
route through the untyped host seam instead so it is not gated on this publish.
2026-08-20 20:38:08 +02:00
enricobuehler d5fb1e4479 feat(host): 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. Playnite knew the whole time.

New `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 90s
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, exactly as today.

`LeaseKind::Reported` is the lease that follows from it. `open` reaches it when
the spec is empty and a provider speaks for the id, and — the load-bearing part
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 because
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.

The route is the plugin lane's, like the reconcile. 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 throw away the
liveness of every other running title.

plugin-kit gains `ProviderClient.reportRunning`; a 404 from an older host means
"this host tracks games by scanning".
2026-08-20 20:38:08 +02:00
enricobuehler ea3c9e1202 ci(docs-drift): baseline the new control-message constant
ci / bun-nix (pull_request) Successful in 40s
ci / web (pull_request) Successful in 1m14s
ci / docs-drift (pull_request) Successful in 27s
ci / docs-site (pull_request) Successful in 1m23s
ci / rust-arm64 (pull_request) Successful in 2m23s
ci / rust (pull_request) Successful in 6m15s
android / android (pull_request) Successful in 6m42s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 3m22s
apple / swift (pull_request) Failing after 1m35s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m49s
`check-docs-drift.sh` scans for `PUNKTFUNK_*` identifiers and asks that
each be documented in docs-site or explicitly baselined. It cannot tell
an env knob from a cbindgen-exported `#define`, so the new
`PUNKTFUNK_MSG_DELIVERY_REPORT` header constant tripped it.

It is a wire message type byte, not a knob an operator can set — same as
every other `PUNKTFUNK_MSG_*`, all of which are already in the baseline.
Added in sorted position beside them.
2026-08-20 20:27:37 +02:00
enricobuehler 685c4bd99a Merge pull request 'A pad that was never there kept the console UI on, and the picture sat in the corner' (#359) from worktree-android-gamepad-ui-and-corner into main
ci / rust-arm64 (push) Successful in 2m19s
ci / web (push) Successful in 1m28s
ci / bun-nix (push) Successful in 28s
ci / docs-drift (push) Successful in 26s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 17s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 17s
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 14s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 12s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 17s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 17s
ci / docs-site (push) Successful in 2m10s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 29s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 32s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 18s
docker / builders-arm64cross (push) Successful in 25s
ci / rust (push) Successful in 8m23s
docker / deploy-docs (push) Successful in 1m43s
android / android (push) Successful in 10m20s
2026-08-20 18:22:33 +00:00
enricobuehler 0519b057d5 fix(clients/android): a pad that was never there, and a picture in the corner
ci / rust-arm64 (pull_request) Successful in 3m37s
ci / web (pull_request) Successful in 1m55s
android / android (pull_request) Successful in 8m52s
ci / bun-nix (pull_request) Successful in 19s
ci / docs-site (pull_request) Successful in 2m25s
ci / docs-drift (pull_request) Successful in 23s
ci / rust (pull_request) Successful in 11m0s
Two field reports from one Android user, with one shape between them: a
decision taken once, at a moment when the answer was still wrong, and never
revisited.

The console UI could not be dismissed. "With a controller" asks whether a pad
is attached, and the client answered that with `isPad` — does this device's
source class include gamepad or joystick. That is the right question for
ROUTING an event and the wrong one for presence: devices publish inputs that
claim the source class while being no such thing (OEM game-mode overlays, the
gaming-phone shoulder triggers), and one of them is enough to pin the console
UI on forever, because a pad that was never there can never disconnect.

`pads()` now filters on `looksLikeController`: the source claim AND hardware
behind it — a stick, a HAT, or the A/B face buttons — on a device the platform
did not synthesize itself. The claim is cheap; the hardware is not. `isPad`
keeps its looser meaning for the event lane, where it is correct. It is not a
complete defence (an OEM device that declares BTN_GAMEPAD and two axes is
indistinguishable from a pad at this layer), so the master switch stays the
guaranteed way out — and the Controllers screen still lists everything real in
one column or the other, which is where someone looks when the client's idea of
"a pad is attached" disagrees with the room.

The picture sat in the top-left corner. The ASurfaceControl layer composites
into the SurfaceView's on-screen rectangle, read once at `surfaceCreated` — but
the stream screen hides the system bars and switches the window to draw into
the display cutout a frame or two later, and each of those grows the view under
a surface that is never recreated. The layer went on painting at the size it
started with, anchored at the origin. It passed on glass because a device whose
bars were already hidden when the surface arrived never sees the gap.

The size is now live: a packed atomic on the session handle, seeded by
`nativeStartVideo`, re-reported by `nativeVideoSurfaceSize` from every
`surfaceChanged`, and read by the layer before each present. One atomic load per
frame, and rotation and multi-window come along for free.

Verified: `:kit:cargoNdkClippy` (arm64 + armv7, deny warnings), `:kit:` and
`:app:` unit tests, and the native crate's own suite. The new JNI symbol is
exported in the built `.so`.
2026-08-20 20:07:57 +02:00
enricobuehler 33b029695f Merge pull request 'The gamescope prune deleted the compositor and shipped only its launcher' (#355) from gamescope-show-version-banner into main
ci / rust-arm64 (push) Successful in 1m48s
ci / web (push) Successful in 1m2s
ci / bun-nix (push) Successful in 30s
ci / docs-drift (push) Successful in 24s
ci / docs-site (push) Successful in 1m23s
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 13s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 15s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 18s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 13s
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 16s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 33s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 28s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 15s
docker / deploy-docs (push) Successful in 42s
docker / builders-arm64cross (push) Successful in 27s
ci / rust (push) Canceled after 16m22s
nix / flake (push) Successful in 28m29s
Reviewed-on: #355
2026-08-20 18:00:07 +00:00
enricobuehler 1f6f01cb76 Merge pull request 'The in-stream "Switch to Desktop" went unwatched on Nobara, and both distro families leaked an idled Game Mode' (#358) from worktree-nobara-gamemode-switch into main
ci / web (push) Successful in 1m25s
ci / rust-arm64 (push) Successful in 2m36s
ci / bun-nix (push) Successful in 23s
ci / docs-site (push) Successful in 1m43s
ci / docs-drift (push) Successful in 26s
ci / rust (push) Canceled after 5m55s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (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
deb / build-publish-gamescope (push) Successful in 52s
deb / build-publish-client-arm64 (push) Successful in 1m30s
deb / build-publish (push) Successful in 4m43s
deb / build-publish-host (push) Successful in 5m38s
arch / build-publish (push) Successful in 9m37s
android / android (push) Successful in 11m1s
windows-host / package (push) Successful in 13m55s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 45s
deb / smoke-install (push) Successful in 5m43s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 23m26s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 24m6s
2026-08-20 17:51:44 +00:00
enricobuehler fade2f7af3 Merge remote-tracking branch 'origin/main' into worktree-nobara-gamemode-switch
ci / bun-nix (pull_request) Successful in 33s
ci / web (pull_request) Successful in 1m12s
ci / docs-site (pull_request) Successful in 1m21s
ci / docs-drift (pull_request) Successful in 48s
ci / rust-arm64 (pull_request) Successful in 1m55s
android / android (pull_request) Successful in 7m59s
ci / rust (pull_request) Successful in 16m58s
2026-08-20 19:48:15 +02:00
enricobuehler 6e4cc335c5 Merge pull request 'The console can switch itself off, and a TV remote can reach everything it offers' (#354) from worktree-console-ui-off-toggle into main
ci / bun-nix (push) Successful in 25s
ci / docs-drift (push) Successful in 31s
deb / build-publish (push) Canceled after 46s
ci / web (push) Successful in 1m36s
ci / docs-site (push) Successful in 1m48s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 25s
ci / rust-arm64 (push) Successful in 2m47s
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 24s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 18s
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
deb / build-publish-client-arm64 (push) Successful in 1m53s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 14s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m6s
android / android (push) Canceled after 5m17s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m27s
arch / build-publish (push) Canceled after 5m27s
ci / rust (push) Canceled after 5m33s
deb / build-publish-host (push) Canceled after 4m21s
deb / build-publish-gamescope (push) Canceled after 19s
deb / smoke-install (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 15s
docker / builders-arm64cross (push) Canceled after 0s
docker / deploy-docs (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 2m4s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 6m48s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Failing after 12m41s
flatpak / build-publish (push) Successful in 7m18s
Reviewed-on: #354
2026-08-20 17:46:35 +00:00
enricobuehler d9662c010d fix(gamescope): 0.31.0's idled takeover left nothing watching for "Switch to Desktop"
The managed takeover stopped stopping the display manager and started idling the
box's autologin session instead (c2f5e91b). That commit also deleted the two lines
the old DM stop carried — `record_session_select_baseline()` and
`STOPPED_DM = Some(dm)` — and 38a0f54b then removed every remaining writer of that
static. The in-stream switch gate in `create_managed_session` still reads it, so
`honor_session_select_switch` became unreachable code on every box.

Bazzite never noticed, for two reasons measured on `.41`: its `os-session-select`
is a thin wrapper over `steamosctl` D-Bus calls and writes NO sentinel (verified —
`~/.config/steamos-session-select` does not exist before or after a completed
switch), and `is_steam_htpc_platform()` defaults the mid-stream session watcher ON
for bazzite/steamos, which follows the switch by itself.

Nobara gets neither. `ID=nobara` matches no HTPC default, so no watcher; and its
ChimeraOS-layout `os-session-select` DOES write the sentinel, which was the only
thing the host had to see the switch by. So on Nobara the user's "Switch to
Desktop" went entirely unhandled: the capture loss it causes drives a rebuild, the
rebuild relaunches game mode over the booting desktop, and the stream is thrown
straight back in — the 2026-07-24 field report, reopened by a user 2026-08-20.

Arm the gate on the takeover that actually exists now. `takeover_idled()` reads
IDLE_DROPIN_ARMED, the idle drop-in re-baselines the sentinel when it goes in, and
STOPPED_DM is documented as what it has become: adoption-only state for a takeover
stranded by a pre-0.31.0 host. The hand-back keeps that host's DM restore + switch
replay (now `replay_switch_under_restored_dm`) but runs it only for such an adopted
takeover — a 0.31.0 takeover leaves the DM up precisely so the OS does that work
itself, and on the VM it does: `Updated user selected session to plasma`, then
Plasma.

Both hand-back paths also give the box its own Game Mode back, which neither did.
The takeover replaces that session's ExecStart with a sleep, and a mid-stream
switch is the one exit that leaked it — the disconnect restore sweeps it, but a
switch is not a disconnect. Measured on Bazzite `.41`: after a completed switch to
KDE the unit still carried `ExecStart=/usr/bin/sleep infinity`, so "Return to
Gaming Mode" would have started a unit that does nothing. That is the same barred
way back `release_autologin_mask` already exists to prevent for the mask this
drop-in replaced, so it goes there — covering the watcher-driven distros — and in
`honor_session_select_switch`, covering the sentinel-driven ones.

Verified on the Nobara VM (123 on home-node-3) by driving the real path against
live systemd: takeover -> real `steamos-session-select plasma` -> rebuild. The
rebuild refuses to relaunch game mode, the idle drop-in is gone, and the box
arrives in KDE. Backed out to the 0.31.0 shape on the same box it fails at "the
switch did not advance the sentinel" — the blindness itself. The extended
`the_mask_comes_off_only_when_the_box_takes_itself_back` covers the watcher path
against a live user manager, and fails without the hand-back.
2026-08-20 19:33:19 +02:00
enricobuehler 2b0913cf53 Merge pull request 'A DualSense and an Elite Series 2 on a Fire TV pressed each other's buttons — Android reads the scancode now' (#357) from worktree-android-pad-hid-remap into main
docker / builders-arm64cross (push) Successful in 13s
ci / docs-drift (push) Successful in 31s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 18s
ci / web (push) Successful in 1m22s
ci / docs-site (push) Successful in 1m15s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 19s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 21s
ci / rust-arm64 (push) Successful in 2m31s
ci / bun-nix (push) Successful in 2m18s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 56s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 34s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 29s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 12s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 38s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 31s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m27s
docker / deploy-docs (push) Successful in 43s
ci / rust (push) Successful in 6m35s
android / android (push) Successful in 8m48s
2026-08-20 17:18:46 +00:00
enricobuehler 1280f697be fix(host,core): the video data port was never open, and nothing could tell
apple / swift (pull_request) Successful in 2m16s
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 3m15s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m53s
ci / rust-arm64 (pull_request) Successful in 2m38s
ci / web (pull_request) Successful in 1m29s
ci / docs-site (pull_request) Successful in 58s
ci / docs-drift (pull_request) Failing after 28s
ci / bun-nix (pull_request) Successful in 56s
android / android (pull_request) Successful in 7m21s
ci / rust (pull_request) Canceled after 7m48s
A field host streamed 1919 frames into a black screen while its own log
blamed the client. Four faults, each of which alone makes the failure
invisible.

The Windows firewall rules are `localport=`-scoped (47998-48010, 9777,
5353), but the media data plane binds an EPHEMERAL port per session. No
such rule can ever cover it, so Windows Firewall drops the client's
hole-punch on EVERY session on EVERY Windows host — `punched=false` on
the "data plane bound" line, in all six sessions of two field logs. The
punch then never opens the return path and video is sent blind to an
address the client merely reported. `service install` now also adds a
program-scoped inbound UDP rule for the host executable, which covers
whatever port a session picks. Program-scoped rather than a pinned port:
pinning into 47998-48010 would collide with Sunshine/Apollo.

`LossReport` carried only `loss_ppm`, which is ambiguous at zero — loss
is a ratio over the packets that arrived, so a flawless link and a link
delivering NOTHING both report 0. The host read total silence as perfect
and decayed adaptive FEC to its floor. Clients now also send a
`DeliveryReport` with 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. Sent every window while the
count is zero, once when the first packets land, then never — an older
host warns per unknown message and must not be flooded on a good
session.

`NO_VIDEO_RETRY` (client got nothing) and `FLUSH_COOLDOWN` (client
drowning) were both 2000 ms, so the host's cadence classifier could not
tell two opposite faults apart and picked the wrong one out loud. The
no-video cooldown moves to core beside `FLUSH_COOLDOWN` at 2600 ms, and
both sides compare against the shared constant rather than a copy.

The diagnosis now leads with the delivery count: zero is an error naming
the data plane, a confirmed count keeps the old confident wording, and an
old client that cannot answer gets a warning that says so instead of
guessing. A punch that never arrives is also its own warning now, rather
than a debug field on an info line.
2026-08-20 19:14:49 +02:00
enricobuehler 3b39710a5a fix(clients/android): controllers Android has no key layout for stop landing on the wrong buttons
ci / web (pull_request) Successful in 1m37s
ci / docs-site (pull_request) Successful in 1m43s
ci / bun-nix (pull_request) Successful in 30s
ci / docs-drift (pull_request) Successful in 27s
ci / rust-arm64 (pull_request) Successful in 2m19s
android / android (pull_request) Successful in 6m57s
ci / rust (pull_request) Successful in 7m23s
Reported from a Fire TV Stick 4K Max: a DualSense and an Xbox Elite Series 2,
both over Bluetooth, both identified correctly but with buttons on the wrong
actions ("L1 being L2"). Both work in Moonlight on the same box.

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, 0x132→BUTTON_C
and up. That is only right if the pad's buttons sit where the file assumes, and
a HID gamepad with no kernel driver numbers its buttons 1..n straight through in
its OWN report order, so every keycode past the first divergence is somebody
else's button. Neither pad has a layout there: AOSP ships none for the Elite
Series 2 over Bluetooth (045e:0b05) on any version, and the DualSense's
(054c:0ce6) both postdates Fire OS and carries `requires_kernel_config
CONFIG_HID_PLAYSTATION`, which a Fire TV kernel has not. A DualSense reporting
straight through puts L2 on 0x136, which `Generic.kl` calls BUTTON_L1 — the
reported symptom exactly, and Circle and R1 (BUTTON_C / BUTTON_Z) mapped to
nothing at all and were dropped.

This client read `KeyEvent.keyCode` only, so it inherited every one of those
errors — in the stream, in the console shell's navigation, and in the
Controllers screen's own tester, which is why the tester agreed with the bug.

Resolve buttons from the SCANCODE instead, which is the pad's own report
position and immune to the layout file — the same reason `Keymap.toVk` reads
`scanCode` for keyboards. Two things keep it off pads that already work:

  1. the correction applies ONLY where the delivered keycode is what
     `Generic.kl` would have said, so a device-specific layout always wins;
  2. 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 — never from a model table.

`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. The console and
the game must not disagree about which button a user pressed.

Axes get the same treatment: a pad declaring none of LTRIGGER/RTRIGGER,
BRAKE/GAS or BRAKE/THROTTLE is one Android never mapped, and its triggers are on
raw axes. A Sony pad reporting straight through lays out X, Y, Z, Rz, Rx, Ry, so
only the triggers (RX/RY) were missed; every other such pad puts the right stick
on Rx/Ry and the triggers on Z/Rz — the shape that makes pulling a trigger swing
the right stick. Whether those idle at −1 is measured off the device's own range
rather than assumed, so a pad reporting an honest 0..1 is not rescaled to a
permanent half-pull.

Also: the Controllers screen now reports each press as its raw scancode and
keycode plus the correction, names the resolved layout on the pad's card, and
reads axes through the same map the stream does — so it can be trusted again,
and so an unmapped pad can be pinned from a field report without the hardware.
The Xbox Bluetooth product ids (One S, Elite Series 2 and its Core) join the
identity table; without them an Elite streamed under the Xbox 360 identity.

Moonlight carries the same two report-order tables (`ControllerHandler`'s
`isNonStandardDualShock4` / `isNonStandardXboxBtController`), which is why both
pads work there on the same box.

Verified: :kit:testDebugUnitTest and :app:testDebugUnitTest both green (13 new
cases in PadButtonsTest covering the scancode tables and the axis resolution),
:app:compileDebugKotlin clean. NOT yet tested on glass — the reporter's Fire TV
is the confirmation this needs. The Xbox pad's Guide button stays unreachable
either way: it arrives as KEY_HOMEPAGE, and Android never delivers KEYCODE_HOME
to an app.
2026-08-20 18:46:56 +02:00
enricobuehler 19243c30b4 Merge pull request 'GameStream sessions built the XUSB pad that Steam cannot see — Xbox backend parity with the native plane' (#356) from worktree-gamestream-xbox-hid-parity into main
deb / build-publish-host (push) Successful in 5m28s
ci / bun-nix (push) Successful in 29s
ci / docs-drift (push) Successful in 50s
ci / docs-site (push) Successful in 1m18s
ci / web (push) Successful in 1m27s
ci / rust-arm64 (push) Successful in 1m55s
deb / build-publish-gamescope (push) Successful in 46s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 24s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 20s
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 10s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 10s
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 10s
deb / build-publish-client-arm64 (push) Successful in 2m29s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 18s
deb / build-publish (push) Successful in 5m35s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 2m4s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 2m7s
docker / builders-arm64cross (push) Successful in 20s
docker / deploy-docs (push) Successful in 48s
ci / rust (push) Successful in 7m27s
android / android (push) Successful in 10m54s
deb / smoke-install (push) Successful in 3m58s
arch / build-publish (push) Successful in 11m11s
windows-host / package (push) Successful in 14m38s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 34s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 19m16s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m13s
2026-08-20 16:33:41 +00:00
enricobuehler dfcffcdd50 fix(gamestream/windows): Moonlight sessions built the XUSB pad that Steam cannot see
ci / bun-nix (pull_request) Successful in 36s
ci / docs-drift (pull_request) Successful in 35s
ci / web (pull_request) Successful in 1m36s
ci / docs-site (pull_request) Successful in 1m37s
ci / rust-arm64 (pull_request) Successful in 1m39s
ci / rust (pull_request) Successful in 5m36s
android / android (pull_request) Successful in 5m51s
Reported from the field: gamepad input does nothing on GameStream clients, and it
reproduces across every client and device a user tries (Artemis on a Steam Deck and
on an Android phone both). That breadth is the tell — it is not a client at all.

On Windows there are two virtual Xbox backends and they are not interchangeable to a
game. The XUSB companion registers only GUID_DEVINTERFACE_XUSB and exposes no HID
collection (pf_xusb.inx says so in its own header: "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 on 2026-08-09 (bd5735b8) for exactly that
reason; its doc comment records the reporter who lost a controller for two weeks to it.

The GameStream plane never got that change. It has 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 any decision. bd5735b8 did touch
control.rs — but only to widen the rumble closure's arity, and its note reasons about
"the uinput backend", not about the Windows one sitting behind the same import. So
every Moonlight session since has presented a pad most games cannot enumerate, while
native punktfunk sessions on the same host got the good one.

There was also no way out: `windows_xbox_hid` was `pub(super)`, i.e. unreachable from
this module, so PUNKTFUNK_XBOX_BACKEND did nothing here — and the Windows manager
ignores the arrival `kind` byte, so a client could not ask for a DualSense either, the
escape that saved the earlier reporter.

- native.rs / native/gamepad.rs: `mod gamepad` and `windows_xbox_hid` become
  pub(crate). The knob keeps ONE definition and one name; widening visibility breaks
  no existing caller. Its doc now records that both planes read it, and why being
  `pub(super)` was itself the bug.
- gamestream/control.rs: a `SessionPads` enum is the one place this plane picks a
  backend — the HID pad when the shared knob says so, the XUSB companion otherwise,
  and on every other platform the single backend that exists.
  `PUNKTFUNK_XBOX_BACKEND=xusb` now reverts both planes together.

The HID pad's rich-feedback plane is dropped rather than plumbed: an Xbox pad has no
lightbar or adaptive triggers, and GameStream has no vocabulary for one — its rumble
message (0x010B) carries the two handle motors and nothing else, which is why the
trigger levels were already dropped at the call site.

No test: a `cfg(target_os = "windows")` #[test] would compile NOWHERE. ci.yml excludes
it by target, and windows-host.yml lints the host with `cargo clippy -p punktfunk-host`
without `--all-targets`, which does not build `cfg(test)` modules at all — the same
blind spot that workflow's own comments blame for letting the Linux twin's tests rot to
the wrong arity. It would be dead weight, not coverage.

VERIFIED
  * ON WINDOWS (.133, the only box where this arm compiles at all):
    `cargo check -p punktfunk-host` clean in 57s, then
    `cargo clippy -p punktfunk-host -- -D warnings` clean in 42s — both on default
    features, which include `gamestream`. This compiles BOTH enum arms: `Xusb` and
    `Hid` are Windows types alike.
    Non-vacuous by construction: `cargo clean -p punktfunk-host` ran first (a scp'd
    tree plus this box's lagging clock and a warm shared target dir otherwise yield a
    fresh `Finished` having compiled nothing), and both passes logged
    `Compiling punktfunk-host v0.31.0`. The only warning is punktfunk-core's
    header-write notice — benign and pre-existing.
  * `cargo fmt --all --check` clean.

NOT VERIFIED — owed
  * Linux: no box could run it (.25's root filesystem is 100% full; .21/.136/.138 were
    down; OrbStack here hangs even on `docker run alpine echo`). Exposure is small —
    the Linux arm is three calls on a `GamepadManager` whose Windows XUSB sibling has
    the identical method surface and just compiled — and ci.yml DOES run on
    pull_request, so opening a PR closes this.
  * On glass: no Moonlight session has driven the HID pad through this plane yet. That
    is the real acceptance test, and the log line to look for is
    "virtual Xbox pad created (Windows UMDF HID)" where it used to say
    "virtual Xbox 360 created (Windows XUSB companion)".
  * windows-host.yml has NO `pull_request` trigger (push to main, v* tags,
    workflow_dispatch only), so CI will not re-check this arm on a PR — the .133 run
    above is deliberately standing in for it.
2026-08-20 18:19:19 +02:00
enricobuehler f2b5b3e567 Merge pull request '0.31.0 release notes, re-cut on current main — and the bun install CI flake' (#351) from worktree-release-next-prep into main
ci / bun-nix (push) Successful in 26s
ci / docs-drift (push) Successful in 29s
ci / docs-site (push) Successful in 1m22s
ci / web (push) Successful in 2m13s
ci / rust-arm64 (push) Successful in 2m35s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 4m5s
android-screenshots / screenshots (push) Successful in 2m12s
ci / rust (push) Successful in 8m26s
decky / build-publish (push) Successful in 41s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 8m37s
android / android (push) Successful in 9m57s
linux-client-screenshots / screenshots (push) Successful in 2m32s
sbom / sbom (push) Successful in 26s
arch / build-publish (push) Successful in 10m14s
flatpak / build-publish (push) Successful in 5m2s
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 8s
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 10s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
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 13s
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 16s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m11s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 29s
docker / deploy-docs (push) Successful in 17s
deb / smoke-install (push) Successful in 4m40s
deb / build-publish (push) Successful in 3m24s
deb / build-publish-host (push) Successful in 7m31s
deb / build-publish-gamescope (push) Successful in 22s
deb / build-publish-client-arm64 (push) Successful in 1m11s
web-screenshots / screenshots (push) Successful in 6m19s
windows-host / package (push) Successful in 13m57s
windows-host / canary-manifest (push) Skipped
windows-host / winget-source (push) Successful in 19s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 17m11s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m43s
apple / swift (push) Failing after 18s
apple / distribute (push) Skipped
apple / screenshots (push) Skipped
2026-08-20 07:50:58 +00:00
enricobuehler 6d7e6f71c0 release: 0.31.0 — verification pass before the tag
ci / bun-nix (pull_request) Successful in 28s
ci / web (pull_request) Successful in 1m8s
ci / docs-drift (pull_request) Successful in 1m31s
ci / docs-site (pull_request) Successful in 1m47s
ci / rust-arm64 (pull_request) Successful in 2m23s
ci / rust (pull_request) Successful in 5m4s
Re-measured every version-table row against the tree rather than
trusting the earlier cut: ABI 25, wire 2, driver 6/min 3, gamepad 3,
index schema 1, event schema 1, edition 2024, MSRV 1.85, 27 crate dirs,
39 workspace members, SDK 0.1.5, plugin-kit 0.4.3, openapi 0.31.0 with
both copies byte-identical, gamescope +pfhdr8 with 10 patch files and
no diff against the tag. All match what the table claims. The C header
diff against v0.30.0 removes exactly two things — the old ABI define and
the ex11 doc paragraph that stated the superseded hi-res rule — so
'addition only' holds.

Three fixes to the text itself:

  * The commit count is now taken at the tip being released (170/113 at
    d7fa5847) instead of at this branch, where it moved every time I
    committed to it.

  * The two NixOS items in Before-you-update are one item. They were
    separated by three unrelated bullets, so the audience that has to
    act on both — builds are failing AND there is now a cache — had to
    find them in two places.

  * A TL;DR line read 'it repaired itself on no retry', which says the
    opposite of what it means.

Checked and left alone: the Gaming Mode 'Switch to Desktop' entry is
presented as a bug users hit, and that is right — v0.30.0's takeover
does stop the display manager on mask-fragile flavors
(gamescope.rs:259 at the tag), so it shipped. Only the mask-vs-stop
detail moved inside this cycle.

Gates: fmt clean, punktfunk-core --lib 273 passed, cargo metadata ok,
Play notes 456/500 and unique, voice scan finds no internal names in the
notes body.
2026-08-20 09:42:02 +02:00
enricobuehler 9e3fba10c1 release: 0.31.0 — fold in the four commits that landed during the re-cut
ci / bun-nix (pull_request) Successful in 29s
ci / docs-drift (pull_request) Successful in 29s
ci / web (pull_request) Successful in 1m43s
ci / docs-site (pull_request) Successful in 1m52s
ci / rust-arm64 (pull_request) Successful in 2m27s
ci / rust (pull_request) Canceled after 5m1s
170 -> 173 commits since v0.30.0 (115 non-merge, counted on this branch).

One is user-facing and is in the notes: the web console's Virtual
displays page rendered the Streamed-screen and session-lifetime cards
below the tab shell, so both tabs showed them and the Live tab read as
a duplicate of Configuration. They are policy surfaces and now sit
inside Configuration.

The other three are CHANGELOG-only: the guides trim's second round, the
fifth get-started screenshot (a client's host list), and the workflow
change that made it reachable — linux-client-screenshots now publishes
its PNGs to the generic registry as well, because the v3 artifact store
is browser-only and nothing could pull the shots out of it.

The CI bullet also records this branch's own retry.sh wrapping, since a
reader hitting 'Fail extracting tarball' deserves to find the diagnosis
rather than repeat it.

Gates re-run on this tip: fmt clean, punktfunk-core --lib 273 passed,
Play notes 456/500, both openapi copies identical at 0.31.0.
2026-08-20 09:36:58 +02:00
enricobuehler fd4f032d20 ci: retry bun install — a truncated tarball reads as a corrupt package
ci / bun-nix (pull_request) Successful in 44s
ci / docs-drift (pull_request) Successful in 43s
ci / web (pull_request) Successful in 1m14s
ci / rust (pull_request) Canceled after 2m3s
ci / rust-arm64 (pull_request) Canceled after 2m2s
ci / docs-site (pull_request) Canceled after 2m1s
docs-site died on `error: Fail extracting tarball for
"@rolldown/binding-linux-x64-gnu"` (run 19630, 2026-08-20). The message
points at the package; the package is fine.

MEASURED, because the message invites the wrong fix:
  * The tarball's sha512 matches docs-site/bun.lock exactly, and it is
    an ordinary 3-entry npm tgz — same gzip framing, same modes, no pax
    headers — as the 1.2.0 one that installs fine. Only the payload
    differs in size (20.6 MB vs 19.0 MB of .node).
  * bun 1.3.13 AND 1.3.14 both extract that exact tarball from disk in
    under 80 ms. So it is not the bun bump the floating oven/bun:1 tag
    brought in, and not a format bun stopped accepting.
  * In the SAME run, the web job installed the same registry over the
    same network and passed — it was 25 s ahead of docs-site.
  * Run 19632, seven minutes later, installed the identical lockfile
    and passed.

So: a transient truncation, not a bad package. bun streams
download-and-extract, so a tarball cut off mid-stream surfaces at the
extract step and names the package it was reading — which is why this
looks like `@rolldown/binding-linux-x64-gnu` is broken and why the
obvious fixes (bump rolldown, pin bun, refresh the lockfile) would all
have "worked" by changing which bytes were in flight, and none of them
would have fixed anything.

scripts/ci/retry.sh already exists for precisely this and its header
already diagnosed it: "the runner box executes many jobs in parallel and
its network drops packets under that load … Wrap every single-shot
network command in CI with this instead." `bun install` is a single-shot
network command and was the one class still unwrapped, so it is wrapped
now at all nine Linux sites — ci.yml (web, docs-site), arch, deb, rpm,
web-screenshots, sdk-publish and plugin-kit-publish (both installs).

3 attempts, not retry.sh's usual 5: a genuinely stale lockfile fails
deterministically under --frozen-lockfile, and 10s+20s of backoff is
enough to outlive a load burst without making that honest failure wait
a minute and a half.

The two windows-host.yml installs are left alone: pwsh, and a Windows
box that is not the contended runner.

Verified: all seven workflows still parse; the helper resolves from
web/, docs-site/ and sdk/ (the three working-directory shapes used);
the wrapper recovers a command that fails once and succeeds on the
retry; and `bash ../scripts/ci/retry.sh 3 bun install --frozen-lockfile
--ignore-scripts` in docs-site installs all 1138 packages, so the
lockfile is sound and the wrapper does not change the command.

Not done, deliberately: docs-site's lockfile still pins rolldown 1.1.2
where web has 1.2.0. That difference is real but it is not this bug,
and refreshing a lockfile to chase a network flake would have buried it.
2026-08-20 09:34:50 +02:00
enricobuehler 892e683f0e Merge remote-tracking branch 'origin/main' into worktree-release-next-prep 2026-08-20 09:24:01 +02:00
enricobuehler 7af6c323d0 Merge remote-tracking branch 'origin/main' into worktree-console-ui-off-toggle
android / android (pull_request) Successful in 7m15s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m44s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 3m8s
ci / bun-nix (pull_request) Successful in 31s
ci / docs-drift (pull_request) Successful in 31s
ci / web (pull_request) Successful in 1m4s
ci / docs-site (pull_request) Successful in 2m3s
ci / rust-arm64 (pull_request) Successful in 2m14s
ci / rust (pull_request) Successful in 6m34s
# Conflicts:
#	docs-site/content/docs/client-settings.md
2026-08-20 09:13:10 +02:00
enricobuehler 76e6618b84 fix(console-ui): Settings and the library are reachable without face buttons, for a TV remote
ci / docs-drift (pull_request) Successful in 36s
ci / bun-nix (pull_request) Successful in 38s
ci / web (pull_request) Successful in 1m15s
ci / docs-site (pull_request) Successful in 1m16s
ci / rust-arm64 (pull_request) Successful in 1m29s
android / android (pull_request) Canceled after 1m54s
ci / rust (pull_request) Canceled after 1m26s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Canceled after 0s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Canceled after 1m59s
An Android TV's only input is its remote — a D-pad, OK and Back — and the
console bound both Settings and the game library to face buttons that
remote does not have. X opened Settings, Y opened the shelf, and neither
event could be produced: the Kotlin bridge emits Move/Confirm/Back for a
remote and nothing else, the carousel's only action tiles are Add Host and
Rescan, and the hint chips that also carry those actions need a finger on
a touchscreen. So on a TV the two were simply unreachable. This is the
gap the deleted Compose home used to paper over with its own "no face
buttons" mapping, and the same wall Apple hit on the Siri Remote.

Down on the carousel now opens Settings — the other free direction beside
up, which already opens the host menu. It is a second route on a pad and
the only route on a remote, so the hint bar names the one the device in
hand actually has: ▼ with no pad attached, X with one. That is the whole
extent of the legend change; Y still opens the library and still says so.

The shelf gains a Library row on the host's own options menu, which up
reaches, offered on exactly the terms Y offers it (saved AND paired). It
replaces the menu rather than stacking on it, so Back from the shelf lands
on the carousel and not on a menu about the host just left. This is also
the route `docs/game-library.md` has been describing all along — "open its
options and choose Library" — which until now did not exist.

▼ is a new hint glyph: the ▲ triangle stood on its head rather than a
second drawing routine, and pressable in the hint bar exactly as ▲ is.
2026-08-20 09:06:05 +02:00
enricobuehler 7d2a8778d1 fix(nix): the prune deleted the compositor and shipped only its launcher
ci / docs-drift (pull_request) Successful in 30s
ci / bun-nix (pull_request) Successful in 1m0s
ci / web (pull_request) Successful in 1m15s
ci / docs-site (pull_request) Successful in 1m16s
ci / rust-arm64 (pull_request) Successful in 1m52s
ci / rust (pull_request) Successful in 5m16s
nix / flake (pull_request) Successful in 12m29s
ROOT CAUSE of every gamescope symptom chased today. nixpkgs WRAPS this package:
makeWrapper leaves the real compositor ELF at bin/.gamescope-wrapped and
installs a small launcher at bin/gamescope that sets PATH (xwininfo) before
exec'ing it. Our prune kept only `gamescope`:

    find $out/bin -mindepth 1 ! -name gamescope -delete

so it deleted the compositor and shipped the launcher pointing at a path that
no longer existed. MEASURED (run 19622): $out/bin held one 16 KB file, and
`strings` on it showed the PATH prologue and '/bin/.gamescope-wrapped'.

That single line explains all of it:
  * `--version` printed NOTHING — the launcher exec'd a deleted target. I had
    attributed this to the build sandbox, and to upstream's PrintVersion()
    landing after 3.16.25. Both were wrong.
  * the +pfhdr marker was 'missing from the binary' — a wrapper carries no
    version string, so it never could have been there.

Keep the target through the prune, and assert on the WRAPPED ELF rather than
the launcher (which would otherwise pass or fail by accident). The launcher
references its target by absolute path, so renaming the launcher stays safe.

The guards were right on every run; they were reporting a genuinely broken
artifact, not a flaky check.
2026-08-20 07:02:22 +00:00
enricobuehler 6007bc42cd docs(client-settings): the console-off switch is in both places on Android, and neither row is offered on a TV
The Interface section owned two facts this changes: which surfaces carry
"Gamepad-optimized browsing" (the Android console home carries it now, so
that home can be left from inside it) and what an Android TV sees — the
mode row used to be described as present-but-inert there, and both rows are
simply absent now.
2026-08-20 08:40:59 +02:00
enricobuehler 16d54b73a1 feat(console-ui): the console can switch itself off, where there is a touch UI to switch off to
The Android console's settings screen had a picker for WHEN it fronts the
app but no switch for WHETHER it does — that lived only in the touch
settings, which a user already inside the console has no way to reach.
The console UI became the only UI on Android, so the way back out was a
setting you could only change from the thing you were trying to get to.

Adds the "Controller-optimized UI" row over the same `gamepadUiEnabled`
the touch switch writes, folded through `extra` under
`android.gamepad_ui_enabled` like the other Android-only rows. Off
returns to the touch home immediately: the console's save bumps the
snapshot generation, the host emits its settings event, and Kotlin's
`applySettings` now folds the flag back into the App state that
`gamepadUiActive` reads.

Shown only where "off" has somewhere to land. `ConsoleOptions.fallback_ui`
(new, threaded to `Ctx` beside `deck`) is true only for the Android touch
shell — false on a TV and for the desktop session, where this console IS
the interface and an off switch would strand the user in nothing.

The mode row follows the same rule and moves under it: it decides nothing
on a TV (the tv term satisfies `gamepadUiActive`'s OR on its own) or while
the switch is off, so it is hidden in both cases rather than offered as a
control that changes nothing. Renamed to the touch screen's own word for
it, "Show it" — "Controller UI" directly under "Controller-optimized UI"
was two rows told apart by their tails.
2026-08-20 08:40:59 +02:00
enricobuehler d60b1dda29 fix(nix): split the +pfhdr failure into its two possible stages
The marker is not in the installed ELF, and patch 0005 applies cleanly to a
src/meson.build that still carries the exact line it rewrites. Those two facts
cannot both be reasoned from the log as it stands, so stop guessing and
instrument the two stages separately:

  * postPatch now asserts +pfhdr is in src/meson.build after our vcs_tag
    substitution — the same gate build-punktfunk-gamescope.sh applies. If it
    fires, patch 0005 or the substitution lost the marker, and it prints the
    version block as patched.
  * the installCheck failure branch now prints evidence instead of an
    assertion: $out/bin, anything under $out mentioning pfhdr, and the
    version-shaped strings actually present in the binary.

Whichever fires, the next run says which stage drops it. Each guess here costs
a full compositor build, and I have now spent three on this one symptom.
2026-08-20 06:39:28 +00:00
enricobuehler d7fa5847f1 Merge pull request 'Virtual displays: the Streamed-screen and session⇄game cards showed on both tabs — they join the Configuration tab' (#353) from worktree-displays-tab-dup into main
ci / bun-nix (push) Successful in 39s
ci / web (push) Successful in 1m14s
ci / docs-site (push) Successful in 1m38s
ci / docs-drift (push) Successful in 1m4s
ci / rust-arm64 (push) Successful in 2m28s
deb / build-publish-gamescope (push) Successful in 41s
deb / build-publish-client-arm64 (push) Successful in 1m36s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 14s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 1m50s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 11s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 18s
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 12s
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 14s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 47s
deb / build-publish (push) Successful in 5m1s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m49s
arch / build-publish (push) Successful in 8m38s
deb / build-publish-host (push) Successful in 6m58s
docker / builders-arm64cross (push) Successful in 9s
docker / deploy-docs (push) Successful in 37s
deb / smoke-install (push) Successful in 3m7s
windows-host / package (push) Successful in 18m26s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 34s
ci / rust (push) Successful in 20m18s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m7s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 19m2s
2026-08-20 06:38:08 +00:00
enricobuehler e473a4be7b fix(web): the Virtual displays page's trailing cards join the Configuration tab
ci / bun-nix (pull_request) Successful in 28s
ci / docs-drift (pull_request) Successful in 1m1s
ci / web (pull_request) Successful in 1m23s
ci / rust-arm64 (pull_request) Successful in 2m3s
ci / docs-site (pull_request) Failing after 11m15s
ci / rust (pull_request) Successful in 20m0s
The Streamed screen and session-game lifetime cards sat BELOW the tab
shell, so they rendered on both tabs — from the Live displays tab the
page read as the Configuration tab's content duplicated (reported the
other way round, same sight). Both are policy surfaces, so they ride
inside the Configuration tab as siblings of the config card; the Live
tab is now only the live list + arrangement.
2026-08-20 08:34:05 +02:00
enricobuehler 675030935a fix(nix): assert +pfhdr in the installed binary, not by running --version
`--version` produces EMPTY output under nix's build sandbox — measured on BOTH
nixpkgs' 3.16.25 and the pinned 5fb8dce4 (runs 19551 / 19573 / 19594). It is a
sandbox property, not a defect: gamescope calls PrintVersion() before the getopt
loop (src/main.cpp:721), so `gamescope --version` does print the banner on a
real system, which is what the host's capability probe reads. My earlier claim
that the probe itself was broken was wrong.

packaging/gamescope/build-punktfunk-gamescope.sh already avoids running the
binary, asserting on src/meson.build instead. Grepping the installed ELF is
strictly stronger than either: the version string reaches .rodata via
GamescopeVersion.h's k_szGamescopeVersion, so this proves the marker survived
patching, meson configuration and compilation into the artifact we ship — and it
cannot be defeated by the binary being unable to start.

The src pin from the previous commit stays. It is still right for its own
reason: the patches are authored against 5fb8dce4, nixpkgs' 3.16.24 could not
take patch 0009 at all, and every other channel ships this exact commit.
2026-08-20 06:21:37 +00:00
enricobuehler 21d9190324 Merge pull request 'The fifth get-started screenshot — a client's host list — and the workflow change that made it reachable' (#352) from docs-host-list-shot into main
ci / bun-nix (push) Successful in 29s
ci / web (push) Successful in 1m9s
ci / docs-site (push) Successful in 1m17s
ci / docs-drift (push) Successful in 2m15s
ci / rust-arm64 (push) Successful in 3m17s
ci / rust (push) Successful in 13m27s
2026-08-20 06:19:35 +00:00
enricobuehler 7ae8866a5c Merge pull request 'Guides trim round 2: the five biggest pages each shed one prose category — −16%, no facts, anchors stable' (#350) from docs-guides-trim-round2 into main
ci / rust (push) Canceled after 24s
ci / docs-site (push) Canceled after 23s
ci / web (push) Canceled after 23s
ci / rust-arm64 (push) Canceled after 23s
ci / bun-nix (push) Canceled after 24s
ci / docs-drift (push) Canceled after 24s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 13s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 13s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 13s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 14s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 15s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 17s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 14s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 11s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 59s
docker / builders-arm64cross (push) Successful in 9s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 2m36s
docker / deploy-docs (push) Successful in 1m1s
2026-08-20 06:19:20 +00:00
enricobuehlerandClaude Fable 5 a8099e0f5b docs(quickstart): the client host list — the fifth get-started screenshot, pulled from the pipeline the previous commit teaches to publish
ci / web (pull_request) Successful in 3m38s
ci / bun-nix (pull_request) Successful in 51s
ci / docs-site (pull_request) Successful in 1m25s
ci / rust-arm64 (pull_request) Successful in 5m53s
ci / docs-drift (pull_request) Successful in 50s
ci / rust (pull_request) Successful in 7m46s
Saved hosts with their pairing state + an unpaired host found on the network, from
linux-client-screenshots run 19593 via the new generic-package publish (fixed 'ci' version,
anonymous GET), cropped to the cards. Completes the handoff's screenshot list: console login,
Waiting for approval + PIN, the Approve dialog, live status, and a client's host list.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 01:25:44 +02:00
enricobuehler b4b24f8b57 release: 0.31.0 — re-cut on d801cb72, folding in the 41 commits since #320
ci / rust-arm64 (pull_request) Successful in 1m59s
ci / web (pull_request) Successful in 1m25s
ci / bun-nix (pull_request) Successful in 54s
ci / docs-site (pull_request) Successful in 1m27s
ci / docs-drift (pull_request) Successful in 30s
ci / rust (pull_request) Successful in 16m35s
#320 merged but v0.31.0 was never tagged, and 69 more commits (41
non-merge) landed on top of it. The version stays 0.31.0 and Cargo.toml
does not move; the notes, Play notes and CHANGELOG section are
re-measured on the new tip.

THE NUMBER IS NOW FORCED, where the first cut's was a judgement call.
The C ABI moves 24 -> 25: punktfunk_set_log_callback plus the
PunktfunkLogCb typedef (#338's core half), so an embedder can hear the
core's tracing lines. Addition only — no declaration moved, no repr(C)
struct grew a field, and an embedder that never calls it is byte-
compatible with v24. include/punktfunk_core.h is therefore NO LONGER
byte-identical to the v0.30.0 tag, which the first cut's table and both
"nothing versioned moves" paragraphs claimed.

Table re-measured on d801cb72. Two rows changed since the first cut and
two were already corrected in-tree by their own commits:
  * C ABI 24 -> 25 (above).
  * api/openapi.json 0.29.0 -> 0.31.0 — the STAMP only. No management
    surface moved; the file had been left two versions stale and #337's
    regenerate-and-diff caught it. Both copies cmp identical.
  * SDK 0.1.5 and plugin-kit 0.4.3 were cut during the cycle (#348,
    #334), so the rows the first cut wrote as "owed" are now past tense.
    The registry still skips 0.4.2 on purpose.
Everything else holds: wire 2, driver protocol 6 / min 3, gamepad
channel 3, plugin index schema 1, host event schema 1, edition 2024,
MSRV 1.85, 27 crate dirs, gamescope +pfhdr8 with no new patches. The
PKGBUILD pfhdr7 discrepancy is unchanged and still pre-existing.

REWRITTEN RATHER THAN APPENDED TO, because the intervening work never
shipped and a user must not read a within-cycle correction as a second
bug:
  * The Gaming Mode takeover section. The first cut described "stops the
    login manager first, then steps in" — which #342 replaced before
    shipping, after a field report that a stopped display manager leaves
    the box unable to service Steam's own Switch to Desktop (it hung
    until reboot, and could not even be detected around: every trace of
    that switch is written by the component we had stopped). End state:
    idle the autologin over the gamescope-session-plus@ template, leave
    the DM running. #343 then deleted the whole 142-line stop/linger
    chain the old approach needed. Two shipped facts became false with
    it — the takeover no longer stops the DM and no longer needs the
    punktfunk group (that group still gates the usbip pad nodes) — and
    the notes' Before-you-update says so for Deck/Bazzite operators.
  * The Android ASC entries. HDR seeded from the codec's echo, untagged
    SDR raising blacks to grey, and the console's dispose re-showing the
    system bars over the stream are all regressions against THIS cycle's
    unreleased presenter, so they are folded into the Android story, not
    listed as fixes users hit.

NEW USER-FACING WORK, in rough order of how much it changes:
  * Windows client (#349): the default download becomes a signed
    per-user Inno Setup installer + portable zip; MSIX stays for the
    Store. This is the Steam fix — MSIX puts the exe under WindowsApps,
    which Steam's picker cannot browse, and alias activation defeats
    overlay injection. Before-you-update carries the one action in the
    release: an MSIX user who wants it must Remove-AppxPackage first and
    will re-pair once, because pairing identity lives with the package.
  * install.sh (#345/#346): one command from nothing to a pairable Linux
    host, running platforms.json's lines verbatim, with Sunshine
    coexistence, --dry-run, --uninstall and env twins. Shipped PREVIEW
    on purpose and the notes say so.
  * Send logs to host completes on Apple (#338) and Android (#339) — the
    two legs 0.30's notes named as follow-ups.
  * KWin 6.6 (#344/#331): it refuses to stream an output it created
    disabled, in the session's language, logged nowhere, and persists
    the disable against the stable per-client name so it repeats
    forever. Enable and retry; and a translated refusal no longer burns
    all 8 retries.
  * Deck Wake-on-LAN (#347, closes #322), portable Playnite covers
    (#330), plugin-kit republish on startup/manual (#334), console touch
    gestures and idle gates (#333), cancel-connect (#329), NixOS host
    builds failing outright (#328 + #332 + #336).
  * Docs overhaul (#337/#340/#343/#346): get-started track, Switching
    from Sunshine, screenshots, and data/platforms.json as the single
    source for install facts with seven CI drift gates over it.

Gates on this tree: cargo fmt --all --check clean (no drift this time —
the first cut needed a whitespace commit, which is already in main);
cargo metadata --offline ok; cargo test -p punktfunk-core --lib 273
passed; Play notes gate verbatim 456/500 and unique; voice scan clean
outside For developers; both openapi copies cmp identical at 0.31.0.

⚠ The C ABI harness still did not run here — no libopus on this MacBook
— and this time the header DID change, so ABI 25's C compile is owed to
CI. Named in Verification status rather than left to be found.

⚠ Owed outside this repo: platforms.json changed (the Windows download),
so punktfunk-website needs `bun run sync-platforms` committed — the step
#346 added to docs/releases/README.md, and this is its first exercise.
2026-08-20 01:24:15 +02:00
enricobuehlerandClaude Fable 5 c23fc84bef docs(guides): trim round 2 — each page sheds one prose category, no fact rows, headings or commands touched
ci / rust-arm64 (pull_request) Successful in 1m44s
ci / web (pull_request) Successful in 1m3s
ci / bun-nix (pull_request) Successful in 29s
ci / docs-site (pull_request) Successful in 1m24s
ci / docs-drift (pull_request) Successful in 23s
ci / rust (pull_request) Successful in 12m33s
The category sacrificed, per page (RFC 'screenshots over prose' / phase-2 handoff item 3):
- client-settings.md (4368→3144 words): the why-rationale narrative around each setting; every
  default, number, availability list, env var and link stays.
- virtual-displays.md (3724→3015): status/history notes (the 'What's live today' tracker
  paragraph, 'Windows has always lingered 10 s') and design rationale; compositor-specific
  operational facts stay.
- plugins.mdx (2835→2418): narrative duplication — restart instructions stated twice, story
  asides the linked plugin repos carry.
- input.md (2499→2403) and wake-on-lan.md (2286→2186): rationale asides and historical notes
  ('the plugin used to fire a packet itself…'); these two are per-platform fact tables end to
  end, so only a light pass was honest.

15712→13166 words over the five biggest Guides (−16%). Zero heading changes (anchors stable),
docs-drift + docs-links green, docs-site build + lint green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 01:17:16 +02:00
enricobuehler 0e5a059098 fix(nix): pin gamescope's src to 5fb8dce4, like every other channel
nix was the ONLY channel not pinning the compositor — the RPM spec, the
PKGBUILD, build-punktfunk-gamescope.sh and packaging/gamescope/README.md all
ship 5fb8dce4 (3.16.25-11), while gamescope.nix patched whatever version
nixpkgs happened to carry. That produced two failures in two days, both the
same bug:

  * nixpkgs shipped 3.16.24, where patch 0009's context does not exist, so the
    build died at patchPhase. host.gamescopeHdr defaults true, so every
    `services.punktfunk.host.enable = true` failed the same way.
  * bumping the lock to 3.16.25 fixed the patches, and then --version printed
    NOTHING. Upstream's `gamescope::PrintVersion()` landed AFTER the 3.16.25
    tag; in that tag `--version` is `return 0;` with the comment "We always
    print the version to stderr anyway". The host reads that banner to decide a
    session's bit depth and cursor compositing BEFORE the virtual display
    exists, so a silent banner is a silent fall back to SDR.

The installCheck was right to fail — this was not a check problem.

Also in this commit: the +pfhdr guard now prints the banner it read. That is
how the empty output was identified at all; without it the failure is
indistinguishable from the binary not starting, at ~15 min per guess.

vcs_tag now substitutes the PINNED version (old.version is the pre-override
attr and would still say 3.16.25).

nix-instantiate --parse clean. Hash from nix-prefetch-git --fetch-submodules.
2026-08-19 23:16:18 +00:00
enricobuehlerandClaude Fable 5 674b16d8eb linux-client-screenshots: publish the PNGs to the generic package registry too — the v3 artifact is browser-only, which blocked reusing the shots for the docs
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 01:08:31 +02:00
enricobuehler d801cb72f2 Merge pull request 'Windows client: per-user installer + portable zip as the default download — a stable exe path Steam can launch (overlay, Big Picture); MSIX stays for the Store' (#349) from worktree-win-client-installer into main
ci / rust-arm64 (push) Successful in 2m26s
ci / web (push) Successful in 1m53s
ci / bun-nix (push) Successful in 35s
ci / docs-drift (push) Successful in 31s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 12s
ci / docs-site (push) Successful in 2m11s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 13s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 12s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 21s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 11s
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 9s
ci / rust (push) Successful in 7m57s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 14s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 24s
installer-smoke / smoke (arch) (push) Successful in 45s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m34s
installer-smoke / smoke (fedora-44) (push) Successful in 1m18s
installer-smoke / smoke (debian-13) (push) Successful in 2m26s
docker / builders-arm64cross (push) Successful in 31s
docker / deploy-docs (push) Successful in 32s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m39s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 7m48s
2026-08-19 23:05:32 +00:00
enricobuehler b03acc9153 Windows client: ship a per-user Inno Setup installer + portable zip as the default download; keep the MSIX for the Store
ci / web (pull_request) Successful in 1m21s
ci / docs-site (pull_request) Successful in 1m32s
ci / bun-nix (pull_request) Successful in 21s
ci / rust-arm64 (pull_request) Successful in 2m4s
ci / docs-drift (pull_request) Successful in 25s
installer-smoke / smoke (arch) (pull_request) Successful in 43s
installer-smoke / smoke (fedora-44) (pull_request) Successful in 1m0s
installer-smoke / smoke (debian-13) (pull_request) Successful in 1m33s
ci / rust (pull_request) Successful in 8m26s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 3m23s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m48s
A user report: launching via Big Picture doesn't work and the Steam overlay never appears.
Not because the app is UWP (it's full-trust Win32 under MSIX too) but because of the MSIX
install SHAPE: the exe lives under the ACL'd WindowsApps dir Steam's non-Steam-game picker
can't browse, and alias/shell:AppsFolder activation defeats the overlay's injection — Steam
must spawn the exe itself from a normal path.

- punktfunk-client.iss: per-user (no UAC) install to %LOCALAPPDATA%\Programs\Punktfunk.
  Re-creates the manifest's grants in HKCU (punktfunk:// scheme, Start entries, {app} on the
  user PATH for the punktfunk CLI) and fetches the Windows App Runtime when missing.
- pack-client-installer.ps1: consumes pack-msix.ps1's layout (one assembly, three artifacts),
  signs the four exes individually, emits setup.exe + a portable zip. Same signing backends
  and fail-closed-on-tags rule as the sibling scripts; no .cer (an exe runs untrusted).
- windows-client.yml: pack step after the MSIX, publish + release-attach the new artifacts
  (canary/latest aliases punktfunk-client-setup_<arch>.exe, ..._<arch>-portable.zip).
- deeplink.rs: write_shortcut targets the app-execution alias only under package identity;
  unpackaged installs have no alias but a stable path, so they target current_exe().
  has_package_identity() now shared with main.rs's AppUserModelID probe.
- docs: install-client (installer default + a 'Launching through Steam' section + MSIX/portable
  as alternates), channels, uninstall, clients, platforms.json (both copies, drift check green).
2026-08-20 00:52:46 +02:00
enricobuehler ace01f06a2 Merge pull request 'Cut SDK 0.1.5 — the mgmt-endpoint fix cannot reach a plugin until it ships' (#348) from release-sdk-0.1.5 into main
ci / bun-nix (push) Successful in 42s
ci / rust-arm64 (push) Successful in 1m42s
ci / docs-drift (push) Successful in 23s
ci / docs-site (push) Successful in 2m49s
ci / web (push) Successful in 2m51s
deb / build-publish-gamescope (push) Successful in 47s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 38s
deb / build-publish-client-arm64 (push) Successful in 1m41s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 15s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 15s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 12s
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 12s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 18s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m1s
sdk-publish / publish (push) Successful in 42s
deb / build-publish (push) Successful in 5m5s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m34s
deb / build-publish-host (push) Successful in 4m57s
arch / build-publish (push) Successful in 11m7s
docker / builders-arm64cross (push) Successful in 11s
ci / rust (push) Canceled after 40s
docker / deploy-docs (push) Canceled after 0s
deb / smoke-install (push) Successful in 3m9s
windows-host / package (push) Successful in 17m26s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 36s
nix / flake (push) Failing after 24m46s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 27m7s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 29m36s
2026-08-19 22:51:11 +00:00
enricobuehler e4ec4cec31 chore(sdk): cut 0.1.5 — the mgmt-endpoint fix cannot reach a plugin until it ships
ci / rust-arm64 (pull_request) Successful in 1m33s
ci / bun-nix (pull_request) Successful in 22s
ci / web (pull_request) Successful in 2m7s
ci / docs-drift (pull_request) Successful in 30s
ci / docs-site (pull_request) Successful in 2m12s
ci / rust (pull_request) Successful in 7m25s
nix / flake (pull_request) Successful in 15m37s
v0.31.0's changelog recorded this cut as owed and said why: plugins resolve
`@punktfunk/host` from the registry, so a fix sitting in `sdk/` reaches nobody
until a version carries it. Four commits have touched the SDK since sdk-v0.1.4,
the headline one being the mgmt-port fix — a moved `PUNKTFUNK_MGMT_BIND` left
every plugin and the tray dialing 47990.

`SDK_VERSION` moves with it. It is a hand-maintained constant (the bundled
runner has no package.json to read at runtime), and the runner compares it
against the SDK actually installed in the plugins tree to decide whether to
reinstall — so shipping 0.1.5 with the constant still reading 0.1.4 would have
published the fix and then never delivered it. `version.test.ts` exists for
exactly this and caught it.

Gates: 83/83 SDK tests pass.
2026-08-20 00:50:46 +02:00
enricobuehler 245173a731 fix(nix): make the +pfhdr guard show the banner it actually read
The memory raise got gamescope building, and it now reaches installCheckPhase
and fails there:

    punktfunk-gamescope: the +pfhdr marker is missing — the patches did not take

Patch 0005 applied cleanly to src/meson.build in that same run, so the message
is misleading: the patch DID take. Two very different causes are
indistinguishable from the log as written —

  * the binary never ran (shrunk RPATH, missing loader dep), so --version
    printed nothing at all; or
  * it ran and printed a version without +pfhdrN, meaning upstream no longer
    builds the banner from VCS_TAG.

A guard that reports 'missing' without showing what it read cannot be acted on,
and each guess costs a full compositor build. Capture the output and print it on
failure, with a note on how to read the two cases apart.

No behaviour change: same assertion, same exit. Also verified in this run: the
chmod fix works (prune, rename and layer install all completed) and the WSI
layer is installed.
2026-08-19 22:46:07 +00:00
enricobuehler 230d253b06 Merge pull request 'The Deck learns a host's wake MAC, so Wake-on-LAN can fire there at all' (#347) from worktree-deck-wol-learn-mac into main
ci / web (push) Successful in 1m37s
ci / bun-nix (push) Successful in 28s
ci / docs-drift (push) Successful in 35s
ci / rust-arm64 (push) Successful in 2m42s
ci / docs-site (push) Successful in 2m30s
deb / build-publish-gamescope (push) Successful in 1m40s
deb / build-publish-client-arm64 (push) Successful in 1m58s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 20s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 22s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 20s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 19s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 17s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 16s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 13s
ci / rust (push) Successful in 7m16s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 59s
deb / build-publish (push) Successful in 5m8s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m19s
arch / build-publish (push) Canceled after 8m19s
deb / build-publish-host (push) Canceled after 5m10s
deb / smoke-install (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 1s
docker / builders-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 49s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 48s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Failing after 6m43s
android / android (push) Successful in 11m48s
flatpak / build-publish (push) Successful in 4m58s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m14s
Reviewed-on: #347
2026-08-19 22:41:32 +00:00
enricobuehler def215ae8e Merge pull request 'Installer bedding-in (--uninstall, NVIDIA silent-failure checks, detection matrix as gate 7) and screenshots for the get-started track' (#346) from worktree-docs-phase2 into main
android / android (push) Canceled after 44s
arch / build-publish (push) Canceled after 40s
ci / rust (push) Canceled after 38s
ci / rust-arm64 (push) Canceled after 37s
ci / web (push) Canceled after 36s
ci / docs-site (push) Canceled after 35s
ci / bun-nix (push) Canceled after 0s
ci / docs-drift (push) Canceled after 0s
deb / build-publish (push) Canceled after 4s
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 (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 12s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 10s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 10s
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
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 2s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
decky / build-publish (push) Successful in 47s
installer-smoke / smoke (arch) (push) Successful in 42s
apple / swift (push) Successful in 2m13s
installer-smoke / smoke (debian-13) (push) Successful in 1m2s
installer-smoke / smoke (fedora-44) (push) Successful in 2m15s
apple / distribute (push) Successful in 11m38s
apple / screenshots (push) Successful in 10m38s
2026-08-19 22:40:55 +00:00
enricobuehlerandClaude Fable 5 dfcc530ee7 installer bedding-in: --uninstall, NVIDIA silent-failure checks, the detection matrix as CI gate 7 — and the get-started track gets its screenshots
ci / docs-drift (pull_request) Successful in 48s
ci / docs-site (pull_request) Successful in 1m30s
ci / web (pull_request) Successful in 2m8s
apple / swift (pull_request) Successful in 2m16s
apple / distribute (pull_request) Skipped
installer-smoke / smoke (debian-13) (pull_request) Successful in 58s
apple / screenshots (pull_request) Skipped
installer-smoke / smoke (fedora-44) (pull_request) Successful in 43s
ci / bun-nix (pull_request) Successful in 30s
ci / rust-arm64 (pull_request) Successful in 3m6s
installer-smoke / smoke (arch) (pull_request) Successful in 1m34s
ci / rust (pull_request) Successful in 7m54s
android / android (pull_request) Successful in 8m7s
Phase 2 of the docs-and-onboarding overhaul (items 1-partial, 2 and 4 of the handoff):

install.sh: --uninstall reverses step 1 + step 6 per family (user units off first, only the
punktfunk packages actually installed, then the repo; config/groups/firewall stay, as
/docs/uninstall states) — smoke-tested as a new installer-smoke step on all three families.
The end-of-run check now catches the two NVIDIA silent failures on every family: no driver at
all, and a module the kernel refused to load (Secure Boot) via an nvidia-smi probe pointing at
the troubleshooting anchor; the Fedora ffmpeg-libs/NVENC warning folds into the same block.

check-docs-drift.sh gate 7: the manual 16-file os-release matrix PR #345 was verified with,
committed — every family's detection, its install line, its removal line and the four
unsupported pointers run through the real script under --dry-run on every push (docs-drift's
container gains curl, the script's own prerequisite).

Screenshots (RFC: "screenshots over prose"): four console shots captured from the same
storybook-fixture pipeline web-screenshots.yml runs — login and the armed Pairing page into
quickstart.md, the Approve dialog (access level + expiry + guest fast-path) into pairing.md
replacing the prose that described it, live status into web-console.md. Files under
docs-site/public/img/, dark-theme, bundled+preloaded by the docs build (verified served).
Still missing: a client host-list shot — linux-client-screenshots run 19546 built it, but its
artifact isn't API-downloadable; add it when a browser session can fetch the zip.

WP5 rider: the release-flow docs-freshness step now includes the website content look-over.

NOT flipped: installer stays preview — the handoff gates the default flip on real-box mileage
(Bazzite above all), which a Mac can't provide.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 23:56:19 +02:00
enricobuehler 6b5307618f Merge pull request 'KWin creates our virtual output disabled and refuses to stream it — enable it and retry' (#344) from worktree-kwin-vout-enable-repair into main
ci / bun-nix (push) Successful in 23s
ci / web (push) Successful in 1m23s
ci / docs-drift (push) Successful in 1m10s
ci / docs-site (push) Successful in 1m44s
ci / rust-arm64 (push) Successful in 2m12s
deb / build-publish-gamescope (push) Successful in 59s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 42s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 34s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 17s
deb / build-publish-client-arm64 (push) Successful in 1m36s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 13s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 14s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 16s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 16s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 17s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m8s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m31s
android / android (push) Failing after 7m23s
deb / build-publish-host (push) Successful in 5m43s
ci / rust (push) Successful in 7m28s
docker / builders-arm64cross (push) Successful in 10s
docker / deploy-docs (push) Successful in 34s
arch / build-publish (push) Successful in 8m37s
deb / build-publish (push) Successful in 4m5s
deb / smoke-install (push) Successful in 3m8s
windows-host / package (push) Successful in 12m55s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 35s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m26s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 7m12s
2026-08-19 20:48:43 +00:00
enricobuehler cdacd5636e Merge remote-tracking branch 'origin/main' into worktree-kwin-vout-enable-repair
ci / bun-nix (pull_request) Successful in 32s
ci / docs-drift (pull_request) Successful in 35s
ci / web (pull_request) Successful in 1m13s
ci / docs-site (pull_request) Successful in 1m43s
ci / rust-arm64 (pull_request) Successful in 2m20s
android / android (pull_request) Successful in 5m39s
ci / rust (pull_request) Successful in 6m20s
2026-08-19 22:39:11 +02:00
enricobuehler 47f01149bf Merge pull request 'Guided Linux installer (preview): one command from nothing to a pairable host, running exactly what platforms.json states, smoke-tested in CI per package family' (#345) from worktree-wp4-install-script into main
apple / swift (push) Successful in 2m10s
ci / web (push) Successful in 2m2s
ci / rust-arm64 (push) Successful in 3m4s
ci / bun-nix (push) Successful in 26s
ci / docs-drift (push) Successful in 25s
ci / docs-site (push) Successful in 2m12s
deb / build-publish-gamescope (push) Successful in 27s
decky / build-publish (push) Successful in 1m3s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 24s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 19s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 11s
deb / build-publish-client-arm64 (push) Successful in 2m19s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 15s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 15s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 21s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 11s
deb / build-publish (push) Successful in 4m5s
ci / rust (push) Successful in 8m17s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 27s
arch / build-publish (push) Successful in 10m11s
installer-smoke / smoke (arch) (push) Successful in 56s
installer-smoke / smoke (fedora-44) (push) Successful in 1m0s
installer-smoke / smoke (debian-13) (push) Successful in 1m16s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m41s
docker / builders-arm64cross (push) Successful in 22s
android / android (push) Successful in 11m46s
docker / deploy-docs (push) Successful in 39s
deb / build-publish-host (push) Successful in 8m27s
apple / distribute (push) Successful in 12m59s
deb / smoke-install (push) Successful in 2m35s
apple / screenshots (push) Successful in 9m45s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m1s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m39s
2026-08-19 19:01:45 +00:00
enricobuehlerandClaude Fable 5 59ef285f08 process: the PR template asks whether a user-facing fact changed, and the release flow gets a docs-freshness step
ci / bun-nix (pull_request) Successful in 25s
ci / docs-drift (pull_request) Successful in 59s
ci / docs-site (pull_request) Successful in 1m32s
ci / web (pull_request) Successful in 1m39s
apple / swift (pull_request) Successful in 2m9s
installer-smoke / smoke (arch) (pull_request) Successful in 32s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 2m26s
installer-smoke / smoke (debian-13) (pull_request) Successful in 2m47s
installer-smoke / smoke (fedora-44) (pull_request) Successful in 2m8s
android / android (pull_request) Successful in 8m1s
ci / rust (pull_request) Successful in 8m18s
WP5 of the docs-and-onboarding overhaul, the two riders the RFC attaches to WP2–WP4:

- .gitea/PULL_REQUEST_TEMPLATE.md — one question: did a user-facing fact change, and is the
  docs-site page that owns it updated in this PR (install/repo/port facts in data/platforms.json).
  CI's docs-drift only catches the mechanical half; this is the reminder for the rest.
- docs/releases/README.md step 1 — while the release diff is in front of you, check docs freshness,
  and if platforms.json changed, run `bun run sync-platforms` in punktfunk-website and commit,
  because the download page vendors that file and only refreshes when someone does.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 21:01:00 +02:00
enricobuehler 5bec450402 install.sh: probe /dev/tty by opening it — a container has the node but no controlling terminal, so -r/-w said yes and the redirect failed (first installer-smoke run); name the matrix jobs by family
ci / bun-nix (pull_request) Successful in 41s
ci / docs-drift (pull_request) Successful in 46s
ci / web (pull_request) Successful in 1m12s
ci / docs-site (pull_request) Successful in 1m49s
ci / rust-arm64 (pull_request) Successful in 1m54s
installer-smoke / smoke (arch) (pull_request) Successful in 49s
apple / swift (pull_request) Successful in 2m17s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
installer-smoke / smoke (fedora-44) (pull_request) Successful in 1m22s
installer-smoke / smoke (debian-13) (pull_request) Successful in 1m45s
android / android (pull_request) Canceled after 3m55s
ci / rust (pull_request) Canceled after 3m56s
2026-08-19 20:57:02 +02:00
enricobuehlerandClaude Fable 5 cd6ce34892 install.sh: a guided Linux installer (preview) that runs exactly the commands platforms.json states, with a CI smoke test per package family
ci / bun-nix (pull_request) Successful in 35s
ci / docs-drift (pull_request) Successful in 20s
ci / docs-site (pull_request) Successful in 1m21s
installer-smoke / smoke (arch, archlinux:base, pacman -Sy --noconfirm --needed curl git nodejs && (pacman-key --init >/dev/null 2>&1 || true)) (pull_request) Failing after 23s
installer-smoke / smoke (fedora-44, fedora:44, dnf install -y -q curl git nodejs) (pull_request) Failing after 0s
ci / web (pull_request) Successful in 1m27s
ci / rust-arm64 (pull_request) Successful in 1m31s
installer-smoke / smoke (debian-13, debian:trixie, apt-get update -qq && apt-get install -y -qq --no-install-recommends ca-certificates curl git nodejs) (pull_request) Failing after 21s
apple / swift (pull_request) Successful in 2m19s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
android / android (pull_request) Canceled after 2m37s
ci / rust (pull_request) Canceled after 2m38s
WP4 of the docs-and-onboarding overhaul (punktfunk-planning design/docs-and-onboarding-overhaul.md).

scripts/install.sh — plain POSIX sh (dash-clean), ~380 lines, `curl -fsSL https://punktfunk.unom.io/install.sh | sh`:
detect the distro from os-release (apt / dnf / pacman / rpm-ostree→sysext; NixOS, SteamOS, Windows
and unknown distros get a one-line pointer and stop; Debian 12 / Ubuntu 24.04 / Mint 22 / Fedora
45 hit the documented floors with the right docs link) → install with the platforms.json lines
VERBATIM (channel and the Fedora group are edited into the string at run time; `--yes` rewrites
them non-interactive, a tty hands the package manager its own prompt; stdin is never read, because
under `curl | sh` stdin is the script) → `punktfunk-host detect-conflicts` (exit 1 = active
Sunshine-family host) → offer to keep both by moving the management API port (PUNKTFUNK_MGMT_BIND,
default 47991, the firewall step opens it) → input group (ujust on Bazzite; no-op if already in) →
optional punktfunk group, GameStream compat, shared clipboard (all default no) → firewalld/ufw
profiles → enable host + console (+ the plugin runner where it isn't) → optional linger → verify
(unit active, UDP 9777 bound) and print the console URL, the password command and the pairing
steps. `--dry-run` prints every command and changes nothing; every prompt has a PUNKTFUNK_INSTALL_*
environment twin; re-running is safe (install skipped when the binary exists). Running under sudo
is refused (host.env and the units belong to the user); root without sudo gets a shim so the
verbatim lines still work.

Decisions: the canonical URL is punktfunk.unom.io/install.sh, a 302 on the website to the script at
raw/branch/main (versioned with the code it installs; precedent: the Bazzite sysext bootstrap) —
the website half is punktfunk-website PR #4. GPU drivers stay the docs pages' job; the one silent
failure (Fedora + NVIDIA without RPM Fusion's ffmpeg-libs → no NVENC) is called out at the end.

Gates: check-docs-drift.sh gate 6 — every apt/pacman/dnf/sysext install line in data/platforms.json
must appear verbatim in the script, and the script must parse (shown to fail on a planted drift).
New path-filtered workflow installer-smoke.yml runs the script unattended in debian:trixie,
fedora:44 and archlinux:base against the real registry, then `punktfunk-host --version`,
`detect-conflicts`, and a re-run that must say "already installed".

Docs: install hub gains "Guided install (preview)" rendered from platforms.json's new `installer`
block via an <Installer/> component (one-liner + inspect-first form + flags); CONTRIBUTING names
the new gate. Verified locally: sh/dash -n, both docs gates, docs-site build + lint, and a
--dry-run matrix over 16 faked os-release files (all four families, every floor, canary, every
option, piped stdin). The container run itself is the CI job's to report — Docker on this machine
was wedged under another session's emulated build.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 20:54:13 +02:00
enricobuehler 20568d988f fix(kwin): KWin creates our virtual output disabled and refuses to stream it — enable it and retry
ci / web (pull_request) Successful in 1m29s
ci / rust-arm64 (pull_request) Successful in 2m30s
ci / docs-site (pull_request) Successful in 1m14s
ci / docs-drift (pull_request) Failing after 19s
ci / bun-nix (pull_request) Successful in 1m42s
android / android (pull_request) Successful in 10m8s
ci / rust (pull_request) Failing after 10m48s
On KWin >= 6.6 `streamVirtualOutput` creates the output on the backend and then
passes `workspace()->findOutput(output)` to the stream, which is null for an
output the workspace does not manage (`wantsToManage` = `isEnabled() &&
!isNonDesktop()`). So an output KWin creates DISABLED is refused with
"Could not find output" — translated into the session's language, and logged
nowhere, because disabling an output is a perfectly valid configuration that
applies successfully. 6.4/6.5 passed the backend output straight through and
streamed it either way.

It repeats forever. The host asks for a STABLE per-client output name precisely
so KWin persists that client's scale and mode against it, so a stored setup
naming it `enabled: false` is reapplied to every future session for that client
— and the user cannot fix it in System Settings, because the output only exists
for the few milliseconds the request is alive.

Repair it instead. On a refusal, enable the head over kde_output_management_v2
and let the retry go again. Two properties of KWin make that possible, both read
off Plasma/6.7 rather than assumed:

  * `sendFailed` only sends the event — it does not emit `finished`, and
    `removeVirtualOutput` is wired to `finished`. The disabled output therefore
    stays alive for as long as we hold the failed stream open, which is the
    window the repair runs in (and why it must run inside the worker thread,
    before the connection drops).
  * `WaylandServer::handleOutputAdded` offers EVERY backend output to the
    output-device registry, gating only placeholders and non-desktop ones. Only
    `wl_output` is gated on being enabled, so a disabled output is invisible to
    `stream_output` but fully addressable over output management.

Enabling it is a user-applied configuration, so KWin persists it against that
output's identity: the retry's fresh request finds a stored setup that enables
it. The repair therefore fixes attempt N+1, never attempt N.

`REPAIRED_HINT` keeps that retry reachable. The opener wraps every worker error
with "KWin virtual output failed", which is the phrase the host's
`is_permanent_build_error` matches to short-circuit the retry loop — so a
repaired refusal carrying it would be classified permanent and the retry that
consumes the repair would never run, making the whole path dead code. A repaired
refusal is reported verbatim without that wrapper; an unrepairable one keeps it
and still fails fast, because nothing about the box changed. The host-side test
pins both halves.
2026-08-19 20:29:27 +02:00
enricobuehler 2a60f94f74 Merge pull request 'Land the WP2 docs rewrite on main (#340 merged into the already-merged #337 branch) and make docs-drift green again' (#343) from docs-wp2-to-main into main
apple / swift (push) Successful in 2m8s
ci / rust-arm64 (push) Successful in 2m29s
ci / docs-site (push) Successful in 1m24s
ci / web (push) Successful in 1m33s
ci / bun-nix (push) Successful in 26s
ci / docs-drift (push) Successful in 26s
deb / build-publish-gamescope (push) Successful in 30s
deb / build-publish-client-arm64 (push) Successful in 1m42s
decky / build-publish (push) Successful in 38s
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 16s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 17s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 16s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 16s
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
arch / build-publish (push) Successful in 10m27s
deb / build-publish (push) Successful in 5m7s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 48s
android / android (push) Successful in 11m33s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m4s
deb / build-publish-host (push) Successful in 5m46s
apple / distribute (push) Successful in 11m16s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 14s
docker / deploy-docs (push) Successful in 55s
docker / builders-arm64cross (push) Successful in 13s
ci / rust (push) Successful in 22m5s
apple / screenshots (push) Successful in 9m53s
deb / smoke-install (push) Successful in 7m18s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m47s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 20m7s
2026-08-19 18:23:48 +00:00
enricobuehler 60406c9d72 Merge origin/main into the docs WP2 branch — bazzite.md was rewritten as bazzite.mdx here and re-touched by #342 there; #342's fact (the Gaming Mode takeover no longer needs the punktfunk group) is ported into bazzite.mdx and the four distro pages, and the Apple client's PUNKTFUNK_CORE_LOG_LEVEL (from #338) is baselined so docs-drift is green on main again.
ci / web (pull_request) Successful in 2m8s
apple / swift (pull_request) Successful in 2m18s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / bun-nix (pull_request) Successful in 31s
ci / docs-drift (pull_request) Successful in 36s
ci / rust-arm64 (pull_request) Successful in 2m57s
ci / docs-site (pull_request) Successful in 1m43s
android / android (pull_request) Successful in 6m54s
ci / rust (pull_request) Successful in 8m37s
2026-08-19 20:21:44 +02:00
enricobuehler 65f697651e Merge pull request 'The bun OOM is a 7 GiB cgroup cap, not a big build — measure it in the log' (#341) from nix-serialise-bun-builds into main
ci / docs-drift (push) Failing after 28s
ci / bun-nix (push) Successful in 30s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 17s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 13s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 13s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 11s
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 11s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 11s
ci / docs-site (push) Successful in 1m9s
ci / web (push) Successful in 1m18s
ci / rust-arm64 (push) Successful in 1m26s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 29s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 40s
docker / builders-arm64cross (push) Successful in 39s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m17s
docker / deploy-docs (push) Successful in 1m15s
ci / rust (push) Canceled after 5m30s
nix / flake (push) Failing after 15m16s
Reviewed-on: #341
2026-08-19 18:18:28 +00:00
enricobuehler a00c4d2a6a Merge pull request 'Docs for two audiences: a get-started track that fits on one screen, a Switching-from-Sunshine page, and install commands quoted from platforms.json' (#340) from worktree-docs-wp2-rewrite into worktree-docs-wp0-wp1-anti-drift
Reviewed-on: #340
2026-08-19 18:16:28 +00:00
enricobuehler e12ef6633c Merge pull request 'A stopped display manager left the box unable to switch sessions — idle its autologin instead' (#342) from worktree-kwin-vout-failed-permanent into main
ci / bun-nix (push) Successful in 26s
ci / docs-drift (push) Failing after 26s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 15s
deb / build-publish-gamescope (push) Successful in 33s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 9s
ci / docs-site (push) Successful in 1m19s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 11s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 16s
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 16s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 47s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 56s
deb / build-publish-client-arm64 (push) Successful in 2m37s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m18s
deb / build-publish-host (push) Successful in 7m10s
android / android (push) Successful in 9m56s
ci / rust-arm64 (push) Successful in 3m39s
arch / build-publish (push) Successful in 9m45s
ci / web (push) Successful in 1m45s
windows-host / package (push) Successful in 13m41s
windows-host / winget-source (push) Skipped
docker / builders-arm64cross (push) Successful in 17s
docker / deploy-docs (push) Successful in 43s
windows-host / canary-manifest (push) Successful in 30s
ci / rust (push) Failing after 11m34s
deb / build-publish (push) Successful in 5m49s
deb / smoke-install (push) Successful in 4m53s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 25m37s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 26m25s
2026-08-19 17:40:07 +00:00
enricobuehler 38a0f54b09 refactor(gamescope): delete the display-manager stop chain the idled takeover retired
ci / bun-nix (pull_request) Successful in 37s
ci / web (pull_request) Successful in 1m14s
ci / docs-site (pull_request) Successful in 1m23s
android / android (pull_request) Successful in 6m52s
ci / rust-arm64 (pull_request) Successful in 2m23s
ci / rust (pull_request) Successful in 9m30s
`cargo check` on Linux flagged six functions the previous commit orphaned. The
linger machinery existed for one reason — stopping the display manager ends the
user's last login session, and logind would take the host down with it about 10 s
later — and nothing stops a display manager any more, so the whole chain goes:
`try_stop_display_manager`, `ensure_host_survives_dm_stop`,
`host_is_under_user_manager`, `cgroup_under_user_manager`, `linger_enabled`, and
the one test that covered only the cgroup predicate. 142 lines out, 17 in.

`mask_unit` becomes `#[cfg(test)]` rather than going with them. Its only caller
now is the mask-lift test, and that test still guards live code:
`lift_autologin_mask` cleans up a takeover adopted from a host old enough to have
laid a mask, so the state it undoes has to stay constructible.

`pf-dm-helper` keeps its `stop` and `linger` verbs even though no caller is left —
only `restore` is still reached — because removing them changes the shipped polkit
action's surface, which is a packaging decision rather than a cleanup.
2026-08-19 19:38:57 +02:00
enricobuehler c420ae4676 Merge pull request 'The Apple client can send its logs to the host — and finally keeps a log worth sending' (#338) from worktree-apple-send-logs into main
audit / bun-audit (sdk) (push) Successful in 23s
audit / bun-audit (plugin-kit) (push) Successful in 30s
audit / bun-audit (web) (push) Successful in 29s
audit / docs-site-audit (push) Successful in 21s
audit / pnpm-audit (push) Successful in 21s
audit / cargo-audit (push) Failing after 57s
apple / swift (push) Successful in 2m13s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m24s
android / android (push) Canceled after 4m34s
arch / build-publish (push) Canceled after 4m40s
ci / rust (push) Canceled after 3m47s
ci / rust-arm64 (push) Canceled after 38s
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
deb / build-publish (push) Canceled after 15s
deb / build-publish-host (push) Canceled after 10s
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 (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 19s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 11s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 13s
audit / license-gate (push) Successful in 5m3s
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
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 28s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 28s
windows-host / package (push) Canceled after 0s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
audit / c-abi-asan (push) Successful in 5m35s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 7m11s
audit / miri (push) Successful in 11m8s
apple / distribute (push) Successful in 11m13s
flatpak / build-publish (push) Successful in 6m6s
apple / screenshots (push) Successful in 9m46s
nix / flake (push) Failing after 23m42s
2026-08-19 17:35:44 +00:00
enricobuehlerandClaude Fable 5 bfd0de8973 docs: two audiences, one home per fact — get-started rewrite, a Switching-from-Sunshine page, and install commands quoted from platforms.json
ci / rust-arm64 (pull_request) Successful in 1m30s
apple / swift (pull_request) Successful in 1m59s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 2m5s
ci / bun-nix (pull_request) Successful in 46s
ci / docs-site (pull_request) Successful in 1m25s
ci / docs-drift (pull_request) Successful in 46s
android / android (pull_request) Successful in 8m17s
ci / rust (pull_request) Successful in 22m6s
WP2 of the docs-and-onboarding overhaul (punktfunk-planning design/docs-and-onboarding-overhaul.md),
stacked on #337 (WP0+WP1).

Nav (meta.json) is now Get started / Guides / Switching from Sunshine / Reference / Troubleshooting.

Get-started track, rewritten for someone with no Linux expertise — one task per page, happy path,
under two minutes each: quickstart (5 steps), install (a pick-your-system hub), ubuntu, debian,
fedora, arch, bazzite (+ a firewall step it was missing), a new nixos page (moved out of install),
pairing. Every distro page quotes its install snippet through a new <Install platform="…"/> MDX
component that reads docs-site/src/data/platforms.json — a byte-identical snapshot of
data/platforms.json (the Docker build context is docs-site/ alone, same arrangement as
openapi.json) which scripts/ci/check-docs-drift.sh now gates. <Ports/> renders the port table the
same way on a new reference page, ports.mdx, so the four distro pages stop restating port lists.

platforms.json itself was fixed while wiring it up: the apt snippet lacked the keyring lines, the
winget one lacked `winget source add`, the Arch repo-add is now idempotent (grep guard — the
documented double-append gotcha disappears at the source), names match the real floors (Ubuntu
26.04+, Windows 11 22H2+), a web-console port entry and the firewall profile names were added, and
client platforms (Windows MSIX, macOS, TestFlight, Play, Steam Deck) so the website can render its
cards from the same file.

Dense material moved down rather than deleted: build-from-source.md (the three distro appendices),
the Mint/LMDE/Cinnamon analysis → requirements.md, TOFU/`--open` → security.md, Secure Boot MOK
paths and the Arch libavcodec soname refusal → troubleshooting, sysext channels/rollback/rebase →
updating.md, the 0.28.1 cert note → windows-host.

New page switching-from-sunshine.md: coexistence via the 47990 move (PUNKTFUNK_MGMT_BIND), the
Windows display-topology overlap, detect-conflicts exit semantics (1 only for an active host — the
troubleshooting page said "non-zero if any"), what maps to what, migration steps. The
troubleshooting Sunshine section is tightened and points there.

The three facts whose only home was a README now live in troubleshooting — the ffmpeg-libs weak
dependency (no NVENC on Fedora), the pacman double-append error, the ds_inhibit SELinux storm with
DualSense pads on Bazzite — and packaging/bazzite + packaging/arch READMEs are pointers.

Stale claims corrected against the tree: the packaged unit runs the native-only `serve` and
GameStream is opt-in on every route (kde, hyprland, security, steamos-host said otherwise);
host.env is optional (EnvironmentFile=-); Fedora 43 uses the `bazzite` group; the iOS clipboard
bridge exists; PUNKTFUNK_GAMESCOPE_SDR_NITS defaults to 203.

Guides trimmed for verbosity (~5% by words — they are fact-dense; every heading, command, number,
env var and link target is preserved, verified by token-inventory diffs). Tooling: docs-site README
and CONTRIBUTING describe the platforms snapshot; input/uninstall/support-matrix links retargeted.

Verified: check-docs-drift.sh and check-docs-links.sh green, docs-site `bun run build` + `bun run
lint` green, and the built site served locally to confirm every cross-page anchor the rewrite
links to (37) renders, plus the <Install/> blocks and the <Ports/> table.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 19:08:38 +02:00
enricobuehlerandClaude Fable 5 1dd5df0127 ci: the drift gate matched its own comment, and the OpenAPI snapshot was two versions stale
Two reds on #337's first CI run, both self-inflicted:

- Gate 3 greps every quoted "PUNKTFUNK_*" literal outside docs-site — including the gate's own
  explanatory comment, which spelled out ("PUNKTFUNK_X") as an example and so reported PUNKTFUNK_X
  as a new undocumented knob. Reworded the comment; the regex is unchanged.
- The `rust` job's regenerate-and-diff found `api/openapi.json` still saying `"version": "0.29.0"`
  while the crate is 0.31.0 — the only difference in the whole spec. Regenerated with
  `cargo run -p punktfunk-host -- openapi` (in the punktfunk-rust-ci image) and copied the
  docs-site snapshot.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 19:07:56 +02:00
enricobuehler 08c45b96eb fix(nix): measure the cgroup cap instead of guessing at the bun OOM
ci / web (pull_request) Successful in 1m15s
ci / rust-arm64 (pull_request) Successful in 2m11s
ci / docs-drift (pull_request) Failing after 23s
ci / docs-site (pull_request) Successful in 1m42s
ci / bun-nix (pull_request) Successful in 1m55s
ci / rust (pull_request) Failing after 7m38s
nix / flake (pull_request) Failing after 14m44s
Reverts the --max-jobs 1 from the previous commit. The hypothesis it encoded —
that punktfunk-web and punktfunk-scripting bundling concurrently exhausted the
box — is DISPROVEN: run 19444 failed identically with it, and the Environment
step it added shows why the premise was wrong:

    Mem: 125Gi total, 48Gi available     Swap: 8.0Gi     nproc: 16
    overlay 172G, 108G avail

Nothing was scarce. Serialising also would have cost real wall-clock on the
publish tier, which builds six derivations including an hour of Rust, so it does
not stay on an unproven basis.

Also ruled out: bun is 1.3.13 in BOTH the old and new nixpkgs, so the lock bump
did not change the bundler.

What remains is what those numbers cannot see. Inside a container /proc/meminfo
and free(1) report the HOST, so a cgroup memory cap is invisible to them — and a
cap is the only explanation left for a SIGKILL with 48Gi apparently free. So
read it directly: cgroup v2 memory.max, falling back to v1
memory.limit_in_bytes.

No fix in this commit, deliberately. Two guesses have already cost a day of red
runs; the next change should follow the number.
2026-08-19 17:06:24 +00:00
enricobuehler a52d60e242 test(core): the log-sink collector asserts nothing itself — an extern "C" fn must not panic (hygiene gate B)
ci / bun-nix (pull_request) Successful in 24s
ci / web (pull_request) Successful in 1m10s
ci / docs-site (pull_request) Successful in 2m3s
apple / swift (pull_request) Successful in 2m15s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 3m5s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 3m0s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m36s
android / android (pull_request) Successful in 9m4s
ci / rust (pull_request) Successful in 24m47s
nix / flake (pull_request) Successful in 15m51s
2026-08-19 19:02:21 +02:00
enricobuehler c2f5e91b3d fix(gamescope): a stopped display manager left the box unable to switch sessions — idle its autologin instead
The managed takeover freed Steam by stopping the box's gaming session and,
because a display-manager autologin puts that session straight back, by stopping
the display manager too. That worked until the user asked for a desktop session:
with no DM there is nothing on the box able to start one, so Steam's own "Switch
to Desktop" sat on its modal until a reboot (field report 2026-08-18, .41).

It also could not be detected and worked around. On a steamos-manager box the
switch is a D-Bus call whose every trace is written by the display manager we had
just stopped — measured on .41: the `~/.config/steamos-session-select` sentinel is
never written (that is the ChimeraOS/Nobara layout), `/var/lib/sddm/state.conf`
only advances when sddm actually STARTS a session, `get-default-login-mode` stays
`game` for a non-persistent switch, and `graphical-session.target` going inactive
fires at takeover time too. There is nothing to watch, because the component that
would produce the signal is the one we disabled.

So stop disabling it. The takeover now idles the box's autologin session for the
stream's duration — a drop-in over the `gamescope-session-plus@` template that
replaces `ExecStart` with a process that sleeps — and leaves the DM running. The
autologin still SUCCEEDS, so there is no failed unit to relogin against (a masked
one fails in milliseconds, which is the storm's engine); the session runs nothing,
so Steam is free; and the DM is alive, so the box can service the switch itself.
No privilege, no DM-flavor matrix, and no detection.

Measured on .41, both directions: takeover leaves `steam` down, `sddm` active and
the unit `active (running)` with NRestarts=0; the switch that used to hang brings
Plasma up in ~10 s; the restore puts Steam back within 5 s.

The drop-in lives under $XDG_RUNTIME_DIR for the same reason the bind one does —
it replaces the box's game-mode ExecStart, so a copy outliving the host would be a
box whose Game Mode silently does nothing — and is swept unconditionally at
startup. Its removal sits above every early return in the restore, next to the
bind drop-in's, because the desktop-active return is exactly the path that would
leak it. The restore restarts rather than starts: the unit is active-but-idle, and
`start` on an active unit is a no-op that would log success over it.

Docs and the shipped Bazzite host.env said the takeover has to stop the display
manager and needs the `punktfunk` group; both are now false. The group still gates
the usbip nodes the virtual Steam Deck pad attaches through, which is what the
advice narrows to.
2026-08-19 18:58:18 +02:00
enricobuehler 44cb7f7815 test(core): keep the SAFETY comment on the line before its unsafe block (rustfmt had split them)
apple / swift (pull_request) Successful in 2m10s
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 2m54s
ci / rust-arm64 (pull_request) Successful in 3m43s
ci / web (pull_request) Successful in 3m20s
ci / docs-site (pull_request) Successful in 1m22s
ci / rust (pull_request) Failing after 6m0s
ci / bun-nix (pull_request) Successful in 2m24s
android / android (pull_request) Canceled after 8m49s
nix / flake (pull_request) Canceled after 3m42s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Canceled after 6m9s
2026-08-19 18:53:20 +02:00
enricobuehler 52b89a1592 style(core): rustfmt the log-sink additions
apple / swift (pull_request) Successful in 2m6s
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 2m51s
ci / rust-arm64 (pull_request) Failing after 2m19s
ci / rust (pull_request) Failing after 4m2s
ci / docs-site (pull_request) Successful in 1m22s
android / android (pull_request) Canceled after 4m58s
ci / web (pull_request) Canceled after 2m11s
ci / bun-nix (pull_request) Canceled after 40s
nix / flake (pull_request) Canceled after 27s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Canceled after 2m28s
2026-08-19 18:47:47 +02:00
enricobuehler 57446f9ed9 feat(core): ABI v25 — punktfunk_set_log_callback, so the Apple client hears the core's log lines
ci / rust (pull_request) Failing after 5m3s
android / android (pull_request) Canceled after 6m35s
apple / swift (pull_request) Canceled after 1m41s
apple / distribute (pull_request) Canceled after 0s
apple / screenshots (pull_request) Canceled after 0s
ci / rust-arm64 (pull_request) Canceled after 5m46s
ci / web (pull_request) Canceled after 1m39s
ci / docs-site (pull_request) Canceled after 1m29s
ci / bun-nix (pull_request) Canceled after 0s
nix / flake (pull_request) Canceled after 2s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Canceled after 40s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Canceled after 0s
The core logs through tracing; the desktop and Android shells install a subscriber/logger and
see those lines, the Swift client installed none and saw nothing — every transport warning,
quinn connection event and rustls handshake note vanished, and a client log bundle sent to the
host carried the shell's half only.

- punktfunk_set_log_callback(max_level, cb, user): a log::Log backend behind a C callback
  (level, target, message, user), level-gated by log::set_max_level so anything above the
  ceiling costs no formatting; NULL detaches; Unsupported when another log backend already owns
  the process (android_logger). tracing's `log` feature declared explicitly by the core — it was
  on transitively via quinn, which an ABI promise must not rest on. ABI_VERSION 24 → 25, header
  regenerated, embedding doc §2.6.
- Apple: CoreLog.install() at app start routes the lines into ClientLog under core.<crate>
  (os_log + the send-to-host ring), info ceiling by default (PUNKTFUNK_CORE_LOG_LEVEL raises it).
- Test: a log record and a tracing event both reach the callback with level/target/message,
  interior NUL dropped not truncated, ceiling honoured, detach silences.
2026-08-19 18:33:59 +02:00
enricobuehler a4f6e259e3 fix(nix): serialise the bun builds — punktfunk-web is OOM-killed beside scripting
The nix job's recurring red is not a broken bundle, it is memory:

    punktfunk-web> error: script "build" was terminated by signal SIGKILL (Forced quit)
    Reason: builder failed with exit code 137

MEASURED 2026-08-19: five runs across main and two branches died exactly there,
while other runs on the SAME commits passed — the signature of pressure, not of
a defect. nix builds punktfunk-web and punktfunk-scripting concurrently, and
each one runs a memory-hungry bundler; that pairing is what the runner cannot
afford.

`--max-jobs 1` caps how many DERIVATIONS build at once, not the cores inside
one, so two bundles that each pin a core simply run in sequence. Applied to the
publish tier too, which builds six derivations including those same two.

This step sits BEFORE the publish tier, so every one of those failures also
stopped the binary cache from filling — it has blocked more runs today than
every real bug combined.

Also prints memory in the Environment step. An exit 137 cannot be explained by
the disk numbers that step used to gather on their own.
2026-08-19 16:30:41 +00:00
enricobuehler 6d82716598 Merge pull request 'Android "Send logs to host": the logcat-teed ring uploads over the client's own mTLS' (#339) from worktree-console-ui-sendlogs-android into main
ci / rust-arm64 (push) Successful in 2m3s
ci / web (push) Successful in 2m23s
ci / docs-site (push) Successful in 2m31s
ci / bun-nix (push) Successful in 23s
ci / docs-drift (push) Failing after 25s
deb / build-publish-gamescope (push) Successful in 50s
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 9s
ci / rust (push) Failing after 7m50s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 11s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 10s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 9s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 7s
deb / build-publish-client-arm64 (push) Successful in 2m31s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 53s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m22s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m27s
deb / build-publish-host (push) Successful in 5m2s
arch / build-publish (push) Successful in 11m31s
deb / build-publish (push) Successful in 5m53s
android / android (push) Successful in 13m17s
flatpak / build-publish (push) Successful in 7m7s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 7m15s
docker / builders-arm64cross (push) Successful in 11s
docker / deploy-docs (push) Successful in 48s
deb / smoke-install (push) Successful in 3m24s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 21m57s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 25m9s
Reviewed-on: #339
2026-08-19 16:28:27 +00:00
enricobuehler f584eebb92 feat(android): "Send logs to host" works from the console — the ring, teed from logcat, uploads over the client's own mTLS
ci / bun-nix (pull_request) Successful in 40s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 3m36s
ci / web (pull_request) Successful in 1m38s
ci / docs-site (pull_request) Successful in 1m40s
ci / rust-arm64 (pull_request) Successful in 3m54s
ci / rust (pull_request) Successful in 6m42s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m40s
android / android (pull_request) Successful in 9m5s
WP5b of punktfunk-planning design/console-ui-sweep-2026-08-19.md (the last open item):

- pf-client-core: the logring's RING half (note/render/wallclock — std only) is
  Android-enabled; `send_to_host` stays desktop-gated with the rest of the ureq
  fetches. `wallclock` moves in from the session's ring_layer so every ring feeder
  stamps lines the same way.
- Android native: JNI_OnLoad installs a RingTee — every `log` record goes to logcat
  AND into the ring, in the desktop ring_layer's line shape. `nativeRenderLogs(header)`
  hands Kotlin the rendered bundle.
- Kotlin: `SkiaConsole.sendLogs` replaces the not-available stub — renders the ring
  and POSTs it to /api/v1/client-logs over `mtlsHttpClient` (the library/art path),
  noticing the desktop wording on success/failure. The upload deliberately stays on
  the Kotlin side: OkHttp already owns HTTPS-to-the-pinned-host on this platform,
  and pulling ureq+rustls into the .so for one POST would be a dependency change,
  not a feature.
- console-ui: the host menu's "Send logs" desktop-only gate is gone — paired and
  reachable is the whole condition again; the pinning test flips to assert both
  platforms offer it.
2026-08-19 18:26:13 +02:00
enricobuehler cd0a370229 Merge pull request 'One home per fact: IA rules, docs drift gates in CI, and the READMEs stop restating the docs' (#337) from worktree-docs-wp0-wp1-anti-drift into main
ci / bun-nix (push) Successful in 58s
ci / web (push) Successful in 1m25s
ci / docs-site (push) Successful in 1m34s
ci / docs-drift (push) Failing after 43s
apple / swift (push) Successful in 2m11s
ci / rust-arm64 (push) Successful in 2m8s
deb / build-publish-gamescope (push) Successful in 56s
decky / build-publish (push) Successful in 51s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 23s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 17s
deb / build-publish-client-arm64 (push) Successful in 2m33s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 21s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 13s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 10s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 10s
docker / deploy-docs (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 7s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
deb / build-publish (push) Successful in 4m21s
android / android (push) Canceled after 6m11s
arch / build-publish (push) Canceled after 5m59s
ci / rust (push) Canceled after 4m32s
deb / build-publish-host (push) Canceled after 4m19s
deb / smoke-install (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 44s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 44s
flatpak / build-publish (push) Canceled after 26s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 24s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 16s
apple / distribute (push) Successful in 11m33s
apple / screenshots (push) Successful in 10m3s
Reviewed-on: #337
2026-08-19 16:22:04 +00:00
enricobuehler 3301f5aa60 feat(clients/apple): Send logs to host — a bounded in-app log ring, posted to the paired host from the card menu and the gamepad host options
ci / bun-nix (pull_request) Successful in 26s
ci / web (pull_request) Successful in 1m16s
ci / rust-arm64 (pull_request) Successful in 1m25s
ci / docs-site (pull_request) Successful in 1m26s
apple / swift (pull_request) Successful in 2m16s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / rust (pull_request) Successful in 5m20s
The Gaming Mode console got "Send logs to host" in 0.30 (pf_client_core::logring +
POST /api/v1/client-logs); the Apple client had no ring to send and no POST on its mgmt
transport, and its own session lifecycle was never logged at all — a field report from an
Apple TV carried only the host's half.

- ClientLog: drop-in for Logger(subsystem: "io.unom.punktfunk", category:) — same call
  shape (privacy:/format: interpolations accepted), writes os_log AND a process-global ring
  bounded by 4096 lines / 768 KiB (under the host's 1 MiB cap), stamped with wall-clock
  ISO-8601 so a bundle lines up with the host log. .debug stays out of the ring (per-key
  input chatter would flush it — the Steam Deck DPB lesson). 13 Logger declarations swapped.
- MgmtTransport/MgmtConnection: POST with a length-framed body on the same pooled, pinned
  mTLS connection; LibraryClient.sendLogs posts the ring and returns the bundle id.
- SendLogs.toHost (app): identity + pinned fingerprint required, same gates as the library.
- UI: "Send Logs to Host" in the host card's context menu (paired hosts) with an alert for
  the outcome; a "Send logs to host" row on the gamepad host options screen whose label and
  detail band report Sending…/Logs sent/Couldn't send in place, like Copy link.
- SessionModel logs connect asked/landed/refused, disconnect and session-ended reason.
- ClientLogTests: ring bounds + eviction note + truncation, logger format, POST framing.
2026-08-19 18:17:59 +02:00
enricobuehler 1f0b12de6d Merge pull request 'The gamescope prune can't delete reshade — it installs under read-only directories' (#336) from gamescope-prune-writable into main
ci / bun-nix (push) Successful in 1m24s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 15s
ci / docs-site (push) Successful in 1m51s
ci / web (push) Successful in 1m58s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 15s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 14s
ci / rust-arm64 (push) Successful in 2m18s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 13s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 12s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 18s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 16s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 19s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 40s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 48s
docker / builders-arm64cross (push) Successful in 14s
docker / deploy-docs (push) Successful in 55s
ci / rust (push) Successful in 6m43s
nix / flake (push) Failing after 13m24s
Reviewed-on: #336
2026-08-19 14:42:29 +00:00
enricobuehler 3def50a88a Merge pull request '"Capture system shortcuts" on Mac reaches ⌘Space and ⌘Tab, with Accessibility' (#335) from worktree-apple-system-shortcut-tap into main
ci / bun-nix (push) Successful in 28s
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 11s
ci / web (push) Successful in 1m13s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 13s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 11s
ci / docs-site (push) Successful in 1m43s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 27s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 14s
ci / rust (push) Canceled after 1m55s
ci / rust-arm64 (push) Canceled after 2m6s
apple / swift (push) Successful in 2m16s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 28s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 26s
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
apple / distribute (push) Successful in 11m55s
apple / screenshots (push) Successful in 9m58s
Reviewed-on: #335
2026-08-19 14:40:15 +00:00
enricobuehlerandClaude Opus 5 8f9e451395 docs: one home per fact — IA rules, CI drift gates, and READMEs stop restating the docs
ci / web (pull_request) Successful in 1m3s
ci / bun-nix (pull_request) Successful in 41s
ci / rust-arm64 (pull_request) Successful in 1m58s
ci / docs-drift (pull_request) Failing after 47s
ci / docs-site (pull_request) Successful in 1m48s
android / android (pull_request) Successful in 7m26s
ci / rust (pull_request) Failing after 8m7s
apple / swift (pull_request) Successful in 2m4s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
WP0+WP1 of the docs-and-onboarding overhaul (punktfunk-planning
design/docs-and-onboarding-overhaul.md):

- CONTRIBUTING.md + docs-site/README.md state the ownership rule (docs-site
  owns user-facing facts; READMEs keep rationale + pointers; the website
  deep-links) and the get-started vs reference audience split.
- ci.yml gains a docs-drift job (scripts/ci/check-docs-drift.sh +
  check-docs-links.sh): the OpenAPI snapshot must be a byte copy of
  api/openapi.json, PUNKTFUNK_* vars the docs mention must exist in the tree,
  the undocumented-var set is ratcheted against a named baseline file,
  host-cli.md commands must exist in the host crate, data/platforms.json must
  parse, and internal doc/README links must resolve. The rust job additionally
  regenerates the spec from the built host and diffs it — closing the 'not
  gated' hole CONTRIBUTING itself confessed to. All seven gates were shown to
  FAIL on planted violations before landing (the gate-of-the-gate rule).
- data/platforms.json: single source for install commands, repo URLs, ports
  and the Sunshine/Apollo/Vibeshine conflict facts — the docs rewrite (WP2),
  website download page (WP3) and guided installer (WP4) consume it.
- READMEs (root, bazzite, debian, arch, rpm, flatpak) stop restating docs
  walkthroughs (net -444 lines); facts whose only home a README was (the
  ds_inhibit SELinux storm, the ffmpeg-libs weak dep, the pacman.conf
  double-append) stay put, marked for the WP2 migration.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 16:39:39 +02:00
enricobuehler 0d22333831 Merge pull request 'Console-UI sweep: touch gestures, controller-audio rows, host-menu bind/clipboard, Android idle gates' (#333) from worktree-console-ui-sweep-wps into main
ci / bun-nix (push) Successful in 29s
ci / web (push) Successful in 1m25s
ci / docs-site (push) Successful in 1m33s
ci / rust-arm64 (push) Successful in 2m18s
deb / build-publish-gamescope (push) Successful in 47s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 17s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 17s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 10s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 12s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 12s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m12s
deb / build-publish-client-arm64 (push) Successful in 1m52s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 11s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 12s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 25s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 22s
deb / build-publish (push) Successful in 4m29s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 34s
ci / rust (push) Successful in 6m39s
deb / build-publish-host (push) Successful in 5m54s
docker / builders-arm64cross (push) Successful in 13s
docker / deploy-docs (push) Successful in 35s
arch / build-publish (push) Successful in 10m24s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Failing after 6m42s
android / android (push) Successful in 12m1s
flatpak / build-publish (push) Successful in 7m11s
deb / smoke-install (push) Successful in 3m17s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m41s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m48s
Reviewed-on: #333
2026-08-19 14:26:32 +00:00
enricobuehler 47602f7e59 feat(apple): "Capture system shortcuts" reaches ⌘Space and ⌘Tab, with Accessibility
ci / docs-site (pull_request) Successful in 1m27s
ci / web (pull_request) Successful in 1m30s
ci / bun-nix (pull_request) Successful in 1m32s
apple / swift (pull_request) Successful in 2m10s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 2m15s
ci / rust (pull_request) Successful in 6m56s
A 0.30.0 user reported the setting does nothing for ⌘Space. It never could:
the macOS implementation (0.29, b2146f33) is an NSEvent local monitor, which
only sees the keys AppKit delivers to the app — ⌘Q, ⌘W and their like. The
shortcuts macOS itself owns (⌘Space → Spotlight, ⌘Tab → the Dock, ⌃↑ →
Mission Control, everything under System Settings › Keyboard › Shortcuts) are
consumed by WindowServer before any app is asked. The SDL clients take those
via the private CGSSetGlobalHotKeyOperatingMode, which SDL only compiles in
outside the sandbox; this app is sandboxed on both channels.

The sandbox-legal way is a session-level CGEventTap, which needs Accessibility.
`InputCapture` now installs one while forwarding (and only then — it comes
down with setForwarding(false)/stop(), and capture already releases on any
focus loss, so the tap is never live with another app frontmost). The tap
forwards nothing itself: it takes each keyDown/keyUp off the system and
re-posts it into this app's own queue, addressed to the key window, so it
arrives exactly where the same key would have had macOS not claimed it — the
monitor first (client chords, ⌘ chords → host), then StreamLayerView (the
rest). One key path, no second VK table, no second release bookkeeping; and
the ⌘-chord keyUps macOS used to swallow now arrive too.

Two things verified in standalone harnesses rather than assumed: a reposted
event does reach a local monitor, and a windowless NSEvent(cgEvent:) does NOT
reach the first responder — NSApp.sendEvent routes key events by
event.window — hence the re-stamp onto the key window's number. The
intercept half (tap ahead of Spotlight, inside the sandbox) needs a granted
Accessibility switch this machine doesn't have; that is the live test left.

Gating per event: forwarding, capture mouse model (⌃⌥⇧M flips it
mid-capture, so it is read live rather than at install), app active. Any
other state passes the key through untouched — a tap that swallows keys for
the whole Mac is the failure mode designed against. Installed on the main
run loop on purpose: a hung main thread trips the tap timeout and macOS hands
the keyboard back; the callback re-arms on kCGEventTapDisabledBy* otherwise.

The Accessibility prompt is asked only from Settings — on a genuine off→on
flip of the toggle, or an explicit "Allow Accessibility access…" button that
also opens the pane — never at stream start, and never for the default-on
users this update lands on. Without the grant the setting keeps doing what it
did in 0.29, and its caption now says exactly which half works. App Review
notes carry the justification.
2026-08-19 15:55:02 +02:00
enricobuehler 0fd44d8242 fix(console-ui): drop_non_drop in the bind-profile test — NLL already ends the borrow
ci / bun-nix (pull_request) Successful in 28s
ci / web (pull_request) Successful in 1m11s
ci / docs-site (pull_request) Successful in 1m17s
ci / rust-arm64 (pull_request) Successful in 3m12s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m32s
ci / rust (pull_request) Successful in 8m5s
android / android (pull_request) Successful in 8m48s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 2m51s
2026-08-19 15:45:53 +02:00
enricobuehler 6807d7951c fix(nix): make $out writable before the prune — reshade installs read-only
ci / bun-nix (pull_request) Successful in 25s
ci / docs-site (pull_request) Successful in 1m12s
ci / web (pull_request) Successful in 1m17s
ci / rust-arm64 (pull_request) Successful in 1m43s
ci / rust (pull_request) Successful in 5m13s
nix / flake (pull_request) Failing after 10m41s
With the WSI layer building, the derivation reaches the prune and dies on every
reshade file it tries to remove:

    rm: cannot remove '.../share/gamescope/reshade/Shaders/lilium__tone_mapping.fx':
        Permission denied

gamescope's own default_extras_install.sh installs those under read-only
DIRECTORIES (mode 555), and rm needs write permission on the CONTAINING
directory rather than on the file. The compositor is fully built and installed
by that point, so the log reads as finished right up to the failure.

Only reachable now that #332 got the build past the layer assertion — the third
latent fault in this install path, each one having masked the next: patch drift
(#328), enableWsi defaulting off (#332), and now this.

Nix seals $out read-only after the builder exits, so widening it mid-build costs
nothing and changes nothing in the output.
2026-08-19 13:39:04 +00:00
enricobuehler f50721aedb fix(console-ui): the two HostRow literals the field sweep missed (pair test, settings test)
ci / bun-nix (pull_request) Successful in 44s
ci / rust-arm64 (pull_request) Failing after 1m9s
ci / web (pull_request) Successful in 1m11s
ci / docs-site (pull_request) Successful in 1m12s
ci / rust (pull_request) Failing after 3m2s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Failing after 4m8s
android / android (pull_request) Successful in 5m58s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 3m10s
2026-08-19 15:32:37 +02:00
enricobuehler 5d3301ed9b feat(console-ui): touch gestures, controller-audio rows, host-menu bind/clipboard, Android idle gates
ci / bun-nix (pull_request) Successful in 27s
ci / rust-arm64 (pull_request) Failing after 57s
ci / web (pull_request) Successful in 1m2s
ci / docs-site (pull_request) Successful in 1m17s
ci / rust (pull_request) Failing after 3m7s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Failing after 3m31s
android / android (pull_request) Successful in 6m14s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 2m57s
The 2026-08-19 console-ui sweep (punktfunk-planning design/console-ui-sweep-2026-08-19.md),
WP1-WP6:

- WP1 — touch deferred-tap + drag-to-scroll. PointerInput::Down grows a touch flag
  (SDL Finger* arms and Android toolType feed it; SDL's touch-synthesized mouse events
  are dropped); the shell tracks the gesture: within slop a lift is a tap delivered at
  the anchor, past it drags emit one synthetic scroll tick per 56dp of dominant-axis
  travel. Fixes the on-glass defect where any swipe across the settings list cycled the
  value it landed on (MenuList presses focus AND activate). Mouse behavior unchanged.
  Fling deliberately not included; the Release edge stays for it.
- WP2 — Controller haptics + Controller speaker rows (trust::Settings::{pad_haptics,
  pad_speaker}) in the Controller tab, forwarding-gated like their siblings; the
  speaker row speaks the GTK switch's dialect over the stored string. Detail strings
  are platform-aware now (the Stats/Mouse desktop chords are not taught on Android).
- WP3 — Send logs was already desktop-gated in actions(); pinned by a test.
- WP4 — ConsoleCmd::BindProfile lands (the port design's WP5 leftover): a Default
  profile… host-menu action opens a pin_hosts-style chooser; desktop arm writes
  KnownHost::profile_id, Kotlin arm mirrors it.
- WP5 — per-host Shared clipboard toggle on the host menu (HostRow::clipboard_sync,
  ConsoleCmd::SetClipboard, both service arms). Platform defaults stay divergent on
  purpose; the console shows and writes the stored value only.
- WP6 — Android idle gates: the reachability sweep only probes while the console is
  attached, and the render thread drops to half rate after 60s without input.
2026-08-19 15:14:20 +02:00
enricobuehler 2b81bd286f fix(clients): the Deck learns a host's wake MAC, so Wake-on-LAN can fire there at all
ci / bun-nix (pull_request) Successful in 27s
ci / web (pull_request) Successful in 1m33s
ci / docs-site (pull_request) Successful in 1m41s
ci / rust-arm64 (pull_request) Successful in 1m58s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 3m22s
ci / rust (pull_request) Successful in 5m19s
android / android (pull_request) Successful in 7m21s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m43s
Every wake gate in the codebase reads `!host.mac.is_empty()` against the saved
record — `ConnectPlan::wake`, the console's `can_wake`, `punktfunk wake`. That MAC
only ever reached the store through `trust::learn_mac`, and `learn_mac` had exactly
two callers: the GTK hosts page and the WinUI one.

Neither runs on a Steam Deck. Gaming Mode has only the Decky panel (which drives the
headless CLI) and the console home — and those learned the management port alone,
never the MAC. So a Deck's records stayed MAC-less forever, every wake gate stayed
false, and Wake-on-LAN was skipped silently: no packet, no error, nothing to see.
It worked on desktop purely because those two hosts pages learn on each discovery
tick. (#322)

Rather than add the missing call twice, collapse the three per-field learners
(`learn_mac`, `learn_os`, `learn_mgmt_port` — three `pub fn`s, three load/save
cycles) into one `learn_from_advert`, and call it at every site where an advert
meets a saved record: both desktop hosts pages, the console home, and the CLI's
`discover`. Remembering one call is not a thing a front-end can half-do; remembering
three is what produced this. It takes the three fields rather than a `DiscoveredHost`
because there are two of those — core's and the WinUI shell's verbatim port.

`discover` is where the panel-only flow is fixed: it is the one verb the Decky panel
runs that ever sees an advert. It keeps `KnownHosts::read()`, so it still mints no
ids and cannot join the race that comment warns about, and `learn_from_advert` writes
only when an advert genuinely taught the record something — a steady-state panel
refresh touches no disk.

Two things fall out of the same root cause: the console home now persists the OS
chain too, so a Deck host's icon stops vanishing the moment mDNS goes quiet; and
`punktfunk wake`'s "connect to it once while it's awake" is replaced, since a MAC
comes from an advert and never from a connect — that wording sent this diagnosis
looking in the wrong place.

The magic-packet sender itself was never at fault (`punktfunk-core::wol` passes its
7 tests) and neither was the flatpak sandbox (`--share=network`). Nothing reached
them.

Closes #322
2026-08-19 14:16:14 +02:00
223 changed files with 11820 additions and 5082 deletions
+6
View File
@@ -0,0 +1,6 @@
<!-- What and why — the diff says how. -->
**User-facing fact changed?** (an install step, a knob, a port, what a feature does, a limit)
→ the docs-site page that owns it is updated in this PR, or this is n/a. Install/repo/port facts
live in `data/platforms.json`. (CONTRIBUTING.md "Where facts live"; `docs-drift` in CI only
catches the mechanical half.)
+3 -1
View File
@@ -248,7 +248,9 @@ jobs:
if: steps.webconsole.outputs.cache-hit != 'true'
run: |
cd web
bun install --frozen-lockfile --ignore-scripts
# Retried: bun's download-and-extract is single-shot, and a truncated tarball reads as
# `Fail extracting tarball` (ci.yml's web job has the measurement).
bash ../scripts/ci/retry.sh 3 bun install --frozen-lockfile --ignore-scripts
bun run build
- name: The console must exist (cache hit or fresh build)
+52 -2
View File
@@ -175,6 +175,19 @@ jobs:
- name: Test (unit + loopback + proptest + C ABI harness)
run: cargo test --workspace --locked
# The deep half of the docs-drift gates (the `docs-drift` job checks the docs-site copy
# and the textual rest): the committed spec must match what the binary actually serves.
# Build already compiled punktfunk-host with default features, so this re-links at worst.
# Byte diff on purpose — the generator is deterministic, and if that ever stops being
# true it deserves to surface here.
- name: OpenAPI spec drift gate
run: |
cargo run -p punktfunk-host --locked -- openapi > /tmp/openapi.regen.json
diff -u api/openapi.json /tmp/openapi.regen.json >/dev/null || {
echo "::error::api/openapi.json is stale — regenerate: cargo run -p punktfunk-host -- openapi > api/openapi.json && cp api/openapi.json docs-site/public/openapi.json"
exit 1
}
# The GPU encode backends are OFF by default, so every step above compiles ~none of them:
# `nvenc` gates enc/linux/nvenc_cuda.rs (+ nvenc_core/nvenc_status) and `vulkan-encode` gates
# enc/linux/vulkan_video.rs (+ the vendored vk_av1_encode/vk_valve_rgb bindings) — ~8,150
@@ -326,8 +339,19 @@ jobs:
working-directory: /
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates git nodejs
- uses: actions/checkout@v4
# RETRIED, like every other single-shot network call in CI (scripts/ci/retry.sh's header
# has the why: this box runs many jobs in parallel and drops packets under that load).
# `bun install` streams download-and-extract, so a tarball truncated mid-stream surfaces
# as `error: Fail extracting tarball for "<pkg>"` — which reads like a corrupt package and
# is not one. Measured 2026-08-20: run 19630's docs-site died that way on
# @rolldown/binding-linux-x64-gnu (8.3 MB) while the web job installed the same registry
# in the same run, and run 19632 installed the identical lockfile seven minutes later. The
# tarball's sha512 matches the lockfile and both bun 1.3.13 and 1.3.14 extract it from
# disk, so there was never anything wrong with the package. 3 attempts (10s+20s backoff),
# not retry.sh's usual 5: a genuinely stale lockfile fails deterministically here, and
# 30s is enough to ride out a load burst without making that wait a minute and a half.
- name: Install dependencies
run: bun install --frozen-lockfile --ignore-scripts
run: bash ../scripts/ci/retry.sh 3 bun install --frozen-lockfile --ignore-scripts
# Build first: it generates the orval API client + paraglide messages that
# typechecking imports.
- name: Build
@@ -355,8 +379,9 @@ jobs:
working-directory: /
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates git
- uses: actions/checkout@v4
# Retried — see the web job above; this is the job the flake was measured on.
- name: Install dependencies
run: bun install --frozen-lockfile --ignore-scripts
run: bash ../scripts/ci/retry.sh 3 bun install --frozen-lockfile --ignore-scripts
# Build first: fumadocs-mdx emits the .source typegen the typecheck imports.
- name: Build
run: bun run build
@@ -390,3 +415,28 @@ jobs:
# schema stability across bun2nix releases). Fix with: scripts/ci/check-bun-nix.sh --fix
- name: bun.nix drift gate
run: sh scripts/ci/check-bun-nix.sh
# Docs drift gates — pure git-grep textual checks, no cargo, no bun install (the deep half,
# regenerating the OpenAPI spec from the built host, rides in the `rust` job above). Same
# reasoning as bun-nix for being UNFILTERED: docs drift arrives through commits that look
# unrelated to docs — a renamed env var, a removed subcommand, a moved page.
docs-drift:
runs-on: ubuntu-24.04
container:
image: oven/bun:1
timeout-minutes: 15
steps:
# oven/bun ships neither git nor a real node, and the slim base has no CA bundle —
# actions/checkout needs all three (see the web job).
- name: Install git + node + CA certs
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates curl git nodejs
- uses: actions/checkout@v4
# OpenAPI snapshot in sync, PUNKTFUNK_* vars in docs still exist, undocumented-var
# ratchet (baseline: scripts/ci/docs-undocumented-env-baseline.txt), host-cli.md commands
# still exist, data/platforms.json parses.
- name: Docs drift gates
run: sh scripts/ci/check-docs-drift.sh
# Internal links only: /docs/* page links in docs-site content, relative file links in
# the repo's markdown. External URLs and #anchors are deliberately not checked.
- name: Docs link check
run: sh scripts/ci/check-docs-links.sh
+3 -1
View File
@@ -231,7 +231,9 @@ jobs:
# scripts, and web's `postinstall` is `bun2nix -o bun.nix` — a Nix codegen step this job
# neither consumes nor commits, whose only effect here is to make the install depend on
# bun2nix resolving. `build` re-runs its own `prebuild` codegen regardless.
bun install --frozen-lockfile --ignore-scripts
# Retried: bun's download-and-extract is single-shot, and a truncated tarball reads as
# `Fail extracting tarball` (ci.yml's web job has the measurement).
bash ../scripts/ci/retry.sh 3 bun install --frozen-lockfile --ignore-scripts
bun run build
if ! grep -q 'Bun\.serve' .output/server/index.mjs; then
echo "ERROR: web build is not a bun bundle — need the 'bun' preset + custom entry"; exit 1
+69
View File
@@ -0,0 +1,69 @@
# Smoke test for the guided installer (scripts/install.sh, docs-and-onboarding overhaul WP4).
# Runs the script unattended inside a clean container per package family against the REAL
# package registry — the one path a textual gate can't cover: does the repo line, the key import
# and the install actually work today on a fresh box. `--no-start` because a container has no
# user systemd; the script degrades to printing the enable command, which is also under test.
#
# Path-filtered on purpose: it pulls ~100 MB of packages per family, so it runs when the script
# or its fact source changes, not on every push (check-docs-drift.sh gate 6 covers the cheap
# half — the install lines in the script must match data/platforms.json verbatim — on every push).
name: installer-smoke
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on:
push:
branches: [main]
paths:
- scripts/install.sh
- data/platforms.json
- .gitea/workflows/installer-smoke.yml
pull_request:
paths:
- scripts/install.sh
- data/platforms.json
- .gitea/workflows/installer-smoke.yml
jobs:
smoke:
name: smoke (${{ matrix.family }})
runs-on: ubuntu-24.04
timeout-minutes: 25
strategy:
fail-fast: false
matrix:
include:
# actions/checkout needs git + node + CA certs in the container; curl is the
# script's own prerequisite (it says so and stops without it).
- family: debian-13
image: debian:trixie
prep: apt-get update -qq && apt-get install -y -qq --no-install-recommends ca-certificates curl git nodejs
- family: fedora-44
image: fedora:44
prep: dnf install -y -q curl git nodejs
- family: arch
image: archlinux:base
prep: pacman -Sy --noconfirm --needed curl git nodejs && (pacman-key --init >/dev/null 2>&1 || true)
container:
image: ${{ matrix.image }}
steps:
- name: Prepare the container (${{ matrix.family }})
run: ${{ matrix.prep }}
- uses: actions/checkout@v4
# No tty → the script runs as --yes; --no-start because there is no user systemd here.
# Root without sudo → the script's sudo shim, another path under test.
- name: Run the installer unattended
run: sh scripts/install.sh --yes --no-start
- name: The host is installed and conflict-free
run: |
punktfunk-host --version
punktfunk-host detect-conflicts
- name: Re-running is a no-op install
run: sh scripts/install.sh --yes --no-start | grep -q 'already installed'
- name: --uninstall takes the packages and the repo off again
run: |
sh scripts/install.sh --yes --uninstall
! command -v punktfunk-host
! test -e /etc/apt/sources.list.d/punktfunk.list -o -e /etc/yum.repos.d/punktfunk.repo
! grep -q '^\[punktfunk\]' /etc/pacman.conf 2>/dev/null
@@ -111,3 +111,20 @@ jobs:
name: punktfunk-linux-client-screenshots
path: clients/linux/screenshots
retention-days: 30
# The artifact above is browser-only (Gitea's API doesn't serve v3 artifacts), which
# blocked reusing these shots for the docs. Publish them to the generic package registry
# too — fixed version `ci`, delete-then-PUT so each run overwrites, anonymous GET on a
# public repo:
# https://git.unom.io/api/packages/unom/generic/punktfunk-linux-client-screenshots/ci/<scene>.png
- name: Publish screenshots to the package registry
env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
BASE="https://git.unom.io/api/packages/unom/generic/punktfunk-linux-client-screenshots/ci"
for f in clients/linux/screenshots/*.png; do
name=$(basename "$f")
curl -fsS -o /dev/null --user "enricobuehler:$TOKEN" -X DELETE "$BASE/$name" || true
curl -fsS -o /dev/null --user "enricobuehler:$TOKEN" --upload-file "$f" "$BASE/$name"
echo "published $BASE/$name"
done
+13 -1
View File
@@ -173,7 +173,19 @@ jobs:
# with "no space left on device" mid-`bun install`), and a Nix build is the heaviest thing
# here — so record the headroom, or a future failure is a guess.
- name: Environment
run: df -h / /nix /tmp || true
# Disk AND memory. This job's recurring failure is an OOM kill, and `df` cannot explain
# one — a run that dies at exit 137 with only disk numbers in the log is a guess.
run: |
df -h / /nix /tmp || true
free -h 2>/dev/null || grep -E '^(MemTotal|MemAvailable|SwapTotal)' /proc/meminfo || true
nproc 2>/dev/null || true
# THE number for this job's recurring exit 137. `free` and /proc/meminfo report the HOST
# inside a container, so they showed 125Gi total / 48Gi available on a run that then got
# bun SIGKILLed (19444) — a cgroup cap is invisible to them and is the only remaining
# explanation. cgroup v2 first, then v1; "max" means uncapped.
cat /sys/fs/cgroup/memory.max 2>/dev/null \
|| cat /sys/fs/cgroup/memory/memory.limit_in_bytes 2>/dev/null \
|| echo "no cgroup memory limit readable"
# Evaluates + instantiates every flake output without building any of it.
- name: nix flake check (eval only)
+5 -2
View File
@@ -37,15 +37,18 @@ jobs:
- uses: actions/checkout@v4
# Both installs retried: bun's download-and-extract is single-shot, and a truncated tarball
# reads as `Fail extracting tarball` (ci.yml's web job has the measurement). A publish job
# is the worst place to lose to a dropped packet — the tag is already pushed.
- name: Build the SDK (file:../sdk dependency source)
working-directory: sdk
run: |
bun install --frozen-lockfile --ignore-scripts
bash ../scripts/ci/retry.sh 3 bun install --frozen-lockfile --ignore-scripts
bun run build
- name: Install dependencies
working-directory: plugin-kit
run: bun install --frozen-lockfile --ignore-scripts
run: bash ../scripts/ci/retry.sh 3 bun install --frozen-lockfile --ignore-scripts
# bun 1.3 installs a `file:` dependency by copying its DIRECTORIES but symlinking each
# top-level FILE to itself — `node_modules/@punktfunk/host/package.json -> package.json`, a
+3 -1
View File
@@ -176,7 +176,9 @@ jobs:
if: steps.webconsole.outputs.cache-hit != 'true'
run: |
cd web
bun install --frozen-lockfile --ignore-scripts
# Retried: bun's download-and-extract is single-shot, and a truncated tarball reads as
# `Fail extracting tarball` (ci.yml's web job has the measurement).
bash ../scripts/ci/retry.sh 3 bun install --frozen-lockfile --ignore-scripts
bun run build
# Same mandatory assertion as deb.yml — a missing or wrong-preset bundle must fail here, not
+4 -1
View File
@@ -39,8 +39,11 @@ jobs:
- uses: actions/checkout@v4
# Retried: bun's download-and-extract is single-shot, and a truncated tarball reads as
# `Fail extracting tarball` (ci.yml's web job has the measurement). A publish job is the
# worst place to lose to a dropped packet — the tag is already pushed.
- name: Install dependencies
run: bun install --frozen-lockfile --ignore-scripts
run: bash ../scripts/ci/retry.sh 3 bun install --frozen-lockfile --ignore-scripts
- name: Typecheck
run: bun run typecheck
+3 -1
View File
@@ -40,8 +40,10 @@ jobs:
- uses: actions/checkout@v4
# --ignore-scripts skips the prepare→codegen hook (mirrors ci.yml); run codegen
# explicitly since build-storybook has no prebuild hook of its own.
# Retried: bun's download-and-extract is single-shot, and a truncated tarball reads as
# `Fail extracting tarball` (ci.yml's web job has the measurement).
- name: Install dependencies
run: bun install --frozen-lockfile --ignore-scripts
run: bash ../scripts/ci/retry.sh 3 bun install --frozen-lockfile --ignore-scripts
- name: Generate API client + i18n messages
run: bun run codegen
# Pulls the matching Chromium build + the apt libs it needs (root in-container).
+38 -8
View File
@@ -56,8 +56,12 @@
#
# ── Packaging (the `Pack + sign MSIX` step onward; skipped on pull requests) ──────────────────────
#
# Publishes signed MSIX packages (x64 + ARM64) to Gitea's generic package registry, so Windows boxes
# can install a real package (Start tile, clean install/uninstall) instead of a loose exe.
# Publishes THREE artifacts per arch (x64 + ARM64) to Gitea's generic package registry, all packed
# from one assembled layout:
# punktfunk-client-setup_<arch>.exe — Inno Setup per-user installer, the DEFAULT download
# (stable path Steam can launch: overlay + Big Picture work)
# punktfunk-client-windows_<arch>-portable.zip — the same file set, no installer
# punktfunk-client-windows_<arch>.msix — kept for Microsoft Store compatibility
#
# Registry (public, unom org): https://git.unom.io/unom/-/packages (generic group)
# Packaging internals: clients/windows/packaging/README.md.
@@ -283,6 +287,28 @@ jobs:
-Version $env:MSIX_VERSION -Arch ${{ matrix.arch }} `
-TargetDir ${{ matrix.td }}\${{ matrix.target }}\release -OutDir ${{ matrix.td }}\msix
# The DEFAULT download: a per-user Inno Setup exe + a portable zip, packed from the layout
# the MSIX step just assembled. The MSIX shape (WindowsApps ACLs, alias-only activation)
# breaks Steam's non-Steam-game picker, the Steam overlay injection and Big Picture launch;
# the installer's stable %LOCALAPPDATA%\Programs\Punktfunk path is the fix. The MSIX stays
# published for Microsoft Store compatibility. Same signing env as the MSIX step above.
- name: Pack + sign installer + portable zip
if: github.event_name != 'pull_request'
shell: pwsh
env:
AZURE_CODESIGNING_ENDPOINT: https://neu.codesigning.azure.net/
AZURE_CODESIGNING_ACCOUNT: unomsigning
AZURE_CODESIGNING_PROFILE: unom-io
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
MSIX_CERT_PFX_B64: ${{ secrets.MSIX_CERT_PFX_B64 }}
MSIX_CERT_PASSWORD: ${{ secrets.MSIX_CERT_PASSWORD }}
run: |
& clients/windows/packaging/pack-client-installer.ps1 `
-Version $env:MSIX_VERSION -Arch ${{ matrix.arch }} `
-LayoutDir ${{ matrix.td }}\msix\layout -OutDir ${{ matrix.td }}\installer
- name: Publish to Gitea generic registry
if: github.event_name != 'pull_request'
shell: pwsh
@@ -301,7 +327,10 @@ jobs:
# on that accident, so removing the quotes can't silently reintroduce it.
$aliasNames = @{ "$($env:MSIX_PATH)" = "$($env:PKG)_${{ matrix.arch }}.msix" }
if ($env:MSIX_CER_PATH) { $aliasNames[$env:MSIX_CER_PATH] = "$($env:PKG)_${{ matrix.arch }}.cer" }
$files = @($env:MSIX_PATH, $env:MSIX_CER_PATH) | Where-Object { $_ -and (Test-Path $_) }
# The installer + portable zip (the default download; docs point at these alias URLs).
if ($env:CLIENT_SETUP_PATH) { $aliasNames[$env:CLIENT_SETUP_PATH] = "punktfunk-client-setup_${{ matrix.arch }}.exe" }
if ($env:CLIENT_ZIP_PATH) { $aliasNames[$env:CLIENT_ZIP_PATH] = "$($env:PKG)_${{ matrix.arch }}-portable.zip" }
$files = @($env:MSIX_PATH, $env:MSIX_CER_PATH, $env:CLIENT_SETUP_PATH, $env:CLIENT_ZIP_PATH) | Where-Object { $_ -and (Test-Path $_) }
if (-not $files) { throw "pack produced no artifacts to publish" }
function Put($f, $url) {
# The generic registry makes a versioned path immutable and 409s a re-upload, so a tag
@@ -324,10 +353,11 @@ jobs:
Put $f "$base/$alias/$an"
}
# On a real release, also attach the MSIX (+ its .cer) to the unified Gitea Release. Both
# arch legs attach to the same release concurrently — the helper's create-or-fetch handles
# the race, and x64/arm64 filenames differ so the assets don't collide.
- name: Attach MSIX to the Gitea release (stable tags only)
# On a real release, also attach the installer + portable zip + MSIX (+ its .cer) to the
# unified Gitea Release. Both arch legs attach to the same release concurrently — the
# helper's create-or-fetch handles the race, and x64/arm64 filenames differ so the assets
# don't collide.
- name: Attach client artifacts to the Gitea release (stable tags only)
if: startsWith(gitea.ref, 'refs/tags/v')
shell: pwsh
env:
@@ -335,6 +365,6 @@ jobs:
run: |
. scripts/ci/gitea-release.ps1
$rid = Ensure-GiteaRelease -Tag $env:GITHUB_REF_NAME -Name $env:GITHUB_REF_NAME -Prerelease 'auto'
foreach ($f in @($env:MSIX_PATH, $env:MSIX_CER_PATH)) {
foreach ($f in @($env:CLIENT_SETUP_PATH, $env:CLIENT_ZIP_PATH, $env:MSIX_PATH, $env:MSIX_CER_PATH)) {
if ($f -and (Test-Path $f)) { Upsert-GiteaAsset -ReleaseId $rid -File $f }
}
+643 -50
View File
@@ -12,18 +12,293 @@ with the version table of the release you are moving to, then read **Breaking ch
---
## 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
90 commits since v0.30.0 (65 non-merge).
170 commits since v0.30.0 (113 non-merge), counted at the tip this was cut from.
Nothing versioned moves. `WIRE_VERSION` stays **2**, the C ABI stays **24**`include/punktfunk_core.h`
is byte-identical to the v0.30.0 tag — the driver protocol, gamepad channel and plugin index schema
are all unchanged, and no `trust::Settings` field, capability bit or control-message type byte was
added. Every 0.30.x host, client, driver and plugin keeps interoperating in both directions, with no
re-pairing.
One versioned surface moves, additively: the **C ABI goes 24 → 25**, a single new symbol
(`punktfunk_set_log_callback`) that lets an embedder hear the core's own log lines. Nothing else
does — `WIRE_VERSION` stays **2**, the driver protocol, gamepad channel and plugin index schema are
unchanged, and no `trust::Settings` field, capability bit or control-message type byte was added.
No existing C function changed its signature or behaviour and no `#[repr(C)]` struct grew a field,
so an embedder that adopts nothing rebuilds against the new header and is done. Every 0.30.x host,
client, driver and plugin keeps interoperating in both directions, with no re-pairing.
What did move is beneath the versioned surfaces, and three parts of it are worth a packager's or
embedder's attention: the Linux host package installs **three new system files** (a udev rule, a
Beneath the versioned surfaces, four things are worth a packager's or embedder's attention: the
**Windows client's default download changes** to a per-user installer plus a portable zip, with the
MSIX kept for the Store; the Linux host package installs **three new system files** (a udev rule, a
WirePlumber policy and an ALSA UCM drop-in) that the DualSense audio path depends on; the Linux
desktop-audio capture **flipped topology by default** (`PUNKTFUNK_STREAM_SINK` unset now means a
host-owned `null-audio-sink`, with `=stream` a one-release escape hatch to the 0.30 shape); and the
@@ -35,7 +310,7 @@ three ABIs, which removes the Compose screenshot scenes.
| | v0.30.0 | v0.31.0 | Notes |
|---|---|---|---|
| Wire protocol | 2 | **2** | unchanged |
| C ABI | 24 | **24** | unchanged — `include/punktfunk_core.h` is byte-identical to the v0.30.0 tag; the only new `pub` items in `punktfunk-core` are three RT-safe DSP helpers (`crossfade_insert`, `pcm::raised_cosine_tail`, `pcm::raised_cosine_head`), Rust-only, no `pub const` for cbindgen to pick up |
| C ABI | 24 | **25** | one additive step: v25 adds `punktfunk_set_log_callback` and the `PunktfunkLogCb` typedef (below). No existing declaration moved and no struct grew a field. Also new in `punktfunk-core`, Rust-only: three RT-safe DSP helpers (`crossfade_insert`, `pcm::raised_cosine_tail`, `pcm::raised_cosine_head`) |
| Rust edition | 2024 | **2024** | unchanged |
| MSRV (`rust-version`) | 1.85 | **1.85** | unchanged |
| Workspace crate dirs | 27 | **27** | unchanged (39 `[workspace] members`, also unchanged) |
@@ -43,20 +318,39 @@ three ABIs, which removes the Compose screenshot scenes.
| Windows virtual-gamepad channel | 3 | **3** | unchanged |
| Plugin index schema | 1 | **1** | unchanged |
| Host event schema | 1 | **1** | unchanged (`punktfunk-host/src/events.rs`) |
| `api/openapi.json` | 0.29.0 | **0.29.0** | unchanged — no management-API surface moved this cycle; both copies (`api/` and `docs-site/public/`) are byte-identical to each other and to the tag |
| `api/openapi.json` | 0.29.0 | **0.31.0** | **the stamp only** — no management-API surface moved this cycle. The file had been left at 0.29.0 while the crate was already 0.31.0; #337's regenerate-and-diff caught it and it was regenerated, which is a one-line change to both copies. `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` still says `pfhdr7` — pre-existing at v0.30.0, not a regression this cycle, but the Arch package builds a binary the host's `>= 8` probe rejects for the keymap path |
| `@punktfunk/host` (SDK) | 0.1.4 | **0.1.4** | unchanged in `package.json` — but `sdk/src/config.ts` and `runner-cli.ts` changed (the `mgmt-endpoint` fix below), so a `sdk-v0.1.5` cut is **owed**; plugins resolve the SDK from the registry and cannot pick the fix up until it ships |
| `@punktfunk/host` (SDK) | 0.1.4 | **0.1.5** | cut — `sdk/src/config.ts` and `runner-cli.ts` carry the `mgmt-endpoint` fix below, and plugins resolve the SDK from the registry, so it could not reach them until it shipped |
| `@punktfunk/plugin-kit` | 0.4.2 | **0.4.3** | cut, for the two `sync-engine.ts` changes that cannot reach a plugin any other way: `minInterval` (below) and the always-apply sync reasons (`startup`/`manual` publish even when the fingerprint matches, so a host-side art drop is recoverable by restarting rather than by deleting the plugin's cache). Note the registry skips 0.4.2: `plugin-kit-v0.4.2` was tagged but its publish never landed, and the tag is left where it is rather than moved |
⚠ The SDK and plugin-kit version independently of the app (`sdk-v*` / `plugin-kit-v*` tags,
`sdk-publish.yml` / `plugin-kit-publish.yml`); this release commit does not bump them. Both have
unpublished code changes, called out in the table so they are cut deliberately rather than
discovered.
`sdk-publish.yml` / `plugin-kit-publish.yml`), so their rows record what the registry holds, not
what this tag ships. Both were cut during this cycle rather than left owed — a plugin resolves them
from the registry, so a fix that never ships there never reaches one.
### ⚠ Breaking changes
**None on any versioned surface.** No wire change, no C ABI change, no driver-protocol change, no
plugin-contract change. Four things are worth attention anyway; none breaks a build:
**None that break a build.** No wire change, no driver-protocol change, no plugin-contract change.
The C ABI moves 24 → 25 by **addition only**:
- **v25 — `punktfunk_set_log_callback(max_level, cb, user)`.** The core logs through `tracing`; an
embedder that installs no Rust subscriber hears none of it — transport warnings, connection events,
handshake notes — and a client log bundle carries the shell's half alone, which is exactly what an
Apple TV field report turned out to be. The call registers a `log::Log` backend behind a C callback
(`PunktfunkLogCb`: level, target, message, user), gated by `log::set_max_level` so anything above
the ceiling costs no formatting; `NULL` detaches, and it answers `Unsupported` when another log
backend already owns the process (`android_logger`). Both strings are borrowed for the call only,
and an interior NUL drops the line rather than truncating it. `punktfunk-core` now declares
`tracing`'s `log` feature explicitly — it had been on transitively via quinn, which an ABI promise
must not rest on. An embedder that never calls it is byte-compatible with v24; see
`docs/embedding-the-c-abi.md` §2.6.
- **One header comment was wrong and is corrected, with no signature change:**
`punktfunk_connect_ex10`'s summary still stated the pre-2026-08-16 rule that only a format other
than 48000/16 requests the lossless plane. Any non-zero format at all does, 48000/16 included —
which is what its own warning already said and what the code always did. Embedders reading the
summary were reading the old rule.
Five more things are worth attention; none breaks a build:
- **`refactor(android)!` — the Compose console is deleted.** `pf-console-ui` (the Skia shell the
desktop session binary draws) is now Android's console on arm64-v8a, x86_64 **and** armeabi-v7a;
@@ -78,6 +372,11 @@ plugin-contract change. Four things are worth attention anyway; none breaks a bu
monitors disabled for the session now (closes #284).
- **Three new system files in the Linux host package** — the DualSense audio path does not work
without them. Downstream repackagers: see the packaging section.
- **The Windows client's default download is a per-user installer, not the MSIX.** The MSIX stays,
for the Store; the installer and a portable zip are what the download page now offers, and the
release carries `punktfunk-client-setup_<arch>.exe` and `..._<arch>-portable.zip` alongside it.
Anyone scripting against the MSIX asset name is unaffected; anyone scripting against "the Windows
client download" gets a different artifact. See the Windows client section.
### DualSense audio and haptics on Linux: five faults, and the files they needed
@@ -295,6 +594,25 @@ gains two direct deps already in the graph.
`frameRatePowerSavingsBalanced`) raises the render-range floor — so the ineffective pins were
removed again and `pf.present` gained the cadence loop's late-permille / jitter / cushion /
re-anchors / qDepth.
- **Colour tagging, which the SurfaceView path never had to do.** MediaCodec tags its own window
buffers; with `AImageReader``ASurfaceControl` the transaction is the only carrier, and a
dataspace of 0 means `setBufferDataSpace` is never called. Two consequences, both fixed inside the
cycle: **HDR** was seeded from a hardcoded `BT2020_ITU_PQ` guess and then overwritten by whatever
the codec echoed on the first output-format change — a decoder that omits color-transfer (common)
echoes None, clobbering the dataspace to 0 before the first present, so P010 buffers composited as
sRGB, and an HLG stream was mis-seeded PQ. The initial dataspace now derives from `client.color`
(PQ vs HLG, range) and a format change only *refines* it when the codec actually reports an HDR
transfer, never resets it — the SurfaceView path's semantics. And **SDR** was untagged entirely:
a limited-range BT.709 buffer read as full range shows black (16) as grey, so SDR now maps to
`ADATASPACE_BT709` and every ASC buffer is tagged.
- **One owner for the system bars.** Console → stream rides an `AnimatedContent` cross-fade, so the
outgoing console shell stays composed until the fade ends and its
`onDispose { show(systemBars()) }` fired *after* `StreamScreen`'s hide — parking the status and
gesture bars over the video for the whole session. Hide/show now lives once in `App.kt`, keyed on
the resolved intent (streaming or console fronting = immersive, touch shell = bars back), and both
screens' per-screen bar management is deleted.
- **Idle gates** (from the console-ui sweep): the reachability sweep only probes while the console is
attached, and the render thread drops to half rate after 60 s without input.
### Hyprland / sway: `topology: exclusive` (closes #284)
@@ -314,20 +632,92 @@ with non-legacy parsers"). `primary` stays extend and warns distinctly. ⚠ **Th
exercised on a live sway** — no box in the fleet runs one; both argv shapes are pinned by tests and
the read-back turns a wrong guess into a warning naming the outputs. Six new unit tests.
### Gaming Mode takeover: the mask was the relogin storm
### Gaming Mode takeover: it no longer touches the display manager at all
On an SDDM-autologin box the runtime mask the takeover laid sat in SDDM's relogin path, so every
autologin failed in milliseconds and `Relogin=true` has no backoff: 962 logind sessions in 3.7 min,
system buttons re-scanned 5,688×, udev `change` at ~20/s, iio-sensor-proxy crash-looping ~16
starts/s, load 26 on 12 cores — and Wine's bus driver, re-enumerating udev per event, read the pad at
~1.4 Hz. `dm_plan` loses its `mask` input and `dm_survives_masked_unit`; the mask is laid **only after
the stop has landed** and every restore path unmasks before restarting; a planned DM stop that does
not land now **fails the takeover** and the caller degrades to ATTACH. `skip` is `!any_live` on every
flavor; `any_live` now counts `deactivating` and `reloading`. New `DmHelperError::shape()`;
`watch_for_relogin_storm()` (two `read_dir`s of `/run/systemd/sessions` 5 s apart, ERROR above 1/s,
detect-only); `systemctl_system` captures stderr at DEBUG (the "requires interactive authentication"
line was going to the journal on the *successful* path). `cargo test -p pf-vdisplay --lib gamescope`
52 passed, 1 ignored.
This landed in two steps within the cycle, and the second retired the first — read the end state.
**The storm.** On an SDDM-autologin box the runtime mask the takeover laid sat in SDDM's relogin
path, so every autologin failed in milliseconds and `Relogin=true` has no backoff: 962 logind
sessions in 3.7 min, system buttons re-scanned 5,688×, udev `change` at ~20/s, iio-sensor-proxy
crash-looping ~16 starts/s, load 26 on 12 cores — and Wine's bus driver, re-enumerating udev per
event, read the pad at ~1.4 Hz. Masking without stopping the display manager is not a weaker
defence; it is the storm's engine.
**Then stopping the DM proved wrong too.** With no display manager there is nothing on the box able
to start a desktop session, so Steam's own "Switch to Desktop" sat on its modal until a reboot
(field report 2026-08-18, `.41`). It could not even be detected and worked around: on a
steamos-manager box every trace of that switch is written by the component we had just stopped —
the `~/.config/steamos-session-select` sentinel is never written (that is the ChimeraOS/Nobara
layout), `/var/lib/sddm/state.conf` only advances when sddm actually *starts* a session,
`get-default-login-mode` stays `game` for a non-persistent switch, and `graphical-session.target`
going inactive fires at takeover time as well.
**End state: idle the autologin, leave the display manager alone.** The takeover drops a unit
override over the `gamescope-session-plus@` template replacing `ExecStart` with a process that
sleeps. The autologin still *succeeds*, so there is no failed unit to relogin against; the session
runs nothing, so Steam is free; and the DM is alive, so the box can service its own session switch.
No privilege, no DM-flavour matrix, no detection. Measured on `.41` in both directions: takeover
leaves `steam` down, `sddm` active, the unit `active (running)` with `NRestarts=0`; the switch that
used to hang brings Plasma up in ~10 s; the restore puts Steam back within 5 s. The drop-in lives
under `$XDG_RUNTIME_DIR` (a copy outliving the host would be a box whose Game Mode silently does
nothing), is swept unconditionally at startup, and its removal sits above every early return in the
restore — the desktop-active return is exactly the path that would leak it. The restore *restarts*
rather than starts, because `start` on an active-but-idle unit is a no-op that would log success
over it.
With nothing stopping a display manager any more, the whole chain built to survive doing so is
deleted: `try_stop_display_manager`, `ensure_host_survives_dm_stop`, `host_is_under_user_manager`,
`cgroup_under_user_manager`, `linger_enabled` and `dm_plan`'s mask input — 142 lines out, 17 in.
**Two shipped facts became false and are corrected:** the takeover no longer has to stop the display
manager, and it no longer needs the `punktfunk` group (the docs and the shipped Bazzite `host.env`
both said it did). That group still gates the usbip nodes the virtual Steam Deck pad attaches
through, which is what the advice now narrows to. Kept from the first step: `any_live` counts
`deactivating` and `reloading` (a unit mid-teardown used to read as a dead leftover, so a box that
*is* in gaming mode sampled as idle); `DmHelperError::shape()`; `watch_for_relogin_storm()` (two
`read_dir`s of `/run/systemd/sessions` 5 s apart, ERROR above 1/s, detect-only, and it states that
no audio, input or PipeWire measurement taken during a storm is valid); and `systemctl_system`
capturing stderr at DEBUG, since that verb is *expected* to fail on an unprivileged host and its
"requires interactive authentication" line was going to the journal on the successful path.
### KWin 6.6 creates our virtual output disabled, and refuses to stream it
On KWin ≥ 6.6 `streamVirtualOutput` creates the output on the backend and then hands
`workspace()->findOutput(output)` to the stream — null for an output the workspace does not manage
(`wantsToManage` = `isEnabled() && !isNonDesktop()`). An output KWin creates **disabled** is
therefore refused with "Could not find output", translated into the session's language and logged
nowhere, because disabling an output is a perfectly valid configuration that applies successfully.
6.4/6.5 passed the backend output straight through and streamed it either way. It repeats forever:
the host asks for a *stable* per-client output name precisely so KWin persists that client's scale
and mode against it, so a stored configuration naming it `enabled: false` is reapplied to every
future session for that client — and the user cannot fix it in System Settings, because the output
only exists for the few milliseconds the request is alive. The host now enables the output and
retries. Related, from the same investigation: a **translated** KWin refusal used to burn all 8
retries because the match was against KWin's message rather than our own prefix.
### Windows client: a per-user installer and a portable zip, because Steam must spawn the exe
A user report — launching through Big Picture does not work and the Steam overlay never appears —
turned out to be nothing to do with the app being UWP (it is full-trust Win32 under MSIX too) and
everything to do with the MSIX install **shape**: the exe lives under the ACL'd `WindowsApps`
directory that Steam's non-Steam-game picker cannot browse, and alias / `shell:AppsFolder`
activation defeats the overlay's injection. Steam has to spawn the exe itself, from a normal path.
- **`punktfunk-client.iss`** — a per-user Inno Setup install (no UAC) to
`%LOCALAPPDATA%\Programs\Punktfunk`, re-creating in `HKCU` what the MSIX manifest granted: the
`punktfunk://` scheme, the Start entries, and `{app}` on the user PATH for the `punktfunk` CLI. It
fetches the Windows App Runtime when missing.
- **`pack-client-installer.ps1`** consumes `pack-msix.ps1`'s layout (one assembly, three artifacts),
signs the four exes individually and emits `setup.exe` plus a portable zip — same signing backends
and fail-closed-on-tags rule as its siblings, and no `.cer`, because an exe runs untrusted.
- **`windows-client.yml`** packs after the MSIX and publishes/attaches the new artifacts;
canary/latest aliases are `punktfunk-client-setup_<arch>.exe` and `..._<arch>-portable.zip`.
- **`deeplink.rs`**: `write_shortcut` targets the app-execution alias only under package identity —
an unpackaged install has no alias but does have a stable path, so it targets `current_exe()`.
`has_package_identity()` is now shared with `main.rs`'s AppUserModelID probe.
- Uninstall is `Settings → Apps → Installed apps` (per-user, no admin prompt) or
`unins000.exe /VERYSILENT`; a portable unzip registers nothing and is deleted by hand. Documented
in install-client (with a "Launching through Steam" section), channels, clients, uninstall, and
both copies of `platforms.json`.
### Windows host: two session-killers
@@ -351,6 +741,123 @@ line was going to the journal on the *successful* path). `cargo test -p pf-vdisp
instead of `launching` forever. Fixture in `a_pid_only_launch_reports_its_exit` widened 4 → 8 s
(it passed only because of the bug); new ignored test drives the field report.
### `scripts/install.sh`: a guided Linux host install (preview)
Plain POSIX `sh`, dash-clean, `curl -fsSL https://punktfunk.unom.io/install.sh | sh`. Detect the
distro from os-release (apt / dnf / pacman / rpm-ostree→sysext; NixOS, SteamOS, Windows and unknown
distros get a one-line pointer and stop; Debian 12 / Ubuntu 24.04 / Mint 22 / Fedora 45 hit the
documented floors with the right docs link) → install using the `data/platforms.json` lines
**verbatim** (channel and the Fedora group are edited into the string at run time) → run
`punktfunk-host detect-conflicts` (exit 1 = an active Sunshine-family host) → offer to keep both by
moving the management API port (`PUNKTFUNK_MGMT_BIND`, default 47991, which the firewall step then
opens) → input group (`ujust` on Bazzite) → optional `punktfunk` group, GameStream compat and shared
clipboard, all defaulting to no → firewalld/ufw profiles → enable host + console (+ the plugin
runner where it is not) → optional linger → verify (unit active, UDP 9777 bound) and print the
console URL, the password command and the pairing steps.
`--dry-run` prints every command and changes nothing; `--uninstall` reverses the install and the
service enable per family (user units off first, then only the punktfunk packages actually
installed, then the repo — config, groups and firewall stay, as `/docs/uninstall` states). Every
prompt has a `PUNKTFUNK_INSTALL_*` environment twin so `--yes` (or no terminal) runs unattended, and
stdin is never read, because under `curl | sh` stdin *is* the script. Re-running is safe. The
end-of-run check catches the two NVIDIA silent failures on every family — no driver at all, and a
module the kernel refused to load under Secure Boot — via an `nvidia-smi` probe pointing at the
troubleshooting anchor.
It is labelled **PREVIEW** on purpose: the per-distro docs pages remain the documented default until
it has mileage. CI runs it: a new `installer-smoke.yml` exercises install and `--uninstall` per
package family, and `check-docs-drift.sh` gate 7 runs the 16-file os-release detection matrix
through the real script under `--dry-run` on every push. One bug fixed by the first smoke run: the
`/dev/tty` probe used `-r`/`-w`, which answer yes in a container that has the node but no
controlling terminal, so the redirect failed — it opens the device instead now.
### One home per fact: `data/platforms.json`, and CI gates against drift
Install commands, repo URLs and port numbers had drifted across four surfaces. They now live in
`data/platforms.json` and nowhere else: the docs-site install pages quote it through an
`<Install platform="…"/>` MDX component reading a byte-identical snapshot at
`docs-site/src/data/platforms.json` (the Docker build context is `docs-site/` alone, the same
arrangement `openapi.json` uses), `<Ports/>` renders the port table from it, the website download
page vendors it, and `install.sh` runs it. `scripts/ci/check-docs-drift.sh` gates the parse, the
snapshot sync, undocumented `PUNKTFUNK_*` knobs (against a checked-in baseline) and the detection
matrix; `check-docs-links.sh` covers dead links.
**Two consequences for whoever cuts this release.** The website vendors `platforms.json` and only
refreshes when someone runs `bun run sync-platforms` in punktfunk-website and commits — the release
flow in `docs/releases/README.md` gained that step, and `platforms.json` **did** change this cycle
(the Windows client download). And the `.gitea/PULL_REQUEST_TEMPLATE.md` now asks the one question
CI cannot: did a user-facing fact change, and is the page that owns it updated in the same PR.
### Clients can send their logs to the host, on every platform that has a console
0.30 shipped "Send logs to host" on the Gaming Mode console alone and named the Apple and Android
legs as follow-ups. Both landed here.
- **Apple** — a `ClientLog` drop-in for `Logger(subsystem: "io.unom.punktfunk", category:)` with the
same call shape, writing os_log *and* a process-global ring bounded at 4096 lines / 768 KiB (under
the host's 1 MiB cap), stamped wall-clock ISO-8601 so a bundle lines up with the host log;
`.debug` stays out of the ring, which is the Steam Deck DPB lesson applied in advance. 13 `Logger`
declarations swapped. `MgmtTransport`/`MgmtConnection` POST a length-framed body on the same
pooled, pinned mTLS connection; `SendLogs.toHost` requires identity and pinned fingerprint, the
same gates as the library. Reachable from the host card's context menu and the gamepad host
options. Paired with ABI v25 above, the Swift client finally hears the core's own lines too
(`core.<crate>`, info ceiling by default, `PUNKTFUNK_CORE_LOG_LEVEL` raises it).
- **Android**`pf-client-core`'s logring RING half (note/render/wallclock, std-only) is
Android-enabled, with `send_to_host` still desktop-gated alongside the ureq fetches; `wallclock`
moves in from the session's ring layer so every feeder stamps lines identically. `JNI_OnLoad`
installs a `RingTee`, so every `log` record goes to logcat **and** into the ring in the desktop
ring layer's line shape; `nativeRenderLogs(header)` hands Kotlin the rendered bundle, and the
upload rides the client's own mTLS.
### A provider plugin can report which of its titles are **running**
New: `PUT /api/v1/library/provider/{provider}/running`, body
`{"running":[{"external_id":"…","pid":1234}]}` — the **live** counterpart to the static `detect`
hints a reconcile carries. `detect` says *how to recognize* a title's process; this says *it is
running now*, and carries the pid where the provider knows one. Additive: no existing route,
payload or behaviour changes, and a host with no reporting plugin behaves exactly as before.
It exists because one class of title could never be tracked at all. The host derives liveness by
scanning (`procscan` + `DetectSpec`), which needs something recognizable on disk — an install
directory, an executable, a Steam reaper. A Playnite-launched emulated game, a manually added one,
or a library plugin that records no install directory has none of that, and its launch is a
`playnite://` hand-off, so the host holds no process either: the lease went `Untracked`, its exit
was never noticed, `session_on_game_exit` could not fire, and `POST /game/end` had nothing to aim
at. Playnite knew the whole time — it starts the game, tracks it in the mode the person configured,
and fires an event on both edges carrying the pid. That was being thrown away.
- **Declarative and idempotent**, like the reconcile beside it: the body is the provider's
**complete** running set, so a missed event, a plugin restart or an install mid-game self-correct
on the next report instead of drifting. Absent from the set = stopped.
- **Reports expire** (`crate::runstate::REPORT_TTL`, 90 s; the answer carries `ttl_s`). This is what
makes it safe for a live provider to hold a streaming session open for a game the host cannot
see: a plugin that dies with a game running stops counting shortly after and the host falls back
to scanning. Reporters must restate well inside the window.
- **New `gamelease::LeaseKind::Reported`** — a lease with no process signal of its own, tracked by
what its provider says. `open` reaches it when the spec is empty and a provider speaks for the id;
the shim-reclassification paths (every Windows launch is a hand-off by construction) fall back to
it too, where they previously fell to `Untracked`. Phase 1 accepts "running" as the game
appearing; phase 2 treats "stopped" as the exit, and — unlike `procscan::running_hint`, which may
only ever *delay* an exit because Steam's registry flag survives an unclean exit — a fresh
provider 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).
- **Route authority**: the plugin lane, like the reconcile (`mgmt::auth::plugin_may_access`, and its
exhaustive classification table). No new authority — the host maps `external_id` through the
catalog, so a provider can only ever 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
throw away the liveness of every other running title.
- **`@punktfunk/plugin-kit`: `ProviderClient.reportRunning(providerId, running)`**, returning
`{matched, unknown, ttlS}`; a 404 from an older host means "this host tracks games by scanning".
Version bumped to **0.4.4** — **unpublished, `plugin-kit-v0.4.4` owed.**
The Playnite half lives in `punktfunk-plugin-playnite` (**0.4.5**, exporter **0.4.0**): the C#
exporter hooks Playnite's `OnGameStarted`/`OnGameStopped`/`OnGameStartupCancelled` and writes a
small `punktfunk-running.json` beside the library export, re-stamped every 30 s and *deleted* when
Playnite closes; the plugin polls it and restates the set to this route. It calls the route through
the kit's untyped host seam rather than `reportRunning`, deliberately — depending on the method
would make that repo unbuildable until the kit publishes, for the same request. Needs a host
carrying this route; an older one 404s and the plugin carries on without it.
### Everything else an integrator might notice
- **`mgmt-endpoint` is followed everywhere.** `PUNKTFUNK_MGMT_BIND` moved off 47990 left every plugin,
@@ -402,44 +909,130 @@ line was going to the journal on the *successful* path). `cargo test -p pf-vdisp
(screenshot harness only).
- **New environment variables:** `PUNKTFUNK_PAD_SINK_VOLUME` (`=0` skips both pad-sink pins),
`PUNKTFUNK_DUALSENSE_USBIP_GRACE_MS` (pad-arrival grace), `PUNKTFUNK_USBIP_TRACE` (byte-level
USB/IP trace prefix, off by default), and the three Apple screenshot-harness hooks above.
USB/IP trace prefix, off by default), `PUNKTFUNK_CORE_LOG_LEVEL` (Apple: raises the ABI v25 log
sink's ceiling above its info default), the three Apple screenshot-harness hooks above, and nine
`PUNKTFUNK_INSTALL_*` twins for `install.sh`'s prompts (`_YES`, `_CHANNEL`, `_GAMESTREAM`,
`_CLIPBOARD`, `_PUNKTFUNK_GROUP`, `_LINGER`, `_MGMT_PORT`, `_DRY_RUN`, `_OS_RELEASE`).
`PUNKTFUNK_STREAM_SINK` gained the `stream` value and is documented for the first time.
- **A Steam Deck never learned a host's wake MAC, so Wake-on-LAN was skipped there in silence.**
Every wake gate reads `!host.mac.is_empty()`, and the MAC only ever reached the store through
`trust::learn_mac`, whose two callers were the GTK and WinUI hosts pages — neither of which runs
in Gaming Mode. Rather than add the missing call twice, the three per-field learners (`learn_mac`,
`learn_os`, `learn_mgmt_port`) collapse into one `learn_from_advert`, called wherever an advert
meets a saved record: both desktop hosts pages, the console home, and the CLI's `discover`.
Remembering one call is not something a front-end can half-do; remembering three is what produced
this (#322).
- **`HostRow` gains `clipboard_sync`** (`#[serde(default)]`) and `ConsoleCmd` two variants,
`BindProfile` and `SetClipboard` — additive and default-tolerant. From the 2026-08-19 console-ui
sweep, which also brought touch deferred-tap and drag-to-scroll to the console (a swipe across the
settings list used to cycle whatever value it landed on, because `MenuList` presses focus *and*
activate), Controller haptics/speaker rows, and two Android idle gates (the reachability sweep
only probes while the console is attached, and the render thread halves its rate after 60 s
without input).
- **Cancelling a connect returns the console immediately.** The takeover could only be dismissed by
a session phase coming back from the embedder and nothing guaranteed one would: Android's shell
sent no phase at all on the cancelled path, and the desktop shell waited on a pump parked inside
the blocking `NativeClient::connect*`, which had no abort — 15 s on a normal dial, **185 s** on a
request-access connect the host holds pending approval. The private `connect_*` inner fn takes a
trailing `cancel: Option<Arc<AtomicBool>>`; not exported through the C ABI.
- **A portable Playnite's covers survive the art confinement.** A Playnite unzipped outside the
users base keeps its library beside the exe, so every cover it exports sits outside every default
art root: the games synced and all **70** covers were dropped, with `PUNKTFUNK_LIBRARY_ART_ROOTS`
the only way out. The Playnite install dirs are art roots now, exactly as Steam's install root
already was, and `playnite_install_dirs` learned to find a portable copy at all — it registers no
uninstall entry and sits under no profile, but it does register the `playnite://` handler, which
is the very registration the launch path already follows. So a portable install also gets its
Fullscreen launcher tile, which it never had. The confinement is not loosened: roots come from the
host's own registry and filesystem probes, never from the plugin lane that supplies the art path.
Paired with the plugin-kit fix below, a fixed host no longer needs a cache file deleted.
- **`plugin-kit`: `startup` and `manual` sync reasons always publish.** The fingerprint says the
plugin would compute the same entries again; it does *not* say the host still holds them — and the
host may accept a payload and store less of it (an art path outside its roots is stripped and the
games kept, deliberately, because a cover must not cost a library). Once that happened the
fingerprint was a permanent "no changes", and the only way out was deleting the plugin's cache
file, which is exactly the advice a portable-Playnite library with 70 dropped covers was given.
The two triggers with a person behind them now always apply.
- **Nix:** nixpkgs bumped because its gamescope 3.16.24 no longer took our patch 0009 (the publish
tier was red on every build); `enableWsi` is a nixpkgs *function argument* defaulting to false, so
the plain derivation shipped a compositor with **no WSI layer at all** and nothing under it could
obtain an HDR10 swapchain — our own postInstall assertion caught it. Also: the prune makes `$out`
writable first (reshade installs read-only), the bun builds are serialised and the OOM is measured
against the real 7 GiB cgroup cap rather than guessed at, and a dispatch opt-in compared against
the string `"true"` silently skipped when the API delivered a real JSON boolean — the step was
skipped and the job still reported success.
- **New packaging payload (Linux host, rpm/deb/arch; nix where noted):** `scripts/60-punktfunk.rules`
(+2 sound rules), `scripts/60-punktfunk-dualsense.conf` (WirePlumber, also nix),
`scripts/alsa-ucm2/…` (UCM drop-in, **not** nix). Bazzite sysext inherits all three from the RPMs.
- **Docs:** `AGENTS.md` + `docs/agents/` (issue tracker is Gitea via the `gitea` MCP server; the
five triage labels; single-context domain docs). A host audio-source comment corrected
(`pw_impl_node_set_driver` marks props changed but leaves the flush to the next info emission).
- **CI:** Nix publish job records `df` after the build as well as before.
- **CI:** the Nix publish job records `df` after the build as well as before; the
`linux-client-screenshots` run publishes its PNGs to the generic package registry as well as the
v3 artifact store (which is browser-only, so nothing could reuse the shots for the docs — that is
how the get-started track got its fifth screenshot, a client's host list); and **every Linux
`bun install` is now wrapped in `scripts/ci/retry.sh`**. That last one is a real failure, not
tidying: `bun install` streams download-and-extract, so a tarball truncated by the runner's
packet loss under parallel load surfaces as `error: Fail extracting tarball for "<pkg>"` and
names a package that is perfectly intact — measured on run 19630, where docs-site died on
`@rolldown/binding-linux-x64-gnu` while the web job installed the same registry in the same run
and run 19632 installed the identical lockfile seven minutes later. The tarball's sha512 matches
the lockfile and bun 1.3.13 and 1.3.14 both extract it from disk, so neither the package nor the
floating `oven/bun:1` bump was ever at fault. `retry.sh`'s header had already diagnosed this
class and said to wrap every single-shot network command; `bun install` was the one still
unwrapped. Three attempts rather than the usual five, so a genuinely stale lockfile still fails
fast under `--frozen-lockfile`.
- **The web console's Virtual displays page** put the Streamed-screen and session-lifetime cards
below the tab shell, so both rendered on both tabs; they are policy surfaces and now sit inside
the Configuration tab, leaving the Live tab as the live list plus arrangement.
### Verification status
Gates run on the release tree (this MacBook, rustc/rustfmt 1.96.0 per `rust-toolchain.toml`):
`cargo fmt --all --check` clean**after** a whitespace-only commit on the release branch: two files
(`pf-console-ui/src/screens/controllers.rs`, `punktfunk-host/src/audio/linux/pad_card_volume.rs`)
had landed on main formatted differently from rustfmt 1.96.0, so `ci.yml`'s Format step was red on
the tip this is cut from; `cargo metadata --offline` ok with the `Cargo.lock` diff versions-only
(36/36 lines); `cargo test -p punktfunk-core` **272 passed** in the unit suite; the android.yml Play
notes gate run verbatim — 498/500 characters and not byte-identical to any prior release's; both
openapi copies `cmp` identical and unchanged since the tag; `include/punktfunk_core.h` regenerated
by the build and `git diff` clean against the tag.
`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 android.yml
Play notes gate run verbatim — 456/500 characters and not byte-identical to any prior release's;
both openapi copies `cmp` identical, both stamped 0.31.0; notes voice scan clean outside the
For developers section.
**The C ABI harness (`tests/c_abi.rs`) did not run on this cut**: it links the staticlib with
`-lopus` and this machine has no libopus (`ld: library 'opus' not found`), which is an environment
gap, not a code fault. The header it exercises is byte-identical to v0.30.0's, where the harness
passed (261 + 1 + 8), and nothing in `punktfunk-core`'s C surface changed. The CI runner is its
first execution for this tag.
**This release was cut more than once.** The first cut (`601f040f`, merged as #320) was never
tagged, and 41 more non-merge commits landed on top of it — the Windows client installer, the
guided Linux installer, the docs overhaul, ABI v25, the KWin 6.6 repair and the takeover's final
shape among them; a handful more (the Virtual displays tab fix, the fifth get-started screenshot)
arrived while the second cut was being written. This section, the version table and the notes are
all re-measured on the latest tip; where the cuts disagreed, the earlier text was **rewritten
rather than appended to**, because none of
the intervening work ever shipped. Specifically: the "C ABI unchanged / header byte-identical"
claim is gone (it is 25 now), the openapi row moved off 0.29.0, the SDK and plugin-kit rows record
cuts that have happened rather than cuts that were owed, and the Gaming Mode takeover section
describes idling the autologin rather than stopping the display manager — a within-cycle correction
no user could have seen.
**Verified by reading only** — compiled nowhere available to the cutting host: the Windows runner
log redirect (`scripting-run.cmd`), the tray's `Option<u16>` port on Windows, and the sway half of
`topology: exclusive` (no live sway in the fleet, as with #283).
**The C ABI harness (`tests/c_abi.rs`) did not run on this cut**, and this time the header *did*
change: it links the staticlib with `-lopus` and this machine has no libopus (`ld: library 'opus'
not found`), which is an environment gap, not a code fault. `punktfunk_set_log_callback` is
therefore compiled by cbindgen and by the Rust unit tests here, but the generated header has not
been compiled by a C compiler on this cut — the CI runner is its first. Worth naming because ABI 25
is the one versioned surface that moved.
**Verified by reading only** — compiled nowhere available to the cutting host: the Windows client
installer and portable zip (`punktfunk-client.iss`, `pack-client-installer.ps1` — the pack step is a
Windows runner's), the Windows runner log redirect (`scripting-run.cmd`), the tray's `Option<u16>`
port on Windows, and the sway half of `topology: exclusive` (no live sway in the fleet, as with
#283).
**Not verified on hardware by this cut**, named rather than left to be discovered: the null-sink
capture topology's on-glass validation (pw-top showing our sink at the top of its own group, 5 min
of loud audio at `delivered_pct=100 gaps=0` on a box where a hardware sink also runs) was still owed
when it landed; the 96 kbps speaker lane was judged on glass by ear only; and the Android
`ASurfaceControl` path was verified on one device (Nothing Phone 3) the fallback presenter is
byte-for-byte the 0.30 one.
when it landed; the 96 kbps speaker lane was judged on glass by ear only; the Android
`ASurfaceControl` path was verified on one device (Nothing Phone 3), with the fallback presenter
byte-for-byte the 0.30 one; the Mac Accessibility intercept (the tap ahead of Spotlight, inside the
sandbox) needs a granted Accessibility switch the dev machine does not have; and `install.sh` is
smoke-tested per package family in CI containers but is shipped **preview** precisely because it has
no real-box mileage, Bazzite above all.
**Owed outside this repository:** `data/platforms.json` changed this cycle (the Windows client
download), and the website's download page vendors a copy that only refreshes when someone runs
`bun run sync-platforms` in punktfunk-website and commits — step 1 of `docs/releases/README.md`.
---
+29 -3
View File
@@ -83,15 +83,41 @@ Two more gates that only apply to some changes:
instead of waiting for the CI job that compiles it.
Generated artifacts are checked in. `include/punktfunk_core.h` (cbindgen) is regenerated by the build
and CI fails if the committed copy drifts. `api/openapi.json` is **not** gated — nothing in CI
regenerates or diffs it, so regenerate and commit it yourself whenever you touch the management API,
and copy the snapshot the docs site serves:
and CI fails if the committed copy drifts. `api/openapi.json` is gated the same way: the `rust` job
regenerates the spec and diffs it against the committed file, and the `docs-drift` job checks that
`docs-site/public/openapi.json` — the snapshot the docs site serves — is a byte-for-byte copy of it.
Touch the management API and CI stays red until you regenerate and re-copy:
```sh
cargo run -p punktfunk-host -- openapi > api/openapi.json
cp api/openapi.json docs-site/public/openapi.json
```
## Where facts live (docs vs READMEs vs website)
Every user-facing fact has exactly one canonical home; everything else links to it. Duplicated
walkthroughs are how the docs drifted before — don't add new ones.
| Surface | Owns | Never contains |
|---|---|---|
| [docs-site](https://docs.punktfunk.unom.io) (`docs-site/content/`) | All user-facing facts: install, config, features, troubleshooting | Design rationale |
| READMEs (root, `packaging/*`, `scripts/*`) | Dev/packager rationale and pointers into the docs | User walkthroughs duplicated from docs-site |
| [punktfunk.unom.io](https://punktfunk.unom.io) (separate repo) | Marketing, downloads, blog | Instructions — it deep-links the docs instead |
| punktfunk-planning (private) | Design rationale, RFCs, plans | Anything user-facing |
Docs pages are written for one of two audiences, not both at once: the **get-started track**
(quickstart, install, pairing — short, one task per page, happy path only) assumes no Linux
expertise; the **reference track** (configuration, CLI, API, per-compositor pages) is allowed to be
dense. When a change touches a user-facing fact, update the docs-site page that owns it in the same
PR.
CI enforces the cheap half of this (`scripts/ci/check-docs-drift.sh` and `check-docs-links.sh`):
the OpenAPI snapshot must match `api/openapi.json`, the docs-site copy of `data/platforms.json` must
match the canonical one, `scripts/install.sh` must carry the file's install lines verbatim, every `PUNKTFUNK_*` variable the docs mention
must still exist in the tree, the counts of undocumented `PUNKTFUNK_*` variables and undocumented
`punktfunk-host` subcommands may never grow (document the new knob, or consciously raise the
baseline in the script), and internal docs links must resolve.
Match the surrounding code's comment density and naming. Commit messages end with the
`Co-Authored-By` trailer (see `git log`).
Generated
+37 -36
View File
@@ -1090,7 +1090,7 @@ dependencies = [
[[package]]
name = "cursor-probe"
version = "0.31.0"
version = "0.31.1"
dependencies = [
"anyhow",
"pf-capture",
@@ -1222,7 +1222,7 @@ dependencies = [
[[package]]
name = "display-disturb"
version = "0.31.0"
version = "0.31.1"
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.1"
[[package]]
name = "lazy_static"
@@ -2446,7 +2446,7 @@ dependencies = [
[[package]]
name = "libvpl-sys"
version = "0.31.0"
version = "0.31.1"
dependencies = [
"bindgen",
"cmake",
@@ -2475,7 +2475,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "loss-harness"
version = "0.31.0"
version = "0.31.1"
dependencies = [
"punktfunk-core",
]
@@ -2967,7 +2967,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pf-bitstream"
version = "0.31.0"
version = "0.31.1"
dependencies = [
"cros-codecs",
"tracing",
@@ -2975,7 +2975,7 @@ dependencies = [
[[package]]
name = "pf-capture"
version = "0.31.0"
version = "0.31.1"
dependencies = [
"anyhow",
"ashpd",
@@ -2996,7 +2996,7 @@ dependencies = [
[[package]]
name = "pf-client-core"
version = "0.31.0"
version = "0.31.1"
dependencies = [
"anyhow",
"ash",
@@ -3032,7 +3032,7 @@ dependencies = [
[[package]]
name = "pf-clipboard"
version = "0.31.0"
version = "0.31.1"
dependencies = [
"anyhow",
"ashpd",
@@ -3050,7 +3050,7 @@ dependencies = [
[[package]]
name = "pf-console-ui"
version = "0.31.0"
version = "0.31.1"
dependencies = [
"anyhow",
"ash",
@@ -3073,7 +3073,7 @@ dependencies = [
[[package]]
name = "pf-dxvadec"
version = "0.31.0"
version = "0.31.1"
dependencies = [
"cros-codecs",
"pf-bitstream",
@@ -3083,7 +3083,7 @@ dependencies = [
[[package]]
name = "pf-encode"
version = "0.31.0"
version = "0.31.1"
dependencies = [
"anyhow",
"ash",
@@ -3109,7 +3109,7 @@ dependencies = [
[[package]]
name = "pf-frame"
version = "0.31.0"
version = "0.31.1"
dependencies = [
"anyhow",
"libc",
@@ -3122,7 +3122,7 @@ dependencies = [
[[package]]
name = "pf-gpu"
version = "0.31.0"
version = "0.31.1"
dependencies = [
"anyhow",
"pf-host-config",
@@ -3136,11 +3136,11 @@ dependencies = [
[[package]]
name = "pf-host-config"
version = "0.31.0"
version = "0.31.1"
[[package]]
name = "pf-inject"
version = "0.31.0"
version = "0.31.1"
dependencies = [
"anyhow",
"ashpd",
@@ -3169,14 +3169,14 @@ dependencies = [
[[package]]
name = "pf-paths"
version = "0.31.0"
version = "0.31.1"
dependencies = [
"tracing",
]
[[package]]
name = "pf-presenter"
version = "0.31.0"
version = "0.31.1"
dependencies = [
"anyhow",
"ash",
@@ -3191,7 +3191,7 @@ dependencies = [
[[package]]
name = "pf-update"
version = "0.31.0"
version = "0.31.1"
dependencies = [
"serde",
"serde_json",
@@ -3199,7 +3199,7 @@ dependencies = [
[[package]]
name = "pf-update-check"
version = "0.31.0"
version = "0.31.1"
dependencies = [
"anyhow",
"aws-lc-rs",
@@ -3211,7 +3211,7 @@ dependencies = [
[[package]]
name = "pf-vaadec"
version = "0.31.0"
version = "0.31.1"
dependencies = [
"cros-codecs",
"pf-bitstream",
@@ -3220,7 +3220,7 @@ dependencies = [
[[package]]
name = "pf-vdisplay"
version = "0.31.0"
version = "0.31.1"
dependencies = [
"anyhow",
"ashpd",
@@ -3253,7 +3253,7 @@ dependencies = [
[[package]]
name = "pf-vkdecode"
version = "0.31.0"
version = "0.31.1"
dependencies = [
"ash",
"cros-codecs",
@@ -3264,7 +3264,7 @@ dependencies = [
[[package]]
name = "pf-win-display"
version = "0.31.0"
version = "0.31.1"
dependencies = [
"pf-paths",
"punktfunk-core",
@@ -3275,7 +3275,7 @@ dependencies = [
[[package]]
name = "pf-zerocopy"
version = "0.31.0"
version = "0.31.1"
dependencies = [
"anyhow",
"ash",
@@ -3487,7 +3487,7 @@ dependencies = [
[[package]]
name = "punktfunk-cli"
version = "0.31.0"
version = "0.31.1"
dependencies = [
"pf-client-core",
"punktfunk-core",
@@ -3497,7 +3497,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-android"
version = "0.31.0"
version = "0.31.1"
dependencies = [
"android_logger",
"anyhow",
@@ -3521,7 +3521,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-linux"
version = "0.31.0"
version = "0.31.1"
dependencies = [
"anyhow",
"async-channel",
@@ -3538,7 +3538,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-session"
version = "0.31.0"
version = "0.31.1"
dependencies = [
"log",
"pf-client-core",
@@ -3554,7 +3554,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-windows"
version = "0.31.0"
version = "0.31.1"
dependencies = [
"async-channel",
"mdns-sd",
@@ -3572,7 +3572,7 @@ dependencies = [
[[package]]
name = "punktfunk-core"
version = "0.31.0"
version = "0.31.1"
dependencies = [
"aes-gcm",
"cbindgen",
@@ -3582,6 +3582,7 @@ dependencies = [
"hmac 0.13.0",
"if-addrs",
"libc",
"log",
"opus",
"proptest",
"quinn",
@@ -3604,7 +3605,7 @@ dependencies = [
[[package]]
name = "punktfunk-encode-worker"
version = "0.31.0"
version = "0.31.1"
dependencies = [
"pf-encode",
"tracing",
@@ -3613,7 +3614,7 @@ dependencies = [
[[package]]
name = "punktfunk-host"
version = "0.31.0"
version = "0.31.1"
dependencies = [
"aes",
"aes-gcm",
@@ -3683,7 +3684,7 @@ dependencies = [
[[package]]
name = "punktfunk-probe"
version = "0.31.0"
version = "0.31.1"
dependencies = [
"anyhow",
"mdns-sd",
@@ -3697,7 +3698,7 @@ dependencies = [
[[package]]
name = "punktfunk-tray"
version = "0.31.0"
version = "0.31.1"
dependencies = [
"anyhow",
"ksni",
@@ -3721,7 +3722,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
[[package]]
name = "pyrowave-sys"
version = "0.31.0"
version = "0.31.1"
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.1"
edition = "2024"
rust-version = "1.85"
license = "MIT OR Apache-2.0"
+4 -29
View File
@@ -109,36 +109,11 @@ installer (all-vendor: NVIDIA, AMD, Intel).
`punktfunk-host` is the streaming host; `punktfunk-web` is the browser console (pairing + status).
**Linux:** every package ships systemd **user** units, so you don't launch the host by hand. The
host unit won't start until `~/.config/punktfunk/host.env` exists, so copy the template your package
installed first:
```sh
mkdir -p ~/.config/punktfunk
# /usr/share/punktfunk/ on Fedora/Arch/Bazzite, /usr/share/punktfunk-host/ on Debian/Ubuntu
# (on Bazzite take host.env.bazzite instead)
cp /usr/share/punktfunk/host.env.example ~/.config/punktfunk/host.env
systemctl --user enable --now punktfunk-host # the streaming host
systemctl --user enable --now punktfunk-web # the web console (Arch: install punktfunk-web first)
```
The shipped host unit runs `serve --gamestream` — the native `punktfunk/1` plane **plus** the
GameStream/Moonlight-compat planes, which belong on a trusted LAN only; for a native-only host drop
the flag with a `systemctl --user edit punktfunk-host` drop-in (which needs an empty `ExecStart=`
line before the replacement — the install guide has the snippet). Then open
`https://<host-ip>:47992` and pair.
How the virtual display and input are wired up depends on your desktop — see
[KDE](https://docs.punktfunk.unom.io/docs/kde) · [GNOME](https://docs.punktfunk.unom.io/docs/gnome) ·
The per-platform guide walks you through the rest — first run, the web console, pairing, and the
desktop-specific wiring ([KDE](https://docs.punktfunk.unom.io/docs/kde) ·
[GNOME](https://docs.punktfunk.unom.io/docs/gnome) ·
[Steam / gamescope](https://docs.punktfunk.unom.io/docs/gamescope) ·
[Sway](https://docs.punktfunk.unom.io/docs/sway).
**Windows:** the installer registers and starts the host as a `LocalSystem` service, so there is
nothing to run by hand — open the web console and pair. Use
`punktfunk-host service start|stop|restart|status` if you need to control it. Upgrades happen in
place — the console's **Updates** card, `winget upgrade unom.PunktfunkHost`, or the newer
`setup.exe` over the old install; uninstall from Add/Remove Programs.
[Sway](https://docs.punktfunk.unom.io/docs/sway)).
Full instructions: **[docs.punktfunk.unom.io/docs/install](https://docs.punktfunk.unom.io/docs/install)**.
+126 -1
View File
@@ -10,7 +10,7 @@
"name": "MIT OR Apache-2.0",
"identifier": "MIT OR Apache-2.0"
},
"version": "0.29.0"
"version": "0.31.1"
},
"paths": {
"/api/v1/client-logs": {
@@ -1860,6 +1860,69 @@
}
}
},
"/api/v1/library/provider/{provider}/running": {
"put": {
"tags": [
"library"
],
"summary": "Report which of a provider's titles are running",
"description": "The **live** counterpart to the `detect` hints in a reconcile payload: that one says *how to\nrecognize* a title's process, this one says *it is running now* (design §9,\n[`crate::runstate`]). For a provider that starts games itself and knows when they stop —\nPlaynite tracks every launch and fires an event on both edges — this is a fact the host would\notherwise have to re-derive by scanning, and for a title with nothing to scan for (an emulated\ngame, a manually added one) could not derive at all.\n\nDeclarative and idempotent, like the reconcile: the body is the provider's **complete** running\nset, so a missed event, a plugin restart or an install mid-game all self-correct on the next\nreport rather than drifting.\n\nThe report **expires** after `ttl_s` (90s) unless restated, which is what makes it safe for a\nlive provider to keep a streaming session open for a game the host cannot see: a plugin that\ndies with a game running stops counting shortly after, and the host falls back to process\nscanning exactly as it does without one. Re-report on every change **and** on a timer well\ninside the window.\n\nTitles the provider does not currently publish are ignored (counted in `unknown`), not an error:\na report may legitimately race its own reconcile.",
"operationId": "reportProviderRunning",
"parameters": [
{
"name": "provider",
"in": "path",
"description": "The provider id ([a-z0-9._-], `manual` reserved)",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProviderRunningInput"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "The report was accepted",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProviderRunningAccepted"
}
}
}
},
"400": {
"description": "Invalid provider id or payload",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/v1/library/scanners": {
"get": {
"tags": [
@@ -7792,6 +7855,46 @@
}
}
},
"ProviderRunningAccepted": {
"type": "object",
"description": "The result of a liveness report.",
"required": [
"matched",
"unknown",
"ttl_s"
],
"properties": {
"matched": {
"type": "integer",
"description": "How many reported titles matched an entry this provider currently publishes.",
"minimum": 0
},
"ttl_s": {
"type": "integer",
"format": "int64",
"description": "Seconds this report stays authoritative without being restated — re-report inside it while\nanything is running.",
"minimum": 0
},
"unknown": {
"type": "integer",
"description": "How many were ignored because no such entry exists (a report that raced a reconcile).",
"minimum": 0
}
}
},
"ProviderRunningInput": {
"type": "object",
"description": "Request body for `reportProviderRunning`.",
"properties": {
"running": {
"type": "array",
"items": {
"$ref": "#/components/schemas/RunningTitle"
},
"description": "Every title of this provider's that is running **right now**. The full set, not a delta:\nanything absent from it is reported as stopped."
}
}
},
"ReleaseDisplayRequest": {
"type": "object",
"description": "Request body for `releaseDisplay`.",
@@ -7846,6 +7949,28 @@
}
}
},
"RunningTitle": {
"type": "object",
"description": "One running title in a provider's liveness report.",
"required": [
"external_id"
],
"properties": {
"external_id": {
"type": "string",
"description": "The provider's own stable id for the title — the same key its reconcile payload uses."
},
"pid": {
"type": [
"integer",
"null"
],
"format": "int32",
"description": "The process id the provider started for it, when it knows one. Optional, and never trusted\nas a bare number: the host re-resolves it and pins it to its start time before it is ever\nsignalled, so a stale or recycled pid simply contributes nothing.",
"minimum": 0
}
}
},
"RuntimeRequest": {
"type": "object",
"required": [
@@ -96,7 +96,11 @@ internal fun ControllersScreen(
InputDevice.getDeviceIds()
.toList()
.mapNotNull { InputDevice.getDevice(it) }
.filter { !it.isVirtual && !Gamepad.isPad(it) }
// Everything real that is NOT counted as a controller — including a device that claims
// a pad source with no pad hardware behind it, which the Gamepads list above now
// rejects. One list or the other, never neither: this screen is where someone looks
// when the client's idea of "a pad is attached" disagrees with the room.
.filter { !it.isVirtual && !Gamepad.looksLikeController(it) }
}
DisposableEffect(Unit) {
val im = context.getSystemService(InputManager::class.java)
@@ -136,14 +140,19 @@ internal fun ControllersScreen(
// Read ONCE, up front: the test can end inside this very event, and the release that
// ended it still has to be swallowed here — see the B branch below.
val consume = consuming
// The CORRECTED keycode, so this screen shows the button the stream will send and not
// the one Android guessed for a pad it has no key layout for — the two differ on every
// controller [Gamepad.padKeyCode] exists for, and a tester that disagrees with the
// stream is worse than no tester. The raw pair is still reported in "Last input".
val code = Gamepad.padKeyCode(event)
when (event.action) {
KeyEvent.ACTION_DOWN -> {
held[event.keyCode] = true
if (event.keyCode == KeyEvent.KEYCODE_BUTTON_B) bHeld = true
held[code] = true
if (code == KeyEvent.KEYCODE_BUTTON_B) bHeld = true
}
KeyEvent.ACTION_UP -> {
held[event.keyCode] = false
if (event.keyCode == KeyEvent.KEYCODE_BUTTON_B) {
held[code] = false
if (code == KeyEvent.KEYCODE_BUTTON_B) {
bHeld = false
if (consume) {
if (event.eventTime - event.downTime >= HOLD_TO_FINISH_MS) {
@@ -167,23 +176,43 @@ internal fun ControllersScreen(
}
}
}
lastInput = "${event.device?.name}: ${KeyEvent.keyCodeToString(event.keyCode)}"
// Raw scancode AND keycode, plus the correction when one fired: this line is what a
// field report needs to pin an unmapped pad's report order without the device in hand.
val raw = KeyEvent.keyCodeToString(event.keyCode).removePrefix("KEYCODE_")
val fixed = KeyEvent.keyCodeToString(code).removePrefix("KEYCODE_")
lastInput = "${event.device?.name}: scan 0x%X · %s%s".format(
event.scanCode,
raw,
if (code != event.keyCode) "$fixed" else "",
)
consume
}
val motionProbe: (MotionEvent) -> Boolean = probe@{ event ->
if (!Gamepad.isPad(event.device)) return@probe false
// Through the device's resolved map, exactly as `Gamepad.AxisMapper` reads it while
// streaming — on a pad Android has no key layout for, the right stick and the triggers
// are not on the axes their names suggest.
val map = Gamepad.padMap(event.device)
axes["LX"] = event.getAxisValue(MotionEvent.AXIS_X)
axes["LY"] = event.getAxisValue(MotionEvent.AXIS_Y)
axes["RX"] = event.getAxisValue(MotionEvent.AXIS_Z)
axes["RY"] = event.getAxisValue(MotionEvent.AXIS_RZ)
axes["LT"] = maxOf(
event.getAxisValue(MotionEvent.AXIS_LTRIGGER),
event.getAxisValue(MotionEvent.AXIS_BRAKE),
)
axes["RT"] = maxOf(
event.getAxisValue(MotionEvent.AXIS_RTRIGGER),
event.getAxisValue(MotionEvent.AXIS_GAS),
)
axes["RX"] = event.getAxisValue(map.rightStickX)
axes["RY"] = event.getAxisValue(map.rightStickY)
axes["LT"] = if (map.leftTrigger == Gamepad.AXIS_NONE) {
maxOf(
event.getAxisValue(MotionEvent.AXIS_LTRIGGER),
event.getAxisValue(MotionEvent.AXIS_BRAKE),
)
} else {
map.level(event.getAxisValue(map.leftTrigger))
}
axes["RT"] = if (map.rightTrigger == Gamepad.AXIS_NONE) {
maxOf(
event.getAxisValue(MotionEvent.AXIS_RTRIGGER),
event.getAxisValue(MotionEvent.AXIS_GAS),
)
} else {
map.level(event.getAxisValue(map.rightTrigger))
}
axes["HX"] = event.getAxisValue(MotionEvent.AXIS_HAT_X)
axes["HY"] = event.getAxisValue(MotionEvent.AXIS_HAT_Y)
consuming
@@ -689,6 +718,16 @@ private fun PadRow(info: PadInfo, gamepadSetting: Int) {
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
// Only when a correction is actually in force: on a pad Android has a key layout for
// there is nothing to say, and a line that says "normal" on every device teaches
// nobody anything. Named rather than merely flagged, so a field report can quote it.
padButtonsNote(info.buttons)?.let {
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (info.canRumble) {
OutlinedButton(onClick = { info.dev?.let(::testRumble) }) { Text("Test rumble") }
} else {
@@ -784,6 +823,12 @@ internal data class PadInfo(
val controllerNumber: Int,
val resolvedPref: Int,
val canRumble: Boolean,
/**
* The report order this pad's buttons were resolved to ([Gamepad.padButtons]). Defaults to
* the pad Android already knows, which is what a screenshot scene wants and what the note
* under the card stays silent about.
*/
val buttons: Gamepad.PadButtons = Gamepad.PadButtons.NATIVE,
val dev: InputDevice? = null,
)
@@ -793,6 +838,7 @@ internal fun padInfoOf(dev: InputDevice): PadInfo = PadInfo(
forwarded = isForwarded(dev),
controllerNumber = dev.controllerNumber,
resolvedPref = Gamepad.prefFor(dev),
buttons = Gamepad.padMap(dev).buttons, // via padMap so the list refresh reuses the cache
canRumble = deviceHasVibrator(dev),
dev = dev,
)
@@ -823,6 +869,20 @@ internal fun testRumble(dev: InputDevice) {
}
/** Identity line: VID:PID + the source classes Android assigned. */
/**
* What to say about a pad whose buttons had to be resolved from their scancodes because Android
* has no key layout for it — null for a pad it does know, which needs no explanation.
*/
private fun padButtonsNote(buttons: Gamepad.PadButtons): String? = when (buttons) {
Gamepad.PadButtons.NATIVE -> null
Gamepad.PadButtons.GENERIC_SONY ->
"Android has no button layout for this controller — read as a PlayStation pad"
Gamepad.PadButtons.GENERIC_XBOX ->
"Android has no button layout for this controller — read as an Xbox pad"
Gamepad.PadButtons.SONY_MODERN ->
"Android has no button layout for this controller — face buttons corrected"
}
private fun deviceDetail(dev: InputDevice): String =
"%04X:%04X · %s".format(dev.vendorId, dev.productId, sourcesLabel(dev.sources))
@@ -11,6 +11,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.platform.LocalContext
import io.unom.punktfunk.kit.Gamepad
import kotlin.math.abs
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
@@ -96,7 +97,7 @@ fun GamepadNavEffect(
val keyProbe: (KeyEvent) -> Boolean = probe@{ ev ->
val down = ev.action == KeyEvent.ACTION_DOWN
val edge = down && ev.repeatCount == 0
when (ev.keyCode) {
when (Gamepad.padKeyCode(ev)) {
KeyEvent.KEYCODE_DPAD_LEFT -> { state.dpadX = if (down) -1 else 0; true }
KeyEvent.KEYCODE_DPAD_RIGHT -> { state.dpadX = if (down) 1 else 0; true }
// TV remote (no face buttons): Up → Settings, Down → a saved host's Options.
@@ -202,7 +203,7 @@ fun GamepadNavEffect2D(
val keyProbe: (KeyEvent) -> Boolean = probe@{ ev ->
val down = ev.action == KeyEvent.ACTION_DOWN
val edge = down && ev.repeatCount == 0
when (ev.keyCode) {
when (Gamepad.padKeyCode(ev)) {
KeyEvent.KEYCODE_DPAD_LEFT -> { state.dpadX = if (down) -1 else 0; true }
KeyEvent.KEYCODE_DPAD_RIGHT -> { state.dpadX = if (down) 1 else 0; true }
KeyEvent.KEYCODE_DPAD_UP -> { state.dpadY = if (down) -1 else 0; true }
@@ -616,7 +616,7 @@ class MainActivity : ComponentActivity() {
// no BUTTON_SELECT scancode delivers its Select: see [Gamepad.padButtonBit], which is
// why this asks it rather than `buttonBit`).
if (event.isFromSource(InputDevice.SOURCE_GAMEPAD)) {
val bit = Gamepad.padButtonBit(event.keyCode, event.flags)
val bit = Gamepad.padButtonBit(Gamepad.padKeyCode(event), event.flags)
if (bit != 0) {
// The router forwards the bit on this device's own wire pad index and tracks held
// state per pad. The emergency-exit chord (Select + Start + L1 + R1) is handled
@@ -708,8 +708,10 @@ class MainActivity : ComponentActivity() {
if (event.isFromSource(InputDevice.SOURCE_GAMEPAD)) {
// Not streaming: a game controller drives the Compose UI (TV + phone). Map the face
// buttons to the navigation the focus system / back stack understand; D-pad *keys*
// already move focus on their own, so they fall through to super untouched.
when (event.keyCode) {
// already move focus on their own, so they fall through to super untouched. Read
// through [Gamepad.padKeyCode] so a pad Android has no key layout for reaches the
// menus on the right buttons too, not only the stream.
when (Gamepad.padKeyCode(event)) {
// B → back. Drive the OnBackPressedDispatcher directly rather than synthesising a
// BACK KeyEvent: a synthetic event isn't "tracking", so the framework's default
// onKeyUp(BACK) never calls onBackPressed() and Compose BackHandlers wouldn't fire.
@@ -940,6 +940,17 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
}
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {
// The view's CURRENT pixel size, for the ASurfaceControl layer's
// destination rect. It is reported here and not only at
// surfaceCreated because the view grows a frame or two after the
// stream screen appears — hiding the system bars and switching on
// cutout drawing both resize it, and neither recreates the surface.
// A layer left on the start-up rect paints the picture small, in the
// top-left corner. The view's own size, not the buffer geometry in
// `width`/`height`: the layer composites in the view's space.
NativeBridge.nativeVideoSurfaceSize(
handle, this@apply.width, this@apply.height,
)
// Re-assert the frame-rate vote: a buffer-geometry change can reset
// the surface's frame-rate setting on some OEM builds, silently
// dropping the 120 Hz pin mid-stream. Mirrors the native hint's
@@ -65,6 +65,7 @@ internal object ConsoleJson {
.put("online", online)
.put("mgmt_port", advert?.mgmtPort ?: h.mgmtPort ?: DEFAULT_MGMT_PORT)
.put("can_wake", !online && h.mac.isNotEmpty())
.put("clipboard_sync", h.clipboardSync)
.put("last_used", JSONObject.NULL)
.put("os", advert?.os?.takeIf { it.isNotEmpty() } ?: h.os)
.put("pin", JSONObject.NULL)
@@ -106,6 +107,7 @@ internal object ConsoleJson {
.put("online", true)
.put("mgmt_port", d.mgmtPort ?: DEFAULT_MGMT_PORT)
.put("can_wake", false)
.put("clipboard_sync", false)
.put("last_used", JSONObject.NULL)
.put("os", d.os)
.put("pin", JSONObject.NULL)
@@ -129,6 +131,7 @@ internal object ConsoleJson {
.put("online", true)
.put("mgmt_port", h.mgmtPort ?: DEFAULT_MGMT_PORT)
.put("can_wake", false)
.put("clipboard_sync", h.clipboardSync)
.put("last_used", JSONObject.NULL)
.put("os", h.os)
.put("pin", pin?.let(::profileChip) ?: JSONObject.NULL)
@@ -314,15 +317,20 @@ internal object ConsoleJson {
j.put("invert_scroll", s.invertScroll)
j.put("pad_haptics", s.padHaptics)
j.put("pad_speaker", if (s.padSpeaker) "pad" else "off")
// Android-only rows ride `extra` (WP5 gives them RowIds); nothing on the desktop reads them.
val extra = j.optJSONObject("extra") ?: JSONObject()
extra.put("android.low_latency", s.lowLatencyMode)
extra.put("android.rumble_on_phone", s.rumbleOnPhone)
extra.put("android.gyro_on_phone", s.gyroOnPhone)
extra.put("android.sc2_capture", s.sc2Capture)
extra.put("android.ds_capture", s.dsCapture)
extra.put("android.gamepad_ui_mode", s.gamepadUiMode)
j.put("extra", extra)
// Android-only rows ride `Settings::extra`, which is `#[serde(flatten)]` — so they are
// TOP-LEVEL keys of this document, not a nested `extra` object. Nesting them put the
// whole object into the map under the literal key "extra", where no console row could
// read it and every value the console wrote came straight back as the one we had sent.
j.put("android.low_latency", s.lowLatencyMode)
j.put("android.rumble_on_phone", s.rumbleOnPhone)
j.put("android.gyro_on_phone", s.gyroOnPhone)
j.put("android.sc2_capture", s.sc2Capture)
j.put("android.ds_capture", s.dsCapture)
j.put("android.gamepad_ui_mode", s.gamepadUiMode)
j.put("android.gamepad_ui_enabled", s.gamepadUiEnabled)
// A store written by the nesting build carries the stale wrapper; drop it rather than
// round-trip a copy of these keys that nothing reads for the life of the install.
j.remove("extra")
return j
}
@@ -332,7 +340,8 @@ internal object ConsoleJson {
*/
fun applySettings(s: Settings, j: JSONObject): Settings {
fun str(k: String, cur: String) = j.optString(k, cur).ifEmpty { cur }
val extra = j.optJSONObject("extra") ?: JSONObject()
// The `android.*` keys are TOP-LEVEL here, not nested: `Settings::extra` is
// `#[serde(flatten)]`, so the console writes them beside `width` and `codec`.
return s.copy(
width = j.optInt("width", s.width),
height = j.optInt("height", s.height),
@@ -369,13 +378,14 @@ internal object ConsoleJson {
"off" -> false
else -> s.padSpeaker
},
lowLatencyMode = extra.optBoolean("android.low_latency", s.lowLatencyMode),
rumbleOnPhone = extra.optBoolean("android.rumble_on_phone", s.rumbleOnPhone),
gyroOnPhone = extra.optBoolean("android.gyro_on_phone", s.gyroOnPhone),
sc2Capture = extra.optBoolean("android.sc2_capture", s.sc2Capture),
dsCapture = extra.optBoolean("android.ds_capture", s.dsCapture),
gamepadUiMode = extra.optString("android.gamepad_ui_mode", s.gamepadUiMode)
lowLatencyMode = j.optBoolean("android.low_latency", s.lowLatencyMode),
rumbleOnPhone = j.optBoolean("android.rumble_on_phone", s.rumbleOnPhone),
gyroOnPhone = j.optBoolean("android.gyro_on_phone", s.gyroOnPhone),
sc2Capture = j.optBoolean("android.sc2_capture", s.sc2Capture),
dsCapture = j.optBoolean("android.ds_capture", s.dsCapture),
gamepadUiMode = j.optString("android.gamepad_ui_mode", s.gamepadUiMode)
.ifEmpty { s.gamepadUiMode },
gamepadUiEnabled = j.optBoolean("android.gamepad_ui_enabled", s.gamepadUiEnabled),
)
}
}
@@ -37,8 +37,10 @@ import io.unom.punktfunk.models.ActiveSession
import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicLong
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONArray
import org.json.JSONObject
@@ -157,6 +159,9 @@ object SkiaConsole {
val opts = JSONObject()
.put("device_name", deviceName(app))
.put("gpu_cache_bytes", gpuCacheBytes(app))
// The touch shell exists as a fallback on phones/tablets but not on a TV —
// gates the console's own "Controller-optimized UI" off switch.
.put("fallback_ui", !io.unom.punktfunk.isTvDevice(app))
.put("settings", ConsoleJson.settings(initial, base))
.put("profiles", JSONArray(ConsoleJson.profiles(profiles)))
.put("known_hosts", JSONObject(ConsoleJson.knownHosts(knownHostStore.all())))
@@ -213,6 +218,14 @@ object SkiaConsole {
main.post(object : Runnable {
override fun run() {
if (handle == 0L) return
// Only while the console is ON SCREEN (attached): parked behind the touch UI
// or a stream there is nobody to show the presence pips to — and mid-stream
// the radio belongs to the session, which is exactly why discovery stops for
// it. The timer keeps ticking so probes resume within a cadence of re-attach.
if (onConnected == null) {
main.postDelayed(this, 12_000)
return
}
val targets = knownHostStore.all().filter { kh -> discovered.none { kh.matches(it) } }
ioPool.execute {
val up = targets.filter { NativeBridge.nativeProbe(it.address, it.port, 3_000) }
@@ -536,12 +549,14 @@ object SkiaConsole {
c.optJSONObject("FetchLibrary")?.let { fetchLibrary(it, refreshOnly = false) }
c.optJSONObject("RefreshRunning")?.let { fetchLibrary(it, refreshOnly = true) }
c.optJSONObject("Pair")?.let(::pair)
c.optJSONObject("SendLogs")?.let { notice("Sending logs isn't available on this device yet") }
c.optJSONObject("SendLogs")?.let(::sendLogs)
c.optJSONObject("SaveHost")?.let(::saveHost)
c.optJSONObject("UpdateHost")?.let(::updateHost)
c.optJSONObject("ForgetHost")?.let(::forgetHost)
c.optJSONObject("Wake")?.let(::wake)
c.optJSONObject("SetPin")?.let(::setPin)
c.optJSONObject("BindProfile")?.let(::bindProfile)
c.optJSONObject("SetClipboard")?.let(::setClipboard)
c.optJSONObject("OpenPlatformScreen")?.let { onPlatformScreen?.invoke(it.optString("id")) }
c.optJSONObject("PadAction")?.let { onPadAction?.invoke(it.optString("action"), it.optString("pad_key")) }
c.optString("OpenPlatformScreen").takeIf { c.has("OpenPlatformScreen") && c.opt("OpenPlatformScreen") is String }
@@ -582,6 +597,22 @@ object SkiaConsole {
pushHosts(); pushKnownHosts()
}
/** `ConsoleCmd::BindProfile` — the host's default binding (`KnownHost.profileId`); null clears. */
private fun bindProfile(c: JSONObject) {
val kh = hostForKey(c.optString("key")) ?: return
val pid = c.optString("profile_id")
.takeIf { c.has("profile_id") && !c.isNull("profile_id") && it.isNotEmpty() }
knownHostStore.save(kh.copy(profileId = pid))
pushHosts(); pushKnownHosts()
}
/** `ConsoleCmd::SetClipboard` — the per-host clipboard trust toggle. */
private fun setClipboard(c: JSONObject) {
val kh = hostForKey(c.optString("key")) ?: return
knownHostStore.save(kh.copy(clipboardSync = c.optBoolean("on")))
pushHosts(); pushKnownHosts()
}
private fun setPin(c: JSONObject) {
val kh = hostForKey(c.optString("key")) ?: return
val pid = c.optString("profile_id"); val pin = c.optBoolean("pin")
@@ -591,6 +622,52 @@ object SkiaConsole {
pushHosts(); pushKnownHosts()
}
/**
* `ConsoleCmd::SendLogs` — the native log ring (`nativeRenderLogs`) posted to this
* paired host's `POST /api/v1/client-logs` over the same mTLS client the library fetch
* uses; the result comes back as a notice, in the desktop console's wording. The header
* mirrors the desktop's identity line (`punktfunk-session <ver> (<os> <arch>) — client
* log bundle`).
*/
private fun sendLogs(c: JSONObject) {
val addr = c.optString("addr"); val mgmt = c.optInt("mgmt"); val fp = c.optString("fp_hex")
val hostName = c.optString("host_name").ifEmpty { addr }
val id = identity
if (id == null) {
notice("Identity not ready yet — try again in a moment")
return
}
val version = appContext?.let { app ->
runCatching { app.packageManager.getPackageInfo(app.packageName, 0).versionName }.getOrNull()
} ?: "?"
val header = "punktfunk-android $version (android ${android.os.Build.VERSION.RELEASE}; " +
"${android.os.Build.SUPPORTED_ABIS.firstOrNull() ?: "?"}) — client log bundle"
ioPool.execute {
val err = runCatching {
val body = NativeBridge.nativeRenderLogs(header)
val client = io.unom.punktfunk.kit.library.mtlsHttpClient(
id.certPem, id.privateKeyPem, addr, fp,
)
val req = Request.Builder()
.url("https://$addr:$mgmt/api/v1/client-logs")
.post(body.toRequestBody("text/plain; charset=utf-8".toMediaType()))
.build()
client.newCall(req).execute().use { resp ->
if (resp.code == 200) "" else "host answered HTTP ${resp.code}"
}
}.getOrElse { it.message ?: "upload failed" }
main.post {
notice(
if (err.isEmpty()) {
"Logs sent to $hostName — download them from its web console's Logs page"
} else {
"Couldn't send logs — $err"
},
)
}
}
}
private fun pair(c: JSONObject) {
val addr = c.optString("addr"); val port = c.optInt("port")
val pin = c.optString("pin"); val name = c.optString("device_name")
@@ -159,7 +159,12 @@ fun SkiaConsoleShell(
if (ev.action != KeyEvent.ACTION_DOWN && ev.action != KeyEvent.ACTION_UP) return@probe false
val fromPad = ev.isFromSource(InputDevice.SOURCE_GAMEPAD)
if (fromPad) {
val bit = when (ev.keyCode) {
// The CORRECTED keycode: a pad Android has no key layout for delivers its buttons
// under other buttons' names, so read raw this console answered ✕ with whatever
// sat in BUTTON_A's scancode slot. Same resolution the stream uses — the console
// and the game must not disagree about which button a user pressed.
val code = Gamepad.padKeyCode(ev)
val bit = when (code) {
KeyEvent.KEYCODE_BUTTON_A -> 0
KeyEvent.KEYCODE_BUTTON_B -> 1
KeyEvent.KEYCODE_BUTTON_X -> 2
@@ -179,7 +184,7 @@ fun SkiaConsoleShell(
}
return@probe true
}
val dbit = when (ev.keyCode) {
val dbit = when (code) {
KeyEvent.KEYCODE_DPAD_UP -> 0
KeyEvent.KEYCODE_DPAD_DOWN -> 1
KeyEvent.KEYCODE_DPAD_LEFT -> 2
@@ -191,7 +196,7 @@ fun SkiaConsoleShell(
padState.push(handle)
return@probe true
}
if (ev.keyCode == KeyEvent.KEYCODE_BUTTON_SELECT && down && ev.repeatCount == 0) {
if (code == KeyEvent.KEYCODE_BUTTON_SELECT && down && ev.repeatCount == 0) {
NativeBridge.nativeConsoleMenu(handle, 0) // ▲ opens the tile's options on Home
return@probe true
}
@@ -287,10 +292,14 @@ fun SkiaConsoleShell(
})
// Touch → the console's pointer (surface pixels): the escape hatch when no
// pad is attached, and the natural way to press a legend hint on a phone.
// A finger's down is kind 6 (the shell defers it so a swipe scrolls); a
// mouse — which Android delivers through this same listener — keeps kind 1
// and acts on the press, as a mouse should.
setOnTouchListener { v, ev ->
if (handle == 0L) return@setOnTouchListener false
val kind = when (ev.actionMasked) {
MotionEvent.ACTION_DOWN -> 1
MotionEvent.ACTION_DOWN ->
if (ev.getToolType(0) == MotionEvent.TOOL_TYPE_MOUSE) 1 else 6
MotionEvent.ACTION_MOVE -> 0
MotionEvent.ACTION_UP -> 2
MotionEvent.ACTION_CANCEL -> 5
@@ -0,0 +1,73 @@
package io.unom.punktfunk
import io.unom.punktfunk.console.ConsoleJson
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The Android-only console settings ride `trust::Settings::extra`, which is `#[serde(flatten)]`:
* they are TOP-LEVEL keys of the settings document, beside `width` and `codec`.
*
* They were written and read nested under an `"extra"` object instead. Serde put that whole
* object into the map under the literal key `"extra"`, so no console row ever found
* `android.gamepad_ui_enabled` — and the value the console saved came back to Kotlin as the one
* Kotlin had just sent. On glass that was a "Controller-optimized UI" switch you could turn off
* with nothing happening: the console stayed up, because the setting never moved.
*/
class ConsoleSettingsExtraTest {
@Test
fun androidKeysAreWrittenFlat() {
val j = ConsoleJson.settings(Settings(gamepadUiEnabled = false, lowLatencyMode = false), null)
assertTrue("the console reads this key at the top level", j.has("android.gamepad_ui_enabled"))
assertFalse(j.getBoolean("android.gamepad_ui_enabled"))
assertFalse(j.getBoolean("android.low_latency"))
assertFalse("a nested wrapper is what serde swallows whole", j.has("extra"))
}
/** A store written by the nesting build must not keep echoing its dead wrapper. */
@Test
fun aStaleNestedWrapperIsDropped() {
val base = JSONObject().put(
"extra",
JSONObject().put("android.gamepad_ui_enabled", true),
)
assertFalse(ConsoleJson.settings(Settings(gamepadUiEnabled = false), base).has("extra"))
}
@Test
fun theConsolesOwnSaveIsReadBack() {
val saved = JSONObject()
.put("android.gamepad_ui_enabled", false)
.put("android.gamepad_ui_mode", GAMEPAD_UI_ALWAYS)
.put("android.ds_capture", false)
val next = ConsoleJson.applySettings(Settings(), saved)
assertFalse("turning the console off must reach the store", next.gamepadUiEnabled)
assertEquals(GAMEPAD_UI_ALWAYS, next.gamepadUiMode)
assertFalse(next.dsCapture)
}
/** Both halves against each other — the shape only holds if they agree. */
@Test
fun theRoundTripKeepsEveryAndroidRow() {
val want = Settings(
gamepadUiEnabled = false,
gamepadUiMode = GAMEPAD_UI_ALWAYS,
lowLatencyMode = false,
rumbleOnPhone = true,
gyroOnPhone = true,
sc2Capture = false,
dsCapture = false,
)
val got = ConsoleJson.applySettings(Settings(), ConsoleJson.settings(want, null))
assertEquals(want.gamepadUiEnabled, got.gamepadUiEnabled)
assertEquals(want.gamepadUiMode, got.gamepadUiMode)
assertEquals(want.lowLatencyMode, got.lowLatencyMode)
assertEquals(want.rumbleOnPhone, got.rumbleOnPhone)
assertEquals(want.gyroOnPhone, got.gyroOnPhone)
assertEquals(want.sc2Capture, got.sc2Capture)
assertEquals(want.dsCapture, got.dsCapture)
}
}
@@ -3,6 +3,7 @@ package io.unom.punktfunk.kit
import android.view.InputDevice
import android.view.KeyEvent
import android.view.MotionEvent
import java.util.concurrent.ConcurrentHashMap
import kotlin.math.roundToInt
/**
@@ -127,8 +128,12 @@ object Gamepad {
// Microsoft Xbox One / Series product ids (wired + the common Bluetooth/dongle revisions). All
// behave like Xbox 360 on the host minus the glyph identity, so they share one pref byte.
// The Bluetooth revisions (0x02E0/0x02FD Xbox One S, 0x0B05/0x0B22 Elite Series 2 and its
// Core) are here for the same reason as the wired ones: they are the pads a couch actually
// pairs to a TV box, and without them an Elite streams under the Xbox 360 identity.
private val PID_XBOXONE = setOf(
0x02D1, 0x02DD, 0x02E3, 0x02EA, 0x0B00, 0x0B12, 0x0B13, 0x0B20,
0x02D1, 0x02DD, 0x02E0, 0x02E3, 0x02EA, 0x02FD,
0x0B00, 0x0B05, 0x0B12, 0x0B13, 0x0B20, 0x0B22,
)
/**
@@ -188,9 +193,53 @@ object Gamepad {
s and InputDevice.SOURCE_JOYSTICK == InputDevice.SOURCE_JOYSTICK
}
/** All connected gamepad/joystick [InputDevice]s, in system enumeration order. */
fun pads(): List<InputDevice> =
InputDevice.getDeviceIds().toList().mapNotNull { InputDevice.getDevice(it) }.filter { isPad(it) }
/**
* True when [dev] is a controller someone can actually hold: a pad source ([isPad]) that is a
* REAL device carrying real pad hardware — a stick, a HAT, or the A/B face buttons.
*
* [isPad] alone answers "did this event come from a pad source", which is the right question
* for ROUTING an event and the wrong one for "is a controller attached". Devices publish
* inputs that claim `SOURCE_GAMEPAD`/`SOURCE_JOYSTICK` while being no such thing — OEM
* game-mode overlays and the gaming-phone shoulder triggers among them — and one of those is
* enough to pin the console UI on forever: a pad that was never there cannot disconnect, so
* "With a controller" has no way back to the touch UI.
*
* The capability probe is what separates them: a source class is a claim, a stick or a face
* button is hardware. It is not a complete defence — an OEM device that declares `BTN_GAMEPAD`
* and a pair of axes is indistinguishable from a pad at this layer — so the master switch stays
* the guaranteed way out. `isVirtual` only means "device id < 0" (the platform's own synthetic
* device), which is worth excluding but catches none of the above.
*/
fun looksLikeController(dev: InputDevice?): Boolean {
val d = dev ?: return false
return looksLikeController(
padSource = isPad(d),
virtual = d.isVirtual,
hasStick = d.getMotionRange(MotionEvent.AXIS_X, InputDevice.SOURCE_JOYSTICK) != null ||
d.getMotionRange(MotionEvent.AXIS_HAT_X, InputDevice.SOURCE_JOYSTICK) != null,
// `hasKeys` answers for the DEVICE, so a pad with no sticks at all (an arcade stick,
// a d-pad-only pad) still counts.
hasFaceButtons = d.hasKeys(KeyEvent.KEYCODE_BUTTON_A, KeyEvent.KEYCODE_BUTTON_B)
.any { it },
)
}
/** [looksLikeController]'s decision, over plain facts — the seam its truth table is tested at
* (an [InputDevice] cannot be built off a device). */
fun looksLikeController(
padSource: Boolean,
virtual: Boolean,
hasStick: Boolean,
hasFaceButtons: Boolean,
): Boolean = padSource && !virtual && (hasStick || hasFaceButtons)
/**
* All connected controllers, in system enumeration order — the devices that answer "is a pad
* attached", so the filter is [looksLikeController] rather than the looser [isPad].
*/
fun pads(): List<InputDevice> = InputDevice.getDeviceIds().toList()
.mapNotNull { InputDevice.getDevice(it) }
.filter { looksLikeController(it) }
/** First connected gamepad/joystick [InputDevice], or null when none is attached. */
fun firstPad(): InputDevice? = pads().firstOrNull()
@@ -293,6 +342,334 @@ object Gamepad {
else -> BTN_BACK
}
// ---------------------------------------------------------------------------------------
// Controllers Android has no key layout for
//
// Android turns a pad's raw evdev scancode into a `KeyEvent.keyCode` through a KEY LAYOUT
// file matched on USB VID/PID (`Vendor_054c_Product_0ce6.kl` & co.). 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, `0x132`→BUTTON_C, and so on up. That is only right if
// the pad's buttons happen to sit at the positions the file assumes, and a HID gamepad with
// no kernel driver behind it numbers its buttons 1..n straight through IN ITS OWN REPORT
// ORDER — so every keycode after the first divergence is somebody else's button.
//
// Reported from a Fire TV Stick 4K Max (2026-08-20): a DualSense and an Xbox Elite Series 2,
// both over Bluetooth, both identified correctly but with buttons landing on the wrong
// actions ("L1 being L2"). Neither has a layout there — AOSP ships none for the Elite
// Series 2 over Bluetooth (`045e:0b05`) on ANY version, and the DualSense's
// (`054c:0ce6`) both postdates Fire OS and carries `requires_kernel_config
// CONFIG_HID_PLAYSTATION`, which a Fire TV kernel does not have. A DualSense reporting
// straight through puts L2 on `0x136`, which `Generic.kl` calls BUTTON_L1: the reported
// symptom exactly.
//
// The fix is to resolve buttons from the SCANCODE, which is the pad's own report position and
// 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. 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 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
/**
* The report order a controller's buttons are numbered in, and with it which scancode carries
* which physical button. Resolved once per device by [padButtons] from what the device
* declares; [correct] then maps one scancode to the keycode it should have produced.
*/
enum class PadButtons {
/**
* The keycode Android delivered is already right — a device-specific key layout is in
* force, or the generic one happens to agree. [correct] changes nothing.
*/
NATIVE,
/**
* A Sony pad numbering straight through with no kernel driver behind it: □ ✕ ○ △ L1 R1
* L2 R2 Create Options L3 R3 PS, i.e. `0x130`..`0x13c` in that order. The analog trigger
* value rides `AXIS_RX`/`AXIS_RY` on such a pad, so the digital L2/R2 fold to keycodes
* [buttonBit] deliberately drops — the wire carries the axis, never both.
*/
GENERIC_SONY,
/**
* An Xbox-layout pad numbering straight through: A B X Y LB RB View Menu LS RS, i.e.
* `0x130`..`0x139`. Also the fallback for an unbranded pad, which near-universally
* clones the Xbox layout — the same assumption [styleFor] makes for its glyphs.
*/
GENERIC_XBOX,
/**
* A Sony pad WITH a kernel driver (`hid-playstation` / `hid-sony`) but still no key
* layout — the combination an Android 11 box on a 5.10 kernel lands in. Such a driver
* emits the modern Linux gamepad codes, where `0x133` is BTN_NORTH (△) and `0x134` is
* BTN_WEST (□); `Generic.kl` reads those two as BUTTON_X and BUTTON_Y, so exactly the
* face pair comes out swapped and nothing else is wrong.
*/
SONY_MODERN,
;
/**
* The keycode scancode [scan] should have produced, given Android delivered [keyCode].
*
* Returns [keyCode] untouched unless it is precisely what [genericKeyCode] would have
* said for [scan] — anything else is a device-specific layout's answer, which outranks
* this table. That guard is what makes the correction idempotent and safe to run on
* every pad: it can only ever fire where Android was guessing in the first place.
*/
fun correct(scan: Int, keyCode: Int): Int {
if (this == NATIVE) return keyCode
if (keyCode != genericKeyCode(scan)) return keyCode
val fixed = when (this) {
GENERIC_SONY -> when (scan) {
0x130 -> KeyEvent.KEYCODE_BUTTON_X // □
0x131 -> KeyEvent.KEYCODE_BUTTON_A // ✕
0x132 -> KeyEvent.KEYCODE_BUTTON_B // ○
0x133 -> KeyEvent.KEYCODE_BUTTON_Y // △
0x134 -> KeyEvent.KEYCODE_BUTTON_L1
0x135 -> KeyEvent.KEYCODE_BUTTON_R1
0x136 -> KeyEvent.KEYCODE_BUTTON_L2 // analog: AXIS_RX
0x137 -> KeyEvent.KEYCODE_BUTTON_R2 // analog: AXIS_RY
0x138 -> KeyEvent.KEYCODE_BUTTON_SELECT // Create / Share
0x139 -> KeyEvent.KEYCODE_BUTTON_START // Options
0x13a -> KeyEvent.KEYCODE_BUTTON_THUMBL
0x13b -> KeyEvent.KEYCODE_BUTTON_THUMBR
0x13c -> KeyEvent.KEYCODE_BUTTON_MODE // PS
// 0x13d touchpad click / 0x13e mute: no wire button, dropped as before.
else -> KeyEvent.KEYCODE_UNKNOWN
}
GENERIC_XBOX -> when (scan) {
0x132 -> KeyEvent.KEYCODE_BUTTON_X
0x133 -> KeyEvent.KEYCODE_BUTTON_Y
0x134 -> KeyEvent.KEYCODE_BUTTON_L1
0x135 -> KeyEvent.KEYCODE_BUTTON_R1
0x136 -> KeyEvent.KEYCODE_BUTTON_SELECT // View
0x137 -> KeyEvent.KEYCODE_BUTTON_START // Menu
0x138 -> KeyEvent.KEYCODE_BUTTON_THUMBL
0x139 -> KeyEvent.KEYCODE_BUTTON_THUMBR
else -> keyCode // 0x130 A / 0x131 B already agree
}
// Only the face pair; every other row of Generic.kl is right for these codes.
SONY_MODERN -> when (scan) {
0x133 -> KeyEvent.KEYCODE_BUTTON_Y // BTN_NORTH = △
0x134 -> KeyEvent.KEYCODE_BUTTON_X // BTN_WEST = □
else -> keyCode
}
NATIVE -> keyCode
}
return fixed
}
}
/**
* AOSP `Generic.kl`'s gamepad rows — the layout Android falls back to when no device-specific
* key layout matches the pad's VID/PID. Scancodes outside it answer [KeyEvent.KEYCODE_UNKNOWN],
* which never equals a real delivered keycode, so [PadButtons.correct]'s guard leaves those
* events alone.
*/
fun genericKeyCode(scan: Int): Int = when (scan) {
0x130 -> KeyEvent.KEYCODE_BUTTON_A
0x131 -> KeyEvent.KEYCODE_BUTTON_B
0x132 -> KeyEvent.KEYCODE_BUTTON_C
0x133 -> KeyEvent.KEYCODE_BUTTON_X
0x134 -> KeyEvent.KEYCODE_BUTTON_Y
0x135 -> KeyEvent.KEYCODE_BUTTON_Z
0x136 -> KeyEvent.KEYCODE_BUTTON_L1
0x137 -> KeyEvent.KEYCODE_BUTTON_R1
0x138 -> KeyEvent.KEYCODE_BUTTON_L2
0x139 -> KeyEvent.KEYCODE_BUTTON_R2
0x13a -> KeyEvent.KEYCODE_BUTTON_SELECT
0x13b -> KeyEvent.KEYCODE_BUTTON_START
0x13c -> KeyEvent.KEYCODE_BUTTON_MODE
0x13d -> KeyEvent.KEYCODE_BUTTON_THUMBL
0x13e -> KeyEvent.KEYCODE_BUTTON_THUMBR
else -> KeyEvent.KEYCODE_UNKNOWN
}
/**
* How one controller must be read: its button report order plus the axes its right stick and
* analog triggers actually arrive on. Resolved once per device by [padMap].
*/
class PadMap(
val buttons: PadButtons,
val rightStickX: Int = MotionEvent.AXIS_Z,
val rightStickY: Int = MotionEvent.AXIS_RZ,
/**
* The trigger axes, or [AXIS_NONE] for a pad Android already names them on — that case
* keeps folding LTRIGGER with BRAKE and RTRIGGER with GAS by max, which is what pads that
* report one pair, the other, or both have always needed.
*/
val leftTrigger: Int = AXIS_NONE,
val rightTrigger: Int = AXIS_NONE,
/** Those trigger axes rest at 1 rather than 0, measured off the device's own range. */
val triggersSigned: Boolean = false,
) {
/** One resolved trigger axis value, folded to the 0..1 the wire scale expects. */
fun level(v: Float): Float = if (triggersSigned) (v + 1f) / 2f else v
}
/** The map every pad with a key layout uses: Android's own names, unchanged. */
private val NATIVE_MAP = PadMap(PadButtons.NATIVE)
/**
* Resolved [PadMap]s, keyed by [InputDevice.getDescriptor] — the device's stable identity
* hash, so a pad that reconnects is recognised and a model resolves once for the process.
* Nothing here depends on a live connection, so entries never need evicting.
*/
private val padMaps = ConcurrentHashMap<String, PadMap>()
/**
* 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.
*
* `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, namedTriggers: Boolean): PadButtons {
val has = dev.hasKeys(KeyEvent.KEYCODE_BUTTON_C, KeyEvent.KEYCODE_BUTTON_Z, 0)
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
}
/**
* The [PadMap] for [dev] — its button report order and the axes its right stick and triggers
* arrive on, resolved once per device model and cached.
*
* Axes get the same treatment as buttons: a pad Android has a layout for names its triggers
* LTRIGGER/RTRIGGER (or BRAKE/GAS, or BRAKE/THROTTLE) and is left exactly as it was. A pad
* with NONE of those names is one Android never mapped, and its triggers are sitting on two
* raw axes under the names the HID report gave them. Which two depends on the same report
* order the buttons did:
*
* - a Sony pad reporting straight through lays out X, Y, Z, Rz, Rx, Ry = left stick, right
* stick, then the triggers — so the right stick is already right and only the triggers
* (`AXIS_RX`/`AXIS_RY`) are missed;
* - every other such pad puts the right stick on Rx/Ry and the triggers on Z/Rz, which is
* the shape that makes pulling a trigger swing the right stick.
*
* Whether those axes idle at 1 is MEASURED from the device's own range rather than assumed,
* so a pad that reports an honest 0..1 is not rescaled to a permanent half-pull.
*/
fun padMap(dev: InputDevice?): PadMap {
if (dev == null) return NATIVE_MAP
padMaps[dev.descriptor]?.let { return it }
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.
val restsNegative = if (buttons == PadButtons.GENERIC_SONY) {
(rx?.min ?: 0f) < -0.5f
} else {
(axis(dev, MotionEvent.AXIS_Z)?.min ?: 0f) < -0.5f
}
val map = padMap(buttons, namedTriggers = named, hasRxRy = hasRxRy, restsNegative = restsNegative)
padMaps[dev.descriptor] = map
return map
}
/**
* The axis half of [padMap], decided from four facts about the device so it can be pinned
* without one — see `PadButtonsTest`. [namedTriggers] is whether the pad calls its triggers
* anything Android knows (LTRIGGER/RTRIGGER, BRAKE/GAS, BRAKE/THROTTLE); if it does, nothing
* here applies and the pad is read exactly as it always was. [restsNegative] is measured off
* whichever axis pair the fallback picks, never assumed.
*/
fun padMap(
buttons: PadButtons,
namedTriggers: Boolean,
hasRxRy: Boolean,
restsNegative: Boolean,
): PadMap = when {
namedTriggers || !hasRxRy -> PadMap(buttons)
// X, Y, Z, Rz, Rx, Ry = left stick, right stick, triggers. The sticks already read right.
buttons == PadButtons.GENERIC_SONY -> PadMap(
buttons,
leftTrigger = MotionEvent.AXIS_RX,
rightTrigger = MotionEvent.AXIS_RY,
triggersSigned = restsNegative,
)
// Right stick on Rx/Ry and triggers on Z/Rz — the shape in which reading Z/Rz as the
// right stick makes pulling a trigger swing it.
else -> PadMap(
buttons,
rightStickX = MotionEvent.AXIS_RX,
rightStickY = MotionEvent.AXIS_RY,
leftTrigger = MotionEvent.AXIS_Z,
rightTrigger = MotionEvent.AXIS_RZ,
triggersSigned = restsNegative,
)
}
/** [dev]'s range for one joystick [axis], under either source class a pad reports on. */
private fun axis(dev: InputDevice, axis: Int): InputDevice.MotionRange? =
dev.getMotionRange(axis, InputDevice.SOURCE_JOYSTICK)
?: dev.getMotionRange(axis, InputDevice.SOURCE_GAMEPAD)
/**
* The keycode [event] should have carried, given the controller it came from — [event]'s own
* keycode for every pad Android has a key layout for, and the scancode's true button for one
* it does not (see the block comment above [PadButtons]).
*
* A drop-in for `event.keyCode` at every gamepad reader: the console UI's navigation, the
* Controllers screen's tester, and the streaming branch all route through it, so a mis-mapped
* pad is fixed in the menus and in the game at once. Events from anything that is not a
* controller, and events with no scancode (soft keyboards, synthetic events), pass through
* untouched.
*/
fun padKeyCode(event: KeyEvent): Int {
val dev = event.device ?: return event.keyCode
if (event.scanCode == 0 || !isPad(dev)) return event.keyCode
return padMap(dev).buttons.correct(event.scanCode, event.keyCode)
}
/**
* Maps one controller's joystick MotionEvents to axis (+ HAT→dpad) sends on wire pad index [pad],
* **on change only**. Holds the previous axis/hat state so an unchanged frame emits nothing. One
@@ -306,7 +683,12 @@ object Gamepad {
* node (DualSense/DS4 motion sensors), which reports every pad axis as 0. [onMotion] therefore
* folds the event straight in without re-qualifying it.
*/
class AxisMapper(private val handle: Long, private val pad: Int) {
class AxisMapper(
private val handle: Long,
private val pad: Int,
/** Which axes this controller's right stick and triggers arrive on — see [padMap]. */
private val map: PadMap = NATIVE_MAP,
) {
// Sentinel so the first real value (incl. 0) always sends once after attach (Linux parity).
private val last = IntArray(6) { Int.MIN_VALUE }
private var hatX = 0 // -1 / 0 / +1
@@ -317,30 +699,18 @@ object Gamepad {
// Sticks: Android floats 1..1, +y = down → ±32767, negate Y for the wire's +y = up.
sendAxis(AXIS_LS_X, stick(event.getAxisValue(MotionEvent.AXIS_X)))
sendAxis(AXIS_LS_Y, stick(-event.getAxisValue(MotionEvent.AXIS_Y)))
sendAxis(AXIS_RS_X, stick(event.getAxisValue(MotionEvent.AXIS_Z)))
sendAxis(AXIS_RS_Y, stick(-event.getAxisValue(MotionEvent.AXIS_RZ)))
sendAxis(AXIS_RS_X, stick(event.getAxisValue(map.rightStickX)))
sendAxis(AXIS_RS_Y, stick(-event.getAxisValue(map.rightStickY)))
// Triggers: pads report LTRIGGER/RTRIGGER or BRAKE/GAS (some mirror both) — merge
// with max, the same fold as the Controllers screen probe, so a pad that reports
// only one pair and a pad that reports both behave identically; 0..1 → 0..255.
sendAxis(
AXIS_LT,
trigger(
maxOf(
event.getAxisValue(MotionEvent.AXIS_LTRIGGER),
event.getAxisValue(MotionEvent.AXIS_BRAKE),
),
),
)
sendAxis(
AXIS_RT,
trigger(
maxOf(
event.getAxisValue(MotionEvent.AXIS_RTRIGGER),
event.getAxisValue(MotionEvent.AXIS_GAS),
),
),
)
// only one pair and a pad that reports both behave identically; 0..1 → 0..255. A pad
// reporting NONE of those names is one Android has no key layout for, and [map]
// carries the raw axes its triggers really landed on instead.
val lt = resolved(event, map.leftTrigger, MotionEvent.AXIS_LTRIGGER, MotionEvent.AXIS_BRAKE)
val rt = resolved(event, map.rightTrigger, MotionEvent.AXIS_RTRIGGER, MotionEvent.AXIS_GAS)
sendAxis(AXIS_LT, trigger(lt))
sendAxis(AXIS_RT, trigger(rt))
// HAT → dpad button transitions. Android BATCHES joystick ACTION_MOVEs, so a rapid d-pad
// tap (press+release inside one batch window) lives only in the historical samples — the
@@ -383,6 +753,17 @@ object Gamepad {
hatY = 0
}
/**
* One trigger's 0..1 value: [resolvedAxis] when this pad needed one resolved for it,
* else the max of the two names Android gives a trigger it does know.
*/
private fun resolved(event: MotionEvent, resolvedAxis: Int, named: Int, alias: Int): Float =
if (resolvedAxis == AXIS_NONE) {
maxOf(event.getAxisValue(named), event.getAxisValue(alias))
} else {
map.level(event.getAxisValue(resolvedAxis))
}
private fun sendAxis(id: Int, v: Int) {
if (last[id] == v) return
last[id] = v
@@ -605,7 +605,7 @@ class GamepadRouter(
// for the slot's life; the sensor path reads it on every sample.
val slot = Slot(
index,
Gamepad.AxisMapper(handle, index),
Gamepad.AxisMapper(handle, index, Gamepad.padMap(dev)),
NativeBridge.nativePadMotionReaches(handle, pref),
)
slots[dev.id] = slot
@@ -146,6 +146,14 @@ object NativeBridge {
name: String,
): String
/**
* The native client's recent log ring rendered as one text bundle, oldest first,
* prefixed by [header] (this app's identity line) the body for "Send logs to host"
* (`POST /api/v1/client-logs` over the same mTLS client the library fetch uses).
* Never empty; cheap (string copy, no I/O).
*/
external fun nativeRenderLogs(header: String): String
/**
* The machine token of the most recent failed [nativeConnect]/[nativePair], cleared on read
* (`""` when none) call right after a `0` handle / `""` fingerprint. A typed host rejection
@@ -290,6 +298,18 @@ object NativeBridge {
surfaceH: Int,
)
/**
* Re-report the video SurfaceView's on-screen pixel size call it from every `surfaceChanged`.
*
* The ASurfaceControl present backend composites the picture into exactly this rectangle, and
* the view grows AFTER [nativeStartVideo] has run: the stream screen hides the system bars and
* switches the window to draw into the display cutout a frame or two later, and neither
* recreates the surface. Without this the layer keeps painting at its start-up size in the
* corner of a now-bigger surface. Non-positive values are ignored. No-op on a `0` handle;
* cheap (one atomic store), UI-safe.
*/
external fun nativeVideoSurfaceSize(handle: Long, width: Int, height: Int)
/** Stop + join the decode thread without closing the session. No-op on `0`. */
external fun nativeStopVideo(handle: Long)
@@ -0,0 +1,254 @@
package io.unom.punktfunk.kit
import android.view.KeyEvent
import android.view.MotionEvent
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* Pure JVM test of [Gamepad.PadButtons.correct] the scancode resolution for controllers Android
* has no key layout for. Only `KeyEvent`'s compile-time-inlined keycode constants are involved, so
* no Android runtime is needed. Run: `./gradlew :kit:testDebugUnitTest`.
*
* The regression it pins is a field report from a Fire TV Stick 4K Max (2026-08-20): a DualSense
* and an Xbox Elite Series 2, both over Bluetooth, both identified correctly but with buttons
* landing on the wrong actions "L1 being L2". Neither pad has a key layout on that box (AOSP
* ships none for `045e:0b05` at all, and the DualSense's requires `CONFIG_HID_PLAYSTATION`), so
* both fall back to `Generic.kl`, which names keycodes by scancode POSITION. A pad with no kernel
* driver numbers its HID buttons 1..n straight through in its own report order, so every keycode
* after the first divergence belongs to a different button.
*
* The table below is the pad's physical button on the left and where `Generic.kl` put it on the
* right; the assertions read it back the other way.
*/
class PadButtonsTest {
private fun sony(scan: Int) =
Gamepad.PadButtons.GENERIC_SONY.correct(scan, Gamepad.genericKeyCode(scan))
private fun xbox(scan: Int) =
Gamepad.PadButtons.GENERIC_XBOX.correct(scan, Gamepad.genericKeyCode(scan))
/**
* The exact report: a DualSense's L2 sits at scancode `0x136`, which `Generic.kl` calls
* BUTTON_L1 so pulling L2 read as a shoulder press, and L1 (at `0x134`, read as BUTTON_Y)
* read as a face button.
*/
@Test
fun `a DualSense's shoulders stop being each other's buttons`() {
assertEquals(KeyEvent.KEYCODE_BUTTON_L1, sony(0x134)) // L1, delivered as BUTTON_Y
assertEquals(KeyEvent.KEYCODE_BUTTON_R1, sony(0x135)) // R1, delivered as BUTTON_Z
assertEquals(KeyEvent.KEYCODE_BUTTON_L2, sony(0x136)) // L2, delivered as BUTTON_L1
assertEquals(KeyEvent.KEYCODE_BUTTON_R2, sony(0x137)) // R2, delivered as BUTTON_R1
}
/** ✕ is the bottom button — the one A means everywhere else — and □ is the left one. */
@Test
fun `a DualSense's face buttons land on their Xbox positions`() {
assertEquals(KeyEvent.KEYCODE_BUTTON_X, sony(0x130)) // □
assertEquals(KeyEvent.KEYCODE_BUTTON_A, sony(0x131)) // ✕
assertEquals(KeyEvent.KEYCODE_BUTTON_B, sony(0x132)) // ○
assertEquals(KeyEvent.KEYCODE_BUTTON_Y, sony(0x133)) // △
}
/**
* Create/Options/L3/R3/PS. Select in particular: without this it arrived as BUTTON_THUMBL,
* which took the exit, mic and stats chords with it every one of them is built on Select.
*/
@Test
fun `a DualSense's menu buttons and stick clicks are themselves`() {
assertEquals(KeyEvent.KEYCODE_BUTTON_SELECT, sony(0x138)) // Create
assertEquals(KeyEvent.KEYCODE_BUTTON_START, sony(0x139)) // Options
assertEquals(KeyEvent.KEYCODE_BUTTON_THUMBL, sony(0x13a)) // L3
assertEquals(KeyEvent.KEYCODE_BUTTON_THUMBR, sony(0x13b)) // R3
assertEquals(KeyEvent.KEYCODE_BUTTON_MODE, sony(0x13c)) // PS
}
/** The touchpad click and mute have no wire button; they must resolve to nothing, not to R3. */
@Test
fun `a DualSense's touchpad and mute are dropped rather than mistaken`() {
assertEquals(KeyEvent.KEYCODE_UNKNOWN, sony(0x13d))
assertEquals(KeyEvent.KEYCODE_UNKNOWN, sony(0x13e))
assertEquals(0, Gamepad.buttonBit(sony(0x13d)))
}
/** An Xbox-layout pad numbering straight through: A B X Y LB RB View Menu LS RS. */
@Test
fun `an Xbox pad numbering straight through keeps its own layout`() {
assertEquals(KeyEvent.KEYCODE_BUTTON_A, xbox(0x130))
assertEquals(KeyEvent.KEYCODE_BUTTON_B, xbox(0x131))
assertEquals(KeyEvent.KEYCODE_BUTTON_X, xbox(0x132))
assertEquals(KeyEvent.KEYCODE_BUTTON_Y, xbox(0x133))
assertEquals(KeyEvent.KEYCODE_BUTTON_L1, xbox(0x134))
assertEquals(KeyEvent.KEYCODE_BUTTON_R1, xbox(0x135))
assertEquals(KeyEvent.KEYCODE_BUTTON_SELECT, xbox(0x136)) // View
assertEquals(KeyEvent.KEYCODE_BUTTON_START, xbox(0x137)) // Menu
assertEquals(KeyEvent.KEYCODE_BUTTON_THUMBL, xbox(0x138))
assertEquals(KeyEvent.KEYCODE_BUTTON_THUMBR, xbox(0x139))
}
/** `hid-playstation` emits the modern Linux codes, where only the face pair reads swapped. */
@Test
fun `a driver-backed Sony pad has only its face pair corrected`() {
val m = Gamepad.PadButtons.SONY_MODERN
assertEquals(KeyEvent.KEYCODE_BUTTON_Y, m.correct(0x133, KeyEvent.KEYCODE_BUTTON_X)) // △
assertEquals(KeyEvent.KEYCODE_BUTTON_X, m.correct(0x134, KeyEvent.KEYCODE_BUTTON_Y)) // □
for (scan in listOf(0x130, 0x131, 0x136, 0x137, 0x13a, 0x13b, 0x13c)) {
assertEquals(Gamepad.genericKeyCode(scan), m.correct(scan, Gamepad.genericKeyCode(scan)))
}
}
/**
* The guard that makes all of this safe to run on every pad: a keycode that is NOT what
* `Generic.kl` would have said came from a device-specific key layout, which knows this
* controller better than any table here. Correcting it would break a pad that works.
*/
@Test
fun `a keycode a device layout already resolved is never second-guessed`() {
// AOSP's DualSense layout puts △ on BUTTON_Y itself. Every profile must leave it be.
for (p in Gamepad.PadButtons.entries) {
assertEquals(KeyEvent.KEYCODE_BUTTON_Y, p.correct(0x133, KeyEvent.KEYCODE_BUTTON_Y))
}
// Same for a scancode outside the generic gamepad block entirely — a pad's Back key.
assertEquals(
KeyEvent.KEYCODE_BACK,
Gamepad.PadButtons.GENERIC_SONY.correct(158, KeyEvent.KEYCODE_BACK),
)
}
/** Correcting twice is correcting once — the output is never itself a generic-layout answer. */
@Test
fun `correction is idempotent`() {
for (p in Gamepad.PadButtons.entries) {
for (scan in 0x130..0x13e) {
val once = p.correct(scan, Gamepad.genericKeyCode(scan))
assertEquals(once, p.correct(scan, once))
}
}
}
/**
* The axis half. A pad that names its triggers something Android knows is read exactly as it
* always was this is the branch that must NOT fire on the pads that already work.
*/
@Test
fun `a pad that names its triggers is read unchanged`() {
for (p in Gamepad.PadButtons.entries) {
val map = Gamepad.padMap(p, namedTriggers = true, hasRxRy = true, restsNegative = true)
assertEquals(MotionEvent.AXIS_Z, map.rightStickX)
assertEquals(MotionEvent.AXIS_RZ, map.rightStickY)
assertEquals(Gamepad.AXIS_NONE, map.leftTrigger)
assertEquals(Gamepad.AXIS_NONE, map.rightTrigger)
}
// Same when there is no Rx/Ry to fall back to in the first place.
val none = Gamepad.padMap(Gamepad.PadButtons.GENERIC_SONY, false, hasRxRy = false, restsNegative = false)
assertEquals(Gamepad.AXIS_NONE, none.leftTrigger)
}
/**
* A Sony pad reporting straight through lays out X, Y, Z, Rz, Rx, Ry left stick, right
* stick, then the triggers. Only the triggers were being missed; the sticks already read
* right and must be left alone.
*/
@Test
fun `an unmapped Sony pad keeps its sticks and gains its triggers`() {
val map = Gamepad.padMap(Gamepad.PadButtons.GENERIC_SONY, false, hasRxRy = true, restsNegative = false)
assertEquals(MotionEvent.AXIS_Z, map.rightStickX)
assertEquals(MotionEvent.AXIS_RZ, map.rightStickY)
assertEquals(MotionEvent.AXIS_RX, map.leftTrigger)
assertEquals(MotionEvent.AXIS_RY, map.rightTrigger)
}
/**
* Every other unmapped pad is the opposite way round: right stick on Rx/Ry, triggers on Z/Rz.
* Reading Z/Rz as the right stick there is what makes pulling a trigger swing it so the two
* pairs must never be mixed up, which is the whole point of pinning them.
*/
@Test
fun `an unmapped Xbox-layout pad has its stick and triggers the other way round`() {
for (p in listOf(Gamepad.PadButtons.GENERIC_XBOX, Gamepad.PadButtons.SONY_MODERN)) {
val map = Gamepad.padMap(p, namedTriggers = false, hasRxRy = true, restsNegative = false)
assertEquals(MotionEvent.AXIS_RX, map.rightStickX)
assertEquals(MotionEvent.AXIS_RY, map.rightStickY)
assertEquals(MotionEvent.AXIS_Z, map.leftTrigger)
assertEquals(MotionEvent.AXIS_RZ, map.rightTrigger)
}
}
/**
* A trigger axis that idles at 1 is rescaled; one that idles at 0 must NOT be, or it would
* read as a permanent half-pull. Which it is gets measured off the device, never assumed
* both the DualSense's raw RX/RY and the Xbox pad's Z/Rz report an honest 0..1.
*/
@Test
fun `only a trigger that idles negative is rescaled`() {
val signed = Gamepad.padMap(Gamepad.PadButtons.GENERIC_SONY, false, hasRxRy = true, restsNegative = true)
assertEquals(0f, signed.level(-1f), 1e-6f)
assertEquals(0.5f, signed.level(0f), 1e-6f)
assertEquals(1f, signed.level(1f), 1e-6f)
val unsigned = Gamepad.padMap(Gamepad.PadButtons.GENERIC_SONY, false, hasRxRy = true, restsNegative = false)
assertEquals(0f, unsigned.level(0f), 1e-6f)
assertEquals(1f, unsigned.level(1f), 1e-6f)
}
/** A pad Android does know is untouched, which is most of them. */
@Test
fun `a pad with a key layout is left alone`() {
for (scan in 0x130..0x13e) {
val generic = Gamepad.genericKeyCode(scan)
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))
}
}
@@ -0,0 +1,67 @@
package io.unom.punktfunk.kit
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The truth table behind "is a controller attached" the question the console UI's
* "With a controller" mode is answered by. A false positive here is not cosmetic: it pins the
* console UI on with no pad in the room, and no setting short of turning the whole thing off can
* dismiss it, because the phantom pad never disconnects.
*/
class PadPresenceTest {
/** A real pad: the source class plus hardware behind it, in either of the two shapes. */
@Test
fun realPadsCount() {
assertTrue(
Gamepad.looksLikeController(
padSource = true, virtual = false, hasStick = true, hasFaceButtons = true,
),
)
// An arcade stick / d-pad-only pad — buttons, no analog stick.
assertTrue(
Gamepad.looksLikeController(
padSource = true, virtual = false, hasStick = false, hasFaceButtons = true,
),
)
// A wheel or flight stick — axes, no A/B.
assertTrue(
Gamepad.looksLikeController(
padSource = true, virtual = false, hasStick = true, hasFaceButtons = false,
),
)
}
/** The gaming-phone shoulder triggers and OEM game-mode overlays: a virtual device wearing the
* gamepad source class. This is the field report the console UI that could not be dismissed. */
@Test
fun virtualDevicesAreNotControllers() {
assertFalse(
Gamepad.looksLikeController(
padSource = true, virtual = true, hasStick = true, hasFaceButtons = true,
),
)
}
/** A device that claims a pad source with nothing behind it is not a pad either. */
@Test
fun aSourceClaimWithoutHardwareIsNotAController() {
assertFalse(
Gamepad.looksLikeController(
padSource = true, virtual = false, hasStick = false, hasFaceButtons = false,
),
)
}
/** And a keyboard/mouse with sticks it never reports on the joystick source stays out. */
@Test
fun nonPadSourcesNeverCount() {
assertFalse(
Gamepad.looksLikeController(
padSource = false, virtual = false, hasStick = true, hasFaceButtons = true,
),
)
}
}
+24 -1
View File
@@ -249,6 +249,13 @@ impl ConsoleHost {
}
}
/// No input for this long = the console is being looked at, not used — halve the redraw
/// rate (`IDLE_FRAME_STEP` slept between swaps). 60 s keeps every interaction and its
/// afterglow at full smoothness and only calms a genuinely parked screen.
const IDLE_AFTER: Duration = Duration::from_secs(60);
/// One extra ~vsync period per frame while idle: 60 Hz → ~30, 120 Hz → ~40.
const IDLE_FRAME_STEP: Duration = Duration::from_millis(16);
/// The render thread. Owns EGL + Skia + the console; runs until `Cmd::Quit`.
fn render_loop(mut console: Console, shared: Arc<Shared>, store: Arc<SnapshotStore>) -> Result<()> {
let egl = EglContext::new()?;
@@ -267,6 +274,8 @@ fn render_loop(mut console: Console, shared: Arc<Shared>, store: Arc<SnapshotSto
let mut was_editing = console.editing();
let mut saved_gen = store.saved_gen();
let mut menu_out: Vec<MenuEvent> = Vec::new();
// When the last input arrived — the idle throttle's clock (see the draw site below).
let mut last_input = Instant::now();
// Consecutive GL setup failures (window surface / Skia wrap). One is a transient (a window
// torn down mid-create); a run of them is a context that is not coming back — most likely
// reclaimed by Android while the app was backgrounded. Only exiting reports that: each
@@ -304,21 +313,28 @@ fn render_loop(mut console: Console, shared: Arc<Shared>, store: Arc<SnapshotSto
return Ok(());
}
Cmd::Menu(ev) => {
last_input = Instant::now();
if let Some(p) = console.menu(ev) {
shared.emit(HostEvent::Pulse(p));
}
}
Cmd::PadSample(s) => {
last_input = Instant::now();
sample = s;
poll_now = true;
}
Cmd::Pointer(p) => {
last_input = Instant::now();
console.pointer(p);
}
Cmd::Key { key, shift, repeat } => {
last_input = Instant::now();
console.key(key, shift, repeat);
}
Cmd::Text(t) => console.text(&t),
Cmd::Text(t) => {
last_input = Instant::now();
console.text(&t);
}
Cmd::Phase(ph) => {
match &ph {
Phase::Connecting => console.session_phase(SessionPhase::Connecting),
@@ -413,6 +429,13 @@ fn render_loop(mut console: Console, shared: Arc<Shared>, store: Arc<SnapshotSto
}
// Draw, if there is somewhere to draw.
// ponytail: half-rate after 60 s without input — one extra frame period between
// swaps, so an idle carousel stops redrawing a phone's panel at its full rate
// (the aurora still breathes, at half tempo). Any input restores full rate on
// its own frame; damage-driven rendering if a TV box ever needs more.
if last_input.elapsed() >= IDLE_AFTER {
std::thread::sleep(IDLE_FRAME_STEP);
}
if let (Some(s), Some(g)) = (surface.as_mut(), gpu.as_mut()) {
let (w, h) = (s.width, s.height);
let need_wrap = match &skia {
+16 -2
View File
@@ -45,6 +45,10 @@ struct CreateOptions {
device_name: String,
/// Skia's resource budget, bytes (Kotlin sizes it from `ActivityManager.memoryClass`).
gpu_cache_bytes: usize,
/// Whether the touch shell exists as a fallback (phones/tablets; false on a TV) —
/// gates the console-off settings row. Default false: absent means don't offer it.
#[serde(default)]
fallback_ui: bool,
/// The settings snapshot the shell starts from (`pf_client_core::trust::Settings` JSON).
settings: pf_client_core::trust::Settings,
/// The profile catalog as `[[id, name], …]`.
@@ -150,6 +154,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConsoleCrea
let console_opts = ConsoleOptions {
device_name: opts.device_name,
deck: false,
fallback_ui: opts.fallback_ui,
store: Some(store.clone()),
platform: Platform::Android,
gpu_cache_bytes: opts.gpu_cache_bytes.max(16 << 20),
@@ -323,8 +328,9 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConsoleMenu
}
/// `NativeBridge.nativeConsolePointer(handle, kind, x, y, dy)` — touch/mouse in surface pixels:
/// kind 0 move, 1 primary down, 2 primary up, 3 secondary down (= Back), 4 wheel (`dy` steps,
/// + = up), 5 cancel.
/// kind 0 move, 1 primary down (a mouse — acts immediately), 2 primary up, 3 secondary down
/// (= Back), 4 wheel (`dy` steps, + = up), 5 cancel, 6 primary down from a finger/stylus on
/// the glass — the shell defers it so a swipe scrolls instead of acting on contact.
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConsolePointer(
_env: EnvUnowned,
@@ -341,6 +347,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConsolePoin
x,
y,
button: PointerButton::Primary,
touch: false,
},
2 => PointerInput::Up {
x,
@@ -351,9 +358,16 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConsolePoin
x,
y,
button: PointerButton::Secondary,
touch: false,
},
4 => PointerInput::Wheel { x, y, dy },
5 => PointerInput::Cancel,
6 => PointerInput::Down {
x,
y,
button: PointerButton::Primary,
touch: true,
},
_ => return,
};
if let Some(h) = host(handle) {
@@ -142,21 +142,21 @@ pub(super) struct AscBackend {
impl AscBackend {
/// Create the reader + compositor layer, or `None` on API < 29 / init failure (the caller then
/// runs the SurfaceView presenter). `window` is the SurfaceView's `ANativeWindow`; `src_w/h` the
/// negotiated decode size; `panel_hz` the mode-table panel rate (seeds the learner);
/// negotiated decode size; `surface_size` the LIVE view size the layer composites into;
/// `panel_hz` the mode-table panel rate (seeds the learner);
/// `dataspace` the `ADataSpace` from the negotiated colour; `source_hz` the negotiated stream rate.
#[allow(clippy::too_many_arguments)]
pub(super) fn create(
window: &NativeWindow,
src_w: i32,
src_h: i32,
surface_w: i32,
surface_h: i32,
surface_size: std::sync::Arc<std::sync::atomic::AtomicU64>,
panel_hz: i32,
dataspace: i32,
source_hz: u32,
priority: PresentPriority,
) -> Option<AscBackend> {
let layer = Layer::create(window, surface_w, surface_h)?;
let layer = Layer::create(window, surface_size)?;
let usage = ndk::hardware_buffer::HardwareBufferUsage::GPU_SAMPLED_IMAGE
| ndk::hardware_buffer::HardwareBufferUsage::COMPOSER_OVERLAY;
let reader = match ImageReader::new_with_usage(
@@ -96,8 +96,7 @@ pub(super) fn run_async(
present_priority,
smooth_buffer,
panel_hz,
surface_w,
surface_h,
surface_size,
} = opts;
boost_thread_priority();
let mode = client.mode();
@@ -199,8 +198,7 @@ pub(super) fn run_async(
&window,
mode.width as i32,
mode.height as i32,
surface_w,
surface_h,
surface_size,
panel_hz,
initial_ds,
mode.refresh_hz,
+14 -7
View File
@@ -91,7 +91,14 @@ const NO_VIDEO_PATIENCE: std::time::Duration = std::time::Duration::from_millis(
/// Re-ask cadence once [`NO_VIDEO_PATIENCE`] has elapsed with still nothing received. Slow, because
/// this state is either self-healing on the first ask or not ours to heal — and each pass logs.
const NO_VIDEO_RETRY: std::time::Duration = std::time::Duration::from_millis(2000);
///
/// ⚠ Taken from core, NOT a local number. `FLUSH_COOLDOWN` (the jump-to-live rate limit) is 2000 ms,
/// and the host classifies a keyframe-recovery cadence by matching a cooldown's period ±10 % to
/// decide WHICH client failure it is looking at. The two are opposites — "I have received nothing"
/// versus "I am drowning in frames I cannot drain" — so while this was also 2000 ms the host
/// confidently reported the wrong one, and a black-screen field case was diagnosed as a slow decoder
/// for days (2026-08-20). Keeping the value in core is what stops the two drifting back together.
const NO_VIDEO_RETRY: std::time::Duration = punktfunk_core::client::NO_VIDEO_RETRY;
/// Whether low-latency mode uses the event-driven async decode loop (default) or the synchronous
/// poll loop. Flip to `false` to A/B the two on the HUD (`design/…`); the async loop presents a
@@ -133,12 +140,12 @@ pub(crate) struct DecodeOptions {
/// named here is not necessarily the one the panel ends up in. The measured timeline spacing
/// corrects it in both directions ([`punktfunk_core::phase::PanelGrid`]).
pub panel_hz: i32,
/// The video `SurfaceView`'s on-screen pixel size (the aspect-fitted display footprint), from
/// Kotlin at `surfaceCreated`. The ASurfaceControl backend composites its layer in this
/// coordinate space — NOT the window's buffer geometry, which is rotated/scaled. `0` = Kotlin
/// couldn't read it yet, and the backend falls back to the window buffer size.
pub surface_w: i32,
pub surface_h: i32,
/// The video `SurfaceView`'s LIVE on-screen pixel size (the aspect-fitted display footprint),
/// packed by [`crate::session::pack_surface_size`] and re-reported by Kotlin on every
/// `surfaceChanged`. The ASurfaceControl backend composites its layer in this coordinate space
/// — NOT the window's buffer geometry, which is rotated/scaled. `0` = Kotlin couldn't read it
/// yet, and the backend falls back to the window buffer size.
pub surface_size: std::sync::Arc<std::sync::atomic::AtomicU64>,
}
/// The decode entry point on the `pf-decode` thread: dispatches to the async or synchronous loop.
@@ -24,6 +24,7 @@ use ndk::hardware_buffer::HardwareBuffer;
use ndk::native_window::NativeWindow;
use std::ffi::c_void;
use std::os::fd::{FromRawFd, OwnedFd, RawFd};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{mpsc, Arc};
use super::async_loop::DecodeEvent;
@@ -276,9 +277,14 @@ unsafe extern "C" fn on_complete(context: *mut c_void, stats: *mut ASurfaceTrans
pub(super) struct Layer {
api: Api,
sc: Arc<ScHandle>,
/// Destination rectangle (the SurfaceView's pixel size) — the buffer is scaled to fill it.
dest_w: i32,
dest_h: i32,
/// The SurfaceView's LIVE pixel size, packed by `pack_surface_size` and re-read before every
/// present — the destination rectangle the buffer is scaled to fill. Live rather than captured
/// because the view resizes under a surface that is never recreated (see `dest`).
surface_size: Arc<AtomicU64>,
/// Fallback destination for as long as `surface_size` is still `0` (Kotlin hadn't measured the
/// view when video started): the window's own buffer geometry, the best remaining guess.
fallback_w: i32,
fallback_h: i32,
/// `true` once the first transaction has made the layer visible + set its z-order + frame rate.
configured: bool,
}
@@ -287,13 +293,16 @@ impl Layer {
/// Create the compositor layer over `window` (the SurfaceView's `ANativeWindow`), or `None` on
/// API < 29 / a null layer — the caller then uses the SurfaceView presenter.
///
/// `dest_w/h` are the SurfaceView's **on-screen pixel size** — the coordinate space the child
/// layer is composited into, which is the display footprint of the (aspect-fitted) video view,
/// NOT the window's buffer size. `ANativeWindow_getWidth/Height` return the buffer geometry in a
/// rotated/scaled space (observed 1260×567 for a 2800×1260 full-bleed stream) — using it shrank
/// the picture to the top-left corner. A non-positive `dest_w/h` (Kotlin couldn't read the view
/// yet) falls back to that buffer size as the best remaining guess.
pub(super) fn create(window: &NativeWindow, dest_w: i32, dest_h: i32) -> Option<Layer> {
/// `surface_size` carries the SurfaceView's **on-screen pixel size** — the coordinate space the
/// child layer is composited into, which is the display footprint of the (aspect-fitted) video
/// view, NOT the window's buffer size. `ANativeWindow_getWidth/Height` return the buffer
/// geometry in a rotated/scaled space (observed 1260×567 for a 2800×1260 full-bleed stream) —
/// using it shrank the picture to the top-left corner. It is read fresh on every present
/// because that view RESIZES mid-stream under a surface that is never recreated: the stream
/// screen hides the system bars and switches on cutout drawing a frame or two after
/// `surfaceCreated`, and each one grows it. An empty `surface_size` (Kotlin hadn't measured the
/// view yet) falls back to the buffer size as the best remaining guess.
pub(super) fn create(window: &NativeWindow, surface_size: Arc<AtomicU64>) -> Option<Layer> {
let api = Api::resolve()?;
// SAFETY: `window.ptr()` is the live `ANativeWindow` the decode thread owns; the name is a
// static NUL-terminated string; the call returns null on failure (checked).
@@ -303,20 +312,11 @@ impl Layer {
log::warn!("asc: createFromWindow returned null — falling back to SurfaceView");
return None;
}
let dest_w = if dest_w > 0 {
dest_w
} else {
window.width().max(1)
};
let dest_h = if dest_h > 0 {
dest_h
} else {
window.height().max(1)
};
let fallback_w = window.width().max(1);
let fallback_h = window.height().max(1);
log::info!(
"asc: layer created, dest {dest_w}x{dest_h} (window buffer {}x{})",
window.width(),
window.height(),
"asc: layer created, dest {:?} (window buffer {fallback_w}x{fallback_h})",
crate::session::unpack_surface_size(surface_size.load(Ordering::Relaxed)),
);
Some(Layer {
sc: Arc::new(ScHandle {
@@ -324,12 +324,20 @@ impl Layer {
release: api.ac_release,
}),
api,
dest_w,
dest_h,
surface_size,
fallback_w,
fallback_h,
configured: false,
})
}
/// The destination rectangle for this present: the live view size, or the window's buffer
/// geometry while Kotlin has reported nothing.
fn dest(&self) -> (i32, i32) {
crate::session::unpack_surface_size(self.surface_size.load(Ordering::Relaxed))
.unwrap_or((self.fallback_w, self.fallback_h))
}
/// Present one decoded buffer at `desired_present_ns` (`CLOCK_MONOTONIC`; `0` = ASAP). Consumes
/// `acquire_fence` (ownership passes to SurfaceFlinger via `setBuffer`). Registers a one-shot
/// completion that reports the real latch + the previous buffer's release fence on `ev_tx`,
@@ -370,11 +378,12 @@ impl Layer {
right: src_w.max(1),
bottom: src_h.max(1),
};
let (dest_w, dest_h) = self.dest();
let dst = ARect {
left: 0,
top: 0,
right: self.dest_w,
bottom: self.dest_h,
right: dest_w,
bottom: dest_h,
};
(self.api.txn_set_geometry)(txn, sc, &src, &dst, TRANSFORM_IDENTITY);
if dataspace != 0 {
@@ -50,8 +50,7 @@ pub(super) fn run_sync(
panel_hz: _,
// The ASurfaceControl backend is async-loop only; the sync loop renders straight to the
// SurfaceView, so it never needs the view's on-screen size.
surface_w: _,
surface_h: _,
surface_size: _,
} = opts;
boost_thread_priority();
let mode = client.mode();
+45 -6
View File
@@ -34,6 +34,9 @@ mod audio;
// shell over EGL/GLES, on every ABI (the armv7 Skia archive is self-hosted — see Cargo.toml).
#[cfg(target_os = "android")]
mod console;
// "Send logs to host": the log-ring upload (`pf-client-core` is Android-target-only here).
#[cfg(target_os = "android")]
mod logs;
// The RESOLVED audio format + its ms ⇄ sample arithmetic, split out of `audio` and — unlike it —
// ungated, because that arithmetic is what a rate the ladder does not divide gets wrong (44 100 Hz
// used to come out 2.3 % off in every direction at once) and it must be provable without a phone.
@@ -60,22 +63,58 @@ mod wol;
// it off the main thread to light saved-host "online" pips independently of mDNS.
mod probe;
/// Initialize `android_logger` once when the JVM loads the library. Logs land in logcat under the
/// `punktfunk` tag. Core `tracing` events (transport warnings: socket-buffer clamp, QoS failures)
/// arrive here too: tracing's "log" feature — declared explicitly in Cargo.toml rather than relied
/// on via quinn's defaults — forwards them as `log` records since no tracing subscriber is ever
/// installed. Android-only — there is no JVM (and no logcat) on the host build.
/// Every `log` record, teed: to logcat (via [`android_logger::AndroidLogger`]) AND into
/// `pf_client_core::logring` — the source for the console's "Send logs to host" action
/// ([`logs`]). The ring line mirrors the desktop `ring_layer`'s shape (wallclock, level,
/// target, message) so a bundle reads the same on the host's Logs page whichever client
/// sent it. Both sinks share the crate's Info ceiling — the field ring gets exactly what
/// logcat gets, which also keeps per-frame DEBUG chatter out of it by construction.
#[cfg(target_os = "android")]
struct RingTee(android_logger::AndroidLogger);
#[cfg(target_os = "android")]
impl log::Log for RingTee {
fn enabled(&self, metadata: &log::Metadata) -> bool {
self.0.enabled(metadata)
}
fn log(&self, record: &log::Record) {
self.0.log(record);
pf_client_core::logring::note(format!(
"{} {:5} {} {}",
pf_client_core::logring::wallclock(),
record.level().as_str(),
record.target(),
record.args()
));
}
fn flush(&self) {
self.0.flush();
}
}
/// Initialize logging once when the JVM loads the library: logcat under the `punktfunk` tag,
/// teed into the client log ring (see [`RingTee`]). Core `tracing` events (transport warnings:
/// socket-buffer clamp, QoS failures) arrive here too: tracing's "log" feature — declared
/// explicitly in Cargo.toml rather than relied on via quinn's defaults — forwards them as
/// `log` records since no tracing subscriber is ever installed. Android-only — there is no
/// JVM (and no logcat) on the host build.
#[cfg(target_os = "android")]
#[unsafe(no_mangle)]
pub extern "system" fn JNI_OnLoad(
_vm: *mut jni::sys::JavaVM,
_reserved: *mut std::ffi::c_void,
) -> jint {
android_logger::init_once(
let logcat = android_logger::AndroidLogger::new(
android_logger::Config::default()
.with_max_level(log::LevelFilter::Info)
.with_tag("punktfunk"),
);
// `set_boxed_logger` (unlike `init_once`) does not set the max level itself.
if log::set_boxed_logger(Box::new(RingTee(logcat))).is_ok() {
log::set_max_level(log::LevelFilter::Info);
}
log::info!(
"punktfunk_android loaded (core ABI v{})",
punktfunk_core::ABI_VERSION
+27
View File
@@ -0,0 +1,27 @@
//! JNI seam for "Send logs to host": hand Kotlin the client's recent log ring (fed by the
//! [`crate::RingTee`] logcat tee) rendered as one text bundle. The UPLOAD stays on the
//! Kotlin side — its mTLS OkHttp client (`mtlsHttpClient`, the library/art path) already
//! owns HTTPS-to-the-pinned-host on this platform, and `logring::send_to_host`'s ureq
//! agent is deliberately desktop-only. Android-gated (unlike [`crate::wol`]/[`crate::probe`])
//! because `pf-client-core` is an Android-target dependency of this crate.
use jni::errors::LogErrorAndDefault;
use jni::objects::{JObject, JString};
use jni::EnvUnowned;
/// `NativeBridge.nativeRenderLogs(header): String` — the ring as one text bundle, oldest
/// first, prefixed by `header` (the Kotlin side's identity line) and an eviction note when
/// the ring wrapped. Never empty (the header line is always present); cheap enough for any
/// thread, though the caller is about to do network anyway.
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeRenderLogs<'local>(
mut env: EnvUnowned<'local>,
_this: JObject<'local>,
header: JString<'local>,
) -> JString<'local> {
env.with_env(|env| {
let header: String = header.try_to_string(env)?;
env.new_string(pf_client_core::logring::render(&header))
})
.resolve::<LogErrorAndDefault>()
}
@@ -470,6 +470,8 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
// A fresh session is never muted (mute is per-session UI state, not a setting).
mic_muted: Arc::new(std::sync::atomic::AtomicBool::new(false)),
access_seq: std::sync::atomic::AtomicU32::new(0),
// Reported by Kotlin at `surfaceCreated` and on every resize after it.
surface_size: Arc::new(std::sync::atomic::AtomicU64::new(0)),
};
Box::into_raw(Box::new(handle)) as jlong
}
+58 -1
View File
@@ -26,7 +26,7 @@ mod probe;
use punktfunk_core::client::NativeClient;
use std::panic::AssertUnwindSafe;
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::thread::JoinHandle;
@@ -87,6 +87,37 @@ pub(crate) struct SessionHandle {
/// `nativeAccessState` poll ([`access`]) — how the Kotlin poller tells a fresh update
/// (the host's expiry warnings) arrived without holding a blocking event thread.
pub(crate) access_seq: AtomicU32,
/// The video `SurfaceView`'s LIVE on-screen pixel size ([`pack_surface_size`]), written by
/// `nativeStartVideo` and by every `nativeVideoSurfaceSize` the `surfaceChanged` callback
/// sends, read by the ASurfaceControl presenter before each present.
///
/// Shared and live rather than a start-time parameter because the view RESIZES under a surface
/// that is never recreated: hiding the system bars and switching the window to
/// `LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS` both happen a frame or two AFTER `surfaceCreated`,
/// and each one grows the video view. A destination rect captured once at creation then keeps
/// compositing the picture at its old, smaller size anchored at the layer's origin — the
/// "stream in the top-left corner" field report. `0` = nothing reported yet, and the layer
/// falls back to the window's buffer geometry.
pub surface_size: Arc<AtomicU64>,
}
/// Pack a surface's pixel size into one `u64` — so the presenter reads width and height as a
/// single atomic load and can never see a torn pair (a new width against an old height).
/// Non-positive values pack as `0`, the "not reported yet" sentinel.
pub(crate) fn pack_surface_size(w: i32, h: i32) -> u64 {
if w <= 0 || h <= 0 {
return 0;
}
((w as u64) << 32) | (h as u64 & 0xffff_ffff)
}
/// The inverse of [`pack_surface_size`]: `None` for the `0` sentinel.
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
pub(crate) fn unpack_surface_size(packed: u64) -> Option<(i32, i32)> {
if packed == 0 {
return None;
}
Some((((packed >> 32) as u32) as i32, (packed as u32) as i32))
}
struct VideoThread {
@@ -160,3 +191,29 @@ fn parse_hex32(s: &str) -> Option<[u8; 32]> {
}
Some(out)
}
#[cfg(test)]
mod tests {
use super::{pack_surface_size, unpack_surface_size};
/// The pair the presenter reads as one atomic load must survive the round trip — including a
/// size wider than a signed 16-bit value, which every panel this runs on now is.
#[test]
fn surface_size_round_trips() {
assert_eq!(
unpack_surface_size(pack_surface_size(2800, 1260)),
Some((2800, 1260))
);
assert_eq!(unpack_surface_size(pack_surface_size(1, 1)), Some((1, 1)));
}
/// "Not reported yet" — and anything nonsensical — is the one sentinel, so the layer falls back
/// to the window's buffer geometry rather than composing into an empty rectangle.
#[test]
fn non_positive_sizes_are_the_sentinel() {
assert_eq!(pack_surface_size(0, 0), 0);
assert_eq!(pack_surface_size(1920, 0), 0);
assert_eq!(pack_surface_size(-1, 1080), 0);
assert_eq!(unpack_surface_size(0), None);
}
}
+39 -2
View File
@@ -72,6 +72,13 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo(
let client = h.client.clone();
let sd = shutdown.clone();
let st = h.stats.clone(); // session-lifetime stats (gate survives surface recreate)
// Seed the live view size with what the view measures right now; `surfaceChanged` keeps it
// current from here on (the bars hide and the cutout mode changes AFTER this call).
h.surface_size.store(
super::pack_surface_size(surface_w, surface_h),
std::sync::atomic::Ordering::Relaxed,
);
let opts = crate::decode::DecodeOptions {
decoder_name: decoder,
ll_feature,
@@ -80,8 +87,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo(
present_priority,
smooth_buffer,
panel_hz: panel_fps,
surface_w,
surface_h,
surface_size: h.surface_size.clone(),
};
let join = std::thread::Builder::new()
.name("pf-decode".into())
@@ -93,6 +99,37 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo(
.resolve::<LogErrorAndDefault>()
}
/// `NativeBridge.nativeVideoSurfaceSize(handle, width, height)` — the video `SurfaceView`'s
/// on-screen pixel size, re-reported on every `surfaceChanged`.
///
/// The ASurfaceControl presenter composites its child layer into exactly this rectangle, and the
/// view resizes UNDER a surface that is never recreated: the stream screen hides the system bars
/// and asks to draw into the display cutout a frame or two after `surfaceCreated`, both of which
/// grow it. Without this the layer would keep painting the picture at its start-up size, in the
/// corner of a bigger surface. Non-positive values are ignored (they'd blank the picture).
/// No-op on a `0` handle. Stored whether or not video is running — the next `nativeStartVideo`
/// then starts from a measured view rather than the window's guess. Not android-gated: pure `jni`
/// + an atomic store, so it links on the host build too.
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoSurfaceSize(
_env: EnvUnowned,
_this: JObject,
handle: jlong,
width: jni::sys::jint,
height: jni::sys::jint,
) {
jni_guard((), || {
let packed = super::pack_surface_size(width, height);
if handle == 0 || packed == 0 {
return;
}
// SAFETY: live handle per the nativeConnect/nativeClose contract.
let h = unsafe { &*(handle as *const SessionHandle) };
h.surface_size
.store(packed, std::sync::atomic::Ordering::Relaxed);
})
}
/// `NativeBridge.nativeVideoMime(handle): String` — the MediaCodec MIME for the codec the host
/// resolved (`"video/hevc"` / `"video/avc"` / `"video/av01"`), so Kotlin can rank `MediaCodecList`
/// decoders for it before calling [`Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo`].
+4
View File
@@ -31,6 +31,10 @@ Opus audio, cert pinning — lives in the shared Rust **`punktfunk-core`** (stat
Keychain-stored identity.
- **Tune the stream** — a fps / Mb·s / **latency** HUD (skew-corrected across machines), a bitrate
control, a per-host **network speed test** with a recommended bitrate, and a host-compositor picker.
- **Send logs to host** — the app keeps its recent log in a bounded in-memory ring (`ClientLog`, a
drop-in for `os.Logger` that also writes the unified log); a host card's menu (or the gamepad
UI's host options) posts it to the paired host's `/api/v1/client-logs`, where the web console's
Logs page shows it next to the host's own — the same action the Gaming Mode console has.
Runs from one shared codebase across **macOS, iOS, iPadOS, and tvOS**.
@@ -654,6 +654,7 @@ struct GamepadHomeView: View {
guard let profile = target.profile else { return }
store.setPinned(host.id, profileID: profile.id, pinned: false)
},
onSendLogs: host.pinnedSHA256 != nil ? { await SendLogs.toHost(host) } : nil,
close: { if !transitioning { hostOptionsTarget = nil } },
controllerActive: active)
}
@@ -64,6 +64,9 @@ struct GamepadHostOptionsView: View {
/// Delete the saved record outright.
let onRemove: () -> Void
let onUnpin: () -> Void
/// Upload this device's recent log to the host; answers with what to tell the user. nil on an
/// unpaired host the upload rides the pairing, so there is nothing to offer before it.
var onSendLogs: (() async -> (ok: Bool, message: String))?
var close: (() -> Void)?
var controllerActive = true
@@ -81,13 +84,21 @@ struct GamepadHostOptionsView: View {
/// strict as it is, and none at all to be looser.
@State private var armed = false
@State private var copied = false
/// The send-logs row's own state: its label and the detail band report the outcome in place,
/// the same way Copy link says "Copied" this surface has no toast.
@State private var sendLogs: SendLogsState = .idle
@State private var focusID: String?
private enum SendLogsState: Equatable {
case idle, sending, done(ok: Bool, message: String)
}
private enum Action: String {
case wake
case copyLink
case edit
case forgetPairing
case sendLogs
case remove
case unpin
case cancel
@@ -195,6 +206,15 @@ struct GamepadHostOptionsView: View {
}
list.append(Row(action: .copyLink, label: copied ? "Copied" : "Copy link", icon: "link"))
list.append(Row(action: .edit, label: "Edit\u{2026}", icon: "pencil"))
if onSendLogs != nil {
let label: String
switch sendLogs {
case .idle: label = "Send logs to host"
case .sending: label = "Sending logs\u{2026}"
case .done(let ok, _): label = ok ? "Logs sent" : "Couldn't send logs"
}
list.append(Row(action: .sendLogs, label: label, icon: "doc.text"))
}
// Only a paired host has a pairing to drop.
if host.pinnedSHA256 != nil {
list.append(Row(
@@ -221,6 +241,9 @@ struct GamepadHostOptionsView: View {
case .forgetPairing:
return "Drop the stored fingerprint. The host stays saved and the next connect "
+ "pairs again."
case .sendLogs:
if case .done(_, let message) = sendLogs { return message }
return "Upload this device's recent log to the host, for its web console's Logs page."
case .remove:
return armed
? "Press again to remove — this cannot be undone."
@@ -263,6 +286,15 @@ struct GamepadHostOptionsView: View {
case .forgetPairing:
onForgetPairing()
performClose()
case .sendLogs:
guard let onSendLogs, sendLogs != .sending else { return }
withAnimation(.smooth(duration: 0.2)) { sendLogs = .sending }
Task {
let outcome = await onSendLogs()
withAnimation(.smooth(duration: 0.2)) {
sendLogs = .done(ok: outcome.ok, message: outcome.message)
}
}
case .remove:
guard armed else {
withAnimation(.smooth(duration: 0.2)) { armed = true }
@@ -45,6 +45,8 @@ struct HomeView: View {
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = true
/// The host being edited (name / address / port / Wake-on-LAN MAC) drives the edit sheet.
@State private var editTarget: StoredHost?
/// The outcome of the last "Send Logs to Host" drives its alert.
@State private var sendLogsResult: (ok: Bool, message: String)?
// How this device shows its own list. `.added` is the default because it is what the grid
// did before it could sort at all an update should not rearrange anyone's hosts.
@AppStorage(DefaultsKey.hostSort) private var sortRaw = HostSort.added.rawValue
@@ -194,6 +196,16 @@ struct HomeView: View {
}
#endif
}
.alert(
sendLogsResult?.ok == true ? "Logs Sent" : "Couldn't Send Logs",
isPresented: Binding(
get: { sendLogsResult != nil },
set: { if !$0 { sendLogsResult = nil } })
) {
Button("OK", role: .cancel) {}
} message: {
Text(sendLogsResult?.message ?? "")
}
#if os(macOS)
.frame(minWidth: 480, minHeight: 360)
#endif
@@ -292,6 +304,8 @@ struct HomeView: View {
onBrowseLibrary: onBrowseLibrary,
onWake: { wake(host) },
onEdit: { editTarget = host },
onSendLogs: host.pinnedSHA256 != nil
? { Task { sendLogsResult = await SendLogs.toHost(host) } } : nil,
profileMenu: profileMenu(for: host),
pinnedProfile: pinned)
}
@@ -137,6 +137,9 @@ struct HostCardView: View {
var onWake: (() -> Void)? = nil
/// Open the edit sheet (name / address / port / Wake-on-LAN MAC).
var onEdit: (() -> Void)? = nil
/// Upload this device's recent log to the host (`SendLogs`). `nil` when the host is unpaired
/// the upload is authenticated by the pairing, so there is nothing to offer before it.
var onSendLogs: (() -> Void)? = nil
/// This card's profile affordances nil on surfaces that don't offer them.
var profileMenu: HostProfileMenu? = nil
/// Set on a PINNED card: the profile this card connects with. nil = the host's primary card,
@@ -252,6 +255,9 @@ struct HostCardView: View {
if let onBrowseLibrary {
Button("Browse Library…", action: onBrowseLibrary)
}
if let onSendLogs {
Button("Send Logs to Host", action: onSendLogs)
}
if !isOnline, !host.wakeMacs.isEmpty, PunktfunkConnection.wakeOnLANAvailable, let onWake {
Button("Wake Host", systemImage: "power", action: onWake)
}
@@ -4,6 +4,7 @@
#if os(macOS)
import AppKit
#endif
import PunktfunkKit
import SwiftUI
@main
@@ -13,6 +14,9 @@ struct PunktfunkClientApp: App {
#endif
init() {
// Before anything touches the core, so its first lines (identity load, the first connect's
// transport setup) land in the log ring "Send logs to host" uploads.
CoreLog.install()
#if os(iOS)
// Put Geist on the navigation titles before any bar is built.
BrandTheme.apply()
@@ -19,7 +19,11 @@ import SwiftUI
/// on-screen HUD (Console.app, wirelessly on an iPad/Apple TV). The HUD is not a neutral
/// instrument: any visible overlay forces the metal layer through the compositor, which costs a
/// refresh period on the vsync-latched platforms this is how to measure with it off.
private let statsLog = Logger(subsystem: "io.unom.punktfunk", category: "stats")
private let statsLog = ClientLog(category: "stats")
/// The session's lifecycle connect asked/landed/refused, how it ended. Until this existed a
/// client log bundle had a 1 Hz stats line and no sentence saying which host it was streaming
/// from, with what, or why it stopped; the host's own log has always said all three.
private let sessionLog = ClientLog(category: "session")
/// Mirror the 1 Hz vitals line to STDOUT as well as the unified log.
///
/// Exists for **tvOS, where the unified log is unreachable**: `log stream --device` is gone from
@@ -448,6 +452,11 @@ final class SessionModel: ObservableObject {
// default (PUNKTFUNK_444, default on), so this toggle is the one real switch; the
// hardware-decode probe below still gates what can actually be advertised.
let want444 = effective.enable444
let connectLine = "connect \(host.displayName) \(host.address):\(host.port) "
+ "mode=\(width)x\(height)@\(hz) codec=\(effective.codec) bitrate=\(bitrateKbps)kbps "
+ "hdr=\(hdrCapable) 444=\(want444) audio=\(audioChannels)ch/\(audioRateHz)Hz/\(audioBits)bit "
+ "pinned=\(pin != nil) tofu=\(allowTofu) launch=\(launchID ?? "-")"
sessionLog.info("\(connectLine, privacy: .public)")
Task.detached(priority: .userInitiated) {
// PunktfunkConnection.init blocks on the QUIC handshake keep it off the main
// actor. The persistent identity is presented on every connect so a paired
@@ -530,6 +539,14 @@ final class SessionModel: ObservableObject {
}
switch result {
case .success(let conn):
let landed = "connected \(host.displayName) "
+ "mode=\(conn.width)x\(conn.height)@\(conn.refreshHz) "
+ "codec=\(conn.videoCodec) bitrate=\(conn.resolvedBitrateKbps)kbps "
+ "depth=\(conn.bitDepth) chroma=\(conn.isChroma444 ? "444" : "420") hdr=\(conn.isHDR) "
+ "audio=\(conn.resolvedAudioChannels)ch/\(conn.resolvedAudioRateHz)Hz/\(conn.resolvedAudioBits)bit "
+ "shard=\(conn.shardPayload) compositor=\(conn.resolvedCompositor.rawValue) "
+ "gamepad=\(conn.resolvedGamepad.rawValue) mgmt=\(conn.hostMgmtPort)"
sessionLog.info("\(landed, privacy: .public)")
if pin != nil || autoTrust || requestAccess {
// requestAccess: the operator approved this device on the host, so the
// session is trusted stream directly (the caller pins it as paired).
@@ -553,6 +570,8 @@ final class SessionModel: ObservableObject {
+ "Pair with its PIN before streaming."
}
case .failure(let error):
sessionLog.warning(
"connect \(host.displayName, privacy: .public) failed: \(String(describing: error), privacy: .public)")
self.phase = .idle
self.activeHost = nil
SessionSettings.end() // the dial failed back to the plain globals
@@ -782,6 +801,10 @@ final class SessionModel: ObservableObject {
/// `disconnectQuit()` so the host skips the keep-alive linger; `sessionEnded()` (a host-ended /
/// dropped session) passes `false` to leave the linger intact.
func disconnect(deliberate: Bool = true) {
if connection != nil {
let line = "disconnect \(activeHost?.displayName ?? "-") deliberate=\(deliberate) phase=\(phase)"
sessionLog.info("\(line, privacy: .public)")
}
statsTimer?.invalidate()
statsTimer = nil
// Release the session's resolved settings: from here every reader falls back to the plain
@@ -902,6 +925,9 @@ final class SessionModel: ObservableObject {
// The shelf it came off falling back to the host's own if a caller launched a title
// without naming one, which is what that launch effectively browsed.
let shelf = launchedShelf ?? activeHost.map { LibraryTarget(host: $0) }
let endLine = "session ended by \(name) reason=\(reason) "
+ "rejection=\(rejection.map { String(describing: $0) } ?? "-")"
sessionLog.info("\(endLine, privacy: .public)")
disconnect(deliberate: false) // host/network ended it keep the linger for a reconnect
if let rejection {
// The shared typed-rejection wording ("Your access to this host has expired").
@@ -522,7 +522,25 @@ extension SettingsView {
}
described(inhibitShortcutsDescription, field: "inhibit_shortcuts") {
Toggle("Capture system shortcuts", isOn: scoped(SettingsFields.inhibitShortcuts))
// Turning it ON is the moment to ask for Accessibility never at stream start,
// where a TCC dialog over a captured stream would be the surprise.
.onChange(of: effective.inhibitShortcuts) { was, on in
if on, !was, !accessibilityTrusted { InputCapture.requestSystemShortcutAccess() }
}
if effective.inhibitShortcuts, !accessibilityTrusted {
Button("Allow Accessibility access…") {
InputCapture.requestSystemShortcutAccess()
// The prompt's own "Open System Settings" only shows the FIRST time the system
// asks; after that the user has to find the pane themselves open it for them.
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility") {
NSWorkspace.shared.open(url)
}
}
}
}
.onReceive(NotificationCenter.default.publisher(
for: NSApplication.didBecomeActiveNotification
)) { _ in accessibilityTrusted = InputCapture.systemShortcutsAvailable }
#endif
described(
(ModifierLayout(rawValue: effective.modifierLayout) ?? .mac).detail,
@@ -549,8 +567,13 @@ extension SettingsView {
if (MouseInputMode(rawValue: effective.mouseMode) ?? .capture) == .desktop {
return "No effect under the desktop mouse model — switch Mouse input to Capture."
}
return "Sends ⌘ shortcuts to the host while captured. ⌘⎋ always stays local — it "
+ "releases capture."
if accessibilityTrusted {
return "Sends ⌘ shortcuts — ⌘Space, ⌘Tab and Mission Control included — to the host "
+ "while captured. ⌘⎋ always stays local — it releases capture."
}
return "Sends the app's ⌘ shortcuts (⌘Q, ⌘W, ⌘H…) to the host while captured. ⌘Space, "
+ "⌘Tab and Mission Control need Accessibility access — macOS claims them before any "
+ "app sees them. ⌘⎋ always stays local — it releases capture."
}
/// The SELECTED mouse model explained dynamic, like the touch-mode caption.
@@ -124,6 +124,10 @@ struct SettingsView: View {
/// instead of the app menu while captured). macOS-only: it is the one platform whose window
/// system hands a plain app no keyboard grab, so the client has to claim the chords itself.
@AppStorage(DefaultsKey.inhibitShortcuts) var inhibitShortcuts = true
/// Accessibility granted? Gates the system-shortcut half of `inhibit_shortcuts` (Space, Tab
/// need the event tap). Re-read whenever the app comes back to the front that is when the
/// user returns from flipping the switch in System Settings.
@State var accessibilityTrusted = InputCapture.systemShortcutsAvailable
@AppStorage(DefaultsKey.speakerUID) var speakerUID = ""
@AppStorage(DefaultsKey.micUID) var micUID = ""
@AppStorage(DefaultsKey.micChannel) var micChannel = 0
@@ -0,0 +1,39 @@
// "Send logs to host" the one action behind the host card's menu item and the gamepad options
// row. Posts `ClientLogRing` to the PAIRED host (`LibraryClient.sendLogs`), where the web
// console's Logs page shows it next to the host's own log. The Apple port of the Gaming Mode
// console's `ConsoleCmd::SendLogs` (clients/session/src/console.rs), same wording on success.
import Foundation
import PunktfunkKit
private let log = ClientLog(category: "logs")
enum SendLogs {
/// Upload this device's recent log to `host`. Never throws: the caller shows `message` either
/// way, and the outcome is itself the last line of the NEXT bundle.
static func toHost(_ host: StoredHost) async -> (ok: Bool, message: String) {
// The same two preconditions the library screen applies: this device's mTLS identity
// (minted on the first connect) and the host's pinned fingerprint (pairing) an upload
// is an outbound write carrying the device's diagnostics, and it goes to a host the user
// has actually paired with, not to whoever answers on that port.
guard let identity = (try? ClientIdentityStore.shared.load())?.identity else {
return (false, "Connect to this host once first — sending logs uses the identity "
+ "created on the first connect.")
}
guard let pin = host.pinnedSHA256 else {
return (false, "Pair with \(host.displayName) first — logs are only sent to a paired host.")
}
do {
let id = try await LibraryClient.sendLogs(
address: host.address, port: host.effectiveMgmtPort,
certPEM: identity.certPEM, keyPEM: identity.keyPEM, hostFingerprint: pin)
log.info("client logs uploaded to \(host.displayName, privacy: .public) id=\(id, privacy: .public)")
return (true, "Logs sent to \(host.displayName) — download them from its web console's "
+ "Logs page.")
} catch {
let why = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
log.warning("client log upload to \(host.displayName, privacy: .public) failed: \(why, privacy: .public)")
return (false, "Couldn't send logs — \(why)")
}
}
}
@@ -16,7 +16,7 @@ import os
import CoreAudio
#endif
private let log = Logger(subsystem: "io.unom.punktfunk", category: "audio")
private let log = ClientLog(category: "audio")
final class AudioDeviceWatcher {
/// Why the owner is being told. Only for the log line every reason leads to the same
@@ -38,7 +38,7 @@
import AVFoundation
import os
private let log = Logger(subsystem: "io.unom.punktfunk", category: "audio")
private let log = ClientLog(category: "audio")
/// Render-block-owned scratch storage: freed exactly when the closure (and thus the
/// last possible render call) is released never racing CoreAudio.
@@ -235,6 +235,40 @@ public enum LibraryClient {
return status.games ?? []
}
/// Upload this client's recent log (`ClientLogRing`) to the host `POST /api/v1/client-logs`,
/// the one WRITE a paired certificate may make (the host's `mgmt/client_logs.rs`). Same lane
/// and identity as the library; the host files the bundle under this device and shows it on
/// its web console's Logs page next to its own log. Returns the stored bundle id (empty for a
/// host that predates the id in the reply).
///
/// Why it exists: on an Apple TV (or a phone, for anyone who is not a developer) there is no
/// way to get the client's log off the device, so every fault report arrived with only the
/// host's half of the story. `hostFingerprint` is required, not optional: this is an outbound
/// write carrying the device's diagnostics, and it goes to the host the user paired with.
public static func sendLogs(
address: String,
port: UInt16 = punktfunkDefaultMgmtPort,
certPEM: String,
keyPEM: String,
hostFingerprint: Data
) async throws -> String {
let identity = try clientIdentity(certPEM: certPEM, keyPEM: keyPEM)
let body = Data(ClientLogRing.render(header: ClientLogRing.header()).utf8)
let response = try await send(
path: "/api/v1/client-logs", address: address, port: port,
identity: identity, hostFingerprint: hostFingerprint,
body: (body, "text/plain; charset=utf-8"))
switch response.status {
case 200, 201:
let json = try? JSONSerialization.jsonObject(with: response.body) as? [String: Any]
return json?["id"] as? String ?? ""
case 401, 403:
throw LibraryError.unauthorized
default:
throw LibraryError.http(response.status)
}
}
/// Just the slice of `/status` this client reads. Everything else on that payload is the
/// operator console's business, and decoding only what we use keeps an unrelated schema change
/// on the host from breaking the library screen.
@@ -259,12 +293,20 @@ public enum LibraryClient {
}
}
/// One GET against the host, with transport failures mapped onto `LibraryError`.
/// One request against the host a GET, or a POST when `body` is given with transport
/// failures mapped onto `LibraryError`.
static func send(
path: String, address: String, port: UInt16,
identity: SecIdentity, hostFingerprint: Data?
identity: SecIdentity, hostFingerprint: Data?,
body: (data: Data, contentType: String)? = nil
) async throws -> HTTPResponse {
do {
if let body {
return try await MgmtTransport.post(
host: address, port: port, path: path, body: body.data,
contentType: body.contentType,
identity: identity, pinnedHostFingerprint: hostFingerprint)
}
return try await MgmtTransport.get(
host: address, port: port, path: path,
identity: identity, pinnedHostFingerprint: hostFingerprint)
@@ -56,6 +56,41 @@ enum MgmtTransport {
identity: SecIdentity,
pinnedHostFingerprint: Data?,
timeout: TimeInterval = 15
) async throws -> HTTPResponse {
try await request(
host: host, port: port, method: "GET", path: path, body: nil, contentType: nil,
identity: identity, pinnedHostFingerprint: pinnedHostFingerprint, timeout: timeout)
}
/// `POST https://host:port/path` with a body same transport, trust and retry rule as `get`.
/// The one write a paired device may make is the client-log upload, which is idempotent in
/// the only sense that matters (a retried bundle is a second bundle, not a corrupted one).
static func post(
host: String,
port: UInt16,
path: String,
body: Data,
contentType: String,
identity: SecIdentity,
pinnedHostFingerprint: Data?,
timeout: TimeInterval = 15
) async throws -> HTTPResponse {
try await request(
host: host, port: port, method: "POST", path: path, body: body,
contentType: contentType, identity: identity,
pinnedHostFingerprint: pinnedHostFingerprint, timeout: timeout)
}
private static func request(
host: String,
port: UInt16,
method: String,
path: String,
body: Data?,
contentType: String?,
identity: SecIdentity,
pinnedHostFingerprint: Data?,
timeout: TimeInterval
) async throws -> HTTPResponse {
guard let nwPort = NWEndpoint.Port(rawValue: port) else {
throw MgmtTransportError.invalidPort(port)
@@ -70,7 +105,9 @@ enum MgmtTransport {
}
let wasReused = connection.hasServedRequest
do {
let response = try await connection.perform(path: path, timeout: timeout)
let response = try await connection.perform(
method: method, path: path, body: body, contentType: contentType,
timeout: timeout)
await MgmtConnectionPool.shared.release(connection, key: key)
return response
} catch {
@@ -230,7 +267,10 @@ final class MgmtConnection: @unchecked Sendable {
private let rejection: RejectionFlag
private final class RejectionFlag: @unchecked Sendable { var value = false }
func perform(path: String, timeout: TimeInterval) async throws -> HTTPResponse {
func perform(
method: String = "GET", path: String, body: Data? = nil, contentType: String? = nil,
timeout: TimeInterval
) async throws -> HTTPResponse {
try await withCheckedThrowingContinuation { continuation in
queue.async {
guard self.phase != .dead else {
@@ -240,7 +280,9 @@ final class MgmtConnection: @unchecked Sendable {
self.operation += 1
let op = self.operation
self.pending = continuation
self.pendingRequest = self.requestBytes(path: path)
self.pendingRequest = Self.requestBytes(
host: self.host, port: self.port,
method: method, path: path, body: body, contentType: contentType)
self.buffer.removeAll(keepingCapacity: true)
self.queue.asyncAfter(deadline: .now() + timeout) { [weak self] in
guard let self, self.operation == op else { return }
@@ -361,17 +403,24 @@ final class MgmtConnection: @unchecked Sendable {
rejection.value ? .pinMismatch : .connection(String(describing: error))
}
private func requestBytes(path: String) -> Data {
/// The wire bytes of one request. Pure (and `static`) so the framing is unit-testable.
static func requestBytes(
host: String, port: UInt16,
method: String, path: String, body: Data?, contentType: String?
) -> Data {
// An IPv6 literal is bracketed in the Host header (RFC 9110 §7.2); a name or IPv4 is not.
let authority = host.contains(":") ? "[\(host)]:\(port)" : "\(host):\(port)"
let request = """
GET \(path) HTTP/1.1\r
Host: \(authority)\r
User-Agent: punktfunk-apple\r
Accept: */*\r
\r
"""
return Data(request.utf8)
var head = "\(method) \(path) HTTP/1.1\r\nHost: \(authority)\r\n"
+ "User-Agent: punktfunk-apple\r\nAccept: */*\r\n"
if let body {
// Always framed by length a request body has no EOF to end it on a kept-alive
// connection, and the host's axum would otherwise wait for one.
head += "Content-Type: \(contentType ?? "application/octet-stream")\r\n"
head += "Content-Length: \(body.count)\r\n"
}
head += "\r\n"
var request = Data(head.utf8)
if let body { request.append(body) }
return request
}
}
@@ -19,7 +19,7 @@ import IOKit
import IOKit.hid
import os
private let log = Logger(subsystem: "io.unom.punktfunk", category: "gamepad")
private let log = ClientLog(category: "gamepad")
/// Opens one connected Sony DualSense and forwards motor rumble to it over raw HID.
///
@@ -3,7 +3,7 @@ import Foundation
import GameController
import os
private let log = Logger(subsystem: "io.unom.punktfunk", category: "gamepad")
private let log = ClientLog(category: "gamepad")
/// Tuning constants + the pure scheduling decisions of the rumble renderer, split out so the
/// policy is unit-testable without a `CHHapticEngine` or a physical pad.
@@ -48,7 +48,7 @@ import os
/// PUNKTFUNK_INPUT_DEBUG=1 in the environment to surface whether relative motion + buttons
/// are actually being SENT to the host without needing host-side logs. Motion is throttled
/// to once per second (see `motionDebugTick`); buttons log every transition.
private let inputLog = Logger(subsystem: "io.unom.punktfunk", category: "input")
private let inputLog = ClientLog(category: "input")
private let inputDebug = ProcessInfo.processInfo.environment["PUNKTFUNK_INPUT_DEBUG"] == "1"
public final class InputCapture {
@@ -60,6 +60,10 @@ public final class InputCapture {
private var keyboards: [GCKeyboard] = []
#if os(macOS)
private var keyEventMonitor: Any?
/// The system-shortcut tap (see `installSystemKeyTap`) and its run-loop source. Live only
/// while forwarding with `inhibit_shortcuts` on AND Accessibility granted; nil otherwise.
private var systemKeyTap: CFMachPort?
private var systemKeyTapSource: CFRunLoopSource?
#endif
// Main-queue-only state (see header comment).
@@ -194,7 +198,13 @@ public final class InputCapture {
if on {
forwarding = true
suppressedButton = suppressClick ? 1 : nil
#if os(macOS)
installSystemKeyTap()
#endif
} else if forwarding {
#if os(macOS)
removeSystemKeyTap()
#endif
releaseAll()
forwarding = false
suppressedButton = nil
@@ -369,6 +379,7 @@ public final class InputCapture {
NSEvent.removeMonitor(monitor)
keyEventMonitor = nil
}
removeSystemKeyTap()
#endif
// Don't clobber the handlers if a newer capture has taken the global devices.
if Self.activeCapture === self || Self.activeCapture == nil {
@@ -672,6 +683,128 @@ public final class InputCapture {
}
commandChordVKs.removeAll()
}
// MARK: - System shortcut tap
/// Whether the system-shortcut tap CAN run: Accessibility granted to this process. Read live
/// (the user flips it in System Settings while the app runs); never prompts the prompt is the
/// Settings toggle's job (`requestSystemShortcutAccess`), not something a stream start springs.
public static var systemShortcutsAvailable: Bool { AXIsProcessTrusted() }
/// Show the one-time Accessibility prompt (a no-op once granted). Called from Settings when the
/// user turns "Capture system shortcuts" on or presses the grant button.
public static func requestSystemShortcutAccess() {
let opts = [kAXTrustedCheckOptionPrompt.takeUnretainedValue(): true] as CFDictionary
_ = AXIsProcessTrustedWithOptions(opts)
}
/// The other half of `inhibit_shortcuts` on macOS. The keyDown monitor above claims the
/// chords that REACH the app but Space, Tab, and the rest of System Settings Keyboard
/// Shortcuts never do: WindowServer hands them to Spotlight / the Dock / Mission Control before
/// any app sees them. The SDL clients get those through a private CGS hotkey-mode call that a
/// sandboxed app cannot make; the sandbox-legal way is a session-level event tap, which sees
/// every key ahead of the hotkey dispatch and only exists with Accessibility granted.
///
/// The tap does NOT forward anything itself. It takes each keyDown/keyUp off the system and
/// re-posts it, addressed to the key window, into THIS app's event queue (`NSApp.postEvent`), so
/// it arrives exactly where the same key would have arrived had macOS not claimed it the
/// monitor first (client chords, chords host), then `StreamLayerView.keyDown/keyUp`
/// (everything else host). One key path, no second VK table, no second release bookkeeping.
/// In-process posts don't re-enter the tap, so there is no loop. Keys the system would have
/// delivered anyway are unaffected (we drop the original and deliver the copy) the tap only
/// changes what happens to the ones it wouldn't. Bonus: the keyUp of a -chord key now arrives
/// too (the tap sees HID, which never stopped delivering it), so `flushCommandChord` has less
/// to synthesize.
///
/// Gating, every event: `forwarding` (capture engaged and capture releases on any focus loss,
/// so this is never true with another app frontmost), `!desktopMouse` (system chords stay local
/// under the desktop model, like every other client), `NSApp.isActive` as belt-and-braces.
/// Anything else passes through untouched a tap that swallows keys for the whole Mac is the
/// failure mode to design against. Installed on the main run loop on purpose: a hung main thread
/// trips the tap's timeout and macOS disables it, handing the keyboard back.
private func installSystemKeyTap() {
// `desktopMouse` is NOT an install condition: M flips it mid-capture, so the callback
// reads it per event instead and the tap simply idles under the desktop model.
guard systemKeyTap == nil, SessionSettings.current.inhibitShortcuts, AXIsProcessTrusted()
else { return }
let mask = (1 << CGEventType.keyDown.rawValue) | (1 << CGEventType.keyUp.rawValue)
let callback: CGEventTapCallBack = { _, type, event, userInfo in
guard let userInfo else { return Unmanaged.passUnretained(event) }
let capture = Unmanaged<InputCapture>.fromOpaque(userInfo).takeUnretainedValue()
return capture.handleTapped(type: type, event: event)
}
guard let tap = CGEvent.tapCreate(
tap: .cgSessionEventTap, place: .headInsertEventTap, options: .defaultTap,
eventsOfInterest: CGEventMask(mask), callback: callback,
userInfo: Unmanaged.passUnretained(self).toOpaque())
else {
inputLog.error("system shortcut tap: tapCreate failed (Accessibility revoked?)")
return
}
let source = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0)
CFRunLoopAddSource(CFRunLoopGetMain(), source, .commonModes)
CGEvent.tapEnable(tap: tap, enable: true)
systemKeyTap = tap
systemKeyTapSource = source
if inputDebug { inputLog.debug("system shortcut tap installed") }
}
private func removeSystemKeyTap() {
guard let tap = systemKeyTap else { return }
CGEvent.tapEnable(tap: tap, enable: false)
if let source = systemKeyTapSource {
CFRunLoopRemoveSource(CFRunLoopGetMain(), source, .commonModes)
}
CFMachPortInvalidate(tap)
systemKeyTap = nil
systemKeyTapSource = nil
if inputDebug { inputLog.debug("system shortcut tap removed") }
}
/// The tap callback body (main run loop). Returns the event to let it through, nil to swallow.
private func handleTapped(type: CGEventType, event: CGEvent) -> Unmanaged<CGEvent>? {
switch type {
case .tapDisabledByTimeout, .tapDisabledByUserInput:
// macOS switched us off (main thread stalled past the tap's deadline, or a
// system-level interruption); re-arm if still wanted, else stay down.
if let tap = systemKeyTap, forwarding { CGEvent.tapEnable(tap: tap, enable: true) }
return Unmanaged.passUnretained(event)
case .keyDown, .keyUp:
// Stamped with the KEY window: `NSApp.sendEvent` routes a key event by `event.window`,
// and an NSEvent wrapped straight from the CGEvent has none it reaches the local
// monitor but not the first responder (verified in a harness). The key window is the
// stream window whenever `forwarding` is true (capture releases on resignKey); if there
// somehow is none, let the key go rather than swallow it into nothing.
guard Self.tapClaims(forwarding: forwarding, desktopMouse: desktopMouse,
appActive: NSApp.isActive),
let windowNumber = NSApp.keyWindow?.windowNumber,
let copy = event.copy(), let raw = NSEvent(cgEvent: copy),
let stamped = Self.restamp(raw, windowNumber: windowNumber)
else { return Unmanaged.passUnretained(event) }
NSApp.postEvent(stamped, atStart: false)
return nil
default:
return Unmanaged.passUnretained(event)
}
}
/// The same key event, addressed to `windowNumber` (see `handleTapped`).
static func restamp(_ raw: NSEvent, windowNumber: Int) -> NSEvent? {
NSEvent.keyEvent(
with: raw.type, location: .zero, modifierFlags: raw.modifierFlags,
timestamp: raw.timestamp, windowNumber: windowNumber, context: nil,
characters: raw.characters ?? "",
charactersIgnoringModifiers: raw.charactersIgnoringModifiers ?? "",
isARepeat: raw.type == .keyDown && raw.isARepeat, keyCode: raw.keyCode)
}
/// Does the system-shortcut tap take this key off macOS and hand it to the app's own key path?
/// Pure, for the tests: only while captured, only under the capture mouse model, only with the
/// app frontmost. The `inhibit_shortcuts` setting is checked once at install time (the tap does
/// not exist with it off).
static func tapClaims(forwarding: Bool, desktopMouse: Bool, appActive: Bool) -> Bool {
forwarding && !desktopMouse && appActive
}
#endif
private func attach(mouse: GCMouse) {
@@ -0,0 +1,195 @@
// The client's own recent-log ring + the drop-in logger that feeds it the source for the
// "Send logs to host" action (`LibraryClient.sendLogs`), the Apple port of
// `pf_client_core::logring` + `punktfunk-session`'s `ring_layer`.
//
// WHY A RING AND NOT `OSLogStore`. The unified log already holds everything these loggers write,
// and `OSLogStore(scope: .currentProcessIdentifier)` can read it back but only the levels the
// system PERSISTS (`.notice`/`.error`/`.fault`). `.info` is memory-only and purged under pressure,
// and `.info` is exactly where the lines a field report needs live: the 1 Hz stats line, the
// decoder/presenter setup, the audio underrun notes. On an Apple TV there is no Console.app to
// read any of it on either. So every `ClientLog` call goes to os_log as before AND into this
// process-global ring, and an explicit user action posts the ring to the PAIRED host, where the
// web console shows it next to the host's own log.
//
// Bounded by lines AND bytes so a log-storm can't grow memory; the byte budget stays under the
// host's 1 MiB upload cap so a full ring always uploads whole. `.debug` deliberately skips the
// ring: it is per-key/per-event input chatter here, and a ring that a healthy keyboard can flush
// in thirty seconds is worse than no ring (the session client learned this from a Steam Deck
// bundle whose whole 27-minute session had been evicted by decoder DPB chatter).
import Foundation
import os
/// Process-global bounded ring of formatted log lines. `note` is cheap (one lock, one append);
/// `render` is the upload body.
public enum ClientLogRing {
/// Newest lines kept matches the host's own ring depth and the session client's.
public static let maxLines = 4096
/// Byte budget under the host's 1 MiB bundle cap with headroom for the header.
public static let maxBytes = 768 * 1024
private static let lock = OSAllocatedUnfairLock(initialState: State())
private struct State {
var lines: [String] = []
/// Index of the oldest live line in `lines` popped lazily, compacted when half is dead,
/// so eviction is O(1) amortised without a deque type.
var head = 0
var bytes = 0
var dropped = 0
}
/// Append one formatted log line (no trailing newline). Oversized lines are truncated to keep
/// a single event from evicting the whole ring.
public static func note(_ line: String) {
// `decoding:` rather than `String(_:)`: a cut mid-scalar yields U+FFFD, not nil.
let line = line.utf8.count > 2048
? String(decoding: line.utf8.prefix(2048), as: UTF8.self) + "" : line
let size = line.utf8.count
lock.withLock { s in
s.lines.append(line)
s.bytes += size
while s.lines.count - s.head > maxLines || s.bytes > maxBytes, s.head < s.lines.count {
s.bytes -= s.lines[s.head].utf8.count
s.head += 1
s.dropped += 1
}
if s.head > 0, s.head * 2 >= s.lines.count {
s.lines.removeFirst(s.head)
s.head = 0
}
}
}
/// The ring rendered as one text bundle, oldest first, prefixed by `header` (the app's own
/// identity line name, version, platform) and an eviction note when the ring wrapped.
public static func render(header: String) -> String {
lock.withLock { s in
var out = header + "\n"
if s.dropped > 0 {
out += "\(s.dropped) older lines evicted from the ring …\n"
}
for line in s.lines[s.head...] {
out += line
out += "\n"
}
return out
}
}
/// The bundle's first line: `punktfunk-apple 0.31.0 (42) (iOS 26.0.0 arm64; Apple TV) client
/// log bundle` the same shape as the session client's, so the host's log page reads them alike.
public static func header() -> String {
let info = Bundle.main.infoDictionary
let version = info?["CFBundleShortVersionString"] as? String ?? "dev"
let build = info?["CFBundleVersion"] as? String
let os = ProcessInfo.processInfo.operatingSystemVersion
#if os(macOS)
let platform = "macOS"
#elseif os(tvOS)
let platform = "tvOS"
#elseif os(iOS)
let platform = "iOS"
#else
let platform = "apple"
#endif
#if arch(arm64)
let arch = "arm64"
#else
let arch = "x86_64"
#endif
let v = build.map { "\(version) (\($0))" } ?? version
return "punktfunk-apple \(v) (\(platform) \(os.majorVersion).\(os.minorVersion).\(os.patchVersion) "
+ "\(arch); \(DeviceName.kind)) — client log bundle"
}
/// `2026-08-15T12:03:47.123Z` wall time, so a bundle correlates with the host log it lands
/// next to (the session client's `wallclock`).
static func stamp(_ date: Date = Date()) -> String {
Date.ISO8601FormatStyle(includingFractionalSeconds: true).format(date)
}
}
/// Drop-in for `Logger(subsystem: "io.unom.punktfunk", category:)`: the same call shape (string
/// interpolation with `privacy:`/`format:` options), forwarded to os_log AND noted in
/// `ClientLogRing`. Interpolated values are rendered in the clear in both places the unified log
/// was already being read with the app attached, and the ring only ever leaves the device by an
/// explicit "Send logs to host" to a host the user paired with.
public struct ClientLog: Sendable {
public let category: String
private let logger: Logger
public init(category: String) {
self.category = category
self.logger = Logger(subsystem: "io.unom.punktfunk", category: category)
}
/// os_log only see the file comment for why debug stays out of the ring.
public func debug(_ message: ClientLogMessage) {
logger.debug("\(message.text, privacy: .public)")
}
public func info(_ message: ClientLogMessage) {
logger.info("\(message.text, privacy: .public)")
note("INFO", message.text)
}
public func notice(_ message: ClientLogMessage) {
logger.notice("\(message.text, privacy: .public)")
note("INFO", message.text)
}
public func warning(_ message: ClientLogMessage) {
logger.warning("\(message.text, privacy: .public)")
note("WARN", message.text)
}
public func error(_ message: ClientLogMessage) {
logger.error("\(message.text, privacy: .public)")
note("ERROR", message.text)
}
public func fault(_ message: ClientLogMessage) {
logger.fault("\(message.text, privacy: .public)")
note("ERROR", message.text)
}
private func note(_ level: String, _ text: String) {
ClientLogRing.note("\(ClientLogRing.stamp()) \(level.padding(toLength: 5, withPad: " ", startingAt: 0)) \(category) \(text)")
}
}
/// The interpolated message: accepts the `OSLogMessage` options the call sites use (`privacy:`,
/// `format:`) so swapping `Logger` for `ClientLog` touches one declaration per file, not every
/// log line. Privacy is accepted and ignored (see `ClientLog`); `.fixed(precision:)` is honoured.
public struct ClientLogMessage: ExpressibleByStringInterpolation, ExpressibleByStringLiteral, Sendable {
public let text: String
public init(stringLiteral value: String) { text = value }
public init(stringInterpolation: StringInterpolation) { text = stringInterpolation.out }
public struct StringInterpolation: StringInterpolationProtocol, Sendable {
var out = ""
public init(literalCapacity: Int, interpolationCount: Int) {
out.reserveCapacity(literalCapacity + interpolationCount * 8)
}
public mutating func appendLiteral(_ literal: String) { out += literal }
public mutating func appendInterpolation<T>(_ value: T, privacy: OSLogPrivacy = .auto) {
out += String(describing: value)
}
public mutating func appendInterpolation<T: BinaryFloatingPoint>(
_ value: T, format: ClientLogFloatFormat, privacy: OSLogPrivacy = .auto
) {
switch format {
case .fixed(let precision):
out += String(format: "%.\(precision)f", Double(value))
}
}
}
}
/// The one float format the call sites use. `OSLogFloatFormatting` cannot be pattern-matched, so
/// the message type names its own same spelling at the call site: `format: .fixed(precision: 2)`.
public enum ClientLogFloatFormat: Sendable {
case fixed(precision: Int)
}
@@ -0,0 +1,39 @@
// The Rust core's log lines, routed into `ClientLog` (os_log + the send-to-host ring).
//
// The core logs through `tracing`. The desktop and Android shells install a subscriber/logger and
// see those lines; this app never did, so every transport warning (socket-buffer clamp, QoS
// refusal), every quinn connection event and every rustls handshake note vanished, and a bundle
// sent to the host carried the Swift half of the story only. `punktfunk_set_log_callback`
// (ABI v25) hands them to the C callback below, which files each under a `core.<crate>` category.
import Foundation
import PunktfunkCore
public enum CoreLog {
/// Install once at launch. Levels above `maxLevel` (1 = error 5 = trace) are not even
/// formatted on the Rust side. Info is the ceiling on purpose: quinn's debug/trace is
/// per-packet and would churn the ring the same gate the session client's ring applies.
/// `PUNKTFUNK_CORE_LOG_LEVEL=4` raises it for a debugging session.
public static func install() {
let level = UInt8(ProcessInfo.processInfo.environment["PUNKTFUNK_CORE_LOG_LEVEL"] ?? "") ?? 3
let status = punktfunk_set_log_callback(level, { level, target, message, _ in
// Called from whichever Rust thread logged copy both C strings out before anything
// else, then hand off to ClientLog, which is cheap (one lock + os_log) and thread-safe.
let target = target.map { String(cString: $0) } ?? "core"
let message = message.map { String(cString: $0) } ?? ""
// The crate (first path segment) becomes the category; the full target stays in the
// line, so `quinn::connection` reads as `core.quinn quinn::connection `.
let crate_ = target.split(separator: ":", maxSplits: 1).first.map(String.init) ?? target
let log = ClientLog(category: "core.\(crate_)")
switch level {
case 1: log.error("\(target, privacy: .public) \(message, privacy: .public)")
case 2: log.warning("\(target, privacy: .public) \(message, privacy: .public)")
case 3: log.info("\(target, privacy: .public) \(message, privacy: .public)")
default: log.debug("\(target, privacy: .public) \(message, privacy: .public)")
}
}, nil)
if status != PUNKTFUNK_STATUS_OK.rawValue {
ClientLog(category: "core").warning("core log callback not installed: status \(status)")
}
}
}
@@ -21,7 +21,7 @@ import Metal
import QuartzCore
import os
private let presenterLog = Logger(subsystem: "io.unom.punktfunk", category: "presenter")
private let presenterLog = ClientLog(category: "presenter")
#if os(macOS)
/// HOW a windowed (composited) macOS session pushes finished frames to glass the DCP
@@ -34,7 +34,7 @@ import Foundation
import Metal
import os
private let waveletLog = Logger(subsystem: "io.unom.punktfunk", category: "pyrowave")
private let waveletLog = ClientLog(category: "pyrowave")
/// The per-(component, level, band) 32x32-block table the exact Swift port of
/// `WaveletBuffers::init_block_meta` (pyrowave_common.cpp): the walk order (level 40,
@@ -56,9 +56,9 @@ let presentDebug = ProcessInfo.processInfo.environment["PUNKTFUNK_PRESENT_DEBUG"
/// SessionModel "stats" mirror's sibling, so DEADLINE sessions stream their pacing decomposition
/// to Console.app wirelessly with no env var / Xcode attach. Always on for deadline pacing (the
/// stats are a few arrays + one log line per second); other pacings keep the env-gated print.
private let presentLog = Logger(subsystem: "io.unom.punktfunk", category: "present")
private let presentLog = ClientLog(category: "present")
/// Pump-side events (loss recovery, format seeding) the stage-2 sibling of StreamPump's log.
private let pumpLog = Logger(subsystem: "io.unom.punktfunk", category: "pump")
private let pumpLog = ClientLog(category: "pump")
/// Decoded-frame hand-off between the decode half and the render thread. The POLICY is the
/// user's presentation intent (design/apple-presentation-rebuild.md the 2026-07 rebuild that
@@ -8,7 +8,7 @@ import AVFoundation
import Foundation
import os
private let pumpLog = Logger(subsystem: "io.unom.punktfunk", category: "video")
private let pumpLog = ClientLog(category: "video")
/// One pump per instance; create a fresh StreamPump per start (the stop is permanent
/// a restart hands the old pump its own token, so it can never be revived by a newer start()).
@@ -26,7 +26,7 @@ import os
/// Same diagnostic switch as InputCapture: PUNKTFUNK_INPUT_DEBUG=1 logs when the macOS
/// NSEvent mouse monitor (relative motion + buttons) is installed/removed, so the user can
/// confirm the new motion path is actually live for a session.
private let streamInputLog = Logger(subsystem: "io.unom.punktfunk", category: "input")
private let streamInputLog = ClientLog(category: "input")
private let streamInputDebug =
ProcessInfo.processInfo.environment["PUNKTFUNK_INPUT_DEBUG"] == "1"
@@ -47,7 +47,7 @@ import AVKit // AVDisplayManager — the per-session display-mode (HDR10/refresh
/// resolved pointer-lock state each time capture engages, so the user can see whether the
/// scene actually locked (GCMouse only delivers deltas while it did) or whether we're on
/// the touch fallback.
private let iosInputLog = Logger(subsystem: "io.unom.punktfunk", category: "input")
private let iosInputLog = ClientLog(category: "input")
private let iosInputDebug = ProcessInfo.processInfo.environment["PUNKTFUNK_INPUT_DEBUG"] == "1"
public struct StreamView: UIViewControllerRepresentable {
@@ -0,0 +1,69 @@
// The client log ring behind "Send logs to host", and the POST framing that carries it.
import XCTest
@testable import PunktfunkKit
final class ClientLogTests: XCTestCase {
/// The ring is process-global, so this single test owns the whole lifecycle (parallel tests
/// over one global would interleave) the same shape as `pf_client_core::logring`'s test.
func testRingBoundsAndRendersWithEvictionNote() {
let marker = "ringtest-\(ProcessInfo.processInfo.processIdentifier)"
for i in 0..<(ClientLogRing.maxLines + 10) {
ClientLogRing.note("\(marker) line \(i)")
}
let text = ClientLogRing.render(header: "punktfunk-apple test")
XCTAssertTrue(text.hasPrefix("punktfunk-apple test\n"))
XCTAssertTrue(text.contains("older lines evicted from the ring"))
XCTAssertFalse(text.contains("\n\(marker) line 0\n"), "oldest line survived eviction")
XCTAssertTrue(text.hasSuffix("\(marker) line \(ClientLogRing.maxLines + 9)\n"))
XCTAssertLessThanOrEqual(text.utf8.count, ClientLogRing.maxBytes + 256)
// A pathological line is truncated, not ring-flushing and cut safely mid-scalar.
ClientLogRing.note(String(repeating: "é", count: 10_000))
let after = ClientLogRing.render(header: "h")
XCTAssertTrue(after.contains(""))
XCTAssertTrue(after.hasSuffix("\n"))
// The drop-in logger formats `stamp LEVEL category message` and honours the OSLogMessage
// options the call sites use; debug stays out of the ring.
let log = ClientLog(category: "test")
log.info("\(marker) value \(1.23456, format: .fixed(precision: 2)) \(42, privacy: .public)")
log.debug("\(marker) debug-only")
let lines = ClientLogRing.render(header: "h").components(separatedBy: "\n")
let info = lines.last { $0.contains("\(marker) value") }
XCTAssertNotNil(info)
XCTAssertTrue(info!.contains(" INFO test \(marker) value 1.23 42"), info!)
// `2026-08-15T12:03:47.123Z ` leads wall time, so a bundle lines up with the host log.
XCTAssertNotNil(
info!.range(of: #"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z INFO test "#, options: .regularExpression),
info!)
XCTAssertFalse(lines.contains { $0.contains("debug-only") })
}
func testHeaderNamesTheAppAndPlatform() {
let header = ClientLogRing.header()
XCTAssertTrue(header.hasPrefix("punktfunk-apple "))
XCTAssertTrue(header.hasSuffix(" — client log bundle"))
#if os(macOS)
XCTAssertTrue(header.contains("macOS"))
#endif
}
func testPostIsLengthFramedAndGetHasNoBody() {
let body = Data("hello ring\n".utf8)
let post = String(decoding: MgmtConnection.requestBytes(
host: "fd00::1", port: 47990, method: "POST", path: "/api/v1/client-logs",
body: body, contentType: "text/plain; charset=utf-8"), as: UTF8.self)
XCTAssertTrue(post.hasPrefix("POST /api/v1/client-logs HTTP/1.1\r\nHost: [fd00::1]:47990\r\n"))
XCTAssertTrue(post.contains("\r\nContent-Type: text/plain; charset=utf-8\r\n"))
XCTAssertTrue(post.contains("\r\nContent-Length: \(body.count)\r\n\r\nhello ring\n"))
XCTAssertTrue(post.hasSuffix("\r\n\r\nhello ring\n"))
let get = String(decoding: MgmtConnection.requestBytes(
host: "192.168.1.2", port: 47990, method: "GET", path: "/api/v1/library",
body: nil, contentType: nil), as: UTF8.self)
XCTAssertTrue(get.hasPrefix("GET /api/v1/library HTTP/1.1\r\nHost: 192.168.1.2:47990\r\n"))
XCTAssertFalse(get.contains("Content-Length"))
XCTAssertTrue(get.hasSuffix("\r\n\r\n"))
}
}
@@ -106,6 +106,25 @@ final class CommandChordTests: XCTestCase {
XCTAssertEqual(InputCapture.keyCodeToVK[leftArrow], 0x25) // VK_LEFT
}
/// The system-shortcut tap (Space, Tab the keys macOS claims before the app sees them)
/// takes keys off the system ONLY while captured, under the capture mouse model, with the app
/// frontmost. Any other state must pass through: a tap that eats keys for the whole Mac is the
/// failure to pin here.
func testTheSystemShortcutTapOnlyClaimsWhileCapturedAndFrontmost() {
XCTAssertTrue(InputCapture.tapClaims(forwarding: true, desktopMouse: false, appActive: true))
XCTAssertFalse(InputCapture.tapClaims(forwarding: false, desktopMouse: false, appActive: true))
XCTAssertFalse(InputCapture.tapClaims(forwarding: true, desktopMouse: true, appActive: true))
XCTAssertFalse(InputCapture.tapClaims(forwarding: true, desktopMouse: false, appActive: false))
}
/// The keys the tap exists for must have host VKs it reposts them into the ordinary key path,
/// which drops unmapped keyCodes on the floor.
func testTheSystemShortcutKeysMapToHostVKs() {
XCTAssertEqual(InputCapture.keyCodeToVK[49], 0x20) // Space (Space)
XCTAssertEqual(InputCapture.keyCodeToVK[48], 0x09) // Tab (Tab)
XCTAssertEqual(InputCapture.keyCodeToVK[126], 0x26) // Up arrow ( Mission Control)
}
private func keyEvent(_ keyCode: UInt16, _ flags: NSEvent.ModifierFlags) -> NSEvent? {
NSEvent.keyEvent(
with: .keyDown, location: .zero, modifierFlags: flags, timestamp: 0,
+9
View File
@@ -81,6 +81,15 @@ WHY THE APP ASKS FOR WHAT IT ASKS FOR
- network.server (macOS): the app is outbound-only, but the App Sandbox gates bind() itself. Our
QUIC endpoint and UDP socket each bind a local port to receive host-to-client datagrams;
without this, no video, audio or rumble arrives.
- Accessibility (macOS, optional, never requested unprompted): "Capture system shortcuts" in
Settings > Input lets ⌘Space, ⌘Tab and Mission Control reach the remote desktop instead of the
Mac while the stream has captured the keyboard -- the same thing every remote-desktop/VM app
offers. macOS delivers those keys to Spotlight/the Dock before any app, so the only way to
receive them is a keyboard event tap, which needs Accessibility. The prompt appears only when
the user turns the toggle on or presses "Allow Accessibility access…"; the tap exists only while
a stream has the keyboard captured and the app is frontmost, and it reads nothing -- keys are
handed to the app's own stream window, never logged or stored. Without the grant, the toggle
still works for the app's own ⌘ shortcuts and simply says the system ones need Accessibility.
- UIBackgroundModes "audio" (iPhone/iPad): a session carries real, audible audio from the host,
and this keeps it alive if the user steps away briefly. Backgrounded, video decoding stops, only
the real audio keeps rendering, and a bounded timer disconnects automatically. We never play
+18 -5
View File
@@ -370,15 +370,26 @@ from the config directory for a true factory reset."
.unwrap_or(DISCOVER_DEFAULT_SECS)
.min(DISCOVER_MAX_SECS);
let found = pf_client_core::discovery::discover_for(Duration::from_secs_f64(secs));
// `read`, not `load`: this verb only LOOKS at the records to annotate what it found, and
// never hands their ids back. `load` would mint ids for a pre-mint store and save them
// a write from a read-only verb, and one that races the `hosts list` a caller is very
// likely running at the same moment (the Decky panel issues both together).
// `read`, not `load`: this verb never hands a record's id back, so it has no business
// MINTING one. `load` would mint ids for a pre-mint store and save them, racing the
// `hosts list` a caller is very likely running at the same moment (the Decky panel issues
// both together) — after which the ids one of them already handed out no longer resolve.
let known = KnownHosts::read();
let rows: Vec<(
&pf_client_core::discovery::DiscoveredHost,
Option<&KnownHost>,
)> = found.iter().map(|d| (d, match_saved(&known, d))).collect();
// The one write this verb does make, and why it doesn't contradict the above: an advert
// is the only place a host's wake MAC is ever published, and this verb is the only one
// the Decky panel runs that ever sees one. Without it a Deck in Gaming Mode never learns
// a MAC at all and Wake-on-LAN cannot fire, with nothing to show for it (#322).
// `learn_from_advert` mints nothing either, and writes only when an advert genuinely
// taught the record something new — so a steady-state panel refresh touches no disk.
for (d, saved) in &rows {
if let Some(k) = saved {
trust::learn_from_advert(&k.fp_hex, &k.addr, k.port, &d.mac, &d.os, d.mgmt_port);
}
}
if has(args, "--json") {
let hosts: Vec<serde_json::Value> = rows
.iter()
@@ -733,7 +744,9 @@ from the config directory for a true factory reset."
};
let host = &known.hosts[i];
if host.mac.is_empty() {
eprintln!("no Wake-on-LAN address known for {} — connect to it once while it's awake so the client can learn it", host.name);
// A MAC is learned from the host's mDNS advert, never from a connect — say so, since
// "connect to it once" sent at least one Deck owner looking in the wrong place (#322).
eprintln!("no Wake-on-LAN address known for {} — run `punktfunk discover` while it's awake (the Deck panel does this every time it opens) so the client learns it from the host's advert", host.name);
return UNRESOLVED;
}
if !has(args, "--wait") {
+14 -27
View File
@@ -1087,33 +1087,20 @@ impl HostsPage {
// Online = advertising on mDNS OR proven reachable by the last probe sweep.
let online = self.adverts.values().any(|a| matches(k, a))
|| self.probed.get(&saved_key(k)).copied().unwrap_or(false);
// Learn this host's wake MAC(s) from its live advert while it's online.
if let Some(a) = self
.adverts
.values()
.find(|a| matches(k, a) && !a.mac.is_empty())
{
crate::trust::learn_mac(&k.fp_hex, &k.addr, k.port, &a.mac);
}
// Same for its OS chain — the icon then survives the host going offline.
if let Some(a) = self
.adverts
.values()
.find(|a| matches(k, a) && !a.os.is_empty())
{
crate::trust::learn_os(&k.fp_hex, &k.addr, k.port, &a.os);
}
// Same for its management port — and this one is not cosmetic: without it a host
// that moved off 47990 loses its library the moment mDNS is unavailable, because
// the advert was the only place the real port ever lived.
if let Some(a) = self
.adverts
.values()
.find(|a| matches(k, a) && a.mgmt_port.is_some())
{
if let Some(p) = a.mgmt_port {
crate::trust::learn_mgmt_port(&k.fp_hex, &k.addr, k.port, p);
}
// Learn what this host's live advert teaches while it's online: its wake MAC(s),
// its OS chain (so the icon survives it going offline), and its management port
// — the last one not cosmetic, since a host that moved off 47990 loses its
// library the moment mDNS is unavailable and the advert is the only place the
// real port ever lived.
if let Some(a) = self.adverts.values().find(|a| matches(k, a)) {
crate::trust::learn_from_advert(
&k.fp_hex,
&k.addr,
k.port,
&a.mac,
&a.os,
a.mgmt_port,
);
}
saved.push_back(HostCard {
connecting: self.connecting.as_deref() == Some(k.fp_hex.as_str()),
+30 -3
View File
@@ -53,9 +53,9 @@ use punktfunk_core::config::Role;
use punktfunk_core::input::{InputEvent, InputKind};
use punktfunk_core::packet::FLAG_PROBE;
use punktfunk_core::quic::{
endpoint, io, window_loss_ppm, BitrateChanged, CursorRenderMode, Hello, LossReport,
ProbeRequest, ProbeResult, Reconfigure, Reconfigured, RequestKeyframe, SetBitrate, Start,
Welcome,
endpoint, io, window_loss_ppm, BitrateChanged, CursorRenderMode, DeliveryReport, Hello,
LossReport, ProbeRequest, ProbeResult, Reconfigure, Reconfigured, RequestKeyframe, SetBitrate,
Start, Welcome,
};
use punktfunk_core::transport::UdpTransport;
use punktfunk_core::{CompositorPref, Mode, PunktfunkError, Session};
@@ -987,10 +987,18 @@ async fn session(args: Args) -> Result<()> {
let mut ls = send;
let lp = loss_ppm.clone();
let df = dropped_frames.clone();
// Delivery truth for the host's dead-data-plane check: report what actually landed on the
// wire, so the probe reproduces a real client's answer rather than the "cannot answer"
// sentinel — which is exactly what makes it usable for testing that path.
let rxp = rx_wire_packets.clone();
tokio::spawn(async move {
use std::sync::atomic::Ordering::Relaxed;
let mut last_report = std::time::Instant::now();
let mut last_dropped = 0u64;
// Mirrors the real clients' rule (see `pump/data.rs`): report the delivery count every
// window while it is zero, once when the first packets land, then stop — so a host that
// predates the message is not flooded with "unknown control message" on a good session.
let mut delivery_confirmed = false;
loop {
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
let d = df.load(Relaxed);
@@ -1007,6 +1015,25 @@ async fn session(args: Args) -> Result<()> {
if last_report.elapsed() >= std::time::Duration::from_millis(750) {
last_report = std::time::Instant::now();
let v = lp.swap(u32::MAX, Relaxed);
// Independent of whether there is a fresh loss sample: "no fresh sample" is
// exactly the shape a dead data plane has, so gating it on one would silence
// it in the state it exists to report.
let received = rxp.load(Relaxed);
if received == 0 || !delivery_confirmed {
delivery_confirmed = received > 0;
if io::write_msg(
&mut ls,
&DeliveryReport {
packets_received: received,
}
.encode(),
)
.await
.is_err()
{
break; // control stream gone
}
}
if v != u32::MAX
&& io::write_msg(&mut ls, &LossReport { loss_ppm: v }.encode())
.await
+52 -4
View File
@@ -82,6 +82,7 @@ pub fn run(target: Option<&str>) -> u8 {
.or_else(|| k.and_then(|h| h.mgmt_port))
.unwrap_or(library::DEFAULT_MGMT_PORT),
can_wake: false,
clipboard_sync: k.is_some_and(|h| h.clipboard_sync),
last_used: k.and_then(|h| h.last_used),
os: k.map(|h| h.os.clone()).unwrap_or_default(),
pin: None,
@@ -336,6 +337,7 @@ fn fake_host_row() -> HostRow {
online: true,
mgmt_port: library::DEFAULT_MGMT_PORT,
can_wake: false,
clipboard_sync: false,
last_used: None,
os: "linux/arch/steamos".into(),
pin: None,
@@ -698,6 +700,40 @@ impl ServiceState {
// `run` refreshes the rows right after this drain, so the carousel and
// the pin screen reflect the new card within the same service pass.
}
ConsoleCmd::BindProfile { key, profile_id } => {
// The BINDING half of the profile pair — `KnownHost::profile_id`, what a
// plain A-press on the primary tile connects with. `SetPin` above is the
// presentation half and never touches this field; this never touches the
// pins. Same store discipline, same refresh-after-drain.
let mut known = trust::KnownHosts::load();
let idx = index_for_key(&known, &key);
let Some(h) = idx.and_then(|i| known.hosts.get_mut(i)) else {
tracing::warn!(%key, "profile bind for an unknown host — ignoring");
return;
};
if h.profile_id != profile_id {
h.profile_id = profile_id;
if let Err(e) = known.save() {
tracing::warn!(error = %format!("{e:#}"), "saving known hosts");
}
}
}
ConsoleCmd::SetClipboard { key, on } => {
// Per-host clipboard trust (`KnownHost::clipboard_sync`) — the host
// menu's toggle. Same store discipline as the two arms above.
let mut known = trust::KnownHosts::load();
let idx = index_for_key(&known, &key);
let Some(h) = idx.and_then(|i| known.hosts.get_mut(i)) else {
tracing::warn!(%key, "clipboard toggle for an unknown host — ignoring");
return;
};
if h.clipboard_sync != on {
h.clipboard_sync = on;
if let Err(e) = known.save() {
tracing::warn!(error = %format!("{e:#}"), "saving known hosts");
}
}
}
}
}
@@ -764,11 +800,21 @@ impl ServiceState {
|| (d.addr == h.addr && d.port == h.port)
});
let online = advert.is_some() || probed.get(&key).copied().unwrap_or(false);
// Write the advertised mgmt port down while the host is visible, so this console
// keeps working against a moved port once it is not. No-op (and no disk write)
// Write down everything the advert teaches while the host is visible: the mgmt
// port (so this console keeps working against a moved one once it is not), the
// OS chain, and the wake MAC — which matters most here, because this console and
// the Decky panel are the only surfaces a Deck in Gaming Mode ever runs, and a
// record that never learned a MAC can never be woken. No-op (and no disk write)
// when unchanged, so this is safe on every refresh tick.
if let Some(p) = advert.and_then(|d| d.mgmt_port) {
pf_client_core::trust::learn_mgmt_port(&h.fp_hex, &h.addr, h.port, p);
if let Some(a) = advert {
pf_client_core::trust::learn_from_advert(
&h.fp_hex,
&h.addr,
h.port,
&a.mac,
&a.os,
a.mgmt_port,
);
}
let row = HostRow {
key: key.clone(),
@@ -787,6 +833,7 @@ impl ServiceState {
.or(h.mgmt_port)
.unwrap_or(library::DEFAULT_MGMT_PORT),
can_wake: !online && !h.mac.is_empty(),
clipboard_sync: h.clipboard_sync,
last_used: h.last_used,
os: advert
.filter(|d| !d.os.is_empty())
@@ -845,6 +892,7 @@ impl ServiceState {
online: true,
mgmt_port: d.mgmt_port.unwrap_or(library::DEFAULT_MGMT_PORT),
can_wake: false,
clipboard_sync: false,
last_used: None,
os: d.os.clone(),
pin: None,
+1 -29
View File
@@ -68,7 +68,7 @@ impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for RingLayer {
event.record(&mut v);
pf_client_core::logring::note(format!(
"{} {:5} {} {}",
wallclock(),
pf_client_core::logring::wallclock(),
meta.level().as_str(),
meta.target(),
v.0
@@ -76,34 +76,6 @@ impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for RingLayer {
}
}
/// `2026-08-15T12:03:47.123Z` from the system clock — wall time, so a bundle correlates with
/// the host log it lands next to. No chrono dep; same civil-date derivation the host uses.
fn wallclock() -> String {
let ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
let secs = (ms / 1000) as i64;
let days = secs.div_euclid(86_400);
let tod = secs.rem_euclid(86_400);
// Howard Hinnant's civil_from_days.
let z = days + 719_468;
let era = if z >= 0 { z } else { z - 146_096 }.div_euclid(146_097);
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let mo = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if mo <= 2 { y + 1 } else { y };
let (h, mi, s) = (tod / 3600, (tod % 3600) / 60, tod % 60);
format!(
"{y:04}-{mo:02}-{d:02}T{h:02}:{mi:02}:{s:02}.{:03}Z",
ms % 1000
)
}
#[cfg(test)]
mod tests {
use super::*;
+7 -4
View File
@@ -33,11 +33,14 @@ the fast **`punktfunk/1`** protocol.
hooks with Moonlight-style capture: Ctrl+Alt+Shift+Q releases the pointer, a click on the stream
re-captures it, and system shortcuts (Alt+Tab, Win, …) can act locally or forward to the host.
Builds and ships for both **x64** and **ARM64** as a signed **MSIX**.
Builds and ships for both **x64** and **ARM64**, three ways from one layout: a signed **installer**
(the default — a per-user setup.exe whose stable install path Steam can launch, so the Steam
overlay and Big Picture work), a **portable zip**, and a signed **MSIX** (kept for Microsoft Store
compatibility).
## Get it
Install the signed MSIX from the package registry — see
Install the signed installer from the package registry — see
**[docs.punktfunk.unom.io/docs/install-client](https://docs.punktfunk.unom.io/docs/install-client)**.
A stock [Moonlight](https://moonlight-stream.org/) client also works over GameStream if you prefer.
@@ -58,7 +61,7 @@ punktfunk-client --headless --speed-test --connect host[:port] # probe burst
```
> `CARGO_HOME` must be an ASCII path — non-ASCII characters break SDL3's MSVC precompiled-header
> build. Packaging (MSIX manifest, signing) lives in [`packaging/`](packaging/).
> build. Packaging (MSIX manifest, the Inno Setup installer, signing) lives in [`packaging/`](packaging/).
## Layout
@@ -79,7 +82,7 @@ src/
trust.rs · discovery.rs persistent identity, TOFU/PIN pairing, mDNS browse
probe.rs · wol.rs speed probe · Wake-on-LAN
logfile.rs log tee to %LOCALAPPDATA%
packaging/ MSIX manifest, signing, pack script
packaging/ MSIX manifest + Inno Setup installer, signing, pack scripts
```
## Manual smoke checklist
+24 -5
View File
@@ -1,11 +1,30 @@
# punktfunk Windows client — MSIX packaging
# punktfunk Windows client — packaging
The Windows client ships as **signed MSIX** packages so Windows boxes get a real package (Start
tile, clean install/uninstall) instead of a loose exe. CI builds + publishes them from
[`.gitea/workflows/windows-client.yml`](../../../.gitea/workflows/windows-client.yml) to Gitea's
The Windows client ships **three ways, packed from one assembled layout** by CI
([`.gitea/workflows/windows-client.yml`](../../../.gitea/workflows/windows-client.yml)) to Gitea's
**generic** package registry (`https://git.unom.io/unom/-/packages`), on every `main` push that
touches the client (canary) and on `vX.Y.Z` release tags (stable) — see
[Release Channels](https://punktfunk.unom.io/docs/channels).
[Release Channels](https://punktfunk.unom.io/docs/channels):
1. **Inno Setup installer** (`punktfunk-client-setup_<arch>.exe`) — the **default download**. A
per-user, no-UAC install to `%LOCALAPPDATA%\Programs\Punktfunk`. It exists because the MSIX
install shape breaks the top user-reported flows: the exe lands under the ACL'd
`C:\Program Files\WindowsApps`, which Steam's *Add a Non-Steam Game* picker can't browse, and
the alias/`shell:AppsFolder` activation defeats the Steam overlay's injection and Big Picture
launch — Steam must spawn the exe itself from a normal path. `punktfunk-client.iss` +
`pack-client-installer.ps1`; it re-creates the manifest's declarative grants per-user
(`punktfunk://` in HKCU Classes, Start shortcuts, `{app}` on the user PATH for the
`punktfunk` CLI) and fetches the Windows App Runtime when missing.
2. **Portable zip** (`punktfunk-client-windows_<arch>-portable.zip`) — the same signed file set,
nothing registered.
3. **Signed MSIX** (`punktfunk-client-windows_<arch>.msix`) — kept for **Microsoft Store**
compatibility. Everything below the fold documents this path.
`pack-msix.ps1` assembles the layout and packs the MSIX; `pack-client-installer.ps1` then consumes
that same `layout/` for the installer + zip (and signs the four exes individually — the MSIX only
signs its container).
# MSIX packaging
**Two architectures, one x64 runner.** Both `x64` and `arm64` packages are produced off the single
x64 Windows runner — `x86_64-pc-windows-msvc` builds natively, `aarch64-pc-windows-msvc` is
@@ -0,0 +1,246 @@
<#
.SYNOPSIS
Pack + sign the punktfunk Windows client as an Inno Setup setup.exe (the default download) and a
portable .zip, from the layout pack-msix.ps1 already assembled.
.DESCRIPTION
Runs AFTER pack-msix.ps1 in the same job and consumes its $OutDir\layout verbatim one assembly,
three artifacts (.msix, setup.exe, portable .zip). Why the installer exists at all: the MSIX
install shape (WindowsApps ACLs + alias-only activation) breaks Steam's non-Steam-game picker,
the Steam overlay's injection, and Big Picture launching — see punktfunk-client.iss's header.
Steps:
1. stage the runtime file set from -LayoutDir (drops AppxManifest.xml + the tile Assets),
2. sign the four exes individually (the MSIX only signs its container),
3. zip the stage -> the portable build,
4. ISCC punktfunk-client.iss over the same stage, sign the setup.exe,
5. emit CLIENT_SETUP_PATH / CLIENT_ZIP_PATH to GITHUB_ENV for the publish step.
Signing backend precedence is identical to pack-msix.ps1 / pack-host-installer.ps1 (Azure
Artifact Signing -> supplied .pfx -> ephemeral self-signed; fail closed on v* tags). No .cer is
exported here: unlike an MSIX, a plain exe RUNS regardless of signer trust an untrusted
signature only costs a SmartScreen warning, so canary self-signed builds need nothing imported.
.EXAMPLE
pwsh -File pack-client-installer.ps1 -Version 0.2.137.0 -Arch x64 `
-LayoutDir C:\t\msix\layout -OutDir C:\t\installer
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)][string]$Version, # 4-part numeric, same as the MSIX
[Parameter(Mandatory = $true)][string]$LayoutDir, # pack-msix.ps1's $OutDir\layout
[ValidateSet('x64', 'arm64')][string]$Arch = 'x64',
[string]$OutDir = (Join-Path (Split-Path -Parent $LayoutDir) 'installer'),
# Subject for the EPHEMERAL self-signed fallback only; Azure/pfx carry their own subjects.
[string]$Publisher = "CN=unom - Enrico B$([char]0xFC)hler, O=unom - Enrico B$([char]0xFC)hler, L=Rottweil, S=Baden-W$([char]0xFC)rttemberg, C=DE",
[string]$PfxBase64 = $env:MSIX_CERT_PFX_B64, # reuse the client's signing secret
[string]$PfxPassword = $env:MSIX_CERT_PASSWORD,
[string]$AzureEndpoint = $env:AZURE_CODESIGNING_ENDPOINT,
[string]$AzureAccount = $env:AZURE_CODESIGNING_ACCOUNT,
[string]$AzureProfile = $env:AZURE_CODESIGNING_PROFILE,
[string]$AzureDlib = $env:AZURE_CODESIGNING_DLIB,
[ValidateSet('auto', 'true', 'false')][string]$RequireSignedCert = 'auto',
[switch]$NoSign # skip signing (local debug)
)
$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'
# Keep the "check $LASTEXITCODE myself" model (see pack-host-installer.ps1): pwsh 7.4 must not
# turn a non-zero native exit into a terminating error before Sign-File's timestamp retry runs.
$PSNativeCommandUseErrorActionPreference = $false
if ($Version -notmatch '^\d+\.\d+\.\d+\.\d+$') {
throw "Version must be 4-part numeric (Major.Minor.Build.Revision); got '$Version'."
}
$here = Split-Path -Parent $MyInvocation.MyCommand.Path
$iss = Join-Path $here 'punktfunk-client.iss'
# --- locate ISCC (Inno Setup) + signtool (Windows SDK) — same finders as the sibling scripts ---
function Find-Iscc {
foreach ($p in @(
'C:\Program Files (x86)\Inno Setup 6\ISCC.exe',
'C:\Program Files\Inno Setup 6\ISCC.exe')) {
if (Test-Path $p) { return $p }
}
$c = Get-Command iscc -ErrorAction SilentlyContinue
if ($c) { return $c.Source }
throw "ISCC.exe (Inno Setup 6, any 6.x) not found - install it (choco install innosetup -y)."
}
function Find-SdkTool([string]$name) {
$root = 'C:\Program Files (x86)\Windows Kits\10\bin'
$hit = Get-ChildItem -Path $root -Recurse -Filter $name -ErrorAction SilentlyContinue |
Where-Object { $_.FullName -match '\\(10\.0\.\d+\.\d+)\\x64\\' } |
Sort-Object { [version]([regex]::Match($_.FullName, '\\(10\.0\.\d+\.\d+)\\x64\\').Groups[1].Value) } |
Select-Object -Last 1
if (-not $hit) { throw "$name not found under $root - install the Windows 10/11 SDK." }
$hit.FullName
}
function Find-AzureDlib([string]$Explicit) {
if ($Explicit) {
if (-not (Test-Path $Explicit)) { throw "AZURE_CODESIGNING_DLIB points at a missing file: $Explicit" }
return (Resolve-Path $Explicit).Path
}
$roots = @(
(Join-Path $env:USERPROFILE '.nuget\packages\microsoft.trusted.signing.client'),
'C:\trusted-signing\microsoft.trusted.signing.client'
) | Where-Object { $_ -and (Test-Path $_) }
$hit = $roots | ForEach-Object { Get-ChildItem -Path $_ -Recurse -Filter 'Azure.CodeSigning.Dlib.dll' -ErrorAction SilentlyContinue } |
Where-Object { $_.FullName -match '\\bin\\x64\\' } |
Sort-Object LastWriteTime | Select-Object -Last 1
if (-not $hit) {
throw ("Azure.CodeSigning.Dlib.dll not found. Install the signing client on this box, e.g. " +
"``nuget install Microsoft.Trusted.Signing.Client -OutputDirectory " +
"`$env:USERPROFILE\.nuget\packages``, or set AZURE_CODESIGNING_DLIB to its full path.")
}
$hit.FullName
}
$iscc = Find-Iscc
Write-Host "ISCC: $iscc"
# --- stage the runtime file set (the portable layout = what the installer lays down) ----------
# Explicit list, not a wildcard copy: the MSIX layout also holds AppxManifest.xml and the tile
# Assets, which mean nothing outside a package (the exes embed their icons via build.rs).
$required = @('punktfunk-client.exe', 'punktfunk-session.exe', 'punktfunk-console.exe', 'punktfunk.exe',
'Microsoft.WindowsAppRuntime.Bootstrap.dll', 'SDL3.dll', 'resources.pri')
$stage = Join-Path $OutDir 'portable'
if (Test-Path $stage) { Remove-Item $stage -Recurse -Force }
New-Item -ItemType Directory -Force -Path $stage | Out-Null
foreach ($f in $required) {
$src = Join-Path $LayoutDir $f
if (-not (Test-Path $src)) { throw "missing '$f' in $LayoutDir (did pack-msix.ps1 run first?)" }
Copy-Item $src (Join-Path $stage $f) -Force
}
$licSrc = Join-Path $LayoutDir 'licenses'
if (-not (Test-Path $licSrc)) { throw "missing licenses\ in $LayoutDir (did pack-msix.ps1 run first?)" }
Copy-Item $licSrc (Join-Path $stage 'licenses') -Recurse -Force
# --- signing backend, same precedence + fail-closed rule as pack-msix.ps1 ---------------------
$requireCert = if ($RequireSignedCert -eq 'auto') { $env:GITHUB_REF -like 'refs/tags/v*' }
else { [Convert]::ToBoolean($RequireSignedCert) }
if ($NoSign -and $requireCert) {
throw "release build ($env:GITHUB_REF) with -NoSign - refusing to publish an unsigned installer."
}
$pfxPath = Join-Path $OutDir 'signing.pfx'
$azureMetadata = Join-Path $OutDir 'azure-codesigning.json'
$signMode = 'none'
$signtool = $null
if (-not $NoSign) {
$signtool = Find-SdkTool 'signtool.exe'
Write-Host "signtool: $signtool"
if ($AzureEndpoint -and $AzureAccount -and $AzureProfile) {
$signMode = 'azure'
$AzureDlib = Find-AzureDlib $AzureDlib
@{
Endpoint = $AzureEndpoint
CodeSigningAccountName = $AzureAccount
CertificateProfileName = $AzureProfile
} | ConvertTo-Json | Set-Content -Path $azureMetadata -Encoding utf8
Write-Host "signing via Azure Artifact Signing: $AzureAccount/$AzureProfile at $AzureEndpoint"
foreach ($v in 'AZURE_TENANT_ID', 'AZURE_CLIENT_ID', 'AZURE_CLIENT_SECRET') {
if (-not [Environment]::GetEnvironmentVariable($v)) {
throw ("Azure signing selected but $v is not set. The dlib authenticates with " +
"DefaultAzureCredential; without the service-principal trio it falls through to " +
"an interactive login that cannot complete on a runner and hangs the build.")
}
}
}
elseif ($PfxBase64) {
$signMode = 'pfx'
Write-Host "signing with supplied code-signing cert (MSIX_CERT_PFX_B64)"
[IO.File]::WriteAllBytes($pfxPath, [Convert]::FromBase64String($PfxBase64))
}
elseif ($requireCert) {
throw ("release build ($env:GITHUB_REF) with neither AZURE_CODESIGNING_* nor MSIX_CERT_PFX_B64 - " +
"refusing to fall back to an ephemeral self-signed cert. Restore the signing secrets " +
"(packaging/windows/README.md), or pass -RequireSignedCert false if this really is a test build.")
}
else {
$signMode = 'selfsigned'
Write-Host "no MSIX_CERT_PFX_B64 -> generating an ephemeral self-signed cert (subject $Publisher)"
if (-not $PfxPassword) { $PfxPassword = 'punktfunk' }
$tmp = New-SelfSignedCertificate -Type Custom -Subject $Publisher `
-KeyUsage DigitalSignature -FriendlyName 'punktfunk client installer (self-signed)' `
-CertStoreLocation 'Cert:\CurrentUser\My' `
-TextExtension @('2.5.29.37={text}1.3.6.1.5.5.7.3.3', '2.5.29.19={text}')
$sec = ConvertTo-SecureString -String $PfxPassword -Force -AsPlainText
Export-PfxCertificate -Cert "Cert:\CurrentUser\My\$($tmp.Thumbprint)" -FilePath $pfxPath -Password $sec | Out-Null
Remove-Item "Cert:\CurrentUser\My\$($tmp.Thumbprint)" -Force
}
}
# Timestamp policy matches the sibling scripts: best-effort for a long-lived .pfx, MANDATORY under
# Azure signing (those leaf certs expire in ~3 days; untimestamped signatures die with them).
function Sign-File([string]$Path) {
if ($NoSign) { return }
if ($signMode -eq 'azure') {
$signArgs = @('sign', '/fd', 'SHA256', '/dlib', $AzureDlib, '/dmdf', $azureMetadata)
$ts = 'http://timestamp.acs.microsoft.com'
}
else {
$signArgs = @('sign', '/fd', 'SHA256', '/f', $pfxPath)
if ($PfxPassword) { $signArgs += @('/p', $PfxPassword) }
$ts = 'http://timestamp.digicert.com'
}
& $signtool ($signArgs + @('/tr', $ts, '/td', 'SHA256', $Path))
if ($LASTEXITCODE -eq 0) { return }
if ($signMode -eq 'azure') {
throw ("timestamped sign failed for $Path ($LASTEXITCODE) - NOT retrying without a timestamp. " +
"An Azure signing cert is valid for ~3 days; an untimestamped signature would go " +
"untrusted within days of release.")
}
Write-Warning "timestamped sign failed for $Path - retrying without a timestamp"
& $signtool ($signArgs + @($Path))
if ($LASTEXITCODE -ne 0) { throw "signtool sign failed for $Path ($LASTEXITCODE)" }
}
# --- sign the inner exes, zip the stage (portable build), then build + sign the installer ------
foreach ($f in $required | Where-Object { $_ -like '*.exe' }) {
Sign-File (Join-Path $stage $f)
}
$zip = Join-Path $OutDir "punktfunk-client-windows_${Version}_${Arch}-portable.zip"
if (Test-Path $zip) { Remove-Item $zip -Force }
Compress-Archive -Path (Join-Path $stage '*') -DestinationPath $zip
Write-Host "==> portable zip: $zip"
# Stage the .iss + branding next to each other under $OutDir: ISCC is a 32-bit process, and on the
# SYSTEM-profile runner WOW64 redirection breaks reads from the checkout path (see
# pack-host-installer.ps1's staging note) — everything ISCC touches must live under C:\t.
$issLocal = Join-Path $OutDir 'punktfunk-client.iss'
Copy-Item -LiteralPath $iss -Destination $issLocal -Force
$brandSrc = (Resolve-Path (Join-Path $here '..\..\..\packaging\windows\branding')).Path
$brandStage = Join-Path $OutDir 'branding'
if (Test-Path $brandStage) { Remove-Item $brandStage -Recurse -Force }
New-Item -ItemType Directory -Force -Path $brandStage | Out-Null
Copy-Item (Join-Path $brandSrc '*.bmp') $brandStage -Force
Copy-Item (Join-Path $brandSrc 'punktfunk.ico') $brandStage -Force
$defines = @(
"/DMyAppVersion=$Version",
"/DArch=$Arch",
"/DLayoutDir=$stage",
"/DBrandingDir=$brandStage",
"/DOutputDir=$OutDir"
)
Write-Host "==> ISCC $($defines -join ' ') $issLocal"
& $iscc @defines $issLocal
if ($LASTEXITCODE -ne 0) { throw "ISCC failed ($LASTEXITCODE)" }
$setup = Join-Path $OutDir "punktfunk-client-setup-${Version}_${Arch}.exe"
if (-not (Test-Path $setup)) { throw "expected installer not produced: $setup" }
Sign-File $setup
Remove-Item $pfxPath -Force -ErrorAction SilentlyContinue
Remove-Item $azureMetadata -Force -ErrorAction SilentlyContinue
Write-Host ""
Write-Host "==> installer: $setup"
if ($signMode -eq 'azure') {
Write-Host "==> signed by a publicly trusted CA."
}
elseif ($signMode -ne 'none') {
Write-Host "==> $signMode-signed: the exe still runs everywhere; expect a SmartScreen prompt on canary builds."
}
if ($env:GITHUB_ENV) {
"CLIENT_SETUP_PATH=$setup" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
"CLIENT_ZIP_PATH=$zip" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
}
@@ -0,0 +1,244 @@
; punktfunk Windows CLIENT installer (Inno Setup 6) — the default download.
;
; A classic per-user setup.exe, NOT because MSIX failed technically (the app is full-trust Win32
; either way) but because the MSIX install SHAPE breaks the most-reported use case: the exe lands
; under the ACL'd C:\Program Files\WindowsApps, which Steam's "Add a Non-Steam Game" picker cannot
; browse and whose activation path defeats the overlay's GameOverlayRenderer64.dll injection —
; Steam has to spawn the process itself from a normal path for the overlay (and a Big Picture
; launch) to work. This installs to {userpf}\Punktfunk: user-writable-visible, no UAC, and a
; stable path Steam can target. The MSIX is kept for Microsoft Store compatibility
; (clients/windows/packaging/pack-msix.ps1 — both are packed from the same layout every build).
;
; Built by pack-client-installer.ps1, e.g.:
; ISCC.exe /DMyAppVersion=0.2.137.0 /DArch=x64 /DLayoutDir=C:\t\installer\portable \
; /DBrandingDir=C:\t\installer\branding /DOutputDir=C:\t\installer punktfunk-client.iss
;
; What the MSIX manifest granted declaratively is re-created here per-user (all HKCU, so no
; elevation and uninstall leaves nothing behind):
; punktfunk:// protocol -> HKCU\Software\Classes\punktfunk (deeplink.rs positional parse)
; Start entries -> {userprograms} shortcuts (Punktfunk + Punktfunk Console)
; punktfunk.exe CLI alias -> {app} appended to the HKCU PATH (Playnite importer shells to it)
; punktfunk-client.exe alias -> unnecessary: deeplink.rs targets current_exe() when unpackaged
; Microsoft.WindowsAppRuntime.2 PackageDependency
; -> download + run the runtime installer when missing ([Code])
#ifndef MyAppVersion
#define MyAppVersion "0.0.0.0"
#endif
#ifndef Arch
#define Arch "x64"
#endif
#ifndef LayoutDir
#define LayoutDir "."
#endif
#ifndef BrandingDir
#define BrandingDir "..\..\..\packaging\windows\branding"
#endif
#ifndef OutputDir
#define OutputDir "."
#endif
; The unpackaged app resolves an INSTALLED Windows App SDK runtime via the bootstrap DLL
; (windows-reactor pins WINDOWSAPPSDK_RELEASE_MAJORMINOR = 0x20000; the MSIX manifest's
; PackageDependency floor is 2.2 — keep the two in sync with packaging/AppxManifest.xml).
#define AppRuntimeUrl "https://aka.ms/windowsappsdk/2.2/latest/windowsappruntimeinstall-" + Arch + ".exe"
[Setup]
AppId={{52464E61-68A1-4621-B6B3-5B8BBB823D1A}
AppName=Punktfunk
AppVersion={#MyAppVersion}
AppPublisher=unom
AppPublisherURL=https://git.unom.io/unom/punktfunk
; Per-user, no UAC: {userpf} = %LOCALAPPDATA%\Programs. A browsable, stable path is the point —
; see the header (Steam overlay / Big Picture).
DefaultDirName={userpf}\Punktfunk
PrivilegesRequired=lowest
DisableProgramGroupPage=yes
UsePreviousAppDir=yes
; Same floor as the MSIX manifest's TargetDeviceFamily MinVersion (10.0.17763).
MinVersion=10.0.17763
#if Arch == "arm64"
ArchitecturesAllowed=arm64
ArchitecturesInstallIn64BitMode=arm64
#else
ArchitecturesAllowed=x64
ArchitecturesInstallIn64BitMode=x64
#endif
OutputDir={#OutputDir}
OutputBaseFilename=punktfunk-client-setup-{#MyAppVersion}_{#Arch}
Compression=lzma2/max
SolidCompression=yes
; Modern branded wizard, same version gate as the host installer (punktfunk-host.iss).
#if VER >= EncodeVer(6,6,0)
WizardStyle=modern dynamic windows11
#else
WizardStyle=modern
#endif
SetupIconFile={#BrandingDir}\punktfunk.ico
WizardImageFile={#BrandingDir}\wizard-image-*.bmp
WizardSmallImageFile={#BrandingDir}\wizard-small-*.bmp
UninstallDisplayName=Punktfunk {#MyAppVersion}
UninstallDisplayIcon={app}\punktfunk-client.exe
; {app} goes on the USER PATH (see [Registry] + PathNeedsAdd/RemoveAppFromPath below) so the
; documented `punktfunk hosts list` / `punktfunk launch` one-liners work by name — same contract
; the MSIX's punktfunk.exe app-execution alias provided. Broadcasts WM_SETTINGCHANGE.
ChangesEnvironment=yes
[Languages]
Name: "english"; MessagesFile: "compiler:Default.isl"
[Tasks]
Name: "desktopicon"; Description: "Create a Desktop shortcut"; Flags: unchecked
[Files]
; The staged MSIX layout, minus the package-only bits (AppxManifest.xml, the tile Assets — the
; exes embed their own icons via build.rs winresource). pack-client-installer.ps1 signs the four
; exes individually before ISCC runs; the .msix signs only its container, so this cannot be
; skipped by "the MSIX build already signed them".
Source: "{#LayoutDir}\punktfunk-client.exe"; DestDir: "{app}"; Flags: ignoreversion
Source: "{#LayoutDir}\punktfunk-session.exe"; DestDir: "{app}"; Flags: ignoreversion
Source: "{#LayoutDir}\punktfunk-console.exe"; DestDir: "{app}"; Flags: ignoreversion
Source: "{#LayoutDir}\punktfunk.exe"; DestDir: "{app}"; Flags: ignoreversion
Source: "{#LayoutDir}\Microsoft.WindowsAppRuntime.Bootstrap.dll"; DestDir: "{app}"; Flags: ignoreversion
Source: "{#LayoutDir}\SDL3.dll"; DestDir: "{app}"; Flags: ignoreversion
Source: "{#LayoutDir}\resources.pri"; DestDir: "{app}"; Flags: ignoreversion
; MIT/Apache + the client-scoped THIRD-PARTY-NOTICES — same payload the MSIX carries.
Source: "{#LayoutDir}\licenses\*"; DestDir: "{app}\licenses"; Flags: ignoreversion
[Icons]
; Flat Start-menu entries, mirroring the MSIX's two Application tiles.
Name: "{userprograms}\Punktfunk"; Filename: "{app}\punktfunk-client.exe"
Name: "{userprograms}\Punktfunk Console"; Filename: "{app}\punktfunk-console.exe"; \
Comment: "Controller-driven couch interface for TVs and HTPCs"
Name: "{userdesktop}\Punktfunk"; Filename: "{app}\punktfunk-client.exe"; Tasks: desktopicon
[Registry]
; The punktfunk:// scheme (design/client-deep-links.md §4.2) — the registry twin of the MSIX
; manifest's windows.protocol extension. Protocol activation delivers the URI as "%1" on the
; command line, so this lands in the same positional URL parse in main() that the packaged
; activation does. HKCU + uninsdeletekey: nothing survives uninstall.
Root: HKCU; Subkey: "Software\Classes\punktfunk"; ValueType: string; \
ValueData: "URL:Punktfunk stream link"; Flags: uninsdeletekey
Root: HKCU; Subkey: "Software\Classes\punktfunk"; ValueType: string; ValueName: "URL Protocol"; ValueData: ""
Root: HKCU; Subkey: "Software\Classes\punktfunk\DefaultIcon"; ValueType: string; \
ValueData: "{app}\punktfunk-client.exe,0"
Root: HKCU; Subkey: "Software\Classes\punktfunk\shell\open\command"; ValueType: string; \
ValueData: """{app}\punktfunk-client.exe"" ""%1"""
; Put {app} on the USER PATH so `punktfunk` (the headless CLI) is runnable by name. Appended to
; {olddata} and guarded by PathNeedsAdd so a repair/upgrade never appends a duplicate. NOT
; uninsdeletevalue — that would delete the whole Path value; the uninstaller surgically removes
; just our entry (RemoveAppFromPath). expandsz preserves %VAR%-style entries other software put here.
Root: HKCU; Subkey: "Environment"; ValueType: expandsz; ValueName: "Path"; \
ValueData: "{olddata};{app}"; Check: PathNeedsAdd(ExpandConstant('{app}'))
[Code]
const
EnvKey = 'Environment'; { the HKCU per-user environment key }
{ Is the install dir missing from the user PATH? Guards the [Registry] append so a repair or
upgrade can't add a second copy. Semicolon-delimited, case-insensitive — a path that merely
CONTAINS ours as a substring doesn't count as a match. (Same helper as punktfunk-host.iss,
retargeted from the HKLM machine key to HKCU.) }
function PathNeedsAdd(Param: String): Boolean;
var
OrigPath: String;
begin
if not RegQueryStringValue(HKEY_CURRENT_USER, EnvKey, 'Path', OrigPath) then
begin
Result := True; { no Path value at all - the append creates it }
exit;
end;
Result := Pos(';' + Uppercase(Param) + ';', ';' + Uppercase(OrigPath) + ';') = 0;
end;
{ Remove exactly our install-dir entry from the user PATH on uninstall, leaving every other entry
(and their order) intact. Entry-by-entry rebuild, never a substring delete. }
procedure RemoveAppFromPath;
var
OrigPath, NewPath, Entry: String;
Target: String;
P: Integer;
begin
if not RegQueryStringValue(HKEY_CURRENT_USER, EnvKey, 'Path', OrigPath) then
exit;
Target := Uppercase(ExpandConstant('{app}'));
NewPath := '';
OrigPath := OrigPath + ';';
repeat
P := Pos(';', OrigPath);
Entry := Trim(Copy(OrigPath, 1, P - 1));
OrigPath := Copy(OrigPath, P + 1, Length(OrigPath));
if (Entry <> '') and (Uppercase(Entry) <> Target) then
begin
if NewPath <> '' then NewPath := NewPath + ';';
NewPath := NewPath + Entry;
end;
until OrigPath = '';
RegWriteExpandStringValue(HKEY_CURRENT_USER, EnvKey, 'Path', NewPath);
end;
procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep);
begin
if CurUninstallStep = usPostUninstall then
RemoveAppFromPath;
end;
{ The Windows App SDK runtime the bootstrap DLL resolves at launch (the unpackaged twin of the
MSIX's PackageDependency). Probe per-user via Get-AppxPackage; when missing, fetch Microsoft's
runtime installer and run it quietly — it registers Store-signed framework packages, which
needs no elevation. Every failure path is NON-FATAL and ends in the same message the docs
carry, because the app itself reports the missing runtime on first launch too. }
function AppRuntimeMissing(): Boolean;
var
ResultCode: Integer;
begin
{ exit 0 = found, 1 = missing; a powershell failure (rc <> 0/1) counts as missing - the
download below is idempotent and the runtime installer no-ops when it is present. }
if not Exec('powershell.exe',
'-NoProfile -ExecutionPolicy Bypass -Command "if (Get-AppxPackage -Name Microsoft.WindowsAppRuntime.2*) { exit 0 } else { exit 1 }"',
'', SW_HIDE, ewWaitUntilTerminated, ResultCode) then
begin
Result := True;
exit;
end;
Result := ResultCode <> 0;
end;
procedure EnsureAppRuntime;
var
ResultCode: Integer;
Installer: String;
begin
if not AppRuntimeMissing() then
exit;
Installer := 'windowsappruntimeinstall.exe';
try
DownloadTemporaryFile('{#AppRuntimeUrl}', Installer, '', nil);
if not Exec(ExpandConstant('{tmp}\' + Installer), '--quiet', '',
SW_HIDE, ewWaitUntilTerminated, ResultCode) or (ResultCode <> 0) then
RaiseException('runtime installer exit code ' + IntToStr(ResultCode));
except
SuppressibleMsgBox(
'The Windows App Runtime 2.x could not be installed automatically.' + #13#10 + #13#10 +
'Punktfunk needs it to start. Install it from ' + #13#10 +
'https://learn.microsoft.com/windows/apps/windows-app-sdk/downloads' + #13#10 +
'and then launch Punktfunk normally.',
mbInformation, MB_OK, IDOK);
end;
end;
procedure CurStepChanged(CurStep: TSetupStep);
var
ResultCode: Integer;
begin
{ On upgrade a running shell/stream locks the exes; kill them best-effort so the copy succeeds.
taskkill matches the image NAME, so "punktfunk.exe" hits only the CLI, not the host service. }
if CurStep = ssInstall then
Exec(ExpandConstant('{sys}\taskkill.exe'),
'/F /IM punktfunk-client.exe /IM punktfunk-session.exe /IM punktfunk-console.exe /IM punktfunk.exe',
'', SW_HIDE, ewWaitUntilTerminated, ResultCode);
{ ssPostInstall, NOT a wizard-page hook: silent installs (winget-style /VERYSILENT) show no
pages, and skipping the runtime there would ship an app that cannot start. This step runs on
every install mode, and SuppressibleMsgBox keeps the failure path unattended-safe. }
if CurStep = ssPostInstall then
EnsureAppRuntime;
end;
+15 -23
View File
@@ -700,31 +700,23 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
.iter()
.any(|h| h.fp_hex == k.fp_hex || (h.addr == k.addr && h.port == k.port))
|| props.probed.get(&k.fp_hex).copied().unwrap_or(false);
// Learn this host's wake MAC(s) from its live advert while it's online, so we can wake
// it once it sleeps (no-op / no disk write when unchanged).
if let Some(a) = hosts.iter().find(|h| {
(h.fp_hex == k.fp_hex || (h.addr == k.addr && h.port == k.port))
&& !h.mac.is_empty()
}) {
crate::trust::learn_mac(&k.fp_hex, &k.addr, k.port, &a.mac);
}
// Same for its OS chain — the tile's mark then survives the host going offline.
if let Some(a) = hosts.iter().find(|h| {
(h.fp_hex == k.fp_hex || (h.addr == k.addr && h.port == k.port)) && !h.os.is_empty()
}) {
crate::trust::learn_os(&k.fp_hex, &k.addr, k.port, &a.os);
}
// Same for its management port — load-bearing, unlike the two above: a host moved off
// 47990 loses its library entirely once mDNS is gone unless we write the port down.
if let Some(p) = hosts
// Learn what this host's live advert teaches while it's online: its wake MAC(s) (so we
// can wake it once it sleeps), its OS chain (so the tile's mark survives it going
// offline), and its management port — the last load-bearing rather than cosmetic, as
// a host moved off 47990 loses its library entirely once mDNS is gone unless we write
// the port down. No-op, and no disk write, when unchanged.
if let Some(a) = hosts
.iter()
.find(|h| {
(h.fp_hex == k.fp_hex || (h.addr == k.addr && h.port == k.port))
&& h.mgmt_port.is_some()
})
.and_then(|h| h.mgmt_port)
.find(|h| h.fp_hex == k.fp_hex || (h.addr == k.addr && h.port == k.port))
{
crate::trust::learn_mgmt_port(&k.fp_hex, &k.addr, k.port, p);
crate::trust::learn_from_advert(
&k.fp_hex,
&k.addr,
k.port,
&a.mac,
&a.os,
a.mgmt_port,
);
}
let can_wake = !online && !k.mac.is_empty();
let menu = {
+32 -7
View File
@@ -203,14 +203,30 @@ pub(crate) fn queue(url: String) {
INBOX.lock().unwrap().push(url);
}
/// Whether this process runs with MSIX package identity. Decides how a shortcut must target us
/// (`write_shortcut` below) and whether the process may stamp its own AppUserModelID
/// (`set_app_user_model_id` in main.rs).
pub(crate) fn has_package_identity() -> bool {
use windows::Win32::appmodel::GetCurrentPackageFullName;
use windows::Win32::winerror::APPMODEL_ERROR_NO_PACKAGE;
// SAFETY: `GetCurrentPackageFullName` with `len = 0` and no buffer is the documented identity
// PROBE — it writes nothing and only reports whether this process is packaged.
unsafe {
let mut len: u32 = 0;
GetCurrentPackageFullName(&mut len, None) != APPMODEL_ERROR_NO_PACKAGE
}
}
/// Write a `.lnk` on the Desktop that launches this URL, and return its path.
///
/// The shortcut targets the app execution alias with the URL as an ARGUMENT, rather than being
/// a `.url` internet shortcut. Both would work while the scheme is registered; only this one
/// still works if it isn't, because it invokes the client directly — which is the whole point
/// of a shortcut being a container for a URL rather than a second launch mechanism
/// (design/client-deep-links.md §5). Targeting the alias (not the package path) is what keeps
/// it valid across updates, since the install path changes and the alias doesn't.
/// The shortcut targets the client exe with the URL as an ARGUMENT, rather than being a `.url`
/// internet shortcut. Both would work while the scheme is registered; only this one still works
/// if it isn't, because it invokes the client directly — which is the whole point of a shortcut
/// being a container for a URL rather than a second launch mechanism
/// (design/client-deep-links.md §5). Which exe reference is durable depends on how we were
/// installed: under MSIX the install path changes on every update but the app execution alias
/// doesn't, so packaged runs target the alias; the Inno Setup / portable installs have no alias
/// but a stable install dir, so unpackaged runs target the absolute exe path.
pub(crate) fn write_shortcut(label: &str, url: &str) -> Result<std::path::PathBuf, String> {
use windows::core::{Interface, HSTRING};
use windows::Win32::combaseapi::{CoCreateInstance, CoInitializeEx};
@@ -223,6 +239,15 @@ pub(crate) fn write_shortcut(label: &str, url: &str) -> Result<std::path::PathBu
.map(|p| std::path::PathBuf::from(p).join("Desktop"))
.map_err(|_| "USERPROFILE isn't set".to_string())?;
let path = desktop.join(format!("{}.lnk", file_name(label)));
// Alias when packaged, absolute path when not — see the doc comment above.
let target = if has_package_identity() {
"punktfunk-client.exe".to_string()
} else {
std::env::current_exe()
.map_err(|e| format!("current exe: {e}"))?
.to_string_lossy()
.into_owned()
};
// SAFETY: COM calls on this thread's apartment. `CoCreateInstance` returns an owned interface
// checked by `?`, and every setter below takes a borrowed `HSTRING`/`PCWSTR` that outlives its
// synchronous call; nothing here dereferences a pointer the caller supplied.
@@ -233,7 +258,7 @@ pub(crate) fn write_shortcut(label: &str, url: &str) -> Result<std::path::PathBu
let _ = CoInitializeEx(None, COINIT_APARTMENTTHREADED as u32);
let link: IShellLinkW = CoCreateInstance(&ShellLink, None, CLSCTX_INPROC_SERVER)
.map_err(|e| format!("shell link: {e}"))?;
link.SetPath(&HSTRING::from("punktfunk-client.exe"))
link.SetPath(&HSTRING::from(target.as_str()))
.ok()
.map_err(|e| format!("shortcut target: {e}"))?;
link.SetArguments(&HSTRING::from(url))
+1 -1
View File
@@ -29,7 +29,7 @@ pub struct DiscoveredHost {
/// persisted like `mac`. Empty if absent (older host).
pub os: String,
/// The management API's port from the mDNS `mgmt` TXT — where the game library is served.
/// Persisted like `mac` (`trust::learn_mgmt_port`), and load-bearing rather than cosmetic:
/// Persisted like `mac` (`trust::learn_from_advert`), and load-bearing rather than cosmetic:
/// a host moved off 47990 loses its library once mDNS is gone unless we write this down.
/// `None` if absent (older host) — resolve via `library::DEFAULT_MGMT_PORT`.
pub mgmt_port: Option<u16>,
+4 -10
View File
@@ -173,18 +173,12 @@ fn main() {
/// processes are left alone. Must run before any window exists.
#[cfg(windows)]
fn set_app_user_model_id() {
use windows::Win32::appmodel::GetCurrentPackageFullName;
use windows::Win32::shobjidl_core::SetCurrentProcessExplicitAppUserModelID;
use windows::Win32::winerror::APPMODEL_ERROR_NO_PACKAGE;
// SAFETY: `GetCurrentPackageFullName` is called with `len = 0` and no buffer, which is the
// documented identity PROBE — it writes nothing and only reports whether this process is
// packaged; `SetCurrentProcessExplicitAppUserModelID` takes a static wide literal.
if deeplink::has_package_identity() {
return; // packaged (or indeterminate) — leave the identity alone
}
// SAFETY: `SetCurrentProcessExplicitAppUserModelID` takes a static wide literal.
unsafe {
let mut len: u32 = 0;
// No buffer: just probe whether the process has package identity.
if GetCurrentPackageFullName(&mut len, None) != APPMODEL_ERROR_NO_PACKAGE {
return; // packaged (or indeterminate) — leave the identity alone
}
// Must stay in sync with pf-presenter's win32.rs, or the windows stop grouping.
let _ = SetCurrentProcessExplicitAppUserModelID(windows::core::w!("unom.punktfunk.client"));
}
+2 -2
View File
@@ -8,6 +8,6 @@
//! still load via a serde alias in core.
pub use pf_client_core::trust::{
hex, learn_mac, learn_mgmt_port, learn_os, load_or_create_identity, pair_error_message,
parse_hex32, KnownHost, KnownHosts, Settings,
hex, learn_from_advert, load_or_create_identity, pair_error_message, parse_hex32, KnownHost,
KnownHosts, Settings,
};
+6
View File
@@ -67,6 +67,12 @@ pub enum PointerInput {
x: f32,
y: f32,
button: PointerButton,
/// A finger (or stylus) on the glass, as opposed to a mouse button. The console
/// defers a touch press until the lift so a swipe can scroll instead of acting on
/// whatever the finger first lands on; a mouse press keeps acting immediately.
/// Only `Down` carries it — the console tracks the gesture it opened, so the
/// matching `Up`/`Move`/`Cancel` need no flag of their own.
touch: bool,
},
Up {
x: f32,
+5 -1
View File
@@ -63,7 +63,11 @@ pub mod library;
// Per-host catalog cache, so a library screen has titles to show while a sleeping host boots.
#[cfg(any(target_os = "linux", windows))]
pub mod library_cache;
#[cfg(any(target_os = "linux", windows))]
// Android-enabled for the RING half (note/render — std only): the client's "Send logs to
// host" needs the ring on every platform. The `send_to_host` uploader inside stays
// desktop-gated with the rest of the ureq fetches; Android posts the rendered bundle
// through its own mTLS OkHttp client (`SkiaConsole.sendLogs`).
#[cfg(any(target_os = "linux", windows, target_os = "android"))]
pub mod logring;
// The `punktfunk://` grammar (design/client-deep-links.md §2): one parser/emitter for the
// shells, the session and the CLI, held to the Swift/Kotlin ports by a shared vector file.
+31
View File
@@ -55,6 +55,36 @@ pub fn note(mut line: String) {
}
}
/// `2026-08-15T12:03:47.123Z` from the system clock — wall time, so a bundle correlates with
/// the host log it lands next to. No chrono dep; same civil-date derivation the host uses.
/// Lives here (not in a shell) because every ring FEEDER wants the same stamp: the session's
/// `ring_layer` and the Android client's logcat tee both prefix their lines with it.
pub fn wallclock() -> String {
let ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
let secs = (ms / 1000) as i64;
let days = secs.div_euclid(86_400);
let tod = secs.rem_euclid(86_400);
// Howard Hinnant's civil_from_days.
let z = days + 719_468;
let era = if z >= 0 { z } else { z - 146_096 }.div_euclid(146_097);
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let mo = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if mo <= 2 { y + 1 } else { y };
let (h, mi, s) = (tod / 3600, (tod % 3600) / 60, tod % 60);
format!(
"{y:04}-{mo:02}-{d:02}T{h:02}:{mi:02}:{s:02}.{:03}Z",
ms % 1000
)
}
/// The ring rendered as one text bundle, oldest first, prefixed by `header` (the shell's own
/// identity line — binary name, version, platform) and an eviction note when the ring wrapped.
pub fn render(header: &str) -> String {
@@ -79,6 +109,7 @@ pub fn render(header: &str) -> String {
/// trust as the library fetch: TLS client auth with the device identity, host pinned by
/// fingerprint. Errors reuse the library's classification (401/403 ⇒ `NotPaired`, a pin-verifier
/// rejection ⇒ `PinMismatch`), so the shell's existing error strings apply.
#[cfg(any(target_os = "linux", windows))]
pub fn send_to_host(
addr: &str,
mgmt_port: u16,
+82 -54
View File
@@ -675,10 +675,10 @@ pub fn forget_placeholder(addr: &str, port: u16) {
}
}
/// The record [`learn_mac`]/[`learn_os`] should write what an advert taught them onto:
/// the fingerprint match if there is one, else whatever the address resolves to. Fingerprint
/// FIRST — a single pass that took "either" would hand a stale record at the same address the
/// data the live host advertised, purely because it came earlier in the file.
/// The record an advert's lesson should land on: the fingerprint match if there is one, else
/// whatever the address resolves to. Fingerprint FIRST — a single pass that took "either" would
/// hand a stale record at the same address the data the live host advertised, purely because it
/// came earlier in the file.
fn learn_target<'a>(
known: &'a mut KnownHosts,
fp_hex: &str,
@@ -692,61 +692,62 @@ fn learn_target<'a>(
known.hosts.get_mut(i)
}
/// Learn/refresh a saved host's Wake-on-LAN MAC(s) from its live advert (called while the host
/// is online, matched by fingerprint or address). No-op — and no disk write — when unchanged, so
/// the hosts page can call it on every discovery tick without churning the store.
pub fn learn_mac(fp_hex: &str, addr: &str, port: u16, mac: &[String]) {
if mac.is_empty() {
return;
}
let mut known = KnownHosts::load();
let Some(h) = learn_target(&mut known, fp_hex, addr, port) else {
return;
};
if h.mac == mac {
return;
}
h.mac = mac.to_vec();
let _ = known.save();
}
/// Learn/refresh a saved host's OS-identity chain from its live advert (mDNS `os` TXT), matched
/// like [`learn_mac`]: by fingerprint or address. No-op — and no disk write — when unchanged, so
/// the hosts page can call it on every discovery tick without churning the store.
pub fn learn_os(fp_hex: &str, addr: &str, port: u16, os: &str) {
if os.is_empty() {
return;
}
let mut known = KnownHosts::load();
let Some(h) = learn_target(&mut known, fp_hex, addr, port) else {
return;
};
if h.os == os {
return;
}
h.os = os.to_string();
let _ = known.save();
}
/// Learn/refresh a saved host's management-API port from its live advert (mDNS `mgmt` TXT),
/// matched like [`learn_mac`]: by fingerprint or address. No-op — and no disk write — when
/// unchanged, so the hosts page can call it on every discovery tick without churning the store.
/// Copy everything an advert can teach onto a saved record — wake MAC(s), OS-identity chain,
/// management port — and report whether anything actually moved, so the caller writes only when
/// there is something to write. Pure (no disk, no clock), which is what makes it testable.
///
/// This is what makes a moved mgmt port outlive mDNS. Until it existed the port was read straight
/// off the live advert and thrown away, so the library worked on the LAN and went blank over a VPN.
pub fn learn_mgmt_port(fp_hex: &str, addr: &str, port: u16, mgmt_port: u16) {
if mgmt_port == 0 {
return;
/// A field the advert does not carry is left alone, never cleared: an older host simply omits the
/// TXT, and forgetting a MAC already learned would cost the user their wake.
fn apply_advert(h: &mut KnownHost, mac: &[String], os: &str, mgmt_port: Option<u16>) -> bool {
let mut changed = false;
if !mac.is_empty() && h.mac != mac {
h.mac = mac.to_vec();
changed = true;
}
let mut known = KnownHosts::load();
if !os.is_empty() && h.os != os {
h.os = os.to_string();
changed = true;
}
// 0 is how "not advertised" reaches us from a caller whose own type has no `Option`.
if mgmt_port.is_some_and(|p| p != 0 && h.mgmt_port != Some(p)) {
h.mgmt_port = mgmt_port;
changed = true;
}
changed
}
/// Write down everything a live advert teaches the saved record it matched — wake MAC(s), OS
/// chain, management port — matched by fingerprint or address. No-op, and no disk write, when
/// the record already says all three, so a surface can call this on every discovery tick.
///
/// ONE call rather than three. Each field used to be learned by its own function, which meant
/// every front-end had to remember all three, and only the two desktop hosts pages ever did:
/// the console home and the headless CLI learned the management port alone. On a Steam Deck,
/// whose Gaming Mode runs nothing but those two, that left every saved host with no MAC forever
/// — and every wake gate in the codebase reads `!mac.is_empty()` against this record, so
/// Wake-on-LAN there could not fire at all, with no error to show for it (#322).
///
/// [`KnownHosts::read`], not [`KnownHosts::load`]: `punktfunk discover` calls this, and that verb
/// is deliberately not an id-minter (see [`KnownHosts::read`] for the race that avoids). Learning
/// a MAC is no reason to become one.
///
/// Takes the three learned fields rather than a `DiscoveredHost` because there are two of those
/// — core's and the WinUI shell's verbatim port — and this has to serve both.
pub fn learn_from_advert(
fp_hex: &str,
addr: &str,
port: u16,
mac: &[String],
os: &str,
mgmt_port: Option<u16>,
) {
let mut known = KnownHosts::read();
let Some(h) = learn_target(&mut known, fp_hex, addr, port) else {
return;
};
if h.mgmt_port == Some(mgmt_port) {
return;
if apply_advert(h, mac, os, mgmt_port) {
let _ = known.save();
}
h.mgmt_port = Some(mgmt_port);
let _ = known.save();
}
/// Re-key a saved host's address/port after it rediscovered on a new DHCP lease (matched by
@@ -785,7 +786,7 @@ pub fn touch_last_used(fp_hex: &str) {
/// Save a host's management-API port learned from the **session's own `Welcome`**, keyed by
/// fingerprint alone — the identity a just-connected client is certain of.
///
/// This is the mDNS-free path, and the one that matters most: [`learn_mgmt_port`] can only fire
/// This is the mDNS-free path, and the one that matters most: [`learn_from_advert`] can only fire
/// where an advert is visible, whereas this fires on any successful connect, including a host
/// added by IP on a network where discovery has never worked. No-op — and no disk write — when
/// the fingerprint isn't stored or the value is unchanged, so it is safe on every connect.
@@ -2293,6 +2294,33 @@ mod tests {
assert!(learn_target(&mut k, &fp('e'), "10.0.0.9", 9777).is_none());
}
/// What an advert carries lands on the record; what it omits is left alone; and a repeat of
/// the same advert reports no change — which is what lets every surface call this on every
/// discovery tick without churning the store.
#[test]
fn apply_advert_learns_what_it_carries_and_keeps_what_it_omits() {
let mut h = KnownHost::default();
let mac = vec!["aa:bb:cc:dd:ee:ff".to_string()];
assert!(apply_advert(&mut h, &mac, "linux/arch", Some(47991)));
assert_eq!(h.mac, mac);
assert_eq!(h.os, "linux/arch");
assert_eq!(h.mgmt_port, Some(47991));
// The same advert a tick later: nothing moved, so there is nothing to persist.
assert!(!apply_advert(&mut h, &mac, "linux/arch", Some(47991)));
// An older host advertises none of the three. Clearing a learned MAC here is exactly what
// would cost the user their wake, so an absent field must never overwrite a known one.
assert!(!apply_advert(&mut h, &[], "", None));
assert_eq!(h.mac, mac);
assert_eq!(h.os, "linux/arch");
assert_eq!(h.mgmt_port, Some(47991));
// 0 is how "not advertised" reaches us from a consumer that has no Option — not a port.
assert!(!apply_advert(&mut h, &[], "", Some(0)));
assert_eq!(h.mgmt_port, Some(47991));
// A host that genuinely moved: the new value wins.
assert!(apply_advert(&mut h, &[], "", Some(47992)));
assert_eq!(h.mgmt_port, Some(47992));
}
/// Pins render in card order, deduplicated, with deleted profiles simply gone — a pin is
/// presentation state, so a dangling one is never an error surface.
#[test]
+24 -9
View File
@@ -148,6 +148,10 @@ pub(crate) enum HintKey {
/// there isn't (the library grid spends up on rows) the same menu hangs off
/// [`HintKey::Tertiary`] instead; the button differs, the word "Options" does not.
Up,
/// ▼ — the home carousel's other spare direction, which opens Settings. Advertised in
/// place of [`HintKey::Tertiary`] where no pad is attached, because that is exactly the
/// device that has no X to press: a TV remote is a D-pad, OK and Back.
Down,
Key(&'static str),
}
@@ -272,7 +276,7 @@ fn glyph_width(fonts: &Fonts, key: HintKey, style: GlyphStyle, k: f64) -> f64 {
match resolved(key, style) {
Resolved::Badge(_) | Resolved::Adjust => BADGE_D * k,
Resolved::Shoulders => 2.0 * shoulder_w(fonts, k) + 3.0 * k,
Resolved::Up => BADGE_D * k,
Resolved::Up | Resolved::Down => BADGE_D * k,
Resolved::Key(text) => keycap_w(fonts, text, k),
}
}
@@ -294,6 +298,9 @@ enum Resolved {
/// The d-pad's up — drawn the same in every style, because it is a direction rather
/// than a button whose label changes with the pad.
Up,
/// The d-pad's down — the same triangle stood on its head, and style-free for the
/// same reason [`Resolved::Up`] is.
Down,
Key(&'static str),
}
@@ -317,6 +324,7 @@ fn resolved(key: HintKey, style: GlyphStyle) -> Resolved {
HintKey::Shoulders => Resolved::Key("Tab"),
HintKey::Adjust => Resolved::Adjust,
HintKey::Up => Resolved::Up,
HintKey::Down => Resolved::Down,
HintKey::Key(t) => Resolved::Key(t),
};
}
@@ -327,6 +335,7 @@ fn resolved(key: HintKey, style: GlyphStyle) -> Resolved {
HintKey::Secondary => Resolved::Badge(Face::Y),
HintKey::Shoulders => Resolved::Shoulders,
HintKey::Adjust => Resolved::Adjust,
HintKey::Down => Resolved::Down,
HintKey::Up => Resolved::Up,
HintKey::Key(t) => Resolved::Key(t),
}
@@ -394,17 +403,23 @@ fn draw_glyph(
pen += w + 3.0 * k;
}
}
Resolved::Up => {
// ▲ — one solid triangle in a badge-sized slot.
g @ (Resolved::Up | Resolved::Down) => {
// ▲ / ▼ — one solid triangle in a badge-sized slot, the same triangle either
// way up: apex toward the direction it names, base at the other end.
let r = BADGE_D * k / 2.0;
let (cx, cyf) = ((x + r) as f32, cy as f32);
let (tw, th) = ((5.5 * k) as f32, (4.5 * k) as f32);
let mut up = PathBuilder::new();
up.move_to((cx, cyf - th));
up.line_to((cx - tw, cyf + th));
up.line_to((cx + tw, cyf + th));
up.close();
canvas.draw_path(&up.detach(), &fill(fg(0.85)));
let (apex, base) = if matches!(g, Resolved::Down) {
(cyf + th, cyf - th)
} else {
(cyf - th, cyf + th)
};
let mut tri = PathBuilder::new();
tri.move_to((cx, apex));
tri.line_to((cx - tw, base));
tri.line_to((cx + tw, base));
tri.close();
canvas.draw_path(&tri.detach(), &fill(fg(0.85)));
}
Resolved::Adjust => {
// ◀ ▶ — two small solid triangles.
+19
View File
@@ -41,6 +41,11 @@ pub struct HostRow {
pub mgmt_port: u16,
/// Offline + a stored MAC → activating wakes first ("Wake & Connect").
pub can_wake: bool,
/// Share this device's clipboard with THIS host while streaming
/// (`KnownHost::clipboard_sync`) — surfaced so the host menu can show and flip it.
/// `serde(default)`: a producer predating the field still parses (as not-shared).
#[serde(default)]
pub clipboard_sync: bool,
/// Last successful connect (UNIX seconds) — the most-recent accent.
pub last_used: Option<u64>,
/// The host's OS-identity chain (live advert preferred, else the stored one), for a
@@ -220,6 +225,19 @@ pub enum ConsoleCmd {
profile_id: String,
pin: bool,
},
/// Bind (or clear) a saved host's DEFAULT profile — `KnownHost::profile_id`, the one a
/// plain A-press on the primary tile connects with (the port design's WP5 leftover;
/// [`ConsoleCmd::SetPin`] is presentation, this is the binding). `key` is the HOST
/// row's key; `None` clears the binding. Idempotent like `SetPin`: re-binding the
/// bound profile is a no-op.
BindProfile {
key: String,
profile_id: Option<String>,
},
/// Share (or stop sharing) this device's clipboard with a saved host while streaming —
/// `KnownHost::clipboard_sync`, the host menu's toggle. Per-host, never global:
/// handing a host your clipboard is a trust decision about that host.
SetClipboard { key: String, on: bool },
/// Open a screen the PLATFORM owns over the console (design android-skia-console-port.md
/// D7) — Android's connected-controllers view, the open-source licences. `id` is a
/// [`crate::platform::PlatformScreen::id`]. The host draws it, holds the console's input
@@ -276,6 +294,7 @@ mod tests {
online: false,
mgmt_port: 47990,
can_wake: false,
clipboard_sync: false,
last_used: None,
os: String::new(),
pin: None,
+14
View File
@@ -4,6 +4,7 @@
//! every screen animates and reads identically.
pub(crate) mod add_host;
pub(crate) mod bind_profile;
pub(crate) mod collections;
pub(crate) mod controllers;
pub(crate) mod home;
@@ -62,6 +63,10 @@ pub(crate) struct Ctx<'a> {
pub pads: &'a [PadInfo],
/// Steam Deck: never draw our keyboard — Steam's types via SDL text input.
pub deck: bool,
/// The host app has another interface to fall back to when the console is switched
/// off (an Android phone/tablet's touch shell) — see
/// [`crate::shell::ConsoleOptions::fallback_ui`]. Gates the console-off settings row.
pub fallback_ui: bool,
/// The name the HOST stores this client under when pairing (the machine's
/// hostname, resolved by the binary).
pub device_name: &'a str,
@@ -180,6 +185,10 @@ pub(crate) enum Screen {
AddHost(add_host::AddHostScreen),
Pair(pair::PairScreen),
PinHosts(pin_hosts::PinHostsScreen),
/// "Default for <host>": which profile the host's primary tile connects with — the
/// binding sibling of [`Screen::PinHosts`]'s presentation cards. Raised by the host
/// menu's "Default profile…" action.
BindProfile(bind_profile::BindProfileScreen),
/// "Connected controllers": the attached pads and their identity lines, plus the grants
/// and tests only the platform can perform. Android-reachable only — the settings row
/// that opens it is in `settings::row_on`'s Android-only list.
@@ -205,6 +214,7 @@ impl Screen {
Screen::AddHost(s) => s.menu(ev, ctx, fx),
Screen::Pair(s) => s.menu(ev, ctx, fx),
Screen::PinHosts(s) => s.menu(ev, ctx, fx),
Screen::BindProfile(s) => s.menu(ev, ctx, fx),
Screen::Controllers(s) => s.menu(ev, ctx, fx),
Screen::HostOptions(s) => s.menu(ev, ctx, fx),
}
@@ -224,6 +234,7 @@ impl Screen {
Screen::AddHost(s) => s.pointer(p, ctx, fx),
Screen::Pair(s) => s.pointer(p, ctx, fx),
Screen::PinHosts(s) => s.pointer(p, ctx, fx),
Screen::BindProfile(s) => s.pointer(p, ctx, fx),
Screen::Controllers(s) => s.pointer(p, ctx, fx),
Screen::HostOptions(s) => s.pointer(p, ctx, fx),
}
@@ -274,6 +285,7 @@ impl Screen {
Screen::AddHost(s) => s.title(),
Screen::Pair(s) => format!("Pair with {}", s.host_name()),
Screen::PinHosts(s) => format!("Pin \u{201c}{}\u{201d}", s.profile_name()),
Screen::BindProfile(s) => format!("Default for {}", s.host_name()),
Screen::Controllers(_) => "Connected controllers".into(),
Screen::HostOptions(s) => s.title(),
}
@@ -288,6 +300,7 @@ impl Screen {
Screen::AddHost(s) => s.hints(ctx),
Screen::Pair(s) => s.hints(ctx),
Screen::PinHosts(s) => s.hints(ctx),
Screen::BindProfile(s) => s.hints(ctx),
Screen::Controllers(s) => s.hints(ctx),
Screen::HostOptions(s) => s.hints(ctx),
}
@@ -313,6 +326,7 @@ impl Screen {
Screen::AddHost(s) => s.render(canvas, rect, k, dt, fonts, ctx),
Screen::Pair(s) => s.render(canvas, rect, k, dt, fonts, ctx),
Screen::PinHosts(s) => s.render(canvas, rect, k, dt, fonts, ctx),
Screen::BindProfile(s) => s.render(canvas, rect, k, dt, fonts, ctx),
Screen::Controllers(s) => s.render(canvas, rect, k, dt, fonts, ctx),
Screen::HostOptions(s) => s.render(canvas, rect, k, dt, fonts, ctx),
}
@@ -396,6 +396,7 @@ mod tests {
platform: crate::platform::Platform::Desktop,
pads,
deck,
fallback_ui: false,
device_name: "t",
t: 0.0,
}
@@ -0,0 +1,338 @@
//! "Default for “Desk”" — choose the profile a plain A-press on a saved host connects
//! with (`KnownHost::profile_id`), reached from the host tile's menu. One row per catalog
//! profile behind a leading "No default" row; choosing rides
//! [`ConsoleCmd::BindProfile`] to the binary, which persists the binding and refreshes
//! the rows — the checkmark follows the model, so what the list says is always what the
//! store holds (and what the tile's chip shows). Pinning is the sibling decision
//! (`pin_hosts.rs`): a pin adds a CARD, this changes what the primary tile itself does.
use crate::glyphs::{Hint, HintKey};
use crate::model::ConsoleCmd;
use crate::pointer::Pointer;
use crate::screens::{Ctx, Outbox};
use crate::theme::{fg, Fonts, W};
use crate::widgets::{ListMsg, MenuList, RowSpec};
use pf_client_core::menu_nav::{MenuEvent, MenuPulse};
use skia_safe::{Canvas, Rect};
pub(crate) struct BindProfileScreen {
/// The HOST row's primary key (fingerprint or `addr:port`, never a pinned card's
/// composite) — what every [`ConsoleCmd::BindProfile`] here addresses.
host_key: String,
host_name: String,
/// The catalog's `(id, name)` pairs, loaded once at construction — same stability
/// assumption the settings screen's Profiles tab makes (the console can't create
/// profiles, so the list can't change under this screen).
profiles: Vec<(String, String)>,
list: MenuList,
}
impl BindProfileScreen {
pub(crate) fn new(
host_key: String,
host_name: String,
profiles: Vec<(String, String)>,
) -> BindProfileScreen {
BindProfileScreen {
host_key,
host_name,
profiles,
list: MenuList::new(),
}
}
pub(crate) fn host_name(&self) -> &str {
&self.host_name
}
/// The host's current binding, read from the model — the primary row's chip IS the
/// state, so the checkmark can never disagree with what the carousel shows.
fn bound(&self, ctx: &Ctx) -> Option<String> {
ctx.hosts
.iter()
.find(|r| r.key == self.host_key)
.and_then(|r| r.bound_profile.as_ref())
.map(|p| p.id.clone())
}
/// Row `i`'s meaning: 0 is "No default", the rest the catalog in order.
fn choice(&self, i: usize) -> Option<Option<&str>> {
if i == 0 {
Some(None)
} else {
self.profiles.get(i - 1).map(|(id, _)| Some(id.as_str()))
}
}
fn len(&self) -> usize {
self.profiles.len() + 1
}
pub(crate) fn menu(
&mut self,
ev: MenuEvent,
ctx: &mut Ctx,
fx: &mut Outbox,
) -> Option<MenuPulse> {
if ev == MenuEvent::Back {
fx.pop();
return None;
}
let (msg, pulse) = self.list.menu(ev, self.len());
self.choose(msg, pulse, ctx, fx)
}
pub(crate) fn pointer(&mut self, p: Pointer, ctx: &mut Ctx, fx: &mut Outbox) -> bool {
let (msg, pulse) = self.list.pointer(p, self.len());
if matches!(msg, ListMsg::None) && pulse.is_none() {
return false;
}
self.choose(msg, pulse, ctx, fx);
true
}
/// One list message against the focused row — shared by both input paths. A choice is
/// a radio press, not a toggle: A on the row that is already the binding is a boundary
/// thud, and ◀/▶ adjust nothing here.
fn choose(
&mut self,
msg: ListMsg,
pulse: Option<MenuPulse>,
ctx: &mut Ctx,
fx: &mut Outbox,
) -> Option<MenuPulse> {
let Some(choice) = self.choice(self.list.cursor) else {
return pulse;
};
match msg {
ListMsg::Adjust(_) => Some(MenuPulse::Boundary),
ListMsg::None => pulse,
ListMsg::Activate => {
let current = self.bound(ctx);
if current.as_deref() == choice {
return Some(MenuPulse::Boundary);
}
fx.cmds.push(ConsoleCmd::BindProfile {
key: self.host_key.clone(),
profile_id: choice.map(str::to_owned),
});
Some(MenuPulse::Confirm)
}
}
}
pub(crate) fn hints(&self, _ctx: &Ctx) -> Vec<Hint> {
if self.profiles.is_empty() {
return vec![Hint::new(HintKey::Back, "Done")];
}
vec![
Hint::new(HintKey::Confirm, "Set default"),
Hint::new(HintKey::Back, "Done"),
]
}
pub(crate) fn render(
&mut self,
canvas: &Canvas,
rect: Rect,
k: f64,
dt: f64,
fonts: &Fonts,
ctx: &mut Ctx,
) {
let cx = f64::from(rect.left) + f64::from(rect.width()) / 2.0;
if self.profiles.is_empty() {
fonts.centered(
canvas,
"No profiles yet \u{2014} create them in the desktop app, then choose one here.",
W::Regular,
14.0 * k,
fg(0.55),
cx,
f64::from(rect.top) + f64::from(rect.height()) / 2.0,
f64::from(rect.width()) * 0.7,
);
return;
}
// The explainer band under the list, like the settings screen's detail text.
let detail_h = 34.0 * k;
let list_rect = Rect::from_ltrb(
rect.left,
rect.top,
rect.right,
rect.bottom - detail_h as f32,
);
let bound = self.bound(ctx);
let rows: Vec<RowSpec> = (0..self.len())
.map(|i| {
let (label, id) = if i == 0 {
("No default".to_string(), None)
} else {
let (id, name) = &self.profiles[i - 1];
(name.clone(), Some(id.as_str()))
};
let current = bound.as_deref() == id;
RowSpec {
header: None,
label,
value: Some(if current {
"Default".into()
} else {
String::new()
}),
value_dim: !current,
caret: false,
adjustable: false,
enabled: true,
}
})
.collect();
self.list
.render(canvas, list_rect, &rows, fonts, k, dt, true);
fonts.centered(
canvas,
"What a plain press on this host's tile connects with. Pinned cards keep their own.",
W::Regular,
13.0 * k,
fg(0.55),
cx,
f64::from(rect.bottom) - detail_h + 6.0 * k,
f64::from(rect.width()) * 0.8,
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{HostRow, ProfileChip};
use pf_client_core::menu_nav::MenuDir;
use pf_client_core::trust::Settings;
fn host(bound: Option<&str>) -> HostRow {
HostRow {
key: "aa".into(),
name: "Desk".into(),
addr: "10.0.0.9".into(),
port: 9777,
fp_hex: "aa".into(),
paired: true,
saved: true,
online: true,
mgmt_port: 47990,
can_wake: false,
clipboard_sync: false,
last_used: None,
os: String::new(),
pin: None,
bound_profile: bound.map(|id| ProfileChip {
id: id.into(),
name: "Work".into(),
accent: None,
}),
}
}
fn screen() -> BindProfileScreen {
BindProfileScreen::new(
"aa".into(),
"Desk".into(),
vec![("p1".into(), "Work".into()), ("p2".into(), "Game".into())],
)
}
#[test]
fn choosing_a_profile_binds_and_no_default_clears() {
let mut settings = Settings::default();
let pads = Vec::new();
let library = crate::library::LibraryShared::default();
let hosts = [host(Some("p1"))];
let mut ctx = Ctx {
hosts: &hosts,
library: &library,
settings: &mut settings,
store: crate::store::file_store(),
platform: crate::platform::Platform::Desktop,
pads: &pads,
deck: false,
fallback_ui: false,
device_name: "t",
t: 0.0,
};
let mut s = screen();
// Row 2 = the second profile: binds it.
let mut fx = Outbox::default();
s.menu(MenuEvent::Move(MenuDir::Down), &mut ctx, &mut fx);
s.menu(MenuEvent::Move(MenuDir::Down), &mut ctx, &mut fx);
let pulse = s.menu(MenuEvent::Confirm, &mut ctx, &mut fx);
assert_eq!(
fx.cmds,
vec![ConsoleCmd::BindProfile {
key: "aa".into(),
profile_id: Some("p2".into()),
}]
);
assert!(matches!(pulse, Some(MenuPulse::Confirm)));
// Row 0 clears the binding.
let mut fx = Outbox::default();
s.menu(MenuEvent::Move(MenuDir::Up), &mut ctx, &mut fx);
s.menu(MenuEvent::Move(MenuDir::Up), &mut ctx, &mut fx);
s.menu(MenuEvent::Confirm, &mut ctx, &mut fx);
assert_eq!(
fx.cmds,
vec![ConsoleCmd::BindProfile {
key: "aa".into(),
profile_id: None,
}]
);
}
#[test]
fn re_choosing_the_current_binding_is_a_boundary_not_a_command() {
let mut settings = Settings::default();
let pads = Vec::new();
let library = crate::library::LibraryShared::default();
let hosts = [host(Some("p1"))];
let mut ctx = Ctx {
hosts: &hosts,
library: &library,
settings: &mut settings,
store: crate::store::file_store(),
platform: crate::platform::Platform::Desktop,
pads: &pads,
deck: false,
fallback_ui: false,
device_name: "t",
t: 0.0,
};
let mut s = screen();
// Row 1 = "Work", already bound.
let mut fx = Outbox::default();
s.menu(MenuEvent::Move(MenuDir::Down), &mut ctx, &mut fx);
let pulse = s.menu(MenuEvent::Confirm, &mut ctx, &mut fx);
assert!(fx.cmds.is_empty());
assert!(matches!(pulse, Some(MenuPulse::Boundary)));
// An unbound host: "No default" is already the state.
let hosts = [host(None)];
let mut settings = Settings::default();
let mut ctx = Ctx {
hosts: &hosts,
library: &library,
settings: &mut settings,
store: crate::store::file_store(),
platform: crate::platform::Platform::Desktop,
pads: &pads,
deck: false,
fallback_ui: false,
device_name: "t",
t: 0.0,
};
let mut s = screen();
let mut fx = Outbox::default();
let pulse = s.menu(MenuEvent::Confirm, &mut ctx, &mut fx);
assert!(fx.cmds.is_empty());
assert!(matches!(pulse, Some(MenuPulse::Boundary)));
}
}
@@ -823,6 +823,7 @@ mod tests {
online: true,
mgmt_port: 9778,
can_wake: false,
clipboard_sync: false,
last_used: None,
os: String::new(),
pin: None,
@@ -940,6 +941,7 @@ mod tests {
platform: crate::platform::Platform::Desktop,
pads: &[],
deck: false,
fallback_ui: false,
device_name: "test",
t: 0.0,
};
@@ -378,6 +378,7 @@ mod tests {
platform,
pads,
deck: false,
fallback_ui: false,
device_name: "t",
t: 0.0,
};
@@ -429,6 +430,7 @@ mod tests {
platform,
pads,
deck: false,
fallback_ui: false,
device_name: "t",
t: 0.0,
}
+85 -2
View File
@@ -206,7 +206,17 @@ impl HomeScreen {
}
_ => Some(MenuPulse::Boundary),
},
MenuEvent::Move(_) => None,
// Down is Settings — the same screen X opens. The carousel is horizontal, so
// down is the other free direction, and it is the only route to Settings on a
// device whose input has no face buttons: an Android TV remote is a D-pad, OK
// and Back, and X never arrives. (Apple hit this on the Siri Remote too, and
// answered it by moving rows out to the ordinary Settings app.)
MenuEvent::Move(MenuDir::Down) => {
fx.push(Screen::Settings(super::settings::SettingsScreen::new(
ctx.store,
)));
Some(MenuPulse::Confirm)
}
}
}
@@ -279,7 +289,15 @@ impl HomeScreen {
{
hints.push(Hint::new(HintKey::Up, "Options"));
}
hints.push(Hint::new(HintKey::Tertiary, "Settings"));
// Name the route this device actually has. With no pad attached the legend is
// already speaking keyboard, and the one input that reaches here with neither a
// pad NOR letter keys is a TV remote — for which X is not a button that exists.
// Down opens Settings for everyone; only the advertisement changes.
hints.push(if ctx.pads.is_empty() {
Hint::new(HintKey::Down, "Settings")
} else {
Hint::new(HintKey::Tertiary, "Settings")
});
hints.push(Hint::new(HintKey::Back, "Quit"));
hints
}
@@ -811,6 +829,7 @@ mod tests {
online,
mgmt_port: 47990,
can_wake,
clipboard_sync: false,
last_used: None,
os: String::new(),
pin: None,
@@ -858,6 +877,7 @@ mod tests {
platform: crate::platform::Platform::Desktop,
pads: &pads,
deck: false,
fallback_ui: false,
device_name: "test",
t: 0.0,
};
@@ -884,6 +904,67 @@ mod tests {
));
}
/// Everything this screen offers must be reachable from a D-pad, OK and Back alone —
/// an Android TV remote has no face buttons, so Settings (X) and the options menu
/// would otherwise be unreachable there. Up is the menu, down is Settings, and the
/// legend names the direction rather than X when nothing is plugged in.
#[test]
fn a_remote_reaches_settings_and_options_without_face_buttons() {
let mut settings = ctx_settings();
let hosts = [host("paired", true, true, false)];
let pads: Vec<pf_client_core::menu_nav::PadInfo> = Vec::new();
let library = crate::library::LibraryShared::default();
let mut ctx = Ctx {
hosts: &hosts,
library: &library,
settings: &mut settings,
store: crate::store::file_store(),
platform: crate::platform::Platform::Android,
pads: &pads,
deck: false,
fallback_ui: true,
device_name: "test",
t: 0.0,
};
let mut s = HomeScreen::new();
// Down opens the same screen X opens.
let mut fx = Outbox::default();
s.menu(MenuEvent::Move(MenuDir::Down), &mut ctx, &mut fx);
assert!(
matches!(fx.nav, Some(crate::screens::Nav::Push(ref sc)) if matches!(**sc, Screen::Settings(_))),
"down must open Settings"
);
// Up still opens the host's own menu — the library hangs off that menu now.
let mut fx = Outbox::default();
s.menu(MenuEvent::Move(MenuDir::Up), &mut ctx, &mut fx);
assert!(
matches!(fx.nav, Some(crate::screens::Nav::Push(ref sc)) if matches!(**sc, Screen::HostOptions(_))),
"up must open the host options menu"
);
// With no pad the legend advertises the direction, not a button that isn't there.
assert!(
s.hints(&ctx).iter().any(|h| h.key == HintKey::Down),
"a padless device is told about down"
);
// With a pad it goes back to naming X, which is faster to press.
let pads = vec![pf_client_core::menu_nav::PadInfo {
name: "Pad".into(),
key: "045e:028e:Pad".into(),
pref: punktfunk_core::config::GamepadPref::Xbox360,
steam_virtual: false,
battery: None,
detail: "045E:028E · gamepad".into(),
forwarded: true,
rumble: false,
}];
ctx.pads = &pads;
assert!(
s.hints(&ctx).iter().any(|h| h.key == HintKey::Tertiary),
"a pad is told about X"
);
}
/// A pinned card's A-press is a connect WITH its profile (one-off), titled so the
/// connecting takeover says which settings are coming (§5.2a).
#[test]
@@ -907,6 +988,7 @@ mod tests {
platform: crate::platform::Platform::Desktop,
pads: &pads,
deck: false,
fallback_ui: false,
device_name: "test",
t: 0.0,
};
@@ -931,6 +1013,7 @@ mod tests {
platform: crate::platform::Platform::Desktop,
pads: &pads,
deck: false,
fallback_ui: false,
device_name: "test",
t: 0.0,
};
@@ -2162,6 +2162,7 @@ mod tests {
online: true,
mgmt_port: 9778,
can_wake: false,
clipboard_sync: false,
last_used: None,
os: String::new(),
pin: None,
@@ -2217,6 +2218,7 @@ mod tests {
platform: crate::platform::Platform::Desktop,
pads: &[],
deck: false,
fallback_ui: false,
device_name: "test",
t: 0.0,
}
+205 -9
View File
@@ -34,8 +34,21 @@ use skia_safe::{Canvas, Rect};
enum Action {
Wake,
SendLogs,
/// Open this host's game library — the same shelf the home carousel's Y opens, offered
/// here because Y is a face button and a TV remote has none. Saved-and-paired only,
/// exactly like that Y (an unpaired host has no shelf to fetch).
Library,
CopyLink,
Edit,
/// Choose the profile the host's primary tile connects with (opens the
/// [`Screen::BindProfile`] chooser). Offered on saved primary tiles only — a pinned
/// card's profile IS the card, and a title's menu addresses the title.
BindProfile,
/// Share this device's clipboard with THIS host while streaming
/// (`KnownHost::clipboard_sync`). Per-host because it is a trust decision about that
/// host — which is why it lives on the host and not in Settings. A toggle: the label
/// carries the current state, activating flips it.
Clipboard,
Forget,
Unpin,
Cancel,
@@ -115,7 +128,10 @@ impl OptionsScreen {
key.split('\0').next().unwrap_or(key)
}
fn actions(&self, platform: crate::platform::Platform) -> Vec<Action> {
// `_platform` is the seam platform-conditional rows plug into (Send logs used it until
// Android grew an uploader); unused today, kept so the next such row has its question
// already answered at every call site.
fn actions(&self, _platform: crate::platform::Platform) -> Vec<Action> {
let host = match &self.subject {
Subject::Host(h) => h,
// Deliberately not [Play, …]: the host menu does not repeat its tile's own A
@@ -137,14 +153,22 @@ impl OptionsScreen {
// error. This is the log-escape hatch for platforms whose own filesystem the user
// can't reach (Deck Gaming Mode, tvOS): the bundle lands on the host, listed in
// its web console next to the host's own logs.
// Only where a service exists to upload them: the Android client has no log-ring
// uploader yet, and a row that can only toast "not available" is a promise broken.
if host.paired && host.online && platform == crate::platform::Platform::Desktop {
// Every platform has an uploader now (Android's rides `nativeSendLogs` over the
// same `logring` the desktop drains), so paired-and-reachable is the whole gate.
if host.paired && host.online {
a.push(Action::SendLogs);
}
// The shelf, on the same terms the carousel's Y offers it. Ahead of Copy link
// because it is the one row here that goes somewhere rather than acting on the
// host — and on a remote-only device it is the ONLY way to the library.
if host.paired && host.saved {
a.push(Action::Library);
}
a.extend([
Action::CopyLink,
Action::Edit,
Action::BindProfile,
Action::Clipboard,
Action::Forget,
Action::Cancel,
]);
@@ -157,8 +181,18 @@ impl OptionsScreen {
match a {
Action::Wake => "Wake host".into(),
Action::SendLogs => "Send logs to host".into(),
Action::Library => "Library".into(),
Action::CopyLink => "Copy link".into(),
Action::Edit => "Edit\u{2026}".into(),
Action::BindProfile => "Default profile\u{2026}".into(),
Action::Clipboard => format!(
"Shared clipboard: {}",
if self.host().clipboard_sync {
"On"
} else {
"Off"
}
),
Action::Forget if self.armed => "Forget \u{2014} press again".into(),
Action::Forget => "Forget".into(),
Action::Unpin => "Unpin card".into(),
@@ -211,7 +245,7 @@ impl OptionsScreen {
ListMsg::Adjust(_) => Some(MenuPulse::Boundary),
ListMsg::None => pulse,
ListMsg::Activate => {
self.run(action, ctx.store, fx);
self.run(action, ctx, fx);
pulse
}
}
@@ -234,7 +268,8 @@ impl OptionsScreen {
}
}
fn run(&mut self, action: Action, store: &dyn crate::store::SettingsStore, fx: &mut Outbox) {
fn run(&mut self, action: Action, ctx: &Ctx, fx: &mut Outbox) {
let store = ctx.store;
let key = self.host_key().to_string();
match action {
Action::Wake => {
@@ -266,9 +301,45 @@ impl OptionsScreen {
}
fx.pop();
}
// Same two steps the home carousel's Y takes: ask for the shelf, then open it
// on the epoch read BEFORE the command drains, so the screen can tell its own
// fetch's titles from the ones already in the model. `replace`, not push — the
// menu has said its piece, and Back from the shelf belongs on the carousel
// rather than on a menu about the host you just left.
Action::Library => {
let host = self.host();
fx.cmds.push(ConsoleCmd::FetchLibrary {
addr: host.addr.clone(),
mgmt: host.mgmt_port,
fp_hex: host.fp_hex.clone(),
});
let epoch = ctx.library.fetch_epoch();
fx.replace(Screen::Library(super::library::LibraryScreen::new(
self.host(),
epoch,
)));
}
Action::Edit => fx.replace(Screen::AddHost(super::add_host::AddHostScreen::edit(
self.host(),
))),
Action::BindProfile => fx.replace(Screen::BindProfile(
super::bind_profile::BindProfileScreen::new(
key,
self.host().name.clone(),
store.profiles(),
),
)),
Action::Clipboard => {
let host = self.host();
let on = !host.clipboard_sync;
fx.toast = Some(if on {
format!("Clipboard shared with {}", host.name)
} else {
format!("Clipboard no longer shared with {}", host.name)
});
fx.cmds.push(ConsoleCmd::SetClipboard { key, on });
fx.pop();
}
Action::Forget if !self.armed => self.armed = true,
Action::Forget => {
fx.cmds.push(ConsoleCmd::ForgetHost { key });
@@ -366,6 +437,27 @@ mod tests {
use crate::model::ProfileChip;
use crate::screens::Nav;
/// Activate one row. `run` reads the store, and — for Library — the shared library's
/// fetch epoch; nothing else in this menu touches the context, so one throwaway is
/// enough for every action test here.
fn run_action(s: &mut OptionsScreen, action: Action, fx: &mut Outbox) {
let mut settings = pf_client_core::trust::Settings::default();
let library = crate::library::LibraryShared::default();
let ctx = Ctx {
hosts: &[],
library: &library,
settings: &mut settings,
store: crate::store::file_store(),
platform: crate::platform::Platform::Desktop,
pads: &[],
deck: false,
fallback_ui: false,
device_name: "test",
t: 0.0,
};
s.run(action, &ctx, fx);
}
fn host() -> HostRow {
HostRow {
key: "aa".into(),
@@ -378,6 +470,7 @@ mod tests {
online: true,
mgmt_port: 9778,
can_wake: false,
clipboard_sync: false,
last_used: None,
os: String::new(),
pin: None,
@@ -438,6 +531,25 @@ mod tests {
.contains(&Action::Wake));
}
/// "Send logs" is offered wherever a paired, reachable host can receive it — on BOTH
/// platforms since Android's uploader landed (`SkiaConsole.sendLogs` → `nativeSendLogs`
/// over the shared `logring`); before that the row was desktop-only, because a row that
/// can only toast "not available" is a promise broken.
#[test]
fn send_logs_is_offered_on_every_platform_with_an_uploader() {
let reachable = OptionsScreen::for_host(&HostRow {
paired: true,
online: true,
..host()
});
assert!(reachable
.actions(crate::platform::Platform::Desktop)
.contains(&Action::SendLogs));
assert!(reachable
.actions(crate::platform::Platform::Android)
.contains(&Action::SendLogs));
}
#[test]
fn a_pinned_card_cannot_forget_or_edit_the_host() {
let s = OptionsScreen::for_host(&pinned());
@@ -449,6 +561,89 @@ mod tests {
assert_eq!(s.host_key(), "aa");
}
/// The shelf is on this menu, which is the only route to it that survives a device with
/// no face buttons: home's Y opens it too, but an Android TV remote has no Y. Offered on
/// the same terms that Y is (saved AND paired), and it REPLACES the menu, so Back from
/// the shelf lands on the carousel rather than on a menu about the host just left.
#[test]
fn the_library_hangs_off_the_menu_for_a_padless_device() {
let mut s = OptionsScreen::for_host(&host());
assert!(s
.actions(crate::platform::Platform::Android)
.contains(&Action::Library));
let mut fx = Outbox::default();
run_action(&mut s, Action::Library, &mut fx);
assert!(
matches!(fx.cmds.first(), Some(ConsoleCmd::FetchLibrary { .. })),
"opening the shelf asks for it first"
);
match fx.nav {
Some(Nav::Replace(screen)) => assert!(matches!(*screen, Screen::Library(_))),
_ => panic!("expected the shelf to replace the menu"),
}
// An unpaired host has no shelf to fetch — the row is absent, not inert.
let unpaired = OptionsScreen::for_host(&HostRow {
paired: false,
..host()
});
assert!(!unpaired
.actions(crate::platform::Platform::Android)
.contains(&Action::Library));
}
/// "Default profile…" swaps the menu for the chooser — a Replace like Edit's, and for
/// the same reason — addressed to the HOST's plain key even from rows that carry a
/// composite one.
#[test]
fn default_profile_opens_the_chooser_on_the_hosts_plain_key() {
let mut s = OptionsScreen::for_host(&host());
assert!(s
.actions(crate::platform::Platform::Desktop)
.contains(&Action::BindProfile));
let mut fx = Outbox::default();
run_action(&mut s, Action::BindProfile, &mut fx);
match fx.nav {
Some(crate::screens::Nav::Replace(screen)) => match *screen {
Screen::BindProfile(b) => assert_eq!(b.host_name(), "Desk"),
_ => panic!("expected the bind-profile chooser"),
},
_ => panic!("expected a replace"),
}
}
/// The clipboard toggle: the label says where the host stands, activating flips it —
/// and both address the HOST's plain key.
#[test]
fn the_clipboard_toggle_flips_the_stored_state() {
let mut s = OptionsScreen::for_host(&host());
assert!(s.label(Action::Clipboard).ends_with("Off"));
let mut fx = Outbox::default();
run_action(&mut s, Action::Clipboard, &mut fx);
assert_eq!(
fx.cmds,
vec![ConsoleCmd::SetClipboard {
key: "aa".into(),
on: true,
}]
);
let mut s = OptionsScreen::for_host(&HostRow {
clipboard_sync: true,
..host()
});
assert!(s.label(Action::Clipboard).ends_with("On"));
let mut fx = Outbox::default();
run_action(&mut s, Action::Clipboard, &mut fx);
assert_eq!(
fx.cmds,
vec![ConsoleCmd::SetClipboard {
key: "aa".into(),
on: false,
}]
);
}
#[test]
fn forget_needs_two_presses() {
let mut s = OptionsScreen::for_host(&host());
@@ -457,12 +652,12 @@ mod tests {
s.list.cursor = i;
let mut fx = Outbox::default();
s.run(Action::Forget, crate::store::file_store(), &mut fx);
run_action(&mut s, Action::Forget, &mut fx);
assert!(fx.cmds.is_empty(), "the first press only arms");
assert!(s.armed);
assert!(s.label(Action::Forget).contains("press again"));
s.run(Action::Forget, crate::store::file_store(), &mut fx);
run_action(&mut s, Action::Forget, &mut fx);
assert_eq!(
fx.cmds,
vec![ConsoleCmd::ForgetHost { key: "aa".into() }],
@@ -485,6 +680,7 @@ mod tests {
platform: crate::platform::Platform::Desktop,
pads: &[],
deck: false,
fallback_ui: false,
device_name: "test",
t: 0.0,
};
@@ -547,7 +743,7 @@ mod tests {
OptionsScreen::for_game(&host(), &game()),
] {
let mut fx = Outbox::default();
s.run(Action::CopyLink, crate::store::file_store(), &mut fx);
run_action(&mut s, Action::CopyLink, &mut fx);
assert!(matches!(fx.nav, Some(Nav::Pop)));
assert!(fx.toast.is_some());
}
+3
View File
@@ -476,6 +476,7 @@ mod tests {
online: true,
mgmt_port: 47990,
can_wake: false,
clipboard_sync: false,
last_used: None,
os: String::new(),
pin: None,
@@ -496,6 +497,7 @@ mod tests {
platform: crate::platform::Platform::Desktop,
pads: &pads,
deck: false,
fallback_ui: false,
device_name: "living-room-deck",
t: 0.0,
};
@@ -536,6 +538,7 @@ mod tests {
platform: crate::platform::Platform::Desktop,
pads: &pads,
deck: false,
fallback_ui: false,
device_name: "deck",
t: 0.0,
};

Some files were not shown because too many files have changed in this diff Show More