Compare commits

..
Author SHA1 Message Date
enricobuehler 9fb41affba fix(clients/settings): a controller setting you can't use no longer looks like one you can
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 1m37s
ci / rust-arm64 (pull_request) Successful in 2m41s
ci / web (pull_request) Successful in 3m30s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m16s
android / android (pull_request) Successful in 5m37s
ci / rust (pull_request) Successful in 8m4s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m14s
apple / swift (pull_request) Successful in 1m30s
Turn "Forward controllers" off and four rows below it stop meaning anything — nothing is
forwarded, so there is no pad type to pick and no guide button to route. GTK desensitised
them, the touch settings on both mobile clients dimmed them and the console UI refused the
step; the Windows client and BOTH controller-navigable screens left them fully live, so you
could sit there changing settings that did nothing.

Windows: `.enabled(s.gamepad_forwarding)` on the forwarded-controller picker, pad type,
guide button and hold-Select rows — the same builder the echo-cancellation row already used
to follow the mic switch.

Apple's gamepad settings had no way to say it: `Row` carried `adjustable` (which only hides
the chevrons) and nothing else. Added `Row.enabled`, dimmed the row CONTENTS only so the
glass still reads as a focusable row, and enforced the inertness centrally in `adjust(id:)`
/ `activate(id:)` rather than in each builder's closure. The hint bar drops "Adjust"/"Change"
on a dimmed row, because advertising them was the same lie the live row told.

Android's gamepad settings already had `GpRow.enabled` — documented as "dimmed + inert" —
but it only faded the label: every dimmed row still stepped and still wrote its setting. The
"No profiles yet" placeholder looked inert only because its own closures were empty. Made it
real in one named place (`liveRow`), covering all three input paths (left/right, A, and a tap
on the already-focused row), then gated the pad rows on it.

Also on that screen: the DualSense / DualShock passthrough toggle, which the touch settings
have carried beside its SC2 twin all along. It was missing exactly where it matters most —
a TV box has no touch interface to fall back to, so there was no way to reach it at all.

Apple capture, separately: with forwarding off, opening a slot still claimed EVERY element's
system gesture and powered the controller's IMU. Neither reaches the host, so the first only
took the user's screenshot/Home gestures away for nothing and the second drained the pad's
battery streaming gyro over Bluetooth. Narrowed rather than skipped — the escape chord is
read off the same slot and on tvOS is the ONLY controller way out of a stream, so the chord's
own four buttons keep their claim. A test pins the alias list against the chord mask; if they
drift the symptom is a session nobody can leave, with nothing logged.

Closes R17, R18, R19 (design/haptics-sweep-2026-08-03.md M11). R17 as filed named Windows and
"Apple"; Apple's TOUCH settings were already correct and Android's controller-navigable screen
was not — both corrected here.

Verified: Windows clippy -D warnings exit 0 on a real Windows box; Apple swift build clean +
full suite 192 tests / 0 failures (3 new); Android :app: + :kit: green (5 new); cargo fmt
--all --check clean. Each fix probed by reverting it — every probe failed the tests it should.
2026-08-04 22:25:51 +02:00
enricobuehler ee0b179618 Merge pull request 'docs(apple/store): App Store copy for iOS, macOS and tvOS, counted against Apple's limits' (#49) from worktree-appstore-copy into main
ci / rust-arm64 (push) Successful in 2m29s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 10s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 11s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 10s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 6s
ci / web (push) Successful in 2m49s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
ci / docs-site (push) Successful in 2m38s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 18s
docker / builders-arm64cross (push) Successful in 7s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m42s
release / apple (push) Successful in 9m23s
apple / swift (push) Successful in 1m34s
ci / rust (push) Successful in 7m27s
docker / deploy-docs (push) Failing after 6m11s
apple / screenshots (push) Successful in 6m13s
Reviewed-on: #49
2026-08-04 20:03:34 +00:00
enricobuehler d7e22c3db2 Merge pull request 'fix(apple/shots): the store screenshots show the app as it actually is' (#48) from worktree-apple-store-screenshots into main
apple / swift (push) Canceled after 15s
apple / screenshots (push) Canceled after 0s
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
release / apple (push) Canceled after 0s
Reviewed-on: #48
2026-08-04 20:02:37 +00:00
enricobuehler c1231fa2e6 Merge pull request 'feat(clients/input): system buttons route around local overlays' (#47) from worktree-system-buttons-routing into main
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
arch / build-publish (push) Successful in 8m52s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 2m1s
release / apple (push) Canceled after 0s
decky / build-publish (push) Successful in 41s
deb / build-publish-client-arm64 (push) Successful in 1m4s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m28s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m11s
android / android (push) Successful in 5m19s
deb / build-publish (push) Successful in 5m44s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 19m32s
deb / build-publish-host (push) Successful in 5m51s
apple / swift (push) Canceled after 9s
apple / screenshots (push) Canceled after 0s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m57s
ci / rust (push) Canceled after 12s
flatpak / build-publish (push) Successful in 6m23s
ci / rust-arm64 (push) Canceled after 0s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 59s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
Reviewed-on: #47
2026-08-04 20:02:22 +00:00
enricobuehler 1db7058a5d feat(clients/input): system buttons route around local overlays
apple / swift (pull_request) Successful in 1m30s
apple / screenshots (pull_request) Skipped
android / android (pull_request) Successful in 4m16s
ci / rust-arm64 (pull_request) Successful in 3m20s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 59s
ci / web (pull_request) Successful in 1m36s
ci / docs-site (pull_request) Successful in 2m7s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 1m55s
ci / rust (pull_request) Successful in 10m36s
Pressing guide/Steam/QAM collided with the client device's own shell: iOS 26
opens its Game Overlay for the Home press (no app opt-out until iOS 27 makes
it a user setting), and a Gaming-Mode client opened BOTH Steam overlays for
one press — the local one covering the stream.

Two cross-client tier-P settings, zero wire changes:

- system_buttons (auto|forward|local): raw guide+misc1 passthrough. Auto
  forwards everywhere EXCEPT under gamescope, where SteamOS reacts to the
  same physical press no matter what.
- guide_gesture (auto|on|off): hold Select ALONE ~350ms sends the HOST's
  guide, down until release — held on, that's the host's long-press, which
  opens a Gaming-Mode host's QAM for regular pads. A Select tap is delivered
  on release with its up TAP_PRESS (50ms) behind, because per-transition
  sends fold into seq'd GamepadState snapshots and a back-to-back pair can
  coalesce into no press at all. A Select inside a combo (the escape chord)
  passes through untouched. Auto arms it only where the raw press can't
  reach the host cleanly: gamescope, iOS/iPadOS, tvOS.

The same SelectGesture rules live in pf-client-core (pure state machine +
unit tests), the Apple client (mask-diff adaptation in GamepadCapture), and
Android's GamepadRouter. Settings rows on every surface (GTK, WinUI,
console UI, Decky, Apple x2, Android x2) with profile plumbing throughout.

punktfunk-session grows a control socket
($XDG_RUNTIME_DIR[/app/$FLATPAK_ID]/punktfunk-session-ctl.sock — the one
runtime path a flatpak and the host see identically): 'guide'/'qam' verbs
inject synthetic taps. The Decky panel gains a Host menus section (visible
while the client runs) whose buttons press the host's Steam/QAM and close
the local menu so the host's shows through.

iOS 27's GCControllerHomeButtonSettingsManager deep-link is a TODO (the
class needs the Xcode 27 SDK to compile). Docs: input, client-settings,
steam-deck. Design: punktfunk-planning design/system-buttons-routing.md.

Gates: docker clippy --all-targets --locked -D warnings + tests
(pf-client-core 88 incl. 6 new gesture tests, pf-console-ui 47),
cargo fmt --all --check, swift build (macOS), gradle kit+app compile,
decky tsc --noEmit + py_compile. clients/windows not compiled (no box).
2026-08-04 21:46:27 +02:00
enricobuehler 83a12c7413 Merge pull request 'Decky, slimmed: a thin Gaming-Mode wrapper around the client' (#46) from worktree-decky-slim-rework into main
ci / web (push) Successful in 1m19s
ci / docs-site (push) Successful in 1m40s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m58s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 59s
deb / build-publish-client-arm64 (push) Successful in 2m25s
android / android (push) Successful in 7m27s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m56s
decky / build-publish (push) Successful in 19s
apple / screenshots (push) Successful in 6m31s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 7s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 6s
docker / builders-arm64cross (push) Successful in 7s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 9s
docker / deploy-docs (push) Successful in 34s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
flatpak / build-publish (push) Canceled after 10m10s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
ci / rust (push) Successful in 7m33s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 10m13s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 9m55s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 11s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 19s
arch / build-publish (push) Successful in 8m21s
deb / build-publish-host (push) Successful in 4m33s
apple / swift (push) Successful in 1m18s
deb / build-publish (push) Successful in 5m28s
ci / rust-arm64 (push) Successful in 2m28s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m7s
Reviewed-on: #46
2026-08-04 19:41:32 +00:00
enricobuehler 0d407a866d fix: a host that changed DHCP lease could no longer be streamed from the panel
ci / rust (pull_request) Successful in 7m15s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 4m58s
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
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.
2026-08-04 21:26:09 +02:00
enricobuehler 0890cf3244 docs(apple/store): App Store copy for iOS, macOS and tvOS, counted against Apple's limits
ci / docs-site (pull_request) Successful in 1m47s
ci / rust (pull_request) Successful in 7m49s
apple / swift (pull_request) Successful in 1m22s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m16s
ci / rust-arm64 (pull_request) Successful in 2m13s
German-first Promotional Text, Descriptions, Keywords and App Review notes,
plus the app-specific privacy text the existing website policy is missing.

Every character-limited field is checked by check-limits.py, which also catches
headings whose stated count has drifted from the real length. Three things the
brief assumed turned out not to hold, and the copy says so rather than shipping
the claim: a Mac cannot act as a host, the published privacy policy covers only
the website, and the App Review notes field caps at 4000 characters.
2026-08-04 21:25:55 +02:00
enricobuehler bf2d8505cf docs(decky): the gamepad-UI shortcut comment still named PF_HOST
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.
2026-08-04 21:08:52 +02:00
enricobuehler 414380fc9e fix(cli): discover reads the host store without writing to it
`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.
2026-08-04 21:07:37 +02:00
enricobuehler 6267dcdcd3 fix(decky): let a CLI payload's own key never override this layer's ok
`{"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.
2026-08-04 21:04:16 +02:00
enricobuehler 8042a2fd52 fix(decky): a saved host's pin is what it PINNED, never what it's advertising
`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.
2026-08-04 21:01:59 +02:00
enricobuehler 7e40098bc6 test(decky): tear the CLI fixture dir down before building it
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.
2026-08-04 21:00:15 +02:00
enricobuehler ac5299d4ce docs: the Deck plugin is a launcher now, not a second client
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.
2026-08-04 20:58:11 +02:00
enricobuehler 017c37b78a feat(decky): rebuild the panel as a launcher — nested cards and request access
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.
2026-08-04 20:41:30 +02:00
enricobuehler 2fd303e22f refactor(decky): delete the second client
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.
2026-08-04 20:41:07 +02:00
enricobuehler f84c5b8114 feat(cli): launch --request-access — let the host's operator admit this device
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.
2026-08-04 20:28:34 +02:00
enricobuehler aec02b9d26 fix(cli): hosts add --fp fills in an empty fingerprint instead of dropping it
`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.
2026-08-04 20:28:11 +02:00
enricobuehler 48bb1769b4 feat(cli): punktfunk discover — browse the LAN, annotated against what you've saved
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.
2026-08-04 20:26:59 +02:00
59 changed files with 4412 additions and 3575 deletions
@@ -65,7 +65,7 @@ import io.unom.punktfunk.kit.security.KnownHostStore
// a controller: up/down moves the focus bar, left/right steps the focused value, A cycles/toggles it,
// B closes. Both write the same SharedPreferences, so values round-trip with the touch settings.
private class GpRow(
internal class GpRow(
val id: String,
val header: String?,
val label: String,
@@ -78,6 +78,15 @@ private class GpRow(
val enabled: Boolean = true, // dimmed + inert when false (still focusable, for its detail)
)
/**
* The row at [index], or null when it is dimmed. The single place the "disabled ⇒ inert" half of
* [GpRow.enabled] is enforced, so the three input paths (pad left/right, A, and a tap on the
* already-focused row) cannot drift apart — before this, `enabled` dimmed the label and nothing
* else, and every dimmed row still stepped its setting.
*/
internal fun liveRow(rows: List<GpRow>, index: Int): GpRow? =
rows.getOrNull(index)?.takeIf { it.enabled }
@Composable
fun GamepadSettingsScreen(
initial: Settings,
@@ -144,11 +153,13 @@ fun GamepadSettingsScreen(
when (dir) {
NavDir.UP -> if (focus > 0) focus--
NavDir.DOWN -> if (focus < rows.lastIndex) focus++
NavDir.LEFT -> { adjustDir = -1; rows.getOrNull(focus)?.adjust(-1) }
NavDir.RIGHT -> { adjustDir = 1; rows.getOrNull(focus)?.adjust(1) }
// A disabled row is INERT, not just dim — the step is refused instead of writing a
// setting that has nothing to act on (see `liveRow`).
NavDir.LEFT -> { adjustDir = -1; liveRow(rows, focus)?.adjust(-1) }
NavDir.RIGHT -> { adjustDir = 1; liveRow(rows, focus)?.adjust(1) }
}
},
onActivate = { adjustDir = 1; rows.getOrNull(focus)?.activate() },
onActivate = { adjustDir = 1; liveRow(rows, focus)?.activate() },
)
// Keep the focused row on screen, but only SCROLL when it's actually off-screen — so entering the
// screen (focus on the first row) leaves the "Settings" heading visible instead of jumping past it.
@@ -186,7 +197,10 @@ fun GamepadSettingsScreen(
}
itemsIndexed(rows, key = { _, r -> r.id }) { index, row ->
SettingRowView(row, focused = index == focus, adjustDir = adjustDir, onClick = {
if (focus == index) { adjustDir = 1; row.activate() } else focus = index
// Same inertness as the pad path above — tapping a dimmed row focuses it (so
// its detail explains itself) but never flips it.
if (focus != index) focus = index
else if (row.enabled) { adjustDir = 1; row.activate() }
})
}
}
@@ -340,7 +354,7 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick
/** Build the console settings rows from the current [Settings], writing through [update].
* [hasBodyVibrator] gates the "Rumble on this phone" row (absent on TVs); [av1Capable] gates the
* AV1 codec entry (see `codecOptionsFor`). */
private fun buildSettingsRows(
internal fun buildSettingsRows(
s: Settings,
hasBodyVibrator: Boolean,
av1Capable: Boolean,
@@ -348,13 +362,14 @@ private fun buildSettingsRows(
): List<GpRow> {
fun <T> choice(
id: String, header: String?, label: String, detail: String,
options: List<Pair<T, String>>, current: T, write: (T) -> Unit,
options: List<Pair<T, String>>, current: T, enabled: Boolean = true, write: (T) -> Unit,
): GpRow {
val idx = options.indexOfFirst { it.first == current }
return GpRow(
id, header, label,
value = options.getOrNull(idx)?.second ?: "",
detail = detail,
enabled = enabled,
adjust = { delta ->
if (idx < 0) {
options.firstOrNull()?.let { write(it.first) } != null
@@ -371,11 +386,12 @@ private fun buildSettingsRows(
}
fun toggle(
id: String, header: String?, label: String, detail: String,
value: Boolean, write: (Boolean) -> Unit,
value: Boolean, enabled: Boolean = true, write: (Boolean) -> Unit,
): GpRow = GpRow(
id, header, label,
value = if (value) "On" else "Off",
detail = detail,
enabled = enabled,
adjust = { delta -> val target = delta > 0; if (value != target) { write(target); true } else false },
activate = { write(!value) },
toggled = value,
@@ -478,11 +494,27 @@ private fun buildSettingsRows(
"so games don't see two of them.",
s.gamepadForwarding,
) { update(s.copy(gamepadForwarding = it)) },
// Everything below the master switch follows it — dim and inert while nothing is being
// forwarded, the same relationship the touch settings draw with `enabled =`. This screen
// had the capability (`GpRow.enabled`) and used it only for the profiles placeholder, so
// the pad rows kept stepping settings that had nothing to act on.
choice(
"padType", null, "Controller type",
"The virtual pad the host creates — Automatic matches this controller.",
GAMEPAD_OPTIONS, s.gamepad,
GAMEPAD_OPTIONS, s.gamepad, enabled = s.gamepadForwarding,
) { update(s.copy(gamepad = it)) },
choice(
"systemButtons", null, "Guide button",
"Where the guide (Xbox/PS) and share presses go while streaming — Automatic " +
"sends them to the host whenever this device delivers them.",
SYSTEM_BUTTON_OPTIONS, s.systemButtons, enabled = s.gamepadForwarding,
) { update(s.copy(systemButtons = it)) },
choice(
"guideGesture", null, "Hold Select for guide",
"Hold Select alone to press the host's guide button — keep holding for a " +
"Gaming-Mode host's quick-access menu. A Select tap still goes through.",
GUIDE_GESTURE_OPTIONS, s.guideGesture, enabled = s.gamepadForwarding,
) { update(s.copy(guideGesture = it)) },
) + listOfNotNull(
if (hasBodyVibrator) {
toggle(
@@ -501,8 +533,18 @@ private fun buildSettingsRows(
"sc2", null, "Steam Controller 2 passthrough",
"Capture a Steam Controller 2 (wired, Puck dongle, or paired Bluetooth) and stream " +
"it as-is — Steam on the host drives it like the physical pad.",
s.sc2Capture,
s.sc2Capture, enabled = s.gamepadForwarding,
) { update(s.copy(sc2Capture = it)) },
// The SC2 row's twin, and missing here until now: the touch settings have carried both
// side by side, so a couch user on a TV box — where there IS no touch interface to fall
// back to — could turn on SC2 passthrough but not the Sony one. Same no-vibrator-gate
// reasoning: this capture renders feedback on the CONTROLLER's motors, not this device's.
toggle(
"dsCapture", null, "DualSense / DualShock passthrough (USB)",
"Drive a USB-connected Sony pad directly — rumble on any phone, plus adaptive " +
"triggers, lightbar and gyro.",
s.dsCapture, enabled = s.gamepadForwarding,
) { update(s.copy(dsCapture = it)) },
)
}
@@ -44,6 +44,8 @@ data class SettingsOverlay(
val invertScroll: Boolean? = null,
val gamepad: Int? = null,
val gamepadForwarding: Boolean? = null,
val systemButtons: String? = null,
val guideGesture: String? = null,
val statsVerbosity: StatsVerbosity? = null,
/**
* Android-only tier-P addition (design §3): the decode pipeline is a device fact everywhere
@@ -78,6 +80,8 @@ data class SettingsOverlay(
invertScroll = invertScroll ?: base.invertScroll,
gamepad = gamepad ?: base.gamepad,
gamepadForwarding = gamepadForwarding ?: base.gamepadForwarding,
systemButtons = systemButtons ?: base.systemButtons,
guideGesture = guideGesture ?: base.guideGesture,
statsVerbosity = statsVerbosity ?: base.statsVerbosity,
lowLatencyMode = lowLatencyMode ?: base.lowLatencyMode,
presentPriority = presentPriority ?: base.presentPriority,
@@ -115,6 +119,8 @@ data class SettingsOverlay(
gamepadForwarding =
if (after.gamepadForwarding != before.gamepadForwarding) after.gamepadForwarding
else gamepadForwarding,
systemButtons = if (after.systemButtons != before.systemButtons) after.systemButtons else systemButtons,
guideGesture = if (after.guideGesture != before.guideGesture) after.guideGesture else guideGesture,
statsVerbosity = if (after.statsVerbosity != before.statsVerbosity) after.statsVerbosity else statsVerbosity,
lowLatencyMode = if (after.lowLatencyMode != before.lowLatencyMode) after.lowLatencyMode else lowLatencyMode,
presentPriority = if (after.presentPriority != before.presentPriority) after.presentPriority else presentPriority,
@@ -142,6 +148,8 @@ data class SettingsOverlay(
"invert_scroll" -> copy(invertScroll = null)
"gamepad" -> copy(gamepad = null)
"gamepad_forwarding" -> copy(gamepadForwarding = null)
"system_buttons" -> copy(systemButtons = null)
"guide_gesture" -> copy(guideGesture = null)
"stats_verbosity" -> copy(statsVerbosity = null)
"low_latency_mode" -> copy(lowLatencyMode = null)
"present_priority" -> copy(presentPriority = null)
@@ -166,6 +174,8 @@ data class SettingsOverlay(
if (invertScroll != null) add("invert_scroll")
if (gamepad != null) add("gamepad")
if (gamepadForwarding != null) add("gamepad_forwarding")
if (systemButtons != null) add("system_buttons")
if (guideGesture != null) add("guide_gesture")
if (statsVerbosity != null) add("stats_verbosity")
if (lowLatencyMode != null) add("low_latency_mode")
if (presentPriority != null) add("present_priority")
@@ -198,6 +208,8 @@ data class SettingsOverlay(
invertScroll?.let { j.put("invert_scroll", it) }
gamepad?.let { j.put("gamepad", it) }
gamepadForwarding?.let { j.put("gamepad_forwarding", it) }
systemButtons?.let { j.put("system_buttons", it) }
guideGesture?.let { j.put("guide_gesture", it) }
statsVerbosity?.let { j.put("stats_verbosity", it.name) }
lowLatencyMode?.let { j.put("low_latency_mode", it) }
presentPriority?.let { j.put("present_priority", it) }
@@ -214,6 +226,7 @@ data class SettingsOverlay(
"width", "height", "refresh_hz", "bitrate_kbps", "render_scale", "codec",
"hdr_enabled", "compositor", "audio_channels", "mic_enabled", "echo_cancel",
"touch_mode", "mouse_mode", "invert_scroll", "gamepad", "gamepad_forwarding",
"system_buttons", "guide_gesture",
"stats_verbosity",
"low_latency_mode", "present_priority", "smooth_buffer",
)
@@ -237,6 +250,8 @@ data class SettingsOverlay(
invertScroll = j.optBooleanOrNull("invert_scroll"),
gamepad = j.optIntOrNull("gamepad"),
gamepadForwarding = j.optBooleanOrNull("gamepad_forwarding"),
systemButtons = j.optStringOrNull("system_buttons"),
guideGesture = j.optStringOrNull("guide_gesture"),
statsVerbosity = j.optStringOrNull("stats_verbosity")
?.let { n -> StatsVerbosity.entries.firstOrNull { it.name == n } },
lowLatencyMode = j.optBooleanOrNull("low_latency_mode"),
@@ -45,6 +45,20 @@ data class Settings(
* bind — which is why it gates the USB capture paths, not just the wire sends.
*/
val gamepadForwarding: Boolean = true,
/**
* Where the guide (Xbox/PS) and misc/share presses land while streaming — the
* cross-client `system_buttons` key: `"auto"` (forward on Android — the press reaches
* the app on most devices) | `"forward"` | `"local"`.
*/
val systemButtons: String = "auto",
/**
* The hold-Select guide gesture — the cross-client `guide_gesture` key: `"auto"` (off
* on Android) | `"on"` | `"off"`. On: holding Select alone ≥350 ms sends the HOST's
* guide, down until release (long hold = the host's long-press → a Gaming-Mode host's
* QAM); a Select tap is delivered on release, slightly delayed. For devices whose
* shell intercepts the physical guide button.
*/
val guideGesture: String = "auto",
/** Requested audio channel count: 2 (stereo), 6 (5.1) or 8 (7.1). The host clamps to what it
* can capture; the resolved count drives the decoder + AAudio layout. */
val audioChannels: Int = 2,
@@ -228,6 +242,8 @@ class SettingsStore(context: Context) {
compositor = prefs.getInt(K_COMPOSITOR, 0),
gamepad = prefs.getInt(K_GAMEPAD, 0),
gamepadForwarding = prefs.getBoolean(K_GAMEPAD_FORWARDING, true),
systemButtons = prefs.getString(K_SYSTEM_BUTTONS, "auto") ?: "auto",
guideGesture = prefs.getString(K_GUIDE_GESTURE, "auto") ?: "auto",
audioChannels = prefs.getInt(K_AUDIO_CH, 2),
codec = prefs.getString(K_CODEC, "auto") ?: "auto",
micEnabled = prefs.getBoolean(K_MIC, false),
@@ -275,6 +291,8 @@ class SettingsStore(context: Context) {
.putInt(K_COMPOSITOR, s.compositor)
.putInt(K_GAMEPAD, s.gamepad)
.putBoolean(K_GAMEPAD_FORWARDING, s.gamepadForwarding)
.putString(K_SYSTEM_BUTTONS, s.systemButtons)
.putString(K_GUIDE_GESTURE, s.guideGesture)
.putInt(K_AUDIO_CH, s.audioChannels)
.putString(K_CODEC, s.codec)
.putBoolean(K_MIC, s.micEnabled)
@@ -305,6 +323,8 @@ class SettingsStore(context: Context) {
const val K_COMPOSITOR = "compositor"
const val K_GAMEPAD = "gamepad"
const val K_GAMEPAD_FORWARDING = "gamepad_forwarding"
const val K_SYSTEM_BUTTONS = "system_buttons"
const val K_GUIDE_GESTURE = "guide_gesture"
const val K_AUDIO_CH = "audio_channels"
const val K_CODEC = "codec"
const val K_MIC = "mic_enabled"
@@ -539,6 +559,15 @@ fun codecOptionsFor(stored: String, av1Capable: Boolean): List<Pair<String, Stri
}
}
/** Resolved [Settings.systemButtons]: forward the raw guide/misc presses? Auto = forward on
* Android — the press reaches the app on most devices, and where the shell shows its own UI
* for it that's the shell's business. */
fun Settings.systemButtonsForward(): Boolean = systemButtons != "local"
/** Resolved [Settings.guideGesture]: auto = OFF on Android (the raw press already reaches the
* host); "on" is for devices whose shell intercepts the physical guide button. */
fun Settings.guideGestureEnabled(): Boolean = guideGesture == "on"
/** The [Settings.codec] string as a `quic::CODEC_*` preference byte (`0` = auto). H264=1, HEVC=2,
* AV1=4, PyroWave=8 (never decodable here, but the byte is the shared contract). */
fun Settings.preferredCodec(): Int = when (codec) {
@@ -621,3 +650,17 @@ val GAMEPAD_OPTIONS = listOf(
io.unom.punktfunk.kit.Gamepad.PREF_DUALSHOCK4 to "DualShock 4",
io.unom.punktfunk.kit.Gamepad.PREF_STEAMDECK to "Steam Deck",
)
/** (stored `system_buttons` value, label) — where the guide/share presses land while streaming. */
val SYSTEM_BUTTON_OPTIONS = listOf(
"auto" to "Automatic",
"forward" to "Send to host",
"local" to "This device",
)
/** (stored `guide_gesture` value, label) — the hold-Select guide gesture. */
val GUIDE_GESTURE_OPTIONS = listOf(
"auto" to "Automatic",
"on" to "On",
"off" to "Off",
)
@@ -838,6 +838,25 @@ private fun ControllerSettings(s: Settings, update: (Settings) -> Unit, onOpenCo
caption = "The virtual pad the host creates. Automatic matches your controller; " +
"every connected one is forwarded as its own player.",
) { g -> update(s.copy(gamepad = g)) }
SettingDropdown(
label = "Guide button",
options = SYSTEM_BUTTON_OPTIONS,
selected = s.systemButtons,
field = "system_buttons",
enabled = s.gamepadForwarding,
caption = "Where the guide (Xbox/PS) and share presses go while streaming. " +
"Automatic sends them to the host whenever this device delivers them.",
) { v -> update(s.copy(systemButtons = v)) }
SettingDropdown(
label = "Hold Select for guide",
options = GUIDE_GESTURE_OPTIONS,
selected = s.guideGesture,
field = "guide_gesture",
enabled = s.gamepadForwarding,
caption = "Hold Select alone to press the host's guide button — keep holding for a " +
"Gaming-Mode host's quick-access menu. A Select tap still goes through, " +
"slightly delayed. For devices that intercept the real guide button.",
) { v -> update(s.copy(guideGesture = v)) }
DeviceScopeOnly {
ClickableRow(
title = "Connected controllers",
@@ -323,6 +323,7 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
// controller (Automatic). Built here, released on dispose.
val router = GamepadRouter(
context, handle, initialSettings.gamepad, initialSettings.gamepadForwarding,
initialSettings.systemButtonsForward(), initialSettings.guideGestureEnabled(),
)
activity?.gamepadRouter = router
// Select+Start+L1+R1 chord leaves the stream — a deliberate quit (signal it so the host skips
@@ -0,0 +1,97 @@
package io.unom.punktfunk
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The controller-navigable settings rows: what the master forwarding switch governs, and that a
* governed row is inert rather than merely dim.
*
* The touch settings and the desktop console have carried this relationship for a while (`enabled =
* s.gamepadForwarding` / `RowSpec.enabled`); this screen dimmed nothing and stepped everything, so
* these tests pin both halves — the flag AND the refusal to write.
*/
class GamepadSettingsRowsTest {
/** Rows for a given forwarding state, capturing whatever a row writes back. */
private fun rows(
forwarding: Boolean,
sink: MutableList<Settings> = mutableListOf(),
): List<GpRow> = buildSettingsRows(
Settings(gamepadForwarding = forwarding),
hasBodyVibrator = true,
av1Capable = true,
) { sink += it }
private fun row(rows: List<GpRow>, id: String): GpRow =
rows.first { it.id == id }
/** Every row that only means something while a controller is actually being forwarded. */
private val governed = listOf("padType", "systemButtons", "guideGesture", "sc2", "dsCapture")
@Test
fun `forwarding off dims every row that depends on it`() {
val off = rows(forwarding = false)
for (id in governed) {
assertFalse("$id should be dimmed with forwarding off", row(off, id).enabled)
}
// The master switch itself stays live — otherwise it could never be turned back on.
assertTrue(row(off, "padForward").enabled)
}
@Test
fun `forwarding on leaves them all live`() {
val on = rows(forwarding = true)
for (id in governed) {
assertTrue("$id should be live with forwarding on", row(on, id).enabled)
}
}
@Test
fun `a dimmed row is inert - liveRow withholds it and nothing is written`() {
val writes = mutableListOf<Settings>()
val off = rows(forwarding = false, sink = writes)
for (id in governed) {
val i = off.indexOfFirst { it.id == id }
assertNull("$id must not be reachable while dimmed", liveRow(off, i))
// What the screen actually does on left/right/A — the whole point is that it no-ops.
liveRow(off, i)?.adjust(1)
liveRow(off, i)?.adjust(-1)
liveRow(off, i)?.activate()
}
assertEquals("a dimmed row wrote a setting", emptyList<Settings>(), writes)
}
@Test
fun `the same rows do write once forwarding is on`() {
val writes = mutableListOf<Settings>()
val on = rows(forwarding = true, sink = writes)
val i = on.indexOfFirst { it.id == "sc2" }
assertNotNull(liveRow(on, i))
liveRow(on, i)?.activate()
assertEquals(1, writes.size)
assertFalse("activate flips the toggle", writes[0].sc2Capture)
}
/**
* R18: the Sony passthrough toggle the touch settings have always had. It matters most exactly
* where this screen is the only one reachable — a TV box has no touch interface to fall back to.
*/
@Test
fun `the DualSense passthrough toggle is present, next to its SC2 twin`() {
val on = rows(forwarding = true)
val ids = on.map { it.id }
assertTrue("dsCapture row is missing", "dsCapture" in ids)
assertEquals(
"the two passthrough rows belong side by side",
ids.indexOf("sc2") + 1,
ids.indexOf("dsCapture"),
)
// Drawn as a switch, and reading the persisted default.
assertEquals(true, row(on, "dsCapture").toggled)
}
}
@@ -50,12 +50,35 @@ class GamepadRouter(
* capture links, which `StreamScreen` does not start at all while this is off.
*/
private val forwarding: Boolean = true,
/**
* Forward raw guide/QAM presses (`Settings.systemButtons` resolved — auto = forward on
* Android, where the press reaches the app on most devices; `local` exists for
* cross-client profile parity with the Gaming-Mode clients). Off keeps them entirely
* with this device.
*/
private val systemForward: Boolean = true,
/**
* The hold-Select guide gesture (`Settings.guideGesture` resolved — auto = off on
* Android): holding Select ALONE ≥ [GUIDE_HOLD_MS] sends the HOST's guide button, down
* until release — so a long hold is the host's long-press, a Gaming-Mode host's QAM. A
* Select tap is delivered on release (delayed by up to the threshold); a Select pressed
* while other buttons are down passes through untouched, so the exit/mic chords keep
* working. pf-client-core's `SelectGesture`, on the main-thread handler.
*/
private val guideGesture: Boolean = false,
) {
/** One forwarded controller: its stable wire pad index, per-device axis state, and held buttons. */
private class Slot(val index: Int, val mapper: Gamepad.AxisMapper) {
/** Forwarded button bits currently held (Gamepad.BTN_*) — for release-on-close + chord detection. */
var held = 0
// Hold-Select→guide gesture state ([guideGesture]): the pending Select's hold
// timer / a delivered tap's owed release (both on the main handler), and whether
// the held Select was transformed into a synthetic guide.
var pendingGuide: Runnable? = null
var pendingTapUp: Runnable? = null
var selectAsGuide = false
}
/** deviceId → slot. Concurrent: the feedback poll threads read it via [deviceForPad]. */
@@ -139,7 +162,24 @@ class GamepadRouter(
* the mic-mute chord ([MIC_CHORD]).
*/
private fun slotButton(slot: Slot, bit: Int, down: Boolean, send: Boolean) {
// Raw system buttons stay local under the "local" policy — no wire send and no held
// tracking, symmetric on both edges so nothing leaks into the chords either.
if (!systemForward && (bit == Gamepad.BTN_GUIDE || bit == Gamepad.BTN_MISC1)) return
if (down) {
if (guideGesture && send) {
// A Select pressed ALONE is held back until it resolves: a tap (delivered
// on release), a combo member (the next button flushes it as a real
// press), or — past GUIDE_HOLD_MS — a synthetic guide. Held state records
// it either way, so the exit/mic chords read as if the gesture didn't
// exist (Select+Y still fires the mic toggle: the flush sends Select's
// down before Y's).
if (bit == Gamepad.BTN_BACK && slot.held == 0) {
slot.held = slot.held or bit
armGuide(slot)
return
}
flushPendingSelect(slot)
}
if (send && forwarding) {
NativeBridge.nativeSendGamepadButton(handle, bit, true, slot.index)
}
@@ -155,7 +195,8 @@ class GamepadRouter(
onMicChord?.invoke()
}
} else {
if (send && forwarding) {
val owned = guideGesture && bit == Gamepad.BTN_BACK && consumeSelectRelease(slot)
if (!owned && send && forwarding) {
NativeBridge.nativeSendGamepadButton(handle, bit, false, slot.index)
}
slot.held = slot.held and bit.inv()
@@ -167,6 +208,61 @@ class GamepadRouter(
}
}
/** Start a pending Select's hold countdown ([GUIDE_HOLD_MS] → a synthetic guide, down until release). */
private fun armGuide(slot: Slot) {
val r = Runnable {
slot.pendingGuide = null
slot.selectAsGuide = true
if (forwarding) {
NativeBridge.nativeSendGamepadButton(handle, Gamepad.BTN_GUIDE, true, slot.index)
}
}
slot.pendingGuide = r
mainHandler.postDelayed(r, GUIDE_HOLD_MS)
}
/**
* A second button joined while Select was pending — it was a real Select after all; its
* deferred down goes out before the caller sends the new button's, preserving chronology.
*/
private fun flushPendingSelect(slot: Slot) {
val r = slot.pendingGuide ?: return
mainHandler.removeCallbacks(r)
slot.pendingGuide = null
if (forwarding) {
NativeBridge.nativeSendGamepadButton(handle, Gamepad.BTN_BACK, true, slot.index)
}
}
/**
* Select released with gesture state outstanding — true when the gesture owned the
* release. A transformed hold lifts the synthetic guide; a pending tap delivers its
* held-back press now, with the release [TAP_PRESS_MS] behind it (a back-to-back pair
* can fold into nothing in the host's per-pad input fold).
*/
private fun consumeSelectRelease(slot: Slot): Boolean {
if (slot.selectAsGuide) {
slot.selectAsGuide = false
if (forwarding) {
NativeBridge.nativeSendGamepadButton(handle, Gamepad.BTN_GUIDE, false, slot.index)
}
return true
}
val r = slot.pendingGuide ?: return false
mainHandler.removeCallbacks(r)
slot.pendingGuide = null
if (forwarding) {
NativeBridge.nativeSendGamepadButton(handle, Gamepad.BTN_BACK, true, slot.index)
val up = Runnable {
slot.pendingTapUp = null
NativeBridge.nativeSendGamepadButton(handle, Gamepad.BTN_BACK, false, slot.index)
}
slot.pendingTapUp = up
mainHandler.postDelayed(up, TAP_PRESS_MS)
}
return true
}
/** Arm the exit-chord hold timer (once); on expiry, if the chord is still held, flush + leave. */
private fun armExit() {
if (pendingExit != null) return // already counting down
@@ -362,6 +458,24 @@ class GamepadRouter(
/** Lift every held button + zero the axes/HAT dpad for [slot] (wire events only, all on its index). */
private fun releaseHeld(slot: Slot) {
// Gesture first: a pending (never-sent) Select just drops its timer; an owed tap
// release goes out NOW (its down is already on the wire and the handle may not
// outlive this slot); a transformed guide — which is not in `held` — is lifted.
slot.pendingGuide?.let { mainHandler.removeCallbacks(it) }
slot.pendingGuide = null
slot.pendingTapUp?.let {
mainHandler.removeCallbacks(it)
slot.pendingTapUp = null
if (forwarding) {
NativeBridge.nativeSendGamepadButton(handle, Gamepad.BTN_BACK, false, slot.index)
}
}
if (slot.selectAsGuide) {
slot.selectAsGuide = false
if (forwarding) {
NativeBridge.nativeSendGamepadButton(handle, Gamepad.BTN_GUIDE, false, slot.index)
}
}
var bits = slot.held
while (bits != 0) {
val bit = bits and -bits // lowest set bit
@@ -403,5 +517,14 @@ class GamepadRouter(
/** Synthetic slot-key base for [ExternalPad]s — below every real (positive) InputDevice id. */
const val EXTERNAL_ID_BASE = -1000
/** pf-client-core's `GUIDE_HOLD`: hold Select alone this long → the host's guide goes down. */
const val GUIDE_HOLD_MS = 350L
/**
* pf-client-core's `TAP_PRESS`: a held-back Select tap's release trails its press by
* this much, so the pair can't coalesce into no press at all.
*/
const val TAP_PRESS_MS = 50L
}
}
@@ -675,8 +675,13 @@ final class SessionModel: ObservableObject {
// `gamepadForwarding` off means the host gets this device's pads from somewhere else
// (USB passthrough, or a pad plugged into the host) capture still runs, and still
// watches for the escape chord, but puts nothing on the wire.
// System-button routing: whether raw guide/share presses ride the wire, and whether
// hold-Select arms as the alternate guide route (auto = on everywhere but macOS
// iOS reserves the physical Home press, tvOS never delivers it).
let capture = GamepadCapture(
connection: conn, manager: .shared, forwarding: settings.gamepadForwarding)
connection: conn, manager: .shared, forwarding: settings.gamepadForwarding,
systemForward: settings.systemButtonsForward,
guideGesture: settings.guideGestureEnabled)
// The cross-client escape chord (hold L1+R1+Start+Select 1.5 s) on tvOS the only
// controller way out of a stream (B/Menu is swallowed during sessions; see ContentView).
capture.onDisconnectRequest = { [weak self] in self?.disconnect() }
@@ -39,6 +39,8 @@ struct GamepadSettingsView: View {
@AppStorage(DefaultsKey.compositor) private var compositor = 0
@AppStorage(DefaultsKey.gamepadType) private var gamepadType = 0
@AppStorage(DefaultsKey.gamepadForwarding) private var gamepadForwarding = true
@AppStorage(DefaultsKey.systemButtons) private var systemButtons = "auto"
@AppStorage(DefaultsKey.guideGesture) private var guideGesture = "auto"
@AppStorage(DefaultsKey.bitrateKbps) private var bitrateKbps = 0
@AppStorage(DefaultsKey.audioChannels) private var audioChannels = 2
@AppStorage(DefaultsKey.hdrEnabled) private var hdrEnabled = true
@@ -164,6 +166,11 @@ struct GamepadSettingsView: View {
/// layer" rule), and a hostless picker has nothing to pin, so only Back remains.
private var hints: [GamepadHint] {
guard pinTarget != nil else {
// A dimmed row takes neither, so offering them would be the same lie the row itself
// used to tell only Done remains, and the detail line says what to turn on first.
guard rows.first(where: { $0.id == focusID })?.enabled ?? true else {
return [.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done")]
}
return [
.init(glyph: "arrow.left.and.right", text: "Adjust"),
.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Change"),
@@ -216,7 +223,8 @@ struct GamepadSettingsView: View {
HStack(spacing: 9) {
Image(systemName: "chevron.left")
.font(.system(size: m.chevronFont, weight: .semibold))
.foregroundStyle(.white.opacity(focused && row.adjustable ? 0.6 : 0))
.foregroundStyle(
.white.opacity(focused && row.adjustable && row.enabled ? 0.6 : 0))
// Keyed by the value so a change slides the new option in instead of
// hard-swapping the string a QUIET horizontal slip following the user's
// motion (a right-step enters from the right), crossfading over ~14 pt.
@@ -237,9 +245,13 @@ struct GamepadSettingsView: View {
.animation(.smooth(duration: 0.22), value: row.value)
Image(systemName: "chevron.right")
.font(.system(size: m.chevronFont, weight: .semibold))
.foregroundStyle(.white.opacity(focused && row.adjustable ? 0.6 : 0))
.foregroundStyle(
.white.opacity(focused && row.adjustable && row.enabled ? 0.6 : 0))
}
}
// Contents only the glass and border below stay at full strength, so a dimmed row
// still reads as a row you can sit on (which you can: its detail is the point).
.opacity(row.enabled ? 1 : 0.45)
.padding(.horizontal, m.rowHPad)
.padding(.vertical, m.rowVPad)
// Every row is Liquid Glass; the focused one takes a brand wash and reacts to press.
@@ -274,6 +286,13 @@ struct GamepadSettingsView: View {
/// Whether left/right means anything here false hides the value's chevrons (the
/// Profiles rows navigate, and the placeholder rows do nothing at all).
var adjustable = true
/// Dimmed and inert when false: a row whose meaning depends on another setting that is
/// currently off. It stays in the list and stays FOCUSABLE its `detail` is how the
/// user learns which switch to flip first, and a row that vanished mid-list would
/// shift everything under the cursor. Enforced centrally in `adjust(id:by:)` /
/// `activate(id:)`, not per closure, so no row builder can forget it.
/// (Android's `GpRow.enabled` and `pf-console-ui`'s `RowSpec.enabled` are the twins.)
var enabled = true
/// Left/right step; returns whether the value actually changed (false boundary thud).
let adjust: (Int) -> Bool
/// A cycle forward (wrapping) / flip.
@@ -284,12 +303,14 @@ struct GamepadSettingsView: View {
/// (never on state captured at wire time).
private func adjust(id: String, by delta: Int) -> Bool {
lastAdjustDelta = delta
return rows.first { $0.id == id }?.adjust(delta) ?? false
guard let row = rows.first(where: { $0.id == id }), row.enabled else { return false }
return row.adjust(delta)
}
private func activate(id: String) {
lastAdjustDelta = 1 // A always cycles forward
rows.first { $0.id == id }?.activate()
guard let row = rows.first(where: { $0.id == id }), row.enabled else { return }
row.activate()
}
private var rows: [Row] {
@@ -389,16 +410,36 @@ struct GamepadSettingsView: View {
+ "controller already reaches the host another way — USB passthrough such "
+ "as VirtualHere — so games don't see two of them.",
value: $gamepadForwarding),
// The four rows below only mean something while something is being forwarded, so
// they follow the switch above the same relationship the touch settings draw with
// `.disabled(!effective.gamepadForwarding)`. This screen could not express it until
// `Row.enabled` existed, so it alone left them live and steppable.
choiceRow(
id: "pad", icon: "gamecontroller", label: "Use controller",
detail: "Which pad is forwarded to the host, as player 1.",
options: controllers, current: gamepads.preferredID
options: controllers, current: gamepads.preferredID,
enabled: gamepadForwarding
) { gamepads.preferredID = $0 },
choiceRow(
id: "padType", icon: "dpad", label: "Controller type",
detail: "The virtual pad the host creates — Automatic matches this controller.",
options: SettingsOptions.padTypes, current: gamepadType
options: SettingsOptions.padTypes, current: gamepadType,
enabled: gamepadForwarding
) { gamepadType = $0 },
choiceRow(
id: "systemButtons", icon: "house.circle", label: "Guide button",
detail: "Where the guide (Xbox/PS) and share presses go while streaming — "
+ "Automatic sends them to the host whenever this device delivers them.",
options: SettingsOptions.systemButtons, current: systemButtons,
enabled: gamepadForwarding
) { systemButtons = $0 },
choiceRow(
id: "guideGesture", icon: "hand.point.up.left", label: "Hold Select for guide",
detail: "Hold Select alone to press the host's guide button — keep holding "
+ "for a Gaming-Mode host's quick-access menu. A tap still goes through.",
options: SettingsOptions.guideGestures, current: guideGesture,
enabled: gamepadForwarding
) { guideGesture = $0 },
choiceRow(
id: "hud", header: "Interface", icon: "chart.bar", label: "Statistics overlay",
@@ -569,13 +610,15 @@ struct GamepadSettingsView: View {
private func choiceRow<T: Equatable>(
id: String, header: String? = nil, icon: String, label: String, detail: String,
options: [(label: String, tag: T)], current: T, write: @escaping (T) -> Void
options: [(label: String, tag: T)], current: T, enabled: Bool = true,
write: @escaping (T) -> Void
) -> Row {
let index = options.firstIndex { $0.tag == current }
return Row(
id: id, header: header, icon: icon, label: label,
value: index.map { options[$0].label } ?? "",
detail: detail,
enabled: enabled,
adjust: { delta in
// Unknown current value: snap to the first option on any step.
guard let index else {
@@ -596,12 +639,13 @@ struct GamepadSettingsView: View {
private func toggleRow(
id: String, header: String? = nil, icon: String, label: String, detail: String,
value: Binding<Bool>
value: Binding<Bool>, enabled: Bool = true
) -> Row {
Row(
id: id, header: header, icon: icon, label: label,
value: value.wrappedValue ? "On" : "Off",
detail: detail,
enabled: enabled,
adjust: { delta in
// Directional semantics: left = off, right = on; a no-op reads as a boundary.
let target = delta > 0
@@ -34,6 +34,22 @@ enum SettingsOptions {
("DualShock 4", 4),
]
/// System-button routing (the cross-client `system_buttons` key): where the guide
/// (Xbox/PS) and share presses land while streaming. Auto = forward on Apple.
static let systemButtons: [(label: String, tag: String)] = [
("Automatic", "auto"),
("Send to host", "forward"),
("This device", "local"),
]
/// The hold-Select guide gesture (the cross-client `guide_gesture` key). Auto = on
/// everywhere but macOS.
static let guideGestures: [(label: String, tag: String)] = [
("Automatic", "auto"),
("On", "on"),
("Off", "off"),
]
static let hudPlacements: [(label: String, tag: String)] =
HUDPlacement.allCases.map { ($0.label, $0.rawValue) }
@@ -126,6 +126,14 @@ enum SettingsFields {
.init(name: "gamepad_forwarding", key: DefaultsKey.gamepadForwarding,
overlay: \.gamepadForwarding, effective: \.gamepadForwarding)
}
static var systemButtons: SettingsField<String> {
.init(name: "system_buttons", key: DefaultsKey.systemButtons,
overlay: \.systemButtons, effective: \.systemButtons)
}
static var guideGesture: SettingsField<String> {
.init(name: "guide_gesture", key: DefaultsKey.guideGesture,
overlay: \.guideGesture, effective: \.guideGesture)
}
static var statsVerbosity: SettingsField<String> {
.init(name: "stats_verbosity", key: DefaultsKey.statsVerbosity,
overlay: \.statsVerbosity, effective: \.statsVerbosity)
@@ -681,6 +681,29 @@ extension SettingsView {
}
.disabled(!effective.gamepadForwarding)
}
described("Where the guide (Xbox/PS) and share presses go while streaming. "
+ "Automatic sends them to the host whenever this device delivers them "
+ "— the hold-Select gesture below reaches the host regardless.",
field: "system_buttons") {
Picker("Guide button", selection: scoped(SettingsFields.systemButtons)) {
Text("Automatic").tag("auto")
Text("Send to host").tag("forward")
Text("This device").tag("local")
}
.disabled(!effective.gamepadForwarding)
}
described("Hold Select on its own to press the host's guide button — keep "
+ "holding for a Gaming-Mode host's quick-access menu. A Select tap still "
+ "goes through, slightly delayed. Automatic arms it wherever the real "
+ "button can't reach the host (this device reserves it).",
field: "guide_gesture") {
Picker("Hold Select for guide", selection: scoped(SettingsFields.guideGesture)) {
Text("Automatic").tag("auto")
Text("On").tag("on")
Text("Off").tag("off")
}
.disabled(!effective.gamepadForwarding)
}
#if os(iOS)
// iPhone only in practice: hidden where the device itself can't play haptics (iPad).
if !inProfileScope, CHHapticEngine.capabilitiesForHardware().supportsHaptics {
@@ -67,6 +67,17 @@ public final class GamepadCapture {
var axes: [Int32] = [0, 0, 0, 0, 0, 0]
var fingerActive: [Bool] = [false, false]
var lastMotionNs: UInt64 = 0
// Hold-Selectguide gesture state (pf-client-core's `SelectGesture`, adapted to
// this class's mask-diff model): a Select pressed ALONE is held out of the mask
// until it resolves into a tap (delivered on release) or past `guideHold` a
// synthetic guide, down until release.
var selectPending = false
var selectAsGuide = false
/// A delivered tap's release is owed (`tapTimer` scheduled) its down went out
/// outside `buttons`, so `flush` must know to lift it.
var tapReleaseOwed = false
var gestureTimer: Timer?
var tapTimer: Timer?
init(controller: GCController, pad: UInt32, pref: PunktfunkConnection.GamepadType) {
self.controller = controller
self.pad = pad
@@ -87,10 +98,29 @@ public final class GamepadCapture {
/// `onDisconnectRequest`; the chord keeps forwarding to the host meanwhile (the user is
/// leaving anyway). The desktop clients' quick-press step (leave fullscreen / release
/// capture) has no Apple equivalent worth wiring macOS has Q/D, touch has the HUD.
private static let escapeChord: UInt32 =
/// Internal rather than private only so `GamepadEscapeChordTests` can pin it against
/// `escapeChordElements` below the two must not drift.
static let escapeChord: UInt32 =
GamepadWire.leftShoulder | GamepadWire.rightShoulder | GamepadWire.start | GamepadWire.back
/// `escapeChord`'s four elements by GameController alias the ONLY system gestures claimed
/// while forwarding is off (see `openSlot`). Kept beside the mask it mirrors: change one and
/// change the other, or the chord silently stops reaching us on tvOS. A test asserts the two
/// agree, because the failure is invisible until someone is stuck in a stream on an Apple TV.
static let escapeChordElements = [
GCInputLeftShoulder, GCInputRightShoulder, GCInputButtonMenu, GCInputButtonOptions,
]
/// pf-client-core's `DISCONNECT_HOLD` the same 1.5 s on every client.
private static let disconnectHold: TimeInterval = 1.5
/// pf-client-core's `GUIDE_HOLD`: hold Select alone this long the HOST's guide goes
/// down (until release, so a long hold is the host's long-press a Gaming-Mode
/// host's QAM). The gesture exists because iOS reserves the physical Home press (the
/// Game Overlay; sanctioned opt-out only via the user's iOS 27+ Home-button setting)
/// and tvOS never delivers it at all.
private static let guideHold: TimeInterval = 0.35
/// pf-client-core's `TAP_PRESS`: a held-back Select tap is delivered as a press with
/// its release this far behind back-to-back transitions can fold into nothing in
/// the host's per-pad input fold.
private static let tapPress: TimeInterval = 0.05
private var chordTimer: Timer?
/// Fired ON MAIN once the escape chord has been held `disconnectHold` the session owner
/// disconnects. On tvOS this (plus the Siri Remote's hold-Back) is the ONLY way out of a
@@ -115,10 +145,23 @@ public final class GamepadCapture {
/// "don't forward" is one fact in one place rather than a condition at twelve call sites.
private var wire: PunktfunkConnection? { forwarding ? connection : nil }
public init(connection: PunktfunkConnection, manager: GamepadManager, forwarding: Bool = true) {
/// Forward the raw guide + share/QAM presses (`EffectiveSettings.systemButtonsForward`,
/// default true on Apple where the OS shows its own overlay for them, that's the OS's
/// business; local mode exists for profile parity with the Gaming-Mode clients).
public let systemForward: Bool
/// The hold-Select guide gesture (`EffectiveSettings.guideGestureEnabled` auto = on
/// everywhere but macOS). See `guideHold`.
public let guideGesture: Bool
public init(
connection: PunktfunkConnection, manager: GamepadManager, forwarding: Bool = true,
systemForward: Bool = true, guideGesture: Bool = false
) {
self.connection = connection
self.manager = manager
self.forwarding = forwarding
self.systemForward = systemForward
self.guideGesture = guideGesture
}
public func start() {
@@ -202,11 +245,28 @@ public final class GamepadCapture {
// gesture attached the press is the system's, not the game's. During capture the remote
// session IS the game: the share button must reach the host (e.g. Steam screenshots),
// the PS button must open the host's Steam overlay. Restored to .enabled on close.
for element in c.physicalInputProfile.elements.values {
//
// With forwarding OFF none of that applies no press reaches the host, so taking the
// user's screenshot gesture away buys nothing. NARROWED, not skipped: the escape chord
// is still read off this slot, and on tvOS it is the only controller way out of a
// stream, so the chord's own four elements keep their claim. (Menu especially: leave
// its gesture attached on tvOS and the press is the system's the chord would never
// complete and the session would have no controller exit at all.)
let claimed = forwarding
? Array(c.physicalInputProfile.elements.values)
: Self.escapeChordElements.compactMap { c.physicalInputProfile.elements[$0] }
for element in claimed {
element.preferredSystemGestureState = .disabled
}
// The Home/PS button ( guide; the host maps it to the DualSense PS / Xbox guide bit,
// BTN_MODE on the virtual xpad the Steam-overlay button). Driven DIRECTLY from this
// BTN_MODE on the virtual xpad the Steam-overlay button). On iOS 26 the OS opens its
// Game Overlay for this press regardless of the gesture claim below (the app is
// LSApplicationCategoryType=games, which enrolls it); the sanctioned per-controller
// opt-out is the USER's iOS 27+ Home-button setting. TODO(iOS 27 SDK): read
// `GCControllerHomeButtonSettingsManager` and surface a one-time
// `openControllerHomeButtonSettings(for:)` deep-link so users can hand the button to
// the stream the class is Swift-only and 27.0+, so it needs the Xcode 27 SDK to
// even compile. Until then hold-Select is the reliable route. Driven DIRECTLY from this
// handler's pressed value (not via buttonMask), because the legacy
// `extendedGamepad.buttonHome` is unreliable/often nil even when the physical element
// exists. On tvOS the element is absent (reserved) nil, the whole block no-ops.
@@ -235,7 +295,11 @@ public final class GamepadCapture {
MainActor.assumeIsolated { if let self, let slot { self.touch(slot, finger: 1, x: x, y: y) } }
}
}
if let motion = c.motion {
// Motion is wire-only `forwardMotion` has nothing to do with forwarding off, and no
// local feature reads it. Powering the IMU anyway costs the pad real battery (it streams
// gyro + accel continuously over Bluetooth, which is why `closeSlot` is careful to power
// it back down), so with nothing to forward we simply never turn it on.
if forwarding, let motion = c.motion {
if motion.sensorsRequireManualActivation { motion.sensorsActive = true }
motion.valueChangedHandler = { [weak self, weak slot] m in
MainActor.assumeIsolated { if let self, let slot { self.forwardMotion(slot, m) } }
@@ -289,7 +353,14 @@ public final class GamepadCapture {
// as "changed" otherwise the first stick/button move after a guide press would emit a
// spurious guide-UP while the button is still physically held (and drop the bit from
// `slot.buttons`, swallowing the real release too). `flush`/`allButtons` still release it.
let newButtons = Self.buttonMask(g) | (slot.buttons & GamepadWire.guide)
var raw = Self.buttonMask(g)
// Raw system buttons stay local when passthrough is off: misc1 (share/QAM) is
// masked here, guide is gated at its own handler.
if !systemForward { raw &= ~GamepadWire.misc1 }
// The hold-Select gesture rewrites the mask: a Select pressed alone is held out
// until it resolves (tap on release / synthetic guide past the threshold).
if guideGesture { raw = gestureFiltered(slot, raw) }
let newButtons = raw | (slot.buttons & GamepadWire.guide)
let changed = newButtons ^ slot.buttons
if changed != 0 {
for bit in GamepadWire.allButtons where changed & bit != 0 {
@@ -312,10 +383,106 @@ public final class GamepadCapture {
updateEscapeChord()
}
/// The hold-Selectguide state machine over one sync's raw mask (pf-client-core's
/// `SelectGesture` rules): Select pressed ALONE is suppressed while pending; another
/// button joining makes it real (unsuppressed the diff sends its down); released
/// inside `guideHold` it's a tap, delivered out-of-band on release with the release
/// `tapPress` behind; past the threshold `gestureHoldFired` turned it into a synthetic
/// guide, lifted here when Select physically releases.
///
/// One deliberate divergence from the Rust worker: while transformed into a guide the
/// Select stays OUT of `slot.buttons`, so the escape chord doesn't complete on top of
/// an in-flight guide-hold release Select and press the chord plainly instead (the
/// chord's four-at-once press never lingers in pending long enough to be affected).
private func gestureFiltered(_ slot: Slot, _ raw: UInt32) -> UInt32 {
let back = GamepadWire.back
let backDown = raw & back != 0
let othersDown = raw & ~back != 0
if slot.selectAsGuide {
if backDown { return raw & ~back }
slot.selectAsGuide = false
sendGuide(slot, down: false, raw: false)
return raw
}
if slot.selectPending {
if !backDown {
endPending(slot)
deliverTap(slot)
return raw
}
if othersDown {
// A combo after all Select unsuppresses and the diff sends its down.
endPending(slot)
return raw
}
return raw & ~back
}
if backDown, !othersDown, slot.buttons & back == 0 {
// Newly pressed, alone: hold it back. An owed tap release goes out first so
// the host never sees two downs in a row.
if slot.tapReleaseOwed { finishTap(slot) }
slot.selectPending = true
let timer = Timer(timeInterval: Self.guideHold, repeats: false) { [weak self, weak slot] _ in
Task { @MainActor in
if let self, let slot { self.gestureHoldFired(slot) }
}
}
RunLoop.main.add(timer, forMode: .common)
slot.gestureTimer?.invalidate()
slot.gestureTimer = timer
return raw & ~back
}
return raw
}
/// The hold threshold passed with Select still pending it IS the guide now, down
/// until the physical release (`gestureFiltered`'s `selectAsGuide` branch lifts it).
private func gestureHoldFired(_ slot: Slot) {
guard slot.selectPending else { return }
slot.selectPending = false
slot.gestureTimer = nil
slot.selectAsGuide = true
sendGuide(slot, down: true, raw: false)
}
private func endPending(_ slot: Slot) {
slot.selectPending = false
slot.gestureTimer?.invalidate()
slot.gestureTimer = nil
}
/// Deliver a held-back Select tap: the press now, its release `tapPress` behind. Both
/// sends bypass `slot.buttons` (the raw mask no longer carries Select, so the diff
/// stays consistent); `tapReleaseOwed` is what `flush` checks so the press can't
/// outlive the slot.
private func deliverTap(_ slot: Slot) {
wire?.send(.gamepadButton(GamepadWire.back, down: true, pad: slot.pad))
slot.tapReleaseOwed = true
let timer = Timer(timeInterval: Self.tapPress, repeats: false) { [weak self, weak slot] _ in
Task { @MainActor in
if let self, let slot { self.finishTap(slot) }
}
}
RunLoop.main.add(timer, forMode: .common)
slot.tapTimer?.invalidate()
slot.tapTimer = timer
}
private func finishTap(_ slot: Slot) {
guard slot.tapReleaseOwed else { return }
slot.tapReleaseOwed = false
slot.tapTimer?.invalidate()
slot.tapTimer = nil
wire?.send(.gamepadButton(GamepadWire.back, down: false, pad: slot.pad))
}
/// Forward the guide (Home/PS) transition directly it's kept out of `buttonMask` (the legacy
/// `buttonHome` element is unreliable). Folds into the slot's `buttons` so a held PS button is
/// released by `flush` on focus loss / close just like the others.
private func sendGuide(_ slot: Slot, down: Bool) {
/// released by `flush` on focus loss / close just like the others. `raw: true` marks the
/// physical Home handler's calls, which the system-buttons policy can keep local; the
/// gesture's synthetic transitions pass `raw: false` and always go out.
private func sendGuide(_ slot: Slot, down: Bool, raw: Bool = true) {
if raw, !systemForward { return }
guard !suspended else { return }
let bit = GamepadWire.guide
let now = down ? (slot.buttons | bit) : (slot.buttons & ~bit)
@@ -449,6 +616,12 @@ public final class GamepadCapture {
/// (no GC calls) safe against an already-removed device. Does NOT close the slot or send
/// GamepadRemove (that's `closeSlot`).
private func flush(_ slot: Slot) {
// Gesture first: a pending (never-sent) Select just drops, an owed tap release
// goes out, and a transformed guide's bit folded into `buttons` by `sendGuide`
// is lifted by the loop below like any held button.
endPending(slot)
slot.selectAsGuide = false
if slot.tapReleaseOwed { finishTap(slot) }
for bit in GamepadWire.allButtons where slot.buttons & bit != 0 {
wire?.send(.gamepadButton(bit, down: false, pad: slot.pad))
}
@@ -38,6 +38,17 @@ public enum DefaultsKey {
/// host two pads for one pair of hands. Read at connect: `SessionModel` then never starts
/// `GamepadCapture`, so no slot opens, no arrival is sent and no virtual pad is built.
public static let gamepadForwarding = "punktfunk.gamepadForwarding"
/// Where a controller's SYSTEM buttons (guide + the share/QAM misc) land while streaming:
/// `"auto"` | `"forward"` | `"local"` the cross-client `system_buttons` key. Auto
/// forwards on every Apple platform: the local Game Overlay is the OS's business (and on
/// iOS 27+ the user can hand the Home button to the app in Settings), so suppressing our
/// send would gain nothing.
public static let systemButtons = "punktfunk.systemButtons"
/// The hold-Select guide gesture: `"auto"` | `"on"` | `"off"` the cross-client
/// `guide_gesture` key. Auto arms it everywhere but macOS: iOS reserves the physical Home
/// press for the Game Overlay (uncapturable pre-27) and tvOS never delivers it at all, so
/// holding Select is the controller route to the host's guide there.
public static let guideGesture = "punktfunk.guideGesture"
public static let bitrateKbps = "punktfunk.bitrateKbps"
/// Requested audio channel count: 2 (stereo), 6 (5.1) or 8 (7.1). The host clamps to what it
/// can capture; the resolved count drives the in-core decode + AVAudioEngine layout.
@@ -35,6 +35,10 @@ public struct EffectiveSettings: Equatable, Sendable {
public var invertScroll = false
public var gamepadType = 0
public var gamepadForwarding = true
/// Cross-client `system_buttons`: "auto" | "forward" | "local".
public var systemButtons = "auto"
/// Cross-client `guide_gesture`: "auto" | "on" | "off".
public var guideGesture = "auto"
/// A `StatsVerbosity` raw value; the enum lives in PunktfunkKit, which this module can't see.
public var statsVerbosity = "normal"
public var fullscreenWhileStreaming = true
@@ -95,6 +99,8 @@ public struct EffectiveSettings: Equatable, Sendable {
invertScroll = bool(DefaultsKey.invertScroll, invertScroll)
gamepadType = int(DefaultsKey.gamepadType, gamepadType)
gamepadForwarding = bool(DefaultsKey.gamepadForwarding, gamepadForwarding)
systemButtons = str(DefaultsKey.systemButtons, systemButtons)
guideGesture = str(DefaultsKey.guideGesture, guideGesture)
statsVerbosity = Self.storedStatsVerbosity(defaults)
fullscreenWhileStreaming = bool(
DefaultsKey.fullscreenWhileStreaming, fullscreenWhileStreaming)
@@ -121,6 +127,36 @@ public struct EffectiveSettings: Equatable, Sendable {
return "normal"
}
/// The `system_buttons` policy resolved for this platform: forward the raw guide (and
/// share/QAM misc) presses? Auto = forward on every Apple platform where the OS shows
/// its own overlay for the press that is the OS's business, and suppressing our send
/// would only break users who handed the button to the app (iOS 27's Home-button
/// setting; macOS with the gestures claimed).
public var systemButtonsForward: Bool {
switch systemButtons {
case "local": return false
default: return true
}
}
/// The hold-Select guide gesture resolved for this platform ([`guideGesture`]). Auto =
/// on everywhere but macOS: iOS reserves the physical Home press (the Game Overlay,
/// uncapturable pre-27) and tvOS never delivers it, so holding Select is the controller
/// route to the host's guide and, held on, to a Gaming-Mode host's QAM. On macOS the
/// raw press reaches the host, so auto stays off and Select keeps its exact timing.
public var guideGestureEnabled: Bool {
switch guideGesture {
case "on": return true
case "off": return false
default:
#if os(macOS)
return false
#else
return true
#endif
}
}
/// The one resolution seam: this overlay on top of these settings. Pure no store reads, no
/// clock so it is testable field by field. A `.some` that happens to equal the base is a
/// legitimate PIN: it keeps its value when the global later moves.
@@ -143,6 +179,8 @@ public struct EffectiveSettings: Equatable, Sendable {
if let v = overlay.invertScroll { s.invertScroll = v }
if let v = overlay.gamepadType { s.gamepadType = v }
if let v = overlay.gamepadForwarding { s.gamepadForwarding = v }
if let v = overlay.systemButtons { s.systemButtons = v }
if let v = overlay.guideGesture { s.guideGesture = v }
if let v = overlay.statsVerbosity { s.statsVerbosity = v }
if let v = overlay.fullscreenWhileStreaming { s.fullscreenWhileStreaming = v }
if let v = overlay.enable444 { s.enable444 = v }
@@ -111,6 +111,8 @@ public struct SettingsOverlay: Codable, Equatable, Sendable {
public var invertScroll: Bool?
public var gamepadType: Int?
public var gamepadForwarding: Bool?
public var systemButtons: String?
public var guideGesture: String?
/// A `StatsVerbosity` raw value ("off"/"compact"/"normal"/"detailed") the enum lives in
/// PunktfunkKit, which this module must not depend on.
public var statsVerbosity: String?
@@ -153,6 +155,8 @@ public struct SettingsOverlay: Codable, Equatable, Sendable {
case invertScroll = "invert_scroll"
case gamepadType = "gamepad"
case gamepadForwarding = "gamepad_forwarding"
case systemButtons = "system_buttons"
case guideGesture = "guide_gesture"
case statsVerbosity = "stats_verbosity"
case fullscreenWhileStreaming = "fullscreen_on_stream"
case enable444 = "enable_444"
@@ -187,6 +191,8 @@ public struct SettingsOverlay: Codable, Equatable, Sendable {
invertScroll = bool(.invertScroll)
gamepadType = int(.gamepadType)
gamepadForwarding = bool(.gamepadForwarding)
systemButtons = str(.systemButtons)
guideGesture = str(.guideGesture)
statsVerbosity = str(.statsVerbosity)
fullscreenWhileStreaming = bool(.fullscreenWhileStreaming)
enable444 = bool(.enable444)
@@ -224,6 +230,8 @@ public struct SettingsOverlay: Codable, Equatable, Sendable {
try c.encodeIfPresent(gamepadType, forKey: AnyKey(Key.gamepadType.rawValue))
try c.encodeIfPresent(
gamepadForwarding, forKey: AnyKey(Key.gamepadForwarding.rawValue))
try c.encodeIfPresent(systemButtons, forKey: AnyKey(Key.systemButtons.rawValue))
try c.encodeIfPresent(guideGesture, forKey: AnyKey(Key.guideGesture.rawValue))
try c.encodeIfPresent(statsVerbosity, forKey: AnyKey(Key.statsVerbosity.rawValue))
try c.encodeIfPresent(
fullscreenWhileStreaming, forKey: AnyKey(Key.fullscreenWhileStreaming.rawValue))
@@ -277,6 +285,8 @@ public enum OverlayField {
case "invert_scroll": overlay.invertScroll = nil
case "gamepad": overlay.gamepadType = nil
case "gamepad_forwarding": overlay.gamepadForwarding = nil
case "system_buttons": overlay.systemButtons = nil
case "guide_gesture": overlay.guideGesture = nil
case "stats_verbosity": overlay.statsVerbosity = nil
case "fullscreen_on_stream": overlay.fullscreenWhileStreaming = nil
case "enable_444": overlay.enable444 = nil
@@ -313,6 +323,8 @@ public enum OverlayField {
case "invert_scroll": return o.invertScroll != nil
case "gamepad": return o.gamepadType != nil
case "gamepad_forwarding": return o.gamepadForwarding != nil
case "system_buttons": return o.systemButtons != nil
case "guide_gesture": return o.guideGesture != nil
case "stats_verbosity": return o.statsVerbosity != nil
case "fullscreen_on_stream": return o.fullscreenWhileStreaming != nil
case "enable_444": return o.enable444 != nil
@@ -0,0 +1,52 @@
import GameController
import XCTest
@testable import PunktfunkKit
/// The escape chord's mask and its GameController alias list have to describe the same four
/// buttons. `GamepadCapture.openSlot` claims the system gesture of every element while forwarding
/// is on, but only of `escapeChordElements` while it is off so if the alias list ever stops
/// covering the mask, the missing button's press stays the system's and the chord never completes.
///
/// That matters most on tvOS, where this chord is the only controller way out of a stream: the
/// symptom is a session nobody can leave with the pad in their hands, and nothing logs or crashes.
/// Hence a test on the invariant rather than trusting the comment beside it.
@MainActor
final class GamepadEscapeChordTests: XCTestCase {
/// The intended aliasbit pairing, spelled out independently of the implementation.
private let pairing: [(alias: String, bit: UInt32)] = [
(GCInputLeftShoulder, GamepadWire.leftShoulder),
(GCInputRightShoulder, GamepadWire.rightShoulder),
(GCInputButtonMenu, GamepadWire.start),
(GCInputButtonOptions, GamepadWire.back),
]
func testChordMaskIsExactlyTheFourPairedButtons() {
XCTAssertEqual(
pairing.reduce(UInt32(0)) { $0 | $1.bit },
GamepadCapture.escapeChord,
"the chord mask and the alias pairing describe different buttons")
}
func testEveryChordBitHasAnElementToClaim() {
// One alias per bit a mask that grew a fifth button without a matching alias would
// leave that button's gesture with the OS while forwarding is off.
XCTAssertEqual(
GamepadCapture.escapeChordElements.count,
GamepadCapture.escapeChord.nonzeroBitCount,
"alias list and chord mask differ in size")
XCTAssertEqual(GamepadCapture.escapeChordElements, pairing.map(\.alias))
}
/// The claim list is a strict subset of what a forwarding slot takes it is a NARROWING of
/// the full sweep, never an extra grab, and it must not be empty (that would be "skip", which
/// is the behaviour this deliberately avoids).
func testClaimListIsNonEmptyAndAllDistinct() {
XCTAssertFalse(GamepadCapture.escapeChordElements.isEmpty)
XCTAssertEqual(
Set(GamepadCapture.escapeChordElements).count,
GamepadCapture.escapeChordElements.count,
"a repeated alias would mean a chord bit has no element")
}
}
+51
View File
@@ -0,0 +1,51 @@
# App Store copy
Source of truth for what goes into App Store Connect. Every character-limited field in here has
been counted with `check-limits.py`; run it after any edit.
```sh
python3 clients/apple/store/check-limits.py
```
| File | Covers |
|------|--------|
| [`ios.md`](ios.md) | iOS/iPadOS Promotional Text (DE + EN), with alternates |
| [`macos.md`](macos.md) | macOS Promotional Text, Description, Keywords (DE + EN) |
| [`tvos.md`](tvos.md) | tvOS Promotional Text, Description, Keywords (DE + EN) |
| [`review-notes.md`](review-notes.md) | App Review notes template + pre-submission checklist |
| [`privacy-app-addendum.md`](privacy-app-addendum.md) | App-specific privacy text to add to the existing policy page |
German is primary throughout and uses the same informal "du" voice as the website
(`punktfunk-website/messages/de.json`). English is a localisation, not a translation exercise — a
few lines diverge where the German idiom does not carry.
## Three things that contradicted the original brief
1. **A Mac cannot be a host.** The brief suggested Mac copy could cover "running as a host/server
or client on Mac". There is no macOS host — `punktfunk-host` has no macOS capture, virtual
display, or encode backend. The macOS copy is client-only and says so explicitly.
2. **The existing privacy policy is website-only.** It covers server logs, Plausible, and a
language cookie, and never mentions the apps. Linking it unchanged from App Store Connect is
the kind of thing that draws a reviewer's attention to analytics that have nothing to do with
the app. See `privacy-app-addendum.md` for the text to append.
3. **App Review notes cap at 4000 characters**, not the unlimited field the brief implied. The
template is 3919 and fits.
## Claims used, and where they come from
Everything asserted in the copy was checked against the source rather than the marketing site:
- Hardware decode, HDR/4:4:4, controller and input support — `clients/apple/README.md`
- Entitlements and their justifications — `Config/Punktfunk.entitlements`,
`Config/Punktfunk-macOS.entitlements` (both carry detailed rationale comments)
- Background audio mode and its 2.5.4 constraints — `Config/Info.plist`
- "Collects no data" — verified by absence: no analytics SDK in `Package.swift`, no telemetry
symbols in `Sources/`, `URLSession` used only against the paired host
- Host platforms and protocol details — root `README.md`, `docs/releases/v0.24.0.md`
- Feature ship dates — `git tag --contains` on the relevant commits
## Not done here
`clients/apple` has no `PrivacyInfo.xcprivacy`. The app uses `UserDefaults`, which is a
required-reason API, so a manifest is expected. Flagged at the end of `review-notes.md`; left
alone because it is a code change, not copy.
+82
View File
@@ -0,0 +1,82 @@
#!/usr/bin/env python3
"""Check every App Store copy block in this directory against its field limit.
App Store Connect silently truncates or hard-rejects over-long fields, and the German copy is the
easy one to get wrong because umlauts read as one character but two bytes. Apple counts characters,
so `len()` on a `str` is the right measure do not switch this to a byte count.
Each fenced code block in the .md files here is one field. Which limit applies is inferred from the
nearest heading above it. Exit status is non-zero if anything is over, so CI can gate on it.
"""
from __future__ import annotations
import pathlib
import re
import sys
LIMITS = {"PROMO": 170, "DESC": 4000, "KW": 100, "NOTES": 4000}
def blocks(text: str):
"""Yield (heading, body) for every fenced block, tagged with the heading above it."""
heading = None
buf: list[str] | None = None
for line in text.split("\n"):
if line.startswith("#") and buf is None:
heading = line.lstrip("#").strip()
if line.strip() == "```":
if buf is None:
buf = []
else:
yield heading or "", "\n".join(buf)
buf = None
continue
if buf is not None:
buf.append(line)
def kind_of(heading: str, body: str) -> str:
low = heading.lower()
if "keyword" in low or re.fullmatch(r"(de|en) \(\d+\)", low):
return "KW"
if "template" in low:
return "NOTES"
return "DESC" if len(body) > 400 else "PROMO"
def main() -> int:
here = pathlib.Path(__file__).parent
failures = 0
stale = 0
for path in sorted(here.glob("*.md")):
found = list(blocks(path.read_text(encoding="utf-8")))
if not found:
continue
print(f"\n=== {path.name} ===")
for heading, body in found:
kind = kind_of(heading, body)
limit = LIMITS[kind]
n = len(body)
over = n > limit
failures += over
# Headings carry the count in parentheses; flag any that drifted from the real length.
claimed = re.search(r"\((\d+)\)\s*$", heading)
drift = ""
if claimed and int(claimed.group(1)) != n:
drift = f" [heading claims {claimed.group(1)}]"
stale += 1
status = "OVER" if over else "ok"
print(f" [{kind:5}] {status:>4} {n:>4}/{limit} {heading[:48]}{drift}")
if failures:
print(f"\n{failures} block(s) OVER the limit")
elif stale:
print(f"\nAll within limits, but {stale} heading count(s) are stale")
else:
print("\nAll blocks within limits, all heading counts accurate")
return 1 if failures or stale else 0
if __name__ == "__main__":
sys.exit(main())
+71
View File
@@ -0,0 +1,71 @@
# iOS / iPadOS — App Store metadata
Existing, unchanged:
- **Name:** Punktfunk
- **Subtitle (DE):** Schnell, lokal & offen.
Only the Promotional Text is new here. It is the one field that can be changed **without** a new
build or a review, so it is the right place for "what landed most recently".
---
## Promotional Text (DE) — max 170 characters
### Primary (160)
```
Neu: Profile pro Host Auflösung, Bitrate und Ton einmal einstellen, dann mit einem Tipp verbinden. Dazu Live Activity, Sperrbildschirm-Widget und Wake-on-LAN.
```
### Alternate A — evergreen hook, no "new" claim (156)
```
Dein Gaming-PC auf dem iPhone, in dessen exakter Auflösung ohne Konto, ohne Cloud, nur dein Netzwerk. Hardware-Decoding, HDR und dein DualSense mit allem.
```
### Alternate B — leads on the DualSense (161)
```
Dein DualSense, vollständig: Rumble, adaptive Trigger, Lightbar, Touchpad und Gyro gehen bis ins Spiel durch. Dazu Profile pro Host und Wake-on-LAN vom Sofa aus.
```
### Alternate C — leads on latency (153)
```
Kein Konto, keine Cloud, kein Umweg: punktfunk/1 fährt über QUIC direkt zu deinem PC. Auflösungswechsel mitten im Stream, ohne die Verbindung zu trennen.
```
---
## Promotional Text (EN) — max 170 characters
### Primary (152)
```
New: per-host profiles — set resolution, bitrate and audio once, then connect with one tap. Plus Live Activities, a Lock Screen widget, and Wake-on-LAN.
```
### Alternate A — evergreen hook (159)
```
Your gaming PC on your iPhone, at your iPhone's exact resolution — no account, no cloud, just your network. Hardware decoding, HDR, and your DualSense in full.
```
### Alternate B — leads on the DualSense (160)
```
Your DualSense, in full: rumble, adaptive triggers, lightbar, touchpad and gyro all reach the game. Plus per-host profiles and Wake-on-LAN from across the room.
```
---
## Notes on the claims
- "Profile pro Host" shipped in **v0.22.0** (`25b12780`, `80c0ca69`) and is in every tag since. It is
the strongest recent user-facing Apple feature, so "Neu" is defensible for one release cycle — but
drop the word once 0.25 ships something newer.
- Live Activities and the Hosts widget shipped long ago (`ba1caf02`, in v0.15.0+). They are safe to
*mention* but should not be called "neu".
- The only Apple-visible feature unique to **v0.24.0** is the "Forward controllers" off switch
(`b297542c`), which is too niche to headline.
+159
View File
@@ -0,0 +1,159 @@
# macOS — App Store metadata
> **Scope correction.** The Mac app is a **client only**. There is no macOS host: `punktfunk-host`
> has no macOS capture, virtual-display, or encode backend (the two `cfg!(target_os = "macos")` hits
> in the host crate are OS *detection* for the host tile and a path helper; the loopback-test host
> is a synthetic frame source for `test-loopback.sh`, not a shippable host). A macOS host is a
> feasibility study — it needs four new backends and the private `CGVirtualDisplay` API.
> None of the copy below claims a Mac can host, and it should not until that ships.
- **Name:** Punktfunk
- **Subtitle (DE):** Schnell, lokal & offen.
- **Subtitle (EN):** Fast, local & open.
---
## Promotional Text (DE) — max 170 characters
### Primary (164)
```
Neu: Profile pro Host ein Mac, mehrere Gaming-PCs, jeder mit eigenen Einstellungen. Dazu AV1-Hardware-Decoding auf M3 und neuer, HDR und volles 4:4:4 für Schrift.
```
### Alternate (156)
```
Dein Gaming-PC im Fenster oder im Vollbild, in der exakten Auflösung deines Displays. Maus und Tastatur gehen durch, Auflösungswechsel ohne neue Verbindung.
```
## Promotional Text (EN) — max 170 characters
### Primary (161)
```
New: per-host profiles — one Mac, several gaming PCs, each with its own settings. Plus AV1 hardware decoding on M3 and later, HDR, and full 4:4:4 for crisp text.
```
### Alternate (156)
```
Your gaming PC in a window or full screen, at your display's exact resolution. Mouse and keyboard pass straight through; resize without dropping the stream.
```
---
## Description (DE) — max 4000 characters
```
Punktfunk streamt deinen Gaming-PC auf den Mac in der exakten Auflösung und Bildwiederholrate deines Displays, über dein eigenes Netzwerk, ohne Konto und ohne Cloud.
Punktfunk besteht aus zwei Hälften: einem Host auf dem PC, von dem du streamst, und dieser App auf dem Gerät, auf dem du spielst. Der Host ist quelloffen und kostenlos, läuft auf Linux und auf Windows 11 auf dem Gaming-Rig unterm Schreibtisch, auf einem Laptop oder headless auf einem Server, an dem gar kein Monitor hängt.
DEIN MAC BEKOMMT SEIN EIGENES DISPLAY
Für jede Verbindung legt der Host ein echtes virtuelles Display an in genau der Auflösung und Bildrate, die dein Mac meldet. Kein Skalieren, keine schwarzen Balken, kein Umsortieren deiner echten Monitore. Änderst du mitten im Stream die Fenstergröße oder gehst auf Vollbild, wird die Auflösung neu ausgehandelt, ohne die Verbindung zu trennen. Mehrere Geräte können gleichzeitig streamen, jedes auf seinem eigenen Display.
SCHNELL, WEIL UNS DER GANZE WEG GEHÖRT
Die nativen Apps sprechen punktfunk/1: eine QUIC-Steuerebene und eine verschlüsselte Datenebene mit Vorwärtsfehlerkorrektur, die Auflösung und Bildrate mitten im Stream wechselt, ohne neu zu verbinden. Dekodiert wird in Hardware über VideoToolbox H.264, HEVC und AV1 auf Macs, die AV1 in Hardware können (M3 und neuer).
FÜR DEN MAC GEMACHT
• Im Fenster oder im Vollbild, auf jedem angeschlossenen Display
• Maus und Tastatur gehen vollständig durch Klick zum Fangen, Cmd+Esc oder Ctrl+Alt+Shift+Q zum Freigeben
• Ein Stream-Menü in der Menüleiste: Maus freigeben, Trennen, Statistik einblenden
• Mikrofon-Uplink mit Echounterdrückung dein Mac wird zum Headset am PC
• HDR mit PQ-Passthrough und ein optionaler Vollchroma-Modus (4:4:4), damit kleine Schrift und feine Linien scharf bleiben
CONTROLLER, VOLLSTÄNDIG
DualSense, Xbox- und weitere MFi-kompatible Controller. Beim DualSense gehen Rumble, Lightbar, Player-LEDs, adaptive Trigger, Touchpad und Gyro bis ins Spiel durch. Welchen Typ das virtuelle Gamepad am Host annimmt, richtet sich nach dem, was bei dir wirklich in der Hand liegt.
DEINE BIBLIOTHEK, DEIN NETZWERK
Installierte Steam-Titel und selbst hinzugefügte Spiele erscheinen als Raster mit Artwork und starten direkt. Hosts findet die App im Netzwerk von allein. Beim ersten Mal koppelst du einmalig mit einer PIN, danach verbindet sich der Mac über eine gepinnte Identität aus deinem Schlüsselbund kein Konto, kein Login. Einen schlafenden PC weckt Punktfunk per Wake-on-LAN.
MESSEN STATT GLAUBEN
Ein gestuftes Overlay zeigt Bildrate, Bitrate und Latenz über zwei Maschinen hinweg um den Uhrenversatz korrigiert, also eine Messung und kein Versprechen. Ein Geschwindigkeitstest pro Host schlägt eine passende Bitrate vor. Profile halten pro Host fest, wie gestreamt werden soll.
WAS DU BRAUCHST
Einen Punktfunk-Host auf einem Linux-PC oder auf Windows 11 (22H2 oder neuer) im selben Netzwerk. Der Host ist quelloffen (MIT/Apache-2.0) und kostenlos Anleitungen und Quellcode findest du auf punktfunk.unom.io. Diese App ist der Client: ein Mac kann derzeit nicht selbst Host sein.
Kein Konto. Keine Cloud. Keine Telemetrie. Die App erfasst keine Daten über dich.
```
---
## Description (EN) — max 4000 characters
```
Punktfunk streams your gaming PC to your Mac — at your display's exact resolution and refresh rate, over your own network, with no account and no cloud.
Punktfunk comes in two halves: a host on the PC you stream from, and this app on the device you play on. The host is open source and free, and runs on Linux and on Windows 11 — on the gaming rig under your desk, on a laptop, or headless on a server with no monitor attached at all.
YOUR MAC GETS A DISPLAY OF ITS OWN
For every connection, the host creates a real virtual display at exactly the resolution and refresh rate your Mac reports. No scaling, no black bars, no rearranging your actual monitors. Resize the window mid-stream or go full screen and the resolution is renegotiated without dropping the connection. Several devices can stream at once, each on its own display.
FAST, BECAUSE WE OWN THE WHOLE PATH
The native apps speak punktfunk/1: a QUIC control plane and an encrypted data plane with forward error correction, able to change resolution and frame rate mid-stream without reconnecting. Decoding is done in hardware through VideoToolbox — H.264, HEVC, and AV1 on Macs with an AV1 hardware decoder (M3 and later).
BUILT FOR THE MAC
• In a window or full screen, on any attached display
• Mouse and keyboard pass straight through — click to capture, Cmd+Esc or Ctrl+Alt+Shift+Q to release
• A Stream menu in the menu bar: release the mouse, disconnect, toggle the stats overlay
• Microphone uplink with echo cancellation — your Mac becomes the headset on your PC
• HDR with PQ passthrough, plus an optional full-chroma (4:4:4) mode that keeps small text and fine UI lines sharp
CONTROLLERS, IN FULL
DualSense, Xbox, and other MFi-compatible controllers. On a DualSense, rumble, lightbar, player LEDs, adaptive triggers, touchpad, and gyro all reach the game. The virtual gamepad the host presents takes its type from the controller actually in your hands.
YOUR LIBRARY, YOUR NETWORK
Installed Steam titles and games you add yourself appear as a grid with artwork, ready to launch. The app finds hosts on your network by itself. The first time, you pair once with a PIN; after that your Mac reconnects on a pinned identity stored in your keychain — no account, no login. Punktfunk can wake a sleeping PC over Wake-on-LAN.
MEASURED, NOT PROMISED
A tiered overlay shows frame rate, bitrate, and latency — corrected for clock skew across the two machines, so it is a measurement rather than a claim. A per-host speed test suggests a bitrate that matches your link. Profiles remember how each host should be streamed.
WHAT YOU NEED
A Punktfunk host on a Linux PC or on Windows 11 (22H2 or later) on the same network. The host is open source (MIT/Apache-2.0) and free — guides and source at punktfunk.unom.io. This app is the client: a Mac cannot currently act as a host.
No account. No cloud. No telemetry. This app collects no data about you.
```
---
## Keywords — max 100 characters
Comma-separated, **no spaces after the commas** (spaces count against the limit). The app name and
the subtitle are already indexed, so `punktfunk`, `schnell`, `lokal`, and `offen` are deliberately
absent — repeating them would waste characters.
### DE (97)
```
streaming,spiele,remote,desktop,fernzugriff,pc,linux,windows,controller,gamepad,latenz,quelloffen
```
### EN (95)
```
streaming,remote,desktop,pc,linux,windows,gaming,controller,gamepad,latency,selfhosted,lan,play
```
**Deliberately excluded:** `Moonlight`, `GameStream`, `NVIDIA`, `Steam`. Punktfunk genuinely is
GameStream-compatible and does read your Steam library, but App Store Review Guideline 4.1 and the
metadata rules disallow third-party app, product, and company names in the **keyword** field — it is
a routine rejection. Saying it in the description is fine; the current descriptions avoid naming
Moonlight and mention Steam only as a factual statement about your own library.
The previous keyword set (`Game-Streaming, Lokal, Open-Source, Gaming`) spent characters on spaces,
on `Lokal` (already in the subtitle), and on both `Game-Streaming` and `Gaming`, which share a stem.
+146
View File
@@ -0,0 +1,146 @@
# Privacy — what to link from App Store Connect
## The situation
You already have a privacy policy at **punktfunk.unom.io/legal/privacy**. It is good, current
(Stand: 28. Juni 2026), and localised DE/EN. But it is a **website** privacy policy: it covers
server log files, Plausible Analytics on `analytics.unom.io`, the `PARAGLIDE_LOCALE` cookie, and
self-hosted fonts. It does not mention the apps at all.
That is a problem for App Store Connect in two directions:
1. Apple requires the linked policy to describe **the app's** data practices. A reviewer following
the link finds a page about a website.
2. It reads as *contradicting* a "Data Not Collected" declaration. The page prominently describes
analytics and a cookie. A reviewer who skims it sees "Reichweitenmessung mit Plausible
Analytics" and has every reason to question the App Privacy answers.
**Recommendation:** keep the existing page and append an app-specific section to it (the text
below), so one URL covers both. The alternative — a separate `/legal/privacy-apps` route — also
works, but one URL is less to keep in sync.
The page is CMS-driven (`src/routes/legal/privacy.tsx` renders Payload `RichText` blocks from the
`pages` collection, slug `legal/privacy`, tenant `punktfunk`), so this is a CMS edit rather than a
code change.
## Confirming the "collects no data" framing
Checked against the source rather than taken on trust, and it holds:
- **No analytics, telemetry, or crash-reporting SDK.** `Package.swift` declares no such dependency.
A case-insensitive sweep of `Sources/` for `sentry|firebase|analytics|telemetry|amplitude|
mixpanel|crashlytics|posthog|plausible` returns 43 hits — 43 of them the word "amplitude" in
haptics code (rumble amplitude), and one the English word "plausible" in a comment.
- **No outbound calls to us.** The only `URLSession` use is `LibraryClient`, fetching cover art
**from the paired host**, over a TLS session that pins the host's own certificate. The only
external URLs anywhere in the Swift sources are three UI links the user can tap: the docs site,
the source on `git.unom.io`, and the Discord invite.
- **No account system.** Identity is a client keypair in the device keychain
(`keychain-access-groups`, `ClientIdentityStore`); pairing is SPAKE2 with a PIN, host-to-device.
- **Data stays on device.** Saved hosts and settings live in a shared `UserDefaults` suite
(`group.io.unom.punktfunk`) so the widget can read them. Nothing syncs; there is no CloudKit
entitlement.
- **No ATT.** No `NSUserTrackingUsageDescription` anywhere, consistent with no tracking.
So **App Privacy → "Data Not Collected"** is accurate for all four platforms. Two caveats worth
stating in the policy text anyway, because they are true and pre-empt questions:
- The microphone uplink **is** audio leaving the device — but only to the host the user paired with,
encrypted, and never to us. Apple's questionnaire asks about data collected *by you or your
third-party partners*; streaming to the user's own machine is not collection. Saying so plainly
is better than staying silent about a microphone permission.
- The apps are distributed through the App Store, so **Apple** collects its own analytics. That is
Apple's processing, not yours, but naming it avoids looking like an omission.
---
## Text to append — Deutsch
> ## Die Punktfunk-Apps
>
> Dieser Abschnitt betrifft die Punktfunk-Apps für iPhone, iPad, Apple TV, Mac, Windows, Linux und
> Android im Unterschied zu den vorstehenden Abschnitten, die sich auf diese Website beziehen.
>
> **Die Apps erheben keine personenbezogenen Daten.** Es gibt keine Benutzerkonten, keine
> Registrierung und keine Anmeldung. Die Apps enthalten keine Analyse-, Tracking-, Werbe- oder
> Absturzbericht-Bibliotheken von Drittanbietern. Es findet kein Tracking im Sinne des App
> Tracking Transparency Frameworks statt, und es werden keine Daten an uns oder an Dritte
> übermittelt.
>
> **Wohin die Daten fließen.** Punktfunk verbindet Ihr Gerät direkt mit einem Host-Rechner, den Sie
> selbst betreiben in der Regel in Ihrem eigenen Netzwerk. Video, Ton, Maus-, Tastatur- und
> Controller-Eingaben sowie sofern Sie ihn einschalten Ihr Mikrofon werden ausschließlich
> zwischen Ihrem Gerät und diesem Host übertragen, verschlüsselt und ohne Umweg über einen Server
> von uns. Wir betreiben für den Streaming-Betrieb keine Vermittlungs-, Relay- oder Cloud-Dienste
> und haben zu keinem Zeitpunkt Zugriff auf die Inhalte einer Sitzung.
>
> **Was auf dem Gerät bleibt.** Die App speichert lokal auf Ihrem Gerät: die von Ihnen
> hinzugefügten oder im Netzwerk gefundenen Hosts, Ihre Einstellungen und Profile sowie einen
> kryptografischen Schlüssel, mit dem sich Ihr Gerät gegenüber einem gekoppelten Host ausweist
> (auf Apple-Geräten im Schlüsselbund). Diese Daten verlassen Ihr Gerät nicht und werden gelöscht,
> wenn Sie die App entfernen.
>
> **Berechtigungen.** Die App fragt nur Berechtigungen ab, die für den Betrieb nötig sind: den
> Zugriff auf das lokale Netzwerk, um Hosts zu finden und sich mit ihnen zu verbinden, und nur
> wenn Sie die Mikrofonübertragung nutzen das Mikrofon. Das Mikrofonsignal wird an den von Ihnen
> gekoppelten Host übertragen, wo es als virtuelles Mikrofon erscheint; es wird nicht
> aufgezeichnet und nicht an uns gesendet.
>
> **Verteilung über App-Stores.** Wenn Sie die App über den App Store oder Google Play beziehen,
> verarbeiten Apple bzw. Google im Rahmen der Auslieferung eigene Daten (etwa Kauf-, Installations-
> und Absturzstatistiken). Darauf haben wir keinen Einfluss; es gelten die
> Datenschutzbestimmungen des jeweiligen Anbieters. Aggregierte Statistiken, die uns Apple oder
> Google in ihren Entwicklerkonsolen anzeigen, lassen keinen Rückschluss auf einzelne Personen zu.
>
> **Der Host.** Der Punktfunk-Host ist quelloffene Software, die Sie selbst auf Ihrem eigenen
> Rechner betreiben. Welche Daten dabei anfallen etwa lokale Protokolldateien , bleibt
> vollständig unter Ihrer Kontrolle; wir erhalten davon nichts. Der Quellcode ist unter
> git.unom.io/unom/punktfunk einsehbar.
---
## Text to append — English
> ## The Punktfunk apps
>
> This section concerns the Punktfunk apps for iPhone, iPad, Apple TV, Mac, Windows, Linux, and
> Android — as distinct from the sections above, which concern this website.
>
> **The apps collect no personal data.** There are no user accounts, no registration, and no sign-in.
> The apps contain no third-party analytics, tracking, advertising, or crash-reporting libraries.
> No tracking within the meaning of Apple's App Tracking Transparency framework takes place, and no
> data is transmitted to us or to any third party.
>
> **Where your data goes.** Punktfunk connects your device directly to a host machine that you run
> yourself, normally on your own network. Video, audio, mouse, keyboard, and controller input — and
> your microphone, if you switch it on — travel only between your device and that host, encrypted,
> without passing through any server of ours. We operate no brokering, relay, or cloud service for
> streaming, and we have no access to the contents of a session at any point.
>
> **What stays on your device.** The app stores locally on your device: the hosts you have added or
> discovered on your network, your settings and profiles, and a cryptographic key your device uses
> to identify itself to a paired host (in the keychain, on Apple devices). This data does not leave
> your device and is removed when you delete the app.
>
> **Permissions.** The app requests only the permissions it needs to work: access to the local
> network, in order to find hosts and connect to them, and — only if you use microphone streaming —
> the microphone. The microphone signal is sent to the host you paired with, where it appears as a
> virtual microphone; it is not recorded and is not sent to us.
>
> **Distribution through app stores.** If you obtain the app from the App Store or Google Play,
> Apple or Google process their own data as part of distributing it (such as purchase, installation,
> and crash statistics). We have no influence over this, and the respective provider's privacy
> policy applies. The aggregated statistics Apple and Google show us in their developer consoles do
> not allow any individual to be identified.
>
> **The host.** The Punktfunk host is open source software that you run on your own machine. Any
> data it produces — local log files, for instance — remains entirely under your control, and none
> of it reaches us. The source is available at git.unom.io/unom/punktfunk.
---
## Also update
- Bump **Stand: / Effective date:** on the page when you add this.
- App Store Connect → App Privacy → **Data Not Collected** for all four platforms.
- The same URL works for Google Play's Data safety declaration; the wording above already covers it.
+132
View File
@@ -0,0 +1,132 @@
# App Review notes
## The core problem, stated plainly
Punktfunk is the client half of a two-part system. Without a reachable host it shows a host list, a
pairing sheet, and settings — and nothing else. There is **no demo or offline mode in a release
build**: the mock-data screens in `Sources/PunktfunkClient/Screenshots/` are wrapped in `#if DEBUG`
and are compiled out of anything you ship. A reviewer who launches the App Store build with no host
on their network sees an empty "On this network" list.
Guideline 2.1 requires you to supply whatever is needed to fully exercise the app. So you must
attach **one** of:
- **(a) A reachable demo host.** Best outcome — the reviewer sees the real thing. Requires a host
exposed to the internet with its UDP ports forwarded, plus a pairing PIN in the notes. The client
can add a host by IP or hostname, so mDNS discovery is not required for this path.
- **(b) A demo video.** Apple accepts this for hardware- or setup-dependent apps. Less good: a
reviewer who cannot reproduce is a reviewer who can reject on something unrelated.
**Attach (a) if you can keep a host up for the review window; (b) is the fallback.** Whichever you
pick, fill in the placeholders before submitting — the template assumes (a) and marks the spots.
> **⚠ Decide before submitting:** if you go with (b), replace the "CONNECTING TO OUR DEMO HOST"
> section with the video URL and say explicitly that no host can be provided.
---
## Notes template — paste into App Store Connect
The App Review Information "Notes" field caps at **4000 characters**. The block below is **3919**,
and filling the five placeholders in shortens it further (the literal `[[FILL IN: …]]` text is
longer than the values that replace it). If you add to it, re-check the count — an over-long note
is silently truncated, and what gets cut is the end, where the privacy and entitlement answers
live.
```
WHAT THIS APP IS
Punktfunk is a low-latency game- and desktop-streaming client. It streams from a "host" the user
installs on their own gaming PC (Linux, or Windows 11 22H2+), over their own network. The host is
separate open-source software we publish at https://git.unom.io/unom/punktfunk; it is not sold,
and this app has no purchases.
This app is the client half only: it renders video and audio from the user's own machine and
sends input back. There is no content library and no server of ours in a session.
IMPORTANT: THIS APP NEEDS A HOST
With no reachable host, the app can only show its host list, the pairing screen and settings --
inherent to what it is, not an incomplete build. We have provided a live host for review.
CONNECTING TO OUR DEMO HOST
1. Launch Punktfunk. The main screen lists hosts on the local network. Ours is not on yours, so
add it by hand: "+" (top right) then "Add host"; on Apple TV, "Add host" on the main screen.
2. Enter: Host: [[FILL IN: hostname or IP]] Port: [[FILL IN: port, default 47998]]
Name it anything, then confirm.
3. The app connects and asks for a pairing PIN. Enter: [[FILL IN: PIN]]
A one-time SPAKE2 pairing; afterwards the device is remembered and needs no PIN.
4. The host's game library appears as a grid. Select any title to stream; video and audio start
within a few seconds.
5. While streaming: stats overlay = Ctrl+Alt+Shift+S (or three-finger tap on iOS/iPadOS); release
mouse = Cmd+Esc or Ctrl+Alt+Shift+Q; disconnect = Ctrl+Alt+Shift+D.
6. Settings (gear) covers decoder, bitrate, HDR, audio, controllers and profiles; the per-host
"Speed test" suggests a bitrate for the link.
The host stays reachable throughout review. If you cannot reach it, please contact
[[FILL IN: contact email]] and we will restore it promptly.
WHY THE APP ASKS FOR WHAT IT ASKS FOR
- Local Network: finds hosts via Bonjour (_punktfunk._udp) and connects to them -- the app's
entire purpose.
- Microphone (optional, off by default): audio goes to the user's own paired host, appearing
there as a virtual microphone for voice chat. Never recorded, never sent to us.
- networking.multicast: sends the Wake-on-LAN magic packet, which must go to a broadcast address:
a sleeping PC has no ARP entry, so unicast cannot reach it. Used for nothing else.
- device.usb / device.bluetooth (macOS): the GameController framework reaches wired controllers
through IOHIDLibUserClient and wireless ones through startWirelessControllerDiscovery. USB also
drives DualSense rumble, which CoreHaptics will not. Without these, no controller input.
- network.server (macOS): the app is outbound-only, but the App Sandbox gates bind() itself. Our
QUIC endpoint and UDP socket each bind a local port to receive host-to-client datagrams;
without this, no video, audio or rumble arrives.
- UIBackgroundModes "audio" (iPhone/iPad): a session carries real, audible audio from the host,
and this keeps it alive if the user steps away briefly. Backgrounded, video decoding stops, only
the real audio keeps rendering, and a bounded timer disconnects automatically. We never play
silence to stay alive, nor use the mode outside an audible session.
REGARDING BUILD 0.4.2 (3384)
That build was rejected under 2.4.5(i) for a temporary-exception entitlement
(mach-lookup.global-name, com.apple.audioanalyticsd), added on a mistaken belief about CoreHaptics
rumble under the App Sandbox. We have since verified rumble works without it; this build carries
no temporary exception.
ACCOUNTS, PURCHASES, DATA
No account, no sign-in, no in-app purchase. The app collects no personal data: no analytics,
tracking, advertising or crash-reporting SDKs, and no connection to any server of ours during a
session. Device identity is a keychain keypair used only to authenticate to the user's own host.
Privacy policy: [[FILL IN: https://punktfunk.unom.io/legal/privacy]]
```
---
## Before you submit — checklist
- [ ] Fill every `[[FILL IN: …]]` placeholder. There are five.
- [ ] Confirm the demo host is reachable **from outside your own network** — test it on cellular,
not on the LAN it lives on. This is the failure mode that wastes a review cycle.
- [ ] Confirm the pairing PIN in the notes is the one the host will actually accept during the
review window, and that pairing is left open (it is on-demand in the web console).
- [ ] Put at least one launchable title in the demo host's library. An empty grid after a
successful pairing looks like a broken app.
- [ ] If submitting tvOS, verify the whole flow is reachable with the **Siri Remote alone**. A
reviewer will not have a controller paired, and "requires an accessory to navigate" is a
tvOS rejection.
- [ ] Attach the demo video as a URL in the notes if you are going the (b) route.
## Separately worth checking: the privacy manifest
There is **no `PrivacyInfo.xcprivacy`** anywhere in `clients/apple`. The app does use
`UserDefaults` (`HostStore` reads the `group.io.unom.punktfunk` suite), and `UserDefaults` is one of
Apple's "required reason" APIs, which are expected to be declared in a privacy manifest. Apps
missing a declaration typically get an automated **ITMS-91053** notice on upload.
This is adjacent to the copy work rather than part of it, so nothing has been changed here — but it
is worth adding a manifest declaring `NSPrivacyAccessedAPICategoryUserDefaults` with reason code
`CA92.1` (access to an app group container) and `NSPrivacyTracking` set to `false`, before the next
submission. Confirm the current reason codes against Apple's documentation rather than taking the
code above on trust; the list has changed since it was introduced.
+145
View File
@@ -0,0 +1,145 @@
# tvOS — App Store metadata
Client only, living-room framing. Things the other platforms have that the **Apple TV does not**,
and which the copy therefore avoids claiming:
- **No microphone uplink.** There is no usable audio input on tvOS, so the "your Mac becomes the
headset" line does not transfer.
- **No gamepad console shell.** `ShotScenes` builds the gamepad home/settings screens for iOS and
macOS only — tvOS uses the native focus engine instead.
- **No AV1.** Apple TV 4K has no AV1 hardware decoder; HEVC and H.264 only.
- Mouse/keyboard capture exists on tvOS but is not a living-room story, so it stays out.
Kept, and genuinely tvOS-shaped: Siri Remote pointer navigation (`SiriRemotePointer`), controllers
including the full DualSense feedback set, HDR passthrough, and Wake-on-LAN — which is the single
best Apple TV feature, because it is what removes the trip to the other room.
- **Name:** Punktfunk
- **Subtitle (DE):** Schnell, lokal & offen.
- **Subtitle (EN):** Fast, local & open.
---
## Promotional Text (DE) — max 170 characters
### Primary (161)
```
Anschalten, Host wählen, spielen: Punktfunk weckt deinen Gaming-PC per Wake-on-LAN und verbindet sich, sobald er wach ist. In 4K, mit HDR, mit deinem Controller.
```
### Alternate A — leads on the picture (157)
```
Dein Gaming-PC am großen Bildschirm in genau der Auflösung und Bildrate deines Fernsehers, mit HDR. Ohne Konto, ohne Cloud, nur über dein eigenes Netzwerk.
```
### Alternate B — leads on the DualSense (160)
```
Dein DualSense am Apple TV, vollständig: Rumble, adaptive Trigger, Lightbar, Touchpad und Gyro gehen bis ins Spiel durch. Dazu Profile pro Host und Wake-on-LAN.
```
## Promotional Text (EN) — max 170 characters
### Primary (160)
```
Turn on, pick a host, play: Punktfunk wakes your gaming PC over Wake-on-LAN and connects as soon as it's up. In 4K, with HDR, with the controller in your hands.
```
### Alternate A — leads on the picture (148)
```
Your gaming PC on the big screen — at your TV's exact resolution and refresh rate, with HDR. No account, no cloud, nothing leaving your own network.
```
---
## Description (DE) — max 4000 characters
```
Punktfunk macht aus deinem Apple TV die Konsole für den Gaming-PC, der ohnehin schon im Haus steht in 4K, mit HDR, über dein eigenes Netzwerk, ohne Konto und ohne Cloud.
Punktfunk besteht aus zwei Hälften: einem Host auf dem PC, von dem du streamst, und dieser App auf dem Gerät, auf dem du spielst. Der Host ist quelloffen und kostenlos, läuft auf Linux und auf Windows 11 auch headless auf einem Rechner, an dem gar kein Monitor hängt.
VOM SOFA AUS, VON ANFANG BIS ENDE
Anschalten, Host auswählen, spielen. Die App findet Hosts im Netzwerk von allein. Beim ersten Mal koppelst du einmalig mit einer PIN, danach verbindet sich der Apple TV über eine gepinnte Identität kein Konto, kein Login, kein Abtippen von IP-Adressen. Steht dein Gaming-PC im Standby, weckt ihn Punktfunk per Wake-on-LAN und verbindet sich, sobald er wach ist. Niemand muss dafür aufstehen.
DAS BILD, DAS DEIN FERNSEHER WIRKLICH KANN
Für den Apple TV legt der Host ein echtes virtuelles Display an in genau der Auflösung und Bildrate, die dein Fernseher meldet, bis 4K. Kein Skalieren, keine schwarzen Balken, und die Monitore am PC werden nicht umsortiert. Dekodiert wird in Hardware über VideoToolbox (HEVC und H.264), HDR wird als PQ durchgereicht, statt es flach zu rechnen.
CONTROLLER, VOLLSTÄNDIG
DualSense, Xbox- und weitere MFi-kompatible Controller. Beim DualSense gehen Rumble, Lightbar, Player-LEDs, adaptive Trigger, Touchpad und Gyro bis ins Spiel durch. Welchen Typ das virtuelle Gamepad am Host annimmt, richtet sich nach dem, was bei dir wirklich in der Hand liegt. Bedienen lässt sich alles mit der Siri Remote oder komplett mit dem Controller die Oberfläche ist für die Fernbedienung gebaut, nicht für eine Maus.
DEINE BIBLIOTHEK AUF DEM FERNSEHER
Installierte Steam-Titel und selbst hinzugefügte Spiele erscheinen als Raster mit Artwork und starten direkt vom Sofa aus. Mehrere Geräte können gleichzeitig streamen, jedes auf seinem eigenen Display der Apple TV im Wohnzimmer stört also niemanden, der am Schreibtisch weiterarbeitet.
SCHNELL, WEIL UNS DER GANZE WEG GEHÖRT
Die nativen Apps sprechen punktfunk/1: eine QUIC-Steuerebene und eine verschlüsselte Datenebene mit Vorwärtsfehlerkorrektur. Ein gestuftes Overlay zeigt Bildrate, Bitrate und Latenz über zwei Maschinen hinweg um den Uhrenversatz korrigiert, also eine Messung und kein Versprechen. Ein Geschwindigkeitstest pro Host schlägt eine passende Bitrate für dein Netzwerk vor.
WAS DU BRAUCHST
Einen Punktfunk-Host auf einem Linux-PC oder auf Windows 11 (22H2 oder neuer) im selben Netzwerk. Für die beste Erfahrung hängt der Apple TV am Kabel oder an einem guten 5-GHz-WLAN. Der Host ist quelloffen (MIT/Apache-2.0) und kostenlos Anleitungen und Quellcode findest du auf punktfunk.unom.io.
Kein Konto. Keine Cloud. Keine Telemetrie. Die App erfasst keine Daten über dich.
```
---
## Description (EN) — max 4000 characters
```
Punktfunk turns your Apple TV into a console for the gaming PC you already own — in 4K, with HDR, over your own network, with no account and no cloud.
Punktfunk comes in two halves: a host on the PC you stream from, and this app on the device you play on. The host is open source and free, and runs on Linux and on Windows 11 — including headless, on a machine with no monitor attached at all.
FROM THE COUCH, START TO FINISH
Turn on, pick a host, play. The app finds hosts on your network by itself. The first time, you pair once with a PIN; after that your Apple TV reconnects on a pinned identity — no account, no login, no typing IP addresses with a remote. If your gaming PC is asleep, Punktfunk wakes it over Wake-on-LAN and connects as soon as it is up. Nobody has to get up to make that happen.
THE PICTURE YOUR TV CAN ACTUALLY SHOW
For your Apple TV, the host creates a real virtual display at exactly the resolution and refresh rate your TV reports, up to 4K. No scaling, no black bars, and the monitors on your PC are left where they are. Decoding is done in hardware through VideoToolbox (HEVC and H.264), and HDR is passed through as PQ rather than flattened.
CONTROLLERS, IN FULL
DualSense, Xbox, and other MFi-compatible controllers. On a DualSense, rumble, lightbar, player LEDs, adaptive triggers, touchpad, and gyro all reach the game. The virtual gamepad the host presents takes its type from the controller actually in your hands. Everything is navigable with the Siri Remote or entirely with a controller — the interface is built for a remote, not for a mouse.
YOUR LIBRARY ON THE BIG SCREEN
Installed Steam titles and games you add yourself appear as a grid with artwork, ready to launch from the couch. Several devices can stream at once, each on its own display — so the Apple TV in the living room does not disturb anyone still working at the desk.
FAST, BECAUSE WE OWN THE WHOLE PATH
The native apps speak punktfunk/1: a QUIC control plane and an encrypted data plane with forward error correction. A tiered overlay shows frame rate, bitrate, and latency — corrected for clock skew across the two machines, so it is a measurement rather than a claim. A per-host speed test suggests a bitrate that matches your network.
WHAT YOU NEED
A Punktfunk host on a Linux PC or on Windows 11 (22H2 or later) on the same network. For the best experience, put your Apple TV on Ethernet or on good 5 GHz Wi-Fi. The host is open source (MIT/Apache-2.0) and free — guides and source at punktfunk.unom.io.
No account. No cloud. No telemetry. This app collects no data about you.
```
---
## Keywords — max 100 characters
### DE (93)
```
streaming,spiele,gaming,controller,gamepad,wohnzimmer,fernseher,pc,linux,windows,4k,hdr,couch
```
### EN (91)
```
streaming,gaming,controller,gamepad,livingroom,tv,pc,linux,windows,4k,hdr,couch,remote,play
```
Same exclusions as macOS: no `Moonlight`, `GameStream`, `NVIDIA`, or `Steam` in the keyword field.
+399 -12
View File
@@ -41,16 +41,23 @@ mod cli {
const PROBE_TIMEOUT: Duration = Duration::from_millis(2500);
/// The handshake budget `--request-access` runs on. Matches the host's `PENDING_APPROVAL_WAIT`
/// — the connect is PARKED for that long while an operator decides, so anything shorter would
/// give up while the approval prompt is still on their screen.
const REQUEST_ACCESS_TIMEOUT_SECS: u64 = 185;
const USAGE: &str = "\
punktfunk the Punktfunk client, headless
punktfunk discover [--json] [--timeout SECS]
punktfunk pair <host[:port]> [--pin N] [--name LABEL]
punktfunk hosts list [--probe] [--json]
punktfunk hosts add <host[:port]> [--name LABEL] [--fp HEX]
punktfunk hosts forget <host-ref>
punktfunk wake <host-ref> [--wait]
punktfunk library <host-ref> [--json]
punktfunk launch <host-ref> [--game ID] [--profile REF] [--exec] [--fullscreen]
punktfunk launch <host-ref> [--game ID] [--profile REF] [--request-access]
[--exec] [--fullscreen]
punktfunk open <punktfunk://…>
punktfunk reachable <host-ref>
punktfunk speed-test <host-ref>
@@ -68,6 +75,24 @@ punktfunk:// link takes. Exit codes: 0 ok, 2 connect, 3 trust, 4 renderer, 5 not
/// (what goes to stdout vs stderr, and which exit codes mean what).
fn verb_help(verb: &str) -> Option<&'static str> {
Some(match verb {
"discover" => {
"\
punktfunk discover [--json] [--timeout SECS] browse the LAN for hosts
Listens for Punktfunk hosts advertising over mDNS and prints what answered:
name TAB addr:port TAB saved|new TAB paired|unpaired. `saved` means this
device already has a record for it, matched by fingerprint first and address
second the same rule every other surface joins the two lists by.
--timeout SECS how long to browse (default 3, capped at 30) a bounded
call, so a panel can wait for it
--json {\"hosts\":[{\"name\",\"addr\",\"port\",\"fp\",\"pair\",\"id\",\"mgmt\",
\"os\",\"saved\",\"paired\"}]}
Nothing answering is an answer, not a failure: an empty list exits 0. A host
mDNS never sees (Tailscale, another subnet) will not appear here save it by
address with `punktfunk hosts add` and it shows in `hosts list --probe`."
}
"pair" => {
"\
punktfunk pair <host[:port]> enrol this device with a host (PIN ceremony)
@@ -96,6 +121,13 @@ punktfunk hosts — the saved-hosts store (shared with the desktop client)
another subnet). Without --fp it is a placeholder to pair later; with a
64-hex fingerprint it is pinned immediately (still unpaired).
Idempotent, and keyed on the FINGERPRINT once there is one: re-running it
for a host already saved is a no-op, and giving a known fingerprint a new
address MOVES that host's record there rather than filing a second one
(which is how a host that changed DHCP lease stays reachable by its id).
A different fingerprint for an address already saved is refused, exit 3
a changed identity is a decision for a person.
punktfunk hosts forget <host-ref>
Remove a saved host, its pinned fingerprint included. A later connect
must pair or trust it again."
@@ -119,7 +151,8 @@ this. Needs a paired host (exit 6 otherwise)."
}
"launch" => {
"\
punktfunk launch <host-ref> [--game ID] [--profile REF] [--exec] [--fullscreen]
punktfunk launch <host-ref> [--game ID] [--profile REF] [--request-access]
[--exec] [--fullscreen]
Start a stream waking the host first if it is asleep and its MAC is known.
The stream runs in the punktfunk-session renderer; this command supervises it
@@ -132,6 +165,16 @@ and relays its lifecycle to stderr.
--exec become the session process instead of supervising it the
gamescope-wrapper mode, where the launched process must BE
the streaming one for focus and lifecycle to work
--request-access
ask the host's operator to let this device in instead of
typing a PIN. The host PARKS the connect until somebody
approves it in its console or web UI (up to ~185 s), then
admits it and the stream starts by itself; the host is
recorded as paired once that happens, so later streams are
silent. Needs the host's fingerprint pinned already
(`punktfunk hosts add <addr> --fp <hex>`), and cannot be
combined with --exec under --exec there is no process
left to record the approval.
Exit 0 when the stream ends cleanly, 2 connect failed, 3 the host no longer
trusts this device (re-pair), 4 the renderer could not start."
@@ -222,7 +265,7 @@ from the config directory for a true factory reset."
fn flag_takes_value(flag: &str) -> bool {
matches!(
flag,
"--pin" | "--name" | "--fp" | "--game" | "--profile" | "--port"
"--pin" | "--name" | "--fp" | "--game" | "--profile" | "--port" | "--timeout"
)
}
@@ -269,6 +312,7 @@ from the config directory for a true factory reset."
return OK;
}
match verb.as_str() {
"discover" => discover(&rest),
"pair" => pair(&rest),
"hosts" => hosts(&rest),
"wake" => wake(&rest),
@@ -306,6 +350,104 @@ from the config directory for a true factory reset."
}
}
/// How long `discover` browses when nobody says, and the ceiling on what they can ask for.
/// The cap is not politeness: this verb is called from a Quick Access panel, and a typo'd
/// `--timeout 3000` would hang that panel with no way to cancel it.
const DISCOVER_DEFAULT_SECS: f64 = 3.0;
const DISCOVER_MAX_SECS: f64 = 30.0;
/// `discover [--json] [--timeout SECS]` — browse the LAN over mDNS and print what answered,
/// annotated against the saved-hosts store.
///
/// The annotation is the point: a caller wants "can I stream this", which is a question
/// about BOTH lists, and joining them itself is how two surfaces end up disagreeing about
/// the same host. So the match rule lives here, once, and is the same one every other
/// surface uses — fingerprint first (survives a DHCP move), address second.
fn discover(args: &[String]) -> u8 {
let secs = value(args, "--timeout")
.and_then(|v| v.parse::<f64>().ok())
.filter(|s| *s > 0.0)
.unwrap_or(DISCOVER_DEFAULT_SECS)
.min(DISCOVER_MAX_SECS);
let found = pf_client_core::discovery::discover_for(Duration::from_secs_f64(secs));
// `read`, not `load`: this verb only LOOKS at the records to annotate what it found, and
// never hands their ids back. `load` would mint ids for a pre-mint store and save them —
// a write from a read-only verb, and one that races the `hosts list` a caller is very
// likely running at the same moment (the Decky panel issues both together).
let known = KnownHosts::read();
let rows: Vec<(
&pf_client_core::discovery::DiscoveredHost,
Option<&KnownHost>,
)> = found.iter().map(|d| (d, match_saved(&known, d))).collect();
if has(args, "--json") {
let hosts: Vec<serde_json::Value> = rows
.iter()
.map(|(d, saved)| {
serde_json::json!({
"name": d.name,
"addr": d.addr,
"port": d.port,
"fp": d.fp_hex,
"pair": d.pair,
"id": d.advertised_id(),
// 0 = not advertised, which is what a consumer's own "no mgmt port"
// already means — an older host simply omits the TXT.
"mgmt": d.mgmt_port.unwrap_or(0),
"os": d.os,
"saved": saved.is_some(),
"paired": saved.is_some_and(|h| h.paired),
})
})
.collect();
println!("{}", serde_json::json!({ "hosts": hosts }));
} else {
for (d, saved) in &rows {
println!(
"{}\t{}:{}\t{}\t{}",
d.name,
d.addr,
d.port,
if saved.is_some() { "saved" } else { "new" },
if saved.is_some_and(|h| h.paired) {
"paired"
} else {
"unpaired"
},
);
}
}
// An empty LAN is an answer, not a failure — a caller branching on the exit code is
// asking "did the browse run", and it did.
OK
}
/// The saved record an advert belongs to, if any: fingerprint first, address second.
///
/// Fingerprint FIRST is deliberate and load-bearing — a host that moved to a new DHCP lease
/// still matches its record, and a *different* host that inherited the old address does not
/// inherit its pairing. This is the rule the plugin's `mergeHosts` and the shells' hosts
/// pages already use; keeping one copy is what stops two surfaces disagreeing about whether
/// the box in front of you is paired.
fn match_saved<'a>(
known: &'a KnownHosts,
advert: &pf_client_core::discovery::DiscoveredHost,
) -> Option<&'a KnownHost> {
known
.hosts
.iter()
.find(|h| {
!h.fp_hex.is_empty()
&& !advert.fp_hex.is_empty()
&& h.fp_hex.eq_ignore_ascii_case(&advert.fp_hex)
})
.or_else(|| {
known
.hosts
.iter()
.find(|h| h.addr == advert.addr && h.port == advert.port)
})
}
/// `pair <host[:port]> [--pin N]` — the SPAKE2 ceremony. Without `--pin` it prompts, which
/// is the interactive shape; with one it is scriptable. Refuses rather than prompting when
/// stdin isn't a terminal and no PIN was given: a pairing that silently blocks a CI job
@@ -424,16 +566,69 @@ from the config directory for a true factory reset."
return UNRESOLVED;
};
let (addr, port) = split_host_port(&target);
let fp = value(args, "--fp").unwrap_or_default();
let name = value(args, "--name");
let mut known = KnownHosts::load();
if known.hosts.iter().any(|h| h.addr == addr && h.port == port) {
eprintln!("{addr}:{port} is already saved");
return OK;
if let Some(i) = known
.hosts
.iter()
.position(|h| h.addr == addr && h.port == port)
{
return match merge_saved_host(&mut known, i, &fp, name.as_deref()) {
AddOutcome::Unchanged => {
eprintln!("{addr}:{port} is already saved");
OK
}
AddOutcome::Conflict => {
eprintln!(
"{addr}:{port} is already saved with a different fingerprint — \
forget it first if you really mean to replace it \
(punktfunk hosts forget {addr}:{port})"
);
TRUST_REJECTED
}
AddOutcome::Pinned => match known.save() {
Ok(()) => {
println!("updated {addr}:{port}");
OK
}
Err(e) => {
eprintln!("saving: {e:#}");
CONNECT_FAILED
}
},
};
}
// No record at this address — but a record carrying this exact FINGERPRINT is
// this same host at a new one. Re-point it rather than filing a second record:
// the fingerprint is the identity, and a host that changed DHCP lease is the
// whole reason `hosts add --fp` is idempotent in the first place. Without this a
// moved host accumulates one record per address it has ever held, and the one a
// stable id resolves to keeps the address it can no longer be reached at.
if let Some(i) = known
.hosts
.iter()
.position(|h| !fp.is_empty() && h.fp_hex.eq_ignore_ascii_case(&fp))
{
let was = format!("{}:{}", known.hosts[i].addr, known.hosts[i].port);
known.hosts[i].addr = addr.clone();
known.hosts[i].port = port;
return match known.save() {
Ok(()) => {
println!("moved {was} to {addr}:{port}");
OK
}
Err(e) => {
eprintln!("saving: {e:#}");
CONNECT_FAILED
}
};
}
known.hosts.push(KnownHost {
name: value(args, "--name").unwrap_or_else(|| addr.clone()),
name: name.unwrap_or_else(|| addr.clone()),
addr: addr.clone(),
port,
fp_hex: value(args, "--fp").unwrap_or_default(),
fp_hex: fp,
..Default::default()
});
match known.save() {
@@ -475,6 +670,55 @@ from the config directory for a true factory reset."
}
}
/// What `hosts add` did to a record that was ALREADY saved for this address.
#[derive(Debug, PartialEq, Eq)]
enum AddOutcome {
/// Nothing to do — no fingerprint was offered, or the record already carries this one.
/// Exits 0 on purpose: a panel retrying step 1 of request access must not have to
/// invent an error to show for a state that is already correct.
Unchanged,
/// The record had no fingerprint and now has this one.
Pinned,
/// The record carries a DIFFERENT fingerprint. Refused, never overwritten.
Conflict,
}
/// `hosts add --fp` against an address that is already saved. The difference between these
/// three is a trust decision, not bookkeeping.
///
/// Filling in an empty fingerprint is step 1 of request access (design §5): a host found by
/// advert is saved by address first and pinned second. Without it the `--fp` is dropped on
/// the floor and the launch that follows refuses for want of a pin — which is what this did
/// before, silently and with exit 0.
///
/// A *different* fingerprint is refused because a changed identity is a decision for a
/// person, at a surface that can show them both. That is what `upsert_trusted` exists to
/// enforce; quietly overwriting it here would be a back door through the pinning the rest
/// of the client is built on.
fn merge_saved_host(
known: &mut KnownHosts,
i: usize,
fp: &str,
name: Option<&str>,
) -> AddOutcome {
let existing = known.hosts[i].fp_hex.clone();
if fp.is_empty() || existing.eq_ignore_ascii_case(fp) {
return AddOutcome::Unchanged;
}
if !existing.is_empty() {
return AddOutcome::Conflict;
}
known.hosts[i].fp_hex = fp.to_string();
// Only a record still named after its own address is renamed: a label the user chose is
// theirs, and an advert's name must not quietly overwrite it.
if let Some(label) = name {
if known.hosts[i].name == known.hosts[i].addr {
known.hosts[i].name = label.to_string();
}
}
AddOutcome::Pinned
}
/// `wake <host-ref> [--wait]` — a magic packet, and with `--wait` the same bounded
/// wake-and-wait the shells run (`WakeWait`: a packet every 6 s, presence polled every
/// second, 90 s budget).
@@ -585,6 +829,19 @@ from the config directory for a true factory reset."
eprintln!("usage: punktfunk launch <host-ref> [--game ID] [--profile REF] [--exec]");
return UNRESOLVED;
};
let exec = has(args, "--exec");
let request_access = has(args, "--request-access");
// Refused rather than silently downgraded: under `--exec` this process BECOMES the
// session, so nothing survives to see `Ready` and record the approval. A launch that
// quietly dropped the persistence would leave hosts reading "trusted" forever with
// nobody able to say why.
if request_access && exec {
eprintln!(
"--request-access can't be combined with --exec: under --exec there is no \
process left to record the host's approval"
);
return UNRESOLVED;
}
let (known, i) = match resolve(&reference) {
Ok(v) => v,
Err(code) => return code,
@@ -597,7 +854,10 @@ from the config directory for a true factory reset."
if has(args, "--fullscreen") {
plan.settings.fullscreen_on_stream = true;
}
run_plan(plan, has(args, "--exec"))
if request_access {
plan.connect_timeout_secs = Some(REQUEST_ACCESS_TIMEOUT_SECS);
}
run_plan(plan, exec, request_access)
}
/// `open <url>` — the `punktfunk://` grammar, headless. Same parser, same refusal rules and
@@ -622,7 +882,7 @@ from the config directory for a true factory reset."
&trust::Settings::load(),
);
match outcome {
Ok(PlanOutcome::Connect(plan)) => run_plan(*plan, has(args, "--exec")),
Ok(PlanOutcome::Connect(plan)) => run_plan(*plan, has(args, "--exec"), false),
// A URL may never pair or trust on its own — that is a decision for a person, at a
// surface that can show them the fingerprint.
Ok(PlanOutcome::ConfirmUnknown(u)) => {
@@ -646,7 +906,13 @@ from the config directory for a true factory reset."
}
/// Wake if needed, then run the session — supervising it, or becoming it under `--exec`.
fn run_plan(plan: ConnectPlan, exec: bool) -> u8 {
///
/// `persist_paired` records the host as *paired* when the child reports ready. Only
/// `launch --request-access` passes true: there, the host parked the connect until an
/// operator approved this device, so `Ready` IS the approval arriving — the same thing
/// `SpawnOpts::persist_paired` means in the GTK shell. Every other launch records nothing,
/// which is correct: a plain connect proves reachability, not a new trust decision.
fn run_plan(plan: ConnectPlan, exec: bool, persist_paired: bool) -> u8 {
if plan.host.fp_hex.is_none() {
eprintln!(
"{} has no pinned fingerprint — punktfunk pair {}",
@@ -708,7 +974,24 @@ from the config directory for a true factory reset."
let mut failure: Option<(String, bool)> = None;
while let Ok(ev) = rx.recv() {
match ev {
SessionEvent::Ready => eprintln!("streaming"),
SessionEvent::Ready => {
eprintln!("streaming");
// The pin we connected WITH, not one re-derived from the store: the record
// is what we are about to rewrite, and the session proved the host holds
// exactly this identity by completing a pinned handshake against it.
if persist_paired {
if let Some(fp_hex) = &plan.host.fp_hex {
trust::persist_host(
&plan.host.name,
&plan.host.addr,
plan.host.port,
fp_hex,
true,
);
trust::forget_placeholder(&plan.host.addr, plan.host.port);
}
}
}
SessionEvent::Error {
msg,
trust_rejected,
@@ -967,6 +1250,7 @@ from the config directory for a true factory reset."
#[test]
fn every_usage_verb_has_help() {
for verb in [
"discover",
"pair",
"hosts",
"wake",
@@ -988,6 +1272,109 @@ from the config directory for a true factory reset."
assert!(verb_help("bogus").is_none());
}
fn saved(name: &str, addr: &str, fp: &str) -> KnownHost {
KnownHost {
name: name.into(),
addr: addr.into(),
port: 9777,
fp_hex: fp.into(),
..Default::default()
}
}
/// Step 1 of request access: a host saved by address gains the fingerprint its advert
/// carried. Before this, `hosts add --fp` on an existing record exited 0 having done
/// NOTHING — the launch that followed then refused for want of a pin, and the panel had
/// no way to tell why.
#[test]
fn adding_a_fingerprint_to_a_placeholder_fills_it_in() {
let mut known = KnownHosts {
hosts: vec![saved("192.168.1.9", "192.168.1.9", "")],
};
assert_eq!(
merge_saved_host(&mut known, 0, "abc123", Some("living-room")),
AddOutcome::Pinned
);
assert_eq!(known.hosts[0].fp_hex, "abc123");
assert_eq!(
known.hosts[0].name, "living-room",
"a record still named after its address takes the offered label"
);
}
/// A label the user chose is theirs — an advert's name must not overwrite it.
#[test]
fn filling_in_a_fingerprint_keeps_a_user_chosen_name() {
let mut known = KnownHosts {
hosts: vec![saved("Basement rig", "192.168.1.9", "")],
};
merge_saved_host(&mut known, 0, "abc123", Some("living-room"));
assert_eq!(known.hosts[0].name, "Basement rig");
}
/// Idempotent: the panel may retry step 1, and re-offering the fingerprint a record
/// already carries is a state that is already correct, not an error to render.
#[test]
fn re_adding_the_same_fingerprint_changes_nothing() {
let mut known = KnownHosts {
hosts: vec![saved("desk", "192.168.1.9", "ABC123")],
};
assert_eq!(
merge_saved_host(&mut known, 0, "abc123", None),
AddOutcome::Unchanged,
"fingerprints compare case-insensitively"
);
// And a bare `hosts add` with no --fp at all leaves the pin alone.
assert_eq!(
merge_saved_host(&mut known, 0, "", None),
AddOutcome::Unchanged
);
assert_eq!(known.hosts[0].fp_hex, "ABC123");
}
/// A changed identity is a decision for a person. Never a silent overwrite — this is the
/// same rule `upsert_trusted` enforces, and a back door here would defeat it everywhere.
#[test]
fn a_different_fingerprint_is_refused_not_overwritten() {
let mut known = KnownHosts {
hosts: vec![saved("desk", "192.168.1.9", "abc123")],
};
assert_eq!(
merge_saved_host(&mut known, 0, "deadbeef", None),
AddOutcome::Conflict
);
assert_eq!(
known.hosts[0].fp_hex, "abc123",
"the pin must survive intact"
);
}
/// A host that changed DHCP lease is re-pointed, not filed a second time. Without this
/// the record a stable id resolves to keeps an address the host has left, so a launch
/// dials into the void while the panel shows the live one.
#[test]
fn a_known_fingerprint_at_a_new_address_moves_the_record() {
let mut known = KnownHosts {
hosts: vec![saved("desk", "192.168.1.9", "abc123")],
};
// Simulates `hosts add 192.168.1.50 --fp abc123` finding no record at that address.
let by_addr = known
.hosts
.iter()
.position(|h| h.addr == "192.168.1.50" && h.port == 9777);
assert!(
by_addr.is_none(),
"the new address is not yet on any record"
);
let by_fp = known
.hosts
.iter()
.position(|h| h.fp_hex.eq_ignore_ascii_case("abc123"));
assert_eq!(by_fp, Some(0), "the fingerprint still identifies the host");
known.hosts[0].addr = "192.168.1.50".into();
assert_eq!(known.hosts.len(), 1, "one host, one record");
}
#[test]
fn value_reads_the_argument_after_its_flag() {
let a = argv(&["--game", "steam:570", "--exec"]);
+18
View File
@@ -67,3 +67,21 @@ fn unknown_verbs_refuse_with_the_not_found_code() {
let out = punktfunk(&["help", "frobnicate"]);
assert_eq!(out.status.code(), Some(5), "unknown help topic exits 5");
}
/// `discover` and `launch --request-access` document themselves. Help only — the verbs
/// themselves browse the LAN and dial a host, which no runner may be asked to do.
///
/// The Decky panel detects a too-old client by exactly the signature the test above pins
/// (exit 5 + `unknown command`), so this is the other half of that contract: on a client new
/// enough, `discover` is a verb with help rather than an unknown word.
#[test]
fn the_request_access_surfaces_document_themselves() {
let out = punktfunk(&["help", "discover"]);
assert!(out.status.success(), "discover has its own help topic");
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(stdout.contains("--timeout"), "discover documents --timeout");
assert!(stdout.contains("--json"), "discover documents --json");
let out = punktfunk(&["launch", "--help"]);
assert!(String::from_utf8_lossy(&out.stdout).contains("--request-access"));
}
+86 -56
View File
@@ -2,49 +2,61 @@
Stream to your **Steam Deck** without ever leaving Gaming Mode. This
**[Decky Loader](https://decky.xyz/)** plugin adds a **Punktfunk** panel to the Quick Access Menu
(the `…` button): discover hosts on your network, pair with a PIN, tweak stream settings, and launch
a fullscreen, gamescope-focused stream — all from the couch, gamepad-navigable.
(the `…` button): the hosts you can stream, the pinned cards you set up, and one tap into each.
The video itself is the native GTK4 Linux client (the `io.unom.Punktfunk` flatpak); the plugin
discovers, pairs, configures, and *launches it the right way* so gamescope fullscreens it — the same
Steam-shortcut trick MoonDeck uses. Because it's built from real Steam UI primitives (`@decky/ui`),
the panel looks and feels native to Gaming Mode.
The plugin is a **launcher**, not a client. It doesn't decode video, browse your library, or hold
any settings of its own — the Rust client does all of that, and the plugin's job is to start it
*the right way* so gamescope fullscreens and focuses it (the same Steam-shortcut trick MoonDeck
uses). Everything the panel doesn't do is one tap away in the client's own gamepad UI.
## What it does
1. **Discover** — browses the LAN over mDNS for Punktfunk hosts, in both the QAM panel and a
fullscreen page; each host row opens a details view (address, pairing policy, certificate
fingerprint to cross-check against the host's log).
2. **Pair**for a host that requires it, a gamepad-navigable PIN keypad runs the SPAKE2 pairing
ceremony headlessly, then remembers the host so future streams connect silently.
3. **Stream** — launches fullscreen via a branded "Punktfunk" Steam shortcut so gamescope focuses it.
4. **Games** — each host row has a games button that opens its **library picker**: pin titles as
one-tap "Stream <Game>" rows in the QAM (jump straight into e.g. Playnite on the host), or
**"Open library on screen"** to launch the client's controller-driven, console-style library
browser (aurora backdrop + poster coverflow; A plays, B returns to Gaming Mode). Pins survive
plugin reinstalls (stored next to the client's config) and follow a host across IP changes
(matched by certificate fingerprint).
5. **Settings** — the client's whole settings store, written to its config. Laid out like SteamOS's
own Settings: a left rail of categories (`SidebarNavigation`), one page each, so no page needs
scrolling. The categories and their order are the console settings screen's — Stream (resolution
/ refresh / render scale / bitrate / compositor), Video (codec / decoder / GPU / HDR / 4:4:4),
Presentation (prioritize / smoothness buffer / V-Sync / VRR), Audio (channels / output + mic
device / echo cancellation), Controllers, Touch & mouse, Interface (stats overlay / auto-wake /
library / fullscreen). The device pickers are populated
from the session binary (`--list-adapters` / `--list-audio`); the GPU row appears only where
there is more than one adapter.
6. **About** — plugin version, an explicit "Check for updates" button, the setup-guide link, and
a force-stop for a wedged stream client.
1. **Hosts** — the hosts on your network plus the ones you've saved, in one list. Discovery is
mDNS; saved hosts are also probed directly, so a box reached over Tailscale or a VPN shows as
online even though it never advertises. Rows sort online-first, then most recently used.
2. **Trust**an unpaired host opens a small sheet with two ways in:
- **Request access** (the default) — no PIN. The host's operator approves this Deck in its
console or web UI and the stream starts by itself. See [Request access](#request-access).
- **Use a PIN instead** — the gamepad-navigable keypad, running the same SPAKE2 ceremony.
3. **Stream** — launches fullscreen via a branded "Punktfunk" Steam shortcut so gamescope focuses
it. A sleeping host is woken first (the client runs the real wake-and-wait loop, then dials).
4. **Pinned cards** — a *(host, profile)* pair renders nested under its host as `▸ <Profile name>`
and streams with that settings profile applied. Cards are the **shared** pinning model every
other client speaks, stored on the host's record — so one you make in the desktop client shows
up here, and vice versa. The plugin renders them; it doesn't create or edit them.
5. **Open Punktfunk** — launches the client's **console home**: the host picker, add-host by
address, PIN pairing, the game library browser, and the **full settings screen**. This is where
everything the panel no longer does now lives.
6. **About** — plugin version, "Check for updates", "Recreate library shortcut", and a force-stop
for a wedged stream.
To leave a stream: the in-client controller chord (**L1 + R1 + Start + Select**), or close the
"game" from the Steam overlay — either returns you to Gaming Mode.
### Request access
Request access is not a second pairing ceremony — it is a **launch**. The plugin saves the host
with the fingerprint it **advertised**, then starts an ordinary identified connect with the
handshake budget stretched to 185 s. The host *parks* that connection until its operator approves
the device, then admits the same connection; the stream starts on its own, and the record flips
to **paired** so every later stream is silent.
**No advertised fingerprint, no request access.** That pinned fingerprint is the only thing
standing between a 185-second wait and an impostor answering for the host, so a host you typed in
by address gets the PIN path only — and the sheet says why. The plugin never trusts-on-first-use
past a missing fingerprint.
## Install on the Deck
You need **[Decky Loader](https://decky.xyz/)** and the **`io.unom.Punktfunk` flatpak**
([`packaging/flatpak`](../../packaging/flatpak/README.md)) installed on the Deck — SteamOS `/usr` is
read-only, so the flatpak (which bundles libadwaita/SDL3) is the canonical client. Discovery uses
`avahi-browse`, which ships on SteamOS/Bazzite.
You need **[Decky Loader](https://decky.xyz/)** and a **Punktfunk client** on the Deck. On a normal
Deck that's the `io.unom.Punktfunk` flatpak ([`packaging/flatpak`](../../packaging/flatpak/README.md))
SteamOS `/usr` is read-only, so the flatpak (which bundles libadwaita/SDL3) is the canonical client.
A native install (sysext, distro package, nix profile, your own build) works too.
**The client must be v0.22.0 or newer** — that is when the headless `punktfunk` CLI shipped, and
the panel drives everything through it. An older client says so in the panel, with the update
button that fixes it right there. (Discovery no longer needs `avahi-browse` on the Deck; the
client's own mDNS does it.)
**Recommended — install from URL** (published by CI): in Decky → Settings → **Developer Mode**
**Install Plugin from URL**, paste:
@@ -55,17 +67,15 @@ https://unom.io/pf-decky
(short link for `https://git.unom.io/api/packages/unom/generic/punktfunk-decky/latest/punktfunk.zip`;
for a pinned version use `https://git.unom.io/api/packages/unom/generic/punktfunk-decky/<version>/punktfunk.zip`
directly). The plugin then **self-updates** without
the Decky store — when a newer build exists, an **Update** button appears and drives Decky
Loader's own (SHA-256-verified) install. Installs and updates can take a couple of minutes on some
networks: Decky's installer also contacts its plugin store first, which may be slow or blackholed
before the actual download proceeds.
directly). The plugin then **self-updates** without the Decky store — when a newer build exists, an
**Update** button appears and drives Decky Loader's own (SHA-256-verified) install. Installs and
updates can take a couple of minutes on some networks: Decky's installer also contacts its plugin
store first, which may be slow or blackholed before the actual download proceeds.
### Updating the client
The plugin also reports — and where it can, installs — updates for the **client** it launches.
What is possible depends on how that client was installed, and the About tab names the install
kind so the answer is never a mystery:
What is possible depends on how that client was installed:
| Install | Update |
| --- | --- |
@@ -88,6 +98,8 @@ pnpm install
pnpm build # rollup → dist/index.js
pnpm run package # → out/punktfunk/ + out/punktfunk-v<ver>.zip
DECK=deck@<deck-ip> pnpm run deploy # rsync → /tmp, sudo-install into the root-owned plugins dir, restart loader
python3.13 scripts/test-backend.py # backend unit checks (needs Python ≥3.10)
```
`~/homebrew/plugins/` is root-owned (the loader runs as root), so `deploy.sh` stages to a temp dir
@@ -96,28 +108,46 @@ restart is required for an out-of-band install to appear.
## Architecture
Everything below the panel is the CLI. `main.py` builds argv and maps exit codes; it parses none of
the client's data files and re-implements none of its rules.
| File | Role |
| --- | --- |
| `src/index.tsx` | Plugin entry: the QAM panel + route registration. |
| `src/page.tsx` | The `/punktfunk` fullscreen page — Hosts (with per-host details) / Settings / About tabs. |
| `src/settings.tsx` · `src/pair.tsx` | The settings screen (a `SidebarNavigation` of seven category pages over one shared settings object); the gamepad-navigable PIN-pairing modal. |
| `src/library.tsx` | The per-host game picker (pin/unpin, "Open library on screen") + the pinned-game launch helper. |
| `src/hostmgmt.tsx` | Add / edit host dialogs — mutate the shared known-hosts store (`client-known-hosts.json`) via the flatpak client's headless modes, so a host saved here shows up in the desktop client too. |
| `src/ui.tsx` | Shared UI primitives for the fullscreen page + modals (right-aligned row actions, consistent Field layout). |
| `src/hooks.ts` · `src/boundary.tsx` | Shared discovery/update/pins hooks + actions; the render error boundary. |
| `src/steam.ts` | Steam-shortcut launch (`AddShortcut` / `SetAppLaunchOptions` / `RunGame`) — the focus-correct stream start. The shortcut's exe is `/bin/sh` with the wrapper passed as an argument, so the script never needs an exec bit (Decky's zip extraction drops it and the root-owned plugins dir can't be chmodded by the unprivileged backend). Launch extras ride env-prefix tokens: `PF_LAUNCH=<id>` (pinned game) / `PF_BROWSE=1` + `PF_MGMT=<port>` (on-screen library); ids are validated space/quote-free at pin AND launch time. |
| `src/backend.ts` | Typed `callable` bridges to `main.py`. |
| `bin/punktfunkrun.sh` | The launch wrapper the Steam shortcut runs (so the window is focusable); maps `PF_LAUNCH`/`PF_BROWSE`/`PF_MGMT` to `--launch`/`--browse`/`--mgmt`. An older flatpak ignores the flags harmlessly (plain stream / hosts page). |
| `main.py` | Backend: `discover` (via `avahi-browse`) / `pair` / `library` (headless flatpak `--library`, TSV) / pins store (`decky-pinned.json`) / settings / `kill_stream` / `check_update` (with an explicit CA-bundle search — Decky's embedded Python has no usable default TLS roots on SteamOS). |
| `scripts/test-backend.py` | Stdlib-only checks for the backend's pure parsers (TSV, error classes, avahi TXT) + the pins round trip. |
| `src/index.tsx` | Plugin entry + the QAM panel: update banner, hosts (with nested pinned cards), the console-home door, about. |
| `src/hooks.ts` | `useHosts` (one call merging discovery and the saved store), the update hooks, and the launch action. Also the trust-state model the rows render. |
| `src/trust.tsx` · `src/pair.tsx` | The trust sheet (Request access / Use a PIN instead / Cancel) and the gamepad-navigable PIN keypad. |
| `src/steam.ts` | Steam-shortcut launch (`AddShortcut` / `SetAppLaunchOptions` / `RunGame`) — the focus-correct stream start. The shortcut's exe is `/bin/sh` with the wrapper passed as an argument, so the script never needs an exec bit (Decky's zip extraction drops it and the root-owned plugins dir can't be chmodded by the unprivileged backend). |
| `src/backend.ts` · `src/boundary.tsx` · `src/os-icon.tsx` | Typed `callable` bridges to `main.py`; the render error boundary; the host row's OS mark. |
| `bin/punktfunkrun.sh` | The launch wrapper the Steam shortcut runs (so the window is focusable). Reads `PF_REF` / `PF_PROFILE` / `PF_REQUEST_ACCESS` / `PF_BROWSE` and runs `punktfunk launch` — or the session's `--browse` for console home. |
| `main.py` | Backend: four thin CLI shells (`discover` / `hosts` / `pair` / `trust_host`) plus the Steam-side work only a plugin can do — `runner_info`, `shortcut_art`, `apply_controller_config`, `kill_stream`, `check_update` / `update_client` (with an explicit CA-bundle search — Decky's embedded Python has no usable default TLS roots on SteamOS). |
| `scripts/test-backend.py` | Stdlib-only checks: argv shape, the CLI exit-code mapping, and the Steam configset editor. |
| `plugin.json` · `update.json` | Decky manifest; CI-baked update channel. |
### Why the launch goes through Steam
gamescope only gives focus and fullscreen to the window tree Steam launched via `reaper` (it
detects the "current app" by AppID — gamescope#484). A client spawned from the plugin's own
backend comes up invisible and unfocused. So the plugin registers non-Steam shortcuts whose exe is
`/bin/sh` running `bin/punktfunkrun.sh`, and starts them with `RunGame`.
There are **two** shortcuts, both named `Punktfunk` so Steam keys them to one Steam Input
configset (the key is the lowercase name): a hidden, stateful one that carries the stream, and the
visible, stateless library entry that opens console home.
## Limitations / next steps
- No manual "add host by IP" entry yet (discovery is mDNS-only).
- No in-stream overlay inside the plugin — the client owns the session once launched.
- Pairing needs the operator to **arm pairing on the host** so it shows the PIN; the plugin can't arm
it remotely.
- **Profiles and pinned cards can't be created here** — the panel renders them; making one needs
the desktop client, or the client's own gamepad UI once that work lands. A Deck with no profiles
simply sees host rows, and nothing is broken.
- **Per-game pins are on hold.** The shared model pins *host+profile*; nothing in the shared store
persists a pinned *game* yet. The old `decky-pinned.json` is left on disk untouched so a later
migration can read it.
- Pairing with a PIN needs the operator to **arm pairing on the host** so it shows the PIN; the
plugin can't arm it remotely. Request access needs no arming — just an approval.
- **A parked connect looks like a hanging one.** The plugin toasts before launching a request-access
stream to set expectations, which is a patch rather than a fix; teaching the session's connect
screen the same "waiting for approval" copy the console shell already has would pay off for every
shell.
## Related
+56 -53
View File
@@ -1,33 +1,32 @@
#!/usr/bin/env bash
# punktfunk stream runner — the target of the hidden non-Steam shortcut the plugin creates.
# punktfunk stream runner — the target of the non-Steam shortcuts the plugin creates.
#
# WHY A WRAPPER SCRIPT (load-bearing, from MoonDeck's hard-won knowledge): the stream client
# must be a descendant of the process Steam launches via `reaper`, or gamescope never gives
# its window focus/fullscreen in Gaming Mode (gamescope detects the "current app" by AppID,
# which only attaches to reaper's descendants — see gamescope#484). So the Decky plugin
# launches THIS script through SteamClient.Apps.RunGame; the script then execs the flatpak
# client, which inherits the shortcut's AppID and is focused. Launching the flatpak directly
# from the (root) Decky backend produces an unfocused, invisible window.
# launches THIS script through SteamClient.Apps.RunGame; the script then runs the client,
# which inherits the shortcut's AppID and is focused. Launching the client directly from the
# (root) Decky backend produces an unfocused, invisible window.
#
# Per-session parameters arrive as environment variables, set as the shortcut's Steam launch
# options by the plugin (SteamClient.Apps.SetAppLaunchOptions), so ONE generic shortcut serves
# every host (and every pinned game):
# PF_HOST host[:port] to connect to (required for streaming; optional for browse)
# PF_LAUNCH library id to launch on connect (optional, e.g. steam:570 — pinned games)
# PF_BROWSE non-empty = open the gamepad library (optional; --browse instead of --connect)
# PF_MGMT management-API port for --browse (optional; client defaults to 47990)
# PF_CONNECT_TIMEOUT connect budget in seconds (optional; the plugin stretches it after
# firing Wake-on-LAN so the connect survives the host's resume)
# PF_APPID flatpak app id (default io.unom.Punktfunk)
# PF_FLATPAK override the flatpak binary path (default: `flatpak` on PATH)
# every host:
# PF_REF host reference — a saved host's stable id, or addr[:port] (required to stream)
# PF_PROFILE settings-profile id for a pinned card (optional)
# PF_REQUEST_ACCESS non-empty = ask the host's operator to admit this device instead of
# pairing with a PIN. The connect PARKS until somebody approves it.
# PF_BROWSE non-empty = open the client's console home instead of streaming
# PF_APPID flatpak app id (default io.unom.Punktfunk)
# PF_FLATPAK override the flatpak binary path (default: `flatpak` on PATH)
# PF_CLIENT_BIN absolute path of a NATIVE client (optional; set by the plugin when it
# resolved a non-flatpak install — then the client is exec'd directly and
# resolved a non-flatpak install — then the client is run directly and
# PF_APPID/PF_FLATPAK are unused)
#
# Values are plain tokens (the plugin validates launch ids to space/quote-free ASCII before
# they ever reach Steam launch options). An older flatpak without --launch/--browse ignores
# the unknown flags harmlessly (hand-scanned argv): PF_LAUNCH degrades to the plain desktop
# session, PF_BROWSE to the client's hosts page.
# A REFERENCE, NEVER A VALUE. Host refs and profile ids are the only things that ride this
# channel; no resolution, bitrate or codec ever does. The client resolves both against its own
# stores, which is what keeps a Steam launch option from becoming a second settings surface.
# The plugin validates them to space/quote-free ASCII before they reach Steam's tokenizer.
#
# Runs as the `deck` user (Steam launched it), so the --user flatpak install is visible and
# WAYLAND_DISPLAY / XDG_RUNTIME_DIR are already correct for gamescope.
@@ -42,13 +41,22 @@ APPID="${PF_APPID:-io.unom.Punktfunk}"
FLATPAK="${PF_FLATPAK:-flatpak}"
# The client is not always the flatpak: a sysext, a .deb/.rpm, an AUR build or a nix profile
# installs a native `punktfunk-client`, and the plugin passes its absolute path here when that
# is what it resolved. Both kinds take the same argv and share ~/.config/punktfunk, so the only
# difference is the prefix in front of it.
# installs a native `punktfunk-client` with the CLI as its sibling, and the plugin passes the
# client's absolute path here when that is what it resolved.
#
# exec so the client IS the game process — when it exits, Steam ends the "game" and Gaming Mode
# reclaims focus automatically (no manual refocus needed).
run_client() {
# run_cli execs the HEADLESS CLI (`punktfunk`); run_session execs the GTK/console shell
# (`punktfunk-client`). Both live in the same place in both install kinds — /app/bin inside the
# flatpak, reachable with `--command=`, and one bindir natively.
run_cli() {
if [ -n "${PF_CLIENT_BIN:-}" ]; then
# `${VAR%/*}` rather than `dirname`: pure parameter expansion, so this works with no
# PATH at all — which is the environment a Steam launch option can leave us in.
exec "${PF_CLIENT_BIN%/*}/punktfunk" "$@"
fi
exec "$FLATPAK" run --arch=x86_64 --command=punktfunk "$APPID" "$@"
}
run_session() {
if [ -n "${PF_CLIENT_BIN:-}" ]; then
exec "$PF_CLIENT_BIN" "$@"
fi
@@ -58,40 +66,35 @@ run_client() {
# What we are about to run, for the log line each branch prints.
CLIENT_LABEL="${PF_CLIENT_BIN:-$APPID}"
# --fullscreen: present the stream chrome-less and fullscreen (the client also auto-detects the
# Deck/gamescope env, and ignores the flag harmlessly on older builds that predate it).
# The console home: the client's own gamepad UI (host picker, pairing, add-host by address, the
# library browser and the full settings screen). UNCHANGED from before this rework — the shell
# binary already execs the session for `--browse`, so there is nothing to repoint here.
if [ -n "${PF_BROWSE:-}" ]; then
# The gamepad UI. BARE `--browse` (no PF_HOST) opens the console home — the self-contained
# host picker + pairing + settings, gamepad-navigable — which is what the stateless, visible
# library shortcut launches. `--browse <host>` opens straight into that host's library (the
# per-host "open on screen" action). A streams a game, session end returns here, B quits.
if [ -z "${PF_HOST:-}" ]; then
echo "punktfunkrun: gamepad UI $CLIENT_LABEL --browse (console home)" >&2
run_client --browse --fullscreen
fi
echo "punktfunkrun: library $CLIENT_LABEL --browse $PF_HOST" >&2
if [ -n "${PF_MGMT:-}" ]; then
run_client --browse "$PF_HOST" --mgmt "$PF_MGMT" --fullscreen
fi
run_client --browse "$PF_HOST" --fullscreen
echo "punktfunkrun: gamepad UI $CLIENT_LABEL --browse (console home)" >&2
run_session --browse --fullscreen
fi
# Streaming modes need a host (browse above is the only host-less path).
if [ -z "${PF_HOST:-}" ]; then
echo "punktfunkrun: PF_HOST is not set (the plugin sets it as a launch option)" >&2
if [ -z "${PF_REF:-}" ]; then
echo "punktfunkrun: PF_REF is not set (the plugin sets it as a launch option)" >&2
exit 2
fi
# Trailing args shared by both streaming execs. A stretched connect budget rides along when the
# plugin set one (it just fired Wake-on-LAN, so the host may still be resuming); an older flatpak
# without --connect-timeout ignores the flag harmlessly (hand-scanned argv).
set -- --fullscreen
if [ -n "${PF_CONNECT_TIMEOUT:-}" ]; then
set -- --connect-timeout "$PF_CONNECT_TIMEOUT" "$@"
if [ -n "${PF_PROFILE:-}" ]; then
set -- --profile "$PF_PROFILE" "$@"
fi
if [ -n "${PF_LAUNCH:-}" ]; then
# A pinned game: the id rides the session Hello and the host launches that title.
echo "punktfunkrun: streaming $CLIENT_LABEL --connect $PF_HOST --launch $PF_LAUNCH" >&2
run_client --connect "$PF_HOST" --launch "$PF_LAUNCH" "$@"
# REQUEST ACCESS RUNS SUPERVISED — no `--exec`. Under --exec the CLI BECOMES the session, so no
# process survives to see the stream come up and record the host as paired; the CLI refuses the
# combination outright rather than downgrading silently. This is safe for gamescope because
# focus follows reaper's DESCENDANT TREE, not a single process, and `flatpak run`/`bwrap`
# already sit between reaper and the client on every other path.
if [ -n "${PF_REQUEST_ACCESS:-}" ]; then
echo "punktfunkrun: request access $CLIENT_LABEL launch $PF_REF (waiting for approval)" >&2
run_cli launch "$PF_REF" --request-access "$@"
fi
echo "punktfunkrun: streaming $CLIENT_LABEL --connect $PF_HOST" >&2
run_client --connect "$PF_HOST" "$@"
# The ordinary stream. `--exec` is the documented gamescope-wrapper mode: the CLI becomes the
# session, so the process tree stays flat and Steam's "game" ends exactly when the stream does.
echo "punktfunkrun: streaming $CLIENT_LABEL launch $PF_REF" >&2
run_cli launch "$PF_REF" --exec "$@"
+265 -723
View File
File diff suppressed because it is too large Load Diff
+126 -127
View File
@@ -1,12 +1,12 @@
#!/usr/bin/env python3
"""Unit checks for main.py's pure helpers — stdlib only, no Decky runtime needed.
Stubs the ``decky`` module (main.py imports it at module level), then asserts the
avahi/TSV/error parsers against fixture strings. The LibraryError fixtures are pinned to
the REAL Display strings in clients/linux/src/library.rs if those are reworded, the
classifier degrades to ``client-error`` and the matching assertion here fails on purpose.
Stubs the ``decky`` module (main.py imports it at module level), then asserts the argv
shapes, the exit-code mapping and the Steam VDF editor against fixtures.
python3 clients/decky/scripts/test-backend.py
Needs Python >= 3.10 for `X | None` annotations macOS ships 3.9, so run it explicitly:
python3.13 clients/decky/scripts/test-backend.py
"""
import sys
@@ -40,136 +40,135 @@ def check(name: str, cond: bool):
failures += 1
# ---- _parse_library_tsv -----------------------------------------------------------------
tsv = (
"steam:570\tsteam\tDota 2\n"
"custom:abc\tcustom\tTabs\tin\ttitle\n" # tabs inside the title survive (split max 2)
"2 game(s)\n" # the count trailer has no tabs — self-skips
# ---- _cli_argv: the flatpak app id must stay LAST ---------------------------------------
#
# `flatpak run --command=X <app-id> ARGS` — everything after the app id is the APP's argv, so
# an app id that drifts left silently turns our flags into the client's. This is the shape the
# deleted _session_argv used and the one thing about it that is easy to get wrong.
main._client_argv = lambda: ["/usr/bin/flatpak", "run", "--arch=x86_64", "io.unom.Punktfunk"]
main._flatpak = lambda: "/usr/bin/flatpak"
check(
"cli argv: flatpak form, app id last",
main._cli_argv()
== [
"/usr/bin/flatpak",
"run",
"--arch=x86_64",
"--command=punktfunk",
"io.unom.Punktfunk",
],
)
games = main._parse_library_tsv(tsv)
check("tsv: two games parsed", len(games) == 2)
check("tsv: fields", games[0] == {"id": "steam:570", "store": "steam", "title": "Dota 2"})
check("tsv: tabs in title preserved", games[1]["title"] == "Tabs\tin\ttitle")
check("tsv: empty input", main._parse_library_tsv("0 game(s)\n") == [])
# ---- _classify_library_error (fixtures = library.rs Display strings) --------------------
check(
"err: not-paired",
main._classify_library_error(
"library: The host didn't recognize this device. Pair with the host first — the "
"library is authorized by this device's certificate (no token needed)."
)
== "not-paired",
)
check(
"err: pin-mismatch",
main._classify_library_error(
"library: The host's certificate doesn't match the pinned fingerprint. "
"Re-pair with a PIN to re-establish trust."
)
== "pin-mismatch",
)
check(
"err: unreachable",
main._classify_library_error(
"library: Couldn't reach the host's management API: connection refused. Check the "
"host is updated and reachable."
)
== "unreachable",
)
check(
"err: http",
main._classify_library_error("library: The management API returned HTTP 500.") == "http",
)
check(
"err: outdated client (GTK init noise)",
main._classify_library_error("cannot open display: \nGtk-WARNING: init failed")
== "client-outdated",
)
check("err: generic fallback", main._classify_library_error("boom") == "client-error")
# ---- _parse_avahi_browse (incl. the new id/mgmt TXT keys) --------------------------------
avahi = (
"+;eth0;IPv4;living-room;_punktfunk._udp;local\n"
"=;eth0;IPv4;living-room;_punktfunk._udp;local;lr.local;192.168.1.42;9777;"
'"proto=punktfunk/1" "fp=aabbcc" "pair=required" "id=abc123" "mgmt=47990"\n'
"=;eth0;IPv6;living-room;_punktfunk._udp;local;lr.local;fe80::1;9777;"
'"proto=punktfunk/1" "fp=aabbcc" "pair=required" "id=abc123" "mgmt=47990"\n'
"=;eth0;IPv4;bare-host;_punktfunk._udp;local;bh.local;192.168.1.77;9777;"
'"proto=punktfunk/1" "fp=ddeeff" "pair=optional"\n'
)
hosts = main._parse_avahi_browse(avahi)
check("avahi: two hosts (id-dedup, IPv4 preferred)", len(hosts) == 2)
lr = next(h for h in hosts if h["name"] == "living-room")
check("avahi: ipv4 wins", lr["host"] == "192.168.1.42")
check("avahi: mgmt parsed", lr["mgmt"] == 47990)
check("avahi: id parsed", lr["id"] == "abc123")
bare = next(h for h in hosts if h["name"] == "bare-host")
check("avahi: mgmt absent -> 0", bare["mgmt"] == 0)
check("avahi: id absent -> empty", bare["id"] == "")
# ---- pins store (round-trip through the real methods, isolated HOME) --------------------
import asyncio # noqa: E402
# A native install: the CLI is the client binary's sibling. Absent => no CLI at all, which the
# caller must see as "unavailable" rather than as an empty result.
#
# The fixture dir is torn down FIRST, not just created: leaving the sibling behind made the
# "absent" assertion below pass only on the first run of the day and fail on every rerun.
import shutil # noqa: E402
shutil.rmtree(decky.DECKY_USER_HOME, ignore_errors=True)
plugin = main.Plugin()
pin = {
"game_id": "steam:570",
"title": "Dota 2",
"store": "steam",
"host_fp": "AABBCC",
"host_id": "abc123",
"host_name": "living-room",
"host": "192.168.1.42",
"port": 9777,
"mgmt": 47990,
"added_at": 1780000000,
}
dupe = dict(pin, title="Dota 2 again")
junk = {"title": "no game id"}
res = asyncio.run(plugin.set_pins([pin, dupe, junk]))
check("pins: write ok", res.get("ok") is True)
got = asyncio.run(plugin.get_pins())["pins"]
check("pins: dedup + junk dropped", len(got) == 1)
check("pins: unpaired without known-hosts", got[0]["paired"] is False)
# Mark the host paired in the client's known-hosts store — get_pins must pick it up.
cfg = main._client_config_dir()
cfg.mkdir(parents=True, exist_ok=True)
(cfg / "client-known-hosts.json").write_text(
'{"hosts": [{"name": "living-room", "addr": "192.168.1.42", "port": 9777, '
'"fp_hex": "aabbcc", "paired": true}]}'
)
got = asyncio.run(plugin.get_pins())["pins"]
check("pins: paired via known-hosts fp (case-insensitive)", got[0]["paired"] is True)
shutil.rmtree(decky.DECKY_USER_HOME, ignore_errors=True)
shutil.rmtree("/tmp/pf-test-native", ignore_errors=True)
tmp = Path("/tmp/pf-test-native/bin")
tmp.mkdir(parents=True, exist_ok=True)
(tmp / "punktfunk-client").write_text("")
main._client_argv = lambda: [str(tmp / "punktfunk-client")]
check("cli argv: native without a sibling CLI is None", main._cli_argv() is None)
(tmp / "punktfunk").write_text("")
check("cli argv: native sibling found", main._cli_argv() == [str(tmp / "punktfunk")])
# ---- `--list-audio` parsing (the settings tab's device pickers) --------------------------
sinks, sources = main._parse_audio_endpoints(
"sink\talsa_output.pci-0000_04_00.6.analog-stereo\tSteam Deck Speakers\n"
"sink\tbluez_output.AC_12_2F.1\tWH-1000XM4\n"
"source\talsa_input.pci-0000_04_00.6.analog-stereo\tSteam Deck Microphone\n"
# ---- _cli_error: the CLI's exit-code contract -------------------------------------------
#
# Exit 5 + `unknown command` is how a client too old for a verb announces itself — the ONE
# signature the panel turns into "update the client" plus the button that fixes it. Getting it
# wrong makes an out-of-date client look like a broken plugin.
check(
"err: unknown verb => client-outdated",
main._cli_error(5, 'unknown command "discover"\n\npunktfunk — the Punktfunk client')
== "client-outdated",
)
check("audio: sinks parsed", [d["name"] for d in sinks] == [
"alsa_output.pci-0000_04_00.6.analog-stereo", "bluez_output.AC_12_2F.1"
])
check("audio: sources parsed", len(sources) == 1)
check("audio: description kept", sinks[1]["description"] == "WH-1000XM4")
check(
"err: exit 5 without that phrase is NOT outdated",
main._cli_error(5, 'no saved host matches "desk"') == "unresolved",
)
check("err: connect failed", main._cli_error(2, "unreachable 10.0.0.1:9777") == "unreachable")
check("err: trust rejected", main._cli_error(3, "wrong PIN") == "refused")
check("err: needs a person", main._cli_error(6, "pair it first") == "needs-pairing")
check("err: nothing ran", main._cli_error(-1, "") == "client-unavailable")
check("err: unmapped code falls back", main._cli_error(4, "renderer") == "client-error")
# Junk the picker must not offer: no node.name is unusable (it is the id that gets stored), a
# short line is malformed, and an unknown kind belongs to neither list. A blank description
# falls back to the name so no entry renders unlabelled.
sinks, sources = main._parse_audio_endpoints(
"sink\t\tNo node name\n"
"sink\tonly-two-columns\n"
"monitor\tsome.monitor\tNot a sink or source\n"
"source\tbare.node\t\n"
"\n"
# ---- _cli_json: a zero exit with junk on stdout is a FAILURE, not an empty result --------
import asyncio # noqa: E402
def _fake_cli(rc: int, out: str, err: str = ""):
async def run(_args, timeout=20.0):
return rc, out, err
return run
main._run_cli = _fake_cli(0, '{"hosts": [{"name": "desk"}]}')
got = asyncio.run(main._cli_json(["discover", "--json"]))
check("json: payload merged under ok", got == {"ok": True, "hosts": [{"name": "desk"}]})
main._run_cli = _fake_cli(0, "not json at all")
got = asyncio.run(main._cli_json(["discover", "--json"]))
check("json: unparseable stdout is an error, not an empty list", got["ok"] is False)
check("json: ...and says so specifically", got["error"] == "client-error")
main._run_cli = _fake_cli(5, "", 'unknown command "discover"')
got = asyncio.run(main._cli_json(["discover", "--json"]))
check("json: old client surfaces as client-outdated", got["error"] == "client-outdated")
check("json: detail carries the CLI's own last line", "unknown command" in got["detail"])
# ---- _field_from (flatpak info parsing, drives the client update check) ------------------
info = " ID: io.unom.Punktfunk\n Origin: punktfunk-origin\n Commit: abc123def\n"
check("field: commit", main._field_from(info, "Commit") == "abc123def")
check("field: origin", main._field_from(info, "Origin") == "punktfunk-origin")
check("field: absent", main._field_from(info, "Nope") == "")
# ---- _looks_outdated (the GTK-init signature of a client predating a headless flag) ------
check("outdated: gtk init noise", main._looks_outdated("cannot open display: \nGtk-WARNING") is True)
check("outdated: an ordinary error is not", main._looks_outdated("connection refused") is False)
# ---- _semver_tuple (plugin update comparison) --------------------------------------------
check("semver: plain", main._semver_tuple("1.2.3") == (1, 2, 3))
check("semver: pre-release suffix dropped", main._semver_tuple("1.2.3-rc1") == (1, 2, 3))
check("semver: short forms pad", main._semver_tuple("2") == (2, 0, 0))
check("semver: ordering", main._semver_tuple("0.10.0") > main._semver_tuple("0.9.9"))
# ---- _upsert_configset_entry (Steam Input layout binding) --------------------------------
#
# Untested until now, and the riskiest thing that survived the cut: it edits a file holding
# HUNDREDS of other games' controller bindings, in place. Every assertion below is about not
# touching them.
empty = main._upsert_configset_entry("", "punktfunk", "template", "punktfunk.vdf")
check("vdf: builds the skeleton when the file is new", '"controller_config"' in empty)
check("vdf: the entry lands", '"punktfunk"' in empty and '"punktfunk.vdf"' in empty)
existing = (
'"controller_config"\n'
"{\n"
'\t"halflife2"\n'
"\t{\n"
'\t\t"template"\t\t"other.vdf"\n'
"\t}\n"
"}\n"
)
check("audio: junk lines dropped", sinks == [])
check("audio: blank description falls back to the node name", sources == [
{"name": "bare.node", "description": "bare.node"}
])
added = main._upsert_configset_entry(existing, "punktfunk", "template", "punktfunk.vdf")
check("vdf: an existing game's entry survives insertion", '"halflife2"' in added)
check("vdf: ours is inserted", '"punktfunk"' in added)
# Re-running must REPLACE our block, not accumulate a second one (this runs on every plugin
# session gated only by a localStorage marker, so idempotence is the whole contract).
twice = main._upsert_configset_entry(added, "punktfunk", "template", "punktfunk.vdf")
check("vdf: idempotent", twice.count('"punktfunk"\n') == 1)
check("vdf: neighbour still intact after the rewrite", '"halflife2"' in twice)
# Steam keys non-Steam games by their LOWERCASE name, and files on disk may carry either case —
# a case-sensitive match would append a duplicate the game never reads.
mixed = existing.replace('"halflife2"', '"Punktfunk"')
replaced = main._upsert_configset_entry(mixed, "punktfunk", "template", "punktfunk.vdf")
check("vdf: matches an existing key case-insensitively", replaced.count("unktfunk\"\n") == 1)
print()
if failures:
+100 -217
View File
@@ -1,95 +1,94 @@
// Bridge to the Python backend (main.py) + shared types.
//
// Every call here is a thin shell over the headless `punktfunk` CLI, so these types are the
// CLI's JSON shapes rather than anything this plugin invents. That is deliberate: the plugin
// used to model the client's stores itself and drifted from them with every field the client
// added.
import { callable } from "@decky/api";
export interface Host {
name: string;
host: string;
port: number;
pair: string; // "required" | "optional" — the HOST's policy
fp: string; // host cert SHA-256 fingerprint (lowercase hex) from the mDNS advert
proto: string; // advertised protocol, e.g. "punktfunk/1"
paired: boolean; // whether THIS device has already PIN-paired this host (by fingerprint)
id: string; // the host's stable instance id (mDNS TXT `id`; "" when not advertised)
mgmt: number; // management-API port (mDNS TXT `mgmt`; 0 = not advertised → default 47990)
os: string; // OS-identity chain (mDNS TXT `os`, e.g. "linux/fedora/bazzite"); "" on older hosts
}
// One title from a host's game library (the flatpak client's --library TSV, parsed by the
// backend). `id` is store-qualified (steam:<appid> / custom:<id>) and doubles as the
// launch handle (PF_LAUNCH → the session Hello).
export interface GameEntry {
/** A settings profile as the CLI resolves it — ids are dangling-checked and names attached. */
export interface Profile {
id: string;
store: string; // "steam" | "custom" | "heroic" | "lutris" | …
title: string;
name: string;
}
export interface LibraryResult {
ok: boolean;
games?: GameEntry[];
// "flatpak-not-found" | "timeout" | "not-paired" | "pin-mismatch" | "unreachable" |
// "http" | "client-outdated" | "client-error"
error?: string;
detail?: string; // the client's own one-line reason, for the generic error copy
}
// A pinned game — a one-tap stream row in the QAM. The host is identified primarily by
// cert fingerprint (survives IP changes; pairing is fp-keyed too), with the stored
// address as the launch fallback when the host isn't currently advertising.
export interface PinnedGame {
game_id: string;
title: string;
store: string;
host_fp: string;
host_id: string;
host_name: string;
host: string;
port: number;
mgmt: number;
added_at: number; // unix seconds
paired?: boolean; // annotated by get_pins from the client's known-hosts store
}
export interface PairResult {
ok: boolean;
fp?: string;
error?: string;
}
// A host in the SHARED saved-hosts store (client-known-hosts.json) — the same file the desktop
// client reads/writes, so add/rename/pair in either surface shows up in both. `online` comes
// from a mDNS-INDEPENDENT reachability probe (a Tailscale/VPN host isn't shown offline just
// because it doesn't advertise); `null` means reachability is unknown (probe skipped or a client
// too old for `--list-hosts`, which then also can't probe).
export interface SavedHost {
/**
* A host answering on mDNS right now (`punktfunk discover --json`).
*
* `saved`/`paired` are annotated BY THE CLI against the saved-hosts store fingerprint first,
* address second. The plugin does not join the two lists itself; that rule living in one place
* is what stops this surface disagreeing with the desktop client about the same box.
*/
export interface DiscoveredHost {
name: string;
addr: string;
port: number;
fp_hex: string; // host cert fingerprint (lowercase hex); "" for a not-yet-paired manual entry
fp: string; // advertised cert fingerprint (lowercase hex); "" when not advertised
pair: string; // the HOST's policy: "required" | "optional"
id: string; // the host's advertised stable id; "" when not advertised
mgmt: number; // management-API port; 0 = not advertised
os: string; // OS-identity chain, e.g. "linux/fedora/bazzite"; "" on older hosts
saved: boolean;
paired: boolean;
}
/**
* A host in the shared saved-hosts store (`punktfunk hosts list --probe --json`) the same
* `client-known-hosts.json` the desktop client owns.
*
* `online` comes from a mDNS-INDEPENDENT probe, so a host reached over Tailscale/VPN is not
* shown offline merely because it never advertises; `null` means the probe was skipped.
*
* `profile` is the host's DEFAULT binding, which a plain connect applies silently. It is not
* the same thing as `pinned_profiles`, which are the cards a user chose to surface. Both come
* back already resolved against the profile catalog, so this plugin never opens it.
*/
export interface SavedHost {
id: string | null; // the record's stable id — the reference a launch should use
name: string;
addr: string;
port: number;
fp_hex: string; // "" for a placeholder saved by address with no pin yet
paired: boolean;
mac: string[];
// OS-identity chain learned by the desktop client; optional because the installed
// flatpak client may predate the field.
os?: string;
os: string;
last_used: number | null;
clipboard_sync: boolean;
profile: Profile | null;
pinned_profiles: Profile[];
online: boolean | null;
}
export interface HostsResult {
ok: boolean;
hosts: SavedHost[];
probed: boolean;
fallback?: boolean; // true when read straight off disk (client too old for --list-hosts)
}
// The result of a host-store mutation (add/edit/forget). `error` is a stable code:
// "client-unavailable" (flatpak missing) | "client-outdated" (client predates the mode) |
// "unreachable"/"http"/… (from the client) | "client-error" (generic; see `detail`).
export interface MutationResult {
/**
* Every backend call answers in this shape. `error` is a stable code, never prose:
*
* - `client-unavailable` no client is installed, or the call never ran
* - `client-outdated` the installed client predates the verb (exit 5 + `unknown command`)
* - `unreachable` the host did not answer
* - `refused` trust rejected: a wrong PIN, or a fingerprint that already differs
* - `needs-pairing` the CLI refused because it needs a person
* - `unresolved` nothing matched what was named
* - `client-error` anything else; `detail` carries the CLI's own last line
*/
export interface CliResult {
ok: boolean;
error?: string;
detail?: string;
}
export interface DiscoverResult extends CliResult {
hosts?: DiscoveredHost[];
}
export interface HostsResult extends CliResult {
hosts?: SavedHost[];
}
export interface PairResult extends CliResult {
fp?: string;
}
export interface RunnerInfo {
runner: string; // absolute path to bin/punktfunkrun.sh
app_id: string; // flatpak app id
@@ -101,99 +100,6 @@ export interface RunnerInfo {
client_bin?: string;
}
// The flatpak client's settings JSON — the SAME `client-gtk-settings.json` the desktop client
// and the console's settings screen own, so a value changed in any of them shows in the others.
//
// Every field the client's `Settings` struct persists is modelled here EXCEPT the ones that
// cannot be answered from a plugin backend or aren't settings at all:
// • `forward_pad` — which physical pad is player 1. Needs SDL's live device list, which only
// the client process has; there is no CLI that enumerates pads.
// • `last_window_w/h` — the session's remembered window size, written BY the client, not a
// preference anyone sets.
// Both round-trip untouched: get_settings returns the whole parsed file, patches are object
// spreads, and set_settings merges onto what's on disk.
//
// Optional (`?`) marks a key the client writes with a serde `default`, so a store written before
// that key existed simply lacks it. Read those through the same fallback the client uses —
// `?? true` for the default-on ones, never `!!` — or a pre-existing file reads as "off" here
// while the stream runs with it on.
export interface StreamSettings {
// ---- Stream mode ----
width: number; // 0 = native
height: number; // 0 = native
refresh_hz: number; // 0 = native
render_scale?: number; // render-resolution multiplier; 1.0 = native (absent in pre-scale files)
bitrate_kbps: number; // 0 = host default
compositor: string; // "auto" | "kwin" | "wlroots" | "mutter" | "gamescope"
// Stream mode follows the session window instead of width/height, renegotiating on resize.
// Overrides width/height while on; degenerates to the display's native mode on fullscreen.
match_window?: boolean;
// ---- Video ----
codec?: string; // "auto" | "hevc" | "h264" | "av1" | "pyrowave" (absent in pre-codec files)
decoder?: string; // "auto" | "vulkan" | "vaapi" | "software"
hdr_enabled?: boolean; // default ON — advertise 10-bit/HDR10
enable_444?: boolean; // default off — ask for full chroma
adapter?: string; // decode/present GPU by marketing name; "" = automatic
// ---- Presentation ----
// What the client optimises for when a decoded frame is ready: "latency" | "smooth". Shared
// with the Apple and Android clients under this name, so one profile reads the same everywhere.
present_priority?: string;
smooth_buffer?: number; // frames held back under "smooth"; 0 = Automatic (resolves to 2), else 13
vsync?: boolean; // default ON — tear-free; off asks for a tearing present mode (best-effort)
allow_vrr?: boolean; // default ON — let a VRR panel refresh in step with the stream
// ---- Audio ----
audio_channels?: number; // 2 (stereo) | 6 (5.1) | 8 (7.1)
speaker_device?: string; // PipeWire node.name for playback; "" = system default
mic_enabled: boolean;
mic_device?: string; // PipeWire node.name for capture; "" = system default
echo_cancel?: boolean; // default ON; only meaningful while mic_enabled
// ---- Controllers ----
gamepad: string; // "auto" | "xbox360" | "xboxone" | "dualsense" | "dualshock4" | "steamdeck"
// Forward this device's controllers at all. Absent in pre-forwarding files, where the
// client's own serde default (true) applies — so `?? true` at every read, never `!!`.
gamepad_forwarding?: boolean;
// ---- Touchscreen, mouse & keyboard ----
touch_mode?: string; // "trackpad" | "pointer" | "touch"
mouse_mode?: string; // "capture" | "desktop"
invert_scroll?: boolean;
// Whether the session grabs the keyboard so Alt+Tab/Super reach the host.
inhibit_shortcuts: boolean;
// ---- Interface & behaviour ----
// Stats-overlay tier: "off" | "compact" | "normal" | "detailed". Absent in a pre-tier file,
// which resolves through `show_stats` — read both the way the client's
// `Settings::stats_verbosity` does, and write both the way `set_stats_verbosity` does.
stats_verbosity?: string;
// The legacy on/off the tier supersedes; kept written in sync so a client that predates the
// tiers still honours an Off chosen here.
show_stats?: boolean;
fullscreen_on_stream?: boolean;
auto_wake?: boolean; // default ON — Wake-on-LAN a sleeping host before connecting
library_enabled?: boolean; // the CLIENT's own library browser (this plugin has its own)
}
// One audio endpoint from the client's enumeration: the stable id that gets stored, plus the
// human name to show.
export interface AudioDevice {
name: string; // PipeWire node.name — what `speaker_device` / `mic_device` store
description: string; // human label ("Steam Deck Speakers")
}
// What the device pickers need, read from the session binary (`--list-adapters` / `--list-audio`).
// `ok: false` = the session binary couldn't be run or failed; every list is then empty and the
// pickers stay on their stored value rather than pretending the device is gone.
export interface DeviceLists {
ok: boolean;
adapters: string[]; // Vulkan physical devices, discrete first
sinks: AudioDevice[]; // playback endpoints
sources: AudioDevice[]; // capture endpoints
}
export interface UpdateInfo {
current: string; // installed PLUGIN version (package.json)
latest: string; // newest plugin version in our registry for this channel
@@ -229,21 +135,30 @@ export interface ShortcutArt {
icon_path: string;
}
export const discover = callable<[], Host[]>("discover");
// ---- The four CLI shells --------------------------------------------------------------
/** Browse the LAN over mDNS. Bounded by the CLI (3 s) plus a cold-start allowance. */
export const discover = callable<[], DiscoverResult>("discover");
/** The saved hosts, probed for reachability, with profiles and pinned cards resolved. */
export const hosts = callable<[], HostsResult>("hosts");
/** The PIN ceremony. `refused` = wrong PIN or a host that isn't armed. */
export const pair = callable<
[host: string, port: number, pin: string, name: string],
[addr: string, port: number, pin: string, name: string],
PairResult
>("pair");
// Fetch a paired host's game library (headless flatpak --library; can take seconds on a
// cold client start — show a spinner). Pass fp whenever known so the pin can't degrade.
export const library = callable<
[host: string, mgmt_port: number, fp: string],
LibraryResult
>("library");
export const getPins = callable<[], { pins: PinnedGame[] }>("get_pins");
export const setPins = callable<[pins: PinnedGame[]], { ok: boolean; error?: string }>(
"set_pins",
);
/**
* Step 1 of request access: save the host with its ADVERTISED fingerprint, pinned but unpaired.
* The launch that follows pins the same fingerprint, which is the only thing standing between a
* 185 s wait for approval and an impostor answering for the host. Idempotent; a host already
* saved under a DIFFERENT fingerprint comes back `refused` rather than being overwritten.
*/
export const trustHost = callable<
[addr: string, port: number, fp: string, name: string],
CliResult
>("trust_host");
// ---- Steam / plugin business (only a Decky plugin can do these) ------------------------
export const runnerInfo = callable<[], RunnerInfo>("runner_info");
export const shortcutArt = callable<[], ShortcutArt>("shortcut_art");
// Install the Steam Input layout (native touchscreen `ts_n` + gamepad passthrough) and point our
@@ -254,48 +169,16 @@ export const applyControllerConfig = callable<
[name: string],
{ ok: boolean; applied?: string[]; errors?: string[]; accounts?: number; error?: string; detail?: string }
>("apply_controller_config");
export const getSettings = callable<[], StreamSettings>("get_settings");
export const setSettings = callable<[settings: StreamSettings], { ok: boolean }>(
"set_settings",
);
// GPUs + audio endpoints for the device pickers. Costs a subprocess that initialises Vulkan and
// PipeWire, so it is called ONCE when the settings tab mounts and never on the launch path.
export const listDevices = callable<[], DeviceLists>("list_devices");
// The same, bypassing the backend's cache — for the user who just plugged in a headset.
export const refreshDevices = callable<[], DeviceLists>("refresh_devices");
export const killStream = callable<[], { ok: boolean }>("kill_stream");
// Send a Wake-on-LAN magic packet to a saved host (headless flatpak --wake) so a sleeping host is
// up by the time the stream connects. The MAC is looked up from the flatpak client's own
// known-hosts store; `ok: false` (no-op) when none has been learned yet. Fire before launching.
export const wake = callable<[host: string, port: number], { ok: boolean; error?: string }>(
"wake",
// Whether the streaming client's control socket exists (a stream/console client is up) —
// gates the QAM panel's host-button section.
export const streamRunning = callable<[], { running: boolean }>("stream_running");
// Press a HOST system button on the running stream: "guide" | "qam". The raw Steam/QAM
// presses stay on the Deck by default (the client's Controllers settings), so this — and
// holding Select — is how the host's own menus are reached.
export const hostAction = callable<[action: string], { ok: boolean; error?: string }>(
"host_action",
);
// ---- Shared saved-hosts store (the SAME client-known-hosts.json the desktop client owns) ----
// The saved hosts, each annotated with a live (mDNS-independent) `online` probe when `probe` is
// true. Falls back to a direct JSON read (no reachability) on a client too old for --list-hosts.
export const listHosts = callable<[probe: boolean], HostsResult>("list_hosts");
// Save a host by address (survives mDNS-blind networks). `fp` empty = unpaired placeholder to
// pair next; a later pair replaces it with the fingerprinted entry.
export const addHost = callable<[target: string, name: string, fp: string], MutationResult>(
"add_host",
);
// Rename and/or re-point a saved host. `selector` = its fingerprint (survives IP change) or
// current addr[:port]; empty fields are left untouched.
export const editHost = callable<
[selector: string, name: string, addr: string, port: number],
MutationResult
>("edit_host");
// Remove a saved host by fingerprint or addr[:port] (idempotent).
export const forgetHost = callable<[selector: string], MutationResult>("forget_host");
// Reset this device's Punktfunk state (saved hosts + stream settings + pins); KEEPS the client
// identity so the box isn't seen as new everywhere (re-pairing re-adds hosts).
export const resetConfig = callable<[], { ok: boolean; error?: string }>("reset_config");
// Reachability of one host[:port] via the client's mDNS-independent QUIC probe (a "test address"
// check). `{ ok: true, online }` when determined, else `{ ok: false, error }`.
export const probeHost = callable<
[target: string],
{ ok: boolean; online?: boolean; error?: string }
>("probe_host");
export const checkUpdate = callable<[force: boolean], UpdateInfo>("check_update");
// Update the client by whichever route its install supports: `flatpak update --user` for the
// flatpak, `punktfunk-client --apply-update` (the packaged root helper) for a one-tap-capable
+194 -347
View File
@@ -1,18 +1,14 @@
// Shared state hooks + user actions for the QAM panel and the fullscreen page.
// Shared state hooks + user actions for the QAM panel.
import { toaster } from "@decky/api";
import { Navigation } from "@decky/ui";
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import {
checkUpdate,
discover,
GameEntry,
getPins,
Host,
listHosts,
PinnedGame,
resetConfig,
DiscoveredHost,
hosts as listHosts,
Profile,
SavedHost,
setPins as setPinsBackend,
updateClient,
UpdateInfo,
} from "./backend";
@@ -37,19 +33,191 @@ declare global {
// PluginInstallType.UPDATE in decky-loader's browser.py (INSTALL=0/REINSTALL=1/UPDATE=2/…).
const INSTALL_TYPE_UPDATE = 2;
/**
* How far this device has got with a host. The three states are what the row says under the
* name, and which of them a host is in decides whether pressing it streams or opens the trust
* sheet.
*
* - `paired` the host approved this device (a PIN ceremony, or request access).
* - `trusted` its fingerprint is pinned but nobody has approved us yet. Streams work if
* the host's policy is `optional`; under `required` the connect parks.
* - `needs-access` no pinned fingerprint. Not streamable until the trust sheet runs.
*/
export type TrustState = "paired" | "trusted" | "needs-access";
/**
* One host as the panel shows it the union of the saved store and the live mDNS browse.
*
* A saved host is ONLINE when it either advertises or answers the reachability probe, so a box
* reached over Tailscale/VPN stops reading as offline. Discovered hosts that aren't saved are
* appended as extra rows.
*/
export interface HostView {
name: string;
addr: string;
port: number;
/**
* The fingerprint PINNED ON THE RECORD. "" means nothing is pinned, which is exactly what
* makes a host unstreamable the session binary refuses a pinless connect.
*
* Deliberately NOT filled in from a live advert. A host saved by address that happens to be
* advertising right now still has an empty pin on disk, and borrowing the advert's here would
* draw it as ready to stream while every launch refused for want of a fingerprint. What the
* advert offers is [`advertisedFp`], and moving it onto the record is a trust decision the
* user makes in the sheet.
*/
fp: string;
/** What the host is advertising right now, if anything — what request access would pin. */
advertisedFp: string;
/**
* The host is answering at an address its record does not carry it changed DHCP lease.
*
* This matters because a launch names the host by [`ref`], and the CLI dials whatever address
* the RECORD holds. So the row would show the live address and dial the dead one. The record
* has to be re-pointed before such a host can stream; `startStream` does it.
*/
moved: boolean;
paired: boolean;
online: boolean;
saved: boolean;
/** The advert's policy ("required"|"optional"); "" when the host isn't advertising. */
pairPolicy: string;
/** OS-identity chain (live advert preferred, else the stored one); "" unknown. */
os: string;
/**
* What a launch should NAME this host by: the record's stable id, which survives renames and
* DHCP moves, falling back to `addr:port` for a row that has no record yet (a discovered host
* the trust sheet is about to save, or a client too old to have minted ids).
*/
ref: string;
/** The host's default profile binding — applied silently by a plain connect, not a card. */
profile: Profile | null;
/** The cards to render nested under this host; already resolved against the catalog. */
pinnedProfiles: Profile[];
lastUsed: number | null;
}
export function trustState(v: HostView): TrustState {
if (v.paired) return "paired";
return v.fp ? "trusted" : "needs-access";
}
/**
* Must this host go through the trust sheet before it can stream?
*
* A pinned fingerprint is the ONLY rule. The session binary refuses a pinless connect, so a row
* without one can offer nothing but a button that fails; with one, the connect is verified and
* the host either admits it or parks it for an operator. The old rule also consulted the
* advertised policy for unsaved hosts, which made the answer depend on which of two lists a row
* came from the same box could read differently before and after being saved.
*/
export function needsPair(v: HostView): boolean {
return v.fp === "";
}
function advertMatchesSaved(a: DiscoveredHost, s: SavedHost): boolean {
return (
(!!s.fp_hex && !!a.fp && s.fp_hex.toLowerCase() === a.fp.toLowerCase()) ||
(s.addr === a.addr && s.port === a.port)
);
}
/**
* Join the saved store and the live browse into the rows the panel draws.
*
* Fingerprint first, address second 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's
* `discover` annotates `saved`/`paired` by exactly this rule too, so the two can't disagree.
*/
export function mergeHosts(saved: SavedHost[], discovered: DiscoveredHost[]): HostView[] {
const views: HostView[] = saved.map((s) => {
// Prefer a live advert's address: the host may have moved since it was last saved.
const advert = discovered.find((a) => advertMatchesSaved(a, s));
return {
name: s.name || s.addr,
addr: advert?.addr ?? s.addr,
port: advert?.port ?? s.port,
fp: s.fp_hex,
advertisedFp: advert?.fp ?? "",
moved: !!advert && (advert.addr !== s.addr || advert.port !== s.port),
paired: s.paired,
online: !!advert || s.online === true,
saved: true,
pairPolicy: advert?.pair ?? "",
os: advert?.os || s.os || "",
ref: s.id || `${advert?.addr ?? s.addr}:${advert?.port ?? s.port}`,
profile: s.profile,
pinnedProfiles: s.pinned_profiles ?? [],
lastUsed: s.last_used,
};
});
for (const a of discovered) {
if (saved.some((s) => advertMatchesSaved(a, s))) {
continue; // already rendered as its saved row, with a live pip
}
views.push({
name: a.name,
addr: a.addr,
port: a.port,
// No record, so nothing is pinned — whatever it advertises is an OFFER, not a pin.
fp: "",
advertisedFp: a.fp,
moved: false, // no record, so nothing to be stale
paired: a.paired,
online: true,
saved: false,
pairPolicy: a.pair,
os: a.os,
ref: `${a.addr}:${a.port}`,
profile: null,
pinnedProfiles: [],
lastUsed: null,
});
}
return views.sort(sortRows);
}
/**
* Online first, then most recently used, then by name. The host you streamed last night should
* be the first thing under your thumb; a host that is off right now should never be.
*/
function sortRows(a: HostView, b: HostView): number {
if (a.online !== b.online) return a.online ? -1 : 1;
if ((a.lastUsed ?? 0) !== (b.lastUsed ?? 0)) return (b.lastUsed ?? 0) - (a.lastUsed ?? 0);
return a.name.localeCompare(b.name);
}
// ----------------------------------------------------------------------------------------
// Discovery — mDNS scan state shared by the QAM panel and the full page.
// Hosts — ONE call site for both lists. They were separate hooks when the plugin had two
// views mounting them independently; the panel is the only view now, and merging them means
// the "scanning" state covers the whole row set rather than half of it flickering in first.
// ----------------------------------------------------------------------------------------
export function useHosts() {
const [hosts, setHosts] = useState<Host[]>([]);
const [views, setViews] = useState<HostView[]>([]);
const [scanning, setScanning] = useState(false);
// Why the list is empty, when it is empty for a reason other than an empty LAN. Rendering
// either of these as "No hosts yet" would blame the user's network for the plugin's problem:
// "client-outdated" — the installed client predates `punktfunk discover`
// "client-unavailable" — there is no client installed at all
const [problem, setProblem] = useState<string | null>(null);
const refresh = useCallback(async () => {
setScanning(true);
try {
setHosts(await discover());
// Both in flight at once: the browse is time-bounded and the probe is network-bound, so
// running them in sequence would cost the sum of two waits for no benefit.
const [d, s] = await Promise.all([discover(), listHosts()]);
// Both calls run the same binary, so they fail the same way; take whichever answered.
setProblem(
d.error === "client-unavailable" || s.error === "client-unavailable"
? "client-unavailable"
: d.error === "client-outdated" || s.error === "client-outdated"
? "client-outdated"
: null,
);
setViews(mergeHosts(s.hosts ?? [], d.hosts ?? []));
} catch (e) {
toaster.toast({ title: "Punktfunk", body: `Discovery failed: ${e}` });
toaster.toast({ title: "Punktfunk", body: `Couldn't list hosts: ${e}` });
} finally {
setScanning(false);
}
@@ -59,157 +227,7 @@ export function useHosts() {
void refresh();
}, [refresh]);
return { hosts, scanning, refresh };
}
// ----------------------------------------------------------------------------------------
// Saved hosts — the SHARED known-hosts store (client-known-hosts.json), the same file the
// desktop client reads/writes. Fetched WITH a reachability probe so a host reached over a
// routed network (Tailscale/VPN) reports online without ever appearing on mDNS.
// ----------------------------------------------------------------------------------------
export function useSavedHosts() {
const [saved, setSaved] = useState<SavedHost[]>([]);
const [loading, setLoading] = useState(false);
const refresh = useCallback(async () => {
setLoading(true);
try {
const r = await listHosts(true);
setSaved(r.hosts ?? []);
} catch {
/* backend unavailable — keep the current view */
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void refresh();
}, [refresh]);
return { saved, loading, refresh };
}
/**
* One host as the UI shows it the union of the saved store and the live mDNS scan. A saved
* host is ONLINE when it either advertises on mDNS OR answers the reachability probe (so
* mDNS-blind-but-reachable hosts stop reading as offline). Discovered hosts not in the store
* are appended as unsaved rows.
*/
export interface HostView {
name: string;
addr: string;
port: number;
fp: string; // "" for a saved-but-unpaired placeholder
paired: boolean; // PIN-paired specifically (a TOFU host has fp but paired=false)
online: boolean;
saved: boolean; // present in the known-hosts store
pairPolicy: string; // the advert's policy ("required"|"optional"), "" when not advertising
mgmt: number; // advertised mgmt-API port (0 = not advertised → default)
id: string; // advertised stable host id ("" when not advertising)
os: string; // OS-identity chain (live advert preferred, else the stored one); "" unknown
}
function advertMatchesSaved(a: Host, s: SavedHost): boolean {
return (
(!!s.fp_hex && !!a.fp && s.fp_hex.toLowerCase() === a.fp.toLowerCase()) ||
(s.addr === a.host && s.port === a.port)
);
}
export function mergeHosts(saved: SavedHost[], discovered: Host[]): HostView[] {
const views: HostView[] = saved.map((s) => {
// Prefer a live advert's address (a host may have moved DHCP leases since it was saved).
const advert = discovered.find((a) => advertMatchesSaved(a, s));
return {
name: s.name || s.addr,
addr: advert?.host ?? s.addr,
port: advert?.port ?? s.port,
fp: s.fp_hex || advert?.fp || "",
paired: s.paired,
online: !!advert || s.online === true,
saved: true,
pairPolicy: advert?.pair ?? "",
mgmt: advert?.mgmt ?? 0,
id: advert?.id ?? "",
os: advert?.os || s.os || "",
};
});
for (const a of discovered) {
if (saved.some((s) => advertMatchesSaved(a, s))) {
continue; // already rendered as its saved card (with a live pip)
}
views.push({
name: a.name,
addr: a.host,
port: a.port,
fp: a.fp,
paired: a.paired,
online: true,
saved: false,
pairPolicy: a.pair,
mgmt: a.mgmt,
id: a.id,
os: a.os,
});
}
return views;
}
/**
* True when this host must be paired before it can stream. A saved host is streamable once it
* has a pinned fingerprint (PIN-paired OR TOFU-trusted); a saved placeholder (no fp yet) must be
* paired. For an unsaved discovered host we keep the advertised-policy rule the UI always used.
*/
export function needsPair(v: HostView): boolean {
return v.saved ? v.fp === "" : v.pairPolicy === "required" && !v.paired;
}
/** Adapt a merged view back into the `Host` shape the pair/library/stream helpers consume. */
export function toHost(v: HostView): Host {
return {
name: v.name,
host: v.addr,
port: v.port,
pair: v.pairPolicy || (needsPair(v) ? "required" : "optional"),
fp: v.fp,
proto: "",
paired: v.paired,
id: v.id,
mgmt: v.mgmt,
os: v.os,
};
}
/** Is a pinned game's host currently online, considering BOTH the live scan and saved probe? */
export function pinIsOnline(pin: PinnedGame, views: HostView[]): boolean {
const fp = pin.host_fp.toLowerCase();
return views.some(
(v) =>
v.online &&
((!!fp && v.fp.toLowerCase() === fp) ||
(!!pin.host_id && v.id === pin.host_id) ||
(v.addr === pin.host && v.port === pin.port)),
);
}
/**
* Reset all Punktfunk state (saved hosts + stream settings + pins), keeping the client identity.
* Refreshes whatever views are passed so the UI clears immediately. Ends in a toast.
*/
export async function resetAll(refreshers: Array<() => void | Promise<void>>): Promise<void> {
try {
const r = await resetConfig();
for (const fn of refreshers) void fn();
toaster.toast({
title: "Punktfunk",
body: r.ok
? "Reset — saved hosts, settings, and pins cleared."
: `Reset failed${r.error ? ` (${r.error})` : ""}.`,
});
} catch {
toaster.toast({ title: "Punktfunk", body: "Reset failed." });
}
return { views, scanning, problem, refresh };
}
// ----------------------------------------------------------------------------------------
@@ -260,36 +278,6 @@ export function clientUpdateIsOneTap(info: UpdateInfo | null | undefined): boole
);
}
/**
* How the client got onto this box, in words a Deck user recognises. The raw kind comes from
* the client's own detector (`pf_update_check::detect`); anything unmapped falls through as
* itself rather than as "unknown", because the raw word is still more useful than a shrug.
*/
export function clientInstallLabel(kind: string): string {
switch (kind) {
case "flatpak":
return "Flatpak (per-user)";
case "apt":
return "System package (apt)";
case "dnf":
return "System package (dnf)";
case "rpm-ostree":
return "Layered package (rpm-ostree)";
case "pacman":
return "System package (pacman)";
case "sysext":
return "System extension (sysext)";
case "nix":
return "Nix profile";
case "steamos-source":
return "On-device build";
case "source":
return "Built from source";
default:
return kind;
}
}
/** True when the only pending update is one this Deck can't apply itself. */
export function clientUpdateIsManualOnly(info: UpdateInfo | null | undefined): boolean {
return !!info && info.client_update_available && !clientUpdateIsOneTap(info);
@@ -427,167 +415,26 @@ export async function applyUpdate(
}
// ----------------------------------------------------------------------------------------
// Stream launch — via the hidden Steam shortcut (see steam.ts for why).
// Stream launch — via the hidden Steam shortcut (see steam.ts for why it can't be direct).
// ----------------------------------------------------------------------------------------
/**
* Stream this host. `opts.profileId` streams one of its pinned cards; `opts.requestAccess`
* runs the supervised launch that waits for the host's operator to approve this Deck.
*
* The host is named by REFERENCE (`v.ref`), never by value no resolution, bitrate or codec
* ever rides the launch path, which is the same rule the deep-link grammar enforces.
*/
export async function startStream(
h: Host,
v: HostView,
opts: LaunchOpts = {},
label?: string,
): Promise<void> {
try {
await launchStream(h.host, h.port, opts);
await launchStream(v.ref, opts);
Navigation.CloseSideMenus();
toaster.toast({ title: "Punktfunk", body: `Starting ${label ?? "stream"}${h.name}` });
toaster.toast({ title: "Punktfunk", body: `Starting ${label ?? "stream"}${v.name}` });
} catch (e) {
toaster.toast({ title: "Punktfunk", body: `Launch failed: ${e}` });
}
}
/** Open the GTK client's gamepad library launcher for a host (`--browse` via PF_BROWSE). */
export async function startBrowse(h: Host): Promise<void> {
try {
await launchStream(h.host, h.port, { browse: true, mgmt: h.mgmt });
Navigation.CloseSideMenus();
toaster.toast({ title: "Punktfunk", body: `Opening library — ${h.name}` });
} catch (e) {
toaster.toast({ title: "Punktfunk", body: `Launch failed: ${e}` });
}
}
// ----------------------------------------------------------------------------------------
// Pinned games — the QAM's one-tap game rows, persisted by the backend next to the
// client's config (survives plugin reinstalls).
// ----------------------------------------------------------------------------------------
export interface PinsApi {
pins: PinnedGame[];
addPin: (h: Host, g: GameEntry) => void;
removePin: (hostFp: string, gameId: string) => void;
isPinned: (hostFp: string, gameId: string) => boolean;
/** Refresh a pin's stored address from a live advert (hosts change IPs). */
updatePinHost: (pin: PinnedGame, h: Host) => void;
refresh: () => Promise<void>;
}
export function usePins(): PinsApi {
const [pins, setPins] = useState<PinnedGame[]>([]);
// A live mirror of `pins`. The Games picker is mounted by Decky's `showModal` into a
// detached portal that captures this hook's callbacks ONCE and never re-renders with fresh
// props, so a mutator closing over the `pins` array reads a frozen base — pinning a second
// game in the same session would compute from the stale `[]` and clobber the first (silent
// data loss). Reading the ref keeps every mutation based on the current set, and lets the
// callbacks keep a stable identity (deps free of `pins`).
const pinsRef = useRef<PinnedGame[]>([]);
pinsRef.current = pins;
const refresh = useCallback(async () => {
try {
setPins((await getPins()).pins);
} catch {
/* backend unavailable — keep the current view */
}
}, []);
useEffect(() => {
void refresh();
}, [refresh]);
// Optimistic local state; the backend validates/dedups and is re-read on failure.
const save = useCallback(
(next: PinnedGame[]) => {
pinsRef.current = next;
setPins(next);
setPinsBackend(next).catch(() => void refresh());
},
[refresh],
);
const addPin = useCallback(
(h: Host, g: GameEntry) => {
const pin: PinnedGame = {
game_id: g.id,
title: g.title,
store: g.store,
host_fp: h.fp,
host_id: h.id,
host_name: h.name,
host: h.host,
port: h.port,
mgmt: h.mgmt,
added_at: Math.floor(Date.now() / 1000),
paired: h.paired,
};
save([
...pinsRef.current.filter(
(p) => !(p.host_fp === pin.host_fp && p.game_id === pin.game_id),
),
pin,
]);
},
[save],
);
const removePin = useCallback(
(hostFp: string, gameId: string) => {
save(pinsRef.current.filter((p) => !(p.host_fp === hostFp && p.game_id === gameId)));
},
[save],
);
const isPinned = useCallback(
(hostFp: string, gameId: string) =>
pins.some((p) => p.host_fp === hostFp && p.game_id === gameId),
[pins],
);
const updatePinHost = useCallback(
(pin: PinnedGame, h: Host) => {
if (pin.host === h.host && pin.port === h.port && pin.mgmt === h.mgmt) {
return;
}
save(
pinsRef.current.map((p) =>
p.host_fp === pin.host_fp && p.game_id === pin.game_id
? { ...p, host: h.host, port: h.port, mgmt: h.mgmt, host_name: h.name }
: p,
),
);
},
[save],
);
return { pins, addPin, removePin, isPinned, updatePinHost, refresh };
}
/**
* The host a pin should launch against right now: match the live mDNS scan by cert
* fingerprint first (pairing is fp-keyed, survives IP changes), then by the host's stable
* id, else fall back to the stored address (host offline or scan flaky still launch).
*/
export function resolvePinHost(
pin: PinnedGame,
live: Host[],
): { host: Host; online: boolean } {
const fp = pin.host_fp.toLowerCase();
const match =
(fp && live.find((h) => h.fp && h.fp.toLowerCase() === fp)) ||
(pin.host_id && live.find((h) => h.id && h.id === pin.host_id)) ||
undefined;
if (match) {
return { host: match, online: true };
}
return {
host: {
name: pin.host_name || pin.host,
host: pin.host,
port: pin.port,
pair: pin.paired ? "optional" : "required",
fp: pin.host_fp,
proto: "",
paired: !!pin.paired,
id: pin.host_id,
mgmt: pin.mgmt,
os: "", // pins don't store the chain; the icon is a hosts-tab affordance
},
online: false,
};
}
-164
View File
@@ -1,164 +0,0 @@
// Add / edit host dialogs for the fullscreen page. These mutate the SHARED known-hosts store
// (client-known-hosts.json) through the flatpak client's headless modes, so a host saved or
// renamed here shows up in the desktop client too. Text entry uses @decky/ui's TextField, which
// brings up Steam's on-screen keyboard on focus (the digit-grid trick in pair.tsx is only needed
// for the numeric PIN).
import { DialogButton, Focusable, ModalRoot, Spinner, TextField } from "@decky/ui";
import { toaster } from "@decky/api";
import { ChangeEvent, FC, useState } from "react";
import { addHost, editHost, MutationResult } from "./backend";
import { HostView } from "./hooks";
import { actionButton } from "./ui";
/** Stable copy for a failed host-store mutation. */
export function mutationError(r: MutationResult): string {
switch (r.error) {
case "client-unavailable":
return "The Punktfunk client isn't installed (flatpak io.unom.Punktfunk).";
case "client-outdated":
return "The installed client is too old for host management — update it from the About tab.";
default:
return r.detail || "Couldn't save the host.";
}
}
// Split a typed address: a pasted `host:port` wins over the separate port field. IPv6 literals
// aren't supported by the host advert/known-hosts format, so a bare colon is treated as host:port.
function targetFrom(addr: string, port: string): string {
const a = addr.trim();
if (a.includes(":")) {
return a;
}
const p = port.trim() || "9777";
return `${a}:${p}`;
}
const field: React.CSSProperties = { marginBottom: "0.8em" };
const HostForm: FC<{
title: string;
submitLabel: string;
initial: { addr: string; port: string; name: string };
addrDisabled?: boolean;
onSubmit: (addr: string, port: string, name: string) => Promise<MutationResult>;
onDone: () => void;
closeModal?: () => void;
}> = ({ title, submitLabel, initial, addrDisabled, onSubmit, onDone, closeModal }) => {
const [addr, setAddr] = useState(initial.addr);
const [port, setPort] = useState(initial.port);
const [name, setName] = useState(initial.name);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const submit = async () => {
if (!addr.trim()) {
setError("Enter an address.");
return;
}
setBusy(true);
setError(null);
try {
const r = await onSubmit(addr.trim(), port.trim(), name.trim());
if (r.ok) {
onDone();
closeModal?.();
} else {
setError(mutationError(r));
}
} catch (e) {
setError(String(e));
} finally {
setBusy(false);
}
};
return (
<ModalRoot closeModal={closeModal}>
<div style={{ fontWeight: "bold", fontSize: "1.3em", marginBottom: "0.6em" }}>{title}</div>
<div style={field}>
<TextField
label="Address"
description="IP or hostname (a Tailscale/VPN name works too). Add :port to override."
value={addr}
disabled={addrDisabled || busy}
onChange={(e: ChangeEvent<HTMLInputElement>) => setAddr(e.target.value)}
/>
</div>
<div style={field}>
<TextField
label="Port"
value={port}
mustBeNumeric
disabled={busy}
onChange={(e: ChangeEvent<HTMLInputElement>) => setPort(e.target.value)}
/>
</div>
<div style={field}>
<TextField
label="Name (optional)"
value={name}
disabled={busy}
onChange={(e: ChangeEvent<HTMLInputElement>) => setName(e.target.value)}
/>
</div>
{error && (
<div style={{ color: "#ff6b6b", marginBottom: "0.6em" }}>{error}</div>
)}
<Focusable style={{ display: "flex", gap: "0.5em", justifyContent: "flex-end" }}>
<DialogButton style={actionButton} disabled={busy} onClick={() => closeModal?.()}>
Cancel
</DialogButton>
<DialogButton style={actionButton} disabled={busy} onClick={submit}>
{busy ? <Spinner style={{ height: "1em" }} /> : submitLabel}
</DialogButton>
</Focusable>
</ModalRoot>
);
};
/** "+" — save a new host by address (unpaired placeholder; the user pairs it next). */
export const AddHostModal: FC<{ onDone: () => void; closeModal?: () => void }> = ({
onDone,
closeModal,
}) => (
<HostForm
title="Add host"
submitLabel="Add"
initial={{ addr: "", port: "9777", name: "" }}
onSubmit={async (addr, port, name) => {
const r = await addHost(targetFrom(addr, port), name, "");
if (r.ok) {
toaster.toast({ title: "Punktfunk", body: `Added ${name || addr}` });
}
return r;
}}
onDone={onDone}
closeModal={closeModal}
/>
);
/** Rename / re-point a saved host. Identified by fingerprint when it has one (survives IP
* changes), else by its current address. */
export const EditHostModal: FC<{
host: HostView;
onDone: () => void;
closeModal?: () => void;
}> = ({ host, onDone, closeModal }) => {
const selector = host.fp || `${host.addr}:${host.port}`;
return (
<HostForm
title={`Edit ${host.name}`}
submitLabel="Save"
initial={{ addr: host.addr, port: String(host.port), name: host.name }}
onSubmit={async (addr, port, name) => {
const r = await editHost(selector, name, addr, parseInt(port, 10) || 0);
if (r.ok) {
toaster.toast({ title: "Punktfunk", body: `Updated ${name || addr}` });
}
return r;
}}
onDone={onDone}
closeModal={closeModal}
/>
);
};
+200 -114
View File
@@ -1,5 +1,10 @@
// Plugin entry: the Quick Access Menu panel + route registration. The fullscreen page lives
// in page.tsx; shared hooks/actions in hooks.ts; the Steam-shortcut launch in steam.ts.
// Plugin entry: the Quick Access Menu panel. That is the whole plugin now — the fullscreen
// route, the settings screen, the host editor and the games picker are gone, because the
// client's own console home does all four one shortcut away (and is gamepad-navigable, which
// a QAM panel re-implementing them never quite was).
//
// What is left is what only a Decky plugin can do: start a stream through Steam so gamescope
// focuses it (see steam.ts), and stand in front of the trust decision that gates it.
import {
ButtonItem,
Field,
@@ -10,37 +15,35 @@ import {
showModal,
staticClasses,
} from "@decky/ui";
import { definePlugin, routerHook, toaster } from "@decky/api";
import { FC } from "react";
import { definePlugin, toaster } from "@decky/api";
import { FC, useEffect, useState } from "react";
import {
FaDownload,
FaGamepad,
FaLock,
FaLockOpen,
FaPlay,
FaPlus,
FaStopCircle,
FaSyncAlt,
FaTv,
} from "react-icons/fa";
import { hostAction, killStream, streamRunning } from "./backend";
import { PluginErrorBoundary } from "./boundary";
import {
applyUpdate,
checkForUpdatesNow,
clientUpdateIsManualOnly,
hasUpdate,
mergeHosts,
HostView,
needsPair,
pinIsOnline,
startStream,
toHost,
trustState,
useHosts,
usePins,
useSavedHosts,
useUpdate,
} from "./hooks";
import { streamPin } from "./library";
import { PunktfunkRoute, ROUTE } from "./page";
import { PairModal } from "./pair";
import { ensureGamepadUiShortcut, recreateShortcuts } from "./steam";
import { OsMark } from "./os-icon";
import { ensureGamepadUiShortcut, launchGamepadUi, recreateShortcuts, stopStream } from "./steam";
import { TrustSheet } from "./trust";
// Recovery action for "the Punktfunk library entry vanished" — recreates the visible shortcut.
// Deleting the shortcut (optionally + reinstalling the plugin) leaves a stale appId in Steam's
@@ -54,22 +57,106 @@ async function recreatePunktfunkShortcut(): Promise<void> {
});
}
// ----------------------------------------------------------------------------------------
// QAM panel — quick status + entry into the full page + one-tap stream for known hosts
// and pinned games.
// ----------------------------------------------------------------------------------------
const QamPanel: FC = () => {
const { hosts: discovered, scanning, refresh: refreshDiscovered } = useHosts();
const { saved, loading: loadingSaved, refresh: refreshSaved } = useSavedHosts();
const { info: update, checking, check } = useUpdate();
const pins = usePins();
/** Force-stop a wedged stream: end Steam's "game", then make sure the client itself is gone. */
async function forceStop(): Promise<void> {
stopStream();
try {
await killStream();
} catch {
/* best-effort — the TerminateApp above is usually enough */
}
toaster.toast({ title: "Punktfunk", body: "Stopped the stream" });
}
const hosts = mergeHosts(saved, discovered);
const busy = scanning || loadingSaved;
const refresh = () => {
void refreshDiscovered();
void refreshSaved();
};
// Press a host system button (guide/QAM) on the running stream, then hand the screen back
// to it — closing the local menus is what lets the HOST's overlay show through. The raw
// Steam/··· presses stay on the Deck by default (both overlays would open at once), so this
// is the panel route to the host's menus; holding Select is the controller route.
async function pressHost(action: "guide" | "qam"): Promise<void> {
const r = await hostAction(action).catch(() => ({ ok: false as const, error: "backend" }));
if (r.ok) {
Navigation.CloseSideMenus();
} else {
toaster.toast({
title: "Punktfunk",
body: r.error === "no-stream" ? "No stream is running" : "Couldn't reach the stream",
});
}
}
/** The line under a host's name: where it is, whether it's up, and how far trust has got. */
function hostDescription(v: HostView): string {
const trust = {
paired: "paired",
trusted: "trusted",
"needs-access": "needs access",
}[trustState(v)];
return `${v.addr}:${v.port} · ${v.online ? "online" : "offline"} · ${trust}`;
}
const HostRow: FC<{ host: HostView; refresh: () => void }> = ({ host, refresh }) => {
const gated = needsPair(host);
const stream = (opts: { requestAccess?: boolean } = {}) => void startStream(host, opts);
return (
<>
<PanelSectionRow>
<ButtonItem
layout="below"
onClick={() =>
gated
? showModal(
<TrustSheet host={host} onStream={stream} onChanged={refresh} />,
)
: stream()
}
label={
<span style={{ display: "inline-flex", alignItems: "center", gap: "0.4em" }}>
{gated ? <FaLock /> : <OsMark os={host.os} />}
{host.name}
</span>
}
description={hostDescription(host)}
>
{gated ? "Connect…" : "Stream"}
</ButtonItem>
</PanelSectionRow>
{/* Pinned cards, nested under their host rather than in a section of their own: a card
IS a (host, profile) pair, and a row that floats free of its host is the "a pinned
tile reads as a duplicate host" problem the desktop shells still have. The host's
own BOUND profile is deliberately not a card it applies silently on the plain row
above, and showing it twice would suggest they do different things. */}
{!gated &&
host.pinnedProfiles.map((p) => (
<PanelSectionRow key={`${host.ref}:${p.id}`}>
<ButtonItem
layout="below"
onClick={() => void startStream(host, { profileId: p.id }, `${p.name}`)}
label={`${p.name}`}
>
<FaPlay style={{ marginRight: "0.5em" }} />
Stream
</ButtonItem>
</PanelSectionRow>
))}
</>
);
};
const QamPanel: FC = () => {
const { views, scanning, problem, refresh } = useHosts();
const { info: update, checking, check } = useUpdate();
// The host-buttons section shows only while the streaming client is up (checked per
// panel open — the QAM panel mounts fresh each time).
const [streaming, setStreaming] = useState(false);
useEffect(() => {
let live = true;
void streamRunning()
.then((r) => live && setStreaming(r.running))
.catch(() => {});
return () => {
live = false;
};
}, []);
return (
<>
@@ -110,15 +197,62 @@ const QamPanel: FC = () => {
</PanelSection>
))}
<PanelSection title="Hosts">
<PanelSectionRow>
<ButtonItem layout="below" onClick={() => void refresh()} disabled={scanning}>
{scanning ? (
<Spinner style={{ height: "1em", marginRight: "0.5em" }} />
) : (
<FaSyncAlt style={{ marginRight: "0.5em" }} />
)}
{scanning ? "Scanning…" : "Refresh"}
</ButtonItem>
</PanelSectionRow>
{/* A client that is missing or too old explains itself rather than rendering an empty
list "no hosts on your LAN" would blame the network for the plugin's problem, and
for the outdated case the button that fixes it is in this same panel. */}
{problem && (
<PanelSectionRow>
<Field
focusable={false}
label={
problem === "client-unavailable"
? "Punktfunk isnt installed"
: "Update the Punktfunk client"
}
description={
problem === "client-unavailable"
? "This panel launches the Punktfunk app, which isnt on this Deck yet. Install it in Desktop Mode."
: "This client is too old to find hosts on your network. Saved hosts still work."
}
/>
</PanelSectionRow>
)}
{views.length === 0 && scanning && (
<PanelSectionRow>
<Field focusable={false} description="Scanning your network…" />
</PanelSectionRow>
)}
{views.length === 0 && !scanning && !problem && (
<PanelSectionRow>
<Field
focusable={false}
label="No hosts yet"
description="Open Punktfunk to find and pair one."
/>
</PanelSectionRow>
)}
{views.map((v) => (
<HostRow key={v.ref} host={v} refresh={refresh} />
))}
</PanelSection>
<PanelSection title="Punktfunk">
<PanelSectionRow>
<ButtonItem
layout="below"
description="Host details, stream settings, and help"
onClick={() => {
Navigation.Navigate(ROUTE);
Navigation.CloseSideMenus();
}}
description="Settings, adding a host by address, and browsing a host's games all live here."
onClick={() => void launchGamepadUi()}
>
<FaTv style={{ marginRight: "0.5em" }} />
Open Punktfunk
@@ -126,85 +260,31 @@ const QamPanel: FC = () => {
</PanelSectionRow>
</PanelSection>
{/* Pinned games the "jump straight into Playnite" rows. Pin games from a host's
picker (fullscreen page host row games button). */}
{pins.pins.length > 0 && (
<PanelSection title="Pinned Games">
{pins.pins.map((pin) => {
const online = pinIsOnline(pin, hosts);
return (
<PanelSectionRow key={`${pin.host_fp}:${pin.game_id}`}>
<ButtonItem
layout="below"
onClick={() => streamPin(pin, hosts.map(toHost), pins)}
label={pin.title}
description={`${pin.host_name}${online ? "" : " · offline?"}${
pin.paired ? "" : " · pairing required"
}`}
>
<FaPlay style={{ marginRight: "0.5em" }} />
Stream
</ButtonItem>
</PanelSectionRow>
);
})}
{streaming && (
<PanelSection title="Host menus">
<PanelSectionRow>
<ButtonItem
layout="below"
description="Press the Steam/guide button on the host"
onClick={() => void pressHost("guide")}
>
<FaGamepad style={{ marginRight: "0.5em" }} />
Steam menu on host
</ButtonItem>
</PanelSectionRow>
<PanelSectionRow>
<ButtonItem
layout="below"
description="Open the host's Quick Access Menu"
onClick={() => void pressHost("qam")}
>
<FaGamepad style={{ marginRight: "0.5em" }} />
Quick access on host
</ButtonItem>
</PanelSectionRow>
</PanelSection>
)}
<PanelSection title="Hosts">
<PanelSectionRow>
<ButtonItem layout="below" onClick={refresh} disabled={busy}>
{busy ? (
<Spinner style={{ height: "1em", marginRight: "0.5em" }} />
) : (
<FaSyncAlt style={{ marginRight: "0.5em" }} />
)}
{busy ? "Scanning…" : "Refresh"}
</ButtonItem>
</PanelSectionRow>
{hosts.length === 0 && busy && (
<PanelSectionRow>
<Field focusable={false} description="Scanning your network…" />
</PanelSectionRow>
)}
{hosts.length === 0 && !busy && (
<PanelSectionRow>
<Field
focusable={false}
label="No hosts found"
description="Open Punktfunk to add a host by address, or start a host on this network and refresh."
/>
</PanelSectionRow>
)}
{hosts.map((v) => {
const pair = needsPair(v);
const h = toHost(v);
return (
<PanelSectionRow key={v.fp || `${v.addr}:${v.port}`}>
<ButtonItem
layout="below"
onClick={() =>
pair
? showModal(<PairModal host={h} onPaired={() => startStream(h)} />)
: startStream(h)
}
label={
<span style={{ display: "inline-flex", alignItems: "center", gap: "0.4em" }}>
{pair ? <FaLock /> : <FaLockOpen />}
{v.name}
</span>
}
description={`${v.addr}:${v.port} · ${v.online ? "online" : "offline"}${
pair ? " · pairing required" : v.paired ? " · paired" : ""
}`}
>
{pair ? "Pair & Stream" : "Stream"}
</ButtonItem>
</PanelSectionRow>
);
})}
</PanelSection>
<PanelSection title="About">
<PanelSectionRow>
<Field
@@ -236,13 +316,22 @@ const QamPanel: FC = () => {
Recreate library shortcut
</ButtonItem>
</PanelSectionRow>
<PanelSectionRow>
<ButtonItem
layout="below"
description="Ends a stream that stopped responding."
onClick={() => void forceStop()}
>
<FaStopCircle style={{ marginRight: "0.5em" }} />
Force-stop
</ButtonItem>
</PanelSectionRow>
</PanelSection>
</>
);
};
export default definePlugin(() => {
routerHook.addRoute(ROUTE, PunktfunkRoute, { exact: true });
// Ensure the visible, stateless "Punktfunk" library entry (opens the gamepad UI / console
// home) exists and is repointed to the current plugin dir — also installs the native-touch
// controller config. Fire-and-forget: cosmetic library upkeep must never block plugin load.
@@ -260,8 +349,5 @@ export default definePlugin(() => {
</PluginErrorBoundary>
),
icon: <FaTv />,
onDismount() {
routerHook.removeRoute(ROUTE);
},
};
});
-230
View File
@@ -1,230 +0,0 @@
// The per-host game picker + pinned-game launch helper. The picker fetches a paired
// host's library through the backend (headless flatpak --library — a cold client start
// can take seconds, hence the explicit spinner copy) and pins titles as one-tap rows in
// the QAM's Games section; its header also launches the GTK client's on-screen gamepad
// library (`--browse`).
import { DialogButton, Field, ModalRoot, Spinner, showModal } from "@decky/ui";
import { FC, useEffect, useState } from "react";
import { FaThLarge, FaTv } from "react-icons/fa";
import { GameEntry, Host, library, LibraryResult, PinnedGame } from "./backend";
import { PinsApi, resolvePinHost, startBrowse, startStream } from "./hooks";
import { isSafeLaunchId } from "./steam";
import { PairModal } from "./pair";
import { RowActions, actionButton } from "./ui";
/** Human store tag (mirrors the GTK client's `store_label`). */
export function storeLabel(store: string): string {
switch (store) {
case "steam":
return "Steam";
case "custom":
return "Custom";
case "heroic":
return "Heroic";
case "lutris":
return "Lutris";
case "epic":
return "Epic";
case "gog":
return "GOG";
case "xbox":
return "Xbox";
default:
return "Game";
}
}
/**
* Stream a pinned game: resolve the host from the live scan (fp id stored address),
* opportunistically refresh a drifted stored address, and route through pairing first if
* this device is no longer paired with the host.
*/
export function streamPin(pin: PinnedGame, live: Host[], pins: PinsApi): void {
const { host, online } = resolvePinHost(pin, live);
if (online) {
pins.updatePinHost(pin, host); // no-op unless the address actually drifted
}
if (!pin.paired) {
showModal(
<PairModal
host={host}
onPaired={() => {
void pins.refresh(); // pick up the now-paired annotation
void startStream(host, { launchId: pin.game_id }, pin.title);
}}
/>,
);
return;
}
void startStream(host, { launchId: pin.game_id }, pin.title);
}
// Copy per backend error code (LibraryResult.error); `detail` covers the generic case.
function errorCopy(res: LibraryResult): string {
switch (res.error) {
case "not-paired":
return "This Deck isn't paired with the host — pair first, then browse its library.";
case "pin-mismatch":
return "The host's identity changed — re-pair to re-establish trust.";
case "unreachable":
return "Couldn't reach the host's management API. Is the host online and up to date?";
case "timeout":
return "Timed out talking to the host — try again.";
case "flatpak-not-found":
return "The Punktfunk client isn't installed (flatpak io.unom.Punktfunk).";
case "client-outdated":
return "The installed client is too old for library browsing — update it from the About tab.";
default:
return res.detail || "Couldn't fetch the library.";
}
}
// ----------------------------------------------------------------------------------------
// The picker modal: "open on screen" + a pin-toggle list of the host's games.
// ----------------------------------------------------------------------------------------
export const GamePickerModal: FC<{
host: Host;
pins: PinsApi;
clientUpdatePending?: boolean;
closeModal?: () => void;
}> = ({ host, pins, clientUpdatePending, closeModal }) => {
const [result, setResult] = useState<LibraryResult | null>(null);
const [attempt, setAttempt] = useState(0); // bump to refetch (retry / after pairing)
// The modal is a detached `showModal` portal that never re-renders from the page's pin
// state, so `pins.isPinned` would read a frozen snapshot and the Pin/Unpin label would
// never flip within a session. Track this host's pinned ids locally, seeded once from the
// snapshot at open; persistence still goes through the (stale-closure-safe) pins API.
const [pinnedIds, setPinnedIds] = useState<Set<string>>(
() => new Set(pins.pins.filter((p) => p.host_fp === host.fp).map((p) => p.game_id)),
);
const togglePin = (g: GameEntry) => {
const wasPinned = pinnedIds.has(g.id);
setPinnedIds((prev) => {
const next = new Set(prev);
if (wasPinned) next.delete(g.id);
else next.add(g.id);
return next;
});
if (wasPinned) pins.removePin(host.fp, g.id);
else pins.addPin(host, g);
};
useEffect(() => {
let stale = false;
setResult(null);
library(host.host, host.mgmt, host.fp)
.then((res) => {
if (!stale) setResult(res);
})
.catch((e) => {
if (!stale) setResult({ ok: false, error: "client-error", detail: String(e) });
});
return () => {
stale = true;
};
}, [host.host, host.mgmt, host.fp, attempt]);
const games = (result?.ok && result.games) || [];
const sorted = [...games].sort((a, b) => a.title.localeCompare(b.title));
return (
<ModalRoot closeModal={closeModal}>
<div style={{ fontWeight: "bold", fontSize: "1.3em", marginBottom: "0.4em" }}>
{host.name} Games
</div>
<Field
label="Open library on screen"
description="Browse this host's games with the controller, full screen"
childrenContainerWidth="max"
>
<RowActions>
<DialogButton
style={actionButton}
onClick={() => {
closeModal?.();
void startBrowse(host);
}}
>
<FaTv style={{ marginRight: "0.4em" }} />
Open
</DialogButton>
</RowActions>
</Field>
{clientUpdatePending && (
<Field
focusable={false}
description="A client update is available — direct game launch and on-screen browsing need the latest client."
/>
)}
{result === null && (
<Field
focusable={false}
label={
<span style={{ display: "inline-flex", alignItems: "center", gap: "0.6em" }}>
<Spinner style={{ height: "1em" }} />
Fetching the library
</span>
}
description="This starts the client headlessly — a cold start can take a few seconds."
/>
)}
{result !== null && !result.ok && (
<Field label="Couldn't fetch the library" description={errorCopy(result)} childrenContainerWidth="max">
<RowActions>
{result.error === "not-paired" && (
<DialogButton
style={actionButton}
onClick={() =>
showModal(<PairModal host={host} onPaired={() => setAttempt((n) => n + 1)} />)
}
>
Pair
</DialogButton>
)}
<DialogButton style={actionButton} onClick={() => setAttempt((n) => n + 1)}>
Retry
</DialogButton>
</RowActions>
</Field>
)}
{result?.ok && sorted.length === 0 && (
<Field
focusable={false}
label="No games found"
description="Install Steam titles or add custom entries in the host's web console."
/>
)}
{sorted.length > 0 && (
<div style={{ maxHeight: "55vh", overflowY: "auto" }}>
{sorted.map((g: GameEntry) => {
const pinned = pinnedIds.has(g.id);
const safe = isSafeLaunchId(g.id);
return (
<Field
key={g.id}
label={g.title}
description={
storeLabel(g.store) + (safe ? "" : " · unsupported id — can't be pinned")
}
childrenContainerWidth="max"
>
<RowActions>
<DialogButton style={actionButton} disabled={!safe} onClick={() => togglePin(g)}>
<FaThLarge style={{ marginRight: "0.4em" }} />
{pinned ? "Unpin" : "Pin"}
</DialogButton>
</RowActions>
</Field>
);
})}
</div>
)}
</ModalRoot>
);
};
-596
View File
@@ -1,596 +0,0 @@
// The fullscreen page (registered as the /punktfunk route) — Hosts / Settings / About tabs.
import {
ConfirmModal,
DialogButton,
Field,
Focusable,
ModalRoot,
Navigation,
Spinner,
Tabs,
showModal,
staticClasses,
} from "@decky/ui";
import { RowActions, actionButton, iconButton } from "./ui";
import { toaster } from "@decky/api";
import { CSSProperties, FC, useState } from "react";
import {
FaArrowLeft,
FaDownload,
FaExternalLinkAlt,
FaInfoCircle,
FaLock,
FaLockOpen,
FaPen,
FaPlay,
FaPlus,
FaSyncAlt,
FaThLarge,
FaTrashAlt,
} from "react-icons/fa";
import { UpdateInfo, forgetHost, killStream } from "./backend";
import { PluginErrorBoundary } from "./boundary";
import { OsMark } from "./os-icon";
import {
DOCS_URL,
HostView,
PinsApi,
applyUpdate,
checkForUpdatesNow,
clientInstallLabel,
clientUpdateIsManualOnly,
hasUpdate,
mergeHosts,
needsPair,
pinIsOnline,
resetAll,
startStream,
toHost,
useHosts,
usePins,
useSavedHosts,
useUpdate,
} from "./hooks";
import { AddHostModal, EditHostModal, mutationError } from "./hostmgmt";
import { GamePickerModal, storeLabel, streamPin } from "./library";
import { PairModal } from "./pair";
import { SettingsSection } from "./settings";
import { stopStream } from "./steam";
export const ROUTE = "/punktfunk";
// Bottom inset so the last control clears Gaming Mode's footer hint bar. Routed pages render
// *under* that bar otherwise — that's why the last Stream-settings row was getting hidden. The
// value is generous on purpose (and harmless where the tab area already insets); tune to taste.
const SAFE_BOTTOM = "80px";
// Each tab is its own scroll area so long content is always reachable above the footer.
const tabScroll: CSSProperties = {
height: "100%",
overflowY: "auto",
padding: "0.5em 2.5em",
paddingBottom: SAFE_BOTTOM,
boxSizing: "border-box",
};
// The one-line status under a host name: address, live presence, and trust state.
function hostSubtitle(v: HostView): string {
const parts = [`${v.addr}:${v.port}`, v.online ? "online" : "offline"];
if (needsPair(v)) {
parts.push("pairing required");
} else if (v.paired) {
parts.push("paired");
} else if (v.saved) {
parts.push("trusted");
}
return parts.join(" · ");
}
/** Confirm + forget a saved host, then refresh the list. */
function confirmForget(v: HostView, refresh: () => void): void {
const selector = v.fp || `${v.addr}:${v.port}`;
showModal(
<ConfirmModal
strTitle={`Forget ${v.name}?`}
strDescription="You'll need to pair or trust it again to reconnect."
strOKButtonText="Forget"
bDestructiveWarning
onOK={async () => {
const r = await forgetHost(selector);
toaster.toast({
title: "Punktfunk",
body: r.ok ? `Forgot ${v.name}` : mutationError(r),
});
refresh();
}}
/>,
);
}
// ----------------------------------------------------------------------------------------
// Host details — everything we know, plus (for a saved host) rename / edit / forget.
// ----------------------------------------------------------------------------------------
const HostDetailsModal: FC<{
host: HostView;
onChanged: () => void;
closeModal?: () => void;
}> = ({ host, onChanged, closeModal }) => {
const fp = host.fp ? (host.fp.match(/.{1,4}/g) ?? [host.fp]).join(" ") : "not known yet";
return (
<ModalRoot closeModal={closeModal}>
<div style={{ fontWeight: "bold", fontSize: "1.3em", marginBottom: "0.4em" }}>
{host.name}
</div>
<Field focusable={false} label="Address">
{host.addr}:{host.port}
</Field>
<Field focusable={false} label="Presence">
{host.online ? "Online" : "Offline"}
</Field>
<Field focusable={false} label="This Deck">
{host.paired ? "Paired" : host.fp ? "Trusted" : "Not paired yet"}
</Field>
<Field
focusable={false}
label="Certificate fingerprint (SHA-256)"
description={
<span
style={{ fontFamily: "monospace", fontSize: "0.85em", wordBreak: "break-word" }}
>
{fp}
</span>
}
/>
{host.saved && (
<Field label="Manage" childrenContainerWidth="max">
<RowActions>
<DialogButton
style={actionButton}
onClick={() => {
closeModal?.();
showModal(<EditHostModal host={host} onDone={onChanged} />);
}}
>
<FaPen style={{ marginRight: "0.4em" }} />
Edit
</DialogButton>
<DialogButton
style={actionButton}
onClick={() => {
closeModal?.();
confirmForget(host, onChanged);
}}
>
<FaTrashAlt style={{ marginRight: "0.4em" }} />
Forget
</DialogButton>
</RowActions>
</Field>
)}
</ModalRoot>
);
};
// ----------------------------------------------------------------------------------------
// One host row: status icon + address, details / pair / stream actions.
// ----------------------------------------------------------------------------------------
const HostRow: FC<{
host: HostView;
onChanged: () => void;
onGames: () => void;
}> = ({ host, onChanged, onGames }) => {
const pair = needsPair(host);
const h = toHost(host);
return (
<Field
label={
<span style={{ display: "inline-flex", alignItems: "center", gap: "0.4em" }}>
<OsMark os={host.os} />
{pair ? <FaLock /> : <FaLockOpen />}
{host.name}
</span>
}
description={hostSubtitle(host)}
childrenContainerWidth="max"
>
<RowActions>
<DialogButton
style={iconButton}
onClick={() => showModal(<HostDetailsModal host={host} onChanged={onChanged} />)}
>
<FaInfoCircle />
</DialogButton>
{/* Labeled, not icon-only: this is the entry to the game picker AND the on-screen
library browser, and controller nav has no hover tooltip to explain a bare icon. */}
<DialogButton style={actionButton} onClick={onGames}>
<FaThLarge style={{ marginRight: "0.4em" }} />
Games
</DialogButton>
{pair && (
<DialogButton
style={actionButton}
onClick={() => showModal(<PairModal host={h} onPaired={onChanged} />)}
>
Pair
</DialogButton>
)}
<DialogButton
style={actionButton}
onClick={() =>
pair
? showModal(<PairModal host={h} onPaired={() => startStream(h)} />)
: startStream(h)
}
>
<FaPlay style={{ marginRight: "0.4em" }} />
Stream
</DialogButton>
</RowActions>
</Field>
);
};
const HostsTab: FC<{
hosts: HostView[];
scanning: boolean;
refresh: () => void;
pins: PinsApi;
clientUpdatePending: boolean;
}> = ({ hosts, scanning, refresh, pins, clientUpdatePending }) => (
<div style={tabScroll}>
<Field
label="Hosts"
description={
scanning
? "Scanning the LAN…"
: `${hosts.length} host${hosts.length === 1 ? "" : "s"} — saved and on your network`
}
childrenContainerWidth="max"
bottomSeparator={hosts.length ? "standard" : "none"}
>
<RowActions>
<DialogButton
style={actionButton}
onClick={() => showModal(<AddHostModal onDone={refresh} />)}
>
<FaPlus style={{ marginRight: "0.5em" }} />
Add
</DialogButton>
<DialogButton style={actionButton} disabled={scanning} onClick={refresh}>
{scanning ? (
<Spinner style={{ height: "1em", marginRight: "0.5em" }} />
) : (
<FaSyncAlt style={{ marginRight: "0.5em" }} />
)}
{scanning ? "Scanning…" : "Refresh"}
</DialogButton>
</RowActions>
</Field>
{hosts.length === 0 && !scanning && (
<Field
focusable={false}
label="No hosts yet"
description="Add one by address with +, or start a Punktfunk host on this network and refresh. The setup guide (About tab) covers installing a host."
/>
)}
{hosts.map((h) => (
<HostRow
key={h.fp || `${h.addr}:${h.port}`}
host={h}
onChanged={refresh}
onGames={() =>
showModal(
<GamePickerModal
host={toHost(h)}
pins={pins}
clientUpdatePending={clientUpdatePending}
/>,
)
}
/>
))}
{/* Pinned games — also the cleanup surface for pins whose host is gone from the scan. */}
{pins.pins.length > 0 && (
<>
<Field
focusable={false}
label="Pinned games"
description="One-tap streams — they also live in the quick-access menu"
bottomSeparator="standard"
/>
{pins.pins.map((pin) => {
const online = pinIsOnline(pin, hosts);
return (
<Field
key={`${pin.host_fp}:${pin.game_id}`}
label={pin.title}
description={`${storeLabel(pin.store)} · ${pin.host_name}${
online ? "" : " · offline?"
}${pin.paired ? "" : " · pairing required"}`}
childrenContainerWidth="max"
>
<RowActions>
<DialogButton
style={actionButton}
onClick={() => streamPin(pin, hosts.map(toHost), pins)}
>
<FaPlay style={{ marginRight: "0.4em" }} />
Play
</DialogButton>
<DialogButton
style={actionButton}
onClick={() => pins.removePin(pin.host_fp, pin.game_id)}
>
Remove
</DialogButton>
</RowActions>
</Field>
);
})}
</>
)}
</div>
);
// NOT `tabScroll`: the settings screen is a SidebarNavigation, which lays out its own rail +
// content pane and scrolls the pane itself. Wrapping it in an outer scroll area would give it an
// indefinite height to fill, collapsing the rail — so this pane only hands it the full height and
// keeps its hands off the overflow. The footer inset lives inside the pages instead.
const settingsPane: CSSProperties = { height: "100%", overflow: "hidden" };
const SettingsTab: FC = () => (
<div style={settingsPane}>
<SettingsSection />
</div>
);
// ----------------------------------------------------------------------------------------
// About — plugin version + explicit update check, docs link, stream-exit help, force-stop,
// and the destructive "reset everything" action.
// ----------------------------------------------------------------------------------------
async function forceStopStream(): Promise<void> {
stopStream(); // ask Steam to end the "game" first (clean path)
const res = await killStream(); // then the flatpak-level hammer for a wedged client
toaster.toast({
title: "Punktfunk",
body: res.ok ? "Stream client stopped." : "Couldnt stop the stream client.",
});
}
function confirmReset(refreshers: Array<() => void | Promise<void>>): void {
showModal(
<ConfirmModal
strTitle="Reset Punktfunk?"
strDescription="Clears every saved host, your stream settings, and all pinned games on this Deck. Your client identity is kept, so you'll re-pair hosts to reconnect. This can't be undone."
strOKButtonText="Reset"
bDestructiveWarning
onOK={() => void resetAll(refreshers)}
/>,
);
}
const AboutTab: FC<{
update: UpdateInfo | null;
checking: boolean;
check: (force: boolean) => Promise<UpdateInfo | null>;
onReset: () => void;
}> = ({ update, checking, check, onReset }) => (
<div style={tabScroll}>
<Field
label="Version"
description={
update
? `v${update.current}${
update.channel ? ` · ${update.channel} channel` : " · development build"
}`
: "…"
}
childrenContainerWidth="max"
>
<RowActions>
<DialogButton
style={actionButton}
disabled={checking}
onClick={() => void checkForUpdatesNow(check)}
>
{checking ? <Spinner style={{ height: "1em" }} /> : "Check for updates"}
</DialogButton>
</RowActions>
</Field>
{/* What the client IS, so "why is there no Update button?" has a visible answer. The
install kind decides everything below it. */}
{!!update?.client_install && (
<Field
label="Client"
description={`${clientInstallLabel(update.client_install)}${
update.client_current ? ` · ${update.client_current}` : ""
}`}
/>
)}
{hasUpdate(update) && (
<Field
label={
update!.update_available
? `Plugin update — v${update!.latest}${
update!.client_update_available ? " + client" : ""
}`
: `Client update — ${update!.client_latest || "available"}`
}
description={
// Only promise a one-tap install when there is one. On a notify-only install the
// row becomes the command itself, which is the whole answer for that box.
clientUpdateIsManualOnly(update) && !update!.update_available
? update!.client_opt_in || update!.client_command
: "Installing can take a couple of minutes; Decky reloads the plugin when done"
}
childrenContainerWidth="max"
>
{clientUpdateIsManualOnly(update) && !update!.update_available ? null : (
<RowActions>
<DialogButton style={actionButton} onClick={() => applyUpdate(update!, check)}>
<FaDownload style={{ marginRight: "0.4em" }} />
Update
</DialogButton>
</RowActions>
)}
</Field>
)}
{!!update?.client_error && (
<Field
label="Client update check"
description={
update.client_error === "client-outdated"
? "This client predates update checks — update it once by hand and the check starts working."
: "Couldnt check the client for updates."
}
/>
)}
<Field
label="Setup guide"
description="Hosts, pairing, controllers, and troubleshooting — docs.punktfunk.unom.io"
childrenContainerWidth="max"
>
<RowActions>
<DialogButton
style={actionButton}
onClick={() => Navigation.NavigateToExternalWeb(DOCS_URL)}
>
<FaExternalLinkAlt style={{ marginRight: "0.4em" }} />
Open
</DialogButton>
</RowActions>
</Field>
<Field
focusable={false}
label="Leaving a stream"
description="Hold L1 + R1 + Start + Select inside the stream, or close the “game” from the Steam overlay — either returns you to Gaming Mode."
/>
<Field
label="Stream stuck?"
description="Force-stop the stream client if a session wedges"
childrenContainerWidth="max"
>
<RowActions>
<DialogButton style={actionButton} onClick={() => void forceStopStream()}>
Force-stop
</DialogButton>
</RowActions>
</Field>
<Field
label="Reset Punktfunk"
description="Clear saved hosts, stream settings, and pinned games on this Deck (keeps your client identity)"
childrenContainerWidth="max"
>
<RowActions>
<DialogButton style={actionButton} onClick={onReset}>
<FaTrashAlt style={{ marginRight: "0.4em" }} />
Reset
</DialogButton>
</RowActions>
</Field>
</div>
);
const PunktfunkPage: FC = () => {
const { hosts: discovered, scanning, refresh: refreshDiscovered } = useHosts();
const { saved, loading: loadingSaved, refresh: refreshSaved } = useSavedHosts();
const { info: update, checking, check } = useUpdate();
const pins = usePins();
const [tab, setTab] = useState("hosts");
const hosts = mergeHosts(saved, discovered);
// A host action (pair/add/edit/forget) can change either store, so refresh both.
const refreshHosts = () => {
void refreshDiscovered();
void refreshSaved();
};
return (
<div
style={{
marginTop: "40px",
height: "calc(100% - 40px)",
display: "flex",
flexDirection: "column",
}}
>
{/* Header is title + back only — updates live on the About tab (and the QAM banner). */}
<Focusable
style={{
display: "flex",
alignItems: "center",
gap: "1em",
padding: "0 2.5em",
marginBottom: "0.4em",
flexShrink: 0,
}}
>
<DialogButton style={iconButton} onClick={() => Navigation.NavigateBack()}>
<FaArrowLeft />
</DialogButton>
<div className={staticClasses?.Title} style={{ flex: 1, margin: 0 }}>
Punktfunk
</div>
</Focusable>
{/* Two things fight each other on an L1/R1 tab switch:
1. Valve's Tabs slides the incoming panel in from the right with a CSS transform.
2. `autoFocusContents` then focuses a control inside that still-offscreen panel, which
fires scrollIntoView. Because the panel is offset by a *transform* (not by scroll
position), scrollIntoView can't satisfy it by scrolling any one ancestor, so it walks
up and pans the whole page the "screen jumps right, then animates back" glitch.
Dropping autoFocusContents removes the scrollIntoView entirely, so nothing fights the
slide. L1/R1 still cycles tabs (that handler lives on the Tabs focus scope, active while
focus is anywhere inside including the tab strip); after a switch, focus stays on the
strip and Down enters the content, which is how Steam's own tabbed pages behave.
The overflow:hidden clip stays as defense-in-depth against any stray horizontal pan. */}
<div style={{ flex: 1, minHeight: 0, overflow: "hidden" }}>
<Tabs
activeTab={tab}
onShowTab={(id: string) => setTab(id)}
tabs={[
{
id: "hosts",
title: "Hosts",
content: (
<HostsTab
hosts={hosts}
scanning={scanning || loadingSaved}
refresh={refreshHosts}
pins={pins}
clientUpdatePending={!!update?.client_update_available}
/>
),
},
{
id: "settings",
title: "Settings",
content: <SettingsTab />,
},
{
id: "about",
title: "About",
content: (
<AboutTab
update={update}
checking={checking}
check={check}
onReset={() => confirmReset([refreshHosts, pins.refresh])}
/>
),
},
]}
/>
</div>
</div>
);
};
// Full page behind the boundary — registered as the /punktfunk route.
export const PunktfunkRoute: FC = () => (
<PluginErrorBoundary>
<PunktfunkPage />
</PluginErrorBoundary>
);
+26 -4
View File
@@ -3,10 +3,32 @@
import { DialogButton, Focusable, ModalRoot, Spinner } from "@decky/ui";
import { toaster } from "@decky/api";
import { FC, useState } from "react";
import { Host, pair } from "./backend";
import { pair } from "./backend";
import { HostView } from "./hooks";
/**
* User-facing copy for a failed ceremony. The CLI's stable exit codes say WHICH failure it was,
* so the keypad can name the fix instead of echoing a log line: `refused` is overwhelmingly a
* mistyped PIN or a host nobody armed, and telling someone to check their network for that
* would send them the wrong way entirely.
*/
function pairErrorBody(error: string | undefined, name: string): string {
switch (error) {
case "refused":
return "Wrong PIN, or the host isnt showing one. Arm pairing again and retry.";
case "unreachable":
return `Couldnt reach ${name}.`;
case "client-outdated":
return "Update the Punktfunk client to pair from here.";
case "client-unavailable":
return "Couldnt reach the Punktfunk client — is it still installed?";
default:
return "Pairing failed.";
}
}
export const PairModal: FC<{
host: Host;
host: HostView;
closeModal?: () => void;
onPaired: () => void;
}> = ({ host, closeModal, onPaired }) => {
@@ -21,13 +43,13 @@ export const PairModal: FC<{
setBusy(true);
setError(null);
try {
const res = await pair(host.host, host.port, pin, "Steam Deck");
const res = await pair(host.addr, host.port, pin, "Steam Deck");
if (res.ok) {
toaster.toast({ title: "Punktfunk", body: `Paired with ${host.name}` });
onPaired();
closeModal?.();
} else {
setError(res.error ?? "pairing failed");
setError(pairErrorBody(res.error, host.name));
setPin("");
}
} catch (e) {
-657
View File
@@ -1,657 +0,0 @@
// Stream settings — the client's WHOLE settings store, written to the JSON the client reads on
// launch (main.py set_settings, merged onto what's on disk). This is the same
// `client-gtk-settings.json` the desktop client and the console's settings screen own, so a value
// changed in any of the three shows in the other two.
//
// SHAPE OF THIS SCREEN. Thirty rows is too many to scroll past on a thumbstick, so they are split
// across a `SidebarNavigation` — the same left-rail-of-categories layout SteamOS's own Settings
// uses, and the one Deck users already know. Every page fits on screen without scrolling, which is
// the whole point of the split: the rail is the index, so nothing is more than one hop away.
//
// The categories, their order, and the wording of the rows are the console's settings screen
// (pf-console-ui/src/screens/settings.rs) — that screen is the other settings editor a user
// reaches without leaving Gaming Mode, and two different orders for one store is how people stop
// trusting either. It shows them as one steppable list because it has no pointer and no room for
// a rail; here they become the rail's pages, same groups, same sequence. Three more rules:
//
// • A setting that depends on another is INDENTED under it and DISABLED, never hidden — the
// console dims those rows rather than dropping them, and a row that vanishes as you toggle
// the one above it is a moving target for a thumbstick.
// • A picker whose options this device doesn't have doesn't appear at all (the GPU row on a
// one-GPU Deck). A dead control is worse than an absent one.
// • Anything that behaves differently *here* than it does on a desktop says so in its own
// description, rather than being silently dropped from the screen.
//
// The accepted gamepad/compositor/codec/decoder names mirror punktfunk-core's `*Pref::from_name`
// and the console's tables; the tier/mode names mirror the `StatsVerbosity` / `TouchMode` /
// `MouseMode` enums, which serialize lowercase.
import {
DialogButton,
Dropdown,
Field,
SidebarNavigation,
SliderField,
Spinner,
ToggleField,
} from "@decky/ui";
import { CSSProperties, FC, ReactElement, ReactNode, useEffect, useState } from "react";
import {
FaDesktop,
FaGamepad,
FaHandPointer,
FaSlidersH,
FaTv,
FaVideo,
FaVolumeUp,
} from "react-icons/fa";
import {
AudioDevice,
DeviceLists,
getSettings,
listDevices,
refreshDevices,
setSettings,
StreamSettings,
} from "./backend";
import { actionButton, RowActions } from "./ui";
// Decky's Dropdown has no width prop — it fills whatever container it's in, and a
// `childrenContainerWidth="max"` Field is the whole row. Wrapping it in this fit-content shell
// (inside the right-aligned RowActions) shrinks the control to its selected label, with a floor
// so short values like "60 Hz" don't collapse to a nub and a ceiling so nothing runs edge to
// edge. Matches the right-aligned, content-sized buttons everywhere else.
const selectShell: CSSProperties = {
width: "fit-content",
minWidth: "10em",
maxWidth: "24em",
};
// ----------------------------------------------------------------------------------------
// Option tables — the console's, so the two Gaming-Mode editors offer the same choices.
// ----------------------------------------------------------------------------------------
// "native" and "match" are virtual: they store `width`/`height` of 0 with `match_window` off/on.
// Match window is offered even though this plugin's launches are always fullscreen (where it
// degenerates to the display's native mode) — leaving it out would make the row lie about a
// store the desktop client can set it in.
const MATCH_WINDOW = "match";
const RESOLUTIONS: [number, number, string][] = [
[0, 0, "Native display"],
[1280, 720, "1280 × 720"],
[1280, 800, "1280 × 800 (Deck)"],
[1920, 1080, "1920 × 1080"],
[2560, 1440, "2560 × 1440"],
[3840, 2160, "3840 × 2160"],
];
const resolutionKey = (w: number, h: number): string => (w === 0 && h === 0 ? "native" : `${w}x${h}`);
const REFRESH = [0, 30, 60, 90, 120];
// Render-resolution multipliers (mirrors punktfunk_core::render_scale::PRESETS). 1.0 = native.
const RENDER_SCALES = [0.5, 0.67, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0];
const renderScaleLabel = (x: number): string =>
x === 1 ? "Native (1×)" : x > 1 ? `${x}× · supersample` : `${x}×`;
const COMPOSITORS: [string, string][] = [
["auto", "Automatic"],
["kwin", "KDE Plasma (KWin)"],
["wlroots", "Sway (wlroots)"],
["mutter", "GNOME (Mutter)"],
["gamescope", "gamescope"],
];
const CODECS: [string, string][] = [
["auto", "Automatic"],
["hevc", "HEVC (H.265)"],
["h264", "H.264 (AVC)"],
["av1", "AV1"],
// Opt-in wired-LAN low-latency codec (100400 Mbit/s class, 8-bit SDR). Only ever selected
// when the host advertises it too; anything else falls back to HEVC.
["pyrowave", "PyroWave (wired LAN)"],
];
const DECODERS: [string, string][] = [
["auto", "Automatic"],
["vulkan", "Vulkan Video"],
["vaapi", "VAAPI"],
["software", "Software"],
];
// Presentation intent — the `present_priority` key shared with the Apple and Android clients, so
// one profile reads the same on every device.
const PRESENT_PRIORITIES: [string, string][] = [
["latency", "Lowest latency"],
["smooth", "Smoothness"],
];
// Smoothness buffer depth in frames; 0 = Automatic (resolves to 2).
const SMOOTH_BUFFERS: [number, string][] = [
[0, "Automatic"],
[1, "1 frame"],
[2, "2 frames"],
[3, "3 frames"],
];
const AUDIO_CHANNELS: [number, string][] = [
[2, "Stereo"],
[6, "5.1 surround"],
[8, "7.1 surround"],
];
const GAMEPADS: [string, string][] = [
["auto", "Automatic"],
["xbox360", "Xbox 360"],
["xboxone", "Xbox One"],
["dualsense", "DualSense"],
["dualshock4", "DualShock 4"],
["steamdeck", "Steam Deck"],
];
const TOUCH_MODES: [string, string][] = [
["trackpad", "Trackpad"],
["pointer", "Direct pointer"],
["touch", "Touch passthrough"],
];
const MOUSE_MODES: [string, string][] = [
["capture", "Capture (games)"],
["desktop", "Desktop (absolute)"],
];
const STATS_TIERS: [string, string][] = [
["off", "Off"],
["compact", "Compact"],
["normal", "Normal"],
["detailed", "Detailed"],
];
// ----------------------------------------------------------------------------------------
// Row primitives — every picker row is Field + right-aligned, content-sized Dropdown, so the
// twelve of them below stay one line each and can't drift apart.
// ----------------------------------------------------------------------------------------
const SelectRow = <T extends string | number>({
label,
description,
options,
value,
onChange,
formatUnknown,
disabled,
indent,
}: {
label: string;
description?: ReactNode;
options: [T, string][];
value: T;
onChange: (v: T) => void;
// How to name a stored value this table doesn't list (see below); defaults to the raw value.
formatUnknown?: (v: T) => string;
disabled?: boolean;
indent?: boolean;
}): ReactElement => {
// A Dropdown can only display a value that is one of its options, and this store has four other
// writers — the desktop client, the console, a settings profile, a newer client with presets
// this build doesn't know. Rather than render a blank control (or, worse, silently show a
// different value than the stream will actually use), carry the stored one as its own entry.
const shown: [T, string][] = options.some(([v]) => v === value)
? options
: [...options, [value, formatUnknown ? formatUnknown(value) : String(value)]];
return (
<Field
label={label}
description={description}
disabled={disabled}
indentLevel={indent ? 1 : undefined}
childrenContainerWidth="max"
>
<RowActions>
<div style={selectShell}>
<Dropdown
disabled={disabled}
rgOptions={shown.map(([data, l]) => ({ data, label: l }))}
selectedOption={value}
onChange={(o) => onChange(o.data as T)}
/>
</div>
</RowActions>
</Field>
);
};
// An audio-endpoint picker. The stored value is a PipeWire `node.name`; "" means "whatever the OS
// is using". A stored endpoint that isn't in the current enumeration still gets an entry — it is
// a real preference that simply isn't plugged in right now, and dropping it would silently
// re-point the next stream at the default without ever showing the user why.
const DeviceRow: FC<{
label: string;
description: string;
devices: AudioDevice[] | null;
value: string;
onChange: (v: string) => void;
disabled?: boolean;
indent?: boolean;
}> = ({ label, description, devices, value, onChange, disabled, indent }) => {
const options: [string, string][] = [["", "System default"]];
for (const d of devices ?? []) options.push([d.name, d.description]);
if (value && !options.some(([name]) => name === value)) {
options.push([value, `${value} (not connected)`]);
}
return (
<SelectRow
label={label}
description={devices === null ? "Reading this device's audio endpoints…" : description}
options={options}
value={value}
onChange={onChange}
disabled={disabled || devices === null}
indent={indent}
/>
);
};
// ----------------------------------------------------------------------------------------
// The pages. One settings object, seven views on it — every page takes the same context rather
// than fetching or holding state of its own, so a change on one page is visible on the others
// the moment you switch.
// ----------------------------------------------------------------------------------------
interface PageCtx {
s: StreamSettings;
patch: (p: Partial<StreamSettings>) => void;
devices: DeviceLists | null;
reading: boolean;
readDevices: (again: boolean) => void;
}
// SidebarNavigation gives each page Steam's own padding, but the routed page still renders
// UNDER Gaming Mode's footer hint bar, so the last row of a page needs to clear it (the same
// inset the tabs use).
const pageBody: CSSProperties = { paddingBottom: "80px" };
const StreamPage: FC<PageCtx> = ({ s, patch }) => {
const renderScale = s.render_scale ?? 1;
const resolution = s.match_window ? MATCH_WINDOW : resolutionKey(s.width, s.height);
return (
<div style={pageBody}>
<SelectRow
label="Resolution"
description="The host creates a virtual display at exactly this size — no scaling. Match window follows the stream window instead, which in Gaming Mode means the Deck's native size."
options={[
...RESOLUTIONS.map(([w, h, label]) => [resolutionKey(w, h), label] as [string, string]),
[MATCH_WINDOW, "Match window"] as [string, string],
]}
value={resolution}
// A size set from a desktop profile that isn't one of these presets, spelled the way the
// presets are rather than left as the raw "1600x900" key.
formatUnknown={(v) => v.replace("x", " × ")}
onChange={(v) => {
if (v === MATCH_WINDOW) {
// The tri-state the console stores: the flag on, the explicit size cleared.
patch({ match_window: true, width: 0, height: 0 });
return;
}
const found = RESOLUTIONS.find(([w, h]) => resolutionKey(w, h) === v);
patch({ match_window: false, width: found?.[0] ?? 0, height: found?.[1] ?? 0 });
}}
/>
<SelectRow
label="Refresh rate"
description="Native follows the display the stream is on."
options={REFRESH.map((r) => [r, r === 0 ? "Native" : `${r} Hz`] as [number, string])}
value={s.refresh_hz}
formatUnknown={(v) => `${v} Hz`}
onChange={(v) => patch({ refresh_hz: v })}
/>
<SelectRow
label="Render scale"
description="The host renders larger or smaller than the stream mode and the Deck resamples — above 1× supersamples for sharpness, below 1× saves bandwidth."
options={RENDER_SCALES.map((x) => [x, renderScaleLabel(x)] as [number, string])}
// Snap the stored value to the nearest preset so the dropdown always shows a match.
value={RENDER_SCALES.reduce((best, x) =>
Math.abs(x - renderScale) < Math.abs(best - renderScale) ? x : best,
)}
onChange={(v) => patch({ render_scale: v })}
/>
<SliderField
label="Bitrate"
description="0 = the host's own default (20 Mbit/s)."
value={Math.round(s.bitrate_kbps / 1000)}
min={0}
max={150}
step={5}
showValue
valueSuffix=" Mbit/s"
onChange={(v) => patch({ bitrate_kbps: v * 1000 })}
/>
<SelectRow
label="Host compositor"
description="Which compositor drives the virtual display — honoured only if it's available on the host. Automatic suits almost every host."
options={COMPOSITORS}
value={s.compositor}
onChange={(v) => patch({ compositor: v })}
/>
</div>
);
};
const VideoPage: FC<PageCtx> = ({ s, patch, devices }) => {
// Only worth a row on a box that actually has a choice to make. A Deck has one adapter, and a
// picker with a single option is a control that can't do anything.
const showGpuRow = (devices?.adapters.length ?? 0) > 1;
return (
<div style={pageBody}>
<SelectRow
label="Video codec"
description="A preference — the host falls back when its GPU can't encode this one."
options={CODECS}
value={s.codec ?? "auto"}
onChange={(v) => patch({ codec: v })}
/>
<SelectRow
label="Video decoder"
description="How the Deck decodes the stream. Automatic prefers Vulkan Video, then VAAPI, then software."
options={DECODERS}
value={s.decoder ?? "auto"}
onChange={(v) => patch({ decoder: v })}
/>
{showGpuRow && (
<SelectRow
label="Decode GPU"
description="Which adapter decodes and presents the stream. Automatic picks the discrete GPU where there is one."
options={[
["", "Automatic"],
...(devices?.adapters ?? []).map((a) => [a, a] as [string, string]),
]}
value={s.adapter ?? ""}
onChange={(v) => patch({ adapter: v })}
/>
)}
<ToggleField
label="10-bit HDR"
description="Advertise HDR10 so the host sends 10-bit when the content is HDR. Off means never ask for 10-bit."
checked={s.hdr_enabled ?? true}
onChange={(v) => patch({ hdr_enabled: v })}
/>
<ToggleField
label="Full chroma (4:4:4)"
description="Full-colour video: crisp small text and thin lines, at more bandwidth. Needs an NVIDIA host (NVENC) or the PyroWave codec — other encoders stream 4:2:0 and the session falls back silently."
checked={s.enable_444 ?? false}
onChange={(v) => patch({ enable_444: v })}
/>
</div>
);
};
const PresentationPage: FC<PageCtx> = ({ s, patch }) => {
const smooth = (s.present_priority ?? "latency") === "smooth";
return (
<div style={pageBody}>
<SelectRow
label="Prioritize"
description="What to optimise for when a decoded frame is ready. Lowest latency shows each frame the moment the display can take it — a network hiccup becomes an occasional repeated or skipped frame. Smoothness buffers a little to even those out."
options={PRESENT_PRIORITIES}
value={s.present_priority ?? "latency"}
onChange={(v) => patch({ present_priority: v })}
/>
<SelectRow
label="Smoothness buffer"
description="Frames held back before showing. Each one absorbs about a refresh of network hiccup and adds a refresh of delay. Automatic holds two."
options={SMOOTH_BUFFERS}
value={s.smooth_buffer ?? 0}
formatUnknown={(v) => `${v} frames`}
onChange={(v) => patch({ smooth_buffer: v })}
disabled={!smooth}
indent
/>
<ToggleField
label="V-Sync"
description="Tear-free. Off removes the wait for the screen's refresh — the lowest possible delay, at the cost of visible tearing. Best-effort: not every driver offers it, and the Detailed stats overlay names the mode actually in use."
checked={s.vsync ?? true}
onChange={(v) => patch({ vsync: v })}
/>
<ToggleField
label="Follow variable refresh"
description="On a VRR screen, let the panel refresh in step with the stream instead of on a fixed cadence. Applies to fullscreen sessions — which a Gaming-Mode stream always is — and is harmless on a fixed-refresh screen."
checked={s.allow_vrr ?? true}
onChange={(v) => patch({ allow_vrr: v })}
/>
</div>
);
};
const AudioPage: FC<PageCtx> = ({ s, patch, devices, reading, readDevices }) => {
const micOn = s.mic_enabled;
// What the pickers get: null while the enumeration is in flight (they show a loading state),
// [] when it answered but couldn't read the endpoints (System default plus whatever is
// stored), and the real list otherwise.
const endpoints = (list: AudioDevice[] | undefined): AudioDevice[] | null =>
reading || !devices ? null : devices.ok ? (list ?? []) : [];
return (
<div style={pageBody}>
<SelectRow
label="Audio channels"
description="The speaker layout requested from the host, which clamps it to what it can capture."
options={AUDIO_CHANNELS}
value={s.audio_channels ?? 2}
formatUnknown={(v) => `${v} channels`}
onChange={(v) => patch({ audio_channels: v })}
/>
<DeviceRow
label="Output device"
description="Where stream audio plays. System default follows whatever the Deck is using, including a headset you plug in mid-stream."
devices={endpoints(devices?.sinks)}
value={s.speaker_device ?? ""}
onChange={(v) => patch({ speaker_device: v })}
/>
<ToggleField
label="Stream microphone"
description="Send the Deck's microphone to the host's virtual mic. Ctrl+Alt+Shift+V mutes and unmutes it mid-stream."
checked={micOn}
onChange={(v) => patch({ mic_enabled: v })}
/>
<DeviceRow
label="Microphone device"
description="Which input the mic uplink captures from."
devices={endpoints(devices?.sources)}
value={s.mic_device ?? ""}
onChange={(v) => patch({ mic_device: v })}
disabled={!micOn}
indent
/>
<ToggleField
label="Echo cancellation"
description="Stops the host's audio, playing from the Deck's speakers, being picked up and sent back. Turn it off if your microphone already runs its own processing."
checked={s.echo_cancel ?? true}
onChange={(v) => patch({ echo_cancel: v })}
disabled={!micOn}
indentLevel={1}
/>
{/* The escape hatch for a headset plugged in after this page was opened, and the honest
answer when the enumeration failed outright (a client too old to ship the session
binary). Rendered unconditionally, including while it is reading: a row that comes and
goes under a thumbstick is a moving target, so only its wording changes. */}
<Field
label={
!reading && devices && !devices.ok ? "Couldn't read this device's hardware" : "Devices"
}
description={
reading
? "Reading this device's audio endpoints and GPUs…"
: devices && !devices.ok
? "The output, microphone and GPU pickers fall back to Automatic. Reading them needs the client's session binary, which a client older than the two-binary split doesn't ship — update it from the About tab."
: "Plugged something in just now? Read the audio endpoints and GPUs again."
}
childrenContainerWidth="max"
>
<RowActions>
<DialogButton style={actionButton} disabled={reading} onClick={() => readDevices(true)}>
{reading ? <Spinner style={{ height: "1em" }} /> : "Refresh"}
</DialogButton>
</RowActions>
</Field>
</div>
);
};
const ControllersPage: FC<PageCtx> = ({ s, patch }) => {
const forwarding = s.gamepad_forwarding ?? true;
return (
<div style={pageBody}>
<ToggleField
label="Forward controllers"
description="Send controllers connected to the Deck to the host. Turn it off when your controller already reaches the host another way — USB passthrough such as VirtualHere, or a pad plugged into the host — so games don't see two of them."
checked={forwarding}
onChange={(v) => patch({ gamepad_forwarding: v })}
/>
<SelectRow
label="Controller type"
description="The virtual pad the host creates. Automatic matches the controller you're holding."
options={GAMEPADS}
value={s.gamepad}
onChange={(v) => patch({ gamepad: v })}
disabled={!forwarding}
indent
/>
{forwarding && (s.gamepad === "steamdeck" || s.gamepad === "auto") && (
<Field
label="⚠ Disable Steam Input"
description="On a Deck, Automatic forwards the built-in controller as a Steam Deck pad — paddles, both trackpads, and gyro included. For that, Steam Input must be OFF for Punktfunk: on the game page tap ⚙ → Controller Settings → set Steam Input to Off. Otherwise Steam keeps the Deck's controls and only the sticks + buttons reach the host."
indentLevel={1}
/>
)}
</div>
);
};
const PointerPage: FC<PageCtx> = ({ s, patch }) => (
<div style={pageBody}>
<SelectRow
label="Touch mode"
description="How the touchscreen drives the host: Trackpad (relative cursor, tap to click), Direct pointer (the cursor jumps to your finger), or Touch passthrough (every finger is a host contact — only helps apps that understand touch)."
options={TOUCH_MODES}
value={s.touch_mode ?? "trackpad"}
onChange={(v) => patch({ touch_mode: v })}
/>
<SelectRow
label="Mouse mode"
description="How a physical mouse drives the host: Capture locks the pointer for games, Desktop leaves it free and sends absolute positions. Ctrl+Alt+Shift+M switches it live mid-stream."
options={MOUSE_MODES}
value={s.mouse_mode ?? "capture"}
onChange={(v) => patch({ mouse_mode: v })}
/>
<ToggleField
label="Invert scroll direction"
description="Reverses the wheel and trackpad scroll direction sent to the host."
checked={s.invert_scroll ?? false}
onChange={(v) => patch({ invert_scroll: v })}
/>
<ToggleField
label="Capture system shortcuts"
description="Sends Alt+Tab, Super and friends to the host while input is captured, instead of leaving them to the local desktop. Gaming Mode is gamescope, which has no shortcuts to hold back — this is for a keyboard attached to the Deck in Desktop Mode, and for the desktop client sharing these settings."
checked={s.inhibit_shortcuts}
onChange={(v) => patch({ inhibit_shortcuts: v })}
/>
</div>
);
const InterfacePage: FC<PageCtx> = ({ s, patch }) => {
// `Settings::stats_verbosity`: no tier = a pre-tier store, resolved through the legacy bool,
// which itself defaults to true.
const statsTier = s.stats_verbosity ?? ((s.show_stats ?? true) ? "normal" : "off");
return (
<div style={pageBody}>
<SelectRow
label="Statistics overlay"
description="How much the in-stream overlay shows: Compact (fps · latency · bitrate on one line) → Normal → Detailed. A three-finger tap on the touchscreen cycles it mid-stream."
options={STATS_TIERS}
value={statsTier}
// Both keys, in sync — the same pairing `Settings::set_stats_verbosity` keeps, so a
// client too old for the tiers still honours an Off chosen here.
onChange={(v) => patch({ stats_verbosity: v, show_stats: v !== "off" })}
/>
<ToggleField
label="Wake hosts automatically"
description="Send Wake-on-LAN to a sleeping host before connecting and wait for it to boot. Turn it off for hosts reached over a VPN, where an offline-looking host is really just unreachable by broadcast and the wait only adds delay."
checked={s.auto_wake ?? true}
onChange={(v) => patch({ auto_wake: v })}
/>
<ToggleField
label="Show game library in the client"
description="Lets the client's own host cards browse a paired host's games. This plugin's library browser works either way — this is for the client's screens."
checked={s.library_enabled ?? false}
onChange={(v) => patch({ library_enabled: v })}
/>
<ToggleField
label="Start streams fullscreen"
description="Streams open fullscreen instead of windowed. Launches from this plugin are always fullscreen whatever this says — it's here because the desktop client reads the same settings."
checked={s.fullscreen_on_stream ?? true}
onChange={(v) => patch({ fullscreen_on_stream: v })}
/>
</div>
);
};
// ----------------------------------------------------------------------------------------
export const SettingsSection: FC = () => {
const [s, setS] = useState<StreamSettings | null>(null);
// null until the enumeration answers — the pickers show a loading state rather than briefly
// claiming this device has no endpoints.
const [devices, setDevices] = useState<DeviceLists | null>(null);
const [reading, setReading] = useState(true);
const readDevices = (again: boolean) => {
setReading(true);
void (again ? refreshDevices() : listDevices())
.then(setDevices)
.finally(() => setReading(false));
};
useEffect(() => {
void getSettings().then(setS);
// Deliberately not awaited together with the settings: a cold flatpak initialising Vulkan
// takes seconds, and the rest of the screen must not wait for it.
readDevices(false);
}, []);
const patch = (p: Partial<StreamSettings>) => {
setS((cur) => {
if (!cur) return cur;
const next = { ...cur, ...p };
void setSettings(next);
return next;
});
};
if (!s) return <Spinner style={{ height: "1.5em" }} />;
const ctx: PageCtx = { s, patch, devices, reading, readDevices };
return (
<SidebarNavigation
// We are already inside the plugin's own `/punktfunk` route, rendered in a tab. Route
// reporting would have this nav push entries of its own onto the router and fight the
// page for the back gesture; the pages are addressed by `identifier` instead.
disableRouteReporting
pages={[
{ title: "Stream", identifier: "stream", icon: <FaDesktop />, content: <StreamPage {...ctx} /> },
{ title: "Video", identifier: "video", icon: <FaVideo />, content: <VideoPage {...ctx} /> },
{
title: "Presentation",
identifier: "presentation",
icon: <FaTv />,
content: <PresentationPage {...ctx} />,
},
{ title: "Audio", identifier: "audio", icon: <FaVolumeUp />, content: <AudioPage {...ctx} /> },
{
title: "Controllers",
identifier: "controllers",
icon: <FaGamepad />,
content: <ControllersPage {...ctx} />,
},
{
title: "Touch & mouse",
identifier: "pointer",
icon: <FaHandPointer />,
content: <PointerPage {...ctx} />,
},
{
title: "Interface",
identifier: "interface",
icon: <FaSlidersH />,
content: <InterfacePage {...ctx} />,
},
]}
/>
);
};
+64 -59
View File
@@ -8,16 +8,16 @@
//
// TWO shortcuts, both named "Punktfunk" (so they share ONE Steam Input controller-config key —
// see applyControllerConfig):
// • STREAM — hidden, stateful: the per-session launcher. Its launch options carry the host /
// pinned game (PF_HOST/PF_LAUNCH/PF_BROWSE), rewritten per launch, so one shortcut serves
// every host. Driven by the QAM/pins/host-library actions. Hidden — an implementation detail.
// • STREAM — hidden, stateful: the per-session launcher. Its launch options carry the host
// reference and the card's profile (PF_REF/PF_PROFILE/PF_REQUEST_ACCESS), rewritten per
// launch, so one shortcut serves every host. Hidden — an implementation detail.
// • GAMEPAD UI — visible, stateless: fixed launch options = bare `--browse` (PF_BROWSE, no
// host) → the client's console home (host picker + pairing + settings, gamepad-navigable).
// This is the library-visible "Punktfunk" app the user opens directly.
//
// Both get the shipped artwork and the native-touch controller config.
import { applyControllerConfig, runnerInfo, shortcutArt, wake } from "./backend";
import { applyControllerConfig, runnerInfo, shortcutArt } from "./backend";
// SteamClient is a Steam-internal global injected into the CEF context; it is not fully typed
// by @decky/ui, so declare the surface we use. Signatures verified against MoonDeck + the
@@ -257,11 +257,12 @@ export async function ensureGamepadUiShortcut(): Promise<number | null> {
}
const startDir = info.runner.replace(/\/[^/]*$/, "");
void ensureControllerConfig();
// Bare browse: PF_BROWSE with no PF_HOST → the wrapper runs `--browse --fullscreen` (console
// home). %command% expands to the shortcut exe (/bin/sh); the wrapper rides behind as an arg.
// PF_CLIENT_BIN only when the backend resolved a NATIVE client — else the wrapper's flatpak
// default stands and this shortcut is exactly what it always was.
const clientBin = info.client_bin ? `PF_CLIENT_BIN=${info.client_bin} ` : "";
// PF_BROWSE → the wrapper runs the SESSION's `--browse --fullscreen` (console home), which is
// the one branch this rework deliberately left alone. %command% expands to the shortcut exe
// (/bin/sh); the wrapper rides behind as an arg. PF_CLIENT_BIN only when the backend resolved
// a NATIVE client — else the wrapper's flatpak default stands and this shortcut is exactly
// what it always was.
const clientBin = safeClientBin(info.client_bin) ? `PF_CLIENT_BIN=${info.client_bin} ` : "";
const launchOpts = `${clientBin}PF_BROWSE=1 %command% "${info.runner}"`;
// Reuse the remembered entry only if it still exists; a stale appId (deleted shortcut whose
@@ -319,77 +320,81 @@ export async function launchGamepadUi(): Promise<void> {
}
}
/** Per-launch extras beyond the host target (all optional — {} is the plain stream). */
/** Per-launch extras beyond the host reference (all optional — {} is the plain stream). */
export interface LaunchOpts {
/** Library id to launch on connect (a pinned game) — rides PF_LAUNCH → `--launch`. */
launchId?: string;
/** Open the gamepad library launcher instead of streaming (PF_BROWSE → `--browse`). */
browse?: boolean;
/** Management-API port for the launcher's library fetch (PF_MGMT; 0/absent = default). */
mgmt?: number;
/** A pinned card: stream with this settings profile, one-off (PF_PROFILE → `--profile`). */
profileId?: string;
/**
* Ask the host's operator to admit this Deck rather than typing a PIN (PF_REQUEST_ACCESS).
* The connect PARKS until somebody approves it, and the launch runs SUPERVISED see the
* wrapper for why `--exec` is dropped on this path alone.
*/
requestAccess?: boolean;
}
// Launch ids ride Steam launch options as an env-prefix token (`PF_LAUNCH=<id>`), so they
// must be space/quote-free — Steam's tokenizer and the wrapper's env both break otherwise.
// Real ids are `steam:<digits>` / `custom:<slug>`, so this rejects nothing in practice;
// it's VALIDATION, never encoding (the host must match the opaque token verbatim).
const UNSAFE_LAUNCH_ID = /["'\\$`\s]/;
// Host refs and profile ids ride Steam launch options as env-prefix tokens (`PF_REF=<ref>`),
// so they must be space/quote-free — Steam's tokenizer and the wrapper's env both break
// otherwise. Real values are UUIDs or `addr:port`, so this rejects nothing in practice; it is
// VALIDATION, never encoding (the client must receive the opaque token verbatim).
const UNSAFE_TOKEN = /["'\\$`\s]/;
export function isSafeLaunchId(id: string): boolean {
return (
id.length > 0 &&
id.length <= 128 &&
UNSAFE_LAUNCH_ID.exec(id) === null &&
UNSAFE_TOKEN.exec(id) === null &&
/^[\x21-\x7e]+$/.test(id)
);
}
/**
* Launch a stream to `host:port` fullscreen in Gaming Mode (optionally straight into a
* library title, or into a host's gamepad library). Encodes the target into the STREAM
* shortcut's launch options (so one hidden shortcut serves every host and every pinned game),
* then RunGame.
* Is a resolved native-client path safe to put in Steam's launch options? Same rule, separate
* name because the failure is different: an unsafe id is a bug in our own data, an unsafe path
* is just where the user installed the client so the browse shortcut degrades to its flatpak
* default rather than refusing to exist.
*/
export async function launchStream(
host: string,
port: number,
opts: LaunchOpts = {},
): Promise<void> {
// Wake-on-LAN: if this host is asleep, nudge it awake before the stream connects. Kicked off now
// so it races with the shortcut setup (near-zero added latency); its outcome is needed below
// (the connect budget), and RunGame follows the await either way, so nothing is slower for it.
// Best-effort — the flatpak client's --wake looks up the host's learned MAC (a no-op if none is
// known), and the connect that follows has its own retry window, so a failure never blocks launch.
const waking = wake(host, port).catch(() => ({ ok: false }));
const [{ appId, runner, clientBin }, woke] = await Promise.all([ensureStreamShortcut(), waking]);
const target = port && port !== 9777 ? `${host}:${port}` : host;
const env = [`PF_HOST=${target}`];
function safeClientBin(bin: string | undefined): bin is string {
return !!bin && isSafeLaunchId(bin);
}
/**
* Stream `ref` fullscreen in Gaming Mode, optionally with a pinned card's profile. Encodes the
* target into the STREAM shortcut's launch options one hidden shortcut serves every host
* then RunGame.
*
* No Wake-on-LAN here any more. The plugin used to fire a magic packet itself and then stretch
* the connect budget to 75 s to cover the host's resume, which was a workaround for the era
* before the CLI existed. `punktfunk launch` now runs the real wake-and-wait loop (packet at
* t=0, re-sent every 6 s, presence polled every second) and only dials once the host answers
* strictly better, and it deletes a backend method, a frontend call and a shell branch.
*/
export async function launchStream(ref: string, opts: LaunchOpts = {}): Promise<void> {
if (!isSafeLaunchId(ref)) {
throw new Error(`unsupported host reference: ${ref}`);
}
if (opts.profileId && !isSafeLaunchId(opts.profileId)) {
throw new Error(`unsupported profile id: ${opts.profileId}`);
}
const { appId, runner, clientBin } = await ensureStreamShortcut();
const env = [`PF_REF=${ref}`];
// Set only for a NATIVE client install; absent, the wrapper takes its flatpak default, so every
// existing Deck install produces byte-identical launch options to before.
if (clientBin) {
// The one launch-option value that comes from the backend rather than a store id, and so
// the one that could carry a space: a path like `/home/deck/my apps/punktfunk-client` would
// split Steam's tokenizer and land its tail in front of %command% as a bogus env token.
if (!isSafeLaunchId(clientBin)) {
throw new Error(`client path can't ride Steam's launch options: ${clientBin}`);
}
env.push(`PF_CLIENT_BIN=${clientBin}`);
}
// A magic packet actually went out (a MAC was known), so the host may be mid-resume from
// suspend — that takes far longer than the client's default 15 s connect budget. Stretch the
// budget so the client's wake-tolerant dial keeps retrying across the resume; against an
// already-awake host the connect still lands in under a second, so this costs nothing.
if (woke.ok) {
env.push("PF_CONNECT_TIMEOUT=75");
if (opts.profileId) {
env.push(`PF_PROFILE=${opts.profileId}`);
}
if (opts.browse) {
env.push("PF_BROWSE=1");
if (opts.mgmt) {
env.push(`PF_MGMT=${Math.floor(opts.mgmt)}`);
}
} else if (opts.launchId) {
if (!isSafeLaunchId(opts.launchId)) {
// Enforced at pin time too (the picker disables Pin) — this is the backstop.
throw new Error(`unsupported launch id: ${opts.launchId}`);
}
env.push(`PF_LAUNCH=${opts.launchId}`);
if (opts.requestAccess) {
env.push("PF_REQUEST_ACCESS=1");
}
// KEY=value ... %command% args — %command% expands to the shortcut exe (/bin/sh); the wrapper
// script rides behind it as an argument and reads PF_* from the environment. The wake was
// awaited above, so the magic packet is out before the connect attempt.
// script rides behind it as an argument and reads PF_* from the environment.
SteamClient.Apps.SetAppLaunchOptions(appId, `${env.join(" ")} %command% "${runner}"`);
SteamClient.Apps.RunGame(gameIdFromAppId(appId), "", -1, 100);
}
+164
View File
@@ -0,0 +1,164 @@
// The trust sheet — the step between "I can see a host" and "I can stream it".
//
// Two ways in, in the order the GTK dialog and the console's pair screen offer them:
//
// • REQUEST ACCESS (default) — no PIN. Save the host with the fingerprint it ADVERTISED,
// then launch. The host parks that connect until its operator approves this Deck in the
// console or web UI, admits it, and the stream starts by itself. It is not a second
// pairing ceremony; it is an ordinary identified connect with a stretched budget, which
// is why it costs no ceremony surface here at all.
// • USE A PIN INSTEAD — the existing gamepad-navigable keypad (pair.tsx).
//
// NO FINGERPRINT, NO REQUEST ACCESS. The parked connect pins the advertised fingerprint, and
// that pin is the only thing standing between a 185 s wait and an impostor answering for the
// host. A host typed in by address advertises nothing, so it gets the PIN path only — and is
// told why, rather than being shown a button that could only fail. Under no circumstances does
// this sheet trust-on-first-use its way past a missing fingerprint.
import { DialogButton, Focusable, ModalRoot, Spinner, showModal } from "@decky/ui";
import { toaster } from "@decky/api";
import { FC, useRef, useState } from "react";
import { trustHost } from "./backend";
import { HostView } from "./hooks";
import { PairModal } from "./pair";
/** User-facing copy for a `trustHost` failure code. */
function trustErrorBody(error: string | undefined, name: string): string {
switch (error) {
case "refused":
return `${name} is already saved under a different identity. Forget it in the Punktfunk app before trusting it again.`;
case "client-outdated":
return "Update the Punktfunk client to use request access.";
case "client-unavailable":
return "Couldnt reach the Punktfunk client — is it still installed?";
default:
return `Couldnt save ${name}.`;
}
}
export const TrustSheet: FC<{
host: HostView;
closeModal?: () => void;
/** Stream this host, having just been let in. */
onStream: (opts: { requestAccess?: boolean }) => void;
/** Re-read the host list — the record changed underneath the panel. */
onChanged: () => void;
}> = ({ host, closeModal, onStream, onChanged }) => {
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
// ⚠ This sheet is a `showModal` PORTAL: it captures its callbacks ONCE and never re-renders
// from panel state. Anything it needs to act on later must be read through a ref, not out of
// a captured value — reading a captured array is exactly what made pinning a second game
// compute from a stale base and clobber the first.
const props = useRef({ host, onStream, onChanged });
props.current = { host, onStream, onChanged };
// Request access pins what the host ADVERTISES. The record's own pin is a different thing:
// a host that already has one streams without ever opening this sheet.
const hasIdentity = host.advertisedFp !== "";
// A host advertising `pair=optional` admits anyone who pins its identity — there is no
// operator decision to wait for, and asking for one would be a wait that never ends and a
// record claiming somebody approved this Deck when nobody did. `paired` means the PIN
// ceremony or a real approval; the desktop client records exactly this case as *trusted*.
const needsApproval = host.pairPolicy !== "optional";
const canRequestAccess = hasIdentity && needsApproval;
const canTrustDirectly = hasIdentity && !needsApproval;
/**
* Pin the advertised identity, then stream.
*
* `approval` is what differs between the two doors, and it is not cosmetic: it decides whether
* the launch waits ~185 s for an operator AND whether the record ends up marked paired.
*/
const letIn = async (approval: boolean) => {
setBusy(true);
setError(null);
const { host: h, onStream: stream, onChanged: changed } = props.current;
try {
// Step 1: save it with the ADVERTISED fingerprint, pinned but unpaired ("trusted").
// Idempotent, so a retry after a declined approval is free.
const r = await trustHost(h.addr, h.port, h.advertisedFp, h.name);
if (!r.ok) {
setError(trustErrorBody(r.error, h.name));
setBusy(false);
return;
}
changed();
// Step 2: the launch. Under approval it PARKS — and the session's plain connecting screen
// looks identical whether it is parked or hanging, so say what is about to happen BEFORE
// it starts. That toast is a patch over that, and the real fix belongs in the session.
if (approval) {
toaster.toast({
title: "Punktfunk",
body: `Approve this Deck in ${h.name}s console — the stream starts by itself`,
duration: 10_000,
});
}
stream({ requestAccess: approval });
closeModal?.();
} catch (e) {
setError(String(e));
setBusy(false);
}
};
const usePin = () => {
// Hand off to the keypad. Closing first keeps one modal on screen at a time, which is what
// the gamepad focus model expects.
const { host: h, onStream: stream, onChanged: changed } = props.current;
closeModal?.();
showModal(
<PairModal
host={h}
onPaired={() => {
changed();
stream({});
}}
/>,
);
};
return (
<ModalRoot closeModal={closeModal}>
<div style={{ fontWeight: "bold", fontSize: "1.3em", marginBottom: "0.3em" }}>
Connect to {host.name}
</div>
<div style={{ opacity: 0.8, marginBottom: "1em" }}>
{!hasIdentity
? "No advertised identity for this host — pair with a PIN instead."
: canTrustDirectly
? `${host.name} accepts new devices. Connecting pins its identity so later streams are silent.`
: `${host.name} needs to let this device in before it can stream.`}
</div>
{error && (
<div style={{ color: "#ff6b6b", marginBottom: "0.6em" }}>{error}</div>
)}
<Focusable style={{ display: "flex", flexDirection: "column", gap: "0.5em" }}>
{canRequestAccess && (
<DialogButton disabled={busy} onClick={() => void letIn(true)}>
{busy ? <Spinner style={{ height: "1em" }} /> : "Request access"}
</DialogButton>
)}
{canTrustDirectly && (
<DialogButton disabled={busy} onClick={() => void letIn(false)}>
{busy ? <Spinner style={{ height: "1em" }} /> : "Connect"}
</DialogButton>
)}
<DialogButton disabled={busy} onClick={usePin}>
Use a PIN instead
</DialogButton>
<DialogButton disabled={busy} onClick={() => closeModal?.()}>
Cancel
</DialogButton>
</Focusable>
{canRequestAccess && (
<div style={{ opacity: 0.6, fontSize: "0.85em", marginTop: "0.8em" }}>
Request access asks {host.name}s operator to approve this Deck in its console or web
UI. No PIN to type the stream starts as soon as they do.
</div>
)}
</ModalRoot>
);
};
-46
View File
@@ -1,46 +0,0 @@
// Shared UI primitives for the fullscreen page + modals. The one rule that keeps every row
// looking consistent: a Field's action(s) always sit right-aligned, with real space between
// them and the label text — never hugging it.
//
// Decky lays a Field out as `[ label .......... children ]`. When the children container is
// grown (`childrenContainerWidth="max"`, which we want so multi-button clusters have room), a
// bare `fit-content` button LEFT-aligns inside that grown container and ends up pressed against
// the label with the space wasted to its right. Wrapping the action(s) in `RowActions` pushes
// them to the right edge and evenly spaces multiples — the same treatment every row now gets.
import { Focusable } from "@decky/ui";
import { CSSProperties, FC, ReactNode } from "react";
export const RowActions: FC<{ children: ReactNode }> = ({ children }) => (
<Focusable
style={{
display: "flex",
gap: "0.5em",
justifyContent: "flex-end",
alignItems: "center",
}}
>
{children}
</Focusable>
);
// A single action button sized to its content (not the gamepad-UI default of 100% width), with
// a floor so short labels ("Pair", "Remove") don't render as tiny nubs and every row's button
// reads at the same weight.
export const actionButton: CSSProperties = {
width: "fit-content",
minWidth: "7em",
flexShrink: 0,
};
// Square icon-only button (details ⓘ, header back arrow). Needs an explicit height or the zero
// padding collapses it to the icon's line height.
export const iconButton: CSSProperties = {
width: "40px",
minWidth: "40px",
height: "40px",
padding: 0,
flexShrink: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
};
+75 -1
View File
@@ -157,6 +157,20 @@ mod index {
GAMEPADS.iter().position(|&g| g == s.gamepad).unwrap_or(0) as u32
}
pub fn system_buttons(s: &Settings) -> u32 {
SYSTEM_BUTTONS
.iter()
.position(|&v| v == s.system_buttons)
.unwrap_or(0) as u32
}
pub fn guide_gesture(s: &Settings) -> u32 {
GUIDE_GESTURES
.iter()
.position(|&v| v == s.guide_gesture)
.unwrap_or(0) as u32
}
pub fn present_priority(s: &Settings) -> u32 {
// Unknown values (a newer client's intent) read as the default, exactly as
// `PresentPriority::resolve` treats them.
@@ -642,6 +656,12 @@ fn commit_profile(active: &StreamProfile, touched: &Touched, values: &Settings)
if touched.has("gamepad_forwarding") {
o.gamepad_forwarding = Some(values.gamepad_forwarding);
}
if touched.has("system_buttons") {
o.system_buttons = Some(values.system_buttons.clone());
}
if touched.has("guide_gesture") {
o.guide_gesture = Some(values.guide_gesture.clone());
}
if touched.has("stats_verbosity") {
o.stats_verbosity = Some(values.stats_verbosity());
}
@@ -687,6 +707,15 @@ const GAMEPADS: &[&str] = &[
"dualshock4",
"steamdeck",
];
/// System-button routing values (persisted under the cross-client `system_buttons` key):
/// where the guide (Xbox/PS/Steam) and quick-access presses land while streaming. Auto =
/// the host, except under Gaming Mode where the local Steam UI reacts to the same press.
const SYSTEM_BUTTONS: &[&str] = &["auto", "forward", "local"];
const SYSTEM_BUTTON_LABELS: &[&str] = &["Automatic", "Send to host", "This device"];
/// Hold-Select guide gesture values (the cross-client `guide_gesture` key). Auto arms it
/// only where the raw guide press can't reach the host (Gaming Mode here).
const GUIDE_GESTURES: &[&str] = &["auto", "on", "off"];
const GUIDE_GESTURE_LABELS: &[&str] = &["Automatic", "On", "Off"];
const COMPOSITORS: &[&str] = &["auto", "kwin", "wlroots", "mutter", "gamescope"];
/// Codec setting values (persisted) paired with their display labels below. PyroWave is
/// preference-only by design (`Settings::preferred_codec`) — the ladder falls back to
@@ -1542,16 +1571,39 @@ pub fn show_scoped(
"Steam Deck",
],
);
// Both pad rows only mean something while something is being forwarded (the same
// Where the guide (Xbox/PS/Steam) + quick-access presses land, and the hold-Select
// gesture that keeps the host's guide reachable when they stay local. Desktop rarely
// needs either off Automatic — they exist here because profiles are authored on the
// desktop and applied everywhere, Gaming Mode included.
let sysbtn_row = ChoiceRow::new(
&dialog,
inline,
"Steam / guide button",
"Automatic sends it to the host, except where this device reacts to it too",
SYSTEM_BUTTON_LABELS,
);
let gesture_row = ChoiceRow::new(
&dialog,
inline,
"Hold Select for guide",
"Hold Select alone for the host's guide button — a tap still goes through",
GUIDE_GESTURE_LABELS,
);
// The pad rows only mean something while something is being forwarded (the same
// relationship mic → echo cancellation draws just above, initial state included: the
// seed's `set_active` fires this only when it CHANGES the switch).
{
let (f, t) = (forward_row.widget().clone(), pad_row.widget().clone());
let (sb, gg) = (sysbtn_row.widget().clone(), gesture_row.widget().clone());
f.set_sensitive(seed.gamepad_forwarding);
t.set_sensitive(seed.gamepad_forwarding);
sb.set_sensitive(seed.gamepad_forwarding);
gg.set_sensitive(seed.gamepad_forwarding);
pad_forward_row.connect_active_notify(move |r| {
f.set_sensitive(r.is_active());
t.set_sensitive(r.is_active());
sb.set_sensitive(r.is_active());
gg.set_sensitive(r.is_active());
});
}
@@ -1566,6 +1618,8 @@ pub fn show_scoped(
bitrate_row.set_value(f64::from(s.bitrate_kbps) / 1000.0);
pad_forward_row.set_active(s.gamepad_forwarding);
pad_row.set_selected(index::gamepad(s));
sysbtn_row.set_selected(index::system_buttons(s));
gesture_row.set_selected(index::guide_gesture(s));
let touch_i = index::touch(s);
touch_row.set_selected(touch_i);
// set_selected never fires the changed hook, so seed the dynamic caption directly.
@@ -1795,6 +1849,18 @@ pub fn show_scoped(
index::surround
);
choice!(pad_row, "gamepad", o.gamepad.is_some(), index::gamepad);
choice!(
sysbtn_row,
"system_buttons",
o.system_buttons.is_some(),
index::system_buttons
);
choice!(
gesture_row,
"guide_gesture",
o.guide_gesture.is_some(),
index::guide_gesture
);
toggle!(
pad_forward_row,
"gamepad_forwarding",
@@ -2001,6 +2067,8 @@ pub fn show_scoped(
controllers_group.add(forward_row.widget());
}
controllers_group.add(pad_row.widget());
controllers_group.add(sysbtn_row.widget());
controllers_group.add(gesture_row.widget());
controllers.add(&controllers_group);
// Cap every caption in one pass, after the rows exist: a per-row call would be sixteen
@@ -2040,6 +2108,12 @@ pub fn show_scoped(
if pad_sel != 0 || GAMEPADS.contains(&s.gamepad.as_str()) {
s.gamepad = GAMEPADS[pad_sel].to_string();
}
s.system_buttons = SYSTEM_BUTTONS
[(sysbtn_row.selected() as usize).min(SYSTEM_BUTTONS.len() - 1)]
.to_string();
s.guide_gesture = GUIDE_GESTURES
[(gesture_row.selected() as usize).min(GUIDE_GESTURES.len() - 1)]
.to_string();
s.touch_mode =
TOUCH_MODES[(touch_row.selected() as usize).min(TOUCH_MODES.len() - 1)].to_string();
s.mouse_mode =
+95 -3
View File
@@ -18,6 +18,78 @@
#[cfg(all(any(target_os = "linux", windows), feature = "ui"))]
mod console;
/// The session control socket: a line-per-connection unix socket other same-user
/// processes use to poke the RUNNING stream — today two verbs, `guide` and `qam`, which
/// press the HOST's system buttons (the Decky panel's "Steam menu / Quick access on the
/// host" buttons; see `GamepadService::tap_guide`). Plain text, no JSON: `<verb>\n` in,
/// `ok\n` / `err\n` back.
///
/// The path is `$XDG_RUNTIME_DIR/punktfunk-session-ctl.sock` — inside the flatpak app
/// runtime dir (`…/app/$FLATPAK_ID/`) when sandboxed, the ONE runtime path a flatpak and
/// the host see identically, which is what lets the Decky backend (outside the sandbox)
/// reach a flatpak-run session.
#[cfg(all(unix, any(target_os = "linux", windows)))]
mod ctl_socket {
use pf_client_core::gamepad::GamepadService;
use std::io::{BufRead, BufReader, Write};
use std::os::unix::net::UnixListener;
use std::path::PathBuf;
fn path() -> Option<PathBuf> {
let mut p = PathBuf::from(std::env::var_os("XDG_RUNTIME_DIR")?);
if let Ok(id) = std::env::var("FLATPAK_ID") {
p.push("app");
p.push(id);
}
Some(p.join("punktfunk-session-ctl.sock"))
}
/// Bind + serve on a background thread, once per process (later calls no-op). Any
/// failure just logs at debug — the socket is a convenience surface, never worth
/// failing a stream over.
pub(crate) fn spawn(gamepad: GamepadService) {
static ONCE: std::sync::Once = std::sync::Once::new();
ONCE.call_once(move || {
let Some(path) = path() else { return };
// A previous session's socket file refuses the bind — it's ours to replace.
let _ = std::fs::remove_file(&path);
let listener = match UnixListener::bind(&path) {
Ok(l) => l,
Err(e) => {
tracing::debug!(error = %e, path = %path.display(), "session ctl socket unavailable");
return;
}
};
let spawned = std::thread::Builder::new()
.name("pf-session-ctl".into())
.spawn(move || {
for stream in listener.incoming() {
let Ok(mut s) = stream else { continue };
let mut line = String::new();
if BufReader::new(&s).read_line(&mut line).is_err() {
continue;
}
let ok = match line.trim() {
"guide" => {
gamepad.tap_guide();
true
}
"qam" => {
gamepad.tap_qam();
true
}
_ => false,
};
let _ = s.write_all(if ok { b"ok\n" } else { b"err\n" });
}
});
if let Err(e) = spawned {
tracing::debug!(error = %e, "session ctl thread failed to start");
}
});
}
}
#[cfg(any(target_os = "linux", windows))]
mod session_main {
use pf_client_core::gamepad::GamepadService;
@@ -44,14 +116,20 @@ mod session_main {
std::env::args().any(|a| a == flag)
}
/// Running under Gaming Mode (a Deck, or any gamescope session): the environment
/// where the local Steam UI owns the physical Steam/QAM buttons — the system-button
/// "auto" policy keys off this.
pub(crate) fn gaming_mode() -> bool {
std::env::var_os("SteamDeck").is_some()
|| std::env::var_os("GAMESCOPE_WAYLAND_DISPLAY").is_some()
}
/// Run fullscreen: `--fullscreen`, or the Deck/gamescope env as a fallback so a
/// manual launch under Gaming Mode does the right thing too. (Browse-mode only —
/// gated with `mod browse`, its one caller.)
#[cfg(feature = "ui")]
pub(crate) fn fullscreen_mode() -> bool {
arg_flag("--fullscreen")
|| std::env::var_os("SteamDeck").is_some()
|| std::env::var_os("GAMESCOPE_WAYLAND_DISPLAY").is_some()
arg_flag("--fullscreen") || gaming_mode()
}
/// `--window-pos X,Y` → the window's top-left in desktop coordinates (a spawning
@@ -194,6 +272,20 @@ mod session_main {
// it back. It goes on before the attach below, so a non-forwarding session never opens
// — never grabs — the device.
gamepad.set_forwarding(settings.gamepad_forwarding);
// System-button routing: whether raw guide/QAM presses ride the wire, and whether
// hold-Select arms as the alternate guide route. Auto keys off Gaming Mode — the
// local Steam UI reacts to the same physical buttons there no matter what, so
// forwarding raw opens BOTH overlays, the local one on top of the stream. Set
// unconditionally for the same browse-mode-reuse reason as the line above.
let game_mode = gaming_mode();
gamepad.set_system_buttons(
settings.system_buttons_forward(game_mode),
settings.guide_gesture_enabled(game_mode),
);
// The control socket (guide/QAM injection — the Decky panel's host buttons).
// Spawned at first params-build so it exists for --connect AND console launches.
#[cfg(unix)]
crate::ctl_socket::spawn(gamepad.clone());
let mode = Mode {
width: if settings.width == 0 {
native.width
+72 -1
View File
@@ -79,6 +79,17 @@ const GAMEPADS: &[(&str, &str)] = &[
// user could not ask the host for the Deck-shaped pad (trackpads, back grips).
("steamdeck", "Steam Deck"),
];
/// System-button routing: `(stored value, display label)` — where the guide (Xbox/PS)
/// and quick-access presses land while streaming. The cross-client `system_buttons` key;
/// Automatic forwards on desktop and stays local under Gaming Mode.
const SYSTEM_BUTTONS: &[(&str, &str)] = &[
("auto", "Automatic"),
("forward", "Send to host"),
("local", "This device"),
];
/// The hold-Select guide gesture: `(stored value, display label)` — the cross-client
/// `guide_gesture` key. Automatic arms it only where the raw press can't reach the host.
const GUIDE_GESTURES: &[(&str, &str)] = &[("auto", "Automatic"), ("on", "On"), ("off", "Off")];
/// Stats-overlay tiers: `(stored value, display label)` — the cross-client verbosity ladder
/// (Compact ⊂ Normal ⊂ Detailed); Ctrl+Alt+Shift+S cycles it live in the session window.
const STATS_TIERS: &[(StatsVerbosity, &str)] = &[
@@ -479,6 +490,8 @@ struct OverrideFlags {
inhibit_shortcuts: bool,
gamepad: bool,
gamepad_forwarding: bool,
system_buttons: bool,
guide_gesture: bool,
stats_verbosity: bool,
fullscreen_on_stream: bool,
present_priority: bool,
@@ -512,6 +525,8 @@ impl OverrideFlags {
inhibit_shortcuts: o.inhibit_shortcuts.is_some(),
gamepad: o.gamepad.is_some(),
gamepad_forwarding: o.gamepad_forwarding.is_some(),
system_buttons: o.system_buttons.is_some(),
guide_gesture: o.guide_gesture.is_some(),
stats_verbosity: o.stats_verbosity.is_some(),
fullscreen_on_stream: o.fullscreen_on_stream.is_some(),
present_priority: o.present_priority.is_some(),
@@ -966,6 +981,13 @@ pub(crate) fn settings_page(
s.forward_pad = key.unwrap_or_default();
s.save();
})
// Dimmed with the master switch above it, like echo cancellation under the mic
// (see that row) — this and the three below have nothing to act on while no
// controller is forwarded at all. Every commit bumps `rev` and re-renders this
// screen, so they follow the toggle live. Brings this client in line with how GTK
// (`set_sensitive`), the touch settings on both mobile clients (`enabled`) and the
// console UI (dim + refuse the step) have always drawn the same relationship.
.enabled(s.gamepad_forwarding)
};
let pad_forward_toggle =
setting_toggle(ctx, scope, (rev, set_rev), s.gamepad_forwarding, |s, on| {
@@ -976,7 +998,32 @@ pub(crate) fn settings_page(
});
let pad_combo = setting_combo(ctx, scope, (rev, set_rev), pad_names, pad_i, |s, i| {
s.gamepad = GAMEPADS[i].0.to_string();
});
})
.enabled(s.gamepad_forwarding);
let (sysbtn_names, sysbtn_i) = presets(SYSTEM_BUTTONS, |v| *v == s.system_buttons);
let sysbtn_combo = setting_combo(
ctx,
scope,
(rev, set_rev),
sysbtn_names,
sysbtn_i,
|s, i| {
s.system_buttons = SYSTEM_BUTTONS[i].0.to_string();
},
)
.enabled(s.gamepad_forwarding);
let (gesture_names, gesture_i) = presets(GUIDE_GESTURES, |v| *v == s.guide_gesture);
let gesture_combo = setting_combo(
ctx,
scope,
(rev, set_rev),
gesture_names,
gesture_i,
|s, i| {
s.guide_gesture = GUIDE_GESTURES[i].0.to_string();
},
)
.enabled(s.gamepad_forwarding);
let (touch_names, touch_i) = presets(TOUCH_MODES, |v| *v == s.touch_mode);
let touch_combo = setting_combo(ctx, scope, (rev, set_rev), touch_names, touch_i, |s, i| {
s.touch_mode = TOUCH_MODES[i].0.to_string();
@@ -1407,6 +1454,30 @@ pub(crate) fn settings_page(
\u{2014} a DualSense keeps adaptive triggers, lightbar, touchpad and \
motion.",
)),
Some(described_overridable(
(rev, set_rev),
scope,
"system_buttons",
"Steam / guide button",
over.system_buttons,
sysbtn_combo,
"Where the guide (Xbox/PS) and quick-access presses go while \
streaming. Automatic sends them to the host \u{2014} except on \
devices whose own overlay reacts to the same press (Gaming Mode), \
where they stay local and the gesture below reaches the host.",
)),
Some(described_overridable(
(rev, set_rev),
scope,
"guide_gesture",
"Hold Select for guide",
over.guide_gesture,
gesture_combo,
"Hold Select on its own to press the host's guide button \u{2014} keep \
holding for a Gaming-Mode host's quick-access menu. A Select tap \
still goes through, slightly delayed. Automatic arms it only where \
the real button can't reach the host.",
)),
]
.into_iter()
.flatten()
+186 -3
View File
@@ -4,6 +4,8 @@
//! cards and flip a saved host's online pip when its advert disappears.
use mdns_sd::{ServiceDaemon, ServiceEvent};
use std::collections::BTreeMap;
use std::time::{Duration, Instant};
#[derive(Clone, Debug)]
pub struct DiscoveredHost {
@@ -31,6 +33,19 @@ pub struct DiscoveredHost {
pub os: String,
}
impl DiscoveredHost {
/// The host's advertised stable id (mDNS TXT `id`), or `""` when it doesn't advertise one.
/// [`DiscoveredHost::key`] falls back to the mDNS fullname in that case, so the two being
/// equal is exactly the "no id" signal — read it through here rather than re-deriving it.
pub fn advertised_id(&self) -> &str {
if self.key == self.fullname {
""
} else {
&self.key
}
}
}
/// One discovery update for the UI's advert map.
pub enum DiscoveryEvent {
/// A host advert appeared or refreshed (new address, pairing flipped, …).
@@ -39,8 +54,8 @@ pub enum DiscoveryEvent {
Removed { fullname: String },
}
/// Browse continuously for the app's lifetime. The thread exits when the receiver is
/// dropped (the send fails) or the daemon dies.
/// Browse continuously. The worker exits when the returned receiver is dropped, or when the
/// daemon dies — checked on a tick, so it stops even on a LAN where no advert ever arrives.
pub fn browse() -> async_channel::Receiver<DiscoveryEvent> {
let (tx, rx) = async_channel::unbounded();
std::thread::Builder::new()
@@ -60,7 +75,24 @@ pub fn browse() -> async_channel::Receiver<DiscoveryEvent> {
return;
}
};
while let Ok(event) = receiver.recv() {
// Polled rather than blocked on: the worker has to notice that its consumer went
// away even when NOTHING is arriving, which is the normal state of a LAN with no
// hosts on it. A plain `recv()` parks forever there, and the ignored-event arm below
// never touches `tx` — so a bounded consumer like `discover_for` would leak this
// thread and its daemon (another thread, and a socket bound to :5353) on every call.
loop {
// Checked at the TOP so it also covers the arms below that `continue` without
// ever touching `tx` — the ignored event kinds, and an advert with no IPv4
// address. Those are the paths that would otherwise keep this thread alive with
// nobody to send to.
if tx.is_closed() {
break;
}
let event = match receiver.recv_timeout(Duration::from_millis(250)) {
Ok(event) => event,
Err(_) if receiver.is_disconnected() => break,
Err(_) => continue,
};
let update = match event {
ServiceEvent::ServiceResolved(info) => {
let props = info.get_properties();
@@ -117,3 +149,154 @@ pub fn browse() -> async_channel::Receiver<DiscoveryEvent> {
.expect("spawn mdns thread");
rx
}
/// The advert map one browse window folded down to. Kept separate from [`discover_for`] so the
/// fold — which is where dedupe and removal actually live — is testable without a network.
type Adverts = BTreeMap<String, DiscoveredHost>;
/// Apply one event to the map. A refreshed advert WINS over the one already there (it carries
/// the newer address — a host that changed DHCP lease re-announces), and a removal drops
/// whichever entry that mDNS fullname produced, whatever it was keyed under.
fn fold(adverts: &mut Adverts, event: DiscoveryEvent) {
match event {
DiscoveryEvent::Resolved(host) => {
adverts.insert(host.key.clone(), host);
}
DiscoveryEvent::Removed { fullname } => {
adverts.retain(|_, h| h.fullname != fullname);
}
}
}
/// Browse for `timeout`, then return what answered — deduped by `key`, address-sorted.
///
/// Blocking; intended for one-shot consumers (the CLI's `discover` verb, a plugin backend that
/// wants one bounded call rather than a stream). The streaming [`browse`] stays the UI's door:
/// a live hosts page wants adverts as they land, not a snapshot taken `timeout` after it opened.
pub fn discover_for(timeout: Duration) -> Vec<DiscoveredHost> {
let rx = browse();
let deadline = Instant::now() + timeout;
let mut adverts = Adverts::new();
while Instant::now() < deadline {
while let Ok(event) = rx.try_recv() {
fold(&mut adverts, event);
}
// A short tick rather than a blocking recv with a deadline: `async_channel`'s blocking
// receive has no timeout, and the whole point of this call is that it is bounded.
std::thread::sleep(Duration::from_millis(50).min(timeout));
}
while let Ok(event) = rx.try_recv() {
fold(&mut adverts, event);
}
// Dropping the receiver is what stops the worker — it polls for that, so this holds even
// when nothing is advertising. Without it a one-shot consumer would leak a browse per call.
drop(rx);
sorted(adverts)
}
/// The map as the list a caller gets: sorted by address, then port. IPv4 is compared
/// NUMERICALLY (a lexical sort puts `.10` before `.9`, which reads as scrambled in a host list).
fn sorted(adverts: Adverts) -> Vec<DiscoveredHost> {
let mut hosts: Vec<DiscoveredHost> = adverts.into_values().collect();
hosts.sort_by_key(|h| {
(
h.addr.parse::<std::net::Ipv4Addr>().ok().map(u32::from),
h.addr.clone(),
h.port,
)
});
hosts
}
#[cfg(test)]
mod tests {
use super::*;
fn host(key: &str, fullname: &str, addr: &str) -> DiscoveredHost {
DiscoveredHost {
key: key.into(),
fullname: fullname.into(),
name: fullname.split('.').next().unwrap_or("?").into(),
addr: addr.into(),
port: 9777,
fp_hex: "aa".into(),
pair: "required".into(),
mgmt_port: Some(47990),
mac: vec![],
os: String::new(),
}
}
/// Two adverts for the same host collapse to one row, and the LATER one wins — that is how
/// a host that moved to a new address stops being listed at the stale one.
#[test]
fn refreshed_advert_supersedes_the_earlier_one() {
let mut adverts = Adverts::new();
fold(
&mut adverts,
DiscoveryEvent::Resolved(host("id-1", "desk._punktfunk._udp.local.", "192.168.1.9")),
);
fold(
&mut adverts,
DiscoveryEvent::Resolved(host("id-1", "desk._punktfunk._udp.local.", "192.168.1.20")),
);
let out = sorted(adverts);
assert_eq!(out.len(), 1, "same key must not render twice");
assert_eq!(out[0].addr, "192.168.1.20", "the newer address wins");
}
/// A host that goes away during the browse window is not in the answer.
#[test]
fn removal_drops_the_advert_it_names() {
let mut adverts = Adverts::new();
fold(
&mut adverts,
DiscoveryEvent::Resolved(host("id-1", "desk._punktfunk._udp.local.", "192.168.1.9")),
);
fold(
&mut adverts,
DiscoveryEvent::Resolved(host("id-2", "tv._punktfunk._udp.local.", "192.168.1.10")),
);
fold(
&mut adverts,
DiscoveryEvent::Removed {
fullname: "desk._punktfunk._udp.local.".into(),
},
);
let out = sorted(adverts);
assert_eq!(out.len(), 1);
assert_eq!(out[0].key, "id-2");
}
/// A host with no `id` TXT is keyed by its fullname — and must not then report that
/// fullname as an id, which would send a caller launching against a nonexistent reference.
#[test]
fn advertised_id_is_empty_without_the_txt() {
let named = host("id-1", "desk._punktfunk._udp.local.", "10.0.0.1");
assert_eq!(named.advertised_id(), "id-1");
let anonymous = host(
"desk._punktfunk._udp.local.",
"desk._punktfunk._udp.local.",
"10.0.0.1",
);
assert_eq!(anonymous.advertised_id(), "");
}
/// Addresses sort the way a person reads them, not the way strings compare.
#[test]
fn addresses_sort_numerically() {
let mut adverts = Adverts::new();
for (i, addr) in ["192.168.1.20", "192.168.1.9", "192.168.1.100"]
.into_iter()
.enumerate()
{
fold(
&mut adverts,
DiscoveryEvent::Resolved(host(&format!("id-{i}"), &format!("h{i}."), addr)),
);
}
let out = sorted(adverts);
let addrs: Vec<&str> = out.iter().map(|h| h.addr.as_str()).collect();
assert_eq!(addrs, ["192.168.1.9", "192.168.1.20", "192.168.1.100"]);
}
}
+381 -2
View File
@@ -61,6 +61,23 @@ const ESCAPE_CHORD: [u32; 4] = [wire::BTN_LB, wire::BTN_RB, wire::BTN_START, wir
/// Hold the [`ESCAPE_CHORD`] at least this long to disconnect (escalates the leave-fullscreen press).
const DISCONNECT_HOLD: Duration = Duration::from_millis(1500);
/// Hold Select/Back ALONE at least this long to send the HOST the guide button — the
/// [`SelectGesture`], armed by [`Settings::guide_gesture`]. The synthetic guide stays down
/// for as long as Select is held, so a long hold IS the host's long-press (the QAM on a
/// Gaming-Mode host). Exists because on some platforms the physical guide press can never
/// reach the host cleanly: the local shell reserves it (iOS's Game Overlay, tvOS) or
/// reacts to it in parallel (Gaming Mode's Steam UI — see [`Settings::system_buttons`]).
///
/// [`Settings::guide_gesture`]: crate::trust::Settings::guide_gesture
/// [`Settings::system_buttons`]: crate::trust::Settings::system_buttons
const GUIDE_HOLD: Duration = Duration::from_millis(350);
/// A held-back Select TAP is delivered as a press with its release scheduled this far
/// behind — never back-to-back: per-transition sends are folded into seq'd `GamepadState`
/// snapshots by the core input task, and a down+up inside one fold window can coalesce
/// into no press at all.
const TAP_PRESS: Duration = Duration::from_millis(50);
/// Steam Deck actuator-decay keepalive cadence, declared to the core's rumble policy engine as an
/// [`ActuatorQuirks`] at slot open. The Deck's built-in actuator decays inside SDL's ~2 s internal
/// rumble resend (`SDL_RUMBLE_RESEND_MS`) and SDL short-circuits an identical `set_rumble` value
@@ -337,6 +354,8 @@ enum Ctl {
Pin(Option<String>),
KindOverride(GamepadPref),
Forwarding(bool),
SystemButtons { forward_raw: bool, gesture: bool },
TapButton(u32),
MenuMode(bool),
MenuRumble(MenuPulse),
}
@@ -503,6 +522,39 @@ impl GamepadService {
let _ = self.ctl.send(Ctl::Forwarding(on));
}
/// The session's system-button policy, resolved from
/// [`Settings::system_buttons_forward`] × [`Settings::guide_gesture_enabled`]:
/// `forward_raw` gates the physical guide/QAM presses onto the wire (off = they stay
/// with the local shell — the Gaming-Mode default, where Steam reacts to them no
/// matter what and forwarding opens BOTH overlays); `gesture` arms the hold-Select
/// guide gesture ([`GUIDE_HOLD`]), the alternate route that keeps the host's guide —
/// and, held longer, a Gaming-Mode host's QAM — reachable from a controller.
///
/// [`Settings::system_buttons_forward`]: crate::trust::Settings::system_buttons_forward
/// [`Settings::guide_gesture_enabled`]: crate::trust::Settings::guide_gesture_enabled
pub fn set_system_buttons(&self, forward_raw: bool, gesture: bool) {
let _ = self.ctl.send(Ctl::SystemButtons {
forward_raw,
gesture,
});
}
/// One-shot synthetic tap of the HOST's guide button ([`Ctl::TapButton`]): down now,
/// up [`TAP_PRESS`] later, on the first forwarded slot's wire index (pad 0 when none
/// is open). The session control socket's "press the host's Steam/guide button" verb
/// — the Decky panel's UI route to the host overlay. No-op while no session is
/// attached.
pub fn tap_guide(&self) {
let _ = self.ctl.send(Ctl::TapButton(wire::BTN_GUIDE));
}
/// Like [`Self::tap_guide`] for the quick-access button (`MISC1` — the Deck `…`).
/// Opens the QAM on a Gaming-Mode host whose virtual pad is Deck-shaped; other
/// virtual pads map it to their own misc button (or drop it) — harmless.
pub fn tap_qam(&self) {
let _ = self.ctl.send(Ctl::TapButton(wire::BTN_MISC1));
}
pub fn attach(&self, connector: Arc<NativeClient>) {
let _ = self.ctl.send(Ctl::Attach(connector));
}
@@ -552,6 +604,7 @@ impl GamepadPump {
/// chord-hold and haptics inside the threaded worker's tolerances).
pub fn tick(&mut self) {
let _ = self.worker.drain_ctl(&self.ctl_rx);
self.worker.gesture_poll();
self.worker.maybe_fire_disconnect();
self.worker.menu_poll();
self.worker.render_feedback();
@@ -698,6 +751,9 @@ struct Slot {
/// close lift a click held across detach/unplug.
held_clicks: [bool; 2],
last_accel: [i16; 3],
/// Hold-Select→guide state ([`SelectGesture`]) — only fed while the worker's
/// `guide_gesture` policy is on.
gesture: SelectGesture,
}
impl Slot {
@@ -713,6 +769,7 @@ impl Slot {
surface_last: [(0, 0, false); 2],
held_clicks: [false; 2],
last_accel: [0; 3],
gesture: SelectGesture::default(),
}
}
@@ -723,6 +780,98 @@ impl Slot {
}
}
/// Per-slot hold-Select→guide state machine (see [`GUIDE_HOLD`]). Pure — fed transitions
/// and polled with a clock, it emits the wire sends due as `(button bit, down)` pairs —
/// so the timing rules are testable without SDL or a live session.
///
/// The rules:
/// - Select pressed ALONE is held back (pending). Any other button already down means
/// Select is part of a combo — the escape chord ends in it — and passes through.
/// - A button pressed WHILE Select is pending makes it a real Select after all; its
/// deferred down goes out first, preserving chronology.
/// - Pending past [`GUIDE_HOLD`] becomes a synthetic guide, down until Select releases.
/// - Released before the threshold, it's a TAP: press delivered on release, the release
/// itself [`TAP_PRESS`] behind it (back-to-back transitions can fold into nothing).
#[derive(Default)]
struct SelectGesture {
/// Select is down and held back — tap-or-guide undecided.
pending_since: Option<Instant>,
/// The held-back Select became a synthetic guide; its release lifts the guide.
as_guide: bool,
/// A delivered tap's release is owed at this time.
release_due: Option<Instant>,
}
impl SelectGesture {
/// Select went down (`alone` = no other button held on this slot). Returns true when
/// the press is held back; false lets the caller forward it as a normal button.
fn on_select_down(&mut self, now: Instant, alone: bool, out: &mut Vec<(u32, bool)>) -> bool {
// A previous tap's scheduled release still owed: lift it before the new press.
if self.release_due.take().is_some() {
out.push((wire::BTN_BACK, false));
}
if alone {
self.pending_since = Some(now);
return true;
}
false
}
/// Another button went down on this slot: a pending Select is a real Select after
/// all — its deferred down goes out before the caller sends the new button's.
fn on_other_down(&mut self, out: &mut Vec<(u32, bool)>) {
if self.pending_since.take().is_some() {
out.push((wire::BTN_BACK, true));
}
}
/// Select released. Returns true when the gesture owned this release (the caller
/// skips the normal button-up send).
fn on_select_up(&mut self, now: Instant, out: &mut Vec<(u32, bool)>) -> bool {
if self.as_guide {
self.as_guide = false;
out.push((wire::BTN_GUIDE, false));
return true;
}
if self.pending_since.take().is_some() {
// A tap: deliver the held-back press now, its release TAP_PRESS behind.
out.push((wire::BTN_BACK, true));
self.release_due = Some(now + TAP_PRESS);
return true;
}
false
}
/// Clock-driven work: the hold threshold and the owed tap release.
fn poll(&mut self, now: Instant, out: &mut Vec<(u32, bool)>) {
if let Some(since) = self.pending_since {
if now.duration_since(since) >= GUIDE_HOLD {
self.pending_since = None;
self.as_guide = true;
out.push((wire::BTN_GUIDE, true));
}
}
if let Some(due) = self.release_due {
if now >= due {
self.release_due = None;
out.push((wire::BTN_BACK, false));
}
}
}
/// Slot close / gesture disarm: nothing may stay down (or owed) on the wire.
fn flush(&mut self, out: &mut Vec<(u32, bool)>) {
self.pending_since = None;
if self.as_guide {
self.as_guide = false;
out.push((wire::BTN_GUIDE, false));
}
if self.release_due.take().is_some() {
out.push((wire::BTN_BACK, false));
}
}
}
struct Worker {
subsystem: sdl3::GamepadSubsystem,
/// UI-facing state (the `GamepadService` accessors): pad list, active pad, pin.
@@ -750,6 +899,14 @@ struct Worker {
/// `Auto` = per-pad detection. Applied at slot open to the kind DECLARED to the host, never
/// to [`Slot::pref`] — the local feedback paths must keep reading the physical pad.
kind_override: GamepadPref,
/// Forward raw guide/QAM presses ([`GamepadService::set_system_buttons`]); off keeps
/// them with the local shell.
system_forward: bool,
/// The hold-Select guide gesture is armed ([`GamepadService::set_system_buttons`]).
guide_gesture: bool,
/// Releases owed for synthetic taps ([`Ctl::TapButton`]): `(pad, bit, due)` — the
/// down went out on receipt, the up goes out from the poll once `due` passes.
synthetic_ups: Vec<(u8, u32, Instant)>,
attached: Option<Arc<NativeClient>>,
/// Raises the UI escape signal; the escape chord fires it once per press.
escape_tx: async_channel::Sender<()>,
@@ -1051,6 +1208,14 @@ impl Worker {
/// Emits wire events only (no SDL device calls), so it is safe against an already-removed pad.
fn flush_slot(c: &NativeClient, slot: &mut Slot) {
let pad = slot.index;
// Gesture first: a synthetic guide is NOT in `held_buttons`, so the drain below
// would never lift it — and a still-pending Select was never sent, so dropping
// it beats delivering a ghost press into the close.
let mut due = Vec::new();
slot.gesture.flush(&mut due);
for (b, down) in due {
send(c, InputKind::GamepadButton, b, down as i32, pad);
}
for b in slot.held_buttons.drain(..) {
send(c, InputKind::GamepadButton, b, 0, pad);
}
@@ -1128,6 +1293,36 @@ impl Worker {
}
}
/// Clock-driven [`SelectGesture`] work — the hold threshold and owed tap releases —
/// polled like the chord hold, so timings carry at most one wakeup (~10 ms attached)
/// of jitter.
fn gesture_poll(&mut self) {
let Some(c) = self.attached.clone() else {
self.synthetic_ups.clear();
return;
};
let now = Instant::now();
// Owed releases of synthetic taps (the control socket's guide/QAM verbs).
self.synthetic_ups.retain(|&(pad, bit, due)| {
if now >= due {
send(&c, InputKind::GamepadButton, bit, 0, pad);
false
} else {
true
}
});
if !self.guide_gesture {
return;
}
for slot in &mut self.slots {
let mut due = Vec::new();
slot.gesture.poll(now, &mut due);
for (b, down) in due {
send(&c, InputKind::GamepadButton, b, down as i32, slot.index);
}
}
}
/// Fire the disconnect signal once the escape chord has been continuously held past
/// [`DISCONNECT_HOLD`]. Polled from the main loop so the hold completes without new events.
fn maybe_fire_disconnect(&mut self) {
@@ -1305,6 +1500,41 @@ impl Worker {
self.refresh_active();
}
Ok(Ctl::KindOverride(pref)) => self.kind_override = pref,
Ok(Ctl::SystemButtons {
forward_raw,
gesture,
}) => {
self.system_forward = forward_raw;
if self.guide_gesture == gesture {
continue;
}
self.guide_gesture = gesture;
// A mid-session flip may strand gesture state — a synthetic guide
// still down, an owed tap release — lift it now (no-op on the way on:
// an unarmed gesture was never fed).
if let Some(c) = self.attached.clone() {
for slot in &mut self.slots {
let mut due = Vec::new();
slot.gesture.flush(&mut due);
for (b, down) in due {
send(&c, InputKind::GamepadButton, b, down as i32, slot.index);
}
}
}
}
Ok(Ctl::TapButton(bit)) => {
// Synthetic system-button tap (the session control socket): down on
// the first forwarded slot's index — pad 0 when none is open (a
// forwarding-off session; best-effort there, the wire pad may not
// exist host-side). The up is owed via `synthetic_ups`, TAP_PRESS
// later, so the pair can't fold into nothing.
if let Some(c) = self.attached.clone() {
let pad = self.slots.first().map_or(0, |s| s.index);
send(&c, InputKind::GamepadButton, bit, 1, pad);
self.synthetic_ups
.push((pad, bit, Instant::now() + TAP_PRESS));
}
}
Ok(Ctl::Forwarding(on)) => {
if self.forwarding == on {
continue;
@@ -1405,8 +1635,32 @@ impl Worker {
return;
}
if let Some(bit) = button_bit(button) {
// Raw system buttons stay with the local shell when passthrough is
// off (the Gaming-Mode default): Steam already opened ITS overlay
// for this press; the host's is reached via the hold-Select gesture
// (and the Decky panel) instead.
if !self.system_forward && matches!(bit, wire::BTN_GUIDE | wire::BTN_MISC1) {
return;
}
let mut due = Vec::new();
let held_back = if !self.guide_gesture {
false
} else if bit == wire::BTN_BACK {
let alone = slot.held_buttons.is_empty();
slot.gesture.on_select_down(Instant::now(), alone, &mut due)
} else {
slot.gesture.on_other_down(&mut due);
false
};
for (b, down) in due {
send(&c, InputKind::GamepadButton, b, down as i32, slot.index);
}
// Held-back or not, the chord bookkeeping sees the physical press —
// the escape chord must not care that the gesture exists.
slot.held_buttons.push(bit);
send(&c, InputKind::GamepadButton, bit, 1, slot.index);
if !held_back {
send(&c, InputKind::GamepadButton, bit, 1, slot.index);
}
self.maybe_fire_escape();
}
}
@@ -1422,8 +1676,20 @@ impl Worker {
return;
}
if let Some(bit) = button_bit(button) {
if !self.system_forward && matches!(bit, wire::BTN_GUIDE | wire::BTN_MISC1) {
return;
}
slot.held_buttons.retain(|&b| b != bit);
send(&c, InputKind::GamepadButton, bit, 0, slot.index);
let mut due = Vec::new();
let owned = self.guide_gesture
&& bit == wire::BTN_BACK
&& slot.gesture.on_select_up(Instant::now(), &mut due);
for (b, down) in due {
send(&c, InputKind::GamepadButton, b, down as i32, slot.index);
}
if !owned {
send(&c, InputKind::GamepadButton, bit, 0, slot.index);
}
self.rearm_escape();
}
}
@@ -1671,6 +1937,9 @@ impl Worker {
pinned: None,
forwarding: true,
kind_override: GamepadPref::Auto,
system_forward: true,
guide_gesture: false,
synthetic_ups: Vec::new(),
attached: None,
escape_tx,
disconnect_tx,
@@ -1742,6 +2011,7 @@ fn run(
// Escalate a held escape chord to a disconnect (polled — the hold completes with no
// new button events; the chord itself is only detected while a session is attached).
w.gesture_poll();
w.maybe_fire_disconnect();
w.menu_poll();
@@ -1749,6 +2019,115 @@ fn run(
}
}
#[cfg(test)]
mod select_gesture_tests {
use super::*;
#[test]
fn tap_delivers_press_then_scheduled_release() {
let mut g = SelectGesture::default();
let t = Instant::now();
let mut out = Vec::new();
assert!(g.on_select_down(t, true, &mut out), "not held back");
assert!(out.is_empty(), "a held-back press sends nothing yet");
// Released inside the threshold: the press goes out on release…
let up = t + Duration::from_millis(120);
assert!(g.on_select_up(up, &mut out));
assert_eq!(out, vec![(wire::BTN_BACK, true)]);
out.clear();
// …and the release only TAP_PRESS behind it, so the pair can't fold away.
g.poll(up + TAP_PRESS - Duration::from_millis(1), &mut out);
assert!(out.is_empty(), "release went out early");
g.poll(up + TAP_PRESS, &mut out);
assert_eq!(out, vec![(wire::BTN_BACK, false)]);
}
#[test]
fn hold_becomes_guide_down_until_release() {
let mut g = SelectGesture::default();
let t = Instant::now();
let mut out = Vec::new();
assert!(g.on_select_down(t, true, &mut out));
g.poll(t + GUIDE_HOLD - Duration::from_millis(1), &mut out);
assert!(out.is_empty(), "guide fired inside the threshold");
g.poll(t + GUIDE_HOLD, &mut out);
assert_eq!(out, vec![(wire::BTN_GUIDE, true)]);
out.clear();
// Held on: nothing more (the host times its own long-press = QAM).
g.poll(t + GUIDE_HOLD * 4, &mut out);
assert!(out.is_empty());
// Release lifts the guide, never a Select.
assert!(g.on_select_up(t + GUIDE_HOLD * 5, &mut out));
assert_eq!(out, vec![(wire::BTN_GUIDE, false)]);
}
#[test]
fn second_button_makes_pending_select_real() {
let mut g = SelectGesture::default();
let t = Instant::now();
let mut out = Vec::new();
assert!(g.on_select_down(t, true, &mut out));
// A joins inside the window: the deferred Select down goes out first (the
// caller then sends A's own down — chronology preserved).
g.on_other_down(&mut out);
assert_eq!(out, vec![(wire::BTN_BACK, true)]);
out.clear();
// The release is a normal button-up now — the gesture doesn't own it.
assert!(!g.on_select_up(t + Duration::from_millis(200), &mut out));
assert!(out.is_empty());
// And no stale guide fires later.
g.poll(t + GUIDE_HOLD * 2, &mut out);
assert!(out.is_empty());
}
#[test]
fn select_inside_a_combo_passes_through() {
let mut g = SelectGesture::default();
let mut out = Vec::new();
// L1+R1+Start already down (the escape chord ends in Select): not held back.
assert!(!g.on_select_down(Instant::now(), false, &mut out));
assert!(out.is_empty());
}
#[test]
fn quick_repress_lifts_owed_release_first() {
let mut g = SelectGesture::default();
let t = Instant::now();
let mut out = Vec::new();
assert!(g.on_select_down(t, true, &mut out));
assert!(g.on_select_up(t + Duration::from_millis(80), &mut out));
out.clear();
// Re-pressed before the owed release fired: the up goes out before the new
// press is held back — the host never sees two downs in a row.
assert!(g.on_select_down(t + Duration::from_millis(100), true, &mut out));
assert_eq!(out, vec![(wire::BTN_BACK, false)]);
}
#[test]
fn flush_lifts_synthetic_guide_and_owed_release() {
let mut g = SelectGesture::default();
let t = Instant::now();
let mut out = Vec::new();
// Transformed hold: flush lifts the guide.
assert!(g.on_select_down(t, true, &mut out));
g.poll(t + GUIDE_HOLD, &mut out);
out.clear();
g.flush(&mut out);
assert_eq!(out, vec![(wire::BTN_GUIDE, false)]);
out.clear();
// Owed tap release: flush emits it. A pending (never-sent) Select just drops.
assert!(g.on_select_down(t, true, &mut out));
assert!(g.on_select_up(t + Duration::from_millis(80), &mut out));
out.clear();
g.flush(&mut out);
assert_eq!(out, vec![(wire::BTN_BACK, false)]);
out.clear();
assert!(g.on_select_down(t, true, &mut out));
g.flush(&mut out);
assert!(out.is_empty(), "a never-sent pending Select ghosted a send");
}
}
#[cfg(test)]
mod menu_nav_tests {
use super::*;
+22
View File
@@ -76,6 +76,10 @@ pub struct SettingsOverlay {
#[serde(skip_serializing_if = "Option::is_none")]
pub gamepad_forwarding: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub system_buttons: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub guide_gesture: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stats_verbosity: Option<StatsVerbosity>,
#[serde(skip_serializing_if = "Option::is_none")]
pub fullscreen_on_stream: Option<bool>,
@@ -159,6 +163,12 @@ impl SettingsOverlay {
if let Some(v) = self.gamepad_forwarding {
s.gamepad_forwarding = v;
}
if let Some(v) = &self.system_buttons {
s.system_buttons = v.clone();
}
if let Some(v) = &self.guide_gesture {
s.guide_gesture = v.clone();
}
if let Some(v) = self.stats_verbosity {
// Through the setter so the legacy `show_stats` bool stays coherent for
// pre-tier binaries reading the same settings file.
@@ -252,6 +262,12 @@ impl SettingsOverlay {
if after.gamepad_forwarding != before.gamepad_forwarding {
self.gamepad_forwarding = Some(after.gamepad_forwarding);
}
if after.system_buttons != before.system_buttons {
self.system_buttons = Some(after.system_buttons.clone());
}
if after.guide_gesture != before.guide_gesture {
self.guide_gesture = Some(after.guide_gesture.clone());
}
if after.stats_verbosity() != before.stats_verbosity() {
self.stats_verbosity = Some(after.stats_verbosity());
}
@@ -302,6 +318,8 @@ impl SettingsOverlay {
"inhibit_shortcuts" => self.inhibit_shortcuts = None,
"gamepad" => self.gamepad = None,
"gamepad_forwarding" => self.gamepad_forwarding = None,
"system_buttons" => self.system_buttons = None,
"guide_gesture" => self.guide_gesture = None,
"stats_verbosity" => self.stats_verbosity = None,
"fullscreen_on_stream" => self.fullscreen_on_stream = None,
"present_priority" => self.present_priority = None,
@@ -506,6 +524,8 @@ mod tests {
inhibit_shortcuts: Some(false),
gamepad: Some("dualsense".into()),
gamepad_forwarding: Some(false),
system_buttons: Some("local".into()),
guide_gesture: Some("on".into()),
match_window: Some(true),
fullscreen_on_stream: Some(false),
stats_verbosity: Some(StatsVerbosity::Detailed),
@@ -532,6 +552,8 @@ mod tests {
assert!(!out.inhibit_shortcuts);
assert_eq!(out.gamepad, "dualsense");
assert!(!out.gamepad_forwarding);
assert_eq!(out.system_buttons, "local");
assert_eq!(out.guide_gesture, "on");
assert!(out.match_window);
assert!(!out.fullscreen_on_stream);
assert_eq!(out.stats_verbosity(), StatsVerbosity::Detailed);
+65 -5
View File
@@ -232,17 +232,29 @@ impl KnownHosts {
/// A read-only config dir just keeps re-minting in memory, which harms nothing: no lookup
/// is keyed by the id yet (design §4.5).
pub fn load() -> KnownHosts {
let mut k: KnownHosts = Self::path()
.and_then(|p| Ok(std::fs::read_to_string(p)?))
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default();
let mut k = Self::read();
if k.mint_missing_ids() {
let _ = k.save();
}
k
}
/// The store exactly as it is on disk — no mint, and so no write.
///
/// For a consumer that only needs to LOOK at the records (annotating a discovery result
/// against them, say) and never dials one by id. [`KnownHosts::load`]'s mint is a write, and
/// two processes started together against a pre-mint store will each mint a *different* id
/// for the same record and race to save it — after which whichever one already handed its
/// ids to a caller has handed out references that no longer resolve. A read that stays a
/// read cannot take part in that.
pub fn read() -> KnownHosts {
Self::path()
.and_then(|p| Ok(std::fs::read_to_string(p)?))
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default()
}
/// Give every record still missing one a stable id; returns true if anything changed
/// (i.e. whether this needs persisting). Idempotent — a store that has been through it
/// once is left byte-identical.
@@ -867,6 +879,25 @@ pub struct Settings {
/// forwarded as pad 0; empty = automatic (most recently connected). Applied to the
/// gamepad service at startup so the choice survives restarts.
pub forward_pad: String,
/// What a controller's SYSTEM buttons — guide (Xbox/PS/Steam) and the Deck's QAM `…` —
/// do while streaming: `"auto"` (default), `"forward"` (raw presses go to the host,
/// the pre-setting behaviour), or `"local"` (they stay with this device; the host's
/// are reached via the hold-Select gesture instead). Auto resolves per platform in
/// [`Settings::system_buttons_forward`]: forward everywhere EXCEPT under Gaming Mode,
/// where the local Steam UI always reacts to the same physical press — forwarding
/// there opens BOTH overlays, the local one on top of the stream.
#[serde(default = "default_auto")]
pub system_buttons: String,
/// The hold-Select guide gesture: holding Select/Back alone ≥ ~350 ms sends the HOST
/// the guide button (down for as long as it's held, so a long hold is the host's
/// long-press — the QAM on a Gaming-Mode host). `"auto"` (default) / `"on"` / `"off"`,
/// resolved in [`Settings::guide_gesture_enabled`]: auto = on only where the raw
/// guide press can't reach the host cleanly (Gaming Mode; iOS/tvOS resolve their own
/// auto in the Apple client). While armed, a Select TAP is delivered on release —
/// costing it up to the hold threshold in latency — and a Select held as part of a
/// combo (any other button already down) passes through untouched.
#[serde(default = "default_auto")]
pub guide_gesture: String,
/// Which host compositor backend to request (advisory; the host falls back to
/// auto-detect when unavailable).
pub compositor: String,
@@ -1020,6 +1051,10 @@ fn default_codec() -> String {
"auto".into()
}
fn default_auto() -> String {
"auto".into()
}
fn default_touch_mode() -> String {
"trackpad".into()
}
@@ -1069,6 +1104,29 @@ impl Settings {
PresentPriority::resolve(&self.present_priority, self.smooth_buffer)
}
/// Whether raw system-button presses (guide + QAM) are forwarded to the host.
/// `game_mode` = this client runs as the embedded Gaming-Mode stream (gamescope),
/// where the local Steam UI reacts to the same physical buttons no matter what we
/// do — auto keeps them local there and forwards everywhere else.
pub fn system_buttons_forward(&self, game_mode: bool) -> bool {
match self.system_buttons.as_str() {
"forward" => true,
"local" => false,
_ => !game_mode,
}
}
/// Whether the hold-Select guide gesture is armed ([`Settings::guide_gesture`]).
/// Auto = on only under Gaming Mode, where it is the sole controller route to the
/// host's guide once raw presses stay local.
pub fn guide_gesture_enabled(&self, game_mode: bool) -> bool {
match self.guide_gesture.as_str() {
"on" => true,
"off" => false,
_ => game_mode,
}
}
/// The `codec` setting as a `quic::CODEC_*` preference bit (`0` = auto).
pub fn preferred_codec(&self) -> u8 {
match self.codec.as_str() {
@@ -1095,6 +1153,8 @@ impl Default for Settings {
gamepad: "auto".into(),
gamepad_forwarding: true,
forward_pad: String::new(),
system_buttons: "auto".into(),
guide_gesture: "auto".into(),
compositor: "auto".into(),
touch_mode: "trackpad".into(),
mouse_mode: "capture".into(),
+50 -2
View File
@@ -41,6 +41,8 @@ enum RowId {
PadForward,
Pad,
PadType,
SystemButtons,
GuideGesture,
Touch,
Mouse,
InvertScroll,
@@ -57,7 +59,7 @@ enum RowId {
// cancellation all were). Still deliberately smaller than the desktop dialogs — device
// pickers (GPU/speaker/mic) stay desktop-only, and profiles are pinnable here (the
// trailing Profiles section) but created and edited only in the desktop app (design §5.4).
const ROWS: [RowId; 27] = [
const ROWS: [RowId; 29] = [
RowId::Resolution,
RowId::Refresh,
RowId::RenderScale,
@@ -77,6 +79,8 @@ const ROWS: [RowId; 27] = [
RowId::PadForward,
RowId::Pad,
RowId::PadType,
RowId::SystemButtons,
RowId::GuideGesture,
RowId::Touch,
RowId::Mouse,
RowId::InvertScroll,
@@ -152,6 +156,16 @@ const PAD_TYPES: [(&str, &str); 6] = [
("dualshock4", "DualShock 4"),
("steamdeck", "Steam Deck"),
];
/// Where the guide (Xbox/PS/Steam) and quick-access presses land while streaming — the
/// shared `system_buttons` key. Auto = host everywhere except Gaming Mode, where the
/// local Steam UI reacts to the same press and both overlays would open at once.
const SYSTEM_BUTTONS: [(&str, &str); 3] = [
("auto", "Automatic"),
("forward", "Send to host"),
("local", "This device"),
];
/// The hold-Select guide gesture — the shared `guide_gesture` key.
const GUIDE_GESTURE: [(&str, &str); 3] = [("auto", "Automatic"), ("on", "On"), ("off", "Off")];
pub(crate) struct SettingsScreen {
list: MenuList,
@@ -350,7 +364,9 @@ fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec {
// move everything under the cursor).
let enabled = match id {
RowId::EchoCancel => s.mic_enabled,
RowId::Pad | RowId::PadType => s.gamepad_forwarding,
RowId::Pad | RowId::PadType | RowId::SystemButtons | RowId::GuideGesture => {
s.gamepad_forwarding
}
RowId::SmoothBuffer => s.present_priority == "smooth",
_ => true,
};
@@ -457,6 +473,16 @@ fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec {
"Controller type",
label_for(&PAD_TYPES, &s.gamepad).into(),
),
RowId::SystemButtons => (
None,
"Steam / guide button",
label_for(&SYSTEM_BUTTONS, &s.system_buttons).into(),
),
RowId::GuideGesture => (
None,
"Hold Select for guide",
label_for(&GUIDE_GESTURE, &s.guide_gesture).into(),
),
RowId::Touch => (
Some("Touchscreen"),
"Touch mode",
@@ -553,6 +579,16 @@ fn detail(id: RowId) -> &'static str {
}
RowId::Pad => "Which pad is forwarded to the host, as player 1.",
RowId::PadType => "The virtual pad the host creates — Automatic matches this controller.",
RowId::SystemButtons => {
"Where the guide (Xbox/PS/Steam) and quick-access presses go. Automatic \
sends them to the host except in Gaming Mode, where Steam on this device \
reacts to the same press and both overlays would open at once."
}
RowId::GuideGesture => {
"Hold Select on its own to press the host's guide button — keep holding for \
the host's quick-access menu. Automatic arms it only where the real button \
can't reach the host. A Select tap still goes through, slightly delayed."
}
RowId::Touch => {
"How the touchscreen drives the host: Trackpad (relative cursor), \
Direct pointer (cursor jumps to your finger), or Touch passthrough (raw contacts)."
@@ -699,6 +735,18 @@ fn adjust(id: RowId, delta: i32, wrap: bool, ctx: &mut Ctx) -> bool {
}
step_str(&PAD_TYPES, &mut s.gamepad, delta, wrap)
}
RowId::SystemButtons => {
if !s.gamepad_forwarding {
return false;
}
step_str(&SYSTEM_BUTTONS, &mut s.system_buttons, delta, wrap)
}
RowId::GuideGesture => {
if !s.gamepad_forwarding {
return false;
}
step_str(&GUIDE_GESTURE, &mut s.guide_gesture, delta, wrap)
}
RowId::Touch => {
let cur = TouchMode::ALL.iter().position(|m| *m == s.touch_mode());
step_option(cur, TouchMode::ALL.len(), delta, wrap)
+60 -43
View File
@@ -15,13 +15,14 @@ The Linux, Windows, Mac, iPhone/iPad and Android apps group settings the same wa
**Display**, **Input**, **Audio**, **Controllers** — under *Preferences* on Linux and *Settings*
elsewhere. The Apple TV app shows one scrolling list instead, and so does any client's settings
screen reached with a controller. A controller-driven launch (Steam Deck Gaming Mode) opens the
client's **console home**, whose settings screen is one steppable list; the Decky plugin's Settings
tab covers the same store in the same groups and the same order, as a left rail of categories the
way SteamOS's own Settings looks. The console home is part of the
client — it is not the host's
[web console](/docs/web-console).
client's **console home**, whose settings screen is one steppable list of sections — **Stream**,
**Video**, **Presentation**, **Audio**, **Controller**, **Touchscreen**, **Interface**,
**Profiles**. On a Steam Deck that list *is* the settings surface: the
[Decky plugin](/docs/steam-deck) is a launcher and keeps no settings of its own, and its **Open
Punktfunk** button puts the console home one tap from the Quick Access Menu. The console home is
part of the client — it is not the host's [web console](/docs/web-console).
Linux stores them in `~/.config/punktfunk/client-gtk-settings.json`, the same file the Decky plugin
Linux stores them in `~/.config/punktfunk/client-gtk-settings.json`, the same file the console home
writes, so a change in either shows up in the other. Windows uses
`%APPDATA%\punktfunk\client-windows-settings.json`; the Apple and Android apps use their own stores.
@@ -45,9 +46,9 @@ and your client scales what it gets — see
**Match window** — *default: off.* The stream mode follows your window instead, and each resize
renegotiates the host's display and encoder, so a windowed session stays pixel-exact. Fullscreen
degenerates to the display's native mode. Offered by the Linux, Windows, Mac, iPhone/iPad, console
home and Decky screens (on Decky it sits in the Resolution picker, and Gaming-Mode streams are
always fullscreen, so it lands on native); not by Android.
degenerates to the display's native mode. Offered by the Linux, Windows, Mac, iPhone/iPad and
console-home screens (in the console home it is an option inside the Resolution picker, and a
Gaming-Mode stream is always fullscreen, so there it lands on native); not by Android.
**Refresh rate** — *default: Native*, the refresh of the display your window is on. The Apple app
stores an explicit rate (60 Hz by default): iPhone and iPad offer the rates the device can display,
@@ -68,11 +69,11 @@ capacity probe stay off for the whole session.
multiplied by this, and your device resamples the result to its window. Above 1× supersamples for
sharpness, at more bandwidth *and* more decode work; below 1× is lighter on both the host and the
link. The stops run 0.5× to 4×. The result is floored to an even size and capped per axis at
4096 px for H.264, 8192 px otherwise. Offered everywhere except the console home's list.
4096 px for H.264, 8192 px otherwise. Offered everywhere.
**Video codec** — *default: Automatic.* A soft preference: the host emits your choice when it can
also produce it, otherwise the best codec you both speak, in the order HEVC → AV1 → H.264.
**PyroWave** is never auto-picked — pick it explicitly on Linux, Windows, the console home, Decky, or
**PyroWave** is never auto-picked — pick it explicitly on Linux, Windows, the console home, or
an Apple device whose decode probe passes; anywhere else it isn't offered, and asking for it lands on
that same order. See [PyroWave](/docs/pyrowave). The Android and Apple apps hide AV1 unless the
device has a hardware AV1 decoder; Android never offers PyroWave.
@@ -86,13 +87,13 @@ Full detail: [HDR](/docs/hdr).
needs HEVC or PyroWave, the host's own 4:4:4 policy left on, a capture path that delivers full
chroma, and a GPU that can encode it; if any gate fails the host says 4:2:0 before your decoder is
built. The Apple, Linux and Windows apps all advertise it (Apple additionally requires its hardware
decode probe to pass). The console home and Decky offer the toggle; Android doesn't.
decode probe to pass). The console home offers the toggle; Android doesn't.
**Prioritize** — *default: Lowest latency.* What the client optimizes for when a decoded frame is
ready. **Lowest latency** shows every frame the moment the display can take it, so a network hiccup
becomes an occasional repeated or skipped frame. **Smoothness** holds a small buffer that evens
those hiccups out, at that buffer's worth of added delay. Linux and Windows apps, the console home
and Decky; the Apple and Android apps have carried the same setting for a while, and it is stored
those hiccups out, at that buffer's worth of added delay. Linux and Windows apps and the console
home; the Apple and Android apps have carried the same setting for a while, and it is stored
under the same name, so a [profile](/docs/profiles-and-links) means the same thing on every device.
**Smoothness buffer** — *default: Automatic (two frames).* Only shown under **Smoothness**. How
@@ -106,7 +107,7 @@ the instant it's ready instead of waiting for the screen's next refresh: the low
can give you, at the cost of visible tearing on fast motion. It is **best-effort** — not every
driver or compositor offers a tearing mode, and where none is available the stream stays tear-free.
The Detailed [stats overlay](/docs/stats) names the mode actually in use, so you can tell "off"
from "off but unavailable". Linux and Windows apps, the console home and Decky.
from "off but unavailable". Linux and Windows apps and the console home.
**Follow variable refresh rate** — *default: on.* On a VRR / FreeSync / G-Sync screen, let the panel
refresh in step with the stream rather than on a fixed cadence — which removes the wait between a
@@ -115,8 +116,8 @@ windowed one is at the compositor's mercy) and is harmless on a fixed-refresh sc
graphics driver that offers the modern queue-free display mode; on an older driver it does nothing
unless you also set `PUNKTFUNK_VRR_FIFO=1` (see [configuration](/docs/configuration)), because the
older way of following a panel costs noticeable latency on a fixed-refresh screen. The stats overlay
reports `vrr yes` once it has *measured* that the panel really is following. Linux and Windows apps,
the console home and Decky.
reports `vrr yes` once it has *measured* that the panel really is following. Linux and Windows apps
and the console home.
**Host compositor** — *default: Automatic.* Which backend a **Linux** host uses to drive the virtual
output. Advisory: a host without that backend quietly auto-detects instead.
@@ -130,7 +131,7 @@ claims a sink advertising exactly that many channels, so applications produce re
**Windows** host loopback-captures your current output endpoint and lets Windows convert it — so 5.1
from a stereo endpoint is an upmix, not new channels. Offered everywhere.
**Microphone** — *default: off on Linux, Windows, Android, the console home and Decky; on in the
**Microphone** — *default: off on Linux, Windows, Android and the console home; on in the
Apple app.* Sends this device's microphone to the host's virtual mic. On Linux and Windows the
row is spelled *Stream microphone*, and **Ctrl+Alt+Shift+V** mutes it mid-stream without ending
anything — see [Muting your microphone](/docs/input#muting-your-microphone).
@@ -142,18 +143,18 @@ from an echo-cancelled PipeWire source when your desktop provides one, on **Wind
for the Communications stream category so the endpoint's processing engages, and on **Apple** and
**Android** the platform's voice-processing mode. Turn it off if your microphone already runs its
own processing, or if the canceller makes your voice sound thin. The row sits under the microphone
toggle and greys out while the microphone is off. Offered by the Linux, Windows, Apple, Android,
console-home and Decky clients. What it can and can't fix is in
toggle and greys out while the microphone is off. Offered by the Linux, Windows, Apple, Android and
console-home clients. What it can and can't fix is in
[Why do I hear myself](/docs/echo).
**Speaker** and **Microphone** device pickers — *default: System default.* Which endpoint stream
audio plays out of, and which input feeds the uplink. Only the Linux app (PipeWire nodes), the
**Mac** app (which also has a microphone *channel* picker) and **Decky** have these — iPhone, iPad,
Apple TV, Android and the console home have none, and the Windows app has none and ignores a stored
speaker choice. On Linux, a device that has since disappeared keeps a "(not detected)" entry rather
than silently snapping back to the default; the Mac shows it as "Unavailable device" and Decky as
"(not connected)". Decky reads the endpoint list from the client's session binary, so a client
older than the two-binary split leaves these pickers on Automatic.
audio plays out of, and which input feeds the uplink. Only the Linux app (PipeWire nodes) and the
**Mac** app (which also has a microphone *channel* picker) have these — iPhone, iPad, Apple TV,
Android and the console home have none, and the Windows app has none and ignores a stored speaker
choice. On Linux, a device that has since disappeared keeps a "(not detected)" entry rather than
silently snapping back to the default; the Mac shows it as "Unavailable device". A Steam Deck in
Gaming Mode therefore has no endpoint picker at all: the session uses whatever the Desktop-Mode app
last stored, and the system default otherwise.
## Input
@@ -182,7 +183,7 @@ client greys them out to say so.
**Gamepad type** (*Controller type* on Apple, Android and the console home) — *default: Automatic*,
which matches each physical controller. The pickers offer Xbox 360, Xbox One, DualSense and
DualShock 4 everywhere, plus Steam Deck on Linux, Android, the console home and Decky. Your client
DualShock 4 everywhere, plus Steam Deck on Linux, Android and the console home. Your client
declares a type per pad as it connects — Automatic declares what that controller really is, an
explicit choice declares your choice — and the host builds each virtual pad from that. A type the
host has no backend for degrades to an Xbox 360 pad rather than failing: Xbox One on a Windows host,
@@ -193,8 +194,23 @@ which forwards *every* connected controller, each as its own player, on Linux, W
console home. Pinning one restricts the session to that controller alone — single-player. The Android
app has no such picker.
**Capture system shortcuts** — *default: on.* Offered by the Linux and Windows apps, the console home
and Decky; Windows spells the row out as *Capture system shortcuts (Alt+Tab, Win, …)*. On a Deck it
**Steam / guide button** (*Guide button* on Apple and Android) — *default: Automatic*, on every
client. Where the guide (Xbox/PS/Steam) and quick-access presses go while streaming: **Send to
host** forwards them raw, **This device** keeps them local. Automatic forwards everywhere except
Gaming Mode, where SteamOS opens its own menus for those buttons no matter what — forwarding raw
there opens *both* menus at once, the local one covering the stream. The full story, including how
to reach the host's menus when the raw press stays local, is on the
[Input page](/docs/input#the-guide-button-xbox--ps--steam-and-quick-access).
**Hold Select for guide** — *default: Automatic*, on every client. The gesture that presses the
host's guide button from any controller: hold Select (Back/View) on its own for about a third of a
second, and keep holding for the host's long-press (a Gaming-Mode host's Quick Access Menu, on a
regular pad). Automatic arms it only where the raw guide press can't reach the host cleanly —
Gaming Mode, iPhone/iPad, Apple TV — because the gesture has a cost: a Select *tap* arrives a beat
late, and a game that expects a *held* Select would trigger it. Set **On** or **Off** to overrule.
**Capture system shortcuts** — *default: on.* Offered by the Linux and Windows apps and the console
home; Windows spells the row out as *Capture system shortcuts (Alt+Tab, Win, …)*. On a Deck it
matters only for a keyboard you attached yourself, for the reason the paragraph below gives: Gaming
Mode is gamescope, which has nothing to hold back. On, Alt+Tab and the Windows key
(Super on Linux) reach the host while the stream has input captured. Off, they act on this machine
@@ -215,21 +231,22 @@ the wlroots compositors all do, and X11 sessions grab the keyboard directly. Und
Wake-on-LAN and waits for it to boot — only for a host whose MAC address this client has already
learned. Turn it off for hosts you reach over a VPN, where "offline" usually means "not reachable by
broadcast" and the wake only adds a delay. The Linux, Windows, Apple and Android apps have this
toggle, as do the console home and Decky — and note that the Decky plugin sends a wake of its own
before a stream starts whatever this setting says, so on a Deck it governs the client's connect
rather than the launch. The console home also offers wake as an explicit action on an offline host.
See
toggle, as does the console home — and on a Steam Deck it governs the
[Decky plugin's](/docs/steam-deck) launches too, because the plugin starts every stream through the
client, which reads this setting like any other connect. The console home also offers wake as an
explicit action on an offline host, whatever the toggle says. See
[Wake-on-LAN](/docs/wake-on-lan).
**Show game library** — *default: off on Linux and Windows; on in the Apple and Android apps.* Browse
a paired host's games and launch one directly; the Windows app still labels it experimental. The
console home and Decky have the toggle too — on Decky it governs the *client's* screens, since the
plugin's own library browser works either way. See [Game library](/docs/game-library).
console home has the toggle too, and it governs the desktop clients that share the store — the
console's own **Library** button is offered on any paired host either way. See
[Game library](/docs/game-library).
**Start streams in fullscreen** — *default: on.* On Linux and Windows, F11 or Alt+Enter leaves
fullscreen live. On a Mac the setting is **Fullscreen while streaming**, and the window comes back
when you return to the host list. The console home and Decky carry the row for the desktop client
that shares the store — a Gaming-Mode launch is fullscreen whatever it says. iPhone, iPad, Apple TV
when you return to the host list. The console home carries the row for the desktop client that
shares the store — a Gaming-Mode launch is fullscreen whatever it says. iPhone, iPad, Apple TV
and Android have no equivalent.
## Overlay
@@ -237,9 +254,9 @@ and Android have no equivalent.
**Statistics overlay** — *default: Normal.* Four tiers — Off, Compact, Normal, Detailed — each a
superset of the one before. This setting only picks the tier a session *starts* at — you can cycle
them live in-stream, with a shortcut that differs by platform. The Apple app additionally lets you
choose which corner the overlay sits in (Top Left, Top Right, Bottom Left, Bottom Right). The Decky
plugin has the tier picker too, in its Settings section. The shortcuts, and every number in the
overlay, are in
choose which corner the overlay sits in (Top Left, Top Right, Bottom Left, Bottom Right). The
console home has the tier picker too, as **Statistics overlay** under **Interface**. The shortcuts,
and every number in the overlay, are in
[Understanding the stats overlay](/docs/stats).
## Settings that are facts about your device
@@ -251,8 +268,8 @@ stay global and **cannot be put in a settings profile**:
vendor-ordered and falls back on its own; change it only when debugging, and note that
`PUNKTFUNK_DECODER` overrides it
([Configuration](/docs/configuration#client-side-native-clients)). The decoder picker is on Linux,
Windows, in the console home and in Decky; the GPU picker on Windows, and on Linux and Decky only
when the machine has more than one adapter — which a Deck doesn't, so the row isn't there. The
Windows and in the console home; the GPU picker on Windows, and on Linux only when the machine has
more than one adapter — the console home has none, and a Deck has a single adapter anyway. The
Apple and Android apps have neither.
- **Speaker** and **Microphone** device pickers — this device's audio endpoints.
- **Forwarded controller** — which physical pad is in your hands. The *type* the host creates is a
+5 -3
View File
@@ -77,7 +77,8 @@ The setting is read when a session starts, so if you change it while streaming,
macOS can also flip it mid-session: **Stream ▸ Share Clipboard** (⌃⌥⇧C), which becomes **Stop
Sharing Clipboard** once the host has acknowledged it.
iOS, iPadOS, tvOS and the Steam Deck Decky plugin have no clipboard switch — see
iOS, iPadOS, tvOS and a Steam Deck in Gaming Mode have no clipboard switch — neither the Decky
panel nor the client's console home has a host edit sheet — see
[what each client does](#which-hosts-and-clients-support-it) below.
## Nothing crosses until something pastes
@@ -134,8 +135,9 @@ when a host application pastes.
The **Linux client has the switch but no working clipboard bridge**: it enables the plane and then
has no code to read or write the desktop's own clipboard, so nothing is announced and nothing is
pasted. Turning it on there is harmless but has no effect today. The Decky plugin on the Steam Deck
has no switch at all.
pasted. Turning it on there is harmless but has no effect today. On a Steam Deck in Gaming Mode
there is no switch at all — the Decky panel doesn't edit hosts — and since a Deck streams with that
same Linux client, a switch there would have nothing to move anyway.
When you copy **on the Windows client**, images cross only if the copying application publishes the
registered `PNG` clipboard format. Many Windows apps publish only a bitmap, and those copies aren't
+4 -5
View File
@@ -132,11 +132,10 @@ and runs what it already knows about the title, so a client can never hand the h
- **Android** — the library lives only in the controller-optimized home, which a TV always uses and a
phone or tablet switches to when a controller is connected. Press **Y** on a saved host, or open its
options and choose **Library**.
- **Steam Deck (Decky)** — the plugin's per-host **Games** picker lists the library and lets you
**Pin** titles; a pinned game becomes a one-tap row under **Pinned Games** in the Quick Access Menu.
The picker itself doesn't launch anything — either tap a pinned row, or use **Open library on
screen** to browse the host's games full-screen on the Deck and launch from there. See
[Steam Deck](/docs/steam-deck).
- **Steam Deck (Decky)** — the panel is a launcher and browses nothing itself: tap **Open
Punktfunk**, which opens the client's console home, and a paired host's **Library** button is
right there — full-screen covers, gamepad-navigable, and a press starts the stream with the title
launching. See [Steam Deck](/docs/steam-deck).
- **Moonlight** — when the host runs with `--gamestream`, your library appears in Moonlight's app
list beside `Desktop`, with covers served by the host. A title keeps the same app id across host
restarts, so Moonlight's cached tiles stay correct. Titles with no launch recipe are left out.
+36 -2
View File
@@ -49,8 +49,9 @@ your settings. If the stream isn't sending a microphone at all (**Stream microph
[client settings](/docs/client-settings#audio)) the shortcut does nothing and no badge appears,
rather than pretending to mute something.
This is on the **Linux and Windows** clients. The Apple, Android and Decky clients have no mute
shortcut yet; turn **Stream microphone** off in their settings instead.
This is on the **Linux and Windows** clients — including a Steam Deck stream, which is the Linux
client, so an attached keyboard gets the chord. The Apple and Android clients have no mute shortcut
yet; turn **Stream microphone** off in their settings instead.
Alt-Tabbing away releases input on its own and takes it back when you return. A release you asked
for with the chord stays released until you opt back in. Either way, keys and buttons you were
@@ -98,6 +99,39 @@ there the client stops opening the controller at all, which is the point of the
**Ctrl+Alt+Shift+D** or the client's own UI to leave instead. The Apple and Android apps keep
watching for the chord either way.
### The guide button (Xbox / PS / Steam) and Quick Access
A controller's **guide button** — the Xbox logo, the PS button, the Deck's **Steam** button — is
meant to open menus **on the host**: the Steam overlay, or a Gaming-Mode host's Steam menu. Some
devices want that button for themselves, so every client also carries a gesture that works
everywhere:
**Hold Select (Back / View) on its own for about a third of a second.** The host sees its guide
button go down, and it stays down for as long as you hold — so keeping it held reads as a long
press on the host, which is how SteamOS opens the **Quick Access Menu** for a regular pad. A quick
tap of Select still reaches the game, delivered when you let go (a beat late). Select pressed as
part of a combo — including the leave chord above — passes through untouched.
What the raw button does, per client:
- **Linux & Windows desktop, macOS, Android** — the guide press is forwarded to the host. If
Steam Big Picture or the Xbox Game Bar is also watching for it *on the device in your hands*,
both may react — that's a local setting on that device, not something the stream can suppress.
- **Steam Deck / Gaming Mode** — the **Steam** and **`…`** buttons stay with the Deck by default:
SteamOS always opens its own menus for them, so forwarding the raw press as well opened BOTH
menus at once, the Deck's on top of the stream. Reach the host's menus with **hold-Select**, or
with the Punktfunk panel's **Host menus** buttons ([Steam Deck page](/docs/steam-deck)). The
old behavior is one setting away: **Steam / guide button → Send to host**.
- **iPhone / iPad** — iOS reserves the Home press for its own Game Overlay, so hold-Select is the
reliable route to the host's overlay. On iOS 27 or later you can also hand the button to the
app yourself, in the system's per-controller Home-button setting.
- **Apple TV** — tvOS never delivers the Home press to apps; hold-Select is the only route.
Both halves are [settings](/docs/client-settings#input), per profile like everything else:
**Steam / guide button** (Automatic / Send to host / This device) and **Hold Select for guide**
(Automatic / On / Off). Automatic picks the behavior above for each platform — the gesture stays
off where the raw button already works, so games that use a *held* Select keep it.
## Mouse modes
There are two, and they are a per-client setting called **Mouse input**:
+5 -3
View File
@@ -79,9 +79,11 @@ list: [Clients → the `punktfunk` CLI](/docs/clients#scripting-the-punktfunk-cl
## Steam Deck
Most Deck users want **Gaming Mode**: install the **[Decky plugin](/docs/steam-deck)** and a
**Punktfunk** panel lands in the Quick Access Menu, so you can discover hosts, pair with a PIN, and
stream **without dropping to the desktop**. Follow the **[Steam Deck (Decky) guide](/docs/steam-deck)**
— it walks through Decky Loader, the plugin, and the one-time client install.
**Punktfunk** panel lands in the Quick Access Menu, so you can find a host, get let in (a PIN, or a
request the host's operator approves), and stream **without dropping to the desktop**. Everything
else — settings, the game library, adding a host by address — is one tap away in the client's own
gamepad UI. Follow the **[Steam Deck (Decky) guide](/docs/steam-deck)** — it walks through Decky
Loader, the plugin, and the one-time client install.
> The plugin doesn't decode video itself — it drives whichever `punktfunk-client` is installed on
> the Deck. The Flatpak below is the tested default; a native package or a sysext works too. If your
+2 -2
View File
@@ -61,8 +61,8 @@ Then, on the client:
- **[Native clients](/docs/clients) (Apple, Linux, Windows, Android):** select the host (or use
*Pair with PIN…* from its menu) and enter the PIN the host displays.
- **[Steam Deck](/docs/steam-deck) (the Decky plugin):** open Punktfunk from the Quick Access menu
and pick the host — an unpaired one's button reads **Pair & Stream**. Enter the PIN on the
4-digit pad it opens.
and pick the host — an unpaired one opens a sheet offering **Request access** (no PIN: somebody
approves the Deck at the host) or **Use a PIN instead**, which opens the 4-digit pad.
- **[Moonlight](/docs/moonlight):** choose **Pair**; Moonlight shows a 4-digit PIN, and you type
that PIN into the console's **Moonlight (GameStream) pairing** card and press **Submit PIN**.
(This direction is the reverse of the native flow, and arming doesn't apply to it.)
+3 -2
View File
@@ -11,8 +11,9 @@ Both live in the client apps — the Apple app, the Linux GTK client, the Window
Android app. Neither exists in the host's [web console](/docs/web-console).
The controller-driven surfaces are a half-exception: Apple TV, the Android app's console mode and
the Steam Deck console the Decky plugin launches all *use* the profile a host is bound to, but none
of them can create or edit one. Do that on a desktop or a phone first.
the Steam Deck console the Decky plugin launches all *use* the profile a host is bound to and can
pin one as its own card, but none of them can create or edit one. Do that on a desktop or a phone
first. The Decky panel itself only *shows* those pins, nested under their host as one-tap cards.
## What a profile is
+95 -51
View File
@@ -7,10 +7,12 @@ The **Decky plugin** adds a **Punktfunk** panel to the Steam Deck's Quick Access
button), so you can find a host, pair, and start streaming **without leaving Gaming Mode**. It's the
couch-friendly front end for the Steam Deck — built from real Steam UI, gamepad-navigable end to end.
Under the hood the plugin doesn't decode video itself: it discovers hosts, runs the PIN pairing, and
**launches the regular [Linux client](/docs/clients#linux-desktop-client-gtk4)** (usually the
`io.unom.Punktfunk` Flatpak) the way gamescope needs so it fullscreens correctly. So the Deck has two
ways to stream, and they share one client + one paired identity:
The plugin is a **launcher**, not a second client. It doesn't decode video, browse your library or
hold settings of its own — it starts the regular
[Linux client](/docs/clients#linux-desktop-client-gtk4) (usually the `io.unom.Punktfunk` Flatpak)
the way gamescope needs so it fullscreens correctly. Everything the panel doesn't do is one tap
away in that client's own gamepad UI. So the Deck has two ways to stream, and they share one
client + one paired identity:
- **Gaming Mode** → the **Decky plugin** (this page).
- **Desktop Mode** → run the [Flatpak](/docs/install-client#steam-deck) directly, like any Linux app.
@@ -30,11 +32,13 @@ You need three things on the Deck:
(Full options: [Install a Client → Steam Deck](/docs/install-client#steam-deck).) If you have
no Flatpak but a native `punktfunk-client` — a sysext, a distro package, a nix profile, your own
build — the plugin launches that instead; with both installed the Flatpak wins, unless
`PF_DECKY_CLIENT=native` (or `flatpak`) is set in the plugin backend's environment. But
**pairing, Wake-on-LAN and the host game library still go through the Flatpak**, so install it
on the Deck even then. Both kinds share `~/.config/punktfunk`, so your identity, known hosts
and settings are the same either way.
build — the plugin uses that instead; with both installed the Flatpak wins, unless
`PF_DECKY_CLIENT=native` (or `flatpak`) is set in the plugin backend's environment. Both kinds
share `~/.config/punktfunk`, so your identity, known hosts and settings are the same either way.
**The client must be v0.22.0 or newer.** The panel drives everything through the client's
headless `punktfunk` command, which shipped in that release. An older client says so in the
panel, with the update button that fixes it right there.
3. **A Punktfunk host** running on your LAN — see [Install the Host](/docs/install). The Deck finds
it automatically over mDNS, so nothing to configure here.
@@ -64,40 +68,68 @@ The **Punktfunk** panel appears in the Quick Access Menu right away — no Deck
## Use it
Open the **Punktfunk** panel from the Quick Access Menu, or **Open Punktfunk** for the full-screen
page (host list + stream settings).
Open the **Punktfunk** panel from the Quick Access Menu. It has one list — the hosts you can
stream — plus a door into the client's own gamepad UI for everything else.
- **Discover** — hosts on your network appear automatically (mDNS). Tap **Refresh** to rescan. A
lock icon means the host requires [pairing](/docs/pairing).
- **Add a host by hand** — if mDNS can't reach it (another subnet, a VPN), tap **+** on the Hosts
tab and enter its address; the port defaults to **9777**. Saved hosts can be renamed, re-pointed
at a new address, or forgotten from the same row.
- **Sleeping host?** Streaming sends a [Wake-on-LAN](/docs/wake-on-lan) packet first, and when one
actually went out the Deck waits far longer than usual for the host to answer, so a stream
survives a resume from sleep. Nothing to enable — it's a no-op until the plugin has learned that
host's MAC address, and the packet only lands if the host machine is armed to wake in its
BIOS and its network card.
- **Pair** — for a locked host, [arm pairing on the host](/docs/pairing) (its console or web
console shows a 4-digit PIN), then enter that PIN on the Deck's keypad. Pairing persists, so the
next connection is silent.
- **Stream** — pick a host and the stream launches fullscreen in Gaming Mode. The plugin drives a
- **Hosts** — hosts on your network appear automatically (mDNS), alongside the ones you've already
saved. A saved host is also probed directly, so a box reached over a VPN or Tailscale shows as
online even though it never advertises. Tap **Refresh** to rescan. The list sorts online hosts
first, then whichever you streamed most recently. A lock icon means the host still has to let
this Deck in.
- **Let a host in** — tapping a locked host opens a small sheet with two ways through:
- **Request access** — no PIN at all. See [Request access](#request-access) below.
- **Use a PIN instead** — [arm pairing on the host](/docs/pairing) (its console or web console
shows a 4-digit PIN), then enter it on the Deck's keypad.
Either way the host is remembered, so the next connection is silent.
- **Stream** — tap a host and the stream launches fullscreen in Gaming Mode. The plugin drives a
hidden Steam shortcut behind the scenes so gamescope focuses and fullscreens it.
- **Library entry** — a visible, branded **Punktfunk** app also appears in your Steam library.
Launching it opens the client's console home (host picker, pairing, settings), gamepad-navigable
— it does not resume a stream. If it ever disappears, the Quick Access Menu panel has a button to
put it back.
- **Games** — tap **Games** on a host row to browse that host's [library](/docs/game-library), and
**Pin** the ones you play. Pinned games show up on the full page *and* in the Quick Access Menu
as one-tap streams that launch straight into the game.
- **Settings** — resolution, refresh rate, **render scale**, bitrate, **video codec**, gamepad type,
**host compositor**, and mic, written to the client the plugin launches. Leave **Resolution** /
**Refresh** on *Native* to get the Deck's own mode, **Render scale** at 1× unless you want to
trade bandwidth for sharpness (>1×) or sharpness for bandwidth (<1×), and **Video codec** /
**Host compositor** on *Automatic* — that suits almost every host, so change them only when
you're troubleshooting. With **Gamepad type** on *Automatic* the Deck's built-in controller is
forwarded as a **Steam Deck** pad (paddles, both trackpads, gyro) — that needs Steam Input set to
**Off** for Punktfunk (game page → ⚙ → Controller Settings), else Steam keeps those controls and
only sticks + buttons reach the host.
- **Sleeping host?** Streaming sends a [Wake-on-LAN](/docs/wake-on-lan) packet and waits for the
host to actually come back before dialling, so a stream survives a resume from sleep. Nothing to
enable — it's a no-op until the client has learned that host's MAC address, and the packet only
lands if the host machine is armed to wake in its BIOS and its network card.
- **Pinned cards** a host with pinned [settings profiles](/docs/client-settings) shows them
nested underneath it as `▸ <Profile name>`. Tapping one streams that host with that profile
applied — your "4K on the TV" and "battery saver" presets, one tap each. Pins are made in the
Punktfunk app (or any other client) and shared across all of them; the panel shows them, it
doesn't create them.
- **Open Punktfunk** — opens the client's console home: the host picker, adding a host by address,
pairing, browsing a host's [game library](/docs/game-library), and the **full settings screen**.
This is where resolution, bitrate, codec, audio, controllers and the stats overlay live.
- **Library entry** — a visible, branded **Punktfunk** app also appears in your Steam library, and
launching it opens that same console home — it does not resume a stream. If it ever disappears,
the Quick Access Menu panel has a button to put it back.
> **Where did the plugin's settings tab go?** Into the app, at **Open Punktfunk → Settings** — the
> same rows over the same settings, gamepad-navigable, and one tap from the same panel. The plugin
> used to carry its own copy of that screen, which meant two places to change one setting and a
> copy that fell behind. There is now one.
With **Controller type** on *Automatic* the Deck's built-in controller is forwarded as a **Steam
Deck** pad (paddles, both trackpads, gyro) — that needs Steam Input set to **Off** for Punktfunk
(game page → ⚙ → Controller Settings), else Steam keeps those controls and only sticks + buttons
reach the host.
### Request access
**Request access lets you in without typing a PIN**: instead of the host showing you a code, you
ask, and whoever is at the host approves the Deck in its [web console](/docs/web-console) or on
screen.
Tap the host → **Request access**. The Deck says *"Approve this Deck in <host>'s console — the
stream starts by itself"*, and the stream opens and waits. The moment somebody approves it, the
picture comes up — no going back to the panel, nothing else to tap. If nobody approves within
about three minutes, it gives up like any failed connection and you can try again or use a PIN.
It's the better option when you're not the person sitting at the host, or when reading a PIN off
another screen is awkward. Two things to know:
- The host must be **advertising on your network** for this to be offered. A host you added by
address (a VPN box, another subnet) has no advertised identity for the Deck to pin, so the sheet
offers the PIN path only and says so. That's a safety rule, not a limitation to work around:
pinning the advertised identity is what stops something else answering in the host's place while
the Deck waits.
- Once approved, the host shows as **paired** and every later stream connects silently.
> **Steam Input off is a trade-off, not a free win.** The plugin installs a Steam Input layout
> called **Punktfunk** and points its shortcuts at it, and that layout's whole job is making the
@@ -111,13 +143,22 @@ for about a second and a half, or close the "game" from the Steam overlay. Eithe
and drops you straight back to Gaming Mode. A quick press of the same four only releases captured
input, so it is safe to hit by accident.
**The Steam and `…` buttons stay with the Deck while streaming.** SteamOS opens its own menus for
them no matter what, so forwarding the raw press as well opened *both* menus at once — the Deck's
covering the stream. To reach the **host's** menus instead: **hold Select** for the host's Steam
menu ([how it works](/docs/input#the-guide-button-xbox--ps--steam-and-quick-access)), or open the
Punktfunk panel — while a stream runs it grows a **Host menus** section whose two buttons,
**Steam menu on host** and **Quick access on host**, press the button on the host and close the
Deck's own menu so the host's shows through. Want the raw forwarding back? **Open Punktfunk →
Settings → Steam / guide button** → *Send to host*.
## Updating
The plugin **checks for updates itself** — no Decky store needed. It covers **both** the plugin *and*
the streaming client (they version independently), so when either has a newer build the panel shows an
**Update** button (in the Quick Access Menu and on the full page). Tap it: the client updates in
place, and if the plugin itself changed it downloads, verifies, replaces itself, and reloads — all
without leaving Gaming Mode.
**Update** button at the top of the panel. Tap it: the client updates in place, and if the plugin
itself changed it downloads, verifies, replaces itself, and reloads — all without leaving Gaming
Mode.
One exception: if your client isn't one the plugin can install for you (a sysext, a nix profile, a
source build), the panel shows you the update **command** instead of a button — tap-to-install would
@@ -139,13 +180,16 @@ The plugin check follows the [channel](/docs/channels) you installed from: a plu
| Symptom | Fix |
|---|---|
| The stream never starts, **Pair** reports `flatpak-not-found`, or **Games** says the client isn't installed | Install the client Flatpak in Desktop Mode (see [Before you start](#before-you-start)). |
| No hosts listed | Make sure the host is running and on the **same LAN**; the Deck needs `avahi` (shipped on SteamOS). Tap **Refresh**. |
| Pairing fails / "not armed" | The PIN is shown only after you **arm pairing on the host**. Arm it, then enter the PIN within the window. |
| Stream launches but doesn't focus | Start it from the panel (not by launching the Flatpak by hand) so Steam/gamescope focuses it. |
| The stream wedges — black, or won't close | Open the full page → **About** tab → **Force-stop**, then start it again. |
| The **Punktfunk** library entry disappeared | Quick Access Menu → **Recreate library shortcut**; it puts the entry back in place. |
| You want a clean slate | **About** tab → **Reset Punktfunk** — clears saved hosts, stream settings and pinned games on this Deck, and keeps your paired identity. |
| The panel says **"Update the Punktfunk client"** | The installed client predates v0.22.0 and has no `punktfunk` command to drive. Tap the update button in the same panel, or update it in Desktop Mode. |
| The stream never starts, or the panel can't reach the client | Install the client Flatpak in Desktop Mode (see [Before you start](#before-you-start)). |
| No hosts listed | Make sure the host is running and on the **same LAN**. Tap **Refresh**. For a host mDNS can't reach, add it by address in **Open Punktfunk → Add host**. |
| Pairing fails / "not armed" | The PIN is shown only after you **arm pairing on the host**. Arm it, then enter the PIN within the window — or use **Request access** instead, which needs no PIN. |
| **Request access** isn't offered | The host isn't advertising on this network, so there's no identity to pin. Use the PIN path. |
| A request-access stream sits there | That's it waiting — somebody has to approve the Deck on the host. It gives up after about three minutes. |
| Stream launches but doesn't focus | Start it from the panel (not by launching the client by hand) so Steam/gamescope focuses it. |
| The stream wedges — black, or won't close | Panel → **About****Force-stop**, then start it again. |
| The **Punktfunk** library entry disappeared | Panel → **Recreate library shortcut**; it puts the entry back in place. |
| You want a clean slate | **Open Punktfunk → Settings** for stream settings, or `punktfunk reset` in Desktop Mode to forget every saved host. Your paired identity is kept either way. |
Nothing here matching? The problem is probably on the host side — start at
[Troubleshooting](/docs/troubleshooting), which is organised by symptom (host not found, pairing
+10 -7
View File
@@ -354,7 +354,7 @@ macOS, iOS/iPadOS and tvOS. Android is one app, with Android TV being the same a
| iPhone · iPad | ✅ | ✅ | ✅ ⁴ | ✅ | ✅ | ❌ ³ |
| Apple TV | ⚠️ ⁵ | ✅ | ✅ ⁴ | ✅ | ✅ | ❌ ³ |
| Android · Android TV | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ ³ |
| Decky (Steam Deck) | ⁶ | ❌ | ⁷ | ❌ | ✅ | ✅ ⁸ |
| Decky (Steam Deck) | ⚠️ ⁶ | ❌ | ⚠️ ⁷ | ❌ | ✅ | ✅ ⁸ |
| `punktfunk` CLI | ✅ | ✅ ⁹ | ✅ | ✅ | ✅ | ❌ |
| Moonlight | ❌ ¹⁰ | ❌ ¹⁰ | ✅ ¹¹ | ❓ ¹² | ❓ ¹² | ❓ ¹² |
@@ -375,10 +375,12 @@ macOS, iOS/iPadOS and tvOS. Android is one app, with Android TV being the same a
exists, so a fresh Apple TV has none. Of the settings a profile can carry, tvOS also drops the
ones the platform has no input for: inverted scroll, modifier layout, variable refresh rate,
mouse mode and touch mode.
6. The plugin writes flat values into the shared client settings; it has no profile surface. The
client it launches still honours whatever profile that settings file names.
7. Including pinned one-tap "Stream *game*" rows in the Quick Access Menu, which follow a host
across IP changes. Not subject to the desktop opt-in.
6. The panel *shows* the profiles a host has pinned, as nested one-tap cards, and streams with
them; it has no profile surface of its own. Pins are made in a client's own UI — including the
console home **Open Punktfunk** opens — and are shared, so every client shows the same cards.
Creating and editing a profile stays a desktop-app job.
7. Not in the panel: **Open Punktfunk** opens the client's console home, and a paired host's
library is one button from there.
8. Both the plugin itself and, where the install kind allows it, the client it launches.
9. The CLI parses and follows links; it does not register the URL scheme — the graphical apps do.
10. [Profiles and links](/docs/profiles-and-links) are Punktfunk-app concepts and do not exist on
@@ -400,7 +402,8 @@ macOS, iOS/iPadOS and tvOS. Android is one app, with Android TV being the same a
1. Multiple controllers, each on its own stable slot, arriving and leaving independently. The pad
**type** the host emulates is picked per pad; the pickers are not identical across apps — Linux,
Android and Decky offer six presets including Steam Deck, Windows and Apple offer five.
Android and the console home offer six presets including Steam Deck, Windows and Apple offer
five.
2. DualSense and DualShock 4 touchpad and motion are forwarded, and the host's adaptive-trigger and
lightbar effects are replayed on a real DualSense. On the desktop clients any controller SDL
exposes a gyro on forwards motion — a Switch Pro or the Steam Deck's own pad included — and the
@@ -514,7 +517,7 @@ capability.
| **Linux and Windows desktop clients** | Packaged and current. They are one codebase: the same session binary streams for both, and for the Decky plugin and the `punktfunk` CLI. |
| **Apple client** (macOS · iOS · iPadOS · tvOS) | One universal build, distributed as a **TestFlight beta**; the Mac also has a notarized DMG. Feature-complete apart from the platform gaps named above (no microphone on tvOS, clipboard on macOS only). |
| **Android client** (phone · TV) | Published on **Google Play** as a public listing for releases, with an invite-only Internal testing track for canary, plus a sideloadable APK. The same app in leanback mode is the TV client. |
| **Decky plugin** (Steam Deck) | Ships through install-from-URL rather than the Decky store, and keeps itself and the client it launches up to date. It launches the Linux client rather than streaming itself, and has no settings surface of its own beyond the flat values it writes into the shared client settings. |
| **Decky plugin** (Steam Deck) | Ships through install-from-URL rather than the Decky store, and keeps itself and the client it launches up to date. It is a launcher, not a second client: it starts the Linux client rather than streaming itself, and holds no settings, no library and no host editor of its own — its **Open Punktfunk** button hands all of that to the client's console home. |
| **Web console** | The full management surface — dashboard and sessions, pairing, library, displays, plugins and the plugin store, logs, stats, settings, and host updates. It cannot yet run a speed test or set a bitrate; the client apps can. |
| **Plugins** | Three first-party ones (ROM Manager, Playnite, VirtualHere) plus the SDK, installed from the console. See [Plugins](/docs/plugins). |
| **`pf-webos`** (LG TV) | A community client in a separate repository. Nothing here can establish its state; ask that project. |
+12 -7
View File
@@ -74,9 +74,10 @@ saved host's own menu, and only appears when that host is offline *and* an addre
| Android · Android TV | **Wake host** — waits, showing the "Waking…" screen | **Wake-on-LAN MAC** in **Edit host** |
| Punktfunk Console (controller shell) | on an offline host with a known address, the confirm button reads **Wake & Connect** — it waits, then connects | not offered |
Punktfunk Console has no auto-wake setting of its own, and offers **Wake & Connect** whatever the
desktop app's setting says. In the Apple apps the same button appears when you drive them with a
controller, but there it does follow the auto-wake setting.
Punktfunk Console carries the row too — **Wake hosts automatically**, in the same settings list the
desktop apps write — but its **Wake & Connect** button is an explicit action and appears whatever
that row says. In the Apple apps the same button appears when you drive them with a controller, but
there it does follow the auto-wake setting.
The Apple apps also publish a **Wake Host** action to Shortcuts, so an automation can wake a host
without opening the app. On iPhone and iPad it has a ready-made phrase: *"Wake ⟨host⟩ with
@@ -88,10 +89,14 @@ host list, and shows an explanation with a link to system settings if you declin
### On the Steam Deck
The [Decky plugin](/docs/steam-deck) has no wake button and no wake setting. It sends a wake through
the Flatpak client just before **every** stream launch, and it is a no-op until that client has
learned the host's address. When a packet really did go out, the plugin also stretches the stream's
connect budget to 75 seconds, so the connection survives the host resuming from sleep.
The [Decky plugin](/docs/steam-deck) has no wake button and no wake setting of its own. It starts
every stream through the client, so the wake is the client's, on exactly the terms above: a packet
the moment the host doesn't answer, re-sent every 6 seconds while the client watches for it once a
second, and the dial only when it really is back. It follows **Wake hosts automatically** in the
client's own settings — **Open Punktfunk → Settings** from the same panel — and is a no-op until the
client has learned that host's MAC address. (The plugin used to fire a packet itself and stretch the
connect budget to 75 seconds to cover the resume; a wait that watches for the host beats a fixed
budget, so that is gone.)
### From the command line