Decky, slimmed: a thin Gaming-Mode wrapper around the client #46

Merged
enricobuehler merged 12 commits from worktree-decky-slim-rework into main 2026-08-04 19:41:34 +00:00
Owner

Implements decky-slim-rework{,-implementation-plan}.md — M1 through M4. M5 (the on-glass gate) has not run: the Deck was offline the whole session, so nothing here has been seen on one. See What is still owed at the bottom before merging.

The Decky plugin was a second client: its own mDNS discovery, its own host-store editor, its own settings UI over the entire client settings store, its own per-game pin store and picker, its own fullscreen route with three tabs. Every one of those was a copy — in Python and TypeScript — of a rule that already lives in Rust, and each drifted from the original. They existed because when the plugin was written there was nothing headless to ask. There has been since v0.22.0.

5,340 → 2,830 lines. What is left is what only a Decky plugin can do: start a stream through Steam so gamescope focuses it, and stand in front of the trust decision that gates it.

The CLI gains two things (they ship independently of the plugin)

  • punktfunk discover [--json] [--timeout SECS] — built on a new discovery::discover_for, a bounded collector beside the streaming browse() the UI uses. It annotates each hit against the saved-hosts store rather than handing back two lists to join: saved/paired are answered by fingerprint first, address second, which is what stops a host that moved DHCP lease reading as new and a different box that inherited the old address reading as paired.
  • punktfunk launch --request-access — sets the 185 s budget (the host's PENDING_APPROVAL_WAIT) and records the host as paired when the session reports ready. Refused under --exec with exit 5 rather than silently downgraded: under --exec the CLI becomes the session, so nothing survives to observe the approval.

Plus a live bug the plan flagged as R3: hosts add --fp was silently discarding the fingerprint when the address was already saved, exiting 0 having done nothing — so the host stayed pinless and every later connect refused with no line anywhere saying why.

The plugin

One QAM panel: update banner, hosts, the console-home door, about. Pinned host+profile cards render nested under their host as ▸ <Profile name> — a card is a (host, profile) pair, and a row floating free of its host is exactly the "a pinned tile reads as a duplicate host" problem the desktop shells still have. The plugin renders cards; it creates none.

A host with nothing pinned opens a trust sheet: Request access (the host's operator approves this Deck — no PIN) or Use a PIN instead. No advertised fingerprint means the PIN path only, with the reason stated — that pin is the only thing standing between a 185 s wait and an impostor answering for the host.

The launch path becomes punktfunk launch <ref> [--profile <id>] --exec --fullscreen, and request-access launches run supervised (no --exec) so something survives to record the approval. Wake-on-LAN comes out entirely: the plugin used to fire its own packet and stretch the connect budget to 75 s, a workaround for the CLI-less era; punktfunk launch runs the real wake-and-wait loop and only dials once the host answers.

The settings screen is not lost, it moved: console home → Settings, same rows over the same store, gamepad-navigable, one tap from the same panel. Per-game pins have no shared equivalent yet — decky-pinned.json is deliberately left on disk untouched so a later migration can read it. Both are stated in the release-note copy rather than glossed.

A regression found by review, and fixed

An adversarial review of this branch caught something I had introduced and would not have found by testing on a Deck either.

The panel names a host by its record's stable id — which the design recommends because an id "survives renames and DHCP moves". It does. What does not is the address on the record, and ConnectPlan::for_host dials exactly that, while the panel renders the advert's address. After the cut nothing on a Deck writes a moved address back (discover and hosts list are both reads; only the desktop shells' hosts pages update one). So after any DHCP move the row read "online" at the new address and every press dialled the old one — a 15 s dead connect, or a black Steam "game" for the full 90 s wake budget if a MAC had ever been learned. The old plugin was immune because it dialled the advert directly.

Fixed by making hosts add <new-addr> --fp <known-fp> move the matching record instead of filing a second one (the fingerprint is the identity — the same rule that makes the verb idempotent), and having the panel re-point a host it can see has moved before launching.

Three smaller ones in the same commit: client-unavailable was rendered as "No hosts yet", so a Deck with no client installed was told its network was empty; browse()'s worker never exited on a LAN with no Punktfunk host (it parks in recv(), and the ignored-event arms never touch the sender — so the doc claiming "dropping the receiver stops it" was false); and a pair=optional host was recorded paired: true when nobody had approved anything, which would have made the same box read "paired" here and "trusted" in the desktop client.

Docs

clients/decky/README.md and docs-site/content/docs/steam-deck.md rewritten, with a new Request access section and a plain statement of where the settings went. client-settings.md claimed ~18 settings were "offered by … and Decky"; none are.

Verifying that file against crates/pf-console-ui/src/screens/settings.rs also turned up three claims that were already wrong, independent of this work: render scale is in the console home's row list; the console home does have an auto-wake setting; and its Library button is not gated on library_enabled (that field appears nowhere in pf-console-ui outside its own toggle row). All three fixed here.

What was verified, and how

On .21 (CachyOS), against the live LAN:

  • discover / discover --json list a live host with saved/paired correct; --timeout 0.5 returns in 0.503 s. The fingerprint-first rule proved itself unprompted — the advert at 192.168.1.21 matched a record saved under 127.0.0.1, because that box is its own host.
  • All four hosts add --fp branches with their real exit codes, store byte-identical on the refusal. launch --request-access --exec refused with exit 5.
  • The launch path traced from both ends — a stub CLI behind the wrapper, then the real CLI in front of it. This is what proves the supervised/exec split, by which code path the CLI actually took: --request-access reaches spawn_session, everything else reaches exec_session.
  • The DHCP-move fix proven with a stub session binary: before, --connect 10.0.0.5:9777 for a host answering at .99; after, one record, moved, and --connect 10.0.0.99:9777.
  • discover leaves a pre-mint store byte-identical while hosts list still mints. Ten back-to-back browses settle back to the baseline thread count.
  • cargo fmt --check clean, clippy clean at -D warnings --all-targets, 100 Rust tests, and pf-console-ui / punktfunk-client-session / punktfunk-client-linux all still compile.
  • pnpm typecheck + pnpm build green; 31 backend checks pass — and pass on a re-run, which the first version of that suite did not.

Closed statically because the Deck was unreachable: the flatpak installs the CLI at /app/bin/punktfunk so --command=punktfunk resolves, and the deb installs it beside punktfunk-client so the wrapper's sibling assumption holds. That is the packaging half of the plan's R1.

What is still owed

  • All of M5, on a Deck. Discovery → request access → approve on the host → the stream starting by itself → the row reading paired; the PIN path; a pinned card applying a visibly different setting; console home; the update banner; a plugin reinstall. Spike S2 in particular — does a supervised, --exec-less launch still get gamescope focus? — is answered only by running it. If it does not, the recorded fallback is to keep --exec everywhere and have the plugin flip the record after a successful exit.
  • The environment half of S1: that the CLI, run from the root backend, resolves the deck user's config dir. It rides the unchanged _flatpak_env() the old --pair path already used, but it is unproven.
  • Windows compile parity for the CLI on .173 — that box was offline too. Low risk (the Rust diff adds only portable std APIs and the sole new #[cfg] is #[cfg(test)]), but unrun is unrun.

Deviations from the plan

main.py is 1,028 lines against the plan's "under ~500", and the plugin totals ~2,830 rather than the design's ~1,660. Every deleted surface is gone and an AST pass confirms nothing dead remains; the rest is kept code (the SSL-root repair, the update-registry path, the Steam configset editor) plus this codebase's comment density. Cutting further would mean deleting comments each paid for by a failure.

_classify_library_error could not simply be deleted as the plan lists — _native_update_state, which the plan keeps, needs it to spot a client too old for --check-update. Narrowed to _looks_outdated(); the CLI path uses the deterministic exit-5 + unknown command signature instead.

Release-note copy is drafted in the plan's ledger rather than committed here, since notes are authored at version-bump time and this train bumps nothing.

Implements `decky-slim-rework{,-implementation-plan}.md` — M1 through M4. **M5 (the on-glass gate) has not run: the Deck was offline the whole session, so nothing here has been seen on one.** See *What is still owed* at the bottom before merging. The Decky plugin was a second client: its own mDNS discovery, its own host-store editor, its own settings UI over the entire client settings store, its own per-game pin store and picker, its own fullscreen route with three tabs. Every one of those was a copy — in Python and TypeScript — of a rule that already lives in Rust, and each drifted from the original. They existed because when the plugin was written there was nothing headless to ask. There has been since v0.22.0. **5,340 → 2,830 lines.** What is left is what only a Decky plugin can do: start a stream through Steam so gamescope focuses it, and stand in front of the trust decision that gates it. ## The CLI gains two things (they ship independently of the plugin) - **`punktfunk discover [--json] [--timeout SECS]`** — built on a new `discovery::discover_for`, a bounded collector beside the streaming `browse()` the UI uses. It annotates each hit against the saved-hosts store rather than handing back two lists to join: `saved`/`paired` are answered by fingerprint first, address second, which is what stops a host that moved DHCP lease reading as new and a different box that inherited the old address reading as paired. - **`punktfunk launch --request-access`** — sets the 185 s budget (the host's `PENDING_APPROVAL_WAIT`) and records the host as paired when the session reports ready. Refused under `--exec` with exit 5 rather than silently downgraded: under `--exec` the CLI *becomes* the session, so nothing survives to observe the approval. Plus a live bug the plan flagged as R3: **`hosts add --fp` was silently discarding the fingerprint** when the address was already saved, exiting 0 having done nothing — so the host stayed pinless and every later connect refused with no line anywhere saying why. ## The plugin One QAM panel: update banner, hosts, the console-home door, about. Pinned host+profile cards render **nested under their host** as `▸ <Profile name>` — a card *is* a (host, profile) pair, and a row floating free of its host is exactly the "a pinned tile reads as a duplicate host" problem the desktop shells still have. The plugin renders cards; it creates none. A host with nothing pinned opens a **trust sheet**: *Request access* (the host's operator approves this Deck — no PIN) or *Use a PIN instead*. No advertised fingerprint means the PIN path only, with the reason stated — that pin is the only thing standing between a 185 s wait and an impostor answering for the host. The launch path becomes `punktfunk launch <ref> [--profile <id>] --exec --fullscreen`, and request-access launches run **supervised** (no `--exec`) so something survives to record the approval. Wake-on-LAN comes out entirely: the plugin used to fire its own packet and stretch the connect budget to 75 s, a workaround for the CLI-less era; `punktfunk launch` runs the real wake-and-wait loop and only dials once the host answers. **The settings screen is not lost, it moved:** console home → Settings, same rows over the same store, gamepad-navigable, one tap from the same panel. Per-game pins have no shared equivalent yet — `decky-pinned.json` is deliberately left on disk untouched so a later migration can read it. Both are stated in the release-note copy rather than glossed. ## A regression found by review, and fixed An adversarial review of this branch caught something I had introduced and would not have found by testing on a Deck either. The panel names a host by its record's stable id — which the design recommends *because* an id "survives renames and DHCP moves". It does. What does not is the **address on the record**, and `ConnectPlan::for_host` dials exactly that, while the panel renders the *advert's* address. After the cut nothing on a Deck writes a moved address back (`discover` and `hosts list` are both reads; only the desktop shells' hosts pages update one). So after any DHCP move the row read "online" at the new address and every press dialled the old one — a 15 s dead connect, or a black Steam "game" for the full 90 s wake budget if a MAC had ever been learned. The old plugin was immune because it dialled the advert directly. Fixed by making `hosts add <new-addr> --fp <known-fp>` **move** the matching record instead of filing a second one (the fingerprint is the identity — the same rule that makes the verb idempotent), and having the panel re-point a host it can see has moved before launching. Three smaller ones in the same commit: `client-unavailable` was rendered as "No hosts yet", so a Deck with no client installed was told its *network* was empty; `browse()`'s worker never exited on a LAN with no Punktfunk host (it parks in `recv()`, and the ignored-event arms never touch the sender — so the doc claiming "dropping the receiver stops it" was false); and a `pair=optional` host was recorded `paired: true` when nobody had approved anything, which would have made the same box read "paired" here and "trusted" in the desktop client. ## Docs `clients/decky/README.md` and `docs-site/content/docs/steam-deck.md` rewritten, with a new **Request access** section and a plain statement of where the settings went. `client-settings.md` claimed ~18 settings were "offered by … and Decky"; none are. Verifying that file against `crates/pf-console-ui/src/screens/settings.rs` also turned up **three claims that were already wrong**, independent of this work: render scale *is* in the console home's row list; the console home *does* have an auto-wake setting; and its Library button is *not* gated on `library_enabled` (that field appears nowhere in `pf-console-ui` outside its own toggle row). All three fixed here. ## What was verified, and how On **.21** (CachyOS), against the live LAN: - `discover` / `discover --json` list a live host with `saved`/`paired` correct; `--timeout 0.5` returns in 0.503 s. The fingerprint-first rule proved itself unprompted — the advert at `192.168.1.21` matched a record saved under `127.0.0.1`, because that box is its own host. - All four `hosts add --fp` branches with their real exit codes, store byte-identical on the refusal. `launch --request-access --exec` refused with exit 5. - **The launch path traced from both ends** — a stub CLI behind the wrapper, then the real CLI in front of it. This is what proves the supervised/exec split, by which code path the CLI actually took: `--request-access` reaches `spawn_session`, everything else reaches `exec_session`. - The DHCP-move fix proven with a stub session binary: before, `--connect 10.0.0.5:9777` for a host answering at `.99`; after, one record, moved, and `--connect 10.0.0.99:9777`. - `discover` leaves a pre-mint store byte-identical while `hosts list` still mints. Ten back-to-back browses settle back to the baseline thread count. - `cargo fmt --check` clean, clippy clean at `-D warnings --all-targets`, **100 Rust tests**, and `pf-console-ui` / `punktfunk-client-session` / `punktfunk-client-linux` all still compile. - `pnpm typecheck` + `pnpm build` green; **31 backend checks** pass — and pass on a *re-run*, which the first version of that suite did not. Closed statically because the Deck was unreachable: the flatpak installs the CLI at `/app/bin/punktfunk` so `--command=punktfunk` resolves, and the deb installs it beside `punktfunk-client` so the wrapper's sibling assumption holds. That is the packaging half of the plan's R1. ## What is still owed - **All of M5, on a Deck.** Discovery → request access → approve on the host → the stream starting by itself → the row reading paired; the PIN path; a pinned card applying a visibly different setting; console home; the update banner; a plugin reinstall. Spike **S2** in particular — *does a supervised, `--exec`-less launch still get gamescope focus?* — is answered only by running it. If it does not, the recorded fallback is to keep `--exec` everywhere and have the plugin flip the record after a successful exit. - The environment half of **S1**: that the CLI, run from the *root* backend, resolves the `deck` user's config dir. It rides the unchanged `_flatpak_env()` the old `--pair` path already used, but it is unproven. - **Windows compile parity** for the CLI on .173 — that box was offline too. Low risk (the Rust diff adds only portable std APIs and the sole new `#[cfg]` is `#[cfg(test)]`), but unrun is unrun. ## Deviations from the plan `main.py` is 1,028 lines against the plan's "under ~500", and the plugin totals ~2,830 rather than the design's ~1,660. Every deleted surface is gone and an AST pass confirms nothing dead remains; the rest is kept code (the SSL-root repair, the update-registry path, the Steam configset editor) plus this codebase's comment density. Cutting further would mean deleting comments each paid for by a failure. `_classify_library_error` could not simply be deleted as the plan lists — `_native_update_state`, which the plan keeps, needs it to spot a client too old for `--check-update`. Narrowed to `_looks_outdated()`; the CLI path uses the deterministic exit-5 + `unknown command` signature instead. Release-note copy is drafted in the plan's ledger rather than committed here, since notes are authored at version-bump time and this train bumps nothing.
enricobuehler added 12 commits 2026-08-04 19:40:58 +00:00
The CLI could do everything with a host except FIND one, so every headless consumer
grew its own mDNS: the Decky plugin parses ~120 lines of avahi TXT escaping in Python,
which drifts from the host's advert every time a key is added and makes the plugin
depend on Avahi being the resolver.

`discovery::discover_for(timeout)` is the bounded collector beside the streaming
`browse()` the UI uses — same service type, same TXT keys, folded to one row per host.
A refreshed advert wins (it carries the newer address), a removal drops the row, and
dropping the receiver on the way out stops the worker so a one-shot call can't leak a
browse per invocation.

The verb annotates each hit against the saved-hosts store rather than handing back two
lists to join: `saved`/`paired` are answered by fingerprint first and address second —
the same rule every other surface uses. That is what stops a host that moved DHCP lease
from reading as new, and stops a different box that inherited the old address from
reading as paired.

  punktfunk discover [--json] [--timeout SECS]

Default 3 s, capped at 30 — this is called from a Quick Access panel, and a typo'd
`--timeout 3000` would hang that panel with no way to cancel. An empty LAN exits 0: a
caller branching on the code is asking whether the browse ran, and it did.
`punktfunk hosts add <addr> --fp <hex>` against an address already in the store printed
"is already saved" and exited 0 — having done nothing at all. The --fp was silently
discarded, so a host saved by address stayed pinless and every later connect refused
for want of a fingerprint, with no line anywhere saying why.

Three outcomes now, and the difference between them is a trust decision:

  • no fingerprint on the record, one offered  → fill it in, print `updated <addr>:<port>`
  • the same fingerprint offered again        → no-op, exit 0 (a panel may retry a step
                                                whose state is already correct without
                                                having to invent an error to show)
  • a DIFFERENT fingerprint                    → refuse, exit 3

The refusal is the important one. A changed identity is a decision for a person at a
surface that can show them both — the rule `upsert_trusted` exists to enforce — and
quietly overwriting a pin here would be a back door through the pinning the rest of the
client is built on.

A record still named after its own address takes an offered --name; a label the user
chose is theirs and an advert's name must not overwrite it.
Request access is not a second pairing ceremony, it is a LAUNCH: an ordinary identified
connect with the advertised fingerprint pinned and the handshake budget stretched past
the host's approval window. The host parks the connection until somebody approves the
device in its console or web UI, then admits the same connection and the stream starts
by itself. The desktop shells and the console home have had this for a while
(`SpawnOpts::persist_paired`, `screens/pair.rs`); headless callers had no door to it.

  punktfunk launch <host-ref> --request-access

Two behaviours, both small:

* `connect_timeout_secs = 185`, matching the host's PENDING_APPROVAL_WAIT. Anything
  shorter gives up while the approval prompt is still on the operator's screen.
* `run_plan` records the host as paired on SessionEvent::Ready. That event IS the
  approval arriving, and it records the pin the session actually connected WITH rather
  than re-reading the store — the handshake completed against that identity, which is
  what makes the record true. Every other launch still records nothing: a plain connect
  proves reachability, not a new trust decision.

Refused under `--exec` (exit 5) rather than silently downgraded. Under --exec the CLI
BECOMES the session, so no process survives to observe Ready — a quiet downgrade would
leave hosts reading "trusted" forever with nobody able to explain why.
The Decky plugin was a second client. It had its own mDNS discovery, its own host-store
editor, its own settings UI over the entire client settings store, its own per-game pin store
and picker, and its own fullscreen route with three tabs — about 3,000 lines of TypeScript and
Python mirroring, in two other languages, things the Rust client already does. Every one of
them drifted from the original: the TXT parser fell behind each key the host advert added, the
settings screen modelled a subset of a store that kept growing.

They existed because when this plugin was written there was nothing headless to ask. There has
been since v0.22.0, so this deletes them.

GONE, frontend: page.tsx (the fullscreen route), settings.tsx (a seven-page sidebar over the
whole store), hostmgmt.tsx (add/edit/forget), library.tsx (the games picker), ui.tsx (row
primitives only the page used).

GONE, backend: get/set_settings, list/refresh_devices, library, get/set_pins, list_hosts,
add/edit/forget_host, probe_host, reset_config, wake, the avahi browse and its TXT parser, and
the direct reads of client-known-hosts.json.

WHAT REPLACES THE BACKEND is four shells, each about fifteen lines of build-argv-run-parse:

  discover()    -> punktfunk discover --json
  hosts()       -> punktfunk hosts list --probe --json
  pair()        -> punktfunk pair <addr:port> --pin N --name LABEL
  trust_host()  -> punktfunk hosts add <addr:port> --fp HEX --name LABEL

trust_host is the ONLY write this backend makes to the client's store, and it goes through the
CLI — which writes temp+rename into a user-owned directory, so a root backend driving it
cannot lock the desktop client out of its own files. Nothing here opens client-known-hosts.json
or client-profiles.json any more; `hosts list --json` returns profile bindings and pinned cards
already resolved against the catalog.

_cli_argv mirrors the deleted _session_argv exactly, pointed at `punktfunk`: the flatpak app id
stays LAST, because flatpak treats everything after it as the app's own argv. The
LD_LIBRARY_PATH repair applies unchanged — Decky's PyInstaller leak breaks the flatpak's
libcurl whichever binary inside the sandbox is being started.

A client too old for a verb now announces itself DETERMINISTICALLY: exit 5 plus
`unknown command "<verb>"`, mapped to `client-outdated`, which the panel renders as one
explanatory row plus the update button that fixes it. That replaces guessing from GTK-init
noise, which survives only where the update check still drives `punktfunk-client` directly.

KEPT unchanged in mechanism, because only a Decky plugin can do them: runner_info,
shortcut_art, apply_controller_config, check_update/update_client, kill_stream.

The settings screen is not lost, it moved: console home -> Settings has the same rows over the
same store, is gamepad-navigable, and is one tap from this same panel. Per-game pins have no
shared equivalent yet — decky-pinned.json is deliberately left ON DISK, untouched, so a later
migration can read it.

test-backend.py is rewritten against what is left — argv shape, the exit-code mapping, and the
Steam configset editor, which was untested until now and is the riskiest thing that survived:
it edits a file holding hundreds of other games' bindings, in place.
What is left of the plugin is what only a Decky plugin can do: start a stream through Steam so
gamescope focuses it, and stand in front of the trust decision that gates it. One Quick Access
panel, four sections, no route.

HOSTS. One `useHosts()` calls discover and hosts-list together and merges them by fingerprint
first, address second — so a host that moved DHCP lease still matches its record, and a
different box that inherited the old address does not inherit its pairing. The CLI annotates
`saved`/`paired` by that same rule, so the two surfaces cannot disagree. Rows sort online
first, then most recently used, then by name: the host you streamed last night is the first
thing under your thumb, and a host that is off right now never is.

`needsPair` is now ONE rule: no pinned fingerprint. The session binary refuses a pinless
connect, so a row without one can offer nothing but a button that fails. The old rule also
consulted the advertised policy for unsaved hosts, which made the same box read differently
before and after being saved.

PINNED CARDS render NESTED under their host as `▸ <Profile name>`, not in a section of their
own — a card IS a (host, profile) pair, and a row floating free of its host is exactly the "a
pinned tile reads as a duplicate host" problem the desktop shells still have. The host's own
BOUND profile is deliberately not drawn as a card: it applies silently on the plain row, and
showing it twice would suggest the two do different things. This plugin creates, edits and
deletes no profile and no card — pin creation belongs where profiles are edited.

TRUST SHEET (new, trust.tsx). Request access (default) / Use a PIN instead… / Cancel, in the
GTK dialog's order and wording. Request access is not a second ceremony — it saves the host
with the fingerprint it ADVERTISED, then launches; the host parks that connect until its
operator approves this Deck, admits it, and the stream starts by itself.

No fingerprint, no request access. A host typed in by address advertises none, so the sheet
offers the PIN path only and says why, rather than showing a button that could only fail. The
sheet never TOFUs past a missing fingerprint: that pin is the only thing standing between a
185 s wait and an impostor answering for the host.

The sheet is a `showModal` portal, so it captures its callbacks once and never re-renders from
panel state — everything it acts on later is read through a ref. Reading a captured value is
precisely what made pinning a second game compute from a stale base and clobber the first.

LAUNCH PATH. The wrapper's contract becomes PF_REF / PF_PROFILE / PF_REQUEST_ACCESS /
PF_BROWSE; PF_HOST, PF_LAUNCH, PF_MGMT and PF_CONNECT_TIMEOUT are gone. A stream is now
`punktfunk launch <ref> [--profile <id>] --exec --fullscreen`, and a reference is all that ever
rides Steam's launch options — no resolution, bitrate or codec, the same rule the deep-link
grammar enforces.

Request-access launches run SUPERVISED, without `--exec`: under --exec the CLI becomes the
session, so no process survives to see the stream come up and record the approval. Safe for
gamescope because focus follows reaper's descendant tree, not a single process, and
flatpak-run/bwrap already sit in that tree on every other path.

Wake-on-LAN comes out entirely. The plugin used to fire a magic packet itself and then stretch
the connect budget to 75 s to cover the host's resume — a workaround for the CLI-less era.
`punktfunk launch` runs the real wake-and-wait loop and only dials once the host answers, which
is strictly better and deletes a backend method, a frontend call and a shell branch.

The console-home branch of the wrapper is untouched on purpose: the shell binary already execs
the session for `--browse`, so there is nothing to repoint and no reason to spend a diff there.

Everything else in steam.ts — two shortcuts sharing one name (and so one Steam Input configset
key), artwork versioning, appId verification, controller config, stopStream — is unchanged.
The plugin's settings tab, fullscreen page, host editor and games picker are gone, and the
docs described all four in detail. Sweeps clients/decky/README.md and the docs site.

steam-deck.md gains a **Request access** section — the no-PIN path where the host's operator
approves the Deck, which is the one genuinely new thing a user gets — and says plainly where
the settings went: **Open Punktfunk → Settings**, the same rows over the same store, one tap
from the same panel. A removal that reads as a regression is worth a sentence, not a silence.
The troubleshooting table drops the rows for surfaces that no longer exist and gains the two
questions the new path will actually raise ("request access isn't offered", "the stream just
sits there").

client-settings.md claimed ~18 settings were "offered by … and Decky". None are; the console
home offers them. Its intro now names the console home's real sections (Stream, Video,
Presentation, Audio, Controller, Touchscreen, Interface, Profiles) instead of describing the
deleted sidebar.

Three claims in that file turned out to be wrong ALREADY, independent of this rework, and are
fixed here because verifying against crates/pf-console-ui/src/screens/settings.rs is what
found them:

  • "Render scale — offered everywhere except the console home's list". RowId::RenderScale has
    been in the console's ROWS since 2026-07-31.
  • wake-on-lan.md: "Punktfunk Console has no auto-wake setting of its own". It does —
    RowId::AutoWake, "Wake hosts automatically". Its Wake & Connect BUTTON is independent of
    the setting, which is the true half that sentence was built on.
  • The console home's Library button was documented as gated on the "Show game library"
    toggle. It isn't — `library_enabled` appears nowhere in pf-console-ui outside the toggle
    row itself; home.rs offers Library on any paired, saved host.

Also updated: support-matrix (Decky's Profiles and Game library go /⚠️ — the panel shows
pinned profile cards but creates none, and the library lives in the console home),
wake-on-lan (the plugin no longer fires its own packet or stretches the connect budget — the
CLI runs the real wake-and-wait), pairing, game-library, profiles-and-links, input, clipboard
and install-client.
The "a native install with no sibling CLI resolves to None" check created
/tmp/pf-test-native/bin/punktfunk and never removed it, so the assertion that the sibling is
ABSENT held only on the first run on a given machine and failed on every rerun. Caught by
running the suite twice.
`mergeHosts` filled a row's fingerprint as `s.fp_hex || advert?.fp || ""`, so a host saved by
address — nothing pinned on disk — borrowed the fingerprint of whatever was advertising at that
address and rendered as ready to stream. The launch then refused for want of a pin, from a row
that had just shown "Stream" and "trusted".

Under the old rule the mistake was mostly hidden, because `needsPair` asked a different
question for saved and unsaved rows. This rework makes a pinned fingerprint the ONLY rule, so
the same conflation would now decide the whole thing.

The two are different facts and are now separate fields. `fp` is what the RECORD pins — the
thing the session binary requires. `advertisedFp` is what the host is offering right now, which
is what request access would pin, and moving one to the other is a trust decision the user
makes in the sheet rather than something the merge does behind them.

The trust sheet gates on and pins `advertisedFp` accordingly: a saved placeholder that happens
to be advertising can now be let in with request access, and one that isn't still gets the PIN
path with the reason.
`{"ok": True, **data}` let a future payload carrying its own `ok` report failure through the
field the shell layer owns. Spread first, set `ok` last.
`KnownHosts::load()` mints a stable id for any record that lacks one and SAVES it — which makes
it a write, and `discover` was calling it purely to annotate what the browse found with
saved/paired. It never hands those ids back to anyone.

That matters because the Decky panel issues `discover` and `hosts list` together, in parallel.
Against a store written before ids existed, both processes read it, both mint DIFFERENT ids for
the same record, and both save. Whichever loses the race has already handed its ids to its
caller — so the panel could draw a row whose host reference no longer resolves, and pressing it
would exit 5 ("no saved host matches") until the next refresh settled things.

`KnownHosts::read()` is `load` without the mint: the store exactly as it is on disk. `discover`
uses it; every caller that dials a host by id still uses `load`, so ids are still minted the
first time anything needs one.

Verified on a fixture store with no ids: `punktfunk discover` leaves it byte-identical, and a
following `punktfunk hosts list` mints as before.
PF_HOST is gone; the browse branch is keyed on PF_BROWSE alone and runs the SESSION binary,
which is the one path this rework deliberately did not repoint. Comment only.
fix: a host that changed DHCP lease could no longer be streamed from the panel
ci / web (pull_request) Successful in 1m7s
apple / swift (pull_request) Successful in 1m24s
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 1m54s
ci / rust-arm64 (pull_request) Successful in 3m5s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 3m16s
android / android (pull_request) Successful in 5m2s
ci / rust (pull_request) Successful in 7m15s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 4m58s
0d407a866d
An adversarial review of this branch found a regression I introduced, plus three smaller
defects. All four are fixed here, each verified on .21.

**The regression.** `mergeHosts` names a host by its record's stable id, and `hosts list --json`
always emits one (`KnownHosts::load` mints ids for every record). So a launch always went out as
`punktfunk launch <uuid>` → `ConnectPlan::for_host` → `HostTarget::from(&KnownHost)`, which
copies the address stored ON THE RECORD. Meanwhile the panel deliberately renders the LIVE
advert's address. Nothing on a Deck ever writes a moved address back — `discover` and
`hosts list` are both reads, and only the desktop shells' hosts pages update one.

So after any DHCP move the row read "online" at the new address and every press dialled the old
one: a 15 s dead connect, or — if a MAC had ever been learned — a black Steam "game" for the
full 90 s wake budget. Proven with a stub session binary: `launch abc-123` emitted
`--connect 10.0.0.5:9777` for a host answering at `10.0.0.99`.

This worked on origin/main, which dialled `toHost(v).host` — the advert's address. The fix
restores that without giving up stable ids: `hosts add <new-addr> --fp <known-fp>` now MOVES the
matching record instead of filing a second one (the fingerprint is the identity — this is the
same rule that makes the verb idempotent), and the panel re-points a host it can see has moved
before launching it. Verified: `moved 10.0.0.5:9777 to 10.0.0.99:9777`, one record still, and
`launch abc-123` then emits `--connect 10.0.0.99:9777`.

**"No hosts yet" was also how a missing client looked.** `_cli_argv()` returning None becomes
`client-unavailable`, which the panel dropped on the floor — so a Deck with no client installed
was told its network was empty, under a button that launches the client that isn't there. It now
says which of the two it is.

**The browse worker never exited on a quiet LAN.** `discover_for` drops the receiver and the
doc claimed that stops the thread. It does not: the worker parks in `recv()`, and the arms that
ignore an event (`SearchStarted`, `ServiceFound`, `SearchStopped`, a v6-only advert) never touch
the sender, so on a LAN with no Punktfunk host nothing ever wakes it. Harmless today because the
only caller is a short-lived CLI process, but the function invites in-process use, where it would
leak a thread and an mDNS daemon per call. Now polled with a 250 ms tick and a check at the top
of the loop. Verified: ten back-to-back browses settle back to the baseline thread count.

**A `pair=optional` host was recorded as paired.** Every unsaved host now goes through the trust
sheet (it has no pin, so it cannot stream without one), but the sheet's only non-PIN action ran
`--request-access`, which persists `paired: true` on Ready. An optional host admits anyone who
pins its identity — there is no operator decision, so nothing was approved and the same box read
"paired" here and "trusted" in the desktop client. Such a host now gets **Connect** instead,
which pins and streams without claiming an approval, and the "approve this Deck" toast is no
longer shown to someone who has nobody to ask.

Also: `PF_CLIENT_BIN` was the one launch-option value never validated — a client installed under
a path with a space would split Steam's tokenizer.
enricobuehler merged commit 83a12c7413 into main 2026-08-04 19:41:34 +00:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: unom/punktfunk#46