Compare commits

...
Author SHA1 Message Date
enricobuehler 2e753fd84d fix(gamepad): our virtual DualSense wakes Bazzite's ds_inhibit into an SELinux audit storm that freezes the stream
ci / rust-arm64 (pull_request) Successful in 1m23s
ci / bun-nix (pull_request) Successful in 1m26s
ci / web (pull_request) Successful in 4m22s
ci / docs-site (pull_request) Successful in 4m32s
ci / rust (pull_request) Failing after 4m57s
android / android (pull_request) Successful in 13m27s
Field-diagnosed on Bazzite 43 (2026-08-15): the virtual DualSense/DualShock 4
binds hid-playstation, and Valve's ds_inhibit (steamos-manager) reacts to every
open/close of any such hidraw by walking /proc/*/fd — it has no VID/PID or
virtual filtering. SELinux denies steamos_manager_t that walk (sys_ptrace,
dac_read_search, dac_override) at ~324 AVCs/sec, and setroubleshootd amplifies
the flood into a box-wide fork storm (267+ procs/sec, a core burned, RSS
climbing for 15+ min AFTER the denials stop) that starves the stream: gamescope
0 fps, encode submit ~150 ms/frame, tx 300 -> 1 Mbps, session death. punktfunk
is the trigger, not the defect — but we ship the trigger.

- packaging/bazzite/punktfunk-ds-inhibit.cil: a dontaudit drop-in (dontaudit,
  not allow — granting another vendor's daemon sys_ptrace/dac_* is not ours to
  do; the scan keeps failing quietly and ds_inhibit leaves the pad
  uninhibited, which is what we want anyway). The RPM ships the source under
  /usr/share/punktfunk/selinux/ (the policy STORE is host state, so a sysext
  image can only carry source); inserted idempotently by punktfunk-sysext
  post_merge / reapply and best-effort by the RPM %post, both keyed on the
  steamos-manager binary and on the module name — rename the .cil if its rules
  ever change, or existing installs never converge.
- native/gamepad.rs: warn_if_ds_inhibit_storm in the resolve_gamepad funnel —
  one-shot, warn-only (a per-pad degrade has no wire channel back to the
  client and would strip the DS5 feature set exactly where users want it).
  Fires on steamos-manager running + SELinux enforcing, and puts the cause in
  OUR logs: the AVC lines read comm="tokio-rt-worker" and look like us.
- packaging/bazzite/README.md: the failure chain, both diagnosis traps, and
  the setroubleshootd mask as general hardening (any AVC burst reproduces the
  amplifier; nothing depends on that daemon).

Not pursued: suppressing the touchpad mouse node to duck ds_inhibit's
selection — hid-playstation registers the touchpad from hardcoded driver code
(ps_touchpad_create in dualsense_create/dualshock4_create), not from our HID
descriptor, so no descriptor shaping can remove it.

Verified: gamepad tests incl. the new detection test pass on linux-gnu
(punktfunk-rust-ci container); clippy --all-targets -D warnings clean; the CIL
compiles under secilc against a stub base (planted-error control caught);
shellcheck clean on punktfunk-sysext.sh.
2026-08-15 14:23:07 +02:00
enricobuehler a17571c6bf Merge pull request 'Per-client access control: grants + temporary guest access, host-enforced end to end (WP1–WP13)' (#245) from worktree-per-client-access-a0 into main
apple / swift (push) Successful in 2m2s
ci / rust-arm64 (push) Successful in 3m57s
ci / web (push) Successful in 1m16s
ci / docs-site (push) Successful in 1m22s
ci / bun-nix (push) Successful in 18s
android / android (push) Successful in 8m2s
arch / build-publish (push) Successful in 10m39s
deb / build-publish-gamescope (push) Successful in 29s
deb / build-publish (push) Successful in 4m21s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 28s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 18s
deb / build-publish-client-arm64 (push) Successful in 1m16s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 13s
deb / build-publish-host (push) Successful in 4m58s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 13s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 11s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
apple / distribute (push) Successful in 10m56s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 46s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m8s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m26s
docker / builders-arm64cross (push) Successful in 1m22s
ci / rust (push) Failing after 20m5s
docker / deploy-docs (push) Successful in 1m53s
apple / screenshots (push) Successful in 9m44s
flatpak / build-publish (push) Successful in 11m4s
deb / smoke-install (push) Successful in 8m3s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 11m47s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m13s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 17m52s
windows-host / package (push) Successful in 20m52s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 1m49s
Reviewed-on: #245
2026-08-15 09:51:19 +00:00
enricobuehler 97f81a6ea6 Merge origin/main — the security sweep meets the access branch: ABI re-bumped to 22 (ex10 took 21), the peer gate and the grant mask compose, and the approve dialog states which knock it is
ci / bun-nix (pull_request) Successful in 58s
ci / web (pull_request) Successful in 1m20s
ci / docs-site (pull_request) Successful in 1m39s
apple / swift (pull_request) Successful in 1m58s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 3m7s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 14m14s
android / android (pull_request) Successful in 19m8s
ci / rust-arm64 (pull_request) Successful in 20m19s
ci / rust (pull_request) Failing after 20m24s
2026-08-15 11:50:01 +02:00
enricobuehler b8ec8ea260 Merge pull request 'security: fix 13 findings from the 2026-08-15 whole-project sweep' (#244) from worktree-security-review-2026-08-15-fixes into main
apple / swift (push) Successful in 1m58s
ci / web (push) Successful in 1m26s
ci / docs-site (push) Successful in 1m16s
ci / bun-nix (push) Successful in 34s
android / android (push) Successful in 7m53s
deb / build-publish-gamescope (push) Successful in 1m6s
deb / build-publish-client-arm64 (push) Successful in 1m1s
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 7s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 6s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 7s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 8s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
deb / build-publish (push) Successful in 5m22s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 47s
deb / build-publish-host (push) Successful in 5m31s
apple / distribute (push) Canceled after 10m30s
apple / screenshots (push) Canceled after 0s
ci / rust-arm64 (push) Successful in 11m10s
arch / build-publish (push) Canceled after 11m26s
ci / rust (push) Canceled after 11m29s
deb / smoke-install (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 11s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 1m7s
docker / deploy-docs (push) Canceled after 0s
flatpak / build-publish (push) Canceled after 1m3s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 1m4s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 18s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 1m28s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 0s
windows-host / package (push) Canceled after 0s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
2026-08-15 09:37:08 +00:00
enricobuehler 4aeec6051d harden: HTTP Content-Length overflow trap, and forward-slash UNC art paths
apple / swift (pull_request) Successful in 2m3s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / bun-nix (pull_request) Successful in 3m11s
ci / web (pull_request) Successful in 3m11s
ci / docs-site (pull_request) Successful in 3m25s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 4m34s
android / android (pull_request) Successful in 5m0s
ci / rust (pull_request) Failing after 9m57s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 8m53s
ci / rust-arm64 (pull_request) Successful in 14m37s
security-review 2026-08-15, two low/informational findings.

- clients/apple HTTPResponse: a malicious host sending Content-Length = Int.max
  made bodyStart + length overflow, and Swift integer overflow TRAPS (an
  uncatchable crash) rather than throwing. Use addingReportingOverflow and reject.
  Verified in the Apple build.
- host library/art.rs: art_path_is_confined's UNC guard was a leading double-
  backslash string test, so forward-slash (//server/share) and mixed UNC forms
  slipped past it, and canonicalize() itself would then coerce the SYSTEM host
  into outbound SMB auth. Reject ANY two leading path separators before touching
  the filesystem.
2026-08-15 11:15:54 +02:00
enricobuehler da13d14159 fix(ci): verify the pinned bun windows zip by sha256 before staging it
security-review 2026-08-15 finding 12 (windows). bun-windows-x64.zip was
downloaded and Expand-Archived with no integrity check, then Authenticode-signed
into the installer and its hash published in the Ed25519 update manifest — our
signature vouching for bytes we never verified (GitHub release assets are mutable
at a fixed URL). Pin and verify the sha256. The Linux curl|bash sites
(arch/rpm/deb + builder Dockerfiles) still need version+hash pinning — tracked.
2026-08-15 11:15:29 +02:00
enricobuehler bb781ca694 fix(gamestream): refuse an ambiguous pairing-PIN submit
security-review 2026-08-15 finding 7. The PIN is a single global slot with no
binding to a specific handshake, so with N parked getservercert waiters whichever
polls first takes it — an attacker who floods the parking slots while the
operator pairs could take the operator's PIN and pin its own certificate. Real
pairing is one client at a time, so PinGate::submit now refuses (returns false)
when more than one handshake is parked, and POST /pair/pin answers 409. This
narrows the window to a tight post-submit timing race; the full fix keys the gate
by uniqueid (mgmt API + console change, tracked separately). Compiles on .133.
2026-08-15 11:15:27 +02:00
enricobuehler 662df795b2 fix(host/windows): distrust a non-admin-owned host.env / web-password
security-review 2026-08-15 findings 3c and 4. %ProgramData% lets BUILTIN\Users
pre-create the punktfunk dir and plant host.env / web-password before a
privileged install runs; the bytes were then adopted verbatim (SYSTEM service
environment + command line; the console password), with the plant's owner erased
by the dir re-own that runs first.

Add install::is_admin_owned() (reads the file owner SID via GetNamedSecurityInfoW,
reusing privileged_sids()) and consult it BEFORE create_private_dir re-owns the
file:
- ensure_default_host_env: a non-admin-owned host.env is renamed aside and the
  default written over it (the !planted skip forces the overwrite even if the
  rename fails).
- set_web_password: a non-admin-owned password file is rotated to a fresh random
  instead of kept as an 'upgrade'.

A file from a prior privileged install is Administrators-owned and is kept.
Compiles clean on the windows-amd64 box (.133). The installer-side .iss freshness
signal (which also gates the password page) is a separate follow-up.
2026-08-15 11:15:13 +02:00
enricobuehler 9a05750376 fix(apple): require a pinned host identity before browsing its library
security-review 2026-08-15 finding 8. MgmtTransport's verify block replaces
system trust wholesale (the host cert is self-signed, no SAN) and, for a host
with no pinnedSHA256, accepted ANY certificate trust-on-first-use with no prompt
or log. A host can be saved yet pin-less (manual add, deep link, abandoned
pairing, or after Forget Identity), so a LAN MITM could serve a forged catalog
and harvest the device's mTLS pairing identity.

Gate the library entry points on host.pinnedSHA256 != nil — HomeView's browse
action, GamepadHomeView's hasLibrary tile flag, and a load() guard in LibraryView
(covering the deep-link path) — mirroring how the stream path already refuses an
unpinned connect. The transport's silent-accept is left for a follow-up (it is
also reached pre-pairing, so tightening it needs the QUIC path's approval flow).

NOTE: not compiled locally (no Xcode on the build host); verify on the Apple CI.
2026-08-15 10:52:54 +02:00
enricobuehler 6cd25b4829 fix(host/windows): allow-list host.env keys loaded into the SYSTEM service
security-review 2026-08-15 finding 3 (part 1). load_host_env imported EVERY key
of %ProgramData%\punktfunk\host.env into the LocalSystem service's own
environment. Since %ProgramData% lets BUILTIN\Users pre-create the dir, an
unprivileged user could plant host.env before install; a planted SystemRoot then
redirected the absolute icacls.exe / powershell.exe paths pf-paths and the
network-profile warner build from it — code execution as SYSTEM. Import only the
PUNKTFUNK_* / RUST_LOG keys the child already allow-lists at the spawn boundary,
closing the SystemRoot/PATH class of sinks.

Residual (planted PUNKTFUNK_HOST_CMD / PUNKTFUNK_CONFIG_DIR, which are legitimate
installer knobs) needs distrusting a non-admin-owned host.env — findings 3c/4,
which share an installer provisioning-signal decision and Windows build
verification; tracked, not yet fixed here.
2026-08-15 10:50:24 +02:00
enricobuehler 4369e3b2ec fix(ci): namespace the fork-PR cargo cache, log out the docs-deploy PAT
security-review 2026-08-15 findings 5 and 14.

- ci.yml: the cargo-home cache shared its unnamespaced key with the signed
  release builds (deb.yml / android.yml). registry/src holds already-extracted
  crate sources cargo compiles without re-checksumming, so a fork PR could poison
  a release artifact through the shared pool. Namespace ci.yml's key to
  cargo-home-ci- so its (untrusted) caches never reach the release pool. The
  fork-approval gate remains the definitive operator-side control.
- docker.yml: the deploy-docs SSH step left a write:package PAT base64-encoded in
  ~/.docker/config.json on the long-lived internet-facing docs VM. Add a
  trap ... EXIT docker logout so it is cleared on every exit path, matching the
  ephemeral LAN-registry jobs.
2026-08-15 10:48:26 +02:00
enricobuehler ff33eb872e fix(web): gate PUT /api/v1/library/provider/{p} behind the console password
security-review 2026-08-15 finding 6. confirmIfCommandExecution was wired into
only the two custom-entry routes; the provider reconcile route had no BFF handler
and fell through to the /api/** catch-all, which injects the full admin bearer —
so a bare session cookie could plant a persistent prep/launch.kind:command entry
without the password. Add the missing handler so it runs the same
command-execution gate before forwarding (an ordinary catalog reconcile is
untouched).
2026-08-15 10:48:17 +02:00
enricobuehler 6ada74066d fix(pyrowave): reject a sub-header packet so the decode cursor always advances
security-review 2026-08-15 finding 9. push_packet advanced by
payload_words*4; a 12-bit payload_words of 0 passed the length guard, and
decode_packet's duplicate-block early return fired before its own minimum-size
check — so a duplicate block_index with payload_words==0 spun the client decode
thread at 100% CPU forever (no allocation, no timeout, inside FFI). Hoist the
minimum-size check into push_packet before decode_packet is consulted. Carried
as vendored patch 0008.
2026-08-15 10:48:10 +02:00
enricobuehler 59d8b8a677 fix(core): meter per-block reassembly state against the in-flight budget
security-review 2026-08-15 finding 11. The reassembler's memory firewall counted
only FrameBuf::buf bytes; BlockState (have_data + recovery vectors, both sized
from attacker-declared header fields) was allocated unmetered. A slice-streamed
frame can mint thousands of distinct-index blocks while keeping the metered
buffer pinned near zero, committing multiple GB against a ~13 MB accounted
figure — a deterministic remote client OOM from a hostile/compromised host.

Add block_state_bytes()/frame_cost(); gate each new block on the same
IN_FLIGHT_BUF_FACTOR x max_frame_bytes budget as the frame buffer, and release
the full frame cost (buffer + block state) at every removal site.
2026-08-15 10:48:03 +02:00
enricobuehler 6b3e793b39 fix(gamestream): bind the UDP/ENet media plane to the launch owner
security-review 2026-08-15 findings 1, 2, 13. The Moonlight-compat plane bound
its UDP video/audio endpoints to the first datagram from anyone and let any ENet
peer keep a connection (pinning per-peer reassembly memory) — the peer_ip the
RTSP/launch planes already enforce was never threaded to the media/control
sockets.

- stream.rs/audio.rs: the video/audio endpoint learn now discards datagrams whose
  source IP is not the launch owner's until the 10s budget is spent, so an
  off-path LAN peer can no longer win the endpoint race and be handed the
  (plaintext) video stream.
- control.rs: an OwnerFilteredSocket drops non-owner datagrams before ENet
  allocates any per-peer state (closes the ~32 MiB x peer_limit pin and the
  source-spoof injection variant), and the Event::Receive arm now honors only the
  tracked session peer's input as defense-in-depth.

GameStream is runtime opt-in and off in the shipped unit, so this is deferrable
but the code's own comments claimed a peer bind already protected these paths.
2026-08-15 10:47:55 +02:00
enricobuehler ab55dd4e39 Merge pull request 'Every device pending approval was called "This device" — the C ABI connect had no name parameter' (#243) from worktree-pairing-friendly-device-name into main
apple / swift (push) Successful in 1m58s
android / android (push) Successful in 7m57s
ci / docs-site (push) Successful in 1m19s
ci / bun-nix (push) Successful in 20s
ci / rust-arm64 (push) Successful in 7m38s
ci / web (push) Successful in 7m49s
deb / build-publish-gamescope (push) Successful in 39s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 40s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m13s
deb / build-publish (push) Successful in 5m4s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 11s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
deb / build-publish-host (push) Successful in 5m20s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 18s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 18s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 1m17s
ci / rust (push) Successful in 12m39s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
deb / build-publish-client-arm64 (push) Successful in 2m42s
apple / distribute (push) Successful in 10m51s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 58s
docker / builders-arm64cross (push) Successful in 1m22s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 2m57s
docker / deploy-docs (push) Successful in 43s
deb / smoke-install (push) Successful in 4m26s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 7m16s
arch / build-publish (push) Successful in 19m39s
apple / screenshots (push) Successful in 9m41s
flatpak / build-publish (push) Successful in 10m42s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m30s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 23m18s
windows-host / package (push) Successful in 19m49s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 22s
2026-08-15 08:42:53 +00:00
enricobuehler ab8fa46b66 feat(web): access in one dialog — approve, arm, and the column that counts down (per-client access WP7+WP8)
android / android (pull_request) Canceled after 0s
apple / swift (pull_request) Canceled after 0s
apple / distribute (pull_request) Canceled after 0s
apple / screenshots (pull_request) Canceled after 0s
ci / rust (pull_request) Canceled after 0s
ci / rust-arm64 (pull_request) Canceled after 0s
ci / web (pull_request) Canceled after 0s
ci / docs-site (pull_request) Canceled after 0s
ci / bun-nix (pull_request) Canceled after 0s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Canceled after 0s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Canceled after 0s
One component family (sections/Pairing/access.tsx) serves all three grant
moments: the approve dialog (Full + Forever defaults per D1, one-click
'Approve as guest' = Controller only + 4 h per D2/D4, stored-access pre-fill
on the expired-guest re-knock), the arm card, and the paired-row edit sheet
(partial PATCH: extend / expire now / make permanent / remove).

The Access column derives its chip + countdown client-side from expires_unix
on ONE shared interval (no refetch storms), keeps expired rows listed as
'Expired' (D3), gives Moonlight rows an honest 'Full (ungoverned)' chip and
no editor, and renders '—' against hosts older than the fields — access_level
is the presence sentinel, and every new field is read defensively.

35 new strings in en + de; stories for both dialogs, the column matrix
(incl. old-host and Expired), and the edit sheet, on a fixed fixture clock.
2026-08-15 10:16:15 +02:00
enricobuehler c32fad8aee feat(gamestream): the Moonlight plane honors grants — launch, input, and the clock (per-client access WP13)
The grants registry serves both paired stores, keyed on fingerprint hex
(design §8): a Moonlight fingerprint with NO record is ungoverned — an
existing pairing keeps full control (back-compat) — while a record that
exists (created via the console) governs exactly as on the native plane,
via the new NativePairing::moonlight_effective (one store snapshot, so a
deletion can't race into reading expired).

nvhttp: /launch and /resume check LAUNCH + expiry beside peer_is_paired
(an expired record fails closed exactly like unpaired); /cancel gates on
expiry only — it is Moonlight's Quit App, owner-restricted already, and
denying a downgraded owner its own quit would only wedge the session.

Control thread: the session's owner_fp resolves to the same mask, folded
per 2 ms tick from the fingerprint's watch channel; every decoded event
passes one mask test against the exhaustive classifier before injection
(deny-at-setup for pads — no GAMEPAD, no uinput node), with per-class
counters, one warn per class, totals at session end. The deadline check
rides the same tick and ends the session through quit_session — the
host-side-ended arm's TERMINATION + disconnect is the whole message,
since GameStream has no AccessUpdate vocabulary (silent enforcement,
accepted by the design).
2026-08-15 10:02:42 +02:00
enricobuehler 6753641c5e feat(mgmt): access on the wire of record — grants in the payloads, and PATCH to change them
The management API is where grants become operable (WP6): the paired-client and
pending-device payloads carry grants/expiry/grant-time plus a derived
access_level preset name, the approve and arm requests take an optional access
choice (expiry RELATIVE in the API, stored absolute), and a new
PATCH /native/clients/{fingerprint} does partial access edits — omitted halves
keep their current value, clear_expiry makes access permanent. Reserved grant
bits are a 400, never silently cleared.
2026-08-15 10:00:21 +02:00
enricobuehler 19411d8d6d feat(host): sessions now carry their grants — admission, expiry, and the input plane enforce them (per-client access WP3–WP5)
Admission consults effective() (an expired record knocks into the pending
list; re-approval is the re-grant), the Welcome advertises the real mask +
remaining lifetime, and a per-session lifecycle task owns the deadline:
wall-clock re-evaluated every lap, AccessUpdate warnings at T−5m/T−1m,
console edits folded into the live Arc<AtomicU32> within one watch event,
and the typed 0x69 close on expiry / expire-now / unpair.

The datagram dispatch classifies every plane against that one atomic before
offer() (one relaxed load per event; per-class counters, one warn per class),
the input thread re-guards the pad-creating arms (deny-at-setup: no GAMEPAD,
no uinput/pad-audio), launch without LAUNCH is a typed 0x6A refusal before
the handshake, and clipboard ANDs the grant into the operator policy — new
CLIP_REASON_NOT_PERMITTED (5), coordinator never starts ungranted.

Events: access.granted / access.changed / access.expired from the facade
choke points and the deadline fire.
2026-08-15 09:42:26 +02:00
enricobuehler b2e6debb22 feat(client): the desktop session wears its access level — live grants on the connector, capture that asks first, the chip, and honest endings (per-client access WP9)
The connector now carries the session's LIVE access truth: the Welcome advert
seeds NativeClient::access_grants / access_deadline_unix (client-anchored, so
skew never moves the countdown), the control task folds every MSG_ACCESS_UPDATE
in latest-wins before waking next_access_update, and a typed mid-session close
latches as end_reject — an access expiry now ends as "your access to this host
has expired", not "the host ended the session with an error".

pf-client-core surfaces it as SessionEvent::Access { SessionAccess, notice }
(module `access`: derived preset labels, chip text, toast wording — the rules
the Apple/Android ports mirror), gates the mic uplink and clipboard bridge at
spawn on their grants (deny-at-setup, client half), and follows a live MIC edit
by stopping/starting the uplink without a reconnect.

The presenter gates capture on the mask (§7 "not capture what can't land"): no
pointer lock without POINTER, no keyboard grab without KEYBOARD, engage refuses
outright when neither is granted (the hint pill stays down), every wire send
funnels through the host's own classify(), and a live edit flushes what a
removed class still held. The overlay wears the chip — "Controller only · ends
in 1 h 58 m", top-right beside the mic badge, at every stats tier — and the
T−5 m / T−1 m warnings ride the pill slot as toasts. Full-control permanent
(every old host) renders exactly today's look.
2026-08-15 09:30:59 +02:00
enricobuehler 1bed82423d feat(android): the stream knows its access level — router gating, the chip, and expiry toasts
The Android leg of per-client-access.md §7 (WP11). The bridge grows one
poll shim, nativeAccessState -> [grants, remainingSecs, updateSeq]: the
connector already folds every AccessUpdate latest-wins into its live
grants/deadline slots, so Kotlin polls the fold ~1 Hz alongside its
session-ended watchdog instead of holding a blocking event thread; the
seq counter is only how a fresh update (the host's T-5m/T-1m warnings)
is told apart from state the poll would re-read anyway. The countdown is
clamped to >= 1 once a deadline exists — 0 stays the permanent sentinel.

Kotlin gates what can't land rather than capturing it: GamepadRouter's
wire sends fold the GAMEPAD grant into the existing forwarding gate
(slots and the exit/mic/stats chords stay alive — they are local
controls that happen to sit on pad buttons, and the phone-gyro mirror
stands down through the same sendsEnabled read); without POINTER the
touch/stylus gesture layer is never installed, the mouse forwarder goes
inert and never grabs the pointer, and the TV remote can't enter pointer
mode; without KEYBOARD the VK path consumes without sending and the IME
summon (gesture and remote toggle) declines; without MIC no capture
opens — the recording indicator must not announce a mic nobody can hear
— and a mid-session revocation stops a running one; without CLIPBOARD
the sync never starts.

StreamScreen carries the Access chip top-end in the shared pill family
("Controller only · 1 h 58 m left"), composed only when there is
something to say — a full-control permanent session, which is every
session against an old host, looks exactly like today. The expiry
warnings surface as toasts, and a session that dies inside the final
countdown is worded with the shared rejection sentence ("Your access to
this host has expired") — recognized off the countdown because the
generic end-reason byte predates the typed close. ConnectErrors learns
the two new reject tokens (access-expired, launch-not-permitted).

Verified: cargo clippy -D warnings + fmt (host target), gradle
:kit:/:app:compileDebugKotlin, :kit:+:app: unit tests (new
SessionAccessTest pins the bit mirror and the preset labels), and the
release cargo-ndk cross-build of all three ABIs. The gradle
cargoNdkClippy leg could not complete on this machine — the shared disk
filled mid-run (environment, not code; the Rust delta is
target-independent and is covered by the host clippy + the ABI builds).
2026-08-15 01:55:29 +02:00
enricobuehler ee6dff116c feat(apple): the session wears its access level — chip, countdown, and capture that asks first
ABI v21 (per-client access WP10): punktfunk_connection_grants and
punktfunk_connection_access_expires_in read the session's LIVE access
state (Welcome seed, latest-wins over every mid-session AccessUpdate),
and punktfunk_connection_end_reject surfaces the typed rejection a
mid-session close carried, so an access expiry renders its real
sentence instead of the generic host-error one. NEW symbols only; the
Rust-side live slots they read landed with the pf-client-core work.

Swift: PunktfunkConnection wraps the three (grant bits, the derived
AccessLevel labels, and class-gated send funnels — key/pointer/pad/pen/
mic events the grants exclude never leave the device); SessionModel
polls at the 1 Hz stats tick for the chip ("Controller only · ends in
1 h 58 m"), the T−5 m / T−1 m warning toasts, mic + clipboard hiding,
and the live release of an engaged capture on revoke; macOS gates
engage + the cursor grab and the iPad gates pointer lock on the bits;
tvOS states the level as a stats-overlay line instead of a chip. A
full-and-permanent session — every old host — renders exactly today's
UI.
2026-08-15 01:53:48 +02:00
enricobuehler 25487a8bd4 feat(host): grants on the trust record — and re-pairing can no longer escalate (per-client access WP2)
PairedClient grows grants/expires_unix/granted_unix (serde-defaulted; an
absent field means full/permanent, so pre-grants stores decode unchanged).
effective(fp, now) is the new authorization verb — None when unpaired OR
expired, reserved bits masked on read — while is_paired() stays the
expiry-blind listing verb (both documented in the facade header).

The security-critical change: add() is now name-only for an existing
fingerprint. It used to replace the record, so a guest limited to
Controller · tonight could re-run the pairing ceremony and silently walk
back to full control forever. The authorized grant paths take an explicit
Access (grants + absolute expiry): add_with_access, set_access, the
approve dialog via approve_pending(.., access), and the armed PIN window
via arm_for(.., access) — the ceremony reads armed_access() before the
single-use consume wipes it. A test now fails if add() ever escalates
again (plan §8 risk table).

NativePairing also gains the access watch registry: one watch channel per
fingerprint carrying (masked grants, raw deadline, revoked). Every
mutation — pair, edit, unpair — publishes through it, so a console edit
or unpair reaches every live session within one event (design §5.6);
sessions subscribe() at admission (WP3). Existing call sites pass None
everywhere: enforcement wiring is WP3, the mgmt/console fields are WP6/7.
2026-08-15 01:46:59 +02:00
enricobuehler 97fe3a0ff5 feat(core): the grant vocabulary — six bits, one exhaustive classifier, and the Welcome advert (per-client access WP1)
The wire layer of design/per-client-access.md: quic/access.rs carries the
GRANT_* bits (u32, reserved-must-be-zero), the three presets, and the
exhaustive InputKind -> GrantClass classifier whose wildcard-free match is
the default-deny mechanism — a new input kind now fails to compile until
someone decides its grant class.

AccessUpdate { grants, remaining_secs } rides the control stream as 0x58
(verified free; both peers' dispatch loops drop unknown ids with a warn,
so old clients just miss the courtesy). reject.rs grows the 0x69/0x6A
close codes -> RejectReason::{AccessExpired, LaunchNotPermitted}, mirrored
into PunktfunkStatus -30/-31 and the shared client-facing sentences.

The Welcome advertises grants + expires_in_secs as trailing fields one
link past mgmt_port, with the same placeholder discipline: emitting the
advert forces the cipher byte and the mgmt port so the two u32s land at a
deterministic offset, while a full-control permanent session stays
byte-identical to the pre-grants wire form. Absent fields decode to
GRANT_ALL / permanent — exactly what an old host enforces. No
WIRE_VERSION bump (trailing fields, per the v20 mgmt_port precedent), no
ABI bump (no new C symbols; the header gains prefixed defines and two
appended status values only). The host sends GRANT_ALL until WP2/WP3
wire the trust store in.
2026-08-15 01:23:36 +02:00
enricobuehler 00f9c1f4d3 Merge pull request '0.29.0' (#242) from worktree-release-0290 into main
audit / bun-audit (sdk) (push) Successful in 26s
audit / cargo-audit (push) Successful in 38s
audit / pnpm-audit (push) Successful in 12s
audit / bun-audit (plugin-kit) (push) Successful in 1m40s
audit / docs-site-audit (push) Successful in 1m39s
audit / bun-audit (web) (push) Successful in 1m39s
audit / license-gate (push) Successful in 4m34s
audit / miri (push) Successful in 5m4s
ci / web (push) Successful in 1m2s
ci / bun-nix (push) Successful in 16s
ci / docs-site (push) Successful in 1m13s
audit / c-abi-asan (push) Successful in 11m33s
ci / rust-arm64 (push) Successful in 12m19s
nix / flake (push) Successful in 15m45s
ci / rust (push) Successful in 31m23s
deb / build-publish-gamescope (push) Successful in 36s
decky / build-publish (push) Successful in 34s
deb / build-publish-client-arm64 (push) Successful in 1m19s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 8s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 8s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 8s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 9s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
apple / swift (push) Successful in 2m12s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 55s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m20s
deb / build-publish-host (push) Successful in 4m13s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 5m26s
linux-client-screenshots / screenshots (push) Successful in 3m14s
android-screenshots / screenshots (push) Successful in 7m2s
sbom / sbom (push) Successful in 42s
deb / build-publish (push) Successful in 9m22s
docker / builders-arm64cross (push) Successful in 23s
docker / deploy-docs (push) Successful in 59s
arch / build-publish (push) Successful in 12m48s
web-screenshots / screenshots (push) Successful in 6m8s
android / android (push) Successful in 14m5s
flatpak / build-publish (push) Successful in 11m30s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 9m5s
apple / distribute (push) Successful in 13m54s
deb / smoke-install (push) Successful in 6m26s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m33s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 17m52s
apple / screenshots (push) Successful in 9m36s
windows-host / package (push) Successful in 19m10s
windows-host / canary-manifest (push) Skipped
windows-host / winget-source (push) Successful in 20s
2026-08-14 23:18:48 +00:00
enricobuehler 18f595698c docs(site): access levels — the three presets, expiry, and what they honestly do not cover
The new owner-facing Access levels page documents the Full/Controller/View
presets, the six Advanced toggles, wall-clock expiry with T-5m/T-1m warnings
and one-click re-grant, and the three honest limits: shared-desktop
visibility is not isolated, Moonlight rows are ungoverned until the
GameStream phase, and older clients are enforced without the chrome. The
pairing page gains the one-dialog approval flow (access level + expiry,
'Approve as guest') and the Access column note.
2026-08-15 01:12:54 +02:00
enricobuehler 78efedc0d8 release: 0.29.0 — version bump, notes, CHANGELOG, Play notes
apple / swift (pull_request) Successful in 2m3s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 2m52s
android / android (pull_request) Successful in 7m14s
ci / web (pull_request) Successful in 1m23s
ci / bun-nix (pull_request) Successful in 17s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m27s
ci / docs-site (pull_request) Successful in 1m27s
ci / rust-arm64 (pull_request) Successful in 15m57s
nix / flake (pull_request) Successful in 13m40s
ci / rust (pull_request) Successful in 31m1s
53 commits since v0.28.1 (36 non-merge). Cut from origin/main 8c6099da.

THE NUMBER: 0.29.0 is forced, not chosen. The C ABI moved 19 -> 20 (#230
added punktfunk_connection_mgmt_port for the in-band mgmt-port advert),
and the Windows MSIX package identity changed with the Azure signing
move (#228) — either alone rules out a patch. scripts/ci/pf-version.sh
derives the canary base as latest-stable + one minor, so canaries move
from 0.29.x to 0.30.x after the tag; no collision either way.

Version table measured, not copied forward: wire stays 2 (Welcome grew
a trailing u16 older peers never read, with an explicit cipher byte
whenever a port rides along so offset 68 keeps meaning cipher), driver
protocol 6/min 3 (pf-driver-proto has no diff against the v0.28.1 tag),
gamepad channel 3, plugin index schema 1, edition 2024, MSRV 1.85, 27
crate dirs, gamescope +pfhdr7 (patch series untouched), SDK 0.1.4,
plugin-kit 0.4.1. api/openapi.json stays stamped 0.28.0 — the mgmt API
surface did not change this cycle — and docs-site/public/openapi.json
is byte-identical to it, so no re-sync is owed for once.

Re-synced once as main moved (b5cace3a -> 8c6099da, PRs #237–#241):
the Hyprland six-fix arc and the Windows mgmt-port completion joined
the notes and CHANGELOG; contract surfaces (include/, pf-driver-proto,
sdk, plugin-kit, api/) show no diff from the extra commits, so every
version-table row survived the re-sync unchanged.

Gates run on this tree: cargo fmt --all --check clean; cargo metadata
--locked ok; Cargo.lock diff versions-only (36/36 lines); cargo test
-p punktfunk-core green including the c_abi harness (the header with
the v20 symbol compiles and round-trips); Play whatsnew 444/500 chars
(counted as characters, not bytes) and not byte-identical to any prior
release's; notes voice scan finds internal names only in the For
developers section.
2026-08-15 00:26:56 +02:00
103 changed files with 8522 additions and 551 deletions
+16 -4
View File
@@ -114,8 +114,14 @@ jobs:
path: |
/usr/local/cargo/registry
/usr/local/cargo/git
key: cargo-home-${{ hashFiles('Cargo.lock') }}
restore-keys: cargo-home-
# Namespaced `-ci-` so a fork PR's cargo-home cache can never land in the `cargo-home-`
# pool the SIGNED release builds (deb.yml / android.yml) restore: registry/src holds
# already-extracted crate sources that cargo compiles WITHOUT re-checksumming past
# `.cargo-ok`, so a poisoned entry would be arbitrary Rust source compiled into a release
# artifact with no Cargo.lock diff. security-review 2026-08-15 finding 5. (The definitive
# control is operator-side: Gitea's "require approval for fork PRs".)
key: cargo-home-ci-${{ hashFiles('Cargo.lock') }}
restore-keys: cargo-home-ci-
- uses: actions/cache@v4
with:
path: target
@@ -258,8 +264,14 @@ jobs:
path: |
/usr/local/cargo/registry
/usr/local/cargo/git
key: cargo-home-${{ hashFiles('Cargo.lock') }}
restore-keys: cargo-home-
# Namespaced `-ci-` so a fork PR's cargo-home cache can never land in the `cargo-home-`
# pool the SIGNED release builds (deb.yml / android.yml) restore: registry/src holds
# already-extracted crate sources that cargo compiles WITHOUT re-checksumming past
# `.cargo-ok`, so a poisoned entry would be arbitrary Rust source compiled into a release
# artifact with no Cargo.lock diff. security-review 2026-08-15 finding 5. (The definitive
# control is operator-side: Gitea's "require approval for fork PRs".)
key: cargo-home-ci-${{ hashFiles('Cargo.lock') }}
restore-keys: cargo-home-ci-
- uses: actions/cache@v4
with:
path: target
+6
View File
@@ -363,6 +363,12 @@ jobs:
envs: REGISTRY_TOKEN
script: |
set -euo pipefail
# Log out on EVERY exit path: unlike the ephemeral LAN-registry runners, this is a
# long-lived internet-facing VM, so a `write:package` PAT left base64-encoded in
# ~/.docker/config.json is credential-at-rest on the most exposed host in the estate.
# The LAN jobs above already `docker logout`; this one omitted it. security-review
# 2026-08-15 finding 14.
trap 'docker logout git.unom.io || true' EXIT
printf '%s' "$REGISTRY_TOKEN" | docker login git.unom.io -u enricobuehler --password-stdin
cd ~/punktfunk-docs
docker compose -f compose.production.yml pull docs
+8
View File
@@ -297,9 +297,17 @@ jobs:
# so the installer ships just bun + a ~75-file .output instead of node + a node_modules forest.
$ver = 'bun-v1.3.14'
$url = "https://github.com/oven-sh/bun/releases/download/$ver/bun-windows-x64.zip"
# SHA-256 of this exact asset, pinned. GitHub release assets are MUTABLE at a fixed URL, so
# the tag alone vouches for nothing — this binary is Authenticode-signed into our installer
# and its hash published in the Ed25519 update manifest, i.e. our signature vouches for bytes
# we downloaded. Verify them. On a bun bump, update BOTH $ver and $sha (compute:
# `shasum -a 256 bun-windows-x64.zip`). security-review 2026-08-15 finding 12.
$sha = '0a0620930b6675d7ba440e81f4e0e00d3cfbe096c4b140d3fff02205e9e18922'
New-Item -ItemType Directory -Force -Path C:\t | Out-Null
$zip = 'C:\t\bun.zip'; $dst = 'C:\t\bundist'
Invoke-WebRequest -Uri $url -OutFile $zip
$got = (Get-FileHash -Algorithm SHA256 $zip).Hash.ToLower()
if ($got -ne $sha) { throw "bun zip sha256 mismatch for ${ver}: got $got, pinned $sha" }
if (Test-Path $dst) { Remove-Item $dst -Recurse -Force }
Expand-Archive -Path $zip -DestinationPath $dst -Force
$bun = (Get-ChildItem -Path $dst -Recurse -Filter bun.exe | Select-Object -First 1).FullName
+177 -1
View File
@@ -12,7 +12,183 @@ with the version table of the release you are moving to, then read **Breaking ch
---
## v0.28.1
## v0.29.0
53 commits since v0.28.1 (36 non-merge).
The headline contract change is one **additive** C ABI bump: the host now tells the client, in-band,
where its management API lives, and the connection grew an accessor for it. The wire protocol, the
driver protocol and the plugin contract do not move; every 0.28.x host, client, driver and plugin
keeps interoperating with 0.29.0 in both directions, with no re-pairing. The one thing that needs an
operator's hand is on Windows: the MSIX package identity changed with the move to a publicly
trusted signing certificate, so that install path needs a one-time uninstall + reinstall.
### Versions
| | v0.28.1 | v0.29.0 | Notes |
|---|---|---|---|
| Wire protocol | 2 | **2** | unchanged — `Welcome` grew a trailing field older peers never read (below) |
| C ABI | 19 | **20** | one symbol added: `punktfunk_connection_mgmt_port` (below) |
| Rust edition | 2024 | **2024** | unchanged |
| MSRV (`rust-version`) | 1.85 | **1.85** | unchanged |
| Workspace crate dirs | 27 | **27** | unchanged |
| Virtual-display driver protocol | 6 | **6** | unchanged (minimum accepted still 3); `pf-driver-proto` shows no diff against the v0.28.1 tag |
| Windows virtual-gamepad channel | 3 | **3** | unchanged |
| Plugin index schema | 1 | **1** | unchanged |
| `api/openapi.json` | 0.28.0 | **0.28.0** | the management API surface did not change; the file keeps the stamp it was regenerated under |
| gamescope patch level (`+pfhdrN`) | 7 | **7** | unchanged — the patch series is untouched |
| `@punktfunk/host` (SDK) | 0.1.4 | **0.1.4** | unchanged |
| `@punktfunk/plugin-kit` | 0.4.1 | **0.4.1** | unchanged |
### ⚠ Breaking changes
- **C ABI 19 → 20, addition only.** `include/punktfunk_core.h` gains exactly one declaration,
`punktfunk_connection_mgmt_port(const PunktfunkConnection *, uint16_t *)` — the management-API
port the host advertised in its `Welcome`, or the documented default when it advertised none.
Nothing is removed or reshaped; an embedder that compares `PUNKTFUNK_ABI_VERSION` at build time
rebuilds against the new header and is done. Nothing in-tree compares it at runtime.
- **The Windows MSIX package identity changed.** Releases are now signed by Azure Artifact Signing
(below), and the MSIX manifest `Publisher` must equal the signer subject byte-for-byte — so it
moved from the self-signed `CN=unom` to the verified subject. Package identity is Name +
Publisher: Windows treats the new package as a different app, and an in-place upgrade is
impossible by design. One-time uninstall + reinstall for MSIX installs; the `.exe` installer and
winget-via-installer paths upgrade normally.
- **Android embedder edge, additive:** `NativeBridge` gains `nativeHostMgmtPort`, and the native
discovery record gains its 9th field, `mgmt` (the record's append-only rule; 0, non-numeric and
out-of-range all parse as unknown). Out-of-tree JNI callers are unaffected unless they want the
value.
### The management port is movable, survives, and is learned in-band
47990 is the management API's port and also the web-UI port of Sunshine and its forks — with the
GameStream planes off, the only port the two still contend for. Moving it now actually works, end
to end:
- **`PUNKTFUNK_MGMT_BIND` joins `host.env`** (the `PUNKTFUNK_GAMESTREAM` shape: env or CLI flag,
the flag wins), so the choice survives package upgrades that rewrite the unit file. `serve`
publishes the port it *actually bound* to `~/.config/punktfunk/mgmt-endpoint` (KEY=VALUE, written
write-then-rename), and the console, the Windows service and the unit files all derive from that
one file; the six hardcoded 47990 literals survive only as the old-host fallback.
- **`Welcome.mgmt_port`** — a trailing `u16` after the cipher block, the same additive discipline
as the eight fields before it, so `WIRE_VERSION` stays 2 and an older peer stops earlier and uses
the default. ⚠ One encode subtlety, pinned by test: `cipher` used to be emitted only when
non-default, and appending the port to an AES `Welcome` would land its low byte at offset 68 —
exactly where every shipped 0.28.x client reads `cipher`, fail-closed. `encode` therefore writes
an explicit cipher byte whenever a port rides along; a host advertising no port still emits
exactly 68 bytes. The standalone `punktfunk1-host` binary advertises `0` (it has no management
API).
- **Clients persist it**: `KnownHost.mgmt_port` + `effective_mgmt_port()` across the Rust clients
(three-rung: live advert → stored → default), the session console, Android (through
`DiscoveredHost`), and Apple — where `StoredHost.mgmtPort` had existed all along but nothing ever
wrote it, so every Apple client resolved 47990 regardless. A host that has never been seen over
mDNS (VPN, routed subnet, multicast-dead network) now learns the port from the authenticated
connection itself.
- **`PUNKTFUNK_NATIVE_PORT`** completes the pair for the data plane — `--native-port` was CLI-only
and died on upgrade. A bad value is a startup **error**, not a silent fall back to 9777.
- The Windows shell's half of the client-side learn landed separately (#241): `trust.rs` re-exports
`learn_mgmt_port`, the shell's own mDNS browser parses the `mgmt` TXT, and `HostTarget` carries
the port like the mac client's target does.
### Linux thread priority: the renice was a no-op on every install to date
`boost_thread_priority`'s `setpriority()` needs `CAP_SYS_NICE` or a raised `RLIMIT_NICE`; no
channel granted either, and the host binary can never carry a file capability (a capped process's
`/proc/<pid>/exe` is unreadable to KWin — the 0.26.0-1 incident). So capture/encode/send ran at
nice 0, and a shader-compile storm could deschedule them hard enough to stutter audio and drag ABR
to its floor at zero loss. Now:
- **RealtimeKit fallback** — `MakeThreadHighPriorityWithPID`, the same unprivileged broker
PipeWire clients use. Only the nice verb, never `MakeThreadRealtime`; nothing enters the
permitted set, KWin identification is untouched.
- **The audio plane is boosted at all, for the first time**: the 5 ms Opus
capture→encode→send loop, the PipeWire capture mainloop, and the pad-audio streamer (on Windows
too, via the existing `SetThreadPriority` arm).
- **Packaging ships headroom for rtkit-less boxes**: `packaging/linux/50-punktfunk-nice.conf`
(`user@.service.d`, `LimitNICE=-15` — a limit, not a grant; effective from next login) on rpm,
Arch and deb, written to `/etc/systemd/system/user@.service.d` by the Steam Deck installer; deb
and rpm gain a weak `Recommends: rtkit`, Arch an optdepends hint, and the NixOS module sets
`security.rtkit.enable = mkDefault true`.
### Host capture gain works on `punktfunk/1`, and boosting no longer hard-clips
`PUNKTFUNK_AUDIO_GAIN` existed only on the GameStream plane, and where it applied it was a hard
`clamp(-1.0, 1.0)` — flat-topping, so pushing past ~1.5× sounded broken long before it got loud
(WASAPI loopback taps upstream of the endpoint's master volume, so the host's own slider never
changes the sent level either). `punktfunk_core::audio::apply_gain` now serves **both planes** with
a tanh soft knee above `SOFT_LIMIT_KNEE` (0.7, ≈−3.1 dBFS): C1-continuous, bounded by
construction, odd-symmetric, memoryless (zero added latency). Unity is a no-op inside the function
itself, so the default wire stays byte-for-byte identical. `capture_gain` rejects non-positive
values and caps at 8.0 (+18 dB). This buys headroom, not loudness — it is deliberately not a
compressor, and the docs say so. `SOFT_LIMIT_KNEE` is excluded from cbindgen on purpose.
### Windows binaries are signed by Azure Artifact Signing
Account `unomsigning`, profile `unom-io`, signed by a service principal holding only the
profile-scoped signer role. Azure mints a **per-request leaf that expires in ~3 days**, which
changes two rules: a timestamped countersignature is now *mandatory* (the old retry-without-
timestamp fallback is a hard failure in Azure mode — it would ship an artifact that goes untrusted
days later, everywhere at once), and leaf pinning is structurally impossible (the updater's
`AUTHENTICODE_SHA256` note claiming otherwise is corrected). `pack-msix.ps1` reads the signature
back off the packed `.msix` and fails on Publisher drift. Driver catalogs are deliberately
untouched: they keep the `DRIVER_CERT_*` cert and the installer still plants it as a machine root
(PnP trust is independent of SmartScreen/UAC trust). Canary and fork builds keep the `.pfx` and
ephemeral fallbacks.
### Library: a launcher the host cannot open no longer costs the whole sync
`valid_launcher_ui` conflated vocabulary with environment. It is now split: `known_launcher_ui`
(an unknown launcher kind is a plugin bug — still a hard 400) and `resolvable_launcher_ui` (the
launcher just is not installed on this box — the entry is dropped with one warn and the games
sync). Same shape as the unservable-cover fix, on the launch side. And Playnite is actually
findable now: the old lookup read the LocalSystem service's own HKCU and `%LOCALAPPDATA%` (the
SYSTEM profile — a per-user Playnite is invisible there) and matched a registry key name Inno Setup
never writes. Now: every loaded hive under `HKEY_USERS` plus both HKLM views, matched on
`DisplayName`, then `C:\Users\*\AppData\Local\Playnite`.
### Hyprland/sway capture: six defects, all ours, and streaming now survives past one session
The wlr portal route looked environmental and never was. Measured on Hyprland 0.55.4 +
xdg-desktop-portal-hyprland 1.3.12, fixed in one arc (#240):
- **The dmabuf pod offered `BGRx`; xdph offers `BGRA`.** The modifier lists intersect perfectly,
the fourcc never does, so PipeWire failed the link itself (`no more input formats`) — and the
pods live only in the PipeWire *daemon's* log, which is why it read as a GPU/modifier problem.
- **A per-cast tokio runtime orphaned ashpd's process-global D-Bus connection.** ashpd caches its
connection in a `OnceLock`; the first cast's runtime hosted zbus's reader task and then died
with the cast, so the first stream of a host process worked and every later one went black.
Both wlr backends now share one long-lived portal runtime.
- **Teardown removed the captured output before closing the cast**, and xdph spun on the wreckage;
the order is now cast-then-output.
- **A hung portal handshake leaked its thread** and the leak poisoned every later cast; the
handshake is now bounded.
- **The wlr absolute-motion injector aimed at the operator's head**, never the streamed one; the
pointer is now bound to the streamed output.
- **The cursor park schedule read a missing cursor overlay as a lost pointer** — an Embedded-mode
portal never sends one.
### Everything else an integrator might notice
- **vdisplay/KDE:** a bare-spawn gamescope session under an exclusive topology now darkens the
physical panels over `org_kde_kwin_dpms` (new in-process `kwin_dpms` module,
`kscreen-doctor --dpms` fallback), refcounted host-wide so concurrent spawns compose; DPMS is
non-persistent, so a dead host leaves nothing to journal. Managed and Attach routes untouched.
- **macOS client:** `Settings::inhibit_shortcuts` is finally implemented on Apple — a local
keyDown monitor claims every ⌘ chord while input is captured and forwards it host-side (AppKit
dispatches menu key equivalents before the stream view sees them, so ⌘Q used to quit the
client). ⌘⎋ and ⌃⌘F stay client-side; ⌘Tab/⌘Space/Mission Control are out of reach without a
CGEventTap. Chord matching no longer compares Caps Lock and `.function`/`.numericPad` bits raw.
- **Android client:** `Gamepad.padButtonBit` resolves a gamepad-sourced `KEYCODE_BACK` to
`BTN_BACK` — pads that report Select as plain BACK (the Android-TV shape) no longer quit the
stream on one press, and the Select chords (exit chord, mic mute, stats tier) become reachable
on exactly those pads. `FLAG_FALLBACK` events stay excluded.
- **CI:** Android canaries now feed Play **open testing (beta) and closed testing (alpha)** from
one Play edit (`play-upload.py --also-track`); tags still publish production only, and a manual
`android.yml` dispatch can now opt into publishing (`publish=true`), so a lost merge run is no
longer a dead end. Windows
runners provision the .NET 8 runtime and a machine-wide signing client (a mixed-mode dlib with
no runtime makes signtool exit 3 in silence).
60 commits since v0.28.0.
Generated
+36 -36
View File
@@ -1090,7 +1090,7 @@ dependencies = [
[[package]]
name = "cursor-probe"
version = "0.28.1"
version = "0.29.0"
dependencies = [
"anyhow",
"pf-capture",
@@ -1222,7 +1222,7 @@ dependencies = [
[[package]]
name = "display-disturb"
version = "0.28.1"
version = "0.29.0"
dependencies = [
"pf-win-display",
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
@@ -2343,7 +2343,7 @@ dependencies = [
[[package]]
name = "latency-probe"
version = "0.28.1"
version = "0.29.0"
[[package]]
name = "lazy_static"
@@ -2446,7 +2446,7 @@ dependencies = [
[[package]]
name = "libvpl-sys"
version = "0.28.1"
version = "0.29.0"
dependencies = [
"bindgen",
"cmake",
@@ -2475,7 +2475,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "loss-harness"
version = "0.28.1"
version = "0.29.0"
dependencies = [
"punktfunk-core",
]
@@ -2967,7 +2967,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pf-bitstream"
version = "0.28.1"
version = "0.29.0"
dependencies = [
"cros-codecs",
"tracing",
@@ -2975,7 +2975,7 @@ dependencies = [
[[package]]
name = "pf-capture"
version = "0.28.1"
version = "0.29.0"
dependencies = [
"anyhow",
"ashpd",
@@ -2996,7 +2996,7 @@ dependencies = [
[[package]]
name = "pf-client-core"
version = "0.28.1"
version = "0.29.0"
dependencies = [
"anyhow",
"ash",
@@ -3031,7 +3031,7 @@ dependencies = [
[[package]]
name = "pf-clipboard"
version = "0.28.1"
version = "0.29.0"
dependencies = [
"anyhow",
"ashpd",
@@ -3049,7 +3049,7 @@ dependencies = [
[[package]]
name = "pf-console-ui"
version = "0.28.1"
version = "0.29.0"
dependencies = [
"anyhow",
"ash",
@@ -3071,7 +3071,7 @@ dependencies = [
[[package]]
name = "pf-dxvadec"
version = "0.28.1"
version = "0.29.0"
dependencies = [
"cros-codecs",
"pf-bitstream",
@@ -3081,7 +3081,7 @@ dependencies = [
[[package]]
name = "pf-encode"
version = "0.28.1"
version = "0.29.0"
dependencies = [
"anyhow",
"ash",
@@ -3107,7 +3107,7 @@ dependencies = [
[[package]]
name = "pf-frame"
version = "0.28.1"
version = "0.29.0"
dependencies = [
"anyhow",
"libc",
@@ -3120,7 +3120,7 @@ dependencies = [
[[package]]
name = "pf-gpu"
version = "0.28.1"
version = "0.29.0"
dependencies = [
"anyhow",
"pf-host-config",
@@ -3134,11 +3134,11 @@ dependencies = [
[[package]]
name = "pf-host-config"
version = "0.28.1"
version = "0.29.0"
[[package]]
name = "pf-inject"
version = "0.28.1"
version = "0.29.0"
dependencies = [
"anyhow",
"ashpd",
@@ -3167,14 +3167,14 @@ dependencies = [
[[package]]
name = "pf-paths"
version = "0.28.1"
version = "0.29.0"
dependencies = [
"tracing",
]
[[package]]
name = "pf-presenter"
version = "0.28.1"
version = "0.29.0"
dependencies = [
"anyhow",
"ash",
@@ -3189,7 +3189,7 @@ dependencies = [
[[package]]
name = "pf-update"
version = "0.28.1"
version = "0.29.0"
dependencies = [
"serde",
"serde_json",
@@ -3197,7 +3197,7 @@ dependencies = [
[[package]]
name = "pf-update-check"
version = "0.28.1"
version = "0.29.0"
dependencies = [
"anyhow",
"aws-lc-rs",
@@ -3209,7 +3209,7 @@ dependencies = [
[[package]]
name = "pf-vaadec"
version = "0.28.1"
version = "0.29.0"
dependencies = [
"cros-codecs",
"pf-bitstream",
@@ -3218,7 +3218,7 @@ dependencies = [
[[package]]
name = "pf-vdisplay"
version = "0.28.1"
version = "0.29.0"
dependencies = [
"anyhow",
"ashpd",
@@ -3251,7 +3251,7 @@ dependencies = [
[[package]]
name = "pf-vkdecode"
version = "0.28.1"
version = "0.29.0"
dependencies = [
"ash",
"cros-codecs",
@@ -3262,7 +3262,7 @@ dependencies = [
[[package]]
name = "pf-win-display"
version = "0.28.1"
version = "0.29.0"
dependencies = [
"pf-paths",
"punktfunk-core",
@@ -3273,7 +3273,7 @@ dependencies = [
[[package]]
name = "pf-zerocopy"
version = "0.28.1"
version = "0.29.0"
dependencies = [
"anyhow",
"ash",
@@ -3485,7 +3485,7 @@ dependencies = [
[[package]]
name = "punktfunk-cli"
version = "0.28.1"
version = "0.29.0"
dependencies = [
"pf-client-core",
"punktfunk-core",
@@ -3495,7 +3495,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-android"
version = "0.28.1"
version = "0.29.0"
dependencies = [
"android_logger",
"jni",
@@ -3513,7 +3513,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-linux"
version = "0.28.1"
version = "0.29.0"
dependencies = [
"anyhow",
"async-channel",
@@ -3530,7 +3530,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-session"
version = "0.28.1"
version = "0.29.0"
dependencies = [
"pf-client-core",
"pf-console-ui",
@@ -3544,7 +3544,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-windows"
version = "0.28.1"
version = "0.29.0"
dependencies = [
"async-channel",
"mdns-sd",
@@ -3562,7 +3562,7 @@ dependencies = [
[[package]]
name = "punktfunk-core"
version = "0.28.1"
version = "0.29.0"
dependencies = [
"aes-gcm",
"cbindgen",
@@ -3594,7 +3594,7 @@ dependencies = [
[[package]]
name = "punktfunk-encode-worker"
version = "0.28.1"
version = "0.29.0"
dependencies = [
"pf-encode",
"tracing",
@@ -3603,7 +3603,7 @@ dependencies = [
[[package]]
name = "punktfunk-host"
version = "0.28.1"
version = "0.29.0"
dependencies = [
"aes",
"aes-gcm",
@@ -3673,7 +3673,7 @@ dependencies = [
[[package]]
name = "punktfunk-probe"
version = "0.28.1"
version = "0.29.0"
dependencies = [
"anyhow",
"mdns-sd",
@@ -3687,7 +3687,7 @@ dependencies = [
[[package]]
name = "punktfunk-tray"
version = "0.28.1"
version = "0.29.0"
dependencies = [
"anyhow",
"ksni",
@@ -3710,7 +3710,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
[[package]]
name = "pyrowave-sys"
version = "0.28.1"
version = "0.29.0"
dependencies = [
"bindgen",
"cmake",
+1 -1
View File
@@ -65,7 +65,7 @@ exclude = [
ndk = { path = "clients/android/native/vendor/ndk" }
[workspace.package]
version = "0.28.1"
version = "0.29.0"
edition = "2024"
rust-version = "1.85"
license = "MIT OR Apache-2.0"
+341 -5
View File
@@ -10,7 +10,7 @@
"name": "MIT OR Apache-2.0",
"identifier": "MIT OR Apache-2.0"
},
"version": "0.28.0"
"version": "0.28.1"
},
"paths": {
"/api/v1/clients": {
@@ -1903,6 +1903,97 @@
}
}
}
},
"patch": {
"tags": [
"native"
],
"summary": "Update a native client's access",
"description": "Partial edit of a paired device's grants/expiry (the console edit sheet: preset change,\nextend, \"expire now\", make permanent). Omitted fields keep their current value; the edit\nreaches the device's live sessions immediately. Not a way to pair a device (404 when the\nfingerprint isn't in the trust store).",
"operationId": "updateNativeClientAccess",
"parameters": [
{
"name": "fingerprint",
"in": "path",
"description": "Hex SHA-256 of the client certificate (case-insensitive)",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdateNativeAccess"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Access updated; the stored record as now in force",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/NativeClient"
}
}
}
},
"400": {
"description": "Reserved grant bits set, or expires_in_secs together with clear_expiry",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"404": {
"description": "No paired native client with that fingerprint",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"500": {
"description": "Could not persist the trust store",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"503": {
"description": "Native host not enabled",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/v1/native/pair": {
@@ -1976,7 +2067,7 @@
"native"
],
"summary": "Arm native pairing",
"description": "Opens a pairing window and mints a fresh PIN to display. The user enters it on their device\nwithin `ttl_secs`; the device then appears in the native client list.",
"description": "Opens a pairing window and mints a fresh PIN to display. The user enters it on their device\nwithin `ttl_secs`; the device then appears in the native client list. An access choice\n(`grants` / `expires_in_secs`) applies to whichever device completes this window's ceremony.",
"operationId": "armNativePairing",
"requestBody": {
"content": {
@@ -1999,6 +2090,16 @@
}
}
},
"400": {
"description": "Reserved grant bits set",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
@@ -2063,7 +2164,7 @@
"native"
],
"summary": "Approve a pending device",
"description": "Pairs the device's certificate fingerprint — it can connect immediately (no PIN). Optionally\nrelabel it via the body; send `{}` to keep the name it knocked with.",
"description": "Pairs the device's certificate fingerprint — it can connect immediately (no PIN). Optionally\nrelabel it and/or choose its access via the body; send `{}` to keep the name it knocked with\nand its existing access (full/permanent for a first pairing). The response is the stored\nrecord — what is actually in force, not necessarily this request's inputs.",
"operationId": "approvePendingDevice",
"parameters": [
{
@@ -2090,7 +2191,7 @@
},
"responses": {
"200": {
"description": "Device paired",
"description": "Device paired; the stored record as now in force",
"content": {
"application/json": {
"schema": {
@@ -2099,6 +2200,16 @@
}
}
},
"400": {
"description": "Reserved grant bits set",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
@@ -4128,8 +4239,28 @@
},
"ApprovePending": {
"type": "object",
"description": "Approve-pending-device request body. Send `{}` to keep the device's own name.",
"description": "Approve-pending-device request body. Send `{}` to keep the device's own name and — for a\nre-approved device — its existing access (the full/permanent default for a first pairing).",
"properties": {
"expires_in_secs": {
"type": [
"integer",
"null"
],
"format": "int64",
"description": "Access expiry in seconds **from now** (relative — the host stores the absolute deadline\nand stamps the grant time). Alone, it means full control until then.",
"example": 14400,
"minimum": 0
},
"grants": {
"type": [
"integer",
"null"
],
"format": "int32",
"description": "Access choice: grant bitmask (`GRANT_*` bits 05). Reserved bits are a 400. Omitting BOTH\naccess fields keeps a re-approved device's stored access; `grants` without\n`expires_in_secs` grants permanently.",
"example": 1,
"minimum": 0
},
"name": {
"type": [
"string",
@@ -4144,6 +4275,16 @@
"type": "object",
"description": "Arm-native-pairing request body.",
"properties": {
"expires_in_secs": {
"type": [
"integer",
"null"
],
"format": "int64",
"description": "Optional access expiry for the pairing device, in seconds **from now** (relative — the\nhost stores the absolute deadline). NOT the pairing window's length; that is `ttl_secs`.\nOmit for permanent access (when `grants` is set) or preserved access (when neither is).",
"example": 14400,
"minimum": 0
},
"fingerprint": {
"type": [
"string",
@@ -4152,6 +4293,16 @@
"description": "Optional: bind the window to ONE device fingerprint (hex SHA-256, e.g. from a pending knock).\nWhen set, only a pairing attempt from that fingerprint consumes the window — so an unpaired\nLAN peer can neither pair nor burn a window armed for a specific device (security-review #9).\nOmit for an unbound window (any device may use the PIN — trusted-LAN only).",
"example": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
},
"grants": {
"type": [
"integer",
"null"
],
"format": "int32",
"description": "Optional access choice for whichever device completes this window's ceremony: a grant\nbitmask (`GRANT_*` bits 05). Reserved bits are a 400. Omit (with `expires_in_secs`) for\ntoday's behavior — a new device gets full control, a re-pairing device keeps what it has.",
"example": 1,
"minimum": 0
},
"ttl_secs": {
"type": [
"integer",
@@ -5231,6 +5382,91 @@
}
}
},
{
"type": "object",
"description": "A device was granted access with an explicit operator choice — the approve dialog, the\narm window's carried choice, or any other `add_with_access(Some)` path\n(design/per-client-access.md §6). A plain pairing with no choice emits only\n`pairing.completed` (its access is the preserved/default record, nothing was *chosen*).",
"required": [
"device",
"grants",
"kind"
],
"properties": {
"device": {
"$ref": "#/components/schemas/DeviceRef"
},
"expires_unix": {
"type": [
"integer",
"null"
],
"format": "int64",
"description": "Absolute expiry, host wall clock unix seconds; absent = permanent."
},
"grants": {
"type": "integer",
"format": "int32",
"description": "The granted mask (the `GRANT_*` bit vocabulary), reserved bits already cleared.",
"minimum": 0
},
"kind": {
"type": "string",
"enum": [
"access.granted"
]
}
}
},
{
"type": "object",
"description": "A paired device's access was edited after the fact (the console edit sheet / extend /\n\"expire now\") — the owner's hook can say \"the TV is view-only now\".",
"required": [
"device",
"grants",
"kind"
],
"properties": {
"device": {
"$ref": "#/components/schemas/DeviceRef"
},
"expires_unix": {
"type": [
"integer",
"null"
],
"format": "int64"
},
"grants": {
"type": "integer",
"format": "int32",
"minimum": 0
},
"kind": {
"type": "string",
"enum": [
"access.changed"
]
}
}
},
{
"type": "object",
"description": "A device's temporary access reached its deadline and its live session was closed — \"guest\naccess ended\". Emitted at deadline fire by the expiring session (a device with no live\nsession expires silently; the console row flips to \"Expired\" either way).",
"required": [
"device",
"kind"
],
"properties": {
"device": {
"$ref": "#/components/schemas/DeviceRef"
},
"kind": {
"type": "string",
"enum": [
"access.expired"
]
}
}
},
{
"type": "object",
"required": [
@@ -6497,10 +6733,44 @@
"fingerprint"
],
"properties": {
"access_level": {
"type": [
"string",
"null"
],
"description": "The preset this device's mask amounts to, for display: `full` | `controller` | `view` |\n`custom`. Derived from `grants` on the host; absent only on hosts older than the field.",
"example": "controller"
},
"expires_unix": {
"type": [
"integer",
"null"
],
"format": "int64",
"description": "Absolute access expiry, unix seconds on the host's wall clock. `null` = permanent. Whether\nit has already passed is the reader's arithmetic — an expired device stays listed (shown\nas \"Expired\"), it just isn't authorized."
},
"fingerprint": {
"type": "string",
"description": "Hex SHA-256 of the client certificate — its stable id here."
},
"granted_unix": {
"type": [
"integer",
"null"
],
"format": "int64",
"description": "When access was last granted, unix seconds — display/audit only, never enforced."
},
"grants": {
"type": [
"integer",
"null"
],
"format": "int32",
"description": "Grant bitmask (`GRANT_*` bits 05). `null` = a record from before grants existed, which\nmeans full control.",
"example": 1,
"minimum": 0
},
"name": {
"type": "string",
"description": "The name the client supplied when pairing.",
@@ -6627,16 +6897,49 @@
"age_secs"
],
"properties": {
"access_level": {
"type": [
"string",
"null"
],
"description": "The stored mask's preset name (`full` | `controller` | `view` | `custom`) — `null` for a\ndevice with no stored record, unlike [`NativeClient`] where it is always derivable.",
"example": "controller"
},
"age_secs": {
"type": "integer",
"format": "int64",
"description": "Seconds since the device last knocked.",
"minimum": 0
},
"expires_unix": {
"type": [
"integer",
"null"
],
"format": "int64",
"description": "The stored record's absolute expiry (unix seconds; likely in the past — that's why it's\nknocking). `null` when unknown or permanent."
},
"fingerprint": {
"type": "string",
"description": "Hex SHA-256 of the device's certificate — what approval pins."
},
"granted_unix": {
"type": [
"integer",
"null"
],
"format": "int64",
"description": "When the stored record's access was granted (unix seconds). `null` when unknown."
},
"grants": {
"type": [
"integer",
"null"
],
"format": "int32",
"description": "The grant mask this fingerprint is ALREADY stored with, if it was paired before (the\nexpired-guest re-knock: the approve dialog can offer \"re-grant what they had\"). `null`\nwhen the device is unknown, or known with a pre-grants record (= full).",
"minimum": 0
},
"id": {
"type": "integer",
"format": "int32",
@@ -7856,6 +8159,39 @@
}
}
},
"UpdateNativeAccess": {
"type": "object",
"description": "PATCH body for a paired device's access (the console edit sheet: change the preset, extend,\n\"expire now\", make permanent). **Partial**: an omitted `grants` keeps the current grants, and\nomitted expiry fields keep the current expiry — send only what changes.",
"properties": {
"clear_expiry": {
"type": [
"boolean",
"null"
],
"description": "`true` removes the expiry — access becomes permanent. Mutually exclusive with\n`expires_in_secs` (400)."
},
"expires_in_secs": {
"type": [
"integer",
"null"
],
"format": "int64",
"description": "New expiry in seconds **from now** (relative; the host stores the absolute deadline).\n`0` expires the device now. Omit to keep the current expiry. Mutually exclusive with\n`clear_expiry` (400).",
"example": 14400,
"minimum": 0
},
"grants": {
"type": [
"integer",
"null"
],
"format": "int32",
"description": "New grant bitmask (`GRANT_*` bits 05); reserved bits are a 400. Omit to keep the\ndevice's current grants.",
"example": 1,
"minimum": 0
}
}
},
"UpdateResultInfo": {
"type": "object",
"description": "Durable outcome of the most recent apply attempt (survives the host's own restart).",
@@ -53,6 +53,11 @@ object ConnectErrors {
"on the host."
"wire-version" -> "Client and host versions don't match — update both to the same release."
"busy" -> "The host is busy with another session."
"access-expired" ->
"Your access to this host has expired — ask the host's owner to grant it again."
"launch-not-permitted" ->
"This device's access doesn't include launching games — connect to the desktop, " +
"or ask the host's owner."
else -> null
}
@@ -34,6 +34,7 @@ import io.unom.punktfunk.kit.Gamepad
import io.unom.punktfunk.kit.GamepadRouter
import io.unom.punktfunk.kit.Keymap
import io.unom.punktfunk.kit.NativeBridge
import io.unom.punktfunk.kit.SessionAccess
import io.unom.punktfunk.kit.link.DeepLinkResult
import io.unom.punktfunk.kit.link.DeepLinks
import io.unom.punktfunk.kit.link.HostResolution
@@ -86,6 +87,16 @@ class MainActivity : ComponentActivity() {
*/
var streamHandle: Long = 0L
/**
* The active session's access-grant mask ([SessionAccess] bits) set with [streamHandle] by
* StreamScreen and kept live by its access poll; back to [SessionAccess.ALL] when the stream
* leaves. Consulted only while streaming: the VK keyboard path below goes inert without
* [SessionAccess.KEYBOARD] (the keys are consumed, not sent the host would drop them, and
* letting them fall through would drive Android navigation under a live stream). Courtesy
* gating; the host enforces regardless.
*/
var streamAccess: Int = SessionAccess.ALL
/**
* Multi-controller router for the active session (built/released by StreamScreen): assigns each
* connected pad a stable wire index, threads it onto every event, declares/removes pads on
@@ -596,6 +607,10 @@ class MainActivity : ComponentActivity() {
KeyEvent.ACTION_UP -> false
else -> return super.dispatchKeyEvent(event)
}
// Without the KEYBOARD grant the key path is inert: consumed (so nothing
// drives Android navigation under the stream) but never sent — the host
// would drop it, and the Access chip is what says why. Courtesy gating.
if (streamAccess and SessionAccess.KEYBOARD == 0) return true
// Full-event overload: evdev scancode first (positional under ANY selected
// physical-keyboard layout), keycode fallback — see Keymap docs.
val vk = Keymap.toVk(event)
@@ -44,6 +44,18 @@ class MouseForwarder(
var onRequestCapture: (() -> Unit)? = null
var onReleaseCapture: (() -> Unit)? = null
/**
* Whether this session's access includes the POINTER grant ([io.unom.punktfunk.kit.SessionAccess.POINTER])
* seeded from the Welcome, kept live by StreamScreen's access poll. Without it the mouse
* path goes inert: nothing forwards, and the part that matters the pointer is never
* GRABBED, because a captured mouse that moves nothing is the "my mouse does nothing and
* nobody says why" failure the grants UX exists to prevent (the Access chip says why
* instead). Revocation mid-session releases an existing grab (StreamScreen calls [release]).
* Volatile: set on the main thread, read wherever the dispatch path runs.
*/
@Volatile
var pointerGranted: Boolean = true
/** Live capture state, updated from [android.app.Activity.onPointerCaptureChanged]. */
var captured = false
private set
@@ -59,6 +71,7 @@ class MouseForwarder(
/** Uncaptured mouse events on the TOUCH stream (position while a button is down). */
fun onTouchEvent(ev: MotionEvent): Boolean {
if (!pointerGranted) return true // inert: consumed over the stream, nothing forwards
when (ev.actionMasked) {
MotionEvent.ACTION_DOWN -> {
if (captureWanted && !captured && !userReleased) {
@@ -80,6 +93,7 @@ class MouseForwarder(
/** Uncaptured mouse events on the GENERIC stream (hover motion, wheel, button edges). */
fun onGenericMotion(ev: MotionEvent): Boolean {
if (!pointerGranted) return true // inert: consumed over the stream, nothing forwards
when (ev.actionMasked) {
MotionEvent.ACTION_HOVER_MOVE -> sendAbs(ev)
MotionEvent.ACTION_SCROLL -> wheel(ev)
@@ -98,6 +112,7 @@ class MouseForwarder(
* gesture layer is the touchpad story); returning false leaves those to the framework.
*/
fun onCapturedPointer(ev: MotionEvent): Boolean {
if (!pointerGranted) return true // a revocation is racing the release of the grab
if (!ev.isFromSource(InputDevice.SOURCE_MOUSE_RELATIVE)) return false
when (ev.actionMasked) {
MotionEvent.ACTION_MOVE -> {
@@ -131,7 +146,7 @@ class MouseForwarder(
if (captured) {
userReleased = true
onReleaseCapture?.invoke()
} else {
} else if (pointerGranted) { // never grab a pointer whose input can't land
userReleased = false
onRequestCapture?.invoke()
}
@@ -139,7 +154,7 @@ class MouseForwarder(
/** Auto-engage at stream start (setting on + a mouse actually present). */
fun engageFromStart() {
if (captureWanted && !captured && !userReleased && hasPhysicalMouse()) {
if (pointerGranted && captureWanted && !captured && !userReleased && hasPhysicalMouse()) {
onRequestCapture?.invoke()
}
}
@@ -204,7 +219,9 @@ class MouseForwarder(
* input reader synthesizes them in), so both paths funnel into the same held-set and the
* add/remove guard collapses the pair into a single wire press.
*/
fun sideButtonKey(back: Boolean, down: Boolean) = press(if (back) 4 else 5, down)
fun sideButtonKey(back: Boolean, down: Boolean) {
if (pointerGranted) press(if (back) 4 else 5, down)
}
private fun button(actionButton: Int, down: Boolean) {
val b = when (actionButton) {
@@ -44,6 +44,21 @@ class RemotePointer(
var active = false
private set
/**
* Whether this session's access includes the POINTER grant StreamScreen keeps it live from
* the access poll. Ungranted, the SELECT long-press stops entering pointer mode (a mode whose
* every action the host would drop; the Access chip says why), and a revocation while the
* mode is on leaves it cleanly ([setGranted]). Everything else passes through untouched,
* exactly as when the mode is off the remote stays a remote.
*/
private var granted = true
/** Update the POINTER grant; revoking while pointer mode is on leaves the mode. Main thread. */
fun setGranted(ok: Boolean) {
granted = ok
if (!ok && active) toggle()
}
private val handler = Handler(Looper.getMainLooper())
private val held = mutableSetOf<Int>() // D-pad keycodes currently down
private var moveAccX = 0f
@@ -169,6 +184,7 @@ class RemotePointer(
}
private fun toggle() {
if (!active && !granted) return // never enter a mode whose input can't land
active = !active
if (!active) {
held.clear()
@@ -70,6 +70,7 @@ import io.unom.punktfunk.kit.deviceBodyVibrator
import io.unom.punktfunk.kit.NativeBridge
import io.unom.punktfunk.kit.PadSensors
import io.unom.punktfunk.kit.Sc2Capture
import io.unom.punktfunk.kit.SessionAccess
import io.unom.punktfunk.kit.SessionEndReason
import io.unom.punktfunk.kit.VideoDecoders
import io.unom.punktfunk.models.ActiveSession
@@ -102,6 +103,20 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
window?.let { WindowCompat.getInsetsController(it, it.decorView) }
}
// The session's access level (the per-client grants of design/per-client-access.md), the
// courtesy mirror of what the host enforces: seeded from the Welcome's advert here, kept live
// by the 1 Hz poll below (the host's AccessUpdate messages fold latest-wins into the native
// state). Full control + permanent — the only state an old host or an old native lib ever
// reports — gates nothing and draws nothing: today's look, unchanged.
val initialAccess = remember(handle) { NativeBridge.nativeAccessState(handle) }
var accessGrants by remember(handle) {
mutableStateOf(initialAccess?.getOrNull(0) ?: SessionAccess.ALL)
}
// Seconds until this session's access expires (0 = permanent), as last reported natively.
var accessRemaining by remember(handle) {
mutableStateOf(initialAccess?.getOrNull(1) ?: 0)
}
// Start mic only if the user enabled it AND granted RECORD_AUDIO (else the AAudio input fails).
val micWanted = micEnabled && ContextCompat.checkSelfPermission(
context,
@@ -182,6 +197,34 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
NativeBridge.nativeSetMicMuted(handle, muted)
}
// Push a grant mask into every gate that consults one — called at session start (once the
// router/forwarders exist) and again whenever the poll sees the mask change (an AccessUpdate
// revoked or restored something mid-session). A lambda, deliberately not a local fun — this
// codebase has been burned by `::localFun` references in composable scopes. The gates it does
// NOT reach (the Compose-side ones — the touch layer, the IME summon, the banner line, the
// chip) key on `accessGrants` directly and re-run on the state write.
val applyAccess: (Int) -> Unit = { grants ->
activity?.streamAccess = grants
activity?.gamepadRouter?.gamepadGranted = grants and SessionAccess.GAMEPAD != 0
val pointerOk = grants and SessionAccess.POINTER != 0
activity?.mouseForwarder?.let { m ->
m.pointerGranted = pointerOk
// A revocation must also let an existing grab go (and lift held buttons): a captured
// mouse that moves nothing reads as a broken mouse, not a spectator session.
if (!pointerOk) m.release()
}
activity?.remotePointer?.setGranted(pointerOk)
// Mic revoked mid-session: stop the capture — the host detaches its end regardless, and
// an open mic (with the platform's recording indicator lit) feeding a plane the host
// drops would be the worst kind of lie. Not restarted on a re-grant: the host attaches
// the mic service at session setup only, so a fresh session is the honest offer.
if (grants and SessionAccess.MIC == 0 && micRunning) {
releaseMicEffects(micEffects)
NativeBridge.nativeStopMic(handle)
micRunning = false
}
}
// Live decode stats for the HUD. `statsOn` (verbosity != OFF) gates the whole native pipeline:
// the per-frame sampling (nativeSetVideoStatsEnabled — a hidden HUD costs one atomic load per
// frame) AND the 1 s poll loop, which only runs while the overlay is visible. Enabling resets
@@ -243,22 +286,62 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
// open, so this fires only on a genuinely dead peer, never a false positive. Keyed on `handle`, so
// it stops the moment we navigate away (the handle is only freed later, in onDispose).
LaunchedEffect(handle) {
var lastAccessSeq = initialAccess?.getOrNull(2) ?: 0
while (true) {
delay(1000)
// Access first, ended second: a session about to close on its expiry gets its final
// countdown read, which is what lets the ended branch word that close honestly.
NativeBridge.nativeAccessState(handle)?.let { st ->
val grants = st.getOrNull(0) ?: SessionAccess.ALL
val seq = st.getOrNull(2) ?: 0
if (grants != accessGrants) {
accessGrants = grants
applyAccess(grants)
}
accessRemaining = st.getOrNull(1) ?: 0
if (seq != lastAccessSeq) {
lastAccessSeq = seq
// A fresh AccessUpdate close to the deadline is the host's T5 m / T1 m
// courtesy warning — surface it. Grant edits (and a warning's grant echo)
// otherwise just move the chip; a toast per edit would be noise.
if (accessRemaining in 1..330) {
val mins = (accessRemaining + 30) / 60
Toast.makeText(
context,
if (mins <= 1) {
"Access expires in about a minute."
} else {
"Access expires in about $mins minutes."
},
Toast.LENGTH_LONG,
).show()
}
}
}
if (NativeBridge.nativeSessionEnded(handle)) {
// WHY it ended decides what the user is told. This used to show the "host may be
// asleep" line for EVERY ending — including a game the player had just quit and a
// session the host ended on purpose — which reads as a failure report for
// something nobody did wrong. Only a connection that actually died says that now.
val reason = SessionEndReason.fromNative(NativeBridge.nativeEndReason(handle))
when (reason) {
SessionEndReason.LOST ->
when {
// The session died inside the access countdown's final stretch: that IS the
// typed expiry close (ACCESS_EXPIRED), worded with the shared rejection
// sentence rather than the generic host-ended silence. Recognized off the
// countdown because the generic end-reason byte predates the expiry code.
accessRemaining in 1..75 ->
Toast.makeText(
context,
"Your access to this host has expired.",
Toast.LENGTH_LONG,
).show()
reason == SessionEndReason.LOST ->
Toast.makeText(
context,
"Connection lost — the host may be asleep. Wake it to reconnect.",
Toast.LENGTH_LONG,
).show()
SessionEndReason.HOST_ERROR ->
reason == SessionEndReason.HOST_ERROR ->
Toast.makeText(
context,
"The host ended the session with an error.",
@@ -266,10 +349,7 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
).show()
// Deliberate endings — the player quit the game, the host was stopped, or we
// closed it. Leaving the stream IS the feedback; a toast would only add noise.
SessionEndReason.GAME_EXITED,
SessionEndReason.HOST_ENDED,
SessionEndReason.LOCAL,
SessionEndReason.NONE -> {}
else -> {}
}
onSessionEnded(reason)
return@LaunchedEffect
@@ -465,15 +545,32 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
handle,
surfaceWidth = { videoView?.width?.takeIf { it > 0 } ?: decor?.width ?: 1920 },
onActiveChanged = { on -> remotePointerOn = on },
onKeyboardToggle = { keyCapture?.let { it.setImeVisible(!it.imeShown) } },
// The toggle TYPES — summoning also needs the KEYBOARD grant (hiding is free).
onKeyboardToggle = {
keyCapture?.let { v ->
if (v.imeShown || accessGrants and SessionAccess.KEYBOARD != 0) {
v.setImeVisible(!v.imeShown)
}
}
},
)
} else {
null
}
activity?.remotePointer = remote
// Shared clipboard (text v1): only when the user setting is on AND the host has a
// working clipboard service. Protocol-level opt-in + the poll thread live in the sync.
val clip = if (session.clipboardSync && NativeBridge.nativeClipSupported(handle)) {
// Everything the grant gates hang off now exists — apply the session's access level once
// up front (the poll only re-applies on change, and a restricted session is restricted
// from its first event, not from its first poll).
applyAccess(accessGrants)
// Shared clipboard (text v1): only when the user setting is on AND the session's access
// includes the clipboard AND the host has a working clipboard service. Ungranted, the
// host's policy resolution declines everything anyway (grants AND into it); not starting
// the sync is the client-side mirror — no offers announced, no poll thread for a plane
// that cannot move. Applied at session start only, like the host's own coordinator gate.
val clip = if (session.clipboardSync &&
accessGrants and SessionAccess.CLIPBOARD != 0 &&
NativeBridge.nativeClipSupported(handle)
) {
ClipboardSync(context, handle).also { it.start() }
} else {
null
@@ -699,6 +796,7 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
activity?.remotePointer = null
decor?.pointerIcon = priorPointerIcon
activity?.streamHandle = 0L
activity?.streamAccess = SessionAccess.ALL // grants are per session, like the handle
activity?.requestStreamExit = null
// Back in the menus: the SC2 (if present) resumes driving the console UI.
activity?.startSc2MenuNav()
@@ -817,7 +915,12 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
.roundToInt(),
)
NativeBridge.nativeStartAudio(handle, lowLatencyMode, isTv)
if (micWanted) {
// The MIC grant is read live (a surface recreate re-runs this, and
// the mask may have changed since the last one): without it no
// capture opens — the host never attached this session to its mic
// service, so the platform's recording indicator would announce a
// mic nobody can hear.
if (micWanted && accessGrants and SessionAccess.MIC != 0) {
val sessionId =
NativeBridge.nativeStartMic(handle, initialSettings.echoCancel)
if (initialSettings.echoCancel) {
@@ -881,6 +984,22 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
)
}
}
// The Access chip — what this session is allowed to do, said in the preset vocabulary
// ("Controller only · 1 h 58 m left"), standing for the whole stream. Full control with
// no expiry — every session against an old host, and most against a new one — shows
// NOTHING: the chip exists for the sessions where input silently not landing needs an
// explanation, not as new chrome on everyone's stream. TopEnd, in the shared pill family
// (TopStart is the HUD's, TopCentre the transient cues', BottomCentre the banner's).
val accessChip = when {
accessGrants and SessionAccess.ALL == SessionAccess.ALL && accessRemaining == 0 -> null
accessRemaining > 0 ->
"${SessionAccess.label(accessGrants)} · " +
"${SessionAccess.remainingLabel(accessRemaining)} left"
else -> SessionAccess.label(accessGrants)
}
if (accessChip != null) {
AccessChip(accessChip, Modifier.align(Alignment.TopEnd).padding(12.dp))
}
// "Hold to quit" hint while the gamepad exit chord is armed — the exit debounces on a ~1 s
// hold, so without this cue a couch user reads the (deliberately no-longer-instant) chord as
// broken. Purely visual; it sits above the video and below the gesture layer.
@@ -898,7 +1017,7 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
// Above the video and below the gesture layer: it teaches touches, it must never eat one.
//
// Bottom-centre is the desktop's placement and the only edge left — TopStart is the HUD,
// TopEnd the mic badge, TopCentre the three transient cues — but MotionUnreachableHint
// TopEnd the Access chip, TopCentre the three transient cues — but MotionUnreachableHint
// already owns it, and both of these can be up at t≈0. The banner YIELDS rather than
// stacking or sliding off-centre: the notice reports something broken about THIS session
// and names the setting that fixes it, while the banner repeats shortcuts that will be
@@ -919,8 +1038,13 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
// button — all land on the same BackHandler).
add("Back leaves the stream")
// The tap lives in the pointer touch models only — passthrough gives every
// finger to the host verbatim — and needs a screen to put three fingers on.
if (hasTouch && touchMode != TouchMode.TOUCH) add("three-finger tap for stats")
// finger to the host verbatim — and needs a screen to put three fingers on,
// plus the POINTER grant (without it the gesture layer is not installed).
if (hasTouch && touchMode != TouchMode.TOUCH &&
accessGrants and SessionAccess.POINTER != 0
) {
add("three-finger tap for stats")
}
}
}.joinToString(" · "),
alpha = bannerAlpha,
@@ -951,23 +1075,35 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
// Stylus lane (design/pen-tablet-input.md §7): against a HOST_CAP_PEN host a stylus
// splits out of BOTH touch models onto the pen plane; its heartbeat coroutine keeps a
// stationary held stroke alive (and its cancellation lifts everything on teardown).
val stylus = remember(handle) {
if (NativeBridge.nativeHostSupportsPen(handle)) StylusStream(handle) else null
// The POINTER grant gates the whole touch/stylus capture layer — "don't capture what
// can't land": ungranted, no gesture handler is installed at all (and no pen lane opens),
// rather than fingers being read into events the host will drop. Keyed on the grant so an
// AccessUpdate flipping it mid-session swaps the layer live.
val pointerOk = accessGrants and SessionAccess.POINTER != 0
val stylus = remember(handle, pointerOk) {
if (pointerOk && NativeBridge.nativeHostSupportsPen(handle)) StylusStream(handle) else null
}
if (stylus != null) {
LaunchedEffect(stylus) { stylus.heartbeatLoop() }
}
Box(
videoFit.pointerInput(handle, touchMode) {
when (touchMode) {
TouchMode.TOUCH -> streamTouchPassthrough(handle, stylus)
videoFit.pointerInput(handle, touchMode, pointerOk) {
when {
!pointerOk -> {} // no capture — the Access chip is what says why
touchMode == TouchMode.TOUCH -> streamTouchPassthrough(handle, stylus)
else -> streamTouchInput(
handle,
stylus,
trackpad = touchMode == TouchMode.TRACKPAD,
invertScroll = initialSettings.invertScroll,
onCycleStats = { statsVerbosity = statsVerbosity.next() },
onKeyboard = { show -> keyCapture?.setImeVisible(show) },
// The summon rides the pointer gesture but TYPES — so it also needs the
// KEYBOARD grant (dismissing is always allowed).
onKeyboard = { show ->
if (!show || accessGrants and SessionAccess.KEYBOARD != 0) {
keyCapture?.setImeVisible(show)
}
},
)
}
},
@@ -1031,6 +1167,25 @@ private fun MicChordHint(text: String, modifier: Modifier = Modifier) {
)
}
/**
* The standing Access chip the session's access level in the preset vocabulary, with the live
* countdown when the grant expires ("Controller only · 1 h 58 m left"). Same pill family as the
* other in-stream overlays, sized down a step because it stands for the whole session rather than
* flashing a moment's confirmation. Only composed when there is something to say: a full-control
* permanent session today's normal shows nothing at all.
*/
@Composable
private fun AccessChip(text: String, modifier: Modifier = Modifier) {
Text(
text,
modifier = modifier
.background(Color.Black.copy(alpha = 0.55f), RoundedCornerShape(8.dp))
.padding(horizontal = 10.dp, vertical = 5.dp),
color = Color.White,
fontSize = 12.sp,
)
}
/**
* "This pad's gyro can't reach the game" shown briefly when a captured controller with motion
* meets a session whose virtual pad has no motion plane (the X-Box classes have no gyro in their
@@ -105,9 +105,12 @@ class DeviceGyro(
for (i in 0..2) lastAccel[i] = Gamepad.motionAccelWire(v[i])
}
Sensor.TYPE_GYROSCOPE -> {
// The write gate, per sample: pad 0 must exist (motion never creates a pad)
// and must not be a capture link's (its own IMU is streaming).
val write = router.padPresent(0) && !router.padHasOwnMotion(0)
// The write gate, per sample: sends must be on at all (the forwarding
// preference AND the session's GAMEPAD grant — an AccessUpdate can revoke it
// mid-session), pad 0 must exist (motion never creates a pad) and must not be
// a capture link's (its own IMU is streaming).
val write = router.sendsEnabled() && router.padPresent(0) &&
!router.padHasOwnMotion(0)
if (!write) {
// Stand-down edge: never leave the last angular velocity latched host-side.
if (wasWriting) {
@@ -51,7 +51,7 @@ class GamepadRouter(
* claimed by keeping a slot the Android input stack shares controllers unlike the USB
* capture links, which `StreamScreen` does not start at all while this is off.
*/
private val forwarding: Boolean = true,
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
@@ -70,6 +70,25 @@ class GamepadRouter(
private val guideGesture: Boolean = false,
) {
/** The ctor's forwarding preference, fixed for the session — one term of [forwarding]. */
private val forwardingSetting = forwarding
/**
* Whether this session's access includes the GAMEPAD grant ([SessionAccess.GAMEPAD])
* seeded from the Welcome and kept live by `StreamScreen`'s access poll (an `AccessUpdate`
* can revoke or restore it mid-session, latest-wins). Gates exactly what the forwarding
* preference gates: the wire sends, never the slots the exit/mic/stats chords must keep
* working on a Controller-less access level too (they are local controls that happen to be
* read off pad buttons). The host enforces regardless; this stops the client paying to send
* events that will be dropped. Volatile: the sensor and USB-capture threads read it per
* sample through [forwarding].
*/
@Volatile
var gamepadGranted: Boolean = true
/** Send on the wire at all — the forwarding preference AND the session's GAMEPAD grant. */
private val forwarding: Boolean get() = forwardingSetting && gamepadGranted
/** One forwarded controller: its stable wire pad index, per-device axis state, and held buttons. */
private class Slot(
val index: Int,
@@ -380,6 +399,13 @@ class GamepadRouter(
/** Whether ANY live slot currently holds wire pad [pad]. Read from the phone-gyro thread. */
fun padPresent(pad: Int): Boolean = slots.values.any { it.index == pad }
/**
* Whether wire sends are on at all the forwarding preference AND the session's GAMEPAD
* grant. For the writers that ride the pad planes from OUTSIDE this router (the phone-gyro
* mirror), which must stand down with it. Read from the sensor thread.
*/
fun sendsEnabled(): Boolean = forwarding
/**
* Whether wire pad [pad]'s motion already comes from the controller's OWN IMU either a
* capture-link slot ([ExternalPad] USB DualSense / SC2; synthetic ids are negative
@@ -99,6 +99,17 @@ object NativeBridge {
*/
external fun nativeEndReason(handle: Long): Int
/**
* The session's live access state as `[grants, remainingSecs, updateSeq]`, or `null` on a `0`
* handle. `grants` is a [SessionAccess] bitmask; `remainingSecs` counts down to the access
* expiry (`0` = permanent); `updateSeq` increments once per `AccessUpdate` the host sent
* (latest-wins the state IS the fold, this counter is how a poller tells a fresh T5 m /
* T1 m warning arrived and owes a toast). Seeded from the Welcome's access advert; an old
* host or an old native lib reads as full control, permanent, exactly what such a host
* enforces. Poll ~1 Hz alongside [nativeSessionEnded]. Cheap; safe on the UI thread.
*/
external fun nativeAccessState(handle: Long): IntArray?
/**
* Run the SPAKE2 PIN ceremony, presenting [certPem]/[keyPem]. Returns the host's verified
* fingerprint (64-hex) to persist + pin, or `""` on failure (wrong PIN / MITM / unreachable).
@@ -0,0 +1,60 @@
package io.unom.punktfunk.kit
/**
* Per-client access grants the Kotlin mirror of `punktfunk_core::quic::access` (bit-for-bit;
* `design/per-client-access.md` §3), read per session via [NativeBridge.nativeAccessState].
*
* The host is the only enforcer: everything gated on these bits client-side is courtesy UX over
* the same vocabulary don't capture what can't land (a keyboard that silently does nothing is
* the failure mode this exists to prevent), and say what this session is (the stream's Access
* chip). The user-facing word is **"Access"**; the preset labels are *derived* from the mask,
* never stored, so they can't drift from what the host actually granted.
*/
object SessionAccess {
/** Controller input — gamepad events, rich pad input, pad audio, rumble return. */
const val GAMEPAD = 1 shl 0
/** Pointing input — mouse rel/abs + buttons, scroll, touch, and the pen plane. */
const val POINTER = 1 shl 1
/** Key input — key down/up and IME-committed text. */
const val KEYBOARD = 1 shl 2
/** Shared clipboard (ANDed into the host's clipboard policy). */
const val CLIPBOARD = 1 shl 3
/** Mic injection — the uplink plane + the per-session mic attach. */
const val MIC = 1 shl 4
/** Library launch (`Hello.launch`). */
const val LAUNCH = 1 shl 5
/** Every defined grant — full control, and what an old host's Welcome decodes to. */
const val ALL = GAMEPAD or POINTER or KEYBOARD or CLIPBOARD or MIC or LAUNCH
/**
* The preset name a mask displays as §3.2's rule: three levels people actually reason
* about, "Custom" for any other combination, never a raw bit list.
*/
fun label(grants: Int): String = when (grants and ALL) {
ALL -> "Full control"
GAMEPAD -> "Controller only"
0 -> "View only"
else -> "Custom"
}
/**
* Compact time-left wording for the Access chip ("1 h 58 m", "12 m", "45 s") hours and
* minutes once the span has them, bare seconds only under a minute (the final countdown).
*/
fun remainingLabel(secs: Int): String {
val h = secs / 3600
val m = (secs % 3600) / 60
return when {
h > 0 && m > 0 -> "$h h $m m"
h > 0 -> "$h h"
m > 0 -> "$m m"
else -> "${secs.coerceAtLeast(0)} s"
}
}
}
@@ -0,0 +1,48 @@
package io.unom.punktfunk.kit
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* Pure JVM test of [SessionAccess] the bit values are an ABI contract with
* `punktfunk_core::quic::access` (wire == store == this mirror), and the preset labels are the
* §3.2 naming rule the Access chip renders from: three levels people reason about, "Custom" for
* anything else, derived from the mask so they cannot drift. Run: `./gradlew :kit:testDebugUnitTest`.
*/
class SessionAccessTest {
/** Bit-for-bit the core vocabulary — a reorder here would mislabel every session. */
@Test
fun `bits mirror punktfunk-core`() {
assertEquals(1, SessionAccess.GAMEPAD)
assertEquals(2, SessionAccess.POINTER)
assertEquals(4, SessionAccess.KEYBOARD)
assertEquals(8, SessionAccess.CLIPBOARD)
assertEquals(16, SessionAccess.MIC)
assertEquals(32, SessionAccess.LAUNCH)
assertEquals(0x3F, SessionAccess.ALL)
}
@Test
fun `preset labels derive from the mask`() {
assertEquals("Full control", SessionAccess.label(SessionAccess.ALL))
assertEquals("Controller only", SessionAccess.label(SessionAccess.GAMEPAD))
assertEquals("View only", SessionAccess.label(0))
// Any other combination is Custom — including controller + clipboard, the design's
// media-remote example.
assertEquals(
"Custom",
SessionAccess.label(SessionAccess.GAMEPAD or SessionAccess.CLIPBOARD),
)
assertEquals("Custom", SessionAccess.label(SessionAccess.ALL and SessionAccess.LAUNCH.inv()))
}
@Test
fun `remaining label is compact and never empty`() {
assertEquals("1 h 58 m", SessionAccess.remainingLabel(7130))
assertEquals("2 h", SessionAccess.remainingLabel(7200))
assertEquals("12 m", SessionAccess.remainingLabel(725))
assertEquals("45 s", SessionAccess.remainingLabel(45))
assertEquals("0 s", SessionAccess.remainingLabel(0))
}
}
@@ -0,0 +1,69 @@
//! The session's access level over JNI — the Android leg of `design/per-client-access.md` §7.
//!
//! One poll shim: Kotlin reads `[grants, remainingSecs, updateSeq]` ~1 Hz (alongside its
//! session-ended watchdog) instead of holding a blocking event thread — access news is a
//! console edit or an expiry warning, a handful per session, and every gate the mask drives
//! re-checks within a second anyway. The connector already folds each mid-session
//! [`punktfunk_core::quic::AccessUpdate`] latest-wins into its live grants/deadline slots;
//! the seq counter here only exists so the poller can tell a FRESH update arrived (the host's
//! T5 m / T1 m warnings owe a toast) without diffing state that a warning doesn't change.
use jni::errors::LogErrorAndDefault;
use jni::objects::{JIntArray, JObject};
use jni::sys::jlong;
use jni::EnvUnowned;
use std::sync::atomic::Ordering;
use std::time::Duration;
use super::SessionHandle;
/// `NativeBridge.nativeAccessState(handle): IntArray?` — the live access state as
/// `[grants, remainingSecs, updateSeq]`; `null` on a `0` handle. `grants` is the
/// `GRANT_GAMEPAD`-family bitmask, seeded from the Welcome's advert (an old host reads as
/// `GRANT_ALL` — full control, today's behavior); `remainingSecs` counts down to the access
/// deadline on the CLIENT's clock (`0` = permanent, clamped to ≥ 1 once a deadline exists so
/// the sentinel can never be reached by counting); `updateSeq` increments once per
/// `AccessUpdate` drained from the connector's event plane. Not android-gated — pure `jni` +
/// connector reads, so it links on the host build too. Cheap; safe on the UI thread.
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeAccessState<'local>(
mut env: EnvUnowned<'local>,
_this: JObject<'local>,
handle: jlong,
) -> JIntArray<'local> {
env.with_env(|env| -> jni::errors::Result<JIntArray<'local>> {
if handle == 0 {
return Ok(JIntArray::default());
}
// SAFETY: live handle per the nativeConnect/nativeClose contract.
let h = unsafe { &*(handle as *const SessionHandle) };
// Drain the event plane into the seq counter. The connector's grants/deadline slots
// are already the latest-wins fold when an event lands — the events carry no state
// this read doesn't get below, they are purely the "something arrived" cue. Zero
// timeout: this is the UI thread's poll, it must never park.
while h.client.next_access_update(Duration::ZERO).is_ok() {
h.access_seq.fetch_add(1, Ordering::Relaxed);
}
let remaining: u64 = match h.client.access_deadline_unix() {
None => 0, // permanent
Some(deadline) => {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_secs());
// ≥ 1 once a deadline exists: 0 is the "permanent" sentinel, and a deadline
// already past with the session still up (the host's typed close is in
// flight) must keep reading as "about to end", never flip to "forever".
deadline.saturating_sub(now).max(1)
}
};
let buf: [i32; 3] = [
h.client.access_grants() as i32,
remaining.min(i32::MAX as u64) as i32,
h.access_seq.load(Ordering::Relaxed) as i32,
];
let arr = env.new_int_array(buf.len())?;
arr.set_region(env, 0, &buf)?;
Ok(arr)
})
.resolve::<LogErrorAndDefault>()
}
@@ -315,6 +315,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
pad_audio: Mutex::new(None),
// A fresh session is never muted (mute is per-session UI state, not a setting).
mic_muted: Arc::new(std::sync::atomic::AtomicBool::new(false)),
access_seq: std::sync::atomic::AtomicU32::new(0),
};
Box::into_raw(Box::new(handle)) as jlong
}
+6 -1
View File
@@ -17,6 +17,7 @@
//! TODO(M4 Android stage 1): client→host DualSense rich input (`send_rich_input`), mode
//! renegotiation. Port the remaining orchestration from `clients/linux`.
mod access;
mod clipboard;
mod connect;
mod input;
@@ -25,7 +26,7 @@ mod probe;
use punktfunk_core::client::NativeClient;
use std::panic::AssertUnwindSafe;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::{Arc, Mutex};
use std::thread::JoinHandle;
@@ -82,6 +83,10 @@ pub(crate) struct SessionHandle {
/// fresh capture could send an unmuted frame. Per session and never persisted: a new session
/// starts unmuted.
pub mic_muted: Arc<AtomicBool>,
/// Count of `AccessUpdate`s drained from the connector's event plane, bumped by the
/// `nativeAccessState` poll ([`access`]) — how the Kotlin poller tells a fresh update
/// (the host's expiry warnings) arrived without holding a blocking event thread.
pub(crate) access_seq: AtomicU32,
}
struct VideoThread {
@@ -378,7 +378,11 @@ struct ContentView: View {
#if !os(tvOS)
.focusedSceneValue(\.sessionFocus, SessionFocus(
isStreaming: model.connection != nil,
clipboardAvailable: model.connection?.hostSupportsClipboard == true,
// Host cap AND this device's CLIPBOARD grant (per-client access §7) an
// ungranted session's menu item greys out instead of inviting a refused enable.
clipboardAvailable: model.connection.map {
$0.hostSupportsClipboard && $0.canUseClipboard
} == true,
clipboardOn: model.clipboardEnabled,
toggleClipboard: { model.toggleClipboardSync() },
micAvailable: model.micAvailable,
@@ -1063,7 +1067,25 @@ struct ContentView: View {
MotionUnreachableBadge()
.transition(.opacity.combined(with: .scale(scale: 0.9)))
}
// The expiry-warning toast (T5 m / T1 m, per-client access §7)
// transient, every platform, every tier: "the pad just died" must
// read as "the evening's access ended" while it can still be fixed.
if captureEnabled, let warning = model.accessWarning {
AccessWarningBadge(text: warning)
.transition(.opacity.combined(with: .scale(scale: 0.9)))
}
#if !os(tvOS)
// The access chip up for the life of a LIMITED session ("Controller
// only · ends in 1 h 58 m"), at every tier and with the overlay off.
// Never mounted for a full-and-permanent session (every old host):
// today's look must not change there. tvOS states it as a line in the
// stats overlay instead (StreamHUDView).
if captureEnabled && model.accessLimited {
AccessChipBadge(
label: model.accessLevel.label,
remainingSecs: model.accessRemainingSecs)
.transition(.opacity.combined(with: .scale(scale: 0.9)))
}
// Shown for as long as the mic is muted, at every stats tier and with the
// overlay off see MicMutedBadge. tvOS has no microphone to mute.
if captureEnabled && model.micMuted {
@@ -1083,6 +1105,8 @@ struct ContentView: View {
}
.padding(.bottom, 24)
.animation(.easeOut(duration: 0.2), value: model.micMuted)
.animation(.easeOut(duration: 0.2), value: model.accessWarning)
.animation(.easeOut(duration: 0.2), value: model.accessLimited)
}
#if os(iOS)
// Touch users have no menu / D, so when the HUD's Disconnect button isn't on
@@ -551,8 +551,10 @@ struct GamepadHomeView: View {
filled: true,
// A pinned card reaches the library too, and gets its OWN shelf: browsing is
// this card's connect with a title picked first, not a host-level action like
// wake or forget.
hasLibrary: true,
// wake or forget. Gated on a pinned identity: the library plane's MgmtTransport
// accepts any cert for an unpinned host, so an unpaired host must not expose a
// library affordance a LAN MITM could answer. security-review 2026-08-15 #8.
hasLibrary: host.pinnedSHA256 != nil,
osChain: host.osChain,
canWake: autoWakeEnabled && PunktfunkConnection.wakeOnLANAvailable
&& !online && !host.wakeMacs.isEmpty,
@@ -268,7 +268,12 @@ struct HomeView: View {
let selection: ProfileSelection = pinned.map { .profile($0.id) } ?? .inherit
// and browsing is that same connect with a title picked first, so a pinned card opens its
// OWN shelf: every launch off it carries the card's profile rather than the host's binding.
let onBrowseLibrary: (() -> Void)? = libraryEnabled
// Gated on a pinned identity, not just the feature toggle: the library plane's
// MgmtTransport trust-on-first-use accepts ANY cert for a pin-less host (self-signed, no
// SAN system trust is bypassed), so browsing an unpinned host lets a LAN MITM serve a
// forged catalog and harvest the device's pairing identity. Pair first, exactly as the
// stream path already refuses an unpinned connect. security-review 2026-08-15 finding 8.
let onBrowseLibrary: (() -> Void)? = (libraryEnabled && host.pinnedSHA256 != nil)
? { libraryTarget = LibraryTarget(host: host, profile: selection) }
: nil
return HostCardView(
@@ -357,6 +357,17 @@ struct LibraryView: View {
loading = false
return
}
// Beyond the client identity, require the HOST's pinned fingerprint. MgmtTransport accepts
// ANY cert for a pin-less host (self-signed, no SAN system trust is bypassed), so browsing
// one lets a LAN MITM serve a forged catalog and harvest this device's mTLS identity. A host
// can hold a client identity yet no host pin (abandoned pairing, or after "Forget
// Identity"), so this is a distinct check. security-review 2026-08-15 finding 8.
guard current.pinnedSHA256 != nil else {
games = []
errorText = "Pair with this host before browsing its library."
loading = false
return
}
do {
// `launchersFirst` groups launcher entries ahead of titles once, here, so the grid and
// the gamepad coverflow both inherit the D4 ordering.
@@ -262,6 +262,27 @@ final class SessionModel: ObservableObject {
/// The host's last `ClipState.reason` (`CLIP_REASON_*`) why an enable was refused
/// (backend unavailable / policy disabled / ); 0 = OK.
@Published private(set) var clipboardReason: UInt8 = 0
// MARK: - Per-client access (design/per-client-access.md §7)
/// The session's access preset, derived live from the grants mask (§3.2 the label is
/// never stored). `.fullControl` against every old host and for every full-grant device,
/// so nothing below changes today's look there.
@Published private(set) var accessLevel: PunktfunkConnection.AccessLevel = .fullControl
/// Seconds until this session's access expires; `0` = permanent. Ticks down at the 1 Hz
/// stats cadence the chip's countdown renders straight from it.
@Published private(set) var accessRemainingSecs: UInt32 = 0
/// Anything about this session's access differs from full-and-permanent the visibility
/// gate for the chip (and the tvOS stats-overlay line). False = today's look, untouched.
@Published private(set) var accessLimited = false
/// The transient expiry-warning toast ("Access ends in 5 m") non-nil for a few seconds
/// around the T5 m / T1 m marks the host also warns at via `AccessUpdate`.
@Published private(set) var accessWarning: String?
/// One-shot latches for the two warning marks (reset per session).
private var accessWarned5m = false
private var accessWarned1m = false
/// Auto-dismiss for `accessWarning` held so a newer warning replaces a pending clear.
private var accessWarningTimer: Task<Void, Never>?
#if os(tvOS)
/// Siri Remote host pointer while streaming (touch surface moves, press = left click,
/// Play/Pause = right click) + the remote's deliberate exit (hold Back 1 s). See
@@ -566,7 +587,9 @@ final class SessionModel: ObservableObject {
#if os(tvOS)
return false // no app-accessible microphone SessionAudio never opens an uplink either
#else
guard settings.micEnabled else { return false }
// The session's grants must include MIC (per-client access §7 hide the mic UI when
// ungranted; a mute button over a mic the host drops would be a lie twice over).
guard settings.micEnabled, connection?.canUseMic != false else { return false }
switch AVCaptureDevice.authorizationStatus(for: .audio) {
case .authorized, .notDetermined: return true
default: return false // denied / restricted there is no uplink to mute
@@ -613,6 +636,71 @@ final class SessionModel: ObservableObject {
audio?.setMicMuted(micMuted || isBackgrounded)
}
// MARK: - Per-client access (chip state + expiry warnings)
/// Refresh the published access state from the connection's LIVE grants + countdown
/// called by the 1 Hz stats tick, which is also what makes a mid-session `AccessUpdate`
/// (a console edit) reach the chip and the capture gates within a second. The equality
/// guards keep a full-and-permanent session (every old host) from publishing anything.
private func updateAccessState() {
guard let conn = connection else { return }
let grants = conn.accessGrants
let level = PunktfunkConnection.AccessLevel(grants: grants)
let remaining = conn.accessExpiresInSeconds
if accessLevel != level { accessLevel = level }
if accessRemainingSecs != remaining { accessRemainingSecs = remaining }
let limited = level != .fullControl || remaining != 0
if accessLimited != limited { accessLimited = limited }
// A mid-session edit that removed BOTH input classes releases an engaged capture:
// holding a frozen cursor and swallowed keys over input the host now drops is
// exactly the "keyboard does nothing and nobody says why" failure §7 exists to
// prevent. (Engage is gated at the stream views; this is the live-revoke half.)
if mouseCaptured,
grants & (PunktfunkConnection.grantPointer | PunktfunkConnection.grantKeyboard) == 0 {
NotificationCenter.default.post(name: .punktfunkReleaseCapture, object: nil)
}
// The T5 m / T1 m warning toasts (§7). Derived from the countdown CROSSING the
// marks rather than from the AccessUpdate messages alone: the host's warnings
// re-anchor the same countdown, so this shows them when they arrive AND still fires
// on plain clock progress if a warning datagram never lands. One shot each; an edit
// that extends the deadline back above a mark re-arms it.
guard remaining != 0 else { return }
if remaining > 300 {
accessWarned5m = false
accessWarned1m = false
} else if remaining > 60 {
accessWarned1m = false
if !accessWarned5m {
accessWarned5m = true
showAccessWarning("Access ends in \(Self.accessCountdown(remaining))")
}
} else if !accessWarned1m {
accessWarned1m = true
accessWarned5m = true
showAccessWarning("Access ends in under a minute")
}
}
/// Put one warning toast up for a few seconds (the motion hint's pattern: last one wins,
/// its timer restarts, teardown cancels a pending clear).
private func showAccessWarning(_ text: String) {
accessWarning = text
accessWarningTimer?.cancel()
accessWarningTimer = Task { [weak self] in
try? await Task.sleep(for: .seconds(Self.motionHintSeconds))
guard !Task.isCancelled else { return }
self?.accessWarning = nil
}
}
/// "1 h 58 m" / "12 m" / "45 s" the countdown wording the chip and the warnings share.
static func accessCountdown(_ secs: UInt32) -> String {
let s = Int(secs)
if s >= 3600 { return "\(s / 3600) h \((s % 3600) / 60) m" }
if s >= 60 { return "\(s / 60) m" }
return "\(s) s"
}
/// Follow a live stats-overlay cycle (S, the three-finger tap, the Stream menu). Those
/// surfaces write the GLOBAL setting as they always have; this moves the session's own tier
/// with it, so cycling still works in a session a profile put on a different tier.
@@ -658,6 +746,16 @@ final class SessionModel: ObservableObject {
motionHintTimer?.cancel()
motionHintTimer = nil
motionUnreachableKind = nil
// Access state is per-session: back to the invisible full-and-permanent default, and
// no warning latch may carry into the next stream (same discipline as the mic mute).
accessWarningTimer?.cancel()
accessWarningTimer = nil
accessWarning = nil
accessLevel = .fullControl
accessRemainingSecs = 0
accessLimited = false
accessWarned5m = false
accessWarned1m = false
let audio = self.audio
self.audio = nil
// Gamepad capture is main-actor (releases held buttons on the wire while the
@@ -732,6 +830,10 @@ final class SessionModel: ObservableObject {
let name = activeHost?.displayName ?? "host"
// WHY it ended, asked while the connection is still up `disconnect` tears it down.
let reason = conn.sessionEndReason
// A typed mid-session rejection outranks the coarse reason: an access-expiry close
// (per-client access §4) files under `.hostError` there, and "ended with an error"
// is the wrong sentence for "your access expired".
let rejection = conn.endRejection
// Where a game exit sends us: back into the library this title was launched from, so the
// next one is a tap away. Only for a launch that CAME from the library a game exiting in
// a plain desktop session has no library to return to.
@@ -741,6 +843,11 @@ final class SessionModel: ObservableObject {
// without naming one, which is what that launch effectively browsed.
let shelf = launchedShelf ?? activeHost.map { LibraryTarget(host: $0) }
disconnect(deliberate: false) // host/network ended it keep the linger for a reconnect
if let rejection {
// The shared typed-rejection wording ("Your access to this host has expired").
errorMessage = "\(name): \(rejection.userMessage)"
return
}
switch reason {
case .gameExited:
// The player quit their own game. Not a failure, and they are probably after the next
@@ -795,7 +902,9 @@ final class SessionModel: ObservableObject {
speakerUID: settings.speakerUID,
micUID: settings.micUID,
micChannel: settings.micChannel,
micEnabled: settings.micEnabled,
// Deny-at-setup for an ungranted mic (per-client access §5): no MIC bit, no
// uplink at all a capture the host would only drop is pure privacy downside.
micEnabled: settings.micEnabled && conn.canUseMic,
echoCancel: settings.echoCancel,
// The A/V sync reference: `endToEnd` is captureon-glass, the one figure that says
// where the picture actually IS, and the audio ring steers its depth to land with it.
@@ -833,9 +942,11 @@ final class SessionModel: ObservableObject {
gamepadFeedback = feedback
#if os(macOS)
// Shared clipboard: opt-in per host AND host-advertised (older hosts / operator-disabled
// hosts never see a ClipControl). Same trust gate as audio nothing is announced
// hosts never see a ClipControl) AND granted to this device (per-client access §5
// without the bit the host would refuse with CLIP_REASON_NOT_PERMITTED anyway; not
// asking keeps the UI honest). Same trust gate as audio nothing is announced
// during the trust prompt.
if activeHost?.clipboardSync == true, conn.hostSupportsClipboard {
if activeHost?.clipboardSync == true, conn.hostSupportsClipboard, conn.canUseClipboard {
startClipboardSync(conn)
}
#endif
@@ -875,7 +986,7 @@ final class SessionModel: ObservableObject {
clipboardEnabled = false
clipboardReason = 0
Task.detached { sync.stop() }
} else if conn.hostSupportsClipboard {
} else if conn.hostSupportsClipboard, conn.canUseClipboard {
startClipboardSync(conn)
}
#endif
@@ -892,6 +1003,9 @@ final class SessionModel: ObservableObject {
// success; this only fires after the timeout.
self.resizeIndicator.tick(now: Date().timeIntervalSinceReferenceDate)
self.resizing = self.resizeIndicator.active
// Access chip + expiry warnings: the same tick that drives every other live
// readout also walks the countdown and picks up mid-session grant edits.
self.updateAccessState()
let (frames, bytes, total) = self.meter.drain()
self.fps = frames
self.mbps = Double(bytes) * 8 / 1_000_000
@@ -96,6 +96,21 @@ struct StreamHUDView: View {
.lineLimit(1)
}
}
#if os(tvOS)
// The session's access level (per-client access §7). tvOS carries it HERE, as a
// stats-overlay line, instead of the floating chip the pointer platforms wear a
// couch surface where every extra overlay competes with the picture keeps the
// fact with the other session facts. Absent for full-and-permanent sessions
// (every old host): today's overlay must not change there.
if model.accessLimited {
Text(model.accessRemainingSecs == 0
? "access \(model.accessLevel.label.lowercased())"
: "access \(model.accessLevel.label.lowercased()) · ends in "
+ SessionModel.accessCountdown(model.accessRemainingSecs))
.font(.system(.caption2, design: .monospaced))
.foregroundStyle(.secondary)
}
#endif
if model.endToEndValid {
// Stage-2: the end-to-end headline (captureon-glass, measured directly, skew-
// corrected) "(same-host clock)" when the host didn't answer the skew
@@ -210,15 +225,20 @@ struct StreamHUDView: View {
// Capture hint, shown only until input is captured how to grab it. The RELEASE
// shortcut is intentionally not surfaced in the overlay (it lives on the Stream menu
// and, on macOS, the start-of-stream banner), keeping the HUD uncluttered while playing.
// Both hints are additionally gated on the session's grants ALLOWING a capture
// (per-client access §7): inviting a Controller-only or View-only session to
// "capture input" the host would only drop is the lie the grants advert exists
// to prevent. Read live off the connection a re-render lands with the model's
// access churn.
#if os(macOS)
if !model.mouseCaptured {
if !model.mouseCaptured, connection.canSendPointer || connection.canSendKeyboard {
Text("Click the stream to capture input")
.font(.geist(11, relativeTo: .caption2))
.foregroundStyle(.secondary)
}
#elseif os(iOS)
// Touch always plays directly; (hardware keyboard) captures kb/mouse.
if !model.mouseCaptured {
if !model.mouseCaptured, connection.canSendPointer || connection.canSendKeyboard {
Text("⌘⎋ captures keyboard & mouse")
.font(.geist(11, relativeTo: .caption2))
.foregroundStyle(.secondary)
@@ -361,6 +381,68 @@ struct MotionUnreachableBadge: View {
}
}
#if !os(tvOS)
/// The session's access chip (per-client access §7) "Controller only · ends in 1 h 58 m".
/// Rides over the stream for the life of a LIMITED session, at every stats tier and with the
/// overlay off entirely, in the badges' glass language: what this session may do (and for how
/// long) is not a statistic, and a guest whose keyboard does nothing deserves the why on
/// screen. Never mounted for full-and-permanent sessions today's look does not change.
/// (tvOS states the same fact as a stats-overlay line instead a chip would fight the couch
/// UI's single-focus rule.)
struct AccessChipBadge: View {
let label: String
/// Seconds until access expires; `0` = permanent (the chip then shows the level alone).
let remainingSecs: UInt32
var body: some View {
HStack(spacing: 7) {
Image(systemName: "lock.fill")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(.white.opacity(0.75))
Text(remainingSecs == 0
? label
: "\(label) · ends in \(SessionModel.accessCountdown(remainingSecs))")
.font(.geist(12, .medium, relativeTo: .caption))
.foregroundStyle(.white.opacity(0.9))
}
.padding(.horizontal, 14)
.padding(.vertical, 8)
.glassBackground(Capsule())
.environment(\.colorScheme, .dark) // reads over any frame, like the resize overlay
.accessibilityElement(children: .combine)
.accessibilityLabel(
remainingSecs == 0
? "Access level: \(label)"
: "Access level: \(label), ends in \(SessionModel.accessCountdown(remainingSecs))")
}
}
#endif
/// The expiry-warning toast (per-client access §7): the host's T5 m / T1 m `AccessUpdate`
/// warnings, surfaced briefly in the badge stack every platform, tvOS included (unlike the
/// chip, a warning is worth a moment of couch overlay; it is how "the pad just died" becomes
/// "the evening's access ended, ask for more").
struct AccessWarningBadge: View {
let text: String
var body: some View {
HStack(spacing: 7) {
Image(systemName: "clock.badge.exclamationmark")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(.yellow)
Text(text)
.font(.geist(12, .medium, relativeTo: .caption))
.foregroundStyle(.white.opacity(0.9))
}
.padding(.horizontal, 14)
.padding(.vertical, 8)
.glassBackground(Capsule())
.environment(\.colorScheme, .dark) // reads over any frame, like the resize overlay
.accessibilityElement(children: .combine)
.accessibilityLabel(text)
}
}
#if !os(tvOS)
/// The muted-microphone badge the mute STATE, as opposed to the buttons that flip it. It rides
/// over the stream whenever the mic is muted, INDEPENDENT of the stats overlay (which the user
@@ -56,7 +56,12 @@ enum HTTPResponseParser {
guard let length = Int(field.trimmingCharacters(in: .whitespaces)), length >= 0 else {
throw HTTPParseError.malformedHeader
}
let end = head.bodyStart + length
// A malicious host can send Content-Length = Int.max; `bodyStart + length` would then
// overflow, and Swift integer overflow TRAPS (uncatchable crash), not throws. Add
// reporting overflow and reject instead. security-review 2026-08-15 (low: HTTPResponse
// Int overflow).
let (end, overflow) = head.bodyStart.addingReportingOverflow(length)
if overflow { throw HTTPParseError.malformedHeader }
return b.count >= end ? end : nil
}
return nil // framed by connection close
@@ -99,6 +99,12 @@ public enum HostRejection: Sendable {
case superseded
case wireVersionMismatch
case busy
/// This device's access grant expired (per-client access §4) at connect (an expired
/// record races the knock path), or as the typed close ending a live session.
case accessExpired
/// The Hello asked to launch a title but this device's grants exclude `LAUNCH` refused
/// at the handshake so the user gets a sentence, not a bare desktop they didn't ask for.
case launchNotPermitted
init?(status: Int32) {
switch status {
@@ -111,6 +117,8 @@ public enum HostRejection: Sendable {
case PUNKTFUNK_STATUS_REJECTED_SUPERSEDED.rawValue: self = .superseded
case PUNKTFUNK_STATUS_REJECTED_WIRE_VERSION.rawValue: self = .wireVersionMismatch
case PUNKTFUNK_STATUS_REJECTED_BUSY.rawValue: self = .busy
case PUNKTFUNK_STATUS_REJECTED_ACCESS_EXPIRED.rawValue: self = .accessExpired
case PUNKTFUNK_STATUS_REJECTED_LAUNCH_NOT_PERMITTED.rawValue: self = .launchNotPermitted
default: return nil
}
}
@@ -140,6 +148,12 @@ public enum HostRejection: Sendable {
return "Client and host versions don't match — update both to the same release."
case .busy:
return "The host is busy with another session."
case .accessExpired:
return "Your access to this host has expired — ask its owner to grant "
+ "access again."
case .launchNotPermitted:
return "This device isn't permitted to launch games on the host — connect "
+ "to the desktop instead, or ask the owner to allow launching."
}
}
}
@@ -481,6 +495,135 @@ public final class PunktfunkConnection {
hostCaps & UInt8(PUNKTFUNK_HOST_CAP_PEN) != 0
}
// MARK: - Per-client access (design/per-client-access.md §7)
/// The `PUNKTFUNK_GRANT_*` access bits what a paired device may DO on the host, per the
/// session's live grants (``accessGrants``). Values are wire/ABI-frozen (the header's
/// expression macros don't import into Swift, like `userFlagChunkAligned`'s).
public static let grantGamepad: UInt32 = 1 << 0
public static let grantPointer: UInt32 = 1 << 1
public static let grantKeyboard: UInt32 = 1 << 2
public static let grantClipboard: UInt32 = 1 << 3
public static let grantMic: UInt32 = 1 << 4
public static let grantLaunch: UInt32 = 1 << 5
/// Every defined grant full control, today's behavior and what an old host's Welcome
/// decodes to.
public static let grantAll: UInt32 = 0x3F
/// The three user-facing access presets plus "Custom", DERIVED from the mask (never
/// stored design §3.2, no drift). The label vocabulary is the cross-client one the web
/// console's Access column uses.
public enum AccessLevel: Sendable, Equatable {
case fullControl
case controllerOnly
case viewOnly
case custom
public init(grants: UInt32) {
switch grants & PunktfunkConnection.grantAll {
case PunktfunkConnection.grantAll: self = .fullControl
case PunktfunkConnection.grantGamepad: self = .controllerOnly
case 0: self = .viewOnly
default: self = .custom
}
}
public var label: String {
switch self {
case .fullControl: return "Full control"
case .controllerOnly: return "Controller only"
case .viewOnly: return "View only"
case .custom: return "Custom"
}
}
}
/// The session's LIVE effective access grants (`PUNKTFUNK_GRANT_*`): the Welcome advert
/// first, then latest-wins over every mid-session `AccessUpdate` (a console edit) so a
/// 1 Hz poll of this is how the chip and the capture gates track changes. Full control
/// against an old host, and after close (nothing to restrict on a dead session).
///
/// Courtesy truth only: the HOST enforces the mask regardless. The client uses it to not
/// capture what can't land a keyboard that silently does nothing is the failure mode
/// this exists to prevent.
public var accessGrants: UInt32 {
abiLock.lock()
defer { abiLock.unlock() }
guard let h = handle, !closeRequested else { return Self.grantAll }
var grants: UInt32 = Self.grantAll
_ = punktfunk_connection_grants(h, &grants)
return grants
}
/// Seconds until this session's access expires, LIVE (the core counts it down from the
/// Welcome / the latest `AccessUpdate`, anchored to this device's clock skew never moves
/// it). `0` = permanent show no countdown then; while a deadline exists it clamps to
/// 1, so `0` stays unambiguous. Poll ~1 Hz for the "ends in 1 h 58 m" chip.
public var accessExpiresInSeconds: UInt32 {
abiLock.lock()
defer { abiLock.unlock() }
guard let h = handle, !closeRequested else { return 0 }
var secs: UInt32 = 0
_ = punktfunk_connection_access_expires_in(h, &secs)
return secs
}
/// The session's grants allow controller input (pads, rich DualSense input).
public var canSendGamepad: Bool { accessGrants & Self.grantGamepad != 0 }
/// The session's grants allow pointing input (mouse, scroll, touch, pen) the
/// pointer-lock / touch-capture gate.
public var canSendPointer: Bool { accessGrants & Self.grantPointer != 0 }
/// The session's grants allow key input the keyboard-grab gate.
public var canSendKeyboard: Bool { accessGrants & Self.grantKeyboard != 0 }
/// The session's grants allow the shared clipboard (AND this with
/// ``hostSupportsClipboard`` before offering the toggle).
public var canUseClipboard: Bool { accessGrants & Self.grantClipboard != 0 }
/// The session's grants allow mic injection hide the mic UI without it.
public var canUseMic: Bool { accessGrants & Self.grantMic != 0 }
/// Anything about this session's access differs from the everyday full-and-permanent
/// the chip's visibility gate: full + permanent must look exactly like today.
public var accessIsLimited: Bool {
accessGrants & Self.grantAll != Self.grantAll || accessExpiresInSeconds != 0
}
/// The grant bit one wire input kind needs the Swift mirror of core's exhaustive
/// `classify` (keys keyboard; mouse/scroll/touch pointer; pads gamepad), consulted
/// by ``send(_:)``'s courtesy filter. An unknown/future kind maps to 0 never granted
/// matching the host's default-deny.
private static func grantBit(forInputKind kind: UInt8) -> UInt32 {
switch UInt32(kind) {
case PUNKTFUNK_INPUT_KIND_KEY_DOWN.rawValue,
PUNKTFUNK_INPUT_KIND_KEY_UP.rawValue,
PUNKTFUNK_INPUT_KIND_TEXT_INPUT.rawValue:
return grantKeyboard
case PUNKTFUNK_INPUT_KIND_MOUSE_MOVE.rawValue,
PUNKTFUNK_INPUT_KIND_MOUSE_MOVE_ABS.rawValue,
PUNKTFUNK_INPUT_KIND_MOUSE_BUTTON_DOWN.rawValue,
PUNKTFUNK_INPUT_KIND_MOUSE_BUTTON_UP.rawValue,
PUNKTFUNK_INPUT_KIND_MOUSE_SCROLL.rawValue,
PUNKTFUNK_INPUT_KIND_TOUCH_DOWN.rawValue,
PUNKTFUNK_INPUT_KIND_TOUCH_MOVE.rawValue,
PUNKTFUNK_INPUT_KIND_TOUCH_UP.rawValue:
return grantPointer
case PUNKTFUNK_INPUT_KIND_GAMEPAD_BUTTON.rawValue,
PUNKTFUNK_INPUT_KIND_GAMEPAD_AXIS.rawValue,
PUNKTFUNK_INPUT_KIND_GAMEPAD_STATE.rawValue,
PUNKTFUNK_INPUT_KIND_GAMEPAD_REMOVE.rawValue,
PUNKTFUNK_INPUT_KIND_GAMEPAD_ARRIVAL.rawValue:
return grantGamepad
default:
return 0
}
}
/// Whether the LIVE grants include `bit`. Call with `abiLock` held and a live handle
/// the send paths' shape, so the read and the send see the same session.
private func granted(_ bit: UInt32, handle h: OpaquePointer) -> Bool {
var grants: UInt32 = Self.grantAll
_ = punktfunk_connection_grants(h, &grants)
return grants & bit != 0
}
/// One forwarded host-cursor shape (the cursor channel, ABI v11): straight-alpha RGBA,
/// `rgba.count == width * height * 4`, hotspot within the bitmap. Cache by `serial`
/// states reference shapes by it and a re-shown serial never resends pixels.
@@ -1280,12 +1423,17 @@ public final class PunktfunkConnection {
}
/// Send one input event (delivered to the host as a QUIC datagram). Thread-safe;
/// silently dropped after close.
/// silently dropped after close and dropped when the session's live grants exclude the
/// event's class (the courtesy mirror of the host's classify-and-drop: the HOST enforces
/// regardless, but not putting undeliverable events on the wire is what lets every input
/// path honor a mid-session grant edit without each caller re-checking).
public func send(_ event: PunktfunkInputEvent) {
var ev = event
abiLock.lock()
defer { abiLock.unlock() }
guard let h = handle, !closeRequested else { return }
guard let h = handle, !closeRequested,
granted(Self.grantBit(forInputKind: ev.kind), handle: h)
else { return }
_ = punktfunk_connection_send_input(h, &ev)
}
@@ -1296,7 +1444,9 @@ public final class PunktfunkConnection {
guard !samples.isEmpty else { return }
abiLock.lock()
defer { abiLock.unlock() }
guard let h = handle, !closeRequested else { return }
// The pen plane is pointing input same courtesy grant gate as `send(_:)`.
guard let h = handle, !closeRequested, granted(Self.grantPointer, handle: h)
else { return }
samples.withUnsafeBufferPointer { buf in
_ = punktfunk_connection_send_pen(h, buf.baseAddress, UInt32(buf.count))
}
@@ -1348,7 +1498,10 @@ public final class PunktfunkConnection {
public func sendMic(_ opus: Data, seq: UInt32, ptsNs: UInt64) {
abiLock.lock()
defer { abiLock.unlock() }
guard let h = handle, !closeRequested else { return }
// Mic injection needs its grant same courtesy gate as `send(_:)` (the host drops
// the plane regardless; the UI additionally hides the mic controls via `canUseMic`).
guard let h = handle, !closeRequested, granted(Self.grantMic, handle: h)
else { return }
opus.withUnsafeBytes { p in
_ = punktfunk_connection_send_mic(
h, p.bindMemory(to: UInt8.self).baseAddress, UInt(opus.count), seq, ptsNs)
@@ -1362,7 +1515,9 @@ public final class PunktfunkConnection {
public func sendTouchpad(pad: UInt8 = 0, finger: UInt8, active: Bool, x: UInt16, y: UInt16) {
abiLock.lock()
defer { abiLock.unlock() }
guard let h = handle, !closeRequested else { return }
// Rich pad input rides the GAMEPAD grant (it IS controller input) same gate as `send`.
guard let h = handle, !closeRequested, granted(Self.grantGamepad, handle: h)
else { return }
var rich = PunktfunkRichInput()
rich.kind = UInt8(PUNKTFUNK_RICH_TOUCHPAD)
rich.pad = pad
@@ -1383,7 +1538,9 @@ public final class PunktfunkConnection {
) {
abiLock.lock()
defer { abiLock.unlock() }
guard let h = handle, !closeRequested else { return }
// Motion is controller input too same GAMEPAD gate as `sendTouchpad`.
guard let h = handle, !closeRequested, granted(Self.grantGamepad, handle: h)
else { return }
var rich = PunktfunkRichInput()
rich.kind = UInt8(PUNKTFUNK_RICH_MOTION)
rich.pad = pad
@@ -1592,6 +1749,20 @@ public final class PunktfunkConnection {
/// Shorthand for the single most actionable reason: the host's launched game exited.
public var endedBecauseGameExited: Bool { sessionEndReason == .gameExited }
/// The typed rejection a MID-SESSION close carried, if any an access expiry being the
/// case this exists for: `sessionEndReason` can only file that deliberate close under
/// `.hostError`, and "ended with an error" is the wrong sentence for "your access
/// expired". Same read discipline as `sessionEndReason` (ask after the end, before
/// teardown); nil for every ordinary end and for connect-time rejections (those surface
/// from the connect itself as `.rejected`).
public var endRejection: HostRejection? {
guard let h = liveHandle() else { return nil }
var status: Int32 = 0
guard punktfunk_connection_end_reject(h, &status) == statusOK, status != 0
else { return nil }
return HostRejection(status: status)
}
deinit { close() }
/// Snapshot the handle unless close is pending (callers hold their plane lock).
@@ -472,15 +472,25 @@ public final class StreamLayerView: NSView {
// NSApp.isActive / isKeyWindow are still false for the click coming in from
// another app) only the auto-engage paths require already-held key status.
// `connection != nil` is the session-active gate (presenter internals are opaque here).
guard captureEnabled, !captured, connection != nil, window != nil,
guard captureEnabled, !captured, let connection, window != nil,
fromClick || (NSApp.isActive && window?.isKeyWindow == true)
else { return }
// Per-client access §7 never capture what can't land: a Controller-only or
// View-only session gets NO mouse/keyboard grab (its clicks stay local UI clicks),
// instead of a frozen cursor over input the host silently drops. Live grants, so a
// mid-session re-grant makes the next click work; the revoke direction is released
// by the session model's access tick.
guard connection.canSendPointer || connection.canSendKeyboard else { return }
// If the cursor grab is refused (e.g. the reactivating click arrives before the app is
// frontmost), stay released so the NEXT click retries never latch captured=true over
// a free cursor, which would make mouseDown's `!captured` guard reject every later click.
// In the desktop mouse model there is no grab (the pointer stays free) capture
// always engages and the monitor forwards absolute positions instead.
guard cursorCapture.capture(in: self, disassociate: !desktopMouse) else { return }
// always engages and the monitor forwards absolute positions instead. A session
// whose grants exclude POINTER also keeps its cursor free (keyboard-only capture):
// freezing a pointer whose motion cannot land would just trap the user's mouse.
guard cursorCapture.capture(
in: self, disassociate: !desktopMouse && connection.canSendPointer)
else { return }
inputCapture?.setForwarding(true, suppressClick: fromClick)
// Install AFTER the warp + setForwarding: the engage warp generates no forwarded
// delta (the monitor isn't up yet), and the engage click's suppression latch is
@@ -316,7 +316,11 @@ public final class StreamViewController: StreamViewControllerBase {
/// full-screen + frontmost and may drop the lock (Slide Over/Stage Manager/backgrounding)
/// syncPointerLock() handles the actual grant/drop and falls back to absolute when unlocked.
private var wantsPointerLock: Bool {
// The trailing grant test is per-client access §7 no pointer lock without the
// POINTER bit (a Controller-only guest's trackpad stays a normal local pointer);
// read live, so a mid-session re-grant lets the next resolve pass lock.
captured && pointerCaptureEnabled && UIDevice.current.userInterfaceIdiom == .pad
&& connection?.canSendPointer == true
}
public override var prefersPointerLocked: Bool { wantsPointerLock && !pointerLockForcedOff }
+202
View File
@@ -0,0 +1,202 @@
//! The session's effective access, client-side (design/per-client-access.md §7): one
//! snapshot type over the shared grant vocabulary, the preset label derived from the mask
//! (never stored — §3.2), the overlay chip's text, and the toast wording for a mid-session
//! [`AccessUpdate`](punktfunk_core::quic::AccessUpdate). Pure presentation logic on purpose —
//! the HOST enforces the mask whatever a client renders; everything here is the courtesy
//! that makes a limited session say what it is instead of feeling broken.
//!
//! The Apple/Android clients mirror these rules rather than link them — the labels, the
//! chip/notice wording and the derive-not-store rule below are the contract they copy.
use punktfunk_core::quic::{GRANT_ALL, GRANT_PRESET_CONTROLLER_ONLY, GRANT_PRESET_VIEW_ONLY};
use std::time::{Duration, Instant};
/// What this session may do and for how long — the client-side snapshot of the host's
/// [`Welcome`](punktfunk_core::quic::Welcome) advert, revised by every mid-session
/// [`AccessUpdate`](punktfunk_core::quic::AccessUpdate) (latest wins). Carried on
/// [`SessionEvent::Access`](crate::session::SessionEvent::Access); the default — full
/// control, permanent — is exactly what an old host's Welcome decodes to, so a session
/// against one renders today's chrome unchanged (no chip, everything enabled).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct SessionAccess {
/// The effective grant bitmask ([`punktfunk_core::quic::GRANT_GAMEPAD`] family).
pub grants: u32,
/// When this access ends, on the CLIENT's monotonic clock; `None` = permanent.
/// Monotonic so the chip's countdown never jumps with a wall-clock step.
pub deadline: Option<Instant>,
}
impl Default for SessionAccess {
fn default() -> Self {
SessionAccess {
grants: GRANT_ALL,
deadline: None,
}
}
}
impl SessionAccess {
/// Snapshot the connector's live access truth (grants + deadline), converting the
/// wall-clock deadline the core keeps into this process's monotonic clock.
pub fn from_connector(c: &punktfunk_core::client::NativeClient) -> SessionAccess {
let deadline = c.access_deadline_unix().map(|deadline_unix| {
let now_unix = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_secs());
Instant::now() + Duration::from_secs(deadline_unix.saturating_sub(now_unix))
});
SessionAccess {
grants: c.access_grants(),
deadline,
}
}
/// Whether traffic needing `bit` (one `GRANT_*` constant) may land on the host.
pub fn allows(&self, bit: u32) -> bool {
self.grants & bit != 0
}
/// Full control, permanent — today's default look, which must stay unchanged: no chip,
/// no gating, no toasts (design §7; old-host degrade).
pub fn is_default(&self) -> bool {
self.grants == GRANT_ALL && self.deadline.is_none()
}
/// Time left before this access expires — `None` = permanent, zero = already due
/// (the host's expiry close is on its way).
pub fn remaining(&self, now: Instant) -> Option<Duration> {
self.deadline.map(|d| d.saturating_duration_since(now))
}
/// The overlay chip's text — "Controller only · ends in 1 h 58 m" — or `None` for the
/// default session, which shows no chip at all.
pub fn chip_text(&self, now: Instant) -> Option<String> {
if self.is_default() {
return None;
}
let label = preset_label(self.grants);
match self.remaining(now) {
Some(left) => Some(format!("{label} · ends in {}", format_remaining(left))),
None => Some(label.to_string()),
}
}
}
/// The user-facing preset name DERIVED from the mask (design §3.2 — never stored, no
/// drift): the three presets, and "Custom" for any other combination.
pub fn preset_label(grants: u32) -> &'static str {
match grants {
GRANT_ALL => "Full control",
GRANT_PRESET_CONTROLLER_ONLY => "Controller only",
GRANT_PRESET_VIEW_ONLY => "View only",
_ => "Custom",
}
}
/// A remaining-time figure the chip/toast can wear: "1 h 58 m", "2 h", "58 m", and
/// "under 1 m" below the resolution the wire's whole seconds can honestly promise.
pub fn format_remaining(left: Duration) -> String {
let mins = left.as_secs() / 60;
match (mins / 60, mins % 60) {
(0, 0) => "under 1 m".to_string(),
(0, m) => format!("{m} m"),
(h, 0) => format!("{h} h"),
(h, m) => format!("{h} h {m} m"),
}
}
/// The toast for a mid-session access change (design §7 "end honestly"): a grants edit
/// names the new level; an unchanged-grants update is the host's expiry warning (T5 m /
/// T1 m) and names the time left. `None` = nothing worth interrupting for (an update
/// that reaffirmed a permanent, unchanged mask).
pub fn update_notice(prev_grants: u32, next: &SessionAccess, now: Instant) -> Option<String> {
if next.grants != prev_grants {
return Some(format!("Access is now {}", preset_label(next.grants)));
}
match next.remaining(now) {
Some(left) if left > Duration::ZERO => {
Some(format!("Access ends in {}", format_remaining(left)))
}
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use punktfunk_core::quic::{GRANT_CLIPBOARD, GRANT_GAMEPAD, GRANT_KEYBOARD, GRANT_POINTER};
#[test]
fn labels_derive_from_the_mask_per_the_design() {
assert_eq!(preset_label(GRANT_ALL), "Full control");
assert_eq!(preset_label(GRANT_GAMEPAD), "Controller only");
assert_eq!(preset_label(0), "View only");
// Anything off the three presets is Custom — including "controller + clipboard",
// the media-remote example, and a full mask missing one bit.
assert_eq!(preset_label(GRANT_GAMEPAD | GRANT_CLIPBOARD), "Custom");
assert_eq!(preset_label(GRANT_ALL & !GRANT_KEYBOARD), "Custom");
}
#[test]
fn the_default_session_wears_no_chip() {
let now = Instant::now();
assert!(SessionAccess::default().is_default());
assert_eq!(SessionAccess::default().chip_text(now), None);
// …and each departure from the default brings one: a narrower mask, or a deadline.
let limited = SessionAccess {
grants: GRANT_GAMEPAD,
deadline: None,
};
assert_eq!(limited.chip_text(now).as_deref(), Some("Controller only"));
let expiring = SessionAccess {
grants: GRANT_ALL,
deadline: Some(now + Duration::from_secs(2 * 3600 - 120)),
};
assert_eq!(
expiring.chip_text(now).as_deref(),
Some("Full control · ends in 1 h 58 m")
);
}
#[test]
fn remaining_time_formats_at_honest_granularity() {
assert_eq!(format_remaining(Duration::from_secs(0)), "under 1 m");
assert_eq!(format_remaining(Duration::from_secs(59)), "under 1 m");
assert_eq!(format_remaining(Duration::from_secs(60)), "1 m");
assert_eq!(format_remaining(Duration::from_secs(58 * 60)), "58 m");
assert_eq!(format_remaining(Duration::from_secs(2 * 3600)), "2 h");
assert_eq!(
format_remaining(Duration::from_secs(3600 + 58 * 60 + 30)),
"1 h 58 m"
);
}
#[test]
fn notices_name_a_grants_change_first_and_warnings_by_time_left() {
let now = Instant::now();
// A console edit: the new level is the news, even with a deadline running.
let narrowed = SessionAccess {
grants: GRANT_GAMEPAD,
deadline: Some(now + Duration::from_secs(300)),
};
assert_eq!(
update_notice(GRANT_ALL, &narrowed, now).as_deref(),
Some("Access is now Controller only")
);
// The host's T5 m warning: same grants, a deadline — name the time.
let warned = SessionAccess {
grants: GRANT_GAMEPAD,
deadline: Some(now + Duration::from_secs(300)),
};
assert_eq!(
update_notice(GRANT_GAMEPAD, &warned, now).as_deref(),
Some("Access ends in 5 m")
);
// An update that reaffirmed a permanent, unchanged mask: nothing to say.
let same = SessionAccess {
grants: GRANT_POINTER,
deadline: None,
};
assert_eq!(update_notice(GRANT_POINTER, &same, now), None);
}
}
+7
View File
@@ -42,6 +42,13 @@ pub mod deeplink;
// state machine every front-end drives, and the session spawn + stdout contract.
#[cfg(any(target_os = "linux", windows))]
pub mod orchestrate;
// The session's effective access, client-side (design/per-client-access.md §7): the
// snapshot type over the shared grant vocabulary, the derived preset label, the overlay
// chip's text and the AccessUpdate toast wording. Pure presentation logic — the
// Apple/Android ports mirror its rules rather than link it. Gated with the session
// modules only because macOS has no punktfunk-core dependency to name the grants with.
#[cfg(any(target_os = "linux", windows))]
pub mod access;
// The host's OS-identity chain (mDNS `os=` TXT): sanitize + icon-walk order. Pure string
// logic, built everywhere (the Apple/Android ports mirror it rather than link it).
pub mod os;
+86 -17
View File
@@ -347,6 +347,21 @@ pub enum SessionEvent {
msg: String,
},
Stats(Stats),
/// The session's effective access (design/per-client-access.md §7): emitted once right
/// after [`Self::Connected`] with the Welcome's advert, then again for every mid-session
/// `AccessUpdate` the host sends (a console edit, the T5 m / T1 m expiry warnings) —
/// latest wins. `notice` is the toast-worthy one-liner for a mid-session change
/// ("Access is now Controller only", "Access ends in 5 m"); `None` on the initial
/// snapshot and on updates with nothing worth interrupting for.
///
/// Courtesy chrome only — the HOST enforces the mask whatever an embedder does with
/// this. Embedders use it to gate capture (no pointer lock / keyboard grab without the
/// bits) and to wear the overlay chip; a default access (full control, permanent — every
/// old host) must render exactly today's look.
Access {
access: crate::access::SessionAccess,
notice: Option<String>,
},
}
/// How many times THIS PROCESS has had a session's codec exhaust the decode ladder — the
@@ -606,6 +621,14 @@ fn pump(
mode: connector.mode(),
fingerprint: connector.host_fingerprint,
});
// The Welcome's access advert, straight after Connected so the embedder can gate its
// capture BEFORE it engages (design §7 "not capture what can't land"). Old hosts decode
// to full-control/permanent and the embedder renders today's look unchanged.
let mut access = crate::access::SessionAccess::from_connector(&connector);
let _ = ev_tx.send_blocking(SessionEvent::Access {
access,
notice: None,
});
// Build the decoder for the codec the host resolved (never assume HEVC), honoring the
// Settings backend preference (auto/native-*/software).
@@ -728,30 +751,35 @@ fn pump(
.flatten();
// The shared clipboard (design/clipboard-and-file-transfer.md §5): its own thread, since
// `next_clip` blocks and the OS clipboard calls can wait on other apps. Returns straight
// away when the host has no clipboard capability, so spawning is unconditional.
let clipboard_thread = params
.clipboard
.then(|| {
let c = connector.clone();
let s = stop.clone();
std::thread::Builder::new()
.name("pf-clipboard".into())
.spawn(move || crate::clipboard::run(c, s))
.ok()
})
.flatten();
// away when the host has no clipboard capability, so spawning is gated only by the
// setting — and by the session's CLIPBOARD grant (the client half of design §5.4
// "deny at setup": the host's coordinator never starts for an ungranted session, so a
// bridge here would only ever collect NOT_PERMITTED refusals).
let clipboard_thread = (params.clipboard
&& access.allows(punktfunk_core::quic::GRANT_CLIPBOARD))
.then(|| {
let c = connector.clone();
let s = stop.clone();
std::thread::Builder::new()
.name("pf-clipboard".into())
.spawn(move || crate::clipboard::run(c, s))
.ok()
})
.flatten();
// The uplink, and with it the mute the embedder's chord drives. `set_live` is what makes
// the chord (and its indicator) real: a mic turned off in Settings, or a capture device
// that wouldn't open, leaves it false and the chord stays an honest no-op.
let _mic = params
.mic_enabled
// the chord (and its indicator) real: a mic turned off in Settings, a capture device
// that wouldn't open, OR a session without the MIC grant (the host would drop the
// datagrams — don't open the capture device for a plane that can't land) leaves it
// false and the chord stays an honest no-op. `mut`: a mid-session AccessUpdate moves
// the grant, and the uplink follows it live below.
let mut mic_uplink = (params.mic_enabled && access.allows(punktfunk_core::quic::GRANT_MIC))
.then(|| {
audio::MicStreamer::spawn(connector.clone(), mic.flag(), params.echo_cancel)
.map_err(|e| tracing::warn!(error = %e, "mic uplink disabled"))
.ok()
})
.flatten();
mic.set_live(_mic.is_some());
mic.set_live(mic_uplink.is_some());
// Live host↔client clock offset: loaded per frame (Relaxed) so mid-stream re-syncs (an NTP
// step, drift) keep the capture-clock latency stats honest — never cached at session start.
@@ -874,6 +902,39 @@ fn pump(
debug_reconfig = None;
}
}
// Mid-session access updates (a console edit, the T5 m / T1 m expiry warnings).
// Drain the queue and re-read the connector's live truth ONCE — latest wins per
// design, and the connector already folded every update before waking us. The mic
// uplink follows its grant live: removed → the capture device closes now (the host
// is dropping the plane anyway); granted back (and wanted in Settings) → it starts
// again without a reconnect.
{
let mut updated = false;
while connector.next_access_update(Duration::ZERO).is_ok() {
updated = true;
}
if updated {
let prev = access;
access = crate::access::SessionAccess::from_connector(&connector);
let notice = crate::access::update_notice(prev.grants, &access, Instant::now());
let mic_on = params.mic_enabled && access.allows(punktfunk_core::quic::GRANT_MIC);
if !mic_on && mic_uplink.is_some() {
tracing::info!("MIC grant removed mid-session — stopping the mic uplink");
mic_uplink = None;
mic.set_live(false);
} else if mic_on && mic_uplink.is_none() {
mic_uplink = audio::MicStreamer::spawn(
connector.clone(),
mic.flag(),
params.echo_cancel,
)
.map_err(|e| tracing::warn!(error = %e, "mic uplink disabled"))
.ok();
mic.set_live(mic_uplink.is_some());
}
let _ = ev_tx.send_blocking(SessionEvent::Access { access, notice });
}
}
// 20 ms wait: audio has its own thread now, so this only bounds stop-flag
// responsiveness and the per-iteration keyframe-recovery check (a frame arrives
// every ~816 ms at 60120 Hz anyway, so this rarely times out mid-stream).
@@ -1270,6 +1331,14 @@ fn pump(
// line in front of the player for quitting their own game.
Err(PunktfunkError::Closed) => {
use punktfunk_core::client::PunktfunkEndReason as End;
// A typed mid-session rejection names itself — today that is the access
// expiry (close 0x69, after the host's T5 m / T1 m warnings), which
// would otherwise file under HostError and render as "the host ended the
// session with an error": true, and exactly the wrong sentence. Same
// wording as the connect-time path, one vocabulary (design §7).
if let Some(reason) = connector.end_reject() {
break Some(crate::trust::connect_reject_message(reason));
}
break match connector.end_reason() {
// The player quit the game the host launched. Nothing to report; a launcher
// embedder returns to its library, which is where they were headed anyway.
+8
View File
@@ -867,6 +867,14 @@ pub fn connect_reject_message(reason: punktfunk_core::reject::RejectReason) -> S
(web console Log) has the cause."
.into()
}
R::AccessExpired => {
"Your access to this host has expired — ask the host's owner to grant it again.".into()
}
R::LaunchNotPermitted => {
"This device isn't permitted to launch games on the host — connect without picking \
a game, or ask the host's owner to allow launching."
.into()
}
}
}
+63 -1
View File
@@ -50,6 +50,12 @@ struct Drawn {
height: u32,
stats: Option<String>,
hint: Option<String>,
/// The access chip's text ("Controller only · ends in 1 h 58 m"). Its countdown moves
/// once a minute, which is exactly one damage redraw a minute — a steady chip costs
/// nothing per frame.
access: Option<String>,
/// The transient access toast (holds the hint pill's slot while up).
notice: Option<String>,
/// The mic-mute badge is up. Part of the damage key like everything else here — the badge
/// is static once drawn, so a muted stream still re-renders nothing per frame.
mic_muted: bool,
@@ -465,6 +471,8 @@ impl Overlay for SkiaOverlay {
let resize_step = resize_phase.map_or(0, |p| (p * 120.0) as u16 + 1);
if ctx.stats.is_none()
&& ctx.hint.is_none()
&& ctx.access.is_none()
&& ctx.notice.is_none()
&& !ctx.mic_muted
&& banner_step == 0
&& resize_step == 0
@@ -480,6 +488,8 @@ impl Overlay for SkiaOverlay {
height: ctx.height,
stats: ctx.stats.map(str::to_owned),
hint: ctx.hint.map(str::to_owned),
access: ctx.access.map(str::to_owned),
notice: ctx.notice.map(str::to_owned),
mic_muted: ctx.mic_muted,
scale_pct: (scale * 100.0).round() as u16,
banner_step,
@@ -521,7 +531,17 @@ impl Overlay for SkiaOverlay {
if want.mic_muted {
draw_mic_muted_badge(canvas, font, ctx.width, scale);
}
if let Some(hint) = &want.hint {
// The access chip shares the top-right corner (same tier-independence argument as
// the badge — "what may this session do" must survive the stats overlay being
// Off), stacking under the badge when both are up.
if let Some(access) = &want.access {
draw_access_chip(canvas, font, access, ctx.width, want.mic_muted, scale);
}
// The access toast outranks the capture hint for its few seconds — an "Access
// ends in 1 m" must not lose the slot to "click to capture".
if let Some(notice) = &want.notice {
draw_hint_pill(canvas, font, notice, ctx.width, ctx.height, 1.0, scale);
} else if let Some(hint) = &want.hint {
draw_hint_pill(canvas, font, hint, ctx.width, ctx.height, 1.0, scale);
} else if banner_step > 0 {
// The start banner: the leave/stats shortcuts, fading out on its own —
@@ -787,6 +807,48 @@ fn draw_mic_muted_badge(canvas: &Canvas, base_font: &Font, width: u32, scale: f3
);
}
/// The access chip (per-client access §7 "say what this session is"): the session's
/// derived preset label and its countdown — "Controller only · ends in 1 h 58 m" — on the
/// same translucent pill as the rest of the chrome, pinned to the TOP-RIGHT corner and
/// stacked under the mic badge when both are up.
///
/// Standing by design, like the badge and unlike the toasts: "why does my keyboard do
/// nothing" and "when does my access end" must be answerable ten minutes in, at every
/// stats tier including Off. Never drawn for a full-control permanent session — the run
/// loop passes `None` and today's default look stays untouched.
fn draw_access_chip(
canvas: &Canvas,
base_font: &Font,
text: &str,
width: u32,
below_badge: bool,
scale: f32,
) {
// Short line (label + countdown) — fits any window the stream runs in.
let font = &chrome_font(base_font, scale);
let (_, metrics) = font.metrics();
let line_h = metrics.descent - metrics.ascent;
let (pad_x, pad_y) = (base::PILL_PAD_X * scale, base::PILL_PAD_Y * scale);
let text_w = font.measure_str(text, None).0;
let w = text_w + 2.0 * pad_x;
let h = line_h + 2.0 * pad_y;
let margin = base::OSD_MARGIN * scale;
// One row down when the mic badge holds the corner (its height is the same formula,
// sans dot — the dot fits inside the shared line height).
let y = margin + if below_badge { h + 8.0 * scale } else { 0.0 };
let x = width as f32 - w - margin;
canvas.draw_rrect(
RRect::new_rect_xy(Rect::from_xywh(x, y, w, h), h / 2.0, h / 2.0),
&Paint::new(Color4f::new(0.0, 0.0, 0.0, 0.62), None),
);
canvas.draw_str(
text,
Point::new(x + pad_x, y + pad_y - metrics.ascent),
font,
&Paint::new(Color4f::new(1.0, 1.0, 1.0, 0.92), None),
);
}
/// The mid-stream-resize cover: a full-screen dark scrim, the shared rotating spinner, and
/// a "Resizing…" label centered over it — so the host's 0.32 s virtual-display + encoder
/// rebuild reads as a deliberate pause rather than the stream stretching to the changed
+224 -17
View File
@@ -27,6 +27,7 @@ use crate::touch::{Abs, Act, Gestures};
use pf_client_core::trust::{MouseMode, TouchMode};
use punktfunk_core::client::NativeClient;
use punktfunk_core::input::{InputEvent, InputKind};
use punktfunk_core::quic::{classify, GRANT_KEYBOARD, GRANT_POINTER};
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
@@ -70,9 +71,30 @@ pub struct Capture {
/// Reverse the scroll direction sent to the host ([`Settings::invert_scroll`]).
invert_scroll: bool,
gestures: Gestures,
/// The session's effective access grants (per-client access §7) — the courtesy gate in
/// front of every wire send here, keyed by the SAME `classify()` the host's filter uses:
/// an event whose class the mask doesn't cover never leaves this struct (the host would
/// drop it anyway; not sending is what keeps "my keyboard does nothing" from being a
/// mystery — the run loop pairs this with not grabbing what can't land). Moved live by
/// [`Capture::set_grants`] on a mid-session `AccessUpdate`.
grants: u32,
}
fn send(connector: &NativeClient, kind: InputKind, code: u32, x: i32, y: i32, flags: u32) {
/// Forward one event IF the session's grants cover its class — the client half of the
/// host's classify-and-drop filter, sharing its exhaustive [`classify`] so a future
/// `InputKind` can't slip past one side and not the other.
fn send(
connector: &NativeClient,
grants: u32,
kind: InputKind,
code: u32,
x: i32,
y: i32,
flags: u32,
) {
if grants & classify(kind).bit() == 0 {
return;
}
let _ = connector.send_input(&InputEvent {
kind,
_pad: [0; 3],
@@ -86,12 +108,15 @@ fn send(connector: &NativeClient, kind: InputKind, code: u32, x: i32, y: i32, fl
impl Capture {
/// `abs_ok` = the host injector accepts absolute pointer events; without it the
/// desktop model is unavailable and `mouse_mode` silently resolves to capture.
/// `grants` = the session's effective access mask (the Welcome advert — the run loop
/// keeps it live through [`Capture::set_grants`]).
pub fn new(
connector: Arc<NativeClient>,
touch_mode: TouchMode,
invert_scroll: bool,
mouse_mode: MouseMode,
abs_ok: bool,
grants: u32,
) -> Capture {
Capture {
connector,
@@ -108,6 +133,7 @@ impl Capture {
touch_mode,
invert_scroll,
gestures: Gestures::new(touch_mode == TouchMode::Trackpad),
grants,
}
}
@@ -115,6 +141,73 @@ impl Capture {
self.captured
}
/// The session's effective access grants — what the run loop passes to
/// `apply_capture` so pointer lock and the keyboard grab track the mask.
pub fn grants(&self) -> u32 {
self.grants
}
/// Whether engaging capture buys anything at all: with neither POINTER nor KEYBOARD
/// granted there is nothing to lock or grab FOR (a view-only or controller-only
/// session), so [`Capture::engage`] refuses and the "click to capture" hint stays
/// down — the worst failure mode is a locked pointer whose motion lands nowhere.
pub fn can_capture(&self) -> bool {
self.grants & (GRANT_POINTER | GRANT_KEYBOARD) != 0
}
/// Fold a mid-session `AccessUpdate` into the gate. A class REMOVED while something
/// of its kind is held flushes the held state up first, under the OLD mask — the
/// host may still honor the ups, and either way nothing stays pressed locally. The
/// run loop re-applies pointer lock / keyboard grab (and releases capture entirely
/// when [`Capture::can_capture`] went false) right after this.
pub fn set_grants(&mut self, grants: u32) {
if grants == self.grants {
return;
}
let lost = self.grants & !grants;
if lost & GRANT_KEYBOARD != 0 {
for vk in self.held_keys.drain() {
send(
&self.connector,
self.grants,
InputKind::KeyUp,
vk as u32,
0,
0,
0,
);
}
}
if lost & GRANT_POINTER != 0 {
self.pending_rel = (0, 0);
self.pending_abs = None;
for b in self.held_buttons.drain() {
send(
&self.connector,
self.grants,
InputKind::MouseButtonUp,
b,
0,
0,
0,
);
}
for slot in self.touch_slots.drain().map(|(_, slot)| slot) {
send(
&self.connector,
self.grants,
InputKind::TouchUp,
slot,
0,
0,
0,
);
}
self.gestures.reset();
}
self.grants = grants;
}
/// The desktop (absolute, uncaptured) mouse model is active.
pub fn desktop(&self) -> bool {
self.desktop
@@ -153,10 +246,17 @@ impl Capture {
!self.captured && !self.user_released
}
/// Engage capture. The caller flips SDL relative mouse mode on (pointer lock).
/// Engage capture. The caller flips SDL relative mouse mode on (pointer lock)
/// only on `true`: a session whose grants cover neither pointer nor keyboard
/// refuses (see [`Capture::can_capture`]), and the caller must leave the pointer
/// free rather than lock it over input that can't land.
pub fn engage(&mut self) -> bool {
if !self.can_capture() {
return false;
}
self.user_released = false;
!std::mem::replace(&mut self.captured, true)
self.captured = true;
true
}
/// Release capture, flushing everything held so nothing sticks down on the host.
@@ -172,13 +272,37 @@ impl Capture {
self.pending_rel = (0, 0); // never flush motion gathered while captured
self.pending_abs = None;
for vk in self.held_keys.drain() {
send(&self.connector, InputKind::KeyUp, vk as u32, 0, 0, 0);
send(
&self.connector,
self.grants,
InputKind::KeyUp,
vk as u32,
0,
0,
0,
);
}
for b in self.held_buttons.drain() {
send(&self.connector, InputKind::MouseButtonUp, b, 0, 0, 0);
send(
&self.connector,
self.grants,
InputKind::MouseButtonUp,
b,
0,
0,
0,
);
}
for slot in self.touch_slots.drain().map(|(_, slot)| slot) {
send(&self.connector, InputKind::TouchUp, slot, 0, 0, 0);
send(
&self.connector,
self.grants,
InputKind::TouchUp,
slot,
0,
0,
0,
);
}
// The gesture engine's held left button (a tap-drag in progress) rides in
// `held_buttons` above, so it was just flushed — here we only forget its state.
@@ -191,11 +315,20 @@ impl Capture {
pub fn flush_motion(&mut self) {
let (dx, dy) = std::mem::take(&mut self.pending_rel);
if dx != 0 || dy != 0 {
send(&self.connector, InputKind::MouseMove, 0, dx, dy, 0);
send(
&self.connector,
self.grants,
InputKind::MouseMove,
0,
dx,
dy,
0,
);
}
if let Some(a) = self.pending_abs.take() {
send(
&self.connector,
self.grants,
InputKind::MouseMoveAbs,
0,
a.x,
@@ -231,7 +364,15 @@ impl Capture {
// when the key lands (e.g. "press E at the crosshair").
self.flush_motion();
self.held_keys.insert(vk);
send(&self.connector, InputKind::KeyDown, vk as u32, 0, 0, 0);
send(
&self.connector,
self.grants,
InputKind::KeyDown,
vk as u32,
0,
0,
0,
);
}
}
@@ -239,7 +380,15 @@ impl Capture {
if let Some(vk) = keymap_sdl::scancode_to_vk(sc) {
// Flush-on-release may have beaten us to it — only forward if still held.
if self.held_keys.remove(&vk) {
send(&self.connector, InputKind::KeyUp, vk as u32, 0, 0, 0);
send(
&self.connector,
self.grants,
InputKind::KeyUp,
vk as u32,
0,
0,
0,
);
}
}
}
@@ -254,7 +403,15 @@ impl Capture {
self.flush_motion();
if let Some(gs) = keymap_sdl::mouse_button_to_gs(b) {
self.held_buttons.insert(gs);
send(&self.connector, InputKind::MouseButtonDown, gs, 0, 0, 0);
send(
&self.connector,
self.grants,
InputKind::MouseButtonDown,
gs,
0,
0,
0,
);
}
}
@@ -262,7 +419,15 @@ impl Capture {
self.flush_motion(); // the release must not beat the motion before it
if let Some(gs) = keymap_sdl::mouse_button_to_gs(b) {
if self.held_buttons.remove(&gs) {
send(&self.connector, InputKind::MouseButtonUp, gs, 0, 0, 0);
send(
&self.connector,
self.grants,
InputKind::MouseButtonUp,
gs,
0,
0,
0,
);
}
}
}
@@ -282,12 +447,28 @@ impl Capture {
let vy = ay.trunc() as i32;
if vy != 0 {
ay -= f64::from(vy);
send(&self.connector, InputKind::MouseScroll, 0, vy, 0, 0);
send(
&self.connector,
self.grants,
InputKind::MouseScroll,
0,
vy,
0,
0,
);
}
let vx = ax.trunc() as i32;
if vx != 0 {
ax -= f64::from(vx);
send(&self.connector, InputKind::MouseScroll, 1, vx, 0, 0);
send(
&self.connector,
self.grants,
InputKind::MouseScroll,
1,
vx,
0,
0,
);
}
self.scroll_acc = (ax, ay);
}
@@ -319,6 +500,7 @@ impl Capture {
let slot = self.touch_slot(finger_id);
send(
&self.connector,
self.grants,
InputKind::TouchDown,
slot,
x,
@@ -336,6 +518,7 @@ impl Capture {
if let Some(&slot) = self.touch_slots.get(&finger_id) {
send(
&self.connector,
self.grants,
InputKind::TouchMove,
slot,
x,
@@ -350,7 +533,15 @@ impl Capture {
/// no-ops), but a stray up must never strand a pressed contact on the host.
pub fn on_touch_up(&mut self, finger_id: u64) {
if let Some(slot) = self.touch_slots.remove(&finger_id) {
send(&self.connector, InputKind::TouchUp, slot, 0, 0, 0);
send(
&self.connector,
self.grants,
InputKind::TouchUp,
slot,
0,
0,
0,
);
}
}
@@ -409,15 +600,31 @@ impl Capture {
if down {
self.flush_motion(); // the press lands where the cursor now is
self.held_buttons.insert(gs);
send(&self.connector, InputKind::MouseButtonDown, gs, 0, 0, 0);
send(
&self.connector,
self.grants,
InputKind::MouseButtonDown,
gs,
0,
0,
0,
);
} else if self.held_buttons.remove(&gs) {
self.flush_motion();
send(&self.connector, InputKind::MouseButtonUp, gs, 0, 0, 0);
send(
&self.connector,
self.grants,
InputKind::MouseButtonUp,
gs,
0,
0,
0,
);
}
}
other => {
if let Some((kind, code, x, y, flags)) = other.wire() {
send(&self.connector, kind, code, x, y, flags);
send(&self.connector, self.grants, kind, code, x, y, flags);
}
}
}
+7
View File
@@ -56,6 +56,13 @@ pub struct FrameCtx<'a> {
pub stats: Option<&'a str>,
/// The capture hint (bottom-center pill, "click to capture…"); `None` = hidden.
pub hint: Option<&'a str>,
/// The access chip (per-client access §7 "say what this session is"): a small standing
/// pill — "Controller only · ends in 1 h 58 m" — drawn at every stats tier, `None` for
/// a full-control permanent session (today's default look, and every old host).
pub access: Option<&'a str>,
/// A transient access toast ("Access is now Controller only", "Access ends in 5 m") —
/// takes the hint pill's slot with priority while up. The run loop owns its timing.
pub notice: Option<&'a str>,
/// The user muted their microphone mid-stream (Ctrl+Alt+Shift+V). Draws a persistent
/// badge, deliberately independent of the stats tier: a muted mic is a fact about what
/// the host is hearing, and "did my mute take?" must be answerable with the overlay off.
+167 -31
View File
@@ -349,6 +349,16 @@ struct StreamState {
/// `None` = nothing sent yet. Edge-detected each iteration from the live mouse model, so
/// the chord, the M3 auto-flip, and engage/release all reconcile through one path.
sent_client_draws: Option<bool>,
/// The session's effective access (per-client access §7): the Welcome's advert, then
/// every mid-session `AccessUpdate` (latest wins). Drives the capture gating, the
/// overlay chip, and which held state a live edit flushes. The default — full
/// control, permanent, what every old host decodes to — renders today's look
/// unchanged: no chip, everything enabled.
access: pf_client_core::access::SessionAccess,
/// A transient access toast ("Access is now Controller only", "Access ends in 5 m")
/// and when it went up — cleared after [`ACCESS_NOTICE_S`]. Rides the hint-pill slot
/// with priority: an access change outranks "click to capture" for a few seconds.
access_notice: Option<(String, Instant)>,
/// The params this session was started with, kept so a codec fallback can re-dial
/// with `exclude_codecs` widened — see [`SessionEvent::CodecFallback`]. Cloned once
/// per session start, so anything the SESSION changed after launch (an accepted mode
@@ -398,6 +408,8 @@ impl StreamState {
connector: None,
capture: None,
cursor_chan: None,
access: pf_client_core::access::SessionAccess::default(),
access_notice: None,
last_hint: None,
hint_override: false,
sent_client_draws: None,
@@ -779,7 +791,14 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
WindowEvent::FocusLost => {
if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) {
if cap.release(false) {
apply_capture(&mut window, &mouse, false, false, inhibit_shortcuts);
apply_capture(
&mut window,
&mouse,
false,
false,
inhibit_shortcuts,
0,
);
tracing::info!("focus lost — input released");
}
}
@@ -797,14 +816,14 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
// An auto-release (Alt-Tab) undoes itself; a chord release
// stays released until the user opts back in.
if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) {
if cap.should_reengage() {
cap.engage();
if cap.should_reengage() && cap.engage() {
apply_capture(
&mut window,
&mouse,
true,
cap.desktop(),
inhibit_shortcuts,
cap.grants(),
);
tracing::info!("focus gained — input recaptured");
}
@@ -864,15 +883,22 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) {
if cap.captured() {
cap.release(true);
apply_capture(&mut window, &mouse, false, false, inhibit_shortcuts);
} else {
cap.engage();
apply_capture(
&mut window,
&mouse,
false,
false,
inhibit_shortcuts,
0,
);
} else if cap.engage() {
apply_capture(
&mut window,
&mouse,
true,
cap.desktop(),
inhibit_shortcuts,
cap.grants(),
);
}
tracing::info!(captured = cap.captured(), "chord: release/engage");
@@ -894,6 +920,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
true,
desktop,
inhibit_shortcuts,
cap.grants(),
);
}
flipped = true;
@@ -917,7 +944,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
if let Some(st) = &mut stream {
tracing::info!("chord: disconnect");
st.request_quit();
apply_capture(&mut window, &mouse, false, false, inhibit_shortcuts);
apply_capture(&mut window, &mouse, false, false, inhibit_shortcuts, 0);
// The pump emits Ended(None); the end path routes per mode.
}
continue;
@@ -1000,15 +1027,20 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
Event::MouseButtonDown { mouse_btn, .. } => {
if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) {
if !cap.captured() {
// The engaging click is suppressed toward the host.
cap.engage();
apply_capture(
&mut window,
&mouse,
true,
cap.desktop(),
inhibit_shortcuts,
);
// The engaging click is suppressed toward the host. `engage`
// refuses on a session whose access covers neither pointer nor
// keyboard — the click then does nothing, which is the honest
// rendering of "there is nothing to capture for".
if cap.engage() {
apply_capture(
&mut window,
&mouse,
true,
cap.desktop(),
inhibit_shortcuts,
cap.grants(),
);
}
} else {
cap.on_button_down(mouse_btn);
}
@@ -1182,6 +1214,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
true,
cap.desktop(),
inhibit_shortcuts,
cap.grants(),
);
if cap.desktop() {
// Reappear where the host last had the pointer, so the
@@ -1225,7 +1258,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
while escape_rx.try_recv().is_ok() {
if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) {
if cap.release(true) {
apply_capture(&mut window, &mouse, false, false, inhibit_shortcuts);
apply_capture(&mut window, &mouse, false, false, inhibit_shortcuts, 0);
}
}
if fullscreen && !opts.fullscreen {
@@ -1238,7 +1271,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
if let Some(st) = &mut stream {
tracing::info!("controller chord: disconnect");
st.request_quit();
apply_capture(&mut window, &mouse, false, false, inhibit_shortcuts);
apply_capture(&mut window, &mouse, false, false, inhibit_shortcuts, 0);
}
}
@@ -1379,15 +1412,32 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
(relative-only input) using capture"
);
}
// The session's access truth, straight off the Welcome (the pump's
// Access event lands in this same drain, but the capture below must
// be built gated, not re-gated a beat later).
st.access = pf_client_core::access::SessionAccess::from_connector(&c);
let mut cap = Capture::new(
c.clone(),
opts.touch_mode,
opts.invert_scroll,
opts.mouse_mode,
abs_ok,
st.access.grants,
);
cap.engage(); // capture engages when the stream starts (ui_stream parity)
apply_capture(&mut window, &mouse, true, cap.desktop(), inhibit_shortcuts);
// Capture engages when the stream starts (ui_stream parity) — unless
// this session's access covers neither pointer nor keyboard (view-only
// / controller-only), where `engage` refuses and the pointer stays
// free over the stream (§7 "not capture what can't land").
if cap.engage() {
apply_capture(
&mut window,
&mouse,
true,
cap.desktop(),
inhibit_shortcuts,
cap.grants(),
);
}
st.capture = Some(cap);
st.cursor_chan = Some(crate::cursor::CursorChannel::new(&c));
// Read the mgmt port BEFORE `c` is moved into `st` — the Welcome's answer to
@@ -1430,6 +1480,44 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
}
st.last_stats = Some(s);
}
// The session's access — the Welcome's advert first, then every
// mid-session AccessUpdate (design §7). Re-gate the live capture to the
// new mask: a removed POINTER/KEYBOARD bit releases the pointer lock /
// keyboard grab it backed, and with neither class left the capture drops
// entirely (auto-release, so a later re-grant re-engages on click).
// Courtesy chrome — the host enforces the mask regardless.
SessionEvent::Access { access, notice } => {
st.access = access;
if let Some(n) = notice {
tracing::info!(notice = %n, "session access changed");
st.access_notice = Some((n, Instant::now()));
}
if let Some(cap) = st.capture.as_mut() {
cap.set_grants(access.grants);
if cap.captured() {
if cap.can_capture() {
apply_capture(
&mut window,
&mouse,
true,
cap.desktop(),
inhibit_shortcuts,
cap.grants(),
);
} else {
cap.release(false);
apply_capture(
&mut window,
&mouse,
false,
false,
inhibit_shortcuts,
0,
);
}
}
}
}
SessionEvent::Failed {
msg,
trust_rejected,
@@ -1446,7 +1534,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
if let Some(st) = stream.take() {
st.shutdown();
}
apply_capture(&mut window, &mouse, false, false, inhibit_shortcuts);
apply_capture(&mut window, &mouse, false, false, inhibit_shortcuts, 0);
if let Some(o) = overlay.as_mut() {
// A user-canceled dial ends silently — no error scene.
if canceled {
@@ -1463,7 +1551,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
if let Some(cap) = &mut st.capture {
cap.release(true);
}
apply_capture(&mut window, &mouse, false, false, inhibit_shortcuts);
apply_capture(&mut window, &mouse, false, false, inhibit_shortcuts, 0);
match &mode {
ModeCtl::Single(_) => break 'main Some(Outcome::Ended(reason)),
ModeCtl::Browse(_) => {
@@ -1512,7 +1600,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
if let Some(cap) = &mut st.capture {
cap.release(true);
}
apply_capture(&mut window, &mouse, false, false, inhibit_shortcuts);
apply_capture(&mut window, &mouse, false, false, inhibit_shortcuts, 0);
// Widen the exclusion rather than replace it: a second fallback in the
// same run must not re-offer what the first one already ruled out.
let mut params = st.params.clone();
@@ -1608,17 +1696,32 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
st.resize_overlay.tick(Instant::now());
}
// Access toast expiry — before the overlay borrows the stream immutably.
if let Some(st) = stream.as_mut() {
if st
.access_notice
.as_ref()
.is_some_and(|(_, at)| at.elapsed() >= Duration::from_secs(ACCESS_NOTICE_S))
{
st.access_notice = None;
}
}
// --- Console UI: damage-driven overlay re-render for this iteration --------------
if let Some(o) = overlay.as_mut() {
let (pw, ph) = window.size_in_pixels();
let (stats, hint) = match &stream {
Some(st) if st.connector.is_some() => {
// No "click to capture" over a session with nothing to capture FOR
// (view-only / controller-only — the chip says what this session is).
let hint = match &st.capture {
Some(cap) if !cap.captured() => Some(if gamepad.active().is_some() {
HINT_WITH_PAD
} else {
HINT_KEYBOARD
}),
Some(cap) if !cap.captured() && cap.can_capture() => {
Some(if gamepad.active().is_some() {
HINT_WITH_PAD
} else {
HINT_KEYBOARD
})
}
_ => None,
};
(
@@ -1629,6 +1732,20 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
}
_ => (None, None),
};
// The access chip (design §7 "say what this session is"): a small standing
// pill — "Controller only · ends in 1 h 58 m" — in the same overlay family as
// the stats HUD, at every stats tier including Off. `None` (and so exactly
// today's look) for a full-control permanent session, which is every session
// against an old host. The countdown re-derives per pass; the overlay's
// damage gate turns its once-a-minute text change into a redraw.
let access_chip = match &stream {
Some(st) if st.connector.is_some() => st.access.chip_text(Instant::now()),
_ => None,
};
let access_notice = stream
.as_ref()
.filter(|st| st.connector.is_some())
.and_then(|st| st.access_notice.as_ref().map(|(n, _)| n.as_str()));
let pad = gamepad.active();
let pads = gamepad.pads();
let resizing = stream
@@ -1647,6 +1764,8 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
scale: overlay_scale(window.display_scale(), osd_scale_pref),
stats,
hint,
access: access_chip.as_deref(),
notice: access_notice,
mic_muted,
resizing,
pad: pad.as_ref().map(|p| p.name.as_str()),
@@ -2464,16 +2583,29 @@ impl ResizeIndicator {
/// tracking our absolute sends, is the one you see (until the M2 cursor channel flips
/// who draws it) — and system chords stay local (a remote desktop is something you
/// Alt-Tab away from, not into). `desktop` only matters while `on`.
///
/// `grants` is the session's effective access mask (per-client access §7 "not capture
/// what can't land"): no pointer lock without the POINTER bit, no keyboard grab without
/// KEYBOARD — a locked pointer whose motion the host drops, or grabbed system chords
/// over dead keys, is the "my input does nothing and nobody says why" failure mode this
/// exists to prevent. On-sites pass `Capture::grants()`; off-sites pass `0` (with `on`
/// false every term is off regardless).
fn apply_capture(
window: &mut sdl3::video::Window,
mouse: &sdl3::mouse::MouseUtil,
on: bool,
desktop: bool,
inhibit: bool,
grants: u32,
) {
mouse.set_relative_mouse_mode(window, on && !desktop);
mouse.show_cursor(!on);
let grab = on && !desktop && inhibit;
use punktfunk_core::quic::{GRANT_KEYBOARD, GRANT_POINTER};
let pointer = grants & GRANT_POINTER != 0;
mouse.set_relative_mouse_mode(window, on && !desktop && pointer);
// The local cursor hides only while the HOST's cursor stands in for it — without the
// POINTER grant no absolute/relative send lands, so hiding it would leave a
// keyboard-only session with no cursor at all.
mouse.show_cursor(!(on && pointer));
let grab = on && !desktop && inhibit && grants & GRANT_KEYBOARD != 0;
if !window.set_keyboard_grab(grab) && grab {
// The one refusal SDL reports is a missing mechanism — a Wayland compositor with no
// shortcuts-inhibit global. Said once per process: the answer never changes
@@ -2763,6 +2895,10 @@ struct PresentedWindow {
forced: u32,
}
/// How long an access toast holds the pill slot (an "Access ends in…" warning must be
/// seen, not studied — the chip keeps the standing truth).
const ACCESS_NOTICE_S: u64 = 6;
/// The capture hints (`ui_stream` parity — the words the user reads while released).
const HINT_KEYBOARD: &str = "Click the stream to capture input · Ctrl+Alt+Shift+Q releases · \
Ctrl+Alt+Shift+M mouse mode · Ctrl+Alt+Shift+D disconnects · Ctrl+Alt+Shift+S stats";
+15
View File
@@ -85,6 +85,7 @@ include = ["PunktfunkEndReason"]
# which is the very property whose absence makes a bare `MAX_PADS` dangerous — they are
# namespaced, just not by us.
"ABI_VERSION" = "PUNKTFUNK_ABI_VERSION"
"ACCESS_EXPIRED_CLOSE_CODE" = "PUNKTFUNK_ACCESS_EXPIRED_CLOSE_CODE"
"APP_EXITED_CLOSE_CODE" = "PUNKTFUNK_APP_EXITED_CLOSE_CODE"
"BTN_MISC1" = "PUNKTFUNK_BTN_MISC1"
"BTN_PADDLE1" = "PUNKTFUNK_BTN_PADDLE1"
@@ -113,6 +114,7 @@ include = ["PunktfunkEndReason"]
"CLIP_POLICY_TEXT" = "PUNKTFUNK_CLIP_POLICY_TEXT"
"CLIP_REASON_BACKEND_UNAVAILABLE" = "PUNKTFUNK_CLIP_REASON_BACKEND_UNAVAILABLE"
"CLIP_REASON_NO_FILES" = "PUNKTFUNK_CLIP_REASON_NO_FILES"
"CLIP_REASON_NOT_PERMITTED" = "PUNKTFUNK_CLIP_REASON_NOT_PERMITTED"
"CLIP_REASON_OK" = "PUNKTFUNK_CLIP_REASON_OK"
"CLIP_REASON_POLICY_DISABLED" = "PUNKTFUNK_CLIP_REASON_POLICY_DISABLED"
"CLIP_REASON_TAKEN_OVER" = "PUNKTFUNK_CLIP_REASON_TAKEN_OVER"
@@ -137,6 +139,17 @@ include = ["PunktfunkEndReason"]
"FLAG_PIC" = "PUNKTFUNK_FLAG_PIC"
"FLAG_PROBE" = "PUNKTFUNK_FLAG_PROBE"
"FLAG_SOF" = "PUNKTFUNK_FLAG_SOF"
"GRANT_ALL" = "PUNKTFUNK_GRANT_ALL"
"GRANT_CLIPBOARD" = "PUNKTFUNK_GRANT_CLIPBOARD"
"GRANT_GAMEPAD" = "PUNKTFUNK_GRANT_GAMEPAD"
"GRANT_KEYBOARD" = "PUNKTFUNK_GRANT_KEYBOARD"
"GRANT_LAUNCH" = "PUNKTFUNK_GRANT_LAUNCH"
"GRANT_MIC" = "PUNKTFUNK_GRANT_MIC"
"GRANT_POINTER" = "PUNKTFUNK_GRANT_POINTER"
"GRANT_PRESET_CONTROLLER_ONLY" = "PUNKTFUNK_GRANT_PRESET_CONTROLLER_ONLY"
"GRANT_PRESET_FULL" = "PUNKTFUNK_GRANT_PRESET_FULL"
"GRANT_PRESET_VIEW_ONLY" = "PUNKTFUNK_GRANT_PRESET_VIEW_ONLY"
"GRANT_RESERVED" = "PUNKTFUNK_GRANT_RESERVED"
"HDR_META_BODY_LEN" = "PUNKTFUNK_HDR_META_BODY_LEN"
"HDR_META_MAGIC" = "PUNKTFUNK_HDR_META_MAGIC"
"HELLO_LAUNCH_MAX" = "PUNKTFUNK_HELLO_LAUNCH_MAX"
@@ -155,6 +168,7 @@ include = ["PunktfunkEndReason"]
"INBOUND_REQ_FLAG" = "PUNKTFUNK_INBOUND_REQ_FLAG"
"INPUT_MAGIC" = "PUNKTFUNK_INPUT_MAGIC"
"INPUT_WIRE_LEN" = "PUNKTFUNK_INPUT_WIRE_LEN"
"LAUNCH_NOT_PERMITTED_CLOSE_CODE" = "PUNKTFUNK_LAUNCH_NOT_PERMITTED_CLOSE_CODE"
"LEGACY_STALE_MS" = "PUNKTFUNK_LEGACY_STALE_MS"
"MAX_DATAGRAM_BYTES" = "PUNKTFUNK_MAX_DATAGRAM_BYTES"
"MAX_PADS" = "PUNKTFUNK_MAX_PADS"
@@ -163,6 +177,7 @@ include = ["PunktfunkEndReason"]
"MIN_SCALE" = "PUNKTFUNK_MIN_SCALE"
"MIN_SHARD_PAYLOAD" = "PUNKTFUNK_MIN_SHARD_PAYLOAD"
"MIN_STREAM_BLOCK_SHARDS" = "PUNKTFUNK_MIN_STREAM_BLOCK_SHARDS"
"MSG_ACCESS_UPDATE" = "PUNKTFUNK_MSG_ACCESS_UPDATE"
"MSG_BITRATE_CHANGED" = "PUNKTFUNK_MSG_BITRATE_CHANGED"
"MSG_CLIP_CONTROL" = "PUNKTFUNK_MSG_CLIP_CONTROL"
"MSG_CLIP_FETCH" = "PUNKTFUNK_MSG_CLIP_FETCH"
+127
View File
@@ -3999,6 +3999,133 @@ pub unsafe extern "C" fn punktfunk_connection_host_caps(
})
}
/// The session's LIVE effective access grants — a `PUNKTFUNK_GRANT_*` bitmask
/// (per-client access, `design/per-client-access.md` §7): seeded from the `Welcome` advert
/// and moved by every mid-session `AccessUpdate` the host sends (latest wins), so this is
/// current state, NOT a connect-time snapshot. An old host advertises nothing and this reads
/// `PUNKTFUNK_GRANT_ALL` — full control, the pre-grants behavior, so an embedder keying UI
/// off it changes nothing there.
///
/// Courtesy truth only: the HOST enforces the mask whatever a client renders. Use it to not
/// capture what can't land (no pointer lock / keyboard grab without the bits) and to label
/// the session ("Controller only"). Cheap (one relaxed atomic load) — poll it alongside a
/// stats tick rather than caching it for the session. Safe any time after connect.
///
/// # Safety
/// `c` is a valid connection handle; `grants` is writable (NULL is skipped).
#[cfg(feature = "quic")]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_grants(
c: *const PunktfunkConnection,
grants: *mut u32,
) -> PunktfunkStatus {
guard(|| {
// SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
// has not yet freed, or null, which `as_ref` reports as `None` and the `match` handles.
let c = match unsafe { c.as_ref() } {
Some(c) => c,
None => return PunktfunkStatus::NullPointer,
};
// SAFETY: per the ABI contract - the out-param is OPTIONAL, so it is null-checked before
// it is written; a non-null one is a caller-owned writable slot.
unsafe {
if !grants.is_null() {
*grants = c.inner.access_grants();
}
}
PunktfunkStatus::Ok
})
}
/// Seconds until this session's access expires, LIVE — counted down from the `Welcome`'s
/// `expires_in_secs` and re-anchored by every mid-session `AccessUpdate`, so successive reads
/// shrink on their own (render a countdown by polling this, ~1 Hz). `0` = permanent: today's
/// default, and everything an old host's Welcome decodes to — show nothing then. The deadline
/// is anchored to the CLIENT's clock at receipt (the wire carries relative seconds), so
/// host/client skew never moves the countdown.
///
/// While a deadline exists the value never reads `0`: in the sliver between the deadline
/// passing and the host's typed expiry close (`PUNKTFUNK_STATUS_REJECTED_ACCESS_EXPIRED`
/// via [`punktfunk_connection_end_reject`]) it clamps to `1`, so `0` stays unambiguous.
/// The T5 m / T1 m warnings are the embedder's to derive from the countdown crossing
/// those marks. Safe any time after connect.
///
/// # Safety
/// `c` is a valid connection handle; `secs` is writable (NULL is skipped).
#[cfg(feature = "quic")]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_access_expires_in(
c: *const PunktfunkConnection,
secs: *mut u32,
) -> PunktfunkStatus {
guard(|| {
// SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
// has not yet freed, or null, which `as_ref` reports as `None` and the `match` handles.
let c = match unsafe { c.as_ref() } {
Some(c) => c,
None => return PunktfunkStatus::NullPointer,
};
let remaining = match c.inner.access_deadline_unix() {
None => 0,
Some(deadline) => {
let now = crate::quic::wall_clock_ns() / 1_000_000_000;
// Clamp to ≥ 1 while a deadline is set: 0 means "permanent", never "expired".
u32::try_from(deadline.saturating_sub(now))
.unwrap_or(u32::MAX)
.max(1)
}
};
// SAFETY: per the ABI contract - the out-param is OPTIONAL, so it is null-checked before
// it is written; a non-null one is a caller-owned writable slot.
unsafe {
if !secs.is_null() {
*secs = remaining;
}
}
PunktfunkStatus::Ok
})
}
/// The typed rejection a MID-SESSION close carried, as its `PUNKTFUNK_STATUS_REJECTED_*`
/// value (`0` = none — every ordinary end). Exists because
/// [`punktfunk_connection_end_reason`] can only file an unrecognized deliberate close under
/// `PUNKTFUNK_END_REASON_HOST_ERROR`, and "the host ended the session with an error" is the
/// wrong sentence for an access expiry (`PUNKTFUNK_STATUS_REJECTED_ACCESS_EXPIRED`) — the
/// case this was added for; any future typed mid-session close surfaces the same way. Ask
/// AFTER the session ended, before freeing the handle, exactly like `end_reason` (the two
/// latch together); connect-time rejections never land here — they come back from the
/// connect call itself.
///
/// # Safety
/// `c` is a valid connection handle; `status` is writable (NULL is skipped).
#[cfg(feature = "quic")]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_end_reject(
c: *const PunktfunkConnection,
status: *mut i32,
) -> PunktfunkStatus {
guard(|| {
// SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
// has not yet freed, or null, which `as_ref` reports as `None` and the `match` handles.
let c = match unsafe { c.as_ref() } {
Some(c) => c,
None => return PunktfunkStatus::NullPointer,
};
let value = match c.inner.end_reject() {
Some(reason) => crate::error::PunktfunkError::Rejected(reason).status() as i32,
None => 0,
};
// SAFETY: per the ABI contract - the out-param is OPTIONAL, so it is null-checked before
// it is written; a non-null one is a caller-owned writable slot.
unsafe {
if !status.is_null() {
*status = value;
}
}
PunktfunkStatus::Ok
})
}
/// Enable or disable the shared clipboard for this session (`design` §3.1). Opt-in: nothing is
/// announced or served until this is called with `enabled = true`. `flags` carries
/// `quic::CLIP_FLAG_FILES` (allow file transfer). The host replies with a `State` event.
@@ -77,4 +77,13 @@ pub(crate) struct Negotiated {
/// advertise one. Surfaced to the embedder via [`crate::NativeClient::mgmt_port`] so a client
/// can reach the game library without ever having seen an mDNS advert.
pub(crate) mgmt_port: u16,
/// The session's effective access grants ([`crate::quic::Welcome::grants`]) — the
/// [`crate::quic::GRANT_GAMEPAD`] family. An old host's Welcome decodes to
/// [`crate::quic::GRANT_ALL`], the pre-grants behavior. This is only the STARTING truth:
/// a mid-session [`crate::quic::AccessUpdate`] moves the live mask the control task keeps
/// (see [`crate::NativeClient::access_grants`]).
pub(crate) grants: u32,
/// Seconds until this device's access expires ([`crate::quic::Welcome::expires_in_secs`]);
/// `0` = permanent. Like `grants`, the connect-time seed for the live deadline.
pub(crate) expires_in_secs: u32,
}
+99
View File
@@ -116,6 +116,23 @@ pub struct MicUplinkStats {
/// the control task is wedged, which callers treat as a closed session.
const CTRL_QUEUE: usize = 32;
/// Inbound access-update queue depth. The traffic is a console edit or an expiry warning —
/// a handful per session at most; the live grants/deadline slots hold the truth, so a full
/// queue drops news the embedder would re-derive from them anyway.
const ACCESS_QUEUE: usize = 8;
/// The absolute access deadline (client wall clock, unix seconds) a relative
/// `expires_in_secs` / `remaining_secs` resolves to at `now_ns`; `0` stays `0` (permanent).
/// Anchored to the CLIENT's clock on purpose: the wire value is relative, so host/client
/// skew never moves the countdown a chip renders from this.
pub(crate) fn access_deadline_from(now_ns: u64, remaining_secs: u32) -> u64 {
if remaining_secs == 0 {
0
} else {
now_ns / 1_000_000_000 + u64::from(remaining_secs)
}
}
/// Why a session ended — [`NativeClient::end_reason`], and `punktfunk_connection_end_reason` on the
/// C surface.
///
@@ -234,6 +251,11 @@ pub struct NativeClient {
cursor_shape: Mutex<Receiver<crate::quic::CursorShape>>,
/// Inbound per-frame cursor state — `0xD0` datagrams (same negotiation gate as shapes).
cursor_state: Mutex<Receiver<crate::quic::CursorState>>,
/// Inbound mid-session access updates (control-stream [`crate::quic::AccessUpdate`]) —
/// the wake-up plane behind [`NativeClient::next_access_update`]. The live TRUTH is
/// `access_grants` / `access_deadline_unix` below, already updated when an event lands
/// here, so a dropped event (full queue) loses news but never accuracy.
access: Mutex<Receiver<crate::quic::AccessUpdate>>,
input_tx: tokio::sync::mpsc::UnboundedSender<InputEvent>,
/// Outbound mic frames `(seq, pts_ns, opus)` → encoded as 0xCB datagrams by the worker.
/// Bounded ([`MIC_QUEUE`]): the pump sheds stale frames oldest-first and a full queue drops
@@ -271,6 +293,16 @@ pub struct NativeClient {
/// The host's management-API port ([`crate::quic::Welcome::mgmt_port`]), or `0` when the host
/// did not advertise one — see [`NativeClient::mgmt_port`].
pub mgmt_port: u16,
/// The session's LIVE effective access grants (the [`crate::quic::GRANT_GAMEPAD`] family):
/// seeded from the Welcome advert, moved by every mid-session
/// [`crate::quic::AccessUpdate`] (latest wins) — see [`NativeClient::access_grants`].
access_grants: Arc<AtomicU32>,
/// The live access deadline (client wall clock, unix seconds; `0` = permanent) — see
/// [`NativeClient::access_deadline_unix`].
access_deadline_unix: Arc<AtomicU64>,
/// The typed [`crate::reject::RejectReason`] close code a mid-session end carried
/// (`0` = none) — see [`NativeClient::end_reject`].
end_reject_code: Arc<AtomicU32>,
/// Speed-test accumulator, shared with the data-plane pump + control task.
probe: Arc<Mutex<ProbeState>>,
shutdown: Arc<AtomicBool>,
@@ -612,6 +644,8 @@ impl NativeClient {
std::sync::mpsc::sync_channel::<crate::quic::CursorShape>(CURSOR_SHAPE_QUEUE);
let (cursor_state_tx, cursor_state_rx) =
std::sync::mpsc::sync_channel::<crate::quic::CursorState>(CURSOR_STATE_QUEUE);
let (access_tx, access_rx) =
std::sync::mpsc::sync_channel::<crate::quic::AccessUpdate>(ACCESS_QUEUE);
let (ready_tx, ready_rx) = std::sync::mpsc::channel::<Result<Negotiated>>();
let shutdown = Arc::new(AtomicBool::new(false));
let end_reason = Arc::new(AtomicU8::new(PunktfunkEndReason::None as u8));
@@ -629,6 +663,12 @@ impl NativeClient {
let decode_lat = Arc::new(Mutex::new(DecodeLatAcc::default()));
// Seeded by the pump from the Welcome (before ready_tx), then follows every ack.
let live_bitrate = Arc::new(AtomicU32::new(0));
// Access truth (same seeding discipline as `live_bitrate`): the pump writes the
// Welcome advert into both before ready_tx, the control task follows every
// AccessUpdate. GRANT_ALL/permanent here is only the pre-handshake placeholder.
let access_grants = Arc::new(AtomicU32::new(crate::quic::GRANT_ALL));
let access_deadline_unix = Arc::new(AtomicU64::new(0));
let end_reject_code = Arc::new(AtomicU32::new(0));
let host = host.to_string();
let frame_chan_w = frame_chan.clone();
@@ -645,6 +685,9 @@ impl NativeClient {
let decode_lat_w = decode_lat.clone();
let live_bitrate_w = live_bitrate.clone();
let pad_audio_caps_w = pad_audio_caps.clone();
let access_grants_w = access_grants.clone();
let access_deadline_w = access_deadline_unix.clone();
let end_reject_w = end_reject_code.clone();
let ctrl_tx_pump = ctrl_tx.clone(); // the data-plane pump sends adaptive-FEC LossReports
let worker = std::thread::Builder::new()
.name("punktfunk-client".into())
@@ -720,6 +763,10 @@ impl NativeClient {
clock_offset: clock_offset_w,
decode_lat: decode_lat_w,
live_bitrate: live_bitrate_w,
access_grants: access_grants_w,
access_deadline_unix: access_deadline_w,
access_tx,
end_reject_code: end_reject_w,
}));
})
.map_err(PunktfunkError::Io)?;
@@ -751,6 +798,10 @@ impl NativeClient {
host_timing: Mutex::new(host_timing_rx),
cursor_shape: Mutex::new(cursor_shape_rx),
cursor_state: Mutex::new(cursor_state_rx),
access: Mutex::new(access_rx),
access_grants,
access_deadline_unix,
end_reject_code,
input_tx,
mic_tx,
mic_stats,
@@ -1018,6 +1069,17 @@ impl NativeClient {
self.end_reason() == PunktfunkEndReason::GameExited
}
/// The typed [`crate::reject::RejectReason`] a MID-SESSION close carried, if any — an
/// access expiry (`0x69`) being the case this exists for: [`end_reason`](Self::end_reason)
/// can only file an unrecognized deliberate close under `HostError`, and "the host ended
/// the session with an error" is the wrong sentence for "your access expired". Latches
/// with `end_reason` (same ordering discipline); `None` for every ordinary end. The
/// CONNECT-time rejections never land here — they surface as
/// [`PunktfunkError::Rejected`] from [`connect`](Self::connect) itself.
pub fn end_reject(&self) -> Option<crate::reject::RejectReason> {
crate::reject::RejectReason::from_close_code(self.end_reject_code.load(Ordering::SeqCst))
}
/// Register the calling thread as latency-critical so a later
/// [`hot_thread_ids`](Self::hot_thread_ids) includes it. An embedder calls this from its own
/// plane threads (e.g. the Android client's decode + audio threads) to fold them into the same
@@ -1429,6 +1491,43 @@ impl NativeClient {
self.mgmt_port
}
/// The session's LIVE effective access grants — the [`crate::quic::GRANT_GAMEPAD`] family,
/// seeded from the `Welcome` advert and moved by every mid-session
/// [`crate::quic::AccessUpdate`] (latest wins). An old host advertises nothing and this
/// reads [`crate::quic::GRANT_ALL`] — full control, the pre-grants behavior, so an
/// embedder keying UI off it changes nothing there.
///
/// Courtesy truth only: the HOST enforces the mask whatever a client renders. Read it per
/// use (one relaxed load), never cache across an [`next_access_update`](Self::next_access_update)
/// wake.
pub fn access_grants(&self) -> u32 {
self.access_grants.load(Ordering::Relaxed)
}
/// When this session's access expires, as CLIENT wall clock unix seconds — `None` =
/// permanent (today's default, and everything an old host's Welcome decodes to). Anchored
/// client-side from the wire's relative seconds, so host/client clock skew never moves a
/// countdown rendered from it; re-anchored by every `AccessUpdate`.
pub fn access_deadline_unix(&self) -> Option<u64> {
match self.access_deadline_unix.load(Ordering::Relaxed) {
0 => None,
d => Some(d),
}
}
/// Pull the next mid-session [`crate::quic::AccessUpdate`] (a console edit, or the host's
/// T5 m / T1 m expiry warnings). One consumer, like every plane. The live truth is
/// already in [`access_grants`](Self::access_grants) /
/// [`access_deadline_unix`](Self::access_deadline_unix) when this wakes — the event is the
/// UI's cue to re-gate capture and toast, not the data's source of record.
pub fn next_access_update(&self, timeout: Duration) -> Result<crate::quic::AccessUpdate> {
match self.access.lock().unwrap().recv_timeout(timeout) {
Ok(u) => Ok(u),
Err(RecvTimeoutError::Timeout) => Err(PunktfunkError::NoFrame),
Err(RecvTimeoutError::Disconnected) => Err(PunktfunkError::Closed),
}
}
/// Enable or disable the shared clipboard for this session (`design/clipboard-and-file-transfer.md`
/// §3.1). Opt-in: nothing is announced or served until this crosses with `enabled = true`.
/// `flags` carries [`crate::quic::CLIP_FLAG_FILES`]. Non-blocking; the host replies with a
+22
View File
@@ -76,6 +76,10 @@ pub(super) async fn run_pump(args: WorkerArgs) {
clock_offset,
decode_lat,
live_bitrate,
access_grants,
access_deadline_unix,
access_tx,
end_reject_code,
..
} = args;
// Copies the pump needs after `negotiated` is handed over to `connect`.
@@ -88,6 +92,15 @@ pub(super) async fn run_pump(args: WorkerArgs) {
// Same discipline for the live encoder target: the Welcome resolve is the starting truth
// (0 against an old host that reports none); every BitrateChanged ack moves it from there.
live_bitrate.store(negotiated.bitrate_kbps, Ordering::Relaxed);
// …and for the live access truth: the Welcome advert seeds both slots before the embedder
// can observe the client, so `access_grants()` never reads a pre-handshake GRANT_ALL on a
// limited session. The deadline is anchored to the CLIENT's wall clock here — the wire
// carries a relative `expires_in_secs`, so host/client skew never moves the countdown.
access_grants.store(negotiated.grants, Ordering::Relaxed);
access_deadline_unix.store(
access_deadline_from(wall_clock_ns(), negotiated.expires_in_secs),
Ordering::Relaxed,
);
// Bumped by the control task each time a re-sync batch is APPLIED; the pump watches it to
// reset its staleness counters and re-arm the clock-based jump-to-live detector.
let clock_gen = Arc::new(AtomicU32::new(0));
@@ -166,6 +179,9 @@ pub(super) async fn run_pump(args: WorkerArgs) {
clip_event_tx: clip_event_tx.clone(),
cursor_shape_tx,
mode_gen: mode_gen.clone(),
access_grants,
access_deadline_unix,
access_tx,
}
.run(),
);
@@ -205,6 +221,12 @@ pub(super) async fn run_pump(args: WorkerArgs) {
// Latch the reason BEFORE `shutdown`: the two are observed by different threads, and a
// client that reacts to the shutdown flag must never find the reason still unset.
let reason = crate::client::PunktfunkEndReason::from(&why);
// A typed rejection code on a MID-SESSION close (access expiry, and whatever the
// vocabulary grows next) rides beside the coarse reason, same ordering discipline,
// so the embedder's end path can say the real sentence instead of "host error".
if let Some(r) = reject_from_close(&conn) {
end_reject_code.store(r.close_code(), Ordering::SeqCst);
}
end_reason.store(reason as u8, Ordering::SeqCst);
shutdown.store(true, Ordering::SeqCst);
});
@@ -35,6 +35,17 @@ pub(super) struct ControlTask {
/// resets the bitrate controller's mode-scoped learned state — the encoder ceiling / compute
/// knee it was taught belong to the OLD mode.
pub(super) mode_gen: Arc<AtomicU32>,
/// The session's LIVE access grants ([`NativeClient::access_grants`]): every inbound
/// [`AccessUpdate`] overwrites it (latest wins) BEFORE the event is forwarded, so a reader
/// woken by the event never sees the pre-update mask.
pub(super) access_grants: Arc<AtomicU32>,
/// The live access deadline (client wall clock, unix seconds; `0` = permanent) — re-anchored
/// from every `AccessUpdate`'s relative `remaining_secs`.
pub(super) access_deadline_unix: Arc<std::sync::atomic::AtomicU64>,
/// Access updates → the embedder's event plane ([`NativeClient::next_access_update`]).
/// try_send like the clipboard/cursor planes: a lagging embedder drops the oldest news,
/// and the two live slots above already hold the latest truth it would re-derive.
pub(super) access_tx: std::sync::mpsc::SyncSender<crate::quic::AccessUpdate>,
}
impl ControlTask {
@@ -54,6 +65,9 @@ impl ControlTask {
clip_event_tx,
cursor_shape_tx,
mode_gen,
access_grants,
access_deadline_unix,
access_tx,
} = self;
// Mid-stream clock re-sync (see [`ClockResync`]): a batch runs every
// CLOCK_RESYNC_INTERVAL and whenever the pump asks (CtrlRequest::ClockResync after
@@ -275,6 +289,26 @@ impl ControlTask {
"out-of-bounds shard-payload change — ignoring (no ack)"
);
}
} else if let Ok(upd) = crate::quic::AccessUpdate::decode(&msg) {
// Mid-session access change (a console edit) or an expiry warning
// (T5 m / T1 m). Latest-wins per design: fold the update into the
// live slots FIRST, then wake the embedder — the host enforces
// regardless, this is the courtesy that lets the client release a
// grab it no longer backs and warn before the expiry close.
tracing::info!(
grants = upd.grants,
remaining_secs = upd.remaining_secs,
"host updated this session's access"
);
access_grants.store(upd.grants, Ordering::Relaxed);
access_deadline_unix.store(
crate::client::access_deadline_from(
wall_clock_ns(),
upd.remaining_secs,
),
Ordering::Relaxed,
);
let _ = access_tx.try_send(upd);
} else if let Ok(shape) = crate::quic::CursorShape::decode(&msg) {
// Pointer bitmap changed (cursor channel, only when negotiated). try_send:
// an overflowing ring drops the newest shape — the next change resends.
@@ -256,6 +256,8 @@ pub(super) async fn connect_and_handshake(args: &WorkerArgs) -> Result<Handshake
shard_payload: welcome.shard_payload,
host_caps: welcome.host_caps,
mgmt_port: welcome.mgmt_port,
grants: welcome.grants,
expires_in_secs: welcome.expires_in_secs,
},
welcome.host_caps,
))
@@ -90,6 +90,23 @@ pub(crate) struct WorkerArgs {
/// The live encoder-target mirror (see [`NativeClient::live_bitrate_kbps`]): the worker seeds
/// it from the Welcome; the control task updates it on every `BitrateChanged` ack.
pub(crate) live_bitrate: Arc<AtomicU32>,
/// The session's LIVE access grants (see [`NativeClient::access_grants`]): seeded from the
/// Welcome advert; every [`crate::quic::AccessUpdate`] moves it (latest wins, per design).
pub(crate) access_grants: Arc<AtomicU32>,
/// The live access deadline as client wall clock, unix seconds; `0` = permanent. Seeded
/// from the Welcome's `expires_in_secs`, re-anchored by every `AccessUpdate` — see
/// [`NativeClient::access_deadline_unix`].
pub(crate) access_deadline_unix: Arc<AtomicU64>,
/// Inbound access updates → the embedder's event plane
/// ([`NativeClient::next_access_update`]), pushed by the control task AFTER it folded the
/// update into the two live slots above.
pub(crate) access_tx: SyncSender<crate::quic::AccessUpdate>,
/// The typed close code a MID-SESSION end carried, when it is one of the shared
/// [`crate::reject::RejectReason`] vocabulary; `0` = none. Latched by the worker's
/// close watch beside `end_reason`, so an access-expiry close (0x69) can render its
/// real sentence instead of the generic host-error one — see
/// [`NativeClient::end_reject`].
pub(crate) end_reject_code: Arc<AtomicU32>,
}
/// The worker: QUIC handshake, then the input/datagram/control tasks + the blocking
+4
View File
@@ -62,6 +62,8 @@ pub enum PunktfunkStatus {
RejectedWireVersion = -27,
RejectedBusy = -28,
RejectedSetupFailed = -29,
RejectedAccessExpired = -30,
RejectedLaunchNotPermitted = -31,
Panic = -99,
}
@@ -91,6 +93,8 @@ impl PunktfunkError {
R::WireVersionMismatch => PunktfunkStatus::RejectedWireVersion,
R::Busy => PunktfunkStatus::RejectedBusy,
R::SetupFailed => PunktfunkStatus::RejectedSetupFailed,
R::AccessExpired => PunktfunkStatus::RejectedAccessExpired,
R::LaunchNotPermitted => PunktfunkStatus::RejectedLaunchNotPermitted,
}
}
}
+14 -1
View File
@@ -195,7 +195,20 @@ pub use stats::Stats;
/// widened one: `ex9` keeps its parameter list AND its behaviour — it passes a null name, which
/// selects that same default. Additive and client-local: the name rides the `Hello::name` field
/// hosts have read since the pending list existed, so [`WIRE_VERSION`] is unchanged.
pub const ABI_VERSION: u32 = 21;
/// v22: the per-client access surface (`design/per-client-access.md` §7) —
/// `punktfunk_connection_grants` and `punktfunk_connection_access_expires_in` read the session's
/// LIVE access state (the `PUNKTFUNK_GRANT_*` mask and the countdown to its expiry — Welcome
/// snapshot first, then latest-wins over every mid-session `AccessUpdate` the control task
/// folds in), and `punktfunk_connection_end_reject` reports the typed rejection a mid-session
/// close carried (`PUNKTFUNK_STATUS_REJECTED_*`; `0` = none), because `end_reason` can only
/// file an access-expiry close under HOST_ERROR and that is the wrong sentence for "your
/// access expired". NEW symbols, not widened ones — the same rule v18 states: every existing
/// function keeps its signature and behaviour, and an embedder that never adopts any of the
/// three is unchanged (it simply lacks the courtesy UX; the HOST enforces the grants either
/// way). Additive and client-local: the mask, the expiry and the `AccessUpdate` message all
/// shipped with the Welcome's trailing-field append (old peers skip them in both directions),
/// so [`WIRE_VERSION`] is unchanged.
pub const ABI_VERSION: u32 = 22;
/// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check.
/// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface**
+53 -16
View File
@@ -203,6 +203,28 @@ const IN_FLIGHT_BUF_FACTOR: usize = 4;
/// more; it also needs ~6× fewer buffers per block, so the pool rarely fills there.
const RECOVERY_POOL_MAX: usize = 512;
/// Byte cost a [`BlockState`] commits to the in-flight budget. Both vectors are sized from
/// attacker-declared header fields (`data_shards`, `recovery_shards`), so a slice-streamed frame
/// can mint thousands of distinct-index blocks while its `FrameBuf::buf` stays pinned near zero —
/// they must be metered exactly like the buffer, or the firewall meters only half the allocation
/// (security-review 2026-08-15 finding 11).
fn block_state_bytes(data_shards: usize, recovery_shards: usize) -> usize {
std::mem::size_of::<BlockState>()
+ data_shards // have_data: Vec<bool>
+ recovery_shards * std::mem::size_of::<Option<Vec<u8>>>() // recovery slot table
}
/// Everything a frame has committed to the in-flight budget: its zeroed buffer plus every block's
/// state. Computed at each release site BEFORE any `buf` truncation, so it nets exactly against the
/// increments made at buffer allocation and block insertion.
fn frame_cost(f: &FrameBuf) -> usize {
f.buf.len()
+ f.blocks
.values()
.map(|b| block_state_bytes(b.data_shards, b.recovery_shards))
.sum::<usize>()
}
/// Buffers incoming shards, recovers lost ones via FEC, and emits whole access units.
/// Client-side only.
pub struct Reassembler {
@@ -232,7 +254,8 @@ pub struct Reassembler {
/// still need their own storage (data shards land straight in the frame buffer). Capped at
/// [`RECOVERY_POOL_MAX`].
recovery_pool: Vec<Vec<u8>>,
/// Sum of in-flight `FrameBuf::buf` bytes across both windows (see [`IN_FLIGHT_BUF_FACTOR`]).
/// Sum of in-flight `FrameBuf::buf` bytes PLUS per-block [`BlockState`] cost across both
/// windows (see [`IN_FLIGHT_BUF_FACTOR`] and [`block_state_bytes`]).
in_flight_bytes: usize,
}
@@ -638,7 +661,7 @@ impl Reassembler {
.frames
.remove(&hdr.frame_index)
.expect("frame entry exists");
*in_flight_bytes -= f.buf.len();
*in_flight_bytes -= frame_cost(&f);
// Remember the index (with its late-shard memory, exactly like an aged-out
// frame) so stragglers can't resurrect it, reclaim the parity buffers, and
// count the loss — the client's recovery request is the right outcome for a
@@ -708,17 +731,31 @@ impl Reassembler {
} else {
block_idx * lim.max_data_shards
};
let block = blocks.entry(hdr.block_index).or_insert_with(|| BlockState {
data_shards,
recovery_shards,
base_shard,
have_data: vec![false; data_shards],
data_received: 0,
recovery: vec![None; recovery_shards],
recovery_received: 0,
done: false,
reconstructed: false,
});
let block = match blocks.entry(hdr.block_index) {
std::collections::hash_map::Entry::Occupied(e) => e.into_mut(),
std::collections::hash_map::Entry::Vacant(e) => {
// A NEW block's state is sized from the header-declared shard counts, so gate it on
// the same in-flight budget as the frame buffer — otherwise a slice-streamed frame
// mints unmetered block state per distinct index (security-review 2026-08-15 #11).
let cost = block_state_bytes(data_shards, recovery_shards);
if *in_flight_bytes + cost > IN_FLIGHT_BUF_FACTOR * lim.max_frame_bytes {
drop(stats);
return Ok(None);
}
*in_flight_bytes += cost;
e.insert(BlockState {
data_shards,
recovery_shards,
base_shard,
have_data: vec![false; data_shards],
data_received: 0,
recovery: vec![None; recovery_shards],
recovery_received: 0,
done: false,
reconstructed: false,
})
}
};
if block.recovery_shards != recovery_shards {
drop(stats);
return Ok(None);
@@ -838,7 +875,7 @@ impl Reassembler {
hdr.frame_index,
reconstructed_shards(&done.blocks, lim.max_data_shards),
);
*in_flight_bytes -= done.buf.len();
*in_flight_bytes -= frame_cost(&done); // buffer + block state, before the truncate below
done.buf.truncate(done.frame_bytes); // trim trailing-shard zero padding
// Slice-progressive consumers already hold the delivered prefix — the completing
// packet hands up only the SUFFIX (with `last`), or the degenerate whole-AU part
@@ -998,8 +1035,8 @@ impl ReassemblyWindow {
// before the frame died still counted `fec_recovered_shards`, so their restored
// shards join the late-shard memory exactly like an emitted frame's.
completed.insert(idx, reconstructed_shards(&f.blocks, max_data_shards));
// Release its buffer budget and reclaim its parity bufs for the pool.
*in_flight_bytes -= f.buf.len();
// Release its buffer budget (+ block state) and reclaim its parity bufs for the pool.
*in_flight_bytes -= frame_cost(f);
// Partial delivery (chunk-aligned AUs only): the buffer is already exactly
// what the consumer needs — received shards at their final offsets, zeros
// where shards are missing (the codec's block walk skips zero windows).
+185
View File
@@ -0,0 +1,185 @@
//! Per-client access grants — the shared vocabulary of `design/per-client-access.md` §3.
//!
//! Trust used to be binary: a paired device got *everything*, forever. Grants split that into
//! six capabilities a device may hold (a guest pad that can't type over the owner's desktop,
//! a TV that can't read the clipboard), carried as a `u32` bitmask that is the SAME value on
//! the wire ([`Welcome`](super::Welcome) advert, [`AccessUpdate`](super::AccessUpdate)) and in
//! the host's trust store — no translation layer to drift. Reserved bits must be zero; the
//! management API rejects masks with unknown bits set, and hosts never emit them.
//!
//! The host is the only enforcer: nothing here appears in any client→host message, so nothing
//! a client sends can widen its grants. Client-side use of the mask (capture gating, the
//! "Controller only" chip) is courtesy UX over the same vocabulary.
//!
//! [`classify`] is the default-deny mechanism for the input plane: an exhaustive, non-wildcard
//! match from [`InputKind`] to [`GrantClass`], shared by the host's datagram filter and the
//! clients' capture gates. A future `InputKind` that nobody classified does not compile —
//! the compiler, not a code review, keeps a new event kind from slipping past the filter.
use crate::input::InputKind;
/// Controller input: gamepad button/axis/snapshot/remove/arrival events, plus everything that
/// rides with a pad — rich DualSense input (0xCC motion/touchpad), pad-audio, rumble return,
/// and virtual-pad creation itself (deny-at-setup: no bit, no uinput node).
pub const GRANT_GAMEPAD: u32 = 1 << 0;
/// Pointing input: mouse rel/abs + buttons, scroll, touch, and the pen plane.
pub const GRANT_POINTER: u32 = 1 << 1;
/// Key input: key down/up and IME-committed text.
pub const GRANT_KEYBOARD: u32 = 1 << 2;
/// Shared clipboard — ANDed into the operator clipboard policy, never overriding it.
pub const GRANT_CLIPBOARD: u32 = 1 << 3;
/// Mic injection: the mic datagram plane + the per-session mic-service attach.
pub const GRANT_MIC: u32 = 1 << 4;
/// Library launch: `Hello.launch` resolution (and any future in-session launch/end verbs).
pub const GRANT_LAUNCH: u32 = 1 << 5;
/// Every defined grant. Also the value an *absent* mask means — a record from before grants
/// existed (or an old host's Welcome that omits the field) is full control, so existing
/// pairings keep today's behavior.
pub const GRANT_ALL: u32 =
GRANT_GAMEPAD | GRANT_POINTER | GRANT_KEYBOARD | GRANT_CLIPBOARD | GRANT_MIC | GRANT_LAUNCH;
/// The reserved-must-be-zero region: a mask with any of these bits set is invalid today and is
/// rejected at the management API (never silently cleared — the caller meant *something* this
/// host doesn't understand, and clearing would grant less than they asked for without saying so).
pub const GRANT_RESERVED: u32 = !GRANT_ALL;
/// Preset: **Full control** — all bits; today's behavior and the default for absent grants.
pub const GRANT_PRESET_FULL: u32 = GRANT_ALL;
/// Preset: **Controller only** — the guest/co-play preset. Deliberately excludes `LAUNCH`
/// (design §11 D2: in co-play the owner drives what runs).
pub const GRANT_PRESET_CONTROLLER_ONLY: u32 = GRANT_GAMEPAD;
/// Preset: **View only** — spectator; sees and hears the stream, sends nothing.
pub const GRANT_PRESET_VIEW_ONLY: u32 = 0;
/// The grant a piece of traffic needs — one variant per [`GRANT_GAMEPAD`]-family bit.
/// [`classify`] maps every input event onto the first three; the last three name the
/// plane/message gates (clipboard coordinator, mic attach, `Hello.launch`) so their
/// drop counters and log lines share this vocabulary.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum GrantClass {
Gamepad,
Pointer,
Keyboard,
Clipboard,
Mic,
Launch,
}
impl GrantClass {
/// The grant bit that authorizes this class — the mask test is
/// `grants & class.bit() != 0`.
pub fn bit(self) -> u32 {
match self {
Self::Gamepad => GRANT_GAMEPAD,
Self::Pointer => GRANT_POINTER,
Self::Keyboard => GRANT_KEYBOARD,
Self::Clipboard => GRANT_CLIPBOARD,
Self::Mic => GRANT_MIC,
Self::Launch => GRANT_LAUNCH,
}
}
}
/// Which grant an input event needs before it may reach the injector.
///
/// Exhaustive and wildcard-free ON PURPOSE — this match IS the default-deny mechanism
/// (design §5.3): adding an [`InputKind`] without deciding its grant class is a compile
/// error here, not a filter hole in the field. Do not "fix" a build break by adding a
/// `_ =>` arm; classify the new kind.
///
/// Only the `0xC8` event vocabulary routes through here. The mic (`0xCA`), rich-input
/// (`0xCC`) and pen planes are gated by their *plane* tag before per-event decode — their
/// classes are [`GrantClass::Mic`], [`GrantClass::Gamepad`] and [`GrantClass::Pointer`]
/// by construction.
pub fn classify(kind: InputKind) -> GrantClass {
match kind {
InputKind::KeyDown | InputKind::KeyUp | InputKind::TextInput => GrantClass::Keyboard,
InputKind::MouseMove
| InputKind::MouseMoveAbs
| InputKind::MouseButtonDown
| InputKind::MouseButtonUp
| InputKind::MouseScroll
| InputKind::TouchDown
| InputKind::TouchMove
| InputKind::TouchUp => GrantClass::Pointer,
InputKind::GamepadButton
| InputKind::GamepadAxis
| InputKind::GamepadState
| InputKind::GamepadRemove
| InputKind::GamepadArrival => GrantClass::Gamepad,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bits_are_disjoint_and_all_covers_exactly_them() {
let bits = [
GRANT_GAMEPAD,
GRANT_POINTER,
GRANT_KEYBOARD,
GRANT_CLIPBOARD,
GRANT_MIC,
GRANT_LAUNCH,
];
let mut acc = 0u32;
for b in bits {
assert_eq!(b.count_ones(), 1);
assert_eq!(acc & b, 0, "overlapping grant bits");
acc |= b;
}
assert_eq!(acc, GRANT_ALL);
assert_eq!(GRANT_ALL & GRANT_RESERVED, 0);
assert_eq!(GRANT_ALL | GRANT_RESERVED, u32::MAX);
}
#[test]
fn presets_match_the_design() {
// Full = everything; Controller-only = pad bit ONLY (no LAUNCH — §11 D2); View = nothing.
assert_eq!(GRANT_PRESET_FULL, GRANT_ALL);
assert_eq!(GRANT_PRESET_CONTROLLER_ONLY, GRANT_GAMEPAD);
assert_eq!(GRANT_PRESET_CONTROLLER_ONLY & GRANT_LAUNCH, 0);
assert_eq!(GRANT_PRESET_VIEW_ONLY, 0);
}
#[test]
fn every_input_kind_classifies_per_the_design_table() {
use GrantClass::*;
// Walk the whole wire vocabulary via from_u8, so a new kind added to the enum AND the
// decoder shows up here too (the classify match itself already breaks the build).
let mut seen = 0;
for v in 0..=u8::MAX {
let Some(kind) = InputKind::from_u8(v) else {
continue;
};
seen += 1;
let want = match kind {
InputKind::KeyDown | InputKind::KeyUp | InputKind::TextInput => Keyboard,
InputKind::GamepadButton
| InputKind::GamepadAxis
| InputKind::GamepadState
| InputKind::GamepadRemove
| InputKind::GamepadArrival => Gamepad,
_ => Pointer,
};
assert_eq!(classify(kind), want, "kind {kind:?}");
}
assert_eq!(
seen, 16,
"InputKind wire vocabulary grew — classify the new kind"
);
}
#[test]
fn class_bits_round_onto_the_grant_consts() {
assert_eq!(GrantClass::Gamepad.bit(), GRANT_GAMEPAD);
assert_eq!(GrantClass::Pointer.bit(), GRANT_POINTER);
assert_eq!(GrantClass::Keyboard.bit(), GRANT_KEYBOARD);
assert_eq!(GrantClass::Clipboard.bit(), GRANT_CLIPBOARD);
assert_eq!(GrantClass::Mic.bit(), GRANT_MIC);
assert_eq!(GrantClass::Launch.bit(), GRANT_LAUNCH);
}
}
+2
View File
@@ -341,6 +341,8 @@ mod tests {
codec: CODEC_HEVC,
host_caps: HOST_CAP_GAMEPAD_STATE | HOST_CAP_CLIPBOARD,
mgmt_port: 0,
grants: GRANT_ALL,
expires_in_secs: 0,
cipher: 0,
key_chacha: None,
};
+101
View File
@@ -664,6 +664,11 @@ pub const CLIP_REASON_POLICY_DISABLED: u8 = 3;
/// [`ClipState::reason`]: enabled, but the host policy forbids file transfer (`no-files` /
/// `text-only`) — surfaced so the client greys "Include files" with a footnote.
pub const CLIP_REASON_NO_FILES: u8 = 4;
/// [`ClipState::reason`]: the operator policy allows clipboard, but THIS device's access grants
/// don't (`GRANT_CLIPBOARD` unbit — design/per-client-access.md §5.4). Distinct from
/// [`CLIP_REASON_POLICY_DISABLED`] so the client can say "not permitted for this device" instead
/// of "the host has clipboard off".
pub const CLIP_REASON_NOT_PERMITTED: u8 = 5;
/// [`ClipFetchHdr::status`]: the requested format is being served; data chunks follow until FIN.
pub const CLIP_FETCH_OK: u8 = 0;
@@ -1040,6 +1045,54 @@ impl CursorRenderMode {
}
}
// --- Per-client access (design/per-client-access.md §4/§9) -----------------------------------
// Mid-session grant/expiry traffic. The grant vocabulary itself lives in [`super::access`];
// this is the one control message that carries it host → client.
// ---------------------------------------------------------------------------------------------
/// Type byte of [`AccessUpdate`] (host → client): the session's effective grants or remaining
/// lifetime changed. 0x58: the 0x50 block belongs to the cursor channel (0x50/0x51 taken),
/// so access sits at its top, clear of both the clipboard block (0x40-0x44) and any further
/// cursor growth.
pub const MSG_ACCESS_UPDATE: u8 = 0x58;
/// `host → client` ([`MSG_ACCESS_UPDATE`]): a console edit changed this device's grants, or its
/// temporary access is about to run out (the T5 m / T1 m warnings). Latest-wins and
/// best-effort — the HOST enforces regardless; this exists so the client can re-gate capture
/// and warn the user before the expiry close ([`ACCESS_EXPIRED_CLOSE_CODE`](crate::reject))
/// instead of the session just ending. An older client hits its "unknown control message" arm
/// and simply misses the courtesy.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct AccessUpdate {
/// The effective grant bitmask ([`super::GRANT_GAMEPAD`] family) — same vocabulary as the
/// [`Welcome`](super::Welcome) advert.
pub grants: u32,
/// Seconds until this device's access expires; `0` = permanent (no deadline).
pub remaining_secs: u32,
}
impl AccessUpdate {
pub fn encode(&self) -> Vec<u8> {
// magic[0..4] type[4] grants[5..9] remaining_secs[9..13]
let mut b = Vec::with_capacity(13);
b.extend_from_slice(CTL_MAGIC);
b.push(MSG_ACCESS_UPDATE);
b.extend_from_slice(&self.grants.to_le_bytes());
b.extend_from_slice(&self.remaining_secs.to_le_bytes());
b
}
pub fn decode(b: &[u8]) -> Result<AccessUpdate> {
if b.len() != 13 || &b[0..4] != CTL_MAGIC || b[4] != MSG_ACCESS_UPDATE {
return Err(PunktfunkError::InvalidArg("bad AccessUpdate"));
}
Ok(AccessUpdate {
grants: u32::from_le_bytes(b[5..9].try_into().unwrap()),
remaining_secs: u32::from_le_bytes(b[9..13].try_into().unwrap()),
})
}
}
#[cfg(test)]
mod tests {
use crate::config::Mode;
@@ -1346,10 +1399,30 @@ mod tests {
policy: CLIP_POLICY_TEXT,
reason: CLIP_REASON_NO_FILES,
},
ClipState {
enabled: false,
policy: CLIP_POLICY_TEXT | CLIP_POLICY_FILES,
reason: CLIP_REASON_NOT_PERMITTED,
},
];
for m in cases {
assert_eq!(ClipState::decode(&m.encode()).unwrap(), m);
}
// The reason vocabulary stays collision-free: a shipped client switches on these bytes,
// so a re-used value would mislabel refusals in the field, not fail loudly.
let reasons = [
CLIP_REASON_OK,
CLIP_REASON_BACKEND_UNAVAILABLE,
CLIP_REASON_TAKEN_OVER,
CLIP_REASON_POLICY_DISABLED,
CLIP_REASON_NO_FILES,
CLIP_REASON_NOT_PERMITTED,
];
for (i, a) in reasons.iter().enumerate() {
for b in &reasons[i + 1..] {
assert_ne!(a, b, "CLIP_REASON_* values must be distinct");
}
}
// A ClipControl must not decode as a ClipState (type byte).
assert!(ClipState::decode(
&ClipControl {
@@ -1518,4 +1591,32 @@ mod tests {
// Distinct from the neighboring vocabulary.
assert!(ClipState::decode(&s.encode()).is_err());
}
#[test]
fn access_update_roundtrip() {
for (grants, remaining_secs) in [
(GRANT_ALL, 0u32), // full control, permanent
(GRANT_PRESET_CONTROLLER_ONLY, 300), // guest at the T5 m warning
(GRANT_PRESET_VIEW_ONLY, 60), // spectator at T1 m
(GRANT_GAMEPAD | GRANT_CLIPBOARD, u32::MAX),
] {
let m = AccessUpdate {
grants,
remaining_secs,
};
assert_eq!(AccessUpdate::decode(&m.encode()).unwrap(), m);
}
// 0x58 stays clear of every neighbor's decoder (an old peer's dispatch chain must fall
// through to its "unknown control message" arm), and the length is exact.
let bytes = AccessUpdate {
grants: GRANT_ALL,
remaining_secs: 1,
}
.encode();
assert_eq!(bytes[4], MSG_ACCESS_UPDATE);
assert!(ClipState::decode(&bytes).is_err());
assert!(CursorRenderMode::decode(&bytes).is_err());
assert!(AccessUpdate::decode(&[bytes.as_slice(), &[0]].concat()).is_err());
assert!(AccessUpdate::decode(&bytes[..bytes.len() - 1]).is_err());
}
}
+129 -7
View File
@@ -227,6 +227,20 @@ pub struct Welcome {
/// than at the next free fixed offset, and emitting it forces the `cipher` placeholder — see
/// the note in [`Welcome::encode`]. `0` when an older host omitted it.
pub mgmt_port: u16,
/// The session's effective access grants — the [`GRANT_GAMEPAD`](super::GRANT_GAMEPAD)-family
/// bitmask (per-client access, `design/per-client-access.md` §7). Courtesy, not authority:
/// the HOST enforces the mask regardless; the client uses this to not capture what can't
/// land (no keyboard grab without the bit — "my keyboard does nothing and nobody says why"
/// is the failure mode this prevents) and to label the session ("Controller only").
/// Appended after `mgmt_port` as 4 trailing bytes; absent (an older host) →
/// [`GRANT_ALL`](super::GRANT_ALL), the pre-grants behavior.
pub grants: u32,
/// Seconds until this device's access expires, measured when the Welcome is built; `0` =
/// permanent (also what an older host's omission decodes to). Mid-session changes ride
/// [`AccessUpdate`](super::AccessUpdate); the expiry itself closes with
/// [`ACCESS_EXPIRED_CLOSE_CODE`](crate::reject). Appended after `grants` as 4 trailing
/// bytes.
pub expires_in_secs: u32,
/// The 256-bit ChaCha20-Poly1305 session key (RFC 8439 requires the full 32 bytes; wire
/// cost is once per handshake) — present iff `cipher == 1`, at offsets 69..101. The legacy
/// 16-byte `key` keeps its offset and stays independently random, so nothing downstream
@@ -498,15 +512,26 @@ impl Welcome {
// handshake would break against currently-shipped clients. An explicit `cipher = 0` is
// harmless by comparison: a current client reads AES (correct), and a pre-cipher client
// stops before 68 regardless.
// The access advert (grants + expiry) follows `mgmt_port`, extending the same chain:
// emitting it forces BOTH placeholders before it — the cipher byte (as 0 = AES) and the
// mgmt port (as 0 = not advertised, exactly what its absence decodes to) — so the two
// u32s always land at a deterministic offset. A full-control permanent session
// (`GRANT_ALL`, no deadline) is what every absent-field decode yields anyway, so it is
// omitted and the common case stays byte-identical to the pre-grants wire form.
let mgmt_present = self.mgmt_port != 0;
if self.cipher != CIPHER_AES_128_GCM || mgmt_present {
let access_present = self.grants != super::access::GRANT_ALL || self.expires_in_secs != 0;
if self.cipher != CIPHER_AES_128_GCM || mgmt_present || access_present {
b.push(self.cipher);
if let Some(k) = &self.key_chacha {
b.extend_from_slice(k);
}
if mgmt_present {
if mgmt_present || access_present {
b.extend_from_slice(&self.mgmt_port.to_le_bytes());
}
if access_present {
b.extend_from_slice(&self.grants.to_le_bytes());
b.extend_from_slice(&self.expires_in_secs.to_le_bytes());
}
}
b
}
@@ -517,12 +542,14 @@ impl Welcome {
// salt[45..49] frames[49..53] compositor[53] gamepad[54] bitrate_kbps[55..59]
// bit_depth[59] color.primaries[60] color.transfer[61] color.matrix[62] color.range[63]
// chroma_format[64] audio_channels[65] codec[66] host_caps[67] cipher[68]
// key_chacha[69..101] mgmt_port[69..71 | 101..103] (everything from compositor on is an
// key_chacha[69..101] mgmt_port[69..71 | 101..103] grants[71..75 | 103..107]
// expires_in_secs[75..79 | 107..111] (everything from compositor on is an
// optional trailing byte; an older host stops earlier; cipher/key_chacha are present only
// when ChaCha was negotiated). `mgmt_port` is the one field whose offset is NOT fixed: it
// follows the cipher block, so it starts at 69 for an AES session and 101 when a 32-byte
// ChaCha key precedes it. Emitting it forces the cipher byte (see `encode`), so "cipher
// absent" and "mgmt_port present" can never both hold.
// when ChaCha was negotiated). `mgmt_port` and the access pair are the fields whose
// offsets are NOT fixed: they follow the cipher block, shifted by 32 when a ChaCha key
// precedes them. Emitting a later field forces every earlier one (see `encode`), so
// "cipher absent" and "mgmt_port present" — or "mgmt_port absent" and "grants present" —
// can never both hold.
if b.len() < 53 || &b[0..4] != MAGIC {
return Err(PunktfunkError::InvalidArg("bad Welcome"));
}
@@ -562,6 +589,18 @@ impl Welcome {
.get(mgmt_off..mgmt_off + 2)
.map(|s| u16::from_le_bytes(s.try_into().unwrap()))
.unwrap_or(0);
// The access advert trails the mgmt port. Absent (an older host, or a full-control
// permanent session — encode omits the default) → GRANT_ALL / no deadline, which is
// exactly the pre-grants behavior; a truncated tail is never half an advert.
let grants_off = mgmt_off + 2;
let grants = b
.get(grants_off..grants_off + 4)
.map(|s| u32::from_le_bytes(s.try_into().unwrap()))
.unwrap_or(super::access::GRANT_ALL);
let expires_in_secs = b
.get(grants_off + 4..grants_off + 8)
.map(|s| u32::from_le_bytes(s.try_into().unwrap()))
.unwrap_or(0);
Ok(Welcome {
abi_version: u32at(4),
udp_port: u16at(8),
@@ -630,6 +669,8 @@ impl Welcome {
// snapshots; the client keeps sending legacy per-transition events).
host_caps: b.get(67).copied().unwrap_or(0),
mgmt_port,
grants,
expires_in_secs,
cipher,
key_chacha,
})
@@ -717,6 +758,8 @@ mod tests {
codec: CODEC_H264, // exercise a non-default codec through the roundtrip
host_caps: HOST_CAP_GAMEPAD_STATE,
mgmt_port: 0,
grants: GRANT_ALL,
expires_in_secs: 0,
cipher: 0,
key_chacha: None,
};
@@ -783,6 +826,8 @@ mod tests {
codec: CODEC_HEVC,
host_caps: 0,
mgmt_port: 0,
grants: GRANT_ALL,
expires_in_secs: 0,
cipher: CIPHER_AES_128_GCM,
key_chacha: None,
};
@@ -868,6 +913,77 @@ mod tests {
assert_eq!(Welcome::decode(&cenc).unwrap().mgmt_port, 0);
// A truncated tail (one byte of the port) is not half a port: it reads as unknown.
assert_eq!(Welcome::decode(&menc[..70]).unwrap().mgmt_port, 0);
// ── grants + expiry, the access advert after the mgmt port ────────────────────────────
//
// Same chain discipline one link further: emitting the access pair forces BOTH the
// cipher byte (as 0 = AES) and the mgmt port (as 0 = not advertised) so the two u32s
// land at a deterministic offset — 71..79 for AES, 103..111 behind a ChaCha key.
let guest = Welcome {
grants: GRANT_PRESET_CONTROLLER_ONLY,
expires_in_secs: 4 * 3600,
..base
};
let genc = guest.encode();
assert_eq!(
genc.len(),
68 + 1 + 2 + 8,
"cipher + mgmt placeholders + 2 u32s"
);
assert_eq!(genc[68], CIPHER_AES_128_GCM, "forced cipher placeholder");
assert_eq!(
&genc[69..71],
&0u16.to_le_bytes(),
"forced mgmt placeholder"
);
assert_eq!(&genc[71..75], &GRANT_PRESET_CONTROLLER_ONLY.to_le_bytes());
assert_eq!(&genc[75..79], &(4u32 * 3600).to_le_bytes());
assert_eq!(Welcome::decode(&genc).unwrap(), guest);
// The forced-zero mgmt placeholder decodes exactly like its absence: unknown.
assert_eq!(Welcome::decode(&genc).unwrap().mgmt_port, 0);
// All three trailing features together, behind a ChaCha key: 103..111.
let full_chain = Welcome {
mgmt_port: 47991,
grants: GRANT_PRESET_VIEW_ONLY,
expires_in_secs: 60,
cipher: CIPHER_CHACHA20_POLY1305,
key_chacha: Some(k32),
..base
};
let fenc = full_chain.encode();
assert_eq!(fenc.len(), 68 + 1 + 32 + 2 + 8);
assert_eq!(&fenc[103..107], &GRANT_PRESET_VIEW_ONLY.to_le_bytes());
assert_eq!(&fenc[107..111], &60u32.to_le_bytes());
assert_eq!(Welcome::decode(&fenc).unwrap(), full_chain);
// Old-welcome-decodes-with-defaults: every shorter wire form — the pre-cipher 68 bytes,
// a cipher-only form, and a mgmt-port form — reads as full control, permanent. Grants
// arriving as GRANT_ALL from an old host is the CORRECT meaning: that host enforces
// nothing, exactly like a full-control session.
for old in [&enc[..], &cenc[..], &menc[..]] {
let w = Welcome::decode(old).unwrap();
assert_eq!(w.grants, GRANT_ALL);
assert_eq!(w.expires_in_secs, 0);
}
// A truncated advert (partial u32) is never half a mask; grants-without-expiry reads
// the mask and leaves the deadline permanent.
assert_eq!(Welcome::decode(&genc[..73]).unwrap().grants, GRANT_ALL);
let g_only = Welcome::decode(&genc[..75]).unwrap();
assert_eq!(g_only.grants, GRANT_PRESET_CONTROLLER_ONLY);
assert_eq!(g_only.expires_in_secs, 0);
// New-welcome-decoded-by-old-reader semantics: a 0.29-era reader stops at the bytes it
// knows. The mgmt-port-era reader consumes [..71] of the guest Welcome and sees a valid
// session (cipher 0 = AES, port 0 = unknown) — the appended advert never perturbs it.
let old_view = Welcome::decode(&genc[..71]).unwrap();
assert_eq!(old_view.cipher, CIPHER_AES_128_GCM);
assert_eq!(old_view.mgmt_port, 0);
assert_eq!(old_view, base);
// A full-control permanent session emits NO advert — the common case stays
// byte-identical to the pre-grants wire form (and to the pre-cipher one).
assert_eq!(base.encode().len(), 68);
}
#[test]
@@ -963,6 +1079,8 @@ mod tests {
codec: CODEC_PYROWAVE,
host_caps: 0,
mgmt_port: 0,
grants: GRANT_ALL,
expires_in_secs: 0,
cipher: 0,
key_chacha: None,
}
@@ -1038,6 +1156,8 @@ mod tests {
codec: CODEC_H264,
host_caps: 0,
mgmt_port: 0,
grants: GRANT_ALL,
expires_in_secs: 0,
cipher: 0,
key_chacha: None,
}
@@ -1150,6 +1270,8 @@ mod tests {
codec: CODEC_HEVC,
host_caps: HOST_CAP_GAMEPAD_STATE,
mgmt_port: 0,
grants: GRANT_ALL,
expires_in_secs: 0,
cipher: 0,
key_chacha: None,
};
+4 -1
View File
@@ -24,7 +24,8 @@
//!
//! Split by concern (networking-audit deferred plan §3 — a pure move): `handshake` the
//! positional Hello/Welcome/Start codecs, `caps` the capability/codec-negotiation
//! vocabulary, `control` the typed control + clipboard messages, `pairing` the pairing
//! vocabulary, `access` the per-client grant bits + input-kind classifier,
//! `control` the typed control + clipboard messages, `pairing` the pairing
//! message codecs with [`pake`] the SPAKE2 itself, `datagram` the 0xC90xD1 plane codecs,
//! `pen` the stylus batch (0xCC kind 0x05) + host stroke tracker,
//! [`io`] framed stream IO, `clock` skew estimation + mid-stream re-sync, [`endpoint`] the
@@ -41,6 +42,7 @@ pub const MAGIC: &[u8; 4] = b"PKF1";
/// vice-versa, regardless of field values.
pub const CTL_MAGIC: &[u8; 4] = b"PKFc";
mod access;
mod caps;
mod clock;
mod control;
@@ -68,6 +70,7 @@ pub mod clipstream;
/// cannot reach a shared key).
pub mod pake;
pub use access::*;
pub use caps::*;
pub use clock::*;
pub use control::*;
+27 -3
View File
@@ -39,6 +39,15 @@ pub const WIRE_VERSION_CLOSE_CODE: u32 = 0x67;
/// code, a setup failure reached the client as a bare dropped connection ("control stream
/// finished mid-frame") — indistinguishable from transport trouble.
pub const SETUP_FAILED_CLOSE_CODE: u32 = 0x68;
/// This device's temporary access ran out (per-client access, `design/per-client-access.md` §4)
/// — sent when the deadline fires mid-session, and by "Expire now" in the console. Only the
/// expiring device's sessions close with it; a reconnect lands in the console's pending list
/// for a one-click re-grant.
pub const ACCESS_EXPIRED_CLOSE_CODE: u32 = 0x69;
/// The `Hello.launch` request named a game this device's grants don't cover (no `LAUNCH` bit).
/// Refused AT the handshake — a crisp typed reason beats silently dropping the user onto a
/// bare desktop they didn't ask for. Connecting *without* a launch request still works.
pub const LAUNCH_NOT_PERMITTED_CLOSE_CODE: u32 = 0x6A;
/// Why a host turned a connection away, decoded from the QUIC application close code — the
/// client-side view of [`PAIR_NOT_ARMED_CLOSE_CODE`]..[`WIRE_VERSION_CLOSE_CODE`] plus
@@ -68,6 +77,10 @@ pub enum RejectReason {
/// The host admitted the connection but failed to start the stream session (host-side
/// setup error — the host log has the specific cause).
SetupFailed,
/// This device's temporary access to the host has expired (per-client access).
AccessExpired,
/// This device's grants don't include launching games (the `LAUNCH` bit is clear).
LaunchNotPermitted,
}
impl RejectReason {
@@ -85,6 +98,8 @@ impl RejectReason {
WIRE_VERSION_CLOSE_CODE => Self::WireVersionMismatch,
REJECT_BUSY_CLOSE_CODE => Self::Busy,
SETUP_FAILED_CLOSE_CODE => Self::SetupFailed,
ACCESS_EXPIRED_CLOSE_CODE => Self::AccessExpired,
LAUNCH_NOT_PERMITTED_CLOSE_CODE => Self::LaunchNotPermitted,
_ => return None,
})
}
@@ -102,6 +117,8 @@ impl RejectReason {
Self::WireVersionMismatch => WIRE_VERSION_CLOSE_CODE,
Self::Busy => REJECT_BUSY_CLOSE_CODE,
Self::SetupFailed => SETUP_FAILED_CLOSE_CODE,
Self::AccessExpired => ACCESS_EXPIRED_CLOSE_CODE,
Self::LaunchNotPermitted => LAUNCH_NOT_PERMITTED_CLOSE_CODE,
}
}
@@ -119,6 +136,8 @@ impl RejectReason {
Self::WireVersionMismatch => "wire-version",
Self::Busy => "busy",
Self::SetupFailed => "setup-failed",
Self::AccessExpired => "access-expired",
Self::LaunchNotPermitted => "launch-not-permitted",
}
}
}
@@ -138,6 +157,8 @@ impl std::fmt::Display for RejectReason {
Self::WireVersionMismatch => "client and host versions do not match",
Self::Busy => "the host is busy with another session",
Self::SetupFailed => "the host could not start the stream session",
Self::AccessExpired => "your access to this host has expired",
Self::LaunchNotPermitted => "this device is not permitted to launch games on the host",
})
}
}
@@ -146,7 +167,7 @@ impl std::fmt::Display for RejectReason {
mod tests {
use super::*;
const ALL: [RejectReason; 10] = [
const ALL: [RejectReason; 12] = [
RejectReason::PairingNotArmed,
RejectReason::PairingBoundToOtherDevice,
RejectReason::PairingRateLimited,
@@ -157,6 +178,8 @@ mod tests {
RejectReason::WireVersionMismatch,
RejectReason::Busy,
RejectReason::SetupFailed,
RejectReason::AccessExpired,
RejectReason::LaunchNotPermitted,
];
#[test]
@@ -177,8 +200,9 @@ mod tests {
#[test]
fn foreign_codes_stay_untyped() {
// Bare closes, the client's own pair-done codes, and the deliberate-end codes must
// never read as a host rejection.
for code in [0u32, 1, 0x41, 0x51, 0x52, 0x5f, 0x69, u32::MAX] {
// never read as a host rejection. (0x69/0x6A left this list when they became the
// access-expired / launch-not-permitted codes; 0x6B is the block's next free id.)
for code in [0u32, 1, 0x41, 0x51, 0x52, 0x5f, 0x6B, 0x70, u32::MAX] {
assert_eq!(RejectReason::from_close_code(code), None);
}
}
+91 -3
View File
@@ -184,6 +184,33 @@ pub enum EventKind {
PairingCompleted { device: DeviceRef },
#[serde(rename = "pairing.denied")]
PairingDenied { device: DeviceRef },
/// A device was granted access with an explicit operator choice — the approve dialog, the
/// arm window's carried choice, or any other `add_with_access(Some)` path
/// (design/per-client-access.md §6). A plain pairing with no choice emits only
/// `pairing.completed` (its access is the preserved/default record, nothing was *chosen*).
#[serde(rename = "access.granted")]
AccessGranted {
device: DeviceRef,
/// The granted mask (the `GRANT_*` bit vocabulary), reserved bits already cleared.
grants: u32,
/// Absolute expiry, host wall clock unix seconds; absent = permanent.
#[serde(skip_serializing_if = "Option::is_none")]
expires_unix: Option<i64>,
},
/// A paired device's access was edited after the fact (the console edit sheet / extend /
/// "expire now") — the owner's hook can say "the TV is view-only now".
#[serde(rename = "access.changed")]
AccessChanged {
device: DeviceRef,
grants: u32,
#[serde(skip_serializing_if = "Option::is_none")]
expires_unix: Option<i64>,
},
/// A device's temporary access reached its deadline and its live session was closed — "guest
/// access ended". Emitted at deadline fire by the expiring session (a device with no live
/// session expires silently; the console row flips to "Expired" either way).
#[serde(rename = "access.expired")]
AccessExpired { device: DeviceRef },
#[serde(rename = "display.created")]
DisplayCreated {
/// The virtual-display backend that minted it (`VirtualDisplay::name`).
@@ -256,6 +283,9 @@ impl EventKind {
EventKind::PairingPending { .. } => "pairing.pending",
EventKind::PairingCompleted { .. } => "pairing.completed",
EventKind::PairingDenied { .. } => "pairing.denied",
EventKind::AccessGranted { .. } => "access.granted",
EventKind::AccessChanged { .. } => "access.changed",
EventKind::AccessExpired { .. } => "access.expired",
EventKind::DisplayCreated { .. } => "display.created",
EventKind::DisplayReleased { .. } => "display.released",
EventKind::LibraryChanged { .. } => "library.changed",
@@ -288,7 +318,10 @@ impl EventKind {
}
EventKind::PairingPending { device }
| EventKind::PairingCompleted { device }
| EventKind::PairingDenied { device } => Some(&device.name),
| EventKind::PairingDenied { device }
| EventKind::AccessGranted { device, .. }
| EventKind::AccessChanged { device, .. }
| EventKind::AccessExpired { device } => Some(&device.name),
_ => None,
}
}
@@ -300,7 +333,10 @@ impl EventKind {
| EventKind::ClientDisconnected { client, .. } => client.fingerprint.as_deref(),
EventKind::PairingPending { device }
| EventKind::PairingCompleted { device }
| EventKind::PairingDenied { device } => Some(&device.fingerprint),
| EventKind::PairingDenied { device }
| EventKind::AccessGranted { device, .. }
| EventKind::AccessChanged { device, .. }
| EventKind::AccessExpired { device } => Some(&device.fingerprint),
_ => None,
}
}
@@ -318,7 +354,10 @@ impl EventKind {
}
EventKind::PairingPending { device }
| EventKind::PairingCompleted { device }
| EventKind::PairingDenied { device } => Some(device.plane),
| EventKind::PairingDenied { device }
| EventKind::AccessGranted { device, .. }
| EventKind::AccessChanged { device, .. }
| EventKind::AccessExpired { device } => Some(device.plane),
_ => None,
}
}
@@ -635,6 +674,55 @@ mod tests {
);
}
/// The `access.*` wire shapes (per-client access, design §6): additive-only like the rest of
/// the catalog, and reachable by the same hook/SSE filters (`access.*`).
#[test]
fn access_event_wire_shapes_and_filters() {
let device = DeviceRef {
name: "Guest Deck".into(),
fingerprint: "ab12".into(),
plane: Plane::Native,
};
let ev = HostEvent {
seq: 8,
ts_ms: 1_700_000_000_000,
schema: 1,
kind: EventKind::AccessGranted {
device: device.clone(),
grants: 1, // GRANT_GAMEPAD — controller-only
expires_unix: Some(1_700_000_400),
},
};
assert_eq!(
serde_json::to_string(&ev).unwrap(),
r#"{"seq":8,"ts_ms":1700000000000,"schema":1,"kind":"access.granted","device":{"name":"Guest Deck","fingerprint":"ab12","plane":"native"},"grants":1,"expires_unix":1700000400}"#
);
// A permanent grant omits the expiry (not nulled) — the optional-field convention.
let ev = HostEvent {
seq: 9,
ts_ms: 1_700_000_000_000,
schema: 1,
kind: EventKind::AccessChanged {
device: device.clone(),
grants: 63,
expires_unix: None,
},
};
assert_eq!(
serde_json::to_string(&ev).unwrap(),
r#"{"seq":9,"ts_ms":1700000000000,"schema":1,"kind":"access.changed","device":{"name":"Guest Deck","fingerprint":"ab12","plane":"native"},"grants":63}"#
);
let expired = EventKind::AccessExpired { device };
assert_eq!(expired.name(), "access.expired");
assert!(kind_matches("access.*", expired.name()));
assert!(!kind_matches("pairing.*", expired.name()));
assert_eq!(expired.client_name(), Some("Guest Deck"));
assert_eq!(expired.fingerprint(), Some("ab12"));
assert_eq!(expired.plane(), Some(Plane::Native));
}
/// The `game.*` events must be reachable by the same hook/SSE filters as every other kind — a
/// filterable event nobody can select is not a feature.
#[test]
+27 -4
View File
@@ -218,12 +218,15 @@ pub fn start(
params: AudioParams,
audio_cap: AudioCapSlot,
on_lost: super::OnSessionLost,
owner_ip: Option<std::net::IpAddr>,
) {
let _ = std::thread::Builder::new()
.name("punktfunk-audio".into())
.spawn(move || {
tracing::info!(?params, "audio stream starting");
if let Err(e) = run(&running, &gcm_key, rikeyid, params, &audio_cap, &on_lost) {
if let Err(e) = run(
&running, &gcm_key, rikeyid, params, &audio_cap, &on_lost, owner_ip,
) {
tracing::error!(error = %format!("{e:#}"), "audio stream failed");
}
running.store(false, Ordering::SeqCst);
@@ -243,6 +246,7 @@ pub fn start(
_params: AudioParams,
_audio_cap: AudioCapSlot,
_on_lost: super::OnSessionLost,
_owner_ip: Option<std::net::IpAddr>,
) {
tracing::error!("GameStream audio requires Linux (PipeWire) or Windows (WASAPI) + libopus");
running.store(false, std::sync::atomic::Ordering::SeqCst);
@@ -256,6 +260,7 @@ fn run(
params: AudioParams,
audio_cap: &std::sync::Mutex<Option<Box<dyn AudioCapturer>>>,
on_lost: &super::OnSessionLost,
owner_ip: Option<std::net::IpAddr>,
) -> Result<()> {
let sock = UdpSocket::bind(("0.0.0.0", AUDIO_PORT)).context("bind audio UDP")?;
// Grow SO_SNDBUF/RCVBUF; the opt-in DSCP/QoS tag happens after connect below (Windows
@@ -265,9 +270,27 @@ fn run(
sock.set_read_timeout(Some(Duration::from_secs(10)))?;
tracing::debug!(port = AUDIO_PORT, "audio: awaiting client ping");
let mut probe = [0u8; 256];
let (_, client) = sock
.recv_from(&mut probe)
.context("audio: no client ping within 10s")?;
// Same owner-IP bind as the video plane (LaunchSession::peer_ip): only the launching peer's
// pings are honored, so an off-path LAN peer cannot capture the audio endpoint (a DoS here, as
// audio payload is AES-CBC under `rikey`). `None` keeps the pre-owner behavior.
// security-review 2026-08-15 finding 1.
let client = {
let deadline = std::time::Instant::now() + Duration::from_secs(10);
loop {
let remaining = deadline.saturating_duration_since(std::time::Instant::now());
if remaining.is_zero() {
anyhow::bail!("audio: no client ping from the launch owner within 10s");
}
sock.set_read_timeout(Some(remaining))?;
let (_, src) = sock
.recv_from(&mut probe)
.context("audio: no client ping within 10s")?;
if owner_ip.is_some_and(|ip| ip != src.ip()) {
continue;
}
break src;
}
};
sock.connect(client)
.context("connect client audio endpoint")?;
// Opt-in DSCP/QoS-tag this as the audio class (PUNKTFUNK_DSCP=1); the guard keeps the
+403 -12
View File
@@ -31,7 +31,7 @@ use super::{AppState, CONTROL_PORT};
use crate::inject::gamepad::GamepadManager;
use anyhow::{anyhow, Context, Result};
use punktfunk_core::input::InputEvent;
use punktfunk_core::quic::HdrMeta;
use punktfunk_core::quic::{classify, GrantClass, HdrMeta, GRANT_ALL};
use rusty_enet::{Event, Host, HostSettings, Packet, PeerID};
use std::net::UdpSocket;
use std::sync::atomic::{AtomicBool, Ordering};
@@ -76,6 +76,159 @@ struct Running {
thread: std::thread::JoinHandle<()>,
}
/// The live session's per-client access (design/per-client-access.md §8, WP13), resolved from
/// the launch owner's cert fingerprint against the shared grants registry
/// ([`AppState::access`]). The control thread owns it single-threadedly, so a plain `u32`
/// stands where the native plane's `Arc<AtomicU32>` does — the idiom is otherwise WP4's:
/// resolve at session start, fold console edits in via the watch (within one 2 ms tick), one
/// mask test per event, and the wall-clock deadline cuts the session.
struct SessionAccess {
/// The launch owner's fingerprint (lowercase hex) this state was resolved for — a
/// different owner (steal/new session) re-resolves from scratch.
fp_hex: String,
/// Live edits from the console arrive here (`NativePairing::subscribe`); polled per tick.
/// `None` when no registry is wired (tests) — then the mask stays ungoverned-full forever.
rx: Option<tokio::sync::watch::Receiver<crate::native_pairing::AccessState>>,
/// The effective grant mask input is filtered against.
mask: u32,
/// Absolute expiry, host wall clock, unix seconds; `None` = permanent. Checked each tick.
deadline: Option<i64>,
}
impl SessionAccess {
/// Resolve a session owner's access: subscribe FIRST, then fold the channel's current
/// value, so a console edit racing this resolution lands either in the borrow or as the
/// first change notification — never in a gap between the two (the WP3 admission order).
fn resolve(
registry: Option<&Arc<crate::native_pairing::NativePairing>>,
fp_hex: String,
) -> SessionAccess {
let mut access = SessionAccess {
fp_hex,
rx: None,
mask: GRANT_ALL,
deadline: None,
};
if let Some(np) = registry {
let rx = np.subscribe(&access.fp_hex);
let st = *rx.borrow();
access.rx = Some(rx);
access.fold(st);
}
access
}
/// Fold one watch state in — with the Moonlight reading of `revoked` (design §8): a
/// fingerprint with no grants record is *ungoverned* (full control), because this plane's
/// pairing authority is the GameStream cert list, whose unpair ends the session through
/// the mgmt endpoint, not through this watch. A record that exists governs as on the
/// native plane: its mask applies and its deadline (checked per tick) cuts the session.
fn fold(&mut self, st: crate::native_pairing::AccessState) {
if st.revoked {
self.mask = GRANT_ALL;
self.deadline = None;
} else {
self.mask = st.grants;
self.deadline = st.deadline_unix;
}
}
/// Fold any pending watch edit (non-blocking; the control thread is not async).
fn poll(&mut self) {
if let Some(rx) = self.rx.as_mut() {
if rx.has_changed().unwrap_or(false) {
let st = *rx.borrow_and_update();
self.fold(st);
}
}
}
/// Whether the deadline has passed at `now` (the deadline second itself is expired — the
/// same evaluation as the trust store's `effective`). An "expire now" console edit is just
/// a deadline in the past arriving through the watch, so it lands here too.
fn expired(&self, now_unix: i64) -> bool {
self.deadline.is_some_and(|d| now_unix >= d)
}
}
/// Quiet per-(session, grant-class) enforcement-drop accounting — the GameStream twin of the
/// native plane's `GrantDrops` (design §5.5): one counter and ONE `warn!` per class for the
/// whole session (per-event logging is the DoS), totals surfaced once at session end. Plain
/// integers, not atomics: the control thread is the only writer and reader.
struct GrantDrops {
counts: [u64; 6],
warned: [bool; 6],
}
impl GrantDrops {
fn new() -> GrantDrops {
GrantDrops {
counts: [0; 6],
warned: [false; 6],
}
}
/// A class's slot in the fixed tables — the bit position of its grant, so the layout can
/// never drift from the wire vocabulary.
fn idx(class: GrantClass) -> usize {
class.bit().trailing_zeros() as usize
}
/// Count one dropped item; log only the FIRST drop of each class — the support signal for
/// "my keyboard does nothing" from a Moonlight client, which has no grants UX at all
/// (silent enforcement is protocol-inherent here, design §8).
fn note(&mut self, class: GrantClass) {
let i = Self::idx(class);
self.counts[i] += 1;
if !self.warned[i] {
self.warned[i] = true;
tracing::warn!(
class = ?class,
"gamestream: dropping client input this session's access grants don't cover — \
counted; further drops of this class are silent until the session-end totals"
);
}
}
/// Log the session's drop totals (if any) and reset for the next session. Called from
/// every per-session teardown arm — disconnect, host-side end, thread stop.
fn end_of_session(&mut self) {
use std::fmt::Write;
let mut out = String::new();
for class in [
GrantClass::Gamepad,
GrantClass::Pointer,
GrantClass::Keyboard,
GrantClass::Clipboard,
GrantClass::Mic,
GrantClass::Launch,
] {
let n = self.counts[Self::idx(class)];
if n != 0 {
if !out.is_empty() {
out.push(' ');
}
let _ = write!(out, "{class:?}={n}");
}
}
if !out.is_empty() {
tracing::info!(drops = %out, "gamestream: access-grant drop totals for the session");
}
*self = GrantDrops::new();
}
}
/// The one mask test standing between a decoded event class and its injector (design §5.3):
/// `true` = inject; `false` = counted and dropped. Kept a free function so the filter the
/// session actually runs is the thing the tests exercise.
fn permitted(mask: u32, class: GrantClass, drops: &mut GrantDrops) -> bool {
if mask & class.bit() != 0 {
return true;
}
drops.note(class);
false
}
/// Reconcile the control port to the paired-client list: bound while at least one pairing
/// exists, closed when none remain. Idempotent and race-free (see [`Gate::running`]); call it
/// wherever the paired list changes — startup, pairing phase 4, unpair.
@@ -119,6 +272,54 @@ pub(crate) fn sync(state: &Arc<AppState>) -> Result<()> {
}
}
/// A [`rusty_enet::Socket`] that drops datagrams whose source IP is not the launch owner's.
///
/// `rusty_enet` 0.4.0 exposes no setter for `maximum_waiting_data` (the C default of 32 MiB of
/// per-peer reassembly), so an off-path LAN peer that connects on 47999 can pin ~32 MiB × the
/// `peer_limit` and occupy peer slots without ever authenticating — and the same unfiltered path
/// lets an on-path attacker spoof the owner's source to feed the tracked peer. Filtering at the
/// socket drops those datagrams BEFORE ENet allocates any per-peer state. The owner is read live
/// from `launch` on each receive: before `/launch` (owner `None`) the filter passes everything,
/// matching the plane's existing "trust the connect when no owner is captured" fallback used by
/// the `Event::Connect` arm below. security-review 2026-08-15 findings 2 and 13.
struct OwnerFilteredSocket {
inner: UdpSocket,
state: Arc<AppState>,
}
impl rusty_enet::Socket for OwnerFilteredSocket {
type Address = std::net::SocketAddr;
type Error = std::io::Error;
fn init(&mut self, opts: rusty_enet::SocketOptions) -> Result<(), std::io::Error> {
rusty_enet::Socket::init(&mut self.inner, opts)
}
fn send(&mut self, address: Self::Address, buffer: &[u8]) -> Result<usize, std::io::Error> {
rusty_enet::Socket::send(&mut self.inner, address, buffer)
}
fn receive(
&mut self,
buffer: &mut [u8; rusty_enet::MTU_MAX],
) -> Result<Option<(Self::Address, rusty_enet::PacketReceived)>, std::io::Error> {
// Loop so a dropped non-owner datagram doesn't starve a following owner datagram in the
// same drain; the inner socket is non-blocking, so this returns `Ok(None)` on WouldBlock.
loop {
match rusty_enet::Socket::receive(&mut self.inner, buffer)? {
Some((addr, received)) => {
let owner = self.state.launch.lock().unwrap().and_then(|s| s.peer_ip);
if owner.is_some_and(|ip| ip != addr.ip()) {
continue;
}
return Ok(Some((addr, received)));
}
None => return Ok(None),
}
}
}
}
/// Bind the ENet control host on 47999 and service it on a dedicated thread until `stop`.
fn spawn(state: Arc<AppState>) -> Result<Running> {
let socket = UdpSocket::bind(("0.0.0.0", CONTROL_PORT)).context("bind control UDP")?;
@@ -126,7 +327,10 @@ fn spawn(state: Arc<AppState>) -> Result<Running> {
.set_nonblocking(true)
.context("control socket nonblocking")?;
let mut host = Host::new(
socket,
OwnerFilteredSocket {
inner: socket,
state: state.clone(),
},
HostSettings {
peer_limit: 4,
// Moonlight connects with CTRL_CHANNEL_COUNT (0x30) channels and sends gamepad
@@ -170,6 +374,11 @@ fn spawn(state: Arc<AppState>) -> Result<Running> {
// clears `launch` — and the key lives there — so without this copy the one message that
// has to go out *because* the session ended could no longer be sealed.
let mut last_key: Option<[u8; 16]> = None;
// Per-client access (WP13): the live session's grant mask + deadline, resolved
// from the launch owner's fingerprint; `None` while no session is live. `drops`
// is the session's quiet enforcement accounting (counters, never per-event logs).
let mut access: Option<SessionAccess> = None;
let mut drops = GrantDrops::new();
loop {
// WP0 teardown: the last pairing was removed while we were live. Tell a
// connected client the session is over — termination + disconnect, the same
@@ -194,10 +403,43 @@ fn spawn(state: Arc<AppState>) -> Result<Running> {
std::thread::sleep(Duration::from_millis(2));
}
}
drops.end_of_session();
state.end_session("control stream stopped — last pairing removed");
tracing::info!(port = CONTROL_PORT, "control: stopped (no paired clients)");
return;
}
// Track the live session's access each tick (2 ms): resolve on a new owner,
// fold any console edit in (one watch poll — cheap version check), and cut the
// session the tick its deadline passes. Events serviced below read the folded
// mask, so an edit reaches enforcement within one tick of the watch publish.
let owner_fp = state.launch.lock().unwrap().and_then(|s| s.owner_fp);
match owner_fp {
None => access = None,
Some(fp) => {
let fp_hex = hex::encode(fp);
if access.as_ref().is_none_or(|a| a.fp_hex != fp_hex) {
access = Some(SessionAccess::resolve(state.access.get(), fp_hex));
} else if let Some(a) = access.as_mut() {
a.poll();
}
if access
.as_ref()
.is_some_and(|a| a.expired(super::wall_unix_now()))
{
// Expiry (or an "expire now" edit) ends the session as a decision
// — like the mgmt unpair, not like a network drop. `quit_session`
// clears `launch`, and the host-side-ended arm below then sends
// the TERMINATION + disconnect: GameStream has no AccessUpdate
// vocabulary, so that close IS the whole message (design §8). The
// nvhttp gates keep the expired record from re-launching.
tracing::info!(
"gamestream: session access expired — ending the session"
);
state.quit_session("gamestream access expired");
access = None;
}
}
}
loop {
match host.service() {
Ok(Some(event)) => match event {
@@ -240,6 +482,8 @@ fn spawn(state: Arc<AppState>) -> Result<Running> {
// uinput pen releases any held tool/tip kernel-side).
pads = GamepadManager::new();
pointer = super::pen::GsPointer::new();
// Surface the session's enforcement-drop totals (WP13).
drops.end_of_session();
// The control stream is the session's liveness anchor — Moonlight
// holds it for the whole stream, and ENet detects a vanished peer
// via its reliable-ping timeout (~530 s), which ALSO lands here.
@@ -252,8 +496,26 @@ fn spawn(state: Arc<AppState>) -> Result<Running> {
state.end_session("control stream disconnected");
}
Event::Receive {
channel_id, packet, ..
peer: p,
channel_id,
packet,
} => {
// Only the tracked session peer's input is honored. The owner-IP
// socket filter already drops non-owner datagrams once a launch is
// recorded; this is defense-in-depth for the window before the
// owner is captured (and mirrors the `Disconnect` arm's gate) so a
// peer that connected while `owner_ip` was `None` still cannot
// inject keyboard/mouse/gamepad after another peer became the
// session. security-review 2026-08-15 finding 2.
if peer != Some(p.id()) {
continue;
}
// The mask a missing SessionAccess stands in for is FULL:
// input only decrypts under the /launch key, so a decryptable
// event with no resolved access can only be the ≤2 ms sliver
// between `/launch` landing and the next tick's resolve — and
// an ungoverned (recordless) session is full-control anyway.
on_receive(
&state,
channel_id,
@@ -263,6 +525,8 @@ fn spawn(state: Arc<AppState>) -> Result<Running> {
&inj_tx,
&mut pads,
&mut pointer,
access.as_ref().map(|a| a.mask).unwrap_or(GRANT_ALL),
&mut drops,
);
}
},
@@ -321,6 +585,7 @@ fn spawn(state: Arc<AppState>) -> Result<Running> {
hdr_sent = false;
pads = GamepadManager::new();
pointer = super::pen::GsPointer::new();
drops.end_of_session();
}
}
// Service the pads' force-feedback protocol every tick (games block inside
@@ -414,7 +679,8 @@ fn decode_rfi_range(pt: &[u8]) -> Option<(i64, i64)> {
}
/// Handle one received control packet: decrypt it (learning the GCM scheme on the first one),
/// decode any input event, and inject it into the host session.
/// decode any input event, classify it against the session's grant mask (WP13 — drops are
/// counted, not logged), and inject what the grants cover into the host session.
#[allow(clippy::too_many_arguments)]
fn on_receive(
state: &AppState,
@@ -425,6 +691,8 @@ fn on_receive(
inj_tx: &Sender<InputEvent>,
pads: &mut GamepadManager,
pointer: &mut super::pen::GsPointer,
grants: u32,
drops: &mut GrantDrops,
) {
let Some(key) = state.launch.lock().unwrap().map(|s| s.gcm_key) else {
return; // control traffic before /launch — no key yet
@@ -489,18 +757,26 @@ fn on_receive(
}
}
// Controller events go to the uinput virtual pads (created on demand per the mask).
// Controller events go to the uinput virtual pads (created on demand per the mask)
// gated BEFORE the manager sees them, which is also the deny-at-setup (WP4's idiom): a
// session without the GAMEPAD grant never creates a uinput node or a pad-audio streamer,
// because the creating event never arrives.
if let Some(gp) = super::gamepad::decode(&pt) {
pads.handle(&gp);
if permitted(grants, GrantClass::Gamepad, drops) {
pads.handle(&gp);
}
return;
}
// Pen/touch extension events (Moonlight sends them only after seeing our feature flag):
// pen drives this session's virtual tablet; touch forwards as ordinary wire touches.
// Pointer-class by construction (the plane tag decides, like the native pen plane).
if let Some(p) = super::input::decode_pointer(&pt) {
pointer.apply(&p, |ev| {
let _ = inj_tx.send(ev);
});
if permitted(grants, GrantClass::Pointer, drops) {
pointer.apply(&p, |ev| {
let _ = inj_tx.send(ev);
});
}
return;
} else if super::input::is_pointer_magic(&pt) {
// A pointer magic that failed the body parse — a layout mismatch against this
@@ -529,10 +805,14 @@ fn on_receive(
}
// Forward to the dedicated injector thread (it opens the backend on the first event and
// coalesces redundant motion). A closed channel means the injector thread died at startup —
// input is lossy, so drop silently rather than spam.
// coalesces redundant motion) — each event past one mask test against the exhaustive
// classifier (design §5.3), so a Controller-only Moonlight guest's keyboard/mouse is inert
// before injection, exactly like the native datagram dispatch. A closed channel means the
// injector thread died at startup — input is lossy, so drop silently rather than spam.
for ev in events {
let _ = inj_tx.send(ev);
if permitted(grants, classify(ev.kind), drops) {
let _ = inj_tx.send(ev);
}
}
}
@@ -926,4 +1206,115 @@ mod tests {
assert_eq!(&pt[2..4], &27u16.to_le_bytes());
assert_eq!(pt[4], 0); // disabled
}
/// The WP13 acceptance at filter level: under the Controller-only mask the pad passes and
/// keyboard/pointer are counted-and-dropped — the exact test the session's injection arms
/// run (`permitted` is what `on_receive` calls). Also pins the quiet-accounting reset.
#[test]
fn controller_only_mask_passes_the_pad_and_drops_keyboard_and_pointer() {
use punktfunk_core::input::InputKind;
use punktfunk_core::quic::{classify, GrantClass, GRANT_PRESET_CONTROLLER_ONLY};
let mut drops = super::GrantDrops::new();
let mask = GRANT_PRESET_CONTROLLER_ONLY;
// Pad events inject (and pad creation with them — deny-at-setup is upstream of this).
assert!(super::permitted(
mask,
classify(InputKind::GamepadButton),
&mut drops
));
// Keyboard (keys + committed text) and every pointer shape are inert.
assert!(!super::permitted(
mask,
classify(InputKind::KeyDown),
&mut drops
));
assert!(!super::permitted(
mask,
classify(InputKind::TextInput),
&mut drops
));
assert!(!super::permitted(
mask,
classify(InputKind::MouseMove),
&mut drops
));
// The pen/touch plane is Pointer-class by its plane tag.
assert!(!super::permitted(mask, GrantClass::Pointer, &mut drops));
assert_eq!(
drops.counts[super::GrantDrops::idx(GrantClass::Keyboard)],
2
);
assert_eq!(drops.counts[super::GrantDrops::idx(GrantClass::Pointer)], 2);
assert_eq!(drops.counts[super::GrantDrops::idx(GrantClass::Gamepad)], 0);
// Session end logs totals once and resets for the next session.
drops.end_of_session();
assert_eq!(drops.counts, [0u64; 6]);
}
/// The session's live access state (WP13): a fingerprint with NO grants record is
/// ungoverned (full control — the back-compat rule for existing Moonlight pairings), a
/// record that exists governs, console edits fold in via the watch within one poll, an
/// "expire now" edit is a past deadline through the same channel, and deleting the record
/// returns the session to ungoverned rather than reading as a revocation (GameStream
/// unpair ends sessions through the mgmt endpoint, not through this watch).
#[test]
fn session_access_resolves_folds_edits_and_expires() {
use crate::native_pairing::{Access, NativePairing};
use punktfunk_core::quic::{GRANT_ALL, GRANT_GAMEPAD};
use std::sync::Arc;
let x = 0u8;
let p = std::env::temp_dir().join(format!(
"pf-gs-session-access-{}-{}.json",
std::process::id(),
&x as *const _ as usize
));
let _ = std::fs::remove_file(&p);
let np = Arc::new(NativePairing::load_with(Some(p.clone()), None, false).unwrap());
let now = super::super::wall_unix_now();
// No registry wired (an AppState that never went through `serve`): ungoverned forever.
let a = super::SessionAccess::resolve(None, "ab12".into());
assert_eq!(a.mask, GRANT_ALL);
assert!(!a.expired(now + 1_000_000));
// Registry wired, no record: ungoverned — a stock Moonlight pairing keeps full control.
let mut a = super::SessionAccess::resolve(Some(&np), "ab12".into());
assert_eq!(a.mask, GRANT_ALL);
assert_eq!(a.deadline, None);
// A record created for this fingerprint (the console path) governs the live session
// within one watch poll.
np.add_with_access(
"Moonlight Deck",
"AB12", // registry keys case-insensitively, like the store
Some(Access {
grants: GRANT_GAMEPAD,
expires_unix: Some(now + 60),
}),
)
.unwrap();
a.poll();
assert_eq!(a.mask, GRANT_GAMEPAD);
assert!(!a.expired(now + 59));
assert!(a.expired(now + 60), "the deadline second itself is expired");
// "Expire now" is just a deadline in the past arriving through the same watch.
np.set_access(
"ab12",
Access {
grants: GRANT_GAMEPAD,
expires_unix: Some(now - 1),
},
)
.unwrap();
a.poll();
assert!(a.expired(now));
// Deleting the record: back to ungoverned, session survives.
assert!(np.remove("ab12").unwrap());
a.poll();
assert_eq!(a.mask, GRANT_ALL);
assert!(!a.expired(now));
let _ = std::fs::remove_file(&p);
}
}
@@ -250,6 +250,12 @@ pub struct AppState {
/// reads `is_armed()` per frame and emits samples; the same `Arc` is shared with the mgmt API
/// and the native punktfunk/1 loops so one capture spans whichever path is streaming.
pub stats: Arc<crate::stats_recorder::StatsRecorder>,
/// The per-client access grants registry (design/per-client-access.md §8, WP13): the SAME
/// registry the native plane's trust store owns, keyed by certificate fingerprint hex — it
/// serves both paired stores. Set once by [`serve`] after the native-pairing handle exists;
/// unset (tests, exotic embedders) the Moonlight plane treats every paired peer as
/// ungoverned — full control, exactly the pre-grants behavior.
pub access: std::sync::OnceLock<Arc<crate::native_pairing::NativePairing>>,
}
/// Session-lost callback the media threads invoke when they detect the client is unreachable
@@ -331,6 +337,7 @@ impl AppState {
video_cap: std::sync::Arc::new(std::sync::Mutex::new(None)),
audio_cap: std::sync::Arc::new(std::sync::Mutex::new(None)),
stats,
access: std::sync::OnceLock::new(),
}
}
@@ -350,6 +357,7 @@ impl AppState {
rfi_range: std::sync::Arc::new(std::sync::Mutex::new(None)),
audio_cap: std::sync::Arc::new(std::sync::Mutex::new(None)),
stats,
access: std::sync::OnceLock::new(),
}
}
}
@@ -413,6 +421,10 @@ pub fn serve(
crate::native_pairing::NativePairing::load_with(None, None, false)
.context("native pairing store")?,
);
// WP13: hand the GameStream planes the grants registry — the nvhttp launch surface and the
// ENet control thread resolve a Moonlight fingerprint's mask against the same registry the
// native plane enforces (design §8: it keys on fingerprint hex and serves both stores).
let _ = state.access.set(np.clone());
// The identity the native QUIC plane and the mgmt API present (the identity split): P-256 on
// hosts no native client ever pinned, the legacy RSA cert otherwise — resolved ONCE here so
// the two planes cannot race the first-run adoption. See `crate::identity`.
@@ -555,6 +567,18 @@ pub fn serve(
})
}
/// Host wall clock, unix seconds — the clock every per-client-access deadline is stored in and
/// evaluated against (design/per-client-access.md §4: wall time at each check, no cached
/// monotonic offset, so an NTP step moves a deadline with the clock). Shared by the nvhttp
/// launch gates and the control thread's expiry check.
#[cfg(feature = "gamestream")]
pub(crate) fn wall_unix_now() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
/// The name this host shows up under everywhere a human sees it: Moonlight's host tile (the
/// serverinfo `<hostname>` element) and Punktfunk's own client lists (the mDNS service *instance*
/// name of both adverts). `PUNKTFUNK_HOST_NAME` wins — that's the point of the knob, a box whose
@@ -18,6 +18,7 @@ use axum::{
routing::get,
Extension, Router,
};
use punktfunk_core::quic::{GRANT_ALL, GRANT_LAUNCH};
use std::collections::HashMap;
use std::net::SocketAddr;
use std::sync::Arc;
@@ -70,6 +71,26 @@ fn peer_fp(peer: &Option<Extension<PeerCertFingerprint>>) -> Option<[u8; 32]> {
}
}
/// The grant mask the verified HTTPS peer is authorized for *right now*, resolved against the
/// shared grants registry (design/per-client-access.md §8, WP13). `None` = an EXPIRED grants
/// record — the launch surface fails that closed exactly like an unpaired cert. A fingerprint
/// with NO record is ungoverned (`Some(GRANT_ALL)`): the Moonlight plane's pairing authority
/// is its own cert list, so existing pairings keep full control (plan §8 risk table) — but a
/// record that exists (created via the console) governs. Consulted only AFTER
/// [`peer_is_paired`], which is why a certless peer resolves to expired-shaped `None` here:
/// it can never reach this gate with the pairing gate intact, and if it somehow did, failing
/// closed is the right wrong answer.
fn peer_grants(peer: &Option<Extension<PeerCertFingerprint>>, st: &AppState) -> Option<u32> {
let Some(Extension(PeerCertFingerprint(Some(fp)))) = peer else {
return None;
};
match st.access.get() {
Some(np) => np.moonlight_effective(fp, super::wall_unix_now()),
// No registry wired (tests / embedders that never call `serve`): pre-grants behavior.
None => Some(GRANT_ALL),
}
}
/// Whether the caller may control (resume/cancel) the current launch session. `true` when there is
/// no session (nothing to protect — keeps cancel idempotent), or the session's owner fingerprint
/// matches the caller's. Only a paired-but-DIFFERENT client with a known, mismatching fingerprint is
@@ -158,6 +179,22 @@ async fn h_launch(
tracing::warn!("launch rejected — client is not paired");
return xml(error_xml()).into_response();
}
// Per-client access (WP13, design §8): LAUNCH + expiry beside the pairing gate. An expired
// grants record fails closed exactly like an unpaired cert; a Controller-only record (no
// LAUNCH bit) is refused too — on GameStream, launch IS the session, there is no owner-
// launched session to join. The protocol has no reject vocabulary, so the client just sees
// the generic error and the story lives in the console (silent enforcement, accepted).
match peer_grants(&peer, &st) {
Some(g) if g & GRANT_LAUNCH != 0 => {}
Some(_) => {
tracing::warn!("launch rejected — this client's access grants do not include Launch");
return xml(error_xml()).into_response();
}
None => {
tracing::warn!("launch rejected — this client's access has expired");
return xml(error_xml()).into_response();
}
}
let req_fp: Option<[u8; 32]> = peer_fp(&peer);
// Mode-conflict ADMISSION (Stage 4) — GameStream is single-session (`st.launch`), so a DIFFERENT
@@ -232,6 +269,19 @@ async fn h_resume(
tracing::warn!("resume rejected — client is not paired");
return xml(error_xml());
}
// Same access gate as `/launch` (WP13): resuming re-attaches the full input/media planes,
// so it needs the same LAUNCH grant, and expiry fails closed like unpaired.
match peer_grants(&peer, &st) {
Some(g) if g & GRANT_LAUNCH != 0 => {}
Some(_) => {
tracing::warn!("resume rejected — this client's access grants do not include Launch");
return xml(error_xml());
}
None => {
tracing::warn!("resume rejected — this client's access has expired");
return xml(error_xml());
}
}
if !peer_may_control_session(&peer, &st) {
tracing::warn!("resume rejected — caller does not own the session");
return xml(error_xml());
@@ -251,6 +301,15 @@ async fn h_cancel(
tracing::warn!("cancel rejected — client is not paired");
return xml(error_xml());
}
// Expiry gates `/cancel` likewise (an expired record fails closed exactly like unpaired) —
// but the LAUNCH bit deliberately does NOT: cancel is Moonlight's "Quit App", a teardown,
// and `peer_may_control_session` below already restricts it to the session's owner. Denying
// a mid-session-downgraded owner its own quit would only wedge the session it is trying to
// end — ending sessions is what enforcement *wants*.
if peer_grants(&peer, &st).is_none() {
tracing::warn!("cancel rejected — this client's access has expired");
return xml(error_xml());
}
if !peer_may_control_session(&peer, &st) {
tracing::warn!("cancel rejected — caller does not own the session");
return xml(error_xml());
@@ -518,4 +577,187 @@ mod tests {
GsDecision::Reject
));
}
/// A fresh grants registry backed by a per-test temp store.
fn test_registry(
tag: &str,
) -> (
Arc<crate::native_pairing::NativePairing>,
std::path::PathBuf,
) {
let p = std::env::temp_dir().join(format!(
"pf-nvhttp-access-{tag}-{}.json",
std::process::id()
));
let _ = std::fs::remove_file(&p);
let np = Arc::new(
crate::native_pairing::NativePairing::load_with(Some(p.clone()), None, false).unwrap(),
);
(np, p)
}
/// WP13's resolution rule at the nvhttp gate: no grants record = ungoverned full control
/// (existing Moonlight pairings keep today's behavior); a record that exists governs; an
/// expired record resolves `None` — the shape the handlers fail closed exactly like
/// unpaired. Certless peers resolve `None` too (they never pass `peer_is_paired` anyway).
#[test]
fn peer_grants_resolution_rule() {
use crate::native_pairing::Access;
use punktfunk_core::quic::GRANT_GAMEPAD;
let st = test_state();
let der = b"grants-client-der".to_vec();
let fp_hex = fp_of(&der);
let peer = Some(Extension(PeerCertFingerprint(Some(fp_hex.clone()))));
// No registry wired (an AppState that never went through `serve`): pre-grants behavior.
assert_eq!(peer_grants(&peer, &st), Some(GRANT_ALL));
let (np, store) = test_registry("rule");
assert!(st.access.set(np.clone()).is_ok());
// Registry wired, no record: ungoverned.
assert_eq!(peer_grants(&peer, &st), Some(GRANT_ALL));
// A Controller-only record governs — LAUNCH absent.
np.add_with_access(
"Guest",
&fp_hex,
Some(Access {
grants: GRANT_GAMEPAD,
expires_unix: None,
}),
)
.unwrap();
assert_eq!(peer_grants(&peer, &st), Some(GRANT_GAMEPAD));
assert_eq!(peer_grants(&peer, &st).unwrap() & GRANT_LAUNCH, 0);
// Expired: the fail-closed shape.
np.set_access(
&fp_hex,
Access {
grants: GRANT_ALL,
expires_unix: Some(super::super::wall_unix_now() - 5),
},
)
.unwrap();
assert_eq!(peer_grants(&peer, &st), None);
// Certless peer: `None` — it can never reach the grants gate past `peer_is_paired`,
// and failing closed is the right wrong answer if it somehow did.
assert_eq!(peer_grants(&None, &st), None);
assert_eq!(
peer_grants(&Some(Extension(PeerCertFingerprint(None))), &st),
None
);
let _ = std::fs::remove_file(&store);
}
/// The WP13 acceptance at handler level: `/resume` (same gate as `/launch`) works for a
/// paired client with no grants record (stock back-compat), refuses a Controller-only
/// record (no LAUNCH), refuses an expired record exactly like unpaired — and `/cancel`
/// gates on expiry only, so a re-granted limited client can still quit its own session.
#[tokio::test]
async fn resume_and_cancel_honor_grants_and_expiry() {
use crate::native_pairing::Access;
use punktfunk_core::quic::GRANT_GAMEPAD;
async fn body_of(resp: Response) -> String {
let b = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.unwrap();
String::from_utf8(b.to_vec()).unwrap()
}
let st = test_state();
let der = b"resume-grants-client".to_vec();
let fp_hex = fp_of(&der);
let owner_fp = punktfunk_core::quic::endpoint::cert_fingerprint(&der);
st.paired.lock().unwrap().push(der);
let peer = Some(Extension(PeerCertFingerprint(Some(fp_hex.clone()))));
let (np, store) = test_registry("resume");
assert!(st.access.set(np.clone()).is_ok());
let session = LaunchSession {
gcm_key: [0; 16],
rikeyid: 0,
width: 1920,
height: 1080,
fps: 60,
appid: 1,
peer_ip: None,
owner_fp: Some(owner_fp),
};
*st.launch.lock().unwrap() = Some(session);
// No grants record: a stock Moonlight pairing resumes exactly as today.
let ok = body_of(
h_resume(State(st.clone()), peer.clone())
.await
.into_response(),
)
.await;
assert!(ok.contains("<resume>1</resume>"), "ungoverned resume: {ok}");
// Controller-only record: the LAUNCH bit is missing — refused.
let now = super::super::wall_unix_now();
np.add_with_access(
"Guest",
&fp_hex,
Some(Access {
grants: GRANT_GAMEPAD,
expires_unix: Some(now + 3600),
}),
)
.unwrap();
let no = body_of(
h_resume(State(st.clone()), peer.clone())
.await
.into_response(),
)
.await;
assert!(!no.contains("<resume>1</resume>"), "no-LAUNCH resume: {no}");
// Expired full record: fails closed exactly like unpaired — for /resume AND /cancel.
np.set_access(
&fp_hex,
Access {
grants: GRANT_ALL,
expires_unix: Some(now - 5),
},
)
.unwrap();
let no = body_of(
h_resume(State(st.clone()), peer.clone())
.await
.into_response(),
)
.await;
assert!(!no.contains("<resume>1</resume>"), "expired resume: {no}");
let no = body_of(
h_cancel(State(st.clone()), peer.clone())
.await
.into_response(),
)
.await;
assert!(!no.contains("<cancel>1</cancel>"), "expired cancel: {no}");
assert!(
st.launch.lock().unwrap().is_some(),
"a refused cancel must not tear the session down"
);
// Re-granted Controller-only (unexpired, still no LAUNCH): /cancel is deliberately NOT
// LAUNCH-gated — the session's owner may always quit its own app.
np.set_access(
&fp_hex,
Access {
grants: GRANT_GAMEPAD,
expires_unix: Some(now + 3600),
},
)
.unwrap();
let ok = body_of(
h_cancel(State(st.clone()), peer.clone())
.await
.into_response(),
)
.await;
assert!(ok.contains("<cancel>1</cancel>"), "owner cancel: {ok}");
assert!(st.launch.lock().unwrap().is_none(), "cancel tears down");
let _ = std::fs::remove_file(&store);
}
}
@@ -44,9 +44,27 @@ impl PinGate {
}
}
pub fn submit(&self, pin: String) {
/// Deliver the operator's PIN to a parked handshake. Returns `false` (delivering nothing) when
/// more than one handshake is parked.
///
/// The PIN is a single global slot with no binding to a specific handshake, so with N parked
/// waiters whichever polls first takes it. An attacker who floods `getservercert` slots (up to
/// `MAX_PARKED_WAITERS - 1`) while the operator is pairing could therefore take the operator's
/// PIN, derive the ceremony key from its own salt, and pin its own certificate. Real pairing is
/// one operator-driven client at a time, so when the target is ambiguous we refuse rather than
/// hand the secret to a racer — the operator retries once the flood clears. This narrows the
/// window to a tight post-submit timing race; a full fix keys the gate by `uniqueid` (see the
/// design note). security-review 2026-08-15 finding 7.
pub fn submit(&self, pin: String) -> bool {
if self.waiters.load(Ordering::SeqCst) > 1 {
tracing::warn!(
"pairing: more than one handshake is awaiting a PIN — refusing an ambiguous submit"
);
return false;
}
*self.pin.lock().unwrap() = Some(pin);
self.notify.notify_waiters();
true
}
/// True while a pairing handshake is parked waiting for the user's PIN.
@@ -286,6 +286,7 @@ fn handle_request(req: &Request, state: &Arc<AppState>, peer: Option<SocketAddr>
stream::GameLifetime {
quit: state.quit.clone(),
fingerprint: ls.owner_fp.map(hex::encode),
owner_ip: ls.peer_ip,
on_game_exit: {
let st = state.clone();
Arc::new(move || {
@@ -310,6 +311,9 @@ fn handle_request(req: &Request, state: &Arc<AppState>, peer: Option<SocketAddr>
*state.audio_params.lock().unwrap(),
state.audio_cap.clone(),
on_lost,
// Same owner-IP bind as the video plane: only the launching peer's pings are
// honored at the audio endpoint. security-review 2026-08-15 finding 1.
ls.peer_ip,
);
}
response(&req.cseq, &[("Session", "DEADBEEFCAFE;timeout = 90")], None)
+26 -3
View File
@@ -66,6 +66,11 @@ pub struct GameLifetime {
/// Hex cert fingerprint of the paired client that owns the launch, so only it can reclaim its own
/// game. `None` when the peer cert couldn't be read.
pub fingerprint: Option<String>,
/// Source IP of the launching peer ([`super::LaunchSession::peer_ip`]), enforced when the video
/// thread learns its UDP endpoint so an off-path LAN peer cannot win the endpoint race and be
/// handed the (plaintext) video stream. `None` keeps the pre-owner behavior. security-review
/// 2026-08-15 finding 1.
pub owner_ip: Option<std::net::IpAddr>,
/// Ends the whole session, deliberately — the action for "the launched game exited".
pub on_game_exit: super::OnSessionLost,
}
@@ -193,9 +198,27 @@ fn run(
"video: awaiting client ping to learn endpoint"
);
let mut probe = [0u8; 256];
let (_, client) = sock
.recv_from(&mut probe)
.context("video: no client ping within 10s")?;
// Bind only to the launch owner's source IP (LaunchSession::peer_ip), the same owner the
// RTSP/ENet planes enforce. Video is plaintext by design, so without this an off-path LAN peer
// trickling UDP at this port wins the endpoint race in `recv_from` and is handed the desktop.
// `None` keeps the pre-owner behavior. security-review 2026-08-15 finding 1.
let client = {
let deadline = std::time::Instant::now() + Duration::from_secs(10);
loop {
let remaining = deadline.saturating_duration_since(std::time::Instant::now());
if remaining.is_zero() {
anyhow::bail!("video: no client ping from the launch owner within 10s");
}
sock.set_read_timeout(Some(remaining))?;
let (_, src) = sock
.recv_from(&mut probe)
.context("video: no client ping within 10s")?;
if life.owner_ip.is_some_and(|ip| ip != src.ip()) {
continue;
}
break src;
}
};
sock.connect(client)
.context("connect client video endpoint")?;
// Opt-in DSCP/QoS-tag this as the video class (PUNKTFUNK_DSCP=1); the guard keeps the
+9 -2
View File
@@ -259,8 +259,15 @@ fn steam_art_roots() -> Vec<PathBuf> {
/// where every host secret lives.
fn art_path_is_confined(path: &Path) -> bool {
// A UNC value (`\\attacker\share\a.png`) is refused outright: reading it would coerce the host's
// machine account into outbound SMB authentication to a peer of the caller's choosing.
if path.to_string_lossy().starts_with(r"\\") {
// machine account into outbound SMB authentication to a peer of the caller's choosing — and the
// `canonicalize()` below would itself do it. A bare `starts_with(r"\\")` missed the forward-slash
// (`//server/share`) and mixed (`\/`, `/\`) UNC forms Windows accepts equally, so test for ANY two
// leading path separators before touching the filesystem. security-review 2026-08-15 (low: art
// UNC guard is a `\\` string test).
let lossy = path.to_string_lossy();
let bytes = lossy.as_bytes();
let is_sep = |c: u8| c == b'\\' || c == b'/';
if bytes.len() >= 2 && is_sep(bytes[0]) && is_sep(bytes[1]) {
return false;
}
let Ok(real) = path.canonicalize() else {
+5 -1
View File
@@ -326,7 +326,11 @@ fn api_router_parts() -> (Router<Arc<MgmtState>>, utoipa::openapi::OpenApi) {
native::list_native_clients,
native::unpair_all_native_clients
))
.routes(routes!(native::unpair_native_client))
// DELETE and PATCH share `/native/clients/{fingerprint}` — one `routes!`, same rule.
.routes(routes!(
native::unpair_native_client,
native::update_native_client_access
))
.routes(routes!(native::list_pending_devices))
.routes(routes!(native::approve_pending_device))
.routes(routes!(native::deny_pending_device))
+8 -1
View File
@@ -265,6 +265,13 @@ pub(crate) async fn submit_pairing_pin(
"no pairing handshake is waiting for a PIN",
);
}
st.app.pairing.pin.submit(pin.to_string());
if !st.app.pairing.pin.submit(pin.to_string()) {
// More than one handshake is parked, so which one the operator means is ambiguous — the PIN
// slot is unbound (security-review 2026-08-15 #7). Refuse rather than let a racer take it.
return api_error(
StatusCode::CONFLICT,
"more than one client is waiting to pair — retry once only your device is pairing",
);
}
StatusCode::NO_CONTENT.into_response()
}
+303 -25
View File
@@ -2,6 +2,68 @@
//! delegated approval of pending knocks. Split out of the `mgmt` facade (plan §W5).
use super::shared::*;
use crate::native_pairing::{Access, PairedClient};
use punktfunk_core::quic::{
GRANT_ALL, GRANT_PRESET_CONTROLLER_ONLY, GRANT_PRESET_FULL, GRANT_PRESET_VIEW_ONLY,
GRANT_RESERVED,
};
/// Host wall clock, unix seconds — the clock every stored access deadline is expressed in
/// (design §4). The API takes expiry RELATIVE (`expires_in_secs`) and converts here, at handling
/// time, so the client never has to know the host's clock.
fn unix_now() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
/// The absolute deadline a relative expiry means, saturating rather than wrapping on absurd
/// inputs (a u64 of seconds can overflow i64 arithmetic; "effectively forever" is the only
/// sane reading of such a request).
fn absolute_expiry(expires_in_secs: u64) -> i64 {
unix_now().saturating_add(i64::try_from(expires_in_secs).unwrap_or(i64::MAX))
}
/// 400 for a mask with reserved bits set. Rejected, never silently cleared (design §3): a console
/// speaking a NEWER grant vocabulary must learn its bit didn't take, not have it vanish.
fn reject_reserved(grants: u32) -> Option<Response> {
if grants & GRANT_RESERVED != 0 {
return Some(api_error(
StatusCode::BAD_REQUEST,
&format!("grants has reserved bits set (the valid mask is 0x{GRANT_ALL:x})"),
));
}
None
}
/// The operator's access choice from a request's optional `grants` + `expires_in_secs` pair:
/// `None` when neither field is present (back-compat — the request behaves exactly as it did
/// before grants existed), else an [`Access`] with absent halves defaulted the way the dialogs
/// read (`grants` alone = permanent; `expires_in_secs` alone = full control until then) and the
/// relative expiry converted to the absolute deadline the store keeps. The caller has already
/// run [`reject_reserved`] — this only merges.
fn chosen_access(grants: Option<u32>, expires_in_secs: Option<u64>) -> Option<Access> {
if grants.is_none() && expires_in_secs.is_none() {
return None;
}
Some(Access {
grants: grants.unwrap_or(GRANT_ALL),
expires_unix: expires_in_secs.map(absolute_expiry),
})
}
/// The display preset a grant mask amounts to: exactly a preset's mask reads as that preset,
/// anything else is `custom`. Derived from the mask (reserved bits ignored, absent = full — the
/// same reading enforcement uses), never stored: two representations of one fact would drift.
fn access_level(grants: Option<u32>) -> &'static str {
match grants.unwrap_or(GRANT_ALL) & GRANT_ALL {
m if m == GRANT_PRESET_FULL => "full",
m if m == GRANT_PRESET_CONTROLLER_ONLY => "controller",
m if m == GRANT_PRESET_VIEW_ONLY => "view",
_ => "custom",
}
}
/// Native (punktfunk/1) pairing status. Unlike GameStream, the **host** mints the PIN (the SPAKE2
/// ceremony needs it client-side first), so the console **displays** `pin` for the user to enter on
@@ -33,6 +95,16 @@ pub(crate) struct ArmNativePairing {
/// Omit for an unbound window (any device may use the PIN — trusted-LAN only).
#[schema(example = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08")]
fingerprint: Option<String>,
/// Optional access choice for whichever device completes this window's ceremony: a grant
/// bitmask (`GRANT_*` bits 05). Reserved bits are a 400. Omit (with `expires_in_secs`) for
/// today's behavior — a new device gets full control, a re-pairing device keeps what it has.
#[schema(example = 1)]
grants: Option<u32>,
/// Optional access expiry for the pairing device, in seconds **from now** (relative — the
/// host stores the absolute deadline). NOT the pairing window's length; that is `ttl_secs`.
/// Omit for permanent access (when `grants` is set) or preserved access (when neither is).
#[schema(example = 14400)]
expires_in_secs: Option<u64>,
}
/// A paired native (punktfunk/1) client.
@@ -43,6 +115,35 @@ pub(crate) struct NativeClient {
name: String,
/// Hex SHA-256 of the client certificate — its stable id here.
fingerprint: String,
/// Grant bitmask (`GRANT_*` bits 05). `null` = a record from before grants existed, which
/// means full control.
#[schema(example = 1)]
grants: Option<u32>,
/// Absolute access expiry, unix seconds on the host's wall clock. `null` = permanent. Whether
/// it has already passed is the reader's arithmetic — an expired device stays listed (shown
/// as "Expired"), it just isn't authorized.
expires_unix: Option<i64>,
/// When access was last granted, unix seconds — display/audit only, never enforced.
granted_unix: Option<i64>,
/// The preset this device's mask amounts to, for display: `full` | `controller` | `view` |
/// `custom`. Derived from `grants` on the host; absent only on hosts older than the field.
#[schema(example = "controller")]
access_level: Option<String>,
}
impl NativeClient {
/// The response payload for a stored record — one place derives `access_level`, so the list,
/// the approve response, and the access PATCH can never disagree on what a mask is called.
fn from_record(c: PairedClient) -> NativeClient {
NativeClient {
access_level: Some(access_level(c.grants).to_string()),
name: c.name,
fingerprint: c.fingerprint,
grants: c.grants,
expires_unix: c.expires_unix,
granted_unix: c.granted_unix,
}
}
}
/// An unpaired device that tried to connect while the host requires pairing — awaiting
@@ -58,14 +159,56 @@ pub(crate) struct PendingDevice {
fingerprint: String,
/// Seconds since the device last knocked.
age_secs: u64,
/// The grant mask this fingerprint is ALREADY stored with, if it was paired before (the
/// expired-guest re-knock: the approve dialog can offer "re-grant what they had"). `null`
/// when the device is unknown, or known with a pre-grants record (= full).
grants: Option<u32>,
/// The stored record's absolute expiry (unix seconds; likely in the past — that's why it's
/// knocking). `null` when unknown or permanent.
expires_unix: Option<i64>,
/// When the stored record's access was granted (unix seconds). `null` when unknown.
granted_unix: Option<i64>,
/// The stored mask's preset name (`full` | `controller` | `view` | `custom`) — `null` for a
/// device with no stored record, unlike [`NativeClient`] where it is always derivable.
#[schema(example = "controller")]
access_level: Option<String>,
}
/// Approve-pending-device request body. Send `{}` to keep the device's own name.
/// Approve-pending-device request body. Send `{}` to keep the device's own name and — for a
/// re-approved device — its existing access (the full/permanent default for a first pairing).
#[derive(Deserialize, ToSchema)]
pub(crate) struct ApprovePending {
/// Operator-chosen label for the device (defaults to the name it knocked with).
#[schema(example = "Living Room TV")]
name: Option<String>,
/// Access choice: grant bitmask (`GRANT_*` bits 05). Reserved bits are a 400. Omitting BOTH
/// access fields keeps a re-approved device's stored access; `grants` without
/// `expires_in_secs` grants permanently.
#[schema(example = 1)]
grants: Option<u32>,
/// Access expiry in seconds **from now** (relative — the host stores the absolute deadline
/// and stamps the grant time). Alone, it means full control until then.
#[schema(example = 14400)]
expires_in_secs: Option<u64>,
}
/// PATCH body for a paired device's access (the console edit sheet: change the preset, extend,
/// "expire now", make permanent). **Partial**: an omitted `grants` keeps the current grants, and
/// omitted expiry fields keep the current expiry — send only what changes.
#[derive(Deserialize, ToSchema)]
pub(crate) struct UpdateNativeAccess {
/// New grant bitmask (`GRANT_*` bits 05); reserved bits are a 400. Omit to keep the
/// device's current grants.
#[schema(example = 1)]
grants: Option<u32>,
/// New expiry in seconds **from now** (relative; the host stores the absolute deadline).
/// `0` expires the device now. Omit to keep the current expiry. Mutually exclusive with
/// `clear_expiry` (400).
#[schema(example = 14400)]
expires_in_secs: Option<u64>,
/// `true` removes the expiry — access becomes permanent. Mutually exclusive with
/// `expires_in_secs` (400).
clear_expiry: Option<bool>,
}
pub(crate) fn native_status(st: &MgmtState) -> NativePairStatus {
@@ -111,7 +254,8 @@ pub(crate) async fn get_native_pairing(State(st): State<Arc<MgmtState>>) -> Json
/// Arm native pairing
///
/// Opens a pairing window and mints a fresh PIN to display. The user enters it on their device
/// within `ttl_secs`; the device then appears in the native client list.
/// within `ttl_secs`; the device then appears in the native client list. An access choice
/// (`grants` / `expires_in_secs`) applies to whichever device completes this window's ceremony.
#[utoipa::path(
post,
path = "/native/pair/arm",
@@ -120,6 +264,7 @@ pub(crate) async fn get_native_pairing(State(st): State<Arc<MgmtState>>) -> Json
request_body = ArmNativePairing,
responses(
(status = OK, description = "Pairing armed; the response carries the PIN to display", body = NativePairStatus),
(status = BAD_REQUEST, description = "Reserved grant bits set", body = ApiError),
(status = SERVICE_UNAVAILABLE, description = "Native host not available in this process", body = ApiError),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
)
@@ -134,6 +279,11 @@ pub(crate) async fn arm_native_pairing(
"native host not available in this process",
);
};
// Validate the access choice BEFORE arming: a 400 must not leave a window open.
if let Some(resp) = req.grants.and_then(reject_reserved) {
return resp;
}
let access = chosen_access(req.grants, req.expires_in_secs);
let ttl = req.ttl_secs.unwrap_or(120).clamp(15, 600);
// A bound window (operator selected a specific device) is DoS-proof: only that fingerprint can
// consume it (#9). An unbound window (no fingerprint) keeps the legacy any-device behavior.
@@ -144,10 +294,14 @@ pub(crate) async fn arm_native_pairing(
.filter(|s| !s.is_empty())
.map(|s| s.to_ascii_lowercase());
let bound_to_device = bound.is_some();
let _pin = np.arm_for(std::time::Duration::from_secs(ttl as u64), bound);
// The window carries the operator's access choice to whichever device completes the ceremony
// (design §5.7 — the arm dialog is one of the three authorized grant paths); `None` keeps
// today's behavior (full/permanent for a new device, preserved for a re-pairing one).
let _pin = np.arm_for(std::time::Duration::from_secs(ttl as u64), bound, access);
tracing::info!(
ttl_secs = ttl,
bound_to_device,
with_access = access.is_some(),
"management API: native pairing armed"
);
Json(native_status(&st)).into_response()
@@ -193,10 +347,7 @@ pub(crate) async fn list_native_clients(
Some(np) => np
.list()
.into_iter()
.map(|c| NativeClient {
name: c.name,
fingerprint: c.fingerprint,
})
.map(NativeClient::from_record)
.collect(),
None => Vec::new(),
};
@@ -256,6 +407,114 @@ pub(crate) async fn unpair_native_client(
}
}
/// Update a native client's access
///
/// Partial edit of a paired device's grants/expiry (the console edit sheet: preset change,
/// extend, "expire now", make permanent). Omitted fields keep their current value; the edit
/// reaches the device's live sessions immediately. Not a way to pair a device (404 when the
/// fingerprint isn't in the trust store).
#[utoipa::path(
patch,
path = "/native/clients/{fingerprint}",
tag = "native",
operation_id = "updateNativeClientAccess",
params(
("fingerprint" = String, Path,
description = "Hex SHA-256 of the client certificate (case-insensitive)")
),
request_body = UpdateNativeAccess,
responses(
(status = OK, description = "Access updated; the stored record as now in force", body = NativeClient),
(status = BAD_REQUEST, description = "Reserved grant bits set, or expires_in_secs together with clear_expiry", body = ApiError),
(status = NOT_FOUND, description = "No paired native client with that fingerprint", body = ApiError),
(status = SERVICE_UNAVAILABLE, description = "Native host not enabled", body = ApiError),
(status = INTERNAL_SERVER_ERROR, description = "Could not persist the trust store", body = ApiError),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
)
)]
pub(crate) async fn update_native_client_access(
State(st): State<Arc<MgmtState>>,
Path(fingerprint): Path<String>,
ApiJson(req): ApiJson<UpdateNativeAccess>,
) -> Response {
let Some(np) = &st.native else {
return api_error(StatusCode::SERVICE_UNAVAILABLE, "native host not enabled");
};
if let Some(resp) = req.grants.and_then(reject_reserved) {
return resp;
}
if req.clear_expiry == Some(true) && req.expires_in_secs.is_some() {
return api_error(
StatusCode::BAD_REQUEST,
"expires_in_secs and clear_expiry conflict — send one or the other",
);
}
// PATCH semantics: read the current record to fill whichever halves the request omitted —
// `set_access` below overwrites the WHOLE access, so the merge happens here.
let Some(current) = np
.list()
.into_iter()
.find(|c| c.fingerprint.eq_ignore_ascii_case(&fingerprint))
else {
return api_error(
StatusCode::NOT_FOUND,
"no paired native client with that fingerprint",
);
};
let access = Access {
grants: match req.grants {
Some(g) => g,
// The current mask, read the way enforcement reads it (absent = full, reserved
// bits off) — so an expiry-only PATCH re-stores exactly what is in force.
None => current.grants.unwrap_or(GRANT_ALL) & GRANT_ALL,
},
expires_unix: if req.clear_expiry == Some(true) {
None
} else {
match req.expires_in_secs {
Some(s) => Some(absolute_expiry(s)),
None => current.expires_unix,
}
},
};
match np.set_access(&fingerprint, access) {
Ok(true) => {
tracing::info!(
fingerprint,
grants = access.grants,
expires_unix = access.expires_unix,
"management API: native client access updated"
);
// Read the stored record back for the response: the store stamped `granted_unix`,
// and the payload must report what is actually in force. (If the device was
// unpaired in the instant since, fall back to what this edit wrote — the write DID
// land before the removal.)
let stored = np
.list()
.into_iter()
.find(|c| c.fingerprint.eq_ignore_ascii_case(&fingerprint))
.unwrap_or(PairedClient {
name: current.name,
fingerprint: current.fingerprint,
grants: Some(access.grants),
expires_unix: access.expires_unix,
granted_unix: Some(unix_now()),
});
Json(NativeClient::from_record(stored)).into_response()
}
// The store's own unknown-fingerprint answer — the record vanished between the read
// above and the write (an unpair racing this edit).
Ok(false) => api_error(
StatusCode::NOT_FOUND,
"no paired native client with that fingerprint",
),
Err(e) => api_error(
StatusCode::INTERNAL_SERVER_ERROR,
&format!("could not persist trust store: {e}"),
),
}
}
/// Unpair every native client
///
/// The collection form of [`unpair_native_client`]: empties the punktfunk/1 trust store in ONE
@@ -320,19 +579,30 @@ pub(crate) async fn unpair_all_native_clients(State(st): State<Arc<MgmtState>>)
pub(crate) async fn list_pending_devices(
State(st): State<Arc<MgmtState>>,
) -> Json<Vec<PendingDevice>> {
let pending = st
.native
.as_ref()
.map(|np| np.pending())
.unwrap_or_default();
// A knock can come from a fingerprint the store already lists — the expired guest asking
// again. Surfacing that record's access here lets the approve dialog offer "re-grant what
// they had" instead of making the operator reconstruct last night's choice.
let (pending, paired) = match &st.native {
Some(np) => (np.pending(), np.list()),
None => (Vec::new(), Vec::new()),
};
Json(
pending
.into_iter()
.map(|p| PendingDevice {
id: p.id,
name: p.name,
fingerprint: p.fingerprint,
age_secs: p.age_secs,
.map(|p| {
let stored = paired
.iter()
.find(|c| c.fingerprint.eq_ignore_ascii_case(&p.fingerprint));
PendingDevice {
id: p.id,
name: p.name,
fingerprint: p.fingerprint,
age_secs: p.age_secs,
grants: stored.and_then(|c| c.grants),
expires_unix: stored.and_then(|c| c.expires_unix),
granted_unix: stored.and_then(|c| c.granted_unix),
access_level: stored.map(|c| access_level(c.grants).to_string()),
}
})
.collect(),
)
@@ -341,7 +611,9 @@ pub(crate) async fn list_pending_devices(
/// Approve a pending device
///
/// Pairs the device's certificate fingerprint — it can connect immediately (no PIN). Optionally
/// relabel it via the body; send `{}` to keep the name it knocked with.
/// relabel it and/or choose its access via the body; send `{}` to keep the name it knocked with
/// and its existing access (full/permanent for a first pairing). The response is the stored
/// record — what is actually in force, not necessarily this request's inputs.
#[utoipa::path(
post,
path = "/native/pending/{id}/approve",
@@ -350,7 +622,8 @@ pub(crate) async fn list_pending_devices(
params(("id" = u32, Path, description = "Pending-request id from the pending list")),
request_body = ApprovePending,
responses(
(status = OK, description = "Device paired", body = NativeClient),
(status = OK, description = "Device paired; the stored record as now in force", body = NativeClient),
(status = BAD_REQUEST, description = "Reserved grant bits set", body = ApiError),
(status = NOT_FOUND, description = "No pending request with that id (expired?)", body = ApiError),
(status = SERVICE_UNAVAILABLE, description = "Native host not enabled", body = ApiError),
(status = INTERNAL_SERVER_ERROR, description = "Could not persist the trust store", body = ApiError),
@@ -365,15 +638,20 @@ pub(crate) async fn approve_pending_device(
let Some(np) = &st.native else {
return api_error(StatusCode::SERVICE_UNAVAILABLE, "native host not enabled");
};
match np.approve_pending(id, req.name.as_deref()) {
// The approve dialog's access choice (design §5.7 — one of the three authorized grant
// paths). `None` keeps a re-approved device's existing access, or the full/permanent
// default for a first pairing — exactly the pre-grants behavior. A reserved-bit choice is
// refused before anything is consumed — the knock stays pending for a corrected approve.
if let Some(resp) = req.grants.and_then(reject_reserved) {
return resp;
}
let access = chosen_access(req.grants, req.expires_in_secs);
match np.approve_pending(id, req.name.as_deref(), access) {
Ok(Some(client)) => {
tracing::info!(name = %client.name, fingerprint = %client.fingerprint,
with_access = access.is_some(),
"management API: pending device approved (delegated pairing)");
Json(NativeClient {
name: client.name,
fingerprint: client.fingerprint,
})
.into_response()
Json(NativeClient::from_record(client)).into_response()
}
Ok(None) => api_error(
StatusCode::NOT_FOUND,
+384
View File
@@ -1363,6 +1363,14 @@ fn every_route_is_classified_for_the_plugin_and_cert_lanes() {
false,
false,
),
// Editing a device's grants/expiry is pairing administration in both lanes: a plugin
// must not widen (or cut) another device's access, and a paired client even less so.
(
"PATCH",
"/api/v1/native/clients/{fingerprint}",
false,
false,
),
// ---- pairing administration + PIN visibility: the operator's token alone.
("GET", "/api/v1/pair", false, false),
("POST", "/api/v1/pair/pin", false, false),
@@ -1892,6 +1900,382 @@ async fn pending_devices_approve_and_deny() {
assert_eq!(s, StatusCode::NOT_FOUND);
}
fn patch_json(path: &str, body: serde_json::Value) -> axum::http::Request<Body> {
axum::http::Request::patch(path)
.header(axum::http::header::CONTENT_TYPE, "application/json")
.body(Body::from(body.to_string()))
.unwrap()
}
/// Host wall clock, unix seconds — for asserting the relative-in/absolute-stored conversion.
fn wall_now() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs() as i64
}
/// The WP6 acceptance spine: PATCH a device's access → the list reflects it AND the device's
/// live-session watch fires — plus the PATCH's partial semantics (each omitted half keeps its
/// current value; `clear_expiry` makes access permanent) and the `access_level` derivation.
#[tokio::test]
async fn patch_native_access_reflects_in_list_and_fires_watch() {
use punktfunk_core::quic::{GRANT_GAMEPAD, GRANT_POINTER};
let np = Arc::new(
crate::native_pairing::NativePairing::load_with(
Some(std::env::temp_dir().join(format!("pf-mgmt-patch-{}.json", std::process::id()))),
None,
false,
)
.unwrap(),
);
let app = test_app_native(test_state(), np.clone());
np.add("Living Room TV", "aa11").unwrap();
// What a live session holds at admission — the edit must reach it within one event.
let mut rx = np.subscribe("aa11");
// Guest preset: controller-only for 2 hours. Case-insensitive fingerprint, like DELETE.
let now = wall_now();
let (s, b) = send(
&app,
patch_json(
"/api/v1/native/clients/AA11",
serde_json::json!({"grants": GRANT_GAMEPAD, "expires_in_secs": 7200}),
),
)
.await;
assert_eq!(s, StatusCode::OK);
assert_eq!(b["grants"], GRANT_GAMEPAD);
assert_eq!(b["access_level"], "controller");
let deadline = b["expires_unix"].as_i64().unwrap();
assert!(
(now + 7200..=now + 7202).contains(&deadline),
"relative expiry stored as an absolute deadline: {deadline}"
);
assert!(b["granted_unix"].as_i64().unwrap() >= now, "grant stamped");
// The list reflects the same record (one derivation, no drift).
let (_, list) = send(&app, get_req("/api/v1/native/clients")).await;
assert_eq!(list[0]["grants"], GRANT_GAMEPAD);
assert_eq!(list[0]["access_level"], "controller");
assert_eq!(list[0]["expires_unix"].as_i64().unwrap(), deadline);
// The watch fired — a live session from aa11 saw the edit.
assert!(rx.has_changed().unwrap(), "the access watch must fire");
{
let state = rx.borrow_and_update();
assert_eq!(state.grants, GRANT_GAMEPAD);
assert_eq!(state.deadline_unix, Some(deadline));
assert!(!state.revoked);
}
// Partial: a new expiry alone keeps the grants.
let (s, b) = send(
&app,
patch_json(
"/api/v1/native/clients/aa11",
serde_json::json!({"expires_in_secs": 60}),
),
)
.await;
assert_eq!(s, StatusCode::OK);
assert_eq!(b["grants"], GRANT_GAMEPAD, "omitted grants keep current");
let short_deadline = b["expires_unix"].as_i64().unwrap();
assert!(short_deadline < deadline, "the expiry did change");
// Partial: new grants alone keep the expiry — exactly, not re-derived.
let (s, b) = send(
&app,
patch_json(
"/api/v1/native/clients/aa11",
serde_json::json!({"grants": 0}),
),
)
.await;
assert_eq!(s, StatusCode::OK);
assert_eq!(b["access_level"], "view");
assert_eq!(
b["expires_unix"].as_i64().unwrap(),
short_deadline,
"omitted expiry keeps current"
);
// `clear_expiry` makes it permanent; grants (still view) survive.
let (s, b) = send(
&app,
patch_json(
"/api/v1/native/clients/aa11",
serde_json::json!({"clear_expiry": true}),
),
)
.await;
assert_eq!(s, StatusCode::OK);
assert!(b["expires_unix"].is_null(), "clear_expiry = permanent");
assert_eq!(b["access_level"], "view");
// A mask that is no preset reads as `custom`.
let (_, b) = send(
&app,
patch_json(
"/api/v1/native/clients/aa11",
serde_json::json!({"grants": GRANT_GAMEPAD | GRANT_POINTER}),
),
)
.await;
assert_eq!(b["access_level"], "custom");
}
/// The PATCH's refusals: reserved grant bits and the expiry-field conflict are 400s that change
/// nothing, an unknown fingerprint is a 404 (editing access is not a way to pair a device), and
/// no native plane is the usual 503.
#[tokio::test]
async fn patch_native_access_validates_and_404s() {
use punktfunk_core::quic::{GRANT_ALL, GRANT_GAMEPAD};
let np = Arc::new(
crate::native_pairing::NativePairing::load_with(
Some(
std::env::temp_dir().join(format!("pf-mgmt-patch-val-{}.json", std::process::id())),
),
None,
false,
)
.unwrap(),
);
let app = test_app_native(test_state(), np.clone());
np.add("Deck", "bb22").unwrap();
// Reserved bits: 400, never silently cleared — and the record is untouched.
let (s, b) = send(
&app,
patch_json(
"/api/v1/native/clients/bb22",
serde_json::json!({"grants": GRANT_ALL | (1u32 << 30)}),
),
)
.await;
assert_eq!(s, StatusCode::BAD_REQUEST);
assert!(b["error"].as_str().unwrap().contains("reserved"));
assert_eq!(np.list()[0].grants, None, "a 400 writes nothing");
// Conflicting expiry instructions: 400.
let (s, b) = send(
&app,
patch_json(
"/api/v1/native/clients/bb22",
serde_json::json!({"expires_in_secs": 60, "clear_expiry": true}),
),
)
.await;
assert_eq!(s, StatusCode::BAD_REQUEST);
assert!(b["error"].as_str().unwrap().contains("clear_expiry"));
// Unknown fingerprint: 404, and no record appears.
let (s, _) = send(
&app,
patch_json(
"/api/v1/native/clients/nope99",
serde_json::json!({"grants": GRANT_GAMEPAD}),
),
)
.await;
assert_eq!(s, StatusCode::NOT_FOUND);
assert!(!np.is_paired("nope99"), "PATCH must never pair a device");
// No native plane: 503, like every other /native route.
let plain = test_app(test_state(), None);
let (s, _) = send(
&plain,
patch_json(
"/api/v1/native/clients/bb22",
serde_json::json!({"grants": GRANT_GAMEPAD}),
),
)
.await;
assert_eq!(s, StatusCode::SERVICE_UNAVAILABLE);
}
/// Approve-with-access pins the operator's chosen mask (plan WP6 acceptance): the response is
/// the stored record — grants, absolute expiry, stamped grant time — enforcement agrees, and a
/// re-knock from that fingerprint surfaces the stored access in the pending list. A reserved-bit
/// choice is refused WITHOUT consuming the pending entry.
#[tokio::test]
async fn approve_with_access_pins_the_chosen_mask() {
use punktfunk_core::quic::{GRANT_ALL, GRANT_GAMEPAD};
let np = Arc::new(
crate::native_pairing::NativePairing::load_with(
Some(
std::env::temp_dir()
.join(format!("pf-mgmt-approve-acc-{}.json", std::process::id())),
),
None,
false,
)
.unwrap(),
);
let app = test_app_native(test_state(), np.clone());
// A fresh (never-paired) knock carries no stored access for the dialog.
np.note_pending("Guest Phone", "cc33", None);
let (_, pend) = send(&app, get_req("/api/v1/native/pending")).await;
assert!(pend[0]["grants"].is_null());
assert!(pend[0]["access_level"].is_null());
let id = pend[0]["id"].as_u64().unwrap();
// Reserved bits: 400, and the knock is still there to approve properly.
let (s, _) = send(
&app,
post_json(
&format!("/api/v1/native/pending/{id}/approve"),
serde_json::json!({"grants": GRANT_ALL | (1u32 << 31)}),
),
)
.await;
assert_eq!(s, StatusCode::BAD_REQUEST);
assert!(
np.pending_contains("cc33"),
"a 400 must not consume the knock"
);
assert!(!np.is_paired("cc33"));
// The guest preset: controller-only, 4 hours.
let now = wall_now();
let (s, b) = send(
&app,
post_json(
&format!("/api/v1/native/pending/{id}/approve"),
serde_json::json!({"name": "Guest Phone", "grants": GRANT_GAMEPAD, "expires_in_secs": 14400}),
),
)
.await;
assert_eq!(s, StatusCode::OK);
assert_eq!(b["name"], "Guest Phone");
assert_eq!(b["grants"], GRANT_GAMEPAD);
assert_eq!(b["access_level"], "controller");
let deadline = b["expires_unix"].as_i64().unwrap();
assert!((now + 14400..=now + 14402).contains(&deadline));
assert!(b["granted_unix"].as_i64().unwrap() >= now, "grant stamped");
// Enforcement agrees with the payload.
assert_eq!(np.effective("cc33", now), Some(GRANT_GAMEPAD));
// A later re-knock (the expired-guest flow) shows the STORED access to the approve dialog.
np.note_pending("Guest Phone", "cc33", None);
let (_, pend) = send(&app, get_req("/api/v1/native/pending")).await;
assert_eq!(pend[0]["grants"], GRANT_GAMEPAD);
assert_eq!(pend[0]["access_level"], "controller");
assert_eq!(pend[0]["expires_unix"].as_i64().unwrap(), deadline);
}
/// Arm-with-access: the armed window carries the operator's choice (relative expiry already made
/// absolute) and the ceremony inherits it — while a reserved-bit choice is refused BEFORE a
/// window opens.
#[tokio::test]
async fn arm_with_access_ceremony_inherits_the_choice() {
use punktfunk_core::quic::{GRANT_ALL, GRANT_GAMEPAD};
let np = Arc::new(
crate::native_pairing::NativePairing::load_with(
Some(std::env::temp_dir().join(format!("pf-mgmt-arm-acc-{}.json", std::process::id()))),
None,
false,
)
.unwrap(),
);
let app = test_app_native(test_state(), np.clone());
// Reserved bits: 400 and NO window — a rejected request must not leave pairing open.
let (s, _) = send(
&app,
post_json(
"/api/v1/native/pair/arm",
serde_json::json!({"grants": GRANT_ALL | (1u32 << 29)}),
),
)
.await;
assert_eq!(s, StatusCode::BAD_REQUEST);
assert!(!np.status().armed, "a 400 must not arm the window");
let now = wall_now();
let (s, b) = send(
&app,
post_json(
"/api/v1/native/pair/arm",
serde_json::json!({"ttl_secs": 60, "grants": GRANT_GAMEPAD, "expires_in_secs": 3600}),
),
)
.await;
assert_eq!(s, StatusCode::OK);
assert_eq!(b["armed"], true);
let carried = np.armed_access().expect("the window carries the choice");
assert_eq!(carried.grants, GRANT_GAMEPAD);
let deadline = carried.expires_unix.expect("absolute deadline");
assert!((now + 3600..=now + 3602).contains(&deadline));
// The ceremony choke point consumes `armed_access()` (WP2) — pairing under it inherits the
// window's choice, which is then what enforcement sees.
np.add_with_access("Guest Deck", "dd44", np.armed_access())
.unwrap();
assert_eq!(np.effective("dd44", now), Some(GRANT_GAMEPAD));
let (_, list) = send(&app, get_req("/api/v1/native/clients")).await;
assert_eq!(list[0]["access_level"], "controller");
assert_eq!(list[0]["expires_unix"].as_i64().unwrap(), deadline);
}
/// Back-compat: approve and arm WITHOUT the new access fields behave exactly as before grants
/// existed — no explicit choice reaches the store (`None`), so a new device gets the legacy
/// full/permanent record (all access fields absent) and the list derives `full`.
#[tokio::test]
async fn approve_and_arm_without_access_fields_keep_todays_behavior() {
let np = Arc::new(
crate::native_pairing::NativePairing::load_with(
Some(
std::env::temp_dir()
.join(format!("pf-mgmt-acc-compat-{}.json", std::process::id())),
),
None,
false,
)
.unwrap(),
);
let app = test_app_native(test_state(), np.clone());
// Arm with only the legacy fields → the window carries NO access choice.
let (s, _) = send(
&app,
post_json(
"/api/v1/native/pair/arm",
serde_json::json!({"ttl_secs": 60}),
),
)
.await;
assert_eq!(s, StatusCode::OK);
assert_eq!(np.armed_access(), None, "no fields = no choice");
// Approve with only a name → the stored record is the legacy full/permanent one.
np.note_pending("Old Laptop", "ee55", None);
let (_, pend) = send(&app, get_req("/api/v1/native/pending")).await;
let id = pend[0]["id"].as_u64().unwrap();
let (s, b) = send(
&app,
post_json(
&format!("/api/v1/native/pending/{id}/approve"),
serde_json::json!({"name": "Old Laptop"}),
),
)
.await;
assert_eq!(s, StatusCode::OK);
assert!(
b["grants"].is_null(),
"no choice = the absent-grants record"
);
assert!(b["expires_unix"].is_null());
assert!(b["granted_unix"].is_null());
assert_eq!(b["access_level"], "full", "absent grants read as full");
let stored = &np.list()[0];
assert_eq!(stored.grants, None);
assert_eq!(stored.expires_unix, None);
assert_eq!(stored.granted_unix, None);
}
#[tokio::test]
async fn native_endpoints_report_disabled_without_native_host() {
let app = test_app(test_state(), None);
File diff suppressed because it is too large Load Diff
+132 -20
View File
@@ -52,6 +52,13 @@ pub(super) async fn run(
cursor_client_draws: Arc<AtomicBool>,
clip_enabled: Arc<AtomicBool>,
clip: pf_clipboard::ClipCoord,
// Per-client access (design/per-client-access.md §5): the session's LIVE grant mask — the
// same atomic the datagram filter reads; the deadline/watch task folds console edits into
// it, so a `ClipControl` arriving after a mid-session revoke resolves against the new mask.
session_grants: Arc<AtomicU32>,
// `AccessUpdate`s from the session's deadline/watch task (expiry warnings + mid-session
// grant edits) — this task is the control stream's sole writer, so they cross here.
mut access_rx: tokio::sync::mpsc::UnboundedReceiver<punktfunk_core::quic::AccessUpdate>,
) {
let pf_clipboard::ClipCoord {
available: clip_available,
@@ -64,6 +71,9 @@ pub(super) async fn run(
// Same discipline for the wire-MTU watcher's channel — its bounded lifetime ends mid-session
// on every healthy path.
let mut shard_change_closed = false;
// …and for the access-update channel: a session with no fingerprint (an `--open` anonymous
// client) never spawns the deadline/watch task, so the sender drops immediately.
let mut access_closed = false;
let mut active = initial_mode;
// Host-side switch rate limit (a backstop against a hostile/broken client spamming
// Reconfigure into pipeline-rebuild churn — the drain-to-newest in the data plane already
@@ -291,27 +301,14 @@ pub(super) async fn run(
} else if let Ok(ctl) = ClipControl::decode(&msg) {
// Shared clipboard enable/disable (design/clipboard-and-file-transfer.md
// §3.1). Reply with the resolved state; the operator policy is authoritative
// over the client's request. When the policy allows it but no backend bound
// (gamescope / older GNOME), enable is refused with BACKEND_UNAVAILABLE so the
// over the client's request, and the device's CLIPBOARD grant is ANDed into
// it (per-client access §5.4). Refusals carry the honest reason so the
// client can say *why*. The resolved `enabled` gates the coordinator.
let policy = pf_clipboard::policy();
let (enabled, resolved_policy, reason) = match policy {
None => (false, 0, punktfunk_core::quic::CLIP_REASON_POLICY_DISABLED),
Some(p) if ctl.enabled && !clip_available => {
(false, p, punktfunk_core::quic::CLIP_REASON_BACKEND_UNAVAILABLE)
}
Some(p) => {
let files_ok = p & punktfunk_core::quic::CLIP_POLICY_FILES != 0;
let wants_files =
ctl.flags & punktfunk_core::quic::CLIP_FLAG_FILES != 0;
let reason = if wants_files && !files_ok {
punktfunk_core::quic::CLIP_REASON_NO_FILES
} else {
punktfunk_core::quic::CLIP_REASON_OK
};
(ctl.enabled, p, reason)
}
};
let granted = session_grants.load(Ordering::Relaxed)
& punktfunk_core::quic::GRANT_CLIPBOARD
!= 0;
let (enabled, resolved_policy, reason) =
resolve_clip_control(pf_clipboard::policy(), granted, clip_available, ctl);
clip_enabled.store(enabled, Ordering::SeqCst);
// Drive the coordinator: enable re-announces the current host clipboard,
// disable drops any selection we own. A dropped send (inert handle) is fine.
@@ -375,6 +372,21 @@ pub(super) async fn run(
break;
}
}
update = access_rx.recv(), if !access_closed => {
// Per-client access: an expiry warning (T5 m / T1 m) or a mid-session grant
// edit from the session's deadline/watch task — forward to the client
// (best-effort, latest-wins; an old client ignores the unknown message). `None`
// = the task ended (or never existed for an anonymous session): disable this
// branch, the `clip_offer_closed` pattern.
match update {
Some(u) => {
if io::write_msg(&mut ctrl_send, &u.encode()).await.is_err() {
break;
}
}
None => access_closed = true,
}
}
offer = clip_offer_rx.recv(), if !clip_offer_closed => {
// Host copied → the coordinator minted a `ClipOffer`; forward it to the client
// (only while sync is on — a race with a just-received disable would otherwise
@@ -425,3 +437,103 @@ pub(super) async fn run(
}
}
}
/// Resolve a client's [`ClipControl`] against the three authorities, in precedence order:
/// the operator policy (`None` = clipboard off host-wide), the device's `CLIPBOARD` grant
/// (per-client access §5.4 — ANDed into the policy, never overriding it), and backend
/// availability. Returns `(enabled, resolved_policy, reason)` for the [`ClipState`] ack.
///
/// The grant refusal still reports the operator policy bits (like the backend refusal): the
/// client's UI can then say "not permitted for this device" without also greying the file
/// toggle for the wrong reason.
fn resolve_clip_control(
policy: Option<u8>,
granted: bool,
clip_available: bool,
ctl: ClipControl,
) -> (bool, u8, u8) {
match policy {
None => (false, 0, punktfunk_core::quic::CLIP_REASON_POLICY_DISABLED),
Some(p) if !granted => (false, p, punktfunk_core::quic::CLIP_REASON_NOT_PERMITTED),
Some(p) if ctl.enabled && !clip_available => (
false,
p,
punktfunk_core::quic::CLIP_REASON_BACKEND_UNAVAILABLE,
),
Some(p) => {
let files_ok = p & punktfunk_core::quic::CLIP_POLICY_FILES != 0;
let wants_files = ctl.flags & punktfunk_core::quic::CLIP_FLAG_FILES != 0;
let reason = if wants_files && !files_ok {
punktfunk_core::quic::CLIP_REASON_NO_FILES
} else {
punktfunk_core::quic::CLIP_REASON_OK
};
(ctl.enabled, p, reason)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use punktfunk_core::quic::{
CLIP_FLAG_FILES, CLIP_POLICY_FILES, CLIP_POLICY_TEXT, CLIP_REASON_BACKEND_UNAVAILABLE,
CLIP_REASON_NOT_PERMITTED, CLIP_REASON_NO_FILES, CLIP_REASON_OK,
CLIP_REASON_POLICY_DISABLED,
};
const ON: ClipControl = ClipControl {
enabled: true,
flags: 0,
};
/// The three-way clipboard resolution (per-client access WP5): operator policy off →
/// POLICY_DISABLED; policy on but the device's grant unbit → NOT_PERMITTED (grants AND into
/// the policy, and the refusal names the device, not the host); granted + policy on →
/// today's backend/files resolution unchanged.
#[test]
fn clip_resolution_three_way() {
let both = CLIP_POLICY_TEXT | CLIP_POLICY_FILES;
// Operator policy off: the host-wide refusal wins over everything, grant included.
assert_eq!(
resolve_clip_control(None, false, true, ON),
(false, 0, CLIP_REASON_POLICY_DISABLED)
);
assert_eq!(
resolve_clip_control(None, true, true, ON),
(false, 0, CLIP_REASON_POLICY_DISABLED)
);
// Policy on, grant unbit: refused as not-permitted — even with a live backend, and for
// a DISABLE too (an ungranted device can't "resolve" any clipboard state but off).
assert_eq!(
resolve_clip_control(Some(both), false, true, ON),
(false, both, CLIP_REASON_NOT_PERMITTED)
);
// Granted: the pre-grants resolution, unchanged — backend gate…
assert_eq!(
resolve_clip_control(Some(both), true, false, ON),
(false, both, CLIP_REASON_BACKEND_UNAVAILABLE)
);
// …files-vs-policy…
assert_eq!(
resolve_clip_control(
Some(CLIP_POLICY_TEXT),
true,
true,
ClipControl {
enabled: true,
flags: CLIP_FLAG_FILES,
}
),
(true, CLIP_POLICY_TEXT, CLIP_REASON_NO_FILES)
);
// …and the plain enable.
assert_eq!(
resolve_clip_control(Some(both), true, true, ON),
(true, both, CLIP_REASON_OK)
);
}
}
+110
View File
@@ -140,6 +140,78 @@ fn degrade_if_no_uhid(chosen: GamepadPref) -> GamepadPref {
chosen
}
/// Detection half of [`warn_if_ds_inhibit_storm`], split out for tests: `true` when a process
/// named `steamos-manager` is running (its full name fits `comm`'s 15-char limit exactly) AND
/// SELinux is enforcing (the `enforce` file reads `1`). Both halves are required for the storm:
/// a permissive box logs one denial per walk and moves on, and without steamos-manager there is
/// no ds_inhibit to trigger.
#[cfg(target_os = "linux")]
fn ds_inhibit_storm_risk(proc_root: &std::path::Path, enforce: &std::path::Path) -> bool {
let enforcing = std::fs::read_to_string(enforce).is_ok_and(|v| v.trim() == "1");
if !enforcing {
return false;
}
std::fs::read_dir(proc_root)
.ok()
.into_iter()
.flatten()
.flatten()
.any(|e| {
std::fs::read_to_string(e.path().join("comm"))
.is_ok_and(|c| c.trim() == "steamos-manager")
})
}
/// One-shot diagnostic for the Bazzite/SteamOS ds_inhibit audit storm: Valve's `steamos-manager`
/// reacts to every open/close of a `hid-playstation` hidraw — exactly what our virtual
/// DualSense / DualShock 4 is; it has no VID/PID or virtual/uhid filtering — by walking
/// `/proc/*/fd/` to see whether Steam holds the node. Under SELinux enforcing that walk is
/// denied (`steamos_manager_t` lacks `sys_ptrace`/`dac_*`; measured ~324 AVCs/sec on Bazzite),
/// and `setroubleshootd` amplifies the flood into a box-wide fork storm that starves the stream
/// (gamescope 0 fps, encode submit ~150 ms/frame). The audit lines read `comm="tokio-rt-worker"`
/// and look like us — they are steamos-manager's (check `scontext=`).
///
/// Warn-only, never degrade: a per-pad fold has no wire channel back to the client
/// (`Welcome::gamepad` only describes the session default), and it would strip the DS5 feature
/// set on exactly the platform where users want it. The real fix is the shipped SELinux drop-in;
/// this warning cannot see the policy store (root-only), so it fires even where that drop-in is
/// already installed — it names that, and puts the cause in OUR logs so the audit-log trap above
/// doesn't get someone blaming the encoder again.
#[cfg(target_os = "linux")]
fn warn_if_ds_inhibit_storm(chosen: GamepadPref) {
use std::sync::atomic::{AtomicBool, Ordering};
static ONCE: AtomicBool = AtomicBool::new(true);
// Selection criterion is the bound driver (`sony`/`playstation`) plus the touchpad's mouse
// node — i.e. the hid-playstation backends. (The kernel registers the touchpad from its own
// hardcoded DS5/DS4 handling, so no descriptor shaping can duck the selection.)
let playstation = matches!(
chosen,
GamepadPref::DualSense | GamepadPref::DualSenseEdge | GamepadPref::DualShock4
);
if !playstation
|| !ds_inhibit_storm_risk(
std::path::Path::new("/proc"),
std::path::Path::new("/sys/fs/selinux/enforce"),
)
|| !ONCE.swap(false, Ordering::Relaxed)
{
return;
}
tracing::warn!(
gamepad = chosen.as_str(),
"steamos-manager is running and SELinux is enforcing — its ds_inhibit scans /proc on \
every open/close of this pad's hidraw, the scan is denied at hundreds of AVCs/sec, and \
setroubleshootd can amplify that into a box-wide stall that starves the stream. Install \
the shipped SELinux drop-in (`sudo punktfunk-sysext reapply`, or `sudo semodule -i \
/usr/share/punktfunk/selinux/punktfunk-ds-inhibit.cil`) harmless if already installed. \
Masking setroubleshootd (`sudo systemctl mask --now setroubleshootd`) hardens the box \
against any audit flood. Details: packaging/bazzite/README.md."
);
}
#[cfg(not(target_os = "linux"))]
fn warn_if_ds_inhibit_storm(_chosen: GamepadPref) {}
/// The Valve product id (`28DE:xxxx`) a virtual Steam backend enumerates as, or `None` for a
/// non-Steam backend. This is the identity the conflict gate compares against the *physical* Valve
/// devices attached to the host: only a genuine duplicate (same VID **and** PID) confuses Steam
@@ -323,6 +395,10 @@ pub(super) fn resolve_gamepad(pref: GamepadPref) -> GamepadPref {
// The XUSB escape hatch can only present a 360 identity, so the One S / Elite wishes fold when
// `PUNKTFUNK_XBOX_BACKEND=xusb` is set.
let chosen = degrade_xbox_identity(chosen);
// Bazzite/SteamOS heads-up, warn-only (see the fn for why never a degrade): a
// hid-playstation pad on a box running steamos-manager under SELinux enforcing risks the
// ds_inhibit audit storm.
warn_if_ds_inhibit_storm(chosen);
match pref {
GamepadPref::Auto => {
// The operator's env knob deserves a diagnostic when it didn't drive the
@@ -519,4 +595,38 @@ mod tests {
assert_eq!(steam_backend_product(Xbox360), None);
assert_eq!(steam_backend_product(SwitchPro), None);
}
// The ds_inhibit-storm detection needs BOTH halves: steamos-manager running AND SELinux
// enforcing. A permissive box logs one denial per walk without storming, and without
// steamos-manager there is no ds_inhibit — either alone must stay silent.
#[cfg(target_os = "linux")]
#[test]
fn ds_inhibit_storm_risk_needs_both_halves() {
use super::ds_inhibit_storm_risk;
let dir = tempfile::tempdir().unwrap();
let proc_root = dir.path().join("proc");
let enforce = dir.path().join("enforce");
std::fs::create_dir_all(proc_root.join("123")).unwrap();
// Enforcing + steamos-manager present → risk.
std::fs::write(proc_root.join("123/comm"), "steamos-manager\n").unwrap();
std::fs::write(&enforce, "1\n").unwrap();
assert!(ds_inhibit_storm_risk(&proc_root, &enforce));
// Permissive (or SELinux absent — the enforce file unreadable) → no risk.
std::fs::write(&enforce, "0\n").unwrap();
assert!(!ds_inhibit_storm_risk(&proc_root, &enforce));
assert!(!ds_inhibit_storm_risk(
&proc_root,
&dir.path().join("missing")
));
// Enforcing but no steamos-manager (a comm that merely CONTAINS the name must not
// match — the scan compares the whole trimmed comm) → no risk.
std::fs::write(&enforce, "1\n").unwrap();
std::fs::write(proc_root.join("123/comm"), "not-steamos\n").unwrap();
assert!(!ds_inhibit_storm_risk(&proc_root, &enforce));
std::fs::write(proc_root.join("123/comm"), "steamos-managerX\n").unwrap();
assert!(!ds_inhibit_storm_risk(&proc_root, &enforce));
}
}
@@ -135,6 +135,12 @@ pub(super) async fn negotiate(
// `stop`; `quit` rides into the display lease).
quit: Arc<AtomicBool>,
stop: Arc<AtomicBool>,
// Per-client access (design/per-client-access.md §7): the session's effective grant mask and
// saturating seconds until its expiry (`0` = permanent), resolved by `serve_session` at
// admission. Advertised in the Welcome so the client can gate capture / show the
// "Controller only · ends in…" chip; the host enforces the same mask regardless.
grants: u32,
expires_in_secs: u32,
) -> Result<(
Hello,
Welcome,
@@ -662,6 +668,11 @@ pub(super) async fn negotiate(
// advert to find it. `0` on the standalone punktfunk1-host binary (no management API),
// and the client then keeps its compiled-in default.
mgmt_port: crate::mgmt::effective_port(),
// Per-client access (design/per-client-access.md §7): the trust record's effective mask
// and remaining lifetime, resolved by `serve_session` at admission. A full-control
// permanent device advertises `GRANT_ALL, 0` — exactly what every pre-grants client assumes.
grants,
expires_in_secs,
// The negotiated session AEAD (resolved above) + its 32-byte key toward a ChaCha
// client; toward everyone else cipher 0 keeps the Welcome byte-identical to the
// pre-cipher wire form — unless a mgmt port rides along, which forces the cipher
+174 -142
View File
@@ -887,6 +887,12 @@ pub(super) fn input_thread(
inj_tx: std::sync::mpsc::Sender<InputEvent>,
gamepad: GamepadPref,
pad_audio_on: bool,
// The session's LIVE grant mask (per-client access §5.4). The datagram dispatch already
// drops non-granted traffic before it reaches this channel; the guards below are the
// deny-at-SETUP layer — without `GRANT_GAMEPAD` no arm that could create a virtual pad (or
// spawn a pad-audio streamer) ever runs, so a View-only session holds no uinput/ViGEm node
// a dispatch-filter bug could drive. One relaxed load per item.
grants: Arc<AtomicU32>,
) {
let mut pads = Pads::new(gamepad);
// Per-pad 0xD1 audio streamers, live only when the Welcome granted the cap (`pad_audio_on`
@@ -959,7 +965,10 @@ pub(super) fn input_thread(
match rx.recv_timeout(poll) {
// Rich input (touchpad / motion) is applied the moment it arrives; the single channel
// wakes for gyro samples instead of making them wait out the feedback poll interval.
Ok(ClientInput::Rich(rich)) => {
// Guarded on the pad grant like every gamepad arm below — see the `grants` parameter.
Ok(ClientInput::Rich(rich))
if grants.load(Ordering::Relaxed) & punktfunk_core::quic::GRANT_GAMEPAD != 0 =>
{
// Per-pad inter-arrival, unconditionally: one subtraction and one array increment,
// cheap enough that a session no longer has to be re-run with debug logging on to
// answer "is the gyro feed even arriving evenly". The old instrument grew and
@@ -971,159 +980,182 @@ pub(super) fn input_thread(
pads.apply_rich(rich);
}
// Stylus batches apply on arrival like rich input — the tracker synthesizes the
// transitions, the lazily-created virtual tablet renders them.
Ok(ClientInput::Pen(batch)) => pen.apply(&batch),
Ok(ClientInput::Event(ev)) => match ev.kind {
InputKind::GamepadButton | InputKind::GamepadAxis => {
// A bad index / unknown axis just doesn't update a pad — fall through (no
// `continue`) so the rich-input drain + feedback pump below still run every
// iteration (the DualSense GET_REPORT handshake must be serviced promptly).
let idx = ev.flags as usize;
if idx < MAX_WIRE_PADS && pad_state[idx].apply(&ev) {
pad_mask |= 1 << idx;
let frame = pad_state[idx].frame(idx, pad_mask);
pads.handle(&punktfunk_core::input::GamepadEvent::State(frame));
// transitions, the lazily-created virtual tablet renders them. The pen plane is
// pointer-class (core `classify`), and the guard is also its deny-at-setup: a
// session that never passes it never creates the virtual tablet.
Ok(ClientInput::Pen(batch))
if grants.load(Ordering::Relaxed) & punktfunk_core::quic::GRANT_POINTER != 0 =>
{
pen.apply(&batch)
}
// Per-event grant test, the same one the datagram dispatch already ran (one relaxed
// load + the exhaustive `classify`): kept here too so the resource-creating arms
// below (virtual pads, pad-audio streamers) are unreachable without their grant even
// if an upstream filter regresses.
Ok(ClientInput::Event(ev))
if grants.load(Ordering::Relaxed)
& punktfunk_core::quic::classify(ev.kind).bit()
!= 0 =>
{
match ev.kind {
InputKind::GamepadButton | InputKind::GamepadAxis => {
// A bad index / unknown axis just doesn't update a pad — fall through (no
// `continue`) so the rich-input drain + feedback pump below still run every
// iteration (the DualSense GET_REPORT handshake must be serviced promptly).
let idx = ev.flags as usize;
if idx < MAX_WIRE_PADS && pad_state[idx].apply(&ev) {
pad_mask |= 1 << idx;
let frame = pad_state[idx].frame(idx, pad_mask);
pads.handle(&punktfunk_core::input::GamepadEvent::State(frame));
}
}
}
InputKind::GamepadState => {
// Idempotent full-state snapshot from a capable client (see
// `GamepadSnapshot`): applied only when its seq supersedes the last one, so
// a datagram the network reordered can't roll held state backwards. The
// client refreshes touched pads every ~100 ms, so an unchanged refresh is
// the common case — skip the frame emit then (an XInput packet-number bump
// for identical state is pure churn), but always advance the gate.
use punktfunk_core::input::GamepadSnapshot;
if let Some(snap) = GamepadSnapshot::from_event(&ev) {
let idx = snap.pad as usize;
if idx < MAX_WIRE_PADS && GamepadSnapshot::seq_newer(snap.seq, pad_seq[idx])
{
pad_seq[idx] = Some(snap.seq);
let before = pad_state[idx];
pad_state[idx].set_snapshot(&snap);
let first = pad_mask & (1 << idx) == 0;
if first || pad_state[idx] != before {
pad_mask |= 1 << idx;
let frame = pad_state[idx].frame(idx, pad_mask);
pads.handle(&punktfunk_core::input::GamepadEvent::State(frame));
InputKind::GamepadState => {
// Idempotent full-state snapshot from a capable client (see
// `GamepadSnapshot`): applied only when its seq supersedes the last one, so
// a datagram the network reordered can't roll held state backwards. The
// client refreshes touched pads every ~100 ms, so an unchanged refresh is
// the common case — skip the frame emit then (an XInput packet-number bump
// for identical state is pure churn), but always advance the gate.
use punktfunk_core::input::GamepadSnapshot;
if let Some(snap) = GamepadSnapshot::from_event(&ev) {
let idx = snap.pad as usize;
if idx < MAX_WIRE_PADS
&& GamepadSnapshot::seq_newer(snap.seq, pad_seq[idx])
{
pad_seq[idx] = Some(snap.seq);
let before = pad_state[idx];
pad_state[idx].set_snapshot(&snap);
let first = pad_mask & (1 << idx) == 0;
if first || pad_state[idx] != before {
pad_mask |= 1 << idx;
let frame = pad_state[idx].frame(idx, pad_mask);
pads.handle(&punktfunk_core::input::GamepadEvent::State(frame));
}
}
}
}
}
InputKind::GamepadRemove => {
// Mid-session hot-unplug from a snapshot-capable client (the native plane's
// `activeGamepadMask` equivalent). Seq-gated in the SAME per-pad sequence
// space as snapshots, so a snapshot the network reordered past this removal
// is dropped (older seq) and can't resurrect the pad — while a later re-plug
// on the same index arrives with a still-newer seq and is accepted. Clearing
// the `active_mask` bit and re-emitting the frame fires every backend's
// unplug sweep (`inject/*/gamepad.rs`), tearing down just this pad's device.
let (pad, seq) = punktfunk_core::input::decode_gamepad_remove(ev.flags);
let idx = pad as usize;
if idx < MAX_WIRE_PADS
&& punktfunk_core::input::GamepadSnapshot::seq_newer(seq, pad_seq[idx])
{
pad_seq[idx] = Some(seq);
if pad_mask & (1 << idx) != 0 {
pad_mask &= !(1 << idx);
pad_state[idx] = PadState::default();
let frame = pad_state[idx].frame(idx, pad_mask);
pads.handle(&punktfunk_core::input::GamepadEvent::State(frame));
tracing::info!(pad = idx, "gamepad unplugged (native detach)");
}
// Fresh feedback bookkeeping so a later re-plug on this index inherits no
// stale rumble lease (a lease still ticking would buzz the new pad).
//
// `rumble_seq` deliberately SURVIVES — do not reset it here. The client's
// rumble reorder gate (`client/pump/datagram_task.rs`) is per-CONNECTION
// and has no reset path, so restarting this counter strands every later
// envelope for the re-plugged pad until the host climbs back past the
// value the client already stored (up to 128 sends ≈ 15 s of continuous
// rumble, or dozens of separate rumble events). The three clears below are
// what actually kill a stale lease; the sibling `pad_seq` gate keeps its
// value across a removal for exactly the same reason (see the comment at
// the top of this arm).
clear_pad_feedback(
&mut rumble_state[idx],
&mut rumble_seen[idx],
&mut rumble_stop_burst[idx],
);
// The unplugged pad's 0xD1 streamer goes with it (seq-gated like the
// rest of this arm, so a reordered stale removal can't kill the
// stream of a re-plugged pad). A re-plug re-arrives and re-spawns.
pad_streams.stop(idx);
}
}
InputKind::GamepadArrival => {
// Per-pad controller kind declaration (mixed types): route this pad's future
// frames to a backend of the declared kind. `code` = the GamepadPref wire
// byte, `flags` = pad index in the LOW BYTE — bits 8/9 carry the pad's
// audio-render caps (haptics/speaker) from a pad-audio-capable client, so
// the index MUST come from `decode_gamepad_arrival`, never the whole word.
// Applied before the pad's first frame (the client sends it on slot open),
// so the device is built as the right type from the start. The audio caps
// are surfaced here for the 0xD1 capture path (which emits pad audio only
// toward pads that declared a renderer).
let (pad, audio_caps) = punktfunk_core::input::decode_gamepad_arrival(ev.flags);
let idx = pad as usize;
let kind = GamepadPref::from_u8(ev.code as u8);
if audio_caps != 0 {
tracing::debug!(
pad = idx,
haptics = audio_caps & 0x01 != 0,
speaker = audio_caps & 0x02 != 0,
"pad-audio render caps declared (arrival flags bits 8/9)"
);
}
pads.set_kind(idx, kind);
// Pad audio (0xD1): stream toward DualSense-family pads that declared a
// renderer, only on a session that negotiated the cap. Idempotent across
// the arrival re-sends (same kinds keeps the running streamer); a
// re-declare without bits — or as a kind with no pad audio — stops it.
if pad_audio_on {
let want = if matches!(
kind,
GamepadPref::DualSense | GamepadPref::DualSenseEdge
) {
audio_caps
} else {
0
};
if want != 0 {
pad_streams.ensure(
&conn,
pad,
want,
matches!(kind, GamepadPref::DualSenseEdge),
InputKind::GamepadRemove => {
// Mid-session hot-unplug from a snapshot-capable client (the native plane's
// `activeGamepadMask` equivalent). Seq-gated in the SAME per-pad sequence
// space as snapshots, so a snapshot the network reordered past this removal
// is dropped (older seq) and can't resurrect the pad — while a later re-plug
// on the same index arrives with a still-newer seq and is accepted. Clearing
// the `active_mask` bit and re-emitting the frame fires every backend's
// unplug sweep (`inject/*/gamepad.rs`), tearing down just this pad's device.
let (pad, seq) = punktfunk_core::input::decode_gamepad_remove(ev.flags);
let idx = pad as usize;
if idx < MAX_WIRE_PADS
&& punktfunk_core::input::GamepadSnapshot::seq_newer(seq, pad_seq[idx])
{
pad_seq[idx] = Some(seq);
if pad_mask & (1 << idx) != 0 {
pad_mask &= !(1 << idx);
pad_state[idx] = PadState::default();
let frame = pad_state[idx].frame(idx, pad_mask);
pads.handle(&punktfunk_core::input::GamepadEvent::State(frame));
tracing::info!(pad = idx, "gamepad unplugged (native detach)");
}
// Fresh feedback bookkeeping so a later re-plug on this index inherits no
// stale rumble lease (a lease still ticking would buzz the new pad).
//
// `rumble_seq` deliberately SURVIVES — do not reset it here. The client's
// rumble reorder gate (`client/pump/datagram_task.rs`) is per-CONNECTION
// and has no reset path, so restarting this counter strands every later
// envelope for the re-plugged pad until the host climbs back past the
// value the client already stored (up to 128 sends ≈ 15 s of continuous
// rumble, or dozens of separate rumble events). The three clears below are
// what actually kill a stale lease; the sibling `pad_seq` gate keeps its
// value across a removal for exactly the same reason (see the comment at
// the top of this arm).
clear_pad_feedback(
&mut rumble_state[idx],
&mut rumble_seen[idx],
&mut rumble_stop_burst[idx],
);
} else {
// The unplugged pad's 0xD1 streamer goes with it (seq-gated like the
// rest of this arm, so a reordered stale removal can't kill the
// stream of a re-plugged pad). A re-plug re-arrives and re-spawns.
pad_streams.stop(idx);
}
}
}
_ => {
// Track press/release so a mid-press disconnect can be undone below.
match ev.kind {
InputKind::MouseButtonDown if held_buttons.len() < MAX_HELD => {
held_buttons.insert(ev.code);
InputKind::GamepadArrival => {
// Per-pad controller kind declaration (mixed types): route this pad's future
// frames to a backend of the declared kind. `code` = the GamepadPref wire
// byte, `flags` = pad index in the LOW BYTE — bits 8/9 carry the pad's
// audio-render caps (haptics/speaker) from a pad-audio-capable client, so
// the index MUST come from `decode_gamepad_arrival`, never the whole word.
// Applied before the pad's first frame (the client sends it on slot open),
// so the device is built as the right type from the start. The audio caps
// are surfaced here for the 0xD1 capture path (which emits pad audio only
// toward pads that declared a renderer).
let (pad, audio_caps) =
punktfunk_core::input::decode_gamepad_arrival(ev.flags);
let idx = pad as usize;
let kind = GamepadPref::from_u8(ev.code as u8);
if audio_caps != 0 {
tracing::debug!(
pad = idx,
haptics = audio_caps & 0x01 != 0,
speaker = audio_caps & 0x02 != 0,
"pad-audio render caps declared (arrival flags bits 8/9)"
);
}
InputKind::MouseButtonUp => {
held_buttons.remove(&ev.code);
pads.set_kind(idx, kind);
// Pad audio (0xD1): stream toward DualSense-family pads that declared a
// renderer, only on a session that negotiated the cap. Idempotent across
// the arrival re-sends (same kinds keeps the running streamer); a
// re-declare without bits — or as a kind with no pad audio — stops it.
if pad_audio_on {
let want = if matches!(
kind,
GamepadPref::DualSense | GamepadPref::DualSenseEdge
) {
audio_caps
} else {
0
};
if want != 0 {
pad_streams.ensure(
&conn,
pad,
want,
matches!(kind, GamepadPref::DualSenseEdge),
);
} else {
pad_streams.stop(idx);
}
}
InputKind::KeyDown if held_keys.len() < MAX_HELD => {
held_keys.insert(ev.code);
}
InputKind::KeyUp => {
held_keys.remove(&ev.code);
}
_ => {}
}
// Pointer/keyboard → the host-lifetime injector service (one persistent
// portal session for every punktfunk/1 session). A send error only means the
// service thread is gone (host shutting down) — dropping the event is fine,
// input is lossy by design.
let _ = inj_tx.send(ev);
_ => {
// Track press/release so a mid-press disconnect can be undone below.
match ev.kind {
InputKind::MouseButtonDown if held_buttons.len() < MAX_HELD => {
held_buttons.insert(ev.code);
}
InputKind::MouseButtonUp => {
held_buttons.remove(&ev.code);
}
InputKind::KeyDown if held_keys.len() < MAX_HELD => {
held_keys.insert(ev.code);
}
InputKind::KeyUp => {
held_keys.remove(&ev.code);
}
_ => {}
}
// Pointer/keyboard → the host-lifetime injector service (one persistent
// portal session for every punktfunk/1 session). A send error only means the
// service thread is gone (host shutting down) — dropping the event is fine,
// input is lossy by design.
let _ = inj_tx.send(ev);
}
}
},
}
// An item whose grant guard above didn't hold: dropped. Normally unreachable — the
// datagram dispatch filters (and counts, and logs) non-granted traffic before it is
// offered to this channel — so no second counter here; this arm only exists to make
// the guarded arms above sound.
Ok(_) => {}
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
}
+5 -1
View File
@@ -69,6 +69,10 @@ pub(super) async fn pair_ceremony(
// window (no DoS from garbage). The operator re-arms (web console / restart) for the next device —
// including after a successful pair; the protocol gives no reliable host-observable "wrong PIN"
// signal to scope this to failures only (the client just disconnects).
//
// The armed window carries the operator's access choice for whoever completes this ceremony
// (design §5.7) — read it BEFORE the consume below wipes it with the rest of the window.
let access = np.armed_access();
np.disarm();
let proof = tokio::time::timeout(PAIRING_TIMEOUT, io::read_msg(&mut recv))
@@ -81,7 +85,7 @@ pub(super) async fn pair_ceremony(
let ok = pake::verify(&confirms.client, &proof.confirm);
if ok {
if let Err(e) = np.add(&req.name, &fingerprint_hex(&client_fp)) {
if let Err(e) = np.add_with_access(&req.name, &fingerprint_hex(&client_fp), access) {
tracing::error!(error = %format!("{e:#}"), "could not persist paired clients");
}
tracing::info!(name = %name, "pairing complete — client trusted");
+638 -38
View File
@@ -18,11 +18,27 @@
//! clearing the pending knock lives in `approval`, so [`NativePairing::add`] drives both in order
//! (pin, THEN clear + notify) and [`NativePairing::wait_for_decision`] injects an `is_paired` closure
//! into the store-blind approval queue.
//!
//! **Two verbs, two questions** (per-client access, design §3–§5). [`NativePairing::is_paired`]
//! answers "is this device *listed*?" — in the store, expiry-blind; it feeds the approval queue's
//! decision closure, the status count, and the device list, where an expired guest must still
//! appear (as "Expired") rather than vanish. [`NativePairing::effective`] answers "what is this
//! device *authorized* for right now?" — `None` when unpaired or expired, else the grant mask —
//! and is the only verb admission and enforcement may consult. Don't substitute one for the other.
//!
//! Beside the store sits the **access watch registry**: one `tokio::sync::watch` channel per
//! fingerprint carrying [`AccessState`]. Every access mutation (pair, edit, unpair) publishes
//! through it, so a console edit or unpair reaches every live session from that fingerprint
//! within one event (design §5.6); sessions [`NativePairing::subscribe`] at admission.
use anyhow::Result;
use punktfunk_core::quic::GRANT_ALL;
use std::collections::HashMap;
use std::net::IpAddr;
use std::path::PathBuf;
use std::sync::Mutex;
use std::time::Duration;
use tokio::sync::watch;
mod approval;
mod arming;
@@ -31,7 +47,23 @@ mod store;
pub use approval::{PairingDecision, PendingRequest};
pub use arming::PinAttempt;
pub use store::PairedClient;
pub use store::{Access, PairedClient};
/// What a live session observes about its device's access, published through the per-fingerprint
/// watch channel. Carries the record's *raw* deadline rather than a pre-evaluated verdict —
/// expiry is evaluated against the wall clock at each check (design §4), so an "expire now" edit
/// is just a deadline in the past and the session's own deadline task fires on it.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct AccessState {
/// Effective grant mask, reserved bits already cleared. `0` for a revoked device.
pub grants: u32,
/// Absolute deadline, unix seconds host wall clock. `None` = permanent.
pub deadline_unix: Option<i64>,
/// The fingerprint is no longer in the store at all (unpaired) — terminal for the device's
/// sessions: end them, don't merely mute them. Distinct from an edit to view-only
/// (`grants == 0, revoked == false`), where the session survives as a spectator.
pub revoked: bool,
}
/// Re-exported for the stream marker's quoting (its `imp` is `cfg(unix)` — gate alike, or the
/// Windows build trips `-D unused-imports`).
@@ -42,12 +74,26 @@ pub(crate) use sanitize::is_spoofy_char;
/// reaches it there).
pub(crate) use sanitize::sanitize_device_name;
/// Host wall clock, unix seconds — the clock access deadlines are stored in and evaluated
/// against (design §4: wall time at each check, so an NTP step moves a deadline with the clock).
fn unix_now() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
/// Shared native-pairing state: the arming PIN window + the persistent trust store + the
/// pending-approval queue.
pub struct NativePairing {
arm: arming::ArmState,
store: store::TrustStore,
approval: approval::ApprovalQueue,
/// The access watch registry: fingerprint (lowercased) → the channel its live sessions
/// observe. Senders are retained after unpair (the map is bounded by fingerprints ever
/// seen, and retaining avoids a publish/close race with late subscribers); a re-pair
/// publishes fresh state on the same channel.
access_watch: Mutex<HashMap<String, watch::Sender<AccessState>>>,
}
/// A snapshot for the management API / web console.
@@ -73,23 +119,40 @@ impl NativePairing {
arm: arming::ArmState::new(arm_at_start, fixed_pin),
store: store::TrustStore::open(store_path)?,
approval: approval::ApprovalQueue::new(),
access_watch: Mutex::new(HashMap::new()),
})
}
// -- Arming window ------------------------------------------------------
/// Arm pairing with a fresh random PIN, valid for `ttl`, **unbound** (any well-formed attempt
/// consumes it). Returns the PIN to display. Prefer [`Self::arm_for`] with a specific device
/// fingerprint on untrusted LANs — an unbound window is burnable by any peer (#9).
/// consumes it) and with no access choice (whoever pairs gets the full/permanent default).
/// Returns the PIN to display. Prefer [`Self::arm_for`] with a specific device fingerprint on
/// untrusted LANs — an unbound window is burnable by any peer (#9).
pub fn arm(&self, ttl: Duration) -> String {
self.arm.arm_for(ttl, None)
self.arm.arm_for(ttl, None, None)
}
/// Arm pairing with a fresh random PIN, valid for `ttl`. If `bound_fp` is `Some`, the window is
/// bound to that device fingerprint: only a pairing attempt from it consumes the window, so an
/// unrelated (attacker) fingerprint can neither pair nor burn the window (#9). Returns the PIN.
pub fn arm_for(&self, ttl: Duration, bound_fp: Option<String>) -> String {
self.arm.arm_for(ttl, bound_fp)
/// unrelated (attacker) fingerprint can neither pair nor burn the window (#9). `access` is the
/// operator's choice for whichever device completes this window's ceremony (the arm dialog is
/// one of the three authorized grant paths, design §5.7); `None` = the full/permanent default.
/// Returns the PIN.
pub fn arm_for(
&self,
ttl: Duration,
bound_fp: Option<String>,
access: Option<Access>,
) -> String {
self.arm.arm_for(ttl, bound_fp, access)
}
/// The access choice the current armed window carries (`None` when disarmed, expired, or armed
/// without a choice). The PIN ceremony reads this **before** consuming the single-use window —
/// [`Self::disarm`] wipes it along with the PIN.
pub fn armed_access(&self) -> Option<Access> {
self.arm.armed_access()
}
/// Resolve the PIN for an attempt from `client_fp_hex`, honoring fingerprint binding (#9):
@@ -123,46 +186,195 @@ impl NativePairing {
// -- Trust store --------------------------------------------------------
/// Is this client (hex SHA-256 fingerprint) in the paired set?
/// Is this client (hex SHA-256 fingerprint) in the paired set? **Listed, not authorized**:
/// expiry-blind on purpose (an expired guest still shows in the device list and still
/// short-circuits the approval queue's paired-check). Admission and enforcement must ask
/// [`Self::effective`] instead — see the module header's two-verbs contract.
pub fn is_paired(&self, fp_hex: &str) -> bool {
self.store.is_paired(fp_hex)
}
/// Record a successful pairing (re-pairing the same fingerprint just updates the name). The name
/// is sanitized (untrusted); a persist failure rolls the in-memory store back. Pins the
/// fingerprint in the store FIRST, then clears any pending knock for it and wakes parked waiters
/// — an order [`Self::wait_for_decision`] relies on (a woken waiter must observe the fully
/// settled state: paired = true, no longer pending).
/// The grant mask this fingerprint is authorized for at `now_unix` (host wall clock, unix
/// seconds): `None` when unpaired OR expired; `Some(mask)` otherwise, with absent grants
/// meaning full control and reserved bits already masked off. The admission gate's verb
/// (WP3) — see the module header's two-verbs contract.
pub fn effective(&self, fp_hex: &str, now_unix: i64) -> Option<u32> {
self.store.effective(fp_hex, now_unix)
}
/// The Moonlight/GameStream plane's resolution verb (design §8, WP13). The grants registry
/// serves BOTH paired stores, keyed on fingerprint hex — but the two planes read an ABSENT
/// record oppositely. A native fingerprint with no record is unpaired (fail closed,
/// [`Self::effective`]); a GameStream fingerprint's pairing authority is its own cert list
/// (`gamestream::AppState::paired`), so no record here means **ungoverned** — an existing
/// Moonlight pairing keeps full control (back-compat, plan §8 risk table). A record that
/// EXISTS governs exactly as on the native plane: its mask applies (reserved bits cleared)
/// and expiry answers `None`, which the caller's gate fails closed like an unpaired cert.
///
/// One store snapshot on purpose: an `is_paired` + `effective` pair would race a
/// concurrent record deletion into reading "listed but expired" for a record that just
/// became ungoverned.
pub fn moonlight_effective(&self, fp_hex: &str, now_unix: i64) -> Option<u32> {
match self.store.get(fp_hex) {
None => Some(GRANT_ALL),
// Same evaluation as `TrustStore::effective` (the deadline second itself is
// expired; absent grants = full; mask reserved bits on read).
Some(c) => {
if c.expires_unix.is_some_and(|t| now_unix >= t) {
None
} else {
Some(c.grants.unwrap_or(GRANT_ALL) & GRANT_ALL)
}
}
}
}
/// Record a successful pairing with no explicit access choice: for a NEW fingerprint, the
/// full/permanent default; for an existing one, **name-only** — grants and expiry are
/// preserved, so a limited guest re-running the ceremony cannot escalate itself back to full
/// control (design §5.7). Access is chosen through [`Self::add_with_access`] /
/// [`Self::set_access`] / [`Self::approve_pending`], all operator-driven.
pub fn add(&self, name: &str, fp_hex: &str) -> Result<()> {
self.store.add(name, fp_hex)?;
self.add_with_access(name, fp_hex, None)
}
/// Record a successful pairing, optionally with the operator's access choice (`Some` replaces
/// the record's access — the operator just chose anew; `None` is [`Self::add`]'s preserving
/// behavior). The name is sanitized (untrusted); a persist failure rolls the in-memory store
/// back. Pins the fingerprint in the store FIRST, then clears any pending knock for it and
/// wakes parked waiters — an order [`Self::wait_for_decision`] relies on (a woken waiter must
/// observe the fully settled state: paired = true, no longer pending) — then publishes the
/// (possibly refreshed) access to any live watchers.
pub fn add_with_access(&self, name: &str, fp_hex: &str, access: Option<Access>) -> Result<()> {
self.store.add_with_access(name, fp_hex, access)?;
self.approval.admit_and_clear(fp_hex);
// The one choke point every successful pairing passes through (PIN ceremony AND
// delegated approval), so the lifecycle event fires exactly once per pairing.
let device = crate::events::DeviceRef {
name: sanitize_device_name(name, fp_hex),
fingerprint: fp_hex.to_string(),
plane: crate::events::Plane::Native,
};
crate::events::emit(crate::events::EventKind::PairingCompleted {
device: crate::events::DeviceRef {
name: sanitize_device_name(name, fp_hex),
fingerprint: fp_hex.to_string(),
plane: crate::events::Plane::Native,
},
device: device.clone(),
});
// `access.granted` fires only for an EXPLICIT operator choice (approve dialog, arm
// window) — this is likewise the one choke point both of those pass through. Read the
// stored record back for the payload: the store masked reserved bits and stamped the
// grant time, so the event must report what is actually in force. A choice-less pairing
// (preserved or default access) emits only `pairing.completed` above.
if access.is_some() {
if let Some(stored) = self.store.get(fp_hex) {
crate::events::emit(crate::events::EventKind::AccessGranted {
device,
grants: stored.grants.unwrap_or(GRANT_ALL) & GRANT_ALL,
expires_unix: stored.expires_unix,
});
}
}
self.publish_current(fp_hex);
Ok(())
}
/// Overwrite a paired device's access (the console edit sheet / extend / "expire now"):
/// persists to the store, then publishes to the fingerprint's live sessions — within one
/// watch event, per design §5.6. Returns `false` (writing and publishing nothing) for an
/// unknown fingerprint: editing access is not a way to pair a device.
pub fn set_access(&self, fp_hex: &str, access: Access) -> Result<bool> {
if !self.store.set_access(fp_hex, access)? {
return Ok(false);
}
// The edit-sheet choke point (design §6 events): read the stored record back — the store
// is what masked reserved bits — so hooks see what is actually in force.
if let Some(stored) = self.store.get(fp_hex) {
crate::events::emit(crate::events::EventKind::AccessChanged {
device: crate::events::DeviceRef {
name: stored.name,
fingerprint: fp_hex.to_ascii_lowercase(),
plane: crate::events::Plane::Native,
},
grants: stored.grants.unwrap_or(GRANT_ALL) & GRANT_ALL,
expires_unix: stored.expires_unix,
});
}
self.publish_current(fp_hex);
Ok(true)
}
/// Subscribe to a fingerprint's access — sessions call this at admission (WP3) and fold every
/// change into their live enforcement mask/deadline. The receiver's current value is the
/// state *now* (unpaired ⇒ already `revoked`); every subsequent mutation (pair, edit, unpair)
/// arrives as a change notification. Grants-blind callers never need this; it exists for the
/// session lifecycle.
pub fn subscribe(&self, fp_hex: &str) -> watch::Receiver<AccessState> {
let mut map = self.access_watch.lock().unwrap();
map.entry(fp_hex.to_ascii_lowercase())
.or_insert_with(|| watch::channel(self.current_state(fp_hex)).0)
.subscribe()
}
/// The watch payload for a fingerprint as the store stands: the record's masked grants and
/// raw deadline, or the terminal `revoked` state when it isn't listed.
fn current_state(&self, fp_hex: &str) -> AccessState {
match self.store.get(fp_hex) {
Some(c) => AccessState {
grants: c.grants.unwrap_or(GRANT_ALL) & GRANT_ALL,
deadline_unix: c.expires_unix,
revoked: false,
},
None => AccessState {
grants: 0,
deadline_unix: None,
revoked: true,
},
}
}
/// Publish the store's current state for `fp_hex` to its watchers (deduplicated — an access
/// mutation that lands on the same value wakes nobody). No-op when nothing ever subscribed
/// to this fingerprint and nothing changed it before: the channel is minted lazily.
fn publish_current(&self, fp_hex: &str) {
let state = self.current_state(fp_hex);
let mut map = self.access_watch.lock().unwrap();
let tx = map
.entry(fp_hex.to_ascii_lowercase())
.or_insert_with(|| watch::channel(state).0);
tx.send_if_modified(|cur| {
if *cur == state {
false
} else {
*cur = state;
true
}
});
}
/// The paired clients (for the management API's device list).
pub fn list(&self) -> Vec<PairedClient> {
self.store.list()
}
/// Remove a paired client by fingerprint. Returns whether one was removed. On a persist
/// failure the in-memory store is rolled back (it never diverges from disk).
/// failure the in-memory store is rolled back (it never diverges from disk). A removal
/// publishes the terminal `revoked` state so the device's live sessions can end themselves
/// (design §5.6 — unpair reaches every live session within one event).
pub fn remove(&self, fp_hex: &str) -> Result<bool> {
self.store.remove(fp_hex)
let removed = self.store.remove(fp_hex)?;
if removed {
self.publish_current(fp_hex);
}
Ok(removed)
}
/// Remove EVERY paired client in one persisted write. Returns the fingerprints removed, so the
/// caller can end the sessions they own. On a persist failure nothing is removed.
/// caller can end the sessions they own. On a persist failure nothing is removed. Publishes
/// `revoked` for each removed fingerprint, like [`Self::remove`].
pub fn remove_all(&self) -> Result<Vec<String>> {
self.store.remove_all()
let removed = self.store.remove_all()?;
for fp in &removed {
self.publish_current(fp);
}
Ok(removed)
}
// -- Delegated approval (roadmap §8b-1) ---------------------------------
@@ -199,24 +411,29 @@ impl NativePairing {
}
/// Approve a pending knock: pair its fingerprint (under `name_override` if the operator labeled
/// it, else the knock's own name) and drop it from the queue. `Ok(None)` = no such (or expired)
/// id. Reads (does NOT pre-remove) the entry, then [`Self::add`] pins the fingerprint and clears
/// the pending entry — an order a parked waiter relies on (see [`Self::wait_for_decision`]).
/// it, else the knock's own name) and drop it from the queue. `access` is the approve dialog's
/// choice — one of the three authorized grant paths (design §5.7); `None` keeps a re-approved
/// device's existing access, or the full/permanent default for a first pairing. `Ok(None)` = no
/// such (or expired) id. Reads (does NOT pre-remove) the entry, then [`Self::add_with_access`]
/// pins the fingerprint and clears the pending entry — an order a parked waiter relies on (see
/// [`Self::wait_for_decision`]). Returns the stored record, access fields included.
pub fn approve_pending(
&self,
id: u32,
name_override: Option<&str>,
access: Option<Access>,
) -> Result<Option<PairedClient>> {
let (knock_name, fp_hex) = match self.approval.read_entry(id) {
Some(x) => x,
None => return Ok(None),
};
let name = name_override.unwrap_or(&knock_name).to_string();
self.add(&name, &fp_hex)?; // pins, clears the pending entry, and notifies waiters
Ok(Some(PairedClient {
name,
fingerprint: fp_hex,
}))
self.add_with_access(&name, &fp_hex, access)?; // pins, clears the entry, notifies waiters
// Read the record back rather than assembling it here: for `access == None` on a re-pair
// the store kept the device's previous grants/expiry, and the caller (the mgmt approve
// response) must see what is actually in force, not this call's inputs.
Ok(self.store.get(&fp_hex))
}
/// Deny (drop) a pending knock. Returns whether one was removed. The device's next knock
@@ -241,9 +458,18 @@ impl NativePairing {
/// Park (async) until an operator decides on a knock identified by `fp_hex`, up to `timeout`.
/// `knock_seq` is the generation [`Self::note_pending`] returned for THIS connection's knock.
/// The store-blind approval queue is handed an `is_paired` closure so it can resolve
/// The store-blind approval queue is handed a paired-check closure so it can resolve
/// [`PairingDecision::Approved`] the instant the fingerprint pairs. See
/// [`approval::ApprovalQueue::wait_for_decision`] for the full decision contract.
///
/// The closure answers with [`Self::effective`], NOT [`Self::is_paired`]: only a knock from
/// an *unauthorized* device ever parks here, and an EXPIRED guest's record is still *listed*
/// — resolving on the listing would "admit" its knock instantly, before the operator
/// re-grants, and the session would then fail admission's own effective-check with a typed
/// expiry close. Parking until the record is effective again makes re-approval (which
/// refreshes access) — or a console re-grant — the thing that admits, per design §4.
/// (Deliberate corollary: a bare PIN re-pair, which preserves an expired record's access
/// per §5.7, does not admit a parked knock — the device stays unauthorized either way.)
pub async fn wait_for_decision(
&self,
fp_hex: &str,
@@ -251,7 +477,9 @@ impl NativePairing {
timeout: Duration,
) -> PairingDecision {
self.approval
.wait_for_decision(fp_hex, knock_seq, timeout, |fp| self.store.is_paired(fp))
.wait_for_decision(fp_hex, knock_seq, timeout, |fp| {
self.store.effective(fp, unix_now()).is_some()
})
.await
}
@@ -337,11 +565,11 @@ mod tests {
np.note_pending("device bb22", "BB22", None);
let id = np.pending()[0].id;
assert!(
np.approve_pending(9999, None).unwrap().is_none(),
np.approve_pending(9999, None, None).unwrap().is_none(),
"unknown id"
);
let client = np
.approve_pending(id, Some("Living Room"))
.approve_pending(id, Some("Living Room"), None)
.unwrap()
.unwrap();
assert_eq!(client.name, "Living Room");
@@ -433,7 +661,9 @@ mod tests {
.find(|x| x.fingerprint == "ab01")
.unwrap()
.id;
np.approve_pending(id, Some("Approved")).unwrap().unwrap();
np.approve_pending(id, Some("Approved"), None)
.unwrap()
.unwrap();
assert_eq!(waiter.await.unwrap(), PairingDecision::Approved);
assert!(np.is_paired("ab01"));
@@ -464,6 +694,64 @@ mod tests {
let _ = std::fs::remove_file(&p);
}
/// An EXPIRED record's knock must PARK (design §4): the record is still *listed*, and a
/// paired-check that resolved on the listing would "admit" the knock instantly, before any
/// re-grant — the session then just dies on admission's effective-check. Only the operator's
/// re-approval (which refreshes access) may resolve the waiter.
#[tokio::test]
async fn expired_record_parks_until_regrant() {
use punktfunk_core::quic::GRANT_GAMEPAD;
use std::sync::Arc;
let p = temp();
let _ = std::fs::remove_file(&p);
let np = Arc::new(NativePairing::load_with(Some(p.clone()), None, false).unwrap());
np.add_with_access(
"Old Guest",
"aa77",
Some(Access {
grants: GRANT_ALL,
expires_unix: Some(wall_now() - 10),
}),
)
.unwrap();
assert!(np.is_paired("aa77"), "expired but still listed");
// No re-grant → the waiter times out; the stale listing must not admit it.
let seq = np.note_pending("Old Guest", "aa77", None);
let d = np
.wait_for_decision("aa77", seq, Duration::from_millis(120))
.await;
assert_eq!(d, PairingDecision::TimedOut);
// Re-approval with fresh access resolves a parked waiter — re-approval IS the re-grant.
let seq = np.note_pending("Old Guest", "aa77", None);
let np2 = np.clone();
let waiter = tokio::spawn(async move {
np2.wait_for_decision("aa77", seq, Duration::from_secs(5))
.await
});
tokio::time::sleep(Duration::from_millis(30)).await;
let id = np
.pending()
.into_iter()
.find(|x| x.fingerprint == "aa77")
.unwrap()
.id;
np.approve_pending(
id,
None,
Some(Access {
grants: GRANT_GAMEPAD,
expires_unix: Some(wall_now() + 3600),
}),
)
.unwrap()
.unwrap();
assert_eq!(waiter.await.unwrap(), PairingDecision::Approved);
assert_eq!(np.effective("aa77", wall_now()), Some(GRANT_GAMEPAD));
let _ = std::fs::remove_file(&p);
}
/// One Approve must admit exactly ONE session: a re-knock supersedes the previous parked
/// waiter (it resolves `Superseded` immediately, not at timeout), the console list keeps a
/// single entry, and a stale-generation waiter that polls only AFTER the approval still
@@ -504,7 +792,7 @@ mod tests {
.find(|x| x.fingerprint == "ee01")
.unwrap()
.id;
np.approve_pending(id, None).unwrap().unwrap();
np.approve_pending(id, None, None).unwrap().unwrap();
assert_eq!(waiter2.await.unwrap(), PairingDecision::Approved);
// A stale-generation waiter polling only after the approval (entry cleared, fingerprint
@@ -529,7 +817,7 @@ mod tests {
assert!(matches!(np.pin_for_attempt("bb22"), PinAttempt::Pin(_)));
// Bound to AA11: only that fp (case-insensitive) gets the PIN; another fp is BoundToOther —
// the caller rejects it WITHOUT consuming the window.
let pin = np.arm_for(Duration::from_secs(60), Some("AA11".into()));
let pin = np.arm_for(Duration::from_secs(60), Some("AA11".into()), None);
assert!(matches!(np.pin_for_attempt("aa11"), PinAttempt::Pin(x) if x == pin));
assert!(matches!(
np.pin_for_attempt("bb22"),
@@ -573,4 +861,316 @@ mod tests {
assert!(np.pending().len() <= PENDING_CAP, "global cap still holds");
let _ = std::fs::remove_file(&p);
}
fn wall_now() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs() as i64
}
/// A store written before grants existed (name + fingerprint only) must decode unchanged and
/// mean what it always meant: full control, forever.
#[test]
fn pre_grants_store_decodes_as_full_permanent() {
let p = temp();
let _ = std::fs::remove_file(&p);
std::fs::write(
&p,
br#"{ "clients": [ { "name": "Old Laptop", "fingerprint": "ab12" } ] }"#,
)
.unwrap();
let np = NativePairing::load_with(Some(p.clone()), None, false).unwrap();
let listed = np.list();
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].name, "Old Laptop");
assert_eq!(listed[0].grants, None, "absent stays absent");
assert_eq!(listed[0].expires_unix, None);
assert_eq!(listed[0].granted_unix, None);
assert!(np.is_paired("AB12"));
assert_eq!(
np.effective("AB12", wall_now()),
Some(GRANT_ALL),
"absent grants = full control"
);
let _ = std::fs::remove_file(&p);
}
/// THE security property of WP2 (design §5.7, plan §8 risk table): re-running the pairing
/// ceremony must never widen access. `add()` — the ceremony choke point when no operator
/// choice is in play — is name-only for an existing fingerprint. If this test fails, a guest
/// limited to Controller · tonight can re-pair itself back to full control, forever.
#[test]
fn repair_via_add_never_escalates() {
use punktfunk_core::quic::GRANT_GAMEPAD;
let p = temp();
let _ = std::fs::remove_file(&p);
let np = NativePairing::load_with(Some(p.clone()), None, false).unwrap();
let now = wall_now();
let guest = Access {
grants: GRANT_GAMEPAD,
expires_unix: Some(now + 3600),
};
np.add_with_access("Guest Deck", "aa11", Some(guest))
.unwrap();
let before = np.list()[0].clone();
assert_eq!(before.grants, Some(GRANT_GAMEPAD));
assert_eq!(before.expires_unix, Some(now + 3600));
assert!(before.granted_unix.is_some());
// The guest re-pairs (new ceremony, no operator access choice): name updates, NOTHING else.
np.add("Guest Deck Again", "AA11").unwrap();
assert_eq!(np.list().len(), 1, "re-pair must not duplicate");
let after = np.list()[0].clone();
assert_eq!(after.name, "Guest Deck Again");
assert_eq!(after.grants, before.grants, "re-pair must NOT touch grants");
assert_eq!(
after.expires_unix, before.expires_unix,
"re-pair must NOT touch expiry"
);
assert_eq!(
after.granted_unix, before.granted_unix,
"re-pair must NOT re-stamp the grant time"
);
assert_eq!(
np.effective("aa11", now),
Some(GRANT_GAMEPAD),
"still controller-only after the re-pair"
);
// And the limitation survives a restart (it's the persisted record, not memory).
drop(np);
let np = NativePairing::load_with(Some(p.clone()), None, false).unwrap();
assert_eq!(np.effective("aa11", now), Some(GRANT_GAMEPAD));
let _ = std::fs::remove_file(&p);
}
/// The authorized paths DO set access: `add_with_access(Some)` on the ceremony, and the
/// approve dialog's choice through `approve_pending` — whose return value reports what is
/// actually stored.
#[test]
fn approve_with_access_pins_the_choice() {
use punktfunk_core::quic::GRANT_GAMEPAD;
let p = temp();
let _ = std::fs::remove_file(&p);
let np = NativePairing::load_with(Some(p.clone()), None, false).unwrap();
let now = wall_now();
np.note_pending("device bb22", "BB22", None);
let id = np.pending()[0].id;
let client = np
.approve_pending(
id,
Some("Guest Phone"),
Some(Access {
grants: GRANT_GAMEPAD,
expires_unix: Some(now + 4 * 3600),
}),
)
.unwrap()
.unwrap();
assert_eq!(client.name, "Guest Phone");
assert_eq!(client.grants, Some(GRANT_GAMEPAD));
assert_eq!(client.expires_unix, Some(now + 4 * 3600));
assert!(client.granted_unix.is_some());
assert_eq!(np.effective("bb22", now), Some(GRANT_GAMEPAD));
let _ = std::fs::remove_file(&p);
}
/// Expiry ends *authorization*, not *listing*: `effective()` flips to `None` at the deadline
/// while `is_paired()`/`list()` keep the row (the console shows "Expired"; re-grant is one
/// click, not a mysterious disappearance — design §4).
#[test]
fn expiry_flips_effective_but_keeps_the_row() {
use punktfunk_core::quic::GRANT_GAMEPAD;
let p = temp();
let _ = std::fs::remove_file(&p);
let np = NativePairing::load_with(Some(p.clone()), None, false).unwrap();
let now = wall_now();
np.add_with_access(
"Evening Guest",
"cc33",
Some(Access {
grants: GRANT_GAMEPAD,
expires_unix: Some(now + 10),
}),
)
.unwrap();
assert_eq!(np.effective("cc33", now), Some(GRANT_GAMEPAD));
assert_eq!(
np.effective("cc33", now + 9),
Some(GRANT_GAMEPAD),
"still authorized one second before the deadline"
);
assert_eq!(
np.effective("cc33", now + 10),
None,
"the deadline itself expires"
);
assert_eq!(np.effective("cc33", now + 3600), None);
assert!(np.is_paired("cc33"), "expired but still LISTED");
assert_eq!(np.list().len(), 1, "the row survives for the console");
let _ = std::fs::remove_file(&p);
}
/// A store edited by a future host version (or by hand) can't smuggle reserved bits into this
/// version's enforcement: `effective()` and the watch state mask with GRANT_ALL on read.
#[test]
fn reserved_bits_are_masked_on_read() {
let p = temp();
let _ = std::fs::remove_file(&p);
let np = NativePairing::load_with(Some(p.clone()), None, false).unwrap();
np.add("Future Device", "dd44").unwrap();
let from_the_future = Access {
grants: GRANT_ALL | (1 << 30),
expires_unix: None,
};
assert!(np.set_access("dd44", from_the_future).unwrap());
assert_eq!(
np.effective("dd44", wall_now()),
Some(GRANT_ALL),
"reserved bit masked off on read"
);
assert_eq!(np.subscribe("dd44").borrow().grants, GRANT_ALL);
// Editing access is not a way to pair a device.
assert!(!np.set_access("nope99", from_the_future).unwrap());
assert!(!np.is_paired("nope99"));
let _ = std::fs::remove_file(&p);
}
/// The watch registry: an edit reaches a live subscriber within one event, and unpair
/// publishes the terminal `revoked` state (design §5.6). Re-pairing publishes fresh state on
/// the same channel.
#[tokio::test]
async fn watch_publishes_on_set_access_and_unpair() {
use punktfunk_core::quic::GRANT_GAMEPAD;
let p = temp();
let _ = std::fs::remove_file(&p);
let np = NativePairing::load_with(Some(p.clone()), None, false).unwrap();
np.add("Living Room", "ee55").unwrap();
// Subscribe under a different hex case — the registry keys case-insensitively, like the
// store.
let mut rx = np.subscribe("EE55");
assert_eq!(
*rx.borrow(),
AccessState {
grants: GRANT_ALL,
deadline_unix: None,
revoked: false
},
"initial value is the state now"
);
let now = wall_now();
np.set_access(
"ee55",
Access {
grants: GRANT_GAMEPAD,
expires_unix: Some(now + 60),
},
)
.unwrap();
rx.changed().await.unwrap();
assert_eq!(
*rx.borrow(),
AccessState {
grants: GRANT_GAMEPAD,
deadline_unix: Some(now + 60),
revoked: false
}
);
// Unpair: the terminal publish a live session ends itself on.
assert!(np.remove("ee55").unwrap());
rx.changed().await.unwrap();
assert_eq!(
*rx.borrow(),
AccessState {
grants: 0,
deadline_unix: None,
revoked: true
}
);
// Re-pairing revives the SAME channel — a stale-but-alive subscriber sees the new state.
np.add("Living Room", "ee55").unwrap();
rx.changed().await.unwrap();
assert!(!rx.borrow().revoked);
assert_eq!(rx.borrow().grants, GRANT_ALL);
// Subscribing to a never-paired fingerprint starts revoked.
assert!(np.subscribe("zz99").borrow().revoked);
let _ = std::fs::remove_file(&p);
}
/// The Moonlight resolution verb reads an absent record OPPOSITELY to the native one
/// (design §8): no record = ungoverned full control (the GameStream cert list is that
/// plane's pairing authority), while a record that exists governs identically on both —
/// including expiry failing closed. If the absent arm ever flips to `None`, every
/// pre-grants Moonlight pairing loses launch on upgrade day.
#[test]
fn moonlight_effective_absent_is_ungoverned_but_a_record_governs() {
use punktfunk_core::quic::GRANT_GAMEPAD;
let p = temp();
let _ = std::fs::remove_file(&p);
let np = NativePairing::load_with(Some(p.clone()), None, false).unwrap();
let now = wall_now();
// Absent record: the two verbs disagree by design.
assert_eq!(np.effective("ab12", now), None, "native: unpaired");
assert_eq!(
np.moonlight_effective("ab12", now),
Some(GRANT_ALL),
"moonlight: ungoverned = full control"
);
// A record that exists governs — identically to `effective`, case-insensitively.
np.add_with_access(
"Guest Deck",
"AB12",
Some(Access {
grants: GRANT_GAMEPAD,
expires_unix: Some(now + 60),
}),
)
.unwrap();
assert_eq!(np.moonlight_effective("ab12", now), Some(GRANT_GAMEPAD));
assert_eq!(
np.moonlight_effective("ab12", now),
np.effective("ab12", now)
);
// Expiry fails closed (None), exactly like the native verb — the deadline second
// itself is expired.
assert_eq!(np.moonlight_effective("ab12", now + 60), None);
assert_eq!(
np.moonlight_effective("ab12", now + 60),
np.effective("ab12", now + 60)
);
// Deleting the record returns the fingerprint to ungoverned (its GameStream pairing —
// a separate store — is what an unpair actually severs, via the mgmt endpoint).
assert!(np.remove("ab12").unwrap());
assert_eq!(np.moonlight_effective("ab12", now), Some(GRANT_ALL));
let _ = std::fs::remove_file(&p);
}
/// The armed window carries the operator's access choice to whichever device completes the
/// ceremony; disarm (the single-use consume) wipes it with the PIN.
#[test]
fn armed_window_carries_access_until_consumed() {
use punktfunk_core::quic::GRANT_GAMEPAD;
let p = temp();
let _ = std::fs::remove_file(&p);
let np = NativePairing::load_with(Some(p.clone()), None, false).unwrap();
assert_eq!(np.armed_access(), None, "disarmed = no choice");
let choice = Access {
grants: GRANT_GAMEPAD,
expires_unix: Some(wall_now() + 3600),
};
np.arm_for(Duration::from_secs(60), None, Some(choice));
assert_eq!(np.armed_access(), Some(choice));
np.disarm();
assert_eq!(np.armed_access(), None, "consumed with the window");
let _ = std::fs::remove_file(&p);
}
}
@@ -19,6 +19,11 @@ struct Armed {
pin: Option<String>,
expires_at: Option<Instant>,
bound_fp: Option<String>,
/// The operator's access choice for whichever device completes this window's ceremony
/// (design §5.7 — the arm dialog is an authorized grant path). `None` = no choice made
/// (the full/permanent default). Wiped with the rest of the window on disarm/expiry, so
/// the ceremony must read it BEFORE consuming the single-use PIN.
access: Option<super::Access>,
}
/// The result of resolving the armed PIN for a specific client fingerprint
@@ -56,6 +61,7 @@ impl ArmState {
pin: Some(fixed_pin.unwrap_or_else(random_pin)),
expires_at: None,
bound_fp: None,
access: None,
}
} else {
Armed::default()
@@ -67,17 +73,33 @@ impl ArmState {
/// Arm pairing with a fresh random PIN, valid for `ttl`. If `bound_fp` is `Some`, the window is
/// bound to that device fingerprint: only a pairing attempt from it consumes the window, so an
/// unrelated (attacker) fingerprint can neither pair nor burn the window (#9). Returns the PIN.
pub(super) fn arm_for(&self, ttl: Duration, bound_fp: Option<String>) -> String {
/// unrelated (attacker) fingerprint can neither pair nor burn the window (#9). `access` is
/// the operator's choice for whichever device completes this window's ceremony (`None` =
/// the full/permanent default). Returns the PIN.
pub(super) fn arm_for(
&self,
ttl: Duration,
bound_fp: Option<String>,
access: Option<super::Access>,
) -> String {
let pin = random_pin();
*self.arm.lock().unwrap() = Armed {
pin: Some(pin.clone()),
expires_at: Some(Instant::now() + ttl),
bound_fp,
access,
};
pin
}
/// The access choice the current window carries (`None` when disarmed/expired or armed
/// without one). Read by the ceremony BEFORE it consumes the window — disarm wipes it.
pub(super) fn armed_access(&self) -> Option<super::Access> {
let mut arm = self.arm.lock().unwrap();
Self::expire(&mut arm);
arm.access
}
/// Resolve the PIN for an attempt from `client_fp_hex`, honoring fingerprint binding (#9):
/// `Disarmed` if no window is armed; `BoundToOther` if a window is armed but bound to a different
/// fingerprint (the caller MUST reject without consuming it); else `Pin` to run the ceremony.
+142 -12
View File
@@ -3,6 +3,7 @@
//! atomic-replace persistence; the pending-approval side of a pairing lives in [`super::approval`].
use anyhow::Result;
use punktfunk_core::quic::GRANT_ALL;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
@@ -18,6 +19,33 @@ pub struct PairedClient {
pub name: String,
/// Hex SHA-256 of the client's certificate.
pub fingerprint: String,
/// Grant bitmask (`punktfunk_core::quic::GRANT_*`). `None` (absent in stores from before
/// grants existed) = full control — existing pairings keep today's behavior. Stored as
/// written; readers mask with [`GRANT_ALL`] so a store edited by a *future* host version
/// can't smuggle reserved bits into this one's enforcement.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub grants: Option<u32>,
/// Absolute expiry, host wall clock, unix seconds. `None` = permanent. Deliberately wall
/// clock (design §4): the user's mental model is "until tonight", so an NTP step moves the
/// deadline with the clock; evaluate at each check, never against a cached monotonic offset.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expires_unix: Option<i64>,
/// When the access was granted (unix seconds) — display/audit only, never enforced.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub granted_unix: Option<i64>,
}
/// An operator's access choice for a device: what it may do, and for how long. The payload of
/// the authorized-widening paths (arm dialog / approve dialog / console edit — design §5.7);
/// everywhere it is `Option<Access>`, `None` means "no explicit choice" — new records get the
/// full/permanent default and existing records keep what they have.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Access {
/// Grant bitmask (`punktfunk_core::quic::GRANT_*` bits). Reserved bits are the management
/// API's job to reject (400, never silently cleared); the store masks them on *read*.
pub grants: u32,
/// Absolute expiry, host wall clock, unix seconds. `None` = permanent.
pub expires_unix: Option<i64>,
}
impl PairedClients {
@@ -39,6 +67,15 @@ fn default_path() -> Result<PathBuf> {
Ok(pf_paths::config_dir().join("punktfunk1-paired.json"))
}
/// Host wall clock, unix seconds — the clock every grant/expiry field is expressed in
/// (design §4: wall time on purpose; the host clock is operator-owned).
fn now_unix() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
fn load(path: &Path) -> PairedClients {
std::fs::read(path)
.ok()
@@ -78,27 +115,96 @@ impl TrustStore {
})
}
/// Is this client (hex SHA-256 fingerprint) in the paired set?
/// Is this client (hex SHA-256 fingerprint) in the paired set? **Expiry-blind**: an expired
/// record still answers `true` (it is *listed*, just not authorized) — see
/// [`Self::effective`] for the authorization question.
pub(super) fn is_paired(&self, fp_hex: &str) -> bool {
self.paired.lock().unwrap().clients.contains(fp_hex)
}
/// Record a successful pairing (re-pairing the same fingerprint just updates the name —
/// matched case-insensitively, like every other fingerprint comparison here). The name is
/// sanitized (untrusted). On a persist failure the in-memory store is rolled back so it never
/// diverges from disk. (Clearing any pending knock for this fingerprint is the caller's job —
/// see [`super::approval::ApprovalQueue::admit_and_clear`].)
/// The grant mask this fingerprint is authorized for *right now* — `None` when unpaired OR
/// expired, `Some(mask)` otherwise (absent grants = [`GRANT_ALL`], the pre-grants record).
/// The mask is ANDed with [`GRANT_ALL`] on the way out: a store written by a future host
/// version (or hand-edited) can't smuggle reserved bits into this version's enforcement.
/// `now_unix` is the caller's wall clock — passed in, not sampled here, so the expiry
/// evaluation and whatever decision it feeds share one instant.
pub(super) fn effective(&self, fp_hex: &str, now_unix: i64) -> Option<u32> {
let p = self.paired.lock().unwrap();
let c = p
.clients
.clients
.iter()
.find(|c| c.fingerprint.eq_ignore_ascii_case(fp_hex))?;
if c.expires_unix.is_some_and(|t| now_unix >= t) {
return None;
}
Some(c.grants.unwrap_or(GRANT_ALL) & GRANT_ALL)
}
/// The stored record for a fingerprint (for the facade's watch-state snapshot and the
/// approval path's honest return value). Verbatim — no expiry evaluation, no masking.
pub(super) fn get(&self, fp_hex: &str) -> Option<PairedClient> {
self.paired
.lock()
.unwrap()
.clients
.clients
.iter()
.find(|c| c.fingerprint.eq_ignore_ascii_case(fp_hex))
.cloned()
}
/// Record a successful pairing with no explicit access choice. For a **new** fingerprint this
/// mints the legacy full/permanent record (all access fields absent — byte-identical to a
/// pre-grants store). For an **existing** fingerprint it is name-only: grants, expiry, and
/// granted-time are preserved. That asymmetry is the security property (design §5.7): today a
/// guest limited to Controller · 4 h could re-run the pairing ceremony and this method used to
/// *replace* the record — silently escalating to full control. The only paths that widen
/// access take an explicit [`Access`] via [`Self::add_with_access`] / [`Self::set_access`],
/// and all of them sit behind the operator (mgmt bearer / armed window).
pub(super) fn add(&self, name: &str, fp_hex: &str) -> Result<()> {
self.add_with_access(name, fp_hex, None)
}
/// Record a successful pairing, optionally with the operator's access choice. `Some(access)`
/// (the arm/approve dialogs) sets grants + expiry and stamps `granted_unix` — on a re-pair it
/// *replaces* the previous access, because the operator just chose anew. `None` behaves like
/// [`Self::add`] (name-only for an existing fingerprint; full/permanent default for a new
/// one). The fingerprint match is case-insensitive, like every other comparison here; the
/// name is sanitized (untrusted). On a persist failure the in-memory store is rolled back so
/// it never diverges from disk. (Clearing any pending knock for this fingerprint is the
/// caller's job — see [`super::approval::ApprovalQueue::admit_and_clear`].)
pub(super) fn add_with_access(
&self,
name: &str,
fp_hex: &str,
access: Option<Access>,
) -> Result<()> {
let name = super::sanitize_device_name(name, fp_hex);
let mut p = self.paired.lock().unwrap();
let snapshot = p.clients.clients.clone(); // restore on a failed save
p.clients
match p
.clients
.retain(|c| !c.fingerprint.eq_ignore_ascii_case(fp_hex));
p.clients.clients.push(PairedClient {
name,
fingerprint: fp_hex.to_string(),
});
.clients
.iter_mut()
.find(|c| c.fingerprint.eq_ignore_ascii_case(fp_hex))
{
Some(existing) => {
existing.name = name;
if let Some(a) = access {
existing.grants = Some(a.grants);
existing.expires_unix = a.expires_unix;
existing.granted_unix = Some(now_unix());
}
}
None => p.clients.clients.push(PairedClient {
name,
fingerprint: fp_hex.to_string(),
grants: access.map(|a| a.grants),
expires_unix: access.and_then(|a| a.expires_unix),
granted_unix: access.map(|_| now_unix()),
}),
}
if let Err(e) = save(&p) {
p.clients.clients = snapshot;
return Err(e);
@@ -106,6 +212,30 @@ impl TrustStore {
Ok(())
}
/// Overwrite an existing record's access (the console edit sheet / "expire now" / extend).
/// Returns `false` (and writes nothing) for an unknown fingerprint — editing access is not a
/// way to pair a device. On a persist failure the in-memory store is rolled back.
pub(super) fn set_access(&self, fp_hex: &str, access: Access) -> Result<bool> {
let mut p = self.paired.lock().unwrap();
let snapshot = p.clients.clients.clone();
let Some(existing) = p
.clients
.clients
.iter_mut()
.find(|c| c.fingerprint.eq_ignore_ascii_case(fp_hex))
else {
return Ok(false);
};
existing.grants = Some(access.grants);
existing.expires_unix = access.expires_unix;
existing.granted_unix = Some(now_unix());
if let Err(e) = save(&p) {
p.clients.clients = snapshot;
return Err(e);
}
Ok(true)
}
/// The paired clients (for the management API's device list).
pub(super) fn list(&self) -> Vec<PairedClient> {
self.paired.lock().unwrap().clients.clients.clone()
+78 -1
View File
@@ -250,6 +250,65 @@ fn privileged_sids() -> Result<Vec<Vec<u8>>> {
.collect()
}
/// Owner check for a SINGLE secret file: `Some(true)` if owned by SYSTEM / Administrators /
/// TrustedInstaller, `Some(false)` if owned by any other (non-privileged) account, `None` if the
/// owner could not be determined. Used to distrust a `host.env` / `web-password` a non-admin
/// pre-created under `%ProgramData%` before a privileged install ran — the file's bytes would
/// otherwise be adopted verbatim into the SYSTEM service's environment / the console password
/// (security-review 2026-08-15 findings 3c and 4). Reads the security descriptor directly, like
/// [`ensure_admin_only_source`], to stay locale-independent. Must be consulted BEFORE any
/// `create_private_dir` re-owns the file to Administrators and erases the signal.
#[cfg(windows)]
pub(crate) fn is_admin_owned(path: &Path) -> Option<bool> {
use std::os::windows::ffi::OsStrExt;
use windows::core::PCWSTR;
use windows::Win32::Foundation::{LocalFree, HLOCAL};
use windows::Win32::Security::Authorization::{GetNamedSecurityInfoW, SE_FILE_OBJECT};
use windows::Win32::Security::{
EqualSid, IsValidSid, OWNER_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR, PSID,
};
let wide: Vec<u16> = path
.as_os_str()
.encode_wide()
.chain(std::iter::once(0))
.collect();
let mut owner = PSID::default();
let mut sd = PSECURITY_DESCRIPTOR::default();
// SAFETY: `wide` is NUL-terminated and outlives the call; the out-params are live locals; the
// returned descriptor is the single allocation, LocalFree'd below (owner points into it).
let rc = unsafe {
GetNamedSecurityInfoW(
PCWSTR(wide.as_ptr()),
SE_FILE_OBJECT,
OWNER_SECURITY_INFORMATION,
Some(&mut owner),
None,
None,
None,
&mut sd,
)
};
let verdict = (|| -> Option<bool> {
rc.ok().ok()?;
let privileged = privileged_sids().ok()?;
// SAFETY: `owner` points into the descriptor returned above; IsValidSid is the probe.
if owner.is_invalid() || !unsafe { IsValidSid(owner) }.as_bool() {
return None;
}
let admin = privileged.iter().any(|p| {
// SAFETY: `owner` passed IsValidSid; `p` is an owned, length-exact SID copy.
unsafe { EqualSid(owner, PSID(p.as_ptr().cast_mut().cast())) }.is_ok()
});
Some(admin)
})();
// SAFETY: `sd` is the single LocalAlloc'd descriptor GetNamedSecurityInfoW returned.
unsafe {
let _ = LocalFree(Some(HLOCAL(sd.0)));
}
verdict
}
/// The subject CN both driver-signing certs carry (`build-pf-vdisplay.ps1` /
/// `build-gamepad-drivers.ps1`). certutil matches a CertId against the subject, so this is how we
/// find our own certs again without parsing any localized output — see `purge_driver_certs`.
@@ -757,12 +816,30 @@ fn web_setup(args: &[String]) -> Result<()> {
/// Source: a non-empty `--password-file` (fresh install) > keep existing (upgrade) > random fallback.
/// Writes `PUNKTFUNK_UI_PASSWORD=<pw>\n` (LF, no BOM) + ACLs it to Administrators + SYSTEM only.
fn set_web_password(pw_path: &Path, pw_file: Option<&str>) {
// A password file that exists but is owned by a NON-admin was planted by an unprivileged user
// before this privileged install (`%ProgramData%` CREATOR OWNER). The installer's
// `FreshWebInstall := not FileExists` check then mistakes it for an upgrade, skips the password
// page, and adopts the attacker's console password. Distrust it: rename aside and rotate to a
// fresh random below (`!planted` forces the random branch even if the rename failed). A password
// file from a prior privileged install is Administrators-owned and is kept. security-review
// 2026-08-15 finding 4.
let planted = pw_path.exists() && is_admin_owned(pw_path) == Some(false);
if planted {
let mut aside = pw_path.to_path_buf().into_os_string();
aside.push(".untrusted");
let aside = std::path::PathBuf::from(aside);
let _ = std::fs::remove_file(&aside);
let _ = std::fs::rename(pw_path, &aside);
println!(
"web console password file was owned by a non-admin (planted before install) — rotating to a fresh password"
);
}
let password = pw_file
.and_then(|f| std::fs::read_to_string(f).ok())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.or_else(|| {
if pw_path.exists() {
if pw_path.exists() && !planted {
println!("keeping existing web console password");
None
} else {
+42 -2
View File
@@ -211,12 +211,24 @@ fn load_host_env() {
}
if let Some((k, v)) = line.split_once('=') {
let (k, v) = (k.trim(), v.trim().trim_matches('"'));
if !k.is_empty() {
// Allow-list, matching `interactive::merged_env_block`'s filter at the child-spawn
// boundary: import ONLY `PUNKTFUNK_*` / `RUST_LOG` into the LocalSystem service's own
// environment. Without this, EVERY key was injected — including `SystemRoot`, from which
// `pf_paths::icacls_path()` / the powershell warner build the absolute program paths a
// privileged service must never resolve through a poisoned env — so a `host.env` planted
// in the user-writable %ProgramData% before install yielded code execution as SYSTEM.
// security-review 2026-08-15 finding 3. (Note: `PUNKTFUNK_HOST_CMD` / `PUNKTFUNK_CONFIG_DIR`
// are legitimate installer-written knobs and still pass; a PLANTED file redirecting THEM
// is closed separately by distrusting a non-admin-owned host.env — findings 3c/4.)
let allowed = k.starts_with("PUNKTFUNK_") || k == "RUST_LOG";
if !k.is_empty() && allowed {
// SAFETY: called from the service main before this process spawns any thread —
// the network-profile warner and the supervisor's host child both start after
// `load_host_env` returns, so nothing reads the environment concurrently.
unsafe { std::env::set_var(k, v) };
n += 1;
} else if !k.is_empty() {
tracing::warn!(key = %k, "host.env: ignoring non-allow-listed key");
}
}
}
@@ -1354,6 +1366,32 @@ fn uninstall() -> Result<()> {
/// defaults to `auto` — the host picks NVENC (NVIDIA) / AMF (AMD) / QSV (Intel) from the GPU vendor.
fn ensure_default_host_env() -> Result<()> {
let path = host_env_path();
// If a host.env already exists but is owned by a NON-admin account, it was pre-created by an
// unprivileged user before this privileged install ran (`%ProgramData%` grants BUILTIN\Users
// add-subdirectory + CREATOR OWNER). Its bytes become the SYSTEM service's environment and the
// command line it launches, so it must NOT be adopted verbatim. Checked HERE, before
// `create_private_dir` below re-owns it to Administrators and erases the only signal that
// distinguishes a planted file from a legitimately-provisioned one. A legitimate host.env from a
// prior privileged install is Administrators-owned and passes. security-review 2026-08-15 #3c.
let planted = path.exists() && crate::install::is_admin_owned(&path) == Some(false);
if planted {
// Best-effort rename-aside for forensics; the security guarantee is the `!planted` skip
// below, which drops through to overwriting the file with the default even if this fails.
let mut aside = path.clone().into_os_string();
aside.push(".untrusted");
let aside = std::path::PathBuf::from(aside);
let _ = std::fs::remove_file(&aside);
match std::fs::rename(&path, &aside) {
Ok(()) => tracing::warn!(
path = %path.display(), aside = %aside.display(),
"host.env was owned by a non-admin account (planted before install) — renamed aside; writing the default"
),
Err(e) => tracing::error!(
error = %e, path = %path.display(),
"host.env is non-admin-owned and could not be renamed aside — overwriting it with the default"
),
}
}
// Harden the config dir FIRST, unconditionally — before the `exists()` check, not inside the
// branch that creates the file.
//
@@ -1367,10 +1405,12 @@ fn ensure_default_host_env() -> Result<()> {
if let Some(dir) = path.parent() {
pf_paths::create_private_dir(dir).ok();
}
if path.exists() {
if path.exists() && !planted {
// An existing host.env may predate the hardening (or have been planted before it ran), in
// which case it is still owned by whoever created it — and an owner can rewrite the DACL it
// inherited. Re-apply the SYSTEM/Administrators lock to the FILE as well as the directory.
// (A non-admin-owned file is `planted` above and is NOT adopted — it falls through to the
// default write below, overwriting it even if the rename-aside failed.)
pf_paths::restrict_existing_secret_file(&path);
return Ok(());
}
@@ -0,0 +1,49 @@
Decoder: reject a sub-header packet so the parse cursor always advances — PUNKTFUNK LOCAL PATCH.
Not upstream. Decoder::Impl::push_packet walks a window body of self-delimiting
packets by advancing `data`/`size` by `packet_size = header->payload_words * 4`.
`payload_words` is a 12-bit wire field (pyrowave_common.hpp) that may be 0, and
the only length guard, `if (packet_size > size)`, does not reject 0 (0 > size is
false). The block-index bound checks the index, not the size.
The check that WOULD catch it — `if (sizeof(*header)/sizeof(uint32_t) >
header->payload_words) return false;` — lives in decode_packet, AFTER its
duplicate-block early return (`if (offset != UINT32_MAX) return true;`). So a
hostile/compromised host that sends block N, then block N again with
payload_words == 0, makes decode_packet return true via the duplicate branch;
push_packet then does `data += 0; size -= 0;` and the `while (size >=
sizeof(BitstreamHeader))` loop re-enters on the identical bytes forever — a 100%
CPU spin inside FFI on the client's decode thread, with no allocation (so it will
not even OOM out) and no way for the Rust side to intervene. PyroWave is the only
path that decodes incomplete access units and ships in every default
Linux/Windows desktop client, and partial delivery is on by default.
Fixed by hoisting the minimum-size check into push_packet, before decode_packet
is consulted, so the cursor is guaranteed to advance every iteration. This
subsumes packet_size == 0. The existing check in decode_packet is left in place
as defense-in-depth (now unreachable-but-correct). The Swift reimplementation
already guards this in MetalWaveletDecoder.swift. security-review 2026-08-15
finding 9.
diff --git a/crates/pyrowave-sys/vendor/pyrowave/pyrowave_decoder.cpp b/crates/pyrowave-sys/vendor/pyrowave/pyrowave_decoder.cpp
index 64958bd0..a41243ad 100644
--- a/crates/pyrowave-sys/vendor/pyrowave/pyrowave_decoder.cpp
+++ b/crates/pyrowave-sys/vendor/pyrowave/pyrowave_decoder.cpp
@@ -211,6 +211,17 @@ bool Decoder::Impl::push_packet(const void *data_, size_t size)
size_t packet_size = header->payload_words * sizeof(uint32_t);
+ // PUNKTFUNK: a packet must be at least its own header, so this parse cursor ALWAYS advances.
+ // payload_words is a 12-bit wire field that may be 0, and decode_packet's duplicate-block
+ // early return fires BEFORE its own payload_words minimum check — so a duplicate block_index
+ // with payload_words == 0 returns true and `data += 0; size -= 0;` spins the decode thread
+ // forever. Reject it here, before decode_packet is consulted. security-review 2026-08-15 #9.
+ if (header->payload_words < sizeof(*header) / sizeof(uint32_t))
+ {
+ LOGE("payload_words (%u) is smaller than the packet header — refusing.\n", header->payload_words);
+ return false;
+ }
+
if (packet_size > size)
{
LOGE("Packet header states %zu bytes, but only %zu bytes left to parse.\n", packet_size, size);
@@ -211,6 +211,17 @@ bool Decoder::Impl::push_packet(const void *data_, size_t size)
size_t packet_size = header->payload_words * sizeof(uint32_t);
// PUNKTFUNK: a packet must be at least its own header, so this parse cursor ALWAYS advances.
// payload_words is a 12-bit wire field that may be 0, and decode_packet's duplicate-block
// early return fires BEFORE its own payload_words minimum check — so a duplicate block_index
// with payload_words == 0 returns true and `data += 0; size -= 0;` spins the decode thread
// forever. Reject it here, before decode_packet is consulted. security-review 2026-08-15 #9.
if (header->payload_words < sizeof(*header) / sizeof(uint32_t))
{
LOGE("payload_words (%u) is smaller than the packet header — refusing.\n", header->payload_words);
return false;
}
if (packet_size > size)
{
LOGE("Packet header states %zu bytes, but only %zu bytes left to parse.\n", packet_size, size);
+107
View File
@@ -0,0 +1,107 @@
---
title: Access levels
description: What each paired device may do, and for how long — the three presets, the advanced toggles, temporary access that expires on its own, and what access control honestly does not cover.
---
Pairing used to be all-or-nothing: a paired device had full control of the host, forever. **Access**
changes that. Every paired device carries an **access level** — what it may send to the host — and
optionally an expiry — how long that lasts. A friend's phone can be a second controller for the
evening and nothing more; the living-room TV can watch and play but never type into your desktop;
a spectator can see and hear without sending anything.
Access is **enforced by the host**. A client's UI reflects its access as a courtesy, but the host
drops anything a device isn't granted regardless of what the client sends — nothing a client can
send widens its own access.
You manage access from the host's [web console](/docs/web-console): when you
[approve a device or arm pairing](/docs/pairing#choosing-access-when-you-admit-a-device), and any
time after on the **Paired devices** table, where each device shows an **Access** chip (with a live
countdown if it expires) and an edit sheet.
## The three presets
| Access level | What the device can do |
|---|---|
| **Full control** | Everything — keyboard, mouse, controllers, clipboard, microphone, launching games. This is what pairing has always meant, and it stays the default: every device paired before access levels existed keeps full control, and so does a plain **Approve**. |
| **Controller only** | Gamepad input only — the guest and co-play preset. The device's pads show up as additional controllers (with rumble and pad audio), but it cannot type, move the mouse, read the clipboard, use the mic, or launch anything. |
| **View only** | See and hear the stream, send nothing. The spectator preset. |
The preset label is derived from the underlying toggles, so a hand-tuned combination simply shows
as **Custom** — there is no separate thing to keep in sync.
## The advanced toggles
Each preset is a bundle of six independent grants, exposed under **Advanced** in the edit sheet:
| Toggle | Covers |
|---|---|
| **Gamepad** | Controller buttons, sticks and motion, plus everything that rides with a pad: virtual pad creation on the host, rumble back to the client, pad audio. |
| **Pointer** | Mouse (relative and absolute), scroll, touch, and pen input. |
| **Keyboard** | Key presses. |
| **Clipboard** | The [shared clipboard](/docs/clipboard). Both switches still apply: the host operator's clipboard policy *and* this grant have to allow it — the grant can only narrow, never widen, what the operator permits. An ungranted device gets a clean "not permitted" instead of a toggle that silently does nothing. |
| **Microphone** | Sending the client's microphone to the host. Without it, the session never attaches to the host's mic service at all. |
| **Launch** | Starting a game from the host's [library](/docs/game-library) when connecting. Without it, a connect that asks to launch is refused with a clear error rather than being dropped onto the bare desktop. The library remains *visible* — this governs launching, not browsing. |
**Controller only deliberately does not include Launch**: in co-play the owner drives what runs. If
you want a guest picking games from the couch, that's one Advanced toggle away.
A session's quality controls — resolution, bitrate, keyframe requests — are *not* governed. They
only shape that device's own stream, so restricting them would cost usability and buy no security.
## Temporary access
Any grant can carry an expiry, picked when you approve the device or set later in its edit sheet:
**1 h / 4 h / 8 h / custom / forever**.
- Expiry is **wall-clock time on the host** — "4 hours" means four hours from now by the host's
clock, matching the mental model of "until tonight".
- A device streaming when its access runs out gets **warnings at 5 minutes and 1 minute** before
the deadline, then its session ends with an explicit reason: *"Your access to this host has
expired."* Only that device's sessions end — yours is untouched.
- An expired device is **not unpaired into oblivion**. It stays listed as **Expired** in the
Paired devices table, and when it next tries to connect it appears under **Waiting for
approval** like a new device — re-granting is one click, with the same access dialog.
- **Extend** and **Expire now** live in the edit sheet, and both hit live sessions immediately:
extending re-arms the running session's deadline, and Expire now ends it with the same clean
"access expired" message — no lingering stream.
Edits other than expiry are just as immediate: changing a device's access level while it streams
takes effect within moments, and removing the device ends its sessions. Access is per *device*,
not per session — two sessions from the same device share one grant.
## What this does not cover
Be honest with yourself about three limits before relying on access levels:
> **A view-only guest still sees your whole desktop.** On the shared-desktop backends every
> session shows the *same* desktop — access levels govern what a device can send *in*, not what it
> sees going *out*. A view-only or controller-only guest watches and hears everything you do,
> notifications included. Don't read email with a spectator attached.
- **Moonlight / GameStream devices are not governed yet.** Access levels currently apply to the
native Punktfunk protocol. A device paired via [Moonlight](/docs/moonlight) has full control and
shows an honest **Full (ungoverned)** chip in the console — not a fake editor. When enforcement
reaches the GameStream plane, it will be *silent* from the client's side: the GameStream
protocol has no way to tell a Moonlight client about its access, so an ungranted keyboard will
simply be inert, with the explanation visible only in the console.
- **Older Punktfunk clients are enforced, but can't explain it.** The host enforces access
identically for every client version. A client from before this feature just lacks the chrome:
no "Controller only · ends in 2 h" chip, no expiry warnings, a generic disconnect instead of
"access expired" — and an ungranted keyboard is silently inert rather than never captured in the
first place. If a guest reports "my keyboard does nothing", check their access level in the
console first, then whether their client is current.
Up-to-date native clients do get the chrome: they stop capturing what can't land (no keyboard grab
without the Keyboard grant), hide the clipboard and mic controls when ungranted, show a small
overlay chip naming the session's access and time remaining, and surface the expiry warnings as
toasts.
## Where enforcement happens
For the security-minded: the host checks every input event against the device's grants before
injecting it, refuses ungranted planes at session setup (no Gamepad grant means the virtual pads
are never created; no Microphone grant means the mic plane never attaches), and re-pairing a
device **preserves** its existing access — the only way to widen a grant is the console's own
dialogs, behind the console login. Dropped traffic is logged once per session and category, not
per event, so a misbehaving client can't flood the log. See [Security & Safe
Use](/docs/security) for the wider picture.
+1
View File
@@ -37,6 +37,7 @@
"clients",
"install-client",
"pairing",
"access-levels",
"steam-deck",
"moonlight",
"---Using Punktfunk---",
+29 -1
View File
@@ -36,6 +36,30 @@ blocklist). Requests expire on their own after **10 minutes**.
This works because approval happens on the host's authenticated management surface — only someone
with console access can admit a device.
## Choosing access when you admit a device
Admitting a device and deciding what it may do are **one dialog**, not two trips. The Approve
dialog carries, alongside the device's name:
- **Access level****Full control**, **Controller only**, or **View only**, with an **Advanced**
expander for the individual toggles. See [Access levels](/docs/access-levels) for exactly what
each covers.
- **Expires****Forever**, or **1 h / 4 h / 8 h / custom**.
The defaults are **Full control · Forever** — approving your *own* new laptop is the common case,
and it should behave like pairing always has. The guest case gets its own button instead: a
distinct secondary action, **"Approve as guest"**, admits the device as **Controller only for
4 hours** in one click. The whole co-play flow is: your friend installs a client, connects, the
knock appears in your console, you tap **Approve as guest** — and their pad lights up as
controller #2. When the evening's over, the access expires on its own.
The same two controls appear on the **arm pairing** card for the PIN path: whatever access and
expiry you choose while arming applies to whichever device completes the PIN ceremony in that
window.
Everything here can be changed later from the Paired devices table — see
[Managing paired devices](#managing-paired-devices) below.
## Pairing with a PIN
PIN pairing is the **default and required** path for any new host: unless the host has explicitly
@@ -127,7 +151,11 @@ re-pairing through the PIN ceremony rather than offering to re-trust the new ide
## Managing paired devices
The [web console](/docs/web-console) lists every paired device and lets you remove one (revoking its
access). Re-pairing is just the PIN ceremony again.
access). Each row also shows the device's **Access** — the preset and, for temporary grants, a live
countdown ("Controller · 2 h left") — and an edit sheet to change the level, extend or cut the
expiry, or expire it right now; edits reach a live session immediately. See
[Access levels](/docs/access-levels). Re-pairing is just the PIN ceremony again — and it keeps the
device's existing access rather than widening it.
If a client can't pair at all, see [Troubleshooting → Pairing is
rejected](/docs/troubleshooting#pairing-is-rejected--the-client-cant-connect).
+45
View File
@@ -0,0 +1,45 @@
Wire-compatible with 0.28.x — everything you have already paired keeps working, and you can update one side at a time. An old client on a new host, or the other way round, streams exactly as it does today.
This release is about getting along: with the other streaming host on your machine, with the game that is hogging your processor, and with Windows itself. Punktfunk can now share a computer with Sunshine and its forks, Linux hosts keep the stream smooth while a game hammers the CPU, streaming from a Hyprland desktop finally works — and keeps working past the first session — and Windows downloads are now signed under a publicly verified publisher.
## TL;DR
- **Punktfunk and Sunshine (or Apollo, or another fork) can now live on the same machine.** Move one port in Punktfunk's settings and you are done — every client finds the new port by itself.
- **Linux hosts stop stuttering when a game hogs the processor.** The streaming threads were supposed to run at high priority and never actually did, on any install to date.
- **If your Windows client is the app package (the `.msix` download), this one update needs an uninstall first** — see *Before you update*. The regular installer updates normally.
- **Streaming from a Hyprland desktop actually works now — and keeps working.** Before, the picture could refuse to appear at all, and at best the first session of a host's lifetime was also its last good one.
- **A controller whose Select button reports itself as "Back" no longer quits your Android stream on one press.**
- **Mac clients can finally capture system shortcuts:** ⌘Q reaches the host's desktop instead of closing the client.
## Before you update
- **Windows client installed as the app package (the `.msix` download):** Windows will refuse to update it in place this one time, because the package's signing identity changed with the move to a verified certificate. Uninstall the old package, then install the new one. This does not apply to the regular installer, and future updates go back to normal.
## New
- **Share a machine with Sunshine, Apollo and friends.** With Moonlight-compatible connections switched off, the two only ever fought over a single network port — and moving Punktfunk's was barely possible: the setting did not survive updates, and clients had no way to find the moved port unless local network discovery happened to be working. All of that is fixed. The port is now a real setting in the host's configuration file, it survives upgrades, the web console follows it, and every client learns it directly from the host the moment it connects — so it also works over a VPN or across subnets, where discovery has never worked. The connection port can be moved the same way.
- **Capture system shortcuts, now on Mac too.** The setting existed on every other client; on a Mac, ⌘Q quit the client itself and other ⌘ shortcuts went to the client's menus instead of your game. While your input is captured, ⌘ shortcuts are now sent to the host — with the escape shortcuts kept local, so there is always a way out. On by default, like everywhere else.
## Improved
- **Linux hosts keep the stream smooth under load.** The threads that capture, encode and send your stream were meant to run ahead of ordinary programs and never did — the request was silently refused on every install channel. A game's shader-compilation storm could starve them, stutter the audio and drag the picture quality to the floor on a connection with nothing wrong. The host now asks the system's scheduling service the way audio apps do, the packages ship the needed headroom for machines without it, and — for the first time — the audio path itself is prioritized, which matters most because a late sound packet is directly audible.
- **The host's volume boost is now trustworthy.** It quietly did nothing on normal Punktfunk connections, and where it did work, turning it up clipped the sound into harsh distortion long before it got loud. It now applies everywhere, and levels above the safe range are rounded off gently instead of clipped — boosting a quiet desktop mix finally sounds like turning it up, not breaking it. It buys headroom for quiet sources; it is deliberately not a loudness compressor.
- **Windows downloads are signed under a publicly verified certificate.** The installer and the app package now carry a verified publisher signature instead of a self-signed one — what Windows shows when it asks "do you trust this?" is now an identity a certificate authority actually checked.
## Fixed
- **Streaming your desktop from Hyprland works now, and keeps working.** Six separate faults, every one of them ours, stacked up on this route: the capture could fail to start at all because Punktfunk offered the picture in a format the desktop's screen-sharing service never accepts; when it did start, only the first session of the host's lifetime worked and every later one was black; ending a stream could wedge the screen-sharing service itself; a stalled start could quietly poison every session after it; your mouse pointer was aimed at the host's own monitor instead of the streamed one; and the host misread the service's normal behaviour as a lost pointer. All six are fixed — measured against a fully up-to-date Hyprland, where it now streams session after session.
- **Your Playnite library now shows up on Windows.** Playnite installs per-user by default, and the host — which runs as a system service — was looking in places where a per-user install can never be found, so syncing failed. Worse, the one tile it could not open took every game down with it. Both are fixed: a normal Playnite install is found, and a shortcut that cannot open on your machine is skipped with a note instead of emptying the library.
- **One press of Select no longer ends your Android stream.** Some controllers — especially the Android TV shape — report their Select button as the same "Back" every TV remote has. That press fell through to "leave the stream", and every shortcut built on Select (the emergency exit hold, mic mute, the stats overlay) was unreachable on exactly those pads. Select now works as Select on them; a remote's Back still exits, as the on-screen hint says.
- **Streaming a game in its own full-screen session from a KDE desktop no longer leaves your monitor lit.** With the host set to take the machine over exclusively, this one way of launching a game kept the physical monitor showing the idle desktop for the whole stream. It now goes dark, wakes when you touch the local mouse or keyboard, and never wakes from anything happening inside the stream.
- **Mac keyboard edge cases in a stream:** with Caps Lock on, the escape shortcuts were not recognized at all, and a shortcut's key could stay "stuck" until pressed again. Both fixed alongside the shortcut-capture work.
## Thanks
The Linux priority fix exists because someone measured their stutter carefully enough — down to which seconds it happened in and what else the machine was doing — that the cause could be found in a place nobody was looking. The Select-button fix likewise arrived as a precise field report. Thank you; that detail is what makes a fault findable.
## For developers
Protocol, ABI, driver and embedder detail — including the version table and the breaking-changes list — is in [CHANGELOG.md](https://git.unom.io/unom/punktfunk/src/tag/v0.29.0/CHANGELOG.md).
Two things deserve a headline even here: the C ABI moves 19 → 20 by pure addition (one new accessor for the host's advertised management port), and the Windows MSIX package identity changed with the signing move — that is the uninstall note above, and it is one-time.
+2
View File
@@ -0,0 +1,2 @@
• One press of Select no longer ends your stream. Some controllers report Select as the "Back" every TV remote has; that press quit the session, and the Select shortcuts (the emergency exit hold, mic mute, stats) never worked on those pads. Fixed — a remote's Back still exits.
• The app now learns a host's settings port from the host itself when it connects, so hosts reached over a VPN or another network work fully, without local discovery.
+159 -1
View File
@@ -124,7 +124,20 @@
// widened one: `ex9` keeps its parameter list AND its behaviour — it passes a null name, which
// selects that same default. Additive and client-local: the name rides the `Hello::name` field
// hosts have read since the pending list existed, so [`WIRE_VERSION`] is unchanged.
#define PUNKTFUNK_ABI_VERSION 21
// v22: the per-client access surface (`design/per-client-access.md` §7) —
// `punktfunk_connection_grants` and `punktfunk_connection_access_expires_in` read the session's
// LIVE access state (the `PUNKTFUNK_GRANT_*` mask and the countdown to its expiry — Welcome
// snapshot first, then latest-wins over every mid-session `AccessUpdate` the control task
// folds in), and `punktfunk_connection_end_reject` reports the typed rejection a mid-session
// close carried (`PUNKTFUNK_STATUS_REJECTED_*`; `0` = none), because `end_reason` can only
// file an access-expiry close under HOST_ERROR and that is the wrong sentence for "your
// access expired". NEW symbols, not widened ones — the same rule v18 states: every existing
// function keeps its signature and behaviour, and an embedder that never adopts any of the
// three is unchanged (it simply lacks the courtesy UX; the HOST enforces the grants either
// way). Additive and client-local: the mask, the expiry and the `AccessUpdate` message all
// shipped with the Welcome's trailing-field append (old peers skip them in both directions),
// so [`WIRE_VERSION`] is unchanged.
#define PUNKTFUNK_ABI_VERSION 22
// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check.
// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface**
@@ -649,6 +662,68 @@
// ride with the next one; the wire is never worse than one flush per slice.
#define PUNKTFUNK_MIN_STREAM_BLOCK_SHARDS 16
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Controller input: gamepad button/axis/snapshot/remove/arrival events, plus everything that
// rides with a pad — rich DualSense input (0xCC motion/touchpad), pad-audio, rumble return,
// and virtual-pad creation itself (deny-at-setup: no bit, no uinput node).
#define PUNKTFUNK_GRANT_GAMEPAD (1 << 0)
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Pointing input: mouse rel/abs + buttons, scroll, touch, and the pen plane.
#define PUNKTFUNK_GRANT_POINTER (1 << 1)
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Key input: key down/up and IME-committed text.
#define PUNKTFUNK_GRANT_KEYBOARD (1 << 2)
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Shared clipboard — ANDed into the operator clipboard policy, never overriding it.
#define PUNKTFUNK_GRANT_CLIPBOARD (1 << 3)
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Mic injection: the mic datagram plane + the per-session mic-service attach.
#define PUNKTFUNK_GRANT_MIC (1 << 4)
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Library launch: `Hello.launch` resolution (and any future in-session launch/end verbs).
#define PUNKTFUNK_GRANT_LAUNCH (1 << 5)
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Every defined grant. Also the value an *absent* mask means — a record from before grants
// existed (or an old host's Welcome that omits the field) is full control, so existing
// pairings keep today's behavior.
#define PUNKTFUNK_GRANT_ALL (((((PUNKTFUNK_GRANT_GAMEPAD | PUNKTFUNK_GRANT_POINTER) | PUNKTFUNK_GRANT_KEYBOARD) | PUNKTFUNK_GRANT_CLIPBOARD) | PUNKTFUNK_GRANT_MIC) | PUNKTFUNK_GRANT_LAUNCH)
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// The reserved-must-be-zero region: a mask with any of these bits set is invalid today and is
// rejected at the management API (never silently cleared — the caller meant *something* this
// host doesn't understand, and clearing would grant less than they asked for without saying so).
#define PUNKTFUNK_GRANT_RESERVED ~PUNKTFUNK_GRANT_ALL
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Preset: **Full control** — all bits; today's behavior and the default for absent grants.
#define PUNKTFUNK_GRANT_PRESET_FULL PUNKTFUNK_GRANT_ALL
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Preset: **Controller only** — the guest/co-play preset. Deliberately excludes `LAUNCH`
// (design §11 D2: in co-play the owner drives what runs).
#define PUNKTFUNK_GRANT_PRESET_CONTROLLER_ONLY PUNKTFUNK_GRANT_GAMEPAD
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Preset: **View only** — spectator; sees and hears the stream, sends nothing.
#define PUNKTFUNK_GRANT_PRESET_VIEW_ONLY 0
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// [`Hello::video_caps`] bit: the client can decode a 10-bit (Main10) HEVC stream.
#define PUNKTFUNK_VIDEO_CAP_10BIT 1
@@ -1104,6 +1179,14 @@
#define PUNKTFUNK_CLIP_REASON_NO_FILES 4
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// [`ClipState::reason`]: the operator policy allows clipboard, but THIS device's access grants
// don't (`GRANT_CLIPBOARD` unbit — design/per-client-access.md §5.4). Distinct from
// [`CLIP_REASON_POLICY_DISABLED`] so the client can say "not permitted for this device" instead
// of "the host has clipboard off".
#define PUNKTFUNK_CLIP_REASON_NOT_PERMITTED 5
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// [`ClipFetchHdr::status`]: the requested format is being served; data chunks follow until FIN.
#define PUNKTFUNK_CLIP_FETCH_OK 0
@@ -1162,6 +1245,14 @@
#define PUNKTFUNK_CURSOR_SHAPE_MAX_SIDE 120
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Type byte of [`AccessUpdate`] (host → client): the session's effective grants or remaining
// lifetime changed. 0x58: the 0x50 block belongs to the cursor channel (0x50/0x51 taken),
// so access sits at its top, clear of both the clipboard block (0x40-0x44) and any further
// cursor growth.
#define PUNKTFUNK_MSG_ACCESS_UPDATE 88
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Datagram wire tags. Video rides UDP; everything low-rate rides QUIC datagrams,
// demultiplexed by the first byte: input = [`crate::input::INPUT_MAGIC`] (0xC8, client→host),
@@ -1568,6 +1659,17 @@
// finished mid-frame") — indistinguishable from transport trouble.
#define PUNKTFUNK_SETUP_FAILED_CLOSE_CODE 104
// This device's temporary access ran out (per-client access, `design/per-client-access.md` §4)
// — sent when the deadline fires mid-session, and by "Expire now" in the console. Only the
// expiring device's sessions close with it; a reconnect lands in the console's pending list
// for a one-click re-grant.
#define PUNKTFUNK_ACCESS_EXPIRED_CLOSE_CODE 105
// The `Hello.launch` request named a game this device's grants don't cover (no `LAUNCH` bit).
// Refused AT the handshake — a crisp typed reason beats silently dropping the user onto a
// bare desktop they didn't ask for. Connecting *without* a launch request still works.
#define PUNKTFUNK_LAUNCH_NOT_PERMITTED_CLOSE_CODE 106
// Minimum supported multiplier (renders under native, upscaled on present).
#define PUNKTFUNK_MIN_SCALE 0.5
@@ -1602,6 +1704,8 @@ enum PunktfunkStatus
PUNKTFUNK_STATUS_REJECTED_WIRE_VERSION = -27,
PUNKTFUNK_STATUS_REJECTED_BUSY = -28,
PUNKTFUNK_STATUS_REJECTED_SETUP_FAILED = -29,
PUNKTFUNK_STATUS_REJECTED_ACCESS_EXPIRED = -30,
PUNKTFUNK_STATUS_REJECTED_LAUNCH_NOT_PERMITTED = -31,
PUNKTFUNK_STATUS_PANIC = -99,
};
#ifndef __cplusplus
@@ -3213,6 +3317,60 @@ PunktfunkStatus punktfunk_connection_mgmt_port(const PunktfunkConnection *c, uin
PunktfunkStatus punktfunk_connection_host_caps(const PunktfunkConnection *c, uint8_t *caps);
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// The session's LIVE effective access grants — a `PUNKTFUNK_GRANT_*` bitmask
// (per-client access, `design/per-client-access.md` §7): seeded from the `Welcome` advert
// and moved by every mid-session `AccessUpdate` the host sends (latest wins), so this is
// current state, NOT a connect-time snapshot. An old host advertises nothing and this reads
// `PUNKTFUNK_GRANT_ALL` — full control, the pre-grants behavior, so an embedder keying UI
// off it changes nothing there.
//
// Courtesy truth only: the HOST enforces the mask whatever a client renders. Use it to not
// capture what can't land (no pointer lock / keyboard grab without the bits) and to label
// the session ("Controller only"). Cheap (one relaxed atomic load) — poll it alongside a
// stats tick rather than caching it for the session. Safe any time after connect.
//
// # Safety
// `c` is a valid connection handle; `grants` is writable (NULL is skipped).
PunktfunkStatus punktfunk_connection_grants(const PunktfunkConnection *c, uint32_t *grants);
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Seconds until this session's access expires, LIVE — counted down from the `Welcome`'s
// `expires_in_secs` and re-anchored by every mid-session `AccessUpdate`, so successive reads
// shrink on their own (render a countdown by polling this, ~1 Hz). `0` = permanent: today's
// default, and everything an old host's Welcome decodes to — show nothing then. The deadline
// is anchored to the CLIENT's clock at receipt (the wire carries relative seconds), so
// host/client skew never moves the countdown.
//
// While a deadline exists the value never reads `0`: in the sliver between the deadline
// passing and the host's typed expiry close (`PUNKTFUNK_STATUS_REJECTED_ACCESS_EXPIRED`
// via [`punktfunk_connection_end_reject`]) it clamps to `1`, so `0` stays unambiguous.
// The T5 m / T1 m warnings are the embedder's to derive from the countdown crossing
// those marks. Safe any time after connect.
//
// # Safety
// `c` is a valid connection handle; `secs` is writable (NULL is skipped).
PunktfunkStatus punktfunk_connection_access_expires_in(const PunktfunkConnection *c,
uint32_t *secs);
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// The typed rejection a MID-SESSION close carried, as its `PUNKTFUNK_STATUS_REJECTED_*`
// value (`0` = none — every ordinary end). Exists because
// [`punktfunk_connection_end_reason`] can only file an unrecognized deliberate close under
// `PUNKTFUNK_END_REASON_HOST_ERROR`, and "the host ended the session with an error" is the
// wrong sentence for an access expiry (`PUNKTFUNK_STATUS_REJECTED_ACCESS_EXPIRED`) — the
// case this was added for; any future typed mid-session close surfaces the same way. Ask
// AFTER the session ended, before freeing the handle, exactly like `end_reason` (the two
// latch together); connect-time rejections never land here — they come back from the
// connect call itself.
//
// # Safety
// `c` is a valid connection handle; `status` is writable (NULL is skipped).
PunktfunkStatus punktfunk_connection_end_reject(const PunktfunkConnection *c, int32_t *status);
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Enable or disable the shared clipboard for this session (`design` §3.1). Opt-in: nothing is
// announced or served until this is called with `enabled = true`. `flags` carries
+23
View File
@@ -504,6 +504,29 @@ desktop viewer.
`ExecStart=/usr/bin/punktfunk-host serve --gamestream` (or bare `serve` for native-only) if needed
(section 5).
- **Stream lags, then freezes, with a DualSense-type client pad (SELinux enforcing).** The virtual
DualSense / DualShock 4 binds the kernel's `hid-playstation` driver, and Valve's `ds_inhibit`
(inside `steamos-manager`, shipped on Bazzite) reacts to *any* such hidraw by walking
`/proc/*/fd/` on every open/close. SELinux denies `steamos_manager_t` that walk, spraying
**~324 `avc: denied` per second**, and `setroubleshootd` amplifies the flood into a box-wide
fork storm that starves the stream (gamescope 0 fps, `tx_mbps` collapsing) — measured live on
Bazzite 43, 2026-08-15. Two traps while diagnosing: the AVC lines read `comm="tokio-rt-worker"`
— that is **steamos-manager, not punktfunk** (check `scontext=…steamos_manager_t…`); and once
started the setroubleshootd storm **outlives the denials by 15+ minutes**, so the box stays
starved after the pad is gone. Fixes:
- punktfunk ships a `dontaudit` SELinux drop-in that silences the flood (ds_inhibit then simply
leaves the pad uninhibited — harmless). The sysext installs it automatically on
install/update; on an existing install run `sudo punktfunk-sysext reapply`. On a layered or
bootc host: `sudo semodule -i /usr/share/punktfunk/selinux/punktfunk-ds-inhibit.cil`
(remove with `sudo semodule -r punktfunk-ds-inhibit`).
- **Hardening (recommended on any streaming host):** `sudo systemctl mask --now
setroubleshootd`. It is purely a desktop alert daemon — nothing depends on it
(`systemctl list-dependencies --reverse setroubleshootd` returns only itself) — and masking
it makes the box robust against *any* AVC burst, not just this one. Reversible with `unmask`.
- Workaround with the feature loss: set the **client's** Controller type to Xbox 360 (uinput,
no `hid-playstation`) — costs adaptive triggers, lightbar and touchpad. The host-side
`PUNKTFUNK_GAMEPAD` knob does **not** help: an explicit client choice outranks it.
- **Moonlight can't see the host.** Ensure UDP 5353 (mDNS) and the GameStream ports are open
(section 6) and client + host are on the same L2 LAN segment.
@@ -0,0 +1,28 @@
; SELinux drop-in for Bazzite / SteamOS-derived hosts: silence the audit flood Valve's
; ds_inhibit (inside steamos-manager) produces around ANY `hid-playstation` hidraw —
; which is exactly what punktfunk's virtual DualSense / DualShock 4 is (ds_inhibit
; selects by bound driver + touchpad mouse node; it has no VID/PID or virtual filtering).
;
; ds_inhibit reacts to every open/close of such a hidraw by walking /proc/*/fd/ to see
; whether Steam holds the node. The distro policy denies steamos_manager_t the three
; capabilities that walk needs, so the scan never succeeds, the state machine never
; latches, and every open/close sprays denials — ~324 AVCs/sec measured on Bazzite 43
; (2026-08-15). setroubleshootd then amplifies the flood into a box-wide fork storm
; (267+ procs/sec) that starves the stream: gamescope 0 fps, encode submit ~150 ms/frame.
;
; dontaudit, NOT allow: granting another vendor's daemon sys_ptrace/dac_override is not
; ours to do — silencing the audit noise is. The scan keeps failing quietly and
; ds_inhibit simply leaves the pad uninhibited, which is what we want anyway (the
; "inhibit" would disable the touchpad-as-mouse of a pad we created for a game). The
; underlying gap — steamos_manager_t lacking what its own ds_inhibit needs — is a
; Bazzite/Valve policy bug; this drop-in is the containment we can ship. A bare,
; un-amplified AVC is cheap, but with dontaudit not even that remains.
;
; Installed idempotently by `punktfunk-sysext` post_merge (and best-effort by the RPM
; %post) wherever steamos-manager exists; the policy store is host state, so a sysext
; image cannot carry the module itself — only this source. By hand:
; sudo semodule -i punktfunk-ds-inhibit.cil # remove: sudo semodule -r punktfunk-ds-inhibit
; ⚠ The installers key idempotence on the module NAME (= this filename): if these rules
; ever change, rename the file (and every reference) so existing installs converge.
(dontaudit steamos_manager_t self (capability (dac_override dac_read_search sys_ptrace)))
(dontaudit steamos_manager_t self (cap_userns (sys_ptrace)))
+16
View File
@@ -191,6 +191,22 @@ post_merge() {
# Re-fire the vhci rule against the (possibly already-present) controller so attach/detach pick up
# the input-group ownership even when the module's original add event predated the reloaded rule.
udevadm trigger --subsystem-match=platform --sysname-match='vhci_hcd.*' 2>/dev/null || :
# ds_inhibit dontaudit drop-in (Bazzite ships steamos-manager; keyed on its binary): Valve's
# ds_inhibit walks /proc/*/fd on every open/close of a hid-playstation hidraw — exactly what the
# virtual DualSense is — and SELinux denies steamos_manager_t that walk at ~324 AVCs/sec;
# setroubleshootd amplifies the flood into a box-wide stall that starves the stream (gamescope
# 0 fps, encode submit ~150 ms/frame). The policy STORE is host state (/var/lib/selinux), so the
# image carries only the CIL source and the module must be inserted here. Keyed on the module
# NAME for idempotence (a policy rebuild costs seconds every merge otherwise — if the rules ever
# change, RENAME the file and every reference so existing installs converge). Rationale and the
# dontaudit-vs-allow choice: the .cil header / packaging/bazzite/README.md.
if command -v semodule >/dev/null 2>&1 && [ -e /usr/lib/steamos-manager ] \
&& [ -f /usr/share/punktfunk/selinux/punktfunk-ds-inhibit.cil ] \
&& ! semodule -l 2>/dev/null | grep -qx punktfunk-ds-inhibit; then
echo "installing SELinux drop-in 'punktfunk-ds-inhibit' (silences the steamos-manager ds_inhibit audit flood)…"
semodule -i /usr/share/punktfunk/selinux/punktfunk-ds-inhibit.cil \
|| echo "!! semodule -i failed — the ds_inhibit audit flood stays live; see packaging/bazzite/README.md" >&2
fi
# The /etc payload a sysext can't carry. The gamescope-session drop-in is %config(noreplace):
# only seed it, never clobber a local edit. The tray autostart entry is not user config.
if [ -f "$ETC_SRC/gamescope-session-plus/sessions.d/steam" ] \
+18
View File
@@ -450,6 +450,13 @@ install -Dm0644 packaging/kde/host.env %{buildroot}%{_datadir}/%
# screencast/virtual-output grant ships as io.unom.Punktfunk.Host.desktop, installed above).
install -d %{buildroot}%{_datadir}/%{name}/bazzite
install -Dm0755 packaging/bazzite/kde-desktop-setup.sh %{buildroot}%{_datadir}/%{name}/bazzite/kde-desktop-setup.sh
# SELinux dontaudit drop-in for Bazzite/SteamOS: Valve's ds_inhibit (steamos-manager) walks
# /proc/*/fd on every open/close of a hid-playstation hidraw — our virtual DualSense — and the
# denied walk sprays ~324 AVCs/sec, which setroubleshootd amplifies into a box-wide stall that
# starves the stream. Shipped as CIL source (the policy STORE is host state); inserted by %%post
# below / punktfunk-sysext post_merge where steamos-manager exists. See the file's header.
install -Dm0644 packaging/bazzite/punktfunk-ds-inhibit.cil \
%{buildroot}%{_datadir}/%{name}/selinux/punktfunk-ds-inhibit.cil
# Layered-update helper for rpm-ostree hosts: `rpm-ostree upgrade` only re-resolves layered
# packages when the BASE changes, so a frozen Bazzite base pins punktfunk forever. The script
# forces a re-resolve of just this layer (--uninstall + --install of the same names in one
@@ -674,6 +681,17 @@ udevadm trigger --subsystem-match=misc 2>/dev/null || :
# Apply the UDP socket-buffer tuning (also auto-applied at boot by systemd-sysctl; on rpm-ostree
# it takes effect on the next boot into the layered deployment).
sysctl -p %{_prefix}/lib/sysctl.d/99-punktfunk-net.conf >/dev/null 2>&1 || :
# Bazzite/SteamOS only (keyed on the steamos-manager binary): insert the ds_inhibit dontaudit
# drop-in — Valve's ds_inhibit walks /proc on every open/close of our virtual DualSense's hidraw,
# the denied walk sprays AVCs, and setroubleshootd amplifies that into a box-wide stall (see
# packaging/bazzite/punktfunk-ds-inhibit.cil). Keyed on the module NAME for idempotence (a policy
# rebuild costs seconds — rename the file if the rules ever change). Best-effort and never fatal:
# rpm-ostree's scriptlet sandbox may refuse semodule; the sysext post_merge and the README's
# manual command cover that path.
if command -v semodule >/dev/null 2>&1 && [ -e /usr/lib/steamos-manager ] &&
! semodule -l 2>/dev/null | grep -qx punktfunk-ds-inhibit; then
semodule -i %{_datadir}/%{name}/selinux/punktfunk-ds-inhibit.cil >/dev/null 2>&1 || :
fi
echo "punktfunk installed. Add yourself to the 'input' group (sudo usermod -aG input \$USER)"
# Naming only the usbip pad here is how a Nobara host shipped broken: its owner had no Deck pad, so
# they correctly skipped this group — and then every managed gamescope takeover degraded silently,
+35
View File
@@ -280,6 +280,41 @@
"pairing_pending_age_just_now": "gerade eben",
"pairing_pending_age_secs": "vor {s}s",
"pairing_pending_age_mins": "vor {min} min",
"pairing_access": "Zugriff",
"pairing_approve_desc": "Lege fest, was dieses Gerät darf und wie lange. Die Freigabe koppelt es sofort.",
"pairing_approve_known_note": "Dieses Gerät war schon gekoppelt — sein bisheriger Zugriff ist vorausgefüllt.",
"pairing_approve_guest": "Als Gast freigeben",
"pairing_approve_guest_hint": "Nur Controller, läuft nach 4 Stunden ab.",
"access_level_label": "Zugriffsstufe",
"access_level_full": "Vollzugriff",
"access_level_controller": "Nur Controller",
"access_level_view": "Nur zusehen",
"access_level_custom": "Benutzerdefiniert",
"access_advanced": "Erweitert",
"access_grant_gamepad": "Controller",
"access_grant_pointer": "Maus, Touch & Stift",
"access_grant_keyboard": "Tastatur",
"access_grant_clipboard": "Zwischenablage",
"access_grant_mic": "Mikrofon",
"access_grant_launch": "Spiele starten",
"access_expires_label": "Zugriff läuft ab",
"access_expires_keep": "Aktuellen Ablauf beibehalten",
"access_expires_forever": "Nie",
"access_expires_1h": "In 1 Stunde",
"access_expires_4h": "In 4 Stunden",
"access_expires_8h": "In 8 Stunden",
"access_expires_custom": "Benutzerdefiniert…",
"access_expires_custom_hours": "Stunden",
"access_left_days": "noch {d} d",
"access_left_hours": "noch {h} h",
"access_left_minutes": "noch {min} min",
"access_left_under_minute": "noch <1 min",
"access_expired": "Abgelaufen",
"access_ungoverned": "Voll (nicht einschränkbar)",
"access_edit_title": "Zugriff bearbeiten",
"access_edit_desc": "Was {name} auf diesem Host darf — und wie lange.",
"access_expire_now": "Jetzt ablaufen lassen",
"access_edit_failed": "Zugriff konnte nicht geändert werden.",
"pairing_moonlight_title": "Moonlight-Kopplung (GameStream)",
"library_title": "Bibliothek",
"library_empty": "Noch keine Spiele gefunden.",
+35
View File
@@ -280,6 +280,41 @@
"pairing_pending_age_just_now": "just now",
"pairing_pending_age_secs": "{s}s ago",
"pairing_pending_age_mins": "{min} min ago",
"pairing_access": "Access",
"pairing_approve_desc": "Choose what this device may do and for how long. Approving pairs it immediately.",
"pairing_approve_known_note": "This device was paired before — its previous access is pre-filled.",
"pairing_approve_guest": "Approve as guest",
"pairing_approve_guest_hint": "Controller only, expires after 4 hours.",
"access_level_label": "Access level",
"access_level_full": "Full control",
"access_level_controller": "Controller only",
"access_level_view": "View only",
"access_level_custom": "Custom",
"access_advanced": "Advanced",
"access_grant_gamepad": "Controller",
"access_grant_pointer": "Mouse, touch & pen",
"access_grant_keyboard": "Keyboard",
"access_grant_clipboard": "Clipboard",
"access_grant_mic": "Microphone",
"access_grant_launch": "Launch games",
"access_expires_label": "Access expires",
"access_expires_keep": "Keep current expiry",
"access_expires_forever": "Never",
"access_expires_1h": "In 1 hour",
"access_expires_4h": "In 4 hours",
"access_expires_8h": "In 8 hours",
"access_expires_custom": "Custom…",
"access_expires_custom_hours": "Hours",
"access_left_days": "{d} d left",
"access_left_hours": "{h} h left",
"access_left_minutes": "{min} min left",
"access_left_under_minute": "<1 min left",
"access_expired": "Expired",
"access_ungoverned": "Full (ungoverned)",
"access_edit_title": "Edit access",
"access_edit_desc": "What {name} may do on this host — and for how long.",
"access_expire_now": "Expire now",
"access_edit_failed": "Couldn't update access.",
"pairing_moonlight_title": "Moonlight (GameStream) pairing",
"library_title": "Library",
"library_empty": "No games found yet.",
@@ -0,0 +1,39 @@
// PUT /api/v1/library/provider/{provider} — the reconcile route a provider plugin uses to publish
// its whole set of entries at once. It carries the SAME primitive the create/update routes gate:
// an entry in the payload can bring `prep` or a `launch.kind === "command"`, both run verbatim as
// the host user. The host defers the "is this the operator's authority?" question to the console
// (mgmt/library.rs), and the console must actually ask it — otherwise a bare session cookie plants
// a persistent command through this lane while `custom.post.ts`/`custom/[id].put.ts` are gated.
// See util/libraryConfirm.ts; security-review 2026-08-15 finding 6.
//
// Wins over the `/api/**` catch-all by h3 route specificity — the catch-all injects the admin
// bearer unconditionally, so without this file the host-side check is inert for this caller.
import { defineEventHandler, getRequestURL, getRouterParam, readBody } from "h3";
import { forwardJson } from "../../../../../util/forward";
import { confirmIfCommandExecution } from "../../../../../util/libraryConfirm";
// The host takes a bare array of entries. Accept that, and also a `{ password, entries }` wrapper
// so the console can carry the confirmation the gate needs; either way only the array is forwarded.
type Entry = Record<string, unknown>;
interface Wrapper {
password?: unknown;
entries?: Entry[];
}
export default defineEventHandler(async (event) => {
const provider = getRouterParam(event, "provider") ?? "";
const body = await readBody<Entry[] | Wrapper>(event);
const entries = Array.isArray(body) ? body : (body?.entries ?? []);
const password = Array.isArray(body) ? undefined : body?.password;
// `confirmIfCommandExecution` already iterates arrays and only prompts when an entry actually
// carries a shell field — an ordinary catalog reconcile forwards untouched.
confirmIfCommandExecution(event, entries, password);
// Preserve `?store=` (the provider routes are store-qualified upstream).
const { search } = getRequestURL(event);
return forwardJson(
event,
`/api/v1/library/provider/${encodeURIComponent(provider)}${search}`,
"PUT",
entries,
);
});
+165
View File
@@ -0,0 +1,165 @@
import { Timer } from "lucide-react";
import { type FC, useEffect, useState } from "react";
import type { ApprovePending } from "@/api/gen/model/approvePending";
import type { PendingDevice } from "@/api/gen/model/pendingDevice";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { m } from "@/paraglide/messages";
import {
type AccessDraft,
AccessControls,
draftExpirySecs,
draftFromStored,
GRANT_ALL,
GUEST_EXPIRES_SECS,
PRESET_CONTROLLER,
} from "./access";
/**
* Approving a pending device the ONE dialog of the co-play flow: name, access level, expiry.
*
* Defaults are Full control · Forever (D1): pairing your OWN new device is the common case, and a
* default-limited dialog trains reflexive clicking-through. The guest case gets its own
* affordance instead "Approve as guest" (Controller only · 4 h) is one click here, two from the
* pending row, and the expiry cleans up after the evening on its own.
*
* `device` is the row SNAPSHOT taken when the dialog opened, not the live polled row the
* pending list refetches every 10 s, and a fresh object identity mid-edit must not reset the
* operator's typing.
*/
export const ApproveDialog: FC<{
/** The device being approved, or null when the dialog is closed. */
device: PendingDevice | null;
onCancel: () => void;
onApprove: (id: number, body: ApprovePending) => void;
isPending: boolean;
}> = ({ device, onCancel, onApprove, isPending }) => {
const [name, setName] = useState("");
const [draft, setDraft] = useState<AccessDraft>(() =>
draftFromStored(null, null, null),
);
// The knocking fingerprint may already be paired (the expired-guest re-knock) — then the
// stored access pre-fills the dialog, so re-approval is re-granting what they had.
const known =
device != null &&
(device.grants != null ||
device.expires_unix != null ||
device.access_level != null);
// Re-arm the form for each newly opened device (and only then — never mid-edit, because the
// snapshot's identity is stable for as long as the dialog is open).
useEffect(() => {
if (!device) return;
setName(device.name);
setDraft(
draftFromStored(device.grants, device.expires_unix, device.granted_unix),
);
}, [device]);
const trimmedName = (): string | null => (name.trim() ? name.trim() : null);
const submit = () => {
if (!device) return;
const body: ApprovePending = { name: trimmedName() };
const secs = draftExpirySecs(draft);
// A device with a stored record gets the dialog's state EXPLICITLY (what you see is what
// is granted — omitting both fields would silently keep the stored access instead). For an
// unknown device the untouched Full · Forever default is omitted: identical semantics, and
// an older host that predates the fields sees exactly yesterday's request.
if (known || draft.grants !== GRANT_ALL || secs != null) {
body.grants = draft.grants;
if (secs != null) body.expires_in_secs = secs;
}
onApprove(device.id, body);
};
const approveAsGuest = () => {
if (!device) return;
onApprove(device.id, {
name: trimmedName(),
grants: PRESET_CONTROLLER,
expires_in_secs: GUEST_EXPIRES_SECS,
});
};
return (
<Dialog open={device !== null} onOpenChange={(open) => !open && onCancel()}>
{device && (
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{m.pairing_pending_name_title()}</DialogTitle>
{/* The name field below is editable text, not a statement of WHICH knock
this is with two devices waiting the operator would be approving
whichever row they hope they clicked. The fingerprint is the only
thing that stays unique when two devices share a name, so the
description states it (security-review 2026-08-15, carried into the
access dialog that replaced the plain name prompt). */}
<DialogDescription>
{m.pairing_pending_name_desc({
name: device.name,
fp: `${device.fingerprint.slice(0, 16)}`,
})}{" "}
{m.pairing_approve_desc()}
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="approve-name">{m.pairing_pending_name_prompt()}</Label>
<Input
id="approve-name"
autoFocus
autoComplete="off"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
{known && (
<p className="rounded-md bg-muted px-3 py-2 text-xs text-muted-foreground">
{m.pairing_approve_known_note()}
</p>
)}
<AccessControls value={draft} onChange={setDraft} idPrefix="approve" />
{/* The guest fast path visually its own thing, deliberately not one of the footer
buttons: one click grants Controller only for 4 hours, no dialog fiddling. */}
<div className="flex items-center justify-between gap-3 rounded-md border p-3">
<p className="text-xs text-muted-foreground">
{m.pairing_approve_guest_hint()}
</p>
<Button
variant="secondary"
size="sm"
className="shrink-0"
disabled={isPending}
onClick={approveAsGuest}
>
<Timer className="size-4" />
{m.pairing_approve_guest()}
</Button>
</div>
<DialogFooter>
<Button variant="outline" onClick={onCancel} disabled={isPending}>
{m.common_cancel()}
</Button>
<Button disabled={isPending} onClick={submit}>
{m.pairing_pending_approve()}
</Button>
</DialogFooter>
</DialogContent>
)}
</Dialog>
);
};
@@ -0,0 +1,143 @@
import { TimerOff, Trash2 } from "lucide-react";
import { type FC, useEffect, useState } from "react";
import type { UpdateNativeAccess } from "@/api/gen/model/updateNativeAccess";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { m } from "@/paraglide/messages";
import {
type AccessDraft,
AccessControls,
draftExpirySecs,
GRANT_ALL,
} from "./access";
/** What the edit sheet needs to know about the row it edits — a SNAPSHOT taken on open. */
export interface EditAccessTarget {
fingerprint: string;
name: string;
grants: number | null | undefined;
expiresUnix: number | null | undefined;
}
/**
* The per-device access editor (native rows only the GameStream plane is not governed yet and
* its rows say so instead of pretending). Preset + Advanced toggles, extend / expire now / make
* permanent (= Expires: Forever), and remove.
*
* The PATCH is PARTIAL: only what changed is sent an untouched mask stays omitted, and the
* default "Keep current expiry" maps to omitting both expiry fields.
*/
export const EditAccessSheet: FC<{
/** The row being edited, or null when the sheet is closed. */
target: EditAccessTarget | null;
nowUnix: number;
onCancel: () => void;
onSave: (fingerprint: string, body: UpdateNativeAccess) => void;
/** "Expire now" — cuts live sessions from this device with the typed close. */
onExpireNow: (fingerprint: string) => void;
/** Hands off to the existing unpair confirmation. */
onRemove: (fingerprint: string) => void;
isPending: boolean;
}> = ({ target, nowUnix, onCancel, onSave, onExpireNow, onRemove, isPending }) => {
const [draft, setDraft] = useState<AccessDraft>({
grants: GRANT_ALL,
expiry: "keep",
customHours: 4,
});
// Re-arm for each newly opened row; `target` is a snapshot, so this never fires mid-edit.
useEffect(() => {
if (!target) return;
setDraft({
grants: (target.grants ?? GRANT_ALL) & GRANT_ALL,
// A permanent device has no expiry to keep — "Forever" is its no-change state.
expiry: target.expiresUnix != null ? "keep" : "forever",
customHours: 4,
});
}, [target]);
const expired =
target?.expiresUnix != null && target.expiresUnix <= nowUnix;
const save = () => {
if (!target) return;
const body: UpdateNativeAccess = {};
if (draft.grants !== ((target.grants ?? GRANT_ALL) & GRANT_ALL))
body.grants = draft.grants;
if (draft.expiry === "forever") {
// Only an actual change clears — `clear_expiry` on a permanent device is a no-op
// request the host doesn't need to see.
if (target.expiresUnix != null) body.clear_expiry = true;
} else {
const secs = draftExpirySecs(draft);
if (secs != null) body.expires_in_secs = secs;
}
if (Object.keys(body).length === 0) {
onCancel(); // nothing changed — no request to make
return;
}
onSave(target.fingerprint, body);
};
return (
<Dialog open={target !== null} onOpenChange={(open) => !open && onCancel()}>
{target && (
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{m.access_edit_title()}</DialogTitle>
<DialogDescription>
{m.access_edit_desc({ name: target.name || target.fingerprint.slice(0, 16) })}
</DialogDescription>
</DialogHeader>
<AccessControls
value={draft}
onChange={setDraft}
idPrefix="edit-access"
allowKeepExpiry={target.expiresUnix != null}
/>
<div className="flex flex-wrap gap-2">
{/* An expired device has nothing left to cut — re-granting is the verb then. */}
{!expired && (
<Button
variant="outline"
size="sm"
disabled={isPending}
onClick={() => onExpireNow(target.fingerprint)}
>
<TimerOff className="size-4" />
{m.access_expire_now()}
</Button>
)}
<Button
variant="destructive"
size="sm"
disabled={isPending}
onClick={() => onRemove(target.fingerprint)}
>
<Trash2 className="size-4" />
{m.action_unpair()}
</Button>
</div>
<DialogFooter>
<Button variant="outline" onClick={onCancel} disabled={isPending}>
{m.common_cancel()}
</Button>
<Button disabled={isPending} onClick={save}>
{m.common_save()}
</Button>
</DialogFooter>
</DialogContent>
)}
</Dialog>
);
};
+41 -5
View File
@@ -1,6 +1,7 @@
import { useQueryClient } from "@tanstack/react-query";
import { KeyRound, Smartphone, Timer } from "lucide-react";
import { type FC, useEffect, useRef } from "react";
import { type FC, useEffect, useRef, useState } from "react";
import type { ArmNativePairing } from "@/api/gen/model/armNativePairing";
import type { NativePairStatus } from "@/api/gen/model/nativePairStatus";
import {
getGetNativePairingQueryKey,
@@ -14,6 +15,12 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import type { Loadable } from "@/lib/query";
import { m } from "@/paraglide/messages";
import {
type AccessDraft,
AccessControls,
draftExpirySecs,
GRANT_ALL,
} from "./access";
/** Seconds → `m:ss`. */
function fmtTime(secs: number): string {
@@ -52,8 +59,10 @@ export const NativePairingSection: FC = () => {
const refresh = () =>
qc.invalidateQueries({ queryKey: getGetNativePairingQueryKey() });
const onArm = () =>
arm.mutate({ data: { ttl_secs: 120 } }, { onSuccess: refresh });
// `access` carries the window's device-access choice (grants + expiry) — NOT the window TTL;
// whichever device completes this window's ceremony gets it.
const onArm = (access: Partial<ArmNativePairing>) =>
arm.mutate({ data: { ttl_secs: 120, ...access } }, { onSuccess: refresh });
const onDisarm = () => disarm.mutate(undefined, { onSuccess: refresh });
return (
@@ -70,12 +79,32 @@ export const NativePairingSection: FC = () => {
/** Native (punktfunk/1) pairing: arm a window → DISPLAY the PIN the user enters on their device. */
export const NativePairingCard: FC<{
status: Loadable<NativePairStatus>;
onArm: () => void;
/** Arm, carrying the chosen device access (empty = today's full/permanent behavior). */
onArm: (access: Partial<ArmNativePairing>) => void;
onDisarm: () => void;
isArming: boolean;
isDisarming: boolean;
}> = ({ status, onArm, onDisarm, isArming, isDisarming }) => {
const d = status.data;
// What the pairing device will be allowed to do — same defaults as the approve dialog (D1):
// Full · Forever, because arming for your OWN next device is the common case.
const [draft, setDraft] = useState<AccessDraft>({
grants: GRANT_ALL,
expiry: "forever",
customHours: 4,
});
const arm = () => {
const secs = draftExpirySecs(draft);
const access: Partial<ArmNativePairing> = {};
// The untouched Full · Forever default is omitted entirely: a re-pairing device then keeps
// the access it already has (the API's omitted-fields contract), and an older host that
// predates the fields sees exactly yesterday's request.
if (draft.grants !== GRANT_ALL || secs != null) {
access.grants = draft.grants;
if (secs != null) access.expires_in_secs = secs;
}
onArm(access);
};
return (
<QueryState
isLoading={status.isLoading}
@@ -120,7 +149,14 @@ export const NativePairingCard: FC<{
<p className="text-sm text-muted-foreground">
{m.pairing_native_desc()}
</p>
<Button disabled={isArming} onClick={onArm}>
{/* The window's device-access choice whichever device completes the
ceremony gets exactly this (design §6.2). */}
<AccessControls
value={draft}
onChange={setDraft}
idPrefix="arm"
/>
<Button disabled={isArming} onClick={arm}>
<KeyRound className="size-4" />
{m.pairing_native_arm()}
</Button>
+145 -27
View File
@@ -1,18 +1,20 @@
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "@unom/ui/toast";
import { Trash2 } from "lucide-react";
import type { FC } from "react";
import { SlidersHorizontal, Trash2 } from "lucide-react";
import { type FC, useState } from "react";
import {
getListPairedClientsQueryKey,
useListPairedClients,
useUnpairAllClients,
useUnpairClient,
} from "@/api/gen/clients/clients";
import type { UpdateNativeAccess } from "@/api/gen/model/updateNativeAccess";
import {
getListNativeClientsQueryKey,
useListNativeClients,
useUnpairAllNativeClients,
useUnpairNativeClient,
useUpdateNativeClientAccess,
} from "@/api/gen/native/native";
import { useDialogs } from "@/components/dialogs";
import { QueryState } from "@/components/query-state";
@@ -28,6 +30,8 @@ import {
TableRow,
} from "@/components/ui/table";
import { m } from "@/paraglide/messages";
import { AccessChip, useNowUnix } from "./access";
import { EditAccessSheet, type EditAccessTarget } from "./EditAccessSheet";
/** The two pairing protocols a device can be paired over. */
export type PairedProtocol = "native" | "moonlight";
@@ -38,8 +42,24 @@ export interface PairedRow {
fingerprint: string;
/** Native devices carry a name; Moonlight clients carry a cert subject; either may be empty. */
name: string;
/**
* Access fields native rows only, and only from hosts that have them (the console pairs
* against older hosts: all four stay `undefined` then, and the Access column shows "—").
* `access_level` is the presence sentinel a current host always derives it for a
* NativeClient, so its absence means the host predates per-client access.
*/
accessLevel?: string | null;
/** Grant bitmask; `null` = a pre-grants record = full control. */
grants?: number | null;
/** Absolute expiry (unix secs); `null` = permanent. "Expired" is our arithmetic. */
expiresUnix?: number | null;
}
/** Whether the host reported access fields for this row (⇒ the chip and editor exist). */
const hasAccess = (r: PairedRow): boolean =>
r.protocol === "native" &&
(r.accessLevel != null || r.grants != null || r.expiresUnix != null);
/**
* Container: ALL paired devices in one list. Merges the native (punktfunk/1) clients and the
* GameStream/Moonlight clients two separate host endpoints into a single table tagged by
@@ -54,6 +74,13 @@ export const PairedDevicesSection: FC = () => {
const unpairMoonlight = useUnpairClient();
const unpairAllNative = useUnpairAllNativeClients();
const unpairAllMoonlight = useUnpairAllClients();
const patchAccess = useUpdateNativeClientAccess();
// One clock for every countdown in the card AND the sheet — recomputed client-side from
// `expires_unix`, so the tick never refetches anything.
const nowUnix = useNowUnix();
// The row whose access is being edited — a snapshot, so a background refetch can't yank the
// form out from under the operator.
const [editing, setEditing] = useState<EditAccessTarget | null>(null);
const rows: PairedRow[] = [
...(native.data ?? []).map(
@@ -61,6 +88,9 @@ export const PairedDevicesSection: FC = () => {
protocol: "native",
fingerprint: c.fingerprint,
name: c.name,
accessLevel: c.access_level,
grants: c.grants,
expiresUnix: c.expires_unix,
}),
),
...(moonlight.data ?? []).map(
@@ -99,6 +129,27 @@ export const PairedDevicesSection: FC = () => {
}
};
const savedAccess = () => {
setEditing(null);
qc.invalidateQueries({ queryKey: getListNativeClientsQueryKey() });
};
const onSaveAccess = (fingerprint: string, body: UpdateNativeAccess) =>
patchAccess.mutate(
{ fingerprint, data: body },
{
onSuccess: savedAccess,
onError: () => toast.error(m.access_edit_failed()),
},
);
// "Expire now" = the same partial PATCH with a zero relative expiry — cuts live sessions
// from this device with the typed AccessExpired close; the row stays listed as "Expired".
const onExpireNow = (fingerprint: string) =>
onSaveAccess(fingerprint, { expires_in_secs: 0 });
const onRemoveFromSheet = (fingerprint: string) => {
setEditing(null);
void onUnpair("native", fingerprint);
};
/**
* Unpair EVERY device, in one confirmation.
*
@@ -149,19 +200,39 @@ export const PairedDevicesSection: FC = () => {
unpairAllNative.isPending || unpairAllMoonlight.isPending;
return (
<PairedDevices
rows={rows}
isLoading={native.isLoading || moonlight.isLoading}
error={native.error ?? moonlight.error}
refetch={() => {
native.refetch();
moonlight.refetch();
}}
onUnpair={onUnpair}
onUnpairAll={onUnpairAll}
pendingFingerprint={pendingFingerprint}
isUnpairingAll={isUnpairingAll}
/>
<>
<PairedDevices
rows={rows}
isLoading={native.isLoading || moonlight.isLoading}
error={native.error ?? moonlight.error}
refetch={() => {
native.refetch();
moonlight.refetch();
}}
nowUnix={nowUnix}
onEditAccess={(r) =>
setEditing({
fingerprint: r.fingerprint,
name: r.name,
grants: r.grants,
expiresUnix: r.expiresUnix,
})
}
onUnpair={onUnpair}
onUnpairAll={onUnpairAll}
pendingFingerprint={pendingFingerprint}
isUnpairingAll={isUnpairingAll}
/>
<EditAccessSheet
target={editing}
nowUnix={nowUnix}
onCancel={() => setEditing(null)}
onSave={onSaveAccess}
onExpireNow={onExpireNow}
onRemove={onRemoveFromSheet}
isPending={patchAccess.isPending}
/>
</>
);
};
@@ -171,6 +242,10 @@ export const PairedDevices: FC<{
isLoading: boolean;
error: unknown;
refetch: () => void;
/** Wall clock (unix secs) for the countdown chips — ONE ticking value for the whole table. */
nowUnix: number;
/** Open the access editor for a native row (only offered where `hasAccess`). */
onEditAccess: (row: PairedRow) => void;
onUnpair: (protocol: PairedProtocol, fingerprint: string) => void;
/** Unpair every row, behind one confirmation. */
onUnpairAll: () => void;
@@ -183,6 +258,8 @@ export const PairedDevices: FC<{
isLoading,
error,
refetch,
nowUnix,
onEditAccess,
onUnpair,
onUnpairAll,
pendingFingerprint,
@@ -217,8 +294,9 @@ export const PairedDevices: FC<{
<TableRow>
<TableHead>{m.clients_name()}</TableHead>
<TableHead>{m.pairing_protocol()}</TableHead>
<TableHead>{m.pairing_access()}</TableHead>
<TableHead>{m.clients_fingerprint()}</TableHead>
<TableHead className="w-12" />
<TableHead className="w-20" />
</TableRow>
</TableHeader>
<TableBody>
@@ -236,21 +314,61 @@ export const PairedDevices: FC<{
: m.pairing_protocol_moonlight()}
</Badge>
</TableCell>
<TableCell>
{r.protocol === "moonlight" ? (
// Honest: the GameStream plane isn't governed by grants
// (yet) — a Moonlight device has full control, and this
// chip says so instead of offering a fake editor.
<Badge
variant="outline"
className="whitespace-nowrap text-muted-foreground"
>
{m.access_ungoverned()}
</Badge>
) : hasAccess(r) ? (
<AccessChip
grants={r.grants}
expiresUnix={r.expiresUnix}
nowUnix={nowUnix}
/>
) : (
// A host older than per-client access reports nothing —
// say nothing rather than guessing.
<span className="text-muted-foreground"></span>
)}
</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground">
{r.fingerprint.slice(0, 16)}
</TableCell>
<TableCell>
<Button
variant="ghost"
size="icon"
aria-label={m.action_unpair()}
disabled={
isUnpairingAll || pendingFingerprint === r.fingerprint
}
onClick={() => onUnpair(r.protocol, r.fingerprint)}
>
<Trash2 className="size-4 text-destructive" />
</Button>
<div className="flex justify-end">
{hasAccess(r) && (
<Button
variant="ghost"
size="icon"
aria-label={m.access_edit_title()}
disabled={
isUnpairingAll ||
pendingFingerprint === r.fingerprint
}
onClick={() => onEditAccess(r)}
>
<SlidersHorizontal className="size-4" />
</Button>
)}
<Button
variant="ghost"
size="icon"
aria-label={m.action_unpair()}
disabled={
isUnpairingAll ||
pendingFingerprint === r.fingerprint
}
onClick={() => onUnpair(r.protocol, r.fingerprint)}
>
<Trash2 className="size-4 text-destructive" />
</Button>
</div>
</TableCell>
</TableRow>
))}
+33 -32
View File
@@ -1,7 +1,8 @@
import { useQueryClient } from "@tanstack/react-query";
import { UserPlus, X } from "lucide-react";
import type { FC } from "react";
import type { PendingDevice } from "@/api/gen/model";
import { type FC, useState } from "react";
import type { ApprovePending } from "@/api/gen/model/approvePending";
import type { PendingDevice } from "@/api/gen/model/pendingDevice";
import {
getListNativeClientsQueryKey,
getListPendingDevicesQueryKey,
@@ -9,7 +10,6 @@ import {
useDenyPendingDevice,
useListPendingDevices,
} from "@/api/gen/native/native";
import { useDialogs } from "@/components/dialogs";
import { QueryState } from "@/components/query-state";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -17,6 +17,7 @@ import { Table, TableBody, TableCell, TableRow } from "@/components/ui/table";
import type { Loadable } from "@/lib/query";
import { fmtAge } from "@/lib/utils";
import { m } from "@/paraglide/messages";
import { ApproveDialog } from "./ApproveDialog";
/**
* Container: devices awaiting delegated approval. Polls so a knock appears while
@@ -28,36 +29,26 @@ export const PendingDevicesSection: FC = () => {
// A knock arrives as a `pairing.pending` event (api/events.ts), so the timer is the fallback —
// but it stays reasonably brisk: this list is the one the operator is actively waiting on, and
// the rows carry an age that should not visibly lag.
const { promptText } = useDialogs();
const pending = useListPendingDevices({ query: { refetchInterval: 10_000 } });
const approve = useApprovePendingDevice();
const deny = useDenyPendingDevice();
// The row whose Approve dialog is open — a snapshot, so the 10 s poll can't reset the form.
const [approving, setApproving] = useState<PendingDevice | null>(null);
const refresh = () => {
qc.invalidateQueries({ queryKey: getListPendingDevicesQueryKey() });
qc.invalidateQueries({ queryKey: getListNativeClientsQueryKey() });
};
// The dialog names the device it is about — the field is pre-filled with the same string, but a
// pre-filled field is editable text, not a statement of WHICH knock this is. With two devices
// waiting the operator would otherwise be approving whichever row they hope they clicked, so the
// fingerprint rides along: it is the only thing that stays unique when two devices share a name.
const onApprove = async (id: number, currentName: string, fingerprint: string) => {
const name = await promptText({
title: m.pairing_pending_name_title(),
description: m.pairing_pending_name_desc({
name: currentName,
fp: `${fingerprint.slice(0, 16)}`,
}),
label: m.pairing_pending_name_prompt(),
defaultValue: currentName,
confirmLabel: m.pairing_pending_approve(),
});
if (name == null) return; // operator cancelled
const onApprove = (id: number, body: ApprovePending) =>
approve.mutate(
{ id, data: { name: name.trim() ? name.trim() : null } },
{ onSuccess: refresh },
{ id, data: body },
{
onSuccess: () => {
setApproving(null);
refresh();
},
},
);
};
const onDeny = (id: number) => deny.mutate({ id }, { onSuccess: refresh });
// The id of the row whose approve/deny is in flight — only that row's buttons disable.
@@ -67,23 +58,33 @@ export const PendingDevicesSection: FC = () => {
null;
return (
<PendingDevices
pending={pending}
onApprove={onApprove}
onDeny={onDeny}
pendingId={pendingId}
/>
<>
<PendingDevices
pending={pending}
onApprove={setApproving}
onDeny={onDeny}
pendingId={pendingId}
/>
<ApproveDialog
device={approving}
onCancel={() => setApproving(null)}
onApprove={onApprove}
isPending={approve.isPending}
/>
</>
);
};
/**
* Devices awaiting delegated approval: an unpaired device that tried to connect
* shows up here, and Approve pairs it on the spot. Renders nothing while empty
* shows up here, and Approve opens the access dialog (name + access level +
* expiry one dialog, per design §6.1). Renders nothing while empty
* (the common case) unless there's an error to surface.
*/
export const PendingDevices: FC<{
pending: Loadable<PendingDevice[]>;
onApprove: (id: number, currentName: string, fingerprint: string) => void;
/** Opens the approve dialog for this row. */
onApprove: (device: PendingDevice) => void;
onDeny: (id: number) => void;
/** Id of the row whose approve/deny is in flight, or null — only that row disables. */
pendingId: number | null;
@@ -144,7 +145,7 @@ export const PendingDevices: FC<{
<Button
size="sm"
disabled={pendingId === p.id}
onClick={() => onApprove(p.id, p.name, p.fingerprint)}
onClick={() => onApprove(p)}
>
{m.pairing_pending_approve()}
</Button>
+311
View File
@@ -0,0 +1,311 @@
import { ChevronDown, ChevronRight } from "lucide-react";
import { type FC, useEffect, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { InputNumber } from "@/components/ui/input-number";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { m } from "@/paraglide/messages";
/**
* The access-control vocabulary + the ONE control family for all three grant moments the
* approve dialog, the arm card, and the paired-row edit sheet (design: per-client-access.md §6).
*
* Grant bits mirror `punktfunk-core` `quic/access.rs` (wire == store; reserved bits must be
* zero). The preset label is DERIVED from the mask, never stored no drift, and a mask the
* presets don't cover honestly displays as "Custom".
*/
export const GRANT_GAMEPAD = 0x01;
export const GRANT_POINTER = 0x02;
export const GRANT_KEYBOARD = 0x04;
export const GRANT_CLIPBOARD = 0x08;
export const GRANT_MIC = 0x10;
export const GRANT_LAUNCH = 0x20;
export const GRANT_ALL = 0x3f;
/** The guest preset (D2): controller only, WITHOUT launch — the owner drives what runs. */
export const PRESET_CONTROLLER = GRANT_GAMEPAD;
export const PRESET_VIEW = 0;
/** The one-click "Approve as guest" duration (D4): 4 hours. */
export const GUEST_EXPIRES_SECS = 4 * 3600;
export type AccessLevel = "full" | "controller" | "view" | "custom";
/** Preset name for a mask — derived, never stored (design §3.2). */
export const levelOfMask = (mask: number): AccessLevel => {
switch (mask & GRANT_ALL) {
case GRANT_ALL:
return "full";
case PRESET_CONTROLLER:
return "controller";
case PRESET_VIEW:
return "view";
default:
return "custom";
}
};
export const levelLabel = (level: AccessLevel): string => {
switch (level) {
case "full":
return m.access_level_full();
case "controller":
return m.access_level_controller();
case "view":
return m.access_level_view();
case "custom":
return m.access_level_custom();
}
};
/**
* The expiry the operator is choosing. "keep" only exists in the edit sheet (a device already
* has an expiry and the PATCH is partial omitting the field keeps it).
*/
export type ExpiryChoice = "keep" | "forever" | "1h" | "4h" | "8h" | "custom";
export interface AccessDraft {
/** Grant bitmask (`GRANT_*`). */
grants: number;
expiry: ExpiryChoice;
/** Only read when `expiry === "custom"`. */
customHours: number;
}
/** Relative seconds for the drafted expiry, or null for forever/keep (callers omit the field). */
export const draftExpirySecs = (draft: AccessDraft): number | null => {
switch (draft.expiry) {
case "1h":
return 3600;
case "4h":
return 4 * 3600;
case "8h":
return 8 * 3600;
case "custom":
return Math.max(1, Math.round(draft.customHours)) * 3600;
default:
return null;
}
};
/**
* Pre-fill a draft from a stored record (the expired-guest re-knock: "re-grant what they had").
* The stored expiry is ABSOLUTE and usually already past, so the previous DURATION is what gets
* re-offered recovered from `expires - granted` when both are known, else Forever.
*/
export const draftFromStored = (
grants: number | null | undefined,
expiresUnix: number | null | undefined,
grantedUnix: number | null | undefined,
): AccessDraft => {
// null grants = a pre-grants record = full control (the API contract).
const mask = grants ?? GRANT_ALL;
if (expiresUnix == null || grantedUnix == null || expiresUnix <= grantedUnix)
return { grants: mask, expiry: "forever", customHours: 4 };
const secs = expiresUnix - grantedUnix;
const expiry: ExpiryChoice =
secs === 3600
? "1h"
: secs === 4 * 3600
? "4h"
: secs === 8 * 3600
? "8h"
: "custom";
return {
grants: mask,
expiry,
customHours: Math.max(1, Math.round(secs / 3600)),
};
};
/**
* The wall clock, ticking on ONE shared interval per caller every countdown in a list derives
* from this single value, so a page of rows re-renders once per tick and never refetches.
*/
export const useNowUnix = (stepMs = 30_000): number => {
const [now, setNow] = useState(() => Math.floor(Date.now() / 1000));
useEffect(() => {
const t = setInterval(
() => setNow(Math.floor(Date.now() / 1000)),
stepMs,
);
return () => clearInterval(t);
}, [stepMs]);
return now;
};
/** Remaining seconds → a short "left" label ("2 h left"); the caller has ruled out ≤ 0. */
export const fmtRemaining = (secs: number): string => {
if (secs >= 48 * 3600) return m.access_left_days({ d: Math.round(secs / 86400) });
if (secs >= 3600) return m.access_left_hours({ h: Math.round(secs / 3600) });
if (secs >= 60) return m.access_left_minutes({ min: Math.ceil(secs / 60) });
return m.access_left_under_minute();
};
/**
* The Access chip: preset label + live countdown ("Controller · 2 h left"), or "Expired" once
* the deadline passed (the row stays listed D3). `grants` may be null (pre-grants = full).
*/
export const AccessChip: FC<{
grants: number | null | undefined;
expiresUnix: number | null | undefined;
nowUnix: number;
}> = ({ grants, expiresUnix, nowUnix }) => {
// "Expired" is the reader's arithmetic against the wall clock — the host keeps the row.
if (expiresUnix != null && expiresUnix <= nowUnix)
return <Badge variant="warning">{m.access_expired()}</Badge>;
const label = levelLabel(levelOfMask((grants ?? GRANT_ALL) & GRANT_ALL));
return (
<Badge variant="secondary" className="whitespace-nowrap">
{expiresUnix == null
? label
: `${label} · ${fmtRemaining(expiresUnix - nowUnix)}`}
</Badge>
);
};
/** The six toggles behind Advanced, in bit order. Labels name what the bit covers. */
const GRANT_TOGGLES: { bit: number; label: () => string }[] = [
{ bit: GRANT_GAMEPAD, label: () => m.access_grant_gamepad() },
{ bit: GRANT_POINTER, label: () => m.access_grant_pointer() },
{ bit: GRANT_KEYBOARD, label: () => m.access_grant_keyboard() },
{ bit: GRANT_CLIPBOARD, label: () => m.access_grant_clipboard() },
{ bit: GRANT_MIC, label: () => m.access_grant_mic() },
{ bit: GRANT_LAUNCH, label: () => m.access_grant_launch() },
];
/**
* The shared access controls: Access level (three presets + an Advanced expander with the six
* grant toggles) and Access expires (Forever / 1 h / 4 h / 8 h / custom). Three grant moments,
* one component approve dialog, arm card, edit sheet.
*/
export const AccessControls: FC<{
value: AccessDraft;
onChange: (next: AccessDraft) => void;
/** Distinct control ids when two instances could mount at once. */
idPrefix: string;
/** Edit sheet: the device already has an expiry — offer "Keep current expiry" (= omit). */
allowKeepExpiry?: boolean;
}> = ({ value, onChange, idPrefix, allowKeepExpiry }) => {
// The expander opens itself when the mask is already custom (a prefilled re-grant) — a
// "Custom" select over six hidden toggles would name a state it doesn't show.
const [advanced, setAdvanced] = useState(
() => levelOfMask(value.grants) === "custom",
);
const level = levelOfMask(value.grants);
const setLevel = (next: string) => {
if (next === "full") onChange({ ...value, grants: GRANT_ALL });
else if (next === "controller")
onChange({ ...value, grants: PRESET_CONTROLLER });
else if (next === "view") onChange({ ...value, grants: PRESET_VIEW });
// "custom" is display-only — it appears when the toggles made the mask custom.
};
return (
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor={`${idPrefix}-level`}>{m.access_level_label()}</Label>
<Select value={level} onValueChange={setLevel}>
<SelectTrigger id={`${idPrefix}-level`}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="full">{m.access_level_full()}</SelectItem>
<SelectItem value="controller">
{m.access_level_controller()}
</SelectItem>
<SelectItem value="view">{m.access_level_view()}</SelectItem>
{/* Only mounted while the toggles hold a non-preset mask "Custom" is a
state the presets can't express, not a preset to pick. */}
{level === "custom" && (
<SelectItem value="custom">{m.access_level_custom()}</SelectItem>
)}
</SelectContent>
</Select>
<button
type="button"
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
aria-expanded={advanced}
onClick={() => setAdvanced((v) => !v)}
>
{advanced ? (
<ChevronDown className="size-3" />
) : (
<ChevronRight className="size-3" />
)}
{m.access_advanced()}
</button>
{advanced && (
<div className="grid grid-cols-2 gap-x-4 gap-y-2 rounded-md border p-3">
{GRANT_TOGGLES.map(({ bit, label }) => (
<Label
key={bit}
className="flex items-center gap-2 text-sm font-normal"
>
<Checkbox
checked={(value.grants & bit) !== 0}
onCheckedChange={(next) =>
onChange({
...value,
grants:
next === true
? value.grants | bit
: value.grants & ~bit,
})
}
/>
{label()}
</Label>
))}
</div>
)}
</div>
<div className="space-y-2">
<Label htmlFor={`${idPrefix}-expires`}>{m.access_expires_label()}</Label>
<Select
value={value.expiry}
onValueChange={(expiry) =>
onChange({ ...value, expiry: expiry as ExpiryChoice })
}
>
<SelectTrigger id={`${idPrefix}-expires`}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{allowKeepExpiry && (
<SelectItem value="keep">{m.access_expires_keep()}</SelectItem>
)}
<SelectItem value="forever">{m.access_expires_forever()}</SelectItem>
<SelectItem value="1h">{m.access_expires_1h()}</SelectItem>
<SelectItem value="4h">{m.access_expires_4h()}</SelectItem>
<SelectItem value="8h">{m.access_expires_8h()}</SelectItem>
<SelectItem value="custom">{m.access_expires_custom()}</SelectItem>
</SelectContent>
</Select>
{value.expiry === "custom" && (
<div className="space-y-2">
<Label htmlFor={`${idPrefix}-hours`}>
{m.access_expires_custom_hours()}
</Label>
<InputNumber
id={`${idPrefix}-hours`}
min={1}
max={24 * 30}
value={value.customHours}
onChange={(customHours) => onChange({ ...value, customHours })}
/>
</div>
)}
</div>
</div>
);
};

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