The gamepad shell's Bitrate picker has been seven rungs ending at 80 Mbps since the
console shipped, which is the ceiling a user just ran into — the GTK dialog beside it
has always gone to 3000 Mbit/s, so the two surfaces disagreed about what this machine
may ask for, and the console was the smaller of the two.
Three changes, one row:
- The ladder is 30 rungs, 1 Mbps to 2 Gbps. Tight at the bottom (1, 2, 3, 4, 5, 6, 8,
10, 12, 15, 20, 25 …), where one rung decides whether a thin link is watchable, and
coarse at the top, where a rung is noise. Rates at or above a gigabit read as Gbps.
- Y opens a typed rate on that row — digits, four of them, through the tray keyboard
(or SDL text input, and Steam's own keyboard on a Deck) exactly like the add-host and
pair fields. A goes on cycling the ladder everywhere, so the console's grammar is
unchanged; the field is what the ladder cannot be, which is every number in between.
- A rate that is not a rung now steps to its NEIGHBOUR. The generic picker snaps a value
it does not recognise to its first option, which on this row is Automatic: one nudge
threw away a rate typed here or set by the desktop spinner.
The desktop dialog gets the same complaint's other half: its spinner steps 1 Mbit/s
instead of 5, so 3, 4 and 6 are reachable without typing.
`Screen::edit_key` now takes the context, because this is the first field that commits
into the settings store when it closes rather than holding text for a later action row.
The v0.31.3 CHANGELOG recorded this cut as a decision left open, on the same
reasoning v0.31.0 used for 0.1.5: a plugin resolves `@punktfunk/host` from the
registry, so types sitting in `sdk/` reach nobody until a version carries them.
#374 added `PATCH /clients/{fingerprint}`, `RenameClient` and
`PairedClient.label` to the management API and regenerated the client for them —
so without this cut the route exists on every 0.31.3 host and no plugin can call
it in a typed way.
ONE FILE is the whole diff since sdk-v0.1.5: `sdk/src/gen/punktfunk.ts`. It is
larger than the feature because regenerating it from the UNCHANGED committed spec
already produced a ~700-line diff — the checked-in copy had drifted from its own
pinned generator, and nothing in CI regenerates or verifies it (unlike
api/openapi.json and include/punktfunk_core.h, which are both gated). #374 landed
the clean regeneration rather than hand-patching generated code, and this cut
publishes it.
`SDK_VERSION` moves with `package.json`. It is a hand-maintained constant — the
build sets `rootDir: "src"` so it cannot import the manifest, and the runner ships
as one bundled `runner-cli.js` with no manifest beside it — and the runner
compares it against the SDK installed in the plugins tree to decide whether to
reinstall. Shipping 0.1.6 with the constant still reading 0.1.5 would publish the
types and then never deliver them. `version.test.ts` gates exactly that, which is
also what sdk-publish.yml's "Tag matches package version" step re-checks against
the tag.
GATES, all four steps sdk-publish.yml runs, in order and locally:
`bun install --frozen-lockfile --ignore-scripts` clean, `bun run typecheck`
clean, `bun test` 83 pass / 0 fail / 191 expect() calls across 12 files (the same
83 the 0.1.5 cut reported), `bun run build` clean. Nothing but the two version
sites and the two release documents is touched — no dist/ or lockfile churn
reached the tree.
`@punktfunk/plugin-kit` is deliberately NOT re-cut: nothing under plugin-kit/ has
moved since 0.4.4, which stays the registry's `latest`.
Tag `sdk-v0.1.6` on the merge commit, alongside `v0.31.3`. The two version
independently by design — sdk-publish.yml triggers on `sdk-v*` and the app's `v*`
tags never republish the SDK — so the shared commit is a convenience, not a
coupling.
41 commits since v0.31.2 (26 non-merge). Cut from origin/main f5931650 (#385
merged, main green).
THE NUMBER: a patch. One versioned surface moves and it moves additively — the
management API gains PATCH /clients/{fingerprint}, the RenameClient schema and
PairedClient.label, none of which existed before, so nothing that consumes the
API today changes shape. Everything else is where v0.31.2 left it: WIRE_VERSION
2, C ABI 25 with include/punktfunk_core.h showing NO diff against the v0.31.2 tag
(second release running), driver protocol 6 / min 3 with pf-driver-proto
unchanged, gamepad channel 3, plugin index schema 1, host event schema 1,
gamescope +pfhdr8 with no new patch files, SDK 0.1.5 and plugin-kit 0.4.4 both
untouched. Two feat commits (#374, #384), both additive; v0.31.1 carried two
feats as a patch on the same reasoning.
THE SHAPE: the faults share a family resemblance — a session degrading or ending
against something ordinary that nothing was checking. Steam's pre-launch trees
latching the game lease and their exit then read as the game's (#372); a
fullscreen game mode-setting the virtual display under both stream loops (#373);
the forced-keyframe coalesce window measured in frames rather than time (#377);
an Android TV negotiating the refresh its own console pin installed rather than
what the panel outputs (#378); a startup capacity probe large enough to
black-hole the link it was measuring (#379); a hand-back that never verified the
panel came back (#375); a half-minted audio devnode nothing afterwards
recognised (#381); and a failed compositor build that unlinked the working one it
never replaced (#382). Plus two Android input/present fixes (#376, #380), the
console's per-frame cost and its new resolution switch (#384, #385), one feature
(#374), and CI (#370, #383).
TWO ENTRIES WORTH THE READER'S ATTENTION, both recorded as such:
* #375 ships WITHOUT a reproduction. Five scenarios across both distro families
on real VMs all recovered cleanly and the first proposed mechanism was
disproved on glass, so it closes the gap that lets any trigger end as a dark
panel rather than guessing at one.
* #380 is re-implemented from #371's diagnosis, and #371 is NOT merged. All
three faults were real and correctly identified; each fix as sent reached
further than the hardware that needed it. The notes credit the diagnosis.
DOCS FRESHNESS, per docs/releases/README.md step 1: #379, #380 and #384 carried
their own docs-site updates (configuration.md, input.md, client-settings.md). The
one fact left owed was naming a Moonlight device, whose canonical home is the
"Managing paired devices" section of docs-site/content/docs/pairing.md — a
paragraph goes there. No new PUNKTFUNK_* variable this cycle
(PUNKTFUNK_RECOVER_SESSION_CMD is pre-existing and already documented in
configuration.md and gamescope.md), no new host subcommand, and no install
command, repo URL or port change, so data/platforms.json and the website's
vendored copy need nothing.
VERIFIED HERE: scripts/ci/check-docs-drift.sh clean; scripts/ci/check-docs-links.sh
clean; the android.yml Play notes gate run verbatim, 444/500 characters and unique
against every other release's file; both openapi copies cmp identical and stamped
0.31.3; cargo fmt --all --check clean; cargo audit clean over all five Rust
lockfiles (h2 fixed in the commit below this one); cargo about --fail clean on the
host workspace; git diff v0.31.2..HEAD on include/punktfunk_core.h and on
crates/pf-driver-proto both empty, which is the direct evidence for those two
version rows; Cargo.lock's 36 workspace version strings moved with Cargo.toml;
notes voice scan clean (zero backticked terms above ## For developers) and the
CHANGELOG link pinned to src/tag/v0.31.3.
NOT RUN HERE, and why: any punktfunk-host build, clippy or cargo test — the host
does not compile on macOS at all, and CI covers it; the web/ and docs-site/ bun
builds — nothing under web/ is touched by this commit and the docs-site edit is
prose in an existing .md; the Android unit tests — nothing here touches Kotlin.
LEFT AS A DECISION, not made here: sdk/src/gen/punktfunk.ts changed in #374 (a
clean regeneration that also absorbed ~700 lines of pre-existing drift) but
@punktfunk/host is not re-cut, so the registry's 0.1.5 has no types for the new
route. Cut sdk-v0.1.6 if anything outside this repo needs them.
`cargo audit` on the root lockfile went red on 2026-08-17, when RUSTSEC-2026-0258
was disclosed against h2 <= 0.4.15 (unbounded empty DATA frames; fixed in
0.4.16). audit.yml's cargo-audit job is BLOCKING and fires on every Cargo.lock
change, so the 0.31.3 version bump in the next commit would have taken it red on
merge regardless of this advisory's own timing.
h2 is transitive — no manifest in the workspace declares it — so this is a
lockfile-only change.
MINIMAL ON PURPOSE. `cargo update -p h2` reports "Locking 1 package" but also
rewrote nine unrelated entries from `windows-sys 0.61.2` to 0.52.0/0.59.0,
gratuitous resolver drift that would have changed what the Windows builds compile
against for no reason. That was discarded; the two h2 lines are applied directly
instead, and `cargo metadata --locked` accepts the result with nothing else
moving — which is the proof the resolver needed none of the rest.
VERIFIED: `cargo audit` over all five Rust lockfiles. The root one is now clean;
the other four already were. The two lines cargo-audit still prints
(`audiopus_sys`, `paste`) are *unmaintained* warnings, already allowed via
.cargo/audit.toml, and do not fail the job.
NOT REGENERATED, deliberately: THIRD-PARTY-NOTICES.txt still records h2 0.4.15.
scripts/gen-third-party-notices.sh walks the dependency closure of the machine it
runs on, and on macOS it DROPS nine crates — the rusqlite / libsqlite3-sys /
fallible-iterator / hashlink cluster, 575 -> 566 — because they are gated to
platforms this Mac is not. Committing that would remove attributions a Linux or
Windows build genuinely links, which the script's own header calls a legal
regression rather than an untidiness. Regenerate on Linux. Nothing in
.gitea/workflows diffs the checked-in copy, and build-deb.sh /
pack-host-installer.ps1 / punktfunk.spec / pack-msix.ps1 each regenerate it on
their own platform, so the shipped packages are accurate and this is cosmetic
drift in the in-repo copy only.
`platform_row_split_hides_only_the_other_platforms_concepts` pins the exact
ordered set of rows the desktop does not show, which is the point of it — a row
that silently changed platform is the regression it exists to catch. The new
switch is Android-only by design, so the expected list grows by one, between the
Controllers action row and the console-UI switch (it sits under Reduce motion,
earlier in the Interface tab than either).
Caught by CI on both the Linux and Windows legs, which run this crate's tests;
the row-COUNT assertion next to it was already updated and passed.
The console draws at whatever resolution the panel hands it, and on a 4K
television or projector that is four times the fragment work of 1080p on a
graphics chip built to decode and composite video rather than to draw a moving
interface. The reporter's two devices — a Fire TV Stick 4K Max and a Valerion
projector — are both premium products and both exactly this shape: the money
is in the light engine and the panel, and the SoC is a TV part. A premium 4K
box is MORE likely to want this than a cheap 1080p stick, which never had the
extra pixels to begin with.
So: an off-by-default switch in the controller-optimized settings, directly
under Reduce motion, because the two are the same kind of bargain — give up
some fidelity, get a smoother console. On, the buffer's long edge is capped at
1920 with `SurfaceHolder.setFixedSize` and the compositor scales it up for
free. Text goes a little softer. Nothing else changes.
Two things this had to get right, neither of which is obvious from the call:
`setFixedSize` shrinks the BUFFER and not the VIEW. Everything that speaks in
surface pixels therefore has to be scaled to match — the safe-area insets, the
design-unit scale, and the pointer coordinates, which a mouse still reports in
view pixels and which would otherwise land the cursor at twice its true
offset. The scale factor is one number applied to both axes, so the aspect
ratio survives exactly and no layout can stretch.
And the buffer is sized from the SurfaceView's own laid-out size, reported
back through `onSizeChanged`, rather than from `displayMetrics`. The two
normally agree, but `displayMetrics` has a long history of disagreeing with a
view's real size by a system bar depending on the version and on who is
hiding what, and a buffer whose aspect ratio does not match the rect it is
scaled into is a stretched interface. "Normally agree" is not something to
hang picture geometry on.
The pointer listeners are installed in `AndroidView`'s `factory`, which runs
once, so the factor reaches them through `rememberUpdatedState` — captured
directly it would freeze at its first-composition value (1, before any layout
has reported a size) and a mouse would be wrong for the rest of the session.
The same reason `platformUp` is already held that way.
⚠ This is the INTERFACE only and shares nothing with the stream. Picture size
is `effectiveMode`, off `Display.mode.physicalWidth` — a physical display
mode, not any surface's buffer — and picture scaling is the separate
`renderScale`. The two `SurfaceView`s are different views and this is the only
`setFixedSize` call in the client. The name keeps "interface" in it, and the
docs entry ends by pointing at Resolution and Bitrate, so that nobody turns
this on expecting a sharper stream.
skia-bindings pulls ~19 MB of prebuilt Skia from inside its build script with a
bare curl and no retry, and swallows a failed download into a from-source Skia
build the CI containers cannot complete — so a dropped transfer surfaced as
"Clippy (Android target) failed" with the real cause 1,800 lines up.
A retrying curl shim first on PATH covers it (skia-bindings already resumes and
caches the part-file, so a retry continues the transfer). The prose rule in
android.yml's env block is now a gate that fails on STARTING A FULL BUILD.
A field report of a sluggish console UI on a Fire TV Stick 4K Max and a
Valerion projector. The Skia shell is faster than the Compose one it replaced
per unit of work; it was doing far more work than anyone had counted, and all
of it on every frame whether or not anything had changed.
Four costs, none of which change a pixel:
`Fonts::paragraph` built a `ParagraphBuilder`, added its text and called
`layout()` on every call — the whole shaper, HarfBuzz and line breaking and
font fallback, for every string on screen, sixty times a second. It is now
built once per distinct (text, shape, weight, size, width, colour) and kept.
Position is deliberately not in the key, so a shelf that scrolls and a screen
that slides both re-use what they already shaped. Cold entries are dropped
once the map passes its ceiling, by the two frames that last drew them, so the
live set is what is on screen and paging a large library cannot grow it
forever. The loose `(TextAlign, Option<usize>)` pair became a `Para` tag on
the way past: those two were never independent, and it is half of a hash key
now.
`LayerEnv::paint` raised an unbounded `save_layer` unconditionally — including
on the settled path, where alpha is 1, the scale is 1 and the slide is 0. That
allocates an offscreen the size of the whole SURFACE and composites it back,
to apply an alpha of one, on every frame the console sat still. Skia does not
elide it: `SkCanvas::saveLayerAlphaf` forwards alpha >= 1 straight to
`saveLayer(bounds, nullptr)`, whose only early-out is an empty clip. On a 4K
panel that is a 33 MB render target per frame, against a Skia budget that is
64 MB on a 2 GB box — so it was evicting real work to do nothing. Dropping it
is pixel-identical rather than close: nothing in this crate draws with a blend
mode other than `SrcOver`, `SrcOver` is associative, and there is no LCD
subpixel text to gain or lose an isolation. `screens::home` had already
learned this one tile-deep; this is the same fix one level up.
The toast's layer was unbounded too, for a 34 dp pill. Everything inside it is
inside the pill, so it takes the pill's rect and some slack for the hairline.
`draw_clipped` measured its ellipsis fit by allocating a `String` per
character, for every over-long title on screen, every frame. It measures out
of a stack buffer now. The controller chip's string stopped being rebuilt
sixty times a second to say the same thing.
On the Android host, the render thread now takes the same priority lift the
decode thread has taken all along (`-8`, a band below the stream's `-10`, so
the two do not compete when the console is up mid-session). At default nice, a
TV box's scheduler is free to park the console's frame loop on a little core
behind background work, which reads as a UI that lags the remote.
And the thing that made this hard to answer in the first place: the console
logged its GLES version and its cache budget and never its render resolution
or its frame cost, so "it feels sluggish" could not be triaged from a log
bundle at all. It now names the surface size when it wraps one, and reports
mean and peak draw time once a minute. The window is timed around the draw and
not the swap — `eglSwapBuffers` blocks on vsync, so wall-clock per iteration
is always the panel period and says nothing.
What is deliberately NOT here is the biggest single lever on a 4K box: capping
the console's render resolution. That is a real quality trade on a panel
someone bought for its resolution, and it is not this commit's to make.
A host that died mid-mint left an orphan devnode and the next start minted a
duplicate; the registry stamp route reached for the Render hive even for capture
endpoints. Both reproduced and verified on the .173 Windows lab box.
The Recording-tab hang that prompted the investigation is NOT fixed — it did not
reproduce on .173, and nine candidate mechanisms were ruled out by direct
measurement. See the PR body for the disproof table.
scripts/ci/retry.sh already wraps every single-shot network call in CI, for the
reason documented there: the runner box runs many jobs in parallel and its
network sheds packets under that load. One of the largest fetches in this
workspace was never wrappable that way - skia-bindings pulls ~19 MB of prebuilt
Skia per target from INSIDE its build script, with a bare 'curl -sS -f -L' and
no retry (build_support/binary_cache/utils.rs).
Measured on main 2026-08-22, android job:
DOWNLOAD AND INSTALL FAILED: curl error code: "18"
curl stderr: "curl: (18) end of response with 17054400 bytes missing"
2 MB of 19,057,024 arrived before git.unom.io closed the connection; the same
asset pulls fine from a dev box. skia-bindings then swallowed it - its
try_prepare_download falls through to STARTING A FULL BUILD, a from-source Skia
build the CI containers carry no deps for - so the job surfaced as
'Clippy (Android target) failed' with a Gradle stack trace and the real cause
1,800 lines above it.
* A retrying curl shim first on PATH is the only lever that reaches inside a
build script, and the cheapest correct one: skia-bindings already passes
'-C -' and caches the part-file under OUT_DIR/.cache, so a retry CONTINUES
the truncated transfer rather than restarting it. --retry-all-errors is
load-bearing: a truncated transfer is not an HTTP status, so plain --retry
would let error 18 through.
* Wired into android.yml and both ci.yml rust jobs - pf-console-ui pulls
skia-safe too, so ci/rust downloads Skia on any target-cache miss.
* The rule android.yml's env block states in prose ('Every ABI's log must show
DOWNLOAD AND INSTALL SUCCEEDED') is now a gate that fails the job on
STARTING A FULL BUILD, so a dropped prebuilt can never masquerade as a lint
failure again.
Caught on the SteamOS lab VM while verifying the previous commit end to end. With
libx11-xcb-dev added the build finally COMPLETED (652/652, banner "3.16.25-21-gb71a56c
+pfhdr8") — and then failed its on-glass check:
punktfunk-gamescope: error while loading shared libraries:
libdisplay-info.so.2: cannot open shared object file
Self-inflicted: the previous commit also took libdisplay-info-dev from the CI image's
list. gamescope vendors libdisplay-info as a submodule, but it is NOT in
force_fallback_for, so meson preferred the system lib the moment the build box had the
-dev package and linked it SHARED. SteamOS ships no libdisplay-info.so.2, so the binary
built, installed and printed its +pfhdr banner inside the distrobox and could not start
on the machine it exists for.
This is verbatim the wlroots trap the same comment block already documents ("starts fine
on the build host and dies with libwlroots-0.19.so ... anywhere else"), so it gets the
same remedy rather than a second one: libdisplay-info joins force_fallback_for. "Just
don't install the -dev package" does not hold — Debian, Fedora and Arch all have it and
anything can pull it in transitively, and the failure is silent right up to the on-glass
check that build-gamescope.sh happens to run.
Also drop libdisplay-info-dev from the Deck list (pointless once the fallback is pinned)
and record why that list must NOT be synced with ci/gamescope-trixie.Dockerfile: the CI
list targets a .deb that runs on Debian, this one cross-builds in trixie for SteamOS
glass. libx11-xcb-dev and libxkbcommon-x11-dev stay — SteamOS ships both sonames.
The on-glass check did its job here: it caught the bad binary, removed it and left the
box SDR rather than letting the host promise HDR it could not deliver.
write_stamps falls back to a raw-registry write when the property store
denies it. That fallback built its path from MMDEV_RENDER_PATH unconditionally,
so stamping the minted microphone's CAPTURE endpoint reached for
...\MMDevices\Audio\Render\{capture-guid}\Properties - a key that cannot
exist. RegOpenKeyExW then failed, write_stamps returned the error, and
stamp_identity degraded to 'keeps the driver's default name'.
Invisible to the pad program, whose endpoints are render-only, and invisible on
any box where the property store route succeeds (both field logs show
registry=[] on every stamp line, so neither reporter ever took this path). It
only bites where the property store is denied - exactly the boxes the ACL
repair exists for.
The hive now follows the direction the endpoint id encodes, with render as the
default for anything unrecognised. Unit-tested.
ROOT CAUSE of "HDR stopped working after updating to 0.31.2" on a Deck source install.
Two defects, one symptom.
1. scripts/steamdeck/build-gamescope.sh has been UNBUILDABLE since 2026-08-13, when
3ac4548c turned `-Denable_gamescope_wsi_layer=true` on. The layer needs x11-xcb, which
Debian splits into its own libx11-xcb-dev; the distrobox apt list — last touched
2026-07-31 — never got it. MEASURED on debian:trixie against that list verbatim,
gamescope at the pinned 5fb8dce4:
Run-time dependency x11-xcb found: NO (tried pkgconfig and cmake)
src/layer/meson.build:3:14: ERROR: Dependency "x11-xcb" not found
`meson setup` exits 1 with the list as it was and 0 with libx11-xcb-dev added, and
build-punktfunk-gamescope.sh treats a missing layer as a hard error, so the whole build
fails. ci/gamescope-trixie.Dockerfile walked into the identical trap one release later
(1b28a7f7, v0.28.1) and asserts x11-xcb at image build; this list never got the same
fix. Debian-family only: Arch's libx11 and Fedora's libX11-devel carry x11-xcb.pc.
xkbcommon-x11 and libdisplay-info measured absent too, and are added with it.
2. The build-failure branch then called `unwire`, deleting PUNKTFUNK_GAMESCOPE_BIN from
host.env. A failed build REPLACED NOTHING — the previously installed binary is still on
disk and still passes `verifies`. So a rebuild that never landed took HDR away from a
box that had been streaming it minutes earlier. The script warns into a log nobody reads
and exits 0, the update reports success, and the host then resolves the distro's stock
/usr/bin/gamescope at patch level 0 and fixes the session at 8-bit SDR in the Welcome —
which the punktfunk/1 handshake cannot take back.
A verifying binary now stays wired (and a box a previous run of this bug unwired gets
re-wired). `unwire` happens only where the binary itself fails its on-glass check, which
is the branch that also removes it.
Also promote the "no +pfhdr marker" line from DEBUG to INFO. The handshake already reports
capture_supports_hdr=false at INFO while the one line saying WHY sat a level below it —
that asymmetry is what made this field report expensive to answer.
Verified: the meson reproduction above (exit 1 -> exit 0); all three added package names
resolve on trixie and satisfy their pkg-config modules; the four states of the changed
branch exercised in isolation (working binary stays wired, stock/missing binary unwired,
previously-unwired box re-wired). Not verified on real SteamOS glass — the lab VM was
unreachable from this machine.
Minting an audio devnode is two PnP steps: SetupDiRegisterDeviceInfo makes it
real and bindable, then the owner marker goes into Device Parameters. A host
that dies between them - the 0.30.0 TLS-destructor abort did exactly this,
five times on one field box - leaves a registered, driver-bound, endpoint-
serving devnode carrying no marker.
Nothing resolved it afterwards. find_role_devnode matches on the marker, so
the next pass minted a SECOND devnode and the orphan stayed: a duplicate
'Punktfunk Speakers'/'Punktfunk Microphone' in the Sound zoo that no uninstall
removed, because devnode_cleanup is marker-matched too. A field box showed
exactly this shape - 'Punktfunk Speakers (3- Punktfunk)' beside an unstamped
'Punktfunk Speakers (4- Steam Streaming Speakers)' - in every wiring plan it
logged. Reproduced on .173 against the shipping 0.31.2 binary by clearing the
marker: ROOT\MEDIA\0005 was minted and 0004 was abandoned, still active and
still serving two live Punktfunk Microphone endpoints.
* minted.rs adopts before it mints. An unmarked ROOT\MEDIA\NNNN devnode
carrying the role's Steam hardware id is re-marked and reused, so the
endpoint GUID survives and no device-change broadcast is paid.
* devnode_cleanup sweeps the same shape, so orphans already on a box go at
uninstall instead of outliving the product.
The instance prefix is what keeps both off Valve's own devices: Steam's
devnodes carry these hardware ids and are ROOT-enumerated too, but live under
ROOT\SteamStreamingSpeakers\* / ROOT\SteamStreamingMicrophone\*. Only
ROOT\MEDIA\* can come from our SetupDiCreateDeviceInfoW(DICD_GENERATE_ID).
is_abandoned_mint carries that rule with unit tests.
Reproduced on both Bazzite 44.20260818 and Nobara f44: a host killed mid-takeover
left the box's Game Mode running `/usr/bin/sleep infinity` with the panel lit and
blank, permanently. Fixed and re-verified on both boxes against canary
0.32.0-0.ci15147.gc63e8cee, with no regression to the ordinary disconnect.
The hand-back also measures its own outcome now instead of trusting a systemd job
status, so any other route to a dark panel is caught and escalated rather than
logged as success.
Three defects reported against a Bluetooth DualSense on a Fire TV Stick 4K Max,
re-implemented from #371's diagnosis. #371 itself should not be merged: all
three problems are real, but each fix lands somewhere that breaks more hardware
than it repairs.
1. Some buttons never reach the stream. Fire OS is reported to tag certain
DualSense buttons SOURCE_KEYBOARD even though the keycodes are standard
BUTTON_*, and MainActivity's `event.isFromSource(SOURCE_GAMEPAD)` gate then
drops them. The event's source class is the platform's per-event guess; the
DEVICE's is the fact. New `MainActivity.fromPad` widens to the device — but
ONLY for `KeyEvent.isGamepadButton` keycodes. That exclusion is the whole
safety of it: DPAD keycodes are a keyboard's arrow keys and BACK is a
remote's way out of the stream, and both share their keycodes with a pad.
`Gamepad.isPad` is untouched (source-class only) and no vendor-id or
device-name matching is added anywhere — the field report records both pads
being IDENTIFIED correctly; only their button positions were wrong.
2. Touchpad click and Mute were dropped. Both have wire bits (BTN_TOUCHPAD,
BTN_MISC1) and no Android keycode, so GENERIC_SONY's `0x13d`/`0x13e` rows now
borrow BUTTON_15/BUTTON_16 to carry them into `buttonBit`. Inside
GENERIC_SONY and nowhere else: `0x13d`/`0x13e` are BTN_THUMBL/BTN_THUMBR —
L3 and R3 — in the standard Linux mapping, and they mean touchpad and mute
only inside the straight-through report order a driverless pad uses. A row in
SONY_MODERN, or an override above `padMap(dev)`, costs every Xbox pad, Switch
Pro, 8BitDo, Steam Deck and hid-playstation DualSense both stick clicks.
`correct()`'s `genericKeyCode` guard stays exactly as it was.
3. Mute toggles the mic — once per press, and only on a pad that has one.
Edge-triggered through the existing `completesChord` as the one-button chord
it is: `onButton` still calls `slotButton(down = true)` on auto-repeat, so an
unguarded check would flap the mic for as long as the button is held. Gated
on a new `Slot.hasMuteButton`, because BTN_MISC1 is the wire's misc/QAM bit
and `Sc2Device` puts a Steam Controller 2's QAM button on it — "any MISC1"
would mute the microphone on every QAM press. Resolved at slot open from what
each path knows: the report order for an InputDevice, the declared kind for a
capture link. Under the "local" system-button policy a real mute button is
exempt from the early return (that policy means the press stays with this
device, which is exactly what the toggle does) and loses only its wire send;
every other system button behaves as before.
Tests: `every other pad keeps L3 and R3 on those scancodes` is the regression
that matters and fails on #371's shape (verified by reproducing it). Plus the
rewritten touchpad/mute assertions, the guard's negative path — untested in
either direction until now, because every existing case fed `correct()` the
keycode `Generic.kl` would have produced — and the mute button's edge rule in
GamepadChordTest. `an Xbox pad at the standard positions keeps X, Y and its
shoulders` is kept.
Not yet verified on hardware: no Fire TV Stick 4K Max or DualSense here, and no
adb device attached. §1's premise (the SOURCE_KEYBOARD tagging) is therefore
unconfirmed — the change is a no-op if it does not hold.
Field reports on 0.31.x, Bazzite and Nobara: after disconnecting, the box's own
physical screen stays black.
I could not reproduce it (PR #375 has the full negative write-up: five scenarios
across both distro families on the real VMs, all recovering cleanly, and the
mechanism I first proposed disproved on glass). So this does not guess at the
trigger. It closes the gap that lets ANY trigger end as a dark panel, and fixes
the one black-screen path I could prove.
## The restore never checked its own work
`do_restore_tv_session` issues a lifecycle verb and logs what systemd said about
the JOB. "The job succeeded" and "the box shows a picture" are different
questions, and nothing in this file has ever asked the second one — the restore
walks away the moment the verb returns, so every way the box can end up dark
looks identical to success in the log.
So measure it. After the hand-back a detached watcher polls
`detect_active_session()`, whose `None` means no compositor of our uid is running
at all — exactly the symptom. If the box is still dark 25 s later it climbs a
ladder of remedies, each measured on both images (Bazzite 44.20260818, Nobara
f44, 2026-08-22):
1. STOP the autologin unit. Its login session's script is parked on
`systemctl --user --wait start <unit>` on both images, so a stop releases that
wait, the session exits, and `Relogin=true` logs back in — starting the unit
inside a session with a seat. `stop`, not `restart`: a restart does NOT
release the parked waiter (measured), which is why it cannot rescue a box the
ordinary restart already failed to bring back.
2. Restart the display manager — what the pre-0.31.0 takeover did on every
disconnect, and proven on the Bazzite VM to return the box to game mode.
3. `PUNKTFUNK_RECOVER_SESSION_CMD`, then an ERROR naming the command a human has
to run.
Detached, and that is load-bearing: the restore holds `RESTORE_FLIGHT`, which a
reconnecting client must take before it can re-take the box, so watching for up
to a minute while holding it would put that wait in front of every reconnect.
The watcher also stands down the instant `takeover_live()` says a new takeover
armed — the box belongs to that stream now, and a remedy fired into it would be
a fresh bug. It runs after `clear_takeover()` so that check means "a client
reconnected" and not "our own takeover has not been filed yet".
Skipped on the shutdown path: `restore_takeover_now` runs inside `native.rs`'s
20 s `SHUTDOWN_RESTORE_GRACE`, and spending that grace watching would cost the
hand-back rather than check it. What covers a shutdown that left the box dark is
the next host start — which this commit also makes true.
## A crashed host left the box's game mode asleep, provably
`restore_takeover_on_startup` sweeps a leftover idle drop-in off the box and logs
that the box's "own Game Mode session would have started and then done nothing".
Removing the FILE does not touch the unit RUNNING under it: its `ExecStart` is
still the sleep, so it sits `active` drawing nothing. Nothing below that sweep
restarts it either — the takeover file may be absent, unparseable, or fail
`takeover_state_is_live`, and all three exits leave the box on a dark panel with
its game mode "running". Any host killed mid-takeover (SIGKILL, OOM, a yanked
update) lands exactly there, and it survives until someone reboots.
`hand_back_idled_units_after_crash` restarts those units, gated on the box
actually being dark so a user already in game mode or on a desktop is never
bounced, and only for ACTIVE instances — under a just-removed idle drop-in,
active means "running the sleep".
## Not changed
The `restart` verb on the ordinary restore path. It works on both distros
(measured), and 0.31.0 chose it deliberately for the idled unit. The `stop` idea
survives only as escalation rung 1, where it runs after the proven path has
already failed.
`listed_autologin_units` is factored out of `stop_autologin_sessions` so both
callers share it, and its column parsing — which decides whether a live gaming
session can be told from a dead leftover — finally has a test against real
`--plain` output from both images.
The startup link-capacity probe burst at a flat 2 Gbps on the reasoning that it
must measure the link and not itself. That reasoning is obsolete: the ABR
already clamps the measured ceiling to `stream_cap_kbps` (what this session's
mode + codec could plausibly use), so every bit measured above `cap / 0.7` is
discarded the moment it lands. The height bought a number nothing reads, and
paid bufferbloat for it — a constrained Wi-Fi link can black-hole under it
(measured on webOS: a 6 s probe timeout delaying first video to 14 s, and a
"successful" probe still reporting send_dropped=20211; the same shape is now
reported on a Fire TV Stick 4K Max).
Derive the target instead: `stream_cap_kbps × 2`, capped at the old 2 Gbps.
×2 is the smallest multiplier that can still prove the cap (the ceiling is
`delivered × 0.7`, so proving it needs `delivered ≥ cap × 1.43`), so this can
never cap anyone — a session whose mode justifies a high ceiling asks for a
high target by itself, and a mode `stream_ceiling_kbps` declines to size still
gets 2 Gbps. Deliberately not a platform `cfg!`: the constraint is the
session's, not Android's, and webOS has the same bug.
Second half of the black screen: if the burst takes the first keyframe down
with it, nothing re-requests one and the client sits on black until an
unrelated recovery path happens to fire. Ask for a keyframe at probe end when
no frame completed across the burst — compared against the count snapshotted
at the burst's leading edge rather than against 0, so it also covers a
mid-session embedder speed test that kills a running stream. One request per
probe, through the control task's coalescer, so it cannot IDR-storm.
`PUNKTFUNK_ABR_PROBE_KBPS` and its `> 0` filter are unchanged.
Field report: on Android TV / Fire Stick, latency explodes whenever the client's
refresh differs from the host's, and setting the refresh by hand is the only
workaround.
The client was manufacturing that mismatch itself, in three steps:
1. `MainActivity.onCreate` pins the panel to its highest-refresh mode for the
console UI (`setConsoleHighRefreshRate(true)`) — unconditionally, TVs
included. That pin exists for phone refresh governors (Nothing OS's LTPO
logic among them) which cap third-party apps at 60 Hz. No TV has one.
2. At connect, `nativeDisplayMode` resolves "Native" refresh from
`display.mode` — which now reports the mode the MENU pinned, not the TV's
real HDMI output. So the session negotiates (say) 120.
3. `StreamScreen` releases the pin again on TV, by design: there the decoder's
own `setFrameRate(CHANGE_FRAME_RATE_ALWAYS)` governs the HDMI mode. The
panel falls back to 60 while the host is already serving 120.
A 120 fps stream on a 60 Hz output, by construction, on exactly the two form
factors in the report. Picking a refresh explicitly is precisely what bypasses
step 2, which is why that is the workaround people found. The mode comparator
sorts refresh before area, so the same pin could also drop a 4K TV to 1080p120
and negotiate the stream at that.
Fixed at the choke point: `resolveHighRefreshMode` returns early on a TV, leaving
`highRefreshModeId` at 0, which `setConsoleHighRefreshRate` already treats as a
no-op — so all three of its callers are covered by the one guard. A TV that
genuinely wants 120 still gets it by choosing it, driven by the native mode
switch, exactly as the TV path documents.
Also in the same chain: `nativeDisplayMode` TRUNCATED the panel rate, so a TV
reporting the fractional NTSC rates over HDMI (59.94, 29.97, 23.976) asked the
host for 59 / 29 / 23 — rates no display mode has, which the host serves by
clamping down to the highest it advertises at or below. Rounded now, which also
makes it agree with `MainActivity.streamPanelFps`; the two describe the same
panel and must not disagree.
The window was `frame_interval * 2`, which is 16.7 ms at 120 fps. A Moonlight
client that has lost decode sync re-asks for an IDR roughly every 30 ms, so the
gate never closed between requests and effectively every request became a full
keyframe.
Field log (AMD RX 7800 XT, Bazzite 44, 1080p120 HEVC over the GameStream plane):
1118 IDR requests in one 91 s session, 1115 honoured, only 3 coalesced — about
one full IDR every tenth frame at a 100 Mbps target. IDRs that size saturate the
send path, which causes the loss that prompts the next request, so the storm
sustains itself. It reads as stutter at a flat latency, because frames are being
lost rather than queued. The same session's H.264 leg (libav VAAPI, same
bitrate) took 2 IDR requests and was clean.
The window is a round-trip bound — how long until the client can receive and
decode the IDR it already asked for — so it needs an absolute floor rather than
a frame count. 100 ms matches the encoder-reset backoff in the same loop.
Simulated against the logged 30 ms request cadence, this cuts honoured IDRs over
a 91 s session from every request to roughly a quarter, while still recovering
promptly from a genuine loss event.
Every pf-decode SIGABRT on the Shield is fdsan catching a double-close of the
acquire fence the ASC presenter passes to ASurfaceTransaction_setBuffer, in
three shapes: inside Fence::Fence(int) under setBuffer when the number had
already been re-owned ("fd N is owned by unique_fd, was expected to be
unowned"), at the end of Transaction::apply when the layer state is torn down,
and in Parcel::freeDataNoInit once the number churns.
The fence is not ours to give. AImageReader::acquireLatestImage drains with a
single int* out-param it overwrites per image, then releases each dropped image
with whatever that out-param currently holds — the successor's fence — and
returns the last value written. So as soon as a burst gives it two images to
collapse, the caller receives an fd the reader has already adopted and closed,
plus one leaked fd per extra drop. This is unfixed as of AOSP main, so the
newest-wins collapse has to happen on our side.
Drain both present intents with acquireNextImageAsync, whose fence is always a
fresh dup we exclusively own, and let latency pick the newest itself — the loop
the smoothing FIFO already ran. Superseded candidates drop as before: image back
to the pool, its own acquire fence closed. Reader drops now show up in `skipped`
instead of vanishing inside the reader lock.
The GameStream twin of the native fix. A fullscreen game can mode-set the
virtual display mid-stream; the IDD-push capturer re-opens its ring at the
new mode, and `try_latest` then hands this loop a frame the encoder cannot
accept. Every submit fails, the submit ladder rebuilds the encoder IN
PLACE at the same configured size — which cannot converge on a size the
source has already left — and after five resets the stream ends, costing
the Moonlight client a full disconnect/reconnect.
Reopen at the delivered size instead, with the same bookkeeping the
capture-loss rebuild in this loop already does (ring depth, RFI caps,
forced IDR, in-flight numbering restart). A failed reopen spends the
shared `encoder_resets` budget at the existing exponential pace rather
than ending the stream on the first try — a mode-set leaves the driver
settling, which is what that backoff exists for.
`gs_bit_depth(frame.format)` is derived per open, so an HDR flip that
recreates the ring at P010 now re-opens at the right depth too.
The client is NOT told: GameStream has no mid-stream mode-change message,
so Moonlight decodes a bitstream that disagrees with the resolution it
configured its decoder from. That is the same bargain the first open in
this function already takes whenever the captured size differs from the
negotiated one (the monitor-mirror case, §7.3) — tolerant decoders re-init
off the SPS and scale; a strict one (Media Foundation on Xbox) may stall
and drop the session. Taking it here too is strictly better than the
alternative, which is ending every stream the moment a game changes mode.
The guard carries that note.
A trailing comment that long makes rustfmt treat the two comment lines
that follow it as a continuation of the same block and reflow them into a
hanging indent past column 60, which fails `cargo fmt --all --check`.
Put it on its own line above the statement instead.
`ci / rust` failed the unsafe-hygiene gate: mgmt/tests.rs went to 6 process-global-API
mentions against a baseline of 3. The new rename test had copy-pasted the existing
`EnvGuard` + CONFIG_DIR_TEST_LOCK + tempdir dance, which is exactly the duplication gate C
exists to catch — its advice is to fix the call site rather than raise the baseline.
So there is now ONE `ConfigDirOverride` both tests use. It also makes the pairing harder to
get wrong than the copies were: the lock is a FIELD rather than a separate `_serial` binding
a test could forget, and since `Drop::drop` runs before any field drops, the environment is
restored while the guard still holds the lock.
Back to 3 mentions, and `sh scripts/ci/check-unsafe-hygiene.sh` reports all three gates clean.
Note the last one is a PROSE mention: the grep counts comments too (deliberately — "keep it
dumb and stable"), so the doc comment had to stop naming the function it warns about.
Not re-run on .173: the box went off-network mid-change. It does not need to be — this is
`mgmt/tests.rs`, which is not Windows-gated, so Linux CI compiles and runs it. The Windows-only
verification (clippy over the `cfg(target_os = "windows")` devtest change) was already done and
that file is untouched here.
The reopen added in the previous commit bailed the session on the FIRST
failed `open_video`. That is worse than what it replaced: the mode-set
that triggers the reopen is exactly the kind of event that leaves the
driver settling, which is the transient the submit path's backoff already
exists for ("NVENC session open failing after a codec switch", 2026-07 —
no 8 ms retry could outlive it).
Spend the shared `encoder_resets` budget on it at the same exponential
pace (100 ms → 1.6 s), re-entering the follow-the-source guard each round.
The old encoder stays installed and mismatched meanwhile, so it simply
keeps failing submit until an open succeeds or the budget runs out — the
same ~3 s ceiling as before, but now every round is a real attempt at the
new mode instead of an in-place re-init that cannot converge.
Also tag the exhausted path accurately: it is an encoder REOPEN failure,
not a submit failure, and the session-end log prints that context.
A fullscreen game can mode-set the virtual display mid-session with no
client Reconfigure. The IDD-push capturer already handles that — it
re-opens its ring at the new mode on a confirmed descriptor change — but
nothing re-opened the ENCODER, which is the one component that cannot
follow a resolution change in place.
Every submit then failed with "captured frame 1920x1080 != encoder
3840x2160", and the submit-error path only rebuilds the encoder IN PLACE
(Terminate + re-Init at the SAME configured size), which cannot fix a
size the source has already left. All five resets burned on it and the
video session ended ~3 s later, with audio still running — the client
sees a frozen picture and has to reconnect.
Field report 2026-08-22 (host 0.31.2, RX 6800 XT, AMF/HEVC 4K60):
IDD push: display descriptor changed — recreating the ring at the new
mode target_id=259 from=3840x2160 hdr=true to=1920x1080 hdr=true
encoder submit failed — encoder rebuilt in place, forcing an IDR
error=captured frame 1920x1080 != encoder 3840x2160 reset=1 max=5
... reset=5 max=5
encoder did not recover after repeated in-place rebuilds — ending the
video session ... resets=6
Track what the encoder was opened against and, when the source delivers
something else, re-open at the delivered size through the same
`open_video` path the client-initiated resize uses — then publish the new
mode to the client exactly as an accepted resize does, so its mode slot,
stats and aspect follow. PyroWave's Automatic rate is re-resolved for the
new mode (it is a per-mode bpp pin); H.26x rates stay with ABR.
Also covers a mid-session frame-format change (an HDR flip re-creates the
ring at a new format), which failed the same way.
The GameStream/Moonlight loop has the identical gap, left alone here: that
protocol has no mid-stream mode-change message, so following the source
there needs its own decision.
A player had to launch Rocket League twice: the first launch streamed the
"Processing Vulkan shaders" dialog and then dropped, ten seconds in. The host
did that to itself.
`reaper SteamLaunch AppId=<appid>` is the *appid's* wrapper, not the game's.
Steam wraps its pre-launch work for a title in one too, so a launch is a chain
of appid-tagged trees and only the last is the game. The lease matched the
first tree two seconds in, and that single sighting latched it out of the start
phase (START_GRACE, five minutes, ending nothing) into the exit watch
(EXIT_CONFIRM, three seconds, ending the session). When the tree exited with
the game still starting, the watch called it the game exiting and closed the
connection with APP_EXITED.
Linux has nothing to catch that: `procscan::running_hint` is Windows-only, and
no provider reports runstate for Steam, so an appid scan with three seconds of
slack is the whole signal. (Steam's registry.vdf is not an option — RunningAppID
is no longer set on modern Steam Linux, and the per-app Running key is
unreliable.)
Two layers, because only one of them can be certain:
* The matcher now rejects a `SteamLaunch AppId=` reaper whose payload is
`fossilize_replay` — Steam's shader replayer, never a game.
* A scan match must be seen continuously for SHIM_WINDOW before it latches.
This is the rule already applied to a spawned child ("a launcher about to
hand off looks exactly like the game for its first few seconds"); the scan
side never had it. It bounds the pre-launch trees nobody has named yet, at
the cost of a few seconds of GameRunning latency. Exit detection is
untouched, and a provider report still latches immediately — that is the
launcher's own statement, not an inference from a lookalike.
The log said `procs=1` and never which process, which is what made this
unclosable from a log alone; `procscan::names` puts that on the line.
Reported from the field: "is there a possibility of renaming the moonlight paired
devices? as they're all named CN=NVidia Gamestream Client". They are, and it is not a
display bug — every moonlight-common-c client self-signs with that same fixed subject,
so the certificate carries no device identity at all. Until now the console listed that
string for every Moonlight row, which means a user with a phone, a TV and a Switch saw
three identical rows and had nothing but a fingerprint prefix to tell them apart — most
sharply when deciding which one to unpair.
The name is an operator-supplied label, stored host-side keyed by fingerprint:
* `client-labels.json`, a SIDECAR to `paired.json` rather than a field inside it.
`paired.json` is a bare `Vec<Vec<u8>>` of DERs, so giving it a shape would be a
migration on the one file that decides who may connect — and a label is not part of
that trust decision, so a corrupt or missing label file must never be able to lock
anyone out. Same atomic temp-file + rename as `save_paired`.
* `PATCH /api/v1/clients/{fingerprint}` sets or clears it; `GET /clients` grows a
`label`. A whitespace-only body clears rather than storing a blank name, and only an
already-paired fingerprint may be named (a label for an unknown one would be
invisible and never cleaned up). Unpairing forgets the label, so the file cannot grow
without bound and a re-pairing of the same certificate starts unnamed.
* Scrubbing reuses `native_pairing::sanitize_device_name` rather than growing a second
one: it already strips C0/C1 controls and Unicode bidi overrides and caps at 64.
That is not cosmetic here — the label is the ONLY thing distinguishing two paired
devices in the console, so an unscrubbed one could dress a stranger's device up as
the operator's TV and be spared an unpair on that basis. For the same reason the new
route takes the plugin/cert lanes of the DELETE beside it (neither may reach it),
not the roster GET's read permission; the lane test now pins that.
* Console: a pencil on Moonlight rows opens the existing `promptText` dialog seeded
with the current label (not the `CN=…` fallback, or every rename would start by
deleting boilerplate). Native rows keep their pairing-supplied name and get no
pencil.
Test: one round trip through the API — name it, see it in the list, watch the bidi
override and the whitespace collapse get scrubbed, clear it two ways, reject a
malformed and an unpaired fingerprint, and assert the unpair forgot it on disk.
VERIFIED on .173 (the Windows box, since punktfunk-host does not build on macOS):
`cargo test -p punktfunk-host mgmt::` → 58 passed, including the new
`client_label_round_trips_scrubs_and_is_forgotten_on_unpair` and both guardrails that
caught this work in progress (`every_route_is_classified_for_the_plugin_and_cert_lanes`
and `openapi_document_is_complete_and_checked_in`). Web `tsc --noEmit` clean.
Two notes on the diff, both PRE-EXISTING and verified as such rather than assumed:
* `sdk/src/gen/punktfunk.ts` is bigger than this feature. Regenerating it from the
UNCHANGED committed spec already produces a ~700-line diff, i.e. the checked-in copy
had drifted from its own pinned generator — nothing in CI regenerates or verifies
it. This lands the clean regeneration rather than hand-patching generated code.
* `api/openapi.json` was regenerated on Windows, not CI's Linux. Checked structurally
before committing: the only differences are `PATCH /clients/{fingerprint}`, the
`RenameClient` schema and `PairedClient.label` — no OS-driven drift.
Unrelated and NOT touched: `mgmt::tests::display_monitors_answers_even_with_no_compositor`
fails on Windows, at HEAD as well. It answers `compositor="windows", monitors=[],
error=null`, and the test's escape hatches only cover gamescope, an absent compositor or
an error. Either the test needs a Windows arm or Windows display enumeration is returning
nothing it should — that is a real question, so it is left for someone to answer rather
than papered over here.
Chasing "gamepad still dead on GameStream clients after dfcffcdd" (Artemis on
Android, Moonlight on a Switch; both report only mouse/touch working). dfcffcdd
moved this plane from the XUSB companion to the UMDF HID Xbox pad and was verified
by `cargo check` + `clippy` only, so nothing about it had ever run.
The suspicion this flag was built to test: `UhidManager` has a `heartbeat` whose
own doc says a UMDF pad "treats a multi-second input silence as an unplugged
controller", the native plane calls it every tick, and `SessionPads::pump_rumble`
does not. That asymmetry looked decisive because the two planes differ in exactly
the way that would expose it: punktfunk's own client re-sends every live pad's
snapshot every 100 ms unconditionally (`input_task.rs` refresh tick), so a native
pad is never silent, while moonlight-common-c sends a controller packet only on
CHANGE — an untouched pad emits nothing at all.
`--idle-after N` stops the state frames while still pumping; `--resume-after M`
starts them again, because enumeration surviving a silence proves nothing on its
own (a pad can stay listed and deliver no input) — what matters is whether a report
written after the silence still lands.
MEASURED on .173 (Win11 26200), and it does NOT reproduce: with `--xboxhid
--idle-after 12 --seconds 75`, the pad sat through 58 s of total input silence with
`SWD\PUNKTFUNK\PF_XBOX_0` at Status=OK and its promoted `HID\PUNKTFUNK&IG_00` child
still present the whole time. So the heartbeat gap is NOT the field bug, and the
one-line "add a heartbeat to the GameStream arm" fix this was going to justify is
not warranted — which is the point of landing the probe rather than the guess.
Also measured with the same binary, and worth recording because it IS real:
* two LIVE processes wanting pad index 0 collide exactly as `PadCreateFault::
IndexOwnedElsewhere` describes (`Global\pfds-boot-0`, ACCESS_DENIED because the
mailbox DACL is SYSTEM+LocalService). dfcffcdd put BOTH input planes on that one
name — before it, GameStream used `Global\pfxusb-boot-0` and the two could never
collide — so the hazard is new, even if it is not what the reporter hit.
* a clean release-then-retake does NOT collide: back-to-back runs at 0 s, 1 s and
3 s gaps all created their pad, so an ordinary client reconnect is not the trigger.
Ruled out on the same box while here: the driver package (`pf_gamepad.inf` 08/18
declares all three Xbox hwids and the `xinputhid` promotion), stale drivers in the
field (the Windows updater is a full Inno Setup run that re-runs `driver install
--gamepad`), and access grants (a Moonlight fingerprint has no grants record, which
`control.rs` reads as GRANT_ALL).
Still open, and it needs a live session: .173 runs `PUNKTFUNK_HOST_CMD=serve`, i.e.
GameStream is switched OFF, so this box has never exercised the plane dfcffcdd
changed. That is how a compile-only fix reached users unexercised, and it is the
first thing to change before the next attempt.
flatpak-deps-present.sh decides whether the job talks to Flathub at all, and
a push-paths filter that ignores it means a change to that decision ships
untested until the next unrelated client commit happens to rebuild. Same
reason .gitea/workflows/flatpak.yml is already listed.
The flatpak job died on every attempt with
Updating runtime/org.freedesktop.Sdk.Extension.rust-stable/x86_64/25.08
Error: Failed to update org.freedesktop.Sdk.Extension.rust-stable: While
pulling ... .filez: Server returned HTTP 404
dl.flathub.org was serving a 404 for one object of the then-current
rust-stable//25.08 commit. retry.sh burned all 10 attempts (~9 min) on the
same object, and flatpak-builder segfaulted on its own error path (rc=139),
so the wrapper could not tell a dead end from a load blip either.
Root cause is ours, not Flathub's: `--install-deps-only` does not install
what is missing, it UPDATES what is present. builder_manifest_install_dep()
branches on `flatpak info --show-commit <ref>` succeeding and runs
`flatpak update` for every already-installed dep, with no fallback to a
plain install when that update fails. ci/flatpak-ci.Dockerfile bakes the
entire runtime set, so that update was a pure no-op on a healthy run while
making every build depend on Flathub's health at that minute. Nothing wanted
the newer commit — the manifest pins a runtime VERSION, not a commit.
So ask first, and reach for Flathub only on a real miss. The check is
scripts/ci/flatpak-deps-present.sh (runtime + SDK at the manifest's exact
runtime-version, sdk-extensions by presence, since their version comes from
the SDK's metadata and any bump that moves them moves runtime-version too).
It fails OPEN: anything it cannot parse takes the full install path. Its
--self-test stubs `flatpak` and covers baked / cold / each dep missing /
wrong version / unreadable manifest.
Also drop --install-deps-from=flathub from the build step. Its comment
called it "a no-op safety net"; builder-main.c calls
builder_manifest_install_deps() whenever that flag is set, and
--install-deps-only only decides whether it exits afterwards, so the step
billed as offline was re-running the same update — and could only ever fire
if the prefetch step had already failed the job.
packaging/flatpak/build-flatpak.sh keeps --install-deps-from: a dev box
genuinely wants deps installed, and it has no baked image.
10 commits since v0.31.1 (6 non-merge). Cut from origin/main 48eeae75 (#368
merged).
THE NUMBER: a patch, and unlike the last cut the version table does not even
have to argue for it. Nothing versioned moved — WIRE_VERSION 2, C ABI 25 with
include/punktfunk_core.h showing NO diff at all against the v0.31.1 tag (not
even a #define, unlike the last two releases), driver protocol 6 / min 3 with
pf-driver-proto unchanged, gamepad channel 3, plugin index schema 1, host event
schema 1, gamescope +pfhdr8 with no new patch files, SDK 0.1.5 and plugin-kit
0.4.4 both untouched. No `!` commit, no feat, no route added or removed, no
breaking change of any kind. Every non-merge commit is fix/refactor/test.
The cycle has a shape: three of the six non-merge commits are the same class of
fault — the host using the wrong local address — reached from three directions.
The data socket bound 0.0.0.0:0 and let routing pick the video source, which the
client's connected socket then dropped in-kernel (#367). Host::detect() froze the
advertised address at process start, so a cold boot that beat the network pinned
127.0.0.1 for the life of the process and broke both mDNS adverts, the Moonlight
session URL, the WoL mac record and HostInfo together (#366). And the firewall
rules guarding the ports those addresses point at admitted any program on the
machine (#368). The fourth is an Android regression from v0.31.1 (#365); the
remaining two are the refactor and test supporting #366.
api/openapi.json changes in DOCUMENTATION ONLY this time — two description
strings on HostInfo, no route, schema, required field or type — plus the stamp.
Re-stamped here, not regenerated: punktfunk-host does not build on macOS, and
#366 regenerated the document itself on a runner where
openapi_document_is_complete_and_checked_in actually executes. "0.31.1" appears
nowhere in either copy afterwards, and the two copies are byte-identical.
That description change is load-bearing rather than cosmetic, so it is called out
as a behaviour change in the CHANGELOG beside the firewall one: HostInfo.local_ip
was a field snapshotted at detect() and is now a method that re-reads per
request, so a consumer that cached it at startup was caching a value that could
be 127.0.0.1 forever.
The other behaviour change is the externally visible one: Windows service install
now scopes all five fixed-port rules to the listening executable while keeping
their localport=, so 5353 is punktfunk's alone and anything else on the machine
that was reachable on mDNS through our any-program rule needs its own. Fallbacks
are asymmetric on purpose — a fixed-port rule that cannot resolve its exe falls
back to the old wide form (a looser rule still streams), while the data-plane
rule skips (it has no port to fall back to, so a program-less version would not
be looser, it would be open).
Also in this commit, because a cut is when docs freshness bites:
docs-site/content/docs/ports.mdx. Its "Video needs nothing opened" bullet has
been wrong for Windows since v0.31.1 added the data-plane rule — it now says so
and names why (no fixed rule can cover a per-session ephemeral port). And the
Windows line gains a Callout for the 5353 change above, since that is the one
thing on this page a reader may have to act on. Callout shape copied from the
proven usage in plugins.mdx (no `title` prop — node_modules is not installed here
and fumadocs' prop surface could not be verified offline).
Play notes are Android-only per whatsnew/TEMPLATE.txt, which this cycle means the
#365 regression alone. The three host-side fixes are deliberately NOT in there:
updating the app does not fix any of them, so listing them on the store page
would promise something the download does not deliver.
Gates: cargo fmt --all --check clean; cargo metadata --offline ok with the
Cargo.lock diff versions-only (36/36); cargo test -p punktfunk-core --lib 273
passed; the C ABI harness PASSED reporting abi_version=25 (needed `brew install
opus` on this Mac to link — the first run failed on the missing library, not on
the code); cbindgen regenerated include/punktfunk_core.h during that build and it
came out byte-identical to the checked-in file AND to the v0.31.1 tag, which is a
stronger check on the ABI row than diffing it; scripts/ci/check-docs-drift.sh
clean; scripts/ci/check-docs-links.sh clean; the android.yml Play notes gate run
verbatim, 357/500 characters and unique; both openapi copies cmp identical and
stamped 0.31.2; notes voice scan clean (one backticked term in the whole file,
the `punktfunk-host service install` command, and the only technical vocabulary
sits inside `## For developers`).
Not run here, and why: clippy and any punktfunk-host build (does not compile on
macOS — CI covers it), and the Android unit tests (:kit: and :app: were run on
#365 itself; nothing in this commit touches Kotlin).
One judgement call left for the tag: SECURITY.md promises to credit a reporter in
the release notes when the fix is public, and the #368 commit records only "a
user on 2026-08-21" with no name. The notes credit them unnamed. If they want
their name on it, that is a one-line edit to docs/releases/v0.31.2.md before the
tag is pushed.
`service install` added `dir=in action=allow` rules carrying only `localport=`,
which admit ANY process on the machine on those ports — GameStream
(47984/47989/47998-48010/48010), the native plane (9777), mgmt (47990), mDNS
(5353), and the console pair (47992/47993). Binding a high port on Windows
needs no elevation, so an unprivileged program could take any of them and be
reachable from the LAN simply by binding first — silently, because our rule is
precisely what suppresses the "Allow this app to communicate on…" prompt that
would otherwise be the only way in.
Scope every rule to the executable that actually listens on it, keeping the
ports: program AND port is strictly tighter than either alone. The host rules
name the host exe (resolved once and shared with the data-plane rule, which
already worked this way and is the pattern the rest now follow); the console
rules name the bundled `<app>/bun/bun.exe` the supervisor spawns.
The old argument for leaving them unscoped — "an install whose recorded exe
path later moves still has its fixed ports open" — does not hold: `service
install` re-runs this whole remove-then-add on every upgrade, so the path is
refreshed rather than left stale.
Fallbacks are deliberate and asymmetric. A fixed-port rule whose program can't
be resolved falls back to the old any-program form, because a looser rule still
streams and no rule is a black screen. The data-plane rule instead skips: it
has no `localport=` to fall back to, so a program-less version of it would not
be a looser rule but an open host.
One externally visible change, called out in installer output: 5353 is ours
alone now, so anything else on the machine that answered mDNS through
punktfunk's rule needs its own.
Reported by a user on 2026-08-21, after the source-IP fix in #367 resolved
their black screen.
`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.
`Host::detect()` snapshotted the LAN address once, at process start, and every
consumer read that frozen field for the life of the process. On a cold boot the
host outruns the network — the Windows service is `AutoStart` with no
dependencies — so the route probe failed with ENETUNREACH and the loopback
fallback stuck until someone restarted the host by hand.
Four surfaces broke off that one field: both mDNS adverts published 127.0.0.1 as
their A record, `session_url_xml()` handed Moonlight `rtsp://127.0.0.1:48010`,
`wol::wake_macs()` dropped the `mac` TXT record and silently disabled
Wake-on-LAN, and `HostInfo.local_ip` reported loopback to the console.
Fixed at the choke point: `primary_local_ip()` never returns loopback and falls
back to the first non-loopback interface address when no default route exists
yet; `Host::local_ip` re-reads instead of freezing; and a live mDNS advert
re-registers when the routed address changes. Also covers a changed DHCP lease
and a host moved between Wi-Fi and Ethernet.
The route probe needs a default route, which on a cold boot lands after the NIC
has its address; the fallback is what answers in between, and nothing exercised
it. Split it into `first_lan_ipv4` so a test can assert the one thing that
matters: it never hands back the loopback `get_if_addrs` also reports.
std's mpsc doubles as the sleep and the stop signal: the loop times out every
IP_RECHECK to re-check the address, and the Advert dropping its sender wakes the
thread immediately instead of leaving it to notice a flag up to 10s later. Drops
the Arc<AtomicBool> and the Drop impl.
`Host::detect()` snapshotted the LAN address once, at process start, and every
consumer read that frozen field forever. On a cold boot the host wins the race
against the network — the Windows service is registered `AutoStart` with no
dependencies — so `primary_local_ip()`'s route probe to 8.8.8.8 failed with
ENETUNREACH and the loopback fallback stuck for the life of the process.
Restarting the host re-ran `detect()` on a live network, which is the workaround
users found.
Four surfaces broke together, all off that one field:
* both mDNS adverts (`_punktfunk._udp`, `_nvstream._tcp`) published `127.0.0.1`
as their A record — the address a client lists and dials;
* `session_url_xml()` handed Moonlight `rtsp://127.0.0.1:48010` after /launch,
so even a manually-added host could not stream;
* `wol::wake_macs()` found no interface for loopback and dropped the `mac` TXT
record, silently disabling Wake-on-LAN;
* `HostInfo.local_ip` reported loopback to the web console.
Fixed at the choke point rather than per-caller:
* `primary_local_ip()` never returns loopback or the unspecified address. When
the route probe fails it falls back to the first non-loopback interface
address, which exists as soon as the NIC is configured even if the default
route is not installed yet — the common shape of the boot race.
* `Host::local_ip` becomes a method that re-reads instead of a field that
freezes. A `connect(2)` on an unconnected UDP socket sends no packets and
costs nothing beside the HTTP response it is serialized into.
* mDNS records are pushed, not polled, so a live advert re-registers when the
routed address changes (`discovery::advertise_live`, shared by both service
types). It polls the routed address rather than subscribing to the daemon's
IpAdd events because the boot race usually resolves without one: the NIC
often has its address before we register and only the route lands late.
This also covers the sibling cases that were never reported — DHCP handing out a
different lease, and a host moved between Wi-Fi and Ethernet.
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.
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.
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.
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.
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.
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)".
`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.
`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.
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".
`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.
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`.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
`--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.
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>
#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.
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>
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.
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).
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.
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.
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>
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>
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>
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.
`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.
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>
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>
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.
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.
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.
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.
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.
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.
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>
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.
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.
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.
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
264 changed files with 16558 additions and 5462 deletions
if grep -q "STARTING A FULL BUILD" /tmp/android-clippy.log; then
echo "::error::skia-bindings did not get its prebuilt archive and started building Skia from source — the download was dropped (see DOWNLOAD AND INSTALL FAILED above). This is a fetch failure, not a lint failure."
exit 1
fi
exit $rc
# The kit's JVM unit tests — the pure parsers, migrations and feedback policies. They were
# running nowhere: this workflow only assembled, and android-screenshots.yml runs the :app
# Error: Failed to update org.freedesktop.Sdk.Extension.rust-stable: While pulling …
# .filez: Server returned HTTP 404
# dl.flathub.org served a 404 for one object of the then-current rust-stable//25.08
# commit, deterministically — all 10 retry.sh attempts died on the SAME object over
# ~9 min — and flatpak-builder SEGFAULTED on its own error path (rc=139), so retry.sh
# saw a crash rather than a clean "this will never work" either. The build never wanted
# that newer commit: the manifest pins a runtime VERSION, not a commit, and the baked
# one satisfies it. Updating bought nothing and imported an upstream outage.
#
# So: assert what the image already has, and reach for Flathub only on a real miss —
# the same "guard, don't install on top of a stale image" doctrine as the Tooling step.
# The check lives in scripts/ci/flatpak-deps-present.sh (run its --self-test after
# touching it): a bug in it that reports "satisfied" when it is not would build against
# whatever runtime happened to be lying around, which is worth more than an inline
# if-statement. It deliberately fails OPEN — anything it cannot parse takes the slow
# install path below.
if bash scripts/ci/flatpak-deps-present.sh "$MANIFEST"; then
echo "deps satisfied by the baked image — not touching Flathub"
flatpak list --user --columns=ref
else
echo "::warning::$MANIFEST declares deps punktfunk-flatpak-ci does not have — pulling from Flathub (~1.5 GB). Bump GNOME_VERSION/FREEDESKTOP_VERSION in ci/flatpak-ci.Dockerfile so this stays off the hot path."
"description":"Sets or clears the operator-visible display name for one paired Moonlight client. This is\npurely cosmetic — it touches no certificate and no trust decision — but it is the only way to\ntell paired devices apart: every moonlight-common-c client self-signs with the identical\nsubject `CN=NVIDIA GameStream Client`, so an unnamed list is a row of clones distinguishable\nonly by fingerprint. The name is stored beside the pairing store and survives host restarts;\nunpairing the device forgets it.",
"operationId":"renameClient",
"parameters":[
{
"name":"fingerprint",
"in":"path",
"description":"Hex SHA-256 fingerprint of the client certificate DER (64 chars, case-insensitive)",
"required":true,
"schema":{
"type":"string"
}
}
],
"requestBody":{
"content":{
"application/json":{
"schema":{
"$ref":"#/components/schemas/RenameClient"
}
}
},
"required":true
},
"responses":{
"200":{
"description":"The client as it now reads",
"content":{
"application/json":{
"schema":{
"$ref":"#/components/schemas/PairedClient"
}
}
}
},
"400":{
"description":"Malformed fingerprint",
"content":{
"application/json":{
"schema":{
"$ref":"#/components/schemas/ApiError"
}
}
}
},
"401":{
"description":"Missing or invalid bearer token",
"content":{
"application/json":{
"schema":{
"$ref":"#/components/schemas/ApiError"
}
}
}
},
"404":{
"description":"No paired client with that fingerprint",
"content":{
"application/json":{
"schema":{
"$ref":"#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/v1/compositors":{
@@ -1860,6 +1931,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)",
"description":"Host identity and advertised capabilities (static for the life of the process).",
"description":"Host identity and advertised capabilities (static for the life of the process, except\n`local_ip`).",
"required":[
"hostname",
"uniqueid",
@@ -6671,7 +6805,7 @@
},
"local_ip":{
"type":"string",
"description":"Best-effort primary LAN IP."
"description":"Best-effort primary LAN IP, read fresh on every request — a host that started before its\nnetwork did (cold boot) reports `127.0.0.1` only until it actually has an address, and a\nhost that moves networks reports the new one. Poll it rather than caching it."
},
"os":{
"type":"string",
@@ -7312,6 +7446,14 @@
"description":"Lowercase hex SHA-256 of the client certificate DER — the client's stable id here.",
"description":"Operator-assigned display name for this device, if one has been set (`PATCH /clients/{fp}`).\n\nThis is the ONLY thing that can tell two paired Moonlight devices apart in a list, because\ntheir certificates cannot: see [`Self::subject`]. Absent until somebody names the device.",
"example":"Living Room TV"
},
"not_after_unix":{
"type":[
"integer",
@@ -7333,7 +7475,7 @@
"string",
"null"
],
"description":"Certificate subject (e.g. `CN=NVIDIA GameStream Client`), if the DER parses."
"description":"Certificate subject (e.g. `CN=NVIDIA GameStream Client`), if the DER parses.\n\nDo not display this as a device name. Every moonlight-common-c client self-signs with that\nsame fixed subject, so it identifies the *protocol*, not the device — a list of paired\nphones, TVs and handhelds all read identically. [`Self::label`] is the field to show."
}
}
},
@@ -7792,6 +7934,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 +8028,42 @@
}
}
},
"RenameClient":{
"type":"object",
"description":"Body of `PATCH /clients/{fingerprint}` — the device's display name.",
"properties":{
"label":{
"type":[
"string",
"null"
],
"description":"The name to show for this device. `null` (or an empty/whitespace-only string) clears it and\nthe device goes back to being listed by fingerprint alone.\n\nScrubbed before storage by the same sanitizer the native plane runs on device names:\ncontrol characters and Unicode bidi overrides are stripped (they could make one paired\ndevice impersonate another in this very list), whitespace collapsed, and the result capped\nat 64 characters.",
"example":"Living Room TV"
}
}
},
"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.",
@@ -733,7 +744,9 @@ from the config directory for a true factory reset."
};
lethost=&known.hosts[i];
ifhost.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);
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.